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
This commit is contained in:
funman300
2026-08-04 20:44:02 -07:00
parent 87e5cd2e53
commit b0bbc2a07f
21 changed files with 230413 additions and 35 deletions
+272
View File
@@ -0,0 +1,272 @@
{
"20209": {
"first": "Ricardo",
"last": "Costa",
"known": "",
"teamid": 897,
"nation": 38,
"league": 189,
"position": 25
},
"20289": {
"first": "Yaya",
"last": "Touré",
"known": "",
"teamid": 10,
"nation": 108,
"league": 13,
"position": 25
},
"20551": {
"first": "Andreas",
"last": "Isaksson",
"known": "",
"teamid": 710,
"nation": 46,
"league": 56,
"position": 25
},
"20556": {
"first": "Alan Osório",
"last": "da Costa Silva",
"known": "Alan",
"teamid": 1896,
"nation": 54,
"league": 308,
"position": 25
},
"20689": {
"first": "Joakim",
"last": "Karlsson",
"known": "",
"teamid": 112130,
"nation": 46,
"league": 56,
"position": 25
},
"20699": {
"first": "Kennedy",
"last": "Bakırcıoğlu",
"known": "",
"teamid": 708,
"nation": 46,
"league": 56,
"position": 25
},
"20775": {
"first": "Ricardo",
"last": "Quaresma",
"known": "",
"teamid": 327,
"nation": 38,
"league": 68,
"position": 25
},
"20798": {
"first": "Carlos",
"last": "Martins",
"known": "",
"teamid": 1889,
"nation": 38,
"league": 308,
"position": 25
},
"20799": {
"first": "Custódio Miguel",
"last": "Dias de Castro",
"known": "Custódio",
"teamid": 111340,
"nation": 38,
"league": 68,
"position": 25
},
"20800": {
"first": "Daniel Miguel",
"last": "Alves Gomes",
"known": "Danny",
"teamid": 100769,
"nation": 38,
"league": 67,
"position": 25
},
"20801": {
"first": "Cristiano",
"last": "Ronaldo",
"known": "",
"teamid": 243,
"nation": 38,
"league": 53,
"position": 25
},
"21146": {
"first": "Michael",
"last": "Carrick",
"known": "",
"teamid": 11,
"nation": 14,
"league": 13,
"position": 25
},
"22151": {
"first": "Martin",
"last": "Albrechtsen",
"known": "",
"teamid": 269,
"nation": 13,
"league": 1,
"position": 25
},
"22391": {
"first": "Raffaele",
"last": "Cretaro",
"known": "",
"teamid": 563,
"nation": 25,
"league": 65,
"position": 25
},
"23312": {
"first": "Julian",
"last": "De Guzmán",
"known": "",
"teamid": 111592,
"nation": 70,
"league": 382,
"position": 25
},
"23467": {
"first": "Jamie",
"last": "McGuire",
"known": "",
"teamid": 1940,
"nation": 14,
"league": 61,
"position": 25
},
"23823": {
"first": "Andreas",
"last": "Johansson",
"known": "",
"teamid": 702,
"nation": 46,
"league": 56,
"position": 25
},
"24630": {
"first": "José",
"last": "Reina",
"known": "",
"teamid": 48,
"nation": 45,
"league": 31,
"position": 25
},
"24932": {
"first": "Per",
"last": "Nilsson",
"known": "",
"teamid": 819,
"nation": 46,
"league": 1,
"position": 25
},
"26501": {
"first": "Alex",
"last": "Hunter",
"known": "",
"teamid": 0,
"nation": 14,
"league": 0,
"position": 25
},
"155862": {
"first": "Sergio",
"last": "Ramos",
"known": "",
"teamid": 243,
"nation": 45,
"league": 53,
"position": 5
},
"158023": {
"first": "Lionel",
"last": "Messi",
"known": "",
"teamid": 241,
"nation": 52,
"league": 53,
"position": 23
},
"176580": {
"first": "Luis",
"last": "Suárez",
"known": "",
"teamid": 241,
"nation": 60,
"league": 53,
"position": 25
},
"182521": {
"first": "Toni",
"last": "Kroos",
"known": "",
"teamid": 21,
"nation": 21,
"league": 19,
"position": 14
},
"183277": {
"first": "Eden",
"last": "Hazard",
"known": "",
"teamid": 5,
"nation": 7,
"league": 13,
"position": 16
},
"183907": {
"first": "Jérôme",
"last": "Boateng",
"known": "",
"teamid": 21,
"nation": 21,
"league": 19,
"position": 5
},
"188545": {
"first": "Robert",
"last": "Lewandowski",
"known": "",
"teamid": 240,
"nation": 37,
"league": 53,
"position": 25
},
"189332": {
"first": "Jordi",
"last": "Alba",
"known": "",
"teamid": 241,
"nation": 45,
"league": 53,
"position": 7
},
"197445": {
"first": "David",
"last": "Alaba",
"known": "",
"teamid": 21,
"nation": 40,
"league": 19,
"position": 7
},
"200389": {
"first": "Jan",
"last": "Oblak",
"known": "",
"teamid": 240,
"nation": 44,
"league": 53,
"position": 0
}
}
+56 -14
View File
@@ -3,8 +3,27 @@
the moment its unpacked code is mapped. Idempotent; keeps watching across relaunches."""
import glob, time, struct
# 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="/tmp/autopatch.log"
def log(m):
@@ -19,6 +38,13 @@ def find_pids():
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)
@@ -27,23 +53,39 @@ def wr(pid,va,b):
f.seek(va); f.write(b)
patched=set()
store_patched=set()
log("=== AUTOPATCH watching for FIFA17.exe ===")
while True:
for pid in find_pids():
if pid in patched: continue
try:
g2=rd(pid,GATE2,3); g1=rd(pid,GATE1,6)
except Exception:
continue # code not mapped yet / no ptrace perm yet
if g2==GATE2_PATCH and g1==GATE1_PATCH:
log(f"pid {pid}: already patched"); patched.add(pid); continue
if g2==GATE2_ORIG and g1==GATE1_ORIG:
if pid not in patched:
try:
wr(pid,GATE2,GATE2_PATCH); wr(pid,GATE1,GATE1_PATCH)
v2=rd(pid,GATE2,3).hex(); v1=rd(pid,GATE1,6).hex()
log(f"pid {pid}: PATCHED gate2={v2} gate1={v1}")
patched.add(pid)
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}: patch write failed: {e}")
# else: partial/unknown state -> wait
log(f"pid {pid}: store patch write failed: {e}")
time.sleep(1)
+736
View File
@@ -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())
+459
View File
@@ -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())
+325
View File
@@ -0,0 +1,325 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Extract FIFA 17's real player roster (playerId -> name + rating) from a LIVE
FIFA17.exe process. READ-ONLY: /proc/PID/mem is opened 'rb' and only ever
seek()/read(). There is no write path in this file.
WHY THIS FILE EXISTS, AND WHY IT IS NOT "dbdata.dll"
---------------------------------------------------
The plan of record was to pull the roster out of `/mnt/games/FIFA 17/dbdata.dll`
(2,686,152 bytes, one export `getTableData`, 2.5 MB `.xdata` payload). That DLL
is NOT a database. Verified this session by building tools/dbdata_probe.c with
x86_64-w64-mingw32-gcc and running it under Wine:
base=00006FFFFA980000 getTableData=00006FFFFA9816B0 (rva 0x16b0)
call 0: ret=00007FFFFEBF5DB0 len=1012 -> 1012 chars of base64url
decoded: 759 bytes, md5 dc97c0dfd5edea5fb379dc14d8017980, entropy ~7.9
`.xdata` measures 7.52 bits/byte of entropy uniformly across its whole 2,515,528
bytes (sampled at 0x0/0x1000/0x100000/0x200000/0x260000, zero 16-byte NUL runs),
i.e. it is encrypted/packed, and the single export hands back a ~759-byte
attestation blob, not tables. There is no table selector argument. So the
roster cannot be read out of dbdata.dll without breaking its packer.
The roster IS, however, fully resident in the running game. FIFA 17 builds a
flat, rating-sorted index of every player in the base DB and keeps it on the
heap. That is what this tool reads.
THE STRUCTURE (resolved live, 2026-08-04, pid 11864, game sitting at the menu)
-----------------------------------------------------------------------------
Two heap regions cooperate:
* a "name pool" region (seen at 0x0d790000..0x0dc40000, 4.8 MB, rw-p) holding
~17.5k individually-allocated, NUL-terminated UTF-8 strings in the form
"<firstName>|<lastName>|<commonName>"
e.g. "Cristiano|Ronaldo|", "Neymar|da Silva Santos Jr.|Neymar".
commonName is usually empty (the string then ends in "||").
* an index table (seen at 0x0b8450d40 .. 0x0b8562fc0, rw-p) of 17,547 entries
at a constant stride of 0x40 bytes, no gaps, sorted by rating DESCENDING:
+0x00 u32 playerId (20801 = Cristiano Ronaldo)
+0x04 u32 rank (0..16546, dense, == entry index)
+0x08 u32 rating (73..94 at the head, down to 40s at tail)
+0x0c u32 aux (0 for most entries; a 32-bit hash for
some -- purpose unresolved)
+0x10 char* name begin -> into the name pool
+0x18 char* name end == begin + strlen
+0x20 char* name end + 1
+0x28 u64 0x2c020e50 (constant across every entry)
+0x30 u64 1, or a 32-bit hash in the low dword
+0x38 u64 0x6ffffc32a968 (constant across every entry -- a vtable
or allocator handle in the Wine range)
The {begin, end, end+1} triple at +0x10 is the reliable signature: it is
self-validating (end-begin == strlen, and end+1 == the third pointer), which is
why this tool anchors on it instead of on any hard-coded address. Nothing here
is a fixed VA: run it against any FIFA17.exe and it re-locates the table.
WHAT THIS GIVES YOU AND WHAT IT DOES NOT
----------------------------------------
GIVES: playerId, rank, rating, firstName, lastName, commonName -- for the
complete 17,547-player FIFA 17 roster.
DOES NOT: position, nationality, teamId, or the six face attributes. Those
are NOT in this table. See the "STILL MISSING" note at the bottom
of this file for the leads that were found for them.
ANCHOR CHECK (the one the task asked for): playerId 20801 must be
"Cristiano|Ronaldo|" rated 94. --check enforces it and exits non-zero if the
parse disagrees.
USAGE
-----
./dbdata_extract.py # extract, write players_fifa17.json
./dbdata_extract.py --check # extract + assert the Ronaldo anchor
./dbdata_extract.py -o /tmp/roster.json
./dbdata_extract.py --pid 11864
./dbdata_extract.py --top 40 # print the top 40 and exit
Requires ptrace access to the FIFA process (this project already runs with
kernel.yama.ptrace_scope=1 and the same uid, which is sufficient).
"""
import argparse
import json
import os
import re
import struct
import sys
from collections import Counter
ENTRY_STRIDE = 0x40
NAME_TRIPLE_OFF = 0x10 # offset of {begin,end,end+1} inside an entry
MAX_NAME_LEN = 120
RONALDO_ID = 20801
RONALDO_RATING = 94
RONALDO_NAME = "Cristiano|Ronaldo|"
def find_pid():
for d in os.listdir('/proc'):
if not d.isdigit():
continue
try:
if open('/proc/%s/comm' % d).read().strip() == 'FIFA17.exe':
return int(d)
except OSError:
pass
raise SystemExit("FIFA17.exe is not running (this tool needs the live game)")
def read_maps(pid):
"""Readable, non-file-backed-or-anon RW regions, small enough to slurp."""
out = []
for line in open('/proc/%d/maps' % pid):
parts = line.split()
lo, hi = parts[0].split('-')
lo, hi = int(lo, 16), int(hi, 16)
perms = parts[1]
if 'r' not in perms:
continue
if hi - lo > (1 << 31):
continue
out.append((lo, hi, perms, parts[5] if len(parts) > 5 else ''))
return out
class Mem(object):
def __init__(self, pid):
self.f = open('/proc/%d/mem' % pid, 'rb', 0)
self.cache = {}
def read(self, va, n):
self.f.seek(va)
return self.f.read(n)
def region(self, lo, hi):
if (lo, hi) not in self.cache:
try:
self.f.seek(lo)
self.cache[(lo, hi)] = self.f.read(hi - lo)
except OSError:
self.cache[(lo, hi)] = b''
return self.cache[(lo, hi)]
def find_name_pools(mem, maps):
"""Regions containing many '<a>|<b>|<c>\\0' strings = the player-name pool."""
pat = re.compile(rb'[^\x00|][^\x00|]{0,44}\|[^\x00|]{0,49}\|[^\x00|]{0,49}\x00')
pools = []
for lo, hi, perms, name in maps:
if 'w' not in perms or name:
continue
if not (0x100000 <= hi - lo <= 0x4000000):
continue
d = mem.region(lo, hi)
if not d:
continue
n = len(pat.findall(d))
if n >= 2000:
pools.append((lo, hi, n))
return pools
def scan_entries(mem, maps, pools):
"""Anchor on the self-validating {begin,end,end+1} name triple."""
lows = [(lo, hi) for lo, hi, _ in pools]
def in_pool(va):
for lo, hi in lows:
if lo <= va < hi:
return True
return False
def pool_bytes(va, n):
for lo, hi in lows:
if lo <= va and va - lo + n <= hi - lo:
return mem.region(lo, hi)[va - lo:va - lo + n]
return mem.read(va, n)
found = {}
for lo, hi, perms, name in maps:
if 'w' not in perms or name:
continue
d = mem.region(lo, hi)
if len(d) < ENTRY_STRIDE:
continue
for off in range(0, len(d) - ENTRY_STRIDE, 8):
b, e, c = struct.unpack_from('<QQQ', d, off)
if not (b < e < b + MAX_NAME_LEN and c == e + 1):
continue
if not in_pool(b):
continue
s = pool_bytes(b, e - b)
if s.count(b'|') != 2:
continue
base = off - NAME_TRIPLE_OFF
if base < 0:
continue
pid_, rank, rating, aux = struct.unpack_from('<IIII', d, base)
found[lo + base] = (pid_, rank, rating, aux,
s.decode('utf-8', 'replace'))
return found
def build(found):
addrs = sorted(found)
rows = []
for a in addrs:
pid_, rank, rating, aux, s = found[a]
f0, f1, f2 = (s.split('|') + ['', '', ''])[:3]
rows.append({
'playerId': pid_,
'resourceId': pid_, # version 0; resourceId = playerId | version<<24
'rank': rank,
'rating': rating,
'firstName': f0,
'lastName': f1,
'commonName': f2,
'aux': aux,
'addr': '0x%x' % a,
})
return addrs, rows
def main():
ap = argparse.ArgumentParser(description=__doc__.split('\n')[0])
ap.add_argument('--pid', type=int)
ap.add_argument('-o', '--out', default='players_fifa17.json')
ap.add_argument('--check', action='store_true',
help='assert playerId 20801 == Cristiano Ronaldo, 94')
ap.add_argument('--top', type=int, default=0)
args = ap.parse_args()
pid = args.pid or find_pid()
mem = Mem(pid)
maps = read_maps(pid)
sys.stderr.write("pid %d, %d readable regions\n" % (pid, len(maps)))
pools = find_name_pools(mem, maps)
if not pools:
raise SystemExit("no player-name pool found -- is the game past the "
"main menu with the player DB loaded?")
for lo, hi, n in pools:
sys.stderr.write("name pool 0x%x-0x%x %d name strings\n" % (lo, hi, n))
found = scan_entries(mem, maps, pools)
addrs, rows = build(found)
if not rows:
raise SystemExit("index table not found")
strides = Counter(addrs[i + 1] - addrs[i] for i in range(len(addrs) - 1))
sys.stderr.write("index table 0x%x-0x%x %d entries strides=%s\n"
% (addrs[0], addrs[-1], len(rows), strides.most_common(3)))
ranks = [r['rank'] for r in rows]
sys.stderr.write("rank %d..%d (%d unique) playerIds %d unique rating %d..%d\n"
% (min(ranks), max(ranks), len(set(ranks)),
len(set(r['playerId'] for r in rows)),
min(r['rating'] for r in rows),
max(r['rating'] for r in rows)))
if args.top:
for r in rows[:args.top]:
print('%6d %2d %s' % (r['playerId'], r['rating'],
'|'.join([r['firstName'], r['lastName'],
r['commonName']])))
return 0
by_id = {r['playerId']: r for r in rows}
ok = True
cr = by_id.get(RONALDO_ID)
if cr is None:
sys.stderr.write("ANCHOR FAIL: playerId %d absent\n" % RONALDO_ID)
ok = False
else:
got = '|'.join([cr['firstName'], cr['lastName'], cr['commonName']])
sys.stderr.write("anchor: playerId %d -> %r rating %d\n"
% (RONALDO_ID, got, cr['rating']))
if cr['rating'] != RONALDO_RATING or got != RONALDO_NAME:
sys.stderr.write("ANCHOR FAIL: expected %r / %d\n"
% (RONALDO_NAME, RONALDO_RATING))
ok = False
with open(args.out, 'w') as fh:
json.dump(rows, fh, ensure_ascii=False, indent=1)
sys.stderr.write("wrote %s (%d players)\n" % (args.out, len(rows)))
if args.check and not ok:
return 1
return 0
# STILL MISSING: position / nationality / teamId / the six attributes.
#
# They are NOT in the index table above. Two leads were located live and are
# recorded here so the next pass does not have to re-find them:
#
# (1) Materialised FUT card records. In the 40 MB heap region at 0x0b63b0000
# the squad's resolved cards sit at a 0x180 stride, e.g. 0x0b840c2c0 =
# Lewandowski and 0x0b840c440 = Luis Suarez. Layout relative to the record
# word at +0x00c (0xf0, 0xf1 -- consecutive, an index):
# +0x010..+0x02c six u32 attributes then two more u32
# Lewandowski: 77 88 75 82 42 82 | 99 90
# Suarez : 83 90 79 87 42 80 | 99 92
# the last u32 is the overall rating (90 / 92, both correct for
# FIFA 17), the 99 is constant across both.
# +0x030 char[16] firstName ("Robert", "Luis")
# +0x040 char[16] lastName ("Lewandowski", "Suárez")
# Only ~a hundred of these exist process-wide -- they are built per card
# that the client actually materialises, not a table. So this is a
# VERIFICATION oracle for attribute values, not a bulk source.
#
# (2) A 32-byte-stride keyed table in the 238 MB heap region 0x37440000..,
# seen at 0x42e8dbe8, carrying u64 fields keyed by playerId:
# 20801 -> 27, 94, 77
# 41236 -> 25, 90, 80 (41236 = Zlatan Ibrahimovic, rating 90)
# The rating column is right in both rows; the 27/25 and 77/80 columns were
# NOT identified. A 32-bit hash is interleaved in the high dword of a
# rotating slot, so it is a hash container, not a flat array. Worth one
# focused pass.
#
# Neither of these was pushed to completion in the session that wrote this file.
# Do not cite them as resolved.
if __name__ == '__main__':
sys.exit(main())
+73
View File
@@ -0,0 +1,73 @@
/* dbdata_probe.c -- call FIFA 17's dbdata.dll!getTableData and dump what it returns.
*
* FINDING (2026-08-04): dbdata.dll is NOT a player database. Its single export
* `getTableData` is an ANTI-TAMPER ATTESTATION function whose name is a decoy.
* Signature resolved empirically (crash-matrix over the 4 Win64 register args):
*
* const char * __cdecl getTableData(int *pOutBase64Len);
*
* It returns a heap-allocated, NUL-terminated base64url string:
* dbdata.dll -> 1012 chars (759 bytes decoded) md5 59b46dce231e419f4c1effbd8024e5ae
* dbdataEA.dll -> 1004 chars (753 bytes decoded) md5 8e10d1c4ce5ca54974aad42a10f05de0
* Deterministic across calls and across processes. There is no table selector
* argument; args 2..4 are ignored.
*
* What it actually does (Wine +relay trace):
* CommandLineToArgvW(GetCommandLineW())
* for each argv: StrStrW(argv[i], L"/antitamperdiagnosis")
* GetModuleHandleExA(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS, &getTableData)
* GetModuleFileNameA -> CreateFileW(own .dll, GENERIC_READ)
* ReadFile(0x28f000) <- whole file into a heap buffer
* VirtualAlloc(1MB, PAGE_EXECUTE_READWRITE) <- unpack scratch, freed before return
* SetFilePointerEx(0x28e200); ReadFile(0x200) <- Authenticode Security Directory
* -> 759-byte attestation blob, base64url encoded
*
* Flip one byte inside the .xdata payload and it raises 0xC0000096
* (PRIVILEGED_INSTRUCTION) at image+0x3C0DF instead of returning.
*
* Build + run:
* x86_64-w64-mingw32-gcc -O0 -o dbdata_probe.exe dbdata_probe.c
* cp "/mnt/games/FIFA 17/dbdata.dll" . # must sit next to the exe
* WINEDEBUG=-all wine dbdata_probe.exe dbdata.dll 1 out
* Add WINEDEBUG=+relay to re-derive the call sequence above.
*/
#include <windows.h>
#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>
typedef void *(*F4)(uint64_t, uint64_t, uint64_t, uint64_t);
static LONG CALLBACK veh(EXCEPTION_POINTERS *ep) {
fprintf(stderr, "!! EXCEPTION 0x%08lx rip=%p addr=%p\n",
(unsigned long)ep->ExceptionRecord->ExceptionCode,
(void *)ep->ContextRecord->Rip, ep->ExceptionRecord->ExceptionAddress);
fflush(stderr);
ExitProcess(9);
return EXCEPTION_CONTINUE_SEARCH;
}
int main(int argc, char **argv) {
AddVectoredExceptionHandler(1, veh);
const char *dllname = (argc > 1) ? argv[1] : "dbdata.dll";
int iters = (argc > 2) ? atoi(argv[2]) : 1;
const char *pfx = (argc > 3) ? argv[3] : "gtd";
HMODULE h = LoadLibraryA(dllname);
if (!h) { printf("LoadLibraryA(%s) failed err=%lu\n", dllname, GetLastError()); return 1; }
F4 f = (F4)GetProcAddress(h, "getTableData");
printf("base=%p getTableData=%p (rva 0x%llx)\n", (void *)h, (void *)f,
(unsigned long long)((uintptr_t)f - (uintptr_t)h));
if (!f) return 1;
for (int i = 0; i < iters; i++) {
volatile int len = -1;
void *r = f((uint64_t)(uintptr_t)&len, 0, 0, 0);
printf("call %d: ret=%p len=%d\n", i, r, len);
if (!r || len <= 0) continue;
char fn[256]; sprintf(fn, "%s_%d.bin", pfx, i);
FILE *fp = fopen(fn, "wb");
fwrite(r, 1, (size_t)len, fp);
fclose(fp);
printf(" wrote %s (%d bytes of base64url)\n", fn, len);
}
return 0;
}
+175
View File
@@ -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()
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
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
+12
View File
@@ -0,0 +1,12 @@
{
"club_abbr": "OFC",
"club_name": "OpenFUT",
"established": "2026",
"persona_id": 33068179,
"persona_name": "CAGE",
"pow_exp": 450,
"pow_exp_max": 1000,
"pow_funds": 2500,
"pow_funds_cap": 100000,
"pow_level": 12
}
File diff suppressed because it is too large Load Diff
+541
View File
@@ -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())
+15 -8
View File
@@ -21,12 +21,19 @@ Static gate methods (CardsDLL image base 0x180000000), patched to `mov eax,1; re
import sys, glob, os
IMG_BASE = 0x180000000
GATES = {
0x1800f7fb0: "IS_EASTORE_SERVICE_READY",
0x1800fb850: "IS_STORE_ENABLED",
0x180100500: "IS_COIN_PURCHASABLE",
}
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"
@@ -53,7 +60,7 @@ def main():
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 in GATES.items():
for va, (name, patch_bytes) in PATCHES.items():
live = base + (va - IMG_BASE)
origf = f'/tmp/orig_{live:x}.bin'
if restore:
@@ -62,9 +69,9 @@ def main():
data = open(origf, 'rb').read()
else:
with open(mem, 'rb') as f:
f.seek(live); orig = f.read(len(RET_TRUE))
f.seek(live); orig = f.read(len(patch_bytes))
open(origf, 'wb').write(orig)
data = RET_TRUE
data = patch_bytes
with open(mem, 'r+b') as f:
f.seek(live); f.write(data)
f.seek(live); chk = f.read(len(data))
+136
View File
@@ -0,0 +1,136 @@
#!/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 main():
ap = argparse.ArgumentParser()
ap.add_argument("--show", action="store_true")
a = ap.parse_args()
db = load()
if a.show:
show(db)
return 0
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())
+64 -13
View File
@@ -1297,23 +1297,74 @@ def sweep_window():
return _ID_SWEEP
def sweep_items():
"""Synthetic club contents for one id-sweep window."""
win = sweep_window()
# Auto-advance state. The client PAGES the club -- one visit produced seven
# GET /club?...start=50&count=11 fetches -- so an auto window can hand out a new
# chunk on every fetch and cover ~7 chunks per visit instead of one. Item ids are
# derived from the candidate's OFFSET IN THE WHOLE RANGE, not from its index in
# the chunk, so chunks never collide in the map and results ACCUMULATE across
# fetches; one probe at the end reads them all.
_SWEEP_SPEC = None
_SWEEP_POS = 0
def _parse_window(win):
"""'lo-hi' or 'auto:lo-hi:step' -> (lo, hi, step or None). None on garbage."""
auto = win.startswith("auto:")
step = None
if auto:
parts = win[5:].split(":")
rng = parts[0]
if len(parts) > 1:
step = int(parts[1], 0)
else:
rng = win
try:
lo, hi = (int(x, 0) for x in win.split("-", 1))
lo, hi = (int(x, 0) for x in rng.split("-", 1))
except Exception:
log(" SWEEP: bad window %r, want '<lo>-<hi>'" % win)
return []
return None
if hi < lo:
lo, hi = hi, lo
out = []
for i, pid in enumerate(range(lo, hi + 1)):
it = _item(SWEEP_ID_BASE + i, pid, SWEEP_SENTINEL_RATING,
"ST", 0, 0, 0, [1, 1, 1, 1, 1, 1])
out.append(it)
log(" SWEEP: serving %d candidate playerid(s) %d..%d as the club "
"[synthetic, nothing saved]" % (len(out), lo, hi))
if auto and not step:
step = 5000
return lo, hi, step
def sweep_items():
"""Synthetic club contents for the current sweep window.
5000 candidates per response is live-proven. 20000 was served fine and then
silently NOT ingested -- the map did not change at all -- so there is a
ceiling between the two. Auto chunks therefore default to 5000, and a chunk
that is not ingested costs one fetch, not the sweep.
"""
global _SWEEP_SPEC, _SWEEP_POS
win = sweep_window()
parsed = _parse_window(win)
if not parsed:
log(" SWEEP: bad window %r, want '<lo>-<hi>' or 'auto:<lo>-<hi>:<step>'" % win)
return []
lo, hi, step = parsed
if win != _SWEEP_SPEC: # re-aimed: restart the walk
_SWEEP_SPEC, _SWEEP_POS = win, 0
if step:
start = lo + _SWEEP_POS
if start > hi:
log(" SWEEP: range %d..%d EXHAUSTED -- probe now, then re-aim" % (lo, hi))
return []
end = min(start + step - 1, hi)
_SWEEP_POS += step
else:
start, end = lo, hi
out = [_item(SWEEP_ID_BASE + (pid - lo), pid, SWEEP_SENTINEL_RATING,
"ST", 0, 0, 0, [1, 1, 1, 1, 1, 1])
for pid in range(start, end + 1)]
log(" SWEEP: serving %d candidate playerid(s) %d..%d%s "
"[synthetic, nothing saved]"
% (len(out), start, end,
(" (auto, %d..%d done)" % (lo, end)) if step else ""))
return out
+590
View File
@@ -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())
+82
View File
@@ -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")