10 Commits

Author SHA1 Message Date
OpenFUT Dev 6b8b8e052f chore(fifa17-client): preserve .105 client tooling WIP; gitignore local .screens 2026-08-20 09:12:06 -07:00
funman300 3153a93edf fifa17-recon: drop superseded docker-side tools/data copies
fifa17-recon/tools (authoritative) and fifa17-recon/data now feed the Docker
build directly via the curated runtime-tools.list manifest. The duplicated
fifa17-python/tools+data are removed so the repo has a single source of truth;
the rebuilt openfut-fut-backend:dev image is byte-identical to the previous
deployment (verified: manifest diff empty, 446/446 contract checks pass).
2026-08-10 17:21:58 -07:00
funman300 f64106ed8b fifa17-recon: fix compose dockerfile path for relocated build context 2026-08-10 17:20:27 -07:00
funman300 9faaf12dd7 fifa17-recon: Docker build consumes authoritative tools via curated manifest
Build context moves from docker/fifa17-python/ up to fifa17-recon/ so the
Dockerfile reads the single-source tools/ and data/ trees. Only the 77 runtime
files listed in runtime-tools.list are installed into /app/tools (baseline image
minus the two git-ignored certs, regenerated in-image). memdump and recon
artifacts are excluded via fifa17-recon/.dockerignore.
2026-08-10 17:19:07 -07:00
funman300 83539e33ec fifa17-recon: take running-backend versions of 8 runtime files (direction fix)
The earlier reconcile committed the local working-tree versions of these
files, which are OLDER than the deployed backend. The running container (C)
is byte-identical to docker/fifa17-python/tools (B) and is a strict superset:
it adds profile_path_for/select_account/ensure_security_question (fut_store),
safe_header_for_log/safe_request_path/security_question_route (utas_server),
account_sync_route/_match_call/match_ready_body, plus POW balance fields and
match lifecycle support, with zero unique local functions lost.

Reconciled tree is now a strict superset of B with every shared file
byte-identical; verified via md5 map (0 missing, 0 differing).
2026-08-10 17:12:27 -07:00
funman300 695421cfd4 Merge remote-tracking branch 'origin/main' into fifa17-fut-squad-and-userinfo 2026-08-10 17:08:08 -07:00
funman300 8cba70dc90 fifa17-recon: reconcile authoritative tools with running backend (B)
- Add 8 files present in docker/fifa17-python/tools but missing from the
  top-level tree: fut_accounts.py + 7 test_*.py contracts (all committed in
  the server's docker tree; byte-identical to the running image).
- Preserve newer responder work already matching the running container:
  utas_server.py (offlineSeason), lsx_responder_v2.py (OPENFUT_BIND),
  blaze_responder_v3b.py, autopatch.py, pow_server.py, fut_store.py,
  test_fut_contract.py, fifa17-hook-m1.sh.
- Add 30 newer ghidra_queries (draft purchase/state, SBC 9-26, runtime
  registries). Local tree is now a strict superset of B with all shared
  files byte-identical.
2026-08-10 17:08:06 -07:00
root 28773e7cf1 fifa17-python: sync tools to running container state
The frozen baseline image predates two hot-patches made in the running
container after build:
* utas_server.py: FUT_MODES-gated offlineSeason block in GetHubData's club
  response (keeps the offline-season summary valid)
* test_hub_offline_season_contract.py added to /app/tools

Sync fifa17-python/tools to the running container (verified byte-identical,
237 files incl. the redir cert pair) and snapshot the live FS as
openfut-fut-backend:python-running-2026-08-10 (docker commit). A fresh build
from the committed sources now reproduces the running backend exactly
(baked SHA256SUMS.txt diffed against the container manifest: identical).
2026-08-10 23:56:58 +00:00
root 3ae5587a38 docs: baseline manifest equivalence note (pycache + cert deltas expected) 2026-08-10 23:54:59 +00:00
root 70a64e3709 fifa17-python: commit working FUT backend deployment (client/server split)
Freeze the running offline FUT backend into version control as
fifa17-recon/docker/fifa17-python/ - declarative and rebuildable from a
fresh checkout:

* OPENFUT_BIND / OPENFUT_ADVERTISE client/server split in the responders
  (lsx, blaze, roster, utas, pow) + entrypoint.sh; OPENFUT_ADVERTISE is
  required for remote mode (compose and entrypoint fail without it)
* docker-compose.yml reproducing the frozen baseline container exactly
  (env, ports incl. the 8085->8080 POW-content remap, /state bind, restart)
* .env.example / .env for site config - the LAN IP is never hardcoded in source
* tools/ + data/ staged from openfut-fut-backend:python-baseline-2026-08-10,
  verified byte-identical to the running container at freeze time
* client_arm.sh (the 105 client-side arming counterpart)
* Dockerfile bakes /app/SHA256SUMS.txt so any image is self-identifying
* docs/BASELINE-python-2026-08-10.md: frozen image/container/hash record,
  restore instructions and rebuild-equivalence procedure

Secrets (redir key/cert, .env) and runtime state (docker/state) stay gitignored.
The live container is untouched pending the .105 launcher audit.
2026-08-10 23:54:04 +00:00
59 changed files with 2900 additions and 183 deletions
+6
View File
@@ -27,3 +27,9 @@ __pycache__/
# OS # OS
.DS_Store .DS_Store
Thumbs.db Thumbs.db
# Frozen baseline archives / inspects / manifests
/docker-backups/
# local dev screenshots (not versioned)
fifa17-recon/.screens/
Generated
+5
View File
@@ -3156,6 +3156,10 @@ dependencies = [
"uuid", "uuid",
] ]
[[package]]
name = "openfut-common"
version = "0.1.0"
[[package]] [[package]]
name = "openfut-core" name = "openfut-core"
version = "0.1.0" version = "0.1.0"
@@ -3184,6 +3188,7 @@ dependencies = [
name = "openfut-hook" name = "openfut-hook"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"openfut-common",
"windows-sys 0.59.0", "windows-sys 0.59.0",
] ]
+45
View File
@@ -263,3 +263,48 @@ Both matter beyond themselves, because they are the only two routes into a match
Useful framing: this project's failures have almost always come from proposing a fix Useful framing: this project's failures have almost always come from proposing a fix
before testing the assumption under it. Hypotheses that come with a cheap way to before testing the assumption under it. Hypotheses that come with a cheap way to
disconfirm them are worth far more than plausible ones. disconfirm them are worth far more than plausible ones.
## FIFA 17 network-redirect milestone (2026-08-09)
Hook now installs a GENERIC network redirect on the fifa17 feature path (fifa17.rs
install_network_redirect): getaddrinfo IAT patch + inline connect detour + WSAConnect
IAT, with a configurable destination (connect_hook::set_target_ipv4) read from
openfut.cfg (single-line IP). Deployed DLL md5 bc9e0bc6, cfg=10.10.0.120.
RESULT of live launch (client 105 -> server 120):
- Error changed: "servers shut down" -> "Unable to connect to EA servers / check
network". Redirect IS firing (progress).
- BLOCKER A: getaddrinfo IAT patched 0+0 -> FIFA 17 does NOT resolve via IAT
getaddrinfo in the main exe or EAWebKit.dll. Names resolved via another path
(gethostbyname or internal DirtySDK resolver). So no hostname reached 120.
- BLOCKER B (architectural): FIFA 17 online = Blaze binary TCP on high ports. Log
shows connect 20.51.153.159:42230 sock_type=1 -> wsa_err=10035 (WOULDBLOCK->dead).
Port 42230 is NOT in the remap set (443,10041,42127,3216) so it was not redirected.
Even if redirected, the Docker bridge only speaks HTTPS on 8443 -- no Blaze
listener exists for FIFA 17. This is a server-side build, not a hook tweak.
NEXT (evidence-first): add gethostbyname (and possibly a DirtySDK resolver) capture
to learn the hostname behind 20.51.153.159; widen Blaze port remap; then scope a
Blaze-speaking bridge listener before expecting the error to clear.
## DNS/getaddrinfo fix — RESOLVED (2026-08-09, hook md5 67e3639b)
Added src/resolver_hook.rs: INLINE detours at ws2_32 export addresses for
getaddrinfo + GetAddrInfoW + gethostbyname (same unhook/rehook pattern as
connect_hook). Replaces the IAT approach that patched 0 slots on FIFA 17.
Wired into fifa17.rs install_network_redirect; hooks.rs gained redirect_ip_cstr()
and redirect_ip_str() helpers.
LIVE RESULT (client 105 -> server 120):
- resolver detours 3/3 installed.
- getaddrinfo(winter15.gosredirector.ea.com) -> redirect. Game now dials
10.10.0.120 (was 20.51.153.159 before). DNS BLOCKER A = SOLVED.
REMAINING BLOCKER B (architectural, NOT DNS): FIFA 17 online = EA Blaze binary
TCP. Game connects 10.10.0.120:42230 (gosredirector/Blaze redirector) ->
wsa_err=10035 (nothing listening). Two gaps: (1) connect_hook remap set lacks
42230; (2) even remapped, the Docker bridge only serves HTTPS on 8443 — no Blaze
listener exists. Clearing Unable to connect requires a Blaze redirector+main
server on the bridge side (real server build), not a hook change.
NOTE: the 3s TLS-handshake-EOF spam in bridge logs on :8443 is the LAUNCHER health
poller, not the game.
+16
View File
@@ -0,0 +1,16 @@
# Keep the authoritative-tree build context lean: only tools/ and data/ runtime
# files (plus the Dockerfile's own entrypoint/manifest) are needed in-image.
.git
.gitignore
artifacts
captures
futmem
staging
docs
FUT-RUNBOOK.md
README.md
data/memdump
**/__pycache__
*.pyc
*.pem
*.key
+1
View File
@@ -0,0 +1 @@
state/
@@ -0,0 +1,11 @@
# Copy to .env in this directory. Required for remote deployment.
#
# 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=10.10.0.120
# 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
@@ -0,0 +1,62 @@
# OpenFUT FIFA-17 FUT backend — Python migration deployment.
#
# Runs the 5 network responders (LSX / Blaze / roster / UTAS / POW) that FIFA 17
# dials to reach the FUT hub. Pure-Python; the only third-party dep is
# pycryptodome (LSX AES handshake). autopatch.py is intentionally NOT run here —
# it patches the game process memory and belongs on the client (105).
#
# Build context is fifa17-recon/ (the repo tree). tools/ is the AUTHORITATIVE
# recon tree (fifa17-recon/tools/). Only the runtime file set listed in
# docker/fifa17-python/runtime-tools.list is installed into /app/tools, so the
# deployed manifest stays byte-identical to the frozen baseline image
# openfut-fut-backend:python-baseline-2026-08-10 (see docs/BASELINE-*.md) while
# recon scripts, ghidra_queries and docs stay out of the image. data/ comes
# from the authoritative fifa17-recon/data. A SHA256SUMS.txt is baked into the
# image so any running backend can be matched to the exact dataset it was built
# from.
FROM python:3.12-slim
RUN pip install --no-cache-dir pycryptodome==3.20.0
WORKDIR /app
# Stage the authoritative tools tree in full...
COPY tools/ /app/tools-full/
# ...then install ONLY the runtime manifest (baseline image minus the two
# git-ignored certs, which are regenerated below).
COPY docker/fifa17-python/runtime-tools.list /app/runtime-tools.list
RUN set -eu; \
mkdir -p /app/tools; \
while IFS= read -r f; do \
[ -n "$f" ] || continue; \
mkdir -p "/app/tools/$(dirname "$f")"; \
cp "/app/tools-full/$f" "/app/tools/$f"; \
done < /app/runtime-tools.list; \
rm -rf /app/tools-full
COPY data/ /app/data/
# Redirector TLS cert (CN/SAN = winter15.gosredirector.ea.com). ProtoSSL
# cert-verify is patched client-side, so a self-signed cert is fine. The pair is
# git-ignored (*.pem/*.key); regenerate if absent so a fresh checkout builds
# without extra steps.
RUN if [ ! -s tools/redir_cert.pem ] || [ ! -s tools/redir_key.pem ]; then \
apt-get update && apt-get install -y --no-install-recommends openssl && \
openssl req -x509 -newkey rsa:2048 -nodes \
-keyout tools/redir_key.pem -out tools/redir_cert.pem \
-days 3650 -subj "/CN=winter15.gosredirector.ea.com" \
-addext "subjectAltName=DNS:winter15.gosredirector.ea.com,DNS:*.gosredirector.ea.com,DNS:*.ea.com" && \
rm -rf /var/lib/apt/lists/*; \
fi
# Bake a dataset manifest so every image is self-identifying.
RUN find /app/tools /app/data -type f | LC_ALL=C sort | xargs sha256sum > /app/SHA256SUMS.txt
COPY docker/fifa17-python/entrypoint.sh /app/entrypoint.sh
RUN chmod +x /app/entrypoint.sh
# LSX 4216 | Blaze redir/main/nucleus 42127/42130/42131 | roster 8081 | UTAS 8099 | POW 8094/8080
EXPOSE 4216 42127 42130 42131 8081 8099 8094 8080
ENTRYPOINT ["/app/entrypoint.sh"]
@@ -0,0 +1,102 @@
#!/usr/bin/env bash
# ============================================================================
# OpenFUT FIFA-17 — CLIENT-side arming (runs on the GAME machine, e.g. 105).
#
# Companion to the dev container on the SERVER (120). The server runs the heavy
# responders (Blaze / UTAS / roster / POW). Two pieces are inherently local to
# the game and therefore stay here:
#
# * autopatch.py — patches FIFA17.exe process memory (ProtoSSL cert-verify).
# Must run where the game runs; cannot be containerised.
# * lsx_responder — the Origin/EADesktop emulator the game dials on the
# hardcoded loopback 127.0.0.1:4216. Loopback IPC can't be
# cleanly redirected to a remote host, so it lives here.
#
# Everything the game reaches by a routable address is redirected to the server:
# * winter15.gosredirector.ea.com (hardcoded EA IP 159.153.51.20) -> SERVER:42127
# * easw.easports.com (dead hardcoded UTAS host) -> SERVER (:8099)
#
# The server's responders were started with OPENFUT_ADVERTISE=<SERVER_IP>, so
# after these first redirected contacts the game is handed <SERVER_IP> for every
# later hop (Blaze main, roster, UTAS, telemetry) and dials the server directly.
#
# Usage: sudo OPENFUT_SERVER=203.0.113.10 ./client_arm.sh
# (re-run after every reboot; the sysctl/iptables state is volatile)
# ============================================================================
set -euo pipefail
SERVER="${OPENFUT_SERVER:?set OPENFUT_SERVER to the backend host IP, e.g. 203.0.113.10}"
GOS_EA_IP="159.153.51.20" # winter15.gosredirector.ea.com (hardcoded in FIFA17)
UTAS_HOST="easw.easports.com" # dead UTAS host baked into CardsDLL
UTAS_RE="${UTAS_HOST//./\\.}" # same, safe to embed in a regex
if [ "$(id -u)" -ne 0 ]; then
echo "!! must run as root (sudo). Re-run: sudo OPENFUT_SERVER=$SERVER $0" >&2
exit 1
fi
echo "[client_arm] backend server = $SERVER"
# 1) allow /proc/PID/mem writes (autopatch's ProtoSSL cert-verify patch)
sysctl -q kernel.yama.ptrace_scope=0
# 2) Redirect the hardcoded Blaze redirector IP to the server's redirector.
# (Replace any stale rule first so re-runs and IP changes are clean.)
while iptables -t nat -D OUTPUT -p tcp -d "$GOS_EA_IP" -j DNAT \
--to-destination "$SERVER:42127" 2>/dev/null; do :; done
iptables -t nat -A OUTPUT -p tcp -d "$GOS_EA_IP" -j DNAT --to-destination "$SERVER:42127"
# 2b) DNAT from OUTPUT to a REMOTE host needs a matching source-NAT on the way
# out, or the server's replies (from its own IP) won't match the game's
# conntrack entry. MASQUERADE the redirected flow so it is SNAT'd to this
# host's outbound IP. (Harmless duplicate-guarded like the DNAT above.)
while iptables -t nat -D POSTROUTING -p tcp -d "$SERVER" --dport 42127 \
-j MASQUERADE 2>/dev/null; do :; done
iptables -t nat -A POSTROUTING -p tcp -d "$SERVER" --dport 42127 -j MASQUERADE
# 3) Point the dead hardcoded UTAS host at the server. The port (8099) is carried
# in the game's own URL, so only the name needs redirecting. Remove any prior
# OpenFUT-managed line (loopback or other server) and write the current one.
sed -i "/[[:space:]]${UTAS_RE}\b.*# openfut\$/d" /etc/hosts
printf '%s\t%s\t# openfut\n' "$SERVER" "$UTAS_HOST" >> /etc/hosts
echo "[client_arm] --- armed ---"
sysctl kernel.yama.ptrace_scope
iptables -t nat -L OUTPUT -n | grep -i "$GOS_EA_IP" || echo " (DNAT missing!)"
# Verify the hosts entry by EFFECT, not by presence.
#
# glibc returns the FIRST match in /etc/hosts, so our line can be written
# correctly and still lose to an earlier one -- and the sed above only removes
# lines this script wrote (`# openfut`), so re-running never clears a foreign
# one. The old check here was `grep easw /etc/hosts && echo ok`, which passed on
# the shadowing line itself and reported success while resolution was wrong.
#
# Observed on 2026-08-11: a leftover `127.0.0.1 easw.easports.com` from the
# single-machine era shadowed the OpenFUT line, and every re-run said "ok".
resolved="$(getent ahosts "$UTAS_HOST" 2>/dev/null | awk '{print $1}' | sort -u | tr '\n' ' ')"
# SERVER may be a hostname, so compare address-to-address rather than comparing
# the literal string against resolved IPs (which would warn spuriously).
server_ips="$(getent ahosts "$SERVER" 2>/dev/null | awk '{print $1}' | sort -u)"
[ -n "$server_ips" ] || server_ips="$SERVER"
match=0
for ip in $server_ips; do
printf '%s' "$resolved" | grep -qw -- "$ip" && match=1
done
if [ "$match" -eq 1 ]; then
echo " /etc/hosts ok ($UTAS_HOST -> $resolved)"
else
echo
echo " !! WARNING: $UTAS_HOST resolves to [$resolved], not $SERVER."
echo " An earlier /etc/hosts line is shadowing the OpenFUT one:"
grep -nE "^[[:space:]]*[^#].*[[:space:]]${UTAS_RE}([[:space:]]|\$)" /etc/hosts \
| grep -v '# openfut$' | sed 's/^/ /' || true
echo
echo " Not fatal: the responders advertise $SERVER, so the game stops using"
echo " this name after the first hop. Worth removing the line above anyway."
echo " Lines are listed rather than deleted -- this script will not remove"
echo " /etc/hosts entries it did not write."
fi
echo
echo "[client_arm] Next: start the LOCAL pieces (LSX + autopatch) with client_local.sh,"
echo " ensure the container is up on $SERVER, then launch FIFA 17."
@@ -0,0 +1,49 @@
# OpenFUT FIFA-17 FUT backend — declarative deployment (server side, runs on 120).
#
# cp .env.example .env # set OPENFUT_ADVERTISE to THIS host's LAN IP
# docker compose up -d --build
#
# Brings up the 5 responders the game dials. OPENFUT_ADVERTISE is the 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.
name: openfut-fut-backend
services:
fut-backend:
build:
context: ../..
dockerfile: docker/fifa17-python/Dockerfile
image: openfut-fut-backend:dev
container_name: openfut-fut-backend
restart: unless-stopped
environment:
# Bind all interfaces inside the container.
OPENFUT_BIND: "${OPENFUT_BIND:-0.0.0.0}"
# Address advertised to the client for the next hop. MUST be this host's
# LAN IP as seen from the game machine (105). Required (see .env.example).
OPENFUT_ADVERTISE: "${OPENFUT_ADVERTISE:?set OPENFUT_ADVERTISE in .env to this host's LAN IP, e.g. 10.10.0.120}"
# POW content advertises port 8080 by default, which collides with the
# openfut-core publish on this host. Remap it to 8085 on the host and
# advertise the remapped endpoint.
POW_CONTENT_ADDR: "0.0.0.0:8080"
POW_CONTENT_HOST: "${OPENFUT_ADVERTISE}:8085"
# Launcher-selected EA/Origin identity shared by LSX, Blaze, POW and UTAS.
# FUT saves are isolated by persona beneath /state/accounts.
FUT_ACCOUNT_PATH: "/state/active_account.json"
FUT_PROFILE_ROOT: "/state/accounts"
FUT_SETTINGS: "off"
FUT_MODES: "1"
volumes:
- "../state:/state"
ports:
- "4216:4216" # LSX (Origin bootstrap)
- "42127:42127" # Blaze redirector (TLS)
- "42130:42130" # Blaze main
- "42131:42131" # Nucleus OAuth stub
- "8081:8081" # FUT roster XML (HTTPS)
- "8099:8099" # UTAS / RS4 FUT REST API
- "8094:8094" # POW / EASFC API
- "8085:8080" # POW content (host 8085 -> container 8080; avoids core:8080)
@@ -0,0 +1,71 @@
#!/usr/bin/env bash
# ============================================================================
# OpenFUT FIFA-17 FUT backend — in-CONTAINER orchestrator.
#
# Runs the 5 network responders that the game dials. Unlike the host-based
# openfut-fut.sh, this does NO host arming (no pkexec / iptables / /etc/hosts /
# ptrace) — those are client-side concerns handled on the game machine (105).
# autopatch.py is NOT run here: it patches the FIFA17.exe process memory and must
# run on the box the game runs on.
#
# Address behaviour is driven by 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. 10.10.0.120)
# ============================================================================
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. 10.10.0.120)}"
export OPENFUT_BIND="$BIND"
export OPENFUT_ADVERTISE="$ADV"
# 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"
# name script extra-env
declare -a SERVERS=(
"lsx|lsx_responder_v2.py|OPENFUT_LSX_EVENT_COUNT=100000"
"blaze|blaze_responder_v3b.py|-"
"roster|roster_server.py|-"
"utas|utas_server.py|FUT_TRADING=1 FUT_PILESIZES=1 FUT_TRADEABLE=1 FUT_DISCARD_TABLE=1 FUT_DISCARD_SEND=1"
"pow|pow_server.py|-"
)
pids=()
names=()
for entry in "${SERVERS[@]}"; do
IFS='|' read -r name script env <<<"$entry"
envprefix=""; [ "$env" != "-" ] && envprefix="env $env"
echo "[openfut] starting $name ($script)"
# shellcheck disable=SC2086
$envprefix python3 -u "$script" &
pids+=($!)
names+=("$name")
done
# Propagate SIGTERM/SIGINT to children so `docker stop` is clean.
term() {
echo "[openfut] shutting down…"
for p in "${pids[@]}"; do kill "$p" 2>/dev/null || true; done
wait
exit 0
}
trap term TERM INT
# If ANY responder dies, take the whole container down so the failure is visible
# (they all bind ports the game needs — a partial stack is a broken stack).
while true; do
for i in "${!pids[@]}"; do
if ! kill -0 "${pids[$i]}" 2>/dev/null; then
echo "[openfut] responder ${names[$i]} (pid ${pids[$i]}) exited - bringing container down"
term
fi
done
sleep 2
done
@@ -0,0 +1,77 @@
origin_login_probe.py
card_proof.py
force_login_flag.py
card_record_poke.py
test_tournament_contract.py
dmp_stack.py
fut_clubitems.py
test_autopatch_logging.py
capture_lsx.py
roster_server.py
autopatch.py
dbschema_probe.py
test_account_profiles.py
coach_window.py
watch_club_model.py
db_dump.py
coach_probe.py
uidiff.py
probe_club_stats.py
blaze_responder_v3.py
dbdata_extract.py
decode_fire2.py
check_club_stat_vocab.py
fut_accounts.py
strip_dead_cards.py
test_hub_offline_season_contract.py
repair_club.py
forge_node.py
verify_preauth.py
fut_coaches.py
heat2.py
test_security_question.py
test_utas_log_redaction.py
sbc_populate_poke.py
atomdump.py
lsx_responder.py
fut_staff.py
fut_cards.py
blaze_responder.py
blaze_responder_v2.py
fut_store.py
blaze_responder_v3b.py
test_fut_contract.py
utas_server.py
lsx_force_online.py
grab_crash_code.py
gate_byte_probe.py
fut_admin.py
test_match_rewards.py
lsx_responder_v2.py
card_identity_probe.py
extract_player_ids.py
watch_online_mode.py
store_enable_poke.py
pow_server.py
fut_account.py
blaze_responder_v3_patched.py
check_settings_flags.py
test_match_lifecycle.py
sbc_hook_poke.py
futlog.py
fut_seed.py
hub_counter_probe.py
fut_consumables.py
db_catalog_walk.py
memtool.py
build_player_facts.py
sweep_collect.py
test_card_families.py
fut_club_stats.py
dmp.py
build_consumables.py
test_market_buy.py
dump_login_code.py
auth_watch.py
vgamepad.py
ghidra_env.py
@@ -0,0 +1,121 @@
# Python backend baseline — 2026-08-10
Frozen rollback target for the working offline FUT backend (Python migration) as
it ran on 10.10.0.120. Everything here was recorded from the live system before
any cleanup/restructure; the image and state are archived in
`/home/alex/OpenFUT/docker-backups/`.
## Frozen image
| field | value |
|------------|-------|
| tag | `openfut-fut-backend:python-baseline-2026-08-10` |
| image id | `e1f93ad647ab` |
| digest | `sha256:e1f93ad647abbec32e2751f3e88fed75d3e574d4500395b21c31d0f0b96abac6` |
| created | 2026-08-10T02:14:56Z (built as `openfut-fut-backend:dev`) |
| size | 278 MB |
| archive | `docker-backups/openfut-fut-backend-python-baseline-2026-08-10.tar.gz` (53 MB, `docker save \| gzip -1`) |
## Frozen container
| field | value |
|------------|-------|
| id | `f16d3204cbf48151be232ff8f4194b429e311042f8f6f95644760d4b8eba2938` |
| created | 2026-08-10T02:14:56.194470252Z |
| image | `openfut-fut-backend:dev` (= baseline image id) |
| restart | `unless-stopped` |
| network | `docker_default`, IP `172.19.0.2`, aliases `openfut-fut-backend`, `fut-backend` |
| log | json-file |
| inspect | `docker-backups/openfut-fut-backend-container-inspect-2026-08-10.json` |
### Environment (Config.Env)
```
FUT_SETTINGS=off
FUT_MODES=1
OPENFUT_BIND=0.0.0.0
OPENFUT_ADVERTISE=10.10.0.120
POW_CONTENT_ADDR=0.0.0.0:8080
POW_CONTENT_HOST=10.10.0.120:8085
FUT_ACCOUNT_PATH=/state/active_account.json
FUT_PROFILE_ROOT=/state/accounts
PYTHON_VERSION=3.12.13 (python:3.12-slim base)
```
### Volumes / mounts
Bind mount `docker/state` (host) -> `/state` (container, rw). Runtime state:
`active_account.json` (active persona) + `accounts/` (FUT saves by persona).
Snapshot: `docker-backups/state-2026-08-10/`.
### Ports (host -> container)
| host | container | service |
|------|-----------|---------|
| 4216 | 4216 | LSX (Origin bootstrap) |
| 42127 | 42127 | Blaze redirector (TLS) |
| 42130 | 42130 | Blaze main |
| 42131 | 42131 | Nucleus OAuth stub |
| 8081 | 8081 | FUT roster XML (HTTPS) |
| 8099 | 8099 | UTAS / RS4 FUT REST API |
| 8094 | 8094 | POW / EASFC API |
| 8085 | 8080 | POW content (remapped to avoid openfut-core:8080) |
All listeners verified bound on `0.0.0.0` in the container (LSX/Blaze/nucleus,
roster, UTAS, POW, POW content).
## Dataset manifest
`docker-backups/SHA256SUMS-container-baseline-2026-08-10.txt` — sha256 of all
323 files under `/app/tools` + `/app/data` inside the running container.
`fifa17-python/tools/` and `fifa17-python/data/` are the staged sources that
built this image (verified byte-identical to the container copies at freeze
time). Images rebuilt from git now bake their own `/app/SHA256SUMS.txt`; the
rebuild-equivalence check is `diff` between that and this manifest; the only expected deltas are pycache files (not committed) and the redir cert pair (regenerated per build).
## Restore
```sh
# From the archived image (works offline, exact layers):
docker load -i /home/alex/OpenFUT/docker-backups/openfut-fut-backend-python-baseline-2026-08-10.tar.gz
docker tag openfut-fut-backend:python-baseline-2026-08-10 openfut-fut-backend:dev
# Or rebuild from git:
cd /home/alex/OpenFUT/fifa17-recon/docker/fifa17-python
cp .env.example .env # set OPENFUT_ADVERTISE
docker compose up -d --build
```
## Status at freeze time
- The 2026-08-10 `openfut-fut-backend` container was **left running untouched**
(the .105 launcher audit uses it). No rebuild/replacement happens until that
audit finishes; the frozen image is the rollback target if cleanup breaks it.
- `docker/state` was **not** moved during restructure (bind path must not change
while the container is live); the new compose mounts `../state` from the same
location.
- TURN/relay re-addressing (multiplayer) and long-tail endpoints (weather,
matchday, kit assets) are deferred feature gaps — tracked separately.
## Running state vs image — what the frozen image does NOT contain
The baseline image (`python-baseline-2026-08-10` / `dev`) was built at 02:14Z,
but the container's `/app` was hot-patched afterwards:
* `tools/utas_server.py` — gained the `FUT_MODES`-gated `offlineSeason` block in
GetHubData's club response (keeps the hub's offline-season summary valid).
* `tools/test_hub_offline_season_contract.py` — added to `/app/tools`.
`docker save` captures the image, not the container's writable layer, so the
baseline tar.gz lacks those two changes. Two paths cover the exact runtime:
* `openfut-fut-backend:python-running-2026-08-10` — `docker commit` of the
running container (sha256:093a98fa0496...), the exact runtime FS.
* The committed `fifa17-python/tools` + `data` — synced to match the running
container byte-for-byte (237 files verified, incl. the redir cert pair), so a
fresh build reproduces the actual running backend. Proven by rebuilding from
the committed sources and diffing the baked `/app/SHA256SUMS.txt` against the
container manifest: identical.
Archive: `docker-backups/openfut-fut-backend-python-running-2026-08-10.tar.gz`.
Regular → Executable
+144 -33
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Watch for a (re)launched FIFA17.exe and auto-apply both ProtoSSL cert patches """Watch for a (re)launched FIFA17.exe and auto-apply both ProtoSSL cert patches
the moment its unpacked code is mapped. Idempotent; keeps watching across relaunches.""" the moment its unpacked code is mapped. Idempotent; keeps watching across relaunches."""
import glob, time, struct import glob, time, struct, sys
# Watch for a (re)launched FIFA17.exe and auto-apply ProtoSSL cert + FUT store patches # Watch for a (re)launched FIFA17.exe and auto-apply ProtoSSL cert + FUT store patches
import glob, time, os import glob, time, os
@@ -24,7 +24,46 @@ STORE_PATCHES = {
0x1800175aa: NOP2, 0x1800175aa: NOP2,
} }
LOG="/tmp/autopatch.log" # Store resolver crash-guard for the empty "My Packs" case (bug 6c; PROVEN R1 on the
# tested FIFA 17 build -- see docs/plans/FIFA17_EMPTY_MYPACKS_CLIENT_FIX.md PART IV and
# docs/evidence/FIFA17_EMPTY_MYPACKS_CLIENT_CONTRACT.md).
#
# When no `mypacks` group exists, FIFA's Store resolver receives category id -1. CardsDLL
# FUN_1800147f0 @ 0x180014858 is `JNZ 0x14869` (75 0f): the original treats every non-zero
# category (including -1) as resolvable, calls FUN_180014420, gets NULL, and crashes at the
# [NULL+0x48] deref in FUN_1800147f0 (0x180014882). Changing JNZ->JG (7f 0f) preserves
# positive-category resolution (EDI>0 branch) while routing zero/negative categories through
# the existing Browse/list-all path -> no NULL lookup, no crash, Store opens on Browse Packs.
#
# CAVEAT: this guards the category SIGN only. It does NOT protect a stale *positive* invalid
# ordinal produced by changing the Store group topology (sentinel-present <-> sentinel-absent)
# DURING one running FIFA process -- that reproduced the same crash in the confounded run F3.
# The empty-My-Packs representation MUST stay stable for a FIFA session (see the SESSION-STABLE
# invariant in the client-fix plan).
#
# Orig-verified / fail-closed: applied only when the live bytes are the known original (75 0f);
# already-patched (7f 0f) is a no-op; anything else is logged and SKIPPED (never blindly
# overwritten), so an unrecognised CardsDLL build is not patched.
STORE_PATCHES_GUARDED = {
0x180014858: (bytes.fromhex("750f"), bytes.fromhex("7f0f")), # JNZ 0x14869 -> JG 0x14869
}
# Capability advertised to the launcher/backend once the resolver guard is VERIFIED
# live in a specific FIFA process (docs/plans/FIFA17_PATCHED_CLIENT_CAPABILITY.md #3/#4).
EMPTY_MYPACKS_RESOLVER_CAPABILITY = "fifa17.empty_mypacks_resolver"
EMPTY_MYPACKS_RESOLVER_VERSION = 1
# The guarded site whose verified enforcement backs the capability above.
RESOLVER_GUARD_VA = 0x180014858
# Per-FIFA-pid guard status (fail-closed; FIFA17_PATCHED_CLIENT_CAPABILITY.md #4).
GUARD_NOT_ATTEMPTED = "NOT_ATTEMPTED" # CardsDLL not mapped / guard not yet evaluated
GUARD_VERIFIED = "VERIFIED" # live bytes == patch after enforcement (patch or noop)
GUARD_UNSUPPORTED_BUILD = "UNSUPPORTED_BUILD" # neither original nor patched (guarded_action -> skip)
GUARD_WRITE_FAILED = "WRITE_FAILED" # /proc/<pid>/mem write raised
GUARD_VERIFY_FAILED = "VERIFY_FAILED" # post-write re-read != patch
LOG=os.environ.get("OPENFUT_AUTOPATCH_LOG", f"/tmp/openfut-autopatch-{os.getuid()}.log")
def log(m): def log(m):
line=f"[{time.strftime('%H:%M:%S')}] {m}" line=f"[{time.strftime('%H:%M:%S')}] {m}"
@@ -52,40 +91,112 @@ def wr(pid,va,b):
with open(f'/proc/{pid}/mem','r+b') as f: with open(f'/proc/{pid}/mem','r+b') as f:
f.seek(va); f.write(b) f.seek(va); f.write(b)
def guarded_action(cur, orig, patch):
"""Fail-closed decision for a guarded byte patch (see STORE_PATCHES_GUARDED).
Returns "noop" when the live bytes are already patched, "patch" when they are the
known original (safe to apply), or "skip" for anything else -- an unrecognised
CardsDLL build that must never be blindly overwritten.
"""
if cur == patch:
return "noop"
if cur == orig:
return "patch"
return "skip"
def guard_state_after(cur_before, orig, patch, wrote_ok, cur_after):
"""Map a guarded-patch enforcement outcome to a per-pid guard STATE (pure).
Mirrors guarded_action's decision, extended with post-write verification so the
caller advertises the capability only on VERIFIED. No /proc access -- unit-testable.
- cur_before == patch -> VERIFIED (already patched; guarded_action "noop")
- cur_before == orig -> WRITE_FAILED if the write raised, else VERIFIED when the
re-read is patch, else VERIFY_FAILED (guarded_action "patch")
- otherwise -> UNSUPPORTED_BUILD (guarded_action "skip")
"""
if cur_before == patch:
return GUARD_VERIFIED
if cur_before == orig:
if not wrote_ok:
return GUARD_WRITE_FAILED
if cur_after == patch:
return GUARD_VERIFIED
return GUARD_VERIFY_FAILED
return GUARD_UNSUPPORTED_BUILD
patched=set() patched=set()
store_patched=set() store_patched=set()
guard_reported=set()
log("=== AUTOPATCH watching for FIFA17.exe ===") if __name__ == "__main__":
while True: launcher_pid = None
for pid in find_pids(): if "--launcher-pid" in sys.argv:
if pid not in patched: try: launcher_pid = int(sys.argv[sys.argv.index("--launcher-pid") + 1])
try: except (ValueError, IndexError): raise SystemExit("invalid --launcher-pid")
g2=rd(pid,GATE2,3); g1=rd(pid,GATE1,6)
except Exception: log("=== AUTOPATCH watching for FIFA17.exe ===")
continue # code not mapped yet while True:
if g2==GATE2_PATCH and g1==GATE1_PATCH: if launcher_pid and not os.path.exists(f"/proc/{launcher_pid}"):
log(f"pid {pid}: cert gates already patched"); patched.add(pid) log(f"launcher pid {launcher_pid} exited; stopping autopatch")
elif g2==GATE2_ORIG and g1==GATE1_ORIG: break
for pid in find_pids():
if pid not in patched:
try: try:
wr(pid,GATE2,GATE2_PATCH); wr(pid,GATE1,GATE1_PATCH) g2=rd(pid,GATE2,3); g1=rd(pid,GATE1,6)
log(f"pid {pid}: PATCHED cert gates") except Exception:
patched.add(pid) continue # code not mapped yet
if g2==GATE2_PATCH and g1==GATE1_PATCH:
log(f"pid {pid}: cert gates already patched"); patched.add(pid)
elif g2==GATE2_ORIG and g1==GATE1_ORIG:
try:
wr(pid,GATE2,GATE2_PATCH); wr(pid,GATE1,GATE1_PATCH)
log(f"pid {pid}: PATCHED cert gates")
patched.add(pid)
except Exception as e:
log(f"pid {pid}: cert patch write failed: {e}")
# Continuously enforce store patches every tick
cbase = cardsdll_base(pid)
if cbase is not None:
try:
for va, data in STORE_PATCHES.items():
live = cbase + (va - IMG_BASE)
if rd(pid, live, len(data)) != data:
wr(pid, live, data)
log(f"pid {pid}: ENFORCED store patch @ {live:#x}")
for va, (orig, patch) in STORE_PATCHES_GUARDED.items():
live = cbase + (va - IMG_BASE)
cur = rd(pid, live, len(patch))
action = guarded_action(cur, orig, patch)
wrote_ok = True
cur_after = cur
if action == "patch":
try:
wr(pid, live, patch)
log(f"pid {pid}: ENFORCED guarded store patch @ {live:#x} (JNZ->JG, empty My Packs)")
except Exception as e:
wrote_ok = False
log(f"pid {pid}: guarded patch write failed @ {live:#x}: {e}")
if wrote_ok:
try:
cur_after = rd(pid, live, len(patch))
except Exception:
cur_after = b""
elif action == "skip":
log(f"pid {pid}: SKIP guarded patch @ {live:#x}: unexpected {cur.hex()} (build mismatch)")
# action == "noop": already patched; nothing to write.
if va == RESOLVER_GUARD_VA and pid not in guard_reported:
state = guard_state_after(cur, orig, patch, wrote_ok, cur_after)
if state == GUARD_VERIFIED:
log(f"[store-guard] verified capability {EMPTY_MYPACKS_RESOLVER_CAPABILITY}={EMPTY_MYPACKS_RESOLVER_VERSION} fifa_pid={pid}")
else:
log(f"[store-guard] guard status={state} fifa_pid={pid} (no capability advertised)")
guard_reported.add(pid)
if pid not in store_patched:
log(f"pid {pid}: PATCHED store gates in CardsDLL @ {cbase:#x}")
store_patched.add(pid)
except Exception as e: except Exception as e:
log(f"pid {pid}: cert patch write failed: {e}") log(f"pid {pid}: store patch write failed: {e}")
# Continuously enforce store patches every tick time.sleep(1)
cbase = cardsdll_base(pid)
if cbase is not None:
try:
for va, data in STORE_PATCHES.items():
live = cbase + (va - IMG_BASE)
if rd(pid, live, len(data)) != data:
wr(pid, live, data)
log(f"pid {pid}: ENFORCED store patch @ {live:#x}")
if pid not in store_patched:
log(f"pid {pid}: PATCHED store gates in CardsDLL @ {cbase:#x}")
store_patched.add(pid)
except Exception as e:
log(f"pid {pid}: store patch write failed: {e}")
time.sleep(1)
+104 -36
View File
@@ -128,14 +128,52 @@ CLIENT_ID = ACCOUNT.CLIENT_ID
PLATFORM = ACCOUNT.PLATFORM PLATFORM = ACCOUNT.PLATFORM
SERVER_VERSION = "Blaze 15.1.1.3.0 (OpenFUT)\n" SERVER_VERSION = "Blaze 15.1.1.3.0 (OpenFUT)\n"
# ================================================================== config
HOST = "127.0.0.1" def refresh_account_identity():
"""Refresh launcher-selected identity before constructing a Blaze session.
The account sync endpoint runs in the separate UTAS process and atomically
replaces the shared active-account file. Blaze snapshots these aliases for
its response builders, so refresh them once at each new TCP session.
"""
global PERSONA_ID, PERSONA_NAME, USER_ID, EXT_ID, EMAIL, ACCOUNT_LOCALE_FALLBACK
ACCOUNT.load(force=True)
PERSONA_ID = ACCOUNT.persona_id
PERSONA_NAME = ACCOUNT.persona_name
USER_ID = ACCOUNT.user_id
EXT_ID = ACCOUNT.ext_id
EMAIL = ACCOUNT.email
ACCOUNT_LOCALE_FALLBACK = ACCOUNT.account_locale_int
# ================================================================== config
#
# Client/server split support (OpenFUT dev-container): 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")
def _ip_str_to_u32(ip):
"""Dotted-quad -> big-endian u32 (matches the original (127<<24)|1 layout).
Falls back to loopback if the advertise value isn't a bare IPv4 literal."""
try:
a, b, c, d = (int(x) for x in ip.split("."))
return (a << 24) | (b << 16) | (c << 8) | d
except Exception:
return (127 << 24) | 1
HOST = _BIND
REDIR_PORT = 42127 REDIR_PORT = 42127
BLAZE_PORT = 42130 BLAZE_PORT = 42130
NUCLEUS_PORT = 42131 NUCLEUS_PORT = 42131
BLAZE_IP_STR = "127.0.0.1" BLAZE_IP_STR = _ADVERTISE
BLAZE_IP_U32 = (127 << 24) | 1 BLAZE_IP_U32 = _ip_str_to_u32(_ADVERTISE)
LOG = "/tmp/blaze_responder.log" LOG = "/tmp/blaze_responder.log"
RXDIR = "/tmp/blaze_rx" RXDIR = "/tmp/blaze_rx"
HERE = os.path.dirname(os.path.abspath(__file__)) HERE = os.path.dirname(os.path.abspath(__file__))
@@ -157,7 +195,9 @@ REPLY_EMPTY_TO_UNKNOWN = True
# (grid-blaze order) or after (pamplona order). Both are reported to work. # (grid-blaze order) or after (pamplona order). Both are reported to work.
NOTIFY_BEFORE_LOGIN_REPLY = False NOTIFY_BEFORE_LOGIN_REPLY = False
DUMP_FRAMES = True # Raw Fire2 frames and decoded TDF can contain auth/session material. Keep the
# reverse-engineering capture path, but require an explicit opt-in for it.
DUMP_FRAMES = os.environ.get("OPENFUT_BLAZE_DUMP_FRAMES") == "1"
_log_lock = threading.Lock() _log_lock = threading.Lock()
@@ -525,7 +565,8 @@ OSDK_TICKER = []
# branch does NOT wrap the value ("https://%s" is only the ini path) -> ABSOLUTE url. # branch does NOT wrap the value ("https://%s" is only the ini path) -> ABSOLUTE url.
# Serve HTTPS (EA's production value is https; the DirtySDK download mgr may reject # Serve HTTPS (EA's production value is https; the DirtySDK download mgr may reject
# http). Our ProtoSSL cert-verify is patched (autopatch), so a self-signed cert is OK. # http). Our ProtoSSL cert-verify is patched (autopatch), so a self-signed cert is OK.
ROSTER_HOST = "127.0.0.1:8081" ROSTER_HOST = "%s:8081" % _ADVERTISE
POW_CONTENT_HOST = os.environ.get("POW_CONTENT_HOST", "127.0.0.1:8080")
OSDK_ROSTER = [ OSDK_ROSTER = [
("ROSTERUPDATE_URL", "https://%s/fifa17/fut/rosterupdate.xml" % ROSTER_HOST), ("ROSTERUPDATE_URL", "https://%s/fifa17/fut/rosterupdate.xml" % ROSTER_HOST),
("ROSTER_URL", "https://%s/fifa17/roster/" % ROSTER_HOST), # @0x143973aa0 ("ROSTER_URL", "https://%s/fifa17/roster/" % ROSTER_HOST), # @0x143973aa0
@@ -562,7 +603,6 @@ IDENTITY_PARAMS = [
# FUT_POW=1 ./openfut-fut.sh restart # FUT_POW=1 ./openfut-fut.sh restart
# and read /tmp/pow_server.log. FUT_POW=off is the instant fallback. # and read /tmp/pow_server.log. FUT_POW=off is the instant fallback.
POW_HOST = os.environ.get("POW_HOST", "127.0.0.1:8094") POW_HOST = os.environ.get("POW_HOST", "127.0.0.1:8094")
POW_CONTENT_HOST = os.environ.get("POW_CONTENT_HOST", "127.0.0.1:8080")
_POW_ON = os.environ.get("FUT_POW", "").lower() in ("1", "true", "on", "yes") _POW_ON = os.environ.get("FUT_POW", "").lower() in ("1", "true", "on", "yes")
OSDK_POW = [ OSDK_POW = [
("FIFA_POW_URL", "http://%s/" % POW_HOST), ("FIFA_POW_URL", "http://%s/" % POW_HOST),
@@ -571,6 +611,14 @@ OSDK_POW = [
("POW_IS_ON", "1"), ("POW_IS_ON", "1"),
] if _POW_ON else [] ] if _POW_ON else []
# CardsDLL's shared web-file downloader also reads this key for FUT-owned content.
# In particular, opening SBC downloads /fut/packs/loc/storepackdescriptions.<locale>.xml
# after /sbs/sets succeeds. Keep the content base available even while the unrelated
# POW API remains opt-in through FUT_POW/POW_IS_ON.
FUT_CONTENT_CONFIG = [
("FIFA_POW_CONTENT_SERVER_URL", "http://%s" % POW_CONTENT_HOST),
]
CLIENT_CONFIGS = { CLIENT_CONFIGS = {
"BlazeSDK": None, # built dynamically, see below "BlazeSDK": None, # built dynamically, see below
"netres": OSDK_NETRES, # CFID (verified @0x143962be0) "netres": OSDK_NETRES, # CFID (verified @0x143962be0)
@@ -595,7 +643,7 @@ CLIENT_CONFIGS = {
# /etc/hosts easw.easports.com->127.0.0.1 redirect. MUST be exactly "http://127.0.0.1:8099/" # /etc/hosts easw.easports.com->127.0.0.1 redirect. MUST be exactly "http://127.0.0.1:8099/"
# (scheme + trailing slash mandatory on the auth path). Do NOT serve FUT_TARGET_PORT # (scheme + trailing slash mandatory on the auth path). Do NOT serve FUT_TARGET_PORT
# (bug @0x1801808e8 reads FUT_MAX_HOPS instead) nor FUT/MODULE_BASEURL_* (dead code). # (bug @0x1801808e8 reads FUT_MAX_HOPS instead) nor FUT/MODULE_BASEURL_* (dead code).
UTAS_BASE = "http://127.0.0.1:8099/" UTAS_BASE = "http://%s:8099/" % _ADVERTISE
FUT_RS4_MODULES = [ FUT_RS4_MODULES = [
"AUCTIONHOUSE", "CLUB_USER", "CLUB_INFO", "CLUB", "DREAM", "SQUAD", "AUCTIONHOUSE", "CLUB_USER", "CLUB_INFO", "CLUB", "DREAM", "SQUAD",
"DELETE_SQUAD", "LBOPTIONS", "LBDEFAULT", "PAFPRACTICE", "UT", "USER", "DELETE_SQUAD", "LBOPTIONS", "LBDEFAULT", "PAFPRACTICE", "UT", "USER",
@@ -731,18 +779,21 @@ FUT_RS4_CONFIG = (
def client_config_for(cfid: str) -> list: def client_config_for(cfid: str) -> list:
"""-> sorted [(key, value)]. Unknown CFID -> [] (an EMPTY MAP, which we """Return sorted config rows for one section.
still wrap in a present CONF field -- never an empty frame).
FUT_RS4_* base-URL keys ride on EVERY CFID (merged '_all' store; which section Unknown CFIDs still receive the shared FUT/content/POW rows because those
CardsDLL reads is unproven, so serve them everywhere).""" consumers read the merged ``_all`` store and the contributing section is
unproven. The response always carries a present CONF field.
"""
# OSDK_POW rides on EVERY CFID for the same reason FUT_RS4_* does: powdll's # OSDK_POW rides on EVERY CFID for the same reason FUT_RS4_* does: powdll's
# FUN_18005a460 reads FIFA_POW_URL out of the merged '_all' store, and which # FUN_18005a460 reads FIFA_POW_URL out of the merged '_all' store, and which
# section it happens to read is unproven. Empty list when FUT_POW is unset, so # section it happens to read is unproven. Empty list when FUT_POW is unset, so
# this is a no-op by default. (Putting the keys ONLY under a hypothetical # this is a no-op by default. (Putting the keys ONLY under a hypothetical
# "OSDK_POW" CFID would be dead code -- nothing is known to request that name.) # "OSDK_POW" CFID would be dead code -- nothing is known to request that name.)
if cfid == "BlazeSDK": if cfid == "BlazeSDK":
return sorted(blazesdk_config() + FUT_RS4_CONFIG + OSDK_POW) return sorted(blazesdk_config() + FUT_RS4_CONFIG + FUT_CONTENT_CONFIG + OSDK_POW)
return sorted((CLIENT_CONFIGS.get(cfid) or []) + FUT_RS4_CONFIG + OSDK_POW) return sorted((CLIENT_CONFIGS.get(cfid) or []) + FUT_RS4_CONFIG
+ FUT_CONTENT_CONFIG + OSDK_POW)
def fetch_config_response_fields(cfid: str) -> "OrderedDict": def fetch_config_response_fields(cfid: str) -> "OrderedDict":
@@ -765,7 +816,7 @@ def qos_config() -> "OrderedDict":
has NO SVID, unlike Mirror's Edge Catalyst).""" has NO SVID, unlike Mirror's Edge Catalyst)."""
return OrderedDict([ return OrderedDict([
("BWPS", (STRUCT, OrderedDict([ # Blaze::QosPingSiteInfo ("BWPS", (STRUCT, OrderedDict([ # Blaze::QosPingSiteInfo
("PSA", (STRING, "127.0.0.1")), ("PSA", (STRING, _ADVERTISE)),
("PSP", (INT, 17502)), ("PSP", (INT, 17502)),
]))), ]))),
("LNP", (INT, 10)), ("LNP", (INT, 10)),
@@ -1091,7 +1142,7 @@ def post_auth_response_fields(sess: Session) -> "OrderedDict":
client to have a well-formed config and then fail to connect quietly rather client to have a well-formed config and then fail to connect quietly rather
than resolve a real EA hostname.""" than resolve a real EA hostname."""
tele = OrderedDict([ # GetTelemetryServerResponse (15) tele = OrderedDict([ # GetTelemetryServerResponse (15)
("ADRS", (STRING, "127.0.0.1")), ("ADRS", (STRING, _ADVERTISE)),
("ANON", (INT, 0)), ("ANON", (INT, 0)),
("DISA", (STRING, "")), ("DISA", (STRING, "")),
("EDCT", (INT, 0)), ("EDCT", (INT, 0)),
@@ -1108,7 +1159,7 @@ def post_auth_response_fields(sess: Session) -> "OrderedDict":
("SVNM", (STRING, "telemetry-openfut")), ("SVNM", (STRING, "telemetry-openfut")),
]) ])
tick = OrderedDict([ # GetTickerServerResponse (3) tick = OrderedDict([ # GetTickerServerResponse (3)
("ADRS", (STRING, "127.0.0.1")), ("ADRS", (STRING, _ADVERTISE)),
("PORT", (INT, 8999)), ("PORT", (INT, 8999)),
("SKEY", (STRING, "")), ("SKEY", (STRING, "")),
]) ])
@@ -1252,8 +1303,10 @@ def dispatch(hdr: dict, fields, raw_payload: bytes, sess: Session) -> list:
log(" -- client locale 0x%08x captured for ALOC" % loc) log(" -- client locale 0x%08x captured for ALOC" % loc)
resp = preauth_response_fields(service_name=sess.service_name) resp = preauth_response_fields(service_name=sess.service_name)
payload = encode_tdf(resp) payload = encode_tdf(resp)
log(" -> PreAuthResponse (INST=%r, %d payload bytes):\n%s" log(" -> PreAuthResponse (INST=%r, %d payload bytes)"
% (sess.service_name, len(payload), heat2.dump(resp))) % (sess.service_name, len(payload)))
if DUMP_FRAMES:
log(" -> PreAuthResponse TDF:\n%s" % heat2.dump(resp))
return [reply_to(hdr, payload)] return [reply_to(hdr, payload)]
if cmd == CMD_PING: if cmd == CMD_PING:
@@ -1267,8 +1320,9 @@ def dispatch(hdr: dict, fields, raw_payload: bytes, sess: Session) -> list:
n = len(resp["CONF"][1][2]) n = len(resp["CONF"][1][2])
log(" -> FetchConfigResponse CFID=%r -> %d key(s)%s" log(" -> FetchConfigResponse CFID=%r -> %d key(s)%s"
% (cfid, n, "" if n else " (EMPTY MAP, unknown CFID)")) % (cfid, n, "" if n else " (EMPTY MAP, unknown CFID)"))
for k, v in resp["CONF"][1][2]: if DUMP_FRAMES:
log(" %-32s = %s" % (k, v)) for k, v in resp["CONF"][1][2]:
log(" %-32s = %s" % (k, v))
return [reply_to(hdr, encode_tdf(resp))] return [reply_to(hdr, encode_tdf(resp))]
if cmd == CMD_POSTAUTH: if cmd == CMD_POSTAUTH:
@@ -1302,12 +1356,13 @@ def dispatch(hdr: dict, fields, raw_payload: bytes, sess: Session) -> list:
sess.auth_code = get_str(fields or {}, "AUTH", "") sess.auth_code = get_str(fields or {}, "AUTH", "")
sess.logged_in = True sess.logged_in = True
sess.login_time = int(time.time()) sess.login_time = int(time.time())
log(" == Authentication::login AUTH=%r (accepted WITHOUT Nucleus " log(" == Authentication::login AUTH=[REDACTED] "
"validation -- forged offline session)" % sess.auth_code) "(accepted as an offline OpenFUT session)")
resp = login_response_fields(sess) resp = login_response_fields(sess)
payload = encode_tdf(resp) payload = encode_tdf(resp)
log(" -> LoginResponse (%d bytes):\n%s" log(" -> LoginResponse (%d bytes)" % len(payload))
% (len(payload), heat2.dump(resp))) if DUMP_FRAMES:
log(" -> LoginResponse TDF:\n%s" % heat2.dump(resp))
notifs = build_login_notifications(sess, sess.login_time) notifs = build_login_notifications(sess, sess.login_time)
out = [] out = []
if NOTIFY_BEFORE_LOGIN_REPLY: if NOTIFY_BEFORE_LOGIN_REPLY:
@@ -1458,9 +1513,10 @@ _frame_counter = [0]
def blaze_handle(raw: socket.socket, addr) -> None: def blaze_handle(raw: socket.socket, addr) -> None:
refresh_account_identity()
log("*** BLAZE CONNECT from %s ***" % (addr,)) log("*** BLAZE CONNECT from %s ***" % (addr,))
sess = Session() sess = Session()
log(" session key minted: %s" % sess.session_key) log(" session key minted: [REDACTED]")
buf = bytearray() buf = bytearray()
raw.settimeout(300) raw.settimeout(300)
try: try:
@@ -1491,10 +1547,10 @@ def blaze_handle(raw: socket.socket, addr) -> None:
MSGTYPE_NAME.get(hdr["msg_type"], hdr["msg_type"]), MSGTYPE_NAME.get(hdr["msg_type"], hdr["msg_type"]),
hdr["msg_num"], hdr["user_index"], hdr["options"], hdr["msg_num"], hdr["user_index"], hdr["options"],
hdr["metadata_len"], hdr["payload_len"])) hdr["metadata_len"], hdr["payload_len"]))
log("RX #%d HEX:\n%s" % (n, hexdump(frame)))
if metadata:
log("RX #%d METADATA:\n%s" % (n, hexdump(metadata)))
if DUMP_FRAMES: if DUMP_FRAMES:
log("RX #%d HEX:\n%s" % (n, hexdump(frame)))
if metadata:
log("RX #%d METADATA:\n%s" % (n, hexdump(metadata)))
try: try:
os.makedirs(RXDIR, exist_ok=True) os.makedirs(RXDIR, exist_ok=True)
fn = os.path.join(RXDIR, "rx_%04d_%04x_%04x.bin" fn = os.path.join(RXDIR, "rx_%04d_%04x_%04x.bin"
@@ -1509,7 +1565,8 @@ def blaze_handle(raw: socket.socket, addr) -> None:
if payload: if payload:
try: try:
fields = decode_tdf(payload) fields = decode_tdf(payload)
log("RX #%d TDF:\n%s" % (n, heat2.dump(fields))) if DUMP_FRAMES:
log("RX #%d TDF:\n%s" % (n, heat2.dump(fields)))
except Exception as e: except Exception as e:
log("RX #%d TDF DECODE FAILED: %s" % (n, e)) log("RX #%d TDF DECODE FAILED: %s" % (n, e))
else: else:
@@ -1530,7 +1587,8 @@ def blaze_handle(raw: socket.socket, addr) -> None:
ohdr["msg_type"]), ohdr["msg_type"]),
MSGTYPE_NAME.get(ohdr["msg_type"], ohdr["msg_type"]), MSGTYPE_NAME.get(ohdr["msg_type"], ohdr["msg_type"]),
ohdr["msg_num"], len(out), ohdr["payload_len"])) ohdr["msg_num"], len(out), ohdr["payload_len"]))
log("TX #%d.%d HEX:\n%s" % (n, k, hexdump(out, limit=1024))) if DUMP_FRAMES:
log("TX #%d.%d HEX:\n%s" % (n, k, hexdump(out, limit=1024)))
except ConnectionResetError: except ConnectionResetError:
log("BLAZE %s: connection reset by client" % (addr,)) log("BLAZE %s: connection reset by client" % (addr,))
except Exception as e: except Exception as e:
@@ -1628,6 +1686,10 @@ def redir_handle(raw: socket.socket, addr) -> None:
# client can never reach accounts.ea.com. Note the exact spacing in the JSON: # client can never reach accounts.ea.com. Note the exact spacing in the JSON:
# the client searches for the literal '"access_token" : "'. # the client searches for the literal '"access_token" : "'.
def nucleus_sent_log(addr, size):
return "NUCLEUS SENT %s %dB access_token=[REDACTED]" % (addr, size)
def nucleus_handle(raw: socket.socket, addr) -> None: def nucleus_handle(raw: socket.socket, addr) -> None:
try: try:
raw.settimeout(10) raw.settimeout(10)
@@ -1640,9 +1702,9 @@ def nucleus_handle(raw: socket.socket, addr) -> None:
head, _, rest = req.partition(b"\r\n\r\n") head, _, rest = req.partition(b"\r\n\r\n")
line0 = head.split(b"\r\n", 1)[0].decode(errors="replace") if head else "" line0 = head.split(b"\r\n", 1)[0].decode(errors="replace") if head else ""
log("NUCLEUS REQ %s: %s" % (addr, line0)) log("NUCLEUS REQ %s: %s" % (addr, line0))
if head: if head and DUMP_FRAMES:
log("NUCLEUS HEADERS:\n%s" % head.decode(errors="replace")) log("NUCLEUS HEADERS:\n%s" % head.decode(errors="replace"))
if rest: if rest and DUMP_FRAMES:
log("NUCLEUS BODY: %r" % rest[:512]) log("NUCLEUS BODY: %r" % rest[:512])
token = "OPENFUT_" + "".join( token = "OPENFUT_" + "".join(
@@ -1656,7 +1718,7 @@ def nucleus_handle(raw: socket.socket, addr) -> None:
b"Cache-Control: no-store\r\nContent-Length: " b"Cache-Control: no-store\r\nContent-Length: "
+ str(len(body)).encode() + b"\r\nConnection: close\r\n\r\n" + body) + str(len(body)).encode() + b"\r\nConnection: close\r\n\r\n" + body)
raw.sendall(out) raw.sendall(out)
log("NUCLEUS SENT %s %dB access_token=%s" % (addr, len(out), token)) log(nucleus_sent_log(addr, len(out)))
except Exception as e: except Exception as e:
log("NUCLEUS ERR %s: %s" % (addr, e)) log("NUCLEUS ERR %s: %s" % (addr, e))
finally: finally:
@@ -1708,6 +1770,10 @@ def _selftest() -> None:
sess.account_locale = 0x656E5553 sess.account_locale = 0x656E5553
now = 1469000000 now = 1469000000
nucleus_summary = nucleus_sent_log(("127.0.0.1", 1234), 380)
assert "[REDACTED]" in nucleus_summary
assert "OPENFUT_selftest_secret" not in nucleus_summary
# ---- 1. preAuth still round-trips (regression guard vs v2) # ---- 1. preAuth still round-trips (regression guard vs v2)
pre = preauth_response_fields() pre = preauth_response_fields()
p = _check_roundtrip("PreAuthResponse", pre) p = _check_roundtrip("PreAuthResponse", pre)
@@ -1731,9 +1797,11 @@ def _selftest() -> None:
assert items == client_config_for(cfid), cfid assert items == client_config_for(cfid), cfid
print("[ok] fetchClientConfig %-26s %2d keys, %4d payload bytes" print("[ok] fetchClientConfig %-26s %2d keys, %4d payload bytes"
% (cfid, len(items), len(pb))) % (cfid, len(items), len(pb)))
assert client_config_for("TOTALLY_UNKNOWN") == [], "unknown CFID must be []" shared = sorted(FUT_RS4_CONFIG + FUT_CONTENT_CONFIG + OSDK_POW)
assert client_config_for("TOTALLY_UNKNOWN") == shared, \
"unknown CFID must carry only the shared merged-store rows"
assert len(fetch_config_response_fields("TOTALLY_UNKNOWN")) == 1, \ assert len(fetch_config_response_fields("TOTALLY_UNKNOWN")) == 1, \
"unknown CFID must still carry a CONF field (empty map, not empty frame)" "unknown CFID must still carry a CONF field"
# ---- 3. LoginResponse # ---- 3. LoginResponse
lr = login_response_fields(sess) lr = login_response_fields(sess)
+47 -6
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")"
@@ -158,6 +188,7 @@ launch() {
local trace_enabled=0 local trace_enabled=0
local request_trace_enabled=0 local request_trace_enabled=0
local notifier_trace_enabled=0 local notifier_trace_enabled=0
local dispatch_enabled=0
case "$mode" in case "$mode" in
baseline) baseline)
[[ "${OPENFUT_FIFA17_LAUNCH:-}" == "I_ACCEPT_M1_BASELINE_LAUNCH" ]] || [[ "${OPENFUT_FIFA17_LAUNCH:-}" == "I_ACCEPT_M1_BASELINE_LAUNCH" ]] ||
@@ -176,6 +207,12 @@ launch() {
request_trace_enabled=1 request_trace_enabled=1
notifier_trace_enabled=1 notifier_trace_enabled=1
;; ;;
dispatch)
[[ "${OPENFUT_FIFA17_DISPATCH:-}" == "I_ACCEPT_GUARDED_NATIVE_DISPATCH" ]] ||
die "launch-dispatch requires OPENFUT_FIFA17_DISPATCH=I_ACCEPT_GUARDED_NATIVE_DISPATCH"
request_trace_enabled=1
dispatch_enabled=1
;;
*) die "unknown launch mode: $mode" ;; *) die "unknown launch mode: $mode" ;;
esac esac
need_file "$deployed_dll" need_file "$deployed_dll"
@@ -192,12 +229,12 @@ launch() {
[[ "$(sha256 "$deployed_dll")" == "$recorded" ]] || [[ "$(sha256 "$deployed_dll")" == "$recorded" ]] ||
die "deployed version.dll does not match the staged M1 artifact" die "deployed version.dll does not match the staged M1 artifact"
command -v umu-run >/dev/null || die "umu-run is required" command -v umu-run >/dev/null || die "umu-run is required"
for name in OPENFUT_SBC_DISPATCH OPENFUT_SBC_COMMIT OPENFUT_SBC_ARM_ONLY OPENFUT_SBC_POPULATE; do for name in OPENFUT_SBC_DISPATCH OPENFUT_SBC_ARM_ONLY OPENFUT_SBC_POPULATE; do
[[ -z "${!name:-}" || "${!name}" == "0" ]] || die "$name must be unset or 0 for this launch" [[ -z "${!name:-}" || "${!name}" == "0" ]] || die "$name must be unset or 0 for this launch"
done done
mkdir -p "${wine_prefix}/dosdevices" mkdir -p "${wine_prefix}/dosdevices"
ln -sfn /mnt "${wine_prefix}/dosdevices/w:" ln -sfn /mnt "${wine_prefix}/dosdevices/w:"
note "Launching $mode mode (SBC_HOOK=$hook_enabled; SBC_TRACE=$trace_enabled; SBC_REQUEST_TRACE=$request_trace_enabled; SBC_NOTIFIER_TRACE=$notifier_trace_enabled; every mutation feature disabled); log=/tmp/fifa17-hook-m1-launch.log" note "Launching $mode mode (SBC_HOOK=$hook_enabled; SBC_TRACE=$trace_enabled; SBC_REQUEST_TRACE=$request_trace_enabled; SBC_NOTIFIER_TRACE=$notifier_trace_enabled; SBC_DISPATCH=$dispatch_enabled); log=/tmp/fifa17-hook-m1-launch.log"
cd "$game_dir" cd "$game_dir"
env \ env \
GAMEID=fifa17 \ GAMEID=fifa17 \
@@ -208,8 +245,8 @@ launch() {
OPENFUT_SBC_TRACE="$trace_enabled" \ OPENFUT_SBC_TRACE="$trace_enabled" \
OPENFUT_SBC_REQUEST_TRACE="$request_trace_enabled" \ OPENFUT_SBC_REQUEST_TRACE="$request_trace_enabled" \
OPENFUT_SBC_NOTIFIER_TRACE="$notifier_trace_enabled" \ OPENFUT_SBC_NOTIFIER_TRACE="$notifier_trace_enabled" \
OPENFUT_SBC_DISPATCH=0 \ OPENFUT_SBC_DISPATCH="$dispatch_enabled" \
OPENFUT_SBC_COMMIT=0 \ OPENFUT_SBC_DISPATCH_TRACE=0 \
OPENFUT_SBC_ARM_ONLY=0 \ OPENFUT_SBC_ARM_ONLY=0 \
OPENFUT_SBC_POPULATE=0 \ OPENFUT_SBC_POPULATE=0 \
umu-run _fifa17.exe 2>&1 | tee /tmp/fifa17-hook-m1-launch.log umu-run _fifa17.exe 2>&1 | tee /tmp/fifa17-hook-m1-launch.log
@@ -217,7 +254,7 @@ launch() {
usage() { usage() {
cat <<'EOF' cat <<'EOF'
Usage: fifa17-hook-m1.sh [inspect|build|stage|deploy|launch|launch-resolve|launch-trace] Usage: fifa17-hook-m1.sh [inspect|build|stage|deploy|launch|launch-resolve|launch-trace|launch-dispatch]
inspect Read-only PE/hash/export preflight (default). inspect Read-only PE/hash/export preflight (default).
build Cross-build the inert FIFA17 hook, then run inspect. build Cross-build the inert FIFA17 hook, then run inspect.
@@ -230,8 +267,11 @@ Usage: fifa17-hook-m1.sh [inspect|build|stage|deploy|launch|launch-resolve|launc
Start M2 resolve-only mode (guarded reads/logging, no detours/writes); requires: Start M2 resolve-only mode (guarded reads/logging, no detours/writes); requires:
OPENFUT_FIFA17_RESOLVE=I_ACCEPT_M2_RESOLVE_LAUNCH OPENFUT_FIFA17_RESOLVE=I_ACCEPT_M2_RESOLVE_LAUNCH
launch-trace launch-trace
Start the single M3 passive factory/deserializer trace; requires: Start the M3-M6 passive parser/request/notifier trace; requires:
OPENFUT_FIFA17_TRACE=I_ACCEPT_M3_PASSIVE_TRACE OPENFUT_FIFA17_TRACE=I_ACCEPT_M3_PASSIVE_TRACE
launch-dispatch
Trace and repair only a fully validated native status-999 completion; requires:
OPENFUT_FIFA17_DISPATCH=I_ACCEPT_GUARDED_NATIVE_DISPATCH
Optional path overrides: Optional path overrides:
OPENFUT_FIFA17_HOOK_DLL, OPENFUT_FIFA17_GAME_DIR, OPENFUT_FIFA17_HOOK_DLL, OPENFUT_FIFA17_GAME_DIR,
@@ -247,6 +287,7 @@ case "${1:-inspect}" in
launch) launch baseline ;; launch) launch baseline ;;
launch-resolve) launch resolve ;; launch-resolve) launch resolve ;;
launch-trace) launch trace ;; launch-trace) launch trace ;;
launch-dispatch) launch dispatch ;;
-h|--help|help) usage ;; -h|--help|help) usage ;;
*) usage >&2; die "unknown command: $1" ;; *) usage >&2; die "unknown command: $1" ;;
esac esac
+38 -1
View File
@@ -204,6 +204,7 @@ class Account:
def __init__(self, path=None): def __init__(self, path=None):
self.path = path or ACCOUNT_PATH self.path = path or ACCOUNT_PATH
self._loaded = False self._loaded = False
self._file_signature = None
self._stored = {} # what is on disk (tier 2+3 only) self._stored = {} # what is on disk (tier 2+3 only)
for f in _FIELDS: for f in _FIELDS:
setattr(self, "_" + f, None) setattr(self, "_" + f, None)
@@ -214,7 +215,8 @@ class Account:
save the first time. Never raises on a malformed file -- a broken save the first time. Never raises on a malformed file -- a broken
account file must not stop the harness booting.""" account file must not stop the harness booting."""
with _LOCK: with _LOCK:
if self._loaded and not force: signature = self._signature()
if self._loaded and not force and signature == self._file_signature:
return self return self
stored = {} stored = {}
if os.path.exists(self.path): if os.path.exists(self.path):
@@ -239,8 +241,22 @@ class Account:
% (self.path, e)) % (self.path, e))
self._stored = stored self._stored = stored
self._loaded = True self._loaded = True
self._file_signature = self._signature()
return self return self
def _signature(self):
"""Identity of the active-account file across atomic replacements.
The launcher can select an account while Blaze/POW are already running
in separate processes. inode + mtime + size lets every process notice
the replacement on its next property read without restarting Docker.
"""
try:
st = os.stat(self.path)
return st.st_dev, st.st_ino, st.st_mtime_ns, st.st_size
except OSError:
return None
def _migrate_from_profile(self): def _migrate_from_profile(self):
"""Lift identity/club out of a pre-existing fifa17_profile.json so an """Lift identity/club out of a pre-existing fifa17_profile.json so an
existing club name survives the move to this module. Read-only: the game existing club name survives the move to this module. Read-only: the game
@@ -266,11 +282,32 @@ class Account:
return out return out
def _write(self): def _write(self):
parent = os.path.dirname(self.path)
if parent:
os.makedirs(parent, exist_ok=True)
tmp = self.path + ".tmp" tmp = self.path + ".tmp"
with open(tmp, "w") as f: with open(tmp, "w") as f:
json.dump(self._stored, f, indent=1, sort_keys=True) json.dump(self._stored, f, indent=1, sort_keys=True)
f.write("\n") f.write("\n")
os.replace(tmp, self.path) os.replace(tmp, self.path)
self._file_signature = self._signature()
def replace(self, values):
"""Atomically replace the active identity with validated persisted values."""
with _LOCK:
clean = {k: v for k, v in values.items() if k in _FIELDS and v is not None}
if "persona_id" not in clean or "persona_name" not in clean:
raise ValueError("persona_id and persona_name are required")
clean["persona_id"] = int(clean["persona_id"])
clean["persona_name"] = str(clean["persona_name"]).strip()
if clean["persona_id"] <= 0 or not clean["persona_name"]:
raise ValueError("persona_id must be positive and persona_name must not be empty")
self._stored = clean
for field in _FIELDS:
setattr(self, "_" + field, None)
self._loaded = True
self._write()
return self
def save(self): def save(self):
"""Persist tiers 2+3 (only fields that differ from the built-in default, """Persist tiers 2+3 (only fields that differ from the built-in default,
+71
View File
@@ -0,0 +1,71 @@
#!/usr/bin/env python3
"""Launcher-to-server active-account selection for the single-player stack."""
import json
import os
from fut_account import ACCOUNT
from fut_store import STORE, profile_path_for
def _existing_identity(persona_id):
path = profile_path_for(persona_id)
try:
with open(path) as f:
profile = json.load(f)
except (OSError, ValueError):
return {}
if not isinstance(profile, dict):
return {}
return {
"club_name": profile.get("clubName"),
"club_abbr": profile.get("clubAbbr"),
"established": profile.get("established"),
"pow_level": profile.get("powLevel"),
"pow_exp": profile.get("powExp"),
"pow_exp_max": profile.get("powExpMax"),
"pow_funds": profile.get("powFunds"),
"pow_funds_cap": profile.get("powFundsCap"),
}
def activate(payload):
"""Select/create one persistent profile and publish it to all responders."""
if not isinstance(payload, dict):
raise ValueError("account payload must be an object")
try:
persona_id = int(payload.get("personaId"))
except (TypeError, ValueError):
raise ValueError("personaId must be a positive integer") from None
persona_name = payload.get("personaName")
if persona_id <= 0 or not isinstance(persona_name, str) or not persona_name.strip():
raise ValueError("personaId must be positive and personaName must not be empty")
values = _existing_identity(persona_id)
values.update(persona_id=persona_id, persona_name=persona_name.strip())
for wire, field in (("clubName", "club_name"), ("clubAbbr", "club_abbr"),
("established", "established"), ("squadName", "squad_name"),
("level", "pow_level"), ("experience", "pow_exp"),
("experienceMax", "pow_exp_max"), ("accountFunds", "pow_funds"),
("accountFundsCap", "pow_funds_cap")):
if payload.get(wire) not in (None, ""):
values[field] = payload[wire]
ACCOUNT.replace(values)
ACCOUNT.set_online_profile()
ACCOUNT.save()
profile = STORE.select_account(persona_id)
STORE.ensure_security_question()
return {
"personaId": ACCOUNT.persona_id,
"personaName": ACCOUNT.persona_name,
"clubName": ACCOUNT.club_name,
"clubAbbr": ACCOUNT.club_abbr,
"level": ACCOUNT.pow_level,
"experience": ACCOUNT.pow_exp,
"experienceMax": ACCOUNT.pow_exp_max,
"accountFunds": ACCOUNT.pow_funds,
"accountFundsCap": ACCOUNT.pow_funds_cap,
"profilePath": os.path.relpath(STORE.path, os.path.dirname(ACCOUNT.path)),
"coins": profile.get("coins", 0),
"unopenedPacks": len(profile.get("unopenedPackIds", [])),
}
+159 -11
View File
@@ -17,7 +17,19 @@ sys.path.insert(0, HERE)
import fut_cards import fut_cards
from fut_account import ACCOUNT # single source of truth for identity/club from fut_account import ACCOUNT # single source of truth for identity/club
PROFILE_PATH = os.environ.get("FUT_PROFILE", os.path.join(HERE, "fifa17_profile.json")) PROFILE_ROOT = os.environ.get("FUT_PROFILE_ROOT", "")
def profile_path_for(persona_id):
explicit = os.environ.get("FUT_PROFILE")
if explicit:
return explicit
if PROFILE_ROOT:
return os.path.join(PROFILE_ROOT, str(int(persona_id)), "fifa17_profile.json")
return os.path.join(HERE, "fifa17_profile.json")
PROFILE_PATH = profile_path_for(ACCOUNT.persona_id)
# ---- FUT_DISCARD_TABLE: the REAL FIFA 17 quick-sell values ------------------ # ---- FUT_DISCARD_TABLE: the REAL FIFA 17 quick-sell values ------------------
# #
@@ -226,6 +238,56 @@ def _item(item_id, asset, rating, pos, nation, league, team, attrs, version=0x00
# the club showing different numbers for the same card. # the club showing different numbers for the same card.
SPECIAL_CARD_TYPES = {
# name: (rareflag, revision byte, rating/attribute boost, selection weight)
# rareflag names come from FIFA 17's ItemRareType enum. Revisions are local,
# stable identities; the client resolves the footballer from the low 24 bits.
"TOTW": (3, 1, 2, 34),
"PURPLE": (4, 2, 3, 7),
"TOTY": (5, 3, 6, 3),
"RECORD_BREAKER": (6, 4, 5, 2),
"TOTS": (11, 5, 5, 7),
"OTW": (21, 6, 2, 14),
"HALLOWEEN": (22, 7, 3, 8),
"MOVEMBER": (23, 8, 3, 8),
"SBC": (24, 9, 4, 17),
}
def choose_special_type(player, rng=None):
"""Choose a rating-appropriate FIFA 17 promo family for one pool row."""
import random
rng = rng or random
rating = player[1]
eligible = []
for name, spec in SPECIAL_CARD_TYPES.items():
if name in ("TOTY", "RECORD_BREAKER") and rating < 85:
continue
if name == "TOTS" and rating < 75:
continue
eligible.append((name, spec[3]))
names, weights = zip(*eligible)
return rng.choices(names, weights=weights, k=1)[0]
def player_item(item_id, player, special=False):
"""Build a base or named FIFA 17 special revision from a pool row.
`special=True` remains supported and chooses a weighted eligible family;
callers and tests may also pass an explicit name such as ``"TOTY"``.
"""
asset, rating, pos, nation, league, team, attrs = player
if special:
special_name = choose_special_type(player) if special is True else special
rareflag, version, boost, _weight = SPECIAL_CARD_TYPES[special_name]
rating = min(99, rating + boost)
attrs = [min(99, value + boost) for value in attrs]
else:
rareflag, version = 1, 0
return _item(item_id, asset, rating, pos, nation, league, team, attrs,
version=version, rareflag=rareflag)
# FUT_DISCARD_SEND: put discardValue (atom 0xd7) on the wire so the CLIENT DISPLAYS # FUT_DISCARD_SEND: put discardValue (atom 0xd7) on the wire so the CLIENT DISPLAYS
# the same number the server pays. # the same number the server pays.
# #
@@ -293,6 +355,10 @@ def _new_profile():
"purchased": [], # unassigned/pending items from opened packs "purchased": [], # unassigned/pending items from opened packs
"squads": [], # saved squads (raw squad objects from PUT /squad) "squads": [], # saved squads (raw squad objects from PUT /squad)
"packsOpened": 0, "packsOpened": 0,
# Owned reward packs are separate from purchased items. Pack 70 is a
# one-time migration grant used to bring the retail My Packs flow online.
"unopenedPackIds": [70],
"unopenedSeeded": True,
} }
@@ -311,6 +377,10 @@ class Store:
self._p = _new_profile() self._p = _new_profile()
self._sync_identity() self._sync_identity()
self._save() self._save()
if not self._p.get("unopenedSeeded"):
self._p.setdefault("unopenedPackIds", []).append(70)
self._p["unopenedSeeded"] = True
self._save()
self._sync_identity() self._sync_identity()
return self._p return self._p
@@ -328,18 +398,51 @@ class Store:
p["clubName"] = ACCOUNT.club_name p["clubName"] = ACCOUNT.club_name
p["clubAbbr"] = ACCOUNT.club_abbr p["clubAbbr"] = ACCOUNT.club_abbr
p["established"] = ACCOUNT.established p["established"] = ACCOUNT.established
# EA/EASFC account-bar state belongs to the same persona as the FUT
# save, but remains a distinct balance from FUT coins.
p["powLevel"] = ACCOUNT.pow_level
p["powExp"] = ACCOUNT.pow_exp
p["powExpMax"] = ACCOUNT.pow_exp_max
p["powFunds"] = ACCOUNT.pow_funds
p["powFundsCap"] = ACCOUNT.pow_funds_cap
return p return p
def _save(self): def _save(self):
parent = os.path.dirname(self.path)
if parent:
os.makedirs(parent, exist_ok=True)
tmp = self.path + ".tmp" tmp = self.path + ".tmp"
with open(tmp, "w") as f: with open(tmp, "w") as f:
json.dump(self._p, f, indent=1) json.dump(self._p, f, indent=1)
os.replace(tmp, self.path) os.replace(tmp, self.path)
def select_account(self, persona_id):
"""Switch the single active session to its isolated persistent FUT save."""
with _LOCK:
self.path = profile_path_for(persona_id)
self._p = None
return self.load()
# ---- accessors used by utas_server ------------------------------------- # ---- accessors used by utas_server -------------------------------------
def profile(self): def profile(self):
return self.load() return self.load()
def ensure_security_question(self):
"""Persist OpenFUT's account-scoped compatibility state for the FUT gate.
FIFA 17 transforms any entered answer before sending it. OpenFUT does not
need that value to emulate a retired service, so neither the clear text nor
the transformed value is stored. The only durable fact is that this
OpenFUT profile has an initialized, verified compatibility record.
"""
expected = {"version": 1, "verified": True}
with _LOCK:
p = self.load()
if p.get("securityQuestion") != expected:
p["securityQuestion"] = dict(expected)
self._save()
return dict(p["securityQuestion"])
def refresh_identity(self): def refresh_identity(self):
"""Re-mirror ACCOUNT into the save AND persist it. """Re-mirror ACCOUNT into the save AND persist it.
@@ -513,6 +616,32 @@ class Store:
sq = self.load()["squads"] sq = self.load()["squads"]
return sq[0] if sq else None return sq[0] if sq else None
def unopened_packs(self):
"""Owned reward-pack template IDs, including repeated grants."""
return list(self.load().get("unopenedPackIds", []))
def consume_unopened_pack(self, pack_id):
"""Atomically consume one owned instance of a reward pack."""
with _LOCK:
p = self.load()
owned = p.setdefault("unopenedPackIds", [])
try:
owned.remove(pack_id)
except ValueError:
return False
self._save()
return True
def grant_unopened_pack(self, pack_id):
"""Persist one additional owned reward-pack instance."""
if pack_by_id(pack_id) is None:
return False
with _LOCK:
p = self.load()
p.setdefault("unopenedPackIds", []).append(pack_id)
self._save()
return True
def reconstruct_squad(self, squad): def reconstruct_squad(self, squad):
"""FIFA's updateActiveSquad PUT stores each slot as itemData={id:<clubItemId>} """FIFA's updateActiveSquad PUT stores each slot as itemData={id:<clubItemId>}
(a reference). Re-embed the FULL club item by id so the squad reloads with (a reference). Re-embed the FULL club item by id so the squad reloads with
@@ -557,7 +686,8 @@ class Store:
return i return i
def open_pack(self, price, count, gold=True, tiers=None): def open_pack(self, price, count, gold=True, tiers=None, special_chance=0.0,
players_only=False):
"""Deduct `price` coins, generate `count` player items from the pool, and """Deduct `price` coins, generate `count` player items from the pool, and
place them in the PENDING purchased pile (unassigned). They are NOT owned place them in the PENDING purchased pile (unassigned). They are NOT owned
club items until moved there via FutMoveCard (PUT /item). Returns None if club items until moved there via FutMoveCard (PUT /item). Returns None if
@@ -579,19 +709,31 @@ class Store:
# fixed number so it scales from a 5-card bronze to an 11-card premium. # fixed number so it scales from a 5-card bronze to an 11-card premium.
n_extra = 0 n_extra = 0
extras = [] extras = []
if PACK_MIX and count >= 5: if PACK_MIX and not players_only and count >= 5:
n_extra = max(1, count // 4) n_extra = max(1, count // 4)
extras = _pack_extras(n_extra, self) extras = _pack_extras(n_extra, self)
n_extra = len(extras) n_extra = len(extras)
n_players = max(1, count - n_extra) n_players = max(1, count - n_extra)
if tiers: if tiers:
picks = [random.choice(fut_cards.pool_for(random.choice(tiers))) # Draw each tier independently but reject duplicate asset IDs inside
for _ in range(n_players)] # one pack. The real pool is large enough that this normally succeeds
# on the first attempt; the cap makes malformed tiny test pools safe.
picks = []
used_assets = set()
for _ in range(n_players):
tier_pool = fut_cards.pool_for(random.choice(tiers))
available = [p for p in tier_pool if p[0] not in used_assets]
pick = random.choice(available or tier_pool)
picks.append(pick)
used_assets.add(pick[0])
else: else:
pool = [p for p in PACK_POOL if (p[1] >= 75) == gold] or PACK_POOL pool = [p for p in PACK_POOL if (p[1] >= 75) == gold] or PACK_POOL
picks = [random.choice(pool) for _ in range(n_players)] picks = random.sample(pool, min(n_players, len(pool)))
items = [_item(self.new_item_id(), a, r, p, n, lg, tm, at) while len(picks) < n_players:
for (a, r, p, n, lg, tm, at) in picks] picks.append(random.choice(pool))
items = [player_item(self.new_item_id(), pick,
special=random.random() < special_chance)
for pick in picks]
items += extras items += extras
random.shuffle(items) random.shuffle(items)
with _LOCK: with _LOCK:
@@ -677,11 +819,17 @@ _LEGACY_POOL = STARTER_PLAYERS + [
# no silver or bronze players at all, so all three packs were identical in practice. # no silver or bronze players at all, so all three packs were identical in practice.
PACK_CATALOG = [ PACK_CATALOG = [
{"id": 1, "name": "Bronze Pack", "price": 400, "count": 5, "gold": False, {"id": 1, "name": "Bronze Pack", "price": 400, "count": 5, "gold": False,
"tiers": ["bronze"] * 8 + ["silver"] * 2}, "tiers": ["bronze"] * 8 + ["silver"] * 2, "specialChance": 0.005},
{"id": 5, "name": "Gold Pack", "price": 5000, "count": 7, "gold": True, {"id": 5, "name": "Gold Pack", "price": 5000, "count": 7, "gold": True,
"tiers": ["gold"] * 6 + ["silver"] * 4}, "tiers": ["gold"] * 6 + ["silver"] * 4, "specialChance": 0.03},
{"id": 6, "name": "Premium Gold", "price": 15000, "count": 11, "gold": True, {"id": 6, "name": "Premium Gold", "price": 15000, "count": 11, "gold": True,
"tiers": ["gold"] * 9 + ["silver"] * 1}, "tiers": ["gold"] * 9 + ["silver"] * 1, "specialChance": 0.08},
{"id": 7, "name": "Special Players Pack", "price": 25000, "count": 11,
"gold": True, "tiers": ["gold"], "specialChance": 1.0,
"playersOnly": True},
{"id": 70, "name": "Reward Special Players Pack", "price": 0, "count": 11,
"gold": True, "tiers": ["gold"], "specialChance": 1.0,
"playersOnly": True, "ownedOnly": True},
] ]
@@ -0,0 +1,25 @@
"""Trace FutPurchaseDraftModeServerResponse beyond its known seven-int parser."""
cls = "FutPurchaseDraftModeServerResponse"
print("CLASS", cls, class_deser(cls))
seen = set()
for deser, vt, factory in class_deser(cls):
print("\nVTABLE", hex(vt), "FACTORY", hex(factory), "DESER", hex(deser))
print(vtable(vt, 32))
for target in [factory, deser] + [t for _, t, name in vtable(vt, 32) if name]:
if target in seen:
continue
seen.add(target)
print("\n===", hex(target), fname(target), "===")
print(dec(target, 300))
print("XREFS", xrefs_to(target)[:100])
for target in (0x18014C090, 0x18014C260, 0x18014C820, 0x18014C8A0):
if target in seen:
continue
print("\n=== CANDIDATE", hex(target), fname(target), "===")
print(dec(target, 300))
print("XREFS", xrefs_to(target)[:100])
print("QUERY_DONE")
@@ -0,0 +1,22 @@
"""Bind the live draft-purchase URI builder to one of its two response factories."""
for literal in (
"purchase/mode/",
"/purchase/mode/",
"draft",
"ut/%s/draft/mode",
"FutPurchaseDraftModeServerResponse",
):
print("\nLITERAL", repr(literal))
for hit in find_all(literal.encode() + b"\x00"):
print(hex(hit), rd_str(hit), xrefs_to(hit)[:100])
for target in (0x180224EF8, 0x1802262F0, 0x18014C090, 0x180150260):
print("\nTARGET", hex(target), fname(target))
print("XREFS", xrefs_to(target)[:200])
for frm, typ, fn, ent in xrefs_to(target):
if ent:
print("\nOWNER", hex(ent), fn)
print(dec(ent, 300))
print("QUERY_DONE")
@@ -0,0 +1,16 @@
"""Dump both request vtables sharing FutPurchaseDraftModeServerResponse."""
for vt in (0x180226300, 0x180224F08):
print("\nREQUEST_VTABLE", hex(vt))
rows = vtable(vt, 40)
print(rows)
seen = set()
for off, target, name in rows:
if not name or target in seen:
continue
seen.add(target)
print("\n=== SLOT", hex(off), hex(target), name, "===")
print(dec(target, 300))
print("XREFS", xrefs_to(target)[:100])
print("QUERY_DONE")
@@ -0,0 +1,23 @@
"""Recover the JSON element shape consumed by the array-root draft response."""
targets = (
0x180138BD0, # helper called once per array element
0x180150310, # array-root FutPurchaseDraftModeServerResponse parser
)
seen = set()
for target in targets:
print("\n=== TARGET", hex(target), fname(target), "===")
print(dec(target, 500))
print("XREFS", xrefs_to(target)[:150])
# Include direct callees so small string/value accessors used by the helper
# are visible without broad, noisy whole-program searching.
for callee, name in callees(target):
if callee in seen:
continue
seen.add(callee)
print("\n--- CALLEE", hex(callee), name, "---")
print(dec(callee, 250))
print("QUERY_DONE")
@@ -0,0 +1,17 @@
"""Trace active draft-state enum literals and the current-state response consumers."""
for literal in ("DRAFTSQUAD_ON", "DRAFTSQUAD_OFF", "DRAFT_SQUAD", "squadState",
"stateParam1", "stateParam2", "roundsInfo"):
print("\n=== LITERAL", literal, "===")
for hit in find_all(literal.encode() + b"\x00", (".rdata", ".data")):
print("HIT", hex(hit), "XREFS", xrefs_to(hit)[:100])
for _frm, _typ, _name, entry in xrefs_to(hit):
print("\n--- XREF FUNCTION", hex(entry), fname(entry), "---")
print(dec(entry, 500))
for target in (0x180147070,):
print("\n=== STATE DESERIALIZER", hex(target), fname(target), "===")
print(dec(target, 500))
print("CALLERS", callers(target))
print("QUERY_DONE")
@@ -0,0 +1,21 @@
"""Resolve draft-state atom IDs to their authoritative wire strings."""
ATOM_TABLE = 0x1802D2760
def atom_name(index):
pointer = qword(ATOM_TABLE + index * 8)
return rd_str(pointer, 96)
groups = {
"squadState values": (0x1AC, 0x6A, 0x9C, 0x12C, 0x169, 0x225, 0x23E, 0x277, 0x278),
"stateParam1 values": (0x169, 0x1AA, 0x22D),
"entranceCriteria keys": (0x96, 0xDF, 0x241),
"top-level keys": (0x108, 0x13B, 0x293, 0x2CD, 0x2D5, 0x2EE, 0x2EF),
}
for group, indices in groups.items():
print("\n===", group, "===")
for index in indices:
print(hex(index), repr(atom_name(index)))
print("QUERY_DONE")
@@ -0,0 +1,50 @@
"""Resolve the concrete owner behind request+0x08 for the SBC category request.
q_md_sbc_9 proved generic slot +0x88 (0x1801631e0) invokes:
owner = *(request + 8)
owner.vtable[+0x18](owner, parsed_response, 0)
Work backwards from the category request constructor and its callers to identify who
supplies request+8, then map candidate owner vtables and their +0x18 consumers.
"""
import traceback
try:
def show(a, label):
f = func(a)
print("\n=== %s %#x %s ===" % (label, a, f.getName() if f else "?"))
print(dec(a))
ctor = 0x18017a7c0
show(ctor, "category request constructor")
print("\n=== ctor callers ===")
for ent, name in callers(ctor):
print(" %#x %s" % (ent, name))
show(ent, "ctor caller")
print("\n=== ctor xrefs ===")
for frm, typ, name, ent in xrefs_to(ctor):
print(" from=%#x type=%s fn=%s entry=%#x" % (frm, typ, name, ent))
# The request base constructor is usually visible as the first direct call in
# the category constructor. Dump every direct callee so request+8 initialization
# can be distinguished from URI/tag setup.
print("\n=== constructor direct callees ===")
for target, name in callees(ctor):
print(" %#x %s" % (target, name))
show(target, "ctor callee")
# Ghidra did not create a function at the traced +0x90 thunk. Print its raw
# instructions and nearby containing-function identity without assuming a body.
print("\n=== raw callback thunk at 0x180154830 ===")
ad = addr(0x180154830)
for _ in range(48):
ins = listing.getInstructionAt(ad)
if ins is None:
print(" %s <not disassembled>" % ad)
ad = ad.add(1)
continue
print(" %s %s" % (ad, ins))
ad = ins.getNext().getAddress() if ins.getNext() else ad.add(ins.getLength())
except Exception:
traceback.print_exc()
@@ -0,0 +1,34 @@
"""Trace the FUT-root constructor's third argument, inherited by every request at +8.
The category request lives at FUT root +0x4140 (qword index 0x828). Its base ctor
stores the root constructor's param_3 at request+8, making that object the receiver
of owner.vtable[+0x18](owner, parsed_response, 0).
"""
import traceback
try:
root_ctor = 0x18010cdc0
print("=== root ctor callers ===")
for ent, name in callers(root_ctor):
print("\n--- %#x %s ---" % (ent, name))
print(dec(ent))
print("\n=== root ctor xrefs ===")
for frm, typ, name, ent in xrefs_to(root_ctor):
print(" from=%#x type=%s fn=%s entry=%#x" % (frm, typ, name, ent))
if ent:
print(dec(ent))
# Static singleton slot and root vtables provide adjacent factory/type metadata.
for site in (0x1802e6398, 0x18021c2a0, 0x18021cda8, 0x18021cdb8):
print("\n=== qwords around %#x ===" % site)
for i in range(-8, 16):
p = site + i * 8
try:
value = qword(p)
except Exception:
continue
print(" [%#x] = %#x %s" % (p, value, fname(value)))
except Exception:
traceback.print_exc()
@@ -0,0 +1,29 @@
"""Map the category success notifier already instrumented at 0x18017aa80.
The checkpoint hook can passively record ctx+0x88 and the +0x58..+0x60 handler
vector. Establish where this notifier sits relative to request ownership transfer and
whether it is the concrete receiver-side publication path we need to observe live.
"""
import traceback
try:
target = 0x18017aa80
print("=== notifier 0x18017aa80 ===")
print(dec(target))
print("\n=== notifier callers ===")
for ent, name in callers(target):
print(" %#x %s" % (ent, name))
print(dec(ent))
print("\n=== notifier xrefs ===")
for frm, typ, name, ent in xrefs_to(target):
print(" from=%#x type=%s fn=%s entry=%#x" % (frm, typ, name, ent))
# Adjacent category request methods often expose the notifier through a vtable
# or callback descriptor; inspect nearby functions and data references.
for a in (0x18017aa80, 0x18017aaf0, 0x18017ab80, 0x18017b1c0):
f = func(a)
print("\n=== %#x %s ===" % (a, f.getName() if f else "?"))
print(dec(a))
except Exception:
traceback.print_exc()
@@ -0,0 +1,31 @@
"""Map the sole live category-notifier listener into CardsDLL.
Live capture 2026-08-07:
listener object 0x4216ca48
listener vtable 0x6ffffc20d6d0
vtable +0x08 0x6ffffc1e577a
CardsDLL slide 0x6ffe7c020000
static method 0x1801c577a
"""
TARGET = 0x1801C577A
VTABLE = 0x1801ED6D0
print("=== live notifier listener method ===")
target_function = func(TARGET)
if target_function is None:
print("no Ghidra function at %#x" % TARGET)
print("raw PE decoding: jmp [0x1801e5200], imported CRT _purecall")
else:
print("containing function:", target_function.getName(),
hex(int(target_function.getEntryPoint().getOffset())))
print(dec(TARGET))
print("\n=== listener vtable ===")
for slot, target, name in vtable(VTABLE, 12):
print("%+#04x %#x %s" % (slot, target, name))
print("\n=== method callers/xrefs ===")
print("callers:", callers(TARGET) if target_function is not None else [])
for row in xrefs_to(TARGET):
print(row)
@@ -0,0 +1,29 @@
"""Find concrete siblings of the live notifier listener's abstract vtable."""
import struct
VTABLE = 0x1801ED6D0
DTOR = 0x180018EF0
PURECALL_THUNK = 0x1801C577A
print("=== exact vtable references ===")
for row in xrefs_to(VTABLE):
print(row)
print("\n=== vtables sharing the live listener destructor ===")
for hit in find_all(struct.pack("<Q", DTOR), blocks=(".rdata", ".data")):
try:
slots = [qword(hit + i * 8) for i in range(12)]
except Exception:
continue
# Require the same broad interface shape: destructor in slot 0 and at least
# one CardsDLL code pointer after it. This filters incidental data matches.
if slots[0] != DTOR or not any(0x180000000 <= x < 0x1801E5000 for x in slots[1:]):
continue
print("vtable=%#x slot8=%#x %s" %
(hit, slots[1], "PURE" if slots[1] == PURECALL_THUNK else "CONCRETE"))
for i, target in enumerate(slots):
print(" +%#04x %#x %s" % (i * 8, target, fname(target)))
refs_here = xrefs_to(hit)
if refs_here:
print(" refs:", refs_here)
@@ -0,0 +1,31 @@
"""Locate event 0x753c users and category-listener registration/removal paths."""
import struct
EVENT = 0x753C
NOTIFIER = 0x18017AA80
print("=== immediate/data occurrences of event 0x753c ===")
seen = set()
for hit in find_all(struct.pack("<I", EVENT)):
print("hit", hex(hit))
owner = func(hit)
if owner is not None:
entry = int(owner.getEntryPoint().getOffset())
print(" containing", hex(entry), owner.getName())
seen.add(entry)
for row in xrefs_to(hit):
print(" ", row)
if row[3]:
seen.add(row[3])
print("\n=== decompile functions referencing event literal ===")
for entry in sorted(seen):
print("\n--- %#x %s ---" % (entry, fname(entry)))
print(dec(entry))
print("\n=== category request ctor/dtor and notifier neighborhood ===")
for target in (0x18017A7C0, 0x18017AA10, NOTIFIER, 0x18017AAF0, 0x18017B1C0):
print("\n--- %#x %s ---" % (target, fname(target)))
print("callers", callers(target))
print("xrefs", xrefs_to(target))
@@ -0,0 +1,16 @@
"""Resolve the SBC controller and its 0x756c refresh registration/dispatch contract."""
TARGETS = (
(0x1800B5260, "SBC controller allocation/ctor neighborhood"),
(0x1800B53F0, "SBC controller constructor"),
(0x1800B5760, "SBC service/controller constructor"),
(0x1800B5E00, "SBC tile builder"),
(0x1801A4A70, "event registration"),
(0x1801A4CD0, "event dispatch"),
)
for target, label in TARGETS:
print("\n=== %s %#x %s ===" % (label, target, fname(target)))
print(dec(target))
print("callers", callers(target))
print("xrefs", xrefs_to(target))
@@ -0,0 +1,10 @@
"""Decompile the concrete SBC controller event-listener vtable."""
VTABLE = 0x18020A888
print("=== SBC controller event subobject vtable ===")
for off in range(0, 0x80, 8):
target = qword(VTABLE + off)
print("\nslot +%#x -> %#x %s" % (off, target, fname(target)))
if 0x180001000 <= target < 0x180200000:
print(dec(target, 180))
print("callers", callers(target)[:30])
@@ -0,0 +1,7 @@
"""Follow the SBC category-completion continuation registered by event 0x753c."""
for target in (0x1800B8950, 0x1800B89D0, 0x1800B8C30, 0x1800BA460, 0x1800B7090):
print("\n=== %#x %s ===" % (target, fname(target)))
print(dec(target, 300))
print("callers", callers(target)[:50])
print("xrefs", xrefs_to(target)[:50])
@@ -0,0 +1,10 @@
"""Resolve manager +0xe0 used to schedule the ServerErrSets continuation."""
for target in (0x180009C80, 0x1800D7170, 0x180154830, 0x1801631E0):
print("\n=== %#x %s ===" % (target, fname(target)))
print(dec(target, 300))
print("xrefs", xrefs_to(target)[:80])
print("\n=== candidate manager vtables referencing category request callbacks ===")
for target in (0x1800B8950, 0x18017AA80, 0x18017B2B0):
print(hex(target), xrefs_to(target)[:100])
@@ -0,0 +1,21 @@
"""Find completion callbacks that test the same status field at response+0x1c."""
patterns = (
bytes.fromhex("83 7a 1c 00"), # cmp dword ptr [rdx+1c],0
bytes.fromhex("83 79 1c 00"), # cmp dword ptr [rcx+1c],0
bytes.fromhex("83 78 1c 00"), # cmp dword ptr [rax+1c],0
)
seen = set()
for pattern in patterns:
print("\npattern", pattern.hex())
for hit in find_all(pattern):
f = func(hit)
if f is None:
continue
entry = int(f.getEntryPoint().getOffset())
if entry in seen:
continue
seen.add(entry)
print("\n=== hit %#x function %#x %s ===" % (hit, entry, f.getName()))
print(dec(f, 180)[:5000])
@@ -0,0 +1,14 @@
"""Map the live category response object's vtable and status-bearing base class."""
VTABLE = 0x18022E5B0
print("=== live category response vtable ===")
print("vtable xrefs", xrefs_to(VTABLE)[:100])
for off in range(0, 0x100, 8):
target = qword(VTABLE + off)
print("slot +%#x -> %#x %s" % (off, target, fname(target)))
if 0x180001000 <= target < 0x180200000 and off < 0x60:
print(dec(target, 120)[:3000])
print("\n=== direct references to vtable entries/address ===")
for a in range(VTABLE - 0x20, VTABLE + 0x20, 8):
print(hex(a), xrefs_to(a)[:40])
@@ -0,0 +1,22 @@
"""Find static assignments/usages of completion status 999 (0x3e7)."""
patterns = []
for modrm in (0x40, 0x41, 0x42, 0x43, 0x46, 0x47, 0x80, 0x81, 0x82, 0x83, 0x86, 0x87):
patterns.append(bytes((0xC7, modrm, 0x1C, 0xE7, 0x03, 0x00, 0x00)))
patterns.extend((bytes.fromhex("b8 e7 03 00 00"), bytes.fromhex("b9 e7 03 00 00"),
bytes.fromhex("ba e7 03 00 00"), bytes.fromhex("41 b8 e7 03 00 00")))
seen = set()
for pattern in patterns:
for hit in find_all(pattern):
f = func(hit)
entry = int(f.getEntryPoint().getOffset()) if f else 0
key = (entry, hit)
if key in seen:
continue
seen.add(key)
print("\n=== pattern %s hit %#x function %#x %s ===" %
(pattern.hex(), hit, entry, f.getName() if f else "?"))
if f:
print(dec(f, 240)[:10000])
print("callers", callers(f)[:80])
@@ -0,0 +1,8 @@
"""Trace callers of the HTTP/FUT status mapper returning 999."""
for target in (0x1801844C0, 0x180163120, 0x180165050, 0x180165CC0,
0x18016C060, 0x180184A90):
print("\n=== %#x %s ===" % (target, fname(target)))
print(dec(target, 300)[:18000])
print("callers", callers(target)[:100])
print("xrefs", xrefs_to(target)[:100])
@@ -0,0 +1,26 @@
"""Decompile the transport-result conversion and SBC response base methods."""
TARGETS = (
0x180184420,
0x1801844C0,
0x180184A90,
0x180163120,
0x1801631E0,
0x180165050,
0x180165CC0,
0x18016C060,
0x18016C110,
0x18016C950,
0x18016CA40,
0x18016CAC0,
0x18016CB20,
0x18016CB90,
0x18016CBE0,
0x18016CCA0,
0x18016D230,
)
for address in TARGETS:
print("\n===== %#x %s =====" % (address, fname(address)))
print(dec(address, 60))
@@ -0,0 +1,31 @@
"""Enumerate CardsDLL instructions that write a dword-like value to object +0x1c.
This is intentionally a read-only listing query. It finds explicit memory writes whose
rendered destination operand contains displacement 0x1c, then groups them by function.
"""
listing = prog.getListing()
seen = set()
for insn in listing.getInstructions(True):
text = insn.toString().lower()
if "0x1c" not in text and "+1ch" not in text:
continue
refs = insn.getReferencesFrom()
has_write = any(ref.getReferenceType().isWrite() for ref in refs)
# Register-relative memory writes do not always produce a Ghidra reference, so retain
# the common write mnemonics and require the first rendered operand to contain +0x1c.
mnemonic = insn.getMnemonicString().lower()
dst = insn.getDefaultOperandRepresentation(0).lower()
if "0x1c" not in dst and "+1ch" not in dst:
continue
if not has_write and mnemonic not in ("mov", "movzx", "and", "or", "xor", "inc", "dec"):
continue
owner = func(int(insn.getAddress().getOffset()))
entry = int(owner.getEntryPoint().getOffset()) if owner else 0
key = (entry, int(insn.getAddress().getOffset()))
if key in seen:
continue
seen.add(key)
print("%#x function=%#x %s :: %s" %
(key[1], entry, owner.getName() if owner else "?", insn.toString()))
@@ -0,0 +1,7 @@
"""Inspect the two additional CardsDLL functions with explicit dword writes to +0x1c."""
for target in (0x180171970, 0x1801790A0):
print("\n===== %#x %s =====" % (target, fname(target)))
print(dec(target, 180))
print("callers", callers(target)[:100])
print("xrefs", xrefs_to(target)[:100])
@@ -0,0 +1,61 @@
"""Continue the SBC response handoff analysis after the 2026-08-07 passive trace.
Proven live boundary:
request +0x80 factory -> response 0x18022e5b0
response +0x08 -> 0x18017b2b0 returns true
request +0x90 -> parsed response callback returns normally
request +0x88 -> ownership transfer returns normally
The next unknown is the receiving owner's virtual +0x18 consumer called by
0x1801631e0. Recover the concrete receiver, its vtable, and downstream publication.
"""
import traceback
try:
def dump_function(a, label):
f = func(a)
print("\n=== %s @%#x (%s) ===" % (label, a, f.getName() if f else "?"))
if f:
print("entry=%s body=%s" % (f.getEntryPoint(), f.getBody()))
print(dec(a))
def dump_instructions(a, before=0, count=80):
f = func(a)
print("\n=== instructions around %#x ===" % a)
if not f:
return
rows = []
for ad in f.getBody().getAddresses(True):
ins = listing.getInstructionAt(ad)
if ins:
rows.append(ins)
pivot = next((i for i, ins in enumerate(rows)
if int(ins.getAddress().getOffset()) >= a), 0)
for ins in rows[max(0, pivot-before):pivot+count]:
print(" %s %s" % (ins.getAddress(), ins))
dump_function(0x1801631e0, "post-request ownership handoff / owner consumer")
dump_instructions(0x1801631e0, count=120)
print("\n=== callers/xrefs of 0x1801631e0 ===")
for ent, name in callers(0x1801631e0):
print(" caller %#x %s" % (ent, name))
print(dec(ent))
for frm, typ, name, ent in xrefs_to(0x1801631e0):
print(" xref from=%#x type=%s fn=%s entry=%#x" %
(frm, typ, name, ent))
request_vtable = 0x18022e5c0
print("\n=== category request vtable %#x ===" % request_vtable)
for off, target, name in vtable(request_vtable, 40):
print(" +%#04x -> %#x %s" % (off, target, name))
for slot, label in ((0x80, "typed factory"),
(0x88, "ownership transfer"),
(0x90, "completion callback")):
target = qword(request_vtable + slot)
dump_function(target, "request %s slot +%#x" % (label, slot))
dump_instructions(target, count=100)
except Exception:
traceback.print_exc()
@@ -0,0 +1,30 @@
"""Find indirect calls to service-interface slot +0xe0 and compare contracts."""
PATTERNS = (
bytes.fromhex("ff 90 e0 00 00 00"),
bytes.fromhex("ff 91 e0 00 00 00"),
bytes.fromhex("ff 92 e0 00 00 00"),
bytes.fromhex("ff 93 e0 00 00 00"),
bytes.fromhex("ff 96 e0 00 00 00"),
bytes.fromhex("ff 97 e0 00 00 00"),
bytes.fromhex("41 ff 90 e0 00 00 00"),
bytes.fromhex("41 ff 91 e0 00 00 00"),
bytes.fromhex("41 ff 92 e0 00 00 00"),
bytes.fromhex("41 ff 93 e0 00 00 00"),
)
seen = set()
for pattern in PATTERNS:
for hit in find_all(pattern, blocks=(".text",)):
owner = func(hit)
if owner is None:
continue
entry = int(owner.getEntryPoint().getOffset())
if entry in seen:
continue
seen.add(entry)
print("\n===== call %#x function %#x %s =====" %
(hit, entry, owner.getName()))
print(dec(owner, 120)[:12000])
print("callees", callees(owner)[:80])
@@ -0,0 +1,27 @@
"""Map the FIFA response-registry primitives surrounding state-3 completion."""
TARGETS = (
0x145336C50,
0x145336E60,
0x1453370B0,
0x1453371B0,
0x145337B20,
0x1453388A0,
0x145338950,
0x145339650,
0x1453396A0,
0x145339B10,
0x145374E10,
0x145375070,
0x145376200,
0x145376270,
0x1453762E0,
0x145376360,
)
for target in TARGETS:
print("\n===== %#x %s =====" % (target, fname(target)))
print(dec(target, 180)[:16000])
print("callers", callers(target)[:120])
print("callees", callees(target)[:120])
@@ -0,0 +1,16 @@
"""Analyze the unpacked FIFA17 SBC completion route offline."""
TARGETS = (
(0x146B805B0, "SBC request owner thunk"),
(0x145374E80, "generic request-state dispatcher"),
(0x145376270, "state-3 completion handler"),
(0x1453388A0, "response registry lookup (manager mode 1)"),
(0x145338950, "response registry lookup (manager mode 2)"),
(0x146162F50, "completion broadcast invoked on lookup miss"),
)
for target, label in TARGETS:
print("\n=== %s %#x %s ===" % (label, target, fname(target)))
print(dec(target, 300))
print("callers", callers(target)[:100])
print("xrefs", xrefs_to(target)[:100])
@@ -0,0 +1,17 @@
"""Resolve the two data tables that reference the SBC request-owner thunk."""
THUNK = 0x146B805B0
REFERENCES = (0x14366D3E0, 0x14381B5D0)
print("thunk bytes", read_bytes(THUNK, 32).hex(" "))
for reference in REFERENCES:
print("\n=== reference %#x ===" % reference)
print("raw", read_bytes(reference - 0x40, 0x90).hex(" "))
for slot in range(reference - 0x40, reference + 0x48, 8):
target = qword(slot)
print("%#x rel=%+#x -> %#x %s xrefs=%s" %
(slot, slot - reference, target, fname(target), xrefs_to(slot)[:8]))
if func(target) is not None:
print(dec(target, 60)[:5000])
+41 -8
View File
@@ -162,6 +162,35 @@ def log(*a):
print("[lsx]", *a, flush=True) print("[lsx]", *a, flush=True)
def spawn_parent_watchdog():
parent = os.getppid()
def _watch():
while True:
time.sleep(1)
if os.getppid() != parent:
log(f"launcher pid {parent} exited; stopping lsx")
os._exit(0)
threading.Thread(target=_watch, daemon=True).start()
_SECRET_ATTR_RE = re.compile(
r'(?i)\b(AuthCode|AuthToken|SessionKey|Token|Sid)="[^"]*"')
_AUTH_CODE_ATTR_RE = re.compile(r'(?i)\b(value|Code|Return)="[^"]*"')
_CHALLENGE_ATTR_RE = re.compile(r'(?i)\b(response)="[^"]*"')
def safe_xml_for_log(xml):
"""Redact credential-bearing LSX attributes from ordinary diagnostics."""
safe = _SECRET_ATTR_RE.sub(lambda m: '%s="[REDACTED]"' % m.group(1), xml)
if "<AuthCode " in safe:
safe = _AUTH_CODE_ATTR_RE.sub(lambda m: '%s="[REDACTED]"' % m.group(1), safe)
if "<ChallengeAccepted " in safe:
safe = _CHALLENGE_ATTR_RE.sub(lambda m: '%s="[REDACTED]"' % m.group(1), safe)
return safe
# ---------------------------------------------------------------- crypto # ---------------------------------------------------------------- crypto
# (verbatim from v1 -- verified end-to-end by decrypting captured # (verbatim from v1 -- verified end-to-end by decrypting captured
# captures/lsx/lsx_raw/C1_ENC-IN_*.bin. DO NOT TOUCH.) # captures/lsx/lsx_raw/C1_ENC-IN_*.bin. DO NOT TOUCH.)
@@ -389,8 +418,7 @@ def build_reply(mid, req_name, attrs, conn, recipient=""):
conn.stop_events = True conn.stop_events = True
log("*** GetAuthCode ISSUED ***") log("*** GetAuthCode ISSUED ***")
log(f" ClientId={client_id!r} Scope={scope!r}") log(f" ClientId={client_id!r} Scope={scope!r}")
log(f" code={code} -- this must arrive as Blaze " log(" code=[REDACTED] -- issued for Blaze Authentication::login (1/0x0A)")
f"LoginRequest.AUTH in Authentication::login (1/0x0A)")
return resp(mid, return resp(mid,
f'AuthCode value="{code}" Code="{code}" Return="{code}"') f'AuthCode value="{code}" Code="{code}" Return="{code}"')
@@ -489,8 +517,7 @@ def serve(sock, addr):
client_resp = mr.group(1) if mr else "" client_resp = mr.group(1) if mr else ""
h = challenge_response(client_key, client_resp) h = challenge_response(client_key, client_resp)
conn.key = derive_session_key(h) conn.key = derive_session_key(h)
log(f"client key={client_key} response={h[:16]}... " log("handshake accepted; session crypto initialized")
f"session_key={conn.key.hex()}")
# 3. plaintext ChallengeAccepted # 3. plaintext ChallengeAccepted
conn.send_plain(resp(1, f'ChallengeAccepted response="{h}"', "EALS")) conn.send_plain(resp(1, f'ChallengeAccepted response="{h}"', "EALS"))
@@ -525,7 +552,7 @@ def serve(sock, addr):
continue continue
mm = REQ_RE.search(xml) mm = REQ_RE.search(xml)
if not mm: if not mm:
log("<<", xml) log("<<", safe_xml_for_log(xml))
continue continue
mid, name, rest = mm.group(1), mm.group(2), mm.group(3) mid, name, rest = mm.group(1), mm.group(2), mm.group(3)
attrs = dict(ATTR_RE.findall(rest)) attrs = dict(ATTR_RE.findall(rest))
@@ -533,7 +560,7 @@ def serve(sock, addr):
recip = rm.group(1) if rm else "" recip = rm.group(1) if rm else ""
reply = build_reply(mid, name, attrs, conn, recip) reply = build_reply(mid, name, attrs, conn, recip)
log(f"<< id={mid} {name} recipient={recip!r} {attrs}") log(f"<< id={mid} {name} recipient={recip!r} {attrs}")
log(f">> {reply}") log(">>", safe_xml_for_log(reply))
conn.send_enc(reply) conn.send_enc(reply)
why = PUSH_AFTER.get(name) why = PUSH_AFTER.get(name)
@@ -558,9 +585,10 @@ def serve(sock, addr):
def main(): def main():
spawn_parent_watchdog()
s = socket.socket() s = socket.socket()
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(("127.0.0.1", 4216)) s.bind((os.environ.get("OPENFUT_BIND", "127.0.0.1"), 4216))
s.listen(8) s.listen(8)
log("v2 listening on 127.0.0.1:4216 (start FIFA 17 now)") log("v2 listening on 127.0.0.1:4216 (start FIFA 17 now)")
log(f"login-state event push: {'ENABLED' if EVENTS_ENABLED else 'DISABLED'}" log(f"login-state event push: {'ENABLED' if EVENTS_ENABLED else 'DISABLED'}"
@@ -591,7 +619,12 @@ def selftest():
# 'value' is the only attribute lsx::AuthCodeT's deserializer (0x1471312a0) # 'value' is the only attribute lsx::AuthCodeT's deserializer (0x1471312a0)
# actually reads; Code=/Return= are legacy padding. # actually reads; Code=/Return= are legacy padding.
assert '<AuthCode value=' in r, r assert '<AuthCode value=' in r, r
print("[ok] GetAuthCode ->", r) redacted = safe_xml_for_log(
'<AuthCode value="secret" Code="secret" Return="secret"/>')
assert "secret" not in redacted and redacted.count("[REDACTED]") == 3, redacted
status = safe_xml_for_log('<ErrorSuccess Code="0" Description=""/>')
assert 'Code="0"' in status, status
print("[ok] GetAuthCode response shape and log redaction")
print("[ok] selftest passed") print("[ok] selftest passed")
+23
View File
@@ -65,6 +65,18 @@ MODE = os.environ.get("POW_MODE", "serve")
API_ADDR = os.environ.get("POW_ADDR", "127.0.0.1:8094") API_ADDR = os.environ.get("POW_ADDR", "127.0.0.1:8094")
CONTENT_ADDR = os.environ.get("POW_CONTENT_ADDR", "127.0.0.1:8080") CONTENT_ADDR = os.environ.get("POW_CONTENT_ADDR", "127.0.0.1:8080")
# CardsDLL's store-description localizer accepts an empty translation catalogue;
# transport/XML success is the gate. It discovers individual <trans-unit> records
# when present, so keep a standards-shaped empty XLIFF document rather than invent
# labels for server content we do not yet expose.
STOREPACK_DESCRIPTIONS_XML = b"""<?xml version="1.0" encoding="UTF-8"?>
<xliff version="1.2">
<file source-language="en_us" datatype="plaintext" original="storepackdescriptions">
<body />
</file>
</xliff>
"""
def _split(hostport, default_port): def _split(hostport, default_port):
host, _, port = hostport.partition(":") host, _, port = hostport.partition(":")
@@ -296,6 +308,17 @@ class _Handler(http.server.BaseHTTPRequestHandler):
log(" body: %s" % body[:65536].decode("utf-8", "replace")) log(" body: %s" % body[:65536].decode("utf-8", "replace"))
if self.kind == "content": if self.kind == "content":
content_path = self.path.split("?", 1)[0]
if content_path.rstrip("/") == "/fut/packs/loc/storepackdescriptions.en_us.xml":
raw = STOREPACK_DESCRIPTIONS_XML
self.send_response(200)
self.send_header("Content-Type", "application/xml; charset=utf-8")
self.send_header("Content-Length", str(len(raw)))
self.end_headers()
if self.command != "HEAD":
self.wfile.write(raw)
log(" -> 200 storepack descriptions XML (%d bytes)" % len(raw))
return
# Art assets (.dds/.png). We have none; 404 is the honest answer and is # Art assets (.dds/.png). We have none; 404 is the honest answer and is
# what a missing-asset CDN would return. Logged so we learn what art the # what a missing-asset CDN would return. Logged so we learn what art the
# client wants before deciding to synthesise any. # client wants before deciding to synthesise any.
+1 -1
View File
@@ -20,7 +20,7 @@ HERE = os.path.dirname(os.path.abspath(__file__))
CERT = os.path.join(HERE, "redir_cert.pem") CERT = os.path.join(HERE, "redir_cert.pem")
KEY = os.path.join(HERE, "redir_key.pem") KEY = os.path.join(HERE, "redir_key.pem")
LOG = "/tmp/roster_server.log" LOG = "/tmp/roster_server.log"
ADDR = ("127.0.0.1", 8081) ADDR = (os.environ.get("OPENFUT_BIND", "127.0.0.1"), 8081)
# Minimal "no update available" roster body. Unknown-format -> iterate from the log. # Minimal "no update available" roster body. Unknown-format -> iterate from the log.
ROSTER_XML = b'<?xml version="1.0" encoding="utf-8"?>\n<rosterupdate version="0"/>\n' ROSTER_XML = b'<?xml version="1.0" encoding="utf-8"?>\n<rosterupdate version="0"/>\n'
@@ -0,0 +1,85 @@
#!/usr/bin/env python3
"""Regression tests for launcher-selected persistent FIFA 17 accounts."""
import importlib
import json
import os
import sys
import tempfile
TOOLS = os.path.dirname(os.path.abspath(__file__))
if TOOLS not in sys.path:
sys.path.insert(0, TOOLS)
def main():
with tempfile.TemporaryDirectory() as state:
os.environ["FUT_ACCOUNT_PATH"] = os.path.join(state, "active_account.json")
os.environ["FUT_PROFILE_ROOT"] = os.path.join(state, "accounts")
os.environ.pop("FUT_PROFILE", None)
import fut_account
import fut_store
import fut_accounts
import utas_server
importlib.reload(fut_account)
importlib.reload(fut_store)
importlib.reload(fut_accounts)
importlib.reload(utas_server)
a = fut_accounts.activate({
"personaId": 111001, "personaName": "TEST_A",
"level": 12, "experience": 345, "experienceMax": 1000,
"accountFunds": 50, "accountFundsCap": 100000,
})
assert a["personaId"] == 111001
assert a["level"] == 12
assert fut_store.STORE.coins() == 15000
assert fut_store.STORE.unopened_packs() == [70]
fut_store.STORE.spend(400)
fut_store.STORE.consume_unopened_pack(70)
b = fut_accounts.activate({"personaId": 222002, "personaName": "TEST_B"})
assert b["personaId"] == 222002
assert fut_store.STORE.coins() == 15000
assert fut_store.STORE.unopened_packs() == [70]
a2 = fut_accounts.activate({"personaId": 111001, "personaName": "TEST_A"})
assert a2["personaId"] == 111001
assert fut_store.STORE.coins() == 14600
assert fut_store.STORE.unopened_packs() == []
assert a2["level"] == 12
assert a2["accountFunds"] == 50
active = json.load(open(os.environ["FUT_ACCOUNT_PATH"]))
assert active["persona_id"] == 111001
assert active["persona_name"] == "TEST_A"
# A second process-like Account instance must observe an atomic active
# account file replacement rather than retaining its first loaded value.
observer = fut_account.Account(os.environ["FUT_ACCOUNT_PATH"])
assert observer.persona_id == 111001
fut_accounts.activate({"personaId": 222002, "personaName": "TEST_B"})
assert observer.persona_id == 222002
class Purchase:
command = "POST"
_body = b'{"packId":6,"useCredits":1,"usePreOrder":0,"currency":"COINS"}'
utas_server._OPENED_PACK_GRACE.clear()
status, _ = utas_server.purchased_items(Purchase())
assert status == 200
assert utas_server._OPENED_PACK_GRACE == [6]
status, catalog = utas_server.store_catalog(None)
assert status == 200
grace = [p for p in catalog["purchase"]
if p.get("id") == 6 and p.get("unopened")]
assert len(grace) == 1
assert grace[0]["state"] == "active"
assert grace[0]["displayGroup"]["value"] == "mypacks"
print("account profile isolation: PASS")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,37 @@
#!/usr/bin/env python3
"""Regression: autopatch logging is per-launcher/user writable.
The watcher is run with a definitely-absent launcher PID, so it writes its
startup/ownership-exit diagnostics and terminates without touching FIFA.
"""
import os
import pathlib
import subprocess
import sys
import tempfile
def main():
script = pathlib.Path(__file__).with_name("autopatch.py")
with tempfile.TemporaryDirectory(prefix="openfut-autopatch-test-") as root:
log_path = pathlib.Path(root) / "autopatch.log"
env = os.environ.copy()
env["OPENFUT_AUTOPATCH_LOG"] = str(log_path)
result = subprocess.run(
[sys.executable, str(script), "--launcher-pid", "999999999"],
env=env,
text=True,
capture_output=True,
timeout=5,
)
assert result.returncode == 0, result.stderr or result.stdout
assert log_path.is_file(), "OPENFUT_AUTOPATCH_LOG was ignored"
text = log_path.read_text()
assert "watching for FIFA17.exe" in text
assert "launcher pid 999999999 exited" in text
print("autopatch writable-log override: PASS")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+14 -4
View File
@@ -104,11 +104,21 @@ def test_store_catalog():
check("catalog.purchase is array", is_arr(d.get("purchase")), repr(type(d.get("purchase")))) check("catalog.purchase is array", is_arr(d.get("purchase")), repr(type(d.get("purchase"))))
for p in d.get("purchase", []): for p in d.get("purchase", []):
check("pack has assetId (real identity)", "assetId" in p, repr(p.get("assetId"))) check("pack has assetId (real identity)", "assetId" in p, repr(p.get("assetId")))
check("pack.currencies is array (coin price)", is_arr(p.get("currencies")))
check("pack.packContentInfo is object", is_obj(p.get("packContentInfo"))) check("pack.packContentInfo is object", is_obj(p.get("packContentInfo")))
check("pack.extPrice is object", is_obj(p.get("extPrice"))) # The inactive zero-item sentinel keeps FIFA's hardcoded `mypacks`
ep = p.get("extPrice", {}) # navigation destination resolvable when no owned packs remain. It is
check("extPrice.finalPrice is object", is_obj(ep.get("finalPrice"))) # intentionally neither owned nor purchasable and therefore has no
# pricing. Validate prices only for active store packs.
if p.get("state") == "active" and not p.get("unopened"):
check("store pack currencies is array (coin price)",
is_arr(p.get("currencies")))
check("store pack extPrice is object", is_obj(p.get("extPrice")))
ep = p.get("extPrice", {})
check("store extPrice.finalPrice is object",
is_obj(ep.get("finalPrice")))
elif p.get("unopened"):
check("owned pack omits purchase currencies", "currencies" not in p)
check("owned pack omits external purchase price", "extPrice" not in p)
def test_market_bodies(): def test_market_bodies():
@@ -0,0 +1,45 @@
#!/usr/bin/env python3
"""Regression for the FIFA 17 FUT hub offline-Seasons summary.
CardsDLL's hub parser at 0x180139610 recognizes offlineSeason (atom 0x1ec)
and passes its object to 0x18013c3a0. That nested parser recognizes the
string-valued divisionId, gamesPlayed, points, totalGames, and
progressDataVersion fields. Without offlineSeason, the Single Player Season
UI rejects the otherwise-successful GetHubData response before /season is sent.
"""
import os
import pathlib
import sys
import tempfile
TOOLS = pathlib.Path(__file__).resolve().parent
sys.path.insert(0, str(TOOLS))
def main():
with tempfile.TemporaryDirectory(prefix="openfut-hub-season-test-") as state:
os.environ["FUT_PROFILE_ROOT"] = os.path.join(state, "accounts")
os.environ.pop("FUT_PROFILE", None)
os.environ["FUT_MODES"] = "1"
import utas_server
body = utas_server.hub_data()
assert isinstance(body, dict), "hub response root must be an object"
summary = body.get("offlineSeason")
assert isinstance(summary, dict), "hub.offlineSeason must be an object"
expected = {"divisionId", "gamesPlayed", "points", "totalGames",
"progressDataVersion"}
assert set(summary) == expected, repr(summary)
for key in expected:
assert isinstance(summary[key], str), "%s must be a string: %r" % (key, summary[key])
assert summary["divisionId"] == "10", repr(summary)
assert summary["gamesPlayed"] == "0", repr(summary)
assert summary["points"] == "0", repr(summary)
if __name__ == "__main__":
main()
print("PASS: FUT hub includes the recovered offline-Seasons summary")
@@ -0,0 +1,88 @@
#!/usr/bin/env python3
"""Isolated account-scoped regression for the FUT match HTTP lifecycle.
Drives CREATE -> READY -> PLAY -> END through match_route using a temporary
profile root. No live profile or server is touched.
"""
import importlib
import json
import os
import sys
import tempfile
TOOLS = os.path.dirname(os.path.abspath(__file__))
if TOOLS not in sys.path:
sys.path.insert(0, TOOLS)
class Request:
def __init__(self, path, body, command="POST"):
self.path = path
self.command = command
self._body = json.dumps(body).encode("utf-8")
def main():
with tempfile.TemporaryDirectory() as state:
os.environ["FUT_ACCOUNT_PATH"] = os.path.join(state, "active_account.json")
os.environ["FUT_PROFILE_ROOT"] = os.path.join(state, "accounts")
os.environ.pop("FUT_PROFILE", None)
import fut_account
import fut_store
import fut_accounts
import utas_server
importlib.reload(fut_account)
importlib.reload(fut_store)
importlib.reload(fut_accounts)
importlib.reload(utas_server)
persona_id = 909001
fut_accounts.activate({"personaId": persona_id, "personaName": "MATCH_TEST"})
initial = fut_store.STORE.load()
initial_coins = initial["coins"]
initial_next_id = initial["nextItemId"]
status, created = utas_server.match_route(
Request("/ut/game/fifa17/match", {}))
assert status == 200
match_id = created["id"]
assert created["reportIdEnabled"] is False
assert fut_store.STORE.load()["nextItemId"] == initial_next_id + 1
status, ready = utas_server.match_route(
Request("/ut/game/fifa17/match/ready", {"matchId": match_id}))
assert status == 200
assert ready == {"matchId": match_id, "opponentPersonaId": 0}
next_id_before_play = fut_store.STORE.load()["nextItemId"]
status, played = utas_server.match_route(
Request("/ut/game/fifa17/match", {"matchId": match_id}))
assert status == 200
assert played == {}
assert fut_store.STORE.load()["nextItemId"] == next_id_before_play
status, ended = utas_server.match_route(Request(
"/ut/game/fifa17/match/end",
{"matchId": match_id, "endReason": "WIN",
"myMatchStats": {"goals": 2},
"opponentMatchStats": {"goals": 1}},
))
assert status == 200
expected_reward = (utas_server.MATCH_COINS["won"]
+ utas_server.MATCH_PARTICIPATION)
assert ended["allCoins"] == initial_coins + expected_reward
profile_path = os.path.join(state, "accounts", str(persona_id),
"fifa17_profile.json")
persisted = json.load(open(profile_path, encoding="utf-8"))
assert persisted["coins"] == initial_coins + expected_reward
assert persisted["record"] == {"won": 1, "draw": 0, "loss": 0}
assert persisted["matchesPlayed"] == 1
print("match lifecycle persistence: PASS")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+31 -1
View File
@@ -11,6 +11,8 @@ Guards the two things that would silently break the loop:
* `destroy_match_body()` drifting from FutDestroyMatchServerResponse * `destroy_match_body()` drifting from FutDestroyMatchServerResponse
(deser 0x180121b60): a non-scalar there is the freeze class at 0x1801c7f1a, (deser 0x180121b60): a non-scalar there is the freeze class at 0x1801c7f1a,
and a renamed key is silently SKIP'd, i.e. the reward vanishes with no error. and a renamed key is silently SKIP'd, i.e. the reward vanishes with no error.
* the shared base `/match` path distinguishing CREATEMATCH from PLAYGAME by
the body-level matchId that CardsDLL serializes for subsequent operations
Run: python3 tools/test_match_rewards.py (exit 0 = pass) Run: python3 tools/test_match_rewards.py (exit 0 = pass)
""" """
@@ -152,9 +154,37 @@ def test_payout_table():
check("draw pays >= loss", U.MATCH_COINS["draw"] >= U.MATCH_COINS["loss"]) check("draw pays >= loss", U.MATCH_COINS["draw"] >= U.MATCH_COINS["loss"])
def test_match_call_classification():
"""CREATEMATCH and PLAYGAME share a path; only the latter has a matchId."""
cases = (
("/ut/game/fifa17/match", "POST", {}, "create"),
("/ut/game/fifa17/match", "POST", {"matchId": 1234}, "play"),
("/ut/game/fifa17/match/ready", "POST", {"matchId": 1234}, "ready"),
("/ut/game/fifa17/match/end", "POST", {"matchId": 1234}, "end"),
("/ut/game/fifa17/match/reset", "PUT", {"matchId": 1234}, "reset"),
("/ut/game/fifa17/match/keepalive", "POST", {"matchId": 1234}, "keepalive"),
)
for path, method, body, want in cases:
got = U._match_call(path, method, body)
check("%s %s -> %s" % (method, path, want), got == want, "got %s" % got)
def test_match_ready_body():
"""FutMatchReadyServerResponse parses these two scalar identifiers."""
body = U.match_ready_body(1234, 33068179)
check("ready echoes matchId", body.get("matchId") == 1234, repr(body))
check("ready has opponentPersonaId", body.get("opponentPersonaId") == 33068179,
repr(body))
check("ready IDs are scalar ints",
all(isinstance(v, int) and not isinstance(v, bool) for v in body.values()),
repr(body))
check("ready omits unproven nested items", "items" not in body, repr(body))
def main(): def main():
for t in (test_result_detection, test_reward_body, for t in (test_result_detection, test_reward_body,
test_end_reason_is_authoritative, test_payout_table): test_end_reason_is_authoritative, test_payout_table,
test_match_call_classification, test_match_ready_body):
try: try:
t() t()
except Exception as e: except Exception as e:
@@ -0,0 +1,134 @@
#!/usr/bin/env python3
"""Regression tests for FIFA 17's account-scoped phishing/security gate."""
import importlib
import json
import os
import sys
import tempfile
TOOLS = os.path.dirname(os.path.abspath(__file__))
if TOOLS not in sys.path:
sys.path.insert(0, TOOLS)
DEVICE_ID = "1" * 32
TRANSFORMED_ANSWER = "a" * 32 # sanitized replay value, not a real answer
class Request:
def __init__(self, method, path, sid=None):
self.command = method
self.path = path
self.headers = {"X-UT-SID": sid} if sid is not None else {}
self._body = b""
def request(utas_server, method, suffix, sid=None):
sid = utas_server.SID if sid is None else sid
h = Request(method, "/ut/game/fifa17/phishing/" + suffix, sid)
return utas_server.security_question_route(h)
def profile(state, persona_id):
path = os.path.join(state, "accounts", str(persona_id), "fifa17_profile.json")
with open(path) as f:
return json.load(f)
def main():
with tempfile.TemporaryDirectory() as state:
os.environ["FUT_ACCOUNT_PATH"] = os.path.join(state, "active_account.json")
os.environ["FUT_PROFILE_ROOT"] = os.path.join(state, "accounts")
os.environ.pop("FUT_PROFILE", None)
import fut_account
import fut_store
import fut_accounts
import utas_server
importlib.reload(fut_account)
importlib.reload(fut_store)
importlib.reload(fut_accounts)
importlib.reload(utas_server)
# New/missing state: launcher account selection initializes one account only.
fut_accounts.activate({"personaId": 771001, "personaName": "SEC_A"})
p = profile(state, 771001)
assert p["securityQuestion"] == {"version": 1, "verified": True}
# Actual trusted-device response fields parsed by CardsDLL 0x18012a170.
code, body = request(
utas_server, "GET", "trusteddevice?deviceId=" + DEVICE_ID)
assert code == 200
assert body == {
"changed": False,
"exists": True,
"locked": False,
"trusted": True,
}
# Existing initialized state survives a fresh Store instance/process view.
reopened = fut_store.Store(fut_store.profile_path_for(771001))
assert reopened.profile()["securityQuestion"] == {
"version": 1, "verified": True}
# FIFA's observed repeat-session request: POST, empty body, opaque 32-hex
# deviceId and transformed answer in the query string. The answer is accepted
# for OpenFUT compatibility but never persisted.
code, body = request(
utas_server,
"POST",
"validate?deviceId=%s&answer=%s" % (DEVICE_ID, TRANSFORMED_ANSWER),
)
assert (code, body) == (200, {})
saved = profile(state, 771001)
assert TRANSFORMED_ANSWER not in json.dumps(saved)
# Question lookup uses the three fields parsed by CardsDLL 0x180129850.
code, body = request(
utas_server, "GET", "question?deviceId=" + DEVICE_ID)
assert code == 200
assert set(body) == {"question", "attempts", "recoverAttempts"}
assert all(isinstance(body[k], int) for k in body)
# Malformed values/methods and missing sessions fail explicitly.
code, _ = request(utas_server, "POST", "validate?deviceId=bad&answer=bad")
assert code == 400
code, _ = request(
utas_server, "DELETE", "trusteddevice?deviceId=" + DEVICE_ID)
assert code == 405
h = Request(
"GET", "/ut/game/fifa17/phishing/trusteddevice?deviceId=" + DEVICE_ID)
code, _ = utas_server.security_question_route(h)
assert code == 400
# Ordinary request logging must redact answer query values.
raw_path = "/ut/game/fifa17/phishing/validate?deviceId=%s&answer=%s" % (
DEVICE_ID, TRANSFORMED_ANSWER)
safe_path = utas_server.safe_request_path(raw_path)
assert TRANSFORMED_ANSWER not in safe_path
assert "answer=%5BREDACTED%5D" in safe_path
# Multiple profiles receive independent persisted state; selecting B must not
# alter A's initialized record.
fut_accounts.activate({"personaId": 771002, "personaName": "SEC_B"})
assert profile(state, 771002)["securityQuestion"] == {
"version": 1, "verified": True}
assert profile(state, 771001)["securityQuestion"] == {
"version": 1, "verified": True}
# Legacy profile with the field removed is repaired once and persisted.
b_path = os.path.join(state, "accounts", "771002", "fifa17_profile.json")
b = profile(state, 771002)
b.pop("securityQuestion")
with open(b_path, "w") as f:
json.dump(b, f)
fut_store.STORE._p = None
code, body = request(
utas_server, "GET", "trusteddevice?deviceId=" + DEVICE_ID)
assert code == 200 and body["exists"] and body["trusted"]
assert profile(state, 771002)["securityQuestion"]["verified"] is True
print("security-question compatibility: PASS")
if __name__ == "__main__":
main()
@@ -0,0 +1,37 @@
#!/usr/bin/env python3
"""Regression for the FIFA 17 tournament-list response wrapper.
CardsDLL's endpoint response parser at 0x18016b220 accepts an OBJECT root,
recognizes only tournament (atom 0x328), then opens its ARRAY and invokes the
element parser at 0x180169ef0. A bare array therefore parses as no tournament
list at all.
"""
import os
import pathlib
import sys
import tempfile
TOOLS = pathlib.Path(__file__).resolve().parent
sys.path.insert(0, str(TOOLS))
def main():
with tempfile.TemporaryDirectory(prefix="openfut-tournament-test-") as state:
os.environ["FUT_PROFILE_ROOT"] = os.path.join(state, "accounts")
os.environ.pop("FUT_PROFILE", None)
import utas_server
body = utas_server.tournament_list()
assert isinstance(body, dict), "tournament response root must be an object"
body_dict = dict(body)
assert set(body_dict) == {"tournament"}, repr(body_dict)
tournaments = body_dict["tournament"]
assert isinstance(tournaments, list), "tournament must be an array"
assert tournaments, "the offline tournament catalog must not be empty"
assert all(isinstance(entry, dict) for entry in tournaments)
if __name__ == "__main__":
main()
print("PASS: tournament response uses the recovered object/array wrapper")
@@ -0,0 +1,37 @@
#!/usr/bin/env python3
"""Regression: ordinary UTAS diagnostics never expose session credentials."""
import importlib
import os
import sys
import tempfile
TOOLS = os.path.dirname(os.path.abspath(__file__))
if TOOLS not in sys.path:
sys.path.insert(0, TOOLS)
CANARY = "OPENFUT_UTAS_CANARY_SECRET"
def main():
with tempfile.TemporaryDirectory() as state:
os.environ["FUT_ACCOUNT_PATH"] = os.path.join(state, "active_account.json")
os.environ["FUT_PROFILE_ROOT"] = os.path.join(state, "accounts")
os.environ["FUT_LOG"] = os.path.join(state, "utas.log")
os.environ.pop("FUT_PROFILE", None)
import utas_server
importlib.reload(utas_server)
for name in ("X-UT-SID", "Authorization", "Cookie", "Set-Cookie"):
rendered = utas_server.safe_header_for_log(name, CANARY)
assert rendered == "[REDACTED]", (name, rendered)
assert CANARY not in rendered
assert utas_server.safe_header_for_log("Content-Type", "application/json") == "application/json"
assert utas_server.safe_header_for_log("X-Request-Id", "status-0") == "status-0"
print("UTAS header redaction: PASS")
if __name__ == "__main__":
main()
+547 -82
View File
@@ -11,19 +11,23 @@ Rules (from CardsDLL 0x18016D230 / 0x1801a33a0):
* body must parse as JSON (else err 0x3E6); 204 + empty body is accepted. * body must parse as JSON (else err 0x3E6); 204 + empty body is accepted.
* [resp+0x1c] == 0 is the success test; 404 is OK only on the first user GET. * [resp+0x1c] == 0 is the success test; 404 is OK only on the first user GET.
""" """
import datetime, json, os, re, sys, http.server import copy, datetime, json, os, random, re, sys, http.server
from urllib.parse import parse_qs, urlencode, urlsplit, urlunsplit
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from fut_seed import CLUB, SQUAD, USER_LIST, squad_summary # forged starter squad (clean-room) from fut_seed import CLUB, SQUAD, USER_LIST, squad_summary # forged starter squad (clean-room)
from fut_store import STORE, PACK_CATALOG, pack_by_id, PACK_POOL, _item # profile + packs from fut_store import STORE, PACK_CATALOG, pack_by_id, PACK_POOL, _item, player_item
import fut_cards
import fut_staff
from fut_account import ACCOUNT, validate_club # identity + club, single source from fut_account import ACCOUNT, validate_club # identity + club, single source
from fut_accounts import activate as activate_account
# FUT_PORT exists so a second, THROWAWAY instance can be started without touching the # FUT_PORT exists so a second, THROWAWAY instance can be started without touching the
# one the live client is talking to. Research agents kept bouncing the live server # one the live client is talking to. Research agents kept bouncing the live server
# because the only way to exercise a route was to restart the only server there was; # because the only way to exercise a route was to restart the only server there was;
# with this plus FUT_PROFILE (a copy of the save) and FUT_TEST_BASE, a test run is # with this plus FUT_PROFILE (a copy of the save) and FUT_TEST_BASE, a test run is
# fully isolated. The default stays 8099: that is the port the hook redirects to. # fully isolated. The default stays 8099: that is the port the hook redirects to.
ADDR = ("127.0.0.1", int(os.environ.get("FUT_PORT", "8099"))) ADDR = (os.environ.get("OPENFUT_BIND", "127.0.0.1"), int(os.environ.get("FUT_PORT", "8099")))
LOG = os.environ.get("FUT_LOG", "/tmp/utas_server.log") LOG = os.environ.get("FUT_LOG", "/tmp/utas_server.log")
SID = "OPENFUT-SID-0000000000000001" SID = "OPENFUT-SID-0000000000000001"
# IDENTITY NOTE: there are no PERSONA_ID / PERSONA_NAME literals in this file any # IDENTITY NOTE: there are no PERSONA_ID / PERSONA_NAME literals in this file any
@@ -37,6 +41,16 @@ SID = "OPENFUT-SID-0000000000000001"
# Flip to True once you want to exercise the create-club path instead. # Flip to True once you want to exercise the create-club path instead.
NEW_USER = False NEW_USER = False
# An owned pack is consumed persistently when opened, but FIFA's reveal controller
# still returns to the My Packs group after all items are assigned/sold. Keep the
# just-opened catalogue record visible until the next hub request so that group is
# not deleted underneath a live UI controller.
_OPENED_PACK_GRACE = []
def visible_unopened_packs():
return STORE.unopened_packs() + list(_OPENED_PACK_GRACE)
def now(): def now():
return datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") return datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
@@ -49,6 +63,96 @@ def log(m):
f.write(line + "\n") f.write(line + "\n")
def safe_request_path(path):
"""Redact legacy phishing answers before ordinary request logging."""
parts = urlsplit(path)
query = []
for key, value in parse_qs(parts.query, keep_blank_values=True).items():
query.extend((key, "[REDACTED]" if key.lower() == "answer" else item)
for item in value)
return urlunsplit((parts.scheme, parts.netloc, parts.path,
urlencode(query), parts.fragment))
_SECRET_HEADERS = {
"authorization", "cookie", "set-cookie", "x-ut-sid", "x-pow-sid",
}
def safe_header_for_log(name, value):
"""Return a diagnostic-safe HTTP header value."""
if name.lower() in _SECRET_HEADERS:
return "[REDACTED]"
return value
_PHISHING_HEX32 = re.compile(r"^[0-9a-fA-F]{32}$")
def security_question_route(h):
"""Emulate FIFA 17's retired FUT phishing/security-question service.
Clean-room CardsDLL evidence:
GET /question?deviceId=%s parses question/attempts/recoverAttempts.
POST /validate?deviceId=%s&answer=%s parses no response fields.
/trusteddevice parses changed/exists/locked/trusted booleans.
The answer is an opaque client-transformed 32-hex value. Successful legacy
set/validate calls have empty response contracts, so OpenFUT acknowledges a
well-formed value without retaining or comparing it. Account selection has
already initialized the server-owned verified compatibility state.
"""
if h.headers.get("X-UT-SID") != SID:
log("[FUT] security-question request has no matching OpenFUT session")
return 400, {"reason": "invalid_session"}
parts = urlsplit(h.path)
action = parts.path.rstrip("/").rsplit("/", 1)[-1]
params = parse_qs(parts.query, keep_blank_values=True)
device_id = params.get("deviceId", [""])[0]
if not _PHISHING_HEX32.fullmatch(device_id):
log("[FUT] malformed security-question device identifier")
return 400, {"reason": "malformed_request"}
state = STORE.ensure_security_question()
log("[FUT] security-question %s request" % action)
log("[FUT] profile security state: %s"
% ("initialized" if state.get("verified") else "not initialized"))
if action == "trusteddevice":
if h.command != "GET":
return 405, {"reason": "method_not_allowed"}
log("[FUT] returning verified trusted-device response")
return 200, {
"changed": False,
"exists": True,
"locked": False,
"trusted": True,
}
if action == "question" and h.command == "GET":
return 200, {"question": 0, "attempts": 5, "recoverAttempts": 0}
if action == "question" and h.command in ("POST", "PUT"):
answer = params.get("answer", [""])[0]
question = params.get("question", [""])[0]
if not question.isdigit() or not _PHISHING_HEX32.fullmatch(answer):
log("[FUT] malformed security-question setup request")
return 400, {"reason": "malformed_request"}
log("[FUT] security-question compatibility setup completed")
return 200, {}
if action == "validate" and h.command == "POST":
answer = params.get("answer", [""])[0]
if not _PHISHING_HEX32.fullmatch(answer):
log("[FUT] malformed security-question validation request")
return 400, {"reason": "malformed_request"}
log("[FUT] security-question accepted")
return 200, {}
return 405, {"reason": "method_not_allowed"}
# ---- payloads ------------------------------------------------------------- # ---- payloads -------------------------------------------------------------
def auth_body(h=None): def auth_body(h=None):
"""POST ut/auth. """POST ut/auth.
@@ -92,6 +196,19 @@ def auth_body(h=None):
return {"protocol": 1, "sid": SID, "serverTime": now(), "lastOnlineTime": now()} return {"protocol": 1, "sid": SID, "serverTime": now(), "lastOnlineTime": now()}
def account_sync_route(h):
"""Launcher-only active-profile selection, before LSX/Blaze login starts."""
try:
body = json.loads(h._body.decode("utf-8")) if getattr(h, "_body", b"") else {}
account = activate_account(body)
except (ValueError, TypeError) as error:
return 400, {"error": str(error)}
log(" ACCOUNT: selected %s/%r profile=%s coins=%s unopened=%s"
% (account["personaId"], account["personaName"], account["profilePath"],
account["coins"], account["unopenedPacks"]))
return 200, {"account": account, "status": "OK"}
def current_squad(): def current_squad():
"""The squad the client should see: the persisted one (item refs re-embedded """The squad the client should see: the persisted one (item refs re-embedded
from the club) or the seed ladder squad on first run. from the club) or the seed ladder squad on first run.
@@ -285,10 +402,16 @@ def user_info():
"actives": (current_squad().get("actives") or [])[:5], "actives": (current_squad().get("actives") or [])[:5],
}) })
# ---- the two side-effecting members, off by default (see _UI above) -------- # ---- the two side-effecting members, off by default (see _UI above) --------
if _UI in ("packs", "full"): # Ownership counters must reflect persistent inventory only. The catalogue
# grace row prevents StoreFront from deleting a group beneath its live reveal
# controller, but the pack was already consumed and must not remain in FIFA's
# cached unopened-pack count.
unopened_count = len(STORE.unopened_packs())
if unopened_count or _UI in ("packs", "full"):
# unopenedPacks(0x35e): after parsing preOrderPacks(0x24b)+recoveredPacks # unopenedPacks(0x35e): after parsing preOrderPacks(0x24b)+recoveredPacks
# (0x27b) the deser calls singleton->vtbl[0x4e0](preOrder + recovered). # (0x27b) the deser calls singleton->vtbl[0x4e0](preOrder + recovered).
info["unopenedPacks"] = {"preOrderPacks": 0, "recoveredPacks": 0} info["unopenedPacks"] = {"preOrderPacks": 0,
"recoveredPacks": unopened_count}
if _UI in ("roster", "full"): if _UI in ("roster", "full"):
# squadList(0x2d4) -> FUN_180142260 on singleton->vtbl[0x480]+0x30, i.e. it # squadList(0x2d4) -> FUN_180142260 on singleton->vtbl[0x480]+0x30, i.e. it
# fills the global squad-ROSTER model ("MY SQUADS" on the Squads screen). # fills the global squad-ROSTER model ("MY SQUADS" on the Squads screen).
@@ -936,13 +1059,30 @@ def quick_sell_route(h):
except Exception: except Exception:
body = {} body = {}
ids = [it.get("id") for it in (body.get("itemData") or []) if isinstance(it, dict)] ids = [it.get("id") for it in (body.get("itemData") or []) if isinstance(it, dict)]
# Live FIFA 17 bulk serializer FUN_180126f40 emits the singular atom
# `itemId` containing an array of int64 handles. Keep itemIds as a tolerant
# alias for old replay fixtures, but never rely on it for the retail client.
if not ids and isinstance(body.get("itemId"), list):
ids = body["itemId"]
if not ids and isinstance(body.get("itemIds"), list): if not ids and isinstance(body.get("itemIds"), list):
ids = body["itemIds"] ids = body["itemIds"]
ids = [int(i) for i in ids if isinstance(i, int) and i > 0]
sellable_before = {it.get("id") for it in STORE.purchased() + STORE.items()}
sold, coins = STORE.quick_sell(ids) sold, coins = STORE.quick_sell(ids)
if sold: if sold:
log(" QUICKSELL: sold %d card(s) for %d coins (total %d)" log(" QUICKSELL: sold %d card(s) for %d coins (total %d)"
% (sold, coins, STORE.coins())) % (sold, coins, STORE.coins()))
return 200, {} else:
log(" QUICKSELL: no requested ids were found; balance unchanged at %d"
% STORE.coins())
# FutDiscardCardServerResponse. `totalCredits` is the absolute post-sale
# wallet balance, not the sale delta. Only echo accounted-for IDs; duplicate
# or stale request handles must not be removed from the client model twice.
sold_ids = list(dict.fromkeys(iid for iid in ids if iid in sellable_before))
return 200, {
"items": [{"id": iid} for iid in sold_ids],
"totalCredits": STORE.coins(),
}
def _move_ack(req, moved): def _move_ack(req, moved):
@@ -1060,6 +1200,9 @@ def item_route(h):
G = r"/ut/game/[^/]+" G = r"/ut/game/[^/]+"
ROUTES = [ ROUTES = [
# Launcher control-plane endpoint. It is intentionally outside /ut so FIFA
# never calls it; launch is blocked unless this succeeds first.
(re.compile(r"^/openfut/account/sync$"), lambda m, h: account_sync_route(h)),
# ---- FUT item-definition endpoints (must precede generic /item, /user) ---- # ---- FUT item-definition endpoints (must precede generic /item, /user) ----
(re.compile(G + r"/item/resource"), lambda m, h: defs_route(h)), (re.compile(G + r"/item/resource"), lambda m, h: defs_route(h)),
(re.compile(G + r"/defid"), lambda m, h: defs_route(h)), (re.compile(G + r"/defid"), lambda m, h: defs_route(h)),
@@ -1082,12 +1225,10 @@ ROUTES = [
(re.compile(r"^/ut/auth"), lambda m, h: (200, auth_body(h))), (re.compile(r"^/ut/auth"), lambda m, h: (200, auth_body(h))),
(re.compile(r"^/ut/delete/auth"), lambda m, h: (200, {})), (re.compile(r"^/ut/delete/auth"), lambda m, h: (200, {})),
(re.compile(G + r"/settings"), lambda m, h: (200, SETTINGS)), (re.compile(G + r"/settings"), lambda m, h: (200, SETTINGS)),
# Device-trust ("phishing") flow. trusteddevice parser 0x18012a170 reads 4 # Device-trust ("phishing") flow. One handler owns its exact state machine,
# booleans by key-id 0x7e/0x117/0x19e/0x351; 0x351 == JSON key "trusted". # validation, persistence and redacted diagnostics; keep these above /user.
# Returning trusted=true makes FUT SKIP the security question. (re.compile(G + r"/phishing/(trusteddevice|validate|question)"),
(re.compile(G + r"/phishing/trusteddevice"), lambda m, h: (200, {"trusted": True})), lambda m, h: security_question_route(h)),
(re.compile(G + r"/phishing/validate"), lambda m, h: (200, {"token": "OPENFUT-TRUST-0000000000000000"})),
(re.compile(G + r"/phishing/question"), lambda m, h: (200, {"question": 0, "answer": "", "attempts": 5})),
(re.compile(G + r"/user/credits"), lambda m, h: credits_route(h)), (re.compile(G + r"/user/credits"), lambda m, h: credits_route(h)),
# ---- club/squad routes (2026-08-03: squad schema 0x18013d1f0 now reversed) ---- # ---- club/squad routes (2026-08-03: squad schema 0x18013d1f0 now reversed) ----
# /user, /squad and /userMassInfo serve real data again -- the squad object # /user, /squad and /userMassInfo serve real data again -- the squad object
@@ -1116,6 +1257,19 @@ ROUTES = [
# is composed by appending a suffix, so it is invisible to the request-template # is composed by appending a suffix, so it is invisible to the request-template
# table, and the generic /squad route below was swallowing it. MUST precede it. # table, and the generic /squad route below was swallowing it. MUST precede it.
(re.compile(G + r"/squad/mode/draft/state"), lambda m, h: draft_state_route(h)), (re.compile(G + r"/squad/mode/draft/state"), lambda m, h: draft_state_route(h)),
# Live-composed Draft URL, likewise invisible in the static request templates.
# It must precede generic /squad or squad_route answers {"id":0}, leaving the
# formation carousel empty even though FORMATION_DRAFT was accepted.
(re.compile(G + r"/squad/mode/\d+/draft/choices/formation"),
lambda m, h: draft_formation_choices_route(h)),
(re.compile(G + r"/squad/mode/\d+/draft/choices/captain"),
lambda m, h: draft_captain_choices_route(h)),
(re.compile(G + r"/squad/mode/\d+/draft/choices/player"),
lambda m, h: draft_player_choices_route(h)),
(re.compile(G + r"/squad/mode/\d+/draft/choices/manager"),
lambda m, h: draft_manager_choices_route(h)),
(re.compile(G + r"/squad/mode/\d+/draft/choose"),
lambda m, h: draft_choose_route(h)),
(re.compile(G + r"/purchase/mode/\d+/draft"), lambda m, h: draft_purchase_route(h)), (re.compile(G + r"/purchase/mode/\d+/draft"), lambda m, h: draft_purchase_route(h)),
(re.compile(G + r"/squad"), lambda m, h: squad_route(h)), (re.compile(G + r"/squad"), lambda m, h: squad_route(h)),
(re.compile(G + r"/match/keepalive"), lambda m, h: (204, None)), (re.compile(G + r"/match/keepalive"), lambda m, h: (204, None)),
@@ -1286,13 +1440,32 @@ def hub_data():
showed it) yet the TRANSFER LIST tile read '0 items / Selling 0' -- the tile reads showed it) yet the TRANSFER LIST tile read '0 items / Selling 0' -- the tile reads
hub.tradePile, not /tradePile/counts (which the tile never re-polls). All active hub.tradePile, not /tradePile/counts (which the tile never re-polls). All active
listings are 'selling'; none are 'sold'. count == selling == number of listings.""" listings are 'selling'; none are 'sold'. count == selling == number of listings."""
if _OPENED_PACK_GRACE:
log(" STORE: retiring %d opened-pack grace entry at hub"
% len(_OPENED_PACK_GRACE))
_OPENED_PACK_GRACE.clear()
if not HUBDATA: if not HUBDATA:
return {} return {}
players = len([i for i in STORE.items() if _is_player(i)]) players = len([i for i in STORE.items() if _is_player(i)])
auctions = len(STORE.listings()) auctions = len(STORE.listings())
log(" HUB: clubPlayers=%d auctionCount=%d selling=%d" % (players, auctions, auctions)) log(" HUB: clubPlayers=%d auctionCount=%d selling=%d" % (players, auctions, auctions))
return {"clubPlayers": players, "auctionCount": auctions, body = {"clubPlayers": players, "auctionCount": auctions,
"tradePile": {"count": auctions, "selling": auctions, "sold": 0}} "tradePile": {"count": auctions, "selling": auctions, "sold": 0}}
if _MODES:
# GetHubData's parser 0x180139610 recognises offlineSeason (atom 0x1ec)
# and passes it to 0x18013c3a0. The nested scalar fields are STRING
# getters, despite representing numbers. Omitting the object leaves the
# offline-season summary invalid and the UI aborts before requesting
# /season. The initial division matches season_list()/season_user(); the
# ten-game length is a live-test hypothesis, isolated behind FUT_MODES.
body["offlineSeason"] = {
"divisionId": "10",
"gamesPlayed": "0",
"points": "0",
"totalGames": "10",
"progressDataVersion": "0",
}
return body
# ---- club stats: the CLUB STATS panel, and probably the MY CLUB tile too ------ # ---- club stats: the CLUB STATS panel, and probably the MY CLUB tile too ------
@@ -2154,12 +2327,11 @@ def clientdata_route(h):
"""ut/%s/clientdata/<key> -- opaque client blob storage. """ut/%s/clientdata/<key> -- opaque client blob storage.
LIVE-OBSERVED: `PUT ut/game/fifa17/clientdata/userHubData` fires from the FUT LIVE-OBSERVED: `PUT ut/game/fifa17/clientdata/userHubData` fires from the FUT
hub (20:40 session). The client is storing its own hub state -- so the correct hub. Persist the client-owned blob so its matching GET can restore it. The PUT
server behaviour is to keep the blob and hand back exactly what was given, which acknowledgement remains the historical empty object: echoing the body was
is zero-risk by construction: we never synthesise a shape, we echo the client's exercised live with both observed values ([3,0] and [3,1]) and did not unlock
own bytes. Persisting it is also the most plausible route to the hub's offline Seasons or produce a subsequent /season request. No response schema has
"MANAGER TASKS 0/0" tile surviving a relaunch, since no FutGetObjectives class been recovered for SetTutData, so do not infer one from the request shape.
exists in the binary at all (§9) -- the tile state may simply live in this blob.
""" """
key = h.path.split("/clientdata/", 1)[-1].split("?")[0] or "default" key = h.path.split("/clientdata/", 1)[-1].split("?")[0] or "default"
if h.command in ("PUT", "POST"): if h.command in ("PUT", "POST"):
@@ -2231,10 +2403,17 @@ def season_user():
def tournament_list(): def tournament_list():
"""GET ut/%s/tournament -- FutTournamentList, deser 0x180169ef0 (MEDIUM). """GET ut/%s/tournament -- object wrapper parsed at 0x18016b220 (HIGH).
ARRAY root; rounds/prizeSet/staff/kit atoms are nested FREEZE-RISK -> omitted."""
return [{"id": 1, "difficulty": 1, "coins": 500, "rewardMultiplier": 1, The response parser recognizes only tournament(0x328), opens its ARRAY, then
"assetName": "", "eligibilityOperation": ""}] invokes the element parser at 0x180169ef0. A bare array populates nothing.
rounds(0x292) and elgReq(0xf7) are nested ARRAY loops and remain omitted.
The wrapper/root shape is recovered; element semantics remain live-unverified.
"""
return {"tournament": [
{"id": 1, "difficulty": 1, "coins": 500, "rewardMultiplier": 1,
"assetName": "", "eligibilityOperation": ""},
]}
def tournament_user(): def tournament_user():
@@ -2522,25 +2701,214 @@ def sbc_tag_route(h):
# Draft cannot be entered at all today. FUT_DRAFT_STATE=0 restores the old routing if # Draft cannot be entered at all today. FUT_DRAFT_STATE=0 restores the old routing if
# this turns out to be wrong. # this turns out to be wrong.
DRAFT_STATE = os.environ.get("FUT_DRAFT_STATE", "1") == "1" DRAFT_STATE = os.environ.get("FUT_DRAFT_STATE", "1") == "1"
# Modes that successfully passed the entry-purchase response in this server process.
# Keep this volatile until the complete draft lifecycle (including abandon/rewards)
# is implemented; persisting a half-built draft would make recovery harder.
_DRAFT_SESSIONS = {}
def _draft_squad(session):
"""A Draft-owned squad model, separate from STORE's regular active squad."""
squad = copy.deepcopy(SQUAD)
squad.update({
"id": 0,
"personaId": ACCOUNT.persona_id,
"squadName": "My Draft",
"formation": session.get("formation", "f442"),
"squadType": "DRAFT_SQUAD",
"chemistry": 0,
"starRating": 0,
"captain": 0,
"manager": ([{
"id": session["manager"].get("id", 0),
"itemData": copy.deepcopy(session["manager"]),
"dream": False,
}] if session.get("manager") else []),
})
# SQUAD is currently the empty, schema-proven seed, but explicitly stripping
# itemData prevents a future seed-mode change from leaking the regular XI here.
selected = session.get("selected", {})
squad["players"] = []
for index in range(23):
player = {"index": index, "kitNumber": 0}
if index in selected:
player["itemData"] = copy.deepcopy(selected[index])
squad["players"].append(player)
captain_slot = session.get("captain_slot")
if captain_slot in selected:
squad["captain"] = selected[captain_slot].get("id", 0)
return squad
def draft_state_route(h): def draft_state_route(h):
if not DRAFT_STATE: if not DRAFT_STATE:
return squad_route(h) return squad_route(h)
m = re.search(r"[?&]mode=([^&]+)", h.path)
mode = m.group(1) if m else ""
session = _DRAFT_SESSIONS.get(mode)
purchased = session is not None
return 200, [{ return 200, [{
"squadState": "INVALID", # 0x2d5 STRING enum # Atom-table-backed enum consumed by FUN_180147070. After a successful
# entry purchase FIFA's next legitimate stage is formation selection.
"squadState": session.get("stage", "FORMATION_DRAFT") if purchased else "INVALID",
"stateParam1": "INVALID", # STRING "stateParam1": "INVALID", # STRING
"stateParam2": "0", # STRING (the int getter also accepts it) "stateParam2": "0", # STRING (the int getter also accepts it)
"gamesWonCurrentMatch": 0, # INT "gamesWonCurrentMatch": 0, # INT
"roundsInfo": [], # array of the 7-scalar element; empty is safe "roundsInfo": [], # array of the 7-scalar element; empty is safe
**({"squad": _draft_squad(session)} if purchased else {}),
# entranceCriteria: OMITTED. Shape known, not needed, skip-safe. # entranceCriteria: OMITTED. Shape known, not needed, skip-safe.
}] }]
def draft_formation_choices_route(h):
"""Return the first Draft round: a formation carousel.
FutGetDraftChoicesServerResponse (FUN_18014f2d0) consumes an object root with
choices[] records. Formation records use only index + formation; itemData is
reserved for later player/manager rounds.
"""
log(" DRAFT: serving formation choices")
return 200, {
"positionid": 0,
"tier": 1,
"choices": [
{"index": 0, "formation": "f442"},
{"index": 1, "formation": "f433"},
],
}
def draft_captain_choices_route(h):
"""Offer five known-good player cards for the captain round."""
candidates = [player for player in fut_cards.POOL if player[1] >= 84]
cards = [player_item(800000000 + index, player, special=random.random() < 0.20)
for index, player in enumerate(random.sample(candidates, 5))]
m = re.search(r"/squad/mode/(\d+)/draft/", h.path)
mode = "SINGLE_PLAYER" if (m and m.group(1) == "1") else "ONLINE"
session = _DRAFT_SESSIONS.setdefault(mode, {"stage": "CAPTAIN_DRAFT"})
session["pending_choices"] = cards
log(" DRAFT: serving %d captain choices" % len(cards))
return 200, {
"positionid": 0,
"tier": 1,
"choices": [
{"index": index, "itemData": card}
for index, card in enumerate(cards)
],
}
def draft_player_choices_route(h):
"""Offer a player round for the slot requested by the Draft UI."""
try:
body = json.loads(h._body.decode()) if getattr(h, "_body", b"") else {}
except Exception:
body = {}
position_id = int(body.get("positionId", body.get("positionid", 0)))
m = re.search(r"/squad/mode/(\d+)/draft/", h.path)
mode = "SINGLE_PLAYER" if (m and m.group(1) == "1") else "ONLINE"
session = _DRAFT_SESSIONS.setdefault(mode, {"stage": "PLAYER_DRAFT"})
selected_assets = {
card.get("assetId") for card in session.get("selected", {}).values()
}
def draft_manager_choices_route(h):
"""Offer five verified FIFA 17 managercards for the final Draft round."""
m = re.search(r"/squad/mode/(\d+)/draft/", h.path)
mode = "SINGLE_PLAYER" if (m and m.group(1) == "1") else "ONLINE"
session = _DRAFT_SESSIONS.setdefault(mode, {"stage": "MANAGER_DRAFT"})
manager_ids = random.sample(fut_staff.STARTER_MANAGERS, 5)
cards = [fut_staff.manager_item(800200000 + index, carddbid)
for index, carddbid in enumerate(manager_ids)]
session["stage"] = "MANAGER_DRAFT"
session["pending_choices"] = cards
session["pending_position"] = 23
log(" DRAFT: serving %d manager choices" % len(cards))
return 200, {
"positionid": 23,
"tier": 1,
"choices": [
{"index": index, "itemData": card}
for index, card in enumerate(cards)
],
}
# Prefer the requested slot's broad position family. If FIFA sends only a
# numeric squad slot (as observed), the client still enforces chemistry/fit;
# offering varied high-quality players is safer than inventing a slot map for
# every formation. Five unique assets are guaranteed per carousel.
candidates = [player for player in fut_cards.POOL
if player[1] >= 75 and player[0] not in selected_assets]
picks = random.sample(candidates, 5)
draft_seq = session.get("draft_item_seq", 0)
cards = [player_item(800100000 + draft_seq + index, player,
special=random.random() < 0.12)
for index, player in enumerate(picks)]
session["draft_item_seq"] = draft_seq + len(cards)
session["pending_choices"] = cards
session["pending_position"] = position_id
log(" DRAFT: serving %d player choices for slot %d"
% (len(cards), position_id))
return 200, {
"positionid": position_id,
"tier": 1,
"choices": [
{"index": index, "itemData": card}
for index, card in enumerate(cards)
],
}
def draft_choose_route(h):
"""Acknowledge a pick and advance the volatile Draft state machine."""
try:
body = json.loads(h._body.decode()) if getattr(h, "_body", b"") else {}
except Exception:
body = {}
m = re.search(r"/squad/mode/(\d+)/draft/choose", h.path)
mode_id = int(m.group(1)) if m else 0
mode = "SINGLE_PLAYER" if mode_id == 1 else "ONLINE"
session = _DRAFT_SESSIONS.setdefault(mode, {"stage": "FORMATION_DRAFT"})
if session.get("stage") == "FORMATION_DRAFT":
formations = ("f442", "f433")
choice = body.get("choiceIndex", 0)
session["formation"] = formations[choice] if choice in range(len(formations)) else "f442"
session["stage"] = "CAPTAIN_DRAFT"
log(" DRAFT: chose formation %s; advancing to captain"
% session["formation"])
elif session.get("stage") in ("CAPTAIN_DRAFT", "PLAYER_DRAFT", "MANAGER_DRAFT"):
choice_index = int(body.get("choiceIndex", 0))
position_id = int(body.get("positionId", session.get("pending_position", 0)))
choices = session.get("pending_choices", [])
if 0 <= choice_index < len(choices):
session.setdefault("selected", {})[position_id] = copy.deepcopy(
choices[choice_index]
)
if session.get("stage") == "CAPTAIN_DRAFT":
session["captain_slot"] = position_id
session["stage"] = "PLAYER_DRAFT"
log(" DRAFT: chose captain for slot %d; advancing to players"
% position_id)
elif session.get("stage") == "MANAGER_DRAFT":
session["manager"] = copy.deepcopy(choices[choice_index])
session["stage"] = "COMPLETED_DRAFT"
log(" DRAFT: chose manager; draft squad is complete")
else:
log(" DRAFT: chose player for slot %d" % position_id)
else:
log(" DRAFT: rejected out-of-range choice %d at slot %d"
% (choice_index, position_id))
else:
log(" DRAFT: acknowledged pick at stage %s body=%s"
% (session.get("stage"), json.dumps(body)))
# FutPickDraftChoiceServerResponse uses the generic no-field response parser.
return 200, {}
# ---- Draft entry purchase ---------------------------------------------------- # ---- Draft entry purchase ----------------------------------------------------
# POST ut/%s/purchase/mode/{price}/draft body {"currency":"COINS","usePreOrder":0} # POST ut/%s/purchase/mode/{mode}/draft body {"currency":"COINS","usePreOrder":0}
# -> FutPurchaseDraftModeServerResponse. "Buys" entry into draft mode and returns # -> FutPurchaseDraftModeServerResponse. The path component is the draft mode
# the fresh draft session summary. # (1 for SINGLE_PLAYER), not the entry price.
# #
# LIVE 2026-08-04: this endpoint was UNMAPPED, answered {} by the catch-all, and the # LIVE 2026-08-04: this endpoint was UNMAPPED, answered {} by the catch-all, and the
# client CRASHED immediately after. Sequence, from the log: # client CRASHED immediately after. Sequence, from the log:
@@ -2552,33 +2920,22 @@ def draft_state_route(h):
# unimplemented call, which is the outcome a correct fix is supposed to have. # unimplemented call, which is the outcome a correct fix is supposed to have.
# #
# WHICH ENVELOPE. ENDPOINT_MAP flags a "response-variant ambiguity" here: two # WHICH ENVELOPE. ENDPOINT_MAP flags a "response-variant ambiguity" here: two
# structures reference the class name. Resolved this session, and the doc's note about # structures reference the class name. Live behaviour plus the request vtable resolves
# the second one is wrong: # the POST to the second variant:
# 0x18014c260 (vtable 0x180224ef8, factory 0x18014c090) 3188 chars, OBJECT root # 0x18014c260 (vtable 0x180224ef8, factory 0x18014c090) 3188 chars, OBJECT root
# (prologue tests != 10 = END_OBJECT), 1 skip-handler call, and exactly # (prologue tests != 10 = END_OBJECT), 1 skip-handler call, and seven
# the seven scalar ints below. THIS IS THE RESPONSE PARSER. # scalar ints. This is a distinct response using the same class name.
# 0x180150310 (vtable 0x1802262f0, factory 0x180150260) 1836 chars, ARRAY root # 0x180150310 (vtable 0x1802262f0, factory 0x180150260) 1836 chars, ARRAY root
# (loops until 0xd = END_ARRAY), ZERO skip handlers -- and it is not a # (loops until 0xd = END_ARRAY), ZERO skip handlers. Each element is
# response root at all. It parses ENTRANCE CRITERIA: each element's # parsed by FUN_180138bd0 as name/funds/finalFunds, then name is compared
# name is strcmp'd against the literals "COINS", "POINTS" and # with "COINS", "POINTS", and "DRAFT_TOKEN". Request vtable
# "DRAFT_TOKEN" and stored at +0x28/+0x2c/+0x30. It shares the name # 0x180226300 selects factory 0x180150260 for the live purchase POST.
# string because it is the fee sub-object, not an alternate envelope.
# #
# THE CRASH ITSELF DISCRIMINATES, which is worth recording as a technique. An # LIVE 2026-08-07: returning the seven-int object made FIFA consume the POST (HTTP
# object-root parser handed {} parses benignly and leaves defaults; an array-root # 200), issue no follow-up request, and spin at high CPU. That is the array parser's
# parser handed {} desyncs and HANGS, which is exactly what draft/state did before it # exact EOF-loop signature. The response below therefore uses the required array root.
# was fixed. We observed a CRASH, not a hang, so the object-root parser is what ran, # No coins are deducted yet: the emulator has not served a verified entrance price,
# and the failure is downstream of an empty-but-valid parse. That is consistent with # and the path's mode id must not be mistaken for a price.
# 0x18014c260 and inconsistent with 0x180150310.
#
# All seven members are scalar ints and the skip handler is present, so unknown keys
# are inert and there is no freeze surface here.
#
# NOT DEDUCTED FROM COINS. The client posts {"currency":"COINS"} with the price in the
# URL, and the price it sent was 0 because we omit entranceCriteria from draft/state,
# so there is no fee to charge yet. Charging a guessed amount would be inventing an
# economy rule; when entranceCriteria is served the price becomes real and this is the
# place to take it.
# #
# DEFAULT ON for the same reason as FUT_DRAFT_STATE: the current behaviour is a # DEFAULT ON for the same reason as FUT_DRAFT_STATE: the current behaviour is a
# confirmed crash, so there is no working state being protected. # confirmed crash, so there is no working state being protected.
@@ -2593,18 +2950,18 @@ def draft_purchase_route(h):
# calling .group() on the first argument. Doing that raised AttributeError, # calling .group() on the first argument. Doing that raised AttributeError,
# which killed the connection outright -- strictly worse than the {} it replaced. # which killed the connection outright -- strictly worse than the {} it replaced.
m = re.search(r"/purchase/mode/(\d+)/draft", h.path) m = re.search(r"/purchase/mode/(\d+)/draft", h.path)
price = int(m.group(1)) if m else 0 mode = int(m.group(1)) if m else 0
log(" DRAFT: purchase entry, price=%d (not deducted -- no entranceCriteria " mode_name = "SINGLE_PLAYER" if mode == 1 else "ONLINE"
"served yet, so the client posted its own price)" % price) _DRAFT_SESSIONS[mode_name] = {"stage": "FORMATION_DRAFT", "formation": "f442"}
return 200, { coins = STORE.coins()
"championEventId": 0, points = STORE.profile().get("points", 0)
"expectedTierLevel": 1, log(" DRAFT: purchase entry, mode=%d; returning array-root currency result "
"gamesPlayed": 0, "(entry fee not deducted until entrance criteria are verified)" % mode)
"gamesRemaining": 4, # a draft run is 4 rounds return 200, [
"rank": 0, {"name": "COINS", "funds": coins, "finalFunds": coins},
"score": 0, {"name": "POINTS", "funds": points, "finalFunds": points},
"tierLevel": 1, {"name": "DRAFT_TOKEN", "funds": 0, "finalFunds": 0},
} ]
def champion_route(h): def champion_route(h):
@@ -2759,14 +3116,46 @@ def destroy_match_body(result, coins, total):
return body return body
def _match_call(path, method, body):
"""Classify one of CardsDLL's six match calls.
The RPC descriptor block gives READY/END/RESET/KEEPALIVE explicit suffixes.
CREATEMATCH and PLAYGAME both use the bare ``ut/%s/match`` path; CardsDLL
serializes atom ``matchId`` as an integer for operations on an existing
match, which is the discriminator for PLAYGAME. HTTP verbs are intentionally
not used for that pair because method selection lives outside CardsDLL.
"""
clean = path.split("?", 1)[0].rstrip("/")
for suffix, call in (("/ready", "ready"), ("/end", "end"),
("/reset", "reset"), ("/keepalive", "keepalive")):
if clean.endswith(suffix):
return call
if method == "DELETE" or "/ut/delete/" in clean:
return "end"
if isinstance(body, dict) and isinstance(body.get("matchId"), int):
return "play"
return "create"
def match_ready_body(match_id, opponent_persona_id):
"""Minimal FutMatchReadyServerResponse (CardsDLL parser 0x1801205d0).
The parser has scalar ``matchId`` and ``opponentPersonaId`` members plus a
nested ``items`` member. The latter remains omitted until its opponent-squad
item contract is recovered; unrecognized/absent members are skip-safe.
"""
return {"matchId": int(match_id),
"opponentPersonaId": int(opponent_persona_id)}
def match_route(h): def match_route(h):
"""POST create / PUT ready / POST play / DELETE destroy(+rewards).""" """Create / ready / play / destroy(+rewards) on CardsDLL's match paths."""
try: try:
body = json.loads(h._body.decode("utf-8")) if getattr(h, "_body", b"") else {} body = json.loads(h._body.decode("utf-8")) if getattr(h, "_body", b"") else {}
except Exception: except Exception:
body = {} body = {}
m = re.search(r"/match/(\d+)", h.path) m = re.search(r"/match/(\d+)", h.path)
match_id = int(m.group(1)) if m else None url_match_id = int(m.group(1)) if m else None
# THE REAL URLS, from the RPC descriptor block (rows 49-54, all using template # THE REAL URLS, from the RPC descriptor block (rows 49-54, all using template
# index 16 = `ut/%s/match`, each appending a fixed suffix via the params object # index 16 = `ut/%s/match`, each appending a fixed suffix via the params object
# at slot +0x08): CREATEMATCH and PLAYGAME append nothing, MATCHREADY `/ready`, # at slot +0x08): CREATEMATCH and PLAYGAME append nothing, MATCHREADY `/ready`,
@@ -2783,10 +3172,11 @@ def match_route(h):
# and accept any verb. A reviewer specifically flagged the claim "the reward path # and accept any verb. A reviewer specifically flagged the claim "the reward path
# can never fire" as overreach on exactly this point, since the verb is unknown # can never fire" as overreach on exactly this point, since the verb is unknown
# rather than known-wrong, so this widens the gate instead of replacing it. # rather than known-wrong, so this widens the gate instead of replacing it.
is_delete = (h.command == "DELETE" or "/ut/delete/" in h.path call = _match_call(h.path, h.command, body)
or (MATCH_END and h.path.split("?")[0].endswith("/match/end"))) body_match_id = body.get("matchId") if isinstance(body, dict) else None
match_id = body_match_id if isinstance(body_match_id, int) else url_match_id
if is_delete: if call == "end":
# FutDestroyMatch -- the ONLY place a match awards anything. # FutDestroyMatch -- the ONLY place a match awards anything.
result, score = _match_result(body) result, score = _match_result(body)
coins = MATCH_COINS.get(result, 0) + MATCH_PARTICIPATION coins = MATCH_COINS.get(result, 0) + MATCH_PARTICIPATION
@@ -2796,15 +3186,24 @@ def match_route(h):
rec["won"], rec["draw"], rec["loss"])) rec["won"], rec["draw"], rec["loss"]))
return 200, destroy_match_body(result, coins, total) return 200, destroy_match_body(result, coins, total)
if h.command == "POST" and match_id is None: if call == "create":
# FutCreateMatch. `squad` is nested + freeze-risky -> omitted (SKIP-safe). # FutCreateMatch. `squad` is nested + freeze-risky -> omitted (SKIP-safe).
mid = STORE.new_item_id() mid = STORE.new_item_id()
log(" MATCH: created id=%d" % mid) log(" MATCH: created id=%d" % mid)
return 200, {"startDateTime": int(datetime.datetime.now().timestamp()), return 200, {"startDateTime": int(datetime.datetime.now().timestamp()),
"reportIdEnabled": False, "id": mid} "reportIdEnabled": False, "id": mid}
# PUT {id} = MatchReady, POST {id} = PlayGame. Both have NO deserializer at if call == "ready":
# all, so {} is a complete response; the result is claimed on destroy. # FutMatchReadyServerResponse has two scalar IDs and an optional nested
# item list. Preserve an explicit opponent supplied by the request. For
# offline AI the value is not yet live-confirmed; zero is deliberately a
# TODO/CONFIRM neutral placeholder, never the selected user's persona.
opponent_id = body.get("opponentPersonaId", 0) if isinstance(body, dict) else 0
if not isinstance(opponent_id, int):
opponent_id = 0
return 200, match_ready_body(match_id or 0, opponent_id)
# FutPlayGameServerResponse has no parsed fields. The result is claimed on end.
if body: if body:
log(" MATCH: %s %s body=%s" % (h.command, h.path, json.dumps(body)[:400])) log(" MATCH: %s %s body=%s" % (h.command, h.path, json.dumps(body)[:400]))
return 200, {} return 200, {}
@@ -2866,7 +3265,7 @@ def _probe_final_funds(p):
return p["price"] return p["price"]
def _pack_body(p, idx): def _pack_body(p, idx, owned=False):
"""One entry of FutStoreGetPackTypes.purchase (element deser 0x18013af30). """One entry of FutStoreGetPackTypes.purchase (element deser 0x18013af30).
THIS IS THE ORIGINAL, KNOWN-GOOD BODY -- restored 2026-08-04 after my "field THIS IS THE ORIGINAL, KNOWN-GOOD BODY -- restored 2026-08-04 after my "field
@@ -2922,10 +3321,20 @@ def _pack_body(p, idx):
"goldQuantity": p["count"] if gold else 0, "goldQuantity": p["count"] if gold else 0,
"rareQuantity": p["count"] if gold else 0, "rareQuantity": p["count"] if gold else 0,
"itemQuantity": p["count"], "itemQuantity": p["count"],
"unopened": False,
}, },
# This is a top-level BOOL in the 0x158-byte pack record. Nesting it in
# packContentInfo (the old code) is skip-safe but completely inert.
"unopened": bool(owned),
} }
if STORE_DISPLAYGROUP: if owned:
# Reward packs are opened with usePreOrder=1 and have no purchase path.
# Leaving zero-value coin/mtx objects attached makes the My Packs tile
# format an unavailable payment label as the literal "undefined".
body.pop("currencies", None)
body.pop("extPrice", None)
if owned:
body["displayGroup"] = {"value": "mypacks", "priority": idx}
elif STORE_DISPLAYGROUP:
# THE "unknown" FIX. displayGroup(0xd9) is parsed INLINE as a FLAT OBJECT -- # THE "unknown" FIX. displayGroup(0xd9) is parsed INLINE as a FLAT OBJECT --
# it is NOT recursive, the case-0xd9 body never re-enters 0x18013af30, so the # it is NOT recursive, the case-0xd9 body never re-enters 0x18013af30, so the
# recursion the old notes assumed does not exist and there was never anything # recursion the old notes assumed does not exist and there was never anything
@@ -2955,7 +3364,19 @@ def _pack_body(p, idx):
# than a caption, the store goes from ugly-but-working to unusable. Default OFF # than a caption, the store goes from ugly-but-working to unusable. Default OFF
# for exactly that reason, and the live test buys a pack to prove the buy path # for exactly that reason, and the live test buys a pack to prove the buy path
# still works. # still works.
body["displayGroup"] = {"value": p["name"]} # FIFA 17's StoreFront does not treat this value as an arbitrary caption.
# It resolves exactly six hard-coded category tokens: mypacks, points,
# bronze, silver, gold and special (FUN_180014580/FUN_180014df0). Pack
# titles here create unsupported pseudo-categories and make GOTO_STORE_MYPACK
# initially land on the all-groups screen. Keep ordinary packs in the
# client's canonical categories; `description` remains the per-pack title.
if p.get("specialChance", 0.0) >= 1.0:
category = "special"
elif gold:
category = "gold"
else:
category = "bronze"
body["displayGroup"] = {"value": category}
# FUT_STORE_GROUPID. The risk flagged above ACTUALLY HAPPENED, live 2026-08-05: # FUT_STORE_GROUPID. The risk flagged above ACTUALLY HAPPENED, live 2026-08-05:
# sending displayGroup did switch the store to a grouped render path, all three # sending displayGroup did switch the store to a grouped render path, all three
# packs collapsed into ONE group, and drilling into any of the three group tiles # packs collapsed into ONE group, and drilling into any of the three group tiles
@@ -2997,7 +3418,32 @@ def store_catalog(h):
consumed -- an infinite loop inside FUN_1801c7f10, whose body contains the spin PC consumed -- an infinite loop inside FUN_1801c7f10, whose body contains the spin PC
0x1801c7f1a that was observed live. Not a mystery freeze; a traced one. 0x1801c7f1a that was observed live. Not a mystery freeze; a traced one.
""" """
packs = [_pack_body(p, idx) for idx, p in enumerate(PACK_CATALOG, start=1)] normal = [p for p in PACK_CATALOG if not p.get("ownedOnly")]
packs = [_pack_body(p, idx) for idx, p in enumerate(normal, start=1)]
owned_ids = visible_unopened_packs()
for idx, pack_id in enumerate(owned_ids, start=1):
owned = pack_by_id(pack_id)
if owned:
packs.append(_pack_body(owned, idx, owned=True))
if not owned_ids:
# GOTO_STORE_MYPACK resolves the hard-coded `mypacks` group before it
# renders rows. If the group is absent FIFA falls back to Bronze and
# shows the empty-category dialog over the wrong tab. Retain an inactive
# zero-item sentinel so the destination resolves, while state != active
# keeps it out of the visible row list. Its id is deliberately absent
# from PACK_CATALOG, so both purchase/open handlers reject it as well.
sentinel = {
"id": 65534,
"name": "",
"price": 0,
"count": 0,
"gold": True,
"specialChance": 0.0,
}
empty = _pack_body(sentinel, 1, owned=True)
empty["state"] = "inactive"
empty["unopened"] = False
packs.append(empty)
return 200, {"purchase": packs, "timestamp": 1596326400} return 200, {"purchase": packs, "timestamp": 1596326400}
@@ -3015,10 +3461,11 @@ def store_buy(h):
if body.get("state") == "TRANSACTIONCANCEL" or not isinstance(pid, int): if body.get("state") == "TRANSACTIONCANCEL" or not isinstance(pid, int):
return 200, {} # not a confirmed buy return 200, {} # not a confirmed buy
pack = pack_by_id(pid) pack = pack_by_id(pid)
if not pack: if not pack or pack.get("ownedOnly"):
return 200, {} return 200, {}
items = STORE.open_pack(pack["price"], pack["count"], pack["gold"], items = STORE.open_pack(pack["price"], pack["count"], pack["gold"],
pack.get("tiers")) pack.get("tiers"), pack.get("specialChance", 0.0),
pack.get("playersOnly", False))
if items is None: if items is None:
return 461, {"reason": "insufficient_coins", "credits": STORE.coins()} return 461, {"reason": "insufficient_coins", "credits": STORE.coins()}
log(" STORE: opened pack %s -> %d items, coins=%d" % (pack["name"], len(items), STORE.coins())) log(" STORE: opened pack %s -> %d items, coins=%d" % (pack["name"], len(items), STORE.coins()))
@@ -3047,10 +3494,21 @@ def purchased_items(h):
pack = pack_by_id(pid) if isinstance(pid, int) else None pack = pack_by_id(pid) if isinstance(pid, int) else None
if pack is None: if pack is None:
return 200, {"itemData": STORE.last_pack()} return 200, {"itemData": STORE.last_pack()}
if pack.get("ownedOnly") and not STORE.consume_unopened_pack(pid):
log(" STORE: rejected unopened pack %s; no owned instance" % pid)
return 200, {"itemData": STORE.last_pack()}
items = STORE.open_pack(pack["price"], pack["count"], pack["gold"], items = STORE.open_pack(pack["price"], pack["count"], pack["gold"],
pack.get("tiers")) pack.get("tiers"), pack.get("specialChance", 0.0),
pack.get("playersOnly", False))
if items is None: if items is None:
return 461, {"reason": "insufficient_coins", "credits": STORE.coins()} return 461, {"reason": "insufficient_coins", "credits": STORE.coins()}
# FIFA always returns to its hard-coded `mypacks` group after the reveal,
# including for an ordinary coin-purchased pack. Keep one owned-shaped
# catalogue copy alive until the next hub request; otherwise that group
# contains only the inactive sentinel and FIFA shows "The pack you've
# selected is currently not available" after a successful opening.
if pid not in _OPENED_PACK_GRACE:
_OPENED_PACK_GRACE.append(pid)
log(" STORE: POST /purchased opened pack %s -> %d items, coins=%d" log(" STORE: POST /purchased opened pack %s -> %d items, coins=%d"
% (pack["name"], len(items), STORE.coins())) % (pack["name"], len(items), STORE.coins()))
if PACK_AUTOCLUB: if PACK_AUTOCLUB:
@@ -3072,13 +3530,20 @@ def credits_route(h):
# The FUT hub coin counter binds to currencies[].funds (deser 0x180122c50, # The FUT hub coin counter binds to currencies[].funds (deser 0x180122c50,
# atom "currencies" 0xc5), NOT a "credits" key -- wf_76fcf89b. # atom "currencies" 0xc5), NOT a "credits" key -- wf_76fcf89b.
c = STORE.coins() c = STORE.coins()
return 200, { body = {
"credits": c, "credits": c,
"currencies": [ "currencies": [
{"name": "coins", "funds": c, "finalFunds": c}, {"name": "coins", "funds": c, "finalFunds": c},
{"name": "points", "funds": 0, "finalFunds": 0}, {"name": "points", "funds": 0, "finalFunds": 0},
], ],
} }
# Do not count _OPENED_PACK_GRACE here: it is a UI-lifetime catalogue shim,
# not an owned pack. Reporting it would leave the My Packs badge stuck at 1.
unopened_count = len(STORE.unopened_packs())
if unopened_count:
body["unopenedPacks"] = {"preOrderPacks": 0,
"recoveredPacks": unopened_count}
return 200, body
# ---- TRANSFER MARKET / AUCTION HOUSE (ENDPOINT_MAP market §) ---------------- # ---- TRANSFER MARKET / AUCTION HOUSE (ENDPOINT_MAP market §) ----------------
@@ -3264,9 +3729,9 @@ class H(http.server.BaseHTTPRequestHandler):
n = int(self.headers.get("Content-Length", 0) or 0) n = int(self.headers.get("Content-Length", 0) or 0)
body = self.rfile.read(n) if n else b"" body = self.rfile.read(n) if n else b""
self._body = body # route fns (squad PUT) read this self._body = body # route fns (squad PUT) read this
log("%s %s" % (self.command, self.path)) log("%s %s" % (self.command, safe_request_path(self.path)))
for k, v in self.headers.items(): for k, v in self.headers.items():
log(" %s: %s" % (k, v)) log(" %s: %s" % (k, safe_header_for_log(k, v)))
if body: if body:
log(" body: %s" % body[:65536].decode("utf-8", "replace")) log(" body: %s" % body[:65536].decode("utf-8", "replace"))