Compare commits
407 Commits
9348b83374
...
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 | |||
| b1d7ed2570 | |||
| dcbef721f2 | |||
| 772f8a615a | |||
| bf9ae20367 | |||
| 58d1f9426f | |||
| 3cd31c4322 | |||
| ae5feb05b7 | |||
| f2c4927ea6 | |||
| aa2abc2772 | |||
| 1aa84afa9a | |||
| 33e9118329 | |||
| e06fd57211 | |||
| 42fd3c7e90 | |||
| 0bc71dbd74 | |||
| 2ecd830d75 | |||
| 3a51b0ebd4 | |||
| ad406f21bd | |||
| 12fb9fc38b | |||
| 7b580a0070 | |||
| 22443a3810 | |||
| 0fce1e521c | |||
| 71fcf5e251 | |||
| 979e71fbea | |||
| 45e0b0bd95 | |||
| 70eb3fc13f | |||
| b30aa352f6 | |||
| 67cc33cfee | |||
| 6eec3b9ec7 | |||
| 57773b98ec | |||
| fe9b899a0e | |||
| abe9e663c1 | |||
| f5a33eb58c | |||
| a85090c3c6 | |||
| 97d48d8371 | |||
| 5020137050 | |||
| cf05ab2a9e | |||
| a6416a3f1d | |||
| 47ced228de | |||
| b8beeba98d | |||
| df6994c957 | |||
| d74e86c065 | |||
| 76512f6048 | |||
| 93a46d4de7 | |||
| 3ba24a0faf | |||
| 6926bb9528 | |||
| b1643309f6 | |||
| 43917a0051 | |||
| 1fac71e3ef | |||
| 747cc234c1 | |||
| fe72f0def2 | |||
| 884ecbba64 | |||
| 580d80a86e | |||
| 0e2ca5a7c3 | |||
| 4d2b8b9be3 | |||
| 0b31abe1d1 | |||
| 6b652cb0a2 | |||
| 3507c5714d | |||
| 4f78b9a875 | |||
| f3aebafcc7 | |||
| 1df0bc4f00 | |||
| 7d5d0cff06 | |||
| 8d752cb0e4 | |||
| 96e80ab293 | |||
| 49c5185ae3 | |||
| 181bd94341 | |||
| 09675a9f7f | |||
| 56c364e4c9 | |||
| 3021a4e761 | |||
| d240a61157 | |||
| d7c5307045 | |||
| 838d76f95e | |||
| 9791eee67b | |||
| 5d119f5555 | |||
| 46e5f612c8 | |||
| 41494bd18f | |||
| 40ebf7c1e7 | |||
| c7609252d2 | |||
| 4cf388dd3b | |||
| 00d85aa6e4 | |||
| d9e80a774a | |||
| a82407c686 | |||
| 805d754dc8 | |||
| d4c3811665 | |||
| b25761ea31 | |||
| 1c396dd562 | |||
| fc29c2eb9b | |||
| b0d5e04bb9 | |||
| 6746c75302 | |||
| b2697b13dc | |||
| e8ee6c34e7 | |||
| f42279f869 | |||
| 54ad9e8f79 | |||
| 6f16a231fc | |||
| 626c972232 | |||
| 44fcf24d92 | |||
| e187cd49a2 | |||
| 1631d3b1a2 | |||
| c71c2a8d33 | |||
| a51947562c | |||
| 63f02c4fb1 | |||
| 4fd5ee2608 | |||
| c7d4b9f753 | |||
| 37c2e5d7ee | |||
| 7dbd878398 | |||
| c2e2e0d8f2 | |||
| afc909fd3b | |||
| b607ff28cb | |||
| cf86d4e425 | |||
| 85761390a8 | |||
| 4d30d8b3e8 | |||
| 0e30980632 | |||
| 46a81e7a07 | |||
| e09344490f | |||
| 80a8bc4520 | |||
| b50e0359f7 | |||
| 58a300c7f4 | |||
| eb8311a5ee | |||
| 550a59d12c | |||
| 8c1d1ed958 | |||
| fc00b0c6f9 | |||
| 5276dd2066 | |||
| 88da16a11e | |||
| 3ef3bc32ec | |||
| 36fe1caa3f | |||
| f55c401b6c | |||
| b8037b9b22 | |||
| c0a3f68ded | |||
| 04c5043aba | |||
| 0b66662525 | |||
| cdea85e214 | |||
| 05f6147433 | |||
| 8f3b659c33 | |||
| c9ae914910 | |||
| 84e81f2037 | |||
| 696386a9c1 | |||
| aa2679162d | |||
| 096d1c882f | |||
| ca63095786 | |||
| c65e9c54ce | |||
| b1bc7a764e | |||
| d7c0a5521d | |||
| 2ae90b1ea9 | |||
| cfb0435d96 | |||
| fc411bb6f1 | |||
| 5bc39e902d | |||
| e2c4ca6d56 | |||
| 288d990821 | |||
| c03702707b | |||
| 89f77470f3 | |||
| 0d576a14b7 | |||
| c5807c07a9 | |||
| 8f5f54833f | |||
| f451406058 | |||
| 8aab2c0d41 | |||
| ed0ccb8c2b | |||
| b40adac3fc | |||
| bfb7876ed4 | |||
| 55b4e54d5f | |||
| fafa2f1858 | |||
| c84fd14cac | |||
| 23374312bc | |||
| a84a72e0c0 | |||
| 6c102f00c0 | |||
| 48aa955212 | |||
| cf3ddde3a6 | |||
| 468b006008 | |||
| e091921b18 | |||
| a9eb54ae9c | |||
| cf961603fe | |||
| cc3ecddc06 | |||
| a9a816e0ed | |||
| 3153a93edf | |||
| f64106ed8b | |||
| 9faaf12dd7 | |||
| 83539e33ec | |||
| 695421cfd4 | |||
| 8cba70dc90 | |||
| 28773e7cf1 | |||
| 3ae5587a38 | |||
| 70a64e3709 | |||
| 622a774f6a | |||
| cc694774a3 | |||
| 3d3239bab9 | |||
| a7e3e43ae9 | |||
| 31fc590b99 | |||
| 245c22161b | |||
| 43557989f5 | |||
| a3fd51692f | |||
| e578443d73 | |||
| e3092ca0f9 | |||
| 21a81ad63c | |||
| 3f3d5704a7 | |||
| 89da7b7609 | |||
| 1605e6effd | |||
| afdbb364ca | |||
| d0dbfa99c0 | |||
| 897259c8fb |
@@ -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
|
||||||
@@ -27,3 +27,12 @@ __pycache__/
|
|||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
|
# Frozen baseline archives / inspects / manifests
|
||||||
|
/docker-backups/
|
||||||
|
gate-evidence/
|
||||||
|
|
||||||
|
# Raw Fire2 frame captures — forensic evidence, may contain session material.
|
||||||
|
# Sanitize with `blaze-sanitize` before anything leaves this machine.
|
||||||
|
*.ofcap
|
||||||
|
captures/
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
# AGENTS.md — OpenFUT
|
||||||
|
|
||||||
|
**Read this first.** It is the entry point for AI-assisted work on OpenFUT. It supersedes the
|
||||||
|
root `README.md` and `CLAUDE.md`, which are **stale** (they describe an earlier FIFA 23 plan).
|
||||||
|
|
||||||
|
## Project
|
||||||
|
|
||||||
|
OpenFUT is a preservation / private-server project that restores **offline, single-player FIFA
|
||||||
|
Ultimate Team (FUT)** after EA retired the online servers. You must own the game legitimately; the
|
||||||
|
project does not bypass ownership checks — it only re-serves the dead online services locally.
|
||||||
|
|
||||||
|
**Current active target: FIFA 17 (PC).** A clean-room emulation of the full online + FUT stack
|
||||||
|
was proven working end-to-end on **2026-08-01** (auth → Blaze login → device-trust → FUT hub).
|
||||||
|
This lives in `fifa17-recon/`. The FIFA 17 work is explicitly the **Rosetta Stone for FIFA 23**
|
||||||
|
(identical Blaze/LSX/UTAS wire format), so FIFA 23 remains the eventual second target.
|
||||||
|
|
||||||
|
Three moving parts, kept strictly separate:
|
||||||
|
- **The FIFA client** — the retail game (FIFA 17 now). Unmodified except live cert-verify patches.
|
||||||
|
- **The emulation layer** — Python responders in `fifa17-recon/tools/` (LSX, Blaze, UTAS, roster)
|
||||||
|
that impersonate EA's online services on localhost. This is where all reverse engineering lives.
|
||||||
|
- **OpenFUT Core** — a game-independent REST FUT economy backend (`openfut-core/`), feature-complete
|
||||||
|
and tested. Knows nothing about FIFA. Intended to eventually back the emulation layer's FUT data.
|
||||||
|
|
||||||
|
> The emulation layer and Core are **not yet wired together.** The FIFA 17 UTAS server currently
|
||||||
|
> serves its own hardcoded/JSON payloads, not Core's API. See `docs/PROJECT_STATE.md`.
|
||||||
|
|
||||||
|
## Repository map
|
||||||
|
|
||||||
|
Monorepo. `openfut-core`, `openfut-bridge`, `openfut-launcher`, `fifa-blaze` are **git submodules**
|
||||||
|
(each with independent history — use `tea`/Gitea, not `gh`). `fifa17-recon/` is a plain directory.
|
||||||
|
|
||||||
|
| Path | What it is | Status |
|
||||||
|
|---|---|---|
|
||||||
|
| `fifa17-recon/` | **The live path.** FIFA 17 offline FUT emulation: Python responders, cert patcher, runbook, RE write-ups. | Working |
|
||||||
|
| `openfut-core/` | Rust (Axum + SQLite) FUT economy backend. Game-independent REST API. | Working, tested |
|
||||||
|
| `openfut-bridge/` | Rust FIFA 23 in-process hook / proxy RE effort. | Blocked (see below) |
|
||||||
|
| `fifa-blaze/` | Rust Blaze protocol emulator scaffold for FIFA 23 (capture stub). | Milestone 1 stub |
|
||||||
|
| `openfut-launcher/` | Rust egui/eframe desktop launcher (targets FIFA 23 hook flow). | Legacy plan |
|
||||||
|
| `docs/` | **Mirrors** of the vault (`OpenFUT-Vault`), which is canonical. Direction pivots + context. | — |
|
||||||
|
| `tools/` | Host-side RE helpers (file-watch-diff, exporters, squad-injector) from the FLE-bridge idea. | Legacy plan |
|
||||||
|
| `setup.sh` | FIFA 23 full-stack orchestrator (core+bridge). | Legacy plan |
|
||||||
|
|
||||||
|
**Legacy vs live:** the project pivoted twice — (1) FIFA 23 Blaze backend → (2) FIFA 23 as a match
|
||||||
|
renderer driven by an FLE Lua bridge (`docs/direction.md`) → (3) **FIFA 17 full online emulation,
|
||||||
|
which succeeded and is now the primary path** (`fifa17-recon/`). Treat `openfut-bridge`,
|
||||||
|
`openfut-launcher`, `fifa-blaze`, `tools/`, `setup.sh`, and `docs/direction.md` as historical unless
|
||||||
|
a task explicitly targets the FIFA 23 port.
|
||||||
|
|
||||||
|
## Architecture (live path)
|
||||||
|
|
||||||
|
```
|
||||||
|
FIFA 17 client (Wine/Proton, base 0x140000000)
|
||||||
|
│ autopatch.py NOPs two ProtoSSL cert-verify gates in /proc/PID/mem
|
||||||
|
├─ LSX 127.0.0.1:4216 → lsx_responder_v2.py (Origin login/profile/authcode)
|
||||||
|
├─ TLS 127.0.0.1:42127 → blaze_responder_v3b.py (Blaze redirector, via DNAT of 159.153.51.20)
|
||||||
|
├─ Blaze 42130 / Nucleus 42131 → blaze_responder_v3b.py (Fire2/Heat2 binary + login)
|
||||||
|
├─ easw.easports.com (→127.0.0.1) :8099 → utas_server.py (UTAS/RS4 FUT API + device-trust)
|
||||||
|
└─ roster :8081 → roster_server.py (FUT roster-update XML)
|
||||||
|
|
||||||
|
OpenFUT Core (openfut-core, :8080) ── clean REST FUT economy ── NOT YET CONNECTED to the above
|
||||||
|
```
|
||||||
|
|
||||||
|
Host arming (`root_arm.sh` via `pkexec`, volatile across reboot): `ptrace_scope=0`,
|
||||||
|
`route_localnet=1`, iptables DNAT `159.153.51.20→127.0.0.1:42127`, `/etc/hosts easw.easports.com`.
|
||||||
|
|
||||||
|
## Development commands (verified)
|
||||||
|
|
||||||
|
**FIFA 17 emulation** (from `fifa17-recon/tools/`):
|
||||||
|
- Start everything (idempotent; re-run after reboot): `./openfut-fut.sh start`
|
||||||
|
- Status / stop / restart: `./openfut-fut.sh status | stop | restart`
|
||||||
|
- Then launch the game fresh (`~/Desktop/launch-fifa17.sh`) and pick Ultimate Team.
|
||||||
|
- Logs: `/tmp/{lsx,blaze,roster,utas,autopatch}.log`
|
||||||
|
- Full procedure + gate-ladder troubleshooting: `fifa17-recon/FUT-RUNBOOK.md`
|
||||||
|
|
||||||
|
**OpenFUT Core** (from `openfut-core/`): `cargo run` (creates `openfut.db`) · `cargo test`
|
||||||
|
(full in-memory integration suite; requires `data/`) · `cargo test <name>` for one ·
|
||||||
|
`cargo clippy -- -D warnings` · `cargo fmt`. Env: `LISTEN_ADDR` (127.0.0.1:8080), `DATABASE_URL`
|
||||||
|
(sqlite://openfut.db), `DATA_DIR` (data).
|
||||||
|
|
||||||
|
**Other Rust crates** (`openfut-bridge`, `fifa-blaze`, `openfut-launcher`): standard
|
||||||
|
`cargo run/build/test/clippy/fmt` from within each. `fifa-blaze` is a workspace (`--bin blaze-server`).
|
||||||
|
|
||||||
|
**CI:** only `openfut-core` has it (`.gitea/workflows/ci.yml`): `fmt --check`, `clippy -D warnings`,
|
||||||
|
`build --locked`, `test --locked` on push/PR to main. No CI on the other crates or the recon dir.
|
||||||
|
|
||||||
|
There is **no install step, no Docker, no JS/TS frontend, no typecheck** in this repo. Do not invent them.
|
||||||
|
|
||||||
|
## Coding conventions
|
||||||
|
|
||||||
|
- **Rust (Core):** Axum 0.7 + SQLx 0.7 (SQLite, compile-time-checked queries). Strict layering —
|
||||||
|
`routes/` (handlers, extract state, call services) → `services/` (own **all** DB access + logic)
|
||||||
|
→ `models/` (pure `Serde`/`FromRow` data). Errors via `AppError` (`src/error.rs`) with
|
||||||
|
`IntoResponse`. One file per domain across `routes/`, `services/`, `models/`. **Single-profile
|
||||||
|
design:** every service reads "the active profile" as the first DB row — intentional, don't
|
||||||
|
parameterize it. Content is data-driven: JSON under `data/` loaded at startup into Arc registries
|
||||||
|
in `AppState`. Add content by dropping JSON files, not code. Migrations are numbered SQL in
|
||||||
|
`migrations/`. Keep `clippy -D warnings` and `fmt` clean (CI enforces).
|
||||||
|
- **Python (recon):** stdlib-only servers, no framework. Each responder is a standalone script with
|
||||||
|
the reverse-engineered contract documented in its module docstring (byte offsets, VAs, symbol
|
||||||
|
names). When changing a responder, preserve byte-exactness — the client is the oracle.
|
||||||
|
- **Clean-room, always.** Every finding derives from binaries we own + live observation. **Never**
|
||||||
|
use, reference, or reproduce leaked EA source. If a task seems to need it, stop and say so.
|
||||||
|
|
||||||
|
## AI-agent rules
|
||||||
|
|
||||||
|
1. Read this file before exploring the repo.
|
||||||
|
2. Read the vault file relevant to the task (`../OpenFUT-Vault/`), not the whole tree. Repo
|
||||||
|
`docs/` files are mirrors of the vault — consult them for the same content, but treat the
|
||||||
|
vault as canonical.
|
||||||
|
3. Don't scan the whole repository unless the knowledge base is clearly stale — if you find it
|
||||||
|
stale, update the vault, then its repo `docs/` mirror.
|
||||||
|
4. Search the specific directory (`fifa17-recon/`, `openfut-core/src/<layer>/`) before a repo-wide search.
|
||||||
|
5. Update the vault when architecture materially changes (and sync the matching `docs/` mirror).
|
||||||
|
6. Don't refactor or rewrite unrelated working code.
|
||||||
|
7. Prefer small, testable changes; run the narrowest relevant test first (`cargo test <name>`).
|
||||||
|
8. **Never invent EA/FIFA/Blaze protocol behavior.** Values you don't know are `TODO/CONFIRM`, not
|
||||||
|
confident guesses. The live client is the only oracle for whether a gate is satisfied.
|
||||||
|
9. Clearly separate discovered behavior from hypotheses; record findings in
|
||||||
|
`../OpenFUT-Vault/02 Reverse Engineering/FIFA 17/Protocol Findings.md` under the right confidence
|
||||||
|
tier — never silently promote a hypothesis to a fact.
|
||||||
|
10. Root `README.md` / `CLAUDE.md` and `openfut-bridge/CLAUDE.md` describe superseded FIFA 23 plans;
|
||||||
|
prefer vault + repository evidence over them when they conflict.
|
||||||
|
|
||||||
|
## AI Session Bootstrap
|
||||||
|
|
||||||
|
Future agents should start with:
|
||||||
|
1. Read `AGENTS.md`.
|
||||||
|
2. Read the vault README (`../OpenFUT-Vault/README.md`) to locate the canonical files.
|
||||||
|
3. Identify the subsystem the task affects and read the corresponding vault file: Architecture,
|
||||||
|
Project State, Roadmap/Current Priorities, or Protocol Findings.
|
||||||
|
4. Inspect only the relevant source directories.
|
||||||
|
5. Check `../OpenFUT-Vault/02 Reverse Engineering/FIFA 17/Protocol Findings.md` before assuming
|
||||||
|
anything about FIFA/EA behavior.
|
||||||
|
6. Check `../OpenFUT-Vault/06 Agent Memory/Project State.md` before assuming a feature exists.
|
||||||
|
7. Implement the smallest coherent change.
|
||||||
|
8. Run the narrowest relevant tests.
|
||||||
|
9. Update the vault (and its repo `docs/` mirror) only if the change makes existing knowledge
|
||||||
|
inaccurate.
|
||||||
|
|
||||||
|
Do not reread the entire repository during every session.
|
||||||
|
|
||||||
|
## OpenFUT Knowledge Base
|
||||||
|
|
||||||
|
**The OpenFUT Vault is the canonical project knowledge base.** Repo `docs/` files mirror it; the
|
||||||
|
vault wins on any disagreement. Consult it before starting substantial work and update it after
|
||||||
|
durable discoveries.
|
||||||
|
|
||||||
|
Vault location: `../OpenFUT-Vault/` — start at `../OpenFUT-Vault/README.md`.
|
||||||
|
|
||||||
|
Canonical files:
|
||||||
|
- Dashboard: `00 Dashboard/OpenFUT.md`
|
||||||
|
- Architecture: `01 Architecture/Architecture.md` (repo mirror `docs/ARCHITECTURE.md`)
|
||||||
|
- RE findings: `02 Reverse Engineering/FIFA 17/Protocol Findings.md`
|
||||||
|
(repo mirror `docs/research/KNOWN_FINDINGS.md`)
|
||||||
|
- Direction history: `04 Decisions/Direction History.md`
|
||||||
|
- Project State: `06 Agent Memory/Project State.md` (repo mirror `docs/PROJECT_STATE.md`)
|
||||||
|
- Current Priorities: `06 Agent Memory/Current Priorities.md`
|
||||||
|
- Known Issues: `06 Agent Memory/Known Issues.md`
|
||||||
|
- Important Discoveries: `06 Agent Memory/Important Discoveries.md`
|
||||||
|
- Roadmap: `08 Roadmap/Roadmap.md` (repo mirror `docs/ROADMAP.md`)
|
||||||
|
|
||||||
|
When editing knowledge that exists in both places, edit the vault first, then update the matching
|
||||||
|
`docs/` mirror so they stay in sync.
|
||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
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
+6640
File diff suppressed because it is too large
Load Diff
+31
@@ -0,0 +1,31 @@
|
|||||||
|
[workspace]
|
||||||
|
resolver = "2"
|
||||||
|
members = [
|
||||||
|
"openfut-core",
|
||||||
|
"openfut-protocol-blaze",
|
||||||
|
"openfut-adapter-fifa17",
|
||||||
|
"openfut-blaze-host",
|
||||||
|
"openfut-host-config",
|
||||||
|
"openfut-http",
|
||||||
|
"openfut-tls",
|
||||||
|
"openfut-redirector-host",
|
||||||
|
"openfut-roster-host",
|
||||||
|
"openfut-utas-host",
|
||||||
|
"openfut-identity",
|
||||||
|
"openfut-import-fifa17",
|
||||||
|
"openfut-bridge",
|
||||||
|
"openfut-launcher",
|
||||||
|
# The two companion services the launcher used to shell out to Python for.
|
||||||
|
"openfut-lsx",
|
||||||
|
"openfut-autopatch",
|
||||||
|
"fifa-blaze/crates/blaze-proto",
|
||||||
|
"fifa-blaze/crates/server",
|
||||||
|
]
|
||||||
|
# openfut-hook is a Windows-only version.dll proxy injected into the FIFA client.
|
||||||
|
# It MUST build with its own [profile.release] (panic="abort" — unwinding across
|
||||||
|
# the DllMain/FFI boundary into the game process is UB — plus strip + opt-level="s").
|
||||||
|
# Cargo ignores a non-root member's profile and forbids per-package `panic` overrides,
|
||||||
|
# so the hook is deliberately EXCLUDED from this workspace to build as its own root
|
||||||
|
# (this also lands its artifact in openfut-hook/target/, matching the launcher's
|
||||||
|
# config.rs default hook_dll_path). Build: cargo build --release --target x86_64-pc-windows-gnu.
|
||||||
|
exclude = ["openfut-launcher/openfut-hook"]
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
# OpenFUT
|
# 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,340 @@
|
|||||||
|
{
|
||||||
|
"metadata": {
|
||||||
|
"reportDate": "2026-07-28",
|
||||||
|
"codebaseName": "OpenFUT",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"submodulesCovered": [
|
||||||
|
"openfut-core",
|
||||||
|
"openfut-bridge",
|
||||||
|
"openfut-launcher"
|
||||||
|
],
|
||||||
|
"language": "Rust",
|
||||||
|
"framework": "Axum + SQLite"
|
||||||
|
},
|
||||||
|
"vulnerabilities": [
|
||||||
|
{
|
||||||
|
"severity": "critical",
|
||||||
|
"category": "authentication",
|
||||||
|
"file": "openfut-core/src/services/profile.rs",
|
||||||
|
"line": 8,
|
||||||
|
"cwe": "CWE-287",
|
||||||
|
"title": "Missing Authentication on All Endpoints",
|
||||||
|
"description": "No authentication or authorization checks on any API endpoint. The system uses single-profile design with get_active_profile() returning the first row (LIMIT 1) without any token validation, session management, or per-user isolation. In a networked context, any HTTP client can access all endpoints without credentials.",
|
||||||
|
"impact": "Complete compromise of data confidentiality and integrity. Any attacker can view, modify, or delete all user data without authentication.",
|
||||||
|
"exploitPath": "curl http://127.0.0.1:8080/clubs - accesses club data without any auth headers or tokens",
|
||||||
|
"recommendation": "Implement stateless JWT tokens or session-based authentication. Add middleware to validate tokens on all endpoints. Implement per-user authorization checks in services."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"severity": "critical",
|
||||||
|
"category": "injection",
|
||||||
|
"file": "openfut-core/src/routes/auth.rs",
|
||||||
|
"line": 87,
|
||||||
|
"cwe": "CWE-89",
|
||||||
|
"title": "SQL Injection via String Interpolation",
|
||||||
|
"description": "SQL table names are interpolated using string formatting: sqlx::query(&format!(\"DELETE FROM {table}\")). Although currently hardcoded in a loop, this violates parameterized query principles and creates a risk if the table list ever becomes user-controlled or the pattern is copied elsewhere.",
|
||||||
|
"impact": "Potential remote code execution via database manipulation. If extended to user input, attackers could modify arbitrary tables or drop the database.",
|
||||||
|
"exploitPath": "Currently mitigated by hardcoded table names, but the pattern is dangerous and violates secure coding practices.",
|
||||||
|
"recommendation": "Use SQLx's dynamic query builders or identifier types that properly escape table/column names. Replace format! string interpolation with sqlx::query_builder for dynamic identifiers."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"severity": "high",
|
||||||
|
"category": "configuration",
|
||||||
|
"file": "openfut-bridge/src/proxy.rs",
|
||||||
|
"line": 44,
|
||||||
|
"cwe": "CWE-295",
|
||||||
|
"title": "TLS Certificate Validation Disabled",
|
||||||
|
"description": "HTTP client explicitly disables TLS certificate validation: .danger_accept_invalid_certs(true). This bypasses all certificate pinning, expiration, and hostname verification, making the bridge vulnerable to man-in-the-middle attacks.",
|
||||||
|
"impact": "Attacker positioned between bridge and upstream can intercept, modify, or read all traffic. Compromises confidentiality and integrity of requests to Core and external services.",
|
||||||
|
"exploitPath": "MITM attack between openfut-bridge and openfut-core or upstream services. ARP spoofing on localhost subnet would redirect traffic.",
|
||||||
|
"recommendation": "Remove .danger_accept_invalid_certs(true) in production. If testing requires it, gate behind a development-only environment variable with strong warning. Use proper certificate management (CA bundles, cert pinning)."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"severity": "high",
|
||||||
|
"category": "dos",
|
||||||
|
"file": "openfut-core/src/services/season.rs",
|
||||||
|
"line": 23,
|
||||||
|
"cwe": "CWE-248",
|
||||||
|
"title": "Unguarded expect() Causes Denial of Service",
|
||||||
|
"description": "Multiple unchecked expect() calls that will panic and crash the server if database queries fail or return unexpected results: Ok(fetch(pool, profile_id).await?.expect(\"just inserted\"))",
|
||||||
|
"impact": "Denial of service. A single database inconsistency or race condition crashes the entire server, making the application unavailable.",
|
||||||
|
"exploitPath": "Trigger race conditions during concurrent requests (e.g., rapid profile deletion + season fetch). Database corruption or migration failure crashes the service immediately.",
|
||||||
|
"recommendation": "Replace expect() with proper error handling (Result types, error logging, graceful degradation). Handle database query failures without panicking. Add integration tests for race conditions."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"severity": "high",
|
||||||
|
"category": "dos",
|
||||||
|
"file": "openfut-core/src/services/season.rs",
|
||||||
|
"line": 69,
|
||||||
|
"cwe": "CWE-248",
|
||||||
|
"title": "Unguarded expect() in season fetch",
|
||||||
|
"description": "let season = fetch(pool, profile_id).await?.expect(\"season must exist\"); Panics if season is not found.",
|
||||||
|
"impact": "Server crash on missing or deleted season records.",
|
||||||
|
"exploitPath": "Delete a season via concurrent requests, then call /seasons endpoint. Server panics.",
|
||||||
|
"recommendation": "Return proper error (AppError::NotFound) instead of panicking."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"severity": "high",
|
||||||
|
"category": "dos",
|
||||||
|
"file": "openfut-core/src/services/season.rs",
|
||||||
|
"line": 144,
|
||||||
|
"cwe": "CWE-248",
|
||||||
|
"title": "Unguarded expect() in season update",
|
||||||
|
"description": "let updated = fetch(pool, profile_id).await?.expect(\"season must exist\");",
|
||||||
|
"impact": "Server crash on concurrent season modifications.",
|
||||||
|
"exploitPath": "Rapid concurrent season updates that fail race conditions.",
|
||||||
|
"recommendation": "Handle missing records gracefully."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"severity": "high",
|
||||||
|
"category": "cors",
|
||||||
|
"file": "openfut-core/src/app.rs",
|
||||||
|
"line": 257,
|
||||||
|
"cwe": "CWE-346",
|
||||||
|
"title": "Permissive CORS Configuration Allows All Origins",
|
||||||
|
"description": ".layer(CorsLayer::permissive()) enables CORS for all origins (*), methods, and headers. Any website can make cross-origin requests to the API and access/modify data.",
|
||||||
|
"impact": "Cross-site request forgery (CSRF) attacks. Malicious websites can issue API requests on behalf of users. Data exfiltration via JavaScript from any origin.",
|
||||||
|
"exploitPath": "Attacker website:\n <img src=\"http://127.0.0.1:8080/clubs\" />\n Fetch API calls to delete profiles, modify squads, etc.",
|
||||||
|
"recommendation": "Restrict CORS to specific origins (e.g., localhost:3000 for web UI, or the game process if exposed). Use CorsLayer::very_restrictive() as default and explicitly allowlist origins."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"severity": "high",
|
||||||
|
"category": "dos",
|
||||||
|
"file": "openfut-bridge/src/proxy.rs",
|
||||||
|
"line": 47,
|
||||||
|
"cwe": "CWE-248",
|
||||||
|
"title": "HTTP Client Construction Panic",
|
||||||
|
"description": ".expect(\"failed to build HTTP client\") will panic if the HTTP client fails to initialize, crashing the entire proxy service on startup.",
|
||||||
|
"impact": "Service unavailability. Bridge cannot start if HTTP client configuration is invalid.",
|
||||||
|
"exploitPath": "Invalid system configuration or missing TLS libraries causes HTTP client build to fail, crashing bridge during startup.",
|
||||||
|
"recommendation": "Return Result<ProxyState, Error> from new() and handle construction errors. Use anyhow::Context for better error messages."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"severity": "medium",
|
||||||
|
"category": "information-disclosure",
|
||||||
|
"file": "openfut-core/src/error.rs",
|
||||||
|
"line": 54,
|
||||||
|
"cwe": "CWE-209",
|
||||||
|
"title": "Error Messages Leak Implementation Details",
|
||||||
|
"description": "JSON parsing errors are returned directly to clients: format!(\"json parse error: {e}\"). Exposes serde_json parser internals and syntax details useful for crafting attacks.",
|
||||||
|
"impact": "Information disclosure. Attackers learn the JSON parser implementation and can tailor payloads to bypass validation or find parser-specific quirks.",
|
||||||
|
"exploitPath": "Send malformed JSON to any endpoint. Response includes parser error details (e.g., 'expected `,` at line 2 col 5') that aid in crafting exploits.",
|
||||||
|
"recommendation": "Return generic error message to clients: 'invalid request format'. Log detailed errors internally with tracing for debugging."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"severity": "medium",
|
||||||
|
"category": "information-disclosure",
|
||||||
|
"file": "openfut-core/src/error.rs",
|
||||||
|
"line": 40,
|
||||||
|
"cwe": "CWE-215",
|
||||||
|
"title": "Database Errors Logged with Full Details",
|
||||||
|
"description": "Database errors are logged with full SQL/query details: tracing::error!(\"Database error: {e}\"). If logs are exposed or compromised, schema, query patterns, and data structure are revealed.",
|
||||||
|
"impact": "Information disclosure in logs. Compromised log files expose database schema and query logic useful for SQL injection or data exfiltration planning.",
|
||||||
|
"exploitPath": "Access server logs (via log aggregation service, file access, etc.) and extract database schema and query patterns.",
|
||||||
|
"recommendation": "Log only error type and ID to clients. Sanitize logs before exporting. Use structured logging with field masking for queries."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"severity": "medium",
|
||||||
|
"category": "input-validation",
|
||||||
|
"file": "openfut-core/src/routes/auth.rs",
|
||||||
|
"line": 17,
|
||||||
|
"cwe": "CWE-1025",
|
||||||
|
"title": "Hardcoded Default Credentials",
|
||||||
|
"description": "Default username 'Player 1' is hardcoded with no unique identifier enforcement. Multiple profiles can be created with identical usernames, and weak defaults are used.",
|
||||||
|
"impact": "Weak account creation, potential for account confusion or conflicts. No strong identity guarantees.",
|
||||||
|
"exploitPath": "Multiple users create profiles with default 'Player 1' username. No way to distinguish profiles programmatically.",
|
||||||
|
"recommendation": "Require explicit username on profile creation. Use UUIDs as primary identifiers. Validate username uniqueness and minimum length."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"severity": "medium",
|
||||||
|
"category": "input-validation",
|
||||||
|
"file": "openfut-core/src/services/",
|
||||||
|
"line": 0,
|
||||||
|
"cwe": "CWE-400",
|
||||||
|
"title": "Missing Input Length Validation",
|
||||||
|
"description": "No maximum length checks on string fields (usernames, club names, squad names, etc.). Large inputs can cause database bloat, memory exhaustion, or DoS.",
|
||||||
|
"impact": "Denial of service via large payloads. Database bloat. Memory exhaustion. While DefaultBodyLimit::max(256KB) provides some protection, field-level validation is missing.",
|
||||||
|
"exploitPath": "POST /auth/local with username = 256KB string. Database receives bloated data. Repeated calls exhaust storage.",
|
||||||
|
"recommendation": "Add input validation for all user-submitted strings. Set maximum lengths (e.g., username: 50 chars, club name: 100 chars). Validate at route handler level."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"severity": "medium",
|
||||||
|
"category": "configuration",
|
||||||
|
"file": "openfut-core/src/db.rs",
|
||||||
|
"line": 13,
|
||||||
|
"cwe": "CWE-315",
|
||||||
|
"title": "Unencrypted SQLite Database on Disk",
|
||||||
|
"description": "SQLite database file (openfut.db) is stored unencrypted on disk. All user data, profiles, squads, cards, etc., are readable by anyone with filesystem access.",
|
||||||
|
"impact": "Data breach if server filesystem is compromised. No protection against:local file access, stolen backups, forensic recovery.",
|
||||||
|
"exploitPath": "Attacker gains filesystem access (compromised server, stolen disk). Reads openfut.db directly. All game data is readable without authentication.",
|
||||||
|
"recommendation": "Use SQLite encryption (e.g., sqlcipher crate) or migrate to PostgreSQL with TLS. Implement file-level encryption. Use restrictive filesystem permissions (0600)."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"severity": "medium",
|
||||||
|
"category": "rate-limiting",
|
||||||
|
"file": "openfut-core/src/app.rs",
|
||||||
|
"line": 0,
|
||||||
|
"cwe": "CWE-770",
|
||||||
|
"title": "No Rate Limiting on Endpoints",
|
||||||
|
"description": "No per-IP or per-user rate limiting. Endpoints like POST /auth/reset can be called repeatedly without restriction, allowing attackers to repeatedly wipe all data.",
|
||||||
|
"impact": "Denial of service and data destruction. Attacker can spam /auth/reset to destroy user data or exhaust server resources.",
|
||||||
|
"exploitPath": "for i in 1..1000: POST /auth/reset with confirm='reset'. All data wiped repeatedly.",
|
||||||
|
"recommendation": "Implement rate limiting middleware using tower_governor or similar. Add per-IP limits (e.g., 10 requests/min) and per-endpoint limits. Use exponential backoff."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"severity": "low",
|
||||||
|
"category": "audit-logging",
|
||||||
|
"file": "openfut-core/src/services/",
|
||||||
|
"line": 0,
|
||||||
|
"cwe": "CWE-778",
|
||||||
|
"title": "Missing Audit Logging",
|
||||||
|
"description": "No audit trail of user actions (profile creation, data deletion, squad modifications). Cannot detect unauthorized access, data tampering, or compliance violations.",
|
||||||
|
"impact": "Incident response and forensics are impossible. Cannot determine who did what and when. Compliance risks (GDPR, etc.).",
|
||||||
|
"exploitPath": "Attacker deletes all profiles, modifies squads. No audit log shows what happened or who did it.",
|
||||||
|
"recommendation": "Add audit logging for all data mutations. Log: timestamp, user (profile) ID, action, resource affected, before/after state. Store in separate immutable table."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"severity": "low",
|
||||||
|
"category": "dependencies",
|
||||||
|
"file": "openfut-bridge/Cargo.toml",
|
||||||
|
"line": 0,
|
||||||
|
"cwe": "CWE-1035",
|
||||||
|
"title": "Older Dependency Versions (reqwest, rustls)",
|
||||||
|
"description": "openfut-bridge uses reqwest 0.11 (latest is 0.12) and rustls 0.21 (latest is 0.23). Intentional for version matching, but creates a larger surface area for known CVEs.",
|
||||||
|
"impact": "Potential vulnerabilities in older dependencies. Delayed access to security patches.",
|
||||||
|
"exploitPath": "Known CVE in reqwest 0.11 or rustls 0.21 could be exploited. Combined with danger_accept_invalid_certs, TLS bypass becomes easier.",
|
||||||
|
"recommendation": "Upgrade dependencies to latest versions when possible. Monitor CVE databases (CVE, RustSec) for the versions in use. Pin versions and set up automated dependency updates."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"severity": "low",
|
||||||
|
"category": "error-handling",
|
||||||
|
"file": "openfut-core/src/app.rs",
|
||||||
|
"line": 256,
|
||||||
|
"cwe": "CWE-248",
|
||||||
|
"title": "Body Size Limit Without Per-Field Validation",
|
||||||
|
"description": "DefaultBodyLimit::max(256KB) limits the entire request body, but individual fields are not validated. A single large field can consume most of the limit.",
|
||||||
|
"impact": "Mild DoS. Large field values cause database bloat. Not a critical issue due to body limit, but field-level validation would be better.",
|
||||||
|
"exploitPath": "POST /auth/local with 250KB club_name field. Database receives bloated data.",
|
||||||
|
"recommendation": "Add per-field validation in addition to body limits. Validate and sanitize fields before database insertion."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"riskScore": 82,
|
||||||
|
"riskCategory": "CRITICAL",
|
||||||
|
"riskSummary": "OpenFUT has critical security issues that would make it unsafe for production or networked deployment. The most severe are the complete absence of authentication/authorization and the SQL injection pattern in the auth.rs module. The system is designed as single-player (single-profile) with no multi-tenant isolation, which is dangerous if exposed to the network.",
|
||||||
|
"recommendations": [
|
||||||
|
{
|
||||||
|
"priority": "CRITICAL",
|
||||||
|
"area": "Authentication & Authorization",
|
||||||
|
"recommendation": "Implement JWT-based or session-based authentication on all endpoints. Add middleware to validate auth tokens on every request. Implement per-profile authorization checks. Currently any HTTP client can access all endpoints.",
|
||||||
|
"effort": "High",
|
||||||
|
"impact": "Blocks all data breaches from unauthenticated access"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"priority": "CRITICAL",
|
||||||
|
"area": "SQL Injection Prevention",
|
||||||
|
"recommendation": "Replace sqlx::query(&format!(...)) in auth.rs:87 with proper parameterized identifiers. Use sqlx::query_builder for dynamic table/column names instead of string interpolation.",
|
||||||
|
"effort": "Low",
|
||||||
|
"impact": "Prevents SQL injection even if pattern is copied to user input"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"priority": "HIGH",
|
||||||
|
"area": "TLS & Transport Security",
|
||||||
|
"recommendation": "Remove .danger_accept_invalid_certs(true) from proxy.rs:44. If development requires it, gate behind an environment variable (e.g., DEV_SKIP_TLS_VERIFICATION) with strong warnings in logs.",
|
||||||
|
"effort": "Low",
|
||||||
|
"impact": "Prevents MITM attacks on bridge-to-core communication"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"priority": "HIGH",
|
||||||
|
"area": "Error Handling",
|
||||||
|
"recommendation": "Replace all expect() calls with proper Result handling. Use anyhow::Context or custom error types. Add logging for debugging but return generic errors to clients.",
|
||||||
|
"effort": "Medium",
|
||||||
|
"impact": "Prevents DoS via server panics"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"priority": "HIGH",
|
||||||
|
"area": "CORS",
|
||||||
|
"recommendation": "Replace CorsLayer::permissive() with CorsLayer::very_restrictive() or explicit allowlist. For single-player use, restrict to localhost and the game process only.",
|
||||||
|
"effort": "Low",
|
||||||
|
"impact": "Prevents CSRF and cross-origin attacks"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"priority": "HIGH",
|
||||||
|
"area": "Rate Limiting",
|
||||||
|
"recommendation": "Add per-IP rate limiting using tower_governor or similar. Implement limits on destructive endpoints (e.g., POST /auth/reset: 1 request per hour per IP).",
|
||||||
|
"effort": "Medium",
|
||||||
|
"impact": "Prevents DoS and repeated data destruction"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"priority": "MEDIUM",
|
||||||
|
"area": "Input Validation",
|
||||||
|
"recommendation": "Add maximum length validation for all string fields (username, club_name, squad_name, etc.). Enforce at route handler level. Example: username max 50 chars, club_name max 100 chars.",
|
||||||
|
"effort": "Medium",
|
||||||
|
"impact": "Prevents database bloat and data validation failures"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"priority": "MEDIUM",
|
||||||
|
"area": "Data Encryption",
|
||||||
|
"recommendation": "Use SQLite encryption (sqlcipher) or migrate to PostgreSQL with TLS. Set restrictive filesystem permissions (0600) on openfut.db.",
|
||||||
|
"effort": "High",
|
||||||
|
"impact": "Protects data at rest from filesystem access"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"priority": "MEDIUM",
|
||||||
|
"area": "Error Message Handling",
|
||||||
|
"recommendation": "Return generic error messages to clients. Log detailed errors internally. Example: client sees 'invalid request', server logs 'JSON parse error: expected `,` at line 2'.",
|
||||||
|
"effort": "Low",
|
||||||
|
"impact": "Reduces information disclosure"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"priority": "MEDIUM",
|
||||||
|
"area": "Audit Logging",
|
||||||
|
"recommendation": "Add audit trail for all data mutations (create, update, delete). Log timestamp, profile ID, action, resource, and before/after state. Store in immutable audit_log table.",
|
||||||
|
"effort": "Medium",
|
||||||
|
"impact": "Enables incident response and forensics"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"priority": "LOW",
|
||||||
|
"area": "Dependency Management",
|
||||||
|
"recommendation": "Upgrade reqwest to 0.12 and rustls to 0.23 when possible. Set up Dependabot or RustSec monitoring for CVEs. Regularly audit dependencies.",
|
||||||
|
"effort": "Low",
|
||||||
|
"impact": "Reduces attack surface from known CVEs"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"priority": "LOW",
|
||||||
|
"area": "Default Values",
|
||||||
|
"recommendation": "Remove hardcoded default username 'Player 1'. Require explicit username on profile creation. Use UUIDs for profile identification.",
|
||||||
|
"effort": "Low",
|
||||||
|
"impact": "Improves account identity and prevents confusion"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"securityDesignNotes": {
|
||||||
|
"intendedUse": "OpenFUT is designed for single-player offline use. Single-profile design is intentional for local FIFA 23 emulation.",
|
||||||
|
"deploymentContext": "Localhost only (127.0.0.1:8080). Not intended for networked or multi-user deployment.",
|
||||||
|
"implicationForSecurity": "Many security issues (no auth, permissive CORS) are acceptable for localhost-only use. However, the code structure lacks security boundaries, so if ever exposed to the network, it would be completely unsecured. Recommend adding security gates now rather than retrofitting later.",
|
||||||
|
"suggestedDefensiveApproach": "Even for single-player use, add security layers (basic auth, CORS restrictions, rate limiting) to prevent accidental misuse if deployed in an unsafe context."
|
||||||
|
},
|
||||||
|
"positiveFindingsAndStrengths": [
|
||||||
|
"✓ SQLx is used throughout with parameterized queries (except auth.rs:87)",
|
||||||
|
"✓ Foreign key constraints are enforced in SQLite",
|
||||||
|
"✓ UUIDs are used for entity IDs instead of sequential IDs (reduces enumeration attacks)",
|
||||||
|
"✓ Request body size is limited to 256KB (prevents large payload DoS)",
|
||||||
|
"✓ Concurrency is limited to 256 concurrent requests",
|
||||||
|
"✓ Sensitive tokens (X-UT-SID, X-UT-PHISHING-TOKEN) are stripped from captures",
|
||||||
|
"✓ Logging is structured using tracing crate (good for audit trails)",
|
||||||
|
"✓ Services layer properly encapsulates database access"
|
||||||
|
],
|
||||||
|
"testingRecommendations": [
|
||||||
|
"Add integration tests for authentication bypass (attempt to access endpoints without tokens)",
|
||||||
|
"Test SQL injection payloads in auth.rs:87 pattern (if table names become dynamic)",
|
||||||
|
"Test CORS with cross-origin requests from external origins",
|
||||||
|
"Test rate limiting with rapid concurrent requests to /auth/reset",
|
||||||
|
"Test input validation with oversized strings (100MB+ usernames)",
|
||||||
|
"Test panic handling with corrupted database state",
|
||||||
|
"Test TLS MITM scenarios (certificate pinning validation)",
|
||||||
|
"Add fuzz testing for JSON parsing to find edge cases"
|
||||||
|
],
|
||||||
|
"complianceNotes": {
|
||||||
|
"gdpr": "No explicit data handling policy. If user data is processed, GDPR requires consent, data retention limits, and audit trails. Not currently implemented.",
|
||||||
|
"dataProtection": "Unencrypted database at rest violates most data protection frameworks.",
|
||||||
|
"logging": "Audit logging is missing, violating compliance requirements."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
# ============================================================================
|
||||||
|
# ⚠️ LEGACY (FIFA 23 lineage). This compose runs core + bridge for the
|
||||||
|
# superseded FIFA 23 direction. It is NOT the canonical server bring-up.
|
||||||
|
#
|
||||||
|
# Canonical server (FIFA 17):
|
||||||
|
# cd fifa17-recon/docker/fifa17-python && docker compose up -d
|
||||||
|
# (runbook: fifa17-recon/FUT-RUNBOOK.md)
|
||||||
|
#
|
||||||
|
# `core` (openfut-core) IS still the shared, game-independent backend and is
|
||||||
|
# used by the FIFA 17 UTAS host (OPENFUT_CORE_URL). `bridge` (openfut-bridge)
|
||||||
|
# is the retired FIFA 23 integration, kept for reference.
|
||||||
|
# Status source of truth: docs/PROJECT_STATE.md
|
||||||
|
# ============================================================================
|
||||||
|
# OpenFUT server stack — offline FUT backend (Core) + FIFA proxy (Bridge).
|
||||||
|
#
|
||||||
|
# Bring up: docker compose up -d
|
||||||
|
# Tear down: docker compose down (keeps data/captures volumes)
|
||||||
|
# Wipe state: docker compose down -v (also drops volumes)
|
||||||
|
# Rebuild: docker compose build (or ./scripts/registry.sh build)
|
||||||
|
# Logs: docker compose logs -f
|
||||||
|
#
|
||||||
|
# Images are pulled from / pushed to the Gitea container registry. Override the
|
||||||
|
# registry, namespace, or tag in .env (see .env.example). When REGISTRY is set,
|
||||||
|
# `up` pulls prebuilt images; the build: blocks let you rebuild locally too.
|
||||||
|
|
||||||
|
name: openfut
|
||||||
|
|
||||||
|
services:
|
||||||
|
core:
|
||||||
|
image: ${REGISTRY:-git.aleshym.co}/${NAMESPACE:-openfut}/openfut-core:${TAG:-latest}
|
||||||
|
build:
|
||||||
|
context: ./openfut-core
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
LISTEN_ADDR: 0.0.0.0:8080
|
||||||
|
DATABASE_URL: sqlite:///app/db/openfut.db
|
||||||
|
DATA_DIR: /app/data
|
||||||
|
RUST_LOG: ${CORE_LOG:-openfut_core=info,tower_http=info}
|
||||||
|
volumes:
|
||||||
|
- core-db:/app/db
|
||||||
|
# Bound to localhost by default — the bridge reaches core over the internal
|
||||||
|
# network, so core need not be world-exposed. Set CORE_PUBLISH=0.0.0.0 in
|
||||||
|
# .env if you want to hit the REST API directly from other hosts.
|
||||||
|
ports:
|
||||||
|
- "${CORE_PUBLISH:-127.0.0.1}:8080:8080"
|
||||||
|
networks:
|
||||||
|
- openfut
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8080/health"]
|
||||||
|
interval: 15s
|
||||||
|
timeout: 4s
|
||||||
|
retries: 5
|
||||||
|
start_period: 10s
|
||||||
|
|
||||||
|
bridge:
|
||||||
|
image: ${REGISTRY:-git.aleshym.co}/${NAMESPACE:-openfut}/openfut-bridge:${TAG:-latest}
|
||||||
|
build:
|
||||||
|
context: ./openfut-bridge
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
core:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
BRIDGE_LISTEN_ADDR: 0.0.0.0:8443
|
||||||
|
CORE_URL: http://core:8080
|
||||||
|
CAPTURES_DIR: /app/captures
|
||||||
|
PLACEHOLDER_MODE: ${PLACEHOLDER_MODE:-true}
|
||||||
|
TLS_ENABLED: "true"
|
||||||
|
RUST_LOG: ${BRIDGE_LOG:-openfut_bridge=info,tower_http=info}
|
||||||
|
volumes:
|
||||||
|
- bridge-captures:/app/captures
|
||||||
|
# The FIFA client connects here — publish on all interfaces by default so
|
||||||
|
# LAN clients (e.g. 10.10.0.0/24) can reach it.
|
||||||
|
ports:
|
||||||
|
- "${BRIDGE_PUBLISH:-0.0.0.0}:8443:8443"
|
||||||
|
networks:
|
||||||
|
- openfut
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-fsSk", "https://127.0.0.1:8443/_bridge/health"]
|
||||||
|
interval: 15s
|
||||||
|
timeout: 4s
|
||||||
|
retries: 5
|
||||||
|
start_period: 8s
|
||||||
|
|
||||||
|
networks:
|
||||||
|
openfut:
|
||||||
|
driver: bridge
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
core-db:
|
||||||
|
bridge-captures:
|
||||||
@@ -1,250 +0,0 @@
|
|||||||
# OpenFUT — Direction Document
|
|
||||||
*The pivot: FUT lives in the app; FIFA 23 is the match renderer.*
|
|
||||||
*Supersedes the Blaze-backend approach as the primary plan. Last updated 2026-06-30.*
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Goal (revised)
|
|
||||||
|
|
||||||
Deliver an **intuitive way to play a FUT-style experience with FIFA 23**, where:
|
|
||||||
|
|
||||||
- The entire **FUT experience** — cards, squads, packs, SBCs, coins, chemistry,
|
|
||||||
progression — lives in a **custom app** (web UI or desktop) built on the
|
|
||||||
already-complete OpenFUT Core economy backend.
|
|
||||||
- **FIFA 23 is demoted to a match renderer.** Its only job is to play a
|
|
||||||
single-player match using the squad the app built. No FUT mode, no online, no
|
|
||||||
Blaze, no EA servers.
|
|
||||||
|
|
||||||
This deliberately drops in-game FUT cards/UI (they live in the app) in exchange
|
|
||||||
for a project that **converges** instead of being gated behind months of
|
|
||||||
backend reverse-engineering.
|
|
||||||
|
|
||||||
### Why this replaces the backend plan
|
|
||||||
|
|
||||||
The status review confirmed the backend route (faking EA's online stack) is
|
|
||||||
blocked at an upstream in-process EbisuSDK gate, with Blaze/Fire2 unconfirmed
|
|
||||||
beyond it — realistically 3–6 months of expert RE that may not converge. The
|
|
||||||
app-centric route sidesteps **every** wall in that review by never making FIFA's
|
|
||||||
own FUT mode run.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Base mode: Career, not Kick-Off
|
|
||||||
|
|
||||||
**Career mode is the base.** Reasons:
|
|
||||||
|
|
||||||
- FLE's live-editing API (`EditDBTableField`, Freeze Lineup) is **confirmed to
|
|
||||||
work in career mode** and explicitly does NOT work in FUT/online modes.
|
|
||||||
- Career already provides the FUT-shaped scaffolding we'd otherwise fake:
|
|
||||||
persistent club, a fixture schedule, recorded results, progression across a
|
|
||||||
season.
|
|
||||||
- **Match results are written into the career DB**, making result capture a DB
|
|
||||||
read rather than a fragile live-memory grab.
|
|
||||||
|
|
||||||
**Kick-Off is the prototype sandbox.** Use it first to prove squad injection
|
|
||||||
works with nothing to corrupt (no save to break), then move the real loop onto
|
|
||||||
career. Run the foundational injection test in BOTH.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Core architecture: the bidirectional FLE bridge
|
|
||||||
|
|
||||||
The backbone is a **bidirectional channel between the app and a resident FLE Lua
|
|
||||||
script running inside the game.** Everything else is messages over this channel.
|
|
||||||
|
|
||||||
```
|
|
||||||
Custom App (FUT experience)
|
|
||||||
│ squad push ──────────────► ┌─────────────────────────────┐
|
|
||||||
│ │ Resident FLE Lua script │
|
|
||||||
│ ◄────────── game state │ (inside FIFA 23, career) │
|
|
||||||
│ ◄────────── match result │ - reads game state │
|
|
||||||
└────────────────────────────► │ - applies squad live │
|
|
||||||
(file-watch or local socket) │ - reads results from DB │
|
|
||||||
└─────────────────────────────┘
|
|
||||||
│
|
|
||||||
FIFA 23 plays the match
|
|
||||||
```
|
|
||||||
|
|
||||||
Three message types over the bridge:
|
|
||||||
|
|
||||||
1. **App → Game: squad push.** The app's chosen XI + stats applied LIVE via
|
|
||||||
`EditDBTableField`, replicating whatever DB write FLE's "Freeze Lineup"
|
|
||||||
feature performs (see `docs/foundational-xi-injection-test.md` — the exact
|
|
||||||
field(s) are found by diffing, not assumed). No restart, no
|
|
||||||
file-copy-reload. (File-load remains a fallback.)
|
|
||||||
|
|
||||||
2. **Game → App: game state.** The resident script polls the game's current
|
|
||||||
screen/menu state and reports "safe to apply" vs "not safe", driving a smart
|
|
||||||
Apply button in the app (see §5).
|
|
||||||
|
|
||||||
3. **Game → App: match result.** After full-time, the script reads the result
|
|
||||||
from the career DB and pushes score/scorers to the app, which awards
|
|
||||||
coins/progression. (Manual entry is the baseline fallback.)
|
|
||||||
|
|
||||||
The bridge transport can be a watched file the in-game Lua polls, or a local
|
|
||||||
socket — decided in build (see §7). Either way the *game keeps running*; a file,
|
|
||||||
if used, is just the message channel, not a reload.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Tiered mod scope
|
|
||||||
|
|
||||||
Build in tiers matched to risk. The core tier is all the SAME kind of DB write,
|
|
||||||
so it lands together once squad injection works.
|
|
||||||
|
|
||||||
### Tier 1 — Core writes (ride the same live DB-edit mechanism)
|
|
||||||
- **Squad / custom XI** — the load-bearing primitive (Freeze Lineup's
|
|
||||||
underlying write, replicated via script — see §6).
|
|
||||||
- **Player stats as "cards"** — card tiers, in-form versions, SBC upgrades all
|
|
||||||
expressed as written attribute values.
|
|
||||||
- **Chemistry as stat adjustment** — app computes FUT chemistry, applies it as
|
|
||||||
small stat bumps when writing players in (no in-game chem UI; that's in the app).
|
|
||||||
- **Appearance / identity** — kits, names, team assignment, so the club looks
|
|
||||||
like your club on the pitch.
|
|
||||||
- **Formation / tactics** — squad structure carries the app's build onto the pitch.
|
|
||||||
|
|
||||||
### Tier 2 — Confirm-then-add
|
|
||||||
- **Match difficulty per game** — to drive a Squad-Battles-style "this opponent is
|
|
||||||
World Class". Settable in-game trivially; programmatic drive needs confirming.
|
|
||||||
- **Match rules / modifiers** (half length, etc.) — for app-defined challenges.
|
|
||||||
|
|
||||||
### Tier 3 — Result capture (manual baseline + automated stretch)
|
|
||||||
- **Manual:** user enters the score in the app after the match. Zero RE, ships
|
|
||||||
first.
|
|
||||||
- **Automated:** resident script reads the career-DB result (or, for Kick-Off,
|
|
||||||
reads the in-match score from memory at full-time — precedent exists: the
|
|
||||||
CM cheat table's `export_season_stats.lua` already reads goals/cards from
|
|
||||||
memory via known offsets). Push to app → auto-award progression.
|
|
||||||
|
|
||||||
### Out of scope (stays in the app, by design)
|
|
||||||
- In-game FUT cards, FUT menus, pack-opening animation, chemistry board, FUT
|
|
||||||
presentation. The app is where it looks/feels like FUT.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. The smart Apply button (state-aware)
|
|
||||||
|
|
||||||
Live DB edits only "stick" in safe menu states (the in-game "Edit Player" screen,
|
|
||||||
for example, overwrites edits). So the bridge reads game state and gates applying:
|
|
||||||
|
|
||||||
- Resident Lua script polls the game's current-screen value (a few Hz),
|
|
||||||
classifies **safe / not safe**, reports to the app.
|
|
||||||
- App's **Apply button is enabled only when the script confirms a safe state**
|
|
||||||
(squad hub, main menu); greyed otherwise.
|
|
||||||
- **Safe-by-default-OFF:** unknown state → button greyed → never a risky write.
|
|
||||||
Expand the known-safe list incrementally as states are confirmed.
|
|
||||||
- **v2 (more seamless):** instead of greying, the app always lets you click and
|
|
||||||
the script **queues** the apply, executing the moment a safe state is entered,
|
|
||||||
then confirms back. Greying is v1; queue-and-apply is v2.
|
|
||||||
|
|
||||||
`IsInCM()` is a confirmed state-read; the specific screen-state address + the
|
|
||||||
value→screen mapping is one-time reconnaissance (same technique as result reading).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. What's confirmed vs what needs validating
|
|
||||||
|
|
||||||
**Confirmed (from FLE's own Lua API docs/wiki, checked 2026-06-30):**
|
|
||||||
- FLE live-edits the running career DB without restart, via `EditDBTableField`
|
|
||||||
(real signature: `EditDBTableField(cell)` where `cell = row["fieldname"]`
|
|
||||||
with `.value` mutated first — not the table/index/field/value form an
|
|
||||||
earlier draft of this doc assumed).
|
|
||||||
- FLE reads game state via `IsInCM()`.
|
|
||||||
- A `MEMORY` Lua class exists (`ReadInt`/`WriteInt`/`ReadMultilevelPointer`/
|
|
||||||
etc.) for arbitrary process memory — confirms the result-reading fallback
|
|
||||||
in §4 Tier 3 is a real, documented capability, not just cheat-table analogy.
|
|
||||||
- `GetPlayersStats()` is a documented function returning per-player
|
|
||||||
goals/assists/cards/etc. — a better confirmed path for match-result capture
|
|
||||||
than raw memory offsets.
|
|
||||||
- **Freeze Lineup** (Formation Editor → arrange XI → tick "Freeze Lineup" →
|
|
||||||
`Data → Save`) is FLE's actual documented mechanism for forcing a starting
|
|
||||||
XI in career mode. This **replaces** "selection bias" below.
|
|
||||||
- OpenFUT Core (economy) is complete and tested.
|
|
||||||
|
|
||||||
**Walked back — not actually confirmed:**
|
|
||||||
- "Selection bias forces specific players into the starting XI" — no such
|
|
||||||
field appears anywhere in FLE's documented Lua API or its own example
|
|
||||||
scripts. This was an unverified assumption carried over from general FIFA
|
|
||||||
modding precedent (other titles), not anything checked against FLE/FIFA 23.
|
|
||||||
See `docs/foundational-xi-injection-test.md` for the corrected plan, which
|
|
||||||
uses Freeze Lineup instead.
|
|
||||||
|
|
||||||
**Needs validating (the foundational tests — see §7):**
|
|
||||||
- Whether Freeze Lineup actually holds into a played match (FLE's wiki
|
|
||||||
documents the feature but not a live-match test of it).
|
|
||||||
- What DB table/field Freeze Lineup's `Data → Save` actually writes — it's
|
|
||||||
GUI-only and undocumented at that level; finding it is part of the
|
|
||||||
foundational test.
|
|
||||||
- Whether that write can be replicated by a script (`EditDBTableField`) well
|
|
||||||
enough to drive it from an EXTERNAL trigger, not just the Formation Editor
|
|
||||||
UI — required for the app↔game bridge.
|
|
||||||
- The app↔game bridge transport (file-watch vs socket) works cleanly under the
|
|
||||||
run setup.
|
|
||||||
- The screen-state address + safe/not-safe classification (FLE's `Events`
|
|
||||||
API page exists in the wiki index but its content is currently empty/
|
|
||||||
undocumented — this is more open than previously assumed).
|
|
||||||
- Result read-back from the career DB after a match.
|
|
||||||
|
|
||||||
**Standing caveat:** the whole stack rides on **EAAC staying neutralized**
|
|
||||||
(FLE's fake-launcher bypass). If a game update re-enables it, hooks fail. Keep
|
|
||||||
game updates off; confirm neutralized state each session.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Build order / next steps
|
|
||||||
|
|
||||||
Each is a bounded, verifiable step. Do them in order; later ones depend on
|
|
||||||
earlier answers.
|
|
||||||
|
|
||||||
1. **FOUNDATIONAL TEST — live custom XI in career.** Confirm Freeze Lineup
|
|
||||||
holds into a played match, reverse-engineer the DB write it makes, then
|
|
||||||
replicate that write from a script so it can be triggered externally
|
|
||||||
instead of through the Formation Editor UI. See
|
|
||||||
`docs/foundational-xi-injection-test.md` for the full procedure. *Done =
|
|
||||||
a script-driven write produces a match that fields the squad you
|
|
||||||
specified.* Everything rests on this.
|
|
||||||
|
|
||||||
2. **Pick the bridge transport.** Decide file-watch vs local socket for app↔game
|
|
||||||
messaging; implement the minimal app→game squad push. *Done = app sends a
|
|
||||||
squad, the resident script receives and applies it.*
|
|
||||||
|
|
||||||
3. **Game-state reader + smart Apply.** Find the screen-state address, classify
|
|
||||||
safe/not-safe, expose to the app, gate the Apply button. *Done = button greys
|
|
||||||
when you enter a match/edit screen, enables in the squad hub.*
|
|
||||||
|
|
||||||
4. **Result read-back.** Read the career-DB match result post-game, push to app,
|
|
||||||
award progression. Manual entry ships alongside as the fallback. *Done = app
|
|
||||||
updates coins from a played match.*
|
|
||||||
|
|
||||||
5. **Tier 1 breadth.** Extend the squad push to carry stats, appearance,
|
|
||||||
formation (same write mechanism). *Done = the club looks and plays like the
|
|
||||||
app's build.*
|
|
||||||
|
|
||||||
6. **Tier 2 + economy loop polish.** Difficulty drive, challenges, and the full
|
|
||||||
pack → SBC → squad → match → reward loop closed end-to-end.
|
|
||||||
|
|
||||||
### Decision still open
|
|
||||||
- **App form factor:** web UI vs desktop app. This affects the bridge transport
|
|
||||||
(a desktop app can hold a local socket more naturally; a web UI leans toward a
|
|
||||||
small local helper/file-watch). Decide before step 2.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Provenance
|
|
||||||
|
|
||||||
Clean-room throughout. This route relies on FLE's documented public API and the
|
|
||||||
game's own supported career mode — no EA backend, no Blaze, and nothing derived
|
|
||||||
from leaked EA source. The earlier backend RE remains clean-room and is preserved
|
|
||||||
as a spec artifact; it is simply no longer the primary path.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. One-paragraph summary
|
|
||||||
|
|
||||||
OpenFUT becomes a **FUT companion app that uses FIFA 23 as a match engine.** The
|
|
||||||
app owns the entire FUT experience; a resident FLE Lua script in career mode
|
|
||||||
applies the app's squad live (no restart), reports game state to drive a safe
|
|
||||||
Apply button, and reads match results back to feed progression. This sidesteps
|
|
||||||
every backend wall, runs on confirmed FLE capabilities, builds on the finished
|
|
||||||
economy core, and delivers the intuitive, offline, FUT-flavored loop that is the
|
|
||||||
actual goal.
|
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
# FIFA 23 PC Startup Flow (Offline / Proton)
|
|
||||||
|
|
||||||
Observed via FLE log, hook log, and file inspection on 2026-06-26.
|
|
||||||
|
|
||||||
## Launch chain
|
|
||||||
|
|
||||||
```
|
|
||||||
umu-run / Steam → FIFA23.exe (via Proton/Wine)
|
|
||||||
│
|
|
||||||
├─ DLL load order (before entry point)
|
|
||||||
│ ntdll.dll, kernel32.dll, ws2_32.dll …
|
|
||||||
│ version.dll ← our hook DLL slot (loads here)
|
|
||||||
│ FIFALiveEditor.DLL ← injected by FLE launcher after ~100 ms
|
|
||||||
│
|
|
||||||
├─ anadius / LSX emulator (anadius64.dll)
|
|
||||||
│ Fakes EA App / Origin session
|
|
||||||
│ Reads HKLM\SOFTWARE\Wow6432Node\Origin\ClientPath
|
|
||||||
│ Writes AppData\Local\anadius\LSX emu\achievement-*.xml
|
|
||||||
│ Provides fake PersonaId=1144668899 / UserId=1000200030000
|
|
||||||
│
|
|
||||||
├─ EA Anti-Cheat (EAAntiCheat.GameServiceLauncher.exe)
|
|
||||||
│ Spawns as child; checks EAAntiCheat.cfg
|
|
||||||
│ Not active in offline/cracked builds (FakeEAACLauncher present)
|
|
||||||
│
|
|
||||||
└─ FIFA23.exe entry point
|
|
||||||
Frostbite engine init (BuildDate 2023-07-05, changelist 5417699)
|
|
||||||
Reads Data\initfs_Win32 ← Frostbite package manifest
|
|
||||||
Reads Data\layout.toc ← file-system layout
|
|
||||||
Reads Patch\initfs_Win32 ← patches on top of base
|
|
||||||
Reads Documents\FIFA 23\fifasetup.ini ← display settings
|
|
||||||
Reads Data\locale.ini ← language table
|
|
||||||
Reads Data\db_meta.xml (via FLE) ← DB schema for all tables
|
|
||||||
```
|
|
||||||
|
|
||||||
## Phase timing (observed, single machine)
|
|
||||||
|
|
||||||
| Phase | Time after launch | Trigger |
|
|
||||||
|------------------------------|-------------------|----------------------------------|
|
|
||||||
| DLL load + FLE injection | 0 – 0.3 s | OS loader |
|
|
||||||
| Engine + DirectX init | 0.3 – 5 s | FIFA23 entry point |
|
|
||||||
| "Press any key" splash | ~5 s | First rendered frame |
|
|
||||||
| Main menu | ~25 s | After key press |
|
|
||||||
| FUT mode entry (attempted) | user-driven | User selects FUT tile |
|
|
||||||
| Network calls to EA services | at FUT entry | DirtySDK / EAWebKit |
|
|
||||||
|
|
||||||
## Files read at startup (observed)
|
|
||||||
|
|
||||||
| File | Format | Purpose |
|
|
||||||
|------|--------|---------|
|
|
||||||
| `Data/initfs_Win32` | Frostbite pkg | Base asset manifest |
|
|
||||||
| `Data/layout.toc` | Frostbite TOC | File layout index |
|
|
||||||
| `Patch/initfs_Win32` | Frostbite pkg | Patch layer |
|
|
||||||
| `Data/locale.ini` | INI | String localisation |
|
|
||||||
| `Data/db_meta.xml` | XML | DB schema (loaded by FLE) |
|
|
||||||
| `Data/id_map.json` | JSON | Player/team ID→name map |
|
|
||||||
| `Data/char_conv.json` | JSON | Character conversion table |
|
|
||||||
| `Documents/FIFA 23/fifasetup.ini` | INI | Display/audio settings |
|
|
||||||
| `AppData/Local/Temp/FIFA 23/_replay0.bin` | binary | Replay buffer |
|
|
||||||
| `anadius.cfg` | VDF | Fake EA persona config |
|
|
||||||
| `AppData/Local/anadius/LSX emu/achievement-*.xml` | XML | Achievement state |
|
|
||||||
|
|
||||||
## Files written during a session (observed)
|
|
||||||
|
|
||||||
| File | When written | Content |
|
|
||||||
|------|-------------|---------|
|
|
||||||
| `Documents/FIFA 23/settings/Settings*` | Main menu reached | FBCHUNKS — controller/display prefs |
|
|
||||||
| `Documents/FIFA 23/settings/ProfileOptions` | Profile load | FBCHUNKS — 1.5 MB profile blob |
|
|
||||||
| `Documents/FIFA 23/filesystemcache/survey.state` | Startup | Empty state file |
|
|
||||||
| `Documents/FIFA 23/filesystemcache/atlPlayTimeJson/playtime_*.json` | Ongoing | Playtime tracking |
|
|
||||||
| `FIFA 23 Live Editor/config.json` | FLE ready | FLE settings (rewritten each session) |
|
|
||||||
| `Logs/log_DD-MM-YYYY.txt` | Throughout | FLE debug log |
|
|
||||||
|
|
||||||
## Save file formats
|
|
||||||
|
|
||||||
### FBCHUNKS (Frostbite chunk container)
|
|
||||||
- Magic: `46 42 43 48 55 4E 4B 53` (`FBCHUNKS`)
|
|
||||||
- Byte 8: version (01 seen)
|
|
||||||
- Offset 0x12: null-terminated label string (e.g. "Personal Settings 1", "Career - Player Progress 1")
|
|
||||||
- Remainder: compressed/binary chunk data — no public spec; requires Frostbite tooling to fully parse
|
|
||||||
- Tools: [Frosty Tool Suite](https://github.com/CadeEvs/FrostyToolSuite) can read/write these
|
|
||||||
|
|
||||||
### fifasetup.ini
|
|
||||||
- Plain `KEY = VALUE` ini, fully human-readable
|
|
||||||
- Safe to edit (display resolution, locale, vsync)
|
|
||||||
|
|
||||||
## Network calls at FUT entry (observed with iptables redirect)
|
|
||||||
|
|
||||||
Traffic pattern captured before changing strategy:
|
|
||||||
- Multiple TLS connections to port 443 (destination: EA servers, resolved as various EA IPs)
|
|
||||||
- TLS 1.3, AES-256-GCM (DirtySDK's copy of ProtoSSL, inline in FIFA23.exe)
|
|
||||||
- No SNI sent (DirtySDK does not set `server_name` extension)
|
|
||||||
- Connections originate from Wine/Proton network stack via Linux kernel TCP
|
|
||||||
|
|
||||||
Specific EA hostnames used (from openfut-bridge captures, not decoded from TLS):
|
|
||||||
- `fut.ea.com` (FUT API)
|
|
||||||
- `accounts.ea.com` (auth)
|
|
||||||
- `gateway.ea.com` (entitlements)
|
|
||||||
- `pin-river.data.ea.com` (telemetry)
|
|
||||||
|
|
||||||
## Key FLE Lua API hooks
|
|
||||||
|
|
||||||
FLE injects `FIFALiveEditor.DLL` and exposes a Lua engine that can:
|
|
||||||
- Read any in-memory DB table via `GetDBTableRows(tableName)`
|
|
||||||
- Write any cell via `EditDBTableField`
|
|
||||||
- Query career mode state via `IsInCM()`
|
|
||||||
- Get player/team names via `GetPlayerName`, `GetTeamName`
|
|
||||||
|
|
||||||
This is the primary safe integration path (see `fut-integration-options.md`).
|
|
||||||
@@ -1,191 +0,0 @@
|
|||||||
# Foundational test — live custom XI via Freeze Lineup
|
|
||||||
|
|
||||||
**Status: PENDING — test has not yet been run.**
|
|
||||||
|
|
||||||
This is build-order step 1 from `docs/direction.md`: the test everything else
|
|
||||||
in the direction pivot depends on.
|
|
||||||
|
|
||||||
## What changed since the first draft of this doc
|
|
||||||
|
|
||||||
The first version of this test guessed at a "selection bias" DB field and a
|
|
||||||
candidate squad/lineup table name, based on general FIFA-modding precedent
|
|
||||||
that turned out not to hold for FLE's documented API — no such field appears
|
|
||||||
anywhere in FLE's actual Lua API docs or its own example scripts. While
|
|
||||||
researching an unrelated hotkey issue, a **confirmed, FLE-documented**
|
|
||||||
mechanism for forcing a starting XI turned up instead: the **Formation
|
|
||||||
Editor's "Freeze Lineup" feature** (FLE wiki, `Formation-Editor.md`):
|
|
||||||
|
|
||||||
> This feature can be used in player career mode if you want to manage the
|
|
||||||
> starting lineup of your team. Can be also used in manager career mode to
|
|
||||||
> manually manage your next opponent's starting lineup.
|
|
||||||
|
|
||||||
Steps (GUI, no scripting): open Formation Editor for a team → arrange players
|
|
||||||
on the pitch → tick **Freeze Lineup** → `Data → Save`.
|
|
||||||
|
|
||||||
This is real and documented, but it's GUI-only — there is no Lua function for
|
|
||||||
it, and what DB write it actually performs under the hood is undocumented.
|
|
||||||
This test is now two phases: confirm the GUI feature works at all, then
|
|
||||||
reverse the DB write it makes so it can be replicated programmatically
|
|
||||||
(required for the app→game bridge in build-order step 2, which needs this
|
|
||||||
driven from outside the game, not from a person clicking checkboxes).
|
|
||||||
|
|
||||||
Also fixed in this pass: `EditDBTableField`'s real signature, confirmed from
|
|
||||||
FLE's own docs and `lua/scripts/99ovr_99pot.lua`, is
|
|
||||||
`EditDBTableField(cell)` where `cell` is `row["fieldname"]` with `.value`
|
|
||||||
mutated in place — **not** `EditDBTableField(table, row_index, field, value)`
|
|
||||||
as originally (incorrectly) written into the first draft of the injector
|
|
||||||
script.
|
|
||||||
|
|
||||||
## What this test settles
|
|
||||||
|
|
||||||
Whether a *specific, externally-chosen* 11 players can be forced into a
|
|
||||||
career (or Kick-Off) match's starting lineup, live, with no restart — and
|
|
||||||
whether the mechanism that does it (Freeze Lineup's underlying DB write) can
|
|
||||||
be driven by a script instead of a person clicking through the Formation
|
|
||||||
Editor UI.
|
|
||||||
|
|
||||||
If Freeze Lineup itself doesn't actually hold under match start (the wiki
|
|
||||||
doesn't show it being tested against a live match, only "you should be able
|
|
||||||
to see... when you play against them"), the whole bridge architecture in
|
|
||||||
`docs/direction.md` §3 needs rethinking — there is no other documented
|
|
||||||
mechanism for forcing a lineup.
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
- FIFA 23 launched normally (FLE injected, EAAC neutralized — same baseline
|
|
||||||
as `track-c-fut-table-test.md`)
|
|
||||||
- A career save loaded (Freeze Lineup is documented for career mode
|
|
||||||
specifically — confirm separately whether it does anything in Kick-Off,
|
|
||||||
don't assume it does)
|
|
||||||
- Note 11 player IDs from your club (`tools/squad-exporter/export_squad.lua`
|
|
||||||
output, `playerid` field) that are NOT currently your starting XI
|
|
||||||
|
|
||||||
## Phase 1 — confirm Freeze Lineup actually holds into a match
|
|
||||||
|
|
||||||
This has zero scripting and should be done first since everything else is
|
|
||||||
wasted effort if it fails.
|
|
||||||
|
|
||||||
1. Open the Live Editor overlay (F9, or `Windows → Settings` from the
|
|
||||||
overlay's own menu bar if the hotkey isn't registering — see the umu/Wine
|
|
||||||
hotkey note below).
|
|
||||||
2. `Features → Teams` → find your team → `Edit`.
|
|
||||||
3. `Team → Formation` to open the Formation Editor.
|
|
||||||
4. Swap players around on the pitch so the XI differs from your current
|
|
||||||
actual starting XI in some checkable way (e.g. swap two outfield players'
|
|
||||||
positions, or bench/start a specific player).
|
|
||||||
5. Tick **Freeze Lineup**.
|
|
||||||
6. `Data → Save`.
|
|
||||||
7. Hide Live Editor (F9), save your career **on a new slot** (don't overwrite
|
|
||||||
your main save in case this corrupts something), exit to main menu, reload
|
|
||||||
that save, and check the team's lineup screen / play a match and watch who
|
|
||||||
starts.
|
|
||||||
|
|
||||||
**Record in the Results table below whether the frozen lineup actually took
|
|
||||||
the pitch.** If not, stop here — Phase 2 is moot.
|
|
||||||
|
|
||||||
## Phase 2 — find the underlying DB write
|
|
||||||
|
|
||||||
Only proceed if Phase 1 confirmed Freeze Lineup works.
|
|
||||||
|
|
||||||
1. In FLE's Lua Engine, run `tools/squad-injector/snapshot_lineup_tables.lua`.
|
|
||||||
This dumps every DB table whose name contains `squad`, `lineup`,
|
|
||||||
`formation`, `tactic`, `teamsheet`, `selection`, `players`, or `teams` to
|
|
||||||
`C:\FIFA 23 Live Editor\openfut_snapshot_<timestamp>.json`. Note this
|
|
||||||
filename — this is your **before** snapshot.
|
|
||||||
2. Without restarting or reloading, repeat the Formation Editor steps from
|
|
||||||
Phase 1 (steps 2–6 only — open Formation Editor, change the lineup, tick
|
|
||||||
Freeze Lineup, `Data → Save`). Don't save/reload the career between
|
|
||||||
snapshot and this step — keep it to a single live session so the diff
|
|
||||||
isn't polluted by other state changes.
|
|
||||||
3. Run `snapshot_lineup_tables.lua` again. This is your **after** snapshot.
|
|
||||||
4. Copy both JSON files out of the Wine prefix (same path pattern as
|
|
||||||
`track-c-fut-table-test.md`: `~/Games/umu/.../drive_c/FIFA 23 Live
|
|
||||||
Editor/`) and run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python3 tools/squad-injector/diff_snapshots.py before.json after.json
|
|
||||||
```
|
|
||||||
|
|
||||||
5. The output shows exactly which table(s) and field(s) changed. This is the
|
|
||||||
real, confirmed write Freeze Lineup performs — record it in the Results
|
|
||||||
table below.
|
|
||||||
|
|
||||||
## Phase 3 — replicate the write via script
|
|
||||||
|
|
||||||
1. Open `tools/squad-injector/apply_lineup_write.lua` and fill in
|
|
||||||
`TARGET_TABLE` and `TARGET_FIELDS` using Phase 2's diff output.
|
|
||||||
2. Edit `C:\FIFA 23 Live Editor\openfut_test_xi.json`:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"team_id": 12345,
|
|
||||||
"xi": [
|
|
||||||
{ "player_id": 111111, "position": 0 },
|
|
||||||
{ "player_id": 222222, "position": 5 }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Use 11 entries. Position codes are **confirmed numeric 0–27**
|
|
||||||
(`GK=0, SW=1, RWB=2, RB=3, RCB=4, CB=5, LCB=6, LB=7, LWB=8, RDM=9, CDM=10,
|
|
||||||
LDM=11, RM=12, RCM=13, CM=14, LCM=15, LM=16, RAM=17, CAM=18, LAM=19,
|
|
||||||
RF=20, CF=21, LF=22, RW=23, RS=24, ST=25, LS=26, LW=27`) — from
|
|
||||||
`lua/scripts/export_season_stats.lua`'s `get_pos_name` table in FLE's own
|
|
||||||
repo, not a guess.
|
|
||||||
3. Run `apply_lineup_write.lua` from FLE's Lua Engine.
|
|
||||||
4. Repeat the save-to-new-slot / reload / check-lineup verification from
|
|
||||||
Phase 1, but this time without ever opening the Formation Editor — the
|
|
||||||
write was made entirely from the script.
|
|
||||||
|
|
||||||
## Classification criteria
|
|
||||||
|
|
||||||
### "Confirmed — full mechanism works"
|
|
||||||
|
|
||||||
Phase 1 holds, Phase 2 finds a clean diff, Phase 3's scripted write produces
|
|
||||||
the same in-match result as the manual GUI path.
|
|
||||||
|
|
||||||
**Verdict:** Build-order step 1 done. Proceed to step 2 (bridge transport) in
|
|
||||||
`docs/direction.md`.
|
|
||||||
|
|
||||||
### "GUI works, script doesn't"
|
|
||||||
|
|
||||||
Phase 1 holds but Phase 3's replicated write doesn't stick, even though the
|
|
||||||
diffed fields matched what changed in Phase 2.
|
|
||||||
|
|
||||||
**Verdict:** Freeze Lineup likely does more than a single DB field write
|
|
||||||
(e.g. an internal engine call beyond `EditDBTableField`'s reach, or a second
|
|
||||||
write the diff missed because it happened in a table outside the `KEYWORDS`
|
|
||||||
filter in `snapshot_lineup_tables.lua` — widen the filter and redo Phase 2).
|
|
||||||
|
|
||||||
### "Freeze Lineup doesn't hold at all"
|
|
||||||
|
|
||||||
Phase 1 fails — the lineup reverts to the game's own AI-picked XI regardless.
|
|
||||||
|
|
||||||
**Verdict:** No confirmed mechanism exists for forcing a lineup. This kills
|
|
||||||
the bridge architecture as designed in `direction.md` §3 and needs a return
|
|
||||||
to first principles — there is no fallback documented anywhere in FLE's wiki
|
|
||||||
for this specific case.
|
|
||||||
|
|
||||||
## A note on the umu/Wine F9/F11 hotkey issue
|
|
||||||
|
|
||||||
If FLE's F9 (hide/show) hotkey isn't registering under umu, this is plausibly
|
|
||||||
a Wine keyboard-hook limitation (FLE's global hotkey detection likely uses a
|
|
||||||
low-level hook that doesn't translate cleanly through Wine's input layer) —
|
|
||||||
not something documented anywhere in FLE's own troubleshooting docs, which
|
|
||||||
don't mention Linux/Wine at all. F11 specifically has **no documented FLE
|
|
||||||
function** — F9 is the only documented toggle. Workaround: click directly
|
|
||||||
into the FLE overlay window (it should still be visible/clickable even if the
|
|
||||||
hotkey doesn't fire) and use its own menu bar instead of relying on the
|
|
||||||
hotkey.
|
|
||||||
|
|
||||||
## Results
|
|
||||||
|
|
||||||
*(To be filled in after the test is run.)*
|
|
||||||
|
|
||||||
| Field | Value |
|
|
||||||
|---|---|
|
|
||||||
| Date run | — |
|
|
||||||
| Phase 1: Freeze Lineup holds into a match? | — |
|
|
||||||
| Phase 2: table(s)/field(s) changed | — |
|
|
||||||
| Phase 3: scripted write reproduces Phase 1 result? | — |
|
|
||||||
| **Classification** | **PENDING** |
|
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
# FUT Integration Options
|
|
||||||
|
|
||||||
How to connect FIFA 23 to the OpenFUT local simulator, ranked by safety and feasibility.
|
|
||||||
|
|
||||||
## Option A — FLE Lua scripting (RECOMMENDED)
|
|
||||||
|
|
||||||
**What it does:** Use FIFA Live Editor's in-memory Lua API to read and write the game's
|
|
||||||
database tables at runtime. FLE is already injected; no additional hooking needed.
|
|
||||||
|
|
||||||
**Why it's the right path:**
|
|
||||||
- Fully offline, no EA servers touched
|
|
||||||
- FLE is already trusted by the user (it's the launch mechanism)
|
|
||||||
- `GetDBTableRows` / `EditDBTableField` expose the full Frostbite DB in memory
|
|
||||||
- Scripts run inside the game process; no IPC complexity
|
|
||||||
- Same mechanism used by modders for career mode edits today
|
|
||||||
|
|
||||||
**Integration design:**
|
|
||||||
|
|
||||||
```
|
|
||||||
openfut-core (SQLite)
|
|
||||||
│
|
|
||||||
│ HTTP REST (localhost)
|
|
||||||
▼
|
|
||||||
openfut-bridge (port 8080, plain HTTP, no TLS)
|
|
||||||
│ pulls club/squad/player data as JSON
|
|
||||||
▼
|
|
||||||
FLE Lua bridge script
|
|
||||||
│ calls GetDBTableRows, EditDBTableField
|
|
||||||
▼
|
|
||||||
FIFA 23 in-memory DB (Frostbite)
|
|
||||||
```
|
|
||||||
|
|
||||||
The Lua script polls openfut-core's REST API at intervals (or on FUT menu entry)
|
|
||||||
and writes simulator data (coins, items, squad) into the appropriate DB tables.
|
|
||||||
|
|
||||||
**Tables likely involved (to verify with export_squad.lua):**
|
|
||||||
|
|
||||||
| Table | Expected FUT content |
|
|
||||||
|-------|---------------------|
|
|
||||||
| `players` | Player attributes (OVR, potential, stats) |
|
|
||||||
| `teams` | Club identity, stadium, colors |
|
|
||||||
| `fut_clubs` | FUT club record (if in memory when FUT loads) |
|
|
||||||
| `fut_items` | Card inventory (if in memory) |
|
|
||||||
| `fut_squads` | Active squad (if in memory) |
|
|
||||||
|
|
||||||
**Steps to implement:**
|
|
||||||
1. Run `tools/squad-exporter/export_squad.lua` from FLE Lua Engine while in FUT to discover which tables are live
|
|
||||||
2. Map openfut-core's data model to the discovered table fields
|
|
||||||
3. Write a Lua polling script that fetches `/api/v1/club`, `/api/v1/squad`, etc. from openfut-core and calls `EditDBTableField` to populate them
|
|
||||||
4. Optionally add a small HTTP client to the Lua script using LuaSocket (FLE ships with Lua 5.4)
|
|
||||||
|
|
||||||
**Limitations:**
|
|
||||||
- Changes are in-memory only; they reset on game restart (acceptable for a simulator)
|
|
||||||
- Only works while FLE is running (always true in our setup)
|
|
||||||
- FUT tables may only be populated when the FUT hub is loaded; test with the exporter
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Option B — Local save file injection (career mode proxy)
|
|
||||||
|
|
||||||
**What it does:** Generate or modify offline career mode save files that contain FUT-like
|
|
||||||
squad/player data, using Frostbite's FBCHUNKS format.
|
|
||||||
|
|
||||||
**Feasibility:** Medium
|
|
||||||
- FBCHUNKS format is not publicly documented but has been partially reverse-engineered by the Frosty Tool Suite project
|
|
||||||
- Career saves are 16 MB — large and complex
|
|
||||||
- Changes take effect only after a game restart
|
|
||||||
|
|
||||||
**Best use:** Pre-populating a career club with the same players as the FUT simulator squad, so offline Squad Battles use "your" players.
|
|
||||||
|
|
||||||
**Steps:**
|
|
||||||
1. Use Frosty Tool Suite to open a career save and map the schema
|
|
||||||
2. Build a Python exporter that writes a valid FBCHUNKS save with simulator squad data
|
|
||||||
3. Test: replace the career save, launch FIFA, verify squad is correct
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Option C — Local companion web UI
|
|
||||||
|
|
||||||
**What it does:** The user manages their FUT simulator entirely in a web browser (openfut-core already has this). A button exports the current squad/club state to a format that a Lua script or file injector can consume.
|
|
||||||
|
|
||||||
**This is already implemented** — openfut-core serves the FUT simulator REST API. The missing piece is the Lua bridge script (Option A) that reads from it.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Option D — Local proxy for non-secured local calls only
|
|
||||||
|
|
||||||
**What it does:** Intercept FIFA 23's calls to `localhost:*` or a known local endpoint (not EA servers) and respond with simulator data.
|
|
||||||
|
|
||||||
**Feasibility:** Low value in isolation
|
|
||||||
- FIFA 23 does not make calls to localhost in normal operation (except EA App on port 10853)
|
|
||||||
- All FUT API calls go to EA's servers over TLS
|
|
||||||
- Intercepting those would require the approach we explicitly ruled out
|
|
||||||
|
|
||||||
**Not recommended as a primary path.** Could be combined with Option A if the Lua script exposes a local socket that a coordinator process writes to.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Option E — Memory bridge (Cheat Engine / FLE offsets)
|
|
||||||
|
|
||||||
**What it does:** Use known memory offsets (FLE's `offset_cache.json`) to read/write FUT state directly in FIFA23.exe's heap.
|
|
||||||
|
|
||||||
**Feasibility:** Medium — FLE already does this for career mode
|
|
||||||
- FLE's `offset_cache.json` contains addresses for many game structures
|
|
||||||
- FUT in-memory structs are separate from career structs and may not be mapped yet
|
|
||||||
- This is fragile (offsets change with game updates)
|
|
||||||
|
|
||||||
**Not recommended** unless Options A and B both fail — too brittle.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Recommendation
|
|
||||||
|
|
||||||
**Start with Option A (FLE Lua scripting).**
|
|
||||||
|
|
||||||
1. Run `tools/squad-exporter/export_squad.lua` in-game to discover which DB tables exist in FUT mode
|
|
||||||
2. Use `tools/file-watch-diff/watch.sh` to snapshot file state entering FUT and identify any new local files
|
|
||||||
3. Use `tools/network-metadata-logger/netlog.sh` to log which EA hosts FIFA contacts at FUT entry (metadata only, no decryption)
|
|
||||||
4. Map findings back to openfut-core's data model
|
|
||||||
5. Implement the Lua bridge script that calls openfut-core's REST API and writes to discovered tables
|
|
||||||
|
|
||||||
If FUT tables are not exposed by FLE's DB API (they may not be — FUT data lives server-side in online mode), fall back to **Option B** (career save injection) to provide a squad that mirrors the simulator's club.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Safety boundary
|
|
||||||
|
|
||||||
The following are out of scope and must not be implemented:
|
|
||||||
|
|
||||||
- Decrypting or inspecting EA's TLS traffic
|
|
||||||
- Spoofing EA domain names or impersonating EA servers
|
|
||||||
- Sending modified clients to EA's production services
|
|
||||||
- Bypassing EA App login or account verification
|
|
||||||
- Anything that could constitute online cheating or violate EA's ToS for online play
|
|
||||||
|
|
||||||
All integration must remain local/offline/single-player.
|
|
||||||
@@ -1,208 +0,0 @@
|
|||||||
# OpenFUT Status Review
|
|
||||||
*Generated 2026-06-30 — read-only stocktake, no code changed.*
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Executive Summary
|
|
||||||
|
|
||||||
OpenFUT has a mature offline FUT economy backend (Core, 25 phases, fully functional in
|
|
||||||
isolation) and a sophisticated hook DLL that loads into FIFA 23, redirects EA hostnames
|
|
||||||
to loopback, and bypasses TLS certificate verification. The Blaze/ProtoSSL layer is
|
|
||||||
structurally ready: framing code exists, a TLS listener runs, cert-verify is patched.
|
|
||||||
However the project is currently blocked before any Blaze traffic is ever seen.
|
|
||||||
The fundamental problem is that FIFA 23 submits `GoOnline` to EbisuSDK and then
|
|
||||||
**waits for an asynchronous ONLINE_STATUS_EVENT push** from the EA-app LSX server —
|
|
||||||
a push that current code never sends. Every approach tried so far (flipping poll
|
|
||||||
return values, forcing the state flags, read-only probes) confirms the gate is
|
|
||||||
event-driven, not poll-driven. The Blaze captures directory contains six empty files.
|
|
||||||
No Fire2 frame from FIFA 23 has ever been decoded. Until the ONLINE_STATUS_EVENT push
|
|
||||||
is synthesized and delivered correctly, Milestones 2–7 are all waiting on the same
|
|
||||||
single wall.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Proven vs Assumed
|
|
||||||
|
|
||||||
| Claim | Status | Evidence |
|
|
||||||
|---|---|---|
|
|
||||||
| FIFA 23 uses DirtySDK / ProtoSSL | **Proven** | String scan hit `ProtoSSLSend`, `ProtoSSLRecv`, `gosredirector` in FIFA23.exe memory (Task 1) |
|
|
||||||
| `version.dll` loads and runs hook code | **Proven** | `hook.log` written at DLL_PROCESS_ATTACH |
|
|
||||||
| `getaddrinfo` IAT hook redirects EA domains to loopback | **Proven** | Hook log records every EA `getaddrinfo` call; connect_hook log confirms port redirects |
|
|
||||||
| ProtoSSL cert-verify prologue found and patched (FIFA23.exe) | **Proven** | ssl_patch.rs prologue confirmed at file offset 0xf0c850; hook log "ssl: main exe cert-verify patched" |
|
|
||||||
| ProtoSSL cert-verify patched in EAWebKit.dll | **Proven** (if loaded) | Lazy patch fires on first EA getaddrinfo call; hook log message confirms |
|
|
||||||
| Gate is upstream of DirtySDK — no DNS/connect fires on FUT entry | **Proven** | getaddrinfo, connect, WSASend/Recv hooks all show zero external traffic during "connecting to EA Servers" |
|
|
||||||
| `GoOnline` is called by the game | **Proven** | Read-only detour on `anadius64.dll+0x2BB90` confirmed hit |
|
|
||||||
| anadius returns GoOnline success | **Proven** | Handler observed returning successfully; game still retries every ~7 s |
|
|
||||||
| Gate is downstream of GoOnline | **Proven** | GoOnline called + returns success; no Blaze connect follows |
|
|
||||||
| Connection-state function: `GetInternetConnectedState @ anadius64.dll+0x27790` | **Proven** | Located via anadius LSX command-registration table; two-flag branch decoded (`+0xCAB1A`, `+0xCAB1B`) |
|
|
||||||
| Gate is event-driven (game waits for async push, not a poll return) | **Proven** | Forced both state flags AND GoOnline return to "1"; game kept retrying; worker-thread stack scan confirms handler runs on anadius IOCP thread, not FIFA's thread |
|
|
||||||
| GoOnline runs on anadius worker thread, not FIFA's call thread | **Proven** | Stack scan from inside detour found zero FIFA23.exe frames, sp ~2.4 KB from thread stack top |
|
|
||||||
| `protossl-scan` live toolkit is exhausted for finding GoOnline in FIFA23.exe | **Proven** | No `"GoOnline"` string in image; worker-thread call stack has no FIFA frames; jmpscan yields ~3875 hits (overwhelmingly data false positives) |
|
|
||||||
| FIFA 23 redirector config references `Authorization:` header (Nucleus token) | **Proven** | Found in FIFA23.exe .rdata pointer table @ `+0x83FC858` |
|
|
||||||
| openfut-core REST API complete and tested | **Proven** | 25 phases, 15 migrations, passing integration tests |
|
|
||||||
| Bridge LSX server starts and handles request-response | **Proven** (code) | `openfut-bridge/src/lsx.rs` + `main.rs` — server starts on 127.0.0.1:3216 |
|
|
||||||
| Bridge LSX server ACTUALLY receives FIFA's LSX connections | **UNCONFIRMED** | anadius may intercept the same calls in-process before the TCP connection reaches the bridge |
|
|
||||||
| Bridge LSX server `GetInternetConnectedState → connected="1"` unblocks the gate | **UNCONFIRMED (known to fail in-process)** | Flipping the value via anadius in-process failed; bridge path not yet confirmed working |
|
|
||||||
| ONLINE_STATUS_EVENT push XML format | **UNKNOWN** | No capture; format not derived |
|
|
||||||
| Fire2 framing is correct for FIFA 23 | **UNCONFIRMED** | Implemented based on post-2012 EA convention; all blaze captures are empty (0 bytes) |
|
|
||||||
| Blaze component / command IDs for FIFA 23 | **UNKNOWN** | Zero captures; dispatch table entirely empty placeholders |
|
|
||||||
| ProtoSSL recv-injection convention (non-blocking return values etc.) | **UNCONFIRMED** | Never reached M4; recv_hook module removed from active install path |
|
|
||||||
| FUT REST endpoint paths in mapper.rs | **SPECULATIVE** | Based on community knowledge of older FIFA titles; the one actual capture in `captures/` is an early GET from before the Blaze strategy |
|
|
||||||
| FLE Lua API exposes FUT DB tables in memory | **UNKNOWN** | `export_squad.lua` has never been run; FUT data may only exist server-side in online mode |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Milestone Status
|
|
||||||
|
|
||||||
| Milestone | Status | Blocker | Depends on unconfirmed assumption? |
|
|
||||||
|---|---|---|---|
|
|
||||||
| **M1** — Locate connection-state decision point | ✅ Done | — | No |
|
|
||||||
| **M2** — Flip gate, force "connected" | ⛔ Blocked | Game waits for async ONLINE_STATUS_EVENT push; no current code sends it | Yes — unknown event XML format |
|
|
||||||
| **M3** — First ProtoSSL plaintext on Blaze connection | 🔲 Not started | Depends on M2 | Yes — Fire2 framing unconfirmed |
|
|
||||||
| **M4** — Answer redirector + decode first Fire2 frame | 🔲 Not started | Hard wall: Fire2 framing, recv-injection convention, component/command IDs all unconfirmed | Yes — all three unknown |
|
|
||||||
| **M5** — Blaze preauth / login / postauth | 🔲 Not started | Depends on M4 | Yes — Blaze auth TDF body layout unknown |
|
|
||||||
| **M6** — FUT entry + hub load | 🔲 Not started | Depends on M5; also requires FUT REST response shapes confirmed | Yes — endpoint paths speculative |
|
|
||||||
| **M7** — Squad Battles (AI FUT) | 🔲 Not started | Depends on M6 | Yes |
|
|
||||||
|
|
||||||
**Note on roadmap.md wording:** Under M2–M4, roadmap.md uses `**Done (observable):**` bullets. These describe the *success criterion* for each milestone, not an achieved state. The authoritative status is in `connection-gate-findings.md` (M2 attempts failed; M3/M4 never started). The roadmap has not been updated to reflect M2 failure.
|
|
||||||
|
|
||||||
### M4 is the first hard wall in detail
|
|
||||||
|
|
||||||
Even assuming M2 is solved, M4 requires three unconfirmed things simultaneously:
|
|
||||||
1. **Fire2 framing** — the 12-byte header layout is assumed; if FIFA 23 uses an older Fire variant or a custom delta, the codec will misparse every packet.
|
|
||||||
2. **ProtoSSL recv-injection** — delivering responses to the game via recv hook requires knowing what return values and buffer conventions ProtoSSL expects; recv_hook.rs exists but is not installed.
|
|
||||||
3. **Blaze component/command IDs** — the dispatch table is entirely empty; we cannot answer any request until IDs are known from captures.
|
|
||||||
|
|
||||||
All three are resolved by getting one real captured frame. M4 is primarily a capture problem, not a decoding problem — once bytes exist, the framing and IDs are immediately readable.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Blockers, Risks, Unknowns
|
|
||||||
|
|
||||||
### Blockers (stop progress now)
|
|
||||||
|
|
||||||
1. **ONLINE_STATUS_EVENT push not synthesized** *(M2 wall)*
|
|
||||||
The game calls GoOnline, gets success, then waits indefinitely for a push event on the LSX socket that never arrives. This is the single gate blocking all Blaze work. Options: (a) trace the event format via Ghidra on FIFA23.exe (xref `ONLINE_STATUS_EVENT` string + the game's EbisuSDK listener), (b) RE anadius's LSX event-send path (find what it would push in an "online" scenario), (c) brute-force push candidate event XMLs and observe whether the game advances.
|
|
||||||
|
|
||||||
2. **Bridge LSX server delivery unconfirmed** *(architectural risk converted to blocker)*
|
|
||||||
The hook passes port 3216 connections through, assuming the bridge LSX server on the Linux host receives them. If anadius's in-process hooks intercept the winsock calls before they reach the TCP stack, the bridge server is never reached. This must be confirmed by checking `openfut_hook.log` for a getaddrinfo on the LSX host, or by observing the bridge server's accept logs.
|
|
||||||
|
|
||||||
### Risks (could derail later)
|
|
||||||
|
|
||||||
3. **Fire2 framing wrong** *(M4 risk)*
|
|
||||||
If FIFA 23 uses Fire (pre-2012) or a modified frame layout, the codec misparses. Mitigation: the server has a `Raw` fallback mode for capturing raw bytes when framing fails.
|
|
||||||
|
|
||||||
4. **Secondary auth-token gate** *(M5 risk)*
|
|
||||||
`connection-gate-findings.md` noted the redirector request carries an `Authorization:` header. M1's final conclusion said `GetAuthCode` returns a fake token that appears accepted — but this was inferred, not confirmed by seeing the redirector request actually constructed with that token.
|
|
||||||
|
|
||||||
5. **EAAC not fully neutralized** *(persistent risk)*
|
|
||||||
`FakeEAACLauncher` bypasses the anticheat launcher. The hook DLL is unsigned. If EAAC is ever active (e.g., after a game update re-enables it), all hooks fail silently. Marked as "not active in offline/cracked builds" — assumed, not confirmed on every launch.
|
|
||||||
|
|
||||||
6. **FUT REST response shapes wrong** *(M6 risk)*
|
|
||||||
The 61 endpoint mappings in mapper.rs and the shaper stubs in shaper.rs are based on community guesses about older FIFA FUT APIs, not FIFA 23 captures. Response JSON shapes may differ enough to cause the client to fail silently or crash.
|
|
||||||
|
|
||||||
### Unknowns (open questions)
|
|
||||||
|
|
||||||
7. **ONLINE_STATUS_EVENT XML format** — exact tag names, field order, sender attribute, and any nonces/tokens required.
|
|
||||||
8. **GoOnline event sequence** — whether ONLINE_STATUS_EVENT alone is sufficient or a sequence of events (e.g., PROFILE_EVENT, LOGIN_EVENT, COMMERCE_EVENT) is expected.
|
|
||||||
9. **Whether FLE exposes FUT DB tables** — FUT card inventory and squad data likely live server-side in online mode; FLE may not surface them for in-process editing.
|
|
||||||
10. **Blaze component/command IDs for FIFA 23** — entirely unknown; no captures.
|
|
||||||
11. **openfut_hook.log current content** — we have the code but no log output in any document. Whether the current hook (with connect, ssl_patch, tls_bypass, WSAIoctl, origin_spy all installed) fires correctly and what it observes is unverified in this review.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Track Comparison
|
|
||||||
|
|
||||||
### Track A — Full EA-backend fake (M1–M7, playable FUT vs AI)
|
|
||||||
|
|
||||||
**What it delivers:** The FIFA 23 FUT hub loads from OpenFUT Core; Squad Battles matches play and reward economy items.
|
|
||||||
|
|
||||||
**Effort:** Research-grade. Minimum path: synthesize ONLINE_STATUS_EVENT (unknown format, 1–2 weeks of RE), then capture Fire2 frames (days once M2 is solved), then implement Blaze auth handlers (weeks), then implement FUT entry (weeks), then Squad Battles (weeks). Realistic minimum: 3–6 months of expert RE work.
|
|
||||||
|
|
||||||
**Proven support:** Hook loads and redirects correctly. TLS bypass patched. Core economy backend complete. Blaze framing code and TLS listener exist.
|
|
||||||
|
|
||||||
**Assumed:** Fire2 framing correct; component/command IDs discoverable from captures; FUT REST shapes close enough to community guesses; no additional undiscovered gates.
|
|
||||||
|
|
||||||
**Evidence for:** Architecture is coherent. The M1 finding (gate precisely named and decoded) was achieved cleanly. The in-process hook approach is validated.
|
|
||||||
|
|
||||||
**Evidence against:** M2 was attempted and failed with the in-process approach. The event-driven architecture adds a full EbisuSDK emulation layer before even one Blaze byte is seen. The live toolkit is exhausted (Path A verdict); Ghidra-level work on a 505 MB binary is required. Six capture files with zero bytes.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Track B — Clean-room spec deliverable (M1–M5 documented)
|
|
||||||
|
|
||||||
**What it delivers:** A documented map of the connection gate, LSX event sequence, Blaze auth surface (transport, framing, gate conditions, component IDs, TDF schemas). Valuable as an archival/community artifact even if Track A stalls.
|
|
||||||
|
|
||||||
**Effort:** Medium. M1 is done. M2–M5 documentation emerges as a by-product of engineering work. The spec itself (writing) is lightweight; the engineering to produce the captures is the cost.
|
|
||||||
|
|
||||||
**Proven support:** M1 complete and documented. connection-gate-findings.md is already a high-quality spec artifact.
|
|
||||||
|
|
||||||
**Assumed:** Same as Track A for the unconfirmed values, but the spec can mark them `TODO/CONFIRM` rather than needing to implement them.
|
|
||||||
|
|
||||||
**Evidence for:** The clean-room constraint means a spec is the only artifact that can be safely published. connection-gate-findings.md shows this approach produces real value. B finishes even if A is never fully playable.
|
|
||||||
|
|
||||||
**Evidence against:** Track B alone doesn't produce a playable FUT; it is a foundation, not an end-user product.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Track C — FLE Lua bridge (local-match path, skip the backend gate)
|
|
||||||
|
|
||||||
**What it delivers:** FIFA 23 career mode or Kick-Off with an OpenFUT club's players and squad loaded via FLE's in-memory DB API. No online gate, no Blaze, no TLS. Fully offline from day one.
|
|
||||||
|
|
||||||
**Effort:** Low-to-medium. FLE is already loaded in the normal launch path. Tools exist (`tools/squad-exporter/`, `tools/profile-exporter/`). Primary unknown is whether FUT-relevant DB tables are accessible.
|
|
||||||
|
|
||||||
**Proven support:** FLE Lua API exposes `GetDBTableRows` / `EditDBTableField` for career mode. `fifa23-startup-flow.md` confirms FLE injects at load. `fut-integration-options.md` documents the integration path in detail and rates this as the recommended option.
|
|
||||||
|
|
||||||
**Assumed:** FUT card/club/squad data has in-memory DB table representations that FLE can write. If FUT data is purely server-side (loaded from EA servers, not from the Frostbite DB layer), Track C produces no FUT simulation at all — only career mode player stats.
|
|
||||||
|
|
||||||
**Evidence for:** Career mode already works with FLE edits (community precedent). Tools are present and designed for this path. No infrastructure work needed.
|
|
||||||
|
|
||||||
**Evidence against:** FUT in FIFA 23 uses server-side data. The cards in a player's FUT club, the coins, the squad — these are fetched from `fut.ea.com` REST APIs, not from the Frostbite embedded DB. FLE's `GetDBTableRows` likely exposes base player stats tables but not FUT item tables. The crucial test (run `export_squad.lua` while in FUT mode) has never been done.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Recommendation
|
|
||||||
|
|
||||||
**Start Track C immediately as a parallel, low-cost validation.**
|
|
||||||
|
|
||||||
Run `export_squad.lua` in FLE while inside the FUT hub (or attempting to enter it). If FUT tables appear in the export, Track C is viable and is the fastest path to something a user can interact with. This test takes one session and costs nothing.
|
|
||||||
|
|
||||||
Simultaneously, **continue Track A/B with the next concrete RE step:** synthesize the ONLINE_STATUS_EVENT push. The most actionable option is to run `origin_spy` logs from the current hook to see what LSX events fire during a session, then attempt to push candidate event XMLs via the bridge LSX server and watch whether the game advances. This is bounded, testable work that either unblocks M2 or produces the spec value for Track B.
|
|
||||||
|
|
||||||
**Do not abandon Track A/B for Track C** — they are complementary. Core is already built; the bridge is mostly built. The gap is purely the RE wall at M2.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Architecture and Provenance Sanity-Check
|
|
||||||
|
|
||||||
### Hook + Brain coherence
|
|
||||||
|
|
||||||
The CLAUDE.md bridge architecture diagram (hook intercepts ProtoSSL → plain localhost TCP → blaze_brain → Core) remains coherent. The M1/M2 findings revealed one additional layer (EbisuSDK LSX event) that must precede the Blaze connection. The bridge has been updated to handle LSX directly. The overall design is sound; the M2 blocker is an implementation gap (event synthesis), not an architectural flaw.
|
|
||||||
|
|
||||||
**One inconsistency to flag:** The hook's `lsx.rs` contains a complete in-process LSX emulator (AES-128-ECB, CRandom, all response builders), but the recv/send hooks that activate it are explicitly removed (`lib.rs`: "recv/send hooks removed — LSX is now handled by the native openfut-bridge LSX server"). This is dead code. The bridge's LSX server is the current path. The in-process lsx.rs should either be deleted or documented as a fallback; its presence is confusing.
|
|
||||||
|
|
||||||
### Clean-room status
|
|
||||||
|
|
||||||
No evidence of EA leaked source anywhere in the tree. All RE work is derived from:
|
|
||||||
- Running the shipping binary and observing behavior (function return values, network traffic patterns)
|
|
||||||
- Memory scanning of the live process (string search, xref, disasm of observed addresses)
|
|
||||||
- Reading anadius's own compiled output (its exported symbols, its LSX XML format — which is anadius's own implementation, not EA's)
|
|
||||||
- Community FUT API knowledge (mapper.rs endpoint paths — plausible but speculative)
|
|
||||||
|
|
||||||
The Blaze framing in `fifa-blaze/crates/blaze-proto/src/frame.rs` cites "Fire2 used by ME3, BF3, and most post-2012 titles" — this is sourced from public community documentation of those older titles, not from any leaked EA source. **Clean-room intact.**
|
|
||||||
|
|
||||||
The `AES_KEY` in the hook's lsx.rs (`[0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15]`) is a placeholder key used for the LSX session encryption. The real session key is derived from the challenge seed via CRandom — this algorithm was RE'd from anadius's own binary. No EA source required.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. If You Read Only This
|
|
||||||
|
|
||||||
- **The project is blocked at M2.** FIFA 23 submits `GoOnline`, gets success, then waits for an async `ONLINE_STATUS_EVENT` push on the LSX socket that no current code ever sends. All six Blaze capture files are empty (0 bytes). No Fire2 frame has ever been decoded.
|
|
||||||
|
|
||||||
- **M1 is the only completed milestone.** The gate function (`GetInternetConnectedState @ anadius64.dll+0x27790`) is precisely named and its two-flag branch decoded. Everything after M1 is either blocked or not started.
|
|
||||||
|
|
||||||
- **The next concrete action** is synthesizing the ONLINE_STATUS_EVENT push XML and testing whether the bridge's LSX server can deliver it to the game. This is the single thing that unblocks all Blaze work.
|
|
||||||
|
|
||||||
- **Track C (FLE Lua) is untested but cheap to validate.** Run `export_squad.lua` while in FUT to find out if FUT DB tables are accessible. If yes, it is the fastest path to user-visible results. If no, it is ruled out with one session.
|
|
||||||
|
|
||||||
- **openfut-core is complete and ready** — 25 phases, 15 migrations, full economy REST API, passing tests. It is not blocking anything; it is waiting for the bridge to connect to it.
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
# Track C — FUT DB table viability test
|
|
||||||
|
|
||||||
**Status: PENDING — test has not yet been run.**
|
|
||||||
|
|
||||||
## What this test settles
|
|
||||||
|
|
||||||
Track C ("FLE Lua bridge") would inject OpenFUT club data directly into FIFA 23's
|
|
||||||
in-memory Frostbite DB tables at runtime, bypassing the entire backend/Blaze stack.
|
|
||||||
It is only viable for FUT (not just career mode) if FUT-specific tables — card
|
|
||||||
inventory, squad composition with FUT fields, coins — are accessible in memory when
|
|
||||||
the game is in the FUT area.
|
|
||||||
|
|
||||||
FUT data in online mode is fetched server-side from `fut.ea.com`. It is not known
|
|
||||||
whether FIFA 23 mirrors any of this into the Frostbite in-memory DB that FLE
|
|
||||||
can read/write. This test settles that question directly.
|
|
||||||
|
|
||||||
## Test procedure
|
|
||||||
|
|
||||||
**Prerequisites:**
|
|
||||||
- FIFA 23 launched normally via umu-run/Steam
|
|
||||||
- FLE (FIFA Live Editor) injected and active (normal launch path)
|
|
||||||
- EAAC in offline/neutralized state
|
|
||||||
- Game navigated as deep into FUT as possible (FUT hub if reachable; otherwise the
|
|
||||||
furthest FUT screen before the gate blocks it)
|
|
||||||
|
|
||||||
**Run the exporter:**
|
|
||||||
1. In FLE's Lua Engine, open and run `tools/squad-exporter/export_squad.lua`
|
|
||||||
(full path on the Windows side: `C:\<game>\openfut_squad_export.json`)
|
|
||||||
2. Wait for the MessageBox "Done! N players, M teams." or "ERROR writing..."
|
|
||||||
3. Retrieve the output file from the Wine prefix:
|
|
||||||
`~/Games/umu/fifa23-tools/drive_c/FIFA 23 Live Editor/openfut_squad_export.json`
|
|
||||||
(or wherever `C:\FIFA 23 Live Editor\` maps in the active prefix)
|
|
||||||
|
|
||||||
**What to inspect in the output:**
|
|
||||||
- `all_db_tables` array — the complete list of table names visible to FLE right now
|
|
||||||
- `fut_tables` object — any table whose name contains `fut`, `club`, `pack`, `item`, or
|
|
||||||
`market` (the script auto-extracts these)
|
|
||||||
- `is_career_mode` — confirms whether FUT or career mode was active
|
|
||||||
|
|
||||||
## Classification criteria
|
|
||||||
|
|
||||||
### "FUT tables present"
|
|
||||||
|
|
||||||
`fut_tables` is non-empty AND contains FUT-specific fields beyond base player stats:
|
|
||||||
- e.g., `fut_items` with card-type / rating / chemistry fields
|
|
||||||
- e.g., a squad table with FUT formation / chemistry / loan-flag fields
|
|
||||||
- e.g., a coins or points balance field
|
|
||||||
|
|
||||||
**Verdict:** Track C is viable for FUT. Fastest path to user-visible results.
|
|
||||||
|
|
||||||
### "only base player tables"
|
|
||||||
|
|
||||||
`fut_tables` is empty (no `fut_*` / `club_*` / `item_*` / `market_*` table names found
|
|
||||||
in `all_db_tables`), OR those tables exist but contain only base player attributes
|
|
||||||
(OVR, potential, position, pace, …) — the same fields visible in career mode.
|
|
||||||
|
|
||||||
**Verdict:** Track C cannot produce FUT. It could at most provide a custom Kick-Off or
|
|
||||||
career-mode match with players sourced from OpenFUT Core. FUT items and coins exist
|
|
||||||
only on EA's servers (not in the in-memory DB in offline mode).
|
|
||||||
|
|
||||||
### "FUT area unreachable to test"
|
|
||||||
|
|
||||||
The connection gate blocked entering FUT deeply enough for FUT tables to be populated.
|
|
||||||
Record which tables were visible and at what screen the test was run.
|
|
||||||
|
|
||||||
**Verdict:** Retest after M2 is unblocked, OR test with `TLS_ENABLED=false` bridge
|
|
||||||
handling the entry check stub.
|
|
||||||
|
|
||||||
## Results
|
|
||||||
|
|
||||||
*(To be filled in after the test is run.)*
|
|
||||||
|
|
||||||
| Field | Value |
|
|
||||||
|---|---|
|
|
||||||
| Date run | — |
|
|
||||||
| FIFA screen at test time | — |
|
|
||||||
| `is_career_mode` | — |
|
|
||||||
| Total tables in `all_db_tables` | — |
|
|
||||||
| FUT-specific table names found | — |
|
|
||||||
| Key FUT fields present | — |
|
|
||||||
| **Classification** | **PENDING** |
|
|
||||||
|
|
||||||
## Honest prior
|
|
||||||
|
|
||||||
`fut-integration-options.md` rates this as the recommended path and lists `fut_clubs`,
|
|
||||||
`fut_items`, `fut_squads` as "expected" tables. However those expectations are based on
|
|
||||||
analogy with career mode (which does store club/squad in the DB). FUT's data model is
|
|
||||||
architecturally different — it is account-bound server-side. The expectation may be
|
|
||||||
wrong. This test is the oracle.
|
|
||||||
|
|
||||||
The `export_squad.lua` script checks `GetDBTablesNames()` exhaustively (not just
|
|
||||||
assumed names), so it will surface any FUT tables that actually exist, regardless of
|
|
||||||
what name they use.
|
|
||||||
+1
-1
Submodule fifa-blaze updated: eccd46f52b...f4f33969f2
@@ -0,0 +1,16 @@
|
|||||||
|
# Keep the authoritative-tree build context lean: only tools/ and data/ runtime
|
||||||
|
# files (plus the Dockerfile's own entrypoint/manifest) are needed in-image.
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
artifacts
|
||||||
|
captures
|
||||||
|
futmem
|
||||||
|
staging
|
||||||
|
docs
|
||||||
|
FUT-RUNBOOK.md
|
||||||
|
README.md
|
||||||
|
data/memdump
|
||||||
|
**/__pycache__
|
||||||
|
*.pyc
|
||||||
|
*.pem
|
||||||
|
*.key
|
||||||
@@ -9,4 +9,5 @@
|
|||||||
*.log
|
*.log
|
||||||
__pycache__/
|
__pycache__/
|
||||||
captures/
|
captures/
|
||||||
|
staging/
|
||||||
tools/fifa17_profile.json
|
tools/fifa17_profile.json
|
||||||
|
|||||||
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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
state/
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# Copy to .env in this directory. Required for remote deployment.
|
||||||
|
#
|
||||||
|
# OPENFUT_ADVERTISE — the IP address of THIS host as seen from the game machine
|
||||||
|
# (105). Responders advertise it for Blaze, UTAS, telemetry, and QoS.
|
||||||
|
OPENFUT_ADVERTISE=203.0.113.10 # <- REPLACE with this host's LAN IP
|
||||||
|
|
||||||
|
# OPENFUT_BIND — address the listeners bind inside the container.
|
||||||
|
# Defaults to 0.0.0.0 (container-facing); the original all-on-localhost flow
|
||||||
|
# uses the loopback default baked into the responders when unset.
|
||||||
|
OPENFUT_BIND=0.0.0.0
|
||||||
|
|
||||||
|
# FIFA17's roster verifier accepts dNSName SANs but ignores iPAddress SANs.
|
||||||
|
# Advertise the certificate's DNS identity, then resolve that one hostname to
|
||||||
|
# OPENFUT_ADVERTISE on the client without changing the URL or certificate.
|
||||||
|
OPENFUT_ROSTER_HOST=winter15.gosredirector.ea.com:8081
|
||||||
|
|
||||||
|
# OPENFUT_SERVERS — which Python responders Docker runs (space/comma separated).
|
||||||
|
# Default (unset) = the server-side set: "blaze roster utas pow".
|
||||||
|
#
|
||||||
|
# This host is the SERVER (.120). Docker runs ONLY components that have NOT been
|
||||||
|
# migrated to a Rust host. During migration the Rust hosts (redirector / roster
|
||||||
|
# / utas) run OUTSIDE Docker; as each Python component is replaced, remove its
|
||||||
|
# name here so the two never serve the same role at once.
|
||||||
|
# blaze Blaze redirector + main + nucleus (bundled) :42127 :42130 :42131
|
||||||
|
# roster FUT roster-update XML :8081
|
||||||
|
# utas FUT/UTAS RS4 API :8099
|
||||||
|
# (Rust utas-host still proxies its non-/club routes here for now)
|
||||||
|
# pow POW / EASFC :8094 (+ content :8080)
|
||||||
|
# lsx Origin LSX bootstrap :4216
|
||||||
|
# CLIENT-SIDE: LSX runs on the game machine (.105) with autopatch, NOT
|
||||||
|
# on this server. Leave it OUT unless client and server share one box.
|
||||||
|
#
|
||||||
|
# Example — Rust already owns roster, so Docker should not also serve it:
|
||||||
|
# OPENFUT_SERVERS=blaze utas pow
|
||||||
|
# When you drop a component, also stop advertising / DNAT'ing its port to this
|
||||||
|
# container so the client is routed to the Rust host instead.
|
||||||
|
#OPENFUT_SERVERS=blaze roster utas pow
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# OpenFUT FIFA-17 FUT backend — Python migration deployment.
|
||||||
|
#
|
||||||
|
# Runs the 5 network responders (LSX / Blaze / roster / UTAS / POW) that FIFA 17
|
||||||
|
# dials to reach the FUT hub. Pure-Python; the only third-party dep is
|
||||||
|
# pycryptodome (LSX AES handshake). autopatch.py is intentionally NOT run here —
|
||||||
|
# it patches the game process memory and belongs on the client (105).
|
||||||
|
#
|
||||||
|
# Build context is fifa17-recon/ (the repo tree). tools/ is the AUTHORITATIVE
|
||||||
|
# recon tree (fifa17-recon/tools/). Only the runtime file set listed in
|
||||||
|
# docker/fifa17-python/runtime-tools.list is installed into /app/tools, so the
|
||||||
|
# deployed manifest stays byte-identical to the frozen baseline image
|
||||||
|
# openfut-fut-backend:python-baseline-2026-08-10 (see docs/BASELINE-*.md) while
|
||||||
|
# recon scripts, ghidra_queries and docs stay out of the image. data/ comes
|
||||||
|
# from the authoritative fifa17-recon/data. A SHA256SUMS.txt is baked into the
|
||||||
|
# image so any running backend can be matched to the exact dataset it was built
|
||||||
|
# from.
|
||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
RUN pip install --no-cache-dir pycryptodome==3.20.0
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Stage the authoritative tools tree in full...
|
||||||
|
COPY tools/ /app/tools-full/
|
||||||
|
|
||||||
|
# ...then install ONLY the runtime manifest (baseline image minus the two
|
||||||
|
# git-ignored certs, which are regenerated below).
|
||||||
|
COPY docker/fifa17-python/runtime-tools.list /app/runtime-tools.list
|
||||||
|
RUN set -eu; \
|
||||||
|
mkdir -p /app/tools; \
|
||||||
|
while IFS= read -r f; do \
|
||||||
|
[ -n "$f" ] || continue; \
|
||||||
|
mkdir -p "/app/tools/$(dirname "$f")"; \
|
||||||
|
cp "/app/tools-full/$f" "/app/tools/$f"; \
|
||||||
|
done < /app/runtime-tools.list; \
|
||||||
|
rm -rf /app/tools-full
|
||||||
|
|
||||||
|
COPY data/ /app/data/
|
||||||
|
|
||||||
|
# Redirector/roster TLS certificate. FIFA17's roster verifier compares only
|
||||||
|
# dNSName SAN entries, so deployment advertises winter15.gosredirector.ea.com
|
||||||
|
# through OPENFUT_ROSTER_HOST and resolves that hostname on the client. The
|
||||||
|
# entrypoint validates this stable certificate; it never reissues it for an IP
|
||||||
|
# SAN that the verifier ignores.
|
||||||
|
#
|
||||||
|
# OpenSSL remains in the image both to create the git-ignored keypair on a fresh
|
||||||
|
# checkout and to validate the configured DNS identity at startup.
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends openssl && \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
RUN if [ ! -s tools/redir_cert.pem ] || [ ! -s tools/redir_key.pem ]; then \
|
||||||
|
openssl req -x509 -newkey rsa:2048 -nodes \
|
||||||
|
-keyout tools/redir_key.pem -out tools/redir_cert.pem \
|
||||||
|
-days 3650 -subj "/CN=winter15.gosredirector.ea.com" \
|
||||||
|
-addext "subjectAltName=DNS:winter15.gosredirector.ea.com,DNS:*.gosredirector.ea.com,DNS:*.ea.com,IP:127.0.0.1"; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Bake a dataset manifest so every image is self-identifying.
|
||||||
|
RUN find /app/tools /app/data -type f | LC_ALL=C sort | xargs sha256sum > /app/SHA256SUMS.txt
|
||||||
|
|
||||||
|
COPY docker/fifa17-python/entrypoint.sh /app/entrypoint.sh
|
||||||
|
RUN chmod +x /app/entrypoint.sh
|
||||||
|
|
||||||
|
# LSX 4216 | Blaze redir/main/nucleus 42127/42130/42131 | roster 8081 | UTAS 8099 | POW 8094/8080
|
||||||
|
EXPOSE 4216 42127 42130 42131 8081 8099 8094 8080
|
||||||
|
|
||||||
|
ENTRYPOINT ["/app/entrypoint.sh"]
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# ============================================================================
|
||||||
|
# OpenFUT FIFA-17 — CLIENT-side arming (runs on the GAME machine, e.g. 105).
|
||||||
|
#
|
||||||
|
# Companion to the dev container on the SERVER (120). The server runs the heavy
|
||||||
|
# responders (Blaze / UTAS / roster / POW). Two pieces are inherently local to
|
||||||
|
# the game and therefore stay here:
|
||||||
|
#
|
||||||
|
# * autopatch.py — patches FIFA17.exe process memory (ProtoSSL cert-verify).
|
||||||
|
# Must run where the game runs; cannot be containerised.
|
||||||
|
# * lsx_responder — the Origin/EADesktop emulator the game dials on the
|
||||||
|
# hardcoded loopback 127.0.0.1:4216. Loopback IPC can't be
|
||||||
|
# cleanly redirected to a remote host, so it lives here.
|
||||||
|
#
|
||||||
|
# Everything the game reaches by a routable address is redirected to the server:
|
||||||
|
# * winter15.gosredirector.ea.com (hardcoded EA IP 159.153.51.20) -> SERVER:42127
|
||||||
|
# * easw.easports.com (dead hardcoded UTAS host) -> SERVER (:8099)
|
||||||
|
#
|
||||||
|
# The server's responders were started with OPENFUT_ADVERTISE=<SERVER_IP>, so
|
||||||
|
# after these first redirected contacts the game is handed <SERVER_IP> for every
|
||||||
|
# later hop (Blaze main, roster, UTAS, telemetry) and dials the server directly.
|
||||||
|
#
|
||||||
|
# Usage: sudo OPENFUT_SERVER=203.0.113.10 ./client_arm.sh
|
||||||
|
# (re-run after every reboot; the sysctl/iptables state is volatile)
|
||||||
|
# ============================================================================
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SERVER="${OPENFUT_SERVER:?set OPENFUT_SERVER to the backend host IP, e.g. 203.0.113.10}"
|
||||||
|
GOS_EA_IP="159.153.51.20" # winter15.gosredirector.ea.com (hardcoded in FIFA17)
|
||||||
|
UTAS_HOST="easw.easports.com" # dead UTAS host baked into CardsDLL
|
||||||
|
UTAS_RE="${UTAS_HOST//./\\.}" # same, safe to embed in a regex
|
||||||
|
|
||||||
|
if [ "$(id -u)" -ne 0 ]; then
|
||||||
|
echo "!! must run as root (sudo). Re-run: sudo OPENFUT_SERVER=$SERVER $0" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[client_arm] backend server = $SERVER"
|
||||||
|
|
||||||
|
# 1) allow /proc/PID/mem writes (autopatch's ProtoSSL cert-verify patch)
|
||||||
|
sysctl -q kernel.yama.ptrace_scope=0
|
||||||
|
|
||||||
|
# 2) Redirect the hardcoded Blaze redirector IP to the server's redirector.
|
||||||
|
# (Replace any stale rule first so re-runs and IP changes are clean.)
|
||||||
|
while iptables -t nat -D OUTPUT -p tcp -d "$GOS_EA_IP" -j DNAT \
|
||||||
|
--to-destination "$SERVER:42127" 2>/dev/null; do :; done
|
||||||
|
iptables -t nat -A OUTPUT -p tcp -d "$GOS_EA_IP" -j DNAT --to-destination "$SERVER:42127"
|
||||||
|
|
||||||
|
# 2b) DNAT from OUTPUT to a REMOTE host needs a matching source-NAT on the way
|
||||||
|
# out, or the server's replies (from its own IP) won't match the game's
|
||||||
|
# conntrack entry. MASQUERADE the redirected flow so it is SNAT'd to this
|
||||||
|
# host's outbound IP. (Harmless duplicate-guarded like the DNAT above.)
|
||||||
|
while iptables -t nat -D POSTROUTING -p tcp -d "$SERVER" --dport 42127 \
|
||||||
|
-j MASQUERADE 2>/dev/null; do :; done
|
||||||
|
iptables -t nat -A POSTROUTING -p tcp -d "$SERVER" --dport 42127 -j MASQUERADE
|
||||||
|
|
||||||
|
# 3) Point the dead hardcoded UTAS host at the server. The port (8099) is carried
|
||||||
|
# in the game's own URL, so only the name needs redirecting. Remove any prior
|
||||||
|
# OpenFUT-managed line (loopback or other server) and write the current one.
|
||||||
|
sed -i "/[[:space:]]${UTAS_RE}\b.*# openfut\$/d" /etc/hosts
|
||||||
|
printf '%s\t%s\t# openfut\n' "$SERVER" "$UTAS_HOST" >> /etc/hosts
|
||||||
|
|
||||||
|
echo "[client_arm] --- armed ---"
|
||||||
|
sysctl kernel.yama.ptrace_scope
|
||||||
|
iptables -t nat -L OUTPUT -n | grep -i "$GOS_EA_IP" || echo " (DNAT missing!)"
|
||||||
|
|
||||||
|
# Verify the hosts entry by EFFECT, not by presence.
|
||||||
|
#
|
||||||
|
# glibc returns the FIRST match in /etc/hosts, so our line can be written
|
||||||
|
# correctly and still lose to an earlier one -- and the sed above only removes
|
||||||
|
# lines this script wrote (`# openfut`), so re-running never clears a foreign
|
||||||
|
# one. The old check here was `grep easw /etc/hosts && echo ok`, which passed on
|
||||||
|
# the shadowing line itself and reported success while resolution was wrong.
|
||||||
|
#
|
||||||
|
# Observed on 2026-08-11: a leftover `127.0.0.1 easw.easports.com` from the
|
||||||
|
# single-machine era shadowed the OpenFUT line, and every re-run said "ok".
|
||||||
|
resolved="$(getent ahosts "$UTAS_HOST" 2>/dev/null | awk '{print $1}' | sort -u | tr '\n' ' ')"
|
||||||
|
# SERVER may be a hostname, so compare address-to-address rather than comparing
|
||||||
|
# the literal string against resolved IPs (which would warn spuriously).
|
||||||
|
server_ips="$(getent ahosts "$SERVER" 2>/dev/null | awk '{print $1}' | sort -u)"
|
||||||
|
[ -n "$server_ips" ] || server_ips="$SERVER"
|
||||||
|
match=0
|
||||||
|
for ip in $server_ips; do
|
||||||
|
printf '%s' "$resolved" | grep -qw -- "$ip" && match=1
|
||||||
|
done
|
||||||
|
if [ "$match" -eq 1 ]; then
|
||||||
|
echo " /etc/hosts ok ($UTAS_HOST -> $resolved)"
|
||||||
|
else
|
||||||
|
echo
|
||||||
|
echo " !! WARNING: $UTAS_HOST resolves to [$resolved], not $SERVER."
|
||||||
|
echo " An earlier /etc/hosts line is shadowing the OpenFUT one:"
|
||||||
|
grep -nE "^[[:space:]]*[^#].*[[:space:]]${UTAS_RE}([[:space:]]|\$)" /etc/hosts \
|
||||||
|
| grep -v '# openfut$' | sed 's/^/ /' || true
|
||||||
|
echo
|
||||||
|
echo " Not fatal: the responders advertise $SERVER, so the game stops using"
|
||||||
|
echo " this name after the first hop. Worth removing the line above anyway."
|
||||||
|
echo " Lines are listed rather than deleted -- this script will not remove"
|
||||||
|
echo " /etc/hosts entries it did not write."
|
||||||
|
fi
|
||||||
|
echo
|
||||||
|
echo "[client_arm] Next: start the LOCAL pieces (LSX + autopatch) with client_local.sh,"
|
||||||
|
echo " ensure the container is up on $SERVER, then launch FIFA 17."
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# OpenFUT FIFA-17 FUT backend — declarative deployment (server side, runs on 120).
|
||||||
|
#
|
||||||
|
# cp .env.example .env # set OPENFUT_ADVERTISE to THIS host's LAN IP
|
||||||
|
# docker compose up -d --build
|
||||||
|
#
|
||||||
|
# Brings up the 5 responders the game dials. OPENFUT_ADVERTISE is the server IP
|
||||||
|
# handed out for Blaze, UTAS, telemetry, and QoS; OPENFUT_ROSTER_HOST is the
|
||||||
|
# certificate DNS identity handed out for roster HTTPS.
|
||||||
|
#
|
||||||
|
# The client (105) still needs its first-hop redirect (hook or DNAT) plus
|
||||||
|
# autopatch.py running locally; see client_arm.sh and the FIFARUNBOOK.
|
||||||
|
name: openfut-fut-backend
|
||||||
|
|
||||||
|
services:
|
||||||
|
fut-backend:
|
||||||
|
build:
|
||||||
|
context: ../..
|
||||||
|
dockerfile: docker/fifa17-python/Dockerfile
|
||||||
|
image: openfut-fut-backend:dev
|
||||||
|
container_name: openfut-fut-backend
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
# Bind all interfaces inside the container.
|
||||||
|
OPENFUT_BIND: "${OPENFUT_BIND:-0.0.0.0}"
|
||||||
|
# Address advertised to the client for the next hop. MUST be this host's
|
||||||
|
# LAN IP as seen from the game machine (105). Required (see .env.example).
|
||||||
|
OPENFUT_ADVERTISE: "${OPENFUT_ADVERTISE:?set OPENFUT_ADVERTISE in .env to this host's LAN IP, e.g. 203.0.113.10}"
|
||||||
|
# FIFA17 roster TLS matches only certificate dNSName SANs. The client must
|
||||||
|
# resolve this hostname to OPENFUT_ADVERTISE.
|
||||||
|
OPENFUT_ROSTER_HOST: "${OPENFUT_ROSTER_HOST:-winter15.gosredirector.ea.com:8081}"
|
||||||
|
# POW content advertises port 8080 by default, which collides with the
|
||||||
|
# openfut-core publish on this host. Remap it to 8085 on the host and
|
||||||
|
# advertise the remapped endpoint.
|
||||||
|
POW_CONTENT_ADDR: "0.0.0.0:8080"
|
||||||
|
POW_CONTENT_HOST: "${OPENFUT_ADVERTISE}:8085"
|
||||||
|
# Launcher-selected EA/Origin identity shared by LSX, Blaze, POW and UTAS.
|
||||||
|
# FUT saves are isolated by persona beneath /state/accounts.
|
||||||
|
FUT_ACCOUNT_PATH: "/state/active_account.json"
|
||||||
|
FUT_PROFILE_ROOT: "/state/accounts"
|
||||||
|
FUT_SETTINGS: "off"
|
||||||
|
FUT_MODES: "1"
|
||||||
|
# Which Python responders this SERVER runs. Default excludes lsx (that is
|
||||||
|
# a client-side responder — see below). Drop a name once it is migrated to
|
||||||
|
# a Rust host (run outside Docker) so the two never overlap. See .env.example.
|
||||||
|
OPENFUT_SERVERS: "${OPENFUT_SERVERS:-blaze roster utas pow}"
|
||||||
|
volumes:
|
||||||
|
- "../state:/state"
|
||||||
|
ports:
|
||||||
|
- "4216:4216" # LSX — CLIENT-SIDE (.105); only used if lsx is enabled for all-on-one-box
|
||||||
|
- "42127:42127" # Blaze redirector (TLS)
|
||||||
|
- "42130:42130" # Blaze main
|
||||||
|
- "42131:42131" # Nucleus OAuth stub
|
||||||
|
- "8081:8081" # FUT roster XML (HTTPS)
|
||||||
|
- "8099:8099" # UTAS / RS4 FUT REST API
|
||||||
|
- "8094:8094" # POW / EASFC API
|
||||||
|
- "8085:8080" # POW content (host 8085 -> container 8080; avoids core:8080)
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# ============================================================================
|
||||||
|
# OpenFUT FIFA-17 FUT backend — in-CONTAINER orchestrator.
|
||||||
|
#
|
||||||
|
# Runs the 5 network responders that the game dials. Unlike the host-based
|
||||||
|
# openfut-fut.sh, this does NO host arming (no pkexec / iptables / /etc/hosts /
|
||||||
|
# ptrace) — those are client-side concerns handled on the game machine (105).
|
||||||
|
# autopatch.py is NOT run here: it patches the FIFA17.exe process memory and must
|
||||||
|
# run on the box the game runs on.
|
||||||
|
#
|
||||||
|
# Address behaviour is driven by three env vars (see each responder):
|
||||||
|
# OPENFUT_BIND bind address for every listener (container: 0.0.0.0)
|
||||||
|
# OPENFUT_ADVERTISE IP address handed out for Blaze, UTAS, telemetry, and QoS
|
||||||
|
# OPENFUT_ROSTER_HOST certificate DNS host:port handed out for roster HTTPS
|
||||||
|
# ============================================================================
|
||||||
|
set -uo pipefail
|
||||||
|
cd "$(dirname "$(readlink -f "$0")")/tools"
|
||||||
|
|
||||||
|
BIND="${OPENFUT_BIND:-0.0.0.0}"
|
||||||
|
ADV="${OPENFUT_ADVERTISE:?OPENFUT_ADVERTISE must be set to the server LAN IP (e.g. 203.0.113.10)}"
|
||||||
|
ROSTER_HOST="${OPENFUT_ROSTER_HOST:-winter15.gosredirector.ea.com:8081}"
|
||||||
|
export OPENFUT_BIND="$BIND"
|
||||||
|
export OPENFUT_ADVERTISE="$ADV"
|
||||||
|
export OPENFUT_ROSTER_HOST="$ROSTER_HOST"
|
||||||
|
# POW keys advertised by blaze must also point at the server, not loopback.
|
||||||
|
export POW_HOST="${POW_HOST:-$ADV:8094}"
|
||||||
|
export POW_CONTENT_HOST="${POW_CONTENT_HOST:-$ADV:8080}"
|
||||||
|
export POW_ADDR="${POW_ADDR:-$BIND:8094}"
|
||||||
|
export POW_CONTENT_ADDR="${POW_CONTENT_ADDR:-$BIND:8080}"
|
||||||
|
|
||||||
|
echo "[openfut] bind=$BIND advertise=$ADV roster=$ROSTER_HOST"
|
||||||
|
|
||||||
|
# FIFA17's roster verifier compares only dNSName SAN entries. It ignores a valid
|
||||||
|
# iPAddress SAN when the advertised URL contains an IP literal, so certificate
|
||||||
|
# regeneration cannot fix that URL. Keep the certificate stable and fail startup
|
||||||
|
# if the configured roster hostname is not already one of its DNS identities.
|
||||||
|
CERT=redir_cert.pem
|
||||||
|
ROSTER_NAME="${ROSTER_HOST%%:*}"
|
||||||
|
if ! openssl x509 -in "$CERT" -noout -checkhost "$ROSTER_NAME" >/dev/null 2>&1; then
|
||||||
|
echo "[openfut] FATAL: TLS cert does not cover roster hostname $ROSTER_NAME" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "[openfut] roster certificate matches $ROSTER_NAME; fingerprint: $(openssl x509 -in "$CERT" -noout -fingerprint -sha256)"
|
||||||
|
|
||||||
|
# name script extra-env
|
||||||
|
declare -a SERVERS=(
|
||||||
|
"lsx|lsx_responder_v2.py|OPENFUT_LSX_EVENT_COUNT=100000"
|
||||||
|
"blaze|blaze_responder_v3b.py|-"
|
||||||
|
"roster|roster_server.py|-"
|
||||||
|
"utas|utas_server.py|FUT_TRADING=1 FUT_PILESIZES=1 FUT_TRADEABLE=1 FUT_DISCARD_TABLE=1 FUT_DISCARD_SEND=1"
|
||||||
|
"pow|pow_server.py|-"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Component selection ──────────────────────────────────────────────────────
|
||||||
|
# OPENFUT_SERVERS picks which Python responders run (space- or comma-separated).
|
||||||
|
# This container is the SERVER side (.120). It serves ONLY components that have
|
||||||
|
# NOT been migrated to a Rust host — as each moves to Rust (which runs OUTSIDE
|
||||||
|
# Docker during migration), drop its name so the two never serve the same role.
|
||||||
|
# blaze Blaze redirector + main + nucleus (bundled) :42127 :42130 :42131
|
||||||
|
# roster FUT roster-update XML :8081
|
||||||
|
# utas FUT/UTAS RS4 API :8099
|
||||||
|
# (the Rust utas-host currently reverse-proxies its non-/club routes
|
||||||
|
# back here, so keep this enabled until UTAS is fully migrated)
|
||||||
|
# pow POW / EASFC :8094 (+ content :8080)
|
||||||
|
# lsx Origin LSX bootstrap :4216
|
||||||
|
# CLIENT-SIDE — LSX runs on the game machine (.105) with autopatch,
|
||||||
|
# NOT on the server. Excluded by default; enable ONLY for an
|
||||||
|
# all-on-one-box dev setup where client and server share a host.
|
||||||
|
OPENFUT_SERVERS="${OPENFUT_SERVERS:-blaze roster utas pow}"
|
||||||
|
want=" ${OPENFUT_SERVERS//,/ } "
|
||||||
|
known=" lsx blaze roster utas pow "
|
||||||
|
for w in $want; do
|
||||||
|
case "$known" in
|
||||||
|
*" $w "*) ;;
|
||||||
|
*) echo "[openfut] unknown component '$w' in OPENFUT_SERVERS (valid: lsx blaze roster utas pow)" >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
echo "[openfut] servers=$OPENFUT_SERVERS"
|
||||||
|
|
||||||
|
pids=()
|
||||||
|
names=()
|
||||||
|
for entry in "${SERVERS[@]}"; do
|
||||||
|
IFS='|' read -r name script env <<<"$entry"
|
||||||
|
case "$want" in
|
||||||
|
*" $name "*) ;;
|
||||||
|
*) echo "[openfut] skipping $name (not in OPENFUT_SERVERS)"; continue ;;
|
||||||
|
esac
|
||||||
|
envprefix=""; [ "$env" != "-" ] && envprefix="env $env"
|
||||||
|
echo "[openfut] starting $name ($script)"
|
||||||
|
# shellcheck disable=SC2086
|
||||||
|
$envprefix python3 -u "$script" &
|
||||||
|
pids+=($!)
|
||||||
|
names+=("$name")
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "${#pids[@]}" -eq 0 ]; then
|
||||||
|
echo "[openfut] OPENFUT_SERVERS selected no components; nothing to run" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Propagate SIGTERM/SIGINT to children so `docker stop` is clean.
|
||||||
|
term() {
|
||||||
|
echo "[openfut] shutting down…"
|
||||||
|
for p in "${pids[@]}"; do kill "$p" 2>/dev/null || true; done
|
||||||
|
wait
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
trap term TERM INT
|
||||||
|
|
||||||
|
# If ANY responder dies, take the whole container down so the failure is visible
|
||||||
|
# (they all bind ports the game needs — a partial stack is a broken stack).
|
||||||
|
while true; do
|
||||||
|
for i in "${!pids[@]}"; do
|
||||||
|
if ! kill -0 "${pids[$i]}" 2>/dev/null; then
|
||||||
|
echo "[openfut] responder ${names[$i]} (pid ${pids[$i]}) exited - bringing container down"
|
||||||
|
term
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
origin_login_probe.py
|
||||||
|
card_proof.py
|
||||||
|
force_login_flag.py
|
||||||
|
card_record_poke.py
|
||||||
|
test_tournament_contract.py
|
||||||
|
dmp_stack.py
|
||||||
|
fut_clubitems.py
|
||||||
|
test_autopatch_logging.py
|
||||||
|
capture_lsx.py
|
||||||
|
roster_server.py
|
||||||
|
autopatch.py
|
||||||
|
dbschema_probe.py
|
||||||
|
test_account_profiles.py
|
||||||
|
coach_window.py
|
||||||
|
watch_club_model.py
|
||||||
|
db_dump.py
|
||||||
|
coach_probe.py
|
||||||
|
uidiff.py
|
||||||
|
probe_club_stats.py
|
||||||
|
blaze_responder_v3.py
|
||||||
|
dbdata_extract.py
|
||||||
|
decode_fire2.py
|
||||||
|
check_club_stat_vocab.py
|
||||||
|
fut_accounts.py
|
||||||
|
strip_dead_cards.py
|
||||||
|
test_hub_offline_season_contract.py
|
||||||
|
repair_club.py
|
||||||
|
forge_node.py
|
||||||
|
verify_preauth.py
|
||||||
|
fut_coaches.py
|
||||||
|
heat2.py
|
||||||
|
test_security_question.py
|
||||||
|
test_utas_log_redaction.py
|
||||||
|
sbc_populate_poke.py
|
||||||
|
atomdump.py
|
||||||
|
lsx_responder.py
|
||||||
|
fut_staff.py
|
||||||
|
fut_cards.py
|
||||||
|
blaze_responder.py
|
||||||
|
blaze_responder_v2.py
|
||||||
|
fut_store.py
|
||||||
|
blaze_responder_v3b.py
|
||||||
|
test_fut_contract.py
|
||||||
|
utas_server.py
|
||||||
|
lsx_force_online.py
|
||||||
|
grab_crash_code.py
|
||||||
|
gate_byte_probe.py
|
||||||
|
fut_admin.py
|
||||||
|
test_match_rewards.py
|
||||||
|
lsx_responder_v2.py
|
||||||
|
card_identity_probe.py
|
||||||
|
extract_player_ids.py
|
||||||
|
watch_online_mode.py
|
||||||
|
store_enable_poke.py
|
||||||
|
pow_server.py
|
||||||
|
fut_account.py
|
||||||
|
blaze_responder_v3_patched.py
|
||||||
|
check_settings_flags.py
|
||||||
|
test_match_lifecycle.py
|
||||||
|
sbc_hook_poke.py
|
||||||
|
futlog.py
|
||||||
|
fut_seed.py
|
||||||
|
hub_counter_probe.py
|
||||||
|
fut_consumables.py
|
||||||
|
db_catalog_walk.py
|
||||||
|
memtool.py
|
||||||
|
build_player_facts.py
|
||||||
|
sweep_collect.py
|
||||||
|
test_card_families.py
|
||||||
|
fut_club_stats.py
|
||||||
|
dmp.py
|
||||||
|
build_consumables.py
|
||||||
|
test_market_buy.py
|
||||||
|
dump_login_code.py
|
||||||
|
auth_watch.py
|
||||||
|
vgamepad.py
|
||||||
|
ghidra_env.py
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
# Python backend baseline — 2026-08-10
|
||||||
|
|
||||||
|
Frozen rollback target for the working offline FUT backend (Python migration) as
|
||||||
|
it ran on 10.10.0.120. Everything here was recorded from the live system before
|
||||||
|
any cleanup/restructure; the image and state are archived in
|
||||||
|
`/home/alex/OpenFUT/docker-backups/`.
|
||||||
|
|
||||||
|
## Frozen image
|
||||||
|
|
||||||
|
| field | value |
|
||||||
|
|------------|-------|
|
||||||
|
| tag | `openfut-fut-backend:python-baseline-2026-08-10` |
|
||||||
|
| image id | `e1f93ad647ab` |
|
||||||
|
| digest | `sha256:e1f93ad647abbec32e2751f3e88fed75d3e574d4500395b21c31d0f0b96abac6` |
|
||||||
|
| created | 2026-08-10T02:14:56Z (built as `openfut-fut-backend:dev`) |
|
||||||
|
| size | 278 MB |
|
||||||
|
| archive | `docker-backups/openfut-fut-backend-python-baseline-2026-08-10.tar.gz` (53 MB, `docker save \| gzip -1`) |
|
||||||
|
|
||||||
|
## Frozen container
|
||||||
|
|
||||||
|
| field | value |
|
||||||
|
|------------|-------|
|
||||||
|
| id | `f16d3204cbf48151be232ff8f4194b429e311042f8f6f95644760d4b8eba2938` |
|
||||||
|
| created | 2026-08-10T02:14:56.194470252Z |
|
||||||
|
| image | `openfut-fut-backend:dev` (= baseline image id) |
|
||||||
|
| restart | `unless-stopped` |
|
||||||
|
| network | `docker_default`, IP `172.19.0.2`, aliases `openfut-fut-backend`, `fut-backend` |
|
||||||
|
| log | json-file |
|
||||||
|
| inspect | `docker-backups/openfut-fut-backend-container-inspect-2026-08-10.json` |
|
||||||
|
|
||||||
|
### Environment (Config.Env)
|
||||||
|
|
||||||
|
```
|
||||||
|
FUT_SETTINGS=off
|
||||||
|
FUT_MODES=1
|
||||||
|
OPENFUT_BIND=0.0.0.0
|
||||||
|
OPENFUT_ADVERTISE=10.10.0.120
|
||||||
|
POW_CONTENT_ADDR=0.0.0.0:8080
|
||||||
|
POW_CONTENT_HOST=10.10.0.120:8085
|
||||||
|
FUT_ACCOUNT_PATH=/state/active_account.json
|
||||||
|
FUT_PROFILE_ROOT=/state/accounts
|
||||||
|
PYTHON_VERSION=3.12.13 (python:3.12-slim base)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Volumes / mounts
|
||||||
|
|
||||||
|
Bind mount `docker/state` (host) -> `/state` (container, rw). Runtime state:
|
||||||
|
`active_account.json` (active persona) + `accounts/` (FUT saves by persona).
|
||||||
|
Snapshot: `docker-backups/state-2026-08-10/`.
|
||||||
|
|
||||||
|
### Ports (host -> container)
|
||||||
|
|
||||||
|
| host | container | service |
|
||||||
|
|------|-----------|---------|
|
||||||
|
| 4216 | 4216 | LSX (Origin bootstrap) |
|
||||||
|
| 42127 | 42127 | Blaze redirector (TLS) |
|
||||||
|
| 42130 | 42130 | Blaze main |
|
||||||
|
| 42131 | 42131 | Nucleus OAuth stub |
|
||||||
|
| 8081 | 8081 | FUT roster XML (HTTPS) |
|
||||||
|
| 8099 | 8099 | UTAS / RS4 FUT REST API |
|
||||||
|
| 8094 | 8094 | POW / EASFC API |
|
||||||
|
| 8085 | 8080 | POW content (remapped to avoid openfut-core:8080) |
|
||||||
|
|
||||||
|
All listeners verified bound on `0.0.0.0` in the container (LSX/Blaze/nucleus,
|
||||||
|
roster, UTAS, POW, POW content).
|
||||||
|
|
||||||
|
## Dataset manifest
|
||||||
|
|
||||||
|
`docker-backups/SHA256SUMS-container-baseline-2026-08-10.txt` — sha256 of all
|
||||||
|
323 files under `/app/tools` + `/app/data` inside the running container.
|
||||||
|
|
||||||
|
`fifa17-python/tools/` and `fifa17-python/data/` are the staged sources that
|
||||||
|
built this image (verified byte-identical to the container copies at freeze
|
||||||
|
time). Images rebuilt from git now bake their own `/app/SHA256SUMS.txt`; the
|
||||||
|
rebuild-equivalence check is `diff` between that and this manifest; the only expected deltas are pycache files (not committed) and the redir cert pair (regenerated per build).
|
||||||
|
|
||||||
|
## Restore
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# From the archived image (works offline, exact layers):
|
||||||
|
docker load -i /home/alex/OpenFUT/docker-backups/openfut-fut-backend-python-baseline-2026-08-10.tar.gz
|
||||||
|
docker tag openfut-fut-backend:python-baseline-2026-08-10 openfut-fut-backend:dev
|
||||||
|
|
||||||
|
# Or rebuild from git:
|
||||||
|
cd /home/alex/OpenFUT/fifa17-recon/docker/fifa17-python
|
||||||
|
cp .env.example .env # set OPENFUT_ADVERTISE
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Status at freeze time
|
||||||
|
|
||||||
|
- The 2026-08-10 `openfut-fut-backend` container was **left running untouched**
|
||||||
|
(the .105 launcher audit uses it). No rebuild/replacement happens until that
|
||||||
|
audit finishes; the frozen image is the rollback target if cleanup breaks it.
|
||||||
|
- `docker/state` was **not** moved during restructure (bind path must not change
|
||||||
|
while the container is live); the new compose mounts `../state` from the same
|
||||||
|
location.
|
||||||
|
- TURN/relay re-addressing (multiplayer) and long-tail endpoints (weather,
|
||||||
|
matchday, kit assets) are deferred feature gaps — tracked separately.
|
||||||
|
|
||||||
|
## Running state vs image — what the frozen image does NOT contain
|
||||||
|
|
||||||
|
The baseline image (`python-baseline-2026-08-10` / `dev`) was built at 02:14Z,
|
||||||
|
but the container's `/app` was hot-patched afterwards:
|
||||||
|
|
||||||
|
* `tools/utas_server.py` — gained the `FUT_MODES`-gated `offlineSeason` block in
|
||||||
|
GetHubData's club response (keeps the hub's offline-season summary valid).
|
||||||
|
* `tools/test_hub_offline_season_contract.py` — added to `/app/tools`.
|
||||||
|
|
||||||
|
`docker save` captures the image, not the container's writable layer, so the
|
||||||
|
baseline tar.gz lacks those two changes. Two paths cover the exact runtime:
|
||||||
|
|
||||||
|
* `openfut-fut-backend:python-running-2026-08-10` — `docker commit` of the
|
||||||
|
running container (sha256:093a98fa0496...), the exact runtime FS.
|
||||||
|
* The committed `fifa17-python/tools` + `data` — synced to match the running
|
||||||
|
container byte-for-byte (237 files verified, incl. the redir cert pair), so a
|
||||||
|
fresh build reproduces the actual running backend. Proven by rebuilding from
|
||||||
|
the committed sources and diffing the baked `/app/SHA256SUMS.txt` against the
|
||||||
|
container manifest: identical.
|
||||||
|
|
||||||
|
Archive: `docker-backups/openfut-fut-backend-python-running-2026-08-10.tar.gz`.
|
||||||
@@ -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).
|
||||||
@@ -215,7 +215,11 @@ Path template `%s = "game/fifa17"`. Methods inferred from struct verb + endpoint
|
|||||||
### Freeze-risk summary (type fidelity is mandatory)
|
### Freeze-risk summary (type fidelity is mandatory)
|
||||||
- `auctionInfo` → **array** (never object/scalar).
|
- `auctionInfo` → **array** (never object/scalar).
|
||||||
- `itemData` inside each record → **object** (the card; reuse `item_def`).
|
- `itemData` inside each record → **object** (the card; reuse `item_def`).
|
||||||
- `duplicateItemIdList` → **array**.
|
- `duplicateItemIdList` → **array of objects** (element deser `0x180138e10`: `itemId` 0x16d,
|
||||||
|
`duplicateItemId` 0xeb, `itemLoans` 0x16f, `duplicateItemLoans` 0xed). Not an int list.
|
||||||
|
`[]` is safe; a list of bare ints is a freeze. Control that this is not a misread:
|
||||||
|
`dreamSquads` 0xe9 in FutMoveCard genuinely IS a bare int array, parsed by a
|
||||||
|
`while (tok != 0xd)` loop calling the int getter with no inner object loop.
|
||||||
- `bidState`, `tradeState`, `sellerName` → **strings**.
|
- `bidState`, `tradeState`, `sellerName` → **strings**.
|
||||||
- `credits`, `total`, `count`, `*Price`, `*Bid`, `expires`, `tradeId` → **numbers**.
|
- `credits`, `total`, `count`, `*Price`, `*Bid`, `expires`, `tradeId` → **numbers**.
|
||||||
- `watched` → **bool**.
|
- `watched` → **bool**.
|
||||||
@@ -420,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.**
|
||||||
@@ -916,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
|
||||||
@@ -966,7 +1069,11 @@ Notes:
|
|||||||
{ "itemData": [ /* the single updated card item */ ] }
|
{ "itemData": [ /* the single updated card item */ ] }
|
||||||
|
|
||||||
// 8 DiscardCard — DELETE ut/delete/game/fifa17/item
|
// 8 DiscardCard — DELETE ut/delete/game/fifa17/item
|
||||||
{ "items": [ 123456789 ], "totalCredits": 15000, "id": 123456789 }
|
// CORRECTED 2026-08-05: `items` is an array of OBJECTS and there is no top-level `id`.
|
||||||
|
// The previous shape, { "items": [ 123456789 ], ..., "id": 123456789 }, was wrong twice
|
||||||
|
// over, and feeding a bare int where the element parser expects an object is a tokenizer
|
||||||
|
// desync, i.e. a hard freeze at 0x1801c7f1a, not a soft failure.
|
||||||
|
{ "items": [ { "id": 123456789 } ], "totalCredits": 15000 }
|
||||||
|
|
||||||
// 9 DiscardCardByRes — DELETE ut/delete/game/fifa17/item
|
// 9 DiscardCardByRes — DELETE ut/delete/game/fifa17/item
|
||||||
{ "totalCredits": 15000 }
|
{ "totalCredits": 15000 }
|
||||||
@@ -1042,7 +1149,7 @@ desyncs the SAX reader → tokenizer freeze at `0x1801c7f1a`.
|
|||||||
| `displayGroup` | 0xd9 | **ARRAY** | nested (freeze-risk) |
|
| `displayGroup` | 0xd9 | **ARRAY** | nested (freeze-risk) |
|
||||||
| `displayGroupAssetId` | 0xda | INT | `[rbp-0x80]` |
|
| `displayGroupAssetId` | 0xda | INT | `[rbp-0x80]` |
|
||||||
| `displayGroupUseDefaultImage` | 0xdb | BOOL | |
|
| `displayGroupUseDefaultImage` | 0xdb | BOOL | |
|
||||||
| `currencies` | 0xc5 | **ARRAY** | coin price: `[{name,funds,finalFunds}]` (freeze-risk) |
|
| `currencies` | 0xc5 | **ARRAY** | coin price: `[{name,funds,finalFunds}]` (freeze-risk). **`finalFunds` is the number the tile RENDERS. CONFIRMED LIVE 2026-08-05** by serving `funds=15000, finalFunds=4321` on one pack and reading `4,321` off the store tile. `funds` is not displayed. |
|
||||||
| `extPrice` | 0x119 | **OBJECT** | → `finalPrice`(0x125,obj `0x180139070`) + `originalPrice`(0x205,obj `0x18013aae0`); inner uses `amount`(0x1b)/`currency`(0xc4) (freeze-risk) |
|
| `extPrice` | 0x119 | **OBJECT** | → `finalPrice`(0x125,obj `0x180139070`) + `originalPrice`(0x205,obj `0x18013aae0`); inner uses `amount`(0x1b)/`currency`(0xc4) (freeze-risk) |
|
||||||
| `packContentInfo` | 0x20c | **OBJECT** | → `bronzeQuantity`(0x63), `silverQuantity`(0x2c6), `goldQuantity`(0x149), `rareQuantity`(0x273), `itemQuantity`(0x170), `start`(0x2e3), `unopened`(0x35d,bool) (freeze-risk) |
|
| `packContentInfo` | 0x20c | **OBJECT** | → `bronzeQuantity`(0x63), `silverQuantity`(0x2c6), `goldQuantity`(0x149), `rareQuantity`(0x273), `itemQuantity`(0x170), `start`(0x2e3), `unopened`(0x35d,bool) (freeze-risk) |
|
||||||
| `sortPriority` | 0x2cb | INT | |
|
| `sortPriority` | 0x2cb | INT | |
|
||||||
@@ -1092,7 +1199,7 @@ desyncs the SAX reader → tokenizer freeze at `0x1801c7f1a`.
|
|||||||
| `itemList` | 0x16e | **ARRAY** of items (element deser `0x18013fe00`) | freeze-risk |
|
| `itemList` | 0x16e | **ARRAY** of items (element deser `0x18013fe00`) | freeze-risk |
|
||||||
| `numberItems` | 0x1dd | INT | `[rsi+0x28]` |
|
| `numberItems` | 0x1dd | INT | `[rsi+0x28]` |
|
||||||
| `purchasedPackId` | 0x264 | INT | `[rsi+0x70]` |
|
| `purchasedPackId` | 0x264 | INT | `[rsi+0x70]` |
|
||||||
| `duplicateItemIdList` | 0xec | **ARRAY** (int list) | freeze-risk |
|
| `duplicateItemIdList` | 0xec | **ARRAY of OBJECTS** (element deser `0x180138e10`) | freeze-risk |
|
||||||
|
|
||||||
- **Status: already handled — VERIFIED byte-exact** against `store_buy()`.
|
- **Status: already handled — VERIFIED byte-exact** against `store_buy()`.
|
||||||
- **Minimal known-good**:
|
- **Minimal known-good**:
|
||||||
@@ -1243,21 +1350,122 @@ reader → infinite spin at `0x1801c7f1a` (the hub freeze).
|
|||||||
- **Handled:** `utas_server.massinfo()` → `{userInfo, squad, settings, userData}`;
|
- **Handled:** `utas_server.massinfo()` → `{userInfo, squad, settings, userData}`;
|
||||||
`FUT_MASSINFO=full|squad|userinfo|settings|empty` bisects it one member per relaunch.
|
`FUT_MASSINFO=full|squad|userinfo|settings|empty` bisects it one member per relaunch.
|
||||||
|
|
||||||
### FutGetSettingsServerResponse — CONFIDENCE: HIGH ✅ HANDLED
|
### FutGetSettingsServerResponse — CONFIDENCE: HIGH ✅ HANDLED (schema) / the 42 flags are RECOVERED, UNTESTED
|
||||||
- **Deser:** `0x18013c6d0`
|
- **Deser:** `0x18013c6d0` (1982 bytes, 12061-char decompile, read end to end)
|
||||||
- **HTTP:** `GET ut/%s/settings`
|
- **HTTP:** `GET ut/%s/settings`, and the `settings` (0x2bf) member of `userMassInfo`
|
||||||
|
(both callers of the deser: `0x18014e590` and `0x180174630`)
|
||||||
- **Fields:** single wrapper key `configs` (0xa2) → array of config entries
|
- **Fields:** single wrapper key `configs` (0xa2) → array of config entries
|
||||||
`{ type (0x354), value (0x377) }`.
|
`{ type (0x354), value (0x377) }`. The key ladder really does hold nothing else.
|
||||||
- **Handled:** `utas_server.SETTINGS = {"configs": []}`. Min JSON: `{"configs":[]}`.
|
|
||||||
|
|
||||||
### FutGetHubDataServerResponse — CONFIDENCE: LOW (full schema) / HIGH (served {} works) — GAP
|
**The mechanism the key ladder hides.** A flag is not a JSON key. When an element
|
||||||
- **Wrapper:** `0x1801736ad` → inner `0x180173a50` / `0x180173b10` / `0x180173c00`.
|
closes, the client feeds the STRING VALUE of `type` back through the atom hasher
|
||||||
|
(`FUN_180180d00`) and switches on the result, 42 arms wide:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"configs": [{"type": "friendlySeasonsEnabled", "value": 1}]}
|
||||||
|
```
|
||||||
|
|
||||||
|
So the flag vocabulary is the same atom table everything else uses, and the client
|
||||||
|
hashes our string itself — a flag cannot be misnamed silently, it simply falls
|
||||||
|
through to the default arm and is ignored.
|
||||||
|
|
||||||
|
- **`value` is type-forgiving.** Its getter `0x1801c79d0` accepts int (token 2),
|
||||||
|
float (3), bool (4) and string (5, via `sscanf "%I64d"`), coercing all four to
|
||||||
|
int64. `1`, `"1"` and `true` are equivalent. This is one of the few scalar
|
||||||
|
getters in the API with NO desync risk on scalars. An object or array is still
|
||||||
|
a freeze.
|
||||||
|
- **The applier demands exactly 1.** `FUN_18011dc50` is the only writer of the
|
||||||
|
gate bytes and every line is `gate_byte = (field == 1)`. Not truthiness. `2`,
|
||||||
|
`-1` and `"yes"` all read as OFF.
|
||||||
|
|
||||||
|
**Flags that publish a UI gate key.** `FUN_18006cc60` publishes IS_* state keys by
|
||||||
|
reading single bytes inside `FutDataManagerImpl` (service id `0xed84b11`, ctor
|
||||||
|
`0x18010cdc0`). Those bytes are written ONLY by the applier, and the ctor never
|
||||||
|
touches them (whole 16620-char ctor scanned):
|
||||||
|
|
||||||
|
| flag `type` | field | gate byte | UI key |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `tradingEnabled` | `[10]` | `0x1fd2e` | `IS_TRADING_ENABLED` |
|
||||||
|
| `storeEnabled` / `_JP` | `[0xb]` / `[0xc]` | `0x1fd2f` / `0x1fd30` | `IS_STORE_ENABLED` (accessor `0x18011c600` picks `_JP` when region == 4) |
|
||||||
|
| `friendlySeasonsEnabled` | `[0x16]` | `0x1fd3a` | `IS_FRIENDLY_SEASON_ENABLED` |
|
||||||
|
| `tournamentQuitEnabled` | `[0x20]` | `0x1fd3b` | `IS_TOURNAMENT_QUIT_ENABLED` |
|
||||||
|
| `processingStateEnabled` | `[0x21]` | `0x1fd3c` | `IS_PROCESSING_STATE_ENABLED` |
|
||||||
|
| `enableDraftMode` | `[0x17]` | `0x1fd3d` | `IS_DRAFT_MODE_ENABLED` |
|
||||||
|
| `enableOfflineDraftMode` = `enableSinglePlayerDraftMode` | `[0x18]` | `0x1fd3e` | (shared arm, one field) |
|
||||||
|
| `storyModeRewardEnabled` | `[0x1f]` | `0x1fd3f` | `IS_STORY_MODE_REWARD_ENABLED` |
|
||||||
|
| `returningUserRewardsScreenEnabled` | `[0x19]` | `0x1fd40` | `IS_RETURNING_USER_REWARDS_SCREEN_ENABLED` |
|
||||||
|
|
||||||
|
**Why this is the standing suspect for Seasons and Draft.** Both refuse while
|
||||||
|
making zero requests to any of the four servers, which no response shape can
|
||||||
|
explain. A UI key evaluated from a byte that nothing ever wrote does explain it.
|
||||||
|
The store is the control: `IS_STORE_ENABLED` reads the same kind of byte and its
|
||||||
|
screen works, because `storeEnabled` and friends are already shipped through the
|
||||||
|
**Blaze** client-config store (`FUT_RS4_CONFIG` in `blaze_responder_v3b.py`) —
|
||||||
|
and that list contains no seasons, draft or tournament flag. Same mechanism, one
|
||||||
|
population, one blank.
|
||||||
|
|
||||||
|
This is a hypothesis with a mechanism, not a confirmed cause. It predicts that
|
||||||
|
sending the flags opens the screens; if they still refuse, the gate is upstream
|
||||||
|
of the UI key and the whole settings line is dead.
|
||||||
|
|
||||||
|
**Two arms that are not simple assignments:**
|
||||||
|
- `enableObjectives` (0xfd) and `enableObjectivesAsManagerTasks` (0xfe) share an
|
||||||
|
arm that can only ever CLEAR `[0x1c]`: `if (value == 0) field = 0`. Sending 1
|
||||||
|
is a no-op. Objectives cannot be turned ON here, only off.
|
||||||
|
- `clientKeepAliveResetTimeoutSec` (0x86, vtable +0x68) and `getOperationTimeoutSec`
|
||||||
|
(0x13d, +0x58) do not store a field; they call a timer object with `value * 1000`.
|
||||||
|
Sending a small number shortens client timeouts. Leave them alone.
|
||||||
|
|
||||||
|
**`maximumTradePileSize` (0x1c0) is the positive control.** It lands in `[0]` and
|
||||||
|
is passed to `FUN_18011f380`, and transfer-list capacity is visible in game. It
|
||||||
|
distinguishes "the flag did not help" from "the configs array never reached the
|
||||||
|
consumer at all", which no boolean flag can do on its own.
|
||||||
|
|
||||||
|
**Not in the switch:** `enableSquadBuildingSetsFeature` (0x100) is a real atom but
|
||||||
|
has NO arm here, so SBC is gated somewhere else. Scanned the full decompile;
|
||||||
|
this absence is asserted over the whole function, not a slice.
|
||||||
|
|
||||||
|
- **Handled:** `utas_server.SETTINGS`, `FUT_SETTINGS` (default `gates`).
|
||||||
|
`off` restores the historical `{"configs": []}`.
|
||||||
|
|
||||||
|
### FutGetHubDataServerResponse — CONFIDENCE: HIGH (schema fully enumerated) — ✅ HANDLED (tiles populated)
|
||||||
|
- **Deser:** `FUN_180139610` (root object parser). Wrapper `0x1801736ad`.
|
||||||
- **HTTP:** `GET ut/%s/hub`
|
- **HTTP:** `GET ut/%s/hub`
|
||||||
- **Note:** uses **C++ reflection / vtable dispatch** (`call [rax+0x10]`,
|
- **CORRECTION (2026-08-06):** the earlier note here — "uses C++ reflection /
|
||||||
`call [rdx+0x1f8]`), NOT an inline atom ladder — no static field ladder to
|
vtable dispatch, NOT an inline atom ladder, no static field ladder to read,
|
||||||
read. It aggregates sub-objects (userInfo, settings, messages, etc.), each with
|
GAP" — was **WRONG**. `FUN_180139610` has an ordinary inline atom ladder: a
|
||||||
its own deser. Empty `{}` is tolerated (fields default).
|
running-sum `sub ecx,d / … / cmp ecx,d` dispatch plus a few direct `cmp esi,imm`.
|
||||||
- **Handled:** `utas_server` serves `{}` (validated hub-reaching). Deep populate = GAP.
|
It reads **18 atoms**, all enumerated below straight from the on-disk CardsDLL
|
||||||
|
via objdump (`fifa17-recon` scratchpad `hub_ladder.py`). The vtable calls are the
|
||||||
|
per-sub-object dispatch one indirection deeper, not the field read itself.
|
||||||
|
- **The 18 root atoms** (name ← `fut_atoms.tsv`):
|
||||||
|
`allObjectivesForCurrentGameSpaceId`(0x15), `auctionCount`(0x33),
|
||||||
|
`championEvent`(0x7a), `clubPlayers`(0x90), `draftSummary`(0xe4),
|
||||||
|
`friendlySeason`(0x131), `leaderboard`(0x186), `liveMessagesAvailable`(0x190),
|
||||||
|
`objectivesForCurrentUser`(0x1e3), `offlineSeason`(0x1ec), `ONLINE`(0x1f1),
|
||||||
|
`onlineSeason`(0x1f6), `SINGLE_PLAYER`(0x29d), `squad`(0x2cd),
|
||||||
|
`tournament`(0x328), `tournamentProgress`(0x32c), `tradePile`(0x333),
|
||||||
|
`watchlist`(0x381).
|
||||||
|
- **TILE MAP (which atom drives which hub tile):**
|
||||||
|
- `clubPlayers`(0x90) int → MY CLUB tile "N players" (TILE_ID 0x210)
|
||||||
|
- `auctionCount`(0x33) int → TRANSFER MARKET tile "N LIVE TRANSFERS" (TILE_ID 0x1b0)
|
||||||
|
- `tradePile`(0x333) **nested object**, sub-deser `0x18013ead0` → TRANSFER LIST
|
||||||
|
tile "N ITEMS / Selling / Sold". Sub-atoms: `count`(0xbc), `notification`(0x1da),
|
||||||
|
`selling`(0x2b8), `sold`(0x2c9) — all scalar int via `0x1801c79d0` (5 int reads,
|
||||||
|
one SKIP, object field loop; no array/nested object → no type-desync surface).
|
||||||
|
Same atom scheme as `FutGetAuctionCount`. **All active listings are `selling`;
|
||||||
|
`count == selling == len(listings)`, `sold == 0`.**
|
||||||
|
- `watchlist`(0x381) nested object, sub-deser `0x18013f3b0` → WATCH LIST tile (not
|
||||||
|
yet populated; empty watch list defaults to 0, which is correct today).
|
||||||
|
- **LIVE SYMPTOM this fixed (2026-08-06):** a card was actively listed
|
||||||
|
(`auctionCount` 1, Listed Items screen showed it) yet the TRANSFER LIST tile read
|
||||||
|
"0 items / Selling 0". The tile reads `hub.tradePile`, which we were omitting; it
|
||||||
|
does **not** re-poll `/tradePile/counts` (the standalone GetAuctionCount endpoint)
|
||||||
|
once at the hub. Serving `hub.tradePile:{count,selling,sold}` corrected the tile.
|
||||||
|
- **Handled:** `utas_server.hub_data()` serves `clubPlayers`, `auctionCount`, and
|
||||||
|
`tradePile:{count,selling,sold}` (`FUT_HUBDATA=1`, default on). Remaining atoms
|
||||||
|
(seasons/draft/tournament/objectives/leaderboard summaries) default to 0/absent,
|
||||||
|
which is correct while those modes are unpopulated.
|
||||||
|
|
||||||
### FutUserDataServerResponse — CONFIDENCE: MEDIUM
|
### FutUserDataServerResponse — CONFIDENCE: MEDIUM
|
||||||
- **Deser:** `0x18016dd50` (lea r8 @ `0x18016d98d`)
|
- **Deser:** `0x18016dd50` (lea r8 @ `0x18016d98d`)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,153 @@
|
|||||||
|
# The /settings feature gate - live-test script
|
||||||
|
|
||||||
|
> **CORRECTION, 2026-08-05 evening. Section 1 of this document is FALSE and the
|
||||||
|
> test in section 3 should not be run as written.**
|
||||||
|
>
|
||||||
|
> Section 1 claims `IS_FRIENDLY_SEASON_ENABLED` and `IS_DRAFT_MODE_ENABLED` "have
|
||||||
|
> never been set to true by anything, on any run". They are measured as **1**, on
|
||||||
|
> two separate launches, while `/settings` was answering `{"configs": []}`:
|
||||||
|
>
|
||||||
|
> ```
|
||||||
|
> disp 0x1fd3a (friendlySeasonsEnabled) value = 1
|
||||||
|
> disp 0x1fd3d (enableDraftMode) value = 1
|
||||||
|
> disp 0x1fd45 (packOpeningAnimationEnabled) value = 1
|
||||||
|
> ```
|
||||||
|
>
|
||||||
|
> Reproduce with `tools/gate_byte_probe.py` (needs the client at the FUT hub, since
|
||||||
|
> CardsDLL loads only then): it resolves the pid by comm,
|
||||||
|
> re-derives the CardsDLL slide from `/proc/<pid>/maps`, proves it against the FNV
|
||||||
|
> prologue at `0x180180d00` read from disk, walks the model singleton at
|
||||||
|
> `DAT_1802e6398`, and decodes each displacement out of its accessor stub
|
||||||
|
> (`0f b6 81 <disp32>`) rather than assuming it.
|
||||||
|
>
|
||||||
|
> **Where the reasoning went wrong.** The finding that `FUN_18011dc50` is the only
|
||||||
|
> writer and that the `FutDataManagerImpl` constructor never touches those bytes was
|
||||||
|
> correct. The inference drawn from it was not. The applier runs whether or not the
|
||||||
|
> configs array has content, and the settings struct it is handed defaults these
|
||||||
|
> fields to 1, so the bytes were being written all along. "Nothing populates the
|
||||||
|
> array" was treated as "nothing writes the byte". Those are different claims and
|
||||||
|
> only the first one was established.
|
||||||
|
>
|
||||||
|
> Seasons therefore does not refuse because its gate byte is false. Its gate byte is
|
||||||
|
> true. The mechanism is still unknown and needs a fresh diagnosis. Everything below
|
||||||
|
> the correction is kept as the record of a wrong turn, not as a plan.
|
||||||
|
|
||||||
|
Written 2026-08-05, after reversing `FutGetSettingsServerResponse` end to end.
|
||||||
|
Nothing here has been in front of the game yet. The code default is `off`, which
|
||||||
|
serves the exact historical `{"configs": []}`, so the tree is currently at the
|
||||||
|
proven baseline and this test is opt-in.
|
||||||
|
|
||||||
|
Full schema, atom ids, gate bytes and accessor addresses are in `ENDPOINT_MAP.md`
|
||||||
|
under `FutGetSettingsServerResponse`. This file is only the experiment.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. The claim being tested
|
||||||
|
|
||||||
|
`GET /settings` is requested 11 times a session and has always been answered with
|
||||||
|
an empty array. The array is not decoration:
|
||||||
|
|
||||||
|
- Each element is `{"type": "<name>", "value": <scalar>}`. The client hashes the
|
||||||
|
**string value** of `type` through the atom hasher and switches on it, 42 arms
|
||||||
|
wide, so a flag is a row rather than a key.
|
||||||
|
- `FUN_18011dc50` is the **only** writer of the `IS_*` UI gate bytes inside
|
||||||
|
`FutDataManagerImpl`, and every line of it is `byte = (field == 1)`.
|
||||||
|
- The `FutDataManagerImpl` constructor never touches those bytes. The whole
|
||||||
|
16620-char decompile was scanned for the block; it is absent.
|
||||||
|
|
||||||
|
So `IS_FRIENDLY_SEASON_ENABLED` and `IS_DRAFT_MODE_ENABLED` have never been set
|
||||||
|
to true by anything, on any run, in the whole history of this project.
|
||||||
|
|
||||||
|
That is a mechanism for the standing bug in which **Seasons refuses while making
|
||||||
|
zero requests to any of the four servers.** No response shape could ever explain
|
||||||
|
that. A UI key evaluated from a byte nobody wrote does.
|
||||||
|
|
||||||
|
**The store is the control that makes this readable.** `IS_STORE_ENABLED` is the
|
||||||
|
same kind of byte read the same way, and the store screen works. It works because
|
||||||
|
`storeEnabled` and its siblings already reach the client through the **Blaze**
|
||||||
|
client-config store (`FUT_RS4_CONFIG`). That list contains no seasons flag, no
|
||||||
|
draft flag, no tournament flag. Same mechanism, one populated, one blank.
|
||||||
|
|
||||||
|
This is a hypothesis with a mechanism, not a demonstrated cause.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Pre-flight, from the terminal, costs nothing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd fifa17-recon/tools
|
||||||
|
FUT_SETTINGS=gates python3 check_settings_flags.py # expect: 14 rows, PASS
|
||||||
|
python3 check_settings_flags.py # expect: mode=off, PASS
|
||||||
|
```
|
||||||
|
|
||||||
|
The checker asserts every shipped flag name against **both** the atom table and
|
||||||
|
the recovered switch arms. Both are needed: `enableSquadBuildingSetsFeature` is a
|
||||||
|
genuine atom with no arm in this switch, so the atom table alone would wave
|
||||||
|
through a flag that does nothing. A misnamed flag is silently inert and looks
|
||||||
|
exactly like a failed fix, which is the failure mode this guards.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. The run
|
||||||
|
|
||||||
|
Budget: **one launch.**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd fifa17-recon/tools
|
||||||
|
FUT_SETTINGS=gates ./openfut-fut.sh start
|
||||||
|
~/Desktop/launch-fifa17.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Then, in order, and write down what each one does:
|
||||||
|
|
||||||
|
1. **Store.** Open it. This is the control and it goes first, because if
|
||||||
|
populating the array broke the store then the applier demonstrably ran and
|
||||||
|
everything after this reads differently.
|
||||||
|
2. **Transfer list capacity.** Transfers → Transfer List. Read the capacity
|
||||||
|
number. We send `maximumTradePileSize = 77`, a number FUT would never choose
|
||||||
|
on its own.
|
||||||
|
3. **Seasons.** Single-player Seasons, the exact path that has been refusing.
|
||||||
|
4. **FUT Draft.** Both the offline and online entries.
|
||||||
|
5. **Tournaments**, for `tournamentQuitEnabled`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Reading the result
|
||||||
|
|
||||||
|
The control in step 2 is what makes a negative result informative, so read it
|
||||||
|
before concluding anything about steps 3 to 5.
|
||||||
|
|
||||||
|
| Store (1) | Capacity (2) | Seasons (3) | Reading |
|
||||||
|
|---|---|---|---|
|
||||||
|
| works | **77** | opens | Confirmed. The gate was the empty array. Make `gates` the default and move to the `/match` shape, which has been blocked behind this. |
|
||||||
|
| works | **77** | still refuses | The array reached the consumer and the flag was applied, so the gate is **upstream of the UI key**. The settings line is then dead for Seasons and the next move is a live probe of the refusal path, not more response work. This is a real result, not a null one. |
|
||||||
|
| works | not 77 | still refuses | The array never reached the consumer at all. Everything above is untested rather than refuted. Suspect the massinfo `settings` member (the deser's other caller) is what the client actually reads, and check which of the two paths fires in `/tmp/utas.log`. |
|
||||||
|
| **breaks** | any | any | The applier ran and re-asserting the store flags did not hold them. Fall back to `FUT_SETTINGS=keep`, which sends only the already-working flags plus the control. If `keep` also breaks the store, populating the array is harmful in itself and the whole approach is wrong. |
|
||||||
|
|
||||||
|
`keep` exists precisely so that "populating the array at all" and "the new gates"
|
||||||
|
can be separated without guessing, and it costs one restart to use.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. What would make this whole plan wrong
|
||||||
|
|
||||||
|
**The gate might not be a UI key at all.** Seasons could be refusing on an
|
||||||
|
entitlement, a persona attribute, or a Blaze session property evaluated inside
|
||||||
|
the Denuvo-packed executable, in which case no `/settings` body reaches it. The
|
||||||
|
step-2 control is what tells these apart: it distinguishes "the flag did not
|
||||||
|
help" from "the array was never consumed", and no boolean flag can do that alone.
|
||||||
|
|
||||||
|
**The store control could be weaker than it looks.** The argument assumes
|
||||||
|
`IS_STORE_ENABLED` currently comes from the Blaze store rather than from a
|
||||||
|
default. If it turns out the store screen does not read that key at all, then it
|
||||||
|
is not a control for anything and the reasoning in §1 loses its anchor.
|
||||||
|
|
||||||
|
**Draft has a second known suspect.** `GET ut/%s/squad/mode/draft/state` is still
|
||||||
|
answered by the generic `/squad` handler with a full active-squad object, which
|
||||||
|
is a textbook type-desync candidate. If Draft still fails while Seasons opens,
|
||||||
|
that route is the next thing to look at, not the flag.
|
||||||
|
|
||||||
|
**A negative result here is worth having.** The settings array has been the
|
||||||
|
standing suspect for the greyed-out entry points for two rounds without anyone
|
||||||
|
sending a single flag. Ruling it out costs one launch and removes it from the
|
||||||
|
backlog permanently.
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,369 @@
|
|||||||
|
# The refusing modes: Seasons, Draft, SBC/Objectives, Tournaments — where the greying is decided
|
||||||
|
|
||||||
|
Written 2026-08-06. One reconnaissance pass over the live gate-byte block and the
|
||||||
|
six `/hub` mode sub-deserializers, then four parallel per-mode investigations
|
||||||
|
(Seasons, Draft, SBC+Objectives, Tournaments), each followed by an independent
|
||||||
|
adversarial verification round. FIFA 17 was running throughout as **pid 24653**,
|
||||||
|
sitting at the FUT hub, and was read strictly read-only. No server was restarted,
|
||||||
|
no server code was changed, no memory was poked, and FIFA was never launched or
|
||||||
|
killed.
|
||||||
|
|
||||||
|
Slide for every live read: `live = static - 0x180000000 + 0x6ffffc140000`, i.e.
|
||||||
|
slide `0x6ffe7c140000`, re-derived from `/proc/24653/maps` and proved by
|
||||||
|
`tools/gate_byte_probe.py` reporting **CONTROL FNV MATCH** against the FNV hasher
|
||||||
|
prologue at `0x180180d00`. CardsDLL is mapped from `/mnt/games/FIFA 17/
|
||||||
|
CardsDLL_Win64_retail.dll`; the on-disk copy read with `objdump` is
|
||||||
|
`/tmp/fut/cardsdll.dll`, image base `0x180000000`. Every address below is
|
||||||
|
live-verified.
|
||||||
|
|
||||||
|
This document answers one question the brief posed: is the refusal of these four
|
||||||
|
mode families decided by a **server-reachable input we are failing to send** (a hub
|
||||||
|
mode sub-object, a massinfo member, a settings/config field, or a dedicated
|
||||||
|
endpoint), **or** is it decided in the **Denuvo-packed FIFA17.exe / Frostbite
|
||||||
|
front-end** with no server surface at all?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Headline — final verdicts (after adversarial verify)
|
||||||
|
|
||||||
|
Every mode was independently re-derived by a second agent that attempted to refute
|
||||||
|
the first. **All four refutations failed. All four verdicts stand.**
|
||||||
|
|
||||||
|
| Mode | Atoms | Final verdict | Confidence | Verify |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| **FUT Seasons** (offline + online + friendly) | `friendlySeason 0x131`, `offlineSeason 0x1ec`, `onlineSeason 0x1f6` | **NOT_SERVER_REACHABLE** | HIGH | agrees (SAME) |
|
||||||
|
| **FUT Draft** (offline + online) | `draftSummary 0xe4` | **NOT_SERVER_REACHABLE** | HIGH | agrees (SAME), strengthened |
|
||||||
|
| **SBC + Objectives** | `objectivesForCurrentUser 0x1e3`, `allObjectivesForCurrentGameSpaceId 0x15` | **NOT_SERVER_REACHABLE** | HIGH | agrees (SAME), prior chain corrected |
|
||||||
|
| **FUT Tournaments** | `tournament 0x328`, `tournamentProgress 0x32c` | **NOT_SERVER_REACHABLE** | HIGH | agrees (SAME) |
|
||||||
|
|
||||||
|
**There is no server fix for any of the four.** Every server-reachable input that
|
||||||
|
touches these modes is either cosmetic (a hub stat list feeding a caption/count),
|
||||||
|
an *output* value the client emits and never branches on, or a settings byte that
|
||||||
|
is **already live=1** while the tile stays greyed. The decision lives in the packed
|
||||||
|
front-end. This is the same shape as the transfer-market finding of the same day —
|
||||||
|
except there the switch (`userInfo.feature.trade`) was ours to flip; here **no such
|
||||||
|
switch exists on the wire.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Ground truth
|
||||||
|
|
||||||
|
### The named gate-byte block (`FutDataManagerImpl`, live pid 24653)
|
||||||
|
|
||||||
|
Names were resolved by finding the config serializer at `0x18006ccd0`, which pairs
|
||||||
|
each `IS_*_ENABLED` string key (`.rdata 0x1801fc118..`) with a getter vtable slot,
|
||||||
|
then decoding each slot's accessor stub (`0f b6 81 <disp32> c3`) to its model
|
||||||
|
displacement. All values read live, slide-proven.
|
||||||
|
|
||||||
|
| Name | Displacement / slot | Live value |
|
||||||
|
|---|---|---|
|
||||||
|
| (unnamed) | `+0x1fd24` | 0 |
|
||||||
|
| (unnamed) | `+0x1fd2c` | 1 |
|
||||||
|
| (unnamed) | `+0x1fd2d` | 1 |
|
||||||
|
| **IS_TRADING_ENABLED** | `+0x1fd2e` (slot+0x270) | 1 |
|
||||||
|
| (unnamed) | `+0x1fd30` | 1 |
|
||||||
|
| (unnamed) | `+0x1fd37` | 1 |
|
||||||
|
| **IS_FRIENDLY_SEASON_ENABLED** | `+0x1fd3a` (slot+0x2b0) | 1 |
|
||||||
|
| **IS_TOURNAMENT_QUIT_ENABLED** | `+0x1fd3b` (slot+0x2b8) | 1 |
|
||||||
|
| **IS_PROCESSING_STATE_ENABLED** | `+0x1fd3c` (slot+0x2c0) | 1 |
|
||||||
|
| **IS_DRAFT_MODE_ENABLED** | `+0x1fd3d` (slot+0x2c8) | 1 |
|
||||||
|
| (unnamed) | `+0x1fd3e` (offline-draft-enable) | 1 |
|
||||||
|
| **IS_STORY_MODE_REWARD_ENABLED** | `+0x1fd3f` (slot+0x2d8) | 1 |
|
||||||
|
| **IS_RETURNING_USER_REWARDS_SCREEN_ENABLED** | `+0x1fd40` (slot+0x2f0) | 0 |
|
||||||
|
| (unnamed) | `+0x1fd41` | 0 |
|
||||||
|
| (unnamed) | `+0x1fd42` (allowGracePeriod, SBC) | 0 |
|
||||||
|
| (unnamed) | `+0x1fd43` | 0 |
|
||||||
|
| **objectives-enable** (corrected — see §5.3) | `+0x1fd44` | 1 |
|
||||||
|
| **packOpeningAnimation** | `+0x1fd45` | 1 |
|
||||||
|
| (unnamed) | `+0x1fd46` | 1 |
|
||||||
|
| (unnamed) | `+0x1fd47` | 0 |
|
||||||
|
| (unnamed) | `+0x1fd48` | 1 |
|
||||||
|
| **IS_STORE_ENABLED** | computed getter slot+0x280 @`0x18011c600` (not a byte field) | (computed) |
|
||||||
|
|
||||||
|
Every named gate byte that governs a **refusing** mode reads **ENABLED=1** live.
|
||||||
|
The only `0`-valued `*_ENABLED` byte, `IS_RETURNING_USER_REWARDS_SCREEN_ENABLED`,
|
||||||
|
does not gate any of the four mode families. This re-confirms the brief's prior
|
||||||
|
ground truth: the gate-byte layer does **not** explain the refusals.
|
||||||
|
|
||||||
|
### The six `/hub` mode sub-deserializers (`/hub` parser = `FUN_180139610`)
|
||||||
|
|
||||||
|
The hub parser reads 18 atoms via a running-sum sub/dec ladder; six dispatch to the
|
||||||
|
refusing modes. Each nested sub-deser was read in full. **None carries an
|
||||||
|
enable/available/unlocked boolean.**
|
||||||
|
|
||||||
|
| Atom | Name | Sub-deser VA | Fields (all cosmetic/data) |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `0x131` | friendlySeason | `0x1801392a0` | creationTime, dataVersion, opponentPersonaId, opponentUserPoints, round, seasonId, userPoints, defId (8 ints) |
|
||||||
|
| `0x1ec` | offlineSeason | `0x18013c3a0` | divisionId, gamesPlayed, points, progressDataVersion, totalGames (strings) |
|
||||||
|
| `0x1f6` | onlineSeason | `0x18013c3a0` (shared) | divisionId, gamesPlayed, points, progressDataVersion, totalGames (strings) |
|
||||||
|
| `0xe4` | draftSummary | `0x180138d60` | draftState (str-enum), gamesWon (int) |
|
||||||
|
| `0x328` | tournament | `0x18013dc00` | id, assetName, imageFormat, silhouetteName, timeUntilEnd, tournamentType, AMATEUR, live_offline, offerState (display) |
|
||||||
|
| `0x32c` | tournamentProgress | `0x18013df20` | data, tutorialClientData (free-form std::map) |
|
||||||
|
|
||||||
|
The recurring trap: several of these desers write a per-field byte
|
||||||
|
(`offline/onlineSeason` `[r14+0xa]=1`; `tournament` `[rdi+0x162]=1`) that an early
|
||||||
|
naive pass could mistake for a JSON enable flag. Every such write is a
|
||||||
|
**parser-local "field present" marker**, written identically for every field —
|
||||||
|
**not** a JSON-sourced availability input. This is the same class of mistake that
|
||||||
|
made `hub.tradePile` look like a gate before it was shown to be a mere count.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. FUT Seasons — NOT_SERVER_REACHABLE (HIGH)
|
||||||
|
|
||||||
|
**Atoms:** `friendlySeason 0x131`, `offlineSeason 0x1ec`, `onlineSeason 0x1f6`.
|
||||||
|
**Gate byte:** `IS_FRIENDLY_SEASON_ENABLED +0x1fd3a`, live=1.
|
||||||
|
|
||||||
|
**Evidence chain.** The decisive site is the gate byte `+0x1fd3a`. A whole-`.text`
|
||||||
|
grep finds **exactly two** references:
|
||||||
|
|
||||||
|
- **Writer** `0x18011dd2d`: `mov byte[rdi+0x1fd3a],al` inside settings applier
|
||||||
|
`FUN_18011dc50`, preceded by `cmp dword[rbx+0x58],1 / sete al` — the byte is
|
||||||
|
`(settings.field+0x58 == 1)`, sourced from config key `friendlySeasonsEnabled`.
|
||||||
|
This is the **only** writer.
|
||||||
|
- **Reader** `0x18011c500`: `movzx eax,byte[rcx+0x1fd3a]; ret` — a standalone
|
||||||
|
vtable getter stub (slot+0x2b0). Its absolute address appears in the file exactly
|
||||||
|
once, at the vtable, and grep finds **no** call/jmp to `0x18011c500` anywhere in
|
||||||
|
CardsDLL `.text`. Its only consumer is the packed FIFA17.exe front-end via vtable
|
||||||
|
dispatch.
|
||||||
|
|
||||||
|
The one server-writable input (`friendlySeasonsEnabled → +0x1fd3a`) is **already 1
|
||||||
|
live**, and the tile is still greyed — so the front-end does not gate on this byte
|
||||||
|
alone; it reads additional non-server state.
|
||||||
|
|
||||||
|
- **Hub sub-objects** carry no enable flag. `offline/onlineSeason` share deser
|
||||||
|
`0x18013c3a0`, which FNV-hashes string keys and for each stores a division/games/
|
||||||
|
points/version stat; `friendlySeason 0x1801392a0` is 8 numeric stats. The
|
||||||
|
`[r14+0xa]=1` write is the "field present" marker. These feed a caption/count.
|
||||||
|
- **Settings/massinfo:** `friendlySeasonsEnabled` is the sole season key the applier
|
||||||
|
consumes → `+0x1fd3a`, already covered. No massinfo member carries a season enable.
|
||||||
|
There is **no** `onlineSeasonEnabled`/`offlineSeasonEnabled` config key or gate
|
||||||
|
byte anywhere in the DLL — verify enumerated all 24 gate-region getter stubs and
|
||||||
|
the only season getter is `+0x1fd3a`.
|
||||||
|
- **Dedicated endpoint:** `/season` and `/season/user` routes exist in
|
||||||
|
`utas_server.py` (guarded by `FUT_MODES`) but the client has **never** requested
|
||||||
|
them — 0 season hits across `captures/`, 486 real ProtoHttp requests over ~30
|
||||||
|
boots, none for `/season`. And the tile greys at hub load, *before* any `/season`
|
||||||
|
request could fire.
|
||||||
|
- **Front-end:** the only season-enable identifiers in the whole DLL are the config
|
||||||
|
*input* `friendlySeasonsEnabled` and the *output* getter name
|
||||||
|
`IS_FRIENDLY_SEASON_ENABLED`. The viewmodel names
|
||||||
|
(`futonlineseasonsviewmodel`, `futofflineseasonsviewmodel`,
|
||||||
|
`futfriendlyseasons*viewmodel`) live in the Denuvo-packed FIFA17.exe.
|
||||||
|
|
||||||
|
**Authority boundary.** `friendlySeasonsEnabled` is a **server-writable input**,
|
||||||
|
but it is already at ENABLED with its only reader **off-DLL (client)**. Offline/
|
||||||
|
online seasons have **no server surface at all** — no config key, no gate byte, no
|
||||||
|
getter. The grey/refuse decision is **client-side**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. FUT Draft — NOT_SERVER_REACHABLE (HIGH, strengthened by verify)
|
||||||
|
|
||||||
|
**Atoms:** `draftSummary 0xe4`. **Gate byte:** `IS_DRAFT_MODE_ENABLED +0x1fd3d`,
|
||||||
|
live=1.
|
||||||
|
|
||||||
|
**Evidence chain.** Cross-ref of displacement `0x1fd3d` returns exactly two real
|
||||||
|
sites (a `lea` to `0x1801fd3d8` and an instruction at address `0x18011fd3d` are
|
||||||
|
coincidental, not xrefs):
|
||||||
|
|
||||||
|
- **Accessor stub** `0x18011c4b0`: `movzx eax,[rcx+0x1fd3d]; ret` (getter vtable
|
||||||
|
`.rdata 0x18021c568`).
|
||||||
|
- **Writer** `0x18011dd5a`: `mov [rdi+0x1fd3d],al` in applier `FUN_18011dc50`,
|
||||||
|
`al = (settings[rbx+0x5c]==1)` = parsed `enableDraftMode`.
|
||||||
|
|
||||||
|
There is **no cmp/test/branch** on this byte anywhere. Its only CardsDLL consumer
|
||||||
|
is the config serializer `0x18006ccd0`, which walks the `IS_*_ENABLED` key table and
|
||||||
|
`call [rax+0x2c8]` to **emit** the value outward. So `IS_DRAFT_MODE_ENABLED` is an
|
||||||
|
**output the client serializes, not an input any logic branches on.**
|
||||||
|
|
||||||
|
**The verifier strengthened this** by finding a consumer the first pass missed: a
|
||||||
|
flux "DESTINATION" navigation emitter around `0x1800b2700`. At `0x1800b2711` it
|
||||||
|
loads getter slot `+0x2c8` (draft-enable, `+0x1fd3d`) into `sil` and slot `+0x2d0`
|
||||||
|
(offline-draft-enable, `+0x1fd3e`) into `[rsp+0x21]`. All six `GOTO_DRAFT_DISABLED`
|
||||||
|
emit sites (`0x1800b2cb2`, `0x1800b2dc9`, `0x1800b333b/347`, `0x1800b349f/4a7`) are
|
||||||
|
guarded by `test sil,sil` / `cmp [rsp+0x21],0` and route to `GOTO_DRAFT_DISABLED`
|
||||||
|
**only when those bytes are 0**, else to `GOTO_DRAFT_OFFLINE/ONLINE`. Both bytes are
|
||||||
|
**live=1**, so this emitter — the closest thing to a nav decision inside CardsDLL —
|
||||||
|
already produces the ENABLED destinations, yet the tile is still greyed.
|
||||||
|
|
||||||
|
- **Hub sub-object** `draftSummary 0xe4`, member deser `0x180138d60`: exactly two
|
||||||
|
atoms — `draftState 0xe3` (STRING → enum decoder `0x180138cc0`, a resume-state
|
||||||
|
enum: INVALID + 2..8) and `gamesWon 0x13a` (INT). Wrapper `0x18013980c` loops
|
||||||
|
`ONLINE 0x1f1` / `SINGLE_PLAYER 0x29d`, each → `0x180138d60`. No enable atom;
|
||||||
|
`draftState` is the continue-state read after entry, not a tile gate.
|
||||||
|
- **Settings/massinfo:** atoms `enableDraftMode 0xf9` / `enableOfflineDraftMode
|
||||||
|
0xfa` / `enableSinglePlayerDraftMode 0xff` land on sibling emit-only bytes
|
||||||
|
`+0x1fd3d`/`+0x1fd3e`/`+0x1fd3c` via the same applier — none branched on.
|
||||||
|
- **Dedicated endpoints:** `GET /squad/mode/draft/state` (deser `0x180147070`) and
|
||||||
|
`POST /purchase/mode/N/draft` (deser `0x18014c260`) are already routed in utas —
|
||||||
|
but these are the **post-click** entry/session flow (render the draft screen, buy
|
||||||
|
entry *after* the tile is pressed), not a tile-availability query.
|
||||||
|
- **Front-end:** token strings (`USER_HAVE_DRAFT_TOKENS 0x1802055f8`,
|
||||||
|
`GOTO_DRAFT_DISABLED 0x180209aa8`, etc.) are bare key-name `lea` emitters with no
|
||||||
|
greying branch. Decision is in the packed FIFA17.exe.
|
||||||
|
|
||||||
|
**Authority boundary.** The two server-writable inputs (`enableDraftMode`,
|
||||||
|
`enableOfflineDraftMode`) are **already at their enabled value**, and **every**
|
||||||
|
CardsDLL consumer of them (config serializer *and* the navigation emitter) already
|
||||||
|
treats draft as enabled. The persistent greying is decided **client-side** on
|
||||||
|
non-server state.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. SBC + Objectives — NOT_SERVER_REACHABLE (HIGH, prior chain corrected)
|
||||||
|
|
||||||
|
**Atoms:** `objectivesForCurrentUser 0x1e3`, `allObjectivesForCurrentGameSpaceId
|
||||||
|
0x15`. **No `IS_OBJECTIVES`/`IS_SBC` gate-byte name exists** — the task premise that
|
||||||
|
these are governed by no named `FutDataManagerImpl` gate byte is confirmed.
|
||||||
|
|
||||||
|
### 5.1 Hub sub-object = cosmetic list
|
||||||
|
|
||||||
|
In `FUN_180139610` both objectives atoms share one arm: `objectivesForCurrentUser
|
||||||
|
0x1e3` (`0x180139794`) and `allObjectivesForCurrentGameSpaceId 0x15`
|
||||||
|
(`0x1801397ad`) both jump to `0x1801398fe`, guarded by the parser-local marker
|
||||||
|
`cmp BYTE [rsp+0x21],0x1`, calling sub-deser `0x18013a7f0`. That deser parses a
|
||||||
|
nested `objectives 0x1e2` **array** of records (element parser `0x18006c9b0`) with
|
||||||
|
**no** enabled/available/unlocked atom — it feeds the "MANAGER TASKS N/M" tile
|
||||||
|
count/caption, the same cosmetic class as `hub.tradePile`.
|
||||||
|
|
||||||
|
### 5.2 No dedicated endpoint at the hub
|
||||||
|
|
||||||
|
The live log across 26+ hub sessions shows the client requests only `/hub` and
|
||||||
|
`/settings`; it **never** calls `/sbs/*` (grep count 0) or any `/objectives`
|
||||||
|
endpoint. `utas_server.py` has no `/sbs` route. No `FutGetObjectivesServerResponse`
|
||||||
|
class exists — objectives are **ManagerQuests**, client-driven. The `sbs/*` structs
|
||||||
|
that exist serve challenge **content after entry**, never polled at the hub.
|
||||||
|
|
||||||
|
### 5.3 The correction (verify fixed the first pass's chain)
|
||||||
|
|
||||||
|
The first pass mis-traced objectives to settings field `[0x1c]` → model `+0x1fd28`
|
||||||
|
(default 60). **The verifier re-derived the settings jump table (dispatch
|
||||||
|
`0x18013ca1e`, byte-idx `0x18013ced4`, jtbl `0x18013ce90`) and found the truth:**
|
||||||
|
|
||||||
|
- `enableObjectives 0xfd` **and** `enableObjectivesAsManagerTasks 0xfe` route to
|
||||||
|
handler `0x18013cabd` = clear-only-on-zero into settings field `[0x70]`; applier
|
||||||
|
`0x18011ddc7` (`cmp [rbx+0x70],1; sete al; mov [rdi+0x1fd44],al`) maps it to model
|
||||||
|
gate byte **`+0x1fd44`** — which is **inside** the named gate block (not outside,
|
||||||
|
as the first pass claimed), reads **1 (ENABLED) live**, and has exactly one reader
|
||||||
|
DLL-wide: a getter stub `0x18011c570` returning the byte to the front-end with no
|
||||||
|
internal gating use.
|
||||||
|
- The first pass's `+0x1fd28` (default 60) is actually
|
||||||
|
`squadBuildingSetsGracePeriodMinutes 0x2d0`, a numeric grace-period param —
|
||||||
|
behavioral, not availability.
|
||||||
|
- **SBC side:** `enableSquadBuildingSetsFeature 0x100` falls in the dispatch **gap**
|
||||||
|
(`0x100-0x18=0xe8 > 0xe7 → DEFAULT/no handler`), as do `squadBuildingSetsClientData
|
||||||
|
0x2cf` and `squadChallenge 0x2d1`. Only numeric SBC params have handlers
|
||||||
|
(`allowGracePeriod 0x18 → +0x1fd42`, `allowUntradeable 0x19 → +0x206f8`,
|
||||||
|
`gracePeriodMinutes 0x2d0 → [0x1c]/+0x1fd28`). **No SBC availability model byte
|
||||||
|
exists.**
|
||||||
|
|
||||||
|
So the single server-controllable objectives-enable input (`+0x1fd44`) is already at
|
||||||
|
1 yet the tile refuses, and SBC has **no** server enable surface whatsoever.
|
||||||
|
|
||||||
|
**Authority boundary.** Objectives-enable is a **server-writable byte already ON**,
|
||||||
|
read only by the **client**. SBC availability has **no server surface** — its enable
|
||||||
|
key is in the settings dispatch gap and lands on no byte. Decision is **client-side**
|
||||||
|
(`futmanagerquestsviewmodel`; providers `FUT_MQ_QUESTS_DATA_DP` /
|
||||||
|
`FUT_SQUAD_QUESTS_DP`) in the packed FIFA17.exe.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. FUT Tournaments — NOT_SERVER_REACHABLE (HIGH)
|
||||||
|
|
||||||
|
**Atoms:** `tournament 0x328`, `tournamentProgress 0x32c`. **Gate byte:**
|
||||||
|
`IS_TOURNAMENT_QUIT_ENABLED +0x1fd3b`, live=1 — but this governs **quitting** a
|
||||||
|
tournament, not tile availability, and no `tournamentEnabled` atom exists in
|
||||||
|
`docs/fut_atoms.tsv`.
|
||||||
|
|
||||||
|
**Evidence chain.**
|
||||||
|
|
||||||
|
- **Hub sub-objects, both cosmetic.** `tournament 0x328` deser `0x18013dc00` writes
|
||||||
|
only display fields: id `[rdi+0x150]`, round `[rdi+0x160]`, timeUntilEnd
|
||||||
|
`[rdi+0x158]`, silhouette-int `[rdi+0x15c]`, string blobs `[rdi]`/`[rdi+0xa8]`
|
||||||
|
(assetName/silhouette/type), an `imageFormat=="dds"` render bool `[rdi+0x163]`
|
||||||
|
(strcmp vs `.rdata 0x180219400`), and a `tournamentType` enum `[rdi+0x154]`
|
||||||
|
decoded to `live_offline 0x195`/`live_online 0x196`/`offline 0x1e8`/`online 0x1f0`
|
||||||
|
— a categorization, not availability. The `[rdi+0x162]=1` write is a
|
||||||
|
record-completeness marker (all core fields present), not a JSON enable.
|
||||||
|
`tournamentProgress 0x32c` deser `0x18013df20` builds a std::map (ctor
|
||||||
|
`0x1801e5210`) of string keys `data 0xc9` / `tutorialClientData ~0x353` — free-form
|
||||||
|
clientData, no enable atom. (The earlier `0x28a = returningUserRewardsScreenEnabled`
|
||||||
|
label was a running-sum mis-decode; the true sum is `0xc9+0x28a=0x353
|
||||||
|
tutorialClientData`.)
|
||||||
|
- **Massinfo/settings.** `tournamentCoins 809 → +0x30` and `teamOfTournamentWinner
|
||||||
|
776 (bool) → +0x34` appear only in the **FutDestroyMatch** reward deser
|
||||||
|
`0x180121b60` — a match payout reached only *after* you are inside a tournament
|
||||||
|
match; a reward count/trophy flag, not a tile gate. The settings applier switch
|
||||||
|
`0x18013c6d0` has 42 arms; the only tournament arm is `tournamentQuitEnabled 0x32D
|
||||||
|
→ +0x1fd3b` (quit, live=1).
|
||||||
|
- **Gate byte** `+0x1fd3b`: getter stub `0x18011c660` is the vtable **emit**
|
||||||
|
accessor the config serializer `0x18006ccd0` pairs with the JSON key to write it
|
||||||
|
out — the client emits it, does not read it as a server input. Writer
|
||||||
|
`0x18011dd3d`, `al = sete(cmp settings[rbx+off],1)`, defaults to 1. Already 1,
|
||||||
|
wrong feature.
|
||||||
|
- **Dedicated endpoint, never called.** `tournament_list` (deser `0x180169ef0`) and
|
||||||
|
`tournament_user` (deser `0x180147cb0`) exist in `utas_server.py` but grep over
|
||||||
|
`captures/` and the live `/tmp/utas_server.log` (3224 lines) finds **zero**
|
||||||
|
ProtoHttp requests for any `/tournament` path across all boots — same as `/season`.
|
||||||
|
The responses are never consumed.
|
||||||
|
- **Front-end.** No CardsDLL response deserializer writes any "tournament
|
||||||
|
available/unlocked" field. `eligibilities 0xf1` / `unlocks 0x35c` / `available 0x3e`
|
||||||
|
are SBC/store vocab per `docs/ENDPOINT_MAP.md`, not wired to tournaments. Decision
|
||||||
|
is in the packed FIFA17.exe.
|
||||||
|
|
||||||
|
**Authority boundary.** The only server-touchable tournament byte
|
||||||
|
(`IS_TOURNAMENT_QUIT_ENABLED`) is an **emitted output** governing a different
|
||||||
|
feature, already 1. Everything else is cosmetic hub data or post-entry reward data.
|
||||||
|
Tile availability is decided **client-side**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. What changed vs the prior conclusion
|
||||||
|
|
||||||
|
The prior workflow examined **only the `FutDataManagerImpl` gate bytes** and
|
||||||
|
concluded "no server fix" for these modes. This workflow re-opened the question by
|
||||||
|
chasing the **hub-atom lead** — the six mode sub-deserializers we do not currently
|
||||||
|
populate — plus massinfo members, settings arms, and dedicated endpoints.
|
||||||
|
|
||||||
|
**The hub-atom lead does not change the conclusion for any mode.** Per mode:
|
||||||
|
|
||||||
|
- **Seasons:** the hub `friendlySeason`/`offline`/`onlineSeason` sub-objects are
|
||||||
|
numeric stat blobs (division/games/points), cosmetic like `hub.tradePile`. The
|
||||||
|
`[r14+0xa]=1` byte is a "field present" marker, not a JSON enable. No change —
|
||||||
|
still NOT_SERVER_REACHABLE.
|
||||||
|
- **Draft:** `draftSummary` carries only `draftState`+`gamesWon`; verify additionally
|
||||||
|
found the in-DLL navigation emitter already routes to the *enabled* destination on
|
||||||
|
current live state. No change — verdict **strengthened**.
|
||||||
|
- **SBC/Objectives:** the objectives hub arm is a cosmetic list feeding "MANAGER
|
||||||
|
TASKS N/M". Verify *corrected the prior chain* — the real objectives-enable byte is
|
||||||
|
`+0x1fd44` (inside the gate block, live=1), and SBC's enable key falls in a
|
||||||
|
dispatch gap with no byte at all. No change to the verdict; the correction only
|
||||||
|
hardens it.
|
||||||
|
- **Tournaments:** both hub sub-objects are display/clientData only. No change.
|
||||||
|
|
||||||
|
**Net:** examining the hub atoms was the right next step, and it closed the lead
|
||||||
|
rather than opening a fix. Every server-reachable surface for these four modes is
|
||||||
|
now accounted for and none is an availability input. The prior "no server fix"
|
||||||
|
conclusion holds, now on much broader evidence.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Client-vs-server authority boundaries (explicit)
|
||||||
|
|
||||||
|
| Surface | Who writes it | Who reads it | Is it a mode-availability gate? |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Gate bytes `+0x1fd3a/3b/3d/44` etc. | **server** (settings applier `FUN_18011dc50`) | **client** (getter stubs, off-DLL vtable dispatch) + config serializer `0x18006ccd0` (emit) | No — all live=1, never branched on inside CardsDLL |
|
||||||
|
| Hub mode sub-objects (`0x131/1ec/1f6/e4/328/32c`) | **server** (`/hub` body) | CardsDLL parsers → cosmetic captions/counts | No — no enable atom in any of the six desers |
|
||||||
|
| `[r14+0xa]=1`, `[rdi+0x162]=1`, `[rsp+0x21]==1` markers | CardsDLL parser (local) | same parser | No — "field present" bookkeeping, never JSON-sourced |
|
||||||
|
| Settings config keys (`friendlySeasonsEnabled`, `enableDraftMode`, `enableObjectives`, `tournamentQuitEnabled`) | **server** (`/settings`) | applier → gate bytes → **client** | No — inputs already at enabled; readers are off-DLL |
|
||||||
|
| SBC enable (`enableSquadBuildingSetsFeature 0x100`) | — | — | **No surface** — falls in the settings dispatch gap, lands on no byte |
|
||||||
|
| Offline/online season enable | — | — | **No surface** — no config key, no gate byte, no getter |
|
||||||
|
| `/season`, `/tournament`, `/sbs/*` endpoints | server (utas, routed) | never requested at hub | No — client never polls them; tile greys before any request |
|
||||||
|
| DestroyMatch reward fields (`tournamentCoins`, `teamOfTournamentWinner`) | server (post-match) | reward payout | No — reached only inside a match |
|
||||||
|
| The greying/refusal decision itself | — | **client** (Denuvo-packed FIFA17.exe / Frostbite viewmodels) | **This is the gate — and it has no server surface** |
|
||||||
|
|
||||||
|
The single load-bearing fact across all four modes: **every server-writable enable
|
||||||
|
input that exists is already at ENABLED live, its only reader is the client, and the
|
||||||
|
tile refuses anyway.** No response body we can send flips a state the front-end has
|
||||||
|
already decided.
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,256 @@
|
|||||||
|
# FIFA 17 SBC client-hook implementation plan
|
||||||
|
|
||||||
|
## Outcome
|
||||||
|
|
||||||
|
Implement an opt-in, fail-closed hook that repairs the native response-to-deserializer
|
||||||
|
dispatch for `GET /ut/game/fifa17/sbs/sets`. The hook must reuse the genuine response
|
||||||
|
object and SAX reader from the real HTTP 200 transaction, run synchronously on the native
|
||||||
|
transaction thread, and preserve the game's allocator, object ownership, callbacks, and
|
||||||
|
index rebuilds.
|
||||||
|
|
||||||
|
This plan supersedes the intervention direction in `plan-2026-08-07-sbc-hook.md` and
|
||||||
|
`sbc-hook-dll-spec.md` wherever those documents claim the client never issues `/sbs/sets`
|
||||||
|
or recommend constructing a synthetic reader. The fresh 10:20:20 exchange proves the
|
||||||
|
request is issued and receives populated JSON. The reconciliation report is authoritative.
|
||||||
|
|
||||||
|
## Proven anchors
|
||||||
|
|
||||||
|
All addresses are static VAs in `CardsDLL_Win64_retail.dll`, image base `0x180000000`.
|
||||||
|
Runtime addresses are `CardsDLL base + (static VA - 0x180000000)`.
|
||||||
|
|
||||||
|
| Purpose | Address / identity |
|
||||||
|
|---|---|
|
||||||
|
| Category request constructor | `0x18017a7c0`, request vtable `0x18022e5c0`, tag `0x753c` |
|
||||||
|
| `/sets` URI builder | `0x18017a980` |
|
||||||
|
| Typed response factory | `0x18017aa10`, response vtable `0x18022e5b0` |
|
||||||
|
| Typed category deserializer | `0x18017b2b0`, `rcx=response`, `rdx=genuine reader` |
|
||||||
|
| Generic completion | `0x18016cca0`, exact-200 check at `0x18016cdd0` |
|
||||||
|
| FUT root | `A = *0x1802e6398`, expected vtable `0x18021c2a0` |
|
||||||
|
| SBC gate cache | `B=A+0x1f9d8`; ready byte `B+0x28` |
|
||||||
|
| Category store | `M=*(A+0x20a68)`; count `WORD[M+0x50]` |
|
||||||
|
| Renderer count read | `0x1800b5eda` |
|
||||||
|
|
||||||
|
Entering `0x18017b2b0` necessarily invokes the `A+0x20a68` lazy getter before JSON-key
|
||||||
|
parsing. The fresh transaction left that pointer null, proving that the typed category
|
||||||
|
deserializer was not entered.
|
||||||
|
|
||||||
|
## Architecture decision
|
||||||
|
|
||||||
|
Use the existing `openfut-hook` Rust `cdylib` and FIFA 17 feature boundary. Retain its
|
||||||
|
deferred CardsDLL discovery, RVA calculation, guarded reads, default-off environment
|
||||||
|
gates, and logging. Replace the stale Tier-1 idea of constructing a reader with this flow:
|
||||||
|
|
||||||
|
```text
|
||||||
|
real /sbs/sets HTTP 200
|
||||||
|
-> native generic completion and typed-response factory
|
||||||
|
-> observe the real response object and real reader/body cursor
|
||||||
|
-> at the proven skipped dispatch boundary, call the original typed method once
|
||||||
|
-> native parser populates M and rebuilds its indices
|
||||||
|
-> resume the native callback/completion chain
|
||||||
|
-> validate M; use native gate state if available
|
||||||
|
-> only if necessary, arm B+0x28 while B+0x08 remains zero
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not intercept at the socket layer, fabricate a SAX reader, retain response/reader
|
||||||
|
pointers beyond their synchronous lifetime, hand-build EASTL category/set records, or
|
||||||
|
write `B+0x08`/`B+0x20`.
|
||||||
|
|
||||||
|
## State and feature gates
|
||||||
|
|
||||||
|
Use independent flags; no stronger stage should be implied by a weaker one:
|
||||||
|
|
||||||
|
- `OPENFUT_SBC_HOOK=1`: resolve and fingerprint only.
|
||||||
|
- `OPENFUT_SBC_TRACE=1`: install passive probes and structured logging.
|
||||||
|
- `OPENFUT_SBC_DISPATCH=1`: enable the one-shot native dispatch repair.
|
||||||
|
- `OPENFUT_SBC_COMMIT=1`: permit gate/refresh action after validated parse success.
|
||||||
|
- Keep `OPENFUT_SBC_ARM_ONLY=1` solely as a separate negative-control experiment.
|
||||||
|
|
||||||
|
Represent runtime progress with an atomic state machine:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Disabled -> Resolved -> Intercepted -> Parsed -> Validated -> Committed
|
||||||
|
\-> Failed
|
||||||
|
```
|
||||||
|
|
||||||
|
Add a recursion-depth guard and a transaction one-shot keyed by request/response identity.
|
||||||
|
Any fingerprint, pointer, status, class, thread, reader, or postcondition mismatch moves to
|
||||||
|
`Failed` and resumes native execution without a write.
|
||||||
|
|
||||||
|
## Milestones
|
||||||
|
|
||||||
|
### M0 — reconcile and freeze the baseline
|
||||||
|
|
||||||
|
1. Mark the reconciliation report as the address/path authority.
|
||||||
|
2. Record SHA-256, PE timestamp, `SizeOfImage`, and selected section hashes for the shipped
|
||||||
|
CardsDLL, FIFA executable, built hook, and deployed proxy DLL.
|
||||||
|
3. Preserve a known-good launcher and proxy DLL. Do not overwrite a game-directory DLL
|
||||||
|
without an exact backup and hashes.
|
||||||
|
4. Capture a baseline: FUT hub succeeds, `/sbs/sets` returns 200, SBC shows the modal,
|
||||||
|
`M==0`, and the category deserializer is not observed.
|
||||||
|
|
||||||
|
Exit: the baseline is repeatable and its artifacts identify one binary build exactly.
|
||||||
|
|
||||||
|
### M1 — stabilize DLL loading
|
||||||
|
|
||||||
|
The existing `version.dll` injection has one historical successful log, but the current
|
||||||
|
FIFA 17 launcher disables it after later crashes. Resolve this before SBC detours:
|
||||||
|
|
||||||
|
1. Port or implement the complete VERSION proxy export surface and forward every export.
|
||||||
|
2. Build only `--features fifa17` for `x86_64-pc-windows-gnu` into a staging directory.
|
||||||
|
3. Inspect PE architecture, exports, and imports with the MinGW binutils.
|
||||||
|
4. Add a FIFA-17-specific launch path using the existing prefix/UMU configuration and
|
||||||
|
explicit `WINEDLLOVERRIDES=version=n,b`.
|
||||||
|
5. Run three cold launches with every SBC mutation/trace flag disabled.
|
||||||
|
|
||||||
|
Exit: all three launches reach the FUT hub, VERSION calls forward correctly, and disabling
|
||||||
|
the override restores the pre-hook baseline.
|
||||||
|
|
||||||
|
### M2 — strengthen runtime resolution
|
||||||
|
|
||||||
|
Before any detour or byte write, validate:
|
||||||
|
|
||||||
|
- exact CardsDLL identity (`SizeOfImage`, PE metadata, and multiple section/function hashes);
|
||||||
|
- FNV control bytes at `0x180180d00`;
|
||||||
|
- expected bytes at every proposed patch site;
|
||||||
|
- `A` and its expected vtable;
|
||||||
|
- `B` and its expected vtable;
|
||||||
|
- readable `M` slot and sane cache fields; and
|
||||||
|
- that runtime VAs lie inside the expected CardsDLL sections.
|
||||||
|
|
||||||
|
Use the external read-only `futmem`/probe tooling as an independent oracle. Never cache an
|
||||||
|
ASLR slide across launches.
|
||||||
|
|
||||||
|
Exit: resolve-only mode passes on two launches with different slides and aborts cleanly on
|
||||||
|
a deliberately mismatched fingerprint fixture.
|
||||||
|
|
||||||
|
### M3 — passive transaction tracing
|
||||||
|
|
||||||
|
Instrument, without changing return values or state:
|
||||||
|
|
||||||
|
1. generic completion `0x18016cca0`;
|
||||||
|
2. typed response factory `0x18017aa10`;
|
||||||
|
3. typed category deserializer `0x18017b2b0`; and
|
||||||
|
4. once found, the common body/SAX virtual-dispatch callsite.
|
||||||
|
|
||||||
|
Log a monotonic timestamp, session/build ID, thread ID, recursion depth, status, request
|
||||||
|
pointer/vtable, response pointer/vtable, reader/body pointer and vtable, and `M`/`B`
|
||||||
|
before and after. Correlate a request ordinal with `/tmp/utas.log`; do not log SID/auth
|
||||||
|
values or full response bodies.
|
||||||
|
|
||||||
|
Do not use the existing generic four-register probe wrapper for `0x18016cca0`. That routine
|
||||||
|
has a fifth stack argument. Use a relocated trampoline or a narrowly verified assembly
|
||||||
|
stub that preserves the full Win64 ABI: nonvolatile GPRs, XMM6-XMM15 if touched, 32-byte
|
||||||
|
shadow space, 16-byte call alignment, and all stack arguments. The diagnostic
|
||||||
|
unhook/call/rehook mechanism is also racy and is not acceptable for the final repair.
|
||||||
|
|
||||||
|
Exit: one fresh exchange unambiguously identifies whether the factory is skipped, the typed
|
||||||
|
object exists without a body/reader, or virtual deserialization dispatch is skipped.
|
||||||
|
|
||||||
|
### M4 — reverse the exact dispatch contract
|
||||||
|
|
||||||
|
Use M3 captures and static analysis to answer all of these before enabling intervention:
|
||||||
|
|
||||||
|
- the exact common body-to-response-deserializer callsite;
|
||||||
|
- the relationship between response vtable `0x18022e5b0` slot `+0x08` and the older
|
||||||
|
message-object vtable `0x18022e598` slot `+0x20`;
|
||||||
|
- which completion argument or object field owns the genuine reader;
|
||||||
|
- the reader's valid synchronous lifetime;
|
||||||
|
- whether `0x1800b8c30` executes after a successful forced parse;
|
||||||
|
- the native transaction/game thread identity; and
|
||||||
|
- whether the parser can be reached more than once for one response.
|
||||||
|
|
||||||
|
Exit: a written call contract identifies the exact hook site, preserved instructions,
|
||||||
|
original target, arguments, ownership, thread, and resume address.
|
||||||
|
|
||||||
|
### M5 — behavior-preserving detour
|
||||||
|
|
||||||
|
Install the production-form detour at the chosen boundary but initially tail-call the
|
||||||
|
original path unchanged. Prefer a small audited trampoline abstraction over copying the
|
||||||
|
repository's unhook/rehook diagnostic pattern.
|
||||||
|
|
||||||
|
Exit: exactly one balanced entry/exit is recorded per SBC exchange; HTTP traffic, modal,
|
||||||
|
M/B state, timing, and unrelated FUT screens remain unchanged.
|
||||||
|
|
||||||
|
### M6 — guarded dispatch repair
|
||||||
|
|
||||||
|
On the native transaction thread and only while the genuine objects are live:
|
||||||
|
|
||||||
|
1. require request vtable `0x18022e5c0`, response vtable `0x18022e5b0`, and status 200;
|
||||||
|
2. require a readable reader pointer/vtable and recursion depth zero;
|
||||||
|
3. require that this transaction has not already been parsed;
|
||||||
|
4. call the original typed method `0x18017b2b0(response, reader)` exactly once;
|
||||||
|
5. capture its return and the resulting M state; and
|
||||||
|
6. resume the native completion/callback path.
|
||||||
|
|
||||||
|
Never run this from the deferred worker or while the SBC controller is iterating. Do not
|
||||||
|
attempt in-place memory repair after an exception or partial parse; preserve logs and
|
||||||
|
relaunch FIFA.
|
||||||
|
|
||||||
|
Exit: the deserializer is observed once, returns successfully, and native execution
|
||||||
|
continues without gate or refresh writes.
|
||||||
|
|
||||||
|
### M7 — validate and commit UI state
|
||||||
|
|
||||||
|
Before exposing populated data, require:
|
||||||
|
|
||||||
|
- `M != 0` and a bounded category count;
|
||||||
|
- category vector `begin <= end <= capacity`;
|
||||||
|
- `(end-begin) % 0xf0 == 0` and vector length equals `WORD[M+0x50]`;
|
||||||
|
- sane, unique category/set identifiers and bounded nested counts;
|
||||||
|
- all native index-rebuild/finalization calls observed; and
|
||||||
|
- no duplicate parse or partial state.
|
||||||
|
|
||||||
|
First allow the native callback to arm the cache. If it does not, the only fallback is
|
||||||
|
`BYTE[B+0x28]=1` while `B+0x08==0`; never write `B+0x08` or `B+0x20`. Initially require
|
||||||
|
the user to close/reopen SBC for refresh. Do not synthesize Scaleform events until the
|
||||||
|
signature and ownership contract of `0x1801a4a70` are independently proven.
|
||||||
|
|
||||||
|
Exit: no modal; displayed categories and set counts match the served response.
|
||||||
|
|
||||||
|
### M8 — regression, soak, and rollback proof
|
||||||
|
|
||||||
|
1. Open/close SBC ten times; enter every set/challenge and return.
|
||||||
|
2. Verify a second `/sets` response is idempotent and does not duplicate data.
|
||||||
|
3. Smoke-test hub, club, store, squads, and normal service traffic.
|
||||||
|
4. Repeat from two fresh launches with different ASLR slides.
|
||||||
|
5. Soak 30–60 minutes with navigation and, if supported, repeated FUT enter/exit.
|
||||||
|
6. Disable all SBC flags and confirm the baseline behavior returns without detours/writes.
|
||||||
|
7. Disable `WINEDLLOVERRIDES`, restore the exact backed-up proxy if needed, and prove hard
|
||||||
|
rollback with FIFA closed.
|
||||||
|
|
||||||
|
Exit: zero crashes/freezes, stable counts and memory behavior, no unrelated FUT regression,
|
||||||
|
and both soft and hard rollback are demonstrated.
|
||||||
|
|
||||||
|
## Testing and build checks
|
||||||
|
|
||||||
|
Run at minimum:
|
||||||
|
|
||||||
|
```text
|
||||||
|
cargo fmt --check
|
||||||
|
cargo test --features fifa17
|
||||||
|
cargo check --release --features fifa17 --target x86_64-pc-windows-gnu
|
||||||
|
cargo build --release --features fifa17 --target x86_64-pc-windows-gnu
|
||||||
|
```
|
||||||
|
|
||||||
|
Extract pure, host-testable helpers for RVA calculation, fingerprint comparison, state
|
||||||
|
transitions, bounded vector validation, and structured event formatting. Windows calls,
|
||||||
|
raw pointer reads, and patching should remain behind small interfaces so guard logic can be
|
||||||
|
tested without launching FIFA.
|
||||||
|
|
||||||
|
## Stop conditions
|
||||||
|
|
||||||
|
Stop and roll back on any unknown binary fingerprint, patch-byte mismatch, wrong vtable,
|
||||||
|
wrong thread, unexpected factory/deserializer count, recursion, invalid vector geometry,
|
||||||
|
missing finalizer, partial parse, crash/freeze, unrelated FUT regression, or save/profile
|
||||||
|
change. Preserve hook log, UTAS log, binary hashes, and crash evidence before relaunching.
|
||||||
|
|
||||||
|
## Definition of done
|
||||||
|
|
||||||
|
- The hook is default-off and endpoint/class-specific.
|
||||||
|
- Exact binary and patch-site fingerprints are verified before intervention.
|
||||||
|
- The real category deserializer runs exactly once for each intended HTTP 200 response,
|
||||||
|
using the genuine response and reader on their native thread.
|
||||||
|
- `M` passes structural validation and the populated SBC menu supports drill-down.
|
||||||
|
- No communication modal appears and non-SBC FUT behavior is unchanged.
|
||||||
|
- Two fresh ASLR-distinct launches and the soak test pass.
|
||||||
|
- Unsetting flags restores inert behavior; removing the proxy restores the original launch.
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
# SBC Menu Render Intervention — Plan (2026-08-07)
|
||||||
|
|
||||||
|
**STATUS (one line): YES, WITH CAVEATS — a populated SBC menu is achievable via a
|
||||||
|
client-side hook, but ONLY by making the game's own parser fill its store; a
|
||||||
|
/proc/mem byte poke alone can open the menu (negative control) but renders EMPTY, and
|
||||||
|
the one remaining un-reversed item (the SAX input-source `vtable[+0x8]` byte-yield
|
||||||
|
contract) blocks the fully-offline populate until a served /sbs/sets response or a
|
||||||
|
completed reader is wired.**
|
||||||
|
|
||||||
|
All addresses are on-disk RVAs against CardsDLL image base `0x180000000`
|
||||||
|
(`/mnt/games/FIFA 17/CardsDLL_Win64_retail.dll`, working copy `/tmp/fut/cardsdll.dll`).
|
||||||
|
Live slide this session = `0x6ffe7c140000` (mapped base `0x6ffffc140000`), proven via
|
||||||
|
FNV prologue at `0x180180d00`. Live values below are from read-only `/proc/12201/mem`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Definitive SBC data-flow
|
||||||
|
|
||||||
|
### Object graph
|
||||||
|
- **A** = FUT root singleton = `*[0x1802e6398]`. Getter `0x18011a830`. A.vtable static
|
||||||
|
`0x18021c2a0`. Live A = `0xb83e2b60` (vtable matches static — CONFIRMED).
|
||||||
|
- **B** = SBC request/TTL gate cache = `A + 0x1f9d8`. B-getter = A.vtable[+0x4e8] =
|
||||||
|
thunk `0x18011c1f0` (`lea rax,[rcx+0x1f9d8]; ret`). B.vtable static `0x1801fae70`
|
||||||
|
(3 slots: dtor `0x180063040`, isValid `0x180065d40`, clear `0x180065d20`). Live B =
|
||||||
|
`0xb8402538` (vtable matches). **B is the GATE, not the render source.**
|
||||||
|
- **M** = SBC categories/sets store = `*(A + 0x20a68)`. Reached via A.vtable[+0x9b0] =
|
||||||
|
lazy getter `0x18011b7d0` (if `A[+0x20a68]==0` it factory-creates an EMPTY M, type-id
|
||||||
|
`0x13f0`, and caches it). Live M = `0x0` (never built this session — SBC menu not
|
||||||
|
opened). **M IS the render source.**
|
||||||
|
- The "SBC manager" is **A itself**: service-id `0xed84b12` resolver A.vtable[+0x18] =
|
||||||
|
`0x180113f50` returns `this`, so `manager.vtable[+0x9b0] == A.vtable[+0x9b0] ==
|
||||||
|
0x18011b7d0`. The old lead `0x1801e9010` is DEBUNKED — it is an `.rdata` function
|
||||||
|
pointer slot (`->0x18018577a`), not a manager global.
|
||||||
|
|
||||||
|
### Render source (CLIENT authority)
|
||||||
|
The SBC hub/squads controller (ctor `0x1800b5267`) caches M into `controller+0x140`
|
||||||
|
by calling A.vtable[+0x9b0] once (`0x1800b554d`→`0x1800b5571`→store `[rsi+0x140]`),
|
||||||
|
then registers Scaleform events `0x756c`–`0x7574`. The tile-build method (`0x1800b5e00`
|
||||||
|
region) reads `[ctrl+0x140]=M` and at **`0x1800b5eda`** does
|
||||||
|
`movzx ebx,WORD[M+0x50]; add bx,0x2; call [scaleform.vtable+0x58](count)` → emits
|
||||||
|
**(category_count + 2) tiles**. This region reads `[ctrl+0x140]` seven times and reads
|
||||||
|
B/`A+0x1fa00` **zero** times. M layout: cat count `WORD[M+0x50]`; cat vector
|
||||||
|
`[M+0x58]..[M+0x60]` stride `0xf0`; per-cat set count `WORD[cat+0xb8]`, set vector
|
||||||
|
`[cat+0xc0]` stride `0x3570`; secondary/featured vec `[M+0xa10]..[M+0xa18]`;
|
||||||
|
indices at `+0x9e0/+0xa10/+0xa40`. **Correction on record:** earlier passes that
|
||||||
|
called `B[+0x08]` the render source conflated the gate with the data source — the empty
|
||||||
|
render was because M was null/empty, NOT because `B[+0x08]` was null.
|
||||||
|
|
||||||
|
### Populate path (CLIENT authority)
|
||||||
|
The sbs/sets deserializer **`0x18017b2b0`** (rcx=this IGNORED; rdx=SAX cursor is the
|
||||||
|
only live input) does the whole populate: fetch manager → get store M via
|
||||||
|
`[manager.vtable+0x9b0]` (at `0x18017b327`) → clear `0x18015f3a0` → loop atom `0x6f`
|
||||||
|
"categories": per item ctor `0x180159da0` (0xf0, vtable `0x18021b520`), cat-deser
|
||||||
|
`0x18017ab80`, cat-finalize `0x180160e50`, APPEND `0x18015a770` (copy-ctor
|
||||||
|
`0x18015a2b0`), dtor `0x1801105d0` → after loop rebuild indices `0x180160e00` +
|
||||||
|
`0x180160f30` + `0x180161020` → commit `manager.vtable[+0x8]`. Always returns true.
|
||||||
|
Set-row deser `0x18017ad60`. **Populate-target == render-source (both are M).**
|
||||||
|
|
||||||
|
### Prefetch gate (SERVER/front-end authority — THE WALL)
|
||||||
|
There is **no native flag** to flip. The only native online check `0x1801642c0`
|
||||||
|
(inside isValid) is stubbed `mov al,1; ret` — NOT the wall. The block is upstream in
|
||||||
|
the Flash/ActionScript FUT front-end (FNV-name-hash bound; `RequestChallengeData` =
|
||||||
|
`0x1801f9b30`, `futsbchubviewmodel` = `0x1801ee0a0` — no native xref), which refuses to
|
||||||
|
issue `GET ut/game/fifa17/sbs/sets` offline, so deser `0x18017b2b0` never runs.
|
||||||
|
**Newly proven:** the URL template `"ut/%s/sbs"` (`0x18021d908`) has ZERO references
|
||||||
|
in the image (siblings `ut/%s/tournament`, `ut/%s/season` ARE referenced) — so
|
||||||
|
**CardsDLL has no native code that self-builds/issues the sbs GET.** This kills any
|
||||||
|
"force the req-mgr at A+0x2a0 to fetch on its own" idea. This is why the fix must be
|
||||||
|
client-side and must FORCE the populate.
|
||||||
|
|
||||||
|
### Ready-arm (CLIENT authority)
|
||||||
|
isValid `0x180065d40(B)` verified: `if !0x1801642c0() ret0` (stub→always passes);
|
||||||
|
`cmp [rbx+0x28],0; je fail`; **`cmp QWORD[rbx+0x8],0; je 0x180065d75` → returns 1
|
||||||
|
immediately (short-circuit)**; else QueryPerformanceCounter (`0x1801e50c0`) and compare
|
||||||
|
`[rbx+0x20]` deadline. Normally B is armed by the completion callback `0x1800b8c30`
|
||||||
|
(subscribed in svc ctor `0x1800b5765` via `manager.vtable[+0xa90]`) through the generic
|
||||||
|
cache copy-assign `0x1800c21a0` (sets B+0x08=collection, B+0x20=deadline, B+0x28=1).
|
||||||
|
Offline that callback never fires (no response). Live: `B[+0x08]=0`, `B[+0x28]=0`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Chosen minimal intervention and WHY
|
||||||
|
|
||||||
|
**Reuse the client's own parser; do NOT hand-build structs; arm ONLY `B[+0x28]`.**
|
||||||
|
|
||||||
|
Two tiers, safest-first:
|
||||||
|
|
||||||
|
- **Tier-0 (negative control — proves the gate):** write ONLY `BYTE[B+0x28]=1`.
|
||||||
|
isValid short-circuits (B+0x08==0 branch) → menu OPENS instead of the error modal
|
||||||
|
(`0x18016c330`), but renders EMPTY (M is null/empty). Do NOT write `B+0x08` or
|
||||||
|
`B+0x20` — pointing B+0x08 at a collection forces isValid into the QPC-deadline
|
||||||
|
branch, and with the live-stale deadline (`0xf10fb8cb9`) the gate SHUTS → modal, i.e.
|
||||||
|
it DEFEATS the fix. This is the load-bearing correction from adversarial verification.
|
||||||
|
|
||||||
|
- **Tier-1 (real fix — populates M):**
|
||||||
|
- **Preferred (Option 1, cleanest, zero forged state):** inject a canned
|
||||||
|
`/sbs/sets` JSON response at the message-receive layer so the game builds the
|
||||||
|
response-msg (ctor `0x18017b1c0`, vtable `0x18022e598`, deser slot +0x20 =
|
||||||
|
`0x18017b2b0`), seats a genuine SAX cursor, its OWN chain populates M, and the
|
||||||
|
native completion callback `0x1800b8c30` arms B for you. The bridge/core serves the
|
||||||
|
JSON. Nothing forged.
|
||||||
|
- **Fallback (Option 2):** from the hook, stand up a real SAX cursor over canned JSON
|
||||||
|
(ctx `0x1801c63e0` + lexer `0x1801c8060` + an input-source whose `vtable[+0x8]`
|
||||||
|
yields bytes), call deser `0x18017b2b0(rcx=ignored, rdx=cursor)`, then arm ONLY
|
||||||
|
`BYTE[B+0x28]=1`. **Blocker:** the input-source `vtable[+0x8]` byte-yield contract
|
||||||
|
is the ONE un-reversed item — a cold call with a null-source cursor CLEARS M
|
||||||
|
(`0x18015f3a0`) then byte-scans a garbage pointer (`mov rdi,[rdi]` ~`0x18017b353`)
|
||||||
|
→ wipes state + segfault. So Option 2 is NOT safe to run until the reader is
|
||||||
|
reversed.
|
||||||
|
|
||||||
|
**Why not hand-build:** feeding `0x18015a770` a hand-built 0xf0 category (with nested
|
||||||
|
0x3570 set records / EASTL sub-vectors) is the highest crash risk — the copy-ctor
|
||||||
|
`0x18015a2b0` deep-copies inner sub-vectors; any bad begin/end/cap → heap corruption.
|
||||||
|
The parser writes the correct geometry AND runs the index-rebuild finalizers that
|
||||||
|
hand-built appends get wrong. Ruled out.
|
||||||
|
|
||||||
|
**Refresh:** after M is populated, fire refresh events `0x756c`–`0x7574` (or re-open the
|
||||||
|
menu) so `0x1800b5eda` re-reads `WORD[M+0x50]`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. STAGED MORNING TEST PLAN (safest-first)
|
||||||
|
|
||||||
|
Precondition: FIFA at the FUT hub with CardsDLL loaded. Rollback for EVERY step =
|
||||||
|
**relaunch FIFA** (all effects are volatile — single-byte poke or in-session hook state,
|
||||||
|
cleared on restart). NEVER run `--apply` while the SBC menu is open/mid-iterate.
|
||||||
|
|
||||||
|
### Step 1 — Dry-run read confirm (ZERO writes)
|
||||||
|
```
|
||||||
|
python3 /home/alex/Documents/OpenFUT/fifa17-recon/tools/sbc_hook_poke.py
|
||||||
|
```
|
||||||
|
Expect: CONTROL FNV MATCH; A vtable match; B offset decoded live = `0x1f9d8`; B/A vtables
|
||||||
|
match statics; `B+0x28=0`; `M=*(A+0x20a68)=0` (until SBC menu opened once).
|
||||||
|
PASS = addresses match the model. Rollback: none needed (read-only).
|
||||||
|
|
||||||
|
### Step 2 — Review the DLL populate spec (ZERO writes)
|
||||||
|
```
|
||||||
|
python3 /home/alex/Documents/OpenFUT/fifa17-recon/tools/sbc_hook_poke.py --spec
|
||||||
|
```
|
||||||
|
Expect: printed injected-DLL spec (Option 1 preferred, Option 2 fallback). Read-only.
|
||||||
|
|
||||||
|
### Step 3 — Negative control (Tier-0, ONE byte write) — proves the GATE
|
||||||
|
With the SBC menu **CLOSED**:
|
||||||
|
```
|
||||||
|
python3 /home/alex/Documents/OpenFUT/fifa17-recon/tools/sbc_hook_poke.py --apply
|
||||||
|
```
|
||||||
|
Writes exactly `BYTE[B+0x28]=1` (re-proves slide+vtables at write time; aborts on any
|
||||||
|
mismatch; hard-refuses to write B+0x08/B+0x20). Then re-open the SBC menu.
|
||||||
|
Expect: menu OPENS, no error modal, ~2 empty/placeholder tiles. This proves the gate +
|
||||||
|
isValid short-circuit LIVE — it does NOT prove data. If it CRASHES: stop — B
|
||||||
|
resolution/slide is wrong. Rollback: relaunch FIFA (byte clears on restart).
|
||||||
|
|
||||||
|
### Step 4 — Real fix (Tier-1) — proves the DATA (NOT for a blind run)
|
||||||
|
Do this only after the DLL populate is implemented. Preferred: bring up the bridge/core
|
||||||
|
`/sbs/sets` responder and let Option 1 (message-layer injection) drive the native chain;
|
||||||
|
the completion callback arms B and M fills. Then the same gate opens a POPULATED menu
|
||||||
|
(N+2 tiles). The hook module scaffold is `openfut-hook/src/sbc_hook.rs` — Tier-1
|
||||||
|
`populate_m()` is present but deliberately refuses to call the deser until the SAX
|
||||||
|
input-source reader is reversed (else it clears M and crashes). Build (when ready):
|
||||||
|
```
|
||||||
|
cd /home/alex/Documents/OpenFUT/openfut-launcher/openfut-hook && \
|
||||||
|
cargo build --release --features fifa17 --target x86_64-pc-windows-gnu
|
||||||
|
```
|
||||||
|
Deploy as `version.dll` per launcher setup. Env gates (all default OFF):
|
||||||
|
`OPENFUT_SBC_HOOK=1` (read-only resolve+log), `OPENFUT_SBC_ARM_ONLY=1` (Tier-0),
|
||||||
|
`OPENFUT_SBC_POPULATE=1` (Tier-1, currently logs the blocker and returns).
|
||||||
|
Rollback: unset env vars and relaunch FIFA.
|
||||||
|
|
||||||
|
### Step 5 — Cleanup
|
||||||
|
Unset all `OPENFUT_SBC_*` env vars; relaunch FIFA to a clean state.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Crash-risk assessment
|
||||||
|
|
||||||
|
1. **Cold-calling `0x18017b2b0` without a real seated cursor** — CLEARS M
|
||||||
|
(`0x18015f3a0`) first, then `mov rdi,[rdi]` byte-scan on a garbage ptr → wipes
|
||||||
|
state + segfault. HIGHEST. Tier-1 code refuses this until the reader is reversed.
|
||||||
|
2. **Writing `B+0x08`/`B+0x20`** — forces isValid into the QPC-deadline branch; stale
|
||||||
|
deadline → gate SHUTS (modal), or garbage-ptr iterate crash. Self-defeating.
|
||||||
|
Tool/code write ONLY `B+0x28`.
|
||||||
|
3. **Populate off the game thread / mid-iterate** — lazy getter allocates on game heap,
|
||||||
|
appender mutates EASTL vectors; a foreign thread races the allocator/menu iterate →
|
||||||
|
heap corruption. Tier-1 must run on the game/message-pump thread with the menu closed.
|
||||||
|
4. **Skipping the index-rebuild finalizers** (`0x180160e00/0x180160f30/0x180161020`)
|
||||||
|
after append → stale `+0x9e0/+0xa10/+0xa40` indices → by-index getter `0x180160a80`
|
||||||
|
reads OOB → crash/garbage tiles.
|
||||||
|
5. **`WORD[M+0x50]` > actual 0xf0-stride entries** → tile loop walks past vector end
|
||||||
|
(OOB read).
|
||||||
|
6. **Hand-built 0xf0/0x3570 structs fed to `0x18015a770`** — copy-ctor `0x18015a2b0`
|
||||||
|
deep-copies inner EASTL sub-vectors; bad begin/end/cap → heap corruption. Avoid.
|
||||||
|
7. **No refresh after populate** (non-crash) — controller keeps the cached empty M at
|
||||||
|
`ctrl+0x140`; `0x1800b5eda` won't re-run → still 2 placeholder tiles. Fire
|
||||||
|
`0x756c`–`0x7574` or re-open.
|
||||||
|
8. **Manager/store null** — deser does `mov rax,[rbx]` on the manager; registry lookup
|
||||||
|
(hashes `0xed84b11`/`0xed84b12`) returning null → null-deref. Live registry
|
||||||
|
`*[0x1802c2988]` non-null, so low risk; hook must still null-check M/store.
|
||||||
|
|
||||||
|
Tier-0 (single `B+0x28=1` write, B+0x08 left 0) is the verified-SAFE case: isValid
|
||||||
|
short-circuits to 1, renders empty, no crash; bg-thread-tolerant like the /proc poke.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Poke tool + DLL-spec locations
|
||||||
|
|
||||||
|
- Poke tool (read-only default; `--spec`; `--apply` = ONLY `BYTE[B+0x28]=1`):
|
||||||
|
`/home/alex/Documents/OpenFUT/fifa17-recon/tools/sbc_hook_poke.py`
|
||||||
|
- Negative-control byte poke (older, triple-guarded):
|
||||||
|
`/home/alex/Documents/OpenFUT/fifa17-recon/tools/sbc_populate_poke.py`
|
||||||
|
- Slide/read template + FNV control proof:
|
||||||
|
`/home/alex/Documents/OpenFUT/fifa17-recon/tools/gate_byte_probe.py`
|
||||||
|
- DLL integration spec (RVA math, object graph, gate disasm, function-signature table,
|
||||||
|
3 intervention tiers, 8-item crash register, staged test plan):
|
||||||
|
`/home/alex/Documents/OpenFUT/fifa17-recon/docs/sbc-hook-dll-spec.md`
|
||||||
|
- Injected-DLL module (fifa17-only; Tier-0 live, Tier-1 scaffolded/refusing):
|
||||||
|
`/home/alex/Documents/OpenFUT/openfut-launcher/openfut-hook/src/sbc_hook.rs`
|
||||||
|
(wired via `lib.rs` `#[cfg(feature="fifa17")] mod sbc_hook;` + `fifa17.rs`
|
||||||
|
`crate::sbc_hook::install();`)
|
||||||
|
- Atoms table: `/home/alex/Documents/OpenFUT/fifa17-recon/docs/fut_atoms.tsv`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Client-vs-server authority boundaries (flagged)
|
||||||
|
|
||||||
|
- **RENDER (M, tiles at `0x1800b5eda`)** — CLIENT. The client draws tiles solely from
|
||||||
|
M; the server never touches this. Fix is client-side.
|
||||||
|
- **POPULATE (deser `0x18017b2b0` → M)** — CLIENT parser, SERVER-fed data. The parser
|
||||||
|
is native and reusable; the DATA it needs (`/sbs/sets` JSON) is a server response.
|
||||||
|
Preferred fix has the bridge/core supply that JSON so the client parses it natively.
|
||||||
|
- **PREFETCH GATE (issue `GET sbs/sets`)** — SERVER/front-end. THE WALL. No native
|
||||||
|
flag; the SWF/ActionScript front-end refuses to request offline, and CardsDLL has no
|
||||||
|
native code that issues the GET (`ut/%s/sbs` unreferenced). This cannot be fixed
|
||||||
|
server-side by responding — the request is never sent. The hook must force the
|
||||||
|
populate (inject the response at the message layer or drive the parser).
|
||||||
|
- **READY-ARM (`B[+0x28]`, callback `0x1800b8c30`/commit `0x1800c21a0`)** — CLIENT.
|
||||||
|
Normally armed by the completion callback (server-response-driven); offline the hook
|
||||||
|
arms it (Tier-0 byte, or Option 1 lets the native callback arm it).
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
# SBC "problem communicating with the FIFA Ultimate Team servers" — definitive analysis
|
||||||
|
|
||||||
|
**Date:** 2026-08-07
|
||||||
|
**Binary under study:** `/tmp/fut/cardsdll.dll` (on-disk PE, image base `0x180000000`; copy of `/mnt/games/FIFA 17/CardsDLL_Win64_retail.dll`)
|
||||||
|
**Method:** clean-room, read-only. On-disk `objdump` re-verified in this pass; live values quoted from prior read-only `/proc/<pid>/mem` reads (pid 12201, slide `0x6ffe7c140000`, FNV control MATCH). No memory was written; FIFA was not touched.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## VERDICT (one line)
|
||||||
|
|
||||||
|
**The SBC modal is a CLIENT-SIDE, per-feature completion-path defect — the FUT client never re-arms a fetch/re-render for `sbs/sets` the way it does for the hub — so NO server response can cure it; the only offline lever is a client-memory patch, and the clean single-byte patch (`model+0x1fa00 = 1`) only SUPPRESSES the modal by forcing the completion predicate true, rendering from an empty, never-populated cache. It is NOT the go-online wall.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. What the SBC completion predicate actually checks (CONFIRMED on-disk)
|
||||||
|
|
||||||
|
The SBC menu entry runs a completion continuation whose gate is the shared predicate **`0x180065d40`**, called as `[cache_vtable+0x08]`. Re-disassembled this pass, byte-for-byte:
|
||||||
|
|
||||||
|
```
|
||||||
|
180065d40 call 0x1801642c0 ; online/liveness sub-check
|
||||||
|
180065d4e test al,al
|
||||||
|
180065d50 je fail
|
||||||
|
180065d52 cmp byte [rbx+0x28],0 ; <-- THE GATE: "value ready" flag
|
||||||
|
180065d56 je fail
|
||||||
|
180065d58 cmp qword [rbx+0x8],0 ; pending-op ptr
|
||||||
|
180065d5d je pass (mov al,1) ; empty-collection shortcut -> success
|
||||||
|
180065d5f lea rcx,[rsp+0x38]
|
||||||
|
180065d64 call QueryPerformanceCounter ; [rip]->0x1801e50c0
|
||||||
|
180065d6a mov rax,[rbx+0x20] ; QPC deadline
|
||||||
|
180065d6e sub rax,[rsp+0x38]
|
||||||
|
180065d73 js fail ; deadline passed -> fail
|
||||||
|
180065d75 mov al,1 ; pass
|
||||||
|
...
|
||||||
|
180065d7d xor al,al ; fail
|
||||||
|
```
|
||||||
|
|
||||||
|
Reduces to: `subcheck() && byte[cache+0x28]!=0 && (qword[cache+0x08]==0 || deadline[cache+0x20] not yet past)`.
|
||||||
|
|
||||||
|
- **The online/liveness sub-check `0x1801642c0` is stubbed OUT.** On-disk bytes are `b0 01 c3` = `mov al,1; ret` — always true, in the shipped file (not a live loader patch). **This is the reason SBC is NOT the go-online wall** (see §5).
|
||||||
|
- `cache` (`rbx`) is an **embedded sub-object of the FUT root singleton** `A = *[0x1802e6398]`, selected by a vtable thunk (see §2). Its `+0x28` byte is a "value-ready" flag (init 0 by ctor `0x180062460`); `+0x08` is a pending-op pointer; `+0x20` is a QPC deadline. This is a copyable future/async-result value type. **The predicate never reads the parsed SBC categories, HTTP status, session, or any live-connection boolean.**
|
||||||
|
|
||||||
|
> **AUTHORITY BOUNDARY:** everything the predicate reads lives inside client process memory (`A+…`). Nothing in the `sbs/sets` HTTP response is an input to it. This is a **client-authority** decision end to end.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Why hub passes but `sbs/sets` fails (CORRECTED after adversarial verification)
|
||||||
|
|
||||||
|
Both features run the **same predicate function** `0x180065d40`, but on **different embedded caches**, reached through **different per-response-class continuations**. That structural divergence is real and confirmed. **The originally-stated reason ("hub passes because its cache `+0x28` is set") is WRONG** and is corrected here — corroborated by a live measurement (HUB cache `+0x28 = 0` while the hub is displayed with no modal) and by the on-disk FALSE-branch disassembly gathered this pass.
|
||||||
|
|
||||||
|
### The two continuations, side by side (on-disk, this pass)
|
||||||
|
|
||||||
|
| | SBC (`FutLoadSetTypesServerResponse`) | HUB (`FutGetHubDataServerResponse`) |
|
||||||
|
|---|---|---|
|
||||||
|
| continuation | `0x180154860` | `0x180173770` |
|
||||||
|
| get singleton A | `call 0x18011a830` (`mov rax,[0x1802e6398]`) | same |
|
||||||
|
| select cache | `call [rdx+0x4e8]` → thunk `0x18011c1f0` = `lea rax,[rcx+0x1f9d8]` → **SBC cache A+0x1f9d8** | `call [rdx+0x1f8]` → thunk `0x18011a810` = `lea rax,[rcx+0x1fd70]` → **HUB cache A+0x1fd70** |
|
||||||
|
| predicate | `call [rdx+0x08]` = `0x180065d40` | **same** `0x180065d40` |
|
||||||
|
| on TRUE (jne) | render `0x18015491a → 0x180154600` | render `0x18017383d → 0x1801735e0` |
|
||||||
|
| **on FALSE** | `lea rdx,[rbp-0x9]` (descriptor `0x18020a8b8`); **`call 0x18016c330`**; `jmp` return | **`call 0x1801213b0` (state reset)**; `lea 0x1801736f0` (continuation fn); **`call 0x18011f8e0` (register completion closure)**; `lea 0x18022cd30` (descriptor); **`call 0x18016c330`**; **`call 0x18011f900` (cleanup)** |
|
||||||
|
|
||||||
|
### What this proves
|
||||||
|
|
||||||
|
1. **`0x18016c330` is NOT an SBC-only "modal" function.** The HUB continuation calls the very same `0x18016c330` (at `0x18017382c`) on its own not-ready branch. It is a shared, descriptor-parameterized async dispatcher; SBC passes descriptor `0x18020a8b8`, hub passes `0x18022cd30`.
|
||||||
|
|
||||||
|
2. **At idle both predicates return FALSE.** Live: HUB cache `A+0x1fd70+0x28 = 0` **and** SBC cache `A+0x1f9d8+0x28 = 0`, both `+0x08 = 0`. The hub is on screen with no modal *while its own predicate would return FALSE*. So "hub `+0x28` is set" is false; a set flag is not what makes the hub pass.
|
||||||
|
|
||||||
|
3. **The real asymmetry is the FALSE-branch work.** On not-ready the HUB continuation **resets its request-state region** (`0x1801213b0`), **registers a completion closure** (`0x18011f8e0`, continuation `0x1801736f0`) so the arriving response re-runs the continuation and re-renders, then cleans up (`0x18011f900`). It is a proper get-or-fetch: cache-miss → (re)issue request → render on completion. **The SBC continuation does NONE of that** — it fires the dispatcher once with delegate `0x180154590`/descriptor `0x18020a8b8` and returns. It never re-arms a fetch and never wires the `sbs/sets` response back into a re-render.
|
||||||
|
|
||||||
|
**Conclusion:** hub and SBC diverge at the cache-selection call site (`[rdx+0x1f8]` vs `[rdx+0x4e8]`, one instruction apart), and — decisively — in the not-ready handling. The modal is produced **downstream in the SBC dispatched path** (dispatcher `0x18016c330` + delegate `0x180154590`), because the SBC feature is wired as a one-shot with no re-fetch/re-render, whereas the hub is wired as a self-rearming get-or-fetch. It is **not** decided by cache selection alone, **not** by the shared predicate, and **not** by the `+0x28` byte value at idle.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. VERDICT by route — is SBC beatable, and how?
|
||||||
|
|
||||||
|
| Route | Outcome | Why |
|
||||||
|
|---|---|---|
|
||||||
|
| **A. Server response field / header / status** | **RULED OUT — no offline fix here** | No field in the `sbs/sets` body reaches the predicate (client-authority §1). Deeper: the SBC continuation never registers a completion closure to consume the response and re-render, so *even a perfect response is dropped on the floor*. The deserializer `0x18017b2b0` returning TRUE is genuinely irrelevant. |
|
||||||
|
| **B. Client memory byte patch** `model+0x1fa00 = 1` | **Suppresses the modal, but empty menu — cosmetic** | Forces predicate TRUE → routes to the SBC render branch `0x18015491a → 0x180154600`, which reads the embedded SBC cache. That cache was never populated (`+0x08 == 0`, empty collection), so the likely result is an empty / non-functional SBC screen, not populated SBCs. **Untested under the read-only rule.** |
|
||||||
|
| **C. Config `FUT/SBC_USE_STUBS`** (rdata `0x1802270f8`) | **Not the gate** | Read at the deser top only; the normal (off) path already runs. Flipping it does not touch `+0x28` or the continuation wiring. |
|
||||||
|
| **D. "Needs the go-online wall solved"** | **REFUTED** | The only connection-like sub-check on this path (`0x1801642c0`) is stubbed to always-true on-disk. SBC is blocked by local per-feature completion wiring, not by the reconnect gate. See §5. |
|
||||||
|
| **E. Client CODE patch of the SBC FALSE-branch** | **The only route to a *functional* SBC menu** | Make `0x180154860`'s not-ready branch replicate the hub's sequence: state reset `0x1801213b0` + register completion closure `0x18011f8e0`/`0x1801736f0` + dispatch + cleanup `0x18011f900`, so the `sbs/sets` response is fetched and rendered. This is a code patch, not a byte flip and not a server change. Out of scope for a server-side preservation fix; a client-side authority modification. |
|
||||||
|
|
||||||
|
**Bottom line:** there is **no server-side fix**. SBC is "beatable" only in the client-authority sense — either cosmetically (byte B, hides the modal over an empty menu) or functionally (route E, a code patch replicating the hub's re-arm). Neither is a change our offline server can make.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Memory patch details (if used) — flagged CLIENT-SIDE AUTHORITY
|
||||||
|
|
||||||
|
> **CLIENT-SIDE AUTHORITY — this is a modification of the FIFA client's own process memory, not an OpenFUT server response. It changes what the client decides, and it violates the current read-only rule; it is documented for completeness, not endorsed as the fix.**
|
||||||
|
|
||||||
|
- **Cosmetic modal-suppression (route B):**
|
||||||
|
- **Absolute displacement into FUT root singleton:** `A + 0x1f9d8 + 0x28` = **`model + 0x1fa00`**, where `A = *[0x1802e6398]`.
|
||||||
|
- **Live absolute (pid 12201 snapshot):** `0xb8402538 + 0x28 = 0xb8402560`.
|
||||||
|
- **Value:** write `0x01` (one byte).
|
||||||
|
- **Effect:** predicate `0x180065d40` short-circuits at `cmp byte[rbx+0x28],0` → with `+0x08==0` the empty-collection shortcut returns TRUE → continuation `jne 0x18015491a` renders. **Modal gone; SBC cache empty → expect an empty/possibly-broken menu.** Not verified (read-only).
|
||||||
|
- **Persistence:** the object is embedded in the singleton (singleton lifetime). The SBC path calls only `[vt+0x08]`; nothing on this path calls the invalidator `[vt+0x10]=0x180065d20`, so a write should persist across menu re-entry (inferred from structure, not demonstrated).
|
||||||
|
|
||||||
|
- **Functional fix (route E)** requires a `.text` patch to the SBC continuation, not a data byte — see §3 row E. Do not confuse the two.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Relationship to the online-modes / go-online-wall finding
|
||||||
|
|
||||||
|
SBC is **not** the same wall as online Draft's "PRESS Q TO RECONNECT":
|
||||||
|
|
||||||
|
- The single connection-like sub-check reachable from the SBC predicate, `0x1801642c0`, is compiled out (`mov al,1; ret`) in the shipped binary. The SBC gate therefore encodes **no** unmet network condition — it is a purely local completion-wiring problem.
|
||||||
|
- The online modes differ structurally: their gate keeps a real pending network op at `+0x08` and/or a non-stubbed sub-check, so their predicate encodes a network state a local byte-flip cannot satisfy. That is why the online wall is not beatable by a byte and SBC's modal is (cosmetically).
|
||||||
|
- This is consistent with the prior **"refusing modes = no server fix"** finding: no field, count, header, or status in any HTTP response flips the client-side completion state for these features. SBC extends that finding with the precise mechanism — the client never re-arms the `sbs/sets` fetch/re-render at all.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Appendix — confirmed addresses (image base `0x180000000`)
|
||||||
|
|
||||||
|
| Symbol | Address | Note |
|
||||||
|
|---|---|---|
|
||||||
|
| FUT root singleton getter | `0x18011a830` | `mov rax,[0x1802e6398]; ret` |
|
||||||
|
| FUT root singleton ptr | `[0x1802e6398]` | live `A = 0xb83e2b60` |
|
||||||
|
| FUT root vtable (static) | `0x18021c2a0` | |
|
||||||
|
| SBC cache selector thunk | `0x18011c1f0` | `lea rax,[rcx+0x1f9d8]` (slot `A.vt+0x4e8`) |
|
||||||
|
| HUB cache selector thunk | `0x18011a810` | `lea rax,[rcx+0x1fd70]` (slot `A.vt+0x1f8`) |
|
||||||
|
| SBC cache | `A+0x1f9d8` | vtable `0x1801fae70`; live `0xb8402538` |
|
||||||
|
| HUB cache | `A+0x1fd70` | vtable `0x18021c1e0` |
|
||||||
|
| shared predicate `isValid` | `0x180065d40` | `cache.vt+0x08` for both |
|
||||||
|
| stubbed online sub-check | `0x1801642c0` | `b0 01 c3` = `mov al,1; ret` |
|
||||||
|
| cache ctor / copy-ctor | `0x180062460` / `0x1800c21f3` | init `byte[+0x28]=0` |
|
||||||
|
| invalidator | `0x180065d20` | `cache.vt+0x10`; not called on SBC path |
|
||||||
|
| SBC continuation | `0x180154860` | class `RS4:FutLoadSetTypesServerResponse` (str `0x1802270b8`, vt row `0x180227090`) |
|
||||||
|
| HUB continuation | `0x180173770` | class `RS4:FutGetHubDataServerResponse` (str `0x18022ce40`, vt row `0x18022ce18`) |
|
||||||
|
| shared async dispatcher | `0x18016c330` | called by BOTH FALSE-branches (SBC `0x180154913`, HUB `0x18017382c`) |
|
||||||
|
| SBC delegate / descriptor | invoke `0x180154590` / desc `0x18020a8b8` | |
|
||||||
|
| HUB re-arm: state reset | `0x1801213b0` | HUB-only, `0x1801737bd` |
|
||||||
|
| HUB re-arm: register closure | `0x18011f8e0` (cont. `0x1801736f0`) | HUB-only, `0x180173815` |
|
||||||
|
| HUB re-arm: cleanup | `0x18011f900` | HUB-only, `0x180173836` |
|
||||||
|
| SBC render branch (on TRUE) | `0x18015491a → 0x180154600` | reads empty SBC cache |
|
||||||
|
| `sbs/sets` deserializer | `0x18017b2b0` | returns TRUE unconditionally (`mov al,1 @0x18017b751`); irrelevant to predicate |
|
||||||
|
| QueryPerformanceCounter import | `0x1801e50c0` | |
|
||||||
|
|
||||||
|
**Which prior conclusion won:** the structural divergence (same predicate, different cache, different continuation; online sub-check stubbed; not server-fixable) is upheld. The specific pass/fail *reason* is corrected: it is the **FALSE-branch re-arm asymmetry**, not a set `+0x28` byte and not an SBC-exclusive `0x18016c330`.
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
# FIFA 17 SBC response reconciliation
|
||||||
|
|
||||||
|
**Verdict:** the live client receives HTTP 200 for `GET /ut/game/fifa17/sbs/sets`, but the
|
||||||
|
typed `FutSBCLoadCategoryDetailsServerResponse` deserializer is not invoked. The evidence
|
||||||
|
does **not** identify a server-controlled header, envelope field, or correlation value that
|
||||||
|
can fix this. The previous `0x180154860` “SBC continuation” diagnosis was based on the wrong
|
||||||
|
request class and is retracted.
|
||||||
|
|
||||||
|
## Scope and authority
|
||||||
|
|
||||||
|
This pass used only:
|
||||||
|
|
||||||
|
- the shipped `CardsDLL_Win64_retail.dll` copied to `/tmp/fut/cardsdll.dll`;
|
||||||
|
- read-only `/proc/<pid>/mem` access to the running game;
|
||||||
|
- the local OpenFUT request log; and
|
||||||
|
- existing clean-room notes and scripts in this repository.
|
||||||
|
|
||||||
|
No game memory was written, no breakpoint was inserted, and no service or game process was
|
||||||
|
restarted during the measurement.
|
||||||
|
|
||||||
|
## Fresh live observation
|
||||||
|
|
||||||
|
The control run used fresh FIFA process **PID 59054**. The CardsDLL mapping resolved to
|
||||||
|
`0x6ffffc140000`, giving slide `0x6ffe7c140000`. Bytes at static control function
|
||||||
|
`0x180180d00` matched the on-disk DLL, proving the mapping/slide before data reads.
|
||||||
|
|
||||||
|
At the FUT hub, before opening SBC:
|
||||||
|
|
||||||
|
- `A = *[0x1802e6398] = 0xb78f7c50`;
|
||||||
|
- `M = *(A+0x20a68) = 0`;
|
||||||
|
- hub cache byte `*(A+0x1fd70+0x28) = 1` (fresh hub response ready); and
|
||||||
|
- SBC cache byte `*(A+0x1f9d8+0x28) = 0`.
|
||||||
|
|
||||||
|
The user then opened the SBC tile. The real client exchange was:
|
||||||
|
|
||||||
|
```text
|
||||||
|
[10:20:20] GET /ut/game/fifa17/sbs/sets
|
||||||
|
User-Agent: ProtoHttp 1.3/DS 15.1.2.1.0 (Windows)
|
||||||
|
Accept: application/json
|
||||||
|
Content-Type: application/json
|
||||||
|
X-UT-SID: OPENFUT-SID-0000000000000001
|
||||||
|
Accept-Encoding: gzip
|
||||||
|
-> 200 {"categories":[...]}
|
||||||
|
```
|
||||||
|
|
||||||
|
The game displayed “There was a problem communicating with the FIFA Ultimate Team servers.”
|
||||||
|
With that modal still open, the same slide was re-proved and `M` was still exactly zero.
|
||||||
|
|
||||||
|
### What `M == 0` proves
|
||||||
|
|
||||||
|
The typed `/sets` deserializer is `0x18017b2b0`. At `0x18017b309`–`0x18017b327` it obtains
|
||||||
|
the FUT root and calls vtable slot `+0x9b0`, the lazy getter `0x18011b7d0`. That getter
|
||||||
|
allocates and stores `A+0x20a68` before the deserializer examines the root object or the
|
||||||
|
`categories` key.
|
||||||
|
|
||||||
|
Consequently:
|
||||||
|
|
||||||
|
- valid JSON would leave `M` non-null;
|
||||||
|
- malformed or empty JSON reaching this function would also leave `M` non-null; and
|
||||||
|
- `M == 0` after the completed HTTP transaction means `0x18017b2b0` was not invoked.
|
||||||
|
|
||||||
|
The normal reset of `M` is `0x180114ee0`; its observed use belongs to broad FUT-root
|
||||||
|
initialization/reset work, not the `/sets` completion path. There is no evidence that the
|
||||||
|
deserializer ran and then immediately cleared `M` during this transaction.
|
||||||
|
|
||||||
|
## Correct class map
|
||||||
|
|
||||||
|
Three classes were conflated in earlier notes:
|
||||||
|
|
||||||
|
| Function/class | Proven URI | Role |
|
||||||
|
|---|---|---|
|
||||||
|
| `FutSBCLoadCategoryDetailsServerResponse`, request URI builder `0x18017a980`, factory `0x18017aa10`, response deser `0x18017b2b0` | `/sets` under the `ut/%s/sbs` base | Initial category/set list; this is the live failing request |
|
||||||
|
| `FutSBCSetDataServerResponse`, factory `0x18016fca0`, deser `0x18016fe90` | `/squadBuildingSets` (`0x18022bd88`) | Parses `reset`; not the observed `/sbs/sets` request |
|
||||||
|
| `FutLoadSetTypesServerResponse`, deser `0x180154990` | `/challenge/%d/squad` (`0x1802270e0`) | Parses `challengeId`, `playerRequirements`, and `squad`; later challenge flow |
|
||||||
|
|
||||||
|
This corrects two prior claims:
|
||||||
|
|
||||||
|
1. `FutSBCSetDataServerResponse` does **not** share the literal `/sets` URI in this binary;
|
||||||
|
its URI string is `/squadBuildingSets`.
|
||||||
|
2. `0x180154860` is not a dedicated completion continuation for the initial category-list
|
||||||
|
request. `0x180154830` is a generic callback thunk used by multiple request classes, while
|
||||||
|
the nearby `0x180154990` parser and `/challenge/%d/squad` URI belong to
|
||||||
|
`FutLoadSetTypesServerResponse`.
|
||||||
|
|
||||||
|
Therefore the earlier hub-versus-`0x180154860` comparison contrasted the hub with a later
|
||||||
|
challenge-squad operation, not with `GET /sbs/sets`. Its proposed “copy the hub re-arm path”
|
||||||
|
fix is unsupported for the category-list failure.
|
||||||
|
|
||||||
|
## What the generic completion code actually checks
|
||||||
|
|
||||||
|
The shared request completion routine `0x18016cca0`:
|
||||||
|
|
||||||
|
1. calls request vtable slot `+0x80` at `0x18016cd32` to create the class-selected typed
|
||||||
|
response object;
|
||||||
|
2. stores the received status at request offset `+0x48` (`0x18016cd3d`); and
|
||||||
|
3. compares it with decimal 200 at `0x18016cdd0`.
|
||||||
|
|
||||||
|
Exactly 200 takes the success branch to `0x18016d0b9`. Non-200 status invokes the error
|
||||||
|
translation path through request slot `+0x60` first. Response construction is selected by
|
||||||
|
the request vtable; it is not selected by an HTTP response header or a JSON envelope field.
|
||||||
|
|
||||||
|
No pre-deserialization branch found in this path reads `Content-Type`, a request/correlation
|
||||||
|
ID, the `X-UT-SID` response header, or a top-level JSON key. The live server already supplies
|
||||||
|
the one proven transport-level success input: status 200.
|
||||||
|
|
||||||
|
## Hub comparison
|
||||||
|
|
||||||
|
The fresh hub response was consumed successfully and set the hub cache byte to one. After
|
||||||
|
the subsequent navigation its resting value returned to zero. The SBC cache byte remained
|
||||||
|
zero. This confirms that cache `+0x28` is transient async-result/TTL state; a later resting
|
||||||
|
zero does not establish which completion branch ran.
|
||||||
|
|
||||||
|
The previous report's live snapshot—where both values were zero long after the requests—was
|
||||||
|
therefore insufficient to infer the hub/SBC divergence. The fresh before/after measurement
|
||||||
|
supersedes it.
|
||||||
|
|
||||||
|
## Server-fixability verdict
|
||||||
|
|
||||||
|
**Not demonstrated.** In particular:
|
||||||
|
|
||||||
|
- changing the category JSON cannot make the typed parser start, because the lazy store is
|
||||||
|
allocated before any JSON key is inspected;
|
||||||
|
- the server already returns the proven success status, 200;
|
||||||
|
- request-class/response-class selection is client-owned; and
|
||||||
|
- no header, envelope, or correlation field was found feeding a pre-parser decision.
|
||||||
|
|
||||||
|
This does not mathematically prove that no transport variation could ever affect the client.
|
||||||
|
It does prove that the specific server-fix candidates proposed by the killed workflow were
|
||||||
|
speculative and had no reading instruction behind them.
|
||||||
|
|
||||||
|
## Exact remaining unknown and next measurement
|
||||||
|
|
||||||
|
The unresolved boundary is between:
|
||||||
|
|
||||||
|
```text
|
||||||
|
ProtoHttp completion with status 200
|
||||||
|
-> class-selected response object creation
|
||||||
|
-> delivery of response bytes/SAX cursor
|
||||||
|
-> response vtable +0x08 (`0x18017b2b0`)
|
||||||
|
```
|
||||||
|
|
||||||
|
The next useful experiment is transient tracing of calls—not another resting-state scan.
|
||||||
|
Instrument, in a disposable/local diagnostic build or a non-mutating tracing facility:
|
||||||
|
|
||||||
|
- request factory `0x18017aa10`;
|
||||||
|
- typed deserializer `0x18017b2b0`;
|
||||||
|
- generic completion entry `0x18016cca0` and its status at `0x18016cdd0`; and
|
||||||
|
- the generic response-body/SAX dispatch site that calls response vtable slot `+0x08`.
|
||||||
|
|
||||||
|
Record whether the factory is called, whether it returns an object with vtable
|
||||||
|
`0x18022e5b0`, and whether a body/SAX object is delivered. That separates three remaining
|
||||||
|
client-side possibilities: wrong request instance despite the URI, typed object created but
|
||||||
|
body not attached, or body attached but virtual deserialization dispatch skipped.
|
||||||
|
|
||||||
|
Until that transient trace exists, the defensible implementation direction remains the
|
||||||
|
client-side hook described in `docs/sbc-hook-dll-spec.md`, but its rationale must be stated
|
||||||
|
as “native category deserializer is not reached,” not the retracted `0x180154860`
|
||||||
|
hub-rearm theory.
|
||||||
|
|
||||||
|
## 2026-08-07 passive-trace result: deserialization is proven
|
||||||
|
|
||||||
|
The first gated passive client trace supersedes the final inference above. During exactly
|
||||||
|
one SBC navigation, with every mutation feature disabled, the hook recorded:
|
||||||
|
|
||||||
|
```text
|
||||||
|
SBC_TRACE: factory entry=1 exit=1 tid=652 this=0xb80cd910 result=0x7a99178;
|
||||||
|
deser entry=1 exit=1 tid=652 this=0x7a99178 reader=0x7fcff7f8 result=true
|
||||||
|
```
|
||||||
|
|
||||||
|
The matching UTAS request occurred at `11:09:01`: `GET /ut/game/fifa17/sbs/sets` returned
|
||||||
|
HTTP 200 with one category and two sets. No degraded hook state was reported, and FIFA
|
||||||
|
remained alive until the operator closed it after the single permitted attempt.
|
||||||
|
|
||||||
|
This proves all of the following for the observed request:
|
||||||
|
|
||||||
|
- the category response factory is called exactly once and returns a non-null object;
|
||||||
|
- the native category deserializer is called exactly once on that same object;
|
||||||
|
- the body reader is non-null;
|
||||||
|
- deserialization returns success (`true`); and
|
||||||
|
- both calls return normally on the same native thread.
|
||||||
|
|
||||||
|
Therefore the earlier `M == 0` resting snapshot did not prove that `0x18017b2b0` was
|
||||||
|
skipped. The failure boundary is now strictly **after successful native deserialization**.
|
||||||
|
The next measurement must trace the response object's post-deserializer completion,
|
||||||
|
ownership handoff, and publication into the SBC UI/cache collection. Repeating the factory
|
||||||
|
or deserializer trace will not add useful information.
|
||||||
|
|
||||||
|
## 2026-08-07 post-deserializer handoff trace
|
||||||
|
|
||||||
|
A second one-shot run combined the factory/deserializer probes with atomic replacements of
|
||||||
|
the category request vtable slots `+0x90` (completion callback dispatch) and `+0x88`
|
||||||
|
(response ownership transfer). All four calls completed on native thread 656:
|
||||||
|
|
||||||
|
```text
|
||||||
|
request = 0xb80cdfe0
|
||||||
|
factory response = 0x7c94808
|
||||||
|
deserializer this = 0x7c94808, result=true
|
||||||
|
+0x90 callback argument = 0x7c94808
|
||||||
|
+0x88 owner-slot address = 0xbc51f7e8
|
||||||
|
```
|
||||||
|
|
||||||
|
The matching `GET /ut/game/fifa17/sbs/sets` at `11:24:00` returned HTTP 200, and the same
|
||||||
|
communication modal appeared. Both callback probes reported `entry=1 exit=1`; no degraded
|
||||||
|
hook state or process failure occurred.
|
||||||
|
|
||||||
|
This proves that the parsed response reaches the category request's completion dispatcher
|
||||||
|
and that its ownership-transfer routine also returns normally. The remaining failure
|
||||||
|
boundary begins at the receiving owner object's vtable `+0x18` consumer invoked from
|
||||||
|
`0x1801631e0`, or later collection/cache/UI validation. Network transport, response
|
||||||
|
construction, native parsing, callback dispatch, and request-side ownership handoff are no
|
||||||
|
longer candidate root causes.
|
||||||
@@ -0,0 +1,337 @@
|
|||||||
|
# SBC render intervention — injected-DLL integration spec
|
||||||
|
|
||||||
|
**Goal:** make the FIFA 17 FUT **SBC menu render real SBC data** from inside the
|
||||||
|
process (client-side), proven not server-fixable. The DLL is the existing
|
||||||
|
`openfut-hook` (`version.dll`, cross-compiled `x86_64-pc-windows-gnu`, feature
|
||||||
|
`fifa17`). In-process calls to client functions are safe here (unlike `/proc/mem`
|
||||||
|
writes), because we run on the game's own threads with the real allocator.
|
||||||
|
|
||||||
|
**Binary of record (clean-room):** `/mnt/games/FIFA 17/CardsDLL_Win64_retail.dll`
|
||||||
|
(on-disk copy `/tmp/fut/cardsdll.dll`), PE image base `0x180000000`. Every address
|
||||||
|
below was re-verified byte-exact against this PE in this pass (vtable slots read from
|
||||||
|
`.rdata`, prologues from `.text`). Do **not** build/deploy from this spec without the
|
||||||
|
staged morning test (§9).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Module base + RVA math
|
||||||
|
|
||||||
|
CardsDLL is **not** present at `DllMain`/worker time — the boot module dump
|
||||||
|
(`C:\openfut_hook.log`) has no `CardsDLL*` entry. It is loaded lazily **only when the
|
||||||
|
user first enters Ultimate Team**. Therefore the hook must **defer** and poll for it,
|
||||||
|
exactly like `probe::install_probes_deferred` polls for `anadius64.dll`.
|
||||||
|
|
||||||
|
- Loaded module name (Wine keeps the on-disk filename): **`CardsDLL_Win64_retail.dll`**.
|
||||||
|
`GetModuleHandleA(b"CardsDLL_Win64_retail.dll\0")`. Fallback: ToolHelp module walk
|
||||||
|
matching a name containing `CardsDLL` (see `fifa17::dump_modules` for the pattern).
|
||||||
|
- Image base in the PE is `0x180000000`. For any static VA in this doc:
|
||||||
|
|
||||||
|
```
|
||||||
|
rva = VA_static - 0x180000000
|
||||||
|
VA_runtime = cards_base + rva
|
||||||
|
```
|
||||||
|
|
||||||
|
`cards_base` is the runtime `HMODULE` of `CardsDLL_Win64_retail.dll` (its in-memory
|
||||||
|
load address). All the "0x180…" addresses below are **static VAs**; subtract
|
||||||
|
`0x180000000` to get the RVA, add `cards_base` to get the live pointer/callable.
|
||||||
|
|
||||||
|
- Slide-proof control (optional sanity, mirrors `tools/gate_byte_probe.py`): the FNV
|
||||||
|
prologue at VA `0x180180d00` must match the on-disk PE bytes
|
||||||
|
`48 83 ec 28 48 85 c9 74 50 45 33 c0 ba c5 9d 1c 81 …`. If it does not, **abort** —
|
||||||
|
the module map moved and the offsets are untrustworthy.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Verified object graph
|
||||||
|
|
||||||
|
```
|
||||||
|
A = FUT root singleton = *(0x1802e6398) getter thunk 0x18011a830 = { mov rax,[rip→0x1802e6398]; ret }
|
||||||
|
A.vtable (live [A]) = static 0x18021c2a0
|
||||||
|
A.vtable[+0x4e8] = 0x18011c1f0 = { lea rax,[rcx+0x1f9d8]; ret } -> B getter
|
||||||
|
A.vtable[+0x9b0] = 0x18011b7d0 = M lazy getter (see §3) -> M getter
|
||||||
|
A.vtable[+0x18] = 0x180113f50 = service-id 0xed84b12 -> returns `this` (proves manager == A)
|
||||||
|
|
||||||
|
B = SBC request/ready TTL cache = A + 0x1f9d8 vtable static 0x1801fae70
|
||||||
|
B+0x08 collection ptr (live 0 offline)
|
||||||
|
B+0x20 QPC deadline
|
||||||
|
B+0x28 ready byte (== A+0x1fa00 alias) <- the isValid gate byte
|
||||||
|
B.vtable[+0x00] dtor = 0x180063040
|
||||||
|
B.vtable[+0x08] isValid = 0x180065d40 (see §4)
|
||||||
|
B.vtable[+0x10] clear = 0x180065d20
|
||||||
|
|
||||||
|
M = SBC categories/sets store = *(A + 0x20a68) <- THE RENDER SOURCE (see §3, §5)
|
||||||
|
M+0x50 WORD category count
|
||||||
|
M+0x58 cat-vector begin (element stride 0xf0)
|
||||||
|
M+0x60 cat-vector end
|
||||||
|
M+0xa10 secondary/featured vec begin (8-byte elems) (emptiness-checked at render)
|
||||||
|
M+0xa18 secondary vec end
|
||||||
|
per category (+0xf0 stride):
|
||||||
|
cat+0xb8 WORD set count
|
||||||
|
cat+0xc0 set-vector begin (element stride 0x3570)
|
||||||
|
set+0x1c9 byte per-set flag
|
||||||
|
```
|
||||||
|
|
||||||
|
HUB cache (works online) is the **same class** at `A + 0x1fd70` (vtable `0x18021c1e0`)
|
||||||
|
— reference only.
|
||||||
|
|
||||||
|
**Manager fetch used by BOTH the deser and the render controller** (so
|
||||||
|
populate-target == render-source):
|
||||||
|
|
||||||
|
```
|
||||||
|
reg = 0x1800d7170() ; -> ®istry (static 0x1802c2988)
|
||||||
|
mgr = 0x180009c80(&out, reg) ; out = manager (hashes 0xed84b11 / 0xed84b12)
|
||||||
|
M = mgr.vtable[+0x9b0](mgr) ; 0x18011b7d0, lazily creates/returns *(A+0x20a68)
|
||||||
|
```
|
||||||
|
|
||||||
|
Because svc-id `0xed84b12` resolves to `A` (A.vtable[+0x18] returns `this`),
|
||||||
|
`mgr == A` and `mgr.vtable[+0x9b0] == A.vtable[+0x9b0] == 0x18011b7d0`. The hook may
|
||||||
|
therefore fetch M the short way — `A = *(0x1802e6398); M = (*(void***)A)[0x9b0/8](A)` —
|
||||||
|
**or** the long way (registry) — they return the identical object.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. M lazy getter — 0x18011b7d0 (verified disassembly)
|
||||||
|
|
||||||
|
```
|
||||||
|
18011b7d0 push rbx; push rdi; sub rsp,0x38
|
||||||
|
18011b7e0 mov rdi,rcx ; rcx = A (this)
|
||||||
|
18011b7e3 cmp QWORD [rcx+0x20a68],0 ; M already built?
|
||||||
|
18011b7eb jne 18011b873 ; yes -> return it
|
||||||
|
18011b7f1 call 0x18019e3c0 ; factory: allocate an EMPTY M (type-id 0x13f0)
|
||||||
|
… … ; init fields, cache at A+0x20a68, return
|
||||||
|
```
|
||||||
|
|
||||||
|
Cold-calling this alone **creates an EMPTY M** (`WORD[M+0x50]==0`) → the menu draws
|
||||||
|
**2 placeholder tiles** (count+2). It does **not** populate. Populating is §5.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. The gate — isValid 0x180065d40 (verified disassembly)
|
||||||
|
|
||||||
|
```
|
||||||
|
180065d40 push rbx; sub rsp,0x20; mov rbx,rcx ; rcx = B
|
||||||
|
180065d49 call 0x1801642c0 ; online sub-check — STUBBED `mov al,1;ret`
|
||||||
|
180065d4e test al,al ; je fail ; never the wall
|
||||||
|
180065d52 cmp BYTE [rbx+0x28],0 ; je fail ; <-- READY BYTE gate
|
||||||
|
180065d58 cmp QWORD [rbx+0x8],0 ; je 0x180065d75 ; <-- if collection==0 -> RETURN 1 (short-circuit)
|
||||||
|
180065d5f lea rcx,[rsp+0x38]; call [rip→0x1801e50c0]; QueryPerformanceCounter
|
||||||
|
180065d6a mov rax,[rbx+0x20]; sub rax,[rsp+0x38] ; deadline - now
|
||||||
|
180065d73 js fail ; past deadline -> fail
|
||||||
|
180065d75 mov al,1 ; …; ret ; success
|
||||||
|
```
|
||||||
|
|
||||||
|
**Load-bearing correction (adversarially confirmed, verified in this pass):** arm
|
||||||
|
**only** `BYTE[B+0x28]=1` and **leave `QWORD[B+0x08]=0`**. With `B+0x08==0` the function
|
||||||
|
takes the `je 0x180065d75` short-circuit and returns 1 immediately. If you instead
|
||||||
|
write `B+0x08` (pointing it at the collection), isValid falls into the QPC-deadline
|
||||||
|
branch; with the live-stale deadline (`B+0x20 = 0xf10fb8cb9`, already in the past) it
|
||||||
|
returns **0 → modal → gate SHUTS**. So **never** manually write `B+0x08` or `B+0x20`.
|
||||||
|
Rendering reads **M** (§5), not `B+0x08`, so nothing needs `B+0x08` set.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Render source — M, not B (verified disassembly)
|
||||||
|
|
||||||
|
Controller ctor caches M into `controller+0x140`:
|
||||||
|
|
||||||
|
```
|
||||||
|
1800b554d call 0x1800d7170 ; reg
|
||||||
|
1800b555d call 0x180009c80 ; mgr = out
|
||||||
|
1800b556b mov rax,[rbx] ; mgr.vtable
|
||||||
|
1800b5571 call [rax+0x9b0] ; M = 0x18011b7d0(mgr)
|
||||||
|
1800b5577 mov [rsi+0x140], rax ; controller+0x140 = M
|
||||||
|
…then registers Scaleform events 0x756c-0x7574 via 0x1801a4a70
|
||||||
|
```
|
||||||
|
|
||||||
|
Tile-count emit (each menu build):
|
||||||
|
|
||||||
|
```
|
||||||
|
1800b5eda mov rax,[r13+0x140] ; rax = M
|
||||||
|
1800b5ee1 movzx ebx,WORD [rax+0x50] ; ebx = category count
|
||||||
|
1800b5ee5 add bx,0x2 ; +2 placeholder tiles
|
||||||
|
1800b5ee9 mov rax,[r15] ; Scaleform model vtable
|
||||||
|
call [rax+0x58](count) ; push (category_count + 2) list tiles
|
||||||
|
```
|
||||||
|
|
||||||
|
Helper thunks (verified): `0x18015fff0 = lea rax,[rcx+0x58]` (&M cat-vector),
|
||||||
|
`0x1801607e0 = lea rax,[rcx+0xa10]` (&M secondary vector). **Zero** reads of
|
||||||
|
`B`/`A+0x1f9d8`/`A+0x1fa00` exist in the tile-build region — B is purely the entry
|
||||||
|
gate. Populate M ⇒ tiles appear.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Populate path — reuse the real parser (deser 0x18017b2b0)
|
||||||
|
|
||||||
|
The category rows are appended **only** by the sbs/sets deserializer. Its geometry and
|
||||||
|
finalizers are the correct way to fill M (hand-building `0xf0`/`0x3570` structs is
|
||||||
|
brittle and rejected — §8).
|
||||||
|
|
||||||
|
```
|
||||||
|
18017b2b0 (rcx = this, IGNORED) (rdx = a PRIMED SAX reader over the token stream)
|
||||||
|
18017b2ef mov rdi,rdx ; keeps the incoming reader in rdi (the byte source)
|
||||||
|
18017b2fb call 0x1801c63e0(&localctx, 0, 0) ; builds a SECONDARY ctx with a NULL source
|
||||||
|
18017b309 call 0x1800d7170 ; reg
|
||||||
|
18017b316 call 0x180009c80 ; mgr
|
||||||
|
18017b327 call [mgr.vtable+0x9b0] ; M (0x18011b7d0)
|
||||||
|
… clear 0x18015f3a0(M) ; ALWAYS clears M first (see crash risk C1)
|
||||||
|
… loop atom 0x6f "categories":
|
||||||
|
0x180159da0(&tmp) ; cat ctor (0xf0, vtable 0x18021b520)
|
||||||
|
0x18017ab80(&tmp, reader) ; cat deser (needs the reader)
|
||||||
|
0x180160e50(&tmp) ; cat finalize (set index)
|
||||||
|
0x18015a770(M, &tmp) ; APPEND (copy-in; copy-ctor 0x18015a2b0)
|
||||||
|
0x1801105d0(&tmp) ; cat dtor
|
||||||
|
… 0x180160e00(M); 0x180160f30(M); 0x180161020(M) ; rebuild M indices (+0x9e0/+0xa10/+0xa40)
|
||||||
|
… commit mgr.vtable[+0x8](mgr)
|
||||||
|
18017b751 ret (always true)
|
||||||
|
```
|
||||||
|
|
||||||
|
**The reader (`rdx`) is the crux.** The deser does **not** ingest `rdx` through the
|
||||||
|
`0x1801c63e0` ctx it builds (that one is created with a NULL source, `rdx=0/r8=0`);
|
||||||
|
instead it keeps the **incoming** `rdx` in `rdi` and scans its bytes directly (e.g. the
|
||||||
|
NUL-terminated backslash-unescape at `~0x18017b353` does `mov rdi,[rdi]`). So `rdx`
|
||||||
|
must be a **fully-constructed, already-primed SAX reader/cursor object** seated over
|
||||||
|
your canned `sbs/sets` JSON — the same object type the message framework produces on a
|
||||||
|
real response. **Building that reader from scratch is the one remaining un-reversed
|
||||||
|
contract** (its vtable, and specifically the `[+0x8]` byte-yield slot, are not yet
|
||||||
|
pinned). Until it is, the fully-offline parser-reuse call is **not turnkey** — see the
|
||||||
|
three tiers in §7.
|
||||||
|
|
||||||
|
SAX primitives already known (for when the reader is reconstructed): ctx init
|
||||||
|
`0x1801c63e0(rcx=ctx,rdx=source,r8=flags)`, lexer `0x1801c8060`, next-token
|
||||||
|
`0x1801c7f10`, begin-object `0x1801c8270`, INT `0x1801c79d0`, STR `0x1801c7aa0`,
|
||||||
|
BOOL `0x1801c7620`, SKIP `0x180135ff0`.
|
||||||
|
|
||||||
|
Response-msg object (for the message-layer tier): ctor `0x18017b1c0` installs vtable
|
||||||
|
`0x18022e598`; slot `[+0x20] == 0x18017b2b0` (deser) — **verified**. Constructing this
|
||||||
|
object alone still does **not** seat the reader (the framework does that from received
|
||||||
|
bytes), so it doesn't remove the reader gap.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Three intervention tiers (implement in this order)
|
||||||
|
|
||||||
|
**Tier 0 — arm-only negative control (SAFE, non-crash, renders EMPTY).**
|
||||||
|
Resolve A→B, write `BYTE[B+0x28]=1`, leave `B+0x08=0`. isValid short-circuits true, the
|
||||||
|
menu opens and draws **2 placeholder tiles** (M empty/null). Proves the gate model live
|
||||||
|
without any populate. This is the first morning step and the baseline. Implemented and
|
||||||
|
env-gated in `sbc_hook.rs` (`OPENFUT_SBC_ARM_ONLY=1`).
|
||||||
|
|
||||||
|
**Tier 1 — parser-reuse populate (the intended fix, BLOCKED on the reader).**
|
||||||
|
On the game thread: build a primed SAX reader over canned `sbs/sets` JSON served by the
|
||||||
|
bridge/core, `call 0x18017b2b0(rcx=0, rdx=reader)` (self-locates mgr, clears, appends,
|
||||||
|
finalizes, commits → fills M), then Tier-0 arm (`BYTE[B+0x28]=1` only), then trigger a
|
||||||
|
menu refresh (§ below). **Cannot be enabled** until the reader contract (§6) is
|
||||||
|
reversed. `sbc_hook.rs` contains the guarded scaffold that logs the blocker and returns
|
||||||
|
— it does **not** call the deser with a fabricated reader (that would clear M and/or
|
||||||
|
crash — C1/C6).
|
||||||
|
|
||||||
|
**Tier 2 — message-layer injection (cleanest long-term, feasibility unproven).**
|
||||||
|
Push a canned `sbs/sets` response through the real receive path so the framework builds
|
||||||
|
the response-msg (`0x18017b1c0`), seats the reader itself, runs `0x18017b2b0`, fires the
|
||||||
|
completion callback (`0x1800b8c30`, subscribed in svc ctor `0x1800b5765` via
|
||||||
|
`mgr.vtable[+0xa90]`), and arms B natively (generic copy-assign `0x1800c21a0`) — **zero
|
||||||
|
forged state**. Requires reconstructing the message-receive entry + response-msg wiring;
|
||||||
|
treat as the target, not the default.
|
||||||
|
|
||||||
|
**Refresh trigger** (Tier 1/2): the controller re-reads `WORD[M+0x50]` at `0x1800b5eda`
|
||||||
|
on every build, so **re-opening the SBC menu** suffices. Programmatic alternative: fire
|
||||||
|
Scaleform refresh events `0x756c-0x7574` via `0x1801a4a70`. If M is populated but no
|
||||||
|
refresh fires and the controller already cached an empty M at `ctrl+0x140`, you still see
|
||||||
|
2 placeholder tiles (no crash, just no data) — see C7.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Function signatures (Win64 `extern "system"`; rcx, rdx, r8, r9 → rax)
|
||||||
|
|
||||||
|
| Purpose | Static VA | Signature (Rust `unsafe extern "system"`) |
|
||||||
|
|---|---|---|
|
||||||
|
| A getter thunk | 0x18011a830 | `fn() -> *mut u8` (returns `*(0x1802e6398)`) |
|
||||||
|
| B getter (via A vtable +0x4e8) | 0x18011c1f0 | `fn(a: *mut u8) -> *mut u8` (`a+0x1f9d8`) |
|
||||||
|
| M lazy getter (A vtable +0x9b0) | 0x18011b7d0 | `fn(mgr: *mut u8) -> *mut u8` (`*(mgr+0x20a68)`, lazily built) |
|
||||||
|
| isValid (B vtable +0x08) | 0x180065d40 | `fn(b: *mut u8) -> bool` |
|
||||||
|
| registry getter | 0x1800d7170 | `fn() -> *mut u8` |
|
||||||
|
| manager getter | 0x180009c80 | `fn(out: *mut *mut u8, reg: *mut u8) -> *mut u8` |
|
||||||
|
| sbs/sets deser (whole) | 0x18017b2b0 | `fn(this_ignored: *mut u8, reader: *mut u8) -> bool` |
|
||||||
|
| SAX ctx init | 0x1801c63e0 | `fn(ctx: *mut u8, source: *mut u8, flags: u64) -> *mut u8` |
|
||||||
|
| clear M | 0x18015f3a0 | `fn(m: *mut u8)` |
|
||||||
|
| cat ctor (0xf0) | 0x180159da0 | `fn(tmp: *mut u8) -> *mut u8` |
|
||||||
|
| cat deser | 0x18017ab80 | `fn(tmp: *mut u8, reader: *mut u8) -> bool` |
|
||||||
|
| cat finalize | 0x180160e50 | `fn(tmp: *mut u8)` |
|
||||||
|
| append into M | 0x18015a770 | `fn(m: *mut u8, tmp: *mut u8)` |
|
||||||
|
| cat dtor | 0x1801105d0 | `fn(tmp: *mut u8)` |
|
||||||
|
| M index rebuild ×3 | 0x180160e00 / 0x180160f30 / 0x180161020 | `fn(m: *mut u8)` each |
|
||||||
|
| QueryPerformanceCounter thunk | 0x1801e50c0 | (indirect; not needed if B+0x08 left 0) |
|
||||||
|
| Scaleform refresh dispatch | 0x1801a4a70 | `fn(ctrl: *mut u8, event_id: u32, …)` (event ids 0x756c-0x7574) |
|
||||||
|
|
||||||
|
M is **per-session heap** — never hardcode its address; always go A → `A.vtable[+0x9b0]`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Staged morning test plan (human, live)
|
||||||
|
|
||||||
|
Preconditions: FIFA 17 at the FUT hub (so CardsDLL is loaded). One env var flips each
|
||||||
|
tier; all default **off/inert**. Watch `C:\openfut_hook.log`.
|
||||||
|
|
||||||
|
1. **Injection + resolution (read-only).** Launch with `OPENFUT_SBC_HOOK=1` only. The
|
||||||
|
deferred thread should log: CardsDLL base + slide-control OK, then `A=…`, `B=…`,
|
||||||
|
`B+0x28=0`, `M=*(A+0x20a68)=…` (0 until the SBC menu is opened once). No writes.
|
||||||
|
*Pass:* addresses match the model; control FNV OK.
|
||||||
|
2. **Tier-0 arm-only (negative control).** Add `OPENFUT_SBC_ARM_ONLY=1`. Open the SBC
|
||||||
|
menu. Expected: **menu opens, draws ~2 empty placeholder tiles, no modal, no crash.**
|
||||||
|
Confirms the gate byte and short-circuit live. If it crashes → stop (means B
|
||||||
|
resolution is wrong; recheck slide).
|
||||||
|
3. **Tier-1 populate — BLOCKED.** Do **not** enable until the SAX reader contract (§6)
|
||||||
|
is reversed. `OPENFUT_SBC_POPULATE=1` currently only logs the blocker and returns.
|
||||||
|
Next RE session: pin the reader vtable (`[+0x8]` byte-yield) and the reader ctor,
|
||||||
|
then wire the §6 sequence and re-test on the game thread with the menu **closed**,
|
||||||
|
then re-open to refresh.
|
||||||
|
4. Revert env vars to unset when done.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Crash-risk register (verified against the PE + prior adversarial passes)
|
||||||
|
|
||||||
|
- **C1 — cold-calling deser without a real reader.** `0x18017b2b0` **clears M first**
|
||||||
|
(`0x18015f3a0` before any append). A null/garbage reader → parses nothing but **wipes
|
||||||
|
M** (renders empty, destroys prior state), and the byte-scan at `~0x18017b353`
|
||||||
|
(`mov rdi,[rdi]`) segfaults on a bad pointer. This is exactly why Tier 1 is gated off.
|
||||||
|
- **C2 — clear/finalize race.** Deser clears then rebuilds M's vectors+indices; if the
|
||||||
|
render thread reads `WORD[M+0x50]` (`0x1800b5eda`) or by-index `0x180160a80` mid-build
|
||||||
|
→ OOB/crash. Populate on the game thread with the menu **closed**, then refresh.
|
||||||
|
- **C3 — skipping finalizers.** Any manual append via `0x18015a770` **must** be followed
|
||||||
|
by `0x180160e00`/`0x180160f30`/`0x180161020` or the `+0x9e0/+0xa10/+0xa40` indices go
|
||||||
|
stale and by-index lookups read OOB.
|
||||||
|
- **C4 — hand-built `0xf0`/`0x3570` structs.** Append's copy-ctor `0x18015a2b0`
|
||||||
|
deep-copies EASTL sub-vectors; a bad begin/end/cap → heap corruption. **Rejected**
|
||||||
|
(§8): drive the real parser instead.
|
||||||
|
- **C5 — writing `B+0x08`/`B+0x20`.** Forces isValid into the deadline branch; the
|
||||||
|
live-stale deadline shuts the gate → modal. **Set only `B+0x28`, leave `B+0x08=0`.**
|
||||||
|
- **C6 — null manager/M.** Deser does `mov rax,[mgr]`; if the registry lookup returned
|
||||||
|
null it's a null-deref. Live registry `*(0x1802c2988)` is non-null offline, but the
|
||||||
|
hook must null-check A, mgr, M before any use.
|
||||||
|
- **C7 — no refresh (non-crash).** Populate without firing refresh / re-open → controller
|
||||||
|
keeps its cached empty M → still 2 placeholder tiles. Fails the goal, not a crash.
|
||||||
|
- **C8 — foreign-thread allocation.** The lazy getter and appenders allocate on / mutate
|
||||||
|
the game heap; running them off the main/render thread races the allocator. Execute the
|
||||||
|
populate on a game thread (message-pump / a game-thread detour), not a bg thread. The
|
||||||
|
Tier-0 single-byte arm is tolerant of a bg write (it's what the `/proc` poke does), but
|
||||||
|
populate is not.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Live-probe baseline (this pass, read-only `O_RDONLY`, zero writes)
|
||||||
|
|
||||||
|
FIFA17.exe **was running** at spec time (pid 12201), CardsDLL mapped. Fresh live reads
|
||||||
|
this pass match the static model 1:1:
|
||||||
|
|
||||||
|
```
|
||||||
|
slide 0x6ffe7c140000 CONTROL FNV OK
|
||||||
|
A 0xb83e2b60 (= *(0x1802e6398))
|
||||||
|
B 0xb8402538 vt=0x1801fae70 (matches static) B+0x08(coll)=0 B+0x20=0xf10fb8cb9 B+0x28(ready)=0
|
||||||
|
HUB 0xb84028d0 vt=0x18021c1e0 coll=0 ready=0 (reference only)
|
||||||
|
M *(A+0x20a68)=0 (SBC menu not opened this session -> M not yet built)
|
||||||
|
```
|
||||||
|
|
||||||
|
So live: gate SHUT (`B+0x28=0`), collection null, **M null** — Tier-0 arm alone would
|
||||||
|
render empty (matches the model). All §2–§6 addresses + all vtable slots were
|
||||||
|
re-verified byte-exact against the on-disk PE in this pass.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
/target
|
||||||
Generated
+16
@@ -0,0 +1,16 @@
|
|||||||
|
# This file is automatically @generated by Cargo.
|
||||||
|
# It is not intended for manual editing.
|
||||||
|
version = 4
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futmem"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "memchr"
|
||||||
|
version = "2.8.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
[package]
|
||||||
|
name = "futmem"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
description = "Read-only live-memory inspector for the FIFA 17 process (preservation / reverse-engineering tooling)"
|
||||||
|
publish = false
|
||||||
|
|
||||||
|
# An EMPTY [workspace] table makes this crate its own workspace root.
|
||||||
|
# Without it, cargo walks up the directory tree, finds
|
||||||
|
# /home/alex/Documents/OpenFUT/Cargo.toml, sees that futmem is not in its
|
||||||
|
# `members` list, and refuses to build. That parent manifest is untracked and
|
||||||
|
# must not be edited, so we opt out from this side instead.
|
||||||
|
[workspace]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
# memchr is the ONLY dependency, and it earns its place.
|
||||||
|
# A `find` sweep covers roughly 3 GB of resident memory. The naive
|
||||||
|
# `windows(n).position(...)` search runs at a few hundred MB/s; memchr's
|
||||||
|
# memmem uses SIMD (AVX2 on this box) and runs an order of magnitude faster,
|
||||||
|
# which turns a multi-minute sweep into a few seconds.
|
||||||
|
# Everything else (argument parsing for four subcommands, /proc/<pid>/maps
|
||||||
|
# parsing, hex dumping) is a few dozen lines of std and does not justify
|
||||||
|
# pulling in clap or a proc-maps crate.
|
||||||
|
memchr = "2"
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
opt-level = 3
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
# futmem
|
||||||
|
|
||||||
|
A small, read-only live-memory inspector for FIFA 17, built for the OpenFUT
|
||||||
|
preservation project.
|
||||||
|
|
||||||
|
`FIFA17.exe` is Denuvo-packed: its `.text` and `.rdata` exist in plaintext only
|
||||||
|
inside the running process. Anything the packed executable owns can be reached
|
||||||
|
only through live memory. `CardsDLL_Win64_retail.dll`, which holds nearly all the
|
||||||
|
FUT logic, is unpacked but is loaded at a different address on every launch.
|
||||||
|
`futmem` answers both problems: it finds the process, tells you where everything
|
||||||
|
is loaded, and lets you search and dump it without touching a byte.
|
||||||
|
|
||||||
|
```
|
||||||
|
cargo build --release
|
||||||
|
./target/release/futmem maps
|
||||||
|
```
|
||||||
|
|
||||||
|
## Read only by construction
|
||||||
|
|
||||||
|
A live game session may be running while this tool is used, and corrupting it
|
||||||
|
costs the user their session. The read-only property is therefore structural
|
||||||
|
rather than a matter of discipline:
|
||||||
|
|
||||||
|
* `/proc/<pid>/mem` is opened with `File::open`, i.e. `O_RDONLY`. The identifier
|
||||||
|
`OpenOptions` does not appear anywhere in this crate.
|
||||||
|
* `ProcMem` exposes `&self` read methods only. It hands out no `&mut File` and no
|
||||||
|
raw file descriptor, so no caller outside `mem.rs` can upgrade the handle.
|
||||||
|
* Nothing here calls `ptrace`, sends a signal, or stops the target.
|
||||||
|
|
||||||
|
There is no code path in this crate that can write to another process. Even if
|
||||||
|
one were added by mistake, the kernel would reject the write on an `O_RDONLY`
|
||||||
|
descriptor. Keep it that way.
|
||||||
|
|
||||||
|
## Subcommands
|
||||||
|
|
||||||
|
```
|
||||||
|
futmem maps [--pid N]
|
||||||
|
futmem find <pattern> [--pid N] [--ascii|--utf16|--hex] [--module NAME] [--max N]
|
||||||
|
futmem strings [--pid N] [--min 6] [--range START-END] [--module NAME] [--utf16]
|
||||||
|
[--grep SUBSTR] [--max N]
|
||||||
|
futmem read <va> <len> [--pid N]
|
||||||
|
```
|
||||||
|
|
||||||
|
With no `--pid`, the target is resolved by scanning `/proc/*/comm` for exactly
|
||||||
|
`FIFA17.exe`. This matters: several processes in the Proton/umu tree carry
|
||||||
|
"fifa17" in their command line, including a convincing
|
||||||
|
`umu.exe /mnt/games/FIFA 17/_fifa17.exe` decoy, so a `pgrep -f` match is not good
|
||||||
|
enough. Only `comm` is authoritative.
|
||||||
|
|
||||||
|
Addresses may be written `0x140000000` or `140000000`; bare values are read as
|
||||||
|
hex, which is how this project writes them. Lengths accept `0x100`, `256`, `16k`,
|
||||||
|
`2m`.
|
||||||
|
|
||||||
|
## What `maps` gives you that `cat /proc/pid/maps` does not
|
||||||
|
|
||||||
|
### The relocation slide, computed for you
|
||||||
|
|
||||||
|
Every address in the project's Ghidra database is based at `0x180000000`. The
|
||||||
|
live module is somewhere else. `maps` prints the conversion directly:
|
||||||
|
|
||||||
|
```
|
||||||
|
CardsDLL_Win64_retail.dll PRESENT base 0x6ffffc140000 size 0x31d000 static 0x180000000 slide +0x6ffe7c140000
|
||||||
|
|
||||||
|
CardsDLL address conversion: live_va = static_va + 0x6ffe7c140000
|
||||||
|
```
|
||||||
|
|
||||||
|
It derives this by reading `ImageBase` from the *on-disk* PE (where the module
|
||||||
|
wanted to load) and subtracting it from the live load address. The live header
|
||||||
|
cannot be used for this, because Wine rewrites its `ImageBase` field to the
|
||||||
|
actual load address.
|
||||||
|
|
||||||
|
**Module bases move on every launch.** Never cache the slide across a restart.
|
||||||
|
|
||||||
|
### The Wine mapping gotcha, made visible
|
||||||
|
|
||||||
|
Wine keeps only a PE's 4 KiB header file-backed and copies every section into
|
||||||
|
anonymous memory. So this returns exactly one line:
|
||||||
|
|
||||||
|
```
|
||||||
|
$ grep CardsDLL /proc/4048/maps
|
||||||
|
6ffffc140000-6ffffc141000 r--p 00000000 00:37 2941670 /mnt/games/FIFA 17/CardsDLL_Win64_retail.dll
|
||||||
|
```
|
||||||
|
|
||||||
|
It is easy to misread that as "the module is barely mapped". A module table built
|
||||||
|
naively from path grouping reports CardsDLL as a 4 KiB module; it is really
|
||||||
|
`0x31d000` bytes. `futmem` reads `SizeOfImage` from the live PE header instead
|
||||||
|
and flags the discrepancy:
|
||||||
|
|
||||||
|
```
|
||||||
|
6ffffc140000 6ffffc45d000 3.11 MiB 1 CardsDLL_Win64_retail.dll [maps shows only 4.00 KiB; sections are anonymous]
|
||||||
|
```
|
||||||
|
|
||||||
|
This also drives address attribution. A hit inside CardsDLL's `.rdata` lands in
|
||||||
|
an anonymous region as far as the maps are concerned, so `find` checks module
|
||||||
|
image spans *before* the region list and reports
|
||||||
|
`CardsDLL_Win64_retail.dll+0x22c618` rather than `anon`.
|
||||||
|
|
||||||
|
Only genuine PE images claim a range. `/dev/nvidia0` is mapped at many scattered
|
||||||
|
addresses, and letting its min..max span count as an "image" mis-attributed
|
||||||
|
gigabytes of unrelated anonymous memory to it. Non-PE mappings own only their
|
||||||
|
exact regions.
|
||||||
|
|
||||||
|
### Honest degradation
|
||||||
|
|
||||||
|
If the game has not loaded FUT yet, the difference is visible at a glance rather
|
||||||
|
than showing as an empty table:
|
||||||
|
|
||||||
|
```
|
||||||
|
KEY MODULES
|
||||||
|
FIFA17.exe PRESENT base 0x140000000 ...
|
||||||
|
CardsDLL_Win64_retail.dll ABSENT not in this process's maps (the game has not loaded it yet)
|
||||||
|
```
|
||||||
|
|
||||||
|
An explicit `--pid` that does not point at the game is called out too, so a
|
||||||
|
wrong-target mistake cannot pass unnoticed:
|
||||||
|
|
||||||
|
```
|
||||||
|
pid 26072 (comm "bash"), 39 mapped regions <-- NOT FIFA17.exe; this is not the game process
|
||||||
|
```
|
||||||
|
|
||||||
|
## Design notes
|
||||||
|
|
||||||
|
### pread, not seek + read
|
||||||
|
|
||||||
|
`FileExt::read_at` is `pread(2)`: the offset is an argument rather than a mutable
|
||||||
|
cursor on the file. A `&ProcMem` can therefore be shared across threads later
|
||||||
|
without a mutex and without one thread's seek corrupting another's read, and a
|
||||||
|
whole class of "forgot to seek" bugs disappears.
|
||||||
|
|
||||||
|
### Partial sweeps are normal, and are reported
|
||||||
|
|
||||||
|
Many regions marked readable in `/proc/<pid>/maps` are not actually readable:
|
||||||
|
guard pages, Wine's special mappings, and pages Denuvo has not faulted in all
|
||||||
|
return `EIO`. A failed read is skipped and counted, never fatal, and every sweep
|
||||||
|
prints its counts:
|
||||||
|
|
||||||
|
```
|
||||||
|
1 hits; scanned 3552 regions (3.73 GiB), skipped 0 unreadable regions, 3 holes stepped over
|
||||||
|
```
|
||||||
|
|
||||||
|
That line is there so a zero-hit result is never mistaken for proof of absence.
|
||||||
|
When `find` returns nothing it says so explicitly.
|
||||||
|
|
||||||
|
### Chunked reads and the `pattern_len - 1` overlap
|
||||||
|
|
||||||
|
The target has roughly 3 GB resident, so regions are walked in 4 MiB chunks. The
|
||||||
|
classic bug in hand-rolled scanners is that a pattern straddling a chunk boundary
|
||||||
|
is never found: the tail of chunk N holds its first bytes and the head of chunk
|
||||||
|
N+1 holds the rest, and neither buffer contains the whole thing.
|
||||||
|
|
||||||
|
Consecutive chunks therefore overlap by exactly `pattern_len - 1` bytes. That
|
||||||
|
number is neither too small nor too large. Let a chunk cover `[0, n)` and the
|
||||||
|
pattern have length `P`. A match starting at index `s` occupies `s ..= s + P - 1`,
|
||||||
|
so the last match wholly inside the chunk starts at `s = n - P`. Advancing by
|
||||||
|
`n - (P - 1)` starts the next chunk at `n - P + 1`, so:
|
||||||
|
|
||||||
|
* nothing is missed: every straddling match starts at `s >= n - P + 1`, inside
|
||||||
|
the next chunk;
|
||||||
|
* nothing is double-reported: the overlap begins at `n - P + 1`, strictly past
|
||||||
|
`n - P`, the last index that can host a complete match in this chunk. The
|
||||||
|
windows of reportable match *starts* are disjoint even though the byte windows
|
||||||
|
overlap.
|
||||||
|
|
||||||
|
Overlapping by `P` would report every boundary-straddling match twice;
|
||||||
|
overlapping by `P - 2` would miss one alignment.
|
||||||
|
|
||||||
|
This is verified against the live process rather than merely asserted. Region
|
||||||
|
`0x144ed3000` is swept in 4 MiB chunks, so its first boundary falls at
|
||||||
|
`0x1452d3000`. A 16-byte pattern placed 8 bytes before it straddles the boundary,
|
||||||
|
and is found exactly once:
|
||||||
|
|
||||||
|
```
|
||||||
|
$ futmem read 0x1452d2ff8 16
|
||||||
|
0001452d2ff8 a9 48 01 90 90 90 90 90 90 99 51 48 8d 0d 0c 74 |.H........QH...t|
|
||||||
|
|
||||||
|
$ futmem find --hex a948019090909090909951488d0d0c74 --module fifa17
|
||||||
|
0x0001452d2ff8 FIFA17.exe+0x52d2ff8
|
||||||
|
1 hits
|
||||||
|
```
|
||||||
|
|
||||||
|
One hit, not zero and not two.
|
||||||
|
|
||||||
|
String extraction uses a different mechanism for the same reason: it sweeps with
|
||||||
|
zero overlap and carries an unfinished run across contiguous chunks, so a string
|
||||||
|
spanning a boundary is still emitted whole. UTF-16 additionally carries a
|
||||||
|
dangling low byte when a chunk ends mid-pair.
|
||||||
|
|
||||||
|
### Dependencies
|
||||||
|
|
||||||
|
`memchr` is the only dependency. Its `memmem` uses SIMD and runs roughly an order
|
||||||
|
of magnitude faster than `windows(n).position(...)` over multiple gigabytes,
|
||||||
|
which is the difference between a several-minute sweep and a few seconds.
|
||||||
|
Everything else (argument parsing for four subcommands, maps parsing, PE header
|
||||||
|
parsing, hex dumping) is a few dozen lines of `std` and does not justify pulling
|
||||||
|
in `clap`.
|
||||||
|
|
||||||
|
### Standalone workspace
|
||||||
|
|
||||||
|
`Cargo.toml` carries an empty `[workspace]` table. Without it, cargo walks up the
|
||||||
|
directory tree, finds the untracked workspace manifest at the repo root, sees that
|
||||||
|
`futmem` is not in its `members` list, and refuses to build. Opting out from this
|
||||||
|
side avoids editing that manifest.
|
||||||
|
|
||||||
|
## Performance
|
||||||
|
|
||||||
|
Measured against pid 4048 with the game sitting at the main menu, release build,
|
||||||
|
best and worst of three runs each. These are wall clock, and they are dominated
|
||||||
|
by the `pread` syscalls rather than by the search itself.
|
||||||
|
|
||||||
|
| Sweep | Scope | Wall clock |
|
||||||
|
|---|---|---|
|
||||||
|
| `strings --min 8 --grep pack` | 3.20 GiB, all anon private | 6.3 to 6.8 s |
|
||||||
|
| `find --ascii` (global) | 3.73 GiB, all readable | 5.3 to 7.0 s |
|
||||||
|
| `find --ascii --module cardsdll` | 3.11 MiB | 0.05 s |
|
||||||
|
| `maps` | n/a | 0.05 s |
|
||||||
|
|
||||||
|
Scoping with `--module` is over a hundred times cheaper and should be the default
|
||||||
|
habit when the target is known to live in CardsDLL. A global sweep costs about
|
||||||
|
six seconds, which is cheap enough to use freely but not in a tight loop.
|
||||||
|
|
||||||
|
## Worked example
|
||||||
|
|
||||||
|
```
|
||||||
|
$ futmem find --ascii 'RS4:FutSquadSave' --module cardsdll
|
||||||
|
scanning CardsDLL_Win64_retail.dll image span 0x6ffffc140000-0x6ffffc45d000 (3.11 MiB)
|
||||||
|
from /mnt/games/FIFA 17/CardsDLL_Win64_retail.dll
|
||||||
|
pattern 16 bytes, 7 candidate regions (3.11 MiB)
|
||||||
|
|
||||||
|
0x6ffffc36c618 CardsDLL_Win64_retail.dll+0x22c618
|
||||||
|
6ffffc36c618 52 53 34 3a 46 75 74 53 71 75 61 64 53 61 76 65 |RS4:FutSquadSave|
|
||||||
|
6ffffc36c628 53 65 72 76 65 72 52 65 73 70 6f 6e 73 65 00 00 |ServerResponse..|
|
||||||
|
6ffffc36c638 5b 00 00 00 2c 25 64 00 5d 00 00 00 00 00 00 00 |[...,%d.].......|
|
||||||
|
6ffffc36c648 63 61 70 74 61 69 6e 00 22 05 93 19 01 00 00 00 |captain.".......|
|
||||||
|
|
||||||
|
1 hits; scanned 7 regions (3.11 MiB), skipped 0 unreadable regions, 0 holes stepped over
|
||||||
|
```
|
||||||
|
|
||||||
|
The `+0x22c618` offset converts straight back to the Ghidra address
|
||||||
|
`0x18022c618`. Note that the literal is `RS4:FutSquadSaveServerResponse`, not
|
||||||
|
`RS4:FutSquadSave` with a trailing NUL; read such patterns from the PE rather
|
||||||
|
than assuming them.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
This tool is client-side instrumentation. It establishes nothing about the UTAS
|
||||||
|
wire protocol and nothing a server emulator must reimplement. Its value is as the
|
||||||
|
addressing base that lets other work read server-authoritative logic out of
|
||||||
|
CardsDLL. Do not let addresses produced by this tool leak into a protocol
|
||||||
|
document as if they were protocol.
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
//! A deliberately tiny argument parser.
|
||||||
|
//!
|
||||||
|
//! Four subcommands do not justify a `clap` dependency and its build time. The
|
||||||
|
//! only subtlety is that some long options take a value (`--pid 165925`) and
|
||||||
|
//! some are bare booleans (`--utf16`). A parser cannot tell those apart from
|
||||||
|
//! the token stream alone, so each subcommand declares which of its options
|
||||||
|
//! take a value and we look the name up in that list.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
pub struct Args {
|
||||||
|
opts: HashMap<String, Option<String>>,
|
||||||
|
pub positional: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct ArgError(pub String);
|
||||||
|
|
||||||
|
impl std::fmt::Display for ArgError {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
write!(f, "{}", self.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Args {
|
||||||
|
/// `value_flags` lists the long option names that consume the following
|
||||||
|
/// token as their value. Everything else beginning with `--` is a boolean.
|
||||||
|
/// `--name=value` is always accepted regardless of the list.
|
||||||
|
pub fn parse<I: Iterator<Item = String>>(
|
||||||
|
argv: I,
|
||||||
|
value_flags: &[&str],
|
||||||
|
) -> Result<Args, ArgError> {
|
||||||
|
let mut opts: HashMap<String, Option<String>> = HashMap::new();
|
||||||
|
let mut positional = Vec::new();
|
||||||
|
let mut it = argv.peekable();
|
||||||
|
|
||||||
|
while let Some(tok) = it.next() {
|
||||||
|
if let Some(rest) = tok.strip_prefix("--") {
|
||||||
|
if rest.is_empty() {
|
||||||
|
// A bare `--` ends option parsing; the rest is positional.
|
||||||
|
positional.extend(it.by_ref());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if let Some((name, value)) = rest.split_once('=') {
|
||||||
|
opts.insert(name.to_string(), Some(value.to_string()));
|
||||||
|
} else if value_flags.contains(&rest) {
|
||||||
|
let value = it
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| ArgError(format!("--{rest} needs a value")))?;
|
||||||
|
opts.insert(rest.to_string(), Some(value));
|
||||||
|
} else {
|
||||||
|
opts.insert(rest.to_string(), None);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
positional.push(tok);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Args { opts, positional })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn has(&self, name: &str) -> bool {
|
||||||
|
self.opts.contains_key(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn value(&self, name: &str) -> Option<&str> {
|
||||||
|
self.opts.get(name).and_then(|v| v.as_deref())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_value<T: std::str::FromStr>(&self, name: &str) -> Result<Option<T>, ArgError> {
|
||||||
|
match self.value(name) {
|
||||||
|
None => Ok(None),
|
||||||
|
Some(raw) => raw
|
||||||
|
.parse::<T>()
|
||||||
|
.map(Some)
|
||||||
|
.map_err(|_| ArgError(format!("could not parse --{name} value {raw:?}"))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reject typos instead of silently ignoring them. `futmem find --acii foo`
|
||||||
|
/// should not quietly scan for nothing.
|
||||||
|
pub fn reject_unknown(&self, known: &[&str]) -> Result<(), ArgError> {
|
||||||
|
for name in self.opts.keys() {
|
||||||
|
if !known.contains(&name.as_str()) {
|
||||||
|
return Err(ArgError(format!("unknown option --{name}")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse `0x1234`, `1234` (hex assumed when the `0x` prefix is present,
|
||||||
|
/// decimal otherwise) into a virtual address.
|
||||||
|
pub fn parse_addr(raw: &str) -> Result<u64, ArgError> {
|
||||||
|
let cleaned = raw.replace('_', "");
|
||||||
|
let parsed = match cleaned
|
||||||
|
.strip_prefix("0x")
|
||||||
|
.or_else(|| cleaned.strip_prefix("0X"))
|
||||||
|
{
|
||||||
|
Some(hex) => u64::from_str_radix(hex, 16),
|
||||||
|
// Bare addresses in this project are always written in hex
|
||||||
|
// (`6ffffc140000`), so try hex first and fall back to decimal only for
|
||||||
|
// values that are unambiguous.
|
||||||
|
None => u64::from_str_radix(&cleaned, 16).or_else(|_| cleaned.parse::<u64>()),
|
||||||
|
};
|
||||||
|
parsed.map_err(|_| ArgError(format!("bad address {raw:?}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a length: `4096`, `0x1000`, `16k`, `2m`.
|
||||||
|
pub fn parse_len(raw: &str) -> Result<u64, ArgError> {
|
||||||
|
let lower = raw.to_ascii_lowercase();
|
||||||
|
let (body, mult) = match lower.strip_suffix('k') {
|
||||||
|
Some(b) => (b, 1024u64),
|
||||||
|
None => match lower.strip_suffix('m') {
|
||||||
|
Some(b) => (b, 1024 * 1024),
|
||||||
|
None => (lower.as_str(), 1),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let n = match body.strip_prefix("0x") {
|
||||||
|
Some(hex) => u64::from_str_radix(hex, 16),
|
||||||
|
None => body.parse::<u64>(),
|
||||||
|
}
|
||||||
|
.map_err(|_| ArgError(format!("bad length {raw:?}")))?;
|
||||||
|
Ok(n * mult)
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
//! Hex + ASCII rendering, shared by `read` and by `find`'s context blocks.
|
||||||
|
|
||||||
|
use std::fmt::Write as _;
|
||||||
|
use std::io::{self, Write};
|
||||||
|
|
||||||
|
fn ascii_gutter(row: &[u8]) -> String {
|
||||||
|
row.iter()
|
||||||
|
.map(|&b| {
|
||||||
|
if (0x20..=0x7e).contains(&b) {
|
||||||
|
b as char
|
||||||
|
} else {
|
||||||
|
'.'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Classic 16-bytes-per-line dump with absolute addresses in the left column.
|
||||||
|
pub fn hexdump(out: &mut impl Write, base: u64, data: &[u8], indent: &str) -> io::Result<()> {
|
||||||
|
for (i, row) in data.chunks(16).enumerate() {
|
||||||
|
let addr = base + (i * 16) as u64;
|
||||||
|
let mut hex = String::with_capacity(50);
|
||||||
|
for (j, b) in row.iter().enumerate() {
|
||||||
|
if j == 8 {
|
||||||
|
hex.push(' ');
|
||||||
|
}
|
||||||
|
// Writing into a String is infallible.
|
||||||
|
let _ = write!(hex, "{b:02x} ");
|
||||||
|
}
|
||||||
|
writeln!(out, "{indent}{addr:012x} {hex:<50}|{}|", ascii_gutter(row))?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
//! Turning `/proc/<pid>/maps` lines into a usable module table, and turning an
|
||||||
|
//! address back into `module+offset`.
|
||||||
|
//!
|
||||||
|
//! # The Wine mapping gotcha this module exists to work around
|
||||||
|
//!
|
||||||
|
//! Under Wine, only a PE's 4 KiB header stays file-backed. Wine copies every
|
||||||
|
//! section into ANONYMOUS memory. So `grep CardsDLL /proc/<pid>/maps` returns
|
||||||
|
//! exactly one line, 4 KiB long, and a module table built naively from path
|
||||||
|
//! grouping will report CardsDLL as a 4 KiB module. It is really 0x31d000 bytes.
|
||||||
|
//! An agent who trusts the maps extent concludes the module is "barely mapped"
|
||||||
|
//! and gives up, or computes a wrong module size and mis-attributes every hit.
|
||||||
|
//!
|
||||||
|
//! The fix: read `SizeOfImage` out of the live PE header at the module base.
|
||||||
|
//! That field is authoritative for the module's real extent, and the header is
|
||||||
|
//! the one part of the image that is reliably readable.
|
||||||
|
//!
|
||||||
|
//! # Deriving the slide automatically
|
||||||
|
//!
|
||||||
|
//! Wine rewrites the `ImageBase` field of the *live* header to the actual load
|
||||||
|
//! address, so the live header cannot tell us where the module wanted to load.
|
||||||
|
//! The on-disk file still can, and the maps line gives us its path. Reading the
|
||||||
|
//! on-disk `ImageBase` and subtracting gives the relocation slide:
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! slide = live_base - disk_image_base
|
||||||
|
//! live_va = static_va + slide
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! For CardsDLL that is `0x6ffffc140000 - 0x180000000 = 0x6ffe7c140000`, the
|
||||||
|
//! number every Ghidra-derived address in this project has to be adjusted by.
|
||||||
|
//! Printing it removes the most error-prone manual step in the workflow.
|
||||||
|
|
||||||
|
use crate::maps::Region;
|
||||||
|
use crate::mem::ProcMem;
|
||||||
|
use std::fs;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Module {
|
||||||
|
/// Bare file name, e.g. `CardsDLL_Win64_retail.dll`.
|
||||||
|
pub name: String,
|
||||||
|
pub path: String,
|
||||||
|
/// Lowest mapped address carrying this path. For a PE this is the header.
|
||||||
|
pub base: u64,
|
||||||
|
/// Highest address still carrying this path in the maps. Badly understates
|
||||||
|
/// the truth under Wine; see the module docs.
|
||||||
|
pub maps_end: u64,
|
||||||
|
/// Number of separate maps lines mentioning this path.
|
||||||
|
pub region_count: usize,
|
||||||
|
/// `SizeOfImage` from the live PE header, the real extent.
|
||||||
|
pub size_of_image: Option<u64>,
|
||||||
|
/// `ImageBase` from the on-disk file: where the module was linked to load.
|
||||||
|
pub disk_image_base: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Module {
|
||||||
|
/// Best available end address: PE-derived when we have it, maps otherwise.
|
||||||
|
pub fn end(&self) -> u64 {
|
||||||
|
match self.size_of_image {
|
||||||
|
Some(size) => self.base + size,
|
||||||
|
None => self.maps_end,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The relocation slide: add this to a static (Ghidra) VA to get a live VA.
|
||||||
|
pub fn slide(&self) -> Option<i128> {
|
||||||
|
self.disk_image_base
|
||||||
|
.map(|disk| self.base as i128 - disk as i128)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Is this actually a PE image, as opposed to a device node, font or `.nls`
|
||||||
|
/// data file that merely happens to be mapped?
|
||||||
|
pub fn is_pe(&self) -> bool {
|
||||||
|
self.size_of_image.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Only PE images claim an address range.
|
||||||
|
///
|
||||||
|
/// Without the `is_pe` guard this mis-attributes badly. `/dev/nvidia0` is
|
||||||
|
/// mapped at many scattered addresses, so its min..max span covers gigabytes
|
||||||
|
/// of unrelated anonymous memory, and every hit in there would be reported
|
||||||
|
/// as `nvidia0+0x...`. A non-PE mapping only ever owns the exact regions
|
||||||
|
/// listed for it in the maps, which `describe` handles as a fallback.
|
||||||
|
pub fn contains(&self, va: u64) -> bool {
|
||||||
|
self.is_pe() && va >= self.base && va < self.end()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Little-endian scalar helpers. Returning `Option` keeps a truncated or
|
||||||
|
/// malformed header from panicking the whole run.
|
||||||
|
fn u16_at(buf: &[u8], off: usize) -> Option<u16> {
|
||||||
|
buf.get(off..off + 2)
|
||||||
|
.map(|s| u16::from_le_bytes([s[0], s[1]]))
|
||||||
|
}
|
||||||
|
fn u32_at(buf: &[u8], off: usize) -> Option<u32> {
|
||||||
|
buf.get(off..off + 4)
|
||||||
|
.map(|s| u32::from_le_bytes([s[0], s[1], s[2], s[3]]))
|
||||||
|
}
|
||||||
|
fn u64_at(buf: &[u8], off: usize) -> Option<u64> {
|
||||||
|
buf.get(off..off + 8)
|
||||||
|
.map(|s| u64::from_le_bytes([s[0], s[1], s[2], s[3], s[4], s[5], s[6], s[7]]))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `SizeOfImage` and `ImageBase` from a PE header blob.
|
||||||
|
///
|
||||||
|
/// Layout: `e_lfanew` at 0x3c points at the `PE\0\0` signature; the 20-byte
|
||||||
|
/// COFF header follows; the optional header starts at signature+24. Within the
|
||||||
|
/// optional header `SizeOfImage` sits at 0x38 for both PE32 and PE32+ (the
|
||||||
|
/// layouts diverge only between 0x18 and 0x20). `ImageBase` is 8 bytes at 0x18
|
||||||
|
/// for PE32+ and 4 bytes at 0x1c for PE32.
|
||||||
|
fn parse_pe(buf: &[u8]) -> Option<(u64, u64)> {
|
||||||
|
if buf.get(0..2)? != b"MZ" {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let nt = u32_at(buf, 0x3c)? as usize;
|
||||||
|
if buf.get(nt..nt + 4)? != b"PE\0\0" {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let opt = nt + 24;
|
||||||
|
let magic = u16_at(buf, opt)?;
|
||||||
|
let size_of_image = u32_at(buf, opt + 0x38)? as u64;
|
||||||
|
let image_base = match magic {
|
||||||
|
0x20b => u64_at(buf, opt + 0x18)?, // PE32+
|
||||||
|
0x10b => u32_at(buf, opt + 0x1c)? as u64, // PE32
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
Some((size_of_image, image_base))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pe_from_disk(path: &str) -> Option<(u64, u64)> {
|
||||||
|
// 4 KiB is more than enough for MZ + PE + optional header on any real image.
|
||||||
|
let data = fs::read(path).ok()?;
|
||||||
|
parse_pe(&data[..data.len().min(4096)])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the module table. Modules are returned sorted by base address.
|
||||||
|
pub fn modules(regions: &[Region], mem: &ProcMem) -> Vec<Module> {
|
||||||
|
use std::collections::HashMap;
|
||||||
|
let mut by_path: HashMap<&str, (u64, u64, usize)> = HashMap::new();
|
||||||
|
|
||||||
|
for r in regions {
|
||||||
|
let Some(path) = r.path.as_deref() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if r.pseudo() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let entry = by_path.entry(path).or_insert((u64::MAX, 0, 0));
|
||||||
|
entry.0 = entry.0.min(r.start);
|
||||||
|
entry.1 = entry.1.max(r.end);
|
||||||
|
entry.2 += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut out: Vec<Module> = by_path
|
||||||
|
.into_iter()
|
||||||
|
.map(|(path, (base, maps_end, region_count))| {
|
||||||
|
// The live header gives the true extent; the on-disk header gives
|
||||||
|
// the link-time base, which is what the slide is measured against.
|
||||||
|
let live = mem.read_partial(base, 4096);
|
||||||
|
let live_pe = parse_pe(&live);
|
||||||
|
let disk_pe = pe_from_disk(path);
|
||||||
|
Module {
|
||||||
|
name: path.rsplit('/').next().unwrap_or(path).to_string(),
|
||||||
|
path: path.to_string(),
|
||||||
|
base,
|
||||||
|
maps_end,
|
||||||
|
region_count,
|
||||||
|
size_of_image: live_pe.map(|(s, _)| s).or(disk_pe.map(|(s, _)| s)),
|
||||||
|
disk_image_base: disk_pe.map(|(_, b)| b),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
out.sort_by_key(|m| m.base);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Case-insensitive lookup by name substring, e.g. `cardsdll`.
|
||||||
|
pub fn find_module<'a>(mods: &'a [Module], needle: &str) -> Option<&'a Module> {
|
||||||
|
let needle = needle.to_ascii_lowercase();
|
||||||
|
mods.iter()
|
||||||
|
.find(|m| m.name.to_ascii_lowercase().contains(&needle))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Describe an address as `module+0xoff`, falling back to the region kind.
|
||||||
|
///
|
||||||
|
/// Checking module image spans BEFORE the region list is essential here: a hit
|
||||||
|
/// inside CardsDLL's `.rdata` lands in an anonymous region as far as the maps
|
||||||
|
/// are concerned, and would otherwise be reported as `anon`, throwing away the
|
||||||
|
/// single most useful piece of context.
|
||||||
|
pub fn describe(va: u64, mods: &[Module], regions: &[Region]) -> String {
|
||||||
|
if let Some(m) = mods.iter().find(|m| m.contains(va)) {
|
||||||
|
return format!("{}+{:#x}", m.name, va - m.base);
|
||||||
|
}
|
||||||
|
match regions.iter().find(|r| va >= r.start && va < r.end) {
|
||||||
|
Some(r) => match r.path.as_deref() {
|
||||||
|
Some(p) => format!("{}+{:#x}", p.rsplit('/').next().unwrap_or(p), va - r.start),
|
||||||
|
None => format!("anon:{:#x}({})", r.start, r.perms),
|
||||||
|
},
|
||||||
|
None => "unmapped".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,576 @@
|
|||||||
|
//! # futmem: a read-only live-memory inspector for FIFA 17
|
||||||
|
//!
|
||||||
|
//! Preservation and interoperability tooling for the OpenFUT project. FIFA 17's
|
||||||
|
//! `FIFA17.exe` is Denuvo-packed, so its `.text` and `.rdata` exist in plaintext
|
||||||
|
//! only inside the running process. Anything the packed executable owns can be
|
||||||
|
//! reached only through live memory. This tool is how you reach it.
|
||||||
|
//!
|
||||||
|
//! ## READ ONLY BY CONSTRUCTION
|
||||||
|
//!
|
||||||
|
//! A live game session may be running while this tool is used, and corrupting it
|
||||||
|
//! costs the user their session. The read-only property is therefore structural
|
||||||
|
//! rather than a matter of discipline:
|
||||||
|
//!
|
||||||
|
//! * `/proc/<pid>/mem` is opened with `File::open`, i.e. `O_RDONLY`. The string
|
||||||
|
//! `OpenOptions` does not appear anywhere in this crate.
|
||||||
|
//! * `ProcMem` exposes `&self` read methods only, hands out no `&mut File` and
|
||||||
|
//! no raw descriptor, so no caller can upgrade the handle to a writable one.
|
||||||
|
//! * Nothing here calls `ptrace`, sends a signal, or stops the target.
|
||||||
|
//!
|
||||||
|
//! There is no code path in this crate that can write to another process. Even
|
||||||
|
//! if one were added by mistake, the kernel would reject the write on an
|
||||||
|
//! `O_RDONLY` descriptor.
|
||||||
|
//!
|
||||||
|
//! ## Design notes
|
||||||
|
//!
|
||||||
|
//! * **pread, not seek+read.** `FileExt::read_at` takes the offset as an
|
||||||
|
//! argument instead of mutating a shared file cursor, so a `&ProcMem` can be
|
||||||
|
//! shared across threads later without a mutex, and a whole class of "forgot
|
||||||
|
//! to seek" bugs disappears. See `mem.rs`.
|
||||||
|
//! * **Partial sweeps are normal.** Many regions marked readable in
|
||||||
|
//! `/proc/<pid>/maps` are not actually readable: guard pages, Wine's special
|
||||||
|
//! mappings, and pages Denuvo has not faulted in all return `EIO`. A failed
|
||||||
|
//! read is skipped and counted, never fatal, and the counts are printed so a
|
||||||
|
//! zero-hit result is never mistaken for proof of absence. See `scan.rs`.
|
||||||
|
//! * **Chunked reads with a `pattern_len - 1` overlap.** The target has roughly
|
||||||
|
//! 3 GB resident, so regions are walked in 4 MiB chunks. Consecutive chunks
|
||||||
|
//! overlap by exactly `pattern_len - 1` bytes so a pattern straddling a
|
||||||
|
//! boundary is still found, and not double-reported. `scan.rs` carries the
|
||||||
|
//! proof that this specific overlap is the correct one; it is the classic
|
||||||
|
//! off-by-one in scanners of this kind.
|
||||||
|
//! * **Minimal dependencies.** `memchr` is the only one, and it earns its place
|
||||||
|
//! on a multi-gigabyte sweep. Four subcommands do not justify `clap`.
|
||||||
|
//!
|
||||||
|
//! ## The Wine mapping gotcha
|
||||||
|
//!
|
||||||
|
//! Wine keeps only a PE's 4 KiB header file-backed and copies the sections into
|
||||||
|
//! anonymous memory. `grep CardsDLL /proc/<pid>/maps` therefore returns exactly
|
||||||
|
//! one 4 KiB line. A module table built naively from the maps reports CardsDLL as
|
||||||
|
//! a 4 KiB module when it is really 0x31d000 bytes. `futmem maps` reads
|
||||||
|
//! `SizeOfImage` from the live PE header instead, and derives the relocation
|
||||||
|
//! slide by comparing the live load address against the on-disk `ImageBase`, so
|
||||||
|
//! the number needed to convert Ghidra addresses to live ones is printed rather
|
||||||
|
//! than recomputed by hand.
|
||||||
|
|
||||||
|
mod cli;
|
||||||
|
mod dump;
|
||||||
|
mod image;
|
||||||
|
mod maps;
|
||||||
|
mod mem;
|
||||||
|
mod scan;
|
||||||
|
|
||||||
|
use cli::{parse_addr, parse_len, ArgError, Args};
|
||||||
|
use maps::{human, Region};
|
||||||
|
use mem::ProcMem;
|
||||||
|
use std::io::{self, BufWriter, Write};
|
||||||
|
use std::process::ExitCode;
|
||||||
|
|
||||||
|
const COMM: &str = "FIFA17.exe";
|
||||||
|
/// Modules this project always wants to know the status of.
|
||||||
|
const KEY_MODULES: [&str; 3] = [
|
||||||
|
"FIFA17.exe",
|
||||||
|
"CardsDLL_Win64_retail.dll",
|
||||||
|
"powdll_Win64_retail.dll",
|
||||||
|
];
|
||||||
|
|
||||||
|
const USAGE: &str = "\
|
||||||
|
futmem: read-only live-memory inspector for FIFA 17 (OpenFUT preservation tooling)
|
||||||
|
|
||||||
|
USAGE
|
||||||
|
futmem maps [--pid N]
|
||||||
|
futmem find <pattern> [--pid N] [--ascii|--utf16|--hex] [--module NAME] [--max N]
|
||||||
|
futmem strings [--pid N] [--min 6] [--range START-END] [--module NAME] [--utf16]
|
||||||
|
[--grep SUBSTR] [--max N]
|
||||||
|
futmem read <va> <len> [--pid N]
|
||||||
|
|
||||||
|
COMMON
|
||||||
|
--pid N Target pid. Omitted, futmem resolves the process whose
|
||||||
|
/proc/<pid>/comm is exactly \"FIFA17.exe\". Decoy processes in the
|
||||||
|
Proton tree match a pgrep -f on \"fifa17\", so comm is the authority.
|
||||||
|
|
||||||
|
find
|
||||||
|
--ascii Pattern is ASCII text. This is the default.
|
||||||
|
--utf16 Widen the ASCII pattern to UTF-16LE, how Windows stores most UI
|
||||||
|
strings.
|
||||||
|
--hex Pattern is a hex byte string, e.g. 4883ec284885c9. Spaces ignored.
|
||||||
|
--module NAME Restrict the scan to a module's image span, matched case
|
||||||
|
insensitively on a substring of the file name, e.g. --module cardsdll.
|
||||||
|
--max N Stop after N hits.
|
||||||
|
|
||||||
|
strings
|
||||||
|
--min N Minimum run length. Default 6.
|
||||||
|
--range A-B Scan exactly this address range, e.g. --range 0x1450f3000-0x14b1a3000.
|
||||||
|
--module NAME Scan a module's image span.
|
||||||
|
--utf16 Extract UTF-16LE strings instead of ASCII.
|
||||||
|
--grep S Only print strings containing S, matched case insensitively.
|
||||||
|
--max N Stop after N strings.
|
||||||
|
With none of --range or --module, the default scope is every anonymous private
|
||||||
|
region, which is where a packed executable's decrypted data lives.
|
||||||
|
|
||||||
|
Addresses may be written 0x140000000 or 140000000; bare values are read as hex.
|
||||||
|
Lengths accept 0x100, 256, 16k, 2m.
|
||||||
|
|
||||||
|
All operations are strictly read-only. See the crate docs for the guarantee.
|
||||||
|
";
|
||||||
|
|
||||||
|
fn main() -> ExitCode {
|
||||||
|
let argv: Vec<String> = std::env::args().skip(1).collect();
|
||||||
|
let Some(sub) = argv.first().cloned() else {
|
||||||
|
print!("{USAGE}");
|
||||||
|
return ExitCode::FAILURE;
|
||||||
|
};
|
||||||
|
let rest = argv.into_iter().skip(1);
|
||||||
|
|
||||||
|
let stdout = io::stdout();
|
||||||
|
let mut out = BufWriter::new(stdout.lock());
|
||||||
|
|
||||||
|
let result = match sub.as_str() {
|
||||||
|
"maps" => cmd_maps(&mut out, rest),
|
||||||
|
"find" => cmd_find(&mut out, rest),
|
||||||
|
"strings" => cmd_strings(&mut out, rest),
|
||||||
|
"read" => cmd_read(&mut out, rest),
|
||||||
|
"-h" | "--help" | "help" => {
|
||||||
|
print!("{USAGE}");
|
||||||
|
return ExitCode::SUCCESS;
|
||||||
|
}
|
||||||
|
other => {
|
||||||
|
eprintln!("futmem: unknown subcommand {other:?}\n");
|
||||||
|
eprint!("{USAGE}");
|
||||||
|
return ExitCode::FAILURE;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Flushing separately so a broken pipe (futmem strings | head) is not
|
||||||
|
// reported as a failure.
|
||||||
|
let flushed = out.flush();
|
||||||
|
match (result, flushed) {
|
||||||
|
(Err(e), _) if e.kind() == io::ErrorKind::BrokenPipe => ExitCode::SUCCESS,
|
||||||
|
(_, Err(e)) if e.kind() == io::ErrorKind::BrokenPipe => ExitCode::SUCCESS,
|
||||||
|
(Err(e), _) => {
|
||||||
|
eprintln!("futmem: {e}");
|
||||||
|
ExitCode::FAILURE
|
||||||
|
}
|
||||||
|
(Ok(()), Err(e)) => {
|
||||||
|
eprintln!("futmem: {e}");
|
||||||
|
ExitCode::FAILURE
|
||||||
|
}
|
||||||
|
(Ok(()), Ok(())) => ExitCode::SUCCESS,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn arg_err(e: ArgError) -> io::Error {
|
||||||
|
new_invalid(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve the target pid from `--pid` or by scanning `/proc/*/comm`.
|
||||||
|
fn resolve_pid(args: &Args) -> io::Result<i32> {
|
||||||
|
match args.parse_value::<i32>("pid").map_err(arg_err)? {
|
||||||
|
Some(pid) => Ok(pid),
|
||||||
|
None => maps::find_pid(COMM),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- maps
|
||||||
|
|
||||||
|
fn cmd_maps<W: Write>(out: &mut W, argv: impl Iterator<Item = String>) -> io::Result<()> {
|
||||||
|
let args = Args::parse(argv, &["pid"]).map_err(arg_err)?;
|
||||||
|
args.reject_unknown(&["pid"]).map_err(arg_err)?;
|
||||||
|
let pid = resolve_pid(&args)?;
|
||||||
|
let regions = maps::read_maps(pid)?;
|
||||||
|
let mem = ProcMem::open(pid)?;
|
||||||
|
let mods = image::modules(®ions, &mem);
|
||||||
|
|
||||||
|
// Report the comm we actually found, not the one we hoped for: an explicit
|
||||||
|
// --pid may point anywhere, and silently labelling it "FIFA17.exe" would
|
||||||
|
// make a wrong-target mistake invisible.
|
||||||
|
let comm = maps::read_comm(pid);
|
||||||
|
let warn = if comm == COMM {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
format!(" <-- NOT {COMM}; this is not the game process")
|
||||||
|
};
|
||||||
|
writeln!(
|
||||||
|
out,
|
||||||
|
"pid {pid} (comm {comm:?}), {} mapped regions{warn}",
|
||||||
|
regions.len()
|
||||||
|
)?;
|
||||||
|
writeln!(out)?;
|
||||||
|
|
||||||
|
// -- key modules first, so "is FUT loaded yet?" is answerable at a glance.
|
||||||
|
writeln!(out, "KEY MODULES")?;
|
||||||
|
for want in KEY_MODULES {
|
||||||
|
match image::find_module(&mods, want) {
|
||||||
|
Some(m) => {
|
||||||
|
let slide = match m.slide() {
|
||||||
|
Some(s) if s >= 0 => format!("slide +{:#x}", s),
|
||||||
|
Some(s) => format!("slide -{:#x}", -s),
|
||||||
|
None => "slide unknown".to_string(),
|
||||||
|
};
|
||||||
|
let static_base = m
|
||||||
|
.disk_image_base
|
||||||
|
.map(|b| format!("static {b:#x}"))
|
||||||
|
.unwrap_or_else(|| "static ?".to_string());
|
||||||
|
writeln!(
|
||||||
|
out,
|
||||||
|
" {:<28} PRESENT base {:#x} size {:#x} {static_base} {slide}",
|
||||||
|
m.name,
|
||||||
|
m.base,
|
||||||
|
m.size_of_image.unwrap_or(m.maps_end - m.base),
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
None => writeln!(
|
||||||
|
out,
|
||||||
|
" {want:<28} ABSENT not in this process's maps (the game has not loaded it yet)"
|
||||||
|
)?,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(m) = image::find_module(&mods, "CardsDLL") {
|
||||||
|
if let Some(slide) = m.slide() {
|
||||||
|
writeln!(out)?;
|
||||||
|
writeln!(
|
||||||
|
out,
|
||||||
|
" CardsDLL address conversion: live_va = static_va + {slide:#x}"
|
||||||
|
)?;
|
||||||
|
writeln!(
|
||||||
|
out,
|
||||||
|
" (Ghidra static base {:#x} -> live base {:#x}. Valid for pid {pid} only; \
|
||||||
|
module bases move on every launch.)",
|
||||||
|
m.disk_image_base.unwrap_or(0),
|
||||||
|
m.base
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeln!(out)?;
|
||||||
|
|
||||||
|
// -- full module table
|
||||||
|
writeln!(out, "MODULES (file-backed, grouped by path)")?;
|
||||||
|
writeln!(
|
||||||
|
out,
|
||||||
|
" {:<14} {:<14} {:<12} {:>5} name",
|
||||||
|
"base", "end (PE)", "size", "regs"
|
||||||
|
)?;
|
||||||
|
for m in &mods {
|
||||||
|
let note = if !m.is_pe() {
|
||||||
|
// A device node, .nls table or font, not a loadable image. Its
|
||||||
|
// min..max span is meaningless, so say so rather than imply an extent.
|
||||||
|
" [non-PE mapping; span is min..max of scattered regions]".to_string()
|
||||||
|
} else if m.maps_end - m.base < m.end() - m.base {
|
||||||
|
// The Wine gotcha, made visible instead of silently misleading.
|
||||||
|
format!(
|
||||||
|
" [maps shows only {}; sections are anonymous]",
|
||||||
|
human(m.maps_end - m.base)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
writeln!(
|
||||||
|
out,
|
||||||
|
" {:<14x} {:<14x} {:<12} {:>5} {}{}",
|
||||||
|
m.base,
|
||||||
|
m.end(),
|
||||||
|
human(m.end() - m.base),
|
||||||
|
m.region_count,
|
||||||
|
m.name,
|
||||||
|
note
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
writeln!(out)?;
|
||||||
|
|
||||||
|
// -- writable + executable regions: where packers put decrypted code.
|
||||||
|
let wx: Vec<&Region> = regions
|
||||||
|
.iter()
|
||||||
|
.filter(|r| r.writable() && r.executable())
|
||||||
|
.collect();
|
||||||
|
let wx_total: u64 = wx.iter().map(|r| r.size()).sum();
|
||||||
|
writeln!(
|
||||||
|
out,
|
||||||
|
"WRITABLE + EXECUTABLE REGIONS ({} regions, {})",
|
||||||
|
wx.len(),
|
||||||
|
human(wx_total)
|
||||||
|
)?;
|
||||||
|
// Wine emits hundreds of 4 KiB per-thread stubs that are pure noise.
|
||||||
|
let mut small_wx = 0usize;
|
||||||
|
for r in &wx {
|
||||||
|
if r.size() <= 64 * 1024 {
|
||||||
|
small_wx += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
writeln!(
|
||||||
|
out,
|
||||||
|
" {:012x}-{:012x} {} {:>10} {}",
|
||||||
|
r.start,
|
||||||
|
r.end,
|
||||||
|
r.perms,
|
||||||
|
human(r.size()),
|
||||||
|
describe_region(r, &mods)
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
if small_wx > 0 {
|
||||||
|
writeln!(
|
||||||
|
out,
|
||||||
|
" (+{small_wx} regions of 64 KiB or less, Wine per-thread stubs, omitted)"
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
writeln!(out)?;
|
||||||
|
|
||||||
|
// -- large anonymous private regions
|
||||||
|
let mut anon: Vec<&Region> = regions
|
||||||
|
.iter()
|
||||||
|
.filter(|r| r.anonymous() && r.private() && r.readable() && r.size() > 1024 * 1024)
|
||||||
|
.collect();
|
||||||
|
anon.sort_by_key(|r| std::cmp::Reverse(r.size()));
|
||||||
|
let anon_total: u64 = anon.iter().map(|r| r.size()).sum();
|
||||||
|
writeln!(
|
||||||
|
out,
|
||||||
|
"ANONYMOUS PRIVATE REGIONS OVER 1 MB ({} regions, {})",
|
||||||
|
anon.len(),
|
||||||
|
human(anon_total)
|
||||||
|
)?;
|
||||||
|
for r in &anon {
|
||||||
|
writeln!(
|
||||||
|
out,
|
||||||
|
" {:012x}-{:012x} {} {:>10} {}",
|
||||||
|
r.start,
|
||||||
|
r.end,
|
||||||
|
r.perms,
|
||||||
|
human(r.size()),
|
||||||
|
describe_region(r, &mods)
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Label a region with the module whose image span contains it, if any.
|
||||||
|
fn describe_region(r: &Region, mods: &[image::Module]) -> String {
|
||||||
|
if let Some(p) = r.path.as_deref() {
|
||||||
|
// The file offset matters for a packed executable: it says which part of
|
||||||
|
// the on-disk image this mapping still corresponds to.
|
||||||
|
let name = p.rsplit('/').next().unwrap_or(p);
|
||||||
|
return format!("{name} @fileoff {:#x}", r.offset);
|
||||||
|
}
|
||||||
|
match mods.iter().find(|m| m.contains(r.start)) {
|
||||||
|
Some(m) => format!("anon, inside {} image", m.name),
|
||||||
|
None => "anon".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- find
|
||||||
|
|
||||||
|
fn cmd_find<W: Write>(out: &mut W, argv: impl Iterator<Item = String>) -> io::Result<()> {
|
||||||
|
let known = ["pid", "ascii", "utf16", "hex", "module", "max"];
|
||||||
|
let args = Args::parse(argv, &["pid", "module", "max"]).map_err(arg_err)?;
|
||||||
|
args.reject_unknown(&known).map_err(arg_err)?;
|
||||||
|
|
||||||
|
let Some(raw) = args.positional.first() else {
|
||||||
|
return Err(new_invalid(ArgError("find needs a pattern".into())));
|
||||||
|
};
|
||||||
|
|
||||||
|
let pattern: Vec<u8> = if args.has("hex") {
|
||||||
|
parse_hex(raw).map_err(arg_err)?
|
||||||
|
} else if args.has("utf16") {
|
||||||
|
// Widen ASCII to UTF-16LE: each byte followed by a zero high byte.
|
||||||
|
raw.bytes().flat_map(|b| [b, 0]).collect()
|
||||||
|
} else {
|
||||||
|
raw.as_bytes().to_vec()
|
||||||
|
};
|
||||||
|
let max = args.parse_value::<usize>("max").map_err(arg_err)?;
|
||||||
|
|
||||||
|
let pid = resolve_pid(&args)?;
|
||||||
|
let regions = maps::read_maps(pid)?;
|
||||||
|
let mem = ProcMem::open(pid)?;
|
||||||
|
let mods = image::modules(®ions, &mem);
|
||||||
|
|
||||||
|
let module = match args.value("module") {
|
||||||
|
Some(name) => match image::find_module(&mods, name) {
|
||||||
|
Some(m) => Some(m.clone()),
|
||||||
|
None => {
|
||||||
|
return Err(new_invalid(ArgError(format!(
|
||||||
|
"no module matching {name:?} in pid {pid}; run `futmem maps` to list them"
|
||||||
|
))))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(m) = &module {
|
||||||
|
writeln!(
|
||||||
|
out,
|
||||||
|
"scanning {} image span {:#x}-{:#x} ({})\n from {}",
|
||||||
|
m.name,
|
||||||
|
m.base,
|
||||||
|
m.end(),
|
||||||
|
human(m.end() - m.base),
|
||||||
|
m.path
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let targets = scan::scan_targets(®ions, module.as_ref(), false);
|
||||||
|
let target_bytes: u64 = targets.iter().map(|r| r.size()).sum();
|
||||||
|
writeln!(
|
||||||
|
out,
|
||||||
|
"pattern {} bytes, {} candidate regions ({})",
|
||||||
|
pattern.len(),
|
||||||
|
targets.len(),
|
||||||
|
human(target_bytes)
|
||||||
|
)?;
|
||||||
|
writeln!(out)?;
|
||||||
|
|
||||||
|
let mut hits: Vec<u64> = Vec::new();
|
||||||
|
let stats = scan::find_pattern(&mem, &targets, &pattern, max, |va| hits.push(va));
|
||||||
|
|
||||||
|
for va in &hits {
|
||||||
|
let loc = image::describe(*va, &mods, ®ions);
|
||||||
|
writeln!(out, "{va:#014x} {loc}")?;
|
||||||
|
let ctx = mem.read_partial(*va, 64);
|
||||||
|
if !ctx.is_empty() {
|
||||||
|
dump::hexdump(out, *va, &ctx, " ")?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
writeln!(out)?;
|
||||||
|
writeln!(out, "{} hits; {}", hits.len(), stats.summary())?;
|
||||||
|
if hits.is_empty() {
|
||||||
|
writeln!(
|
||||||
|
out,
|
||||||
|
"note: {} regions were unreadable, so an empty result is NOT proof of absence.",
|
||||||
|
stats.regions_skipped
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_hex(raw: &str) -> Result<Vec<u8>, ArgError> {
|
||||||
|
let cleaned: String = raw
|
||||||
|
.chars()
|
||||||
|
.filter(|c| !c.is_whitespace() && *c != ':' && *c != ',')
|
||||||
|
.collect();
|
||||||
|
let cleaned = cleaned.strip_prefix("0x").unwrap_or(&cleaned);
|
||||||
|
if !cleaned.len().is_multiple_of(2) {
|
||||||
|
return Err(ArgError(format!(
|
||||||
|
"hex pattern has an odd number of digits ({})",
|
||||||
|
cleaned.len()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
(0..cleaned.len())
|
||||||
|
.step_by(2)
|
||||||
|
.map(|i| {
|
||||||
|
u8::from_str_radix(&cleaned[i..i + 2], 16)
|
||||||
|
.map_err(|_| ArgError(format!("bad hex byte {:?}", &cleaned[i..i + 2])))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- strings
|
||||||
|
|
||||||
|
fn cmd_strings<W: Write>(out: &mut W, argv: impl Iterator<Item = String>) -> io::Result<()> {
|
||||||
|
let known = ["pid", "min", "range", "module", "utf16", "grep", "max"];
|
||||||
|
let args =
|
||||||
|
Args::parse(argv, &["pid", "min", "range", "module", "grep", "max"]).map_err(arg_err)?;
|
||||||
|
args.reject_unknown(&known).map_err(arg_err)?;
|
||||||
|
|
||||||
|
let min = args
|
||||||
|
.parse_value::<usize>("min")
|
||||||
|
.map_err(arg_err)?
|
||||||
|
.unwrap_or(6);
|
||||||
|
let max = args.parse_value::<usize>("max").map_err(arg_err)?;
|
||||||
|
let grep = args.value("grep");
|
||||||
|
let utf16 = args.has("utf16");
|
||||||
|
|
||||||
|
let pid = resolve_pid(&args)?;
|
||||||
|
let regions = maps::read_maps(pid)?;
|
||||||
|
let mem = ProcMem::open(pid)?;
|
||||||
|
let mods = image::modules(®ions, &mem);
|
||||||
|
|
||||||
|
let targets: Vec<Region> = if let Some(range) = args.value("range") {
|
||||||
|
let (a, b) = range
|
||||||
|
.split_once('-')
|
||||||
|
.ok_or_else(|| new_invalid(ArgError("--range wants START-END".into())))?;
|
||||||
|
let start = parse_addr(a).map_err(arg_err)?;
|
||||||
|
let end = parse_addr(b).map_err(arg_err)?;
|
||||||
|
if end <= start {
|
||||||
|
return Err(new_invalid(ArgError(format!(
|
||||||
|
"--range end {end:#x} is not above start {start:#x}"
|
||||||
|
))));
|
||||||
|
}
|
||||||
|
writeln!(out, "scanning {start:#x}-{end:#x} ({})", human(end - start))?;
|
||||||
|
vec![Region {
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
perms: "r--p".to_string(),
|
||||||
|
offset: 0,
|
||||||
|
path: None,
|
||||||
|
}]
|
||||||
|
} else if let Some(name) = args.value("module") {
|
||||||
|
let m = image::find_module(&mods, name).ok_or_else(|| {
|
||||||
|
new_invalid(ArgError(format!(
|
||||||
|
"no module matching {name:?} in pid {pid}"
|
||||||
|
)))
|
||||||
|
})?;
|
||||||
|
writeln!(
|
||||||
|
out,
|
||||||
|
"scanning {} image span {:#x}-{:#x} ({})\n from {}",
|
||||||
|
m.name,
|
||||||
|
m.base,
|
||||||
|
m.end(),
|
||||||
|
human(m.end() - m.base),
|
||||||
|
m.path
|
||||||
|
)?;
|
||||||
|
scan::scan_targets(®ions, Some(m), false)
|
||||||
|
} else {
|
||||||
|
// Default scope: anonymous private memory, where a packed executable's
|
||||||
|
// decrypted data lives.
|
||||||
|
let t = scan::scan_targets(®ions, None, true);
|
||||||
|
let bytes: u64 = t.iter().map(|r| r.size()).sum();
|
||||||
|
writeln!(
|
||||||
|
out,
|
||||||
|
"scanning {} anonymous private regions ({})",
|
||||||
|
t.len(),
|
||||||
|
human(bytes)
|
||||||
|
)?;
|
||||||
|
t
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut count = 0usize;
|
||||||
|
let stats = scan::find_strings(&mem, &targets, utf16, min, grep, max, |va, s| {
|
||||||
|
count += 1;
|
||||||
|
// Ignoring the write error here keeps the closure simple; a broken pipe
|
||||||
|
// is caught when the buffer is flushed in main.
|
||||||
|
let _ = writeln!(out, "{va:#014x} {}", s);
|
||||||
|
});
|
||||||
|
|
||||||
|
writeln!(out)?;
|
||||||
|
writeln!(out, "{count} strings; {}", stats.summary())?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- read
|
||||||
|
|
||||||
|
fn cmd_read<W: Write>(out: &mut W, argv: impl Iterator<Item = String>) -> io::Result<()> {
|
||||||
|
let args = Args::parse(argv, &["pid"]).map_err(arg_err)?;
|
||||||
|
args.reject_unknown(&["pid"]).map_err(arg_err)?;
|
||||||
|
if args.positional.len() < 2 {
|
||||||
|
return Err(new_invalid(ArgError("read needs <va> and <len>".into())));
|
||||||
|
}
|
||||||
|
let va = parse_addr(&args.positional[0]).map_err(arg_err)?;
|
||||||
|
let len = parse_len(&args.positional[1]).map_err(arg_err)?;
|
||||||
|
if len == 0 || len > 64 * 1024 * 1024 {
|
||||||
|
return Err(new_invalid(ArgError(format!(
|
||||||
|
"length {len} out of range (1 .. 64 MiB)"
|
||||||
|
))));
|
||||||
|
}
|
||||||
|
|
||||||
|
let pid = resolve_pid(&args)?;
|
||||||
|
let regions = maps::read_maps(pid)?;
|
||||||
|
let mem = ProcMem::open(pid)?;
|
||||||
|
let mods = image::modules(®ions, &mem);
|
||||||
|
|
||||||
|
writeln!(out, "{va:#x} {}", image::describe(va, &mods, ®ions))?;
|
||||||
|
let data = mem.read_exact(va, len as usize)?;
|
||||||
|
dump::hexdump(out, va, &data, "")?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn new_invalid(e: ArgError) -> io::Error {
|
||||||
|
io::Error::new(io::ErrorKind::InvalidInput, e.0)
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
//! Parsing `/proc/<pid>/maps` and finding the FIFA 17 process.
|
||||||
|
|
||||||
|
use std::fs;
|
||||||
|
use std::io;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Region {
|
||||||
|
pub start: u64,
|
||||||
|
pub end: u64,
|
||||||
|
/// The raw four permission characters, e.g. `rwxp` or `r--s`.
|
||||||
|
pub perms: String,
|
||||||
|
/// File offset this mapping starts at, meaningless for anonymous regions.
|
||||||
|
pub offset: u64,
|
||||||
|
/// `None` for anonymous mappings.
|
||||||
|
pub path: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Region {
|
||||||
|
pub fn size(&self) -> u64 {
|
||||||
|
self.end - self.start
|
||||||
|
}
|
||||||
|
pub fn readable(&self) -> bool {
|
||||||
|
self.perms.as_bytes().first() == Some(&b'r')
|
||||||
|
}
|
||||||
|
pub fn writable(&self) -> bool {
|
||||||
|
self.perms.as_bytes().get(1) == Some(&b'w')
|
||||||
|
}
|
||||||
|
pub fn executable(&self) -> bool {
|
||||||
|
self.perms.as_bytes().get(2) == Some(&b'x')
|
||||||
|
}
|
||||||
|
pub fn private(&self) -> bool {
|
||||||
|
self.perms.as_bytes().get(3) == Some(&b'p')
|
||||||
|
}
|
||||||
|
pub fn anonymous(&self) -> bool {
|
||||||
|
self.path.is_none()
|
||||||
|
}
|
||||||
|
/// Pseudo-files the kernel exposes. Reading `[vvar]` through
|
||||||
|
/// `/proc/pid/mem` fails, and `[vsyscall]` is not interesting here.
|
||||||
|
pub fn pseudo(&self) -> bool {
|
||||||
|
matches!(self.path.as_deref(), Some(p) if p.starts_with('['))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn read_maps(pid: i32) -> io::Result<Vec<Region>> {
|
||||||
|
let text = fs::read_to_string(format!("/proc/{pid}/maps")).map_err(|e| {
|
||||||
|
let hint = if fs::metadata(format!("/proc/{pid}")).is_err() {
|
||||||
|
format!("no process with pid {pid}")
|
||||||
|
} else {
|
||||||
|
format!("pid {pid} exists but its maps are unreadable (different user?)")
|
||||||
|
};
|
||||||
|
io::Error::new(e.kind(), format!("reading /proc/{pid}/maps: {hint}"))
|
||||||
|
})?;
|
||||||
|
Ok(text.lines().filter_map(parse_line).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The target's `comm`, so output can name what was actually inspected rather
|
||||||
|
/// than assuming an explicit `--pid` pointed at the game.
|
||||||
|
pub fn read_comm(pid: i32) -> String {
|
||||||
|
fs::read_to_string(format!("/proc/{pid}/comm"))
|
||||||
|
.map(|s| s.trim().to_string())
|
||||||
|
.unwrap_or_else(|_| "?".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pull the next whitespace-delimited field starting at `cursor`, advancing it.
|
||||||
|
fn next_field<'a>(line: &'a str, cursor: &mut usize) -> Option<&'a str> {
|
||||||
|
let bytes = line.as_bytes();
|
||||||
|
while *cursor < bytes.len() && bytes[*cursor].is_ascii_whitespace() {
|
||||||
|
*cursor += 1;
|
||||||
|
}
|
||||||
|
let start = *cursor;
|
||||||
|
while *cursor < bytes.len() && !bytes[*cursor].is_ascii_whitespace() {
|
||||||
|
*cursor += 1;
|
||||||
|
}
|
||||||
|
if start == *cursor {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(&line[start..*cursor])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_line(line: &str) -> Option<Region> {
|
||||||
|
// Format: `start-end perms offset dev inode path`
|
||||||
|
//
|
||||||
|
// The path may contain spaces (`/mnt/games/FIFA 17/FIFA17.exe`) and may
|
||||||
|
// carry a ` (deleted)` suffix, so we consume exactly five leading fields by
|
||||||
|
// position and take the untouched remainder as the path.
|
||||||
|
//
|
||||||
|
// Doing this with `line.find(inode)` to locate the split point is a trap:
|
||||||
|
// the inode of an anonymous mapping is "0", and `find("0")` happily matches
|
||||||
|
// a zero digit inside the address range at the very start of the line. That
|
||||||
|
// silently turns half the address into a path. Hence the explicit cursor.
|
||||||
|
let mut cursor = 0usize;
|
||||||
|
let range = next_field(line, &mut cursor)?;
|
||||||
|
let perms = next_field(line, &mut cursor)?;
|
||||||
|
let offset = next_field(line, &mut cursor)?;
|
||||||
|
let _dev = next_field(line, &mut cursor)?;
|
||||||
|
let _inode = next_field(line, &mut cursor)?;
|
||||||
|
|
||||||
|
let (start, end) = range.split_once('-')?;
|
||||||
|
let start = u64::from_str_radix(start, 16).ok()?;
|
||||||
|
let end = u64::from_str_radix(end, 16).ok()?;
|
||||||
|
|
||||||
|
let tail = line[cursor..].trim();
|
||||||
|
let path = if tail.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(tail.to_string())
|
||||||
|
};
|
||||||
|
|
||||||
|
Some(Region {
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
perms: perms.to_string(),
|
||||||
|
offset: u64::from_str_radix(offset, 16).ok()?,
|
||||||
|
path,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find the FIFA 17 process.
|
||||||
|
///
|
||||||
|
/// `comm` is the authority, NOT `cmdline`. Under Proton there are a dozen
|
||||||
|
/// helper processes (bash, umu-run, srt-bwrap, pv-adverb, proton, umu.exe)
|
||||||
|
/// whose command lines mention fifa17, and at least one of them
|
||||||
|
/// (`umu.exe /mnt/games/FIFA 17/_fifa17.exe`) is a convincing decoy. Only the
|
||||||
|
/// real game has `comm == "FIFA17.exe"`. Its `/proc/<pid>/exe` points at
|
||||||
|
/// wine64-preloader, which is expected and is not a reason to doubt the match.
|
||||||
|
pub fn find_pid(comm_name: &str) -> io::Result<i32> {
|
||||||
|
let mut hits = Vec::new();
|
||||||
|
for entry in fs::read_dir("/proc")? {
|
||||||
|
let entry = entry?;
|
||||||
|
let name = entry.file_name();
|
||||||
|
let Some(name) = name.to_str() else { continue };
|
||||||
|
let Ok(pid) = name.parse::<i32>() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if let Ok(comm) = fs::read_to_string(format!("/proc/{pid}/comm")) {
|
||||||
|
if comm.trim() == comm_name {
|
||||||
|
hits.push(pid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
match hits.len() {
|
||||||
|
0 => Err(io::Error::new(
|
||||||
|
io::ErrorKind::NotFound,
|
||||||
|
format!("no process with comm == {comm_name:?}; is the game running? pass --pid to override"),
|
||||||
|
)),
|
||||||
|
1 => Ok(hits[0]),
|
||||||
|
_ => Err(io::Error::new(
|
||||||
|
io::ErrorKind::InvalidData,
|
||||||
|
format!("{} processes have comm == {comm_name:?}: {hits:?}; pass --pid to disambiguate", hits.len()),
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn human(bytes: u64) -> String {
|
||||||
|
const UNITS: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"];
|
||||||
|
let mut value = bytes as f64;
|
||||||
|
let mut unit = 0;
|
||||||
|
while value >= 1024.0 && unit < UNITS.len() - 1 {
|
||||||
|
value /= 1024.0;
|
||||||
|
unit += 1;
|
||||||
|
}
|
||||||
|
if unit == 0 {
|
||||||
|
format!("{bytes} B")
|
||||||
|
} else {
|
||||||
|
format!("{value:.2} {}", UNITS[unit])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
//! Read-only access to another process's address space.
|
||||||
|
//!
|
||||||
|
//! # The safety property this module exists to guarantee
|
||||||
|
//!
|
||||||
|
//! A live FIFA 17 session may be running while this tool is used. Corrupting it
|
||||||
|
//! costs the user their progress and their patience. So the guarantee here is
|
||||||
|
//! structural, not a matter of being careful:
|
||||||
|
//!
|
||||||
|
//! * `/proc/<pid>/mem` is opened with [`File::open`], which is `O_RDONLY`.
|
||||||
|
//! There is no [`std::fs::OpenOptions`] anywhere in this crate.
|
||||||
|
//! * [`ProcMem`] exposes `&self` read methods only. It hands out no `&mut File`
|
||||||
|
//! and no raw fd, so no caller outside this module can upgrade the handle.
|
||||||
|
//! * Nothing in the crate calls `ptrace`, sends a signal, or writes to any
|
||||||
|
//! path under `/proc`.
|
||||||
|
//!
|
||||||
|
//! Even if a caller tried to write, the kernel would reject it on an `O_RDONLY`
|
||||||
|
//! descriptor. The type system and the open mode agree, which is the point.
|
||||||
|
//!
|
||||||
|
//! # Why pread and not seek + read
|
||||||
|
//!
|
||||||
|
//! [`FileExt::read_at`] is `pread(2)`: it takes the offset as an argument
|
||||||
|
//! instead of mutating a shared file cursor. That means a `&ProcMem` can be
|
||||||
|
//! shared across threads later without a mutex and without one thread's seek
|
||||||
|
//! corrupting another's read. It also removes a whole class of "forgot to seek"
|
||||||
|
//! bugs. There is never a reason to prefer seek+read here.
|
||||||
|
|
||||||
|
use std::fs::File;
|
||||||
|
use std::io;
|
||||||
|
use std::os::unix::fs::FileExt;
|
||||||
|
|
||||||
|
/// The page size we assume when stepping over an unreadable hole. Every x86-64
|
||||||
|
/// mapping is a multiple of this, so it is a safe granularity for recovery.
|
||||||
|
pub const PAGE: u64 = 4096;
|
||||||
|
|
||||||
|
/// A read-only handle on a process's memory.
|
||||||
|
pub struct ProcMem {
|
||||||
|
file: File,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a single chunk read produced.
|
||||||
|
pub enum ChunkRead {
|
||||||
|
/// `n` bytes landed in the buffer. May be shorter than requested when the
|
||||||
|
/// read ran into an unmapped hole partway through.
|
||||||
|
Got(usize),
|
||||||
|
/// Nothing readable at this address at all.
|
||||||
|
Hole,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProcMem {
|
||||||
|
/// Open the target read-only. See the module docs for why this is
|
||||||
|
/// `File::open` and must stay that way.
|
||||||
|
pub fn open(pid: i32) -> io::Result<Self> {
|
||||||
|
let file = File::open(format!("/proc/{pid}/mem")).map_err(|e| {
|
||||||
|
io::Error::new(
|
||||||
|
e.kind(),
|
||||||
|
format!("opening /proc/{pid}/mem: {e} (same-user or CAP_SYS_PTRACE required)"),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
Ok(Self { file })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Best-effort read. Never fatal: a hole reports [`ChunkRead::Hole`] rather
|
||||||
|
/// than propagating an error, because in a 3 GB sweep unreadable regions are
|
||||||
|
/// the normal case, not an exceptional one.
|
||||||
|
///
|
||||||
|
/// Guard pages, Wine's special mappings and pages Denuvo has not faulted in
|
||||||
|
/// are all marked readable in `/proc/<pid>/maps` yet return `EIO` here. The
|
||||||
|
/// caller counts these and reports the total so the user knows the sweep was
|
||||||
|
/// partial.
|
||||||
|
pub fn read_chunk(&self, va: u64, buf: &mut [u8]) -> ChunkRead {
|
||||||
|
match self.file.read_at(buf, va) {
|
||||||
|
Ok(0) | Err(_) => ChunkRead::Hole,
|
||||||
|
Ok(n) => ChunkRead::Got(n),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Strict read for cases where a short read is genuinely an error, such as
|
||||||
|
/// an explicit `futmem read <va> <len>` the user asked for by hand.
|
||||||
|
pub fn read_exact(&self, va: u64, len: usize) -> io::Result<Vec<u8>> {
|
||||||
|
let mut buf = vec![0u8; len];
|
||||||
|
self.file.read_exact_at(&mut buf, va).map_err(|e| {
|
||||||
|
io::Error::new(
|
||||||
|
e.kind(),
|
||||||
|
format!("reading {len} bytes at {va:#x}: {e} (address may be unmapped)"),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
Ok(buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read up to `len` bytes, returning however many were actually available.
|
||||||
|
/// Used for printing context around a hit that sits near the end of a region.
|
||||||
|
pub fn read_partial(&self, va: u64, len: usize) -> Vec<u8> {
|
||||||
|
let mut buf = vec![0u8; len];
|
||||||
|
match self.file.read_at(&mut buf, va) {
|
||||||
|
Ok(n) => {
|
||||||
|
buf.truncate(n);
|
||||||
|
buf
|
||||||
|
}
|
||||||
|
Err(_) => Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,376 @@
|
|||||||
|
//! Chunked sweeping of a remote address space, plus the two things we sweep
|
||||||
|
//! for: byte patterns and printable strings.
|
||||||
|
//!
|
||||||
|
//! # Why chunking, and the off-by-one that ruins scanners
|
||||||
|
//!
|
||||||
|
//! The target has roughly 3 GB resident. Reading a region in one allocation is
|
||||||
|
//! wasteful and can fail outright, so regions are walked in 4 MiB chunks.
|
||||||
|
//!
|
||||||
|
//! The classic bug in every hand-rolled scanner is that a pattern straddling a
|
||||||
|
//! chunk boundary is never found: the tail of chunk N holds the first few bytes
|
||||||
|
//! and the head of chunk N+1 holds the rest, and neither buffer contains the
|
||||||
|
//! whole thing. The fix is to overlap consecutive chunks by `pattern_len - 1`
|
||||||
|
//! bytes.
|
||||||
|
//!
|
||||||
|
//! That specific overlap is exactly right, and it is worth showing why it is
|
||||||
|
//! neither too small nor too large. Let a chunk cover `[0, n)` and the pattern
|
||||||
|
//! have length `P`. A match starting at index `s` occupies `s ..= s + P - 1`, so
|
||||||
|
//! the last match fully inside the chunk starts at `s = n - P`. Any match
|
||||||
|
//! starting at `s > n - P` runs off the end and must be caught by the next
|
||||||
|
//! chunk, so the next chunk has to begin at or before `n - P + 1`. Advancing by
|
||||||
|
//! `n - (P - 1)` starts it at precisely `n - P + 1`:
|
||||||
|
//!
|
||||||
|
//! * Nothing is missed: every straddling match starts at `s >= n - P + 1`,
|
||||||
|
//! which is inside the next chunk.
|
||||||
|
//! * Nothing is double-reported: the first index of the overlap is
|
||||||
|
//! `n - P + 1`, which is strictly greater than `n - P`, the last index that
|
||||||
|
//! can host a complete match in this chunk. The two windows of *reportable*
|
||||||
|
//! match starts are disjoint even though the byte windows overlap.
|
||||||
|
//!
|
||||||
|
//! Overlapping by `P` instead would report every boundary-straddling match
|
||||||
|
//! twice; overlapping by `P - 2` would miss one alignment. Hence `P - 1`.
|
||||||
|
//!
|
||||||
|
//! # Holes
|
||||||
|
//!
|
||||||
|
//! A region marked readable in `/proc/<pid>/maps` is frequently not readable in
|
||||||
|
//! practice: guard pages, Wine's special mappings, and pages Denuvo has not
|
||||||
|
//! faulted in all return `EIO`. These are counted and stepped over a page at a
|
||||||
|
//! time, never propagated as errors, because in a sweep this size they are
|
||||||
|
//! routine. The counts are reported so the user knows the sweep was partial and
|
||||||
|
//! does not read a zero-hit result as proof of absence.
|
||||||
|
|
||||||
|
use crate::image::Module;
|
||||||
|
use crate::maps::Region;
|
||||||
|
use crate::mem::{ChunkRead, ProcMem, PAGE};
|
||||||
|
|
||||||
|
pub const CHUNK: usize = 4 * 1024 * 1024;
|
||||||
|
|
||||||
|
#[derive(Default, Debug)]
|
||||||
|
pub struct SweepStats {
|
||||||
|
pub regions_scanned: usize,
|
||||||
|
/// Regions from which not a single byte could be read.
|
||||||
|
pub regions_skipped: usize,
|
||||||
|
/// Individual chunk reads that hit an unreadable hole.
|
||||||
|
pub holes: usize,
|
||||||
|
pub bytes_read: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SweepStats {
|
||||||
|
pub fn summary(&self) -> String {
|
||||||
|
format!(
|
||||||
|
"scanned {} regions ({}), skipped {} unreadable regions, {} holes stepped over",
|
||||||
|
self.regions_scanned,
|
||||||
|
crate::maps::human(self.bytes_read),
|
||||||
|
self.regions_skipped,
|
||||||
|
self.holes
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn align_up(va: u64, align: u64) -> u64 {
|
||||||
|
va.div_ceil(align) * align
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Walk one region in chunks, invoking `f(chunk_va, bytes, contiguous)`.
|
||||||
|
///
|
||||||
|
/// `contiguous` is true when this chunk's data continues directly from the
|
||||||
|
/// previous callback with no gap, which string extraction needs in order to
|
||||||
|
/// join a run that spans a boundary. `overlap` is `pattern_len - 1` for pattern
|
||||||
|
/// search and 0 for stateful scanners that track continuity themselves.
|
||||||
|
///
|
||||||
|
/// Returns early (`false`) if `f` signals it has seen enough.
|
||||||
|
fn sweep_region<F>(
|
||||||
|
mem: &ProcMem,
|
||||||
|
region: &Region,
|
||||||
|
overlap: usize,
|
||||||
|
buf: &mut [u8],
|
||||||
|
stats: &mut SweepStats,
|
||||||
|
f: &mut F,
|
||||||
|
) -> bool
|
||||||
|
where
|
||||||
|
F: FnMut(u64, &[u8], bool) -> bool,
|
||||||
|
{
|
||||||
|
let mut pos = region.start;
|
||||||
|
let mut contiguous = false;
|
||||||
|
let mut read_anything = false;
|
||||||
|
|
||||||
|
while pos < region.end {
|
||||||
|
let want = (buf.len() as u64).min(region.end - pos) as usize;
|
||||||
|
match mem.read_chunk(pos, &mut buf[..want]) {
|
||||||
|
ChunkRead::Hole => {
|
||||||
|
stats.holes += 1;
|
||||||
|
contiguous = false;
|
||||||
|
// Step to the next page; the current one is unreadable.
|
||||||
|
pos = align_up(pos + 1, PAGE);
|
||||||
|
}
|
||||||
|
ChunkRead::Got(n) => {
|
||||||
|
read_anything = true;
|
||||||
|
stats.bytes_read += n as u64;
|
||||||
|
if !f(pos, &buf[..n], contiguous) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if pos + n as u64 >= region.end {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if n < want {
|
||||||
|
// Short read: an unmapped hole begins at pos + n. No pattern
|
||||||
|
// can span a hole, so no overlap is needed here; resume on
|
||||||
|
// the next page boundary.
|
||||||
|
contiguous = false;
|
||||||
|
pos = align_up(pos + n as u64 + 1, PAGE);
|
||||||
|
} else {
|
||||||
|
if n <= overlap {
|
||||||
|
break; // cannot make forward progress
|
||||||
|
}
|
||||||
|
contiguous = true;
|
||||||
|
pos += (n - overlap) as u64;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if read_anything {
|
||||||
|
stats.regions_scanned += 1;
|
||||||
|
} else {
|
||||||
|
stats.regions_skipped += 1;
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which regions a sweep should touch.
|
||||||
|
pub fn scan_targets(regions: &[Region], module: Option<&Module>, anon_only: bool) -> Vec<Region> {
|
||||||
|
regions
|
||||||
|
.iter()
|
||||||
|
.filter(|r| r.readable() && !r.pseudo())
|
||||||
|
.filter(|r| !anon_only || r.anonymous())
|
||||||
|
.filter_map(|r| match module {
|
||||||
|
None => Some(r.clone()),
|
||||||
|
// Clip the region to the module's image span rather than dropping
|
||||||
|
// it: under Wine a module's sections live in large anonymous
|
||||||
|
// regions that may extend past the image.
|
||||||
|
Some(m) => {
|
||||||
|
let start = r.start.max(m.base);
|
||||||
|
let end = r.end.min(m.end());
|
||||||
|
if start < end {
|
||||||
|
let mut clipped = (*r).clone();
|
||||||
|
clipped.start = start;
|
||||||
|
clipped.end = end;
|
||||||
|
Some(clipped)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Search every target region for `pattern`. Calls `hit(va)` per match.
|
||||||
|
pub fn find_pattern<F>(
|
||||||
|
mem: &ProcMem,
|
||||||
|
targets: &[Region],
|
||||||
|
pattern: &[u8],
|
||||||
|
max: Option<usize>,
|
||||||
|
mut hit: F,
|
||||||
|
) -> SweepStats
|
||||||
|
where
|
||||||
|
F: FnMut(u64),
|
||||||
|
{
|
||||||
|
let mut stats = SweepStats::default();
|
||||||
|
if pattern.is_empty() {
|
||||||
|
return stats;
|
||||||
|
}
|
||||||
|
let finder = memchr::memmem::Finder::new(pattern);
|
||||||
|
let overlap = pattern.len() - 1;
|
||||||
|
// The buffer must comfortably exceed the overlap or progress stalls.
|
||||||
|
let mut buf = vec![0u8; CHUNK.max(pattern.len() * 4)];
|
||||||
|
let mut found = 0usize;
|
||||||
|
|
||||||
|
for region in targets {
|
||||||
|
let keep_going = sweep_region(
|
||||||
|
mem,
|
||||||
|
region,
|
||||||
|
overlap,
|
||||||
|
&mut buf,
|
||||||
|
&mut stats,
|
||||||
|
&mut |base, data, _contiguous| {
|
||||||
|
for off in finder.find_iter(data) {
|
||||||
|
hit(base + off as u64);
|
||||||
|
found += 1;
|
||||||
|
if max.is_some_and(|m| found >= m) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
true
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if !keep_going {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stats
|
||||||
|
}
|
||||||
|
|
||||||
|
fn printable(b: u8) -> bool {
|
||||||
|
(0x20..=0x7e).contains(&b)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extracts printable runs, carrying an unfinished run across contiguous chunks
|
||||||
|
/// so a string straddling a boundary is still emitted whole.
|
||||||
|
struct StringScanner {
|
||||||
|
utf16: bool,
|
||||||
|
min: usize,
|
||||||
|
run: Vec<u8>,
|
||||||
|
run_start: u64,
|
||||||
|
open: bool,
|
||||||
|
/// UTF-16 only: a low byte at the very end of a chunk whose high byte will
|
||||||
|
/// arrive in the next one.
|
||||||
|
carry: Option<(u64, u8)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StringScanner {
|
||||||
|
fn new(utf16: bool, min: usize) -> Self {
|
||||||
|
Self {
|
||||||
|
utf16,
|
||||||
|
min,
|
||||||
|
run: Vec::with_capacity(256),
|
||||||
|
run_start: 0,
|
||||||
|
open: false,
|
||||||
|
carry: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn flush<F: FnMut(u64, &str)>(&mut self, emit: &mut F) {
|
||||||
|
if self.open && self.run.len() >= self.min {
|
||||||
|
// Runs are printable ASCII by construction, so this cannot fail.
|
||||||
|
if let Ok(s) = std::str::from_utf8(&self.run) {
|
||||||
|
emit(self.run_start, s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.run.clear();
|
||||||
|
self.open = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push<F: FnMut(u64, &str)>(&mut self, va: u64, b: u8, emit: &mut F) {
|
||||||
|
if !self.open {
|
||||||
|
self.open = true;
|
||||||
|
self.run_start = va;
|
||||||
|
}
|
||||||
|
self.run.push(b);
|
||||||
|
// Guard against a pathological all-printable megabyte eating memory.
|
||||||
|
if self.run.len() >= 4096 {
|
||||||
|
self.flush(emit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn feed<F: FnMut(u64, &str)>(
|
||||||
|
&mut self,
|
||||||
|
base: u64,
|
||||||
|
data: &[u8],
|
||||||
|
contiguous: bool,
|
||||||
|
emit: &mut F,
|
||||||
|
) {
|
||||||
|
if !contiguous {
|
||||||
|
self.flush(emit);
|
||||||
|
self.carry = None;
|
||||||
|
}
|
||||||
|
if self.utf16 {
|
||||||
|
self.feed_utf16(base, data, emit);
|
||||||
|
} else {
|
||||||
|
for (i, &b) in data.iter().enumerate() {
|
||||||
|
if printable(b) {
|
||||||
|
self.push(base + i as u64, b, emit);
|
||||||
|
} else {
|
||||||
|
self.flush(emit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn feed_utf16<F: FnMut(u64, &str)>(&mut self, base: u64, data: &[u8], emit: &mut F) {
|
||||||
|
let mut i = 0usize;
|
||||||
|
// A pair split across the chunk boundary: complete it if the high byte
|
||||||
|
// is the expected 0x00, otherwise the run ends here.
|
||||||
|
if let Some((addr, lo)) = self.carry.take() {
|
||||||
|
if data.first() == Some(&0) && printable(lo) {
|
||||||
|
self.push(addr, lo, emit);
|
||||||
|
i = 1;
|
||||||
|
} else {
|
||||||
|
self.flush(emit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
while i + 1 < data.len() {
|
||||||
|
let (lo, hi) = (data[i], data[i + 1]);
|
||||||
|
if hi == 0 && printable(lo) {
|
||||||
|
self.push(base + i as u64, lo, emit);
|
||||||
|
i += 2;
|
||||||
|
} else {
|
||||||
|
self.flush(emit);
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if i < data.len() {
|
||||||
|
self.carry = Some((base + i as u64, data[i]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract strings from every target region. Calls `emit(va, text)`.
|
||||||
|
pub fn find_strings<F>(
|
||||||
|
mem: &ProcMem,
|
||||||
|
targets: &[Region],
|
||||||
|
utf16: bool,
|
||||||
|
min: usize,
|
||||||
|
grep: Option<&str>,
|
||||||
|
max: Option<usize>,
|
||||||
|
mut emit: F,
|
||||||
|
) -> SweepStats
|
||||||
|
where
|
||||||
|
F: FnMut(u64, &str),
|
||||||
|
{
|
||||||
|
let mut stats = SweepStats::default();
|
||||||
|
let mut buf = vec![0u8; CHUNK];
|
||||||
|
let grep_lower = grep.map(|g| g.to_ascii_lowercase());
|
||||||
|
let mut count = 0usize;
|
||||||
|
|
||||||
|
for region in targets {
|
||||||
|
let mut scanner = StringScanner::new(utf16, min);
|
||||||
|
let mut stop = false;
|
||||||
|
// overlap 0: the scanner tracks continuity itself via `contiguous`.
|
||||||
|
let keep_going = sweep_region(
|
||||||
|
mem,
|
||||||
|
region,
|
||||||
|
0,
|
||||||
|
&mut buf,
|
||||||
|
&mut stats,
|
||||||
|
&mut |base, data, contiguous| {
|
||||||
|
scanner.feed(base, data, contiguous, &mut |va, s| {
|
||||||
|
let matches = match &grep_lower {
|
||||||
|
Some(g) => s.to_ascii_lowercase().contains(g.as_str()),
|
||||||
|
None => true,
|
||||||
|
};
|
||||||
|
if matches {
|
||||||
|
emit(va, s);
|
||||||
|
count += 1;
|
||||||
|
if max.is_some_and(|m| count >= m) {
|
||||||
|
stop = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
!stop
|
||||||
|
},
|
||||||
|
);
|
||||||
|
scanner.flush(&mut |va, s| {
|
||||||
|
let matches = match &grep_lower {
|
||||||
|
Some(g) => s.to_ascii_lowercase().contains(g.as_str()),
|
||||||
|
None => true,
|
||||||
|
};
|
||||||
|
if matches {
|
||||||
|
emit(va, s);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if !keep_going || stop {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stats
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
gvenv/
|
||||||
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()
|
||||||
Regular → Executable
+113
-2
@@ -1,7 +1,7 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Watch for a (re)launched FIFA17.exe and auto-apply both ProtoSSL cert patches
|
"""Watch for a (re)launched FIFA17.exe and auto-apply both ProtoSSL cert patches
|
||||||
the moment its unpacked code is mapped. Idempotent; keeps watching across relaunches."""
|
the moment its unpacked code is mapped. Idempotent; keeps watching across relaunches."""
|
||||||
import glob, time, struct
|
import glob, time, struct, sys
|
||||||
|
|
||||||
# Watch for a (re)launched FIFA17.exe and auto-apply ProtoSSL cert + FUT store patches
|
# Watch for a (re)launched FIFA17.exe and auto-apply ProtoSSL cert + FUT store patches
|
||||||
import glob, time, os
|
import glob, time, os
|
||||||
@@ -24,7 +24,46 @@ STORE_PATCHES = {
|
|||||||
0x1800175aa: NOP2,
|
0x1800175aa: NOP2,
|
||||||
}
|
}
|
||||||
|
|
||||||
LOG="/tmp/autopatch.log"
|
# Store resolver crash-guard for the empty "My Packs" case (bug 6c; PROVEN R1 on the
|
||||||
|
# tested FIFA 17 build -- see docs/plans/FIFA17_EMPTY_MYPACKS_CLIENT_FIX.md PART IV and
|
||||||
|
# docs/evidence/FIFA17_EMPTY_MYPACKS_CLIENT_CONTRACT.md).
|
||||||
|
#
|
||||||
|
# When no `mypacks` group exists, FIFA's Store resolver receives category id -1. CardsDLL
|
||||||
|
# FUN_1800147f0 @ 0x180014858 is `JNZ 0x14869` (75 0f): the original treats every non-zero
|
||||||
|
# category (including -1) as resolvable, calls FUN_180014420, gets NULL, and crashes at the
|
||||||
|
# [NULL+0x48] deref in FUN_1800147f0 (0x180014882). Changing JNZ->JG (7f 0f) preserves
|
||||||
|
# positive-category resolution (EDI>0 branch) while routing zero/negative categories through
|
||||||
|
# the existing Browse/list-all path -> no NULL lookup, no crash, Store opens on Browse Packs.
|
||||||
|
#
|
||||||
|
# CAVEAT: this guards the category SIGN only. It does NOT protect a stale *positive* invalid
|
||||||
|
# ordinal produced by changing the Store group topology (sentinel-present <-> sentinel-absent)
|
||||||
|
# DURING one running FIFA process -- that reproduced the same crash in the confounded run F3.
|
||||||
|
# The empty-My-Packs representation MUST stay stable for a FIFA session (see the SESSION-STABLE
|
||||||
|
# invariant in the client-fix plan).
|
||||||
|
#
|
||||||
|
# Orig-verified / fail-closed: applied only when the live bytes are the known original (75 0f);
|
||||||
|
# already-patched (7f 0f) is a no-op; anything else is logged and SKIPPED (never blindly
|
||||||
|
# overwritten), so an unrecognised CardsDLL build is not patched.
|
||||||
|
STORE_PATCHES_GUARDED = {
|
||||||
|
0x180014858: (bytes.fromhex("750f"), bytes.fromhex("7f0f")), # JNZ 0x14869 -> JG 0x14869
|
||||||
|
}
|
||||||
|
|
||||||
|
# Capability advertised to the launcher/backend once the resolver guard is VERIFIED
|
||||||
|
# live in a specific FIFA process (docs/plans/FIFA17_PATCHED_CLIENT_CAPABILITY.md #3/#4).
|
||||||
|
EMPTY_MYPACKS_RESOLVER_CAPABILITY = "fifa17.empty_mypacks_resolver"
|
||||||
|
EMPTY_MYPACKS_RESOLVER_VERSION = 1
|
||||||
|
|
||||||
|
# The guarded site whose verified enforcement backs the capability above.
|
||||||
|
RESOLVER_GUARD_VA = 0x180014858
|
||||||
|
|
||||||
|
# Per-FIFA-pid guard status (fail-closed; FIFA17_PATCHED_CLIENT_CAPABILITY.md #4).
|
||||||
|
GUARD_NOT_ATTEMPTED = "NOT_ATTEMPTED" # CardsDLL not mapped / guard not yet evaluated
|
||||||
|
GUARD_VERIFIED = "VERIFIED" # live bytes == patch after enforcement (patch or noop)
|
||||||
|
GUARD_UNSUPPORTED_BUILD = "UNSUPPORTED_BUILD" # neither original nor patched (guarded_action -> skip)
|
||||||
|
GUARD_WRITE_FAILED = "WRITE_FAILED" # /proc/<pid>/mem write raised
|
||||||
|
GUARD_VERIFY_FAILED = "VERIFY_FAILED" # post-write re-read != patch
|
||||||
|
|
||||||
|
LOG=os.environ.get("OPENFUT_AUTOPATCH_LOG", f"/tmp/openfut-autopatch-{os.getuid()}.log")
|
||||||
|
|
||||||
def log(m):
|
def log(m):
|
||||||
line=f"[{time.strftime('%H:%M:%S')}] {m}"
|
line=f"[{time.strftime('%H:%M:%S')}] {m}"
|
||||||
@@ -52,11 +91,55 @@ def wr(pid,va,b):
|
|||||||
with open(f'/proc/{pid}/mem','r+b') as f:
|
with open(f'/proc/{pid}/mem','r+b') as f:
|
||||||
f.seek(va); f.write(b)
|
f.seek(va); f.write(b)
|
||||||
|
|
||||||
|
def guarded_action(cur, orig, patch):
|
||||||
|
"""Fail-closed decision for a guarded byte patch (see STORE_PATCHES_GUARDED).
|
||||||
|
|
||||||
|
Returns "noop" when the live bytes are already patched, "patch" when they are the
|
||||||
|
known original (safe to apply), or "skip" for anything else -- an unrecognised
|
||||||
|
CardsDLL build that must never be blindly overwritten.
|
||||||
|
"""
|
||||||
|
if cur == patch:
|
||||||
|
return "noop"
|
||||||
|
if cur == orig:
|
||||||
|
return "patch"
|
||||||
|
return "skip"
|
||||||
|
|
||||||
|
def guard_state_after(cur_before, orig, patch, wrote_ok, cur_after):
|
||||||
|
"""Map a guarded-patch enforcement outcome to a per-pid guard STATE (pure).
|
||||||
|
|
||||||
|
Mirrors guarded_action's decision, extended with post-write verification so the
|
||||||
|
caller advertises the capability only on VERIFIED. No /proc access -- unit-testable.
|
||||||
|
|
||||||
|
- cur_before == patch -> VERIFIED (already patched; guarded_action "noop")
|
||||||
|
- cur_before == orig -> WRITE_FAILED if the write raised, else VERIFIED when the
|
||||||
|
re-read is patch, else VERIFY_FAILED (guarded_action "patch")
|
||||||
|
- otherwise -> UNSUPPORTED_BUILD (guarded_action "skip")
|
||||||
|
"""
|
||||||
|
if cur_before == patch:
|
||||||
|
return GUARD_VERIFIED
|
||||||
|
if cur_before == orig:
|
||||||
|
if not wrote_ok:
|
||||||
|
return GUARD_WRITE_FAILED
|
||||||
|
if cur_after == patch:
|
||||||
|
return GUARD_VERIFIED
|
||||||
|
return GUARD_VERIFY_FAILED
|
||||||
|
return GUARD_UNSUPPORTED_BUILD
|
||||||
|
|
||||||
patched=set()
|
patched=set()
|
||||||
store_patched=set()
|
store_patched=set()
|
||||||
|
guard_reported=set()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
launcher_pid = None
|
||||||
|
if "--launcher-pid" in sys.argv:
|
||||||
|
try: launcher_pid = int(sys.argv[sys.argv.index("--launcher-pid") + 1])
|
||||||
|
except (ValueError, IndexError): raise SystemExit("invalid --launcher-pid")
|
||||||
|
|
||||||
log("=== AUTOPATCH watching for FIFA17.exe ===")
|
log("=== AUTOPATCH watching for FIFA17.exe ===")
|
||||||
while True:
|
while True:
|
||||||
|
if launcher_pid and not os.path.exists(f"/proc/{launcher_pid}"):
|
||||||
|
log(f"launcher pid {launcher_pid} exited; stopping autopatch")
|
||||||
|
break
|
||||||
for pid in find_pids():
|
for pid in find_pids():
|
||||||
if pid not in patched:
|
if pid not in patched:
|
||||||
try:
|
try:
|
||||||
@@ -82,6 +165,34 @@ while True:
|
|||||||
if rd(pid, live, len(data)) != data:
|
if rd(pid, live, len(data)) != data:
|
||||||
wr(pid, live, data)
|
wr(pid, live, data)
|
||||||
log(f"pid {pid}: ENFORCED store patch @ {live:#x}")
|
log(f"pid {pid}: ENFORCED store patch @ {live:#x}")
|
||||||
|
for va, (orig, patch) in STORE_PATCHES_GUARDED.items():
|
||||||
|
live = cbase + (va - IMG_BASE)
|
||||||
|
cur = rd(pid, live, len(patch))
|
||||||
|
action = guarded_action(cur, orig, patch)
|
||||||
|
wrote_ok = True
|
||||||
|
cur_after = cur
|
||||||
|
if action == "patch":
|
||||||
|
try:
|
||||||
|
wr(pid, live, patch)
|
||||||
|
log(f"pid {pid}: ENFORCED guarded store patch @ {live:#x} (JNZ->JG, empty My Packs)")
|
||||||
|
except Exception as e:
|
||||||
|
wrote_ok = False
|
||||||
|
log(f"pid {pid}: guarded patch write failed @ {live:#x}: {e}")
|
||||||
|
if wrote_ok:
|
||||||
|
try:
|
||||||
|
cur_after = rd(pid, live, len(patch))
|
||||||
|
except Exception:
|
||||||
|
cur_after = b""
|
||||||
|
elif action == "skip":
|
||||||
|
log(f"pid {pid}: SKIP guarded patch @ {live:#x}: unexpected {cur.hex()} (build mismatch)")
|
||||||
|
# action == "noop": already patched; nothing to write.
|
||||||
|
if va == RESOLVER_GUARD_VA and pid not in guard_reported:
|
||||||
|
state = guard_state_after(cur, orig, patch, wrote_ok, cur_after)
|
||||||
|
if state == GUARD_VERIFIED:
|
||||||
|
log(f"[store-guard] verified capability {EMPTY_MYPACKS_RESOLVER_CAPABILITY}={EMPTY_MYPACKS_RESOLVER_VERSION} fifa_pid={pid}")
|
||||||
|
else:
|
||||||
|
log(f"[store-guard] guard status={state} fifa_pid={pid} (no capability advertised)")
|
||||||
|
guard_reported.add(pid)
|
||||||
if pid not in store_patched:
|
if pid not in store_patched:
|
||||||
log(f"pid {pid}: PATCHED store gates in CardsDLL @ {cbase:#x}")
|
log(f"pid {pid}: PATCHED store gates in CardsDLL @ {cbase:#x}")
|
||||||
store_patched.add(pid)
|
store_patched.add(pid)
|
||||||
|
|||||||
@@ -128,14 +128,51 @@ CLIENT_ID = ACCOUNT.CLIENT_ID
|
|||||||
PLATFORM = ACCOUNT.PLATFORM
|
PLATFORM = ACCOUNT.PLATFORM
|
||||||
SERVER_VERSION = "Blaze 15.1.1.3.0 (OpenFUT)\n"
|
SERVER_VERSION = "Blaze 15.1.1.3.0 (OpenFUT)\n"
|
||||||
|
|
||||||
# ================================================================== config
|
|
||||||
|
|
||||||
HOST = "127.0.0.1"
|
def refresh_account_identity():
|
||||||
|
"""Refresh launcher-selected identity before constructing a Blaze session.
|
||||||
|
|
||||||
|
The account sync endpoint runs in the separate UTAS process and atomically
|
||||||
|
replaces the shared active-account file. Blaze snapshots these aliases for
|
||||||
|
its response builders, so refresh them once at each new TCP session.
|
||||||
|
"""
|
||||||
|
global PERSONA_ID, PERSONA_NAME, USER_ID, EXT_ID, EMAIL, ACCOUNT_LOCALE_FALLBACK
|
||||||
|
ACCOUNT.load(force=True)
|
||||||
|
PERSONA_ID = ACCOUNT.persona_id
|
||||||
|
PERSONA_NAME = ACCOUNT.persona_name
|
||||||
|
USER_ID = ACCOUNT.user_id
|
||||||
|
EXT_ID = ACCOUNT.ext_id
|
||||||
|
EMAIL = ACCOUNT.email
|
||||||
|
ACCOUNT_LOCALE_FALLBACK = ACCOUNT.account_locale_int
|
||||||
|
|
||||||
|
# ================================================================== config
|
||||||
|
#
|
||||||
|
# Client/server split support (OpenFUT dev-container): bind and advertise default
|
||||||
|
# to loopback so the original all-on-localhost flow is byte-identical.
|
||||||
|
# OPENFUT_BIND — address the listeners bind (0.0.0.0 in a container).
|
||||||
|
# OPENFUT_ADVERTISE — address handed back for Blaze, UTAS, telemetry, QoS,
|
||||||
|
# and (unless overridden) the roster service.
|
||||||
|
# OPENFUT_ROSTER_HOST — optional roster host:port advertised in HTTPS URLs.
|
||||||
|
# Use a certificate dNSName and resolve it on the client.
|
||||||
|
import os as _os_cfg
|
||||||
|
_ADVERTISE = _os_cfg.environ.get("OPENFUT_ADVERTISE", "127.0.0.1")
|
||||||
|
_BIND = _os_cfg.environ.get("OPENFUT_BIND", "127.0.0.1")
|
||||||
|
|
||||||
|
def _ip_str_to_u32(ip):
|
||||||
|
"""Dotted-quad -> big-endian u32 (matches the original (127<<24)|1 layout).
|
||||||
|
Falls back to loopback if the advertise value isn't a bare IPv4 literal."""
|
||||||
|
try:
|
||||||
|
a, b, c, d = (int(x) for x in ip.split("."))
|
||||||
|
return (a << 24) | (b << 16) | (c << 8) | d
|
||||||
|
except Exception:
|
||||||
|
return (127 << 24) | 1
|
||||||
|
|
||||||
|
HOST = _BIND
|
||||||
REDIR_PORT = 42127
|
REDIR_PORT = 42127
|
||||||
BLAZE_PORT = 42130
|
BLAZE_PORT = 42130
|
||||||
NUCLEUS_PORT = 42131
|
NUCLEUS_PORT = 42131
|
||||||
BLAZE_IP_STR = "127.0.0.1"
|
BLAZE_IP_STR = _ADVERTISE
|
||||||
BLAZE_IP_U32 = (127 << 24) | 1
|
BLAZE_IP_U32 = _ip_str_to_u32(_ADVERTISE)
|
||||||
LOG = "/tmp/blaze_responder.log"
|
LOG = "/tmp/blaze_responder.log"
|
||||||
RXDIR = "/tmp/blaze_rx"
|
RXDIR = "/tmp/blaze_rx"
|
||||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
@@ -157,7 +194,9 @@ REPLY_EMPTY_TO_UNKNOWN = True
|
|||||||
# (grid-blaze order) or after (pamplona order). Both are reported to work.
|
# (grid-blaze order) or after (pamplona order). Both are reported to work.
|
||||||
NOTIFY_BEFORE_LOGIN_REPLY = False
|
NOTIFY_BEFORE_LOGIN_REPLY = False
|
||||||
|
|
||||||
DUMP_FRAMES = True
|
# Raw Fire2 frames and decoded TDF can contain auth/session material. Keep the
|
||||||
|
# reverse-engineering capture path, but require an explicit opt-in for it.
|
||||||
|
DUMP_FRAMES = os.environ.get("OPENFUT_BLAZE_DUMP_FRAMES") == "1"
|
||||||
|
|
||||||
_log_lock = threading.Lock()
|
_log_lock = threading.Lock()
|
||||||
|
|
||||||
@@ -523,9 +562,12 @@ 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 = "127.0.0.1:8081"
|
# iPAddress SANs, so an IP-literal URL fails with certificate_unknown. A remote
|
||||||
|
# deployment can advertise a certificate DNS name without changing other hosts.
|
||||||
|
ROSTER_HOST = os.environ.get("OPENFUT_ROSTER_HOST") or "%s:8081" % _ADVERTISE
|
||||||
|
POW_CONTENT_HOST = os.environ.get("POW_CONTENT_HOST", "127.0.0.1:8080")
|
||||||
OSDK_ROSTER = [
|
OSDK_ROSTER = [
|
||||||
("ROSTERUPDATE_URL", "https://%s/fifa17/fut/rosterupdate.xml" % ROSTER_HOST),
|
("ROSTERUPDATE_URL", "https://%s/fifa17/fut/rosterupdate.xml" % ROSTER_HOST),
|
||||||
("ROSTER_URL", "https://%s/fifa17/roster/" % ROSTER_HOST), # @0x143973aa0
|
("ROSTER_URL", "https://%s/fifa17/roster/" % ROSTER_HOST), # @0x143973aa0
|
||||||
@@ -562,7 +604,6 @@ IDENTITY_PARAMS = [
|
|||||||
# FUT_POW=1 ./openfut-fut.sh restart
|
# FUT_POW=1 ./openfut-fut.sh restart
|
||||||
# and read /tmp/pow_server.log. FUT_POW=off is the instant fallback.
|
# and read /tmp/pow_server.log. FUT_POW=off is the instant fallback.
|
||||||
POW_HOST = os.environ.get("POW_HOST", "127.0.0.1:8094")
|
POW_HOST = os.environ.get("POW_HOST", "127.0.0.1:8094")
|
||||||
POW_CONTENT_HOST = os.environ.get("POW_CONTENT_HOST", "127.0.0.1:8080")
|
|
||||||
_POW_ON = os.environ.get("FUT_POW", "").lower() in ("1", "true", "on", "yes")
|
_POW_ON = os.environ.get("FUT_POW", "").lower() in ("1", "true", "on", "yes")
|
||||||
OSDK_POW = [
|
OSDK_POW = [
|
||||||
("FIFA_POW_URL", "http://%s/" % POW_HOST),
|
("FIFA_POW_URL", "http://%s/" % POW_HOST),
|
||||||
@@ -571,6 +612,14 @@ OSDK_POW = [
|
|||||||
("POW_IS_ON", "1"),
|
("POW_IS_ON", "1"),
|
||||||
] if _POW_ON else []
|
] if _POW_ON else []
|
||||||
|
|
||||||
|
# CardsDLL's shared web-file downloader also reads this key for FUT-owned content.
|
||||||
|
# In particular, opening SBC downloads /fut/packs/loc/storepackdescriptions.<locale>.xml
|
||||||
|
# after /sbs/sets succeeds. Keep the content base available even while the unrelated
|
||||||
|
# POW API remains opt-in through FUT_POW/POW_IS_ON.
|
||||||
|
FUT_CONTENT_CONFIG = [
|
||||||
|
("FIFA_POW_CONTENT_SERVER_URL", "http://%s" % POW_CONTENT_HOST),
|
||||||
|
]
|
||||||
|
|
||||||
CLIENT_CONFIGS = {
|
CLIENT_CONFIGS = {
|
||||||
"BlazeSDK": None, # built dynamically, see below
|
"BlazeSDK": None, # built dynamically, see below
|
||||||
"netres": OSDK_NETRES, # CFID (verified @0x143962be0)
|
"netres": OSDK_NETRES, # CFID (verified @0x143962be0)
|
||||||
@@ -595,7 +644,7 @@ CLIENT_CONFIGS = {
|
|||||||
# /etc/hosts easw.easports.com->127.0.0.1 redirect. MUST be exactly "http://127.0.0.1:8099/"
|
# /etc/hosts easw.easports.com->127.0.0.1 redirect. MUST be exactly "http://127.0.0.1:8099/"
|
||||||
# (scheme + trailing slash mandatory on the auth path). Do NOT serve FUT_TARGET_PORT
|
# (scheme + trailing slash mandatory on the auth path). Do NOT serve FUT_TARGET_PORT
|
||||||
# (bug @0x1801808e8 reads FUT_MAX_HOPS instead) nor FUT/MODULE_BASEURL_* (dead code).
|
# (bug @0x1801808e8 reads FUT_MAX_HOPS instead) nor FUT/MODULE_BASEURL_* (dead code).
|
||||||
UTAS_BASE = "http://127.0.0.1:8099/"
|
UTAS_BASE = "http://%s:8099/" % _ADVERTISE
|
||||||
FUT_RS4_MODULES = [
|
FUT_RS4_MODULES = [
|
||||||
"AUCTIONHOUSE", "CLUB_USER", "CLUB_INFO", "CLUB", "DREAM", "SQUAD",
|
"AUCTIONHOUSE", "CLUB_USER", "CLUB_INFO", "CLUB", "DREAM", "SQUAD",
|
||||||
"DELETE_SQUAD", "LBOPTIONS", "LBDEFAULT", "PAFPRACTICE", "UT", "USER",
|
"DELETE_SQUAD", "LBOPTIONS", "LBDEFAULT", "PAFPRACTICE", "UT", "USER",
|
||||||
@@ -669,6 +718,55 @@ FUT_RS4_CONFIG = (
|
|||||||
"IS_FIFAPOINT_PURCHASABLE", "IS_EASTORE_SERVICE_READY",
|
"IS_FIFAPOINT_PURCHASABLE", "IS_EASTORE_SERVICE_READY",
|
||||||
"COINS_PURCHASE_ENABLED", "POINTS_PURCHASE_ENABLED", "MONEY_PURCHASE_ENABLED",
|
"COINS_PURCHASE_ENABLED", "POINTS_PURCHASE_ENABLED", "MONEY_PURCHASE_ENABLED",
|
||||||
)]
|
)]
|
||||||
|
# FUT_TRADING: the transfer-market equivalent of the store block above.
|
||||||
|
#
|
||||||
|
# WHY THIS IS HERE AND NOT IN /settings. "Place on Transfer List" and "List on
|
||||||
|
# Transfer Market" are greyed out because the TO_TRADE_PILE predicate
|
||||||
|
# FUN_1801a7260 needs a service gate at vtable+0x270, which is
|
||||||
|
# `movzx eax, byte [rcx+0x1fd2e]; ret`. That byte is the tradingEnabled gate and it
|
||||||
|
# reads 0.
|
||||||
|
#
|
||||||
|
# Sending tradingEnabled through /settings does NOT move it, PROVEN live 2026-08-06:
|
||||||
|
# the arm is right (case 0x336 writes param_2[10]) and the applier is right
|
||||||
|
# (0x1fd2e = param_2[10] == 1), but the applier has NO caller Ghidra can see and is
|
||||||
|
# not reachable from the settings deserializer. The decisive measurement: we served
|
||||||
|
# maximumTradePileSize=77 and NO int gate field carries 77 (+0x1fd14=0, +0x1fd4c=0,
|
||||||
|
# +0x1fd54=480). Every gate byte is a constructor default. That also explains
|
||||||
|
# storeEnabled reading 1: a default, never our value.
|
||||||
|
#
|
||||||
|
# REFUTED 2026-08-06, KEPT ONLY AS A RECORD. THIS DOES NOT WORK. Do not turn it on
|
||||||
|
# expecting an effect, and do not reason from it.
|
||||||
|
#
|
||||||
|
# The reasoning above was wrong in two places and the flag is inert:
|
||||||
|
#
|
||||||
|
# 1. IS_TRADING_ENABLED IS AN OUTPUT NAME, NOT AN INPUT. FUN_18006cc60 is a
|
||||||
|
# PUBLISHER: at 0x18006ccc6 it does `call [rax+0x270]` (which reads gate byte
|
||||||
|
# 0x1fd2e), then `lea rdx,[IS_TRADING_ENABLED]` and hands the value OUT under
|
||||||
|
# that name. The only rip-relative reference to the literal 0x1801fc118 in the
|
||||||
|
# whole of .text is that lea. There is no comparison against it anywhere, so a
|
||||||
|
# client-config key of that name cannot be read as an input by anything. The same
|
||||||
|
# is true of the IS_* store keys above, which means the store block may also be
|
||||||
|
# inert and its apparent success was never actually attributed.
|
||||||
|
# 2. The gate byte was briefly measured as 1 and that was over-claimed as a success.
|
||||||
|
# On a fresh session it reads 0, and a thorough re-measurement read 0 on the very
|
||||||
|
# pid where it had read 1. Either the first read was transient or something clears
|
||||||
|
# it after login. The only writer of 0x1fd2e is FUN_18011dc50 at 0x18011dc91.
|
||||||
|
#
|
||||||
|
# What IS now known, and supersedes the "/settings is dead" claim in the note above:
|
||||||
|
# FUN_18011dc50 is NOT unreachable. It is a VIRTUAL method at model vtable slot
|
||||||
|
# +0x988 (absolute pointer at 0x18021cc28), which is why a direct-call search found
|
||||||
|
# no callers. The real chain is
|
||||||
|
# settings response -> FUN_180174630 -> FUN_18013c6d0 (deser)
|
||||||
|
# -> completion callback FUN_180173e00 -> vt+0x988 / vt+0x998 -> gate bytes
|
||||||
|
# and FUN_180173e00 bails before applying anything unless the int at response+0x1c
|
||||||
|
# is zero. Which atom writes +0x1c is UNKNOWN and is the thing worth chasing.
|
||||||
|
#
|
||||||
|
# Default OFF and it should stay off.
|
||||||
|
# NOTE: FUT_TRADING no longer does anything here. These keys are inert (output
|
||||||
|
# names the DLL emits, never reads). The REAL trading fix is in utas_server.py:
|
||||||
|
# userInfo.feature was banning trade. Left disabled so the flag has one meaning.
|
||||||
|
+ ([] if True else
|
||||||
|
[(k, "1") for k in ("tradingEnabled", "IS_TRADING_ENABLED")])
|
||||||
# NOTE: do NOT advertise itemDbVersion/checkServerDbVersion here or in any
|
# NOTE: do NOT advertise itemDbVersion/checkServerDbVersion here or in any
|
||||||
# response -- proven inert (wf_96b6c0c5): they are JSON field names that route
|
# response -- proven inert (wf_96b6c0c5): they are JSON field names that route
|
||||||
# to the value-SKIP handler 0x180135ff0, never compared. See docs/CARD_SYSTEM.md.
|
# to the value-SKIP handler 0x180135ff0, never compared. See docs/CARD_SYSTEM.md.
|
||||||
@@ -682,18 +780,21 @@ FUT_RS4_CONFIG = (
|
|||||||
|
|
||||||
|
|
||||||
def client_config_for(cfid: str) -> list:
|
def client_config_for(cfid: str) -> list:
|
||||||
"""-> sorted [(key, value)]. Unknown CFID -> [] (an EMPTY MAP, which we
|
"""Return sorted config rows for one section.
|
||||||
still wrap in a present CONF field -- never an empty frame).
|
|
||||||
FUT_RS4_* base-URL keys ride on EVERY CFID (merged '_all' store; which section
|
Unknown CFIDs still receive the shared FUT/content/POW rows because those
|
||||||
CardsDLL reads is unproven, so serve them everywhere)."""
|
consumers read the merged ``_all`` store and the contributing section is
|
||||||
|
unproven. The response always carries a present CONF field.
|
||||||
|
"""
|
||||||
# OSDK_POW rides on EVERY CFID for the same reason FUT_RS4_* does: powdll's
|
# OSDK_POW rides on EVERY CFID for the same reason FUT_RS4_* does: powdll's
|
||||||
# FUN_18005a460 reads FIFA_POW_URL out of the merged '_all' store, and which
|
# FUN_18005a460 reads FIFA_POW_URL out of the merged '_all' store, and which
|
||||||
# section it happens to read is unproven. Empty list when FUT_POW is unset, so
|
# section it happens to read is unproven. Empty list when FUT_POW is unset, so
|
||||||
# this is a no-op by default. (Putting the keys ONLY under a hypothetical
|
# this is a no-op by default. (Putting the keys ONLY under a hypothetical
|
||||||
# "OSDK_POW" CFID would be dead code -- nothing is known to request that name.)
|
# "OSDK_POW" CFID would be dead code -- nothing is known to request that name.)
|
||||||
if cfid == "BlazeSDK":
|
if cfid == "BlazeSDK":
|
||||||
return sorted(blazesdk_config() + FUT_RS4_CONFIG + OSDK_POW)
|
return sorted(blazesdk_config() + FUT_RS4_CONFIG + FUT_CONTENT_CONFIG + OSDK_POW)
|
||||||
return sorted((CLIENT_CONFIGS.get(cfid) or []) + FUT_RS4_CONFIG + OSDK_POW)
|
return sorted((CLIENT_CONFIGS.get(cfid) or []) + FUT_RS4_CONFIG
|
||||||
|
+ FUT_CONTENT_CONFIG + OSDK_POW)
|
||||||
|
|
||||||
|
|
||||||
def fetch_config_response_fields(cfid: str) -> "OrderedDict":
|
def fetch_config_response_fields(cfid: str) -> "OrderedDict":
|
||||||
@@ -716,7 +817,7 @@ def qos_config() -> "OrderedDict":
|
|||||||
has NO SVID, unlike Mirror's Edge Catalyst)."""
|
has NO SVID, unlike Mirror's Edge Catalyst)."""
|
||||||
return OrderedDict([
|
return OrderedDict([
|
||||||
("BWPS", (STRUCT, OrderedDict([ # Blaze::QosPingSiteInfo
|
("BWPS", (STRUCT, OrderedDict([ # Blaze::QosPingSiteInfo
|
||||||
("PSA", (STRING, "127.0.0.1")),
|
("PSA", (STRING, _ADVERTISE)),
|
||||||
("PSP", (INT, 17502)),
|
("PSP", (INT, 17502)),
|
||||||
]))),
|
]))),
|
||||||
("LNP", (INT, 10)),
|
("LNP", (INT, 10)),
|
||||||
@@ -1042,7 +1143,7 @@ def post_auth_response_fields(sess: Session) -> "OrderedDict":
|
|||||||
client to have a well-formed config and then fail to connect quietly rather
|
client to have a well-formed config and then fail to connect quietly rather
|
||||||
than resolve a real EA hostname."""
|
than resolve a real EA hostname."""
|
||||||
tele = OrderedDict([ # GetTelemetryServerResponse (15)
|
tele = OrderedDict([ # GetTelemetryServerResponse (15)
|
||||||
("ADRS", (STRING, "127.0.0.1")),
|
("ADRS", (STRING, _ADVERTISE)),
|
||||||
("ANON", (INT, 0)),
|
("ANON", (INT, 0)),
|
||||||
("DISA", (STRING, "")),
|
("DISA", (STRING, "")),
|
||||||
("EDCT", (INT, 0)),
|
("EDCT", (INT, 0)),
|
||||||
@@ -1059,7 +1160,7 @@ def post_auth_response_fields(sess: Session) -> "OrderedDict":
|
|||||||
("SVNM", (STRING, "telemetry-openfut")),
|
("SVNM", (STRING, "telemetry-openfut")),
|
||||||
])
|
])
|
||||||
tick = OrderedDict([ # GetTickerServerResponse (3)
|
tick = OrderedDict([ # GetTickerServerResponse (3)
|
||||||
("ADRS", (STRING, "127.0.0.1")),
|
("ADRS", (STRING, _ADVERTISE)),
|
||||||
("PORT", (INT, 8999)),
|
("PORT", (INT, 8999)),
|
||||||
("SKEY", (STRING, "")),
|
("SKEY", (STRING, "")),
|
||||||
])
|
])
|
||||||
@@ -1203,8 +1304,10 @@ def dispatch(hdr: dict, fields, raw_payload: bytes, sess: Session) -> list:
|
|||||||
log(" -- client locale 0x%08x captured for ALOC" % loc)
|
log(" -- client locale 0x%08x captured for ALOC" % loc)
|
||||||
resp = preauth_response_fields(service_name=sess.service_name)
|
resp = preauth_response_fields(service_name=sess.service_name)
|
||||||
payload = encode_tdf(resp)
|
payload = encode_tdf(resp)
|
||||||
log(" -> PreAuthResponse (INST=%r, %d payload bytes):\n%s"
|
log(" -> PreAuthResponse (INST=%r, %d payload bytes)"
|
||||||
% (sess.service_name, len(payload), heat2.dump(resp)))
|
% (sess.service_name, len(payload)))
|
||||||
|
if DUMP_FRAMES:
|
||||||
|
log(" -> PreAuthResponse TDF:\n%s" % heat2.dump(resp))
|
||||||
return [reply_to(hdr, payload)]
|
return [reply_to(hdr, payload)]
|
||||||
|
|
||||||
if cmd == CMD_PING:
|
if cmd == CMD_PING:
|
||||||
@@ -1218,6 +1321,7 @@ def dispatch(hdr: dict, fields, raw_payload: bytes, sess: Session) -> list:
|
|||||||
n = len(resp["CONF"][1][2])
|
n = len(resp["CONF"][1][2])
|
||||||
log(" -> FetchConfigResponse CFID=%r -> %d key(s)%s"
|
log(" -> FetchConfigResponse CFID=%r -> %d key(s)%s"
|
||||||
% (cfid, n, "" if n else " (EMPTY MAP, unknown CFID)"))
|
% (cfid, n, "" if n else " (EMPTY MAP, unknown CFID)"))
|
||||||
|
if DUMP_FRAMES:
|
||||||
for k, v in resp["CONF"][1][2]:
|
for k, v in resp["CONF"][1][2]:
|
||||||
log(" %-32s = %s" % (k, v))
|
log(" %-32s = %s" % (k, v))
|
||||||
return [reply_to(hdr, encode_tdf(resp))]
|
return [reply_to(hdr, encode_tdf(resp))]
|
||||||
@@ -1253,12 +1357,13 @@ def dispatch(hdr: dict, fields, raw_payload: bytes, sess: Session) -> list:
|
|||||||
sess.auth_code = get_str(fields or {}, "AUTH", "")
|
sess.auth_code = get_str(fields or {}, "AUTH", "")
|
||||||
sess.logged_in = True
|
sess.logged_in = True
|
||||||
sess.login_time = int(time.time())
|
sess.login_time = int(time.time())
|
||||||
log(" == Authentication::login AUTH=%r (accepted WITHOUT Nucleus "
|
log(" == Authentication::login AUTH=[REDACTED] "
|
||||||
"validation -- forged offline session)" % sess.auth_code)
|
"(accepted as an offline OpenFUT session)")
|
||||||
resp = login_response_fields(sess)
|
resp = login_response_fields(sess)
|
||||||
payload = encode_tdf(resp)
|
payload = encode_tdf(resp)
|
||||||
log(" -> LoginResponse (%d bytes):\n%s"
|
log(" -> LoginResponse (%d bytes)" % len(payload))
|
||||||
% (len(payload), heat2.dump(resp)))
|
if DUMP_FRAMES:
|
||||||
|
log(" -> LoginResponse TDF:\n%s" % heat2.dump(resp))
|
||||||
notifs = build_login_notifications(sess, sess.login_time)
|
notifs = build_login_notifications(sess, sess.login_time)
|
||||||
out = []
|
out = []
|
||||||
if NOTIFY_BEFORE_LOGIN_REPLY:
|
if NOTIFY_BEFORE_LOGIN_REPLY:
|
||||||
@@ -1409,9 +1514,10 @@ _frame_counter = [0]
|
|||||||
|
|
||||||
|
|
||||||
def blaze_handle(raw: socket.socket, addr) -> None:
|
def blaze_handle(raw: socket.socket, addr) -> None:
|
||||||
|
refresh_account_identity()
|
||||||
log("*** BLAZE CONNECT from %s ***" % (addr,))
|
log("*** BLAZE CONNECT from %s ***" % (addr,))
|
||||||
sess = Session()
|
sess = Session()
|
||||||
log(" session key minted: %s" % sess.session_key)
|
log(" session key minted: [REDACTED]")
|
||||||
buf = bytearray()
|
buf = bytearray()
|
||||||
raw.settimeout(300)
|
raw.settimeout(300)
|
||||||
try:
|
try:
|
||||||
@@ -1442,10 +1548,10 @@ def blaze_handle(raw: socket.socket, addr) -> None:
|
|||||||
MSGTYPE_NAME.get(hdr["msg_type"], hdr["msg_type"]),
|
MSGTYPE_NAME.get(hdr["msg_type"], hdr["msg_type"]),
|
||||||
hdr["msg_num"], hdr["user_index"], hdr["options"],
|
hdr["msg_num"], hdr["user_index"], hdr["options"],
|
||||||
hdr["metadata_len"], hdr["payload_len"]))
|
hdr["metadata_len"], hdr["payload_len"]))
|
||||||
|
if DUMP_FRAMES:
|
||||||
log("RX #%d HEX:\n%s" % (n, hexdump(frame)))
|
log("RX #%d HEX:\n%s" % (n, hexdump(frame)))
|
||||||
if metadata:
|
if metadata:
|
||||||
log("RX #%d METADATA:\n%s" % (n, hexdump(metadata)))
|
log("RX #%d METADATA:\n%s" % (n, hexdump(metadata)))
|
||||||
if DUMP_FRAMES:
|
|
||||||
try:
|
try:
|
||||||
os.makedirs(RXDIR, exist_ok=True)
|
os.makedirs(RXDIR, exist_ok=True)
|
||||||
fn = os.path.join(RXDIR, "rx_%04d_%04x_%04x.bin"
|
fn = os.path.join(RXDIR, "rx_%04d_%04x_%04x.bin"
|
||||||
@@ -1460,6 +1566,7 @@ def blaze_handle(raw: socket.socket, addr) -> None:
|
|||||||
if payload:
|
if payload:
|
||||||
try:
|
try:
|
||||||
fields = decode_tdf(payload)
|
fields = decode_tdf(payload)
|
||||||
|
if DUMP_FRAMES:
|
||||||
log("RX #%d TDF:\n%s" % (n, heat2.dump(fields)))
|
log("RX #%d TDF:\n%s" % (n, heat2.dump(fields)))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log("RX #%d TDF DECODE FAILED: %s" % (n, e))
|
log("RX #%d TDF DECODE FAILED: %s" % (n, e))
|
||||||
@@ -1481,6 +1588,7 @@ def blaze_handle(raw: socket.socket, addr) -> None:
|
|||||||
ohdr["msg_type"]),
|
ohdr["msg_type"]),
|
||||||
MSGTYPE_NAME.get(ohdr["msg_type"], ohdr["msg_type"]),
|
MSGTYPE_NAME.get(ohdr["msg_type"], ohdr["msg_type"]),
|
||||||
ohdr["msg_num"], len(out), ohdr["payload_len"]))
|
ohdr["msg_num"], len(out), ohdr["payload_len"]))
|
||||||
|
if DUMP_FRAMES:
|
||||||
log("TX #%d.%d HEX:\n%s" % (n, k, hexdump(out, limit=1024)))
|
log("TX #%d.%d HEX:\n%s" % (n, k, hexdump(out, limit=1024)))
|
||||||
except ConnectionResetError:
|
except ConnectionResetError:
|
||||||
log("BLAZE %s: connection reset by client" % (addr,))
|
log("BLAZE %s: connection reset by client" % (addr,))
|
||||||
@@ -1579,6 +1687,10 @@ def redir_handle(raw: socket.socket, addr) -> None:
|
|||||||
# client can never reach accounts.ea.com. Note the exact spacing in the JSON:
|
# client can never reach accounts.ea.com. Note the exact spacing in the JSON:
|
||||||
# the client searches for the literal '"access_token" : "'.
|
# the client searches for the literal '"access_token" : "'.
|
||||||
|
|
||||||
|
def nucleus_sent_log(addr, size):
|
||||||
|
return "NUCLEUS SENT %s %dB access_token=[REDACTED]" % (addr, size)
|
||||||
|
|
||||||
|
|
||||||
def nucleus_handle(raw: socket.socket, addr) -> None:
|
def nucleus_handle(raw: socket.socket, addr) -> None:
|
||||||
try:
|
try:
|
||||||
raw.settimeout(10)
|
raw.settimeout(10)
|
||||||
@@ -1591,9 +1703,9 @@ def nucleus_handle(raw: socket.socket, addr) -> None:
|
|||||||
head, _, rest = req.partition(b"\r\n\r\n")
|
head, _, rest = req.partition(b"\r\n\r\n")
|
||||||
line0 = head.split(b"\r\n", 1)[0].decode(errors="replace") if head else ""
|
line0 = head.split(b"\r\n", 1)[0].decode(errors="replace") if head else ""
|
||||||
log("NUCLEUS REQ %s: %s" % (addr, line0))
|
log("NUCLEUS REQ %s: %s" % (addr, line0))
|
||||||
if head:
|
if head and DUMP_FRAMES:
|
||||||
log("NUCLEUS HEADERS:\n%s" % head.decode(errors="replace"))
|
log("NUCLEUS HEADERS:\n%s" % head.decode(errors="replace"))
|
||||||
if rest:
|
if rest and DUMP_FRAMES:
|
||||||
log("NUCLEUS BODY: %r" % rest[:512])
|
log("NUCLEUS BODY: %r" % rest[:512])
|
||||||
|
|
||||||
token = "OPENFUT_" + "".join(
|
token = "OPENFUT_" + "".join(
|
||||||
@@ -1607,7 +1719,7 @@ def nucleus_handle(raw: socket.socket, addr) -> None:
|
|||||||
b"Cache-Control: no-store\r\nContent-Length: "
|
b"Cache-Control: no-store\r\nContent-Length: "
|
||||||
+ str(len(body)).encode() + b"\r\nConnection: close\r\n\r\n" + body)
|
+ str(len(body)).encode() + b"\r\nConnection: close\r\n\r\n" + body)
|
||||||
raw.sendall(out)
|
raw.sendall(out)
|
||||||
log("NUCLEUS SENT %s %dB access_token=%s" % (addr, len(out), token))
|
log(nucleus_sent_log(addr, len(out)))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log("NUCLEUS ERR %s: %s" % (addr, e))
|
log("NUCLEUS ERR %s: %s" % (addr, e))
|
||||||
finally:
|
finally:
|
||||||
@@ -1659,6 +1771,10 @@ def _selftest() -> None:
|
|||||||
sess.account_locale = 0x656E5553
|
sess.account_locale = 0x656E5553
|
||||||
now = 1469000000
|
now = 1469000000
|
||||||
|
|
||||||
|
nucleus_summary = nucleus_sent_log(("127.0.0.1", 1234), 380)
|
||||||
|
assert "[REDACTED]" in nucleus_summary
|
||||||
|
assert "OPENFUT_selftest_secret" not in nucleus_summary
|
||||||
|
|
||||||
# ---- 1. preAuth still round-trips (regression guard vs v2)
|
# ---- 1. preAuth still round-trips (regression guard vs v2)
|
||||||
pre = preauth_response_fields()
|
pre = preauth_response_fields()
|
||||||
p = _check_roundtrip("PreAuthResponse", pre)
|
p = _check_roundtrip("PreAuthResponse", pre)
|
||||||
@@ -1682,9 +1798,11 @@ def _selftest() -> None:
|
|||||||
assert items == client_config_for(cfid), cfid
|
assert items == client_config_for(cfid), cfid
|
||||||
print("[ok] fetchClientConfig %-26s %2d keys, %4d payload bytes"
|
print("[ok] fetchClientConfig %-26s %2d keys, %4d payload bytes"
|
||||||
% (cfid, len(items), len(pb)))
|
% (cfid, len(items), len(pb)))
|
||||||
assert client_config_for("TOTALLY_UNKNOWN") == [], "unknown CFID must be []"
|
shared = sorted(FUT_RS4_CONFIG + FUT_CONTENT_CONFIG + OSDK_POW)
|
||||||
|
assert client_config_for("TOTALLY_UNKNOWN") == shared, \
|
||||||
|
"unknown CFID must carry only the shared merged-store rows"
|
||||||
assert len(fetch_config_response_fields("TOTALLY_UNKNOWN")) == 1, \
|
assert len(fetch_config_response_fields("TOTALLY_UNKNOWN")) == 1, \
|
||||||
"unknown CFID must still carry a CONF field (empty map, not empty frame)"
|
"unknown CFID must still carry a CONF field"
|
||||||
|
|
||||||
# ---- 3. LoginResponse
|
# ---- 3. LoginResponse
|
||||||
lr = login_response_fields(sess)
|
lr = login_response_fields(sess)
|
||||||
|
|||||||
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())
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Offline check: every /settings flag we ship is one the client actually switches on.
|
||||||
|
|
||||||
|
A flag name is not validated by anything at runtime. The client hashes the string
|
||||||
|
we send and switches on the result, so a typo, a renamed field or a flag that
|
||||||
|
simply has no arm in the switch is INERT and looks exactly like "the fix did not
|
||||||
|
work". This asserts each shipped name against two independent sources:
|
||||||
|
|
||||||
|
1. docs/fut_atoms.tsv -- the recovered atom table (the name must hash to an id)
|
||||||
|
2. the switch arms recovered from 0x18013c6d0 (the id must have an arm)
|
||||||
|
|
||||||
|
Source 2 is the one that matters: enableSquadBuildingSetsFeature is a perfectly
|
||||||
|
real atom with NO arm, so source 1 alone would have passed it.
|
||||||
|
|
||||||
|
Run before shipping any settings change. No server needed.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
sys.path.insert(0, HERE)
|
||||||
|
|
||||||
|
# The 42 atoms with an arm in FUN_18013c6d0, recovered 2026-08-05 by
|
||||||
|
# tools/ghidra_queries/q_settings_flags.py + q_settings_types.py (full decompile,
|
||||||
|
# both halves of the switch, coverage asserted by char count).
|
||||||
|
SWITCH_ARMS = {
|
||||||
|
0x18: "allowGracePeriodForSquadBuildingSets",
|
||||||
|
0x19: "allowUntradeableForSquadBuildingSets",
|
||||||
|
0x6D: "cardPackStoreEnabled", 0x6E: "cardPackStoreEnabled_JP",
|
||||||
|
0x80: "checkServerDbVersion", 0x86: "clientKeepAliveResetTimeoutSec",
|
||||||
|
0x8C: "clubCreateThreshold", 0x98: "coinEnabled", 0x99: "coinEnabled_JP",
|
||||||
|
0xA3: "constrainGracePeriod", 0xBB: "couchPlayEnabled",
|
||||||
|
0xF9: "enableDraftMode", 0xFA: "enableOfflineDraftMode",
|
||||||
|
0xFB: "enableLiveMessaging", 0xFC: "enableLoyaltyBonusForConceptPlayers",
|
||||||
|
0xFD: "enableObjectives", 0xFE: "enableObjectivesAsManagerTasks",
|
||||||
|
0xFF: "enableSinglePlayerDraftMode", 0x118: "extendGameSessionTimerSec",
|
||||||
|
0x11F: "fifaPointsEnabled", 0x120: "fifaPointsEnabled_JP",
|
||||||
|
0x133: "friendlySeasonsEnabled", 0x13D: "getOperationTimeoutSec",
|
||||||
|
0x16C: "itemDbVersion", 0x1C0: "maximumTradePileSize",
|
||||||
|
0x1CD: "mtxEnabled", 0x1CE: "mtxEnabled_JP",
|
||||||
|
0x1DE: "numEndMatchRetriesAllowed", 0x20E: "packOpeningAnimationEnabled",
|
||||||
|
0x242: "pointsPackStoreEnabled", 0x257: "processingStateEnabled",
|
||||||
|
0x28A: "returningUserRewardsScreenEnabled",
|
||||||
|
0x2D0: "squadBuildingSetsGracePeriodMinutes",
|
||||||
|
0x2F1: "storeEnabled", 0x2F2: "storeEnabled_JP",
|
||||||
|
0x2F3: "storyModeRewardEnabled",
|
||||||
|
0x2F5: "championsScheduleViewPeriodInMinutes",
|
||||||
|
0x30F: "enableFloatPointSquadRating",
|
||||||
|
0x310: "enableLegacyYearInfoInItemResourceId",
|
||||||
|
0x320: "tokenRedemptionEnabled", 0x32D: "tournamentQuitEnabled",
|
||||||
|
0x336: "tradingEnabled",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Arms that do NOT simply store a value. Shipping these has side effects.
|
||||||
|
SPECIAL = {
|
||||||
|
"enableObjectives": "shared arm can only CLEAR the field; 1 is a no-op, 0 disables",
|
||||||
|
"enableObjectivesAsManagerTasks": "same shared arm as enableObjectives",
|
||||||
|
"clientKeepAliveResetTimeoutSec": "reprograms a client timer with value*1000",
|
||||||
|
"getOperationTimeoutSec": "reprograms a client timer with value*1000",
|
||||||
|
"checkServerDbVersion": "makes the client go read a server_db_version config",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
fails = []
|
||||||
|
|
||||||
|
atoms = {}
|
||||||
|
with open(os.path.join(HERE, "..", "docs", "fut_atoms.tsv")) as fh:
|
||||||
|
for line in fh:
|
||||||
|
p = line.rstrip("\n").split("\t")
|
||||||
|
if len(p) >= 3:
|
||||||
|
try:
|
||||||
|
atoms[p[2]] = int(p[1], 16)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Cross-check the recovered table against the atom table both ways.
|
||||||
|
by_name = {v: k for k, v in SWITCH_ARMS.items()}
|
||||||
|
for name, aid in by_name.items():
|
||||||
|
if name not in atoms:
|
||||||
|
fails.append("switch arm %s (%#x) is not in fut_atoms.tsv" % (name, aid))
|
||||||
|
elif atoms[name] != aid:
|
||||||
|
fails.append("%s: switch says %#x, atom table says %#x"
|
||||||
|
% (name, aid, atoms[name]))
|
||||||
|
|
||||||
|
import utas_server as u
|
||||||
|
|
||||||
|
body = u.SETTINGS
|
||||||
|
if not isinstance(body, dict) or list(body) != ["configs"]:
|
||||||
|
fails.append("body must be exactly {'configs': [...]}, got %r" % (body,))
|
||||||
|
return report(fails)
|
||||||
|
rows = body["configs"]
|
||||||
|
if not isinstance(rows, list):
|
||||||
|
fails.append("configs must be a LIST (a scalar here desyncs the parser)")
|
||||||
|
return report(fails)
|
||||||
|
|
||||||
|
seen = set()
|
||||||
|
for r in rows:
|
||||||
|
if not isinstance(r, dict) or set(r) != {"type", "value"}:
|
||||||
|
fails.append("row must be exactly {type, value}: %r" % (r,))
|
||||||
|
continue
|
||||||
|
t, v = r["type"], r["value"]
|
||||||
|
# value: any scalar is safe (getter 0x1801c79d0 coerces int/float/bool/str),
|
||||||
|
# but the applier tests `== 1`, so a bool True would work and a string "1"
|
||||||
|
# would work -- ints keep it unambiguous. An object or array FREEZES.
|
||||||
|
if isinstance(v, (dict, list)):
|
||||||
|
fails.append("%s: value is %s -- an object/array here FREEZES the client"
|
||||||
|
% (t, type(v).__name__))
|
||||||
|
if not isinstance(t, str):
|
||||||
|
fails.append("type must be a string, got %r" % (t,))
|
||||||
|
continue
|
||||||
|
if t in seen:
|
||||||
|
fails.append("%s sent twice; last one wins, so this is at best confusing" % t)
|
||||||
|
seen.add(t)
|
||||||
|
if t not in by_name:
|
||||||
|
hint = " (it IS an atom, but has no arm in the switch)" if t in atoms else ""
|
||||||
|
fails.append("%s has no arm in 0x18013c6d0 -- INERT%s" % (t, hint))
|
||||||
|
elif t in SPECIAL:
|
||||||
|
print(" NOTE %-34s %s" % (t, SPECIAL[t]))
|
||||||
|
|
||||||
|
gates = {"friendlySeasonsEnabled", "enableDraftMode", "tournamentQuitEnabled"}
|
||||||
|
# Read the mode off the server module, never re-declare the default here: a
|
||||||
|
# checker with its own copy of a default tests the copy, not the server.
|
||||||
|
mode = u._SETTINGS_MODE
|
||||||
|
if mode == "gates":
|
||||||
|
for g in sorted(gates - seen):
|
||||||
|
fails.append("mode 'gates' but %s is missing" % g)
|
||||||
|
for t in sorted(seen & gates):
|
||||||
|
row = next(r for r in rows if r["type"] == t)
|
||||||
|
if row["value"] != 1:
|
||||||
|
fails.append("%s = %r; the applier tests `== 1`, nothing else opens "
|
||||||
|
"the gate" % (t, row["value"]))
|
||||||
|
|
||||||
|
print(" mode=%s, %d rows, %d distinct flags, all with a live switch arm"
|
||||||
|
% (mode, len(rows), len(seen)))
|
||||||
|
return report(fails)
|
||||||
|
|
||||||
|
|
||||||
|
def report(fails):
|
||||||
|
if fails:
|
||||||
|
print("\nFAIL (%d)" % len(fails))
|
||||||
|
for f in fails:
|
||||||
|
print(" - %s" % f)
|
||||||
|
return 1
|
||||||
|
print("PASS")
|
||||||
|
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())
|
||||||
Executable
+293
@@ -0,0 +1,293 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# FIFA 17 hook M1 staging/deployment helper.
|
||||||
|
#
|
||||||
|
# Safe defaults:
|
||||||
|
# inspect (the default) is read-only;
|
||||||
|
# stage writes only below the repository;
|
||||||
|
# deploy and launch require separate, exact confirmation variables.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
|
hook_root="${repo_root}/openfut-launcher/openfut-hook"
|
||||||
|
default_dll="${hook_root}/target/x86_64-pc-windows-gnu/release/openfut_hook.dll"
|
||||||
|
stage_root="${repo_root}/fifa17-recon/staging/fifa17-hook-m1"
|
||||||
|
|
||||||
|
game_dir="${OPENFUT_FIFA17_GAME_DIR:-/mnt/games/FIFA 17}"
|
||||||
|
wine_prefix="${OPENFUT_FIFA17_WINEPREFIX:-/home/alex/Games/umu/fifa17}"
|
||||||
|
proton_path="${OPENFUT_FIFA17_PROTONPATH:-UMU-Proton-10.0-4}"
|
||||||
|
hook_dll="${OPENFUT_FIFA17_HOOK_DLL:-${default_dll}}"
|
||||||
|
system_version="${wine_prefix}/drive_c/windows/system32/version.dll"
|
||||||
|
deployed_dll="${game_dir}/version.dll"
|
||||||
|
|
||||||
|
required_exports=(
|
||||||
|
GetFileVersionInfoA GetFileVersionInfoExA GetFileVersionInfoExW
|
||||||
|
GetFileVersionInfoSizeA GetFileVersionInfoSizeExA GetFileVersionInfoSizeExW
|
||||||
|
GetFileVersionInfoSizeW GetFileVersionInfoW VerFindFileA VerFindFileW
|
||||||
|
VerInstallFileA VerInstallFileW VerLanguageNameA VerLanguageNameW
|
||||||
|
VerQueryValueA VerQueryValueW
|
||||||
|
)
|
||||||
|
|
||||||
|
die() { printf 'ERROR: %s\n' "$*" >&2; exit 1; }
|
||||||
|
note() { printf '%s\n' "$*"; }
|
||||||
|
need_file() { [[ -f "$1" ]] || die "missing file: $1"; }
|
||||||
|
|
||||||
|
sha256() { sha256sum -- "$1" | awk '{print $1}'; }
|
||||||
|
|
||||||
|
pe_exports() {
|
||||||
|
x86_64-w64-mingw32-objdump -p "$1" |
|
||||||
|
awk '/\[Ordinal\/Name Pointer\] Table/{in_names=1; next} in_names && /\+base\[/ {print $NF}'
|
||||||
|
}
|
||||||
|
|
||||||
|
verify_pe64() {
|
||||||
|
local dll=$1
|
||||||
|
local format
|
||||||
|
format="$(x86_64-w64-mingw32-objdump -f "$dll" | awk '/file format/{print $NF}')"
|
||||||
|
[[ "$format" == "pei-x86-64" ]] || die "$dll is not a 64-bit PE DLL (format=${format:-unknown})"
|
||||||
|
}
|
||||||
|
|
||||||
|
verify_exports() {
|
||||||
|
local dll=$1 export_name
|
||||||
|
local exports
|
||||||
|
exports="$(pe_exports "$dll")"
|
||||||
|
for export_name in "${required_exports[@]}"; do
|
||||||
|
grep -Fxq "$export_name" <<<"$exports" ||
|
||||||
|
die "$dll lacks VERSION export $export_name; refusing to stage/deploy"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
# Refuse any DLL that is not a FIFA-17-profile build.
|
||||||
|
#
|
||||||
|
# openfut-hook builds TWO mutually exclusive injection paths from one crate: the
|
||||||
|
# default (FIFA 23) path installs getaddrinfo/connect/ProtoSSL/origin hooks, while
|
||||||
|
# `--features fifa17` installs ONLY the FIFA-17-safe logic (module map, FIFA 17
|
||||||
|
# cert-verify, SBC dispatch, store tab bind). Deploying a default-feature build
|
||||||
|
# into FIFA 17 hijacks the login transport and the client reports "Unable to
|
||||||
|
# connect to the EA servers", with none of the FIFA 17 repairs present.
|
||||||
|
#
|
||||||
|
# That exact mistake happened on 2026-08-19 (artifact 1c71a17a, hand-built without
|
||||||
|
# the feature): two failed launches, diagnosed only by comparing embedded strings.
|
||||||
|
# `build` below passes the feature, but a hand-built DLL can reach `stage`/`deploy`
|
||||||
|
# via OPENFUT_FIFA17_HOOK_DLL, so assert the profile on the bytes themselves.
|
||||||
|
verify_fifa17_profile() {
|
||||||
|
local dll=$1 marker
|
||||||
|
# Markers that MUST be present: the FIFA 17 target module and its repairs.
|
||||||
|
for marker in 'CardsDLL_Win64_retail.dll' 'SBC_DISPATCH'; do
|
||||||
|
grep -qaF -- "$marker" "$dll" ||
|
||||||
|
die "$dll is not a --features fifa17 build (missing $marker); refusing to stage/deploy"
|
||||||
|
done
|
||||||
|
# Markers that MUST be absent: the FIFA-23-only transport hooking.
|
||||||
|
for marker in 'getaddrinfo IAT patched' 'connect: inline-hooked' 'origin_spy'; do
|
||||||
|
if grep -qaF -- "$marker" "$dll"; then
|
||||||
|
die "$dll contains FIFA-23-only hook '$marker'; build with --features fifa17"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
verify_inputs() {
|
||||||
|
command -v sha256sum >/dev/null || die "sha256sum is required"
|
||||||
|
command -v x86_64-w64-mingw32-objdump >/dev/null ||
|
||||||
|
die "x86_64-w64-mingw32-objdump is required"
|
||||||
|
need_file "$hook_dll"
|
||||||
|
need_file "$system_version"
|
||||||
|
verify_pe64 "$hook_dll"
|
||||||
|
verify_fifa17_profile "$hook_dll"
|
||||||
|
}
|
||||||
|
|
||||||
|
inspect() {
|
||||||
|
verify_inputs
|
||||||
|
note "mode=inspect (read-only)"
|
||||||
|
note "hook=$hook_dll"
|
||||||
|
note "hook_sha256=$(sha256 "$hook_dll")"
|
||||||
|
note "system_version=$system_version"
|
||||||
|
note "system_version_sha256=$(sha256 "$system_version")"
|
||||||
|
note "game_dir=$game_dir"
|
||||||
|
if [[ -f "$deployed_dll" ]]; then
|
||||||
|
note "deployed_version_sha256=$(sha256 "$deployed_dll")"
|
||||||
|
else
|
||||||
|
note "deployed_version=absent"
|
||||||
|
fi
|
||||||
|
verify_exports "$hook_dll"
|
||||||
|
note "version_exports=complete"
|
||||||
|
}
|
||||||
|
|
||||||
|
build() {
|
||||||
|
command -v cargo >/dev/null || die "cargo is required"
|
||||||
|
note "Building the inert FIFA 17 hook into the package-local staging source path."
|
||||||
|
CARGO_TARGET_DIR="${hook_root}/target" \
|
||||||
|
cargo build --offline --release --features fifa17 \
|
||||||
|
--target x86_64-pc-windows-gnu --manifest-path "${hook_root}/Cargo.toml"
|
||||||
|
inspect
|
||||||
|
}
|
||||||
|
|
||||||
|
stage() {
|
||||||
|
verify_inputs
|
||||||
|
verify_exports "$hook_dll"
|
||||||
|
need_file "${game_dir}/CardsDLL_Win64_retail.dll"
|
||||||
|
need_file "${game_dir}/FIFA17.exe"
|
||||||
|
mkdir -p "$stage_root"
|
||||||
|
local staged="${stage_root}/version.dll"
|
||||||
|
cp -- "$hook_dll" "$staged"
|
||||||
|
{
|
||||||
|
printf 'artifact=%s\n' "$staged"
|
||||||
|
printf 'artifact_sha256=%s\n' "$(sha256 "$staged")"
|
||||||
|
printf 'source=%s\n' "$hook_dll"
|
||||||
|
printf 'source_sha256=%s\n' "$(sha256 "$hook_dll")"
|
||||||
|
printf 'system_version=%s\n' "$system_version"
|
||||||
|
printf 'system_version_sha256=%s\n' "$(sha256 "$system_version")"
|
||||||
|
printf 'cards_dll_sha256=%s\n' "$(sha256 "${game_dir}/CardsDLL_Win64_retail.dll")"
|
||||||
|
printf 'fifa17_exe_sha256=%s\n' "$(sha256 "${game_dir}/FIFA17.exe")"
|
||||||
|
} >"${stage_root}/manifest.txt"
|
||||||
|
note "staged=$staged"
|
||||||
|
note "manifest=${stage_root}/manifest.txt"
|
||||||
|
note "No game-directory file was changed."
|
||||||
|
}
|
||||||
|
|
||||||
|
require_game_stopped() {
|
||||||
|
if pgrep -fi '(FIFA17|_fifa17)\.exe' >/dev/null; then
|
||||||
|
die "FIFA 17 appears to be running; close it before deployment"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
deploy() {
|
||||||
|
[[ "${OPENFUT_FIFA17_DEPLOY:-}" == "I_ACCEPT_VERSION_DLL_REPLACEMENT" ]] ||
|
||||||
|
die "deploy requires OPENFUT_FIFA17_DEPLOY=I_ACCEPT_VERSION_DLL_REPLACEMENT"
|
||||||
|
require_game_stopped
|
||||||
|
local staged="${stage_root}/version.dll"
|
||||||
|
local manifest="${stage_root}/manifest.txt"
|
||||||
|
need_file "$staged"
|
||||||
|
need_file "$manifest"
|
||||||
|
verify_pe64 "$staged"
|
||||||
|
verify_exports "$staged"
|
||||||
|
verify_fifa17_profile "$staged"
|
||||||
|
local recorded actual
|
||||||
|
recorded="$(awk -F= '$1=="artifact_sha256"{print $2}' "$manifest")"
|
||||||
|
actual="$(sha256 "$staged")"
|
||||||
|
[[ -n "$recorded" && "$recorded" == "$actual" ]] || die "staged artifact hash does not match manifest"
|
||||||
|
|
||||||
|
local backup_dir="${game_dir}/openfut-backups"
|
||||||
|
mkdir -p "$backup_dir"
|
||||||
|
if [[ -f "$deployed_dll" ]]; then
|
||||||
|
local old_hash backup
|
||||||
|
old_hash="$(sha256 "$deployed_dll")"
|
||||||
|
backup="${backup_dir}/version.dll.${old_hash}.bak"
|
||||||
|
if [[ ! -e "$backup" ]]; then
|
||||||
|
cp -- "$deployed_dll" "$backup"
|
||||||
|
fi
|
||||||
|
[[ "$(sha256 "$backup")" == "$old_hash" ]] || die "backup verification failed: $backup"
|
||||||
|
note "backup=$backup"
|
||||||
|
fi
|
||||||
|
cp -- "$staged" "$deployed_dll"
|
||||||
|
[[ "$(sha256 "$deployed_dll")" == "$actual" ]] || die "deployed DLL hash verification failed"
|
||||||
|
note "deployed=$deployed_dll"
|
||||||
|
note "deployed_sha256=$actual"
|
||||||
|
}
|
||||||
|
|
||||||
|
launch() {
|
||||||
|
local mode=${1:-baseline}
|
||||||
|
local hook_enabled=0
|
||||||
|
local trace_enabled=0
|
||||||
|
local request_trace_enabled=0
|
||||||
|
local notifier_trace_enabled=0
|
||||||
|
local dispatch_enabled=0
|
||||||
|
case "$mode" in
|
||||||
|
baseline)
|
||||||
|
[[ "${OPENFUT_FIFA17_LAUNCH:-}" == "I_ACCEPT_M1_BASELINE_LAUNCH" ]] ||
|
||||||
|
die "launch requires OPENFUT_FIFA17_LAUNCH=I_ACCEPT_M1_BASELINE_LAUNCH"
|
||||||
|
;;
|
||||||
|
resolve)
|
||||||
|
[[ "${OPENFUT_FIFA17_RESOLVE:-}" == "I_ACCEPT_M2_RESOLVE_LAUNCH" ]] ||
|
||||||
|
die "launch-resolve requires OPENFUT_FIFA17_RESOLVE=I_ACCEPT_M2_RESOLVE_LAUNCH"
|
||||||
|
hook_enabled=1
|
||||||
|
;;
|
||||||
|
trace)
|
||||||
|
[[ "${OPENFUT_FIFA17_TRACE:-}" == "I_ACCEPT_M3_PASSIVE_TRACE" ]] ||
|
||||||
|
die "launch-trace requires OPENFUT_FIFA17_TRACE=I_ACCEPT_M3_PASSIVE_TRACE"
|
||||||
|
hook_enabled=1
|
||||||
|
trace_enabled=1
|
||||||
|
request_trace_enabled=1
|
||||||
|
notifier_trace_enabled=1
|
||||||
|
;;
|
||||||
|
dispatch)
|
||||||
|
[[ "${OPENFUT_FIFA17_DISPATCH:-}" == "I_ACCEPT_GUARDED_NATIVE_DISPATCH" ]] ||
|
||||||
|
die "launch-dispatch requires OPENFUT_FIFA17_DISPATCH=I_ACCEPT_GUARDED_NATIVE_DISPATCH"
|
||||||
|
request_trace_enabled=1
|
||||||
|
dispatch_enabled=1
|
||||||
|
;;
|
||||||
|
*) die "unknown launch mode: $mode" ;;
|
||||||
|
esac
|
||||||
|
need_file "$deployed_dll"
|
||||||
|
local staged="${stage_root}/version.dll"
|
||||||
|
local manifest="${stage_root}/manifest.txt"
|
||||||
|
need_file "$staged"
|
||||||
|
need_file "$manifest"
|
||||||
|
verify_pe64 "$deployed_dll"
|
||||||
|
verify_exports "$deployed_dll"
|
||||||
|
local recorded
|
||||||
|
recorded="$(awk -F= '$1=="artifact_sha256"{print $2}' "$manifest")"
|
||||||
|
[[ -n "$recorded" && "$(sha256 "$staged")" == "$recorded" ]] ||
|
||||||
|
die "staged artifact hash does not match manifest"
|
||||||
|
[[ "$(sha256 "$deployed_dll")" == "$recorded" ]] ||
|
||||||
|
die "deployed version.dll does not match the staged M1 artifact"
|
||||||
|
command -v umu-run >/dev/null || die "umu-run is required"
|
||||||
|
for name in OPENFUT_SBC_DISPATCH OPENFUT_SBC_ARM_ONLY OPENFUT_SBC_POPULATE; do
|
||||||
|
[[ -z "${!name:-}" || "${!name}" == "0" ]] || die "$name must be unset or 0 for this launch"
|
||||||
|
done
|
||||||
|
mkdir -p "${wine_prefix}/dosdevices"
|
||||||
|
ln -sfn /mnt "${wine_prefix}/dosdevices/w:"
|
||||||
|
note "Launching $mode mode (SBC_HOOK=$hook_enabled; SBC_TRACE=$trace_enabled; SBC_REQUEST_TRACE=$request_trace_enabled; SBC_NOTIFIER_TRACE=$notifier_trace_enabled; SBC_DISPATCH=$dispatch_enabled); log=/tmp/fifa17-hook-m1-launch.log"
|
||||||
|
cd "$game_dir"
|
||||||
|
env \
|
||||||
|
GAMEID=fifa17 \
|
||||||
|
PROTONPATH="$proton_path" \
|
||||||
|
WINEPREFIX="$wine_prefix" \
|
||||||
|
WINEDLLOVERRIDES='version=n,b' \
|
||||||
|
OPENFUT_SBC_HOOK="$hook_enabled" \
|
||||||
|
OPENFUT_SBC_TRACE="$trace_enabled" \
|
||||||
|
OPENFUT_SBC_REQUEST_TRACE="$request_trace_enabled" \
|
||||||
|
OPENFUT_SBC_NOTIFIER_TRACE="$notifier_trace_enabled" \
|
||||||
|
OPENFUT_SBC_DISPATCH="$dispatch_enabled" \
|
||||||
|
OPENFUT_SBC_DISPATCH_TRACE=0 \
|
||||||
|
OPENFUT_SBC_ARM_ONLY=0 \
|
||||||
|
OPENFUT_SBC_POPULATE=0 \
|
||||||
|
umu-run _fifa17.exe 2>&1 | tee /tmp/fifa17-hook-m1-launch.log
|
||||||
|
}
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<'EOF'
|
||||||
|
Usage: fifa17-hook-m1.sh [inspect|build|stage|deploy|launch|launch-resolve|launch-trace|launch-dispatch]
|
||||||
|
|
||||||
|
inspect Read-only PE/hash/export preflight (default).
|
||||||
|
build Cross-build the inert FIFA17 hook, then run inspect.
|
||||||
|
stage Copy a verified DLL into repo-local staging and write a hash manifest.
|
||||||
|
deploy Back up and install version.dll; requires:
|
||||||
|
OPENFUT_FIFA17_DEPLOY=I_ACCEPT_VERSION_DLL_REPLACEMENT
|
||||||
|
launch Start the M1 inert-hook baseline; requires:
|
||||||
|
OPENFUT_FIFA17_LAUNCH=I_ACCEPT_M1_BASELINE_LAUNCH
|
||||||
|
launch-resolve
|
||||||
|
Start M2 resolve-only mode (guarded reads/logging, no detours/writes); requires:
|
||||||
|
OPENFUT_FIFA17_RESOLVE=I_ACCEPT_M2_RESOLVE_LAUNCH
|
||||||
|
launch-trace
|
||||||
|
Start the M3-M6 passive parser/request/notifier trace; requires:
|
||||||
|
OPENFUT_FIFA17_TRACE=I_ACCEPT_M3_PASSIVE_TRACE
|
||||||
|
launch-dispatch
|
||||||
|
Trace and repair only a fully validated native status-999 completion; requires:
|
||||||
|
OPENFUT_FIFA17_DISPATCH=I_ACCEPT_GUARDED_NATIVE_DISPATCH
|
||||||
|
|
||||||
|
Optional path overrides:
|
||||||
|
OPENFUT_FIFA17_HOOK_DLL, OPENFUT_FIFA17_GAME_DIR,
|
||||||
|
OPENFUT_FIFA17_WINEPREFIX, OPENFUT_FIFA17_PROTONPATH
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
case "${1:-inspect}" in
|
||||||
|
inspect) inspect ;;
|
||||||
|
build) build ;;
|
||||||
|
stage) stage ;;
|
||||||
|
deploy) deploy ;;
|
||||||
|
launch) launch baseline ;;
|
||||||
|
launch-resolve) launch resolve ;;
|
||||||
|
launch-trace) launch trace ;;
|
||||||
|
launch-dispatch) launch dispatch ;;
|
||||||
|
-h|--help|help) usage ;;
|
||||||
|
*) usage >&2; die "unknown command: $1" ;;
|
||||||
|
esac
|
||||||
Executable
+86
@@ -0,0 +1,86 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Find an APT/ActionScript symbol inside the FIFA 17 Frostbite .cas archives.
|
||||||
|
|
||||||
|
Frosty is a GUI-only tool and its Legacy Explorer is the documented way to reach
|
||||||
|
these assets, but the chunks holding APT ActionScript are stored plainly enough to
|
||||||
|
grep — so a screen can be identified, and its whole symbol table recovered,
|
||||||
|
without driving the GUI at all.
|
||||||
|
|
||||||
|
ALWAYS passes a control first: `KitAssignmentPopup` is a string from an
|
||||||
|
already-exported BIG, so if it misses, the archives are packed differently than
|
||||||
|
assumed and no negative from this tool may be quoted.
|
||||||
|
|
||||||
|
python3 find_apt_in_cas.py FUT_GET_MATCH_KITS_DP
|
||||||
|
python3 find_apt_in_cas.py --dump 0x3707ecd7 fifa_installpackage_01/cas_01.cas
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import glob
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
ROOT = "/mnt/games/FIFA 17"
|
||||||
|
CONTROL = b"KitAssignmentPopup"
|
||||||
|
|
||||||
|
|
||||||
|
def cas_files():
|
||||||
|
return sorted(glob.glob(os.path.join(ROOT, "**", "*.cas"), recursive=True))
|
||||||
|
|
||||||
|
|
||||||
|
def find(needle: bytes):
|
||||||
|
control_total = 0
|
||||||
|
hits = []
|
||||||
|
for p in cas_files():
|
||||||
|
d = open(p, "rb").read()
|
||||||
|
control_total += d.count(CONTROL)
|
||||||
|
start = 0
|
||||||
|
while True:
|
||||||
|
i = d.find(needle, start)
|
||||||
|
if i < 0:
|
||||||
|
break
|
||||||
|
hits.append((p, i))
|
||||||
|
start = i + 1
|
||||||
|
return control_total, hits
|
||||||
|
|
||||||
|
|
||||||
|
def dump(path, off, span=90000):
|
||||||
|
with open(path, "rb") as f:
|
||||||
|
f.seek(max(0, off - span // 2))
|
||||||
|
d = f.read(span)
|
||||||
|
seen = []
|
||||||
|
for m in re.finditer(rb"[ -~]{4,}", d):
|
||||||
|
t = m.group().decode("latin1")
|
||||||
|
if t not in seen:
|
||||||
|
seen.append(t)
|
||||||
|
return seen
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("needle", nargs="?")
|
||||||
|
ap.add_argument("--dump", metavar="OFFSET")
|
||||||
|
ap.add_argument("--file")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
if args.dump:
|
||||||
|
path = args.file if os.path.isabs(args.file or "") else os.path.join(
|
||||||
|
ROOT, "Data/Win32/superbundlelayout", args.file or "")
|
||||||
|
for s in dump(path, int(args.dump, 0)):
|
||||||
|
print(s)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if not args.needle:
|
||||||
|
ap.error("needle required")
|
||||||
|
ctl, hits = find(args.needle.encode())
|
||||||
|
print(f"control {CONTROL.decode()}: {ctl} hit(s)")
|
||||||
|
if ctl == 0:
|
||||||
|
print("CONTROL FAILED — archives not greppable this way; no negative is valid.")
|
||||||
|
return 1
|
||||||
|
print(f"{args.needle}: {len(hits)} hit(s)")
|
||||||
|
for p, i in hits[:20]:
|
||||||
|
print(f" {os.path.relpath(p, ROOT)} @ {i:#x}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -204,6 +204,7 @@ class Account:
|
|||||||
def __init__(self, path=None):
|
def __init__(self, path=None):
|
||||||
self.path = path or ACCOUNT_PATH
|
self.path = path or ACCOUNT_PATH
|
||||||
self._loaded = False
|
self._loaded = False
|
||||||
|
self._file_signature = None
|
||||||
self._stored = {} # what is on disk (tier 2+3 only)
|
self._stored = {} # what is on disk (tier 2+3 only)
|
||||||
for f in _FIELDS:
|
for f in _FIELDS:
|
||||||
setattr(self, "_" + f, None)
|
setattr(self, "_" + f, None)
|
||||||
@@ -214,7 +215,8 @@ class Account:
|
|||||||
save the first time. Never raises on a malformed file -- a broken
|
save the first time. Never raises on a malformed file -- a broken
|
||||||
account file must not stop the harness booting."""
|
account file must not stop the harness booting."""
|
||||||
with _LOCK:
|
with _LOCK:
|
||||||
if self._loaded and not force:
|
signature = self._signature()
|
||||||
|
if self._loaded and not force and signature == self._file_signature:
|
||||||
return self
|
return self
|
||||||
stored = {}
|
stored = {}
|
||||||
if os.path.exists(self.path):
|
if os.path.exists(self.path):
|
||||||
@@ -239,8 +241,22 @@ class Account:
|
|||||||
% (self.path, e))
|
% (self.path, e))
|
||||||
self._stored = stored
|
self._stored = stored
|
||||||
self._loaded = True
|
self._loaded = True
|
||||||
|
self._file_signature = self._signature()
|
||||||
return self
|
return self
|
||||||
|
|
||||||
|
def _signature(self):
|
||||||
|
"""Identity of the active-account file across atomic replacements.
|
||||||
|
|
||||||
|
The launcher can select an account while Blaze/POW are already running
|
||||||
|
in separate processes. inode + mtime + size lets every process notice
|
||||||
|
the replacement on its next property read without restarting Docker.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
st = os.stat(self.path)
|
||||||
|
return st.st_dev, st.st_ino, st.st_mtime_ns, st.st_size
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
|
||||||
def _migrate_from_profile(self):
|
def _migrate_from_profile(self):
|
||||||
"""Lift identity/club out of a pre-existing fifa17_profile.json so an
|
"""Lift identity/club out of a pre-existing fifa17_profile.json so an
|
||||||
existing club name survives the move to this module. Read-only: the game
|
existing club name survives the move to this module. Read-only: the game
|
||||||
@@ -266,11 +282,32 @@ class Account:
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
def _write(self):
|
def _write(self):
|
||||||
|
parent = os.path.dirname(self.path)
|
||||||
|
if parent:
|
||||||
|
os.makedirs(parent, exist_ok=True)
|
||||||
tmp = self.path + ".tmp"
|
tmp = self.path + ".tmp"
|
||||||
with open(tmp, "w") as f:
|
with open(tmp, "w") as f:
|
||||||
json.dump(self._stored, f, indent=1, sort_keys=True)
|
json.dump(self._stored, f, indent=1, sort_keys=True)
|
||||||
f.write("\n")
|
f.write("\n")
|
||||||
os.replace(tmp, self.path)
|
os.replace(tmp, self.path)
|
||||||
|
self._file_signature = self._signature()
|
||||||
|
|
||||||
|
def replace(self, values):
|
||||||
|
"""Atomically replace the active identity with validated persisted values."""
|
||||||
|
with _LOCK:
|
||||||
|
clean = {k: v for k, v in values.items() if k in _FIELDS and v is not None}
|
||||||
|
if "persona_id" not in clean or "persona_name" not in clean:
|
||||||
|
raise ValueError("persona_id and persona_name are required")
|
||||||
|
clean["persona_id"] = int(clean["persona_id"])
|
||||||
|
clean["persona_name"] = str(clean["persona_name"]).strip()
|
||||||
|
if clean["persona_id"] <= 0 or not clean["persona_name"]:
|
||||||
|
raise ValueError("persona_id must be positive and persona_name must not be empty")
|
||||||
|
self._stored = clean
|
||||||
|
for field in _FIELDS:
|
||||||
|
setattr(self, "_" + field, None)
|
||||||
|
self._loaded = True
|
||||||
|
self._write()
|
||||||
|
return self
|
||||||
|
|
||||||
def save(self):
|
def save(self):
|
||||||
"""Persist tiers 2+3 (only fields that differ from the built-in default,
|
"""Persist tiers 2+3 (only fields that differ from the built-in default,
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Launcher-to-server active-account selection for the single-player stack."""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
|
from fut_account import ACCOUNT
|
||||||
|
from fut_store import STORE, profile_path_for
|
||||||
|
|
||||||
|
|
||||||
|
def _existing_identity(persona_id):
|
||||||
|
path = profile_path_for(persona_id)
|
||||||
|
try:
|
||||||
|
with open(path) as f:
|
||||||
|
profile = json.load(f)
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return {}
|
||||||
|
if not isinstance(profile, dict):
|
||||||
|
return {}
|
||||||
|
return {
|
||||||
|
"club_name": profile.get("clubName"),
|
||||||
|
"club_abbr": profile.get("clubAbbr"),
|
||||||
|
"established": profile.get("established"),
|
||||||
|
"pow_level": profile.get("powLevel"),
|
||||||
|
"pow_exp": profile.get("powExp"),
|
||||||
|
"pow_exp_max": profile.get("powExpMax"),
|
||||||
|
"pow_funds": profile.get("powFunds"),
|
||||||
|
"pow_funds_cap": profile.get("powFundsCap"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def activate(payload):
|
||||||
|
"""Select/create one persistent profile and publish it to all responders."""
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise ValueError("account payload must be an object")
|
||||||
|
try:
|
||||||
|
persona_id = int(payload.get("personaId"))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
raise ValueError("personaId must be a positive integer") from None
|
||||||
|
persona_name = payload.get("personaName")
|
||||||
|
if persona_id <= 0 or not isinstance(persona_name, str) or not persona_name.strip():
|
||||||
|
raise ValueError("personaId must be positive and personaName must not be empty")
|
||||||
|
|
||||||
|
values = _existing_identity(persona_id)
|
||||||
|
values.update(persona_id=persona_id, persona_name=persona_name.strip())
|
||||||
|
for wire, field in (("clubName", "club_name"), ("clubAbbr", "club_abbr"),
|
||||||
|
("established", "established"), ("squadName", "squad_name"),
|
||||||
|
("level", "pow_level"), ("experience", "pow_exp"),
|
||||||
|
("experienceMax", "pow_exp_max"), ("accountFunds", "pow_funds"),
|
||||||
|
("accountFundsCap", "pow_funds_cap")):
|
||||||
|
if payload.get(wire) not in (None, ""):
|
||||||
|
values[field] = payload[wire]
|
||||||
|
|
||||||
|
ACCOUNT.replace(values)
|
||||||
|
ACCOUNT.set_online_profile()
|
||||||
|
ACCOUNT.save()
|
||||||
|
profile = STORE.select_account(persona_id)
|
||||||
|
STORE.ensure_security_question()
|
||||||
|
return {
|
||||||
|
"personaId": ACCOUNT.persona_id,
|
||||||
|
"personaName": ACCOUNT.persona_name,
|
||||||
|
"clubName": ACCOUNT.club_name,
|
||||||
|
"clubAbbr": ACCOUNT.club_abbr,
|
||||||
|
"level": ACCOUNT.pow_level,
|
||||||
|
"experience": ACCOUNT.pow_exp,
|
||||||
|
"experienceMax": ACCOUNT.pow_exp_max,
|
||||||
|
"accountFunds": ACCOUNT.pow_funds,
|
||||||
|
"accountFundsCap": ACCOUNT.pow_funds_cap,
|
||||||
|
"profilePath": os.path.relpath(STORE.path, os.path.dirname(ACCOUNT.path)),
|
||||||
|
"coins": profile.get("coins", 0),
|
||||||
|
"unopenedPacks": len(profile.get("unopenedPackIds", [])),
|
||||||
|
}
|
||||||
@@ -47,16 +47,30 @@ _DATA = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "data", "
|
|||||||
CLUBITEM_ID_BASE = 960000000 # distinct from save 1e8, sweep 9e8, consumables 9.4e8
|
CLUBITEM_ID_BASE = 960000000 # distinct from save 1e8, sweep 9e8, consumables 9.4e8
|
||||||
|
|
||||||
# (table, art id, stat id, stat name, UNVERIFIED cardsubtypeid)
|
# (table, art id, stat id, stat name, UNVERIFIED cardsubtypeid)
|
||||||
|
# CORRECTED 2026-08-06. Every previous subtype was inside the 0x91..0x96 block, which
|
||||||
|
# is TROPHIES: FUN_180108c00 computes subtype = tournamentType + 0x91, and FUN_1800fed90
|
||||||
|
# is the only function in the binary whose case set is exactly {0x91..0x96}. So all five
|
||||||
|
# families were pointed at the trophy range.
|
||||||
|
#
|
||||||
|
# Kits, stadia and badges are NOT cardtype 9. FUN_1800d8330 has
|
||||||
|
# `case 9: case 10: case 0xb: return 7`, and cardtype 7 DOES have a resolver: manager
|
||||||
|
# vtable +0x498 = FUN_180119bd0, reached from FUN_1800f6c40 when item+0x4c == 7, called
|
||||||
|
# with (subtype, teamid, assetId). That matters for testing: CARD_SYSTEM.md said a wrong
|
||||||
|
# club-item id "cannot announce itself", and for these three that is false. A wrong
|
||||||
|
# teamid produces a visibly wrong TeamName_Abbr15_ caption, which is why kits go first.
|
||||||
FAMILIES = [
|
FAMILIES = [
|
||||||
("balls", "fcc_balls.json", 37, 0x1E, "balls", 149),
|
("balls", "fcc_balls.json", 37, 0x1E, "balls", 30),
|
||||||
("stadia", "fcc_stadium.json", 36, 0x14, "stadia", 148),
|
("stadia", "fcc_stadium.json", 36, 0x14, "stadia", 10),
|
||||||
("badges", "fcc_badgecards.json", 39, 0x2E, "badgeDBid", 145),
|
("badges", "fcc_badgecards.json", 39, 0x2E, "badgeDBid", 11),
|
||||||
("kits", "fcc_kitcards.json", 35, 0x28, "kits", 146),
|
("kits", "fcc_kitcards.json", 35, 0x28, "kits", 9),
|
||||||
("leaguelogos", "fcc_leaguelogos.json", 40, 0x2F, "leagueLogos", 150),
|
("leaguelogos", "fcc_leaguelogos.json", 40, 0x2F, "leagueLogos", 31),
|
||||||
]
|
]
|
||||||
|
|
||||||
# Every cardsubtypeid known to reach cardtype 9. Used by probe_shelf().
|
# Candidate set for probe_shelf(). The old set {30,31,145..150} could NOT have answered
|
||||||
CARDTYPE9_SUBTYPES = (30, 31, 145, 146, 147, 148, 149, 150)
|
# the question for kits, stadia or badges, because 9, 10 and 11 were not in it: the
|
||||||
|
# probe route the docs preferred would have spent a launch and returned nothing for
|
||||||
|
# three of the five families.
|
||||||
|
CARDTYPE9_SUBTYPES = (9, 10, 11, 30, 31)
|
||||||
|
|
||||||
# How many of each family the starter club owns. Small on purpose: the point is to
|
# How many of each family the starter club owns. Small on purpose: the point is to
|
||||||
# make the counter non-zero so the client asks, not to hand anyone a collection.
|
# make the counter non-zero so the client asks, not to hand anyone a collection.
|
||||||
@@ -71,22 +85,35 @@ def _rows(fname):
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
def _item(item_id, carddbid, cardassetid, subtype, extra=None):
|
def _item(item_id, carddbid, cardassetid, subtype, teamid=None, extra=None):
|
||||||
"""One club item. Deliberately narrow: no rating, no position, no attributes,
|
"""One club item. Deliberately narrow: no rating, no position, no attributes,
|
||||||
no nation, no league, no team. A club item has none of those, and sending a
|
no nation, no league. A club item has none of those, and sending a field the
|
||||||
field the family does not have is how a wrong shape gets accepted and does
|
family does not have is how a wrong shape gets accepted and does nothing."""
|
||||||
nothing."""
|
|
||||||
it = {
|
it = {
|
||||||
"id": item_id,
|
"id": item_id,
|
||||||
"resourceId": carddbid,
|
"resourceId": carddbid,
|
||||||
"assetId": carddbid,
|
"assetId": carddbid,
|
||||||
"cardassetid": cardassetid, # THE ART ID, never a copy of resourceId
|
"cardassetid": cardassetid, # THE ART ID, never a copy of resourceId
|
||||||
"cardsubtypeid": subtype,
|
"cardsubtypeid": subtype,
|
||||||
"itemType": "club", # UNOBSERVED on the wire; see module docstring
|
|
||||||
"itemState": "free",
|
"itemState": "free",
|
||||||
"owners": 1,
|
"owners": 1,
|
||||||
"untradeable": False,
|
"untradeable": False,
|
||||||
}
|
}
|
||||||
|
# KIT (9) and BADGE (11) display as <caption> + TeamName_Abbr15_<teamid>, so
|
||||||
|
# without teamid the name comes out as the caption alone. STADIUM (10) reads
|
||||||
|
# StadiumName_<assetId>, which resourceId already supplies, so it needs nothing.
|
||||||
|
# teamid is atom 0x306, read with the INT primitive FUN_1801c79d0 and stored at
|
||||||
|
# record +0x94: an established scalar field, not a new shape.
|
||||||
|
#
|
||||||
|
# BE HONEST ABOUT THE 2026-08-05 CRASH: teamid was one of the three extras in the
|
||||||
|
# response that crashed the client, and it was never bisected. `value` is the
|
||||||
|
# established suspect, because it is an OBJECT member elsewhere and a scalar where
|
||||||
|
# an object is expected is the 0x1801c7f1a busy loop, and that response also
|
||||||
|
# carried 30 items across FIVE wrong subtypes at once. This adds teamid ALONE, to
|
||||||
|
# ONE family, with the subtypes now corrected. That is the narrow test the crash
|
||||||
|
# denied us, and it is why families are served one at a time.
|
||||||
|
if teamid is not None and subtype in (9, 11):
|
||||||
|
it["teamid"] = teamid
|
||||||
if extra:
|
if extra:
|
||||||
it.update(extra)
|
it.update(extra)
|
||||||
return it
|
return it
|
||||||
@@ -119,7 +146,13 @@ def shelf(next_id=CLUBITEM_ID_BASE, families=None):
|
|||||||
# at 0x1801c7f1a, which reads exactly like "the game is taking its time"
|
# at 0x1801c7f1a, which reads exactly like "the game is taking its time"
|
||||||
# and then dies. Omission is safe; an unestablished field is not. None of
|
# and then dies. Omission is safe; an unestablished field is not. None of
|
||||||
# the three was needed to draw a card.
|
# the three was needed to draw a card.
|
||||||
picked.append(_item(nid, cid, r.get("cardassetid", art), subtype))
|
# teamid is passed but _item only APPLIES it to kits (9) and badges (11),
|
||||||
|
# which are the two families whose caption is <name> + TeamName_Abbr15_
|
||||||
|
# <teamid>. It is the one field from the fcc row being reintroduced after
|
||||||
|
# the 2026-08-05 crash, deliberately alone and deliberately narrow: see
|
||||||
|
# the note in _item(). value and leagueid stay omitted.
|
||||||
|
picked.append(_item(nid, cid, r.get("cardassetid", art), subtype,
|
||||||
|
teamid=r.get("teamid")))
|
||||||
nid += 1
|
nid += 1
|
||||||
out[name] = picked
|
out[name] = picked
|
||||||
return out
|
return out
|
||||||
|
|||||||
+378
-16
@@ -17,7 +17,125 @@ sys.path.insert(0, HERE)
|
|||||||
import fut_cards
|
import fut_cards
|
||||||
from fut_account import ACCOUNT # single source of truth for identity/club
|
from fut_account import ACCOUNT # single source of truth for identity/club
|
||||||
|
|
||||||
PROFILE_PATH = os.environ.get("FUT_PROFILE", os.path.join(HERE, "fifa17_profile.json"))
|
PROFILE_ROOT = os.environ.get("FUT_PROFILE_ROOT", "")
|
||||||
|
|
||||||
|
|
||||||
|
def profile_path_for(persona_id):
|
||||||
|
explicit = os.environ.get("FUT_PROFILE")
|
||||||
|
if explicit:
|
||||||
|
return explicit
|
||||||
|
if PROFILE_ROOT:
|
||||||
|
return os.path.join(PROFILE_ROOT, str(int(persona_id)), "fifa17_profile.json")
|
||||||
|
return os.path.join(HERE, "fifa17_profile.json")
|
||||||
|
|
||||||
|
|
||||||
|
PROFILE_PATH = profile_path_for(ACCOUNT.persona_id)
|
||||||
|
|
||||||
|
# ---- FUT_DISCARD_TABLE: the REAL FIFA 17 quick-sell values ------------------
|
||||||
|
#
|
||||||
|
# quick_sell() used to pay an invented rating tier (600/300/150/50). That number
|
||||||
|
# was wrong for every card. The real table is `fcc_discardcoins` in the client's
|
||||||
|
# own game DB, 141 rows keyed (cardtype, level, rare) -> price, recovered from the
|
||||||
|
# running client 2026-08-05 and verified against 22 live club items, 22/22 exact.
|
||||||
|
#
|
||||||
|
# The client computes the DISPLAYED value itself with the same table whenever our
|
||||||
|
# `discardValue` (atom 0xd7) is 0 or absent: FUN_18013fe00 stores our value at item
|
||||||
|
# +0x38, and the guard at 0x180141025 (`cmp dword [rbp+0x198],0` / `ja`) skips the
|
||||||
|
# local computation when it is non-zero. So today the client shows the real value
|
||||||
|
# while the server pays a made-up one, and the two disagree on every card. This
|
||||||
|
# makes the paid value agree with the shown value.
|
||||||
|
#
|
||||||
|
# value = round_half_up(rating * price / 100)
|
||||||
|
# level = 3 if rating >= 75, 2 if 65..74, else 1 (0x180141e8a..0x180141ea3;
|
||||||
|
# derived from rating, NOT a wire field)
|
||||||
|
# cardtype = FUN_1800d8330(cardsubtypeid), decoded from its jump table and
|
||||||
|
# checked across every subtype 0..599 with zero disagreements
|
||||||
|
#
|
||||||
|
# ZERO WIRE CHANGE. Nothing new is sent; only the coin figure the server credits
|
||||||
|
# changes. Default off per the house rule, but this is the one patch worth
|
||||||
|
# defaulting on after a single verification.
|
||||||
|
# See docs/plan-2026-08-05-store-subsystem.md section 3.6.
|
||||||
|
DISCARD_TABLE = os.environ.get("FUT_DISCARD_TABLE", "0") == "1"
|
||||||
|
|
||||||
|
_DP = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _dp(ct, rares, p1, p2, p3):
|
||||||
|
for r in rares:
|
||||||
|
_DP[(ct, 1, r)] = p1
|
||||||
|
_DP[(ct, 2, r)] = p2
|
||||||
|
_DP[(ct, 3, r)] = p3
|
||||||
|
|
||||||
|
|
||||||
|
_dp(1, [0], 30, 150, 400)
|
||||||
|
_dp(1, [1], 75, 350, 800)
|
||||||
|
_dp(1, [7], 1500, 5000, 9000)
|
||||||
|
_dp(1, [2, 3, 10, 13] + list(range(17, 32)), 2000, 7000, 12200)
|
||||||
|
_dp(1, [4, 8, 9], 6000, 10000, 18000)
|
||||||
|
_dp(1, [11], 10000, 15000, 24000)
|
||||||
|
_dp(1, [5, 6], 20000, 40000, 80000)
|
||||||
|
_dp(1, [12], 120000, 120000, 120000)
|
||||||
|
_dp(2, [0], 20, 70, 110)
|
||||||
|
_dp(2, [1], 25, 120, 320)
|
||||||
|
for _ct in (3, 4, 5, 10):
|
||||||
|
_dp(_ct, [0], 10, 55, 110)
|
||||||
|
_dp(_ct, [1], 50, 100, 300)
|
||||||
|
for _ct in (6, 7, 8, 9):
|
||||||
|
_dp(_ct, [0], 5, 20, 40)
|
||||||
|
_dp(_ct, [1], 20, 50, 70)
|
||||||
|
|
||||||
|
|
||||||
|
def _cardtype(sub):
|
||||||
|
"""FUN_1800d8330. 0 means no table row, which the client renders as value 0."""
|
||||||
|
if sub is None:
|
||||||
|
return 0
|
||||||
|
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, 231, 232, 233, 236) or 145 <= sub <= 150:
|
||||||
|
return 9
|
||||||
|
if (51 <= sub <= 136) or (201 <= sub <= 220) or (250 <= sub <= 273) \
|
||||||
|
or (300 <= sub <= 341):
|
||||||
|
return 6
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def discard_value(item):
|
||||||
|
"""round_half_up(rating * price / 100), price from fcc_discardcoins.
|
||||||
|
|
||||||
|
Returns None when the formula does not apply, so callers fall back instead of
|
||||||
|
paying nothing. THE UNRATED-CARD CASE IS NOT COVERED BY THE RECOVERED FORMULA:
|
||||||
|
it was verified 22/22 against club items, all of which were rated players, and
|
||||||
|
`rating * price / 100` collapses to 0 for a staff card carrying no rating. Found
|
||||||
|
by running the whole save through it, where exactly one item (a staff card,
|
||||||
|
cardsubtypeid 8, rating None) came back 0 while the old tier paid 50. Paying 0 for
|
||||||
|
a card the previous code paid for is a regression, so unrated cards fall back.
|
||||||
|
What FUT really pays for staff and consumables is UNKNOWN and worth recovering;
|
||||||
|
the likely answer is the unscaled table price, but that is a guess and is not
|
||||||
|
shipped as one.
|
||||||
|
"""
|
||||||
|
r = item.get("rating")
|
||||||
|
if not r:
|
||||||
|
return None
|
||||||
|
ct = _cardtype(item.get("cardsubtypeid"))
|
||||||
|
r = int(r)
|
||||||
|
lvl = 3 if r >= 75 else 2 if r >= 65 else 1
|
||||||
|
price = _DP.get((ct, lvl, int(item.get("rareflag") or 0)), 0)
|
||||||
|
if not price:
|
||||||
|
return None # no table row: the client renders 0, we should not
|
||||||
|
n = r * price
|
||||||
|
return n // 100 + (1 if n % 100 >= 50 else 0)
|
||||||
|
|
||||||
# Back-compat snapshots. Identity now lives in fut_account.ACCOUNT so Blaze, LSX
|
# Back-compat snapshots. Identity now lives in fut_account.ACCOUNT so Blaze, LSX
|
||||||
# and UTAS cannot drift apart; prefer ACCOUNT.<field> in new code. These are
|
# and UTAS cannot drift apart; prefer ACCOUNT.<field> in new code. These are
|
||||||
@@ -62,9 +180,37 @@ ITEM_ID_BASE = 100000000
|
|||||||
_SQUAD_FITNESS_TRAP = 219
|
_SQUAD_FITNESS_TRAP = 219
|
||||||
|
|
||||||
|
|
||||||
|
# FUT_TRADEABLE: send untradeable=false so the client's tradeable byte gets set.
|
||||||
|
#
|
||||||
|
# "Place on Transfer List" and "List on Transfer Market" are greyed out on every card,
|
||||||
|
# and BOTH gates are ours. FUN_1801a7260, the TO_TRADE_PILE predicate published by
|
||||||
|
# FUN_18003e370, returns 1 only if the service gate at vtable+0x270 is non-zero AND
|
||||||
|
# item+0x49 is non-zero. The deserializer stores untradeable INVERTED (case 0x361 does
|
||||||
|
# CONCAT11(cVar6 == '\0', ...)), so untradeable:true writes 0 and kills the flag.
|
||||||
|
#
|
||||||
|
# THIS FLAG ALONE IS NOT ENOUGH, and shipping it alone will look like the finding
|
||||||
|
# failed. The other gate is `movzx eax, byte [rcx+0x1fd2e]; ret`, and 0x1fd2e is the
|
||||||
|
# tradingEnabled gate byte. Measured live 2026-08-06 as 0, while friendlySeasons
|
||||||
|
# (0x1fd3a), draftMode (0x1fd3d) and packOpeningAnimation (0x1fd45) all read 1 in the
|
||||||
|
# same walk. tradingEnabled is the only gate byte yet found that is not already 1, and
|
||||||
|
# it is ALREADY in _SETTINGS_KEEP: it has simply never been sent, because
|
||||||
|
# _SETTINGS_MODE defaults to off. So the run needs FUT_SETTINGS=keep beside this.
|
||||||
|
#
|
||||||
|
# Freeze risk: none beyond what we already send. untradeable is atom 0x361 read by the
|
||||||
|
# BOOL primitive FUN_1801c7620, and we already send the key on every card; only the
|
||||||
|
# value changes. The constructor default for +0x49 is 1 (tradeable), so false moves
|
||||||
|
# the field toward the client's own default rather than away from it.
|
||||||
|
#
|
||||||
|
# Side effects, both permissive rather than restrictive: item+0x49 also feeds
|
||||||
|
# FUN_1800bc580, which counts untradeable squad members and publishes UNTRADABLE_COUNT,
|
||||||
|
# which gates squad submission in FUN_1800bba10 (today that takes the
|
||||||
|
# couldNotSubmitSquad branch).
|
||||||
|
TRADEABLE = os.environ.get("FUT_TRADEABLE", "0") == "1"
|
||||||
|
|
||||||
|
|
||||||
def _item(item_id, asset, rating, pos, nation, league, team, attrs, version=0x00,
|
def _item(item_id, asset, rating, pos, nation, league, team, attrs, version=0x00,
|
||||||
cardsubtypeid=0, rareflag=1):
|
cardsubtypeid=0, rareflag=1):
|
||||||
return {
|
return _with_discard({
|
||||||
"id": item_id,
|
"id": item_id,
|
||||||
"resourceId": (version << 24) | asset,
|
"resourceId": (version << 24) | asset,
|
||||||
"assetId": asset,
|
"assetId": asset,
|
||||||
@@ -82,12 +228,120 @@ def _item(item_id, asset, rating, pos, nation, league, team, attrs, version=0x00
|
|||||||
"attributeList": [{"index": i, "value": v} for i, v in enumerate(attrs)],
|
"attributeList": [{"index": i, "value": v} for i, v in enumerate(attrs)],
|
||||||
"itemState": "free",
|
"itemState": "free",
|
||||||
"owners": 1,
|
"owners": 1,
|
||||||
"untradeable": True,
|
"untradeable": not TRADEABLE,
|
||||||
"contract": 7,
|
"contract": 7,
|
||||||
"fitness": 99,
|
"fitness": 99,
|
||||||
|
})
|
||||||
|
# discardValue is stamped HERE, inside the single item factory, so every path that
|
||||||
|
# builds an item gets it: pack contents, the starter grant, club reads and market
|
||||||
|
# listings alike. Stamping it at one call site would leave the reveal screen and
|
||||||
|
# the club showing different numbers for the same card.
|
||||||
|
|
||||||
|
|
||||||
|
SPECIAL_CARD_TYPES = {
|
||||||
|
# name: (rareflag, revision byte, rating/attribute boost, selection weight)
|
||||||
|
# rareflag names come from FIFA 17's ItemRareType enum. Revisions are local,
|
||||||
|
# stable identities; the client resolves the footballer from the low 24 bits.
|
||||||
|
"TOTW": (3, 1, 2, 34),
|
||||||
|
"PURPLE": (4, 2, 3, 7),
|
||||||
|
"TOTY": (5, 3, 6, 3),
|
||||||
|
"RECORD_BREAKER": (6, 4, 5, 2),
|
||||||
|
"TOTS": (11, 5, 5, 7),
|
||||||
|
"OTW": (21, 6, 2, 14),
|
||||||
|
"HALLOWEEN": (22, 7, 3, 8),
|
||||||
|
"MOVEMBER": (23, 8, 3, 8),
|
||||||
|
"SBC": (24, 9, 4, 17),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def choose_special_type(player, rng=None):
|
||||||
|
"""Choose a rating-appropriate FIFA 17 promo family for one pool row."""
|
||||||
|
import random
|
||||||
|
rng = rng or random
|
||||||
|
rating = player[1]
|
||||||
|
eligible = []
|
||||||
|
for name, spec in SPECIAL_CARD_TYPES.items():
|
||||||
|
if name in ("TOTY", "RECORD_BREAKER") and rating < 85:
|
||||||
|
continue
|
||||||
|
if name == "TOTS" and rating < 75:
|
||||||
|
continue
|
||||||
|
eligible.append((name, spec[3]))
|
||||||
|
names, weights = zip(*eligible)
|
||||||
|
return rng.choices(names, weights=weights, k=1)[0]
|
||||||
|
|
||||||
|
|
||||||
|
def player_item(item_id, player, special=False):
|
||||||
|
"""Build a base or named FIFA 17 special revision from a pool row.
|
||||||
|
|
||||||
|
`special=True` remains supported and chooses a weighted eligible family;
|
||||||
|
callers and tests may also pass an explicit name such as ``"TOTY"``.
|
||||||
|
"""
|
||||||
|
asset, rating, pos, nation, league, team, attrs = player
|
||||||
|
if special:
|
||||||
|
special_name = choose_special_type(player) if special is True else special
|
||||||
|
rareflag, version, boost, _weight = SPECIAL_CARD_TYPES[special_name]
|
||||||
|
rating = min(99, rating + boost)
|
||||||
|
attrs = [min(99, value + boost) for value in attrs]
|
||||||
|
else:
|
||||||
|
rareflag, version = 1, 0
|
||||||
|
return _item(item_id, asset, rating, pos, nation, league, team, attrs,
|
||||||
|
version=version, rareflag=rareflag)
|
||||||
|
|
||||||
|
|
||||||
|
# FUT_DISCARD_SEND: put discardValue (atom 0xd7) on the wire so the CLIENT DISPLAYS
|
||||||
|
# the same number the server pays.
|
||||||
|
#
|
||||||
|
# Measured live 2026-08-06. With FUT_DISCARD_TABLE on, the server correctly paid 600
|
||||||
|
# for a 75-rated rare gold (9,844,900 -> 9,845,500, exact) while the reveal screen
|
||||||
|
# showed "Quick Sell 0", and "Quick Sell all remaining Items" showed 0 too. So the
|
||||||
|
# figure was right and invisible, and the screen contradicted the wallet.
|
||||||
|
#
|
||||||
|
# The cause is the guard the table work reversed. FUN_18013fe00 stores our
|
||||||
|
# discardValue at item +0x38; at 0x180141025 a `cmp dword [rbp+0x198],0` / `ja` skips
|
||||||
|
# the client's own local computation when that value is NON-ZERO. We seed 0, so the
|
||||||
|
# client runs its own fcc_discardcoins lookup and the price register stays 0.
|
||||||
|
#
|
||||||
|
# CORRECTED 2026-08-06: the two claims that used to sit here -- "that lookup
|
||||||
|
# returns no row for our cards" and "WHY its lookup misses is still UNKNOWN" --
|
||||||
|
# are both FALSE. The lookup does not miss; real rows exist for both rare values
|
||||||
|
# on (cardtype 6, level, rare). The tile reads a DIFFERENT property, which is why
|
||||||
|
# the wallet and the screen disagreed. Sending a non-zero value still fixes the
|
||||||
|
# display, for the reason below -- it bypasses the local computation entirely --
|
||||||
|
# but do not carry the "missing row" story forward: it sent one round of work
|
||||||
|
# looking for a table defect that was never there.
|
||||||
|
#
|
||||||
|
# Freeze risk: low and in the safe direction. discardValue is a plain INT read by the
|
||||||
|
# scalar getter 0x1801c79d0. The freezes on this project have all come from feeding an
|
||||||
|
# object or array where a scalar was expected, never the reverse.
|
||||||
|
#
|
||||||
|
# Requires FUT_DISCARD_TABLE, since without the real table this would put the invented
|
||||||
|
# tier on screen and make a wrong number authoritative-looking rather than merely paid.
|
||||||
|
DISCARD_SEND = os.environ.get("FUT_DISCARD_SEND", "0") == "1" and DISCARD_TABLE
|
||||||
|
|
||||||
|
|
||||||
|
def _with_discard(it):
|
||||||
|
"""Apply the read-path flags to one item.
|
||||||
|
|
||||||
|
Two things, both of which MUST happen on read and not only at creation: the
|
||||||
|
saved profile holds 246 items minted long before either flag existed, and the
|
||||||
|
club route serves them straight out of the save. Stamping only in _item() left
|
||||||
|
the wire carrying untradeable:true with FUT_TRADEABLE=1 set, which was caught by
|
||||||
|
reading the served JSON rather than by unit-testing the factory.
|
||||||
|
|
||||||
|
Callers pass a COPY, so the save is never mutated by a read.
|
||||||
|
"""
|
||||||
|
if DISCARD_SEND:
|
||||||
|
# Omit the key entirely when the formula does not apply, rather than sending
|
||||||
|
# 0: a 0 makes the client fall back to its own lookup, and the tile binds our
|
||||||
|
# value anyway, so 0 renders as 0.
|
||||||
|
v = discard_value(it)
|
||||||
|
if v:
|
||||||
|
it["discardValue"] = v
|
||||||
|
if TRADEABLE:
|
||||||
|
it["untradeable"] = False
|
||||||
|
return it
|
||||||
|
|
||||||
|
|
||||||
def _new_profile():
|
def _new_profile():
|
||||||
"""First-run grant: opening coins + the starter squad as owned items."""
|
"""First-run grant: opening coins + the starter squad as owned items."""
|
||||||
items = [_item(ITEM_ID_BASE + i + 1, a, r, p, n, lg, tm, at)
|
items = [_item(ITEM_ID_BASE + i + 1, a, r, p, n, lg, tm, at)
|
||||||
@@ -107,6 +361,10 @@ def _new_profile():
|
|||||||
"purchased": [], # unassigned/pending items from opened packs
|
"purchased": [], # unassigned/pending items from opened packs
|
||||||
"squads": [], # saved squads (raw squad objects from PUT /squad)
|
"squads": [], # saved squads (raw squad objects from PUT /squad)
|
||||||
"packsOpened": 0,
|
"packsOpened": 0,
|
||||||
|
# Owned reward packs are separate from purchased items. Pack 70 is a
|
||||||
|
# one-time migration grant used to bring the retail My Packs flow online.
|
||||||
|
"unopenedPackIds": [70],
|
||||||
|
"unopenedSeeded": True,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -125,6 +383,10 @@ class Store:
|
|||||||
self._p = _new_profile()
|
self._p = _new_profile()
|
||||||
self._sync_identity()
|
self._sync_identity()
|
||||||
self._save()
|
self._save()
|
||||||
|
if not self._p.get("unopenedSeeded"):
|
||||||
|
self._p.setdefault("unopenedPackIds", []).append(70)
|
||||||
|
self._p["unopenedSeeded"] = True
|
||||||
|
self._save()
|
||||||
self._sync_identity()
|
self._sync_identity()
|
||||||
return self._p
|
return self._p
|
||||||
|
|
||||||
@@ -142,18 +404,51 @@ class Store:
|
|||||||
p["clubName"] = ACCOUNT.club_name
|
p["clubName"] = ACCOUNT.club_name
|
||||||
p["clubAbbr"] = ACCOUNT.club_abbr
|
p["clubAbbr"] = ACCOUNT.club_abbr
|
||||||
p["established"] = ACCOUNT.established
|
p["established"] = ACCOUNT.established
|
||||||
|
# EA/EASFC account-bar state belongs to the same persona as the FUT
|
||||||
|
# save, but remains a distinct balance from FUT coins.
|
||||||
|
p["powLevel"] = ACCOUNT.pow_level
|
||||||
|
p["powExp"] = ACCOUNT.pow_exp
|
||||||
|
p["powExpMax"] = ACCOUNT.pow_exp_max
|
||||||
|
p["powFunds"] = ACCOUNT.pow_funds
|
||||||
|
p["powFundsCap"] = ACCOUNT.pow_funds_cap
|
||||||
return p
|
return p
|
||||||
|
|
||||||
def _save(self):
|
def _save(self):
|
||||||
|
parent = os.path.dirname(self.path)
|
||||||
|
if parent:
|
||||||
|
os.makedirs(parent, exist_ok=True)
|
||||||
tmp = self.path + ".tmp"
|
tmp = self.path + ".tmp"
|
||||||
with open(tmp, "w") as f:
|
with open(tmp, "w") as f:
|
||||||
json.dump(self._p, f, indent=1)
|
json.dump(self._p, f, indent=1)
|
||||||
os.replace(tmp, self.path)
|
os.replace(tmp, self.path)
|
||||||
|
|
||||||
|
def select_account(self, persona_id):
|
||||||
|
"""Switch the single active session to its isolated persistent FUT save."""
|
||||||
|
with _LOCK:
|
||||||
|
self.path = profile_path_for(persona_id)
|
||||||
|
self._p = None
|
||||||
|
return self.load()
|
||||||
|
|
||||||
# ---- accessors used by utas_server -------------------------------------
|
# ---- accessors used by utas_server -------------------------------------
|
||||||
def profile(self):
|
def profile(self):
|
||||||
return self.load()
|
return self.load()
|
||||||
|
|
||||||
|
def ensure_security_question(self):
|
||||||
|
"""Persist OpenFUT's account-scoped compatibility state for the FUT gate.
|
||||||
|
|
||||||
|
FIFA 17 transforms any entered answer before sending it. OpenFUT does not
|
||||||
|
need that value to emulate a retired service, so neither the clear text nor
|
||||||
|
the transformed value is stored. The only durable fact is that this
|
||||||
|
OpenFUT profile has an initialized, verified compatibility record.
|
||||||
|
"""
|
||||||
|
expected = {"version": 1, "verified": True}
|
||||||
|
with _LOCK:
|
||||||
|
p = self.load()
|
||||||
|
if p.get("securityQuestion") != expected:
|
||||||
|
p["securityQuestion"] = dict(expected)
|
||||||
|
self._save()
|
||||||
|
return dict(p["securityQuestion"])
|
||||||
|
|
||||||
def refresh_identity(self):
|
def refresh_identity(self):
|
||||||
"""Re-mirror ACCOUNT into the save AND persist it.
|
"""Re-mirror ACCOUNT into the save AND persist it.
|
||||||
|
|
||||||
@@ -182,7 +477,13 @@ class Store:
|
|||||||
return self.load()["coins"]
|
return self.load()["coins"]
|
||||||
|
|
||||||
def items(self):
|
def items(self):
|
||||||
return self.load()["items"]
|
# Stamp discardValue on READ as well as on creation. _item() only covers cards
|
||||||
|
# minted from now on, and the save already holds 246 items built before the
|
||||||
|
# flag existed; without this the reveal screen would show real values while
|
||||||
|
# the club showed 0 for everything older. Stamped on the way out and NOT
|
||||||
|
# persisted, so the save stays clean and turning the flag off is a true revert.
|
||||||
|
its = self.load()["items"]
|
||||||
|
return [_with_discard(dict(it)) for it in its] if DISCARD_SEND else its
|
||||||
|
|
||||||
def add_items(self, new_items):
|
def add_items(self, new_items):
|
||||||
with _LOCK:
|
with _LOCK:
|
||||||
@@ -215,6 +516,15 @@ class Store:
|
|||||||
dv = it.get("discardValue") or 0
|
dv = it.get("discardValue") or 0
|
||||||
if dv:
|
if dv:
|
||||||
return int(dv)
|
return int(dv)
|
||||||
|
if DISCARD_TABLE:
|
||||||
|
# The real table. Matches what the client displays once
|
||||||
|
# FUT_DISCARD_SEND puts the value on the wire.
|
||||||
|
v = discard_value(it)
|
||||||
|
if v is not None:
|
||||||
|
return v
|
||||||
|
# else: unrated card, formula does not apply, fall through
|
||||||
|
# The invented tier. Wrong for every card, kept only as the live-proven
|
||||||
|
# default until FUT_DISCARD_TABLE has been in front of the game once.
|
||||||
r = it.get("rating") or 0
|
r = it.get("rating") or 0
|
||||||
return 600 if r >= 85 else 300 if r >= 80 else 150 if r >= 75 else 50
|
return 600 if r >= 85 else 300 if r >= 80 else 150 if r >= 75 else 50
|
||||||
with _LOCK:
|
with _LOCK:
|
||||||
@@ -299,14 +609,45 @@ class Store:
|
|||||||
return moved
|
return moved
|
||||||
|
|
||||||
def purchased(self):
|
def purchased(self):
|
||||||
|
# Stamped on read exactly like items(). Leaving this out was a real defect:
|
||||||
|
# the pending pile is the ONE place a quick-sell value is actually read, so
|
||||||
|
# the club showed real numbers while the reveal screen showed 0 for anything
|
||||||
|
# already sitting in the pile. Found by a verification pass, not by testing.
|
||||||
"""Items still held in the purchased/unassigned pile (returned by
|
"""Items still held in the purchased/unassigned pile (returned by
|
||||||
GET /purchased/items); they move to the club via FutMoveCard (PUT /item)."""
|
GET /purchased/items); they move to the club via FutMoveCard (PUT /item)."""
|
||||||
return self.load().get("purchased", [])
|
pur = self.load().get("purchased", [])
|
||||||
|
return [_with_discard(dict(it)) for it in pur] if DISCARD_SEND else pur
|
||||||
|
|
||||||
def active_squad(self):
|
def active_squad(self):
|
||||||
sq = self.load()["squads"]
|
sq = self.load()["squads"]
|
||||||
return sq[0] if sq else None
|
return sq[0] if sq else None
|
||||||
|
|
||||||
|
def unopened_packs(self):
|
||||||
|
"""Owned reward-pack template IDs, including repeated grants."""
|
||||||
|
return list(self.load().get("unopenedPackIds", []))
|
||||||
|
|
||||||
|
def consume_unopened_pack(self, pack_id):
|
||||||
|
"""Atomically consume one owned instance of a reward pack."""
|
||||||
|
with _LOCK:
|
||||||
|
p = self.load()
|
||||||
|
owned = p.setdefault("unopenedPackIds", [])
|
||||||
|
try:
|
||||||
|
owned.remove(pack_id)
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
self._save()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def grant_unopened_pack(self, pack_id):
|
||||||
|
"""Persist one additional owned reward-pack instance."""
|
||||||
|
if pack_by_id(pack_id) is None:
|
||||||
|
return False
|
||||||
|
with _LOCK:
|
||||||
|
p = self.load()
|
||||||
|
p.setdefault("unopenedPackIds", []).append(pack_id)
|
||||||
|
self._save()
|
||||||
|
return True
|
||||||
|
|
||||||
def reconstruct_squad(self, squad):
|
def reconstruct_squad(self, squad):
|
||||||
"""FIFA's updateActiveSquad PUT stores each slot as itemData={id:<clubItemId>}
|
"""FIFA's updateActiveSquad PUT stores each slot as itemData={id:<clubItemId>}
|
||||||
(a reference). Re-embed the FULL club item by id so the squad reloads with
|
(a reference). Re-embed the FULL club item by id so the squad reloads with
|
||||||
@@ -351,7 +692,8 @@ class Store:
|
|||||||
return i
|
return i
|
||||||
|
|
||||||
|
|
||||||
def open_pack(self, price, count, gold=True, tiers=None):
|
def open_pack(self, price, count, gold=True, tiers=None, special_chance=0.0,
|
||||||
|
players_only=False):
|
||||||
"""Deduct `price` coins, generate `count` player items from the pool, and
|
"""Deduct `price` coins, generate `count` player items from the pool, and
|
||||||
place them in the PENDING purchased pile (unassigned). They are NOT owned
|
place them in the PENDING purchased pile (unassigned). They are NOT owned
|
||||||
club items until moved there via FutMoveCard (PUT /item). Returns None if
|
club items until moved there via FutMoveCard (PUT /item). Returns None if
|
||||||
@@ -373,19 +715,31 @@ class Store:
|
|||||||
# fixed number so it scales from a 5-card bronze to an 11-card premium.
|
# fixed number so it scales from a 5-card bronze to an 11-card premium.
|
||||||
n_extra = 0
|
n_extra = 0
|
||||||
extras = []
|
extras = []
|
||||||
if PACK_MIX and count >= 5:
|
if PACK_MIX and not players_only and count >= 5:
|
||||||
n_extra = max(1, count // 4)
|
n_extra = max(1, count // 4)
|
||||||
extras = _pack_extras(n_extra, self)
|
extras = _pack_extras(n_extra, self)
|
||||||
n_extra = len(extras)
|
n_extra = len(extras)
|
||||||
n_players = max(1, count - n_extra)
|
n_players = max(1, count - n_extra)
|
||||||
if tiers:
|
if tiers:
|
||||||
picks = [random.choice(fut_cards.pool_for(random.choice(tiers)))
|
# Draw each tier independently but reject duplicate asset IDs inside
|
||||||
for _ in range(n_players)]
|
# one pack. The real pool is large enough that this normally succeeds
|
||||||
|
# on the first attempt; the cap makes malformed tiny test pools safe.
|
||||||
|
picks = []
|
||||||
|
used_assets = set()
|
||||||
|
for _ in range(n_players):
|
||||||
|
tier_pool = fut_cards.pool_for(random.choice(tiers))
|
||||||
|
available = [p for p in tier_pool if p[0] not in used_assets]
|
||||||
|
pick = random.choice(available or tier_pool)
|
||||||
|
picks.append(pick)
|
||||||
|
used_assets.add(pick[0])
|
||||||
else:
|
else:
|
||||||
pool = [p for p in PACK_POOL if (p[1] >= 75) == gold] or PACK_POOL
|
pool = [p for p in PACK_POOL if (p[1] >= 75) == gold] or PACK_POOL
|
||||||
picks = [random.choice(pool) for _ in range(n_players)]
|
picks = random.sample(pool, min(n_players, len(pool)))
|
||||||
items = [_item(self.new_item_id(), a, r, p, n, lg, tm, at)
|
while len(picks) < n_players:
|
||||||
for (a, r, p, n, lg, tm, at) in picks]
|
picks.append(random.choice(pool))
|
||||||
|
items = [player_item(self.new_item_id(), pick,
|
||||||
|
special=random.random() < special_chance)
|
||||||
|
for pick in picks]
|
||||||
items += extras
|
items += extras
|
||||||
random.shuffle(items)
|
random.shuffle(items)
|
||||||
with _LOCK:
|
with _LOCK:
|
||||||
@@ -396,7 +750,9 @@ class Store:
|
|||||||
return items
|
return items
|
||||||
|
|
||||||
def last_pack(self):
|
def last_pack(self):
|
||||||
return self.load().get("purchased", [])
|
# Same stamping as purchased(); this is the reveal-screen read path.
|
||||||
|
pur = self.load().get("purchased", [])
|
||||||
|
return [_with_discard(dict(it)) for it in pur] if DISCARD_SEND else pur
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -469,11 +825,17 @@ _LEGACY_POOL = STARTER_PLAYERS + [
|
|||||||
# no silver or bronze players at all, so all three packs were identical in practice.
|
# no silver or bronze players at all, so all three packs were identical in practice.
|
||||||
PACK_CATALOG = [
|
PACK_CATALOG = [
|
||||||
{"id": 1, "name": "Bronze Pack", "price": 400, "count": 5, "gold": False,
|
{"id": 1, "name": "Bronze Pack", "price": 400, "count": 5, "gold": False,
|
||||||
"tiers": ["bronze"] * 8 + ["silver"] * 2},
|
"tiers": ["bronze"] * 8 + ["silver"] * 2, "specialChance": 0.005},
|
||||||
{"id": 5, "name": "Gold Pack", "price": 5000, "count": 7, "gold": True,
|
{"id": 5, "name": "Gold Pack", "price": 5000, "count": 7, "gold": True,
|
||||||
"tiers": ["gold"] * 6 + ["silver"] * 4},
|
"tiers": ["gold"] * 6 + ["silver"] * 4, "specialChance": 0.03},
|
||||||
{"id": 6, "name": "Premium Gold", "price": 15000, "count": 11, "gold": True,
|
{"id": 6, "name": "Premium Gold", "price": 15000, "count": 11, "gold": True,
|
||||||
"tiers": ["gold"] * 9 + ["silver"] * 1},
|
"tiers": ["gold"] * 9 + ["silver"] * 1, "specialChance": 0.08},
|
||||||
|
{"id": 7, "name": "Special Players Pack", "price": 25000, "count": 11,
|
||||||
|
"gold": True, "tiers": ["gold"], "specialChance": 1.0,
|
||||||
|
"playersOnly": True},
|
||||||
|
{"id": 70, "name": "Reward Special Players Pack", "price": 0, "count": 11,
|
||||||
|
"gold": True, "tiers": ["gold"], "specialChance": 1.0,
|
||||||
|
"playersOnly": True, "ownedOnly": True},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Executable
+66
@@ -0,0 +1,66 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Read the FutDataManagerImpl UI gate bytes out of the LIVE FIFA 17 client.
|
||||||
|
|
||||||
|
Why this exists: on 2026-08-05 the /settings gate plan concluded that
|
||||||
|
IS_FRIENDLY_SEASON_ENABLED and IS_DRAFT_MODE_ENABLED had never been set true by
|
||||||
|
anything. Measured against the running client, both are 1, and have been all along.
|
||||||
|
The applier FUN_18011dc50 runs whether or not the configs array has content, and the
|
||||||
|
settings struct it is handed defaults these fields to 1. "Nothing populates the array"
|
||||||
|
is not "nothing writes the byte".
|
||||||
|
|
||||||
|
Read-only. Opens /proc/<pid>/mem O_RDONLY and preads. Nothing here can write.
|
||||||
|
|
||||||
|
Nothing is assumed:
|
||||||
|
* the pid is resolved by exact /proc/*/comm match, never hardcoded
|
||||||
|
* the CardsDLL base is read from /proc/<pid>/maps, never cached across launches
|
||||||
|
(Wine copies the sections into anonymous memory, so only the 4 KiB PE header is
|
||||||
|
file-backed and `grep CardsDLL maps` returns exactly ONE line, which is easy to
|
||||||
|
misread as "barely mapped")
|
||||||
|
* the slide is PROVEN against the FNV atom-hash prologue at 0x180180d00, read from
|
||||||
|
the on-disk PE, before any other address is trusted
|
||||||
|
* each gate byte displacement is DECODED from its accessor stub (0f b6 81 <disp32>,
|
||||||
|
movzx eax, byte [rcx+disp32]) rather than taken from a table
|
||||||
|
|
||||||
|
Requires the client to have reached Ultimate Team, since CardsDLL loads only then.
|
||||||
|
Usage: python3 gate_byte_probe.py
|
||||||
|
"""
|
||||||
|
import os, struct, sys
|
||||||
|
pid=None
|
||||||
|
for d in os.listdir('/proc'):
|
||||||
|
if d.isdigit():
|
||||||
|
try:
|
||||||
|
if open('/proc/%s/comm'%d).read().strip()=='FIFA17.exe': pid=int(d); break
|
||||||
|
except Exception: pass
|
||||||
|
assert pid, "not running"
|
||||||
|
print("pid", pid)
|
||||||
|
base=None
|
||||||
|
for ln in open('/proc/%d/maps'%pid):
|
||||||
|
if 'CardsDLL' in ln:
|
||||||
|
base=int(ln.split('-')[0],16); print("cardsdll map line:", ln.strip())
|
||||||
|
assert base
|
||||||
|
slide = base - 0x180000000
|
||||||
|
print("base %#x slide %#x" % (base, slide))
|
||||||
|
fd=os.open('/proc/%d/mem'%pid, os.O_RDONLY)
|
||||||
|
def rd(va,n): return os.pread(fd, n, va)
|
||||||
|
# control: FNV prologue, bytes taken from the on-disk PE
|
||||||
|
pe=open('/mnt/games/FIFA 17/CardsDLL_Win64_retail.dll','rb').read()
|
||||||
|
# .text rva 0x1000 rawptr 0x400
|
||||||
|
def f(va): return va-0x180000000-0x1000+0x400
|
||||||
|
ctl_disk=pe[f(0x180180d00):f(0x180180d00)+32]
|
||||||
|
ctl_live=rd(0x180180d00+slide,32)
|
||||||
|
print("CONTROL FNV", "MATCH" if ctl_disk==ctl_live else "MISMATCH", ctl_live.hex())
|
||||||
|
# model singleton
|
||||||
|
dat=0x1802e6398+slide
|
||||||
|
obj=struct.unpack('<Q', rd(dat,8))[0]
|
||||||
|
print("DAT_1802e6398 ->", hex(obj))
|
||||||
|
vt=struct.unpack('<Q', rd(obj,8))[0]
|
||||||
|
print("vtable live %#x static %#x" % (vt, vt-slide))
|
||||||
|
for off,name in [(0x2b0,'friendlySeasons'),(0x2c8,'draftMode'),(0x2e0,'packOpeningAnimation')]:
|
||||||
|
slot=struct.unpack('<Q', rd(vt+off,8))[0]
|
||||||
|
stub=rd(slot,8)
|
||||||
|
disp=struct.unpack('<I', stub[3:7])[0] if stub[:3]==b'\x0f\xb6\x81' else None
|
||||||
|
val=rd(obj+disp,1)[0] if disp is not None else None
|
||||||
|
print(" slot +%#x -> %#x stub=%s disp=%s value=%s" % (off, slot-slide, stub.hex(), hex(disp) if disp else None, val))
|
||||||
|
# unopenedPacks total
|
||||||
|
print("model+0x20950 =", struct.unpack('<I', rd(obj+0x20950,4))[0])
|
||||||
|
os.close(fd)
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Decode the running-sum atom ladders in /hub parser FUN_180139610 and name each
|
||||||
|
atom from docs/fut_atoms.tsv.
|
||||||
|
|
||||||
|
The dispatch is `sub ecx,d0 / sub ecx,d1 / .../ cmp ecx,dN`: the atom that each
|
||||||
|
branch handles is the CUMULATIVE sum of the deltas up to and including that step
|
||||||
|
(a jz after each sub tests atom==running_sum). Plus there are direct `cmp esi,imm`.
|
||||||
|
"""
|
||||||
|
import subprocess, re
|
||||||
|
|
||||||
|
DLL = "/tmp/fut/cardsdll.dll"
|
||||||
|
TSV = "/home/alex/Documents/OpenFUT/fifa17-recon/docs/fut_atoms.tsv"
|
||||||
|
FUNC, STOP = 0x180139610, 0x18013e600
|
||||||
|
|
||||||
|
atoms = {}
|
||||||
|
for line in open(TSV):
|
||||||
|
p = line.rstrip("\n").split("\t")
|
||||||
|
if len(p) >= 3:
|
||||||
|
try: atoms[int(p[1], 16)] = p[2]
|
||||||
|
except ValueError: pass
|
||||||
|
|
||||||
|
out = subprocess.check_output(
|
||||||
|
["objdump", "-d", "-M", "intel",
|
||||||
|
"--start-address=%#x" % FUNC, "--stop-address=%#x" % STOP, DLL], text=True)
|
||||||
|
|
||||||
|
# linear list of (addr, mnem, dest_reg, imm) for sub/cmp on 32-bit regs, stop at int3 pad
|
||||||
|
seq = []
|
||||||
|
int3 = 0
|
||||||
|
for ln in out.splitlines():
|
||||||
|
parts = ln.split("\t")
|
||||||
|
if len(parts) < 3:
|
||||||
|
continue
|
||||||
|
addr_s = parts[0].strip().rstrip(":")
|
||||||
|
try:
|
||||||
|
addr = int(addr_s, 16)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
instr = parts[2].strip()
|
||||||
|
bits = instr.split(None, 1)
|
||||||
|
mnem = bits[0]
|
||||||
|
ops = bits[1].strip() if len(bits) > 1 else ""
|
||||||
|
if mnem == "int3":
|
||||||
|
int3 += 1
|
||||||
|
if int3 >= 4: break
|
||||||
|
continue
|
||||||
|
int3 = 0
|
||||||
|
mo = re.match(r"(e?[a-d]x|e?si|e?di|e?bp|r\d+d?),\s*(0x[0-9a-f]+)$", ops)
|
||||||
|
if mnem in ("sub", "cmp") and mo:
|
||||||
|
seq.append((addr, mnem, mo.group(1), int(mo.group(2), 16)))
|
||||||
|
|
||||||
|
# walk ladders: consecutive sub/cmp on the SAME register form one ladder; the running
|
||||||
|
# sum at each element is the atom that element dispatches. A `cmp` closes the ladder.
|
||||||
|
found = {} # atom -> (addr, kind)
|
||||||
|
i = 0
|
||||||
|
while i < len(seq):
|
||||||
|
addr, mnem, reg, imm = seq[i]
|
||||||
|
# a ladder starts on a sub
|
||||||
|
if mnem == "sub":
|
||||||
|
run = 0
|
||||||
|
j = i
|
||||||
|
while j < len(seq) and seq[j][2] == reg and seq[j][1] in ("sub", "cmp"):
|
||||||
|
run += seq[j][3]
|
||||||
|
found.setdefault(run, (seq[j][0], "ladder"))
|
||||||
|
if seq[j][1] == "cmp":
|
||||||
|
j += 1
|
||||||
|
break
|
||||||
|
j += 1
|
||||||
|
i = j
|
||||||
|
else:
|
||||||
|
# a lone cmp reg,imm on an atom-holding reg is a direct atom test
|
||||||
|
if 0 < imm <= 0x400:
|
||||||
|
found.setdefault(imm, (addr, "direct"))
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
TOKENS = {0x1, 0x6, 0x7, 0x9, 0xa, 0xb, 0xc, 0xd} # SAX token enum, not atoms
|
||||||
|
print("Atoms dispatched by hub parser FUN_%#x:" % FUNC)
|
||||||
|
print("=" * 70)
|
||||||
|
for a in sorted(found):
|
||||||
|
if a in TOKENS:
|
||||||
|
continue
|
||||||
|
tag = " <-- TOKEN?" if a < 0x10 else ""
|
||||||
|
print(" %#06x %-28s (%s @ %#x)%s" %
|
||||||
|
(a, atoms.get(a, "?"), found[a][1], found[a][0], tag))
|
||||||
|
|
||||||
|
print("\nKnown tile counters for reference: 0x33=auctionCount, 0x90=clubPlayers")
|
||||||
|
print("\nName-based tile-count candidates:")
|
||||||
|
KEYS = ("sell","sold","trade","auction","pile","list","count","num","offer",
|
||||||
|
"won","outbid","target","watch","transfer","active","unassigned")
|
||||||
|
for a in sorted(found):
|
||||||
|
if a in TOKENS: continue
|
||||||
|
n = atoms.get(a, "").lower()
|
||||||
|
if any(k in n for k in KEYS):
|
||||||
|
print(" %#06x %s" % (a, atoms.get(a, "?")))
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
"""ADVERSARIAL Q1.
|
||||||
|
|
||||||
|
HYPOTHESIS UNDER ATTACK (dim1 claim 3): "FUN_1800150d0 ... finds-or-creates a group by
|
||||||
|
an exact string compare on displayGroup.value", i.e. wire-record +0x00 holds
|
||||||
|
displayGroup.value.
|
||||||
|
|
||||||
|
WHY IT IS NOT PROVEN: live we serve description == displayGroup.value == the SAME
|
||||||
|
STRING for all three packs ("Bronze Pack"/"Gold Pack"/"Premium Gold"), so the live
|
||||||
|
group caption cannot distinguish displayGroup.value (atom 0xd9->0x377) from
|
||||||
|
description (atom 0xd1). If the key is actually `description`, recommendation #2
|
||||||
|
(serve displayGroup.value="gold") silently does nothing.
|
||||||
|
|
||||||
|
METHOD: decompile the 0x158 wire-record element deserializer 0x18013af30 IN FULL,
|
||||||
|
print len(src), and enumerate the atom dispatch. Explicitly search the raw
|
||||||
|
disassembly of the function for EVERY syntactic dispatch form the brief warns about:
|
||||||
|
== imm, != imm, switch case labels (jump table), and sub/dec ladders.
|
||||||
|
CONTROL: atom 0x20f (packType) is known-present (live pack model +0x38 = "BRONZE"),
|
||||||
|
so whatever form finds packType must also be applied to 0xd1/0xd9/0xda/0x2cb.
|
||||||
|
The control uses the SAME method (raw immediate scan over the same instruction
|
||||||
|
range), not a different one.
|
||||||
|
"""
|
||||||
|
import sys, traceback, re
|
||||||
|
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/store/adv/q1_out.txt"
|
||||||
|
try:
|
||||||
|
fh = open(OUT, "w")
|
||||||
|
def P(*a):
|
||||||
|
s = " ".join(str(x) for x in a)
|
||||||
|
print(s); fh.write(s + "\n")
|
||||||
|
|
||||||
|
ATOMS = {0x23:"assetId",0xd1:"description",0xd9:"displayGroup",0xda:"displayGroupAssetId",
|
||||||
|
0xdb:"displayGroupUseDefaultImage",0x15c:"id",0x20f:"packType",0x250:"priority",
|
||||||
|
0x2cb:"sortPriority",0x377:"value",0x36a:"useDefaultImage",0x260:"purchase"}
|
||||||
|
|
||||||
|
for target in (0x18013af30,):
|
||||||
|
f = func(target)
|
||||||
|
P("=== FUNCTION %s @ %#x body=%s ===" % (f.getName(), int(f.getEntryPoint().getOffset()), f.getBody()))
|
||||||
|
src = dec(target, 300)
|
||||||
|
P("len(src) =", len(src))
|
||||||
|
P("---- FULL DECOMPILE BEGIN ----")
|
||||||
|
P(src)
|
||||||
|
P("---- FULL DECOMPILE END ----")
|
||||||
|
|
||||||
|
# raw instruction scan of the whole function body for every atom immediate
|
||||||
|
P()
|
||||||
|
P("=== RAW INSTRUCTION SCAN over FUN_18013af30 body: all forms ===")
|
||||||
|
f = func(0x18013af30)
|
||||||
|
body = f.getBody()
|
||||||
|
it = listing.getInstructions(body, True)
|
||||||
|
ins = []
|
||||||
|
while it.hasNext():
|
||||||
|
i = it.next()
|
||||||
|
ins.append((int(i.getAddress().getOffset()), str(i.getMnemonicString()), str(i)))
|
||||||
|
P("instruction count:", len(ins))
|
||||||
|
# collect all immediates appearing anywhere in the text form
|
||||||
|
found = {}
|
||||||
|
for a, mn, txt in ins:
|
||||||
|
for m in re.finditer(r'0x([0-9a-fA-F]+)', txt):
|
||||||
|
v = int(m.group(1), 16)
|
||||||
|
if v in ATOMS:
|
||||||
|
found.setdefault(v, []).append((a, mn, txt))
|
||||||
|
for v in sorted(ATOMS):
|
||||||
|
lst = found.get(v, [])
|
||||||
|
P("atom %#05x %-28s hits=%d" % (v, ATOMS[v], len(lst)))
|
||||||
|
for a, mn, txt in lst:
|
||||||
|
P(" %#x %s" % (a, txt))
|
||||||
|
# dispatch-form census: CMP/SUB/DEC ladders on the atom register
|
||||||
|
P()
|
||||||
|
P("=== dispatch-form census (CMP/SUB/DEC/SWITCH inside the function) ===")
|
||||||
|
forms = {"CMP":0,"SUB":0,"DEC":0,"JMP":0,"SWITCH":0}
|
||||||
|
for a, mn, txt in ins:
|
||||||
|
if mn in forms: forms[mn]+=1
|
||||||
|
if mn == "JMP" and "[" in txt: forms["SWITCH"]+=1
|
||||||
|
P(forms)
|
||||||
|
P("all CMP with a small immediate (candidate atom compares):")
|
||||||
|
for a, mn, txt in ins:
|
||||||
|
if mn in ("CMP","SUB","DEC","ADD") :
|
||||||
|
m = re.search(r'0x([0-9a-fA-F]{1,4})\s*$', txt)
|
||||||
|
if m:
|
||||||
|
v=int(m.group(1),16)
|
||||||
|
if 0x10 <= v <= 0x400:
|
||||||
|
P(" %#x %-8s %s -> imm %#x %s" % (a, mn, txt, v, ATOMS.get(v,"")))
|
||||||
|
fh.close()
|
||||||
|
except Exception:
|
||||||
|
traceback.print_exc()
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
"""ADVERSARIAL Q2. Batch.
|
||||||
|
|
||||||
|
Targets under attack:
|
||||||
|
(a) dim1 claim 4: "FUN_1800147f0 ... a miss returns NULL and the caller then
|
||||||
|
dereferences address 0x40, i.e. it would crash" -- ABSENCE OF A NULL CHECK.
|
||||||
|
Method: print the RAW DISASSEMBLY of FUN_1800147f0 from the CALL to
|
||||||
|
FUN_180014420 to the next 40 instructions, so a TEST/JZ is visible if present.
|
||||||
|
Control: the same raw-listing method applied to FUN_180014380's call sites,
|
||||||
|
where the decompiler DOES show a null test, must show TEST/JZ. Same form.
|
||||||
|
(b) dim1 claim 5: "+0x290 is written in exactly TWO places in all of CardsDLL".
|
||||||
|
objdump found 12 dword/qword writes at +0x290 plus one QWORD write at +0x28c
|
||||||
|
that covers it. Resolve the containing function of every one and decide.
|
||||||
|
(c) dim1 claim 9/10: model+0x94 = group ordinal, model+0x1a0 = sortPriority;
|
||||||
|
+0x1a0 pushed to no Flash field. Print FUN_18002c3c0 and FUN_180015d80 in full
|
||||||
|
and print their exact address ranges so the claim can be re-checked in objdump.
|
||||||
|
(d) dim1 claim 3: FUN_1800150d0 / FUN_180012950 / FUN_180014380 full.
|
||||||
|
(e) dim1 claim 7: FUN_180014580 / FUN_180014df0 six literals; enumerate.
|
||||||
|
(f) FUN_180014610 group-tile builder: does tile+0x9c really get the ordinal
|
||||||
|
(CHILD_CATEGORY) and tile+0xac the displayGroupAssetId? Recommendation #1
|
||||||
|
depends entirely on this.
|
||||||
|
"""
|
||||||
|
import sys, traceback, re
|
||||||
|
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/store/adv/q2_out.txt"
|
||||||
|
try:
|
||||||
|
fh = open(OUT, "w")
|
||||||
|
def P(*a):
|
||||||
|
s = " ".join(str(x) for x in a)
|
||||||
|
print(s); fh.write(s + "\n")
|
||||||
|
|
||||||
|
TARGETS = [0x1800150d0, 0x180012950, 0x180014380, 0x180014420, 0x1800147f0,
|
||||||
|
0x180014610, 0x18002c3c0, 0x180015d80, 0x180014580, 0x180014df0,
|
||||||
|
0x18007e7f0, 0x18007d1a0, 0x18007dab0]
|
||||||
|
P("=== FUNCTION BOUNDS ===")
|
||||||
|
for t in TARGETS:
|
||||||
|
f = func(t)
|
||||||
|
if f is None:
|
||||||
|
P("%#x -> NO FUNCTION" % t); continue
|
||||||
|
P("%#x %-22s min=%#x max=%#x size=%#x" % (t, f.getName(),
|
||||||
|
int(f.getBody().getMinAddress().getOffset()),
|
||||||
|
int(f.getBody().getMaxAddress().getOffset()),
|
||||||
|
int(f.getBody().getNumAddresses())))
|
||||||
|
|
||||||
|
# (b) resolve containing functions of every +0x290 write objdump found
|
||||||
|
P()
|
||||||
|
P("=== (b) containing functions of every raw +0x290 / +0x28c write ===")
|
||||||
|
W = [0x180051da3,0x18007d3ba,0x18007f0c0,0x18008c777,0x18008fd45,0x1800d3564,
|
||||||
|
0x1800d43fc,0x18013454a,0x180189d84,0x18018caa3,0x18018e1ff,0x180191f77,
|
||||||
|
0x18015b885,0x180067eb0,0x180067ebf]
|
||||||
|
for w in W:
|
||||||
|
f = func(w)
|
||||||
|
P(" %#x -> %s @ %#x" % (w, f.getName() if f else "NONE",
|
||||||
|
int(f.getEntryPoint().getOffset()) if f else 0))
|
||||||
|
# is any of those functions in the store-screen vtable?
|
||||||
|
P()
|
||||||
|
P("=== store screen vtable 0x1801ff690 (first 48 slots) ===")
|
||||||
|
ents = set()
|
||||||
|
for off, tgt, nm in vtable(0x1801ff690, 48):
|
||||||
|
P(" +%#04x %#x %s" % (off, tgt, nm))
|
||||||
|
ents.add(tgt)
|
||||||
|
P("vtable also at 0x1801ff6f8 / 0x1801ff610 per the claim; dumping 0x1801ff610:")
|
||||||
|
for off, tgt, nm in vtable(0x1801ff610, 24):
|
||||||
|
P(" +%#04x %#x %s" % (off, tgt, nm))
|
||||||
|
|
||||||
|
# (a) raw disassembly around the FUN_180014420 call inside FUN_1800147f0
|
||||||
|
P()
|
||||||
|
P("=== (a) RAW LISTING of FUN_1800147f0 (whole function) ===")
|
||||||
|
f = func(0x1800147f0)
|
||||||
|
it = listing.getInstructions(f.getBody(), True)
|
||||||
|
n = 0
|
||||||
|
while it.hasNext():
|
||||||
|
i = it.next(); n += 1
|
||||||
|
P(" %#x %s" % (int(i.getAddress().getOffset()), str(i)))
|
||||||
|
P("instruction count:", n)
|
||||||
|
|
||||||
|
P()
|
||||||
|
P("=== (a-control) RAW LISTING of FUN_180014610 (whole function) ===")
|
||||||
|
f = func(0x180014610)
|
||||||
|
it = listing.getInstructions(f.getBody(), True)
|
||||||
|
n = 0
|
||||||
|
while it.hasNext():
|
||||||
|
i = it.next(); n += 1
|
||||||
|
P(" %#x %s" % (int(i.getAddress().getOffset()), str(i)))
|
||||||
|
P("instruction count:", n)
|
||||||
|
|
||||||
|
for t in TARGETS:
|
||||||
|
P()
|
||||||
|
f = func(t)
|
||||||
|
P("======== DECOMPILE %s @ %#x ========" % (f.getName() if f else "?", t))
|
||||||
|
src = dec(t, 300)
|
||||||
|
P("len(src) =", len(src))
|
||||||
|
P(src)
|
||||||
|
P("======== END %#x ========" % t)
|
||||||
|
fh.close()
|
||||||
|
except Exception:
|
||||||
|
traceback.print_exc()
|
||||||
|
try: fh.close()
|
||||||
|
except Exception: pass
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
"""ADVERSARIAL Q3.
|
||||||
|
|
||||||
|
Attacking dim1 claim 10: "sortPriority is inert at the UI. It reaches pack+0x1a0 and is
|
||||||
|
pushed to no Flash field ... Both are dead ends for this bug."
|
||||||
|
An objdump scan of the store cluster found 0x1800108cd/0x1800108d3
|
||||||
|
mov eax,[rsi+0x1a0] ; cmp [rbx+0x1a0],eax
|
||||||
|
which is the shape of a SORT COMPARATOR on two 0x1a8 models, and 0x18002cc62
|
||||||
|
mov [rbx+0x1a0],esi
|
||||||
|
inside FUN_18002cc90, which FUN_18002c3c0 tail-calls AFTER setting +0x1a0 = sortPriority.
|
||||||
|
Both were missed by "grep the push list".
|
||||||
|
|
||||||
|
Also decompile:
|
||||||
|
FUN_18002c8b0 -- the per-group filter in FUN_180014610; if it can HIDE a group the
|
||||||
|
tile ordinals the user sees stop matching the group ordinals.
|
||||||
|
FUN_18007e5e0 / FUN_18007df60 -- the six-panel binding (dim1 claim 7).
|
||||||
|
FUN_18007e7f0 cases 0x7551 / 0x753f -- the CATEGORY_ID round trip.
|
||||||
|
callers of FUN_1800147f0.
|
||||||
|
"""
|
||||||
|
import sys, traceback
|
||||||
|
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/store/adv/q3_out.txt"
|
||||||
|
try:
|
||||||
|
fh = open(OUT, "w")
|
||||||
|
def P(*a):
|
||||||
|
s = " ".join(str(x) for x in a)
|
||||||
|
print(s); fh.write(s + "\n")
|
||||||
|
|
||||||
|
for a in (0x1800108cd, 0x18002cc62, 0x180010b5c, 0x180011c2c):
|
||||||
|
f = func(a)
|
||||||
|
P("%#x -> %s @ %#x size=%#x" % (a, f.getName() if f else "NONE",
|
||||||
|
int(f.getEntryPoint().getOffset()) if f else 0,
|
||||||
|
int(f.getBody().getNumAddresses()) if f else 0))
|
||||||
|
|
||||||
|
P()
|
||||||
|
P("=== callers of FUN_1800147f0 ===")
|
||||||
|
for frm, typ, fn, ent in xrefs_to(0x1800147f0):
|
||||||
|
P(" from %#x %s in %s @ %#x" % (frm, typ, fn, ent))
|
||||||
|
P("=== callers of FUN_180014610 ===")
|
||||||
|
for frm, typ, fn, ent in xrefs_to(0x180014610):
|
||||||
|
P(" from %#x %s in %s @ %#x" % (frm, typ, fn, ent))
|
||||||
|
P("=== callers of FUN_18002c8b0 ===")
|
||||||
|
for frm, typ, fn, ent in xrefs_to(0x18002c8b0):
|
||||||
|
P(" from %#x %s in %s @ %#x" % (frm, typ, fn, ent))
|
||||||
|
P("=== callers of the comparator's containing function ===")
|
||||||
|
cf = func(0x1800108cd)
|
||||||
|
if cf:
|
||||||
|
for frm, typ, fn, ent in xrefs_to(int(cf.getEntryPoint().getOffset())):
|
||||||
|
P(" from %#x %s in %s @ %#x" % (frm, typ, fn, ent))
|
||||||
|
|
||||||
|
tg = []
|
||||||
|
if cf: tg.append(int(cf.getEntryPoint().getOffset()))
|
||||||
|
tg += [0x18002cc90, 0x18002c8b0, 0x18007e5e0, 0x18007df60, 0x180014b60]
|
||||||
|
for t in tg:
|
||||||
|
f = func(t)
|
||||||
|
P()
|
||||||
|
P("======== DECOMPILE %s @ %#x ========" % (f.getName() if f else "?", t))
|
||||||
|
src = dec(t, 300)
|
||||||
|
P("len(src) =", len(src))
|
||||||
|
P(src)
|
||||||
|
P("======== END %#x ========" % t)
|
||||||
|
|
||||||
|
# full FUN_18007e7f0 (big) -- print only, it is the CATEGORY_ID round trip
|
||||||
|
P()
|
||||||
|
P("======== DECOMPILE FUN_18007e7f0 (full) ========")
|
||||||
|
src = dec(0x18007e7f0, 600)
|
||||||
|
P("len(src) =", len(src))
|
||||||
|
P(src)
|
||||||
|
P("======== END ========")
|
||||||
|
fh.close()
|
||||||
|
except Exception:
|
||||||
|
traceback.print_exc()
|
||||||
|
try: fh.close()
|
||||||
|
except Exception: pass
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"""ADVERSARIAL Q4. Where is the +0x1a0 (sortPriority) merge sort actually used, and
|
||||||
|
what does the 0x1a8 ctor leave in +0x1a0 / +0x94 for GROUP TILES (FUN_180014610 sets
|
||||||
|
neither)? Also FUN_180012950 and FUN_180014380 in full for the group-key claim."""
|
||||||
|
import sys, traceback
|
||||||
|
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/store/adv/q4_out.txt"
|
||||||
|
try:
|
||||||
|
fh = open(OUT, "w")
|
||||||
|
def P(*a):
|
||||||
|
s = " ".join(str(x) for x in a)
|
||||||
|
print(s); fh.write(s + "\n")
|
||||||
|
P("=== callers of FUN_180010cd0 (the merge-sort driver over +0x1a0) ===")
|
||||||
|
for frm, typ, fn, ent in xrefs_to(0x180010cd0):
|
||||||
|
P(" from %#x %s in %s @ %#x" % (frm, typ, fn, ent))
|
||||||
|
P("=== callers of FUN_180010890 ===")
|
||||||
|
for frm, typ, fn, ent in xrefs_to(0x180010890):
|
||||||
|
P(" from %#x %s in %s @ %#x" % (frm, typ, fn, ent))
|
||||||
|
for t in (0x1800130c0, 0x180012950, 0x180014380, 0x180010cd0):
|
||||||
|
f = func(t)
|
||||||
|
P()
|
||||||
|
P("======== DECOMPILE %s @ %#x ========" % (f.getName() if f else "?", t))
|
||||||
|
src = dec(t, 300)
|
||||||
|
P("len(src) =", len(src))
|
||||||
|
P(src)
|
||||||
|
P("======== END %#x ========" % t)
|
||||||
|
fh.close()
|
||||||
|
except Exception:
|
||||||
|
traceback.print_exc()
|
||||||
|
try: fh.close()
|
||||||
|
except Exception: pass
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
"""ADVERSARIAL Q5. The sortPriority merge sort has exactly one entry point
|
||||||
|
(0x180016f81 -> FUN_180010bc0). Identify its containing function, what list it sorts,
|
||||||
|
and who calls it. Also print FUN_1800130c0 in full to see whether +0x1a0 / +0x94 are
|
||||||
|
initialised at all for group tiles (FUN_180014610 sets neither)."""
|
||||||
|
import sys, traceback
|
||||||
|
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/store/adv/q5_out.txt"
|
||||||
|
try:
|
||||||
|
fh = open(OUT, "w")
|
||||||
|
def P(*a):
|
||||||
|
s = " ".join(str(x) for x in a)
|
||||||
|
print(s); fh.write(s + "\n")
|
||||||
|
f = func(0x180016f81)
|
||||||
|
P("0x180016f81 is inside %s @ %#x size=%#x" % (f.getName(), int(f.getEntryPoint().getOffset()),
|
||||||
|
int(f.getBody().getNumAddresses())))
|
||||||
|
ent = int(f.getEntryPoint().getOffset())
|
||||||
|
P("=== callers of %s ===" % f.getName())
|
||||||
|
for frm, typ, fn, e in xrefs_to(ent):
|
||||||
|
P(" from %#x %s in %s @ %#x" % (frm, typ, fn, e))
|
||||||
|
for t in (ent, 0x1800130c0):
|
||||||
|
g = func(t)
|
||||||
|
P()
|
||||||
|
P("======== DECOMPILE %s @ %#x ========" % (g.getName(), t))
|
||||||
|
src = dec(t, 300)
|
||||||
|
P("len(src) =", len(src)); P(src)
|
||||||
|
P("======== END %#x ========" % t)
|
||||||
|
fh.close()
|
||||||
|
except Exception:
|
||||||
|
traceback.print_exc()
|
||||||
|
try: fh.close()
|
||||||
|
except Exception: pass
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
"""ADVERSARIAL BATCH 1.
|
||||||
|
|
||||||
|
HYPOTHESES UNDER ATTACK (all from another agent, assumed WRONG until reproduced):
|
||||||
|
H1 item+0x49 = (untradeable == false), written by atom 0x361 in FUN_18013fe00.
|
||||||
|
H2 FUN_1801a7260 (TO_TRADE_PILE) requires item+0x49 != 0, and the eight flags are
|
||||||
|
ENABLE flags.
|
||||||
|
H3 FUN_18003e370 publishes 8 names in the order DISCARD, MODIFY, TO_ACTIVE_SQUAD,
|
||||||
|
TO_TRADE_PILE, ... and FUN_1800e2a40 fills those 8 bytes in that order.
|
||||||
|
H4 item+0x54 is the discard LEVEL written at 0x180141e8a..0x180141ea3, not itemType.
|
||||||
|
H5 the itemState table starts at 0x180229cc0 with 12 entries.
|
||||||
|
H6 FUN_180166660 has exactly one caller.
|
||||||
|
H7 FUN_1801a8620 (+0x38) and FUN_1801a8090 (+0x3c) have exactly one xref each.
|
||||||
|
|
||||||
|
CONTROL: for every "exactly one caller" claim I also run the SAME xrefs_to call on a
|
||||||
|
function that is known to have many callers (FUN_180135ff0, the value-SKIP, ~134) and
|
||||||
|
on the FNV hasher 0x180180d00, so a zero/one result cannot be a broken scan.
|
||||||
|
Everything is printed IN FULL; no truncation.
|
||||||
|
"""
|
||||||
|
import traceback, sys
|
||||||
|
|
||||||
|
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/adv2/q1_raw.txt"
|
||||||
|
try:
|
||||||
|
f = open(OUT, "w")
|
||||||
|
|
||||||
|
def P(*a):
|
||||||
|
s = " ".join(str(x) for x in a)
|
||||||
|
f.write(s + "\n")
|
||||||
|
|
||||||
|
P("=" * 30, "CONTROL: xrefs machinery works", "=" * 30)
|
||||||
|
for nm, a in (("FUN_180135ff0 value-SKIP", 0x180135FF0),
|
||||||
|
("FUN_180180d00 FNV hasher", 0x180180D00),
|
||||||
|
("FUN_1801c7620 BOOL prim", 0x1801C7620)):
|
||||||
|
xr = xrefs_to(a)
|
||||||
|
ents = sorted(set(e for _, t, _, e in xr if "CALL" in t and e))
|
||||||
|
P("%s: %d refs, %d distinct calling funcs" % (nm, len(xr), len(ents)))
|
||||||
|
|
||||||
|
P()
|
||||||
|
P("=" * 30, "H7 discard getters", "=" * 30)
|
||||||
|
for nm, a in (("FUN_1801a8620 (+0x38 DISCARD_CREDITS?)", 0x1801A8620),
|
||||||
|
("FUN_1801a8090 (+0x3c CALCULATED?)", 0x1801A8090),
|
||||||
|
("FUN_1801a80c0 (CARD_LEVEL?)", 0x1801A80C0)):
|
||||||
|
P("---", nm)
|
||||||
|
fn = fm.getFunctionAt(addr(a))
|
||||||
|
P(" function at addr:", fn.getName() if fn else None)
|
||||||
|
for frm, t, cf, e in xrefs_to(a):
|
||||||
|
P(" ref %#x %s in %s@%#x" % (frm, t, cf, e))
|
||||||
|
P(" BODY:")
|
||||||
|
P(dec(a))
|
||||||
|
|
||||||
|
P()
|
||||||
|
P("=" * 30, "H6 FUN_180166660 callers", "=" * 30)
|
||||||
|
for frm, t, cf, e in xrefs_to(0x180166660):
|
||||||
|
P(" ref %#x %s in %s@%#x" % (frm, t, cf, e))
|
||||||
|
P(dec(0x180166660))
|
||||||
|
|
||||||
|
P()
|
||||||
|
P("=" * 30, "H5 itemState table walk from 0x180229c00", "=" * 30)
|
||||||
|
a = 0x180229C00
|
||||||
|
for i in range(40):
|
||||||
|
p = qword(a + i * 0x10)
|
||||||
|
q = qword(a + i * 0x10 + 8)
|
||||||
|
s = ""
|
||||||
|
if 0x180000000 <= p < 0x181000000:
|
||||||
|
try:
|
||||||
|
s = rd_str(p, 60)
|
||||||
|
except Exception:
|
||||||
|
s = "?"
|
||||||
|
P(" %#x p=%#018x q=%#018x %r" % (a + i * 0x10, p, q, s))
|
||||||
|
|
||||||
|
P()
|
||||||
|
P("=" * 30, "H2 TO_TRADE_PILE predicate + siblings", "=" * 30)
|
||||||
|
for a in (0x1801A7260, 0x1801A8940, 0x1801A71C0, 0x1801A7210, 0x1801A7250,
|
||||||
|
0x1801A7180, 0x1801A7320, 0x1801A71E0, 0x1801A8900, 0x1801A89F0):
|
||||||
|
fn = fm.getFunctionAt(addr(a))
|
||||||
|
P("### %#x %s xrefs=%d" % (a, fn.getName() if fn else "NO FUNC", len(xrefs_to(a))))
|
||||||
|
for frm, t, cf, e in xrefs_to(a):
|
||||||
|
P(" ref %#x %s in %s@%#x" % (frm, t, cf, e))
|
||||||
|
P(dec(a))
|
||||||
|
P()
|
||||||
|
|
||||||
|
P()
|
||||||
|
P("=" * 30, "H3 publisher + filler, FULL", "=" * 30)
|
||||||
|
for a in (0x18003E370, 0x1800E2A40):
|
||||||
|
P("### %#x len-of-decompile follows" % a)
|
||||||
|
d = dec(a)
|
||||||
|
P(" len(src) =", len(d))
|
||||||
|
P(d)
|
||||||
|
P()
|
||||||
|
|
||||||
|
P()
|
||||||
|
P("=" * 30, "H4 level write at 0x180141e60..0x180141ec0 raw disasm", "=" * 30)
|
||||||
|
ins = listing.getInstructions(addr(0x180141E40), True)
|
||||||
|
n = 0
|
||||||
|
while ins.hasNext() and n < 60:
|
||||||
|
i = ins.next()
|
||||||
|
if int(i.getAddress().getOffset()) > 0x180141EC0:
|
||||||
|
break
|
||||||
|
P(" %#x %s" % (int(i.getAddress().getOffset()), i))
|
||||||
|
n += 1
|
||||||
|
|
||||||
|
f.close()
|
||||||
|
print("WROTE", OUT)
|
||||||
|
except Exception:
|
||||||
|
traceback.print_exc()
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
"""BATCH 10: disassemble the undefined thunk at 0x18011c670 (slot +0x270 of the
|
||||||
|
0xed84b12 service = the second gate on TO_TRADE_PILE)."""
|
||||||
|
import traceback
|
||||||
|
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/adv2/q10_raw.txt"
|
||||||
|
try:
|
||||||
|
f = open(OUT, "w")
|
||||||
|
def P(*a): f.write(" ".join(str(x) for x in a) + "\n")
|
||||||
|
P("bytes at 0x18011c670:", read_bytes(0x18011C670, 64).hex())
|
||||||
|
it = listing.getInstructions(addr(0x18011C670), True)
|
||||||
|
n = 0
|
||||||
|
while it.hasNext() and n < 40:
|
||||||
|
i = it.next(); a = int(i.getAddress().getOffset())
|
||||||
|
if a > 0x18011C6F0: break
|
||||||
|
P(" %#x %s" % (a, i)); n += 1
|
||||||
|
P()
|
||||||
|
for t in (0x18011C4C0, 0x18011C500):
|
||||||
|
P("### %#x" % t); P(dec(t)); P()
|
||||||
|
f.close(); print("WROTE", OUT)
|
||||||
|
except Exception:
|
||||||
|
traceback.print_exc()
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
"""ADVERSARIAL BATCH 2 -- the ABSENCE claims, re-tested with a DIFFERENT method.
|
||||||
|
|
||||||
|
The other agent tested "+0x49 is compared in exactly two places" and "itemState 5/6
|
||||||
|
are never tested" with a LOAD/COMPARE-PAIR scan keyed on displacement. That method
|
||||||
|
has a structural blind spot: a compare performed on a value RETURNED BY AN ACCESSOR
|
||||||
|
never shows the displacement at the compare site. FUN_1801a8940 is exactly such an
|
||||||
|
accessor for +0x49 and it has a caller (FUN_1800bc580) the agent never opened.
|
||||||
|
|
||||||
|
MY METHOD (different): enumerate EVERY instruction in .text whose textual form
|
||||||
|
contains the displacement, with no filter on opcode class at all -- so ==, !=, switch
|
||||||
|
case labels and sub/dec ladders are all caught at the LOAD, and the containing
|
||||||
|
function is then read. Plus a byte-pattern census of the two-instruction accessor
|
||||||
|
shape 48 8b 4x 18 / <load disp> which finds getters my displacement scan would
|
||||||
|
attribute to the getter rather than to its caller.
|
||||||
|
|
||||||
|
CONTROLS (same syntactic form as the targets -- a raw displacement load):
|
||||||
|
0x38 and 0x3c : known-live fields, must come back non-zero
|
||||||
|
0x4c : the other agent reported 37 pairs, must come back >= 37
|
||||||
|
0xdeadbe : impossible displacement, must come back 0 (proves the scan can
|
||||||
|
return zero for a real absence rather than always finding noise)
|
||||||
|
"""
|
||||||
|
import re, traceback
|
||||||
|
|
||||||
|
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/adv2/q2_raw.txt"
|
||||||
|
try:
|
||||||
|
f = open(OUT, "w")
|
||||||
|
|
||||||
|
def P(*a):
|
||||||
|
f.write(" ".join(str(x) for x in a) + "\n")
|
||||||
|
|
||||||
|
TARGETS = [0x38, 0x3c, 0x48, 0x49, 0x4c, 0x54, 0x58, 0x5c, 0x60, 0x88, 0x90]
|
||||||
|
pats = {d: re.compile(r"\+\s*0x%x\s*\]" % d) for d in TARGETS}
|
||||||
|
impossible = re.compile(r"\+\s*0xdeadbe\s*\]")
|
||||||
|
|
||||||
|
hits = {d: [] for d in TARGETS}
|
||||||
|
imp = []
|
||||||
|
n = 0
|
||||||
|
it = listing.getInstructions(True)
|
||||||
|
while it.hasNext():
|
||||||
|
i = it.next()
|
||||||
|
s = i.toString()
|
||||||
|
n += 1
|
||||||
|
for d, p in pats.items():
|
||||||
|
if p.search(s):
|
||||||
|
hits[d].append((int(i.getAddress().getOffset()), s))
|
||||||
|
if impossible.search(s):
|
||||||
|
imp.append(int(i.getAddress().getOffset()))
|
||||||
|
P("instructions scanned:", n)
|
||||||
|
P("IMPOSSIBLE-DISPLACEMENT CONTROL 0xdeadbe hits:", len(imp), "(must be 0)")
|
||||||
|
P()
|
||||||
|
for d in TARGETS:
|
||||||
|
fns = {}
|
||||||
|
for a, s in hits[d]:
|
||||||
|
fn = fm.getFunctionContaining(addr(a))
|
||||||
|
k = (fn.getName(), int(fn.getEntryPoint().getOffset())) if fn else ("?", 0)
|
||||||
|
fns.setdefault(k, []).append((a, s))
|
||||||
|
P("### displacement +0x%02x : %d instructions in %d functions" % (d, len(hits[d]), len(fns)))
|
||||||
|
if d in (0x49, 0x48):
|
||||||
|
for (nm, e), lst in sorted(fns.items(), key=lambda x: x[0][1]):
|
||||||
|
P(" %s @%#x (%d)" % (nm, e, len(lst)))
|
||||||
|
for a, s in lst:
|
||||||
|
P(" %#x %s" % (a, s))
|
||||||
|
elif d == 0x5c:
|
||||||
|
P(" functions:")
|
||||||
|
for (nm, e), lst in sorted(fns.items(), key=lambda x: x[0][1]):
|
||||||
|
P(" %s @%#x n=%d" % (nm, e, len(lst)))
|
||||||
|
P()
|
||||||
|
|
||||||
|
P("=" * 30, "+0x5c FULL instruction list (itemState 5/6 absence retest)", "=" * 30)
|
||||||
|
for a, s in hits[0x5C]:
|
||||||
|
fn = fm.getFunctionContaining(addr(a))
|
||||||
|
P(" %#x %-52s %s" % (a, s, fn.getName() if fn else "?"))
|
||||||
|
P()
|
||||||
|
|
||||||
|
P("=" * 30, "ACCESSOR CENSUS: byte pattern 48 8b 4x 18 followed by a load", "=" * 30)
|
||||||
|
seen = {}
|
||||||
|
for reg in (0x41, 0x51, 0x49, 0x59, 0x71, 0x79):
|
||||||
|
pat = bytes([0x48, 0x8B, reg, 0x18])
|
||||||
|
for a in find_all(pat, blocks=(".text",)):
|
||||||
|
try:
|
||||||
|
nxt = read_bytes(a + 4, 8)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
seen.setdefault(a, nxt)
|
||||||
|
P("call-shape candidates:", len(seen))
|
||||||
|
interest = {}
|
||||||
|
for a, nxt in seen.items():
|
||||||
|
disp = None
|
||||||
|
if nxt[0] == 0x8B and (nxt[1] & 0xC0) == 0x40:
|
||||||
|
disp = nxt[2]
|
||||||
|
elif nxt[0] == 0x0F and nxt[1] in (0xB6, 0xB7) and (nxt[2] & 0xC0) == 0x40:
|
||||||
|
disp = nxt[3]
|
||||||
|
elif nxt[0] == 0x83 and (nxt[1] & 0xC0) == 0x40:
|
||||||
|
disp = nxt[2]
|
||||||
|
elif nxt[0] == 0x8A and (nxt[1] & 0xC0) == 0x40:
|
||||||
|
disp = nxt[2]
|
||||||
|
if disp in (0x38, 0x3C, 0x48, 0x49, 0x4C, 0x54, 0x58, 0x5C, 0x60, 0x88, 0x90):
|
||||||
|
fn = fm.getFunctionContaining(addr(a))
|
||||||
|
interest.setdefault(disp, []).append((a, fn.getName() if fn else "?",
|
||||||
|
int(fn.getEntryPoint().getOffset()) if fn else 0))
|
||||||
|
for d in sorted(interest):
|
||||||
|
P("### accessor-shape loads of +0x%02x : %d" % (d, len(interest[d])))
|
||||||
|
for a, nm, e in sorted(interest[d], key=lambda x: x[2]):
|
||||||
|
P(" %#x in %s @%#x" % (a, nm, e))
|
||||||
|
if e:
|
||||||
|
nc = [(fr, t, cf, ce) for fr, t, cf, ce in xrefs_to(e) if "CALL" in t]
|
||||||
|
P(" callers: %d -> %s" % (len(nc), sorted(set(cf for _, _, cf, _ in nc))))
|
||||||
|
P()
|
||||||
|
|
||||||
|
P("=" * 30, "THE UNOPENED +0x49 CONSUMER: FUN_1800bc580", "=" * 30)
|
||||||
|
d = dec(0x1800BC580)
|
||||||
|
P("len(src) =", len(d))
|
||||||
|
P(d)
|
||||||
|
|
||||||
|
f.close()
|
||||||
|
print("WROTE", OUT)
|
||||||
|
except Exception:
|
||||||
|
traceback.print_exc()
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
"""ADVERSARIAL BATCH 3.
|
||||||
|
|
||||||
|
My batch-2 displacement census turned up FOUR +0x5c sites the other agent's
|
||||||
|
constant-collecting scan did not report, including MOV dword [RDI+0x5c],0x5 and
|
||||||
|
MOV dword [RDI+0x5c],0x6 in FUN_180147070 -- i.e. the client WRITES forSale and
|
||||||
|
offered. Their claim "forSale(5) and offered(6): NEVER TESTED ANYWHERE" and the
|
||||||
|
action "nothing reads them" are under direct attack here.
|
||||||
|
|
||||||
|
Also under attack:
|
||||||
|
- "no other code path can produce the greyout from wire data": FUN_1800bc580 is a
|
||||||
|
THIRD +0x49 consumer (it counts untradeable squad members). What uses that count?
|
||||||
|
- the FUN_1800e2a40 <-> FUN_18003e370 vtable link the agent flagged as a gap.
|
||||||
|
- the +0x23f playStyle mapper, the 0x226 pile mapper, and the record-offset anchor
|
||||||
|
inside FUN_18013fe00 (printed IN FULL, with len).
|
||||||
|
|
||||||
|
CONTROL for the vtable hunt: I search for the 8-byte pointer to FUN_1800e2a40 AND,
|
||||||
|
in the same pass, for the pointer to FUN_1801a7260 (which the agent reported has NO
|
||||||
|
8-byte pointer, only 4-byte .pdata RVAs) and to FUN_18003e370. A hunt that finds all
|
||||||
|
three or none tells me the search itself is sound.
|
||||||
|
"""
|
||||||
|
import traceback, struct
|
||||||
|
|
||||||
|
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/adv2/q3_raw.txt"
|
||||||
|
try:
|
||||||
|
f = open(OUT, "w")
|
||||||
|
|
||||||
|
def P(*a):
|
||||||
|
f.write(" ".join(str(x) for x in a) + "\n")
|
||||||
|
|
||||||
|
P("=" * 25, "A. itemState WRITERS/READERS the other scan missed", "=" * 25)
|
||||||
|
for a in (0x180147070, 0x1801A6FC0, 0x1800A47B0, 0x18011DC50, 0x1800D73D0):
|
||||||
|
d = dec(a)
|
||||||
|
P("### %#x len=%d xrefs:" % (a, len(d)))
|
||||||
|
for frm, t, cf, e in xrefs_to(a):
|
||||||
|
P(" %#x %s in %s@%#x" % (frm, t, cf, e))
|
||||||
|
P(d)
|
||||||
|
P()
|
||||||
|
|
||||||
|
P("=" * 25, "B. the third +0x49 consumer: who calls FUN_1800bc580", "=" * 25)
|
||||||
|
for frm, t, cf, e in xrefs_to(0x1800BC580):
|
||||||
|
P(" %#x %s in %s@%#x" % (frm, t, cf, e))
|
||||||
|
P("--- FUN_1801a8890 (the sibling predicate counted into param_2):")
|
||||||
|
P(dec(0x1801A8890))
|
||||||
|
P("--- FUN_1801a80a0:")
|
||||||
|
P(dec(0x1801A80A0))
|
||||||
|
|
||||||
|
P()
|
||||||
|
P("=" * 25, "C. vtable link FUN_1800e2a40 <- FUN_18003e370 slot 0x40", "=" * 25)
|
||||||
|
for nm, a in (("FUN_1800e2a40", 0x1800E2A40), ("FUN_1801a7260", 0x1801A7260),
|
||||||
|
("FUN_18003e370", 0x18003E370), ("FUN_1800eb850", 0x1800EB850)):
|
||||||
|
pat = struct.pack("<Q", a)
|
||||||
|
hits = find_all(pat, blocks=(".rdata", ".data"))
|
||||||
|
P(" %s ptr8 hits: %s" % (nm, [hex(h) for h in hits]))
|
||||||
|
for h in hits:
|
||||||
|
# walk backwards to find the table start (first qword that is not a .text ptr)
|
||||||
|
start = h
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
v = qword(start - 8)
|
||||||
|
except Exception:
|
||||||
|
break
|
||||||
|
if not (0x180001000 <= v < 0x1801E5000):
|
||||||
|
break
|
||||||
|
start -= 8
|
||||||
|
P(" table start %#x, slot +%#x" % (start, h - start))
|
||||||
|
for i in range(0, 40):
|
||||||
|
try:
|
||||||
|
v = qword(start + i * 8)
|
||||||
|
except Exception:
|
||||||
|
break
|
||||||
|
if not (0x180001000 <= v < 0x1801E5000):
|
||||||
|
P(" +%#04x %#x <END>" % (i * 8, v))
|
||||||
|
break
|
||||||
|
fn = fm.getFunctionAt(addr(v))
|
||||||
|
P(" +%#04x %#x %s%s" % (i * 8, v, fn.getName() if fn else "",
|
||||||
|
" <== TARGET" if v == a else ""))
|
||||||
|
|
||||||
|
P()
|
||||||
|
P("=" * 25, "D. FUN_18013fe00 FULL", "=" * 25)
|
||||||
|
d = dec(0x18013FE00, timeout=600)
|
||||||
|
P("len(src) =", len(d))
|
||||||
|
P(d)
|
||||||
|
|
||||||
|
P()
|
||||||
|
P("=" * 25, "E. mappers", "=" * 25)
|
||||||
|
for nm, a in (("playStyle FUN_180136480", 0x180136480),
|
||||||
|
("pile FUN_180142650", 0x180142650),
|
||||||
|
("owners helper FUN_1800d7b50", 0x1800D7B50),
|
||||||
|
("BOUGHT_FOR mapper FUN_1800d7b30", 0x1800D7B30),
|
||||||
|
("family FUN_1800d8330", 0x1800D8330)):
|
||||||
|
P("### " + nm)
|
||||||
|
dd = dec(a)
|
||||||
|
P(" len=%d" % len(dd))
|
||||||
|
P(dd)
|
||||||
|
P()
|
||||||
|
|
||||||
|
f.close()
|
||||||
|
print("WROTE", OUT)
|
||||||
|
except Exception:
|
||||||
|
traceback.print_exc()
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
"""ADVERSARIAL BATCH 4 -- the remaining serve-changing and absence claims.
|
||||||
|
|
||||||
|
- FUN_180141660: is the +0x54 level write really on the COMMON tail, or only on the
|
||||||
|
"DB Error" path? If only on the error path the whole level story changes.
|
||||||
|
- FUN_1801b3640: CMP dword [RAX+0x5c],R15D -- a REGISTER compare the other agent's
|
||||||
|
constant-collecting scan could not evaluate. If R15D can be 5 or 6 their
|
||||||
|
"forSale/offered are never tested" absence claim dies.
|
||||||
|
- FUN_18003e550: the listing panel. Does "List on Transfer Market" have its own
|
||||||
|
enable predicate the eight-flag array does not cover?
|
||||||
|
- FUN_1800eb850: are DISCARD_CREDITS / CALCULATED_DISCARD_CREDITS really the two
|
||||||
|
names, pushed from 0x1801a8620 / 0x1801a8090?
|
||||||
|
- 0x226 pile census, re-tested by xrefs to the mapper FUN_180142650 (a DIFFERENT
|
||||||
|
method from decompiling all 134 skip-callers).
|
||||||
|
- itemState string-writer absence, re-tested by xrefs to every one of the 12 string
|
||||||
|
literals, with the ITEM-TYPE table strings ('player','staff') as a control that
|
||||||
|
has known extra users.
|
||||||
|
- FUN_180008190: resolve the indirect string compare through the global vtable.
|
||||||
|
"""
|
||||||
|
import traceback, struct
|
||||||
|
|
||||||
|
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/adv2/q4_raw.txt"
|
||||||
|
try:
|
||||||
|
f = open(OUT, "w")
|
||||||
|
|
||||||
|
def P(*a):
|
||||||
|
f.write(" ".join(str(x) for x in a) + "\n")
|
||||||
|
|
||||||
|
P("=" * 25, "A. FUN_180141660 -- is the level write a common tail?", "=" * 25)
|
||||||
|
fn = fm.getFunctionAt(addr(0x180141660))
|
||||||
|
body = fn.getBody()
|
||||||
|
P("body:", body, " min %#x max %#x" % (int(body.getMinAddress().getOffset()),
|
||||||
|
int(body.getMaxAddress().getOffset())))
|
||||||
|
# every RET in the function, and every branch target landing at/after 0x180141e77
|
||||||
|
rets, brs = [], []
|
||||||
|
it = listing.getInstructions(body, True)
|
||||||
|
while it.hasNext():
|
||||||
|
i = it.next()
|
||||||
|
m = i.getMnemonicString()
|
||||||
|
a = int(i.getAddress().getOffset())
|
||||||
|
if m == "RET":
|
||||||
|
rets.append(a)
|
||||||
|
if m.startswith("J"):
|
||||||
|
for r in i.getFlows():
|
||||||
|
t = int(r.getOffset())
|
||||||
|
if 0x180141E70 <= t <= 0x180141EB0:
|
||||||
|
brs.append((a, m, t))
|
||||||
|
P("RET sites:", [hex(x) for x in rets])
|
||||||
|
P("branches into the tail 0x180141e70..0x180141eb0:")
|
||||||
|
for a, m, t in brs:
|
||||||
|
P(" %#x %s -> %#x" % (a, m, t))
|
||||||
|
P()
|
||||||
|
P("FUN_180141660 decompile:")
|
||||||
|
d = dec(0x180141660, timeout=600)
|
||||||
|
P("len =", len(d))
|
||||||
|
P(d)
|
||||||
|
|
||||||
|
P()
|
||||||
|
P("=" * 25, "B. FUN_1801b3640 -- the register compare on +0x5c", "=" * 25)
|
||||||
|
ins = listing.getInstructions(addr(0x1801B3860), True)
|
||||||
|
n = 0
|
||||||
|
while ins.hasNext() and n < 90:
|
||||||
|
i = ins.next()
|
||||||
|
a = int(i.getAddress().getOffset())
|
||||||
|
if a > 0x1801B38E0:
|
||||||
|
break
|
||||||
|
P(" %#x %s" % (a, i))
|
||||||
|
n += 1
|
||||||
|
P()
|
||||||
|
P("R15 setup search 0x1801b3640..0x1801b3894:")
|
||||||
|
ins = listing.getInstructions(addr(0x1801B3640), True)
|
||||||
|
while ins.hasNext():
|
||||||
|
i = ins.next()
|
||||||
|
a = int(i.getAddress().getOffset())
|
||||||
|
if a > 0x1801B3894:
|
||||||
|
break
|
||||||
|
s = i.toString()
|
||||||
|
if "R15" in s:
|
||||||
|
P(" %#x %s" % (a, s))
|
||||||
|
P()
|
||||||
|
d = dec(0x1801B3640, timeout=600)
|
||||||
|
P("FUN_1801b3640 len =", len(d))
|
||||||
|
P(d)
|
||||||
|
|
||||||
|
P()
|
||||||
|
P("=" * 25, "C. FUN_18003e550 listing panel + FUN_1800eb850 discard push", "=" * 25)
|
||||||
|
for a in (0x18003E550, 0x1800EB850):
|
||||||
|
d = dec(a, timeout=600)
|
||||||
|
P("### %#x len=%d" % (a, len(d)))
|
||||||
|
P(d)
|
||||||
|
P()
|
||||||
|
|
||||||
|
P("=" * 25, "D. pile mapper xrefs (different method for the 0x226 census)", "=" * 25)
|
||||||
|
for frm, t, cf, e in xrefs_to(0x180142650):
|
||||||
|
P(" %#x %s in %s@%#x" % (frm, t, cf, e))
|
||||||
|
|
||||||
|
P()
|
||||||
|
P("=" * 25, "E. itemState string literals: every xref", "=" * 25)
|
||||||
|
names = ["invalid", "free", "WAITING_FOR_GAME", "inGame", "forSale", "offered",
|
||||||
|
"activeBadge", "activeHomeKit", "activeAwayKit", "activeBall",
|
||||||
|
"activeStadium", "active",
|
||||||
|
"player", "staff"] # last two = CONTROL, known to be used elsewhere
|
||||||
|
for nm in names:
|
||||||
|
hits = find_all(nm.encode() + b"\x00", blocks=(".rdata", ".data"))
|
||||||
|
P("### %-18s literal hits: %s" % (nm, [hex(h) for h in hits]))
|
||||||
|
for h in hits:
|
||||||
|
for frm, t, cf, e in xrefs_to(h):
|
||||||
|
P(" ref %#x %s in %s@%#x" % (frm, t, cf, e))
|
||||||
|
|
||||||
|
P()
|
||||||
|
P("=" * 25, "F. FUN_180008190 indirect compare + FUN_180130d10 + FUN_1801c3480", "=" * 25)
|
||||||
|
for a in (0x180008190, 0x180130D10, 0x1801C3480):
|
||||||
|
d = dec(a, timeout=600)
|
||||||
|
P("### %#x len=%d" % (a, len(d)))
|
||||||
|
P(d)
|
||||||
|
P()
|
||||||
|
|
||||||
|
f.close()
|
||||||
|
print("WROTE", OUT)
|
||||||
|
except Exception:
|
||||||
|
traceback.print_exc()
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
"""ADVERSARIAL BATCH 5 -- consequences of the one serve-changing action, and the
|
||||||
|
service gate the other agent left open.
|
||||||
|
|
||||||
|
1. untradeable:false flips item+0x49 to 1 on EVERY card. Besides TO_TRADE_PILE that
|
||||||
|
byte feeds FUN_1800bc580, which counts untradeable members of the 11-slot active
|
||||||
|
squad. Who consumes that count, and does flipping it change anything else?
|
||||||
|
2. FUN_1801a7260's other gate: slot +0x270 of the service FUN_180009c80 resolves.
|
||||||
|
Identify the service vtable and that slot if possible.
|
||||||
|
"""
|
||||||
|
import traceback, struct
|
||||||
|
|
||||||
|
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/adv2/q5_raw.txt"
|
||||||
|
try:
|
||||||
|
f = open(OUT, "w")
|
||||||
|
|
||||||
|
def P(*a):
|
||||||
|
f.write(" ".join(str(x) for x in a) + "\n")
|
||||||
|
|
||||||
|
P("=" * 25, "1. consumers of the untradeable-squad count", "=" * 25)
|
||||||
|
for a in (0x1800BB2A0, 0x1800BBA10):
|
||||||
|
d = dec(a, timeout=600)
|
||||||
|
P("### %#x len=%d" % (a, len(d)))
|
||||||
|
P(d)
|
||||||
|
P()
|
||||||
|
|
||||||
|
P("=" * 25, "2. the service locator used by FUN_1801a7260", "=" * 25)
|
||||||
|
for nm, a in (("FUN_1800d7170", 0x1800D7170), ("FUN_180009c80", 0x180009C80),
|
||||||
|
("FUN_180018bd0", 0x180018BD0), ("FUN_180009b60", 0x180009B60)):
|
||||||
|
P("### " + nm)
|
||||||
|
P(dec(a))
|
||||||
|
P()
|
||||||
|
|
||||||
|
P("=" * 25, "3. any vtable with >= 0x280 bytes containing plausible slot 0x270", "=" * 25)
|
||||||
|
# find .rdata runs of >= 0x50 consecutive .text pointers; report those long enough
|
||||||
|
for b in mem.getBlocks():
|
||||||
|
if b.getName() != ".rdata" or not b.isInitialized():
|
||||||
|
continue
|
||||||
|
s = int(b.getStart().getOffset())
|
||||||
|
e = int(b.getEnd().getOffset())
|
||||||
|
a = (s + 7) & ~7
|
||||||
|
run_start = None
|
||||||
|
while a + 8 <= e:
|
||||||
|
try:
|
||||||
|
v = qword(a)
|
||||||
|
except Exception:
|
||||||
|
break
|
||||||
|
ok = 0x180001000 <= v < 0x1801E5000
|
||||||
|
if ok and run_start is None:
|
||||||
|
run_start = a
|
||||||
|
elif not ok and run_start is not None:
|
||||||
|
ln = a - run_start
|
||||||
|
if ln >= 0x280:
|
||||||
|
P(" vtable-ish run %#x..%#x len %#x slot+0x270 -> %#x %s" %
|
||||||
|
(run_start, a, ln, qword(run_start + 0x270),
|
||||||
|
(lambda fn: fn.getName() if fn else "")(fm.getFunctionAt(addr(qword(run_start + 0x270))))))
|
||||||
|
run_start = None
|
||||||
|
a += 8
|
||||||
|
|
||||||
|
f.close()
|
||||||
|
print("WROTE", OUT)
|
||||||
|
except Exception:
|
||||||
|
traceback.print_exc()
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
"""ADVERSARIAL BATCH 6 -- pin the service behind GUID 0xed84b11/0xed84b12 whose
|
||||||
|
vtable slot +0x270 is the OTHER gate on TO_TRADE_PILE. If that gate is an online /
|
||||||
|
transfer-market-availability check it may block the menu even with untradeable:false,
|
||||||
|
which is the single biggest risk to the headline recommendation.
|
||||||
|
|
||||||
|
METHOD: the class that implements an interface references the same GUID constant when
|
||||||
|
it registers. Scan .text for the 4-byte immediates and report every function.
|
||||||
|
CONTROL: the same scan for 0x10c80b95 (the CardInventory-ish service FUN_18003e370
|
||||||
|
uses) and 0xed80ed8 -- if those come back with registrars and 0xed84b11 does not, the
|
||||||
|
absence is about this GUID and not about the scan.
|
||||||
|
"""
|
||||||
|
import traceback, struct
|
||||||
|
|
||||||
|
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/adv2/q6_raw.txt"
|
||||||
|
try:
|
||||||
|
f = open(OUT, "w")
|
||||||
|
|
||||||
|
def P(*a):
|
||||||
|
f.write(" ".join(str(x) for x in a) + "\n")
|
||||||
|
|
||||||
|
for g in (0xED84B11, 0xED84B12, 0x10C80B95, 0x10C80B96, 0xED80ED8):
|
||||||
|
pat = struct.pack("<I", g)
|
||||||
|
hits = find_all(pat, blocks=(".text", ".rdata", ".data"))
|
||||||
|
fns = {}
|
||||||
|
for h in hits:
|
||||||
|
fn = fm.getFunctionContaining(addr(h))
|
||||||
|
k = fn.getName() if fn else "(data)"
|
||||||
|
fns.setdefault(k, []).append(h)
|
||||||
|
P("### GUID %#x : %d byte hits in %d functions" % (g, len(hits), len(fns)))
|
||||||
|
for k, v in sorted(fns.items()):
|
||||||
|
P(" %-24s %s" % (k, [hex(x) for x in v]))
|
||||||
|
P()
|
||||||
|
|
||||||
|
P("=" * 25, "the registrar bodies", "=" * 25)
|
||||||
|
seen = set()
|
||||||
|
for g in (0xED84B11, 0xED84B12):
|
||||||
|
for h in find_all(struct.pack("<I", g), blocks=(".text",)):
|
||||||
|
fn = fm.getFunctionContaining(addr(h))
|
||||||
|
if fn is None:
|
||||||
|
continue
|
||||||
|
e = int(fn.getEntryPoint().getOffset())
|
||||||
|
if e in seen:
|
||||||
|
continue
|
||||||
|
seen.add(e)
|
||||||
|
P("### %s @%#x" % (fn.getName(), e))
|
||||||
|
P(dec(e))
|
||||||
|
P()
|
||||||
|
|
||||||
|
f.close()
|
||||||
|
print("WROTE", OUT)
|
||||||
|
except Exception:
|
||||||
|
traceback.print_exc()
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
"""ADVERSARIAL BATCH 7 -- finish the two open links.
|
||||||
|
|
||||||
|
(a) 0x10c80b96 appears as data at 0x1800e1662, inside the function at vtable slot
|
||||||
|
+0xa8 of the table 0x180215a80 -- the same table whose slot +0xd0 is
|
||||||
|
FUN_1800e2a40. If that holds it independently proves the FUN_18003e370 ->
|
||||||
|
FUN_1800e2a40 link the other agent could only infer semantically.
|
||||||
|
(b) 0xed84b12 appears as data at 0x180113f52. Whatever class that belongs to is the
|
||||||
|
service FUN_1801a7260 calls slot +0x270 on. Find its vtable and read slot 0x270.
|
||||||
|
"""
|
||||||
|
import traceback, struct
|
||||||
|
|
||||||
|
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/adv2/q7_raw.txt"
|
||||||
|
try:
|
||||||
|
f = open(OUT, "w")
|
||||||
|
|
||||||
|
def P(*a):
|
||||||
|
f.write(" ".join(str(x) for x in a) + "\n")
|
||||||
|
|
||||||
|
for a in (0x1800E1662, 0x180113F52):
|
||||||
|
fn = fm.getFunctionContaining(addr(a))
|
||||||
|
P("### data GUID at %#x -> containing function %s @%#x" %
|
||||||
|
(a, fn.getName() if fn else None,
|
||||||
|
int(fn.getEntryPoint().getOffset()) if fn else 0))
|
||||||
|
if fn:
|
||||||
|
e = int(fn.getEntryPoint().getOffset())
|
||||||
|
P(dec(e))
|
||||||
|
hits = find_all(struct.pack("<Q", e), blocks=(".rdata", ".data"))
|
||||||
|
P(" 8-byte pointer to it: %s" % [hex(h) for h in hits])
|
||||||
|
for h in hits:
|
||||||
|
start = h
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
v = qword(start - 8)
|
||||||
|
except Exception:
|
||||||
|
break
|
||||||
|
if not (0x180001000 <= v < 0x1801E5000):
|
||||||
|
break
|
||||||
|
start -= 8
|
||||||
|
P(" run start %#x, this fn at slot +%#x" % (start, h - start))
|
||||||
|
# find the first non-stub entry -- the secondary vtable base
|
||||||
|
base = start
|
||||||
|
while qword(base) == 0x1801C577A:
|
||||||
|
base += 8
|
||||||
|
P(" first non-stub entry at %#x (offset +%#x from run start)" % (base, base - start))
|
||||||
|
P(" => slot of this fn relative to first non-stub: +%#x" % (h - base))
|
||||||
|
for i in range(0, 90):
|
||||||
|
v = qword(base + i * 8)
|
||||||
|
if not (0x180001000 <= v < 0x1801E5000):
|
||||||
|
break
|
||||||
|
f2 = fm.getFunctionAt(addr(v))
|
||||||
|
mark = ""
|
||||||
|
if i * 8 == 0x270:
|
||||||
|
mark = " <== SLOT 0x270"
|
||||||
|
if i * 8 == 0x40:
|
||||||
|
mark = " <== SLOT 0x40"
|
||||||
|
P(" +%#05x %#x %s%s" % (i * 8, v, f2.getName() if f2 else "", mark))
|
||||||
|
P()
|
||||||
|
|
||||||
|
f.close()
|
||||||
|
print("WROTE", OUT)
|
||||||
|
except Exception:
|
||||||
|
traceback.print_exc()
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
"""BATCH 8: the two GUID-returning stubs are undefined functions. Read them as raw
|
||||||
|
instructions and find the vtable that holds them. CONTROL: both stubs must decode to
|
||||||
|
'mov eax, <guid>; ret' -- if they do not, my reading of them as interface-id getters
|
||||||
|
is wrong and I say so."""
|
||||||
|
import traceback, struct
|
||||||
|
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/adv2/q8_raw.txt"
|
||||||
|
try:
|
||||||
|
f = open(OUT, "w")
|
||||||
|
def P(*a): f.write(" ".join(str(x) for x in a) + "\n")
|
||||||
|
for lo, hi in ((0x1800E1650, 0x1800E1690), (0x180113F40, 0x180113F80)):
|
||||||
|
P("### raw %#x..%#x" % (lo, hi))
|
||||||
|
P(" bytes:", read_bytes(lo, hi - lo).hex())
|
||||||
|
it = listing.getInstructions(addr(lo), True)
|
||||||
|
while it.hasNext():
|
||||||
|
i = it.next()
|
||||||
|
a = int(i.getAddress().getOffset())
|
||||||
|
if a >= hi: break
|
||||||
|
P(" %#x %s" % (a, i))
|
||||||
|
P()
|
||||||
|
for cand in (0x1800E1660, 0x180113F50, 0x180113F4C, 0x180113F40):
|
||||||
|
hits = find_all(struct.pack("<Q", cand), blocks=(".rdata", ".data"))
|
||||||
|
P("ptr8 to %#x : %s" % (cand, [hex(h) for h in hits]))
|
||||||
|
for h in hits:
|
||||||
|
start = h
|
||||||
|
while True:
|
||||||
|
try: v = qword(start - 8)
|
||||||
|
except Exception: break
|
||||||
|
if not (0x180001000 <= v < 0x1801E5000): break
|
||||||
|
start -= 8
|
||||||
|
base = start
|
||||||
|
while qword(base) == 0x1801C577A: base += 8
|
||||||
|
P(" run %#x, first non-stub %#x, this at +%#x from non-stub" % (start, base, h - base))
|
||||||
|
for i in range(0, 100):
|
||||||
|
v = qword(base + i * 8)
|
||||||
|
if not (0x180001000 <= v < 0x1801E5000): break
|
||||||
|
fn = fm.getFunctionAt(addr(v))
|
||||||
|
if i*8 in (0x40, 0x270, 0x308, 0x290, 0x2b0, 0x148, 0xd0, 0x20):
|
||||||
|
P(" +%#05x %#x %s" % (i*8, v, fn.getName() if fn else ""))
|
||||||
|
P(" table length: %#x" % (i*8))
|
||||||
|
f.close(); print("WROTE", OUT)
|
||||||
|
except Exception:
|
||||||
|
traceback.print_exc()
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"""BATCH 9. The cast helper is vtable slot +0x18 (FUN_180009c80 calls
|
||||||
|
(*(*svc))[0x18] with the interface GUID). So vtable_base = cast_stub_slot_addr - 0x18.
|
||||||
|
- 0x10c80b96 class: stub ptr at 0x180215b28 -> base 0x180215b10 -> slot +0x40 must be
|
||||||
|
FUN_1800e2a40 if the FUN_18003e370 link is real. (CONTROL for the arithmetic.)
|
||||||
|
- 0xed84b12 class: stub ptr at 0x18021c2b8 -> base 0x18021c2a0 -> slot +0x270 is the
|
||||||
|
other gate on TO_TRADE_PILE.
|
||||||
|
"""
|
||||||
|
import traceback
|
||||||
|
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/adv2/q9_raw.txt"
|
||||||
|
try:
|
||||||
|
f = open(OUT, "w")
|
||||||
|
def P(*a): f.write(" ".join(str(x) for x in a) + "\n")
|
||||||
|
for nm, base, slots in (("iface 0x10c80b96 (CONTROL)", 0x180215B10, (0x18, 0x40)),
|
||||||
|
("iface 0xed84b12", 0x18021C2A0, (0x18, 0x270, 0x290, 0x2b0, 0x308, 0x148))):
|
||||||
|
P("### %s vtable base %#x" % (nm, base))
|
||||||
|
for s in slots:
|
||||||
|
v = qword(base + s)
|
||||||
|
fn = fm.getFunctionAt(addr(v))
|
||||||
|
P(" +%#05x -> %#x %s" % (s, v, fn.getName() if fn else ""))
|
||||||
|
P()
|
||||||
|
for a in (0x1801B1CE0,):
|
||||||
|
pass
|
||||||
|
v = qword(0x18021C2A0 + 0x270)
|
||||||
|
P("=== slot 0x270 body ===")
|
||||||
|
P(dec(v, timeout=300))
|
||||||
|
P("=== xrefs to it ===")
|
||||||
|
for frm, t, cf, e in xrefs_to(v):
|
||||||
|
P(" %#x %s in %s@%#x" % (frm, t, cf, e))
|
||||||
|
f.close(); print("WROTE", OUT)
|
||||||
|
except Exception:
|
||||||
|
traceback.print_exc()
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
"""ADVERSARIAL VERIFICATION BATCH 1 (dim4 + dim5).
|
||||||
|
|
||||||
|
HYPOTHESES UNDER ATTACK
|
||||||
|
H1 (dim5 f5/f7): the publisher FUN_18006cc60 maps model vtable slots to IS_* names,
|
||||||
|
and IS_TRADING_ENABLED (0x1801fc118) has exactly ONE rip-relative reference in
|
||||||
|
.text (the lea), i.e. the name is output-only.
|
||||||
|
CONTROL: run the same rip-relative scanner against a literal that IS known to be
|
||||||
|
compared, e.g. one of the ISOfferTrade error strings 0x180228f20, which must show
|
||||||
|
up in a *different* instruction context, and against IS_STORE_ENABLED.
|
||||||
|
H2 (dim5 f5 positive control): IS_STORE_ENABLED's accessor (vt+0x280) - what does it
|
||||||
|
actually compute? If it is a live-evaluable expression we can compare STORE vs
|
||||||
|
TRADING under the same publish mechanism.
|
||||||
|
H3 (dim4 f2): FutGetSuggestedPricing deser 0x180163ee0 top-level token is
|
||||||
|
START_ARRAY (loop terminates on 0xd) - CONTROL FUN_180165df0 (ISStart) must
|
||||||
|
terminate on 10.
|
||||||
|
H4 (dim4 f4): 0x1801642c0 is `return 1;`.
|
||||||
|
H5 (dim4 f6): tradeState table 0x180229e40 / bidState ladder FUN_180166380.
|
||||||
|
H6 (dim4 f9): IS_MAX_AUCTIONS publisher FUN_1800377c0 + GetAuctionCount deser
|
||||||
|
0x180163770.
|
||||||
|
H7 (dim4 f8): error mapper FUN_1801844c0.
|
||||||
|
Everything printed IN FULL with len(src).
|
||||||
|
"""
|
||||||
|
import traceback, struct
|
||||||
|
|
||||||
|
def full(tag, va):
|
||||||
|
try:
|
||||||
|
s = dec(va)
|
||||||
|
print("\n----- %s %#x len=%d -----" % (tag, va, len(s)))
|
||||||
|
print(s)
|
||||||
|
except Exception:
|
||||||
|
traceback.print_exc()
|
||||||
|
|
||||||
|
try:
|
||||||
|
print("### H1: publisher FUN_18006cc60")
|
||||||
|
full("publisher", 0x18006cc60)
|
||||||
|
|
||||||
|
print("\n### model vtable slots")
|
||||||
|
VT = 0x18021c2a0
|
||||||
|
for off in (0x270, 0x280, 0x2b0, 0x988, 0x998, 0xa58, 0xa60, 0x130, 0x5b8, 0xa00):
|
||||||
|
t = qword(VT + off)
|
||||||
|
print(" vt+%#05x -> %#x %s" % (off, t, fname(t) if 'fname' in dir() else ''))
|
||||||
|
full("vt+0x280 IS_STORE_ENABLED accessor", qword(VT + 0x280))
|
||||||
|
full("vt+0x270 IS_TRADING_ENABLED accessor", qword(VT + 0x270))
|
||||||
|
full("vt+0xa58 TRADE_PILE_SIZE accessor", qword(VT + 0xa58))
|
||||||
|
|
||||||
|
print("\n### H1 rip-relative reference scan, form independent")
|
||||||
|
# Scan .text for any 4-byte little-endian rel32 whose target == literal VA,
|
||||||
|
# for every instruction end position. This catches lea/mov/cmp/push equally.
|
||||||
|
tblk = None
|
||||||
|
for b in mem.getBlocks():
|
||||||
|
if b.getName() == ".text":
|
||||||
|
tblk = b
|
||||||
|
TS = int(tblk.getStart().getOffset()); TE = int(tblk.getEnd().getOffset())
|
||||||
|
text = read_bytes(TS, TE - TS + 1)
|
||||||
|
print(" .text %#x..%#x len=%d" % (TS, TE, len(text)))
|
||||||
|
|
||||||
|
def ripscan(target, label):
|
||||||
|
hits = []
|
||||||
|
for i in range(0, len(text) - 4):
|
||||||
|
rel = struct.unpack_from('<i', text, i)[0]
|
||||||
|
# instruction end = TS + i + 4 (rel32 is the last field of the insn)
|
||||||
|
if TS + i + 4 + rel == target:
|
||||||
|
hits.append(TS + i)
|
||||||
|
print(" %-34s target %#x : %d candidate rel32 sites" % (label, target, len(hits)))
|
||||||
|
for h in hits[:20]:
|
||||||
|
print(" at %#x bytes %s fn %s" % (h - 3, text[h - 6:h + 6].hex(),
|
||||||
|
(fm.getFunctionContaining(addr(h)) or "?")))
|
||||||
|
return hits
|
||||||
|
|
||||||
|
lits = {}
|
||||||
|
for nm in (b"IS_TRADING_ENABLED\x00", b"IS_STORE_ENABLED\x00",
|
||||||
|
b"IS_DRAFT_MODE_ENABLED\x00", b"TRADE_PILE_SIZE\x00",
|
||||||
|
b"IS_MAX_AUCTIONS\x00", b"NUM_MAX_AUCTIONS\x00",
|
||||||
|
b"You are not allowed to bid on this trade\x00"):
|
||||||
|
f = find_all(nm, blocks=(".rdata", ".data", ".text"))
|
||||||
|
lits[nm] = f
|
||||||
|
print(" literal %-45r -> %s" % (nm[:40], [hex(x) for x in f]))
|
||||||
|
for nm, f in lits.items():
|
||||||
|
for a in f:
|
||||||
|
ripscan(a, nm[:30].decode(errors='replace'))
|
||||||
|
|
||||||
|
print("\n### H3 pricelimits vs ISStart control")
|
||||||
|
full("FutGetSuggestedPricing deser", 0x180163ee0)
|
||||||
|
full("FutISStart deser CONTROL", 0x180165df0)
|
||||||
|
|
||||||
|
print("\n### H4 generic ack deser")
|
||||||
|
full("ack deser", 0x1801642c0)
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
traceback.print_exc()
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
"""ADVERSARIAL VERIFICATION BATCH 2.
|
||||||
|
Everything printed IN FULL with len(src). No truncation, no absence claimed from
|
||||||
|
a partial print.
|
||||||
|
H8 dim4 f5: auctionInfo record deser 0x18013e410 has exactly 12 atoms + tradeId
|
||||||
|
identity lookup via model vt+0xa00.
|
||||||
|
H9 dim4 f7: shared IS-list body 0x18013e7f0, credits -> model vt+0x5b8.
|
||||||
|
H10 dim4 f6: tradeState table walk FUN_180166bd0 (table 0x180229e40) and bidState
|
||||||
|
ladder FUN_180166380 -- two DIFFERENT dispatch forms, read separately.
|
||||||
|
H11 dim4 f8: FUN_1801844c0 status map, FUN_180165050 461 override.
|
||||||
|
H12 dim4 f9: FUN_1800377c0 IS_MAX_AUCTIONS + FUN_180163770 GetAuctionCount deser.
|
||||||
|
CONTROL for the publisher form: FUN_18000d550 TRADE_PILE_SIZE.
|
||||||
|
H13 dim4 f11: deser VAs for FutISWatchList / FutGetAuctionCount / FutISStart via
|
||||||
|
RS4 name -> abs64 ptr -> installed vtable -> slot +0x08, with FutISSearch and
|
||||||
|
FutGetTradePile as the CONTROL pair (must come back 0x180163420 / 0x180170810).
|
||||||
|
"""
|
||||||
|
import traceback, struct
|
||||||
|
|
||||||
|
def full(tag, va):
|
||||||
|
try:
|
||||||
|
s = dec(va)
|
||||||
|
print("\n----- %s %#x len=%d -----" % (tag, va, len(s)))
|
||||||
|
print(s)
|
||||||
|
except Exception:
|
||||||
|
traceback.print_exc()
|
||||||
|
|
||||||
|
try:
|
||||||
|
for tag, va in [("auctionInfo record deser", 0x18013e410),
|
||||||
|
("shared IS-list body", 0x18013e7f0),
|
||||||
|
("tradeState decoder", 0x180166bd0),
|
||||||
|
("bidState decoder", 0x180166380),
|
||||||
|
("status mapper", 0x1801844c0),
|
||||||
|
("ISOfferTrade 461 override", 0x180165050),
|
||||||
|
("IS_MAX_AUCTIONS publisher", 0x1800377c0),
|
||||||
|
("TRADE_PILE_SIZE publisher CONTROL", 0x18000d550),
|
||||||
|
("GetAuctionCount deser", 0x180163770),
|
||||||
|
("ISWatchList deser", 0x180166240),
|
||||||
|
("ISSearch deser CONTROL", 0x180163420),
|
||||||
|
("GetTradePile deser CONTROL", 0x180170810)]:
|
||||||
|
full(tag, va)
|
||||||
|
|
||||||
|
print("\n### tradeState table at 0x180229e40")
|
||||||
|
a = 0x180229e40
|
||||||
|
for i in range(10):
|
||||||
|
p = qword(a + i * 16); v = dword(a + i * 16 + 8)
|
||||||
|
if p == 0:
|
||||||
|
print(" [%d] NULL terminator, value=%d" % (i, v)); break
|
||||||
|
print(" [%d] %#x %r = %d" % (i, p, rd_str(p), v if v < 0x80000000 else v - (1 << 32)))
|
||||||
|
|
||||||
|
print("\n### H13 RS4 name -> installed vtable -> slot+0x08")
|
||||||
|
for nm, expect in [(b"RS4:FutISSearchServerResponse\x00", 0x180163420),
|
||||||
|
(b"RS4:FutGetTradePileServerResponse\x00", 0x180170810),
|
||||||
|
(b"RS4:FutISWatchListServerResponse\x00", None),
|
||||||
|
(b"RS4:FutGetAuctionCountServerResponse\x00", None),
|
||||||
|
(b"RS4:FutISStartServerResponse\x00", None),
|
||||||
|
(b"RS4:FutGetSuggestedPricingServerResponse\x00", None),
|
||||||
|
(b"RS4:FutRelistAllServerResponse\x00", None),
|
||||||
|
(b"RS4:FutISWatchTradeServerResponse\x00", None),
|
||||||
|
(b"RS4:FutISRemoveTradeServerResponse\x00", None),
|
||||||
|
(b"RS4:FutISRemoveWatchServerResponse\x00", None),
|
||||||
|
(b"RS4:FutISViewTradeServerResponse\x00", None),
|
||||||
|
(b"RS4:FutISOfferTradeServerResponse\x00", None)]:
|
||||||
|
locs = find_all(nm, blocks=(".rdata", ".data"))
|
||||||
|
print("\n %s -> %s" % (nm.decode().rstrip("\x00"), [hex(x) for x in locs]))
|
||||||
|
for L in locs:
|
||||||
|
xs = xrefs_to(L)
|
||||||
|
print(" xrefs: %s" % [(hex(a), t, f) for a, t, f, _ in xs])
|
||||||
|
for a, t, f, ent in xs:
|
||||||
|
if ent:
|
||||||
|
s = dec(ent)
|
||||||
|
# find the vtable it installs: look for PTR_ / &DAT_ assignment
|
||||||
|
import re
|
||||||
|
m = re.findall(r"(?:PTR_[A-Za-z_0-9]*_|DAT_|&)([0-9a-fA-F]{9})", s)
|
||||||
|
print(" fn %s @%#x len=%d installs %s" % (f, ent, len(s), set(m)))
|
||||||
|
for cand in set(m):
|
||||||
|
try:
|
||||||
|
vt = int(cand, 16)
|
||||||
|
if 0x180200000 <= vt < 0x180290000:
|
||||||
|
slot = qword(vt + 8)
|
||||||
|
print(" vtable %#x slot+0x08 = %#x (expect %s)"
|
||||||
|
% (vt, slot, hex(expect) if expect else "?"))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
traceback.print_exc()
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
"""ADVERSARIAL BATCH 3 -- the relaunch-critical path.
|
||||||
|
H14: does the settings deser FUN_18013c6d0 pre-initialise its struct fields
|
||||||
|
+0x28..+0x40 to 1 before parsing? If it zero-inits them, then the observed
|
||||||
|
live pattern (model+0x1fd2e=0 surrounded by 1s) cannot have come from the
|
||||||
|
applier, i.e. the applier NEVER RAN -- which decides "never set" vs
|
||||||
|
"set then cleared".
|
||||||
|
Also: which atom writes struct+0x1c (the field FUN_180173e00 gates on)?
|
||||||
|
H15: FUN_180173e00 in full -- the test rdx / cmp [rdx+0x1c],0 gate.
|
||||||
|
H16: dim5 f8 -- FUN_180180770 blaze client-config reader, full key list.
|
||||||
|
"""
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
def full(tag, va):
|
||||||
|
try:
|
||||||
|
s = dec(va)
|
||||||
|
print("\n===== %s %#x len=%d =====" % (tag, va, len(s)))
|
||||||
|
print(s)
|
||||||
|
except Exception:
|
||||||
|
traceback.print_exc()
|
||||||
|
|
||||||
|
try:
|
||||||
|
full("settings deser FUN_18013c6d0", 0x18013c6d0)
|
||||||
|
full("settings completion FUN_180173e00", 0x180173e00)
|
||||||
|
full("blaze config reader FUN_180180770", 0x180180770)
|
||||||
|
except Exception:
|
||||||
|
traceback.print_exc()
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user