Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6b8b8e052f | |||
| 3153a93edf | |||
| f64106ed8b | |||
| 9faaf12dd7 | |||
| 83539e33ec | |||
| 695421cfd4 | |||
| 8cba70dc90 | |||
| 28773e7cf1 | |||
| 3ae5587a38 | |||
| 70a64e3709 |
@@ -27,3 +27,9 @@ __pycache__/
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Frozen baseline archives / inspects / manifests
|
||||
/docker-backups/
|
||||
|
||||
# local dev screenshots (not versioned)
|
||||
fifa17-recon/.screens/
|
||||
|
||||
Generated
+5
@@ -3156,6 +3156,10 @@ dependencies = [
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openfut-common"
|
||||
version = "0.1.0"
|
||||
|
||||
[[package]]
|
||||
name = "openfut-core"
|
||||
version = "0.1.0"
|
||||
@@ -3184,6 +3188,7 @@ dependencies = [
|
||||
name = "openfut-hook"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"openfut-common",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
|
||||
@@ -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
|
||||
before testing the assumption under it. Hypotheses that come with a cheap way to
|
||||
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.
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env python3
|
||||
"""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."""
|
||||
import glob, time, struct
|
||||
import glob, time, struct, sys
|
||||
|
||||
# Watch for a (re)launched FIFA17.exe and auto-apply ProtoSSL cert + FUT store patches
|
||||
import glob, time, os
|
||||
@@ -24,7 +24,46 @@ STORE_PATCHES = {
|
||||
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):
|
||||
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:
|
||||
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()
|
||||
store_patched=set()
|
||||
guard_reported=set()
|
||||
|
||||
log("=== AUTOPATCH watching for FIFA17.exe ===")
|
||||
while True:
|
||||
for pid in find_pids():
|
||||
if pid not in patched:
|
||||
try:
|
||||
g2=rd(pid,GATE2,3); g1=rd(pid,GATE1,6)
|
||||
except Exception:
|
||||
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:
|
||||
if __name__ == "__main__":
|
||||
launcher_pid = None
|
||||
if "--launcher-pid" in sys.argv:
|
||||
try: launcher_pid = int(sys.argv[sys.argv.index("--launcher-pid") + 1])
|
||||
except (ValueError, IndexError): raise SystemExit("invalid --launcher-pid")
|
||||
|
||||
log("=== AUTOPATCH watching for FIFA17.exe ===")
|
||||
while True:
|
||||
if launcher_pid and not os.path.exists(f"/proc/{launcher_pid}"):
|
||||
log(f"launcher pid {launcher_pid} exited; stopping autopatch")
|
||||
break
|
||||
for pid in find_pids():
|
||||
if pid not in patched:
|
||||
try:
|
||||
wr(pid,GATE2,GATE2_PATCH); wr(pid,GATE1,GATE1_PATCH)
|
||||
log(f"pid {pid}: PATCHED cert gates")
|
||||
patched.add(pid)
|
||||
g2=rd(pid,GATE2,3); g1=rd(pid,GATE1,6)
|
||||
except Exception:
|
||||
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:
|
||||
log(f"pid {pid}: cert patch write failed: {e}")
|
||||
log(f"pid {pid}: store 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}")
|
||||
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)
|
||||
time.sleep(1)
|
||||
|
||||
@@ -128,14 +128,52 @@ CLIENT_ID = ACCOUNT.CLIENT_ID
|
||||
PLATFORM = ACCOUNT.PLATFORM
|
||||
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
|
||||
BLAZE_PORT = 42130
|
||||
NUCLEUS_PORT = 42131
|
||||
BLAZE_IP_STR = "127.0.0.1"
|
||||
BLAZE_IP_U32 = (127 << 24) | 1
|
||||
BLAZE_IP_STR = _ADVERTISE
|
||||
BLAZE_IP_U32 = _ip_str_to_u32(_ADVERTISE)
|
||||
LOG = "/tmp/blaze_responder.log"
|
||||
RXDIR = "/tmp/blaze_rx"
|
||||
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.
|
||||
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()
|
||||
|
||||
@@ -525,7 +565,8 @@ OSDK_TICKER = []
|
||||
# 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
|
||||
# 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 = [
|
||||
("ROSTERUPDATE_URL", "https://%s/fifa17/fut/rosterupdate.xml" % ROSTER_HOST),
|
||||
("ROSTER_URL", "https://%s/fifa17/roster/" % ROSTER_HOST), # @0x143973aa0
|
||||
@@ -562,7 +603,6 @@ IDENTITY_PARAMS = [
|
||||
# FUT_POW=1 ./openfut-fut.sh restart
|
||||
# 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_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")
|
||||
OSDK_POW = [
|
||||
("FIFA_POW_URL", "http://%s/" % POW_HOST),
|
||||
@@ -571,6 +611,14 @@ OSDK_POW = [
|
||||
("POW_IS_ON", "1"),
|
||||
] 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 = {
|
||||
"BlazeSDK": None, # built dynamically, see below
|
||||
"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/"
|
||||
# (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).
|
||||
UTAS_BASE = "http://127.0.0.1:8099/"
|
||||
UTAS_BASE = "http://%s:8099/" % _ADVERTISE
|
||||
FUT_RS4_MODULES = [
|
||||
"AUCTIONHOUSE", "CLUB_USER", "CLUB_INFO", "CLUB", "DREAM", "SQUAD",
|
||||
"DELETE_SQUAD", "LBOPTIONS", "LBDEFAULT", "PAFPRACTICE", "UT", "USER",
|
||||
@@ -731,18 +779,21 @@ FUT_RS4_CONFIG = (
|
||||
|
||||
|
||||
def client_config_for(cfid: str) -> list:
|
||||
"""-> sorted [(key, value)]. Unknown CFID -> [] (an EMPTY MAP, which we
|
||||
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
|
||||
CardsDLL reads is unproven, so serve them everywhere)."""
|
||||
"""Return sorted config rows for one section.
|
||||
|
||||
Unknown CFIDs still receive the shared FUT/content/POW rows because those
|
||||
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
|
||||
# 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
|
||||
# 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.)
|
||||
if cfid == "BlazeSDK":
|
||||
return sorted(blazesdk_config() + FUT_RS4_CONFIG + OSDK_POW)
|
||||
return sorted((CLIENT_CONFIGS.get(cfid) or []) + 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
|
||||
+ FUT_CONTENT_CONFIG + OSDK_POW)
|
||||
|
||||
|
||||
def fetch_config_response_fields(cfid: str) -> "OrderedDict":
|
||||
@@ -765,7 +816,7 @@ def qos_config() -> "OrderedDict":
|
||||
has NO SVID, unlike Mirror's Edge Catalyst)."""
|
||||
return OrderedDict([
|
||||
("BWPS", (STRUCT, OrderedDict([ # Blaze::QosPingSiteInfo
|
||||
("PSA", (STRING, "127.0.0.1")),
|
||||
("PSA", (STRING, _ADVERTISE)),
|
||||
("PSP", (INT, 17502)),
|
||||
]))),
|
||||
("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
|
||||
than resolve a real EA hostname."""
|
||||
tele = OrderedDict([ # GetTelemetryServerResponse (15)
|
||||
("ADRS", (STRING, "127.0.0.1")),
|
||||
("ADRS", (STRING, _ADVERTISE)),
|
||||
("ANON", (INT, 0)),
|
||||
("DISA", (STRING, "")),
|
||||
("EDCT", (INT, 0)),
|
||||
@@ -1108,7 +1159,7 @@ def post_auth_response_fields(sess: Session) -> "OrderedDict":
|
||||
("SVNM", (STRING, "telemetry-openfut")),
|
||||
])
|
||||
tick = OrderedDict([ # GetTickerServerResponse (3)
|
||||
("ADRS", (STRING, "127.0.0.1")),
|
||||
("ADRS", (STRING, _ADVERTISE)),
|
||||
("PORT", (INT, 8999)),
|
||||
("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)
|
||||
resp = preauth_response_fields(service_name=sess.service_name)
|
||||
payload = encode_tdf(resp)
|
||||
log(" -> PreAuthResponse (INST=%r, %d payload bytes):\n%s"
|
||||
% (sess.service_name, len(payload), heat2.dump(resp)))
|
||||
log(" -> PreAuthResponse (INST=%r, %d payload bytes)"
|
||||
% (sess.service_name, len(payload)))
|
||||
if DUMP_FRAMES:
|
||||
log(" -> PreAuthResponse TDF:\n%s" % heat2.dump(resp))
|
||||
return [reply_to(hdr, payload)]
|
||||
|
||||
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])
|
||||
log(" -> FetchConfigResponse CFID=%r -> %d key(s)%s"
|
||||
% (cfid, n, "" if n else " (EMPTY MAP, unknown CFID)"))
|
||||
for k, v in resp["CONF"][1][2]:
|
||||
log(" %-32s = %s" % (k, v))
|
||||
if DUMP_FRAMES:
|
||||
for k, v in resp["CONF"][1][2]:
|
||||
log(" %-32s = %s" % (k, v))
|
||||
return [reply_to(hdr, encode_tdf(resp))]
|
||||
|
||||
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.logged_in = True
|
||||
sess.login_time = int(time.time())
|
||||
log(" == Authentication::login AUTH=%r (accepted WITHOUT Nucleus "
|
||||
"validation -- forged offline session)" % sess.auth_code)
|
||||
log(" == Authentication::login AUTH=[REDACTED] "
|
||||
"(accepted as an offline OpenFUT session)")
|
||||
resp = login_response_fields(sess)
|
||||
payload = encode_tdf(resp)
|
||||
log(" -> LoginResponse (%d bytes):\n%s"
|
||||
% (len(payload), heat2.dump(resp)))
|
||||
log(" -> LoginResponse (%d bytes)" % len(payload))
|
||||
if DUMP_FRAMES:
|
||||
log(" -> LoginResponse TDF:\n%s" % heat2.dump(resp))
|
||||
notifs = build_login_notifications(sess, sess.login_time)
|
||||
out = []
|
||||
if NOTIFY_BEFORE_LOGIN_REPLY:
|
||||
@@ -1458,9 +1513,10 @@ _frame_counter = [0]
|
||||
|
||||
|
||||
def blaze_handle(raw: socket.socket, addr) -> None:
|
||||
refresh_account_identity()
|
||||
log("*** BLAZE CONNECT from %s ***" % (addr,))
|
||||
sess = Session()
|
||||
log(" session key minted: %s" % sess.session_key)
|
||||
log(" session key minted: [REDACTED]")
|
||||
buf = bytearray()
|
||||
raw.settimeout(300)
|
||||
try:
|
||||
@@ -1491,10 +1547,10 @@ def blaze_handle(raw: socket.socket, addr) -> None:
|
||||
MSGTYPE_NAME.get(hdr["msg_type"], hdr["msg_type"]),
|
||||
hdr["msg_num"], hdr["user_index"], hdr["options"],
|
||||
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:
|
||||
log("RX #%d HEX:\n%s" % (n, hexdump(frame)))
|
||||
if metadata:
|
||||
log("RX #%d METADATA:\n%s" % (n, hexdump(metadata)))
|
||||
try:
|
||||
os.makedirs(RXDIR, exist_ok=True)
|
||||
fn = os.path.join(RXDIR, "rx_%04d_%04x_%04x.bin"
|
||||
@@ -1509,7 +1565,8 @@ def blaze_handle(raw: socket.socket, addr) -> None:
|
||||
if payload:
|
||||
try:
|
||||
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:
|
||||
log("RX #%d TDF DECODE FAILED: %s" % (n, e))
|
||||
else:
|
||||
@@ -1530,7 +1587,8 @@ def blaze_handle(raw: socket.socket, addr) -> None:
|
||||
ohdr["msg_type"]),
|
||||
MSGTYPE_NAME.get(ohdr["msg_type"], ohdr["msg_type"]),
|
||||
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:
|
||||
log("BLAZE %s: connection reset by client" % (addr,))
|
||||
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:
|
||||
# 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:
|
||||
try:
|
||||
raw.settimeout(10)
|
||||
@@ -1640,9 +1702,9 @@ def nucleus_handle(raw: socket.socket, addr) -> None:
|
||||
head, _, rest = req.partition(b"\r\n\r\n")
|
||||
line0 = head.split(b"\r\n", 1)[0].decode(errors="replace") if head else ""
|
||||
log("NUCLEUS REQ %s: %s" % (addr, line0))
|
||||
if head:
|
||||
if head and DUMP_FRAMES:
|
||||
log("NUCLEUS HEADERS:\n%s" % head.decode(errors="replace"))
|
||||
if rest:
|
||||
if rest and DUMP_FRAMES:
|
||||
log("NUCLEUS BODY: %r" % rest[:512])
|
||||
|
||||
token = "OPENFUT_" + "".join(
|
||||
@@ -1656,7 +1718,7 @@ def nucleus_handle(raw: socket.socket, addr) -> None:
|
||||
b"Cache-Control: no-store\r\nContent-Length: "
|
||||
+ str(len(body)).encode() + b"\r\nConnection: close\r\n\r\n" + body)
|
||||
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:
|
||||
log("NUCLEUS ERR %s: %s" % (addr, e))
|
||||
finally:
|
||||
@@ -1708,6 +1770,10 @@ def _selftest() -> None:
|
||||
sess.account_locale = 0x656E5553
|
||||
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)
|
||||
pre = preauth_response_fields()
|
||||
p = _check_roundtrip("PreAuthResponse", pre)
|
||||
@@ -1731,9 +1797,11 @@ def _selftest() -> None:
|
||||
assert items == client_config_for(cfid), cfid
|
||||
print("[ok] fetchClientConfig %-26s %2d keys, %4d payload bytes"
|
||||
% (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, \
|
||||
"unknown CFID must still carry a CONF field (empty map, not empty frame)"
|
||||
"unknown CFID must still carry a CONF field"
|
||||
|
||||
# ---- 3. LoginResponse
|
||||
lr = login_response_fields(sess)
|
||||
|
||||
@@ -55,6 +55,34 @@ verify_exports() {
|
||||
done
|
||||
}
|
||||
|
||||
# Refuse any DLL that is not a FIFA-17-profile build.
|
||||
#
|
||||
# openfut-hook builds TWO mutually exclusive injection paths from one crate: the
|
||||
# default (FIFA 23) path installs getaddrinfo/connect/ProtoSSL/origin hooks, while
|
||||
# `--features fifa17` installs ONLY the FIFA-17-safe logic (module map, FIFA 17
|
||||
# cert-verify, SBC dispatch, store tab bind). Deploying a default-feature build
|
||||
# into FIFA 17 hijacks the login transport and the client reports "Unable to
|
||||
# connect to the EA servers", with none of the FIFA 17 repairs present.
|
||||
#
|
||||
# That exact mistake happened on 2026-08-19 (artifact 1c71a17a, hand-built without
|
||||
# the feature): two failed launches, diagnosed only by comparing embedded strings.
|
||||
# `build` below passes the feature, but a hand-built DLL can reach `stage`/`deploy`
|
||||
# via OPENFUT_FIFA17_HOOK_DLL, so assert the profile on the bytes themselves.
|
||||
verify_fifa17_profile() {
|
||||
local dll=$1 marker
|
||||
# Markers that MUST be present: the FIFA 17 target module and its repairs.
|
||||
for marker in 'CardsDLL_Win64_retail.dll' 'SBC_DISPATCH'; do
|
||||
grep -qaF -- "$marker" "$dll" ||
|
||||
die "$dll is not a --features fifa17 build (missing $marker); refusing to stage/deploy"
|
||||
done
|
||||
# Markers that MUST be absent: the FIFA-23-only transport hooking.
|
||||
for marker in 'getaddrinfo IAT patched' 'connect: inline-hooked' 'origin_spy'; do
|
||||
if grep -qaF -- "$marker" "$dll"; then
|
||||
die "$dll contains FIFA-23-only hook '$marker'; build with --features fifa17"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
verify_inputs() {
|
||||
command -v sha256sum >/dev/null || die "sha256sum is required"
|
||||
command -v x86_64-w64-mingw32-objdump >/dev/null ||
|
||||
@@ -62,6 +90,7 @@ verify_inputs() {
|
||||
need_file "$hook_dll"
|
||||
need_file "$system_version"
|
||||
verify_pe64 "$hook_dll"
|
||||
verify_fifa17_profile "$hook_dll"
|
||||
}
|
||||
|
||||
inspect() {
|
||||
@@ -129,6 +158,7 @@ deploy() {
|
||||
need_file "$manifest"
|
||||
verify_pe64 "$staged"
|
||||
verify_exports "$staged"
|
||||
verify_fifa17_profile "$staged"
|
||||
local recorded actual
|
||||
recorded="$(awk -F= '$1=="artifact_sha256"{print $2}' "$manifest")"
|
||||
actual="$(sha256 "$staged")"
|
||||
@@ -158,6 +188,7 @@ launch() {
|
||||
local trace_enabled=0
|
||||
local request_trace_enabled=0
|
||||
local notifier_trace_enabled=0
|
||||
local dispatch_enabled=0
|
||||
case "$mode" in
|
||||
baseline)
|
||||
[[ "${OPENFUT_FIFA17_LAUNCH:-}" == "I_ACCEPT_M1_BASELINE_LAUNCH" ]] ||
|
||||
@@ -176,6 +207,12 @@ launch() {
|
||||
request_trace_enabled=1
|
||||
notifier_trace_enabled=1
|
||||
;;
|
||||
dispatch)
|
||||
[[ "${OPENFUT_FIFA17_DISPATCH:-}" == "I_ACCEPT_GUARDED_NATIVE_DISPATCH" ]] ||
|
||||
die "launch-dispatch requires OPENFUT_FIFA17_DISPATCH=I_ACCEPT_GUARDED_NATIVE_DISPATCH"
|
||||
request_trace_enabled=1
|
||||
dispatch_enabled=1
|
||||
;;
|
||||
*) die "unknown launch mode: $mode" ;;
|
||||
esac
|
||||
need_file "$deployed_dll"
|
||||
@@ -192,12 +229,12 @@ launch() {
|
||||
[[ "$(sha256 "$deployed_dll")" == "$recorded" ]] ||
|
||||
die "deployed version.dll does not match the staged M1 artifact"
|
||||
command -v umu-run >/dev/null || die "umu-run is required"
|
||||
for name in OPENFUT_SBC_DISPATCH OPENFUT_SBC_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"
|
||||
done
|
||||
mkdir -p "${wine_prefix}/dosdevices"
|
||||
ln -sfn /mnt "${wine_prefix}/dosdevices/w:"
|
||||
note "Launching $mode mode (SBC_HOOK=$hook_enabled; SBC_TRACE=$trace_enabled; SBC_REQUEST_TRACE=$request_trace_enabled; SBC_NOTIFIER_TRACE=$notifier_trace_enabled; 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"
|
||||
env \
|
||||
GAMEID=fifa17 \
|
||||
@@ -208,8 +245,8 @@ launch() {
|
||||
OPENFUT_SBC_TRACE="$trace_enabled" \
|
||||
OPENFUT_SBC_REQUEST_TRACE="$request_trace_enabled" \
|
||||
OPENFUT_SBC_NOTIFIER_TRACE="$notifier_trace_enabled" \
|
||||
OPENFUT_SBC_DISPATCH=0 \
|
||||
OPENFUT_SBC_COMMIT=0 \
|
||||
OPENFUT_SBC_DISPATCH="$dispatch_enabled" \
|
||||
OPENFUT_SBC_DISPATCH_TRACE=0 \
|
||||
OPENFUT_SBC_ARM_ONLY=0 \
|
||||
OPENFUT_SBC_POPULATE=0 \
|
||||
umu-run _fifa17.exe 2>&1 | tee /tmp/fifa17-hook-m1-launch.log
|
||||
@@ -217,7 +254,7 @@ launch() {
|
||||
|
||||
usage() {
|
||||
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).
|
||||
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:
|
||||
OPENFUT_FIFA17_RESOLVE=I_ACCEPT_M2_RESOLVE_LAUNCH
|
||||
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
|
||||
launch-dispatch
|
||||
Trace and repair only a fully validated native status-999 completion; requires:
|
||||
OPENFUT_FIFA17_DISPATCH=I_ACCEPT_GUARDED_NATIVE_DISPATCH
|
||||
|
||||
Optional path overrides:
|
||||
OPENFUT_FIFA17_HOOK_DLL, OPENFUT_FIFA17_GAME_DIR,
|
||||
@@ -247,6 +287,7 @@ case "${1:-inspect}" in
|
||||
launch) launch baseline ;;
|
||||
launch-resolve) launch resolve ;;
|
||||
launch-trace) launch trace ;;
|
||||
launch-dispatch) launch dispatch ;;
|
||||
-h|--help|help) usage ;;
|
||||
*) usage >&2; die "unknown command: $1" ;;
|
||||
esac
|
||||
|
||||
@@ -204,6 +204,7 @@ class Account:
|
||||
def __init__(self, path=None):
|
||||
self.path = path or ACCOUNT_PATH
|
||||
self._loaded = False
|
||||
self._file_signature = None
|
||||
self._stored = {} # what is on disk (tier 2+3 only)
|
||||
for f in _FIELDS:
|
||||
setattr(self, "_" + f, None)
|
||||
@@ -214,7 +215,8 @@ class Account:
|
||||
save the first time. Never raises on a malformed file -- a broken
|
||||
account file must not stop the harness booting."""
|
||||
with _LOCK:
|
||||
if self._loaded and not force:
|
||||
signature = self._signature()
|
||||
if self._loaded and not force and signature == self._file_signature:
|
||||
return self
|
||||
stored = {}
|
||||
if os.path.exists(self.path):
|
||||
@@ -239,8 +241,22 @@ class Account:
|
||||
% (self.path, e))
|
||||
self._stored = stored
|
||||
self._loaded = True
|
||||
self._file_signature = self._signature()
|
||||
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):
|
||||
"""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
|
||||
@@ -266,11 +282,32 @@ class Account:
|
||||
return out
|
||||
|
||||
def _write(self):
|
||||
parent = os.path.dirname(self.path)
|
||||
if parent:
|
||||
os.makedirs(parent, exist_ok=True)
|
||||
tmp = self.path + ".tmp"
|
||||
with open(tmp, "w") as f:
|
||||
json.dump(self._stored, f, indent=1, sort_keys=True)
|
||||
f.write("\n")
|
||||
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):
|
||||
"""Persist tiers 2+3 (only fields that differ from the built-in default,
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Launcher-to-server active-account selection for the single-player stack."""
|
||||
import json
|
||||
import os
|
||||
|
||||
from fut_account import ACCOUNT
|
||||
from fut_store import STORE, profile_path_for
|
||||
|
||||
|
||||
def _existing_identity(persona_id):
|
||||
path = profile_path_for(persona_id)
|
||||
try:
|
||||
with open(path) as f:
|
||||
profile = json.load(f)
|
||||
except (OSError, ValueError):
|
||||
return {}
|
||||
if not isinstance(profile, dict):
|
||||
return {}
|
||||
return {
|
||||
"club_name": profile.get("clubName"),
|
||||
"club_abbr": profile.get("clubAbbr"),
|
||||
"established": profile.get("established"),
|
||||
"pow_level": profile.get("powLevel"),
|
||||
"pow_exp": profile.get("powExp"),
|
||||
"pow_exp_max": profile.get("powExpMax"),
|
||||
"pow_funds": profile.get("powFunds"),
|
||||
"pow_funds_cap": profile.get("powFundsCap"),
|
||||
}
|
||||
|
||||
|
||||
def activate(payload):
|
||||
"""Select/create one persistent profile and publish it to all responders."""
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("account payload must be an object")
|
||||
try:
|
||||
persona_id = int(payload.get("personaId"))
|
||||
except (TypeError, ValueError):
|
||||
raise ValueError("personaId must be a positive integer") from None
|
||||
persona_name = payload.get("personaName")
|
||||
if persona_id <= 0 or not isinstance(persona_name, str) or not persona_name.strip():
|
||||
raise ValueError("personaId must be positive and personaName must not be empty")
|
||||
|
||||
values = _existing_identity(persona_id)
|
||||
values.update(persona_id=persona_id, persona_name=persona_name.strip())
|
||||
for wire, field in (("clubName", "club_name"), ("clubAbbr", "club_abbr"),
|
||||
("established", "established"), ("squadName", "squad_name"),
|
||||
("level", "pow_level"), ("experience", "pow_exp"),
|
||||
("experienceMax", "pow_exp_max"), ("accountFunds", "pow_funds"),
|
||||
("accountFundsCap", "pow_funds_cap")):
|
||||
if payload.get(wire) not in (None, ""):
|
||||
values[field] = payload[wire]
|
||||
|
||||
ACCOUNT.replace(values)
|
||||
ACCOUNT.set_online_profile()
|
||||
ACCOUNT.save()
|
||||
profile = STORE.select_account(persona_id)
|
||||
STORE.ensure_security_question()
|
||||
return {
|
||||
"personaId": ACCOUNT.persona_id,
|
||||
"personaName": ACCOUNT.persona_name,
|
||||
"clubName": ACCOUNT.club_name,
|
||||
"clubAbbr": ACCOUNT.club_abbr,
|
||||
"level": ACCOUNT.pow_level,
|
||||
"experience": ACCOUNT.pow_exp,
|
||||
"experienceMax": ACCOUNT.pow_exp_max,
|
||||
"accountFunds": ACCOUNT.pow_funds,
|
||||
"accountFundsCap": ACCOUNT.pow_funds_cap,
|
||||
"profilePath": os.path.relpath(STORE.path, os.path.dirname(ACCOUNT.path)),
|
||||
"coins": profile.get("coins", 0),
|
||||
"unopenedPacks": len(profile.get("unopenedPackIds", [])),
|
||||
}
|
||||
+159
-11
@@ -17,7 +17,19 @@ sys.path.insert(0, HERE)
|
||||
import fut_cards
|
||||
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 ------------------
|
||||
#
|
||||
@@ -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.
|
||||
|
||||
|
||||
SPECIAL_CARD_TYPES = {
|
||||
# name: (rareflag, revision byte, rating/attribute boost, selection weight)
|
||||
# rareflag names come from FIFA 17's ItemRareType enum. Revisions are local,
|
||||
# stable identities; the client resolves the footballer from the low 24 bits.
|
||||
"TOTW": (3, 1, 2, 34),
|
||||
"PURPLE": (4, 2, 3, 7),
|
||||
"TOTY": (5, 3, 6, 3),
|
||||
"RECORD_BREAKER": (6, 4, 5, 2),
|
||||
"TOTS": (11, 5, 5, 7),
|
||||
"OTW": (21, 6, 2, 14),
|
||||
"HALLOWEEN": (22, 7, 3, 8),
|
||||
"MOVEMBER": (23, 8, 3, 8),
|
||||
"SBC": (24, 9, 4, 17),
|
||||
}
|
||||
|
||||
|
||||
def choose_special_type(player, rng=None):
|
||||
"""Choose a rating-appropriate FIFA 17 promo family for one pool row."""
|
||||
import random
|
||||
rng = rng or random
|
||||
rating = player[1]
|
||||
eligible = []
|
||||
for name, spec in SPECIAL_CARD_TYPES.items():
|
||||
if name in ("TOTY", "RECORD_BREAKER") and rating < 85:
|
||||
continue
|
||||
if name == "TOTS" and rating < 75:
|
||||
continue
|
||||
eligible.append((name, spec[3]))
|
||||
names, weights = zip(*eligible)
|
||||
return rng.choices(names, weights=weights, k=1)[0]
|
||||
|
||||
|
||||
def player_item(item_id, player, special=False):
|
||||
"""Build a base or named FIFA 17 special revision from a pool row.
|
||||
|
||||
`special=True` remains supported and chooses a weighted eligible family;
|
||||
callers and tests may also pass an explicit name such as ``"TOTY"``.
|
||||
"""
|
||||
asset, rating, pos, nation, league, team, attrs = player
|
||||
if special:
|
||||
special_name = choose_special_type(player) if special is True else special
|
||||
rareflag, version, boost, _weight = SPECIAL_CARD_TYPES[special_name]
|
||||
rating = min(99, rating + boost)
|
||||
attrs = [min(99, value + boost) for value in attrs]
|
||||
else:
|
||||
rareflag, version = 1, 0
|
||||
return _item(item_id, asset, rating, pos, nation, league, team, attrs,
|
||||
version=version, rareflag=rareflag)
|
||||
|
||||
|
||||
# FUT_DISCARD_SEND: put discardValue (atom 0xd7) on the wire so the CLIENT DISPLAYS
|
||||
# the same number the server pays.
|
||||
#
|
||||
@@ -293,6 +355,10 @@ def _new_profile():
|
||||
"purchased": [], # unassigned/pending items from opened packs
|
||||
"squads": [], # saved squads (raw squad objects from PUT /squad)
|
||||
"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._sync_identity()
|
||||
self._save()
|
||||
if not self._p.get("unopenedSeeded"):
|
||||
self._p.setdefault("unopenedPackIds", []).append(70)
|
||||
self._p["unopenedSeeded"] = True
|
||||
self._save()
|
||||
self._sync_identity()
|
||||
return self._p
|
||||
|
||||
@@ -328,18 +398,51 @@ class Store:
|
||||
p["clubName"] = ACCOUNT.club_name
|
||||
p["clubAbbr"] = ACCOUNT.club_abbr
|
||||
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
|
||||
|
||||
def _save(self):
|
||||
parent = os.path.dirname(self.path)
|
||||
if parent:
|
||||
os.makedirs(parent, exist_ok=True)
|
||||
tmp = self.path + ".tmp"
|
||||
with open(tmp, "w") as f:
|
||||
json.dump(self._p, f, indent=1)
|
||||
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 -------------------------------------
|
||||
def profile(self):
|
||||
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):
|
||||
"""Re-mirror ACCOUNT into the save AND persist it.
|
||||
|
||||
@@ -513,6 +616,32 @@ class Store:
|
||||
sq = self.load()["squads"]
|
||||
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):
|
||||
"""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
|
||||
@@ -557,7 +686,8 @@ class Store:
|
||||
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
|
||||
place them in the PENDING purchased pile (unassigned). They are NOT owned
|
||||
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.
|
||||
n_extra = 0
|
||||
extras = []
|
||||
if PACK_MIX and count >= 5:
|
||||
if PACK_MIX and not players_only and count >= 5:
|
||||
n_extra = max(1, count // 4)
|
||||
extras = _pack_extras(n_extra, self)
|
||||
n_extra = len(extras)
|
||||
n_players = max(1, count - n_extra)
|
||||
if tiers:
|
||||
picks = [random.choice(fut_cards.pool_for(random.choice(tiers)))
|
||||
for _ in range(n_players)]
|
||||
# Draw each tier independently but reject duplicate asset IDs inside
|
||||
# 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:
|
||||
pool = [p for p in PACK_POOL if (p[1] >= 75) == gold] or PACK_POOL
|
||||
picks = [random.choice(pool) for _ in range(n_players)]
|
||||
items = [_item(self.new_item_id(), a, r, p, n, lg, tm, at)
|
||||
for (a, r, p, n, lg, tm, at) in picks]
|
||||
picks = random.sample(pool, min(n_players, len(pool)))
|
||||
while len(picks) < n_players:
|
||||
picks.append(random.choice(pool))
|
||||
items = [player_item(self.new_item_id(), pick,
|
||||
special=random.random() < special_chance)
|
||||
for pick in picks]
|
||||
items += extras
|
||||
random.shuffle(items)
|
||||
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.
|
||||
PACK_CATALOG = [
|
||||
{"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,
|
||||
"tiers": ["gold"] * 6 + ["silver"] * 4},
|
||||
"tiers": ["gold"] * 6 + ["silver"] * 4, "specialChance": 0.03},
|
||||
{"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])
|
||||
|
||||
Regular → Executable
+41
-8
@@ -162,6 +162,35 @@ def log(*a):
|
||||
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
|
||||
# (verbatim from v1 -- verified end-to-end by decrypting captured
|
||||
# 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
|
||||
log("*** GetAuthCode ISSUED ***")
|
||||
log(f" ClientId={client_id!r} Scope={scope!r}")
|
||||
log(f" code={code} -- this must arrive as Blaze "
|
||||
f"LoginRequest.AUTH in Authentication::login (1/0x0A)")
|
||||
log(" code=[REDACTED] -- issued for Blaze Authentication::login (1/0x0A)")
|
||||
return resp(mid,
|
||||
f'AuthCode value="{code}" Code="{code}" Return="{code}"')
|
||||
|
||||
@@ -489,8 +517,7 @@ def serve(sock, addr):
|
||||
client_resp = mr.group(1) if mr else ""
|
||||
h = challenge_response(client_key, client_resp)
|
||||
conn.key = derive_session_key(h)
|
||||
log(f"client key={client_key} response={h[:16]}... "
|
||||
f"session_key={conn.key.hex()}")
|
||||
log("handshake accepted; session crypto initialized")
|
||||
|
||||
# 3. plaintext ChallengeAccepted
|
||||
conn.send_plain(resp(1, f'ChallengeAccepted response="{h}"', "EALS"))
|
||||
@@ -525,7 +552,7 @@ def serve(sock, addr):
|
||||
continue
|
||||
mm = REQ_RE.search(xml)
|
||||
if not mm:
|
||||
log("<<", xml)
|
||||
log("<<", safe_xml_for_log(xml))
|
||||
continue
|
||||
mid, name, rest = mm.group(1), mm.group(2), mm.group(3)
|
||||
attrs = dict(ATTR_RE.findall(rest))
|
||||
@@ -533,7 +560,7 @@ def serve(sock, addr):
|
||||
recip = rm.group(1) if rm else ""
|
||||
reply = build_reply(mid, name, attrs, conn, recip)
|
||||
log(f"<< id={mid} {name} recipient={recip!r} {attrs}")
|
||||
log(f">> {reply}")
|
||||
log(">>", safe_xml_for_log(reply))
|
||||
conn.send_enc(reply)
|
||||
|
||||
why = PUSH_AFTER.get(name)
|
||||
@@ -558,9 +585,10 @@ def serve(sock, addr):
|
||||
|
||||
|
||||
def main():
|
||||
spawn_parent_watchdog()
|
||||
s = socket.socket()
|
||||
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)
|
||||
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'}"
|
||||
@@ -591,7 +619,12 @@ def selftest():
|
||||
# 'value' is the only attribute lsx::AuthCodeT's deserializer (0x1471312a0)
|
||||
# actually reads; Code=/Return= are legacy padding.
|
||||
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")
|
||||
|
||||
|
||||
|
||||
@@ -65,6 +65,18 @@ MODE = os.environ.get("POW_MODE", "serve")
|
||||
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")
|
||||
|
||||
# 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):
|
||||
host, _, port = hostport.partition(":")
|
||||
@@ -296,6 +308,17 @@ class _Handler(http.server.BaseHTTPRequestHandler):
|
||||
log(" body: %s" % body[:65536].decode("utf-8", "replace"))
|
||||
|
||||
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
|
||||
# what a missing-asset CDN would return. Logged so we learn what art the
|
||||
# client wants before deciding to synthesise any.
|
||||
|
||||
@@ -20,7 +20,7 @@ HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
CERT = os.path.join(HERE, "redir_cert.pem")
|
||||
KEY = os.path.join(HERE, "redir_key.pem")
|
||||
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.
|
||||
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())
|
||||
@@ -104,11 +104,21 @@ def test_store_catalog():
|
||||
check("catalog.purchase is array", is_arr(d.get("purchase")), repr(type(d.get("purchase"))))
|
||||
for p in d.get("purchase", []):
|
||||
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.extPrice is object", is_obj(p.get("extPrice")))
|
||||
ep = p.get("extPrice", {})
|
||||
check("extPrice.finalPrice is object", is_obj(ep.get("finalPrice")))
|
||||
# The inactive zero-item sentinel keeps FIFA's hardcoded `mypacks`
|
||||
# navigation destination resolvable when no owned packs remain. It is
|
||||
# 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():
|
||||
|
||||
@@ -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())
|
||||
@@ -11,6 +11,8 @@ Guards the two things that would silently break the loop:
|
||||
* `destroy_match_body()` drifting from FutDestroyMatchServerResponse
|
||||
(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.
|
||||
* 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)
|
||||
"""
|
||||
@@ -152,9 +154,37 @@ def test_payout_table():
|
||||
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():
|
||||
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:
|
||||
t()
|
||||
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()
|
||||
@@ -11,19 +11,23 @@ Rules (from CardsDLL 0x18016D230 / 0x1801a33a0):
|
||||
* 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.
|
||||
"""
|
||||
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__)))
|
||||
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_accounts import activate as activate_account
|
||||
|
||||
# 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
|
||||
# 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
|
||||
# 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")
|
||||
SID = "OPENFUT-SID-0000000000000001"
|
||||
# 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.
|
||||
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():
|
||||
return datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
@@ -49,6 +63,96 @@ def log(m):
|
||||
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 -------------------------------------------------------------
|
||||
def auth_body(h=None):
|
||||
"""POST ut/auth.
|
||||
@@ -92,6 +196,19 @@ def auth_body(h=None):
|
||||
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():
|
||||
"""The squad the client should see: the persisted one (item refs re-embedded
|
||||
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],
|
||||
})
|
||||
# ---- 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
|
||||
# (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"):
|
||||
# squadList(0x2d4) -> FUN_180142260 on singleton->vtbl[0x480]+0x30, i.e. it
|
||||
# fills the global squad-ROSTER model ("MY SQUADS" on the Squads screen).
|
||||
@@ -936,13 +1059,30 @@ def quick_sell_route(h):
|
||||
except Exception:
|
||||
body = {}
|
||||
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):
|
||||
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)
|
||||
if sold:
|
||||
log(" QUICKSELL: sold %d card(s) for %d coins (total %d)"
|
||||
% (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):
|
||||
@@ -1060,6 +1200,9 @@ def item_route(h):
|
||||
|
||||
G = r"/ut/game/[^/]+"
|
||||
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) ----
|
||||
(re.compile(G + r"/item/resource"), 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/delete/auth"), lambda m, h: (200, {})),
|
||||
(re.compile(G + r"/settings"), lambda m, h: (200, SETTINGS)),
|
||||
# Device-trust ("phishing") flow. trusteddevice parser 0x18012a170 reads 4
|
||||
# booleans by key-id 0x7e/0x117/0x19e/0x351; 0x351 == JSON key "trusted".
|
||||
# Returning trusted=true makes FUT SKIP the security question.
|
||||
(re.compile(G + r"/phishing/trusteddevice"), lambda m, h: (200, {"trusted": True})),
|
||||
(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})),
|
||||
# Device-trust ("phishing") flow. One handler owns its exact state machine,
|
||||
# validation, persistence and redacted diagnostics; keep these above /user.
|
||||
(re.compile(G + r"/phishing/(trusteddevice|validate|question)"),
|
||||
lambda m, h: security_question_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) ----
|
||||
# /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
|
||||
# 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)),
|
||||
# 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"/squad"), lambda m, h: squad_route(h)),
|
||||
(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
|
||||
hub.tradePile, not /tradePile/counts (which the tile never re-polls). All active
|
||||
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:
|
||||
return {}
|
||||
players = len([i for i in STORE.items() if _is_player(i)])
|
||||
auctions = len(STORE.listings())
|
||||
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}}
|
||||
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 ------
|
||||
@@ -2154,12 +2327,11 @@ def clientdata_route(h):
|
||||
"""ut/%s/clientdata/<key> -- opaque client blob storage.
|
||||
|
||||
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
|
||||
server behaviour is to keep the blob and hand back exactly what was given, which
|
||||
is zero-risk by construction: we never synthesise a shape, we echo the client's
|
||||
own bytes. Persisting it is also the most plausible route to the hub's
|
||||
"MANAGER TASKS 0/0" tile surviving a relaunch, since no FutGetObjectives class
|
||||
exists in the binary at all (§9) -- the tile state may simply live in this blob.
|
||||
hub. Persist the client-owned blob so its matching GET can restore it. The PUT
|
||||
acknowledgement remains the historical empty object: echoing the body was
|
||||
exercised live with both observed values ([3,0] and [3,1]) and did not unlock
|
||||
offline Seasons or produce a subsequent /season request. No response schema has
|
||||
been recovered for SetTutData, so do not infer one from the request shape.
|
||||
"""
|
||||
key = h.path.split("/clientdata/", 1)[-1].split("?")[0] or "default"
|
||||
if h.command in ("PUT", "POST"):
|
||||
@@ -2231,10 +2403,17 @@ def season_user():
|
||||
|
||||
|
||||
def tournament_list():
|
||||
"""GET ut/%s/tournament -- FutTournamentList, deser 0x180169ef0 (MEDIUM).
|
||||
ARRAY root; rounds/prizeSet/staff/kit atoms are nested FREEZE-RISK -> omitted."""
|
||||
return [{"id": 1, "difficulty": 1, "coins": 500, "rewardMultiplier": 1,
|
||||
"assetName": "", "eligibilityOperation": ""}]
|
||||
"""GET ut/%s/tournament -- object wrapper parsed at 0x18016b220 (HIGH).
|
||||
|
||||
The response parser recognizes only tournament(0x328), opens its ARRAY, then
|
||||
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():
|
||||
@@ -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
|
||||
# this turns out to be wrong.
|
||||
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):
|
||||
if not DRAFT_STATE:
|
||||
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, [{
|
||||
"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
|
||||
"stateParam2": "0", # STRING (the int getter also accepts it)
|
||||
"gamesWonCurrentMatch": 0, # INT
|
||||
"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.
|
||||
}]
|
||||
|
||||
|
||||
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 ----------------------------------------------------
|
||||
# POST ut/%s/purchase/mode/{price}/draft body {"currency":"COINS","usePreOrder":0}
|
||||
# -> FutPurchaseDraftModeServerResponse. "Buys" entry into draft mode and returns
|
||||
# the fresh draft session summary.
|
||||
# POST ut/%s/purchase/mode/{mode}/draft body {"currency":"COINS","usePreOrder":0}
|
||||
# -> FutPurchaseDraftModeServerResponse. The path component is the draft mode
|
||||
# (1 for SINGLE_PLAYER), not the entry price.
|
||||
#
|
||||
# LIVE 2026-08-04: this endpoint was UNMAPPED, answered {} by the catch-all, and the
|
||||
# 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.
|
||||
#
|
||||
# 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
|
||||
# the second one is wrong:
|
||||
# structures reference the class name. Live behaviour plus the request vtable resolves
|
||||
# the POST to the second variant:
|
||||
# 0x18014c260 (vtable 0x180224ef8, factory 0x18014c090) 3188 chars, OBJECT root
|
||||
# (prologue tests != 10 = END_OBJECT), 1 skip-handler call, and exactly
|
||||
# the seven scalar ints below. THIS IS THE RESPONSE PARSER.
|
||||
# (prologue tests != 10 = END_OBJECT), 1 skip-handler call, and seven
|
||||
# scalar ints. This is a distinct response using the same class name.
|
||||
# 0x180150310 (vtable 0x1802262f0, factory 0x180150260) 1836 chars, ARRAY root
|
||||
# (loops until 0xd = END_ARRAY), ZERO skip handlers -- and it is not a
|
||||
# response root at all. It parses ENTRANCE CRITERIA: each element's
|
||||
# name is strcmp'd against the literals "COINS", "POINTS" and
|
||||
# "DRAFT_TOKEN" and stored at +0x28/+0x2c/+0x30. It shares the name
|
||||
# string because it is the fee sub-object, not an alternate envelope.
|
||||
# (loops until 0xd = END_ARRAY), ZERO skip handlers. Each element is
|
||||
# parsed by FUN_180138bd0 as name/funds/finalFunds, then name is compared
|
||||
# with "COINS", "POINTS", and "DRAFT_TOKEN". Request vtable
|
||||
# 0x180226300 selects factory 0x180150260 for the live purchase POST.
|
||||
#
|
||||
# THE CRASH ITSELF DISCRIMINATES, which is worth recording as a technique. An
|
||||
# object-root parser handed {} parses benignly and leaves defaults; an array-root
|
||||
# parser handed {} desyncs and HANGS, which is exactly what draft/state did before it
|
||||
# was fixed. We observed a CRASH, not a hang, so the object-root parser is what ran,
|
||||
# and the failure is downstream of an empty-but-valid parse. That is consistent with
|
||||
# 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.
|
||||
# LIVE 2026-08-07: returning the seven-int object made FIFA consume the POST (HTTP
|
||||
# 200), issue no follow-up request, and spin at high CPU. That is the array parser's
|
||||
# exact EOF-loop signature. The response below therefore uses the required array root.
|
||||
# No coins are deducted yet: the emulator has not served a verified entrance price,
|
||||
# and the path's mode id must not be mistaken for a price.
|
||||
#
|
||||
# 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.
|
||||
@@ -2593,18 +2950,18 @@ def draft_purchase_route(h):
|
||||
# calling .group() on the first argument. Doing that raised AttributeError,
|
||||
# which killed the connection outright -- strictly worse than the {} it replaced.
|
||||
m = re.search(r"/purchase/mode/(\d+)/draft", h.path)
|
||||
price = int(m.group(1)) if m else 0
|
||||
log(" DRAFT: purchase entry, price=%d (not deducted -- no entranceCriteria "
|
||||
"served yet, so the client posted its own price)" % price)
|
||||
return 200, {
|
||||
"championEventId": 0,
|
||||
"expectedTierLevel": 1,
|
||||
"gamesPlayed": 0,
|
||||
"gamesRemaining": 4, # a draft run is 4 rounds
|
||||
"rank": 0,
|
||||
"score": 0,
|
||||
"tierLevel": 1,
|
||||
}
|
||||
mode = int(m.group(1)) if m else 0
|
||||
mode_name = "SINGLE_PLAYER" if mode == 1 else "ONLINE"
|
||||
_DRAFT_SESSIONS[mode_name] = {"stage": "FORMATION_DRAFT", "formation": "f442"}
|
||||
coins = STORE.coins()
|
||||
points = STORE.profile().get("points", 0)
|
||||
log(" DRAFT: purchase entry, mode=%d; returning array-root currency result "
|
||||
"(entry fee not deducted until entrance criteria are verified)" % mode)
|
||||
return 200, [
|
||||
{"name": "COINS", "funds": coins, "finalFunds": coins},
|
||||
{"name": "POINTS", "funds": points, "finalFunds": points},
|
||||
{"name": "DRAFT_TOKEN", "funds": 0, "finalFunds": 0},
|
||||
]
|
||||
|
||||
|
||||
def champion_route(h):
|
||||
@@ -2759,14 +3116,46 @@ def destroy_match_body(result, coins, total):
|
||||
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):
|
||||
"""POST create / PUT ready / POST play / DELETE destroy(+rewards)."""
|
||||
"""Create / ready / play / destroy(+rewards) on CardsDLL's match paths."""
|
||||
try:
|
||||
body = json.loads(h._body.decode("utf-8")) if getattr(h, "_body", b"") else {}
|
||||
except Exception:
|
||||
body = {}
|
||||
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
|
||||
# index 16 = `ut/%s/match`, each appending a fixed suffix via the params object
|
||||
# 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
|
||||
# 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.
|
||||
is_delete = (h.command == "DELETE" or "/ut/delete/" in h.path
|
||||
or (MATCH_END and h.path.split("?")[0].endswith("/match/end")))
|
||||
call = _match_call(h.path, h.command, body)
|
||||
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.
|
||||
result, score = _match_result(body)
|
||||
coins = MATCH_COINS.get(result, 0) + MATCH_PARTICIPATION
|
||||
@@ -2796,15 +3186,24 @@ def match_route(h):
|
||||
rec["won"], rec["draw"], rec["loss"]))
|
||||
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).
|
||||
mid = STORE.new_item_id()
|
||||
log(" MATCH: created id=%d" % mid)
|
||||
return 200, {"startDateTime": int(datetime.datetime.now().timestamp()),
|
||||
"reportIdEnabled": False, "id": mid}
|
||||
|
||||
# PUT {id} = MatchReady, POST {id} = PlayGame. Both have NO deserializer at
|
||||
# all, so {} is a complete response; the result is claimed on destroy.
|
||||
if call == "ready":
|
||||
# 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:
|
||||
log(" MATCH: %s %s body=%s" % (h.command, h.path, json.dumps(body)[:400]))
|
||||
return 200, {}
|
||||
@@ -2866,7 +3265,7 @@ def _probe_final_funds(p):
|
||||
return p["price"]
|
||||
|
||||
|
||||
def _pack_body(p, idx):
|
||||
def _pack_body(p, idx, owned=False):
|
||||
"""One entry of FutStoreGetPackTypes.purchase (element deser 0x18013af30).
|
||||
|
||||
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,
|
||||
"rareQuantity": p["count"] if gold else 0,
|
||||
"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 --
|
||||
# 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
|
||||
@@ -2955,7 +3364,19 @@ def _pack_body(p, idx):
|
||||
# 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
|
||||
# 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:
|
||||
# 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
|
||||
@@ -2997,7 +3418,32 @@ def store_catalog(h):
|
||||
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.
|
||||
"""
|
||||
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}
|
||||
|
||||
|
||||
@@ -3015,10 +3461,11 @@ def store_buy(h):
|
||||
if body.get("state") == "TRANSACTIONCANCEL" or not isinstance(pid, int):
|
||||
return 200, {} # not a confirmed buy
|
||||
pack = pack_by_id(pid)
|
||||
if not pack:
|
||||
if not pack or pack.get("ownedOnly"):
|
||||
return 200, {}
|
||||
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:
|
||||
return 461, {"reason": "insufficient_coins", "credits": 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
|
||||
if pack is None:
|
||||
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"],
|
||||
pack.get("tiers"))
|
||||
pack.get("tiers"), pack.get("specialChance", 0.0),
|
||||
pack.get("playersOnly", False))
|
||||
if items is None:
|
||||
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"
|
||||
% (pack["name"], len(items), STORE.coins()))
|
||||
if PACK_AUTOCLUB:
|
||||
@@ -3072,13 +3530,20 @@ def credits_route(h):
|
||||
# The FUT hub coin counter binds to currencies[].funds (deser 0x180122c50,
|
||||
# atom "currencies" 0xc5), NOT a "credits" key -- wf_76fcf89b.
|
||||
c = STORE.coins()
|
||||
return 200, {
|
||||
body = {
|
||||
"credits": c,
|
||||
"currencies": [
|
||||
{"name": "coins", "funds": c, "finalFunds": c},
|
||||
{"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 §) ----------------
|
||||
@@ -3264,9 +3729,9 @@ class H(http.server.BaseHTTPRequestHandler):
|
||||
n = int(self.headers.get("Content-Length", 0) or 0)
|
||||
body = self.rfile.read(n) if n else b""
|
||||
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():
|
||||
log(" %s: %s" % (k, v))
|
||||
log(" %s: %s" % (k, safe_header_for_log(k, v)))
|
||||
if body:
|
||||
log(" body: %s" % body[:65536].decode("utf-8", "replace"))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user