70a64e3709
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.
737 lines
32 KiB
Python
737 lines
32 KiB
Python
#!/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())
|