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,53 @@
#!/usr/bin/env python3
# Dump the FUT atom name table (atom index -> key string) from CardsDLL.
# Table at VA 0x1802d2760 is an array of char* pointers into .rdata.
import struct, sys
DLL = "/tmp/fut/cardsdll.dll"
data = open(DLL, "rb").read()
# (VA_start, size, file_off) from objdump -h
SECTIONS = [
(0x180001000, 0x1e3f62, 0x400), # .text
(0x1801e5000, 0xa4094, 0x1e4400), # .rdata
(0x18028a000, 0x54000, 0x288600), # .data
]
def va_to_off(va):
for start, size, off in SECTIONS:
if start <= va < start + size:
return off + (va - start)
return None
def read_cstr(va, maxlen=128):
off = va_to_off(va)
if off is None:
return None
end = data.find(b"\x00", off, off + maxlen)
if end < 0:
return None
try:
return data[off:end].decode("ascii")
except UnicodeDecodeError:
return None
TABLE_VA = 0x1802d2760
off = va_to_off(TABLE_VA)
atoms = {}
for i in range(0, 1200):
ptr = struct.unpack_from("<Q", data, off + i * 8)[0]
if ptr == 0:
s = None
else:
s = read_cstr(ptr)
if s is None:
# allow a few gaps then stop if we run off the end
if i > 40 and all(struct.unpack_from("<Q", data, off + (i + k) * 8)[0] == 0 for k in range(4)):
break
continue
if s.isprintable() and 1 <= len(s) <= 40:
atoms[i] = s
for i in sorted(atoms):
print(f"{i}\t0x{i:x}\t{atoms[i]}")
print(f"# total {len(atoms)} atoms", file=sys.stderr)
@@ -0,0 +1,72 @@
#!/usr/bin/env python3
"""Detached poller: watch the FirstPartyAuthTokenRetriever auth-request region for
ANY change (does FUT-entry ever enqueue an auth request?).
DoTick @0x146f199c0 (read at rip 0x146f199e3) polls *[0x1448a3b20]+0x4e98+0x08 every
frame and always sees 0 -> never requests a token. If entering FUT enqueues a request,
one of these bytes changes. Pure /proc/mem reads (no ptrace) so it survives across turns.
Logs every change with a timestamp to /tmp/auth_watch.log.
"""
import glob, os, struct, time
AUTHBLOCK_PP = 0x1448a3b20
SLOT_OFF = 0x4e98
SPAN = 0x40
ORIGINMGR_PP = 0x1448acf50
LOG = "/tmp/auth_watch.log"
def log(m):
line = f"[{time.strftime('%H:%M:%S')}] {m}"
print(line, flush=True)
with open(LOG, "a") as f: f.write(line + "\n")
def find_pid():
for d in glob.glob('/proc/[0-9]*'):
try:
if open(d + '/comm').read().strip() == 'FIFA17.exe':
return int(os.path.basename(d))
except Exception:
pass
return None
def main():
open(LOG, "w").close()
log("=== auth_watch start ===")
pid = None; f = None; last = None; last_flag = None
while True:
p = find_pid()
if p != pid:
pid = p; last = None; last_flag = None
if f: f.close(); f = None
if pid:
f = open(f"/proc/{pid}/mem", "rb")
log(f"FIFA pid={pid}")
if not pid:
time.sleep(0.2); continue
try:
f.seek(AUTHBLOCK_PP); ab = struct.unpack('<Q', f.read(8))[0]
f.seek(ORIGINMGR_PP); om = struct.unpack('<Q', f.read(8))[0]
snap = None
if ab:
f.seek(ab + SLOT_OFF); snap = f.read(SPAN)
flag = None
if om:
f.seek(om + 0x13); flag = f.read(1)[0]
except Exception:
time.sleep(0.05); continue
if snap is not None and snap != last:
hx = " ".join(f"{b:02x}" for b in snap)
log(f"AUTH-REGION CHANGE @[{ab+SLOT_OFF:#x}]:")
log(f" {hx}")
# decode the two 8-byte slots the retriever cares about
s08 = struct.unpack('<Q', snap[0x08:0x10])[0]
s10 = struct.unpack('<Q', snap[0x10:0x18])[0]
log(f" +0x08={s08:#x} +0x10={s10:#x} (nonzero = auth request enqueued!)")
last = snap
if flag is not None and flag != last_flag:
log(f"m_isLoggedIn -> {flag}")
last_flag = flag
time.sleep(0.01)
if __name__ == "__main__":
main()
@@ -0,0 +1,99 @@
#!/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, sys
# Watch for a (re)launched FIFA17.exe and auto-apply ProtoSSL cert + FUT store patches
import glob, time, os
GATE2=0x1461361b0; GATE2_ORIG=bytes.fromhex("48895c"); GATE2_PATCH=bytes.fromhex("31c0c3")
GATE1=0x146132548; GATE1_ORIG=bytes.fromhex("0f8576010000"); GATE1_PATCH=bytes.fromhex("90"*6)
RET_TRUE = bytes.fromhex("b801000000c3")
NOP2 = bytes.fromhex("9090")
IMG_BASE = 0x180000000
STORE_PATCHES = {
0x1800f7fb0: RET_TRUE,
0x1800fb850: RET_TRUE,
0x180100500: RET_TRUE,
0x180013cf0: RET_TRUE,
0x180017543: bytes.fromhex("eb3f"),
0x180017487: NOP2,
0x180017490: NOP2,
0x1800175aa: NOP2,
}
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}"
print(line,flush=True); open(LOG,"a").write(line+"\n")
def find_pids():
out=[]
for d in glob.glob('/proc/[0-9]*'):
try:
if open(d+'/comm').read().strip()=='FIFA17.exe': out.append(int(d.split('/')[-1]))
except: pass
return out
def cardsdll_base(pid):
try:
for line in open(f'/proc/{pid}/maps'):
if 'CardsDLL' in line: return int(line.split('-')[0], 16)
except: pass
return None
def rd(pid,va,n):
with open(f'/proc/{pid}/mem','rb') as f:
f.seek(va); return f.read(n)
def wr(pid,va,b):
with open(f'/proc/{pid}/mem','r+b') as f:
f.seek(va); f.write(b)
patched=set()
store_patched=set()
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:
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}")
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)
@@ -0,0 +1,116 @@
#!/usr/bin/env python3
# FROZEN: not started by openfut-fut.sh (see its SERVERS array); identity is NOT
# sourced from fut_account.py here. The live pair is lsx_responder_v2.py +
# blaze_responder_v3b.py -- edit those. Kept for reference/bisecting only.
"""FIFA17 Blaze redirector RESPONDER + second-hop Fire2 capture.
- TLS on 42127: answers POST /redirector/getServerInstance with a
<serverinstanceinfo> pointing the client at 127.0.0.1:BLAZE_PORT (secure=0).
- Plain TCP on BLAZE_PORT: logs the client's second-hop Fire2/Heat2 handshake.
All XML schema is a clean-room best-guess from the client's own TDF field names;
iterate based on client reaction (re-POST = parse reject; connect on BLAZE_PORT = success).
"""
import socket, ssl, threading, time, binascii
HOST="127.0.0.1"; REDIR_PORT=42127; BLAZE_PORT=42130
BLAZE_IP_STR="127.0.0.1"; BLAZE_IP_U32=(127<<24)|1 # 2130706433
LOG="/tmp/blaze_responder.log"
def log(m):
line=f"[{time.strftime('%H:%M:%S')}] {m}"
print(line,flush=True)
open(LOG,"a").write(line+"\n")
def build_response():
# Confirmed schema (clean-room, MEC Catalyst): ServerInstanceInfo.address is a
# ServerAddress union -> Heat2 XML union = <address member="N"><valu>...</valu></address>.
# member="0" = ipAddress variant {hostname, ip(uint32 decimal), port(uint16)}.
body=(
'<?xml version="1.0" encoding="UTF-8"?>\n'
'<serverinstanceinfo>\n'
'\t<address member="0">\n'
'\t\t<valu>\n'
f'\t\t\t<hostname>{BLAZE_IP_STR}</hostname>\n'
f'\t\t\t<ip>{BLAZE_IP_U32}</ip>\n'
f'\t\t\t<port>{BLAZE_PORT}</port>\n'
'\t\t</valu>\n'
'\t</address>\n'
'\t<secure>0</secure>\n'
'\t<trialservicename></trialservicename>\n'
'\t<defaultdnsaddress>0</defaultdnsaddress>\n'
'</serverinstanceinfo>\n'
)
b=body.encode()
hdr=(f"HTTP/1.1 200 OK\r\nContent-Type: application/xml\r\n"
f"Content-Length: {len(b)}\r\nConnection: close\r\n\r\n").encode()
return hdr+b
ctx=ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
ctx.load_cert_chain("redir_cert.pem","redir_key.pem")
ctx.minimum_version=ssl.TLSVersion.TLSv1
ctx.set_ciphers("ALL:@SECLEVEL=0")
def redir_handle(raw,addr):
try:
tls=ctx.wrap_socket(raw,server_side=True)
except ssl.SSLError as e:
log(f"REDIR REJECTED {addr}: {e}"); raw.close(); return
log(f"REDIR TLS-OK {addr} cipher={tls.cipher()[0]}")
try:
tls.settimeout(8)
req=b""
while b"\r\n\r\n" not in req:
c=tls.recv(4096)
if not c: break
req+=c
# read body per content-length
if b"content-length:" in req.lower():
hdr,_,rest=req.partition(b"\r\n\r\n")
cl=int([l.split(b":")[1] for l in hdr.split(b"\r\n") if l.lower().startswith(b"content-length")][0])
while len(rest)<cl:
c=tls.recv(4096)
if not c: break
rest+=c
req=hdr+b"\r\n\r\n"+rest
line0=req.split(b"\r\n",1)[0].decode(errors="replace")
log(f"REDIR REQ {addr}: {line0}")
resp=build_response()
tls.sendall(resp)
log(f"REDIR SENT {addr} {len(resp)}B serverinstanceinfo -> {BLAZE_IP_STR}:{BLAZE_PORT}")
time.sleep(0.3)
tls.close()
except Exception as e:
log(f"REDIR ERR {addr}: {e}")
def blaze_handle(raw,addr):
log(f"*** BLAZE 2nd-HOP CONNECT from {addr} (client accepted our redirect!) ***")
try:
raw.settimeout(8)
blob=b""
while len(blob)<65536:
c=raw.recv(4096)
if not c: break
blob+=c
# Fire2 frames are short; log as we go
if len(blob)>=16 and len(c)<4096: break
if blob:
fn=f"/tmp/blaze_fire2_{addr[1]}.bin"
open(fn,"wb").write(blob)
log(f"BLAZE FIRE2 {len(blob)}B -> {fn}")
log("HEX:\n"+"\n".join(f" {i:04x}: {binascii.hexlify(blob[i:i+16]).decode()}" for i in range(0,min(len(blob),192),16)))
else:
log(f"BLAZE connect but no bytes from {addr}")
raw.close()
except Exception as e:
log(f"BLAZE ERR {addr}: {e}")
def serve(port,handler,name):
s=socket.socket(); s.setsockopt(socket.SOL_SOCKET,socket.SO_REUSEADDR,1)
s.bind((HOST,port)); s.listen(16)
log(f"{name} listening on {HOST}:{port}")
while True:
c,a=s.accept()
threading.Thread(target=handler,args=(c,a),daemon=True).start()
log("=== RESPONDER START ===")
threading.Thread(target=serve,args=(BLAZE_PORT,blaze_handle,"BLAZE"),daemon=True).start()
serve(REDIR_PORT,redir_handle,"REDIR")
@@ -0,0 +1,578 @@
#!/usr/bin/env python3
# FROZEN: not started by openfut-fut.sh (see its SERVERS array); identity is NOT
# sourced from fut_account.py here. The live pair is lsx_responder_v2.py +
# blaze_responder_v3b.py -- edit those. Kept for reference/bisecting only.
"""FIFA17 Blaze redirector + SESSION SERVER (v2).
Two listeners:
* TLS on 42127 -- the redirector. Answers POST /redirector/getServerInstance
with a <serverinstanceinfo> pointing the client at 127.0.0.1:BLAZE_PORT
(secure=0). UNCHANGED from blaze_responder.py -- it already works.
* Plain TCP on 42130 -- the Blaze session server. Properly frames Fire2,
decodes the Heat2 TDF body, logs everything, and ANSWERS:
Util(0x0009)/preAuth(0x0007) -> PreAuthResponse (the current gate)
Util(0x0009)/ping(0x0002) -> PingResponse {STIM, TIME}
msgType PING(4) -> PING_REPLY(5), empty body
Everything else is logged in full and (optionally) answered with an empty
REPLY so the client is never left hanging. See the TODO block near
dispatch() for the next RPCs on the path.
CLEAN ROOM. Schema comes from (a) FIFA17.exe's own in-process TDF reflection
metadata that we walked in live memory, (b) our own captured preAuth REQUEST,
and (c) independent third-party clean-room BlazeSDK-15.x reimplementations used
only to cross-check structure. No EA/FIFA leaked source was consulted.
Run: python3 blaze_responder_v2.py (binds 42127 + 42130)
Log: /tmp/blaze_responder.log
"""
from __future__ import annotations
import binascii
import os
import socket
import ssl
import struct
import sys
import threading
import time
from collections import OrderedDict
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import heat2 # noqa: E402
from heat2 import INT, STRING, STRUCT, LIST, MAP, encode_tdf, decode_tdf # noqa: E402
# ------------------------------------------------------------------ config
HOST = "127.0.0.1"
REDIR_PORT = 42127
BLAZE_PORT = 42130
BLAZE_IP_STR = "127.0.0.1"
BLAZE_IP_U32 = (127 << 24) | 1 # 2130706433
LOG = "/tmp/blaze_responder.log"
HERE = os.path.dirname(os.path.abspath(__file__))
CERT = os.path.join(HERE, "redir_cert.pem")
KEY = os.path.join(HERE, "redir_key.pem")
# If True, any RPC we do not implement still gets an empty REPLY frame so the
# client's request does not time out. Flip to False to see which RPC the
# client is actually blocking on.
REPLY_EMPTY_TO_UNKNOWN = True
# Dump every frame we receive to /tmp/blaze_rx_<comp>_<cmd>_<n>.bin
DUMP_FRAMES = True
_log_lock = threading.Lock()
def log(m: str) -> None:
line = "[%s] %s" % (time.strftime("%H:%M:%S"), m)
with _log_lock:
print(line, flush=True)
with open(LOG, "a") as fh:
fh.write(line + "\n")
def hexdump(b: bytes, limit: int = 512) -> str:
out = []
for i in range(0, min(len(b), limit), 16):
chunk = b[i:i + 16]
txt = "".join(chr(c) if 32 <= c < 127 else "." for c in chunk)
out.append(" %04x: %-47s %s"
% (i, binascii.hexlify(chunk, " ").decode(), txt))
if len(b) > limit:
out.append(" ... (%d more bytes)" % (len(b) - limit))
return "\n".join(out)
# ------------------------------------------------------------------ Fire2
#
# CORRECTED 16-byte big-endian header (heat2.build_fire2_frame /
# heat2.parse_fire2_frame encode the OLD, WRONG layout -- do not use them):
#
# [0:4] u32 payload length
# [4:6] u16 metadata length
# [6:8] u16 component
# [8:10] u16 command
# [10:13] u24 msgNum <- 3 bytes; what we once read as "msgType"
# [13] u8 (msgType << 5) | (userIndex & 0x1F)
# [14] u8 options
# [15] u8 reserved
# wire = header(16) || metadata || payload
#
# There is NO error field in the Fire2 header (that is Fire v1's 12-byte frame).
FIRE2_HDR = 16
MESSAGE, REPLY, NOTIFICATION, ERROR_REPLY, PING, PING_REPLY = range(6)
MSGTYPE_NAME = {0: "MESSAGE", 1: "REPLY", 2: "NOTIFICATION",
3: "ERROR_REPLY", 4: "PING", 5: "PING_REPLY"}
COMP_AUTH = 0x0001
COMP_GAMEMANAGER = 0x0004
COMP_REDIRECTOR = 0x0005
COMP_STATS = 0x0007
COMP_UTIL = 0x0009
COMP_MESSAGING = 0x000F
COMP_ASSOCLISTS = 0x0019
COMP_GAMEREPORTING = 0x001C
COMP_USERSESSIONS = 0x7802
CMD_FETCHCLIENTCONFIG = 0x0001
CMD_PING = 0x0002
CMD_PREAUTH = 0x0007
CMD_POSTAUTH = 0x0008
CMD_SETCLIENTSTATE = 0x001C
# Util command table recovered from the binary's getCommandName switch.
UTIL_CMDS = {
0x01: "fetchClientConfig", 0x02: "ping", 0x03: "setClientData",
0x04: "localizeStrings", 0x05: "getTelemetryServer", 0x06: "getTickerServer",
0x07: "preAuth", 0x08: "postAuth", 0x0A: "userSettingsLoad",
0x0B: "userSettingsSave", 0x0C: "userSettingsLoadAll",
0x0E: "userSettingsDelete", 0x0F: "userSettingsLoadAllForUser",
0x14: "filterForProfanity", 0x15: "fetchQosConfig",
0x16: "setClientMetrics", 0x17: "setConnectionState",
0x19: "getUserOptions", 0x1A: "setUserOptions", 0x1B: "suspendUserPing",
0x1C: "setClientState",
}
COMP_NAMES = {
COMP_AUTH: "Authentication", COMP_GAMEMANAGER: "GameManager",
COMP_REDIRECTOR: "Redirector", COMP_STATS: "Stats", COMP_UTIL: "Util",
COMP_MESSAGING: "Messaging", COMP_ASSOCLISTS: "AssociationLists",
COMP_GAMEREPORTING: "GameReporting", COMP_USERSESSIONS: "UserSessions",
}
def rpc_name(component: int, command: int) -> str:
comp = COMP_NAMES.get(component, "Component:0x%04x" % component)
if component == COMP_UTIL:
cmd = UTIL_CMDS.get(command, "cmd:0x%04x" % command)
else:
cmd = "cmd:0x%04x" % command
return "%s::%s" % (comp, cmd)
def fire2(component: int, command: int, msg_num: int, msg_type: int,
payload: bytes = b"", metadata: bytes = b"",
user_index: int = 0, options: int = 0) -> bytes:
h = bytearray(16)
struct.pack_into(">I", h, 0, len(payload))
struct.pack_into(">H", h, 4, len(metadata))
struct.pack_into(">H", h, 6, component & 0xFFFF)
struct.pack_into(">H", h, 8, command & 0xFFFF)
h[10] = (msg_num >> 16) & 0xFF
h[11] = (msg_num >> 8) & 0xFF
h[12] = msg_num & 0xFF
h[13] = ((msg_type & 0x07) << 5) | (user_index & 0x1F)
h[14] = options & 0xFF
h[15] = 0
return bytes(h) + metadata + payload
def parse_fire2_header(buf: bytes) -> dict:
return dict(
payload_len=struct.unpack_from(">I", buf, 0)[0],
metadata_len=struct.unpack_from(">H", buf, 4)[0],
component=struct.unpack_from(">H", buf, 6)[0],
command=struct.unpack_from(">H", buf, 8)[0],
msg_num=(buf[10] << 16) | (buf[11] << 8) | buf[12],
msg_type=(buf[13] >> 5) & 0x07,
user_index=buf[13] & 0x1F,
options=buf[14],
reserved=buf[15],
)
def reply_to(hdr: dict, payload: bytes = b"", msg_type: int = REPLY) -> bytes:
"""A Blaze reply echoes component/command/msgNum/userIndex verbatim and
only overwrites the msgType bits."""
return fire2(hdr["component"], hdr["command"], hdr["msg_num"], msg_type,
payload, user_index=hdr["user_index"])
# ------------------------------------------------------- PreAuthResponse
#
# Reconciled schema: reflection descriptor VA 0x144875600 (14 members) INTERSECT
# the independent clean-room emulators. Members are emitted in ascending
# packed-tag order; heat2.encode_tdf enforces that automatically.
# EA numeric title id. NOT reverse engineered -- onPreAuthResponse only
# memcpy's ASRC/ESRC/RSRC, so any value is accepted here; Authentication
# (component 1) may care later.
TITLE_ID = "309111"
# Nucleus client id. Plausible convention, not RE'd.
CLIENT_ID = "FIFA17-PC-SERVER-BLAZE"
# Persona namespace. Client caps this field at 32 bytes.
PERSONA_NAMESPACE = "cem_ea_id"
PLATFORM = "pc"
SERVER_VERSION = "Blaze 15.1.1.3.0 (OpenFUT)\n" # EA's real value ends in \n
# Component ids recovered from each component's own notification dispatcher in
# FIFA17.exe. This is the client's view of "which components exist server
# side"; later components look themselves up in this list.
COMPONENT_IDS = [
COMP_AUTH, # 1 Authentication
COMP_GAMEMANAGER, # 4 GameManager
COMP_REDIRECTOR, # 5 Redirector
COMP_STATS, # 7 Stats
COMP_UTIL, # 9 Util
COMP_MESSAGING, # 15 Messaging
COMP_ASSOCLISTS, # 25 AssociationLists
COMP_GAMEREPORTING, # 28 GameReporting
COMP_USERSESSIONS, # 30722 UserSessions
]
# The request carried FCCR{CFID="BlazeSDK"}, i.e. an embedded fetchClientConfig
# for the "BlazeSDK" section -- so CONF.CONF is that section. These five keys
# are the ones ConnectionManager::onPreAuthResponse actually reads (verified by
# disassembly); every one has a fallback, so nothing here is strictly required.
# Time values are MICROSECONDS: the client divides by 1000 to get ms.
BLAZESDK_CONFIG = [
("connIdleTimeout", "90000000"), # 90 s
("defaultRequestTimeout", "30000000"), # 30 s
("enableQosBandwidthTest", "false"), # exact string "false" clears bit 1
("enableQosFirewallTest", "false"), # exact string "false" clears bit 0
("pingPeriod", "20000000"), # 20 s (default would be 15000 ms)
]
# TODO(auth gate): the BlazeSDK section also carries the Nucleus endpoints
# nucleusConnect / nucleusConnectTrusted / nucleusPortal / nucleusProxy.
# Pointing those at a local HTTPS shim is our lever for offline login.
# Not emitted yet -- unread at preAuth, and wrong values may send the client
# at a real EA host during Authentication::login.
def qos_config() -> "OrderedDict":
"""Blaze::QosConfigInfo -- 4 members per reflection (there is NO SVID in
FIFA17's descriptor, unlike Mirror's Edge Catalyst)."""
return OrderedDict([
("BWPS", (STRUCT, OrderedDict([ # Blaze::QosPingSiteInfo
("PSA", (STRING, "127.0.0.1")), # address
("PSP", (INT, 17502)), # port
]))),
("LNP", (INT, 10)), # numLatencyProbes
("LTPS", (MAP, (STRING, STRUCT, []))), # pingSiteInfoByAliasMap: EMPTY
("TIME", (INT, 5000000)), # timeout, microseconds
])
def preauth_response_fields(service_name: str = "fifa-2017-pc") -> "OrderedDict":
return OrderedDict([
("ASRC", (STRING, TITLE_ID)), # authenticationSource
("CIDS", (LIST, (INT, COMPONENT_IDS))), # componentIds
("CLID", (STRING, CLIENT_ID)), # clientId
("CONF", (STRUCT, OrderedDict([ # Util::FetchConfigResponse
("CONF", (MAP, (STRING, STRING, list(BLAZESDK_CONFIG)))),
]))),
("ESRC", (STRING, TITLE_ID)), # entitlementSource
("INST", (STRING, service_name)), # serviceName -- echo CDAT.SVCN
("MAID", (INT, 0)), # machineId
("MINR", (INT, 0)), # underageSupported = false
("NASP", (STRING, PERSONA_NAMESPACE)), # personaNamespace
("PILD", (STRING, "")), # legalDocGameIdentifier
("PLAT", (STRING, PLATFORM)), # platform
("QOSS", (STRUCT, qos_config())), # qosSettings
("RSRC", (STRING, TITLE_ID)), # registrationSource
("SVER", (STRING, SERVER_VERSION)), # serverVersion
])
def ping_response_fields() -> "OrderedDict":
"""Blaze 15.1.1.1.0+ reads STIM, 15.1.1.0.x reads TIME. FIFA17 reports
BSDK 15.1.1.3.0, so STIM is the live one -- but unknown tags are ignored,
so emit both and stay version-proof. (Tag order STIM < TIME is handled by
heat2's ascending-tag sort.)"""
now = int(time.time())
return OrderedDict([("STIM", (INT, now)), ("TIME", (INT, now))])
def extract_service_name(fields) -> str:
"""PreAuthRequest.CDAT.SVCN -- echo it back as INST."""
try:
cdat = fields.get("CDAT")
if cdat and cdat[0] == STRUCT:
svcn = cdat[1].get("SVCN")
if svcn and svcn[0] == STRING and svcn[1]:
return svcn[1]
except Exception:
pass
return "fifa-2017-pc"
# ------------------------------------------------------------------ dispatch
def dispatch(hdr: dict, fields, raw_payload: bytes):
"""-> bytes to send back, or None to stay silent."""
comp, cmd, mtype = hdr["component"], hdr["command"], hdr["msg_type"]
# Transport-level PING frame (msgType 4) -- answer with PING_REPLY (5).
if mtype == PING:
log(" -> transport PING, answering PING_REPLY (empty)")
return reply_to(hdr, b"", msg_type=PING_REPLY)
if mtype not in (MESSAGE, PING):
log(" -> msgType %s is not a request; not answering"
% MSGTYPE_NAME.get(mtype, mtype))
return None
if comp == COMP_UTIL and cmd == CMD_PREAUTH:
svcn = extract_service_name(fields) if fields is not None else "fifa-2017-pc"
resp = preauth_response_fields(service_name=svcn)
payload = encode_tdf(resp)
log(" -> PreAuthResponse (INST=%r, %d payload bytes):\n%s"
% (svcn, len(payload), heat2.dump(resp)))
return reply_to(hdr, payload)
if comp == COMP_UTIL and cmd == CMD_PING:
resp = ping_response_fields()
log(" -> PingResponse %s" % dict((k, v[1]) for k, v in resp.items()))
return reply_to(hdr, encode_tdf(resp))
# ---------------------------------------------------------------- TODO
# Expected next RPCs on the FIFA17 login path (in order):
#
# 1. Util::fetchClientConfig (9/1) with FCCR/CFID="IdentityParams"
# -> FetchConfigResponse{CONF: map<str,str>} carrying `display` and
# `redirect_uri`; this drives the Nucleus web login overlay.
# 2. Authentication::login (1/0x0A) with AUTH=<nucleus auth code>
# -> plus server NOTIFICATION 0x7802/8 UserAuthenticated.
# 3. Util::postAuth (9/8)
# -> PostAuthResponse{TELE, TICK, UROP}; plus notifications
# 0x7802/5 and 0x7802/1|2 (UserExtendedData).
# 4. Util::setClientState (9/0x1C), Authentication::getAuthToken (1/0x24),
# AssociationLists::getLists (25/6), UserSessions::updateNetworkInfo
# (0x7802/0x14).
#
# Notifications are msgType=2 with msgNum=0 and are pushed unsolicited.
# Error replies are msgType=3 but the ERROR-CODE placement is UNRESOLVED
# (three clean-room sources disagree: header[14:16] vs metadata ERRC vs
# payload CNTX/ERRC) -- do not emit one until it is verified.
# ----------------------------------------------------------------------
if REPLY_EMPTY_TO_UNKNOWN:
log(" -> UNIMPLEMENTED %s; sending EMPTY REPLY so the client does not "
"hang (all fields fall back to client-side defaults)"
% rpc_name(comp, cmd))
return reply_to(hdr, b"")
log(" -> UNIMPLEMENTED %s; staying silent" % rpc_name(comp, cmd))
return None
# ------------------------------------------------------------- blaze server
def recv_exactly(sock: socket.socket, n: int, buf: bytearray) -> bool:
"""Fill `buf` to at least n bytes. False on clean EOF / short close."""
while len(buf) < n:
try:
chunk = sock.recv(65536)
except socket.timeout:
return False
if not chunk:
return False
buf += chunk
return True
_frame_counter = [0]
def blaze_handle(raw: socket.socket, addr) -> None:
log("*** BLAZE CONNECT from %s ***" % (addr,))
buf = bytearray()
raw.settimeout(300)
try:
while True:
if not recv_exactly(raw, FIRE2_HDR, buf):
break
hdr = parse_fire2_header(bytes(buf[:FIRE2_HDR]))
total = FIRE2_HDR + hdr["metadata_len"] + hdr["payload_len"]
if hdr["payload_len"] > 4 * 1024 * 1024:
log("BLAZE %s: absurd payload_len %d, dropping connection\n%s"
% (addr, hdr["payload_len"], hexdump(bytes(buf[:64]))))
break
if not recv_exactly(raw, total, buf):
log("BLAZE %s: EOF mid-frame (want %d, have %d)"
% (addr, total, len(buf)))
break
frame = bytes(buf[:total])
del buf[:total]
metadata = frame[FIRE2_HDR:FIRE2_HDR + hdr["metadata_len"]]
payload = frame[FIRE2_HDR + hdr["metadata_len"]:]
_frame_counter[0] += 1
n = _frame_counter[0]
log("RX #%d %s msgType=%s msgNum=%d userIdx=%d opts=0x%02x "
"meta=%dB payload=%dB"
% (n, rpc_name(hdr["component"], hdr["command"]),
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:
try:
fn = "/tmp/blaze_rx_%04x_%04x_%d.bin" % (
hdr["component"], hdr["command"], n)
with open(fn, "wb") as fh:
fh.write(frame)
log("RX #%d saved -> %s" % (n, fn))
except Exception as e:
log("RX #%d save failed: %s" % (n, e))
fields = None
if payload:
try:
fields = decode_tdf(payload)
log("RX #%d TDF:\n%s" % (n, heat2.dump(fields)))
except Exception as e:
log("RX #%d TDF DECODE FAILED: %s" % (n, e))
else:
log("RX #%d TDF: (empty payload)" % n)
try:
out = dispatch(hdr, fields, payload)
except Exception as e:
log("RX #%d DISPATCH ERROR: %r" % (n, e))
out = None
if out:
raw.sendall(out)
ohdr = parse_fire2_header(out)
log("TX #%d %s msgType=%s msgNum=%d %dB total (%d payload)"
% (n, rpc_name(ohdr["component"], ohdr["command"]),
MSGTYPE_NAME.get(ohdr["msg_type"], ohdr["msg_type"]),
ohdr["msg_num"], len(out), ohdr["payload_len"]))
log("TX #%d HEX:\n%s" % (n, hexdump(out, limit=1024)))
except ConnectionResetError:
log("BLAZE %s: connection reset by client" % (addr,))
except Exception as e:
log("BLAZE %s ERR: %r" % (addr, e))
finally:
try:
raw.close()
except Exception:
pass
log("BLAZE %s: closed" % (addr,))
# --------------------------------------------------------- redirector (TLS)
def build_redirect_response() -> bytes:
# Confirmed schema (clean-room, MEC Catalyst): ServerInstanceInfo.address is
# a ServerAddress union -> Heat2 XML union = <address member="N"><valu>...
# member="0" = ipAddress variant {hostname, ip(uint32 decimal), port(uint16)}
body = (
'<?xml version="1.0" encoding="UTF-8"?>\n'
'<serverinstanceinfo>\n'
'\t<address member="0">\n'
'\t\t<valu>\n'
f'\t\t\t<hostname>{BLAZE_IP_STR}</hostname>\n'
f'\t\t\t<ip>{BLAZE_IP_U32}</ip>\n'
f'\t\t\t<port>{BLAZE_PORT}</port>\n'
'\t\t</valu>\n'
'\t</address>\n'
'\t<secure>0</secure>\n'
'\t<trialservicename></trialservicename>\n'
'\t<defaultdnsaddress>0</defaultdnsaddress>\n'
'</serverinstanceinfo>\n'
)
b = body.encode()
hdr = ("HTTP/1.1 200 OK\r\nContent-Type: application/xml\r\n"
f"Content-Length: {len(b)}\r\nConnection: close\r\n\r\n").encode()
return hdr + b
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
ctx.load_cert_chain(CERT, KEY)
ctx.minimum_version = ssl.TLSVersion.TLSv1
ctx.set_ciphers("ALL:@SECLEVEL=0")
def redir_handle(raw: socket.socket, addr) -> None:
try:
tls = ctx.wrap_socket(raw, server_side=True)
except ssl.SSLError as e:
log("REDIR REJECTED %s: %s" % (addr, e))
raw.close()
return
log("REDIR TLS-OK %s cipher=%s" % (addr, tls.cipher()[0]))
try:
tls.settimeout(8)
req = b""
while b"\r\n\r\n" not in req:
c = tls.recv(4096)
if not c:
break
req += c
if b"content-length:" in req.lower():
head, _, rest = req.partition(b"\r\n\r\n")
cl = int([l.split(b":")[1] for l in head.split(b"\r\n")
if l.lower().startswith(b"content-length")][0])
while len(rest) < cl:
c = tls.recv(4096)
if not c:
break
rest += c
req = head + b"\r\n\r\n" + rest
line0 = req.split(b"\r\n", 1)[0].decode(errors="replace")
log("REDIR REQ %s: %s" % (addr, line0))
resp = build_redirect_response()
tls.sendall(resp)
log("REDIR SENT %s %dB serverinstanceinfo -> %s:%d"
% (addr, len(resp), BLAZE_IP_STR, BLAZE_PORT))
time.sleep(0.3)
tls.close()
except Exception as e:
log("REDIR ERR %s: %s" % (addr, e))
# ------------------------------------------------------------------ serve
def serve(port: int, handler, name: str) -> None:
s = socket.socket()
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind((HOST, port))
s.listen(16)
log("%s listening on %s:%d" % (name, HOST, port))
while True:
c, a = s.accept()
threading.Thread(target=handler, args=(c, a), daemon=True).start()
def _selftest() -> None:
"""Sanity: build the preAuth reply and round-trip it through the decoder."""
fields = preauth_response_fields()
payload = encode_tdf(fields)
frame = fire2(COMP_UTIL, CMD_PREAUTH, 0, REPLY, payload)
h = parse_fire2_header(frame)
assert h["component"] == COMP_UTIL and h["command"] == CMD_PREAUTH
assert h["msg_type"] == REPLY and h["payload_len"] == len(payload)
assert frame[13] == 0x20, frame[13]
back = decode_tdf(frame[16:])
assert list(back.keys()) == ["ASRC", "CIDS", "CLID", "CONF", "ESRC", "INST",
"MAID", "MINR", "NASP", "PILD", "PLAT", "QOSS",
"RSRC", "SVER"], list(back.keys())
assert encode_tdf(back) == payload
print("selftest OK: preAuth reply = %d bytes (%d payload)"
% (len(frame), len(payload)))
print("header:", frame[:16].hex(" "))
print(heat2.dump(fields))
if __name__ == "__main__":
if "--selftest" in sys.argv:
_selftest()
raise SystemExit(0)
log("=== RESPONDER v2 START (redir %d / blaze %d) ===" % (REDIR_PORT, BLAZE_PORT))
threading.Thread(target=serve, args=(BLAZE_PORT, blaze_handle, "BLAZE"),
daemon=True).start()
serve(REDIR_PORT, redir_handle, "REDIR")
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,245 @@
#!/usr/bin/env python3
"""Generate data/consumables.json -- the complete FIFA 17 consumable (cardtype 6)
enum, derived from the binary and cross-checked against the client's own tables.
SOURCES, all verified 2026-08-05 (slug `consumables`):
* FUN_1800d8330 (714 chars, full) cardsubtypeid -> cardtype
* FUN_18013f4d0 (8354 chars, full) cardsubtypeid -> category(+0xb8), +0xbc, +0xbe,
+0xbf, +0xc0. Two callees only:
FUN_180136480 (playstyle index) and
FUN_1800d7ad0 (int16 clamp). No DB handle.
* FUN_1801bfac0 (42813 chars, full) category -> FUT_CONSUMABLE_* loc keys + the
fixed 5000xxx artwork ids.
* FUN_1801aa230 (6, category) -> fcc_myclubscategories id
* FUN_180048780 (3455 chars, full) that id -> the UI bucket name
* data/tables/fcc_trainingcards.json (143 rows) and fcc_healingcards.json (27),
fcc_contractcards.json (13): EA's own authored amount/rating per subtype.
NOTHING here needs the live game. Run it any time; it only reads files.
"""
import json, os, collections
HERE = os.path.dirname(os.path.abspath(__file__))
TABLES = os.path.join(HERE, "..", "data", "tables")
OUT = os.path.join(HERE, "..", "data", "consumables.json")
POS = ["GK","SW","RWB","RB","RCB","CB","LCB","LB","LWB","RDM","CDM","LDM","RM","RCM",
"CM","LCM","LM","RAM","CAM","LAM","RF","CF","LF","RW","RS","ST","LS","LW"]
# FUN_18013f4d0, arm by arm. value = (category, bc, bf, c0_is_single)
# bf/bc of None means "taken from the `amount` atom at parse time".
GK_ATTR = ["FUT_UC_DIVING","FUT_UC_HANDLING","FUT_UC_KICKING","FUT_UC_REFLEXES",
"FUT_UC_SPEED","FUT_UC_POSITIONING","FUT_FITNESS_UC"]
PL_ATTR = ["FUT_MC_PACE","FUT_MC_SHOOTING","FUT_MC_PASSING","FUT_MC_DRIBBLING",
"FUT_MC_DEFENDING","FUT_MC_HEADING","FUT_FITNESS_MC"]
HEAL_AREA = ["Injury_Head","Injury_UpperBody","Injury_Arm","Injury_Back","Injury_Knee",
"Injury_Leg","Injury_Foot","Injury_All"]
HEAL_LOC = ["FUT_HEAD_HEALING","FUT_UPPERBODY_HEALING","FUT_ARM_HEALING",
"FUT_BACK_HEALING","FUT_KNEE_HEALING","FUT_LEG_HEALING",
"FUT_FOOT_HEALING","FUT_PLAYER_HEALING"]
GK_TRAIN = {51:0, 52:1, 53:2, 54:4, 55:5, 56:3, 57:6} # 0x33..0x39
PL_TRAIN = {61:0, 62:1, 63:2, 64:3, 65:5, 66:4, 67:6} # 0x3d..0x43
FORMATION = [23,24,25,27,14,3,13,6,7,8,19,16,21,29,30,31] # identical in both arms
POSMOD = { # 0x5b..0x6e (bc=from, bf=to)
91:(8,7), 92:(7,8), 93:(2,3), 94:(3,2), 95:(16,27), 96:(12,23),
97:(27,16), 98:(23,12), 99:(27,22), 100:(23,20),101:(22,27),102:(20,23),
103:(14,18),104:(18,14),105:(10,14),106:(14,10),107:(18,21),108:(21,18),
109:(21,25),110:(25,21),
}
CAT_LOC = { # category -> (name loc key, desc loc key, artwork base, myclub cat, ui bucket)
0: ("FUT_CONSUMABLE_NAME_PLAYERTRAINING", "FUT_CONSUMABLE_PLAYERTRAINING", "5000000_<idx>", 0, "training"),
2: ("FUT_CONSUMABLE_NAME_PLAYERCONTRACT", "FUT_CONSUMABLE_PLAYERCONTRACT", "5000007", 1, "contracts"),
3: ("FUT_CONSUMABLE_NAME_MANAGERCONTRACT","FUT_CONSUMABLE_MANAGERCONTRACT","5000008", 2, "contracts"),
4: ("FUT_CONSUMABLE_NAME_PLAYERHEALING", "FUT_CONSUMABLE_PLAYERHEALING", "5000009_<idx>", 3, "healing"),
5: ("FUT_CONSUMABLE_NAME_PLAYERFITNESS", "FUT_CONSUMABLE_PLAYERFITNESS", "5000010", 4, "fitness"),
6: ("FUT_CONSUMABLE_NAME_FORMATIONMOD", "FUT_CONSUMABLE_FORMATIONMOD", "-1", 15, "training"),
7: ("FUT_CONSUMABLE_NAME_FORMATIONMOD", "FUT_CONSUMABLE_FORMATIONMOD", "-1", 16, "formation"),
8: ("FUT_CONSUMABLE_NAME_POSITIONMOD", "FUT_CONSUMABLE_POSITIONMOD", "5000013", 17, "position"),
9: ("FUT_CONSUMABLE_NAME_PLAYERSTYLE", "FUT_CONSUMABLE_PLAYERSTYLE", "5000015_<amt>", 23, "playStyle"),
10: ("FUT_CONSUMABLE_NAME_MANAGERLEAGUE", "FUT_CONSUMABLE_MANAGERLEAGUE", "5000016", 24, "managerLeagueModifier"),
}
def cardtype(s):
"""FUN_1800d8330, verbatim."""
if 0 <= s <= 3: return 1
if s == 4: return 2
if s == 5: return 3
if s == 6: return 10
if s == 7: return 5
if s == 8: return 4
if s in (9, 10, 11): return 7
if s in (30, 31, 145,146,147,148,149,150, 231,232,233, 236): return 9
if 0 <= s - 0x33 <= 0x55: return 6 # 51..136
if 0 <= s - 0xc9 <= 0x13: return 6 # 201..220
if 0 <= s - 0xfa <= 0x17: return 6 # 250..273
if 0 <= s - 300 < 0x2a: return 6 # 300..341
return 0
def rows():
out = []
for s in range(0, 400):
if cardtype(s) != 6:
continue
r = {"cardsubtypeid": s}
if s in GK_TRAIN:
i = GK_TRAIN[s]
r.update(kind="gk_training", category=0, bc=i, bf="amount",
c0=int(s != 57), detail=GK_ATTR[i],
loc_name="FUT_CONSUMABLE_NAME_KEEPERTRAINING",
loc_desc="FUT_CONSUMABLE_KEEPERTRAINING",
artwork="5000001_%d" % i, needs=["amount"])
elif s in PL_TRAIN:
i = PL_TRAIN[s]
r.update(kind="player_training", category=0, bc=i, bf="amount",
c0=int(s != 67), detail=PL_ATTR[i],
loc_name="FUT_CONSUMABLE_NAME_PLAYERTRAINING",
loc_desc="FUT_CONSUMABLE_PLAYERTRAINING",
artwork="5000000_%d" % i, needs=["amount"])
elif 71 <= s <= 86:
f = FORMATION[s - 71]
r.update(kind="manager_formation_mod", category=6, bc=f, bf=0, c0=None,
detail="formationid=%d" % f, artwork="-1", needs=[])
elif s in POSMOD:
a, b = POSMOD[s]
r.update(kind="position_mod", category=8, bc=a, bf=b, c0=None,
detail="%s >> %s" % (POS[a], POS[b]),
artwork="5000013", needs=[])
elif 121 <= s <= 136:
f = FORMATION[s - 121]
r.update(kind="formation_mod", category=7, bc=f, bf=0, c0=None,
detail="formationid=%d" % f, artwork="-1", needs=[])
elif s == 201:
r.update(kind="player_contract", category=2, bc=None, bf=None, c0=None,
detail="games from atom `contract` (0xb8) -> record+0x8c, unit fcc_matches",
artwork="5000007", needs=["contract"])
elif s == 202:
r.update(kind="manager_contract", category=3, bc=None, bf=None, c0=None,
detail="games from atom `contract` (0xb8) -> record+0x8c, unit fcc_matches",
artwork="5000008", needs=["contract"])
elif 211 <= s <= 218:
i = s - 211
r.update(kind="healing", category=4, bc=i, bf="amount", c0=int(s != 218),
detail=HEAL_AREA[i], loc_detail=HEAL_LOC[i],
artwork="5000009_%d" % i, needs=["amount"])
elif s in (219, 220):
r.update(kind="player_fitness" if s == 219 else "squad_fitness",
category=5, bc=0, bf="amount", c0=int(s != 220),
detail="RAREFLAG TRAP: rareflag==1 renders 219 as SQUAD fitness"
if s == 219 else "always squad fitness",
artwork="5000010" if s == 219 else "5000011", needs=["amount"])
if s == 220:
# 0xdc == 220 is the FIRST half of the squad-fitness test in
# FUN_1801bfac0 case 5, so 220 ALWAYS takes that branch -- and that
# branch writes FUT_CONSUMABLE_NAME_SQUADTRAINING. There is no
# FUT_CONSUMABLE_NAME_SQUADFITNESS string in the binary at all
# (24-string census); squad fitness reuses the squad-training name.
r.update(loc_name="FUT_CONSUMABLE_NAME_SQUADTRAINING",
loc_desc="FUT_CONSUMABLE_SQUADTRAINING")
elif 250 <= s <= 273:
i = s - 250
r.update(kind="gk_playstyle" if s >= 269 else "player_playstyle",
category=9, bc=i, be="amount", bf=None, c0=None,
detail="FUT_PLAYSTYLE_%d" % i,
loc_name=("FUT_CONSUMABLE_NAME_GK_PLAYERSTYLE" if s >= 269
else "FUT_CONSUMABLE_NAME_PLAYERSTYLE"),
artwork="5000015_<amount>", needs=["amount"])
elif 300 <= s <= 341:
r.update(kind="manager_league", category=10, bc="clamp_i16(amount)",
bf=None, c0=None, detail="ML: <leagueid from amount>",
artwork="5000016", needs=["amount"])
else:
# FUN_1801bfac0 case 0 tests `subtype - 0x33 < 7` (GK training) then
# `subtype - 0x3d < 7` (player training) and otherwise falls to the
# SQUAD-training branch. No dead-zone subtype satisfies either test, so
# the name is SQUADTRAINING, not PLAYERTRAINING (which is what CAT_LOC[0]
# would otherwise have stamped here).
r.update(kind="DEAD_ZONE", category=0, bc=0, bf=0, c0=None,
detail="falls through to the bottom default of FUN_18013f4d0; "
"renders as SQUAD TRAINING / attribute index 0, amount 0. "
"NOT a loud error -- do not ship these.",
loc_name="FUT_CONSUMABLE_NAME_SQUADTRAINING",
loc_desc="FUT_CONSUMABLE_SQUADTRAINING",
artwork="5000000_0", needs=[])
c = r["category"]
if "loc_name" not in r: r["loc_name"] = CAT_LOC[c][0]
if "loc_desc" not in r: r["loc_desc"] = CAT_LOC[c][1]
r["myclub_category"] = CAT_LOC[c][3]
r["ui_bucket"] = CAT_LOC[c][4]
out.append(r)
return out
def merge_ea():
"""EA's authored (amount, rating) per subtype, straight out of the resident DB."""
ea = collections.defaultdict(list)
for f in ("fcc_trainingcards", "fcc_healingcards"):
d = json.load(open(os.path.join(TABLES, f + ".json")))
for row in d["rows"]:
ea[row["cardsubtype"]].append(
{"carddbid": row["carddbid"], "amount": row["amount"],
"rating": row["rating"], "weightrare": row["weightrare"],
"table": f})
d = json.load(open(os.path.join(TABLES, "fcc_contractcards.json")))
for row in d["rows"]:
ea[row["cardsubtype"]].append(
{"carddbid": row["carddbid"], "rating": row["rating"],
"weightrare": row["weightrare"], "table": "fcc_contractcards",
"gold": row["gold"], "silver": row["silver"], "bronze": row["bronze"]})
return ea
def main():
rs = rows()
ea = merge_ea()
for r in rs:
r["ea_variants"] = ea.get(r["cardsubtypeid"], [])
doc = {
"generated_by": "tools/build_consumables.py",
"derived_from": ["FUN_1800d8330", "FUN_18013f4d0", "FUN_1801bfac0",
"FUN_1801aa230", "FUN_180048780",
"data/tables/fcc_trainingcards.json",
"data/tables/fcc_healingcards.json",
"data/tables/fcc_contractcards.json"],
"record_offsets": {
"0x4c": "cardtype (FUN_1800d8330(cardsubtypeid))",
"0x50": "cardsubtypeid (atom 0x6c). default 342 -> cardtype 0",
"0x54": "level: 1 if rating<65, 2 if <75, else 3 (shared tail of FUN_180141660)",
"0x58": "rareflag (atom 0x271, u32). THE TRAP: ==1 flips subtype 219 to squad fitness",
"0x18": "resourceId (atom 0x287) -- overwritten by FUN_18013f4d0 param_2",
"0x88": "playStyle (atom 0x23f, mapped 250..273 -> 0..23)",
"0x8c": "contract (atom 0xb8) -- the contract card's games count",
"0xb4": "rating (atom 0x274)",
"0xb8": "consumable CATEGORY (written by FUN_18013f4d0)",
"0xbc": "int16 sub-selector: attribute / injury area / formationid / from-position / playstyle index / leagueid",
"0xbe": "int8 playstyle artwork variant = (byte)amount",
"0xbf": "int8 amount, or to-position for position mods",
"0xc0": "bool single-target",
},
"club_type_values_for_consumables": {
"contract": "FUN_18012ec50 arm 24 -> atom 0xb8",
"training": "arm 25 -> atom 0x337",
"healing": "arm 23 -> atom 0x156",
"development": "arm 6 -> atom 0xd3 (best candidate for position/formation/playStyle/managerLeague)",
"STATUS": "ALL FOUR UNOBSERVED ON THE WIRE. Only type=player, type=manager and "
"type=custom have ever been seen from this client.",
},
"subtypes": rs,
}
with open(OUT, "w") as f:
json.dump(doc, f, indent=1)
live = [r for r in rs if r["kind"] != "DEAD_ZONE"]
dead = [r for r in rs if r["kind"] == "DEAD_ZONE"]
print("cardtype-6 subtypes: %d (live %d, dead-zone %d)" % (len(rs), len(live), len(dead)))
print("dead zones:", sorted(r["cardsubtypeid"] for r in dead))
print("with EA variants:", len([r for r in rs if r["ea_variants"]]))
print("live but NO EA variant:",
sorted(r["cardsubtypeid"] for r in live if not r["ea_variants"]))
print("wrote", os.path.normpath(OUT))
if __name__ == "__main__":
main()
@@ -0,0 +1,161 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Turn the raw table dumps written by db_dump.py into ONE file the FUT card
pool can consume: data/player_facts.json.
Input data/tables/{players,teamplayerlinks,teams,leagues,nations,
leagueteamlinks,playerattributesmapping}.json
Output data/player_facts.json
{"meta": {...},
"players": [ {"id":20801,"rating":94,"pos":27,"pos2":16,"pos3":25,"pos4":-1,
"nation":38,"team":243,"league":53,
"attrs":[90,93,82,91,33,80], # PAC SHO PAS DRI DEF PHY
"gk":false}, ... ]}
THE SIX CARD ATTRIBUTES ARE NOT COLUMNS. `players` stores the 29 base
attributes; the six numbers a FUT card shows are a weighted sum of them. The
weights are not guessed here -- they are read out of the game's own
`playerattributesmapping` table, which maps each base attribute id to its
percentage contribution to speed / shooting / passing / dribbling / defending /
physical (and to the five gk* stats). Every column of that table sums to
exactly 100.
The one inference this file makes is attributeid -> players column name, and it
is safe for this purpose: wherever two attributes could be swapped (marking vs
standingtackle at 30 each, shotpower vs longshots at 20 each, ...) they carry
EQUAL weight, so the computed six are identical either way. The assignments
that actually move a number -- 45/55 acceleration/sprintspeed, 45 finishing,
50 dribbling, 35 shortpassing, 30 ballcontrol, 50 strength, 25 stamina,
20 aggression, 20 interceptions, 15 longpassing -- are each the unique
attribute with that weight.
VALIDATION (see the report): overallrating in `players` agrees with
data/roster.json, extracted from a completely different memory structure, on
17547 of 17547 shared players.
"""
import json
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
TABLES = os.path.join(HERE, '..', 'data', 'tables')
OUT = os.path.join(HERE, '..', 'data', 'player_facts.json')
# FUT card slot -> [(players column, percent)], read off playerattributesmapping.
OUTFIELD = [
('pace', [('acceleration', 45), ('sprintspeed', 55)]),
('shooting', [('finishing', 45), ('shotpower', 20), ('longshots', 20),
('positioning', 5), ('volleys', 5), ('penalties', 5)]),
('passing', [('shortpassing', 35), ('vision', 20), ('crossing', 20),
('longpassing', 15), ('freekickaccuracy', 5), ('curve', 5)]),
('dribbling', [('dribbling', 50), ('ballcontrol', 30), ('agility', 10),
('balance', 5), ('reactions', 5)]),
('defending', [('marking', 30), ('standingtackle', 30), ('interceptions', 20),
('headingaccuracy', 10), ('slidingtackle', 10)]),
('physical', [('strength', 50), ('stamina', 25), ('aggression', 20),
('jumping', 5)]),
]
# Keeper card face. The five gk* columns are used at 100% -- they ARE the card
# numbers, no arithmetic. The speed slot is the only weighted one.
GK = [
('diving', [('gkdiving', 100)]),
('handling', [('gkhandling', 100)]),
('kicking', [('gkkicking', 100)]),
('reflexes', [('gkreflexes', 100)]),
('speed', [('acceleration', 60), ('sprintspeed', 40)]),
('positioning', [('gkpositioning', 100)]),
]
POSITION_NAMES = ['GK', 'SW', 'RWB', 'RB', 'RCB', 'CB', 'LCB', 'LB', 'LWB',
'RDM', 'CDM', 'LDM', 'RM', 'RCM', 'CM', 'LCM', 'LM',
'RAM', 'CAM', 'LAM', 'RF', 'CF', 'LF', 'RW', 'RS', 'ST',
'LS', 'LW']
def load(name):
with open(os.path.join(TABLES, name + '.json')) as fh:
return json.load(fh)
def weighted(row, spec):
return int(round(sum(row[c] * w for c, w in spec) / 100.0))
def main():
players = load('players')['rows']
tpl = load('teamplayerlinks')['rows']
teams = {t['teamid']: t for t in load('teams')['rows']}
nations = {n['nationid']: n['nationname'] for n in load('nations')['rows']}
# A player appears in teamplayerlinks once per club AND once per national
# side. The club is the row whose team is not a national team; nations
# and teams share an id space only through teamnationlinks, so the cheap,
# reliable discriminator is: the club link is the one with the lowest
# teamid that is not the player's own nation-team. Keep every link too.
nat_team = set()
for t in load('teamnationlinks')['rows']:
nat_team.add(t['teamid'])
clubs = {}
alllinks = {}
for l in tpl:
alllinks.setdefault(l['playerid'], []).append(l)
if l['teamid'] in nat_team:
continue
prev = clubs.get(l['playerid'])
if prev is None or l['teamid'] < prev['teamid']:
clubs[l['playerid']] = l
out = []
for p in players:
pid = p['playerid']
gk = p['preferredposition1'] == 0
spec = GK if gk else OUTFIELD
club = clubs.get(pid)
out.append({
'id': pid,
'rating': p['overallrating'],
'potential': p['potential'],
'pos': p['preferredposition1'],
'pos2': p['preferredposition2'],
'pos3': p['preferredposition3'],
'pos4': p['preferredposition4'],
'posname': POSITION_NAMES[p['preferredposition1']]
if 0 <= p['preferredposition1'] < len(POSITION_NAMES) else None,
'nation': p['nationality'],
'nationname': nations.get(p['nationality']),
'team': club['teamid'] if club else 0,
'teamname': teams.get(club['teamid'], {}).get('teamname') if club else None,
'jersey': club['jerseynumber'] if club else 0,
'foot': p['preferredfoot'],
'skillmoves': p['skillmoves'],
'weakfoot': p['weakfootabilitytypecode'],
'height': p['height'],
'weight': p['weight'],
'gk': gk,
'attrs': [weighted(p, s) for _, s in spec],
})
doc = {
'meta': {
'source': 'FIFA17.exe resident database, via tools/db_dump.py',
'players': len(out),
'attr_order_outfield': [k for k, _ in OUTFIELD],
'attr_order_gk': [k for k, _ in GK],
'weights_outfield': {k: dict(v) for k, v in OUTFIELD},
'weights_gk': {k: dict(v) for k, v in GK},
'position_enum': POSITION_NAMES,
},
'players': out,
}
with open(OUT, 'w') as fh:
json.dump(doc, fh, ensure_ascii=False, separators=(',', ':'))
sys.stderr.write("wrote %s: %d players (%d keepers)\n"
% (OUT, len(out), sum(1 for x in out if x['gk'])))
return 0
if __name__ == '__main__':
sys.exit(main())
@@ -0,0 +1,141 @@
#!/usr/bin/env python3
"""Capture-first LSX diagnostic for FIFA 17 on 127.0.0.1:4216.
Goal: learn the REAL handshake the game speaks, losing nothing to a crash.
- Detects who sends first (server-initiates vs client-initiates).
- Sends our best-guess Challenge, then captures the client's ChallengeResponse
(its key + exact XML format) -- FLUSHED TO DISK BEFORE we send anything risky.
- Then runs the full instrumented handshake (H, session key, encrypted loop),
logging every computed value + every frame both directions.
So even if the game crashes on our ChallengeAccepted, the key capture is saved.
"""
import socket, time, threading, os, sys
import lsx_responder as L # reuse crypto + build_reply; importing does NOT run main
LOG = "/tmp/lsx_capture.log"
RAWDIR = "/tmp/lsx_raw"
os.makedirs(RAWDIR, exist_ok=True)
_n = 0
def log(m):
line = "[%s] %s" % (time.strftime("%H:%M:%S"), m)
print(line, flush=True)
with open(LOG, "a") as f:
f.write(line + "\n"); f.flush(); os.fsync(f.fileno())
def dump(tag, b):
log("%s : %dB" % (tag, len(b)))
for i in range(0, min(len(b), 512), 16):
c = b[i:i+16]
hx = " ".join("%02x" % x for x in c)
asc = "".join(chr(x) if 32 <= x < 127 else "." for x in c)
log(" %04x: %-47s %s" % (i, hx, asc))
try:
p = "%s/%s_%d.bin" % (RAWDIR, tag.replace(" ", "_").replace("#", "").replace(":", ""), int(time.time()*1000) % 1000000)
open(p, "wb").write(b)
except Exception:
pass
def serve(conn, addr):
global _n; _n += 1; n = _n
log("================= CONN #%d from %s =================" % (n, addr))
# Phase 0 -- does the CLIENT speak first? (would mean lsx_responder has the
# initiator backwards, a prime crash suspect)
conn.settimeout(3.0)
try:
pre = conn.recv(8192)
if pre:
dump("C%d PRE (client spoke FIRST)" % n, pre)
log("C%d: !!! client initiates -- our server-sends-Challenge model is WRONG" % n)
except socket.timeout:
log("C%d: silent 3s -> server initiates (send Challenge)" % n)
pre = b""
except Exception as e:
log("C%d pre-recv err: %s" % (n, e)); pre = b""
# Phase 1 -- send our best-guess plaintext Challenge, capture the reply.
chal = ('<LSX><Event sender="EALS"><Challenge key="%s" build="%s" version="%s"/>'
'</Event></LSX>' % (L.CHALLENGE_KEY, L.BUILD, L.VERSION))
try:
conn.sendall(chal.encode() + b"\0")
log("C%d >> Challenge sent (%dB): %s" % (n, len(chal)+1, chal))
except Exception as e:
log("C%d send-Challenge err: %s" % (n, e)); return
try:
rep = conn.recv(8192)
except Exception as e:
log("C%d recv-after-Challenge err: %s" % (n, e)); return
if not rep:
log("C%d: client closed after our Challenge (no ChallengeResponse) -- "
"Challenge format likely rejected" % n); return
dump("C%d CHALLENGE-RESPONSE (client)" % n, rep) # <-- THE KEY CAPTURE, already flushed
# Phase 2 -- log what WE would compute (do NOT let a crypto error abort logging)
txt = rep.split(b"\0")[0].decode(errors="replace")
log("C%d client-reply text: %s" % (n, txt))
import re
mk = re.search(r'key="([^"]*)"', txt)
mr = re.search(r'response="([^"]*)"', txt)
client_key = mk.group(1) if mk else L.CHALLENGE_KEY
client_resp = mr.group(1) if mr else None
log("C%d parsed: client_key=%r client_response=%r" % (n, client_key, client_resp))
try:
our_h = L.challenge_response(client_key)
log("C%d our computed H (ChallengeAccepted.response) = %s" % (n, our_h))
if client_resp:
log("C%d MATCH client_response==our_H ? %s" % (n, client_resp == our_h))
skey = L.derive_session_key(our_h)
log("C%d derived session_key = %s" % (n, skey.hex()))
except Exception as e:
log("C%d crypto compute err: %s" % (n, e)); our_h = None; skey = None
# Phase 3 -- OPTIONAL: send ChallengeAccepted + run the encrypted loop.
# Guarded by env so the first run can stay capture-only (no risky send).
if os.environ.get("LSX_FULL") == "1" and our_h and skey:
try:
conn.sendall(L.resp(1, 'ChallengeAccepted response="%s"' % our_h, "EALS").encode() + b"\0")
log("C%d >> ChallengeAccepted sent" % n)
except Exception as e:
log("C%d send-Accepted err: %s" % (n, e)); return
while True:
try:
data = conn.recv(65536)
except Exception as e:
log("C%d recv-loop err: %s" % (n, e)); break
if not data:
log("C%d: client closed" % n); break
dump("C%d ENC-IN" % n, data)
for chunk in filter(None, data.split(b"\0")):
try:
xml = L.lsx_decrypt(chunk + b"\0", skey)
log("C%d << decrypted: %s" % (n, xml))
except Exception as e:
log("C%d decrypt fail: %s (raw %s)" % (n, e, chunk[:40])); continue
mm = L.REQ_RE.search(xml)
if mm:
reply = L.build_reply(mm.group(1), mm.group(2),
dict(L.ATTR_RE.findall(mm.group(3))))
try:
conn.sendall(L.lsx_encrypt(reply, skey))
log("C%d >> %s" % (n, reply))
except Exception as e:
log("C%d send-reply err: %s" % (n, e)); break
else:
log("C%d: capture-only (set LSX_FULL=1 to attempt full handshake). Holding 10s." % n)
time.sleep(10)
try: conn.close()
except Exception: pass
def main():
s = socket.socket()
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(("127.0.0.1", 4216)); s.listen(8)
log("=== capture_lsx listening on 127.0.0.1:4216 (LSX_FULL=%s) ===" %
os.environ.get("LSX_FULL", "0"))
while True:
c, a = s.accept()
threading.Thread(target=serve, args=(c, a), daemon=True).start()
if __name__ == "__main__":
main()
@@ -0,0 +1,241 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Read the IDENTITY the client resolved for every card it currently holds.
READ-ONLY. /proc/PID/mem is opened 'rb'; there is no write path in this file.
WHY THIS EXISTS
---------------
Card identity does not come from us. Every item object in every response is
inserted into the CardsDb map by the item-parser tail (0x18014115b -> registrar
vtable +0xa08 = 0x18011cca0, a find-or-INSERT). Just before registering, the
client runs a LOCAL merge (FUN_180141660 -> FUN_180135890 for player cards) that
queries its own `players` table by
playerid = resourceId & 0xffffff
On a HIT it fills the name and face and leaves our rating/position/attributes
alone. On a MISS it hard-writes a fixed generic card. Those MISS constants are a
FINGERPRINT, and that is what makes this probe useful: the resolved record is
sitting in the map, so one read tells us hit-or-miss for EVERY id we have served,
without opening a single card in the UI.
MISS => rating 0x32 (50), teamid 0x78d (1933), nation 0xe (14),
position 2 (RWB), attributes all 1, name " "
That is exactly what a player photographed in a pack on 2026-08-04: two named
cards (ids from VERIFIED_ASSET_IDS) beside three blanks at 50 RWB with every
attribute 1. So the fingerprint is confirmed live, not just read out of Ghidra.
WHAT THIS BUYS
--------------
A bulk oracle. Serving N candidate playerids and reading this once classifies all
N at a time, instead of one id per screenshot. That is the difference between
validating a 79-card pool and validating a database.
OFFSETS, AND HOW MUCH TO TRUST THEM
-----------------------------------
Record base = node + 0x28, size 0x158, copied field-by-field by FUN_1800515e0.
Offsets below were derived in Ghidra from the card view-model FUN_1800d7920 and
the parser's stack record, and cross-checked by a second agent. They are NOT yet
confirmed against a live process -- which is precisely what this tool does. Read
the report critically the first time: if `name` is garbage for a card you KNOW
renders correctly in the UI, the offset is wrong, not the game.
Usage:
python3 card_identity_probe.py # table + summary
python3 card_identity_probe.py --raw # + hexdump of the first record
python3 card_identity_probe.py --json out.json
"""
import argparse
import json
import struct
import sys
import watch_club_model as W
REC = 0x28 # node -> record
F_ID = 0x08 # map key: the item's `id` (atom 0x15c)
F_RESOURCE = 0x18 # resourceId (atom 0x287) -- the DB key lives in the low 24 bits
F_PLAYERID = 0x1C # written by FUN_180135890 as resourceId & 0xffffff
F_ASSET = 0x20 # assetId (atom 0x23) -- parsed, then never read by the merge
F_SUBTYPE = 0x50 # cardsubtypeid (atom 0x6c)
F_CARDTYPE = 0x4C # FUN_1800d8330(subtype); 1 = player => the merge runs
F_TEAM = 0x94 # MISS writes 0x78d
F_ATTRS = (0x98, 0x9C, 0xA0, 0xA4, 0xA8, 0xAC) # MISS writes 1 to each
F_RATING = 0xB4 # MISS writes 0x32
F_NAME_FIRST = 0xB8
F_NAME_LAST = 0xC8
F_NAME_KNOWN = 0xDD # 0x1f bytes
F_POSITION = 0x146 # MISS writes 2
F_NATION = 0x148 # MISS writes 0xe
F_LEAGUE = 0x154 # filled from the DB on a hit
REC_LEN = 0x158
MISS = {"rating": 0x32, "teamid": 0x78D, "nation": 0xE, "position": 2}
def cstr(buf, off, maxlen=0x1F):
"""Inline char array -> str. Names are stored in the record itself, not
interned: three arrays at +0xb8/+0xc8/+0xdd, no string table."""
if buf is None or off + 1 > len(buf):
return ""
end = min(off + maxlen, len(buf))
raw = buf[off:end].split(b"\x00", 1)[0]
return raw.decode("utf-8", "replace").strip()
def u8(b, o):
return b[o] if b and o < len(b) else None
def u16(b, o):
return struct.unpack_from("<H", b, o)[0] if b and o + 2 <= len(b) else None
def u32(b, o):
return struct.unpack_from("<I", b, o)[0] if b and o + 4 <= len(b) else None
def nodes(mem, obj, limit=W.MAX_NODES):
"""[node_addr] for every node in the card tree. Same defensive DFS as
watch_club_model.walk_tree -- both child slots, visited set, bounded."""
root = mem.q(obj + W.TREE_ROOT)
end = obj + W.TREE_END
if root is None or root == 0 or root == end:
return []
out, seen, stack = [], set(), [root]
while stack and len(out) < limit:
p = stack.pop()
if not p or p == end or p in seen or (p & 7):
continue
seen.add(p)
out.append(p)
for slot in (W.NODE_A, W.NODE_B):
c = mem.q(p + slot)
if c and c != end and c not in seen:
stack.append(c)
return out
def read_card(mem, node):
buf = mem.read(node + REC, REC_LEN)
if buf is None or len(buf) < REC_LEN:
return None
res = u32(buf, F_RESOURCE)
c = {
"node": node,
"id": u32(buf, F_ID),
"resourceId": res,
"playerid": res & 0xFFFFFF if res is not None else None,
"playerid_field": u32(buf, F_PLAYERID),
"assetId": u32(buf, F_ASSET),
"cardtype": u32(buf, F_CARDTYPE),
"subtype": u32(buf, F_SUBTYPE),
"teamid": u32(buf, F_TEAM),
"rating": u8(buf, F_RATING),
"position": u8(buf, F_POSITION),
"nation": u16(buf, F_NATION),
"league": u32(buf, F_LEAGUE),
"attrs": [u8(buf, o) for o in F_ATTRS],
"first": cstr(buf, F_NAME_FIRST, 0x10),
"last": cstr(buf, F_NAME_LAST, 0x15),
"known": cstr(buf, F_NAME_KNOWN, 0x1F),
"_raw": buf,
}
c["verdict"] = classify(c)
return c
def classify(c):
"""HIT / MISS / NO-MERGE, from the fingerprint the binary writes.
NO-MERGE matters as much as the other two: if cardsubtypeid is absent the
record defaults to 0x156 -> cardtype 0 -> FUN_180141660 skips the merge
entirely, so the card shows OUR raw JSON and never consults the DB. That
looks nothing like a MISS and must not be reported as one.
"""
if c["cardtype"] != 1:
return "NO-MERGE"
if all(c[k] == v for k, v in MISS.items()) and all(a == 1 for a in c["attrs"]):
return "MISS"
name = (c["known"] or c["last"] or c["first"]).strip()
return "HIT" if name else "MISS?"
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--raw", action="store_true", help="hexdump the first record")
ap.add_argument("--json", metavar="PATH", help="write the full table as JSON")
ap.add_argument("--limit", type=int, default=W.MAX_NODES)
a = ap.parse_args()
pid = W.find_pid()
if pid is None:
print("FIFA17.exe is not running.")
return 1
base = W.dll_base(pid)
if base is None:
print("pid %d is up but %s is not mapped yet." % (pid, W.DLL))
return 1
mem = W.Mem(pid)
obj = mem.q(base + (W.G_CARDSDB - W.IMG_BASE))
if not obj:
print("CardsDb singleton is NULL (no FUT session loaded).")
return 1
size = mem.i32(obj + W.TREE_SIZE)
ns = nodes(mem, obj, a.limit)
print("pid=%d cardsdll=%#x CardsDb=%#x size(+0x160e8)=%s walked=%d"
% (pid, base, obj, size, len(ns)))
if size is not None and size != len(ns):
print(" !! walk disagrees with the size counter -- trust the counter, "
"the walk went wrong")
cards = [c for c in (read_card(mem, n) for n in ns) if c]
cards.sort(key=lambda c: (c["resourceId"] or 0))
print()
print("%-11s %-10s %-8s %-4s %-4s %-6s %-4s %-18s %s"
% ("id", "resource", "playerid", "rat", "pos", "team", "nat", "name", "verdict"))
for c in cards:
nm = (c["known"] or ("%s %s" % (c["first"], c["last"])).strip())[:18]
print("%-11s %-10s %-8s %-4s %-4s %-6s %-4s %-18s %s"
% (c["id"], c["resourceId"], c["playerid"], c["rating"], c["position"],
c["teamid"], c["nation"], nm, c["verdict"]))
tally = {}
for c in cards:
tally[c["verdict"]] = tally.get(c["verdict"], 0) + 1
print("\n" + " ".join("%s=%d" % kv for kv in sorted(tally.items())))
hits = sorted({c["playerid"] for c in cards if c["verdict"] == "HIT"})
miss = sorted({c["playerid"] for c in cards if c["verdict"] in ("MISS", "MISS?")})
if hits:
print("\nplayerids PRESENT in the client's DB (%d): %s"
% (len(hits), ", ".join(str(h) for h in hits)))
if miss:
print("\nplayerids ABSENT (%d): %s"
% (len(miss), ", ".join(str(m) for m in miss)))
if a.raw and cards:
b = cards[0]["_raw"]
print("\nrecord %#x:" % (cards[0]["node"] + REC))
for off in range(0, REC_LEN, 16):
row = b[off:off + 16]
print(" +%03x %-47s %s" % (
off, " ".join("%02x" % x for x in row),
"".join(chr(x) if 32 <= x < 127 else "." for x in row)))
if a.json:
for c in cards:
c.pop("_raw", None)
with open(a.json, "w") as f:
json.dump(cards, f, indent=1)
print("\nwrote %s" % a.json)
print("\nfailed reads=%d" % mem.fails)
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,736 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""THE CARD RECORD PROOF -- a reversible, differential, live DATA write.
*** THIS WRITES TO A RUNNING FIFA17.exe. IT REFUSES TO WRITE WITHOUT --fire. ***
*** A HUMAN DECIDES WHEN TO FIRE IT. Read OUTCOMES at the bottom first. ***
==========================================================================
1. WHY THIS IS NOT "PATCH THE MISS PATH", WHICH IS WHAT WAS ASKED FOR
==========================================================================
docs/CARD_SYSTEM.md "Option C" says: patch the miss branch of the lookup
0x18011cca0 so that a miss emits a fixed real record. That experiment cannot
be built, because BOTH halves of its premise are false. Verified this session
against /mnt/games/FIFA 17/CardsDLL_Win64_retail.dll (freshly imported and
analysed; image base 0x180000000):
FUN_18011cca0 -- FULL decompile 1664 chars, FULL disassembly 84 of 84
instructions, complete coverage, both written to disk and read end to end:
FUN_18011cca0(CardsDb, item, parsed_record)
key = *(parsed_record + 8)
if key == 0: unlink `item` from the observer list at item+0x10; return
walk the RB-tree at CardsDb+0x160c0 (root +0x160d8, header +0x160c8,
node key +0x20, record +0x28)
if MISS: node = FUN_180115c30(...) <-- INSERTS a fresh node
FUN_1800515e0(node+0x28, parsed_record) <-- record := parsed_record
FUN_1800419b0(item, node+0x28) <-- item+0x10 = &record
There is no blank-default record and no miss-emit path. A miss ALLOCATES a
node (FUN_1801155f0 -> record ctor 0x180041250) and the very next call
overwrites that record wholesale from the parsed item. The map is therefore
NOT empty offline: it gains one node per parsed item.
And the actual blank card does not come from this lookup at all -- see 2.
That is better news than the plan assumed: the thing we want to prove is
reachable as a plain DATA write into an existing live buffer. No instruction
patching anywhere, and the restore is just writing the old bytes back.
==========================================================================
2. WHERE THE BLANK CARD REALLY COMES FROM (this is the real find)
==========================================================================
The item parser FUN_18013fe00 builds a record on its own stack at RBP+0x160,
then does two things in this order:
0x180141020 CALL 0x180141660 <-- LOCAL-DB MERGE (first)
0x180141176 CALL [R9+0xa08] <-- the map lookup (second)
FUN_180141660 switches on record+0x4c (the card TYPE) and queries FIFA's OWN
local card database:
case 1 -> FUN_180135890 players
case 2 -> FUN_1801356c0
case 3 -> "headcoachcards" case 4 -> "fitnesscoachcards"
case 5 -> "gkcoachcards" case 10 -> "physiocards"
FUN_180135890 is the player path (decompile 7425 chars). It reads
assetId = *(record + 0x18) & 0xFFFFFF
and runs SELECT ... FROM players WHERE playerid = assetId.
ON A DATABASE MISS it stamps, verbatim:
record+0xb4 = 0x32 (= 50) <-- "rating 50"
record+0x98..+0xac = 1,1,1,1,1,1 <-- "every attribute 1"
record+0x146 = 2 <-- the position rendered as RWB
record+0x148 = 0xe (nation 14)
record+0x94 = 0x78d (team 1933)
record+0xdd = DAT_1801eaf98 = " " <-- a single space: no name
record+0xb8 = " " (firstname buffer)
ON A HIT it writes the name from the local DB:
FUN_1800081b0(record+0xdd, <name>, 0x1f)
FUN_1800081b0(record+0xb8, <firstname>, 0x10)
and it fills nation/team/league ONLY IF they are still zero. It does NOT
touch record+0xb4 or record+0x98..+0xac on a hit.
That single fact explains the whole 2026-08-04 REFUTED observation in
docs/CARD_SYSTEM.md, exactly, with no residue:
* SILVA / NOWAK resolved because their assetIds ARE in the local players
table -> name came from FIFA's DB, and rating + the six hand-invented
attributes survived from OUR JSON because the HIT path never overwrites
them. That is why invented numbers appeared on screen.
* The three blanks were assetIds NOT in the players table -> the MISS path
stamped 50 / all-ones / RWB / no name over everything we sent.
It also answers the "TODO/CONFIRM" left at the end of that document
(does a resolved card's rating come from our JSON?) statically: YES on a DB
hit, because the merge's hit path contains no write to record+0xb4.
==========================================================================
3. WHAT THIS SCRIPT ACTUALLY DOES
==========================================================================
1. finds FIFA17.exe and CardsDLL's live base from /proc/PID/maps
2. reads the CardsDb singleton (static slot 0x1802e6398)
3. walks the std::map at CardsDb+0x160c0 and decodes every record through
the card view-model's OWN offsets, flagging which records carry the
DB-MISS signature from section 2. Read-only; always runs; this census
alone is worth the trip.
4. self-check: for every record, follow record+0x00 (the observer-list head)
to an item and confirm item+0x10 points back at that record. This is
what makes a null result interpretable instead of ambiguous.
5. with --fire: writes a DIFFERENTIAL beacon into TWO records -- flavour A
into one, flavour B into another -- and deliberately leaves every other
record alone as a negative control. Backups are written to disk BEFORE
any process memory is touched.
6. --restore <manifest.json> puts the original bytes back.
Reversibility: the full 0x158-byte record is snapshotted to <backup>.bin, and
the manifest records every (offset, original bytes, new bytes). --restore
rewrites ONLY the ranges we wrote, and only where our beacon is still present
-- never the whole record, because the record also carries live intrusive-list
pointers that legitimately change between patch and restore. Killing FIFA also
clears everything: this is live memory only, nothing is persisted in the game.
Needs ptrace access: tools/root_arm.sh (kernel.yama.ptrace_scope=0)
USAGE
python3 tools/card_proof.py # read-only census
python3 tools/card_proof.py --fire --a <id> --b <id>
python3 tools/card_proof.py --restore /tmp/openfut_cardproof_<...>.json
==========================================================================
4. EVERY ADDRESS BELOW WAS RESOLVED THIS SESSION
==========================================================================
0x1802e6398 CardsDb singleton slot. Getter FUN_18011a830 is 2 instructions
and returns DAT_1802e6398. Six xrefs total to the slot.
0x18021c2a0 CardsDb vtable; the qword 0x18011cca0 occurs EXACTLY ONCE in
the whole image, at 0x18021cca8 = 0x18021c2a0 + 0xa08.
CardsDb+0x160c0 std::map; +0x160c8 header node, +0x160d8 root, +0x160e8 size
node: left +0x00, right +0x08, parent +0x10, colour +0x18,
key(item id) +0x20, record +0x28
record size 0x158. ctor 0x180041250 zeroes explicitly through +0xb7 then
memset(+0xb8, 0, 0xa0) -> 0xb8+0xa0 = 0x158. Its non-zero defaults
are +0x48 word 0x100, +0x4c dword 0xffffffff, +0x50 qword 0x156,
+0x70 = &PTR_LAB_1801eaac0. Rating/attrs/position/nation default to
ZERO -- the 50/1/RWB blank is the DB-miss stamp, not the ctor.
assignment operator 0x1800515e0 copies +0x08 .. +0x157 and re-splices the
intrusive list at +0x70/+0x78/+0x80. It never touches +0x00.
record+0x00 is the observer-list head; item+0x08 is the intrusive next;
item+0x10 is the record pointer (FUN_1800419b0). So the view-model's
*(item+0x10) is exactly node+0x28.
Card view-model FUN_1800d7920 -- full decompile 1843 chars, 77 instructions,
complete coverage. rec = *(item+0x10):
out[0] = dword rec+0x18 (resourceId)
out[1] = dword rec+0x18 & 0xFFFFFF (assetId)
out[2] = dword rec+0x94 (teamid)
out[3] = word rec+0x148 (nation)
out[4] = byte rec+0xb4 (RATING)
out[5] = byte rec+0x146 (position)
out[6] = dword rec+0x88 (league)
out[7] = dword rec+0x58
out+0x20 bool = (rec+0xb5 != 0) && (rec+0xb6 == 0)
out+0x21 bool = (int)rec+0x90 > 0
out+0x22 = 0x20 bytes from rec+0xdd, BUT if strlen(rec+0xdd)==0
it takes them from rec+0xc8 instead
out+0x42..47 = the six attributes read as SINGLE BYTES from the
dword slots rec+0x98,+0x9c,+0xa0,+0xa4,+0xa8,+0xac
Note the last one: the attributes are byte-truncated on read, so any value
over 255 wraps. Earlier notes described these as dwords, which is true of
the storage but not of the render.
The THREE name fields, all written by FUN_180135890 from the local players
table (Ghidra prints 0xc8 as decimal 200, which is why an earlier grep
missed the middle one):
FUN_1800081b0(rec+0xb8, firstname, 0x10)
FUN_1800081b0(rec+0xc8, lastname, 0x15)
FUN_1800081b0(rec+0xdd, knownAs, 0x1f)
and on a DB miss all three get DAT_1801eaf98 = " " (one space). Because a
space is not an empty string, the view-model's strlen(rec+0xdd) test passes
and a blank card renders a SPACE rather than falling back to +0xc8.
==========================================================================
5. OUTCOMES -- what each result proves. READ THIS BEFORE FIRING.
==========================================================================
Confirmed read-only, live, before any write was contemplated: 164 records,
map size field agreed with the walk, and all 164 backlinks resolved
(record+0x00 -> item -> +0x10 -> that same record). Records held real data:
assetId 20801, rating 94, attrs [90,93,82,91,33,80], firstname "Cristiano",
lastname "Ronaldo", knownAs "". So the record-offset model and the address
chain are already confirmed as a MEMORY model. What the write still buys is
the RENDER link: proof that the view-model re-reads this buffer and that what
it reads reaches the screen.
BOTH cards change, each to its own flavour, third card unchanged
Total success. The record is the single source of truth for the card
face, the view-model re-reads it per redraw, and per-record resolution
works. Combined with section 2 this closes the card problem entirely:
it is a DATA problem (ship assetIds that exist in FIFA's players table)
and no injection is ever needed in production.
Numbers change but the NAME does not
The most likely partial, and the informative one. It means the record
layout is right and the view-model re-read it, but the name string was
resolved once and cached above the view-model -- the 0x20 bytes it
copies to out+0x22 land in a struct that a hover-redraw does not
rebuild. Verdict: rating/attrs/nation/team are live-patchable, the
name is not, and any name work must go through the assetId -> players
table route rather than through this buffer.
The NAME changes but the numbers do not
Would mean we patched a record that is not the one driving those
pixels, i.e. two records exist for one card. Re-run the census and
check for a second node with the same assetId. Do not conclude
anything about layout from this; conclude the target was wrong.
NEITHER card changes, but the backlink check passed
The redraw did not call FUN_1800d7920 at all. This is a redraw
problem, not a model problem. Escalate the redraw (open Player
Details) but NOT by switching tabs -- a tab switch refetches, the
parser rebuilds the record, the merge re-stamps it and the copy inside
0x18011cca0 overwrites the beacon, which would look identical to a
failure and would be a false negative.
Both cards change to the SAME values
The view-model is reading one shared record for every card. That would
falsify per-record resolution and is the one outcome that would send us
back to the resolve path. This is precisely why the beacon is
differential and why a third card is left untouched.
A field changes on screen to something OTHER than the beacon
Read it as an enum decode, not as a failure: position 0 and 1 and
nation 38 and 14 are deliberately valid values, so the on-screen label
tells us the enum mapping. Position 2 is already known to render as
RWB, from the DB-miss stamp.
"""
import argparse
import glob
import json
import os
import struct
import sys
import time
# ------------------------------------------------------------------ constants
IMG_BASE = 0x180000000
DLL = "CardsDLL"
G_CARDSDB = 0x1802E6398
MAP_BASE = 0x160C0
MAP_HEADER = 0x160C8
MAP_ROOT = 0x160D8
MAP_SIZE = 0x160E8
NODE_L, NODE_R, NODE_KEY, NODE_REC = 0x00, 0x08, 0x20, 0x28
REC_SIZE = 0x158
MAX_NODES = 100000
# Byte ranges inside the record that are POINTERS or the map key.
# 0x00..0x08 observer-list head (FUN_1800419b0 splices items onto it)
# 0x08..0x10 the map key; changing it desynchronises node+0x20 and the tree
# 0x70..0x88 the embedded intrusive-list node the assignment operator
# re-splices rather than copies
# Every write is checked against this and the script dies rather than proceed.
FORBIDDEN = ((0x00, 0x10), (0x70, 0x88))
# The exact stamp FUN_180135890 writes when `players WHERE playerid=assetId`
# returns nothing. A record matching this is a card that rendered blank.
DB_MISS = {
0x0B4: ("u8", 0x32),
0x098: ("u32", 1), 0x09C: ("u32", 1), 0x0A0: ("u32", 1),
0x0A4: ("u32", 1), 0x0A8: ("u32", 1), 0x0AC: ("u32", 1),
0x146: ("u8", 2),
0x148: ("u16", 0xE),
0x094: ("u32", 0x78D),
}
# ------------------------------------------------------------------- beacons --
# TWO flavours, deliberately different in EVERY field. One record gets A, a
# second gets B, and every other record is left untouched as a negative
# control. A single fixed beacon cannot distinguish "the view-model re-read
# our record" from "that card already looked like that"; two different ones,
# plus an untouched third, can.
#
# resourceId (rec+0x18) is deliberately NOT in the beacon. It is the key the
# local DB query and any art/face lookup use, so changing it would confound
# the very thing we are measuring. --also-resourceid is a separate, later
# pass, one variable at a time.
#
# Values are chosen so each is unmistakable in a screenshot:
# rating 99 / 11 -- no card in our pool is either
# attrs 11..66 / 66..11 -- also reveals the on-card ORDER of the six
# name pure ASCII, cannot come from FIFA's own player DB
BEACONS = {
"A": [
(0x094, "u32", "teamid", 243),
(0x098, "u32", "attr0", 11),
(0x09C, "u32", "attr1", 22),
(0x0A0, "u32", "attr2", 33),
(0x0A4, "u32", "attr3", 44),
(0x0A8, "u32", "attr4", 55),
(0x0AC, "u32", "attr5", 66),
(0x0B4, "u8", "RATING", 99),
(0x146, "u8", "position enum", 0),
(0x148, "u16", "nation", 38),
(0x0DD, "str", "NAME (vm source)", "OPENFUT-A"),
],
"B": [
(0x094, "u32", "teamid", 9),
(0x098, "u32", "attr0", 66),
(0x09C, "u32", "attr1", 55),
(0x0A0, "u32", "attr2", 44),
(0x0A4, "u32", "attr3", 33),
(0x0A8, "u32", "attr4", 22),
(0x0AC, "u32", "attr5", 11),
(0x0B4, "u8", "RATING", 11),
(0x146, "u8", "position enum", 1),
(0x148, "u16", "nation", 14),
(0x0DD, "str", "NAME (vm source)", "OPENFUT-B"),
],
}
# The game itself writes the name with FUN_1800081b0(rec+0xdd, src, 0x1f), so
# 0x1f is the length the record is built for. We never exceed it.
NAME_MAX = 0x1F
KIND_LEN = {"u8": 1, "u16": 2, "u32": 4}
def encode(kind, value):
if kind == "u8":
return struct.pack("<B", value & 0xFF)
if kind == "u16":
return struct.pack("<H", value & 0xFFFF)
if kind == "u32":
return struct.pack("<I", value & 0xFFFFFFFF)
if kind == "str":
b = value.encode("ascii", "replace")
if len(b) >= NAME_MAX:
raise SystemExit("REFUSING: name %r is %d bytes, max %d"
% (value, len(b), NAME_MAX - 1))
return b + b"\0"
raise ValueError(kind)
def check_write(off, length):
"""Die unless [off, off+length) is a safe scalar range inside the record."""
if off < 0 or off + length > REC_SIZE:
raise SystemExit("REFUSING: write %#x..%#x is outside the record (size %#x)"
% (off, off + length, REC_SIZE))
for lo, hi in FORBIDDEN:
if off < hi and lo < off + length:
raise SystemExit(
"REFUSING: write %#x..%#x overlaps pointer/key range %#x..%#x"
% (off, off + length, lo, hi))
# ------------------------------------------------------------------ process --
def find_pid():
for d in glob.glob("/proc/[0-9]*"):
try:
if open(d + "/comm").read().strip() == "FIFA17.exe":
return int(d.rsplit("/", 1)[-1])
except Exception:
pass
return None
def dll_base(pid, name=DLL):
lo = None
try:
for line in open("/proc/%d/maps" % pid):
if name in line:
a = int(line.split("-")[0], 16)
lo = a if lo is None else min(lo, a)
except Exception:
return None
return lo
class Mem(object):
def __init__(self, pid, writable=False):
self.pid = pid
self.f = open("/proc/%d/mem" % pid, "r+b" if writable else "rb", buffering=0)
self.writable = writable
def read(self, va, n):
self.f.seek(va)
b = self.f.read(n)
if b is None or len(b) != n:
raise IOError("short read at %#x" % va)
return b
def try_read(self, va, n):
try:
return self.read(va, n)
except Exception:
return None
def write(self, va, data):
if not self.writable:
raise RuntimeError("Mem opened read-only")
self.f.seek(va)
self.f.write(data)
def q(self, va):
b = self.try_read(va, 8)
return struct.unpack("<Q", b)[0] if b else None
def i32(self, va):
b = self.try_read(va, 4)
return struct.unpack("<i", b)[0] if b else None
# ------------------------------------------------------------------- the map --
def walk(mem, cdb):
"""[(node_addr, key)] for every node in CardsDb's item map.
Generic DFS over both child slots with a visited set: the left/right
convention does not matter for a census, and a garbage pointer terminates
the walk instead of hanging it.
"""
header = cdb + MAP_HEADER
root = mem.q(cdb + MAP_ROOT)
if root is None:
return None
if root == 0 or root == header:
return []
out, seen, stack = [], set(), [root]
while stack and len(out) < MAX_NODES:
p = stack.pop()
if not p or p == header or p in seen or (p & 7):
continue
seen.add(p)
k = mem.q(p + NODE_KEY)
if k is None:
continue
out.append((p, k))
for slot in (NODE_L, NODE_R):
c = mem.q(p + slot)
if c and c != header and c not in seen:
stack.append(c)
out.sort(key=lambda t: t[1])
return out
def decode_record(buf):
"""Decode a 0x158-byte record through the view-model's OWN offsets."""
u8 = lambda o: buf[o]
u16 = lambda o: struct.unpack_from("<H", buf, o)[0]
u32 = lambda o: struct.unpack_from("<I", buf, o)[0]
i32 = lambda o: struct.unpack_from("<i", buf, o)[0]
def s(o, n):
raw = bytes(buf[o:o + n])
z = raw.find(b"\0")
return (raw[:z] if z >= 0 else raw).decode("ascii", "replace")
return {
"id(+0x08)": struct.unpack_from("<Q", buf, 0x08)[0],
"resourceId(+0x18)": u32(0x18),
"assetId(low24)": u32(0x18) & 0xFFFFFF,
"cardType(+0x4c)": i32(0x4C),
"league(+0x88)": u32(0x88),
"teamid(+0x94)": u32(0x94),
"attrs(+0x98..ac)": [u8(0x98 + 4 * i) for i in range(6)],
"rating(+0xb4)": u8(0xB4),
"name(+0xdd)": s(0xDD, NAME_MAX),
"firstname(+0xb8)": s(0xB8, 0x10),
"fallback(+0xc8)": s(0xC8, 0x15),
"position(+0x146)": u8(0x146),
"nation(+0x148)": u16(0x148),
"observers(+0x00)": struct.unpack_from("<Q", buf, 0x00)[0],
}
def is_db_miss(buf):
"""True if this record carries FUN_180135890's players-table MISS stamp."""
for off, (kind, want) in DB_MISS.items():
n = KIND_LEN[kind]
got = int.from_bytes(bytes(buf[off:off + n]), "little")
if got != want:
return False
return True
def backlink_ok(mem, rec):
"""Follow record+0x00 (observer head) -> item, check item+0x10 == rec.
This is the check that makes a null result interpretable: if it holds, the
record we are about to patch really is the one the view-model dereferences.
Returns (verdict_string, item_addr_or_None).
"""
head = mem.q(rec + 0x00)
if head is None:
return ("record unreadable", None)
if head == 0:
return ("no observer -- this record is not bound to a rendered item", None)
back = mem.q(head + 0x10)
if back is None:
return ("observer %#x unreadable" % head, head)
if back == rec:
return ("OK item %#x -> +0x10 -> this record" % head, head)
return ("MISMATCH item %#x +0x10 = %#x, expected %#x" % (head, back, rec), head)
def print_census(mem, cdb, nodes):
size = mem.i32(cdb + MAP_SIZE)
print(" CardsDb %#x" % cdb)
print(" map base %#x (header %#x, root %#x)"
% (cdb + MAP_BASE, cdb + MAP_HEADER, mem.q(cdb + MAP_ROOT) or 0))
print(" map size field %s walked nodes %s"
% (size, "unreadable" if nodes is None else len(nodes)))
if nodes is None:
print("\n TREE UNREADABLE. Nothing further can be said.")
return []
if size is not None and len(nodes) != size:
print(" !! walk count != size field -- the WALK is wrong, not the game.")
if not nodes:
print("\n THE MAP IS EMPTY. No item has been parsed in this session yet.")
print(" Enter the Squads tab (GET /squad/0) or open the club, then re-run.")
return []
blanks = []
print()
for node, key in nodes:
rec = node + NODE_REC
buf = mem.try_read(rec, REC_SIZE)
if buf is None:
print(" item id %-12d node %#x <record unreadable>" % (key, node))
continue
d = decode_record(buf)
miss = is_db_miss(buf)
if miss:
blanks.append((key, node))
print(" item id %-12d node %#x record %#x %s"
% (key, node, rec, "<< DB-MISS BLANK" if miss else ""))
print(" assetId %-9d rating %-4d pos %-4d nation %-5d team %-6d"
% (d["assetId(low24)"], d["rating(+0xb4)"], d["position(+0x146)"],
d["nation(+0x148)"], d["teamid(+0x94)"]))
print(" attrs %-24s cardType %s"
% (d["attrs(+0x98..ac)"], d["cardType(+0x4c)"]))
print(" name(+0xdd) %-14r first(+0xb8) %-14r fallback(+0xc8) %r"
% (d["name(+0xdd)"], d["firstname(+0xb8)"], d["fallback(+0xc8)"]))
if not d["name(+0xdd)"]:
print(" -> +0xdd is EMPTY, so the view-model renders the "
"+0xc8 fallback instead")
verdict, _ = backlink_ok(mem, rec)
print(" backlink: %s" % verdict)
print()
print(" %d of %d records carry the players-table DB-MISS stamp"
% (len(blanks), len(nodes)))
if blanks:
print(" blank item ids: %s" % ", ".join(str(k) for k, _ in blanks))
print(" Those are the best patch targets: they are the cards that")
print(" currently render generic, so ANY change is unambiguous.")
else:
print(" Every assetId in play resolved against FIFA's local players")
print(" table, so there is no generic card to patch right now. Patch")
print(" two RESOLVED cards instead: the beacon values are chosen so")
print(" they cannot be confused with real ones, and doing it on a")
print(" resolved card additionally answers the name question, because")
print(" a resolved card is exactly the case where +0xdd is empty and")
print(" the +0xc8 fallback is being rendered.")
return blanks
# --------------------------------------------------------------------- patch --
def plan_writes(snap, flavour):
writes = []
for off, kind, name, value in BEACONS[flavour]:
new = encode(kind, value)
check_write(off, len(new))
writes.append({"off": off, "len": len(new), "name": name,
"flavour": flavour,
"orig_hex": snap[off:off + len(new)].hex(),
"new_hex": new.hex()})
return writes
def do_patch(mem, nodes, targets, backup_path):
"""targets = [(item_id, flavour), ...]"""
chosen = []
for item_id, flavour in targets:
match = [(n, k) for n, k in nodes if k == item_id]
if not match:
raise SystemExit("item id %d is not in the map. Present: %s"
% (item_id, ", ".join(str(k) for _, k in nodes[:20])))
node, key = match[0]
chosen.append((node, key, flavour))
manifest = {"tool": "card_proof.py",
"ts": time.strftime("%Y-%m-%dT%H:%M:%S"),
"pid": mem.pid, "records": []}
# --- snapshot EVERYTHING before touching the process ---------------------
for node, key, flavour in chosen:
rec = node + NODE_REC
snap = mem.read(rec, REC_SIZE)
binpath = "%s.item%d.bin" % (os.path.splitext(backup_path)[0], key)
with open(binpath, "wb") as f:
f.write(snap)
manifest["records"].append({
"item_id": key, "node": node, "record": rec,
"flavour": flavour, "snapshot": binpath,
"writes": plan_writes(snap, flavour),
})
with open(backup_path, "w") as f:
json.dump(manifest, f, indent=2)
print(" backup manifest: %s" % backup_path)
for r in manifest["records"]:
print(" %s (%d bytes)" % (r["snapshot"], REC_SIZE))
# --- only now do we write ------------------------------------------------
ok = True
for r in manifest["records"]:
print("\n flavour %s -> item %d, record %#x"
% (r["flavour"], r["item_id"], r["record"]))
verdict, _ = backlink_ok(mem, r["record"])
print(" backlink before write: %s" % verdict)
for w in r["writes"]:
mem.write(r["record"] + w["off"], bytes.fromhex(w["new_hex"]))
back = mem.read(r["record"] + w["off"], w["len"]).hex()
if back != w["new_hex"]:
ok = False
print(" %s +%#05x %-18s %s -> %s"
% ("OK " if back == w["new_hex"] else "FAIL",
w["off"], w["name"], w["orig_hex"], back))
print()
if not ok:
print(" !! at least one write did not read back. STOP and restore.")
return 1
print(" Beacons in place. DO NOT switch tabs: a tab switch refetches")
print(" /squad/0 or /club, the parser rebuilds the record, the local-DB")
print(" merge re-stamps it and the copy at 0x18011cca0 overwrites ours.")
print(" Force a REDRAW only: move the cursor onto and off the card, or")
print(" open and close Player Details.")
print(" Restore with: python3 %s --restore %s" % (sys.argv[0], backup_path))
return 0
def do_restore(path):
with open(path) as f:
m = json.load(f)
pid = m["pid"]
if not os.path.exists("/proc/%d" % pid):
print("pid %d is gone -- FIFA restarted; the patch went with it "
"(live memory only)." % pid)
return 0
if open("/proc/%d/comm" % pid).read().strip() != "FIFA17.exe":
print("pid %d is no longer FIFA17.exe. REFUSING to write." % pid)
return 1
mem = Mem(pid, writable=True)
for r in m["records"]:
rec = r["record"]
print("restoring item %d, record %#x (%d ranges)"
% (r["item_id"], rec, len(r["writes"])))
for w in r["writes"]:
cur = mem.read(rec + w["off"], w["len"]).hex()
if cur != w["new_hex"]:
print(" SKIP +%#05x holds %s, not our beacon %s -- the game "
"rewrote it; restoring would be wrong."
% (w["off"], cur, w["new_hex"]))
continue
check_write(w["off"], w["len"])
mem.write(rec + w["off"], bytes.fromhex(w["orig_hex"]))
back = mem.read(rec + w["off"], w["len"]).hex()
print(" %s +%#05x %-18s -> %s"
% ("OK " if back == w["orig_hex"] else "FAIL",
w["off"], w["name"], back))
print("done.")
return 0
# ---------------------------------------------------------------------- main --
def main():
ap = argparse.ArgumentParser(
description="Read (and with --fire, beacon-patch) live FUT card records.")
ap.add_argument("--a", type=int, metavar="ITEMID",
help="item id to receive beacon flavour A")
ap.add_argument("--b", type=int, metavar="ITEMID",
help="item id to receive beacon flavour B (differential)")
ap.add_argument("--fire", action="store_true",
help="REQUIRED to write anything. Without it this is read-only.")
ap.add_argument("--restore", metavar="MANIFEST.json",
help="undo a previous --fire using its backup manifest")
args = ap.parse_args()
if args.restore:
return do_restore(args.restore)
pid = find_pid()
if pid is None:
print("FIFA17.exe is not running.")
return 1
base = dll_base(pid)
if base is None:
print("pid %d is running but %s is not mapped yet (reach the FUT hub first)."
% (pid, DLL))
return 1
try:
mem = Mem(pid, writable=bool(args.fire))
except Exception as e:
print("cannot open /proc/%d/mem: %s" % (pid, e))
print("Need ptrace access: sudo sysctl -w kernel.yama.ptrace_scope=0"
" (tools/root_arm.sh)")
return 1
print("FIFA pid=%d %s base=%#x (static image base %#x)"
% (pid, DLL, base, IMG_BASE))
cdb = mem.q(base + (G_CARDSDB - IMG_BASE))
if not cdb:
print(" CardsDb singleton is NULL -- the FUT layer is not constructed yet.")
return 1
nodes = walk(mem, cdb)
blanks = print_census(mem, cdb, nodes)
if not args.fire:
print("\nREAD-ONLY. Nothing was written.")
print("To run the experiment pick two ids from above (ideally two")
print("DB-MISS blanks) and add: --a <id> --b <id> --fire")
return 0
if not nodes:
print("nothing to patch.")
return 1
if args.a is None or args.b is None:
print("--fire needs BOTH --a <id> and --b <id>.")
print("The differential is the point: one beacon cannot distinguish a")
print("re-read from a coincidence, and a third untouched card is the")
print("negative control.")
if blanks:
print("Suggested: --a %d --b %s"
% (blanks[0][0],
blanks[1][0] if len(blanks) > 1 else "<another id>"))
return 1
if args.a == args.b:
print("--a and --b must be different records.")
return 1
backup = "/tmp/openfut_cardproof_%d_%d.json" % (pid, int(time.time()))
return do_patch(mem, nodes, [(args.a, "A"), (args.b, "B")], backup)
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,459 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Validate the FUT card record-offset model END TO END, live, with a DATA write.
DO NOT RUN THIS WITHOUT READING THE "WHAT THIS ACTUALLY DOES" SECTION.
It writes to a running FIFA17.exe. It refuses to write unless you pass --fire.
--------------------------------------------------------------------------
WHY THIS IS NOT THE EXPERIMENT docs/CARD_SYSTEM.md ASKED FOR
--------------------------------------------------------------------------
CARD_SYSTEM.md "Option C" says: patch the miss branch of the lookup 0x18011cca0
so a miss emits a fixed real record. That experiment cannot be built as written,
because the premise is wrong. Re-read of the lookup this session (full decompile,
1664 chars; full disassembly, 84 instructions -- both in the session scratchpad):
FUN_18011cca0(CardsDb, item, parsed_record)
key = *(parsed_record + 8) // atom 0x15c = "id"
walk the RB-tree at CardsDb+0x160c0
if MISS: node = FUN_180115c30(...) // <-- INSERTS a fresh node
FUN_1800515e0(node+0x28, parsed_record) // record = parsed_record
FUN_1800419b0(item, node+0x28) // item+0x10 = &record
There is no "blank default record" and no miss-emit path. A miss ALLOCATES a
node (FUN_1801155f0 -> record ctor 0x180041250, zero-init, size 0x158) and the
very next instruction overwrites that record from the parsed item. So the map is
NOT empty offline -- it gains one node per parsed item, keyed by the item's `id`,
and each node's record at +0x28 is the exact buffer the card view-model
0x1800d7920 dereferences through item+0x10.
That is strictly better news: the thing we want to prove is reachable as a plain
DATA WRITE into an existing live buffer. No instruction patching at all.
--------------------------------------------------------------------------
WHAT THIS ACTUALLY DOES
--------------------------------------------------------------------------
1. finds FIFA17.exe and CardsDLL's live base from /proc/PID/maps
2. reads the CardsDb singleton (static 0x1802e6398)
3. walks the std::map at CardsDb+0x160c0 and DECODES every record through the
view-model's own offsets -- this alone is the pre-check that decides the
experiment (see OUTCOMES below); it is read-only and always runs
4. only with --fire --item <id>: writes a BEACON of deliberately unmistakable
values into ONE record, after snapshotting it to a backup file
5. --restore <backup.json> puts the original bytes back
Reversibility: the full 0x158-byte record is snapshotted to <backup>.bin before
any write, and the manifest records every (offset, original bytes, new bytes).
--restore rewrites ONLY the byte ranges we wrote -- never the whole record --
because the record also contains live intrusive-list pointers that legitimately
change between patch and restore, and blindly restoring those would corrupt the
observer list. A FIFA restart also clears everything (live memory only).
Needs ptrace access: tools/root_arm.sh (kernel.yama.ptrace_scope=0).
USAGE
python3 tools/card_record_poke.py # read-only census
python3 tools/card_record_poke.py --item 100000001 --fire
python3 tools/card_record_poke.py --restore /tmp/openfut_cardrec_<...>.json
VERIFIED THIS SESSION (static, cardsdll.dll @ 0x180000000)
0x1802e6398 CardsDb singleton (getter FUN_18011a830 returns DAT_1802e6398)
0x18021c2a0 CardsDb vtable; slot +0xa08 -> 0x18011cca0 (the lookup)
(found by scanning .rdata for the qword 0x18011cca0: exactly one
hit, at 0x18021cca8 = 0x18021c2a0 + 0xa08)
CardsDb+0x160c0 std::map base; +0x160c8 embedded header node;
header+0x10 = +0x160d8 = root; map+0x28 = +0x160e8 = size
(the insert increments *(int*)(mapbase+0x28))
node: child/child +0x00/+0x08, parent +0x10, key(itemId) +0x20, record +0x28
record size 0x158 (ctor 0x180041250 memsets +0xb8..+0x158 and the assignment
operator 0x1800515e0 copies through +0x150)
record field offsets, read straight out of the view-model 0x1800d7920:
+0x18 dword resourceId (its low 24 bits are used separately)
+0x58 dword, +0x88 dword, +0x90 int (>0 -> a bool),
+0x94 dword teamid, +0x98/9c/a0/a4/a8/ac dword attrs,
+0xb4 byte rating, +0xb5/+0xb6 bytes gate a bool,
+0xdd 32-byte name (falls back to +0xc8 when +0xdd is empty),
+0x146 byte position, +0x148 word nation
record+0x00/+0x08 and +0x70/+0x78/+0x80 are POINTERS (the ctor stores
&PTR_LAB_1801eaac0 at +0x70). This script refuses to write them.
"""
import argparse
import glob
import json
import os
import struct
import sys
import time
# ------------------------------------------------------------------ constants
IMG_BASE = 0x180000000
DLL = "CardsDLL"
G_CARDSDB = 0x1802E6398 # CardsDb singleton slot
MAP_BASE = 0x160C0 # std::map object inside CardsDb
MAP_HEADER = 0x160C8 # embedded header node
MAP_ROOT = 0x160D8 # header + 0x10
MAP_SIZE = 0x160E8 # map + 0x28
NODE_L, NODE_R, NODE_KEY, NODE_REC = 0x00, 0x08, 0x20, 0x28
REC_SIZE = 0x158
MAX_NODES = 100000
# Byte ranges inside the record that are POINTERS / intrusive-list links.
# Writing them can corrupt FIFA's heap. Every write is checked against this.
FORBIDDEN = ((0x00, 0x10), (0x70, 0x88))
# ------------------------------------------------------------------- beacon --
# Deliberately unmistakable values. Every one is independently identifiable in a
# screenshot, so a PARTIAL result tells us exactly which field drove which pixel.
# rating 99 -- no real starter card is 99
# attrs 11..66 -- also reveals the on-card ORDER of the six attributes
# nation 38 -- Portugal flag
# teamid 243 -- Real Madrid badge
# resourceId 20801 (version 0) -- Ronaldo; this is what a face/art lookup keys on
# name -- pure ASCII, cannot be mistaken for a dbdata name
BEACON = [
(0x018, "u32", "resourceId (vm field0/1)", 20801),
(0x088, "u32", "vm field6 (league?)", 53),
(0x094, "u32", "teamid", 243),
(0x098, "u32", "attr0", 11),
(0x09C, "u32", "attr1", 22),
(0x0A0, "u32", "attr2", 33),
(0x0A4, "u32", "attr3", 44),
(0x0A8, "u32", "attr4", 55),
(0x0AC, "u32", "attr5", 66),
(0x0B4, "u8", "rating", 99),
(0x146, "u8", "position (enum probe)", 25),
(0x148, "u16", "nation", 38),
(0x0DD, "str32", "name", "OPENFUT PROOF"),
]
KIND_LEN = {"u8": 1, "u16": 2, "u32": 4, "str32": 0x20}
def encode(kind, value):
if kind == "u8":
return struct.pack("<B", value & 0xFF)
if kind == "u16":
return struct.pack("<H", value & 0xFFFF)
if kind == "u32":
return struct.pack("<I", value & 0xFFFFFFFF)
if kind == "str32":
b = value.encode("ascii", "replace")[:0x1F]
return b + b"\0" * (0x20 - len(b))
raise ValueError(kind)
def check_write(off, length):
"""Raise unless [off, off+length) is a safe scalar range in the record."""
if off < 0 or off + length > REC_SIZE:
raise SystemExit("REFUSING: write %#x..%#x is outside the record (size %#x)"
% (off, off + length, REC_SIZE))
for lo, hi in FORBIDDEN:
if off < hi and lo < off + length:
raise SystemExit(
"REFUSING: write %#x..%#x overlaps pointer range %#x..%#x "
"(intrusive list / vtable slot)" % (off, off + length, lo, hi))
# ------------------------------------------------------------------ process --
def find_pid():
for d in glob.glob("/proc/[0-9]*"):
try:
if open(d + "/comm").read().strip() == "FIFA17.exe":
return int(d.rsplit("/", 1)[-1])
except Exception:
pass
return None
def dll_base(pid, name=DLL):
try:
for line in open("/proc/%d/maps" % pid):
if name in line:
return int(line.split("-")[0], 16) # lowest mapping = base
except Exception:
return None
return None
class Mem(object):
def __init__(self, pid, writable=False):
self.pid = pid
self.path = "/proc/%d/mem" % pid
self.f = open(self.path, "r+b" if writable else "rb", buffering=0)
self.writable = writable
def read(self, va, n):
self.f.seek(va)
b = self.f.read(n)
if b is None or len(b) != n:
raise IOError("short read at %#x" % va)
return b
def try_read(self, va, n):
try:
return self.read(va, n)
except Exception:
return None
def write(self, va, data):
if not self.writable:
raise RuntimeError("Mem opened read-only")
self.f.seek(va)
self.f.write(data)
def q(self, va):
b = self.try_read(va, 8)
return struct.unpack("<Q", b)[0] if b else None
def i32(self, va):
b = self.try_read(va, 4)
return struct.unpack("<i", b)[0] if b else None
# ------------------------------------------------------------------- the map --
def walk(mem, cdb):
"""[(node_addr, key)] for every node in CardsDb's item map.
Generic DFS over both child slots with a visited set: the exact left/right
convention does not matter for a census, and a garbage pointer terminates the
walk instead of hanging it.
"""
header = cdb + MAP_HEADER
root = mem.q(cdb + MAP_ROOT)
if root is None:
return None
if root == 0 or root == header:
return []
out, seen, stack = [], set(), [root]
while stack and len(out) < MAX_NODES:
p = stack.pop()
if not p or p == header or p in seen or (p & 7):
continue
seen.add(p)
k = mem.q(p + NODE_KEY)
if k is None:
continue
out.append((p, k))
for slot in (NODE_L, NODE_R):
c = mem.q(p + slot)
if c and c != header and c not in seen:
stack.append(c)
out.sort(key=lambda t: t[1])
return out
def decode_record(buf):
"""Decode a 0x158-byte record through the view-model's own offsets."""
u8 = lambda o: buf[o]
u16 = lambda o: struct.unpack_from("<H", buf, o)[0]
u32 = lambda o: struct.unpack_from("<I", buf, o)[0]
i32 = lambda o: struct.unpack_from("<i", buf, o)[0]
def s(o, n=0x20):
raw = bytes(buf[o:o + n])
z = raw.find(b"\0")
raw = raw[:z] if z >= 0 else raw
return raw.decode("ascii", "replace")
return {
"tradeId(+0x10)": struct.unpack_from("<Q", buf, 0x10)[0],
"resourceId(+0x18)": u32(0x18),
" assetId(low24)": u32(0x18) & 0xFFFFFF,
" version(>>24)": u32(0x18) >> 24,
"vm7(+0x58)": u32(0x58),
"vm6(+0x88)": u32(0x88),
"int(+0x90)": i32(0x90),
"teamid(+0x94)": u32(0x94),
"attrs(+0x98..ac)": [u32(0x98 + 4 * i) for i in range(6)],
"rating(+0xb4)": u8(0xB4),
"flagA(+0xb5)": u8(0xB5),
"flagB(+0xb6)": u8(0xB6),
"name(+0xdd)": s(0xDD),
"nameFallback(+0xc8)": s(0xC8, 0x15),
"position(+0x146)": u8(0x146),
"nation(+0x148)": u16(0x148),
}
def print_census(mem, cdb, nodes):
size = mem.i32(cdb + MAP_SIZE)
print(" CardsDb %#x" % cdb)
print(" map base %#x (header %#x, root %#x)"
% (cdb + MAP_BASE, cdb + MAP_HEADER, mem.q(cdb + MAP_ROOT) or 0))
print(" map size field %s walked nodes %s"
% (size, "unreadable" if nodes is None else len(nodes)))
if nodes is None:
print("\n TREE UNREADABLE. Nothing further can be said.")
return
if size is not None and len(nodes) != size:
print(" !! walk count != size field -- the walk is wrong, not the game.")
if not nodes:
print("\n THE MAP IS EMPTY. No item has been parsed in this session yet.")
print(" Enter the Squads tab (so GET /squad/0 is served) and re-run.")
return
print()
for node, key in nodes:
rec = node + NODE_REC
buf = mem.try_read(rec, REC_SIZE)
print(" item id %-12d node %#x record %#x" % (key, node, rec))
if buf is None:
print(" <record unreadable>")
continue
d = decode_record(buf)
for k in ("resourceId(+0x18)", " assetId(low24)", "rating(+0xb4)",
"teamid(+0x94)", "nation(+0x148)", "position(+0x146)",
"attrs(+0x98..ac)", "name(+0xdd)", "nameFallback(+0xc8)"):
print(" %-22s %s" % (k, d[k]))
print()
# --------------------------------------------------------------------- patch --
def do_patch(mem, cdb, nodes, item_id, backup_path):
match = [(n, k) for n, k in nodes if k == item_id]
if not match:
raise SystemExit(
"item id %d is not in the map. Present: %s"
% (item_id, ", ".join(str(k) for _, k in nodes[:20])))
node, key = match[0]
rec = node + NODE_REC
snap = mem.read(rec, REC_SIZE)
writes = []
for off, kind, name, value in BEACON:
ln = KIND_LEN[kind]
check_write(off, ln)
new = encode(kind, value)
assert len(new) == ln
writes.append({"off": off, "len": ln, "name": name,
"orig_hex": snap[off:off + ln].hex(), "new_hex": new.hex()})
manifest = {
"tool": "card_record_poke.py",
"ts": time.strftime("%Y-%m-%dT%H:%M:%S"),
"pid": mem.pid,
"item_id": key,
"node": node,
"record": rec,
"record_snapshot": os.path.splitext(backup_path)[0] + ".bin",
"writes": writes,
}
with open(manifest["record_snapshot"], "wb") as f:
f.write(snap)
with open(backup_path, "w") as f:
json.dump(manifest, f, indent=2)
print(" backup written: %s" % backup_path)
print(" %s (%d bytes)" % (manifest["record_snapshot"], len(snap)))
print("\n writing beacon into record %#x" % rec)
ok = True
for w in writes:
mem.write(rec + w["off"], bytes.fromhex(w["new_hex"]))
back = mem.read(rec + w["off"], w["len"]).hex()
flag = "OK " if back == w["new_hex"] else "FAIL"
if back != w["new_hex"]:
ok = False
print(" %s +%#05x %-24s %s -> %s" % (flag, w["off"], w["name"],
w["orig_hex"], back))
print()
if not ok:
print(" !! at least one write did not read back. STOP and restore.")
return 1
print(" Beacon in place. Do NOT switch tabs (a tab switch refetches /squad/0")
print(" and the parser will overwrite this record). Move the cursor onto and")
print(" off the card, or open Player Details, to force a redraw.")
print(" Restore with: python3 %s --restore %s" % (sys.argv[0], backup_path))
return 0
def do_restore(path):
with open(path) as f:
m = json.load(f)
pid = m["pid"]
if not os.path.exists("/proc/%d" % pid):
print("pid %d is gone -- FIFA restarted, the patch is already gone with it."
% pid)
return 0
if open("/proc/%d/comm" % pid).read().strip() != "FIFA17.exe":
print("pid %d is no longer FIFA17.exe. REFUSING to write." % pid)
return 1
mem = Mem(pid, writable=True)
rec = m["record"]
print("restoring record %#x in pid %d (%d ranges)" % (rec, pid, len(m["writes"])))
for w in m["writes"]:
cur = mem.read(rec + w["off"], w["len"]).hex()
if cur != w["new_hex"]:
print(" note +%#05x holds %s, not our beacon %s -- the game rewrote "
"it; restoring anyway is WRONG, skipping." % (w["off"], cur, w["new_hex"]))
continue
check_write(w["off"], w["len"])
mem.write(rec + w["off"], bytes.fromhex(w["orig_hex"]))
back = mem.read(rec + w["off"], w["len"]).hex()
print(" %s +%#05x %-24s -> %s"
% ("OK " if back == w["orig_hex"] else "FAIL", w["off"], w["name"], back))
print("done.")
return 0
# ---------------------------------------------------------------------- main --
def main():
ap = argparse.ArgumentParser(
description="Read (and, with --fire, beacon-patch) a live FUT card record.")
ap.add_argument("--item", type=int,
help="item id (map key) of the record to patch")
ap.add_argument("--fire", action="store_true",
help="REQUIRED to write anything. Without it this tool is read-only.")
ap.add_argument("--restore", metavar="BACKUP.json",
help="undo a previous --fire using its backup manifest")
args = ap.parse_args()
if args.restore:
return do_restore(args.restore)
pid = find_pid()
if pid is None:
print("FIFA17.exe is not running.")
return 1
base = dll_base(pid)
if base is None:
print("pid %d is running but %s is not mapped yet (reach the FUT hub first)."
% (pid, DLL))
return 1
try:
mem = Mem(pid, writable=bool(args.fire))
except Exception as e:
print("cannot open /proc/%d/mem: %s" % (pid, e))
print("Need ptrace access: sudo sysctl -w kernel.yama.ptrace_scope=0"
" (tools/root_arm.sh)")
return 1
print("FIFA pid=%d %s base=%#x (image base %#x)" % (pid, DLL, base, IMG_BASE))
cdb = mem.q(base + (G_CARDSDB - IMG_BASE))
if not cdb:
print(" CardsDb singleton is NULL -- the FUT layer is not constructed yet.")
return 1
nodes = walk(mem, cdb)
print_census(mem, cdb, nodes)
if not args.fire:
print("READ-ONLY. Nothing was written. Add --item <id> --fire to patch.")
return 0
if args.item is None:
print("--fire needs --item <id>. Pick one from the census above.")
return 1
if not nodes:
print("nothing to patch.")
return 1
backup = "/tmp/openfut_cardrec_%d_%d_%d.json" % (pid, args.item, int(time.time()))
return do_patch(mem, cdb, nodes, args.item, backup)
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,219 @@
#!/usr/bin/env python3
"""READ-ONLY checker for the club/stats vocabulary change.
Nothing here mutates a save, opens a pack, or writes an item. It issues GETs
only, and every GET it issues is one the client already issues by itself.
Run it BEFORE the integrator lands the consumables rows (it will report the
14 rows missing, which is the current, wrong state) and AFTER (it should report
all clear). It is deliberately NOT part of test_fut_contract.py /
test_card_families.py -- those two have fixed baselines (439 / 414) that must
not move.
python3 check_club_stat_vocab.py [--host 127.0.0.1:8099]
WHAT IT CHECKS, and why each check exists
-----------------------------------------
1. SPELLING. The 14 consumables* type strings and all 40 vocabulary names are
asserted against docs/fut_atoms.tsv. An unrecognised `type` string is not an
error on the client: FUN_18012fd40 returns stat id 0 and the row lands in a
bucket nothing reads. A typo therefore fails SILENTLY to zero and looks
exactly like "the hypothesis was wrong". This is the highest-risk detail in
the whole change, so it is checked first and off the atom table, not off a
transcription.
2. THE THREE DEAD ATOMS. consumablesContract 0xa6 / consumablesTraining 0xa7 /
consumablesFitness 0xa8 are real atom names with NO arm in FUN_18012fd40.
Sending them proves nothing and lands in bucket 0 under stat id 0. Assert we
never send them.
3. PURELY ADDITIVE. Every global `type` the server sends today must still be
sent, with the same value, after the change. The player/manager/coach panels
are live-proven and ride on those rows.
4. SHAPE. All four keys in every element (element-local vars are not reset
between elements, so a missing key silently inherits the previous element's
value), every value an int, contextId 1 for the global bucket.
5. THE COUNTS ARE REACHABLE AT ALL. If FUT_CONSUMABLES is armed, the 14 rows
must sum to the size of the shelf the server would actually serve. All
fourteen at zero while the shelf is armed is the specific failure this whole
round is trying to avoid: the club STORE holds no consumables (the shelf is a
GET-time overlay), so counting the store alone yields fourteen zeros and an
uninterpretable live test.
"""
import argparse
import json
import os
import sys
import urllib.request
HERE = os.path.dirname(os.path.abspath(__file__))
DOCS = os.path.join(os.path.dirname(HERE), "docs")
sys.path.insert(0, HERE)
# stat id -> atom id, for the fourteen rows the CONSUMABLES panel (FUN_180043b90
# case 6) reads out of bucket 0. Both columns are cross-checked below: the name
# against docs/fut_atoms.tsv, the stat id against fut_club_stats.VOCAB.
CONSUMABLE_ATOMS = {
0x3C: 0xA5, 0x41: 0xAF, 0x42: 0xA9, 0x43: 0xB3, 0x44: 0xAB,
0x45: 0xB2, 0x46: 0xB5, 0x47: 0xAA, 0x48: 0xAD, 0x49: 0xB4,
0x4A: 0xAC, 0x4B: 0xB0, 0x4C: 0xB1, 0x4D: 0xAE,
}
# Real atom names with no arm in FUN_18012fd40 -> stat id 0 -> dropped.
DEAD_NAMES = ("consumablesContract", "consumablesTraining", "consumablesFitness")
PASS, FAIL = [], []
def ok(msg):
PASS.append(msg)
def bad(msg):
FAIL.append(msg)
def load_atoms():
path = os.path.join(DOCS, "fut_atoms.tsv")
atoms = {}
with open(path, encoding="utf8", errors="replace") as fh:
for line in fh:
p = line.rstrip("\n").split("\t")
if len(p) >= 3:
atoms[p[2]] = int(p[1], 16)
return atoms
def get(base, path):
url = "http://%s/ut/game/fifa17%s" % (base, path)
with urllib.request.urlopen(url, timeout=20) as r:
return json.loads(r.read().decode() or "{}")
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--host", default="127.0.0.1:8099")
args = ap.parse_args()
import fut_club_stats as fcs
# ---- 1 / 2 spelling, off the atom table ------------------------------
atoms = load_atoms()
for sid, atom in sorted(CONSUMABLE_ATOMS.items()):
name = fcs.VOCAB.get(sid)
if name is None:
bad("VOCAB has no name for stat id 0x%02x" % sid)
elif atoms.get(name) != atom:
bad("0x%02x %r: atom %s, expected %s"
% (sid, name, hex(atoms.get(name) or 0), hex(atom)))
else:
ok("0x%02x %-42s atom %s" % (sid, name, hex(atom)))
unknown = [n for n in fcs.VOCAB.values() if n not in atoms]
if unknown:
bad("VOCAB names absent from fut_atoms.tsv: %s" % unknown)
else:
ok("all %d vocabulary names resolve in fut_atoms.tsv" % len(fcs.VOCAB))
if "leaguelogos" in fcs.VOCAB.values():
bad("lowercase leaguelogos (0x18d) is NOT in the map; use leagueLogos (0x18e)")
else:
ok("leagueLogos capitalisation correct")
# ---- live bodies ------------------------------------------------------
try:
bodies = {m: get(args.host, "/club/stats/" + m)
for m in ("year", "consumables", "country/14", "league/13")}
except Exception as exc: # noqa: BLE001
print("cannot reach the server at %s: %s" % (args.host, exc))
return 2
for mode, body in bodies.items():
rows = body.get("stat", [])
if not rows:
bad("%s: empty stat body" % mode)
continue
glob = [r for r in rows if r.get("contextId") == 1]
# ---- 4 shape ----------------------------------------------------
wrong_keys = [r for r in rows
if set(r) != {"contextId", "contextValue", "type", "typeValue"}]
wrong_type = [r for r in rows
if not isinstance(r.get("typeValue"), int)
or not isinstance(r.get("contextValue"), int)]
if wrong_keys:
bad("%s: %d elements do not carry all four keys" % (mode, len(wrong_keys)))
elif wrong_type:
bad("%s: %d elements carry a non-int value" % (mode, len(wrong_type)))
else:
ok("%-12s %3d rows, all four keys, all ints" % (mode, len(rows)))
if rows[0].get("type") != "players":
bad("%s: first row is %r, not players -- club_stats_route logs "
"stats[0]['typeValue'] as the player count" % (mode, rows[0].get("type")))
# ---- 2 dead atoms ------------------------------------------------
sent = {r["type"] for r in glob}
for n in DEAD_NAMES:
if n in sent:
bad("%s: sends dead atom %s (no arm in FUN_18012fd40)" % (mode, n))
# ---- the fourteen -------------------------------------------------
want = {fcs.VOCAB[s] for s in CONSUMABLE_ATOMS}
miss = sorted(want - sent)
if miss:
bad("%s: %d of the 14 consumables rows MISSING: %s"
% (mode, len(miss), ", ".join(miss)))
else:
ok("%-12s carries all 14 consumables rows" % mode)
# ---- 3 purely additive ----------------------------------------------
ref = {r["type"]: r["typeValue"]
for r in bodies["year"]["stat"] if r.get("contextId") == 1}
for mode in ("consumables", "country/14", "league/13"):
cur = {r["type"]: r["typeValue"]
for r in bodies[mode]["stat"] if r.get("contextId") == 1}
drift = {k: (v, cur.get(k)) for k, v in ref.items() if cur.get(k) != v}
if drift:
bad("%s: global rows disagree with year: %s" % (mode, drift))
if not any("disagree with year" in f for f in FAIL):
ok("the global bucket is identical across all four modes")
for k in ("players", "playersGold", "playersSilver", "playersBronze",
"rarePlayers", "staff"):
if k not in ref:
bad("live-proven row %r is no longer being sent" % k)
# ---- 5 the counts are reachable at all -------------------------------
total = ref.get("consumables")
if total is None:
ok("(consumables total not sent yet -- pre-change state)")
else:
leaves = sum(ref.get(fcs.VOCAB[s], 0)
for s in CONSUMABLE_ATOMS if s != 0x3C)
if total != leaves:
bad("consumables total %d != sum of the 13 leaves %d" % (total, leaves))
else:
ok("consumables total %d == sum of the leaves" % total)
try:
import fut_consumables as fc
shelf = len(fc.starter_consumables(fc.CONSUMABLE_ID_BASE))
except Exception: # noqa: BLE001
shelf = None
if shelf and total == 0:
bad("all 14 rows are ZERO while fut_consumables would serve %d items. "
"The shelf is a GET-time OVERLAY and is NOT in the club STORE, so "
"counting STORE.items() alone yields fourteen zeros -- see "
"_staff_overlay_counts() for the pattern the staff rows already use."
% shelf)
elif shelf and total != shelf:
bad("consumables total %d != shelf size %d" % (total, shelf))
elif shelf:
ok("consumables total matches the %d-item shelf" % shelf)
print("\n".join(" ok " + m for m in PASS))
if FAIL:
print("\n".join(" FAIL " + m for m in FAIL))
print("\n%d ok, %d failed" % (len(PASS), len(FAIL)))
return 1 if FAIL else 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,150 @@
#!/usr/bin/env python3
"""Offline check: every /settings flag we ship is one the client actually switches on.
A flag name is not validated by anything at runtime. The client hashes the string
we send and switches on the result, so a typo, a renamed field or a flag that
simply has no arm in the switch is INERT and looks exactly like "the fix did not
work". This asserts each shipped name against two independent sources:
1. docs/fut_atoms.tsv -- the recovered atom table (the name must hash to an id)
2. the switch arms recovered from 0x18013c6d0 (the id must have an arm)
Source 2 is the one that matters: enableSquadBuildingSetsFeature is a perfectly
real atom with NO arm, so source 1 alone would have passed it.
Run before shipping any settings change. No server needed.
"""
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
# The 42 atoms with an arm in FUN_18013c6d0, recovered 2026-08-05 by
# tools/ghidra_queries/q_settings_flags.py + q_settings_types.py (full decompile,
# both halves of the switch, coverage asserted by char count).
SWITCH_ARMS = {
0x18: "allowGracePeriodForSquadBuildingSets",
0x19: "allowUntradeableForSquadBuildingSets",
0x6D: "cardPackStoreEnabled", 0x6E: "cardPackStoreEnabled_JP",
0x80: "checkServerDbVersion", 0x86: "clientKeepAliveResetTimeoutSec",
0x8C: "clubCreateThreshold", 0x98: "coinEnabled", 0x99: "coinEnabled_JP",
0xA3: "constrainGracePeriod", 0xBB: "couchPlayEnabled",
0xF9: "enableDraftMode", 0xFA: "enableOfflineDraftMode",
0xFB: "enableLiveMessaging", 0xFC: "enableLoyaltyBonusForConceptPlayers",
0xFD: "enableObjectives", 0xFE: "enableObjectivesAsManagerTasks",
0xFF: "enableSinglePlayerDraftMode", 0x118: "extendGameSessionTimerSec",
0x11F: "fifaPointsEnabled", 0x120: "fifaPointsEnabled_JP",
0x133: "friendlySeasonsEnabled", 0x13D: "getOperationTimeoutSec",
0x16C: "itemDbVersion", 0x1C0: "maximumTradePileSize",
0x1CD: "mtxEnabled", 0x1CE: "mtxEnabled_JP",
0x1DE: "numEndMatchRetriesAllowed", 0x20E: "packOpeningAnimationEnabled",
0x242: "pointsPackStoreEnabled", 0x257: "processingStateEnabled",
0x28A: "returningUserRewardsScreenEnabled",
0x2D0: "squadBuildingSetsGracePeriodMinutes",
0x2F1: "storeEnabled", 0x2F2: "storeEnabled_JP",
0x2F3: "storyModeRewardEnabled",
0x2F5: "championsScheduleViewPeriodInMinutes",
0x30F: "enableFloatPointSquadRating",
0x310: "enableLegacyYearInfoInItemResourceId",
0x320: "tokenRedemptionEnabled", 0x32D: "tournamentQuitEnabled",
0x336: "tradingEnabled",
}
# Arms that do NOT simply store a value. Shipping these has side effects.
SPECIAL = {
"enableObjectives": "shared arm can only CLEAR the field; 1 is a no-op, 0 disables",
"enableObjectivesAsManagerTasks": "same shared arm as enableObjectives",
"clientKeepAliveResetTimeoutSec": "reprograms a client timer with value*1000",
"getOperationTimeoutSec": "reprograms a client timer with value*1000",
"checkServerDbVersion": "makes the client go read a server_db_version config",
}
def main():
fails = []
atoms = {}
with open(os.path.join(HERE, "..", "docs", "fut_atoms.tsv")) as fh:
for line in fh:
p = line.rstrip("\n").split("\t")
if len(p) >= 3:
try:
atoms[p[2]] = int(p[1], 16)
except ValueError:
pass
# Cross-check the recovered table against the atom table both ways.
by_name = {v: k for k, v in SWITCH_ARMS.items()}
for name, aid in by_name.items():
if name not in atoms:
fails.append("switch arm %s (%#x) is not in fut_atoms.tsv" % (name, aid))
elif atoms[name] != aid:
fails.append("%s: switch says %#x, atom table says %#x"
% (name, aid, atoms[name]))
import utas_server as u
body = u.SETTINGS
if not isinstance(body, dict) or list(body) != ["configs"]:
fails.append("body must be exactly {'configs': [...]}, got %r" % (body,))
return report(fails)
rows = body["configs"]
if not isinstance(rows, list):
fails.append("configs must be a LIST (a scalar here desyncs the parser)")
return report(fails)
seen = set()
for r in rows:
if not isinstance(r, dict) or set(r) != {"type", "value"}:
fails.append("row must be exactly {type, value}: %r" % (r,))
continue
t, v = r["type"], r["value"]
# value: any scalar is safe (getter 0x1801c79d0 coerces int/float/bool/str),
# but the applier tests `== 1`, so a bool True would work and a string "1"
# would work -- ints keep it unambiguous. An object or array FREEZES.
if isinstance(v, (dict, list)):
fails.append("%s: value is %s -- an object/array here FREEZES the client"
% (t, type(v).__name__))
if not isinstance(t, str):
fails.append("type must be a string, got %r" % (t,))
continue
if t in seen:
fails.append("%s sent twice; last one wins, so this is at best confusing" % t)
seen.add(t)
if t not in by_name:
hint = " (it IS an atom, but has no arm in the switch)" if t in atoms else ""
fails.append("%s has no arm in 0x18013c6d0 -- INERT%s" % (t, hint))
elif t in SPECIAL:
print(" NOTE %-34s %s" % (t, SPECIAL[t]))
gates = {"friendlySeasonsEnabled", "enableDraftMode", "tournamentQuitEnabled"}
# Read the mode off the server module, never re-declare the default here: a
# checker with its own copy of a default tests the copy, not the server.
mode = u._SETTINGS_MODE
if mode == "gates":
for g in sorted(gates - seen):
fails.append("mode 'gates' but %s is missing" % g)
for t in sorted(seen & gates):
row = next(r for r in rows if r["type"] == t)
if row["value"] != 1:
fails.append("%s = %r; the applier tests `== 1`, nothing else opens "
"the gate" % (t, row["value"]))
print(" mode=%s, %d rows, %d distinct flags, all with a live switch arm"
% (mode, len(rows), len(seen)))
return report(fails)
def report(fails):
if fails:
print("\nFAIL (%d)" % len(fails))
for f in fails:
print(" - %s" % f)
return 1
print("PASS")
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,294 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Read back what the client resolved for STAFF cards (head coach, GK coach,
physio, fitness coach) and grade every one HIT / MISS / WRONG-BRANCH / NO-MERGE.
READ-ONLY. Uses card_identity_probe.nodes() and the same /proc/PID/mem reader;
there is no write path in this file.
WHY A SEPARATE FILE FROM card_identity_probe
--------------------------------------------
card_identity_probe is a PLAYER tool and is actively wrong for staff:
* classify() returns "NO-MERGE" for anything with cardtype != 1, so all four
coach families come back NO-MERGE and the tool reports nothing.
* F_NAME_KNOWN = 0xdd is the player knownAs string. For physio and fitness
coach the merge writes RAW STAT BYTES into 0xdd..0xe3, and for managers
talkrating/negotiation land at 0xe2/0xe3. So `known` is garbage for every
non-player family and must never be read as a name there.
* F_ATTRS (0x98..0xac) is only meaningful for head coach and GK coach, and
even there the merge writes exactly ONE element.
THE MECHANISM (FUN_180141660, cardsdll.dll, 2129 bytes / 214 decompiled lines,
read end to end)
------------------------------------------------------------------------------
The merge switches on record+0x4c, which FUN_1800d8330 derives from the JSON
atom 0x6c cardsubtypeid alone:
cardsubtypeid -> +0x4c -> table key column
5 3 headcoachcards carddbid == *(u32*)(rec+0x18)
8 4 fitnesscoachcards carddbid == *(u32*)(rec+0x18)
7 5 physiocards carddbid == *(u32*)(rec+0x18)
6 10 gkcoachcards carddbid == *(u32*)(rec+0x18)
4 2 managercards carddbid == *(u32*)(rec+0x18)
0..3 1 players playerid == rec+0x18 & 0xffffff
record+0x18 is atom 0x287 resourceId. THE COACH BRANCHES DO NOT MASK IT: unlike
FUN_180135890 (players), which does `& 0xffffff` twice, the four staff branches
and the manager branch pass the raw u32 straight into the `==` predicate. So a
version nibble in the high byte of resourceId breaks every staff lookup silently.
MISS FINGERPRINTS -- these are NOT uniform, contrary to earlier notes
--------------------------------------------------------------------
All four write firstname/lastname "DB Error", rating(+0xb4) 0x32 and rare(+0x58)
1, plus a TABLE-UNIQUE fallback assetid at +0x20. Only head coach and GK coach
write 0xf into the attribute array; physio writes 0xf into a BYTE at +0xdd, and
fitness coach writes no 0xf at all -- it writes fieldpos 1 / posbonus 7 /
amount 1 into +0xdd/+0xde/+0xdf.
The fallback assetid is what makes a negative interpretable: it names the branch
that ran. A head-coach id that comes back with assetId 3000259 means the FITNESS
branch ran, which is a different bug from "the id is wrong".
Two facts checked against the on-disk dumps in data/tables/ and used as oracles:
* no row in any of the four tables has value == 50, so rating 0x32 is an
unambiguous MISS for all four families;
* no fitnesscoachcards row has (fieldpos, posbonus, amount) == (1, 7, 1), so
that byte triple is an unambiguous MISS for fitness coach on its own.
Usage:
python3 coach_probe.py # graded table + tally
python3 coach_probe.py --json out.json
python3 coach_probe.py --raw # + hexdump of the first staff record
"""
import argparse
import json
import os
import struct
import sys
import card_identity_probe as P
import watch_club_model as W
TABLES = os.path.join(os.path.dirname(os.path.abspath(__file__)),
"..", "data", "tables")
# cardsubtypeid -> (family, table file, +0x4c value, fallback assetid)
FAMILY = {
5: ("headcoach", "headcoachcards.json", 3, 0x1E8514), # 2000148
8: ("fitnesscoach", "fitnesscoachcards.json", 4, 0x2DC7C3), # 3000259
7: ("physio", "physiocards.json", 5, 0x3D0992), # 4000146
6: ("gkcoach", "gkcoachcards.json", 10, 0x895542), # 9000258
4: ("manager", "managercards.json", 2, None), # NO miss-fill
}
F_ATTRS_I32 = (0x98, 0x9C, 0xA0, 0xA4, 0xA8, 0xAC) # head/GK coach boost slot
F_STAT_BYTES = 0xDD # 0xdd..0xe3, 7 bytes
MISS_RATING = 0x32
MISS_NAME = "DB Error"
def load_tables():
"""{cardsubtypeid: {carddbid: row}} from the read-only on-disk dumps."""
out = {}
for sub, (_, fn, _, _) in FAMILY.items():
path = os.path.join(TABLES, fn)
try:
with open(path) as f:
rows = json.load(f)["rows"]
except (IOError, OSError, ValueError):
continue
out[sub] = {r["carddbid"]: r for r in rows}
return out
def read_staff(mem, node):
buf = mem.read(node + P.REC, P.REC_LEN)
if buf is None or len(buf) < P.REC_LEN:
return None
return {
"node": node,
"id": P.u32(buf, P.F_ID),
"resourceId": P.u32(buf, P.F_RESOURCE),
"assetId": P.u32(buf, P.F_ASSET),
"cardtype": P.u32(buf, P.F_CARDTYPE),
"subtype": P.u32(buf, P.F_SUBTYPE),
"rating": P.u8(buf, P.F_RATING),
"rare": P.u32(buf, 0x58),
"tier": P.u32(buf, 0x54),
"first": P.cstr(buf, P.F_NAME_FIRST, 0x10),
"last": P.cstr(buf, P.F_NAME_LAST, 0x15),
# what OUR json set and the coach branches never touch:
"teamid": P.u32(buf, P.F_TEAM),
"position": P.u8(buf, P.F_POSITION),
"nation": P.u16(buf, P.F_NATION),
"league": P.u32(buf, P.F_LEAGUE),
# the two stat regions, read as raw numbers, never as a string:
"attrs": [P.u32(buf, o) for o in F_ATTRS_I32],
"stat_bytes": list(buf[F_STAT_BYTES:F_STAT_BYTES + 7]),
"_raw": buf,
}
def expected_tier(rating):
"""The tail of FUN_180141660, which runs for EVERY family including miss."""
if rating is None:
return None
return 3 if rating >= 0x4B else (2 if rating >= 0x41 else 1)
def grade(c, tables):
"""HIT / MISS / WRONG-BRANCH / NO-MERGE / UNEXPECTED, plus a reason."""
sub = c["subtype"]
if sub not in FAMILY:
return "NOT-STAFF", "cardsubtypeid %s is not a staff family" % sub
name, _, want_ct, fallback = FAMILY[sub]
c["family"] = name
if c["cardtype"] != want_ct:
return "NO-MERGE", ("cardtype %s, expected %d -- FUN_1800d8330 did not "
"map this subtype, so no query ran"
% (c["cardtype"], want_ct))
# A miss anywhere names its own branch through the fallback assetid.
for osub, (oname, _, _, ofb) in FAMILY.items():
if ofb is not None and c["assetId"] == ofb and c["rating"] == MISS_RATING:
if osub == sub:
row = tables.get(sub, {}).get(c["resourceId"])
if row is not None:
return "UNEXPECTED", ("MISS, but carddbid %d IS in %s -- the "
"key or the field is wrong, not the id"
% (c["resourceId"], name))
return "MISS", "id %d absent from %s (as designed)" % (
c["resourceId"], name)
return "WRONG-BRANCH", ("fallback assetid %d belongs to %s, but we "
"sent cardsubtypeid %d (%s)"
% (c["assetId"], oname, sub, name))
if c["rating"] == MISS_RATING or MISS_NAME in (c["first"], c["last"]):
return "UNEXPECTED", ("miss fingerprint without a known fallback assetid "
"(assetId=%s)" % c["assetId"])
row = tables.get(sub, {}).get(c["resourceId"])
if row is None:
if sub == 4:
return "SILENT", ("managercards writes NO miss-fill; a wrong id is "
"indistinguishable from a wrong mechanism")
return "UNEXPECTED", ("no miss fingerprint, but id %d is absent from %s"
% (c["resourceId"], name))
# HIT: every column we can see must agree with the on-disk row.
bad = []
if c["rating"] != row["value"]:
bad.append("rating %s != value %s" % (c["rating"], row["value"]))
if c["assetId"] != row["assetid"]:
bad.append("assetId %s != assetid %s" % (c["assetId"], row["assetid"]))
if c["rare"] != (1 if row["rare"] == 1 else 0):
bad.append("rare %s != %s" % (c["rare"], row["rare"]))
if c["tier"] != expected_tier(c["rating"]):
bad.append("tier %s != %s" % (c["tier"], expected_tier(c["rating"])))
if sub in (5, 6): # head coach / GK coach
got = c["attrs"][row["attribute"]]
if got != row["amount"]:
bad.append("attrs[%d] %s != amount %s"
% (row["attribute"], got, row["amount"]))
elif sub == 7: # physio, via FUN_180136270
got = c["stat_bytes"][row["attribute"]]
if got != row["amount"]:
bad.append("+%#x %s != amount %s"
% (0xDD + row["attribute"], got, row["amount"]))
elif sub == 8: # fitness coach: three columns at once
for i, col in enumerate(("fieldpos", "posbonus", "amount")):
if c["stat_bytes"][i] != row[col]:
bad.append("+%#x %s != %s %s"
% (0xDD + i, c["stat_bytes"][i], col, row[col]))
if bad:
return "HIT-MISMATCH", "; ".join(bad)
return "HIT", "every visible column agrees with %s row %d" % (
name, c["resourceId"])
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--raw", action="store_true")
ap.add_argument("--json", metavar="PATH")
ap.add_argument("--all", action="store_true",
help="also list player/unknown cards instead of skipping them")
a = ap.parse_args()
tables = load_tables()
if not tables:
print("no table dumps under %s -- run tools/db_dump.py first" % TABLES)
return 1
pid = W.find_pid()
if pid is None:
print("FIFA17.exe is not running.")
return 1
base = W.dll_base(pid)
if base is None:
print("pid %d is up but %s is not mapped yet." % (pid, W.DLL))
return 1
mem = W.Mem(pid)
obj = mem.q(base + (W.G_CARDSDB - W.IMG_BASE))
if not obj:
print("CardsDb singleton is NULL (no FUT session loaded).")
return 1
ns = P.nodes(mem, obj)
print("pid=%d CardsDb=%#x size(+0x160e8)=%s walked=%d"
% (pid, obj, mem.i32(obj + W.TREE_SIZE), len(ns)))
cards = []
for n in ns:
c = read_staff(mem, n)
if not c:
continue
c["verdict"], c["why"] = grade(c, tables)
cards.append(c)
if not a.all:
cards = [c for c in cards if c["verdict"] != "NOT-STAFF"]
cards.sort(key=lambda c: (c["subtype"] or 0, c["resourceId"] or 0))
print()
print("%-13s %-9s %-8s %-4s %-4s %-4s %-22s %-13s %s"
% ("family", "resource", "assetId", "rat", "tie", "rar", "name",
"verdict", "why"))
for c in cards:
nm = ("%s %s" % (c["first"], c["last"])).strip()[:22]
print("%-13s %-9s %-8s %-4s %-4s %-4s %-22s %-13s %s"
% (c.get("family", "?"), c["resourceId"], c["assetId"],
c["rating"], c["tier"], c["rare"], nm, c["verdict"], c["why"]))
tally = {}
for c in cards:
tally[c["verdict"]] = tally.get(c["verdict"], 0) + 1
print("\n" + " ".join("%s=%d" % kv for kv in sorted(tally.items())))
print("\nfields the coach branches NEVER write, i.e. OURS on screen if they "
"render at all:")
for c in cards[:8]:
print(" %-9s teamid=%-6s leagueId=%-6s nation=%-5s position=%s"
% (c["resourceId"], c["teamid"], c["league"], c["nation"],
c["position"]))
if a.raw and cards:
b = cards[0]["_raw"]
print("\nrecord %#x:" % (cards[0]["node"] + P.REC))
for off in range(0, P.REC_LEN, 16):
row = b[off:off + 16]
print(" +%03x %-47s %s" % (
off, " ".join("%02x" % x for x in row),
"".join(chr(x) if 32 <= x < 127 else "." for x in row)))
if a.json:
for c in cards:
c.pop("_raw", None)
with open(a.json, "w") as f:
json.dump(cards, f, indent=1)
print("\nwrote %s" % a.json)
print("\nfailed reads=%d" % mem.fails)
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,257 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Build the MIXED-CONTROL staff window: known-good coach ids interleaved with
known-bad ones, so a correct result and an incorrect one look different in the
same screenshot and in the same probe readback.
THIS FILE FIRES NOTHING. It prints/writes JSON. The integrate agent wires it in;
nobody else touches utas_server.py, fut_cards.py or fut_store.py.
WHY THIS SHAPE
--------------
The four coach branches of FUN_180141660 write a LOUD miss-fill -- firstname and
lastname "DB Error", rating 0x32, rare 1, and a TABLE-UNIQUE fallback assetid.
That gives three separable outcomes instead of two:
HIT real name, rating == the row's `value`, assetId == carddbid
MISS "DB Error", rating 50, assetId == this family's fallback
WRONG-BRANCH "DB Error", rating 50, assetId == ANOTHER family's fallback
NO-MERGE our sentinel rating survives, name empty -> the query never ran
so every failure mode says which one it is. That is what makes the negative
interpretable, and it is the reason coaches are the cheapest family to prove.
Two properties were checked against the on-disk dumps and both hold:
* no row in any of the four tables has value == 50, so rating 50 can only be a
miss -- it can never be a hit that happens to look like one;
* no fitnesscoachcards row has (fieldpos, posbonus, amount) == (1, 7, 1), the
fitness miss-fill triple at +0xdd/+0xde/+0xdf, so fitness has a second,
fully independent oracle that does not depend on reading a name at all.
The known-bad ids are ids INSIDE each family's own carddbid band that are absent
from the table -- 195..207 such gaps exist per family, so a bad control is never
an out-of-range value the client might reject for an unrelated reason.
THE SENTINEL. Every item is sent with rating SENTINEL (=1), which no coach row
carries and which the miss-fill never writes. If a card comes back still holding
rating 1, the merge did not run at all; that is the NO-MERGE arm and it is a
different bug from either a hit or a miss.
DO NOT ROUTE THIS THROUGH FUT_ID_SWEEP. sweep_items() keeps itemType "player"
and varies only cardsubtypeid, and club_route answers a sweep BEFORE the ?type=
filter, so a sweep-borne staff experiment is confounded twice over.
WHICH SCREEN FIRES IT. The client's own ?type= taxonomy (FUN_18012ec50, 29 arms
+ default) does contain headcoach / gkcoach / physio / fitnesscoach / staff --
atoms 0x153 / 0x13f / 0x21d / 0x129 / 0x2dc. NONE of those five has ever been
seen on the wire. A grep of every capture and log in this repo finds exactly
three values: type=player (x14), type=manager (x2), type=custom (x1). So the
screen to aim this at is the STAFF tab, which sends type=manager, and the reason
these items reach it is that club_route already filters on cardsubtypeid -- not
on itemType and not on the type string -- keeping everything outside 0..3.
Usage:
python3 coach_window.py # human-readable prediction table
python3 coach_window.py --json items.json
python3 coach_window.py --family headcoach --json one.json
"""
import argparse
import json
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
SEEDS = os.path.join(HERE, "..", "data", "coach_seeds.json")
# Item ids for the experiment. Far above fut_store's ITEM_ID_BASE range so a
# stray record in the CardsDb map can always be attributed.
ID_BASE = 950000000
SENTINEL_RATING = 1
# A DISTINCT, unmistakable nation + league per family. This is the SECOND
# question the same window answers, and it is free: the four coach branches of
# FUN_180141660 never write nation (+0x148), leagueId (+0x154), teamid (+0x94) or
# position (+0x146), and none of the four tables even HAS a nation/league/team
# column, so these values cannot change the hit/miss outcome. Whatever a coach
# card shows for country or league therefore came from US.
#
# Both the good and the bad ids of a family carry the same pair, so a "DB Error"
# card flying a Brazilian flag is itself direct proof that the miss-fill leaves
# our fields alone.
#
# nation 14 (England) is deliberately AVOIDED: 14 is what the PLAYER miss-fill
# writes, and a value that doubles as a known failure fingerprint is not a probe.
FACE = { # family -> (nationid, leagueid)
"headcoach": (54, 13), # Brazil, Premier League
"gkcoach": (45, 19), # Spain, Bundesliga
"physio": (27, 16), # Italy, Ligue 1
"fitnesscoach": (21, 53), # Germany, LaLiga Santander
}
def _item(item_id, carddbid, cardsubtypeid, nation=0, league=0):
"""One staff item.
EXACTLY the field set fut_store._item() already builds and that this client
is live-proven to parse. The only changes are cardsubtypeid and the ids. No
new atom is introduced: the wire shape of a real staff item has NEVER been
observed, and inventing one -- a scalar where the parser wants an object --
is the change class that busy-loops the client at 0x1801c7f1a.
resourceId carries NO version nibble. The staff branches compare
`carddbid == *(u32*)(record+0x18)` on the RAW dword; players are the only
family that masks with & 0xffffff. A high byte here breaks every lookup and
does it silently.
itemType stays "player" for the first run. The merge dispatches on
cardsubtypeid alone, and club_route's ?type= filter already keys on
cardsubtypeid (anything not in 0..3 survives a non-player type), so nothing
needs itemType to be changed in order for these to reach the STAFF tab.
Flipping it to the taxonomy name ("headcoach"/"gkcoach"/"physio"/
"fitnesscoach", atoms 0x153/0x13f/0x21d/0x129) is a separate, later,
one-variable experiment.
"""
return {
"id": item_id,
"resourceId": carddbid, # == carddbid, raw, no version byte
"assetId": carddbid,
"cardassetid": carddbid,
"definitionId": carddbid,
"cardsubtypeid": cardsubtypeid,
"itemType": "player",
"rareflag": 1, # overwritten by the merge either way
"rating": SENTINEL_RATING,
"preferredPosition": "ST",
"nation": nation,
"teamid": 0,
"leagueId": league,
"playStyle": 250,
# zeros so that, for head coach and GK coach, the ONE slot the merge
# writes (attrs[row.attribute] = row.amount) stands out against five
# untouched zeros -- that single write verifies two columns at once.
"attributeList": [{"index": i, "value": 0} for i in range(6)],
"itemState": "free",
"owners": 1,
"untradeable": True,
"contract": 7,
"fitness": 99,
}
def build(families=None):
with open(SEEDS) as f:
spec = json.load(f)
fams = spec["families"]
order = [f for f in ("headcoach", "gkcoach", "physio", "fitnesscoach")
if families is None or f in families]
items, predict, n = [], [], 0
for fam in order:
d = fams[fam]
sub, fb = d["cardsubtypeid"], d["miss_fill_assetid"]
nat, lg = FACE[fam]
good = [r["carddbid"] for r in d["seeds"]]
bad = d["bad_controls"]
rows = {r["carddbid"]: r for r in d["seeds"]}
# INTERLEAVE. The client pages the club (start=N&count=11 observed), so
# good and bad must alternate or a page can come back all-good/all-bad
# and prove nothing on its own screenshot.
mixed, gi, bi = [], 0, 0
while gi < len(good) or bi < len(bad):
for _ in range(3):
if gi < len(good):
mixed.append((good[gi], True)); gi += 1
if bi < len(bad):
mixed.append((bad[bi], False)); bi += 1
for cid, is_good in mixed:
iid = ID_BASE + n; n += 1
items.append(_item(iid, cid, sub, nat, lg))
if is_good:
r = rows[cid]
v = r["value"]
p = {"id": iid, "family": fam, "carddbid": cid, "expect": "HIT",
"rating": v, "tier": 3 if v >= 75 else (2 if v >= 65 else 1),
"rare": r["rare"], "assetId": cid, "name": "a real person",
"face": "nation %d / leagueId %d must SURVIVE" % (nat, lg)}
if fam in ("headcoach", "gkcoach"):
p["attrs"] = "index %d == %d, other five == 0" % (
r["attribute"], r["amount"])
elif fam == "physio":
p["stat_byte"] = "+%#x == %d" % (0xDD + r["attribute"],
r["amount"])
else:
p["stat_bytes"] = "+0xdd/+0xde/+0xdf == %d/%d/%d" % (
r["fieldpos"], r["posbonus"], r["amount"])
else:
p = {"id": iid, "family": fam, "carddbid": cid, "expect": "MISS",
"rating": 50, "tier": 1, "rare": 1, "assetId": fb,
"name": "DB Error DB Error",
"face": "nation %d / leagueId %d must SURVIVE" % (nat, lg)}
if fam in ("headcoach", "gkcoach"):
p["attrs"] = "index 0 == 15, other five == 0"
elif fam == "physio":
p["stat_byte"] = "+0xdd == 15"
else:
p["stat_bytes"] = "+0xdd/+0xde/+0xdf == 1/7/1"
predict.append(p)
# ONE deliberate cross-family item per family: this family's BEST-KNOWN
# good id sent under the NEXT family's cardsubtypeid. It must miss, and
# its fallback assetid must name the OTHER table. That is the only item
# in the window that can distinguish "the subtype picks the table" from
# "the id band picks the table", and it is interpretable in both
# directions: a real name here would refute the dispatch outright.
other = order[(order.index(fam) + 1) % len(order)]
if other != fam:
osub = fams[other]["cardsubtypeid"]
ofb = fams[other]["miss_fill_assetid"]
iid = ID_BASE + n; n += 1
# the cross-family item keeps the SOURCE family's face pair, so if
# it ever renders it is visibly the head-coach flag on a gkcoach slot
items.append(_item(iid, good[0], osub, nat, lg))
predict.append({"id": iid, "family": "%s-id/%s-subtype" % (fam, other),
"carddbid": good[0], "expect": "MISS (cross-family)",
"rating": 50, "tier": 1, "rare": 1, "assetId": ofb,
"name": "DB Error DB Error",
"refutes": "a real name here means cardsubtypeid does "
"NOT select the table"})
return items, predict
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--json", metavar="PATH", help="write the item array")
ap.add_argument("--family", action="append",
choices=["headcoach", "gkcoach", "physio", "fitnesscoach"])
a = ap.parse_args()
items, predict = build(a.family)
nfam = len({p["family"] for p in predict if "/" not in p["family"]})
print("%d items across %d familie(s), including %d deliberate cross-family "
"controls. Every item carries rating=%d as the NO-MERGE sentinel.\n"
% (len(items), nfam, sum(1 for p in predict if "/" in p["family"]),
SENTINEL_RATING))
print("%-11s %-24s %-9s %-20s %-5s %-4s %-8s %s"
% ("id", "family", "carddbid", "expect", "rat", "tier", "assetId", "extra"))
for p in predict:
extra = p.get("attrs") or p.get("stat_byte") or p.get("stat_bytes") or ""
print("%-11d %-24s %-9d %-20s %-5d %-4d %-8d %s"
% (p["id"], p["family"], p["carddbid"], p["expect"], p["rating"],
p["tier"], p["assetId"], extra))
n_hit = sum(1 for p in predict if p["expect"] == "HIT")
print("\npredicted: HIT=%d MISS=%d (a run where all %d agree is the proof; "
"any single disagreement names its own failure mode)"
% (n_hit, len(predict) - n_hit, len(predict)))
if a.json:
with open(a.json, "w") as f:
json.dump({"itemData": items, "predictions": predict}, f, indent=1)
print("\nwrote %s" % a.json)
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,160 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""READ-ONLY: enumerate every resident FIFA 17 DB table and dump its rows.
See dbwalk.py header for the on-heap format. This version locates a table's
layout block by its header signature (ncols<<16 | 0xffff) and matches on the
column tag set. Opens /proc/PID/mem 'rb'; only seek()/read().
"""
import glob, struct, sys, json
CONST = 0x07C20760
def find_pid():
for d in glob.glob("/proc/[0-9]*"):
try:
if open(d + "/comm").read().strip() == "FIFA17.exe":
return int(d.rsplit("/", 1)[-1])
except Exception:
pass
pid = find_pid()
f = open("/proc/%d/mem" % pid, "rb")
def rd(va, n):
try:
f.seek(va); b = f.read(n)
return b if b and len(b) == n else None
except Exception:
return None
def q(va):
b = rd(va, 8)
return struct.unpack("<Q", b)[0] if b else None
def cstr(va, m=64):
b = rd(va, m)
if not b:
return None
z = b.find(b"\x00")
if z <= 0:
return None
try:
return b[:z].decode("ascii")
except UnicodeDecodeError:
return None
def regions(lo_lim=0, hi_lim=1 << 62):
out = []
for line in open("/proc/%d/maps" % pid):
p = line.split()
a, b = p[0].split("-")
if "r" not in p[1] or "w" not in p[1]:
continue
lo, hi = int(a, 16), int(b, 16)
if hi - lo > 512 << 20 or hi < lo_lim or lo > hi_lim:
continue
out.append((max(lo, lo_lim), min(hi, hi_lim)))
return out
# ------- load the DB heap once -------
CHUNKS = []
for lo, hi in regions(0x06000000, 0x48000000):
off = lo
while off < hi:
n = min(16 << 20, hi - off)
d = rd(off, n)
if d:
CHUNKS.append((off, d))
off += n
print("loaded %d chunks, %.0f MB" % (len(CHUNKS), sum(len(c[1]) for c in CHUNKS) / 1e6))
def scan(pat):
out = []
for base, d in CHUNKS:
i = d.find(pat)
while i != -1:
out.append(base + i)
i = d.find(pat, i + 1)
return out
# ------- catalog -------
tables = {}
for c in scan(struct.pack("<Q", CONST)):
if c % 8:
continue
nm = cstr(q(c + 8) or 0)
if not nm:
continue
h = rd(c - 0x10, 0x10)
if not h:
continue
tag, cnt, colarr = struct.unpack("<IIQ", h)
if not (1 <= cnt <= 200) or colarr < 0x1000 or q(colarr + 0x20) != CONST:
continue
cols = []
for i in range(cnt):
d = rd(colarr + i * 0x30, 0x30)
if not d:
break
ty, ctag, mn, mx, ln = struct.unpack_from("<IIiII", d, 0)
cols.append(dict(name=cstr(struct.unpack_from("<Q", d, 0x28)[0]),
type=ty, tag=ctag, min=mn, max=mx, len=ln))
tables.setdefault(nm, []).append(dict(desc=c - 0x10, ncols=cnt, cols=cols))
print("tables: %d" % len(tables))
# ------- layout blocks by header signature -------
ncounts = sorted({t["ncols"] for v in tables.values() for t in v})
blocks = []
for n in ncounts:
sig = struct.pack("<I", (n << 16) | 0xFFFF)
for a in scan(sig):
if a % 4:
continue
hdr = a - 8 # hdr = {cap, rowcount, sig, ?}
ents = []
ok = True
for k in range(n):
e = rd(hdr + 0x10 + k * 16, 16)
if not e:
ok = False; break
off, tag, w, ty = struct.unpack("<4I", e)
tb = struct.pack("<I", tag)
if not (all(0x30 <= x < 0x7B for x in tb) and 0 < w <= 512 and off < 16384):
ok = False; break
ents.append((off, tag, w, ty))
if ok:
blocks.append((hdr, struct.unpack("<I", rd(hdr + 4, 4))[0], ents))
print("layout blocks: %d" % len(blocks))
byset = {}
for hdr, rc, ents in blocks:
byset.setdefault(frozenset(e[1] for e in ents), []).append((hdr, rc, ents))
def align4(x):
return (x + 3) & ~3
out = {}
for name in sorted(tables):
for t in tables[name]:
tset = frozenset(c["tag"] for c in t["cols"])
for hdr, rc, ents in byset.get(tset, []):
lay = {e[1]: (e[0], e[2], e[3]) for e in ents}
maxbit = max(o + w for o, w, ty in
[(lay[c["tag"]][0], 32 if c["type"] == 1 else lay[c["tag"]][1],
0) for c in t["cols"]])
stride = align4((maxbit + 7) // 8)
rowptr = q(hdr - 0x48)
out.setdefault(name, []).append(
dict(hdr=hdr, rows=rc, rowptr=rowptr, stride=stride,
cols=[(c["name"], c["type"], lay[c["tag"]][0],
lay[c["tag"]][1], c["min"], c["max"]) for c in t["cols"]]))
for name in sorted(out):
for b in out[name]:
print("\n== %-24s rows=%-7d stride=%-3d rowptr=%#x hdr=%#x"
% (name, b["rows"], b["stride"], b["rowptr"] or 0, b["hdr"]))
for cn, ty, o, w, mn, mx in sorted(b["cols"], key=lambda x: x[2]):
print(" bit %-5d w=%-4d %-26s type=%d [%d..%d]" % (o, w, cn, ty, mn, mx))
json.dump({k: [{kk: vv for kk, vv in b.items()} for b in v] for k, v in out.items()},
open("layouts.json", "w"), indent=1)
print("\nwrote layouts.json for %d tables" % len(out))
@@ -0,0 +1,569 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Dump FIFA 17's LOADED relational database (schema + every row) out of a live
FIFA17.exe, or out of a saved memory image.
READ-ONLY. /proc/PID/mem is opened 'rb' and only ever seek()/read(). There is
no write path in this file.
WHY THIS EXISTS
---------------
dbdata.dll on disk is packed (see dbdata_extract.py's docstring), so the game's
tables cannot be read off disk. They ARE fully resident in the running process,
in a self-describing form: the DB carries its own catalogue, its own column
names, and its own bit-level row layout. This tool walks that catalogue and
decodes the rows.
dbschema_probe.py got as far as the catalogue but had two defects, both fixed
here and both worth writing down because they are easy to re-introduce:
1. THE NAME IS AT anchor+0x08, NOT AT anchor-0x20. Every catalogue record --
table records and column records alike -- carries the constant qword
0x07c20760 (SHARED_ANCHOR). Reading the name from the start of the
32-byte-aligned slot picks up the PREVIOUS record's name, which silently
mislabels every table by one slot: what the old tool called "gkcoachcards"
is really `players`, what it called "teams" is really `teamplayerlinks`,
and so on. The mislabelling is invisible because both names are real.
The check that catches it: a table's column set must match its name
(`nations` must contain nationname, `players` must contain acceleration).
2. --list MIXED COLUMNS INTO THE TABLE LIST. Column records and table
records share the same anchor, so an anchor scan alone yields both. The
discriminator used here is the TABLE VTABLE: a record is a table if and
only if its descriptor's first qword is 0x07c31028. That takes 4126
anchor hits down to exactly 149 tables, with zero column names among them.
THE STRUCTURES (resolved live 2026-08-04, pid 52703, game in the FUT club UI)
------------------------------------------------------------------------------
CATALOGUE RECORD (table). Found by scanning for SHARED_ANCHOR; the record
starts 0x18 before it:
+0x00 void* descriptor
+0x08 u32 table name hash4 (repeated at descriptor+0x40)
+0x0c u32 column count
+0x10 void* column-definition array ("p2")
+0x18 u64 SHARED_ANCHOR 0x07c20760
+0x20 char* table name
(stride 0x30)
COLUMN-DEFINITION RECORD (0x30 bytes, p2[i]) -- the human-readable half:
+0x00 u32 kind: 1 = string, 2 = integer, 4 = date
+0x04 u32 name hash4
+0x08 u32 min value (as u32; may be negative, e.g. -1 for a position)
+0x0c u32 max value
+0x20 u64 SHARED_ANCHOR
+0x28 char* column name
TABLE DESCRIPTOR (at the record's descriptor pointer):
+0x00 u64 0x07c31028 (the table vtable -- the discriminator)
+0x30 void* ROW BLOCK
+0x40 u32 table name hash4 (self-identification)
+0x44 u32 row size in BYTES
+0x48 u32 max bit index (== rowsize*8 - 1)
+0x7c u32 ROW COUNT
+0x82 u16 column count
+0x88 ... column layout array, `ncol` records of 16 bytes:
u32 bit_offset, u32 name hash4, u32 bit_width, u32 flags
(sorted by hash4, so it needs sorting by bit_offset to read)
ROW BLOCK (pointed at by descriptor+0x30) is preceded by a 16-byte header:
-0x10 u32 byte size of the block
-0x08 u64 0x2c020e60 (a second constant, a useful check)
Rows are `rowsize` bytes, densely packed, no gaps.
FIELD DECODING
--------------
value = (int.from_bytes(row, 'little') >> bit_offset) & ((1 << width) - 1)
Integer columns then add `min`, so a column declared min=-1 max=32 stores 0..33
and reads back -1..32. This was confirmed on managercards.carddbid (bit 64,
width 24) reading 1000001 on row 0 -- the exact value docs/managercards_ids.txt
recorded from a completely independent live sweep.
STRINGS come in two forms and the tool decides per column:
* INLINE when bit_offset + width <= the next column's bit_offset: the field
is a fixed-size NUL-terminated char array inside the row. This is how
nations.nationname, leagues.leaguename and teams.teamname are stored, and
all three decode to real names.
* OFFSET otherwise: the field is a 32-bit offset into a string pool that is
NOT resident as a flat blob (searched for; not found). Those columns are
emitted as raw integers and flagged `"storage": "offset-unresolved"` in the
schema block. managercards.firstname/lastname and playernames.name are of
this kind. Player names are already available from data/roster.json via
dbdata_extract.py, so nothing depends on resolving them.
USAGE
-----
./db_dump.py --list # every table, row count, columns
./db_dump.py --schema players # one table's column layout
./db_dump.py --check # run the anchor checks, exit 1 on fail
./db_dump.py --dump players teams -o DIR # dump named tables as JSON
./db_dump.py --dump-all -o DIR # dump all 149
./db_dump.py --save-mem DIR # snapshot the process (do this FIRST;
# live memory is perishable)
./db_dump.py --mem DIR ... # work from a snapshot, no live game
Requires ptrace access to FIFA17.exe (ptrace_scope=1 + same uid is enough) when
reading live; --mem needs nothing but the snapshot directory.
"""
import argparse
import bisect
import json
import mmap
import os
import re
import struct
import sys
SHARED_ANCHOR = 0x07C20760 # on every catalogue record, at record+0x18/+0x20
TABLE_VTABLE = 0x07C31028 # descriptor[0] iff the record describes a table
BLOCK_MARK = 0x2C020E60 # rowblock[-0x08]
IDENT = re.compile(r'[A-Za-z][A-Za-z0-9_]*\Z')
# Anonymous mappings above this are the host libc arenas; the game DB is below.
MAX_VA = 0x200000000
KIND_STRING, KIND_INT, KIND_DATE = 1, 2, 4
# --------------------------------------------------------------------------- #
# memory access
# --------------------------------------------------------------------------- #
def find_pid():
for d in os.listdir('/proc'):
if not d.isdigit():
continue
try:
if open('/proc/%s/comm' % d).read().strip() == 'FIFA17.exe':
return int(d)
except OSError:
pass
raise SystemExit("FIFA17.exe is not running (use --mem DIR to work offline)")
def anon_regions(pid):
out = []
for line in open('/proc/%d/maps' % pid):
p = line.split()
lo, hi = (int(x, 16) for x in p[0].split('-'))
perms = p[1]
path = p[5] if len(p) > 5 else ''
if 'r' not in perms or path or lo >= MAX_VA:
continue
out.append((lo, hi, perms))
return out
class Image(object):
"""Uniform read-only view over either a live process or a saved snapshot."""
def __init__(self, pid=None, memdir=None):
self.regions = [] # list of dicts lo/hi
self._buf = {}
if memdir:
idx = json.load(open(os.path.join(memdir, 'index.json')))
idx.sort(key=lambda r: r['lo'])
self.regions = idx
self.memdir = memdir
self.live = None
else:
self.live = open('/proc/%d/mem' % pid, 'rb', 0)
self.memdir = None
for lo, hi, perms in anon_regions(pid):
self.regions.append({'lo': lo, 'hi': hi, 'perms': perms})
self._los = [r['lo'] for r in self.regions]
# -- region buffers ----------------------------------------------------- #
def buf(self, i):
if i not in self._buf:
r = self.regions[i]
if self.memdir:
f = open(os.path.join(self.memdir, r['file']), 'rb')
self._buf[i] = mmap.mmap(f.fileno(), 0, prot=mmap.PROT_READ)
else:
self.live.seek(r['lo'])
self._buf[i] = self.live.read(r['hi'] - r['lo'])
return self._buf[i]
def _find(self, a):
i = bisect.bisect_right(self._los, a) - 1
if i >= 0 and self.regions[i]['lo'] <= a < self.regions[i]['hi']:
return i
return -1
def read(self, a, n):
if a is None or a <= 0:
return b''
i = self._find(a)
if i < 0:
return b''
o = a - self.regions[i]['lo']
return bytes(self.buf(i)[o:o + n])
def u32(self, a):
b = self.read(a, 4)
return struct.unpack('<I', b)[0] if len(b) == 4 else None
def u64(self, a):
b = self.read(a, 8)
return struct.unpack('<Q', b)[0] if len(b) == 8 else None
def cstr(self, a, maxlen=96):
b = self.read(a, maxlen)
j = b.find(b'\x00')
if j <= 0:
return None
s = b[:j]
return s.decode('latin1') if re.fullmatch(rb'[ -~]+', s) else None
def save(self, outdir):
"""Snapshot every region to disk (do this first: live data is perishable)."""
os.makedirs(outdir, exist_ok=True)
idx = []
for i, r in enumerate(self.regions):
d = self.buf(i)
fn = '%012x.bin' % r['lo']
with open(os.path.join(outdir, fn), 'wb') as fh:
fh.write(d)
idx.append({'lo': r['lo'], 'hi': r['lo'] + len(d),
'perms': r.get('perms', 'rw-p'), 'file': fn})
json.dump(idx, open(os.path.join(outdir, 'index.json'), 'w'), indent=1)
return sum(r['hi'] - r['lo'] for r in idx)
# --------------------------------------------------------------------------- #
# catalogue walk
# --------------------------------------------------------------------------- #
def s32(v):
return v - (1 << 32) if v is not None and v >= (1 << 31) else v
def anchor_hits(img):
"""Addresses of every SHARED_ANCHOR qword, 8-byte aligned."""
pat = struct.pack('<Q', SHARED_ANCHOR)
out = []
for i, r in enumerate(img.regions):
b = img.buf(i)
st = 0
while True:
j = b.find(pat, st)
if j < 0:
break
st = j + 1
if j % 8 == 0:
out.append(r['lo'] + j)
return out
def catalogue(img):
"""name -> table dict. Tables only: the descriptor vtable is the filter."""
tables = {}
for anch in anchor_hits(img):
name = img.cstr(img.u64(anch + 8) or 0, 64)
if not name or not IDENT.match(name):
continue
desc = img.u64(anch - 0x18)
head = img.read(desc or 0, 0x50)
if len(head) < 0x50 or struct.unpack_from('<Q', head, 0)[0] != TABLE_VTABLE:
continue
h4, ncol = struct.unpack_from('<II', img.read(anch - 0x10, 8), 0)
dh4, rowsz, maxbit, _ = struct.unpack_from('<IIII', head, 0x40)
if dh4 != h4: # descriptor must self-identify
continue
tables[name] = {
'name': name, 'anchor': anch, 'desc': desc, 'hash4': h4,
'ncol': ncol, 'p2': img.u64(anch - 8),
'rowsize': rowsz, 'maxbit': maxbit,
'rowcount': img.u32(desc + 0x7c),
'rowblock': img.u64(desc + 0x30),
}
return tables
def columns(img, t):
"""Full column list, sorted by bit offset, with names/kinds/ranges."""
defs = {}
for i in range(t['ncol']):
b = img.read(t['p2'] + i * 0x30, 0x30)
if len(b) < 0x30:
break
kind, h4 = struct.unpack_from('<II', b, 0)
mn, mx = struct.unpack_from('<II', b, 8)
nm = img.cstr(struct.unpack_from('<Q', b, 0x28)[0], 64)
if nm:
defs[h4] = (nm, kind, s32(mn), s32(mx))
lay = img.read(t['desc'] + 0x88, t['ncol'] * 16)
cols = []
for i in range(t['ncol']):
if (i + 1) * 16 > len(lay):
break
bit, h4, width, flags = struct.unpack_from('<IIII', lay, i * 16)
nm, kind, mn, mx = defs.get(h4, ('?%08x' % h4, 0, 0, 0))
cols.append({'name': nm, 'bit': bit, 'width': width, 'kind': kind,
'min': mn, 'max': mx, 'flags': flags})
cols.sort(key=lambda c: c['bit'])
# inline vs offset strings, decided by whether the declared width fits
for i, c in enumerate(cols):
nxt = cols[i + 1]['bit'] if i + 1 < len(cols) else t['rowsize'] * 8
if c['kind'] == KIND_STRING:
if c['bit'] + c['width'] <= nxt:
c['storage'] = 'inline-string'
else:
c['storage'] = 'offset-unresolved'
c['width'] = 32
else:
c['storage'] = 'int'
return cols
def read_rows(img, t, cols, limit=None):
n = t['rowcount'] or 0
if limit is not None:
n = min(n, limit)
rsz = t['rowsize']
if n == 0 or rsz == 0 or not t['rowblock']:
return []
blob = img.read(t['rowblock'], n * rsz)
if len(blob) < n * rsz:
raise RuntimeError('%s: row block short: got %d of %d bytes'
% (t['name'], len(blob), n * rsz))
out = []
for i in range(n):
row = blob[i * rsz:(i + 1) * rsz]
raw = int.from_bytes(row, 'little')
rec = {}
for c in cols:
if c['storage'] == 'inline-string':
lo = c['bit'] // 8
s = row[lo:lo + c['width'] // 8]
j = s.find(b'\x00')
rec[c['name']] = (s if j < 0 else s[:j]).decode('latin1')
else:
v = (raw >> c['bit']) & ((1 << c['width']) - 1)
rec[c['name']] = v + c['min'] if c['kind'] == KIND_INT else v
out.append(rec)
return out
def block_size(img, t):
h = img.read((t['rowblock'] or 0) - 0x10, 16)
if len(h) < 16:
return None, None
return struct.unpack_from('<I', h, 0)[0], struct.unpack_from('<Q', h, 8)[0]
# --------------------------------------------------------------------------- #
# checks -- every dump must be justified against something independent
# --------------------------------------------------------------------------- #
CHECKS = []
def check(msg, cond):
CHECKS.append((msg, bool(cond)))
print(" [%s] %s" % ("PASS" if cond else "FAIL", msg))
return bool(cond)
def run_checks(img, tables):
print("sanity checks (independent ground truth):")
ok = True
# (1) the catalogue must not contain column names masquerading as tables
ok &= check("catalogue holds no known column name as a table "
"(acceleration/carddbid/assetid absent)",
not ({'acceleration', 'carddbid', 'assetid'} & set(tables)))
# (2) each table's columns must match its own name
for tn, must in (('nations', 'nationname'), ('leagues', 'leaguename'),
('teams', 'teamname'), ('players', 'acceleration'),
('managercards', 'carddbid'),
('teamplayerlinks', 'jerseynumber')):
t = tables.get(tn)
names = {c['name'] for c in columns(img, t)} if t else set()
ok &= check("table %-16s contains column %-14s" % (tn, must), must in names)
# (3) THE anchor: playerid 20801 is Cristiano Ronaldo, FIFA 17 -- 94 rated,
# a left winger, Portuguese, 185 cm, at Real Madrid.
t = tables['players']
cols = columns(img, t)
pl = {r['playerid']: r for r in read_rows(img, t, cols)}
r = pl.get(20801)
ok &= check("players has 20801", r is not None)
if r:
ok &= check(" 20801 overallrating == 94 (got %s)" % r['overallrating'],
r['overallrating'] == 94)
ok &= check(" 20801 preferredposition1 == 27 (LW) (got %s)"
% r['preferredposition1'], r['preferredposition1'] == 27)
ok &= check(" 20801 nationality == 38 (Portugal) (got %s)" % r['nationality'],
r['nationality'] == 38)
ok &= check(" 20801 height == 185 cm (got %s)" % r['height'], r['height'] == 185)
ok &= check(" 20801 preferredfoot == 1 (right) (got %s)" % r['preferredfoot'],
r['preferredfoot'] == 1)
# nation 38 must literally spell Portugal, from a different table
nt = tables['nations']
nat = {x['nationid']: x for x in read_rows(img, nt, columns(img, nt))}
ok &= check("nations[38].nationname == 'Portugal' (got %r)"
% (nat.get(38, {}).get('nationname')),
nat.get(38, {}).get('nationname') == 'Portugal')
# and teamplayerlinks must put him at Real Madrid
tp = tables['teamplayerlinks']
links = [x for x in read_rows(img, tp, columns(img, tp)) if x['playerid'] == 20801]
tt = tables['teams']
teams = {x['teamid']: x for x in read_rows(img, tt, columns(img, tt))}
names = sorted({teams.get(l['teamid'], {}).get('teamname') for l in links})
ok &= check("teamplayerlinks[20801] includes Real Madrid (got %s)" % (names,),
'Real Madrid' in names)
# (4) managercards' id space, measured independently in a previous session
# (docs/managercards_ids.txt: 416 ids from 1000001 upward)
mt = tables['managercards']
mc = read_rows(img, mt, columns(img, mt))
ids = sorted(x['carddbid'] for x in mc)
ok &= check("managercards row 0 carddbid == 1000001 (got %s)" % (ids[0] if ids else None),
ids and ids[0] == 1000001)
ok &= check("managercards ids all in [1000001, 1002000] (got %s..%s, n=%d)"
% (ids[0] if ids else None, ids[-1] if ids else None, len(ids)),
ids and 1000001 <= ids[0] and ids[-1] <= 1002000)
ok &= check("managercards assetid == carddbid on every row",
all(x['assetid'] == x['carddbid'] for x in mc))
# (5) every table's row block must carry the block marker, and every byte of
# rowcount*rowsize must actually be readable. (The u32 at block-0x10 is
# a byte count only for the heap-resident tables; for the ~17 small
# tables that live in the 0x07xxxxxx pool it is something else, so it is
# reported but not required.)
unmarked, short, oddsize = [], [], []
for n, t in tables.items():
if not t['rowcount'] or not t['rowblock']:
continue
need = t['rowcount'] * t['rowsize']
sz, mark = block_size(img, t)
if mark != BLOCK_MARK:
unmarked.append(n)
if len(img.read(t['rowblock'], need)) < need:
short.append(n)
if sz is None or sz < need:
oddsize.append(n)
ok &= check("every non-empty table's row block carries the 0x2c020e60 marker "
"(%d without)" % len(unmarked), not unmarked)
ok &= check("every non-empty table's rowcount*rowsize bytes are readable "
"(%d short)" % len(short), not short)
print(" [note] %d small pool-resident tables have a block-size field that is "
"not a byte count: %s" % (len(oddsize), ", ".join(sorted(oddsize))))
return ok
# --------------------------------------------------------------------------- #
def dump_table(img, t, outdir, limit=None):
cols = columns(img, t)
rows = read_rows(img, t, cols, limit)
sz, mark = block_size(img, t)
doc = {
'table': t['name'],
'source': 'FIFA17.exe resident database (tools/db_dump.py)',
'rowcount': t['rowcount'],
'rowsize_bytes': t['rowsize'],
'rows_emitted': len(rows),
'rowblock': '0x%x' % (t['rowblock'] or 0),
'rowblock_bytes': sz,
'descriptor': '0x%x' % t['desc'],
'schema': [{'name': c['name'], 'bit': c['bit'], 'width': c['width'],
'kind': {1: 'string', 2: 'int', 4: 'date'}.get(c['kind'], 'unknown'),
'min': c['min'], 'max': c['max'], 'storage': c['storage']}
for c in cols],
'rows': rows,
}
os.makedirs(outdir, exist_ok=True)
p = os.path.join(outdir, t['name'] + '.json')
with open(p, 'w') as fh:
json.dump(doc, fh, ensure_ascii=False, separators=(',', ':'))
return p, len(rows), os.path.getsize(p)
def main():
ap = argparse.ArgumentParser(description=__doc__.split('\n')[0])
ap.add_argument('--pid', type=int)
ap.add_argument('--mem', help='work from a snapshot directory instead of a live game')
ap.add_argument('--save-mem', help='snapshot the process to this directory and exit')
ap.add_argument('--list', action='store_true')
ap.add_argument('--schema', action='append', default=[])
ap.add_argument('--dump', nargs='*')
ap.add_argument('--dump-all', action='store_true')
ap.add_argument('--check', action='store_true')
ap.add_argument('--limit', type=int)
ap.add_argument('-o', '--out', default='../data/tables')
a = ap.parse_args()
if a.mem:
img = Image(memdir=a.mem)
sys.stderr.write("snapshot %s: %d regions\n" % (a.mem, len(img.regions)))
else:
pid = a.pid or find_pid()
img = Image(pid=pid)
sys.stderr.write("FIFA17.exe pid %d: %d anonymous regions\n"
% (pid, len(img.regions)))
if a.save_mem:
n = img.save(a.save_mem)
sys.stderr.write("saved %.1f MB to %s\n" % (n / 1048576.0, a.save_mem))
return 0
tables = catalogue(img)
sys.stderr.write("catalogue: %d tables\n" % len(tables))
if a.list:
for n in sorted(tables):
t = tables[n]
cols = columns(img, t)
print("%-34s rows=%-7s rowsize=%-5d cols=%-4d | %s"
% (n, t['rowcount'], t['rowsize'], t['ncol'],
", ".join(c['name'] for c in cols[:6])))
return 0
for n in a.schema:
t = tables.get(n)
if not t:
print("%s: not in the catalogue" % n)
continue
print("=== %s rows=%s rowsize=%d bytes cols=%d rowblock=0x%x"
% (n, t['rowcount'], t['rowsize'], t['ncol'], t['rowblock'] or 0))
for c in columns(img, t):
print(" bit %4d w %-3d %-18s %-9s min %-8s max %s"
% (c['bit'], c['width'], c['name'], c['storage'], c['min'], c['max']))
rc = 0
if a.check:
rc = 0 if run_checks(img, tables) else 1
want = None
if a.dump_all:
want = sorted(tables)
elif a.dump is not None:
want = a.dump or sorted(tables)
if want:
total = 0
for n in want:
t = tables.get(n)
if not t:
sys.stderr.write(" %s: not in the catalogue\n" % n)
continue
try:
p, nr, nb = dump_table(img, t, a.out, a.limit)
except Exception as e: # noqa: BLE001
sys.stderr.write(" %-30s FAILED: %s\n" % (n, e))
rc = 1
continue
total += nb
sys.stderr.write(" %-34s %7d rows %9.1f KB %s\n"
% (n, nr, nb / 1024.0, p))
sys.stderr.write("wrote %.1f MB into %s\n" % (total / 1048576.0, a.out))
return rc
if __name__ == '__main__':
sys.exit(main())
+325
View File
@@ -0,0 +1,325 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Extract FIFA 17's real player roster (playerId -> name + rating) from a LIVE
FIFA17.exe process. READ-ONLY: /proc/PID/mem is opened 'rb' and only ever
seek()/read(). There is no write path in this file.
WHY THIS FILE EXISTS, AND WHY IT IS NOT "dbdata.dll"
---------------------------------------------------
The plan of record was to pull the roster out of `/mnt/games/FIFA 17/dbdata.dll`
(2,686,152 bytes, one export `getTableData`, 2.5 MB `.xdata` payload). That DLL
is NOT a database. Verified this session by building tools/dbdata_probe.c with
x86_64-w64-mingw32-gcc and running it under Wine:
base=00006FFFFA980000 getTableData=00006FFFFA9816B0 (rva 0x16b0)
call 0: ret=00007FFFFEBF5DB0 len=1012 -> 1012 chars of base64url
decoded: 759 bytes, md5 dc97c0dfd5edea5fb379dc14d8017980, entropy ~7.9
`.xdata` measures 7.52 bits/byte of entropy uniformly across its whole 2,515,528
bytes (sampled at 0x0/0x1000/0x100000/0x200000/0x260000, zero 16-byte NUL runs),
i.e. it is encrypted/packed, and the single export hands back a ~759-byte
attestation blob, not tables. There is no table selector argument. So the
roster cannot be read out of dbdata.dll without breaking its packer.
The roster IS, however, fully resident in the running game. FIFA 17 builds a
flat, rating-sorted index of every player in the base DB and keeps it on the
heap. That is what this tool reads.
THE STRUCTURE (resolved live, 2026-08-04, pid 11864, game sitting at the menu)
-----------------------------------------------------------------------------
Two heap regions cooperate:
* a "name pool" region (seen at 0x0d790000..0x0dc40000, 4.8 MB, rw-p) holding
~17.5k individually-allocated, NUL-terminated UTF-8 strings in the form
"<firstName>|<lastName>|<commonName>"
e.g. "Cristiano|Ronaldo|", "Neymar|da Silva Santos Jr.|Neymar".
commonName is usually empty (the string then ends in "||").
* an index table (seen at 0x0b8450d40 .. 0x0b8562fc0, rw-p) of 17,547 entries
at a constant stride of 0x40 bytes, no gaps, sorted by rating DESCENDING:
+0x00 u32 playerId (20801 = Cristiano Ronaldo)
+0x04 u32 rank (0..16546, dense, == entry index)
+0x08 u32 rating (73..94 at the head, down to 40s at tail)
+0x0c u32 aux (0 for most entries; a 32-bit hash for
some -- purpose unresolved)
+0x10 char* name begin -> into the name pool
+0x18 char* name end == begin + strlen
+0x20 char* name end + 1
+0x28 u64 0x2c020e50 (constant across every entry)
+0x30 u64 1, or a 32-bit hash in the low dword
+0x38 u64 0x6ffffc32a968 (constant across every entry -- a vtable
or allocator handle in the Wine range)
The {begin, end, end+1} triple at +0x10 is the reliable signature: it is
self-validating (end-begin == strlen, and end+1 == the third pointer), which is
why this tool anchors on it instead of on any hard-coded address. Nothing here
is a fixed VA: run it against any FIFA17.exe and it re-locates the table.
WHAT THIS GIVES YOU AND WHAT IT DOES NOT
----------------------------------------
GIVES: playerId, rank, rating, firstName, lastName, commonName -- for the
complete 17,547-player FIFA 17 roster.
DOES NOT: position, nationality, teamId, or the six face attributes. Those
are NOT in this table. See the "STILL MISSING" note at the bottom
of this file for the leads that were found for them.
ANCHOR CHECK (the one the task asked for): playerId 20801 must be
"Cristiano|Ronaldo|" rated 94. --check enforces it and exits non-zero if the
parse disagrees.
USAGE
-----
./dbdata_extract.py # extract, write players_fifa17.json
./dbdata_extract.py --check # extract + assert the Ronaldo anchor
./dbdata_extract.py -o /tmp/roster.json
./dbdata_extract.py --pid 11864
./dbdata_extract.py --top 40 # print the top 40 and exit
Requires ptrace access to the FIFA process (this project already runs with
kernel.yama.ptrace_scope=1 and the same uid, which is sufficient).
"""
import argparse
import json
import os
import re
import struct
import sys
from collections import Counter
ENTRY_STRIDE = 0x40
NAME_TRIPLE_OFF = 0x10 # offset of {begin,end,end+1} inside an entry
MAX_NAME_LEN = 120
RONALDO_ID = 20801
RONALDO_RATING = 94
RONALDO_NAME = "Cristiano|Ronaldo|"
def find_pid():
for d in os.listdir('/proc'):
if not d.isdigit():
continue
try:
if open('/proc/%s/comm' % d).read().strip() == 'FIFA17.exe':
return int(d)
except OSError:
pass
raise SystemExit("FIFA17.exe is not running (this tool needs the live game)")
def read_maps(pid):
"""Readable, non-file-backed-or-anon RW regions, small enough to slurp."""
out = []
for line in open('/proc/%d/maps' % pid):
parts = line.split()
lo, hi = parts[0].split('-')
lo, hi = int(lo, 16), int(hi, 16)
perms = parts[1]
if 'r' not in perms:
continue
if hi - lo > (1 << 31):
continue
out.append((lo, hi, perms, parts[5] if len(parts) > 5 else ''))
return out
class Mem(object):
def __init__(self, pid):
self.f = open('/proc/%d/mem' % pid, 'rb', 0)
self.cache = {}
def read(self, va, n):
self.f.seek(va)
return self.f.read(n)
def region(self, lo, hi):
if (lo, hi) not in self.cache:
try:
self.f.seek(lo)
self.cache[(lo, hi)] = self.f.read(hi - lo)
except OSError:
self.cache[(lo, hi)] = b''
return self.cache[(lo, hi)]
def find_name_pools(mem, maps):
"""Regions containing many '<a>|<b>|<c>\\0' strings = the player-name pool."""
pat = re.compile(rb'[^\x00|][^\x00|]{0,44}\|[^\x00|]{0,49}\|[^\x00|]{0,49}\x00')
pools = []
for lo, hi, perms, name in maps:
if 'w' not in perms or name:
continue
if not (0x100000 <= hi - lo <= 0x4000000):
continue
d = mem.region(lo, hi)
if not d:
continue
n = len(pat.findall(d))
if n >= 2000:
pools.append((lo, hi, n))
return pools
def scan_entries(mem, maps, pools):
"""Anchor on the self-validating {begin,end,end+1} name triple."""
lows = [(lo, hi) for lo, hi, _ in pools]
def in_pool(va):
for lo, hi in lows:
if lo <= va < hi:
return True
return False
def pool_bytes(va, n):
for lo, hi in lows:
if lo <= va and va - lo + n <= hi - lo:
return mem.region(lo, hi)[va - lo:va - lo + n]
return mem.read(va, n)
found = {}
for lo, hi, perms, name in maps:
if 'w' not in perms or name:
continue
d = mem.region(lo, hi)
if len(d) < ENTRY_STRIDE:
continue
for off in range(0, len(d) - ENTRY_STRIDE, 8):
b, e, c = struct.unpack_from('<QQQ', d, off)
if not (b < e < b + MAX_NAME_LEN and c == e + 1):
continue
if not in_pool(b):
continue
s = pool_bytes(b, e - b)
if s.count(b'|') != 2:
continue
base = off - NAME_TRIPLE_OFF
if base < 0:
continue
pid_, rank, rating, aux = struct.unpack_from('<IIII', d, base)
found[lo + base] = (pid_, rank, rating, aux,
s.decode('utf-8', 'replace'))
return found
def build(found):
addrs = sorted(found)
rows = []
for a in addrs:
pid_, rank, rating, aux, s = found[a]
f0, f1, f2 = (s.split('|') + ['', '', ''])[:3]
rows.append({
'playerId': pid_,
'resourceId': pid_, # version 0; resourceId = playerId | version<<24
'rank': rank,
'rating': rating,
'firstName': f0,
'lastName': f1,
'commonName': f2,
'aux': aux,
'addr': '0x%x' % a,
})
return addrs, rows
def main():
ap = argparse.ArgumentParser(description=__doc__.split('\n')[0])
ap.add_argument('--pid', type=int)
ap.add_argument('-o', '--out', default='players_fifa17.json')
ap.add_argument('--check', action='store_true',
help='assert playerId 20801 == Cristiano Ronaldo, 94')
ap.add_argument('--top', type=int, default=0)
args = ap.parse_args()
pid = args.pid or find_pid()
mem = Mem(pid)
maps = read_maps(pid)
sys.stderr.write("pid %d, %d readable regions\n" % (pid, len(maps)))
pools = find_name_pools(mem, maps)
if not pools:
raise SystemExit("no player-name pool found -- is the game past the "
"main menu with the player DB loaded?")
for lo, hi, n in pools:
sys.stderr.write("name pool 0x%x-0x%x %d name strings\n" % (lo, hi, n))
found = scan_entries(mem, maps, pools)
addrs, rows = build(found)
if not rows:
raise SystemExit("index table not found")
strides = Counter(addrs[i + 1] - addrs[i] for i in range(len(addrs) - 1))
sys.stderr.write("index table 0x%x-0x%x %d entries strides=%s\n"
% (addrs[0], addrs[-1], len(rows), strides.most_common(3)))
ranks = [r['rank'] for r in rows]
sys.stderr.write("rank %d..%d (%d unique) playerIds %d unique rating %d..%d\n"
% (min(ranks), max(ranks), len(set(ranks)),
len(set(r['playerId'] for r in rows)),
min(r['rating'] for r in rows),
max(r['rating'] for r in rows)))
if args.top:
for r in rows[:args.top]:
print('%6d %2d %s' % (r['playerId'], r['rating'],
'|'.join([r['firstName'], r['lastName'],
r['commonName']])))
return 0
by_id = {r['playerId']: r for r in rows}
ok = True
cr = by_id.get(RONALDO_ID)
if cr is None:
sys.stderr.write("ANCHOR FAIL: playerId %d absent\n" % RONALDO_ID)
ok = False
else:
got = '|'.join([cr['firstName'], cr['lastName'], cr['commonName']])
sys.stderr.write("anchor: playerId %d -> %r rating %d\n"
% (RONALDO_ID, got, cr['rating']))
if cr['rating'] != RONALDO_RATING or got != RONALDO_NAME:
sys.stderr.write("ANCHOR FAIL: expected %r / %d\n"
% (RONALDO_NAME, RONALDO_RATING))
ok = False
with open(args.out, 'w') as fh:
json.dump(rows, fh, ensure_ascii=False, indent=1)
sys.stderr.write("wrote %s (%d players)\n" % (args.out, len(rows)))
if args.check and not ok:
return 1
return 0
# STILL MISSING: position / nationality / teamId / the six attributes.
#
# They are NOT in the index table above. Two leads were located live and are
# recorded here so the next pass does not have to re-find them:
#
# (1) Materialised FUT card records. In the 40 MB heap region at 0x0b63b0000
# the squad's resolved cards sit at a 0x180 stride, e.g. 0x0b840c2c0 =
# Lewandowski and 0x0b840c440 = Luis Suarez. Layout relative to the record
# word at +0x00c (0xf0, 0xf1 -- consecutive, an index):
# +0x010..+0x02c six u32 attributes then two more u32
# Lewandowski: 77 88 75 82 42 82 | 99 90
# Suarez : 83 90 79 87 42 80 | 99 92
# the last u32 is the overall rating (90 / 92, both correct for
# FIFA 17), the 99 is constant across both.
# +0x030 char[16] firstName ("Robert", "Luis")
# +0x040 char[16] lastName ("Lewandowski", "Suárez")
# Only ~a hundred of these exist process-wide -- they are built per card
# that the client actually materialises, not a table. So this is a
# VERIFICATION oracle for attribute values, not a bulk source.
#
# (2) A 32-byte-stride keyed table in the 238 MB heap region 0x37440000..,
# seen at 0x42e8dbe8, carrying u64 fields keyed by playerId:
# 20801 -> 27, 94, 77
# 41236 -> 25, 90, 80 (41236 = Zlatan Ibrahimovic, rating 90)
# The rating column is right in both rows; the 27/25 and 77/80 columns were
# NOT identified. A 32-bit hash is interleaved in the high dword of a
# rotating slot, so it is a hash container, not a flat array. Worth one
# focused pass.
#
# Neither of these was pushed to completion in the session that wrote this file.
# Do not cite them as resolved.
if __name__ == '__main__':
sys.exit(main())
+242
View File
@@ -0,0 +1,242 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Read FIFA 17's LOADED database schema (and any table's rows) out of a live
FIFA17.exe. READ-ONLY: /proc/PID/mem is opened 'rb' and only ever seek()/read().
There is no write path in this file.
WHY THIS EXISTS
---------------
`managercards` (and headcoachcards / gkcoachcards / physiocards /
fitnesscoachcards) are queried by CardsDLL through a column called `carddbid`,
and the set of legal carddbid values was unknown -- two blind sweeps (1..5000 and
6000..8000) resolved nothing, and the manager branch writes no miss-fill so a
wrong id is completely silent. dbdata.dll on disk is packed (see
dbdata_extract.py's docstring: uniform ~7.5 bits/byte entropy, one export that
returns a ~759-byte attestation blob), so the tables cannot be read off disk.
They ARE fully resident in the running game, and unlike the player roster they
are reachable through the DB's own SCHEMA rather than by content-anchoring.
THE STRUCTURES (resolved live, 2026-08-04, pid 11864, sitting in the FUT club UI)
--------------------------------------------------------------------------------
Three cooperating structures, all in the game's rw-p heap:
* IDENTIFIER INTERN POOL, seen at 0x0770_0000..0x07d0_0000. Every table name
and every column name in the whole database, interned once. Entry:
+0x00 char* next-in-bucket
+0x08 u64 strlen+1
+0x10 u64 hash
+0x18 char[] the NUL-terminated identifier, INLINE
e.g. "managercards" at 0x07be63a0, "carddbid" at 0x078eefc8,
"talkrating" at 0x078ec188, "headcoachcards" at 0x07be5b80.
* TABLE DIRECTORY, seen at 0x4254_7ff0.., 573 entries of 40 bytes. The
reliable signature is the CONSTANT QWORD 0x07c20760 that every entry carries
at +0x20 -- anchor on that, not on any fixed address:
+0x00 char* table name (into the intern pool)
+0x08 void* table descriptor ("p1")
+0x10 u32 name hash4 (4 bytes, also repeated inside the descriptor)
+0x14 u32 column count
+0x18 void* column-descriptor bucket ("p2")
+0x20 void* 0x07c20760 (the anchor)
Live values read this session:
managercards p1=0x42482f08 hash='xIfB' ncol=62
headcoachcards p1=0x427429d8 hash='WTdJ' ncol=18
gkcoachcards p1=0x42e46648 hash='CZUM' ncol=107
physiocards p1=0x42482478 hash='AThf' ncol=9
fitnesscoachcards p1=0x42d668e8 hash='MmoU' ncol=11
* TABLE DESCRIPTOR (at p1). Self-identifies by repeating the table's own
hash4; immediately after that hash come
u32 row_size_bytes, u32 max_bit_index
(managercards: 0x80 and 0x3ff -- 128 bytes == 1024 bits, consistent), and
then a column array of 16-byte records:
u32 type (3 for the scalar columns seen), u32 bit_offset,
u32 name hash4, u32 bit_width
First managercards columns read live (bit_offset, width):
(0x260,7) (0x20,8) (0x267,15) (0x28,8) (0x276,14) (0x284,5)
(0x30,8) (0x289,7) (0x290,2) (0x38,8) (0x292,18) (0x2a4,...)
Note the bit offsets run past 416, so a row is NOT the 0x34-byte
firstname/lastname record seen near 0x42530928 -- that is a different table.
* COLUMN-NAME entry (0x30 bytes, in the p2 buckets, also anchored by
0x07c20760 at +0x20): +0x04 hash4, +0x28 char* name. This is what turns a
column's hash4 back into "carddbid".
WHAT THIS TOOL DOES NOT YET DO
------------------------------
It stops at the schema. It does not locate the ROW STORAGE for a table -- that
pointer was not identified before the game exited. Candidate leads recorded at
the time: managercards' descriptor holds 0x426cb278 at p1+0x08 and 0x07822918 at
p1+0x30, and the 16 bytes before p1 read `... 81 04 00 00` (0x481 = 1153, a
plausible row count). Once row storage is found, carddbid for every row is a
mechanical bit-extract with (bit_offset, bit_width) from --table.
USAGE
-----
./dbschema_probe.py # list the FUT card tables
./dbschema_probe.py --table managercards # full column list for one table
./dbschema_probe.py --list # every table in the directory
Requires ptrace access to the FIFA process (ptrace_scope=1 + same uid suffices)
and FIFA17.exe actually running.
"""
import argparse
import os
import re
import struct
import sys
SHARED_ANCHOR = 0x07C20760 # the qword every directory/column entry carries at +0x20
POOL_LO, POOL_HI = 0x07700000, 0x07D00000
HEAP_LO, HEAP_HI = 0x42000000, 0x43000000
IDENT = re.compile(r'[A-Za-z][A-Za-z0-9_]*\Z')
FUT_TABLES = ("managercards", "headcoachcards", "gkcoachcards", "physiocards",
"fitnesscoachcards", "players", "teams", "leagues", "nations")
def find_pid():
for d in os.listdir('/proc'):
if not d.isdigit():
continue
try:
if open('/proc/%s/comm' % d).read().strip() == 'FIFA17.exe':
return int(d)
except OSError:
pass
raise SystemExit("FIFA17.exe is not running (this tool needs the live game)")
class Mem(object):
"""Read-only window onto /proc/PID/mem, with the two regions pre-slurped."""
def __init__(self, pid):
self.fh = open('/proc/%d/mem' % pid, 'rb')
self.pool = self.read(POOL_LO, POOL_HI - POOL_LO)
self.heap = self.read(HEAP_LO, HEAP_HI - HEAP_LO)
if not self.pool or not self.heap:
raise SystemExit("could not read the DB regions -- is the game past the "
"main menu? (the DB is loaded lazily)")
def read(self, addr, n):
if addr < 0 or addr > (1 << 47):
return b''
try:
self.fh.seek(addr)
return self.fh.read(n)
except (OSError, ValueError):
return b''
def cstr(self, addr, maxlen=64):
for base, buf in ((POOL_LO, self.pool), (HEAP_LO, self.heap)):
if base <= addr < base + len(buf):
i = addr - base
j = buf.find(b'\x00', i, i + maxlen)
if j <= i:
return None
s = buf[i:j]
return s.decode('latin1') if re.fullmatch(rb'[ -~]+', s) else None
return None
def hash4_to_names(mem):
"""Every column-name entry in the DB, keyed by its 4-byte name hash."""
out = {}
for base, buf in ((HEAP_LO, mem.heap), (POOL_LO, mem.pool)):
for off in range(0, len(buf) - 0x30, 8):
if struct.unpack_from('<Q', buf, off + 0x20)[0] != SHARED_ANCHOR:
continue
nm = mem.cstr(struct.unpack_from('<Q', buf, off + 0x28)[0], 48)
if not nm or not IDENT.match(nm):
continue
out.setdefault(struct.unpack_from('<I', buf, off + 4)[0], set()).add(nm)
return out
def table_directory(mem):
"""name -> (entry_addr, descriptor, hash4, ncol, colbucket)."""
out = {}
buf = mem.heap
for off in range(0, len(buf) - 0x28, 8):
if struct.unpack_from('<Q', buf, off + 0x20)[0] != SHARED_ANCHOR:
continue
nm = mem.cstr(struct.unpack_from('<Q', buf, off)[0], 48)
if not nm or not IDENT.match(nm):
continue
out[nm] = (HEAP_LO + off,
struct.unpack_from('<Q', buf, off + 8)[0],
struct.unpack_from('<I', buf, off + 0x10)[0],
struct.unpack_from('<I', buf, off + 0x14)[0],
struct.unpack_from('<Q', buf, off + 0x18)[0])
return out
def h4str(v):
return struct.pack('<I', v).decode('latin1')
def describe(mem, tables, h2n, name):
if name not in tables:
print("### %-20s NOT IN DIRECTORY" % name)
return
ent, p1, h4, ncol, p2 = tables[name]
print("\n### %s dir@%#x descriptor=%#x hash=%r columns=%d"
% (name, ent, p1, h4str(h4), ncol))
blk = mem.read(p1, 0x80 + ncol * 16 + 0x80)
if not blk:
print(" descriptor unreadable")
return
pos = blk.find(struct.pack('<I', h4))
if pos < 0:
print(" self-hash not found in the descriptor -- layout changed")
return
rowsz, maxbit = struct.unpack_from('<II', blk, pos + 4)
print(" self-hash at +%#x row_size=%#x bytes max_bit=%#x" % (pos, rowsz, maxbit))
# the column array begins at the first 16-byte record whose hash4 is a known
# column name and whose width is sane
st = pos + 12
while st < len(blk) - 16:
t, boff, ch, w = struct.unpack_from('<IIII', blk, st)
if t == 3 and ch in h2n and 0 < w <= 64:
break
st += 4
print(" column array at +%#x" % st)
cols = []
for i in range(ncol):
o = st + i * 16
if o + 16 > len(blk):
break
t, boff, ch, w = struct.unpack_from('<IIII', blk, o)
cols.append((boff, w, t, ch,
"|".join(sorted(h2n.get(ch, {"?" + h4str(ch)})))))
for boff, w, t, ch, nm in sorted(cols):
print(" bit %5d width %-3d type %-2d %s" % (boff, w, t, nm))
return cols
def main():
ap = argparse.ArgumentParser()
ap.add_argument('--pid', type=int)
ap.add_argument('--table', action='append')
ap.add_argument('--list', action='store_true')
a = ap.parse_args()
pid = a.pid or find_pid()
print("FIFA17.exe pid %d" % pid)
mem = Mem(pid)
h2n = hash4_to_names(mem)
tables = table_directory(mem)
print("column-name hashes: %d tables in directory: %d" % (len(h2n), len(tables)))
if a.list:
for nm in sorted(tables):
ent, p1, h4, ncol, p2 = tables[nm]
print(" %-34s desc=%#012x hash=%r cols=%d" % (nm, p1, h4str(h4), ncol))
return
for nm in (a.table or FUT_TABLES):
describe(mem, tables, h2n, nm)
if __name__ == '__main__':
main()
@@ -0,0 +1,67 @@
#!/usr/bin/env python3
"""Decode a captured Blaze Fire2 frame: 16-byte header + Heat2 TDF payload.
Clean-room: parses the wire bytes of our own client's traffic."""
import sys, struct
def decode_tag(b):
# Heat2 tag: 3 bytes -> 4 chars, each 6-bit; 0 -> ' ' (trimmed). char = v ? v+0x20 : ' '
a,b1,c = b[0],b[1],b[2]
v=[ (a>>2)&0x3f, ((a&0x3)<<4)|((b1>>4)&0xf), ((b1&0xf)<<2)|((c>>6)&0x3), c&0x3f ]
return ''.join(chr(x+0x20) if x else ' ' for x in v).rstrip()
TYPES={0x00:'int',0x01:'string',0x02:'blob',0x03:'struct',0x04:'list',
0x05:'map',0x06:'union',0x07:'intlist',0x08:'objtype',0x09:'objid',0x0a:'float'}
def read_varint(buf,i):
# Heat2 varint: 7 bits/byte, high bit = continue; first byte only 6 data bits (bit6=continue)
b=buf[i]; i+=1
val=b&0x3f
if b&0x80:
shift=6
while True:
b=buf[i]; i+=1
val|=(b&0x7f)<<shift; shift+=7
if not (b&0x80): break
return val,i
def walk(buf, depth=0, i=0, end=None):
if end is None: end=len(buf)
pad=' '*depth
while i < end:
if i+4>end:
print(f"{pad}[trailing {buf[i:end].hex()}]"); break
tag=decode_tag(buf[i:i+3]); typ=buf[i+3]; i+=4
tn=TYPES.get(typ,f'0x{typ:02x}')
if typ==0x00: # int varint
v,i=read_varint(buf,i); print(f"{pad}{tag} (int) = {v}")
elif typ==0x01: # string: varint len + bytes (incl null)
ln,i=read_varint(buf,i); s=buf[i:i+ln]; i+=ln
print(f"{pad}{tag} (str) = {s.rstrip(bytes([0])).decode(errors='replace')!r}")
elif typ==0x02: # blob
ln,i=read_varint(buf,i); print(f"{pad}{tag} (blob[{ln}]) = {buf[i:i+ln].hex()}"); i+=ln
elif typ==0x03: # struct: nested until 0x00 terminator
print(f"{pad}{tag} (struct) {{")
i=walk(buf,depth+1,i,end) # walk handles 0x00 term
print(f"{pad}}}")
else:
# unknown/complex: dump remainder briefly and stop this level
print(f"{pad}{tag} ({tn}) <complex; raw from here> {buf[i:min(i+24,end)].hex()}")
# best-effort: skip nothing, bail to avoid misparse
return end
if i<end and buf[i]==0x00: # struct terminator
i+=1; return i
return i
def main():
data=open(sys.argv[1],'rb').read()
ln=struct.unpack('>I',data[0:4])[0]
comp=struct.unpack('>H',data[6:8])[0]
cmd=struct.unpack('>H',data[8:10])[0]
err=struct.unpack('>H',data[10:12])[0]
mtyp=data[12]
print(f"== {sys.argv[1]} ==")
print(f"Fire2 header: len={ln} component=0x{comp:04x} command=0x{cmd:04x} error=0x{err:04x} msgtype=0x{mtyp:02x}")
print(f"payload ({len(data)-16} bytes):")
walk(data[16:])
main()
@@ -0,0 +1,71 @@
#!/usr/bin/env python3
"""Minimal minidump reader: exception code/address + module list, so a FIFA 17
CrashDump can be mapped to <module>+RVA (and thence to a Ghidra address)."""
import struct, sys
p = sys.argv[1]
d = open(p, "rb").read()
assert d[:4] == b"MDMP", "not a minidump: %r" % d[:4]
ver, nstreams, dirrva = struct.unpack_from("<IiI", d, 4)[0], *struct.unpack_from("<II", d, 8)
nstreams, dirrva = struct.unpack_from("<II", d, 8)
streams = {}
for i in range(nstreams):
st, size, rva = struct.unpack_from("<III", d, dirrva + i * 12)
streams.setdefault(st, []).append((size, rva))
print("streams:", sorted(streams))
def mstring(rva):
(ln,) = struct.unpack_from("<I", d, rva)
return d[rva + 4: rva + 4 + ln].decode("utf-16-le", "replace")
mods = []
if 4 in streams:
size, rva = streams[4][0]
(n,) = struct.unpack_from("<I", d, rva)
off = rva + 4
for i in range(n):
base, sz, csum, ts, nrva = struct.unpack_from("<QIIII", d, off)
mods.append((base, sz, mstring(nrva)))
off += 108
print("modules:", len(mods))
exc_addr = None
if 6 in streams:
size, rva = streams[6][0]
tid, _pad = struct.unpack_from("<II", d, rva)
code, flags, recptr, addr, nparams = struct.unpack_from("<IIQQI", d, rva + 8)
exc_addr = addr
NAMES = {0xC0000005: "ACCESS_VIOLATION", 0xC000001D: "ILLEGAL_INSTRUCTION",
0xC0000094: "INT_DIVIDE_BY_ZERO", 0xC0000096: "PRIV_INSTRUCTION",
0x80000003: "BREAKPOINT", 0xC00000FD: "STACK_OVERFLOW",
0xC0000374: "HEAP_CORRUPTION", 0xC0000135: "DLL_NOT_FOUND"}
print("\n=== EXCEPTION ===")
print(" thread : %#x" % tid)
print(" code : %#010x %s" % (code, NAMES.get(code, "?")))
print(" address : %#018x" % addr)
params = [struct.unpack_from("<Q", d, rva + 8 + 32 + i * 8)[0] for i in range(min(nparams, 15))]
print(" params : %s" % [hex(x) for x in params])
if code == 0xC0000005 and len(params) >= 2:
print(" -> %s at %#x" % ({0: "READ from", 1: "WRITE to", 8: "EXECUTE at"}.get(params[0], "access"),
params[1]))
if exc_addr is not None:
hit = [m for m in mods if m[0] <= exc_addr < m[0] + m[1]]
print("\n=== FAULTING MODULE ===")
if hit:
base, sz, name = hit[0]
short = name.split("\\")[-1]
print(" %s base=%#x size=%#x" % (short, base, sz))
print(" RVA = %#x" % (exc_addr - base))
print(" ghidra (PE base 0x180000000) = %#x" % (0x180000000 + (exc_addr - base)))
else:
print(" address %#x is in NO loaded module (bad indirect call / corrupt ptr)" % exc_addr)
print("\n=== modules of interest ===")
for base, sz, name in mods:
s = name.split("\\")[-1].lower()
if any(k in s for k in ("cards", "fifa", "dbdata", "core", "game")):
print(" %-28s base=%#014x size=%#x" % (name.split("\\")[-1], base, sz))
@@ -0,0 +1,87 @@
#!/usr/bin/env python3
"""Pseudo-backtrace from a minidump: walk the faulting thread's stack and report
every qword that points into a loaded module's code (i.e. plausible return
addresses), innermost first."""
import struct, sys
p = sys.argv[1]
d = open(p, "rb").read()
nstreams, dirrva = struct.unpack_from("<II", d, 8)
streams = {}
for i in range(nstreams):
st, size, rva = struct.unpack_from("<III", d, dirrva + i * 12)
streams.setdefault(st, []).append((size, rva))
def mstring(rva):
(ln,) = struct.unpack_from("<I", d, rva)
return d[rva + 4: rva + 4 + ln].decode("utf-16-le", "replace")
mods = []
size, rva = streams[4][0]
(n,) = struct.unpack_from("<I", d, rva)
off = rva + 4
for i in range(n):
base, sz, csum, ts, nrva = struct.unpack_from("<QIIII", d, off)
mods.append((base, sz, mstring(nrva).split("\\")[-1]))
off += 108
mods.sort()
# faulting thread + exception address
size, rva = streams[6][0]
tid, _ = struct.unpack_from("<II", d, rva)
code, flags, recptr, exc_addr, nparams = struct.unpack_from("<IIQQI", d, rva + 8)
def whose(a):
for base, sz, name in mods:
if base <= a < base + sz:
return name, a - base
return None, 0
# thread list
size, rva = streams[3][0]
(nthreads,) = struct.unpack_from("<I", d, rva)
off = rva + 4
target = None
for i in range(nthreads):
t_id, susp, pcls, prio, teb, stk_start, stk_size, stk_rva, ctx_size, ctx_rva = \
struct.unpack_from("<IIIIQQIIII", d, off)
if t_id == tid:
target = (stk_start, stk_size, stk_rva, ctx_size, ctx_rva)
off += 48
print("faulting thread %#x exception at %s+%#x" % (tid, *whose(exc_addr)))
if not target:
print("no stack captured for the faulting thread"); sys.exit(0)
stk_start, stk_size, stk_rva, ctx_size, ctx_rva = target
print("stack %#x..%#x (%d bytes)\n" % (stk_start, stk_start + stk_size, stk_size))
# CONTEXT_AMD64: Rsp at offset 0x98, Rip at 0xF8 (RUNTIME layout)
if ctx_size >= 0x100:
rsp = struct.unpack_from("<Q", d, ctx_rva + 0x98)[0]
rip = struct.unpack_from("<Q", d, ctx_rva + 0xF8)[0]
print("RSP=%#x RIP=%#x (%s+%#x)\n" % (rsp, rip, *whose(rip)))
else:
rsp = stk_start
print("=== plausible return addresses (innermost first) ===")
seen, out = set(), []
start = max(rsp - stk_start, 0)
for o in range(int(start), stk_size - 8, 8):
(v,) = struct.unpack_from("<Q", d, stk_rva + o)
name, off2 = whose(v)
if name is None:
continue
key = (name, off2)
if key in seen:
continue
seen.add(key)
out.append((stk_start + o, name, off2))
for i, (sa, name, off2) in enumerate(out[:45]):
tag = ""
if "cards" in name.lower():
tag = " <-- CardsDLL ghidra %#x" % (0x180000000 + off2)
print(" [%2d] %#x %s+%#x%s" % (i, sa, name, off2, tag))
+96
View File
@@ -0,0 +1,96 @@
#!/usr/bin/env python3
"""Dump the DECRYPTED FIFA17 login machinery from live /proc/PID/mem.
FIFA17.exe's .data-region code is packed/encrypted on disk (objdump of the file is
garbage); the real instructions only exist decrypted in memory at runtime. This grabs
generous windows around the known login-path VAs (from prior live recon) plus the
resolved OriginMgr / session objects, then disassembles each window at its true VA so a
follow-up reversing pass works on real code.
Run while FIFA17 is running (ptrace_scope=0). Output -> ./login_dump/ + manifest.txt.
"""
import glob, os, subprocess, struct, sys
OUT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "login_dump")
os.makedirs(OUT, exist_ok=True)
# (name, VA, bytes_before, bytes_after) — code windows around the login machinery.
CODE = [
("dispatch_case2", 0x146f1e080, 0x120, 0x180), # event dispatcher; case-2 sets m_isLoggedIn
("event_matcher", 0x147102880, 0x40, 0x400), # sender/element matcher
("login_parser", 0x147138660, 0x40, 0x400), # <Login> element parser (reads IsLoggedIn)
("loginstate_pclogin", 0x1471b58e0, 0x40, 0x600), # LoginStatePCLogin entry
("txt_not_login_ebisu", 0x1471b5b00, 0x40, 0x400), # TXT_NOT_LOGIN_TO_EBISU write site(s)
("pclogin_callsite", 0x1471b6780, 0x60, 0x120), # session-object call: ff 50 60 (vtbl+0x60)
]
# Data pointers to resolve (name, ptr_VA, deref_chain_offsets, dump_span).
# We read *[ptr_VA], then optionally add offsets, then dump `span` bytes there.
DATA = [
("originmgr", 0x1448acf50, [], 0x80), # OriginMgr; m_isLoggedIn @+0x13, loginError @+0x14
("online_flags", 0x1448a3ac0, [], 0x40), # "internet reachable" byte lives here
("auth_block", 0x1448a3b20, [0x4e98], 0x60), # auth slots +0x08/+0x10
("session_obj", 0x144b86bf8, [], 0x80), # LoginStatePCLogin session object (vtbl @+0)
]
def find_pid():
for d in glob.glob('/proc/[0-9]*'):
try:
if open(d + '/comm').read().strip() == 'FIFA17.exe':
return int(os.path.basename(d))
except Exception:
pass
return None
def read(f, va, n):
f.seek(va); return f.read(n)
def rd_u64(f, va):
b = read(f, va, 8)
return struct.unpack('<Q', b)[0] if len(b) == 8 else 0
def disasm(path, va):
asm = path + ".asm"
with open(asm, "w") as out:
subprocess.run(["objdump", "-D", "-b", "binary", "-m", "i386:x86-64",
"-M", "intel", "--adjust-vma=%#x" % va, path],
stdout=out, stderr=subprocess.DEVNULL)
return asm
def main():
pid = find_pid()
if not pid:
print("FIFA17.exe not running — launch it first."); sys.exit(1)
man = open(os.path.join(OUT, "manifest.txt"), "w")
man.write("FIFA17 login-machinery dump pid=%d\n\n" % pid)
with open(f"/proc/{pid}/mem", "rb") as f:
for name, va, before, after in CODE:
start = va - before
data = read(f, start, before + after)
p = os.path.join(OUT, f"{name}_{start:x}.bin")
open(p, "wb").write(data)
disasm(p, start)
line = f"CODE {name:22s} window {start:#x}..{start+len(data):#x} ({len(data)}B) -> {os.path.basename(p)}[.asm]"
print(line); man.write(line + "\n")
man.write("\n")
for name, ptr, chain, span in DATA:
base = rd_u64(f, ptr)
addr = base
trail = f"*[{ptr:#x}]={base:#x}"
for off in chain:
nxt = rd_u64(f, addr + off) if off and base else base
# for a single deref-with-offset we dump AT base+off, not deref again:
addr = base
target = base + (chain[0] if chain else 0)
data = read(f, target, span) if base else b""
p = os.path.join(OUT, f"{name}_{target:x}.bin")
open(p, "wb").write(data)
# hex preview
hexp = " ".join("%02x" % x for x in data[:0x40])
line = f"DATA {name:22s} {trail} dump@{target:#x} ({len(data)}B) -> {os.path.basename(p)}\n first64: {hexp}"
print(line); man.write(line + "\n")
man.close()
print("\nWrote dumps + manifest to", OUT)
if __name__ == "__main__":
main()
@@ -0,0 +1,175 @@
#!/usr/bin/env python3
"""Extract the set of REAL FIFA 17 playerids (with names) from the game's own files.
WHY THIS EXISTS
---------------
FUT cards render generic because the client resolves identity from its LOCAL
`players` table, keyed by `playerid = resourceId & 0xffffff` (see docs/CARD_SYSTEM.md).
An id that is not in that table produces the MISS fingerprint: rating 0x32 (50),
teamid 0x78d (1933), nation 0xe (14), position 2, all attributes 1, name " ".
So the one thing the server needs is a list of playerids that actually exist.
WHERE THE IDS COME FROM
-----------------------
Frostbite bundle indexes. `Data/Win32/contentsb.{toc,sb}`, `contentlaunchsb.{toc,sb}`
and their `Update/Patch/` counterparts store asset paths as plain ASCII, and the
player face assets are named:
content/character/player/player_<bucket>/<name>_<playerid>_starhead_brt
content/character/player/player_<bucket>/<name>_<playerid>/hair_<playerid>_0_0_...
<bucket> is floor(playerid/500)*500, which this script uses as a self-check: an id is
only accepted if it falls inside its own directory's bucket. No decryption, no
Frostbite parsing, no cas archives -- the paths are literally in the clear in the
index files. dbdata.dll is NOT involved (its single export `getTableData` is an
anti-tamper attestation routine; see dbdata_probe.c).
COVERAGE, STATED HONESTLY
-------------------------
This yields every player who has a scanned STARHEAD (real face) asset: 1677 ids in
this install. That is NOT the whole `players` table (~18k rows including generic-face
players) -- it is the subset with real faces, which is also the subset whose cards
look best. Getting the full table needs the encrypted dbdata.dll payload or a live
memory read, neither of which this script attempts.
The extracted names are ASSET FILE names (lowercase, ASCII-folded, e.g.
`cristiano_ronaldo`), not the client's display names. You do not need them for the
wire: on a DB hit the client writes the display name, the face, and -- if you send
them as ZERO -- the nation and teamid itself. Only rating, position and the six
attributes are left as the server sent them. So the id alone buys a correct card.
ANCHOR CHECK
------------
playerid 20801 must map to cristiano_ronaldo. The script fails loudly if it does not.
WHAT THIS ALREADY CORRECTED IN fut_cards.VERIFIED_ASSET_IDS
----------------------------------------------------------
16 of the 18 ids there are confirmed by this extract. Two are not:
* 169193 was labelled "Alonso". This build ships
`player_45000/xabi_alonso_45197_launch_starhead_brt`, so Xabi Alonso is 45197
here. 169193 is not him; it may or may not be some other valid row.
* 200389 was labelled "Oblak". No `oblak` asset and no `200389` string appears
anywhere in the bundle indexes, so it is unconfirmed.
Absence from this list is NOT proof an id is invalid -- players without a scanned
face have no starhead asset but are still in the `players` table. This list is a
lower bound on the valid id set, not the id set.
Also note playerid 0 (`chris_head`) is a developer placeholder head; drop it before
using the list as a card pool.
USAGE
-----
./extract_player_ids.py # summary + anchor check
./extract_player_ids.py --tsv out.tsv # playerid<TAB>asset_name
./extract_player_ids.py --json out.json
FIFA17_DIR=/path/to/FIFA\\ 17 ./extract_player_ids.py
"""
import argparse
import json
import os
import re
import sys
GAME_DIR = os.environ.get("FIFA17_DIR", "/mnt/games/FIFA 17")
# Only the bundle indexes hold plaintext paths; the 30GB of .cas archives do not
# need to be touched.
INDEX_SUFFIXES = (".toc", ".sb")
SCAN_ROOTS = ("Data", "Update")
PLAYER_PATH = re.compile(rb"content/character/player/player_(\d+)/([a-z0-9_\-\.]+)")
NAMED_LEAF = re.compile(r"^([a-z][a-z_\-\.]*?)_(\d+)(?:_launch)?(?:_starhead_brt)?$")
HAIR_LEAF = re.compile(r"^hair_(\d+)_")
BUCKET = 500 # player_9500/ holds playerids 9500..9999
ANCHOR = (20801, "cristiano_ronaldo")
def index_files(game_dir):
out = []
for root_name in SCAN_ROOTS:
base = os.path.join(game_dir, root_name)
if not os.path.isdir(base):
continue
for root, _dirs, files in os.walk(base):
for f in files:
if f.endswith(INDEX_SUFFIXES):
out.append(os.path.join(root, f))
return sorted(out)
def extract(game_dir):
"""-> (names: {playerid: asset_name}, faceless: set[playerid], stats: dict)"""
names, faceless = {}, set()
leaves = set()
files = index_files(game_dir)
for path in files:
try:
data = open(path, "rb").read()
except OSError as exc:
print(" skip %s: %s" % (path, exc), file=sys.stderr)
continue
for m in PLAYER_PATH.finditer(data):
leaves.add((int(m.group(1)), m.group(2).decode("ascii", "replace")))
unmatched = 0
for bucket, leaf in leaves:
m = NAMED_LEAF.match(leaf)
if m and bucket <= int(m.group(2)) < bucket + BUCKET:
names.setdefault(int(m.group(2)), m.group(1))
continue
m = HAIR_LEAF.match(leaf)
if m and bucket <= int(m.group(1)) < bucket + BUCKET:
faceless.add(int(m.group(1)))
continue
unmatched += 1
stats = {"index_files": len(files), "leaf_paths": len(leaves),
"unmatched_leaves": unmatched}
return names, faceless - set(names), stats
def main():
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--tsv")
ap.add_argument("--json")
ap.add_argument("--dir", default=GAME_DIR)
args = ap.parse_args()
if not os.path.isdir(args.dir):
sys.exit("game dir not found: %s (set FIFA17_DIR)" % args.dir)
names, extra, stats = extract(args.dir)
if not names:
sys.exit("no player asset paths found under %s -- wrong dir?" % args.dir)
pid, expect = ANCHOR
got = names.get(pid)
if got != expect:
sys.exit("ANCHOR CHECK FAILED: playerid %d -> %r, expected %r. "
"The parse is wrong, not the game." % (pid, got, expect))
print("scanned %d bundle index files, %d player asset leaf paths"
% (stats["index_files"], stats["leaf_paths"]))
print("playerids with a real starhead: %d (id range %d..%d)"
% (len(names), min(names), max(names)))
print("hair-only playerids (no named face asset): %d" % len(extra))
print("unmatched leaf paths: %d" % stats["unmatched_leaves"])
print("anchor OK: %d -> %s" % (pid, got))
if args.tsv:
with open(args.tsv, "w") as fh:
fh.write("playerid\tasset_name\n")
for k in sorted(names):
fh.write("%d\t%s\n" % (k, names[k]))
print("wrote %s" % args.tsv)
if args.json:
with open(args.json, "w") as fh:
json.dump({str(k): names[k] for k in sorted(names)}, fh, indent=1)
print("wrote %s" % args.json)
if __name__ == "__main__":
main()
@@ -0,0 +1,55 @@
#!/usr/bin/env python3
"""Continuously PIN OriginMgr.m_isLoggedIn = 1 (and clear loginError) from the
earliest moment OriginMgr exists, for the whole FIFA17 session.
Rationale (2026-07-31 live finding): setting the flag AFTER boot does nothing --
FIFA decides logged-out during its boot Blaze handshake (sends Authentication::
logout 1/0x46, never login 1/0x0A) and never re-auths. This pins the flag to 1
FROM BOOT so it is already set when FIFA does that handshake. Run BEFORE launching
FIFA; it auto-attaches to each FIFA17.exe and re-pins fast enough to win the race.
Needs ptrace_scope=0 (already set by root_arm.sh). Idempotent, harmless.
"""
import glob, os, struct, time
ORIGINMGR_PP = 0x1448acf50 # *(void**)0x1448acf50 -> OriginMgr
OFF_LOGGEDIN = 0x13 # OriginMgr.m_isLoggedIn (u8)
OFF_LOGINERR = 0x14 # OriginMgr.loginError (u32)
def find_pid():
for d in glob.glob('/proc/[0-9]*'):
try:
if open(d + '/comm').read().strip() == 'FIFA17.exe':
return int(os.path.basename(d))
except Exception:
pass
return None
def main():
print("[pin] waiting for FIFA17.exe (pin m_isLoggedIn=1 from boot)...", flush=True)
last_pid = None
first_pin = False
while True:
pid = find_pid()
if not pid:
last_pid = None; first_pin = False; time.sleep(0.2); continue
if pid != last_pid:
print(f"[pin] FIFA17.exe pid={pid}", flush=True); last_pid = pid; first_pin = False
try:
with open(f"/proc/{pid}/mem", "r+b") as f:
f.seek(ORIGINMGR_PP); om = struct.unpack('<Q', f.read(8))[0]
if om:
f.seek(om + OFF_LOGGEDIN); cur = f.read(1)
if cur != b'\x01':
f.seek(om + OFF_LOGGEDIN); f.write(b'\x01')
f.seek(om + OFF_LOGINERR); f.write(b'\x00\x00\x00\x00')
if not first_pin:
print(f"[pin] OriginMgr={om:#x} -> m_isLoggedIn PINNED=1 "
f"(was {cur[0] if cur else '?'})", flush=True)
first_pin = True
except Exception:
pass
time.sleep(0.02) # 50 Hz: fast enough to win the boot race + hold it
if __name__ == "__main__":
main()
@@ -0,0 +1,92 @@
#!/usr/bin/env python3
"""Forge a FifaOnline::FirstPartyAuthCodeFutureImpl node and enqueue it so DoTick
(@0x146f199c0) fires GetAuthCode over LSX. Clean-room; from our own RE (ENQUEUE_PLAN.md,
adversarially verified). ptrace_scope=0 required. LSX responder MUST be answering
GetAuthCode first (the auth call is synchronous with a 15s timeout).
Two gates (both live-verified 0): the retriever queue slot AND OriginSDK[+0x3a0] default-user.
This sets BOTH. Treat the first run as a PROBE: setting the default user flips ~15 other
GetDefaultUser consumers; `--revert` restores the originals.
Usage: python3 forge_node.py # forge + enqueue
python3 forge_node.py --revert # restore SDK default-user + clear the slot
"""
import sys, struct, glob, os, json
ONLINEMGR_PP = 0x1448a3b20 # *-> OnlineManager ; retriever = +0x4e98
RETR_OFF = 0x4e98
GUARD = 0x1448a3ac3 # enqueue guard byte (must be 1)
SDK_PP = 0x144b7c7a0 # *-> OriginSDK
SDK_DEFUSER = 0x3a0 # default-user slot (BLOCKER) -- set +0x3a0 AND +0x3a8
SDK_DEREF = 0x3b0 # deref'd unconditionally downstream; must stay non-null
VPTR = 0x1438f5d58 # node primary vtable (AddRef/Release/dtor/GetStatus/GetResult)
VPTR2 = 0x1438f5d90 # node secondary vtable -- MUST be this, never 0 (Release calls [this+8]->[0])
NODE_VA = 0x14300a380 # validated zero/unreferenced scratch (ENQUEUE_PLAN §4) -- re-checked below
CLIENTID = b"FIFA17PC" # only proven constraint: non-empty
SAVE = "/tmp/forge_node_orig.json"
def pid():
for d in glob.glob('/proc/[0-9]*'):
try:
if open(d+'/comm').read().strip()=='FIFA17.exe': return int(d.split('/')[-1])
except Exception: pass
raise SystemExit("FIFA17.exe not running")
def build_node():
b = bytearray(0xF0)
struct.pack_into('<Q', b, 0x00, VPTR)
struct.pack_into('<Q', b, 0x08, VPTR2)
struct.pack_into('<I', b, 0x10, 2) # refcount=2 -> survives DoTick's Release, never freed
b[0x18:0x18+len(CLIENTID)] = CLIENTID # inline clientId, NUL-terminated
return bytes(b)
def main():
p = pid(); mp = f"/proc/{p}/mem"
f = open(mp, "r+b")
def rq(va): f.seek(va); return struct.unpack('<Q', f.read(8))[0]
def rd(va,n): f.seek(va); return f.read(n)
def wr(va,b): f.seek(va); f.write(b)
onlinemgr = rq(ONLINEMGR_PP); retr = onlinemgr + RETR_OFF
sdk = rq(SDK_PP)
slot = retr + 0x08
if "--revert" in sys.argv:
orig = json.load(open(SAVE)) if os.path.exists(SAVE) else {}
wr(sdk+SDK_DEFUSER, struct.pack('<Q', orig.get("defuser",0)))
wr(sdk+0x3a8, struct.pack('<Q', orig.get("defuser8",0)))
wr(slot, struct.pack('<Q', orig.get("slot",0)))
print(f"[revert] SDK+0x3a0/0x3a8 -> {orig.get('defuser',0):#x}/{orig.get('defuser8',0):#x}, slot -> {orig.get('slot',0):#x}")
return
# --- preconditions (verify, do not assume) ---
assert rd(GUARD,1)[0] == 1, "guard byte != 1"
assert rq(sdk+SDK_DEREF) != 0, "SDK+0x3b0 is NULL (would fault downstream) -- abort"
assert rq(slot) == 0, f"queue slot already non-zero ({rq(slot):#x}) -- abort"
scratch = rd(NODE_VA, 0xF0)
assert all(x==0 for x in scratch), "scratch NODE_VA not zero -- abort"
assert rq(VPTR) == 0x147e8f160, "node vtable[0] mismatch -- wrong build?"
# save originals for --revert
json.dump({"defuser": rq(sdk+SDK_DEFUSER), "defuser8": rq(sdk+0x3a8), "slot": rq(slot)}, open(SAVE,"w"))
# 1) forge the node into scratch (BEFORE anything is armed)
wr(NODE_VA, build_node())
assert rd(NODE_VA,0xF0) == build_node(), "node write-back mismatch"
print(f"[+] node forged @ {NODE_VA:#x} clientId={CLIENTID.decode()} refcount=2")
# 2) gate 2: set the Origin default user (both slots, like the real SDK)
wr(sdk+SDK_DEFUSER, struct.pack('<Q', sdk))
wr(sdk+0x3a8, struct.pack('<Q', sdk))
assert rq(sdk+SDK_DEFUSER)==sdk and rq(sdk+0x3a8)==sdk, "defuser write-back mismatch"
print(f"[+] OriginSDK[+0x3a0]/[+0x3a8] set -> {sdk:#x} (default user)")
# 3) gate 1 (the TRIGGER, set last): enqueue the node
wr(slot, struct.pack('<Q', NODE_VA))
assert rq(slot)==NODE_VA, "slot write-back mismatch"
print(f"[+] retriever+0x8 ({slot:#x}) -> {NODE_VA:#x} *** ENQUEUED ***")
print(" Watch /tmp/lsx.log for <GetAuthCode ClientId=\"FIFA17PC\">. Then node+0xE8 -> 1,")
print(" node+0xE0=200 = error (read node+0x58 msg). --revert to undo.")
if __name__ == "__main__":
main()
@@ -0,0 +1,689 @@
#!/usr/bin/env python3
"""Central ACCOUNT config for the FIFA 17 offline stack (OpenFUT, clean-room).
ONE source of truth for the identity every layer has to agree on. Before this
module the same persona id / display name / namespace literals were copy-pasted
into blaze_responder_v3b.py, lsx_responder_v2.py, fut_store.py, fut_seed.py and
utas_server.py -- five files, seven copies. The stack only works while all of
them agree, so the copies were a silent drift surface.
THE REAL CONSTRAINT IS CROSS-LAYER CONSISTENCY, NOT ANY PARTICULAR VALUE.
Blaze LoginResponse.SESS.PDTL, LSX GetProfileResponse and the UTAS
userInfo/squad bodies must all assert the SAME persona. That is why they
now all read this module instead of their own literal.
(The older comments in blaze/lsx claimed DSNM "MUST be CAGE" and PID "MUST
be 33068179", justified by AUTH_ERR_INVALID_PERSONA. That justification is
wrong on two counts: those are Blaze *server* error codes and we are the
server, and neither literal appears anywhere in FIFA17.exe / CardsDLL /
dbdata.dll. 33068179 occurs only inside stp-origin_emu.dll, as that emu's
own ini default. The values are kept as DEFAULTS because they are what the
currently-working stack asserts -- not because the client demands them.)
THREE TIERS
1. LOCKED wire constants -- module-level, no env, never persisted. These are
baked into the binaries or into EA's own catalogue; changing them is a
protocol change, not a preference.
2. IDENTITY -- persona id / display name / email / locale. Env-overridable,
persisted.
3. CLUB -- club name / abbreviation / established year / squad name.
Env-overridable, persisted. This tier is the offline, crash-free way to
name your club (the in-game rename path is a separate, gated experiment).
PRECEDENCE for tiers 2 and 3: env var > fut_account.json > built-in default.
PERSISTENCE
Its own file, tools/fut_account.json (override with FUT_ACCOUNT_PATH), NOT the
game save. Two reasons: the account must survive deleting fifa17_profile.json
to reset progress, and blaze/lsx must be able to import this module without
dragging in the profile store. On first load, if fut_account.json is absent
and fifa17_profile.json exists, the identity/club values are MIGRATED out of
it so an existing club name is never lost.
CLI (this is the safe club-rename path -- offline, no client involvement):
python3 tools/fut_account.py --show
python3 tools/fut_account.py --club-name 'Real OpenFUT' --club-abbr ROF
Then restart the harness. See --help.
"""
import json
import os
import sys
import threading
HERE = os.path.dirname(os.path.abspath(__file__))
ACCOUNT_PATH = os.environ.get("FUT_ACCOUNT_PATH", os.path.join(HERE, "fut_account.json"))
# Legacy home of these values; read once for migration, never written by us.
LEGACY_PROFILE_PATH = os.environ.get("FUT_PROFILE", os.path.join(HERE, "fifa17_profile.json"))
_LOCK = threading.RLock()
# ====================================================================== tier 1
# LOCKED WIRE CONSTANTS. No env override on purpose: these are not preferences.
# Each carries its provenance -- do not "clean up" a value without re-deriving it.
NAMESPACE = "cem_ea_id"
"""Persona namespace. Baked into FIFA17.exe (file offset 0x36b748) in the
BlazeSDK platform->namespace default table; exactly one occurrence. Must equal
PreAuthResponse.NASP and every later NASP/NSNM/ASRC we emit."""
PLATFORM = "pc"
"""Wire platform string. The client sends nucleusPersonaPlatform="pc" in its own
POST /ut/auth body -- we echo it, we do not choose it."""
CLIENT_PLATFORM = 4
"""Blaze::ClientPlatformType enum value for pc."""
SKU = "FFA17PCC"
"""CardsDLL FUN_180125900 literal @0x1802201e0; also the "game/<sku>" URL segment."""
TITLE_ID = "309111"
CLIENT_ID = "FIFA17-PC-SERVER-BLAZE"
CONTENT_ID = "1027460"
"""FIFA 17 EA offer id (retail)."""
ENTITLEMENT_TAG = "ONLINE_ACCESS"
"""TRIAL_ONLINE_ACCESS for FIFA17_Trial.exe."""
ENTITLEMENT_GROUP = "FIFA17PCBoxContent"
"""strstr needle @0x144334030. EntitlementComponent::onListEntitlements
(0x146f27440) keeps an entitlement only if GNAM contains "FIFA17PCBoxContent" or
"FIFA16PC", TAG is non-empty and STAT==1. Plain "FIFA17PC" matched neither and
produced an empty store."""
PERSONA_STATUS = 2
"""PersonaStatus::Code ACTIVE (verified live: table 0x14487ad20)."""
USER_SESSION_TYPE = 0
"""Blaze::UserSessionType -> normal/console user."""
_LOCKED = ("NAMESPACE", "PLATFORM", "CLIENT_PLATFORM", "SKU", "TITLE_ID",
"CLIENT_ID", "CONTENT_ID", "ENTITLEMENT_TAG", "ENTITLEMENT_GROUP",
"PERSONA_STATUS", "USER_SESSION_TYPE")
# ================================================================ tiers 2 + 3
# field -> (env var, default). Only these keys are ever persisted.
_FIELDS = {
# tier 2: identity
"persona_id": ("FUT_PERSONA_ID", 33068179),
"persona_name": ("FUT_PERSONA_NAME", "CAGE"),
"email": ("FUT_ACCOUNT_EMAIL", None), # None -> derived from persona_name
"locale": ("FUT_LOCALE", "en_US"),
"country": ("FUT_COUNTRY", "US"),
"currency": ("FUT_CURRENCY", "USD"),
# tier 3: club
"club_name": ("FUT_CLUB_NAME", "OpenFUT"),
"club_abbr": ("FUT_CLUB_ABBR", "OFC"),
"established": ("FUT_ESTABLISHED", "2026"),
"squad_name": ("FUT_SQUAD_NAME", "OpenFUT"),
# tier 4: the ONLINE (EASFC/POW) profile -- what the top-right hub bar shows.
# Served by pow_server.py; key names below are the literal strings powdll's
# parsers compare against (see pow_server.py for addresses), so these map 1:1
# onto the wire:
# pow_level -> "level", pow_exp -> "exp" (widget renders exp/expMax)
# pow_funds -> EASFC credits, the coin counter next to the cart
"pow_level": ("FUT_POW_LEVEL", 1),
"pow_exp": ("FUT_POW_EXP", 0),
"pow_exp_max": ("FUT_POW_EXP_MAX", 1000), # currLevelExpMax
"pow_funds": ("FUT_POW_FUNDS", 0), # EASFC credit balance
"pow_funds_cap": ("FUT_POW_FUNDS_CAP", 100000),
}
_INT_FIELDS = ("persona_id", "pow_level", "pow_exp", "pow_exp_max",
"pow_funds", "pow_funds_cap")
# Club-name limits, reversed from CardsDLL:
# * clubAbbr 1..3 -- the client's own write-back after a successful rename is
# FUN_180007f80(rec+0x3e, 4, "%s", abbr), a FOUR-BYTE buffer (handler
# FUN_1800829c0), so 4+ chars truncate.
# * clubName 5..15 -- view-model builder FUN_180082c30 carries
# name_min_length=5, name_max_length=0xf, abbr_max_length=3. The userInfo
# write-back buffer at rec+0x20 is 30 bytes, so 15 is the binding constraint.
CLUB_NAME_MIN = 5
CLUB_NAME_MAX = 15
CLUB_ABBR_MIN = 1
CLUB_ABBR_MAX = 3
# CardsDLL FUN_180125900 uses this literal as the display name when the OSDK
# online-user object is NULL. Seeing it means "the client has no identity", not
# "the user is called mememe" -- never adopt it.
NULL_IDENTITY_NAME = "mememe"
def validate_club(name, abbr, established=None):
"""Validate club identity against the client's own limits.
Returns (name, abbr) -- or (name, abbr, established) when `established` is
passed. Raises ValueError with a message naming the reversed constraint.
"""
if not isinstance(name, str):
raise ValueError("clubName must be a string, got %r" % type(name).__name__)
if not isinstance(abbr, str):
raise ValueError("clubAbbr must be a string, got %r" % type(abbr).__name__)
name = name.strip()
abbr = abbr.strip()
if not (CLUB_NAME_MIN <= len(name) <= CLUB_NAME_MAX):
raise ValueError(
"clubName %r is %d chars; must be %d..%d (view-model FUN_180082c30 "
"name_min_length=5 name_max_length=0xf)"
% (name, len(name), CLUB_NAME_MIN, CLUB_NAME_MAX))
if not (CLUB_ABBR_MIN <= len(abbr) <= CLUB_ABBR_MAX):
raise ValueError(
"clubAbbr %r is %d chars; must be %d..%d (write-back buffer at "
"userInfo+0x3e is 4 bytes -> FUN_180007f80(dst,4,\"%%s\",abbr))"
% (abbr, len(abbr), CLUB_ABBR_MIN, CLUB_ABBR_MAX))
if established is None:
return name, abbr
est = established
if isinstance(est, int) and not isinstance(est, bool):
est = str(est)
# userInfo deser 0x18013ec10 case 0x110 takes the STRING getter then strtol
# base 10 into rec+0x64. An int on the wire here is the scalar/string type
# mismatch class that busy-loops the SAX reader at 0x1801c7f1a.
if not isinstance(est, str) or not est.isdigit():
raise ValueError("established must be a STRING of digits (deser "
"0x18013ec10 case 0x110 -> strtol base 10), got %r"
% (established,))
return name, abbr, est
class Account:
"""Mutable singleton; see module docstring for the tier/precedence rules."""
# tier 1 re-exported as attributes so call sites can just use ACCOUNT.X
NAMESPACE = NAMESPACE
PLATFORM = PLATFORM
CLIENT_PLATFORM = CLIENT_PLATFORM
SKU = SKU
TITLE_ID = TITLE_ID
CLIENT_ID = CLIENT_ID
CONTENT_ID = CONTENT_ID
ENTITLEMENT_TAG = ENTITLEMENT_TAG
ENTITLEMENT_GROUP = ENTITLEMENT_GROUP
PERSONA_STATUS = PERSONA_STATUS
USER_SESSION_TYPE = USER_SESSION_TYPE
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)
# ------------------------------------------------------------ persistence
def load(self, force=False):
"""Idempotent. Reads fut_account.json, migrating from the legacy game
save the first time. Never raises on a malformed file -- a broken
account file must not stop the harness booting."""
with _LOCK:
signature = self._signature()
if self._loaded and not force and signature == self._file_signature:
return self
stored = {}
if os.path.exists(self.path):
try:
with open(self.path) as f:
raw = json.load(f)
if isinstance(raw, dict):
stored = {k: v for k, v in raw.items() if k in _FIELDS}
except (OSError, ValueError) as e:
sys.stderr.write("[account] WARN: ignoring unreadable %s (%s)\n"
% (self.path, e))
else:
stored = self._migrate_from_profile()
if stored:
self._stored = stored
try:
self._write()
except OSError as e:
# A read-only tools/ must not stop a server booting; the
# migrated values still apply for this process.
sys.stderr.write("[account] WARN: could not write %s (%s)\n"
% (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
save is never modified, and its copies stay there harmlessly."""
if not os.path.exists(LEGACY_PROFILE_PATH):
return {}
try:
with open(LEGACY_PROFILE_PATH) as f:
p = json.load(f)
except (OSError, ValueError):
return {}
if not isinstance(p, dict):
return {}
out = {}
for src, dst in (("personaId", "persona_id"), ("personaName", "persona_name"),
("clubName", "club_name"), ("clubAbbr", "club_abbr"),
("established", "established")):
if p.get(src) not in (None, ""):
out[dst] = p[src]
if out:
sys.stderr.write("[account] migrated %s from %s\n"
% (",".join(sorted(out)), os.path.basename(LEGACY_PROFILE_PATH)))
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,
plus anything already stored). Tier 1 is never written."""
with _LOCK:
self.load()
for f in _FIELDS:
v = getattr(self, "_" + f)
if v is not None:
self._stored[f] = v
self._write()
return self
# ------------------------------------------------------------ field access
def _get(self, field):
self.load()
env, default = _FIELDS[field]
v = getattr(self, "_" + field)
if v is None:
v = os.environ.get(env)
if v is None:
v = self._stored.get(field)
if v is None:
v = default
if field in _INT_FIELDS and v is not None:
v = int(v)
return v
def _set(self, field, value):
with _LOCK:
self.load()
if field in _INT_FIELDS:
value = int(value)
setattr(self, "_" + field, value)
# tier 2 ---------------------------------------------------------------
@property
def persona_id(self):
"""Blaze SESS.BUID / SESS.UID / PDTL.PID, LSX PersonaId/UserId, UTAS
userInfo.personaId and squad.personaId. UNVERIFIED KNOB: REPACK_INTEL
Section 2.2 records the repack's decrypted .dlf license carrying
<UserId>33068179</UserId> (consumed by dbdata.dll!getTableData). No .dlf
exists on disk any more, so changing this cannot be re-checked
statically -- treat it as a deliberate single-variable experiment."""
return self._get("persona_id")
@persona_id.setter
def persona_id(self, v):
self._set("persona_id", v)
@property
def persona_name(self):
"""Blaze PDTL.DSNM / LSX GetProfileResponse Persona / UTAS sellerName."""
return self._get("persona_name")
@persona_name.setter
def persona_name(self, v):
v = str(v).strip()
if not v:
raise ValueError("persona_name must not be empty")
self._set("persona_name", v)
@property
def email(self):
"""Blaze SESS.MAIL / AccountInfo.MAIL. Derived from persona_name when unset."""
v = self._get("email")
return v if v else "%s@openfut.local" % self.persona_name.lower()
@email.setter
def email(self, v):
self._set("email", v)
@property
def locale(self):
return self._get("locale")
@locale.setter
def locale(self, v):
self._set("locale", v)
@property
def country(self):
return self._get("country")
@property
def currency(self):
return self._get("currency")
# DERIVED, read-only. Deliberately NOT independent knobs: the client sends
# both `nuc` and `nucleusPersonaId` and both came out equal, so the
# getter->field mapping is undetermined. Do not split them until a live test
# proves Blaze USER_ID/EXT_ID may legitimately differ from PERSONA_ID.
@property
def user_id(self):
"""Blaze blazeId / userId (SESS.BUID, SESS.UID, AccountInfo.UID)."""
return self.persona_id
@property
def ext_id(self):
"""Blaze XREF / EXID externalId."""
return self.persona_id
@property
def locale_dash(self):
""""en-US" form, as the client sends it in POST /ut/auth."""
return self.locale.replace("_", "-")
@property
def account_locale_int(self):
"""Packed 4-char locale for Blaze AccountInfo; 'enUS' == 0x656E5553.
Overwritten per-session by the client's own PreAuthRequest LANG/LOC."""
s = (self.locale.replace("_", "") + "\0\0\0\0")[:4]
return int.from_bytes(s.encode("latin-1"), "big")
# tier 3 ---------------------------------------------------------------
@property
def club_name(self):
return self._get("club_name")
@club_name.setter
def club_name(self, v):
name, _ = validate_club(v, self.club_abbr)
self._set("club_name", name)
@property
def club_abbr(self):
return self._get("club_abbr")
@club_abbr.setter
def club_abbr(self, v):
_, abbr = validate_club(self.club_name, v)
self._set("club_abbr", abbr)
@property
def established(self):
"""STRING of digits -- see validate_club()."""
return str(self._get("established"))
@established.setter
def established(self, v):
_, _, est = validate_club(self.club_name, self.club_abbr, v)
self._set("established", est)
@property
def squad_name(self):
return self._get("squad_name")
@squad_name.setter
def squad_name(self, v):
self._set("squad_name", v)
def set_club(self, name=None, abbr=None, established=None):
"""Atomic validated club update. Raises ValueError before mutating
anything, so a rejected rename leaves the account untouched."""
with _LOCK:
n = self.club_name if name is None else name
a = self.club_abbr if abbr is None else abbr
e = self.established if established is None else established
n, a, e = validate_club(n, a, e)
self._set("club_name", n)
self._set("club_abbr", a)
self._set("established", e)
return n, a, e
# ------------------------------------------------- tier 4: online profile
@property
def pow_level(self):
return self._get("pow_level")
@property
def pow_exp(self):
return self._get("pow_exp")
@property
def pow_exp_max(self):
return self._get("pow_exp_max")
@property
def pow_funds(self):
return self._get("pow_funds")
@property
def pow_funds_cap(self):
return self._get("pow_funds_cap")
def set_online_profile(self, level=None, exp=None, exp_max=None,
funds=None, funds_cap=None):
"""Atomic validated update of the EASFC/POW profile (the top-right hub
bar: LVL x, the exp bar, and the credit counter).
Validation is deliberately light -- unlike the club fields there is no
reversed length/range check to cite, so we only enforce what is
structurally required: non-negative ints, and exp <= exp_max so the
widget cannot render a bar past 100%."""
with _LOCK:
lv = self.pow_level if level is None else int(level)
xp = self.pow_exp if exp is None else int(exp)
xm = self.pow_exp_max if exp_max is None else int(exp_max)
fu = self.pow_funds if funds is None else int(funds)
fc = self.pow_funds_cap if funds_cap is None else int(funds_cap)
if min(lv, xp, xm, fu, fc) < 0:
raise ValueError("online-profile values must be >= 0")
if lv < 1:
raise ValueError("pow_level must be >= 1")
if xm < 1:
raise ValueError("pow_exp_max must be >= 1")
if xp > xm:
raise ValueError("pow_exp (%d) exceeds pow_exp_max (%d)" % (xp, xm))
if fu > fc:
raise ValueError("pow_funds (%d) exceeds pow_funds_cap (%d)" % (fu, fc))
for k, v in (("pow_level", lv), ("pow_exp", xp), ("pow_exp_max", xm),
("pow_funds", fu), ("pow_funds_cap", fc)):
self._set(k, v)
return lv, xp, xm, fu, fc
# ------------------------------------------------------------- adoption
def adopt_from_auth(self, body):
"""Adopt the identity the client itself asserts in POST /ut/auth.
Live-observed body (three byte-identical runs; builder CardsDLL
FUN_180125900):
{"sku":"FFA17PCC","nucleusPersonaPlatform":"pc","nuc":33068179,
"nucleusPersonaId":33068179,"nucleusPersonaDisplayName":"CAGE",
"locale":"en-US","regionCode":"US",...}
RECONCILIATION RULE: the wire is truth, the stored JSON is a cache.
Adopt-and-overwrite with a WARN; never refuse -- a mismatch is the
NORMAL state on the first boot after a rename. Returns True if anything
changed. FUT_ADOPT_AUTH=0 disables adoption entirely.
Why it matters: the squad parser 0x18013d1f0 stores personaId (atom
0x21b) at squad+0x38 and compares it against
FUN_18011a830()->vtbl[0x908]; on mismatch it silently builds a throwaway
squad instead of erroring. Same comparison in FUN_1801464e0 for squad
summaries. Adopting makes that comparison correct by construction.
"""
if os.environ.get("FUT_ADOPT_AUTH") == "0":
return False
if not isinstance(body, dict):
return False
changed = []
pid = body.get("nucleusPersonaId", body.get("nuc"))
if isinstance(pid, (int, str)) and not isinstance(pid, bool):
try:
pid = int(pid)
except (TypeError, ValueError):
pid = None
if pid and pid != self.persona_id:
changed.append("persona %d -> %d" % (self.persona_id, pid))
self._set("persona_id", pid)
name = body.get("nucleusPersonaDisplayName")
if isinstance(name, str):
name = name.strip()
if name and name != NULL_IDENTITY_NAME and name != self.persona_name:
changed.append("name %r -> %r" % (self.persona_name, name))
self._set("persona_name", name)
loc = body.get("locale")
if isinstance(loc, str) and loc:
loc = loc.replace("-", "_")
if loc != self.locale:
changed.append("locale %r -> %r" % (self.locale, loc))
self._set("locale", loc)
if changed:
sys.stderr.write("[account] WARN: adopted from /ut/auth: %s\n"
% "; ".join(changed))
self.save()
return bool(changed)
# ------------------------------------------------------------------ misc
def as_dict(self):
"""Effective tier 2+3 values plus the derived ones (for --show / logs)."""
d = {f: getattr(self, f) for f in _FIELDS}
d["email"] = self.email # resolve the derived default
d.update(user_id=self.user_id, ext_id=self.ext_id,
locale_dash=self.locale_dash,
account_locale_int=self.account_locale_int)
return d
def locked(self):
return {k: globals()[k] for k in _LOCKED}
def __repr__(self):
return ("<Account persona=%d/%r club=%r/%r est=%s ns=%s>"
% (self.persona_id, self.persona_name, self.club_name,
self.club_abbr, self.established, self.NAMESPACE))
ACCOUNT = Account()
# Back-compat aliases so the old module-level names keep resolving where they
# are still imported. Prefer ACCOUNT.<field> in new code -- these are snapshots
# taken at import time and will NOT reflect a later adopt_from_auth().
PERSONA_ID = ACCOUNT.persona_id
PERSONA_NAME = ACCOUNT.persona_name
# ===================================================================== CLI
def _main(argv):
import argparse
ap = argparse.ArgumentParser(
prog="fut_account.py",
description="Inspect / edit the OpenFUT FIFA 17 account identity. "
"Editing the club here is the SAFE rename path: it is "
"offline, validated against the client's own limits, and "
"never involves the in-game rename flow. Restart the "
"harness after changing anything.")
ap.add_argument("--show", action="store_true", help="print the account and exit")
ap.add_argument("--club-name", help="club name (%d..%d chars)" % (CLUB_NAME_MIN, CLUB_NAME_MAX))
ap.add_argument("--club-abbr", help="club abbreviation (%d..%d chars)" % (CLUB_ABBR_MIN, CLUB_ABBR_MAX))
ap.add_argument("--established", help="founding year, digits only")
ap.add_argument("--squad-name", help="default squad name")
ap.add_argument("--persona-name", help="display name (Blaze DSNM / LSX Persona)")
ap.add_argument("--persona-id", type=int, help="persona id (UNVERIFIED knob; see docstring)")
ap.add_argument("--email", help="account email (Blaze MAIL)")
# tier 4: the online (EASFC/POW) profile shown in the top-right hub bar
ap.add_argument("--pow-level", type=int, help="online profile level (LVL)")
ap.add_argument("--pow-exp", type=int, help="online profile XP into the current level")
ap.add_argument("--pow-exp-max", type=int, help="XP needed for the next level")
ap.add_argument("--pow-funds", type=int, help="EASFC credits (the coin counter)")
ap.add_argument("--pow-funds-cap", type=int, help="EASFC credit cap")
ap.add_argument("--json", action="store_true", help="machine-readable output")
a = ap.parse_args(argv)
ACCOUNT.load()
dirty = False
try:
if a.club_name or a.club_abbr or a.established:
ACCOUNT.set_club(a.club_name, a.club_abbr, a.established)
dirty = True
if a.persona_name:
ACCOUNT.persona_name = a.persona_name
dirty = True
if a.persona_id:
ACCOUNT.persona_id = a.persona_id
dirty = True
if a.email:
ACCOUNT.email = a.email
dirty = True
if a.squad_name:
ACCOUNT.squad_name = a.squad_name
dirty = True
if any(v is not None for v in (a.pow_level, a.pow_exp, a.pow_exp_max,
a.pow_funds, a.pow_funds_cap)):
ACCOUNT.set_online_profile(a.pow_level, a.pow_exp, a.pow_exp_max,
a.pow_funds, a.pow_funds_cap)
dirty = True
except ValueError as e:
sys.stderr.write("error: %s\n" % e)
return 2
if dirty:
ACCOUNT.save()
print("saved %s" % ACCOUNT.path)
if a.json:
print(json.dumps({"account": ACCOUNT.as_dict(), "locked": ACCOUNT.locked()},
indent=1, sort_keys=True))
else:
d = ACCOUNT.as_dict()
print("account file : %s" % ACCOUNT.path)
print("-- identity (env-overridable, persisted) --")
for k in ("persona_id", "persona_name", "email", "locale", "country", "currency"):
print(" %-14s %s" % (k, d[k]))
print("-- club --")
for k in ("club_name", "club_abbr", "established", "squad_name"):
print(" %-14s %s" % (k, d[k]))
print("-- derived --")
for k in ("user_id", "ext_id", "locale_dash"):
print(" %-14s %s" % (k, d[k]))
print(" %-14s 0x%08x" % ("account_locale", d["account_locale_int"]))
print("-- locked wire constants (not settable) --")
for k, v in sorted(ACCOUNT.locked().items()):
print(" %-18s %s" % (k, v))
if dirty:
print("\nrestart the harness for this to take effect: ./openfut-fut.sh restart")
return 0
if __name__ == "__main__":
sys.exit(_main(sys.argv[1:]))
@@ -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", [])),
}
@@ -0,0 +1,118 @@
#!/usr/bin/env python3
"""OpenFUT save maintenance — inspect and repair fifa17_profile.json offline.
Exists because the packclub hand-off is not proven. Cards from an opened pack land
in the PENDING pile (`profile["purchased"]`) and only reach the club when the client
sends `PUT ut/%s/item` (FutMoveCard) from the reveal screen's "send to club". Across
every logged session that request has fired **zero** times, while 12 cards sit
pending so either the flow was never exercised in-game, or the client does not
issue it the way we assume. Same shape as the squad blocker: an assumed client
request that never actually arrives.
Until a live pack-open settles it, this is the manual path.
SAFETY: the client desyncs fatally (logout) if a card exists in BOTH the pending pile
and the club see docs/CARD_SYSTEM.md and Store.move_items. `--flush-purchased`
therefore MOVES (never copies): each card is removed from `purchased` in the same
transaction that appends it to `items`. Run it with FIFA CLOSED so the client cannot
be holding a stale view of either pile.
Usage:
fut_admin.py --show profile summary (default)
fut_admin.py --flush-purchased move every pending card into the club
fut_admin.py --flush-purchased -n dry run: show what would move
fut_admin.py --backup timestamped copy of the profile
"""
import argparse
import datetime
import json
import os
import shutil
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from fut_store import STORE # noqa: E402
def _fmt(it):
return "asset=%-7s rating=%-3s pos=%-4s id=%s" % (
it.get("assetId"), it.get("rating"), it.get("preferredPosition"), it.get("id"))
def show():
p = STORE.profile()
rec = p.get("record", {})
print("profile : %s" % STORE.path)
print("club : %s (%s) est %s" % (p.get("clubName"), p.get("clubAbbr"),
p.get("established")))
print("coins : %s points: %s" % (p.get("coins"), p.get("points")))
print("record : %s-%s-%s matches: %s"
% (rec.get("won", 0), rec.get("draw", 0), rec.get("loss", 0),
p.get("matchesPlayed", 0)))
print("club items : %d" % len(p.get("items", [])))
print("squads saved : %d" % len(p.get("squads", [])))
print("packs opened : %s" % p.get("packsOpened", 0))
print("listings : %d" % len(p.get("listings", [])))
print("clientdata : %s" % (sorted(p.get("clientdata", {})) or "none"))
pend = p.get("purchased", [])
print("PENDING pack items: %d%s"
% (len(pend), " <-- not in the club; see --flush-purchased" if pend else ""))
for it in pend[:20]:
print(" %s" % _fmt(it))
if len(pend) > 20:
print(" ... and %d more" % (len(pend) - 20))
def backup():
dst = "%s.%s.bak" % (STORE.path,
datetime.datetime.now().strftime("%Y%m%d-%H%M%S"))
shutil.copy2(STORE.path, dst)
print("backup -> %s" % dst)
return dst
def flush(dry_run):
pend = list(STORE.profile().get("purchased", []))
if not pend:
print("nothing pending — the club already has every pack card")
return 0
print("%d pending card(s)%s:" % (len(pend), " (DRY RUN)" if dry_run else ""))
for it in pend:
print(" %s" % _fmt(it))
if dry_run:
print("\ndry run — nothing written. Re-run without -n to move them.")
return 0
backup()
# Reuse the server's own move path so the pending/club invariant is enforced in
# exactly one place: move_items() deletes from `purchased` in the same locked
# transaction that appends to `items`.
moved = STORE.move_items([{"id": it["id"], "pile": "club"} for it in pend])
p = STORE.profile()
print("\nmoved %d card(s) into the club" % len(moved))
print("club items now: %d pending now: %d"
% (len(p.get("items", [])), len(p.get("purchased", []))))
if p.get("purchased"):
print("WARNING: %d card(s) did not move — ids missing from the pending pile"
% len(p["purchased"]))
return 0
def main(argv=None):
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--show", action="store_true", help="profile summary (default)")
ap.add_argument("--flush-purchased", action="store_true",
help="move pending pack cards into the club (run with FIFA closed)")
ap.add_argument("-n", "--dry-run", action="store_true", help="with --flush-purchased")
ap.add_argument("--backup", action="store_true", help="timestamped profile copy")
a = ap.parse_args(argv)
if a.backup:
backup()
if a.flush_purchased:
return flush(a.dry_run)
show()
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,250 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""The FUT card pool: the REAL FIFA 17 roster, 17,547 players.
WHAT CHANGED, AND WHY IT MATTERS
--------------------------------
This module used to hold 79 hand-written rows whose asset ids were mostly
invented, on the premise (from an older CARD_SYSTEM.md) that the client's card
map is empty offline so no id could ever render. That premise was wrong.
Card identity does not come from us. The client inserts every item we serve into
its CardsDb map and, just before that, merges in its OWN local players table
keyed on `resourceId & 0xffffff`. An invented id renders as a blank generic card;
a real one renders as a real player. Proven live: a pack showed SILVA and NOWAK
with real names, badges and flags beside three blanks at rating 50.
So the pool is now built from the game's own roster, extracted from a running
FIFA17.exe by tools/dbdata_extract.py into data/roster.json. It was cross-checked
against a completely independent method -- the sweep oracle in
tools/sweep_collect.py, which reads back the identity the CLIENT resolved -- and
573 of 573 overlapping names agreed exactly.
WHICH FIELDS ARE REAL AND WHICH ARE NOT. Be honest about this when reading a card:
playerid REAL data/roster.json
rating REAL same
name REAL resolved by the client from the id; we never send a name
club REAL we send teamid 0 and the client fills its own value
nation REAL we send nation 0, same mechanism
league REAL the client always recomputes leagueid on a DB hit
position PARTLY 59 ids are known (data/positions.json); the rest are
SYNTHETIC, assigned deterministically per id
attributes SYNTH derived from rating and position
The merge fills nation/teamid ONLY when they arrive as zero, and never touches
rating, position or attributes. That asymmetry is the whole design of this file:
send zero for everything the client knows better than us, and send our own value
only where the client has nothing.
THE MISSING COLUMNS, AND THE LEADS FOR THEM
-------------------------------------------
position / nationality / teamId / attributes are NOT in the rating index. Two
live sources exist and BOTH are per-materialised-card caches, not tables:
* the 0x180-stride resolved card records (attributes + names), and
* a 32-byte-stride keyed container, entries {playerId, position | hash<<32,
rating, ?}, found at 0x42e8dbe8 inside the 238 MB heap region.
data/positions.json comes from the second one. Sweeping 5,000 ids did NOT
populate it, so it caches what the game itself materialises rather than what we
ask about. 59 entries survived validation (each entry's rating had to match the
roster's). Anyone extending this: a full-roster position source has not been
found, and the FUT rating index does not contain one.
Position codes are the standard FIFA enum, decoded against our own club cards
read live: GK 0, CB 5, LB 7, CM 14, LM 16, RW 23, ST 25, LW 27.
"""
import json
import os
_HERE = os.path.dirname(os.path.abspath(__file__))
_DATA = os.path.join(_HERE, "..", "data")
# Position code -> the string the client's parser expects in preferredPosition.
POSITION_BY_CODE = {
0: "GK", 1: "SW", 2: "RWB", 3: "RB", 4: "CB", 5: "CB", 6: "CB",
7: "LB", 8: "LWB", 9: "CDM", 10: "CDM", 11: "CDM", 12: "RM",
13: "CM", 14: "CM", 15: "CM", 16: "LM", 17: "CAM", 18: "CAM",
19: "CAM", 20: "RF", 21: "CF", 22: "LF", 23: "RW", 24: "ST",
25: "ST", 26: "ST", 27: "LW",
}
# Synthetic-position distribution, shaped like a real squad (one keeper, four at
# the back, four in midfield, three forward) so a random pack looks like a
# football team rather than eleven strikers.
_SYNTH_POSITIONS = (["GK"] +
["RB", "CB", "CB", "LB"] +
["CDM", "CM", "CM", "CAM"] +
["RW", "ST", "LW"])
# Attribute profiles: (pace, shooting, passing, dribbling, defending, physical)
# as multipliers on the rating. The GK profile stands in for the six keeper
# stats the card face shows in that slot instead.
_PROFILE = {
"GK": (0.68, 0.70, 0.40, 0.66, 0.20, 0.68),
"RB": (1.02, 0.72, 0.90, 0.92, 1.00, 0.95),
"LB": (1.02, 0.72, 0.90, 0.92, 1.00, 0.95),
"RWB": (1.05, 0.75, 0.92, 0.95, 0.96, 0.92),
"LWB": (1.05, 0.75, 0.92, 0.95, 0.96, 0.92),
"CB": (0.82, 0.55, 0.78, 0.75, 1.05, 1.05),
"SW": (0.82, 0.55, 0.78, 0.75, 1.05, 1.05),
"CDM": (0.85, 0.75, 0.98, 0.92, 1.00, 1.00),
"CM": (0.90, 0.85, 1.02, 0.98, 0.88, 0.92),
"RM": (1.05, 0.88, 0.98, 1.02, 0.72, 0.85),
"LM": (1.05, 0.88, 0.98, 1.02, 0.72, 0.85),
"CAM": (0.95, 0.92, 1.02, 1.04, 0.62, 0.82),
"RW": (1.08, 0.92, 0.95, 1.05, 0.60, 0.80),
"LW": (1.08, 0.92, 0.95, 1.05, 0.60, 0.80),
"RF": (1.02, 0.98, 0.95, 1.04, 0.58, 0.85),
"LF": (1.02, 0.98, 0.95, 1.04, 0.58, 0.85),
"CF": (1.00, 1.00, 0.95, 1.02, 0.58, 0.88),
"ST": (1.00, 1.05, 0.85, 0.98, 0.45, 0.95),
}
def _load(name, default):
try:
with open(os.path.join(_DATA, name)) as f:
return json.load(f)
except (IOError, ValueError):
return default
ROSTER = _load("roster.json", [])
KNOWN_POSITIONS = {int(k): v for k, v in _load("positions.json", {}).items()}
# data/pool.json -- the REAL thing, and it supersedes everything below it.
#
# Built 2026-08-05 from the game's own resident database (data/tables/*.json, dumped
# read-only by tools/db_dump.py, then tools/build_player_facts.py). Per player it
# carries the MEASURED position (players.preferredposition1), nationality, teamid,
# leagueid (via leagueteamlinks) and the six card attributes.
#
# The six attributes are not columns: they are a weighted sum of the 29 base
# attributes, and the weights come from the game's OWN `playerattributesmapping`
# table rather than from published formulas. The result checks out against real FIFA
# 17 cards: Messi 89/90/86/96/26/61 and Ibrahimovic 72/90/81/85/31/86 are exact,
# Suarez is one off on physical, Ronaldo within two on pace and shooting.
#
# NOTE THE REVERSAL on nation/team/league. When those fields were unknown we sent
# ZERO so the client would fill its own values (the merge fills them only when they
# arrive zero). Now that we hold the game's own numbers there is nothing to gain, and
# zeros actively HURT: our club-stats drill-downs bucket by the item's own nation and
# leagueId, so a club full of zeros would have emptied the per-nation and per-league
# panels that were fixed yesterday. Send the real values.
POOL_FACTS = _load("pool.json", [])
# Hand-checked positions, carried over from the curated pool this file replaces.
# They are KNOWLEDGE, not measurement, which is why they rank below the codes the
# game itself supplied. They exist because a synthetic position is unnoticeable on
# an unknown 62-rated defender and glaring on Neuer, and the famous players are
# precisely the ones a pack shows off.
CURATED_POSITIONS = {int(k): v for k, v in _load("positions_curated.json", {}).items()}
def _position(pid):
"""The real position where the game told us one, else curated, else synthetic.
Deterministic in the id, so a player never changes shape between packs or
between runs.
"""
code = KNOWN_POSITIONS.get(pid)
if code is not None:
return POSITION_BY_CODE.get(code, "ST")
if pid in CURATED_POSITIONS:
return CURATED_POSITIONS[pid]
return _SYNTH_POSITIONS[pid % len(_SYNTH_POSITIONS)]
def _attrs(rating, pos):
prof = _PROFILE.get(pos, _PROFILE["CM"])
out = []
for i, mult in enumerate(prof):
# A small, stable per-player wobble so two 82-rated strikers are not
# byte-identical. Seeded by rating and slot, never by wall-clock, so the
# pool is reproducible.
v = int(round(rating * mult)) + ((rating * 7 + i * 13) % 5) - 2
out.append(max(1, min(99, v)))
return out
def _build():
if POOL_FACTS:
pool = []
for r in POOL_FACTS:
pid, rating = r["id"], r["rating"]
if not pid or rating <= 0:
# A zero id is not a harmless skip: the registrar writes
# *(item+0x10) = 0 and the card view-model dereferences it with no
# null check, so a zero id reaching the card UI is a crash.
continue
pool.append((pid, rating, r["pos"], r["nation"], r["league"], r["team"],
list(r["attrs"])))
return pool
# Fallback: the rating-index roster, with synthetic positions and attributes.
# Kept so the pool still builds if data/pool.json is missing, but everything it
# produces below is a guess where the block above is a measurement.
pool = []
for r in ROSTER:
pid, rating = r["id"], r["rating"]
if not pid or rating <= 0:
# A zero id is not a harmless skip: the registrar writes
# *(item+0x10) = 0 and the card view-model dereferences item+0x10
# with no null check, so a zero id reaching the card UI is a crash.
continue
pos = _position(pid)
# nation / league / team are ZERO on purpose -- that is what makes the
# client fill in the real ones. Do not "improve" this by guessing them.
pool.append((pid, rating, pos, 0, 0, 0, _attrs(rating, pos)))
return pool
POOL = _build()
# Kept for the record: the old hand-written "verified" set. 169193 is in it and
# is NOT a real FIFA 17 player -- the client resolves it to the database's empty
# placeholder row, which reads as "Jamal Blackman" on every card. That is exactly
# the failure this rebuild removes, and two independent methods agreed on it. Do
# not restore this set as a source of truth; it is here so older notes stay
# traceable.
VERIFIED_ASSET_IDS = {
20801, 158023, 176580, 167495, 183907, 155862, 188545, 182521,
183277, 177003, 192985, 190871, 200389, 197445, 202126, 189332,
169193, 184941,
}
NAME_BY_ID = {r["id"]: (r["common"] or ("%s %s" % (r["first"], r["last"])).strip())
for r in ROSTER}
def tier(rating):
return "gold" if rating >= 75 else "silver" if rating >= 65 else "bronze"
def pool_for(tier_name):
"""Players of one tier. Falls back to the whole pool rather than returning []."""
sel = [p for p in POOL if tier(p[1]) == tier_name]
return sel or POOL
def name_of(pid):
"""For LOGS only. The name a player actually sees comes from the client."""
return NAME_BY_ID.get(pid, "?")
if __name__ == "__main__":
print("pool: %d players from the real FIFA 17 roster" % len(POOL))
for name in ("gold", "silver", "bronze"):
sel = pool_for(name)
print(" %-7s %5d ratings %d-%d" % (name, len(sel),
min(p[1] for p in sel),
max(p[1] for p in sel)))
if POOL_FACTS:
print("source: data/pool.json -- positions, nation, club, league and all six "
"attributes MEASURED from the game's own database")
else:
print("source: data/roster.json FALLBACK -- %d real positions, the rest "
"synthetic, attributes derived from rating" % len(KNOWN_POSITIONS))
print("\ntop 10 by rating:")
for p in sorted(POOL, key=lambda x: -x[1])[:10]:
print(" %-7s %-3s %-4s %-26s %s" % (p[0], p[1], p[2], name_of(p[0]), p[6]))
@@ -0,0 +1,515 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""The MY CLUB stat vocabulary, censused from CardsDLL, and the body per mode.
HANDOVER MODULE -- nothing here is wired in. `utas_server.club_stats_route` is
owned by the integrate agent; this file is the spec in executable form. Import it
and call `stats_body()` / `staff_bonus_body()`, or lift the tables.
=============================================================================
1. THE VOCABULARY IS COMPLETE, AND IT IS AN ATOM TABLE, NOT A STRING TABLE
=============================================================================
`FUN_18012fd40` (1732 chars, decompiled and read END TO END -- every arm below is
transcribed from it, none elided) is the whole map. It does NOT compare strings:
iVar1 = FUN_180180d00(<the 0x30-byte `type` buffer>); // atom lookup
switch (iVar1) { ... } // 40 arms
return 0; // default
So the accepted vocabulary is exactly 40 ATOM IDS, and their spellings are the
atom names in docs/fut_atoms.tsv -- which is why a census is possible at all and
why "a filtered scan" is not needed: the function IS the census. Anything else
(including the real atoms `consumablesContract` 0xa6, `consumablesTraining` 0xa7,
`consumablesFitness` 0xa8 and the lowercase `leaguelogos` 0x18d, all of which
exist in the atom table and all of which are ABSENT from the switch) returns 0
and lands in bucket key 0, which no reader ever looks up. Unknown strings are
therefore inert, not fatal.
Coverage statement, per the standing rule about absences: the claim "these 40 and
no others" is a claim about a 1732-char function that was decompiled in full and
whose default arm is `return 0`. It is not an inference from a grep.
=============================================================================
2. STORAGE, AND WHY EVERY BODY MUST BE COMPLETE
=============================================================================
Deserializer 0x180130150 (7870 chars, read in full) writes
`store[contextValue][statId] = typeValue` where store = CardsDb + 0x1F8B0.
* contextId (0xb6) is ONLY a guard: `if (contextId == 1 || contextId-5 < 5)`
-> contextValue is forced to 0. contextId 3 is used below purely because it
is outside that range and so preserves contextValue.
* the storage key is contextValue ALONE. Nation 14, league 14 and team 14 share
one bucket. One kind of context per response, never two.
* element-local variables are cleared ONCE before the array loop, never inside
it. Omit a key in element N and it silently inherits element N-1's value.
EMIT ALL FOUR KEYS IN EVERY ELEMENT.
* `type` is copied with FUN_180008120(buf, s, 0x30) -- a 48-byte buffer. The
longest name we use is 40 chars. Fits.
THE WIPE IS REAL AND IT IS NOT IN THE DESERIALIZER. That is why one investigator
read the deserializer, found no clear, and reported "no wipe". The clear is in the
RESPONSE FACTORY, `FUN_18012f6d0`:
store = CardsDb->vt[0x7f0]();
FUN_180116240(store+0x30, *(store+0x48)); // destroy the whole outer tree
store+0x38 = store+0x40 = store+0x38; // head = root = sentinel
store+0x48 = 0; store+0x50 = 0; store+0x58 = 0;
... then allocate "RS4:FutStickerBookStats2ServerResponse"
So EVERY Stats2 response erases the entire map before parsing. The map only ever
holds ONE mode's rows. `FUN_18012f680` is a second, standalone clear of the same
tree (session teardown).
AND THE ORDER IS DECIDED, MEASURED IN /tmp/utas_server.log: every MY CLUB entry
is `year` then `consumables`, in that order, 45 and 42 times this session. The
panels are therefore ALWAYS reading whatever we returned on `consumables`. A
consumables body that carries only consumable rows would blank the players,
staff, kits and badges rows of the very same tab strip. Hence: one union body,
served on every tab-strip mode.
(`FUN_18012fa90` also caches: if store+0x78/0x7c/0x80 already equal the requested
mode/arg1/arg2, no HTTP request is made at all. Re-entering the SAME screen twice
in a row is served from the map that is already there.)
=============================================================================
3. WHO READS WHAT. Five consumers, and that is all five.
=============================================================================
Census method: byte-scan of .text for `call [reg+0x7f8]` / `[reg+0x800]` over all
16 registers (with and without REX). Exactly five functions contain such a call:
FUN_180043b90 the club-stats data provider; switch on store+0x78 (the MODE)
FUN_180094ce0 the MY CLUB eight-row summary panel
FUN_180095360 the MY CLUB CONSUMABLES tab (7 rows + NUM_COLLECTED)
FUN_180096670 the MY CLUB tab builder (staff tab, consumables tab, tiles)
FUN_180097c70 the MY CLUB tile-detail panel
Call graph: FUN_180095660 -> FUN_180096670 -> {FUN_180095360, FUN_180097c70 ->
FUN_180094ce0}. FUN_180043b90 has no in-image caller (it is registered).
CRUCIAL, AND IT CORRECTS THE RECORD: the four MY CLUB panels do NOT switch on the
mode. They read the store unconditionally. Only FUN_180043b90 switches. So the
mode decides which of ITS cases runs, but the MY CLUB screen renders from
whatever the last response left behind, regardless of mode. This is why the union
body works and why "the client never asks for /club/stats/club" is survivable.
=============================================================================
4. THE CORRECTION THAT MATTERS MOST THIS ROUND
=============================================================================
REBUILD_RESEARCH S19 states: "Case 6 reads ids 0x3d CONTRACTS, 0x3e TRAINING and
0x40 FITNESS, which are exactly the three ids the type-string map cannot produce.
The consumables view is unsettable from this endpoint by construction."
THAT IS WRONG, and it is the reason the consumables tab is empty. 0x3d/0x3e/0x40
are read by case 5 (newcards), not case 6. Case 6 (consumables) reads:
0x43 0x46 0x42 0x44 0x41 0x4b 0x4c 0x45 0x47 0x48 0x49 0x4d 0x4a 0x3c
FOURTEEN ids, and EVERY ONE OF THEM IS IN THE TYPE MAP. The consumables panel is
fully settable from /club/stats/consumables. The same fourteen (minus 0x48) drive
the MY CLUB consumables tab FUN_180095360. We have simply never sent one of them:
the body we serve on `consumables` today is the PLAYER stat set.
Three ids in the vocabulary are read by nobody: 0x29 kitsHome and 0x2a kitsAway
are read only by case 5, 0x2f leagueLogos only by case 5. Three ids are read but
CANNOT be set: 0x3d, 0x3e, 0x40 (no atom maps to them) -- case 5 only.
=============================================================================
5. THE STAFF BONUS ENDPOINT IS A SECOND, DISJOINT VOCABULARY
=============================================================================
GET club/stats/staff is FutStaffBonus, deserializer 0x18012b730 (2243 chars, read
in full), shape {"bonus":[{"type":str,"value":int}]}. It does NOT touch the Stats2
map (so it cannot wipe it), and it does NOT go through FUN_18012fd40. It calls
store = CardsDb->vt[0x938]() // == CardsDb + 0x5AF0, a flat struct
FUN_18012b370(store, typeString, byteValue)
and FUN_18012b370 is a 22-arm atom switch writing ONE BYTE each at store+0x30 ..
store+0x45. Value path: INT getter 0x1801c79d0 -> FUN_1800d7b50, which clamps to
0..255 and returns 0 for anything <= 0. `type` goes into a 0x20 buffer; the
longest name in the vocabulary is 14 chars.
Those 22 bytes are the PERCENTAGES on the MY CLUB -> STAFF tab (FUN_180096670
case 8, customData 0x14): every row is published with LEFT_PERCENT/RIGHT_PERCENT
set to 1. The five COUNTS on the same tab come from the Stats2 store instead
(ids 0xb..0xf), so the staff tab needs BOTH endpoints answered.
=============================================================================
6. WHAT IS INFERRED RATHER THAN PROVEN
=============================================================================
FUN_180094ce0 iterates a vector at model+0x140 (stride 0x40): dword 0 is a
category code, dword +8 is the contextValue it looks up. Codes 1..7 and 9 do the
six per-context reads (0x28, 0x2d, 4, 3, 2, 5); code 8 reads stadia globally, 10
balls, 0x10 the six trophy ids, 0x12 the five staff ids. That +8 value is
INFERRED to be a nation id: FUN_180043b90 case 2 performs the identical six reads
on rows whose id it fetches as "NATION_ID", and FUN_180097c70 (this function's
caller) does the same. The vector's producer was not located, so this is a strong
structural inference, not a proof. See LIVE_TESTS at the bottom.
"""
# --------------------------------------------------------------------------
# THE VOCABULARY. statId -> the JSON `type` string (== the atom name).
# Transcribed arm by arm from FUN_18012fd40. 40 entries, complete.
# --------------------------------------------------------------------------
VOCAB = {
0x01: "players", # atom 0x238
0x02: "playersBronze", # atom 0x239
0x03: "playersSilver", # atom 0x23b
0x04: "playersGold", # atom 0x23a
0x05: "rarePlayers", # atom 0x272
0x0A: "staff", # atom 0x2dc
0x0B: "staffManager", # atom 0x2dd
0x0C: "staffHeadCoach", # atom 0x2de
0x0D: "staffGKCoach", # atom 0x2e0 <- NOTE 0x2e0, not 0x2df
0x0E: "staffPhysio", # atom 0x2e1
0x0F: "staffFitnessCoach", # atom 0x2df <- the pair is transposed
0x14: "stadia", # atom 0x2d7
0x1E: "balls", # atom 0x04f
0x28: "kits", # atom 0x17c
0x29: "kitsHome", # atom 0x17d
0x2A: "kitsAway", # atom 0x17e
0x2D: "badges", # atom 0x04b
0x2E: "badgeDBid", # atom 0x04a
0x2F: "leagueLogos", # atom 0x18e (NOT 0x18d `leaguelogos`)
0x32: "trophies", # atom 0x340
0x33: "trophiesOffline", # atom 0x343
0x34: "trophiesOnline", # atom 0x344
0x35: "trophiesFeaturedOffline", # atom 0x341
0x36: "trophiesFeaturedOnline", # atom 0x342
0x37: "trophiesSeasonOffline", # atom 0x345
0x38: "trophiesSeasonOnline", # atom 0x346
0x3C: "consumables", # atom 0x0a5
0x41: "consumablesHealing", # atom 0x0af
0x42: "consumablesContractPlayer", # atom 0x0a9
0x43: "consumablesTrainingPlayer", # atom 0x0b3
0x44: "consumablesFitnessPlayer", # atom 0x0ab
0x45: "consumablesPosition", # atom 0x0b2
0x46: "consumablesTrainingGk", # atom 0x0b5
0x47: "consumablesContractManager", # atom 0x0aa
0x48: "consumablesFormationManager", # atom 0x0ad
0x49: "consumablesTrainingManager", # atom 0x0b4
0x4A: "consumablesFitnessTeam", # atom 0x0ac
0x4B: "consumablesTrainingPlayerPlayStyle", # atom 0x0b0
0x4C: "consumablesTrainingGkPlayStyle", # atom 0x0b1
0x4D: "consumablesTrainingManagerLeagueModifier", # atom 0x0ae
}
# Read by a consumer but produced by NO atom -- unsettable from this endpoint.
UNSETTABLE = {0x3D: "CONTRACTS (case 5)", 0x3E: "TRAINING (case 5)",
0x40: "FITNESS (case 5)"}
# --------------------------------------------------------------------------
# WHO READS WHICH ID. (id, reader, on-screen row)
# --------------------------------------------------------------------------
# FUN_180043b90 case 1 "club" global 1,0x1e,0x28,0x14,0x0a,0x32
# FUN_180043b90 case 2 "year" global 0x1e,0x14,0xb,0xc,0xe,0xd,0xf,
# 0x33,0x34,0x35,0x36,0x37,0x38
# + per NATION_ID 2,3,4 (PLAYERS = their sum),5,
# 0x28,0x2d
# FUN_180043b90 case 3 "country/id" per LEAGUE_ID 2,3,4,5,0x28,0x2d
# FUN_180043b90 case 4 "league/id" per TEAM_ID 1,0x28,0x2e
# FUN_180043b90 case 5 "newcards" global 1,0x0a,0x14,0x1e,0x28,0x2f,0xb,
# 0xc,0xf,0xd,0xe,0x2d,0x29,0x2a,
# 0x3c,[0x3d,0x3e,0x40],0x41
# FUN_180043b90 case 6 "consumables" global 0x43,0x46,0x42,0x44,0x41,0x4b,
# 0x4c,0x45,0x47,0x48,0x49,0x4d,
# 0x4a,0x3c
# FUN_180094ce0 summary per tile id 0x28,0x2d,4,3,2,5 ; global 0x14,0x1e,
# 0x33..0x38, 0xb..0xf
# FUN_180095360 consumables tab global 0x46+0x43, 0x47+0x42, 0x4a+0x44, 0x41,
# 0x4c+0x4b, 0x4d, 0x49+0x45, 0x3c
# FUN_180096670 staff tab global 0xb,0xc,0xf,0xd,0xe (+ the bonus bytes)
# FUN_180097c70 tile detail global 0x14,0x33..0x38,0xb..0xf,0x1e ;
# per id 2,3,4,0x28,0x2d,5
MODE_READS = {
"club": {"global": (0x01, 0x1E, 0x28, 0x14, 0x0A, 0x32), "context": None},
"year": {"global": (0x1E, 0x14, 0x0B, 0x0C, 0x0E, 0x0D, 0x0F,
0x33, 0x34, 0x35, 0x36, 0x37, 0x38),
"context": ("nation", (0x02, 0x03, 0x04, 0x05, 0x28, 0x2D))},
"country": {"global": (), "context": ("leagueId", (0x02, 0x03, 0x04, 0x05,
0x28, 0x2D))},
"league": {"global": (), "context": ("teamid", (0x01, 0x28, 0x2E))},
"newcards": {"global": (0x01, 0x0A, 0x14, 0x1E, 0x28, 0x2F, 0x0B, 0x0C,
0x0F, 0x0D, 0x0E, 0x2D, 0x29, 0x2A, 0x3C, 0x41),
"context": None},
"consumables": {"global": (0x43, 0x46, 0x42, 0x44, 0x41, 0x4B, 0x4C, 0x45,
0x47, 0x48, 0x49, 0x4D, 0x4A, 0x3C), "context": None},
}
# The tab-strip modes: all four render the SAME MY CLUB screen, whose panels read
# the store unconditionally. They get the identical union body.
TAB_STRIP_MODES = ("", "year", "consumables", "club", "newcards")
# --------------------------------------------------------------------------
# THE STAFF-BONUS VOCABULARY. atom name -> (store byte offset, screen row)
# Transcribed arm by arm from FUN_18012b370 (22 arms), rows from FUN_180096670
# case 8. Group codes are the staff-group table at 0x180203310, stride 0x18.
# --------------------------------------------------------------------------
STAFF_BONUS = {
# manager group (code 2, count id 0x0b)
"contract": (0x30, "FUT_CONTRACTS"),
"managerTalk": (0x31, None), # parsed, no reader found in case 8
# fitness-coach group (code 4, count id 0x0f)
"fitness": (0x32, "FUT_FITNESS"),
# physio group (code 5, count id 0x0e)
"physioHead": (0x33, "FUT_MC_HEAD"),
"physioShoudler": (0x34, "FUT_MC_UPPERBODY"), # sic, EA's spelling
"physioArm": (0x35, "FUT_MC_ARM"),
"physioBack": (0x36, "FUT_MC_BACK"),
"physioHip": (0x37, "FUT_MC_KNEE"),
"physioLeg": (0x38, "FUT_MC_LEG"),
"physioFoot": (0x39, "FUT_MC_FOOT"),
# GK-coach group (code 10, count id 0x0d)
"gkDiving": (0x3A, "FUT_MC_DIVING"),
"gkHandling": (0x3B, "FUT_MC_HANDLING"),
"gkKicking": (0x3C, "FUT_MC_KICKING"),
"gkReflexes": (0x3D, "FUT_MC_REFLEXES"),
"gkOneOnOne": (0x3E, "FUT_MC_ACCELERATION"), # label/name disagree; EA's
"gkPositioning": (0x3F, "FUT_MC_POSITIONING"),
# head-coach group (code 3, count id 0x0c)
"pace": (0x40, "FUT_MC_PACE"),
"shooting": (0x41, "FUT_MC_SHOOTING"),
"passing": (0x42, "FUT_MC_PASSING"),
"dribbling": (0x43, "FUT_MC_DRIBBLING"),
"defending": (0x44, "FUT_MC_DEFENDING"),
"heading": (0x45, "FUT_MC_HEADING"),
}
# --------------------------------------------------------------------------
# consumable card `kind` (fut_consumables.SUBTYPES) -> stat id.
# --------------------------------------------------------------------------
CONSUMABLE_KIND_STAT = {
"player_contract": 0x42,
"manager_contract": 0x47,
"healing": 0x41,
"player_fitness": 0x44,
"squad_fitness": 0x4A,
"gk_training": 0x46,
"player_training": 0x43,
"position_mod": 0x45,
"player_playstyle": 0x4B,
"gk_playstyle": 0x4C,
"manager_league": 0x4D,
"manager_formation_mod": 0x48,
"formation_mod": 0x48,
# DEAD_ZONE subtypes are never shipped and are counted nowhere.
}
# cardsubtypeid -> staff stat id (the merge's own families, see CARD_SYSTEM.md).
STAFF_SUBTYPE_STAT = {4: 0x0B, 5: 0x0C, 6: 0x0D, 7: 0x0E, 8: 0x0F}
PLAYER_SUBTYPES = (0, 1, 2, 3)
# --------------------------------------------------------------------------
# THE TWO UI GROUP TABLES, for reference. Both are (code, label, extra) triples
# at stride 0x18, and both are indexed by the switch in FUN_180096670.
#
# consumables tab, table at 0x180203260, switch case 0xb:
# code 0x00 FUT_MYCLUB_CONSUMABLES_TRAINING_EARNED "training"
# code 0x01 FUT_MYCLUB_CONSUMABLES_CONTRACT_EARNED "contracts"
# code 0x04 FUT_MYCLUB_CONSUMABLES_FITNESS_EARNED "fitness"
# code 0x03 FUT_MYCLUB_CONSUMABLES_HEALING_EARNED "healing"
# code 0x17 FUT_MYCLUB_CONSUMABLES_PLAYSTYLE_EARNED "playStyle"
# code 0x18 FUT_MYCLUB_CONSUMABLES_MANAGER_LEAGUE_EARNED "managerLeagueModifier"
# code 0x11 FUT_MYCLUB_CONSUMABLES_TACTIC_TRAINING_EARNED "position"
#
# staff tab, table at 0x180203310, switch case 8:
# code 0x02 FUT_MYCLUB_MANAGERS count 0x0b bonus row FUT_CONTRACTS
# code 0x03 FUT_MYCLUB_HEADCOACHES count 0x0c 6 attribute bonus rows
# code 0x04 FUT_MYCLUB_FITNESS count 0x0f bonus row FUT_FITNESS
# code 0x0a FUT_MYCLUB_GKCOACHES count 0x0d 6 GK bonus rows
# code 0x05 FUT_MYCLUB_PHYSIO count 0x0e 7 body-part bonus rows
#
# THOSE GROUP NAMES ARE NOT ?type= VALUES. The club query taxonomy is a separate
# 30-arm atom switch, FUN_18012ec50, and it reads:
# 0 any, 1 player, 2 manager, 3 headcoach, 4 fitnesscoach, 5 physio,
# 6 development, 7 custom, 8 unlocks, 9 gkcoach, 10 staff, 11 badge, 12 kit,
# 13 stadium, 14 ball, 15 equippables, 16 leaguelogos, 17 offlinetrophy,
# 18 onlinetrophy, 19 featuredofflinetrophy, 20 featuredonlinetrophy,
# 21 allofflinetrophy, 22 allonlinetrophy, 23 healing, 24 contract,
# 25 training, 26 misc, 27 playerdefender, 28 playermidfielder,
# 29 playerforward.
# So last round's type=contract / training / healing / development arms were
# CORRECTLY NAMED. The empty consumables tab is not a naming bug on that route.
# --------------------------------------------------------------------------
def _row(ctx_id, ctx_val, stat_id, value):
"""One stat element. All four keys, always -- see the note about element-local
variables not being reset between elements."""
return {"contextId": int(ctx_id), "contextValue": int(ctx_val),
"type": VOCAB[stat_id], "typeValue": int(value)}
def global_counts(items, staff_counts=None):
"""{statId: value} for the global bucket, from the items the club holds.
`items` is the club item list (utas_server STORE.items() shape).
`staff_counts` optionally overrides the staff tally with the synthetic
overlay's counts, keyed by cardsubtypeid 4..8.
"""
try:
import fut_consumables
except Exception:
fut_consumables = None
players = [i for i in items if i.get("cardsubtypeid", 0) in PLAYER_SUBTYPES]
rating = lambda i: i.get("rating") or 0
c = {
0x01: len(players),
0x04: len([i for i in players if rating(i) >= 75]),
0x03: len([i for i in players if 65 <= rating(i) < 75]),
0x02: len([i for i in players if 0 < rating(i) < 65]),
0x05: len([i for i in players if i.get("rareflag")]),
}
# staff, per family
staff = dict(staff_counts) if staff_counts else {}
if not staff:
for i in items:
st = i.get("cardsubtypeid", 0)
if st in STAFF_SUBTYPE_STAT:
staff[st] = staff.get(st, 0) + 1
for st, sid in STAFF_SUBTYPE_STAT.items():
c[sid] = staff.get(st, 0)
c[0x0A] = sum(c[s] for s in STAFF_SUBTYPE_STAT.values())
# consumables, per family
for sid in (0x41, 0x42, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49,
0x4A, 0x4B, 0x4C, 0x4D):
c[sid] = 0
total_cons = 0
if fut_consumables is not None:
for i in items:
rec = fut_consumables.BY_SUBTYPE.get(i.get("cardsubtypeid", 0))
if rec is None:
continue
total_cons += 1
sid = CONSUMABLE_KIND_STAT.get(rec["kind"])
if sid:
c[sid] = c.get(sid, 0) + 1
c[0x3C] = total_cons
# club items. Honest zeros unless the club really holds them; cardtype 9 has
# no merge arm, so we cannot classify these from the item record and the club
# holds none today. Every one of these is READ by some panel, so it must be
# present or the panel keeps the previous screen's number.
for sid in (0x14, 0x1E, 0x28, 0x29, 0x2A, 0x2D, 0x2E, 0x2F,
0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38):
c.setdefault(sid, 0)
return c
def context_rows(items, kind):
"""Per-context rows for one screen. `kind` is "", "country" or "league"."""
field = {"": "nation", "country": "leagueId", "league": "teamid"}.get(kind)
if not field:
return [], 0
players = [i for i in items if i.get("cardsubtypeid", 0) in PLAYER_SUBTYPES]
rating = lambda i: i.get("rating") or 0
ctxs = sorted({i.get(field) for i in players if i.get(field) is not None})
rows = []
for ctx in ctxs:
sel = [i for i in players if i.get(field) == ctx]
if field == "teamid":
# case 4 reads 1, 0x28, 0x2e. FUN_180043b90 publishes 0x2e raw as
# BADGES_AVAILABLE; the tab builder FUN_180096670 publishes the same
# id as `(uint)(iVar6 != 0)` -- a HAS-A-BADGE boolean. Any non-zero
# therefore reads as 1 on one screen and as itself on the other.
vals = [(0x01, len(sel)), (0x28, 0), (0x2E, 0)]
else:
# cases 2 and 3 COMPUTE players as gold+silver+bronze and never read
# id 1. The tier counts are mandatory, not decoration.
vals = [(0x04, len([i for i in sel if rating(i) >= 75])),
(0x03, len([i for i in sel if 65 <= rating(i) < 75])),
(0x02, len([i for i in sel if 0 < rating(i) < 65])),
(0x05, len([i for i in sel if i.get("rareflag")])),
(0x28, 0), (0x2D, 0)]
rows += [_row(3, ctx, sid, v) for sid, v in vals]
return rows, len(ctxs)
def stats_body(mode, items, staff_counts=None):
"""The FutStickerBookStats2 body for GET ut/%s/club/stats/<mode>.
`mode` is the URL tail: "", "year", "consumables", "club", "newcards",
"country/<id>", "league/<id>". The id in the URL says WHICH SCREEN, never
which bucket: country/<n> renders a list of LEAGUES and league/<n> a list of
TEAMS, and the reader looks each row up by that row's own id.
"""
parts = (mode or "").split("/")
head = parts[0]
glob = global_counts(items, staff_counts)
stats = [_row(1, 0, sid, val) for sid, val in sorted(glob.items())]
if len(parts) >= 2 and parts[1].isdigit() and head in ("country", "league"):
ctx, _n = context_rows(items, head)
else:
ctx, _n = context_rows(items, "")
return {"stat": stats + ctx}
def staff_bonus_body(bonuses):
"""The FutStaffBonus body for GET ut/%s/club/stats/staff.
`bonuses` is {atom name: 0..255}. Names not in STAFF_BONUS are dropped rather
than sent: an unknown name is inert (FUN_18012b370 falls through) but sending
one proves nothing and widens the surface. An empty dict yields {"bonus":[]},
which is a different thing from today's {} -- see the live test.
"""
out = []
for name, val in bonuses.items():
if name not in STAFF_BONUS:
continue
v = int(val)
out.append({"type": name, "value": max(0, min(255, v))})
return {"bonus": out}
# --------------------------------------------------------------------------
# LIVE TESTS (a human fires these; nothing here mutates a save)
# --------------------------------------------------------------------------
# T1 CONSUMABLES TAB. Serve the union body on every tab-strip mode, then open
# MY CLUB -> CONSUMABLES.
# positive: the seven rows read TRAINING 42, CONTRACT 13, FITNESS 6,
# HEALING 21, PLAYSTYLE 24, MANAGER LEAGUE 0, TACTIC TRAINING 20,
# and the header count reads 126 (with the 126-item shelf armed).
# negative: all seven still 0 -> the tab is not reading the Stats2 store at
# all and FUN_180095360 is not the renderer. That is interpretable
# and it kills the whole approach, which is why it is worth firing.
#
# T2 NATION KEYING (settles the one inference in this file). With the union
# body live, read FUT_MYCLUB_PLAYERS_EMPLOYED on the MY CLUB summary.
# positive: 205 (the sum over all 29 nation buckets).
# negative: 0 while the ENGLAND -> Premier League row still reads 17 -> the
# tile vector at model+0x140 is NOT keyed by nation id, and the
# producer of that vector has to be found. Also interpretable.
#
# T3 STAFF BONUS. Answer club/stats/staff with staff_bonus_body({"pace": 7,
# "contract": 3}) and open MY CLUB -> STAFF.
# positive: the head-coach group shows PACE 7% and the manager group shows
# CONTRACTS 3%.
# negative: both read 0% -> the bytes at CardsDb+0x5AF0+0x40/+0x30 are not
# what the tab renders, and the 22-name table is wrong about its
# consumer (it is not wrong about the parser).
# Two distinct values on two distinct groups on purpose: one number could be
# a coincidence, two in the right places cannot.
if __name__ == "__main__":
import json
import os
import sys
here = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, here)
prof = json.load(open(os.path.join(here, "fifa17_profile.json")))
items = prof["items"]
print("club: %d items" % len(items))
for m in ("year", "consumables", "country/14", "league/13"):
b = stats_body(m, items)
g = [r for r in b["stat"] if r["contextId"] == 1]
c = [r for r in b["stat"] if r["contextId"] == 3]
print(" %-12s %3d rows (%d global, %d context)"
% (m, len(b["stat"]), len(g), len(c)))
print("\nglobal bucket, non-zero rows:")
for r in stats_body("year", items)["stat"]:
if r["contextId"] == 1 and r["typeValue"]:
print(" %-42s %d" % (r["type"], r["typeValue"]))
@@ -0,0 +1,196 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Club items: balls, stadia, badges, kits and league logos.
Built from the game's OWN tables, dumped read-only into data/tables/ by db_dump.py:
fcc_balls 42 rows carddbid 8120194+ cardassetid 37
fcc_stadium 78 rows carddbid 6200000+ cardassetid 36
fcc_badgecards 656 rows carddbid 6000000+ cardassetid 39
fcc_kitcards 1482 rows carddbid 6300000+ cardassetid 35
fcc_leaguelogos 44 rows carddbid 8010000+ cardassetid 40
TWO ID COLUMNS, AND THEY ARE NOT INTERCHANGEABLE. Every fcc_ row carries BOTH
carddbid and cardassetid. carddbid is the database key the merge would use;
cardassetid is the ART id the card draws from. fut_store._item copies resourceId
into cardassetid, which is right for players and wrong for every other family --
that is exactly what produced the green "NOT FOUND" placeholder on consumables
(external/ion_fut/artAssets/.../notfound.swf) until it was fixed on 2026-08-05.
So this module sets both explicitly and never lets one default to the other.
WHAT IS NOT KNOWN YET, AND IS NOT GUESSED HERE
----------------------------------------------
cardtype 9 (the club-item family) has NO arm in the merge FUN_180141660: no table
query and no miss-fill. So unlike a player or a coach, a club item's identity does
NOT come from the local card DB, and a wrong id cannot announce itself. The
cardsubtypeid values that reach cardtype 9 are the eight-value set
{30, 31, 145, 146, 147, 148, 149, 150}, and WHICH of those means ball versus
stadium versus badge is assigned nowhere in the 149 dumped tables.
Rather than guess, SUBTYPE is a per-family constant below with an explicit
"unverified" marker, and probe_shelf() serves one item per candidate subtype so the
screen itself can say which is which. The counts do not need any of this: a count is
just a number, which is why counts come first.
THE COUNT IS THE GATE. Proven on consumables the same day: the client does not ask
for an item list until club/stats reports a non-zero count for that family. The
CLUB tab reads global stat ids 1 (players), 0x1e (balls), 0x28 (kits), 0x14
(stadia), 0x0a (staff) and 0x32 (trophies), so making those non-zero is what makes
the client reveal the item route it uses. Nothing here should be believed to work
until that route is observed in the log.
"""
import json
import os
_DATA = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "data", "tables")
CLUBITEM_ID_BASE = 960000000 # distinct from save 1e8, sweep 9e8, consumables 9.4e8
# (table, art id, stat id, stat name, UNVERIFIED cardsubtypeid)
# CORRECTED 2026-08-06. Every previous subtype was inside the 0x91..0x96 block, which
# is TROPHIES: FUN_180108c00 computes subtype = tournamentType + 0x91, and FUN_1800fed90
# is the only function in the binary whose case set is exactly {0x91..0x96}. So all five
# families were pointed at the trophy range.
#
# Kits, stadia and badges are NOT cardtype 9. FUN_1800d8330 has
# `case 9: case 10: case 0xb: return 7`, and cardtype 7 DOES have a resolver: manager
# vtable +0x498 = FUN_180119bd0, reached from FUN_1800f6c40 when item+0x4c == 7, called
# with (subtype, teamid, assetId). That matters for testing: CARD_SYSTEM.md said a wrong
# club-item id "cannot announce itself", and for these three that is false. A wrong
# teamid produces a visibly wrong TeamName_Abbr15_ caption, which is why kits go first.
FAMILIES = [
("balls", "fcc_balls.json", 37, 0x1E, "balls", 30),
("stadia", "fcc_stadium.json", 36, 0x14, "stadia", 10),
("badges", "fcc_badgecards.json", 39, 0x2E, "badgeDBid", 11),
("kits", "fcc_kitcards.json", 35, 0x28, "kits", 9),
("leaguelogos", "fcc_leaguelogos.json", 40, 0x2F, "leagueLogos", 31),
]
# Candidate set for probe_shelf(). The old set {30,31,145..150} could NOT have answered
# the question for kits, stadia or badges, because 9, 10 and 11 were not in it: the
# probe route the docs preferred would have spent a launch and returned nothing for
# three of the five families.
CARDTYPE9_SUBTYPES = (9, 10, 11, 30, 31)
# How many of each family the starter club owns. Small on purpose: the point is to
# make the counter non-zero so the client asks, not to hand anyone a collection.
STARTER_N = {"balls": 6, "stadia": 4, "badges": 8, "kits": 8, "leaguelogos": 4}
def _rows(fname):
try:
with open(os.path.join(_DATA, fname)) as f:
return json.load(f).get("rows") or []
except (IOError, ValueError):
return []
def _item(item_id, carddbid, cardassetid, subtype, teamid=None, extra=None):
"""One club item. Deliberately narrow: no rating, no position, no attributes,
no nation, no league. A club item has none of those, and sending a field the
family does not have is how a wrong shape gets accepted and does nothing."""
it = {
"id": item_id,
"resourceId": carddbid,
"assetId": carddbid,
"cardassetid": cardassetid, # THE ART ID, never a copy of resourceId
"cardsubtypeid": subtype,
"itemState": "free",
"owners": 1,
"untradeable": False,
}
# KIT (9) and BADGE (11) display as <caption> + TeamName_Abbr15_<teamid>, so
# without teamid the name comes out as the caption alone. STADIUM (10) reads
# StadiumName_<assetId>, which resourceId already supplies, so it needs nothing.
# teamid is atom 0x306, read with the INT primitive FUN_1801c79d0 and stored at
# record +0x94: an established scalar field, not a new shape.
#
# BE HONEST ABOUT THE 2026-08-05 CRASH: teamid was one of the three extras in the
# response that crashed the client, and it was never bisected. `value` is the
# established suspect, because it is an OBJECT member elsewhere and a scalar where
# an object is expected is the 0x1801c7f1a busy loop, and that response also
# carried 30 items across FIVE wrong subtypes at once. This adds teamid ALONE, to
# ONE family, with the subtypes now corrected. That is the narrow test the crash
# denied us, and it is why families are served one at a time.
if teamid is not None and subtype in (9, 11):
it["teamid"] = teamid
if extra:
it.update(extra)
return it
def shelf(next_id=CLUBITEM_ID_BASE, families=None):
"""The starter club-item shelf, {family: [item]}.
`families` limits which are built. The combined `equippables` view is what
crashed the client: 30 items across FIVE unverified subtypes in one response is
the widest possible blast radius for a wrong shape. One family at a time is the
only way to learn which subtype is wrong.
"""
out, nid = {}, next_id
for name, table, art, _sid, _sname, subtype in FAMILIES:
if families is not None and name not in families:
out[name] = []
continue
rows = _rows(table)
picked = []
for r in rows[:STARTER_N.get(name, 4)]:
cid = r.get("carddbid")
if not cid:
continue
# NO EXTRAS. An earlier version copied teamid/leagueid/value straight
# out of the fcc row and the game hung and then CRASHED on the first
# equippables fetch (2026-08-05). `value` is the prime suspect: it
# appears elsewhere as an OBJECT member (displayGroup {"value": ...}),
# and a scalar where an object is expected is the type-desync busy loop
# at 0x1801c7f1a, which reads exactly like "the game is taking its time"
# and then dies. Omission is safe; an unestablished field is not. None of
# the three was needed to draw a card.
# teamid is passed but _item only APPLIES it to kits (9) and badges (11),
# which are the two families whose caption is <name> + TeamName_Abbr15_
# <teamid>. It is the one field from the fcc row being reintroduced after
# the 2026-08-05 crash, deliberately alone and deliberately narrow: see
# the note in _item(). value and leagueid stay omitted.
picked.append(_item(nid, cid, r.get("cardassetid", art), subtype,
teamid=r.get("teamid")))
nid += 1
out[name] = picked
return out
def counts(next_id=CLUBITEM_ID_BASE):
"""[(stat name, count)] for the club panel."""
s = shelf(next_id)
return [(sname, len(s.get(name, [])))
for name, _t, _a, _sid, sname, _st in FAMILIES]
def probe_shelf(family, next_id=CLUBITEM_ID_BASE):
"""One item per CANDIDATE cardsubtypeid, same carddbid, for the live oracle.
Which of {30,31,145..150} means which family is unknown and unguessable from the
dumped tables. Serving all eight and looking at the screen is the cheapest way to
find out, and unlike a sweep it is READABLE: the family that draws real artwork
names its own subtype.
"""
entry = next((f for f in FAMILIES if f[0] == family), None)
if entry is None:
return []
_n, table, art, _sid, _sname, _st = entry
rows = _rows(table)
if not rows:
return []
r = rows[0]
return [_item(next_id + i, r.get("carddbid"), r.get("cardassetid", art), st)
for i, st in enumerate(CARDTYPE9_SUBTYPES)]
if __name__ == "__main__":
s = shelf()
for name, items in s.items():
print("%-12s %2d item(s)" % (name, len(items)))
if items:
i = items[0]
print(" resourceId=%-9s cardassetid=%-4s subtype=%s"
% (i["resourceId"], i["cardassetid"], i["cardsubtypeid"]))
print("\ncounts:", counts())
@@ -0,0 +1,242 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""The four coach families for the FIFA 17 offline backend.
cardsubtypeid 5 -> cardtype 3 headcoachcards 124 rows 2000004..2000328
cardsubtypeid 6 -> cardtype 10 gkcoachcards 121 rows 9000001..9000324
cardsubtypeid 7 -> cardtype 5 physiocards 51 rows 4000002..4000259
cardsubtypeid 8 -> cardtype 4 fitnesscoachcards 115 rows 3000019..3000328
All four ids come out of data/tables/, dumped READ-ONLY from the running client, so
none of this needed the live game. rowcount == rows_emitted == len(rows) on all four,
which is what makes "this id is absent from the table" a claim about a COMPLETE dump.
WHY COACHES ARE THE CHEAPEST FAMILY TO TEST
-------------------------------------------
Their merge arms are the only ones in the game that LABEL THEIR OWN FAILURE. All four
inline branches of FUN_180141660 (2,129 bytes, 214-line decompile read to its closing
`return`) do, on rowcount < 1:
firstname = lastname = "DB Error" rec+0xb4 = 0x32 (rating 50) rec+0x58 = 1
and a TABLE-UNIQUE assetid: head 2000148 fitness 3000259 physio 4000146
gkcoach 9000258
so a wrong coach id puts the words DB ERROR on the screen instead of failing silently
the way a manager does. Two independent facts make that a legitimate one-glance oracle:
no row in ANY of the four tables has value == 50, and three of those four fallback
assetids are REAL rows in their own table (3000259 is not) -- so they are excluded
from everything this module ships.
THE MISS-FILL IS NOT UNIFORM, contrary to the earlier note in
docs/plan-2026-08-04-card-families.md. Only head coach and GK coach write 0xf into the
attribute array at rec+0x98. Physio writes 0xf into a BYTE at rec+0xdd, and fitness
coach writes no 0xf at all -- it writes rec+0xde = 0x107 and rec+0xdd = 1, i.e.
fieldpos 1 / posbonus 7 / amount 1.
THE KEY IS RAW. All four staff branches pass *(u32*)(rec+0x18) unmasked into
`WHERE carddbid == ?`. Players are the ONLY family that masks with & 0xffffff. A
version byte in the top octet therefore breaks every staff lookup, silently on a
manager and loudly on a coach.
WHAT IS OURS AND WHAT IS THEIRS. The merge overwrites firstname, lastname,
assetId(+0x20), rating(+0xb4), rare(+0x58), the tier(+0x54) it derives from rating,
and the family stat block. It never writes teamid(+0x94), preferredPosition(+0x146),
nation(+0x148) or leagueId(+0x154) -- and none of the four tables even HAS a nation,
league or team column, so any value we put there would be INVENTED. We therefore send
none of them: omission leaves the memset zero, and rec+0x146/+0x98.. are read by the
generic view-model FUN_1800d7920, so sending them would hang a position label and six
attribute numbers on a coach card.
"""
import json, os
HERE = os.path.dirname(os.path.abspath(__file__))
TABLES = os.path.join(os.path.dirname(HERE), "data", "tables")
# family -> (cardsubtypeid, record+0x4c cardtype, table, miss-fill assetid)
FAMILIES = {
"headcoach": (5, 3, "headcoachcards", 2000148),
"gkcoach": (6, 10, "gkcoachcards", 9000258),
"physio": (7, 5, "physiocards", 4000146),
"fitnesscoach": (8, 4, "fitnesscoachcards", 3000259),
}
# The ?type= arms that ask for coaching staff. FUN_18012ec50 resolves its 29 explicit
# arms through the atom table to headcoach(3), fitnesscoach(4), physio(5), gkcoach(9)
# and staff(10) -- but NONE of those five strings has ever been seen on the wire from
# this client. Only player, manager and custom have. Hence FUT_COACHES=all, which also
# answers type=manager (the one staff request that HAS been observed, from the STAFF
# tab on 2026-08-04).
CLUB_TYPES = ("headcoach", "gkcoach", "physio", "fitnesscoach", "staff")
COACH_ID_BASE = 950000000 # clear of the save (1e8), the sweep (9e8) and the
# consumable overlay (9.4e8)
def _rows(table):
with open(os.path.join(TABLES, table + ".json")) as f:
d = json.load(f)
assert d["rowcount"] == d["rows_emitted"] == len(d["rows"]), (
"%s: partial dump -- every 'this id is absent' claim below would be void"
% table)
return d["rows"]
def _build():
out = {}
for fam, (sub, ct, table, missfill) in FAMILIES.items():
rows = []
for r in _rows(table):
row = {
"family": fam,
"subtype": sub,
"cardtype": ct,
"carddbid": r["carddbid"], # == assetid on every row, all 4 tables
"rating": r["value"], # the client writes this itself
"rare": r["rare"],
# the family stat, for OUR predictions only -- never sent
"amount": r["amount"],
}
if fam == "fitnesscoach":
row["fieldpos"] = r["fieldpos"]
row["posbonus"] = r["posbonus"]
else:
row["attribute"] = r["attribute"]
rows.append(row)
out[fam] = rows
return out
COACHES = _build()
BY_ID = {(r["subtype"], r["carddbid"]): r for fam in COACHES for r in COACHES[fam]}
# ids that must never be shipped: they ARE the miss-fill fingerprint. 2000148, 4000146
# and 9000258 are genuine rows in their own tables, so a card carrying one of them is
# ambiguous -- a hit and a miss look identical. 3000259 is not a row at all.
MISS_FILL_IDS = {v[3] for v in FAMILIES.values()}
def tier(rating):
"""The shared tail of FUN_180141660 writes rec+0x54 for EVERY arm including the
miss arms: 3 if rating >= 0x4b, else 2 - (rating < 0x41). Bronze/silver/gold."""
return 3 if rating >= 75 else (2 if rating >= 65 else 1)
def coach_item(item_id, subtype, carddbid, contract=7, untradeable=True, rating=None):
"""One coach item. Eight keys, and every one of them is already proven on the wire.
id -> rec+0x08 our handle. NOTE FUN_180141660 opens with
`if (*(longlong *)(param_1 + 8) == 0) return;` -- an
item with id 0 or no id gets NO merge for ANY family:
no name, no rating, not even DB Error.
resourceId -> rec+0x18 THE merge key, compared RAW against carddbid.
cardsubtypeid -> rec+0x50 the ONLY family selector (FUN_1800d8330 -> rec+0x4c).
itemType inert (atom 0x173 never reaches the record); "staff"
is for our own readers, matching fut_staff.
contract -> rec+0x8c
itemState / owners / untradeable as on a player.
NOT SENT, each for a reason: rating/rareflag/assetId (all overwritten by the
merge), nation/leagueId/teamid (no such column exists in any coach table, so any
value would be invented), preferredPosition/attributeList (they SURVIVE the merge
and are read by the generic view-model), definitionId (not an atom).
`rating` is exposed only so a probe can plant a sentinel.
"""
if subtype not in {v[0] for v in FAMILIES.values()}:
raise ValueError("cardsubtypeid %r is not a coach family (5, 6, 7 or 8)"
% (subtype,))
if carddbid in MISS_FILL_IDS:
raise ValueError("carddbid %d is a miss-fill assetid: a hit and a miss would "
"look identical on that card" % carddbid)
it = {
"id": item_id,
"resourceId": carddbid,
"cardsubtypeid": subtype,
"itemType": "staff",
"contract": contract,
"itemState": "free",
"owners": 1,
"untradeable": untradeable,
}
if rating is not None:
it["rating"] = rating
return it
def _ambiguous(fam, r):
"""True if this row's own stat write is byte-identical to its family's miss-fill.
Head coach and GK coach both miss-fill with *(u32*)(rec+0x98) = 0xf, i.e.
attrs[0] = 15 -- so a genuine row with attribute 0 and amount 15 leaves the
attribute array indistinguishable from a miss. rating and the name still tell them
apart, but there is no reason to ship a card that needs a tie-break.
Fitness coach's miss triple (fieldpos 1, posbonus 7, amount 1) occurs on no row of
its table, and physio's rec+0xdd = 0xf collides only with attribute 0 amount 15."""
if fam == "fitnesscoach":
return (r["fieldpos"], r["posbonus"], r["amount"]) == (1, 7, 1)
return r["attribute"] == 0 and r["amount"] == 15
def _pick(fam):
"""One real id per (tier, rare) combination that the family actually has -- so at
most six cards, spanning bronze/silver/gold and both rare flags, with the miss-fill
assetid and every miss-ambiguous row excluded."""
rows = [r for r in COACHES[fam]
if r["carddbid"] not in MISS_FILL_IDS and not _ambiguous(fam, r)]
picked = []
for want in ((3, 1), (3, 0), (2, 1), (2, 0), (1, 1), (1, 0)):
for r in rows:
if (tier(r["rating"]), r["rare"]) == want:
picked.append(r)
break
return picked
# A readable STAFF tab: up to six cards per family, every one a real row.
STARTER_COACHES = {fam: [r["carddbid"] for r in _pick(fam)] for fam in FAMILIES}
def items_for_type(kind, next_id=COACH_ID_BASE):
"""The starter shelf filtered to one ?type= arm.
`staff` (arm 10) means all four families; each family's own arm means only that
family. Ids stay stable per family regardless of which arm asked, so the same card
keeps the same item id across tabs."""
all_items = starter_coaches(next_id)
if kind == "staff":
return all_items
fam = FAMILIES.get(kind)
if fam is None:
return []
return [i for i in all_items if i["cardsubtypeid"] == fam[0]]
def starter_coaches(next_id=COACH_ID_BASE):
if isinstance(next_id, int):
base = [next_id]
alloc = lambda: (base.__setitem__(0, base[0] + 1), base[0] - 1)[1]
else:
alloc = next_id
out = []
for fam in ("headcoach", "gkcoach", "physio", "fitnesscoach"):
sub = FAMILIES[fam][0]
for cid in STARTER_COACHES[fam]:
out.append(coach_item(alloc(), sub, cid))
return out
if __name__ == "__main__":
for fam, (sub, ct, table, miss) in sorted(FAMILIES.items()):
rows = COACHES[fam]
print("%-13s subtype %d cardtype %2d %3d rows %d..%d miss-fill %d"
% (fam, sub, ct, len(rows), rows[0]["carddbid"], rows[-1]["carddbid"],
miss))
for cid in STARTER_COACHES[fam]:
r = BY_ID[(sub, cid)]
print(" %7d rating %2d (%s) rare %d %s"
% (cid, r["rating"], "bronze silver gold".split()[tier(r["rating"]) - 1],
r["rare"],
"fieldpos %d posbonus %d amount %d"
% (r["fieldpos"], r["posbonus"], r["amount"])
if fam == "fitnesscoach"
else "attrs[%d] = %d" % (r["attribute"], r["amount"])))
print("starter_coaches(): %d item(s)" % len(starter_coaches()))
@@ -0,0 +1,291 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Consumable cards (cardtype 6) for the FIFA 17 offline backend.
THE CHEAPEST WHOLE FAMILY IN THE GAME: no id space has to be discovered, because a
consumable carries no identity at all. `FUN_18013f4d0` (8,354 chars, read to its
closing brace) has exactly two callees -- a range clamp and an enum map -- and never
touches a DB handle. Everything on the card comes from `cardsubtypeid` alone:
cardsubtypeid --FUN_1800d8330--> cardtype 6
--FUN_18013f4d0--> category -> record+0xb8
sub-sel -> record+0xbc (i16)
amount -> record+0xbf (i8) [or +0xbe playstyle]
single -> record+0xc0
and `FUN_1801bfac0` then renders category + those bytes into a FUT_CONSUMABLE_*
string and a hardcoded 5000xxx artwork constant. resourceId NEVER reaches the screen
for a consumable, which is why we can pick ids freely -- we still use EA's own
`fcc_*` carddbids so nothing drifts out of their space.
THE TWO THINGS WE MUST GET RIGHT
--------------------------------
1. `amount` (atom 0x1b) is MANDATORY for categories 0, 4, 5, 9, 10. The parser
initialises its temp to 0xffffffffffffffff, so OMITTING it stamps (byte)-1 into
record+0xbf -- and the accessors FUN_1801a8040/FUN_1801a8060 both do
`(int)*(char *)`, i.e. SIGNED, so the card reads "-1", not "255". Categories 2 and
3 (the two contract cards) take their number from a DIFFERENT atom, `contract`
(0xb8) -> record+0x8c, and IGNORE `amount` entirely.
2. rareflag must be 0 on subtype 219. See fut_store._SQUAD_FITNESS_TRAP: rareflag 1
silently converts a Player Fitness card into a Squad Fitness card.
A WRONG SUBTYPE IS SILENT *AND LOOKS PLAUSIBLE*. There is no "DB Error" analogue
here: a dead-zone subtype falls to the bottom default of FUN_18013f4d0 (category 0,
+0xbc = 0, +0xbf = 0) and FUN_1801bfac0 then renders it as a perfectly ordinary Squad
Training (Pace) card with amount 0. That is why every subtype we ship comes out of
data/consumables.json and `consumable_item` REFUSES a dead zone rather than trusting
the caller.
data/consumables.json is generated by tools/build_consumables.py from the three
decompiles above plus EA's own authored variants in fcc_trainingcards (143 rows),
fcc_healingcards (27) and fcc_contractcards (13).
"""
import json, os
HERE = os.path.dirname(os.path.abspath(__file__))
DATA = os.path.join(os.path.dirname(HERE), "data", "consumables.json")
with open(DATA) as _f:
_DOC = json.load(_f)
SUBTYPES = _DOC["subtypes"]
BY_SUBTYPE = {r["cardsubtypeid"]: r for r in SUBTYPES}
# ?type= -> the consumable CATEGORIES that arm asks for.
#
# The vocabulary is certain: FUN_18012ec50's 29 arms resolve through the atom table
# to healing=23, contract=24, training=25, development=6 (and there is NO fitness,
# position, formation, playstyle or managerLeague arm). The category grouping below
# is INFERRED from FUN_180048780's UI-bucket names, because the tab-to-arm binding
# has NEVER been observed on the wire -- only type=player, type=manager and
# type=custom have ever come from this client. Hence the flag, and hence the log line
# in utas_server's club_route that prints every ?type= it is asked for.
TYPE_CATEGORIES = {
"contract": {2, 3}, # player contract, manager contract
"training": {0}, # GK training + player training
"healing": {4, 5}, # healing, and fitness has no arm of its own
"development": {6, 7, 8, 9, 10}, # formation, position, playstyle, mgr league
}
# Families worth shipping: they have EA-authored variants and none needs a lookup the
# client cannot do from the subtype alone.
#
# THREE FAMILIES ARE DELIBERATELY EXCLUDED, each for a named reason:
# manager_formation_mod (71-86, category 6) vestigial AND a crash candidate:
# zero rows in the 143-row fcc_trainingcards (the carddbid run jumps 5003042 ->
# 5003059, exactly 16 ids), and FUN_1801bfac0 case 6 calls FUN_1801a0100 on the
# formations query result WITHOUT the `if (0 < rowcount)` guard its otherwise
# identical case 7 has.
# formation_mod (121-136, category 7) guarded, but its artwork constant is -1, so
# there is nothing to look at yet. Left for a later round.
# manager_league (300-341, category 10) FUN_1801bfac0 case 10 formats the label as
# literally "ML: %d" from record+0xbc -- a raw number, no league-name lookup --
# and one shipped amount (2118, subtype 337) is in neither leagues.json nor
# fcc_leagues.json.
CORE_KINDS = ("player_contract", "manager_contract", "healing",
"player_fitness", "squad_fitness", "gk_training", "player_training",
"position_mod", "player_playstyle", "gk_playstyle")
# carddbid -> cardassetid, the ART id, read from the game's own fcc_ tables.
#
# THE TWO IDS ARE NOT INTERCHANGEABLE and this cost a live debugging round. A card
# draws its artwork from cardassetid, which is a SMALL id (3 training, 7 contract,
# 10 healing, 45 misc), not the carddbid. Copying resourceId into cardassetid is
# right for players and wrong here: the client looked up art 5003001, found nothing,
# and drew external/ion_fut/artAssets/.../notfound.swf -- a green NOT FOUND box on
# every consumable card until 2026-08-05.
_ART_BY_CARDDBID = None
def art_id(carddbid, default=None):
global _ART_BY_CARDDBID
if _ART_BY_CARDDBID is None:
import glob
d = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "data", "tables")
m = {}
for f in glob.glob(os.path.join(d, "fcc_*.json")):
try:
rows = json.load(open(f)).get("rows") or []
except Exception:
continue
for r in rows:
if "carddbid" in r and "cardassetid" in r:
m[r["carddbid"]] = r["cardassetid"]
_ART_BY_CARDDBID = m
return _ART_BY_CARDDBID.get(carddbid, default)
CONSUMABLE_ID_BASE = 940000000 # distinct from the save (1e8), the sweep (9e8)
# and the staff overlay (9.5e8)
def variants(subtype):
"""EA's own authored (carddbid, amount, rating, weightrare) rows for a subtype."""
return BY_SUBTYPE[subtype].get("ea_variants", [])
def consumable_item(item_id, subtype, amount=None, contract=None, rating=None,
resource_id=None, rareflag=0, untradeable=True):
"""Build one consumable item.
Eleven common keys plus at most one class key. Every one of the eleven is already
proven on the wire by the live player path, so this introduces NO new wire shape
-- which matters, because a scalar where the parser wants an object busy-loops the
client at 0x1801c7f1a.
id -> rec+0x08 our handle
resourceId -> rec+0x18 written by FUN_18013f4d0 from its param_2; never
rendered for a consumable (artwork is a constant), so
this is bookkeeping only. Defaults to EA's carddbid.
assetId/cardassetid same value, for our own readers
cardsubtypeid -> rec+0x50 THE ONLY selector. Category, artwork, name and both
stat bytes all derive from it.
itemType "player": the ONLY value this client has ever been
sent. cardtype is derived from cardsubtypeid alone
(FUN_18013fe00 line 713), so the string cannot affect
the render. If the consumables tab comes back empty
this is the first thing to vary; the candidates from
the atom table are "training"/"contract"/"healing".
rareflag -> rec+0x58 0 by default. Read unconditionally into the card's
rare/backing art AND, in category 5 only, as the
squad-fitness selector -- see the 219 guard below.
rating -> rec+0xb4 drives level (rec+0x54: <65 bronze, <75 silver, else
gold) and therefore the fcc_discardcoins price.
itemState / owners / untradeable same meaning as on a player.
amount -> rec+0xbf (or +0xbe for playstyle). MANDATORY where `needs`
says so: omitting it stamps -1, not 0.
contract -> rec+0x8c categories 2 and 3 only.
DELIBERATELY ABSENT: preferredPosition, nation, teamid, leagueId, playStyle,
attributeList, fitness (all player-only), definitionId (not an atom at all -- the
parser has always been skipping it), and discardValue (the client computes it from
fcc_discardcoins on (cardtype 6, level, rare), and real rows exist for both rare
values, so omission is safe).
"""
r = BY_SUBTYPE.get(subtype)
if r is None:
raise ValueError("cardsubtypeid %r is not a cardtype-6 subtype" % (subtype,))
if r["kind"] == "DEAD_ZONE":
raise ValueError(
"cardsubtypeid %d is a DEAD ZONE: it renders as a plausible Squad "
"Training (Pace) card with amount 0 and gives no hint anything is wrong"
% subtype)
needs = set(r.get("needs", ()))
if "amount" in needs and amount is None:
raise ValueError("cardsubtypeid %d needs `amount`; omitting it reads back as "
"-1 on screen, not 0" % subtype)
if "contract" in needs and contract is None:
raise ValueError("cardsubtypeid %d needs `contract` (atom 0xb8)" % subtype)
if subtype == 219 and rareflag:
# Same fact as fut_store._SQUAD_FITNESS_TRAP, enforced at the other end so a
# caller cannot reintroduce it by passing rareflag through.
raise ValueError("rareflag must be 0 on subtype 219: FUN_1801bfac0 case 5 "
"renders a rare Player Fitness card as a SQUAD Fitness card")
ev = variants(subtype)
if resource_id is None:
resource_id = ev[0]["carddbid"] if ev else 5000000 + subtype
if rating is None:
rating = ev[0]["rating"] if ev else 55
it = {
"id": item_id,
"resourceId": resource_id,
"assetId": resource_id,
# THE ART ID, not a copy of resourceId -- see art_id() above.
"cardassetid": art_id(resource_id, resource_id),
"cardsubtypeid": subtype,
"itemType": "player",
"rareflag": rareflag,
"rating": rating,
"itemState": "free",
"owners": 1,
"untradeable": untradeable,
}
if amount is not None:
it["amount"] = int(amount)
if contract is not None:
it["contract"] = int(contract)
return it
def item_from_variant(item_id, subtype, variant):
"""Build the item EA itself authored: their carddbid, their amount, their rating."""
r = BY_SUBTYPE[subtype]
kw = dict(resource_id=variant["carddbid"], rating=variant["rating"])
if "amount" in r.get("needs", ()):
kw["amount"] = variant["amount"]
if "contract" in r.get("needs", ()):
# fcc_contractcards has NO amount column at all (schema: carddbid, cardsubtype,
# weightrare, cardassetid, gold, rating, bronze, silver), so the games count is
# NOT in the shipped data. 7 is INVENTED. Live test 3 is exactly the test that
# makes that safe: the client displays whatever `contract` we send.
kw["contract"] = variant.get("contract", 7)
return consumable_item(item_id, subtype, **kw)
# The starter shelf: every core family, every EA variant of it, in subtype order.
# Contracts, healing, fitness and training are what a club actually spends.
def starter_consumables(next_id):
"""[(item)] for one of each EA-authored variant of every CORE_KIND.
`next_id` is a zero-argument allocator (or an int base). Ids come from
CONSUMABLE_ID_BASE by default because these are served as an OVERLAY -- they are
not written into the save, so they must not consume the save's id space.
"""
if isinstance(next_id, int):
base = [next_id]
alloc = lambda: (base.__setitem__(0, base[0] + 1), base[0] - 1)[1]
else:
alloc = next_id
out = []
for r in SUBTYPES:
if r["kind"] not in CORE_KINDS:
continue
for v in r.get("ea_variants", []):
out.append(item_from_variant(alloc(), r["cardsubtypeid"], v))
return out
def items_for_type(kind, next_id=CONSUMABLE_ID_BASE):
"""The starter shelf filtered to one ?type= arm. [] if the arm is not ours."""
cats = TYPE_CATEGORIES.get(kind)
if cats is None:
return []
return [i for i in starter_consumables(next_id)
if BY_SUBTYPE[i["cardsubtypeid"]]["category"] in cats]
# resourceId -> cardsubtypeid, for the item-DEFINITION route (ut/%s/item/resource).
# Without this a def request for a consumable is answered with cardsubtypeid 0, which
# makes it cardtype 0 -- a player with no merge, i.e. plausible garbage.
DEF_BY_RESOURCE = {}
for _r in SUBTYPES:
for _v in _r.get("ea_variants", []):
DEF_BY_RESOURCE[_v["carddbid"]] = (_r["cardsubtypeid"], _v)
def def_for(rid):
"""The item-definition body for a consumable resourceId, or None."""
hit = DEF_BY_RESOURCE.get(rid)
if hit is None:
return None
subtype, v = hit
return item_from_variant(rid, subtype, v)
if __name__ == "__main__":
import sys
if "--json" in sys.argv:
print(json.dumps({"itemData": starter_consumables(CONSUMABLE_ID_BASE)}, indent=1))
else:
live = [r for r in SUBTYPES if r["kind"] != "DEAD_ZONE"]
print("%d cardtype-6 subtypes (%d live, %d dead zones)"
% (len(SUBTYPES), len(live), len(SUBTYPES) - len(live)))
shelf = starter_consumables(CONSUMABLE_ID_BASE)
print("starter shelf: %d items across %d subtypes"
% (len(shelf), len({i["cardsubtypeid"] for i in shelf})))
for k, cats in sorted(TYPE_CATEGORIES.items()):
n = len(items_for_type(k))
print(" type=%-12s categories %-18s -> %2d item(s)"
% (k, sorted(cats), n))
@@ -0,0 +1,203 @@
# ---------------------------------------------------------------------------
# OpenFUT / FIFA 17 UTAS -- forged squad ladder (clean-room). Imported by
# utas_server.py. Derived from CardsDLL_Win64_retail.dll (PE base 0x180000000).
#
# The prior full 11-player squad HUNG FIFA. Workflow wf_0bc80ab3 (5 agents,
# adversarially verified) proved: the deserializer PARSES our JSON fine; the
# freeze is POST-PARSE, at the per-item finalize resolve 0x180141176 (call
# singleton 0x18011a830 -> [r9+0xa08]) that fires for EVERY parsed item object
# (manager, players[].itemData, actives[]). Whether that resolve BLOCKS offline
# on an unresolvable item is UNVERIFIED -> we bisect it empirically with a ladder.
#
# The ladder (select via env FUT_SQUAD_STEP, default "s1"). Each step is one
# small change so a single FIFA relaunch isolates one variable:
# s1 zero-resolve: players are bare {index,kitNumber}, no itemData, manager=[]
# -> item deser NEVER entered, resolve fires 0 times. Tests envelope+HTTP
# framing only. Reaches hub => hang IS item-resolve. Freezes => framing.
# s1b players[0] gets an EMPTY itemData {id:0,dream:false} (still no real asset)
# -> resolve fires once on id 0. Freezes => id-0 resolve itself blocks
# offline. Reaches hub => id-0 is fine, the asset value is what matters.
# s2v0 players[0] = ONE real item, resourceId==assetId (version byte 0x00);
# club serves the same item. Renders => version 0 is correct.
# s2v1 same but version byte 0x01 (resourceId = 0x01<<24|assetId).
# s3v0 full XI with the winning version byte (default 0x00); club in lockstep.
# s3v1 full XI, version 0x01.
# resourceId decompose 0x180166ca0 CONFIRMED: assetId = resourceId & 0xffffff,
# high byte = version. Version byte value is the open question s2v0/s2v1 answer.
# ---------------------------------------------------------------------------
import os, sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from fut_account import ACCOUNT # single source of truth for identity
# Back-compat snapshot; prefer ACCOUNT.persona_id in new code.
PERSONA_ID = ACCOUNT.persona_id
ITEM_ID_BASE = 100000000
# Real FIFA17 assetIds read earlier from the live InGameDB. assetId 41 (Iniesta)
# was flagged by the verifier as possibly having NO InGameDB definition -> it is
# DROPPED from the XI until a live test confirms a replacement. (asset, rating, pos, kit)
REAL_XI = [
(20801, 94, "LW", 7), # Ronaldo -- STEP-2 uses this one
(158023, 93, "RW", 10), # Messi
(200389, 87, "GK", 1), # Oblak
(183907, 90, "CB", 5), # Boateng
(155862, 89, "CB", 4), # Ramos
(197445, 87, "LB", 2), # Alaba
(189332, 86, "LB", 3), # Alba
(182521, 88, "CM", 8), # Kroos
(183277, 88, "LM", 11), # Hazard
(176580, 92, "ST", 9), # Suarez
# (41, 88, "CM", ..) DROPPED: verifier says no InGameDB def -> stall risk
]
def player_item(asset, rating, pos, version=0x00, nation=38, team=243, league=53,
attrs=(90, 93, 82, 91, 33, 80)):
"""FULL item -- in case the card system needs more than the minimal set to
PLACE + render a real player (the minimal item rendered generic + rating 0).
Defaults are Ronaldo (Portugal 38 / Real Madrid 243 / La Liga 53)."""
rid = (version << 24) | asset
return {
"id": ITEM_ID_BASE + (asset & 0xffffff) + 1, # unique, != 0
"resourceId": rid,
"assetId": asset,
"cardassetid": asset,
# definitionId is INERT in FIFA 17: it is not in the atom table at all, so
# the client's key hash never matches and it routes straight to the value-SKIP
# handler 0x180135ff0 -- same class as the itemDbVersion/checkServerDbVersion
# keys proven phantom in blaze_responder. Kept (harmless, and other FIFA
# versions do use it) but it is NOT read here; the live key is resourceId.
"definitionId": rid,
"cardsubtypeid": 0, # 0..3 => PLAYER
"itemType": "player",
"rareflag": 1,
"rating": rating,
"preferredPosition": pos, # STRING enum "GK"/"CB"/...
"nation": nation,
"teamid": team,
"leagueId": league,
"playStyle": 250,
"attributeList": [{"index": i, "value": v} for i, v in enumerate(attrs)],
"itemState": "free",
"owners": 1,
"untradeable": True,
"contract": 7,
"fitness": 99,
"loans": 0,
"discardValue": 0,
"statsList": [],
"lifetimeStats": [],
}
def _base_squad():
"""Envelope shared by every step: valid formation/custom/kicktakers, but
players are bare {index,kitNumber} (index is the direct hash bucket key,
MUST be unique 0..22) and manager empty -> zero item-deser calls by default."""
return {
"id": 0,
"personaId": ACCOUNT.persona_id, # must equal logged-in persona (0x18014659c)
"squadName": ACCOUNT.squad_name,
"formation": "f442",
"squadType": "REGULAR_SQUAD",
"chemistry": 100,
"starRating": 5,
"captain": 0,
"changed": 0,
"manager": [],
"actives": [],
"custom": "[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,"
"50,50,0,50,40,65,0,65,50,50,1]",
"players": [{"index": i, "kitNumber": 0} for i in range(23)],
"kicktakers": [{"index": i, "id": 0, "dream": False} for i in range(5)],
}
def _put_item(squad, index, itemdata, kit=0):
squad["players"][index] = {"index": index, "itemData": itemdata, "kitNumber": kit}
def make_squad(step="s1"):
"""Return (squad, club) for a ladder step. club is {"itemData":[...]}."""
step = step.lower()
squad = _base_squad()
club_items = []
if step == "s0":
# ABSOLUTE minimal squad: no custom (only field re-parsed by a sub-reader,
# 0x1801c8270 -- prime suspect for the reader EOF-spin), empty players &
# kicktakers arrays. Reaches hub => envelope OK, spin is custom/players/
# kicktakers -> add back one at a time. Freezes => any squad object spins.
squad.pop("custom", None)
squad["players"] = []
squad["kicktakers"] = []
elif step == "s1":
pass # bare players, empty club
elif step == "s1b":
_put_item(squad, 0, {"id": 0, "dream": False}) # one empty item, no asset
elif step in ("s2v0", "s2v1"):
version = 0x00 if step.endswith("v0") else 0x01
asset, rating, pos, kit = REAL_XI[0] # Ronaldo
it = player_item(asset, rating, pos, version)
_put_item(squad, 0, it, kit)
squad["captain"] = it["id"]
club_items = [it]
elif step in ("s3v0", "s3v1"):
version = 0x00 if step.endswith("v0") else 0x01
for slot, (asset, rating, pos, kit) in enumerate(REAL_XI):
it = player_item(asset, rating, pos, version)
_put_item(squad, slot, it, kit)
club_items.append(it)
squad["captain"] = club_items[0]["id"]
else:
raise ValueError("unknown FUT_SQUAD_STEP=%r (s1|s1b|s2v0|s2v1|s3v0|s3v1)" % step)
return squad, {"itemData": club_items}
def squad_rating(squad):
"""Squad rating = mean of the rated players actually placed (0 when the squad
is empty, e.g. the s1 zero-resolve ladder step)."""
ratings = [(pl.get("itemData") or {}).get("rating", 0)
for pl in squad.get("players", [])]
ratings = [r for r in ratings if isinstance(r, int) and r > 0]
return sum(ratings) // len(ratings) if ratings else 0
def squad_summary(squad):
"""One FutSquadList element (deser 0x180141fc0, verified 2026-08-03).
Exactly six atoms are recognised; everything else is SKIP'd:
rating 0x274 int (scalar getter 0x1801c79d0)
chemistry 0x81 int (scalar getter 0x1801c79d0)
formation 0x12b STRING (string getter 0x1801c7aa0 -> enum conv 0x180166590)
id 0x15c int
squadName 0x2d3 STRING
squadType 0x2d6 STRING (string getter 0x1801c7aa0 -> enum conv 0x1801668e0)
NOTE: formation/squadType are STRINGS here, exactly as in the full-squad parser
0x18013d1f0 -- they go through the same 0x1801c7aa0 + converter pair. (The
rebuild plan's "<int>" for those two was wrong; feeding ints to a string getter
is the classic type-mismatch freeze at 0x1801c7f1a.)
"""
return {
"rating": squad_rating(squad),
"chemistry": int(squad.get("chemistry", 0)),
"formation": squad.get("formation", "f442"),
"id": int(squad.get("id", 0)),
"squadName": squad.get("squadName", ACCOUNT.squad_name),
"squadType": squad.get("squadType", "REGULAR_SQUAD"),
}
# Selected at import time from the environment (default s1 = the zero-resolve test).
STEP = os.environ.get("FUT_SQUAD_STEP", "s1")
SQUAD, CLUB = make_squad(STEP)
# Back-compat exports for utas_server.
USER_LIST = {"user": []}
@@ -0,0 +1,280 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Manager (and, later, coach) cards for the FIFA 17 offline backend.
HAND-OVER MODULE. Nothing here is imported by utas_server / fut_store / fut_cards
yet -- those three have a single writer. This file is the data plus the exact item
shape that the integrator should wire in. See the spec at the bottom.
WHY A MANAGER ITEM LOOKS NOTHING LIKE A PLAYER ITEM
---------------------------------------------------
Both go through the SAME deserializer, `FUN_18013fe00` (1234 instructions, fully
enumerated -- record base is RBP+0x160, the record is memset to zero at
0x180140020 and spans rec+0x00..0x157). At the tail it calls the merge dispatcher
`FUN_180141660`, which switches on rec+0x4c (the cardtype that `FUN_1800d8330`
derives from `cardsubtypeid` alone) and, for cardtype 2, calls `FUN_1801356c0`:
SELECT firstname,lastname,assetid,value,talkrating,negotiation,rare
FROM managercards WHERE carddbid == *(u32*)(rec+0x18) <-- RAW, no & 0xffffff
on rowcount > 0: rec+0xb8 firstname(16) rec+0xc8 lastname(21)
rec+0x20 assetid rec+0xb4 rating ( = `value` )
rec+0xe2 talkrating rec+0xe3 negotiation
rec+0x58 (rare == 1)
on rowcount < 1: NOTHING. There is no else-branch. A wrong manager id is silent.
So every field above is the CLIENT's to fill and ours to leave out. What the merge
does NOT write is ours alone, and the parser reserves two slots specifically for a
manager (verified at instruction level, 0x180140e1c..0x180140e37):
MOV [RBP+0x1ac],EAX ; rec+0x4c = cardtype
DEC EAX / JZ ...e32 ; cardtype == 1 -> player
DEC EAX / JNZ ...e3e ; cardtype != 2 -> the JSON `nation` is DISCARDED
MOVZX EAX,word [RSP+0x38]
MOV word [RBP+0x23e],AX ; rec+0xde <-- MANAGER nation
...
MOV word [RBP+0x2a8],AX ; rec+0x148 <-- PLAYER nation
`leagueId` (atom 0x18a) is stored unconditionally as a u16 at rec+0xe0
(0x180140739). For a player the merge immediately overwrites rec+0xdd.. with the
knownAs string, so it only survives on a card whose merge does not write there --
i.e. a manager. rec+0xde nation, rec+0xe0 league, rec+0xe2 talkrating,
rec+0xe3 negotiation is one contiguous manager block, and it is exactly the
league+nation pair that manager chemistry is built on.
CONCLUSION, and it inverts the player rule: for a PLAYER we send zero and let the
client fill in; for a MANAGER the client fills in name/rating/rare/assetid and
WE are the only source of nation and league.
"""
import json, os
HERE = os.path.dirname(os.path.abspath(__file__))
TABLES = os.path.join(os.path.dirname(HERE), "data", "tables")
def _table(name):
with open(os.path.join(TABLES, name + ".json")) as f:
return json.load(f)["rows"]
def _fix(s):
"""db_dump wrote UTF-8 bytes through a latin-1 decode; undo it."""
try:
return s.encode("latin-1").decode("utf-8")
except Exception:
return s
def _build():
"""managercards x manager x leagueteamlinks -> one row per manager card.
`managercards` (417 rows, carddbid 1000001..1001552, assetid == carddbid for
all 417) has firstname/lastname as 32-bit string-pool offsets whose pool was
never located, so it gives us no names. The `manager` table (747 rows,
managerid 1..1631) has INLINE names, and the client itself joins the two:
`FUN_1801bb060` does
SELECT firstname,surname,headid,suittypeid,skintonecode,... FROM manager
WHERE managerid == (*(u32*)(rec+0x18) & 0xffffff) - 1000000
to build the in-match manager. carddbid - 1000000 == managerid, confirmed by
the names it produces: 1000089 Wenger 86, 1000509 Luis Enrique 88, 1000417
Guardiola 87, 1000113 Zidane 88. 297 of the 417 join; the 120 that do not are
the 1001432+ tail, all rated 57, with no career appearance row.
Names are for OUR logs only. We never put a name on the wire -- the client
writes its own into rec+0xb8/+0xc8 from managercards.
"""
# `manager` has 747 rows but only 746 distinct managerids: managerid 107 appears
# twice, once as Leonid Slutskiy (teamid 315) and once as an EMPTY-name row
# (teamid 1357). A plain dict comprehension keeps the last, which silently gave
# carddbid 1000107 a blank name and the wrong club. Prefer the row that has a name.
mgr = {}
for r in _table("manager"):
prev = mgr.get(r["managerid"])
if prev is None or (not (prev["firstname"] or prev["surname"])
and (r["firstname"] or r["surname"])):
mgr[r["managerid"]] = r
league_of = {}
for r in _table("leagueteamlinks"):
league_of.setdefault(r["teamid"], r["leagueid"])
out = []
for r in _table("managercards"):
m = mgr.get(r["carddbid"] - 1000000)
team = m["teamid"] if m else 0
out.append({
"carddbid": r["carddbid"], # the merge key; also the artwork key
"rating": r["value"], # the client writes this itself
"rare": r["rare"], # the client writes this itself
"nation": r["nation"], # OURS: rec+0xde
"leagueId": league_of.get(team, 0), # OURS: rec+0xe0
"teamid": team, # OURS: rec+0x94
"name": _fix(m["firstname"] + " " + m["surname"]) if m else "",
})
return out
MANAGERS = _build()
BY_ID = {m["carddbid"]: m for m in MANAGERS}
# Ten real carddbids spread across the whole 57..88 rating band, every one of them
# joined to a `manager` row (so the portrait resolves) and to a league (so the
# league logo and the league half of chemistry resolve).
STARTER_MANAGERS = [
1000509, # Luis Enrique 88 Spain(45) LaLiga(53) Barcelona
1000183, # Antonio Conte 87 Italy(27) Premier(13) Chelsea
1000089, # Arsene Wenger 86 France(18) Premier(13) Arsenal
1000414, # Juergen Klopp 84 Germany(21) Premier(13) Liverpool
1000096, # Ronald Koeman 82 Netherlands(34) Premier(13) Everton
1000052, # Alan Pardew 80 England(14) Premier(13) Crystal Palace
1000034, # Eusebio Di Francesco 78 Italy(27) Serie A(31) Sassuolo
1000090, # Peter Maes 75 Belgium(7) Pro League(4) Genk
1000019, # Dariusz Wdowczyk 70 Poland(37) Ekstraklasa(66) Wisla Krakow
1000505, # Martin Canning 65 Scotland(42) Scottish Prem(50) Hamilton
]
MANAGER_SUBTYPE = 4 # cardsubtypeid 4 -> FUN_1800d8330 -> cardtype 2
# The four staff families that DO write a loud miss-fill, for use as positive
# controls: firstname/lastname "DB Error", rating 0x32, rare 1, and a table-unique
# assetid. subtype -> (cardtype, table, a deliberately INVALID carddbid).
STAFF_CONTROLS = {
5: (3, "headcoachcards", 2999999),
6: (10, "gkcoachcards", 9999999),
7: (5, "physiocards", 4999999),
8: (4, "fitnesscoachcards", 3999999),
}
def manager_item(item_id, carddbid, rating=None, contract=7, untradeable=True):
"""The manager item JSON. Every key is justified; nothing else is sent.
id -> rec+0x08 our item handle, needed for move/quick-sell
resourceId -> rec+0x18 THE merge key, read RAW as a u32. It must equal
carddbid exactly: no version byte, because
FUN_1801356c0 does not mask. The same field, masked
to &0xffffff, is what the view-model (FUN_1800d7920,
p2[1]) and FUN_1801bb060 use as the artwork key.
cardsubtypeid -> rec+0x50 4. This alone selects the managercards merge.
itemType -> DISCARDED atom 0x173 is parsed into a stack std::string at
RBP+0xc8 (0x180140262..0x180140279) and freed at the
end of the function. It is not written into the
record: all 1234 instructions of FUN_18013fe00 were
enumerated and every store and every LEA into
RBP+0x160..0x2b7 accounted for; none comes from that
string. "staff" is therefore inert on the wire, and
is used only so OUR OWN readers can see at a glance
that this is not a footballer. Nothing may depend on
it -- see the spec note about the three filters in
utas_server that currently key on itemType.
nation -> rec+0xde MANAGER-ONLY SLOT. Not touched by the merge, so this
is the only source. Nation half of chemistry + flag.
leagueId -> rec+0xe0 Same: ours alone. League half of chemistry + logo.
teamid -> rec+0x94 read by the view-model (p2[2]). The manager's real
club, from the `manager` table.
contract -> rec+0x8c staff cards consume contracts like players do.
itemState -> rec+0x5c "free" == not listed; same meaning as for a player.
owners -> rec+0x48
untradeable -> rec+0x49
DELIBERATELY ABSENT, each because it provably does nothing on a manager card:
assetId / cardassetid rec+0x20 is overwritten by the merge with the DB
assetid (which equals carddbid for all 417 rows).
definitionId not one of the 52 atoms this parser handles -> routed
to the value-SKIP handler FUN_180135ff0.
rating rec+0xb4 is overwritten by the merge with `value`.
Pass rating=<sentinel> ONLY for the live probe below.
rareflag rec+0x58 is overwritten with (rare == 1). This is also
why fut_store._item()'s hardcoded "rareflag": 1 -- the
trap that silently converts a Player Fitness consumable
into a Squad Fitness one -- cannot hurt a manager.
preferredPosition rec+0x146 survives the merge and is read by the
view-model, so sending it would hang a position label
on a manager.
attributeList rec+0x98.. likewise survives and is likewise read.
playStyle / fitness player-only.
"""
it = {
"id": item_id,
"resourceId": carddbid,
"cardsubtypeid": MANAGER_SUBTYPE,
"itemType": "staff",
"nation": BY_ID[carddbid]["nation"],
"leagueId": BY_ID[carddbid]["leagueId"],
"teamid": BY_ID[carddbid]["teamid"],
"contract": contract,
"itemState": "free",
"owners": 1,
"untradeable": untradeable,
}
if rating is not None:
it["rating"] = rating
return it
def staff_control_item(item_id, subtype, carddbid=None):
"""A self-labelling positive control. The four coach arms of FUN_180141660 all
write, on rowcount < 1: firstname = lastname = "DB Error", rec+0xb4 = 0x32,
rec+0x58 = 1, and a table-unique assetid. A deliberately invalid id therefore
puts the words DB ERROR on screen, which proves the whole chain ran."""
ct, table, bad = STAFF_CONTROLS[subtype]
return {
"id": item_id,
"resourceId": carddbid if carddbid is not None else bad,
"cardsubtypeid": subtype,
"itemType": "staff",
"itemState": "free",
"owners": 1,
"untradeable": True,
}
OVERLAY_ID_BASE = 960000000 # the club overlay's ids: clear of the save (1e8), the
# sweep (9e8), the consumable shelf (9.4e8), the coach
# shelf (9.5e8) and the probe below (9.1e8)
PROBE_ID_BASE = 910000000 # distinct from the save (1e8) and the sweep (9e8)
PROBE_SENTINEL_RATING = 7 # nowhere near the 57..88 band `value` can produce
def probe_items(carddbid=1000509):
"""THE live test. Four items, one club fetch, every outcome interpretable.
A real manager, sentinel rating 7
B the same manager with a version byte in the top octet of resourceId
C a headcoach id that cannot exist -> must render "DB Error"
D a manager id that cannot exist -> the silent-miss reference
"""
n = PROBE_ID_BASE
m = BY_ID[carddbid]
a = manager_item(n + 1, carddbid, rating=PROBE_SENTINEL_RATING)
b = manager_item(n + 2, carddbid, rating=PROBE_SENTINEL_RATING)
b["resourceId"] = (1 << 24) | carddbid
c = staff_control_item(n + 3, 5)
d = manager_item(n + 4, carddbid, rating=PROBE_SENTINEL_RATING)
d["resourceId"] = 1009999
d["nation"] = m["nation"]
d["leagueId"] = m["leagueId"]
d["teamid"] = m["teamid"]
return [a, b, c, d]
if __name__ == "__main__":
import sys
if "--probe" in sys.argv:
print(json.dumps({"itemData": probe_items()}, indent=1))
elif "--starter" in sys.argv:
print(json.dumps([manager_item(100900000 + i, c)
for i, c in enumerate(STARTER_MANAGERS)], indent=1))
else:
print("%d manager cards, carddbid %d..%d, rating %d..%d, %d rare, "
"%d with a `manager` row"
% (len(MANAGERS), MANAGERS[0]["carddbid"], MANAGERS[-1]["carddbid"],
min(m["rating"] for m in MANAGERS),
max(m["rating"] for m in MANAGERS),
sum(m["rare"] for m in MANAGERS),
sum(1 for m in MANAGERS if m["name"])))
for c in STARTER_MANAGERS:
m = BY_ID[c]
print(" %d %-24s rating %2d rare %d nation %3d league %3d team %6d"
% (c, m["name"], m["rating"], m["rare"], m["nation"],
m["leagueId"], m["teamid"]))
@@ -0,0 +1,843 @@
"""Persistent FUT profile store for the FIFA 17 offline backend (OpenFUT).
A single JSON file holds the user's save: coins/points, owned club items, squads,
and record. Loaded once, saved on every mutation. This is the "user + saved files"
layer; packs and the starter grant build on it. (Later this gets ported into
openfut-core's SQLite backend behind a FIFA-17 bridge; JSON keeps iteration fast
while we nail the wire format.)
Item shape mirrors what /club and /squad already serve (fut_seed.player_item):
resourceId/assetId + attrs; identity (name/photo/club/nation) resolves locally in
FIFA from dbdata.dll on the club-search/add path (see docs/CARD_SYSTEM.md).
"""
import json, os, sys, threading
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
import fut_cards
from fut_account import ACCOUNT # single source of truth for identity/club
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 ------------------
#
# quick_sell() used to pay an invented rating tier (600/300/150/50). That number
# was wrong for every card. The real table is `fcc_discardcoins` in the client's
# own game DB, 141 rows keyed (cardtype, level, rare) -> price, recovered from the
# running client 2026-08-05 and verified against 22 live club items, 22/22 exact.
#
# The client computes the DISPLAYED value itself with the same table whenever our
# `discardValue` (atom 0xd7) is 0 or absent: FUN_18013fe00 stores our value at item
# +0x38, and the guard at 0x180141025 (`cmp dword [rbp+0x198],0` / `ja`) skips the
# local computation when it is non-zero. So today the client shows the real value
# while the server pays a made-up one, and the two disagree on every card. This
# makes the paid value agree with the shown value.
#
# value = round_half_up(rating * price / 100)
# level = 3 if rating >= 75, 2 if 65..74, else 1 (0x180141e8a..0x180141ea3;
# derived from rating, NOT a wire field)
# cardtype = FUN_1800d8330(cardsubtypeid), decoded from its jump table and
# checked across every subtype 0..599 with zero disagreements
#
# ZERO WIRE CHANGE. Nothing new is sent; only the coin figure the server credits
# changes. Default off per the house rule, but this is the one patch worth
# defaulting on after a single verification.
# See docs/plan-2026-08-05-store-subsystem.md section 3.6.
DISCARD_TABLE = os.environ.get("FUT_DISCARD_TABLE", "0") == "1"
_DP = {}
def _dp(ct, rares, p1, p2, p3):
for r in rares:
_DP[(ct, 1, r)] = p1
_DP[(ct, 2, r)] = p2
_DP[(ct, 3, r)] = p3
_dp(1, [0], 30, 150, 400)
_dp(1, [1], 75, 350, 800)
_dp(1, [7], 1500, 5000, 9000)
_dp(1, [2, 3, 10, 13] + list(range(17, 32)), 2000, 7000, 12200)
_dp(1, [4, 8, 9], 6000, 10000, 18000)
_dp(1, [11], 10000, 15000, 24000)
_dp(1, [5, 6], 20000, 40000, 80000)
_dp(1, [12], 120000, 120000, 120000)
_dp(2, [0], 20, 70, 110)
_dp(2, [1], 25, 120, 320)
for _ct in (3, 4, 5, 10):
_dp(_ct, [0], 10, 55, 110)
_dp(_ct, [1], 50, 100, 300)
for _ct in (6, 7, 8, 9):
_dp(_ct, [0], 5, 20, 40)
_dp(_ct, [1], 20, 50, 70)
def _cardtype(sub):
"""FUN_1800d8330. 0 means no table row, which the client renders as value 0."""
if sub is None:
return 0
if 0 <= sub <= 3:
return 1
if sub == 4:
return 2
if sub == 5:
return 3
if sub == 6:
return 10
if sub == 7:
return 5
if sub == 8:
return 4
if 9 <= sub <= 11:
return 7
if sub in (30, 31, 231, 232, 233, 236) or 145 <= sub <= 150:
return 9
if (51 <= sub <= 136) or (201 <= sub <= 220) or (250 <= sub <= 273) \
or (300 <= sub <= 341):
return 6
return 0
def discard_value(item):
"""round_half_up(rating * price / 100), price from fcc_discardcoins.
Returns None when the formula does not apply, so callers fall back instead of
paying nothing. THE UNRATED-CARD CASE IS NOT COVERED BY THE RECOVERED FORMULA:
it was verified 22/22 against club items, all of which were rated players, and
`rating * price / 100` collapses to 0 for a staff card carrying no rating. Found
by running the whole save through it, where exactly one item (a staff card,
cardsubtypeid 8, rating None) came back 0 while the old tier paid 50. Paying 0 for
a card the previous code paid for is a regression, so unrated cards fall back.
What FUT really pays for staff and consumables is UNKNOWN and worth recovering;
the likely answer is the unscaled table price, but that is a guess and is not
shipped as one.
"""
r = item.get("rating")
if not r:
return None
ct = _cardtype(item.get("cardsubtypeid"))
r = int(r)
lvl = 3 if r >= 75 else 2 if r >= 65 else 1
price = _DP.get((ct, lvl, int(item.get("rareflag") or 0)), 0)
if not price:
return None # no table row: the client renders 0, we should not
n = r * price
return n // 100 + (1 if n % 100 >= 50 else 0)
# Back-compat snapshots. Identity now lives in fut_account.ACCOUNT so Blaze, LSX
# and UTAS cannot drift apart; prefer ACCOUNT.<field> in new code. These are
# import-time snapshots and will NOT reflect a later adopt_from_auth().
PERSONA_ID = ACCOUNT.persona_id
PERSONA_NAME = ACCOUNT.persona_name
_LOCK = threading.Lock()
# Starter squad granted on first run (real FIFA17 assetIds; identity resolves
# locally in-game). (assetId, rating, position, nation, leagueId, teamid, [6 attrs])
STARTER_PLAYERS = [
(20801, 94, "LW", 38, 53, 243, [90, 93, 82, 91, 33, 80]), # Ronaldo
(158023, 93, "RW", 52, 53, 241, [89, 90, 86, 96, 26, 61]), # Messi
(200389, 87, "GK", 44, 53, 240, [86, 88, 47, 88, 25, 86]), # Oblak
(183907, 90, "CB", 21, 19, 21, [80, 58, 70, 71, 89, 85]), # Boateng
(155862, 89, "CB", 45, 53, 243, [77, 62, 72, 74, 87, 84]), # Ramos
(197445, 87, "LB", 40, 19, 21, [86, 68, 79, 82, 82, 79]), # Alaba
(189332, 86, "LB", 45, 53, 241, [92, 66, 78, 84, 80, 71]), # Alba
(182521, 88, "CM", 21, 19, 21, [61, 79, 89, 82, 72, 74]), # Kroos
(183277, 88, "LM", 7, 13, 5, [89, 80, 82, 92, 37, 62]), # Hazard
(176580, 92, "ST", 60, 53, 241, [83, 90, 79, 87, 42, 80]), # Suarez
]
ITEM_ID_BASE = 100000000
# cardsubtypeid 219 is Player Fitness. FUN_1801bfac0 case 5 (consumable category 5)
# takes the SQUAD-fitness branch when `(subtype == 0xdc) || FUN_1801a88c0(rec)`, and
# FUN_1801a88c0 is exactly `*(int *)(rec + 0x58) == 1` -- rec+0x58 being the rareflag
# atom 0x271. So a Player Fitness card sent with rareflag 1 silently RENDERS as a
# Squad Fitness card (name FUT_CONSUMABLE_NAME_SQUADTRAINING, artwork 5000011 instead
# of 5000010) and has its single-target count at param_5+0x1bc forced to 0.
#
# rec+0x58 is read TWICE in that 42,813-char render function: unconditionally near the
# top into param_5+0x1f0 (the rare/backing flag, every cardtype), and via FUN_1801a88c0
# in category 5 only. So this guard changes two things for subtype 219 -- the card also
# stops being drawn as rare -- and that is intended: a Player Fitness card must not be
# rare, because rare IS the squad-fitness selector.
#
# Players are untouched: every existing caller passes 8 positional arguments, so
# cardsubtypeid defaults to 0, 0 != 219, and the dict is byte-identical to before.
_SQUAD_FITNESS_TRAP = 219
# FUT_TRADEABLE: send untradeable=false so the client's tradeable byte gets set.
#
# "Place on Transfer List" and "List on Transfer Market" are greyed out on every card,
# and BOTH gates are ours. FUN_1801a7260, the TO_TRADE_PILE predicate published by
# FUN_18003e370, returns 1 only if the service gate at vtable+0x270 is non-zero AND
# item+0x49 is non-zero. The deserializer stores untradeable INVERTED (case 0x361 does
# CONCAT11(cVar6 == '\0', ...)), so untradeable:true writes 0 and kills the flag.
#
# THIS FLAG ALONE IS NOT ENOUGH, and shipping it alone will look like the finding
# failed. The other gate is `movzx eax, byte [rcx+0x1fd2e]; ret`, and 0x1fd2e is the
# tradingEnabled gate byte. Measured live 2026-08-06 as 0, while friendlySeasons
# (0x1fd3a), draftMode (0x1fd3d) and packOpeningAnimation (0x1fd45) all read 1 in the
# same walk. tradingEnabled is the only gate byte yet found that is not already 1, and
# it is ALREADY in _SETTINGS_KEEP: it has simply never been sent, because
# _SETTINGS_MODE defaults to off. So the run needs FUT_SETTINGS=keep beside this.
#
# Freeze risk: none beyond what we already send. untradeable is atom 0x361 read by the
# BOOL primitive FUN_1801c7620, and we already send the key on every card; only the
# value changes. The constructor default for +0x49 is 1 (tradeable), so false moves
# the field toward the client's own default rather than away from it.
#
# Side effects, both permissive rather than restrictive: item+0x49 also feeds
# FUN_1800bc580, which counts untradeable squad members and publishes UNTRADABLE_COUNT,
# which gates squad submission in FUN_1800bba10 (today that takes the
# couldNotSubmitSquad branch).
TRADEABLE = os.environ.get("FUT_TRADEABLE", "0") == "1"
def _item(item_id, asset, rating, pos, nation, league, team, attrs, version=0x00,
cardsubtypeid=0, rareflag=1):
return _with_discard({
"id": item_id,
"resourceId": (version << 24) | asset,
"assetId": asset,
"cardassetid": asset,
"definitionId": (version << 24) | asset,
"cardsubtypeid": cardsubtypeid,
"itemType": "player",
"rareflag": 0 if cardsubtypeid == _SQUAD_FITNESS_TRAP else rareflag,
"rating": rating,
"preferredPosition": pos,
"nation": nation,
"teamid": team,
"leagueId": league,
"playStyle": 250,
"attributeList": [{"index": i, "value": v} for i, v in enumerate(attrs)],
"itemState": "free",
"owners": 1,
"untradeable": not TRADEABLE,
"contract": 7,
"fitness": 99,
})
# discardValue is stamped HERE, inside the single item factory, so every path that
# builds an item gets it: pack contents, the starter grant, club reads and market
# listings alike. Stamping it at one call site would leave the reveal screen and
# 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.
#
# Measured live 2026-08-06. With FUT_DISCARD_TABLE on, the server correctly paid 600
# for a 75-rated rare gold (9,844,900 -> 9,845,500, exact) while the reveal screen
# showed "Quick Sell 0", and "Quick Sell all remaining Items" showed 0 too. So the
# figure was right and invisible, and the screen contradicted the wallet.
#
# The cause is the guard the table work reversed. FUN_18013fe00 stores our
# discardValue at item +0x38; at 0x180141025 a `cmp dword [rbp+0x198],0` / `ja` skips
# the client's own local computation when that value is NON-ZERO. We seed 0, so the
# client runs its own fcc_discardcoins lookup, that lookup returns no row for our
# cards, the price register stays 0, and it renders 0. WHY its lookup misses is still
# UNKNOWN and worth knowing, but it does not have to be answered to fix the display:
# sending a non-zero value bypasses the lookup entirely and the client uses ours.
#
# Freeze risk: low and in the safe direction. discardValue is a plain INT read by the
# scalar getter 0x1801c79d0. The freezes on this project have all come from feeding an
# object or array where a scalar was expected, never the reverse.
#
# Requires FUT_DISCARD_TABLE, since without the real table this would put the invented
# tier on screen and make a wrong number authoritative-looking rather than merely paid.
DISCARD_SEND = os.environ.get("FUT_DISCARD_SEND", "0") == "1" and DISCARD_TABLE
def _with_discard(it):
"""Apply the read-path flags to one item.
Two things, both of which MUST happen on read and not only at creation: the
saved profile holds 246 items minted long before either flag existed, and the
club route serves them straight out of the save. Stamping only in _item() left
the wire carrying untradeable:true with FUT_TRADEABLE=1 set, which was caught by
reading the served JSON rather than by unit-testing the factory.
Callers pass a COPY, so the save is never mutated by a read.
"""
if DISCARD_SEND:
# Omit the key entirely when the formula does not apply, rather than sending
# 0: a 0 makes the client fall back to its own lookup, and the tile binds our
# value anyway, so 0 renders as 0.
v = discard_value(it)
if v:
it["discardValue"] = v
if TRADEABLE:
it["untradeable"] = False
return it
def _new_profile():
"""First-run grant: opening coins + the starter squad as owned items."""
items = [_item(ITEM_ID_BASE + i + 1, a, r, p, n, lg, tm, at)
for i, (a, r, p, n, lg, tm, at) in enumerate(STARTER_PLAYERS)]
return {
"version": 1,
# personaId/personaName/clubName/clubAbbr/established are NOT seeded
# here any more -- they belong to fut_account.ACCOUNT. _sync_identity()
# mirrors them into the save on every load so existing readers
# (utas_server's userInfo, tradepile sellerName) keep working unchanged
# and can never disagree with what Blaze/LSX assert.
"coins": 15000,
"points": 0,
"record": {"won": 0, "draw": 0, "loss": 0},
"nextItemId": ITEM_ID_BASE + len(STARTER_PLAYERS) + 1,
"items": items, # owned club items
"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,
}
class Store:
def __init__(self, path=PROFILE_PATH):
self.path = path
self._p = None
def load(self):
if self._p is not None:
return self._p
if os.path.exists(self.path):
with open(self.path) as f:
self._p = json.load(f)
else:
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
def _sync_identity(self):
"""Mirror ACCOUNT's identity/club into the in-memory save.
The save file used to OWN these five keys; they now live in
fut_account.json (which is where ACCOUNT migrated them from on first
run, so this is a no-op for an existing profile). Mirroring rather than
deleting keeps every current reader working without an edit, and makes
drift between the save and the wire impossible by construction."""
p = self._p
p["personaId"] = ACCOUNT.persona_id
p["personaName"] = ACCOUNT.persona_name
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.
Call this after anything mutates ACCOUNT at runtime (utas_server's club
rename, or the /ut/auth persona adoption) so the save cannot lag a session
behind the wire. Identity itself is owned by fut_account.json -- this only
keeps the save's copy honest."""
with _LOCK:
self.load()
self._sync_identity()
self._save()
return self._p
def profile_identity(self):
"""Identity/club as served to the client. Sourced from ACCOUNT, never
from the save -- use this instead of profile().get("clubName")."""
return {
"personaId": ACCOUNT.persona_id,
"personaName": ACCOUNT.persona_name,
"clubName": ACCOUNT.club_name,
"clubAbbr": ACCOUNT.club_abbr,
"established": ACCOUNT.established,
}
def coins(self):
return self.load()["coins"]
def items(self):
# Stamp discardValue on READ as well as on creation. _item() only covers cards
# minted from now on, and the save already holds 246 items built before the
# flag existed; without this the reveal screen would show real values while
# the club showed 0 for everything older. Stamped on the way out and NOT
# persisted, so the save stays clean and turning the flag off is a true revert.
its = self.load()["items"]
return [_with_discard(dict(it)) for it in its] if DISCARD_SEND else its
def add_items(self, new_items):
with _LOCK:
p = self.load()
for it in new_items:
it.setdefault("id", p["nextItemId"]); p["nextItemId"] += 1
p["items"].append(it)
self._save()
return new_items
def spend(self, amount):
with _LOCK:
p = self.load()
if p["coins"] < amount:
return False
p["coins"] -= amount
self._save()
return True
def grant_coins(self, amount):
with _LOCK:
self.load()["coins"] += amount
self._save()
def quick_sell(self, ids):
"""Remove cards (from either pile) and credit their discard value.
-> (count_sold, coins_credited). discardValue is 0 on our seeded cards, so
fall back to a rating-based figure rather than paying nothing."""
def value(it):
dv = it.get("discardValue") or 0
if dv:
return int(dv)
if DISCARD_TABLE:
# The real table. Matches what the client displays once
# FUT_DISCARD_SEND puts the value on the wire.
v = discard_value(it)
if v is not None:
return v
# else: unrated card, formula does not apply, fall through
# The invented tier. Wrong for every card, kept only as the live-proven
# default until FUT_DISCARD_TABLE has been in front of the game once.
r = it.get("rating") or 0
return 600 if r >= 85 else 300 if r >= 80 else 150 if r >= 75 else 50
with _LOCK:
p = self.load()
want = {i for i in ids if i is not None}
total = 0
sold = 0
for pile in ("purchased", "items"):
keep = []
for it in p.get(pile, []):
if it.get("id") in want:
total += value(it)
sold += 1
else:
keep.append(it)
p[pile] = keep
if sold:
p["coins"] = p.get("coins", 0) + total
self._save()
return sold, total
def set_clientdata(self, key, value):
"""Persist an opaque client blob (ut/%s/clientdata/<key>). We never
interpret it -- the client wrote it, the client reads it back."""
with _LOCK:
p = self.load()
p.setdefault("clientdata", {})[key] = value
self._save()
def get_clientdata(self, key):
return self.load().get("clientdata", {}).get(key, {})
def record_match(self, result, coins):
"""Commit a finished match: bump the W/D/L record and credit coins.
`result` is "won" | "draw" | "loss". Returns the new (record, coins) so the
caller can build FutDestroyMatchServerResponse without a second read --
allCoins must be the balance AFTER crediting, and reading it separately
would race another mutation."""
with _LOCK:
p = self.load()
rec = p.setdefault("record", {"won": 0, "draw": 0, "loss": 0})
if result in rec:
rec[result] += 1
p["coins"] = p.get("coins", 0) + max(0, int(coins))
p.setdefault("matchesPlayed", 0)
p["matchesPlayed"] += 1
self._save()
return dict(rec), p["coins"]
def save_squad(self, squad):
with _LOCK:
p = self.load()
sid = squad.get("id", 0)
p["squads"] = [s for s in p["squads"] if s.get("id") != sid] + [squad]
self._save()
def move_items(self, requests):
"""FutMoveCard: transfer item(s) from the pending/purchased pile into their
target pile (FIFO's model), persist, return the moved cards. A purchased
card must NOT exist in both the purchased pile and the club, or the client
desyncs -> fatal logout. Cards live in profile["purchased"] until moved."""
with _LOCK:
p = self.load()
pending = p.setdefault("purchased", [])
by_id = {it["id"]: it for it in pending}
moved = []
for r in requests:
it = by_id.get(r.get("id"))
if it is None:
continue
pile = r.get("pile", it.get("pile", "club"))
it["pile"] = pile
if pile == "club":
it["itemState"] = "free"
p.setdefault("items", []).append(it)
moved.append(it)
if moved:
moved_ids = {it["id"] for it in moved}
p["purchased"] = [x for x in p["purchased"] if x["id"] not in moved_ids]
self._save()
return moved
def purchased(self):
# Stamped on read exactly like items(). Leaving this out was a real defect:
# the pending pile is the ONE place a quick-sell value is actually read, so
# the club showed real numbers while the reveal screen showed 0 for anything
# already sitting in the pile. Found by a verification pass, not by testing.
"""Items still held in the purchased/unassigned pile (returned by
GET /purchased/items); they move to the club via FutMoveCard (PUT /item)."""
pur = self.load().get("purchased", [])
return [_with_discard(dict(it)) for it in pur] if DISCARD_SEND else pur
def active_squad(self):
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
real players instead of empty slots ('active squad resets')."""
by_id = {it["id"]: it for it in self.items()}
out = dict(squad)
players = []
for pl in squad.get("players", []):
iid = (pl.get("itemData") or {}).get("id", 0)
if iid and iid in by_id:
players.append({**pl, "itemData": by_id[iid]})
else:
players.append(pl)
out["players"] = players
return out
# ---- transfer-market listings (user's own sale pile) -------------------
def list_for_sale(self, item_id, start, buynow):
with _LOCK:
p = self.load()
p.setdefault("listings", [])
p["listings"] = [l for l in p["listings"] if l.get("itemId") != item_id]
tid = 900500000 + p.get("nextListingSeq", 0)
p["nextListingSeq"] = p.get("nextListingSeq", 0) + 1
p["listings"].append({"tradeId": tid, "itemId": item_id,
"startingBid": start, "buyNowPrice": buynow})
self._save()
return tid
def listings(self):
return self.load().get("listings", [])
def remove_listing(self, tid):
with _LOCK:
p = self.load()
p["listings"] = [l for l in p.get("listings", []) if l.get("tradeId") != tid]
self._save()
def new_item_id(self):
with _LOCK:
p = self.load(); i = p["nextItemId"]; p["nextItemId"] += 1; self._save()
return i
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
not enough coins.
`tiers` is a weighted list of tier names, e.g. ["bronze"]*8 + ["silver"]*2,
so a bronze pack can actually contain bronzes. The old signature took a
single `gold` boolean and split the pool at rating 75, which with the old
18-player pool (all rated 85 to 94) meant EVERY pack, including the bronze
one, dealt gold rares. `gold` is still honoured when `tiers` is absent so
nothing that calls this the old way changes behaviour.
"""
import random
if not self.spend(price):
return None
# A real FUT pack is not eleven footballers. It is mostly players with a
# couple of consumables and the occasional staff card, which is what
# FUT_PACK_MIX reproduces. Kept as a RATIO of the pack size rather than a
# fixed number so it scales from a 5-card bronze to an 11-card premium.
n_extra = 0
extras = []
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:
# 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.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:
p = self.load()
p.setdefault("purchased", []).extend(items)
p["packsOpened"] += 1
self._save()
return items
def last_pack(self):
# Same stamping as purchased(); this is the reveal-screen read path.
pur = self.load().get("purchased", [])
return [_with_discard(dict(it)) for it in pur] if DISCARD_SEND else pur
# FUT_PACK_MIX: put non-player cards in packs.
#
# Consumables and staff are included because both are LIVE-PROVEN to render (staff on
# 2026-08-05 with zero DB Error, consumables the same day with real artwork once
# cardassetid was fixed). Club items are NOT included: cardtype 9 has no arm in the
# merge, so which cardsubtypeid means "ball" versus "stadium" is still unverified, and
# a pack is the worst place to discover that a subtype was wrong -- the card lands in
# the save and has to be cleaned out by hand.
PACK_MIX = os.environ.get("FUT_PACK_MIX", "1") == "1"
def _pack_extras(n, store):
"""n non-player cards for a pack: mostly consumables, occasionally staff."""
import random
out = []
for _ in range(n):
want_staff = random.random() < 0.25
it = None
if want_staff:
try:
import fut_staff
pool = list(fut_staff.STARTER_MANAGERS)
try:
import fut_coaches
pool += fut_coaches.starter_coaches(fut_coaches.COACH_ID_BASE)
except Exception:
pass
if pool:
it = dict(random.choice(pool))
except Exception:
it = None
if it is None:
try:
import fut_consumables as fc
shelf = fc.starter_consumables(fc.CONSUMABLE_ID_BASE)
if shelf:
it = dict(random.choice(shelf))
except Exception:
it = None
if it is None:
continue
it["id"] = store.new_item_id() # a pack card needs its OWN item id
out.append(it)
return out
# Card pool for packs. Now lives in fut_cards.py (79 players across three rating
# tiers, 7 leagues, 20 nations, 18 teams, every outfield position plus GK). The old
# 18-entry list below is kept ONLY as the starter-squad source and as the fallback
# for callers that still pass the legacy `gold` boolean.
PACK_POOL = fut_cards.POOL
_LEGACY_POOL = STARTER_PLAYERS + [
(167495, 90, "GK", 27, 19, 22, [86, 88, 52, 88, 22, 88]), # Neuer
(192985, 88, "RM", 21, 19, 22, [80, 82, 85, 85, 63, 68]), # De Bruyne
(188545, 89, "ST", 37, 16, 240, [77, 88, 75, 82, 42, 82]), # Lewandowski
(169193, 87, "CDM",54, 16, 240, [70, 66, 80, 78, 82, 84]), # Alonso(X)
(202126, 86, "ST", 18, 13, 5, [79, 84, 74, 82, 45, 79]), # Kane
(177003, 88, "CM", 14, 13, 5, [65, 78, 88, 79, 71, 66]), # Modric(X)
(190871, 87, "LW", 54, 16, 240, [90, 78, 80, 88, 36, 61]), # Neymar(X)
(184941, 85, "CB", 14, 4, 5, [72, 40, 55, 60, 86, 85]), # (X)
]
# 3 store packs (price in coins, card count, gold-only). Ids are stable.
# `tiers` is the weighted draw for each pack. A bronze pack is mostly bronze with a
# chance of silver, a gold pack is mostly gold. Before fut_cards existed the pool had
# 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, "specialChance": 0.005},
{"id": 5, "name": "Gold Pack", "price": 5000, "count": 7, "gold": True,
"tiers": ["gold"] * 6 + ["silver"] * 4, "specialChance": 0.03},
{"id": 6, "name": "Premium Gold", "price": 15000, "count": 11, "gold": True,
"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},
]
def pack_by_id(pid):
for p in PACK_CATALOG:
if p["id"] == pid:
return p
return None
STORE = Store()
@@ -0,0 +1,207 @@
#!/usr/bin/env python3
"""Read the UTAS request log, FILTERED TO THE REAL CLIENT BY DEFAULT.
futlog.py what the game asked for, one line each
futlog.py -v ... with request bodies and responses
futlog.py --all include our own curl/python probes
futlog.py --probes only our probes
futlog.py -s summary: request counts per endpoint
futlog.py --unmapped only requests that fell through to the catch-all
futlog.py -p item -v only paths matching a regex
futlog.py --since 11:15 only from that clock time onward
WHY THE FILTER IS THE DEFAULT, and why every future analysis tool here should do the
same. The log records User-Agent. The real client sends ProtoHttp; this project's own
probes send curl/* or Python-urllib/*. Reading the log unfiltered gave the project a
materially wrong picture of itself: /clubUser and /user/list had 93 and 180 recorded
hits and NOT ONE came from the game, while endpoints assumed to be exercised turned out
to be exercised only by us. Any claim of the form "the client asks for X" made before
this distinction existed is unsupported until re-checked with the filter on.
The corollary matters just as much: do NOT probe the live server while the client is
running. It pollutes the evidence you are collecting. Probe a scratch instance on
another port instead.
The log defaults to utas_server.py's own LOG path and can be pointed elsewhere with
FUT_LOG or a positional argument, because the harness has been started both ways.
"""
import argparse
import collections
import os
import re
import sys
LOG = os.environ.get("FUT_LOG", "/tmp/utas_server.log")
FALLBACKS = ("/tmp/utas_server.log", "/tmp/utas.log")
# The game. Everything else in this log is us.
CLIENT_UA = "ProtoHttp"
REQ_RE = re.compile(r"^\[(\d\d:\d\d:\d\d)\] (GET|POST|PUT|DELETE|HEAD|PATCH) (\S+)")
RES_RE = re.compile(r"^\[\d\d:\d\d:\d\d\]\s+-> (\d{3}) ?(.*)$")
HDR_RE = re.compile(r"^\[\d\d:\d\d:\d\d\]\s+([A-Za-z-]+): (.*)$")
BODY_RE = re.compile(r"^\[\d\d:\d\d:\d\d\]\s+body: (.*)$")
NOTE_RE = re.compile(r"^\[\d\d:\d\d:\d\d\]\s+([A-Z]{3,}): (.*)$")
class Req(object):
__slots__ = ("t", "method", "path", "ua", "body", "status", "resp", "notes", "unmapped")
def __init__(self, t, method, path):
self.t, self.method, self.path = t, method, path
self.ua = ""
self.body = ""
self.status = ""
self.resp = ""
self.notes = []
self.unmapped = False
@property
def is_client(self):
return CLIENT_UA in self.ua
@property
def base(self):
"""Path without the query string, for grouping."""
return self.path.split("?", 1)[0]
def resolve(path):
if path or os.path.exists(LOG):
return path or LOG
for f in FALLBACKS:
if os.path.exists(f):
return f
return LOG
def parse(path):
"""Log lines to Req objects. Tolerates a truncated final entry."""
out = []
cur = None
try:
fh = open(path, errors="replace")
except IOError as e:
sys.exit("cannot read %s: %s" % (path, e))
with fh:
for line in fh:
line = line.rstrip("\n")
m = REQ_RE.match(line)
if m:
cur = Req(*m.groups())
out.append(cur)
continue
if cur is None:
continue
m = RES_RE.match(line)
if m:
cur.status, cur.resp = m.group(1), m.group(2)
continue
m = BODY_RE.match(line)
if m:
cur.body = m.group(1)
continue
if "UNMAPPED" in line:
cur.unmapped = True
continue
m = HDR_RE.match(line)
if m and m.group(1).lower() == "user-agent":
cur.ua = m.group(2)
continue
m = NOTE_RE.match(line)
if m:
cur.notes.append(line.split("] ", 1)[1].strip())
return out
def select(reqs, a):
"""Apply the filters. Client-only unless told otherwise."""
if a.probes:
reqs = [r for r in reqs if not r.is_client]
elif not a.all:
reqs = [r for r in reqs if r.is_client]
if a.since:
reqs = [r for r in reqs if r.t >= a.since]
if a.until:
reqs = [r for r in reqs if r.t <= a.until]
if a.path:
rx = re.compile(a.path)
reqs = [r for r in reqs if rx.search(r.path)]
if a.unmapped:
reqs = [r for r in reqs if r.unmapped]
if a.status:
reqs = [r for r in reqs if r.status == a.status]
return reqs
def cut(s, n):
return s if len(s) <= n else s[: n - 1] + "\u2026"
def main():
p = argparse.ArgumentParser(description=__doc__.split("\n\n")[0],
formatter_class=argparse.RawDescriptionHelpFormatter)
p.add_argument("logfile", nargs="?", default="")
g = p.add_mutually_exclusive_group()
g.add_argument("--all", action="store_true", help="include our own probes (default: client only)")
g.add_argument("--probes", action="store_true", help="show ONLY our probes")
p.add_argument("-v", "--verbose", action="store_true", help="show request bodies and responses")
p.add_argument("-s", "--summary", action="store_true", help="counts per endpoint instead of a timeline")
p.add_argument("-p", "--path", help="regex the path must match")
p.add_argument("--unmapped", action="store_true", help="only catch-all fallthroughs")
p.add_argument("--status", help="only this HTTP status")
p.add_argument("--since", help="HH:MM or HH:MM:SS lower bound")
p.add_argument("--until", help="HH:MM or HH:MM:SS upper bound")
p.add_argument("-w", "--width", type=int, default=150, help="truncate bodies to this width")
a = p.parse_args()
for attr in ("since", "until"):
v = getattr(a, attr)
if v and len(v) == 5:
setattr(a, attr, v + (":00" if attr == "since" else ":59"))
logfile = resolve(a.logfile)
everything = parse(logfile)
reqs = select(everything, a)
n_client = sum(1 for r in everything if r.is_client)
scope = "our probes" if a.probes else ("client + probes" if a.all else "client only")
print("%s: %d requests total, %d from the game (%s), showing %d [%s]"
% (logfile, len(everything), n_client, CLIENT_UA, len(reqs), scope))
if not reqs:
if not a.all and not a.probes and n_client == 0 and everything:
print("\nNothing from the game in this log. Every request here is ours.")
print("If you expected client traffic, the client never reached the server:")
print("check that it got past auth, and that the server was up the whole time.")
return
if a.summary:
by = collections.Counter(r.base for r in reqs)
unmapped = collections.Counter(r.base for r in reqs if r.unmapped)
print()
for path, n in by.most_common():
flag = " UNMAPPED" if unmapped.get(path) else ""
print(" %5d %s%s" % (n, path, flag))
if unmapped:
print("\n%d request(s) fell through to the catch-all. Those are endpoints the"
% sum(unmapped.values()))
print("client wants and we do not serve, and the binary's URL template table")
print("does not list them: four such suffix endpoints have been found this way.")
return
print()
for r in reqs:
mark = " !!UNMAPPED" if r.unmapped else ""
print(" %s %-6s %-3s %s%s" % (r.t, r.method, r.status or "?", cut(r.path, a.width), mark))
if a.verbose:
if r.body:
print(" req: %s" % cut(r.body, a.width))
for n in r.notes:
print(" log: %s" % cut(n, a.width))
if r.resp:
print(" res: %s" % cut(r.resp, a.width))
if __name__ == "__main__":
main()
+66
View File
@@ -0,0 +1,66 @@
#!/usr/bin/env python3
"""Read the FutDataManagerImpl UI gate bytes out of the LIVE FIFA 17 client.
Why this exists: on 2026-08-05 the /settings gate plan concluded that
IS_FRIENDLY_SEASON_ENABLED and IS_DRAFT_MODE_ENABLED had never been set true by
anything. Measured against the running client, both are 1, and have been all along.
The applier FUN_18011dc50 runs whether or not the configs array has content, and the
settings struct it is handed defaults these fields to 1. "Nothing populates the array"
is not "nothing writes the byte".
Read-only. Opens /proc/<pid>/mem O_RDONLY and preads. Nothing here can write.
Nothing is assumed:
* the pid is resolved by exact /proc/*/comm match, never hardcoded
* the CardsDLL base is read from /proc/<pid>/maps, never cached across launches
(Wine copies the sections into anonymous memory, so only the 4 KiB PE header is
file-backed and `grep CardsDLL maps` returns exactly ONE line, which is easy to
misread as "barely mapped")
* the slide is PROVEN against the FNV atom-hash prologue at 0x180180d00, read from
the on-disk PE, before any other address is trusted
* each gate byte displacement is DECODED from its accessor stub (0f b6 81 <disp32>,
movzx eax, byte [rcx+disp32]) rather than taken from a table
Requires the client to have reached Ultimate Team, since CardsDLL loads only then.
Usage: python3 gate_byte_probe.py
"""
import os, struct, sys
pid=None
for d in os.listdir('/proc'):
if d.isdigit():
try:
if open('/proc/%s/comm'%d).read().strip()=='FIFA17.exe': pid=int(d); break
except Exception: pass
assert pid, "not running"
print("pid", pid)
base=None
for ln in open('/proc/%d/maps'%pid):
if 'CardsDLL' in ln:
base=int(ln.split('-')[0],16); print("cardsdll map line:", ln.strip())
assert base
slide = base - 0x180000000
print("base %#x slide %#x" % (base, slide))
fd=os.open('/proc/%d/mem'%pid, os.O_RDONLY)
def rd(va,n): return os.pread(fd, n, va)
# control: FNV prologue, bytes taken from the on-disk PE
pe=open('/mnt/games/FIFA 17/CardsDLL_Win64_retail.dll','rb').read()
# .text rva 0x1000 rawptr 0x400
def f(va): return va-0x180000000-0x1000+0x400
ctl_disk=pe[f(0x180180d00):f(0x180180d00)+32]
ctl_live=rd(0x180180d00+slide,32)
print("CONTROL FNV", "MATCH" if ctl_disk==ctl_live else "MISMATCH", ctl_live.hex())
# model singleton
dat=0x1802e6398+slide
obj=struct.unpack('<Q', rd(dat,8))[0]
print("DAT_1802e6398 ->", hex(obj))
vt=struct.unpack('<Q', rd(obj,8))[0]
print("vtable live %#x static %#x" % (vt, vt-slide))
for off,name in [(0x2b0,'friendlySeasons'),(0x2c8,'draftMode'),(0x2e0,'packOpeningAnimation')]:
slot=struct.unpack('<Q', rd(vt+off,8))[0]
stub=rd(slot,8)
disp=struct.unpack('<I', stub[3:7])[0] if stub[:3]==b'\x0f\xb6\x81' else None
val=rd(obj+disp,1)[0] if disp is not None else None
print(" slot +%#x -> %#x stub=%s disp=%s value=%s" % (off, slot-slide, stub.hex(), hex(disp) if disp else None, val))
# unopenedPacks total
print("model+0x20950 =", struct.unpack('<I', rd(obj+0x20950,4))[0])
os.close(fd)
@@ -0,0 +1,245 @@
#!/usr/bin/env python3
"""OpenFUT Ghidra helper: opens the analysed cardsdll.dll program once and exposes
decompile / xref / vtable helpers, so each RE question is a small python file
instead of a JVM restart + OSGi compile.
Usage: ghidra_env.py <query.py> -- runs <query.py> with the helpers in scope
(see tools/ghidra_queries/ for worked examples)
WHY PYGHIDRA: this box's Ghidra 12.1.2 cannot compile .java scripts at all --
analyzeHeadless -postScript Foo.java dies with "Failed to get OSGi bundle
containing script" for EVERY script, including ones that ran before (it is the
in-process OSGi/javac path that is broken, not the scripts). PyGhidra bypasses it.
Setup once:
python3 -m venv gvenv
gvenv/bin/pip install --no-index \
--find-links /opt/ghidra/Ghidra/Features/PyGhidra/pypkg/dist pyghidra
gvenv/bin/python ghidra_env.py <query.py>
Two traps this file already works around:
* open_program(..., nested_project_location=False) -- otherwise pyghidra creates
a NEW empty project at <loc>/<name>/ and re-imports (losing the analysis).
* os._exit(0) at the end -- JVM teardown under jpype deadlocks forever.
* read_bytes() uses a Java byte[]; passing a Python bytearray to Memory.getBytes
silently reads NOTHING and every scan comes back with 0 hits.
"""
import os, sys
os.environ.setdefault("GHIDRA_INSTALL_DIR", "/opt/ghidra")
import pyghidra
pyghidra.start(verbose=False)
from ghidra.app.decompiler import DecompInterface # noqa: E402
from ghidra.util.task import ConsoleTaskMonitor # noqa: E402
# Defaults target CardsDLL; override for another binary, e.g. powdll (the EASFC/POW
# layer, which is UNPACKED unlike FIFA17.exe):
# GHIDRA_DLL=/tmp/pow/powdll_Win64_retail.dll GHIDRA_PROJ_DIR=/tmp/pow \
# GHIDRA_PROJ=powproj ghidra_env.py <query.py>
DLL = os.environ.get("GHIDRA_DLL", "/tmp/fut/cardsdll.dll")
PROJ_DIR = os.environ.get("GHIDRA_PROJ_DIR", "/tmp/ghidra_fut")
PROJ = os.environ.get("GHIDRA_PROJ", "cardsdll")
PROG = os.environ.get("GHIDRA_PROG", os.path.basename(DLL))
# nested_project_location=False -> use /tmp/ghidra_fut/cardsdll.gpr itself (the
# already-analysed project) instead of creating /tmp/ghidra_fut/cardsdll/.
_ctx = pyghidra.open_program(DLL, project_location=PROJ_DIR, project_name=PROJ,
analyze=False, program_name=PROG,
nested_project_location=False)
flat = _ctx.__enter__()
prog = flat.getCurrentProgram()
mon = ConsoleTaskMonitor()
fm = prog.getFunctionManager()
listing = prog.getListing()
mem = prog.getMemory()
refs = prog.getReferenceManager()
_dec = DecompInterface()
_dec.openProgram(prog)
def addr(a):
return prog.getAddressFactory().getDefaultAddressSpace().getAddress(int(a))
def func(a):
return fm.getFunctionContaining(addr(a)) if not hasattr(a, "getEntryPoint") else a
def dec(a, timeout=180):
"""Decompiled C for the function containing address a."""
f = func(a)
if f is None:
return "// no function at %#x" % int(a)
r = _dec.decompileFunction(f, timeout, mon)
if r is None or not r.decompileCompleted():
return "// decompile failed for %s" % f.getName()
return str(r.getDecompiledFunction().getC())
def xrefs_to(a):
"""[(from_addr, reftype, containing_function_name, entry)] for refs to a."""
out = []
it = refs.getReferencesTo(addr(a))
while it.hasNext():
r = it.next()
f = fm.getFunctionContaining(r.getFromAddress())
out.append((int(r.getFromAddress().getOffset()), str(r.getReferenceType()),
f.getName() if f else "?",
int(f.getEntryPoint().getOffset()) if f else 0))
return out
def qword(a):
return mem.getLong(addr(a)) & 0xFFFFFFFFFFFFFFFF
def dword(a):
return mem.getInt(addr(a)) & 0xFFFFFFFF
import jpype # noqa: E402
_JBYTE = jpype.JArray(jpype.JByte)
def read_bytes(a, n):
"""Bulk read n bytes at a. MUST use a Java byte[] -- passing a Python
bytearray to Memory.getBytes silently reads nothing (this bug quietly
zeroed several earlier scans)."""
buf = _JBYTE(int(n))
got = mem.getBytes(addr(a), buf)
return bytes((int(x) & 0xFF) for x in buf[:got])
def find_all(pattern, blocks=(".text", ".rdata", ".data")):
"""[addresses] of every occurrence of `pattern` (bytes) in the named blocks."""
hits = []
for b in mem.getBlocks():
if b.getName() not in blocks or not b.isInitialized():
continue
s = int(b.getStart().getOffset())
size = int(b.getEnd().getOffset()) - s + 1
off = 0
chunk = 1 << 20
while off < size:
ln = min(chunk, size - off)
try:
data = read_bytes(s + off, ln)
except Exception:
off += ln
continue
i = data.find(pattern)
while i != -1:
hits.append(s + off + i)
i = data.find(pattern, i + 1)
off += ln - (len(pattern) - 1) if ln == chunk else ln
return hits
def rd_str(a, maxlen=200):
b = bytearray()
p = int(a)
for _ in range(maxlen):
c = mem.getByte(addr(p)) & 0xFF
if c == 0:
break
b.append(c)
p += 1
return b.decode("utf-8", "replace")
def vtable(a, n=64):
"""[(slot_offset, target_addr, function_name)] reading n qwords at a."""
out = []
for i in range(n):
try:
t = qword(int(a) + i * 8)
except Exception:
break
f = fm.getFunctionAt(addr(t)) if 0x180000000 <= t < 0x181000000 else None
out.append((i * 8, t, f.getName() if f else ""))
return out
def class_deser(cls):
"""FutXServerResponse class name -> [(deserializer, vtable, factory), ...].
THE -4 RULE, AND WHAT IT ACTUALLY IS. A response class's name literal is
preceded by a 4-byte header, and the factory's `lea r8,[rip+...]` points at
THAT header, not at the text, so the reference to look up is `name_addr - 4`.
Six attempts at class->deser resolution failed before this was noticed; four of
them returned zero candidates and were nearly written up as "the class has no
deserializer". Ghidra does create the reference, so no manual instruction
decoding is needed.
The "4-byte header" is not a length prefix or a refcount. It is literally the
ASCII string `RS4:`. The full literal is `RS4:FutXServerResponse`, and searching
for the bare class name lands four bytes into it. Knowing that, the rule stops
being a magic constant to remember and becomes obvious, and it also means you
can search for `RS4:` + the class name directly and skip the arithmetic.
(Established 2026-08-04 by a verification agent that had been told to distrust
the rule; it did, and found the reason instead of the offset.)
From the factory, the object's vtable is the .rdata address it references whose
first two qwords are functions; the deserializer is vtable slot +0x08.
Verified against known-good controls: FutSquadSave -> 0x180171a60,
FutSquadList -> 0x180172140, FutCreateMatch -> 0x180120380 (3/3 correct when it
resolves). It DOES produce false negatives -- FutDestroyMatch and
FutSeasonLoadData return nothing despite having known deserializers -- so treat
an empty result as "unknown", never as "no deserializer exists". Always include
a control with a known answer in any batch.
"""
res = []
for a in find_all(cls.encode() + b"\x00"):
for frm, typ, fn, ent in xrefs_to(a - 4):
if not ent:
continue
f = func(ent)
if f is None:
continue
for ad in f.getBody().getAddresses(True):
ins = listing.getInstructionAt(ad)
if ins is None:
continue
for r in ins.getReferencesFrom():
t = int(r.getToAddress().getOffset())
if not (0x1801E5000 <= t <= 0x1802891FF):
continue
try:
v0, v1 = qword(t), qword(t + 8)
except Exception:
continue
if (fm.getFunctionAt(addr(v0)) and fm.getFunctionAt(addr(v1))):
res.append((v1, t, ent))
return res
def fname(a):
f = func(a)
return f.getName() if f else "?"
def callees(a):
f = func(a)
return sorted({(int(c.getEntryPoint().getOffset()), c.getName())
for c in f.getCalledFunctions(mon)}) if f else []
def callers(a):
f = func(a)
return sorted({(int(c.getEntryPoint().getOffset()), c.getName())
for c in f.getCallingFunctions(mon)}) if f else []
if __name__ == "__main__":
if len(sys.argv) > 1:
g = dict(globals())
g["__name__"] = "__main__"
exec(open(sys.argv[1]).read(), g)
else:
print("loaded:", prog.getName(), fm.getFunctionCount(), "functions")
sys.stdout.flush()
# JVM teardown deadlocks under jpype here -- skip it, all output is flushed.
os._exit(0)
@@ -0,0 +1,71 @@
#!/usr/bin/env python3
"""Wait for FIFA17.exe, then dump + disassemble the unpacked code around a VA.
FIFA17.exe is packed on disk but Wine maps it flat at 0x140000000 and it unpacks
at load, so the only way to read the real instructions is from a LIVE process
(/proc/<pid>/mem, needs ptrace_scope=0 -- openfut-fut.sh's root_arm does that).
The game does NOT need to be at the crash point; the code is mapped as soon as
the module is up.
Usage: grab_crash_code.py [va_hex] [nbytes_before] [nbytes_after]
Default VA is the 2026-08-03 create-club crash site FIFA17.exe+0x71b8651.
"""
import glob, os, sys, time
VA = int(sys.argv[1], 16) if len(sys.argv) > 1 else 0x1471B8651
BEFORE = int(sys.argv[2]) if len(sys.argv) > 2 else 0xC0
AFTER = int(sys.argv[3]) if len(sys.argv) > 3 else 0x60
OUT = "/tmp/crash_code.txt"
def find_pid():
for d in glob.glob("/proc/[0-9]*"):
try:
if open(d + "/comm").read().strip() == "FIFA17.exe":
return int(d.split("/")[-1])
except Exception:
pass
return None
print("waiting for FIFA17.exe (launch the game; no need to reach the crash)...",
flush=True)
pid = None
while pid is None:
pid = find_pid()
if pid is None:
time.sleep(2)
print("pid=%d, reading %#x" % (pid, VA), flush=True)
# give the unpacker a moment after process start
time.sleep(5)
start = VA - BEFORE
with open("/proc/%d/mem" % pid, "rb") as f:
f.seek(start)
data = f.read(BEFORE + AFTER)
lines = ["pid=%d window %#x..%#x (%d bytes)" % (pid, start, start + len(data), len(data)),
"raw: " + data.hex()]
try:
import capstone
md = capstone.Cs(capstone.CS_ARCH_X86, capstone.CS_MODE_64)
md.detail = False
# align: disassemble from several offsets, keep the run that lands exactly on VA
best = None
for skip in range(0, 16):
ins = list(md.disasm(data[skip:], start + skip))
if any(i.address == VA for i in ins):
if best is None or len(ins) > len(best[1]):
best = (skip, ins)
if best:
for i in best[1]:
mark = " <<<<< FAULT (read from 0x0)" if i.address == VA else ""
lines.append(" %#x %-10s %s%s" % (i.address, i.mnemonic, i.op_str, mark))
else:
lines.append("could not align a disassembly onto the fault VA")
except ImportError:
lines.append("(capstone not installed; raw bytes above)")
open(OUT, "w").write("\n".join(lines) + "\n")
print("\n".join(lines))
print("\nwrote " + OUT)
@@ -0,0 +1,478 @@
#!/usr/bin/env python3
"""
heat2.py -- self-contained Blaze Heat2 TDF encoder/decoder + Fire2 framing.
CLEAN ROOM PROVENANCE
---------------------
Everything here was derived from:
* the wire bytes of our own FIFA17 client's first Blaze RPC
(fifa17-recon/captures/blaze/blaze_fire2_37161.bin), and
* our own decoder (decode_fire2.py) written against those bytes.
Complex-type layouts that the capture does NOT exercise (list/map/union/
varintlist/objtype/objid/float) are marked UNVERIFIED below; they are
consistent with independent third-party clean-room BlazeSDK-15.x
reimplementations (the `tdf` crate cloned in this scratchpad), which were used
only as a cross-check of *structure*, never copied.
NO EA/FIFA leaked source was consulted.
VALIDATED RULES (byte-exact round-trip against the 219-byte capture)
--------------------------------------------------------------------
Fire2 frame header, 16 bytes big-endian:
[0:4] u32 payload length (bytes after the header)
[4:6] u16 always 0 (observed)
[6:8] u16 component
[8:10] u16 command
[10:12]u16 error / msgId
[12] u8 msgType (0x01 ping, 0x02 request, 0x03 pong/response)
[13:16]3 reserved bytes (observed 00 00 00)
Heat2 field = 3-byte packed tag + 1 type byte + value.
TAG PACKING (validated):
Take the 4-char label, right-pad with spaces to exactly 4 chars, truncate
to 4. Each char c -> 6-bit code (ord(c) - 0x20) & 0x3F (so ' ' -> 0).
The four 6-bit codes are concatenated MSB-first into 24 bits = 3 bytes:
b0 = c0<<2 | c1>>4
b1 = (c1 & 0x0F)<<4 | c2>>2
b2 = (c2 & 0x03)<<6 | c3
Decode is the exact inverse; code 0 decodes to ' ' and trailing spaces are
stripped, so "ENV" round-trips as "ENV" (encoded as "ENV ").
VARINT (validated):
First byte carries only 6 data bits (mask 0x3F); bit 0x80 = "more".
Bit 0x40 of the first byte is the sign/negative flag (UNVERIFIED - never
set in our capture; we encode non-negative values only by default).
Every following byte carries 7 data bits (mask 0x7F) with bit 0x80 = more.
Little-endian group order: first byte = least significant 6 bits, then
7 bits per byte at shifts 6, 13, 20, 27, ...
Canonical form: emit the shortest sequence; value < 0x40 is one byte.
e.g. LANG = 0x656E5553 ("enUS") -> 93 d5 f2 d6 0c
STRING (validated):
varint length INCLUDING the NUL terminator, then that many bytes, the last
of which is 0x00. Empty string = varint 1 + b"\\x00".
STRUCT / group (validated):
type byte 0x03, then the member fields, then a single 0x00 terminator
byte. No group-start marker byte. The top-level payload is NOT
terminated (it is delimited by the Fire2 length).
FIELD ORDER (validated):
Members are serialized in ascending order of the *packed 3-byte tag*
(equivalently ascending by the space-padded label under this 6-bit
packing). Observed: CDAT<CINF<FCCR<LADD, and inside CINF:
BSDK<BTIM<CLNT<CPFT<CSKU<CVER<DSDK<ENV<LOC<PTVR.
VALUE REPRESENTATION (python side)
----------------------------------
A struct is an ordered dict { "TAG": (type, value) }.
INT -> int
STRING -> str (no trailing NUL) or bytes
BLOB -> bytes
STRUCT -> dict as above
LIST -> (elem_type, [value, ...])
MAP -> (key_type, val_type, [(k, v), ...])
UNION -> (active_key:int, (tag, type, value) | None)
VARLIST -> [int, ...]
OBJTYPE -> (component, type)
OBJID -> (component, type, id)
FLOAT -> float
"""
from __future__ import annotations
import struct
from collections import OrderedDict
# ---------------------------------------------------------------- types
INT = 0x00
STRING = 0x01
BLOB = 0x02
STRUCT = 0x03
LIST = 0x04
MAP = 0x05
UNION = 0x06
VARLIST = 0x07
OBJTYPE = 0x08
OBJID = 0x09
FLOAT = 0x0A
TYPE_NAMES = {
INT: "int", STRING: "string", BLOB: "blob", STRUCT: "struct",
LIST: "list", MAP: "map", UNION: "union", VARLIST: "varintlist",
OBJTYPE: "objtype", OBJID: "objid", FLOAT: "float",
}
UNION_UNSET = 0x7F # UNVERIFIED (not present in capture)
# ---------------------------------------------------------------- tags
def encode_tag(label) -> bytes:
"""4-char label -> 3 packed bytes. Shorter labels are space padded."""
if isinstance(label, bytes):
label = label.decode("ascii")
s = (label + " ")[:4]
c = [(ord(ch) - 0x20) & 0x3F for ch in s]
return bytes((
(c[0] << 2) | (c[1] >> 4),
((c[1] & 0x0F) << 4) | (c[2] >> 2),
((c[2] & 0x03) << 6) | c[3],
))
def decode_tag(b: bytes) -> str:
"""3 packed bytes -> label with trailing padding stripped."""
a, b1, c = b[0], b[1], b[2]
v = (
(a >> 2) & 0x3F,
((a & 0x03) << 4) | ((b1 >> 4) & 0x0F),
((b1 & 0x0F) << 2) | ((c >> 6) & 0x03),
c & 0x3F,
)
return "".join(chr(x + 0x20) if x else " " for x in v).rstrip()
def tag_key(label) -> bytes:
"""Sort key enforcing Blaze's ascending-tag member ordering."""
return encode_tag(label)
# ---------------------------------------------------------------- varint
def encode_varint(value: int) -> bytes:
"""Heat2 varint: 6 data bits in byte 0 (0x80=more), 7 bits thereafter."""
neg = value < 0
v = -value if neg else value
first = v & 0x3F
v >>= 6
if neg:
first |= 0x40 # UNVERIFIED sign convention
if v == 0:
return bytes((first,))
out = bytearray((first | 0x80,))
while v >= 0x80:
out.append((v & 0x7F) | 0x80)
v >>= 7
out.append(v)
return bytes(out)
def decode_varint(buf: bytes, i: int):
"""-> (value, next_index)"""
b = buf[i]
i += 1
val = b & 0x3F
neg = bool(b & 0x40)
if b & 0x80:
shift = 6
while True:
b = buf[i]
i += 1
val |= (b & 0x7F) << shift
shift += 7
if not (b & 0x80):
break
return (-val if neg else val), i
# ---------------------------------------------------------------- encoder
def _enc_value(typ: int, value, out: bytearray) -> None:
if typ == INT:
out += encode_varint(int(value))
elif typ == STRING:
raw = value.encode("utf-8") if isinstance(value, str) else bytes(value)
raw = raw.rstrip(b"\x00")
out += encode_varint(len(raw) + 1)
out += raw
out += b"\x00"
elif typ == BLOB:
raw = bytes(value)
out += encode_varint(len(raw))
out += raw
elif typ == STRUCT:
_enc_struct_body(value, out)
out += b"\x00"
elif typ == LIST: # UNVERIFIED
etype, items = value
out.append(etype & 0xFF)
out += encode_varint(len(items))
for it in items:
_enc_value(etype, it, out)
elif typ == MAP: # UNVERIFIED
ktype, vtype, items = value
out.append(ktype & 0xFF)
out.append(vtype & 0xFF)
out += encode_varint(len(items))
for k, v in items:
_enc_value(ktype, k, out)
_enc_value(vtype, v, out)
elif typ == UNION: # UNVERIFIED
key, member = value
out.append(key & 0xFF)
if key != UNION_UNSET and member is not None:
mtag, mtype, mval = member
out += encode_tag(mtag)
out.append(mtype & 0xFF)
_enc_value(mtype, mval, out)
elif typ == VARLIST: # UNVERIFIED
out += encode_varint(len(value))
for n in value:
out += encode_varint(int(n))
elif typ == OBJTYPE: # UNVERIFIED
comp, t = value
out += encode_varint(comp)
out += encode_varint(t)
elif typ == OBJID: # UNVERIFIED
comp, t, oid = value
out += encode_varint(comp)
out += encode_varint(t)
out += encode_varint(oid)
elif typ == FLOAT: # UNVERIFIED
out += struct.pack(">f", float(value))
else:
raise ValueError("cannot encode unknown TDF type 0x%02x" % typ)
def _enc_struct_body(fields, out: bytearray) -> None:
"""Serialize members in ascending packed-tag order (Blaze requirement)."""
if isinstance(fields, dict):
items = list(fields.items())
else: # allow [(tag, (type, value)), ...]
items = list(fields)
items.sort(key=lambda kv: tag_key(kv[0]))
for tag, tv in items:
typ, val = tv
out += encode_tag(tag)
out.append(typ & 0xFF)
_enc_value(typ, val, out)
def encode_tdf(fields) -> bytes:
"""Serialize a top-level TDF struct body (no trailing 0x00 terminator)."""
out = bytearray()
_enc_struct_body(fields, out)
return bytes(out)
# convenient aliases
build_tdf = encode_tdf
encode_struct = encode_tdf
# ---------------------------------------------------------------- decoder
def _dec_value(buf: bytes, i: int, typ: int):
if typ == INT:
return decode_varint(buf, i)
if typ == STRING:
ln, i = decode_varint(buf, i)
raw = buf[i:i + ln]
i += ln
return raw.rstrip(b"\x00").decode("utf-8", "replace"), i
if typ == BLOB:
ln, i = decode_varint(buf, i)
return bytes(buf[i:i + ln]), i + ln
if typ == STRUCT:
return _dec_struct_body(buf, i, terminated=True)
if typ == LIST:
etype = buf[i]; i += 1
n, i = decode_varint(buf, i)
items = []
for _ in range(n):
v, i = _dec_value(buf, i, etype)
items.append(v)
return (etype, items), i
if typ == MAP:
ktype = buf[i]; i += 1
vtype = buf[i]; i += 1
n, i = decode_varint(buf, i)
items = []
for _ in range(n):
k, i = _dec_value(buf, i, ktype)
v, i = _dec_value(buf, i, vtype)
items.append((k, v))
return (ktype, vtype, items), i
if typ == UNION:
key = buf[i]; i += 1
if key == UNION_UNSET:
return (key, None), i
mtag = decode_tag(buf[i:i + 3]); mtype = buf[i + 3]; i += 4
mval, i = _dec_value(buf, i, mtype)
return (key, (mtag, mtype, mval)), i
if typ == VARLIST:
n, i = decode_varint(buf, i)
out = []
for _ in range(n):
v, i = decode_varint(buf, i)
out.append(v)
return out, i
if typ == OBJTYPE:
c, i = decode_varint(buf, i)
t, i = decode_varint(buf, i)
return (c, t), i
if typ == OBJID:
c, i = decode_varint(buf, i)
t, i = decode_varint(buf, i)
o, i = decode_varint(buf, i)
return (c, t, o), i
if typ == FLOAT:
return struct.unpack(">f", buf[i:i + 4])[0], i + 4
raise ValueError("cannot decode unknown TDF type 0x%02x at %d" % (typ, i))
def _dec_struct_body(buf: bytes, i: int, terminated: bool, end: int = None):
"""Read fields until 0x00 terminator (nested) or `end` (top level)."""
if end is None:
end = len(buf)
fields = OrderedDict()
while i < end:
if terminated and buf[i] == 0x00:
i += 1
break
tag = decode_tag(buf[i:i + 3])
typ = buf[i + 3]
i += 4
val, i = _dec_value(buf, i, typ)
fields[tag] = (typ, val)
return fields, i
def decode_tdf(payload: bytes):
"""Decode a top-level TDF payload -> OrderedDict {tag: (type, value)}."""
fields, _ = _dec_struct_body(payload, 0, terminated=False)
return fields
# ---------------------------------------------------------------- Fire2
FIRE2_HEADER_LEN = 16
MSG_PING = 0x01
MSG_REQUEST = 0x02
MSG_RESPONSE = 0x03 # also seen as pong
MSG_NOTIFY = 0x04 # UNVERIFIED
MSG_ERROR = 0x05 # UNVERIFIED
def build_fire2_frame(component: int, command: int, msgType: int,
msgId: int, tdf_bytes: bytes) -> bytes:
"""16-byte big-endian Fire2 header + TDF payload."""
tdf_bytes = bytes(tdf_bytes)
hdr = struct.pack(">IHHHHB3s", len(tdf_bytes), 0, component & 0xFFFF,
command & 0xFFFF, msgId & 0xFFFF, msgType & 0xFF,
b"\x00\x00\x00")
return hdr + tdf_bytes
def parse_fire2_frame(data: bytes):
"""-> (dict header, bytes payload). Raises if the buffer is short."""
if len(data) < FIRE2_HEADER_LEN:
raise ValueError("short Fire2 frame")
(ln, zero, comp, cmd, msgid, mtype, reserved) = struct.unpack(
">IHHHHB3s", data[:FIRE2_HEADER_LEN])
payload = data[FIRE2_HEADER_LEN:FIRE2_HEADER_LEN + ln]
if len(payload) != ln:
raise ValueError("truncated Fire2 payload: want %d have %d"
% (ln, len(payload)))
hdr = {
"length": ln, "zero": zero, "component": comp, "command": cmd,
"msgId": msgid, "msgType": mtype, "reserved": reserved,
}
return hdr, payload
def decode_fire2(data: bytes):
"""-> (header dict, decoded TDF OrderedDict)"""
hdr, payload = parse_fire2_frame(data)
return hdr, decode_tdf(payload)
def encode_fire2(hdr: dict, fields) -> bytes:
"""Inverse of decode_fire2 (uses hdr's component/command/msgType/msgId)."""
return build_fire2_frame(hdr["component"], hdr["command"],
hdr["msgType"], hdr["msgId"], encode_tdf(fields))
# ---------------------------------------------------------------- pretty
def dump(fields, depth: int = 0) -> str:
pad = " " * depth
lines = []
for tag, (typ, val) in fields.items():
tn = TYPE_NAMES.get(typ, "0x%02x" % typ)
if typ == STRUCT:
lines.append("%s%s (struct) {" % (pad, tag))
lines.append(dump(val, depth + 1))
lines.append("%s}" % pad)
elif typ == BLOB:
lines.append("%s%s (blob[%d]) = %s" % (pad, tag, len(val), val.hex()))
else:
lines.append("%s%s (%s) = %r" % (pad, tag, tn, val))
return "\n".join(lines)
# ---------------------------------------------------------------- self-test
CAPTURE = ("/home/alex/Documents/OpenFUT/fifa17-recon/captures/blaze/"
"blaze_fire2_37161.bin")
def _selftest(path: str = CAPTURE) -> bool:
ok = True
# unit: tag packing
for lbl in ("CDAT", "CINF", "FCCR", "LADD", "ENV", "LOC", "BSDK", "PTVR"):
enc = encode_tag(lbl)
assert decode_tag(enc) == lbl, (lbl, enc.hex())
assert encode_tag("CDAT") == bytes.fromhex("8e4874"), encode_tag("CDAT").hex()
assert encode_tag("ENV") == bytes.fromhex("96ed80"), encode_tag("ENV").hex()
assert encode_tag("LADD") == bytes.fromhex("b21924"), encode_tag("LADD").hex()
# unit: varint
assert encode_varint(0) == b"\x00"
assert encode_varint(4) == b"\x04"
assert encode_varint(0x3F) == b"\x3f"
assert encode_varint(0x40) == bytes.fromhex("8001")
assert encode_varint(0x656E5553) == bytes.fromhex("93d5f2d60c")
for n in (0, 1, 63, 64, 127, 128, 8191, 0x656E5553, 2**40, 2**63 - 1):
v, j = decode_varint(encode_varint(n), 0)
assert v == n and j == len(encode_varint(n)), n
# round trip the real capture
original = open(path, "rb").read()
hdr, payload = parse_fire2_frame(original)
fields = decode_tdf(payload)
re_payload = encode_tdf(fields)
re_frame = encode_fire2(hdr, fields)
print("header:", hdr)
print(dump(fields))
print()
print("payload %d -> %d bytes" % (len(payload), len(re_payload)))
print("frame %d -> %d bytes" % (len(original), len(re_frame)))
if re_frame == original:
print("ROUND-TRIP: PASS (byte-identical, %d bytes)" % len(original))
else:
ok = False
print("ROUND-TRIP: FAIL")
n = min(len(re_frame), len(original))
for k in range(n):
if re_frame[k] != original[k]:
print(" first diff at 0x%04x: got %02x want %02x"
% (k, re_frame[k], original[k]))
print(" got %s" % re_frame[max(0, k - 8):k + 16].hex())
print(" want %s" % original[max(0, k - 8):k + 16].hex())
break
else:
print(" length differs only: %d vs %d" % (len(re_frame), len(original)))
return ok
if __name__ == "__main__":
import sys
p = sys.argv[1] if len(sys.argv) > 1 else CAPTURE
raise SystemExit(0 if _selftest(p) else 1)
@@ -0,0 +1,79 @@
#!/usr/bin/env python3
"""Did clubPlayers actually land in the client, or was it eaten as the envelope key?
Read-only. The argument cannot settle this; the client's own memory can.
Chain, from the comment at utas_server.py:1076 (established earlier by two independent
agents and two reviewers, so this probe TESTS that chain rather than assuming it):
R = <model> + 0x1fd70 (FUN_18011a810 is `lea rax,[rcx+0x1fd70]; ret`)
clubPlayers -> R + 0x3c
auctionCount -> R + 0x38
The server logged "HUB: clubPlayers=205 auctionCount=0" for this session.
PREDICTIONS, stated before reading so this cannot be rationalised after the fact:
* if R+0x3c reads 205, clubPlayers reached its arm. The flat two-key hub body is
fine and the envelope worry does not apply to this root.
* if R+0x3c reads 0 while R+0x38 reads 0 too, the result is ambiguous, because
auctionCount is legitimately 0 this session. Say so rather than claiming a result.
* if R+0x3c reads 0 and some other plausible field is populated, clubPlayers was
eaten as the first key/value pair and the MY CLUB tile is showing a wrong number.
"""
import os
import struct
pid = None
for d in os.listdir('/proc'):
if d.isdigit():
try:
if open('/proc/%s/comm' % d).read().strip() == 'FIFA17.exe':
pid = int(d)
break
except Exception:
pass
if not pid:
raise SystemExit("FIFA17.exe not running")
base = None
for ln in open('/proc/%d/maps' % pid):
if 'CardsDLL' in ln:
base = int(ln.split('-')[0], 16)
if not base:
raise SystemExit("CardsDLL not mapped: the client has not reached Ultimate Team")
slide = base - 0x180000000
print("pid %d cardsdll %#x slide %#x" % (pid, base, slide))
fd = os.open('/proc/%d/mem' % pid, os.O_RDONLY)
def rd(va, n):
return os.pread(fd, n, va)
# Prove the slide before trusting any address derived from it.
pe = open('/mnt/games/FIFA 17/CardsDLL_Win64_retail.dll', 'rb').read()
off = 0x180180D00 - 0x180000000 - 0x1000 + 0x400
ok = pe[off:off + 32] == rd(0x180180D00 + slide, 32)
print("slide control (FNV prologue): %s" % ("MATCH" if ok else "MISMATCH -- STOP"))
if not ok:
raise SystemExit(1)
model = struct.unpack('<Q', rd(0x1802E6398 + slide, 8))[0]
print("model singleton %#x" % model)
R = model + 0x1FD70
club, auction = struct.unpack('<i', rd(R + 0x3C, 4))[0], struct.unpack('<i', rd(R + 0x38, 4))[0]
print("\n R = model+0x1fd70 = %#x" % R)
print(" R+0x3c clubPlayers = %d (server sent 205)" % club)
print(" R+0x38 auctionCount = %d (server sent 0)" % auction)
print("\nVERDICT:")
if club == 205:
print(" clubPlayers REACHED its arm. The flat hub body parses correctly and the")
print(" envelope concern does not apply to FutGetHubData.")
elif club == 0:
print(" clubPlayers is 0. Either it was eaten as the first key/value pair, or the")
print(" hub has not been loaded this session. Check the tile in game before")
print(" concluding: auctionCount is legitimately 0, so it cannot break the tie.")
else:
print(" clubPlayers = %d, which is neither 205 nor 0. The chain above is wrong"
" somewhere." % club)
os.close(fd)
@@ -0,0 +1,341 @@
#!/usr/bin/env python3
"""
lsx_force_online.py -- force FIFA 17's Origin/LSX layer to report ONLINE.
THIS IS LAYER 1. It must succeed before ANY Blaze work (blaze_responder_v3.py)
is reachable. The client's own flow graph gates FUT behind Origin:
{"name":"launchFUTFlow","file":"/online/origin.nav",
"outputs":{"OriginIsOnlineTrue":"startFutBlazeLogin","quit":"mainMenu"}}
so while Origin says offline the client shows
"Unable to connect to the EA Servers ... log in to Origin in Online Mode"
(TXT_ORIGIN_OFFLINE_POPUP_TEXT) and sends Authentication::logout (1/0x46) to
Blaze instead of login (1/0x0A).
PREFERRED FIX IS NOT THIS FILE. Prefer `lsx_responder.py`: bind 127.0.0.1:4216
BEFORE launching FIFA 17. The Steampunks stub's socket setup
(sub_0x6ffffc932130) does bind -> listen -> accept with NO SO_REUSEADDR and, on
bind failure, branches to 0x6ffffc932245 -> freeaddrinfo/closesocket/WSACleanup/
return 1 -- i.e. it stands down CLEANLY and the game's OriginSDK connects to us.
That is a real request-driven LSX server and can also answer GetAuthCode,
GetProfile and QueryEntitlements, which no memory patch can synthesise.
USE THIS FILE when the game is ALREADY RUNNING and you only want to flip the
online verdict (e.g. to confirm `OriginIsOnlineTrue` fires at all).
------------------------------------------------------------------------------
PATCH (A) -- the emu's response template. DEFAULT.
------------------------------------------------------------------------------
Region : stp-origin_emu.dll unpacked image, 0x6ffffc931000-0x6ffffc93d000,
already mapped rwxp (no mprotect needed).
Template: 0x6ffffc9353b0
<LSX><Response id="%d" sender=""><InternetConnectedState
connected="0"/></Response></LSX>
VA : 0x6ffffc9353f4 (the '0' inside connected="0")
BEFORE : 30 ('0')
AFTER : 31 ('1')
The format string is re-read on every use, so the patch applies to every
future emission -- but the stub is a BLIND FIXED-SCRIPT REPLAYER (18 canned
responses in a fixed order, then ErrorSuccess forever, loop 0x6ffffc932dd3).
Template 17 is the InternetConnectedState one. If the game has already
passed step 17, the stub is parked in the ErrorSuccess loop and will never
emit this template again -- the patch then does nothing, and Q-to-reconnect
does NOT help. Patch BEFORE the game boots past the Origin probe, or use
patch (B) / lsx_responder.py.
------------------------------------------------------------------------------
PATCH (B) -- g_originOnline, the parsed verdict itself. --flag / --hold
------------------------------------------------------------------------------
Module : FIFA17.exe, mapped flat at 0x140000000 under Wine/Proton.
VA : 0x1443337f8 (g_originOnline, one byte)
BEFORE : 00 (or stale garbage -- see caveat below)
AFTER : 01
Sole reader : 0x146f38aa9 movzx eax, BYTE PTR [0x1443337f8] (the popup /
OriginIsOnline predicate; a bare global read, no refresh)
Sole writer : 0x146f1e6d9 mov BYTE PTR [0x1443337f8], al
-- the LSX GetInternetConnectedState callback (0x146f1e6b0),
which also broadcasts FE::FIFA::OriginOnlineEvent.
CAVEAT (measured live): this byte currently reads 0x01 already, yet the flow
still fails. The stub answered the FIRST GetInternetConnectedState (id 17)
with a well-formed connected="0" but answered the LATER polls (ids 19-22)
with a type-mismatched generic ErrorSuccess, so the SDK found no `connected`
attribute and stored stale garbage. Therefore: a 1 in this byte is NOT
sufficient on its own -- the FE::FIFA::OriginOnlineEvent broadcast that the
writer performs is what actually drives origin.nav. --hold keeps the byte at
1 so it cannot be clobbered, but only a real LSX reply (lsx_responder.py)
makes the callback run and fire the event. Treat (B) as diagnostic.
------------------------------------------------------------------------------
CLEAN ROOM: every address above was recovered by static + dynamic analysis of
binaries we own (FIFA17.exe and stp-origin_emu.dll as loaded in our own running
process). Nothing derives from the 2021 EA/FIFA leak.
USAGE
python3 lsx_force_online.py # apply (A); idempotent
python3 lsx_force_online.py --flag # apply (A) + (B) once
python3 lsx_force_online.py --hold # (A) + rewrite (B) every 0.5s
python3 lsx_force_online.py --status # read both, change nothing
python3 lsx_force_online.py --restore # put the saved originals back
python3 lsx_force_online.py --watch # wait for FIFA17.exe, then apply
Requires /proc/PID/mem write access (kernel.yama.ptrace_scope=0, or run as the
same user with ptrace_scope=1 which is already known to work here).
"""
import glob
import os
import sys
import time
# ------------------------------------------------------------------ patches
# (A) stp-origin_emu.dll InternetConnectedState template.
EMU_TEMPLATE_VA = 0x6FFFFC9353B0
EMU_PATCH_VA = 0x6FFFFC9353F4
EMU_BEFORE = b"0" # 0x30
EMU_AFTER = b"1" # 0x31
EMU_TEMPLATE_HEAD = b'<LSX><Response id="%d" sender=""><InternetConnectedState'
EMU_REGION = (0x6FFFFC931000, 0x6FFFFC93D000) # rwxp unpacked image
# (B) FIFA17.exe g_originOnline.
FLAG_VA = 0x1443337F8
FLAG_AFTER = b"\x01"
BACKUP_DIR = "/tmp/lsx_force_online"
LOGFILE = "/tmp/lsx_force_online.log"
def log(m):
line = "[%s] %s" % (time.strftime("%H:%M:%S"), m)
print(line, flush=True)
try:
with open(LOGFILE, "a") as fh:
fh.write(line + "\n")
except Exception:
pass
# ------------------------------------------------------------------ process
def find_pid():
for d in glob.glob("/proc/[0-9]*"):
try:
if open(d + "/comm").read().strip() == "FIFA17.exe":
return int(d.rsplit("/", 1)[-1])
except Exception:
pass
return None
def rd(pid, va, n):
with open("/proc/%d/mem" % pid, "rb") as f:
f.seek(va)
return f.read(n)
def wr(pid, va, b):
with open("/proc/%d/mem" % pid, "r+b") as f:
f.seek(va)
f.write(b)
def backup(va, orig):
os.makedirs(BACKUP_DIR, exist_ok=True)
p = os.path.join(BACKUP_DIR, "orig_%x.bin" % va)
if not os.path.exists(p): # never overwrite the true original
with open(p, "wb") as fh:
fh.write(orig)
return p
# ------------------------------------------------------ template relocation
#
# The unpacked emu image address has been stable at 0x6ffffc931000 across our
# runs, but it is a runtime mapping -- do not trust it blindly. Verify the
# template is where we expect; if not, rescan the rwxp regions for it and
# recompute the patch offset from the template head.
def locate_emu_patch(pid):
"""-> (patch_va, template_va) or (None, None)."""
want = EMU_TEMPLATE_HEAD
try:
head = rd(pid, EMU_TEMPLATE_VA, len(want))
if head == want:
return EMU_PATCH_VA, EMU_TEMPLATE_VA
except Exception:
pass
log("template not at 0x%x -- rescanning writable+executable maps"
% EMU_TEMPLATE_VA)
delta = EMU_PATCH_VA - EMU_TEMPLATE_VA # +0x44
try:
maps = open("/proc/%d/maps" % pid).read().splitlines()
except Exception as e:
log("cannot read maps: %s" % e)
return None, None
for line in maps:
try:
rng, perms = line.split()[0], line.split()[1]
if "w" not in perms or "r" not in perms:
continue
lo, hi = (int(x, 16) for x in rng.split("-"))
if hi - lo > 64 * 1024 * 1024:
continue
blob = rd(pid, lo, hi - lo)
except Exception:
continue
off = blob.find(want)
while off != -1:
tva = lo + off
pva = tva + delta
try:
if rd(pid, pva, 1) in (EMU_BEFORE, EMU_AFTER):
log("template relocated: 0x%x (patch byte 0x%x)" % (tva, pva))
return pva, tva
except Exception:
pass
off = blob.find(want, off + 1)
return None, None
# ------------------------------------------------------------------ actions
def show_template(pid, tva):
try:
raw = rd(pid, tva, 96).split(b"\x00")[0]
log(" template @0x%x: %s" % (tva, raw.decode("ascii", "replace")))
except Exception:
pass
def apply_emu(pid):
pva, tva = locate_emu_patch(pid)
if pva is None:
log("PATCH (A): template NOT FOUND -- is stp-origin_emu loaded? "
"(is the game past the Origin probe already?)")
return False
cur = rd(pid, pva, 1)
if cur == EMU_AFTER:
log("PATCH (A): already applied at 0x%x (connected=\"1\")" % pva)
show_template(pid, tva)
return True
if cur != EMU_BEFORE:
log("PATCH (A): UNEXPECTED byte %s at 0x%x (want %s) -- refusing"
% (cur.hex(), pva, EMU_BEFORE.hex()))
return False
backup(pva, cur)
wr(pid, pva, EMU_AFTER)
now = rd(pid, pva, 1)
log("PATCH (A): 0x%x %s -> %s %s"
% (pva, cur.hex(), now.hex(), "OK" if now == EMU_AFTER else "FAILED"))
show_template(pid, tva)
return now == EMU_AFTER
def apply_flag(pid):
cur = rd(pid, FLAG_VA, 1)
if cur == FLAG_AFTER:
log("PATCH (B): g_originOnline @0x%x already 0x01" % FLAG_VA)
return True
backup(FLAG_VA, cur)
wr(pid, FLAG_VA, FLAG_AFTER)
now = rd(pid, FLAG_VA, 1)
log("PATCH (B): g_originOnline @0x%x %s -> %s %s"
% (FLAG_VA, cur.hex(), now.hex(), "OK" if now == FLAG_AFTER else "FAILED"))
return now == FLAG_AFTER
def status(pid):
pva, tva = locate_emu_patch(pid)
if pva is None:
log("STATUS (A): template not found in this process")
else:
b = rd(pid, pva, 1)
log("STATUS (A): 0x%x = %s -> connected=\"%s\"%s"
% (pva, b.hex(), b.decode("ascii", "replace"),
" [PATCHED]" if b == EMU_AFTER else ""))
show_template(pid, tva)
b = rd(pid, FLAG_VA, 1)
log("STATUS (B): g_originOnline @0x%x = %s (%s)"
% (FLAG_VA, b.hex(),
"online" if b == b"\x01" else "offline/garbage"))
log("NOTE: a 1 in (B) is NOT proof of success -- see the CAVEAT in this "
"file's docstring. Only a well-formed LSX connected=\"1\" reply makes "
"the callback broadcast FE::FIFA::OriginOnlineEvent, which is what "
"origin.nav actually consumes.")
def restore(pid):
if not os.path.isdir(BACKUP_DIR):
log("RESTORE: nothing saved in %s" % BACKUP_DIR)
return
for fn in sorted(os.listdir(BACKUP_DIR)):
if not fn.startswith("orig_"):
continue
va = int(fn[5:].split(".")[0], 16)
orig = open(os.path.join(BACKUP_DIR, fn), "rb").read()
try:
wr(pid, va, orig)
log("RESTORE: 0x%x <- %s (now %s)"
% (va, orig.hex(), rd(pid, va, len(orig)).hex()))
except Exception as e:
log("RESTORE: 0x%x FAILED: %s" % (va, e))
# ------------------------------------------------------------------ main
def main():
argv = sys.argv[1:]
want_flag = "--flag" in argv or "--hold" in argv
hold = "--hold" in argv
if "--watch" in argv:
log("=== WATCH: waiting for FIFA17.exe ===")
seen = set()
while True:
pid = find_pid()
if pid and pid not in seen:
try:
if apply_emu(pid):
if want_flag:
apply_flag(pid)
seen.add(pid)
except Exception as e:
log("pid %d not ready yet (%s)" % (pid, e))
time.sleep(1)
pid = find_pid()
if not pid:
raise SystemExit("FIFA17.exe not running (use --watch to wait for it)")
log("=== lsx_force_online pid=%d ===" % pid)
if "--status" in argv:
status(pid)
return
if "--restore" in argv:
restore(pid)
return
apply_emu(pid)
if want_flag:
apply_flag(pid)
if hold:
log("HOLD: rewriting g_originOnline every 0.5 s (Ctrl-C to stop)")
try:
while True:
try:
if rd(pid, FLAG_VA, 1) != FLAG_AFTER:
wr(pid, FLAG_VA, FLAG_AFTER)
log("HOLD: g_originOnline was clobbered, reset to 0x01")
except Exception as e:
log("HOLD: process gone (%s)" % e)
break
time.sleep(0.5)
except KeyboardInterrupt:
log("HOLD: stopped")
if __name__ == "__main__":
main()
@@ -0,0 +1,245 @@
#!/usr/bin/env python3
# FROZEN: not started by openfut-fut.sh (see its SERVERS array); identity is NOT
# sourced from fut_account.py here. The live pair is lsx_responder_v2.py +
# blaze_responder_v3b.py -- edit those. Kept for reference/bisecting only.
"""
OpenFUT clean-room LSX responder for FIFA 17 (replaces the Steampunks stp-origin_emu
in-process stub on 127.0.0.1:4216).
PROVENANCE / CLEAN-ROOM: every constant and algorithm here was recovered by static +
dynamic analysis of binaries we own (FIFA17.exe and stp-origin_emu.dll as loaded in
our own running process). Nothing is derived from the 2021 EA/FIFA leak.
WIRE PROTOCOL (reversed from stp-origin_emu.dll @ base 0x6ffffc930000):
transport : TCP 127.0.0.1:4216, each message is a NUL-terminated byte string
(send length == strlen(msg)+1).
handshake : server sends <Challenge key="..."> IN PLAINTEXT.
client replies (plaintext) with response="..." and key="..." attrs.
server replies <ChallengeAccepted response="H"> where
H = hex(AES128_ECB_encrypt(clientKeyAscii[0:32], K_FIXED))
K_FIXED = 000102030405060708090a0b0c0d0e0f (emu .rdata 0x935038)
session : every later message is
hex_lower( AES128_ECB_encrypt( pkcs7_pad16( xml ) ) )
under SESSION_KEY, which both sides derive from H (see derive_session_key).
Incoming messages are hex-decoded, decrypted, pad-stripped.
USAGE: bind this BEFORE launching FIFA 17. The stub's bind() then fails and its
server thread returns cleanly (it has no SO_REUSEADDR and no retry), so the
game's OriginSDK connects to us instead.
"""
import socket, sys, re, os, threading
from Crypto.Cipher import AES
# ---------------------------------------------------------------- identity
# SHARED CONSTANTS -- must stay byte-identical to stp-origin_emu.ini [Globals]
# AND to the same block at the top of blaze_responder_v3.py. A mismatch between
# what LSX reports here and what Blaze returns in LoginResponse.SESS.PDTL is
# exactly what raises AUTH_ERR_INVALID_PERSONA / AUTH_ERR_USER_DOES_NOT_MATCH_
# PERSONA / AUTH_ERR_PERSONA_NOT_FOUND.
PERSONA_ID = 33068179
PERSONA_NAME = "CAGE"
USER_ID = 33068179
CONTENT_ID = "1027460" # FIFA 17 EA offer id
ENTITLEMENT_TAG = "ONLINE_ACCESS"
# TWO-LAYER ORDERING: this file is LAYER 1. It must be listening on
# 127.0.0.1:4216 BEFORE FIFA 17 starts. Only once GetInternetConnectedState
# answers connected="1" does origin.nav take the OriginIsOnlineTrue exit into
# futBlazeLogin; only then does the client call GetAuthCode and put the result
# in Blaze LoginRequest.AUTH (1/0x0A). Until then it sends
# Authentication::logout (1/0x46) and blaze_responder_v3.py can do nothing.
# The auth code we hand out is echoed to AUTHCODE_FILE purely so the Blaze log
# can be correlated; blaze_responder_v3 accepts whatever AUTH arrives and never
# validates it against Nucleus.
AUTHCODE_FILE = "/tmp/openfut_authcode.txt"
# Recovered from stp-origin_emu.dll .rdata @ VA 0x6ffffc935038
K_FIXED = bytes(range(16)) # 000102030405060708090a0b0c0d0e0f
# Emu's own advertised challenge (any 32 hex chars work; the client echoes it back)
CHALLENGE_KEY = "2b8ee7faea76e8a34f5f5d20e5328e32"
BUILD = "release"
VERSION = "10,4,13,6637"
# ---------------------------------------------------------------- crypto
def msvcr_rand(seed):
"""MSVCR120 srand/rand LCG (verified: srand(7); rand() == 61)."""
s = seed & 0xFFFFFFFF
while True:
s = (s * 214013 + 2531011) & 0xFFFFFFFF
yield (s >> 16) & 0x7FFF
def derive_session_key(resp_hex: str) -> bytes:
"""Reimplementation of emu sub_0x6ffffc931f10 tail (0x9320bf-0x932101).
srand(7); r0 = rand() -> r0 == 61
bx = (u16)((resp[0] << 8) + resp[1]) (16-bit wrap)
srand(bx + r0)
key[i] = (uint8_t)rand() for i in 0..15
"""
r0 = next(msvcr_rand(7)) # == 61
bx = ((ord(resp_hex[0]) << 8) + ord(resp_hex[1])) & 0xFFFF
g = msvcr_rand((bx + r0) & 0xFFFFFFFF)
return bytes(next(g) & 0xFF for _ in range(16))
def challenge_response(client_key_ascii: str) -> str:
"""H = hex(AES128-ECB(K_FIXED, PKCS7pad16(clientKeyAscii))).
VERIFIED against a captured client ChallengeResponse (2026-07-30): the 32-char
ASCII key is PKCS7-padded to 48 bytes (3 AES blocks, 96 hex), NOT zero-padded
to 32. With server challenge key '2b8ee7fa...' this reproduces the client's
response '00b9c8af...216684899' exactly."""
b = client_key_ascii.encode()
pad = 16 - (len(b) % 16) # 32 -> +16 full block -> 48 bytes
b += bytes([pad]) * pad
return AES.new(K_FIXED, AES.MODE_ECB).encrypt(b).hex()
def lsx_encrypt(xml: str, key: bytes) -> bytes:
"""pkcs7-pad to 16, AES-128-ECB, lowercase hex, NUL-terminated."""
b = xml.encode()
pad = 16 - (len(b) % 16) # emu always pads (pad==16 when aligned)
b += bytes([pad]) * pad
return AES.new(key, AES.MODE_ECB).encrypt(b).hex().encode() + b"\0"
def lsx_decrypt(data: bytes, key: bytes) -> str:
h = data.split(b"\0")[0].strip()
raw = AES.new(key, AES.MODE_ECB).decrypt(bytes.fromhex(h.decode()))
pad = raw[-1]
if 0 < pad <= 16 and all(c == pad for c in raw[-pad:]):
raw = raw[:-pad]
return raw.split(b"\0")[0].decode(errors="replace")
# ---------------------------------------------------------------- responses
def resp(mid, body, sender=""):
return f'<LSX><Response id="{mid}" sender="{sender}"><{body}/></Response></LSX>'
def build_reply(mid, req_name, attrs):
"""Request-DRIVEN dispatch (the Steampunks stub was a blind fixed script)."""
if req_name == "GetInternetConnectedState":
# THE ONLINE GATE. Stub hardcoded connected="0" -> "log in to Origin".
return resp(mid, 'InternetConnectedState connected="1"')
if req_name == "GetAuthCode":
# Stub never implemented this at all. Element name is <AuthCode> (confirmed
# in FIFA17.exe element table @0x143937ae0). Emit both plausible value attrs;
# the client reads the one it knows and ignores the other.
code = os.environ.get("OPENFUT_AUTHCODE", "OPENFUT-" + "0" * 24)
try:
with open(AUTHCODE_FILE, "w") as fh:
fh.write(code)
except Exception:
pass
print(f"[lsx] *** GetAuthCode issued: {code} -- this is what should "
f"arrive as Blaze LoginRequest.AUTH (1/0x0A) ***")
return resp(mid, f'AuthCode Code="{code}" Return="{code}"', sender="EbisuSDK")
if req_name == "QueryEntitlements":
item = (f'<OriginItem ItemId="{ENTITLEMENT_TAG}" EntitlementId="1" '
f'ResourceId="{CONTENT_ID}" OfferId="{CONTENT_ID}" '
f'GrantDate="2016-09-01T00:00:00Z" bIsOwned="true" Uses="0"/>')
return (f'<LSX><Response id="{mid}" sender="EbisuSDK">'
f'<QueryEntitlementsResponse>{item}</QueryEntitlementsResponse>'
f'</Response></LSX>')
if req_name == "GetProfile":
return resp(mid,
f'GetProfileResponse IsSubscriber="true" PersonaId="{PERSONA_ID}" '
f'AvatarId="" Country="US" CommerceCountry="US" GeoCountry="US" '
f'UserId="{USER_ID}" Persona="{PERSONA_NAME}" IsUnderAge="false" '
f'CommerceCurrency="USD"', sender="EbisuSDK")
if req_name == "GetGameInfo":
gi = attrs.get("GameInfoId")
if gi == "LANGUAGES":
return resp(mid, 'GetGameInfoResponse GameInfo="ar_SA,cs_CZ,da_DK,de_DE,'
'en_US,es_ES,es_MX,fr_FR,it_IT,nl_NL,no_NO,pl_PL,pt_BR,'
'pt_PT,ru_RU,sv_SE,tr_TR,zh_TW"')
if gi == "UPTODATE":
# "is the title up to date?" -- MUST be true or the client shows
# "Your title version is outdated" and blocks all online features.
return resp(mid, 'GetGameInfoResponse GameInfo="true"')
# FREETRIAL etc. -> false (retail, not a trial)
return resp(mid, 'GetGameInfoResponse GameInfo="false"')
if req_name == "GetSetting":
sid = attrs.get("SettingId", "").upper() # client asks UPPERCASE (ENVIRONMENT/LANGUAGE)
if sid in ("ENVIRONMENT", "ENVIRONMENTNAME"):
return resp(mid, 'GetSettingResponse Setting="production"')
if sid == "LANGUAGE":
return resp(mid, 'GetSettingResponse Setting="en_US"')
return resp(mid, 'GetSettingResponse Setting="false"')
if req_name == "GetConfig":
return resp(mid, 'GetConfigResponse Config="false"', sender="EbisuSDK")
if req_name == "IsProgressiveInstallationAvailable":
return resp(mid, 'IsProgressiveInstallationAvailableResponse ItemId="" '
'Available="false"')
return resp(mid, 'ErrorSuccess Code="0" Description=""')
REQ_RE = re.compile(r'<Request[^>]*\bid="(\d+)"[^>]*>\s*<([A-Za-z]+)([^>]*)/?>')
ATTR_RE = re.compile(r'(\w+)="([^"]*)"')
def serve(conn):
# 1. plaintext Challenge
chal = (f'<LSX><Event sender="EALS"><Challenge key="{CHALLENGE_KEY}" '
f'build="{BUILD}" version="{VERSION}"/></Event></LSX>')
conn.sendall(chal.encode() + b"\0")
# 2. plaintext ChallengeResponse from client
data = conn.recv(4096)
m = re.search(r'key="([^"]*)"', data.decode(errors="replace"))
client_key = m.group(1) if m else CHALLENGE_KEY
h = challenge_response(client_key)
key = derive_session_key(h)
print(f"[lsx] client key={client_key} response={h[:16]}... session_key={key.hex()}")
# 3. plaintext ChallengeAccepted
conn.sendall(resp(1, f'ChallengeAccepted response="{h}"', "EALS").encode() + b"\0")
# 4. encrypted request/response loop
while True:
data = conn.recv(65536)
if not data:
break
for chunk in filter(None, data.split(b"\0")):
try:
xml = lsx_decrypt(chunk + b"\0", key)
except Exception as e:
print("[lsx] decrypt fail:", e)
continue
mm = REQ_RE.search(xml)
if not mm:
print("[lsx] <<", xml)
continue
mid, name, rest = mm.group(1), mm.group(2), mm.group(3)
attrs = dict(ATTR_RE.findall(rest))
reply = build_reply(mid, name, attrs)
print(f"[lsx] << id={mid} {name} {attrs}\n[lsx] >> {reply}")
conn.sendall(lsx_encrypt(reply, key))
def main():
s = socket.socket()
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(("127.0.0.1", 4216))
s.listen(8)
print("[lsx] listening on 127.0.0.1:4216 (start FIFA 17 now)")
while True:
c, a = s.accept()
print("[lsx] connection from", a)
threading.Thread(target=serve, args=(c,), daemon=True).start()
if __name__ == "__main__":
main()
@@ -0,0 +1,621 @@
#!/usr/bin/env python3
"""
OpenFUT clean-room LSX responder for FIFA 17 -- v2 (EVENT-PUSHING).
v2 vs v1 (lsx_responder.py): v1 was REQUEST-DRIVEN ONLY. It answered every verb
the client asked for and never sent an unsolicited frame. That is exactly why
the client never issued GetAuthCode and never sent Blaze Authentication::login.
THE ORIGIN SDK HAS TWO INDEPENDENT FLAGS, FED BY TWO DIFFERENT MECHANISMS:
(1) "internet is reachable" -> OriginMgr online byte [0x1448a3ac0]
fed by the REQUEST verb GetInternetConnectedState -> connected="1"
(v1 already beat this; live-confirmed == 1)
(2) "a user is LOGGED IN" -> OriginMgr.m_isLoggedIn [OriginMgr+0x13]
fed ONLY by a server-PUSHED <Event sender="LOGIN_EVENT"><Login/>
There is NO request verb that can set it.
v1 fed (1) and never fed (2), so m_isLoggedIn was 0 for the whole session,
FIFA never enqueued an auth-code request into FirstPartyAuthTokenRetriever
(both request slots live-read as 0x0), DoTick @0x146f199c0 exited immediately,
OriginRequestAuthCodeSync @0x1470db3c0 was never called, LoginRequest.AUTH
could never be filled -> no Blaze login -> "Unable to retrieve account
information."
BINARY EVIDENCE (all re-verified byte-for-byte from our own live dumps, not
from any leak; see the PROVENANCE block at the bottom of this docstring):
* Origin event dispatcher @0x146f1e060, case edx==2 (OriginEventT::Login) is
the ONLY case in the whole dispatcher that mutates state:
146f1e09e: 41 83 39 01 cmp DWORD PTR [r9],0x1 ; IsLoggedIn==1
146f1e0ab: c6 41 13 01 mov BYTE PTR [rcx+0x13],1 ; m_isLoggedIn=TRUE
146f1e0af: c7 41 14 00.. mov DWORD PTR [rcx+0x14],0 ; clear login error
146f1e0b8: c6 41 13 00 mov BYTE PTR [rcx+0x13],0 ; else FALSE
* <Login> element matcher @0x147102880:
- reads attribute "sender" (literal @0x143938028) off the <Event> node
via vtbl+0x70; `test rax,rax; je fail` -> sender MUST be present
- inline strcmp of that value against the handler's registered sender
-> a mismatched sender is SILENTLY DROPPED
- then requires the child element name == "Login" (@0x14393d0ac)
* Handler sender strings come from the service-name tables. NOTE there are
TWO parallel structures, so do not "fix" one stride into the other:
- the const char* INIT table @0x144341420 is STRIDE 8;
- the runtime std::string array the SDK actually indexes (sdk+0x3b0, via
GetServiceName @0x1470e4870 with `shl rax,0x5`) is STRIDE 0x20, max
index 0x21.
Both resolve index 14 == LOGIN_EVENT, so the conclusion is the same.
Verified contents of the index space:
idx 0 SDK 1 PROFILE 2 PRESENCE 3 FRIENDS 4 COMMERCE
idx 5 RECENTPLAYER 6 IGO 7 MISC 8 LOGIN
idx 9 UTILITY 10 XMPP 11 CHAT 12 IGO_EVENT
idx13 EALS_EVENTS 14 LOGIN_EVENT 15 INVITE_EVENT
idx16 PROFILE_EVENT ... 27 ONLINE_STATUS_EVENT
"LOGIN_EVENT" (@0x14394c790) is referenced from EXACTLY ONE place in the
whole image: table slot 0x144341490 == index 14. Likewise
"ONLINE_STATUS_EVENT" (@0x14394c868) only from 0x1443414f8 == index 27.
* <Login> attribute parser @0x147138660: opens namespace "lsx", reads attribute
"IsLoggedIn" (@0x14394e0f0), then @0x14713ffa0 does
strcmp(value,"false"); setne al; mov BYTE PTR [rdi],al
-> ANY value except the literal string "false" means TRUE.
* <OnlineStatusEvent> parser @0x147139e00 reads attribute "isOnline"
(@0x14394e180, lower-case i) in the same shape.
* Symbols proving the handler templates are instantiated in this build:
Origin::EventHandler<struct lsx::LoginT,unsigned int>::HandleMessage
Origin::EventHandler<struct lsx::OnlineStatusEventT,bool>::HandleMessage
(payload type `unsigned int` matches `cmp DWORD PTR [r9],1` above.)
* Structural proof that unsolicited <Event> frames are consumable: the LSX
handshake itself is one -- <LSX><Event sender="EALS"><Challenge/></Event>.
WHAT WE DELIBERATELY DID NOT CHANGE
* The crypto (challenge / session-key derivation / AES-ECB+PKCS7+hex+NUL) is
byte-verified against a captured real session; it is copied verbatim.
* Every verb v1 answered is answered identically. Nothing was removed.
WIRE PROTOCOL (unchanged, reversed from stp-origin_emu.dll @ 0x6ffffc930000):
transport : TCP 127.0.0.1:4216, each message NUL-terminated (send strlen+1).
handshake : server sends <Challenge key="..."> IN PLAINTEXT;
client replies plaintext with response=/key=;
server replies <ChallengeAccepted response="H"> where
H = hex(AES128_ECB(K_FIXED, PKCS7pad16(clientKeyAscii)))
K_FIXED = 000102...0f (emu .rdata 0x935038)
session : every later frame (BOTH directions, Responses AND Events) is
hex_lower(AES128_ECB(SESSION_KEY, pkcs7pad16(xml))) + b"\0"
SESSION_KEY derived from H via the MSVCR srand/rand LCG.
USAGE: bind BEFORE launching FIFA 17 so the Steampunks stub's bind() fails.
(This file does NOT auto-start anything; the main session owns processes.)
PROVENANCE / CLEAN ROOM: every constant and algorithm here was recovered by our
own static+dynamic analysis of binaries we own (FIFA17.exe unpacked in our own
process, stp-origin_emu.dll as loaded) plus traffic we ourselves captured.
Nothing is derived from the 2021 EA/FIFA leak.
"""
import os
import re
import socket
import sys
import threading
import time
from Crypto.Cipher import AES
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from fut_account import ACCOUNT # noqa: E402
# ---------------------------------------------------------------- identity
# SOURCED FROM fut_account.ACCOUNT, shared with blaze_responder_v3b.py,
# fut_store.py, fut_seed.py and utas_server.py.
#
# THE CONSTRAINT IS CROSS-LAYER CONSISTENCY, NOT ANY PARTICULAR VALUE: what LSX
# reports here must equal what Blaze returns in LoginResponse.SESS.PDTL and what
# UTAS serves as userInfo.personaId. (The previous comment blamed a mismatch for
# AUTH_ERR_INVALID_PERSONA / AUTH_ERR_USER_DOES_NOT_MATCH_PERSONA /
# AUTH_ERR_PERSONA_NOT_FOUND -- those are Blaze *server* error codes and we are
# the server. Neither "CAGE" nor "33068179" appears in FIFA17.exe, CardsDLL or
# dbdata.dll; 33068179 lives only in stp-origin_emu.dll's own ini default. They
# stay the defaults because they are what the working stack asserts.)
PERSONA_ID = ACCOUNT.persona_id
PERSONA_NAME = ACCOUNT.persona_name
USER_ID = ACCOUNT.user_id # derived from persona_id
CONTENT_ID = ACCOUNT.CONTENT_ID # FIFA 17 EA offer id
ENTITLEMENT_TAG = ACCOUNT.ENTITLEMENT_TAG
LOCALE = ACCOUNT.locale
AUTHCODE_FILE = "/tmp/openfut_authcode.txt"
CLIENTID_FILE = "/tmp/openfut_lsx_clientid.txt"
# Recovered from stp-origin_emu.dll .rdata @ VA 0x6ffffc935038
K_FIXED = bytes(range(16)) # 000102030405060708090a0b0c0d0e0f
# Emu's own advertised challenge (any 32 hex chars work; the client echoes it back)
CHALLENGE_KEY = "2b8ee7faea76e8a34f5f5d20e5328e32"
BUILD = "release"
VERSION = "10,4,13,6637"
# ------------------------------------------------------------- event tuning
# Pushes are idempotent state notifications, so re-sending is harmless and is
# cheap insurance against FIFA registering its <Login> handler later than our
# first push. Set OPENFUT_LSX_EVENTS=0 to fall back to v1 behaviour (useful as
# an A/B control if you want to prove the events are what moved the needle).
EVENTS_ENABLED = os.environ.get("OPENFUT_LSX_EVENTS", "1") != "0"
EVENT_HEARTBEAT_SECS = float(os.environ.get("OPENFUT_LSX_EVENT_PERIOD", "5"))
EVENT_HEARTBEAT_COUNT = int(os.environ.get("OPENFUT_LSX_EVENT_COUNT", "24"))
# EXPERIMENT: push the Login Event in PLAINTEXT right after ChallengeAccepted
# (before the stream goes encrypted) instead of via the encrypted heartbeat.
# Tests the workflow's strongest remaining hypothesis -- that FIFA drops
# encrypted mid-session Events (the emu's only Event, the Challenge, is plaintext
# and pre-key). See serve() step 3b and REPACK_INTEL.md sec.4 step 2.
LOGIN_PLAINTEXT = os.environ.get("OPENFUT_LSX_LOGIN_PLAINTEXT", "0") != "0"
# A/B-control integrity: v1 (lsx_responder.py) answered GetGameInfo
# FULLGAME_PURCHASED with "false" (it fell through to the default). v2 had
# silently changed it to "true", which meant OPENFUT_LSX_EVENTS=0 was NOT a
# byte-identical control any more. Keep it OFF by default so events-off ==
# v1 exactly; flip OPENFUT_LSX_FULLGAME=1 to run the FULLGAME="true" experiment
# on its own.
FULLGAME_PURCHASED_TRUE = os.environ.get("OPENFUT_LSX_FULLGAME", "0") != "0"
def log(*a):
print("[lsx]", *a, flush=True)
_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.)
def msvcr_rand(seed):
"""MSVCR120 srand/rand LCG (verified: srand(7); rand() == 61)."""
s = seed & 0xFFFFFFFF
while True:
s = (s * 214013 + 2531011) & 0xFFFFFFFF
yield (s >> 16) & 0x7FFF
def derive_session_key(resp_hex: str) -> bytes:
"""Reimplementation of emu sub_0x6ffffc931f10 tail (0x9320bf-0x932101).
srand(7); r0 = rand() -> r0 == 61
bx = (u16)((resp[0] << 8) + resp[1]) (16-bit wrap)
srand(bx + r0)
key[i] = (uint8_t)rand() for i in 0..15
"""
r0 = next(msvcr_rand(7)) # == 61
bx = ((ord(resp_hex[0]) << 8) + ord(resp_hex[1])) & 0xFFFF
g = msvcr_rand((bx + r0) & 0xFFFFFFFF)
return bytes(next(g) & 0xFF for _ in range(16))
# AES128-ECB(K_FIXED, 0x10*16) -- the constant the emu appends as the 3rd hex
# block (== PKCS7 pad block of an aligned 32-byte key). See REPACK_INTEL.md sec.0-B.
_TAIL_CONST = AES.new(K_FIXED, AES.MODE_ECB).encrypt(b"\x10" * 16).hex()
def challenge_response(client_key_ascii: str, client_response_attr: str = "") -> str:
"""Emu-exact ChallengeAccepted.response (stp-origin_emu.dll 0x180001f10).
The emu computes only TWO AES blocks from the 32-ASCII client key, then
strcat_s's the client's OWN response[64:] verbatim (@0x1800020a9) -> 96 hex.
Our older 3-block PKCS7 form is numerically identical *while the client
PKCS7-pads its 3rd block* (REPACK_INTEL.md sec.0-A/0-B, workflow-confirmed
byte-exact). We now reproduce the emu exactly and, when the client's
response= is available, echo its tail and assert the constant so a future
client that randomises block 3 fails LOUDLY instead of silently."""
two = AES.new(K_FIXED, AES.MODE_ECB).encrypt(client_key_ascii.encode()).hex()
if len(client_response_attr) >= 64:
tail = client_response_attr[64:]
assert tail == _TAIL_CONST, f"unexpected ChallengeResponse tail {tail!r}"
return two + tail
return two + _TAIL_CONST
def lsx_encrypt(xml: str, key: bytes) -> bytes:
"""pkcs7-pad to 16, AES-128-ECB, lowercase hex, NUL-terminated."""
b = xml.encode()
pad = 16 - (len(b) % 16) # emu always pads (pad==16 when aligned)
b += bytes([pad]) * pad
return AES.new(key, AES.MODE_ECB).encrypt(b).hex().encode() + b"\0"
def lsx_decrypt(data: bytes, key: bytes) -> str:
h = data.split(b"\0")[0].strip()
raw = AES.new(key, AES.MODE_ECB).decrypt(bytes.fromhex(h.decode()))
pad = raw[-1]
if 0 < pad <= 16 and all(c == pad for c in raw[-pad:]):
raw = raw[:-pad]
return raw.split(b"\0")[0].decode(errors="replace")
# ------------------------------------------------------- PUSHED EVENTS (NEW)
#
# Frame shape is identical to the server-initiated <Challenge> that already
# works, i.e. <LSX><Event sender="..."><Element .../></Event></LSX>
# No id attribute (the Challenge has none; the matcher never reads one).
#
# `sender` is strcmp'd against the handler's registered service name. A
# mismatch is silently dropped -- costs us nothing -- so for the Login element
# we emit BOTH candidate senders: "LOGIN_EVENT" (table index 14, the one the
# event-handler factory uses) and "LOGIN" (table index 8, the plain service
# name). Exactly one of them will match; the other is a no-op.
# Event handlers are keyed on serviceNames[facility] too (registrar 0x14710df80);
# with our empty GetConfigResponse those names are "", so the handlers expect
# sender="". "" first; the named variants are harmless no-ops (dropped silently)
# and become correct once GetConfigResponse populates the table (RANK 2).
LOGIN_EVENT_SENDERS = ("", "LOGIN_EVENT", "LOGIN")
ONLINE_EVENT_SENDERS = ("", "ONLINE_STATUS_EVENT")
def event(sender: str, element: str) -> str:
return f'<LSX><Event sender="{sender}"><{element}/></Event></LSX>'
def login_event_frames() -> list:
"""The frames that flip OriginMgr.m_isLoggedIn ([OriginMgr+0x13]) to 1.
IsLoggedIn is parsed as `strcmp(v,"false") != 0`, so "true" -> TRUE.
Keep the value literally "true" anyway: it is what a real Origin client
sends and it keeps the log readable."""
out = [event(s, 'Login IsLoggedIn="true"') for s in LOGIN_EVENT_SENDERS]
out += [event(s, 'OnlineStatusEvent isOnline="true"')
for s in ONLINE_EVENT_SENDERS]
return out
class Conn:
"""Socket + session key + a send lock.
The lock matters: pushes come from a heartbeat thread while the request
loop may be writing a Response. LSX frames are NUL-delimited, so two
interleaved sendall()s would corrupt the stream and the client would drop
the connection (which would look exactly like a protocol bug)."""
def __init__(self, sock, addr):
self.sock = sock
self.addr = addr
self.key = None
self.lock = threading.Lock()
self.alive = True
self.pushed_login = False
# Set True once GetAuthCode has been issued, so the heartbeat stops
# re-pushing Login/OnlineStatus events. Re-pushing after the auth code
# is granted re-enters FIFA's state-mutating Origin event dispatcher
# (case 2 @0x146f1e0ab sets m_isLoggedIn + clears loginError + rebroadcasts
# on the FE bus) ~24 more times DURING Blaze login, which we do not want.
self.stop_events = False
def send_plain(self, xml: str):
with self.lock:
self.sock.sendall(xml.encode() + b"\0")
def send_enc(self, xml: str):
with self.lock:
self.sock.sendall(lsx_encrypt(xml, self.key))
def push_login_state(self, why: str):
if not EVENTS_ENABLED:
return
for frame in login_event_frames():
try:
self.send_enc(frame)
except Exception as e:
self.alive = False
log("push failed:", e)
return
log(f"PUSH ({why}) >> {frame}")
if not self.pushed_login:
self.pushed_login = True
log("*** first <Login IsLoggedIn=\"true\"> pushed. Watch for "
"GetAuthCode next. ***")
def heartbeat(self):
"""Re-push the login state a bounded number of times.
FIFA builds its Origin event handlers lazily; if our first push lands
before the <Login> handler is registered the matcher simply finds no
handler and drops it. Re-pushing removes that race without needing to
guess the exact registration moment."""
for _ in range(EVENT_HEARTBEAT_COUNT):
time.sleep(EVENT_HEARTBEAT_SECS)
if not self.alive or self.stop_events:
return
self.push_login_state("heartbeat")
# ---------------------------------------------------------------- responses
def resp(mid, body, sender=""):
return f'<LSX><Response id="{mid}" sender="{sender}"><{body}/></Response></LSX>'
def build_reply(mid, req_name, attrs, conn, recipient=""):
"""Request-DRIVEN dispatch (the Steampunks stub was a blind fixed script).
CRITICAL (2026-07-31, connect-reverse workflow): FIFA's response matcher
0x1471189b0 rejects any <Response> whose `sender` attribute does not
byte-equal the `recipient` the client put on the matching <Request> (it
reads serviceNames[facility]; with our empty GetConfigResponse all 34 names
are "" so recipient="" for every verb after GetConfig, which itself uses the
hard-coded literal "EbisuSDK"). We were answering GetProfile/GetAuthCode/
QueryEntitlements with sender="EbisuSDK" -> silently discarded -> GetProfile
(the SOLE writer of OriginSDK+0x3a0 default-user) never took -> the whole
online-login chain stalled at OSDK_INVALID_USER. FIX = ECHO the request's
recipient back as the response sender. This local `resp` shadows the module
one and makes `sender` default to `recipient`."""
def resp(mid, body, sender=None):
s = recipient if sender is None else sender
return f'<LSX><Response id="{mid}" sender="{s}"><{body}/></Response></LSX>'
if req_name == "GetInternetConnectedState":
# FLAG (1): "internet is reachable". Stub hardcoded connected="0"
# -> "log in to Origin". This is NOT the logged-in flag; see the
# module docstring.
return resp(mid, 'InternetConnectedState connected="1"')
if req_name == "GetAuthCode":
# Request shape is built at 0x14713b8d0:
# <GetAuthCode ClientId="..." Scope="..."/>
# Response is matched at 0x1470e2b60: outer "LSX", element "AuthCode".
#
# THE ATTRIBUTE NAME IS "value" -- verified, not guessed:
# the "AuthCode" element match at 0x1470e2b63 tail-jumps to 0x14712fac0
# -> 0x1471312a0 = the lsx::AuthCodeT deserializer. It builds one
# attribute name (ns-prefix for "lsx" @0x14394def0, then "value"
# @0x1436c7768, concat at 0x14712d130) and does exactly ONE
# get-attribute-as-string call 0x14713fe50(node, "value", &dest).
# dest is ctx+0x00 == LSXRequest+0xb8, whose std::string size lands at
# +0xc8 -- which is what OriginRequestAuthCodeSync's impl 0x1470e67f0
# reads back at 0x1470e6924 (`mov rbx,[rdi+0xc8]`) as *out_len.
# Code=/Return= are NEVER read; with them alone the parsed string is
# empty -> out_len 0 -> EbisuMgr+0x948 stays NULL -> the OSDK classifier
# 0x14717d5d0 falls into its `test rbp,rbp / je` arm and reports
# OSDK_UNDERAGE_ERROR (a mislabelled "no auth code" fallback).
# Code=/Return= are kept only as harmless padding.
client_id = attrs.get("ClientId", "")
scope = attrs.get("Scope", "")
code = os.environ.get("OPENFUT_AUTHCODE", "OPENFUT-" + "0" * 24)
# Only touch the run's success-signal files on a REAL request (conn is a
# live socket). --selftest calls build_reply(..., conn=None); if it
# wrote these files it would pre-satisfy watch-step "authcode.txt becomes
# non-empty" and make a non-event read as success on the next live run.
if conn is not None:
for path, val in ((AUTHCODE_FILE, code), (CLIENTID_FILE, client_id)):
try:
with open(path, "w") as fh:
fh.write(val)
except Exception:
pass
# GetAuthCode has fired: stop the heartbeat so we do not keep
# re-pushing Login/OnlineStatus events during Blaze login.
conn.stop_events = True
log("*** GetAuthCode ISSUED ***")
log(f" ClientId={client_id!r} Scope={scope!r}")
log(" code=[REDACTED] -- issued for Blaze Authentication::login (1/0x0A)")
return resp(mid,
f'AuthCode value="{code}" Code="{code}" Return="{code}"')
if req_name == "QueryEntitlements":
item = (f'<OriginItem ItemId="{ENTITLEMENT_TAG}" EntitlementId="1" '
f'ResourceId="{CONTENT_ID}" OfferId="{CONTENT_ID}" '
f'GrantDate="2016-09-01T00:00:00Z" bIsOwned="true" Uses="0"/>')
return (f'<LSX><Response id="{mid}" sender="{recipient}">'
f'<QueryEntitlementsResponse>{item}</QueryEntitlementsResponse>'
f'</Response></LSX>')
if req_name == "GetProfile":
# This is the ONLY feed for OriginSDK[+0x3a0]/[+0x3a8]
# (OriginGetDefaultUser @0x1470da6d0 / OriginGetDefaultPersona
# @0x1470da680 are bare reads of those fields, written only by
# OriginSDK::Initialize @0x1470e5ad5/0x1470e5ae1). Keep it complete.
# ONLY PersonaId/UserId/Persona are substituted from ACCOUNT; the rest
# of this template (Country/CommerceCountry/GeoCountry/CommerceCurrency/
# AvatarId/IsSubscriber/IsUnderAge) is byte-exact per REPACK_INTEL 1.4
# and is latched into OriginSDK[+0x3a0]/[+0x3a8] -- leave it verbatim.
return resp(mid,
f'GetProfileResponse IsSubscriber="true" PersonaId="{PERSONA_ID}" '
f'AvatarId="" Country="US" CommerceCountry="US" GeoCountry="US" '
f'UserId="{USER_ID}" Persona="{PERSONA_NAME}" IsUnderAge="false" '
f'CommerceCurrency="USD"')
if req_name == "GetGameInfo":
gi = attrs.get("GameInfoId")
if gi == "LANGUAGES":
return resp(mid, 'GetGameInfoResponse GameInfo="ar_SA,cs_CZ,da_DK,de_DE,'
'en_US,es_ES,es_MX,fr_FR,it_IT,nl_NL,no_NO,pl_PL,pt_BR,'
'pt_PT,ru_RU,sv_SE,tr_TR,zh_TW"')
if gi == "UPTODATE":
# MUST be true or the client shows "Your title version is
# outdated" and blocks all online features.
return resp(mid, 'GetGameInfoResponse GameInfo="true"')
if gi == "FULLGAME_PURCHASED" and FULLGAME_PURCHASED_TRUE:
# OFF by default: v1 answered "false" here (fell through to default).
# Keeping this gated makes OPENFUT_LSX_EVENTS=0 byte-identical to v1.
return resp(mid, 'GetGameInfoResponse GameInfo="true"')
# FREETRIAL / FULLGAME_PURCHASED etc. -> false (retail, not a trial;
# matches v1 exactly)
return resp(mid, 'GetGameInfoResponse GameInfo="false"')
if req_name == "GetSetting":
sid = attrs.get("SettingId", "").upper() # client asks UPPERCASE
if sid in ("ENVIRONMENT", "ENVIRONMENTNAME"):
return resp(mid, 'GetSettingResponse Setting="production"')
if sid == "LANGUAGE":
return resp(mid, f'GetSettingResponse Setting="{LOCALE}"')
return resp(mid, 'GetSettingResponse Setting="false"')
if req_name == "GetConfig":
return resp(mid, 'GetConfigResponse Config="false"')
if req_name == "IsProgressiveInstallationAvailable":
return resp(mid, 'IsProgressiveInstallationAvailableResponse ItemId="" '
'Available="false"')
return resp(mid, 'ErrorSuccess Code="0" Description=""')
# Trigger points: push right after answering these verbs. GetProfile is the
# earliest safe moment -- by then the SDK has built its handler set and has a
# default user, so a Login event has somewhere to land.
PUSH_AFTER = {
"GetProfile": "after GetProfile",
"GetInternetConnectedState": "after GetInternetConnectedState",
"GetGameInfo": "after GetGameInfo UPTODATE",
}
REQ_RE = re.compile(r'<Request[^>]*\bid="(\d+)"[^>]*>\s*<([A-Za-z]+)([^>]*)/?>')
ATTR_RE = re.compile(r'(\w+)="([^"]*)"')
# The response `sender` must byte-equal the request's `recipient` (matcher
# 0x1471189b0). Captured separately (default "") so a frame that ever lacks
# `recipient` still gets answered fast instead of a 15s stall.
RECIP_RE = re.compile(r'<Request[^>]*\brecipient="([^"]*)"')
def serve(sock, addr):
conn = Conn(sock, addr)
hb = None
try:
# 1. plaintext Challenge
conn.send_plain(f'<LSX><Event sender="EALS"><Challenge key="{CHALLENGE_KEY}" '
f'build="{BUILD}" version="{VERSION}"/></Event></LSX>')
# 2. plaintext ChallengeResponse from client. The emu parses response="
# BEFORE key=" (0x180001f10); extract both so challenge_response can
# echo the client's own 3rd block (REPACK_INTEL.md C1/C2).
data = sock.recv(4096)
txt = data.decode(errors="replace")
mk = re.search(r'key="([^"]*)"', txt)
mr = re.search(r'response="([^"]*)"', txt)
client_key = mk.group(1) if mk else CHALLENGE_KEY
client_resp = mr.group(1) if mr else ""
h = challenge_response(client_key, client_resp)
conn.key = derive_session_key(h)
log("handshake accepted; session crypto initialized")
# 3. plaintext ChallengeAccepted
conn.send_plain(resp(1, f'ChallengeAccepted response="{h}"', "EALS"))
# 3b. EXPERIMENT (OPENFUT_LSX_LOGIN_PLAINTEXT=1): the shipped emu's ONLY
# unsolicited Event is the plaintext, pre-session-key Challenge; there
# is zero evidence an *encrypted mid-session* Event routes to the same
# parser (REPACK_INTEL.md sec.4 step 2). So push the Login Event here,
# in PLAINTEXT, right after ChallengeAccepted -- before the stream goes
# encrypted -- and suppress the encrypted heartbeat to keep the A/B clean.
if LOGIN_PLAINTEXT and EVENTS_ENABLED:
conn.stop_events = True
for frame in login_event_frames():
conn.send_plain(frame)
log(f"PUSH (plaintext post-accept) >> {frame}")
# 4. encrypted request/response loop
buf = b""
while True:
data = sock.recv(65536)
if not data:
break
# Buffer partial frames: a 64 KiB recv can straddle a NUL boundary,
# and split() would silently drop the trailing partial (C3).
buf += data
*frames, buf = buf.split(b"\0")
for chunk in filter(None, frames):
try:
xml = lsx_decrypt(chunk + b"\0", conn.key)
except Exception as e:
log("decrypt fail:", e)
continue
mm = REQ_RE.search(xml)
if not mm:
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))
rm = RECIP_RE.search(xml)
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(">>", safe_xml_for_log(reply))
conn.send_enc(reply)
why = PUSH_AFTER.get(name)
if why and EVENTS_ENABLED:
# For GetGameInfo only fire on UPTODATE, otherwise we would
# push three times per boot for FREETRIAL/LANGUAGES too.
if name != "GetGameInfo" or attrs.get("GameInfoId") == "UPTODATE":
conn.push_login_state(why)
if hb is None:
hb = threading.Thread(target=conn.heartbeat,
daemon=True)
hb.start()
except Exception as e:
log("connection error:", e)
finally:
conn.alive = False
try:
sock.close()
except Exception:
pass
log("connection closed", addr)
def main():
s = socket.socket()
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
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'}"
f" (period={EVENT_HEARTBEAT_SECS}s count={EVENT_HEARTBEAT_COUNT})")
while True:
c, a = s.accept()
log("connection from", a)
threading.Thread(target=serve, args=(c, a), daemon=True).start()
# ---------------------------------------------------------------- self-test
def selftest():
"""No live game needed. Proves the crypto is untouched and the new event
frames encrypt/decrypt cleanly through our own codec."""
h = challenge_response("18a70055a3541fb27ab8e0f47afad18c")
assert h.startswith("e4f5166209929e15"), h
k = derive_session_key(h)
assert k.hex() == "6a9da3e78615153cc2f10eec25ae6382", k.hex()
print("[ok] crypto matches the captured 2026-07-30 session verbatim")
frames = login_event_frames()
assert len(frames) == len(LOGIN_EVENT_SENDERS) + len(ONLINE_EVENT_SENDERS)
for f in frames:
assert lsx_decrypt(lsx_encrypt(f, k), k) == f
print("[ok] round-trip:", f)
# "" sender first (correct for the current empty service-name table)
assert '<Event sender=""><Login IsLoggedIn="true"/></Event>' in frames[0]
r = build_reply(42, "GetAuthCode", {"ClientId": "X", "Scope": "Y"}, None)
# 'value' is the only attribute lsx::AuthCodeT's deserializer (0x1471312a0)
# actually reads; Code=/Return= are legacy padding.
assert '<AuthCode value=' in r, 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")
if __name__ == "__main__":
if "--selftest" in sys.argv:
selftest()
else:
main()
@@ -0,0 +1,45 @@
#!/usr/bin/env python3
"""Live FIFA17 /proc/mem reader + patcher.
Usage:
memtool.py read <va_hex> [nbytes]
memtool.py patch <va_hex> <hexbytes> # saves original to /tmp/orig_<va>.bin
memtool.py restore <va_hex>
"""
import sys, os, glob
def find_pid():
for d in glob.glob('/proc/[0-9]*'):
try:
if open(d+'/comm').read().strip() == 'FIFA17.exe':
return int(d.split('/')[-1])
except Exception:
pass
raise SystemExit("FIFA17.exe not found")
def main():
cmd = sys.argv[1]
va = int(sys.argv[2], 16)
pid = find_pid()
path = f'/proc/{pid}/mem'
if cmd == 'read':
n = int(sys.argv[3]) if len(sys.argv) > 3 else 16
with open(path, 'rb') as f:
f.seek(va); data = f.read(n)
print(f"pid={pid} va={va:#x} : " + data.hex())
elif cmd == 'patch':
patch = bytes.fromhex(sys.argv[3])
with open(path, 'rb') as f:
f.seek(va); orig = f.read(len(patch))
open(f'/tmp/orig_{va:x}.bin', 'wb').write(orig)
with open(path, 'r+b') as f:
f.seek(va); f.write(patch)
f.seek(va); check = f.read(len(patch))
print(f"pid={pid} va={va:#x} orig={orig.hex()} -> now={check.hex()}")
elif cmd == 'restore':
orig = open(f'/tmp/orig_{va:x}.bin', 'rb').read()
with open(path, 'r+b') as f:
f.seek(va); f.write(orig)
f.seek(va); check = f.read(len(orig))
print(f"pid={pid} va={va:#x} restored={check.hex()}")
main()
@@ -0,0 +1,134 @@
#!/usr/bin/env python3
"""Live read-only probe: did the pushed LSX <Login> event actually land?
READ-ONLY. Never writes to the game. Safe to run against the live FIFA17.exe
while the main session drives it.
Watches, once per second:
OriginMgr.m_isLoggedIn = *(u8)( *[0x1448acf50] + 0x13 )
Set to 1 by the Origin event dispatcher @0x146f1e060 case 2 (verified:
`cmp DWORD PTR [r9],1` -> `mov BYTE PTR [rcx+0x13],1`), which is reached
only from a server-pushed <Event sender="LOGIN_EVENT"><Login
IsLoggedIn="true"/>. 0 -> 1 means the push was dispatched.
CAVEAT (verify: will-it-reach-login): this byte is a PROXY, not the
decisive consumer. It is written on the SAME dispatcher line that then
falls into the FE re-broadcast loop @0x146f1e116 -> callback 0x147350e30
(packs the event tagged 0xdea12004 and republishes on FIFA's FE event
bus). Nothing downstream READS +0x13; the FE broadcast is what actually
propagates. So treat 0 -> 1 as "the frame was accepted", and confirm
real propagation with a gdb breakpoint on 0x147350e30 (bytes 48 83 ec 58).
Also note LoginStatePCLogin's own gate is a DIFFERENT object
([0x144b86bf8]->vtbl+0x60), so this flag flipping does not guarantee
GetAuthCode fires.
OriginMgr.m_loginError = *(u32)( *[0x1448acf50] + 0x14 )
Cleared to 0 by the same code path.
origin "online" byte = *(u8)[0x1448a3ac0]
INIT-SET, NOT DIAGNOSTIC: OriginMgr::Initialize writes this to 1
unconditionally @0x146f340e1 (`mov BYTE PTR [rip+...],0x1`), so it does
NOT reflect GetInternetConnectedState. Shown for reference only; do not
read it as a live state field.
FirstPartyAuthTokenRetriever slots
retriever = *[0x1448a3b20] + 0x4e98 ; slots at +0x08 and +0x10
DoTick @0x146f199c0 walks these two; if both stay 0 no auth-code
request was ever enqueued and RequestAuthCodeSync @0x1470db3c0 is
never called. Non-zero here = GetAuthCode is imminent.
Usage: python3 origin_login_probe.py [seconds]
"""
import glob
import struct
import sys
import time
ORIGIN_MGR_PP = 0x1448acf50 # -> OriginMgr*
ORIGIN_ONLINE_BYTE = 0x1448a3ac0 # FIFA's separate "origin online" flag
SDK_PP = 0x1448a3b20 # -> OriginSDK*, retriever at +0x4e98
RETRIEVER_OFF = 0x4e98
def find_pid():
for d in glob.glob("/proc/[0-9]*"):
try:
if open(d + "/comm").read().strip() == "FIFA17.exe":
return int(d.rsplit("/", 1)[-1])
except OSError:
pass
return None
class Mem(object):
def __init__(self, pid):
self.f = open("/proc/%d/mem" % pid, "rb")
def rd(self, va, n):
try:
self.f.seek(va)
b = self.f.read(n)
return b if b and len(b) == n else None
except OSError:
return None
def u8(self, va):
b = self.rd(va, 1)
return None if b is None else b[0]
def u32(self, va):
b = self.rd(va, 4)
return None if b is None else struct.unpack("<I", b)[0]
def u64(self, va):
b = self.rd(va, 8)
return None if b is None else struct.unpack("<Q", b)[0]
def hx(v):
return "??" if v is None else ("%#x" % v)
def main():
limit = float(sys.argv[1]) if len(sys.argv) > 1 else 1e9
pid = find_pid()
if pid is None:
print("no FIFA17.exe running")
return 1
print("pid", pid)
m = Mem(pid)
t0 = time.time()
last = None
while time.time() - t0 < limit:
mgr = m.u64(ORIGIN_MGR_PP)
logged = m.u8(mgr + 0x13) if mgr else None
err = m.u32(mgr + 0x14) if mgr else None
online = m.u8(ORIGIN_ONLINE_BYTE)
sdk = m.u64(SDK_PP)
r = (sdk + RETRIEVER_OFF) if sdk else None
s1 = m.u64(r + 0x08) if r else None
s2 = m.u64(r + 0x10) if r else None
row = (logged, err, online, s1, s2)
if row != last:
print("[%s] OriginMgr=%s m_isLoggedIn=%s loginError=%s "
"onlineByte=%s(init-set) | authSlots=%s,%s"
% (time.strftime("%H:%M:%S"), hx(mgr), logged, hx(err),
online, hx(s1), hx(s2)))
# Fire on any non-1 -> 1 transition (including the very first sample
# where OriginMgr was still null and last[0] was None), so a
# None -> 1 flip is not silently missed.
if last is not None and last[0] != 1 and logged == 1:
print(" *** m_isLoggedIn -> 1 : the pushed <Login> event was "
"DISPATCHED (proxy signal; confirm FE re-broadcast at "
"0x147350e30). Watch for LSX GetAuthCode next. ***")
if last is not None and not (last[3] or last[4]) and (s1 or s2):
print(" *** auth-code request ENQUEUED into "
"FirstPartyAuthTokenRetriever. ***")
last = row
time.sleep(1.0)
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,367 @@
#!/usr/bin/env python3
"""OpenFUT — POW / EASFC server for FIFA 17 (clean-room).
WHY THIS EXISTS
---------------
The FUT hub's "EA FC servers are unreachable / PRESS Q TO RE-CONNECT" banner is
NOT the FUT/UTAS layer, NOT Blaze and NOT Origin/LSX -- all three are healthy in
our live logs while the banner is showing. It is the EASFC layer, implemented in
`powdll_Win64_retail.dll` (1.1 MB, UNPACKED and string-rich -- unlike the Denuvo
-packed FIFA17.exe, this one can actually be reversed).
POW is a THIRD HTTP API, alongside Blaze and UTAS, that we have never served:
api pas.gt.easfc.ea.com:8094 paths `pow/...`
content content.lt.easfc.ea.com:8080 paths `pow/imgAssets/...`, artAssets, ...
Neither hostname is in /etc/hosts nor in the iptables DNAT, so every POW call dies
at DNS resolution and the client raises the reconnect prompt.
REVERSED FROM powdll (PE base 0x180000000, Ghidra project /tmp/pow/powproj):
* FUN_18005a460 -- POW config init. Reads, through the SAME client-config store
that already feeds us ROSTERUPDATE_URL (cfg->vtbl[0x30] = getString with a
default): "FIFA_POW_URL", "FIFA_POW_CONTENT_SERVER_URL", and "POW_IS_ON".
It picks an http:// vs https:// prefix (PTR_s_http____18010aee0 /
PTR_s_https____18010aee8). So POW can be redirected purely by serving those
keys from blaze_responder_v3b.py -- no /etc/hosts and no root required.
* FUN_18005cb40 -- the health-check / reconnect handler. Issues
`pow/healthcheck/system/all` via the request builder FUN_18005e780, then sets
the POW connection state at POWmgr[0x6ac]:
1 = connected/online 3 = disconnected (raises the prompt)
It is also the site that fires the `POWService::PowReconnect` FE event.
* FUN_18005c970 fires POWService::PowBlazeDisconnected,
FUN_1800a8590 fires POWService::TriggerPleaseConnectMsg,
FUN_1800ad090 references TXT_EASFC_RECONNECT_PROMPT (the banner string).
STATUS: the REQUEST side is mapped (58 `pow/...` path templates extracted from the
binary, see PATHS below). The RESPONSE schemas are NOT yet reversed -- powdll's
parsers have not been walked. So this server's job right now is to be a faithful,
loud LOGGER: bind the ports, answer every request in a way that cannot wedge the
client, and write the exact method/path/headers/body of everything POW asks for to
/tmp/pow_server.log. That capture is what turns the response schemas from guesswork
into reversing targets, exactly as the UTAS log did for the squad work.
MODES (POW_MODE):
log (default) every request -> 200 {} (assets -> 404), everything logged.
Nothing is asserted about our capabilities; safest first run.
serve additionally answers the handful of paths whose shape we can
infer (auth/healthcheck/counts) with minimal plausible bodies.
Use this only AFTER a capture run, and expect to iterate.
Ports: POW_ADDR (default 127.0.0.1:8094), POW_CONTENT_ADDR (default 127.0.0.1:8080).
"""
import datetime, json, os, re, sys, threading, http.server
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
try:
from fut_account import ACCOUNT # username/persona, single source
except Exception: # keep the logger usable standalone
ACCOUNT = None
LOG = os.environ.get("POW_LOG", "/tmp/pow_server.log")
# Default flipped to `serve` once the schemas were recovered from powdll: `log`
# answers every list with {}, which makes the catalogue pager spin forever (983
# requests in 84s, live-captured). POW_MODE=log is still available for a fresh
# capture run.
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(":")
return (host or "127.0.0.1", int(port or default_port))
def log(m):
line = "[%s] %s" % (datetime.datetime.now().strftime("%H:%M:%S"), m)
print(line, flush=True)
try:
with open(LOG, "a") as f:
f.write(line + "\n")
except Exception:
pass
# Every `pow/...` path template found in powdll_Win64_retail.dll. Kept verbatim so
# the log can flag an incoming path that is NOT in this list (i.e. our extraction
# missed something) rather than silently lumping it in with the known set.
PATHS = [
"pow/auth", "pow/healthcheck/system/all", "pow/nucleus/entitlements",
"pow/v2/activity", "pow/activity/count", "pow/bank/user/account",
"pow/bank/currency/%s/cap/info", "pow/chal/user/prog", "pow/communication/all",
"pow/communication/all/countUnread", "pow/communication/count",
"pow/communication/type/%s", "pow/communication/attributes/type/%s",
"pow/components/EASFCWidget", "pow/gamechange/gamechangetype/%s",
"pow/inventory/item", "pow/inventory/item/list",
"pow/lvl/user/tiergp/%s/tiertp/%s", "pow/lvl/weight/tiergp/%s/tiertp/%s",
"pow/message", "pow/mm", "pow/mm/game/%s/message/list",
"pow/news/count/unread", "pow/news/opt", "pow/news/user",
"pow/pfyc/user", "pow/pfyc/user/club", "pow/pfyc/user/prefs/shareinfo",
"pow/store/game/%s/catalog/list", "pow/store/game/%s/catalog/%d/item/list",
"pow/store/gift/list", "pow/user/friends",
"pow/users/info/tiergp/%s/tiertp/%s",
]
_KNOWN = [re.compile("^/?" + re.escape(p).replace(r"\%s", "[^/]+").replace(r"\%d", r"\d+")
.replace(r"\%lld", r"\d+") + "$") for p in PATHS]
# Asset roots are PREFIXES in the binary ("pow/imgAssets/", plus %d-templated file
# names), so match them by prefix rather than exact template or every art fetch
# trips the unknown-path flag.
_ASSET_PREFIXES = ("pow/imgAssets/", "pow/artAssets/", "pow/facebook/",
"pow/cacheresponse/")
def is_known_path(path):
p = path.split("?", 1)[0]
if p.lstrip("/").startswith(_ASSET_PREFIXES):
return True
return any(rx.match(p) for rx in _KNOWN)
def _username():
if ACCOUNT is not None:
return ACCOUNT.persona_name
return os.environ.get("POW_USERNAME", "CAGE")
def _persona_id():
if ACCOUNT is not None:
return ACCOUNT.persona_id
return int(os.environ.get("POW_PERSONA_ID", "33068179"))
# ---- response schemas, recovered from powdll ---------------------------------
# Every key below is a LITERAL STRING in powdll_Win64_retail.dll, i.e. a name the
# client's parser actually compares against. Addresses are the literal's location.
#
# level parser FUN_180094700 groups exactly these seven:
# level(0x1800c9862) exp(0x1800e1974) currLevelExpMin(0x1800e1978)
# currLevelExpMax(0x1800e1988) isMaxLevel(0x1800e1998) dailyXpCap(0x1800e1bb0)
# currency(0x1800c96e0)
# paging: itemsTotal(0x1800e18c0) numItems(0x1800e17d8) totalCount(0x1800c9218)
# bank (contiguous field-name table, i.e. a reflection-style schema):
# currencies(0x1800c96b8) currency(0x1800c96e0) currencyName(0x1800c96f0)
# funds(0x1800c98f0) fundsBalance(0x1800c98f8) fundsCap(0x1800c9908)
# fundsCapInfo(0x1800c9918) fundsEarned(0x1800c9928)
# accountBalance(0x1800c92e0) balance(0x1800ccb28) numCurrency(0x1800e1808)
# pow_funds(0x1800c7f30) -- the currency NAME the client asks for by
# `pow/bank/currency/pow_funds/cap/info` (live-captured).
#
# DELIBERATELY NOT INVENTED: personaId / personaName / userId / sessionId /
# displayName / personaList do NOT exist as literals anywhere in powdll, so an
# auth response carrying them would be parsed as nothing. An earlier draft of this
# file asserted exactly those keys -- it was wrong and is corrected here.
POW_CURRENCY = "pow_funds"
# ---- ENVELOPE PROBE ----------------------------------------------------------
# The field NAMES are certain (literals in powdll). The top-level ENVELOPE is not:
# serving the level record at the JSON root was live-tested and IGNORED -- the hub
# still read "LVL: 0/0". The wrapper is not statically recoverable so far: the name
# tables (FUN_180094700 etc.) are plain `return names[idx]` helpers with no schema
# descriptor attached, and their only other xrefs are .pdata unwind entries.
#
# So probe empirically, but in ONE launch instead of one-per-candidate: emit the
# record at the root AND under every plausible wrapper key at once. A reflection
# parser ignores members it has no field for (the same SKIP behaviour CardsDLL's
# deserializers use), so the extra copies are inert -- whichever wrapper the client
# looks for, it finds. Wrapper candidates are the envelope-ish literals that exist
# in powdll: data(0x1800c818c) result(0x1800ce3fc) items(0x1800c9e28)
# content(0x1800c9378) status(0x1800dd2c0) success(0x1800daed8) message(0x1800cef28).
#
# Set POW_ENVELOPE=root to serve ONLY the bare record (no probe copies) once the
# right wrapper is known.
_ENVELOPE = os.environ.get("POW_ENVELOPE", "probe")
def _wrap(record, list_key="items"):
"""Root record + probe copies under each candidate wrapper."""
if _ENVELOPE == "root":
return dict(record)
body = dict(record)
for k in ("data", "result", "content"):
body[k] = dict(record)
body[list_key] = [dict(record)]
body["numItems"] = 1
body["itemsTotal"] = 1
body["totalCount"] = 1
body["status"] = "OK"
body["success"] = True
return body
def level_record():
"""The seven fields powdll's level name table (FUN_180094700) enumerates."""
a = ACCOUNT
return {
"level": a.pow_level if a else 1,
"exp": a.pow_exp if a else 0,
"currLevelExpMin": 0,
"currLevelExpMax": a.pow_exp_max if a else 1000,
"isMaxLevel": False,
"dailyXpCap": 0,
"currency": POW_CURRENCY,
}
def level_body():
"""pow/lvl/user/tiergp/%s/tiertp/%s -> the hub's "LVL: x/y" widget."""
return _wrap(level_record(), list_key="levels")
def bank_body():
"""pow/bank/user/account -> the EASFC credit counter next to the cart."""
a = ACCOUNT
funds = a.pow_funds if a else 0
cap = a.pow_funds_cap if a else 100000
entry = {
"currencyName": POW_CURRENCY,
"currency": POW_CURRENCY,
"funds": funds,
"fundsBalance": funds,
"fundsEarned": 0,
"fundsCap": cap,
"balance": funds,
"accountBalance": funds,
}
body = _wrap(entry, list_key="currencies")
body["numCurrency"] = 1
return body
def _empty_page():
"""Any paginated list. The count fields are what TERMINATE the pager.
Not cosmetic: with a bare {} the catalogue pager never learns the result count
and re-requests offset=0&count=49 forever -- 983 identical requests in 84s on
the first live capture, still 432 with only itemsTotal/numItems set. So emit
the FULL count vocabulary that powdll's list envelope reader FUN_180094560
enumerates (numItems 0x1800e17d8, numOwnedItems 0x1800e17e8, numLockedItems
0x1800e17f8, numCurrency 0x1800e1808) plus the totals the catalog-item reader
FUN_1800945c0 knows (itemCount 0x1800e18b0, itemsTotal 0x1800e18c0,
itemsOwned 0x1800e18d0), and an empty array under every plausible list key."""
body = {
"numItems": 0, "numOwnedItems": 0, "numLockedItems": 0, "numCurrency": 0,
"itemCount": 0, "itemsTotal": 0, "itemsOwned": 0,
"totalCount": 0, "count": 0, "offset": 0,
"status": "OK", "success": True,
}
for k in ("items", "list", "data", "result", "content", "catalogs",
"currencies", "entries"):
body[k] = []
return body
def serve_body(path, method):
"""MODE=serve. Bodies built only from keys verified present in powdll (above).
Returns None to fall through to {}."""
p = path.split("?", 1)[0].lstrip("/")
if p == "pow/healthcheck/system/all":
# FUN_18005cb40 issues this first, then sets POWmgr[0x6ac] 1=connected /
# 3=disconnected. Live: the client went ONLINE with a bare {} here, so the
# state is driven by transport success, not by this body. Keep it minimal.
return {}
if p.startswith("pow/lvl/"): # user + weight both parse here
return level_body()
if p == "pow/bank/user/account":
return bank_body()
if p.startswith("pow/bank/currency/") and p.endswith("/cap/info"):
a = ACCOUNT
return {"currencyName": POW_CURRENCY,
"fundsCap": (a.pow_funds_cap if a else 100000),
"fundsEarnedInPeriod": 0}
if p.endswith("/count") or p.endswith("/countUnread"):
return {"count": 0, "totalCount": 0}
# Everything list-shaped gets a terminating page. Catalogue, inventory, gifts,
# friends, activity, messages, news -- all were captured live and all page.
if ("/list" in p or p in ("pow/v2/activity", "pow/user/friends", "pow/message",
"pow/mm", "pow/communication/all", "pow/news/user",
"pow/nucleus/entitlements", "pow/inventory/item")):
return _empty_page()
return None
class _Handler(http.server.BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
kind = "api"
def _handle(self):
n = int(self.headers.get("Content-Length", 0) or 0)
body = self.rfile.read(n) if n else b""
tag = "" if is_known_path(self.path) else " !! PATH NOT IN THE EXTRACTED TEMPLATE SET"
log("%s %s %s%s" % (self.kind.upper(), self.command, self.path, tag))
for k, v in self.headers.items():
log(" %s: %s" % (k, v))
if body:
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.
self.send_response(404)
self.send_header("Content-Length", "0")
self.end_headers()
log(" -> 404 (no asset)")
return
payload = serve_body(self.path, self.command) if MODE == "serve" else None
raw = json.dumps(payload if payload is not None else {}).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(raw)))
self.end_headers()
if self.command != "HEAD":
self.wfile.write(raw)
log(" -> 200 %s" % raw[:400].decode())
do_GET = do_POST = do_PUT = do_DELETE = do_HEAD = do_PATCH = _handle
def log_message(self, *a):
pass
class _ContentHandler(_Handler):
kind = "content"
def _serve(addr, handler, label):
host, port = addr
srv = http.server.ThreadingHTTPServer((host, port), handler)
log("=== pow %s listening on http://%s:%d ===" % (label, host, port))
srv.serve_forever()
if __name__ == "__main__":
open(LOG, "a").close()
api = _split(API_ADDR, 8094)
content = _split(CONTENT_ADDR, 8080)
log("=== pow_server MODE=%s api=%s:%d content=%s:%d user=%r ==="
% (MODE, api[0], api[1], content[0], content[1], _username()))
t = threading.Thread(target=_serve, args=(content, _ContentHandler, "content"),
daemon=True)
t.start()
_serve(api, _Handler, "api")
@@ -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())
@@ -0,0 +1,143 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Bring every club card into line with the game's own player data.
Two different faults, two different remedies:
STALE the card is a real FIFA 17 player but its stored fields are the old
invented ones (rating derived from nothing, synthetic position, guessed
club and nation, attributes computed from the rating). REPAIRED in place
from data/pool.json, which is measured from the game's own database.
Deleting these would throw away almost the whole club for no reason: the
name, face and badge are already right, only the numbers are wrong.
DEAD the playerid does not exist in the roster at all. The client misses on it
and stamps its generic card (rating 50, teamid 1933, nation 14, blank
name). There is nothing to repair, because there is no player to repair it
to. They are LEFT ALONE unless --delete-dead is passed: removing cards from
someone's club is their call, not the tool's, and a blank card is ugly
rather than harmful.
RUN THIS WITH utas_server STOPPED. The server holds the profile in memory and
rewrites it on its own schedule, so an edit made underneath a running server gets
clobbered by the next save. That is exactly what happened on 2026-08-04: nine dead
cards were removed and reappeared a few hours later.
Squad safety: a card referenced by a saved squad is never removed. Repair is safe
for squad members because the item id does not change.
repair_club.py dry run (default)
repair_club.py --fire repair stale cards, keep dead ones
repair_club.py --fire --delete-dead also remove the unrepairable blanks
"""
import argparse
import json
import os
import shutil
import sys
import time
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from fut_store import STORE # noqa: E402
POOL = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "data", "pool.json")
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--fire", action="store_true")
ap.add_argument("--delete-dead", action="store_true",
help="also remove cards whose playerid is not a real player")
a = ap.parse_args()
truth = {p["id"]: p for p in json.load(open(POOL))}
p = STORE.load()
items = p.get("items", [])
squad_ids = set()
def walk(o):
if isinstance(o, dict):
for k, v in o.items():
if k in ("itemId", "id") and isinstance(v, int):
squad_ids.add(v)
walk(v)
elif isinstance(o, list):
for v in o:
walk(v)
for key in ("squad", "squads", "squadList"):
walk(p.get(key))
repaired, dead, untouched = [], [], 0
for it in items:
t = truth.get(it.get("assetId"))
if not t:
dead.append(it)
continue
changes = []
if it.get("rating") != t["rating"]:
changes.append("rating %s->%s" % (it.get("rating"), t["rating"]))
if it.get("preferredPosition") != t["pos"]:
changes.append("pos %s->%s" % (it.get("preferredPosition"), t["pos"]))
if it.get("teamid") != t["team"]:
changes.append("team %s->%s" % (it.get("teamid"), t["team"]))
if it.get("nation") != t["nation"]:
changes.append("nation %s->%s" % (it.get("nation"), t["nation"]))
if it.get("leagueId") != t["league"]:
changes.append("league %s->%s" % (it.get("leagueId"), t["league"]))
if [x.get("value") for x in it.get("attributeList", [])] != t["attrs"]:
changes.append("attrs")
if not changes:
untouched += 1
continue
repaired.append((it, changes, t))
if a.delete_dead:
keep_dead = [d for d in dead if d.get("id") in squad_ids]
drop_dead = [d for d in dead if d.get("id") not in squad_ids]
else:
keep_dead, drop_dead = dead, []
print("club %d: %d already correct, %d to repair, %d dead to remove"
% (len(items), untouched, len(repaired), len(drop_dead)))
if keep_dead:
why = ("a squad references them" if a.delete_dead
else "--delete-dead was not passed")
print(" %d dead card(s) KEPT (%s)" % (len(keep_dead), why))
for it, ch, _ in repaired[:8]:
print(" repair id=%-11s asset=%-7s %s" % (it.get("id"), it.get("assetId"),
"; ".join(ch)[:80]))
if len(repaired) > 8:
print(" ... and %d more" % (len(repaired) - 8))
for it in drop_dead[:8]:
print(" remove id=%-11s asset=%s" % (it.get("id"), it.get("assetId")))
if not a.fire:
print("\ndry run. Re-run with --fire to write.")
return 0
bak = STORE.path + ".bak-repair-%d" % int(time.time())
shutil.copy(STORE.path, bak)
print("\nbackup: %s" % bak)
for it, _, t in repaired:
it["rating"] = t["rating"]
it["preferredPosition"] = t["pos"]
it["teamid"] = t["team"]
it["nation"] = t["nation"]
it["leagueId"] = t["league"]
it["attributeList"] = [{"index": i, "value": v} for i, v in enumerate(t["attrs"])]
drop = {d.get("id") for d in drop_dead}
p["items"] = [i for i in items if i.get("id") not in drop]
STORE._save()
q = STORE.load()
print("club: %d -> %d coins=%s purchased=%d"
% (len(items), len(q.get("items", [])), q.get("coins"),
len(q.get("purchased", []))))
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,79 @@
#!/usr/bin/env python3
"""FUT roster-update HTTPS server for FIFA 17 (OpenFUT).
The FUT loading flow (checkFUTRostersFlow / state CheckFUTRosterUpdateXML) downloads
a roster-update XML from ROSTERUPDATE_URL (which blaze_responder_v3b.py now serves as
https://127.0.0.1:8081/fifa17/fut/rosterupdate.xml). On download SUCCESS the flow
raises `advance` -> CheckFUTSquadBinFile -> (no squad) -> EnterFUT -> CardsDLL loads.
On FAIL it raises `back` -> abort. So this must return something FIFA ACCEPTS.
We don't have (or need) EA's real roster: the base player DB is baked into CardsDLL;
the roster-update is an optional delta. Start by serving a minimal "no update" body,
LOG every request (path/headers) so we learn exactly what FIFA fetches, and iterate.
HTTPS because EA's value is https and the DirtySDK download mgr may reject http; FIFA's
ProtoSSL cert-verify is patched (autopatch), so our self-signed cert is accepted.
"""
import http.server, ssl, os, sys, datetime
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 = (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'
def log(m):
line = "[%s] %s" % (datetime.datetime.now().strftime("%H:%M:%S"), m)
print(line, flush=True)
with open(LOG, "a") as f:
f.write(line + "\n")
class H(http.server.BaseHTTPRequestHandler):
def _handle(self, method):
log("%s %s from %s" % (method, self.path, self.client_address))
for k, v in self.headers.items():
log(" %s: %s" % (k, v))
body = ROSTER_XML
self.send_response(200)
self.send_header("Content-Type", "application/xml")
self.send_header("Content-Length", str(len(body)))
self.send_header("Connection", "close")
self.end_headers()
if method == "GET":
self.wfile.write(body)
log(" -> 200 %dB (%r)" % (len(body), body[:60]))
def do_GET(self): self._handle("GET")
def do_HEAD(self): self._handle("HEAD")
def do_POST(self):
n = int(self.headers.get("Content-Length", 0) or 0)
if n:
log(" POST body: %r" % self.rfile.read(n)[:200])
self._handle("POST")
def log_message(self, *a): # silence default stderr logging
pass
def main():
open(LOG, "a").close()
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
ctx.load_cert_chain(CERT, KEY)
ctx.minimum_version = ssl.TLSVersion.TLSv1
try:
ctx.set_ciphers("ALL:@SECLEVEL=0")
except Exception:
pass
httpd = http.server.HTTPServer(ADDR, H)
httpd.socket = ctx.wrap_socket(httpd.socket, server_side=True)
log("=== roster_server https://%s:%d (FUT roster-update) ===" % ADDR)
httpd.serve_forever()
if __name__ == "__main__":
main()
@@ -0,0 +1,290 @@
#!/usr/bin/env python3
"""SBC menu render probe + minimal gate-arm poke (FIFA 17 CardsDLL).
WHAT THIS DOES
--------------
READ-ONLY BY DEFAULT. With no flags it opens /proc/<pid>/mem O_RDONLY, proves the
CardsDLL slide against the on-disk FNV prologue, and reports the exact live state of
the SBC data flow so the human can see whether a poke would render anything:
A = FUT root singleton = *(0x1802e6398) (vtable static 0x18021c2a0)
B = SBC request/TTL cache = A + 0x1f9d8 (vtable static 0x1801fae70)
B+0x08 collection ptr, B+0x20 QPC deadline, B+0x28 READY byte (the gate)
B offset 0x1f9d8 is DECODED live from A.vtable[+0x4e8] thunk
(48 8d 81 <disp32> = lea rax,[rcx+disp32]), not taken on faith.
M = SBC categories store = *(A + 0x20a68) (THE RENDER SOURCE)
lazy getter A.vtable[+0x9b0] = 0x18011b7d0; category count = WORD[M+0x50];
category vector M+0x58..M+0x60 (stride 0xf0). The SBC menu draws
WORD[M+0x50] + 2 tiles. M is NULL until the menu is opened (lazily built,
empty offline) or the sbs/sets response is parsed.
HUB = sibling cache = A + 0x1fd70
SBC req-mgr = A + 0x2a0
THE VERIFIED GATE (proven byte-exact against the shipped DLL, isValid 0x180065d40):
call 0x1801642c0 ; online sub-check -- STUBBED `mov al,1; ret`, not the wall
cmp BYTE[rbx+0x28],0 ; je fail ; <-- the READY gate
cmp QWORD[rbx+0x8],0 ; je RET_1 ; <-- SHORT-CIRCUIT: coll==0 => return 1
<QPC deadline compare> ; only reached when B+0x08 != 0
So isValid returns TRUE with B+0x28=1 AND B+0x08=0 (short-circuit). Writing B+0x08
forces the deadline branch; with a stale/past B+0x20 that returns 0 -> the error
modal. That is why this tool NEVER writes B+0x08 or B+0x20 -- doing so can DEFEAT
the fix and is a crash risk if the pointer is not a real EASTL collection.
THE INTERVENTION THIS TOOL CAN APPLY (--apply)
----------------------------------------------
The ONLY write blessed by adversarial verification as non-crashing from a bare
/proc/mem poke is:
BYTE[B+0x28] = 1 (arm the SBC ready gate; leave B+0x08 and B+0x20 alone)
This OPENS the SBC menu (isValid short-circuits to true) instead of the error modal.
It renders EMPTY (2 placeholder tiles) unless M is populated, because tiles come from
WORD[M+0x50], not from B. It is the proven-safe NEGATIVE CONTROL / gate-open step.
WHY A POPULATED MENU NEEDS THE INJECTED DLL, NOT THIS TOOL
----------------------------------------------------------
Populating M means running the client's OWN parser (deser 0x18017b2b0) over a real
sbs/sets response, so it clears+builds M with the correct 0xf0/0x3570 geometry and
rebuilds the indices. That requires executing code IN-PROCESS (the openfut-hook DLL)
or serving GET ut/game/fifa17/sbs/sets through the bridge so the native completion
path populates M and arms B for you. A /proc/mem byte poke cannot build M's nested
EASTL vectors safely (hand-building 0xf0/0x3570 records is the highest-crash option
all three verifiers rejected), and it cannot call the deser with a seated SAX cursor.
Cold-calling the deser with a null cursor WIPES M (clear runs before append) and
parses nothing. So: this tool arms the gate; the DLL (spec printed by --spec) does
the populate. See docs and the openfut-hook integration notes.
RISK / SAFETY
-------------
* Default run = READ ONLY. Nothing here writes unless you pass --apply.
* --apply WRITES LIVE GAME MEMORY (/proc/<pid>/mem O_WRONLY): one byte, B+0x28=1.
Do this only on a client sitting in the FUT hub, ideally with the SBC menu CLOSED
(never mutate while the menu is mid-iterate). Then re-open the SBC menu to render.
* --apply re-proves the slide AND re-verifies B.vtable == static 0x1801fae70 before
writing, and aborts on any mismatch. It refuses to write anything but B+0x28.
* If FIFA17.exe is not running or CardsDLL is not mapped, the tool says so and exits
0 -- static analysis is authoritative; live steps are best-effort.
USAGE
python3 sbc_hook_poke.py # read-only probe + dry-run plan (default)
python3 sbc_hook_poke.py --spec # also print the injected-DLL populate spec
python3 sbc_hook_poke.py --apply # WRITE BYTE[B+0x28]=1 (arm gate) -- HUMAN ONLY
"""
import os, struct, sys
# ---- static VAs (image base 0x180000000; add live slide) --------------------
A_SINGLETON = 0x1802e6398 # slot holding A = FUT root singleton ptr
CTRL_VA = 0x180180d00 # FNV atom-hash prologue used to prove the slide
A_VT_STATIC = 0x18021c2a0 # A.vtable (verify live == this + slide)
B_VT_STATIC = 0x1801fae70 # B.vtable (verify live == this + slide)
A_VT_BGETTER = 0x4e8 # A.vtable slot -> thunk lea rax,[rcx+0x1f9d8]
A_VT_MGETTER = 0x9b0 # A.vtable slot -> M lazy getter 0x18011b7d0
M_CACHE_OFF = 0x20a68 # M cache slot on A (decoded from getter cmp)
HUB_OFF = 0x1fd70
REQMGR_OFF = 0x2a0
ISVALID_VA = 0x180065d40
ONLINE_STUB = 0x1801642c0 # expect b0 01 c3 (mov al,1; ret)
B_READY_OFF = 0x28
PE_PATHS = ['/tmp/fut/cardsdll.dll', '/mnt/games/FIFA 17/CardsDLL_Win64_retail.dll']
def find_pid():
for d in os.listdir('/proc'):
if d.isdigit():
try:
if open('/proc/%s/comm' % d).read().strip() == 'FIFA17.exe':
return int(d)
except Exception:
pass
return None
def load_pe():
for p in PE_PATHS:
try:
return open(p, 'rb').read()
except Exception:
continue
return None
def print_spec():
print("""
== INJECTED-DLL POPULATE SPEC (openfut-hook / version.dll) ===================
The poke tool arms the gate; the DLL must POPULATE M. Preferred, lowest-risk,
zero-forged-state path (run ON THE GAME MAIN/UI THREAD, SBC menu CLOSED):
Option 1 (best) -- serve the response, let the native chain do everything:
Route GET ut/game/fifa17/sbs/sets through the bridge/core with real JSON.
The client's own dispatcher builds the response-msg (ctor 0x18017b1c0,
deser slot +0x20 = 0x18017b2b0), seats a genuine SAX cursor, and its own
chain populates M and arms B via the completion callback 0x1800b8c30
(subscribed in svc ctor 0x1800b5765). No memory forging at all. NOTE: the
front-end refuses to ISSUE the fetch offline and the "ut/%s/sbs" template
(0x18021d908) has no native xref, so the DLL must inject the RESPONSE at the
message-receive layer (not rely on the client to send the GET).
Option 2 (fallback) -- drive the real parser from the hook:
1. reg = 0x1800d7170() ; -> &registry 0x1802c2988
2. mgr = 0x180009c80(&out, reg) ; hashes 0xed84b11/0xed84b12
3. build a REAL seated SAX cursor over canned sbs/sets JSON:
ctx = 0x1801c63e0(...) + lexer 0x1801c8060 + an input-source object
whose vtable[+0x8] yields your JSON bytes. A null-source cursor parses
nothing AND the deser clears M first -> do not cold-call with null.
4. 0x18017b2b0(rcx=ignored, rdx=cursor) ; self-locates mgr, clears M,
per-cat ctor 0x180159da0 / cat-deser 0x18017ab80 / finalize 0x180160e50 /
append 0x18015a770, then store finalizers 0x180160e00 + 0x180160f30 +
0x180161020, then commit mgr.vtable[+0x8]. Sets WORD[M+0x50]=N.
5. arm gate: A.vtable[+0x4e8](A) -> B; set ONLY BYTE[B+0x28]=1.
Do NOT write B+0x08 or B+0x20 (short-circuit; see isValid proof).
6. trigger render: re-open the SBC menu, or fire refresh events 0x756c-0x7574
so the controller re-reads WORD[M+0x50] at 0x1800b5eda.
DO NOT: hand-build 0xf0 category / 0x3570 set records for a direct append
(deep-copy ctor 0x18015a2b0 derefs inner EASTL sub-vectors -> heap corruption);
skip the index-rebuild finalizers (by-index getter 0x180160a80 reads OOB);
mutate M while the menu iterates; or run any of this off the main thread.
=============================================================================
""")
def main():
apply = '--apply' in sys.argv
if '--spec' in sys.argv:
print_spec()
pid = find_pid()
if not pid:
print("FIFA17.exe not running -> skipping live steps. Static analysis is "
"authoritative; no write possible. (see --spec for the DLL plan)")
return 0
print("pid %d" % pid)
base = None
for ln in open('/proc/%d/maps' % pid):
if 'CardsDLL' in ln:
base = int(ln.split('-')[0], 16)
break
if not base:
print("CardsDLL not mapped (client not in Ultimate Team yet). Skip live step.")
return 0
slide = base - 0x180000000
print("base %#x slide %#x" % (base, slide))
fdr = os.open('/proc/%d/mem' % pid, os.O_RDONLY)
rd = lambda va, n: os.pread(fdr, n, va)
q = lambda va: struct.unpack('<Q', rd(va, 8))[0]
w = lambda va: struct.unpack('<H', rd(va, 2))[0]
# ---- prove the slide against the on-disk FNV prologue --------------------
pe = load_pe()
if pe is None:
print("on-disk DLL not found (%s); cannot prove slide -> refuse." % PE_PATHS)
os.close(fdr); return 1
f = lambda va: va - 0x180000000 - 0x1000 + 0x400 # .text rva 0x1000 rawptr 0x400
ctl_ok = pe[f(CTRL_VA):f(CTRL_VA)+24] == rd(CTRL_VA + slide, 24)
print("CONTROL FNV %s" % ("MATCH" if ctl_ok else "MISMATCH -> ABORT"))
if not ctl_ok:
os.close(fdr); return 1
# ---- prove the two gate facts from on-disk bytes ------------------------
online_stub = pe[f(ONLINE_STUB):f(ONLINE_STUB)+3]
print("online sub-check 0x1801642c0 on-disk = %s %s"
% (online_stub.hex(), "(stubbed mov al,1;ret -- NOT the wall)"
if online_stub == b'\xb0\x01\xc3' else "(UNEXPECTED)"))
# ---- A root + vtable ----------------------------------------------------
A = q(A_SINGLETON + slide)
A_vt = q(A) - slide
print("A(FUT root) = %#x A.vtable %#x %s"
% (A, A_vt, "(match)" if A_vt == A_VT_STATIC else "(MISMATCH static %#x)" % A_VT_STATIC))
# ---- decode B offset live from A.vtable[+0x4e8] thunk -------------------
bthunk = q((A_vt + slide) + A_VT_BGETTER) # A.vtable slot -> thunk VA (live)
stub = rd(bthunk, 7)
b_off = None
if stub[:3] == b'\x48\x8d\x81': # lea rax,[rcx+disp32]
b_off = struct.unpack('<i', stub[3:7])[0]
print("A.vtable[+0x4e8] -> %#x stub=%s decoded B offset=%s"
% (bthunk - slide, stub.hex(),
hex(b_off) if b_off is not None else "?? (expected 0x1f9d8)"))
if b_off is None:
b_off = 0x1f9d8 # fall back to the model constant, but we warned above
B = A + b_off
# ---- B cache fields -----------------------------------------------------
def show_cache(name, C, expect_vt=None):
vt = q(C) - slide
coll = q(C + 0x08); dl = q(C + 0x20); ready = rd(C + B_READY_OFF, 1)[0]
tag = ""
if expect_vt is not None:
tag = "(match)" if vt == expect_vt else "(MISMATCH static %#x)" % expect_vt
print(" %-4s @%#x vt=%#x %s coll(+8)=%#x deadline(+0x20)=%#x ready(+0x28)=%d"
% (name, C, vt, tag, coll, dl, ready))
return vt, coll, dl, ready
print("live cache state:")
b_vt, b_coll, b_dl, b_ready = show_cache("SBC", B, B_VT_STATIC)
show_cache("HUB", A + HUB_OFF)
print(" reqmgr @%#x +0x08=%#x" % (A + REQMGR_OFF, q(A + REQMGR_OFF + 0x08)))
# ---- M = the render source ---------------------------------------------
M = q(A + M_CACHE_OFF)
if M == 0:
print(" M (render source, *(A+0x20a68)) = 0 -> NOT built yet "
"(SBC menu not opened this session). Empty offline.")
cat_count = 0
else:
cat_count = w(M + 0x50)
print(" M (render source) = %#x WORD[M+0x50] category count = %d "
"(menu would draw %d tiles)" % (M, cat_count, cat_count + 2))
# ---- the plan / dry-run -------------------------------------------------
print("\n-- INTERVENTION PLAN --")
print(" Verified-safe write (this tool, --apply): BYTE @ %#x (B+0x28) = 1"
% (B + B_READY_OFF))
print(" effect: isValid short-circuits TRUE -> SBC menu OPENS instead of modal.")
print(" render: EMPTY unless M is populated (tiles = WORD[M+0x50], not B).")
print(" REFUSED here (footgun): writing B+0x08 or B+0x20 -> deadline branch,")
print(" can return FALSE (modal) and/or crash on a bogus collection ptr.")
print(" Populated render: needs the injected DLL to fill M (run with --spec).")
if not apply:
cur = rd(B + B_READY_OFF, 1)[0]
print("\n[DRY-RUN] default mode -- no memory written. current BYTE[%#x]=%d, "
"would set =1. Pass --apply to write (HUMAN ONLY)."
% (B + B_READY_OFF, cur))
os.close(fdr)
return 0
# ---- --apply: the single blessed byte write -----------------------------
# re-verify EVERYTHING load-bearing before touching live memory.
if not ctl_ok or A_vt != A_VT_STATIC or b_vt != B_VT_STATIC:
print("\n[ABORT] slide/vtable sanity failed at write time -> refusing to write.")
os.close(fdr); return 1
if b_off != 0x1f9d8:
print("\n[ABORT] B offset decoded as %s (expected 0x1f9d8) -> refusing to write."
% hex(b_off))
os.close(fdr); return 1
target = B + B_READY_OFF
before = rd(target, 1)[0]
print("\n[APPLY] target BYTE @ %#x before=%d" % (target, before))
if before == 1:
print("[APPLY] already 1 -> nothing to do (idempotent).")
os.close(fdr); return 0
fdw = os.open('/proc/%d/mem' % pid, os.O_WRONLY)
n = os.pwrite(fdw, b'\x01', target)
os.close(fdw)
after = rd(target, 1)[0]
print("[APPLY] wrote %d byte(s); read-back BYTE @ %#x = %d %s"
% (n, target, after, "(OK)" if after == 1 else "(WRITE FAILED)"))
print("[APPLY] now RE-OPEN the SBC menu. Expect: menu opens (no modal); tiles will")
print(" be EMPTY/placeholder unless M was populated by the DLL first.")
os.close(fdr)
return 0
if __name__ == '__main__':
sys.exit(main())
@@ -0,0 +1,105 @@
#!/usr/bin/env python3
"""SBC cache populate/arm probe + poke (FIFA 17 CardsDLL).
READ-ONLY BY DEFAULT. The write path exists for the human's morning test but is
NEVER reached unless you pass --arm AND --i-mean-it. Running with no args only
READS /proc/<pid>/mem (O_RDONLY) and prints what a poke WOULD do.
Object graph (all static VAs, image base 0x180000000; add the live slide):
A = FUT root singleton = *(0x1802e6398) (getter 0x18011a830)
B = SBC TTL cache = A + 0x1f9d8 (vtable 0x1801fae70)
B+0x08 collection ptr, B+0x20 QPC deadline, B+0x28 ready byte
isValid = 0x180065d40 (B.vtable[+0x08]); clear = 0x180065d20 (B.vtable[+0x10])
HUB TTL cache = A + 0x1fd70 (same class, armed online)
SBC set-data req mgr = A + 0x2a0 (vtable 0x18022be90, ctor 0x18016fac0)
Populate path (normal, online):
fetch sbs/sets -> req mgr A+0x2a0 -> response obj (factory 0x18016fca0, vt 0x18022be80)
-> SAX drive 0x18016c330 -> top deser 0x18017b2b0
(which does service.[+0x9b0] to get the SBC manager, then)
category deser 0x18017ab80 / set deser 0x18017ad60
-> finalizers 0x180160e00 / 0x180160e50 / 0x180161020 build the SBC manager's
category array (stride 0xf0) + set array (stride 0x3570); select/rebuild 0x180160b80
-> generic cache commit copy-assigns a stack temp {collection, deadline, ready=1}
into B (assign 0x1800c21a0), arming B+0x28 and pointing B+0x08 at the manager data.
The UI renders by polling isValid(B) each frame and iterating *(B+0x08). Arming
B+0x28 alone (see --arm-flag-only) opens the menu but draws EMPTY (collection NULL).
A populated render needs *(B+0x08) to point at a real set/category collection.
"""
import os, struct, sys
SLIDE_KNOWN = 0x6ffe7c140000 # informational; actual slide is read from maps
A_SINGLETON = 0x1802e6398
B_OFF = 0x1f9d8
HUB_OFF = 0x1fd70
REQMGR_OFF = 0x2a0
CTRL_VA = 0x180180d00
def find_pid():
for d in os.listdir('/proc'):
if d.isdigit():
try:
if open('/proc/%s/comm' % d).read().strip() == 'FIFA17.exe':
return int(d)
except Exception:
pass
return None
def main():
arm = '--arm' in sys.argv
flag_only = '--arm-flag-only' in sys.argv
confirm = '--i-mean-it' in sys.argv
pid = find_pid()
if not pid:
print("FIFA17.exe not running -> nothing to read. (static analysis is authoritative)")
return
base = None
for ln in open('/proc/%d/maps' % pid):
if 'CardsDLL' in ln:
base = int(ln.split('-')[0], 16); break
if not base:
print("CardsDLL not mapped yet (client not in Ultimate Team). Skip live step.")
return
slide = base - 0x180000000
fdr = os.open('/proc/%d/mem' % pid, os.O_RDONLY)
rd = lambda va, n: os.pread(fdr, n, va)
q = lambda va: struct.unpack('<Q', rd(va, 8))[0]
# prove slide against on-disk FNV prologue
pe = open('/mnt/games/FIFA 17/CardsDLL_Win64_retail.dll', 'rb').read()
f = lambda va: va - 0x180000000 - 0x1000 + 0x400
ok = pe[f(CTRL_VA):f(CTRL_VA)+24] == rd(CTRL_VA + slide, 24)
print("pid %d base %#x slide %#x CONTROL %s" % (pid, base, slide, "OK" if ok else "MISMATCH-ABORT"))
if not ok:
os.close(fdr); return
A = q(A_SINGLETON + slide)
B = A + B_OFF
HUB = A + HUB_OFF
print("A(FUT root)=%#x B(SBC cache)=%#x HUB=%#x reqmgr=%#x" % (A, B, HUB, A + REQMGR_OFF))
def show(name, C):
coll = q(C + 0x08); dl = q(C + 0x20); ready = rd(C + 0x28, 1)[0]
vt = q(C) - slide
print(" %-4s vt=%#x coll(+8)=%#x deadline(+0x20)=%#x ready(+0x28)=%d"
% (name, vt, coll, dl, ready))
return coll, dl, ready
print("live cache state:")
show("SBC", B); show("HUB", HUB)
# what a poke WOULD do
print("\n-- INTERVENTION PLAN (dry-run) --")
print(" [flag-only] write BYTE @ %#x = 1 (opens menu, EMPTY render)" % (B + 0x28))
print(" [real fix] preferred = force the client to issue sbs/sets so its own")
print(" parser populates the SBC manager and commits B. The offline")
print(" block is the FUT front-end refusing to call the native fetch;")
print(" route the issued GET ut/game/fifa17/sbs/sets through the bridge.")
if flag_only and arm and confirm:
# guarded, explicit, single-byte only
fdw = os.open('/proc/%d/mem' % pid, os.O_WRONLY)
os.pwrite(fdw, b'\x01', B + 0x28)
os.close(fdw)
print("\n[WROTE] BYTE @ %#x = 1 (flag-only). Expect menu opens, likely empty." % (B + 0x28))
elif arm:
print("\n[SAFE] --arm given but not both --arm-flag-only and --i-mean-it; no write performed.")
os.close(fdr)
if __name__ == '__main__':
main()
@@ -0,0 +1,83 @@
#!/usr/bin/env python3
"""Force the FIFA 17 FUT store OPEN by patching CardsDLL's online-readiness gate.
The "store is not available" screen is gated by FIFA's online-mode manager
(FUT::CompetitionManager). Three CardsDLL methods report store-enabled and only
return true once that manager reaches the online "service ready" state -- an
online/Blaze wall we can't reach offline. This pokes them to return true.
Reversible: originals are saved to /tmp/orig_<va>.bin; `restore` puts them back;
a FIFA restart also clears the patch (live-memory only, per session).
Needs ptrace_scope=0 (tools/root_arm.sh) and FIFA running. Run as YOUR action:
!python3 tools/store_enable_poke.py # patch (open the store)
!python3 tools/store_enable_poke.py restore # undo
Static gate methods (CardsDLL image base 0x180000000), patched to `mov eax,1; ret`:
0x1800f7fb0 IS_EASTORE_SERVICE_READY (mgr[0x6d4]==0x1fbd0)
0x1800fb850 IS_STORE_ENABLED (online-mode state stack non-empty)
0x180100500 IS_COIN_PURCHASABLE (commerce config)
"""
import sys, glob, os
IMG_BASE = 0x180000000
RET_TRUE = bytes.fromhex("b801000000c3") # mov eax,1 ; ret
NOP2 = bytes.fromhex("9090")
PATCHES = {
0x1800f7fb0: ("IS_EASTORE_SERVICE_READY", RET_TRUE),
0x1800fb850: ("IS_STORE_ENABLED", RET_TRUE),
0x180100500: ("IS_COIN_PURCHASABLE", RET_TRUE),
0x180013cf0: ("IS_STORE_AVAILABLE", RET_TRUE),
0x180017543: ("JMP_BYPASS_RESOLUTION", bytes.fromhex("eb3f")),
0x180017487: ("NOP_JE_STORE_AVAILABLE", NOP2),
0x180017490: ("NOP_JNE_STORE_CACHED", NOP2),
0x1800175aa: ("NOP_JE_STORE_ENTITLEMENT", NOP2),
}
DLL_MATCH = "CardsDLL"
def find_pid():
for d in glob.glob('/proc/[0-9]*'):
try:
if open(d + '/comm').read().strip() == 'FIFA17.exe':
return int(d.split('/')[-1])
except Exception:
pass
raise SystemExit("FIFA17.exe not running")
def cardsdll_base(pid):
for line in open(f'/proc/{pid}/maps'):
if DLL_MATCH in line:
return int(line.split('-')[0], 16) # lowest mapping = module base
raise SystemExit("CardsDLL not mapped in FIFA process")
def main():
restore = len(sys.argv) > 1 and sys.argv[1] == "restore"
pid = find_pid()
base = cardsdll_base(pid)
print(f"FIFA pid={pid} CardsDLL base={base:#x} ({'RESTORE' if restore else 'PATCH'})")
mem = f'/proc/{pid}/mem'
for va, (name, patch_bytes) in PATCHES.items():
live = base + (va - IMG_BASE)
origf = f'/tmp/orig_{live:x}.bin'
if restore:
if not os.path.exists(origf):
print(f" {name}: no saved original, skip"); continue
data = open(origf, 'rb').read()
else:
with open(mem, 'rb') as f:
f.seek(live); orig = f.read(len(patch_bytes))
open(origf, 'wb').write(orig)
data = patch_bytes
with open(mem, 'r+b') as f:
f.seek(live); f.write(data)
f.seek(live); chk = f.read(len(data))
print(f" {name:26s} @ {live:#x} -> {chk.hex()}")
print("Done." + ("" if restore else " Now open the FUT Store in-game."))
if __name__ == "__main__":
main()
@@ -0,0 +1,88 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Remove club cards whose playerid does not exist in FIFA 17's roster.
These are leftovers from the invented-id pool that data/roster.json replaced. The
client resolves them through its own players table, misses, and stamps the generic
card: rating 50, teamid 1933, nation 14, position 2, all attributes 1, blank name.
They are the blanks on screen.
SAFETY
* backs the profile up first, and prints the backup path;
* refuses to touch any card referenced by a saved squad, so a squad slot can
never end up pointing at a deleted item;
* removes from the club pile only, and never from `purchased` in the same pass:
a card present in BOTH piles is the known fatal desync, and the way to avoid
it is to keep every card in exactly one place, which deleting from one pile
preserves.
* dry run unless --fire is passed.
"""
import argparse
import json
import shutil
import sys
import time
sys.path.insert(0, "/home/alex/Documents/OpenFUT/fifa17-recon/tools")
from fut_store import STORE # noqa: E402
import fut_cards # noqa: E402
ap = argparse.ArgumentParser()
ap.add_argument("--fire", action="store_true", help="actually write")
a = ap.parse_args()
REAL = {p[0] for p in fut_cards.POOL}
p = STORE.load()
items = p.get("items", [])
# Every item id any saved squad refers to. Squad shapes have varied, so walk the
# structure generically rather than assuming one layout.
squad_ids = set()
def walk(o):
if isinstance(o, dict):
for k, v in o.items():
if k in ("itemId", "id") and isinstance(v, int):
squad_ids.add(v)
walk(v)
elif isinstance(o, list):
for v in o:
walk(v)
for key in ("squad", "squads", "squadList"):
walk(p.get(key))
dead = [c for c in items
if c.get("assetId") not in REAL and c.get("id") not in squad_ids]
protected = [c for c in items
if c.get("assetId") not in REAL and c.get("id") in squad_ids]
print("club=%d roster=%d" % (len(items), len(REAL)))
print("dead cards to remove: %d" % len(dead))
for c in dead:
print(" id=%-11s asset=%-8s rating=%s" % (c.get("id"), c.get("assetId"), c.get("rating")))
if protected:
print("KEPT (referenced by a squad, removing them would break a slot): %d" % len(protected))
for c in protected:
print(" id=%-11s asset=%s" % (c.get("id"), c.get("assetId")))
if not a.fire:
print("\ndry run. Re-run with --fire to write.")
sys.exit(0)
if not dead:
print("\nnothing to do.")
sys.exit(0)
bak = STORE.path + ".bak-stripdead-%d" % int(time.time())
shutil.copy(STORE.path, bak)
print("\nbackup: %s" % bak)
drop = {c.get("id") for c in dead}
p["items"] = [c for c in items if c.get("id") not in drop]
STORE._save()
q = STORE.load()
print("club: %d -> %d purchased=%d coins=%s"
% (len(items), len(q.get("items", [])), len(q.get("purchased", [])), q.get("coins")))
@@ -0,0 +1,180 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Accumulate FUT_ID_SWEEP results into a real player database.
READ-ONLY against the game (it drives card_identity_probe, which only reads
/proc/PID/mem). It writes exactly one file: data/players.json in this repo.
HOW THE ORACLE WORKS, AND ITS ONE FALSE ANSWER
----------------------------------------------
utas_server's FUT_ID_SWEEP serves a window of candidate playerids as a synthetic
club. The client merges its OWN local players table into every item it parses, so
after one club fetch the resolved identity is sitting in the CardsDb map.
A candidate is REAL when the client gives it a name. The false answer to guard
against is the DB's default row: ids with no entry come back named "Jamal
Blackman", byte-identical every time (first "Jamal", last "Blackman"). Six of our
own pool ids hit this, including one that was in VERIFIED_ASSET_IDS -- which is
why the old "verified" list cannot be trusted and this tool exists.
So DEFAULT_NAME below is a rejection filter, not a curiosity. If a genuine Jamal
Blackman is ever needed, take him from his real id, not from this sweep.
Usage:
sweep_collect.py probe now, merge into data/players.json
sweep_collect.py --show summarise the accumulated database
"""
import argparse
import json
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import card_identity_probe as P # noqa: E402
import watch_club_model as W # noqa: E402
DB = os.path.join(os.path.dirname(os.path.abspath(__file__)),
"..", "data", "players.json")
DEFAULT_NAME = ("Jamal", "Blackman") # the DB's empty-row placeholder
SENTINEL_RATING = 7 # what utas_server serves in a sweep
def load():
try:
with open(DB) as f:
return {int(k): v for k, v in json.load(f).items()}
except (IOError, ValueError):
return {}
def save(db):
os.makedirs(os.path.dirname(DB), exist_ok=True)
with open(DB, "w") as f:
json.dump({str(k): v for k, v in sorted(db.items())}, f,
indent=1, ensure_ascii=False)
def probe():
pid = W.find_pid()
if pid is None:
print("FIFA17.exe is not running.")
return None
base = W.dll_base(pid)
mem = W.Mem(pid)
obj = mem.q(base + (W.G_CARDSDB - W.IMG_BASE)) if base else None
if not obj:
print("CardsDb is not loaded (no FUT session).")
return None
cards = [c for c in (P.read_card(mem, n) for n in P.nodes(mem, obj)) if c]
print("read %d card record(s), failed reads=%d" % (len(cards), mem.fails))
return cards
def merge(db, cards):
added = skipped = placeholder = 0
for c in cards:
first, last = c["first"], c["last"]
if (first, last) == DEFAULT_NAME:
placeholder += 1
continue
if not (first or last or c["known"]):
skipped += 1
continue
pid = c["playerid"]
row = {
"first": first, "last": last, "known": c["known"],
"teamid": c["teamid"], "nation": c["nation"], "league": c["league"],
"position": c["position"],
}
# A sweep card carries our sentinel rating, so the DB never learns a rating
# from it. Ratings from OWNED cards are ours too -- the merge never
# overwrites a nonzero rating. Rating therefore stays out of this file
# rather than being recorded as if the game had supplied it.
if pid not in db:
added += 1
db[pid] = row
return added, skipped, placeholder
def show(db):
print("%d player(s) in %s" % (len(db), os.path.normpath(DB)))
if not db:
return
ids = sorted(db)
print("id range %d..%d" % (ids[0], ids[-1]))
teams = len({r["teamid"] for r in db.values() if r["teamid"]})
nats = len({r["nation"] for r in db.values() if r["nation"]})
lgs = len({r["league"] for r in db.values() if r["league"]})
print("%d club(s), %d nation(s), %d league(s)" % (teams, nats, lgs))
for pid in ids[:10]:
r = db[pid]
print(" %-7s %-28s team=%-6s nat=%-4s league=%s"
% (pid, ("%s %s" % (r["first"], r["last"])).strip()[:28],
r["teamid"], r["nation"], r["league"]))
def watch(db, interval, seconds):
"""Merge continuously while the game pages through an auto sweep.
THE MAP DOES NOT ACCUMULATE. Every club fetch wipes it and repopulates from
that response alone, so a single probe at the end of an auto sweep sees only
the LAST chunk -- which is how 34,000 candidates were nearly thrown away. A
pass costs 0.03s and chunks are ~12s apart, so polling catches all of them.
"""
import time
t0 = time.time()
last = -1
while time.time() - t0 < seconds:
cards = probe_quiet()
if cards:
added, _, _ = merge(db, cards)
if added:
save(db)
if len(db) != last:
last = len(db)
print("[%4ds] %d player(s)" % (time.time() - t0, len(db)), flush=True)
time.sleep(interval)
save(db)
return 0
def probe_quiet():
pid = W.find_pid()
if pid is None:
return None
base = W.dll_base(pid)
if not base:
return None
mem = W.Mem(pid)
obj = mem.q(base + (W.G_CARDSDB - W.IMG_BASE))
if not obj:
return None
return [c for c in (P.read_card(mem, n) for n in P.nodes(mem, obj)) if c]
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--show", action="store_true")
ap.add_argument("--watch", type=float, metavar="SECS",
help="poll continuously for SECS while an auto sweep runs")
ap.add_argument("--interval", type=float, default=1.0)
a = ap.parse_args()
db = load()
if a.show:
show(db)
return 0
if a.watch:
return watch(db, a.interval, a.watch)
cards = probe()
if cards is None:
return 1
added, skipped, placeholder = merge(db, cards)
save(db)
print("added %d new, %d placeholder row(s) rejected, %d unnamed skipped"
% (added, placeholder, skipped))
show(db)
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -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())
@@ -0,0 +1,282 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Offline regression tests for the non-player card families.
WHY THIS IS A SEPARATE SUITE. tools/test_fut_contract.py talks to a LIVE server over
HTTP and imports nothing from the server's own code -- that is what lets it certify a
future non-Python implementation of the same reversed spec. These checks are the
opposite kind: they are unit tests of the item BUILDERS, they must run against the
working tree rather than against whatever process happens to be listening on 8099, and
they must not require the live client to be restarted. Mixing them into the contract
suite would have broken both properties.
Run: python3 tools/test_card_families.py (exit 0 = all pass, stdlib only)
"""
import os, sys
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
_fail = []
_pass = 0
def check(name, cond, detail=""):
global _pass
if cond:
_pass += 1
else:
_fail.append("%s %s" % (name, detail))
def raises(name, fn, *a, **kw):
try:
fn(*a, **kw)
except Exception:
check(name, True)
return
check(name, False, "did NOT raise")
# ---------------------------------------------------------------- the rareflag trap
def test_item_rareflag_trap():
"""fut_store._item() must not stamp rareflag 1 on a Player Fitness card.
FUN_1801bfac0 case 5 takes the SQUAD-fitness branch when
`(cardsubtypeid == 0xdc) || (*(int *)(rec + 0x58) == 1)`, and rec+0x58 IS the
rareflag atom 0x271. So a rare 219 renders as a Squad Fitness card -- a silent
corruption of the first fitness card we would ever serve.
"""
import fut_store
d = fut_store._item(100000001, 20801, 94, "LW", 38, 53, 243, [90, 93, 82, 91, 33, 80])
# 1. THE PLAYER DICT IS BYTE-IDENTICAL TO BEFORE THE FIX. Key order included:
# every existing caller passes 8 positional args, so both new keyword params
# take their defaults.
check("player item key ORDER unchanged",
list(d) == ["id", "resourceId", "assetId", "cardassetid", "definitionId",
"cardsubtypeid", "itemType", "rareflag", "rating",
"preferredPosition", "nation", "teamid", "leagueId", "playStyle",
"attributeList", "itemState", "owners", "untradeable",
"contract", "fitness"], repr(list(d)))
check("player item VALUES unchanged",
d == {"id": 100000001, "resourceId": 20801, "assetId": 20801,
"cardassetid": 20801, "definitionId": 20801, "cardsubtypeid": 0,
"itemType": "player", "rareflag": 1, "rating": 94,
"preferredPosition": "LW", "nation": 38, "teamid": 243, "leagueId": 53,
"playStyle": 250,
"attributeList": [{"index": i, "value": v} for i, v in
enumerate([90, 93, 82, 91, 33, 80])],
"itemState": "free", "owners": 1, "untradeable": True,
"contract": 7, "fitness": 99}, repr(d))
# 2. THE GUARD ITSELF. This is the assertion that fails without the fix.
f = fut_store._item(1, 5002001, 55, "LW", 0, 0, 0, [0] * 6,
cardsubtypeid=219, rareflag=1)
check("subtype 219 forced to rareflag 0 (the squad-fitness trap)",
f["rareflag"] == 0, repr(f["rareflag"]))
check("subtype 219 keeps its cardsubtypeid", f["cardsubtypeid"] == 219,
repr(f["cardsubtypeid"]))
# 3. THE GUARD IS SCOPED. It must not touch any neighbouring subtype.
for sub in (218, 220, 4, 5, 0):
g = fut_store._item(1, 1, 50, "LW", 0, 0, 0, [0] * 6,
cardsubtypeid=sub, rareflag=1)
check("subtype %d keeps rareflag 1 (guard is 219-only)" % sub,
g["rareflag"] == 1, repr(g["rareflag"]))
# ------------------------------------------------------------------- consumables
def test_consumables():
import fut_consumables as fc
check("172 cardtype-6 subtypes", len(fc.SUBTYPES) == 172, len(fc.SUBTYPES))
dead = [r for r in fc.SUBTYPES if r["kind"] == "DEAD_ZONE"]
check("28 dead zones", len(dead) == 28, len(dead))
check("dead zones are exactly the known set",
sorted(r["cardsubtypeid"] for r in dead) ==
[58, 59, 60, 68, 69, 70, 87, 88, 89, 90, 111, 112, 113, 114, 115, 116, 117,
118, 119, 120, 203, 204, 205, 206, 207, 208, 209, 210])
# A dead-zone card renders as a plausible Squad Training (Pace) card with amount
# 0 -- there is no DB-Error analogue -- so the builder must refuse it outright.
raises("consumable_item refuses a dead zone (89)", fc.consumable_item, 1, 89)
raises("consumable_item refuses a non-cardtype-6 subtype", fc.consumable_item, 1, 4)
# Omitting `amount` stamps (byte)-1 into rec+0xbf, and FUN_1801a8040 sign-extends,
# so the card reads "-1" rather than 0. Refuse rather than ship that.
raises("training without amount is refused", fc.consumable_item, 1, 61)
raises("healing without amount is refused", fc.consumable_item, 1, 211)
raises("contract without contract= is refused", fc.consumable_item, 1, 201)
raises("rare Player Fitness (219) is refused", fc.consumable_item, 1, 219,
amount=20, rareflag=1)
# 220 is ALWAYS squad fitness (0xdc is the first half of the branch test), so a
# rare one is merely rare, not corrupted.
check("rare Squad Fitness (220) is allowed",
fc.consumable_item(1, 220, amount=10, rareflag=1)["rareflag"] == 1)
shelf = fc.starter_consumables(fc.CONSUMABLE_ID_BASE)
check("starter shelf is non-empty", len(shelf) > 0, len(shelf))
check("no dead zone on the shelf",
not [i for i in shelf
if fc.BY_SUBTYPE[i["cardsubtypeid"]]["kind"] == "DEAD_ZONE"])
check("shelf ids are unique", len({i["id"] for i in shelf}) == len(shelf))
check("shelf ids are clear of the save's 1e8 space",
all(i["id"] >= fc.CONSUMABLE_ID_BASE for i in shelf))
check("no 219 on the shelf is rare",
all(i["rareflag"] == 0 for i in shelf if i["cardsubtypeid"] == 219))
for i in shelf:
r = fc.BY_SUBTYPE[i["cardsubtypeid"]]
if "amount" in r["needs"]:
check("subtype %d carries amount" % i["cardsubtypeid"], "amount" in i)
if "contract" in r["needs"]:
check("subtype %d carries contract" % i["cardsubtypeid"], "contract" in i)
# Player-only fields must never appear: rec+0x146 and rec+0x98.. survive and
# are read by the generic view-model.
check("subtype %d sends no player-only fields" % i["cardsubtypeid"],
not ({"preferredPosition", "attributeList", "nation", "leagueId",
"teamid", "playStyle", "fitness"} & set(i)), repr(sorted(i)))
# The excluded families, each for a named reason (see CORE_KINDS).
kinds = {fc.BY_SUBTYPE[i["cardsubtypeid"]]["kind"] for i in shelf}
for banned in ("manager_formation_mod", "formation_mod", "manager_league"):
check("%s is NOT shipped" % banned, banned not in kinds)
# ?type= routing
check("type=contract -> only categories 2/3",
{fc.BY_SUBTYPE[i["cardsubtypeid"]]["category"]
for i in fc.items_for_type("contract")} <= {2, 3})
check("type=training -> only category 0",
{fc.BY_SUBTYPE[i["cardsubtypeid"]]["category"]
for i in fc.items_for_type("training")} == {0})
check("an unknown type gets nothing", fc.items_for_type("player") == [])
check("def_for resolves an EA carddbid", fc.def_for(5001001) is not None)
check("def_for returns None for a player asset", fc.def_for(20801) is None)
# ----------------------------------------------------------------- coach families
def test_coaches():
import fut_coaches as cc
check("four coach families", len(cc.FAMILIES) == 4)
for fam, (sub, ct, table, miss) in cc.FAMILIES.items():
rows = cc.COACHES[fam]
check("%s row count" % fam,
len(rows) == {"headcoach": 124, "gkcoach": 121, "physio": 51,
"fitnesscoach": 115}[fam], len(rows))
# The oracle: rating 0x32 can ONLY ever be a miss, in every family.
check("%s has no row with value 50 (the miss-fill rating)" % fam,
not [r for r in rows if r["rating"] == 50])
check("%s ids are all < 2^24 (the key is a raw u32 but the artwork masks)" % fam,
all(r["carddbid"] < (1 << 24) for r in rows))
check("%s carddbids are unique" % fam,
len({r["carddbid"] for r in rows}) == len(rows))
# fitness coach's miss triple must not exist as a real row, or its second oracle
# would be ambiguous.
check("no fitnesscoach row is (fieldpos 1, posbonus 7, amount 1)",
not [r for r in cc.COACHES["fitnesscoach"]
if (r["fieldpos"], r["posbonus"], r["amount"]) == (1, 7, 1)])
# tier() is the binary's own tail, not a convention.
check("tier boundaries", (cc.tier(64), cc.tier(65), cc.tier(74), cc.tier(75))
== (1, 2, 2, 3))
raises("coach_item refuses a non-coach subtype", cc.coach_item, 1, 4, 1000509)
for miss in cc.MISS_FILL_IDS:
raises("coach_item refuses miss-fill assetid %d" % miss,
cc.coach_item, 1, 5, miss)
seeds = cc.starter_coaches(cc.COACH_ID_BASE)
check("24 starter coaches (six per family)", len(seeds) == 24, len(seeds))
check("starter ids unique", len({i["id"] for i in seeds}) == len(seeds))
for i in seeds:
check("starter coach %d is a real row" % i["resourceId"],
(i["cardsubtypeid"], i["resourceId"]) in cc.BY_ID)
check("starter coach %d has a non-zero id (no id -> NO merge at all)"
% i["resourceId"], i["id"] != 0)
check("coach %d sends no invented nation/league/team" % i["resourceId"],
not ({"nation", "leagueId", "teamid", "preferredPosition",
"attributeList", "rating", "rareflag", "assetId"} & set(i)),
repr(sorted(i)))
subs = {i["cardsubtypeid"] for i in seeds}
check("all four families represented", subs == {5, 6, 7, 8}, repr(sorted(subs)))
# ?type= routing: each family's own arm serves only that family; staff (arm 10)
# serves all four.
for fam, (sub, _ct, _tbl, _miss) in cc.FAMILIES.items():
got = cc.items_for_type(fam)
check("type=%s serves only subtype %d" % (fam, sub),
got and {i["cardsubtypeid"] for i in got} == {sub},
repr({i["cardsubtypeid"] for i in got}))
check("type=staff serves all four families",
{i["cardsubtypeid"] for i in cc.items_for_type("staff")} == {5, 6, 7, 8})
check("an unknown type gets nothing", cc.items_for_type("player") == [])
# Ids must not shift depending on which arm asked, or the same card would enter
# the client's CardsDb map twice under two handles.
check("item ids are stable across arms",
{i["resourceId"]: i["id"] for i in cc.items_for_type("staff")} ==
{i["resourceId"]: i["id"]
for fam in cc.FAMILIES for i in cc.items_for_type(fam)})
# --------------------------------------------------------------------- managers
def test_managers():
import fut_staff as fs
check("417 manager cards", len(fs.MANAGERS) == 417, len(fs.MANAGERS))
check("carddbid == assetid band 1000001..1001552",
(fs.MANAGERS[0]["carddbid"], fs.MANAGERS[-1]["carddbid"]) == (1000001, 1001552))
# The `manager` join: 747 rows but 746 distinct managerids -- managerid 107 is
# duplicated with an empty-name row, which used to win the dict comprehension.
check("297 managers carry a real name (the 107 duplicate resolved)",
sum(1 for m in fs.MANAGERS if m["name"]) == 297,
sum(1 for m in fs.MANAGERS if m["name"]))
check("carddbid 1000107 keeps Slutskiy, not the empty row",
fs.BY_ID[1000107]["name"] and fs.BY_ID[1000107]["teamid"] == 315,
repr(fs.BY_ID[1000107]))
check("ten starter managers", len(fs.STARTER_MANAGERS) == 10)
for c in fs.STARTER_MANAGERS:
m = fs.BY_ID[c]
check("starter manager %d has a name" % c, bool(m["name"]))
check("starter manager %d has nation/league/team" % c,
m["nation"] and m["leagueId"] and m["teamid"])
it = fs.manager_item(1, 1000509)
check("manager resourceId is the RAW carddbid (the merge does not mask)",
it["resourceId"] == 1000509)
check("manager subtype 4", it["cardsubtypeid"] == 4)
check("manager sends nation/leagueId (rec+0xde/+0xe0 are OURS alone)",
it["nation"] == 45 and it["leagueId"] == 53, repr(it))
check("manager sends no rating/rareflag/position/attrs (all overwritten or read)",
not ({"rating", "rareflag", "preferredPosition", "attributeList",
"assetId", "definitionId"} & set(it)), repr(sorted(it)))
# ------------------------------------------------------- overlay id-space hygiene
def test_id_spaces():
"""The four id spaces must not overlap: a collision would make two different cards
share an item id, and the client keys its CardsDb map on it."""
import fut_consumables as fc, fut_coaches as cc, fut_staff as fs
spaces = {
"save": (100000000, 100999999),
"consumable": (fc.CONSUMABLE_ID_BASE, fc.CONSUMABLE_ID_BASE + 999999),
"coach": (cc.COACH_ID_BASE, cc.COACH_ID_BASE + 999999),
"manager": (fs.OVERLAY_ID_BASE, fs.OVERLAY_ID_BASE + 999999),
"probe": (fs.PROBE_ID_BASE, fs.PROBE_ID_BASE + 999999),
"sweep": (900000000, 900999999),
}
names = sorted(spaces)
for i, a in enumerate(names):
for b in names[i + 1:]:
lo1, hi1 = spaces[a]
lo2, hi2 = spaces[b]
check("%s and %s id spaces are disjoint" % (a, b),
hi1 < lo2 or hi2 < lo1, "%r %r" % (spaces[a], spaces[b]))
def main():
for t in (test_item_rareflag_trap, test_consumables, test_coaches, test_managers,
test_id_spaces):
try:
t()
except Exception as e:
_fail.append("%s raised %s: %s" % (t.__name__, type(e).__name__, e))
print("\n%d checks passed, %d failed" % (_pass, len(_fail)))
for f in _fail:
print(" FAIL:", f)
return 0 if not _fail else 1
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,547 @@
#!/usr/bin/env python3
"""Contract / freeze-safety regression tests for the FIFA 17 FUT backend.
Hits the LIVE utas_server (default http://127.0.0.1:8099) and asserts each
response matches the shape reversed from CardsDLL (see docs/ENDPOINT_MAP.md).
The point is freeze-safety: FIFA's SAX deserializers hard-freeze (busy-loop at
0x1801c7f1a) if a field that must be an array/object arrives as a scalar. These
tests encode "must be array" / "must be object" / "must be number" per the
reversed schemas so a future edit that reintroduces that class of bug fails here
instead of freezing the game.
MOSTLY read-only: every check but one uses GET, so no pack is bought and no squad
is written. THE ONE EXCEPTION is test_club_rename_roundtrip, which PUTs a club
name to exercise the rename endpoint and RESTORES the original in a finally block.
(The docstring used to promise strictly read-only; that promise is now this
paragraph instead of a lie.)
Run: python3 tools/test_fut_contract.py
Exit 0 = all pass. No pytest dependency (stdlib only).
"""
import json, os, sys, urllib.error, urllib.request
BASE = os.environ.get("FUT_TEST_BASE", "http://127.0.0.1:8099")
G = "/ut/game/fifa17"
V2 = "/ut/v2/game/fifa17"
# IMPLEMENTATION-INDEPENDENT BY CONSTRUCTION.
# This suite talks to a server at a URL and imports NOTHING from the server's own
# code. That is what lets it verify ANY implementation of the reversed spec -- a
# future Rust openfut-core included -- without replaying the reverse engineering.
#
# It used to do `from fut_account import ACCOUNT` for the persona, which was a
# Python import against the Python implementation and quietly made the suite
# unable to certify a non-Python server. The expected persona now comes from the
# environment, defaulting to the value every layer has agreed on all along.
#
# The original reason for reading ACCOUNT still stands and is preserved: the suite
# and the server must not each hold their own copy of the constant, or the
# "identity is consistent" checks would only prove that two copies were copied
# correctly. Point FUT_TEST_PERSONA_ID at whatever the server under test is
# configured with; the default matches the shipped default.
PERSONA_ID = int(os.environ.get("FUT_TEST_PERSONA_ID", "33068179"))
_fail = []
_pass = 0
def _get(path):
with urllib.request.urlopen(BASE + path, timeout=5) as r:
raw = r.read()
return json.loads(raw) if raw else {}
def _req(method, path, body=None):
"""Returns (status, parsed-body). Never raises on 4xx/5xx -- the status itself
is a thing under test (FUT's rule is NEVER 4xx; see club_rename_route)."""
data = json.dumps(body).encode() if body is not None else None
rq = urllib.request.Request(BASE + path, data=data, method=method,
headers={"Content-Type": "application/json"})
try:
with urllib.request.urlopen(rq, timeout=5) as r:
raw, code = r.read(), r.status
except urllib.error.HTTPError as e:
raw, code = e.read(), e.code
try:
return code, (json.loads(raw) if raw else {})
except ValueError:
return code, None # unparseable body -> caller fails the check
def check(name, cond, detail=""):
global _pass
if cond:
_pass += 1
else:
_fail.append(f"{name}: {detail}")
def is_arr(x): return isinstance(x, list)
def is_obj(x): return isinstance(x, dict)
def is_num(x): return isinstance(x, (int, float)) and not isinstance(x, bool)
def is_str(x): return isinstance(x, str)
def test_credits():
d = _get(G + "/user/credits")
# coin counter binds to currencies[name==coins].funds (deser 0x180122c50), NOT "credits"
check("credits.currencies is array", is_arr(d.get("currencies")), repr(d.get("currencies")))
coins = next((c for c in d.get("currencies", []) if c.get("name") == "coins"), None)
check("credits has coins currency", coins is not None)
if coins:
check("coins.funds is number", is_num(coins.get("funds")), repr(coins.get("funds")))
def test_v2_store_gate():
d = _get(V2 + "/store")
# FutStorePackQuantities eligibility gate (deser 0x1801758c0): result must be SUCCESS
check("v2/store result == SUCCESS", d.get("result") == "SUCCESS", repr(d))
def test_store_catalog():
d = _get(G + "/store/purchasegroup/all")
# FutStoreGetPackTypes (deser 0x1801234e0): root key "purchase" MUST be an array
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.packContentInfo is object", is_obj(p.get("packContentInfo")))
# 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():
# Shared IS-list body (deser 0x18013e7f0): auctionInfo MUST be array, credits number
for path in [G + "/auctionhouse?type=player&start=0&num=21",
G + "/tradePile", G + "/watchList", G + "/trade/123"]:
d = _get(path)
check(f"{path} auctionInfo is array", is_arr(d.get("auctionInfo")), repr(d.get("auctionInfo")))
check(f"{path} credits is number", is_num(d.get("credits")), repr(d.get("credits")))
dup = _get(G + "/auctionhouse?type=player").get("duplicateItemIdList")
check("auctionhouse duplicateItemIdList is array", is_arr(dup), repr(dup))
md = _get(G + "/marketdata?defId=1")
check("marketdata minPrice is number", is_num(md.get("minPrice")))
check("marketdata maxPrice is number", is_num(md.get("maxPrice")))
def test_auction_record_shape():
# Every populated auction record MUST match the reversed schema (deser
# 0x18013e410) field-for-field, or the market screen freezes. This proves the
# sample listings are freeze-safe OFFLINE, before the game ever parses them.
d = _get(G + "/auctionhouse?type=player&start=0&num=21")
recs = d.get("auctionInfo", [])
check("auctionhouse returns >=1 listing (or FUT_MARKET=empty)", is_arr(recs))
numeric = ["tradeId", "buyNowPrice", "startingBid", "currentBid", "expires",
"sellerEstablished", "coinsProcessed"]
strings = ["tradeState", "bidState", "sellerName"]
for r in recs:
check("record.itemData is object", is_obj(r.get("itemData")), repr(type(r.get("itemData"))))
check("record.watched is bool", isinstance(r.get("watched"), bool), repr(r.get("watched")))
for k in numeric:
check(f"record.{k} is number", is_num(r.get(k)), repr(r.get(k)))
for k in strings:
check(f"record.{k} is string", is_str(r.get(k)), repr(r.get(k)))
# itemData must itself be a valid card object (reuses club/squad parser)
it = r.get("itemData", {})
check("record.itemData.attributeList is array", is_arr(it.get("attributeList")))
check("record.itemData.resourceId is number", is_num(it.get("resourceId")))
def test_squad_boot():
# LoadActiveSquad (deser 0x18013d1f0): players MUST be array; empty body would reset
# the 23 slots. formation is a string. This is the boot-critical path.
d = _get(G + "/squad/0")
check("squad.players is array", is_arr(d.get("players")), repr(type(d.get("players"))))
check("squad.formation is string", is_str(d.get("formation")), repr(d.get("formation")))
for pl in d.get("players", []):
# Empty bench/reserve slots legitimately carry itemData=null (proven-safe,
# matches the working in-game squad). Only a PRESENT itemData must be an
# object -- a scalar there would desync the reader.
it = pl.get("itemData")
check("squad slot itemData is object-or-null", it is None or is_obj(it), repr(it))
def test_squad_list_shape():
# userInfo.squadList (atom 0x2d4) goes through FUN_180142260 -- the same parser as
# the FutSquadList response -- so it must be an OBJECT with a "squad" ARRAY, never
# a bare array. Each element (0x180141fc0): rating/chemistry/id INT,
# formation/squadName/squadType STRING (string getter 0x1801c7aa0 + enum conv).
d = _get(G + "/user")
ui = d.get("userInfo", {})
check("user.userInfo is object", is_obj(ui), repr(type(ui)))
# Every STRING atom 0x18013ec10 consumes must be present and non-empty: a null
# string pointer in this record is what the 2026-08-03 create-club crash read.
for k in ("clubName", "clubAbbr", "established", "accountCreatedPlatformName"):
check(f"userInfo.{k} is non-empty string", is_str(ui.get(k)) and ui.get(k), repr(ui.get(k)))
# actives is optional (omitted by FUT_USERINFO=min); when present it must be an
# array of at most 5 item refs (0x18013ec10 stops storing past index 4).
act = ui.get("actives")
check("userInfo.actives absent or array", act is None or is_arr(act), repr(act))
check("userInfo.actives <= 5", len(act or []) <= 5)
# coins/record are what the hub renders: currencies elements are read by
# FUN_180138bd0 as name/funds/finalFunds/active -- "value" is NOT a key it knows.
coins = next((c for c in ui.get("currencies", []) if c.get("name") == "coins"), None)
check("userInfo has coins currency", coins is not None, repr(ui.get("currencies")))
if coins:
check("userInfo coins.funds is number", is_num(coins.get("funds")), repr(coins))
check("userInfo coins.finalFunds is number", is_num(coins.get("finalFunds")), repr(coins))
check("userInfo coins uses funds not value", "value" not in coins, repr(coins))
for k in ("won", "draw", "loss"):
check(f"userInfo.{k} is number", is_num(ui.get(k)), repr(ui.get(k)))
# REGRESSION GUARD, KEPT (not deleted -- the recon evidence does NOT show the
# new rename endpoint makes this safe; it shows the opposite: the crash chain
# runs entirely inside FIFA17.exe and never reaches our response).
# clubNameChangeAllowed=true is the isolated root cause of the 2026-08-03
# create-club crash (identical field set, only this bool flipped, 4/4 crash vs
# no crash). It may be absent, but it must never be true UNLESS the operator
# deliberately opted in with FUT_CLUB_RENAME=1 -- i.e. the guard now asserts
# the SAFE DEFAULT rather than blocking the opt-in experiment.
# The opt-in is keyed on a DISTINCT, test-only variable, NOT on FUT_CLUB_RENAME.
# Keying it on the same var the server reads means one exported FUT_CLUB_RENAME=1
# arms the crashing config AND silently disables the check that would catch it --
# the guard has to fail loudly in exactly that case, which is the whole point of
# having it. So: assert the safe default unless a human explicitly says "I am
# testing the rename experiment right now".
if os.environ.get("FUT_TEST_ALLOW_RENAME") == "1":
check("clubNameChangeAllowed is true under FUT_CLUB_RENAME=1",
ui.get("clubNameChangeAllowed") is True, repr(ui.get("clubNameChangeAllowed")))
else:
check("clubNameChangeAllowed is not true (default)",
ui.get("clubNameChangeAllowed") is not True, repr(ui.get("clubNameChangeAllowed")))
# squadList is OPTIONAL (FUT_USERINFO ladder) -- but if present it must be an
# object with a squad array, never a bare array.
sl = ui.get("squadList")
check("userInfo.squadList absent or object", sl is None or is_obj(sl), repr(sl))
if is_obj(sl):
check("squadList.squad is array", is_arr(sl.get("squad")), repr(sl.get("squad")))
for e in sl.get("squad", []):
check("squadList elem is object", is_obj(e), repr(e))
if not is_obj(e):
continue
for k in ("rating", "chemistry", "id"):
check(f"squadList.{k} is number", is_num(e.get(k)), repr(e.get(k)))
for k in ("formation", "squadName", "squadType"):
check(f"squadList.{k} is string", is_str(e.get(k)), repr(e.get(k)))
def test_squad_list_endpoint():
# GET ut/%s/squad/list is the real FutSquadList URL (live-observed 2026-08-03).
# Its parser 0x180142260 recognises ONLY squad(0x2cd), so the body MUST be
# {"squad":[...]}; returning the active-squad object here yields "MY SQUADS: 0".
d = _get(G + "/squad/list")
check("squad/list is object", is_obj(d), repr(type(d)))
check("squad/list has squad array", is_arr(d.get("squad")), repr(d)[:120])
check("squad/list is NOT the active-squad object", "players" not in d, repr(list(d)))
for e in d.get("squad", []):
for k in ("rating", "chemistry", "id"):
check(f"squad/list elem {k} is number", is_num(e.get(k)), repr(e.get(k)))
for k in ("formation", "squadName", "squadType"):
check(f"squad/list elem {k} is string", is_str(e.get(k)), repr(e.get(k)))
def test_massinfo_shape():
# GetUserMassInfo (deser 0x180174630) is a FLAT object -- no "user" wrapper.
# Populated as of 2026-08-03 (see FUT_RESPONSE_REBUILD_PLAN.md S7): userInfo,
# squad, settings, userData. Every member must keep its reversed type or the
# SAX reader desyncs -> busy-loop freeze at 0x1801c7f1a.
d = _get(G + "/userMassInfo")
check("massinfo is object", is_obj(d), repr(type(d)))
check("massinfo has no 'user' wrapper", "user" not in d, repr(list(d)))
if not d:
return # FUT_MASSINFO=empty bisect mode
for k in ("userInfo", "squad", "settings", "userData"):
if k in d:
check(f"massinfo.{k} is object", is_obj(d[k]), repr(type(d.get(k))))
sq = d.get("squad")
if is_obj(sq):
# squad(0x2cd) -> LoadActiveSquad parser 0x18013d1f0, same schema as GET /squad
check("massinfo.squad.players is array", is_arr(sq.get("players")), repr(type(sq.get("players"))))
check("massinfo.squad.formation is string", is_str(sq.get("formation")), repr(sq.get("formation")))
check("massinfo.squad.squadType is string", is_str(sq.get("squadType")), repr(sq.get("squadType")))
check("massinfo.squad.custom is string", is_str(sq.get("custom")), repr(type(sq.get("custom"))))
check("massinfo.squad.actives is array", is_arr(sq.get("actives")), repr(type(sq.get("actives"))))
check("massinfo.squad.manager is array", is_arr(sq.get("manager")), repr(type(sq.get("manager"))))
check("massinfo.squad.kicktakers is array", is_arr(sq.get("kicktakers")), repr(type(sq.get("kicktakers"))))
# personaId MUST equal the logged-in persona (0x18014659c) or SquadLoad
# discards our squad and builds a throwaway one.
check("massinfo.squad.personaId == PERSONA_ID", sq.get("personaId") == PERSONA_ID, repr(sq.get("personaId")))
if is_obj(d.get("settings")):
check("massinfo.settings.configs is array", is_arr(d["settings"].get("configs")),
repr(type(d["settings"].get("configs"))))
def test_club_items():
# /club serves {"itemData":[...]} (SKIP'd by GetClubInfo, but must stay array-safe)
d = _get(G + "/club?type=player&count=5")
check("club.itemData is array", is_arr(d.get("itemData")), repr(type(d.get("itemData"))))
def test_identity_consistency():
"""personaId must be IDENTICAL everywhere it is asserted.
This is the single check that would have caught any drift the old
seven-copies-of-a-literal layout could produce. The squad parser 0x18013d1f0
compares squad.personaId against the logged-in persona at 0x18014659c and, on
mismatch, silently builds a THROWAWAY squad (same comparison in 0x1801464e0
for summaries) -- so drift does not error, it just quietly loses your squad.
The merge FUN_18011e7c0 likewise matches clubUser records to club records on
personaId, so a mismatch there silently loses the gamertag.
"""
seen = {}
seen["userInfo.personaId"] = _get(G + "/user").get("userInfo", {}).get("personaId")
mi = _get(G + "/userMassInfo")
if "userInfo" in mi:
seen["massinfo.userInfo.personaId"] = mi["userInfo"].get("personaId")
if "squad" in mi:
seen["massinfo.squad.personaId"] = mi["squad"].get("personaId")
seen["squad.personaId"] = _get(G + "/squad/0").get("personaId")
cu = _get(G + "/clubUser").get("user") or []
if cu:
seen["clubUser.personaId"] = cu[0].get("personaId")
ul = _get(G + "/user/list?personaIdList=%d" % PERSONA_ID).get("user") or []
if ul:
seen["user/list.personaId"] = ul[0].get("personaId")
for where, v in seen.items():
check("%s == ACCOUNT.persona_id" % where, v == PERSONA_ID,
"%r != %r" % (v, PERSONA_ID))
check("personaId asserted in >=4 places", len(seen) >= 4, repr(sorted(seen)))
def test_club_user_shape():
"""GET /clubUser -- FutGetClubUsers (deser 0x180145c00), key `user`(0x36c).
REGRESSION THIS PINS: /clubUser used to be swallowed by the generic /club
route and answered {"itemData":[...]}, which GetClubUsers SKIPs entirely --
so the club-user (gamertag) model was empty by construction. Assert we are
NOT serving the itemData body.
"""
d = _get(G + "/clubUser")
check("clubUser is object", is_obj(d), repr(type(d)))
if d == {}:
return # FUT_CLUB_IDENTITY=off bisect rung
check("clubUser is NOT the itemData body", "itemData" not in d, repr(list(d)))
users = d.get("user")
check("clubUser.user is array", is_arr(users), repr(users))
for e in users or []:
check("clubUser elem is object", is_obj(e), repr(e))
if not is_obj(e):
continue
# persona(0x21a) STRING, bounded copy FUN_180008120(dst,s,0x21) -> 32 chars
p = e.get("persona")
check("clubUser.persona is non-empty string", is_str(p) and p, repr(p))
check("clubUser.persona <= 32 chars", is_str(p) and len(p) <= 32, repr(p))
check("clubUser.personaId is number", is_num(e.get("personaId")), repr(e.get("personaId")))
check("clubUser.public is bool", isinstance(e.get("public"), bool), repr(e.get("public")))
def test_club_info_shape():
"""GET /user/list -- club-identity records.
established MUST be a STRING of digits: userInfo deser 0x18013ec10 case 0x110
uses the STRING getter then strtol base 10. squadList(0x2d4) must be ABSENT or
an object with a squad array -- a bare array/scalar there goes to FUN_180142260
and is the 0x1801c7f1a busy-loop class.
"""
d = _get(G + "/user/list?personaIdList=%d" % PERSONA_ID)
check("user/list is object", is_obj(d), repr(type(d)))
if d == {}:
return # FUT_CLUB_IDENTITY=off bisect rung
users = d.get("user")
check("user/list.user is array", is_arr(users), repr(users))
for e in users or []:
check("user/list elem is object", is_obj(e), repr(e))
if not is_obj(e):
continue
check("user/list.personaId is number", is_num(e.get("personaId")), repr(e.get("personaId")))
for k in ("clubName", "clubAbbr"):
check(f"user/list.{k} is non-empty string", is_str(e.get(k)) and e.get(k), repr(e.get(k)))
est = e.get("established")
check("user/list.established is string", is_str(est), repr(est))
check("user/list.established is digits", is_str(est) and est.isdigit(), repr(est))
sl = e.get("squadList")
check("user/list.squadList absent or object", sl is None or is_obj(sl), repr(sl))
if is_obj(sl):
check("user/list.squadList.squad is array", is_arr(sl.get("squad")), repr(sl))
def test_accountinfo_shape():
# GET /user/accountinfo: {} by default and that is DELIBERATE -- its parser
# (FutGetUserAccountInfoServerCallConfig) is inside the Denuvo-packed
# FIFA17.exe and cannot be reversed, so key TYPES are unknown and any invented
# container is a freeze candidate. Under FUT_ACCOUNTINFO=1 every value must
# still be a scalar; nothing here may be an array or object.
d = _get(G + "/user/accountinfo")
check("accountinfo is object", is_obj(d), repr(type(d)))
for k, v in (d or {}).items():
check(f"accountinfo.{k} is scalar (no guessed containers)",
not isinstance(v, (list, dict)), repr(v))
def test_club_rename_roundtrip():
"""PUT the ChangeClubName endpoint(s) and prove the name persists.
THE ONLY MUTATING TEST IN THIS FILE -- it restores the original club in a
finally block.
FutChangeClubNameServerResponse has ZERO atoms (vtable 0x18022cb58 slot +0x08
= 0x1801642c0, body `return 1`), so the response body is fully ignored and {}
is complete. What is actually under test:
* HTTP 200, NEVER 4xx -- CardsDLL's failure reporter FUN_18016cca0 skips the
'R4ER: DISCONNECTED' telemetry path only while status==200, so answering
4xx is how a rejected name becomes a disconnect.
* the new name is reflected in userInfo (write-back parity with the client's
own FUN_1800829c0 -> rec+0x20 / rec+0x3e).
* BOTH competing URL derivations are routed (ENDPOINT_MAP row 3 says PUT
ut/%s/club; the recon says ut/%s/user + "/club" suffix appender 0x18014c740).
* an over-long abbr is REJECTED, not echoed: the client's write-back buffer
at userInfo+0x3e is 4 bytes -> FUN_180007f80(dst,4,"%s",abbr).
"""
orig = _get(G + "/user").get("userInfo", {})
o_name, o_abbr = orig.get("clubName"), orig.get("clubAbbr")
check("rename precondition: original club readable",
is_str(o_name) and is_str(o_abbr), repr((o_name, o_abbr)))
if not (is_str(o_name) and is_str(o_abbr)):
return
try:
for path in (G + "/user/club", G + "/club"):
code, body = _req("PUT", path, {"clubName": "TestClub", "clubAbbr": "TST"})
check(f"PUT {path} -> 200 (never 4xx)", code == 200, repr(code))
check(f"PUT {path} body is parseable object", is_obj(body), repr(body))
ui = _get(G + "/user").get("userInfo", {})
check(f"PUT {path} applied clubName", ui.get("clubName") == "TestClub", repr(ui.get("clubName")))
check(f"PUT {path} applied clubAbbr", ui.get("clubAbbr") == "TST", repr(ui.get("clubAbbr")))
# user/list must follow the same source of truth, or the merge
# FUN_18011e7c0 would show a stale club next to a fresh one.
ul = (_get(G + "/user/list?personaIdList=%d" % PERSONA_ID).get("user") or [{}])[0]
if ul:
check(f"PUT {path} reflected in user/list",
ul.get("clubName") in ("TestClub", None), repr(ul.get("clubName")))
# restore between the two URLs so each is tested from a known state
_req("PUT", G + "/user/club", {"clubName": o_name, "clubAbbr": o_abbr})
# over-long abbr: rejected (4 bytes incl. NUL at userInfo+0x3e), never echoed
code, _ = _req("PUT", G + "/user/club", {"clubName": "BadAbbrClub", "clubAbbr": "TOOLONG"})
check("over-long abbr still answers 200", code == 200, repr(code))
ui = _get(G + "/user").get("userInfo", {})
check("over-long abbr not echoed", ui.get("clubAbbr") != "TOOLONG", repr(ui.get("clubAbbr")))
check("over-long abbr <= 3 chars", len(ui.get("clubAbbr") or "") <= 3, repr(ui.get("clubAbbr")))
# too-short name (view-model FUN_180082c30 name_min_length=5) likewise
_req("PUT", G + "/user/club", {"clubName": "Ab", "clubAbbr": "AB"})
ui = _get(G + "/user").get("userInfo", {})
check("too-short name rejected", ui.get("clubName") != "Ab", repr(ui.get("clubName")))
finally:
_req("PUT", G + "/user/club", {"clubName": o_name, "clubAbbr": o_abbr})
ui = _get(G + "/user").get("userInfo", {})
check("original club restored", (ui.get("clubName"), ui.get("clubAbbr")) == (o_name, o_abbr),
repr((ui.get("clubName"), ui.get("clubAbbr"))))
def test_move_verdict_shape():
"""PUT /item must return per-item VERDICT records. This guards the fix for the
project's longest-lived bug.
FutMoveCard's deserializer does not parse an acknowledgement, it builds a vector
of verdict records, and the completion handler raises
EVENT_CARDS_MOVE_CARD_FAILURE when that vector is EMPTY or when a record's
success byte (record+0x0c) is not 1. For seven attempts this endpoint answered
{}, an echo of the moved cards, or a dreamSquads stub, and every one of them told
the client the move had failed, so the client killed the FUT session. It looked
like a client-state bug for weeks because the HTTP status was always 200.
That failure mode is silent at the transport layer, which is exactly why it needs
a contract test: nothing else in this suite would notice a regression to {}.
NON-MUTATING. It asks to move ids that cannot exist, so no item changes pile. The
verdicts therefore come back success=false, which is the honest answer and is not
what is being asserted here. What is asserted is the SHAPE and the RECORD COUNT,
because an empty vector fails the client just as hard as a wrong flag.
"""
ghosts = [{"id": 999000001, "pile": "club", "swap": 0, "tradeId": 0},
{"id": 999000002, "pile": "club", "swap": 0, "tradeId": 0}]
code, body = _req("PUT", G + "/item", {"itemData": ghosts})
check("PUT /item -> 200", code == 200, repr(code))
check("PUT /item body is an object", is_obj(body), repr(body))
recs = body.get("itemData") if is_obj(body) else None
check("PUT /item returns itemData array (NOT {} -- {} reads as move-failed)",
is_arr(recs), repr(body)[:120])
if not is_arr(recs):
return
check("PUT /item returns one record per requested item",
len(recs) == len(ghosts), "%d records for %d items" % (len(recs), len(ghosts)))
for i, r in enumerate(recs):
check("record[%d] is an object" % i, is_obj(r), repr(r))
if not is_obj(r):
continue
# id(0x15c) INT via 0x1801c79d0 -- a string here desyncs the reader
check("record[%d].id is a number" % i, is_num(r.get("id")), repr(r.get("id")))
# pile(0x226) STRING via 0x1801c7aa0 -> enum 0x180142650
check("record[%d].pile is a string" % i, is_str(r.get("pile")), repr(r.get("pile")))
# success(0x2fa) BOOL via 0x1801c7620 -> record+0x0c
check("record[%d].success is a bool" % i, isinstance(r.get("success"), bool),
repr(r.get("success")))
def test_hub_counters():
"""GET /hub must carry the tile counters as INTEGERS.
clubPlayers (atom 0x90) is the MY CLUB tile's big number: the hub body parser
FUN_180139610 is the ONLY writer of the field it lands in (R+0x3c, read by
FUN_1800b0250 as TEXT0 of TILE_ID 0x210). auctionCount (0x33) feeds the TRANSFERS
tile the same way via R+0x38.
This route answered {} for the life of the project, which is why the tile read 0,
and a day was spent looking at /club/stats instead. A regression to {} would be
silent: still 200, still valid JSON, tile quietly back to zero.
Both are read with the INT getter 0x1801c79d0, so a string here would desync.
"""
d = _get(G + "/hub")
check("hub body is an object", is_obj(d), repr(d))
if not is_obj(d):
return
check("hub.clubPlayers present (the MY CLUB tile counter)", "clubPlayers" in d, repr(sorted(d)))
check("hub.clubPlayers is a number, not a string", is_num(d.get("clubPlayers")),
repr(d.get("clubPlayers")))
check("hub.auctionCount is a number", is_num(d.get("auctionCount")),
repr(d.get("auctionCount")))
# the clamp FUN_1800d7b30 turns <=0 into 0, so a negative would silently read as 0
if is_num(d.get("clubPlayers")):
check("hub.clubPlayers is not negative (clamped to 0 by the client)",
d["clubPlayers"] >= 0, repr(d["clubPlayers"]))
def main():
tests = [test_credits, test_v2_store_gate, test_store_catalog, test_market_bodies,
test_auction_record_shape, test_squad_boot, test_squad_list_shape,
test_squad_list_endpoint, test_massinfo_shape, test_club_items,
test_identity_consistency, test_club_user_shape, test_club_info_shape,
test_accountinfo_shape, test_club_rename_roundtrip,
test_move_verdict_shape, test_hub_counters]
try:
_get(G + "/user/credits")
except Exception as e:
print(f"SERVER NOT REACHABLE at {BASE}: {e}\nStart it: python3 tools/utas_server.py")
return 2
for t in tests:
try:
t()
except Exception as e:
_fail.append(f"{t.__name__} raised {type(e).__name__}: {e}")
print(f"\n{_pass} checks passed, {len(_fail)} failed")
for f in _fail:
print(" FAIL:", f)
return 0 if not _fail else 1
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,92 @@
#!/usr/bin/env python3
"""Offline unit test for the transfer-market BUY flow (tools/utas_server.trade_route).
Runs entirely against a TEMP profile (FUT_PROFILE) so it never mutates the real
save. Verifies: buy-now deducts coins + grants the won card to the club + echoes
a CLOSED auction with the validated record shape; insufficient funds -> 461.
No server / no live FIFA needed. Run: python3 tools/test_market_buy.py
"""
import os, json, sys, tempfile
os.environ["FUT_PROFILE"] = os.path.join(tempfile.gettempdir(), "fut_buy_test_profile.json")
if os.path.exists(os.environ["FUT_PROFILE"]):
os.remove(os.environ["FUT_PROFILE"])
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import utas_server as u # noqa: E402
class FakeH:
def __init__(self, cmd, path, body=b""):
self.command, self.path, self._body = cmd, path, body
def main():
fails = []
def ok(name, cond, detail=""):
if not cond:
fails.append(f"{name}: {detail}")
# a listing to buy (cheapest affordable)
_, mkt = u.auctionhouse_route(FakeH("GET", "/ut/game/fifa17/auctionhouse?type=player"))
recs = sorted(mkt["auctionInfo"], key=lambda r: r["buyNowPrice"])
rec0 = recs[0]
tid, price = rec0["tradeId"], rec0["buyNowPrice"]
coins0, items0 = u.STORE.coins(), len(u.STORE.items())
ok("test setup: affordable listing exists", price <= coins0, f"price {price} coins {coins0}")
# BUY NOW
code, resp = u.trade_route(FakeH("POST", f"/ut/game/fifa17/trade/{tid}/bid",
json.dumps({"bid": price}).encode()))
r = resp.get("auctionInfo", [{}])[0]
ok("buy returns 200", code == 200, str(code))
ok("buy auction closed", r.get("tradeState") == "closed", r.get("tradeState"))
ok("buy bidState highest", r.get("bidState") == "highest", r.get("bidState"))
ok("coins deducted by buyNow", u.STORE.coins() == coins0 - price,
f"{coins0}->{u.STORE.coins()} price {price}")
ok("won card added to club", len(u.STORE.items()) == items0 + 1,
f"{items0}->{len(u.STORE.items())}")
ok("won itemData is object", isinstance(r.get("itemData"), dict))
ok("won itemData.attributeList is array", isinstance(r.get("itemData", {}).get("attributeList"), list))
ok("credits is int", isinstance(resp.get("credits"), int))
# GET view of an auction
_, v = u.trade_route(FakeH("GET", f"/ut/game/fifa17/trade/{tid}"))
ok("view auctionInfo is array", isinstance(v.get("auctionInfo"), list))
# insufficient funds -> 461, coins unchanged
u.STORE._p["coins"] = 10
u.STORE._save()
c2, _ = u.trade_route(FakeH("POST", f"/ut/game/fifa17/trade/{tid}/bid", b'{"bid":999999}'))
ok("insufficient funds -> 461", c2 == 461, str(c2))
ok("coins unchanged on failed buy", u.STORE.coins() == 10, str(u.STORE.coins()))
# ---- SELL / list flow ----
owned_id = u.STORE.items()[0]["id"]
_, s = u.auctionhouse_route(FakeH("POST", "/ut/game/fifa17/auctionhouse",
json.dumps({"itemData": {"id": owned_id}, "startingBid": 1000, "buyNowPrice": 5000}).encode()))
ltid = s.get("id")
ok("list returns a tradeId", isinstance(ltid, int), repr(s))
_, tp = u.tradepile_route(FakeH("GET", "/ut/game/fifa17/tradePile"))
ok("tradePile shows the listing", tp.get("total") == 1, repr(tp.get("total")))
if tp.get("auctionInfo"):
rec = tp["auctionInfo"][0]
ok("listing tradeId matches", rec.get("tradeId") == ltid)
ok("listing buyNowPrice preserved", rec.get("buyNowPrice") == 5000)
ok("listing itemData is object", isinstance(rec.get("itemData"), dict))
ok("listing itemData.attributeList is array", isinstance(rec.get("itemData", {}).get("attributeList"), list))
u.delete_trade_route(FakeH("DELETE", f"/ut/delete/game/fifa17/trade/{ltid}"))
_, tp2 = u.tradepile_route(FakeH("GET", "/ut/game/fifa17/tradePile"))
ok("delist empties tradePile", tp2.get("total") == 0, repr(tp2.get("total")))
os.remove(os.environ["FUT_PROFILE"])
print(f"{'PASS' if not fails else 'FAIL'} - market buy+sell flow "
f"({0 if fails else 'all'} checks; {len(fails)} failed)")
for f in fails:
print(" FAIL:", f)
return 1 if fails else 0
if __name__ == "__main__":
sys.exit(main())
@@ -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())
@@ -0,0 +1,199 @@
#!/usr/bin/env python3
"""Unit tests for the FUT match core loop — PURE, no server, no state, no profile.
Why separate from test_fut_contract.py: that suite is read-only by design (it hits
a live server and must never mutate the save), but the match loop credits coins and
bumps the W/D/L record. So the two pure pieces result detection and the reward
body are tested here instead of making the HTTP suite stateful.
Guards the two things that would silently break the loop:
* `_match_result()` mis-reading a scoreline (wrong result -> wrong payout)
* `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)
"""
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
os.environ.setdefault("FUT_PROFILE", "/tmp/openfut_unittest_profile.json")
import utas_server as U # noqa: E402
_fail = []
_pass = 0
def check(name, cond, detail=""):
global _pass
if cond:
_pass += 1
else:
_fail.append("%s: %s" % (name, detail))
# ---- _match_result: scoreline -> outcome ------------------------------------
def test_result_detection():
cases = [
({"goals": 3, "opponentGoals": 1}, "won"),
({"goals": 0, "opponentGoals": 2}, "loss"),
({"goals": 1, "opponentGoals": 1}, "draw"),
({"score": 2, "opponentScore": 0}, "won"),
({"homeGoals": 0, "awayGoals": 4}, "loss"),
({"match": {"goals": 5, "opponentGoals": 0}}, "won"), # nested
({"stats": {"score": 0, "opponentScore": 3}}, "loss"), # nested
({"result": "WIN"}, "won"),
({"outcome": "defeat"}, "loss"),
({"result": "tie"}, "draw"),
({}, "draw"), # unknown -> neutral fallback
(None, "draw"), # malformed body -> neutral fallback
({"goals": "2", "opponentGoals": 1}, "draw"), # non-int -> no guess
]
for body, expect in cases:
got, _ = U._match_result(body)
check("result %r -> %s" % (body, expect), got == expect, "got %s" % got)
# a 0-0 draw must not be mistaken for "no data"
r, s = U._match_result({"goals": 0, "opponentGoals": 0})
check("0-0 is a draw with a score", r == "draw" and s == (0, 0), "%s %s" % (r, s))
# ---- destroy_match_body: the reward record ----------------------------------
# These expectations were REWRITTEN on 2026-08-04 after reversing deser 0x180121b60
# properly. The previous version asserted a top-level `coins` and a
# `qualifiedChampionEventId`, and it passed happily while the server shipped a body
# whose reward field the client never read. A test that encodes the wrong schema is
# worse than no test: it converts a bug into a guarantee.
REQUIRED_INT = ("allCoins", "matchCoins", "seasonCoins", "tournamentCoins",
"boostConis", "participationAward")
def test_reward_body():
b = U.destroy_match_body("won", 400, 13000)
for k in REQUIRED_INT:
check("reward.%s present" % k, k in b)
check("reward.%s is int (scalar, not nested)" % k,
isinstance(b.get(k), int) and not isinstance(b.get(k), bool), repr(b.get(k)))
check("reward.teamOfTournamentWinner is bool",
isinstance(b.get("teamOfTournamentWinner"), bool), repr(b.get("teamOfTournamentWinner")))
check("allCoins is the NEW balance", b["allCoins"] == 13000, repr(b["allCoins"]))
# EA's typo is load-bearing: the atom is 96 == "boostConis", not "boostCoins".
check("key is EA's misspelled boostConis", "boostConis" in b and "boostCoins" not in b,
repr(sorted(b)))
# userData and matchCoinMultipliers stay OUT (SKIP-safe; userData is freeze-risk)
for k in ("userData", "matchCoinMultipliers"):
check("reward omits nested %s" % k, k not in b)
if U.MATCH_END:
# `coins` (atom 149) is read ONLY inside gameModeAward. A top-level one is
# silently skipped, which is what the server used to send.
check("coins is NOT top level", "coins" not in b, repr(sorted(b)))
gma = b.get("gameModeAward")
check("gameModeAward is an object", isinstance(gma, dict), repr(gma))
if isinstance(gma, dict):
check("gameModeAward.coins echoes the credited amount",
gma.get("coins") == 400, repr(gma.get("coins")))
# atom 89 is MATCHED inside gameModeAward and then handled by nothing:
# not read, not skip-routed. Its value token is left unconsumed, which is
# the precondition for the desync spin. This check is the guard rail.
check("gameModeAward NEVER contains bidTokens (freeze trap)",
"bidTokens" not in gma, repr(sorted(gma)))
for k, v in gma.items():
check("gameModeAward.%s is scalar" % k,
isinstance(v, (int, bool, str)), repr(v))
# side-effecting atom 0x269: its branch calls through a manager vtable
check("qualifiedChampionEventId omitted (it has a side effect)",
"qualifiedChampionEventId" not in b, repr(sorted(b)))
else:
check("legacy body keeps top-level coins", b.get("coins") == 400, repr(b.get("coins")))
# nothing non-scalar may sneak in at the top level except gameModeAward
for k, v in b.items():
if k == "gameModeAward":
continue
check("reward.%s is scalar" % k, isinstance(v, (int, bool, str)), repr(v))
def test_end_reason_is_authoritative():
"""endReason, not a score comparison, is how the client reports the outcome.
Reversed from the DestroyMatch serializer: atom 260, a string enum with nine
values. Both stats objects are OMITTED by the client when endReason is DNF or
QUIT, so a result parser must not require them.
"""
for reason, want in (("WIN", "won"), ("DNF_WIN", "won"),
("DRAW", "draw"), ("DNF_DRAW", "draw"), ("NO_CONTEST", "draw"),
("LOSS", "loss"), ("DNF_LOSS", "loss"),
("DNF", "loss"), ("QUIT", "loss")):
r, _ = U._match_result({"endReason": reason})
check("endReason %s -> %s" % (reason, want), r == want, r)
# score comes from goals in the two stats objects, first field of each
r, s = U._match_result({"endReason": "WIN",
"myMatchStats": {"goals": 3},
"opponentMatchStats": {"goals": 1}})
check("goals are read from myMatchStats/opponentMatchStats", s == (3, 1), repr(s))
# a DNF with no stats objects must still resolve, not crash or fall back blindly
r, s = U._match_result({"endReason": "DNF"})
check("DNF with no stats resolves to loss with no score", r == "loss" and s is None,
"%s %s" % (r, s))
# endReason must WIN over a contradictory score probe
r, _ = U._match_result({"endReason": "LOSS", "goals": 5, "opponentGoals": 0})
check("endReason beats the legacy score probe", r == "loss", r)
def test_payout_table():
for res in ("won", "draw", "loss"):
b = U.destroy_match_body(res, U.MATCH_COINS[res], 0)
check("matchCoins matches the %s payout" % res,
b["matchCoins"] == U.MATCH_COINS[res], repr(b["matchCoins"]))
check("win pays >= draw", U.MATCH_COINS["won"] >= U.MATCH_COINS["draw"])
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_match_call_classification, test_match_ready_body):
try:
t()
except Exception as e:
_fail.append("%s raised %s: %s" % (t.__name__, type(e).__name__, e))
print("\n%d checks passed, %d failed" % (_pass, len(_fail)))
for f in _fail:
print(" FAIL:", f)
return 0 if not _fail else 1
if __name__ == "__main__":
sys.exit(main())
@@ -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()
@@ -0,0 +1,58 @@
#!/usr/bin/env python3
"""Diff the atoms the userInfo deserializer (0x18013ec10) can consume against the
keys utas_server.user_info() actually sends, and flag STRING-typed fields we omit
(a NULL string pointer is exactly the crash class seen in FUN_180084f90)."""
import re, sys, os
sys.path.insert(0, "/home/alex/Documents/OpenFUT/fifa17-recon/tools")
os.environ.setdefault("FUT_PROFILE", "/tmp/claude-1000/-home-alex-Documents-OpenFUT/"
"4cf26d25-8cee-4db3-ad9e-9fd1838020eb/scratchpad/diffprof.json")
atoms = {}
for line in open("/home/alex/Documents/OpenFUT/fifa17-recon/docs/fut_atoms.tsv"):
p = line.rstrip("\n").split("\t")
if len(p) >= 3:
atoms[int(p[0])] = p[2]
src = open("/tmp/ghidra_fut/userinfo.txt").read()
# Every comparison against the key-id variable, plus switch cases.
ids = set()
for m in re.finditer(r"iVar\d+ == (0x[0-9a-f]+|\d+)", src):
ids.add(int(m.group(1), 0))
for m in re.finditer(r"case (0x[0-9a-f]+|\d+):", src):
ids.add(int(m.group(1), 0))
for m in re.finditer(r"caseD_([0-9a-f]+)", src):
ids.add(int(m.group(1), 16))
# getter used per id -> type. 0x1801c7aa0 = STRING, 0x1801c79d0 = int,
# 0x1801c7a40/0x1801c7b40 = bool/other scalars.
GET = {"1801c7aa0": "str", "1801c79d0": "int"}
typed = {}
for m in re.finditer(r"(?:iVar\d+ == |case )(0x[0-9a-f]+|\d+)\)?:?\s*\{?\s*\n((?:.*\n){0,6})", src):
try:
i = int(m.group(1), 0)
except ValueError:
continue
blk = m.group(2)
for g, t in GET.items():
if g in blk:
typed[i] = t
break
from utas_server import user_info # noqa: E402
sent = set(user_info().keys())
known = {i: atoms.get(i, "?") for i in sorted(ids) if i in atoms}
print("userInfo deser consumes %d named atoms; user_info() sends %d keys\n"
% (len(known), len(sent)))
missing = [(i, n, typed.get(i, "")) for i, n in known.items() if n not in sent]
extra = sorted(sent - set(known.values()))
print("=== parsed by the client but NOT sent by us (%d) ===" % len(missing))
for i, n, t in sorted(missing, key=lambda x: (x[2] != "str", x[1])):
print(" %-32s atom %#-6x %s" % (n, i, ("<-- STRING" if t == "str" else t)))
print("\n=== we send but the deser does not name (harmless SKIPs) ===")
print(" " + ", ".join(extra))
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,222 @@
#!/usr/bin/env python3
"""ADVERSARIAL independent validator for the PreAuthResponse TDF payload.
Deliberately re-implemented from the documented wire rules rather than
importing heat2's decoder, so an encoder/decoder bug that cancels out in a
round-trip is still caught. Checks:
* every tag decodes to a legal 4-char label (chars 0x20..0x5F, no embedded
space, trailing-space padding only)
* fields at every nesting level are in STRICTLY ascending packed-tag order
* varints are canonical (shortest form), no 0x40 sign bit set
* string lengths include exactly one trailing NUL and no interior NUL
* every struct/group is terminated by exactly one 0x00
* the payload is consumed exactly (no trailing bytes, no overrun)
* list/map headers use legal element type codes
"""
import struct
import sys
PROBLEMS = []
def bad(off, msg, ctx=b""):
PROBLEMS.append((off, msg, ctx))
VALID_TYPES = {0x00: "int", 0x01: "string", 0x02: "blob", 0x03: "struct",
0x04: "list", 0x05: "map", 0x06: "union", 0x07: "intlist",
0x08: "objtype", 0x09: "objid", 0x0A: "float"}
def dec_tag(b, off):
a, b1, c = b[0], b[1], b[2]
v = [(a >> 2) & 0x3F, ((a & 3) << 4) | ((b1 >> 4) & 0xF),
((b1 & 0xF) << 2) | ((c >> 6) & 3), c & 0x3F]
chars = []
for x in v:
chars.append(chr(x + 0x20) if x else " ")
raw = "".join(chars)
label = raw.rstrip()
if not label:
bad(off, "tag decodes to all-padding (empty label) raw=%s" % b.hex())
if " " in label:
bad(off, "tag %r has an interior space (padding is not trailing-only) raw=%s"
% (raw, b.hex()))
for ch in label:
if not (0x20 <= ord(ch) <= 0x5F):
bad(off, "tag %r contains non-Heat2 char %r raw=%s" % (raw, ch, b.hex()))
if not (ch.isupper() or ch.isdigit()):
bad(off, "tag %r char %r is not [A-Z0-9] (suspicious for a Blaze tag)"
% (raw, ch))
# re-encode check
cc = [(ord(ch) - 0x20) & 0x3F for ch in raw]
re_enc = bytes(((cc[0] << 2) | (cc[1] >> 4),
((cc[1] & 0xF) << 4) | (cc[2] >> 2),
((cc[2] & 3) << 6) | cc[3]))
if re_enc != bytes(b):
bad(off, "tag %r does not re-encode: %s != %s" % (raw, re_enc.hex(), bytes(b).hex()))
return label, bytes(b)
def rd_varint(buf, i, what):
start = i
b = buf[i]; i += 1
if b & 0x40:
bad(start, "%s: first varint byte 0x%02x has sign bit 0x40 set" % (what, b))
val = b & 0x3F
nbytes = 1
if b & 0x80:
shift = 6
while True:
if i >= len(buf):
bad(start, "%s: varint runs past end of buffer" % what)
return val, i
b = buf[i]; i += 1
nbytes += 1
val |= (b & 0x7F) << shift
shift += 7
if not (b & 0x80):
if b == 0x00:
bad(start, "%s: non-canonical varint (trailing zero group) %s"
% (what, buf[start:i].hex()))
break
# canonical length check
v = val
exp = 1
v >>= 6
while v:
exp += 1
v >>= 7
if exp != nbytes:
bad(start, "%s: varint for %d used %d bytes, canonical is %d (%s)"
% (what, val, nbytes, exp, buf[start:i].hex()))
return val, i
def rd_value(buf, i, typ, path, depth):
if typ == 0x00:
v, i = rd_varint(buf, i, path)
return v, i
if typ == 0x01:
start = i
ln, i = rd_varint(buf, i, path + ".len")
if ln == 0:
bad(start, "%s: string length 0 (must be >=1 to hold the NUL)" % path)
return "", i
if i + ln > len(buf):
bad(start, "%s: string length %d overruns buffer" % (path, ln))
return "", len(buf)
raw = buf[i:i + ln]; i += ln
if raw[-1] != 0x00:
bad(start, "%s: string not NUL-terminated, last byte 0x%02x (%s)"
% (path, raw[-1], raw.hex()))
if 0x00 in raw[:-1]:
bad(start, "%s: string has interior NUL (%s)" % (path, raw.hex()))
return raw[:-1].decode("utf-8", "replace"), i
if typ == 0x02:
ln, i = rd_varint(buf, i, path + ".len")
return bytes(buf[i:i + ln]), i + ln
if typ == 0x03:
return rd_struct(buf, i, path, depth + 1, terminated=True)
if typ == 0x04:
et = buf[i]
if et not in VALID_TYPES:
bad(i, "%s: list element type 0x%02x is not a legal TDF type" % (path, et))
i += 1
n, i = rd_varint(buf, i, path + ".count")
items = []
for k in range(n):
v, i = rd_value(buf, i, et, "%s[%d]" % (path, k), depth)
items.append(v)
return (VALID_TYPES.get(et), items), i
if typ == 0x05:
kt = buf[i]; vt = buf[i + 1]
for nm, t in (("key", kt), ("value", vt)):
if t not in VALID_TYPES:
bad(i, "%s: map %s type 0x%02x is not a legal TDF type" % (path, nm, t))
i += 2
n, i = rd_varint(buf, i, path + ".count")
items = []
for k in range(n):
kk, i = rd_value(buf, i, kt, "%s{%d}.k" % (path, k), depth)
vv, i = rd_value(buf, i, vt, "%s{%d}.v" % (path, k), depth)
items.append((kk, vv))
return (VALID_TYPES.get(kt), VALID_TYPES.get(vt), items), i
bad(i, "%s: type 0x%02x not handled by validator" % (path, typ))
raise SystemExit("cannot continue")
def rd_struct(buf, i, path, depth, terminated):
fields = []
prev = None
while True:
if i >= len(buf):
if terminated:
bad(i, "%s: struct ran off the end without a 0x00 terminator" % path)
break
if terminated and buf[i] == 0x00:
i += 1
break
if i + 4 > len(buf):
bad(i, "%s: %d trailing bytes, too short for a tag+type header (%s)"
% (path, len(buf) - i, buf[i:].hex()))
break
label, packed = dec_tag(buf[i:i + 3], i)
typ = buf[i + 3]
if typ not in VALID_TYPES:
bad(i + 3, "%s.%s: type byte 0x%02x is not a legal TDF type" % (path, label, typ))
if prev is not None and packed <= prev[1]:
rel = "==" if packed == prev[1] else "<"
bad(i, "%s: field %r (tag %s) is %s previous %r (tag %s) -- ORDER VIOLATION"
% (path, label, packed.hex(), rel, prev[0], prev[1].hex()))
prev = (label, packed)
i += 4
val, i = rd_value(buf, i, typ, "%s.%s" % (path, label), depth)
fields.append((label, VALID_TYPES.get(typ), val))
return fields, i
def show(fields, d=0):
for lbl, tn, v in fields:
if tn == "struct":
print(" " * d + "%s (struct) {" % lbl)
show(v, d + 1)
print(" " * d + "}")
else:
print(" " * d + "%s (%s) = %r" % (lbl, tn, v))
def main(path):
data = open(path, "rb").read()
plen = struct.unpack_from(">I", data, 0)[0]
mlen = struct.unpack_from(">H", data, 4)[0]
comp = struct.unpack_from(">H", data, 6)[0]
cmd = struct.unpack_from(">H", data, 8)[0]
msgnum = (data[10] << 16) | (data[11] << 8) | data[12]
mtype = (data[13] >> 5) & 7
uidx = data[13] & 0x1F
print("== %s (%d bytes) ==" % (path, len(data)))
print("payload_len=%d meta_len=%d comp=0x%04x cmd=0x%04x msgNum=%d "
"msgType=%d userIdx=%d opts=0x%02x rsv=0x%02x"
% (plen, mlen, comp, cmd, msgnum, mtype, uidx, data[14], data[15]))
if 16 + mlen + plen != len(data):
bad(0, "frame size mismatch: 16+%d+%d=%d but file is %d"
% (mlen, plen, 16 + mlen + plen, len(data)))
payload = data[16 + mlen:16 + mlen + plen]
fields, end = rd_struct(payload, 0, "", 0, terminated=False)
if end != len(payload):
bad(end, "payload not fully consumed: stopped at %d of %d (rest=%s)"
% (end, len(payload), payload[end:].hex()))
print("--- decoded ---")
show(fields)
print("--- result ---")
if PROBLEMS:
for off, msg, _ in PROBLEMS:
lo = max(0, off - 8)
print("BUG @0x%04x (payload): %s" % (off, msg))
print(" bytes %s" % payload[lo:off + 16].hex(" "))
return 1
print("PASS: %d top-level fields, payload consumed exactly (%d bytes)"
% (len(fields), len(payload)))
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1]))
@@ -0,0 +1,155 @@
#!/usr/bin/env python3
"""Virtual Xbox-360 gamepad over /dev/uinput (OpenFUT FIFA-17 recon).
FIFA 17 runs under Proton and reads input via evdev/SDL (a real controller),
NOT via X11 XTEST so xdotool keystrokes never reach it. This creates a
kernel-level virtual pad whose events are indistinguishable from hardware, so
FIFA's native gamepad path picks them up. Prototype (pure ctypes, no deps) to
PROVE the approach; port to a Rust driver once confirmed.
./vgamepad.py daemon create the pad + hold it open, read commands from
the FIFO /tmp/vpad.fifo until killed
./vgamepad.py <cmd> [...] send command(s) to the running daemon, e.g.
./vgamepad.py a (A / confirm)
./vgamepad.py b (B / back)
./vgamepad.py up down left right
./vgamepad.py lb rb start back guide
NOTE: FIFA enumerates controllers at launch, so the daemon must be running
BEFORE FIFA starts (or FIFA relaunched) for the pad to be seen.
"""
import os, sys, time, struct, fcntl
FIFO = "/tmp/vpad.fifo"
UINPUT = "/dev/uinput"
# ---- ioctl numbers (x86_64) ------------------------------------------------
UI_SET_EVBIT = 0x40045564
UI_SET_KEYBIT = 0x40045565
UI_SET_ABSBIT = 0x40045567
UI_DEV_CREATE = 0x5501
UI_DEV_DESTROY = 0x5502
EV_SYN, EV_KEY, EV_ABS = 0x00, 0x01, 0x03
SYN_REPORT = 0
BUS_USB = 0x03
# Xbox-360 button codes
BTN = {
"a": 0x130, "b": 0x131, "x": 0x133, "y": 0x134,
"lb": 0x136, "rb": 0x137, "back": 0x13a, "start": 0x13b,
"guide": 0x13c, "l3": 0x13d, "r3": 0x13e,
}
ABS_X, ABS_Y, ABS_Z, ABS_RX, ABS_RY, ABS_RZ = 0, 1, 2, 3, 4, 5
ABS_HAT0X, ABS_HAT0Y = 0x10, 0x11
STICKS = [ABS_X, ABS_Y, ABS_RX, ABS_RY] # -32768..32767
TRIGGERS = [ABS_Z, ABS_RZ] # 0..255
HATS = [ABS_HAT0X, ABS_HAT0Y] # -1..1
# d-pad direction -> (hat axis, value)
DPAD = {
"up": (ABS_HAT0Y, -1), "down": (ABS_HAT0Y, 1),
"left": (ABS_HAT0X, -1), "right": (ABS_HAT0X, 1),
}
def _ev(fd, etype, code, value):
# struct input_event { timeval time(16); u16 type; u16 code; s32 value; }
os.write(fd, struct.pack("llHHi", 0, 0, etype, code, value))
def _syn(fd):
_ev(fd, EV_SYN, SYN_REPORT, 0)
def create_device():
fd = os.open(UINPUT, os.O_WRONLY | os.O_NONBLOCK)
fcntl.ioctl(fd, UI_SET_EVBIT, EV_KEY)
fcntl.ioctl(fd, UI_SET_EVBIT, EV_ABS)
fcntl.ioctl(fd, UI_SET_EVBIT, EV_SYN)
for code in BTN.values():
fcntl.ioctl(fd, UI_SET_KEYBIT, code)
for ax in STICKS + TRIGGERS + HATS:
fcntl.ioctl(fd, UI_SET_ABSBIT, ax)
# legacy uinput_user_dev: name[80], input_id{bus,vendor,product,version}(u16*4),
# ff_effects_max(u32), absmax/min/fuzz/flat[64] each s32
name = b"Microsoft X-Box 360 pad".ljust(80, b"\0")
idv = struct.pack("HHHH", BUS_USB, 0x045e, 0x028e, 0x0114)
ff = struct.pack("I", 0)
absmax = [0] * 64; absmin = [0] * 64; absfuzz = [0] * 64; absflat = [0] * 64
for ax in STICKS:
absmax[ax] = 32767; absmin[ax] = -32768; absflat[ax] = 128
for ax in TRIGGERS:
absmax[ax] = 255; absmin[ax] = 0
for ax in HATS:
absmax[ax] = 1; absmin[ax] = -1
payload = (name + idv + ff
+ struct.pack("64i", *absmax) + struct.pack("64i", *absmin)
+ struct.pack("64i", *absfuzz) + struct.pack("64i", *absflat))
os.write(fd, payload)
fcntl.ioctl(fd, UI_DEV_CREATE)
time.sleep(0.3) # let udev create /dev/input/eventN + jsN
return fd
def do(fd, cmd):
cmd = cmd.strip().lower()
if not cmd:
return
if cmd in BTN:
_ev(fd, EV_KEY, BTN[cmd], 1); _syn(fd); time.sleep(0.08)
_ev(fd, EV_KEY, BTN[cmd], 0); _syn(fd)
elif cmd in DPAD:
ax, val = DPAD[cmd]
_ev(fd, EV_ABS, ax, val); _syn(fd); time.sleep(0.10)
_ev(fd, EV_ABS, ax, 0); _syn(fd)
elif cmd.startswith("hold_") and cmd[5:] in BTN: # hold_lb etc. (no auto-release)
_ev(fd, EV_KEY, BTN[cmd[5:]], 1); _syn(fd)
elif cmd.startswith("rel_") and cmd[4:] in BTN:
_ev(fd, EV_KEY, BTN[cmd[4:]], 0); _syn(fd)
else:
sys.stderr.write("unknown cmd: %s\n" % cmd)
time.sleep(0.12)
def daemon():
if os.path.exists(FIFO):
os.unlink(FIFO)
os.mkfifo(FIFO)
fd = create_device()
sys.stderr.write("[vgamepad] device created, listening on %s\n" % FIFO)
sys.stderr.flush()
try:
while True:
with open(FIFO, "r") as f: # blocks until a writer sends a line
for line in f:
for cmd in line.split():
do(fd, cmd)
finally:
try:
fcntl.ioctl(fd, UI_DEV_DESTROY)
except Exception:
pass
os.close(fd)
if os.path.exists(FIFO):
os.unlink(FIFO)
def send(cmds):
if not os.path.exists(FIFO):
sys.stderr.write("!! daemon not running (no %s). Start: vgamepad.py daemon\n" % FIFO)
sys.exit(2)
with open(FIFO, "w") as f:
f.write(" ".join(cmds) + "\n")
print("sent: %s" % " ".join(cmds))
if __name__ == "__main__":
if len(sys.argv) < 2:
print(__doc__)
sys.exit(1)
if sys.argv[1] == "daemon":
daemon()
else:
send(sys.argv[1:])
@@ -0,0 +1,590 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Watch FIFA 17's FUT club / pile model live -- READ-ONLY, no patching.
WHY THIS EXISTS
---------------
Two problems resisted static analysis because the deciding logic may live in the
Denuvo-packed FIFA17.exe (no code on disk):
(1) the FUT hub tab bar shows "MY CLUB 0" while the club holds ~99 items;
(2) "Send to Club" (PUT ut/%s/item) kills the FUT session.
Static RE (this session) located the structures below inside CardsDLL. What it
could NOT locate is the exact field the "MY CLUB" badge reads. So this tool does
two things at once:
* WATCH the fields we DID identify (CardsDb card map, FUT session state), and
* DIFF-SCAN the whole CardsDb object so the unknown counter reveals ITSELF when
the human performs a labelled action in game.
It is the same pattern as tools/watch_online_mode.py: poll a CardsDLL singleton
through /proc/PID/mem while the game runs.
READ-ONLY GUARANTEE
-------------------
/proc/PID/mem is opened 'rb' and only ever seek()/read(). There is no write path
in this file. It cannot corrupt a save or a running process.
WHAT IS VERIFIED AND WHAT IS NOT
--------------------------------
VERIFIED STATICALLY (Ghidra, CardsDLL_Win64_retail.dll @ 0x180000000):
* 0x1802e6398 CardsDb singleton pointer (getter FUN_18011a830, vtable 0x18021c2a0)
* CardsDb+0x160c0..0x160e8 card item tree: root = *(obj+0x160d8),
sentinel/end = obj+0x160c8, node {childA+0x00, childB+0x08, parent+0x10,
key(itemId)+0x20, record+0x28}. Walked by the resolve FUN_18011cca0
(vtable +0xa08) and by FUN_18011cf40 (vtable +0xa30, which clears
record+0x10 = tradeId).
* 0x1802df338 ION_CardInventory adapter object pointer (registration
FUN_18003d070; bindings GetUserCardIDs / GetCardIDsForPile / GetListItemData
dispatch through its vtable slots +0x08 / +0x38 / +0x30).
* 0x1802e6328 FUT CompetitionManager (already proven by watch_online_mode.py).
VERIFIED LIVE (read-only probe of a running FIFA17.exe, 2026-08-04, main menu,
FUT session already torn down):
* all four globals resolve to non-NULL objects; the whole 0x22000 window reads.
* the tree walk returned 11 nodes and CardsDb+0x160e8 read 11 -- so +0x160e8 is
the tree's SIZE field and the walk agrees with it. Both are reported; a
mismatch between them means the walk went wrong, not the game.
* node keys were 100000001..100000025 -- OUR seeded item ids. This tree is the
client's ITEM store, keyed by item id (not by resourceId).
* session.phase / ready / stackIdx were all -1 (FUT session dead), consistent
with the morning's kill.
STILL UNVERIFIED:
* whether the "MY CLUB" counter lives inside the CardsDb object at all. If the
diff scan reports nothing during the MY CLUB phase, that is itself the
finding: the counter is NOT in CardsDb and lives in FIFA17.exe's own model.
* the `pile` field offset inside a node record. The 11 nodes seen live were all
the same pile, so nothing varied and no offset could be pinned. `items.count`
below is therefore a TOTAL, not a per-pile figure.
* every value observed during actual gameplay (nobody has run this while
opening MY CLUB, opening a pack, or pressing Send to Club).
USAGE
-----
python3 tools/watch_club_model.py # everything, default filters
python3 tools/watch_club_model.py --all # no value filtering (noisy)
python3 tools/watch_club_model.py --no-scan # session + tree only
python3 tools/watch_club_model.py --offsets 0x160c8,0x1234 # lock on candidates
python3 tools/watch_club_model.py --calib 15 # longer idle calibration
While it runs, TYPE A LABEL + ENTER to mark what you are about to do, e.g.
hub<Enter> myclub<Enter> pack<Enter> send<Enter>
Every later line is tagged with that label. Ctrl-C prints a SHORT SUMMARY --
paste the summary, not the stream.
"""
import argparse
import glob
import os
import select
import struct
import sys
import time
from array import array
# ---------------------------------------------------------------- constants --
IMG_BASE = 0x180000000
DLL = "CardsDLL"
# Globals inside CardsDLL (static VAs; rebased to the live mapping at runtime).
G_CARDSDB = 0x1802E6398 # CardsDb singleton (FUN_18011a830 returns this)
G_CARDINV = 0x1802DF338 # ION_CardInventory adapter object
G_CARDINV2 = 0x1802DF348 # second slot written by FUN_18003d360
G_COMPMGR = 0x1802E6328 # FUT::CompetitionManager (see watch_online_mode.py)
# CompetitionManager fields, proven by watch_online_mode.py.
CM_PHASE, CM_READY, CM_STKIDX = 0x218, 0x6D4, 0x214
CM_READY_OK = 0x1FBD0
# CardsDb card/definition tree (see module docstring).
TREE_BASE = 0x160C0 # tree object base (passed to the inserter FUN_180115c30)
TREE_END = 0x160C8 # sentinel node address == obj + this
TREE_P1 = 0x160D0 # anchor slot 1 (iteration start in FUN_18011cf40)
TREE_ROOT = 0x160D8 # root (walk start in FUN_18011cca0)
TREE_SIZE = 0x160E8 # node count -- LIVE-VERIFIED: read 11 while the walk found 11
NODE_A, NODE_B, NODE_KEY = 0x00, 0x08, 0x20 # node children + key(itemId)
NODE_TRADEID = 0x38 # record+0x10; FUN_18011cf40 zeroes it on a successful move
# The scan window over the CardsDb object. 0x20d10 is the highest offset any
# decompiled CardsDb method touches, so 0x22000 is a safe upper bound; the tool
# probes downward if the tail is not mapped.
SCAN_LEN_DEFAULT = 0x22000
PAGE = 0x1000
MAX_NODES = 200000 # hard cap so a corrupt/garbage tree can never hang us
MAX_REPORTS_PER_POLL = 40
# ------------------------------------------------------------------ process --
def find_pid():
for d in glob.glob("/proc/[0-9]*"):
try:
if open(d + "/comm").read().strip() == "FIFA17.exe":
return int(d.rsplit("/", 1)[-1])
except Exception:
pass
return None
def dll_base(pid, name=DLL):
try:
for line in open("/proc/%d/maps" % pid):
if name in line:
return int(line.split("-")[0], 16)
except Exception:
return None
return None
class Mem(object):
"""Read-only /proc/PID/mem accessor. Every failure is reported, never raised."""
def __init__(self, pid):
self.pid = pid
self.fails = 0
self.f = open("/proc/%d/mem" % pid, "rb") # 'rb' -- read-only, by design
def read(self, va, n):
try:
self.f.seek(va)
b = self.f.read(n)
if b is None or len(b) != n:
self.fails += 1
return None
return b
except Exception:
self.fails += 1
return None
def read_pages(self, va, n):
"""Read n bytes, page by page. Returns (bytearray, set_of_bad_page_idx)."""
buf = bytearray(n)
bad = set()
for off in range(0, n, PAGE):
ln = min(PAGE, n - off)
b = self.read(va + off, ln)
if b is None:
bad.add(off // PAGE)
else:
buf[off:off + ln] = b
return buf, bad
def q(self, va):
b = self.read(va, 8)
return struct.unpack("<Q", 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 alive(self):
return os.path.exists("/proc/%d" % self.pid)
# --------------------------------------------------------------- structures --
def walk_tree(mem, obj):
"""Count nodes in the CardsDb card/definition tree, bounded and defensive.
Returns (count, note). count is None when the tree could not be walked.
Generic DFS over BOTH child slots with a visited set -- the exact
left/right convention does not matter for a count, and a cycle or a garbage
pointer terminates the walk instead of hanging it.
"""
root = mem.q(obj + TREE_ROOT)
end = obj + TREE_END
if root is None:
return None, "root unreadable"
if root == 0 or root == end:
return 0, "empty"
seen = set()
stack = [root]
n = 0
truncated = False
while stack:
p = stack.pop()
if p == 0 or p == end or p in seen:
continue
if p & 7: # nodes are 8-byte aligned; anything else is garbage
continue
if n >= MAX_NODES:
truncated = True
break
seen.add(p)
n += 1
for slot in (NODE_A, NODE_B):
c = mem.q(p + slot)
if c is None:
truncated = True
continue
if c and c != end and c not in seen:
stack.append(c)
return n, ("TRUNCATED at %d" % MAX_NODES) if truncated else "ok"
def tree_items(mem, obj, limit=MAX_NODES):
"""{itemId: tradeId} for every node in the tree. Bounded and defensive.
Returns None when the tree could not be read at all. This is the answer to
"does the client actually hold all 99 club items, or only the squad?" --
if the count stays at ~11 while MY CLUB displays 99 players, the client is
rendering a fetch result it never ingested into this store.
"""
root = mem.q(obj + TREE_ROOT)
end = obj + TREE_END
if root is None:
return None
if root == 0 or root == end:
return {}
out, seen, stack = {}, set(), [root]
while stack and len(out) < limit:
p = stack.pop()
if not p or p == end or p in seen or (p & 7):
continue
seen.add(p)
k = mem.q(p + NODE_KEY)
if k is not None:
out[k] = mem.q(p + NODE_TRADEID)
for slot in (NODE_A, NODE_B):
c = mem.q(p + slot)
if c and c != end and c not in seen:
stack.append(c)
return out
def snapshot_named(mem, base):
"""The identified fields, as a labelled dict. Missing/failed reads -> None."""
s = {}
cdb = mem.q(base + (G_CARDSDB - IMG_BASE))
inv = mem.q(base + (G_CARDINV - IMG_BASE))
inv2 = mem.q(base + (G_CARDINV2 - IMG_BASE))
cm = mem.q(base + (G_COMPMGR - IMG_BASE))
s["CardsDb.ptr"] = cdb
s["CardInventory.ptr"] = inv
s["CardInventory2.ptr"] = inv2
s["CompetitionMgr.ptr"] = cm
if cm:
s["session.phase"] = mem.i32(cm + CM_PHASE)
s["session.ready"] = mem.i32(cm + CM_READY)
s["session.stackIdx"] = mem.i32(cm + CM_STKIDX)
if cdb:
for off, nm in ((TREE_BASE, "tree.base"), (TREE_END, "tree.anchor0"),
(TREE_P1, "tree.anchor1"), (TREE_ROOT, "tree.root")):
s["cdb+%#x %s" % (off, nm)] = mem.q(cdb + off)
size = mem.q(cdb + TREE_SIZE)
s["items.size(+0x160e8)"] = size
n, note = walk_tree(mem, cdb)
s["items.walkCount"] = n
if n is not None and size is not None and n != size:
s["items.walkNote"] = "%s MISMATCH vs size field" % note
elif note != "ok":
s["items.walkNote"] = note
return s
# -------------------------------------------------------------- diff engine --
def probe_scan_len(mem, obj, want):
"""Largest readable window <= want, rounded to pages."""
n = want
while n >= PAGE:
if mem.read(obj + n - PAGE, PAGE) is not None:
return n
n -= PAGE
return 0
class Differ(object):
"""Dword-level differ over one memory window, with hot-offset suppression."""
def __init__(self, base_va, length, value_filter=True):
self.va = base_va
self.len = length
self.prev = None
self.hot = set() # offsets that churn while idle -> ignored
self.changes = {} # offset -> [values seen]
self.value_filter = value_filter
def _interesting(self, old, new):
if not self.value_filter:
return True
# Counter-like: small signed ints on both sides.
if -1 <= old <= 100000 and -1 <= new <= 100000:
return True
# A field going to/from zero (pointer or count clear) is worth seeing.
return old == 0 or new == 0
def poll(self, mem, calibrating):
buf, bad = mem.read_pages(self.va, self.len)
cur = array("i")
cur.frombytes(bytes(buf))
if self.prev is None:
self.prev = cur
return [], bad
out = []
prev = self.prev
n = len(cur)
for i in range(n):
a = prev[i]
b = cur[i]
if a == b:
continue
off = i * 4
if (off >> 12) in bad:
continue
if calibrating:
self.hot.add(off)
continue
if off in self.hot:
continue
if not self._interesting(a, b):
self.hot.add(off) # noisy pointer-ish churn, drop it for good
continue
out.append((off, a, b))
self.changes.setdefault(off, [a]).append(b)
self.prev = cur
return out, bad
# -------------------------------------------------------------------- marks --
def read_marker():
"""Non-blocking read of a phase label from stdin. Returns str or None."""
try:
r, _, _ = select.select([sys.stdin], [], [], 0)
except Exception:
return None
if not r:
return None
line = sys.stdin.readline()
if not line:
return None
return line.strip() or "(blank)"
def fmt(v):
if v is None:
return "UNREADABLE"
if isinstance(v, str):
return v
if isinstance(v, int) and abs(v) > 0xFFFF:
return "%#x" % v
return str(v)
# --------------------------------------------------------------------- main --
def main():
ap = argparse.ArgumentParser(description="Read-only live watch of FIFA 17's FUT club/pile model")
ap.add_argument("--interval", type=float, default=0.5, help="poll seconds (default 0.5)")
ap.add_argument("--calib", type=float, default=10.0,
help="idle calibration seconds; offsets that churn during this "
"window are suppressed forever (default 10)")
ap.add_argument("--scan-len", type=lambda s: int(s, 0), default=SCAN_LEN_DEFAULT,
help="bytes of the CardsDb object to diff (default 0x22000)")
ap.add_argument("--no-scan", action="store_true", help="named fields only, no diff scan")
ap.add_argument("--all", action="store_true", help="report every changed dword (noisy)")
ap.add_argument("--offsets", default="",
help="comma-separated CardsDb offsets to always report, e.g. 0x160c8,0x1f00")
args = ap.parse_args()
pid = find_pid()
if pid is None:
print("FIFA17.exe is not running. Start the game, reach the FUT hub, then run this.")
return 1
base = dll_base(pid)
if base is None:
print("FIFA17.exe (pid %d) is running but %s is not mapped yet." % (pid, DLL))
print("Wait until FIFA reaches the main menu / FUT hub and run again.")
return 1
try:
mem = Mem(pid)
except Exception as e:
print("cannot open /proc/%d/mem: %s" % (pid, e))
print("Need ptrace_scope=0: sudo sysctl -w kernel.yama.ptrace_scope=0")
return 1
print("FIFA pid=%d %s base=%#x" % (pid, DLL, base))
named = snapshot_named(mem, base)
for k in ("CardsDb.ptr", "CardInventory.ptr", "CardInventory2.ptr", "CompetitionMgr.ptr"):
print(" %-22s %s" % (k, fmt(named.get(k))))
cdb = named.get("CardsDb.ptr")
if not cdb:
print("\nCardsDb singleton is NULL -- the FUT layer has not been constructed yet.")
print("Enter Ultimate Team first, then re-run. (Watching anyway.)")
if not named.get("CardInventory.ptr"):
print(" note: ION_CardInventory adapter is NULL -- the UI card model is not "
"bound yet (expected outside the FUT hub).")
differ = None
if cdb and not args.no_scan:
length = probe_scan_len(mem, cdb, args.scan_len)
if length == 0:
print(" CardsDb object not readable -- diff scan disabled.")
else:
if length != args.scan_len:
print(" CardsDb readable window shrunk to %#x (tail unmapped)." % length)
differ = Differ(cdb, length, value_filter=not args.all)
print(" diff scan over CardsDb[0 .. %#x) (%d dwords)" % (length, length // 4))
watch_offsets = []
for tok in args.offsets.split(","):
tok = tok.strip()
if tok:
try:
watch_offsets.append(int(tok, 0))
except ValueError:
print(" bad --offsets value: %r (ignored)" % tok)
print("\nCalibrating for %.0fs -- LEAVE THE GAME IDLE ON THE FUT HUB." % args.calib)
print("After that, type a label + Enter before each action (hub / myclub / pack / send).")
print("Ctrl-C prints the summary.\n")
t0 = time.time()
phase = "boot"
last_named = {}
last_ids = None
item_hist = []
marks = []
poll_n = 0
calib_done = False
try:
while True:
if not mem.alive():
print("[%s] FIFA17.exe exited." % time.strftime("%H:%M:%S"))
break
m = read_marker()
if m is not None:
phase = m
marks.append((time.strftime("%H:%M:%S"), m))
print("\n=========== PHASE: %s (%s) ===========" % (m, time.strftime("%H:%M:%S")))
calibrating = (time.time() - t0) < args.calib
if calibrating and poll_n and poll_n % 4 == 0:
sys.stdout.write("\r calibrating... %.0fs left "
% (args.calib - (time.time() - t0)))
sys.stdout.flush()
poll_n += 1
cur = snapshot_named(mem, base)
for k, v in cur.items():
if k in last_named and last_named[k] == v:
continue
if k in last_named:
line = "[%s][%s] %-28s %s -> %s" % (
time.strftime("%H:%M:%S"), phase, k,
fmt(last_named[k]), fmt(v))
if k == "session.ready" and v == CM_READY_OK:
line += " <== FUT SERVICE READY"
if k == "session.phase" and isinstance(v, int) and v < 0:
line += " <== FUT SESSION TORN DOWN"
if k == "CardsDb.ptr" and not v:
line += " <== CardsDb DESTROYED"
print(line)
last_named[k] = v
# --- item store membership: the direct answer to "does the client
# --- actually hold the club, or only the squad?"
if cdb:
items = tree_items(mem, cdb)
if items is None:
if last_ids is not None:
print("[%s][%s] item store unreadable" % (time.strftime("%H:%M:%S"), phase))
last_ids = None
else:
ids = set(items)
if last_ids is None or ids != last_ids:
added = sorted(ids - (last_ids or set()))
gone = sorted((last_ids or set()) - ids)
print("[%s][%s] ITEM STORE count=%d (+%d / -%d)%s%s"
% (time.strftime("%H:%M:%S"), phase, len(ids),
len(added), len(gone),
" added=%s" % added[:8] if added else "",
" removed=%s" % gone[:8] if gone else ""))
traded = [i for i, t in items.items() if t]
if traded:
print(" %d item(s) carry a tradeId (on the trade pile)"
% len(traded))
item_hist.append((time.strftime("%H:%M:%S"), phase, len(ids)))
last_ids = ids
if differ is not None:
hits, bad = differ.poll(mem, calibrating)
if bad and not calibrating:
print("[%s][%s] %d page(s) of the CardsDb window unreadable this poll"
% (time.strftime("%H:%M:%S"), phase, len(bad)))
if hits and not calibrating:
shown = hits[:MAX_REPORTS_PER_POLL]
for off, a, b in shown:
tag = ""
if b == a + 1:
tag = " (+1)"
elif b == a - 1:
tag = " (-1)"
elif a == 0:
tag = " (0 -> %d)" % b
elif b == 0:
tag = " (%d -> 0)" % a
print("[%s][%s] cdb+%#07x %d -> %d%s"
% (time.strftime("%H:%M:%S"), phase, off, a, b, tag))
if len(hits) > len(shown):
print("[%s][%s] ... and %d more changed dwords (use --offsets to lock on)"
% (time.strftime("%H:%M:%S"), phase, len(hits) - len(shown)))
for off in watch_offsets:
if not cdb:
break
v = mem.i32(cdb + off)
k = "watch cdb+%#x" % off
if k in last_named and last_named[k] == v:
continue
if k in last_named:
print("[%s][%s] %-28s %s -> %s" % (time.strftime("%H:%M:%S"), phase,
k, fmt(last_named[k]), fmt(v)))
last_named[k] = v
if not calibrating and not calib_done:
calib_done = True
print("\r calibration done -- %d idle-churn offset(s) suppressed. "
"Label your actions now. " % (len(differ.hot) if differ else 0))
time.sleep(args.interval)
except KeyboardInterrupt:
print("\n\nstopped")
# ------------------------------------------------------------- summary --
print("\n" + "=" * 62)
print("SUMMARY (paste this)")
print("=" * 62)
print("pid=%d %s base=%#x failed reads=%d" % (pid, DLL, base, mem.fails))
print("phases marked: %s" % (", ".join("%s@%s" % (m, t) for t, m in marks) or "(none)"))
print("\nitem-store count over time (the MY CLUB question):")
if item_hist:
for t, ph, n in item_hist:
print(" %s [%s] %d items" % (t, ph, n))
else:
print(" (never read)")
print("\nfinal named fields:")
for k in sorted(last_named):
print(" %-28s %s" % (k, fmt(last_named[k])))
if differ is not None:
print("\nsuppressed as idle-churn: %d offsets" % len(differ.hot))
if differ.changes:
print("candidate fields (changed only AFTER calibration), "
"most-changed last:")
for off in sorted(differ.changes, key=lambda o: len(differ.changes[o])):
vals = differ.changes[off]
seq = " -> ".join(str(v) for v in vals[:12])
if len(vals) > 12:
seq += " -> ... (%d values)" % len(vals)
print(" cdb+%#07x : %s" % (off, seq))
else:
print("NO candidate fields changed after calibration.")
print("If that held across the MY CLUB phase, the counter is NOT in the")
print("CardsDb object -- it lives in FIFA17.exe's own view model.")
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,82 @@
#!/usr/bin/env python3
"""Watch FIFA 17's FUT online-mode state machine live (READ-ONLY, no patching).
Polls FUT::CompetitionManager (singleton global 0x1802e6328) and prints when its
phase / ready-token / state-stack changes. Use to discover WHICH in-game context
starts the online-mode handshake (the store gate is downstream of it).
phase mgr+0x218 -1=idle, then 0->1->2->3
ready mgr+0x6d4 becomes 0x1fbd0 when "service ready"
stackIdx mgr+0x214 -1=empty state stack
Run: python3 tools/watch_online_mode.py (Ctrl-C to stop)
Then navigate FIFA: FUT hub, Store, Online Seasons, FUT Champions, Draft, etc.
Needs FIFA running + read access to /proc/PID/mem (ptrace_scope=0).
"""
import glob, struct, time, sys
IMG_BASE = 0x180000000
SINGLETON_VA = 0x1802e6328
OFF_PHASE, OFF_READY, OFF_STKIDX = 0x218, 0x6d4, 0x214
DLL = "CardsDLL"
def find_pid():
for d in glob.glob('/proc/[0-9]*'):
try:
if open(d + '/comm').read().strip() == 'FIFA17.exe':
return int(d.split('/')[-1])
except Exception:
pass
raise SystemExit("FIFA17.exe not running")
def cardsdll_base(pid):
for line in open(f'/proc/{pid}/maps'):
if DLL in line:
return int(line.split('-')[0], 16)
raise SystemExit("CardsDLL not mapped")
def rd(mem, va, n):
try:
mem.seek(va); return mem.read(n)
except Exception:
return None
def i32(b):
return struct.unpack('<i', b)[0] if b and len(b) == 4 else None
def main():
pid = find_pid()
base = cardsdll_base(pid)
gva = base + (SINGLETON_VA - IMG_BASE)
print(f"FIFA pid={pid} CardsDLL base={base:#x} singleton@{gva:#x}")
print("watching phase/ready/stackIdx — navigate FIFA now (Ctrl-C to stop)")
mem = open(f'/proc/{pid}/mem', 'rb')
last = None
while True:
p = rd(mem, gva, 8)
mgr = struct.unpack('<Q', p)[0] if p else 0
if mgr:
phase = i32(rd(mem, mgr + OFF_PHASE, 4))
ready = i32(rd(mem, mgr + OFF_READY, 4))
stk = i32(rd(mem, mgr + OFF_STKIDX, 4))
cur = (mgr, phase, ready, stk)
else:
cur = (0, None, None, None)
if cur != last:
rt = f"{ready:#x}" if ready is not None else "?"
print(f"[{time.strftime('%H:%M:%S')}] mgr={mgr:#x} phase={phase} "
f"ready={rt}{' <== READY!' if ready == 0x1fbd0 else ''} stackIdx={stk}")
last = cur
time.sleep(0.5)
if __name__ == "__main__":
try:
main()
except KeyboardInterrupt:
print("\nstopped")