2 Commits

Author SHA1 Message Date
funman300 8afb812338 wip(fifa17): pre-existing SBC/economy candidate snapshot
Snapshot of the uncommitted economy/SBC candidate work that built the tested
sbc-host on top of e8d1c1d (NOT authored in this session; committed to leave a
clean tree). Covers pack_content, sbc, store_catalog, host economy_store/lib,
purchasegroup fixtures, economy integration/concurrency/differential tests,
and Cargo.lock. Content matches the running staging host binary.
2026-08-19 20:12:06 +00:00
funman300 3bb4814760 fix(fifa17): open Seasons/Draft via /settings config gate
RE-confirmed root cause: the FUT client's settings applier FUN_18011dc50 is
the sole writer of the IS_* UI gate bytes (byte = field==1). The Rust host
served GET /ut/game/fifa17/settings as {"configs":[]}, so the applier never
ran and Single-Player Seasons/Draft refused to open while issuing ZERO server
requests (friendlySeasonsEnabled -> settings slot 0x16 -> model byte 0x1fd3a
= IS_FRIENDLY_SEASON_ENABLED).

settings_body() is now env-gated: default (OPENFUT_FUT_SETTINGS unset/off)
keeps the empty baseline; OPENFUT_FUT_SETTINGS=gates returns the enable list.
The applier writes ALL gate bytes, so the working store flags (storeEnabled,
coinEnabled, cardPackStoreEnabled, pointsPackStoreEnabled, tradingEnabled,
+ _JP variants) are re-asserted alongside the Seasons/Draft enables to avoid
clearing the live Store. Instantly revertible by restarting without the env.
2026-08-19 20:12:06 +00:00
23 changed files with 118 additions and 1033 deletions
-25
View File
@@ -1,25 +0,0 @@
# 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
-163
View File
@@ -1,163 +0,0 @@
# 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
View File
@@ -2,8 +2,6 @@
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
> ⚠️ **Stale (FIFA 23).** This file's status and targets predate the FIFA 17 pivot. Prefer [`docs/PROJECT_STATE.md`](./docs/PROJECT_STATE.md) (canonical). The working target is **FIFA 17**; the canonical server is `fifa17-recon/docker/fifa17-python` (`docker compose up -d`). `openfut-bridge` (FIFA 23) is superseded; `openfut-core` remains the shared backend.
## Repository Layout
This is a monorepo containing three independent Rust crates as git submodules:
Generated
+1
View File
@@ -3166,6 +3166,7 @@ dependencies = [
name = "openfut-bridge"
version = "0.1.0"
dependencies = [
"aes",
"anyhow",
"axum",
"bytes",
-4
View File
@@ -1,9 +1,5 @@
# OpenFUT
> ⚠️ **Status — see [`docs/PROJECT_STATE.md`](./docs/PROJECT_STATE.md) (canonical).** The working, actively-developed target is **FIFA 17**, not FIFA 23. Everything below this banner describes the **superseded FIFA 23 `bridge` lineage** and is kept for historical context.
>
> **Run the server (canonical):** `cd fifa17-recon/docker/fifa17-python && docker compose up -d` — see [`fifa17-recon/FUT-RUNBOOK.md`](./fifa17-recon/FUT-RUNBOOK.md). `openfut-core` is the shared offline backend (still used by the FIFA 17 path); `openfut-bridge` is the retired FIFA 23 integration.
**Offline Ultimate Team — like SPT, but for FIFA 23.**
OpenFUT replaces EA's retired FUT servers with a fully offline, single-player backend. You own FIFA 23 legitimately. You just want to keep playing after EA shut down the servers.
-93
View File
@@ -1,93 +0,0 @@
# ============================================================================
# ⚠️ 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:
@@ -9,25 +9,3 @@ OPENFUT_ADVERTISE=203.0.113.10 # <- REPLACE with this host's LAN IP
# 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
# 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
@@ -36,14 +36,10 @@ services:
FUT_PROFILE_ROOT: "/state/accounts"
FUT_SETTINGS: "off"
FUT_MODES: "1"
# Which Python responders this SERVER runs. Default excludes lsx (that is
# a client-side responder — see below). Drop a name once it is migrated to
# a Rust host (run outside Docker) so the two never overlap. See .env.example.
OPENFUT_SERVERS: "${OPENFUT_SERVERS:-blaze roster utas pow}"
volumes:
- "../state:/state"
ports:
- "4216:4216" # LSX — CLIENT-SIDE (.105); only used if lsx is enabled for all-on-one-box
- "4216:4216" # LSX (Origin bootstrap)
- "42127:42127" # Blaze redirector (TLS)
- "42130:42130" # Blaze main
- "42131:42131" # Nucleus OAuth stub
@@ -57,40 +57,10 @@ declare -a SERVERS=(
"pow|pow_server.py|-"
)
# ── Component selection ──────────────────────────────────────────────────────
# OPENFUT_SERVERS picks which Python responders run (space- or comma-separated).
# This container is the SERVER side (.120). It serves ONLY components that have
# NOT been migrated to a Rust host — as each moves to Rust (which runs OUTSIDE
# Docker during migration), drop its name so the two never serve the same role.
# blaze Blaze redirector + main + nucleus (bundled) :42127 :42130 :42131
# roster FUT roster-update XML :8081
# utas FUT/UTAS RS4 API :8099
# (the Rust utas-host currently reverse-proxies its non-/club routes
# back here, so keep this enabled until UTAS is fully migrated)
# pow POW / EASFC :8094 (+ content :8080)
# lsx Origin LSX bootstrap :4216
# CLIENT-SIDE — LSX runs on the game machine (.105) with autopatch,
# NOT on the server. Excluded by default; enable ONLY for an
# all-on-one-box dev setup where client and server share a host.
OPENFUT_SERVERS="${OPENFUT_SERVERS:-blaze roster utas pow}"
want=" ${OPENFUT_SERVERS//,/ } "
known=" lsx blaze roster utas pow "
for w in $want; do
case "$known" in
*" $w "*) ;;
*) echo "[openfut] unknown component '$w' in OPENFUT_SERVERS (valid: lsx blaze roster utas pow)" >&2; exit 2 ;;
esac
done
echo "[openfut] servers=$OPENFUT_SERVERS"
pids=()
names=()
for entry in "${SERVERS[@]}"; do
IFS='|' read -r name script env <<<"$entry"
case "$want" in
*" $name "*) ;;
*) echo "[openfut] skipping $name (not in OPENFUT_SERVERS)"; continue ;;
esac
envprefix=""; [ "$env" != "-" ] && envprefix="env $env"
echo "[openfut] starting $name ($script)"
# shellcheck disable=SC2086
@@ -99,11 +69,6 @@ for entry in "${SERVERS[@]}"; do
names+=("$name")
done
if [ "${#pids[@]}" -eq 0 ]; then
echo "[openfut] OPENFUT_SERVERS selected no components; nothing to run" >&2
exit 2
fi
# Propagate SIGTERM/SIGINT to children so `docker stop` is clean.
term() {
echo "[openfut] shutting down…"
-19
View File
@@ -424,25 +424,6 @@ Chemistry/rating/nation/league-count constraints (`teamChemistry 0x307`, `starRa
generically as `{eligibilityKey, eligibilityOperation, eligibilityValue}` triples, **not** as
named scalar fields on the record. **FREEZE-RISK: elgReq must be a JSON array of objects.**
> **2026-08-19 — `eligibilityKey`/`eligibilityOperation` are LOCALIZATION ORDINALS, not the
> atom hex ids above.** Reversed from the pinned CardsDLL (`4706a881…`). The client's sole
> confirmed consumer of these fields is the requirement-display string builder at
> `~0x1800ef900`: it loads the eligibility int fields (`0x148(rcx)`) and formats them through
> *indexed localization keys* — `ELIGIBILITY_STRING%d` (`0x1802186b8`), `LOC_SBC_ELG_KEY_%d`
> (`0x180226710`), `ELIGIBILITY_OPERATION` (`0x1802186e8`) — appending to a string builder via
> vtable `*0x10`/`*0x20`. There is **no comparison/branch**: the client does not validate on
> these ints, it renders `LOC_SBC_ELG_KEY_<eligibilityKey>` (and an operation string) as
> display text. Therefore `eligibilityKey` is a small ordinal that indexes the **packed FIFA17
> locale**, NOT `0x307`/`0x22f`/etc. (those hex values are the atom ids of the *named* fields
> the encoding replaces, not the ordinal values). CONSEQUENCE: correct projection needs the
> ordinal→locale-string map, which lives only in the packed locale (absent from CardsDLL and
> every `fifa17-recon/data` file; a game-dir locale probe on the live client found none) or a
> real EA `elgReq` capture (unavailable on a private server). Emitting a *guessed* ordinal
> renders the WRONG requirement text to the player, so `elgReq` stays `[]` until the ordinal
> map is recovered. This is a display-only gap: SBC submission is fully validated server-side
> (Core), and an invalid squad's generic comms modal originates from the server 400, not from
> the empty `elgReq`.
**awards / grantedAwards** — nested array of reward objects (atoms: `rewardType 0x28e`,
`rewardValue 0x28f`, `rewardQuantity 0x28d`, `rewardMultiplier 0x28c`, `awardCount 0x40`,
`awardSet 0x45`, `awardSetId 0x46`, `prizeSet 0x253`). **FREEZE-RISK: must be array.**
+17 -44
View File
@@ -55,34 +55,6 @@ verify_exports() {
done
}
# Refuse any DLL that is not a FIFA-17-profile build.
#
# openfut-hook builds TWO mutually exclusive injection paths from one crate: the
# default (FIFA 23) path installs getaddrinfo/connect/ProtoSSL/origin hooks, while
# `--features fifa17` installs ONLY the FIFA-17-safe logic (module map, FIFA 17
# cert-verify, SBC dispatch, store tab bind). Deploying a default-feature build
# into FIFA 17 hijacks the login transport and the client reports "Unable to
# connect to the EA servers", with none of the FIFA 17 repairs present.
#
# That exact mistake happened on 2026-08-19 (artifact 1c71a17a, hand-built without
# the feature): two failed launches, diagnosed only by comparing embedded strings.
# `build` below passes the feature, but a hand-built DLL can reach `stage`/`deploy`
# via OPENFUT_FIFA17_HOOK_DLL, so assert the profile on the bytes themselves.
verify_fifa17_profile() {
local dll=$1 marker
# Markers that MUST be present: the FIFA 17 target module and its repairs.
for marker in 'CardsDLL_Win64_retail.dll' 'SBC_DISPATCH'; do
grep -qaF -- "$marker" "$dll" ||
die "$dll is not a --features fifa17 build (missing $marker); refusing to stage/deploy"
done
# Markers that MUST be absent: the FIFA-23-only transport hooking.
for marker in 'getaddrinfo IAT patched' 'connect: inline-hooked' 'origin_spy'; do
if grep -qaF -- "$marker" "$dll"; then
die "$dll contains FIFA-23-only hook '$marker'; build with --features fifa17"
fi
done
}
verify_inputs() {
command -v sha256sum >/dev/null || die "sha256sum is required"
command -v x86_64-w64-mingw32-objdump >/dev/null ||
@@ -90,7 +62,6 @@ verify_inputs() {
need_file "$hook_dll"
need_file "$system_version"
verify_pe64 "$hook_dll"
verify_fifa17_profile "$hook_dll"
}
inspect() {
@@ -158,7 +129,6 @@ deploy() {
need_file "$manifest"
verify_pe64 "$staged"
verify_exports "$staged"
verify_fifa17_profile "$staged"
local recorded actual
recorded="$(awk -F= '$1=="artifact_sha256"{print $2}' "$manifest")"
actual="$(sha256 "$staged")"
@@ -188,7 +158,7 @@ launch() {
local trace_enabled=0
local request_trace_enabled=0
local notifier_trace_enabled=0
local dispatch_enabled=0
local commit_enabled=0
case "$mode" in
baseline)
[[ "${OPENFUT_FIFA17_LAUNCH:-}" == "I_ACCEPT_M1_BASELINE_LAUNCH" ]] ||
@@ -207,11 +177,14 @@ launch() {
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"
commit)
[[ "${OPENFUT_FIFA17_COMMIT:-}" == "I_ACCEPT_POST_PARSE_READY_BYTE" ]] ||
die "launch-commit requires OPENFUT_FIFA17_COMMIT=I_ACCEPT_POST_PARSE_READY_BYTE"
hook_enabled=1
trace_enabled=1
request_trace_enabled=1
dispatch_enabled=1
notifier_trace_enabled=1
commit_enabled=1
;;
*) die "unknown launch mode: $mode" ;;
esac
@@ -234,7 +207,7 @@ launch() {
done
mkdir -p "${wine_prefix}/dosdevices"
ln -sfn /mnt "${wine_prefix}/dosdevices/w:"
note "Launching $mode mode (SBC_HOOK=$hook_enabled; SBC_TRACE=$trace_enabled; SBC_REQUEST_TRACE=$request_trace_enabled; SBC_NOTIFIER_TRACE=$notifier_trace_enabled; SBC_DISPATCH=$dispatch_enabled); log=/tmp/fifa17-hook-m1-launch.log"
note "Launching $mode mode (SBC_HOOK=$hook_enabled; SBC_TRACE=$trace_enabled; SBC_REQUEST_TRACE=$request_trace_enabled; SBC_NOTIFIER_TRACE=$notifier_trace_enabled; SBC_COMMIT=$commit_enabled); log=/tmp/fifa17-hook-m1-launch.log"
cd "$game_dir"
env \
GAMEID=fifa17 \
@@ -245,8 +218,8 @@ launch() {
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_DISPATCH=0 \
OPENFUT_SBC_COMMIT="$commit_enabled" \
OPENFUT_SBC_ARM_ONLY=0 \
OPENFUT_SBC_POPULATE=0 \
umu-run _fifa17.exe 2>&1 | tee /tmp/fifa17-hook-m1-launch.log
@@ -254,7 +227,7 @@ launch() {
usage() {
cat <<'EOF'
Usage: fifa17-hook-m1.sh [inspect|build|stage|deploy|launch|launch-resolve|launch-trace|launch-dispatch]
Usage: fifa17-hook-m1.sh [inspect|build|stage|deploy|launch|launch-resolve|launch-trace|launch-commit]
inspect Read-only PE/hash/export preflight (default).
build Cross-build the inert FIFA17 hook, then run inspect.
@@ -267,11 +240,11 @@ Usage: fifa17-hook-m1.sh [inspect|build|stage|deploy|launch|launch-resolve|launc
Start M2 resolve-only mode (guarded reads/logging, no detours/writes); requires:
OPENFUT_FIFA17_RESOLVE=I_ACCEPT_M2_RESOLVE_LAUNCH
launch-trace
Start the M3-M6 passive parser/request/notifier trace; requires:
Start the single M3 passive factory/deserializer 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
launch-commit
Trace and arm the SBC cache only after a validated native parse; requires:
OPENFUT_FIFA17_COMMIT=I_ACCEPT_POST_PARSE_READY_BYTE
Optional path overrides:
OPENFUT_FIFA17_HOOK_DLL, OPENFUT_FIFA17_GAME_DIR,
@@ -287,7 +260,7 @@ case "${1:-inspect}" in
launch) launch baseline ;;
launch-resolve) launch resolve ;;
launch-trace) launch trace ;;
launch-dispatch) launch dispatch ;;
launch-commit) launch commit ;;
-h|--help|help) usage ;;
*) usage >&2; die "unknown command: $1" ;;
esac
+2 -8
View File
@@ -379,10 +379,7 @@ mod tests {
#[test]
fn honest_zero_club_items_present() {
let g = global(&club_stats_body(
&[player(90, false, None)],
ContextField::Nation,
));
let g = global(&club_stats_body(&[player(90, false, None)], ContextField::Nation));
for atom in [
"stadia",
"balls",
@@ -414,10 +411,7 @@ mod tests {
.filter(|r| r["contextId"] == 3 && r["contextValue"] == 13)
.collect();
assert_eq!(league_rows.len(), 6);
let gold = league_rows
.iter()
.find(|r| r["type"] == "playersGold")
.unwrap();
let gold = league_rows.iter().find(|r| r["type"] == "playersGold").unwrap();
assert_eq!(gold["typeValue"], 1);
// league screen -> team (teamid) buckets: players/kits/badgeDBid (3 rows).
+50 -28
View File
@@ -24,9 +24,48 @@ pub fn accountinfo_body() -> Value {
json!({})
}
/// `GET …/settings` — production oracle returns an empty config list.
/// `GET …/settings` — the FUT client applies this `configs` array via the applier
/// `FUN_18011dc50`, the ONLY writer of the `IS_*` UI gate bytes (each
/// `byte = (field == 1)`). A flag never sent is a gate never opened — which is why
/// Single-Player Seasons/Draft refuse to open while issuing ZERO server requests
/// (`friendlySeasonsEnabled` → slot `[0x16]` → model byte `0x1fd3a`
/// `IS_FRIENDLY_SEASON_ENABLED`; RE-confirmed via pyghidra on CardsDLL).
///
/// Default (`OPENFUT_FUT_SETTINGS` unset/`off`) is the historical empty baseline.
/// `OPENFUT_FUT_SETTINGS=gates` populates the array. SAFETY: populating it is what
/// makes the applier RUN, and it then writes EVERY gate byte, so the already-live
/// store flags MUST be re-asserted here or enabling Seasons would clear the working
/// Store (those reach the client today via the Blaze FUT_RS4_CONFIG store, not
/// here). Mirrors the audited list in `utas_server.py` `_SETTINGS_KEEP`/`_GATES`.
pub fn settings_body() -> Value {
json!({ "configs": [] })
if std::env::var("OPENFUT_FUT_SETTINGS").as_deref() != Ok("gates") {
return json!({ "configs": [] });
}
// Already-live store flags re-asserted (pinned to their working state).
const KEEP: &[&str] = &[
"storeEnabled",
"storeEnabled_JP",
"coinEnabled",
"coinEnabled_JP",
"cardPackStoreEnabled",
"cardPackStoreEnabled_JP",
"pointsPackStoreEnabled",
"tradingEnabled",
];
// Mode gates that nothing has ever populated (the point of the change).
const GATES: &[&str] = &[
"friendlySeasonsEnabled",
"enableDraftMode",
"enableSinglePlayerDraftMode",
"enableOfflineDraftMode",
"tournamentQuitEnabled",
];
let configs: Vec<Value> = KEEP
.iter()
.chain(GATES.iter())
.map(|k| json!({ "type": k, "value": 1 }))
.collect();
json!({ "configs": configs })
}
/// `GET …/leaderboards/options` — production oracle (FUT_MODES off) returns an
@@ -67,19 +106,12 @@ pub fn feature_off_body() -> Value {
pub fn item_def(resource_id: i64) -> Value {
let asset = resource_id & 0xff_ffff;
// (name, rating, position, nation, leagueId, teamid, [6 attrs])
let (name, rating, pos, nation, league, team, attrs): (
&str,
i64,
&str,
i64,
i64,
i64,
[i64; 6],
) = if asset == 20801 {
("Ronaldo", 94, "ST", 38, 53, 243, [90, 93, 82, 91, 33, 80])
} else {
("Player", 75, "CM", 0, 0, 0, [70, 70, 70, 70, 70, 70])
};
let (name, rating, pos, nation, league, team, attrs): (&str, i64, &str, i64, i64, i64, [i64; 6]) =
if asset == 20801 {
("Ronaldo", 94, "ST", 38, 53, 243, [90, 93, 82, 91, 33, 80])
} else {
("Player", 75, "CM", 0, 0, 0, [70, 70, 70, 70, 70, 70])
};
let attribute_list: Vec<Value> = attrs
.iter()
.enumerate()
@@ -372,11 +404,7 @@ pub fn user_mass_info_body(
pub fn format_utc_datetime(epoch_secs: i64) -> String {
let days = epoch_secs.div_euclid(86_400);
let secs_of_day = epoch_secs.rem_euclid(86_400);
let (hour, min, sec) = (
secs_of_day / 3600,
(secs_of_day % 3600) / 60,
secs_of_day % 60,
);
let (hour, min, sec) = (secs_of_day / 3600, (secs_of_day % 3600) / 60, secs_of_day % 60);
// civil_from_days: days is a count of days since 1970-01-01.
let z = days + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
@@ -639,14 +667,8 @@ mod tests {
});
let body = user_mass_info_body(squad, 29_859_876, 0, 33_068_179, "Real FUT", "RF", "2016");
// Flat top-level envelope.
assert_eq!(
body["pileSizeClientData"]["entries"][0],
json!({"key": 2, "value": 100})
);
assert_eq!(
body["pileSizeClientData"]["entries"][1],
json!({"key": 4, "value": 50})
);
assert_eq!(body["pileSizeClientData"]["entries"][0], json!({"key": 2, "value": 100}));
assert_eq!(body["pileSizeClientData"]["entries"][1], json!({"key": 4, "value": 50}));
assert_eq!(body["settings"], json!({"configs": []}));
assert_eq!(body["userData"], json!({}));
// userInfo economy + club identity.
+4 -20
View File
@@ -90,30 +90,14 @@ pub fn generate_pack_contents(
}
let all: Vec<&GeneratedCandidate> = pool.iter().collect();
let gold: Vec<&GeneratedCandidate> = pool.iter().filter(|c| c.rating >= 75).collect();
let silver: Vec<&GeneratedCandidate> = pool
.iter()
.filter(|c| (65..75).contains(&c.rating))
.collect();
let silver: Vec<&GeneratedCandidate> =
pool.iter().filter(|c| (65..75).contains(&c.rating)).collect();
let bronze: Vec<&GeneratedCandidate> = pool.iter().filter(|c| c.rating < 65).collect();
let mut out = Vec::with_capacity(pack.count() as usize);
draw_tier(&mut out, &gold, &all, pack.n_gold, pack.special_chance, rng);
draw_tier(
&mut out,
&silver,
&all,
pack.n_silver,
pack.special_chance,
rng,
);
draw_tier(
&mut out,
&bronze,
&all,
pack.n_bronze,
pack.special_chance,
rng,
);
draw_tier(&mut out, &silver, &all, pack.n_silver, pack.special_chance, rng);
draw_tier(&mut out, &bronze, &all, pack.n_bronze, pack.special_chance, rng);
out
}
+2 -19
View File
@@ -116,15 +116,6 @@ pub fn challenges_body(set_id: i64, challenges: &[ChallengeView]) -> Value {
"status": "OPEN",
"timesCompleted": times_completed,
"awards": [],
// `elgReq` stays empty deliberately. Reversed from the pinned CardsDLL
// (2026-08-19, see ENDPOINT_MAP.md "Shared record shapes"): the client
// consumes `eligibilityKey`/`eligibilityOperation` only as ordinals that
// index the packed locale (`LOC_SBC_ELG_KEY_%d`) to render requirement
// text — it does NOT validate on them. The ordinal->string map is not
// recoverable from any asset we have, so any value we emit would show the
// WRONG requirement to the player. Submission is validated server-side by
// Core regardless; leaving this empty is display-only, never a correctness
// gap. Populate ONLY once the locale ordinal map is captured.
"elgReq": []
})
})
@@ -369,16 +360,8 @@ mod tests {
assert_eq!(
parse_wire_item_ids(retail).unwrap(),
[
100_004_227,
100_004_233,
100_001_317,
100_001_531,
100_000_966,
100_001_947,
100_000_169,
100_002_017,
100_002_765,
100_000_147,
100_004_227, 100_004_233, 100_001_317, 100_001_531, 100_000_966,
100_001_947, 100_000_169, 100_002_017, 100_002_765, 100_000_147,
100_000_311
],
"exactly the 11 non-zero players in wire order; manager and empty slots ignored"
+36 -144
View File
@@ -68,153 +68,45 @@ impl PackDef {
/// never purchasable/openable.
pub const PACK_CATALOG: &[PackDef] = &[
// ── Bronze category ──
PackDef {
id: 1,
name: "Bronze Pack",
price: 400,
n_bronze: 10,
n_silver: 2,
n_gold: 0,
rares: 1,
category: "bronze",
special_chance: 0.01,
owned_only: false,
},
PackDef {
id: 2,
name: "Premium Bronze Pack",
price: 750,
n_bronze: 10,
n_silver: 2,
n_gold: 0,
rares: 3,
category: "bronze",
special_chance: 0.02,
owned_only: false,
},
PackDef { id: 1, name: "Bronze Pack", price: 400,
n_bronze: 10, n_silver: 2, n_gold: 0, rares: 1, category: "bronze",
special_chance: 0.01, owned_only: false },
PackDef { id: 2, name: "Premium Bronze Pack", price: 750,
n_bronze: 10, n_silver: 2, n_gold: 0, rares: 3, category: "bronze",
special_chance: 0.02, owned_only: false },
// ── Silver category ──
PackDef {
id: 3,
name: "Silver Pack",
price: 2500,
n_bronze: 1,
n_silver: 11,
n_gold: 0,
rares: 1,
category: "silver",
special_chance: 0.015,
owned_only: false,
},
PackDef {
id: 4,
name: "Premium Silver Pack",
price: 3750,
n_bronze: 1,
n_silver: 11,
n_gold: 0,
rares: 3,
category: "silver",
special_chance: 0.03,
owned_only: false,
},
PackDef { id: 3, name: "Silver Pack", price: 2500,
n_bronze: 1, n_silver: 11, n_gold: 0, rares: 1, category: "silver",
special_chance: 0.015, owned_only: false },
PackDef { id: 4, name: "Premium Silver Pack", price: 3750,
n_bronze: 1, n_silver: 11, n_gold: 0, rares: 3, category: "silver",
special_chance: 0.03, owned_only: false },
// ── Gold category ──
PackDef {
id: 5,
name: "Gold Pack",
price: 5000,
n_bronze: 0,
n_silver: 2,
n_gold: 10,
rares: 1,
category: "gold",
special_chance: 0.04,
owned_only: false,
},
PackDef {
id: 6,
name: "Premium Gold Pack",
price: 7500,
n_bronze: 0,
n_silver: 2,
n_gold: 10,
rares: 3,
category: "gold",
special_chance: 0.06,
owned_only: false,
},
PackDef { id: 5, name: "Gold Pack", price: 5000,
n_bronze: 0, n_silver: 2, n_gold: 10, rares: 1, category: "gold",
special_chance: 0.04, owned_only: false },
PackDef { id: 6, name: "Premium Gold Pack", price: 7500,
n_bronze: 0, n_silver: 2, n_gold: 10, rares: 3, category: "gold",
special_chance: 0.06, owned_only: false },
// ── Reward (owned-only; opened from My Packs, never coin-purchasable) ──
PackDef {
id: 70,
name: "Reward Gold Pack",
price: 0,
n_bronze: 0,
n_silver: 0,
n_gold: 11,
rares: 11,
category: "gold",
special_chance: 1.0,
owned_only: true,
},
PackDef {
id: 71,
name: "Bronze Pack",
price: 0,
n_bronze: 10,
n_silver: 2,
n_gold: 0,
rares: 1,
category: "bronze",
special_chance: 0.01,
owned_only: true,
},
PackDef {
id: 72,
name: "Silver Pack",
price: 0,
n_bronze: 1,
n_silver: 11,
n_gold: 0,
rares: 1,
category: "silver",
special_chance: 0.02,
owned_only: true,
},
PackDef {
id: 73,
name: "Gold Pack",
price: 0,
n_bronze: 0,
n_silver: 2,
n_gold: 10,
rares: 1,
category: "gold",
special_chance: 0.05,
owned_only: true,
},
PackDef {
id: 74,
name: "Rare Gold Pack",
price: 0,
n_bronze: 0,
n_silver: 2,
n_gold: 10,
rares: 3,
category: "gold",
special_chance: 0.10,
owned_only: true,
},
PackDef {
id: 75,
name: "Icon Pack",
price: 0,
n_bronze: 0,
n_silver: 0,
n_gold: 12,
rares: 12,
category: "gold",
special_chance: 1.0,
owned_only: true,
},
PackDef { id: 70, name: "Reward Gold Pack", price: 0,
n_bronze: 0, n_silver: 0, n_gold: 11, rares: 11, category: "gold",
special_chance: 1.0, owned_only: true },
PackDef { id: 71, name: "Bronze Pack", price: 0,
n_bronze: 10, n_silver: 2, n_gold: 0, rares: 1, category: "bronze",
special_chance: 0.01, owned_only: true },
PackDef { id: 72, name: "Silver Pack", price: 0,
n_bronze: 1, n_silver: 11, n_gold: 0, rares: 1, category: "silver",
special_chance: 0.02, owned_only: true },
PackDef { id: 73, name: "Gold Pack", price: 0,
n_bronze: 0, n_silver: 2, n_gold: 10, rares: 1, category: "gold",
special_chance: 0.05, owned_only: true },
PackDef { id: 74, name: "Rare Gold Pack", price: 0,
n_bronze: 0, n_silver: 2, n_gold: 10, rares: 3, category: "gold",
special_chance: 0.10, owned_only: true },
PackDef { id: 75, name: "Icon Pack", price: 0,
n_bronze: 0, n_silver: 0, n_gold: 12, rares: 12, category: "gold",
special_chance: 1.0, owned_only: true },
];
/// Look up a catalogue pack by id (the 65534 sentinel is never present).
@@ -199,7 +199,7 @@
}
},
{
"assetId": 5,
"assetId": 70,
"id": 70,
"packType": "GOLD",
"description": "Reward Gold Pack",
@@ -212,13 +212,6 @@
"isPremium": false,
"sortPriority": 1,
"displayGroupAssetId": 3,
"currencies": [
{
"name": "coins",
"funds": 0,
"finalFunds": 0
}
],
"packContentInfo": {
"bronzeQuantity": 0,
"silverQuantity": 0,
-146
View File
@@ -1,146 +0,0 @@
#!/usr/bin/env python3
"""Authoritative Core state snapshot for FIFA17 FUT loop verification.
Queries openfut-core's authoritative read API directly (NOT the FIFA UI, NOT the
host projection, NOT Python) and emits a machine-readable JSON snapshot with the
integrity fields needed to detect, across any economy operation or a full stack
restart:
* coin drift (balance vs club.coins cross-check + absolute value)
* duplicate ownership (repeated owned_card_id)
* missing ownership (squad references an owned_card_id not in the collection)
* resurrection (owned-set hash changes when it must not)
* entitlement dup/count (unopened pack entitlements)
* stale squad mappings (squad player ids absent from ownership)
This is verification/oracle tooling, not production behavior. It reads only.
Usage:
core-snapshot.py [--core URL] [--game fifa17] [--squad-ns fifa17.squad]
[--out FILE] [--label NAME]
Env fallbacks: OPENFUT_CORE_URL, OPENFUT_GAME, OPENFUT_SQUAD_NS.
Exit code 0 always for a successful read; integrity problems are reported IN the
snapshot (`integrity.ok` / `integrity.problems`) so callers can diff/assert.
"""
import argparse
import hashlib
import json
import os
import sys
import urllib.error
import urllib.request
from datetime import datetime, timezone
def fetch(core, game, path):
req = urllib.request.Request(core + path, headers={"X-OpenFUT-Game": game})
try:
with urllib.request.urlopen(req, timeout=15) as f:
return f.status, f.read()
except urllib.error.HTTPError as e:
return e.code, e.read()
except Exception as e: # noqa: BLE001 - surface transport errors in the snapshot
return None, str(e).encode()
def fetch_json(core, game, path):
st, body = fetch(core, game, path)
if st != 200:
raise SystemExit(f"Core read {path} failed: status={st} body={body[:200]!r}")
return json.loads(body)
def sha(items):
h = hashlib.sha256()
for it in items:
h.update(str(it).encode())
h.update(b"\x00")
return h.hexdigest()
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--core", default=os.environ.get("OPENFUT_CORE_URL", "http://127.0.0.1:18101"))
ap.add_argument("--game", default=os.environ.get("OPENFUT_GAME", "fifa17"))
ap.add_argument("--squad-ns", default=os.environ.get("OPENFUT_SQUAD_NS", "fifa17.squad"))
ap.add_argument("--out")
ap.add_argument("--label", default="")
args = ap.parse_args()
balance = fetch_json(args.core, args.game, "/economy/balance")
entitlements = fetch_json(args.core, args.game, "/economy/entitlements")
profile = fetch_json(args.core, args.game, "/profile")
club = fetch_json(args.core, args.game, "/club")
collection = fetch_json(args.core, args.game, "/collection")["collection"]
squad = fetch_json(args.core, args.game, f"/squad/ext?namespace={args.squad_ns}")
coins_balance = balance.get("balance")
coins_club = club.get("coins")
owned_ids = sorted(x["owned_card_id"] for x in collection)
card_ids = sorted(x["card"]["id"] for x in collection)
dup_owned = sorted({i for i in owned_ids if owned_ids.count(i) > 1}) if len(owned_ids) != len(set(owned_ids)) else []
loans = [x["owned_card_id"] for x in collection if x.get("is_loan")]
# Squad player ownership references (from the opaque extension payload).
squad_player_ids = []
ext = squad.get("extension") or {}
payload_raw = ext.get("payload")
kit_numbers = {}
if payload_raw:
try:
payload = json.loads(payload_raw)
kit_numbers = payload.get("kit_numbers", {}) or {}
squad_player_ids = sorted(kit_numbers.keys())
except (json.JSONDecodeError, TypeError):
pass
owned_set = set(owned_ids)
squad_missing = sorted(pid for pid in squad_player_ids if pid not in owned_set)
problems = []
if coins_balance != coins_club:
problems.append(f"coin cross-check mismatch: balance={coins_balance} club={coins_club}")
if dup_owned:
problems.append(f"duplicate owned_card_id: {dup_owned[:10]} (+{max(0,len(dup_owned)-10)} more)")
if squad_missing:
problems.append(f"squad references non-owned ids: {squad_missing}")
snap = {
"label": args.label,
"captured_at": datetime.now(timezone.utc).isoformat(),
"core": args.core,
"game": args.game,
"coins": {"balance": coins_balance, "club": coins_club},
"profile": {"username": profile.get("username"), "level": profile.get("level"), "xp": profile.get("xp")},
"entitlements": {"count": len(entitlements), "ids": entitlements},
"collection": {
"owned": len(collection),
"distinct_owned_card_id": len(set(owned_ids)),
"distinct_card_id": len(set(card_ids)),
"loans": len(loans),
"duplicate_owned_card_id": dup_owned,
"owned_set_sha256": sha(owned_ids),
"card_multiset_sha256": sha(card_ids),
},
"squad": {
"namespace": args.squad_ns,
"verdict": squad.get("verdict"),
"player_count": len(squad_player_ids),
"player_ids": squad_player_ids,
"missing_from_ownership": squad_missing,
"ext_schema_version": ext.get("schema_version"),
},
"integrity": {"ok": not problems, "problems": problems},
}
text = json.dumps(snap, indent=2, sort_keys=True)
if args.out:
with open(args.out, "w") as f:
f.write(text + "\n")
print(text)
return 0
if __name__ == "__main__":
sys.exit(main())
-232
View File
@@ -1,232 +0,0 @@
#!/usr/bin/env python3
"""Attribute captured UTAS requests to labelled UI actions, and diff them.
utas-filter-diff.py --session <dir> [--baseline NO_FILTER]
The My Squad filter investigation needs to answer "which wire field changed
when I changed exactly one thing in the UI". That is a diff between labelled
groups of requests, so this tool needs both halves:
raw/utas.ofcap what the client sent
labels.txt MARKER <label> <HH:MM:SS> UTC lines, written when the human
said a search was done
Requests are attributed to the label whose marker most recently PRECEDES them.
Anything before the first marker is 'boot'.
Why a separate tool: the observer must stay a dumb, byte-faithful tee. Anything
that interprets traffic belongs outside it, so a mistake here can never affect
what was recorded.
"""
import argparse
import base64
import collections
import hashlib
import json
import os
import re
import sys
from urllib.parse import parse_qsl
def load(session):
"""Rebuild per-connection streams, keeping the wall time of each chunk."""
path = os.path.join(session, "raw", "utas.ofcap")
conns = collections.defaultdict(
lambda: {"c2s": bytearray(), "s2c": bytearray(), "marks": []}
)
with open(path) as f:
for line in f:
r = json.loads(line)
if "b64" not in r:
continue
c = conns[r["conn"]]
data = base64.b64decode(r["b64"])
if r["dir"] == "c2s":
c["marks"].append((len(c["c2s"]), r["unix"]))
c[r["dir"]].extend(data)
return conns
def labels(session):
out = []
p = os.path.join(session, "labels.txt")
if not os.path.exists(p):
return out
import datetime
for line in open(p):
m = re.match(r"MARKER\s+(\S+)\s+(\d\d):(\d\d):(\d\d)\s+UTC", line.strip())
if m:
name, h, mi, s = m.group(1), *map(int, m.groups()[1:])
out.append((name, h * 3600 + mi * 60 + s))
return out
def split_requests(buf):
"""(offset, method, target, headers, body) per request in a stream."""
out, i = [], 0
while True:
sep = buf.find(b"\r\n\r\n", i)
if sep < 0:
break
head = bytes(buf[i:sep]).decode("latin1")
line0 = head.split("\r\n")[0]
m = re.match(r"(\S+)\s+(\S+)\s+HTTP/", line0)
if not m:
break
hdrs = [h.split(":", 1) for h in head.split("\r\n")[1:] if ":" in h]
hdrs = [(k.strip(), v.strip()) for k, v in hdrs]
cl = next((int(v) for k, v in hdrs if k.lower() == "content-length" and v.isdigit()), 0)
start = sep + 4
body = bytes(buf[start:start + cl])
out.append((i, m.group(1), m.group(2), hdrs, body))
i = start + cl
return out
def split_responses(buf):
out, i = [], 0
while True:
sep = buf.find(b"\r\n\r\n", i)
if sep < 0:
break
head = bytes(buf[i:sep]).decode("latin1")
st = re.match(r"HTTP/\d\.\d\s+(\d+)", head)
cl = re.search(r"(?im)^content-length:\s*(\d+)\s*$", head)
te = re.search(r"(?im)^transfer-encoding:.*chunked", head)
start = sep + 4
if te:
j, body = start, bytearray()
while True:
nl = buf.find(b"\r\n", j)
if nl < 0:
return out
try:
n = int(bytes(buf[j:nl]).split(b";")[0], 16)
except ValueError:
return out
j = nl + 2
if n == 0:
j = buf.find(b"\r\n", j)
j = j + 2 if j >= 0 else len(buf)
break
body.extend(buf[j:j + n])
j += n + 2
out.append((int(st.group(1)) if st else None, bytes(body)))
i = j
elif cl:
n = int(cl.group(1))
out.append((int(st.group(1)) if st else None, bytes(buf[start:start + n])))
i = start + n
else:
out.append((int(st.group(1)) if st else None, b""))
i = start
return out
def time_at(marks, off):
t = marks[0][1] if marks else 0
for pos, unix in marks:
if pos > off:
break
t = unix
return t
def item_ids(body):
"""Item ids in a response, if it looks like an item list."""
try:
d = json.loads(body)
except Exception:
return None
ids = []
def walk(o):
if isinstance(o, dict):
if "id" in o and isinstance(o["id"], int):
ids.append(o["id"])
for v in o.values():
walk(v)
elif isinstance(o, list):
for v in o:
walk(v)
walk(d)
return ids
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--session", required=True)
ap.add_argument("--baseline", default="NO_FILTER")
a = ap.parse_args()
import datetime
conns = load(a.session)
marks = labels(a.session)
rows = []
for cid in sorted(conns):
c = conns[cid]
reqs = split_requests(c["c2s"])
resps = split_responses(c["s2c"])
for i, (off, method, target, hdrs, body) in enumerate(reqs):
unix = time_at(c["marks"], off)
secs = datetime.datetime.utcfromtimestamp(unix)
secs = secs.hour * 3600 + secs.minute * 60 + secs.second
label = "boot"
for name, at in marks:
if at <= secs:
label = name
status, rbody = resps[i] if i < len(resps) else (None, b"")
path, _, query = target.partition("?")
ids = item_ids(rbody)
rows.append({
"label": label,
"time": datetime.datetime.utcfromtimestamp(unix).strftime("%H:%M:%S"),
"conn": cid, "method": method, "path": path,
"query": dict(parse_qsl(query, keep_blank_values=True)) if query else {},
"query_raw": query,
"req_body": body.decode("latin1") if len(body) < 2000 else "<%dB>" % len(body),
"status": status,
"resp_len": len(rbody),
"resp_sha": hashlib.sha256(rbody).hexdigest()[:12],
"item_count": len(ids) if ids is not None else None,
"item_ids": ids[:40] if ids else None,
})
print("=== requests by label ===")
for r in rows:
if r["label"] == "boot":
continue
print("%-18s %s conn%-3d %-5s %-46s %s %5dB sha=%s items=%s"
% (r["label"], r["time"], r["conn"], r["method"], r["path"][:46],
r["status"], r["resp_len"], r["resp_sha"],
r["item_count"] if r["item_count"] is not None else "-"))
if r["query"]:
print("%-18s query: %s" % ("", r["query"]))
if r["req_body"].strip():
print("%-18s body : %s" % ("", r["req_body"][:200]))
# Field-level diff against the baseline label.
base = [r for r in rows if r["label"] == a.baseline]
if not base:
print("\n(no %s rows yet; skipping diff)" % a.baseline)
return 0
print("\n=== query-field diff vs %s ===" % a.baseline)
bq = base[-1]["query"]
bpath = base[-1]["path"]
seen = set()
for r in rows:
if r["label"] in ("boot", a.baseline) or r["label"] in seen:
continue
seen.add(r["label"])
added = {k: v for k, v in r["query"].items() if bq.get(k) != v}
removed = {k: v for k, v in bq.items() if k not in r["query"]}
note = "" if r["path"] == bpath else " PATH DIFFERS: %s" % r["path"]
print(" %-18s +%s -%s%s" % (r["label"], added or "{}", removed or "{}", note))
return 0
if __name__ == "__main__":
sys.exit(main())
+1 -13
View File
@@ -1,12 +1,5 @@
#!/usr/bin/env bash
# OpenFUT Setup Script — ⚠️ LEGACY (FIFA 23 core+bridge lineage)
#
# SUPERSEDED. The working target is FIFA 17. This script builds/starts the old
# FIFA 23 core+bridge stack and redirects EA domains for FIFA 23. Do NOT use it
# for the current server. Canonical server bring-up (FIFA 17):
# cd fifa17-recon/docker/fifa17-python && docker compose up -d
# Status: docs/PROJECT_STATE.md · Runbook: fifa17-recon/FUT-RUNBOOK.md
#
# OpenFUT Setup Script
# Builds, configures, and starts the OpenFUT offline FUT emulator for FIFA 23.
# Run as a regular user; the script will sudo only for hosts/cert/iptables steps.
@@ -414,11 +407,6 @@ EOF
}
# ── Dispatch ───────────────────────────────────────────────────────────────────
# --- Legacy guard: this is the superseded FIFA 23 flow (see header banner). ---
echo "[LEGACY] setup.sh drives the superseded FIFA 23 core+bridge stack." >&2
echo "[LEGACY] Canonical FIFA 17 server: cd fifa17-recon/docker/fifa17-python && docker compose up -d" >&2
echo "[LEGACY] Status: docs/PROJECT_STATE.md · Runbook: fifa17-recon/FUT-RUNBOOK.md" >&2
case "${1:-help}" in
quickstart) cmd_quickstart ;;
build) cmd_build ;;