fifa17-python: commit working FUT backend deployment (client/server split)

Freeze the running offline FUT backend into version control as
fifa17-recon/docker/fifa17-python/ - declarative and rebuildable from a
fresh checkout:

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

Secrets (redir key/cert, .env) and runtime state (docker/state) stay gitignored.
The live container is untouched pending the .105 launcher audit.
This commit is contained in:
root
2026-08-10 23:54:04 +00:00
parent edab23f04a
commit 70a64e3709
242 changed files with 175860 additions and 0 deletions
@@ -0,0 +1,541 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Read FIFA 17's live FUT club-stat store -- STRICTLY READ-ONLY.
WHY THIS EXISTS
---------------
Static analysis produced two contradictory positions about the "MY CLUB / 0 TOTAL
PLAYERS" bug:
(A) our /club/stats response body IS parsed and IS stored, and the panel simply
reads a bucket (mode) we never populate -> the bug is SELECTION;
(B) our body never lands in the store at all -> the bug is DELIVERY/SCHEMA.
Those two differ by one observable fact: what the store actually holds while the
game is sitting on the FUT hub. This tool reads it out of the running process.
THE CHAIN, RESOLVED STATICALLY THIS SESSION (CardsDLL, image base 0x180000000)
-----------------------------------------------------------------------------
FUN_18011a830: return DAT_1802e6398; <- CardsDb singleton
CardsDb vtable = 0x18021c2a0
vt+0x7f0 -> 0x18011bbb0: `lea rax,[rcx+0x1f8b0]; ret` <- THE STORE IS A
SUBOBJECT, not a
separate alloc.
So store == CardsDb + 0x1F8B0. No virtual call is needed to reach it,
which is what makes this probe possible from outside the process.
vt+0x7f8 -> 0x18011bb10: stat_get(this, contextValue, typeId)
vt+0x800 -> 0x18011bba0: `jmp vt+0x7f8(this, 0, typeId)` <- the panel's getter;
contextValue is
HARD-WIRED to 0.
0x18011bb10 (getter_7f8, 47 lines, read in full) walks:
outer map head = this+0x1f8e8 (== store+0x38)
outer map root = this+0x1f8f8 (== store+0x48)
outer node: child0 +0x00, child1 +0x08, parent +0x10,
key = uint32 @ +0x20 (== contextValue)
inner map head = node+0x30, root = node+0x40, size = int32 @ node+0x50
inner node: child0 +0x00, child1 +0x08, parent +0x10,
key = int32 @ +0x20 (== type id)
value = int32 @ +0x24 (== typeValue)
returns 0 when either key is absent -- so a MISSING entry and a STORED ZERO
are indistinguishable to the panel, but NOT to this tool.
0x180130150 (FutStickerBookStats2 deserializer, 6.7 KB, read in full) writes into
exactly those two maps (`lVar12+0x30` outer base, node+0x28 inner base, inner
node alloc size 0x28 with key@+0x20 / value@+0x24), and on END_OBJECT does
`*(byte *)(store + 0x28) = 1` -- a PARSE-COMPLETED flag this tool reports.
FUN_18012fbe0 (request completion) writes store+0x78/+0x7c/+0x80 from
request+0xc4/+0xc8/+0xcc -- the mode tag the panel provider FUN_180043b90
switches on.
WHICH BUCKET EACH PANEL MODE READS (FUN_180043b90, 11675 chars, read in full)
----------------------------------------------------------------------------
This corrects an earlier, wrong case map. Verified line by line:
mode 1 club vt+0x800 -> bucket 0 only. PLAYERS_EMPLOYED, BALLS_EARNED,
KITS_AVAILABLE, STADIA_OWNED, STAFF_EMPLOYED,
TROPHIES_WON.
mode 2 year rows with IS_TEAM_CATEGORY=false -> bucket 0 (balls, stadia,
managers, headcoaches, physio, gkcoaches, fitness,
4 trophy variants);
rows with IS_TEAM_CATEGORY=true -> bucket = row's NATION_ID
(bronze/silver/gold, PLAYERS = their SUM, rare,
kits, badges).
mode 3 country/id bucket = each row's LEAGUE_ID. NEVER bucket 0.
mode 4 league/id bucket = each row's TEAM_ID. NEVER bucket 0.
mode 5 newcards bucket 0 only.
mode 6 consumables bucket 0 only.
So in modes 2 (team rows), 3 and 4, everything we put in bucket 0 is invisible:
the panel asks for buckets keyed by league / team / nation ids. The URL id
(store+0x7c) is NOT the bucket key either -- the key comes from the row list the
UI passes in as param_2.
READ-ONLY GUARANTEE
-------------------
/proc/PID/mem is opened "rb" and the only operations performed on it are seek()
and read(). assert_read_only() re-checks the handle's mode at startup and aborts
if anything ever made it writable. There is no ptrace attach, no write path, and
no code that constructs one.
HOW TO READ THE OUTPUT
----------------------
bucket 0 holds type 1 (players) = 114
-> our body LANDED. The store has the number. The panel showing 0 is then a
SELECTION problem (wrong mode tag / wrong bucket / stale panel), not a
delivery or schema problem.
bucket 0 exists but type 1 is absent
-> the type string in our JSON is not mapping to id 1 (FUN_18012fd40 maps the
atom for "players", 0x238, to 1). Schema bug in the `type` field.
a bucket exists but it is not 0
-> contextId/contextValue guard put us in the wrong bucket; the panel getter
only ever asks bucket 0.
outer map is EMPTY while parsed=1
-> the body parsed but every entry was dropped or the map was wiped after
parse (the factory wipe). Delivery problem.
outer map is EMPTY and parsed=0
-> our response never reached this deserializer at all.
mode tag != 1
-> corroborates the "case 1 is never selected" verdict, but ONLY if the store
does hold the values; on its own it proves nothing.
USAGE
-----
python3 tools/probe_club_stats.py # one snapshot, then exit
python3 tools/probe_club_stats.py --watch # poll until Ctrl-C, print on change
python3 tools/probe_club_stats.py --raw # + hexdump of store[0x00:0x90]
python3 tools/probe_club_stats.py --get 1 # emulate vt+0x800(typeId) exactly
FIFA does not have to be in MY CLUB when you start it; --watch is the intended way
to see the store fill as you navigate.
Needs read access to /proc/PID/mem (kernel.yama.ptrace_scope=0, or run as root).
"""
import argparse
import glob
import os
import struct
import sys
import time
# ---------------------------------------------------------------- constants --
IMG_BASE = 0x180000000
DLL = "CardsDLL"
PROC_NAME = "FIFA17.exe"
G_CARDSDB = 0x1802E6398 # FUN_18011a830 returns this (VERIFIED this session)
STORE_OFF = 0x1F8B0 # vt+0x7f0: lea rax,[rcx+0x1f8b0] (VERIFIED)
# offsets RELATIVE TO THE STORE
ST_PARSED = 0x28 # set to 1 by the deserializer on END_OBJECT
ST_MAP_BASE = 0x30 # outer std::map base
ST_MAP_HEAD = 0x38 # == CardsDb+0x1f8e8, the getter's sentinel
ST_MAP_ANCHOR = 0x40
ST_MAP_ROOT = 0x48 # == CardsDb+0x1f8f8
ST_MAP_SIZE = 0x58 # base+0x28, same layout as the CardsDb item tree
ST_MODE = 0x78 # request+0xc4 (FUN_18012fbe0)
ST_MODE_ARG1 = 0x7C # request+0xc8
ST_MODE_ARG2 = 0x80 # request+0xcc
# node layout, shared by both levels
N_C0, N_C1, N_PARENT, N_KEY = 0x00, 0x08, 0x10, 0x20
IN_VALUE = 0x24 # inner node only
# inner map, relative to an OUTER node
ON_INNER_BASE = 0x28
ON_INNER_HEAD = 0x30
ON_INNER_ROOT = 0x40
ON_INNER_SIZE = 0x50
MAX_NODES = 20000 # a corrupt tree terminates instead of hanging us
PTR_LO, PTR_HI = 0x10000, 0x00007FFFFFFFFFFF # plausible user-space range
# URL builder FUN_18012f4f0
MODE_NAMES = {1: "club", 2: "year", 3: "country+id", 4: "league+id",
5: "newcards", 6: "consumables"}
# FUN_18012fd40: atom -> type id, cross-referenced against docs/fut_atoms.tsv
TYPE_NAMES = {
0x01: 'players', 0x02: 'playersBronze', 0x03: 'playersSilver',
0x04: 'playersGold', 0x05: 'rarePlayers', 0x0a: 'staff',
0x0b: 'staffManager', 0x0c: 'staffHeadCoach', 0x0d: 'staffGKCoach',
0x0e: 'staffPhysio', 0x0f: 'staffFitnessCoach', 0x14: 'stadia',
0x1e: 'balls', 0x28: 'kits', 0x29: 'kitsHome', 0x2a: 'kitsAway',
0x2d: 'badges', 0x2e: 'badgeDBid', 0x2f: 'leagueLogos', 0x32: 'trophies',
0x33: 'trophiesOffline', 0x34: 'trophiesOnline',
0x35: 'trophiesFeaturedOffline', 0x36: 'trophiesFeaturedOnline',
0x37: 'trophiesSeasonOffline', 0x38: 'trophiesSeasonOnline',
0x3c: 'consumables', 0x41: 'consumablesHealing',
0x42: 'consumablesContractPlayer', 0x43: 'consumablesTrainingPlayer',
0x44: 'consumablesFitnessPlayer', 0x45: 'consumablesPosition',
0x46: 'consumablesTrainingGk', 0x47: 'consumablesContractManager',
0x48: 'consumablesFormationManager', 0x49: 'consumablesTrainingManager',
0x4a: 'consumablesFitnessTeam',
0x4b: 'consumablesTrainingPlayerPlayStyle',
0x4c: 'consumablesTrainingGkPlayStyle',
0x4d: 'consumablesTrainingManagerLeagueModifier',
}
PLAYERS_TYPE_ID = 1
# ------------------------------------------------------------------ process --
def find_pid():
for d in glob.glob("/proc/[0-9]*"):
try:
with open(d + "/comm") as f:
if f.read().strip() == PROC_NAME:
return int(d.rsplit("/", 1)[-1])
except Exception:
pass
return None
def module_base(pid, name=DLL):
"""Live load base of CardsDLL. It is NOT 0x180000000 in the Wine process."""
try:
with open("/proc/%d/maps" % pid) as f:
for line in f:
if name in line:
return int(line.split("-", 1)[0], 16)
except Exception:
return None
return None
class Mem(object):
"""Read-only /proc/PID/mem accessor. Failures return None, never raise."""
def __init__(self, pid):
self.pid = pid
self.fails = 0
self.f = open("/proc/%d/mem" % pid, "rb") # "rb": read-only, by design
self.assert_read_only()
def assert_read_only(self):
"""Abort rather than continue if this handle could ever write."""
mode = getattr(self.f, "mode", "")
if not self.f.readable() or self.f.writable() or "+" in mode or "w" in mode:
raise SystemExit("REFUSING TO RUN: /proc/%d/mem handle is not read-only "
"(mode=%r). This tool must never write to the game."
% (self.pid, mode))
def read(self, va, n):
if va is None or va < PTR_LO or va > PTR_HI:
self.fails += 1
return None
try:
self.f.seek(va)
b = self.f.read(n)
except Exception:
self.fails += 1
return None
if b is None or len(b) != n:
self.fails += 1
return None
return b
def q(self, va):
b = self.read(va, 8)
return struct.unpack("<Q", b)[0] if b else None
def u32(self, va):
b = self.read(va, 4)
return struct.unpack("<I", b)[0] if b else None
def i32(self, va):
b = self.read(va, 4)
return struct.unpack("<i", b)[0] if b else None
def u8(self, va):
b = self.read(va, 1)
return b[0] if b else None
def alive(self):
return os.path.exists("/proc/%d" % self.pid)
def plausible(p):
return p is not None and PTR_LO <= p <= PTR_HI and (p & 7) == 0
# ------------------------------------------------------------- tree walking --
def walk(mem, head, root, key_signed, want_value):
"""DFS a red-black tree. Returns (list_of_(key, value_or_None, node_va), note).
Both children are followed with a visited set, so the left/right convention
does not matter and a cycle or a garbage pointer stops the walk instead of
hanging it. `head` is the sentinel; it is never treated as a real node.
"""
if root is None:
return [], "root unreadable"
if root == 0 or root == head:
return [], "empty"
out, seen, stack, note = [], set(), [root], "ok"
while stack:
p = stack.pop()
if p == 0 or p == head or p in seen:
continue
if not plausible(p):
note = "hit an implausible pointer -- walk partial"
continue
if len(out) >= MAX_NODES:
note = "TRUNCATED at %d nodes" % MAX_NODES
break
seen.add(p)
k = mem.i32(p + N_KEY) if key_signed else mem.u32(p + N_KEY)
if k is None:
note = "node key unreadable -- walk partial"
continue
v = mem.i32(p + IN_VALUE) if want_value else None
out.append((k, v, p))
for slot in (N_C0, N_C1):
c = mem.q(p + slot)
if c is None:
note = "child pointer unreadable -- walk partial"
continue
if c and c != head and c not in seen:
stack.append(c)
return out, note
def read_store(mem, cdb):
"""Snapshot the whole two-level stat store. Never raises."""
st = cdb + STORE_OFF
s = {
"cardsdb": cdb,
"store": st,
"parsed": mem.u8(st + ST_PARSED),
"mode": mem.i32(st + ST_MODE),
"mode_arg1": mem.i32(st + ST_MODE_ARG1),
"mode_arg2": mem.i32(st + ST_MODE_ARG2),
"outer_size": mem.i32(st + ST_MAP_SIZE),
"buckets": None,
"note": "",
}
head = st + ST_MAP_HEAD
root = mem.q(st + ST_MAP_ROOT)
s["outer_root"] = root
outer, note = walk(mem, head, root, key_signed=False, want_value=False)
s["note"] = note
if note in ("root unreadable",):
return s
buckets = []
for ctx, _v, node in sorted(outer):
ihead = node + ON_INNER_HEAD
iroot = mem.q(node + ON_INNER_ROOT)
isize = mem.i32(node + ON_INNER_SIZE)
inner, inote = walk(mem, ihead, iroot, key_signed=True, want_value=True)
buckets.append({
"contextValue": ctx,
"node": node,
"size_field": isize,
"entries": sorted((k, v) for k, v, _ in inner),
"note": inote,
})
s["buckets"] = buckets
return s
def stat_get(store_snapshot, ctx, type_id):
"""Exactly what vt+0x7f8 returns: the value, or 0 when either key is absent.
Returns (value, found) so a stored 0 can be told apart from an absent key --
the game itself cannot make that distinction.
"""
for b in store_snapshot.get("buckets") or []:
if b["contextValue"] == ctx:
for k, v in b["entries"]:
if k == type_id:
return v, True
return 0, False
return 0, False
# ---------------------------------------------------------------- reporting --
def tname(t):
return TYPE_NAMES.get(t, "type_%#x" % t)
def fmt_ptr(v):
return "UNREADABLE" if v is None else "%#x" % v
def report(s, raw=None):
lines = []
a = lines.append
a("CardsDb %s" % fmt_ptr(s["cardsdb"]))
a("store (cdb+0x1f8b0) %s" % fmt_ptr(s["store"]))
p = s["parsed"]
a("store+0x28 parsed %s%s" % ("UNREADABLE" if p is None else p,
" <== a FutStickerBookStats2 body completed parsing"
if p == 1 else ""))
m = s["mode"]
a("store+0x78 mode %s (%s) +0x7c=%s +0x80=%s"
% ("UNREADABLE" if m is None else m,
MODE_NAMES.get(m, "unknown/never-set"),
s["mode_arg1"], s["mode_arg2"]))
a("outer map root=%s size_field=%s walk=%s"
% (fmt_ptr(s.get("outer_root")), s["outer_size"], s["note"]))
buckets = s["buckets"]
if buckets is None:
a(" (outer map unreadable)")
elif not buckets:
a(" NO BUCKETS -- the stat map is empty.")
else:
for b in buckets:
a(" bucket contextValue=%d node=%#x size_field=%s entries=%d (%s)"
% (b["contextValue"], b["node"], b["size_field"],
len(b["entries"]), b["note"]))
if b["size_field"] is not None and b["size_field"] != len(b["entries"]):
a(" WARNING: size field disagrees with the walk -- walk suspect")
for k, v in b["entries"]:
a(" %-3d %-42s = %s" % (k, tname(k), v))
a("")
v, found = stat_get(s, 0, PLAYERS_TYPE_ID)
a("vt+0x800(typeId=1 'players') -> %d [%s]"
% (v, "PRESENT in bucket 0" if found
else "ABSENT -- the getter returns 0 by fallthrough"))
if found and v:
a("VERDICT INPUT: the store HOLDS players=%d. Delivery and schema are FINE;"
% v)
a(" a panel reading 0 is then a SELECTION failure.")
elif s["parsed"] == 1 and not found:
a("VERDICT INPUT: a body parsed (parsed=1) but bucket 0 / type 1 is absent.")
a(" Either our contextValue is not 0 or our type string is not")
a(" mapping to id 1. That is a SCHEMA failure, not selection.")
elif not buckets:
a("VERDICT INPUT: the store is empty. Our /club/stats body is NOT landing.")
if m in (2, 3, 4) and buckets is not None:
keyed = [b["contextValue"] for b in buckets if b["contextValue"] != 0]
a("NOTE: mode %d reads PER-ROW buckets (%s), never bucket 0."
% (m, {2: "NATION_ID for team rows", 3: "LEAGUE_ID", 4: "TEAM_ID"}[m]))
a(" non-zero buckets present: %s"
% (keyed if keyed else "NONE -- every per-row lookup falls through to 0"))
if raw is not None:
a("")
a("raw store[0x00:0x90]:")
for off in range(0, 0x90, 16):
chunk = raw[off:off + 16]
a(" +%#04x %s" % (off, " ".join("%02x" % c for c in chunk)))
return "\n".join(lines)
def signature(s):
"""Change key for --watch: everything a human would notice."""
return (s["parsed"], s["mode"], s["mode_arg1"], s["mode_arg2"],
tuple((b["contextValue"], tuple(b["entries"]))
for b in (s["buckets"] or [])))
# --------------------------------------------------------------------- main --
def main():
ap = argparse.ArgumentParser(
description="Read FIFA 17's live FUT club-stat store (READ-ONLY)")
ap.add_argument("--watch", action="store_true",
help="poll and print whenever the store changes (Ctrl-C to stop)")
ap.add_argument("--interval", type=float, default=0.5, help="poll seconds")
ap.add_argument("--raw", action="store_true",
help="also hexdump store[0x00:0x90]")
ap.add_argument("--get", type=lambda x: int(x, 0), default=None, metavar="TYPEID",
help="emulate vt+0x800(TYPEID) and print just that value")
ap.add_argument("--ctx", type=lambda x: int(x, 0), default=0,
help="contextValue bucket for --get (default 0, what the panel uses)")
args = ap.parse_args()
pid = find_pid()
if pid is None:
print("%s is not running. Start FIFA, reach the FUT hub, then run this." % PROC_NAME)
return 1
base = module_base(pid)
if base is None:
print("%s (pid %d) is running but %s is not mapped yet." % (PROC_NAME, pid, DLL))
print("Wait for the FUT layer to load (main menu / Ultimate Team) and re-run.")
return 1
try:
mem = Mem(pid)
except SystemExit:
raise
except Exception as e:
print("cannot open /proc/%d/mem: %s" % (pid, e))
print("Need: sudo sysctl -w kernel.yama.ptrace_scope=0")
return 1
gva = base + (G_CARDSDB - IMG_BASE)
print("pid=%d %s base=%#x (image base %#x)" % (pid, DLL, base, IMG_BASE))
print("CardsDb global @ %#x (static %#x)" % (gva, G_CARDSDB))
def snap():
cdb = mem.q(gva)
if cdb is None:
return None, "CardsDb global unreadable"
if cdb == 0:
return None, "CardsDb singleton is NULL -- the FUT layer is not constructed"
if not plausible(cdb):
return None, "CardsDb global holds an implausible pointer %#x" % cdb
st = cdb + STORE_OFF
if mem.read(st, 0x90) is None:
return None, "store window at %#x is not mapped" % st
return read_store(mem, cdb), None
if args.get is not None:
s, err = snap()
if err:
print(err)
return 1
v, found = stat_get(s, args.ctx, args.get)
print("stat_get(ctx=%d, type=%d %s) = %d [%s]"
% (args.ctx, args.get, tname(args.get), v,
"present" if found else "ABSENT (getter fallthrough 0)"))
return 0
def once():
s, err = snap()
if err:
print("[%s] %s" % (time.strftime("%H:%M:%S"), err))
return None
raw = mem.read(s["store"], 0x90) if args.raw else None
print(report(s, raw))
return s
if not args.watch:
print()
s = once()
print("\nfailed reads: %d" % mem.fails)
return 0 if s else 1
print("watching -- navigate FIFA into MY CLUB now. Ctrl-C to stop.\n")
last = None
try:
while True:
if not mem.alive():
print("[%s] %s exited." % (time.strftime("%H:%M:%S"), PROC_NAME))
break
s, err = snap()
if err:
if last != err:
print("[%s] %s" % (time.strftime("%H:%M:%S"), err))
last = err
else:
sig = signature(s)
if sig != last:
print("=" * 68)
print("[%s] STORE CHANGED" % time.strftime("%H:%M:%S"))
print(report(s, mem.read(s["store"], 0x90) if args.raw else None))
print()
last = sig
time.sleep(args.interval)
except KeyboardInterrupt:
print("\nstopped")
print("failed reads: %d" % mem.fails)
return 0
if __name__ == "__main__":
sys.exit(main())