Compare commits
210 Commits
b1d7ed2570
...
main
| 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 | |||
| bf6db98f0d | |||
| fc55de19fa | |||
| 6ae3364bd0 | |||
| 5c40b4993f | |||
| fbc0da2a1b | |||
| 750d6c2e18 | |||
| 082246c085 | |||
| 16771b0b33 | |||
| 022634704a | |||
| 96ca7c0484 | |||
| 202366611e | |||
| ea92057e53 | |||
| c71593b286 | |||
| 413ad901fb | |||
| e0e46d8a57 | |||
| fbe29da05b | |||
| 9ffbd651b1 | |||
| aa5fb2cc40 | |||
| 468bc0fba9 | |||
| 571c5f9261 | |||
| cb32fe9b84 | |||
| fbe9804d3e | |||
| f6606accb3 | |||
| 0a007f4941 | |||
| 0c4aee6164 | |||
| a57f4930f0 | |||
| f9ca901a50 | |||
| 3ce69f8951 | |||
| acd1def00d | |||
| 5ec9c7f8bf | |||
| 11c028e6eb | |||
| afadb13de4 | |||
| b6398c44e6 | |||
| a2bd048ace | |||
| 2e97ff1461 | |||
| 7f37b37be3 | |||
| 4e31fb98a2 | |||
| 6cc22e5cc5 |
@@ -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.
|
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
|
## Repository Layout
|
||||||
|
|
||||||
This is a monorepo containing three independent Rust crates as git submodules:
|
This is a monorepo containing three independent Rust crates as git submodules:
|
||||||
|
|||||||
Generated
+44
@@ -114,6 +114,17 @@ version = "2.0.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "aes"
|
||||||
|
version = "0.8.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"cipher",
|
||||||
|
"cpufeatures",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ahash"
|
name = "ahash"
|
||||||
version = "0.8.12"
|
version = "0.8.12"
|
||||||
@@ -810,6 +821,16 @@ dependencies = [
|
|||||||
"windows-link",
|
"windows-link",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cipher"
|
||||||
|
version = "0.4.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
|
||||||
|
dependencies = [
|
||||||
|
"crypto-common",
|
||||||
|
"inout",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "clipboard-win"
|
name = "clipboard-win"
|
||||||
version = "5.4.1"
|
version = "5.4.1"
|
||||||
@@ -2307,6 +2328,15 @@ dependencies = [
|
|||||||
"hashbrown 0.17.1",
|
"hashbrown 0.17.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "inout"
|
||||||
|
version = "0.1.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
|
||||||
|
dependencies = [
|
||||||
|
"generic-array",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ipnet"
|
name = "ipnet"
|
||||||
version = "2.12.1"
|
version = "2.12.1"
|
||||||
@@ -3118,6 +3148,10 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "openfut-autopatch"
|
||||||
|
version = "0.1.0"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openfut-blaze-host"
|
name = "openfut-blaze-host"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -3176,6 +3210,7 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
|
"tempfile",
|
||||||
"thiserror 1.0.69",
|
"thiserror 1.0.69",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tower 0.5.3",
|
"tower 0.5.3",
|
||||||
@@ -3232,11 +3267,20 @@ dependencies = [
|
|||||||
"eframe",
|
"eframe",
|
||||||
"egui",
|
"egui",
|
||||||
"openfut-common",
|
"openfut-common",
|
||||||
|
"parking_lot",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "openfut-lsx"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"aes",
|
||||||
|
"parking_lot",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openfut-protocol-blaze"
|
name = "openfut-protocol-blaze"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ members = [
|
|||||||
"openfut-import-fifa17",
|
"openfut-import-fifa17",
|
||||||
"openfut-bridge",
|
"openfut-bridge",
|
||||||
"openfut-launcher",
|
"openfut-launcher",
|
||||||
|
# The two companion services the launcher used to shell out to Python for.
|
||||||
|
"openfut-lsx",
|
||||||
|
"openfut-autopatch",
|
||||||
"fifa-blaze/crates/blaze-proto",
|
"fifa-blaze/crates/blaze-proto",
|
||||||
"fifa-blaze/crates/server",
|
"fifa-blaze/crates/server",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
# OpenFUT
|
# 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.**
|
**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.
|
OpenFUT replaces EA's retired FUT servers with a fully offline, single-player backend. You own FIFA 23 legitimately. You just want to keep playing after EA shut down the servers.
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
# ============================================================================
|
||||||
|
# ⚠️ LEGACY (FIFA 23 lineage). This compose runs core + bridge for the
|
||||||
|
# superseded FIFA 23 direction. It is NOT the canonical server bring-up.
|
||||||
|
#
|
||||||
|
# Canonical server (FIFA 17):
|
||||||
|
# cd fifa17-recon/docker/fifa17-python && docker compose up -d
|
||||||
|
# (runbook: fifa17-recon/FUT-RUNBOOK.md)
|
||||||
|
#
|
||||||
|
# `core` (openfut-core) IS still the shared, game-independent backend and is
|
||||||
|
# used by the FIFA 17 UTAS host (OPENFUT_CORE_URL). `bridge` (openfut-bridge)
|
||||||
|
# is the retired FIFA 23 integration, kept for reference.
|
||||||
|
# Status source of truth: docs/PROJECT_STATE.md
|
||||||
|
# ============================================================================
|
||||||
|
# OpenFUT server stack — offline FUT backend (Core) + FIFA proxy (Bridge).
|
||||||
|
#
|
||||||
|
# Bring up: docker compose up -d
|
||||||
|
# Tear down: docker compose down (keeps data/captures volumes)
|
||||||
|
# Wipe state: docker compose down -v (also drops volumes)
|
||||||
|
# Rebuild: docker compose build (or ./scripts/registry.sh build)
|
||||||
|
# Logs: docker compose logs -f
|
||||||
|
#
|
||||||
|
# Images are pulled from / pushed to the Gitea container registry. Override the
|
||||||
|
# registry, namespace, or tag in .env (see .env.example). When REGISTRY is set,
|
||||||
|
# `up` pulls prebuilt images; the build: blocks let you rebuild locally too.
|
||||||
|
|
||||||
|
name: openfut
|
||||||
|
|
||||||
|
services:
|
||||||
|
core:
|
||||||
|
image: ${REGISTRY:-git.aleshym.co}/${NAMESPACE:-openfut}/openfut-core:${TAG:-latest}
|
||||||
|
build:
|
||||||
|
context: ./openfut-core
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
LISTEN_ADDR: 0.0.0.0:8080
|
||||||
|
DATABASE_URL: sqlite:///app/db/openfut.db
|
||||||
|
DATA_DIR: /app/data
|
||||||
|
RUST_LOG: ${CORE_LOG:-openfut_core=info,tower_http=info}
|
||||||
|
volumes:
|
||||||
|
- core-db:/app/db
|
||||||
|
# Bound to localhost by default — the bridge reaches core over the internal
|
||||||
|
# network, so core need not be world-exposed. Set CORE_PUBLISH=0.0.0.0 in
|
||||||
|
# .env if you want to hit the REST API directly from other hosts.
|
||||||
|
ports:
|
||||||
|
- "${CORE_PUBLISH:-127.0.0.1}:8080:8080"
|
||||||
|
networks:
|
||||||
|
- openfut
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8080/health"]
|
||||||
|
interval: 15s
|
||||||
|
timeout: 4s
|
||||||
|
retries: 5
|
||||||
|
start_period: 10s
|
||||||
|
|
||||||
|
bridge:
|
||||||
|
image: ${REGISTRY:-git.aleshym.co}/${NAMESPACE:-openfut}/openfut-bridge:${TAG:-latest}
|
||||||
|
build:
|
||||||
|
context: ./openfut-bridge
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
core:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
BRIDGE_LISTEN_ADDR: 0.0.0.0:8443
|
||||||
|
CORE_URL: http://core:8080
|
||||||
|
CAPTURES_DIR: /app/captures
|
||||||
|
PLACEHOLDER_MODE: ${PLACEHOLDER_MODE:-true}
|
||||||
|
TLS_ENABLED: "true"
|
||||||
|
RUST_LOG: ${BRIDGE_LOG:-openfut_bridge=info,tower_http=info}
|
||||||
|
volumes:
|
||||||
|
- bridge-captures:/app/captures
|
||||||
|
# The FIFA client connects here — publish on all interfaces by default so
|
||||||
|
# LAN clients (e.g. 10.10.0.0/24) can reach it.
|
||||||
|
ports:
|
||||||
|
- "${BRIDGE_PUBLISH:-0.0.0.0}:8443:8443"
|
||||||
|
networks:
|
||||||
|
- openfut
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-fsSk", "https://127.0.0.1:8443/_bridge/health"]
|
||||||
|
interval: 15s
|
||||||
|
timeout: 4s
|
||||||
|
retries: 5
|
||||||
|
start_period: 8s
|
||||||
|
|
||||||
|
networks:
|
||||||
|
openfut:
|
||||||
|
driver: bridge
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
core-db:
|
||||||
|
bridge-captures:
|
||||||
@@ -1,142 +0,0 @@
|
|||||||
# FIFA 17 FUT — Card Taxonomy (single source of truth)
|
|
||||||
|
|
||||||
Status: verified 2026-08-12. This document supersedes every earlier scattered
|
|
||||||
taxonomy claim in the repo (see "Superseded taxonomy" at the bottom).
|
|
||||||
|
|
||||||
Evidence labels: **OBSERVED** = read directly from a shipped table or decompiled
|
|
||||||
function; **INFERRED** = derived from OBSERVED facts; **HYPOTHESIS** = plausible,
|
|
||||||
not yet proven. `UNKNOWN` is a valid answer and is stated as such.
|
|
||||||
|
|
||||||
## Provenance
|
|
||||||
|
|
||||||
- Authoritative tables: `10.10.0.105:/home/alex/Documents/OpenFUT/fifa17-recon/data/tables/`,
|
|
||||||
copied byte-identical into this repo at `fifa17-recon/data/tables/`.
|
|
||||||
SHA-256 manifest + verification: `docs/evidence/fifa17-recon/table-hashes.sha256`
|
|
||||||
(36 files: 31 `fcc_*.json` + 5 staff tables; combined hash-of-hashes
|
|
||||||
`10f239add919089354d8dbff873fc9737b0a0f80f6ac41b1aa2a096c0ec8d331`).
|
|
||||||
- Each table file is a DECODED dump `{table, source, rowcount, schema[], rows[]}`;
|
|
||||||
card instances are in `rows[]`, one object per card. Row counts and id ranges
|
|
||||||
below were re-extracted from the in-repo `.120` copies.
|
|
||||||
- Semantic (subtype→kind) labels are decompiler-derived, carried in
|
|
||||||
`fifa17-recon/tools/fut_consumables.py` (`BY_SUBTYPE`, `_DOC`), generated by
|
|
||||||
`tools/build_consumables.py` from `FUN_1800d8330`, `FUN_18013f4d0`,
|
|
||||||
`FUN_1801bfac0`, `FUN_1801aa230`, `FUN_180048780` + the fcc tables.
|
|
||||||
- Staff family selection: `fifa17-recon/docs/CARD_SYSTEM.md` (2026-08-04/05 blocks)
|
|
||||||
and `fifa17-recon/tools/fut_staff.py` / `fut_coaches.py`.
|
|
||||||
|
|
||||||
## Family selector (OBSERVED, binary)
|
|
||||||
|
|
||||||
Before registering an item, `FUN_180141660` merges the client's local DB. It
|
|
||||||
switches on record `+0x4c` (`cardtype`), which `FUN_1800d8330` derives from JSON
|
|
||||||
atom `0x6c cardsubtypeid` alone:
|
|
||||||
|
|
||||||
```
|
|
||||||
cardsubtypeid 0..3 -> cardtype 1 players
|
|
||||||
4 -> cardtype 2 managercards
|
|
||||||
5 -> cardtype 3 headcoachcards
|
|
||||||
6 -> cardtype 10 gkcoachcards
|
|
||||||
7 -> cardtype 5 physiocards
|
|
||||||
8 -> cardtype 4 fitnesscoachcards
|
|
||||||
0x1e,0x1f,0x91..0x96,0xe7..0xe9,0xec -> cardtype 9 club items + misc
|
|
||||||
absent (default 342) -> cardtype 0 no merge
|
|
||||||
```
|
|
||||||
|
|
||||||
Key: `carddbid == resourceId` for non-player families (queried RAW, no mask);
|
|
||||||
players are queried by `playerid = resourceId & 0xffffff` (atom `0x287`), and
|
|
||||||
`assetId` (atom `0x23`) is never read by the merge.
|
|
||||||
|
|
||||||
## Consumable & staff families — evidence table (OBSERVED, `.120` tables)
|
|
||||||
|
|
||||||
| Family | Table | Rows | carddbid range | cardsubtype set | cardassetid (ART) |
|
|
||||||
|---|---|---|---|---|---|
|
|
||||||
| Contracts | `fcc_contractcards.json` | 13 | 5001001–5001013 | {201, 202} | {7, 8} |
|
|
||||||
| Fitness + Healing | `fcc_healingcards.json` | 27 | 5002001–5002030 | {211,212,213,215,216,217,218,219,220} | {9, 10} |
|
|
||||||
| Training (6 sub-families) | `fcc_trainingcards.json` | 143 | 5003001–5003159 | 51-57, 61-67, 91-110, 121-136, 250-273, 300-340 | {1,3,32,34,35,50,51} |
|
|
||||||
| Misc | `fcc_misccards.json` | 42 | 5004001–5004042 | {231, 232, 233, 236} | {43, 44, 45, 46} |
|
|
||||||
| Badges | `fcc_badgecards.json` | 656 | 6000000–6000656 | (none in row) | {39} |
|
|
||||||
| Stadiums | `fcc_stadium.json` | 78 | 6200000–6200077 | (none in row) | {36} |
|
|
||||||
| Kits | `fcc_kitcards.json` | 1482 | 6300000–6400654 | (none in row) | {35} |
|
|
||||||
| League logos | `fcc_leaguelogos.json` | 44 | 8010000–8010044 | (none in row) | {40} |
|
|
||||||
| League logo stickers | `fcc_leaguelogostickers.json` | 39 | 8010000–8010039 | (none in row) | {40} |
|
|
||||||
| Balls | `fcc_balls.json` | 42 | 8120194–8120236 | (none in row) | {37} |
|
|
||||||
| Managers | `managercards.json` | 417 | 1000001–1001552 | (staff; by cardsubtypeid 4) | (assetid col) |
|
|
||||||
| Head coaches | `headcoachcards.json` | 124 | 2000004–2000328 | (staff; subtype 5) | — |
|
|
||||||
| GK coaches | `gkcoachcards.json` | 121 | 9000001–9000324 | (staff; subtype 6) | — |
|
|
||||||
| Physios | `physiocards.json` | 51 | 4000002–4000259 | (staff; subtype 7) | — |
|
|
||||||
| Fitness coaches | `fitnesscoachcards.json` | 115 | 3000019–3000328 | (staff; subtype 8) | — |
|
|
||||||
|
|
||||||
Notes:
|
|
||||||
- **Contracts (5001xxx)** — 201 = player_contract, 202 = manager_contract
|
|
||||||
(OBSERVED, `BY_SUBTYPE`).
|
|
||||||
- **Fitness + Healing (5002xxx)** — subtype **214 is a valid enum value but ships
|
|
||||||
ZERO rows** (OBSERVED: 214 absent from `rows[]`). Enum split (OBSERVED,
|
|
||||||
`BY_SUBTYPE`): healing = 211-218, player_fitness = 219, squad_fitness = 220.
|
|
||||||
The `+0x58 rareflag == 1` trap flips subtype 219 to squad-fitness.
|
|
||||||
- **Training (5003xxx)** — SIX sub-families, all OBSERVED in `rows[]` and labelled
|
|
||||||
in `BY_SUBTYPE`:
|
|
||||||
- `gk_training` 51-57 (7)
|
|
||||||
- `player_training` 61-67 (7)
|
|
||||||
- `position_mod` 91-110 (20)
|
|
||||||
- `formation_mod` 121-136 (16)
|
|
||||||
- chem/play styles 250-273 (24): `player_playstyle` 250-268 (19) +
|
|
||||||
`gk_playstyle` 269-273 (5)
|
|
||||||
- `manager_league` 300-**341** in the client enum (42), but the shipped table
|
|
||||||
contains only 300-340 (41 rows) — 341 is defined, not shipped.
|
|
||||||
- Client enum also defines vestigial/unshipped ranges NOT in the table:
|
|
||||||
`manager_formation_mod` 71-86, and `DEAD_ZONE`
|
|
||||||
58-60,68-70,87-90,111-120,203-210 (28). These have no rows.
|
|
||||||
- **Misc (5004xxx)** — subtypes {231,232,233,236}; cardassetid ART 43-46.
|
|
||||||
cardsubtype 231 = 0xe7 anchors the 0xe7..0xe9 block to misc via `FUN_1800d8330`.
|
|
||||||
- **Club items (badges/stadiums/kits/logos/balls)** carry no `cardsubtype` in the
|
|
||||||
row; they are keyed by `carddbid` range and distinguished on the wire by ART
|
|
||||||
`cardassetid` (badges 39, stadium 36, kits 35, logos 40, balls 37). **Kits live
|
|
||||||
at 6300000–6400654 and are NOT badges** (badges are 6000xxx). The client-side
|
|
||||||
subtype→family map (which of 0x1e,0x1f,0x91..0x96 is ball vs stadium vs badge vs
|
|
||||||
kit) is **UNKNOWN** — it is in none of the dumped tables and cardtype 9 has no
|
|
||||||
miss-fill arm (see `CARD_SYSTEM.md`, "Club items", 2026-08-05).
|
|
||||||
|
|
||||||
## Staff — subtype/cardtype selectors (OBSERVED, binary)
|
|
||||||
|
|
||||||
| cardsubtypeid | cardtype | Family | Table | Rows | carddbid range |
|
|
||||||
|---|---|---|---|---|---|
|
|
||||||
| 4 | 2 | Manager | `managercards.json` | 417 | 1000001–1001552 |
|
|
||||||
| 5 | 3 | Head coach | `headcoachcards.json` | 124 | 2000004–2000328 |
|
|
||||||
| 6 | 10 | GK coach | `gkcoachcards.json` | 121 | 9000001–9000324 |
|
|
||||||
| 7 | 5 | Physio | `physiocards.json` | 51 | 4000002–4000259 |
|
|
||||||
| 8 | 4 | Fitness coach | `fitnesscoachcards.json` | 115 | 3000019–3000328 |
|
|
||||||
|
|
||||||
`cardsubtypeid -> rec+0x50` is the only family selector; `FUN_1800d8330` maps it to
|
|
||||||
`rec+0x4c cardtype`. Manager merge = `FUN_1801356c0` (queries `managercards` by
|
|
||||||
`carddbid` raw); coach merges live in the respective tables (`fut_coaches.py`).
|
|
||||||
All five render live with real photos/bonuses, zero "DB Error" (CARD_SYSTEM.md,
|
|
||||||
2026-08-05).
|
|
||||||
|
|
||||||
## Players (context, out of taxonomy scope here)
|
|
||||||
|
|
||||||
Players use cardsubtypeid 0..3 -> cardtype 1; merged by `playerid = resourceId &
|
|
||||||
0xffffff` against the local `players` table. Identity/name/nation/team come from
|
|
||||||
the client DB; rating/position/attributes come from our item JSON. See
|
|
||||||
`CARD_SYSTEM.md` and the migration work in `openfut-adapter-fifa17`.
|
|
||||||
|
|
||||||
## Superseded taxonomy (report only — no other files edited)
|
|
||||||
|
|
||||||
The earlier working taxonomy (in prior agent-session notes / long-term memory, and
|
|
||||||
partially in the original `TablesTaxonomy` scout output) got four things wrong.
|
|
||||||
Corrected here:
|
|
||||||
|
|
||||||
1. **Chemistry / play styles are 250-273, NOT 91-136.** 91-110 = `position_mod`,
|
|
||||||
121-136 = `formation_mod`. (OBSERVED in `fcc_trainingcards` + `BY_SUBTYPE`.)
|
|
||||||
2. **6300xxx/6400xxx are KITS, not badges.** Badges are 6000xxx. (OBSERVED.)
|
|
||||||
3. **5004xxx misc cards exist** (subtypes 231/232/233/236, ART 43-46). Previously
|
|
||||||
omitted. (OBSERVED, `fcc_misccards`.)
|
|
||||||
4. **8010xxx league logos exist** (+ stickers), ART 40. Previously omitted.
|
|
||||||
(OBSERVED, `fcc_leaguelogos`.)
|
|
||||||
|
|
||||||
**Repo blast-radius of the superseded values: NONE.** A repo-wide grep
|
|
||||||
(`docs/`, `openfut-adapter-fifa17/`, `openfut-core/`, `fifa17-recon/`) for the wrong
|
|
||||||
claims (`91-136` chem styles; `6300/6400xxx` as badges) returns zero hits.
|
|
||||||
`fifa17-recon/docs/CARD_SYSTEM.md` and `plan-2026-08-06-card-subsystem.md` already
|
|
||||||
use the correct `250..273` for chem styles and the correct staff subtypes 4-8;
|
|
||||||
`CARD_SYSTEM.md` is a mechanism log, not a taxonomy, and asserts none of the wrong
|
|
||||||
ranges. The superseded values therefore live only in non-repo agent memory, which
|
|
||||||
should be corrected to point at this document.
|
|
||||||
@@ -1,175 +0,0 @@
|
|||||||
# openfut-core — Correctness Issues (audit 2026-08-17)
|
|
||||||
|
|
||||||
Read-only audit of `openfut-core` (game-agnostic axum + SQLite/sqlx economy authority).
|
|
||||||
Four reported issue classes confirmed with exact `file:line` evidence, **plus a bonus
|
|
||||||
HIGH-severity SBC duplicate-card economy exploit**. Nothing here is fixed yet —
|
|
||||||
fixing bumps Core off the frozen P1 reference (`fbb54ea`, the current known-good
|
|
||||||
production Core) and one item needs a DB migration + prod backfill, so this needs a
|
|
||||||
**go/no-go** before rebuild+redeploy.
|
|
||||||
|
|
||||||
> **Zero live users right now**, so the HIGH-severity economy exploits are not
|
|
||||||
> currently exploitable — but they are the exact class (coin overspend + card
|
|
||||||
> duplication) that crashed live clients earlier (project memory), so they should be
|
|
||||||
> fixed before any real play.
|
|
||||||
|
|
||||||
## Architecture context (why the bugs cluster)
|
|
||||||
|
|
||||||
Two generations of economy code coexist:
|
|
||||||
- **NEW** `services/economy.rs` + `routes/economy.rs` — **fully atomic + validated**:
|
|
||||||
every compound op acquires a connection, `BEGIN IMMEDIATE`, composes
|
|
||||||
transaction-scoped primitives (`debit`/`credit`/`add_item`/`remove_item`/
|
|
||||||
`consume_entitlement`), commits/rolls back via `finish()`, rejects negative amounts,
|
|
||||||
and has an extensive in-module test suite. **This is the reference fix pattern.**
|
|
||||||
- **OLD** per-feature services (`club`, `pack`, `market`, `sbc`, `checkin`, `upgrades`,
|
|
||||||
`match_service`, `season`) — predate it, still do read/check/write directly against
|
|
||||||
the `&Pool` with each statement on its own connection: no transaction. `economy.rs`'s
|
|
||||||
own module doc explicitly warns these "cannot offer that guarantee".
|
|
||||||
|
|
||||||
Issues 2 and 3 live entirely in the OLD generation; the fix is to route them through
|
|
||||||
`economy.rs`'s proven atomic ops (or give the pool helpers `&mut SqliteConnection`
|
|
||||||
transactional variants). Request flow is `X-OpenFUT-Game` header → active profile →
|
|
||||||
club → service; clients never pass a `club_id`, so cross-club access is **not** a
|
|
||||||
vector — the risks are intra-club concurrency + unvalidated payloads.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Issue 1 — `sbc_submissions` missing `club_id` (milestone always 0) · **LOW**
|
|
||||||
|
|
||||||
- **Root cause:** `migrations/0001_initial.sql:96-102` creates `sbc_submissions(id,
|
|
||||||
profile_id, sbc_id, submitted_card_ids, passed, submitted_at)` — no `club_id`, and no
|
|
||||||
later migration adds one. But `src/routes/club.rs:94-99` (`get_milestones`) runs
|
|
||||||
`SELECT COUNT(*) FROM sbc_submissions WHERE club_id = ? AND passed = 1`. SQLite errors
|
|
||||||
`no such column: club_id`; the error is swallowed by `.unwrap_or(0)` → the
|
|
||||||
`sbcs_completed` milestone is **always 0**. Writer `services/sbc.rs:74-83` inserts
|
|
||||||
`profile_id`, not `club_id`.
|
|
||||||
- **Impact:** wrong milestone stat only. No crash, no economy corruption.
|
|
||||||
- **Fix (matches the ticket — needs migration + backfill):** new migration `0019`:
|
|
||||||
`ALTER TABLE sbc_submissions ADD COLUMN club_id TEXT;` then backfill
|
|
||||||
`UPDATE sbc_submissions SET club_id = (SELECT c.id FROM clubs c WHERE c.profile_id =
|
|
||||||
sbc_submissions.profile_id);` and bind `club_id` in `sbc.rs:submit_sbc`'s INSERT
|
|
||||||
(`club_id` is already a param at `sbc.rs:41`).
|
|
||||||
- **Simpler alternative (no migration):** change the `club.rs:95` query to
|
|
||||||
`WHERE profile_id = ?` (column already exists). Ticket asks for the column, so both
|
|
||||||
are recorded.
|
|
||||||
- **Migration required:** YES (for the ticket's fix); NO (for the alternative).
|
|
||||||
|
|
||||||
## Issue 2 — Non-atomic check-then-act economy mutations (TOCTOU) · **HIGH**
|
|
||||||
|
|
||||||
Each does read → check → write across multiple pool round-trips with no
|
|
||||||
`BEGIN IMMEDIATE`, so concurrent requests race → overspend / duplication / double reward:
|
|
||||||
|
|
||||||
| Site | Race |
|
|
||||||
|---|---|
|
|
||||||
| `services/club.rs:88-115` `spend_coins` | SELECT coins → `if balance<amount` → UPDATE; two concurrent spends both pass → **overspend / negative balance**. Shared primitive used by all callers below. |
|
|
||||||
| `services/pack.rs:58-141` `open_pack` | read `pack.opened` → INSERT cards loop → UPDATE opened=1; concurrent double-open → **card duplication** |
|
|
||||||
| `services/pack.rs:144-160` `buy_pack` | `spend_coins` then `grant_pack`, separate ops → crash between = coins gone, no pack |
|
|
||||||
| `services/market.rs:129-186` `buy_listing` | SELECT sold=0 → spend → UPDATE sold=1 → INSERT; concurrent double-buy → **two cards minted** |
|
|
||||||
| `services/market.rs:188+` `sell_card` | SELECT owned → DELETE → add_coins; concurrent double-sell → **double credit** |
|
|
||||||
| `services/sbc.rs:35-107` `submit_sbc` | validate → DELETE cards → INSERT submission → add_coins/grant_pack; same cards to two SBCs → **double reward** |
|
|
||||||
| `services/checkin.rs:60-120` `claim` | SELECT last → same-day check → reward → INSERT; concurrent → **double claim** |
|
|
||||||
| `services/upgrades.rs:64-95` `change_position` | fetch → spend_coins → UPDATE |
|
|
||||||
| `services/match_service.rs:97-235` + `season.rs` reward | many sequential writes; partial failure leaves partial rewards |
|
|
||||||
|
|
||||||
- **Impact:** corrupts economy state (coin overspend + card duplication).
|
|
||||||
- **Fix:** wrap each compound op in one `BEGIN IMMEDIATE`…`finish()` transaction
|
|
||||||
exactly as `services/economy.rs:214-236` already does; route pack/market/sbc/checkin/
|
|
||||||
upgrades through `economy.rs`'s composed atomic ops (or add `&mut SqliteConnection`
|
|
||||||
variants of `spend_coins`/`add_coins`/`grant_pack`/`add_item`).
|
|
||||||
- **Migration required:** NO (code-only).
|
|
||||||
|
|
||||||
## Issue 3 — Missing input validation: SBC duplicate-card exploit · **HIGH**
|
|
||||||
|
|
||||||
- **Root cause:** `services/sbc.rs:53-70` iterates `req.owned_card_ids` with **no dedup
|
|
||||||
and no length bound**. The same `owned_card_id` repeated N times resolves the same card
|
|
||||||
N times (each `fetch_optional` succeeds); `validate_sbc` (`sbc.rs:110-116`) counts it
|
|
||||||
toward `squad_size` and passes; the DELETE loop deletes it once → **a user satisfies
|
|
||||||
any SBC with ONE card duplicated → free rewards**. Entry point `routes/sbc.rs:30-49`
|
|
||||||
forwards `req` unvalidated. Unbounded Vec length is also a DoS.
|
|
||||||
- **Fix:** in `submit_sbc`, reject duplicate ids (collect into a `HashSet`, compare
|
|
||||||
`len`) and bound the list (e.g. ≤ 30) before resolving → `AppError::BadRequest`.
|
|
||||||
- **Already-good validation (no change):** `economy.rs:64-67,84-87` reject negative
|
|
||||||
amounts; `match_service.rs:100-104` clamps goals 0..99; `upgrades.rs` validates
|
|
||||||
boost 1..3 / positions. Minor: `routes/economy.rs post_grant_reward` forwards an
|
|
||||||
unbounded `amount` (trusted host caller; add an upper-bound sanity guard).
|
|
||||||
- **Migration required:** NO.
|
|
||||||
|
|
||||||
## Issue 4 — `season.rs` panics + checkin index panic · **LOW**
|
|
||||||
|
|
||||||
- **Root cause:** `services/season.rs` `.expect()` on `fetch_optional` Options at
|
|
||||||
`:23` (`get_or_create`), `:69` and `:144` (`record_match`) — panic if the `seasons`
|
|
||||||
row is absent when expected. `seasons.profile_id` is PRIMARY KEY
|
|
||||||
(`migrations/0006_seasons_loans_packs.sql:2`), so the concurrent-insert case surfaces
|
|
||||||
as a UNIQUE error via `?` (not the panic), lowering probability — but it still
|
|
||||||
panics-on-invariant, aborting that request (axum → 500 for the request; not a full
|
|
||||||
server crash).
|
|
||||||
- **Secondary:** `services/checkin.rs:~52,~88` index `STREAK_COINS[idx]` with
|
|
||||||
`idx = ((streak-1)%7) as usize`; Rust `%` can be negative → a corrupt/negative
|
|
||||||
persisted `streak_day` yields a negative index → **panic**.
|
|
||||||
- **Fix:** replace each `.expect(...)` with
|
|
||||||
`.ok_or_else(|| AppError::Internal("season row missing".into()))?`; guard the checkin
|
|
||||||
index with `.rem_euclid(7)` (or clamp `streak_day >= 1` on read).
|
|
||||||
- **Migration required:** NO.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Fix plan summary
|
|
||||||
|
|
||||||
| Issue | Severity | Migration | Files |
|
|
||||||
|---|---|---|---|
|
|
||||||
| 1 sbc_submissions club_id | LOW | YES (or none via alt) | `migrations/0001` (+new 0019), `routes/club.rs`, `services/sbc.rs` |
|
|
||||||
| 2 non-atomic mutations | HIGH | NO | `services/{club,pack,market,sbc,checkin,upgrades,match_service,season}.rs` → route through `services/economy.rs` |
|
|
||||||
| 3 SBC duplicate-card exploit | HIGH | NO | `services/sbc.rs`, `routes/sbc.rs` |
|
|
||||||
| 4 season/checkin panics | LOW | NO | `services/season.rs`, `services/checkin.rs` |
|
|
||||||
|
|
||||||
**Recommended order:** 3 (smallest, highest-value: kills the free-reward exploit) → 2
|
|
||||||
(the transactional refactor, largest) → 4 (defensive hygiene) → 1 (cosmetic; do with
|
|
||||||
the alt query unless the column is wanted).
|
|
||||||
|
|
||||||
**Deployment note:** all of these change `openfut-core`, which is currently frozen at
|
|
||||||
the P1 reference (`fbb54ea`) in production. Fixing + rebuilding + redeploying prod-core
|
|
||||||
is a deliberate step off that reference — get a go/no-go first. Only Issue 1's
|
|
||||||
column-add needs a migration + prod backfill; 2/3/4 are code-only.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Resolution (2026-08-17, on `openfut-core` @ `fbb54ea` + these edits)
|
|
||||||
|
|
||||||
All four issue classes fixed in the canonical superproject submodule
|
|
||||||
`openfut-core`; full test suite green (179 tests) + clippy clean + a new
|
|
||||||
regression test `tests/integration_test.rs::test_sbc_rejects_duplicate_cards`.
|
|
||||||
|
|
||||||
| Issue | Fix | Files |
|
|
||||||
|---|---|---|
|
|
||||||
| 3 SBC dup-card exploit | dedup (`HashSet`) + `MAX_SBC_CARDS`=30 bound in `submit_sbc`, before card resolution → `BadRequest` | `services/sbc.rs` |
|
|
||||||
| 1 sbc_submissions club_id | migration `0019` adds `club_id` + backfills from `clubs`; `submit_sbc` INSERT now binds `club_id` | `migrations/0019_*.sql`, `services/sbc.rs` |
|
|
||||||
| 4 season/checkin panics | `.expect()` → `.ok_or_else(AppError::Internal)?` (3 sites); checkin index `% 7` → `.rem_euclid(7)` (2 sites) | `services/season.rs`, `services/checkin.rs` |
|
|
||||||
| 2 non-atomic mutations | statement-level compare-and-swap (see below) | `services/{club,pack,market,checkin}.rs` |
|
|
||||||
|
|
||||||
### Issue 2 — how it was fixed, and the residual
|
|
||||||
|
|
||||||
Rather than the full transaction refactor (threading `&mut SqliteConnection`
|
|
||||||
through every service), the concurrency-exploitable races were closed with
|
|
||||||
single-statement **compare-and-swap** — the atomic unit SQLite already gives us,
|
|
||||||
no transaction plumbing, minimal blast radius on the working prod economy path:
|
|
||||||
|
|
||||||
- `club::spend_coins` — `UPDATE … SET coins = coins - ? WHERE id = ? AND coins >= ?`
|
|
||||||
+ `rows_affected` guard; also rejects negative amounts. Kills **overspend** for
|
|
||||||
every caller (the shared root primitive).
|
|
||||||
- `pack::open_pack` — claims the pack (`UPDATE … opened = 1 WHERE … AND opened = 0`)
|
|
||||||
**before** minting cards; loser aborts. Kills **card duplication** via double-open.
|
|
||||||
- `market::buy_listing` — claims the listing (`sold 0→1`) before charging; releases
|
|
||||||
the claim if the debit fails. Kills **double-mint**.
|
|
||||||
- `market::sell_card` — `DELETE … WHERE id = ? AND club_id = ?` + `rows_affected`
|
|
||||||
guard before crediting. Kills **double-credit** via double-sell.
|
|
||||||
- `checkin::claim` — conditional `INSERT … SELECT … WHERE NOT EXISTS (today's row)`
|
|
||||||
+ `rows_affected` guard; pays out only if the claim landed. Kills **double-claim**.
|
|
||||||
|
|
||||||
**Residual (accepted, documented):** the *multi-statement all-or-nothing* edges that
|
|
||||||
need a real transaction to close — `pack::buy_pack` (spend then grant: a crash between
|
|
||||||
loses coins with no pack), `sbc::submit_sbc` (concurrent submits sharing cards could
|
|
||||||
double-consume mid-loop), and `match_service`/`season` reward chains (partial writes on
|
|
||||||
crash). These are **partial-failure durability edges, not statement-level races**, and
|
|
||||||
require concurrency that a single-player FIFA17 client does not generate. Closing them
|
|
||||||
is the `&mut SqliteConnection` transaction refactor originally proposed; deferred as
|
|
||||||
low-value for single-player. Overspend + duplication + double-credit — the vectors that
|
|
||||||
corrupt economy state — are all closed.
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
# FIFA17 Content Completeness (33-record gap)
|
|
||||||
|
|
||||||
Evidence: ContentGapMap scout vs import-input.json (persona 33068179, 1995 items),
|
|
||||||
fifa17-recon/data/tables, manifest/fifa17-import-manifest.json (OBSERVED).
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
| Category | Expected | Resolvable | Unrecoverable |
|
|
||||||
|---|---|---|---|
|
|
||||||
| Legend players | 13 instances (10 defs) | 0 | 13 (need .105 Legends locale names) |
|
|
||||||
| Consumables | 17 | 17 | 0 |
|
|
||||||
| Staff | 3 | 3 | 0 |
|
|
||||||
|
|
||||||
## Consumables (17/17 RESOLVABLE — carddbid present in fcc tables; semantics from cardsubtypeid)
|
|
||||||
| wire_id | resourceId | subtype | kind | table |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| 100000239 | 5003012 | 54 | gk_training | fcc_trainingcards |
|
|
||||||
| 100000249 | 5003011 | 54 | gk_training | fcc_trainingcards |
|
|
||||||
| 100000260 | 5003004 | 52 | gk_training | fcc_trainingcards |
|
|
||||||
| 100000272 | 5003059 | 91 | position/playstyle mod | fcc_trainingcards |
|
|
||||||
| 100000250 | 5003060 | 92 | position/playstyle mod | fcc_trainingcards |
|
|
||||||
| 100000327 | 5003065 | 97 | position/playstyle mod | fcc_trainingcards |
|
|
||||||
| 100000238 | 5003066 | 98 | position/playstyle mod | fcc_trainingcards |
|
|
||||||
| 100000261 | 5003068 | 100 | position/playstyle mod | fcc_trainingcards |
|
|
||||||
| 100000316 | 5003068 | 100 | position/playstyle mod | fcc_trainingcards |
|
|
||||||
| 100000293 | 5003103 | 258 | player_playstyle | fcc_trainingcards |
|
|
||||||
| 100000326 | 5003112 | 267 | player_playstyle | fcc_trainingcards |
|
|
||||||
| 100000283 | 5003116 | 271 | gk_playstyle | fcc_trainingcards |
|
|
||||||
| 100000294 | 5001004 | 201 | player_contract | fcc_contractcards |
|
|
||||||
| 100000304 | 5001008 | 202 | manager_contract | fcc_contractcards |
|
|
||||||
| 100000305 | 5001009 | 202 | manager_contract | fcc_contractcards |
|
|
||||||
| 100000315 | 5002027 | 217 | healing | fcc_healingcards |
|
|
||||||
| 100000426 | 5002013 | 213 | healing | fcc_healingcards |
|
|
||||||
|
|
||||||
## Staff (3/3 RESOLVABLE)
|
|
||||||
| wire_id | resourceId | subtype | role | table |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| 100000271 | 3000083 | 8 | fitnesscoach | fitnesscoachcards |
|
|
||||||
| 100000427 | 3000083 | 8 | fitnesscoach | fitnesscoachcards |
|
|
||||||
| 100000282 | 9000081 | 6 | gkcoach | gkcoachcards |
|
|
||||||
|
|
||||||
## Legends (13 instances / 10 defs — UNRECOVERABLE from .120)
|
|
||||||
All defer via NoName gate (openfut-import-fifa17/src/lib.rs:476-478). 6 assets absent
|
|
||||||
from players.json; 236250/236253/236257 resolve only to placeholder nameid 24313='171918'.
|
|
||||||
dcplayernames.json/editedplayernames.json EMPTY in .120 dump. Version formula holds for all
|
|
||||||
(resourceId == (version<<24)|assetId) — so they auto-promote the instant a .105-derived
|
|
||||||
name row exists; NO code change needed, only name data.
|
|
||||||
|
|
||||||
| asset | resourceId(ver) | rareflag/rating/pos | wire copies |
|
|
||||||
|---|---|---|---|
|
|
||||||
| 169193 | 169193(v0) | 1/r87/CDM | 100000057,100000083,100000146,100000157 |
|
|
||||||
| 211029 | 211029(v0) | 1/r73/CB | 100000165 |
|
|
||||||
| 224512 | 224512(v0) | 1/r67/LB | 100000170 |
|
|
||||||
| 227403 | 227403(v0) | 1/r64/ST | 100000160 |
|
|
||||||
| 236743 | 236743(v0) | 1/r60/LB | 100000163 |
|
|
||||||
| 237510 | 237510(v0) | 1/r59/CB | 100000162 |
|
|
||||||
| 236250 | 17013466(v1) | 3/r78/ST | 100001102 |
|
|
||||||
| 236253 | 17013469(v1) | 3/r77/CAM | 100001932 |
|
|
||||||
| 236253 | 100899549(v6) | 21/r77/CAM | 100001472 |
|
|
||||||
| 236257 | 117676769(v7) | 22/r78/LM | 100001043 |
|
|
||||||
|
|
||||||
### .105 read-only verdict (PROVEN 2026-08-14) — UNRECOVERABLE, confirmed
|
|
||||||
SSH read-only to the FIFA machine (10.10.0.105). Install `/mnt/games/FIFA 17`
|
|
||||||
is retail PC (FIFA17.exe, build 2017-06-09, changelist 3175939, sku FFA17PCC).
|
|
||||||
The resident FIFA17.exe database (recon `data/tables`) shows the DLC/Legend name
|
|
||||||
tables ship ZERO rows in the retail PC build:
|
|
||||||
- `dcplayernames.json`: rowcount:0, rows_emitted:0 (nameid range 30000-35000 = DLC names)
|
|
||||||
- `editedplayernames.json`: rowcount:0, rows_emitted:0
|
|
||||||
- `playernames.json`: 24314 rows (standard players only)
|
|
||||||
FUT Legends were Xbox-One-exclusive in FIFA 17; the PC client contains no Legend
|
|
||||||
names at all. The 13 Legend instances are therefore GENUINELY UNRECOVERABLE from
|
|
||||||
any PC install (.105 == .120, same retail build) — NOT a filtering artifact and
|
|
||||||
NOT fabricated. Architecture is already correct: they auto-promote the instant a
|
|
||||||
name row is supplied (e.g. from an Xbox FIFA17 DB), with no importer code change.
|
|
||||||
## Implementation
|
|
||||||
- Consumables+staff: NEW emit path in openfut-import-fifa17 (classify already buckets;
|
|
||||||
plan_definitions only ingests PlayerCard). Add definition builders + Core CardDefinition
|
|
||||||
rows to fifa17-production-cards.json + owned instances in apply.rs. Data 100% present.
|
|
||||||
- Legends: attempt read-only .105 Legends locale research; if names recoverable add roster
|
|
||||||
rows (auto-promote). Otherwise document as unrecoverable (do NOT fabricate names).
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,198 +0,0 @@
|
|||||||
# FIFA 17 Transfer Market — wire findings
|
|
||||||
|
|
||||||
Reverse-engineering record for the FIFA 17 UTAS transfer-market surface, kept so
|
|
||||||
future agents do not reopen settled questions or re-guess enum spellings.
|
|
||||||
|
|
||||||
Every claim carries a confidence tag:
|
|
||||||
|
|
||||||
| Tag | Meaning |
|
|
||||||
|---|---|
|
|
||||||
| **CONFIRMED** | Observed from our own FIFA17.exe client or live host capture |
|
|
||||||
| **FIFA17-HISTORICAL** | Supported by contemporaneous FIFA 17 implementations (`lorenzh/fut-api`, `futapi/fut` v0.2.18 — the last pre-FIFA-18 release) |
|
|
||||||
| **INFERRED** | Best explanation, not directly captured |
|
|
||||||
| **UNKNOWN** | Requires instrumentation; do NOT implement from guesswork |
|
|
||||||
|
|
||||||
Authority reminder: FIFA 17 field names, enum spellings, sentinel ids and
|
|
||||||
empty-state shapes come from captures or the Python oracle — never from a modern
|
|
||||||
FUT toolkit. Later-FIFA API drift is a known hazard, and reversing a container
|
|
||||||
type or inventing an enum is the documented client-freeze class.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## The auction record (`auctionInfo[]`)
|
|
||||||
|
|
||||||
What we emit today, on `/tradePile`, `/trade/status` and market browse:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"tradeId": 1000000097,
|
|
||||||
"itemData": { "...full shaped card...": "", "itemState": "listFS" },
|
|
||||||
"tradeState": "active",
|
|
||||||
"buyNowPrice": 15000,
|
|
||||||
"startingBid": 150,
|
|
||||||
"currentBid": 0,
|
|
||||||
"offers": 0,
|
|
||||||
"bidState": "none",
|
|
||||||
"expires": 3600,
|
|
||||||
"tradeOwner": true,
|
|
||||||
"sellerId": 33068179,
|
|
||||||
"sellerName": "CAGE",
|
|
||||||
"sellerEstablished": 1,
|
|
||||||
"watched": false,
|
|
||||||
"coinsProcessed": 0
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
| Field | Confidence | Note |
|
|
||||||
|---|---|---|
|
|
||||||
| `tradeOwner` (bool) | **FIFA17-HISTORICAL** | Exists in FIFA 17 auctionInfo. That it is *the* Actions-panel gate is **UNKNOWN** pending live confirmation. |
|
|
||||||
| `sellerId` | **FIFA17-HISTORICAL** exists; type numeric is **INFERRED** | Set to the configured persona so it agrees with `tradeOwner`. Never baked in. |
|
|
||||||
| `sellerName` | **CONFIRMED** it must be the player | `fut_account.py` annotates the persona property as "Blaze PDTL.DSNM / LSX GetProfileResponse Persona / **UTAS sellerName**". EA's `"EASFC"` here is wrong for an own listing. |
|
|
||||||
| `offers` | **FIFA17-HISTORICAL** | `0` valid for active/unbid. |
|
|
||||||
| `bidState: "none"` | **FIFA17-HISTORICAL** | Valid for active/unbid. Other observed concepts: `highest`, `buyNow`. Do NOT "fix" this. |
|
|
||||||
| `expires` | **FIFA17-HISTORICAL** | **SECONDS REMAINING, not an epoch.** Historical durations: 3600, 10800, 21600, 43200, 86400, 259200. |
|
|
||||||
| `itemData.itemState: "listFS"` | **UNKNOWN** | Plausible and unchanged. Public FIFA 17 material gives no trustworthy enumeration. Do not guess replacements — capture. |
|
|
||||||
| `itemData.untradeable` | **FIFA17-HISTORICAL** field; our blanket `false` is **INFERRED** | See "Known debt" below. |
|
|
||||||
| `marketDataMinPrice` / `marketDataMaxPrice` | **do NOT add** | These entered the public parser only after its FIFA 18 migration. |
|
|
||||||
|
|
||||||
### Why the differential could not catch the missing fields
|
|
||||||
|
|
||||||
Our record's key set was **identical to the Python oracle's**, so field-for-field
|
|
||||||
parity was green. The oracle omits `tradeOwner` / `sellerId` / `offers` as well,
|
|
||||||
because *its* remove flow was never driven by a real client either — the only
|
|
||||||
historical live datapoint is a counts-tile bug. Oracle parity is therefore
|
|
||||||
**necessary but not sufficient** for any flow the oracle never actually served.
|
|
||||||
|
|
||||||
The differential now asserts we cover every oracle key AND that our extra keys are
|
|
||||||
exactly `{offers, sellerId, tradeOwner}`, so the deliberate superset is pinned
|
|
||||||
while a new unexplained divergence still fails.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Routes
|
|
||||||
|
|
||||||
| Route | Confidence | Behaviour |
|
|
||||||
|---|---|---|
|
|
||||||
| `GET …/trade/status` | **CONFIRMED** the client polls it continuously | Live auction-state refresh. It previously fell through `starts_with("trade")` into the buy/view arm, where the tail has no numeric id, so **every poll returned `{"auctionInfo": []}`**. Now a real handler: optional `tradeIds` filter, else the whole active pile. Unknown ids are absent, never an error. |
|
|
||||||
| `DELETE /ut/game/<sku>/trade/<id>` | **FIFA17-HISTORICAL** | The spelling contemporaneous FIFA 17 clients use, no body, no meaningful response body. Previously landed in the buy/view arm and **silently cancelled nothing while returning 200.** Now maps to MarketCancel. |
|
|
||||||
| `DELETE /ut/delete/game/<sku>/trade/<id>` | **CONFIRMED** (oracle) | The oracle's spelling; retained because the differential exercises it. Whether FIFA17.exe ever uses it is **UNKNOWN**. |
|
|
||||||
| `POST …/auctionhouse` | **CONFIRMED** | List for sale. The client sends only `itemData.id`; the server resolves wire id → Core instance → `card_id`/`resourceId` and enforces ownership. |
|
|
||||||
| `GET …/tradePile/counts` | **INFERRED** | Five scalar ints (`count`, `maxAuctionsAllowed`, `offered`, `selling`, `sold`); a DISTINCT deserializer from `/tradePile`. Exact FIFA 17 semantics of `count` (active auctions vs whole pile) is **UNKNOWN** — we report active auctions and deliberately did NOT speculate. |
|
|
||||||
| `PUT …/item` (move) | **FIFA17-HISTORICAL** | `{"itemData":[{"pile":"trade"|"club","id":ID}]}` → `{"itemData":[{id,pile,success}]}`. Transfer-List membership is a **separate operation from creating an auction**. |
|
|
||||||
|
|
||||||
### Pile encoding
|
|
||||||
|
|
||||||
* MOVE commands take a **string** pile (`"trade"`, `"club"`) — **FIFA17-HISTORICAL**.
|
|
||||||
* Returned `itemData.pile` is documented **numeric** in FIFA 17 auction data — **FIFA17-HISTORICAL**.
|
|
||||||
* The numeric mapping is **UNKNOWN**. Do not unify the two representations, and do
|
|
||||||
not derive a mapping from unrelated `pileSize` keys.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Q2 — Transfer List item that is not currently auctioned
|
|
||||||
|
|
||||||
A real FUT state: an item in the Transfer List with no active auction (freshly
|
|
||||||
moved, or expired unsold). **CONFIRMED** to exist as a concept (the external hub
|
|
||||||
spec §27, and move-vs-list being separate operations).
|
|
||||||
|
|
||||||
Its wire representation is **UNKNOWN**: `tradeId` 0 / omitted / null, `tradeState`
|
|
||||||
value or omission, and `itemData.itemState` are all unestablished.
|
|
||||||
|
|
||||||
Consequence, and the reason this matters: our `/tradePile` renders only `active`
|
|
||||||
listings, so keying the `/club` exclusion on the `trade` **pile** stranded 4 cards
|
|
||||||
in no screen at all (hidden from the club, absent from the Transfer List).
|
|
||||||
Commit `f2c4927` keys exclusion on the **active listing** instead, which is
|
|
||||||
self-healing. That is a workaround, not fidelity — the faithful model needs the
|
|
||||||
unlisted state represented.
|
|
||||||
|
|
||||||
**Required capture** (four states, full structural diff, not just a shortlist):
|
|
||||||
|
|
||||||
```
|
|
||||||
A. moved Club -> Transfer List, NEVER listed
|
|
||||||
B. actively listed
|
|
||||||
C. listing expired unsold
|
|
||||||
D. listing sold
|
|
||||||
```
|
|
||||||
|
|
||||||
Diff at least: `tradeId`, `tradeOwner`, `tradeState`, `bidState`, `expires`,
|
|
||||||
`offers`, `currentBid`, `startingBid`, `buyNowPrice`, `sellerId`, `sellerName`,
|
|
||||||
`itemData.id`, `itemData.itemState`, `itemData.pile`, `itemData.untradeable`.
|
|
||||||
|
|
||||||
Do NOT drop the unlisted state from the model just because its encoding is unknown.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Deferred, with reasons
|
|
||||||
|
|
||||||
* **5% transfer tax** — **INFERRED** architecture only: auction closes → Core
|
|
||||||
settles → seller credited gross × 0.95, with `auctionInfo` continuing to carry
|
|
||||||
gross. No trustworthy FIFA 17 field named `tax`/`netPrice`/`sellerProceeds` was
|
|
||||||
recovered, and no separate settle operation. Not blocking; do not couple
|
|
||||||
settlement to clearing the sold auction without a capture.
|
|
||||||
* **Bid / Transfer Targets** — not implemented. Watched / active bid / winning /
|
|
||||||
outbid / won / expired are distinct states and must not collapse to a flat list.
|
|
||||||
* **Unassigned** — FIFA 17 had a dedicated Unassigned service; the exact FIFA 17
|
|
||||||
URL is **UNKNOWN**. Our 29-item `purchased` pile is this state and is currently
|
|
||||||
rendered inside `/club`. Do not manufacture a route from a modern toolkit.
|
|
||||||
* **Match CREATE / READY / PLAY** — **UNKNOWN** and explicitly not portable from
|
|
||||||
public FUT web-app work (the web app could not start matches). Instrument the
|
|
||||||
real client from Play Match to kickoff before implementing.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Known debt
|
|
||||||
|
|
||||||
`shape_item` reports `untradeable: false` for **every** owned instance. Correct
|
|
||||||
today (Core models no untradeable items) and necessary — a hardcoded `true` greyed
|
|
||||||
out both list buttons — but it will misrepresent SBC / promo / loan rewards once
|
|
||||||
those exist. `untradeable` belongs on the owned-item instance as authoritative
|
|
||||||
state, not inferred from definition, resourceId or rarity.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## MEASURED in the live client — 2026-08-17
|
|
||||||
|
|
||||||
Read out of the running `FIFA17.exe` (pid-resolved, CardsDLL slide proven against
|
|
||||||
the on-disk FNV prologue) with `fifa17-recon/tools/trade_gate_probe.py`, which
|
|
||||||
extends `gate_byte_probe.py` to vtable slot `+0x270` as the transfer-market
|
|
||||||
analysis asked for. Read-only: `/proc/<pid>/mem` `O_RDONLY` + `pread`.
|
|
||||||
|
|
||||||
| Gate | Python era (2026-08-06) | Now | Owner |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `IS_TRADING_ENABLED` `model+0x1fd2e` (slot `+0x270`) | **0** | **1** | settings struct `+0x28`; was zeroed by `userInfo.feature.trade` |
|
|
||||||
| `TRADE_PILE_SIZE` `model+0x1fd1c` | **0** | **100** | `userMassInfo.pileSizeClientData` key 2 |
|
|
||||||
| watch-list size `model+0x1fd20` | **0** | **50** | same member, key 4 |
|
|
||||||
| `storeEnabled` `model+0x1fd2f` | 1 | 1 | control |
|
|
||||||
| `IS_FRIENDLY_SEASON` / `IS_DRAFT_MODE` / `packOpeningAnimation` | 1 | 1 | controls |
|
|
||||||
|
|
||||||
**CONFIRMED: every CardsDLL-supplied input the transfer-market analysis named as a
|
|
||||||
blocker is now open.** The Rust host does this by construction — it emits
|
|
||||||
`userInfo.feature` as `{}` (no `trade` member, so the kill switch at `0x180174f19`
|
|
||||||
never arms: it fires only when atom `0x330` inside `0x11c` parses as exactly 1) and
|
|
||||||
it already sends `pileSizeClientData` keys 2 and 4. Serving `tradingEnabled: 1` in
|
|
||||||
the settings `configs` array would NOT have worked, because that tail runs after
|
|
||||||
every member is parsed and would overwrite it.
|
|
||||||
|
|
||||||
### What this rules out
|
|
||||||
|
|
||||||
The Transfer List Actions panel not opening on an own listing is therefore **not**:
|
|
||||||
|
|
||||||
* an ownership field — FIFA 17's auctionInfo has no `tradeOwner`/`sellerId` atom;
|
|
||||||
* `IS_TRADING_ENABLED`, `TRADE_PILE_SIZE` or the watch-list size — all measured open;
|
|
||||||
* the cancel route — `DELETE ut/delete/{ns}/trade/{tradeId}` is the PE's spelling and
|
|
||||||
is what we serve;
|
|
||||||
* `tradeState` / `bidState` / `expires` spellings — all three are the PE's own
|
|
||||||
vocabularies and values.
|
|
||||||
|
|
||||||
Per the analysis's own falsifier ("if the byte reads 1 and the screen still refuses,
|
|
||||||
the exe-side predicate has a term we have not enumerated"), the remaining term is
|
|
||||||
**exe-side UI script**, which CardsDLL does not own and the server cannot set.
|
|
||||||
Status: **UNKNOWN**, and it is now the narrowest it has ever been.
|
|
||||||
|
|
||||||
### Confirmed fidelity bug found on the way
|
|
||||||
|
|
||||||
`expires` was a frozen `3600` on every poll, so the client's live countdown never
|
|
||||||
moved and an auction could never run out. Now derived from `created_at + duration`
|
|
||||||
(duration taken from the `ISStart` body), clamped at 0, with an aged-out active
|
|
||||||
listing projecting as `expired`/`none` — FIFA 17's relistable state. Verified live:
|
|
||||||
the standing listing correctly reads `expires: 0` once past its hour.
|
|
||||||
@@ -1,208 +0,0 @@
|
|||||||
# FIFA 17 FUT Match Lifecycle
|
|
||||||
|
|
||||||
Design + contract reference for the **FUT match loop** as OpenFUT implements it on
|
|
||||||
the backend/responder side. Consolidates knowledge previously scattered across
|
|
||||||
`docs/PROJECT_STATE.md`, `fifa17-recon/tools/utas_server.py` (`match_route`),
|
|
||||||
`openfut-utas-host` (Rust economy END leg), `fifa17-recon/tools/test_match_lifecycle.py`,
|
|
||||||
and the vault (`Protocol Findings.md`, `Project State.md`, `Known Issues.md`).
|
|
||||||
|
|
||||||
Evidence labels: **OBSERVED** (live), **PROVEN** (test/static-analysis),
|
|
||||||
**HYPOTHESIS** (reasoned, not yet live-confirmed).
|
|
||||||
|
|
||||||
> ## Status caveat (read first)
|
|
||||||
> **No football match has ever started or completed in FIFA against this stack.**
|
|
||||||
> The lifecycle below is validated by CardsDLL static analysis (RPC descriptor
|
|
||||||
> blocks) + isolated persistence replay (`test_match_lifecycle.py`), **not** in-game
|
|
||||||
> acceptance. The reward amounts are FUT-plausible env-tunable defaults, **not**
|
|
||||||
> reversed values. Two blockers keep `FUT_MODES` **off by default** (see
|
|
||||||
> [Open questions](#open-questions--blockers)).
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
|
|
||||||
This documents the **UTAS responder handshake + economy reward** for a match — the
|
|
||||||
HTTP calls CardsDLL makes around a match and the state they mutate. It does **not**
|
|
||||||
cover the actual football simulation (Blaze game-server side, "past the FUT hub"),
|
|
||||||
which remains unverified.
|
|
||||||
|
|
||||||
## The loop: a four-call state machine
|
|
||||||
|
|
||||||
CardsDLL issues six match RPCs; four form the playable loop. All share the base
|
|
||||||
path `ut/<sku>/match`; the operation is discriminated by **suffix + body**, not by
|
|
||||||
HTTP verb (verb selection lives outside CardsDLL, so the classifier is verb-agnostic).
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
stateDiagram-v2
|
|
||||||
[*] --> Created: POST /match (no matchId)
|
|
||||||
Created --> Ready: POST|PUT /match/ready {matchId}
|
|
||||||
Ready --> Playing: POST /match {matchId} (bare path + int matchId)
|
|
||||||
Playing --> Ended: POST|PUT|DELETE /match/end {matchId, endReason, ...}
|
|
||||||
Ended --> [*]: rewards credited, W/D/L + matchesPlayed persisted
|
|
||||||
```
|
|
||||||
|
|
||||||
### Call classifier (`_match_call`, utas_server.py:3325)
|
|
||||||
|
|
||||||
The discriminator (**PROVEN** from CardsDLL RPC descriptors):
|
|
||||||
|
|
||||||
1. path ends `/match/end` → **END** (routed as `FutDestroyMatch` regardless of verb).
|
|
||||||
2. body has integer `matchId` on the **bare** `/match` path → **PLAY** (`FutPlayGame`).
|
|
||||||
CREATE and PLAY share `ut/<sku>/match`; the presence of an int `matchId` is the
|
|
||||||
only discriminator — this is why a bare `/match` carrying `matchId` must NOT
|
|
||||||
allocate a new match.
|
|
||||||
3. path ends `/match/ready` → **READY** (`FutMatchReady`).
|
|
||||||
4. otherwise → **CREATE** (`FutCreateMatch`).
|
|
||||||
|
|
||||||
## Per-call contracts
|
|
||||||
|
|
||||||
### CREATE — `POST /ut/<sku>/match` (empty/no `matchId`)
|
|
||||||
CardsDLL: `FutCreateMatch` @ `0x180120380`; deserializes `startDateTime`(740,int),
|
|
||||||
`reportIdEnabled`(641,bool). `squad`(717,nested) is a **FREEZE-RISK** and is omitted
|
|
||||||
(SKIP-safe).
|
|
||||||
|
|
||||||
Response (`match_route`, utas_server.py:3399):
|
|
||||||
```json
|
|
||||||
{"startDateTime": <unix_ts:int>, "reportIdEnabled": false, "id": <matchId:int>}
|
|
||||||
```
|
|
||||||
Effect: allocates a match id; **advances `nextItemId` by 1** (PROVEN,
|
|
||||||
`test_match_lifecycle.py:51`).
|
|
||||||
|
|
||||||
### READY — `POST|PUT /ut/<sku>/match/ready` (`{matchId}`)
|
|
||||||
CardsDLL: `FutMatchReady` — no deserializer at all on the request; the server
|
|
||||||
response parser has two scalar members + one nested member.
|
|
||||||
|
|
||||||
Response (`match_ready_body`, utas_server.py:3353):
|
|
||||||
```json
|
|
||||||
{"matchId": <int>, "opponentPersonaId": <int, default 0>}
|
|
||||||
```
|
|
||||||
- `opponentPersonaId` defaults to `0` — a neutral placeholder, **never** the
|
|
||||||
logged-in user's persona.
|
|
||||||
- The parser also has a nested `items` member (the opponent squad). It is **omitted
|
|
||||||
deliberately** until the opponent-squad item contract is recovered from a live
|
|
||||||
capture; unrecognized/absent members are skip-safe. **This omission is one of the
|
|
||||||
two blockers.**
|
|
||||||
|
|
||||||
### PLAY — `POST /ut/<sku>/match` (bare path, `{matchId:int}`)
|
|
||||||
CardsDLL: `FutPlayGame` — no request deserializer.
|
|
||||||
|
|
||||||
Response: `{}` (empty). Effect: **none** — must NOT allocate a match or advance
|
|
||||||
`nextItemId` (PROVEN, `test_match_lifecycle.py:62-63`). This is purely a client
|
|
||||||
keepalive/transition ack.
|
|
||||||
|
|
||||||
### END — `POST|PUT|DELETE /ut/<sku>/match/end` (`{matchId, endReason, myMatchStats, opponentMatchStats}`)
|
|
||||||
CardsDLL: `FutDestroyMatch` @ `0x180121b60` — **the rewards call**. Routed as
|
|
||||||
DestroyMatch regardless of verb (`FUT_MATCH_END`, default ON).
|
|
||||||
|
|
||||||
> **Wire path (Rust vs Python).** The Rust-owned reward is classified by `classify_economy`
|
|
||||||
> on **`POST /ut/delete/game/<sku>/match`** — EA/CardsDLL tunnels DELETE-semantics ops through the
|
|
||||||
> `/ut/delete/game/` prefix (`FutDestroyMatch` = `DELETE ut/%s/match/{id}`). Python's `match_route`
|
|
||||||
> additionally accepts `/ut/game/<sku>/match/end`. Which exact form the retail client emits is
|
|
||||||
> unverified (no match ever played); the Rust economy owns the `/ut/delete/game` form, and a
|
|
||||||
> `/ut/game/.../match/end` would fall to Python. Both credit the same reward shape.
|
|
||||||
|
|
||||||
Request fields that matter:
|
|
||||||
- `endReason` (atom 260) — **STRING enum, the AUTHORITATIVE result signal**. A score
|
|
||||||
comparison is NOT how the client reports the outcome. Nine values, mapped to a
|
|
||||||
win/draw/loss bucket:
|
|
||||||
|
|
||||||
| endReason | bucket |
|
|
||||||
|---|---|
|
|
||||||
| `WIN`, `DNF_WIN` | won |
|
|
||||||
| `DRAW`, `DNF_DRAW`, `NO_CONTEST` | draw |
|
|
||||||
| `LOSS`, `DNF_LOSS`, `DNF`, `QUIT` | loss |
|
|
||||||
| (missing/unknown) | draw (neutral fallback — credits without inventing a win) |
|
|
||||||
|
|
||||||
- `myMatchStats` / `opponentMatchStats` — literal-keyed objects, 15 int fields each,
|
|
||||||
first is `goals`. **Omitted by the client when `endReason` is `DNF`/`QUIT`**, so
|
|
||||||
nothing may require them. Used only as a fallback outcome probe if `endReason` is
|
|
||||||
absent.
|
|
||||||
|
|
||||||
Response (`destroy_match_body` / Rust `build_match_reward_body`) — every field a
|
|
||||||
**top-level scalar** (zero freeze risk) except the deliberately nested reward:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"allCoins": <post-credit balance:int>,
|
|
||||||
"matchCoins": <per-result coins:int>,
|
|
||||||
"seasonCoins": 0,
|
|
||||||
"tournamentCoins": 0,
|
|
||||||
"boostConis": 0, // EA's typo — exact key required
|
|
||||||
"participationAward": <int>,
|
|
||||||
"teamOfTournamentWinner": false,
|
|
||||||
"gameModeAward": { "coins": <total award:int> }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
> **Critical correction (2026-08-04):** the reward `coins` (atom 149) is read by the
|
|
||||||
> deserializer **only inside `gameModeAward`**, never as a top-level key. An earlier
|
|
||||||
> top-level `"coins"` was silently skipped and never reached the client — the one
|
|
||||||
> field most obviously named "the reward" was the one going nowhere.
|
|
||||||
|
|
||||||
Effect (persisted to the active FUT save): credit coins; increment the matching
|
|
||||||
`record.{won,draw,loss}`; increment `matchesPlayed` (PROVEN,
|
|
||||||
`test_match_lifecycle.py:74-81`).
|
|
||||||
|
|
||||||
## Reward policy
|
|
||||||
|
|
||||||
Env-tunable defaults (FUT-plausible, **not reversed** — `economy_policy.rs:20-36`,
|
|
||||||
`utas_server.py:3201-3206`):
|
|
||||||
|
|
||||||
| Result | Match coins | Participation | Total |
|
|
||||||
|---|---|---|---|
|
|
||||||
| Win | 400 (`FUT_MATCH_COINS_WIN`) | 0 (`FUT_MATCH_PARTICIPATION`) | 400 |
|
|
||||||
| Draw | 200 (`FUT_MATCH_COINS_DRAW`) | 0 | 200 |
|
|
||||||
| Loss | 100 (`FUT_MATCH_COINS_LOSS`) | 0 | 100 |
|
|
||||||
|
|
||||||
`allCoins` = post-credit balance; `gameModeAward.coins` = per-result + participation.
|
|
||||||
|
|
||||||
## Ownership split (Rust vs Python)
|
|
||||||
|
|
||||||
The match loop is **partially migrated**. Only the coin-crediting END leg is
|
|
||||||
economy state, so only it is Rust/Core-owned; the CREATE/READY/PLAY legs are still
|
|
||||||
served by the Python oracle.
|
|
||||||
|
|
||||||
| Call | Owner | Where |
|
|
||||||
|---|---|---|
|
|
||||||
| CREATE | Python | `utas_server.py::match_route` (via host `Route::Passthrough`) |
|
|
||||||
| READY | Python | `utas_server.py::match_route` |
|
|
||||||
| PLAY | Python | `utas_server.py::match_route` |
|
|
||||||
| END (reward) | **Rust/Core** (on `POST /ut/delete/game/<sku>/match`) | `EconomyRoute::MatchEnd` → `handle_match_end` → `build_match_reward_body`; outcome via `economy_policy::match_result_from_reason`, coins via `match_result_coins`/`match_reward_total`. 503 on Core error, never Python. Python also accepts `/ut/game/<sku>/match/end`. |
|
|
||||||
|
|
||||||
END is classified in `classify_economy` (`openfut-utas-host/src/lib.rs`) and dispatched
|
|
||||||
by the economy authority barrier ahead of the general classifier — so it can never
|
|
||||||
also reach the Python passthrough (NEVER-BOTH). The Rust END writes the coin reward
|
|
||||||
through the single Core economy transaction (`grant_reward`); W/D/L record + match
|
|
||||||
count still live in the Python save until CREATE/READY/PLAY migrate.
|
|
||||||
|
|
||||||
## Test coverage
|
|
||||||
|
|
||||||
`fifa17-recon/tools/test_match_lifecycle.py` (PROVEN, isolated — temp profile, no
|
|
||||||
live server): drives CREATE→READY→PLAY→END and asserts:
|
|
||||||
- CREATE returns `reportIdEnabled:false` + advances `nextItemId` by 1.
|
|
||||||
- READY returns exactly `{matchId, opponentPersonaId:0}`.
|
|
||||||
- PLAY returns `{}` and does **not** advance `nextItemId`.
|
|
||||||
- END credits `MATCH_COINS["won"] + MATCH_PARTICIPATION`, persists
|
|
||||||
`record == {won:1,draw:0,loss:0}` and `matchesPlayed == 1`.
|
|
||||||
|
|
||||||
## Open questions / blockers
|
|
||||||
|
|
||||||
1. **READY `items` contract (opponent squad)** — the nested `items` member of
|
|
||||||
`FutMatchReadyServerResponse` is unserved pending a live capture. Whether the
|
|
||||||
client requires it present/non-empty to enter a match is unknown. **Blocker.**
|
|
||||||
2. **Client mode-entry gate** — reaching a match from the FUT hub UI is unverified;
|
|
||||||
`FUT_MODES` stays **off by default** (the season/tournament routes return `{}`).
|
|
||||||
3. **No in-game acceptance** — every claim here is static-analysis + isolated replay.
|
|
||||||
The first live match is expected to reveal whether the static read was complete
|
|
||||||
(every match body is logged for exactly this reason).
|
|
||||||
4. **Football simulation** — the actual gameplay (Blaze game-server) is out of scope
|
|
||||||
here and unverified.
|
|
||||||
|
|
||||||
## Sources
|
|
||||||
|
|
||||||
- `docs/PROJECT_STATE.md` (match lifecycle status), `docs/direction.md` (Tier-2 loop).
|
|
||||||
- `fifa17-recon/tools/utas_server.py`: `_match_call` (3325), `match_ready_body` (3346),
|
|
||||||
`match_route` (3357), `destroy_match_body` (3281), `_match_result` (3236),
|
|
||||||
`MATCH_COINS`/`MATCH_PARTICIPATION` (3201), `_END_REASON` (3229); CardsDLL RPC
|
|
||||||
descriptor block (3183-3197).
|
|
||||||
- `openfut-adapter-fifa17/src/fut/economy_policy.rs` (reward policy + outcome map).
|
|
||||||
- `openfut-utas-host/src/lib.rs`: `EconomyRoute::MatchEnd`, `handle_match_end`,
|
|
||||||
`build_match_reward_body`.
|
|
||||||
- `fifa17-recon/tools/test_match_lifecycle.py` (lifecycle regression).
|
|
||||||
- Vault: `02 Reverse Engineering/FIFA 17/Protocol Findings.md`,
|
|
||||||
`06 Agent Memory/{Project State,Known Issues}.md`.
|
|
||||||
@@ -1,165 +0,0 @@
|
|||||||
# Overnight session handoff — 2026-08-17
|
|
||||||
|
|
||||||
Autonomous session while you slept. Low-ceremony per your instruction. Everything
|
|
||||||
below is verified as noted; nothing was committed or pushed (see
|
|
||||||
[Uncommitted work](#uncommitted-work--needs-your-review)).
|
|
||||||
|
|
||||||
## TL;DR
|
|
||||||
|
|
||||||
1. **Production promotion completed** (you chose "promotion"): prod-host swapped to the
|
|
||||||
post-P1 build, content-gap imported. Then I migrated the **remaining Python UTAS
|
|
||||||
routes that have a known contract** to Rust: account/sync, ut/auth (SID mint),
|
|
||||||
userMassInfo (full), clientdata, club/stats/{country,league,team}, and the trivial
|
|
||||||
static acks. The client's **observed FUT-hub/economy flow is now fully Rust**; a
|
|
||||||
tail of lower-traffic routes **without a captured wire shape** (item-defs,
|
|
||||||
user-identity, watchList/marketdata, non-active `squad/<n>`, draft, and mode-gated
|
|
||||||
season/tournament/champion/leaderboards/sbs) **still proxy to the Python oracle**.
|
|
||||||
2. **Launcher redesigned to a shareholder-grade egui UI** (your headline ask). Builds
|
|
||||||
clean; screenshots captured.
|
|
||||||
3. **New docs:** `MATCH_LIFECYCLE.md` (you asked), `CORE_CORRECTNESS_ISSUES.md`
|
|
||||||
(4 known Core bugs + 1 bonus exploit, ready to fix on your go/no-go).
|
|
||||||
4. **Nothing committed** — all work is in the working tree for your review (git state is
|
|
||||||
delicate: preserved-dirty Core submodule + a concurrent `funman300` actor + detached
|
|
||||||
launcher branch; I didn't want to entangle that unsupervised).
|
|
||||||
|
|
||||||
## What's live in production now (`10.10.0.120:8099`)
|
|
||||||
|
|
||||||
| Thing | State |
|
|
||||||
|---|---|
|
|
||||||
| prod-host binary | post-P1 `fda40d12` **+ my migration rebuild** (release, in `target/release/openfut-utas-host`) |
|
|
||||||
| prod-host pid | 3207781 (hub-managed, restart=no; retained spec points at the rebuilt binary) |
|
|
||||||
| Catalog | `9f6addaa` (post-P1) |
|
|
||||||
| Core content (cards) | `136d8d68` (post-P1, +18 content-gap defs) → Core loads **1710** defs |
|
|
||||||
| Core owned | **1982** (1962 players + 17 consumables + 3 staff) |
|
|
||||||
| Coins | **29,876,776** (baseline — reset from the P1 test value when the content-gap DB was swapped in; you said data isn't precious) |
|
|
||||||
| prod-core | **fixed build** from canonical submodule (`fbb54ea` + 4 correctness fixes), DB migrated ver 18 → 19; binary now `/home/alex/OpenFUT/target/release/openfut-core` |
|
|
||||||
| UTAS routes (Rust) | economy, club, squad (0/active/list/PUT), account/sync, ut/auth, userMassInfo, **user**, clientdata, hub, settings, accountinfo, leaderboards/options, match/reset, phishing, club/stats/{year,consumables,staff,country,league,team}, watchList, static acks (store/keepalive/captcha/tfa/livemessage/activeMessage) |
|
|
||||||
| Still Python (:8199) | item-defs (item/resource, defid), club-identity (clubUser, user/list, user/club), `squad/<n>` (n≠0), draft, marketdata, mode-gated (season/tournament/champion/leaderboards/sbs → `{}` while off), and match CREATE/READY/PLAY. See `docs/PRODUCTION_AUTHORITY_MATRIX.md`. |
|
|
||||||
|
|
||||||
Smoke-verified live in prod (in the prod netns): all migrated routes return
|
|
||||||
`owner=RUST`, coins consistent, clientdata round-trips, club/stats context modes emit
|
|
||||||
distinct nation/league/team buckets. Scripts:
|
|
||||||
`/home/alex/openfut-promotion/economy-2026-08-17-p2/{p2_precheck,smoke_migrated,smoke_clubstats}.py`.
|
|
||||||
|
|
||||||
## Changes made (all verified: builds clean, tests green)
|
|
||||||
|
|
||||||
### 1. Production promotion (deployed)
|
|
||||||
- Host binary `e5be8730` (P1) → `fda40d12` (post-P1) + catalog `9f6addaa`.
|
|
||||||
- Content-gap DB swapped in (owned 1962 → 1982). Backups in
|
|
||||||
`/home/alex/openfut-promotion/economy-2026-08-17-p2/backup/` + `ROLLBACK.txt`.
|
|
||||||
|
|
||||||
### 2. Route migration to Rust (deployed, rebuilt binary)
|
|
||||||
- `POST /ut/auth` — Rust mints the SID (`OPENFUT-SID-{:016X}`), opens the Rust session,
|
|
||||||
adopts persona from body. No Python. (`+ /ut/delete/auth`.)
|
|
||||||
- `POST /openfut/account/sync` — full Rust envelope; coins/unopenedPacks from Core.
|
|
||||||
- `GET /userMassInfo` — **full** Rust envelope (was a Python-proxy+overlay hybrid).
|
|
||||||
- `GET/PUT /clientdata/<key>` — new host `ClientDataStore` (JSON-persisted).
|
|
||||||
- `GET /club/stats/{country,league,team}` — made `club_stats_body` context-aware
|
|
||||||
(nation/league/team buckets); classify now routes all `club/stats/*` to Rust.
|
|
||||||
- `GET /squad/0` — routed to the Rust active-squad projection (verified structurally
|
|
||||||
identical to Python `squad/0`: same 15 keys, players=23).
|
|
||||||
- `GET /watchList` (+ no-op add/remove) — empty list + authoritative Core credits.
|
|
||||||
- Static acks (`store`, `match/keepalive`, `captcha`, `tfa`, `livemessage`,
|
|
||||||
`activeMessage`) — Rust constants (StaticAck route), byte-identical to the oracle.
|
|
||||||
- Captured the remaining routes' Python wire shapes as reference fixtures for later
|
|
||||||
migration: `docs/evidence/route-shapes-2026-08-17/` (user, defs, marketdata,
|
|
||||||
clubUser, watchList, squad/0, season/tournament/champion/sbs, draft).
|
|
||||||
- Files: `openfut-utas-host/src/{lib.rs,clientdata_store.rs(new),config.rs}`,
|
|
||||||
`openfut-adapter-fifa17/src/fut/{non_economy.rs,club_stats.rs}`,
|
|
||||||
`openfut-utas-host/tests/economy_integration.rs`.
|
|
||||||
- Tests: `openfut-utas-host` + `openfut-adapter-fifa17` full suites **GREEN**
|
|
||||||
(188 adapter + 76 host lib + all integration incl the 116s economy integration).
|
|
||||||
|
|
||||||
### 3. Launcher redesign + polish + live account panel (built, NOT deployed — client tool)
|
|
||||||
- **Redesign**: new `openfut-launcher/src/theme.rs` design system (palette, embedded
|
|
||||||
fonts, egui Visuals/Style, card/pill helpers). Branded hero header (OF monogram),
|
|
||||||
left nav rail, card-based dashboard with status pills, prominent accent Launch CTA,
|
|
||||||
console-style Logs. All existing launch/health/preflight/service/config logic preserved.
|
|
||||||
- **Polish**: OpenFUT window/taskbar icon (OF monogram `IconData`), Config tab rebuilt
|
|
||||||
into themed cards, consistency sweep.
|
|
||||||
- **Live "Your Club" panel** (new feature): a background `AccountMonitor` (mirrors
|
|
||||||
`HealthMonitor`, 5s poll, non-blocking) fetches the account summary and the Dashboard
|
|
||||||
shows a "Your Club" card — club name/abbr, Manager, **COINS hero number**, Level + XP
|
|
||||||
bar, unopened packs, account funds — with clean loading/offline/error states.
|
|
||||||
- Builds clean (0 warnings). Screenshots preserved (for your shareholder demo) in
|
|
||||||
`/home/alex/openfut-post-p1/launcher-screenshots-2026-08-17/` — `launcher_account.png`
|
|
||||||
(the populated "Your Club" card: COINS 29,876,776, Level 12, packs 3) is the headline;
|
|
||||||
plus dashboard/setup/logs/config + the offline state. All reviewed — product-quality.
|
|
||||||
- Files: `openfut-launcher/src/{theme.rs(new),account_monitor.rs(new),app.rs,main.rs,
|
|
||||||
account_sync.rs,config.rs}` + `assets/` (fonts + icon). All additive; behavior preserved.
|
|
||||||
|
|
||||||
### 4. Core correctness fixes (deployed 2026-08-17)
|
|
||||||
|
|
||||||
All four `CORE_CORRECTNESS_ISSUES.md` classes fixed in the canonical `openfut-core`
|
|
||||||
submodule and deployed to prod-core (see that doc's "Resolution" section):
|
|
||||||
- **Issue 3 (HIGH, exploit):** SBC duplicate-card free-reward — `submit_sbc` now dedups
|
|
||||||
ids + bounds the list (`MAX_SBC_CARDS`=30) → `BadRequest`. Regression test added.
|
|
||||||
- **Issue 2 (HIGH):** non-atomic economy mutations — closed the concurrency-exploit
|
|
||||||
races with single-statement compare-and-swap (`spend_coins` conditional debit,
|
|
||||||
`open_pack`/`buy_listing`/`sell_card`/`checkin` claim-then-act). Multi-statement
|
|
||||||
partial-failure edges (`buy_pack`, concurrent SBC, match/season chains) left as
|
|
||||||
documented residual — need the transaction refactor, negligible for single-player.
|
|
||||||
- **Issue 4 (LOW):** `season.rs` `.expect()` panics → graceful `AppError`; checkin index
|
|
||||||
`% 7` → `.rem_euclid(7)`.
|
|
||||||
- **Issue 1 (LOW):** `sbc_submissions.club_id` — migration `0019` (add + backfill) +
|
|
||||||
`submit_sbc` binds it; milestone query now correct.
|
|
||||||
- Verified: Core suite **179 green** + clippy clean; migration dry-run on a prod-DB copy;
|
|
||||||
post-deploy prod migration ver 19, owned 1982, coins 29,876,776, all Core + host
|
|
||||||
endpoints 200. Rollback: `backup/prod-core.preCoreFix.db` (ver 18) + old binary path —
|
|
||||||
see `backup/ROLLBACK_CORE_FIX.txt`.
|
|
||||||
|
|
||||||
## New / updated docs
|
|
||||||
|
|
||||||
- `docs/MATCH_LIFECYCLE.md` (NEW) — consolidated FUT match loop design (CREATE→READY→
|
|
||||||
PLAY→END), contracts, reward policy, ownership split, blockers. (You asked for this.)
|
|
||||||
- `docs/CORE_CORRECTNESS_ISSUES.md` (NEW) — 4 known Core bugs + 1 bonus SBC
|
|
||||||
duplicate-card exploit, each with file:line + concrete fix + severity. **Needs your
|
|
||||||
go/no-go** (fixing bumps Core off the frozen P1 reference).
|
|
||||||
- `docs/PRODUCTION_AUTHORITY_MATRIX.md` (UPDATED) — reflects the completed migration.
|
|
||||||
- Vault `06 Agent Memory/Current Priorities.md` (UPDATED).
|
|
||||||
|
|
||||||
## Uncommitted work — needs your review
|
|
||||||
|
|
||||||
**I committed nothing** (git state is delicate: openfut-core is intentionally
|
|
||||||
preserved-dirty; a concurrent `funman300` actor; launcher on detached HEAD `d1a71bd`).
|
|
||||||
Review + commit these when you're ready:
|
|
||||||
|
|
||||||
- Superproject (mine): `openfut-utas-host/src/{lib.rs,config.rs}`,
|
|
||||||
`openfut-utas-host/src/clientdata_store.rs`,
|
|
||||||
`openfut-utas-host/tests/{economy_integration.rs,host_test.rs}`,
|
|
||||||
`openfut-adapter-fifa17/src/fut/{club_stats.rs,non_economy.rs}`,
|
|
||||||
`docs/{PRODUCTION_AUTHORITY_MATRIX.md,MATCH_LIFECYCLE.md,CORE_CORRECTNESS_ISSUES.md,OVERNIGHT_HANDOFF_2026-08-17.md,PYTHON_RETIREMENT_PLAN.md}`,
|
|
||||||
and the reference fixtures `docs/evidence/route-shapes-2026-08-17/`.
|
|
||||||
- Launcher submodule (mine): `src/{app.rs,main.rs,theme.rs(new),account_monitor.rs(new),account_sync.rs,config.rs}`, `assets/` (fonts + icon).
|
|
||||||
- **Leave the pre-existing dirt alone** (not mine): `CLAUDE.md`, `README.md`,
|
|
||||||
`.env.example`, `docker-compose.yml`, `AGENTS.md`, `setup.sh`, `openfut-bridge`,
|
|
||||||
`fifa17-recon/docker/...`, `docs/{ARCHITECTURE,ROADMAP,docker,fifa17-emulation}.md`,
|
|
||||||
`docs/research/`, `scripts/utas-filter-diff.py`.
|
|
||||||
- `openfut-core` (mine, this session): `migrations/0019_sbc_submissions_club_id.sql` (new),
|
|
||||||
`src/services/{sbc.rs,club.rs,pack.rs,market.rs,checkin.rs,season.rs}`,
|
|
||||||
`tests/integration_test.rs`. Built + deployed to prod-core; still detached HEAD at
|
|
||||||
`fbb54ea` (edits uncommitted, per your branch strategy).
|
|
||||||
|
|
||||||
## Open decisions for you
|
|
||||||
|
|
||||||
1. ~~**Core correctness bugs**~~ — **DONE (2026-08-17):** all four classes fixed +
|
|
||||||
deployed to prod-core (see "Core correctness fixes" above and the Resolution section
|
|
||||||
of `docs/CORE_CORRECTNESS_ISSUES.md`). prod-core is now off the frozen P1 point,
|
|
||||||
running `fbb54ea` + fixes at migration ver 19. Residual (documented): the
|
|
||||||
multi-statement transaction refactor for partial-failure atomicity — negligible for
|
|
||||||
single-player; do it if/when concurrency matters.
|
|
||||||
2. **Match handshake legs** (CREATE/READY/PLAY) — the only routes still on Python.
|
|
||||||
Deferred: no match has ever been played in-game, READY `items` contract unknown,
|
|
||||||
`FUT_MODES` off (see `docs/MATCH_LIFECYCLE.md`). Migrating them risks the economy
|
|
||||||
`/match/end` routing for a never-exercised path — I judged it not worth it unmonitored.
|
|
||||||
3. **Aux service container cutover** (blaze/redirector/roster → Rust) — operator-gated
|
|
||||||
container change; unchanged.
|
|
||||||
|
|
||||||
## Rollback (still hot)
|
|
||||||
|
|
||||||
- Python P2 image `openfut-fut-backend:p2-rollback` (b1b929953f) + profile 39bb3e83 +
|
|
||||||
`rollback-to-python-p2.sh`.
|
|
||||||
- prod-host P1 binary + P1 catalog backed up in
|
|
||||||
`/home/alex/openfut-promotion/economy-2026-08-17-p2/backup/` (see `ROLLBACK.txt`).
|
|
||||||
- prod state (pre-content-gap) backed up: `backup/prod-core.preB.db`,
|
|
||||||
`prod-identity.preB.json`, plus P1 `fifa17-production-{catalog,cards}.p1.json`.
|
|
||||||
@@ -1,165 +0,0 @@
|
|||||||
# Production Authority Matrix (post-P1 -> P2-routes promoted 2026-08-17)
|
|
||||||
|
|
||||||
> **P2-routes promoted to production 2026-08-17.** prod-host swapped P1 `e5be8730` -> post-P1
|
|
||||||
> `fda40d12`; catalog `35a0913b` -> `9f6addaa` (resolves all owned assets, dropped_no_asset=0).
|
|
||||||
> Every previously-Python non-economy route now RUST in prod (OBSERVED prod log). Rollback hot:
|
|
||||||
> restore P1 binary + backup catalog (`economy-2026-08-17-p2/backup/`).
|
|
||||||
|
|
||||||
Definitive inventory of every production-reachable FIFA17 route/service and its
|
|
||||||
current owner. Derived from the live `prod-host` dispatch log (owner= labels,
|
|
||||||
real Client A session 2026-08-14), `openfut-utas-host/ROUTE_AUTHORITY.md`, and the
|
|
||||||
prod container config (`OPENFUT_SERVERS="blaze roster pow"`).
|
|
||||||
|
|
||||||
Evidence labels: OBSERVED (live log/db), PROVEN (test), INFERRED, HYPOTHESIS.
|
|
||||||
|
|
||||||
Legend: owner R = Rust/Core, P = Python oracle (:8199 proxied via PYTHON_FALLBACK).
|
|
||||||
|
|
||||||
## UTAS HTTP (front door: openfut-utas-host :8099)
|
|
||||||
|
|
||||||
### ECONOMY — already Rust (Python economy hits = 0, OBSERVED)
|
|
||||||
| Method/path (tail) | Prod owner | Writes state | Rust handler | Py proxy |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| GET /user/credits | R | no | handle_credits | NO |
|
|
||||||
| GET /store/purchasegroup[/all] | R | no | handle_purchasegroup | NO |
|
|
||||||
| PUT /store/transaction[/<id>] | R | coins,inv,pile | handle_store_buy | NO |
|
|
||||||
| POST /purchased[/items] | R | coins,inv,ent,pile | handle_pack_open | NO |
|
|
||||||
| GET /purchased[/items] | R | no | shape_purchased_reveal | NO |
|
|
||||||
| DELETE /item/<id> | R | coins,inv | handle_quick_sell_path | NO |
|
|
||||||
| POST /ut/delete/../item | R | coins,inv | handle_quick_sell_body | NO |
|
|
||||||
| PUT /item | R | inv,pile | handle_move_items | NO |
|
|
||||||
| POST /ut/delete/../match | R | coins | handle_match_end | NO |
|
|
||||||
| POST /auctionhouse,/transfermarket | R | listings | handle_market_list | NO |
|
|
||||||
| GET /tradePile | R | no | handle_market_query | NO |
|
|
||||||
| GET /tradePile/counts | R | no | handle_market_counts | NO |
|
|
||||||
| /trade/<id> (POST/PUT/GET) | R | coins,inv,listings | handle_market_buy | NO |
|
|
||||||
| DELETE /ut/delete/../trade/<id> | R | listings | handle_market_cancel | NO |
|
|
||||||
|
|
||||||
**Transfer-market semantics (live-verified 2026-08-17).** Three things here are
|
|
||||||
load-bearing and were each a live defect:
|
|
||||||
1. `/tradePile` and `/tradePile/counts` are **different deserializers** and MUST NOT
|
|
||||||
share a handler. `/counts` is FutGetAuctionCount: five scalar ints
|
|
||||||
(`count`, `maxAuctionsAllowed`, `offered`, `selling`, `sold`) and nothing else.
|
|
||||||
Served the `auctionInfo` body it skips every field, leaving the counts at 0 — the
|
|
||||||
hub tile shows a listing while the Transfer List screen shows no active sale.
|
|
||||||
2. An auction record's `itemData` MUST be the **full card object** (the same shape
|
|
||||||
`/club` emits), not a stub. A listing therefore persists a shaped-card SNAPSHOT
|
|
||||||
(`listings.item_json`) at list time. The seller's own pile stamps
|
|
||||||
`itemState: listFS`; market search uses `forSale`.
|
|
||||||
3. `POST /auctionhouse` resolves the listed card **server-side** from the wire item
|
|
||||||
id (`wire → Core instance → card_id + resourceId`); the client's FutISStart body
|
|
||||||
carries only the id. This also enforces that you can only list what you own.
|
|
||||||
|
|
||||||
### NON-ECONOMY — Rust-owned (route migration 2026-08-17, OBSERVED prod log)
|
|
||||||
| Route | Owner | Notes |
|
|
||||||
|---|---|---|
|
|
||||||
| POST /ut/auth (+ /ut/delete/auth) | R | Rust mints the SID (OPENFUT-SID-{:016X}); opens Rust session; adopts persona from body. No Python. |
|
|
||||||
| POST /openfut/account/sync | R | full Rust envelope; coins/unopenedPacks from Core; clubName OpenFUT / clubAbbr OFC constants |
|
|
||||||
| GET /userMassInfo | R | FULL Rust envelope (userInfo+squad+settings+pileSizeClientData); no Python. coins from Core, squad == /squad/active |
|
|
||||||
| GET/PUT /clientdata/<key> | R | host ClientDataStore (JSON-persisted); PUT acks {}, GET returns blob or {} |
|
|
||||||
| capability (/openfut/fifa17/capability) | R | -> Bound (CleanV1) |
|
|
||||||
| GET /club, /club/* readers | R | Core-backed collection (dropped_no_asset=0). Cards with an **ACTIVE listing are excluded** — a listed card has left the club. Keyed on the listing, NOT on the `trade` pile: the pile can hold cards with no listing (bare move, or cancelled/sold) and `/tradePile` renders only ACTIVE listings, so hiding the pile would make those invisible in BOTH views. Keying on the listing is self-healing — cancel/sale restores club visibility with no extra transition. Pagination then runs over the club-visible set (Core cannot filter host-owned listing state, so letting it paginate would yield short pages); with nothing hidden the fast Core-paginated path is unchanged. |
|
|
||||||
| GET /squad/0, /squad/active, /squad/list; PUT /squad/<n> | R | Core squad projection + tx; GET /squad/0 == active squad (verified structurally identical) |
|
|
||||||
| GET /user/accountinfo | R | {} |
|
|
||||||
| GET /user | R | `{"userInfo": …}` — same userInfo builder as userMassInfo (shared); squad rating is Core-authoritative (DIFFERENT-BY-DESIGN vs Python's stale value) |
|
|
||||||
| GET /settings | R | {"configs":[]} |
|
|
||||||
| GET /leaderboards/options | R | {} |
|
|
||||||
| PUT /match/reset | R | {} |
|
|
||||||
| GET /phishing/trusteddevice | R | security-question stateless ack |
|
|
||||||
| GET /hub | R | Core-derived counts; `clubPlayers` excludes actively-listed cards, `auctionCount`/`tradePile` from the durable market store |
|
|
||||||
| GET /club/stats/{year,consumables,staff,country,league,team} | R | Core aggregation; context buckets keyed nation/league/team (owned=1982); staff={} |
|
|
||||||
| GET /store, /match/keepalive, /captcha, /tfa, /livemessage, /activeMessage | R | unconditional constant acks (byte-identical to the oracle; StaticAck route) |
|
|
||||||
| GET /watchList (+ PUT/POST/DELETE) | R | empty watch list + authoritative Core credits; add/remove is a no-op ack (oracle persists none) |
|
|
||||||
| GET /season, /tournament, /champion, /clubUser, /user/list | R | FUT modes + club-identity off → `{}` (FeatureOffEmpty; byte-identical to the flag-off oracle). Migrated + deployed 2026-08-17 |
|
|
||||||
| POST /ut/.../match/end (DestroyMatch) | R | economy reward (coins credited via Core grant_reward) |
|
|
||||||
| GET /item/resource, /defid | R | `{itemData:[item_def…]}` — asset=rid&0xffffff; hardcoded Ronaldo (20801) + placeholder ("Player",75,CM,attrs 70), mirroring the oracle's `item_def`. Client renders from its LOCAL DB, so the placeholder is exact parity. Migrated + deployed 2026-08-17 |
|
|
||||||
| GET /marketdata, /marketdata/pricelimits | R | suggested pricing, constant band 150..15000. `/pricelimits` = bare ARRAY (one per defId); plain `/marketdata` = OBJECT — container type is load-bearing (object-where-array froze a live client). Migrated + deployed 2026-08-17 |
|
|
||||||
|
|
||||||
### NON-ECONOMY — still Python (PYTHON_FALLBACK)
|
|
||||||
| Method/path | Owner | Reason |
|
|
||||||
|---|---|---|
|
|
||||||
| POST /user/club (rename) | P | mutating club rename; Core has `clubs` but rename needs a Core write (deferred). Reads `clubUser`/`user/list` are now Rust. |
|
|
||||||
| GET /squad/<n> (n≠0, non-active) | P | no multi-squad Core model (Rust owns squad/0, squad/active, /squad/list, PUT) |
|
|
||||||
| /squad/mode/draft/* | P | FUT Draft mode |
|
|
||||||
| GET /leaderboards, /sbs/* | P | mode-gated (FUT_MODES/_SBC off → `{}`/content; `/sbs/sets` ships content); real behavior needs the mode logic ported. `season`/`tournament`/`champion` are now Rust. |
|
|
||||||
| POST /ut/.../match (CREATE), /match/ready (READY), /match (PLAY) | P | match handshake legs; no match ever played in-game (see docs/MATCH_LIFECYCLE.md) |
|
|
||||||
|
|
||||||
## AUXILIARY SERVICES (prod container OPENFUT_SERVERS="blaze roster pow")
|
|
||||||
All aux services are **Python in production today** (container `entrypoint.sh` runs
|
|
||||||
`blaze_responder_v3b.py`/`roster_server.py`/`pow_server.py`). Rust equivalents exist
|
|
||||||
outside Docker; deploying them is operator-gated (production deployment forbidden here).
|
|
||||||
|
|
||||||
| Service | Rust crate | Completeness | Prod owner | Reachable | Blocker to candidate |
|
|
||||||
|---|---|---|---|---|---|
|
|
||||||
| Blaze (:42130) | openfut-blaze-host + openfut-protocol-blaze + adapter::blaze | COMPLETE, gate-proven to real FUT (Gate 10: 3 logins, 10 pack opens, 448/448 py suite) | Python | yes | ERRC error-reply placement unresolved; wire into candidate bring-up |
|
|
||||||
| Redirector (:42127 TLS) | openfut-redirector-host + openfut-tls (OpenSSL vendored) | COMPLETE, 1 live handshake 2026-08-11 (TLSv1.2/AES256-GCM-SHA384) | Python | yes | never full-path gated; cert consistency |
|
|
||||||
| Roster (:8081) | openfut-roster-host | COMPLETE, oracle-parity + lifecycle tests, unit-only | Python | yes | never live-gated |
|
|
||||||
| POW (:8094 + :8080) | NONE (only pow_server.py) | no Rust host; 58 templates, bodies placeholder | Python | yes | LARGEST: no crate + bodies un-reversed |
|
|
||||||
| Nucleus (:42131) | none (advertised string only) | n/a | Python stub (advertised, unused) | NO (0 live hits) | DEAD in current flow — keep advertising URL, no migration |
|
|
||||||
|
|
||||||
RETIRED/out-of-scope: fifa-blaze (FIFA23 capture stub), openfut-bridge (FIFA23). Not in FIFA17 flow.
|
|
||||||
|
|
||||||
## NON-ECONOMY UTAS TARGET OWNER (confirmed via scouts)
|
|
||||||
Core already exposes GET/PUT /settings, GET /club|/collection|/statistics|/profile, /squad/*.
|
|
||||||
New Rust arm = Route variant + classify() arm (lib.rs:118-140) + owner-labelled handler.
|
|
||||||
|
|
||||||
| Route | Port complexity | Target | Notes |
|
|
||||||
|---|---|---|---|
|
|
||||||
| user/accountinfo | trivial-static ({}) | R | host constant |
|
|
||||||
| settings | trivial-static ({"configs":[]}) | R | host constant / Core /settings |
|
|
||||||
| leaderboards/options | trivial-static | R | host constant |
|
|
||||||
| match/reset | trivial-static ({}) | R | host constant ack |
|
|
||||||
| phishing/trusteddevice | small (validate hex + constant) | R | security-question: stateless ack, always verified/trusted |
|
|
||||||
| clientdata/userHubData | small stateful | R | Core PUT /settings upsert keyed userHubData |
|
|
||||||
| hub | medium (derived counts) | R | Core collection + market counts |
|
|
||||||
| club/stats/year | medium (rating-tier aggregation) | R | Core /collection + /statistics |
|
|
||||||
| club/stats/consumables | medium | R | BLOCKED on 17-consumable import |
|
|
||||||
| club/stats/staff | trivial ({}) or derived | R | BLOCKED on 3-staff import |
|
|
||||||
| account/sync | small-medium (persona select + save) | R | hardest: no direct Core route; host session/persona logic |
|
|
||||||
| ut/auth envelope | small (SID mint) | R (currently OBSERVE) | Python still mints envelope; boundary decision |
|
|
||||||
|
|
||||||
## MIGRATION PRIORITY ORDER (Phase 12)
|
|
||||||
1. Content gap consumable+staff emit (unblocks club/stats/{consumables,staff}) — import crate.
|
|
||||||
2. Trivial-static host routes: accountinfo, settings, leaderboards/options, match/reset, phishing/trusteddevice.
|
|
||||||
3. clientdata/userHubData (Core settings upsert).
|
|
||||||
4. hub + club/stats/year (Core-derived aggregation).
|
|
||||||
5. userMassInfo full ownership (envelope scaffold; econ+squad already Rust).
|
|
||||||
6. account/sync + ut/auth envelope (account/session boundary).
|
|
||||||
7. Aux candidate wiring (blaze/roster/redirector already built) + POW (blocked) + Nucleus (dead).
|
|
||||||
|
|
||||||
## ECONOMY EXPECTATION
|
|
||||||
Python economy hits = 0 (OBSERVED live + PROVEN by NEVER-BOTH/no-fallback tests).
|
|
||||||
Any nonzero Python economy hit = P1 regression, priority zero.
|
|
||||||
|
|
||||||
## STATUS
|
|
||||||
Baseline + economy + Rust-owned non-economy rows: OBSERVED/PROVEN.
|
|
||||||
Aux + non-economy target owners: confirmed via HostSourceMap/PythonContractMap/AuxServiceMap/ContentGapMap scouts.
|
|
||||||
|
|
||||||
## CANDIDATE MIGRATION STATUS (post-P1 progress, off-production)
|
|
||||||
DONE (Rust-owned in candidate source; committed on top of 5020137; workspace tests green):
|
|
||||||
- Content gap: consumable+staff emit (20 instances / 18 defs; verified real-profile re-import 1962+20).
|
|
||||||
- Non-economy routes migrated to Rust: user/accountinfo, settings, leaderboards/options,
|
|
||||||
match/reset, phishing/{trusteddevice,question,validate}, club/stats/staff, hub,
|
|
||||||
club/stats/{year,consumables}.
|
|
||||||
(hub clubPlayers = owned player count from Core; may be < Python profile count by the deferred
|
|
||||||
Legend instances = DIFFERENT-BY-DESIGN.)
|
|
||||||
- Reachability reporter (scripts/openfut-reachability.py) gates Python-hit invariants.
|
|
||||||
|
|
||||||
RESIDUAL PYTHON (still proxied; each has a concrete blocker, not ordinary difficulty):
|
|
||||||
- club/stats/{country,league,team} — RESIDUAL (nation/league/team context sub-screens). The global
|
|
||||||
MY-CLUB stat set (club/stats/{year,consumables,staff}) is now Rust (adapter club_stats.rs faithful
|
|
||||||
port of fut_club_stats.py VOCAB + counts, Core-accurate over real imported content, staging-verified
|
|
||||||
RUST route=club-stats owned=1982 players=1962). The per-nation-bucket context sub-screens still proxy
|
|
||||||
Python (low value, unrecognized atoms inert); migrate with a live context capture if ever needed.
|
|
||||||
- account/sync — launcher-facing (POST /openfut/account/sync, pre-auth); envelope has fields not in
|
|
||||||
Core (clubAbbr, established, profilePath, accountFunds cap); changing it risks the launcher. Needs
|
|
||||||
a Core account endpoint or host account logic + launcher-contract verification.
|
|
||||||
- userMassInfo — SAFE hybrid today (Rust overlays economy+squad on Python envelope). Full ownership
|
|
||||||
needs a real full-envelope capture first (missing one scaffold field breaks a hot route); deferred.
|
|
||||||
- ut/auth — ORDERING-BLOCKED: still-proxied Python routes rely on Python's session table, so auth
|
|
||||||
must stay proxied (Python mints SID, Rust OBSERVEs) until every session-needing route is Rust. Migrate LAST.
|
|
||||||
- clientdata/userHubData — BLOCKED: Core /settings supports only 2 fixed keys (difficulty,
|
|
||||||
preferred_formation); storing an arbitrary blob needs a frozen-Core change or a new host store.
|
|
||||||
Low-value UI-pref blob; kept Python per Phase 23 (no Python-by-ideology).
|
|
||||||
|
|
||||||
AUX (Rust built, deploy = operator-gated container cutover, NOT this task): blaze/roster/redirector.
|
|
||||||
POW = blocked (no crate, bodies un-reversed). Nucleus = dead (0 live hits).
|
|
||||||
@@ -1,192 +0,0 @@
|
|||||||
# OpenFUT — Project State
|
|
||||||
|
|
||||||
> **Canonical source:** `../OpenFUT-Vault/06 Agent Memory/Project State.md`
|
|
||||||
> This file is a mirror. If the two disagree, the vault wins. Update the vault first.
|
|
||||||
|
|
||||||
Factual snapshot. Prefer this over the stale root `README.md`/`CLAUDE.md` status tables (FIFA 23).
|
|
||||||
Last compiled from repository evidence during context initialization.
|
|
||||||
|
|
||||||
## Working
|
|
||||||
|
|
||||||
- **FIFA 17 offline FUT stack, end-to-end.** Proven 2026-08-01: auth → Blaze login → device-trust →
|
|
||||||
the FUT hub. Brought up by `fifa17-recon/tools/openfut-fut.sh start`. Evidence: `FUT-RUNBOOK.md`,
|
|
||||||
`fifa17-recon/README.md`, the five responder scripts, gate-ladder troubleshooting table.
|
|
||||||
- **ProtoSSL cert-pin defeat** — two live `/proc/PID/mem` patches (`autopatch.py`), VAs stable
|
|
||||||
across launches. gdb-verified which gate was the wall.
|
|
||||||
- **LSX / Origin layer** — crypto handshake reversed byte-exact and confirmed against the repack's
|
|
||||||
own emu disassembly (`docs/REPACK_INTEL.md`); Origin login gates cleared.
|
|
||||||
- **Blaze redirector + Fire2/Heat2** — both hops defeated; preAuth/login/personas answered.
|
|
||||||
- **UTAS/RS4 FUT API** — `ut/auth` + boot calls + device-trust reach the hub with hand-authored JSON.
|
|
||||||
- **Persistent FIFA 17 account selection** — the launcher synchronizes one configured EA persona to
|
|
||||||
the Python backend before starting LSX/FIFA. LSX, Blaze, POW/EASFC, and UTAS then share that
|
|
||||||
identity, while FUT coins, inventory, squads, progression, and unopened packs persist in an
|
|
||||||
isolated save beneath `fifa17-recon/docker/state/accounts/<persona-id>/`. The POW level/XP/funds
|
|
||||||
shown in FIFA's general account bar are account-scoped but remain distinct from FUT club coins.
|
|
||||||
A reversible server test on 2026-08-09 verified profile switching, POW values, a 400-coin pack
|
|
||||||
debit, five awarded items, and restoration of the original profile.
|
|
||||||
- **Account-scoped FUT security compatibility** — launcher account synchronization initializes a
|
|
||||||
persisted `securityQuestion` verification record in that persona's FIFA 17 profile. The UTAS
|
|
||||||
PHISHING handler returns the complete CardsDLL trusted-console response (`changed`, `exists`,
|
|
||||||
`locked`, `trusted`), accepts only well-formed legacy setup/validate requests under `X-UT-SID`,
|
|
||||||
and never stores or logs the client-transformed answer. This is server-side emulation; the hook
|
|
||||||
and launcher do not contain an answer or add UI automation. Automated contract coverage is in
|
|
||||||
`fifa17-recon/docker/ctx/tools/test_security_question.py`; live first/repeat-launch acceptance is
|
|
||||||
partially complete: the first launch entered FUT without a security dialog on 2026-08-09; a
|
|
||||||
second fresh-process FUT entry is still required to close persistence acceptance.
|
|
||||||
- **Safe responder diagnostics** — ordinary LSX logs redact challenge/session/auth-code attributes;
|
|
||||||
ordinary Blaze logs redact auth/session keys and no longer emit raw Fire2 hex, decoded TDF, or
|
|
||||||
config values. Forensic Blaze capture remains available only with the explicit
|
|
||||||
`OPENFUT_BLAZE_DUMP_FRAMES=1` opt-in. LSX and Blaze self-tests cover the new defaults.
|
|
||||||
- **OpenFUT Core** — Rust FUT economy backend, feature-complete for its scope and tested: profiles,
|
|
||||||
clubs, coins, packs, cards, squads, chemistry styles, SBCs, objectives, matches, market (NPC),
|
|
||||||
draft, FUT Champs, seasons, statistics, achievements, events, daily check-in, division
|
|
||||||
leaderboard, market trade history. 13 migrations. Integration suite (`tests/integration_test.rs`,
|
|
||||||
96 test fns) runs against in-memory SQLite; CI (fmt/clippy/build/test) green on `openfut-core`.
|
|
||||||
|
|
||||||
## Partially implemented
|
|
||||||
|
|
||||||
- **FIFA 17 FUT hub depth** — reaching the hub is proven, but how much of FUT is fully navigable
|
|
||||||
beyond it (playing matches, pack opening, SBC submission through the *game* UI vs. spinner/error
|
|
||||||
states) is not documented as complete. The runbook's gate ladder lists failure modes still
|
|
||||||
guarded against. Treat "past the hub" as unverified.
|
|
||||||
- **Pack opening through the game UI** — proven live on 2026-08-09 with the recovered CAGE test
|
|
||||||
profile: purchase, reveal, item assignment/quick-sell, wallet refresh, and return from the reveal
|
|
||||||
all completed. The Python transaction path also passes its 446-check contract suite. FIFA's
|
|
||||||
hardcoded post-reveal `mypacks` return is supported by a short-lived active grace record for every
|
|
||||||
opened pack; it is excluded from unopened-pack counts and retired at the next hub request.
|
|
||||||
- **FIFA 17 FUT match lifecycle** — CardsDLL static analysis and isolated responder tests now cover
|
|
||||||
CREATE→READY→PLAY→END. Bare `/match` requests carrying body `matchId` are classified as PLAY
|
|
||||||
instead of accidentally allocating another match; READY returns the verified scalar `matchId`
|
|
||||||
and `opponentPersonaId` fields; END persists W/D/L, matches played, and coin rewards per account.
|
|
||||||
The implementation is deployed and `test_match_lifecycle.py` passes, but no football match has
|
|
||||||
started or completed in FIFA yet. The READY opponent `items` contract and client mode-entry gate
|
|
||||||
remain unresolved; `FUT_MODES` therefore stays off by default.
|
|
||||||
- **Core ↔ emulation integration** — the two halves exist and wiring has **started**. First
|
|
||||||
slice (2026-08-11): the My Squad owned-player search. `openfut-core` gained a semantic,
|
|
||||||
game-independent owned-inventory query (`services::inventory::{OwnedItemQuery, apply_query}`
|
|
||||||
+ a `Quality` tier) that filters (AND) → orders deterministically → paginates, wired into
|
|
||||||
`GET /collection`; `openfut-adapter-fifa17::fut::owned_query` parses the FIFA17 `/club` wire
|
|
||||||
query and resolves numeric league/nation/team ids → semantic names (unknown id = hard error,
|
|
||||||
no raw-id passthrough). Intentional fix, not parity: Python applies only `league`+`team` and
|
|
||||||
ignores `level`/`rare`/`position`/`nation`/`start`/`count` (the request-amplification bug);
|
|
||||||
Core applies all proven filters and paginates. `rare=SP` semantics UNKNOWN, unimplemented.
|
|
||||||
Slice 2 (2026-08-11): `openfut-utas-host` — the first live UTAS host. Serves `GET …/club`
|
|
||||||
from Core through the adapter and reverse-proxies every other UTAS route verbatim to the
|
|
||||||
Python oracle (`utas_server.py`); plaintext HTTP/1.1 keep-alive, classify-before-execute,
|
|
||||||
no python-fallback after a Core error. `CoreAccess` is a host-owned boundary (the adapter
|
|
||||||
stays transport-agnostic). 11 host + 22 adapter tests; 10/10 mutations killed; fmt/clippy
|
|
||||||
clean. Slice 3 (2026-08-11, `3ef3bc3`): the real `Fifa17IdentityResolver` — catalog
|
|
||||||
(card id → real asset id) + persistent `openfut-identity` store (owned instance →
|
|
||||||
stable/reversible wire int) + wire-id policy, replacing all placeholders (one
|
|
||||||
production path). Wire-id namespace is globally monotonic within `(fifa17, owned-item)`,
|
|
||||||
not per-account (Core owned ids are UUIDs → unambiguous reverse). Slice 4 (2026-08-11,
|
|
||||||
core `36abd4b`): a curated 32-card real FIFA17 dev content pack
|
|
||||||
(`data/games/fifa17/dev/cards.json`, ids `fifa17_<asset>`), loaded only via opt-in
|
|
||||||
`Config.dev_content_games`; `seed-dev` grants a `game_id=fifa17` profile+club real
|
|
||||||
`OwnedCard`s (no FIFA wire ids — the resolver mints those at request time), idempotent,
|
|
||||||
default content untouched. Slice 5 (2026-08-11, `5276dd2`): the host sends
|
|
||||||
`X-OpenFUT-Game: fifa17` so `/club` resolves the all-mapped fifa17 profile —
|
|
||||||
**composition proven live** over HTTP (real Core+host, no FIFA client): FIFA wire query
|
|
||||||
→ real `resourceId`s (catalog) + stable/reversible wire `id`s (store); 33 renderable,
|
|
||||||
gold=22, Premier League=18, pagination page1=11/page2=7/overlap=0 (clean paging, no
|
|
||||||
drops). **The only remaining gate is the live retail FIFA A/B** (no FIFA client in the
|
|
||||||
build env; runbook `openfut-utas-host/README.md`). See the vault UTAS Endpoint Map +
|
|
||||||
Known Issues (incl. "identity resolution is NOT authorization" for later mutations).
|
|
||||||
**Slice 6 (2026-08-11): `/club` RUNTIME VALIDATED on retail FIFA 17** — operator-assisted
|
|
||||||
live A/B (`.105` client → `.120` backend via a source-scoped NAT redirect into a staged
|
|
||||||
`36abd4b` Core + `openfut-utas-host`). Every checkpoint passed on the real client:
|
|
||||||
transport, per-route Python fallback, Python-negative `/club`, Core `X-OpenFUT-Game`
|
|
||||||
scoping, real card + persistent owned-item identity (incl. the two-copy fixture),
|
|
||||||
no-filter/Gold/position/nation/league/team/combined-AND filtering, retail pagination
|
|
||||||
with no amplification, card selection, identity stability across relaunch, Python
|
|
||||||
rollback, and Rust re-enable (identity store byte-identical across the cycle, 0
|
|
||||||
reallocation). Live findings: the retail client steps `start += 10` with `count=11`
|
|
||||||
(sometimes bulk `count=100`) — Core honours `offset` and terminates; the My Club UI
|
|
||||||
nests team under league; the "~1900" club counter is Python `userMassInfo`, not `/club`.
|
|
||||||
Remaining is operational only (promote the staged stack to a durable deployment).
|
|
||||||
- **Slice 7 (2026-08-12): FUT squad authority (read + write) RUNTIME VALIDATED on retail FIFA 17.**
|
|
||||||
Staged operator-assisted A/B (`.105` retail client → `.120`, source-scoped utas switch
|
|
||||||
`:8099→:8199` into `openfut-utas-host` over a staged `615c5fd` Core seeded by `seed-dev` with the
|
|
||||||
dev 33-card inventory). FIFA itself consumed the Rust squad path end-to-end: FUT boot served
|
|
||||||
`RUST_OVERLAY userMassInfo` (squad overlay) and the squad screen rendered the dev XI; a controlled
|
|
||||||
in-game squad edit issued `PUT …/squad/0` → host `squad-replace` → Core → `{"id":0}`; an in-game
|
|
||||||
formation change f442→f433 persisted to Core (canonical fingerprint changed, `position_index`
|
|
||||||
remapped 0–10); a FULL FIFA relaunch cold-fetched and rendered the persisted f433 squad (no client
|
|
||||||
cache). Reversibility proven on the exact client path by log presence, not response data (both
|
|
||||||
backends coincidentally hold the same dev squad — the Python oracle `fifa17_profile.json` was
|
|
||||||
seeded from the same `squad_put_f442.json` capture): disarmed `.105:8099` reached Python (absent
|
|
||||||
from host log), re-armed reached Rust (`route=club limit=Some(9)` present); the persistent identity
|
|
||||||
store survived the cycle with 0 reallocation. Non-migrated routes (`/ut/auth`, `account/sync`,
|
|
||||||
`accountinfo`, `settings`, `hub`, store txn) correctly Python-fallback. NEW startup requirement:
|
|
||||||
the Core server loads dev card defs only for games in env `OPENFUT_DEV_CONTENT_GAMES` (comma-sep);
|
|
||||||
omitting `fifa17` makes `get_collection` silently drop every owned card (empty `/collection`,
|
|
||||||
"no squad") despite a successful seed — MUST become a deployment/preflight assertion. Preceded the
|
|
||||||
same day by a staged two-process parity gate (real Core+host over HTTP, no FIFA) confirming byte-
|
|
||||||
shape parity of `userMassInfo.squad` vs the captured oracle. Next milestone: real-data Core
|
|
||||||
import / profile strategy → production Rust UTAS. Blaze deferred.
|
|
||||||
- **Slice 8 (2026-08-12): REAL-DATA staged retail A/B PASS (import + club + squad).** The real FIFA 17
|
|
||||||
profile was imported into a staged Core via the two-store protocol (`openfut-import-fifa17 --apply`:
|
|
||||||
generic transactional Core import + `openfut-identity` seeding, idempotent), then FIFA itself
|
|
||||||
consumed it end-to-end over the source-scoped utas switch (`.105`→`.120:8199`). Imported population
|
|
||||||
1949 of 1962 player instances (13 Legend/special assets deferred as unnameable — 9 NoName + the 4
|
|
||||||
copies of `169193`), every original Python wire id preserved (set-equal, 0 minted/dropped), each
|
|
||||||
owned instance an opaque Core UUID. On the retail client: real club rendered (1949, not the 33-card
|
|
||||||
dev XI); `/club` pagination clean (paged==full, no loop/overlap/drop); the `Special` quality filter
|
|
||||||
returned only specials (1665, 0 base leaked) and paginated the filtered set; a squad edit persisted
|
|
||||||
to Core (canonical + opaque extension atomically, fingerprint recomputed) and survived a full cold
|
|
||||||
relaunch; rollback→Python→Rust re-enable proven on the exact client path (disarm reached Python,
|
|
||||||
re-arm returned the imported club + edited squad; identity store 0 reallocation). Three real-data
|
|
||||||
fidelity gaps the base-only dev fixture had hidden were found on the live client and fixed:
|
|
||||||
(1) `e187cd4` versioned `resourceId` — `shape_item` emitted the base assetId as `resourceId`,
|
|
||||||
collapsing every special onto its base card art; `Fifa17Identity` now carries the versioned
|
|
||||||
`resource_id`. (2) `626c972` observed `rareflag` — `shape_item` hardcoded `rareflag=1`, so all
|
|
||||||
specials rendered as basic rare; `rareflag` now flows through the FIFA catalog and onto the wire
|
|
||||||
(wire distribution == source exactly). (3) `6f16a23` `rare=SP` Special filter — previously a no-op
|
|
||||||
("semantics UNKNOWN"), now grounded as `rareflag > 1` and applied host-side (Core has no rareflag).
|
|
||||||
Also `44fcf24`: nation/league/team proven to be INSTANCE metadata (not definition identity — the 4
|
|
||||||
copies of `169193` are identical but for club), so the definition-consistency gate now compares
|
|
||||||
identity only (no `--defer-conflict` needed; no majority-vote). PRODUCTION NOT READY: the 13 deferred
|
|
||||||
Legends are unnameable from the `.120` client dump (absent/placeholder in `players.json`; DLC/Legends
|
|
||||||
name tables empty) — honest names require the FIFA 17 Legends locale data from the `.105` client.
|
|
||||||
Next: resolve the 13 names → re-import to 1962/0 → full-fidelity gate → gitlink reconcile →
|
|
||||||
production Rust UTAS.
|
|
||||||
|
|
||||||
## Stubbed / planned
|
|
||||||
|
|
||||||
- **`fifa-blaze`** (Rust) — Milestone 1 capture stub only. Two TLS listeners that log packets; no
|
|
||||||
FIFA 23 component/command handlers. Its own README says IDs are unknown. Superseded in practice by
|
|
||||||
the Python FIFA 17 responders, kept as the intended FIFA 23 implementation surface.
|
|
||||||
- **`openfut-launcher` legacy controls** — core/bridge and FIFA 23 setup controls belong to a
|
|
||||||
superseded plan. The launcher now also owns the live FIFA 17 client flow: server/hook config,
|
|
||||||
account synchronization, local LSX, privileged autopatch, and game launch.
|
|
||||||
- **`tools/`** (file-watch-diff, squad-injector, exporters) — helpers for the FLE-Lua-bridge idea in
|
|
||||||
`docs/direction.md`. Not part of the live FIFA 17 path.
|
|
||||||
- **`docs/foundational-xi-injection-test.md`** — a planned (not executed) test procedure for the FLE
|
|
||||||
bridge route.
|
|
||||||
|
|
||||||
## Stubbed / blocked (FIFA 23 lineage)
|
|
||||||
|
|
||||||
- **`openfut-bridge`** — in-process `version.dll` hook on ProtoSSL. Git history: injection works but
|
|
||||||
the effort hit an "architectural wall" (async event-driven gate, not a poll). Superseded first by
|
|
||||||
the FLE-bridge pivot, then by the FIFA 17 route. Its `CLAUDE.md` task list is historical.
|
|
||||||
|
|
||||||
## Unknown / requires investigation
|
|
||||||
|
|
||||||
- Whether the FIFA 17 hub supports actually **playing a FUT match** offline and getting results back.
|
|
||||||
- Whether FUT actions beyond the now-verified pack reveal/assignment flow (submit SBC, transfer
|
|
||||||
market buy/sell, matches) round-trip correctly through `utas_server.py`.
|
|
||||||
- The exact division of FUT state ownership once Core is wired in (who is source of truth).
|
|
||||||
- Degree of FIFA 23 wire-format identity — asserted ("identical wire format") but the FIFA 23 client
|
|
||||||
has not been re-tested against these responders in this repo's evidence.
|
|
||||||
|
|
||||||
## Known technical debt / hazards
|
|
||||||
|
|
||||||
- **Root docs are stale.** `README.md`, `CLAUDE.md`, `openfut-bridge/CLAUDE.md` all describe FIFA 23
|
|
||||||
as the target and mark FIFA 23 integration as the open item — they predate the FIFA 17 success.
|
|
||||||
- **All host state is volatile** across reboot except the `/etc/hosts` line — re-run
|
|
||||||
`openfut-fut.sh start`. Requires `ptrace_scope=0` + root arming (security-relevant).
|
|
||||||
- **Whole stack rides on EAAC staying neutralized** and game updates being off; a client update can
|
|
||||||
break the memory patches (VAs) and cert bypass.
|
|
||||||
- **`fifa17-recon/tools/lsx_responder_v2.py` is currently modified in the working tree** (uncommitted).
|
|
||||||
- `33068179` / `CAGE` remains the responder fallback, but the launcher now blocks one-button launch
|
|
||||||
until an explicit persona is configured and synchronized across LSX, Blaze, POW, and UTAS.
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
# Python Retirement Readiness (post-P1)
|
|
||||||
|
|
||||||
Classification of every Python component still present. Evidence: prod-host
|
|
||||||
`owner=` dispatch log (OBSERVED), AuxServiceMap/PythonContractMap scouts,
|
|
||||||
container `entrypoint.sh`. **P2 rollback stays hot regardless** (do NOT remove
|
|
||||||
rollback-to-python-p2.sh, :p2-rollback image b1b929953f, profile 39bb3e83).
|
|
||||||
|
|
||||||
## A. LIVE PRODUCTION REQUIRED (still owns behavior in the live flow)
|
|
||||||
| Component | Role | Rust status | Retire when |
|
|
||||||
|---|---|---|---|
|
|
||||||
| oracle utas_server.py (:8199) NON-ECONOMY | **remaining**: user/club rename, non-active squad/<n>, draft/*, mode-gated leaderboards + /sbs/* | **Most non-economy migrated 2026-08-17** (account/sync, auth, userMassInfo, user, clientdata, hub, club/stats/*, settings, accountinfo, phishing, match/reset, leaderboards/options, watchList, static acks, item-defs (item/resource,defid), marketdata (+/pricelimits), and the flag-off empty reads season/tournament/champion/clubUser/user/list → `{}` — all Rust). See PRODUCTION_AUTHORITY_MATRIX | remaining tail is mutation (user/club), no-Core-model (squad/<n>), or unimplemented modes (draft/leaderboards/sbs) |
|
|
||||||
| blaze_responder_v3b.py (:42130 Blaze) | FUT Blaze transport | Rust openfut-blaze-host COMPLETE, gate-proven (Gate 10) | container cutover (operator-gated deploy) |
|
|
||||||
| blaze_responder_v3b.py (:42127 redirector TLS) | first-hop TLS redirect | Rust openfut-redirector-host COMPLETE (OpenSSL) | container cutover + cert consistency |
|
|
||||||
| roster_server.py (:8081) | roster-update XML | Rust openfut-roster-host COMPLETE (unit-only) | container cutover |
|
|
||||||
| pow_server.py (:8094/:8080) | Proof-of-Work / content | NO Rust crate; 58 templates, bodies un-reversed | needs Rust host + body RE (BLOCKED) |
|
|
||||||
|
|
||||||
## B. ORACLE ONLY (reference / differential, not production authority)
|
|
||||||
- utas_server.py economy handlers: now oracle-only (economy is Rust; Python economy hits = 0). Used by
|
|
||||||
`economy_differential.rs` as the parity oracle. KEEP.
|
|
||||||
- fut_store.py / fut_accounts.py / fut_consumables.py: reference models + Ghidra-derived taxonomy source. KEEP.
|
|
||||||
|
|
||||||
## C. MIGRATION TOOL ONLY (offline)
|
|
||||||
- scripts/*.py (seed_fifa17_cards.py, utas-observe/mutate/diff, check-*). KEEP (dev tooling).
|
|
||||||
- fifa17-recon/tools/db_dump.py etc.: table extraction. KEEP.
|
|
||||||
|
|
||||||
## D. ROLLBACK ONLY (hot, DO NOT REMOVE)
|
|
||||||
- rollback-to-python-p2.sh; image openfut-fut-backend:p2-rollback (b1b929953f) + :dev; profile 39bb3e83;
|
|
||||||
compose/env backup; tuple OPENFUT_SERVERS="blaze roster utas pow".
|
|
||||||
|
|
||||||
## E. DEAD (advertised but not followed in current FIFA17 flow)
|
|
||||||
- Nucleus /connect/token stub (:42131): 0 live hits across Gate 5-10 (client never POSTs). Keep the ADVERTISED
|
|
||||||
URL so the client can't reach real EA, but no migration needed. No Rust listener required.
|
|
||||||
- fifa-blaze (FIFA23 capture stub), openfut-bridge (FIFA23): retired lineage, not in FIFA17 flow.
|
|
||||||
|
|
||||||
## Retirement gating
|
|
||||||
1. **Mostly DONE (2026-08-17)**: account/sync, ut/auth (Rust SID mint), userMassInfo (full), clientdata,
|
|
||||||
club/stats/{country,league,team}, watchList, static acks, the flag-off empty reads
|
|
||||||
(season/tournament/champion/clubUser/user/list → `{}`), item-defs (item/resource,defid), and
|
|
||||||
marketdata (+/pricelimits, container-type-exact) migrated + deployed. **Remaining on Python**:
|
|
||||||
user/club rename (mutating; needs a Core write), non-active squad/<n> (no multi-squad Core model),
|
|
||||||
and the unimplemented modes draft/* + leaderboards + /sbs/* (need the mode logic ported, not a proxy).
|
|
||||||
2. Deploy Rust blaze/roster/redirector via container cutover (operator-gated) — then those Python responders
|
|
||||||
are class D/E only.
|
|
||||||
3. POW: build Rust host + reverse bodies (largest blocker) OR keep Python POW as class A indefinitely.
|
|
||||||
4. Only after a long stable window: consider retiring the P2 rollback (separate explicit decision — NOT now).
|
|
||||||
@@ -1,250 +0,0 @@
|
|||||||
# OpenFUT — Direction Document
|
|
||||||
*The pivot: FUT lives in the app; FIFA 23 is the match renderer.*
|
|
||||||
*Supersedes the Blaze-backend approach as the primary plan. Last updated 2026-06-30.*
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Goal (revised)
|
|
||||||
|
|
||||||
Deliver an **intuitive way to play a FUT-style experience with FIFA 23**, where:
|
|
||||||
|
|
||||||
- The entire **FUT experience** — cards, squads, packs, SBCs, coins, chemistry,
|
|
||||||
progression — lives in a **custom app** (web UI or desktop) built on the
|
|
||||||
already-complete OpenFUT Core economy backend.
|
|
||||||
- **FIFA 23 is demoted to a match renderer.** Its only job is to play a
|
|
||||||
single-player match using the squad the app built. No FUT mode, no online, no
|
|
||||||
Blaze, no EA servers.
|
|
||||||
|
|
||||||
This deliberately drops in-game FUT cards/UI (they live in the app) in exchange
|
|
||||||
for a project that **converges** instead of being gated behind months of
|
|
||||||
backend reverse-engineering.
|
|
||||||
|
|
||||||
### Why this replaces the backend plan
|
|
||||||
|
|
||||||
The status review confirmed the backend route (faking EA's online stack) is
|
|
||||||
blocked at an upstream in-process EbisuSDK gate, with Blaze/Fire2 unconfirmed
|
|
||||||
beyond it — realistically 3–6 months of expert RE that may not converge. The
|
|
||||||
app-centric route sidesteps **every** wall in that review by never making FIFA's
|
|
||||||
own FUT mode run.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Base mode: Career, not Kick-Off
|
|
||||||
|
|
||||||
**Career mode is the base.** Reasons:
|
|
||||||
|
|
||||||
- FLE's live-editing API (`EditDBTableField`, Freeze Lineup) is **confirmed to
|
|
||||||
work in career mode** and explicitly does NOT work in FUT/online modes.
|
|
||||||
- Career already provides the FUT-shaped scaffolding we'd otherwise fake:
|
|
||||||
persistent club, a fixture schedule, recorded results, progression across a
|
|
||||||
season.
|
|
||||||
- **Match results are written into the career DB**, making result capture a DB
|
|
||||||
read rather than a fragile live-memory grab.
|
|
||||||
|
|
||||||
**Kick-Off is the prototype sandbox.** Use it first to prove squad injection
|
|
||||||
works with nothing to corrupt (no save to break), then move the real loop onto
|
|
||||||
career. Run the foundational injection test in BOTH.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Core architecture: the bidirectional FLE bridge
|
|
||||||
|
|
||||||
The backbone is a **bidirectional channel between the app and a resident FLE Lua
|
|
||||||
script running inside the game.** Everything else is messages over this channel.
|
|
||||||
|
|
||||||
```
|
|
||||||
Custom App (FUT experience)
|
|
||||||
│ squad push ──────────────► ┌─────────────────────────────┐
|
|
||||||
│ │ Resident FLE Lua script │
|
|
||||||
│ ◄────────── game state │ (inside FIFA 23, career) │
|
|
||||||
│ ◄────────── match result │ - reads game state │
|
|
||||||
└────────────────────────────► │ - applies squad live │
|
|
||||||
(file-watch or local socket) │ - reads results from DB │
|
|
||||||
└─────────────────────────────┘
|
|
||||||
│
|
|
||||||
FIFA 23 plays the match
|
|
||||||
```
|
|
||||||
|
|
||||||
Three message types over the bridge:
|
|
||||||
|
|
||||||
1. **App → Game: squad push.** The app's chosen XI + stats applied LIVE via
|
|
||||||
`EditDBTableField`, replicating whatever DB write FLE's "Freeze Lineup"
|
|
||||||
feature performs (see `docs/foundational-xi-injection-test.md` — the exact
|
|
||||||
field(s) are found by diffing, not assumed). No restart, no
|
|
||||||
file-copy-reload. (File-load remains a fallback.)
|
|
||||||
|
|
||||||
2. **Game → App: game state.** The resident script polls the game's current
|
|
||||||
screen/menu state and reports "safe to apply" vs "not safe", driving a smart
|
|
||||||
Apply button in the app (see §5).
|
|
||||||
|
|
||||||
3. **Game → App: match result.** After full-time, the script reads the result
|
|
||||||
from the career DB and pushes score/scorers to the app, which awards
|
|
||||||
coins/progression. (Manual entry is the baseline fallback.)
|
|
||||||
|
|
||||||
The bridge transport can be a watched file the in-game Lua polls, or a local
|
|
||||||
socket — decided in build (see §7). Either way the *game keeps running*; a file,
|
|
||||||
if used, is just the message channel, not a reload.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Tiered mod scope
|
|
||||||
|
|
||||||
Build in tiers matched to risk. The core tier is all the SAME kind of DB write,
|
|
||||||
so it lands together once squad injection works.
|
|
||||||
|
|
||||||
### Tier 1 — Core writes (ride the same live DB-edit mechanism)
|
|
||||||
- **Squad / custom XI** — the load-bearing primitive (Freeze Lineup's
|
|
||||||
underlying write, replicated via script — see §6).
|
|
||||||
- **Player stats as "cards"** — card tiers, in-form versions, SBC upgrades all
|
|
||||||
expressed as written attribute values.
|
|
||||||
- **Chemistry as stat adjustment** — app computes FUT chemistry, applies it as
|
|
||||||
small stat bumps when writing players in (no in-game chem UI; that's in the app).
|
|
||||||
- **Appearance / identity** — kits, names, team assignment, so the club looks
|
|
||||||
like your club on the pitch.
|
|
||||||
- **Formation / tactics** — squad structure carries the app's build onto the pitch.
|
|
||||||
|
|
||||||
### Tier 2 — Confirm-then-add
|
|
||||||
- **Match difficulty per game** — to drive a Squad-Battles-style "this opponent is
|
|
||||||
World Class". Settable in-game trivially; programmatic drive needs confirming.
|
|
||||||
- **Match rules / modifiers** (half length, etc.) — for app-defined challenges.
|
|
||||||
|
|
||||||
### Tier 3 — Result capture (manual baseline + automated stretch)
|
|
||||||
- **Manual:** user enters the score in the app after the match. Zero RE, ships
|
|
||||||
first.
|
|
||||||
- **Automated:** resident script reads the career-DB result (or, for Kick-Off,
|
|
||||||
reads the in-match score from memory at full-time — precedent exists: the
|
|
||||||
CM cheat table's `export_season_stats.lua` already reads goals/cards from
|
|
||||||
memory via known offsets). Push to app → auto-award progression.
|
|
||||||
|
|
||||||
### Out of scope (stays in the app, by design)
|
|
||||||
- In-game FUT cards, FUT menus, pack-opening animation, chemistry board, FUT
|
|
||||||
presentation. The app is where it looks/feels like FUT.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. The smart Apply button (state-aware)
|
|
||||||
|
|
||||||
Live DB edits only "stick" in safe menu states (the in-game "Edit Player" screen,
|
|
||||||
for example, overwrites edits). So the bridge reads game state and gates applying:
|
|
||||||
|
|
||||||
- Resident Lua script polls the game's current-screen value (a few Hz),
|
|
||||||
classifies **safe / not safe**, reports to the app.
|
|
||||||
- App's **Apply button is enabled only when the script confirms a safe state**
|
|
||||||
(squad hub, main menu); greyed otherwise.
|
|
||||||
- **Safe-by-default-OFF:** unknown state → button greyed → never a risky write.
|
|
||||||
Expand the known-safe list incrementally as states are confirmed.
|
|
||||||
- **v2 (more seamless):** instead of greying, the app always lets you click and
|
|
||||||
the script **queues** the apply, executing the moment a safe state is entered,
|
|
||||||
then confirms back. Greying is v1; queue-and-apply is v2.
|
|
||||||
|
|
||||||
`IsInCM()` is a confirmed state-read; the specific screen-state address + the
|
|
||||||
value→screen mapping is one-time reconnaissance (same technique as result reading).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. What's confirmed vs what needs validating
|
|
||||||
|
|
||||||
**Confirmed (from FLE's own Lua API docs/wiki, checked 2026-06-30):**
|
|
||||||
- FLE live-edits the running career DB without restart, via `EditDBTableField`
|
|
||||||
(real signature: `EditDBTableField(cell)` where `cell = row["fieldname"]`
|
|
||||||
with `.value` mutated first — not the table/index/field/value form an
|
|
||||||
earlier draft of this doc assumed).
|
|
||||||
- FLE reads game state via `IsInCM()`.
|
|
||||||
- A `MEMORY` Lua class exists (`ReadInt`/`WriteInt`/`ReadMultilevelPointer`/
|
|
||||||
etc.) for arbitrary process memory — confirms the result-reading fallback
|
|
||||||
in §4 Tier 3 is a real, documented capability, not just cheat-table analogy.
|
|
||||||
- `GetPlayersStats()` is a documented function returning per-player
|
|
||||||
goals/assists/cards/etc. — a better confirmed path for match-result capture
|
|
||||||
than raw memory offsets.
|
|
||||||
- **Freeze Lineup** (Formation Editor → arrange XI → tick "Freeze Lineup" →
|
|
||||||
`Data → Save`) is FLE's actual documented mechanism for forcing a starting
|
|
||||||
XI in career mode. This **replaces** "selection bias" below.
|
|
||||||
- OpenFUT Core (economy) is complete and tested.
|
|
||||||
|
|
||||||
**Walked back — not actually confirmed:**
|
|
||||||
- "Selection bias forces specific players into the starting XI" — no such
|
|
||||||
field appears anywhere in FLE's documented Lua API or its own example
|
|
||||||
scripts. This was an unverified assumption carried over from general FIFA
|
|
||||||
modding precedent (other titles), not anything checked against FLE/FIFA 23.
|
|
||||||
See `docs/foundational-xi-injection-test.md` for the corrected plan, which
|
|
||||||
uses Freeze Lineup instead.
|
|
||||||
|
|
||||||
**Needs validating (the foundational tests — see §7):**
|
|
||||||
- Whether Freeze Lineup actually holds into a played match (FLE's wiki
|
|
||||||
documents the feature but not a live-match test of it).
|
|
||||||
- What DB table/field Freeze Lineup's `Data → Save` actually writes — it's
|
|
||||||
GUI-only and undocumented at that level; finding it is part of the
|
|
||||||
foundational test.
|
|
||||||
- Whether that write can be replicated by a script (`EditDBTableField`) well
|
|
||||||
enough to drive it from an EXTERNAL trigger, not just the Formation Editor
|
|
||||||
UI — required for the app↔game bridge.
|
|
||||||
- The app↔game bridge transport (file-watch vs socket) works cleanly under the
|
|
||||||
run setup.
|
|
||||||
- The screen-state address + safe/not-safe classification (FLE's `Events`
|
|
||||||
API page exists in the wiki index but its content is currently empty/
|
|
||||||
undocumented — this is more open than previously assumed).
|
|
||||||
- Result read-back from the career DB after a match.
|
|
||||||
|
|
||||||
**Standing caveat:** the whole stack rides on **EAAC staying neutralized**
|
|
||||||
(FLE's fake-launcher bypass). If a game update re-enables it, hooks fail. Keep
|
|
||||||
game updates off; confirm neutralized state each session.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Build order / next steps
|
|
||||||
|
|
||||||
Each is a bounded, verifiable step. Do them in order; later ones depend on
|
|
||||||
earlier answers.
|
|
||||||
|
|
||||||
1. **FOUNDATIONAL TEST — live custom XI in career.** Confirm Freeze Lineup
|
|
||||||
holds into a played match, reverse-engineer the DB write it makes, then
|
|
||||||
replicate that write from a script so it can be triggered externally
|
|
||||||
instead of through the Formation Editor UI. See
|
|
||||||
`docs/foundational-xi-injection-test.md` for the full procedure. *Done =
|
|
||||||
a script-driven write produces a match that fields the squad you
|
|
||||||
specified.* Everything rests on this.
|
|
||||||
|
|
||||||
2. **Pick the bridge transport.** Decide file-watch vs local socket for app↔game
|
|
||||||
messaging; implement the minimal app→game squad push. *Done = app sends a
|
|
||||||
squad, the resident script receives and applies it.*
|
|
||||||
|
|
||||||
3. **Game-state reader + smart Apply.** Find the screen-state address, classify
|
|
||||||
safe/not-safe, expose to the app, gate the Apply button. *Done = button greys
|
|
||||||
when you enter a match/edit screen, enables in the squad hub.*
|
|
||||||
|
|
||||||
4. **Result read-back.** Read the career-DB match result post-game, push to app,
|
|
||||||
award progression. Manual entry ships alongside as the fallback. *Done = app
|
|
||||||
updates coins from a played match.*
|
|
||||||
|
|
||||||
5. **Tier 1 breadth.** Extend the squad push to carry stats, appearance,
|
|
||||||
formation (same write mechanism). *Done = the club looks and plays like the
|
|
||||||
app's build.*
|
|
||||||
|
|
||||||
6. **Tier 2 + economy loop polish.** Difficulty drive, challenges, and the full
|
|
||||||
pack → SBC → squad → match → reward loop closed end-to-end.
|
|
||||||
|
|
||||||
### Decision still open
|
|
||||||
- **App form factor:** web UI vs desktop app. This affects the bridge transport
|
|
||||||
(a desktop app can hold a local socket more naturally; a web UI leans toward a
|
|
||||||
small local helper/file-watch). Decide before step 2.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Provenance
|
|
||||||
|
|
||||||
Clean-room throughout. This route relies on FLE's documented public API and the
|
|
||||||
game's own supported career mode — no EA backend, no Blaze, and nothing derived
|
|
||||||
from leaked EA source. The earlier backend RE remains clean-room and is preserved
|
|
||||||
as a spec artifact; it is simply no longer the primary path.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. One-paragraph summary
|
|
||||||
|
|
||||||
OpenFUT becomes a **FUT companion app that uses FIFA 23 as a match engine.** The
|
|
||||||
app owns the entire FUT experience; a resident FLE Lua script in career mode
|
|
||||||
applies the app's squad live (no restart), reports game state to drive a safe
|
|
||||||
Apply button, and reads match results back to feed progression. This sidesteps
|
|
||||||
every backend wall, runs on confirmed FLE capabilities, builds on the finished
|
|
||||||
economy core, and delivers the intuitive, offline, FUT-flavored loop that is the
|
|
||||||
actual goal.
|
|
||||||
@@ -1,443 +0,0 @@
|
|||||||
# FIFA 17 — Empty "My Packs" Client Contract (store/purchasegroup)
|
|
||||||
|
|
||||||
> **STATUS (2026-08-13): ROOT CAUSE ESTABLISHED; P2 backend compatibility workaround
|
|
||||||
> IMPLEMENTED.** Root cause: FIFA 17's Store/Scaleform path resolves the `mypacks`
|
|
||||||
> category even with zero unopened packs, and CardsDLL `FUN_1800147f0` assumes the
|
|
||||||
> resolved group is non-null (crash if absent). Backend decision: **P2** — emit an
|
|
||||||
> **active** non-openable synthetic `mypacks` placeholder (id 65534) when
|
|
||||||
> `unopenedPackIds == []` (`fifa17-recon/tools/utas_server.py` `store_catalog`; tests
|
|
||||||
> `fifa17-recon/tools/test_empty_mypacks.py`). Crash-safe + economy-safe; known UX
|
|
||||||
> limitations (fake tile, click-dialog, Browse→My-Packs nav quirk) are Scaleform-driven
|
|
||||||
> and require the client-side fix in `docs/plans/FIFA17_EMPTY_MYPACKS_CLIENT_FIX.md`.
|
|
||||||
> This is a FIFA-17-specific compatibility shim, NOT an EA-authentic representation,
|
|
||||||
> and is confined to the FIFA-17 adapter/backend (NOT OpenFUT Core).
|
|
||||||
|
|
||||||
Evidence labels: **OBSERVED** (runtime capture / crash dump / already-decompiled RE
|
|
||||||
quoted in-repo), **INFERRED**, **HYPOTHESIS**, **UNKNOWN**. No server behavior is
|
|
||||||
changed by this document; it is analysis only.
|
|
||||||
|
|
||||||
Binaries (hashes verified 2026-08-13 on `.105`):
|
|
||||||
`CardsDLL_Win64_retail.dll` SHA-256 `4706a881ae1fc7b5769fd810b25a868d29d2b16a8e65a7513436327ef645573c`
|
|
||||||
(load base in the crash dump `0x00006FFFFC120000`; RE-space base `0x180000000`).
|
|
||||||
`FIFA17.exe` SHA-256 `29c31cef12b0c3c2a7305220617c7b4fa139ab76b8c857851bdbe88987962899` (packed).
|
|
||||||
|
|
||||||
## 1. Question
|
|
||||||
|
|
||||||
How must the server represent an account that owns **zero unopened packs** in
|
|
||||||
`GET /ut/game/fifa17/store/purchasegroup/all?ppInfo=true` so that FIFA 17 neither
|
|
||||||
(a) crashes nor (b) shows "The pack you've selected is currently not available",
|
|
||||||
**without granting the user a real/openable pack** and without breaking the normal
|
|
||||||
bronze/gold/special store? The current OpenFUT answer (a synthetic inactive `mypacks`
|
|
||||||
sentinel) only downgrades a crash to a dialog; it is not the correct contract.
|
|
||||||
|
|
||||||
## 2. Established experimental behavior (OBSERVED)
|
|
||||||
|
|
||||||
Profile-state ladder, all with normal packs 1/5/6/7 unchanged:
|
|
||||||
|
|
||||||
| profile `unopenedPackIds` | `mypacks` group in response | client outcome |
|
|
||||||
|---|---|---|
|
|
||||||
| `[]` (baseline) | one **inactive** sentinel pack `65534`, empty description | "pack not available" dialog → FUT Hub |
|
|
||||||
| `[70]` (Exp A) | one **active** real owned pack `70` | **store works**; My Packs visible |
|
|
||||||
| `[]` + sentinel suppressed (Exp B) | **no `mypacks` group at all** | **client CRASH** |
|
|
||||||
|
|
||||||
Captures: `store_purchasegroup_capture_2026-08-12.json` (baseline),
|
|
||||||
`…_mypacks70_2026-08-12.json` (A), `…_empty_no_sentinel_2026-08-12.json` (B).
|
|
||||||
Details in `STORE_TILE_6C.md` §14-§15.
|
|
||||||
|
|
||||||
## 3. Existing RE evidence (from `docs/plan-2026-08-05-store-subsystem.md`, decompiled)
|
|
||||||
|
|
||||||
All addresses RE-space (CardsDLL base `0x180000000`):
|
|
||||||
- **`FUN_18013af30`** — per-element pack deser; each `purchase[]` entry → a `0x158`
|
|
||||||
wire record. `displayGroup.value`(atom 0x377) → record `+0x00` (ctor default the
|
|
||||||
literal `"unknown"`). `displayGroup.priority`(0x250) → `+0x34`.
|
|
||||||
(store-subsystem §"wire record", :996.)
|
|
||||||
- **`FUN_1800150d0`** — group builder. Walks `purchase[]` in array order; for each
|
|
||||||
pack, finds-or-creates a `0x108` display group by **exact strcmp of
|
|
||||||
`displayGroup.value` against `group+0x70`** (`FUN_180014380`). New group
|
|
||||||
(`FUN_180012950`): `+0x00` = 1-based ordinal (groups-so-far+1), `+0x100` =
|
|
||||||
priority, **`+0x104` = (value == "mypacks")**, `+0x40` = vector of `0x1a8` tile
|
|
||||||
models. Pack→tile via `FUN_18002c3c0`. (:152-161, :1042-1049.)
|
|
||||||
- **`FUN_1800147f0`** — group **resolver/renderer**, called as
|
|
||||||
`FUN_1800147f0(model, screen+0x290, dataProvider, 0, 0)` from `FUN_18007dab0`.
|
|
||||||
`screen+0x290 == 0` → "list the group tiles" (`FUN_180014610`); **any other value
|
|
||||||
→ `FUN_180014420`, which exact-matches `group+0x00` (the ordinal) and returns NULL
|
|
||||||
on a miss, after which `FUN_1800147f0` dereferences `[RAX+0x40]` with NO guard**
|
|
||||||
("checked in raw disassembly … a real absence"). **"The only legal category values
|
|
||||||
are 0 and the ordinals 1..N."** (:163-169, :1051-1054.)
|
|
||||||
- **`FUN_180014580`** — the six-tab bar: switch 0..5 over the hardcoded lowercase
|
|
||||||
literals `mypacks, points, bronze, silver, gold, special`. `FUN_18007e5e0` gives
|
|
||||||
each panel a `PANEL_ID` = the matching group's ordinal, **or HIDES the panel** if no
|
|
||||||
matching group; `FUN_18007df60` publishes `MYPACK_/…/SPECIAL_CATEGORY_ID`.
|
|
||||||
(:202-206, :1058-1062.)
|
|
||||||
|
|
||||||
## 4. Store parser code path (OBSERVED, decompiled)
|
|
||||||
|
|
||||||
```
|
|
||||||
HTTP 200 {"purchase":[...]} (server: store_catalog / _pack_body)
|
|
||||||
→ per-element deser FUN_18013af30 → 0x158 wire records
|
|
||||||
→ FUN_1800150d0 → 0x108 display groups (by displayGroup.value),
|
|
||||||
tiles (0x1a8, FUN_18002c3c0) into group+0x40
|
|
||||||
→ render FUN_18007dab0 → FUN_1800147f0(model, screen+0x290, …)
|
|
||||||
screen+0x290 == 0 → FUN_180014610 list this group's tiles
|
|
||||||
screen+0x290 == N>0 → FUN_180014420 exact-match ordinal; NULL on miss
|
|
||||||
→ [RAX+0x40] dereference (NO NULL GUARD) ← crash site
|
|
||||||
```
|
|
||||||
|
|
||||||
## 5. My Packs group construction (OBSERVED)
|
|
||||||
|
|
||||||
A `mypacks` group exists **iff at least one `purchase[]` entry carries
|
|
||||||
`displayGroup.value == "mypacks"`** (the group is derived from packs; there is no
|
|
||||||
independent group object on the wire). Its ordinal is its 1-based creation position
|
|
||||||
in array order; `group+0x104` is set because the value is `"mypacks"`; its tiles live
|
|
||||||
in `group+0x40`. Consequently the server **cannot** emit an "empty `mypacks` group"
|
|
||||||
via `purchase[]` — removing the pack removes the group entirely.
|
|
||||||
|
|
||||||
## 6. Default / selected group logic (partly UNKNOWN)
|
|
||||||
|
|
||||||
- `FUN_1800147f0` resolves whatever category ordinal it is handed via `screen+0x290`;
|
|
||||||
legal values are `0` (list tiles) and `1..N` (existing ordinals). A value that is
|
|
||||||
not an existing ordinal (e.g. `-1` for a hidden/absent panel) → `FUN_180014420`
|
|
||||||
NULL → crash. (OBSERVED via §8 crash + RE.)
|
|
||||||
- **Whether the store defaults to / auto-resolves the `mypacks` category on open, and
|
|
||||||
why it does so even when the unopened count is 0, is UNKNOWN** — the store-screen
|
|
||||||
controller and default-tab selection are Scaleform/packed-FIFA17.exe
|
|
||||||
("Which tile the movie thinks you clicked | CLIENT | Scaleform, unread",
|
|
||||||
store-subsystem :695). Experiment B proves that in this configuration the client
|
|
||||||
DID resolve a `mypacks` ordinal that did not exist (it crashed), so the store is
|
|
||||||
reaching the My Packs category with zero owned packs. Root of "why" = UNKNOWN.
|
|
||||||
|
|
||||||
## 7. Pack availability predicate (UNKNOWN)
|
|
||||||
|
|
||||||
The predicate that turns the baseline inactive sentinel into "pack not available"
|
|
||||||
(while active pack 70 passes) is **in the packed FIFA17.exe and unread**
|
|
||||||
(`plan-2026-08-05-pack-opening.md:553`: `state/saleType/quantity/purchaseLimit/
|
|
||||||
purchaseCount/start/end` are parsed and copied to the tile, "the predicate that greys
|
|
||||||
a tile is in the packed exe and unread"). Candidate deciding fields, from the
|
|
||||||
baseline↔A diff (INFERRED, unproven): **`state` (`inactive`→`active`)** and/or
|
|
||||||
**`unopened` (`false`→`true`)**. The exact field is **UNKNOWN**.
|
|
||||||
|
|
||||||
## 8. Experiment B crash analysis (OBSERVED)
|
|
||||||
|
|
||||||
Minidump `CrashDump_…18.21.08…dmp` (the only crash in the hour; minute `:21` matches
|
|
||||||
the `00:21:07` store request; SHA-256 `fbddda18…`), parsed:
|
|
||||||
- Exception: **`0xC0000005` ACCESS_VIOLATION**, access type **READ**, **faulting VA
|
|
||||||
`0x0000000000000048`**.
|
|
||||||
- Faulting instruction: **`CardsDLL_Win64_retail.dll + 0x14882`** → RE-space
|
|
||||||
**`0x180014882`** = **`0x92` bytes into `FUN_1800147f0`** (entry `0x1800147f0`).
|
|
||||||
- Interpretation (OBSERVED crash ⋂ decompiled RE): the group pointer returned by
|
|
||||||
`FUN_180014420` was **NULL** (no `mypacks` group present with `unopenedPackIds==[]`
|
|
||||||
and the sentinel suppressed), and `FUN_1800147f0` dereferenced `[NULL+0x48]` → read
|
|
||||||
of address `0x48` → access violation. This is exactly the "no null guard" branch
|
|
||||||
the RE flagged (RE said `[RAX+0x40]`; the actual faulting offset is `+0x48`, same
|
|
||||||
member region — the group struct's `+0x40` vector accessed via a `+0x48` field).
|
|
||||||
- Note: the pre-built Ghidra project and `/tmp/fut/cardsdll.dll` were absent on `.105`
|
|
||||||
(tmp cleared); confirmation used the OBSERVED crash dump + the previously-decompiled
|
|
||||||
RE rather than a fresh (expensive) re-analysis. CardsDLL is unpacked, so this code
|
|
||||||
is statically readable if a fresh project is ever needed.
|
|
||||||
|
|
||||||
## 9. Empty My Packs client contract (the answer, as far as evidence allows)
|
|
||||||
|
|
||||||
- **The client has NO guard for a missing selected group.** If the store resolves the
|
|
||||||
`mypacks` category and no `mypacks` group exists, it null-derefs and crashes.
|
|
||||||
(OBSERVED.)
|
|
||||||
- **A `mypacks` group can only exist if a `purchase[]` entry carries
|
|
||||||
`displayGroup.value=="mypacks"`.** (OBSERVED.) There is no wire representation of an
|
|
||||||
"empty group."
|
|
||||||
- **If the `mypacks` group's selected pack is not a valid/active pack, the client
|
|
||||||
shows "pack not available".** (OBSERVED baseline vs A; deciding field UNKNOWN, §7.)
|
|
||||||
- Therefore, under the *current* client behavior, a zero-unopened-packs account is
|
|
||||||
only cleanly handled when the `mypacks` group contains a **valid active pack**
|
|
||||||
(Exp A). Whether an active-but-non-openable placeholder would also satisfy the
|
|
||||||
availability predicate is **UNKNOWN** (depends on §7).
|
|
||||||
- **The correct retail-EA representation of zero unopened packs is UNKNOWN.** It is
|
|
||||||
NOT "omit the group" (crash) and NOT "inactive placeholder" (dialog). It is most
|
|
||||||
likely one of: (i) the retail store does not auto-select My Packs when the unopened
|
|
||||||
count is 0 (a client/Scaleform decision, possibly gated by a count the server sets),
|
|
||||||
or (ii) retail sends a `mypacks` entry the client treats as an empty-but-valid state
|
|
||||||
via a field we have not identified. Neither is established.
|
|
||||||
|
|
||||||
## 10. Candidate server representations
|
|
||||||
|
|
||||||
| # | Candidate | Client evidence | Expected behavior | Confidence | Safe to test? |
|
|
||||||
|---|---|---|---|---|---|
|
|
||||||
| A | No `mypacks` pack, no `mypacks` group | Exp B crash (`0x180014882`, `[NULL+0x48]`) | **CRASH** | OBSERVED | Already tested — reject |
|
|
||||||
| B | `mypacks` group present but zero packs | Not representable via `purchase[]` (groups derive from packs, `FUN_1800150d0`) | UNKNOWN | INFERRED-not-representable | No server mechanism |
|
|
||||||
| C | Inactive placeholder pack (current sentinel) | Baseline dialog | "pack not available" → Hub | OBSERVED | Already tested — reject |
|
|
||||||
| C′ | **Active** placeholder pack, id absent from `PACK_CATALOG` (so open/buy handlers reject it) | Exp A shows an *active* mypacks pack works; sentinel id 65534 is already rejected by open/buy (not in `PACK_CATALOG`) | Group resolves (no crash); MIGHT pass availability (no dialog) while remaining non-openable → no free pack | HYPOTHESIS | Yes — code change, restart; economy-safe (non-openable) |
|
|
||||||
| D | Hide My Packs when unopened count == 0 | `FUN_18007e5e0` hides a panel with no group, but the store still resolved `mypacks` at count 0 (Exp B crash) | Hiding via absence CRASHES; a client count-gate is Scaleform/unknown | UNKNOWN | Not server-controllable as far as known |
|
|
||||||
| E | Different default category when count == 0 | Default-tab selection is Scaleform/packed | UNKNOWN | UNKNOWN | Not server-controllable as far as known |
|
|
||||||
| F | A count/quantities field (e.g. `ut/v2/store` `FutStorePackQuantities`, or `userInfo.unopenedPacks`) that suppresses the My Packs auto-select | eligibility gate exists (`ENDPOINT_MAP.md:60`); relationship to My Packs default UNKNOWN | UNKNOWN | HYPOTHESIS | Read-only RE first |
|
|
||||||
|
|
||||||
## 11. Recommended next controlled experiment
|
|
||||||
|
|
||||||
**Experiment C′ (economy-safe placeholder).** Keep `unopenedPackIds == []`; change the
|
|
||||||
synthetic sentinel `65534` ONLY in `state` (and, if needed, `unopened`) so the
|
|
||||||
`mypacks` group's single tile is **active** — but leave its id `65534` **absent from
|
|
||||||
`PACK_CATALOG`** so `store_buy`/`open_pack`/`consume_unopened_pack` still reject it
|
|
||||||
(no pack can be opened → **no free pack, no economy change**). Observe whether the
|
|
||||||
store then opens without the "pack not available" dialog (would identify `state` as
|
|
||||||
the availability field and give an economy-safe fix), or still shows the dialog
|
|
||||||
(implicating another field / packed predicate).
|
|
||||||
- Requires a temporary code change to `store_catalog` (sentinel construction) →
|
|
||||||
restart. Same experiment discipline as Experiment B (patch container copy, capture,
|
|
||||||
revert, restart). Economy-safe because the placeholder remains non-openable.
|
|
||||||
- If C′ still fails, escalate to read-only RE of the availability predicate / the
|
|
||||||
count-gated default-tab hypothesis (candidate F) before any further change.
|
|
||||||
|
|
||||||
## 12. Open questions
|
|
||||||
|
|
||||||
1. Exact field that flips the sentinel from "pack not available" to acceptable
|
|
||||||
(`state`? `unopened`? another). UNKNOWN — packed predicate. (Exp C′ targets this.)
|
|
||||||
2. Why does the store resolve/select `mypacks` with zero owned packs? Is there a
|
|
||||||
server-settable count that would stop it? UNKNOWN — Scaleform/packed.
|
|
||||||
3. Does retail FIFA 17 ever present an empty My Packs, and how? No capture on record.
|
|
||||||
4. Is an active-but-non-openable placeholder (C′) accepted by the availability
|
|
||||||
predicate? HYPOTHESIS — untested.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Permanent-fix requirements (Phase 11 — REPORT ONLY, not implemented)
|
|
||||||
|
|
||||||
A correct permanent fix MUST satisfy ALL of:
|
|
||||||
- Zero unopened packs must **NOT** grant the user a free pack.
|
|
||||||
- No synthetic **openable** reward may be created (any placeholder must be rejected by
|
|
||||||
`store_buy`/`open_pack`/`consume_unopened_pack`).
|
|
||||||
- Client must **not crash** (a resolvable `mypacks` group must exist, OR the client
|
|
||||||
must be kept from resolving `mypacks` when empty).
|
|
||||||
- Client must **not** show "The pack you've selected is currently not available".
|
|
||||||
- Normal Bronze/Gold/Special store categories must still work unchanged.
|
|
||||||
- When a genuine unopened pack exists, My Packs must continue to work (Exp A).
|
|
||||||
- Profile/economy semantics must remain correct (no coins/nextItemId/inventory drift).
|
|
||||||
|
|
||||||
Nothing implemented. The evidence favours investigating an **economy-safe active
|
|
||||||
placeholder (C′)** and/or the **count-gated My-Packs default (F)**; it explicitly does
|
|
||||||
NOT support "grant pack 70 whenever My Packs is empty".
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## UPDATE after Experiment C′ (2026-08-13) — active non-openable placeholder tested
|
|
||||||
|
|
||||||
Executed C′: sentinel 65534 `state "inactive"→"active"` only; `unopenedPackIds==[]`;
|
|
||||||
65534 kept out of `PACK_CATALOG`. Full record in `STORE_TILE_6C.md` §16. Capture:
|
|
||||||
`store_purchasegroup_capture_active_placeholder_2026-08-12.json` (C′-vs-baseline JSON
|
|
||||||
diff = only `65534.state`).
|
|
||||||
|
|
||||||
**Resolves §7 (availability predicate), partially:** `state` **DOES participate**
|
|
||||||
(OBSERVED). `state:"active"` removed the "pack not available" dialog while the group's
|
|
||||||
existence still prevented the crash. So the earlier §7 "deciding field UNKNOWN" is
|
|
||||||
updated: **`state` (inactive vs active) is (at least) a deciding field** for the
|
|
||||||
dialog. `unopened` was NOT varied and remains untested. The full predicate may still
|
|
||||||
involve other fields, but `state` alone flips dialog→no-dialog.
|
|
||||||
|
|
||||||
**Updated candidate table verdict:**
|
|
||||||
- **C′ (active placeholder, non-openable): SUPPORTED with UX caveats — best option so
|
|
||||||
far, but NOT adopted.** No crash, no dialog, store usable, and **no automatic
|
|
||||||
transaction/open for 65534** (only a routine boot `TRANSACTIONCANCEL` no-op).
|
|
||||||
Caveats (OBSERVED): (1) the placeholder renders as a **visible empty pack tile**
|
|
||||||
("0 items, 0 bronze, 0 rares", no cover) that a user could try to open (server-safe:
|
|
||||||
opening 65534 → no-op `{}`/stale `last_pack`, no value — §16.1); (2) **navigation
|
|
||||||
gate**: from the Store "Browse Packs" entry the Bronze/Gold/Special categories are
|
|
||||||
not reachable until "My Packs" is opened first (not present with a genuine owned
|
|
||||||
pack, Exp A).
|
|
||||||
- A (real active pack 70): works cleanly but grants a real openable pack → economy
|
|
||||||
risk; rejected as the permanent fix.
|
|
||||||
- Candidate **F (count-gated My-Packs default)** gains weight: C′'s visible-empty-tile
|
|
||||||
and Browse-Packs navigation gate suggest the client is being pushed to resolve/enter
|
|
||||||
My Packs when it should not with zero packs. If a server-settable count (e.g.
|
|
||||||
`userInfo.unopenedPacks` / `ut/v2/store` quantities) suppresses the My-Packs
|
|
||||||
default/tile, that could remove both the crash risk and the empty-tile artifact
|
|
||||||
without any placeholder. UNTESTED.
|
|
||||||
|
|
||||||
**Permanent fix: still NOT established.** Even though C′ is the first
|
|
||||||
crash-free/dialog-free representation, the empty-tile UX + navigation gate + the
|
|
||||||
untested "explicit placeholder selection" behavior bar adoption. Required next steps
|
|
||||||
(design/authorize separately): (a) controlled test of explicitly focusing/opening the
|
|
||||||
active placeholder; (b) investigate candidate F (count-gated My-Packs) to avoid a fake
|
|
||||||
tile entirely. Do NOT adopt `state:"active"` or "grant pack 70" as the fix on current
|
|
||||||
evidence.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Candidate F — Count-Gated My Packs Navigation (READ-ONLY investigation, 2026-08-13)
|
|
||||||
|
|
||||||
Question: can the server make FIFA decide **not** to resolve/default into My Packs
|
|
||||||
when the account owns zero unopened packs (avoiding any placeholder)?
|
|
||||||
|
|
||||||
## 1. Server-sent unopened-pack signals (inventory, OBSERVED code)
|
|
||||||
| field / endpoint | source | value source | when sent | client consumer | conf |
|
|
||||||
|---|---|---|---|---|---|
|
|
||||||
| `userInfo.unopenedPacks.recoveredPacks` (via `userMassInfo`) | `utas_server.py:409-414` | `len(unopenedPackIds)` | boot massinfo; only if count>0 **or** `_UI∈{packs,full}` (default `_UI=roster` → omitted at 0) | hub unopened-pack model / My Packs badge (`FUN…vtbl[0x4e0]`, pack-opening RE) | OBSERVED (code) |
|
|
||||||
| `/user/credits` `.unopenedPacks.recoveredPacks` | `utas_server.py:3542-3545` | `len(unopenedPackIds)` | on credits fetch; **only if count>0** | My Packs badge / CentralUnclaimedPack hub tile | OBSERVED (code+capture) |
|
|
||||||
| `/hub` body | `utas_server.py:1449-1453` | — | hub load | — (**no pack count present**) | OBSERVED |
|
|
||||||
| profile `unopenedPackIds` | `fut_store.py` | account state | internal | not wire-visible directly | OBSERVED |
|
|
||||||
`pileSize`/`store quantities` (`ut/v2/store` `FutStorePackQuantities`) exist as an
|
|
||||||
eligibility gate (`ENDPOINT_MAP.md:60`) but were **never requested** in any capture
|
|
||||||
(8h logs); they carry a store-open `result`, not a My-Packs count.
|
|
||||||
|
|
||||||
## 2. Pre-store request sequence (OBSERVED, captures)
|
|
||||||
Boot → `accountinfo → /ut/auth → settings → phishing → match/reset → userMassInfo →
|
|
||||||
PUT store/transaction/0 (TRANSACTIONCANCEL→{}) → /hub → clientdata → /user/credits →
|
|
||||||
GET /store/purchasegroup/all`. The only pack-count-bearing responses **before**
|
|
||||||
`purchasegroup` are `userMassInfo`(userInfo) and `/user/credits`.
|
|
||||||
|
|
||||||
## 3. Baseline([]) vs Experiment A([70]) pre-store diff (OBSERVED, captures)
|
|
||||||
The single profile change `[] → [70]` altered exactly one pre-store wire signal:
|
|
||||||
- `/user/credits`: **`[]` → no `unopenedPacks` member** (C′ capture, all 3 fetches:
|
|
||||||
`{"credits":…,"currencies":[…]}`); **`[70]` → `"unopenedPacks":{"preOrderPacks":0,
|
|
||||||
"recoveredPacks":1}`** (A capture line 25). OBSERVED.
|
|
||||||
- `userInfo.unopenedPacks`: same pattern (present at `[70]`, omitted at `[]` with
|
|
||||||
`_UI=roster`). INFERRED from code; A-capture credits corroborates.
|
|
||||||
- **No other pre-store field changed.**
|
|
||||||
|
|
||||||
Candidate signal:
|
|
||||||
```
|
|
||||||
Candidate: unopenedPacks.recoveredPacks (count)
|
|
||||||
Endpoint: /user/credits and userMassInfo(userInfo)
|
|
||||||
Baseline([]) value: ABSENT (i.e. zero)
|
|
||||||
Experiment A([70]) value: {preOrderPacks:0, recoveredPacks:1}
|
|
||||||
Source: utas_server.py:3542-3545 / :409-414 (= len(unopenedPackIds))
|
|
||||||
Client-visible before purchasegroup?: YES
|
|
||||||
Confidence: OBSERVED that it differs; its CONTROL over My-Packs nav = see §9
|
|
||||||
```
|
|
||||||
**Key point: this count is already CORRECT** — it reports zero (absent) when the
|
|
||||||
account is empty. OpenFUT is **not** misreporting a nonzero pack count.
|
|
||||||
|
|
||||||
## 4. Navigation / client call path (OBSERVED, decompiled RE)
|
|
||||||
`FUN_18007dab0 → FUN_1800147f0(model, screen+0x290, …)`. `screen+0x290==0` lists
|
|
||||||
group tiles; else `FUN_180014420` exact-matches the group ordinal (NULL on miss →
|
|
||||||
`[NULL+0x48]` crash). `screen+0x290` is written in exactly two CardsDLL sites: the
|
|
||||||
screen ctor `FUN_18007d1a0` writes `0`, and **`FUN_18007e7f0` case `0x7551` copies
|
|
||||||
the Flash movie message field `CATEGORY_ID` verbatim** into it
|
|
||||||
(store-subsystem :172-175, :1055-1056). So the resolved category is chosen by the
|
|
||||||
**Scaleform movie**, not by any server response field.
|
|
||||||
|
|
||||||
## 5. `GOTO_STORE_MYPACK` analysis (OBSERVED, RE)
|
|
||||||
`GOTO_STORE_MYPACK` is the **destination of the hub `CentralUnclaimedPack` tile**
|
|
||||||
(tile type 0x1c); "Nothing in the chain issues a request, and no request could
|
|
||||||
exist" (pack-opening :888-890). Whether that HUB tile appears is gated by the
|
|
||||||
unopened-pack count in the hub model (`model+0x20950`) — i.e. the count DOES control
|
|
||||||
the *hub unclaimed-pack tile*, but the operator reached the store via **Browse Packs**
|
|
||||||
/ the store screen, whose category resolution is the movie-driven `CATEGORY_ID` path
|
|
||||||
(§4), not `GOTO_STORE_MYPACK`. `GOTO_STORE_MYPACK` is a UI navigation command,
|
|
||||||
**not** a server-state-gated store-category selector.
|
|
||||||
|
|
||||||
## 6. Candidate count/flag fields — verdict per field
|
|
||||||
- `unopenedPacks.recoveredPacks`: correct at 0 when empty; controls the hub badge /
|
|
||||||
CentralUnclaimedPack tile, **not** the store's category resolver. Not a viable gate
|
|
||||||
for the store My-Packs entry.
|
|
||||||
- No other server field feeds `screen+0x290` (RE §4: only ctor-0 and movie
|
|
||||||
`CATEGORY_ID`).
|
|
||||||
|
|
||||||
## 7. EA-capture evidence
|
|
||||||
No EA-origin `purchasegroup`/`credits` capture for a zero-unopened-packs account
|
|
||||||
exists in the repo (all captures are OpenFUT-generated). EA count semantics for empty
|
|
||||||
My Packs remain **UNKNOWN**.
|
|
||||||
|
|
||||||
## 8. Where My Packs selection occurs (OBSERVED)
|
|
||||||
**Before** `purchasegroup` parsing decides content, the **Scaleform movie** decides
|
|
||||||
which category to resolve and writes it to `screen+0x290` (§4). The server's role is
|
|
||||||
limited to which groups EXIST in `purchase[]`. Therefore the sentinel is compensating
|
|
||||||
for a **movie-side** decision to resolve My Packs; it is not fixing an incorrect
|
|
||||||
server count (the count is already correct).
|
|
||||||
|
|
||||||
## 9. Candidate F verdict — **F3 (CONTRADICTED)** (with an F4 residue)
|
|
||||||
My Packs selection is **not** controlled by server-sent unopened-pack state:
|
|
||||||
- OBSERVED: the server count is correctly zero/absent when empty, yet the store still
|
|
||||||
resolved My Packs (baseline dialog, Exp-B crash). A correct zero signal did not stop
|
|
||||||
it.
|
|
||||||
- OBSERVED (RE): `screen+0x290` (the resolved category) comes from the movie's
|
|
||||||
`CATEGORY_ID`, with no server-field input; default is 0.
|
|
||||||
Residue (F4): the movie's internal logic for *why* it asks for My Packs on store open
|
|
||||||
is in packed Scaleform and is not statically readable — but no server lever into it
|
|
||||||
has been found. **Conclusion: there is no server-controlled count/flag that makes FIFA
|
|
||||||
skip resolving My Packs; the server can only ensure the `mypacks` group exists.** The
|
|
||||||
"clean count-gated fix" is therefore **not achievable server-side**.
|
|
||||||
|
|
||||||
## 10. Proposed next experiment
|
|
||||||
Because F is contradicted, a count experiment is NOT recommended (the count is already
|
|
||||||
correct and does not gate the store). No single-variable server signal will make FIFA
|
|
||||||
enter Browse Packs instead of My Packs. The realistic next step is the previously
|
|
||||||
deferred **explicit active-placeholder selection test**: with the C′ active
|
|
||||||
non-openable placeholder in place (sentinel 65534 at baseline otherwise), have the
|
|
||||||
operator explicitly focus/open the empty My-Packs tile and observe (server-safe per
|
|
||||||
§16.1 — opening 65534 is a no-op — but UX/navigation behavior unknown). That
|
|
||||||
characterizes the best available server-side option (C′) before any adoption.
|
|
||||||
- Would it change profile state? No (`unopenedPackIds=[]`).
|
|
||||||
- Would it change purchasegroup/sentinel behavior? Only `state:"active"` (as C′),
|
|
||||||
reverted after.
|
|
||||||
- Code change? Yes (same one-line C′ patch). Restart? Yes. (Not authorized here.)
|
|
||||||
If explicit selection proves unsafe/ugly, the remaining options are all **client-side
|
|
||||||
/ out-of-scope** (the decision is in the Scaleform movie), or accepting C′ with its
|
|
||||||
documented UX artifacts.
|
|
||||||
|
|
||||||
**Candidate F does NOT provide the hoped-for clean fix. The active non-openable
|
|
||||||
placeholder (C′) remains the best server-side representation; its empty-tile and
|
|
||||||
Browse-Packs navigation artifacts are movie-driven and not server-fixable.**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Explicit Active-Placeholder Selection Test — FINAL backend-side result (2026-08-13)
|
|
||||||
|
|
||||||
With the C′ active placeholder in place (`unopenedPackIds=[]`, 65534 active/mypacks/
|
|
||||||
∉PACK_CATALOG), the operator explicitly opened the empty My-Packs tile once. Full
|
|
||||||
record in `STORE_TILE_6C.md` §17.
|
|
||||||
- **Outcome: S1 — pure client-side rejection.** Dialog **"This pack is no longer
|
|
||||||
available"** → back to My Packs → Hub; **no crash**, navigation stays usable.
|
|
||||||
- **No server request** on selection (no `/store/transaction`, no `/purchased/items`,
|
|
||||||
no 65534 reference); the verdict is client-side. (OBSERVED)
|
|
||||||
- **Zero economy/profile mutation:** coins/items/nextItemId/`unopenedPackIds`/
|
|
||||||
`last_pack` all unchanged; profile byte-identical (`39bb3e83…`); 65534 not
|
|
||||||
persisted. (OBSERVED)
|
|
||||||
|
|
||||||
**Active-placeholder verdict: MARGINALLY ACCEPTABLE** — crash-safe + economy-safe +
|
|
||||||
navigable, but with user-visible defects (empty fake tile; "no longer available" on
|
|
||||||
explicit click; Browse-Packs nav gate). It is a *strict improvement* over the current
|
|
||||||
inactive-sentinel baseline (which errors on store OPEN and bounces to Hub).
|
|
||||||
|
|
||||||
**Backend-side question is now fully answered.** The complete zero-unopened-packs
|
|
||||||
ladder:
|
|
||||||
```
|
|
||||||
no mypacks group -> CardsDLL null-deref CRASH (unsafe)
|
|
||||||
inactive placeholder -> "pack not available" on store open -> Hub (baseline)
|
|
||||||
active placeholder -> store loads; empty tile; "no longer available" only on
|
|
||||||
explicit click; recoverable; economy-safe (best backend option)
|
|
||||||
real active owned pack -> fully correct UI (but grants a real openable pack — economy risk)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Permanent-fix recommendation: P2.** The active non-openable placeholder is the best
|
|
||||||
*safe* backend-only option, but a fully *clean* zero-pack experience is **not**
|
|
||||||
achievable server-side (Candidate F CONTRADICTED — the My-Packs resolution is
|
|
||||||
Scaleform/movie-driven). Recommend: adopt the active placeholder as an optional
|
|
||||||
backend compatibility mode (safe, strictly better than baseline) AND pursue a
|
|
||||||
client-side fix (hide the fake tile / stop the forced My-Packs resolution) for the
|
|
||||||
fully clean result. **Not implemented.** Do NOT grant a real pack.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Client resolver-guard experiment (2026-08-13) — RESULT F3 (crash, confounded)
|
|
||||||
|
|
||||||
A client-side `autopatch.py` memory guard (CardsDLL `0x180014858` `JNZ`→`JG`, routing
|
|
||||||
category `<0` to list-all/Browse) was tested against the exact no-sentinel server condition
|
|
||||||
(sentinel 65534 suppressed; `GET /store/purchasegroup` ids `[1,5,6,7]`, no mypacks group).
|
|
||||||
The client **crashed at the identical resolver site `0x180014882`** (`[NULL+0x48]`), because
|
|
||||||
it presented a **positive** My-Packs ordinal (crash is in the `>0` resolve branch), not the
|
|
||||||
`-1` the guard diverts. **Confound:** FIFA was not relaunched after the backend flip, so it
|
|
||||||
reused stale (sentinel-present) tab state. So the negative-only guard is **insufficient for a
|
|
||||||
positive stale/invalid ordinal**, and the fresh-client case is **not yet decided** (needs a
|
|
||||||
clean re-test: fresh launch with backend already no-sentinel). Backend P2 sentinel was
|
|
||||||
restored immediately (mandatory rollback). Full record + candidate stronger guard:
|
|
||||||
`docs/plans/FIFA17_EMPTY_MYPACKS_CLIENT_FIX.md` PART III.
|
|
||||||
|
|
||||||
## Fresh-process no-sentinel retest (2026-08-13) — RESULT R1 (SUCCESS)
|
|
||||||
|
|
||||||
Re-ran the above cleanly: backend entered no-sentinel mode **while FIFA was closed**, then a
|
|
||||||
**fresh** FIFA (pid 553220, new autopatch 552999, guard `85 ff 7f 0f` enforced) launched and
|
|
||||||
opened the Store. The genuine no-sentinel `/store/purchasegroup` (ids `[1,5,6,7]`, no 65534/
|
|
||||||
mypacks) is **byte-identical** to the F3 capture, so the only changed variable is client
|
|
||||||
process lifetime. Outcome: **no crash, no dialog, Store opens on Browse Packs, packs
|
|
||||||
navigable** (cosmetics only: no tabs / no cover art / "0 items" — pre-existing). A fresh
|
|
||||||
client publishes category `-1` for the absent group, which `JNZ→JG` routes to Browse/list-all
|
|
||||||
with no NULL deref. **This confirms F3 was stale-positive-ordinal contamination, and proves
|
|
||||||
Strategy A (resolver guard) on the tested build.** Backend P2 sentinel restored immediately
|
|
||||||
(`f416e71e…`, `state=active`) and remains production default. Full record:
|
|
||||||
`docs/plans/FIFA17_EMPTY_MYPACKS_CLIENT_FIX.md` PART IV.
|
|
||||||
@@ -1,924 +0,0 @@
|
|||||||
# Store Tile Investigation (bug 6c)
|
|
||||||
|
|
||||||
Status: **ROOT CAUSE ESTABLISHED — P2 compatibility workaround IMPLEMENTED**
|
|
||||||
(2026-08-13). Full investigation complete (§1-§17); backend fix landed in
|
|
||||||
`fifa17-recon/tools/utas_server.py` `store_catalog` with regression tests in
|
|
||||||
`fifa17-recon/tools/test_empty_mypacks.py`. The store-tiles flags are unchanged
|
|
||||||
(`FUT_STORE_DISPLAYGROUP=ON`, `FUT_STORE_GROUPID=OFF`) and the profile is unchanged.
|
|
||||||
|
|
||||||
## RESOLUTION (2026-08-13)
|
|
||||||
|
|
||||||
**ROOT CAUSE (bug 6c):** FIFA 17's Store/Scaleform path RESOLVES the `mypacks`
|
|
||||||
category even when the account owns zero unopened packs (the category is chosen
|
|
||||||
client-side from the movie's `CATEGORY_ID` → `screen+0x290`; no server field gates
|
|
||||||
it — Candidate F CONTRADICTED). CardsDLL `FUN_1800147f0` then dereferences the
|
|
||||||
resolved group with NO null guard, so an absent `mypacks` group crashes the client
|
|
||||||
(`CardsDLL_Win64_retail.dll+0x14882`, `[NULL+0x48]` — minidump-confirmed, §8).
|
|
||||||
|
|
||||||
**BACKEND RESULT / DECISION — P2 (compatibility workaround):** emit a synthetic,
|
|
||||||
**active**, non-openable `mypacks` placeholder (id 65534, absent from `PACK_CATALOG`)
|
|
||||||
only when `unopenedPackIds == []`. This is crash-safe AND economy-safe (explicit
|
|
||||||
selection is rejected client-side with "This pack is no longer available", sends no
|
|
||||||
backend request, and mutates nothing — §17). It is a FIFA-17 client-compatibility
|
|
||||||
shim, **NOT** an EA-authentic empty-My-Packs representation, and is confined to the
|
|
||||||
FIFA-17 adapter/backend layer (NOT OpenFUT Core).
|
|
||||||
|
|
||||||
**KNOWN UX LIMITATIONS (unfixable server-side):** a fake empty "0 items" tile; an
|
|
||||||
explicit-selection dialog "This pack is no longer available"; and a Browse-Packs →
|
|
||||||
My-Packs navigation quirk. These are Scaleform/movie-driven.
|
|
||||||
|
|
||||||
**CLEAN CLIENT FIX:** still unresolved; belongs to client-side work —
|
|
||||||
`docs/plans/FIFA17_EMPTY_MYPACKS_CLIENT_FIX.md`.
|
|
||||||
|
|
||||||
Evidence labels: **OBSERVED** (running code / `docker inspect` / live log / minidump /
|
|
||||||
table), **INFERRED**, **HYPOTHESIS**, **UNKNOWN**.
|
|
||||||
|
|
||||||
### Hypotheses
|
|
||||||
|
|
||||||
- **H1 (original — subtype/definition):** *Store tiles render "unknown" because the
|
|
||||||
server emits definitions whose type/subtype the client cannot map (prime suspects
|
|
||||||
5004xxx misc {231,232,233,236}, 8010xxx league logos, FCC↔wire subtype gaps).*
|
|
||||||
**Verdict: CONTRADICTED by static store-handler evidence.** The `/store/purchasegroup`
|
|
||||||
handler emits PACK definitions only — no `cardsubtypeid`/`carddbid`/`cardassetid`
|
|
||||||
anywhere in the response (§2). Those subtype families belong to the separate
|
|
||||||
**club-item / consumable / equippable** paths (`fut_clubitems.py`, `fut_consumables`,
|
|
||||||
`/club?type=`), which are OUT OF SCOPE for this store-tile task. History preserved
|
|
||||||
in §3.3 and §8; not investigated further here.
|
|
||||||
- **H2 (revised — displayGroup token):** **HYPOTHESIS (under runtime test).** *The
|
|
||||||
"unknown" FUT Store tile is caused by one or more pack entries whose
|
|
||||||
`displayGroup.value` token is not one of the six categories FIFA 17 can render:*
|
|
||||||
`mypacks, points, bronze, silver, gold, special`. Tested against a real capture in
|
|
||||||
§4-§8.
|
|
||||||
|
|
||||||
---
|
|
||||||
## Runtime capture plan (Phase 2) — OBSERVED
|
|
||||||
|
|
||||||
- **Backend container:** `openfut-fut-backend` (logs on stdout via `log()`,
|
|
||||||
`utas_server.py:59-62`; each request logs `"<VERB> <path>"` at `:3732`, and the
|
|
||||||
response line `" -> <code> <body[:200]>"` at `:3754` — **response body truncated
|
|
||||||
to 200 bytes**, so the full JSON body is NOT in the log).
|
|
||||||
- **Endpoint / path matcher:** `GET /ut/game/fifa17/store/purchasegroup/all?ppInfo=true`
|
|
||||||
(OBSERVED historically in the log; route regex `/store/purchasegroup`,
|
|
||||||
`utas_server.py:1215`).
|
|
||||||
- **Capture marker (UTC, set immediately before the manual test):**
|
|
||||||
`2026-08-12T23:54:01Z` (saved to `/tmp/store_capture_marker.txt`; 0 log lines
|
|
||||||
after it at set-time → clean boundary).
|
|
||||||
- **Log command to isolate the manual action:**
|
|
||||||
`docker logs --since 2026-08-12T23:54:01Z --timestamps openfut-fut-backend`
|
|
||||||
then locate the first `GET .../store/purchasegroup` line after the marker plus its
|
|
||||||
following headers and `-> 200 {"purchase"...` line.
|
|
||||||
- **Full-body recovery (because the log truncates at 200 bytes):** the response is a
|
|
||||||
deterministic pure function — `store_catalog()` (`:3408`) over static `PACK_CATALOG`
|
|
||||||
(`fut_store.py:820`) + live `STORE.unopened_packs()` (from `/state` profile) under
|
|
||||||
fixed flags (`STORE_DISPLAYGROUP=ON`, `STORE_GROUPID=OFF`, `FUT_PRICE_PROBE=OFF`).
|
|
||||||
Plan: reconstruct the exact body from the running `/app` code + live `/state`
|
|
||||||
profile, then **verify** its `json.dumps(...)[:200]` byte-for-byte equals the genuine
|
|
||||||
logged 200-byte prefix. Match ⇒ the reconstruction IS the sent body. No request is
|
|
||||||
synthesized, replayed, or curl'd; the genuine log line is the ground-truth anchor.
|
|
||||||
|
|
||||||
|
|
||||||
## 1. Method
|
|
||||||
|
|
||||||
### Environment (OBSERVED)
|
|
||||||
- Running on `10.10.0.120` (dev-lxc). Client is `10.10.0.105`.
|
|
||||||
- Backend under test: Docker container `openfut-fut-backend`
|
|
||||||
(image `openfut-fut-backend:dev`), `Up 7 hours`, entrypoint `/app/entrypoint.sh`.
|
|
||||||
- `docker inspect openfut-fut-backend`: only mount is
|
|
||||||
`/home/alex/OpenFUT/fifa17-recon/docker/state -> /state (rw)`; container ENV
|
|
||||||
contains **no `FUT_STORE_*`** vars.
|
|
||||||
- `entrypoint.sh` launches `python3 -u utas_server.py` from `/app/tools` with extra
|
|
||||||
env `FUT_TRADING=1 FUT_PILESIZES=1 FUT_TRADEABLE=1 FUT_DISCARD_TABLE=1
|
|
||||||
FUT_DISCARD_SEND=1` — again **no `FUT_STORE_*`**.
|
|
||||||
- The running store code is `/app/tools/utas_server.py`. Extracted read-only via
|
|
||||||
`docker cp openfut-fut-backend:/app/tools /tmp/app-tools` and confirmed
|
|
||||||
**byte-identical** (`sha256`) to the repo copy
|
|
||||||
`fifa17-recon/tools/utas_server.py` (both 3765 lines) and
|
|
||||||
`fifa17-recon/tools/fut_clubitems.py`. All line references below are to the repo
|
|
||||||
paths and equal the running code.
|
|
||||||
|
|
||||||
### Commands (exact)
|
|
||||||
```
|
|
||||||
docker ps --format '{{.Names}}\t{{.Image}}\t{{.Command}}\t{{.Status}}'
|
|
||||||
docker inspect openfut-fut-backend --format '...CMD/ENTRYPOINT/MOUNTS/ENV...'
|
|
||||||
docker exec openfut-fut-backend cat /app/entrypoint.sh
|
|
||||||
docker cp openfut-fut-backend:/app/tools /tmp/app-tools
|
|
||||||
docker cp openfut-fut-backend:/app/data /tmp/app-data
|
|
||||||
diff /tmp/app-tools/utas_server.py fifa17-recon/tools/utas_server.py # identical
|
|
||||||
diff /tmp/app-tools/fut_clubitems.py fifa17-recon/tools/fut_clubitems.py # identical
|
|
||||||
```
|
|
||||||
|
|
||||||
### Manual test sequence
|
|
||||||
NOT executed. The manual FIFA-client store capture (2D/2E) was never reached
|
|
||||||
because the investigation blocked at 2C before any flag could be enabled. No
|
|
||||||
request was synthesized, replayed, or simulated.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Store handler code path (2A) — OBSERVED
|
|
||||||
|
|
||||||
Request → response for the store tile screen:
|
|
||||||
|
|
||||||
1. **Endpoint.** `GET ut/<sku>/store/purchasegroup/...` — `FutStoreGetPackTypes`
|
|
||||||
(client deser root `0x1801234e0`). (`utas_server.py:3408-3409` docstring.)
|
|
||||||
- Route table entry: `(re.compile(r"/store/purchasegroup"), lambda m,h:
|
|
||||||
store_catalog(h))` at `utas_server.py:1215`.
|
|
||||||
- Sibling store routes: `/store/transaction -> store_buy(h)` (`:1216`, the BUY);
|
|
||||||
bare `/store(\?|$) -> (200,{"result":"SUCCESS"})` eligibility gate (`:1221`).
|
|
||||||
2. **Handler.** `store_catalog(h)` (`utas_server.py:3408-3447`).
|
|
||||||
- Iterates `PACK_CATALOG` (non-`ownedOnly` packs) → `_pack_body(p, idx)`.
|
|
||||||
- Appends owned unopened packs from `visible_unopened_packs()` →
|
|
||||||
`STORE.unopened_packs()` + `_OPENED_PACK_GRACE` (`:51-52`, `:3423-3427`).
|
|
||||||
- If no owned packs, appends one inactive `mypacks` sentinel (id 65534) so
|
|
||||||
`GOTO_STORE_MYPACK` resolves (`:3428-3446`).
|
|
||||||
- Returns `200, {"purchase": [<pack bodies>], "timestamp": 1596326400}`
|
|
||||||
(`:3447`).
|
|
||||||
3. **Definition construction.** `_pack_body(p, idx, owned=False)`
|
|
||||||
(`utas_server.py:3268-3405`). Emitted keys (OBSERVED, `:3293-3328`):
|
|
||||||
`assetId`(=`p["id"]`), `id`(=`p["id"]`), `packType`, `description`(=`p["name"]`),
|
|
||||||
`state`, `saleType`, `limitType`, `quantity`, `purchaseLimit`, `purchaseCount`,
|
|
||||||
`isPremium`, `sortPriority`, `currencies`, `extPrice`, `packContentInfo`
|
|
||||||
(`bronze/silver/gold/rare/itemQuantity`), `unopened`, and one of:
|
|
||||||
- owned pack → `displayGroup = {"value":"mypacks","priority":idx}` (`:3336`);
|
|
||||||
- else if `STORE_DISPLAYGROUP` → `displayGroup = {"value": category}` where
|
|
||||||
`category ∈ {special, gold, silver, bronze}` chosen from
|
|
||||||
`p["specialChance"]`/`p["gold"]` (`:3337`,`:3373-3379`), and if `STORE_GROUPID`
|
|
||||||
also `displayGroupAssetId = p["id"]` (`:3403-3404`).
|
|
||||||
4. **Data source(s).**
|
|
||||||
- `PACK_CATALOG` — a hardcoded list of 3 pack dicts
|
|
||||||
(`{id,name,price,count,gold,tiers,specialChance}`) at `fut_store.py:820-841`.
|
|
||||||
There is NO card table read in this path.
|
|
||||||
- `STORE = Store()` (`fut_store.py:843`), backed by the profile JSON
|
|
||||||
(`unopened_packs()` reads `unopenedPackIds`, `fut_store.py:619-621`).
|
|
||||||
5. **Serialization → HTTP.** The dict is JSON-encoded by the server's response
|
|
||||||
writer and returned as the HTTP body.
|
|
||||||
|
|
||||||
### Fields 5 (`cardsubtypeid`/`carddbid`/`cardassetid`) — OBSERVED
|
|
||||||
**None of `cardsubtypeid`, `carddbid`, or `cardassetid` appear anywhere in the
|
|
||||||
store-tile (`purchasegroup`) response.** `_pack_body` (`:3293-3405`) emits only the
|
|
||||||
pack keys listed above; `assetId`/`id` are the PACK id `p["id"]` (e.g. 1, 5), not a
|
|
||||||
card asset id. The store catalog emits PACKS, never card definitions. (Grep of
|
|
||||||
`_pack_body` and `store_catalog` for those three field names returns zero hits.)
|
|
||||||
|
|
||||||
### Families the store handler can emit
|
|
||||||
Only **packs** (`PACK_CATALOG`: "Bronze Pack" id 1, "Gold Pack" id 5, and the third
|
|
||||||
catalog entry) plus owned reward packs and the `mypacks` sentinel. It cannot emit
|
|
||||||
any card family (players, staff, consumables, club items).
|
|
||||||
|
|
||||||
### Filtering / transformation
|
|
||||||
- `normal = [p for p in PACK_CATALOG if not p.get("ownedOnly")]` (`:3421`).
|
|
||||||
- `_pack_body` maps a pack to a category token via `specialChance>=1.0 -> special`,
|
|
||||||
else `gold -> gold`, `p.get("silver") -> silver`, else `bronze` (`:3373-3379`).
|
|
||||||
- The tile caption/category is `displayGroup.value`; `description` carries the
|
|
||||||
per-pack title.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Wire subtype coverage (2B)
|
|
||||||
|
|
||||||
### 3.1 Store path
|
|
||||||
The store-tile path emits **no `cardsubtypeid`** at all (see §2). So for the store
|
|
||||||
tile, "is `cardsubtypeid` the raw FCC subtype, the wire category, transformed, or
|
|
||||||
something else?" → **not present** (N/A). The store tile is selected by
|
|
||||||
`displayGroup.value` (a category-token STRING), not by any card subtype.
|
|
||||||
|
|
||||||
Per `_pack_body:3367-3372` (citing `FUN_180014580`/`FUN_180014df0`): FIFA 17's
|
|
||||||
StoreFront resolves exactly **six hard-coded category tokens** —
|
|
||||||
`mypacks, points, bronze, silver, gold, special`. Any other `displayGroup.value`
|
|
||||||
(e.g. a raw pack title) creates an "unsupported pseudo-category". The documented
|
|
||||||
cause of "unknown" store tiles is therefore a **`displayGroup` category-token**
|
|
||||||
issue on packs (absent group, or a non-canonical token), NOT a card type/subtype.
|
|
||||||
|
|
||||||
### 3.2 Club-item path (the only place wire subtypes live) — `fut_clubitems.py`
|
|
||||||
Club items are served on the **`/club?type=` route** (`utas_server.py:2250`,
|
|
||||||
`handle_club`), gated by `FUT_CLUBITEMS`, NOT by the store route. Current `FAMILIES`
|
|
||||||
(`fut_clubitems.py:61-67`), format `(family, table, art id, stat id, stat name,
|
|
||||||
UNVERIFIED cardsubtypeid)`:
|
|
||||||
|
|
||||||
| wire subtype | mapped family | table | art id | source | confidence |
|
|
||||||
|---|---|---|---|---|---|
|
|
||||||
| 9 | kits | fcc_kitcards.json | 35 | `fut_clubitems.py:65` | HYPOTHESIS (marked "UNVERIFIED", `:49`,`:30-32`) |
|
|
||||||
| 10 | stadia | fcc_stadium.json | 36 | `fut_clubitems.py:63` | HYPOTHESIS ("UNVERIFIED") |
|
|
||||||
| 11 | badges | fcc_badgecards.json| 39 | `fut_clubitems.py:64` | HYPOTHESIS ("UNVERIFIED") |
|
|
||||||
| 30 | balls | fcc_balls.json | 37 | `fut_clubitems.py:62` | HYPOTHESIS ("UNVERIFIED") |
|
|
||||||
| 31 | leaguelogos | fcc_leaguelogos.json| 40| `fut_clubitems.py:66` | HYPOTHESIS ("UNVERIFIED") |
|
|
||||||
|
|
||||||
- The prior recorded mapping (9=kits,10=stadia,11=badges,30=balls,31=logos) is
|
|
||||||
**confirmed present in current code** — but the code itself marks every one
|
|
||||||
"UNVERIFIED cardsubtypeid" and states the binary assigns family↔subtype **nowhere**
|
|
||||||
in the 149 dumped tables; cardtype-9 admits the set `{30,31,145,146,147,148,149,150}`
|
|
||||||
(`fut_clubitems.py:26-28`, `:73`). So these are server-chosen HYPOTHESIS values.
|
|
||||||
- In the club-item wire item (`_item:88-119`), `cardsubtypeid` (`:97`) is a
|
|
||||||
**server-assigned wire-category constant** (9/10/11/30/31), NOT the raw FCC
|
|
||||||
subtype: the club-item fcc tables carry **no `cardsubtype` column at all** (Task 1:
|
|
||||||
badges/stadium/kit/logos/balls have empty subtype sets). `resourceId`/`assetId`
|
|
||||||
= `carddbid`, and `cardassetid` = the family ART id (`:94-96`). So for club items:
|
|
||||||
**`cardsubtypeid` = wire category (server constant), `carddbid`/`cardassetid` =
|
|
||||||
real fcc columns.**
|
|
||||||
- By contrast, consumables (`fut_store._item` / `fut_consumables`) DO carry the raw
|
|
||||||
FCC subtype (51..341) as `cardsubtypeid`.
|
|
||||||
|
|
||||||
### 3.3 Task-1 families vs wire-subtype coverage
|
|
||||||
Families that the **store handler** can map to a wire subtype: **none** — the store
|
|
||||||
handler emits packs, which have no card subtype (by design).
|
|
||||||
|
|
||||||
Families for which a **club-item** wire subtype exists (HYPOTHESIS-grade):
|
|
||||||
kits(9), stadia(10), badges(11), balls(30), leaguelogos(31).
|
|
||||||
|
|
||||||
Task-1 card families with **no wire subtype mapping anywhere in the server**:
|
|
||||||
- **5001xxx contracts, 5002xxx fitness/healing, 5003xxx training** — served as
|
|
||||||
consumables carrying their raw FCC subtype; these are consumable overlays, not
|
|
||||||
store tiles, and not part of any store/club-item wire-category map.
|
|
||||||
- **5004xxx misc {231,232,233,236}** — **no wire subtype mapping found** in
|
|
||||||
`fut_clubitems.py` or the store path. (Grep: no `misc` family, no {231,232,233,236}
|
|
||||||
wire assignment.) They exist only as raw FCC subtypes in consumable data.
|
|
||||||
- **8010xxx league logos/stickers** — mapped in the **club-item** path as wire
|
|
||||||
subtype **31** (`fut_clubitems.py:66`), HYPOTHESIS-grade. `fcc_leaguelogostickers`
|
|
||||||
(39 rows) is NOT wired in `FAMILIES` (only `fcc_leaguelogos`, 44 rows).
|
|
||||||
- **6000xxx badges, 6200xxx stadiums, 6300/6400xxx kits, 8120xxx balls** — mapped in
|
|
||||||
the club-item path (11/10/9/30), HYPOTHESIS-grade.
|
|
||||||
- **staff (managers/coaches, subtypes 4-8)** — served by `fut_staff` on `/club?type=
|
|
||||||
manager`, not a store tile.
|
|
||||||
|
|
||||||
Note none of these belong to the **store-tile** (`purchasegroup`) response.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2C flag investigation — BLOCKED
|
|
||||||
|
|
||||||
### Store-tiles flags located (OBSERVED)
|
|
||||||
Two, both in `utas_server.py`, both module-level constants read **once at import**:
|
|
||||||
|
|
||||||
| flag | env var | line (read) | consumed | default | current running value |
|
|
||||||
|---|---|---|---|---|---|
|
|
||||||
| `STORE_DISPLAYGROUP` | `FUT_STORE_DISPLAYGROUP` | `:975` | `_pack_body:3337` | `"1"` → **ON** | **ON** (no env override) |
|
|
||||||
| `STORE_GROUPID` | `FUT_STORE_GROUPID` | `:980` | `_pack_body:3403` | `"0"` → **OFF** | **OFF** (no env override) |
|
|
||||||
|
|
||||||
- `:975` `STORE_DISPLAYGROUP = os.environ.get("FUT_STORE_DISPLAYGROUP", "1") == "1"`
|
|
||||||
- `:980` `STORE_GROUPID = os.environ.get("FUT_STORE_GROUPID", "0") == "1"`
|
|
||||||
|
|
||||||
**Current values (recorded before any change; nothing was changed):**
|
|
||||||
- `FUT_STORE_DISPLAYGROUP`: unset in container env → default `"1"` →
|
|
||||||
`STORE_DISPLAYGROUP = True` (**ON**). (INFERRED from OBSERVED env dump + OBSERVED
|
|
||||||
code default.)
|
|
||||||
- `FUT_STORE_GROUPID`: unset in container env → default `"0"` →
|
|
||||||
`STORE_GROUPID = False` (**OFF**). This is the flag "expected to be OFF".
|
|
||||||
|
|
||||||
The related club-item flag `FUT_CLUBITEMS` (`:1647-1648`) is likewise unset →
|
|
||||||
`CLUBITEMS = False` (club items not currently served), also a module-level import
|
|
||||||
constant.
|
|
||||||
|
|
||||||
### Dynamic evaluation? NO (OBSERVED)
|
|
||||||
- All three flags are top-level `os.environ.get(...)` assignments evaluated at
|
|
||||||
module import (`:975`, `:980`, `:1647`); `_pack_body` reads the resulting module
|
|
||||||
**constants** (`:3337`, `:3403`), never `os.environ` at request time.
|
|
||||||
- Repo-wide there is **no** `importlib.reload`, no `signal`/`SIGHUP` handler, and no
|
|
||||||
per-request environ re-read for these flags. (The only runtime-refreshable feature
|
|
||||||
is `FUT_ID_SWEEP`, which re-reads a *file* `SWEEP_FILE`, `:1965-1966` — unrelated.)
|
|
||||||
- `:3250` documents the intended workflow explicitly:
|
|
||||||
`# Enable for the test with: FUT_PRICE_PROBE=1 ./openfut-fut.sh restart`.
|
|
||||||
|
|
||||||
### Restart conflict → STOP
|
|
||||||
Changing either store flag requires either setting a container env var (→
|
|
||||||
`docker` recreate = restart) or editing the module (→ re-import = restart). Both
|
|
||||||
violate the no-restart / no-redeploy / no-recreate constraint. Therefore:
|
|
||||||
|
|
||||||
```
|
|
||||||
TASK 2 BLOCKED AT 2C:
|
|
||||||
Flag requires restart, conflicting with no-restart constraint.
|
|
||||||
```
|
|
||||||
|
|
||||||
No flag was enabled. 2D/2E (manual client capture + definition analysis) NOT
|
|
||||||
started. No client request generated, replayed, or simulated.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Captured request and response (Phase 3) — OBSERVED
|
|
||||||
|
|
||||||
Real client action (operator opened the FUT Store on `.105`, 2026-08-12). Only one
|
|
||||||
`/store/purchasegroup` request occurred after the capture marker
|
|
||||||
`2026-08-12T23:54:01Z`, so attribution is unambiguous (only the operator drives the
|
|
||||||
client). Log via `docker logs --since 2026-08-12T23:54:01Z --timestamps openfut-fut-backend`.
|
|
||||||
|
|
||||||
- **Request:** `23:54:43 GET /ut/game/fifa17/store/purchasegroup/all?ppInfo=true`
|
|
||||||
(Host `10.10.0.120:8099`, `User-Agent: ProtoHttp 1.3/DS 15.1.2.1.0 (Windows)`,
|
|
||||||
`X-UT-SID` present). Preceded at 23:54:43 by `GET /ut/game/fifa17/user/credits`
|
|
||||||
→ `200 {"credits": 29876776, ...}`.
|
|
||||||
- **Response:** `200`. The server log truncates the body to 200 bytes
|
|
||||||
(`utas_server.py:3754`, `raw[:200]`), genuine prefix:
|
|
||||||
`{"purchase": [{"assetId": 1, "id": 1, "packType": "BRONZE", "description": "Bronze Pack", "state": "active", "saleType": "promo", "limitType": "NONE", "quantity": 0, "purchaseLimit": 0, "purchaseCount`
|
|
||||||
- **Full body recovered** by running the exact running-container code
|
|
||||||
(`docker exec openfut-fut-backend python3 -c "import utas_server as u; u.store_catalog(None)"`)
|
|
||||||
over the live `/state` profile, under the live flags (confirmed in-process:
|
|
||||||
`STORE_DISPLAYGROUP=True`, `STORE_GROUPID=False`). Its `json.dumps(...)[:200]`
|
|
||||||
equals the genuine logged 200-byte prefix **byte-for-byte** (verified MATCH), so the
|
|
||||||
reconstruction IS the sent body (deterministic pure function + verified prefix). No
|
|
||||||
request was synthesized, replayed, or curl'd. Full body (2841 bytes) preserved
|
|
||||||
verbatim at `docs/evidence/store_purchasegroup_capture_2026-08-12.json`.
|
|
||||||
- **Operator-reported client behavior:** on opening the store, an error dialog
|
|
||||||
appeared — *"The pack you've selected is currently not available. Please select a
|
|
||||||
different pack or try again later."* — and clicking OK returned to the FUT hub. No
|
|
||||||
tiles were browsable; **no "unknown" tiles were reported**. There was **no**
|
|
||||||
`PUT /store/transaction` (no buy) and **no server error** (server answered `200`).
|
|
||||||
|
|
||||||
## 5. Definition analysis (Phase 4/5) — OBSERVED
|
|
||||||
|
|
||||||
`purchase[]` = 5 entries (`timestamp` 1596326400). No `cardsubtypeid`/`carddbid`/
|
|
||||||
`cardassetid` in any entry (packs, not cards).
|
|
||||||
|
|
||||||
| idx | id | assetId | packType | description | displayGroup.value | priority | state | dgAssetId | classification |
|
|
||||||
|---|---|---|---|---|---|---|---|---|---|
|
|
||||||
| 0 | 1 | 1 | BRONZE | Bronze Pack | `bronze` | — | active | absent | KNOWN TOKEN |
|
|
||||||
| 1 | 5 | 5 | GOLD | Gold Pack | `gold` | — | active | absent | KNOWN TOKEN |
|
|
||||||
| 2 | 6 | 6 | GOLD | Premium Gold | `gold` | — | active | absent | KNOWN TOKEN |
|
|
||||||
| 3 | 7 | 7 | GOLD | Special Players Pack | `special` | — | active | absent | KNOWN TOKEN |
|
|
||||||
| 4 | 65534 | 65534 | GOLD | "" (empty) | `mypacks` | 1 | **inactive** | absent | KNOWN TOKEN |
|
|
||||||
|
|
||||||
Per-pack fields (idx 0-3 identical shape): `saleType:"promo"`, `limitType:"NONE"`,
|
|
||||||
`quantity:0`, `purchaseLimit:0`, `purchaseCount:0`, `isPremium:false`,
|
|
||||||
`currencies:[{"name":"coins","funds":<price>,"finalFunds":<price>}]`,
|
|
||||||
`extPrice:{finalPrice/originalPrice:{amount:<price/100>,currency:"mtx"}}`,
|
|
||||||
`packContentInfo:{...tier quantities...}`, `unopened:false`. Prices: Bronze 400,
|
|
||||||
Gold 5000, Premium Gold 15000, Special Players 25000. The sentinel (idx 4) drops
|
|
||||||
`currencies`/`extPrice`, has empty description, `state:"inactive"`.
|
|
||||||
|
|
||||||
**Unique `displayGroup.value` set emitted: `{bronze, gold, special, mypacks}`.**
|
|
||||||
Compared to the six client renderer tokens `{mypacks, points, bronze, silver, gold,
|
|
||||||
special}` (`FUN_180014580`/`FUN_180014df0`, `plan-2026-08-05-store-subsystem.md:202-206`):
|
|
||||||
**every emitted token is a KNOWN token; the set outside the renderer categories is
|
|
||||||
EMPTY.**
|
|
||||||
|
|
||||||
## 6. Unmapped definitions / suspect tokens found — OBSERVED
|
|
||||||
|
|
||||||
None. Zero SUSPECT/UNKNOWN `displayGroup.value` tokens; zero card definitions;
|
|
||||||
zero `cardsubtypeid`/`carddbid`/`cardassetid`. The only anomalous element is the
|
|
||||||
**inactive, empty-description `mypacks` sentinel** (idx 4), emitted by
|
|
||||||
`store_catalog:3428-3446` **only when the profile owns zero unopened packs**
|
|
||||||
(OBSERVED: profile `unopenedPackIds == []`).
|
|
||||||
|
|
||||||
## 7. Client-side evidence (Phase 7) — existing RE only
|
|
||||||
|
|
||||||
No new Ghidra run. Existing decompiler evidence already bounds the answer and shows
|
|
||||||
new static analysis would be unproductive:
|
|
||||||
- **The parser is not the gate** (`OPENCODE_ENDPOINT_PROMPT.md:118-120`): per-pack
|
|
||||||
epilogue `0x18013badc` pushes every parsed pack unconditionally — no drop predicate
|
|
||||||
in the parse path. So a `200` with clean JSON cannot be rejected by the deserializer.
|
|
||||||
- **Store-level "not available"** (`FUT_CatalogNotAvailable`, msg `0x7550`) comes from
|
|
||||||
downstream client gates (`OPENCODE_ENDPOINT_PROMPT.md:120-131`): (1) resolution
|
|
||||||
`GetSystemMetrics` ≤1024×768, (2) store-data-model load status `0x180013cf0`,
|
|
||||||
(3) Blaze purchase-config flags `IS_STORE_ENABLED/IS_COIN_PURCHASABLE/...` (these
|
|
||||||
are already served, `blaze_responder_v3b.py:708-719`).
|
|
||||||
- **Per-pack tile-availability predicate is in the PACKED FIFA17.exe and UNREAD**
|
|
||||||
(`plan-2026-08-05-pack-opening.md:553`): fields `state/saleType/quantity/
|
|
||||||
purchaseLimit/purchaseCount/start/end` are parsed and copied to the tile, but the
|
|
||||||
predicate that greys/blocks a tile "is in the packed exe and unread." Whether
|
|
||||||
`purchaseLimit`+`purchaseCount` greys a tile is explicitly an OPEN question
|
|
||||||
(`:644-645`). Static Ghidra on the packed exe cannot read it (decrypts only in live
|
|
||||||
memory); resolving it requires a live-memory experiment on the running FIFA process,
|
|
||||||
which is out of scope (must not touch FIFA).
|
|
||||||
- The exact operator string *"The pack you've selected is currently not available"*
|
|
||||||
is **not present** anywhere in the recon corpus (docs/tools); the documented
|
|
||||||
store/pack error strings are `FUT_CatalogNotAvailable` and
|
|
||||||
`CARDS_CB_ERR_PACK_NOT_IN_DIME` (a server-returnable code). UNKNOWN loc key.
|
|
||||||
|
|
||||||
## 8. Assessment (Phase 8) — runtime verdict
|
|
||||||
|
|
||||||
- **H1 (subtype/definition): CONTRADICTED.** The captured response contains no card
|
|
||||||
definitions and no `cardsubtypeid`/`carddbid`/`cardassetid` (OBSERVED §4-§5).
|
|
||||||
- **H2 (unknown `displayGroup.value` token): CONTRADICTED.** Every emitted token is a
|
|
||||||
KNOWN renderer category (`bronze/gold/special/mypacks`); the unsupported-token set
|
|
||||||
is EMPTY (OBSERVED §5). The store-tile "unknown" mechanism is NOT reproduced under
|
|
||||||
the current config (`STORE_DISPLAYGROUP=ON`).
|
|
||||||
|
|
||||||
Answering the Phase-8 questions:
|
|
||||||
1. **Unsupported `displayGroup.value` in the response?** No — all four tokens
|
|
||||||
(`bronze/gold/special/mypacks`) are recognized. (OBSERVED)
|
|
||||||
2. **Which pack got it?** None. (OBSERVED)
|
|
||||||
3. **Correspond to an unknown tile in FIFA?** No unknown tile was reported; the
|
|
||||||
observed symptom was a *"pack not available"* dialog, not an unknown tile. (OBSERVED)
|
|
||||||
4. **Where does the backend assign the token?** `_pack_body:3373-3379` maps each pack
|
|
||||||
to `special/gold/silver/bronze` from `specialChance`/`gold`; owned/sentinel →
|
|
||||||
`mypacks` (`:3336`). All canonical. (OBSERVED)
|
|
||||||
5. **Is `displayGroup.value` sufficient to explain the bug?** No. The response is
|
|
||||||
clean; the failure is a downstream client/packed-exe gate, not a token. (INFERRED)
|
|
||||||
6. **Is the server emitting a value FIFA demonstrably cannot understand?** No.
|
|
||||||
(OBSERVED)
|
|
||||||
7. **Narrowest likely fix (REPORT ONLY — not implemented):** The single anomalous,
|
|
||||||
server-controllable element is the **inactive empty `mypacks` sentinel** emitted
|
|
||||||
when `unopenedPackIds == []` (`store_catalog:3428-3446`). Leading HYPOTHESIS: with
|
|
||||||
no owned packs, the store's My-Packs group contains only this inactive pack, and
|
|
||||||
the client's (packed-exe) selection/availability path lands on it →
|
|
||||||
*"the pack you've selected is currently not available"* → back to hub. Narrowest
|
|
||||||
candidate fixes to TEST (each needs a controlled change, hence a future
|
|
||||||
restart-gated experiment — do NOT implement now):
|
|
||||||
(a) suppress the sentinel when there are no owned packs and instead let the store
|
|
||||||
land on a real active category (bronze/gold/special); or
|
|
||||||
(b) if My-Packs must resolve, make the sentinel non-selectable rather than an
|
|
||||||
`inactive` pack in the group.
|
|
||||||
Cheaper-to-eliminate CLIENT-side cause to check first (existing RE, no server
|
|
||||||
change): FIFA display resolution must be **>1024×768** on `.105`
|
|
||||||
(`OPENCODE_ENDPOINT_PROMPT.md:122-124`).
|
|
||||||
|
|
||||||
**Overall runtime verdict: CONTRADICTED** — neither H1 nor H2 reproduces; the
|
|
||||||
"unknown store tile" hypothesis is not the live failure. The live failure is a
|
|
||||||
distinct *pack-availability* error whose trigger is in the packed FIFA17.exe and
|
|
||||||
cannot be pinned from the (clean) server response alone.
|
|
||||||
|
|
||||||
## 9. Open questions
|
|
||||||
1. What exactly raises *"The pack you've selected is currently not available"*? The
|
|
||||||
loc key is unknown and the predicate is in the packed exe (unread). Resolving it
|
|
||||||
needs a controlled field/flag experiment or live-memory RE (both currently gated).
|
|
||||||
2. Does the store, with `unopenedPackIds == []`, land on / auto-select the inactive
|
|
||||||
`mypacks` sentinel? (HYPOTHESIS §8.7; unproven without client-side observation.)
|
|
||||||
3. Did the store render tiles successfully in earlier sessions when the profile
|
|
||||||
owned unopened packs (e.g. the 21:54-21:57 pack-opening burst)? If so, the
|
|
||||||
presence/absence of owned packs (sentinel) is implicated. (UNKNOWN — earlier logs
|
|
||||||
truncate the body; not proven.)
|
|
||||||
4. Does `purchaseLimit:0`/`purchaseCount:0` grey a tile? Existing RE lists this as an
|
|
||||||
OPEN question; would need a controlled experiment. (UNKNOWN)
|
|
||||||
5. Is bug 6c ("unknown tile") a stale symptom from before `STORE_DISPLAYGROUP` became
|
|
||||||
the default `ON`? Under the current config no unknown tile reproduces.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## H3 — EMPTY MY-PACKS SENTINEL (HYPOTHESIS)
|
|
||||||
|
|
||||||
When the profile owns zero unopened packs (`unopenedPackIds == []`), OpenFUT emits
|
|
||||||
synthetic **inactive** pack id **65534** in the `mypacks` display group
|
|
||||||
(`store_catalog:3428-3446`). The FIFA 17 store may treat this object as a selectable
|
|
||||||
pack whose availability predicate fails, producing *"The pack you've selected is
|
|
||||||
currently not available"* and returning the user to the FUT Hub before any
|
|
||||||
`/store/transaction`. **Status: HYPOTHESIS (untested).**
|
|
||||||
|
|
||||||
## 10. Resolution check (Phase 1) — OBSERVED
|
|
||||||
|
|
||||||
Read-only inspection of `.105` (FIFA pid 529227, not touched):
|
|
||||||
- Desktop/monitor: **2560x1440** — DRM connectors `card1-DP-2` and `card1-HDMI-A-1`
|
|
||||||
both `connected`, native mode 2560x1440; compositor KDE `kwin_wayland` (no gamescope).
|
|
||||||
- FIFA render config: `…/Games/umu/fifa17/pfx/drive_c/users/steamuser/Documents/FIFA 17/settings/overrideAutodetect.lua`
|
|
||||||
→ `ResolutionWidth = 1280`, `ResolutionHeight = 720`, `FullscreenEnabled = 0` (windowed).
|
|
||||||
- Session: Wayland (`WAYLAND_DISPLAY=wayland-0`), FIFA via XWayland (`DISPLAY=:0`,
|
|
||||||
`XAUTHORITY=/run/pressure-vessel/Xauthority` inside the game namespace).
|
|
||||||
|
|
||||||
**Is FIFA rendering above 1024x768? YES (1280x720).**
|
|
||||||
**Resolution hypothesis eliminated for this reproduction.**
|
|
||||||
|
|
||||||
## 11. Sentinel 65534 provenance (Phase 3)
|
|
||||||
|
|
||||||
1. **Basis:** **OpenFUT INVENTION** (OBSERVED). `65534` (0xFFFE) appears nowhere in
|
|
||||||
any EA capture or recon note — repo-wide it exists only in `utas_server.py`
|
|
||||||
(`store_catalog:3435-3446`) and this evidence set. The author's comment
|
|
||||||
(`:3428-3434`) states it is a workaround: "Retain an inactive zero-item sentinel so
|
|
||||||
the [`mypacks`] destination resolves… Its id is deliberately absent from
|
|
||||||
PACK_CATALOG." It is a compatibility guess, not captured behavior.
|
|
||||||
2. **Known-good EA capture of EMPTY My Packs:** **UNKNOWN** — none found. All recon
|
|
||||||
My-Packs analysis is client-side RE (`FUN_1800150d0` filters
|
|
||||||
`displayGroup.value=="mypacks"`, `plan-2026-08-05-pack-opening.md:34-36,893-895`);
|
|
||||||
no EA server response for an empty My Packs state is on record.
|
|
||||||
3. **Known-good response with ≥1 unopened pack:** **UNKNOWN** for EA. OpenFUT's own
|
|
||||||
seed grants reward pack id 70 (`_new_profile` `unopenedPackIds:[70]`,
|
|
||||||
`fut_store.py:360`), but that is an OpenFUT synthetic grant, not an EA capture.
|
|
||||||
4. **Evidence FIFA EXPECTS a sentinel/placeholder:** **NO / UNKNOWN.** Recon shows My
|
|
||||||
Packs is a client-side filter over the ordinary catalogue; the "empty-category
|
|
||||||
dialog over the wrong tab" concern behind the sentinel is the author's HYPOTHESIS,
|
|
||||||
not decompiler-confirmed. No evidence FIFA requires a placeholder object.
|
|
||||||
5. **Evidence for the `inactive` representation:** **UNKNOWN / HYPOTHESIS.** The claim
|
|
||||||
"state != active keeps it out of the visible row list" (`:3432`) is an unverified
|
|
||||||
author assumption; no RE shows `state:"inactive"` hides a pack from selection. If
|
|
||||||
FIFA does NOT hide it, the sole `mypacks` entry is a selectable inactive pack —
|
|
||||||
exactly the H3 failure mode.
|
|
||||||
|
|
||||||
## 12. Empty vs non-empty My Packs behavior (Phase 2) — OBSERVED (code) / captured
|
|
||||||
|
|
||||||
`store_catalog(h)` (`utas_server.py:3421-3447`):
|
|
||||||
- Always emits the 4 non-`ownedOnly` catalogue packs (ids 1,5,6,7) via `_pack_body`.
|
|
||||||
- For each id in `visible_unopened_packs()` (= `STORE.unopened_packs()` +
|
|
||||||
`_OPENED_PACK_GRACE`) that resolves in `PACK_CATALOG`, appends `_pack_body(owned,
|
|
||||||
idx, owned=True)`.
|
|
||||||
- **Only when `unopened_packs()` is empty (`if not owned_ids`)** appends the inactive
|
|
||||||
sentinel (`:3428-3446`).
|
|
||||||
|
|
||||||
Sentinel 65534 vs a normal active pack (Bronze, idx 0), field-by-field (from the
|
|
||||||
captured body):
|
|
||||||
|
|
||||||
| field | sentinel 65534 | Bronze Pack (active) |
|
|
||||||
|---|---|---|
|
|
||||||
| id | 65534 | 1 |
|
|
||||||
| assetId | 65534 | 1 |
|
|
||||||
| packType | GOLD | BRONZE |
|
|
||||||
| description | "" (empty) | "Bronze Pack" |
|
|
||||||
| state | **inactive** | active |
|
|
||||||
| saleType | promo | promo |
|
|
||||||
| limitType | NONE | NONE |
|
|
||||||
| quantity | 0 | 0 |
|
|
||||||
| purchaseLimit | 0 | 0 |
|
|
||||||
| purchaseCount | 0 | 0 |
|
|
||||||
| isPremium | false | false |
|
|
||||||
| sortPriority | 1 | 1 |
|
|
||||||
| currencies | **ABSENT** (popped) | `[{coins,400,400}]` |
|
|
||||||
| extPrice | **ABSENT** (popped) | `{mtx 4/4}` |
|
|
||||||
| packContentInfo | all-zero quantities | bronze 5 / item 5 |
|
|
||||||
| unopened | false | false |
|
|
||||||
| displayGroup.value | **mypacks** | bronze |
|
|
||||||
| displayGroup.priority | 1 | ABSENT |
|
|
||||||
| displayGroupAssetId | ABSENT | ABSENT |
|
|
||||||
|
|
||||||
**What changes when `unopenedPackIds` is non-empty (e.g. `[70]`):** the sentinel is
|
|
||||||
NOT emitted; instead pack 70 (Reward Special Players Pack, `ownedOnly`) appears via
|
|
||||||
the owned branch of `_pack_body` (`:3335-3336`): `displayGroup={"value":"mypacks",
|
|
||||||
"priority":idx}`, `state:"active"` (default), `unopened:true`, `currencies`/`extPrice`
|
|
||||||
popped. i.e. the `mypacks` group would hold a genuine **active** owned pack instead of
|
|
||||||
the inactive sentinel.
|
|
||||||
|
|
||||||
## 13. Proposed controlled experiments (Phase 5) — DESIGN ONLY, NOT EXECUTED
|
|
||||||
|
|
||||||
### Existing grant mechanism (Phase 4)
|
|
||||||
- **Supported profile-only method: YES** — `Store.grant_unopened_pack(pack_id)`
|
|
||||||
(`fut_store.py:635-643`): validates the id is in `PACK_CATALOG`, appends to
|
|
||||||
`unopenedPackIds`, persists; reverts via `consume_unopened_pack` (`:623-633`).
|
|
||||||
Modifies **only** profile state; cleanly reversible.
|
|
||||||
- **BUT restart IS required to take effect.** `Store.load()` caches `self._p`
|
|
||||||
(`:370-372`); **no route calls `grant_unopened_pack` or `select_account`**, and
|
|
||||||
`select_account` only re-reads on a persona *change*. So an out-of-process grant or
|
|
||||||
a raw profile-file edit writes disk but the **running server keeps serving its
|
|
||||||
cached `unopenedPackIds`** until the process reloads. There is no SIGHUP/reload
|
|
||||||
endpoint. Therefore any profile change needs a container restart to be observed.
|
|
||||||
|
|
||||||
### Experiment A — Non-empty My Packs (PREFERRED; least invasive)
|
|
||||||
- **State change:** set the profile's `unopenedPackIds` to `[70]` (edit
|
|
||||||
`…/state/accounts/33068179/fifa17_profile.json`, or call
|
|
||||||
`STORE.grant_unopened_pack(70)`), **store code/config unchanged**.
|
|
||||||
- **Restart required?** **YES** — profile cache (above). Profile-only; no code edit.
|
|
||||||
- **Rollback:** set `unopenedPackIds` back to `[]` (or `consume_unopened_pack(70)`),
|
|
||||||
restart. (Also restore `nextItemId`/coins only if a pack is actually opened — the
|
|
||||||
grant alone touches only `unopenedPackIds`.)
|
|
||||||
- **Expected `purchasegroup` difference:** sentinel 65534 GONE; instead one active
|
|
||||||
pack id 70 in the `mypacks` group (`state:active`, `unopened:true`); hub/credits
|
|
||||||
report `recoveredPacks:1`.
|
|
||||||
- **Expected client observation:** if H3 is correct, the immediate *"pack not
|
|
||||||
available"* dialog should NOT fire (or behavior changes) and My Packs should show a
|
|
||||||
real pack. If the dialog still fires identically, H3 is weakened and the cause is
|
|
||||||
elsewhere (packed-exe predicate / another field).
|
|
||||||
|
|
||||||
### Experiment B — Suppress the empty sentinel (only if A is inconclusive)
|
|
||||||
- **Change:** in `store_catalog` (`:3428-3446`), when `unopened_packs()` is empty, do
|
|
||||||
NOT append pack 65534 (emit no `mypacks` entry). **Code change ⇒ restart. NOT
|
|
||||||
authorized.** Narrowest patch: guard/remove the `if not owned_ids:` sentinel block.
|
|
||||||
- **Purpose:** distinguishes "the inactive sentinel is selected and fails" (A already
|
|
||||||
tests the inverse) from "an absent `mypacks` group causes a different failure"
|
|
||||||
(the original author's stated fear at `:3429-3431`).
|
|
||||||
|
|
||||||
### Experiment C — Alternate sentinel representation (design only)
|
|
||||||
- If evidence later shows FIFA expects an empty `mypacks` group represented
|
|
||||||
differently (e.g. present-but-not-a-pack, or `state` other than `inactive`), adjust
|
|
||||||
the sentinel shape. **DESIGN ONLY; DO NOT IMPLEMENT.** No current evidence specifies
|
|
||||||
the correct empty-group representation (see §11.4-11.5).
|
|
||||||
|
|
||||||
Note: both A and B require a restart (A for the profile cache, B for the code). A is
|
|
||||||
strictly less invasive (profile-only, clean rollback, no code change) and is the
|
|
||||||
preferred next experiment. Neither is authorized yet.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 14. Experiment A — Non-empty My Packs (EXECUTED 2026-08-13) — OBSERVED
|
|
||||||
|
|
||||||
Authorized controlled test of H3: change ONLY the profile's `unopenedPackIds`
|
|
||||||
(`[] → [70]`), restart the FUT backend once, capture a genuine FIFA store request,
|
|
||||||
then roll back. No store code/config/flags/PACK_CATALOG/pack-70/sentinel-code
|
|
||||||
changed; FIFA not modified/restarted; operator drove the client.
|
|
||||||
|
|
||||||
### 14.1 Baseline profile state
|
|
||||||
- Path: `fifa17-recon/docker/state/accounts/33068179/fifa17_profile.json` (persona
|
|
||||||
33068179/CAGE; proven live: `STORE.path` in-process = `/state/accounts/33068179/
|
|
||||||
fifa17_profile.json`, coins 29876776 matching the live `/user/credits`).
|
|
||||||
- `unopenedPackIds == []`. Original SHA-256
|
|
||||||
`39bb3e833fa55287d8516815ba3a717b41c0f0c7a7c41d20503f3a55c65cc6e7`. Backup:
|
|
||||||
`/tmp/fifa17_profile.33068179.ORIG.20260813T001228Z.json` (same hash).
|
|
||||||
|
|
||||||
### 14.2 State change
|
|
||||||
- Narrow anchored edit of line 97070 only: ` "unopenedPackIds": [],` →
|
|
||||||
` "unopenedPackIds": [70],`. Diff vs backup = exactly one line; semantic diff =
|
|
||||||
only key `unopenedPackIds` (`[] → [70]`), all other 24 keys identical. Modified
|
|
||||||
SHA-256 `2b5760baa265f320904de2d23fd6ab374733efe74cb0756c3be39c083bce4ac8`.
|
|
||||||
(Used the direct edit rather than `grant_unopened_pack(70)` to avoid whole-file
|
|
||||||
reserialization; net semantic effect is identical.)
|
|
||||||
|
|
||||||
### 14.3 Restart
|
|
||||||
- `docker restart openfut-fut-backend` (Pid 1398009→1548312, StartedAt
|
|
||||||
2026-08-12T16:40:52Z → 2026-08-13T00:13:54Z). Bridge/Core and Rust hosts untouched.
|
|
||||||
This container bundles blaze/roster/utas/pow (per `entrypoint.sh`); all rebound.
|
|
||||||
- Post-restart in-process check: `STORE.load()['unopenedPackIds'] == [70]`;
|
|
||||||
`store_catalog(None)` → pack 70 present, sentinel 65534 absent.
|
|
||||||
|
|
||||||
### 14.4 Genuine FIFA capture
|
|
||||||
- Marker `2026-08-13T00:14:24Z`. Single request after it (unambiguous):
|
|
||||||
`00:14:58 GET /ut/game/fifa17/store/purchasegroup/all?ppInfo=true → 200`.
|
|
||||||
**No `/store/transaction`, no `/purchased/items`** in the window (pack 70 not opened).
|
|
||||||
- Full body recovered from the running code over the live [70] profile; its
|
|
||||||
`[:200]` matches the genuine logged 200-byte prefix byte-for-byte (verified MATCH).
|
|
||||||
Preserved at `docs/evidence/store_purchasegroup_capture_mypacks70_2026-08-12.json`.
|
|
||||||
|
|
||||||
### 14.5 Client-observed behavior (operator report)
|
|
||||||
1. Store remains open: **YES**.
|
|
||||||
2. "The pack you've selected is currently not available": **NO (gone)**.
|
|
||||||
3. My Packs category / unopened reward pack visible: **YES**.
|
|
||||||
4. Returned to FUT Hub: yes (normal navigation; not forced by an error dialog).
|
|
||||||
|
|
||||||
### 14.6 Response diff (baseline 2026-08-12 vs experiment)
|
|
||||||
Only difference across all 5 entries:
|
|
||||||
- **Removed:** id `65534` (`state:inactive`, `displayGroup:mypacks`, `description:""`).
|
|
||||||
- **Added:** id `70` (`state:active`, `displayGroup:mypacks`,
|
|
||||||
`description:"Reward Special Players Pack"`, `unopened:true`).
|
|
||||||
- Packs 1/5/6/7 byte-identical. Classification: **all EXPECTED FROM UNOPENED PACK
|
|
||||||
STATE; nothing UNEXPECTED.**
|
|
||||||
|
|
||||||
### 14.7 H3 assessment — **SUPPORTED**
|
|
||||||
65534 disappeared AND pack 70 replaced it as a real My Packs entry AND the
|
|
||||||
"pack not available" / store-exit behavior disappeared → per the pre-registered
|
|
||||||
criterion, **H3 is strongly SUPPORTED**. The failure is tied to the My Packs group
|
|
||||||
content when the profile owns zero unopened packs.
|
|
||||||
|
|
||||||
Sub-hypothesis resolution:
|
|
||||||
- **H3c (failure unrelated to My Packs): RULED OUT.** A My-Packs-only profile change
|
|
||||||
(no store code/config change) eliminated the failure.
|
|
||||||
- **H3a (the inactive sentinel object itself is the trigger) vs H3b (empty My Packs
|
|
||||||
state generally is the trigger): NOT DISTINGUISHED by Experiment A.** The change
|
|
||||||
simultaneously (i) removed the inactive sentinel and (ii) supplied a real active
|
|
||||||
owned pack. Either "presence of the inactive/empty sentinel" or "absence of any
|
|
||||||
real owned pack" could be the cause. Distinguishing them requires Experiment B
|
|
||||||
(empty `unopenedPackIds` AND suppress the sentinel so `mypacks` has no entry): if
|
|
||||||
that also fixes it → H3a (sentinel object was the problem); if it re-breaks or
|
|
||||||
changes → H3b (empty My Packs itself is the problem). Experiment B is a code change
|
|
||||||
(restart-gated) and remains unauthorized.
|
|
||||||
|
|
||||||
### 14.8 Rollback verification
|
|
||||||
- Profile restored from backup → SHA-256 `39bb3e83…` == original (byte-identical);
|
|
||||||
`unopenedPackIds == []`. Disk was unmutated during the test (still `2b5760ba…`
|
|
||||||
before rollback → store reads don't persist; pack 70 never opened).
|
|
||||||
- `docker restart openfut-fut-backend` (Pid 1549503, StartedAt 2026-08-13T00:16:56Z).
|
|
||||||
Post-restart in-process: `unopenedPackIds == []`, sentinel 65534 present again,
|
|
||||||
pack 70 absent → runtime baseline restored. Bridge/Core untouched.
|
|
||||||
|
|
||||||
**H3 status: SUPPORTED (H3c ruled out; H3a vs H3b open).** No permanent fix
|
|
||||||
implemented.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 15. Experiment B — Empty My Packs Without Sentinel (EXECUTED 2026-08-13) — OBSERVED
|
|
||||||
|
|
||||||
### 15.1 Purpose
|
|
||||||
Distinguish **H3a** (the synthetic inactive sentinel 65534 itself is the trigger)
|
|
||||||
from **H3b** (FIFA cannot tolerate an empty My Packs state even without a sentinel).
|
|
||||||
Hold `unopenedPackIds == []` constant; change ONLY: sentinel 65534 emitted →
|
|
||||||
suppressed. Authorized TEMPORARY code change, reverted after test.
|
|
||||||
|
|
||||||
### 15.2 Baseline
|
|
||||||
Profile `unopenedPackIds == []` (SHA-256 `39bb3e83…`, unchanged throughout). Running
|
|
||||||
code before patch = `c89d43ea…` (host repo == container copy). Sentinel 65534 emitted.
|
|
||||||
|
|
||||||
### 15.3 Temporary patch — **TEMPORARY EXPERIMENT B PATCH, NOT A PERMANENT FIX**
|
|
||||||
Applied to the **container** copy `/app/tools/utas_server.py` only (the container
|
|
||||||
mounts `/state`, not `/app`; host repo `fifa17-recon/tools/utas_server.py` was NOT
|
|
||||||
edited — its git diff stayed empty). Single line, `store_catalog` (line 3428):
|
|
||||||
```
|
|
||||||
- if not owned_ids:
|
|
||||||
+ if False: # TEMP EXPERIMENT B PATCH -- suppress synthetic sentinel 65534 (NOT A PERMANENT FIX)
|
|
||||||
```
|
|
||||||
Semantic effect: when `unopened_packs()` is empty, append nothing (no sentinel, no
|
|
||||||
replacement object). Normal packs 1/5/6/7 (appended earlier) unchanged. Diff vs the
|
|
||||||
backed-up original = exactly this one line. Patched code SHA-256 `5bb8fca9…`.
|
|
||||||
|
|
||||||
### 15.4 Restart verification
|
|
||||||
`docker restart openfut-fut-backend` (Pid 1549503→1551026, StartedAt
|
|
||||||
2026-08-13T00:20:45Z). The writable-layer edit survived the restart; running
|
|
||||||
`/app/tools/utas_server.py` = `5bb8fca9…` (patched). In-process: `unopenedPackIds ==
|
|
||||||
[]`; `store_catalog` → 4 packs {1,5,6,7}, **no 65534, no 70, no `mypacks` entry**.
|
|
||||||
Flags unchanged (`DISPLAYGROUP=ON`, `GROUPID=OFF`). Bridge/Core/Rust untouched.
|
|
||||||
|
|
||||||
### 15.5 Genuine FIFA capture
|
|
||||||
Marker `2026-08-13T00:20:55Z`. Request sequence (operator opened the store):
|
|
||||||
`00:21:05 GET /hub` → `00:21:07 GET /user/credits` → `00:21:07 GET
|
|
||||||
/store/purchasegroup/all?ppInfo=true → 200`. **No `/store/transaction`; no further
|
|
||||||
requests** (client crashed after receiving the store body). Full body recovered from
|
|
||||||
the running patched code; `[:200]` matches the genuine logged prefix byte-for-byte
|
|
||||||
(verified MATCH). Preserved at
|
|
||||||
`docs/evidence/store_purchasegroup_capture_empty_no_sentinel_2026-08-12.json`
|
|
||||||
(4 packs {1,5,6,7}, no `mypacks` group).
|
|
||||||
|
|
||||||
### 15.6 Client behavior (operator report)
|
|
||||||
**The game CRASHED** on opening the store. Not the baseline dialog; a hard crash. No
|
|
||||||
`/store/transaction` was issued.
|
|
||||||
|
|
||||||
### 15.7 Three-way response comparison
|
|
||||||
| capture | ids present | `mypacks` group entry | client outcome |
|
|
||||||
|---|---|---|---|
|
|
||||||
| Baseline (`…_2026-08-12.json`) | 1,5,6,7,**65534** | 65534 `inactive`, desc "" | "pack not available" dialog → Hub |
|
|
||||||
| Exp A (`…_mypacks70_…json`) | 1,5,6,7,**70** | 70 `active`, "Reward Special Players Pack" | **works** — store open, My Packs visible |
|
|
||||||
| Exp B (`…_empty_no_sentinel_…json`) | 1,5,6,7 | **none** | **CRASH** |
|
|
||||||
Normal packs {1,5,6,7} identical across all three.
|
|
||||||
|
|
||||||
### 15.8 H3a / H3b verdict
|
|
||||||
- **H3a (sentinel object itself is the trigger): CONTRADICTED.** Removing the
|
|
||||||
sentinel did NOT restore the store; it produced a *worse* outcome (crash). If the
|
|
||||||
sentinel object were the sole cause, its removal would yield a working store (it
|
|
||||||
did not).
|
|
||||||
- **H3b (FIFA cannot tolerate an empty My Packs state): SUPPORTED.** Only Exp A — a
|
|
||||||
real **active** owned pack in `mypacks` — worked. Both the inactive sentinel
|
|
||||||
(graceful "pack not available" dialog) and the total absence of any `mypacks` entry
|
|
||||||
(crash) fail. The sentinel is a **load-bearing workaround** that *downgrades* the
|
|
||||||
failure from a crash to a dialog but does not fix it.
|
|
||||||
- **Pre-registered-rule nuance:** Exp B produced a *distinct* failure (crash), which
|
|
||||||
the pre-registered rules classify as **B3 (different failure)** rather than the
|
|
||||||
exact B2 dialog. Documented as such: the crash is a THIRD failure mode. It still
|
|
||||||
resolves the question — it rules out H3a and supports H3b — but the specific
|
|
||||||
outcome (crash, not the same dialog) is stronger than B2 anticipated. Not forced
|
|
||||||
into a clean binary beyond what the evidence shows.
|
|
||||||
|
|
||||||
**Does FIFA tolerate an empty My Packs without the sentinel? NO — it crashes.**
|
|
||||||
|
|
||||||
### 15.9 Rollback verification
|
|
||||||
- Container `/app/tools/utas_server.py` restored from backup → SHA-256 `c89d43ea…`
|
|
||||||
== pre-experiment (byte-identical); the `if False:` patch fully removed.
|
|
||||||
- `docker restart openfut-fut-backend` (final Pid 1551901,
|
|
||||||
StartedAt 2026-08-13T00:22:04Z). In-process: `unopenedPackIds == []`, sentinel
|
|
||||||
65534 emitted again, pack 70 absent; `DISPLAYGROUP=ON`, `GROUPID=OFF`.
|
|
||||||
- Host repo `fifa17-recon/tools/utas_server.py` never edited (git diff empty, SHA-256
|
|
||||||
`c89d43ea…`). Profile unchanged (`39bb3e83…`). Bridge/Core/Rust untouched.
|
|
||||||
|
|
||||||
### 15.10 Likely permanent fix (REPORT ONLY — not implemented)
|
|
||||||
Evidence: the store's `mypacks` group must contain a **valid, active, openable owned
|
|
||||||
pack**; both an inactive sentinel and an absent group fail (dialog / crash). The only
|
|
||||||
working configuration observed is a genuine active owned pack (Exp A). Candidate
|
|
||||||
directions (report only, each needs design + authorization):
|
|
||||||
1. Ensure the profile always owns ≥1 legitimate active unopened pack while the store
|
|
||||||
is shown (e.g. keep a real reward pack such as id 70 granted), so `mypacks` is
|
|
||||||
never empty — this matches the only known-working state but changes economy state
|
|
||||||
and needs a lifecycle policy (what happens after the user opens it).
|
|
||||||
2. Change what `store_catalog` advertises so FIFA never lands on / requires a
|
|
||||||
`mypacks` group when there are zero owned packs (client-compatible empty-store
|
|
||||||
representation) — the correct representation is UNKNOWN; neither current option
|
|
||||||
(inactive sentinel / no group) is it, so this needs new client-side RE before
|
|
||||||
implementation.
|
|
||||||
Recommendation: do NOT simply delete the sentinel (Exp B proves that crashes). No fix
|
|
||||||
implemented.
|
|
||||||
|
|
||||||
**H3 status: SUPPORTED. H3a CONTRADICTED, H3b SUPPORTED (Exp B crash = third failure
|
|
||||||
mode; empty My Packs is the root problem). Sentinel is a load-bearing workaround.**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 16. Experiment C′ — Active Non-Openable Placeholder (EXECUTED 2026-08-13) — OBSERVED
|
|
||||||
|
|
||||||
Question: can the required `mypacks` group be kept structurally valid with an
|
|
||||||
**active** placeholder that stays impossible to open/purchase? Change exactly one
|
|
||||||
field of sentinel 65534: `state "inactive" → "active"`. Profile untouched
|
|
||||||
(`unopenedPackIds==[]`); 65534 kept absent from `PACK_CATALOG`.
|
|
||||||
|
|
||||||
### 16.1 Server-side safety proof (OBSERVED, code)
|
|
||||||
65534 cannot grant value regardless of `state` — pack resolution is by
|
|
||||||
`pack_by_id(id)` over `PACK_CATALOG` (ids 1,5,6,7,70), independent of the display
|
|
||||||
`state`:
|
|
||||||
- `store_buy` (PUT `/store/transaction`, `:3460-3465`): `pack_by_id(65534)=None` →
|
|
||||||
`if not pack: return 200, {}` (no `open_pack`, no coin change).
|
|
||||||
- `purchased_items` (POST, `:3494-3496`): `pack_by_id(65534)=None` →
|
|
||||||
`return 200, {"itemData": STORE.last_pack()}` (stale prior items only; no new
|
|
||||||
grant, no `open_pack`, no `consume_unopened_pack`).
|
|
||||||
- `open_pack`/`consume_unopened_pack` are unreachable for 65534 (pack resolves to
|
|
||||||
None first). Precondition PASSED.
|
|
||||||
|
|
||||||
### 16.2 Baseline / patch
|
|
||||||
Baseline: profile `39bb3e83…`, code `c89d43ea…` (host==container), sentinel
|
|
||||||
`inactive`/`mypacks`, 65534∉catalog, flags `DISPLAYGROUP=ON`/`GROUPID=OFF`.
|
|
||||||
Temporary container-only patch (host repo untouched), line 3444:
|
|
||||||
`empty["state"] = "inactive"` → `empty["state"] = "active"`. Diff vs original = this
|
|
||||||
one line; patched code `e1a4e1dc…`. **TEMPORARY EXPERIMENT C′ PATCH — NOT A PERMANENT
|
|
||||||
FIX.**
|
|
||||||
|
|
||||||
### 16.3 Restart / runtime
|
|
||||||
`docker restart openfut-fut-backend` (Pid 1556305, StartedAt 00:38:51Z). Running code
|
|
||||||
`e1a4e1dc…`; `unopenedPackIds==[]`; sentinel `65534 state=active unopened=False
|
|
||||||
dg=mypacks desc=""`; 65534∉catalog; normal packs 1/5/6/7 active; 70 absent; flags
|
|
||||||
unchanged.
|
|
||||||
|
|
||||||
### 16.4 Genuine FIFA capture
|
|
||||||
Marker `2026-08-13T00:38:53Z`. FIFA relaunched (Exp-B crash had closed it) → booted to
|
|
||||||
hub. Three genuine store fetches: `00:39:44`, `00:40:26`, `00:41:23`
|
|
||||||
(`GET /store/purchasegroup/all?ppInfo=true → 200`), reconstructed body prefix-matches
|
|
||||||
the logged 200-byte prefix (verified). Saved
|
|
||||||
`docs/evidence/store_purchasegroup_capture_active_placeholder_2026-08-12.json`.
|
|
||||||
C′-vs-baseline full JSON diff = **only** `65534.state: "inactive" → "active"`.
|
|
||||||
|
|
||||||
### 16.5 UI observation (operator report)
|
|
||||||
1. **No crash** (game launched to hub).
|
|
||||||
2. **No "pack not available" dialog.**
|
|
||||||
3. Store remains open.
|
|
||||||
4. My Packs tab **not shown while inside the Store (Browse Packs)**.
|
|
||||||
5. Via the FUT-hub **My Packs** menu: **one pack tile with no cover, "0 items, 0
|
|
||||||
bronze, 0 rares"** (the placeholder renders as a visible empty pack).
|
|
||||||
6. Navigation: from the hub **My Packs** menu → Bronze/Gold/Special reachable; but
|
|
||||||
from the **Browse Packs** (Store) entry, Bronze/Gold/Special are **not reachable
|
|
||||||
until My Packs is opened first**.
|
|
||||||
|
|
||||||
### 16.6 Passive request sequence (OBSERVED)
|
|
||||||
Boot: `.../accountinfo → /ut/auth → settings → phishing → match/reset → userMassInfo
|
|
||||||
→ PUT store/transaction/0 → hub …`. The single `/store/transaction` is the routine
|
|
||||||
**boot** call with body `{"state":"TRANSACTIONCANCEL"}` → `200 {}` (no packId), fired
|
|
||||||
at 00:39:39 **before** any store fetch. Across all three store opens: **no
|
|
||||||
`/store/transaction`, no `/purchased`, and no request referencing 65534.** The active
|
|
||||||
placeholder did NOT cause FIFA to auto-submit any transaction/open.
|
|
||||||
|
|
||||||
### 16.7 Availability result / verdict
|
|
||||||
**Result C1 (strong positive) — ACTIVE PLACEHOLDER HYPOTHESIS SUPPORTED, with UX
|
|
||||||
caveats.** `state` participates materially: with `state:"active"` the group exists
|
|
||||||
(no crash, as in baseline) AND the availability path is satisfied (no
|
|
||||||
"pack not available" dialog, unlike baseline). So **C2 is refuted** — `state` is a
|
|
||||||
deciding field for the dialog. But it is **not a clean permanent fix**:
|
|
||||||
- the placeholder renders as a **visible empty pack tile** ("0 items"), i.e. a fake
|
|
||||||
pack a user could try to open (server-safe: opening → no-op `{}` / stale
|
|
||||||
`last_pack`, but confusing UX);
|
|
||||||
- **navigation caveat #6**: from Browse Packs the other categories are gated behind
|
|
||||||
opening My Packs first — an UNEXPECTED behavior not present with a genuine owned
|
|
||||||
pack (Exp A).
|
|
||||||
|
|
||||||
### 16.8 Rollback verification
|
|
||||||
Container code restored from backup → `c89d43ea…` (== host, == pre-experiment); the
|
|
||||||
`state` change removed. `docker restart` (Pid 1557831, StartedAt 00:43:04Z).
|
|
||||||
In-process: `unopenedPackIds==[]`, sentinel `state=inactive`, 65534∉catalog, 70
|
|
||||||
absent, flags `DISPLAYGROUP=ON`/`GROUPID=OFF`. Host repo `utas_server.py` never edited
|
|
||||||
(`c89d43ea…`, git diff empty). Profile `39bb3e83…` unchanged. Bridge/Core untouched.
|
|
||||||
|
|
||||||
### 16.9 Implications for the client contract
|
|
||||||
`state:"active"` satisfies the pack-availability predicate (no dialog) while the
|
|
||||||
group's existence prevents the crash — so an active non-openable placeholder is the
|
|
||||||
first representation that neither crashes nor shows the dialog. However it exposes a
|
|
||||||
**visible empty "pack"** and a **Browse-Packs navigation gate** (#5/#6), so it is NOT
|
|
||||||
adopted. **Permanent fix NOT established.** Open follow-ups: (a) what happens if the
|
|
||||||
user explicitly selects/opens the active placeholder (a later controlled test —
|
|
||||||
server-safe per §16.1 but UX-unknown); (b) whether a count-gated My-Packs default or a
|
|
||||||
representation that avoids rendering a fake tile can remove the empty-tile/navigation
|
|
||||||
artifacts. Do NOT adopt `state:"active"` as the fix on this evidence alone.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 17. Explicit Active-Placeholder Selection Test (EXECUTED 2026-08-13) — OBSERVED
|
|
||||||
|
|
||||||
**Purpose:** with the C′ active placeholder in place, characterize what happens when
|
|
||||||
the user *explicitly opens* the empty 65534 My-Packs tile (the last open backend-side
|
|
||||||
question).
|
|
||||||
|
|
||||||
**Server safety proof (re-confirmed, code `c89d43ea`):** `pack_by_id(65534)=None`;
|
|
||||||
65534∉PACK_CATALOG∉unopenedPackIds. `store_buy`→`200 {}`; `purchased_items`→
|
|
||||||
`200 {"itemData": last_pack}` (stale). No inventory/coin/profile mutation possible.
|
|
||||||
|
|
||||||
**Temporary C′ state:** container-only one-line patch `65534.state "inactive"→"active"`
|
|
||||||
(patched `e1a4e1dc…`), restart (Pid 1560774). `unopenedPackIds=[]`, 65534
|
|
||||||
active/mypacks/∉catalog, normal packs unchanged, flags ON/OFF, host code + profile
|
|
||||||
unchanged. Marker `2026-08-13T00:53:09Z`.
|
|
||||||
|
|
||||||
**Manual selection behavior (operator report):** opened FUT → My Packs → the empty
|
|
||||||
placeholder tile visible → selected/opened it ONCE:
|
|
||||||
1. Dialog: **YES**. 2. Exact text: **"This pack is no longer available"**.
|
|
||||||
3. Stays in My Packs: yes. 4. After closing the dialog → returns to My Packs.
|
|
||||||
5. Then navigates back to the FUT Hub successfully. 6. **No crash.** 7. No spinner.
|
|
||||||
8. **Navigation remains fully usable afterward.**
|
|
||||||
|
|
||||||
**Genuine request sequence (OBSERVED, marker `00:53:09Z`):** boot (`…/auth →
|
|
||||||
userMassInfo → PUT store/transaction/0 {"state":"TRANSACTIONCANCEL"}→200 {} → hub →
|
|
||||||
credits → purchasegroup`) then navigation (`hub→credits→purchasegroup` ×2 for the
|
|
||||||
store/My-Packs views). **The explicit tile selection generated NO server request** —
|
|
||||||
no `/store/transaction`, no `/purchased/items`, and NO reference to 65534 anywhere.
|
|
||||||
The "no longer available" verdict is rendered **client-side**.
|
|
||||||
|
|
||||||
**Result class: S1 — pure client-side rejection.**
|
|
||||||
|
|
||||||
**Post-test profile/economy integrity (OBSERVED):** coins 29876776, nextItemId
|
|
||||||
100004837, items 1995, purchased 0, `unopenedPackIds` `[]`, `last_pack` empty — ALL
|
|
||||||
unchanged vs pre-test; 65534 not persisted in items or unopenedPackIds; profile
|
|
||||||
SHA-256 `39bb3e83…` byte-identical. **Zero mutation.**
|
|
||||||
|
|
||||||
**UX assessment:** crash-safe ✓, economy-safe ✓ (no request even sent), navigation
|
|
||||||
recoverable ✓. Blemishes: a **visible empty "0 items" tile**, a **"This pack is no
|
|
||||||
longer available" dialog on explicit click**, and (from §16.5) the **Browse-Packs
|
|
||||||
navigation gate** (must open My Packs first). Notably this is a *strict improvement*
|
|
||||||
over the inactive-sentinel baseline, which throws "pack not available" immediately on
|
|
||||||
STORE OPEN and bounces to the Hub; the active placeholder only errors if the user
|
|
||||||
deliberately clicks the empty tile, and recovers cleanly.
|
|
||||||
|
|
||||||
**Active-placeholder verdict: MARGINALLY ACCEPTABLE.** Safe (crash + economy) and
|
|
||||||
usable, but visibly imperfect (fake tile + click-dialog + browse nav gate). Not
|
|
||||||
UNACCEPTABLE (no crash/economy risk, recoverable); not fully ACCEPTABLE (user-visible
|
|
||||||
defects).
|
|
||||||
|
|
||||||
**Permanent-fix decision: P2.** The active sentinel technically works and is safe, but
|
|
||||||
its UX is poor and — per Candidate F (CONTRADICTED) — **no backend-only *clean*
|
|
||||||
solution exists** (the store's My-Packs resolution is Scaleform/movie-driven, not
|
|
||||||
server-gated). Recommendation: keep the active placeholder as an optional/temporary
|
|
||||||
backend compatibility mode (strictly better than the current inactive-sentinel
|
|
||||||
baseline) and pursue a **client-side** fix for a fully clean zero-pack experience
|
|
||||||
(hiding the fake tile / suppressing the forced My-Packs resolution). NOT implemented.
|
|
||||||
|
|
||||||
**Rollback verification:** container code restored to `c89d43ea…` (== host, ==
|
|
||||||
pre-experiment), sentinel back to `inactive`; `docker restart` (Pid 1562665, StartedAt
|
|
||||||
00:59:04Z); `unopenedPackIds=[]`, 65534 inactive/∉catalog, 70 absent, flags ON/OFF;
|
|
||||||
host `utas_server.py` never edited; profile `39bb3e83…` unchanged. Bridge/Core
|
|
||||||
untouched.
|
|
||||||
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
# FIFA 17 card-table provenance manifest
|
|
||||||
# Source (authoritative): 10.10.0.105:/home/alex/Documents/OpenFUT/fifa17-recon/data/tables/
|
|
||||||
# Dest (this repo): fifa17-recon/data/tables/
|
|
||||||
# Verified 2026-08-12: source and dest byte-identical (sha256), order-independent.
|
|
||||||
# Combined hash-of-hashes: 10f239add919089354d8dbff873fc9737b0a0f80f6ac41b1aa2a096c0ec8d331
|
|
||||||
# 31 fcc_*.json + 5 staff tables = 36 files. Files are DECODED tables:
|
|
||||||
# each carries {table, source, rowcount, schema[], rows[]}; card instances live in rows[].
|
|
||||||
#
|
|
||||||
0d1c9af7ae654c3e4363f18bb89bad03a0631056d36425c84b9a680fa989618c fcc_managerbonusvalues.json
|
|
||||||
0e5d5309cd1d9322476f8047fc6eaf4a88f4f19211b8ea01fe4d28ddd3733134 fcc_healingcards.json
|
|
||||||
1da80e390169ebb8ee8a6543e14b1191d9151f675797f01e23628f6c24d434c5 fcc_misccards.json
|
|
||||||
2212b0ee8d962f0fb6bd346bee35fff6566e22539e397cc2e42bb6efd4dc3ad3 fcc_textposvalues_hd.json
|
|
||||||
2a8e22ddb000b2c08f1a3e5eb47bc56ecf43f733ce6e7519498d332e4f439746 headcoachcards.json
|
|
||||||
3a323e1c0688a4068ccd21be0c9d8e88a875ab10ce2158d3aed79fc14e65f0fb fcc_chemlinkcalc.json
|
|
||||||
4a60bc4c0d8cb8d2b903e152a3dd5302348753568630818fde059b2de41f82ab fcc_leaguelogos.json
|
|
||||||
5304114078200da4564d33612c955598f12a44bdf52f18274184b98922229b8b fcc_GrandStandPlayers.json
|
|
||||||
5503291e381fee5008120615cd6d30a732b97636d4694a88f943eb14cb992741 fcc_leaguelogostickers.json
|
|
||||||
550739c124ca915fb294954afe3d9d04fb7d1faf2b0c96930b2dcdb1bfe7ac8f fcc_formationcardspositions_kc.json
|
|
||||||
5a5aabec1d40ffa21b8effb84f79e4b788cb42352aa592d71d95eba1db0d209e fcc_trainingcards.json
|
|
||||||
6476e396f166905857d2ada4f12cc37645ca42efdca121e8e74270fbf7422336 managercards.json
|
|
||||||
6546f602024973e20d04522a857c6c243473a177cab5b3be8400390651a42fab fcc_navcoords_hd.json
|
|
||||||
6b0209647383e4e940d2af2c3bbb2185a4aac7ac0e799fe6b50ae52e7625710d fcc_contractcards.json
|
|
||||||
6c52c83aafd9d9d3406e21c656762ac5cc0ba4522002f424e5593430bc5190f5 physiocards.json
|
|
||||||
765687d7f1e5c6c989adf45b174a0fdab0f65597c83132304b53b9f859c02586 fcc_stadium.json
|
|
||||||
79e50b07eecc47a0edf4d2a87782e904785e653937698cc712258a82fdf8b079 fcc_formationcardspositions_hd.json
|
|
||||||
7d36e0fbb9349eabd4267215bacbe29d78ff621deeb8cab3380dcac72c535eb9 fcc_preferredformationcalcmid.json
|
|
||||||
8122a4070901662fac97f675a3e4194dd5fd7e02194fdab204989af42676e268 fitnesscoachcards.json
|
|
||||||
828f8b90241672b9f62a9bbd3cb219a1d3bd8856bd160cc46284f2958e08f7d4 fcc_discardcoins.json
|
|
||||||
852bb82ed373881373d8610e6f4f2ca4406bac13da4bda9d6abba693d7cafc56 fcc_myclubscategories.json
|
|
||||||
974dcbbe6a46c02dc97c77df6c270c9a7f09ba23bee23005ecf95fd114ee66a7 gkcoachcards.json
|
|
||||||
9aa4b3b3f226202b21d2e9f96a1508ecce56abba64372099e090df101fce5eeb fcc_nationcalc.json
|
|
||||||
9b6797991520c05f7448b28e66d160f96afc73eefd661ed8272b0a093b6ba89f fcc_kitcards.json
|
|
||||||
9e8e6595fa8d3bcf963eb25bd9f9ea5d131aa92ed0e7e4ae3089adf5e1d55927 fcc_preferredformationcalcst.json
|
|
||||||
a4e8ac2ba0a6db45f1f59fe384fbd39a8cee8fb72a72f846e52daca44f1c7ff9 fcc_myclubs.json
|
|
||||||
bbf405e3a63b6fd03da1237b8b118764c57a40c797faf85d1e4691a1c95a840e fcc_balls.json
|
|
||||||
ca1184bd85cff3308af0104077feda4357ef483fae6335fa964922eea4e94330 fcc_navcoords_kc.json
|
|
||||||
ca3e4ab0f7892aac7473b774de4699c067c54647ca4a82cdd5fd1108c56ed894 fcc_badgecards.json
|
|
||||||
ccdda8ad0a15f73a056fa336abde8739b346d12b78cb8adbea0f0677487bb598 fcc_preferredpositioncalc.json
|
|
||||||
dbf95bddd456137e4b90a44bdd1f637458f4846ecd5c3ba6747d3f069c3f590f fcc_textposvalues_kc.json
|
|
||||||
dd8c2c860b18d999877c37e0f63dac64ef7bb57bff5a2173960cde71242f9a34 fcc_bonusvalues.json
|
|
||||||
df5b997b153941a5bb760ad5bb0fb23dc16cf8612b300559be23ac929b55eec6 fcc_leagues.json
|
|
||||||
e4619db324a7848639a8ba53f513cf3ea153eeaf698de05d71e56c319b3cc424 fcc_coinrewards.json
|
|
||||||
e6b1ca3ecb7c3923d77e73bda2e6c3f9794b9158354d566112f7979b33b4422c fcc_preferredformationcalcgk.json
|
|
||||||
f54814d61b72dd2b6186e9e414df4fbfbdbea1732da6a4622f001a1ff03bc12a fcc_preferredformationcalcback.json
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
# Live UTAS wire captures — 2026-08-15
|
|
||||||
|
|
||||||
Fresh sanitised FIFA 17 UTAS wire, captured during the post-P1 staging A/B on a
|
|
||||||
**real FIFA 17 client** (`10.10.0.105`) driving the isolated post-P1 candidate
|
|
||||||
backend (`10.10.0.121`, host bin `fda40d12`, Core `fbb54ea`). Production untouched.
|
|
||||||
|
|
||||||
This rebuilds the primary-capture corpus that was lost to `.gitignore` (Known Issues
|
|
||||||
#200 / "take sanitised captures on the next live FIFA run and commit them").
|
|
||||||
|
|
||||||
## Files
|
|
||||||
- `utas-requests.sanitised.txt` — 10 real client requests (`---`-separated).
|
|
||||||
- `utas-responses.sanitised.txt` — 12 responses.
|
|
||||||
|
|
||||||
Captured with `tcpdump` on the container netns (UTAS is plain HTTP on `:8099`),
|
|
||||||
reassembled + split + **sanitised** by `openfut-staging/extract_http.py`. Redacted:
|
|
||||||
`X-UT-SID`/`sid` (`<SID>`), `authCode` (`<AUTH>`), `deviceId` (`<DEV>`), `macAddress`
|
|
||||||
(`<MAC>`), and any 32+char hex token (`<TOKEN>`). The raw `.pcap` is intentionally
|
|
||||||
NOT committed (it is unsanitised).
|
|
||||||
|
|
||||||
## Route contracts captured (verbatim shapes)
|
|
||||||
|
|
||||||
### POST /openfut/account/sync (launcher control-plane, Python-served)
|
|
||||||
Request (launcher → backend):
|
|
||||||
```json
|
|
||||||
{"personaId":33068179,"personaName":"CAGE","level":1,"experience":0,
|
|
||||||
"experienceMax":1000,"accountFunds":0,"accountFundsCap":100000}
|
|
||||||
```
|
|
||||||
Response:
|
|
||||||
```json
|
|
||||||
{"account":{"personaId":33068179,"personaName":"CAGE","clubName":"OpenFUT",
|
|
||||||
"clubAbbr":"OFC","level":1,"experience":0,"experienceMax":1000,"accountFunds":0,
|
|
||||||
"accountFundsCap":100000,"profilePath":"accounts/33068179/fifa17_profile.json",
|
|
||||||
"coins":29826776,"unopenedPacks":0},"status":"OK"}
|
|
||||||
```
|
|
||||||
NOTE: `coins` here is Python's STALE profile value (29,826,776) — Core's authoritative
|
|
||||||
balance at capture time was 29,859,876. The launcher's `AccountSummary` parser is
|
|
||||||
lenient and requires only `{personaId,personaName,level,experience,accountFunds,coins,
|
|
||||||
unopenedPacks}` (clubName/clubAbbr/etc. ignored). BLOCKER for a Rust migration is NOT
|
|
||||||
the shape — it is that Python `account/sync` also *selects the active profile* the
|
|
||||||
still-Python-served userMassInfo envelope depends on, so it is coupled to the
|
|
||||||
userMassInfo hybrid and cannot migrate standalone.
|
|
||||||
|
|
||||||
### GET /ut/game/fifa17/store/purchasegroup/all?ppInfo=true (empty My-Packs sentinel)
|
|
||||||
The `mypacks` group carries the synthetic sentinel pack when My Packs is empty:
|
|
||||||
```json
|
|
||||||
{"assetId":65534,"displayGroup":{"priority":1,"value":"mypacks"},"id":65534,
|
|
||||||
"packType":"GOLD","packContentInfo":{"goldQuantity":0,"itemQuantity":0,...},
|
|
||||||
"state":"active","unopened":false}
|
|
||||||
```
|
|
||||||
Client renders empty My Packs AS Browse Packs (no crash) — the bug-6c resolver guard.
|
|
||||||
|
|
||||||
### GET /ut/game/fifa17/userMassInfo (RUST_OVERLAY hybrid, full 8 KB envelope captured)
|
|
||||||
The complete envelope is in `utas-responses.sanitised.txt`: root `pileSizeClientData` +
|
|
||||||
`userInfo` (with `clubName`, `clubAbbr`, `established`, `accountCreatedPlatformName`,
|
|
||||||
`currencies` [Core coins overlaid by Rust], `won/draw/loss`, `clubNameChangeAllowed`,
|
|
||||||
`divisionOffline/Online`, `purchased`, `feature`, `reliability`, `bidTokens`, `trophies`,
|
|
||||||
`sessionCoinsBankBalance`, `actives`, `squadList`). Field spec is authoritative in
|
|
||||||
`fifa17-recon/tools/utas_server.py::user_info()`. This is the target shape for a future
|
|
||||||
full-Rust userMassInfo (needs the `clubAbbr`/`established` account triad, which Core lacks).
|
|
||||||
|
|
||||||
### Other captured request lines
|
|
||||||
`POST /ut/auth`, `GET user/accountinfo`, `GET settings`, `GET phishing/trusteddevice`,
|
|
||||||
`PUT match/reset`, `GET userMassInfo`, `PUT clientdata/userHubData`,
|
|
||||||
`PUT /ut/v2/game/fifa17/store/transaction/0`.
|
|
||||||
|
|
||||||
See `openfut-staging/p1p-live-2026-08-15/AB_VERDICT.md` for the full A/B result.
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
POST /openfut/account/sync HTTP/1.1
|
|
||||||
Host: 10.10.0.121:8099
|
|
||||||
Content-Type: application/json
|
|
||||||
Content-Length: 131
|
|
||||||
Connection: close
|
|
||||||
|
|
||||||
{"personaId":33068179,"personaName":"CAGE","level":1,"experience":0,"experienceMax":1000,"accountFunds":0,"accountFundsCap":100000}
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
GET /ut/game/fifa17/user/accountinfo HTTP/1.1
|
|
||||||
Host: 10.10.0.121:8099
|
|
||||||
Connection: Close
|
|
||||||
User-Agent: ProtoHttp 1.3/DS 15.1.2.1.0 (Windows)
|
|
||||||
Accept: application/json
|
|
||||||
Content-Type: application/json
|
|
||||||
Accept-Encoding: gzip
|
|
||||||
Easw-Session-Data-Nucleus-Id: 33068179
|
|
||||||
Accept-Encoding: gzip
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
POST /ut/auth HTTP/1.1
|
|
||||||
Host: 10.10.0.121:8099
|
|
||||||
Content-Length: 380
|
|
||||||
Connection: Close
|
|
||||||
User-Agent: ProtoHttp 1.3/DS 15.1.2.1.0 (Windows)
|
|
||||||
Accept: application/json
|
|
||||||
Content-Type: application/json
|
|
||||||
Hash: <TOKEN>
|
|
||||||
|
|
||||||
{"isReadOnly":false,"priorityLevel":6,"sku":"FFA17PCC","nucleusPersonaPlatform":"pc","clientVersion":3,"nuc":33068179,"nucleusPersonaId":33068179,"nucleusPersonaDisplayName":"CAGE","locale":"en-US","regionCode":"US","deviceId":"<DEV>","macAddress":"<MAC>","method":"authcode","identification":{"authCode":"<AUTH>"}}
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
GET /ut/game/fifa17/settings HTTP/1.1
|
|
||||||
Host: 10.10.0.121:8099
|
|
||||||
User-Agent: ProtoHttp 1.3/DS 15.1.2.1.0 (Windows)
|
|
||||||
Accept: application/json
|
|
||||||
Content-Type: application/json
|
|
||||||
X-UT-SID: <SID>
|
|
||||||
Accept-Encoding: gzip
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
GET /ut/game/fifa17/phishing/trusteddevice?deviceId=<DEV> HTTP/1.1
|
|
||||||
Host: 10.10.0.121:8099
|
|
||||||
User-Agent: ProtoHttp 1.3/DS 15.1.2.1.0 (Windows)
|
|
||||||
Accept: application/json
|
|
||||||
Content-Type: application/json
|
|
||||||
X-UT-SID: <SID>
|
|
||||||
Accept-Encoding: gzip
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
PUT /ut/game/fifa17/match/reset HTTP/1.1
|
|
||||||
Host: 10.10.0.121:8099
|
|
||||||
Content-Length: 0
|
|
||||||
User-Agent: ProtoHttp 1.3/DS 15.1.2.1.0 (Windows)
|
|
||||||
Accept: application/json
|
|
||||||
Content-Type: application/json
|
|
||||||
X-UT-SID: <SID>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
GET /ut/game/fifa17/userMassInfo HTTP/1.1
|
|
||||||
Host: 10.10.0.121:8099
|
|
||||||
User-Agent: ProtoHttp 1.3/DS 15.1.2.1.0 (Windows)
|
|
||||||
Accept: application/json
|
|
||||||
Content-Type: application/json
|
|
||||||
X-UT-SID: <SID>
|
|
||||||
Accept-Encoding: gzip
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
PUT /ut/v2/game/fifa17/store/transaction/0 HTTP/1.1
|
|
||||||
Host: 10.10.0.121:8099
|
|
||||||
Content-Length: 29
|
|
||||||
User-Agent: ProtoHttp 1.3/DS 15.1.2.1.0 (Windows)
|
|
||||||
Accept: application/json
|
|
||||||
Content-Type: application/json
|
|
||||||
X-UT-SID: <SID>
|
|
||||||
|
|
||||||
{"state":"TRANSACTIONCANCEL"}
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
PUT /ut/game/fifa17/clientdata/userHubData HTTP/1.1
|
|
||||||
Host: 10.10.0.121:8099
|
|
||||||
Content-Length: 53
|
|
||||||
User-Agent: ProtoHttp 1.3/DS 15.1.2.1.0 (Windows)
|
|
||||||
Accept: application/json
|
|
||||||
Content-Type: application/json
|
|
||||||
X-UT-SID: <SID>
|
|
||||||
|
|
||||||
{"entries":[{"key":0,"value":0},{"key":1,"value":1}]}
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
GET /ut/game/fifa17/store/purchasegroup/all?ppInfo=true HTTP/1.1
|
|
||||||
Host: 10.10.0.121:8099
|
|
||||||
User-Agent: ProtoHttp 1.3/DS 15.1.2.1.0 (Windows)
|
|
||||||
Accept: application/json
|
|
||||||
Content-Type: application/json
|
|
||||||
X-UT-SID: <SID>
|
|
||||||
Accept-Encoding: gzip
|
|
||||||
|
|
||||||
File diff suppressed because one or more lines are too long
@@ -1,67 +0,0 @@
|
|||||||
{
|
|
||||||
"champion": {
|
|
||||||
"bytes": 2,
|
|
||||||
"path": "/ut/game/fifa17/champion",
|
|
||||||
"status": 200
|
|
||||||
},
|
|
||||||
"clubUser": {
|
|
||||||
"bytes": 2,
|
|
||||||
"path": "/ut/game/fifa17/clubUser",
|
|
||||||
"status": 200
|
|
||||||
},
|
|
||||||
"defid": {
|
|
||||||
"bytes": 600,
|
|
||||||
"path": "/ut/game/fifa17/defid?definitionId=200389",
|
|
||||||
"status": 200
|
|
||||||
},
|
|
||||||
"defs_resource": {
|
|
||||||
"bytes": 600,
|
|
||||||
"path": "/ut/game/fifa17/item/resource?resourceId=200389",
|
|
||||||
"status": 200
|
|
||||||
},
|
|
||||||
"draft_state": {
|
|
||||||
"bytes": 118,
|
|
||||||
"path": "/ut/game/fifa17/squad/mode/draft/state",
|
|
||||||
"status": 200
|
|
||||||
},
|
|
||||||
"marketdata": {
|
|
||||||
"bytes": 110,
|
|
||||||
"path": "/ut/game/fifa17/marketdata/pricelimits?defId=200389,200104",
|
|
||||||
"status": 200
|
|
||||||
},
|
|
||||||
"sbs_sets": {
|
|
||||||
"bytes": 537,
|
|
||||||
"path": "/ut/game/fifa17/sbs/sets",
|
|
||||||
"status": 200
|
|
||||||
},
|
|
||||||
"season": {
|
|
||||||
"bytes": 2,
|
|
||||||
"path": "/ut/game/fifa17/season",
|
|
||||||
"status": 200
|
|
||||||
},
|
|
||||||
"squad_0": {
|
|
||||||
"bytes": 7792,
|
|
||||||
"path": "/ut/game/fifa17/squad/0",
|
|
||||||
"status": 200
|
|
||||||
},
|
|
||||||
"tournament": {
|
|
||||||
"bytes": 2,
|
|
||||||
"path": "/ut/game/fifa17/tournament",
|
|
||||||
"status": 200
|
|
||||||
},
|
|
||||||
"user": {
|
|
||||||
"bytes": 751,
|
|
||||||
"path": "/ut/game/fifa17/user",
|
|
||||||
"status": 200
|
|
||||||
},
|
|
||||||
"user_list": {
|
|
||||||
"bytes": 2,
|
|
||||||
"path": "/ut/game/fifa17/user/list",
|
|
||||||
"status": 200
|
|
||||||
},
|
|
||||||
"watchList": {
|
|
||||||
"bytes": 52,
|
|
||||||
"path": "/ut/game/fifa17/watchList",
|
|
||||||
"status": 200
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
{
|
|
||||||
"itemData": [
|
|
||||||
{
|
|
||||||
"assetId": 200389,
|
|
||||||
"attributeList": [
|
|
||||||
{
|
|
||||||
"index": 0,
|
|
||||||
"value": 70
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 1,
|
|
||||||
"value": 70
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 2,
|
|
||||||
"value": 70
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 3,
|
|
||||||
"value": 70
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 4,
|
|
||||||
"value": 70
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 5,
|
|
||||||
"value": 70
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"cardType": 0,
|
|
||||||
"cardassetid": 200389,
|
|
||||||
"cardsubtypeid": 0,
|
|
||||||
"commodityId": 200389,
|
|
||||||
"commonName": "Player",
|
|
||||||
"definitionId": 200389,
|
|
||||||
"id": 200389,
|
|
||||||
"itemState": "free",
|
|
||||||
"itemType": "player",
|
|
||||||
"lastName": "Player",
|
|
||||||
"leagueId": 0,
|
|
||||||
"name": "Player",
|
|
||||||
"nation": 0,
|
|
||||||
"playStyle": 250,
|
|
||||||
"preferredPosition": "CM",
|
|
||||||
"rareflag": 1,
|
|
||||||
"rating": 75,
|
|
||||||
"resourceId": 200389,
|
|
||||||
"teamid": 0,
|
|
||||||
"untradeable": true
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
{
|
|
||||||
"itemData": [
|
|
||||||
{
|
|
||||||
"assetId": 200389,
|
|
||||||
"attributeList": [
|
|
||||||
{
|
|
||||||
"index": 0,
|
|
||||||
"value": 70
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 1,
|
|
||||||
"value": 70
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 2,
|
|
||||||
"value": 70
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 3,
|
|
||||||
"value": 70
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 4,
|
|
||||||
"value": 70
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 5,
|
|
||||||
"value": 70
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"cardType": 0,
|
|
||||||
"cardassetid": 200389,
|
|
||||||
"cardsubtypeid": 0,
|
|
||||||
"commodityId": 200389,
|
|
||||||
"commonName": "Player",
|
|
||||||
"definitionId": 200389,
|
|
||||||
"id": 200389,
|
|
||||||
"itemState": "free",
|
|
||||||
"itemType": "player",
|
|
||||||
"lastName": "Player",
|
|
||||||
"leagueId": 0,
|
|
||||||
"name": "Player",
|
|
||||||
"nation": 0,
|
|
||||||
"playStyle": 250,
|
|
||||||
"preferredPosition": "CM",
|
|
||||||
"rareflag": 1,
|
|
||||||
"rating": 75,
|
|
||||||
"resourceId": 200389,
|
|
||||||
"teamid": 0,
|
|
||||||
"untradeable": true
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
[
|
|
||||||
{
|
|
||||||
"gamesWonCurrentMatch": 0,
|
|
||||||
"roundsInfo": [],
|
|
||||||
"squadState": "INVALID",
|
|
||||||
"stateParam1": "INVALID",
|
|
||||||
"stateParam2": "0"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
[
|
|
||||||
{
|
|
||||||
"defId": 200389,
|
|
||||||
"maxPrice": 15000,
|
|
||||||
"minPrice": 150
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"defId": 200104,
|
|
||||||
"maxPrice": 15000,
|
|
||||||
"minPrice": 150
|
|
||||||
}
|
|
||||||
]
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
{
|
|
||||||
"categories": [
|
|
||||||
{
|
|
||||||
"categoryId": 1,
|
|
||||||
"name": "Foundations",
|
|
||||||
"priority": 1,
|
|
||||||
"sets": [
|
|
||||||
{
|
|
||||||
"awards": [],
|
|
||||||
"categoryId": 1,
|
|
||||||
"challengesCompletedCount": 0,
|
|
||||||
"challengesCount": 1,
|
|
||||||
"description": "Submit an 11-player squad.",
|
|
||||||
"endTime": 4102444800,
|
|
||||||
"hidden": false,
|
|
||||||
"name": "Bronze Challenge",
|
|
||||||
"priority": 1,
|
|
||||||
"setId": 1
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"awards": [],
|
|
||||||
"categoryId": 1,
|
|
||||||
"challengesCompletedCount": 0,
|
|
||||||
"challengesCount": 1,
|
|
||||||
"description": "Get started with your first SBC.",
|
|
||||||
"endTime": 4102444800,
|
|
||||||
"hidden": false,
|
|
||||||
"name": "Simple Start",
|
|
||||||
"priority": 2,
|
|
||||||
"setId": 2
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{}
|
|
||||||
@@ -1,707 +0,0 @@
|
|||||||
{
|
|
||||||
"actives": [],
|
|
||||||
"captain": 100000001,
|
|
||||||
"changed": 0,
|
|
||||||
"chemistry": 49,
|
|
||||||
"custom": "[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,50,50,0,50,40,65,0,65,50,50,1]",
|
|
||||||
"formation": "f433",
|
|
||||||
"id": 0,
|
|
||||||
"kicktakers": [
|
|
||||||
{
|
|
||||||
"dream": false,
|
|
||||||
"id": 100000001,
|
|
||||||
"index": 0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"dream": false,
|
|
||||||
"id": 100000001,
|
|
||||||
"index": 1
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"dream": false,
|
|
||||||
"id": 100000001,
|
|
||||||
"index": 2
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"dream": false,
|
|
||||||
"id": 100000001,
|
|
||||||
"index": 3
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"dream": false,
|
|
||||||
"id": 100000001,
|
|
||||||
"index": 4
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"manager": [
|
|
||||||
{
|
|
||||||
"dream": false,
|
|
||||||
"id": 100000427
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"personaId": 33068179,
|
|
||||||
"players": [
|
|
||||||
{
|
|
||||||
"index": 0,
|
|
||||||
"itemData": {
|
|
||||||
"assetId": 200389,
|
|
||||||
"attributeList": [
|
|
||||||
{
|
|
||||||
"index": 0,
|
|
||||||
"value": 83
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 1,
|
|
||||||
"value": 90
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 2,
|
|
||||||
"value": 77
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 3,
|
|
||||||
"value": 82
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 4,
|
|
||||||
"value": 50
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 5,
|
|
||||||
"value": 87
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"cardassetid": 200389,
|
|
||||||
"cardsubtypeid": 0,
|
|
||||||
"contract": 7,
|
|
||||||
"definitionId": 200389,
|
|
||||||
"fitness": 99,
|
|
||||||
"id": 100000003,
|
|
||||||
"itemState": "free",
|
|
||||||
"itemType": "player",
|
|
||||||
"leagueId": 53,
|
|
||||||
"nation": 44,
|
|
||||||
"owners": 1,
|
|
||||||
"playStyle": 250,
|
|
||||||
"preferredPosition": "GK",
|
|
||||||
"rareflag": 1,
|
|
||||||
"rating": 87,
|
|
||||||
"resourceId": 200389,
|
|
||||||
"teamid": 240,
|
|
||||||
"untradeable": true
|
|
||||||
},
|
|
||||||
"kitNumber": 1
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 1,
|
|
||||||
"itemData": {
|
|
||||||
"assetId": 197445,
|
|
||||||
"attributeList": [
|
|
||||||
{
|
|
||||||
"index": 0,
|
|
||||||
"value": 86
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 1,
|
|
||||||
"value": 73
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 2,
|
|
||||||
"value": 81
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 3,
|
|
||||||
"value": 83
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 4,
|
|
||||||
"value": 83
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 5,
|
|
||||||
"value": 73
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"cardassetid": 197445,
|
|
||||||
"cardsubtypeid": 0,
|
|
||||||
"contract": 7,
|
|
||||||
"definitionId": 197445,
|
|
||||||
"fitness": 99,
|
|
||||||
"id": 100000006,
|
|
||||||
"itemState": "free",
|
|
||||||
"itemType": "player",
|
|
||||||
"leagueId": 19,
|
|
||||||
"nation": 4,
|
|
||||||
"owners": 1,
|
|
||||||
"playStyle": 250,
|
|
||||||
"preferredPosition": "LB",
|
|
||||||
"rareflag": 1,
|
|
||||||
"rating": 87,
|
|
||||||
"resourceId": 197445,
|
|
||||||
"teamid": 21,
|
|
||||||
"untradeable": true
|
|
||||||
},
|
|
||||||
"kitNumber": 4
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 2,
|
|
||||||
"itemData": {
|
|
||||||
"assetId": 155862,
|
|
||||||
"attributeList": [
|
|
||||||
{
|
|
||||||
"index": 0,
|
|
||||||
"value": 78
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 1,
|
|
||||||
"value": 63
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 2,
|
|
||||||
"value": 70
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 3,
|
|
||||||
"value": 70
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 4,
|
|
||||||
"value": 87
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 5,
|
|
||||||
"value": 83
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"cardassetid": 155862,
|
|
||||||
"cardsubtypeid": 0,
|
|
||||||
"contract": 7,
|
|
||||||
"definitionId": 155862,
|
|
||||||
"fitness": 99,
|
|
||||||
"id": 100000005,
|
|
||||||
"itemState": "free",
|
|
||||||
"itemType": "player",
|
|
||||||
"leagueId": 53,
|
|
||||||
"nation": 45,
|
|
||||||
"owners": 1,
|
|
||||||
"playStyle": 250,
|
|
||||||
"preferredPosition": "CB",
|
|
||||||
"rareflag": 1,
|
|
||||||
"rating": 89,
|
|
||||||
"resourceId": 155862,
|
|
||||||
"teamid": 243,
|
|
||||||
"untradeable": true
|
|
||||||
},
|
|
||||||
"kitNumber": 3
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 3,
|
|
||||||
"itemData": {
|
|
||||||
"assetId": 182521,
|
|
||||||
"attributeList": [
|
|
||||||
{
|
|
||||||
"index": 0,
|
|
||||||
"value": 45
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 1,
|
|
||||||
"value": 80
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 2,
|
|
||||||
"value": 88
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 3,
|
|
||||||
"value": 79
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 4,
|
|
||||||
"value": 69
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 5,
|
|
||||||
"value": 70
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"cardassetid": 182521,
|
|
||||||
"cardsubtypeid": 0,
|
|
||||||
"contract": 7,
|
|
||||||
"definitionId": 182521,
|
|
||||||
"fitness": 99,
|
|
||||||
"id": 100000008,
|
|
||||||
"itemState": "free",
|
|
||||||
"itemType": "player",
|
|
||||||
"leagueId": 53,
|
|
||||||
"nation": 21,
|
|
||||||
"owners": 1,
|
|
||||||
"playStyle": 250,
|
|
||||||
"preferredPosition": "CM",
|
|
||||||
"rareflag": 1,
|
|
||||||
"rating": 88,
|
|
||||||
"resourceId": 182521,
|
|
||||||
"teamid": 243,
|
|
||||||
"untradeable": true
|
|
||||||
},
|
|
||||||
"kitNumber": 6
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 4,
|
|
||||||
"itemData": {
|
|
||||||
"assetId": 189332,
|
|
||||||
"attributeList": [
|
|
||||||
{
|
|
||||||
"index": 0,
|
|
||||||
"value": 93
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 1,
|
|
||||||
"value": 69
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 2,
|
|
||||||
"value": 75
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 3,
|
|
||||||
"value": 83
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 4,
|
|
||||||
"value": 81
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 5,
|
|
||||||
"value": 75
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"cardassetid": 189332,
|
|
||||||
"cardsubtypeid": 0,
|
|
||||||
"contract": 7,
|
|
||||||
"definitionId": 189332,
|
|
||||||
"fitness": 99,
|
|
||||||
"id": 100000007,
|
|
||||||
"itemState": "free",
|
|
||||||
"itemType": "player",
|
|
||||||
"leagueId": 53,
|
|
||||||
"nation": 45,
|
|
||||||
"owners": 1,
|
|
||||||
"playStyle": 250,
|
|
||||||
"preferredPosition": "LB",
|
|
||||||
"rareflag": 1,
|
|
||||||
"rating": 86,
|
|
||||||
"resourceId": 189332,
|
|
||||||
"teamid": 241,
|
|
||||||
"untradeable": true
|
|
||||||
},
|
|
||||||
"kitNumber": 5
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 5,
|
|
||||||
"itemData": {
|
|
||||||
"assetId": 158023,
|
|
||||||
"attributeList": [
|
|
||||||
{
|
|
||||||
"index": 0,
|
|
||||||
"value": 89
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 1,
|
|
||||||
"value": 90
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 2,
|
|
||||||
"value": 86
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 3,
|
|
||||||
"value": 96
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 4,
|
|
||||||
"value": 26
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 5,
|
|
||||||
"value": 61
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"cardassetid": 158023,
|
|
||||||
"cardsubtypeid": 0,
|
|
||||||
"contract": 7,
|
|
||||||
"definitionId": 158023,
|
|
||||||
"fitness": 99,
|
|
||||||
"id": 100000002,
|
|
||||||
"itemState": "free",
|
|
||||||
"itemType": "player",
|
|
||||||
"leagueId": 53,
|
|
||||||
"nation": 52,
|
|
||||||
"owners": 1,
|
|
||||||
"playStyle": 250,
|
|
||||||
"preferredPosition": "RW",
|
|
||||||
"rareflag": 1,
|
|
||||||
"rating": 93,
|
|
||||||
"resourceId": 158023,
|
|
||||||
"teamid": 241,
|
|
||||||
"untradeable": true
|
|
||||||
},
|
|
||||||
"kitNumber": 9
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 6,
|
|
||||||
"itemData": {
|
|
||||||
"assetId": 183907,
|
|
||||||
"attributeList": [
|
|
||||||
{
|
|
||||||
"index": 0,
|
|
||||||
"value": 79
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 1,
|
|
||||||
"value": 50
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 2,
|
|
||||||
"value": 72
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 3,
|
|
||||||
"value": 68
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 4,
|
|
||||||
"value": 90
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 5,
|
|
||||||
"value": 85
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"cardassetid": 183907,
|
|
||||||
"cardsubtypeid": 0,
|
|
||||||
"contract": 7,
|
|
||||||
"definitionId": 183907,
|
|
||||||
"fitness": 99,
|
|
||||||
"id": 100000004,
|
|
||||||
"itemState": "free",
|
|
||||||
"itemType": "player",
|
|
||||||
"leagueId": 19,
|
|
||||||
"nation": 21,
|
|
||||||
"owners": 1,
|
|
||||||
"playStyle": 250,
|
|
||||||
"preferredPosition": "CB",
|
|
||||||
"rareflag": 1,
|
|
||||||
"rating": 90,
|
|
||||||
"resourceId": 183907,
|
|
||||||
"teamid": 21,
|
|
||||||
"untradeable": true
|
|
||||||
},
|
|
||||||
"kitNumber": 2
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 7,
|
|
||||||
"itemData": {
|
|
||||||
"assetId": 183277,
|
|
||||||
"attributeList": [
|
|
||||||
{
|
|
||||||
"index": 0,
|
|
||||||
"value": 90
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 1,
|
|
||||||
"value": 81
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 2,
|
|
||||||
"value": 82
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 3,
|
|
||||||
"value": 91
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 4,
|
|
||||||
"value": 32
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 5,
|
|
||||||
"value": 64
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"cardassetid": 183277,
|
|
||||||
"cardsubtypeid": 0,
|
|
||||||
"contract": 7,
|
|
||||||
"definitionId": 183277,
|
|
||||||
"fitness": 99,
|
|
||||||
"id": 100000009,
|
|
||||||
"itemState": "free",
|
|
||||||
"itemType": "player",
|
|
||||||
"leagueId": 13,
|
|
||||||
"nation": 7,
|
|
||||||
"owners": 1,
|
|
||||||
"playStyle": 250,
|
|
||||||
"preferredPosition": "LM",
|
|
||||||
"rareflag": 1,
|
|
||||||
"rating": 88,
|
|
||||||
"resourceId": 183277,
|
|
||||||
"teamid": 5,
|
|
||||||
"untradeable": true
|
|
||||||
},
|
|
||||||
"kitNumber": 7
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 8,
|
|
||||||
"itemData": {
|
|
||||||
"assetId": 176580,
|
|
||||||
"attributeList": [
|
|
||||||
{
|
|
||||||
"index": 0,
|
|
||||||
"value": 82
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 1,
|
|
||||||
"value": 90
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 2,
|
|
||||||
"value": 79
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 3,
|
|
||||||
"value": 87
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 4,
|
|
||||||
"value": 42
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 5,
|
|
||||||
"value": 79
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"cardassetid": 176580,
|
|
||||||
"cardsubtypeid": 0,
|
|
||||||
"contract": 7,
|
|
||||||
"definitionId": 176580,
|
|
||||||
"fitness": 99,
|
|
||||||
"id": 100000010,
|
|
||||||
"itemState": "free",
|
|
||||||
"itemType": "player",
|
|
||||||
"leagueId": 53,
|
|
||||||
"nation": 60,
|
|
||||||
"owners": 1,
|
|
||||||
"playStyle": 250,
|
|
||||||
"preferredPosition": "ST",
|
|
||||||
"rareflag": 1,
|
|
||||||
"rating": 92,
|
|
||||||
"resourceId": 176580,
|
|
||||||
"teamid": 241,
|
|
||||||
"untradeable": true
|
|
||||||
},
|
|
||||||
"kitNumber": 10
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 9,
|
|
||||||
"itemData": {
|
|
||||||
"assetId": 188545,
|
|
||||||
"attributeList": [
|
|
||||||
{
|
|
||||||
"index": 0,
|
|
||||||
"value": 81
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 1,
|
|
||||||
"value": 87
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 2,
|
|
||||||
"value": 74
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 3,
|
|
||||||
"value": 85
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 4,
|
|
||||||
"value": 38
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 5,
|
|
||||||
"value": 82
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"cardassetid": 188545,
|
|
||||||
"cardsubtypeid": 0,
|
|
||||||
"contract": 7,
|
|
||||||
"definitionId": 188545,
|
|
||||||
"fitness": 99,
|
|
||||||
"id": 100000025,
|
|
||||||
"itemState": "free",
|
|
||||||
"itemType": "player",
|
|
||||||
"leagueId": 19,
|
|
||||||
"nation": 37,
|
|
||||||
"owners": 1,
|
|
||||||
"pile": "club",
|
|
||||||
"playStyle": 250,
|
|
||||||
"preferredPosition": "ST",
|
|
||||||
"rareflag": 1,
|
|
||||||
"rating": 90,
|
|
||||||
"resourceId": 188545,
|
|
||||||
"teamid": 21,
|
|
||||||
"untradeable": true
|
|
||||||
},
|
|
||||||
"kitNumber": 11
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 10,
|
|
||||||
"itemData": {
|
|
||||||
"assetId": 20801,
|
|
||||||
"attributeList": [
|
|
||||||
{
|
|
||||||
"index": 0,
|
|
||||||
"value": 92
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 1,
|
|
||||||
"value": 92
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 2,
|
|
||||||
"value": 81
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 3,
|
|
||||||
"value": 91
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 4,
|
|
||||||
"value": 33
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 5,
|
|
||||||
"value": 80
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"cardassetid": 20801,
|
|
||||||
"cardsubtypeid": 0,
|
|
||||||
"contract": 7,
|
|
||||||
"definitionId": 20801,
|
|
||||||
"fitness": 99,
|
|
||||||
"id": 100000001,
|
|
||||||
"itemState": "free",
|
|
||||||
"itemType": "player",
|
|
||||||
"leagueId": 53,
|
|
||||||
"nation": 38,
|
|
||||||
"owners": 1,
|
|
||||||
"playStyle": 250,
|
|
||||||
"preferredPosition": "LW",
|
|
||||||
"rareflag": 1,
|
|
||||||
"rating": 94,
|
|
||||||
"resourceId": 20801,
|
|
||||||
"teamid": 243,
|
|
||||||
"untradeable": true
|
|
||||||
},
|
|
||||||
"kitNumber": 8
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 11,
|
|
||||||
"itemData": {
|
|
||||||
"dream": false,
|
|
||||||
"id": 0
|
|
||||||
},
|
|
||||||
"kitNumber": 0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 12,
|
|
||||||
"itemData": {
|
|
||||||
"dream": false,
|
|
||||||
"id": 0
|
|
||||||
},
|
|
||||||
"kitNumber": 0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 13,
|
|
||||||
"itemData": {
|
|
||||||
"dream": false,
|
|
||||||
"id": 0
|
|
||||||
},
|
|
||||||
"kitNumber": 0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 14,
|
|
||||||
"itemData": {
|
|
||||||
"dream": false,
|
|
||||||
"id": 0
|
|
||||||
},
|
|
||||||
"kitNumber": 0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 15,
|
|
||||||
"itemData": {
|
|
||||||
"dream": false,
|
|
||||||
"id": 0
|
|
||||||
},
|
|
||||||
"kitNumber": 0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 16,
|
|
||||||
"itemData": {
|
|
||||||
"dream": false,
|
|
||||||
"id": 0
|
|
||||||
},
|
|
||||||
"kitNumber": 0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 17,
|
|
||||||
"itemData": {
|
|
||||||
"dream": false,
|
|
||||||
"id": 0
|
|
||||||
},
|
|
||||||
"kitNumber": 0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 18,
|
|
||||||
"itemData": {
|
|
||||||
"dream": false,
|
|
||||||
"id": 0
|
|
||||||
},
|
|
||||||
"kitNumber": 0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 19,
|
|
||||||
"itemData": {
|
|
||||||
"dream": false,
|
|
||||||
"id": 0
|
|
||||||
},
|
|
||||||
"kitNumber": 0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 20,
|
|
||||||
"itemData": {
|
|
||||||
"dream": false,
|
|
||||||
"id": 0
|
|
||||||
},
|
|
||||||
"kitNumber": 0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 21,
|
|
||||||
"itemData": {
|
|
||||||
"dream": false,
|
|
||||||
"id": 0
|
|
||||||
},
|
|
||||||
"kitNumber": 0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 22,
|
|
||||||
"itemData": {
|
|
||||||
"dream": false,
|
|
||||||
"id": 0
|
|
||||||
},
|
|
||||||
"kitNumber": 0
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"rating": 90,
|
|
||||||
"squadName": "OpenFUT",
|
|
||||||
"squadType": "REGULAR_SQUAD",
|
|
||||||
"starRating": 90
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{}
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
{
|
|
||||||
"userInfo": {
|
|
||||||
"accountCreatedPlatformName": "pc",
|
|
||||||
"actives": [],
|
|
||||||
"bidTokens": {
|
|
||||||
"count": 0,
|
|
||||||
"updateTime": 0
|
|
||||||
},
|
|
||||||
"clubAbbr": "OFC",
|
|
||||||
"clubName": "OpenFUT",
|
|
||||||
"clubNameChangeAllowed": false,
|
|
||||||
"currencies": [
|
|
||||||
{
|
|
||||||
"active": true,
|
|
||||||
"finalFunds": 29876776,
|
|
||||||
"funds": 29876776,
|
|
||||||
"name": "coins"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"active": true,
|
|
||||||
"finalFunds": 0,
|
|
||||||
"funds": 0,
|
|
||||||
"name": "points"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"divisionOffline": 10,
|
|
||||||
"divisionOnline": 10,
|
|
||||||
"draw": 0,
|
|
||||||
"established": "2026",
|
|
||||||
"feature": {},
|
|
||||||
"loss": 0,
|
|
||||||
"personaId": 33068179,
|
|
||||||
"purchased": false,
|
|
||||||
"reliability": {
|
|
||||||
"matchUnfinishedTime": 0,
|
|
||||||
"reliability": 100
|
|
||||||
},
|
|
||||||
"sessionCoinsBankBalance": 0,
|
|
||||||
"squadList": {
|
|
||||||
"squad": [
|
|
||||||
{
|
|
||||||
"chemistry": 49,
|
|
||||||
"formation": "f433",
|
|
||||||
"id": 0,
|
|
||||||
"rating": 89,
|
|
||||||
"squadName": "OpenFUT",
|
|
||||||
"squadType": "REGULAR_SQUAD"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"trophies": 0,
|
|
||||||
"won": 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
{
|
|
||||||
"auctionInfo": [],
|
|
||||||
"credits": 29876776,
|
|
||||||
"total": 0
|
|
||||||
}
|
|
||||||
@@ -1,199 +0,0 @@
|
|||||||
{
|
|
||||||
"purchase": [
|
|
||||||
{
|
|
||||||
"assetId": 1,
|
|
||||||
"id": 1,
|
|
||||||
"packType": "BRONZE",
|
|
||||||
"description": "Bronze Pack",
|
|
||||||
"state": "active",
|
|
||||||
"saleType": "promo",
|
|
||||||
"limitType": "NONE",
|
|
||||||
"quantity": 0,
|
|
||||||
"purchaseLimit": 0,
|
|
||||||
"purchaseCount": 0,
|
|
||||||
"isPremium": false,
|
|
||||||
"sortPriority": 1,
|
|
||||||
"currencies": [
|
|
||||||
{
|
|
||||||
"name": "coins",
|
|
||||||
"funds": 400,
|
|
||||||
"finalFunds": 400
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"extPrice": {
|
|
||||||
"finalPrice": {
|
|
||||||
"amount": 4,
|
|
||||||
"currency": "mtx"
|
|
||||||
},
|
|
||||||
"originalPrice": {
|
|
||||||
"amount": 4,
|
|
||||||
"currency": "mtx"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"packContentInfo": {
|
|
||||||
"bronzeQuantity": 5,
|
|
||||||
"silverQuantity": 0,
|
|
||||||
"goldQuantity": 0,
|
|
||||||
"rareQuantity": 0,
|
|
||||||
"itemQuantity": 5
|
|
||||||
},
|
|
||||||
"unopened": false,
|
|
||||||
"displayGroup": {
|
|
||||||
"value": "bronze"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"assetId": 5,
|
|
||||||
"id": 5,
|
|
||||||
"packType": "GOLD",
|
|
||||||
"description": "Gold Pack",
|
|
||||||
"state": "active",
|
|
||||||
"saleType": "promo",
|
|
||||||
"limitType": "NONE",
|
|
||||||
"quantity": 0,
|
|
||||||
"purchaseLimit": 0,
|
|
||||||
"purchaseCount": 0,
|
|
||||||
"isPremium": false,
|
|
||||||
"sortPriority": 2,
|
|
||||||
"currencies": [
|
|
||||||
{
|
|
||||||
"name": "coins",
|
|
||||||
"funds": 5000,
|
|
||||||
"finalFunds": 5000
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"extPrice": {
|
|
||||||
"finalPrice": {
|
|
||||||
"amount": 50,
|
|
||||||
"currency": "mtx"
|
|
||||||
},
|
|
||||||
"originalPrice": {
|
|
||||||
"amount": 50,
|
|
||||||
"currency": "mtx"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"packContentInfo": {
|
|
||||||
"bronzeQuantity": 0,
|
|
||||||
"silverQuantity": 0,
|
|
||||||
"goldQuantity": 7,
|
|
||||||
"rareQuantity": 7,
|
|
||||||
"itemQuantity": 7
|
|
||||||
},
|
|
||||||
"unopened": false,
|
|
||||||
"displayGroup": {
|
|
||||||
"value": "gold"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"assetId": 6,
|
|
||||||
"id": 6,
|
|
||||||
"packType": "GOLD",
|
|
||||||
"description": "Premium Gold",
|
|
||||||
"state": "active",
|
|
||||||
"saleType": "promo",
|
|
||||||
"limitType": "NONE",
|
|
||||||
"quantity": 0,
|
|
||||||
"purchaseLimit": 0,
|
|
||||||
"purchaseCount": 0,
|
|
||||||
"isPremium": false,
|
|
||||||
"sortPriority": 3,
|
|
||||||
"currencies": [
|
|
||||||
{
|
|
||||||
"name": "coins",
|
|
||||||
"funds": 15000,
|
|
||||||
"finalFunds": 15000
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"extPrice": {
|
|
||||||
"finalPrice": {
|
|
||||||
"amount": 150,
|
|
||||||
"currency": "mtx"
|
|
||||||
},
|
|
||||||
"originalPrice": {
|
|
||||||
"amount": 150,
|
|
||||||
"currency": "mtx"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"packContentInfo": {
|
|
||||||
"bronzeQuantity": 0,
|
|
||||||
"silverQuantity": 0,
|
|
||||||
"goldQuantity": 11,
|
|
||||||
"rareQuantity": 11,
|
|
||||||
"itemQuantity": 11
|
|
||||||
},
|
|
||||||
"unopened": false,
|
|
||||||
"displayGroup": {
|
|
||||||
"value": "gold"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"assetId": 7,
|
|
||||||
"id": 7,
|
|
||||||
"packType": "GOLD",
|
|
||||||
"description": "Special Players Pack",
|
|
||||||
"state": "active",
|
|
||||||
"saleType": "promo",
|
|
||||||
"limitType": "NONE",
|
|
||||||
"quantity": 0,
|
|
||||||
"purchaseLimit": 0,
|
|
||||||
"purchaseCount": 0,
|
|
||||||
"isPremium": false,
|
|
||||||
"sortPriority": 4,
|
|
||||||
"currencies": [
|
|
||||||
{
|
|
||||||
"name": "coins",
|
|
||||||
"funds": 25000,
|
|
||||||
"finalFunds": 25000
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"extPrice": {
|
|
||||||
"finalPrice": {
|
|
||||||
"amount": 250,
|
|
||||||
"currency": "mtx"
|
|
||||||
},
|
|
||||||
"originalPrice": {
|
|
||||||
"amount": 250,
|
|
||||||
"currency": "mtx"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"packContentInfo": {
|
|
||||||
"bronzeQuantity": 0,
|
|
||||||
"silverQuantity": 0,
|
|
||||||
"goldQuantity": 11,
|
|
||||||
"rareQuantity": 11,
|
|
||||||
"itemQuantity": 11
|
|
||||||
},
|
|
||||||
"unopened": false,
|
|
||||||
"displayGroup": {
|
|
||||||
"value": "special"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"assetId": 65534,
|
|
||||||
"id": 65534,
|
|
||||||
"packType": "GOLD",
|
|
||||||
"description": "",
|
|
||||||
"state": "inactive",
|
|
||||||
"saleType": "promo",
|
|
||||||
"limitType": "NONE",
|
|
||||||
"quantity": 0,
|
|
||||||
"purchaseLimit": 0,
|
|
||||||
"purchaseCount": 0,
|
|
||||||
"isPremium": false,
|
|
||||||
"sortPriority": 1,
|
|
||||||
"packContentInfo": {
|
|
||||||
"bronzeQuantity": 0,
|
|
||||||
"silverQuantity": 0,
|
|
||||||
"goldQuantity": 0,
|
|
||||||
"rareQuantity": 0,
|
|
||||||
"itemQuantity": 0
|
|
||||||
},
|
|
||||||
"unopened": false,
|
|
||||||
"displayGroup": {
|
|
||||||
"value": "mypacks",
|
|
||||||
"priority": 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"timestamp": 1596326400
|
|
||||||
}
|
|
||||||
@@ -1,199 +0,0 @@
|
|||||||
{
|
|
||||||
"purchase": [
|
|
||||||
{
|
|
||||||
"assetId": 1,
|
|
||||||
"id": 1,
|
|
||||||
"packType": "BRONZE",
|
|
||||||
"description": "Bronze Pack",
|
|
||||||
"state": "active",
|
|
||||||
"saleType": "promo",
|
|
||||||
"limitType": "NONE",
|
|
||||||
"quantity": 0,
|
|
||||||
"purchaseLimit": 0,
|
|
||||||
"purchaseCount": 0,
|
|
||||||
"isPremium": false,
|
|
||||||
"sortPriority": 1,
|
|
||||||
"currencies": [
|
|
||||||
{
|
|
||||||
"name": "coins",
|
|
||||||
"funds": 400,
|
|
||||||
"finalFunds": 400
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"extPrice": {
|
|
||||||
"finalPrice": {
|
|
||||||
"amount": 4,
|
|
||||||
"currency": "mtx"
|
|
||||||
},
|
|
||||||
"originalPrice": {
|
|
||||||
"amount": 4,
|
|
||||||
"currency": "mtx"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"packContentInfo": {
|
|
||||||
"bronzeQuantity": 5,
|
|
||||||
"silverQuantity": 0,
|
|
||||||
"goldQuantity": 0,
|
|
||||||
"rareQuantity": 0,
|
|
||||||
"itemQuantity": 5
|
|
||||||
},
|
|
||||||
"unopened": false,
|
|
||||||
"displayGroup": {
|
|
||||||
"value": "bronze"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"assetId": 5,
|
|
||||||
"id": 5,
|
|
||||||
"packType": "GOLD",
|
|
||||||
"description": "Gold Pack",
|
|
||||||
"state": "active",
|
|
||||||
"saleType": "promo",
|
|
||||||
"limitType": "NONE",
|
|
||||||
"quantity": 0,
|
|
||||||
"purchaseLimit": 0,
|
|
||||||
"purchaseCount": 0,
|
|
||||||
"isPremium": false,
|
|
||||||
"sortPriority": 2,
|
|
||||||
"currencies": [
|
|
||||||
{
|
|
||||||
"name": "coins",
|
|
||||||
"funds": 5000,
|
|
||||||
"finalFunds": 5000
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"extPrice": {
|
|
||||||
"finalPrice": {
|
|
||||||
"amount": 50,
|
|
||||||
"currency": "mtx"
|
|
||||||
},
|
|
||||||
"originalPrice": {
|
|
||||||
"amount": 50,
|
|
||||||
"currency": "mtx"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"packContentInfo": {
|
|
||||||
"bronzeQuantity": 0,
|
|
||||||
"silverQuantity": 0,
|
|
||||||
"goldQuantity": 7,
|
|
||||||
"rareQuantity": 7,
|
|
||||||
"itemQuantity": 7
|
|
||||||
},
|
|
||||||
"unopened": false,
|
|
||||||
"displayGroup": {
|
|
||||||
"value": "gold"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"assetId": 6,
|
|
||||||
"id": 6,
|
|
||||||
"packType": "GOLD",
|
|
||||||
"description": "Premium Gold",
|
|
||||||
"state": "active",
|
|
||||||
"saleType": "promo",
|
|
||||||
"limitType": "NONE",
|
|
||||||
"quantity": 0,
|
|
||||||
"purchaseLimit": 0,
|
|
||||||
"purchaseCount": 0,
|
|
||||||
"isPremium": false,
|
|
||||||
"sortPriority": 3,
|
|
||||||
"currencies": [
|
|
||||||
{
|
|
||||||
"name": "coins",
|
|
||||||
"funds": 15000,
|
|
||||||
"finalFunds": 15000
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"extPrice": {
|
|
||||||
"finalPrice": {
|
|
||||||
"amount": 150,
|
|
||||||
"currency": "mtx"
|
|
||||||
},
|
|
||||||
"originalPrice": {
|
|
||||||
"amount": 150,
|
|
||||||
"currency": "mtx"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"packContentInfo": {
|
|
||||||
"bronzeQuantity": 0,
|
|
||||||
"silverQuantity": 0,
|
|
||||||
"goldQuantity": 11,
|
|
||||||
"rareQuantity": 11,
|
|
||||||
"itemQuantity": 11
|
|
||||||
},
|
|
||||||
"unopened": false,
|
|
||||||
"displayGroup": {
|
|
||||||
"value": "gold"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"assetId": 7,
|
|
||||||
"id": 7,
|
|
||||||
"packType": "GOLD",
|
|
||||||
"description": "Special Players Pack",
|
|
||||||
"state": "active",
|
|
||||||
"saleType": "promo",
|
|
||||||
"limitType": "NONE",
|
|
||||||
"quantity": 0,
|
|
||||||
"purchaseLimit": 0,
|
|
||||||
"purchaseCount": 0,
|
|
||||||
"isPremium": false,
|
|
||||||
"sortPriority": 4,
|
|
||||||
"currencies": [
|
|
||||||
{
|
|
||||||
"name": "coins",
|
|
||||||
"funds": 25000,
|
|
||||||
"finalFunds": 25000
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"extPrice": {
|
|
||||||
"finalPrice": {
|
|
||||||
"amount": 250,
|
|
||||||
"currency": "mtx"
|
|
||||||
},
|
|
||||||
"originalPrice": {
|
|
||||||
"amount": 250,
|
|
||||||
"currency": "mtx"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"packContentInfo": {
|
|
||||||
"bronzeQuantity": 0,
|
|
||||||
"silverQuantity": 0,
|
|
||||||
"goldQuantity": 11,
|
|
||||||
"rareQuantity": 11,
|
|
||||||
"itemQuantity": 11
|
|
||||||
},
|
|
||||||
"unopened": false,
|
|
||||||
"displayGroup": {
|
|
||||||
"value": "special"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"assetId": 65534,
|
|
||||||
"id": 65534,
|
|
||||||
"packType": "GOLD",
|
|
||||||
"description": "",
|
|
||||||
"state": "active",
|
|
||||||
"saleType": "promo",
|
|
||||||
"limitType": "NONE",
|
|
||||||
"quantity": 0,
|
|
||||||
"purchaseLimit": 0,
|
|
||||||
"purchaseCount": 0,
|
|
||||||
"isPremium": false,
|
|
||||||
"sortPriority": 1,
|
|
||||||
"packContentInfo": {
|
|
||||||
"bronzeQuantity": 0,
|
|
||||||
"silverQuantity": 0,
|
|
||||||
"goldQuantity": 0,
|
|
||||||
"rareQuantity": 0,
|
|
||||||
"itemQuantity": 0
|
|
||||||
},
|
|
||||||
"unopened": false,
|
|
||||||
"displayGroup": {
|
|
||||||
"value": "mypacks",
|
|
||||||
"priority": 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"timestamp": 1596326400
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"purchase": [{"assetId": 1, "id": 1, "packType": "BRONZE", "description": "Bronze Pack", "state": "active", "saleType": "promo", "limitType": "NONE", "quantity": 0, "purchaseLimit": 0, "purchaseCount": 0, "isPremium": false, "sortPriority": 1, "currencies": [{"name": "coins", "funds": 400, "finalFunds": 400}], "extPrice": {"finalPrice": {"amount": 4, "currency": "mtx"}, "originalPrice": {"amount": 4, "currency": "mtx"}}, "packContentInfo": {"bronzeQuantity": 5, "silverQuantity": 0, "goldQuantity": 0, "rareQuantity": 0, "itemQuantity": 5}, "unopened": false, "displayGroup": {"value": "bronze"}}, {"assetId": 5, "id": 5, "packType": "GOLD", "description": "Gold Pack", "state": "active", "saleType": "promo", "limitType": "NONE", "quantity": 0, "purchaseLimit": 0, "purchaseCount": 0, "isPremium": false, "sortPriority": 2, "currencies": [{"name": "coins", "funds": 5000, "finalFunds": 5000}], "extPrice": {"finalPrice": {"amount": 50, "currency": "mtx"}, "originalPrice": {"amount": 50, "currency": "mtx"}}, "packContentInfo": {"bronzeQuantity": 0, "silverQuantity": 0, "goldQuantity": 7, "rareQuantity": 7, "itemQuantity": 7}, "unopened": false, "displayGroup": {"value": "gold"}}, {"assetId": 6, "id": 6, "packType": "GOLD", "description": "Premium Gold", "state": "active", "saleType": "promo", "limitType": "NONE", "quantity": 0, "purchaseLimit": 0, "purchaseCount": 0, "isPremium": false, "sortPriority": 3, "currencies": [{"name": "coins", "funds": 15000, "finalFunds": 15000}], "extPrice": {"finalPrice": {"amount": 150, "currency": "mtx"}, "originalPrice": {"amount": 150, "currency": "mtx"}}, "packContentInfo": {"bronzeQuantity": 0, "silverQuantity": 0, "goldQuantity": 11, "rareQuantity": 11, "itemQuantity": 11}, "unopened": false, "displayGroup": {"value": "gold"}}, {"assetId": 7, "id": 7, "packType": "GOLD", "description": "Special Players Pack", "state": "active", "saleType": "promo", "limitType": "NONE", "quantity": 0, "purchaseLimit": 0, "purchaseCount": 0, "isPremium": false, "sortPriority": 4, "currencies": [{"name": "coins", "funds": 25000, "finalFunds": 25000}], "extPrice": {"finalPrice": {"amount": 250, "currency": "mtx"}, "originalPrice": {"amount": 250, "currency": "mtx"}}, "packContentInfo": {"bronzeQuantity": 0, "silverQuantity": 0, "goldQuantity": 11, "rareQuantity": 11, "itemQuantity": 11}, "unopened": false, "displayGroup": {"value": "special"}}], "timestamp": 1596326400}
|
|
||||||
@@ -1,173 +0,0 @@
|
|||||||
{
|
|
||||||
"purchase": [
|
|
||||||
{
|
|
||||||
"assetId": 1,
|
|
||||||
"id": 1,
|
|
||||||
"packType": "BRONZE",
|
|
||||||
"description": "Bronze Pack",
|
|
||||||
"state": "active",
|
|
||||||
"saleType": "promo",
|
|
||||||
"limitType": "NONE",
|
|
||||||
"quantity": 0,
|
|
||||||
"purchaseLimit": 0,
|
|
||||||
"purchaseCount": 0,
|
|
||||||
"isPremium": false,
|
|
||||||
"sortPriority": 1,
|
|
||||||
"currencies": [
|
|
||||||
{
|
|
||||||
"name": "coins",
|
|
||||||
"funds": 400,
|
|
||||||
"finalFunds": 400
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"extPrice": {
|
|
||||||
"finalPrice": {
|
|
||||||
"amount": 4,
|
|
||||||
"currency": "mtx"
|
|
||||||
},
|
|
||||||
"originalPrice": {
|
|
||||||
"amount": 4,
|
|
||||||
"currency": "mtx"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"packContentInfo": {
|
|
||||||
"bronzeQuantity": 5,
|
|
||||||
"silverQuantity": 0,
|
|
||||||
"goldQuantity": 0,
|
|
||||||
"rareQuantity": 0,
|
|
||||||
"itemQuantity": 5
|
|
||||||
},
|
|
||||||
"unopened": false,
|
|
||||||
"displayGroup": {
|
|
||||||
"value": "bronze"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"assetId": 5,
|
|
||||||
"id": 5,
|
|
||||||
"packType": "GOLD",
|
|
||||||
"description": "Gold Pack",
|
|
||||||
"state": "active",
|
|
||||||
"saleType": "promo",
|
|
||||||
"limitType": "NONE",
|
|
||||||
"quantity": 0,
|
|
||||||
"purchaseLimit": 0,
|
|
||||||
"purchaseCount": 0,
|
|
||||||
"isPremium": false,
|
|
||||||
"sortPriority": 2,
|
|
||||||
"currencies": [
|
|
||||||
{
|
|
||||||
"name": "coins",
|
|
||||||
"funds": 5000,
|
|
||||||
"finalFunds": 5000
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"extPrice": {
|
|
||||||
"finalPrice": {
|
|
||||||
"amount": 50,
|
|
||||||
"currency": "mtx"
|
|
||||||
},
|
|
||||||
"originalPrice": {
|
|
||||||
"amount": 50,
|
|
||||||
"currency": "mtx"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"packContentInfo": {
|
|
||||||
"bronzeQuantity": 0,
|
|
||||||
"silverQuantity": 0,
|
|
||||||
"goldQuantity": 7,
|
|
||||||
"rareQuantity": 7,
|
|
||||||
"itemQuantity": 7
|
|
||||||
},
|
|
||||||
"unopened": false,
|
|
||||||
"displayGroup": {
|
|
||||||
"value": "gold"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"assetId": 6,
|
|
||||||
"id": 6,
|
|
||||||
"packType": "GOLD",
|
|
||||||
"description": "Premium Gold",
|
|
||||||
"state": "active",
|
|
||||||
"saleType": "promo",
|
|
||||||
"limitType": "NONE",
|
|
||||||
"quantity": 0,
|
|
||||||
"purchaseLimit": 0,
|
|
||||||
"purchaseCount": 0,
|
|
||||||
"isPremium": false,
|
|
||||||
"sortPriority": 3,
|
|
||||||
"currencies": [
|
|
||||||
{
|
|
||||||
"name": "coins",
|
|
||||||
"funds": 15000,
|
|
||||||
"finalFunds": 15000
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"extPrice": {
|
|
||||||
"finalPrice": {
|
|
||||||
"amount": 150,
|
|
||||||
"currency": "mtx"
|
|
||||||
},
|
|
||||||
"originalPrice": {
|
|
||||||
"amount": 150,
|
|
||||||
"currency": "mtx"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"packContentInfo": {
|
|
||||||
"bronzeQuantity": 0,
|
|
||||||
"silverQuantity": 0,
|
|
||||||
"goldQuantity": 11,
|
|
||||||
"rareQuantity": 11,
|
|
||||||
"itemQuantity": 11
|
|
||||||
},
|
|
||||||
"unopened": false,
|
|
||||||
"displayGroup": {
|
|
||||||
"value": "gold"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"assetId": 7,
|
|
||||||
"id": 7,
|
|
||||||
"packType": "GOLD",
|
|
||||||
"description": "Special Players Pack",
|
|
||||||
"state": "active",
|
|
||||||
"saleType": "promo",
|
|
||||||
"limitType": "NONE",
|
|
||||||
"quantity": 0,
|
|
||||||
"purchaseLimit": 0,
|
|
||||||
"purchaseCount": 0,
|
|
||||||
"isPremium": false,
|
|
||||||
"sortPriority": 4,
|
|
||||||
"currencies": [
|
|
||||||
{
|
|
||||||
"name": "coins",
|
|
||||||
"funds": 25000,
|
|
||||||
"finalFunds": 25000
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"extPrice": {
|
|
||||||
"finalPrice": {
|
|
||||||
"amount": 250,
|
|
||||||
"currency": "mtx"
|
|
||||||
},
|
|
||||||
"originalPrice": {
|
|
||||||
"amount": 250,
|
|
||||||
"currency": "mtx"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"packContentInfo": {
|
|
||||||
"bronzeQuantity": 0,
|
|
||||||
"silverQuantity": 0,
|
|
||||||
"goldQuantity": 11,
|
|
||||||
"rareQuantity": 11,
|
|
||||||
"itemQuantity": 11
|
|
||||||
},
|
|
||||||
"unopened": false,
|
|
||||||
"displayGroup": {
|
|
||||||
"value": "special"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"timestamp": 1596326400
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"purchase": [{"assetId": 1, "id": 1, "packType": "BRONZE", "description": "Bronze Pack", "state": "active", "saleType": "promo", "limitType": "NONE", "quantity": 0, "purchaseLimit": 0, "purchaseCount": 0, "isPremium": false, "sortPriority": 1, "currencies": [{"name": "coins", "funds": 400, "finalFunds": 400}], "extPrice": {"finalPrice": {"amount": 4, "currency": "mtx"}, "originalPrice": {"amount": 4, "currency": "mtx"}}, "packContentInfo": {"bronzeQuantity": 5, "silverQuantity": 0, "goldQuantity": 0, "rareQuantity": 0, "itemQuantity": 5}, "unopened": false, "displayGroup": {"value": "bronze"}}, {"assetId": 5, "id": 5, "packType": "GOLD", "description": "Gold Pack", "state": "active", "saleType": "promo", "limitType": "NONE", "quantity": 0, "purchaseLimit": 0, "purchaseCount": 0, "isPremium": false, "sortPriority": 2, "currencies": [{"name": "coins", "funds": 5000, "finalFunds": 5000}], "extPrice": {"finalPrice": {"amount": 50, "currency": "mtx"}, "originalPrice": {"amount": 50, "currency": "mtx"}}, "packContentInfo": {"bronzeQuantity": 0, "silverQuantity": 0, "goldQuantity": 7, "rareQuantity": 7, "itemQuantity": 7}, "unopened": false, "displayGroup": {"value": "gold"}}, {"assetId": 6, "id": 6, "packType": "GOLD", "description": "Premium Gold", "state": "active", "saleType": "promo", "limitType": "NONE", "quantity": 0, "purchaseLimit": 0, "purchaseCount": 0, "isPremium": false, "sortPriority": 3, "currencies": [{"name": "coins", "funds": 15000, "finalFunds": 15000}], "extPrice": {"finalPrice": {"amount": 150, "currency": "mtx"}, "originalPrice": {"amount": 150, "currency": "mtx"}}, "packContentInfo": {"bronzeQuantity": 0, "silverQuantity": 0, "goldQuantity": 11, "rareQuantity": 11, "itemQuantity": 11}, "unopened": false, "displayGroup": {"value": "gold"}}, {"assetId": 7, "id": 7, "packType": "GOLD", "description": "Special Players Pack", "state": "active", "saleType": "promo", "limitType": "NONE", "quantity": 0, "purchaseLimit": 0, "purchaseCount": 0, "isPremium": false, "sortPriority": 4, "currencies": [{"name": "coins", "funds": 25000, "finalFunds": 25000}], "extPrice": {"finalPrice": {"amount": 250, "currency": "mtx"}, "originalPrice": {"amount": 250, "currency": "mtx"}}, "packContentInfo": {"bronzeQuantity": 0, "silverQuantity": 0, "goldQuantity": 11, "rareQuantity": 11, "itemQuantity": 11}, "unopened": false, "displayGroup": {"value": "special"}}], "timestamp": 1596326400}
|
|
||||||
@@ -1,199 +0,0 @@
|
|||||||
{
|
|
||||||
"purchase": [
|
|
||||||
{
|
|
||||||
"assetId": 1,
|
|
||||||
"id": 1,
|
|
||||||
"packType": "BRONZE",
|
|
||||||
"description": "Bronze Pack",
|
|
||||||
"state": "active",
|
|
||||||
"saleType": "promo",
|
|
||||||
"limitType": "NONE",
|
|
||||||
"quantity": 0,
|
|
||||||
"purchaseLimit": 0,
|
|
||||||
"purchaseCount": 0,
|
|
||||||
"isPremium": false,
|
|
||||||
"sortPriority": 1,
|
|
||||||
"currencies": [
|
|
||||||
{
|
|
||||||
"name": "coins",
|
|
||||||
"funds": 400,
|
|
||||||
"finalFunds": 400
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"extPrice": {
|
|
||||||
"finalPrice": {
|
|
||||||
"amount": 4,
|
|
||||||
"currency": "mtx"
|
|
||||||
},
|
|
||||||
"originalPrice": {
|
|
||||||
"amount": 4,
|
|
||||||
"currency": "mtx"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"packContentInfo": {
|
|
||||||
"bronzeQuantity": 5,
|
|
||||||
"silverQuantity": 0,
|
|
||||||
"goldQuantity": 0,
|
|
||||||
"rareQuantity": 0,
|
|
||||||
"itemQuantity": 5
|
|
||||||
},
|
|
||||||
"unopened": false,
|
|
||||||
"displayGroup": {
|
|
||||||
"value": "bronze"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"assetId": 5,
|
|
||||||
"id": 5,
|
|
||||||
"packType": "GOLD",
|
|
||||||
"description": "Gold Pack",
|
|
||||||
"state": "active",
|
|
||||||
"saleType": "promo",
|
|
||||||
"limitType": "NONE",
|
|
||||||
"quantity": 0,
|
|
||||||
"purchaseLimit": 0,
|
|
||||||
"purchaseCount": 0,
|
|
||||||
"isPremium": false,
|
|
||||||
"sortPriority": 2,
|
|
||||||
"currencies": [
|
|
||||||
{
|
|
||||||
"name": "coins",
|
|
||||||
"funds": 5000,
|
|
||||||
"finalFunds": 5000
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"extPrice": {
|
|
||||||
"finalPrice": {
|
|
||||||
"amount": 50,
|
|
||||||
"currency": "mtx"
|
|
||||||
},
|
|
||||||
"originalPrice": {
|
|
||||||
"amount": 50,
|
|
||||||
"currency": "mtx"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"packContentInfo": {
|
|
||||||
"bronzeQuantity": 0,
|
|
||||||
"silverQuantity": 0,
|
|
||||||
"goldQuantity": 7,
|
|
||||||
"rareQuantity": 7,
|
|
||||||
"itemQuantity": 7
|
|
||||||
},
|
|
||||||
"unopened": false,
|
|
||||||
"displayGroup": {
|
|
||||||
"value": "gold"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"assetId": 6,
|
|
||||||
"id": 6,
|
|
||||||
"packType": "GOLD",
|
|
||||||
"description": "Premium Gold",
|
|
||||||
"state": "active",
|
|
||||||
"saleType": "promo",
|
|
||||||
"limitType": "NONE",
|
|
||||||
"quantity": 0,
|
|
||||||
"purchaseLimit": 0,
|
|
||||||
"purchaseCount": 0,
|
|
||||||
"isPremium": false,
|
|
||||||
"sortPriority": 3,
|
|
||||||
"currencies": [
|
|
||||||
{
|
|
||||||
"name": "coins",
|
|
||||||
"funds": 15000,
|
|
||||||
"finalFunds": 15000
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"extPrice": {
|
|
||||||
"finalPrice": {
|
|
||||||
"amount": 150,
|
|
||||||
"currency": "mtx"
|
|
||||||
},
|
|
||||||
"originalPrice": {
|
|
||||||
"amount": 150,
|
|
||||||
"currency": "mtx"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"packContentInfo": {
|
|
||||||
"bronzeQuantity": 0,
|
|
||||||
"silverQuantity": 0,
|
|
||||||
"goldQuantity": 11,
|
|
||||||
"rareQuantity": 11,
|
|
||||||
"itemQuantity": 11
|
|
||||||
},
|
|
||||||
"unopened": false,
|
|
||||||
"displayGroup": {
|
|
||||||
"value": "gold"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"assetId": 7,
|
|
||||||
"id": 7,
|
|
||||||
"packType": "GOLD",
|
|
||||||
"description": "Special Players Pack",
|
|
||||||
"state": "active",
|
|
||||||
"saleType": "promo",
|
|
||||||
"limitType": "NONE",
|
|
||||||
"quantity": 0,
|
|
||||||
"purchaseLimit": 0,
|
|
||||||
"purchaseCount": 0,
|
|
||||||
"isPremium": false,
|
|
||||||
"sortPriority": 4,
|
|
||||||
"currencies": [
|
|
||||||
{
|
|
||||||
"name": "coins",
|
|
||||||
"funds": 25000,
|
|
||||||
"finalFunds": 25000
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"extPrice": {
|
|
||||||
"finalPrice": {
|
|
||||||
"amount": 250,
|
|
||||||
"currency": "mtx"
|
|
||||||
},
|
|
||||||
"originalPrice": {
|
|
||||||
"amount": 250,
|
|
||||||
"currency": "mtx"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"packContentInfo": {
|
|
||||||
"bronzeQuantity": 0,
|
|
||||||
"silverQuantity": 0,
|
|
||||||
"goldQuantity": 11,
|
|
||||||
"rareQuantity": 11,
|
|
||||||
"itemQuantity": 11
|
|
||||||
},
|
|
||||||
"unopened": false,
|
|
||||||
"displayGroup": {
|
|
||||||
"value": "special"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"assetId": 70,
|
|
||||||
"id": 70,
|
|
||||||
"packType": "GOLD",
|
|
||||||
"description": "Reward Special Players Pack",
|
|
||||||
"state": "active",
|
|
||||||
"saleType": "promo",
|
|
||||||
"limitType": "NONE",
|
|
||||||
"quantity": 0,
|
|
||||||
"purchaseLimit": 0,
|
|
||||||
"purchaseCount": 0,
|
|
||||||
"isPremium": false,
|
|
||||||
"sortPriority": 1,
|
|
||||||
"packContentInfo": {
|
|
||||||
"bronzeQuantity": 0,
|
|
||||||
"silverQuantity": 0,
|
|
||||||
"goldQuantity": 11,
|
|
||||||
"rareQuantity": 11,
|
|
||||||
"itemQuantity": 11
|
|
||||||
},
|
|
||||||
"unopened": true,
|
|
||||||
"displayGroup": {
|
|
||||||
"value": "mypacks",
|
|
||||||
"priority": 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"timestamp": 1596326400
|
|
||||||
}
|
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
# FIFA 23 PC Startup Flow (Offline / Proton)
|
|
||||||
|
|
||||||
Observed via FLE log, hook log, and file inspection on 2026-06-26.
|
|
||||||
|
|
||||||
## Launch chain
|
|
||||||
|
|
||||||
```
|
|
||||||
umu-run / Steam → FIFA23.exe (via Proton/Wine)
|
|
||||||
│
|
|
||||||
├─ DLL load order (before entry point)
|
|
||||||
│ ntdll.dll, kernel32.dll, ws2_32.dll …
|
|
||||||
│ version.dll ← our hook DLL slot (loads here)
|
|
||||||
│ FIFALiveEditor.DLL ← injected by FLE launcher after ~100 ms
|
|
||||||
│
|
|
||||||
├─ anadius / LSX emulator (anadius64.dll)
|
|
||||||
│ Fakes EA App / Origin session
|
|
||||||
│ Reads HKLM\SOFTWARE\Wow6432Node\Origin\ClientPath
|
|
||||||
│ Writes AppData\Local\anadius\LSX emu\achievement-*.xml
|
|
||||||
│ Provides fake PersonaId=1144668899 / UserId=1000200030000
|
|
||||||
│
|
|
||||||
├─ EA Anti-Cheat (EAAntiCheat.GameServiceLauncher.exe)
|
|
||||||
│ Spawns as child; checks EAAntiCheat.cfg
|
|
||||||
│ Not active in offline/cracked builds (FakeEAACLauncher present)
|
|
||||||
│
|
|
||||||
└─ FIFA23.exe entry point
|
|
||||||
Frostbite engine init (BuildDate 2023-07-05, changelist 5417699)
|
|
||||||
Reads Data\initfs_Win32 ← Frostbite package manifest
|
|
||||||
Reads Data\layout.toc ← file-system layout
|
|
||||||
Reads Patch\initfs_Win32 ← patches on top of base
|
|
||||||
Reads Documents\FIFA 23\fifasetup.ini ← display settings
|
|
||||||
Reads Data\locale.ini ← language table
|
|
||||||
Reads Data\db_meta.xml (via FLE) ← DB schema for all tables
|
|
||||||
```
|
|
||||||
|
|
||||||
## Phase timing (observed, single machine)
|
|
||||||
|
|
||||||
| Phase | Time after launch | Trigger |
|
|
||||||
|------------------------------|-------------------|----------------------------------|
|
|
||||||
| DLL load + FLE injection | 0 – 0.3 s | OS loader |
|
|
||||||
| Engine + DirectX init | 0.3 – 5 s | FIFA23 entry point |
|
|
||||||
| "Press any key" splash | ~5 s | First rendered frame |
|
|
||||||
| Main menu | ~25 s | After key press |
|
|
||||||
| FUT mode entry (attempted) | user-driven | User selects FUT tile |
|
|
||||||
| Network calls to EA services | at FUT entry | DirtySDK / EAWebKit |
|
|
||||||
|
|
||||||
## Files read at startup (observed)
|
|
||||||
|
|
||||||
| File | Format | Purpose |
|
|
||||||
|------|--------|---------|
|
|
||||||
| `Data/initfs_Win32` | Frostbite pkg | Base asset manifest |
|
|
||||||
| `Data/layout.toc` | Frostbite TOC | File layout index |
|
|
||||||
| `Patch/initfs_Win32` | Frostbite pkg | Patch layer |
|
|
||||||
| `Data/locale.ini` | INI | String localisation |
|
|
||||||
| `Data/db_meta.xml` | XML | DB schema (loaded by FLE) |
|
|
||||||
| `Data/id_map.json` | JSON | Player/team ID→name map |
|
|
||||||
| `Data/char_conv.json` | JSON | Character conversion table |
|
|
||||||
| `Documents/FIFA 23/fifasetup.ini` | INI | Display/audio settings |
|
|
||||||
| `AppData/Local/Temp/FIFA 23/_replay0.bin` | binary | Replay buffer |
|
|
||||||
| `anadius.cfg` | VDF | Fake EA persona config |
|
|
||||||
| `AppData/Local/anadius/LSX emu/achievement-*.xml` | XML | Achievement state |
|
|
||||||
|
|
||||||
## Files written during a session (observed)
|
|
||||||
|
|
||||||
| File | When written | Content |
|
|
||||||
|------|-------------|---------|
|
|
||||||
| `Documents/FIFA 23/settings/Settings*` | Main menu reached | FBCHUNKS — controller/display prefs |
|
|
||||||
| `Documents/FIFA 23/settings/ProfileOptions` | Profile load | FBCHUNKS — 1.5 MB profile blob |
|
|
||||||
| `Documents/FIFA 23/filesystemcache/survey.state` | Startup | Empty state file |
|
|
||||||
| `Documents/FIFA 23/filesystemcache/atlPlayTimeJson/playtime_*.json` | Ongoing | Playtime tracking |
|
|
||||||
| `FIFA 23 Live Editor/config.json` | FLE ready | FLE settings (rewritten each session) |
|
|
||||||
| `Logs/log_DD-MM-YYYY.txt` | Throughout | FLE debug log |
|
|
||||||
|
|
||||||
## Save file formats
|
|
||||||
|
|
||||||
### FBCHUNKS (Frostbite chunk container)
|
|
||||||
- Magic: `46 42 43 48 55 4E 4B 53` (`FBCHUNKS`)
|
|
||||||
- Byte 8: version (01 seen)
|
|
||||||
- Offset 0x12: null-terminated label string (e.g. "Personal Settings 1", "Career - Player Progress 1")
|
|
||||||
- Remainder: compressed/binary chunk data — no public spec; requires Frostbite tooling to fully parse
|
|
||||||
- Tools: [Frosty Tool Suite](https://github.com/CadeEvs/FrostyToolSuite) can read/write these
|
|
||||||
|
|
||||||
### fifasetup.ini
|
|
||||||
- Plain `KEY = VALUE` ini, fully human-readable
|
|
||||||
- Safe to edit (display resolution, locale, vsync)
|
|
||||||
|
|
||||||
## Network calls at FUT entry (observed with iptables redirect)
|
|
||||||
|
|
||||||
Traffic pattern captured before changing strategy:
|
|
||||||
- Multiple TLS connections to port 443 (destination: EA servers, resolved as various EA IPs)
|
|
||||||
- TLS 1.3, AES-256-GCM (DirtySDK's copy of ProtoSSL, inline in FIFA23.exe)
|
|
||||||
- No SNI sent (DirtySDK does not set `server_name` extension)
|
|
||||||
- Connections originate from Wine/Proton network stack via Linux kernel TCP
|
|
||||||
|
|
||||||
Specific EA hostnames used (from openfut-bridge captures, not decoded from TLS):
|
|
||||||
- `fut.ea.com` (FUT API)
|
|
||||||
- `accounts.ea.com` (auth)
|
|
||||||
- `gateway.ea.com` (entitlements)
|
|
||||||
- `pin-river.data.ea.com` (telemetry)
|
|
||||||
|
|
||||||
## Key FLE Lua API hooks
|
|
||||||
|
|
||||||
FLE injects `FIFALiveEditor.DLL` and exposes a Lua engine that can:
|
|
||||||
- Read any in-memory DB table via `GetDBTableRows(tableName)`
|
|
||||||
- Write any cell via `EditDBTableField`
|
|
||||||
- Query career mode state via `IsInCM()`
|
|
||||||
- Get player/team names via `GetPlayerName`, `GetTeamName`
|
|
||||||
|
|
||||||
This is the primary safe integration path (see `fut-integration-options.md`).
|
|
||||||
@@ -1,191 +0,0 @@
|
|||||||
# Foundational test — live custom XI via Freeze Lineup
|
|
||||||
|
|
||||||
**Status: PENDING — test has not yet been run.**
|
|
||||||
|
|
||||||
This is build-order step 1 from `docs/direction.md`: the test everything else
|
|
||||||
in the direction pivot depends on.
|
|
||||||
|
|
||||||
## What changed since the first draft of this doc
|
|
||||||
|
|
||||||
The first version of this test guessed at a "selection bias" DB field and a
|
|
||||||
candidate squad/lineup table name, based on general FIFA-modding precedent
|
|
||||||
that turned out not to hold for FLE's documented API — no such field appears
|
|
||||||
anywhere in FLE's actual Lua API docs or its own example scripts. While
|
|
||||||
researching an unrelated hotkey issue, a **confirmed, FLE-documented**
|
|
||||||
mechanism for forcing a starting XI turned up instead: the **Formation
|
|
||||||
Editor's "Freeze Lineup" feature** (FLE wiki, `Formation-Editor.md`):
|
|
||||||
|
|
||||||
> This feature can be used in player career mode if you want to manage the
|
|
||||||
> starting lineup of your team. Can be also used in manager career mode to
|
|
||||||
> manually manage your next opponent's starting lineup.
|
|
||||||
|
|
||||||
Steps (GUI, no scripting): open Formation Editor for a team → arrange players
|
|
||||||
on the pitch → tick **Freeze Lineup** → `Data → Save`.
|
|
||||||
|
|
||||||
This is real and documented, but it's GUI-only — there is no Lua function for
|
|
||||||
it, and what DB write it actually performs under the hood is undocumented.
|
|
||||||
This test is now two phases: confirm the GUI feature works at all, then
|
|
||||||
reverse the DB write it makes so it can be replicated programmatically
|
|
||||||
(required for the app→game bridge in build-order step 2, which needs this
|
|
||||||
driven from outside the game, not from a person clicking checkboxes).
|
|
||||||
|
|
||||||
Also fixed in this pass: `EditDBTableField`'s real signature, confirmed from
|
|
||||||
FLE's own docs and `lua/scripts/99ovr_99pot.lua`, is
|
|
||||||
`EditDBTableField(cell)` where `cell` is `row["fieldname"]` with `.value`
|
|
||||||
mutated in place — **not** `EditDBTableField(table, row_index, field, value)`
|
|
||||||
as originally (incorrectly) written into the first draft of the injector
|
|
||||||
script.
|
|
||||||
|
|
||||||
## What this test settles
|
|
||||||
|
|
||||||
Whether a *specific, externally-chosen* 11 players can be forced into a
|
|
||||||
career (or Kick-Off) match's starting lineup, live, with no restart — and
|
|
||||||
whether the mechanism that does it (Freeze Lineup's underlying DB write) can
|
|
||||||
be driven by a script instead of a person clicking through the Formation
|
|
||||||
Editor UI.
|
|
||||||
|
|
||||||
If Freeze Lineup itself doesn't actually hold under match start (the wiki
|
|
||||||
doesn't show it being tested against a live match, only "you should be able
|
|
||||||
to see... when you play against them"), the whole bridge architecture in
|
|
||||||
`docs/direction.md` §3 needs rethinking — there is no other documented
|
|
||||||
mechanism for forcing a lineup.
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
- FIFA 23 launched normally (FLE injected, EAAC neutralized — same baseline
|
|
||||||
as `track-c-fut-table-test.md`)
|
|
||||||
- A career save loaded (Freeze Lineup is documented for career mode
|
|
||||||
specifically — confirm separately whether it does anything in Kick-Off,
|
|
||||||
don't assume it does)
|
|
||||||
- Note 11 player IDs from your club (`tools/squad-exporter/export_squad.lua`
|
|
||||||
output, `playerid` field) that are NOT currently your starting XI
|
|
||||||
|
|
||||||
## Phase 1 — confirm Freeze Lineup actually holds into a match
|
|
||||||
|
|
||||||
This has zero scripting and should be done first since everything else is
|
|
||||||
wasted effort if it fails.
|
|
||||||
|
|
||||||
1. Open the Live Editor overlay (F9, or `Windows → Settings` from the
|
|
||||||
overlay's own menu bar if the hotkey isn't registering — see the umu/Wine
|
|
||||||
hotkey note below).
|
|
||||||
2. `Features → Teams` → find your team → `Edit`.
|
|
||||||
3. `Team → Formation` to open the Formation Editor.
|
|
||||||
4. Swap players around on the pitch so the XI differs from your current
|
|
||||||
actual starting XI in some checkable way (e.g. swap two outfield players'
|
|
||||||
positions, or bench/start a specific player).
|
|
||||||
5. Tick **Freeze Lineup**.
|
|
||||||
6. `Data → Save`.
|
|
||||||
7. Hide Live Editor (F9), save your career **on a new slot** (don't overwrite
|
|
||||||
your main save in case this corrupts something), exit to main menu, reload
|
|
||||||
that save, and check the team's lineup screen / play a match and watch who
|
|
||||||
starts.
|
|
||||||
|
|
||||||
**Record in the Results table below whether the frozen lineup actually took
|
|
||||||
the pitch.** If not, stop here — Phase 2 is moot.
|
|
||||||
|
|
||||||
## Phase 2 — find the underlying DB write
|
|
||||||
|
|
||||||
Only proceed if Phase 1 confirmed Freeze Lineup works.
|
|
||||||
|
|
||||||
1. In FLE's Lua Engine, run `tools/squad-injector/snapshot_lineup_tables.lua`.
|
|
||||||
This dumps every DB table whose name contains `squad`, `lineup`,
|
|
||||||
`formation`, `tactic`, `teamsheet`, `selection`, `players`, or `teams` to
|
|
||||||
`C:\FIFA 23 Live Editor\openfut_snapshot_<timestamp>.json`. Note this
|
|
||||||
filename — this is your **before** snapshot.
|
|
||||||
2. Without restarting or reloading, repeat the Formation Editor steps from
|
|
||||||
Phase 1 (steps 2–6 only — open Formation Editor, change the lineup, tick
|
|
||||||
Freeze Lineup, `Data → Save`). Don't save/reload the career between
|
|
||||||
snapshot and this step — keep it to a single live session so the diff
|
|
||||||
isn't polluted by other state changes.
|
|
||||||
3. Run `snapshot_lineup_tables.lua` again. This is your **after** snapshot.
|
|
||||||
4. Copy both JSON files out of the Wine prefix (same path pattern as
|
|
||||||
`track-c-fut-table-test.md`: `~/Games/umu/.../drive_c/FIFA 23 Live
|
|
||||||
Editor/`) and run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python3 tools/squad-injector/diff_snapshots.py before.json after.json
|
|
||||||
```
|
|
||||||
|
|
||||||
5. The output shows exactly which table(s) and field(s) changed. This is the
|
|
||||||
real, confirmed write Freeze Lineup performs — record it in the Results
|
|
||||||
table below.
|
|
||||||
|
|
||||||
## Phase 3 — replicate the write via script
|
|
||||||
|
|
||||||
1. Open `tools/squad-injector/apply_lineup_write.lua` and fill in
|
|
||||||
`TARGET_TABLE` and `TARGET_FIELDS` using Phase 2's diff output.
|
|
||||||
2. Edit `C:\FIFA 23 Live Editor\openfut_test_xi.json`:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"team_id": 12345,
|
|
||||||
"xi": [
|
|
||||||
{ "player_id": 111111, "position": 0 },
|
|
||||||
{ "player_id": 222222, "position": 5 }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Use 11 entries. Position codes are **confirmed numeric 0–27**
|
|
||||||
(`GK=0, SW=1, RWB=2, RB=3, RCB=4, CB=5, LCB=6, LB=7, LWB=8, RDM=9, CDM=10,
|
|
||||||
LDM=11, RM=12, RCM=13, CM=14, LCM=15, LM=16, RAM=17, CAM=18, LAM=19,
|
|
||||||
RF=20, CF=21, LF=22, RW=23, RS=24, ST=25, LS=26, LW=27`) — from
|
|
||||||
`lua/scripts/export_season_stats.lua`'s `get_pos_name` table in FLE's own
|
|
||||||
repo, not a guess.
|
|
||||||
3. Run `apply_lineup_write.lua` from FLE's Lua Engine.
|
|
||||||
4. Repeat the save-to-new-slot / reload / check-lineup verification from
|
|
||||||
Phase 1, but this time without ever opening the Formation Editor — the
|
|
||||||
write was made entirely from the script.
|
|
||||||
|
|
||||||
## Classification criteria
|
|
||||||
|
|
||||||
### "Confirmed — full mechanism works"
|
|
||||||
|
|
||||||
Phase 1 holds, Phase 2 finds a clean diff, Phase 3's scripted write produces
|
|
||||||
the same in-match result as the manual GUI path.
|
|
||||||
|
|
||||||
**Verdict:** Build-order step 1 done. Proceed to step 2 (bridge transport) in
|
|
||||||
`docs/direction.md`.
|
|
||||||
|
|
||||||
### "GUI works, script doesn't"
|
|
||||||
|
|
||||||
Phase 1 holds but Phase 3's replicated write doesn't stick, even though the
|
|
||||||
diffed fields matched what changed in Phase 2.
|
|
||||||
|
|
||||||
**Verdict:** Freeze Lineup likely does more than a single DB field write
|
|
||||||
(e.g. an internal engine call beyond `EditDBTableField`'s reach, or a second
|
|
||||||
write the diff missed because it happened in a table outside the `KEYWORDS`
|
|
||||||
filter in `snapshot_lineup_tables.lua` — widen the filter and redo Phase 2).
|
|
||||||
|
|
||||||
### "Freeze Lineup doesn't hold at all"
|
|
||||||
|
|
||||||
Phase 1 fails — the lineup reverts to the game's own AI-picked XI regardless.
|
|
||||||
|
|
||||||
**Verdict:** No confirmed mechanism exists for forcing a lineup. This kills
|
|
||||||
the bridge architecture as designed in `direction.md` §3 and needs a return
|
|
||||||
to first principles — there is no fallback documented anywhere in FLE's wiki
|
|
||||||
for this specific case.
|
|
||||||
|
|
||||||
## A note on the umu/Wine F9/F11 hotkey issue
|
|
||||||
|
|
||||||
If FLE's F9 (hide/show) hotkey isn't registering under umu, this is plausibly
|
|
||||||
a Wine keyboard-hook limitation (FLE's global hotkey detection likely uses a
|
|
||||||
low-level hook that doesn't translate cleanly through Wine's input layer) —
|
|
||||||
not something documented anywhere in FLE's own troubleshooting docs, which
|
|
||||||
don't mention Linux/Wine at all. F11 specifically has **no documented FLE
|
|
||||||
function** — F9 is the only documented toggle. Workaround: click directly
|
|
||||||
into the FLE overlay window (it should still be visible/clickable even if the
|
|
||||||
hotkey doesn't fire) and use its own menu bar instead of relying on the
|
|
||||||
hotkey.
|
|
||||||
|
|
||||||
## Results
|
|
||||||
|
|
||||||
*(To be filled in after the test is run.)*
|
|
||||||
|
|
||||||
| Field | Value |
|
|
||||||
|---|---|
|
|
||||||
| Date run | — |
|
|
||||||
| Phase 1: Freeze Lineup holds into a match? | — |
|
|
||||||
| Phase 2: table(s)/field(s) changed | — |
|
|
||||||
| Phase 3: scripted write reproduces Phase 1 result? | — |
|
|
||||||
| **Classification** | **PENDING** |
|
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
# FUT Integration Options
|
|
||||||
|
|
||||||
How to connect FIFA 23 to the OpenFUT local simulator, ranked by safety and feasibility.
|
|
||||||
|
|
||||||
## Option A — FLE Lua scripting (RECOMMENDED)
|
|
||||||
|
|
||||||
**What it does:** Use FIFA Live Editor's in-memory Lua API to read and write the game's
|
|
||||||
database tables at runtime. FLE is already injected; no additional hooking needed.
|
|
||||||
|
|
||||||
**Why it's the right path:**
|
|
||||||
- Fully offline, no EA servers touched
|
|
||||||
- FLE is already trusted by the user (it's the launch mechanism)
|
|
||||||
- `GetDBTableRows` / `EditDBTableField` expose the full Frostbite DB in memory
|
|
||||||
- Scripts run inside the game process; no IPC complexity
|
|
||||||
- Same mechanism used by modders for career mode edits today
|
|
||||||
|
|
||||||
**Integration design:**
|
|
||||||
|
|
||||||
```
|
|
||||||
openfut-core (SQLite)
|
|
||||||
│
|
|
||||||
│ HTTP REST (localhost)
|
|
||||||
▼
|
|
||||||
openfut-bridge (port 8080, plain HTTP, no TLS)
|
|
||||||
│ pulls club/squad/player data as JSON
|
|
||||||
▼
|
|
||||||
FLE Lua bridge script
|
|
||||||
│ calls GetDBTableRows, EditDBTableField
|
|
||||||
▼
|
|
||||||
FIFA 23 in-memory DB (Frostbite)
|
|
||||||
```
|
|
||||||
|
|
||||||
The Lua script polls openfut-core's REST API at intervals (or on FUT menu entry)
|
|
||||||
and writes simulator data (coins, items, squad) into the appropriate DB tables.
|
|
||||||
|
|
||||||
**Tables likely involved (to verify with export_squad.lua):**
|
|
||||||
|
|
||||||
| Table | Expected FUT content |
|
|
||||||
|-------|---------------------|
|
|
||||||
| `players` | Player attributes (OVR, potential, stats) |
|
|
||||||
| `teams` | Club identity, stadium, colors |
|
|
||||||
| `fut_clubs` | FUT club record (if in memory when FUT loads) |
|
|
||||||
| `fut_items` | Card inventory (if in memory) |
|
|
||||||
| `fut_squads` | Active squad (if in memory) |
|
|
||||||
|
|
||||||
**Steps to implement:**
|
|
||||||
1. Run `tools/squad-exporter/export_squad.lua` from FLE Lua Engine while in FUT to discover which tables are live
|
|
||||||
2. Map openfut-core's data model to the discovered table fields
|
|
||||||
3. Write a Lua polling script that fetches `/api/v1/club`, `/api/v1/squad`, etc. from openfut-core and calls `EditDBTableField` to populate them
|
|
||||||
4. Optionally add a small HTTP client to the Lua script using LuaSocket (FLE ships with Lua 5.4)
|
|
||||||
|
|
||||||
**Limitations:**
|
|
||||||
- Changes are in-memory only; they reset on game restart (acceptable for a simulator)
|
|
||||||
- Only works while FLE is running (always true in our setup)
|
|
||||||
- FUT tables may only be populated when the FUT hub is loaded; test with the exporter
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Option B — Local save file injection (career mode proxy)
|
|
||||||
|
|
||||||
**What it does:** Generate or modify offline career mode save files that contain FUT-like
|
|
||||||
squad/player data, using Frostbite's FBCHUNKS format.
|
|
||||||
|
|
||||||
**Feasibility:** Medium
|
|
||||||
- FBCHUNKS format is not publicly documented but has been partially reverse-engineered by the Frosty Tool Suite project
|
|
||||||
- Career saves are 16 MB — large and complex
|
|
||||||
- Changes take effect only after a game restart
|
|
||||||
|
|
||||||
**Best use:** Pre-populating a career club with the same players as the FUT simulator squad, so offline Squad Battles use "your" players.
|
|
||||||
|
|
||||||
**Steps:**
|
|
||||||
1. Use Frosty Tool Suite to open a career save and map the schema
|
|
||||||
2. Build a Python exporter that writes a valid FBCHUNKS save with simulator squad data
|
|
||||||
3. Test: replace the career save, launch FIFA, verify squad is correct
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Option C — Local companion web UI
|
|
||||||
|
|
||||||
**What it does:** The user manages their FUT simulator entirely in a web browser (openfut-core already has this). A button exports the current squad/club state to a format that a Lua script or file injector can consume.
|
|
||||||
|
|
||||||
**This is already implemented** — openfut-core serves the FUT simulator REST API. The missing piece is the Lua bridge script (Option A) that reads from it.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Option D — Local proxy for non-secured local calls only
|
|
||||||
|
|
||||||
**What it does:** Intercept FIFA 23's calls to `localhost:*` or a known local endpoint (not EA servers) and respond with simulator data.
|
|
||||||
|
|
||||||
**Feasibility:** Low value in isolation
|
|
||||||
- FIFA 23 does not make calls to localhost in normal operation (except EA App on port 10853)
|
|
||||||
- All FUT API calls go to EA's servers over TLS
|
|
||||||
- Intercepting those would require the approach we explicitly ruled out
|
|
||||||
|
|
||||||
**Not recommended as a primary path.** Could be combined with Option A if the Lua script exposes a local socket that a coordinator process writes to.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Option E — Memory bridge (Cheat Engine / FLE offsets)
|
|
||||||
|
|
||||||
**What it does:** Use known memory offsets (FLE's `offset_cache.json`) to read/write FUT state directly in FIFA23.exe's heap.
|
|
||||||
|
|
||||||
**Feasibility:** Medium — FLE already does this for career mode
|
|
||||||
- FLE's `offset_cache.json` contains addresses for many game structures
|
|
||||||
- FUT in-memory structs are separate from career structs and may not be mapped yet
|
|
||||||
- This is fragile (offsets change with game updates)
|
|
||||||
|
|
||||||
**Not recommended** unless Options A and B both fail — too brittle.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Recommendation
|
|
||||||
|
|
||||||
**Start with Option A (FLE Lua scripting).**
|
|
||||||
|
|
||||||
1. Run `tools/squad-exporter/export_squad.lua` in-game to discover which DB tables exist in FUT mode
|
|
||||||
2. Use `tools/file-watch-diff/watch.sh` to snapshot file state entering FUT and identify any new local files
|
|
||||||
3. Use `tools/network-metadata-logger/netlog.sh` to log which EA hosts FIFA contacts at FUT entry (metadata only, no decryption)
|
|
||||||
4. Map findings back to openfut-core's data model
|
|
||||||
5. Implement the Lua bridge script that calls openfut-core's REST API and writes to discovered tables
|
|
||||||
|
|
||||||
If FUT tables are not exposed by FLE's DB API (they may not be — FUT data lives server-side in online mode), fall back to **Option B** (career save injection) to provide a squad that mirrors the simulator's club.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Safety boundary
|
|
||||||
|
|
||||||
The following are out of scope and must not be implemented:
|
|
||||||
|
|
||||||
- Decrypting or inspecting EA's TLS traffic
|
|
||||||
- Spoofing EA domain names or impersonating EA servers
|
|
||||||
- Sending modified clients to EA's production services
|
|
||||||
- Bypassing EA App login or account verification
|
|
||||||
- Anything that could constitute online cheating or violate EA's ToS for online play
|
|
||||||
|
|
||||||
All integration must remain local/offline/single-player.
|
|
||||||
@@ -1,523 +0,0 @@
|
|||||||
# FIFA 17 — Clean Empty-My-Packs client fix (DESIGN / RESEARCH ONLY)
|
|
||||||
|
|
||||||
Status: **design only — no client binary/movie changes made.** This is the client-side
|
|
||||||
follow-up to bug 6c. The backend already ships a compatibility workaround (P2, active
|
|
||||||
non-openable sentinel 65534; see `docs/evidence/STORE_TILE_6C.md` §17 and
|
|
||||||
`FIFA17_EMPTY_MYPACKS_CLIENT_CONTRACT.md`). This plan describes what a *client-side*
|
|
||||||
fix would need to change so the backend shim can eventually become unnecessary for
|
|
||||||
patched clients.
|
|
||||||
|
|
||||||
Do NOT patch the executable, DLLs, or Scaleform movies in this task.
|
|
||||||
|
|
||||||
## 1. Established client-side evidence
|
|
||||||
|
|
||||||
Binary: `CardsDLL_Win64_retail.dll`
|
|
||||||
SHA-256 `4706a881ae1fc7b5769fd810b25a868d29d2b16a8e65a7513436327ef645573c`
|
|
||||||
(dump load base `0x00006FFFFC120000`; RE-space base `0x180000000`). `FIFA17.exe`
|
|
||||||
(`29c31cef…`) is Denuvo-packed (decrypts only in live memory).
|
|
||||||
|
|
||||||
Store category pipeline (all decompiled; see `docs/plan-2026-08-05-store-subsystem.md`):
|
|
||||||
- `FUN_1800150d0` — builds display groups from `purchase[]`; a `mypacks` group exists
|
|
||||||
iff some pack has `displayGroup.value=="mypacks"`. `group+0x104=(value=="mypacks")`,
|
|
||||||
tiles in `group+0x40`, ordinal in `group+0x00` (1-based creation order).
|
|
||||||
- `FUN_18007dab0` → `FUN_1800147f0(model, screen+0x290, …)` — renders/resolves a
|
|
||||||
category. `screen+0x290==0` lists group tiles (`FUN_180014610`); otherwise
|
|
||||||
`FUN_180014420` exact-matches the ordinal and **returns NULL on a miss**, after
|
|
||||||
which `FUN_1800147f0` dereferences `[RAX+0x48]` with **no null guard** →
|
|
||||||
**crash at `0x180014882`** (`ACCESS_VIOLATION` read of `0x48`, minidump-confirmed).
|
|
||||||
- `screen+0x290` is written in exactly two CardsDLL sites: ctor `FUN_18007d1a0`
|
|
||||||
writes `0`; **`FUN_18007e7f0` case `0x7551` copies the Flash movie message field
|
|
||||||
`CATEGORY_ID` verbatim** into it. So the category is chosen by the Scaleform movie.
|
|
||||||
- `FUN_18007e5e0` binds the six store tabs (`FUN_180014580`: `mypacks, points, bronze,
|
|
||||||
silver, gold, special`) to `PANEL_ID` = matching group ordinal, or hides the panel.
|
|
||||||
- Unopened-pack count signals (server, already correct at 0 when empty):
|
|
||||||
`userInfo.unopenedPacks.recoveredPacks` and `/user/credits .unopenedPacks`. The hub
|
|
||||||
`CentralUnclaimedPack` tile (destination `GOTO_STORE_MYPACK`) is gated by this count
|
|
||||||
in the hub model (`model+0x20950`). **Candidate F (a server count gating the STORE's
|
|
||||||
My-Packs resolution) was CONTRADICTED**: the count is correct at 0 yet the store
|
|
||||||
still resolves My Packs, because the decision is movie-side.
|
|
||||||
|
|
||||||
## 2. Desired clean client behavior
|
|
||||||
|
|
||||||
```
|
|
||||||
unopened-pack count == 0:
|
|
||||||
Store defaults to Browse Packs (e.g. a real category such as bronze/gold)
|
|
||||||
My Packs is NOT selected/resolved
|
|
||||||
no synthetic placeholder tile is required from the server
|
|
||||||
unopened-pack count > 0:
|
|
||||||
existing My Packs behavior unchanged
|
|
||||||
```
|
|
||||||
|
|
||||||
## 3. Candidate insertion points (ranked)
|
|
||||||
|
|
||||||
Ranking favors fixing the UX (not merely preventing the crash) and the smallest,
|
|
||||||
lowest-risk change that achieves it.
|
|
||||||
|
|
||||||
### Rank 1 (preferred, best UX) — Scaleform / category-selection layer
|
|
||||||
Prevent the movie from emitting `CATEGORY_ID == mypacks` (and from defaulting the
|
|
||||||
store into My Packs) when the unopened-pack count is 0; default to Browse Packs
|
|
||||||
instead.
|
|
||||||
- **Where:** the FUT Store Scaleform movie / ActionScript (`StoreFront`,
|
|
||||||
`CATEGORY_ID`/`ACTION_GET_PACKLIST`, `GOTO_STORE_MYPACK`), which the packed exe hosts
|
|
||||||
and which reads the hub model (it already knows the count for the
|
|
||||||
`CentralUnclaimedPack` tile).
|
|
||||||
- **Behavior changed:** the store's initial/selected category when empty.
|
|
||||||
- **Scope:** movie asset edit (client-side), no native-code patch.
|
|
||||||
- **Risk:** medium — Scaleform RE/editing is fiddly; must find where the default
|
|
||||||
`CATEGORY_ID` is chosen and gate it on the count without breaking the count>0 path.
|
|
||||||
- **Compatibility:** per-client asset change; does not touch protocol or other clients.
|
|
||||||
- **Fixes UX or just crash?** **UX** — no fake tile, correct default; the crash also
|
|
||||||
disappears because `mypacks` is never resolved when absent.
|
|
||||||
- **Evidence:** `screen+0x290 ← CATEGORY_ID` (`FUN_18007e7f0` case `0x7551`); count
|
|
||||||
already available client-side (hub model / `unopenedPacks`).
|
|
||||||
|
|
||||||
### Rank 2 — Native Store resolver fallback (CardsDLL)
|
|
||||||
Make `FUN_1800147f0`/`FUN_180014420` fall back to a safe category (e.g. list-tiles
|
|
||||||
`N==0`, or the first existing group) when the requested ordinal misses, instead of
|
|
||||||
dereferencing NULL.
|
|
||||||
- **Behavior changed:** category-miss handling for ALL categories, not just mypacks.
|
|
||||||
- **Scope:** small, localized CardsDLL binary patch near `0x180014420`/`0x180014882`.
|
|
||||||
- **Risk:** medium — alters native store behavior globally; could mask other
|
|
||||||
legitimate misses; the movie may still believe it is in My Packs (empty/odd view).
|
|
||||||
- **Compatibility:** binary patch to the shipped DLL (client-side).
|
|
||||||
- **Fixes UX or just crash?** Crash + partial UX (no crash, but the empty-My-Packs
|
|
||||||
view may still be awkward).
|
|
||||||
- **Evidence:** the no-guard deref at `0x180014882`; `FUN_180014420` returns NULL on
|
|
||||||
miss.
|
|
||||||
|
|
||||||
### Rank 3 (cheapest, crash-only) — CardsDLL null guard
|
|
||||||
Insert a null check before the `[RAX+0x48]` dereference in `FUN_1800147f0` (a single
|
|
||||||
`TEST/JZ` around the deref) so a NULL group is skipped/returned safely.
|
|
||||||
- **Behavior changed:** only the crash path.
|
|
||||||
- **Scope:** minimal (a few bytes) binary patch at `~0x180014882`.
|
|
||||||
- **Risk:** low — smallest change; but purely crash-prevention. With no `mypacks`
|
|
||||||
group the resulting empty view is unverified (could be a blank/empty-category state).
|
|
||||||
- **Compatibility:** binary patch (client-side).
|
|
||||||
- **Fixes UX or just crash?** Crash only.
|
|
||||||
- **Evidence:** minidump faulting instruction `CardsDLL+0x14882`, `[NULL+0x48]`.
|
|
||||||
|
|
||||||
## 4. Recommended long-term outcome
|
|
||||||
|
|
||||||
Rank 1 (Scaleform default-category gating) is the clean fix: with count 0 the store
|
|
||||||
opens on Browse Packs, no `mypacks` resolution, no fake tile — and the **backend
|
|
||||||
sentinel 65534 can be dropped for patched clients** (the server would simply omit the
|
|
||||||
`mypacks` group when empty, which is safe once the client no longer resolves it).
|
|
||||||
Rank 3 (null guard) is a cheap universal crash-safety net that could ship alongside.
|
|
||||||
Until a client-side fix exists, the backend P2 sentinel remains the required
|
|
||||||
compatibility behavior for unpatched retail clients.
|
|
||||||
|
|
||||||
## 5. Open questions / next research (no execution here)
|
|
||||||
- Locate the Store movie's default/initial `CATEGORY_ID` selection and confirm it can
|
|
||||||
read the unopened count (Rank 1 feasibility).
|
|
||||||
- Confirm, via a guarded-resolver experiment, what the empty-My-Packs view degrades to
|
|
||||||
if the `mypacks` group is simply absent + a null guard is present (Rank 2/3).
|
|
||||||
- Determine whether the Browse-Packs→My-Packs navigation gate (observed with the
|
|
||||||
active sentinel) also resolves under Rank 1.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# PART II — Native client-fix design (RE-backed, 2026-08-13)
|
|
||||||
|
|
||||||
Investigation-and-design phase (no client binary/movie changed, no backend changed,
|
|
||||||
no new live Store experiment). CardsDLL was re-analysed in Ghidra on `.105`; the
|
|
||||||
in-repo decompiled addresses were reconfirmed against a freshly-built project. Every
|
|
||||||
claim below is labelled **ESTABLISHED** (read from this build's binary / crash dump),
|
|
||||||
**PROPOSED** (design, not yet implemented), or **UNKNOWN**.
|
|
||||||
|
|
||||||
## 6. Binary + environment verification (ESTABLISHED)
|
|
||||||
|
|
||||||
Hashes re-verified on `.105` (`/mnt/games/FIFA 17/`) — identical to the recorded RE:
|
|
||||||
- `CardsDLL_Win64_retail.dll` SHA-256 `4706a881ae1fc7b5769fd810b25a868d29d2b16a8e65a7513436327ef645573c`,
|
|
||||||
size 3179952, PE `TimeDateStamp` 1497050156 (2017-06-09T23:15:56Z), `SizeOfImage`
|
|
||||||
`0x31d000`, image base `0x180000000` (RE-space). **Unpacked → statically analysable.**
|
|
||||||
- `FIFA17.exe` SHA-256 `29c31cef12b0c3c2a7305220617c7b4fa139ab76b8c857851bdbe88987962899`,
|
|
||||||
size 224639408, **Denuvo-packed** → the Scaleform/StoreFront ActionScript that
|
|
||||||
*decides* to emit `CATEGORY_ID` is NOT statically readable. This is why a
|
|
||||||
pure-Scaleform edit (old "Rank 1") is not the practical vehicle; the fix is taken
|
|
||||||
at the readable native boundary in CardsDLL instead.
|
|
||||||
- Ghidra project rebuilt at `.105:/tmp/ghidra_fut/cardsdll` (headless import+analysis
|
|
||||||
succeeded). Tooling: `fifa17-recon/tools/ghidra_env.py` run under `~/.venv`
|
|
||||||
(`PYTHONPATH=/opt/ghidra/Ghidra/Features/PyGhidra/pypkg/src:/usr/lib/python3.14/site-packages`;
|
|
||||||
`jpype1` reinstalled offline from pip cache). RVAs below = static VA − `0x180000000`.
|
|
||||||
|
|
||||||
## 7. Category-selection path (ESTABLISHED — decompiled this build)
|
|
||||||
|
|
||||||
Store message dispatch `FUN_18007d880` (RVA `0x7d880`) routes Flash message ids:
|
|
||||||
`0x753f → FUN_18007dab0` (render), `0x278a → FUN_18007df60` (publish category ids),
|
|
||||||
and the input handler `FUN_18007e7f0` (RVA `0x7e7f0`) case **`0x7551`** copies the
|
|
||||||
movie field `CATEGORY_ID` verbatim into `screen+0x290` (the only non-ctor writer;
|
|
||||||
ctor `FUN_18007d1a0` writes 0).
|
|
||||||
|
|
||||||
**Store render `FUN_18007dab0` (RVA `0x7dab0`), decompiled verbatim, is the decision
|
|
||||||
point:**
|
|
||||||
```c
|
|
||||||
iVar1 = *(int *)(param_1 + 0x290); // requested CATEGORY_ID (screen+0x290)
|
|
||||||
iVar6 = FUN_180014580(store, 1); // the *points* category id (see tab map)
|
|
||||||
if (iVar1 == iVar6) { // requested category is POINTS (real-money)
|
|
||||||
if (region_check() == 0) { post "REGION_MISMATCH"; return; }
|
|
||||||
if (FUN_180014de0(store) != 0) return; // points group present → handled
|
|
||||||
FUN_180014b60(store, dp); // else points render
|
|
||||||
} else {
|
|
||||||
FUN_1800147f0(store, iVar1, dp, 0, 0); // EVERY other category, incl. My Packs
|
|
||||||
}
|
|
||||||
```
|
|
||||||
- `param_1` (RCX) = the store-screen object; `+0x290` is the requested category.
|
|
||||||
- **Tab→id map `FUN_180014580(store, n)` (RVA `0x14580`): `0=mypacks, 1=points,
|
|
||||||
2=bronze, 3=silver, 4=gold, 5=special`.** Each returns the group's **1-based
|
|
||||||
ordinal** (via caption compare `FUN_180014380`) or **`-1`** if that group is absent.
|
|
||||||
So category ids are DYNAMIC ordinals, not fixed constants. The tab publisher
|
|
||||||
`FUN_18007df60` pushes `MYPACK_/BRONZE_/…_CATEGORY_ID` to the movie from these
|
|
||||||
lookups; the movie echoes one back as `CATEGORY_ID`.
|
|
||||||
- The **points** tab is the only one special-cased (commerce/region gate). **My Packs
|
|
||||||
is NOT special-cased — it falls into the `else` and is resolved by
|
|
||||||
`FUN_1800147f0`.**
|
|
||||||
|
|
||||||
**Resolver `FUN_1800147f0` (RVA `0x147f0`) — the crash (ESTABLISHED, instruction
|
|
||||||
level):**
|
|
||||||
```
|
|
||||||
0x14856: 85 ff TEST EDI,EDI ; EDI = category ordinal (param_2)
|
|
||||||
0x14858: 75 0f JNZ 0x14869 ; ==0 → list-all (Browse), else resolve
|
|
||||||
0x1485a: … CALL 0x14610 ; FUN_180014610 list ALL group tiles
|
|
||||||
0x14867: eb 29 JMP 0x14892
|
|
||||||
0x14869: 8b d7 MOV EDX,EDI
|
|
||||||
0x1486b: e8 … CALL 0x14420 ; FUN_180014420(store, ordinal) → RAX (group|NULL)
|
|
||||||
0x14870: 48 8d 50 40 LEA RDX,[RAX + 0x40] ; RDX = group+0x40 (=0x40 when RAX=NULL)
|
|
||||||
0x14878: 48 3b c2 CMP RAX,RDX
|
|
||||||
0x1487b: 74 15 JZ 0x14892
|
|
||||||
0x14882: 4c 8b 42 08 MOV R8,[RDX + 0x8] ; <-- FAULT: read [0x40+0x8]=0x48 when NULL
|
|
||||||
0x14886: 48 8b 12 MOV RDX,[RDX] ; [0x40]
|
|
||||||
```
|
|
||||||
`FUN_180014420` (RVA `0x14420`) exact-matches `group+0x00` (ordinal), stride `0x108`,
|
|
||||||
**returns NULL on a miss, with no guard in the caller** → faulting read of VA `0x48`
|
|
||||||
at `0x180014882`. This is byte-for-byte the Experiment-B minidump
|
|
||||||
(`0xC0000005` READ `0x48` at `CardsDLL+0x14882`).
|
|
||||||
- `param_2 == 0` → `FUN_180014610` lists **all** group tiles = the safe "Browse Packs"
|
|
||||||
view. `param_2 == existing ordinal` → resolves. `param_2 == a non-existent ordinal`
|
|
||||||
(e.g. `-1`, which `MYPACK_CATEGORY_ID` becomes when the group is absent) → NULL → crash.
|
|
||||||
|
|
||||||
**Why it crashes with zero packs (ESTABLISHED):** with `unopenedPackIds==[]` and no
|
|
||||||
sentinel, no `mypacks` group exists, so `FUN_180014580(store,0) = -1`,
|
|
||||||
`MYPACK_CATEGORY_ID = -1`, the movie still selects My Packs and echoes `CATEGORY_ID =
|
|
||||||
-1`, and `FUN_1800147f0(store, -1, …)` → `FUN_180014420(-1)=NULL` → crash. The active
|
|
||||||
sentinel (65534) works only because it makes a real `mypacks` ordinal exist to resolve.
|
|
||||||
|
|
||||||
## 8. Zero-pack state client-side (ESTABLISHED)
|
|
||||||
|
|
||||||
The client already holds the correct unopened-pack count in a **data-manager
|
|
||||||
singleton** (the same one the store resolver uses):
|
|
||||||
- Obtain: `seed = FUN_1800d7170()` then `FUN_180009c80(&p, seed)` → `p` (release with
|
|
||||||
`p->vtbl[0x08](p)`). This exact accessor already runs inside `FUN_180014420` and
|
|
||||||
`FUN_1800147f0`, so any store-category hook can reach it.
|
|
||||||
- **Read count: `p->vtbl[0x4d8](p)` → int. Write: `p->vtbl[0x4e0](p, n)`.** Confirmed
|
|
||||||
in `FUN_180019780`, which reads slot `0x4d8`, adds the number of set booleans in a
|
|
||||||
pack response, and writes slot `0x4e0` (it also fetches `FutGetPurchasedItems`).
|
|
||||||
- Representation: plain `int`; **0 = no unopened packs**, `>0` = count. Lifetime: the
|
|
||||||
singleton persists for the session; updated on pack acquire/open.
|
|
||||||
- No dedicated "hasUnopenedPacks" boolean helper was found; `count != 0` is the
|
|
||||||
predicate. (The hub `CentralUnclaimedPack` tile is gated by this same count via
|
|
||||||
`model+0x20950`, written by `FUN_18010cdc0`/`FUN_18011e120` — the hub mirror, not the
|
|
||||||
store gate.)
|
|
||||||
|
|
||||||
## 9. Implementation vehicle (ESTABLISHED — reuse, do not build a new loader)
|
|
||||||
|
|
||||||
OpenFUT **already ships a client hook framework**: `openfut-launcher/openfut-hook`
|
|
||||||
(`crate-type=["cdylib"]`) builds **`version.dll`**, a proxy DLL placed in the game dir
|
|
||||||
(`/mnt/games/FIFA 17/version.dll`, present & active; log `~/.wine/drive_c/openfut_hook.log`).
|
|
||||||
- Load path: Wine/Windows loads `version.dll` from the app dir at process start →
|
|
||||||
`DllMain(DLL_PROCESS_ATTACH)` → `install_hooks()`.
|
|
||||||
- Existing hooks (`lib.rs`): `getaddrinfo` (IAT via `iat::resolve`), `connect`
|
|
||||||
(inline detour), `WSAConnect`, `WSAIoctl`/ConnectEx, origin_spy registry/mutex,
|
|
||||||
crypt32 `CertVerifyCertificateChainPolicy`, **and in-memory byte-patching of the
|
|
||||||
loaded (packed) main exe + EAWebKit** (`ssl_patch`: `GetModuleHandleA` → scan for a
|
|
||||||
unique prologue → `VirtualProtect`+`copy_nonoverlapping`).
|
|
||||||
- Inline-hook primitive (`connect_hook`): `write_hook(target, dest)` lays a 14-byte
|
|
||||||
`FF 25 00000000 <abs64>` JMP; `restore_original` restores saved bytes
|
|
||||||
(unhook → call real → rehook, avoiding trampoline relocation).
|
|
||||||
- Config: `openfut.cfg` beside the DLL (`host`/ports today; a `store_mypacks_fix`
|
|
||||||
flag would be added there).
|
|
||||||
- **Suitability for the Store fix: direct.** The DLL is in-process with full access
|
|
||||||
to the loaded `CardsDLL_Win64_retail.dll`; the store fix is a NEW module
|
|
||||||
(`store_hook.rs`) installed from `install_hooks`, reusing the `ssl_patch`
|
|
||||||
signature-scan and the `connect_hook` inline-detour patterns. No new loader, no ASI,
|
|
||||||
no separate injector.
|
|
||||||
|
|
||||||
## 10. Three strategies re-evaluated against the RE (Task 4)
|
|
||||||
|
|
||||||
### A. Category-selection redirect — **PREFERRED** (best UX, native, targeted)
|
|
||||||
Hook `FUN_18007dab0` (RVA `0x7dab0`) at entry; before the original runs, redirect a
|
|
||||||
zero-pack My-Packs request to Browse Packs:
|
|
||||||
```
|
|
||||||
cat = *(int*)(store + 0x290)
|
|
||||||
mypacks_id = FUN_180014580(store, 0) // -1 when the group is absent
|
|
||||||
if (cat == mypacks_id) { // movie asked for My Packs (incl. cat==-1==id)
|
|
||||||
if (unopened_count() == 0) // singleton vtbl[0x4d8]
|
|
||||||
*(int*)(store + 0x290) = 0; // 0 = FUN_180014610 list-all = Browse Packs
|
|
||||||
}
|
|
||||||
// then call the original FUN_18007dab0(store)
|
|
||||||
```
|
|
||||||
- Uses the real count? **Yes** (singleton `vtbl[0x4d8]`). Removes the fake 65534 tile?
|
|
||||||
**Yes** (server can omit the group). Removes the click-dialog? **Yes** (no placeholder
|
|
||||||
to click). Removes the Browse→My-Packs nav gate? **Yes** (store lands on Browse, not
|
|
||||||
an empty My-Packs). Preserves count>0? **Yes** (`cat==mypacks_id` with count>0 is left
|
|
||||||
untouched → normal My Packs). Affects other categories? **No** (`cat!=mypacks_id`
|
|
||||||
path is unmodified; points/bronze/… unchanged).
|
|
||||||
- Prevents the crash as a side effect (My Packs is never resolved when its group is
|
|
||||||
absent). This is the old "Rank 1" INTENT, implemented at the readable native boundary
|
|
||||||
instead of in packed Scaleform.
|
|
||||||
|
|
||||||
### B. Resolver fallback — acceptable safety net, less targeted
|
|
||||||
In `FUN_1800147f0` (or right after the `CALL 0x14420` at RVA `0x1486b`): if the
|
|
||||||
resolved group is NULL, fall back to list-all (`param_2=0`) instead of dereferencing.
|
|
||||||
- Prevents crash? **Yes.** Fixes default nav / removes fake tile? **Partially** — the
|
|
||||||
movie still believes it is in My Packs, so the view may be an empty/odd My-Packs
|
|
||||||
rather than a clean Browse. Leaves other lookups unchanged? **It changes miss-handling
|
|
||||||
for ALL categories** — a generic NULL fallback that could mask a genuine
|
|
||||||
missing-category protocol bug. Higher risk than A for that reason; keep as a
|
|
||||||
belt-and-braces guard, not the primary UX fix. The resolver does NOT know *why*
|
|
||||||
`mypacks` is missing, which is exactly the concern the task flags.
|
|
||||||
|
|
||||||
### C. Null-guard only — weakest (crash-only)
|
|
||||||
Insert `TEST RAX,RAX; JZ 0x14892` immediately after `CALL 0x14420` (RVA `0x1486b`),
|
|
||||||
before `LEA RDX,[RAX+0x40]`. Needs a trampoline (no inline slack).
|
|
||||||
- Converts the crash into whatever an empty tile-vector renders (unverified; likely a
|
|
||||||
blank/empty category). Does **not** remove the fake tile or fix the default category;
|
|
||||||
the sentinel would still be needed for acceptable UX. Verified as expected-weakest.
|
|
||||||
|
|
||||||
## 11. Concrete hook target for strategy A (Task 6, PROPOSED)
|
|
||||||
```
|
|
||||||
module: CardsDLL_Win64_retail.dll (GetModuleHandleA)
|
|
||||||
function: FUN_18007dab0 (store render / message 0x753f)
|
|
||||||
RVA: 0x7dab0 (static VA 0x18007dab0)
|
|
||||||
calling conv: Microsoft x64 fastcall; single arg store-screen ptr in RCX
|
|
||||||
screen offset: store+0x290 = requested CATEGORY_ID (int)
|
|
||||||
helpers to call: FUN_180014580 (RVA 0x14580) tab→ordinal, arg0=RCX store, arg1=EDX index(0=mypacks)
|
|
||||||
count singleton: FUN_1800d7170 (0xd7370-seed) + FUN_180009c80 (0x9c80), read vtbl[0x4d8]
|
|
||||||
redirect target: set store+0x290 = 0 (FUN_180014610 list-all → Browse Packs)
|
|
||||||
original behavior: zero packs → resolves absent mypacks ordinal → FUN_180014420 NULL → crash at 0x14882
|
|
||||||
desired behavior: zero packs + mypacks requested → store+0x290 forced to 0 → Browse Packs; no crash/dialog/tile
|
|
||||||
```
|
|
||||||
Hook mechanics (reuse `connect_hook`): lay a 14-byte `FF 25` JMP at `base+0x7dab0` to a
|
|
||||||
Rust `hooked_store_render(store)`; inside: apply the redirect, unhook, call real
|
|
||||||
`FUN_18007dab0(store)`, rehook, return its value. Intercepting only the entry means the
|
|
||||||
minimum interception is the 14 JMP bytes; the first instructions of `FUN_18007dab0`
|
|
||||||
(`MOV RAX,RSP; MOV [RAX+8],RCX; PUSH …`) are a standard prologue safe to save/restore.
|
|
||||||
Alt insertion point (earlier): `FUN_18007e7f0` case `0x7551`, where `CATEGORY_ID` is
|
|
||||||
written to `screen+0x290` — redirect there instead of at render. Entry-hook of
|
|
||||||
`FUN_18007dab0` is preferred (single, well-typed arg; runs once per store render).
|
|
||||||
|
|
||||||
Thread/context: the store screen runs on the client's UI/update thread; the hook reads
|
|
||||||
one int and (rarely) writes one int on the same object the callee immediately reads —
|
|
||||||
no new synchronization needed. Called for categories other than My Packs? The FUNCTION
|
|
||||||
is, but the redirect body only fires when `cat==mypacks_id`, so other tabs are
|
|
||||||
untouched.
|
|
||||||
|
|
||||||
## 12. Version / build safety (Task 7, PROPOSED)
|
|
||||||
FIFA17-specific compat code MUST validate the client before hooking, and MUST no-op on
|
|
||||||
any other build (the same `version.dll` is also used for FIFA23):
|
|
||||||
1. **Module gate:** only proceed if `GetModuleHandleA("CardsDLL_Win64_retail.dll")`
|
|
||||||
resolves (FIFA23 has no such module → auto-skip).
|
|
||||||
2. **Build gate (both, belt-and-braces):**
|
|
||||||
- Exact hash/PE gate: on-disk SHA-256 == `4706a881…`, or PE `SizeOfImage==0x31d000`
|
|
||||||
&& `TimeDateStamp==1497050156` (cheap in-memory check).
|
|
||||||
- Signature scan + validation: locate `FUN_18007dab0` by a unique prologue/byte
|
|
||||||
window rather than trusting the RVA, and assert the known bytes at the branch
|
|
||||||
(`85 ff 75 0f` region) and at the resolver `CALL 0x14420` site match before
|
|
||||||
installing. Recommend **both**: hash to reject the wrong game fast, signature to
|
|
||||||
confirm the exact patch site.
|
|
||||||
3. **Failure behavior:** any check fails (unknown/updated build) → **do NOT patch**,
|
|
||||||
log, and leave the **backend P2 active-sentinel (65534) as the fallback**. Never
|
|
||||||
patch or crash an unrecognised build.
|
|
||||||
|
|
||||||
## 13. First controlled client experiment (Task 8, PROPOSED — not executed here)
|
|
||||||
Goal: prove a patched client sends zero-pack Store entry to Browse Packs with **no**
|
|
||||||
active placeholder.
|
|
||||||
- Build `openfut-hook` with strategy-A `store_hook`, gated behind `openfut.cfg`
|
|
||||||
`store_mypacks_fix=1` (opt-in; default off preserves today's behavior).
|
|
||||||
- Test profile: `unopenedPackIds == []`.
|
|
||||||
- Sequence (each variable changed alone; operator drives FIFA; read-only capture):
|
|
||||||
1. Deploy patched `version.dll`; confirm `openfut_hook.log` shows the store hook
|
|
||||||
installed + build gate PASSED.
|
|
||||||
2. **Backend test mode (LATER, separately authorized — NOT in this task):** switch the
|
|
||||||
backend to *empty-no-sentinel* (the Exp-B config that crashed the UNPATCHED client)
|
|
||||||
so the patched client must handle a genuinely-absent `mypacks` group.
|
|
||||||
3. Operator opens Store. **Predicted (patched + zero packs + no sentinel):** Store
|
|
||||||
opens, defaults to Browse Packs, no `mypacks` resolve, **no crash, no dialog, no
|
|
||||||
fake tile**.
|
|
||||||
4. Set `unopenedPackIds=[70]`; reopen. **Predicted:** My Packs works normally
|
|
||||||
(hook body skipped because count>0).
|
|
||||||
5. Revert backend to the active sentinel.
|
|
||||||
- **Backend change eventually required for this experiment: YES** — a controlled
|
|
||||||
empty-no-sentinel test mode to force the absent group. It is NOT performed in this
|
|
||||||
phase and MUST be separately authorized (same experiment discipline: patch the
|
|
||||||
container copy, capture, revert, restart; never synthesize a client request).
|
|
||||||
- **Client rollback:** flip `store_mypacks_fix=0` (hook not installed) or restore the
|
|
||||||
original `version.dll`; the game reverts to depending on the backend sentinel. No FIFA
|
|
||||||
binaries/movies/config are modified on disk — the hook is in-memory only, so rollback
|
|
||||||
is a file/flag swap.
|
|
||||||
|
|
||||||
## 14. Interaction with the backend 65534 fallback (ESTABLISHED + PROPOSED)
|
|
||||||
- **Keep the backend sentinel deployed** until strategy A is implemented AND verified.
|
|
||||||
It remains the required behavior for unpatched retail clients and for any client whose
|
|
||||||
build gate fails.
|
|
||||||
- Once strategy A is verified, the server MAY, **for patched clients only**, omit the
|
|
||||||
`mypacks` group when empty (the safe representation the client will then handle) —
|
|
||||||
but only behind explicit detection/opt-in; do NOT drop the sentinel globally, since
|
|
||||||
unpatched clients still crash without it.
|
|
||||||
|
|
||||||
## 15. ESTABLISHED / PROPOSED / UNKNOWN summary
|
|
||||||
- **ESTABLISHED:** binary hashes/build; the full native category path and addresses
|
|
||||||
(`FUN_18007d880/18007dab0/18007e7f0/1800147f0/180014420/180014580/180014610`); the
|
|
||||||
instruction-level crash (`0x14882`, `[NULL+0x48]`); tab→ordinal map; that My Packs is
|
|
||||||
not special-cased and funnels through `FUN_1800147f0`; the unopened-count singleton
|
|
||||||
and its `vtbl[0x4d8]/[0x4e0]` accessors, reachable from store code; the
|
|
||||||
`openfut-hook`/`version.dll` vehicle and its hook/patch primitives.
|
|
||||||
- **PROPOSED (not implemented):** the strategy-A entry hook and its redirect logic; the
|
|
||||||
build-guard scheme; the opt-in config flag; the first experiment and its backend
|
|
||||||
test-mode requirement; the per-patched-client server relaxation.
|
|
||||||
- **UNKNOWN:** exactly why the packed Scaleform movie selects My Packs on store open
|
|
||||||
(Denuvo-packed, unread) — not needed for strategy A, which intercepts the native
|
|
||||||
result; the precise rendered appearance of `category==0` list-all in this empty
|
|
||||||
configuration (to be observed in the experiment); whether any non-store path also
|
|
||||||
drives `screen+0x290` to a My-Packs ordinal (none found; `FUN_18007e7f0` case `0x7551`
|
|
||||||
and the ctor are the only writers).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# PART III — Final no-sentinel resolver experiment (2026-08-13) — RESULT F3 (CRASH), CONFOUNDED
|
|
||||||
|
|
||||||
Vehicle change: the resolver guard was implemented as an **`autopatch.py` memory patch**
|
|
||||||
(the live FIFA-17 client-patch mechanism), NOT the `version.dll` proxy — Proton loads its
|
|
||||||
builtin `version.dll`, so the earlier `store_hook`/`version.dll` prototype was inert and
|
|
||||||
has been rolled back. Guard: at CardsDLL `0x180014858`, `JNZ 0x14869` (`75 0f`) →
|
|
||||||
`JG 0x14869` (`7f 0f`), orig-verified; routes category `< 0` (and `== 0`) to the safe
|
|
||||||
list-all/Browse path (`FUN_180014610`), category `> 0` to the existing resolver.
|
|
||||||
`TEST EDI,EDI` at `0x180014856` is the flag source (OF cleared ⇒ `JG` = signed `> 0`).
|
|
||||||
|
|
||||||
## Setup (verified)
|
|
||||||
- CLIENT: guard active/enforced — live bytes `85 ff 7f 0f` at `0x180014856` (FIFA pid 547843,
|
|
||||||
autopatch pid 547621; log `ENFORCED guarded store patch @ … (JNZ->JG)`, orig `75 0f` matched).
|
|
||||||
- BACKEND: sentinel 65534 suppressed by a one-line `if not owned_ids:` → `if False:` in the
|
|
||||||
container copy only (committed source `f42279f` untouched; backup `/tmp/utas_server.EXP_ORIG.py`).
|
|
||||||
Genuine `GET /store/purchasegroup` (02:44:39Z) → ids `[1,5,6,7]`, **no 65534, no mypacks group**,
|
|
||||||
normal packs unchanged (evidence: `docs/evidence/store_purchasegroup_capture_client_guard_no_sentinel_2026-08-13.json`).
|
|
||||||
- PROFILE: `unopenedPackIds=[]`, coins 29,876,776, sha `39bb3e83…` — unchanged throughout.
|
|
||||||
|
|
||||||
## Result — F3 (CRASH)
|
|
||||||
Minidump `CrashDump_2026.08.12_20.44.40.302.dmp` (preserved `/tmp/expF_crash.dmp`, sha `4dcb0cb7…`):
|
|
||||||
`0xC0000005` READ of VA `0x48` at `ExceptionAddress 0x6ffffc224882` → **RE `0x180014882`** —
|
|
||||||
the **identical** resolver crash instruction as Experiment B (`FUN_1800147f0`,
|
|
||||||
`MOV R8,[RDX+0x8]` with the group ptr NULL).
|
|
||||||
|
|
||||||
**Mechanism (decisive):** `0x14882` lives in the *resolve* branch, which the guard's `JG`
|
|
||||||
reaches **only when category `> 0`**. Since the guard was verified in place, the client
|
|
||||||
presented a **positive** My-Packs ordinal that no longer resolves (no mypacks group) →
|
|
||||||
`FUN_180014420` returned NULL → crash. The guard's design assumption — *absent mypacks ⇒
|
|
||||||
category `-1`* — did NOT hold on this path.
|
|
||||||
|
|
||||||
## Confound (uncontrolled variable)
|
|
||||||
FIFA was **not relaunched** after the backend flipped to no-sentinel; the client carried
|
|
||||||
**stale store/tab state** from the sentinel-present safe stage, where the mypacks group
|
|
||||||
existed at a *positive* ordinal `N` (`MYPACK_CATEGORY_ID = N`). Reopening the Store reused
|
|
||||||
that stale positive ordinal rather than the `-1` a **fresh** launch publishes
|
|
||||||
(`FUN_18007df60 → FUN_180014580(store,0) = -1` when absent). So the intended clean A/B (client
|
|
||||||
only ever sees the no-sentinel response) was not achieved — the category that reached the
|
|
||||||
resolver was a stale `>0`, exactly the case the negative-only guard does not divert.
|
|
||||||
|
|
||||||
## Conclusion / strategy status
|
|
||||||
- **The guard as-written does NOT handle a positive, now-invalid My-Packs ordinal** — proven
|
|
||||||
by this crash. Diverting only `category < 0` is insufficient when the client presents a
|
|
||||||
stale/positive ordinal for an absent group.
|
|
||||||
- **Not falsified for the fresh-client case.** Whether a fresh no-sentinel launch presents
|
|
||||||
`-1` (guard diverts → Browse, no crash) or still a positive ordinal is **UNKNOWN** and needs
|
|
||||||
a **clean re-test**: launch FIFA fresh with the backend already in no-sentinel mode so the
|
|
||||||
client never sees a mypacks group. That is the proper equivalent of Experiment B.
|
|
||||||
- **Candidate stronger guard** (design only, not implemented): divert to list-all when the
|
|
||||||
resolved group is NULL for *any* category (guard `FUN_180014420`'s NULL return at the
|
|
||||||
`0x14870`/`0x14882` site), not merely when `category < 0`. This covers the positive-invalid
|
|
||||||
ordinal too, at the cost of being a generic miss-fallback (the higher-risk Rank-2 behavior).
|
|
||||||
Do NOT implement without authorization and a clean re-test first.
|
|
||||||
|
|
||||||
**Strategy A / resolver guard status: NOT PROVEN.** Crash-guard installs and is build-validated
|
|
||||||
and dormant-safe with the sentinel present, but the first no-sentinel test CRASHED at the
|
|
||||||
resolver via a positive stale ordinal (confounded by no relaunch). Backend P2 active-sentinel
|
|
||||||
was restored immediately (mandatory rollback; source `f416e71e…`, sentinel `state=active`),
|
|
||||||
and remains the production safety net. Guard left in `autopatch.py` (dormant) pending the
|
|
||||||
clean re-test decision; `autopatch.py.pre-storeguard.bak` available to remove it.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# PART IV — Fresh-process no-sentinel retest (2026-08-13) — RESULT R1 (SUCCESS)
|
|
||||||
|
|
||||||
Corrects PART III's confound. This time the mandatory ordering was enforced: the backend
|
|
||||||
entered no-sentinel mode **while FIFA was closed**, then FIFA launched **fresh** (new pid,
|
|
||||||
new autopatch) so the process never saw a sentinel-present Store response.
|
|
||||||
|
|
||||||
## Setup (verified, clean A/B)
|
|
||||||
- BACKEND set no-sentinel at 02:55:09Z with FIFA down; genuine `GET /store/purchasegroup`
|
|
||||||
(02:58:45Z) served to the fresh client = ids `[1,5,6,7]`, **no 65534, no mypacks group**,
|
|
||||||
packs 1/5/6/7 present. This body is **byte-identical** to the PART III (F3) no-sentinel
|
|
||||||
capture — the ONLY changed variable vs F3 is the client process lifetime.
|
|
||||||
Evidence: `docs/evidence/store_purchasegroup_capture_freshretest_no_sentinel_2026-08-13.json`.
|
|
||||||
- CLIENT: NEW FIFA pid 553220, NEW autopatch pid 552999; guard ENFORCED (orig `75 0f`
|
|
||||||
matched → `85 ff 7f 0f` = `TEST EDI,EDI; JG`). Process never saw a sentinel response
|
|
||||||
(0 purchasegroup responses containing 65534 after the no-sentinel restart).
|
|
||||||
- PROFILE unchanged throughout (`39bb3e83…`, `[]`, coins 29,876,776).
|
|
||||||
|
|
||||||
## Result — R1 (operator-observed)
|
|
||||||
- **No crash** (FIFA 553220 alive after the test; no new minidump), **no dialog**, **Store
|
|
||||||
stays open**, opens on **Browse Packs**, Bronze/Gold/Special packs visible and navigable.
|
|
||||||
- Cosmetic-only imperfections (pre-existing, NOT caused by the guard): the six-tab bar is
|
|
||||||
unbound (no tabs), packs render without cover art, and tiles show "0 items". These match
|
|
||||||
the known store tab-bind / list-all rendering quirks (`plan-2026-08-05-store-subsystem.md`
|
|
||||||
§2.1) and are independent of the resolver guard.
|
|
||||||
|
|
||||||
## Causal conclusion (decisive A/B)
|
|
||||||
```
|
|
||||||
server response (no sentinel, no mypacks group) == byte-identical across F3 and R1
|
|
||||||
client original JNZ + this response -> CRASH 0x180014882 (Experiment B)
|
|
||||||
client JG (stale positive ordinal) -> CRASH 0x180014882 (PART III F3, contaminated)
|
|
||||||
client JG (FRESH, category = -1) -> NO CRASH, Browse Packs (PART IV R1) ✅
|
|
||||||
```
|
|
||||||
A **fresh** client publishes `MYPACK_CATEGORY_ID = FUN_180014580(store,0) = -1` for the absent
|
|
||||||
group; the movie echoes `-1`; `TEST EDI,EDI; JG` does **not** take the resolve branch, so the
|
|
||||||
client runs the list-all/Browse path (`FUN_180014610`) — no `FUN_180014420(NULL)` deref, no
|
|
||||||
crash. **PART III's F3 is confirmed as stale-positive-ordinal contamination** (FIFA not
|
|
||||||
relaunched across the sentinel→no-sentinel flip), not a guard failure.
|
|
||||||
|
|
||||||
## Strategy status
|
|
||||||
**Strategy A / resolver guard: PROVEN ON THE TESTED FIFA 17 BUILD** (CardsDLL
|
|
||||||
`4706a881…`) for the clean process-lifetime case — it safely routes the absent My-Packs
|
|
||||||
category to Browse Packs with no crash and no dialog, needing **no** backend sentinel. Scope
|
|
||||||
caveats: (1) tested build only; (2) the negative-only guard does NOT cover a stale/positive
|
|
||||||
invalid ordinal (PART III) — only arises if the client's Store state predates a sentinel→
|
|
||||||
no-sentinel change within one process, which does not happen on a normal launch; a NULL-return
|
|
||||||
guard at `FUN_180014420` would additionally cover that, deferred/not implemented; (3) UX still
|
|
||||||
has the pre-existing no-tabs/no-art/"0 items" cosmetics.
|
|
||||||
|
|
||||||
Backend P2 active-sentinel was restored immediately after capture (mandatory rollback; source
|
|
||||||
`f416e71e…`, sentinel `state=active`) and **remains production default**. The clean UX is only
|
|
||||||
safe to serve when the server knows the client is patched — see PART II §12 rollout options
|
|
||||||
(recommend B: suppress the sentinel only when client patch-capability is known; keep the
|
|
||||||
sentinel universal by default). Guard retained in `autopatch.py` (dormant with the sentinel).
|
|
||||||
|
|
||||||
## INVARIANT — empty-My-Packs capability MUST be session-stable
|
|
||||||
|
|
||||||
F3 vs R1 establish a hard operational invariant for any deployment (sentinel or client
|
|
||||||
guard): **the server MUST NOT switch a running FIFA client between sentinel-present and
|
|
||||||
sentinel-absent for the My Packs group within a single FIFA process lifetime.**
|
|
||||||
|
|
||||||
Rationale: the client resolves and caches the My-Packs group **ordinal** (positive when a
|
|
||||||
group — real or sentinel — is present; `-1` when absent) from the `purchasegroup` response
|
|
||||||
seen at Store-subsystem init. The resolver guard only reclassifies the ordinal *sign*
|
|
||||||
(`≤0` → Browse). If a client that already cached a **positive** ordinal later receives a
|
|
||||||
no-sentinel topology, the stale positive ordinal still takes the resolve branch and
|
|
||||||
`FUN_180014420` returns NULL → crash at `0x180014882` (exactly F3). A **fresh** process that
|
|
||||||
only ever sees the no-sentinel topology caches `-1` and is routed to Browse safely (R1).
|
|
||||||
|
|
||||||
Practical rules:
|
|
||||||
- Choose the My-Packs representation (sentinel-present vs sentinel-absent) **before** a client
|
|
||||||
starts its session, and hold it for that session.
|
|
||||||
- The future patch-capability handshake (PART II §12) MUST therefore be decided at
|
|
||||||
login/session start, not toggled mid-session.
|
|
||||||
- A NULL-return guard at `FUN_180014420` (deferred) is the only thing that would make a
|
|
||||||
mid-session flip crash-safe; until then, session stability is mandatory.
|
|
||||||
@@ -1,358 +0,0 @@
|
|||||||
# FIFA 17 — verified patched-client capability negotiation
|
|
||||||
|
|
||||||
Goal: let the FIFA 17 backend suppress the synthetic My-Packs sentinel (id 65534)
|
|
||||||
**only when the current FIFA process has positively verified that the CardsDLL
|
|
||||||
resolver guard is active** (JNZ→JG at RVA `0x14858`). Unpatched / unsupported /
|
|
||||||
unknown / failed-patch clients keep receiving the existing P2 active sentinel.
|
|
||||||
|
|
||||||
Core principle: **the capability is not "this launcher supports the patch"; it is
|
|
||||||
"the resolver guard was verified in *this particular FIFA process*."**
|
|
||||||
|
|
||||||
This document is the design + the cross-component contract. It is deliberately
|
|
||||||
additive: the P2 active-sentinel path (`docs/evidence/FIFA17_EMPTY_MYPACKS_CLIENT_CONTRACT.md`)
|
|
||||||
remains the default and the universal fallback.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Architecture inventory (as-built, verified by reading the code)
|
|
||||||
|
|
||||||
Data flow today (launch of one FIFA process):
|
|
||||||
|
|
||||||
```
|
|
||||||
LauncherApp::launch_game (openfut-launcher/src/app.rs:450)
|
|
||||||
-> account_sync::sync POST /openfut/account/sync (:8099) [REQUIRED; launch is gated on it]
|
|
||||||
-> ensure_local_services() spawn LSX, then autopatch.py --launcher-pid <launcher_pid>
|
|
||||||
-> game_launch::launch umu-run FIFA17.exe (grandchild; launcher never learns FIFA PID)
|
|
||||||
FIFA process
|
|
||||||
-> autopatch.py self-discovers FIFA by comm=='FIFA17.exe'; patches /proc/<pid>/mem each tick
|
|
||||||
-> FIFA -> backend POST /ut/auth (login) ; GET /store/purchasegroup ; ... (:8099)
|
|
||||||
```
|
|
||||||
|
|
||||||
Facts that shape the design:
|
|
||||||
|
|
||||||
- **Launcher ↔ autopatch IPC = one-way stdout only.** `local_services::spawn`
|
|
||||||
(openfut-launcher/src/local_services.rs:279-296) pipes autopatch stdout/stderr
|
|
||||||
into the launcher `LogBuffer` line-by-line as `[autopatch] <line>`. There is no
|
|
||||||
socket / named pipe / status-file readback. `--launcher-pid` is the *launcher's*
|
|
||||||
own pid (local_services.rs:66), used for liveness, not to identify FIFA.
|
|
||||||
- **Launcher ↔ backend = exactly one control call:** `account_sync::sync`
|
|
||||||
(openfut-launcher/src/account_sync.rs:43) — a tiny stdlib-HTTP `POST
|
|
||||||
/openfut/account/sync` on `openfut_account_sync_port` (default 8099), sent once
|
|
||||||
per launch, *before* FIFA starts, and **launch is blocked unless it succeeds**
|
|
||||||
(utas_server.py:1204). This is the reliable per-FIFA-process session boundary.
|
|
||||||
- **Backend is single-account, stateless-per-request, threaded.** `SID` is a fixed
|
|
||||||
module constant shared by all clients (utas_server.py:32); account identity is one
|
|
||||||
global `ACCOUNT` singleton. There is **no per-session identity** in requests. The
|
|
||||||
only per-connection discriminator available at every handler is
|
|
||||||
`self.client_address[0]` (peer IP), currently unused. Server is
|
|
||||||
`ThreadingHTTPServer` (utas_server.py:3784); module is import-safe (server under
|
|
||||||
`if __name__ == "__main__"`).
|
|
||||||
- **No bridge/proxy in the FIFA-17 path.** FIFA reaches the Python backend's
|
|
||||||
published `:8099` directly (client-side DNAT/hosts redirect); the openfut-bridge is
|
|
||||||
legacy FIFA-23. Docker's iptables DNAT preserves the source IP for external LAN
|
|
||||||
clients. The launcher and FIFA run on the **same** client machine, so the backend
|
|
||||||
observes them under the **same** peer IP regardless of NAT.
|
|
||||||
|
|
||||||
## 2. Capability transport — options and choice
|
|
||||||
|
|
||||||
Ranked against the as-built architecture:
|
|
||||||
|
|
||||||
**autopatch → launcher (chosen: structured stdout line).**
|
|
||||||
1. **Structured stdout line (CHOSEN).** Reuses the existing one-way pipe the
|
|
||||||
launcher already reads. It is *live* (only the current autopatch child's stdout),
|
|
||||||
inherently child-bound, and carries **zero stale-file risk** — a previous
|
|
||||||
launch's capability cannot leak because nothing is persisted. Smallest possible
|
|
||||||
change. Format is a machine-readable token (§4).
|
|
||||||
2. Status file in `$XDG_RUNTIME_DIR` keyed by launcher-pid+FIFA-pid+version+timestamp
|
|
||||||
— works but needs explicit staleness handling and cleanup; more moving parts.
|
|
||||||
3. Unix-domain socket — most capable but overkill; there is no bidirectional need.
|
|
||||||
|
|
||||||
**launcher → backend (chosen: sibling HTTP endpoint on the account-sync port).**
|
|
||||||
- A. **Existing session-init channel (CHOSEN).** Add `POST /openfut/fifa17/capability`
|
|
||||||
next to the existing `/openfut/account/sync` (same port 8099, same tiny stdlib-HTTP
|
|
||||||
client). It cannot ride *inside* account_sync because the capability is only known
|
|
||||||
*after* autopatch verifies (which happens after account_sync + FIFA start), so it is
|
|
||||||
a separate, later call — but on the same proven transport.
|
|
||||||
- B. Blaze/login metadata — rejected: no OpenFUT-owned field is available without
|
|
||||||
risking a field FIFA depends on, and Blaze runs in a separate responder.
|
|
||||||
- C. New local IPC + backend side-channel — unnecessary; A already exists.
|
|
||||||
- D. Server-wide "assume patched" config — dev/testing fallback only; cannot
|
|
||||||
distinguish patched vs unpatched clients, so never the production mechanism.
|
|
||||||
|
|
||||||
## 3. The capability (name + version + VERIFIED semantics)
|
|
||||||
|
|
||||||
- Name: **`fifa17.empty_mypacks_resolver`**, integer version, current **`1`**.
|
|
||||||
- **VERIFIED (v1) means, for THIS FIFA process:** the CardsDLL tested build was
|
|
||||||
recognised AND the live bytes at RVA `0x14858` are `7f 0f` (`JG`) **after
|
|
||||||
autopatch enforcement** — i.e. `guarded_action` returned `"patch"` (was `75 0f`,
|
|
||||||
written, re-read as `7f 0f`) **or** `"noop"` (already `7f 0f`).
|
|
||||||
- It explicitly does **NOT** mean any of: "autopatch.py contains the guard code",
|
|
||||||
"the launcher build is new enough", or "a config flag is set". The signal
|
|
||||||
represents **observed runtime enforcement on the specific process**, nothing less.
|
|
||||||
|
|
||||||
## 4. autopatch verification state + emitted line
|
|
||||||
|
|
||||||
Per-FIFA-pid guard status (fail-closed; never loosens the existing byte guard):
|
|
||||||
|
|
||||||
| state | meaning |
|
|
||||||
|---|---|
|
|
||||||
| `NOT_ATTEMPTED` | CardsDLL not yet mapped / guard not evaluated for this pid |
|
|
||||||
| `VERIFIED` | live bytes == `7f 0f` after enforcement (from `patch` or `noop`) |
|
|
||||||
| `UNSUPPORTED_BUILD` | live bytes are neither the known original nor patched (`guarded_action` → `skip`) |
|
|
||||||
| `WRITE_FAILED` | `/proc/<pid>/mem` write raised |
|
|
||||||
| `VERIFY_FAILED` | post-write re-read != `7f 0f` |
|
|
||||||
|
|
||||||
Only `VERIFIED` advertises capability. On transition to `VERIFIED`, autopatch emits
|
|
||||||
**once per FIFA pid** on stdout:
|
|
||||||
|
|
||||||
```
|
|
||||||
[store-guard] verified capability fifa17.empty_mypacks_resolver=1 fifa_pid=<pid>
|
|
||||||
```
|
|
||||||
|
|
||||||
Any non-verified terminal state emits an explicit, non-advertising status line, e.g.:
|
|
||||||
|
|
||||||
```
|
|
||||||
[store-guard] guard status=UNSUPPORTED_BUILD fifa_pid=<pid> (no capability advertised)
|
|
||||||
```
|
|
||||||
|
|
||||||
## 5. Launcher per-process capability state
|
|
||||||
|
|
||||||
```rust
|
|
||||||
pub struct Fifa17ClientCapabilities { pub empty_mypacks_resolver: Option<u32> }
|
|
||||||
```
|
|
||||||
|
|
||||||
- Starts **UNKNOWN** (`None`) at each launch.
|
|
||||||
- Becomes `Some(1)` when the launcher parses a valid capability line from the
|
|
||||||
**current** autopatch child's stdout (`parse_capability_line`).
|
|
||||||
- **Discarded** when autopatch stops / FIFA exits / launcher exits / next launch. It
|
|
||||||
is never persisted and never reused for a later FIFA process — staleness is
|
|
||||||
structurally impossible.
|
|
||||||
|
|
||||||
On first `Some(v)`, the launcher registers the capability with the backend (§6) once.
|
|
||||||
|
|
||||||
## 6. Launcher → backend registration + binding
|
|
||||||
|
|
||||||
`POST /openfut/fifa17/capability` (port = `openfut_account_sync_port`, 8099), body:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{"capability":"empty_mypacks_resolver","version":1,"personaId":<id>,"fifaPid":<pid>}
|
|
||||||
```
|
|
||||||
|
|
||||||
- **Binding key = source IP** (`self.client_address[0]`). The registration arrives
|
|
||||||
from the client machine's IP; FIFA's `/store/purchasegroup` requests arrive from
|
|
||||||
the **same** IP (same machine). `personaId`/`fifaPid` are for logging only (the
|
|
||||||
backend is single-account, so persona cannot discriminate clients).
|
|
||||||
- Concurrency: distinct client machines → distinct peer IPs → independent decisions
|
|
||||||
(no global state). Two FIFA processes on **one** machine share an IP — an accepted
|
|
||||||
limitation (the backend is single-account anyway); documented in §Trust.
|
|
||||||
|
|
||||||
## 7. Backend session-stable decision
|
|
||||||
|
|
||||||
Per-IP record (guarded by a lock; threaded server):
|
|
||||||
|
|
||||||
```
|
|
||||||
_FIFA17_STORE[ip] = {"resolver": Option[int], "mode": Option[str]} # mode: None|"sentinel"|"clean-v1"
|
|
||||||
```
|
|
||||||
|
|
||||||
- **Reset (session boundary):** `/openfut/account/sync` from `ip` sets
|
|
||||||
`{resolver: None, mode: None}`. This is the launcher's required per-launch call, so
|
|
||||||
every new FIFA process starts from a clean, unfrozen record — no cross-process leak.
|
|
||||||
- **Register:** `/openfut/fifa17/capability` from `ip` sets `resolver = version`. If
|
|
||||||
`mode` is already frozen, it is logged as late and **ignored for this session**.
|
|
||||||
- **Freeze point = first `/store/purchasegroup`** from `ip` (§9): if `mode is None`,
|
|
||||||
set `mode = "clean-v1"` iff `resolver == 1` else `"sentinel"`, and log once.
|
|
||||||
Thereafter `mode` is immutable for the session.
|
|
||||||
- **Default / fail-closed:** an IP with no record (no account-sync, no capability),
|
|
||||||
an unknown resolver version, a late capability, or a disappeared capability all
|
|
||||||
resolve to (or remain) `"sentinel"`.
|
|
||||||
|
|
||||||
## 8. Freeze point rationale
|
|
||||||
|
|
||||||
Freeze at **first `/store/purchasegroup`**, not at login/account-sync. account-sync
|
|
||||||
fires *before* FIFA starts and *before* autopatch can verify, so freezing there would
|
|
||||||
always be `sentinel`. First Store request is the earliest moment at which a genuine
|
|
||||||
capability can already be registered (autopatch verifies at process start; the user
|
|
||||||
opens the Store later), while still being a single, well-defined topology commit for
|
|
||||||
the session. Once Store topology is served, it must not change (the F3 experiment
|
|
||||||
proved a mid-session flip can leave a stale positive ordinal that crashes even the
|
|
||||||
sign-only guard — see `FIFA17_EMPTY_MYPACKS_CLIENT_FIX.md` PART III/IV and the
|
|
||||||
SESSION-STABLE invariant).
|
|
||||||
|
|
||||||
## 9. Store behaviour (additive switch)
|
|
||||||
|
|
||||||
At `store_catalog`, only the zero-owned-packs branch changes:
|
|
||||||
|
|
||||||
```
|
|
||||||
if not owned_ids:
|
|
||||||
if fifa17_empty_mypacks_mode(client_ip) == "clean-v1":
|
|
||||||
pass # patched client: emit NO mypacks group; guard routes -1 to Browse
|
|
||||||
else:
|
|
||||||
<append active 65534 sentinel exactly as today> # P2 fallback (unchanged)
|
|
||||||
```
|
|
||||||
|
|
||||||
Untouched: real owned-pack rendering, `PACK_CATALOG`, pack 70, normal packs 1/5/6/7,
|
|
||||||
profile state, all store env flags. Default remains sentinel. This lives in the FIFA-17
|
|
||||||
Python backend only — **never** in game-independent OpenFUT Core.
|
|
||||||
|
|
||||||
| client | zero packs | real unopened pack |
|
|
||||||
|---|---|---|
|
|
||||||
| verified v1 | **no sentinel** (clean) | genuine My Packs, no sentinel |
|
|
||||||
| no / unknown capability | **active 65534 sentinel** | genuine My Packs, no sentinel |
|
|
||||||
|
|
||||||
## 10. Trust model (Task 14)
|
|
||||||
|
|
||||||
This is **not** anti-cheat / attestation. OpenFUT assumes the user controls the
|
|
||||||
launcher/client machine and the server is a private preservation environment. The
|
|
||||||
verification exists to prevent *accidents*: a stale capability, an unsupported
|
|
||||||
CardsDLL build, a failed autopatch, the wrong process, or an unpatched client
|
|
||||||
receiving no sentinel and crashing. No signatures / PKI / remote attestation.
|
|
||||||
|
|
||||||
Isolation across *distinct client machines* relies on the backend observing distinct
|
|
||||||
peer IPs (source-IP-preserving publish; Docker's default for external LAN via iptables
|
|
||||||
DNAT). Two FIFA processes on one machine cannot be distinguished by IP — accepted,
|
|
||||||
since the backend is single-account. The single-client production case is unaffected
|
|
||||||
by NAT because launcher and FIFA share one IP.
|
|
||||||
|
|
||||||
## 11. Fail-closed matrix (Task 15) — every failure ⇒ sentinel
|
|
||||||
|
|
||||||
autopatch missing / not run · guard `UNSUPPORTED_BUILD` / `WRITE_FAILED` /
|
|
||||||
`VERIFY_FAILED` · launcher cannot parse the line · registration POST fails ·
|
|
||||||
account-sync never called · unknown capability version · capability arrives after
|
|
||||||
freeze · capability disappears after a sentinel freeze — **all resolve to the active
|
|
||||||
65534 sentinel.** Asserted by tests (matrix A–J) and this document.
|
|
||||||
|
|
||||||
## 12. P2 retained (Task 16)
|
|
||||||
|
|
||||||
The active-sentinel implementation is **not** removed. It is the else-branch of the
|
|
||||||
switch and the universal default for unpatched clients, unsupported builds, failed
|
|
||||||
patches, unknown launchers, and late capabilities. The clean path is purely additive.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 13. Session binding (hardening — supersedes the per-IP prototype)
|
|
||||||
|
|
||||||
**History.** The first implementation keyed the backend capability/store-mode by
|
|
||||||
**source IP alone** (§7 as originally written). That was rejected before deployment:
|
|
||||||
two FIFA processes that share a source IP — concurrent, or a relaunch — would share
|
|
||||||
the key, so an *unverified* process could inherit a *verified* one's `clean-v1`
|
|
||||||
topology and crash on the empty-My-Packs resolver. Source IP is now **auxiliary only**
|
|
||||||
(logging, a fail-closed sid/ip sanity check, and the pending hand-off key). This
|
|
||||||
history is retained deliberately; do not treat per-IP as the design.
|
|
||||||
|
|
||||||
**Authoritative key = the per-login UTAS session id (`X-UT-SID`).** `/ut/auth` now
|
|
||||||
mints a fresh unique SID per login (was a shared constant `OPENFUT-SID-…0001`); the
|
|
||||||
client echoes it on every later call, and it is **live-confirmed present on real
|
|
||||||
`/store/purchasegroup` requests**. The SID uniquely identifies one FIFA process/login:
|
|
||||||
a relaunch re-auths → new SID; two concurrent logins → two SIDs. The legacy constant
|
|
||||||
is still accepted by the retired security-question gate only, and is **never** used to
|
|
||||||
grant `clean-v1`. A store request whose SID was opened on a different source IP is
|
|
||||||
fail-closed to sentinel (sid/ip sanity check).
|
|
||||||
|
|
||||||
**Why not persona alone:** the backend is single-account, so `personaId` cannot
|
|
||||||
distinguish two sessions, and a relaunch keeps the same persona — persona alone would
|
|
||||||
leak a prior session's mode. Persona is used only (with IP) to key the pending hand-off.
|
|
||||||
|
|
||||||
### State machine (per session, keyed by SID)
|
|
||||||
```
|
|
||||||
Capability : Unknown | ResolverV1
|
|
||||||
StoreMode : Unfrozen | Sentinel | CleanV1
|
|
||||||
|
|
||||||
/ut/auth (new SID) : Capability=Unknown, StoreMode=Unfrozen, record {ip,persona}
|
|
||||||
+ consume any pending (ip,persona) -> Capability=ResolverV1
|
|
||||||
capability registered : bind to the one live Unfrozen/Unbound session for (ip,persona)
|
|
||||||
-> Capability=ResolverV1 ; else stage single-use pending ;
|
|
||||||
else (a session exists but is frozen/ambiguous) -> ignored-late
|
|
||||||
first /store/purchasegroup : Unfrozen + ResolverV1 -> freeze CleanV1
|
|
||||||
Unfrozen + otherwise -> freeze Sentinel (consume pending first)
|
|
||||||
late capability : StoreMode already frozen -> unchanged (ignored-late, not staged)
|
|
||||||
capability lost/cleared : after a CleanV1 freeze -> stays CleanV1 (mode is cached)
|
|
||||||
session idle > TTL / reaped: session discarded (a later store with that SID -> Sentinel)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Registration order + pending hand-off
|
|
||||||
The verified capability is known only after the FIFA process exists, CardsDLL is
|
|
||||||
loaded, and autopatch confirms the JG bytes — which may land before or after
|
|
||||||
`/ut/auth`, but reliably before the user opens the Store. The launcher cannot know
|
|
||||||
the SID, so its registration is matched to a session by (source_ip, persona) as a
|
|
||||||
**single-use, short-TTL pending** (`FIFA17_PENDING_TTL = 120s`) that is consumed by
|
|
||||||
exactly one session, at whichever of these happens first for that session: its
|
|
||||||
`/ut/auth` (pending predates login), the registration itself (session already live —
|
|
||||||
bound directly), or its first store request (lazy). If the Store is reached before a
|
|
||||||
capability binds, the session freezes **Sentinel** (fail-closed); a later capability
|
|
||||||
does not change it.
|
|
||||||
|
|
||||||
### Session cleanup (Task 10)
|
|
||||||
- **creation:** at `/ut/auth`.
|
|
||||||
- **last activity:** bumped on every `/store/purchasegroup` for the session.
|
|
||||||
- **freeze:** first `/store/purchasegroup`.
|
|
||||||
- **expiry:** lazy sweep on every session op removes sessions idle for
|
|
||||||
`FIFA17_SESSION_TTL = 3600s` and pendings older than `FIFA17_PENDING_TTL`. Explicit
|
|
||||||
Blaze/UTAS teardown is not reliably observable at this handler, so a conservative
|
|
||||||
activity-based TTL is used instead. Reaping only removes *expired* entries and never
|
|
||||||
affects another live session from the same IP/persona (keyed by distinct SIDs).
|
|
||||||
|
|
||||||
### Residual limitation (documented, fail-closed)
|
|
||||||
FIFA carries no launcher-controllable per-process token, so two **simultaneous** logins
|
|
||||||
from the **same (ip, persona)** cannot be disambiguated at the instant a capability is
|
|
||||||
registered while *both* are Unfrozen/Unbound. That ambiguous case resolves to
|
|
||||||
`ignored-late` → **both freeze Sentinel** (safe: an unverified process is never granted
|
|
||||||
clean). The normal one-launcher-per-FIFA and sequential-relaunch flows bind correctly
|
|
||||||
(proven by matrix K/L/M). This is a UX conservativeness, never a safety hole.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 14. Deployment candidate & controlled A/B (overnight reconciliation 2026-08-13)
|
|
||||||
|
|
||||||
**Launcher lineage reconciliation.** The two divergent launcher histories (merge
|
|
||||||
base `87241ac`) were reconciled by a real merge — **not** a rebase/squash/rewrite —
|
|
||||||
in a clean worktree:
|
|
||||||
- `feat/launcher-arming` `13339c1` (client arming + FIFA-17 capability reporting)
|
|
||||||
- `feat/sbc-hook-tracing` `958ff24` (openfut-hook SBC request tracing / RE probes)
|
|
||||||
|
|
||||||
Merged commit **`ca7ce26`** on branch `integration/fifa17-launcher-capability-sbc`
|
|
||||||
retains **both** ancestors (`git merge-base --is-ancestor` true for both `958ff24`
|
|
||||||
and `13339c1`). The only conflict was `src/process.rs` (launcher-arming deleted it +
|
|
||||||
dropped `mod process`; SBC only incidentally tidied it) — resolved **keep-deleted**
|
|
||||||
(orphan module; the SBC feature lives entirely in `openfut-hook/*`). The two features
|
|
||||||
are in disjoint crates/processes (launcher-crate Rust host vs `openfut-hook` Windows
|
|
||||||
DLL) and share no stdout readers, child handles, or lifecycle — no integration code
|
|
||||||
was needed.
|
|
||||||
|
|
||||||
**Gitlink status — DEFERRED (morning blocker).** The superproject gitlink still
|
|
||||||
records the pre-reconciliation `958ff24`. It was **not** bumped to `ca7ce26` because
|
|
||||||
the live submodule checkout carries uncommitted `openfut-hook/*` WIP that overlaps the
|
|
||||||
merged hook content; a non-destructive `git checkout ca7ce26` is refused ("local
|
|
||||||
changes would be overwritten"), and no `-f`/`reset`/`clean` is permitted. The user
|
|
||||||
must first reconcile that WIP against the merged `openfut-hook`, then the gitlink can
|
|
||||||
bump. Preservation artifact: `/tmp/openfut-launcher-overnight-tracked.patch`
|
|
||||||
(sha256 `8e65de2c…`).
|
|
||||||
|
|
||||||
**Validated deployment-candidate tuple** (reproducible from git except the deferred
|
|
||||||
gitlink):
|
|
||||||
```
|
|
||||||
superproject HEAD a82407c (backend per-session + docs)
|
|
||||||
backend guard b0d5e04 fix(fifa17): guard missing store category resolution
|
|
||||||
client proof fc29c2e docs(fifa17): record no-sentinel client resolver proof
|
|
||||||
autopatch report 1c396dd feat(fifa17): report verified client patch capability
|
|
||||||
backend negotiate b25761e feat(fifa17): negotiate clean empty My Packs mode
|
|
||||||
session binding 805d754 fix(fifa17): isolate patched-client capability per session
|
|
||||||
launcher merged HEAD ca7ce26 merge: reconcile launcher capability and SBC tracing
|
|
||||||
(ancestors 13339c1 capability + 958ff24 SBC)
|
|
||||||
launcher gitlink (super) 958ff24 <-- to become ca7ce26 once WIP reconciled
|
|
||||||
```
|
|
||||||
Local build artifacts (NOT deployed): launcher `target/release/openfut-launcher`
|
|
||||||
(sha256 `a390c61d…`); backend image `openfut-fut-backend:candidate-overnight`
|
|
||||||
(`84d280be…`, ships `utas_server.py` `33e0ef3…`). Live `:dev` image and the running
|
|
||||||
container were left untouched.
|
|
||||||
|
|
||||||
### Controlled A/B sequence (execute only in a later authorized deploy task)
|
|
||||||
**A — patched client:** fresh FIFA process → autopatch verifies the JG guard →
|
|
||||||
launcher parses the verified line and registers → `/ut/auth` mints a fresh `X-UT-SID`
|
|
||||||
→ capability binds to that SID → first `/store/purchasegroup` freezes `clean-v1` →
|
|
||||||
backend omits 65534 → Store opens on Browse Packs, no crash.
|
|
||||||
**B — unpatched client, same machine/IP, NEW session:** new `X-UT-SID`, no verified
|
|
||||||
capability → first store freezes `sentinel` → backend emits active 65534 → no crash.
|
|
||||||
Proves same-IP isolation + fail-closed fallback.
|
|
||||||
**C — failed patch (optional):** autopatch reports `UNSUPPORTED_BUILD`/`VERIFY_FAILED`
|
|
||||||
→ launcher never registers → `sentinel`.
|
|
||||||
Production remains the P2 active-sentinel universal default until this A/B passes.
|
|
||||||
@@ -1,208 +0,0 @@
|
|||||||
# OpenFUT Status Review
|
|
||||||
*Generated 2026-06-30 — read-only stocktake, no code changed.*
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Executive Summary
|
|
||||||
|
|
||||||
OpenFUT has a mature offline FUT economy backend (Core, 25 phases, fully functional in
|
|
||||||
isolation) and a sophisticated hook DLL that loads into FIFA 23, redirects EA hostnames
|
|
||||||
to loopback, and bypasses TLS certificate verification. The Blaze/ProtoSSL layer is
|
|
||||||
structurally ready: framing code exists, a TLS listener runs, cert-verify is patched.
|
|
||||||
However the project is currently blocked before any Blaze traffic is ever seen.
|
|
||||||
The fundamental problem is that FIFA 23 submits `GoOnline` to EbisuSDK and then
|
|
||||||
**waits for an asynchronous ONLINE_STATUS_EVENT push** from the EA-app LSX server —
|
|
||||||
a push that current code never sends. Every approach tried so far (flipping poll
|
|
||||||
return values, forcing the state flags, read-only probes) confirms the gate is
|
|
||||||
event-driven, not poll-driven. The Blaze captures directory contains six empty files.
|
|
||||||
No Fire2 frame from FIFA 23 has ever been decoded. Until the ONLINE_STATUS_EVENT push
|
|
||||||
is synthesized and delivered correctly, Milestones 2–7 are all waiting on the same
|
|
||||||
single wall.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Proven vs Assumed
|
|
||||||
|
|
||||||
| Claim | Status | Evidence |
|
|
||||||
|---|---|---|
|
|
||||||
| FIFA 23 uses DirtySDK / ProtoSSL | **Proven** | String scan hit `ProtoSSLSend`, `ProtoSSLRecv`, `gosredirector` in FIFA23.exe memory (Task 1) |
|
|
||||||
| `version.dll` loads and runs hook code | **Proven** | `hook.log` written at DLL_PROCESS_ATTACH |
|
|
||||||
| `getaddrinfo` IAT hook redirects EA domains to loopback | **Proven** | Hook log records every EA `getaddrinfo` call; connect_hook log confirms port redirects |
|
|
||||||
| ProtoSSL cert-verify prologue found and patched (FIFA23.exe) | **Proven** | ssl_patch.rs prologue confirmed at file offset 0xf0c850; hook log "ssl: main exe cert-verify patched" |
|
|
||||||
| ProtoSSL cert-verify patched in EAWebKit.dll | **Proven** (if loaded) | Lazy patch fires on first EA getaddrinfo call; hook log message confirms |
|
|
||||||
| Gate is upstream of DirtySDK — no DNS/connect fires on FUT entry | **Proven** | getaddrinfo, connect, WSASend/Recv hooks all show zero external traffic during "connecting to EA Servers" |
|
|
||||||
| `GoOnline` is called by the game | **Proven** | Read-only detour on `anadius64.dll+0x2BB90` confirmed hit |
|
|
||||||
| anadius returns GoOnline success | **Proven** | Handler observed returning successfully; game still retries every ~7 s |
|
|
||||||
| Gate is downstream of GoOnline | **Proven** | GoOnline called + returns success; no Blaze connect follows |
|
|
||||||
| Connection-state function: `GetInternetConnectedState @ anadius64.dll+0x27790` | **Proven** | Located via anadius LSX command-registration table; two-flag branch decoded (`+0xCAB1A`, `+0xCAB1B`) |
|
|
||||||
| Gate is event-driven (game waits for async push, not a poll return) | **Proven** | Forced both state flags AND GoOnline return to "1"; game kept retrying; worker-thread stack scan confirms handler runs on anadius IOCP thread, not FIFA's thread |
|
|
||||||
| GoOnline runs on anadius worker thread, not FIFA's call thread | **Proven** | Stack scan from inside detour found zero FIFA23.exe frames, sp ~2.4 KB from thread stack top |
|
|
||||||
| `protossl-scan` live toolkit is exhausted for finding GoOnline in FIFA23.exe | **Proven** | No `"GoOnline"` string in image; worker-thread call stack has no FIFA frames; jmpscan yields ~3875 hits (overwhelmingly data false positives) |
|
|
||||||
| FIFA 23 redirector config references `Authorization:` header (Nucleus token) | **Proven** | Found in FIFA23.exe .rdata pointer table @ `+0x83FC858` |
|
|
||||||
| openfut-core REST API complete and tested | **Proven** | 25 phases, 15 migrations, passing integration tests |
|
|
||||||
| Bridge LSX server starts and handles request-response | **Proven** (code) | `openfut-bridge/src/lsx.rs` + `main.rs` — server starts on 127.0.0.1:3216 |
|
|
||||||
| Bridge LSX server ACTUALLY receives FIFA's LSX connections | **UNCONFIRMED** | anadius may intercept the same calls in-process before the TCP connection reaches the bridge |
|
|
||||||
| Bridge LSX server `GetInternetConnectedState → connected="1"` unblocks the gate | **UNCONFIRMED (known to fail in-process)** | Flipping the value via anadius in-process failed; bridge path not yet confirmed working |
|
|
||||||
| ONLINE_STATUS_EVENT push XML format | **UNKNOWN** | No capture; format not derived |
|
|
||||||
| Fire2 framing is correct for FIFA 23 | **UNCONFIRMED** | Implemented based on post-2012 EA convention; all blaze captures are empty (0 bytes) |
|
|
||||||
| Blaze component / command IDs for FIFA 23 | **UNKNOWN** | Zero captures; dispatch table entirely empty placeholders |
|
|
||||||
| ProtoSSL recv-injection convention (non-blocking return values etc.) | **UNCONFIRMED** | Never reached M4; recv_hook module removed from active install path |
|
|
||||||
| FUT REST endpoint paths in mapper.rs | **SPECULATIVE** | Based on community knowledge of older FIFA titles; the one actual capture in `captures/` is an early GET from before the Blaze strategy |
|
|
||||||
| FLE Lua API exposes FUT DB tables in memory | **UNKNOWN** | `export_squad.lua` has never been run; FUT data may only exist server-side in online mode |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Milestone Status
|
|
||||||
|
|
||||||
| Milestone | Status | Blocker | Depends on unconfirmed assumption? |
|
|
||||||
|---|---|---|---|
|
|
||||||
| **M1** — Locate connection-state decision point | ✅ Done | — | No |
|
|
||||||
| **M2** — Flip gate, force "connected" | ⛔ Blocked | Game waits for async ONLINE_STATUS_EVENT push; no current code sends it | Yes — unknown event XML format |
|
|
||||||
| **M3** — First ProtoSSL plaintext on Blaze connection | 🔲 Not started | Depends on M2 | Yes — Fire2 framing unconfirmed |
|
|
||||||
| **M4** — Answer redirector + decode first Fire2 frame | 🔲 Not started | Hard wall: Fire2 framing, recv-injection convention, component/command IDs all unconfirmed | Yes — all three unknown |
|
|
||||||
| **M5** — Blaze preauth / login / postauth | 🔲 Not started | Depends on M4 | Yes — Blaze auth TDF body layout unknown |
|
|
||||||
| **M6** — FUT entry + hub load | 🔲 Not started | Depends on M5; also requires FUT REST response shapes confirmed | Yes — endpoint paths speculative |
|
|
||||||
| **M7** — Squad Battles (AI FUT) | 🔲 Not started | Depends on M6 | Yes |
|
|
||||||
|
|
||||||
**Note on roadmap.md wording:** Under M2–M4, roadmap.md uses `**Done (observable):**` bullets. These describe the *success criterion* for each milestone, not an achieved state. The authoritative status is in `connection-gate-findings.md` (M2 attempts failed; M3/M4 never started). The roadmap has not been updated to reflect M2 failure.
|
|
||||||
|
|
||||||
### M4 is the first hard wall in detail
|
|
||||||
|
|
||||||
Even assuming M2 is solved, M4 requires three unconfirmed things simultaneously:
|
|
||||||
1. **Fire2 framing** — the 12-byte header layout is assumed; if FIFA 23 uses an older Fire variant or a custom delta, the codec will misparse every packet.
|
|
||||||
2. **ProtoSSL recv-injection** — delivering responses to the game via recv hook requires knowing what return values and buffer conventions ProtoSSL expects; recv_hook.rs exists but is not installed.
|
|
||||||
3. **Blaze component/command IDs** — the dispatch table is entirely empty; we cannot answer any request until IDs are known from captures.
|
|
||||||
|
|
||||||
All three are resolved by getting one real captured frame. M4 is primarily a capture problem, not a decoding problem — once bytes exist, the framing and IDs are immediately readable.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Blockers, Risks, Unknowns
|
|
||||||
|
|
||||||
### Blockers (stop progress now)
|
|
||||||
|
|
||||||
1. **ONLINE_STATUS_EVENT push not synthesized** *(M2 wall)*
|
|
||||||
The game calls GoOnline, gets success, then waits indefinitely for a push event on the LSX socket that never arrives. This is the single gate blocking all Blaze work. Options: (a) trace the event format via Ghidra on FIFA23.exe (xref `ONLINE_STATUS_EVENT` string + the game's EbisuSDK listener), (b) RE anadius's LSX event-send path (find what it would push in an "online" scenario), (c) brute-force push candidate event XMLs and observe whether the game advances.
|
|
||||||
|
|
||||||
2. **Bridge LSX server delivery unconfirmed** *(architectural risk converted to blocker)*
|
|
||||||
The hook passes port 3216 connections through, assuming the bridge LSX server on the Linux host receives them. If anadius's in-process hooks intercept the winsock calls before they reach the TCP stack, the bridge server is never reached. This must be confirmed by checking `openfut_hook.log` for a getaddrinfo on the LSX host, or by observing the bridge server's accept logs.
|
|
||||||
|
|
||||||
### Risks (could derail later)
|
|
||||||
|
|
||||||
3. **Fire2 framing wrong** *(M4 risk)*
|
|
||||||
If FIFA 23 uses Fire (pre-2012) or a modified frame layout, the codec misparses. Mitigation: the server has a `Raw` fallback mode for capturing raw bytes when framing fails.
|
|
||||||
|
|
||||||
4. **Secondary auth-token gate** *(M5 risk)*
|
|
||||||
`connection-gate-findings.md` noted the redirector request carries an `Authorization:` header. M1's final conclusion said `GetAuthCode` returns a fake token that appears accepted — but this was inferred, not confirmed by seeing the redirector request actually constructed with that token.
|
|
||||||
|
|
||||||
5. **EAAC not fully neutralized** *(persistent risk)*
|
|
||||||
`FakeEAACLauncher` bypasses the anticheat launcher. The hook DLL is unsigned. If EAAC is ever active (e.g., after a game update re-enables it), all hooks fail silently. Marked as "not active in offline/cracked builds" — assumed, not confirmed on every launch.
|
|
||||||
|
|
||||||
6. **FUT REST response shapes wrong** *(M6 risk)*
|
|
||||||
The 61 endpoint mappings in mapper.rs and the shaper stubs in shaper.rs are based on community guesses about older FIFA FUT APIs, not FIFA 23 captures. Response JSON shapes may differ enough to cause the client to fail silently or crash.
|
|
||||||
|
|
||||||
### Unknowns (open questions)
|
|
||||||
|
|
||||||
7. **ONLINE_STATUS_EVENT XML format** — exact tag names, field order, sender attribute, and any nonces/tokens required.
|
|
||||||
8. **GoOnline event sequence** — whether ONLINE_STATUS_EVENT alone is sufficient or a sequence of events (e.g., PROFILE_EVENT, LOGIN_EVENT, COMMERCE_EVENT) is expected.
|
|
||||||
9. **Whether FLE exposes FUT DB tables** — FUT card inventory and squad data likely live server-side in online mode; FLE may not surface them for in-process editing.
|
|
||||||
10. **Blaze component/command IDs for FIFA 23** — entirely unknown; no captures.
|
|
||||||
11. **openfut_hook.log current content** — we have the code but no log output in any document. Whether the current hook (with connect, ssl_patch, tls_bypass, WSAIoctl, origin_spy all installed) fires correctly and what it observes is unverified in this review.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Track Comparison
|
|
||||||
|
|
||||||
### Track A — Full EA-backend fake (M1–M7, playable FUT vs AI)
|
|
||||||
|
|
||||||
**What it delivers:** The FIFA 23 FUT hub loads from OpenFUT Core; Squad Battles matches play and reward economy items.
|
|
||||||
|
|
||||||
**Effort:** Research-grade. Minimum path: synthesize ONLINE_STATUS_EVENT (unknown format, 1–2 weeks of RE), then capture Fire2 frames (days once M2 is solved), then implement Blaze auth handlers (weeks), then implement FUT entry (weeks), then Squad Battles (weeks). Realistic minimum: 3–6 months of expert RE work.
|
|
||||||
|
|
||||||
**Proven support:** Hook loads and redirects correctly. TLS bypass patched. Core economy backend complete. Blaze framing code and TLS listener exist.
|
|
||||||
|
|
||||||
**Assumed:** Fire2 framing correct; component/command IDs discoverable from captures; FUT REST shapes close enough to community guesses; no additional undiscovered gates.
|
|
||||||
|
|
||||||
**Evidence for:** Architecture is coherent. The M1 finding (gate precisely named and decoded) was achieved cleanly. The in-process hook approach is validated.
|
|
||||||
|
|
||||||
**Evidence against:** M2 was attempted and failed with the in-process approach. The event-driven architecture adds a full EbisuSDK emulation layer before even one Blaze byte is seen. The live toolkit is exhausted (Path A verdict); Ghidra-level work on a 505 MB binary is required. Six capture files with zero bytes.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Track B — Clean-room spec deliverable (M1–M5 documented)
|
|
||||||
|
|
||||||
**What it delivers:** A documented map of the connection gate, LSX event sequence, Blaze auth surface (transport, framing, gate conditions, component IDs, TDF schemas). Valuable as an archival/community artifact even if Track A stalls.
|
|
||||||
|
|
||||||
**Effort:** Medium. M1 is done. M2–M5 documentation emerges as a by-product of engineering work. The spec itself (writing) is lightweight; the engineering to produce the captures is the cost.
|
|
||||||
|
|
||||||
**Proven support:** M1 complete and documented. connection-gate-findings.md is already a high-quality spec artifact.
|
|
||||||
|
|
||||||
**Assumed:** Same as Track A for the unconfirmed values, but the spec can mark them `TODO/CONFIRM` rather than needing to implement them.
|
|
||||||
|
|
||||||
**Evidence for:** The clean-room constraint means a spec is the only artifact that can be safely published. connection-gate-findings.md shows this approach produces real value. B finishes even if A is never fully playable.
|
|
||||||
|
|
||||||
**Evidence against:** Track B alone doesn't produce a playable FUT; it is a foundation, not an end-user product.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Track C — FLE Lua bridge (local-match path, skip the backend gate)
|
|
||||||
|
|
||||||
**What it delivers:** FIFA 23 career mode or Kick-Off with an OpenFUT club's players and squad loaded via FLE's in-memory DB API. No online gate, no Blaze, no TLS. Fully offline from day one.
|
|
||||||
|
|
||||||
**Effort:** Low-to-medium. FLE is already loaded in the normal launch path. Tools exist (`tools/squad-exporter/`, `tools/profile-exporter/`). Primary unknown is whether FUT-relevant DB tables are accessible.
|
|
||||||
|
|
||||||
**Proven support:** FLE Lua API exposes `GetDBTableRows` / `EditDBTableField` for career mode. `fifa23-startup-flow.md` confirms FLE injects at load. `fut-integration-options.md` documents the integration path in detail and rates this as the recommended option.
|
|
||||||
|
|
||||||
**Assumed:** FUT card/club/squad data has in-memory DB table representations that FLE can write. If FUT data is purely server-side (loaded from EA servers, not from the Frostbite DB layer), Track C produces no FUT simulation at all — only career mode player stats.
|
|
||||||
|
|
||||||
**Evidence for:** Career mode already works with FLE edits (community precedent). Tools are present and designed for this path. No infrastructure work needed.
|
|
||||||
|
|
||||||
**Evidence against:** FUT in FIFA 23 uses server-side data. The cards in a player's FUT club, the coins, the squad — these are fetched from `fut.ea.com` REST APIs, not from the Frostbite embedded DB. FLE's `GetDBTableRows` likely exposes base player stats tables but not FUT item tables. The crucial test (run `export_squad.lua` while in FUT mode) has never been done.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Recommendation
|
|
||||||
|
|
||||||
**Start Track C immediately as a parallel, low-cost validation.**
|
|
||||||
|
|
||||||
Run `export_squad.lua` in FLE while inside the FUT hub (or attempting to enter it). If FUT tables appear in the export, Track C is viable and is the fastest path to something a user can interact with. This test takes one session and costs nothing.
|
|
||||||
|
|
||||||
Simultaneously, **continue Track A/B with the next concrete RE step:** synthesize the ONLINE_STATUS_EVENT push. The most actionable option is to run `origin_spy` logs from the current hook to see what LSX events fire during a session, then attempt to push candidate event XMLs via the bridge LSX server and watch whether the game advances. This is bounded, testable work that either unblocks M2 or produces the spec value for Track B.
|
|
||||||
|
|
||||||
**Do not abandon Track A/B for Track C** — they are complementary. Core is already built; the bridge is mostly built. The gap is purely the RE wall at M2.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Architecture and Provenance Sanity-Check
|
|
||||||
|
|
||||||
### Hook + Brain coherence
|
|
||||||
|
|
||||||
The CLAUDE.md bridge architecture diagram (hook intercepts ProtoSSL → plain localhost TCP → blaze_brain → Core) remains coherent. The M1/M2 findings revealed one additional layer (EbisuSDK LSX event) that must precede the Blaze connection. The bridge has been updated to handle LSX directly. The overall design is sound; the M2 blocker is an implementation gap (event synthesis), not an architectural flaw.
|
|
||||||
|
|
||||||
**One inconsistency to flag:** The hook's `lsx.rs` contains a complete in-process LSX emulator (AES-128-ECB, CRandom, all response builders), but the recv/send hooks that activate it are explicitly removed (`lib.rs`: "recv/send hooks removed — LSX is now handled by the native openfut-bridge LSX server"). This is dead code. The bridge's LSX server is the current path. The in-process lsx.rs should either be deleted or documented as a fallback; its presence is confusing.
|
|
||||||
|
|
||||||
### Clean-room status
|
|
||||||
|
|
||||||
No evidence of EA leaked source anywhere in the tree. All RE work is derived from:
|
|
||||||
- Running the shipping binary and observing behavior (function return values, network traffic patterns)
|
|
||||||
- Memory scanning of the live process (string search, xref, disasm of observed addresses)
|
|
||||||
- Reading anadius's own compiled output (its exported symbols, its LSX XML format — which is anadius's own implementation, not EA's)
|
|
||||||
- Community FUT API knowledge (mapper.rs endpoint paths — plausible but speculative)
|
|
||||||
|
|
||||||
The Blaze framing in `fifa-blaze/crates/blaze-proto/src/frame.rs` cites "Fire2 used by ME3, BF3, and most post-2012 titles" — this is sourced from public community documentation of those older titles, not from any leaked EA source. **Clean-room intact.**
|
|
||||||
|
|
||||||
The `AES_KEY` in the hook's lsx.rs (`[0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15]`) is a placeholder key used for the LSX session encryption. The real session key is derived from the challenge seed via CRandom — this algorithm was RE'd from anadius's own binary. No EA source required.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. If You Read Only This
|
|
||||||
|
|
||||||
- **The project is blocked at M2.** FIFA 23 submits `GoOnline`, gets success, then waits for an async `ONLINE_STATUS_EVENT` push on the LSX socket that no current code ever sends. All six Blaze capture files are empty (0 bytes). No Fire2 frame has ever been decoded.
|
|
||||||
|
|
||||||
- **M1 is the only completed milestone.** The gate function (`GetInternetConnectedState @ anadius64.dll+0x27790`) is precisely named and its two-flag branch decoded. Everything after M1 is either blocked or not started.
|
|
||||||
|
|
||||||
- **The next concrete action** is synthesizing the ONLINE_STATUS_EVENT push XML and testing whether the bridge's LSX server can deliver it to the game. This is the single thing that unblocks all Blaze work.
|
|
||||||
|
|
||||||
- **Track C (FLE Lua) is untested but cheap to validate.** Run `export_squad.lua` while in FUT to find out if FUT DB tables are accessible. If yes, it is the fastest path to user-visible results. If no, it is ruled out with one session.
|
|
||||||
|
|
||||||
- **openfut-core is complete and ready** — 25 phases, 15 migrations, full economy REST API, passing tests. It is not blocking anything; it is waiting for the bridge to connect to it.
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
# Track C — FUT DB table viability test
|
|
||||||
|
|
||||||
**Status: PENDING — test has not yet been run.**
|
|
||||||
|
|
||||||
## What this test settles
|
|
||||||
|
|
||||||
Track C ("FLE Lua bridge") would inject OpenFUT club data directly into FIFA 23's
|
|
||||||
in-memory Frostbite DB tables at runtime, bypassing the entire backend/Blaze stack.
|
|
||||||
It is only viable for FUT (not just career mode) if FUT-specific tables — card
|
|
||||||
inventory, squad composition with FUT fields, coins — are accessible in memory when
|
|
||||||
the game is in the FUT area.
|
|
||||||
|
|
||||||
FUT data in online mode is fetched server-side from `fut.ea.com`. It is not known
|
|
||||||
whether FIFA 23 mirrors any of this into the Frostbite in-memory DB that FLE
|
|
||||||
can read/write. This test settles that question directly.
|
|
||||||
|
|
||||||
## Test procedure
|
|
||||||
|
|
||||||
**Prerequisites:**
|
|
||||||
- FIFA 23 launched normally via umu-run/Steam
|
|
||||||
- FLE (FIFA Live Editor) injected and active (normal launch path)
|
|
||||||
- EAAC in offline/neutralized state
|
|
||||||
- Game navigated as deep into FUT as possible (FUT hub if reachable; otherwise the
|
|
||||||
furthest FUT screen before the gate blocks it)
|
|
||||||
|
|
||||||
**Run the exporter:**
|
|
||||||
1. In FLE's Lua Engine, open and run `tools/squad-exporter/export_squad.lua`
|
|
||||||
(full path on the Windows side: `C:\<game>\openfut_squad_export.json`)
|
|
||||||
2. Wait for the MessageBox "Done! N players, M teams." or "ERROR writing..."
|
|
||||||
3. Retrieve the output file from the Wine prefix:
|
|
||||||
`~/Games/umu/fifa23-tools/drive_c/FIFA 23 Live Editor/openfut_squad_export.json`
|
|
||||||
(or wherever `C:\FIFA 23 Live Editor\` maps in the active prefix)
|
|
||||||
|
|
||||||
**What to inspect in the output:**
|
|
||||||
- `all_db_tables` array — the complete list of table names visible to FLE right now
|
|
||||||
- `fut_tables` object — any table whose name contains `fut`, `club`, `pack`, `item`, or
|
|
||||||
`market` (the script auto-extracts these)
|
|
||||||
- `is_career_mode` — confirms whether FUT or career mode was active
|
|
||||||
|
|
||||||
## Classification criteria
|
|
||||||
|
|
||||||
### "FUT tables present"
|
|
||||||
|
|
||||||
`fut_tables` is non-empty AND contains FUT-specific fields beyond base player stats:
|
|
||||||
- e.g., `fut_items` with card-type / rating / chemistry fields
|
|
||||||
- e.g., a squad table with FUT formation / chemistry / loan-flag fields
|
|
||||||
- e.g., a coins or points balance field
|
|
||||||
|
|
||||||
**Verdict:** Track C is viable for FUT. Fastest path to user-visible results.
|
|
||||||
|
|
||||||
### "only base player tables"
|
|
||||||
|
|
||||||
`fut_tables` is empty (no `fut_*` / `club_*` / `item_*` / `market_*` table names found
|
|
||||||
in `all_db_tables`), OR those tables exist but contain only base player attributes
|
|
||||||
(OVR, potential, position, pace, …) — the same fields visible in career mode.
|
|
||||||
|
|
||||||
**Verdict:** Track C cannot produce FUT. It could at most provide a custom Kick-Off or
|
|
||||||
career-mode match with players sourced from OpenFUT Core. FUT items and coins exist
|
|
||||||
only on EA's servers (not in the in-memory DB in offline mode).
|
|
||||||
|
|
||||||
### "FUT area unreachable to test"
|
|
||||||
|
|
||||||
The connection gate blocked entering FUT deeply enough for FUT tables to be populated.
|
|
||||||
Record which tables were visible and at what screen the test was run.
|
|
||||||
|
|
||||||
**Verdict:** Retest after M2 is unblocked, OR test with `TLS_ENABLED=false` bridge
|
|
||||||
handling the entry check stub.
|
|
||||||
|
|
||||||
## Results
|
|
||||||
|
|
||||||
*(To be filled in after the test is run.)*
|
|
||||||
|
|
||||||
| Field | Value |
|
|
||||||
|---|---|
|
|
||||||
| Date run | — |
|
|
||||||
| FIFA screen at test time | — |
|
|
||||||
| `is_career_mode` | — |
|
|
||||||
| Total tables in `all_db_tables` | — |
|
|
||||||
| FUT-specific table names found | — |
|
|
||||||
| Key FUT fields present | — |
|
|
||||||
| **Classification** | **PENDING** |
|
|
||||||
|
|
||||||
## Honest prior
|
|
||||||
|
|
||||||
`fut-integration-options.md` rates this as the recommended path and lists `fut_clubs`,
|
|
||||||
`fut_items`, `fut_squads` as "expected" tables. However those expectations are based on
|
|
||||||
analogy with career mode (which does store club/squad in the DB). FUT's data model is
|
|
||||||
architecturally different — it is account-bound server-side. The expectation may be
|
|
||||||
wrong. This test is the oracle.
|
|
||||||
|
|
||||||
The `export_squad.lua` script checks `GetDBTablesNames()` exhaustively (not just
|
|
||||||
assumed names), so it will surface any FUT tables that actually exist, regardless of
|
|
||||||
what name they use.
|
|
||||||
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.
|
# Copy to .env in this directory. Required for remote deployment.
|
||||||
#
|
#
|
||||||
# OPENFUT_ADVERTISE — the address of THIS host as seen from the game machine
|
# OPENFUT_ADVERTISE — the IP address of THIS host as seen from the game machine
|
||||||
# (105). The responders advertise it to the client for every next hop (Blaze,
|
# (105). Responders advertise it for Blaze, UTAS, telemetry, and QoS.
|
||||||
# roster, UTAS, POW). Compose refuses to start without it.
|
|
||||||
OPENFUT_ADVERTISE=203.0.113.10 # <- REPLACE with this host's LAN IP
|
OPENFUT_ADVERTISE=203.0.113.10 # <- REPLACE with this host's LAN IP
|
||||||
|
|
||||||
# OPENFUT_BIND — address the listeners bind inside the container.
|
# OPENFUT_BIND — address the listeners bind inside the container.
|
||||||
# Defaults to 0.0.0.0 (container-facing); the original all-on-localhost flow
|
# Defaults to 0.0.0.0 (container-facing); the original all-on-localhost flow
|
||||||
# uses the loopback default baked into the responders when unset.
|
# uses the loopback default baked into the responders when unset.
|
||||||
OPENFUT_BIND=0.0.0.0
|
OPENFUT_BIND=0.0.0.0
|
||||||
|
|
||||||
|
# FIFA17's roster verifier accepts dNSName SANs but ignores iPAddress SANs.
|
||||||
|
# Advertise the certificate's DNS identity, then resolve that one hostname to
|
||||||
|
# OPENFUT_ADVERTISE on the client without changing the URL or certificate.
|
||||||
|
OPENFUT_ROSTER_HOST=winter15.gosredirector.ea.com:8081
|
||||||
|
|
||||||
|
# OPENFUT_SERVERS — which Python responders Docker runs (space/comma separated).
|
||||||
|
# Default (unset) = the server-side set: "blaze roster utas pow".
|
||||||
|
#
|
||||||
|
# This host is the SERVER (.120). Docker runs ONLY components that have NOT been
|
||||||
|
# migrated to a Rust host. During migration the Rust hosts (redirector / roster
|
||||||
|
# / utas) run OUTSIDE Docker; as each Python component is replaced, remove its
|
||||||
|
# name here so the two never serve the same role at once.
|
||||||
|
# blaze Blaze redirector + main + nucleus (bundled) :42127 :42130 :42131
|
||||||
|
# roster FUT roster-update XML :8081
|
||||||
|
# utas FUT/UTAS RS4 API :8099
|
||||||
|
# (Rust utas-host still proxies its non-/club routes here for now)
|
||||||
|
# pow POW / EASFC :8094 (+ content :8080)
|
||||||
|
# lsx Origin LSX bootstrap :4216
|
||||||
|
# CLIENT-SIDE: LSX runs on the game machine (.105) with autopatch, NOT
|
||||||
|
# on this server. Leave it OUT unless client and server share one box.
|
||||||
|
#
|
||||||
|
# Example — Rust already owns roster, so Docker should not also serve it:
|
||||||
|
# OPENFUT_SERVERS=blaze utas pow
|
||||||
|
# When you drop a component, also stop advertising / DNAT'ing its port to this
|
||||||
|
# container so the client is routed to the Rust host instead.
|
||||||
|
#OPENFUT_SERVERS=blaze roster utas pow
|
||||||
|
|||||||
@@ -37,17 +37,21 @@ RUN set -eu; \
|
|||||||
|
|
||||||
COPY data/ /app/data/
|
COPY data/ /app/data/
|
||||||
|
|
||||||
# Redirector TLS cert (CN/SAN = winter15.gosredirector.ea.com). ProtoSSL
|
# Redirector/roster TLS certificate. FIFA17's roster verifier compares only
|
||||||
# cert-verify is patched client-side, so a self-signed cert is fine. The pair is
|
# dNSName SAN entries, so deployment advertises winter15.gosredirector.ea.com
|
||||||
# git-ignored (*.pem/*.key); regenerate if absent so a fresh checkout builds
|
# through OPENFUT_ROSTER_HOST and resolves that hostname on the client. The
|
||||||
# without extra steps.
|
# entrypoint validates this stable certificate; it never reissues it for an IP
|
||||||
|
# SAN that the verifier ignores.
|
||||||
|
#
|
||||||
|
# OpenSSL remains in the image both to create the git-ignored keypair on a fresh
|
||||||
|
# checkout and to validate the configured DNS identity at startup.
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends openssl && \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
RUN if [ ! -s tools/redir_cert.pem ] || [ ! -s tools/redir_key.pem ]; then \
|
RUN if [ ! -s tools/redir_cert.pem ] || [ ! -s tools/redir_key.pem ]; then \
|
||||||
apt-get update && apt-get install -y --no-install-recommends openssl && \
|
|
||||||
openssl req -x509 -newkey rsa:2048 -nodes \
|
openssl req -x509 -newkey rsa:2048 -nodes \
|
||||||
-keyout tools/redir_key.pem -out tools/redir_cert.pem \
|
-keyout tools/redir_key.pem -out tools/redir_cert.pem \
|
||||||
-days 3650 -subj "/CN=winter15.gosredirector.ea.com" \
|
-days 3650 -subj "/CN=winter15.gosredirector.ea.com" \
|
||||||
-addext "subjectAltName=DNS:winter15.gosredirector.ea.com,DNS:*.gosredirector.ea.com,DNS:*.ea.com" && \
|
-addext "subjectAltName=DNS:winter15.gosredirector.ea.com,DNS:*.gosredirector.ea.com,DNS:*.ea.com,IP:127.0.0.1"; \
|
||||||
rm -rf /var/lib/apt/lists/*; \
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Bake a dataset manifest so every image is self-identifying.
|
# Bake a dataset manifest so every image is self-identifying.
|
||||||
|
|||||||
@@ -3,9 +3,9 @@
|
|||||||
# cp .env.example .env # set OPENFUT_ADVERTISE to THIS host's LAN IP
|
# cp .env.example .env # set OPENFUT_ADVERTISE to THIS host's LAN IP
|
||||||
# docker compose up -d --build
|
# docker compose up -d --build
|
||||||
#
|
#
|
||||||
# Brings up the 5 responders the game dials. OPENFUT_ADVERTISE is the address
|
# Brings up the 5 responders the game dials. OPENFUT_ADVERTISE is the server IP
|
||||||
# the servers hand the client (105) for every next hop (Blaze, roster, UTAS,
|
# handed out for Blaze, UTAS, telemetry, and QoS; OPENFUT_ROSTER_HOST is the
|
||||||
# POW) and is required — there is no silent loopback fallback in remote mode.
|
# certificate DNS identity handed out for roster HTTPS.
|
||||||
#
|
#
|
||||||
# The client (105) still needs its first-hop redirect (hook or DNAT) plus
|
# The client (105) still needs its first-hop redirect (hook or DNAT) plus
|
||||||
# autopatch.py running locally; see client_arm.sh and the FIFARUNBOOK.
|
# 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
|
# 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).
|
# 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}"
|
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
|
# 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
|
# openfut-core publish on this host. Remap it to 8085 on the host and
|
||||||
# advertise the remapped endpoint.
|
# advertise the remapped endpoint.
|
||||||
@@ -36,10 +39,14 @@ services:
|
|||||||
FUT_PROFILE_ROOT: "/state/accounts"
|
FUT_PROFILE_ROOT: "/state/accounts"
|
||||||
FUT_SETTINGS: "off"
|
FUT_SETTINGS: "off"
|
||||||
FUT_MODES: "1"
|
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:
|
volumes:
|
||||||
- "../state:/state"
|
- "../state:/state"
|
||||||
ports:
|
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)
|
- "42127:42127" # Blaze redirector (TLS)
|
||||||
- "42130:42130" # Blaze main
|
- "42130:42130" # Blaze main
|
||||||
- "42131:42131" # Nucleus OAuth stub
|
- "42131:42131" # Nucleus OAuth stub
|
||||||
|
|||||||
@@ -8,25 +8,39 @@
|
|||||||
# autopatch.py is NOT run here: it patches the FIFA17.exe process memory and must
|
# autopatch.py is NOT run here: it patches the FIFA17.exe process memory and must
|
||||||
# run on the box the game runs on.
|
# run on the box the game runs on.
|
||||||
#
|
#
|
||||||
# Address behaviour is driven by two env vars (see each responder):
|
# Address behaviour is driven by three env vars (see each responder):
|
||||||
# OPENFUT_BIND bind address for every listener (container: 0.0.0.0)
|
# OPENFUT_BIND bind address for every listener (container: 0.0.0.0)
|
||||||
# OPENFUT_ADVERTISE address handed to the client for the next hop
|
# OPENFUT_ADVERTISE IP address handed out for Blaze, UTAS, telemetry, and QoS
|
||||||
# (the server's LAN IP, e.g. 203.0.113.10)
|
# OPENFUT_ROSTER_HOST certificate DNS host:port handed out for roster HTTPS
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
set -uo pipefail
|
set -uo pipefail
|
||||||
cd "$(dirname "$(readlink -f "$0")")/tools"
|
cd "$(dirname "$(readlink -f "$0")")/tools"
|
||||||
|
|
||||||
BIND="${OPENFUT_BIND:-0.0.0.0}"
|
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)}"
|
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_BIND="$BIND"
|
||||||
export OPENFUT_ADVERTISE="$ADV"
|
export OPENFUT_ADVERTISE="$ADV"
|
||||||
|
export OPENFUT_ROSTER_HOST="$ROSTER_HOST"
|
||||||
# POW keys advertised by blaze must also point at the server, not loopback.
|
# POW keys advertised by blaze must also point at the server, not loopback.
|
||||||
export POW_HOST="${POW_HOST:-$ADV:8094}"
|
export POW_HOST="${POW_HOST:-$ADV:8094}"
|
||||||
export POW_CONTENT_HOST="${POW_CONTENT_HOST:-$ADV:8080}"
|
export POW_CONTENT_HOST="${POW_CONTENT_HOST:-$ADV:8080}"
|
||||||
export POW_ADDR="${POW_ADDR:-$BIND:8094}"
|
export POW_ADDR="${POW_ADDR:-$BIND:8094}"
|
||||||
export POW_CONTENT_ADDR="${POW_CONTENT_ADDR:-$BIND:8080}"
|
export POW_CONTENT_ADDR="${POW_CONTENT_ADDR:-$BIND:8080}"
|
||||||
|
|
||||||
echo "[openfut] bind=$BIND advertise=$ADV"
|
echo "[openfut] bind=$BIND advertise=$ADV roster=$ROSTER_HOST"
|
||||||
|
|
||||||
|
# FIFA17's roster verifier compares only dNSName SAN entries. It ignores a valid
|
||||||
|
# iPAddress SAN when the advertised URL contains an IP literal, so certificate
|
||||||
|
# regeneration cannot fix that URL. Keep the certificate stable and fail startup
|
||||||
|
# if the configured roster hostname is not already one of its DNS identities.
|
||||||
|
CERT=redir_cert.pem
|
||||||
|
ROSTER_NAME="${ROSTER_HOST%%:*}"
|
||||||
|
if ! openssl x509 -in "$CERT" -noout -checkhost "$ROSTER_NAME" >/dev/null 2>&1; then
|
||||||
|
echo "[openfut] FATAL: TLS cert does not cover roster hostname $ROSTER_NAME" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "[openfut] roster certificate matches $ROSTER_NAME; fingerprint: $(openssl x509 -in "$CERT" -noout -fingerprint -sha256)"
|
||||||
|
|
||||||
# name script extra-env
|
# name script extra-env
|
||||||
declare -a SERVERS=(
|
declare -a SERVERS=(
|
||||||
@@ -37,10 +51,40 @@ declare -a SERVERS=(
|
|||||||
"pow|pow_server.py|-"
|
"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=()
|
pids=()
|
||||||
names=()
|
names=()
|
||||||
for entry in "${SERVERS[@]}"; do
|
for entry in "${SERVERS[@]}"; do
|
||||||
IFS='|' read -r name script env <<<"$entry"
|
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"
|
envprefix=""; [ "$env" != "-" ] && envprefix="env $env"
|
||||||
echo "[openfut] starting $name ($script)"
|
echo "[openfut] starting $name ($script)"
|
||||||
# shellcheck disable=SC2086
|
# shellcheck disable=SC2086
|
||||||
@@ -49,6 +93,11 @@ for entry in "${SERVERS[@]}"; do
|
|||||||
names+=("$name")
|
names+=("$name")
|
||||||
done
|
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.
|
# Propagate SIGTERM/SIGINT to children so `docker stop` is clean.
|
||||||
term() {
|
term() {
|
||||||
echo "[openfut] shutting down…"
|
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
|
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.
|
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
|
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,
|
round shipped four arms for. That path is a /club PREFIX, so a naive router
|
||||||
which the client has still never used. Worse, that path is a /club PREFIX, so it
|
falls it through to the generic route and answers the consumables screen with
|
||||||
fell through to the generic route and the consumables screen was answered with the
|
the 194-card player list.
|
||||||
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
|
3. THE ELEMENT IS A STACK WRAPPER, NOT AN ITEM. FutConsumablesSearchServerResponse
|
||||||
(RS4 literal 0x1802222f8, factory 0x180130a10, vtable 0x180222200, deser +0x08 =
|
(RS4 literal 0x1802222f8, factory 0x180130a10, vtable 0x180222200, deser +0x08 =
|
||||||
0x180130d10, 6873 chars) reads itemData(0x16b) at the root like the club list, but
|
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
|
# Club items: what the research established, 2026-08-05
|
||||||
|
|
||||||
Researched after a guessed field crashed the client. Facts first, and the one thing
|
> **SUPERSEDED 2026-08-21 in part.** `docs/plan-2026-08-06-card-subsystem.md` is
|
||||||
still unknown is named as unknown.
|
> 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
|
## VERIFIED IN BINARY
|
||||||
|
|
||||||
1. THE CARDTYPE MAP IS EXACT. FUN_1800d8330 (714 chars, read in full) returns cardtype
|
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.
|
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
|
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
|
misc cards.
|
||||||
and league logos.
|
|
||||||
2. ITEMSTATE CARRIES THE EQUIPPED STATE. The enum table at 0x180229d20 (stride 0x10)
|
**CORRECTED 2026-08-21.** The first half is right; the inference that followed
|
||||||
is: WAITING_FOR_GAME, inGame, forSale, offered, activeBadge, activeHomeKit,
|
it was wrong. It read "that leaves 0x1e, 0x1f and 0x91..0x96 for badges, kits,
|
||||||
activeAwayKit, activeBall, activeStadium, active. So an EQUIPPED club item is not a
|
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.
|
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
|
3. CLUB ITEMS HAVE NO CATEGORY GROUP TABLE. Consumables have one at 0x180203260 (seven
|
||||||
codes: training, contracts, fitness, healing, playStyle, managerLeagueModifier,
|
codes: training, contracts, fitness, healing, playStyle, managerLeagueModifier,
|
||||||
position) and staff have one at 0x180203310 (five codes). There is no equivalent
|
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
|
type=ball, type=equippables (the combined customisation view). Not the plural stat
|
||||||
names, and not a club/<family> path.
|
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.
|
The question was "which of 0x1e, 0x1f, 0x91..0x96 means ball versus stadium versus
|
||||||
It is in none of the 149 dumped tables, there is no group table, and cardtype 9 has NO
|
badge versus kit". It was the wrong candidate set — three of the families are not
|
||||||
arm in the merge, so a wrong subtype cannot announce itself the way a coach's "DB
|
in it at all. The settled map:
|
||||||
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
|
| family | cardsubtypeid | cardtype | how the caption resolves |
|
||||||
in the equip path that writes itemState = activeBadge and friends;
|
|---|---|---|---|
|
||||||
b. FUT_CLUBITEMS=probe:<family>, which serves ONE family as eight items, one per
|
| kit | **9** | 7 | `TeamName_Abbr15_<teamid>` |
|
||||||
candidate subtype, so the screen names the right one.
|
| 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
|
## 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
|
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
|
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.
|
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
|
generically as `{eligibilityKey, eligibilityOperation, eligibilityValue}` triples, **not** as
|
||||||
named scalar fields on the record. **FREEZE-RISK: elgReq must be a JSON array of objects.**
|
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`,
|
**awards / grantedAwards** — nested array of reward objects (atoms: `rewardType 0x28e`,
|
||||||
`rewardValue 0x28f`, `rewardQuantity 0x28d`, `rewardMultiplier 0x28c`, `awardCount 0x40`,
|
`rewardValue 0x28f`, `rewardQuantity 0x28d`, `rewardMultiplier 0x28c`, `awardCount 0x40`,
|
||||||
`awardSet 0x45`, `awardSetId 0x46`, `prizeSet 0x253`). **FREEZE-RISK: must be array.**
|
`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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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/…` (CardsGetStaffBonuses) | `bonus`(0x5c) → **nested** (branch sets bool @rbp+0x51) [FREEZE-RISK]; `assetId`(0x23) → int | GAP | 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 |
|
| 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 |
|
| 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:
|
Notes:
|
||||||
- **`0x1801642c0`** is a shared no-op deserializer (function body = `ret`). Three responses
|
- **`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
|
items the warning was aimed at, the server's rating and rare flag are
|
||||||
authoritative.
|
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`
|
### 3.7 `duplicateItemIdList`
|
||||||
|
|
||||||
CONFIRMED shape, INFERRED effect, never observed. Element deser `FUN_180138e10`,
|
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` |
|
| kit | **9** | 7 | `FUN_180119bd0` → `FUT_UC_KITS` + `TeamName_Abbr15_<teamid>` | `teamid` |
|
||||||
| stadium | **10** | 7 | `FUN_180119bd0` → `Stadium` + `StadiumName_<assetId>` | `assetId` |
|
| stadium | **10** | 7 | `FUN_180119bd0` → `Stadium` + `StadiumName_<assetId>` | `assetId` |
|
||||||
| badge | **11** | 7 | `FUN_180119bd0` → `Badge` + `TeamName_Abbr15_<teamid>` | `teamid` |
|
| badge | **11** | 7 | `FUN_180119bd0` → `Badge` + `TeamName_Abbr15_<teamid>` | `teamid` |
|
||||||
| ball | **30** (0x1e) | 9 | none; `FUT_UC_BALL` caption only | `localizedName` |
|
| ball | **30** (0x1e) | 9 | NONE — see the 2026-08-21 measurement below | unnameable |
|
||||||
| league logo | **31** (0x1f) | 9 | `FUN_180098f20` keyed on leagueid | `localizedName`, probably |
|
| 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
|
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
|
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
|
`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.
|
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
|
## 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
|
that gate -- `svc->0x308()` on service `0xed80ed8` -- that nobody resolved, so if
|
||||||
squad submission behaves oddly afterwards, that is where to look.
|
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
|
### Equipping club items
|
||||||
|
|
||||||
`itemState` really is the equip mechanism for the `IS_ACTIVE` tick:
|
`itemState` really is the equip mechanism for the `IS_ACTIVE` tick:
|
||||||
@@ -528,22 +642,60 @@ will not change the kit.
|
|||||||
|
|
||||||
### Needs decompiling only
|
### Needs decompiling only
|
||||||
|
|
||||||
**Who writes item `+0x60`.** It gates the kit swap at value 4 and we can produce 1
|
**Who writes item `+0x60`. ANSWERED 2026-08-21 — NOTHING DOES.** It gates the kit
|
||||||
and 6. Both attempts to scan for it drowned: `+0x60` returns 1688 and 4144
|
swap at value 4 and we can produce 1 and 6. Both earlier scans drowned (`+0x60`
|
||||||
instructions depending on method. The narrower anchor is the `/club` and
|
returns 1688 and 4144 instructions) because it is a common struct offset. Two
|
||||||
`/purchased` response handlers -- find the list-insert that assigns it, read the
|
filters cut it to a readable set: only an IMMEDIATE store can introduce a
|
||||||
constants. This is the single blocker between "we can mark a kit equipped" and "we
|
constant, and item-record code is recognisable by touching `+0x4c`/`+0x5c`
|
||||||
can equip a kit".
|
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
|
**The kit variant selector.** `FUN_1801bfac0` distinguishes home, away and third
|
||||||
kits from `FUN_1801a8800` (`+0xba`, u16) and `FUN_1801a8040` (`+0xbf`, signed
|
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
|
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.
|
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
|
**`FUN_1801aa190`. CLOSED 2026-08-21.** The one unopened link inside the
|
||||||
claimed to resolve `statsList[4]` and `[5]` at `+0x104 + idx*4`. It changes no
|
eight-flag chain. It is eleven instructions, and it resolves TWO parallel arrays
|
||||||
action today because we send no `statsList`, but it is two minutes of work and it
|
rather than the one the earlier claim described:
|
||||||
would close the chain.
|
|
||||||
|
```
|
||||||
|
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.
|
**The `BOUGHT_FOR` consumer.** `+0x34` = atom `0x185 lastSalePrice` is resolved.
|
||||||
What remains is whether the field is visible anywhere worth populating.
|
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
|
dispatch table that reaches it was not identified, and trophies are a whole
|
||||||
unimplemented family.
|
unimplemented family.
|
||||||
|
|
||||||
**Case sensitivity of the `itemState` string match.** Almost certainly
|
**Case sensitivity of the `itemState` string match. RESOLVED 2026-08-21 —
|
||||||
unresolvable statically: `FUN_180008190` is a single indirect call through
|
CASE-SENSITIVE.** It was expected to be unresolvable statically, because
|
||||||
`DAT_1802ddfd8 + 0x248`, a runtime-populated service pointer. Send the exact
|
`FUN_180008190` is nothing but a forwarding stub through a runtime-populated
|
||||||
casing from the table and do not experiment on the live save.
|
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)
|
### 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
|
**Re-read `+0x30` after a refetch** to decide between "monotonic clock" and
|
||||||
"sequence counter". Low value; nothing we send reaches it.
|
"sequence counter". Low value; nothing we send reaches it.
|
||||||
|
|
||||||
**Confirm the FUT roster database is loaded.** The `fcc_discardcoins` result
|
**Confirm the FUT roster database is loaded. PARTLY ANSWERED 2026-08-21 — the
|
||||||
proves `g_db` is loaded and complete; it says nothing about the separate database
|
two databases are now definitively distinct; the load FLAG is still unlocated.**
|
||||||
behind `LoadFUTDatabase` / `.dbFUTVer` / `DL_FUT_LIVEDB`, whose strings live in
|
The `fcc_discardcoins` result proves `g_db` is loaded and complete; it says
|
||||||
FIFA17.exe and not in CardsDLL. These are different databases and they should stop
|
nothing about the separate database behind `LoadFUTDatabase` / `.dbFUTVer` /
|
||||||
being conflated.
|
`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
|
### 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
|
skips strictly reduces executed code. Low value, zero cost, and it removes a field
|
||||||
that three documents describe as if it did something.
|
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
|
## 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`
|
### `docs/CARD_SYSTEM.md`
|
||||||
|
|
||||||
**Replace the "STILL UNKNOWN, AND NOT GUESSED" section entirely.** It is answered.
|
**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
|
# ================================================================== config
|
||||||
#
|
#
|
||||||
# Client/server split support (OpenFUT dev-container): two env vars, both
|
# Client/server split support (OpenFUT dev-container): bind and advertise default
|
||||||
# defaulting to loopback so the original all-on-localhost flow is byte-identical.
|
# 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_BIND — address the listeners bind (0.0.0.0 in a container).
|
||||||
# OPENFUT_ADVERTISE — the address this server hands back to the client for the
|
# OPENFUT_ADVERTISE — address handed back for Blaze, UTAS, telemetry, QoS,
|
||||||
# NEXT hop (Blaze host, roster/UTAS/telemetry/QoS URLs). On
|
# and (unless overridden) the roster service.
|
||||||
# 105-local this is 127.0.0.1; on the 120 server it is the
|
# OPENFUT_ROSTER_HOST — optional roster host:port advertised in HTTPS URLs.
|
||||||
# server's LAN IP so the game dials 120 directly after the
|
# Use a certificate dNSName and resolve it on the client.
|
||||||
# first (hook/DNAT-redirected) contact.
|
|
||||||
import os as _os_cfg
|
import os as _os_cfg
|
||||||
_ADVERTISE = _os_cfg.environ.get("OPENFUT_ADVERTISE", "127.0.0.1")
|
_ADVERTISE = _os_cfg.environ.get("OPENFUT_ADVERTISE", "127.0.0.1")
|
||||||
_BIND = _os_cfg.environ.get("OPENFUT_BIND", "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
|
# 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
|
# 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.
|
# 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
|
# Serve HTTPS (EA's production value is https; the DirtySDK download manager may
|
||||||
# http). Our ProtoSSL cert-verify is patched (autopatch), so a self-signed cert is OK.
|
# reject http). FIFA17's roster verifier accepts dNSName SANs but ignores
|
||||||
ROSTER_HOST = "%s:8081" % _ADVERTISE
|
# 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")
|
POW_CONTENT_HOST = os.environ.get("POW_CONTENT_HOST", "127.0.0.1:8080")
|
||||||
OSDK_ROSTER = [
|
OSDK_ROSTER = [
|
||||||
("ROSTERUPDATE_URL", "https://%s/fifa17/fut/rosterupdate.xml" % ROSTER_HOST),
|
("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
|
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() {
|
verify_inputs() {
|
||||||
command -v sha256sum >/dev/null || die "sha256sum is required"
|
command -v sha256sum >/dev/null || die "sha256sum is required"
|
||||||
command -v x86_64-w64-mingw32-objdump >/dev/null ||
|
command -v x86_64-w64-mingw32-objdump >/dev/null ||
|
||||||
@@ -62,6 +90,7 @@ verify_inputs() {
|
|||||||
need_file "$hook_dll"
|
need_file "$hook_dll"
|
||||||
need_file "$system_version"
|
need_file "$system_version"
|
||||||
verify_pe64 "$hook_dll"
|
verify_pe64 "$hook_dll"
|
||||||
|
verify_fifa17_profile "$hook_dll"
|
||||||
}
|
}
|
||||||
|
|
||||||
inspect() {
|
inspect() {
|
||||||
@@ -129,6 +158,7 @@ deploy() {
|
|||||||
need_file "$manifest"
|
need_file "$manifest"
|
||||||
verify_pe64 "$staged"
|
verify_pe64 "$staged"
|
||||||
verify_exports "$staged"
|
verify_exports "$staged"
|
||||||
|
verify_fifa17_profile "$staged"
|
||||||
local recorded actual
|
local recorded actual
|
||||||
recorded="$(awk -F= '$1=="artifact_sha256"{print $2}' "$manifest")"
|
recorded="$(awk -F= '$1=="artifact_sha256"{print $2}' "$manifest")"
|
||||||
actual="$(sha256 "$staged")"
|
actual="$(sha256 "$staged")"
|
||||||
@@ -158,7 +188,7 @@ launch() {
|
|||||||
local trace_enabled=0
|
local trace_enabled=0
|
||||||
local request_trace_enabled=0
|
local request_trace_enabled=0
|
||||||
local notifier_trace_enabled=0
|
local notifier_trace_enabled=0
|
||||||
local commit_enabled=0
|
local dispatch_enabled=0
|
||||||
case "$mode" in
|
case "$mode" in
|
||||||
baseline)
|
baseline)
|
||||||
[[ "${OPENFUT_FIFA17_LAUNCH:-}" == "I_ACCEPT_M1_BASELINE_LAUNCH" ]] ||
|
[[ "${OPENFUT_FIFA17_LAUNCH:-}" == "I_ACCEPT_M1_BASELINE_LAUNCH" ]] ||
|
||||||
@@ -177,14 +207,11 @@ launch() {
|
|||||||
request_trace_enabled=1
|
request_trace_enabled=1
|
||||||
notifier_trace_enabled=1
|
notifier_trace_enabled=1
|
||||||
;;
|
;;
|
||||||
commit)
|
dispatch)
|
||||||
[[ "${OPENFUT_FIFA17_COMMIT:-}" == "I_ACCEPT_POST_PARSE_READY_BYTE" ]] ||
|
[[ "${OPENFUT_FIFA17_DISPATCH:-}" == "I_ACCEPT_GUARDED_NATIVE_DISPATCH" ]] ||
|
||||||
die "launch-commit requires OPENFUT_FIFA17_COMMIT=I_ACCEPT_POST_PARSE_READY_BYTE"
|
die "launch-dispatch requires OPENFUT_FIFA17_DISPATCH=I_ACCEPT_GUARDED_NATIVE_DISPATCH"
|
||||||
hook_enabled=1
|
|
||||||
trace_enabled=1
|
|
||||||
request_trace_enabled=1
|
request_trace_enabled=1
|
||||||
notifier_trace_enabled=1
|
dispatch_enabled=1
|
||||||
commit_enabled=1
|
|
||||||
;;
|
;;
|
||||||
*) die "unknown launch mode: $mode" ;;
|
*) die "unknown launch mode: $mode" ;;
|
||||||
esac
|
esac
|
||||||
@@ -207,7 +234,7 @@ launch() {
|
|||||||
done
|
done
|
||||||
mkdir -p "${wine_prefix}/dosdevices"
|
mkdir -p "${wine_prefix}/dosdevices"
|
||||||
ln -sfn /mnt "${wine_prefix}/dosdevices/w:"
|
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"
|
cd "$game_dir"
|
||||||
env \
|
env \
|
||||||
GAMEID=fifa17 \
|
GAMEID=fifa17 \
|
||||||
@@ -218,8 +245,8 @@ launch() {
|
|||||||
OPENFUT_SBC_TRACE="$trace_enabled" \
|
OPENFUT_SBC_TRACE="$trace_enabled" \
|
||||||
OPENFUT_SBC_REQUEST_TRACE="$request_trace_enabled" \
|
OPENFUT_SBC_REQUEST_TRACE="$request_trace_enabled" \
|
||||||
OPENFUT_SBC_NOTIFIER_TRACE="$notifier_trace_enabled" \
|
OPENFUT_SBC_NOTIFIER_TRACE="$notifier_trace_enabled" \
|
||||||
OPENFUT_SBC_DISPATCH=0 \
|
OPENFUT_SBC_DISPATCH="$dispatch_enabled" \
|
||||||
OPENFUT_SBC_COMMIT="$commit_enabled" \
|
OPENFUT_SBC_DISPATCH_TRACE=0 \
|
||||||
OPENFUT_SBC_ARM_ONLY=0 \
|
OPENFUT_SBC_ARM_ONLY=0 \
|
||||||
OPENFUT_SBC_POPULATE=0 \
|
OPENFUT_SBC_POPULATE=0 \
|
||||||
umu-run _fifa17.exe 2>&1 | tee /tmp/fifa17-hook-m1-launch.log
|
umu-run _fifa17.exe 2>&1 | tee /tmp/fifa17-hook-m1-launch.log
|
||||||
@@ -227,7 +254,7 @@ launch() {
|
|||||||
|
|
||||||
usage() {
|
usage() {
|
||||||
cat <<'EOF'
|
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).
|
inspect Read-only PE/hash/export preflight (default).
|
||||||
build Cross-build the inert FIFA17 hook, then run inspect.
|
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:
|
Start M2 resolve-only mode (guarded reads/logging, no detours/writes); requires:
|
||||||
OPENFUT_FIFA17_RESOLVE=I_ACCEPT_M2_RESOLVE_LAUNCH
|
OPENFUT_FIFA17_RESOLVE=I_ACCEPT_M2_RESOLVE_LAUNCH
|
||||||
launch-trace
|
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
|
OPENFUT_FIFA17_TRACE=I_ACCEPT_M3_PASSIVE_TRACE
|
||||||
launch-commit
|
launch-dispatch
|
||||||
Trace and arm the SBC cache only after a validated native parse; requires:
|
Trace and repair only a fully validated native status-999 completion; requires:
|
||||||
OPENFUT_FIFA17_COMMIT=I_ACCEPT_POST_PARSE_READY_BYTE
|
OPENFUT_FIFA17_DISPATCH=I_ACCEPT_GUARDED_NATIVE_DISPATCH
|
||||||
|
|
||||||
Optional path overrides:
|
Optional path overrides:
|
||||||
OPENFUT_FIFA17_HOOK_DLL, OPENFUT_FIFA17_GAME_DIR,
|
OPENFUT_FIFA17_HOOK_DLL, OPENFUT_FIFA17_GAME_DIR,
|
||||||
@@ -260,7 +287,7 @@ case "${1:-inspect}" in
|
|||||||
launch) launch baseline ;;
|
launch) launch baseline ;;
|
||||||
launch-resolve) launch resolve ;;
|
launch-resolve) launch resolve ;;
|
||||||
launch-trace) launch trace ;;
|
launch-trace) launch trace ;;
|
||||||
launch-commit) launch commit ;;
|
launch-dispatch) launch dispatch ;;
|
||||||
-h|--help|help) usage ;;
|
-h|--help|help) usage ;;
|
||||||
*) usage >&2; die "unknown command: $1" ;;
|
*) usage >&2; die "unknown command: $1" ;;
|
||||||
esac
|
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
|
# 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
|
# 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
|
# 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
|
# client runs its own fcc_discardcoins lookup and the price register stays 0.
|
||||||
# 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:
|
# CORRECTED 2026-08-06: the two claims that used to sit here -- "that lookup
|
||||||
# sending a non-zero value bypasses the lookup entirely and the client uses ours.
|
# 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
|
# 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
|
# 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())
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user