Files
OpenFUT/fifa17-recon/tools/card_proof.py
T
funman300 b0bbc2a07f fifa17-recon: sweep auto-advance + the three-state oracle, live-proven
The oracle is three-valued, and all three fingerprints are now confirmed live
against a running client rather than read out of Ghidra:

  NAMED        our sentinel rating 7 survives and a real name appears. The id is
               real, and teamid/nation/leagueId come back FILLED by the game
               because we send them as zero.
  placeholder  rating 7 survives but the name is 'Jamal Blackman', team 0. The
               players-table row exists and is an empty slot. This is the trap:
               169193 does this and it was in VERIFIED_ASSET_IDS.
  MISS         rating 0x32, teamid 0x78d, nation 0xe, position 2, name ' '. That
               is the binary's miss-fill, byte for byte, and it is exactly the
               blank card photographed in a pack today.

Scale: 5000 candidates per response ingests cleanly; 20000 was served and then
silently not ingested (the map did not change at all), so the ceiling is between
them and auto chunks default to 5000.

Auto-advance: the client PAGES the club, so one visit yields several fetches.
'auto:lo-hi:step' hands out the next chunk per fetch. Item ids derive from the
candidate's offset in the WHOLE range, not its index in the chunk, so chunks never
collide and results accumulate across fetches for a single probe at the end.

sweep_collect.py accumulates into data/players.json and rejects the placeholder
name as a matter of course. Yield in 20000-24999 was 19 real ids per 5000, which
is why auto-advance matters: the real roster clusters in 150000-240000.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VUT92pz6RWKih9dSr8ZpxW
2026-08-04 20:44:02 -07:00

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())