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
61 changed files with 732 additions and 5580 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:
+3 -29
View File
@@ -1,37 +1,11 @@
# 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 — the address of THIS host as seen from the game machine
# (105). The responders advertise it to the client for every next hop (Blaze,
# roster, UTAS, POW). Compose refuses to start without it.
OPENFUT_ADVERTISE=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
+11 -7
View File
@@ -37,14 +37,18 @@ RUN set -eu; \
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.
# Redirector/roster TLS cert (CN/SAN = winter15.gosredirector.ea.com). ProtoSSL
# cert-verify is patched client-side, so a self-signed cert is fine — but the
# client dials the roster and redirector BY IP, and that path still checks the
# SAN against the dialed address (it is NOT covered by the two patched gates), so
# a cert without a matching IP SAN is rejected with fatal certificate_unknown
# (docs/FIFA17_FUT_SQUAD_UPDATE_TLS.md). The advertised LAN IP is a RUNTIME value,
# unknown here, so this bakes only a loopback-IP baseline and the entrypoint
# reissues with IP:$OPENFUT_ADVERTISE at start.
#
# 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.
# openssl therefore has to remain in the image for the entrypoint, not be dropped
# with the apt lists. The pair is git-ignored (*.pem/*.key); regenerate if absent
# so a fresh checkout builds without extra steps.
RUN apt-get update && apt-get install -y --no-install-recommends openssl && \
rm -rf /var/lib/apt/lists/*
RUN if [ ! -s tools/redir_cert.pem ] || [ ! -s tools/redir_key.pem ]; then \
@@ -3,9 +3,9 @@
# cp .env.example .env # set OPENFUT_ADVERTISE to THIS host's LAN IP
# docker compose up -d --build
#
# Brings up the 5 responders the game dials. OPENFUT_ADVERTISE is the server IP
# handed out for Blaze, UTAS, telemetry, and QoS; OPENFUT_ROSTER_HOST is the
# certificate DNS identity handed out for roster HTTPS.
# Brings up the 5 responders the game dials. OPENFUT_ADVERTISE is the address
# the servers hand the client (105) for every next hop (Blaze, roster, UTAS,
# POW) and is required — there is no silent loopback fallback in remote mode.
#
# The client (105) still needs its first-hop redirect (hook or DNAT) plus
# autopatch.py running locally; see client_arm.sh and the FIFARUNBOOK.
@@ -25,9 +25,6 @@ services:
# Address advertised to the client for the next hop. MUST be this host's
# LAN IP as seen from the game machine (105). Required (see .env.example).
OPENFUT_ADVERTISE: "${OPENFUT_ADVERTISE:?set OPENFUT_ADVERTISE in .env to this host's LAN IP, e.g. 203.0.113.10}"
# FIFA17 roster TLS matches only certificate dNSName SANs. The client must
# resolve this hostname to OPENFUT_ADVERTISE.
OPENFUT_ROSTER_HOST: "${OPENFUT_ROSTER_HOST:-winter15.gosredirector.ea.com:8081}"
# POW content advertises port 8080 by default, which collides with the
# openfut-core publish on this host. Remap it to 8085 on the host and
# advertise the remapped endpoint.
@@ -39,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
+23 -52
View File
@@ -8,39 +8,45 @@
# 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
# Address behaviour is driven by two env vars (see each responder):
# OPENFUT_BIND bind address for every listener (container: 0.0.0.0)
# OPENFUT_ADVERTISE address handed to the client for the next hop
# (the server's LAN IP, e.g. 203.0.113.10)
# ============================================================================
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"
echo "[openfut] bind=$BIND advertise=$ADV"
# 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
# The TLS cert every responder serves must carry the ADVERTISED IP in its SAN.
# The client dials the roster (:8081) and redirector by that IP, and that path
# validates the cert's SAN against the dialed address — it is NOT covered by the
# two client-side ProtoSSL gates autopatch patches, so a cert lacking IP:$ADV is
# rejected with fatal certificate_unknown and the FUT hub fails with "An error
# occurred downloading the FUT Squad Update" (docs/FIFA17_FUT_SQUAD_UPDATE_TLS.md).
# The advertised IP is unknown at image-build time, so reconcile it here: reissue
# only when the current cert does not already carry it, so a restart reuses the
# same cert (no per-start fingerprint churn) and this self-heals if $ADV changes.
CERT=redir_cert.pem KEY=redir_key.pem
if ! openssl x509 -in "$CERT" -noout -ext subjectAltName 2>/dev/null | grep -qF "IP Address:$ADV"; then
echo "[openfut] reissuing TLS cert with SAN IP:$ADV (was missing it)"
openssl req -x509 -newkey rsa:2048 -nodes -keyout "$KEY" -out "$CERT" -days 3650 \
-subj "/CN=winter15.gosredirector.ea.com" \
-addext "subjectAltName=DNS:winter15.gosredirector.ea.com,DNS:*.gosredirector.ea.com,DNS:*.ea.com,IP:$ADV,IP:127.0.0.1" \
>/dev/null 2>&1 \
&& echo "[openfut] cert SAN now: $(openssl x509 -in "$CERT" -noout -ext subjectAltName 2>/dev/null | tail -1 | tr -s ' ')" \
|| { echo "[openfut] FATAL: could not reissue TLS cert" >&2; exit 1; }
fi
echo "[openfut] roster certificate matches $ROSTER_NAME; fingerprint: $(openssl x509 -in "$CERT" -noout -fingerprint -sha256)"
# name script extra-env
declare -a SERVERS=(
@@ -51,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
@@ -93,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.**
+11 -12
View File
@@ -147,13 +147,14 @@ def refresh_account_identity():
# ================================================================== 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.
# Client/server split support (OpenFUT dev-container): two env vars, both
# defaulting to loopback so the original all-on-localhost flow is byte-identical.
# OPENFUT_BIND — the address the listeners bind (0.0.0.0 in a container).
# OPENFUT_ADVERTISE — the address this server hands back to the client for the
# NEXT hop (Blaze host, roster/UTAS/telemetry/QoS URLs). On
# 105-local this is 127.0.0.1; on the 120 server it is the
# server's LAN IP so the game dials 120 directly after the
# first (hook/DNAT-redirected) contact.
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")
@@ -562,11 +563,9 @@ OSDK_TICKER = []
# never gets advance/back -> the silent FUT loading-screen hang. The store is the
# MERGED '_all' section (getSection @0x14719e050), so any fetched CFID works; this
# branch does NOT wrap the value ("https://%s" is only the ini path) -> ABSOLUTE url.
# Serve HTTPS (EA's production value is https; the DirtySDK download manager may
# reject http). FIFA17's roster verifier accepts dNSName SANs but ignores
# iPAddress SANs, so an IP-literal URL fails with certificate_unknown. A remote
# deployment can advertise a certificate DNS name without changing other hosts.
ROSTER_HOST = os.environ.get("OPENFUT_ROSTER_HOST") or "%s:8081" % _ADVERTISE
# Serve HTTPS (EA's production value is https; the DirtySDK download mgr may reject
# http). Our ProtoSSL cert-verify is patched (autopatch), so a self-signed cert is OK.
ROSTER_HOST = "%s:8081" % _ADVERTISE
POW_CONTENT_HOST = os.environ.get("POW_CONTENT_HOST", "127.0.0.1:8080")
OSDK_ROSTER = [
("ROSTERUPDATE_URL", "https://%s/fifa17/fut/rosterupdate.xml" % ROSTER_HOST),
+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
-100
View File
@@ -1,100 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Read back the MANAGER-ONLY chemistry slots the client resolved, and prove
whether the server's `nation`/`leagueId` actually land in the record.
READ-ONLY. /proc/PID/mem is opened 'rb'; there is no write path in this file.
WHY THIS EXISTS
---------------
`card_identity_probe` reads the PLAYER slots (F_NATION = 0x148, F_LEAGUE =
0x154). A manager does not use those, so grading a manager with that tool
reports nation=0 / leagueId=0 and looks like a server bug when it is only the
wrong offsets.
`fifa17-recon/tools/fut_staff.py` records the manager layout from Ghidra:
rec+0x94 teamid (read by the card view-model)
rec+0xde nation MANAGER-ONLY slot, u16
rec+0xe0 leagueId MANAGER-ONLY slot, u16
rec+0xe2 talkrating written by the managercards merge
rec+0xe3 negotiation written by the managercards merge
The merge (FUN_1801356c0) NEVER writes +0xde or +0xe0, so whatever sits there
came from OUR JSON and nowhere else. That makes those two u16s a direct,
unambiguous test of the server's manager chemistry fields: if they read back as
the values we served, the wire contract is PROVEN rather than inferred; if they
read zero, the client discarded them and manager chemistry cannot be rendering.
Usage: python3 manager_chem_probe.py # grade every manager in the map
"""
import sys
import watch_club_model as W
import card_identity_probe as P
MANAGER_CARDTYPE = 2 # FUN_1800d8330: cardsubtypeid 4 -> cardtype 2
F_CARDTYPE = 0x4C
F_RESOURCE = 0x18
F_TEAMID = 0x94
F_NATION_MGR = 0xDE
F_LEAGUE_MGR = 0xE0
F_TALKRATING = 0xE2
F_NEGOTIATION = 0xE3
REC_SIZE = 0x158
def main():
pid = W.find_pid()
if pid is None:
print("FIFA17.exe is not running.")
return 1
base = W.dll_base(pid)
if base is None:
print("pid %d is up but %s is not mapped yet." % (pid, W.DLL))
return 1
mem = W.Mem(pid)
obj = mem.q(base + (W.G_CARDSDB - W.IMG_BASE))
if not obj:
print("CardsDb singleton is NULL (no FUT session loaded).")
return 1
ns = W.nodes(mem, obj) if hasattr(W, "nodes") else P.nodes(mem, obj)
print("pid=%d CardsDb=%#x walked=%d" % (pid, obj, len(ns)))
print()
print("%-10s %-8s %-8s %-8s %-10s %-10s %s"
% ("resource", "teamid", "nation", "league", "talkrating", "negot", "verdict"))
found = 0
for n in ns:
rec = n + 0x28
buf = mem.read(rec, REC_SIZE)
if not buf or len(buf) < REC_SIZE:
continue
if P.u8(buf, F_CARDTYPE) != MANAGER_CARDTYPE:
continue
found += 1
resource = P.u32(buf, F_RESOURCE)
teamid = P.u32(buf, F_TEAMID)
nation = P.u16(buf, F_NATION_MGR)
league = P.u16(buf, F_LEAGUE_MGR)
talk = P.u8(buf, F_TALKRATING)
negot = P.u8(buf, F_NEGOTIATION)
# +0xde and +0xe0 are never written by the merge, so a non-zero value
# can only have come from the server's JSON.
if nation and league:
verdict = "SERVER FIELDS LANDED"
elif nation or league:
verdict = "PARTIAL -- one slot empty"
else:
verdict = "EMPTY -- client kept nothing we sent"
print("%-10d %-8d %-8d %-8d %-10d %-10d %s"
% (resource, teamid, nation, league, talk, negot, verdict))
if not found:
print("(no manager record in the map -- the client has not been served one)")
return 0
if __name__ == "__main__":
sys.exit(main())
-107
View File
@@ -1,107 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Dump the CLASSIFICATION fields the client stored for every card it holds, so
the subtype->cardtype map and the itemState runtime values are read from the
running game instead of inferred.
READ-ONLY. /proc/PID/mem is opened 'rb'; there is no write path in this file.
WHY THIS EXISTS
---------------
Two things this project has repeatedly had to treat as INFERRED:
1. `FUN_1800d8330`'s cardsubtypeid -> cardtype map. It is read out of Ghidra
(0..3->1 players, 4->2 manager, 5->3 headcoach, 6->10 gkcoach, 7->5 physio,
8->4 fitnesscoach, 9..b->7), and the kit selector gate `FUN_1801c3480`
branches on cardtype == 7. Serving a subtype whose cardtype we guessed
wrong fails SILENTLY, because cardtype 9 has no arm in the merge.
2. The itemState enum. The table at 0x180229d20 gives the tokens; the RUNTIME
values the strings deserialize to (notably activeHomeKit/activeAwayKit ->
101/102) have been carried as inferred.
Both are directly observable: the parser writes cardsubtypeid to rec+0x50, the
derived cardtype to rec+0x4c, and the decoded itemState to rec+0x5c. Reading
those back for every record turns the pair into measurements.
rec+0x18 resourceId
rec+0x4c cardtype (derived by FUN_1800d8330 from cardsubtypeid)
rec+0x50 cardsubtypeid (as sent)
rec+0x5c itemState (decoded enum value)
Usage: python3 record_vocab_probe.py
"""
import collections
import sys
import watch_club_model as W
import card_identity_probe as P
F_RESOURCE = 0x18
F_CARDTYPE = 0x4C
F_SUBTYPE = 0x50
F_ITEMSTATE = 0x5C
REC_SIZE = 0x158
# What the Ghidra read of FUN_1800d8330 predicts, so a disagreement is loud.
EXPECTED_CARDTYPE = {0: 1, 1: 1, 2: 1, 3: 1, 4: 2, 5: 3, 6: 10, 7: 5, 8: 4,
9: 7, 10: 7, 11: 7}
def main():
pid = W.find_pid()
if pid is None:
print("FIFA17.exe is not running.")
return 1
base = W.dll_base(pid)
if base is None:
print("pid %d is up but %s is not mapped yet." % (pid, W.DLL))
return 1
mem = W.Mem(pid)
obj = mem.q(base + (W.G_CARDSDB - W.IMG_BASE))
if not obj:
print("CardsDb singleton is NULL (no FUT session loaded).")
return 1
ns = W.nodes(mem, obj) if hasattr(W, "nodes") else P.nodes(mem, obj)
print("pid=%d CardsDb=%#x walked=%d\n" % (pid, obj, len(ns)))
pairs = collections.Counter()
states = collections.Counter()
rows = []
for n in ns:
buf = mem.read(n + 0x28, REC_SIZE)
if not buf or len(buf) < REC_SIZE:
continue
resource = P.u32(buf, F_RESOURCE)
cardtype = P.u8(buf, F_CARDTYPE)
subtype = P.u8(buf, F_SUBTYPE)
state = P.u8(buf, F_ITEMSTATE)
pairs[(subtype, cardtype)] += 1
states[state] += 1
rows.append((resource, subtype, cardtype, state))
print("%-12s %-9s %-9s %s" % ("resource", "subtype", "cardtype", "itemState"))
for r in sorted(rows):
print("%-12d %-9d %-9d %d" % r)
print("\n--- MEASURED cardsubtypeid -> cardtype ---")
for (sub, ct), n in sorted(pairs.items()):
want = EXPECTED_CARDTYPE.get(sub)
if want is None:
verdict = "no Ghidra prediction for this subtype"
elif want == ct:
verdict = "agrees with FUN_1800d8330"
else:
verdict = "DISAGREES -- Ghidra said %d" % want
print(" subtype %-4d -> cardtype %-4d (%d record(s)) %s" % (sub, ct, n, verdict))
print("\n--- MEASURED itemState runtime values ---")
for st, n in sorted(states.items()):
print(" %-5d %d record(s)" % (st, n))
print("\nNOTE: a runtime value only appears here if the client was actually")
print("served an item in that state. Absence is not evidence of absence.")
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -1,61 +0,0 @@
#!/usr/bin/env python3
"""Standalone contract test for Blaze roster-host advertisement."""
import importlib
import os
import sys
TOOLS = os.path.dirname(os.path.abspath(__file__))
if TOOLS not in sys.path:
sys.path.insert(0, TOOLS)
ADVERTISE = "192.0.2.10"
DNS_HOST = "winter15.gosredirector.ea.com:8081"
def assert_roster_config(blaze, host):
config = dict(blaze.OSDK_ROSTER)
assert blaze.ROSTER_HOST == host
assert config["ROSTERUPDATE_URL"] == (
f"https://{host}/fifa17/fut/rosterupdate.xml"
)
assert config["ROSTER_URL"] == f"https://{host}/fifa17/roster/"
assert config["ROSTER_VER"] == "0"
assert config["ROSTER_CSUM"] == ""
def main():
old_advertise = os.environ.get("OPENFUT_ADVERTISE")
old_roster_host = os.environ.get("OPENFUT_ROSTER_HOST")
try:
os.environ["OPENFUT_ADVERTISE"] = ADVERTISE
os.environ.pop("OPENFUT_ROSTER_HOST", None)
import blaze_responder_v3b as blaze
blaze = importlib.reload(blaze)
assert_roster_config(blaze, f"{ADVERTISE}:8081")
os.environ["OPENFUT_ROSTER_HOST"] = DNS_HOST
blaze = importlib.reload(blaze)
assert_roster_config(blaze, DNS_HOST)
os.environ["OPENFUT_ROSTER_HOST"] = ""
blaze = importlib.reload(blaze)
assert_roster_config(blaze, f"{ADVERTISE}:8081")
finally:
if old_advertise is None:
os.environ.pop("OPENFUT_ADVERTISE", None)
else:
os.environ["OPENFUT_ADVERTISE"] = old_advertise
if old_roster_host is None:
os.environ.pop("OPENFUT_ROSTER_HOST", None)
else:
os.environ["OPENFUT_ROSTER_HOST"] = old_roster_host
print("PASS: roster host defaults, override, and URLs")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+1 -63
View File
@@ -38,22 +38,6 @@ pub struct Fifa17CardIdentity {
/// FIFA `cardsubtypeid` for a non-player definition (consumable family /
/// staff role), `0` for a player or when absent.
pub subtype: i64,
/// FIFA card-art class. Players default to `asset_id`; kit definitions carry
/// the verified `fcc_kitcards.cardassetid` value (`35`).
pub card_asset_id: u32,
/// Source team id for a club kit, or a manager's real club. Zero for content
/// kinds that do not use it.
pub team_id: i64,
/// Manager chemistry nation (`managercards.nation`), zero when unused.
///
/// The client NEVER supplies this: the managercards merge (`FUN_1801356c0`)
/// leaves the manager-only record slot `rec+0xde` untouched, so the server is
/// its only source. See `fifa17-recon/tools/fut_staff.py`.
pub nation: i64,
/// Manager chemistry league, zero when unused. Derived upstream through
/// `manager.teamid` → `leagueteamlinks.leagueid`, because `managercards` has
/// no league column. Lands in the equally untouched slot `rec+0xe0`.
pub league_id: i64,
}
/// The FIFA 17 numeric namespace policy for owned-item wire ids.
@@ -74,29 +58,6 @@ impl Fifa17WireItemIdPolicy {
pub fn owned_item_base_floor() -> i64 {
Self::OWNED_ITEM_BASE + 1
}
/// Identity scope for MATCH session ids.
///
/// A match id is deliberately NOT drawn from the owned-item scope. The
/// oracle mints both from one counter, which is why an observed match id
/// looks like an item id — but that is an artifact of a single-counter save
/// file, not a client requirement. Here the identity store keeps a real
/// reverse map, so an item-scoped match id would make
/// `owned_id_for_wire` resolve a match to a bogus owned card and corrupt
/// quick-sell and move. The store is generic over `(game, kind)`, so a
/// separate scope costs one constant and cannot collide with, or advance,
/// the owned-item watermark.
pub const MATCH_KIND: &'static str = "match";
/// Base for match session ids. Clear of the owned-item range
/// (`100_000_000+`) and of every synthetic overlay range the responder
/// reserves (`≥ 9e8`). The client only requires a non-zero int.
pub const MATCH_BASE: i64 = 200_000_000;
/// First match wire id (`200_000_001`).
pub fn match_base_floor() -> i64 {
Self::MATCH_BASE + 1
}
}
/// Highest representable asset id (24 bits); above this `version` would be
@@ -170,18 +131,6 @@ struct RawCard {
/// FIFA `cardsubtypeid` for a non-player entry; absent → `0`.
#[serde(default)]
subtype: i64,
/// Separate card-art id for non-player definitions; absent → `asset_id`.
#[serde(default)]
card_asset_id: Option<u32>,
/// Source team id for a kit or manager definition; absent → `0`.
#[serde(default)]
team_id: Option<i64>,
/// Manager chemistry nation; absent → `0`.
#[serde(default)]
nation: Option<i64>,
/// Manager chemistry league; absent → `0`.
#[serde(default)]
league_id: Option<i64>,
}
fn default_rareflag() -> i64 {
@@ -238,10 +187,6 @@ impl Fifa17CardCatalog {
rareflag: rc.rareflag,
kind: ContentKind::from_str(&rc.kind),
subtype: rc.subtype,
card_asset_id: rc.card_asset_id.unwrap_or(rc.asset_id),
team_id: rc.team_id.unwrap_or(0),
nation: rc.nation.unwrap_or(0),
league_id: rc.league_id.unwrap_or(0),
},
);
}
@@ -452,9 +397,7 @@ mod tests {
r#"{"schema_version":1,"game":"fifa17","cards":{
"fifa17_20801":{"asset_id":20801,"kind":"player","subtype":0},
"fifa17_5003012":{"asset_id":5003012,"kind":"consumable","subtype":54,"rareflag":0},
"fifa17_3000083":{"asset_id":3000083,"kind":"staff","subtype":8,"rareflag":0},
"fifa17_6300006":{"asset_id":6300006,"kind":"kit","subtype":9,
"card_asset_id":35,"team_id":21,"rareflag":0}
"fifa17_3000083":{"asset_id":3000083,"kind":"staff","subtype":8,"rareflag":0}
}}"#,
)
.unwrap();
@@ -464,10 +407,5 @@ mod tests {
assert_eq!(cat.kind_of("fifa17_3000083"), ContentKind::Staff);
assert_eq!(cat.subtype_of("fifa17_3000083"), 8);
assert_eq!(cat.lookup("fifa17_5003012").unwrap().rareflag, 0);
let kit = cat.lookup("fifa17_6300006").unwrap();
assert_eq!(kit.kind, ContentKind::Kit);
assert_eq!(kit.subtype, 9);
assert_eq!(kit.card_asset_id, 35);
assert_eq!(kit.team_id, 21);
}
}
+20 -196
View File
@@ -11,73 +11,33 @@ use serde_json::{json, Value};
use crate::fut::content_taxonomy::ContentKind;
use crate::fut::entities::ReverseEntityResolver;
use crate::fut::item::{shape_item, shape_kit_item, shape_staff_item, STAFF_CONTRACT};
use crate::fut::item::shape_item;
// Re-exported so existing `club_response::{…}` callers keep working; the types
// are now defined once in `fut::item`.
pub use crate::fut::item::{
CoreOwnedItem, Fifa17Identity, Fifa17KitIdentity, Fifa17StaffIdentity, ItemIdentityResolver,
ShapeStats,
};
pub use crate::fut::item::{CoreOwnedItem, Fifa17Identity, ItemIdentityResolver, ShapeStats};
/// Active club-level kit roles, keyed by Core owned-instance id.
#[derive(Debug, Clone, Copy, Default)]
pub struct ActiveKitAssignments<'a> {
pub home: Option<&'a str>,
pub away: Option<&'a str>,
}
/// Shape the player portion of `/club` (the historical/default query).
/// Shape the whole `/club` response. Items without a resolvable real asset id
/// are dropped (counted in `ShapeStats`), never emitted with a fabricated id.
pub fn shape_club_response<I: ItemIdentityResolver + ?Sized>(
items: &[CoreOwnedItem],
ent: &impl ReverseEntityResolver,
ident: &I,
) -> (Value, ShapeStats) {
shape_club_response_with_kits(items, ent, ident, ActiveKitAssignments::default())
}
/// Shape `/club` items, including ownership-backed active kit designations.
/// Consumables/staff remain excluded because they use separate wire envelopes.
pub fn shape_club_response_with_kits<I: ItemIdentityResolver + ?Sized>(
items: &[CoreOwnedItem],
ent: &impl ReverseEntityResolver,
ident: &I,
active_kits: ActiveKitAssignments<'_>,
) -> (Value, ShapeStats) {
let mut out = Vec::with_capacity(items.len());
let mut stats = ShapeStats::default();
for item in items {
match ident.kind_of(item) {
ContentKind::Player => match ident.resolve(item) {
Some(id) => {
out.push(shape_item(item, id, ent));
stats.emitted += 1;
}
None => stats.dropped_no_asset += 1,
},
ContentKind::Kit => match ident.resolve_kit(item) {
Some(id) => {
let item_state = if active_kits.home == Some(item.owned_card_id.as_str()) {
"activeHomeKit"
} else if active_kits.away == Some(item.owned_card_id.as_str()) {
"activeAwayKit"
} else {
"free"
};
out.push(shape_kit_item(id, item_state));
stats.emitted += 1;
}
None => stats.dropped_no_asset += 1,
},
ContentKind::Staff => match ident.resolve_staff(item) {
Some(id) => {
out.push(shape_staff_item(id, STAFF_CONTRACT));
stats.emitted += 1;
}
None => stats.dropped_no_asset += 1,
},
ContentKind::Consumable => {
stats.excluded_non_player += 1;
// Exclude non-player content (consumables/staff): a `/club` player list
// must never render them as 0-rated players. Counted, never emitted.
if ident.kind_of(item) != ContentKind::Player {
stats.excluded_non_player += 1;
continue;
}
match ident.resolve(item) {
Some(id) => {
out.push(shape_item(item, id, ent));
stats.emitted += 1;
}
None => stats.dropped_no_asset += 1,
}
}
(json!({ "itemData": out }), stats)
@@ -247,19 +207,11 @@ mod tests {
struct KindMapIdentity {
ids: HashMap<String, Fifa17Identity>,
kinds: HashMap<String, ContentKind>,
kits: HashMap<String, Fifa17KitIdentity>,
staff: HashMap<String, Fifa17StaffIdentity>,
}
impl ItemIdentityResolver for KindMapIdentity {
fn resolve(&self, it: &CoreOwnedItem) -> Option<Fifa17Identity> {
self.ids.get(&it.card_id).copied()
}
fn resolve_kit(&self, it: &CoreOwnedItem) -> Option<Fifa17KitIdentity> {
self.kits.get(&it.card_id).copied()
}
fn resolve_staff(&self, it: &CoreOwnedItem) -> Option<Fifa17StaffIdentity> {
self.staff.get(&it.card_id).copied()
}
fn kind_of(&self, it: &CoreOwnedItem) -> ContentKind {
self.kinds
.get(&it.card_id)
@@ -269,7 +221,7 @@ mod tests {
}
#[test]
fn consumables_are_excluded_but_staff_is_shaped() {
fn consumable_and_staff_are_excluded_from_club_players() {
let ent = entities();
let id = |item_id: u32, asset: u32| Fifa17Identity {
item_id,
@@ -281,19 +233,8 @@ mod tests {
ids: HashMap::from([
("card_player".to_string(), id(100000001, 20801)),
("card_consumable".to_string(), id(100000002, 5003012)),
("card_staff".to_string(), id(100000003, 3000083)),
]),
kits: HashMap::new(),
staff: HashMap::from([(
"card_staff".to_string(),
Fifa17StaffIdentity {
item_id: 100000003,
resource_id: 3000083,
subtype: 8,
nation: 0,
league_id: 0,
team_id: 0,
},
)]),
kinds: HashMap::from([
("card_consumable".to_string(), ContentKind::Consumable),
("card_staff".to_string(), ContentKind::Staff),
@@ -313,129 +254,12 @@ mod tests {
item("oc3", "card_staff", 0, "", "", "", ""),
];
let (body, stats) = shape_club_response(&items, &ent, &ident);
assert_eq!(
stats.emitted, 2,
"the player and the staff card are emitted"
);
assert_eq!(
stats.excluded_non_player, 1,
"only the consumable is excluded; staff has a wire envelope of its own"
);
assert_eq!(stats.emitted, 1, "only the player is emitted");
assert_eq!(stats.excluded_non_player, 2, "consumable + staff excluded");
assert_eq!(stats.dropped_no_asset, 0);
let arr = body["itemData"].as_array().unwrap();
assert_eq!(arr.len(), 2);
assert_eq!(arr.len(), 1);
assert_eq!(arr[0]["id"], 100000001, "the player survives");
assert_eq!(arr[0]["itemType"], "player");
let coach = &arr[1];
assert_eq!(coach["id"], 100000003);
assert_eq!(coach["resourceId"], 3000083);
assert_eq!(coach["cardsubtypeid"], 8);
assert_eq!(coach["itemType"], "staff");
assert_eq!(coach["contract"], STAFF_CONTRACT);
assert!(
coach.get("nation").is_none()
&& coach.get("leagueId").is_none()
&& coach.get("teamid").is_none(),
"a COACH has no nation/league/team column in the client's tables, so \
those keys must be absent rather than invented as zeroes"
);
assert!(
coach.get("attributeList").is_none() && coach.get("preferredPosition").is_none(),
"both survive the client's merge and are read by the card view-model"
);
}
#[test]
fn manager_carries_the_chemistry_fields_only_the_server_can_supply() {
let ent = entities();
let ident = KindMapIdentity {
ids: HashMap::new(),
kits: HashMap::new(),
staff: HashMap::from([(
"card_manager".to_string(),
Fifa17StaffIdentity {
item_id: 100004871,
resource_id: 1000509,
subtype: 4,
nation: 45,
league_id: 53,
team_id: 241,
},
)]),
kinds: HashMap::from([("card_manager".to_string(), ContentKind::Staff)]),
};
let items = vec![item("oc-mgr", "card_manager", 0, "", "", "", "")];
let (body, stats) = shape_club_response(&items, &ent, &ident);
assert_eq!(stats.emitted, 1);
let mgr = &body["itemData"][0];
assert_eq!(
mgr["cardsubtypeid"], 4,
"subtype alone selects managercards"
);
assert_eq!(
mgr["resourceId"], 1000509,
"the merge key is read RAW: it must equal the carddbid with no version byte"
);
// rec+0xde / rec+0xe0 / rec+0x94 — the merge never writes these, so an
// omission here is an unrecoverable blank flag and zero chemistry.
assert_eq!(mgr["nation"], 45);
assert_eq!(mgr["leagueId"], 53);
assert_eq!(mgr["teamid"], 241);
assert_eq!(mgr["contract"], STAFF_CONTRACT);
assert_eq!(mgr["itemState"], "free");
assert_eq!(mgr["owners"], 1);
let keys: Vec<&String> = mgr.as_object().unwrap().keys().collect();
assert_eq!(
keys.len(),
11,
"exactly the 11 justified keys, no more: {keys:?}"
);
}
#[test]
fn kits_project_with_owned_active_home_and_away_states() {
let ent = entities();
let kit = |item_id, resource_id, team_id| Fifa17KitIdentity {
item_id,
asset_id: resource_id,
resource_id,
card_asset_id: 35,
subtype: 9,
team_id,
};
let ident = KindMapIdentity {
ids: HashMap::new(),
staff: HashMap::new(),
kits: HashMap::from([
("kit-home".into(), kit(100000010, 6300006, 21)),
("kit-away".into(), kit(100000011, 6400003, 21)),
]),
kinds: HashMap::from([
("kit-home".into(), ContentKind::Kit),
("kit-away".into(), ContentKind::Kit),
]),
};
let items = vec![
item("owned-home", "kit-home", 0, "", "", "", ""),
item("owned-away", "kit-away", 0, "", "", "", ""),
];
let (body, stats) = shape_club_response_with_kits(
&items,
&ent,
&ident,
ActiveKitAssignments {
home: Some("owned-home"),
away: Some("owned-away"),
},
);
assert_eq!(stats.emitted, 2);
assert_eq!(body["itemData"][0]["resourceId"], 6300006);
assert_eq!(body["itemData"][0]["cardassetid"], 35);
assert_eq!(body["itemData"][0]["cardsubtypeid"], 9);
assert_eq!(body["itemData"][0]["teamid"], 21);
assert_eq!(body["itemData"][0]["itemState"], "activeHomeKit");
assert_eq!(body["itemData"][1]["itemState"], "activeAwayKit");
assert!(body["itemData"][0].get("attributeList").is_none());
assert!(body["itemData"][0].get("itemType").is_none());
}
}
+8 -148
View File
@@ -6,8 +6,7 @@
//! (`FUN_18012fd40` atom table). The body is `{"stat":[{contextId,contextValue,
//! type,typeValue}, …]}`:
//! * a GLOBAL bucket (contextId 1, contextValue 0) with player tier counts,
//! staff/consumable families, owned-kit count, and honest zeros for other
//! club-item families;
//! staff-by-family, consumables-by-family, and honest zeros for club items;
//! * per-NATION buckets (contextId 3, contextValue = nation id) with the tier
//! counts the MY CLUB summary panel sums into PLAYERS_EMPLOYED.
//!
@@ -24,18 +23,14 @@ use serde_json::{json, Value};
use crate::fut::content_taxonomy::{consumable_family, ContentKind};
/// One owned item, already classified from the catalog + entity tables by the
/// host. `subtype`/`rare`/`asset_id` come from the FIFA catalog; `nation_id`/
/// `league_id`/`team_id` from the reverse entity resolver for players and from
/// the kit table for kits (None = unresolved, bucket skipped).
/// host. `subtype`/`rare` come from the FIFA catalog; `nation_id`/`league_id`/
/// `team_id` from the reverse entity resolver (None = unresolved, bucket skipped).
#[derive(Debug, Clone)]
pub struct ClubStatInput {
pub kind: ContentKind,
pub subtype: i64,
pub rating: i64,
pub rare: bool,
/// Base FIFA asset id. For a kit this is the `fcc_kitcards.assetid` family
/// discriminator, which is what splits the home/away kit counters.
pub asset_id: i64,
pub nation_id: Option<i64>,
pub league_id: Option<i64>,
pub team_id: Option<i64>,
@@ -62,25 +57,8 @@ const S_RARE: i64 = 0x05;
const S_STAFF: i64 = 0x0A;
const S_CONSUMABLES: i64 = 0x3C;
const S_KITS: i64 = 0x28;
const S_KITS_HOME: i64 = 0x29;
const S_KITS_AWAY: i64 = 0x2A;
const S_BADGES: i64 = 0x2D;
/// First `carddbid` of the AWAY kit family. `fcc_kitcards` is split into a
/// `63xxxxx` home family and a `64xxxxx` away family, and the table's own
/// `assetid` column agrees exactly: across all 1482 rows, assetid 14 covers
/// precisely the 828 `63xxxxx` ids and assetid 15 precisely the 654 `64xxxxx`
/// ids, with no exceptions either way. A kit's catalog `asset_id` IS its
/// carddbid, so the id itself is the family key -- the `assetid` column is not
/// carried on the wire and would be a second source of truth for the same fact.
const KIT_AWAY_FLOOR: i64 = 6_400_000;
/// Which kit family an owned kit belongs to. Only meaningful for
/// [`ContentKind::Kit`]; the caller filters first.
fn is_home_kit(kit: &ClubStatInput) -> bool {
kit.asset_id < KIT_AWAY_FLOOR
}
/// cardsubtypeid (staff family) -> stat id (STAFF_SUBTYPE_STAT).
fn staff_stat(subtype: i64) -> Option<i64> {
match subtype {
@@ -237,21 +215,12 @@ pub fn club_stats_body(items: &[ClubStatInput], ctx: ContextField) -> Value {
}
g.insert(S_CONSUMABLES, cons_total);
// Club items: kits are Core-owned and counted (total plus the home/away
// family split); unimplemented families stay honest zeros.
// club items: honest zeros (Core holds none; each is read by some panel).
for sid in [
0x14, 0x1E, 0x2D, 0x2E, 0x2F, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38,
0x14, 0x1E, 0x28, 0x29, 0x2A, 0x2D, 0x2E, 0x2F, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38,
] {
g.entry(sid).or_insert(0);
}
let kits: Vec<&ClubStatInput> = items
.iter()
.filter(|item| matches!(item.kind, ContentKind::Kit))
.collect();
let home = kits.iter().filter(|kit| is_home_kit(kit)).count() as i64;
g.insert(S_KITS, kits.len() as i64);
g.insert(S_KITS_HOME, home);
g.insert(S_KITS_AWAY, kits.len() as i64 - home);
let mut stat: Vec<Value> = g.iter().map(|(sid, v)| row(1, 0, *sid, *v)).collect();
@@ -269,30 +238,10 @@ pub fn club_stats_body(items: &[ClubStatInput], ctx: ContextField) -> Value {
by_ctx.entry(id).or_default().push(p);
}
}
// A kit belongs to the team that wears it and has no nation/league of its
// own, so it only buckets on the team screen -- and it buckets there even if
// the club owns no player from that team, which is the normal case for a kit
// won from a pack.
let mut kits_by_team: BTreeMap<i64, i64> = BTreeMap::new();
if ctx == ContextField::Team {
for kit in &kits {
if let Some(id) = kit.team_id {
*kits_by_team.entry(id).or_insert(0) += 1;
by_ctx.entry(id).or_default();
}
}
}
for (cid, sel) in &by_ctx {
if ctx == ContextField::Team {
stat.push(row(3, *cid, S_PLAYERS, sel.len() as i64));
stat.push(row(
3,
*cid,
S_KITS,
kits_by_team.get(cid).copied().unwrap_or(0),
));
stat.push(row(3, *cid, S_KITS, 0));
stat.push(row(3, *cid, 0x2E, 0)); // badgeDBid
} else {
let gold = sel.iter().filter(|i| i.rating >= 75).count() as i64;
@@ -321,7 +270,6 @@ mod tests {
subtype: 0,
rating,
rare,
asset_id: 158023,
nation_id: nation,
league_id: None,
team_id: None,
@@ -333,7 +281,6 @@ mod tests {
subtype,
rating: 0,
rare: false,
asset_id: 0,
nation_id: None,
league_id: None,
team_id: None,
@@ -345,34 +292,11 @@ mod tests {
subtype,
rating: 0,
rare: false,
asset_id: 0,
nation_id: None,
league_id: None,
team_id: None,
}
}
/// A kit worn by `team`. `carddbid` is the real `fcc_kitcards` id, which is
/// also the catalog `asset_id` and therefore the home/away family key.
fn kit_of(carddbid: i64, team: i64) -> ClubStatInput {
ClubStatInput {
kind: ContentKind::Kit,
subtype: 9,
rating: 0,
rare: false,
asset_id: carddbid,
nation_id: None,
league_id: None,
team_id: Some(team),
}
}
/// Real team-21 kits from `fcc_kitcards`: 6300006 is its home kit and
/// 6400003 its away kit.
const HOME_KIT: i64 = 6_300_006;
const AWAY_KIT: i64 = 6_400_003;
fn kit() -> ClubStatInput {
kit_of(HOME_KIT, 21)
}
fn global(body: &Value) -> std::collections::HashMap<String, i64> {
body["stat"]
@@ -431,64 +355,6 @@ mod tests {
assert_eq!(g["consumablesTrainingPlayerPlayStyle"], 1);
}
#[test]
fn owned_kits_increment_global_kit_count() {
let items = vec![player(90, false, None), kit(), kit()];
let g = global(&club_stats_body(&items, ContextField::Nation));
assert_eq!(g["players"], 1);
assert_eq!(g["kits"], 2);
}
/// `kits` is the total and `kitsHome`/`kitsAway` are its family split, the
/// same total/subset shape as players/playersGold and staff/staffManager.
#[test]
fn kit_counts_split_by_home_and_away_family() {
let items = vec![
kit_of(HOME_KIT, 21),
kit_of(6_300_010, 38),
kit_of(AWAY_KIT, 21),
];
let g = global(&club_stats_body(&items, ContextField::Nation));
assert_eq!(g["kits"], 3);
assert_eq!(g["kitsHome"], 2);
assert_eq!(g["kitsAway"], 1);
}
/// A kit buckets onto the team that wears it -- including a team the club
/// owns no player from, which is the normal case for a kit won from a pack.
#[test]
fn kits_bucket_onto_their_own_team_on_the_team_screen() {
let mut with_team = player(90, false, None);
with_team.team_id = Some(21);
let items = vec![
with_team,
kit_of(HOME_KIT, 21),
kit_of(AWAY_KIT, 21),
kit_of(6_300_010, 38),
];
let body = club_stats_body(&items, ContextField::Team);
let kits_for = |team: i64| {
body["stat"]
.as_array()
.unwrap()
.iter()
.find(|r| r["contextId"] == 3 && r["contextValue"] == team && r["type"] == "kits")
.map(|r| r["typeValue"].as_i64().unwrap())
};
assert_eq!(kits_for(21), Some(2));
// Team 38 has no players, so only the kit creates its bucket.
assert_eq!(kits_for(38), Some(1));
// A nation/league screen has no team context, so kits stay out of it.
let nation = club_stats_body(&items, ContextField::Nation);
assert!(nation["stat"]
.as_array()
.unwrap()
.iter()
.filter(|r| r["contextId"] == 3 && r["type"] == "kits")
.all(|r| r["typeValue"] == 0));
}
#[test]
fn nation_buckets_emitted_and_players_excludes_nonplayers() {
let items = vec![
@@ -513,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",
@@ -548,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).
@@ -18,7 +18,7 @@
//! resolves to `None` — the caller DEFERS it (mirroring the player NoName gate),
//! never fabricating a family.
/// The disjoint content classes a FIFA 17 owned item can belong to. Player is
/// The disjoint content classes a FIFA 17 owned card can belong to. Player is
/// the default so a catalog authored before this taxonomy existed (no `kind`
/// field) still classifies every entry as a player, unchanged.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
@@ -27,7 +27,6 @@ pub enum ContentKind {
Player,
Consumable,
Staff,
Kit,
}
impl ContentKind {
@@ -37,7 +36,6 @@ impl ContentKind {
ContentKind::Player => "player",
ContentKind::Consumable => "consumable",
ContentKind::Staff => "staff",
ContentKind::Kit => "kit",
}
}
@@ -51,7 +49,6 @@ impl ContentKind {
match s {
"consumable" => ContentKind::Consumable,
"staff" => ContentKind::Staff,
"kit" => ContentKind::Kit,
_ => ContentKind::Player,
}
}
@@ -83,12 +80,6 @@ pub fn consumable_family(subtype: i64) -> Option<(&'static str, &'static str)> {
Some(pair)
}
/// `cardsubtypeid` of a MANAGER staff card. This value alone selects the
/// `managercards` merge in the client (`FUN_1800d8330` → cardtype 2 →
/// `FUN_1801356c0`), and it is what distinguishes a manager from the four coach
/// families inside [`ContentKind::Staff`].
pub const MANAGER_SUBTYPE: i64 = 4;
/// The staff role + honest display label for a staff `cardsubtypeid` (4..=8), or
/// `None` for any other subtype (→ DEFER). Grounded in the `FUN_1800d8330`
/// family selector.
@@ -115,7 +106,6 @@ mod tests {
ContentKind::Player,
ContentKind::Consumable,
ContentKind::Staff,
ContentKind::Kit,
] {
assert_eq!(ContentKind::from_str(k.as_str()), k);
}
@@ -2,13 +2,52 @@
//!
//! These translate FIFA 17 wire semantics into the generic amounts the host
//! feeds to Core economy authority. They own NO state — Core owns balances and
//! inventory; these are the FIFA-specific numbers/derivations. Pack prices come
//! from the Store catalogue; the transfer-market fee is the FUT-era 5%.
//!
//! Match result mapping + reward-body shaping live in [`crate::fut::match_wire`].
//! inventory; these are the FIFA-specific numbers/derivations. Values are the
//! current OpenFUT economy (match rewards are the Python oracle's
//! `MATCH_COINS`/`MATCH_PARTICIPATION` at production defaults); pack prices come
//! from the Store catalogue.
use crate::fut::store_catalog::pack_by_id;
/// Normalized match outcome for reward purposes.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MatchResult {
Win,
Draw,
Loss,
}
/// Participation award added to every match reward (oracle `MATCH_PARTICIPATION`
/// default = 0).
pub const MATCH_PARTICIPATION: i64 = 0;
/// Per-result match coins (oracle `MATCH_COINS`: won 400 / draw 200 / loss 100).
pub fn match_result_coins(result: MatchResult) -> i64 {
match result {
MatchResult::Win => 400,
MatchResult::Draw => 200,
MatchResult::Loss => 100,
}
}
/// Total match reward = per-result coins + participation.
pub fn match_reward_total(result: MatchResult) -> i64 {
match_result_coins(result) + MATCH_PARTICIPATION
}
/// Derive the outcome from the match `endReason` enum (the oracle's primary
/// signal, `_END_REASON`). Unknown/absent reasons default to `Draw`, matching
/// the oracle's conservative default. Score-based derivation is a fallback the
/// oracle also supports; the enum is authoritative when present.
pub fn result_from_end_reason(end_reason: Option<&str>) -> MatchResult {
match end_reason.unwrap_or("").to_ascii_uppercase().as_str() {
"WIN" | "DNF_WIN" => MatchResult::Win,
"LOSS" | "QUIT" | "DNF" | "DNF_LOSS" => MatchResult::Loss,
// "DRAW", "DNF_DRAW", "NO_CONTEST", unknown -> draw.
_ => MatchResult::Draw,
}
}
/// The Store buy-now price for a pack id (`None` for unknown/owned-only packs,
/// which are never purchasable).
pub fn pack_price(pack_id: u64) -> Option<u64> {
@@ -61,6 +100,24 @@ pub fn seller_proceeds(gross: i64) -> i64 {
mod tests {
use super::*;
#[test]
fn match_rewards_match_oracle() {
assert_eq!(match_reward_total(MatchResult::Win), 400);
assert_eq!(match_reward_total(MatchResult::Draw), 200);
assert_eq!(match_reward_total(MatchResult::Loss), 100);
}
#[test]
fn end_reason_maps_to_outcome() {
assert_eq!(result_from_end_reason(Some("WIN")), MatchResult::Win);
assert_eq!(result_from_end_reason(Some("dnf_win")), MatchResult::Win);
assert_eq!(result_from_end_reason(Some("LOSS")), MatchResult::Loss);
assert_eq!(result_from_end_reason(Some("QUIT")), MatchResult::Loss);
assert_eq!(result_from_end_reason(Some("DRAW")), MatchResult::Draw);
assert_eq!(result_from_end_reason(None), MatchResult::Draw);
assert_eq!(result_from_end_reason(Some("weird")), MatchResult::Draw);
}
#[test]
fn pack_price_rejects_unknown_and_owned_only() {
assert!(pack_price(1).is_some());
+1 -123
View File
@@ -24,7 +24,7 @@
use serde_json::{json, Value};
use crate::fut::content_taxonomy::{ContentKind, MANAGER_SUBTYPE};
use crate::fut::content_taxonomy::ContentKind;
use crate::fut::entities::ReverseEntityResolver;
/// One owned item in game-independent terms, as read from Core's inventory.
@@ -66,59 +66,11 @@ pub struct Fifa17Identity {
pub rareflag: i64,
}
/// FIFA-side identity fields needed to render an owned club kit. Unlike player
/// items, kit art and source-team metadata come from `fcc_kitcards`, not Core.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Fifa17KitIdentity {
pub item_id: u32,
pub asset_id: u32,
pub resource_id: u32,
pub card_asset_id: u32,
pub subtype: i64,
pub team_id: i64,
}
/// FIFA-side identity fields needed to render an owned staff card (manager or
/// coach). Unlike a player, a staff record carries NO attributes, rating,
/// position or rareflag: the client merges all of those from its own
/// `managercards`/`*coachcards` tables keyed on `resource_id`.
///
/// `nation`/`league_id`/`team_id` are meaningful for a MANAGER only
/// (`subtype == MANAGER_SUBTYPE`) and are zero for the four coach families,
/// whose tables carry no nation/league/team column.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Fifa17StaffIdentity {
pub item_id: u32,
/// THE merge key, read RAW as a u32 by `FUN_1801356c0` with NO `& 0xffffff`
/// mask (players are the only family that is masked). It must equal the
/// table `carddbid` exactly — a non-zero version byte silently breaks the
/// lookup, and the manager branch has no else-arm to report the miss.
pub resource_id: u32,
/// `cardsubtypeid`. This ALONE selects which staff table the client merges.
pub subtype: i64,
pub nation: i64,
pub league_id: i64,
pub team_id: i64,
}
/// Supplies the FIFA numeric identity for a Core item. Returning `None` means
/// "no real FIFA asset id known" → the caller must not fabricate one.
pub trait ItemIdentityResolver {
fn resolve(&self, item: &CoreOwnedItem) -> Option<Fifa17Identity>;
/// Resolve one owned kit definition. Default `None` preserves existing
/// player-only resolvers; the catalog-backed FIFA17 resolver overrides it.
fn resolve_kit(&self, _item: &CoreOwnedItem) -> Option<Fifa17KitIdentity> {
None
}
/// Resolve one owned staff definition (manager or coach). Default `None`
/// preserves existing resolvers; the catalog-backed FIFA17 resolver
/// overrides it.
fn resolve_staff(&self, _item: &CoreOwnedItem) -> Option<Fifa17StaffIdentity> {
None
}
/// Classify a Core item's definition as player/consumable/staff. Defaults to
/// [`ContentKind::Player`] so existing resolvers keep their behaviour; a
/// catalog-backed resolver overrides this to consult its `kind_of`, letting
@@ -206,80 +158,6 @@ pub fn shape_item(
})
}
/// Build one FIFA 17 club-kit item. `item_state` is the proven wire enum token:
/// `free`, `activeHomeKit`, or `activeAwayKit`; the client deserializes the
/// latter two to runtime values 101 and 102.
pub fn shape_kit_item(id: Fifa17KitIdentity, item_state: &str) -> Value {
json!({
"id": id.item_id,
"resourceId": id.resource_id,
"assetId": id.asset_id,
"cardassetid": id.card_asset_id,
"cardsubtypeid": id.subtype,
"itemState": item_state,
"owners": 1,
"untradeable": false,
"teamid": id.team_id,
})
}
/// Contracts remaining on an owned staff card.
///
/// Staff consume contracts exactly as players do (`rec+0x8c`), and the client
/// refuses to start a match when the manager's has run out. Core does not model
/// staff contracts, so this mirrors the constant [`shape_item`] already emits
/// for players rather than inventing a second, different default.
pub const STAFF_CONTRACT: i64 = 7;
/// Build one FIFA 17 staff item (manager or coach).
///
/// The key set is deliberately minimal and is taken field-by-field from the
/// instruction-level reversal in `fifa17-recon/tools/fut_staff.py`, where every
/// key is justified by its CardsDLL record offset:
///
/// * `id` → `rec+0x08`, `resourceId` → `rec+0x18` (the RAW merge key),
/// `cardsubtypeid` → `rec+0x50` (alone selects which staff table is merged),
/// `contract` → `rec+0x8c`, `itemState` → `rec+0x5c`, `owners` → `rec+0x48`,
/// `untradeable` → `rec+0x49`.
/// * `nation` → `rec+0xde` and `leagueId` → `rec+0xe0` are MANAGER-ONLY record
/// slots the client's merge never writes, so the server is their only source;
/// they drive the manager's flag, league badge and both halves of manager
/// chemistry. `teamid` → `rec+0x94` is read by the card view-model.
///
/// Everything else is omitted on purpose, because each is either overwritten by
/// the merge from the client's own table (`assetId`/`cardassetid` at `rec+0x20`,
/// `rating` at `rec+0xb4`, `rareflag` at `rec+0x58`), skipped by the parser
/// (`definitionId`), or — worse — SURVIVES the merge and is then read by the
/// view-model, which would hang a position label or an attribute row on a
/// manager (`preferredPosition` at `rec+0x146`, `attributeList` at `rec+0x98`).
/// A staff card must therefore never be routed through [`shape_item`].
///
/// The four coach families carry no nation/league/team columns in the client's
/// tables, so those three keys are emitted for a manager only rather than being
/// invented as zeroes for a coach.
pub fn shape_staff_item(id: Fifa17StaffIdentity, contract: i64) -> Value {
let mut item = json!({
"id": id.item_id,
"resourceId": id.resource_id,
"cardsubtypeid": id.subtype,
// Inert on the wire (the parser reads atom 0x173 into a stack string and
// frees it), but it is what every staff family reports, and our own
// readers use it to tell a staff card from a footballer at a glance.
"itemType": "staff",
"contract": contract,
"itemState": "free",
"owners": 1,
"untradeable": false,
});
if id.subtype == MANAGER_SUBTYPE {
let obj = item.as_object_mut().expect("json! built an object");
obj.insert("nation".to_string(), json!(id.nation));
obj.insert("leagueId".to_string(), json!(id.league_id));
obj.insert("teamid".to_string(), json!(id.team_id));
}
item
}
#[cfg(test)]
mod tests {
use super::*;
@@ -1,266 +0,0 @@
//! FIFA 17 `/match` + `/match/end` wire ↔ Core match-economy mapping.
//!
//! This module owns the FIFA 17-specific match protocol: the `endReason` enum
//! (wire atom 260), the `PUT …/match/end` payload shape, and the reward-response
//! body. It is pure — no state, no Core calls. The host wires it to OpenFUT
//! Core's authoritative `complete_match` transaction:
//!
//! 1. [`parse_match_end`] turns the client payload into a [`MatchEnd`].
//! 2. [`MatchResult::core_token`] gives Core the canonical, game-independent
//! result string — Core never sees a FIFA `endReason`.
//! 3. Core applies the economy exactly once and returns the authoritative coin
//! numbers, which the host renders back through [`reward_response`].
//!
//! Keeping every FIFA 17 constant here (never in Core) is the layering contract:
//! a second title's adapter maps its own wire onto the same canonical tokens.
use serde_json::{json, Value};
/// Participation award added to every match reward. FIFA 17 economy parameter
/// (oracle `MATCH_PARTICIPATION`, production default `0`). Core owns the coin
/// balance; this is only the wire body's cosmetic `participationAward` field.
pub const MATCH_PARTICIPATION: i64 = 0;
/// Canonical match result. The FIFA 17 `endReason` enum (atom 260) is the
/// AUTHORITATIVE source [STATIC_REVERSED]; this is the normalized shape the host
/// forwards to Core.
///
/// * `Win` / `Draw` / `Loss` — a decided match.
/// * `Dnf` — the reporting player abandoned/quit (`DNF`/`QUIT`). Economically a
/// loss (LIVE_PROVEN: `endReason=DNF` → loss reward), tracked in its own Core
/// statistics bucket.
/// * `NoContest` — a voided match; zero economic effect.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MatchResult {
Win,
Draw,
Loss,
Dnf,
NoContest,
}
impl MatchResult {
/// The canonical Core result token — the ONLY match datum the adapter hands
/// Core. Matches `openfut_core::models::match_result::MatchResultKind`'s serde
/// representation exactly (`win`/`draw`/`loss`/`dnf`/`no_contest`).
pub fn core_token(self) -> &'static str {
match self {
MatchResult::Win => "win",
MatchResult::Draw => "draw",
MatchResult::Loss => "loss",
MatchResult::Dnf => "dnf",
MatchResult::NoContest => "no_contest",
}
}
}
/// Map the FIFA 17 `endReason` enum onto a canonical [`MatchResult`].
///
/// The enum is authoritative when present [STATIC_REVERSED]. `DNF`/`QUIT` are the
/// reporting player's abandon (→ `Dnf`, loss economics, LIVE_PROVEN); the
/// `DNF_WIN`/`DNF_DRAW`/`DNF_LOSS` variants carry a decided outcome (the opponent
/// abandoned) and map to that outcome. `NO_CONTEST` voids the match. An
/// absent/unknown reason is a conservative `Draw`, matching the oracle default.
pub fn result_from_end_reason(end_reason: Option<&str>) -> MatchResult {
match end_reason.unwrap_or("").to_ascii_uppercase().as_str() {
"WIN" => MatchResult::Win,
"DRAW" => MatchResult::Draw,
"LOSS" => MatchResult::Loss,
"DNF" | "QUIT" => MatchResult::Dnf,
"DNF_WIN" => MatchResult::Win,
"DNF_DRAW" => MatchResult::Draw,
"DNF_LOSS" => MatchResult::Loss,
"NO_CONTEST" => MatchResult::NoContest,
_ => MatchResult::Draw,
}
}
/// A parsed `PUT …/match/end` payload: the fields the host needs to drive Core.
/// Unknown fields are ignored.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MatchEnd {
/// The raw `endReason` string, if the client sent one.
pub end_reason: Option<String>,
/// Canonical result derived from `end_reason`.
pub result: MatchResult,
/// `matchReportId` from the payload (observed `0` on the live path).
pub match_report_id: i64,
/// Goals scored by the reporting player — `myMatchStats[0]` (goals is the
/// first of the 15 ints). Absent on `DNF`/`QUIT` (stats omitted) → `0`.
pub goals_for: i64,
/// Opponent goals — `opponentMatchStats[0]`. Absent on `DNF`/`QUIT` → `0`.
pub goals_against: i64,
}
/// Parse the FIFA 17 match-end body. Returns `None` for a body that is not a
/// JSON object (malformed). A well-formed object with a missing/unknown
/// `endReason` still parses — the result defaults to `Draw`.
pub fn parse_match_end(body: &[u8]) -> Option<MatchEnd> {
let v: Value = serde_json::from_slice(body).ok()?;
if !v.is_object() {
return None;
}
let end_reason = v
.get("endReason")
.and_then(Value::as_str)
.map(str::to_string);
let result = result_from_end_reason(end_reason.as_deref());
let match_report_id = v.get("matchReportId").and_then(Value::as_i64).unwrap_or(0);
Some(MatchEnd {
end_reason,
result,
match_report_id,
goals_for: first_stat(&v, "myMatchStats"),
goals_against: first_stat(&v, "opponentMatchStats"),
})
}
/// `myMatchStats`/`opponentMatchStats` are 15 ints with goals first
/// [STATIC_REVERSED]; the arrays are OMITTED on DNF/QUIT, so a missing array is
/// `0` goals, not an error.
fn first_stat(v: &Value, key: &str) -> i64 {
v.get(key)
.and_then(Value::as_array)
.and_then(|a| a.first())
.and_then(Value::as_i64)
.unwrap_or(0)
}
/// Build the FIFA 17 match-reward response body (the `destroy_match_body` shape,
/// [STATIC_REVERSED]).
///
/// `all_coins` is Core's AUTHORITATIVE post-credit balance; `match_coins` is the
/// amount Core granted for THIS match (mirrored into `gameModeAward.coins`, where
/// the client reads it). Emits ONLY the reversed fields — it NEVER emits
/// `bidTokens` or `qualifiedChampionEventId`, which are client freeze traps.
pub fn reward_response(all_coins: i64, match_coins: i64) -> Value {
json!({
"allCoins": all_coins,
"matchCoins": match_coins,
"seasonCoins": 0,
"tournamentCoins": 0,
"boostConis": 0, // EA's misspelling (atom 96), preserved on the wire.
"participationAward": MATCH_PARTICIPATION,
"teamOfTournamentWinner": false,
"gameModeAward": { "coins": match_coins },
})
}
/// Build the FIFA 17 `POST …/match` create ack. Zero economic effect: it only
/// hands the client a match id + start time. `id` doubles as the per-match
/// identity the host later keys Core's exactly-once completion on.
pub fn create_response(id: i64, start_epoch: i64) -> Value {
json!({
"startDateTime": start_epoch,
"reportIdEnabled": false,
"id": id,
})
}
/// Build the FIFA 17 `…/match/ready` ack (FutMatchReady). Zero economic effect.
///
/// Two scalars only. The response type also has an optional nested item list,
/// which is deliberately omitted: a nested value the client half-reads is the
/// documented freeze mode, and nothing needs it here. `opponent_persona_id` is
/// echoed from the request when the client supplies one and is otherwise `0` —
/// an offline AI opponent has no persona, and it must NEVER default to the
/// player's own persona, which would claim the user is their own opponent.
pub fn ready_response(match_id: i64, opponent_persona_id: i64) -> Value {
json!({
"matchId": match_id,
"opponentPersonaId": opponent_persona_id,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_end_reason_maps_to_canonical_result() {
assert_eq!(result_from_end_reason(Some("WIN")), MatchResult::Win);
assert_eq!(result_from_end_reason(Some("DRAW")), MatchResult::Draw);
assert_eq!(result_from_end_reason(Some("LOSS")), MatchResult::Loss);
assert_eq!(result_from_end_reason(Some("DNF")), MatchResult::Dnf);
assert_eq!(result_from_end_reason(Some("QUIT")), MatchResult::Dnf);
assert_eq!(
result_from_end_reason(Some("NO_CONTEST")),
MatchResult::NoContest
);
assert_eq!(result_from_end_reason(Some("DNF_WIN")), MatchResult::Win);
assert_eq!(result_from_end_reason(Some("DNF_DRAW")), MatchResult::Draw);
assert_eq!(result_from_end_reason(Some("DNF_LOSS")), MatchResult::Loss);
// Case-insensitive.
assert_eq!(result_from_end_reason(Some("dnf_win")), MatchResult::Win);
// Unknown / absent → conservative draw.
assert_eq!(result_from_end_reason(Some("weird")), MatchResult::Draw);
assert_eq!(result_from_end_reason(None), MatchResult::Draw);
}
#[test]
fn core_tokens_match_core_serde() {
assert_eq!(MatchResult::Win.core_token(), "win");
assert_eq!(MatchResult::Draw.core_token(), "draw");
assert_eq!(MatchResult::Loss.core_token(), "loss");
assert_eq!(MatchResult::Dnf.core_token(), "dnf");
assert_eq!(MatchResult::NoContest.core_token(), "no_contest");
}
#[test]
fn parse_match_end_reads_reason_and_goals() {
let body = br#"{"matchReportId":7,"endReason":"WIN","myMatchStats":[3,1,2,0,0,0,0,0,0,0,0,0,0,0,0],"opponentMatchStats":[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0]}"#;
let end = parse_match_end(body).expect("parses");
assert_eq!(end.result, MatchResult::Win);
assert_eq!(end.match_report_id, 7);
assert_eq!(end.goals_for, 3);
assert_eq!(end.goals_against, 1);
}
#[test]
fn parse_match_end_dnf_omits_stats_as_zero() {
// The live DNF payload: stats arrays omitted entirely.
let body = br#"{"matchReportId":0,"endReason":"DNF","items":[],"matchData":"","matchStatusFlags":0}"#;
let end = parse_match_end(body).expect("parses");
assert_eq!(end.result, MatchResult::Dnf);
assert_eq!(end.goals_for, 0);
assert_eq!(end.goals_against, 0);
}
#[test]
fn parse_match_end_unknown_reason_defaults_draw() {
let end = parse_match_end(br#"{"endReason":"BANANA"}"#).expect("parses");
assert_eq!(end.result, MatchResult::Draw);
assert_eq!(end.end_reason.as_deref(), Some("BANANA"));
}
#[test]
fn parse_match_end_rejects_malformed() {
assert!(parse_match_end(b"not json").is_none());
assert!(parse_match_end(b"[]").is_none());
assert!(parse_match_end(b"42").is_none());
}
#[test]
fn reward_response_has_only_reversed_fields() {
let body = reward_response(29_876_876, 100);
assert_eq!(body["allCoins"], 29_876_876);
assert_eq!(body["matchCoins"], 100);
assert_eq!(body["gameModeAward"]["coins"], 100);
assert_eq!(body["seasonCoins"], 0);
assert_eq!(body["tournamentCoins"], 0);
assert_eq!(body["boostConis"], 0);
assert_eq!(body["participationAward"], 0);
assert_eq!(body["teamOfTournamentWinner"], false);
// The freeze traps must never appear.
assert!(body.get("bidTokens").is_none());
assert!(body.get("qualifiedChampionEventId").is_none());
}
#[test]
fn create_response_shape() {
let body = create_response(100_004_838, 1_700_000_000);
assert_eq!(body["id"], 100_004_838);
assert_eq!(body["reportIdEnabled"], false);
assert_eq!(body["startDateTime"], 1_700_000_000);
}
}
-2
View File
@@ -12,12 +12,10 @@ pub mod economy;
pub mod economy_policy;
pub mod entities;
pub mod item;
pub mod match_wire;
pub mod non_economy;
pub mod owned_query;
pub mod pack_content;
pub mod sbc;
pub mod season_wire;
pub mod squad;
pub mod squad_ext;
pub mod squad_projection;
+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.
@@ -48,9 +48,6 @@ use std::collections::HashMap;
/// fields are FIFA entity ids that MUST be resolved before reaching Core.
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct Fifa17ClubQuery {
/// Requested FIFA item family (`player`, `kit`, …). Adapter-owned: Core has
/// no FIFA content taxonomy, so the host applies this filter locally.
pub item_type: Option<String>,
/// Quality filter: `any` (default, always present) or `gold`.
pub level: Option<String>,
/// "Special" filter (`SP`). Semantics UNKNOWN — never applied.
@@ -118,7 +115,6 @@ pub fn parse_club_query(query: &str) -> Fifa17ClubQuery {
None => (pair, String::new()),
};
match k {
"type" => out.item_type = Some(v),
"level" => out.level = Some(v),
"rare" => out.rare = Some(v),
"position" => out.position = Some(v),
@@ -329,7 +325,6 @@ mod tests {
assert_eq!(
q,
Fifa17ClubQuery {
item_type: Some("player".into()),
level: Some("gold".into()),
rare: None,
position: Some("ST".into()),
+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
}
+4 -22
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": []
})
})
@@ -286,11 +277,10 @@ mod tests {
times_completed: 1,
};
let repeatable_view =
challenges_body(CHALLENGES[0].set_id, std::slice::from_ref(&repeatable));
let repeatable_view = challenges_body(CHALLENGES[0].set_id, &[repeatable.clone()]);
assert_eq!(repeatable_view["challenges"][0]["timesCompleted"], 0);
assert_eq!(repeatable_view["challenges"][0]["status"], "OPEN");
let once_view = challenges_body(CHALLENGES[1].set_id, std::slice::from_ref(&once));
let once_view = challenges_body(CHALLENGES[1].set_id, &[once.clone()]);
assert_eq!(once_view["challenges"][0]["timesCompleted"], 1);
let sets = sets_body(&[repeatable, once]);
@@ -370,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"
@@ -1,209 +0,0 @@
//! FIFA 17 offline-Seasons wire shapes.
//!
//! Reversed from `CardsDLL_Win64_retail.dll`, not guessed. The `season/list`
//! per-element parser is `FUN_180167740` (element stride 0x318) and the response
//! deserialiser root is `FUN_1801683f0` (an object with the single key
//! `seasons`). Element fields land at:
//!
//! | wire key | atom | element offset |
//! |--------------|-------|--------------------------------|
//! | `id` | 0x15c | +0x1b0 |
//! | `divisionId` | 0x0dc | +0x1f8, as `(0xb - value)` |
//! | `type` | — | +0x1b4 (int, switch) |
//! | `matches` | 0x1b8 | vector at +0x2e8/+0x2f0/+0x2f8 |
//!
//! Each `matches` element is 16 bytes, parsed by `FUN_180167fb0`:
//! `teamId`(0x305) int@+0x0, `difficulty`(0xd4) byte@+0x4, `roundId`(0x291)
//! byte@+0x5, `rewardMult`(0x28b) int@+0x8, `coins`(0x95) int@+0xc.
//!
//! WHY `matches` MUST BE NON-EMPTY: `StartSeason` (`FUN_1800fc500`) reads
//! `matches[*(x+0x70)].teamId` through `*(elem+0x2e8 + index*0x10)`. With an
//! empty vector `elem+0x2e8` is NULL and the client dereferences address 0 —
//! a hard crash at `CardsDLL+0xfc5b5`. Emitting a full round set is therefore a
//! correctness requirement, not a nicety.
//!
//! WHY STRUCTS AND NOT `json!`: `type` must precede `divisionId` — the element
//! parser binds the competition type before it maps the division. `serde_json`'s
//! `Value` is a `BTreeMap` without the `preserve_order` feature, so `json!`
//! silently reorders keys ALPHABETICALLY and would emit `divisionId` first.
//! A `#[derive(Serialize)]` struct serialises in declaration order, so these
//! types ARE the wire contract. For the same reason every body here is rendered
//! straight to a `String` and never round-tripped through `Value`.
use serde::Serialize;
/// Rounds in one FIFA 17 offline season. The division ladder is ten matches.
pub const SEASON_ROUNDS: i64 = 10;
/// Opponent team ids used for the round schedule.
///
/// These are real team ids observed in this client's own database (they appear
/// as the `teamid` of club players in the live kit-item trace), so every round
/// resolves to a team the client can actually render. They are cycled rather
/// than randomised so a season's schedule is stable across reloads — the client
/// re-reads `season/list` and a shifting schedule would renumber fixtures.
const OPPONENT_TEAM_IDS: &[i64] = &[21, 73, 240, 241, 243];
/// One scheduled offline-season round.
#[derive(Debug, Serialize)]
pub struct SeasonMatch {
#[serde(rename = "teamId")]
pub team_id: i64,
pub difficulty: i64,
#[serde(rename = "roundId")]
pub round_id: i64,
#[serde(rename = "rewardMult")]
pub reward_mult: i64,
pub coins: i64,
}
/// One offline competition. FIELD ORDER IS THE WIRE CONTRACT — `type` first.
#[derive(Debug, Serialize)]
pub struct SeasonElement {
#[serde(rename = "type")]
pub kind: &'static str,
pub id: i64,
#[serde(rename = "divisionId")]
pub division_id: i64,
pub matches: Vec<SeasonMatch>,
}
#[derive(Debug, Serialize)]
pub struct SeasonList {
pub seasons: Vec<SeasonElement>,
}
/// The club's position in its current season.
#[derive(Debug, Serialize)]
pub struct SeasonUser {
#[serde(rename = "seasonId")]
pub season_id: i64,
#[serde(rename = "divisionId")]
pub division_id: i64,
pub round: i64,
#[serde(rename = "userPoints")]
pub user_points: i64,
/// Opaque client blob; the client round-trips it and never requires server
/// interpretation.
#[serde(rename = "dataVersion")]
pub data_version: &'static str,
pub data: &'static str,
}
fn round(index: i64) -> SeasonMatch {
SeasonMatch {
team_id: OPPONENT_TEAM_IDS[(index as usize) % OPPONENT_TEAM_IDS.len()],
// Difficulty and reward multiplier are per-round bytes; a flat schedule
// is the honest default until the retail ladder is captured.
difficulty: 1,
round_id: index,
reward_mult: 1,
coins: 400,
}
}
/// `GET …/season/list` — the offline competitions the club can enter, as wire
/// text (see the module note on key order).
pub fn season_list_body(season_id: i64, division_id: i64) -> String {
let list = SeasonList {
seasons: vec![SeasonElement {
kind: "OFFLINE",
id: season_id,
division_id,
matches: (0..SEASON_ROUNDS).map(round).collect(),
}],
};
serde_json::to_string(&list).expect("season list serialises")
}
/// `GET …/season/user` — where the club currently is in its season.
pub fn season_user_body(season_id: i64, division_id: i64, round: i64, user_points: i64) -> String {
let user = SeasonUser {
season_id,
division_id,
round,
user_points,
data_version: "1",
data: "",
};
serde_json::to_string(&user).expect("season user serialises")
}
/// `GET …/season/user/history` — completed seasons. Empty until a season ends;
/// the client renders an empty history without complaint.
pub fn season_history_body() -> String {
String::from(r#"{"seasons":[]}"#)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::Value;
fn parsed(text: &str) -> Value {
serde_json::from_str(text).expect("valid json")
}
#[test]
fn list_emits_a_full_round_schedule() {
let body = parsed(&season_list_body(1, 10));
let season = &body["seasons"][0];
assert_eq!(season["type"], "OFFLINE");
assert_eq!(season["id"], 1);
assert_eq!(season["divisionId"], 10);
assert_eq!(
season["matches"].as_array().unwrap().len(),
SEASON_ROUNDS as usize
);
}
/// An empty `matches` vector makes StartSeason dereference NULL
/// (CardsDLL+0xfc5b5), so the schedule can never be empty.
#[test]
fn matches_are_never_empty_and_every_round_has_a_team() {
let body = parsed(&season_list_body(3, 7));
let matches = body["seasons"][0]["matches"].as_array().unwrap();
assert!(!matches.is_empty());
for (i, m) in matches.iter().enumerate() {
assert_eq!(m["roundId"], i as i64, "rounds are 0..n and in order");
assert!(
m["teamId"].as_i64().is_some_and(|t| t > 0),
"round {i} must name a real opponent team: {m}"
);
assert!(m["coins"].as_i64().is_some());
assert!(m["rewardMult"].as_i64().is_some());
assert!(m["difficulty"].as_i64().is_some());
}
}
/// The element parser binds the competition type before mapping the
/// division, so `type` MUST serialise before `divisionId`. `json!` would
/// order them alphabetically and break this.
#[test]
fn type_is_serialised_before_division_id() {
let text = season_list_body(1, 10);
let type_at = text.find("\"type\"").expect("type key");
let division_at = text.find("\"divisionId\"").expect("divisionId key");
assert!(
type_at < division_at,
"type must precede divisionId on the wire: {text}"
);
}
#[test]
fn user_state_carries_the_season_position() {
let body = parsed(&season_user_body(1, 10, 3, 6));
assert_eq!(body["seasonId"], 1);
assert_eq!(body["divisionId"], 10);
assert_eq!(body["round"], 3);
assert_eq!(body["userPoints"], 6);
assert_eq!(body["dataVersion"], "1");
assert_eq!(body["data"], "");
}
#[test]
fn history_is_an_empty_season_list() {
let body = parsed(&season_history_body());
assert_eq!(body["seasons"].as_array().unwrap().len(), 0);
}
}
+10 -52
View File
@@ -141,31 +141,9 @@ pub struct ProposedSquad {
/// never used to derive slot layout.
pub formation: Option<String>,
pub slots: Vec<ProposedSlot>,
/// The owned instance assigned as the squad's **manager**, reverse-resolved
/// from the wire `manager` ref to a Core `owned_card_id` (so the assignment
/// is ownership-backed, never a dangling wire id). `None` when the save
/// carries no manager, or when its manager ref does not resolve — see
/// [`Self::unresolved_manager_wire_id`].
pub manager_owned_card_id: Option<String>,
/// A non-zero manager ref the resolver could not map, if any.
///
/// This does NOT refuse the save. FIFA 17 sends a manager ref that is not an
/// owned club item: production's own squad points at instance 100000427,
/// which is absent from production's `/club/staff` listing (1975 items,
/// 100000001..100004826), and the client accepts that squad back unchanged —
/// so the client does not validate the manager against the club, and refusing
/// the save would break EVERY real squad save for a field that was not even
/// ownership-backed before migration 0023.
///
/// An unresolvable ref therefore means "no ownership-backed manager": the
/// assignment is cleared, exactly as a full replacement should, and the id is
/// reported so the host can log what it could not map. An occupied PLAYER
/// slot is different and still refuses the save — dropping one would silently
/// lose an owned card from the club.
pub unresolved_manager_wire_id: Option<i64>,
/// Occupied PLAYER wire item ids the resolver could not map. A caller MUST
/// refuse the replacement if this is non-empty — a save must never silently
/// drop an owned player it failed to identify.
/// Occupied wire item ids the resolver could not map. A caller MUST refuse the
/// replacement if this is non-empty — a save must never silently drop an
/// owned player it failed to identify.
pub unresolved_wire_ids: Vec<i64>,
}
@@ -202,14 +180,12 @@ pub fn parse_squad_put(body: &[u8]) -> Result<Fifa17SquadPut, SquadError> {
/// Resolve a parsed save into a **canonical** [`ProposedSquad`]: drop empty
/// (`id == 0`) slots, reverse-map each occupied slot's wire id to a Core
/// `owned_card_id`, flag the captain, derive the bench split from the fixed
/// 23-slot array, and reverse-resolve the manager ref to an owned instance
/// (the manager assignment is ownership-backed canonical state, migration 0023).
/// The remaining FIFA-only state (`custom`, kicktakers, kit numbers, squadType)
/// and client-reported evaluation are NOT canonical — they are built separately
/// into [`crate::fut::squad_ext::Fifa17SquadExtensionV1`]. The formation token is
/// carried verbatim (never mapped). Unresolvable occupied ids are reported,
/// never guessed or dropped.
/// `owned_card_id`, flag the captain, and derive the bench split from the fixed
/// 23-slot array. FIFA-only state (`custom`, manager, kicktakers, kit numbers,
/// squadType) and client-reported evaluation are NOT canonical — they are built
/// separately into [`crate::fut::squad_ext::Fifa17SquadExtensionV1`]. The
/// formation token is carried verbatim (never mapped). Unresolvable occupied ids
/// are reported, never guessed or dropped.
pub fn to_proposed(put: &Fifa17SquadPut, resolver: &dyn SquadWireResolver) -> ProposedSquad {
let captain = put.captain.unwrap_or(0);
let mut slots = Vec::new();
@@ -229,25 +205,11 @@ pub fn to_proposed(put: &Fifa17SquadPut, resolver: &dyn SquadWireResolver) -> Pr
None => unresolved.push(p.item_data.id),
}
}
// Manager: the first non-zero manager ref, reverse-resolved to an owned
// instance. Unresolvable is NOT fatal (see `unresolved_manager_wire_id`) --
// the real client always sends a dangling ref, so refusing would break every
// squad save.
let mut manager_owned_card_id = None;
let mut unresolved_manager_wire_id = None;
if let Some(wire) = put.manager.iter().map(|m| m.id).find(|&id| id != 0) {
match resolver.owned_id_for_wire(wire) {
Some(owned) => manager_owned_card_id = Some(owned),
None => unresolved_manager_wire_id = Some(wire),
}
}
ProposedSquad {
squad_id: put.id,
name: put.squad_name.clone(),
formation: put.formation.clone(),
slots,
manager_owned_card_id,
unresolved_manager_wire_id,
unresolved_wire_ids: unresolved,
}
}
@@ -275,11 +237,9 @@ mod tests {
}
fn full_resolver() -> MapResolver {
// indices 0..=10 (11 starters); the rest of the 23 slots are id==0 (empty).
// 100000427 is the fixture's manager ref — the host resolves it like any
// other owned instance, so the manager assignment is ownership-backed.
let ids = [
100000003, 100000010, 100000005, 100000008, 100000007, 100000006, 100000004, 100000009,
100000001, 100000002, 100000025, 100000427,
100000001, 100000002, 100000025,
];
MapResolver(ids.iter().map(|&w| (w, format!("oc-{w}"))).collect())
}
@@ -323,8 +283,6 @@ mod tests {
assert_eq!(caps[0].owned_card_id, "oc-100000001");
assert_eq!(caps[0].index, 8);
assert_eq!(caps[0].kit_number, 8);
// The manager ref is reverse-resolved to an owned instance (canonical).
assert_eq!(sq.manager_owned_card_id.as_deref(), Some("oc-100000427"));
}
#[test]
+24 -36
View File
@@ -12,7 +12,7 @@
//! | `custom` | opaque 33-int string; meaning UNKNOWN, round-tripped verbatim |
//! | `squad_type` | an observed FIFA wire token; no matching generic Core concept |
//! | `kit_numbers` | keyed by **`owned_card_id`** — evidence: kit follows the player |
//! | ~~manager~~ | MOVED to ownership-backed canonical Core state (migration 0023 `squad_managers`); resolved to an `owned_card_id`, no longer opaque here |
//! | `manager` | a FIFA manager item ref; not a squad player, semantics opaque |
//! | `kicktakers` | role→item refs; relationship to captain UNKNOWN, kept opaque |
//! | `client_reported` | chemistry/rating/starRating — client shadow, NOT authority |
//!
@@ -30,7 +30,7 @@ use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use crate::fut::squad::{ClientReportedSquadEval, Fifa17SquadPut, ProposedSquad};
use crate::fut::squad::{ClientReportedSquadEval, Fifa17SquadPut, ProposedSquad, SquadEntityRef};
/// Opaque scope key Core files this extension under (`game_entity_ext.namespace`).
pub const EXT_NAMESPACE: &str = "fifa17.squad";
@@ -49,6 +49,15 @@ pub struct WireItemRef {
pub dream: bool,
}
impl From<&SquadEntityRef> for WireItemRef {
fn from(r: &SquadEntityRef) -> Self {
WireItemRef {
id: r.id,
dream: r.dream,
}
}
}
/// A kicktaker slot preserved verbatim. `index` is the role slot (0..=4 observed);
/// `item` is the referenced FIFA wire item. The role→player meaning and any
/// relationship to the captain are UNKNOWN, so this is stored opaquely and never
@@ -74,9 +83,9 @@ pub struct Fifa17SquadExtensionV1 {
/// proves the kit number follows the player across swaps and formation change.
#[serde(default)]
pub kit_numbers: BTreeMap<String, i64>,
// NOTE: the squad manager is NO LONGER carried here. It is ownership-backed
// canonical Core state (migration 0023 `squad_managers`), resolved to an
// `owned_card_id` on the ProposedSquad — never a dangling opaque wire ref.
/// Manager item ref(s), opaque. Not a squad player; not shaped as an item.
#[serde(default)]
pub manager: Vec<WireItemRef>,
/// Kicktaker role refs, opaque (see [`KicktakerRef`]).
#[serde(default)]
pub kicktakers: Vec<KicktakerRef>,
@@ -123,6 +132,7 @@ impl Fifa17SquadExtensionV1 {
custom: put.custom.clone(),
squad_type: put.squad_type.clone(),
kit_numbers,
manager: put.manager.iter().map(WireItemRef::from).collect(),
kicktakers: put
.kicktakers
.iter()
@@ -246,9 +256,6 @@ mod tests {
let ids = [
100000003, 100000010, 100000005, 100000008, 100000007, 100000006, 100000004, 100000009,
100000001, 100000002, 100000025,
// The f442 fixture's manager ref — the host resolves it like any other
// owned instance, so the ownership-backed manager assignment is present.
100000427,
];
MapResolver(ids.iter().map(|&w| (w, format!("oc-{w}"))).collect())
}
@@ -301,41 +308,22 @@ mod tests {
}
#[test]
fn manager_is_canonical_and_kicktakers_stay_opaque() {
let build = built();
// Manager is now ownership-backed canonical state: the wire ref resolved
// to a Core owned_card_id on the ProposedSquad, not an opaque ext blob.
fn manager_and_kicktakers_preserved_opaquely() {
let ext = built().extension;
assert_eq!(
build.canonical.manager_owned_card_id.as_deref(),
Some("oc-100000427")
ext.manager,
vec![WireItemRef {
id: 100000427,
dream: false
}]
);
// Kicktakers remain opaque in the extension.
let ext = build.extension;
assert_eq!(ext.kicktakers.len(), 5);
// All five reference the same wire id in this capture; carried verbatim,
// NEVER normalized to the captain even though they coincide here.
assert!(ext.kicktakers.iter().all(|k| k.item.id == 100000001));
assert_eq!(ext.kicktakers[0].index, 0);
}
/// A manager ref that does not resolve must NOT refuse the save: FIFA always
/// sends one, and on a real profile it is dangling (production points at
/// 100000427, absent from its own /club/staff). The save commits with no
/// ownership-backed manager and reports the id it could not map.
#[test]
fn an_unresolvable_manager_ref_clears_the_assignment_without_refusing() {
let put = parse_squad_put(PUT_F442.as_bytes()).unwrap();
let mut ids = full_resolver().0;
ids.remove(&100000427);
let build = build_squad_write(&put, &MapResolver(ids)).expect("save must still commit");
assert_eq!(build.canonical.manager_owned_card_id, None);
assert_eq!(
build.canonical.unresolved_manager_wire_id,
Some(100000427),
"the ref we could not map is reported, not swallowed"
);
// The starting XI is untouched -- only the manager assignment is dropped.
assert_eq!(build.canonical.slots.len(), 11);
}
#[test]
fn unknown_schema_version_is_rejected_not_coerced() {
let payload = built().extension.to_payload();
@@ -36,9 +36,7 @@ use std::collections::HashMap;
use serde_json::{json, Value};
use crate::fut::entities::ReverseEntityResolver;
use crate::fut::item::{
shape_item, shape_staff_item, CoreOwnedItem, ItemIdentityResolver, STAFF_CONTRACT,
};
use crate::fut::item::{shape_item, CoreOwnedItem, ItemIdentityResolver};
use crate::fut::squad::FIFA17_SQUAD_SLOTS;
use crate::fut::squad_ext::Fifa17SquadExtensionV1;
@@ -79,11 +77,6 @@ pub struct SquadProjectionInput<'a> {
/// Every owned item a slot references, keyed by `owned_card_id`. Assembled by
/// the host in one batch — the projector only reads from it.
pub owned: &'a HashMap<String, CoreOwnedItem>,
/// The owned instance assigned as this squad's **manager** (Core's
/// ownership-backed `squad_managers` assignment, migration 0023), or `None`.
/// Projected as the FIFA `manager` wire ref resolved from ownership — never a
/// dangling wire id, and never fabricated when absent.
pub manager: Option<CoreOwnedItem>,
}
/// Result of a projection, with the extension-freshness verdict surfaced.
@@ -178,35 +171,6 @@ pub fn project_squad<I: ItemIdentityResolver + ?Sized>(
}
}
// Manager: the ownership-backed assignment, resolved to its FIFA wire ref
// AND carrying its item, as `[{id, itemData, dream}]`.
//
// The bare `[{id, dream}]` form is NOT sufficient, which cost a real
// debugging round: the operator picked a manager in the hub, the save
// persisted (Core `squad_managers` row written, `outcome=ok`, no unresolved
// ref), and the pre-match squad still showed no manager. Every retail
// capture that shows the bare form has `id: 0` — an EMPTY manager — so none
// of them ever demonstrated that a POPULATED ref resolves without its item.
//
// The squad response is self-contained for players: `players[].itemData`
// carries the whole card rather than an id the client resolves out of band.
// The manager is the same kind of slot in the same object, and the one
// implementation that ever drove a working manager (the Python oracle's
// squad) emits `id` BESIDE `itemData` exactly like this. Note the element
// shape differs from a player slot: `{index, itemData, kitNumber}` there,
// `{id, itemData, dream}` here.
//
// An owned manager with no resolvable FIFA staff identity is omitted
// (non-fatal, like /club dropping an unrenderable card) rather than emitted
// with a fabricated id.
let manager = match input.manager.as_ref().and_then(|m| ident.resolve_staff(m)) {
Some(id) => json!([{
"id": id.item_id,
"itemData": shape_staff_item(id, STAFF_CONTRACT),
"dream": false,
}]),
None => json!([]),
};
let squad = json!({
"id": input.fifa_squad_id,
"squadName": input.name,
@@ -216,7 +180,7 @@ pub fn project_squad<I: ItemIdentityResolver + ?Sized>(
"starRating": ext.client_reported.star_rating,
"rating": ext.client_reported.rating,
"captain": captain_wire,
"manager": manager,
"manager": ext.manager,
"custom": ext.custom,
"players": players,
"kicktakers": ext.kicktakers,
@@ -255,22 +219,14 @@ pub fn squad_list(projected: &Value) -> Value {
mod tests {
use super::*;
use crate::fut::entities::Fifa17Entities;
use crate::fut::item::{Fifa17Identity, Fifa17StaffIdentity};
use crate::fut::item::Fifa17Identity;
// A resolver that mints a distinct wire id per owned item and a fixed asset.
// `staff` is separate because a manager resolves through the STAFF identity,
// which carries the chemistry fields a player identity has no room for.
struct TableIdentity(
HashMap<String, Fifa17Identity>,
HashMap<String, Fifa17StaffIdentity>,
);
struct TableIdentity(HashMap<String, Fifa17Identity>);
impl ItemIdentityResolver for TableIdentity {
fn resolve(&self, it: &CoreOwnedItem) -> Option<Fifa17Identity> {
self.0.get(&it.owned_card_id).copied()
}
fn resolve_staff(&self, it: &CoreOwnedItem) -> Option<Fifa17StaffIdentity> {
self.1.get(&it.owned_card_id).copied()
}
}
fn ent() -> Fifa17Entities {
@@ -306,7 +262,6 @@ mod tests {
}],
ext,
owned,
manager: None,
}
}
@@ -317,6 +272,7 @@ mod tests {
custom: Some("[1,2,3]".into()),
squad_type: Some("REGULAR_SQUAD".into()),
kit_numbers: kit,
manager: vec![],
kicktakers: vec![],
client_reported: Default::default(),
}
@@ -326,18 +282,15 @@ mod tests {
fn fresh_projects_full_23_slot_array_with_captain_wire_id() {
let mut owned = HashMap::new();
owned.insert("oc1".to_string(), owned_item("oc1", "card_x"));
let ident = TableIdentity(
HashMap::from([(
"oc1".to_string(),
Fifa17Identity {
item_id: 100000042,
asset_id: 20801,
resource_id: 20801,
rareflag: 1,
},
)]),
HashMap::new(),
);
let ident = TableIdentity(HashMap::from([(
"oc1".to_string(),
Fifa17Identity {
item_id: 100000042,
asset_id: 20801,
resource_id: 20801,
rareflag: 1,
},
)]));
let input = one_slot_input(&owned, SquadExtInput::Fresh(fresh_ext()));
let SquadProjection::Projected(v) = project_squad(&input, &ident, &ent()).unwrap() else {
panic!("expected Projected");
@@ -365,7 +318,7 @@ mod tests {
let owned = HashMap::new();
// A stale extension IS carried (host may log it) but must not be applied.
let input = one_slot_input(&owned, SquadExtInput::Stale(fresh_ext()));
let ident = TableIdentity(HashMap::new(), HashMap::new());
let ident = TableIdentity(HashMap::new());
assert_eq!(
project_squad(&input, &ident, &ent()).unwrap(),
SquadProjection::Stale
@@ -376,7 +329,7 @@ mod tests {
fn missing_is_explicit_never_fabricated() {
let owned = HashMap::new();
let input = one_slot_input(&owned, SquadExtInput::Missing);
let ident = TableIdentity(HashMap::new(), HashMap::new());
let ident = TableIdentity(HashMap::new());
assert_eq!(
project_squad(&input, &ident, &ent()).unwrap(),
SquadProjection::Missing
@@ -387,7 +340,7 @@ mod tests {
fn occupied_starter_without_asset_identity_is_refused_not_faked() {
let mut owned = HashMap::new();
owned.insert("oc1".to_string(), owned_item("oc1", "card_x"));
let ident = TableIdentity(HashMap::new(), HashMap::new()); // resolves nothing
let ident = TableIdentity(HashMap::new()); // resolves nothing
let input = one_slot_input(&owned, SquadExtInput::Fresh(fresh_ext()));
assert_eq!(
project_squad(&input, &ident, &ent()),
@@ -402,29 +355,26 @@ mod tests {
let mut owned = HashMap::new();
owned.insert("oc-a".to_string(), owned_item("oc-a", "fifa17_101490"));
owned.insert("oc-b".to_string(), owned_item("oc-b", "fifa17_101490"));
let ident = TableIdentity(
HashMap::from([
(
"oc-a".to_string(),
Fifa17Identity {
item_id: 100000030,
asset_id: 101490,
resource_id: 101490,
rareflag: 1,
},
),
(
"oc-b".to_string(),
Fifa17Identity {
item_id: 100000031,
asset_id: 101490,
resource_id: 101490,
rareflag: 1,
},
),
]),
HashMap::new(),
);
let ident = TableIdentity(HashMap::from([
(
"oc-a".to_string(),
Fifa17Identity {
item_id: 100000030,
asset_id: 101490,
resource_id: 101490,
rareflag: 1,
},
),
(
"oc-b".to_string(),
Fifa17Identity {
item_id: 100000031,
asset_id: 101490,
resource_id: 101490,
rareflag: 1,
},
),
]));
let mut kit = std::collections::BTreeMap::new();
kit.insert("oc-a".to_string(), 7);
kit.insert("oc-b".to_string(), 19);
@@ -453,7 +403,6 @@ mod tests {
],
ext: SquadExtInput::Fresh(ext),
owned: owned_ref,
manager: None,
};
let SquadProjection::Projected(v) = project_squad(&input, &ident, &ent()).unwrap() else {
panic!();
@@ -474,89 +423,4 @@ mod tests {
"kit stays with the instance"
);
}
#[test]
fn manager_projected_from_ownership_as_wire_ref() {
let mut owned = HashMap::new();
owned.insert("oc1".to_string(), owned_item("oc1", "card_x"));
let ident = TableIdentity(
HashMap::from([(
"oc1".to_string(),
Fifa17Identity {
item_id: 100000042,
asset_id: 20801,
resource_id: 20801,
rareflag: 1,
},
)]),
HashMap::from([(
"oc-mgr".to_string(),
Fifa17StaffIdentity {
item_id: 100000427,
resource_id: 1_000_509,
subtype: 4,
nation: 45,
league_id: 53,
team_id: 241,
},
)]),
);
let mut input = one_slot_input(&owned, SquadExtInput::Fresh(fresh_ext()));
input.manager = Some(owned_item("oc-mgr", "fifa17_mgr"));
let SquadProjection::Projected(v) = project_squad(&input, &ident, &ent()).unwrap() else {
panic!("expected Projected");
};
// The item must ride ALONG with the ref: a bare `{id, dream}` left the
// pre-match squad with no manager even though the assignment had been
// saved, because nothing in the response described the card.
assert_eq!(
v["manager"],
json!([{
"id": 100000427,
"itemData": {
"id": 100000427,
"resourceId": 1_000_509,
"cardsubtypeid": 4,
"itemType": "staff",
"nation": 45,
"leagueId": 53,
"teamid": 241,
"contract": STAFF_CONTRACT,
"itemState": "free",
"owners": 1,
"untradeable": false,
},
"dream": false,
}]),
"manager is the ownership-backed wire ref WITH its item"
);
}
#[test]
fn absent_manager_projects_empty_array_never_fabricated() {
let mut owned = HashMap::new();
owned.insert("oc1".to_string(), owned_item("oc1", "card_x"));
let ident = TableIdentity(
HashMap::from([(
"oc1".to_string(),
Fifa17Identity {
item_id: 100000042,
asset_id: 20801,
resource_id: 20801,
rareflag: 1,
},
)]),
HashMap::new(),
);
// one_slot_input leaves manager: None.
let input = one_slot_input(&owned, SquadExtInput::Fresh(fresh_ext()));
let SquadProjection::Projected(v) = project_squad(&input, &ident, &ent()).unwrap() else {
panic!("expected Projected");
};
assert_eq!(
v["manager"],
json!([]),
"no manager assignment => empty array, nothing fabricated"
);
}
}
+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,
@@ -11,9 +11,8 @@
//! ```
//!
//! Fidelity is asserted by ownership class:
//! CANONICAL player instance per index, formation, captain, bench split, and
//! the ownership-backed manager assignment (migration 0023)
//! EXTENSION custom, kicktakers, kit numbers (by player), squadType
//! CANONICAL player instance per index, formation, captain, bench split
//! EXTENSION custom, kicktakers, manager, kit numbers (by player), squadType
//! SHADOW chemistry/rating/starRating (client-reported, round-tripped as-is)
//! DERIVED correct FIFA 17 item identity (wire id + resourceId)
//!
@@ -24,9 +23,7 @@
use std::collections::HashMap;
use openfut_adapter_fifa17::fut::item::{
CoreOwnedItem, Fifa17Identity, Fifa17StaffIdentity, ItemIdentityResolver, STAFF_CONTRACT,
};
use openfut_adapter_fifa17::fut::item::{CoreOwnedItem, Fifa17Identity, ItemIdentityResolver};
use openfut_adapter_fifa17::fut::squad::{parse_squad_put, Fifa17SquadPut, SquadWireResolver};
use openfut_adapter_fifa17::fut::squad_ext::{build_squad_write, SquadWriteBuild};
use openfut_adapter_fifa17::fut::squad_projection::{
@@ -52,19 +49,11 @@ impl SquadWireResolver for OcResolver {
}
/// owned_card_id → FIFA identity, so two copies of one definition stay distinct.
/// `staff` is a second table because a manager resolves through the STAFF
/// identity, which carries the chemistry fields a player identity cannot hold.
struct TableIdentity(
HashMap<String, Fifa17Identity>,
HashMap<String, Fifa17StaffIdentity>,
);
struct TableIdentity(HashMap<String, Fifa17Identity>);
impl ItemIdentityResolver for TableIdentity {
fn resolve(&self, it: &CoreOwnedItem) -> Option<Fifa17Identity> {
self.0.get(&it.owned_card_id).copied()
}
fn resolve_staff(&self, it: &CoreOwnedItem) -> Option<Fifa17StaffIdentity> {
self.1.get(&it.owned_card_id).copied()
}
}
/// Neutral entity resolver — badge/flag ids are covered by `fut::item` tests; the
@@ -126,7 +115,7 @@ fn oracle_tables() -> (HashMap<String, CoreOwnedItem>, TableIdentity) {
},
);
}
(owned, TableIdentity(ident, HashMap::new()))
(owned, TableIdentity(ident))
}
/// The full pipeline: parse a captured PUT, build the canonical + extension, then
@@ -157,9 +146,6 @@ fn project_put(
slots,
ext: SquadExtInput::Fresh(extension),
owned,
// This stand-in supplies no owned manager; the manager is projected from
// the ownership-backed assignment, exercised in persisted_read below.
manager: None,
};
match project_squad(&input, ident, &NoEntities).unwrap() {
SquadProjection::Projected(v) => v,
@@ -208,11 +194,9 @@ fn baseline_projects_the_known_squad_round_trip() {
projected["captain"], 100000001,
"captain is the player's WIRE id"
);
// EXTENSION: custom byte-identical, squadType preserved. The manager is now
// an ownership-backed assignment (not projected from the PUT/ext); with none
// supplied to this stand-in it projects empty.
// EXTENSION: custom byte-identical, manager + squadType preserved.
assert_eq!(projected["custom"], put_v["custom"]);
assert!(projected["manager"].as_array().unwrap().is_empty());
assert_eq!(projected["manager"], put_v["manager"]);
assert_eq!(projected["squadType"], "REGULAR_SQUAD");
// SHADOW: client-reported values carried as-is (baseline chemistry 52).
assert_eq!(projected["chemistry"], 52);
@@ -284,11 +268,13 @@ fn persisted_read_round_trips_via_reconstructed_canonical_and_extension() {
// evidence, then project and require the read back — the strongest fidelity
// check across all four ownership classes.
use openfut_adapter_fifa17::fut::squad::ClientReportedSquadEval;
use openfut_adapter_fifa17::fut::squad_ext::{Fifa17SquadExtensionV1, KicktakerRef};
use openfut_adapter_fifa17::fut::squad_ext::{
Fifa17SquadExtensionV1, KicktakerRef, WireItemRef,
};
use std::collections::BTreeMap;
let oracle: Value = serde_json::from_str(READ_ORACLE).unwrap();
let (owned, mut ident) = oracle_tables();
let (owned, ident) = oracle_tables();
let captain = oracle["captain"].as_i64().unwrap();
let mut slots = Vec::new();
@@ -308,40 +294,14 @@ fn persisted_read_round_trips_via_reconstructed_canonical_and_extension() {
is_on_bench: index >= 11,
});
}
// The manager is ownership-backed: register its owned instance + STAFF
// identity and pass it as the assignment, not as an opaque extension field.
// A real managercards row is used (1000509 Luis Enrique, nation 45, LaLiga
// 53, Barcelona 241) so the projected item is a shape the client could
// actually merge.
let mgr_wire = oracle["manager"][0]["id"].as_i64().unwrap();
let mgr_oc = format!("oc-{mgr_wire}");
ident.1.insert(
mgr_oc.clone(),
Fifa17StaffIdentity {
item_id: mgr_wire as u32,
resource_id: 1_000_509,
subtype: 4,
nation: 45,
league_id: 53,
team_id: 241,
},
);
let manager_item = CoreOwnedItem {
owned_card_id: mgr_oc,
card_id: "def-manager".to_string(),
rating: 0,
position: String::new(),
nation: String::new(),
league: String::new(),
club: String::new(),
attributes: [0; 6],
};
let manager: Vec<WireItemRef> = serde_json::from_value(oracle["manager"].clone()).unwrap();
let kicktakers: Vec<KicktakerRef> =
serde_json::from_value(oracle["kicktakers"].clone()).unwrap();
let ext = Fifa17SquadExtensionV1 {
custom: oracle["custom"].as_str().map(str::to_string),
squad_type: oracle["squadType"].as_str().map(str::to_string),
kit_numbers,
manager,
kicktakers,
client_reported: ClientReportedSquadEval {
chemistry: oracle["chemistry"].as_i64(),
@@ -356,7 +316,6 @@ fn persisted_read_round_trips_via_reconstructed_canonical_and_extension() {
slots,
ext: SquadExtInput::Fresh(ext),
owned: &owned,
manager: Some(manager_item),
};
let SquadProjection::Projected(projected) = project_squad(&input, &ident, &NoEntities).unwrap()
else {
@@ -393,23 +352,7 @@ fn persisted_read_round_trips_via_reconstructed_canonical_and_extension() {
}
// EXTENSION + SHADOW: sourced from the read, so they round-trip identically.
assert_eq!(projected["custom"], oracle["custom"]);
// The manager REF round-trips; the item now rides with it. The capture this
// oracle came from carried a bare `{id, dream}`, but its manager was the
// dangling one every retail capture has, so it never showed that a populated
// ref renders on its own — and in practice it did not.
assert_eq!(
projected["manager"][0]["id"], oracle["manager"][0]["id"],
"the manager wire ref itself must still round-trip"
);
assert_eq!(
projected["manager"][0]["dream"],
oracle["manager"][0]["dream"]
);
let mgr_item = &projected["manager"][0]["itemData"];
assert_eq!(mgr_item["id"], oracle["manager"][0]["id"]);
assert_eq!(mgr_item["cardsubtypeid"], 4);
assert_eq!(mgr_item["resourceId"], 1_000_509);
assert_eq!(mgr_item["contract"], STAFF_CONTRACT);
assert_eq!(projected["manager"], oracle["manager"]);
assert_eq!(projected["kicktakers"], oracle["kicktakers"]);
assert_eq!(projected["squadType"], oracle["squadType"]);
assert_eq!(projected["chemistry"], oracle["chemistry"]);
+1 -9
View File
@@ -208,15 +208,7 @@ pub fn plan_apply(
.and_then(|s| s.get(0))
.context("report says a squad is present but profile has no squads[0]")?;
let body = serde_json::to_vec(raw_squad).context("re-serialize source squad")?;
let mut put =
parse_squad_put(&body).map_err(|e| anyhow::anyhow!("parse source squad: {e}"))?;
// A HISTORICAL profile may reference a manager whose owned instance is
// not imported (unsupported/deferred, or a dangling id with no owned
// item at all). Drop such a manager ref here rather than failing the
// whole import — the manager assignment is only imported when its owned
// instance is. (A LIVE squad PUT still refuses an unresolved manager,
// because the client is actively assigning one it must own.)
put.manager.retain(|m| wire_to_owned.contains_key(&m.id));
let put = parse_squad_put(&body).map_err(|e| anyhow::anyhow!("parse source squad: {e}"))?;
let build = build_squad_write(&put, &MapResolver(&wire_to_owned))
.map_err(|e| anyhow::anyhow!("build squad write: {e}"))?;
let formation = build
+20 -76
View File
@@ -166,14 +166,10 @@ pub enum ItemClass {
PlayerCard,
Consumable,
Staff,
Kit,
Other,
}
pub fn classify(item: &Item) -> ItemClass {
if item.cardsubtypeid == Some(9) && (6_300_000..=6_400_654).contains(&item.resource_id) {
return ItemClass::Kit;
}
match item.item_type.as_str() {
"staff" => ItemClass::Staff,
"player" => {
@@ -193,13 +189,12 @@ pub struct ItemCounts {
pub player_cards: usize,
pub consumables: usize,
pub staff: usize,
pub kits: usize,
pub other: usize,
}
impl ItemCounts {
pub fn balances(&self) -> bool {
self.player_cards + self.consumables + self.staff + self.kits + self.other == self.total
self.player_cards + self.consumables + self.staff + self.other == self.total
}
}
@@ -213,7 +208,6 @@ pub fn count_items(profile: &Profile) -> ItemCounts {
ItemClass::PlayerCard => c.player_cards += 1,
ItemClass::Consumable => c.consumables += 1,
ItemClass::Staff => c.staff += 1,
ItemClass::Kit => c.kits += 1,
ItemClass::Other => c.other += 1,
}
}
@@ -530,23 +524,21 @@ pub fn plan_definitions(
// ------------------------------------------------------- non-player content
/// An honest, profile-derived non-player CardDefinition proposal, keyed by
/// `fifa17_<resourceId>`. Neutral player fields are supplied at emit time; FIFA
/// render metadata stays here and in the adapter catalog, never generic Core.
/// An honest, profile-derived NON-player CardDefinition proposal (consumable or
/// staff), keyed by `fifa17_<resourceId>`. Neutral player fields are supplied at
/// emit time; this carries only the identity + honest functional `name` (the
/// taxonomy label, never a marketing name).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NonPlayerDefinition {
pub card_id: String,
pub resource_id: i64,
/// Base asset id when the source carries one.
/// Base asset id when the source carries one (consumables: `== resource_id`);
/// staff carry no `assetId`, so this is `None`.
pub asset_id: Option<i64>,
pub kind: ContentKind,
/// FIFA `cardsubtypeid` (consumable family, staff role, or kit family).
/// FIFA `cardsubtypeid` (consumable family / staff role selector).
pub subtype: i64,
/// Non-player card-art id (`35` for kits), when present.
pub card_asset_id: Option<i64>,
/// Source team id for a kit definition, when present.
pub team_id: Option<i64>,
/// Honest functional label (e.g. "Player Contract", "GK Coach", "Kit").
/// Honest functional label (e.g. "Player Contract", "GK Coach").
pub name: String,
/// Wire ids of every owned copy of this resourceId (preserved).
pub wire_ids: Vec<i64>,
@@ -571,8 +563,6 @@ pub struct NonPlayerPlan {
pub consumables: usize,
/// Count of SUPPORTED staff definitions.
pub staff: usize,
/// Count of SUPPORTED kit definitions.
pub kits: usize,
}
impl NonPlayerPlan {
@@ -582,16 +572,15 @@ impl NonPlayerPlan {
}
}
/// Plan non-player CardDefinitions. Consumable, Staff, and Kit groups must agree
/// on their definition-level metadata across every owned copy; disagreement or
/// missing required metadata defers the whole group, never fabricates a value.
/// Plan the non-player (consumable + staff) CardDefinitions. Groups Consumable
/// and Staff items by `resourceId`; each group must agree on `cardsubtypeid`
/// across copies (a disagreement DEFERS with `subtype_conflict`), then resolves
/// the family (consumable) or role (staff) via the adapter's evidence-based
/// taxonomy. A missing or unknown `cardsubtypeid` DEFERS — never a placeholder.
pub fn plan_non_player_definitions(profile: &Profile) -> NonPlayerPlan {
let mut groups: BTreeMap<i64, Vec<&Item>> = BTreeMap::new();
for it in &profile.items {
if matches!(
classify(it),
ItemClass::Consumable | ItemClass::Staff | ItemClass::Kit
) {
if matches!(classify(it), ItemClass::Consumable | ItemClass::Staff) {
groups.entry(it.resource_id).or_default().push(it);
}
}
@@ -633,21 +622,6 @@ pub fn plan_non_player_definitions(profile: &Profile) -> NonPlayerPlan {
continue;
};
let card_asset_id = items[0].cardassetid;
let team_id = items[0].teamid;
if items
.iter()
.any(|item| item.cardassetid != card_asset_id || item.teamid != team_id)
{
plan.deferred.push(DeferredNonPlayer {
resource_id,
subtype: Some(subtype),
wire_ids,
reason: "render_metadata_conflict".to_string(),
});
continue;
}
let (kind, label) = match class {
ItemClass::Consumable => match consumable_family(subtype) {
Some((_family, label)) => (ContentKind::Consumable, label),
@@ -673,33 +647,15 @@ pub fn plan_non_player_definitions(profile: &Profile) -> NonPlayerPlan {
continue;
}
},
ItemClass::Kit if subtype == 9 => {
if card_asset_id != Some(35) || team_id.is_none() {
plan.deferred.push(DeferredNonPlayer {
resource_id,
subtype: Some(subtype),
wire_ids,
reason: "missing_kit_render_metadata".to_string(),
});
continue;
}
(ContentKind::Kit, "Kit")
}
_ => unreachable!("only Consumable/Staff/Kit were grouped"),
_ => unreachable!("only Consumable/Staff were grouped"),
};
plan.supported.push(NonPlayerDefinition {
card_id: format!("fifa17_{resource_id}"),
resource_id,
asset_id: if class == ItemClass::Kit {
Some(resource_id)
} else {
items[0].asset_id
},
asset_id: items[0].asset_id,
kind,
subtype,
card_asset_id,
team_id,
name: label.to_string(),
wire_ids,
});
@@ -714,11 +670,6 @@ pub fn plan_non_player_definitions(profile: &Profile) -> NonPlayerPlan {
.iter()
.filter(|d| d.kind == ContentKind::Staff)
.count();
plan.kits = plan
.supported
.iter()
.filter(|definition| definition.kind == ContentKind::Kit)
.count();
plan
}
@@ -864,11 +815,10 @@ impl Report {
let mut b = Vec::new();
if !self.counts.balances() {
b.push(format!(
"item-type accounting does not balance ({}+{}+{}+{}+{} != {})",
"item-type accounting does not balance ({}+{}+{}+{} != {})",
self.counts.player_cards,
self.counts.consumables,
self.counts.staff,
self.counts.kits,
self.counts.other,
self.counts.total
));
@@ -961,12 +911,11 @@ impl std::fmt::Display for Report {
)?;
writeln!(
f,
"\nSOURCE ITEMS total={} player_cards={} consumables={} staff={} kits={} other={} balances={}",
"\nSOURCE ITEMS total={} player_cards={} consumables={} staff={} other={} balances={}",
self.counts.total,
self.counts.player_cards,
self.counts.consumables,
self.counts.staff,
self.counts.kits,
self.counts.other,
self.counts.balances()
)?;
@@ -1027,11 +976,10 @@ impl std::fmt::Display for Report {
}
writeln!(
f,
"\nNON-PLAYER CONTENT (consumable/staff/kit) supported={} (consumables={} staff={} kits={}) deferred_groups={} deferred_instances={}",
"\nNON-PLAYER CONTENT (consumable/staff) supported={} (consumables={} staff={}) deferred_groups={} deferred_instances={}",
np.supported.len(),
np.consumables,
np.staff,
np.kits,
np.deferred.len(),
np.deferred_instances()
)?;
@@ -1182,8 +1130,6 @@ pub fn emit_content(
cards.insert(
d.card_id.clone(),
serde_json::json!({
"card_asset_id": d.card_asset_id,
"team_id": d.team_id,
"asset_id": d.asset_id.unwrap_or(d.resource_id),
"version": 0,
"rareflag": 0,
@@ -1249,8 +1195,6 @@ pub fn emit_content(
.iter()
.map(|d| {
serde_json::json!({
"card_asset_id": d.card_asset_id,
"team_id": d.team_id,
"card_id": d.card_id,
"resource_id": d.resource_id,
"asset_id": d.asset_id,
-3
View File
@@ -76,9 +76,6 @@ pub struct Item {
/// Staff/contract `contract` count. Permissive.
#[serde(default)]
pub contract: Option<i64>,
/// Owned-item lifecycle state (`activeHomeKit` / `activeAwayKit` for kits).
#[serde(rename = "itemState", default)]
pub item_state: String,
}
#[derive(Debug, Clone, Deserialize)]
+14 -55
View File
@@ -72,21 +72,11 @@ fn classification_balances_across_disjoint_classes() {
player(100000002, VER5_176580, 176580, 92),
r#"{"id":100000239,"resourceId":5003012,"assetId":5003012,"itemType":"player","rating":85}"#.to_string(),
r#"{"id":100000427,"resourceId":3000083,"itemType":"staff"}"#.to_string(),
r#"{"id":100000500,"resourceId":6300006,"assetId":6300006,
"cardsubtypeid":9,"cardassetid":35,"teamid":21,"itemState":"activeHomeKit"}"#
.to_string(),
];
let c = count_items(&profile(&items, "[]", 100000501));
let c = count_items(&profile(&items, "[]", 100000500));
assert_eq!(
(
c.total,
c.player_cards,
c.consumables,
c.staff,
c.kits,
c.other
),
(5, 2, 1, 1, 1, 0)
(c.total, c.player_cards, c.consumables, c.staff, c.other),
(4, 2, 1, 1, 0)
);
assert!(c.balances());
}
@@ -659,14 +649,6 @@ fn staff(id: i64, resource: i64, subtype: i64) -> String {
)
}
fn kit(id: i64, resource: i64, team_id: i64, item_state: &str) -> String {
format!(
r#"{{"id":{id},"resourceId":{resource},"assetId":{resource},
"cardsubtypeid":9,"cardassetid":35,"teamid":{team_id},
"itemState":"{item_state}","owners":1,"untradeable":false}}"#
)
}
#[test]
fn plan_non_player_supports_seventeen_consumables_and_three_staff() {
// The exact record set from the ticket: distinct resourceIds, so each is its
@@ -694,7 +676,6 @@ fn plan_non_player_supports_seventeen_consumables_and_three_staff() {
);
assert_eq!(plan.consumables, 17);
assert_eq!(plan.staff, 3);
assert_eq!(plan.kits, 0);
assert!(plan.deferred.is_empty(), "0 deferred: {:?}", plan.deferred);
// Honest labels + kinds resolve from the taxonomy (spot checks).
@@ -714,17 +695,6 @@ fn plan_non_player_supports_seventeen_consumables_and_three_staff() {
assert_eq!(by_id("fifa17_3000003").name, "GK Coach");
}
#[test]
fn kit_missing_render_metadata_defers() {
let item = r#"{"id":100000501,"resourceId":6300006,"assetId":6300006,
"cardsubtypeid":9,"itemState":"activeHomeKit"}"#
.to_string();
let plan = plan_non_player_definitions(&profile(&[item], "[]", 100000600));
assert!(plan.supported.is_empty());
assert_eq!(plan.deferred.len(), 1);
assert_eq!(plan.deferred[0].reason, "missing_kit_render_metadata");
}
#[test]
fn unknown_subtype_consumable_defers_never_fabricated() {
let plan = plan_non_player_definitions(&profile(
@@ -793,7 +763,6 @@ fn emit_content_writes_non_player_defs_catalog_kind_and_manifest() {
player(100000001, 20801, 20801, 94),
consumable(100000201, 5003012, 201), // Player Contract
staff(100000427, 3000083, 8), // Fitness Coach
kit(100000500, 6300006, 21, "activeHomeKit"),
];
let rep = analyze(
&profile(&items, "[]", 100000500),
@@ -802,16 +771,16 @@ fn emit_content_writes_non_player_defs_catalog_kind_and_manifest() {
&none(),
);
assert!(!rep.has_blockers(), "blockers: {:?}", rep.blockers());
assert_eq!(rep.non_player.supported.len(), 3);
assert_eq!(rep.non_player.supported.len(), 2);
let dir = tempfile::tempdir().unwrap();
let sum = emit_content(&rep, dir.path(), "fp").unwrap();
assert_eq!(sum.definitions, 1, "one player definition");
assert_eq!(sum.non_player_definitions, 3);
assert_eq!(sum.non_player_instances, 3);
assert_eq!(sum.non_player_definitions, 2);
assert_eq!(sum.non_player_instances, 2);
assert_eq!(
sum.catalog_entries, 4,
"player + 3 non-player catalog entries"
sum.catalog_entries, 3,
"player + 2 non-player catalog entries"
);
// Content pack: neutral non-player CardDefinition with honest name.
@@ -838,29 +807,21 @@ fn emit_content_writes_non_player_defs_catalog_kind_and_manifest() {
assert_eq!(cat["cards"]["fifa17_3000083"]["kind"], "staff");
assert_eq!(cat["cards"]["fifa17_3000083"]["subtype"], 8);
assert_eq!(cat["cards"]["fifa17_3000083"]["asset_id"], 3000083);
assert_eq!(cat["cards"]["fifa17_6300006"]["kind"], "kit");
assert_eq!(cat["cards"]["fifa17_6300006"]["subtype"], 9);
assert_eq!(cat["cards"]["fifa17_6300006"]["card_asset_id"], 35);
assert_eq!(cat["cards"]["fifa17_6300006"]["team_id"], 21);
let loaded = Fifa17CardCatalog::from_file(&sum.host_catalog).unwrap();
assert_eq!(loaded.kind_of("fifa17_20801"), ContentKind::Player);
assert_eq!(loaded.kind_of("fifa17_5003012"), ContentKind::Consumable);
assert_eq!(loaded.subtype_of("fifa17_5003012"), 201);
assert_eq!(loaded.kind_of("fifa17_3000083"), ContentKind::Staff);
assert_eq!(loaded.kind_of("fifa17_6300006"), ContentKind::Kit);
let loaded_kit = loaded.lookup("fifa17_6300006").unwrap();
assert_eq!(loaded_kit.card_asset_id, 35);
assert_eq!(loaded_kit.team_id, 21);
// Manifest: private non_player section with preserved wire ids.
let man: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&sum.manifest).unwrap()).unwrap();
assert_eq!(man["non_player"]["supported_instances"], 3);
assert_eq!(man["non_player"]["supported_instances"], 2);
let np = man["non_player"]["supported_definitions"]
.as_array()
.unwrap();
assert_eq!(np.len(), 3);
assert_eq!(np.len(), 2);
let cons_man = np
.iter()
.find(|d| d["card_id"] == "fifa17_5003012")
@@ -875,14 +836,13 @@ fn plan_apply_mints_non_player_owned_instances() {
player(100000001, 20801, 20801, 94),
consumable(100000201, 5003012, 201),
staff(100000427, 3000083, 8),
kit(100000500, 6300006, 21, "activeHomeKit"),
];
let (report, raw) = report_and_raw(&items, "[]", 100000500);
let plan = plan_apply(&report, &raw, "fp").unwrap();
// Player, consumable, staff, and kit instances mint through one generic path.
assert_eq!(plan.request.owned.len(), 4);
assert_eq!(plan.mappings.len(), 4);
assert_eq!(plan.supported_instances, 4);
// 1 player + 2 non-player owned instances, minted via the identical path.
assert_eq!(plan.request.owned.len(), 3);
assert_eq!(plan.mappings.len(), 3);
assert_eq!(plan.supported_instances, 3);
assert_eq!(plan.deferred_instances, 0);
let cards: BTreeSet<&str> = plan
.request
@@ -892,7 +852,6 @@ fn plan_apply_mints_non_player_owned_instances() {
.collect();
assert!(cards.contains("fifa17_5003012"), "consumable minted");
assert!(cards.contains("fifa17_3000083"), "staff minted");
assert!(cards.contains("fifa17_6300006"), "kit minted");
// Deterministic OwnedItemId per (persona, wire) — same rule as players.
let m = plan
.mappings
+4 -11
View File
@@ -31,9 +31,7 @@ S2 live-staging defect where the v2 Store BUY escaped to Python.
| Quick-sell (path) | `DELETE …/item/<digits>` | `QuickSellPath` |
| Quick-sell (body) | `POST (/ut/delete/game\|/ut/v2/delete/game)/<sku>/item` | `QuickSellBody` |
| Move | `PUT …/item` | `MoveItems` |
| Match create / play | `…/match` (any verb; `matchId` in body = FutPlayGame) | `MatchCreate` |
| Match ready | `…/match/ready` (any verb) | `MatchReady` |
| Match end | `…/match/end` (any verb) — also `POST (/ut/delete/game\|/ut/v2/delete/game)/<sku>/match` | `MatchEnd` |
| Match end | `POST (/ut/delete/game\|/ut/v2/delete/game)/<sku>/match` | `MatchEnd` |
| Market list | `POST …/auctionhouse` \| `…/transfermarket` | `MarketList` |
| Market query | `GET …/tradePile` **and** `…/tradePile/counts` (CASE-INSENSITIVE: `tradepile` too) | `MarketQuery` |
| Market buy | `…/trade/<id>` | `MarketBuy` |
@@ -78,9 +76,7 @@ per-route authority (all economy routes owner = Rust, Python proxy = NO):
| `/item/<id>` (DELETE) | R | -/R | -/R | - | - | - | - | NO | `handle_quick_sell_path` → `sell_item` |
| `/ut/delete/…/item` (POST) | R | -/R | -/R | - | - | - | - | NO | `handle_quick_sell_body` → `sell_item` |
| `/item` (PUT) | R | - | R/- | - | -/R | - | - | NO | `handle_move_items` (PileStore) |
| `/match` (any verb) | R | - | - | - | - | - | - | NO | `handle_match_create` (mints the session id; no economy) |
| `/match/ready` (any verb) | R | - | - | - | - | - | - | NO | `handle_match_ready` |
| `/match/end` (any verb) | R | -/R | - | - | - | - | - | NO | `handle_match_end` → Core `complete_match` |
| `/ut/delete/…/match` (POST) | R | -/R | - | - | - | - | - | NO | `handle_match_end` → `grant_reward` |
| `/auctionhouse`,`/transfermarket` | R | R/- | - | - | - | -/R | - | NO | `handle_market_list` (MarketStore) |
| `/tradePile` (GET) | R | R/- | - | - | - | R/- | - | NO | `handle_market_query` |
| `/trade/<id>` (POST/PUT/GET) | R | R/R | -/R | - | - | R/R | - | NO | `handle_market_buy` → `purchase_item` |
@@ -181,11 +177,8 @@ Landed (Core authority + transport + several handlers; classifier NOT yet flippe
full-gen (`handle_purchasegroup`, no Python body dependency), `userMassInfo`
economy overlay (`overlay_massinfo_economy`). Invariant test: all three read one
Core state.
- **Writer handler**: `/match/end` reward (`handle_match_end` → Core
`complete_match`, the single exactly-once transaction — NOT `grant_reward`).
The per-match identity is the id minted by `POST …/match`, which is what makes
two abandoned matches (whose bodies are byte-identical) both payable while a
replay of either is refused.
- **Writer handler**: `/match` reward (`handle_match_end` → Core `grant_reward`,
oracle `destroy_match_body` shape).
- **Adapter policy mappers** (`181bd94`): match reward, pack price.
- All Core-backed, fail-closed (503, never Python), `FakeEconomy`-tested.
- **Store/item writers** (`4d2b8b9`, `economy_store.rs`, unrouted): `handle_store_buy`
+1 -11
View File
@@ -464,10 +464,7 @@ mod tests {
use std::collections::HashMap;
use std::sync::atomic::{AtomicI64, AtomicU32, Ordering};
use crate::{
CoreMatchCompletion, CoreMatchReceipt, EconomyEntitlement, EconomyPurchase, EconomySale,
EconomySaleReceipt,
};
use crate::{EconomyEntitlement, EconomyPurchase, EconomySale, EconomySaleReceipt};
// ── Recording economy double ────────────────────────────────────────────
@@ -602,13 +599,6 @@ mod tests {
// Sale settlement is not exercised by the Store/quick-sell paths.
Err(CoreError::Status(501))
}
fn complete_match(
&self,
_m: &CoreMatchCompletion<'_>,
) -> Result<CoreMatchReceipt, CoreError> {
// Match completion is not exercised by the Store/quick-sell paths.
Err(CoreError::Status(501))
}
}
// ── Identity / entity / lookup doubles ──────────────────────────────────
File diff suppressed because it is too large Load Diff
+1 -9
View File
@@ -805,8 +805,7 @@ pub async fn handle_move_items(
mod tests {
use super::*;
use crate::{
CoreMatchCompletion, CoreMatchReceipt, EconomyEntitlement, EconomyGrantItem,
EconomyPurchase, EconomySale, EconomySaleReceipt,
EconomyEntitlement, EconomyGrantItem, EconomyPurchase, EconomySale, EconomySaleReceipt,
};
use std::collections::HashMap;
use std::sync::atomic::{AtomicI64, AtomicU64, AtomicUsize, Ordering};
@@ -960,13 +959,6 @@ mod tests {
squad_slots_freed: 0,
})
}
fn complete_match(
&self,
_m: &CoreMatchCompletion<'_>,
) -> Result<CoreMatchReceipt, CoreError> {
// Match completion is not exercised through the market double.
Err(CoreError::Status(501))
}
}
// ---- SquadWireResolver double -----------------------------------------
+12 -35
View File
@@ -436,10 +436,8 @@ fn case_d_two_market_buyers(h: &Harness) -> String {
"POST",
"/ut/game/fifa17/auctionhouse",
&[],
format!(
r#"{{"itemData":{{"id":{item_id}}},"buyNowPrice":1000,"startingBid":500}}"#
)
.as_bytes(),
format!(r#"{{"itemData":{{"id":{item_id}}},"buyNowPrice":1000,"startingBid":500}}"#)
.as_bytes(),
None,
)
.expect("list routed");
@@ -486,39 +484,20 @@ fn case_d_two_market_buyers(h: &Harness) -> String {
)
}
/// E: match REWARD + Store BUY concurrently → one legal serialization (no lost
/// update). The two commute, so the final balance must equal exactly one serial
/// outcome: the match's reported post-credit balance (`allCoins`), or that minus
/// the buy's debit — never a torn value from a clobbered write. Amount-agnostic,
/// so it holds even when a WIN also triggers an XP level-up bonus.
/// E: match REWARD + Store BUY concurrently → final balance is one legal
/// serialization (no lost update). Reward (+400) and buy (−400) commute, so the
/// final balance must equal the start exactly.
fn case_e_reward_and_buy(h: &Harness) -> String {
// Measure the store BUY's deterministic debit once.
set_balance(&h.client, 50_000);
let pre_probe = h.client.balance().unwrap();
let probe = fire(
&h.server,
vec![(
"PUT",
"/ut/game/fifa17/store/transaction".into(),
b"{\"packId\":1}".to_vec(),
)],
);
assert_eq!(probe[0].status, 200, "probe buy ok");
let buy_debit = pre_probe - h.client.balance().unwrap();
assert!(buy_debit > 0, "store buy must debit a positive price");
let start = 8_000i64;
for i in 0..ITERS {
for _ in 0..ITERS {
set_balance(&h.client, start);
// A DISTINCT match per iteration (unique matchReportId) so the
// exactly-once reward applies every time.
let rs = fire(
&h.server,
vec![
(
"POST",
"/ut/delete/game/fifa17/match".into(),
format!("{{\"matchReportId\":{i},\"endReason\":\"WIN\"}}").into_bytes(),
b"{\"endReason\":\"WIN\"}".to_vec(),
),
(
"PUT",
@@ -528,15 +507,13 @@ fn case_e_reward_and_buy(h: &Harness) -> String {
],
);
assert!(rs.iter().all(|r| r.status == 200), "both ops succeed");
let all = bj(&rs[0])["allCoins"].as_i64().expect("allCoins");
let after = h.client.balance().unwrap();
// Both writers serialized: `after` is one of the two legal orderings.
assert!(
after == all || after == all - buy_debit,
"no lost update: after={after}, match allCoins={all}, buy_debit={buy_debit}"
assert_eq!(
h.client.balance().unwrap(),
start,
"reward(+400) and buy(-400) both applied: no lost update"
);
}
format!("E reward+buy: {ITERS} iters, no lost update (buy_debit={buy_debit})")
format!("E reward+buy: {ITERS} iters, final==start ({start}) every time (no lost update)")
}
/// F: MOVE + QUICK-SELL of the same item → one coherent final state (item sold
@@ -432,36 +432,6 @@ fn json_shape(value: &Value) -> Value {
}
}
/// Coins Core has granted through ACHIEVEMENT unlocks so far, summed from Core's
/// own report.
///
/// Core's progression system (objectives + achievements) has no counterpart in
/// the Python oracle, and `complete_match` unlocks achievements in the SAME
/// transaction that pays the match reward. So a raw balance delta around a match
/// is `match reward + newly unlocked achievements`, and the differential has to
/// account for the second term instead of pretending it does not exist.
///
/// NOTE: `GET /achievements` is unlock-on-read (it calls `check_and_unlock`), so
/// this snapshot must be taken BEFORE the pre-op balance is captured; that flushes
/// any already-satisfied unlock and leaves the measured window attributable to the
/// op under test.
fn achievement_coins_granted(http: &reqwest::blocking::Client, core_base: &str) -> i64 {
let body: Value = http
.get(format!("{core_base}/achievements"))
.header("X-OpenFUT-Game", "fifa17")
.send()
.expect("GET /achievements")
.json()
.expect("achievements json");
body["achievements"]
.as_array()
.expect("achievements array")
.iter()
.filter(|a| a["unlocked"].as_bool().unwrap_or(false))
.map(|a| a["reward_coins"].as_i64().unwrap_or(0))
.sum()
}
/// Every op runs on a plain OS thread with NO ambient Tokio runtime (the blocking
/// Core client + `reqwest::blocking` require this), exactly like the
/// thread-per-connection server — the bridge takes its direct `block_on` path.
@@ -862,11 +832,6 @@ fn run_differential(core_base: &str, oracle: &Oracle, dir: &std::path::Path) {
matrix.push(("move-items PUT /item", "PARITY"));
// ── OP 10: match reward (WIN = +400) ───────────────────────────────────
// The oracle pays the match reward and nothing else. Core pays the same match
// reward and, in the same transaction, any achievement the match unlocks — a
// deliberate Rust-authority feature the oracle never had. Flush pending
// unlocks first so the measured window belongs to this match.
let r_ach_before = achievement_coins_granted(&http, core_base);
let o_mbal = oracle.coins();
let (o_mms, o_mm) = oracle.req(
"POST",
@@ -924,15 +889,7 @@ fn run_differential(core_base: &str, oracle: &Oracle, dir: &std::path::Path) {
);
}
assert_eq!(oracle.coins() - o_mbal, 400, "oracle WIN +400");
// Same match reward on both sides; Core additionally credits exactly the
// achievements this match unlocked, and nothing unexplained.
let r_ach_awarded = achievement_coins_granted(&http, core_base) - r_ach_before;
assert_eq!(
client.balance().unwrap() - r_mbal,
400 + r_ach_awarded,
"rust WIN credits +400 plus exactly the achievements it unlocked \
(achievement coins = {r_ach_awarded})"
);
assert_eq!(client.balance().unwrap() - r_mbal, 400, "rust WIN +400");
matrix.push(("match reward (WIN)", "PARITY"));
// ── OP 11: market list (POST /auctionhouse) ────────────────────────────
+3 -9
View File
@@ -30,9 +30,9 @@ use openfut_utas_host::async_bridge::AsyncBridge;
use openfut_utas_host::market_store::MarketStore;
use openfut_utas_host::pile_store::PileStore;
use openfut_utas_host::{
build_content_pool, CoreAccess, CoreEconomy, CoreError, CoreMatchCompletion, CoreMatchReceipt,
EconomyEntitlement, EconomyGrantItem, EconomyPurchase, EconomySale, EconomySaleReceipt,
EconomyServices, Fifa17IdentityResolver, HttpCoreClient, PassClient, Server, WireResponse,
build_content_pool, CoreAccess, CoreEconomy, CoreError, EconomyEntitlement, EconomyGrantItem,
EconomyPurchase, EconomySale, EconomySaleReceipt, EconomyServices, Fifa17IdentityResolver,
HttpCoreClient, PassClient, Server, WireResponse,
};
use parking_lot::Mutex;
use serde_json::Value;
@@ -140,12 +140,6 @@ impl CoreEconomy for FaultEconomy {
}
self.inner.settle_sale(sale)
}
fn complete_match(&self, m: &CoreMatchCompletion<'_>) -> Result<CoreMatchReceipt, CoreError> {
if self.trip("complete_match") {
return Err(Self::injected());
}
self.inner.complete_match(m)
}
}
/// An `ExternalIdentityStore` that forwards to a real `JsonIdentityStore` but can
+19 -151
View File
@@ -140,8 +140,8 @@ fn pack_ids(pg: &Value) -> Vec<u64> {
}
/// Seed via the real Core HTTP API, then exercise the host handlers + transport.
/// Returns the final Core balance so the restart phase can assert persistence.
fn seed_and_exercise(base: &str) -> i64 {
/// Returns nothing; panics on any mismatch.
fn seed_and_exercise(base: &str) {
wait_ready(base);
let http = reqwest::blocking::Client::new();
@@ -155,43 +155,12 @@ fn seed_and_exercise(base: &str) -> i64 {
let credits: Value = serde_json::from_slice(&handle_credits(&client).body).unwrap();
assert_eq!(credits["currencies"][0]["funds"], 5000, "seeded balance");
// Match-reward WRITER: a WIN applies its reward through Core's authoritative,
// exactly-once complete_match transaction, end to end. The reward is at least
// the match coins; Core may also grant XP-driven level-up and first-win
// achievement coins, so assert the flat match coins + a relative delta.
let before_match = client.balance().unwrap();
let m = handle_match_end(&client, 1, Some(200_000_001), br#"{"endReason":"WIN"}"#);
// Match-reward WRITER: win credits +400 via Core grant_reward, end to end.
let m = handle_match_end(&client, br#"{"endReason":"WIN"}"#);
assert_eq!(m.status, 200);
let mb: Value = serde_json::from_slice(&m.body).unwrap();
assert_eq!(mb["matchCoins"], 400, "flat match coins");
let after_match = client.balance().unwrap();
assert!(
after_match >= before_match + 400,
"match credited at least +400"
);
assert_eq!(
mb["allCoins"].as_i64().unwrap(),
after_match,
"response echoes the authoritative Core balance"
);
// Idempotent replay: the SAME match session does NOT double-credit.
let replay = handle_match_end(&client, 1, Some(200_000_001), br#"{"endReason":"WIN"}"#);
assert_eq!(replay.status, 200);
assert_eq!(
client.balance().unwrap(),
after_match,
"replay must not re-credit"
);
// A DIFFERENT session with a BYTE-IDENTICAL body is a different match and
// must credit again. Keyed on the body alone it would not, which is the
// silent under-credit every abandoned match would have hit.
let second = handle_match_end(&client, 1, Some(200_000_002), br#"{"endReason":"WIN"}"#);
assert_eq!(second.status, 200);
let after_second = client.balance().unwrap();
assert!(
after_second >= after_match + 400,
"a second, distinct match must credit: {after_second} vs {after_match}"
);
assert_eq!(mb["allCoins"], 5400, "match reward credited in Core");
assert_eq!(client.balance().unwrap(), 5400);
// Buy a numeric entitlement "70" through the Core economy API (debit 600).
post(
@@ -200,16 +169,11 @@ fn seed_and_exercise(base: &str) -> i64 {
"/economy/purchase-entitlement",
json!({ "cost": 600, "definition_id": "70" }),
);
let after_buy = after_second - 600;
assert_eq!(
client.balance().unwrap(),
after_buy,
"debit applied atomically"
);
assert_eq!(client.balance().unwrap(), 4800, "debit applied atomically");
// credits reflects the debit through the same Core state.
let credits2: Value = serde_json::from_slice(&handle_credits(&client).body).unwrap();
assert_eq!(credits2["currencies"][0]["funds"], after_buy);
assert_eq!(credits2["currencies"][0]["funds"], 4800);
// purchasegroup full-gen shows the owned pack 70 and NO sentinel.
let pg: Value =
@@ -230,27 +194,25 @@ fn seed_and_exercise(base: &str) -> i64 {
client.balance().unwrap(),
client.entitlements().unwrap().len(),
);
assert_eq!(mass["userInfo"]["currencies"][0]["funds"], after_buy);
assert_eq!(mass["userInfo"]["currencies"][0]["funds"], 4800);
// Invariant: credits coins == userMassInfo coins == Core balance.
assert_eq!(
credits2["currencies"][0]["funds"],
mass["userInfo"]["currencies"][0]["funds"]
);
after_buy
}
/// After a Core restart from the same DB file, all economy state persists.
fn verify_after_restart(base: &str, expected_balance: i64) {
fn verify_after_restart(base: &str) {
wait_ready(base);
let client = HttpCoreClient::new(base, "fifa17");
assert_eq!(
client.balance().unwrap(),
expected_balance,
4800,
"coins persisted across restart"
);
let credits: Value = serde_json::from_slice(&handle_credits(&client).body).unwrap();
assert_eq!(credits["currencies"][0]["funds"], expected_balance);
assert_eq!(credits["currencies"][0]["funds"], 4800);
let pg: Value =
serde_json::from_slice(&handle_purchasegroup(&client, StoreMode::Sentinel).body).unwrap();
assert!(
@@ -277,12 +239,12 @@ async fn economy_end_to_end_and_restart_persistence() {
let b1 = base1.clone();
let r = tokio::task::spawn_blocking(move || seed_and_exercise(&b1)).await;
h1.abort();
let expected_balance = r.expect("exercise phase");
r.expect("exercise phase");
// --- Core instance #2: same on-disk DB, prove persistence ---
let (h2, base2) = start_core(&db_url).await;
let b2 = base2.clone();
let r2 = tokio::task::spawn_blocking(move || verify_after_restart(&b2, expected_balance)).await;
let r2 = tokio::task::spawn_blocking(move || verify_after_restart(&b2)).await;
h2.abort();
r2.expect("restart phase");
@@ -460,8 +422,7 @@ fn economy_sequence(base: &str, dir: &std::path::Path) -> SeqResult {
"totalCredits == absolute Core balance"
);
// 4) Match END reward through dispatch. The WIN credits at least the flat
// match coins (Core may also add XP level-up / first-win achievement coins).
// 4) Match END reward through dispatch (WIN = +400).
let before_match = client.balance().unwrap();
let mm = server
.try_handle_economy(
@@ -473,11 +434,10 @@ fn economy_sequence(base: &str, dir: &std::path::Path) -> SeqResult {
)
.expect("match routed");
assert_eq!(mm.status, 200);
let mmb: Value = serde_json::from_slice(&mm.body).unwrap();
assert_eq!(mmb["matchCoins"], 400, "flat match coins");
assert!(
client.balance().unwrap() >= before_match + 400,
"WIN credited at least +400 via Core"
assert_eq!(
client.balance().unwrap(),
before_match + 400,
"WIN credited +400 via Core"
);
// 5) MARKET buy-now (async handlers via the bridge): list -> query -> buy ->
@@ -669,98 +629,6 @@ fn economy_sequence(base: &str, dir: &std::path::Path) -> SeqResult {
.owned_id_for_wire(move_wire)
.expect("moved item reverses to a Core id");
// 8) THE MATCH LIFECYCLE, through the REAL dispatch rather than the handler.
// `POST …/match` and `PUT …/match/end` were not classified at all and were
// proxied to Python, which the client reported as "There was an error
// creating your game session".
let created = server
.try_handle_economy(
"POST",
"/ut/game/fifa17/match",
&[],
br#"{"squadId":0,"type":"OFFLINE","seasonId":1,"divisionId":10}"#,
None,
)
.expect("match create must be claimed by the economy dispatch, never proxied");
assert_eq!(created.status, 200);
let created_body: Value = serde_json::from_slice(&created.body).unwrap();
let match_id = created_body["id"].as_i64().expect("a match id is minted");
assert!(match_id > 0, "the client needs a non-zero session id");
assert_eq!(created_body["reportIdEnabled"], false);
assert!(
created_body.get("squad").is_none(),
"`squad` is nested and a documented freeze risk — it must be omitted"
);
// The match id lives in its OWN identity scope: it must not reverse-map to
// an owned card, or quick-sell and move would resolve a match as an item.
assert!(
resolver.owned_id_for_wire(match_id).is_none(),
"a match id must never appear in the owned-item reverse map"
);
// FutPlayGame reuses the create path, discriminated by an integer matchId.
// It must ack without minting a second session.
let play = server
.try_handle_economy(
"POST",
"/ut/game/fifa17/match",
&[],
format!(r#"{{"matchId":{match_id}}}"#).as_bytes(),
None,
)
.expect("play routed");
assert_eq!(
serde_json::from_slice::<Value>(&play.body).unwrap(),
serde_json::json!({}),
"FutPlayGame parses no fields"
);
// Every abandoned match sends a BYTE-IDENTICAL body. The first credits…
let dnf = br#"{"matchReportId":0,"endReason":"DNF","items":[],"matchData":"","matchPerfTelemetry01":"","matchStatusFlags":0}"#;
let before_dnf = client.balance().unwrap();
let ended = server
.try_handle_economy("PUT", "/ut/game/fifa17/match/end", &[], dnf, None)
.expect("match end must be claimed, never proxied");
assert_eq!(ended.status, 200);
let after_dnf = client.balance().unwrap();
assert!(after_dnf > before_dnf, "the abandoned match credited");
// …a REPLAY of that same match must not. Holding (not taking) the in-flight
// id is what makes the replay reuse one identity; taking it sent the replay
// down the body-fingerprint path, a different identity, and Core paid twice.
server
.try_handle_economy("PUT", "/ut/game/fifa17/match/end", &[], dnf, None)
.expect("replay routed");
assert_eq!(
client.balance().unwrap(),
after_dnf,
"a replayed match end must NOT credit again"
);
// …and a NEW session with the identical body is a different match, which
// must credit. Keyed on the body alone every abandoned match after the
// first would silently pay nothing.
let created2 = server
.try_handle_economy(
"POST",
"/ut/game/fifa17/match",
&[],
br#"{"squadId":0,"type":"OFFLINE"}"#,
None,
)
.expect("second create routed");
let second_id = serde_json::from_slice::<Value>(&created2.body).unwrap()["id"]
.as_i64()
.unwrap();
assert_ne!(second_id, match_id, "each match gets its own id");
server
.try_handle_economy("PUT", "/ut/game/fifa17/match/end", &[], dnf, None)
.expect("second end routed");
assert!(
client.balance().unwrap() > after_dnf,
"a second, distinct abandoned match must credit"
);
SeqResult {
final_balance: client.balance().unwrap(),
sold_listing: trade_id.to_string(),
+10 -403
View File
@@ -15,9 +15,9 @@ use openfut_identity::JsonIdentityStore;
use openfut_utas_host::account_store::AccountStore;
use openfut_utas_host::{
classify, handle_club, handle_put_squad, handle_squad_active, handle_squad_list,
handle_user_mass_info, read_request, ClubDeps, CoreAccess, CoreError, CoreExtState,
CoreKitAssignments, CorePage, CoreReplaceRequest, CoreReplaceResult, CoreSquadRead,
CoreSquadSlot, Fifa17IdentityResolver, HttpCoreClient, PassClient, Route, Server, SquadDeps,
handle_user_mass_info, read_request, ClubDeps, CoreAccess, CoreError, CoreExtState, CorePage,
CoreReplaceRequest, CoreReplaceResult, CoreSquadRead, CoreSquadSlot, Fifa17IdentityResolver,
HttpCoreClient, PassClient, Route, Server, SquadDeps,
};
use parking_lot::Mutex;
use serde_json::Value;
@@ -47,9 +47,6 @@ struct FakeCore {
/// The stored squad + extension returned by `read_squad_ext`. A successful
/// `replace_squad` overwrites it (Fresh), enabling coupled read-after-write.
squad: Mutex<Option<CoreSquadRead>>,
/// The ownership-backed manager assignment, exactly as Core persists it: a
/// full squad replacement writes it (or clears it with `None`).
manager: Mutex<Option<String>>,
replaced: Mutex<Vec<StoredReplace>>,
panic_if_called: bool,
return_err: bool,
@@ -95,9 +92,6 @@ impl FakeCore {
fn last(&self) -> Vec<(String, String)> {
self.last_params.lock().clone()
}
fn manager(&self) -> Option<String> {
self.manager.lock().clone()
}
}
impl CoreAccess for FakeCore {
@@ -190,21 +184,6 @@ impl CoreAccess for FakeCore {
slots_written: req.slots.len(),
})
}
fn get_squad_manager(&self) -> Result<Option<String>, CoreError> {
if self.return_err {
return Err(CoreError::Status(500));
}
Ok(self.manager.lock().clone())
}
fn set_squad_manager(&self, owned_card_id: Option<&str>) -> Result<(), CoreError> {
if self.return_err {
return Err(CoreError::Status(500));
}
*self.manager.lock() = owned_card_id.map(str::to_string);
Ok(())
}
}
fn entities() -> Fifa17Entities {
@@ -359,13 +338,11 @@ fn club_excludes_listed_items_and_paginates_the_visible_set() {
// oc2 has an active transfer-market listing.
let hidden: std::collections::HashSet<String> = ["oc2".to_string()].into_iter().collect();
let active_kits = CoreKitAssignments::default();
let deps = ClubDeps {
core: core.as_ref(),
entities: &ents,
assets: resolver.as_ref(),
hidden: &hidden,
active_kits: &active_kits,
};
let (resp, log) = handle_club("", &deps);
let v: Value = serde_json::from_slice(&resp.body).unwrap();
@@ -393,14 +370,13 @@ fn club_excludes_listed_items_and_paginates_the_visible_set() {
);
assert_eq!(log2.total, 2);
// Nothing hidden → all three player items remain visible.
// Nothing hidden → the fast Core-paginated path, all three visible.
let none: std::collections::HashSet<String> = std::collections::HashSet::new();
let deps_all = ClubDeps {
core: core.as_ref(),
entities: &ents,
assets: resolver.as_ref(),
hidden: &none,
active_kits: &active_kits,
};
let (resp3, log3) = handle_club("", &deps_all);
let v3: Value = serde_json::from_slice(&resp3.body).unwrap();
@@ -408,67 +384,6 @@ fn club_excludes_listed_items_and_paginates_the_visible_set() {
assert_eq!(log3.total, 3);
}
#[test]
fn club_projects_only_owned_kits_with_active_designations() {
let core = Arc::new(FakeCore::new(
vec![
item(
"player",
"card_player",
90,
"ST",
"Argentina",
"Premier League",
"Chelsea",
),
item("home", "kit_home", 0, "", "", "", ""),
item("away", "kit_away", 0, "", "", "", ""),
],
3,
));
let catalog = Fifa17CardCatalog::from_json_str(
r#"{"schema_version":1,"game":"fifa17","cards":{
"card_player":{"asset_id":20801},
"kit_home":{"asset_id":6300006,"kind":"kit","subtype":9,
"card_asset_id":35,"team_id":21,"rareflag":0},
"kit_away":{"asset_id":6400003,"kind":"kit","subtype":9,
"card_asset_id":35,"team_id":21,"rareflag":0}
}}"#,
)
.unwrap();
let resolver = Arc::new(Fifa17IdentityResolver::new(
catalog,
Arc::new(JsonIdentityStore::open(unique_store_path()).unwrap()),
));
let ents = entities();
let hidden = std::collections::HashSet::new();
let active_kits = CoreKitAssignments {
home_owned_card_id: Some("home".into()),
away_owned_card_id: Some("away".into()),
};
let deps = ClubDeps {
core: core.as_ref(),
entities: &ents,
assets: resolver.as_ref(),
hidden: &hidden,
active_kits: &active_kits,
};
let (kit_response, kit_log) = handle_club("type=kit", &deps);
let kits: Value = serde_json::from_slice(&kit_response.body).unwrap();
assert_eq!(kit_log.total, 2);
assert_eq!(kits["itemData"][0]["itemState"], "activeHomeKit");
assert_eq!(kits["itemData"][1]["itemState"], "activeAwayKit");
assert_eq!(kits["itemData"][0]["cardassetid"], 35);
assert_eq!(kits["itemData"][0]["teamid"], 21);
let (player_response, player_log) = handle_club("type=player", &deps);
let players: Value = serde_json::from_slice(&player_response.body).unwrap();
assert_eq!(player_log.total, 1);
assert_eq!(players["itemData"].as_array().unwrap().len(), 1);
assert_eq!(players["itemData"][0]["itemType"], "player");
}
// ── /club served from Core ─────────────────────────────────────────────────
#[test]
@@ -493,7 +408,7 @@ fn club_route_maps_query_and_shapes_core_items() {
let resp = server.handle(
"GET",
"/ut/game/fifa17/club?year=2017&type=player&count=11&level=gold&nation=52&league=13&team=5&sort=desc&start=0",
"/ut/game/fifa17/club?year=2017&type=player&count=11&level=gold&nation=52&league=13&team=5&sort=desc&start=10",
&[],
b"",
);
@@ -523,14 +438,8 @@ fn club_route_maps_query_and_shapes_core_items() {
p.contains(&("club".into(), "Chelsea".into())),
"team id 5 -> club name {p:?}"
);
// The host must NOT ask Core for a window. Kind and transfer-pile membership
// are host-side concepts Core cannot express, so the visible set only exists
// after local filtering and shaping; a Core-side window would paginate the
// unfiltered set and hand the client short pages.
assert!(
!p.iter().any(|(k, _)| k == "offset" || k == "limit"),
"pagination must not be delegated to Core {p:?}"
);
assert!(p.contains(&("offset".into(), "10".into())));
assert!(p.contains(&("limit".into(), "11".into())));
// No raw FIFA id reached Core.
for (_, v) in &p {
if v == "13" || v == "5" || v == "52" {
@@ -539,55 +448,6 @@ fn club_route_maps_query_and_shapes_core_items() {
}
}
/// The client's `start`/`count` window is applied by the host, over the set that
/// survives kind + transfer-pile filtering. A window past the end is an empty
/// page, not the first item.
#[test]
fn club_window_is_applied_locally_after_filtering() {
let core = Arc::new(FakeCore::new(
vec![item(
"oc1",
"card_ch_1",
86,
"CDM",
"Argentina",
"Premier League",
"Chelsea",
)],
1,
));
let server = build_server(
core.clone(),
"http://127.0.0.1:1",
Some(HashMap::from([("card_ch_1".to_string(), 20801u32)])),
);
let in_window = server.handle(
"GET",
"/ut/game/fifa17/club?type=player&start=0&count=11",
&[],
b"",
);
let body: Value = serde_json::from_slice(&in_window.body).unwrap();
assert_eq!(body["itemData"].as_array().unwrap().len(), 1);
let past_end = server.handle(
"GET",
"/ut/game/fifa17/club?type=player&start=10&count=11",
&[],
b"",
);
assert_eq!(
past_end.status, 200,
"a window past the end is still a page"
);
let body: Value = serde_json::from_slice(&past_end.body).unwrap();
assert!(
body["itemData"].as_array().unwrap().is_empty(),
"offset past the visible set yields an empty page, not the first item"
);
}
#[test]
fn club_route_with_empty_asset_map_drops_items_not_fakes_them() {
// The current production reality: no card→asset mapping → empty itemData.
@@ -964,26 +824,11 @@ fn resolver_with_wires(
}
/// A FIFA squad-save body. `players` = (index, wire id, kit number).
///
/// `manager` is explicit because a squad save carries an ownership-backed
/// manager assignment: a ref the resolver cannot map to an owned instance is an
/// unresolved wire id and the whole save is refused (you cannot manage with a
/// card you do not own). Tests that are not about the manager pass `None`.
fn put_body(
formation: &str,
captain: i64,
players: &[(i64, i64, i64)],
custom: &str,
manager: Option<i64>,
) -> Vec<u8> {
fn put_body(formation: &str, captain: i64, players: &[(i64, i64, i64)], custom: &str) -> Vec<u8> {
let ps: Vec<Value> = players
.iter()
.map(|(i, w, k)| json!({"index": i, "itemData": {"id": w, "dream": false}, "kitNumber": k}))
.collect();
let mgr: Vec<Value> = manager
.into_iter()
.map(|id| json!({"id": id, "dream": false}))
.collect();
json!({
"id": 0,
"formation": formation,
@@ -994,7 +839,7 @@ fn put_body(
"starRating": 90,
"captain": captain,
"custom": custom,
"manager": mgr,
"manager": [{"id": 100000427, "dream": false}],
"players": ps,
"kicktakers": [{"index": 0, "id": captain, "dream": false}],
})
@@ -1123,7 +968,6 @@ fn numbered_squad_get_returns_current_core_squad_without_python() {
wires["oc-a"],
&[(0, wires["oc-a"], 1), (1, wires["oc-b"], 9)],
"[1,2,3]",
None,
),
);
assert_eq!(put.status, 200);
@@ -1153,7 +997,6 @@ fn put_full_replacement_commits_canonical_and_extension_and_acks_id0() {
w["oc-a"],
&[(0, w["oc-a"], 1), (1, w["oc-b"], 9)],
"[1,2,3]",
None,
);
let (resp, log) = handle_put_squad(&body, &deps);
@@ -1176,95 +1019,6 @@ fn put_full_replacement_commits_canonical_and_extension_and_acks_id0() {
assert_eq!(r.chemistry, Some(52), "client-reported shadow carried");
}
/// The squad's manager is an ownership-backed assignment, not an opaque wire
/// echo: a save assigns the owned instance behind the ref, and a later save
/// without a manager CLEARS it (a full replacement replaces the manager too).
#[test]
fn put_assigns_the_owned_manager_and_a_later_save_clears_it() {
let items = vec![gk(), st()];
let (resolver, w) = resolver_with_wires(&items, ASSETS);
let core = FakeCore::new(items.clone(), 2);
let ent = entities();
let deps = SquadDeps {
core: &core,
resolver: &resolver,
entities: &ent,
};
let with_manager = put_body(
"f442",
w["oc-a"],
&[(0, w["oc-a"], 1)],
"[]",
Some(w["oc-b"]),
);
let (resp, log) = handle_put_squad(&with_manager, &deps);
assert_eq!(resp.status, 200, "{log:?}");
assert_eq!(
core.manager().as_deref(),
Some("oc-b"),
"manager persisted as the Core owned id, never the wire id"
);
let without_manager = put_body("f442", w["oc-a"], &[(0, w["oc-a"], 1)], "[]", None);
let (resp, log) = handle_put_squad(&without_manager, &deps);
assert_eq!(resp.status, 200, "{log:?}");
assert_eq!(
core.manager(),
None,
"a full replacement without a manager clears the assignment"
);
}
/// The REAL client always sends a manager ref, and on a real profile it does not
/// resolve: production's own save points at 100000427, which is absent from
/// production's `/club/staff`, and the client accepts that squad back unchanged.
/// Refusing the save would therefore break EVERY squad save, so an unresolvable
/// manager ref commits the squad with NO manager assignment.
#[test]
fn put_saves_the_squad_when_the_manager_ref_does_not_resolve() {
let items = vec![gk(), st()];
let (resolver, w) = resolver_with_wires(&items, ASSETS);
let core = FakeCore::new(items.clone(), 2);
let ent = entities();
let deps = SquadDeps {
core: &core,
resolver: &resolver,
entities: &ent,
};
let body = put_body(
"f442",
w["oc-a"],
&[(0, w["oc-a"], 1)],
"[]",
Some(100_000_427),
);
let (resp, log) = handle_put_squad(&body, &deps);
assert_eq!(resp.status, 200, "{log:?}");
assert_eq!(resp.body, br#"{"id":0}"#);
assert_eq!(core.replace_calls(), 1, "the squad itself is committed");
assert_eq!(
core.manager(),
None,
"no ownership-backed manager is invented from a ref we cannot map"
);
// An occupied PLAYER slot that does not resolve is the opposite case: it
// would silently lose an owned card, so it still refuses the whole save.
let bad_player = put_body(
"f442",
w["oc-a"],
&[(0, w["oc-a"], 1), (1, 999_999_999, 9)],
"[]",
None,
);
let (resp, log) = handle_put_squad(&bad_player, &deps);
assert_eq!(resp.status, 400);
assert_eq!(log.outcome, "unresolved_wire_ids");
assert_eq!(core.replace_calls(), 1, "nothing further committed");
}
#[test]
fn put_rejects_wire_id_owned_by_another_profile_core_unchanged() {
// oc-b resolves (identity) but is NOT in the active club's owned set.
@@ -1282,7 +1036,6 @@ fn put_rejects_wire_id_owned_by_another_profile_core_unchanged() {
w["oc-a"],
&[(0, w["oc-a"], 1), (1, w["oc-b"], 9)],
"[]",
None,
);
let (resp, log) = handle_put_squad(&body, &deps);
@@ -1309,7 +1062,6 @@ fn put_rejects_unknown_wire_id() {
w["oc-a"],
&[(0, w["oc-a"], 1), (1, 999_999_999, 9)],
"[]",
None,
);
let (resp, log) = handle_put_squad(&body, &deps);
assert_eq!(resp.status, 400);
@@ -1333,7 +1085,6 @@ fn put_rejects_duplicate_owned_item() {
w["oc-a"],
&[(0, w["oc-a"], 1), (1, w["oc-a"], 9)],
"[]",
None,
);
let (resp, log) = handle_put_squad(&body, &deps);
assert_eq!(resp.status, 400);
@@ -1353,7 +1104,7 @@ fn put_core_failure_returns_error_never_python() {
resolver: &resolver,
entities: &ent,
};
let body = put_body("f442", w["oc-a"], &[(0, w["oc-a"], 1)], "[]", None);
let body = put_body("f442", w["oc-a"], &[(0, w["oc-a"], 1)], "[]");
let (resp, log) = handle_put_squad(&body, &deps);
assert_eq!(resp.status, 502);
assert_eq!(log.outcome, "core_error");
@@ -1375,7 +1126,6 @@ fn repeated_identical_put_is_idempotent() {
w["oc-a"],
&[(0, w["oc-a"], 1), (1, w["oc-b"], 9)],
"[1,2,3]",
None,
);
let (r1, _) = handle_put_squad(&body, &deps);
let (r2, _) = handle_put_squad(&body, &deps);
@@ -1411,7 +1161,6 @@ fn coupled_read_after_write_list_and_usermassinfo_agree() {
w["oc-a"],
&[(0, w["oc-a"], 1), (1, w["oc-b"], 9)],
"[1,2,3]",
None,
);
let (put, _) = handle_put_squad(&body, &deps);
assert_eq!(put.status, 200);
@@ -1517,7 +1266,6 @@ fn squad_active_serves_core_backed_object_with_configured_persona() {
w["oc-a"],
&[(0, w["oc-a"], 1), (1, w["oc-b"], 9)],
"[1,2,3]",
None,
);
let (put, _) = handle_put_squad(&body, &deps);
assert_eq!(put.status, 200);
@@ -1563,7 +1311,6 @@ fn read_path_is_bounded_no_per_slot_lookup() {
w["oc-a"],
&[(0, w["oc-a"], 1), (1, w["oc-b"], 9)],
"[]",
None,
);
handle_put_squad(&body, &deps);
let reads_before = core.read_calls();
@@ -1733,7 +1480,6 @@ fn duplicate_definition_instances_stay_distinct_through_host() {
w["oc-a"],
&[(0, w["oc-a"], 1), (1, w["oc-c"], 7)],
"[]",
None,
);
let (put, _) = handle_put_squad(&body, &deps);
assert_eq!(put.status, 200);
@@ -1889,142 +1635,3 @@ fn club_stats_year_counts_tiers_from_core_no_python() {
assert_eq!(g["staff"], 0);
assert_eq!(rec.lock().len(), 0, "club/stats never reaches Python");
}
/// A resolver over a catalog authored verbatim, so a test can express the
/// non-player fields (`kind`/`subtype`/`nation`/`league_id`/`team_id`) that the
/// `card_id -> asset_id` helper above cannot.
fn resolver_for_catalog(cards_json: &str) -> Arc<Fifa17IdentityResolver> {
let doc = format!("{{\"schema_version\":1,\"game\":\"fifa17\",\"cards\":{{{cards_json}}}}}");
let catalog = Fifa17CardCatalog::from_json_str(&doc).unwrap();
let store = JsonIdentityStore::open(unique_store_path()).unwrap();
Arc::new(Fifa17IdentityResolver::new(catalog, Arc::new(store)))
}
/// `?type=staff` and `?type=manager` must both serve the club's staff.
///
/// Regression: every `?type=` other than player/kit short-circuited to an empty
/// page with `outcome:"unsupported_type"` and never reached Core, so an owned
/// manager was unreachable — and FIFA refuses to start a match without one. The
/// STAFF tab was observed asking with `type=manager`; `type=staff` is the
/// sibling token.
#[test]
fn staff_and_manager_queries_both_serve_the_clubs_staff() {
let core = Arc::new(FakeCore::new(
vec![
item("oc-mgr", "fifa17_1000509", 0, "", "", "", ""),
item("oc-coach", "fifa17_3000083", 0, "", "", "", ""),
item(
"oc-p",
"card_player",
86,
"CDM",
"Argentina",
"Premier League",
"Chelsea",
),
],
3,
));
let resolver = resolver_for_catalog(
"\"fifa17_1000509\":{\"asset_id\":1000509,\"kind\":\"staff\",\"subtype\":4,\
\"nation\":45,\"league_id\":53,\"team_id\":241},\
\"fifa17_3000083\":{\"asset_id\":3000083,\"kind\":\"staff\",\"subtype\":8},\
\"card_player\":{\"asset_id\":20801}",
);
let entities = entities();
let hidden = std::collections::HashSet::new();
let kits = CoreKitAssignments::default();
for token in ["staff", "manager"] {
let deps = ClubDeps {
core: core.as_ref(),
entities: &entities,
assets: resolver.as_ref(),
hidden: &hidden,
active_kits: &kits,
};
let (resp, log) = handle_club(&format!("type={token}&start=0&count=50"), &deps);
assert_eq!(log.outcome, "ok", "type={token} must not be unsupported");
let body: Value = serde_json::from_slice(&resp.body).unwrap();
let items = body["itemData"].as_array().unwrap();
assert_eq!(items.len(), 2, "type={token}: manager + coach, no player");
let manager = items
.iter()
.find(|i| i["cardsubtypeid"] == 4)
.unwrap_or_else(|| panic!("type={token}: no manager in {items:?}"));
// The merge key is read RAW: a version byte here breaks the lookup and
// the manager branch has no else-arm to report the miss.
assert_eq!(manager["resourceId"], 1_000_509);
assert_eq!(manager["itemType"], "staff");
// Slots the client's own merge never writes, so the server is the only
// possible source of the flag, the badge and manager chemistry.
assert_eq!(manager["nation"], 45);
assert_eq!(manager["leagueId"], 53);
assert_eq!(manager["teamid"], 241);
assert!(
manager["contract"].as_i64().unwrap_or(0) > 0,
"a manager out of contract is exactly what blocks kickoff"
);
let coach = items.iter().find(|i| i["cardsubtypeid"] == 8).unwrap();
assert!(
coach.get("nation").is_none() && coach.get("leagueId").is_none(),
"coach tables carry no nation/league column; inventing zeroes would be a lie"
);
}
}
/// A player query must never leak staff, and vice versa: a manager carries
/// nation/leagueId/teamid, which would pollute the by-league and by-team club
/// drill-downs if it appeared among the players.
#[test]
fn staff_never_leaks_into_the_player_query() {
let core = Arc::new(FakeCore::new(
vec![
item("oc-mgr", "fifa17_1000509", 0, "", "", "", ""),
item(
"oc-p",
"card_player",
86,
"CDM",
"Argentina",
"Premier League",
"Chelsea",
),
],
2,
));
let resolver = resolver_for_catalog(
"\"fifa17_1000509\":{\"asset_id\":1000509,\"kind\":\"staff\",\"subtype\":4,\
\"nation\":45,\"league_id\":53,\"team_id\":241},\
\"card_player\":{\"asset_id\":20801}",
);
let entities = entities();
let hidden = std::collections::HashSet::new();
let kits = CoreKitAssignments::default();
let deps = ClubDeps {
core: core.as_ref(),
entities: &entities,
assets: resolver.as_ref(),
hidden: &hidden,
active_kits: &kits,
};
let (resp, _) = handle_club("type=player&start=0&count=50", &deps);
let body: Value = serde_json::from_slice(&resp.body).unwrap();
let items = body["itemData"].as_array().unwrap();
assert_eq!(items.len(), 1);
assert_eq!(items[0]["resourceId"], 20801, "only the footballer");
}
/// `watchList` had a Rust handler that nothing could reach: no classifier arm
/// produced `Route::WatchList`, so every request fell through to Passthrough and,
/// against a staging stack with a deliberately dead Python upstream, 502'd.
#[test]
fn watchlist_is_rust_owned_and_never_passed_through() {
for method in ["GET", "PUT", "POST", "DELETE"] {
assert_eq!(
classify(method, "/ut/game/fifa17/watchList"),
Route::WatchList,
"{method} watchList must be served by Rust, not proxied"
);
}
}
-309
View File
@@ -1,309 +0,0 @@
#!/usr/bin/env python3
"""Take a READ-ONLY snapshot of the operator's real imported FIFA-17 club.
Why this script exists at all
----------------------------
The real club -- the 1986-item CAGE import, persona 33068179 -- lives in the
production state directory `/home/alex/openfut-promotion/state`. That directory is
listed in FORBIDDEN_PATHS by both staging lifecycle scripts, which refuse to open
any path underneath it. That guard is deliberate and stays absolute: the staging
scripts must not be able to reach production state even by accident.
So the club cannot enter staging directly. This script is the ONE place allowed to
read production state, it is read-only by construction, and its only output is a
snapshot in a directory OUTSIDE the guard. `sold-staging-up.py --club real` then
installs from the snapshot and never learns where it came from.
Read-only by construction
-------------------------
* The Core database is opened `mode=ro` and copied with sqlite3's online backup
API, so the copy is transactionally consistent and the source is never written
(a plain file copy of a database with a hot WAL can tear).
* Every destination is asserted to be outside the production state directory
before anything is opened for writing.
* The sha256 of every source file is taken before and after the copy and compared.
A mismatch aborts loudly -- that would mean this script, or something racing it,
modified production state.
The snapshot is a point-in-time artifact, not a live mirror. Re-run it to refresh.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import shutil
import sqlite3
import sys
import time
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from openfut_production import ProductionError, production_state # noqa: E402
# --- fixed facts -------------------------------------------------------------------
# The production state directory. This script READS these files and writes nothing
# back. It is the same path both staging scripts refuse outright.
PROD_STATE = "/home/alex/openfut-promotion/state"
SRC_CORE_DB = os.path.join(PROD_STATE, "prod-core.db")
SRC_IDENTITY = os.path.join(PROD_STATE, "prod-identity.json")
SRC_CLIENTDATA = os.path.join(PROD_STATE, "clientdata.json")
DEFAULT_SNAPSHOT_DIR = "/home/alex/openfut-club-snapshot"
# Snapshot member names. `sold-staging-up.py` knows these and nothing else.
SNAP_CORE_DB = "core.db"
SNAP_IDENTITY = "identity.json"
SNAP_CLIENTDATA = "clientdata.json"
SNAP_MANIFEST = "snapshot.json"
GAME = "fifa17"
def banner(title: str) -> None:
print()
print("=" * 78)
print(f" {title}")
print("=" * 78)
def step(msg: str) -> None:
print(f" {msg}")
def ok(msg: str) -> None:
print(f" [ OK ] {msg}")
class Fatal(ProductionError):
"""Anything that must abort the snapshot loudly rather than degrade."""
def sha256(path: str) -> str:
digest = hashlib.sha256()
with open(path, "rb") as fh:
for chunk in iter(lambda: fh.read(1 << 20), b""):
digest.update(chunk)
return digest.hexdigest()
def assert_outside_production(path: str, what: str) -> str:
"""Every write target goes through here. The snapshot must never be able to
land inside the directory this script is reading."""
resolved = os.path.abspath(path)
if resolved == PROD_STATE or resolved.startswith(PROD_STATE + os.sep):
raise Fatal(
f"REFUSING: {what} {resolved!r} is inside the production state "
f"directory {PROD_STATE!r} -- this script never writes there"
)
return resolved
def read_club_facts(db_path: str) -> dict:
"""Describe the club in a database WITHOUT modifying it. Used on the source (to
record what was taken) and on the copy (to prove the copy is faithful)."""
conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
try:
profiles = conn.execute(
"SELECT id, username, game_id, import_fingerprint FROM profiles "
"WHERE game_id = ?", (GAME,)
).fetchall()
if len(profiles) != 1:
raise Fatal(
f"expected exactly one {GAME} profile in {db_path}, found "
f"{len(profiles)} -- refusing to snapshot an ambiguous club"
)
profile_id, username, game_id, fingerprint = profiles[0]
club = conn.execute(
"SELECT id, name, coins FROM clubs WHERE profile_id = ?", (profile_id,)
).fetchone()
if club is None:
raise Fatal(f"{GAME} profile {profile_id} has no club in {db_path}")
club_id, club_name, coins = club
owned, distinct = conn.execute(
"SELECT COUNT(*), COUNT(DISTINCT card_id) FROM owned_cards WHERE club_id = ?",
(club_id,),
).fetchone()
squads = conn.execute(
"SELECT id, name, formation FROM squads WHERE club_id = ?", (club_id,)
).fetchall()
squad_players = conn.execute(
"SELECT COUNT(*) FROM squad_players sp JOIN squads s ON s.id = sp.squad_id "
"WHERE s.club_id = ?", (club_id,)
).fetchone()[0]
migration = conn.execute(
"SELECT MAX(version) FROM _sqlx_migrations"
).fetchone()[0]
return {
"profile_id": profile_id,
"username": username,
"game_id": game_id,
"import_fingerprint": fingerprint,
"club_id": club_id,
"club_name": club_name,
"coins": coins,
"owned_cards": owned,
"distinct_card_ids": distinct,
"squads": [
{"id": s[0], "name": s[1], "formation": s[2]} for s in squads
],
"squad_players": squad_players,
"schema_version": migration,
}
finally:
conn.close()
def copy_database(src: str, dst: str) -> None:
"""Online-backup copy. The source is opened read-only, so this cannot write to
production state even if the backup API wanted to."""
source = sqlite3.connect(f"file:{src}?mode=ro", uri=True)
try:
if os.path.exists(dst):
os.remove(dst)
target = sqlite3.connect(dst)
try:
source.backup(target)
finally:
target.close()
finally:
source.close()
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument("--dir", default=os.environ.get("OPENFUT_CLUB_SNAPSHOT_DIR",
DEFAULT_SNAPSHOT_DIR),
help=f"snapshot directory (default: {DEFAULT_SNAPSHOT_DIR})")
args = ap.parse_args()
try:
banner("SNAPSHOT THE REAL CLUB (read-only on production state)")
dest = assert_outside_production(args.dir, "snapshot directory")
step(f"source (read-only): {PROD_STATE}")
step(f"destination : {dest}")
# Production is not involved in a file copy -- its Core runs from a docker
# volume, not from this directory -- but proving the freeze held across the
# operation costs nothing and keeps the evidence uniform with the other
# lifecycle scripts.
before_state = production_state()
ok("production before: " + ", ".join(before_state.describe()))
missing = [p for p in (SRC_CORE_DB, SRC_IDENTITY, SRC_CLIENTDATA)
if not os.path.isfile(p)]
if missing:
raise Fatal("missing production artifact(s):\n " + "\n ".join(missing))
before = {p: sha256(p) for p in (SRC_CORE_DB, SRC_IDENTITY, SRC_CLIENTDATA)}
for path, digest in before.items():
step(f"{os.path.basename(path):20s} {os.path.getsize(path):>9,d} B "
f"sha256 {digest[:16]}")
facts = read_club_facts(SRC_CORE_DB)
ok(
f"club to snapshot: {facts['username']} ({facts['club_name']}) "
f"{facts['owned_cards']} items, {facts['distinct_card_ids']} distinct "
f"card ids, {facts['coins']:,} coins, schema v{facts['schema_version']}"
)
banner("COPY")
os.makedirs(dest, exist_ok=True)
snap_db = assert_outside_production(os.path.join(dest, SNAP_CORE_DB), "core db")
copy_database(SRC_CORE_DB, snap_db)
ok(f"{SNAP_CORE_DB} written by sqlite online backup (consistent copy)")
for src, name in ((SRC_IDENTITY, SNAP_IDENTITY),
(SRC_CLIENTDATA, SNAP_CLIENTDATA)):
dst = assert_outside_production(os.path.join(dest, name), name)
shutil.copy2(src, dst)
# The source is root-owned; the copy must be writable by the staging user
# that installs it.
os.chmod(dst, 0o644)
ok(f"{SNAP_IDENTITY} and {SNAP_CLIENTDATA} copied")
banner("PROVE THE COPY IS FAITHFUL AND THE SOURCE IS UNTOUCHED")
after = {p: sha256(p) for p in before}
changed = [os.path.basename(p) for p in before if before[p] != after[p]]
if changed:
raise Fatal(
"PRODUCTION STATE WAS MODIFIED during the snapshot: "
f"{changed} -- this must never happen"
)
ok("every source file byte-identical before and after (sha256)")
copy_facts = read_club_facts(snap_db)
differences = {
key: (facts[key], copy_facts[key])
for key in facts
if facts[key] != copy_facts[key]
}
if differences:
raise Fatal(f"snapshot does not match the source: {differences}")
ok(
f"snapshot matches source exactly: {copy_facts['owned_cards']} owned "
f"cards, {copy_facts['coins']:,} coins, "
f"{copy_facts['squad_players']} squad players"
)
identity = json.load(open(os.path.join(dest, SNAP_IDENTITY)))
rows = identity.get("rows", [])
owned_rows = [r for r in rows if r.get("entity_kind") == "owned-item"]
conn = sqlite3.connect(f"file:{snap_db}?mode=ro", uri=True)
try:
owned_ids = {r[0] for r in conn.execute("SELECT id FROM owned_cards")}
finally:
conn.close()
unmapped = owned_ids - {r["core_id"] for r in owned_rows}
if unmapped:
raise Fatal(
f"{len(unmapped)} owned cards have no wire id in the identity store; "
"the client would see them appear under freshly minted ids and its "
f"cached squad would break. Sample: {sorted(unmapped)[:5]}"
)
ok(
f"identity store maps all {len(owned_ids)} owned cards to stable wire "
f"ids ({len(owned_rows)} rows, watermarks {identity.get('watermarks')})"
)
manifest = {
"taken_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"source_dir": PROD_STATE,
"sources": {
os.path.basename(p): {"sha256": before[p], "bytes": os.path.getsize(p)}
for p in before
},
"members": {
"core_db": SNAP_CORE_DB,
"identity": SNAP_IDENTITY,
"clientdata": SNAP_CLIENTDATA,
},
"club": copy_facts,
"identity_rows": len(rows),
"watermarks": identity.get("watermarks"),
}
with open(os.path.join(dest, SNAP_MANIFEST), "w") as fh:
json.dump(manifest, fh, indent=2)
fh.write("\n")
ok(f"manifest written: {os.path.join(dest, SNAP_MANIFEST)}")
after_state = production_state()
ok("production after : " + ", ".join(after_state.describe()))
banner("SNAPSHOT READY")
print(f" {dest}")
print()
print(" Install it into staging with:")
print(" python3 scripts/sold-staging-up.py --club real "
"--variant highest \\")
print(" --roster-host winter15.gosredirector.ea.com:8081")
return 0
except ProductionError as exc:
print(f"\nFATAL: {exc}", file=sys.stderr)
return 1
if __name__ == "__main__":
sys.exit(main())
-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())
-143
View File
@@ -1,143 +0,0 @@
"""Runtime identity of the PRODUCTION FIFA-17 stack, shared by the staging
lifecycle scripts.
This exists because both `sold-staging-up.py` and `sold-staging-down.py` need the
same answer to the same safety question -- "what is production right now, and is
it still healthy?" -- and two hand-maintained copies of a safety gate is two
chances to rot.
Production runs in containers, so its pids are NOT stable facts: every pid changes
when a container is restarted. A hardcoded pid list decays into the worst of both
worlds -- a kill-refusal gate that guards nothing (the real production pids are no
longer in it) and a liveness gate that fails a perfectly good teardown (the pids it
does list are long dead). So pids and published ports are both resolved from the
container runtime at the moment they are needed.
"""
from __future__ import annotations
import subprocess
# Production containers. Anything running inside one of these is production.
PROD_CONTAINERS = ("openfut-fut-backend", "openfut-bridge-1", "openfut-core-1")
# Reserved ports: staging may never bind one of these, whether or not it is
# currently published. 8199 (Python oracle) and 18080 (Core) belonged to the
# retired host-process deployment and are kept so an old port map cannot be
# silently reused by staging.
PROD_PORTS = frozenset(
{8080, 8081, 8085, 8094, 8099, 8199, 8443, 4216, 18080, 42127, 42130, 42131}
)
class ProductionError(RuntimeError):
"""Production could not be observed, or is not healthy."""
def _inspect(container: str, template: str) -> str:
"""One `docker inspect -f` field.
Any failure is fatal by design: a script that cannot see production must
refuse to signal anything rather than assume the best.
"""
try:
result = subprocess.run(
["docker", "inspect", "-f", template, container],
capture_output=True,
text=True,
timeout=30,
check=False,
)
except (OSError, subprocess.SubprocessError) as exc:
raise ProductionError(
f"cannot inspect production container {container!r}: {exc}"
) from exc
if result.returncode != 0:
detail = result.stderr.strip() or f"docker exited {result.returncode}"
raise ProductionError(
f"cannot inspect production container {container!r}: {detail}"
)
return result.stdout.strip()
def listening_ports() -> set[int]:
"""Ports in state LISTEN, from the kernel socket table.
A trial bind() would report EADDRINUSE for a stopped server's TIME_WAIT
sockets and so wrongly claim a port is still served.
"""
ports: set[int] = set()
for path in ("/proc/net/tcp", "/proc/net/tcp6"):
try:
with open(path) as fh:
next(fh, None) # header
for line in fh:
fields = line.split()
if len(fields) < 4 or fields[3] != "0A": # TCP_LISTEN
continue
ports.add(int(fields[1].rsplit(":", 1)[1], 16))
except OSError:
continue
return ports
class ProductionState:
"""A snapshot of production as the container runtime reports it."""
__slots__ = ("pids", "published")
def __init__(self, pids: dict[int, str], published: dict[int, str]) -> None:
self.pids = pids
self.published = published
def describe(self) -> list[str]:
return [f"{what} pid {pid}" for pid, what in sorted(self.pids.items())]
def assert_serving(self) -> None:
"""Every port production publishes must actually be listening."""
listening = listening_ports()
silent = sorted(port for port in self.published if port not in listening)
if silent:
raise ProductionError(
"production port(s) no longer listening: "
+ ", ".join(f"{port} ({self.published[port]})" for port in silent)
)
def assert_unchanged(self, before: "ProductionState") -> None:
"""Production must be the same processes serving the same ports."""
if self.pids != before.pids:
raise ProductionError(
f"production pids CHANGED: before={before.pids}, after={self.pids}"
)
if set(self.published) != set(before.published):
raise ProductionError(
"production published ports CHANGED: "
f"before={sorted(before.published)}, after={sorted(self.published)}"
)
def production_state() -> ProductionState:
"""Resolve production's current pids and published ports, proving every
production container is running."""
pids: dict[int, str] = {}
published: dict[int, str] = {}
for container in PROD_CONTAINERS:
status = _inspect(container, "{{.State.Status}}")
if status != "running":
raise ProductionError(
f"production container {container} is {status!r}, not running"
)
pid = int(_inspect(container, "{{.State.Pid}}") or 0)
if pid <= 0:
raise ProductionError(
f"production container {container} is running but reports no pid"
)
pids[pid] = f"prod {container}"
ports = _inspect(
container,
"{{range $port, $bindings := .NetworkSettings.Ports}}"
"{{range $bindings}}{{.HostPort}} {{end}}{{end}}",
)
for field in ports.split():
published[int(field)] = container
return ProductionState(pids, published)
+46 -31
View File
@@ -10,12 +10,10 @@ ones. So there is no pattern matching here at all:
* before any signal, /proc/<pid>/cmdline is read and MUST contain the staging
directory -- production's cmdline never can, because staging runs binaries
copied into that directory;
* production's CURRENT pids, resolved from the container runtime, are refused
explicitly as a second gate;
* the known production pids are refused explicitly, as a second gate;
* only the process GROUP the up script created (pgid == pid, via
start_new_session) is signalled, so a responder thread/child cannot be orphaned;
* afterwards every staging port is proven free, and production is proven to be
the same running containers serving the same ports as before.
* afterwards every staging port is proven free and production is proven alive.
python3 scripts/sold-staging-down.py
python3 scripts/sold-staging-down.py --purge # also delete the staging dir
@@ -31,20 +29,20 @@ import signal
import sys
import time
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from openfut_production import ( # noqa: E402
PROD_PORTS,
ProductionError,
listening_ports,
production_state,
)
DEFAULT_STAGING_DIR = "/home/alex/openfut-sold-staging"
FORBIDDEN_PATHS = ("/home/alex/openfut-promotion/state",)
# Production processes that MUST be alive before and after this script runs. These
# two are the ones the batch contract names, and they live in the host pid view.
PROD_PIDS = {3631953: "prod utas-host", 3374264: "prod Core"}
# Reported but not gated: container pids change when the operator restarts the
# container, and a stale entry here would turn a successful teardown into a FATAL.
PROD_PIDS_INFO = {2090886: "prod blaze", 2091170: "prod python oracle",
2090888: "prod pow"}
PROD_PORTS = (8099, 8199, 18080, 8443, 42127, 42130, 42131, 4216, 8080, 8081, 8094)
class Fatal(ProductionError):
class Fatal(RuntimeError):
pass
@@ -89,17 +87,37 @@ def cmdline_of(pid: int) -> str:
return ""
def listening_ports() -> set[int]:
"""Ports in state LISTEN, from the kernel socket table. A trial bind() would
report EADDRINUSE for a stopped server's TIME_WAIT sockets and wrongly claim the
teardown failed."""
ports: set[int] = set()
for path in ("/proc/net/tcp", "/proc/net/tcp6"):
try:
with open(path) as fh:
next(fh, None) # header
for line in fh:
fields = line.split()
if len(fields) < 4 or fields[3] != "0A": # TCP_LISTEN
continue
ports.add(int(fields[1].rsplit(":", 1)[1], 16))
except OSError:
continue
return ports
def port_free(port: int) -> bool:
return port not in listening_ports()
def stop_one(rec: dict, staging_dir: str, prod_pids: dict[int, str]) -> str:
def stop_one(rec: dict, staging_dir: str) -> str:
"""Stop exactly one recorded process. Returns a human-readable outcome."""
name, pid = rec["name"], int(rec["pid"])
if pid in prod_pids:
known_prod = {**PROD_PIDS, **PROD_PIDS_INFO}
if pid in known_prod:
raise Fatal(
f"manifest entry {name} names PRODUCTION pid {pid} ({prod_pids[pid]}). "
f"manifest entry {name} names PRODUCTION pid {pid} ({known_prod[pid]}). "
"REFUSING to signal anything from this manifest."
)
if not pid_alive(pid):
@@ -174,14 +192,8 @@ def main() -> int:
)
step(f"variant : {manifest.get('variant')}")
# Resolved BEFORE anything is signalled: the refusal gate below is only
# meaningful if it knows production's pids as they are right now.
before = production_state()
for line in before.describe():
step(f"production : {line}")
for rec in manifest.get("processes", []):
ok(stop_one(rec, staging_dir, before.pids))
ok(stop_one(rec, staging_dir))
banner("PROVE STAGING IS GONE")
ports = manifest.get("ports", {})
@@ -202,13 +214,16 @@ def main() -> int:
ok("no recorded staging process is alive")
banner("PROVE PRODUCTION IS STILL UP")
after = production_state()
for line in after.describe():
ok(f"{line} alive")
after.assert_unchanged(before)
after.assert_serving()
ok(f"all {len(after.published)} published production ports still listening: "
+ ", ".join(str(port) for port in sorted(after.published)))
dead = [f"{what} pid {pid}" for pid, what in PROD_PIDS.items()
if not pid_alive(pid)]
for pid, what in PROD_PIDS.items():
if pid_alive(pid):
ok(f"{what} pid {pid} alive")
if dead:
raise Fatal("production process(es) NOT alive: " + ", ".join(dead))
for pid, what in PROD_PIDS_INFO.items():
state = "alive" if pid_alive(pid) else "not found (informational only)"
step(f"{what} pid {pid} {state}")
if args.purge:
shutil.rmtree(safe_path(staging_dir), ignore_errors=True)
@@ -228,7 +243,7 @@ def main() -> int:
print()
print(" then RELAUNCH the FIFA 17 client. See docs/SOLD_STAGING_RUNBOOK.md.")
return 0
except ProductionError as exc:
except Fatal as exc:
print(f"\nFATAL: {exc}", file=sys.stderr)
return 1
-35
View File
@@ -21,20 +21,11 @@ answers `{"id": 0}` and does NOT echo the squad.
python3 scripts/sold-staging-seed-squad.py # seed, then verify
python3 scripts/sold-staging-seed-squad.py --show # read-only
This is a FIXTURE tool. `PUT /squad/0` is a FULL REPLACEMENT, so running it against
the operator's real imported club would overwrite that club's own squad with an
auto-picked XI -- a destructive, unrecoverable-in-place edit of real data. It
therefore refuses to run when the staging manifest says the real club is installed,
unless `--force` is given. `--show` stays available in every mode.
"""
import http.client
import json
import os
import sys
STAGING_MANIFEST = "/home/alex/openfut-sold-staging/manifest.json"
HOST, PORT = "127.0.0.1", 8299
FORBIDDEN = {8099, 8199, 18080, 8443, 42127, 42130, 42131, 4216}
HEADERS = {"X-OpenFUT-Game": "fifa17", "Content-Type": "application/json"}
@@ -162,37 +153,11 @@ def show():
return len(filled)
def refuse_on_real_club():
"""`PUT /squad/0` replaces the whole squad. Against the real club that destroys
the operator's own lineup, so the fixture seeder must not be runnable there by
accident -- the staging manifest records which club is installed."""
if "--force" in sys.argv:
print(" --force: seeding over the installed club as instructed")
return
try:
with open(STAGING_MANIFEST) as fh:
manifest = json.load(fh)
except FileNotFoundError:
return # no manifest: nothing claims a real club is installed
if manifest.get("club") != "real":
return
club = manifest.get("real_club") or {}
raise SystemExit(
"REFUSING: staging is running the operator's REAL club "
f"({club.get('username')}, {club.get('owned_cards')} items) and "
"PUT /squad/0 is a FULL REPLACEMENT -- this would overwrite the real squad "
f"({club.get('squad_players')} players) with an auto-picked XI.\n"
" Read it instead: python3 scripts/sold-staging-seed-squad.py --show\n"
" Override only if you mean it: --force"
)
def main():
print("=== staging squad, before ===")
before = show()
if "--show" in sys.argv:
return 0
refuse_on_real_club()
players = club_players()
print(f"=== owned players available: {len(players)} ===")
chosen, missing = pick_xi(players)
+93 -479
View File
@@ -12,10 +12,10 @@ ONE entry point. It starts, in order:
and then prints the three `openfut.cfg` lines the operator must put on the client.
Tear the whole thing down with `scripts/sold-staging-down.py`.
WHY the client only needs Blaze ports: FIFA 17 learns both the UTAS base URL and
the roster URL from Blaze. This script copies the responder into the staging dir,
rewrites the hardcoded UTAS port, and passes the independently configurable roster
host through `OPENFUT_ROSTER_HOST`.
WHY the client only needs Blaze ports: FIFA 17 learns the UTAS base URL from Blaze
(`blaze_responder_v3b.py`'s `UTAS_BASE`, where the `8099` is HARDCODED). This script
copies the responder into the staging dir and rewrites that literal to the staging
UTAS port, so pointing the client at staging Blaze is sufficient to move UTAS too.
ISOLATION, enforced not assumed:
* production ports 8099 8199 18080 8443 42127 42130 42131 4216 8080 8081 8094 are
@@ -33,20 +33,10 @@ ISOLATION, enforced not assumed:
edits `openfut.cfg` by hand, using the block this script prints.
Read-only reuse of production: staging Blaze advertises the production roster
service through a certificate dNSName (`winter15.gosredirector.ea.com:8081` by
default) and advertises the production POW content/API hosts
(`10.10.0.120:8085` / `:8094`) verbatim. These services hold NO economy state.
The client must resolve the roster hostname to this server without changing the
advertised URL.
WHICH CLUB the seller plays with is chosen by `--club`:
* `fixture` (default) seeds a 14-item synthetic club -- 11 starters, one
disposable item to sell, two kits -- which is all the SOLD experiment needs;
* `real` installs the operator's own imported club from the snapshot produced by
`scripts/club-snapshot.py`, including its coins, its squad and the identity
store that keeps every item's wire id stable. This script still never reads
production state: the snapshot lives outside it, which is what makes the
`safe_path()` refusal above compatible with playing the real club.
(10.10.0.120:8081) and POW content/API hosts (10.10.0.120:8085 / :8094) verbatim, the
same values production Blaze advertises. Those services hold NO economy state (roster
XML and POW content are static), staging never connects to them itself -- it only
hands the client the same strings -- and they are therefore shared deliberately.
python3 scripts/sold-staging-up.py --variant highest
python3 scripts/sold-staging-up.py --variant buyNow --coins-processed 1 \
@@ -69,24 +59,16 @@ import subprocess
import sys
import time
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from openfut_production import ( # noqa: E402
PROD_PORTS,
ProductionError,
ProductionState,
listening_ports,
production_state,
)
# --- fixed facts -------------------------------------------------------------------
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# Production. Never bind, never connect, never open. Production's pids are NOT
# listed here: they live in containers and change on every restart, so they are
# resolved from the container runtime by openfut_production.production_state().
FORBIDDEN_PORTS = PROD_PORTS
# Production. Never bind, never connect, never open.
FORBIDDEN_PORTS = frozenset(
{8099, 8199, 18080, 8443, 42127, 42130, 42131, 4216, 8080, 8081, 8094}
)
FORBIDDEN_PATHS = ("/home/alex/openfut-promotion/state",)
PROD_PIDS = {3631953: "prod utas-host", 3374264: "prod Core"}
# The staging port block. One obvious place; every one of these is asserted free.
# 42227 (the first choice for the redirector) is permanently occupied by
@@ -116,7 +98,6 @@ ADVERTISE = "10.10.0.120"
BIND = "0.0.0.0"
POW_CONTENT_HOST = "10.10.0.120:8085"
POW_HOST = "10.10.0.120:8094"
DEFAULT_ROSTER_HOST = "winter15.gosredirector.ea.com:8081"
BLAZE_SRC = os.path.join(REPO, "fifa17-recon", "tools", "blaze_responder_v3b.py")
BLAZE_ASSETS = ("redir_cert.pem", "redir_key.pem")
@@ -128,16 +109,6 @@ CONTENT_SRC = "/home/alex/openfut-post-p1/staging/emit/content"
CARDS_NAME = "fifa17-production-cards.json"
CATALOG_NAME = "fifa17-production-catalog.json"
# The operator's REAL club (the 1986-item CAGE import), as staged by
# scripts/club-snapshot.py. That snapshot lives OUTSIDE the production state
# directory precisely so this script never has to read production state:
# FORBIDDEN_PATHS stays absolute and safe_path() still refuses the live directory.
CLUB_SNAPSHOT_DIR = "/home/alex/openfut-club-snapshot"
SNAP_CORE_DB = "core.db"
SNAP_IDENTITY = "identity.json"
SNAP_CLIENTDATA = "clientdata.json"
SNAP_MANIFEST = "snapshot.json"
GAME = "fifa17"
PERSONA_ID = "33068179"
PERSONA_NAME = "CAGE"
@@ -176,38 +147,6 @@ SELLER_SQUAD_CARDS = [
DISPOSABLE_ITEM = "owned-a-disposable"
DISPOSABLE_CARD = "fifa17_232273" # Nelson Atiagli LB 51, rareflag 1
# Two authoritative modern kit-card definitions for one real source team. These
# are staging fixtures derived from fcc_kitcards, not synthetic FIFA identities.
KIT_TEAM_ID = 21
STAGING_KITS = [
("home", "owned-a-kit-home", "fifa17_6300006", 6_300_006),
("away", "owned-a-kit-away", "fifa17_6400003", 6_400_003),
]
# The club manager. FIFA refuses to start a match without one ("your player or
# managers contracts have expired"), and NEITHER club owns a manager: the real
# import has 1992 players and exactly 3 staff items, all coaches (2 fitness, 1 GK),
# which the client's own club/stats confirms with staffManager:0. So a manager is
# minted here rather than restored.
#
# Every value below is read out of the client's OWN tables, never invented:
# managercards.carddbid 1000509 (assetid == carddbid on all 417 rows)
# managercards.nation 45 -> the flag and the nation half of chemistry
# manager[509].surname "Luis Enrique", teamid 241
# leagueteamlinks[241].leagueid 53 -> the badge and the league half of chemistry
# League 53 is also the dominant league in the restored squad (12 of 23 players),
# so this manager is the chemistry-correct choice for it, not an arbitrary one.
MANAGER_SUBTYPE = 4
STAGING_MANAGER = {
"owned_id": "owned-a-manager",
"card_id": "fifa17_1000509",
"resource_id": 1_000_509,
"nation": 45,
"league_id": 53,
"team_id": 241,
"label": "Luis Enrique",
}
READY_TIMEOUT_S = 60.0
@@ -229,7 +168,7 @@ def ok(msg: str) -> None:
print(f" [ OK ] {msg}")
class Fatal(ProductionError):
class Fatal(RuntimeError):
"""Anything that must abort bring-up loudly rather than degrade."""
@@ -251,12 +190,31 @@ def check_port_allowed(port: int, what: str) -> None:
raise Fatal(f"REFUSING: {what} port {port} is a PRODUCTION port")
def port_free(port: int) -> bool:
"""A trial bind() is the wrong test: after a server exits, its accepted sockets
sit in TIME_WAIT holding the same local port, so bind() reports EADDRINUSE for a
def listening_ports() -> set[int]:
"""Every TCP port in state LISTEN in this network namespace, read straight from
the kernel socket table.
A trial bind() is the wrong test: after a server exits, its accepted sockets sit
in TIME_WAIT holding the same local port, so bind() reports EADDRINUSE for a
minute even though nothing is serving -- and every server here sets SO_REUSEADDR
and would bind fine. listening_ports() mirrors `ss -ltn` (and
host-lifecycle.sh's hl_port_listening), which is the question actually asked."""
and would bind fine. This mirrors `ss -ltn` (and host-lifecycle.sh's
hl_port_listening), which is the question actually being asked."""
ports: set[int] = set()
for path in ("/proc/net/tcp", "/proc/net/tcp6"):
try:
with open(path) as fh:
next(fh, None) # header
for line in fh:
fields = line.split()
if len(fields) < 4 or fields[3] != "0A": # TCP_LISTEN
continue
ports.add(int(fields[1].rsplit(":", 1)[1], 16))
except OSError:
continue
return ports
def port_free(port: int) -> bool:
return port not in listening_ports()
@@ -303,25 +261,14 @@ def cmdline_of(pid: int) -> str:
return ""
_PROD_BASELINE: ProductionState | None = None
def assert_prod_alive(where: str) -> None:
"""Prove production is untouched.
The first call records the baseline; every later call must observe the SAME
container pids publishing the SAME ports, and every published port must still
be listening. Pids are read from the container runtime each time because a
restarted container gets a new one.
"""
global _PROD_BASELINE
state = production_state()
state.assert_serving()
if _PROD_BASELINE is None:
_PROD_BASELINE = state
else:
state.assert_unchanged(_PROD_BASELINE)
ok(f"production untouched at {where}: " + ", ".join(state.describe()))
for pid, what in PROD_PIDS.items():
if not pid_alive(pid):
raise Fatal(f"{what} pid {pid} is NOT alive at {where} -- stop and investigate")
ok(
f"production untouched at {where}: "
+ ", ".join(f"{what} pid {pid} alive" for pid, what in PROD_PIDS.items())
)
# --- HTTP ---------------------------------------------------------------------------
@@ -490,172 +437,27 @@ def materialise(lay: Layout) -> None:
shutil.copy2(safe_path(src), safe_path(dst))
ok(f"FIFA17 content copied from {CONTENT_SRC} (outside production state)")
with open(safe_path(lay.cards)) as fh:
definitions = json.load(fh)
with open(safe_path(lay.catalog)) as fh:
catalog = json.load(fh)
existing = {definition["id"] for definition in definitions}
for _slot, _owned_id, card_id, resource_id in STAGING_KITS:
if card_id not in existing:
definitions.append({
"id": card_id,
"name": "Kit",
"overall": 0,
"position": "",
"nation": "",
"league": "",
"club": "",
"pace": 0,
"shooting": 0,
"passing": 0,
"dribbling": 0,
"defending": 0,
"physical": 0,
"rarity": "bronze",
"image_path": None,
})
catalog["cards"][card_id] = {
"asset_id": resource_id,
"version": 0,
"rareflag": 0,
"kind": "kit",
"subtype": 9,
"card_asset_id": 35,
"team_id": KIT_TEAM_ID,
}
mgr = STAGING_MANAGER
if mgr["card_id"] not in existing:
definitions.append({
"id": mgr["card_id"],
"name": mgr["label"],
"overall": 0,
"position": "",
"nation": "",
"league": "",
"club": "",
"pace": 0,
"shooting": 0,
"passing": 0,
"dribbling": 0,
"defending": 0,
"physical": 0,
"rarity": "bronze",
"image_path": None,
})
# `version` MUST stay 0 and `asset_id` MUST be the raw carddbid: the client's
# managercards merge reads the wire resourceId as a u32 WITHOUT masking off a
# version byte (players are the only family that is masked), and the manager
# branch has no else-arm, so a wrong key fails silently with a blank card.
catalog["cards"][mgr["card_id"]] = {
"asset_id": mgr["resource_id"],
"version": 0,
"rareflag": 0,
"kind": "staff",
"subtype": MANAGER_SUBTYPE,
"team_id": mgr["team_id"],
"nation": mgr["nation"],
"league_id": mgr["league_id"],
}
with open(safe_path(lay.cards), "w") as fh:
json.dump(definitions, fh, indent=2)
fh.write("\n")
with open(safe_path(lay.catalog), "w") as fh:
json.dump(catalog, fh, indent=2)
fh.write("\n")
ok(
"added ownership-backed home/away kit fixtures from fcc_kitcards, and the "
f"manager {STAGING_MANAGER['label']} (carddbid "
f"{STAGING_MANAGER['resource_id']}, nation {STAGING_MANAGER['nation']}, "
f"league {STAGING_MANAGER['league_id']}) from managercards"
)
def install_real_club(lay: Layout) -> dict:
"""Install the operator's real club from the snapshot in place of the fixture.
The snapshot database is a schema generation behind (it predates
match_completions, squad managers and kit assignments), so it is installed
BEFORE migrate_core and Core brings the COPY forward. Production's own files
are never opened here: scripts/club-snapshot.py already took them out, which is
why this script can keep refusing the production state directory outright.
"""
manifest_path = os.path.join(CLUB_SNAPSHOT_DIR, SNAP_MANIFEST)
if not os.path.isfile(manifest_path):
raise Fatal(
f"no club snapshot at {CLUB_SNAPSHOT_DIR}.\n"
" Take one first (it is read-only on production state):\n"
" python3 scripts/club-snapshot.py"
)
with open(manifest_path) as fh:
manifest = json.load(fh)
for name, dst in ((SNAP_CORE_DB, lay.core_db),
(SNAP_IDENTITY, lay.identity),
(SNAP_CLIENTDATA, lay.clientdata)):
src = os.path.join(CLUB_SNAPSHOT_DIR, name)
if not os.path.isfile(src):
raise Fatal(f"club snapshot is incomplete: missing {src}")
shutil.copy2(src, safe_path(dst))
os.chmod(safe_path(dst), 0o644)
club = manifest["club"]
ok(
f"installed the real club from {CLUB_SNAPSHOT_DIR} (taken "
f"{manifest['taken_at']}): {club['username']}, {club['owned_cards']} items, "
f"{club['coins']:,} coins, schema v{club['schema_version']}"
)
ok(
"wire ids come from the snapshot identity store, so item ids the client "
f"already cached still resolve ({manifest['identity_rows']} rows, "
f"watermarks {manifest['watermarks']})"
)
return club
def assert_seed_cards_resolvable(lay: Layout, real_club: dict | None) -> None:
def assert_seed_cards_resolvable(lay: Layout) -> None:
"""Core's content preflight rejects any owned card whose card_id is not a loaded
CardDefinition, and the host refuses to shape a /club item with no catalog
identity. Prove BOTH memberships now, not via a startup crash later.
For the real club this is the check that matters most: a card_id missing from
the pack is not an error at read time, it is silently filter_map-dropped, so the
symptom is an EMPTY /collection with all the rows still sitting in the database.
"""
identity. Prove BOTH memberships now, not via a startup crash later."""
with open(safe_path(lay.cards)) as fh:
pack_ids = {c["id"] for c in json.load(fh)}
with open(safe_path(lay.catalog)) as fh:
catalog_ids = set(json.load(fh)["cards"])
if real_club is None:
wanted = set(
[card for _, card in SELLER_SQUAD_CARDS]
+ [DISPOSABLE_CARD]
+ [card for _, _, card, _ in STAGING_KITS]
+ [STAGING_MANAGER["card_id"]]
)
what = f"all {len(wanted)} fixture seed card ids"
else:
conn = sqlite3.connect(f"file:{safe_path(lay.core_db)}?mode=ro", uri=True)
try:
wanted = {row[0] for row in
conn.execute("SELECT DISTINCT card_id FROM owned_cards")}
finally:
conn.close()
wanted |= {card for _, _, card, _ in STAGING_KITS}
wanted.add(STAGING_MANAGER["card_id"])
what = (f"all {len(wanted)} distinct card ids owned by the real club "
"(plus the kit and manager fixtures)")
missing_pack = sorted(wanted - pack_ids)
missing_cat = sorted(wanted - catalog_ids)
wanted = [c for _, c in SELLER_SQUAD_CARDS] + [DISPOSABLE_CARD]
missing_pack = sorted(set(wanted) - pack_ids)
missing_cat = sorted(set(wanted) - catalog_ids)
if missing_pack or missing_cat:
raise Fatal(
"card ids are not resolvable. Core drops an owned card whose definition "
"is missing instead of failing, so this would surface as an EMPTY club "
"with every row still in the database.\n"
f" absent from content pack ({len(missing_pack)}): {missing_pack[:10]}\n"
f" absent from identity catalog ({len(missing_cat)}): {missing_cat[:10]}"
"seed card ids are not resolvable -- Core or the host would fail at "
f"startup.\n absent from content pack: {missing_pack}"
f"\n absent from identity catalog: {missing_cat}"
)
ok(
f"{what} present in BOTH the content pack ({len(pack_ids)} defs) and the "
f"identity catalog ({len(catalog_ids)} entries)"
f"all {len(wanted)} seed card ids present in BOTH the content pack "
f"({len(pack_ids)} defs) and the identity catalog ({len(catalog_ids)} entries)"
)
@@ -748,208 +550,63 @@ def verify_blaze_patch(lay: Layout) -> None:
# --- seeding ------------------------------------------------------------------------
# FIFA refuses to kick off unless the squad has 11 starters AND 7 substitutes:
# "your squad must have at least 11 players and 7 subs … currently below the
# minimum number of players (18)". The imported club's squad has only its 11
# starters, so a freshly installed real club is UNPLAYABLE until the bench is
# filled. The 23 slots are 0..10 pitch, 11..22 bench/reserves, derived from the
# index alone.
CLIENT_MIN_SQUAD = 18
SQUAD_SLOTS = 23
def fill_bench_to_minimum(conn, club_id: str, squad_id: str, lay: Layout) -> int:
"""Top the squad up to the client's minimum with the club's best spare
players, filling empty bench slots from index 11 upward.
Only EMPTY slots are written, so the starting XI — and any bench the
operator has already chosen — is never touched, and a re-run is a no-op.
Only real PLAYER definitions are eligible: a kit, a manager or a consumable
in a squad slot would be nonsense, and the client would drop it anyway.
"""
taken = {
row[0]
for row in conn.execute(
"SELECT owned_card_id FROM squad_players WHERE squad_id = ?", (squad_id,)
)
}
used_slots = {
row[0]
for row in conn.execute(
"SELECT position_index FROM squad_players WHERE squad_id = ?", (squad_id,)
)
}
if len(taken) >= CLIENT_MIN_SQUAD:
return 0
with open(safe_path(lay.catalog)) as fh:
catalog = json.load(fh)["cards"]
with open(safe_path(lay.cards)) as fh:
overall = {c["id"]: c.get("overall", 0) for c in json.load(fh)}
# Best first, then a stable id tiebreak so a re-run picks the same bench.
candidates = [
(overall.get(card_id, 0), owned_id, card_id)
for owned_id, card_id in conn.execute(
"SELECT id, card_id FROM owned_cards WHERE club_id = ? AND is_loan = 0",
(club_id,),
)
if owned_id not in taken
and catalog.get(card_id, {}).get("kind", "player") == "player"
]
candidates.sort(key=lambda c: (-c[0], c[1]))
free_slots = [i for i in range(11, SQUAD_SLOTS) if i not in used_slots]
need = CLIENT_MIN_SQUAD - len(taken)
if need > len(free_slots) or need > len(candidates):
raise Fatal(
f"cannot reach the client's {CLIENT_MIN_SQUAD}-player minimum: need "
f"{need} more, but {len(free_slots)} free bench slots and "
f"{len(candidates)} eligible spare players"
)
rows = [
(f"sp-bench-{slot}", squad_id, owned_id, slot, 0, 1)
for slot, (_ovr, owned_id, _card) in zip(free_slots, candidates[:need])
]
conn.executemany(
"INSERT INTO squad_players (id, squad_id, owned_card_id, position_index, "
"is_captain, is_on_bench) VALUES (?, ?, ?, ?, ?, ?)",
rows,
)
return len(rows)
def seed_core_db(lay: Layout, real_club: dict | None) -> None:
"""Seed the identities the experiment needs, against the schema Core just
migrated.
def seed_core_db(lay: Layout) -> None:
"""Two identities by direct SQL, against the schema Core just migrated.
Column sets are from openfut-core/migrations/0001_initial.sql plus 0016's
profiles.game_id. Seller A carries game_id `fifa17` so the retail client (which
sends X-OpenFUT-Game: fifa17) resolves to it; Buyer B is parked on its own
game_id so it can never shadow the seller as the active fifa17 profile.
With the real club installed, Seller A already exists -- it IS the imported
persona, with its own club id, coins, items and squad -- so only Buyer B is
added, and the kit fixtures are attached to the real club so the kit work stays
exercisable. The real squad is never touched: it is the operator's own.
"""
conn = sqlite3.connect(safe_path(lay.core_db), timeout=15)
try:
conn.execute("PRAGMA busy_timeout = 15000")
with conn:
profiles = [(BUYER_PROFILE, "BUYER-B", TS, TS, BUYER_GAME)]
clubs = [(BUYER_CLUB, BUYER_PROFILE, "Buyer B FC", BUYER_COINS, TS, TS)]
if real_club is None:
profiles.insert(0, (SELLER_PROFILE, PERSONA_NAME, TS, TS, GAME))
clubs.insert(0, (SELLER_CLUB, SELLER_PROFILE, f"{PERSONA_NAME} FC",
SELLER_COINS, TS, TS))
conn.executemany(
"INSERT INTO profiles (id, username, level, xp, created_at, "
"updated_at, game_id) VALUES (?, ?, 1, 0, ?, ?, ?)",
profiles,
[
(SELLER_PROFILE, PERSONA_NAME, TS, TS, GAME),
(BUYER_PROFILE, "BUYER-B", TS, TS, BUYER_GAME),
],
)
conn.executemany(
"INSERT INTO clubs (id, profile_id, name, coins, level, created_at, "
"updated_at) VALUES (?, ?, ?, ?, 1, ?, ?)",
clubs,
[
(SELLER_CLUB, SELLER_PROFILE, f"{PERSONA_NAME} FC", SELLER_COINS,
TS, TS),
(BUYER_CLUB, BUYER_PROFILE, "Buyer B FC", BUYER_COINS, TS, TS),
],
)
seller_club = SELLER_CLUB if real_club is None else real_club["club_id"]
owned = [
(owned_id, seller_club, card_id, TS)
for _slot, owned_id, card_id, _resource_id in STAGING_KITS
]
owned.append(
(STAGING_MANAGER["owned_id"], seller_club, STAGING_MANAGER["card_id"], TS)
)
if real_club is None:
owned = (
[(item, SELLER_CLUB, card, TS) for item, card in SELLER_SQUAD_CARDS]
+ [(DISPOSABLE_ITEM, SELLER_CLUB, DISPOSABLE_CARD, TS)]
+ owned
)
conn.executemany(
"INSERT INTO owned_cards (id, club_id, card_id, is_loan, "
"acquired_at) VALUES (?, ?, ?, 0, ?)",
owned,
[(item, SELLER_CLUB, card, TS) for item, card in SELLER_SQUAD_CARDS]
+ [(DISPOSABLE_ITEM, SELLER_CLUB, DISPOSABLE_CARD, TS)],
)
if real_club is None:
conn.execute(
"INSERT INTO squads (id, club_id, name, formation, created_at, "
"updated_at) VALUES (?, ?, ?, ?, ?, ?)",
(SELLER_SQUAD, SELLER_CLUB, "Staging XI", "4-3-3", TS, TS),
)
conn.executemany(
"INSERT INTO squad_players (id, squad_id, owned_card_id, "
"position_index, is_captain, is_on_bench) VALUES (?, ?, ?, ?, ?, 0)",
[
(f"sp-{idx}", SELLER_SQUAD, item, idx, 1 if idx == 0 else 0)
for idx, (item, _) in enumerate(SELLER_SQUAD_CARDS)
],
)
conn.executemany(
"INSERT INTO club_kit_assignments (club_id, slot, owned_card_id, updated_at) "
"VALUES (?, ?, ?, ?)",
[
(seller_club, slot, owned_id, TS)
for slot, owned_id, _card_id, _resource_id in STAGING_KITS
],
)
# Assign the manager to whichever squad the club actually has: the
# fixture's own, or the real club's imported squad. Migration 0023
# keys squad_managers by squad_id, so the assignment must name a real
# squad row rather than the club.
squad_row = conn.execute(
"SELECT id FROM squads WHERE club_id = ? ORDER BY updated_at DESC "
"LIMIT 1", (seller_club,)
).fetchone()
if squad_row is None:
raise Fatal(
f"club {seller_club} has no squad, so the manager cannot be "
"assigned; FIFA refuses to start a match without one"
)
conn.execute(
"INSERT OR REPLACE INTO squad_managers (squad_id, owned_card_id, "
"updated_at) VALUES (?, ?, ?)",
(squad_row[0], STAGING_MANAGER["owned_id"], TS),
"INSERT INTO squads (id, club_id, name, formation, created_at, "
"updated_at) VALUES (?, ?, ?, ?, ?, ?)",
(SELLER_SQUAD, SELLER_CLUB, "Staging XI", "4-3-3", TS, TS),
)
# Only the REAL club is meant to be played. The 14-item fixture is a
# market test bed with a single spare player, so demanding 18 there
# would abort a bring-up that never needed to kick off.
filled = (
fill_bench_to_minimum(conn, seller_club, squad_row[0], lay)
if real_club is not None
else 0
conn.executemany(
"INSERT INTO squad_players (id, squad_id, owned_card_id, "
"position_index, is_captain, is_on_bench) VALUES (?, ?, ?, ?, ?, 0)",
[
(f"sp-{idx}", SELLER_SQUAD, item, idx, 1 if idx == 0 else 0)
for idx, (item, _) in enumerate(SELLER_SQUAD_CARDS)
],
)
finally:
conn.close()
if real_club is None:
ok(
f"seeded Seller A ({PERSONA_NAME}, persona {PERSONA_ID}, {SELLER_COINS} "
f"coins, {len(SELLER_SQUAD_CARDS)} starters + 1 disposable + "
f"{len(STAGING_KITS)} active kits) and Buyer B ({BUYER_COINS} coins)"
)
else:
ok(
f"kept the real club untouched ({real_club['owned_cards']} items, "
f"{real_club['coins']:,} coins, squad {real_club['squads'][0]['name']!r} "
f"with {real_club['squad_players']} players); added "
f"{len(STAGING_KITS)} active kits, the manager "
f"{STAGING_MANAGER['label']} and Buyer B ({BUYER_COINS} coins)"
)
if filled:
ok(
f"filled {filled} empty bench slot(s) with the club's best spare "
f"players: FIFA refuses to kick off below {CLIENT_MIN_SQUAD} "
"(11 starters + 7 subs), and the imported squad carries only its XI"
)
ok(
f"seeded Seller A ({PERSONA_NAME}, persona {PERSONA_ID}, {SELLER_COINS} coins, "
f"{len(SELLER_SQUAD_CARDS)} starters + 1 disposable) and Buyer B "
f"({BUYER_COINS} coins)"
)
def db_summary(lay: Layout) -> str:
@@ -1072,12 +729,11 @@ def start_host(lay: Layout, variant: str, coins_processed: str, count_mode: str)
return Launched("staging-utas-host", proc, lay.host_log, f"{BIND}:{HOST_PORT}")
def start_blaze(lay: Layout, roster_host: str) -> Launched:
def start_blaze(lay: Layout) -> Launched:
env = dict(
os.environ,
OPENFUT_ADVERTISE=ADVERTISE,
OPENFUT_BIND=BIND,
OPENFUT_ROSTER_HOST=roster_host,
# Read-only reuse of the production auxiliary services: static content, no
# economy state, and staging never connects to them -- it only advertises
# the same strings production Blaze advertises.
@@ -1110,8 +766,7 @@ def start_blaze(lay: Layout, roster_host: str) -> Launched:
)
ok(
f"staging blaze ready: redirector {BLAZE_REDIR_PORT}, main {BLAZE_MAIN_PORT}, "
f"nucleus {BLAZE_NUCLEUS_PORT} (pid {proc.pid}); roster {roster_host}; "
f"logged {want!r}"
f"nucleus {BLAZE_NUCLEUS_PORT} (pid {proc.pid}); logged {want!r}"
)
return Launched(
"staging-blaze", proc, lay.blaze_log,
@@ -1195,23 +850,8 @@ def cfg_block() -> list[str]:
def print_summary(lay: Layout, variant: str, coins_processed: str, count_mode: str,
roster_host: str, records: list[dict],
real_club: dict | None) -> None:
records: list[dict]) -> None:
banner("STAGING STACK IS UP")
if real_club is None:
print(" club: the 14-item synthetic fixture "
"(--club real installs the operator's own)")
else:
squad = real_club["squads"][0] if real_club["squads"] else None
print(f" club: THE REAL ONE -- {real_club['username']} "
f"({real_club['club_name']}), {real_club['owned_cards']} items, "
f"{real_club['coins']:,} coins")
if squad is not None:
print(f" squad {squad['name']!r} ({squad['formation']}) with "
f"{real_club['squad_players']} players")
print(" DO NOT run sold-staging-seed-squad.py: it would overwrite "
"this squad with the fixture XI")
print()
rows = [
("staging Core", f"127.0.0.1:{CORE_PORT}", "loopback only; client never talks to it"),
("staging utas-host", f"{BIND}:{HOST_PORT}", "UTAS the client reaches"),
@@ -1248,7 +888,6 @@ def print_summary(lay: Layout, variant: str, coins_processed: str, count_mode: s
print(f" experiment variant : {variant}")
print(f" coinsProcessed : {coins_processed}")
print(f" count mode : {count_mode}")
print(f" roster host : {roster_host}")
print(f" banner : {host_banner_line(lay)}")
print(f" seeded state : {db_summary(lay)}")
print(f" disposable item to sell : {DISPOSABLE_ITEM} ({DISPOSABLE_CARD})")
@@ -1292,20 +931,9 @@ def main() -> int:
ap.add_argument("--count-mode", choices=["active", "active_plus_sold"],
default="active",
help="what /tradePile/counts.count reports (default: active)")
ap.add_argument(
"--roster-host",
default=os.environ.get("OPENFUT_ROSTER_HOST", DEFAULT_ROSTER_HOST),
help=f"host:port advertised for roster HTTPS (default: {DEFAULT_ROSTER_HOST})",
)
ap.add_argument("--dir", default=os.environ.get("OPENFUT_SOLD_STAGING_DIR",
DEFAULT_STAGING_DIR),
help=f"staging directory (default: {DEFAULT_STAGING_DIR})")
ap.add_argument(
"--club", choices=["fixture", "real"], default="fixture",
help="which club the seller plays with: the 14-item synthetic fixture "
"(default) or the operator's real imported club, installed from the "
"snapshot taken by scripts/club-snapshot.py",
)
args = ap.parse_args()
lay = Layout(args.dir)
@@ -1316,7 +944,6 @@ def main() -> int:
step(f"repo : {REPO}")
step(f"forbidden ports : {sorted(FORBIDDEN_PORTS)}")
step(f"forbidden paths : {list(FORBIDDEN_PATHS)}")
step(f"club : {args.club}")
assert_prod_alive("preflight")
refuse_if_up(lay)
assert_ports_free()
@@ -1324,25 +951,16 @@ def main() -> int:
banner("MATERIALISE STAGING DIRECTORY")
materialise(lay)
reset_throwaway_state(lay)
# The real club is installed BEFORE Core first runs, so Core migrates the
# snapshot forward (it is a schema generation behind) rather than being
# handed a database it has already opened.
real_club = install_real_club(lay) if args.club == "real" else None
assert_seed_cards_resolvable(lay, real_club)
assert_seed_cards_resolvable(lay)
banner("PATCH THE BLAZE RESPONDER COPY")
patch_blaze(lay)
banner("STAGING CORE")
if real_club is None:
if os.path.exists(lay.core_db):
raise Fatal(
f"{lay.core_db} should have been removed by the state reset"
)
elif not os.path.exists(lay.core_db):
raise Fatal(f"{lay.core_db} should have been installed from the snapshot")
if os.path.exists(lay.core_db):
raise Fatal(f"{lay.core_db} should have been removed by the state reset")
migrate_core(lay)
seed_core_db(lay, real_club)
seed_core_db(lay)
started.append(start_core(lay))
banner("STAGING UTAS-HOST")
@@ -1350,7 +968,7 @@ def main() -> int:
args.count_mode))
banner("STAGING BLAZE")
started.append(start_blaze(lay, args.roster_host))
started.append(start_blaze(lay))
records = [item.record() for item in started]
for rec in records:
@@ -1366,11 +984,8 @@ def main() -> int:
"created_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"staging_dir": lay.root,
"variant": args.variant,
"club": args.club,
"real_club": real_club,
"coins_processed": args.coins_processed,
"count_mode": args.count_mode,
"roster_host": args.roster_host,
"ports": {
"core": CORE_PORT,
"utas_host": HOST_PORT,
@@ -1397,10 +1012,9 @@ def main() -> int:
banner("VERIFY ISOLATION")
verify(lay, args.variant)
print_summary(lay, args.variant, args.coins_processed, args.count_mode,
args.roster_host, records, real_club)
print_summary(lay, args.variant, args.coins_processed, args.count_mode, records)
return 0
except ProductionError as exc:
except Fatal as exc:
print(f"\nFATAL: {exc}", file=sys.stderr)
if started:
print(" rolling back the partial bring-up...", file=sys.stderr)
-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 ;;
-107
View File
@@ -1,107 +0,0 @@
# OpenFUT FIFA 17 - native Windows client
The FIFA 17 client host (`10.10.0.105`, Windows 11 Pro) runs FIFA 17 **natively**
(no Wine/Proton/umu). This directory holds the read-only preflight verifier and
documents the native launch/routing/rollback model.
## Install layout (`C:\FIFA 17`)
| File | Role |
|---|---|
| `FIFA17.exe` | retail game exe (sha256 `29C31CEF…`). **Never modify/patch.** ImageBase `0x140000000`. |
| `_fifa17.exe` | native crack loader (Chemicalflood). This is what you launch. `asInvoker` manifest -> must be elevated externally. |
| `version.dll` | **OpenFUT hook** (in-process via the version.dll load-order hijack). ImageBase `0x180000000`. |
| `version.dll.stale-849k.bak` | **rollback** copy of the previous hook. |
| `CardsDLL_Win64_retail.dll` | FUT card/SBC/kit logic. ImageBase `0x180000000`. |
| `powdll_Win64_retail.dll` | Pack-Opening-World (EASFC store). ImageBase `0x180000000`. |
| `sysdll_Win64_retail.dll` | EA networking / ProtoSSL (cert, ea.com). |
| `stp-origin_emu.dll` + `stp-origin_emu.ini` | Origin/LSX login emulator (in-process; opens LSX `:4216` locally at runtime). Persona configured in the `.ini`. |
| `stp-selector.exe` | ssl/LSX selector companion. |
| `openfut.cfg` | operator-facing routing override (see below). |
## Launch (native - there is NO launcher script by design)
Run `C:\FIFA 17\_fifa17.exe` **as Administrator**. The correct, reproducible way:
- Double-click the **"FIFA 17 (OpenFUT)"** shortcut (Desktop and Start Menu).
It targets `_fifa17.exe`, working dir `C:\FIFA 17`, with the RunAsAdmin bit set.
- `_fifa17.exe` is also flagged `RUNASADMIN` in
`HKCU\Software\Microsoft\Windows NT\CurrentVersion\AppCompatFlags\Layers`,
so any launch (Explorer double-click included) elevates via UAC.
On launch the Windows loader maps `version.dll` from the game directory (hijack),
`stp-origin_emu.dll` emulates Origin login for the configured persona, and the
hook redirects EA endpoints to the OpenFUT backend.
> Do **not** wrap the launch in a script. The elevation + shortcut is the
> supported mechanism. FIFA under native Windows also ignores synthetic input,
> so in-game steps are performed by the operator one at a time.
### OpenFUT Launcher (GUI)
The `openfut-launcher` egui app runs natively on Windows (built for
`x86_64-pc-windows-gnu`; the Linux Proton path is `cfg`-gated out). It is the
one-button front end: it checks the backend, reconciles `openfut.cfg` from its
settings, and starts the game.
- Binary: `C:\OpenFUT\openfut-launcher.exe`; config: `%APPDATA%\openfut-launcher\config.json`.
- Launch it from the **"OpenFUT Launcher"** shortcut (Desktop / Start Menu). The
launcher itself is flagged `RUNASADMIN`, so it elevates once at start; the game
it spawns inherits that elevation (no second UAC prompt, no crack loader
"run as admin" failure).
- On Windows the launcher does NOT spawn LSX/autopatch (they are in-process:
`stp-origin_emu.dll` + the `version.dll` hook) and does NOT arm the host
(routing is purely `openfut.cfg`, which it writes into `C:\FIFA 17`).
- Rebuild from Linux: `cargo build -p openfut-launcher --release --target x86_64-pc-windows-gnu`.
## Routing (`openfut.cfg`)
```
host=10.10.0.120
https_port=8443
blaze_redirector_port=42127
blaze_main_port=42130
```
The hook carries `10.10.0.120` as its baked-in default; `openfut.cfg` is the
override. `10.10.0.120` hosts **both** production and staging.
> **Production safety:** `blaze_main_port=42130` is the **production** Blaze.
> Before any match/economy exercise, repoint `blaze_main_port` (and the matching
> UTAS/HTTPS route) to the staging port so no traffic reaches the prod container.
> The preflight raises a WARN whenever `42130` is configured.
## Rollback
The hook is a single file swap; no installer state.
```powershell
# disable OpenFUT hook (restore previous DLL)
Copy-Item 'C:\FIFA 17\version.dll' 'C:\FIFA 17\version.dll.disabled.bak' -Force
Copy-Item 'C:\FIFA 17\version.dll.stale-849k.bak' 'C:\FIFA 17\version.dll' -Force
# re-arm: copy the desired hook build over version.dll again
```
Always keep a `*.bak` of the live hook before redeploying (the preflight checks
that a rollback backup exists and differs from the live DLL).
## Preflight
`openfut-client-preflight.ps1` is **read-only**: it never launches the game,
never elevates, never writes game files, never mutates economy state. It verifies
the retail exe hash, companion DLLs, hook + rollback, routing + backend
reachability, login persona, launcher elevation, and the RE toolchain
(x64dbg, cargo). Exit 0 = OK, 1 = blocking failure.
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File .\openfut-client-preflight.ps1
```
## Runtime RE (x64dbg)
See the Vault note **`02 Reverse Engineering/FIFA 17/Windows Client Runtime & x64dbg.md`**
for the attach workflow and the RVA<->VA (ASLR) math. In short: these modules'
preferred ImageBase is `0x180000000` (`0x140000000` for `FIFA17.exe`); in x64dbg
a module name evaluates to its runtime (ASLR) base, so a Ghidra address maps to a
breakpoint as `bp CardsDLL_Win64_retail.dll+<RVA>` where
`RVA = ghidra_addr - 0x180000000`.
-129
View File
@@ -1,129 +0,0 @@
<#
.SYNOPSIS
OpenFUT FIFA 17 Windows client preflight - READ ONLY.
.DESCRIPTION
Non-destructive verification of the native Windows FIFA 17 OpenFUT client on
this machine. It NEVER launches the game, never elevates, never writes to
game files, and never mutates any economy state. It only reads files,
registry, and performs TCP connect probes to the configured backend.
Exit code 0 = all PASS/WARN, 1 = one or more FAIL.
Native launch model (there is NO launcher script by design):
run C:\FIFA 17\_fifa17.exe as Administrator
(use the "FIFA 17 (OpenFUT)" shortcut, which carries the RunAsAdmin bit).
#>
[CmdletBinding()]
param(
[string]$FifaRoot = 'C:\FIFA 17'
)
$ProgressPreference = 'SilentlyContinue'
$ErrorActionPreference = 'SilentlyContinue'
# --- known-good fingerprints -------------------------------------------------
# Retail executable MUST NOT be modified/patched (Denuvo + anti-cheat sensitive).
$EXPECT_FIFA17_EXE_SHA256 = '29C31CEF12B0C3C2A7305220617C7B4FA139AB76B8C857851BDBE88987962899'
# Currently deployed OpenFUT hook (base-supply + VEH build). Update on redeploy.
$EXPECT_HOOK_SHA256 = '8844A6BCEE7BE37DD55B989246B312AF52FA711CCBC1220D25FEC64CBAD077D8'
$ROLLBACK_BAK = Join-Path $FifaRoot 'version.dll.stale-849k.bak'
$script:fail = 0
function Say([string]$level, [string]$msg) {
switch ($level) {
'PASS' { $c = 'Green' }
'WARN' { $c = 'Yellow' }
'FAIL' { $c = 'Red'; $script:fail++ }
default { $c = 'Gray' }
}
Write-Host ('[{0}] {1}' -f $level, $msg) -ForegroundColor $c
}
function Sha([string]$p) { if (Test-Path $p) { (Get-FileHash $p -Algorithm SHA256).Hash } else { $null } }
Write-Host '=== OpenFUT FIFA 17 Windows client preflight (read-only) ===' -ForegroundColor Cyan
Say 'INFO' ("host={0} user={1} {2}" -f $env:COMPUTERNAME, $env:USERNAME, (Get-CimInstance Win32_OperatingSystem).Caption)
# --- 1. FIFA install + retail exe integrity ---------------------------------
$exe = Join-Path $FifaRoot 'FIFA17.exe'
$loader = Join-Path $FifaRoot '_fifa17.exe'
if (Test-Path $exe) {
$h = Sha $exe
if ($h -eq $EXPECT_FIFA17_EXE_SHA256) { Say 'PASS' "FIFA17.exe present and unmodified ($($h.Substring(0,16))...)" }
else { Say 'FAIL' "FIFA17.exe hash MISMATCH - retail exe changed! got $($h.Substring(0,16))... expected $($EXPECT_FIFA17_EXE_SHA256.Substring(0,16))..." }
} else { Say 'FAIL' "FIFA17.exe missing at $exe" }
if (Test-Path $loader) { Say 'PASS' "native loader _fifa17.exe present ($((Sha $loader).Substring(0,16))...)" }
else { Say 'FAIL' "_fifa17.exe (native loader) missing - cannot launch" }
# --- 2. companion DLLs -------------------------------------------------------
$companions = 'CardsDLL_Win64_retail.dll','powdll_Win64_retail.dll','sysdll_Win64_retail.dll',
'FootballCompEng_Win64_retail.dll','stp-origin_emu.dll','stp-selector.exe'
foreach ($c in $companions) {
$p = Join-Path $FifaRoot $c
if (Test-Path $p) { Say 'PASS' "companion present: $c" } else { Say 'FAIL' "companion MISSING: $c" }
}
# --- 3. OpenFUT hook (version.dll) + rollback backup ------------------------
$hook = Join-Path $FifaRoot 'version.dll'
if (Test-Path $hook) {
$hh = Sha $hook
if ($hh -eq $EXPECT_HOOK_SHA256) { Say 'PASS' "hook version.dll deployed (expected build $($hh.Substring(0,16))...)" }
else { Say 'WARN' "hook version.dll present but hash differs from recorded build ($($hh.Substring(0,16))...) - may be a newer/older hook" }
$bytes = [IO.File]::ReadAllBytes($hook); $ascii = [Text.Encoding]::ASCII.GetString($bytes)
$markers = @('OpenFUT','fifa17','CardsDLL','SBC_DISPATCH') | Where-Object { $ascii -match [regex]::Escape($_) }
if ($markers.Count -ge 3) { Say 'PASS' "hook markers found: $($markers -join ', ')" }
else { Say 'WARN' "hook markers thin: $($markers -join ', ') - is this the OpenFUT hook?" }
} else { Say 'FAIL' "hook version.dll NOT deployed - client will run vanilla (no OpenFUT)" }
if (Test-Path $ROLLBACK_BAK) {
if ((Sha $ROLLBACK_BAK) -ne (Sha $hook)) { Say 'PASS' "rollback backup present and differs from live: $(Split-Path $ROLLBACK_BAK -Leaf)" }
else { Say 'WARN' "rollback backup equals live version.dll - rollback would be a no-op" }
} else { Say 'WARN' "no rollback backup ($(Split-Path $ROLLBACK_BAK -Leaf)) - keep one before redeploying the hook" }
# --- 4. routing config + backend reachability -------------------------------
$cfg = Join-Path $FifaRoot 'openfut.cfg'
$host120 = $null; $ports = @()
if (Test-Path $cfg) {
$kv = @{}; foreach ($l in Get-Content $cfg) { if ($l -match '^\s*([^=#]+)=(.+)$') { $kv[$matches[1].Trim()] = $matches[2].Trim() } }
$host120 = $kv['host']
Say 'PASS' "openfut.cfg routing: host=$($kv['host']) https=$($kv['https_port']) redirector=$($kv['blaze_redirector_port']) blaze=$($kv['blaze_main_port'])"
foreach ($k in 'https_port','blaze_redirector_port','blaze_main_port') { if ($kv[$k]) { $ports += [int]$kv[$k] } }
if ($kv['blaze_main_port'] -eq '42130') { Say 'WARN' 'blaze_main_port=42130 targets PRODUCTION - repoint to a staging port before match/economy testing' }
} else { Say 'WARN' "openfut.cfg absent - hook uses its baked-in default host" }
if ($host120) {
foreach ($p in $ports) {
$t = New-Object Net.Sockets.TcpClient
try {
$ar = $t.BeginConnect($host120, $p, $null, $null)
if ($ar.AsyncWaitHandle.WaitOne(2500) -and $t.Connected) { Say 'PASS' "backend reachable ${host120}:$p" }
else { Say 'FAIL' "backend UNREACHABLE ${host120}:$p" }
} catch { Say 'FAIL' "backend probe error ${host120}:$p - $($_.Exception.Message)" } finally { $t.Close() }
}
}
# --- 5. login persona (stp origin emulator) ---------------------------------
$ini = Join-Path $FifaRoot 'stp-origin_emu.ini'
if (Test-Path $ini) {
$persona = (Get-Content $ini | Select-String 'PersonaId|PersonaName') -join ' '
Say 'PASS' "login emulator config: $persona"
} else { Say 'FAIL' "stp-origin_emu.ini missing - no login persona" }
# --- 6. native launcher elevation setup -------------------------------------
$lk = 'HKCU:\Software\Microsoft\Windows NT\CurrentVersion\AppCompatFlags\Layers'
$layer = (Get-ItemProperty $lk).$loader
if ($layer -match 'RUNASADMIN') { Say 'PASS' "_fifa17.exe RUNASADMIN flag set ($layer)" }
else { Say 'WARN' '_fifa17.exe has no RUNASADMIN flag - launch must be manual "Run as administrator"' }
$sc = Join-Path $env:USERPROFILE 'Desktop\FIFA 17 (OpenFUT).lnk'
if (Test-Path $sc) { Say 'PASS' "desktop launcher shortcut present: $(Split-Path $sc -Leaf)" }
else { Say 'WARN' 'no desktop launcher shortcut' }
# --- 7. RE toolchain ---------------------------------------------------------
$x = Get-ChildItem "$env:LOCALAPPDATA\Microsoft\WinGet\Packages" -Recurse -Depth 4 -Include x64dbg.exe -Attributes !ReparsePoint -EA SilentlyContinue | Select-Object -First 1 -Expand FullName
if (-not $x) { $x = (Get-Command x64dbg.exe -EA SilentlyContinue).Source }
if ($x) { Say 'PASS' "x64dbg present: $x (v$((Get-Item $x).VersionInfo.FileVersion))" } else { Say 'WARN' 'x64dbg not located - install for runtime RE' }
$cargo = (Get-Command cargo -EA SilentlyContinue).Source
if ($cargo) { Say 'PASS' "rust toolchain: $cargo" } else { Say 'WARN' 'cargo not found - needed to rebuild the hook natively' }
# --- summary -----------------------------------------------------------------
Write-Host ''
if ($script:fail -eq 0) { Write-Host 'PREFLIGHT: PASS (no blocking failures)' -ForegroundColor Green; exit 0 }
else { Write-Host "PREFLIGHT: FAIL ($script:fail blocking issue(s))" -ForegroundColor Red; exit 1 }