8 Commits

Author SHA1 Message Date
funman300 e5d356e8be chore(submodules): bump core/launcher/bridge pointers to pushed commits
openfut-core -> a034e74 (cargo fmt pass, pushed)
openfut-launcher -> 8d5bb62 (fifa17 season diag commits, pushed)
openfut-bridge -> c58e732 (consolidate backup HEAD, on origin/main)
All targets verified present on their remotes. fifa-blaze unchanged.
2026-08-20 16:10:22 +00:00
funman300 0b189b36c5 chore(tools): add utas-filter-diff.py diagnostic
Read-only UTAS capture diff helper. Retained pre-existing WIP verified.
2026-08-20 16:06:29 +00:00
funman300 11c17f3039 style(adapter-fifa17): apply cargo fmt to fut store/pack/non_economy/club_stats
Pure rustfmt reflow; git diff -w confirms logic byte-identical (PACK_CATALOG
values, pack tiers, item_def stubs unchanged). Builds clean. Retained WIP.
2026-08-20 16:06:29 +00:00
funman300 871d02406f chore(deploy): add root core+bridge compose stack + .env.example
Localhost-default (127.0.0.1) compose for the Rust core+bridge; env-driven
CORE_PUBLISH. No secrets/staging-prod defaults. Retained WIP verified.
2026-08-20 16:06:29 +00:00
funman300 6811caeab1 feat(fifa17-docker): OPENFUT_SERVERS component selection for staged Py->Rust migration
entrypoint.sh validates/selects among lsx blaze roster utas pow and skips
unselected responders (errors if none). docker-compose threads the env
through; .env.example documents it. Retained pre-existing WIP verified.
2026-08-20 16:06:28 +00:00
funman300 d71234b03d docs: add AGENTS.md canonical entry point; mark FIFA23 README/CLAUDE/setup stale
AGENTS.md is the new canonical AI-agent entry point (FIFA17 active target,
repo map, FIFA23->FIFA17 pivot history). README/CLAUDE/setup.sh get stale
banners pointing to it. Retained pre-existing WIP brought forward.
2026-08-20 16:06:28 +00:00
funman300 8e1fb640b6 tools: authoritative Core-state snapshot for FUT loop verification
Reads openfut-core's authoritative API directly (balance, entitlements, profile,
club, collection, squad/ext) with the fifa17 game header and emits a
machine-readable JSON snapshot plus integrity checks: coin cross-check, duplicate
owned_card_id, squad-references-non-owned, owned-set + card-multiset hashes for
drift/resurrection detection across operations and restarts. Read-only oracle
tooling; used to verify Phases 1-9 of the Core-backed FUT loop audit.
2026-08-19 19:20:28 +00:00
funman300 0019806a3b tools(fifa17): refuse to stage/deploy a non-fifa17-profile hook DLL
openfut-hook builds two mutually exclusive injection paths from one crate. The
default (FIFA 23) path installs getaddrinfo/connect/ProtoSSL/origin_spy transport
hooks; `--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: the
client reports "Unable to connect to the EA servers at this time" and none of the
FIFA 17 repairs are present in the binary at all.

That happened today: artifact 1c71a17a was built by hand without the feature and
deployed, costing two failed launches. It was diagnosed only by comparing embedded
strings between the deployed DLL and the last known-good one (the deployed DLL had
0 occurrences of CardsDLL_Win64_retail.dll and SBC_DISPATCH, and 6 of cert-verify
plus 1 of "connect: inline-hooked" -- the inverse of a fifa17 build).

`build` already passes --features fifa17, but OPENFUT_FIFA17_HOOK_DLL lets a
hand-built DLL reach stage/deploy, so verify_fifa17_profile asserts the profile on
the bytes: CardsDLL_Win64_retail.dll and SBC_DISPATCH must be present, and the
FIFA-23-only markers must be absent. Wired into verify_inputs (stage/inspect) and
into deploy's staged-artifact checks.

Verified: the gate rejects 1c71a17a, accepts 3641d581 (last known good) and
f0ef528f (the corrected fifa17 build now deployed).
2026-08-19 18:31:27 +00:00
19 changed files with 971 additions and 56 deletions
+25
View File
@@ -0,0 +1,25 @@
# OpenFUT Docker stack configuration. Copy to .env and adjust.
# All values have sensible defaults in docker-compose.yml; override as needed.
# --- Container registry (Gitea) ---
# Images resolve to ${REGISTRY}/${NAMESPACE}/<image>:${TAG}
# e.g. git.aleshym.co/openfut/openfut-core:latest
REGISTRY=git.aleshym.co
NAMESPACE=openfut
TAG=latest
# --- Networking ---
# Where the bridge (FIFA client entry point) is published. 0.0.0.0 = all
# interfaces so LAN clients can connect. Set to a specific IP to restrict.
BRIDGE_PUBLISH=0.0.0.0
# Where core's REST API is published. 127.0.0.1 keeps it host-local (the bridge
# still reaches it over the internal docker network). Set 0.0.0.0 to expose it.
CORE_PUBLISH=127.0.0.1
# --- Behaviour ---
# Bridge returns placeholder JSON + captures unknown routes when true.
PLACEHOLDER_MODE=true
# --- Logging (RUST_LOG filters) ---
CORE_LOG=openfut_core=info,tower_http=info
BRIDGE_LOG=openfut_bridge=info,tower_http=info
+163
View File
@@ -0,0 +1,163 @@
# AGENTS.md — OpenFUT
**Read this first.** It is the entry point for AI-assisted work on OpenFUT. It supersedes the
root `README.md` and `CLAUDE.md`, which are **stale** (they describe an earlier FIFA 23 plan).
## Project
OpenFUT is a preservation / private-server project that restores **offline, single-player FIFA
Ultimate Team (FUT)** after EA retired the online servers. You must own the game legitimately; the
project does not bypass ownership checks — it only re-serves the dead online services locally.
**Current active target: FIFA 17 (PC).** A clean-room emulation of the full online + FUT stack
was proven working end-to-end on **2026-08-01** (auth → Blaze login → device-trust → FUT hub).
This lives in `fifa17-recon/`. The FIFA 17 work is explicitly the **Rosetta Stone for FIFA 23**
(identical Blaze/LSX/UTAS wire format), so FIFA 23 remains the eventual second target.
Three moving parts, kept strictly separate:
- **The FIFA client** — the retail game (FIFA 17 now). Unmodified except live cert-verify patches.
- **The emulation layer** — Python responders in `fifa17-recon/tools/` (LSX, Blaze, UTAS, roster)
that impersonate EA's online services on localhost. This is where all reverse engineering lives.
- **OpenFUT Core** — a game-independent REST FUT economy backend (`openfut-core/`), feature-complete
and tested. Knows nothing about FIFA. Intended to eventually back the emulation layer's FUT data.
> The emulation layer and Core are **not yet wired together.** The FIFA 17 UTAS server currently
> serves its own hardcoded/JSON payloads, not Core's API. See `docs/PROJECT_STATE.md`.
## Repository map
Monorepo. `openfut-core`, `openfut-bridge`, `openfut-launcher`, `fifa-blaze` are **git submodules**
(each with independent history — use `tea`/Gitea, not `gh`). `fifa17-recon/` is a plain directory.
| Path | What it is | Status |
|---|---|---|
| `fifa17-recon/` | **The live path.** FIFA 17 offline FUT emulation: Python responders, cert patcher, runbook, RE write-ups. | Working |
| `openfut-core/` | Rust (Axum + SQLite) FUT economy backend. Game-independent REST API. | Working, tested |
| `openfut-bridge/` | Rust FIFA 23 in-process hook / proxy RE effort. | Blocked (see below) |
| `fifa-blaze/` | Rust Blaze protocol emulator scaffold for FIFA 23 (capture stub). | Milestone 1 stub |
| `openfut-launcher/` | Rust egui/eframe desktop launcher (targets FIFA 23 hook flow). | Legacy plan |
| `docs/` | **Mirrors** of the vault (`OpenFUT-Vault`), which is canonical. Direction pivots + context. | — |
| `tools/` | Host-side RE helpers (file-watch-diff, exporters, squad-injector) from the FLE-bridge idea. | Legacy plan |
| `setup.sh` | FIFA 23 full-stack orchestrator (core+bridge). | Legacy plan |
**Legacy vs live:** the project pivoted twice — (1) FIFA 23 Blaze backend → (2) FIFA 23 as a match
renderer driven by an FLE Lua bridge (`docs/direction.md`) → (3) **FIFA 17 full online emulation,
which succeeded and is now the primary path** (`fifa17-recon/`). Treat `openfut-bridge`,
`openfut-launcher`, `fifa-blaze`, `tools/`, `setup.sh`, and `docs/direction.md` as historical unless
a task explicitly targets the FIFA 23 port.
## Architecture (live path)
```
FIFA 17 client (Wine/Proton, base 0x140000000)
│ autopatch.py NOPs two ProtoSSL cert-verify gates in /proc/PID/mem
├─ LSX 127.0.0.1:4216 → lsx_responder_v2.py (Origin login/profile/authcode)
├─ TLS 127.0.0.1:42127 → blaze_responder_v3b.py (Blaze redirector, via DNAT of 159.153.51.20)
├─ Blaze 42130 / Nucleus 42131 → blaze_responder_v3b.py (Fire2/Heat2 binary + login)
├─ easw.easports.com (→127.0.0.1) :8099 → utas_server.py (UTAS/RS4 FUT API + device-trust)
└─ roster :8081 → roster_server.py (FUT roster-update XML)
OpenFUT Core (openfut-core, :8080) ── clean REST FUT economy ── NOT YET CONNECTED to the above
```
Host arming (`root_arm.sh` via `pkexec`, volatile across reboot): `ptrace_scope=0`,
`route_localnet=1`, iptables DNAT `159.153.51.20→127.0.0.1:42127`, `/etc/hosts easw.easports.com`.
## Development commands (verified)
**FIFA 17 emulation** (from `fifa17-recon/tools/`):
- Start everything (idempotent; re-run after reboot): `./openfut-fut.sh start`
- Status / stop / restart: `./openfut-fut.sh status | stop | restart`
- Then launch the game fresh (`~/Desktop/launch-fifa17.sh`) and pick Ultimate Team.
- Logs: `/tmp/{lsx,blaze,roster,utas,autopatch}.log`
- Full procedure + gate-ladder troubleshooting: `fifa17-recon/FUT-RUNBOOK.md`
**OpenFUT Core** (from `openfut-core/`): `cargo run` (creates `openfut.db`) · `cargo test`
(full in-memory integration suite; requires `data/`) · `cargo test <name>` for one ·
`cargo clippy -- -D warnings` · `cargo fmt`. Env: `LISTEN_ADDR` (127.0.0.1:8080), `DATABASE_URL`
(sqlite://openfut.db), `DATA_DIR` (data).
**Other Rust crates** (`openfut-bridge`, `fifa-blaze`, `openfut-launcher`): standard
`cargo run/build/test/clippy/fmt` from within each. `fifa-blaze` is a workspace (`--bin blaze-server`).
**CI:** only `openfut-core` has it (`.gitea/workflows/ci.yml`): `fmt --check`, `clippy -D warnings`,
`build --locked`, `test --locked` on push/PR to main. No CI on the other crates or the recon dir.
There is **no install step, no Docker, no JS/TS frontend, no typecheck** in this repo. Do not invent them.
## Coding conventions
- **Rust (Core):** Axum 0.7 + SQLx 0.7 (SQLite, compile-time-checked queries). Strict layering —
`routes/` (handlers, extract state, call services) → `services/` (own **all** DB access + logic)
→ `models/` (pure `Serde`/`FromRow` data). Errors via `AppError` (`src/error.rs`) with
`IntoResponse`. One file per domain across `routes/`, `services/`, `models/`. **Single-profile
design:** every service reads "the active profile" as the first DB row — intentional, don't
parameterize it. Content is data-driven: JSON under `data/` loaded at startup into Arc registries
in `AppState`. Add content by dropping JSON files, not code. Migrations are numbered SQL in
`migrations/`. Keep `clippy -D warnings` and `fmt` clean (CI enforces).
- **Python (recon):** stdlib-only servers, no framework. Each responder is a standalone script with
the reverse-engineered contract documented in its module docstring (byte offsets, VAs, symbol
names). When changing a responder, preserve byte-exactness — the client is the oracle.
- **Clean-room, always.** Every finding derives from binaries we own + live observation. **Never**
use, reference, or reproduce leaked EA source. If a task seems to need it, stop and say so.
## AI-agent rules
1. Read this file before exploring the repo.
2. Read the vault file relevant to the task (`../OpenFUT-Vault/`), not the whole tree. Repo
`docs/` files are mirrors of the vault — consult them for the same content, but treat the
vault as canonical.
3. Don't scan the whole repository unless the knowledge base is clearly stale — if you find it
stale, update the vault, then its repo `docs/` mirror.
4. Search the specific directory (`fifa17-recon/`, `openfut-core/src/<layer>/`) before a repo-wide search.
5. Update the vault when architecture materially changes (and sync the matching `docs/` mirror).
6. Don't refactor or rewrite unrelated working code.
7. Prefer small, testable changes; run the narrowest relevant test first (`cargo test <name>`).
8. **Never invent EA/FIFA/Blaze protocol behavior.** Values you don't know are `TODO/CONFIRM`, not
confident guesses. The live client is the only oracle for whether a gate is satisfied.
9. Clearly separate discovered behavior from hypotheses; record findings in
`../OpenFUT-Vault/02 Reverse Engineering/FIFA 17/Protocol Findings.md` under the right confidence
tier — never silently promote a hypothesis to a fact.
10. Root `README.md` / `CLAUDE.md` and `openfut-bridge/CLAUDE.md` describe superseded FIFA 23 plans;
prefer vault + repository evidence over them when they conflict.
## AI Session Bootstrap
Future agents should start with:
1. Read `AGENTS.md`.
2. Read the vault README (`../OpenFUT-Vault/README.md`) to locate the canonical files.
3. Identify the subsystem the task affects and read the corresponding vault file: Architecture,
Project State, Roadmap/Current Priorities, or Protocol Findings.
4. Inspect only the relevant source directories.
5. Check `../OpenFUT-Vault/02 Reverse Engineering/FIFA 17/Protocol Findings.md` before assuming
anything about FIFA/EA behavior.
6. Check `../OpenFUT-Vault/06 Agent Memory/Project State.md` before assuming a feature exists.
7. Implement the smallest coherent change.
8. Run the narrowest relevant tests.
9. Update the vault (and its repo `docs/` mirror) only if the change makes existing knowledge
inaccurate.
Do not reread the entire repository during every session.
## OpenFUT Knowledge Base
**The OpenFUT Vault is the canonical project knowledge base.** Repo `docs/` files mirror it; the
vault wins on any disagreement. Consult it before starting substantial work and update it after
durable discoveries.
Vault location: `../OpenFUT-Vault/` — start at `../OpenFUT-Vault/README.md`.
Canonical files:
- Dashboard: `00 Dashboard/OpenFUT.md`
- Architecture: `01 Architecture/Architecture.md` (repo mirror `docs/ARCHITECTURE.md`)
- RE findings: `02 Reverse Engineering/FIFA 17/Protocol Findings.md`
(repo mirror `docs/research/KNOWN_FINDINGS.md`)
- Direction history: `04 Decisions/Direction History.md`
- Project State: `06 Agent Memory/Project State.md` (repo mirror `docs/PROJECT_STATE.md`)
- Current Priorities: `06 Agent Memory/Current Priorities.md`
- Known Issues: `06 Agent Memory/Known Issues.md`
- Important Discoveries: `06 Agent Memory/Important Discoveries.md`
- Roadmap: `08 Roadmap/Roadmap.md` (repo mirror `docs/ROADMAP.md`)
When editing knowledge that exists in both places, edit the vault first, then update the matching
`docs/` mirror so they stay in sync.
+2
View File
@@ -2,6 +2,8 @@
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
> ⚠️ **Stale (FIFA 23).** This file's status and targets predate the FIFA 17 pivot. Prefer [`docs/PROJECT_STATE.md`](./docs/PROJECT_STATE.md) (canonical). The working target is **FIFA 17**; the canonical server is `fifa17-recon/docker/fifa17-python` (`docker compose up -d`). `openfut-bridge` (FIFA 23) is superseded; `openfut-core` remains the shared backend.
## Repository Layout ## Repository Layout
This is a monorepo containing three independent Rust crates as git submodules: This is a monorepo containing three independent Rust crates as git submodules:
+4
View File
@@ -1,5 +1,9 @@
# OpenFUT # OpenFUT
> ⚠️ **Status — see [`docs/PROJECT_STATE.md`](./docs/PROJECT_STATE.md) (canonical).** The working, actively-developed target is **FIFA 17**, not FIFA 23. Everything below this banner describes the **superseded FIFA 23 `bridge` lineage** and is kept for historical context.
>
> **Run the server (canonical):** `cd fifa17-recon/docker/fifa17-python && docker compose up -d` — see [`fifa17-recon/FUT-RUNBOOK.md`](./fifa17-recon/FUT-RUNBOOK.md). `openfut-core` is the shared offline backend (still used by the FIFA 17 path); `openfut-bridge` is the retired FIFA 23 integration.
**Offline Ultimate Team — like SPT, but for FIFA 23.** **Offline Ultimate Team — like SPT, but for FIFA 23.**
OpenFUT replaces EA's retired FUT servers with a fully offline, single-player backend. You own FIFA 23 legitimately. You just want to keep playing after EA shut down the servers. OpenFUT replaces EA's retired FUT servers with a fully offline, single-player backend. You own FIFA 23 legitimately. You just want to keep playing after EA shut down the servers.
+93
View File
@@ -0,0 +1,93 @@
# ============================================================================
# ⚠️ LEGACY (FIFA 23 lineage). This compose runs core + bridge for the
# superseded FIFA 23 direction. It is NOT the canonical server bring-up.
#
# Canonical server (FIFA 17):
# cd fifa17-recon/docker/fifa17-python && docker compose up -d
# (runbook: fifa17-recon/FUT-RUNBOOK.md)
#
# `core` (openfut-core) IS still the shared, game-independent backend and is
# used by the FIFA 17 UTAS host (OPENFUT_CORE_URL). `bridge` (openfut-bridge)
# is the retired FIFA 23 integration, kept for reference.
# Status source of truth: docs/PROJECT_STATE.md
# ============================================================================
# OpenFUT server stack — offline FUT backend (Core) + FIFA proxy (Bridge).
#
# Bring up: docker compose up -d
# Tear down: docker compose down (keeps data/captures volumes)
# Wipe state: docker compose down -v (also drops volumes)
# Rebuild: docker compose build (or ./scripts/registry.sh build)
# Logs: docker compose logs -f
#
# Images are pulled from / pushed to the Gitea container registry. Override the
# registry, namespace, or tag in .env (see .env.example). When REGISTRY is set,
# `up` pulls prebuilt images; the build: blocks let you rebuild locally too.
name: openfut
services:
core:
image: ${REGISTRY:-git.aleshym.co}/${NAMESPACE:-openfut}/openfut-core:${TAG:-latest}
build:
context: ./openfut-core
dockerfile: Dockerfile
restart: unless-stopped
environment:
LISTEN_ADDR: 0.0.0.0:8080
DATABASE_URL: sqlite:///app/db/openfut.db
DATA_DIR: /app/data
RUST_LOG: ${CORE_LOG:-openfut_core=info,tower_http=info}
volumes:
- core-db:/app/db
# Bound to localhost by default — the bridge reaches core over the internal
# network, so core need not be world-exposed. Set CORE_PUBLISH=0.0.0.0 in
# .env if you want to hit the REST API directly from other hosts.
ports:
- "${CORE_PUBLISH:-127.0.0.1}:8080:8080"
networks:
- openfut
healthcheck:
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8080/health"]
interval: 15s
timeout: 4s
retries: 5
start_period: 10s
bridge:
image: ${REGISTRY:-git.aleshym.co}/${NAMESPACE:-openfut}/openfut-bridge:${TAG:-latest}
build:
context: ./openfut-bridge
dockerfile: Dockerfile
restart: unless-stopped
depends_on:
core:
condition: service_healthy
environment:
BRIDGE_LISTEN_ADDR: 0.0.0.0:8443
CORE_URL: http://core:8080
CAPTURES_DIR: /app/captures
PLACEHOLDER_MODE: ${PLACEHOLDER_MODE:-true}
TLS_ENABLED: "true"
RUST_LOG: ${BRIDGE_LOG:-openfut_bridge=info,tower_http=info}
volumes:
- bridge-captures:/app/captures
# The FIFA client connects here — publish on all interfaces by default so
# LAN clients (e.g. 10.10.0.0/24) can reach it.
ports:
- "${BRIDGE_PUBLISH:-0.0.0.0}:8443:8443"
networks:
- openfut
healthcheck:
test: ["CMD", "curl", "-fsSk", "https://127.0.0.1:8443/_bridge/health"]
interval: 15s
timeout: 4s
retries: 5
start_period: 8s
networks:
openfut:
driver: bridge
volumes:
core-db:
bridge-captures:
@@ -9,3 +9,25 @@ OPENFUT_ADVERTISE=203.0.113.10 # <- REPLACE with this host's LAN IP
# Defaults to 0.0.0.0 (container-facing); the original all-on-localhost flow # Defaults to 0.0.0.0 (container-facing); the original all-on-localhost flow
# uses the loopback default baked into the responders when unset. # uses the loopback default baked into the responders when unset.
OPENFUT_BIND=0.0.0.0 OPENFUT_BIND=0.0.0.0
# OPENFUT_SERVERS — which Python responders Docker runs (space/comma separated).
# Default (unset) = the server-side set: "blaze roster utas pow".
#
# This host is the SERVER (.120). Docker runs ONLY components that have NOT been
# migrated to a Rust host. During migration the Rust hosts (redirector / roster
# / utas) run OUTSIDE Docker; as each Python component is replaced, remove its
# name here so the two never serve the same role at once.
# blaze Blaze redirector + main + nucleus (bundled) :42127 :42130 :42131
# roster FUT roster-update XML :8081
# utas FUT/UTAS RS4 API :8099
# (Rust utas-host still proxies its non-/club routes here for now)
# pow POW / EASFC :8094 (+ content :8080)
# lsx Origin LSX bootstrap :4216
# CLIENT-SIDE: LSX runs on the game machine (.105) with autopatch, NOT
# on this server. Leave it OUT unless client and server share one box.
#
# Example — Rust already owns roster, so Docker should not also serve it:
# OPENFUT_SERVERS=blaze utas pow
# When you drop a component, also stop advertising / DNAT'ing its port to this
# container so the client is routed to the Rust host instead.
#OPENFUT_SERVERS=blaze roster utas pow
@@ -36,10 +36,14 @@ services:
FUT_PROFILE_ROOT: "/state/accounts" FUT_PROFILE_ROOT: "/state/accounts"
FUT_SETTINGS: "off" FUT_SETTINGS: "off"
FUT_MODES: "1" FUT_MODES: "1"
# Which Python responders this SERVER runs. Default excludes lsx (that is
# a client-side responder — see below). Drop a name once it is migrated to
# a Rust host (run outside Docker) so the two never overlap. See .env.example.
OPENFUT_SERVERS: "${OPENFUT_SERVERS:-blaze roster utas pow}"
volumes: volumes:
- "../state:/state" - "../state:/state"
ports: ports:
- "4216:4216" # LSX (Origin bootstrap) - "4216:4216" # LSX — CLIENT-SIDE (.105); only used if lsx is enabled for all-on-one-box
- "42127:42127" # Blaze redirector (TLS) - "42127:42127" # Blaze redirector (TLS)
- "42130:42130" # Blaze main - "42130:42130" # Blaze main
- "42131:42131" # Nucleus OAuth stub - "42131:42131" # Nucleus OAuth stub
@@ -57,10 +57,40 @@ declare -a SERVERS=(
"pow|pow_server.py|-" "pow|pow_server.py|-"
) )
# ── Component selection ──────────────────────────────────────────────────────
# OPENFUT_SERVERS picks which Python responders run (space- or comma-separated).
# This container is the SERVER side (.120). It serves ONLY components that have
# NOT been migrated to a Rust host — as each moves to Rust (which runs OUTSIDE
# Docker during migration), drop its name so the two never serve the same role.
# blaze Blaze redirector + main + nucleus (bundled) :42127 :42130 :42131
# roster FUT roster-update XML :8081
# utas FUT/UTAS RS4 API :8099
# (the Rust utas-host currently reverse-proxies its non-/club routes
# back here, so keep this enabled until UTAS is fully migrated)
# pow POW / EASFC :8094 (+ content :8080)
# lsx Origin LSX bootstrap :4216
# CLIENT-SIDE — LSX runs on the game machine (.105) with autopatch,
# NOT on the server. Excluded by default; enable ONLY for an
# all-on-one-box dev setup where client and server share a host.
OPENFUT_SERVERS="${OPENFUT_SERVERS:-blaze roster utas pow}"
want=" ${OPENFUT_SERVERS//,/ } "
known=" lsx blaze roster utas pow "
for w in $want; do
case "$known" in
*" $w "*) ;;
*) echo "[openfut] unknown component '$w' in OPENFUT_SERVERS (valid: lsx blaze roster utas pow)" >&2; exit 2 ;;
esac
done
echo "[openfut] servers=$OPENFUT_SERVERS"
pids=() pids=()
names=() names=()
for entry in "${SERVERS[@]}"; do for entry in "${SERVERS[@]}"; do
IFS='|' read -r name script env <<<"$entry" IFS='|' read -r name script env <<<"$entry"
case "$want" in
*" $name "*) ;;
*) echo "[openfut] skipping $name (not in OPENFUT_SERVERS)"; continue ;;
esac
envprefix=""; [ "$env" != "-" ] && envprefix="env $env" envprefix=""; [ "$env" != "-" ] && envprefix="env $env"
echo "[openfut] starting $name ($script)" echo "[openfut] starting $name ($script)"
# shellcheck disable=SC2086 # shellcheck disable=SC2086
@@ -69,6 +99,11 @@ for entry in "${SERVERS[@]}"; do
names+=("$name") names+=("$name")
done done
if [ "${#pids[@]}" -eq 0 ]; then
echo "[openfut] OPENFUT_SERVERS selected no components; nothing to run" >&2
exit 2
fi
# Propagate SIGTERM/SIGINT to children so `docker stop` is clean. # Propagate SIGTERM/SIGINT to children so `docker stop` is clean.
term() { term() {
echo "[openfut] shutting down…" echo "[openfut] shutting down…"
+30
View File
@@ -55,6 +55,34 @@ verify_exports() {
done done
} }
# Refuse any DLL that is not a FIFA-17-profile build.
#
# openfut-hook builds TWO mutually exclusive injection paths from one crate: the
# default (FIFA 23) path installs getaddrinfo/connect/ProtoSSL/origin hooks, while
# `--features fifa17` installs ONLY the FIFA-17-safe logic (module map, FIFA 17
# cert-verify, SBC dispatch, store tab bind). Deploying a default-feature build
# into FIFA 17 hijacks the login transport and the client reports "Unable to
# connect to the EA servers", with none of the FIFA 17 repairs present.
#
# That exact mistake happened on 2026-08-19 (artifact 1c71a17a, hand-built without
# the feature): two failed launches, diagnosed only by comparing embedded strings.
# `build` below passes the feature, but a hand-built DLL can reach `stage`/`deploy`
# via OPENFUT_FIFA17_HOOK_DLL, so assert the profile on the bytes themselves.
verify_fifa17_profile() {
local dll=$1 marker
# Markers that MUST be present: the FIFA 17 target module and its repairs.
for marker in 'CardsDLL_Win64_retail.dll' 'SBC_DISPATCH'; do
grep -qaF -- "$marker" "$dll" ||
die "$dll is not a --features fifa17 build (missing $marker); refusing to stage/deploy"
done
# Markers that MUST be absent: the FIFA-23-only transport hooking.
for marker in 'getaddrinfo IAT patched' 'connect: inline-hooked' 'origin_spy'; do
if grep -qaF -- "$marker" "$dll"; then
die "$dll contains FIFA-23-only hook '$marker'; build with --features fifa17"
fi
done
}
verify_inputs() { verify_inputs() {
command -v sha256sum >/dev/null || die "sha256sum is required" command -v sha256sum >/dev/null || die "sha256sum is required"
command -v x86_64-w64-mingw32-objdump >/dev/null || command -v x86_64-w64-mingw32-objdump >/dev/null ||
@@ -62,6 +90,7 @@ verify_inputs() {
need_file "$hook_dll" need_file "$hook_dll"
need_file "$system_version" need_file "$system_version"
verify_pe64 "$hook_dll" verify_pe64 "$hook_dll"
verify_fifa17_profile "$hook_dll"
} }
inspect() { inspect() {
@@ -129,6 +158,7 @@ deploy() {
need_file "$manifest" need_file "$manifest"
verify_pe64 "$staged" verify_pe64 "$staged"
verify_exports "$staged" verify_exports "$staged"
verify_fifa17_profile "$staged"
local recorded actual local recorded actual
recorded="$(awk -F= '$1=="artifact_sha256"{print $2}' "$manifest")" recorded="$(awk -F= '$1=="artifact_sha256"{print $2}' "$manifest")"
actual="$(sha256 "$staged")" actual="$(sha256 "$staged")"
+8 -2
View File
@@ -379,7 +379,10 @@ mod tests {
#[test] #[test]
fn honest_zero_club_items_present() { 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 [ for atom in [
"stadia", "stadia",
"balls", "balls",
@@ -411,7 +414,10 @@ mod tests {
.filter(|r| r["contextId"] == 3 && r["contextValue"] == 13) .filter(|r| r["contextId"] == 3 && r["contextValue"] == 13)
.collect(); .collect();
assert_eq!(league_rows.len(), 6); 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); assert_eq!(gold["typeValue"], 1);
// league screen -> team (teamid) buckets: players/kits/badgeDBid (3 rows). // league screen -> team (teamid) buckets: players/kits/badgeDBid (3 rows).
+22 -5
View File
@@ -67,8 +67,15 @@ pub fn feature_off_body() -> Value {
pub fn item_def(resource_id: i64) -> Value { pub fn item_def(resource_id: i64) -> Value {
let asset = resource_id & 0xff_ffff; let asset = resource_id & 0xff_ffff;
// (name, rating, position, nation, leagueId, teamid, [6 attrs]) // (name, rating, position, nation, leagueId, teamid, [6 attrs])
let (name, rating, pos, nation, league, team, attrs): (&str, i64, &str, i64, i64, i64, [i64; 6]) = let (name, rating, pos, nation, league, team, attrs): (
if asset == 20801 { &str,
i64,
&str,
i64,
i64,
i64,
[i64; 6],
) = if asset == 20801 {
("Ronaldo", 94, "ST", 38, 53, 243, [90, 93, 82, 91, 33, 80]) ("Ronaldo", 94, "ST", 38, 53, 243, [90, 93, 82, 91, 33, 80])
} else { } else {
("Player", 75, "CM", 0, 0, 0, [70, 70, 70, 70, 70, 70]) ("Player", 75, "CM", 0, 0, 0, [70, 70, 70, 70, 70, 70])
@@ -365,7 +372,11 @@ pub fn user_mass_info_body(
pub fn format_utc_datetime(epoch_secs: i64) -> String { pub fn format_utc_datetime(epoch_secs: i64) -> String {
let days = epoch_secs.div_euclid(86_400); let days = epoch_secs.div_euclid(86_400);
let secs_of_day = epoch_secs.rem_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. // civil_from_days: days is a count of days since 1970-01-01.
let z = days + 719_468; let z = days + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097; let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
@@ -628,8 +639,14 @@ mod tests {
}); });
let body = user_mass_info_body(squad, 29_859_876, 0, 33_068_179, "Real FUT", "RF", "2016"); let body = user_mass_info_body(squad, 29_859_876, 0, 33_068_179, "Real FUT", "RF", "2016");
// Flat top-level envelope. // Flat top-level envelope.
assert_eq!(body["pileSizeClientData"]["entries"][0], json!({"key": 2, "value": 100})); assert_eq!(
assert_eq!(body["pileSizeClientData"]["entries"][1], json!({"key": 4, "value": 50})); 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["settings"], json!({"configs": []}));
assert_eq!(body["userData"], json!({})); assert_eq!(body["userData"], json!({}));
// userInfo economy + club identity. // userInfo economy + club identity.
+20 -4
View File
@@ -90,14 +90,30 @@ pub fn generate_pack_contents(
} }
let all: Vec<&GeneratedCandidate> = pool.iter().collect(); let all: Vec<&GeneratedCandidate> = pool.iter().collect();
let gold: Vec<&GeneratedCandidate> = pool.iter().filter(|c| c.rating >= 75).collect(); let gold: Vec<&GeneratedCandidate> = pool.iter().filter(|c| c.rating >= 75).collect();
let silver: Vec<&GeneratedCandidate> = let silver: Vec<&GeneratedCandidate> = pool
pool.iter().filter(|c| (65..75).contains(&c.rating)).collect(); .iter()
.filter(|c| (65..75).contains(&c.rating))
.collect();
let bronze: Vec<&GeneratedCandidate> = pool.iter().filter(|c| c.rating < 65).collect(); let bronze: Vec<&GeneratedCandidate> = pool.iter().filter(|c| c.rating < 65).collect();
let mut out = Vec::with_capacity(pack.count() as usize); 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, &gold, &all, pack.n_gold, pack.special_chance, rng);
draw_tier(&mut out, &silver, &all, pack.n_silver, pack.special_chance, rng); draw_tier(
draw_tier(&mut out, &bronze, &all, pack.n_bronze, pack.special_chance, rng); &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 out
} }
+144 -36
View File
@@ -68,45 +68,153 @@ impl PackDef {
/// never purchasable/openable. /// never purchasable/openable.
pub const PACK_CATALOG: &[PackDef] = &[ pub const PACK_CATALOG: &[PackDef] = &[
// ── Bronze category ── // ── Bronze category ──
PackDef { id: 1, name: "Bronze Pack", price: 400, PackDef {
n_bronze: 10, n_silver: 2, n_gold: 0, rares: 1, category: "bronze", id: 1,
special_chance: 0.01, owned_only: false }, name: "Bronze Pack",
PackDef { id: 2, name: "Premium Bronze Pack", price: 750, price: 400,
n_bronze: 10, n_silver: 2, n_gold: 0, rares: 3, category: "bronze", n_bronze: 10,
special_chance: 0.02, owned_only: false }, 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 ── // ── Silver category ──
PackDef { id: 3, name: "Silver Pack", price: 2500, PackDef {
n_bronze: 1, n_silver: 11, n_gold: 0, rares: 1, category: "silver", id: 3,
special_chance: 0.015, owned_only: false }, name: "Silver Pack",
PackDef { id: 4, name: "Premium Silver Pack", price: 3750, price: 2500,
n_bronze: 1, n_silver: 11, n_gold: 0, rares: 3, category: "silver", n_bronze: 1,
special_chance: 0.03, owned_only: false }, 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 ── // ── Gold category ──
PackDef { id: 5, name: "Gold Pack", price: 5000, PackDef {
n_bronze: 0, n_silver: 2, n_gold: 10, rares: 1, category: "gold", id: 5,
special_chance: 0.04, owned_only: false }, name: "Gold Pack",
PackDef { id: 6, name: "Premium Gold Pack", price: 7500, price: 5000,
n_bronze: 0, n_silver: 2, n_gold: 10, rares: 3, category: "gold", n_bronze: 0,
special_chance: 0.06, owned_only: false }, 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) ── // ── Reward (owned-only; opened from My Packs, never coin-purchasable) ──
PackDef { id: 70, name: "Reward Gold Pack", price: 0, PackDef {
n_bronze: 0, n_silver: 0, n_gold: 11, rares: 11, category: "gold", id: 70,
special_chance: 1.0, owned_only: true }, name: "Reward Gold Pack",
PackDef { id: 71, name: "Bronze Pack", price: 0, price: 0,
n_bronze: 10, n_silver: 2, n_gold: 0, rares: 1, category: "bronze", n_bronze: 0,
special_chance: 0.01, owned_only: true }, n_silver: 0,
PackDef { id: 72, name: "Silver Pack", price: 0, n_gold: 11,
n_bronze: 1, n_silver: 11, n_gold: 0, rares: 1, category: "silver", rares: 11,
special_chance: 0.02, owned_only: true }, category: "gold",
PackDef { id: 73, name: "Gold Pack", price: 0, special_chance: 1.0,
n_bronze: 0, n_silver: 2, n_gold: 10, rares: 1, category: "gold", owned_only: true,
special_chance: 0.05, owned_only: true }, },
PackDef { id: 74, name: "Rare Gold Pack", price: 0, PackDef {
n_bronze: 0, n_silver: 2, n_gold: 10, rares: 3, category: "gold", id: 71,
special_chance: 0.10, owned_only: true }, name: "Bronze Pack",
PackDef { id: 75, name: "Icon Pack", price: 0, price: 0,
n_bronze: 0, n_silver: 0, n_gold: 12, rares: 12, category: "gold", n_bronze: 10,
special_chance: 1.0, owned_only: true }, 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). /// Look up a catalogue pack by id (the 65534 sentinel is never present).
+146
View File
@@ -0,0 +1,146 @@
#!/usr/bin/env python3
"""Authoritative Core state snapshot for FIFA17 FUT loop verification.
Queries openfut-core's authoritative read API directly (NOT the FIFA UI, NOT the
host projection, NOT Python) and emits a machine-readable JSON snapshot with the
integrity fields needed to detect, across any economy operation or a full stack
restart:
* coin drift (balance vs club.coins cross-check + absolute value)
* duplicate ownership (repeated owned_card_id)
* missing ownership (squad references an owned_card_id not in the collection)
* resurrection (owned-set hash changes when it must not)
* entitlement dup/count (unopened pack entitlements)
* stale squad mappings (squad player ids absent from ownership)
This is verification/oracle tooling, not production behavior. It reads only.
Usage:
core-snapshot.py [--core URL] [--game fifa17] [--squad-ns fifa17.squad]
[--out FILE] [--label NAME]
Env fallbacks: OPENFUT_CORE_URL, OPENFUT_GAME, OPENFUT_SQUAD_NS.
Exit code 0 always for a successful read; integrity problems are reported IN the
snapshot (`integrity.ok` / `integrity.problems`) so callers can diff/assert.
"""
import argparse
import hashlib
import json
import os
import sys
import urllib.error
import urllib.request
from datetime import datetime, timezone
def fetch(core, game, path):
req = urllib.request.Request(core + path, headers={"X-OpenFUT-Game": game})
try:
with urllib.request.urlopen(req, timeout=15) as f:
return f.status, f.read()
except urllib.error.HTTPError as e:
return e.code, e.read()
except Exception as e: # noqa: BLE001 - surface transport errors in the snapshot
return None, str(e).encode()
def fetch_json(core, game, path):
st, body = fetch(core, game, path)
if st != 200:
raise SystemExit(f"Core read {path} failed: status={st} body={body[:200]!r}")
return json.loads(body)
def sha(items):
h = hashlib.sha256()
for it in items:
h.update(str(it).encode())
h.update(b"\x00")
return h.hexdigest()
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--core", default=os.environ.get("OPENFUT_CORE_URL", "http://127.0.0.1:18101"))
ap.add_argument("--game", default=os.environ.get("OPENFUT_GAME", "fifa17"))
ap.add_argument("--squad-ns", default=os.environ.get("OPENFUT_SQUAD_NS", "fifa17.squad"))
ap.add_argument("--out")
ap.add_argument("--label", default="")
args = ap.parse_args()
balance = fetch_json(args.core, args.game, "/economy/balance")
entitlements = fetch_json(args.core, args.game, "/economy/entitlements")
profile = fetch_json(args.core, args.game, "/profile")
club = fetch_json(args.core, args.game, "/club")
collection = fetch_json(args.core, args.game, "/collection")["collection"]
squad = fetch_json(args.core, args.game, f"/squad/ext?namespace={args.squad_ns}")
coins_balance = balance.get("balance")
coins_club = club.get("coins")
owned_ids = sorted(x["owned_card_id"] for x in collection)
card_ids = sorted(x["card"]["id"] for x in collection)
dup_owned = sorted({i for i in owned_ids if owned_ids.count(i) > 1}) if len(owned_ids) != len(set(owned_ids)) else []
loans = [x["owned_card_id"] for x in collection if x.get("is_loan")]
# Squad player ownership references (from the opaque extension payload).
squad_player_ids = []
ext = squad.get("extension") or {}
payload_raw = ext.get("payload")
kit_numbers = {}
if payload_raw:
try:
payload = json.loads(payload_raw)
kit_numbers = payload.get("kit_numbers", {}) or {}
squad_player_ids = sorted(kit_numbers.keys())
except (json.JSONDecodeError, TypeError):
pass
owned_set = set(owned_ids)
squad_missing = sorted(pid for pid in squad_player_ids if pid not in owned_set)
problems = []
if coins_balance != coins_club:
problems.append(f"coin cross-check mismatch: balance={coins_balance} club={coins_club}")
if dup_owned:
problems.append(f"duplicate owned_card_id: {dup_owned[:10]} (+{max(0,len(dup_owned)-10)} more)")
if squad_missing:
problems.append(f"squad references non-owned ids: {squad_missing}")
snap = {
"label": args.label,
"captured_at": datetime.now(timezone.utc).isoformat(),
"core": args.core,
"game": args.game,
"coins": {"balance": coins_balance, "club": coins_club},
"profile": {"username": profile.get("username"), "level": profile.get("level"), "xp": profile.get("xp")},
"entitlements": {"count": len(entitlements), "ids": entitlements},
"collection": {
"owned": len(collection),
"distinct_owned_card_id": len(set(owned_ids)),
"distinct_card_id": len(set(card_ids)),
"loans": len(loans),
"duplicate_owned_card_id": dup_owned,
"owned_set_sha256": sha(owned_ids),
"card_multiset_sha256": sha(card_ids),
},
"squad": {
"namespace": args.squad_ns,
"verdict": squad.get("verdict"),
"player_count": len(squad_player_ids),
"player_ids": squad_player_ids,
"missing_from_ownership": squad_missing,
"ext_schema_version": ext.get("schema_version"),
},
"integrity": {"ok": not problems, "problems": problems},
}
text = json.dumps(snap, indent=2, sort_keys=True)
if args.out:
with open(args.out, "w") as f:
f.write(text + "\n")
print(text)
return 0
if __name__ == "__main__":
sys.exit(main())
+232
View File
@@ -0,0 +1,232 @@
#!/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())
+13 -1
View File
@@ -1,5 +1,12 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# OpenFUT Setup Script # 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
#
# Builds, configures, and starts the OpenFUT offline FUT emulator for FIFA 23. # 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. # Run as a regular user; the script will sudo only for hosts/cert/iptables steps.
@@ -407,6 +414,11 @@ EOF
} }
# ── Dispatch ─────────────────────────────────────────────────────────────────── # ── 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 case "${1:-help}" in
quickstart) cmd_quickstart ;; quickstart) cmd_quickstart ;;
build) cmd_build ;; build) cmd_build ;;