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:
@@ -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
@@ -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
@@ -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))
|
||||
@@ -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 pack→club 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()
|
||||
@@ -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() ; -> ®istry 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))
|
||||
+3750
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")
|
||||
Reference in New Issue
Block a user