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:
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,736 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""THE CARD RECORD PROOF -- a reversible, differential, live DATA write.
|
||||
|
||||
*** THIS WRITES TO A RUNNING FIFA17.exe. IT REFUSES TO WRITE WITHOUT --fire. ***
|
||||
*** A HUMAN DECIDES WHEN TO FIRE IT. Read OUTCOMES at the bottom first. ***
|
||||
|
||||
==========================================================================
|
||||
1. WHY THIS IS NOT "PATCH THE MISS PATH", WHICH IS WHAT WAS ASKED FOR
|
||||
==========================================================================
|
||||
docs/CARD_SYSTEM.md "Option C" says: patch the miss branch of the lookup
|
||||
0x18011cca0 so that a miss emits a fixed real record. That experiment cannot
|
||||
be built, because BOTH halves of its premise are false. Verified this session
|
||||
against /mnt/games/FIFA 17/CardsDLL_Win64_retail.dll (freshly imported and
|
||||
analysed; image base 0x180000000):
|
||||
|
||||
FUN_18011cca0 -- FULL decompile 1664 chars, FULL disassembly 84 of 84
|
||||
instructions, complete coverage, both written to disk and read end to end:
|
||||
|
||||
FUN_18011cca0(CardsDb, item, parsed_record)
|
||||
key = *(parsed_record + 8)
|
||||
if key == 0: unlink `item` from the observer list at item+0x10; return
|
||||
walk the RB-tree at CardsDb+0x160c0 (root +0x160d8, header +0x160c8,
|
||||
node key +0x20, record +0x28)
|
||||
if MISS: node = FUN_180115c30(...) <-- INSERTS a fresh node
|
||||
FUN_1800515e0(node+0x28, parsed_record) <-- record := parsed_record
|
||||
FUN_1800419b0(item, node+0x28) <-- item+0x10 = &record
|
||||
|
||||
There is no blank-default record and no miss-emit path. A miss ALLOCATES a
|
||||
node (FUN_1801155f0 -> record ctor 0x180041250) and the very next call
|
||||
overwrites that record wholesale from the parsed item. The map is therefore
|
||||
NOT empty offline: it gains one node per parsed item.
|
||||
|
||||
And the actual blank card does not come from this lookup at all -- see 2.
|
||||
|
||||
That is better news than the plan assumed: the thing we want to prove is
|
||||
reachable as a plain DATA write into an existing live buffer. No instruction
|
||||
patching anywhere, and the restore is just writing the old bytes back.
|
||||
|
||||
==========================================================================
|
||||
2. WHERE THE BLANK CARD REALLY COMES FROM (this is the real find)
|
||||
==========================================================================
|
||||
The item parser FUN_18013fe00 builds a record on its own stack at RBP+0x160,
|
||||
then does two things in this order:
|
||||
|
||||
0x180141020 CALL 0x180141660 <-- LOCAL-DB MERGE (first)
|
||||
0x180141176 CALL [R9+0xa08] <-- the map lookup (second)
|
||||
|
||||
FUN_180141660 switches on record+0x4c (the card TYPE) and queries FIFA's OWN
|
||||
local card database:
|
||||
case 1 -> FUN_180135890 players
|
||||
case 2 -> FUN_1801356c0
|
||||
case 3 -> "headcoachcards" case 4 -> "fitnesscoachcards"
|
||||
case 5 -> "gkcoachcards" case 10 -> "physiocards"
|
||||
|
||||
FUN_180135890 is the player path (decompile 7425 chars). It reads
|
||||
assetId = *(record + 0x18) & 0xFFFFFF
|
||||
and runs SELECT ... FROM players WHERE playerid = assetId.
|
||||
|
||||
ON A DATABASE MISS it stamps, verbatim:
|
||||
record+0xb4 = 0x32 (= 50) <-- "rating 50"
|
||||
record+0x98..+0xac = 1,1,1,1,1,1 <-- "every attribute 1"
|
||||
record+0x146 = 2 <-- the position rendered as RWB
|
||||
record+0x148 = 0xe (nation 14)
|
||||
record+0x94 = 0x78d (team 1933)
|
||||
record+0xdd = DAT_1801eaf98 = " " <-- a single space: no name
|
||||
record+0xb8 = " " (firstname buffer)
|
||||
|
||||
ON A HIT it writes the name from the local DB:
|
||||
FUN_1800081b0(record+0xdd, <name>, 0x1f)
|
||||
FUN_1800081b0(record+0xb8, <firstname>, 0x10)
|
||||
and it fills nation/team/league ONLY IF they are still zero. It does NOT
|
||||
touch record+0xb4 or record+0x98..+0xac on a hit.
|
||||
|
||||
That single fact explains the whole 2026-08-04 REFUTED observation in
|
||||
docs/CARD_SYSTEM.md, exactly, with no residue:
|
||||
* SILVA / NOWAK resolved because their assetIds ARE in the local players
|
||||
table -> name came from FIFA's DB, and rating + the six hand-invented
|
||||
attributes survived from OUR JSON because the HIT path never overwrites
|
||||
them. That is why invented numbers appeared on screen.
|
||||
* The three blanks were assetIds NOT in the players table -> the MISS path
|
||||
stamped 50 / all-ones / RWB / no name over everything we sent.
|
||||
|
||||
It also answers the "TODO/CONFIRM" left at the end of that document
|
||||
(does a resolved card's rating come from our JSON?) statically: YES on a DB
|
||||
hit, because the merge's hit path contains no write to record+0xb4.
|
||||
|
||||
==========================================================================
|
||||
3. WHAT THIS SCRIPT ACTUALLY DOES
|
||||
==========================================================================
|
||||
1. finds FIFA17.exe and CardsDLL's live base from /proc/PID/maps
|
||||
2. reads the CardsDb singleton (static slot 0x1802e6398)
|
||||
3. walks the std::map at CardsDb+0x160c0 and decodes every record through
|
||||
the card view-model's OWN offsets, flagging which records carry the
|
||||
DB-MISS signature from section 2. Read-only; always runs; this census
|
||||
alone is worth the trip.
|
||||
4. self-check: for every record, follow record+0x00 (the observer-list head)
|
||||
to an item and confirm item+0x10 points back at that record. This is
|
||||
what makes a null result interpretable instead of ambiguous.
|
||||
5. with --fire: writes a DIFFERENTIAL beacon into TWO records -- flavour A
|
||||
into one, flavour B into another -- and deliberately leaves every other
|
||||
record alone as a negative control. Backups are written to disk BEFORE
|
||||
any process memory is touched.
|
||||
6. --restore <manifest.json> puts the original bytes back.
|
||||
|
||||
Reversibility: the full 0x158-byte record is snapshotted to <backup>.bin, and
|
||||
the manifest records every (offset, original bytes, new bytes). --restore
|
||||
rewrites ONLY the ranges we wrote, and only where our beacon is still present
|
||||
-- never the whole record, because the record also carries live intrusive-list
|
||||
pointers that legitimately change between patch and restore. Killing FIFA also
|
||||
clears everything: this is live memory only, nothing is persisted in the game.
|
||||
|
||||
Needs ptrace access: tools/root_arm.sh (kernel.yama.ptrace_scope=0)
|
||||
|
||||
USAGE
|
||||
python3 tools/card_proof.py # read-only census
|
||||
python3 tools/card_proof.py --fire --a <id> --b <id>
|
||||
python3 tools/card_proof.py --restore /tmp/openfut_cardproof_<...>.json
|
||||
|
||||
==========================================================================
|
||||
4. EVERY ADDRESS BELOW WAS RESOLVED THIS SESSION
|
||||
==========================================================================
|
||||
0x1802e6398 CardsDb singleton slot. Getter FUN_18011a830 is 2 instructions
|
||||
and returns DAT_1802e6398. Six xrefs total to the slot.
|
||||
0x18021c2a0 CardsDb vtable; the qword 0x18011cca0 occurs EXACTLY ONCE in
|
||||
the whole image, at 0x18021cca8 = 0x18021c2a0 + 0xa08.
|
||||
CardsDb+0x160c0 std::map; +0x160c8 header node, +0x160d8 root, +0x160e8 size
|
||||
node: left +0x00, right +0x08, parent +0x10, colour +0x18,
|
||||
key(item id) +0x20, record +0x28
|
||||
record size 0x158. ctor 0x180041250 zeroes explicitly through +0xb7 then
|
||||
memset(+0xb8, 0, 0xa0) -> 0xb8+0xa0 = 0x158. Its non-zero defaults
|
||||
are +0x48 word 0x100, +0x4c dword 0xffffffff, +0x50 qword 0x156,
|
||||
+0x70 = &PTR_LAB_1801eaac0. Rating/attrs/position/nation default to
|
||||
ZERO -- the 50/1/RWB blank is the DB-miss stamp, not the ctor.
|
||||
assignment operator 0x1800515e0 copies +0x08 .. +0x157 and re-splices the
|
||||
intrusive list at +0x70/+0x78/+0x80. It never touches +0x00.
|
||||
record+0x00 is the observer-list head; item+0x08 is the intrusive next;
|
||||
item+0x10 is the record pointer (FUN_1800419b0). So the view-model's
|
||||
*(item+0x10) is exactly node+0x28.
|
||||
|
||||
Card view-model FUN_1800d7920 -- full decompile 1843 chars, 77 instructions,
|
||||
complete coverage. rec = *(item+0x10):
|
||||
out[0] = dword rec+0x18 (resourceId)
|
||||
out[1] = dword rec+0x18 & 0xFFFFFF (assetId)
|
||||
out[2] = dword rec+0x94 (teamid)
|
||||
out[3] = word rec+0x148 (nation)
|
||||
out[4] = byte rec+0xb4 (RATING)
|
||||
out[5] = byte rec+0x146 (position)
|
||||
out[6] = dword rec+0x88 (league)
|
||||
out[7] = dword rec+0x58
|
||||
out+0x20 bool = (rec+0xb5 != 0) && (rec+0xb6 == 0)
|
||||
out+0x21 bool = (int)rec+0x90 > 0
|
||||
out+0x22 = 0x20 bytes from rec+0xdd, BUT if strlen(rec+0xdd)==0
|
||||
it takes them from rec+0xc8 instead
|
||||
out+0x42..47 = the six attributes read as SINGLE BYTES from the
|
||||
dword slots rec+0x98,+0x9c,+0xa0,+0xa4,+0xa8,+0xac
|
||||
Note the last one: the attributes are byte-truncated on read, so any value
|
||||
over 255 wraps. Earlier notes described these as dwords, which is true of
|
||||
the storage but not of the render.
|
||||
|
||||
The THREE name fields, all written by FUN_180135890 from the local players
|
||||
table (Ghidra prints 0xc8 as decimal 200, which is why an earlier grep
|
||||
missed the middle one):
|
||||
FUN_1800081b0(rec+0xb8, firstname, 0x10)
|
||||
FUN_1800081b0(rec+0xc8, lastname, 0x15)
|
||||
FUN_1800081b0(rec+0xdd, knownAs, 0x1f)
|
||||
and on a DB miss all three get DAT_1801eaf98 = " " (one space). Because a
|
||||
space is not an empty string, the view-model's strlen(rec+0xdd) test passes
|
||||
and a blank card renders a SPACE rather than falling back to +0xc8.
|
||||
|
||||
==========================================================================
|
||||
5. OUTCOMES -- what each result proves. READ THIS BEFORE FIRING.
|
||||
==========================================================================
|
||||
Confirmed read-only, live, before any write was contemplated: 164 records,
|
||||
map size field agreed with the walk, and all 164 backlinks resolved
|
||||
(record+0x00 -> item -> +0x10 -> that same record). Records held real data:
|
||||
assetId 20801, rating 94, attrs [90,93,82,91,33,80], firstname "Cristiano",
|
||||
lastname "Ronaldo", knownAs "". So the record-offset model and the address
|
||||
chain are already confirmed as a MEMORY model. What the write still buys is
|
||||
the RENDER link: proof that the view-model re-reads this buffer and that what
|
||||
it reads reaches the screen.
|
||||
|
||||
BOTH cards change, each to its own flavour, third card unchanged
|
||||
Total success. The record is the single source of truth for the card
|
||||
face, the view-model re-reads it per redraw, and per-record resolution
|
||||
works. Combined with section 2 this closes the card problem entirely:
|
||||
it is a DATA problem (ship assetIds that exist in FIFA's players table)
|
||||
and no injection is ever needed in production.
|
||||
|
||||
Numbers change but the NAME does not
|
||||
The most likely partial, and the informative one. It means the record
|
||||
layout is right and the view-model re-read it, but the name string was
|
||||
resolved once and cached above the view-model -- the 0x20 bytes it
|
||||
copies to out+0x22 land in a struct that a hover-redraw does not
|
||||
rebuild. Verdict: rating/attrs/nation/team are live-patchable, the
|
||||
name is not, and any name work must go through the assetId -> players
|
||||
table route rather than through this buffer.
|
||||
|
||||
The NAME changes but the numbers do not
|
||||
Would mean we patched a record that is not the one driving those
|
||||
pixels, i.e. two records exist for one card. Re-run the census and
|
||||
check for a second node with the same assetId. Do not conclude
|
||||
anything about layout from this; conclude the target was wrong.
|
||||
|
||||
NEITHER card changes, but the backlink check passed
|
||||
The redraw did not call FUN_1800d7920 at all. This is a redraw
|
||||
problem, not a model problem. Escalate the redraw (open Player
|
||||
Details) but NOT by switching tabs -- a tab switch refetches, the
|
||||
parser rebuilds the record, the merge re-stamps it and the copy inside
|
||||
0x18011cca0 overwrites the beacon, which would look identical to a
|
||||
failure and would be a false negative.
|
||||
|
||||
Both cards change to the SAME values
|
||||
The view-model is reading one shared record for every card. That would
|
||||
falsify per-record resolution and is the one outcome that would send us
|
||||
back to the resolve path. This is precisely why the beacon is
|
||||
differential and why a third card is left untouched.
|
||||
|
||||
A field changes on screen to something OTHER than the beacon
|
||||
Read it as an enum decode, not as a failure: position 0 and 1 and
|
||||
nation 38 and 14 are deliberately valid values, so the on-screen label
|
||||
tells us the enum mapping. Position 2 is already known to render as
|
||||
RWB, from the DB-miss stamp.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import struct
|
||||
import sys
|
||||
import time
|
||||
|
||||
# ------------------------------------------------------------------ constants
|
||||
IMG_BASE = 0x180000000
|
||||
DLL = "CardsDLL"
|
||||
|
||||
G_CARDSDB = 0x1802E6398
|
||||
|
||||
MAP_BASE = 0x160C0
|
||||
MAP_HEADER = 0x160C8
|
||||
MAP_ROOT = 0x160D8
|
||||
MAP_SIZE = 0x160E8
|
||||
|
||||
NODE_L, NODE_R, NODE_KEY, NODE_REC = 0x00, 0x08, 0x20, 0x28
|
||||
REC_SIZE = 0x158
|
||||
|
||||
MAX_NODES = 100000
|
||||
|
||||
# Byte ranges inside the record that are POINTERS or the map key.
|
||||
# 0x00..0x08 observer-list head (FUN_1800419b0 splices items onto it)
|
||||
# 0x08..0x10 the map key; changing it desynchronises node+0x20 and the tree
|
||||
# 0x70..0x88 the embedded intrusive-list node the assignment operator
|
||||
# re-splices rather than copies
|
||||
# Every write is checked against this and the script dies rather than proceed.
|
||||
FORBIDDEN = ((0x00, 0x10), (0x70, 0x88))
|
||||
|
||||
# The exact stamp FUN_180135890 writes when `players WHERE playerid=assetId`
|
||||
# returns nothing. A record matching this is a card that rendered blank.
|
||||
DB_MISS = {
|
||||
0x0B4: ("u8", 0x32),
|
||||
0x098: ("u32", 1), 0x09C: ("u32", 1), 0x0A0: ("u32", 1),
|
||||
0x0A4: ("u32", 1), 0x0A8: ("u32", 1), 0x0AC: ("u32", 1),
|
||||
0x146: ("u8", 2),
|
||||
0x148: ("u16", 0xE),
|
||||
0x094: ("u32", 0x78D),
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------- beacons --
|
||||
# TWO flavours, deliberately different in EVERY field. One record gets A, a
|
||||
# second gets B, and every other record is left untouched as a negative
|
||||
# control. A single fixed beacon cannot distinguish "the view-model re-read
|
||||
# our record" from "that card already looked like that"; two different ones,
|
||||
# plus an untouched third, can.
|
||||
#
|
||||
# resourceId (rec+0x18) is deliberately NOT in the beacon. It is the key the
|
||||
# local DB query and any art/face lookup use, so changing it would confound
|
||||
# the very thing we are measuring. --also-resourceid is a separate, later
|
||||
# pass, one variable at a time.
|
||||
#
|
||||
# Values are chosen so each is unmistakable in a screenshot:
|
||||
# rating 99 / 11 -- no card in our pool is either
|
||||
# attrs 11..66 / 66..11 -- also reveals the on-card ORDER of the six
|
||||
# name pure ASCII, cannot come from FIFA's own player DB
|
||||
BEACONS = {
|
||||
"A": [
|
||||
(0x094, "u32", "teamid", 243),
|
||||
(0x098, "u32", "attr0", 11),
|
||||
(0x09C, "u32", "attr1", 22),
|
||||
(0x0A0, "u32", "attr2", 33),
|
||||
(0x0A4, "u32", "attr3", 44),
|
||||
(0x0A8, "u32", "attr4", 55),
|
||||
(0x0AC, "u32", "attr5", 66),
|
||||
(0x0B4, "u8", "RATING", 99),
|
||||
(0x146, "u8", "position enum", 0),
|
||||
(0x148, "u16", "nation", 38),
|
||||
(0x0DD, "str", "NAME (vm source)", "OPENFUT-A"),
|
||||
],
|
||||
"B": [
|
||||
(0x094, "u32", "teamid", 9),
|
||||
(0x098, "u32", "attr0", 66),
|
||||
(0x09C, "u32", "attr1", 55),
|
||||
(0x0A0, "u32", "attr2", 44),
|
||||
(0x0A4, "u32", "attr3", 33),
|
||||
(0x0A8, "u32", "attr4", 22),
|
||||
(0x0AC, "u32", "attr5", 11),
|
||||
(0x0B4, "u8", "RATING", 11),
|
||||
(0x146, "u8", "position enum", 1),
|
||||
(0x148, "u16", "nation", 14),
|
||||
(0x0DD, "str", "NAME (vm source)", "OPENFUT-B"),
|
||||
],
|
||||
}
|
||||
|
||||
# The game itself writes the name with FUN_1800081b0(rec+0xdd, src, 0x1f), so
|
||||
# 0x1f is the length the record is built for. We never exceed it.
|
||||
NAME_MAX = 0x1F
|
||||
|
||||
KIND_LEN = {"u8": 1, "u16": 2, "u32": 4}
|
||||
|
||||
|
||||
def encode(kind, value):
|
||||
if kind == "u8":
|
||||
return struct.pack("<B", value & 0xFF)
|
||||
if kind == "u16":
|
||||
return struct.pack("<H", value & 0xFFFF)
|
||||
if kind == "u32":
|
||||
return struct.pack("<I", value & 0xFFFFFFFF)
|
||||
if kind == "str":
|
||||
b = value.encode("ascii", "replace")
|
||||
if len(b) >= NAME_MAX:
|
||||
raise SystemExit("REFUSING: name %r is %d bytes, max %d"
|
||||
% (value, len(b), NAME_MAX - 1))
|
||||
return b + b"\0"
|
||||
raise ValueError(kind)
|
||||
|
||||
|
||||
def check_write(off, length):
|
||||
"""Die unless [off, off+length) is a safe scalar range inside the record."""
|
||||
if off < 0 or off + length > REC_SIZE:
|
||||
raise SystemExit("REFUSING: write %#x..%#x is outside the record (size %#x)"
|
||||
% (off, off + length, REC_SIZE))
|
||||
for lo, hi in FORBIDDEN:
|
||||
if off < hi and lo < off + length:
|
||||
raise SystemExit(
|
||||
"REFUSING: write %#x..%#x overlaps pointer/key range %#x..%#x"
|
||||
% (off, off + length, lo, hi))
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ process --
|
||||
def find_pid():
|
||||
for d in glob.glob("/proc/[0-9]*"):
|
||||
try:
|
||||
if open(d + "/comm").read().strip() == "FIFA17.exe":
|
||||
return int(d.rsplit("/", 1)[-1])
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def dll_base(pid, name=DLL):
|
||||
lo = None
|
||||
try:
|
||||
for line in open("/proc/%d/maps" % pid):
|
||||
if name in line:
|
||||
a = int(line.split("-")[0], 16)
|
||||
lo = a if lo is None else min(lo, a)
|
||||
except Exception:
|
||||
return None
|
||||
return lo
|
||||
|
||||
|
||||
class Mem(object):
|
||||
def __init__(self, pid, writable=False):
|
||||
self.pid = pid
|
||||
self.f = open("/proc/%d/mem" % pid, "r+b" if writable else "rb", buffering=0)
|
||||
self.writable = writable
|
||||
|
||||
def read(self, va, n):
|
||||
self.f.seek(va)
|
||||
b = self.f.read(n)
|
||||
if b is None or len(b) != n:
|
||||
raise IOError("short read at %#x" % va)
|
||||
return b
|
||||
|
||||
def try_read(self, va, n):
|
||||
try:
|
||||
return self.read(va, n)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def write(self, va, data):
|
||||
if not self.writable:
|
||||
raise RuntimeError("Mem opened read-only")
|
||||
self.f.seek(va)
|
||||
self.f.write(data)
|
||||
|
||||
def q(self, va):
|
||||
b = self.try_read(va, 8)
|
||||
return struct.unpack("<Q", b)[0] if b else None
|
||||
|
||||
def i32(self, va):
|
||||
b = self.try_read(va, 4)
|
||||
return struct.unpack("<i", b)[0] if b else None
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- the map --
|
||||
def walk(mem, cdb):
|
||||
"""[(node_addr, key)] for every node in CardsDb's item map.
|
||||
|
||||
Generic DFS over both child slots with a visited set: the left/right
|
||||
convention does not matter for a census, and a garbage pointer terminates
|
||||
the walk instead of hanging it.
|
||||
"""
|
||||
header = cdb + MAP_HEADER
|
||||
root = mem.q(cdb + MAP_ROOT)
|
||||
if root is None:
|
||||
return None
|
||||
if root == 0 or root == header:
|
||||
return []
|
||||
out, seen, stack = [], set(), [root]
|
||||
while stack and len(out) < MAX_NODES:
|
||||
p = stack.pop()
|
||||
if not p or p == header or p in seen or (p & 7):
|
||||
continue
|
||||
seen.add(p)
|
||||
k = mem.q(p + NODE_KEY)
|
||||
if k is None:
|
||||
continue
|
||||
out.append((p, k))
|
||||
for slot in (NODE_L, NODE_R):
|
||||
c = mem.q(p + slot)
|
||||
if c and c != header and c not in seen:
|
||||
stack.append(c)
|
||||
out.sort(key=lambda t: t[1])
|
||||
return out
|
||||
|
||||
|
||||
def decode_record(buf):
|
||||
"""Decode a 0x158-byte record through the view-model's OWN offsets."""
|
||||
u8 = lambda o: buf[o]
|
||||
u16 = lambda o: struct.unpack_from("<H", buf, o)[0]
|
||||
u32 = lambda o: struct.unpack_from("<I", buf, o)[0]
|
||||
i32 = lambda o: struct.unpack_from("<i", buf, o)[0]
|
||||
|
||||
def s(o, n):
|
||||
raw = bytes(buf[o:o + n])
|
||||
z = raw.find(b"\0")
|
||||
return (raw[:z] if z >= 0 else raw).decode("ascii", "replace")
|
||||
|
||||
return {
|
||||
"id(+0x08)": struct.unpack_from("<Q", buf, 0x08)[0],
|
||||
"resourceId(+0x18)": u32(0x18),
|
||||
"assetId(low24)": u32(0x18) & 0xFFFFFF,
|
||||
"cardType(+0x4c)": i32(0x4C),
|
||||
"league(+0x88)": u32(0x88),
|
||||
"teamid(+0x94)": u32(0x94),
|
||||
"attrs(+0x98..ac)": [u8(0x98 + 4 * i) for i in range(6)],
|
||||
"rating(+0xb4)": u8(0xB4),
|
||||
"name(+0xdd)": s(0xDD, NAME_MAX),
|
||||
"firstname(+0xb8)": s(0xB8, 0x10),
|
||||
"fallback(+0xc8)": s(0xC8, 0x15),
|
||||
"position(+0x146)": u8(0x146),
|
||||
"nation(+0x148)": u16(0x148),
|
||||
"observers(+0x00)": struct.unpack_from("<Q", buf, 0x00)[0],
|
||||
}
|
||||
|
||||
|
||||
def is_db_miss(buf):
|
||||
"""True if this record carries FUN_180135890's players-table MISS stamp."""
|
||||
for off, (kind, want) in DB_MISS.items():
|
||||
n = KIND_LEN[kind]
|
||||
got = int.from_bytes(bytes(buf[off:off + n]), "little")
|
||||
if got != want:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def backlink_ok(mem, rec):
|
||||
"""Follow record+0x00 (observer head) -> item, check item+0x10 == rec.
|
||||
|
||||
This is the check that makes a null result interpretable: if it holds, the
|
||||
record we are about to patch really is the one the view-model dereferences.
|
||||
Returns (verdict_string, item_addr_or_None).
|
||||
"""
|
||||
head = mem.q(rec + 0x00)
|
||||
if head is None:
|
||||
return ("record unreadable", None)
|
||||
if head == 0:
|
||||
return ("no observer -- this record is not bound to a rendered item", None)
|
||||
back = mem.q(head + 0x10)
|
||||
if back is None:
|
||||
return ("observer %#x unreadable" % head, head)
|
||||
if back == rec:
|
||||
return ("OK item %#x -> +0x10 -> this record" % head, head)
|
||||
return ("MISMATCH item %#x +0x10 = %#x, expected %#x" % (head, back, rec), head)
|
||||
|
||||
|
||||
def print_census(mem, cdb, nodes):
|
||||
size = mem.i32(cdb + MAP_SIZE)
|
||||
print(" CardsDb %#x" % cdb)
|
||||
print(" map base %#x (header %#x, root %#x)"
|
||||
% (cdb + MAP_BASE, cdb + MAP_HEADER, mem.q(cdb + MAP_ROOT) or 0))
|
||||
print(" map size field %s walked nodes %s"
|
||||
% (size, "unreadable" if nodes is None else len(nodes)))
|
||||
if nodes is None:
|
||||
print("\n TREE UNREADABLE. Nothing further can be said.")
|
||||
return []
|
||||
if size is not None and len(nodes) != size:
|
||||
print(" !! walk count != size field -- the WALK is wrong, not the game.")
|
||||
if not nodes:
|
||||
print("\n THE MAP IS EMPTY. No item has been parsed in this session yet.")
|
||||
print(" Enter the Squads tab (GET /squad/0) or open the club, then re-run.")
|
||||
return []
|
||||
blanks = []
|
||||
print()
|
||||
for node, key in nodes:
|
||||
rec = node + NODE_REC
|
||||
buf = mem.try_read(rec, REC_SIZE)
|
||||
if buf is None:
|
||||
print(" item id %-12d node %#x <record unreadable>" % (key, node))
|
||||
continue
|
||||
d = decode_record(buf)
|
||||
miss = is_db_miss(buf)
|
||||
if miss:
|
||||
blanks.append((key, node))
|
||||
print(" item id %-12d node %#x record %#x %s"
|
||||
% (key, node, rec, "<< DB-MISS BLANK" if miss else ""))
|
||||
print(" assetId %-9d rating %-4d pos %-4d nation %-5d team %-6d"
|
||||
% (d["assetId(low24)"], d["rating(+0xb4)"], d["position(+0x146)"],
|
||||
d["nation(+0x148)"], d["teamid(+0x94)"]))
|
||||
print(" attrs %-24s cardType %s"
|
||||
% (d["attrs(+0x98..ac)"], d["cardType(+0x4c)"]))
|
||||
print(" name(+0xdd) %-14r first(+0xb8) %-14r fallback(+0xc8) %r"
|
||||
% (d["name(+0xdd)"], d["firstname(+0xb8)"], d["fallback(+0xc8)"]))
|
||||
if not d["name(+0xdd)"]:
|
||||
print(" -> +0xdd is EMPTY, so the view-model renders the "
|
||||
"+0xc8 fallback instead")
|
||||
verdict, _ = backlink_ok(mem, rec)
|
||||
print(" backlink: %s" % verdict)
|
||||
print()
|
||||
print(" %d of %d records carry the players-table DB-MISS stamp"
|
||||
% (len(blanks), len(nodes)))
|
||||
if blanks:
|
||||
print(" blank item ids: %s" % ", ".join(str(k) for k, _ in blanks))
|
||||
print(" Those are the best patch targets: they are the cards that")
|
||||
print(" currently render generic, so ANY change is unambiguous.")
|
||||
else:
|
||||
print(" Every assetId in play resolved against FIFA's local players")
|
||||
print(" table, so there is no generic card to patch right now. Patch")
|
||||
print(" two RESOLVED cards instead: the beacon values are chosen so")
|
||||
print(" they cannot be confused with real ones, and doing it on a")
|
||||
print(" resolved card additionally answers the name question, because")
|
||||
print(" a resolved card is exactly the case where +0xdd is empty and")
|
||||
print(" the +0xc8 fallback is being rendered.")
|
||||
return blanks
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- patch --
|
||||
def plan_writes(snap, flavour):
|
||||
writes = []
|
||||
for off, kind, name, value in BEACONS[flavour]:
|
||||
new = encode(kind, value)
|
||||
check_write(off, len(new))
|
||||
writes.append({"off": off, "len": len(new), "name": name,
|
||||
"flavour": flavour,
|
||||
"orig_hex": snap[off:off + len(new)].hex(),
|
||||
"new_hex": new.hex()})
|
||||
return writes
|
||||
|
||||
|
||||
def do_patch(mem, nodes, targets, backup_path):
|
||||
"""targets = [(item_id, flavour), ...]"""
|
||||
chosen = []
|
||||
for item_id, flavour in targets:
|
||||
match = [(n, k) for n, k in nodes if k == item_id]
|
||||
if not match:
|
||||
raise SystemExit("item id %d is not in the map. Present: %s"
|
||||
% (item_id, ", ".join(str(k) for _, k in nodes[:20])))
|
||||
node, key = match[0]
|
||||
chosen.append((node, key, flavour))
|
||||
|
||||
manifest = {"tool": "card_proof.py",
|
||||
"ts": time.strftime("%Y-%m-%dT%H:%M:%S"),
|
||||
"pid": mem.pid, "records": []}
|
||||
|
||||
# --- snapshot EVERYTHING before touching the process ---------------------
|
||||
for node, key, flavour in chosen:
|
||||
rec = node + NODE_REC
|
||||
snap = mem.read(rec, REC_SIZE)
|
||||
binpath = "%s.item%d.bin" % (os.path.splitext(backup_path)[0], key)
|
||||
with open(binpath, "wb") as f:
|
||||
f.write(snap)
|
||||
manifest["records"].append({
|
||||
"item_id": key, "node": node, "record": rec,
|
||||
"flavour": flavour, "snapshot": binpath,
|
||||
"writes": plan_writes(snap, flavour),
|
||||
})
|
||||
with open(backup_path, "w") as f:
|
||||
json.dump(manifest, f, indent=2)
|
||||
print(" backup manifest: %s" % backup_path)
|
||||
for r in manifest["records"]:
|
||||
print(" %s (%d bytes)" % (r["snapshot"], REC_SIZE))
|
||||
|
||||
# --- only now do we write ------------------------------------------------
|
||||
ok = True
|
||||
for r in manifest["records"]:
|
||||
print("\n flavour %s -> item %d, record %#x"
|
||||
% (r["flavour"], r["item_id"], r["record"]))
|
||||
verdict, _ = backlink_ok(mem, r["record"])
|
||||
print(" backlink before write: %s" % verdict)
|
||||
for w in r["writes"]:
|
||||
mem.write(r["record"] + w["off"], bytes.fromhex(w["new_hex"]))
|
||||
back = mem.read(r["record"] + w["off"], w["len"]).hex()
|
||||
if back != w["new_hex"]:
|
||||
ok = False
|
||||
print(" %s +%#05x %-18s %s -> %s"
|
||||
% ("OK " if back == w["new_hex"] else "FAIL",
|
||||
w["off"], w["name"], w["orig_hex"], back))
|
||||
print()
|
||||
if not ok:
|
||||
print(" !! at least one write did not read back. STOP and restore.")
|
||||
return 1
|
||||
print(" Beacons in place. DO NOT switch tabs: a tab switch refetches")
|
||||
print(" /squad/0 or /club, the parser rebuilds the record, the local-DB")
|
||||
print(" merge re-stamps it and the copy at 0x18011cca0 overwrites ours.")
|
||||
print(" Force a REDRAW only: move the cursor onto and off the card, or")
|
||||
print(" open and close Player Details.")
|
||||
print(" Restore with: python3 %s --restore %s" % (sys.argv[0], backup_path))
|
||||
return 0
|
||||
|
||||
|
||||
def do_restore(path):
|
||||
with open(path) as f:
|
||||
m = json.load(f)
|
||||
pid = m["pid"]
|
||||
if not os.path.exists("/proc/%d" % pid):
|
||||
print("pid %d is gone -- FIFA restarted; the patch went with it "
|
||||
"(live memory only)." % pid)
|
||||
return 0
|
||||
if open("/proc/%d/comm" % pid).read().strip() != "FIFA17.exe":
|
||||
print("pid %d is no longer FIFA17.exe. REFUSING to write." % pid)
|
||||
return 1
|
||||
mem = Mem(pid, writable=True)
|
||||
for r in m["records"]:
|
||||
rec = r["record"]
|
||||
print("restoring item %d, record %#x (%d ranges)"
|
||||
% (r["item_id"], rec, len(r["writes"])))
|
||||
for w in r["writes"]:
|
||||
cur = mem.read(rec + w["off"], w["len"]).hex()
|
||||
if cur != w["new_hex"]:
|
||||
print(" SKIP +%#05x holds %s, not our beacon %s -- the game "
|
||||
"rewrote it; restoring would be wrong."
|
||||
% (w["off"], cur, w["new_hex"]))
|
||||
continue
|
||||
check_write(w["off"], w["len"])
|
||||
mem.write(rec + w["off"], bytes.fromhex(w["orig_hex"]))
|
||||
back = mem.read(rec + w["off"], w["len"]).hex()
|
||||
print(" %s +%#05x %-18s -> %s"
|
||||
% ("OK " if back == w["orig_hex"] else "FAIL",
|
||||
w["off"], w["name"], back))
|
||||
print("done.")
|
||||
return 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------- main --
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(
|
||||
description="Read (and with --fire, beacon-patch) live FUT card records.")
|
||||
ap.add_argument("--a", type=int, metavar="ITEMID",
|
||||
help="item id to receive beacon flavour A")
|
||||
ap.add_argument("--b", type=int, metavar="ITEMID",
|
||||
help="item id to receive beacon flavour B (differential)")
|
||||
ap.add_argument("--fire", action="store_true",
|
||||
help="REQUIRED to write anything. Without it this is read-only.")
|
||||
ap.add_argument("--restore", metavar="MANIFEST.json",
|
||||
help="undo a previous --fire using its backup manifest")
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.restore:
|
||||
return do_restore(args.restore)
|
||||
|
||||
pid = find_pid()
|
||||
if pid is None:
|
||||
print("FIFA17.exe is not running.")
|
||||
return 1
|
||||
base = dll_base(pid)
|
||||
if base is None:
|
||||
print("pid %d is running but %s is not mapped yet (reach the FUT hub first)."
|
||||
% (pid, DLL))
|
||||
return 1
|
||||
try:
|
||||
mem = Mem(pid, writable=bool(args.fire))
|
||||
except Exception as e:
|
||||
print("cannot open /proc/%d/mem: %s" % (pid, e))
|
||||
print("Need ptrace access: sudo sysctl -w kernel.yama.ptrace_scope=0"
|
||||
" (tools/root_arm.sh)")
|
||||
return 1
|
||||
|
||||
print("FIFA pid=%d %s base=%#x (static image base %#x)"
|
||||
% (pid, DLL, base, IMG_BASE))
|
||||
cdb = mem.q(base + (G_CARDSDB - IMG_BASE))
|
||||
if not cdb:
|
||||
print(" CardsDb singleton is NULL -- the FUT layer is not constructed yet.")
|
||||
return 1
|
||||
|
||||
nodes = walk(mem, cdb)
|
||||
blanks = print_census(mem, cdb, nodes)
|
||||
|
||||
if not args.fire:
|
||||
print("\nREAD-ONLY. Nothing was written.")
|
||||
print("To run the experiment pick two ids from above (ideally two")
|
||||
print("DB-MISS blanks) and add: --a <id> --b <id> --fire")
|
||||
return 0
|
||||
if not nodes:
|
||||
print("nothing to patch.")
|
||||
return 1
|
||||
if args.a is None or args.b is None:
|
||||
print("--fire needs BOTH --a <id> and --b <id>.")
|
||||
print("The differential is the point: one beacon cannot distinguish a")
|
||||
print("re-read from a coincidence, and a third untouched card is the")
|
||||
print("negative control.")
|
||||
if blanks:
|
||||
print("Suggested: --a %d --b %s"
|
||||
% (blanks[0][0],
|
||||
blanks[1][0] if len(blanks) > 1 else "<another id>"))
|
||||
return 1
|
||||
if args.a == args.b:
|
||||
print("--a and --b must be different records.")
|
||||
return 1
|
||||
|
||||
backup = "/tmp/openfut_cardproof_%d_%d.json" % (pid, int(time.time()))
|
||||
return do_patch(mem, nodes, [(args.a, "A"), (args.b, "B")], backup)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,459 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Validate the FUT card record-offset model END TO END, live, with a DATA write.
|
||||
|
||||
DO NOT RUN THIS WITHOUT READING THE "WHAT THIS ACTUALLY DOES" SECTION.
|
||||
It writes to a running FIFA17.exe. It refuses to write unless you pass --fire.
|
||||
|
||||
--------------------------------------------------------------------------
|
||||
WHY THIS IS NOT THE EXPERIMENT docs/CARD_SYSTEM.md ASKED FOR
|
||||
--------------------------------------------------------------------------
|
||||
CARD_SYSTEM.md "Option C" says: patch the miss branch of the lookup 0x18011cca0
|
||||
so a miss emits a fixed real record. That experiment cannot be built as written,
|
||||
because the premise is wrong. Re-read of the lookup this session (full decompile,
|
||||
1664 chars; full disassembly, 84 instructions -- both in the session scratchpad):
|
||||
|
||||
FUN_18011cca0(CardsDb, item, parsed_record)
|
||||
key = *(parsed_record + 8) // atom 0x15c = "id"
|
||||
walk the RB-tree at CardsDb+0x160c0
|
||||
if MISS: node = FUN_180115c30(...) // <-- INSERTS a fresh node
|
||||
FUN_1800515e0(node+0x28, parsed_record) // record = parsed_record
|
||||
FUN_1800419b0(item, node+0x28) // item+0x10 = &record
|
||||
|
||||
There is no "blank default record" and no miss-emit path. A miss ALLOCATES a
|
||||
node (FUN_1801155f0 -> record ctor 0x180041250, zero-init, size 0x158) and the
|
||||
very next instruction overwrites that record from the parsed item. So the map is
|
||||
NOT empty offline -- it gains one node per parsed item, keyed by the item's `id`,
|
||||
and each node's record at +0x28 is the exact buffer the card view-model
|
||||
0x1800d7920 dereferences through item+0x10.
|
||||
|
||||
That is strictly better news: the thing we want to prove is reachable as a plain
|
||||
DATA WRITE into an existing live buffer. No instruction patching at all.
|
||||
|
||||
--------------------------------------------------------------------------
|
||||
WHAT THIS ACTUALLY DOES
|
||||
--------------------------------------------------------------------------
|
||||
1. finds FIFA17.exe and CardsDLL's live base from /proc/PID/maps
|
||||
2. reads the CardsDb singleton (static 0x1802e6398)
|
||||
3. walks the std::map at CardsDb+0x160c0 and DECODES every record through the
|
||||
view-model's own offsets -- this alone is the pre-check that decides the
|
||||
experiment (see OUTCOMES below); it is read-only and always runs
|
||||
4. only with --fire --item <id>: writes a BEACON of deliberately unmistakable
|
||||
values into ONE record, after snapshotting it to a backup file
|
||||
5. --restore <backup.json> puts the original bytes back
|
||||
|
||||
Reversibility: the full 0x158-byte record is snapshotted to <backup>.bin before
|
||||
any write, and the manifest records every (offset, original bytes, new bytes).
|
||||
--restore rewrites ONLY the byte ranges we wrote -- never the whole record --
|
||||
because the record also contains live intrusive-list pointers that legitimately
|
||||
change between patch and restore, and blindly restoring those would corrupt the
|
||||
observer list. A FIFA restart also clears everything (live memory only).
|
||||
|
||||
Needs ptrace access: tools/root_arm.sh (kernel.yama.ptrace_scope=0).
|
||||
|
||||
USAGE
|
||||
python3 tools/card_record_poke.py # read-only census
|
||||
python3 tools/card_record_poke.py --item 100000001 --fire
|
||||
python3 tools/card_record_poke.py --restore /tmp/openfut_cardrec_<...>.json
|
||||
|
||||
VERIFIED THIS SESSION (static, cardsdll.dll @ 0x180000000)
|
||||
0x1802e6398 CardsDb singleton (getter FUN_18011a830 returns DAT_1802e6398)
|
||||
0x18021c2a0 CardsDb vtable; slot +0xa08 -> 0x18011cca0 (the lookup)
|
||||
(found by scanning .rdata for the qword 0x18011cca0: exactly one
|
||||
hit, at 0x18021cca8 = 0x18021c2a0 + 0xa08)
|
||||
CardsDb+0x160c0 std::map base; +0x160c8 embedded header node;
|
||||
header+0x10 = +0x160d8 = root; map+0x28 = +0x160e8 = size
|
||||
(the insert increments *(int*)(mapbase+0x28))
|
||||
node: child/child +0x00/+0x08, parent +0x10, key(itemId) +0x20, record +0x28
|
||||
record size 0x158 (ctor 0x180041250 memsets +0xb8..+0x158 and the assignment
|
||||
operator 0x1800515e0 copies through +0x150)
|
||||
record field offsets, read straight out of the view-model 0x1800d7920:
|
||||
+0x18 dword resourceId (its low 24 bits are used separately)
|
||||
+0x58 dword, +0x88 dword, +0x90 int (>0 -> a bool),
|
||||
+0x94 dword teamid, +0x98/9c/a0/a4/a8/ac dword attrs,
|
||||
+0xb4 byte rating, +0xb5/+0xb6 bytes gate a bool,
|
||||
+0xdd 32-byte name (falls back to +0xc8 when +0xdd is empty),
|
||||
+0x146 byte position, +0x148 word nation
|
||||
record+0x00/+0x08 and +0x70/+0x78/+0x80 are POINTERS (the ctor stores
|
||||
&PTR_LAB_1801eaac0 at +0x70). This script refuses to write them.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import struct
|
||||
import sys
|
||||
import time
|
||||
|
||||
# ------------------------------------------------------------------ constants
|
||||
IMG_BASE = 0x180000000
|
||||
DLL = "CardsDLL"
|
||||
|
||||
G_CARDSDB = 0x1802E6398 # CardsDb singleton slot
|
||||
|
||||
MAP_BASE = 0x160C0 # std::map object inside CardsDb
|
||||
MAP_HEADER = 0x160C8 # embedded header node
|
||||
MAP_ROOT = 0x160D8 # header + 0x10
|
||||
MAP_SIZE = 0x160E8 # map + 0x28
|
||||
|
||||
NODE_L, NODE_R, NODE_KEY, NODE_REC = 0x00, 0x08, 0x20, 0x28
|
||||
REC_SIZE = 0x158
|
||||
|
||||
MAX_NODES = 100000
|
||||
|
||||
# Byte ranges inside the record that are POINTERS / intrusive-list links.
|
||||
# Writing them can corrupt FIFA's heap. Every write is checked against this.
|
||||
FORBIDDEN = ((0x00, 0x10), (0x70, 0x88))
|
||||
|
||||
# ------------------------------------------------------------------- beacon --
|
||||
# Deliberately unmistakable values. Every one is independently identifiable in a
|
||||
# screenshot, so a PARTIAL result tells us exactly which field drove which pixel.
|
||||
# rating 99 -- no real starter card is 99
|
||||
# attrs 11..66 -- also reveals the on-card ORDER of the six attributes
|
||||
# nation 38 -- Portugal flag
|
||||
# teamid 243 -- Real Madrid badge
|
||||
# resourceId 20801 (version 0) -- Ronaldo; this is what a face/art lookup keys on
|
||||
# name -- pure ASCII, cannot be mistaken for a dbdata name
|
||||
BEACON = [
|
||||
(0x018, "u32", "resourceId (vm field0/1)", 20801),
|
||||
(0x088, "u32", "vm field6 (league?)", 53),
|
||||
(0x094, "u32", "teamid", 243),
|
||||
(0x098, "u32", "attr0", 11),
|
||||
(0x09C, "u32", "attr1", 22),
|
||||
(0x0A0, "u32", "attr2", 33),
|
||||
(0x0A4, "u32", "attr3", 44),
|
||||
(0x0A8, "u32", "attr4", 55),
|
||||
(0x0AC, "u32", "attr5", 66),
|
||||
(0x0B4, "u8", "rating", 99),
|
||||
(0x146, "u8", "position (enum probe)", 25),
|
||||
(0x148, "u16", "nation", 38),
|
||||
(0x0DD, "str32", "name", "OPENFUT PROOF"),
|
||||
]
|
||||
|
||||
KIND_LEN = {"u8": 1, "u16": 2, "u32": 4, "str32": 0x20}
|
||||
|
||||
|
||||
def encode(kind, value):
|
||||
if kind == "u8":
|
||||
return struct.pack("<B", value & 0xFF)
|
||||
if kind == "u16":
|
||||
return struct.pack("<H", value & 0xFFFF)
|
||||
if kind == "u32":
|
||||
return struct.pack("<I", value & 0xFFFFFFFF)
|
||||
if kind == "str32":
|
||||
b = value.encode("ascii", "replace")[:0x1F]
|
||||
return b + b"\0" * (0x20 - len(b))
|
||||
raise ValueError(kind)
|
||||
|
||||
|
||||
def check_write(off, length):
|
||||
"""Raise unless [off, off+length) is a safe scalar range in the record."""
|
||||
if off < 0 or off + length > REC_SIZE:
|
||||
raise SystemExit("REFUSING: write %#x..%#x is outside the record (size %#x)"
|
||||
% (off, off + length, REC_SIZE))
|
||||
for lo, hi in FORBIDDEN:
|
||||
if off < hi and lo < off + length:
|
||||
raise SystemExit(
|
||||
"REFUSING: write %#x..%#x overlaps pointer range %#x..%#x "
|
||||
"(intrusive list / vtable slot)" % (off, off + length, lo, hi))
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ process --
|
||||
def find_pid():
|
||||
for d in glob.glob("/proc/[0-9]*"):
|
||||
try:
|
||||
if open(d + "/comm").read().strip() == "FIFA17.exe":
|
||||
return int(d.rsplit("/", 1)[-1])
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def dll_base(pid, name=DLL):
|
||||
try:
|
||||
for line in open("/proc/%d/maps" % pid):
|
||||
if name in line:
|
||||
return int(line.split("-")[0], 16) # lowest mapping = base
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
class Mem(object):
|
||||
def __init__(self, pid, writable=False):
|
||||
self.pid = pid
|
||||
self.path = "/proc/%d/mem" % pid
|
||||
self.f = open(self.path, "r+b" if writable else "rb", buffering=0)
|
||||
self.writable = writable
|
||||
|
||||
def read(self, va, n):
|
||||
self.f.seek(va)
|
||||
b = self.f.read(n)
|
||||
if b is None or len(b) != n:
|
||||
raise IOError("short read at %#x" % va)
|
||||
return b
|
||||
|
||||
def try_read(self, va, n):
|
||||
try:
|
||||
return self.read(va, n)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def write(self, va, data):
|
||||
if not self.writable:
|
||||
raise RuntimeError("Mem opened read-only")
|
||||
self.f.seek(va)
|
||||
self.f.write(data)
|
||||
|
||||
def q(self, va):
|
||||
b = self.try_read(va, 8)
|
||||
return struct.unpack("<Q", b)[0] if b else None
|
||||
|
||||
def i32(self, va):
|
||||
b = self.try_read(va, 4)
|
||||
return struct.unpack("<i", b)[0] if b else None
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- the map --
|
||||
def walk(mem, cdb):
|
||||
"""[(node_addr, key)] for every node in CardsDb's item map.
|
||||
|
||||
Generic DFS over both child slots with a visited set: the exact left/right
|
||||
convention does not matter for a census, and a garbage pointer terminates the
|
||||
walk instead of hanging it.
|
||||
"""
|
||||
header = cdb + MAP_HEADER
|
||||
root = mem.q(cdb + MAP_ROOT)
|
||||
if root is None:
|
||||
return None
|
||||
if root == 0 or root == header:
|
||||
return []
|
||||
out, seen, stack = [], set(), [root]
|
||||
while stack and len(out) < MAX_NODES:
|
||||
p = stack.pop()
|
||||
if not p or p == header or p in seen or (p & 7):
|
||||
continue
|
||||
seen.add(p)
|
||||
k = mem.q(p + NODE_KEY)
|
||||
if k is None:
|
||||
continue
|
||||
out.append((p, k))
|
||||
for slot in (NODE_L, NODE_R):
|
||||
c = mem.q(p + slot)
|
||||
if c and c != header and c not in seen:
|
||||
stack.append(c)
|
||||
out.sort(key=lambda t: t[1])
|
||||
return out
|
||||
|
||||
|
||||
def decode_record(buf):
|
||||
"""Decode a 0x158-byte record through the view-model's own offsets."""
|
||||
u8 = lambda o: buf[o]
|
||||
u16 = lambda o: struct.unpack_from("<H", buf, o)[0]
|
||||
u32 = lambda o: struct.unpack_from("<I", buf, o)[0]
|
||||
i32 = lambda o: struct.unpack_from("<i", buf, o)[0]
|
||||
|
||||
def s(o, n=0x20):
|
||||
raw = bytes(buf[o:o + n])
|
||||
z = raw.find(b"\0")
|
||||
raw = raw[:z] if z >= 0 else raw
|
||||
return raw.decode("ascii", "replace")
|
||||
|
||||
return {
|
||||
"tradeId(+0x10)": struct.unpack_from("<Q", buf, 0x10)[0],
|
||||
"resourceId(+0x18)": u32(0x18),
|
||||
" assetId(low24)": u32(0x18) & 0xFFFFFF,
|
||||
" version(>>24)": u32(0x18) >> 24,
|
||||
"vm7(+0x58)": u32(0x58),
|
||||
"vm6(+0x88)": u32(0x88),
|
||||
"int(+0x90)": i32(0x90),
|
||||
"teamid(+0x94)": u32(0x94),
|
||||
"attrs(+0x98..ac)": [u32(0x98 + 4 * i) for i in range(6)],
|
||||
"rating(+0xb4)": u8(0xB4),
|
||||
"flagA(+0xb5)": u8(0xB5),
|
||||
"flagB(+0xb6)": u8(0xB6),
|
||||
"name(+0xdd)": s(0xDD),
|
||||
"nameFallback(+0xc8)": s(0xC8, 0x15),
|
||||
"position(+0x146)": u8(0x146),
|
||||
"nation(+0x148)": u16(0x148),
|
||||
}
|
||||
|
||||
|
||||
def print_census(mem, cdb, nodes):
|
||||
size = mem.i32(cdb + MAP_SIZE)
|
||||
print(" CardsDb %#x" % cdb)
|
||||
print(" map base %#x (header %#x, root %#x)"
|
||||
% (cdb + MAP_BASE, cdb + MAP_HEADER, mem.q(cdb + MAP_ROOT) or 0))
|
||||
print(" map size field %s walked nodes %s"
|
||||
% (size, "unreadable" if nodes is None else len(nodes)))
|
||||
if nodes is None:
|
||||
print("\n TREE UNREADABLE. Nothing further can be said.")
|
||||
return
|
||||
if size is not None and len(nodes) != size:
|
||||
print(" !! walk count != size field -- the walk is wrong, not the game.")
|
||||
if not nodes:
|
||||
print("\n THE MAP IS EMPTY. No item has been parsed in this session yet.")
|
||||
print(" Enter the Squads tab (so GET /squad/0 is served) and re-run.")
|
||||
return
|
||||
print()
|
||||
for node, key in nodes:
|
||||
rec = node + NODE_REC
|
||||
buf = mem.try_read(rec, REC_SIZE)
|
||||
print(" item id %-12d node %#x record %#x" % (key, node, rec))
|
||||
if buf is None:
|
||||
print(" <record unreadable>")
|
||||
continue
|
||||
d = decode_record(buf)
|
||||
for k in ("resourceId(+0x18)", " assetId(low24)", "rating(+0xb4)",
|
||||
"teamid(+0x94)", "nation(+0x148)", "position(+0x146)",
|
||||
"attrs(+0x98..ac)", "name(+0xdd)", "nameFallback(+0xc8)"):
|
||||
print(" %-22s %s" % (k, d[k]))
|
||||
print()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- patch --
|
||||
def do_patch(mem, cdb, nodes, item_id, backup_path):
|
||||
match = [(n, k) for n, k in nodes if k == item_id]
|
||||
if not match:
|
||||
raise SystemExit(
|
||||
"item id %d is not in the map. Present: %s"
|
||||
% (item_id, ", ".join(str(k) for _, k in nodes[:20])))
|
||||
node, key = match[0]
|
||||
rec = node + NODE_REC
|
||||
|
||||
snap = mem.read(rec, REC_SIZE)
|
||||
|
||||
writes = []
|
||||
for off, kind, name, value in BEACON:
|
||||
ln = KIND_LEN[kind]
|
||||
check_write(off, ln)
|
||||
new = encode(kind, value)
|
||||
assert len(new) == ln
|
||||
writes.append({"off": off, "len": ln, "name": name,
|
||||
"orig_hex": snap[off:off + ln].hex(), "new_hex": new.hex()})
|
||||
|
||||
manifest = {
|
||||
"tool": "card_record_poke.py",
|
||||
"ts": time.strftime("%Y-%m-%dT%H:%M:%S"),
|
||||
"pid": mem.pid,
|
||||
"item_id": key,
|
||||
"node": node,
|
||||
"record": rec,
|
||||
"record_snapshot": os.path.splitext(backup_path)[0] + ".bin",
|
||||
"writes": writes,
|
||||
}
|
||||
with open(manifest["record_snapshot"], "wb") as f:
|
||||
f.write(snap)
|
||||
with open(backup_path, "w") as f:
|
||||
json.dump(manifest, f, indent=2)
|
||||
print(" backup written: %s" % backup_path)
|
||||
print(" %s (%d bytes)" % (manifest["record_snapshot"], len(snap)))
|
||||
|
||||
print("\n writing beacon into record %#x" % rec)
|
||||
ok = True
|
||||
for w in writes:
|
||||
mem.write(rec + w["off"], bytes.fromhex(w["new_hex"]))
|
||||
back = mem.read(rec + w["off"], w["len"]).hex()
|
||||
flag = "OK " if back == w["new_hex"] else "FAIL"
|
||||
if back != w["new_hex"]:
|
||||
ok = False
|
||||
print(" %s +%#05x %-24s %s -> %s" % (flag, w["off"], w["name"],
|
||||
w["orig_hex"], back))
|
||||
print()
|
||||
if not ok:
|
||||
print(" !! at least one write did not read back. STOP and restore.")
|
||||
return 1
|
||||
print(" Beacon in place. Do NOT switch tabs (a tab switch refetches /squad/0")
|
||||
print(" and the parser will overwrite this record). Move the cursor onto and")
|
||||
print(" off the card, or open Player Details, to force a redraw.")
|
||||
print(" Restore with: python3 %s --restore %s" % (sys.argv[0], backup_path))
|
||||
return 0
|
||||
|
||||
|
||||
def do_restore(path):
|
||||
with open(path) as f:
|
||||
m = json.load(f)
|
||||
pid = m["pid"]
|
||||
if not os.path.exists("/proc/%d" % pid):
|
||||
print("pid %d is gone -- FIFA restarted, the patch is already gone with it."
|
||||
% pid)
|
||||
return 0
|
||||
if open("/proc/%d/comm" % pid).read().strip() != "FIFA17.exe":
|
||||
print("pid %d is no longer FIFA17.exe. REFUSING to write." % pid)
|
||||
return 1
|
||||
mem = Mem(pid, writable=True)
|
||||
rec = m["record"]
|
||||
print("restoring record %#x in pid %d (%d ranges)" % (rec, pid, len(m["writes"])))
|
||||
for w in m["writes"]:
|
||||
cur = mem.read(rec + w["off"], w["len"]).hex()
|
||||
if cur != w["new_hex"]:
|
||||
print(" note +%#05x holds %s, not our beacon %s -- the game rewrote "
|
||||
"it; restoring anyway is WRONG, skipping." % (w["off"], cur, w["new_hex"]))
|
||||
continue
|
||||
check_write(w["off"], w["len"])
|
||||
mem.write(rec + w["off"], bytes.fromhex(w["orig_hex"]))
|
||||
back = mem.read(rec + w["off"], w["len"]).hex()
|
||||
print(" %s +%#05x %-24s -> %s"
|
||||
% ("OK " if back == w["orig_hex"] else "FAIL", w["off"], w["name"], back))
|
||||
print("done.")
|
||||
return 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------- main --
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(
|
||||
description="Read (and, with --fire, beacon-patch) a live FUT card record.")
|
||||
ap.add_argument("--item", type=int,
|
||||
help="item id (map key) of the record to patch")
|
||||
ap.add_argument("--fire", action="store_true",
|
||||
help="REQUIRED to write anything. Without it this tool is read-only.")
|
||||
ap.add_argument("--restore", metavar="BACKUP.json",
|
||||
help="undo a previous --fire using its backup manifest")
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.restore:
|
||||
return do_restore(args.restore)
|
||||
|
||||
pid = find_pid()
|
||||
if pid is None:
|
||||
print("FIFA17.exe is not running.")
|
||||
return 1
|
||||
base = dll_base(pid)
|
||||
if base is None:
|
||||
print("pid %d is running but %s is not mapped yet (reach the FUT hub first)."
|
||||
% (pid, DLL))
|
||||
return 1
|
||||
try:
|
||||
mem = Mem(pid, writable=bool(args.fire))
|
||||
except Exception as e:
|
||||
print("cannot open /proc/%d/mem: %s" % (pid, e))
|
||||
print("Need ptrace access: sudo sysctl -w kernel.yama.ptrace_scope=0"
|
||||
" (tools/root_arm.sh)")
|
||||
return 1
|
||||
|
||||
print("FIFA pid=%d %s base=%#x (image base %#x)" % (pid, DLL, base, IMG_BASE))
|
||||
cdb = mem.q(base + (G_CARDSDB - IMG_BASE))
|
||||
if not cdb:
|
||||
print(" CardsDb singleton is NULL -- the FUT layer is not constructed yet.")
|
||||
return 1
|
||||
|
||||
nodes = walk(mem, cdb)
|
||||
print_census(mem, cdb, nodes)
|
||||
|
||||
if not args.fire:
|
||||
print("READ-ONLY. Nothing was written. Add --item <id> --fire to patch.")
|
||||
return 0
|
||||
if args.item is None:
|
||||
print("--fire needs --item <id>. Pick one from the census above.")
|
||||
return 1
|
||||
if not nodes:
|
||||
print("nothing to patch.")
|
||||
return 1
|
||||
|
||||
backup = "/tmp/openfut_cardrec_%d_%d_%d.json" % (pid, args.item, int(time.time()))
|
||||
return do_patch(mem, cdb, nodes, args.item, backup)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Executable
+325
@@ -0,0 +1,325 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Extract FIFA 17's real player roster (playerId -> name + rating) from a LIVE
|
||||
FIFA17.exe process. READ-ONLY: /proc/PID/mem is opened 'rb' and only ever
|
||||
seek()/read(). There is no write path in this file.
|
||||
|
||||
WHY THIS FILE EXISTS, AND WHY IT IS NOT "dbdata.dll"
|
||||
---------------------------------------------------
|
||||
The plan of record was to pull the roster out of `/mnt/games/FIFA 17/dbdata.dll`
|
||||
(2,686,152 bytes, one export `getTableData`, 2.5 MB `.xdata` payload). That DLL
|
||||
is NOT a database. Verified this session by building tools/dbdata_probe.c with
|
||||
x86_64-w64-mingw32-gcc and running it under Wine:
|
||||
|
||||
base=00006FFFFA980000 getTableData=00006FFFFA9816B0 (rva 0x16b0)
|
||||
call 0: ret=00007FFFFEBF5DB0 len=1012 -> 1012 chars of base64url
|
||||
decoded: 759 bytes, md5 dc97c0dfd5edea5fb379dc14d8017980, entropy ~7.9
|
||||
|
||||
`.xdata` measures 7.52 bits/byte of entropy uniformly across its whole 2,515,528
|
||||
bytes (sampled at 0x0/0x1000/0x100000/0x200000/0x260000, zero 16-byte NUL runs),
|
||||
i.e. it is encrypted/packed, and the single export hands back a ~759-byte
|
||||
attestation blob, not tables. There is no table selector argument. So the
|
||||
roster cannot be read out of dbdata.dll without breaking its packer.
|
||||
|
||||
The roster IS, however, fully resident in the running game. FIFA 17 builds a
|
||||
flat, rating-sorted index of every player in the base DB and keeps it on the
|
||||
heap. That is what this tool reads.
|
||||
|
||||
THE STRUCTURE (resolved live, 2026-08-04, pid 11864, game sitting at the menu)
|
||||
-----------------------------------------------------------------------------
|
||||
Two heap regions cooperate:
|
||||
|
||||
* a "name pool" region (seen at 0x0d790000..0x0dc40000, 4.8 MB, rw-p) holding
|
||||
~17.5k individually-allocated, NUL-terminated UTF-8 strings in the form
|
||||
"<firstName>|<lastName>|<commonName>"
|
||||
e.g. "Cristiano|Ronaldo|", "Neymar|da Silva Santos Jr.|Neymar".
|
||||
commonName is usually empty (the string then ends in "||").
|
||||
|
||||
* an index table (seen at 0x0b8450d40 .. 0x0b8562fc0, rw-p) of 17,547 entries
|
||||
at a constant stride of 0x40 bytes, no gaps, sorted by rating DESCENDING:
|
||||
|
||||
+0x00 u32 playerId (20801 = Cristiano Ronaldo)
|
||||
+0x04 u32 rank (0..16546, dense, == entry index)
|
||||
+0x08 u32 rating (73..94 at the head, down to 40s at tail)
|
||||
+0x0c u32 aux (0 for most entries; a 32-bit hash for
|
||||
some -- purpose unresolved)
|
||||
+0x10 char* name begin -> into the name pool
|
||||
+0x18 char* name end == begin + strlen
|
||||
+0x20 char* name end + 1
|
||||
+0x28 u64 0x2c020e50 (constant across every entry)
|
||||
+0x30 u64 1, or a 32-bit hash in the low dword
|
||||
+0x38 u64 0x6ffffc32a968 (constant across every entry -- a vtable
|
||||
or allocator handle in the Wine range)
|
||||
|
||||
The {begin, end, end+1} triple at +0x10 is the reliable signature: it is
|
||||
self-validating (end-begin == strlen, and end+1 == the third pointer), which is
|
||||
why this tool anchors on it instead of on any hard-coded address. Nothing here
|
||||
is a fixed VA: run it against any FIFA17.exe and it re-locates the table.
|
||||
|
||||
WHAT THIS GIVES YOU AND WHAT IT DOES NOT
|
||||
----------------------------------------
|
||||
GIVES: playerId, rank, rating, firstName, lastName, commonName -- for the
|
||||
complete 17,547-player FIFA 17 roster.
|
||||
DOES NOT: position, nationality, teamId, or the six face attributes. Those
|
||||
are NOT in this table. See the "STILL MISSING" note at the bottom
|
||||
of this file for the leads that were found for them.
|
||||
|
||||
ANCHOR CHECK (the one the task asked for): playerId 20801 must be
|
||||
"Cristiano|Ronaldo|" rated 94. --check enforces it and exits non-zero if the
|
||||
parse disagrees.
|
||||
|
||||
USAGE
|
||||
-----
|
||||
./dbdata_extract.py # extract, write players_fifa17.json
|
||||
./dbdata_extract.py --check # extract + assert the Ronaldo anchor
|
||||
./dbdata_extract.py -o /tmp/roster.json
|
||||
./dbdata_extract.py --pid 11864
|
||||
./dbdata_extract.py --top 40 # print the top 40 and exit
|
||||
|
||||
Requires ptrace access to the FIFA process (this project already runs with
|
||||
kernel.yama.ptrace_scope=1 and the same uid, which is sufficient).
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import struct
|
||||
import sys
|
||||
from collections import Counter
|
||||
|
||||
ENTRY_STRIDE = 0x40
|
||||
NAME_TRIPLE_OFF = 0x10 # offset of {begin,end,end+1} inside an entry
|
||||
MAX_NAME_LEN = 120
|
||||
RONALDO_ID = 20801
|
||||
RONALDO_RATING = 94
|
||||
RONALDO_NAME = "Cristiano|Ronaldo|"
|
||||
|
||||
|
||||
def find_pid():
|
||||
for d in os.listdir('/proc'):
|
||||
if not d.isdigit():
|
||||
continue
|
||||
try:
|
||||
if open('/proc/%s/comm' % d).read().strip() == 'FIFA17.exe':
|
||||
return int(d)
|
||||
except OSError:
|
||||
pass
|
||||
raise SystemExit("FIFA17.exe is not running (this tool needs the live game)")
|
||||
|
||||
|
||||
def read_maps(pid):
|
||||
"""Readable, non-file-backed-or-anon RW regions, small enough to slurp."""
|
||||
out = []
|
||||
for line in open('/proc/%d/maps' % pid):
|
||||
parts = line.split()
|
||||
lo, hi = parts[0].split('-')
|
||||
lo, hi = int(lo, 16), int(hi, 16)
|
||||
perms = parts[1]
|
||||
if 'r' not in perms:
|
||||
continue
|
||||
if hi - lo > (1 << 31):
|
||||
continue
|
||||
out.append((lo, hi, perms, parts[5] if len(parts) > 5 else ''))
|
||||
return out
|
||||
|
||||
|
||||
class Mem(object):
|
||||
def __init__(self, pid):
|
||||
self.f = open('/proc/%d/mem' % pid, 'rb', 0)
|
||||
self.cache = {}
|
||||
|
||||
def read(self, va, n):
|
||||
self.f.seek(va)
|
||||
return self.f.read(n)
|
||||
|
||||
def region(self, lo, hi):
|
||||
if (lo, hi) not in self.cache:
|
||||
try:
|
||||
self.f.seek(lo)
|
||||
self.cache[(lo, hi)] = self.f.read(hi - lo)
|
||||
except OSError:
|
||||
self.cache[(lo, hi)] = b''
|
||||
return self.cache[(lo, hi)]
|
||||
|
||||
|
||||
def find_name_pools(mem, maps):
|
||||
"""Regions containing many '<a>|<b>|<c>\\0' strings = the player-name pool."""
|
||||
pat = re.compile(rb'[^\x00|][^\x00|]{0,44}\|[^\x00|]{0,49}\|[^\x00|]{0,49}\x00')
|
||||
pools = []
|
||||
for lo, hi, perms, name in maps:
|
||||
if 'w' not in perms or name:
|
||||
continue
|
||||
if not (0x100000 <= hi - lo <= 0x4000000):
|
||||
continue
|
||||
d = mem.region(lo, hi)
|
||||
if not d:
|
||||
continue
|
||||
n = len(pat.findall(d))
|
||||
if n >= 2000:
|
||||
pools.append((lo, hi, n))
|
||||
return pools
|
||||
|
||||
|
||||
def scan_entries(mem, maps, pools):
|
||||
"""Anchor on the self-validating {begin,end,end+1} name triple."""
|
||||
lows = [(lo, hi) for lo, hi, _ in pools]
|
||||
|
||||
def in_pool(va):
|
||||
for lo, hi in lows:
|
||||
if lo <= va < hi:
|
||||
return True
|
||||
return False
|
||||
|
||||
def pool_bytes(va, n):
|
||||
for lo, hi in lows:
|
||||
if lo <= va and va - lo + n <= hi - lo:
|
||||
return mem.region(lo, hi)[va - lo:va - lo + n]
|
||||
return mem.read(va, n)
|
||||
|
||||
found = {}
|
||||
for lo, hi, perms, name in maps:
|
||||
if 'w' not in perms or name:
|
||||
continue
|
||||
d = mem.region(lo, hi)
|
||||
if len(d) < ENTRY_STRIDE:
|
||||
continue
|
||||
for off in range(0, len(d) - ENTRY_STRIDE, 8):
|
||||
b, e, c = struct.unpack_from('<QQQ', d, off)
|
||||
if not (b < e < b + MAX_NAME_LEN and c == e + 1):
|
||||
continue
|
||||
if not in_pool(b):
|
||||
continue
|
||||
s = pool_bytes(b, e - b)
|
||||
if s.count(b'|') != 2:
|
||||
continue
|
||||
base = off - NAME_TRIPLE_OFF
|
||||
if base < 0:
|
||||
continue
|
||||
pid_, rank, rating, aux = struct.unpack_from('<IIII', d, base)
|
||||
found[lo + base] = (pid_, rank, rating, aux,
|
||||
s.decode('utf-8', 'replace'))
|
||||
return found
|
||||
|
||||
|
||||
def build(found):
|
||||
addrs = sorted(found)
|
||||
rows = []
|
||||
for a in addrs:
|
||||
pid_, rank, rating, aux, s = found[a]
|
||||
f0, f1, f2 = (s.split('|') + ['', '', ''])[:3]
|
||||
rows.append({
|
||||
'playerId': pid_,
|
||||
'resourceId': pid_, # version 0; resourceId = playerId | version<<24
|
||||
'rank': rank,
|
||||
'rating': rating,
|
||||
'firstName': f0,
|
||||
'lastName': f1,
|
||||
'commonName': f2,
|
||||
'aux': aux,
|
||||
'addr': '0x%x' % a,
|
||||
})
|
||||
return addrs, rows
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__.split('\n')[0])
|
||||
ap.add_argument('--pid', type=int)
|
||||
ap.add_argument('-o', '--out', default='players_fifa17.json')
|
||||
ap.add_argument('--check', action='store_true',
|
||||
help='assert playerId 20801 == Cristiano Ronaldo, 94')
|
||||
ap.add_argument('--top', type=int, default=0)
|
||||
args = ap.parse_args()
|
||||
|
||||
pid = args.pid or find_pid()
|
||||
mem = Mem(pid)
|
||||
maps = read_maps(pid)
|
||||
sys.stderr.write("pid %d, %d readable regions\n" % (pid, len(maps)))
|
||||
|
||||
pools = find_name_pools(mem, maps)
|
||||
if not pools:
|
||||
raise SystemExit("no player-name pool found -- is the game past the "
|
||||
"main menu with the player DB loaded?")
|
||||
for lo, hi, n in pools:
|
||||
sys.stderr.write("name pool 0x%x-0x%x %d name strings\n" % (lo, hi, n))
|
||||
|
||||
found = scan_entries(mem, maps, pools)
|
||||
addrs, rows = build(found)
|
||||
if not rows:
|
||||
raise SystemExit("index table not found")
|
||||
|
||||
strides = Counter(addrs[i + 1] - addrs[i] for i in range(len(addrs) - 1))
|
||||
sys.stderr.write("index table 0x%x-0x%x %d entries strides=%s\n"
|
||||
% (addrs[0], addrs[-1], len(rows), strides.most_common(3)))
|
||||
ranks = [r['rank'] for r in rows]
|
||||
sys.stderr.write("rank %d..%d (%d unique) playerIds %d unique rating %d..%d\n"
|
||||
% (min(ranks), max(ranks), len(set(ranks)),
|
||||
len(set(r['playerId'] for r in rows)),
|
||||
min(r['rating'] for r in rows),
|
||||
max(r['rating'] for r in rows)))
|
||||
|
||||
if args.top:
|
||||
for r in rows[:args.top]:
|
||||
print('%6d %2d %s' % (r['playerId'], r['rating'],
|
||||
'|'.join([r['firstName'], r['lastName'],
|
||||
r['commonName']])))
|
||||
return 0
|
||||
|
||||
by_id = {r['playerId']: r for r in rows}
|
||||
ok = True
|
||||
cr = by_id.get(RONALDO_ID)
|
||||
if cr is None:
|
||||
sys.stderr.write("ANCHOR FAIL: playerId %d absent\n" % RONALDO_ID)
|
||||
ok = False
|
||||
else:
|
||||
got = '|'.join([cr['firstName'], cr['lastName'], cr['commonName']])
|
||||
sys.stderr.write("anchor: playerId %d -> %r rating %d\n"
|
||||
% (RONALDO_ID, got, cr['rating']))
|
||||
if cr['rating'] != RONALDO_RATING or got != RONALDO_NAME:
|
||||
sys.stderr.write("ANCHOR FAIL: expected %r / %d\n"
|
||||
% (RONALDO_NAME, RONALDO_RATING))
|
||||
ok = False
|
||||
|
||||
with open(args.out, 'w') as fh:
|
||||
json.dump(rows, fh, ensure_ascii=False, indent=1)
|
||||
sys.stderr.write("wrote %s (%d players)\n" % (args.out, len(rows)))
|
||||
|
||||
if args.check and not ok:
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
# STILL MISSING: position / nationality / teamId / the six attributes.
|
||||
#
|
||||
# They are NOT in the index table above. Two leads were located live and are
|
||||
# recorded here so the next pass does not have to re-find them:
|
||||
#
|
||||
# (1) Materialised FUT card records. In the 40 MB heap region at 0x0b63b0000
|
||||
# the squad's resolved cards sit at a 0x180 stride, e.g. 0x0b840c2c0 =
|
||||
# Lewandowski and 0x0b840c440 = Luis Suarez. Layout relative to the record
|
||||
# word at +0x00c (0xf0, 0xf1 -- consecutive, an index):
|
||||
# +0x010..+0x02c six u32 attributes then two more u32
|
||||
# Lewandowski: 77 88 75 82 42 82 | 99 90
|
||||
# Suarez : 83 90 79 87 42 80 | 99 92
|
||||
# the last u32 is the overall rating (90 / 92, both correct for
|
||||
# FIFA 17), the 99 is constant across both.
|
||||
# +0x030 char[16] firstName ("Robert", "Luis")
|
||||
# +0x040 char[16] lastName ("Lewandowski", "Suárez")
|
||||
# Only ~a hundred of these exist process-wide -- they are built per card
|
||||
# that the client actually materialises, not a table. So this is a
|
||||
# VERIFICATION oracle for attribute values, not a bulk source.
|
||||
#
|
||||
# (2) A 32-byte-stride keyed table in the 238 MB heap region 0x37440000..,
|
||||
# seen at 0x42e8dbe8, carrying u64 fields keyed by playerId:
|
||||
# 20801 -> 27, 94, 77
|
||||
# 41236 -> 25, 90, 80 (41236 = Zlatan Ibrahimovic, rating 90)
|
||||
# The rating column is right in both rows; the 27/25 and 77/80 columns were
|
||||
# NOT identified. A 32-bit hash is interleaved in the high dword of a
|
||||
# rotating slot, so it is a hash container, not a flat array. Worth one
|
||||
# focused pass.
|
||||
#
|
||||
# Neither of these was pushed to completion in the session that wrote this file.
|
||||
# Do not cite them as resolved.
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
@@ -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;
|
||||
}
|
||||
Executable
+175
@@ -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
@@ -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
@@ -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())
|
||||
@@ -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))
|
||||
|
||||
@@ -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())
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,590 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Watch FIFA 17's FUT club / pile model live -- READ-ONLY, no patching.
|
||||
|
||||
WHY THIS EXISTS
|
||||
---------------
|
||||
Two problems resisted static analysis because the deciding logic may live in the
|
||||
Denuvo-packed FIFA17.exe (no code on disk):
|
||||
|
||||
(1) the FUT hub tab bar shows "MY CLUB 0" while the club holds ~99 items;
|
||||
(2) "Send to Club" (PUT ut/%s/item) kills the FUT session.
|
||||
|
||||
Static RE (this session) located the structures below inside CardsDLL. What it
|
||||
could NOT locate is the exact field the "MY CLUB" badge reads. So this tool does
|
||||
two things at once:
|
||||
|
||||
* WATCH the fields we DID identify (CardsDb card map, FUT session state), and
|
||||
* DIFF-SCAN the whole CardsDb object so the unknown counter reveals ITSELF when
|
||||
the human performs a labelled action in game.
|
||||
|
||||
It is the same pattern as tools/watch_online_mode.py: poll a CardsDLL singleton
|
||||
through /proc/PID/mem while the game runs.
|
||||
|
||||
READ-ONLY GUARANTEE
|
||||
-------------------
|
||||
/proc/PID/mem is opened 'rb' and only ever seek()/read(). There is no write path
|
||||
in this file. It cannot corrupt a save or a running process.
|
||||
|
||||
WHAT IS VERIFIED AND WHAT IS NOT
|
||||
--------------------------------
|
||||
VERIFIED STATICALLY (Ghidra, CardsDLL_Win64_retail.dll @ 0x180000000):
|
||||
* 0x1802e6398 CardsDb singleton pointer (getter FUN_18011a830, vtable 0x18021c2a0)
|
||||
* CardsDb+0x160c0..0x160e8 card item tree: root = *(obj+0x160d8),
|
||||
sentinel/end = obj+0x160c8, node {childA+0x00, childB+0x08, parent+0x10,
|
||||
key(itemId)+0x20, record+0x28}. Walked by the resolve FUN_18011cca0
|
||||
(vtable +0xa08) and by FUN_18011cf40 (vtable +0xa30, which clears
|
||||
record+0x10 = tradeId).
|
||||
* 0x1802df338 ION_CardInventory adapter object pointer (registration
|
||||
FUN_18003d070; bindings GetUserCardIDs / GetCardIDsForPile / GetListItemData
|
||||
dispatch through its vtable slots +0x08 / +0x38 / +0x30).
|
||||
* 0x1802e6328 FUT CompetitionManager (already proven by watch_online_mode.py).
|
||||
|
||||
VERIFIED LIVE (read-only probe of a running FIFA17.exe, 2026-08-04, main menu,
|
||||
FUT session already torn down):
|
||||
* all four globals resolve to non-NULL objects; the whole 0x22000 window reads.
|
||||
* the tree walk returned 11 nodes and CardsDb+0x160e8 read 11 -- so +0x160e8 is
|
||||
the tree's SIZE field and the walk agrees with it. Both are reported; a
|
||||
mismatch between them means the walk went wrong, not the game.
|
||||
* node keys were 100000001..100000025 -- OUR seeded item ids. This tree is the
|
||||
client's ITEM store, keyed by item id (not by resourceId).
|
||||
* session.phase / ready / stackIdx were all -1 (FUT session dead), consistent
|
||||
with the morning's kill.
|
||||
|
||||
STILL UNVERIFIED:
|
||||
* whether the "MY CLUB" counter lives inside the CardsDb object at all. If the
|
||||
diff scan reports nothing during the MY CLUB phase, that is itself the
|
||||
finding: the counter is NOT in CardsDb and lives in FIFA17.exe's own model.
|
||||
* the `pile` field offset inside a node record. The 11 nodes seen live were all
|
||||
the same pile, so nothing varied and no offset could be pinned. `items.count`
|
||||
below is therefore a TOTAL, not a per-pile figure.
|
||||
* every value observed during actual gameplay (nobody has run this while
|
||||
opening MY CLUB, opening a pack, or pressing Send to Club).
|
||||
|
||||
USAGE
|
||||
-----
|
||||
python3 tools/watch_club_model.py # everything, default filters
|
||||
python3 tools/watch_club_model.py --all # no value filtering (noisy)
|
||||
python3 tools/watch_club_model.py --no-scan # session + tree only
|
||||
python3 tools/watch_club_model.py --offsets 0x160c8,0x1234 # lock on candidates
|
||||
python3 tools/watch_club_model.py --calib 15 # longer idle calibration
|
||||
|
||||
While it runs, TYPE A LABEL + ENTER to mark what you are about to do, e.g.
|
||||
hub<Enter> myclub<Enter> pack<Enter> send<Enter>
|
||||
Every later line is tagged with that label. Ctrl-C prints a SHORT SUMMARY --
|
||||
paste the summary, not the stream.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import glob
|
||||
import os
|
||||
import select
|
||||
import struct
|
||||
import sys
|
||||
import time
|
||||
from array import array
|
||||
|
||||
# ---------------------------------------------------------------- constants --
|
||||
IMG_BASE = 0x180000000
|
||||
DLL = "CardsDLL"
|
||||
|
||||
# Globals inside CardsDLL (static VAs; rebased to the live mapping at runtime).
|
||||
G_CARDSDB = 0x1802E6398 # CardsDb singleton (FUN_18011a830 returns this)
|
||||
G_CARDINV = 0x1802DF338 # ION_CardInventory adapter object
|
||||
G_CARDINV2 = 0x1802DF348 # second slot written by FUN_18003d360
|
||||
G_COMPMGR = 0x1802E6328 # FUT::CompetitionManager (see watch_online_mode.py)
|
||||
|
||||
# CompetitionManager fields, proven by watch_online_mode.py.
|
||||
CM_PHASE, CM_READY, CM_STKIDX = 0x218, 0x6D4, 0x214
|
||||
CM_READY_OK = 0x1FBD0
|
||||
|
||||
# CardsDb card/definition tree (see module docstring).
|
||||
TREE_BASE = 0x160C0 # tree object base (passed to the inserter FUN_180115c30)
|
||||
TREE_END = 0x160C8 # sentinel node address == obj + this
|
||||
TREE_P1 = 0x160D0 # anchor slot 1 (iteration start in FUN_18011cf40)
|
||||
TREE_ROOT = 0x160D8 # root (walk start in FUN_18011cca0)
|
||||
TREE_SIZE = 0x160E8 # node count -- LIVE-VERIFIED: read 11 while the walk found 11
|
||||
|
||||
NODE_A, NODE_B, NODE_KEY = 0x00, 0x08, 0x20 # node children + key(itemId)
|
||||
NODE_TRADEID = 0x38 # record+0x10; FUN_18011cf40 zeroes it on a successful move
|
||||
|
||||
# The scan window over the CardsDb object. 0x20d10 is the highest offset any
|
||||
# decompiled CardsDb method touches, so 0x22000 is a safe upper bound; the tool
|
||||
# probes downward if the tail is not mapped.
|
||||
SCAN_LEN_DEFAULT = 0x22000
|
||||
PAGE = 0x1000
|
||||
|
||||
MAX_NODES = 200000 # hard cap so a corrupt/garbage tree can never hang us
|
||||
MAX_REPORTS_PER_POLL = 40
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ process --
|
||||
def find_pid():
|
||||
for d in glob.glob("/proc/[0-9]*"):
|
||||
try:
|
||||
if open(d + "/comm").read().strip() == "FIFA17.exe":
|
||||
return int(d.rsplit("/", 1)[-1])
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def dll_base(pid, name=DLL):
|
||||
try:
|
||||
for line in open("/proc/%d/maps" % pid):
|
||||
if name in line:
|
||||
return int(line.split("-")[0], 16)
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
class Mem(object):
|
||||
"""Read-only /proc/PID/mem accessor. Every failure is reported, never raised."""
|
||||
|
||||
def __init__(self, pid):
|
||||
self.pid = pid
|
||||
self.fails = 0
|
||||
self.f = open("/proc/%d/mem" % pid, "rb") # 'rb' -- read-only, by design
|
||||
|
||||
def read(self, va, n):
|
||||
try:
|
||||
self.f.seek(va)
|
||||
b = self.f.read(n)
|
||||
if b is None or len(b) != n:
|
||||
self.fails += 1
|
||||
return None
|
||||
return b
|
||||
except Exception:
|
||||
self.fails += 1
|
||||
return None
|
||||
|
||||
def read_pages(self, va, n):
|
||||
"""Read n bytes, page by page. Returns (bytearray, set_of_bad_page_idx)."""
|
||||
buf = bytearray(n)
|
||||
bad = set()
|
||||
for off in range(0, n, PAGE):
|
||||
ln = min(PAGE, n - off)
|
||||
b = self.read(va + off, ln)
|
||||
if b is None:
|
||||
bad.add(off // PAGE)
|
||||
else:
|
||||
buf[off:off + ln] = b
|
||||
return buf, bad
|
||||
|
||||
def q(self, va):
|
||||
b = self.read(va, 8)
|
||||
return struct.unpack("<Q", b)[0] if b else None
|
||||
|
||||
def i32(self, va):
|
||||
b = self.read(va, 4)
|
||||
return struct.unpack("<i", b)[0] if b else None
|
||||
|
||||
def alive(self):
|
||||
return os.path.exists("/proc/%d" % self.pid)
|
||||
|
||||
|
||||
# --------------------------------------------------------------- structures --
|
||||
def walk_tree(mem, obj):
|
||||
"""Count nodes in the CardsDb card/definition tree, bounded and defensive.
|
||||
|
||||
Returns (count, note). count is None when the tree could not be walked.
|
||||
Generic DFS over BOTH child slots with a visited set -- the exact
|
||||
left/right convention does not matter for a count, and a cycle or a garbage
|
||||
pointer terminates the walk instead of hanging it.
|
||||
"""
|
||||
root = mem.q(obj + TREE_ROOT)
|
||||
end = obj + TREE_END
|
||||
if root is None:
|
||||
return None, "root unreadable"
|
||||
if root == 0 or root == end:
|
||||
return 0, "empty"
|
||||
seen = set()
|
||||
stack = [root]
|
||||
n = 0
|
||||
truncated = False
|
||||
while stack:
|
||||
p = stack.pop()
|
||||
if p == 0 or p == end or p in seen:
|
||||
continue
|
||||
if p & 7: # nodes are 8-byte aligned; anything else is garbage
|
||||
continue
|
||||
if n >= MAX_NODES:
|
||||
truncated = True
|
||||
break
|
||||
seen.add(p)
|
||||
n += 1
|
||||
for slot in (NODE_A, NODE_B):
|
||||
c = mem.q(p + slot)
|
||||
if c is None:
|
||||
truncated = True
|
||||
continue
|
||||
if c and c != end and c not in seen:
|
||||
stack.append(c)
|
||||
return n, ("TRUNCATED at %d" % MAX_NODES) if truncated else "ok"
|
||||
|
||||
|
||||
def tree_items(mem, obj, limit=MAX_NODES):
|
||||
"""{itemId: tradeId} for every node in the tree. Bounded and defensive.
|
||||
|
||||
Returns None when the tree could not be read at all. This is the answer to
|
||||
"does the client actually hold all 99 club items, or only the squad?" --
|
||||
if the count stays at ~11 while MY CLUB displays 99 players, the client is
|
||||
rendering a fetch result it never ingested into this store.
|
||||
"""
|
||||
root = mem.q(obj + TREE_ROOT)
|
||||
end = obj + TREE_END
|
||||
if root is None:
|
||||
return None
|
||||
if root == 0 or root == end:
|
||||
return {}
|
||||
out, seen, stack = {}, set(), [root]
|
||||
while stack and len(out) < limit:
|
||||
p = stack.pop()
|
||||
if not p or p == end or p in seen or (p & 7):
|
||||
continue
|
||||
seen.add(p)
|
||||
k = mem.q(p + NODE_KEY)
|
||||
if k is not None:
|
||||
out[k] = mem.q(p + NODE_TRADEID)
|
||||
for slot in (NODE_A, NODE_B):
|
||||
c = mem.q(p + slot)
|
||||
if c and c != end and c not in seen:
|
||||
stack.append(c)
|
||||
return out
|
||||
|
||||
|
||||
def snapshot_named(mem, base):
|
||||
"""The identified fields, as a labelled dict. Missing/failed reads -> None."""
|
||||
s = {}
|
||||
cdb = mem.q(base + (G_CARDSDB - IMG_BASE))
|
||||
inv = mem.q(base + (G_CARDINV - IMG_BASE))
|
||||
inv2 = mem.q(base + (G_CARDINV2 - IMG_BASE))
|
||||
cm = mem.q(base + (G_COMPMGR - IMG_BASE))
|
||||
s["CardsDb.ptr"] = cdb
|
||||
s["CardInventory.ptr"] = inv
|
||||
s["CardInventory2.ptr"] = inv2
|
||||
s["CompetitionMgr.ptr"] = cm
|
||||
|
||||
if cm:
|
||||
s["session.phase"] = mem.i32(cm + CM_PHASE)
|
||||
s["session.ready"] = mem.i32(cm + CM_READY)
|
||||
s["session.stackIdx"] = mem.i32(cm + CM_STKIDX)
|
||||
|
||||
if cdb:
|
||||
for off, nm in ((TREE_BASE, "tree.base"), (TREE_END, "tree.anchor0"),
|
||||
(TREE_P1, "tree.anchor1"), (TREE_ROOT, "tree.root")):
|
||||
s["cdb+%#x %s" % (off, nm)] = mem.q(cdb + off)
|
||||
size = mem.q(cdb + TREE_SIZE)
|
||||
s["items.size(+0x160e8)"] = size
|
||||
n, note = walk_tree(mem, cdb)
|
||||
s["items.walkCount"] = n
|
||||
if n is not None and size is not None and n != size:
|
||||
s["items.walkNote"] = "%s MISMATCH vs size field" % note
|
||||
elif note != "ok":
|
||||
s["items.walkNote"] = note
|
||||
return s
|
||||
|
||||
|
||||
# -------------------------------------------------------------- diff engine --
|
||||
def probe_scan_len(mem, obj, want):
|
||||
"""Largest readable window <= want, rounded to pages."""
|
||||
n = want
|
||||
while n >= PAGE:
|
||||
if mem.read(obj + n - PAGE, PAGE) is not None:
|
||||
return n
|
||||
n -= PAGE
|
||||
return 0
|
||||
|
||||
|
||||
class Differ(object):
|
||||
"""Dword-level differ over one memory window, with hot-offset suppression."""
|
||||
|
||||
def __init__(self, base_va, length, value_filter=True):
|
||||
self.va = base_va
|
||||
self.len = length
|
||||
self.prev = None
|
||||
self.hot = set() # offsets that churn while idle -> ignored
|
||||
self.changes = {} # offset -> [values seen]
|
||||
self.value_filter = value_filter
|
||||
|
||||
def _interesting(self, old, new):
|
||||
if not self.value_filter:
|
||||
return True
|
||||
# Counter-like: small signed ints on both sides.
|
||||
if -1 <= old <= 100000 and -1 <= new <= 100000:
|
||||
return True
|
||||
# A field going to/from zero (pointer or count clear) is worth seeing.
|
||||
return old == 0 or new == 0
|
||||
|
||||
def poll(self, mem, calibrating):
|
||||
buf, bad = mem.read_pages(self.va, self.len)
|
||||
cur = array("i")
|
||||
cur.frombytes(bytes(buf))
|
||||
if self.prev is None:
|
||||
self.prev = cur
|
||||
return [], bad
|
||||
out = []
|
||||
prev = self.prev
|
||||
n = len(cur)
|
||||
for i in range(n):
|
||||
a = prev[i]
|
||||
b = cur[i]
|
||||
if a == b:
|
||||
continue
|
||||
off = i * 4
|
||||
if (off >> 12) in bad:
|
||||
continue
|
||||
if calibrating:
|
||||
self.hot.add(off)
|
||||
continue
|
||||
if off in self.hot:
|
||||
continue
|
||||
if not self._interesting(a, b):
|
||||
self.hot.add(off) # noisy pointer-ish churn, drop it for good
|
||||
continue
|
||||
out.append((off, a, b))
|
||||
self.changes.setdefault(off, [a]).append(b)
|
||||
self.prev = cur
|
||||
return out, bad
|
||||
|
||||
|
||||
# -------------------------------------------------------------------- marks --
|
||||
def read_marker():
|
||||
"""Non-blocking read of a phase label from stdin. Returns str or None."""
|
||||
try:
|
||||
r, _, _ = select.select([sys.stdin], [], [], 0)
|
||||
except Exception:
|
||||
return None
|
||||
if not r:
|
||||
return None
|
||||
line = sys.stdin.readline()
|
||||
if not line:
|
||||
return None
|
||||
return line.strip() or "(blank)"
|
||||
|
||||
|
||||
def fmt(v):
|
||||
if v is None:
|
||||
return "UNREADABLE"
|
||||
if isinstance(v, str):
|
||||
return v
|
||||
if isinstance(v, int) and abs(v) > 0xFFFF:
|
||||
return "%#x" % v
|
||||
return str(v)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- main --
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="Read-only live watch of FIFA 17's FUT club/pile model")
|
||||
ap.add_argument("--interval", type=float, default=0.5, help="poll seconds (default 0.5)")
|
||||
ap.add_argument("--calib", type=float, default=10.0,
|
||||
help="idle calibration seconds; offsets that churn during this "
|
||||
"window are suppressed forever (default 10)")
|
||||
ap.add_argument("--scan-len", type=lambda s: int(s, 0), default=SCAN_LEN_DEFAULT,
|
||||
help="bytes of the CardsDb object to diff (default 0x22000)")
|
||||
ap.add_argument("--no-scan", action="store_true", help="named fields only, no diff scan")
|
||||
ap.add_argument("--all", action="store_true", help="report every changed dword (noisy)")
|
||||
ap.add_argument("--offsets", default="",
|
||||
help="comma-separated CardsDb offsets to always report, e.g. 0x160c8,0x1f00")
|
||||
args = ap.parse_args()
|
||||
|
||||
pid = find_pid()
|
||||
if pid is None:
|
||||
print("FIFA17.exe is not running. Start the game, reach the FUT hub, then run this.")
|
||||
return 1
|
||||
base = dll_base(pid)
|
||||
if base is None:
|
||||
print("FIFA17.exe (pid %d) is running but %s is not mapped yet." % (pid, DLL))
|
||||
print("Wait until FIFA reaches the main menu / FUT hub and run again.")
|
||||
return 1
|
||||
try:
|
||||
mem = Mem(pid)
|
||||
except Exception as e:
|
||||
print("cannot open /proc/%d/mem: %s" % (pid, e))
|
||||
print("Need ptrace_scope=0: sudo sysctl -w kernel.yama.ptrace_scope=0")
|
||||
return 1
|
||||
|
||||
print("FIFA pid=%d %s base=%#x" % (pid, DLL, base))
|
||||
named = snapshot_named(mem, base)
|
||||
for k in ("CardsDb.ptr", "CardInventory.ptr", "CardInventory2.ptr", "CompetitionMgr.ptr"):
|
||||
print(" %-22s %s" % (k, fmt(named.get(k))))
|
||||
cdb = named.get("CardsDb.ptr")
|
||||
if not cdb:
|
||||
print("\nCardsDb singleton is NULL -- the FUT layer has not been constructed yet.")
|
||||
print("Enter Ultimate Team first, then re-run. (Watching anyway.)")
|
||||
if not named.get("CardInventory.ptr"):
|
||||
print(" note: ION_CardInventory adapter is NULL -- the UI card model is not "
|
||||
"bound yet (expected outside the FUT hub).")
|
||||
|
||||
differ = None
|
||||
if cdb and not args.no_scan:
|
||||
length = probe_scan_len(mem, cdb, args.scan_len)
|
||||
if length == 0:
|
||||
print(" CardsDb object not readable -- diff scan disabled.")
|
||||
else:
|
||||
if length != args.scan_len:
|
||||
print(" CardsDb readable window shrunk to %#x (tail unmapped)." % length)
|
||||
differ = Differ(cdb, length, value_filter=not args.all)
|
||||
print(" diff scan over CardsDb[0 .. %#x) (%d dwords)" % (length, length // 4))
|
||||
watch_offsets = []
|
||||
for tok in args.offsets.split(","):
|
||||
tok = tok.strip()
|
||||
if tok:
|
||||
try:
|
||||
watch_offsets.append(int(tok, 0))
|
||||
except ValueError:
|
||||
print(" bad --offsets value: %r (ignored)" % tok)
|
||||
|
||||
print("\nCalibrating for %.0fs -- LEAVE THE GAME IDLE ON THE FUT HUB." % args.calib)
|
||||
print("After that, type a label + Enter before each action (hub / myclub / pack / send).")
|
||||
print("Ctrl-C prints the summary.\n")
|
||||
|
||||
t0 = time.time()
|
||||
phase = "boot"
|
||||
last_named = {}
|
||||
last_ids = None
|
||||
item_hist = []
|
||||
marks = []
|
||||
poll_n = 0
|
||||
calib_done = False
|
||||
|
||||
try:
|
||||
while True:
|
||||
if not mem.alive():
|
||||
print("[%s] FIFA17.exe exited." % time.strftime("%H:%M:%S"))
|
||||
break
|
||||
m = read_marker()
|
||||
if m is not None:
|
||||
phase = m
|
||||
marks.append((time.strftime("%H:%M:%S"), m))
|
||||
print("\n=========== PHASE: %s (%s) ===========" % (m, time.strftime("%H:%M:%S")))
|
||||
|
||||
calibrating = (time.time() - t0) < args.calib
|
||||
if calibrating and poll_n and poll_n % 4 == 0:
|
||||
sys.stdout.write("\r calibrating... %.0fs left "
|
||||
% (args.calib - (time.time() - t0)))
|
||||
sys.stdout.flush()
|
||||
poll_n += 1
|
||||
|
||||
cur = snapshot_named(mem, base)
|
||||
for k, v in cur.items():
|
||||
if k in last_named and last_named[k] == v:
|
||||
continue
|
||||
if k in last_named:
|
||||
line = "[%s][%s] %-28s %s -> %s" % (
|
||||
time.strftime("%H:%M:%S"), phase, k,
|
||||
fmt(last_named[k]), fmt(v))
|
||||
if k == "session.ready" and v == CM_READY_OK:
|
||||
line += " <== FUT SERVICE READY"
|
||||
if k == "session.phase" and isinstance(v, int) and v < 0:
|
||||
line += " <== FUT SESSION TORN DOWN"
|
||||
if k == "CardsDb.ptr" and not v:
|
||||
line += " <== CardsDb DESTROYED"
|
||||
print(line)
|
||||
last_named[k] = v
|
||||
|
||||
# --- item store membership: the direct answer to "does the client
|
||||
# --- actually hold the club, or only the squad?"
|
||||
if cdb:
|
||||
items = tree_items(mem, cdb)
|
||||
if items is None:
|
||||
if last_ids is not None:
|
||||
print("[%s][%s] item store unreadable" % (time.strftime("%H:%M:%S"), phase))
|
||||
last_ids = None
|
||||
else:
|
||||
ids = set(items)
|
||||
if last_ids is None or ids != last_ids:
|
||||
added = sorted(ids - (last_ids or set()))
|
||||
gone = sorted((last_ids or set()) - ids)
|
||||
print("[%s][%s] ITEM STORE count=%d (+%d / -%d)%s%s"
|
||||
% (time.strftime("%H:%M:%S"), phase, len(ids),
|
||||
len(added), len(gone),
|
||||
" added=%s" % added[:8] if added else "",
|
||||
" removed=%s" % gone[:8] if gone else ""))
|
||||
traded = [i for i, t in items.items() if t]
|
||||
if traded:
|
||||
print(" %d item(s) carry a tradeId (on the trade pile)"
|
||||
% len(traded))
|
||||
item_hist.append((time.strftime("%H:%M:%S"), phase, len(ids)))
|
||||
last_ids = ids
|
||||
|
||||
if differ is not None:
|
||||
hits, bad = differ.poll(mem, calibrating)
|
||||
if bad and not calibrating:
|
||||
print("[%s][%s] %d page(s) of the CardsDb window unreadable this poll"
|
||||
% (time.strftime("%H:%M:%S"), phase, len(bad)))
|
||||
if hits and not calibrating:
|
||||
shown = hits[:MAX_REPORTS_PER_POLL]
|
||||
for off, a, b in shown:
|
||||
tag = ""
|
||||
if b == a + 1:
|
||||
tag = " (+1)"
|
||||
elif b == a - 1:
|
||||
tag = " (-1)"
|
||||
elif a == 0:
|
||||
tag = " (0 -> %d)" % b
|
||||
elif b == 0:
|
||||
tag = " (%d -> 0)" % a
|
||||
print("[%s][%s] cdb+%#07x %d -> %d%s"
|
||||
% (time.strftime("%H:%M:%S"), phase, off, a, b, tag))
|
||||
if len(hits) > len(shown):
|
||||
print("[%s][%s] ... and %d more changed dwords (use --offsets to lock on)"
|
||||
% (time.strftime("%H:%M:%S"), phase, len(hits) - len(shown)))
|
||||
|
||||
for off in watch_offsets:
|
||||
if not cdb:
|
||||
break
|
||||
v = mem.i32(cdb + off)
|
||||
k = "watch cdb+%#x" % off
|
||||
if k in last_named and last_named[k] == v:
|
||||
continue
|
||||
if k in last_named:
|
||||
print("[%s][%s] %-28s %s -> %s" % (time.strftime("%H:%M:%S"), phase,
|
||||
k, fmt(last_named[k]), fmt(v)))
|
||||
last_named[k] = v
|
||||
|
||||
if not calibrating and not calib_done:
|
||||
calib_done = True
|
||||
print("\r calibration done -- %d idle-churn offset(s) suppressed. "
|
||||
"Label your actions now. " % (len(differ.hot) if differ else 0))
|
||||
time.sleep(args.interval)
|
||||
except KeyboardInterrupt:
|
||||
print("\n\nstopped")
|
||||
|
||||
# ------------------------------------------------------------- summary --
|
||||
print("\n" + "=" * 62)
|
||||
print("SUMMARY (paste this)")
|
||||
print("=" * 62)
|
||||
print("pid=%d %s base=%#x failed reads=%d" % (pid, DLL, base, mem.fails))
|
||||
print("phases marked: %s" % (", ".join("%s@%s" % (m, t) for t, m in marks) or "(none)"))
|
||||
print("\nitem-store count over time (the MY CLUB question):")
|
||||
if item_hist:
|
||||
for t, ph, n in item_hist:
|
||||
print(" %s [%s] %d items" % (t, ph, n))
|
||||
else:
|
||||
print(" (never read)")
|
||||
|
||||
print("\nfinal named fields:")
|
||||
for k in sorted(last_named):
|
||||
print(" %-28s %s" % (k, fmt(last_named[k])))
|
||||
if differ is not None:
|
||||
print("\nsuppressed as idle-churn: %d offsets" % len(differ.hot))
|
||||
if differ.changes:
|
||||
print("candidate fields (changed only AFTER calibration), "
|
||||
"most-changed last:")
|
||||
for off in sorted(differ.changes, key=lambda o: len(differ.changes[o])):
|
||||
vals = differ.changes[off]
|
||||
seq = " -> ".join(str(v) for v in vals[:12])
|
||||
if len(vals) > 12:
|
||||
seq += " -> ... (%d values)" % len(vals)
|
||||
print(" cdb+%#07x : %s" % (off, seq))
|
||||
else:
|
||||
print("NO candidate fields changed after calibration.")
|
||||
print("If that held across the MY CLUB phase, the counter is NOT in the")
|
||||
print("CardsDb object -- it lives in FIFA17.exe's own view model.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,82 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Watch FIFA 17's FUT online-mode state machine live (READ-ONLY, no patching).
|
||||
|
||||
Polls FUT::CompetitionManager (singleton global 0x1802e6328) and prints when its
|
||||
phase / ready-token / state-stack changes. Use to discover WHICH in-game context
|
||||
starts the online-mode handshake (the store gate is downstream of it).
|
||||
|
||||
phase mgr+0x218 -1=idle, then 0->1->2->3
|
||||
ready mgr+0x6d4 becomes 0x1fbd0 when "service ready"
|
||||
stackIdx mgr+0x214 -1=empty state stack
|
||||
|
||||
Run: python3 tools/watch_online_mode.py (Ctrl-C to stop)
|
||||
Then navigate FIFA: FUT hub, Store, Online Seasons, FUT Champions, Draft, etc.
|
||||
Needs FIFA running + read access to /proc/PID/mem (ptrace_scope=0).
|
||||
"""
|
||||
import glob, struct, time, sys
|
||||
|
||||
IMG_BASE = 0x180000000
|
||||
SINGLETON_VA = 0x1802e6328
|
||||
OFF_PHASE, OFF_READY, OFF_STKIDX = 0x218, 0x6d4, 0x214
|
||||
DLL = "CardsDLL"
|
||||
|
||||
|
||||
def find_pid():
|
||||
for d in glob.glob('/proc/[0-9]*'):
|
||||
try:
|
||||
if open(d + '/comm').read().strip() == 'FIFA17.exe':
|
||||
return int(d.split('/')[-1])
|
||||
except Exception:
|
||||
pass
|
||||
raise SystemExit("FIFA17.exe not running")
|
||||
|
||||
|
||||
def cardsdll_base(pid):
|
||||
for line in open(f'/proc/{pid}/maps'):
|
||||
if DLL in line:
|
||||
return int(line.split('-')[0], 16)
|
||||
raise SystemExit("CardsDLL not mapped")
|
||||
|
||||
|
||||
def rd(mem, va, n):
|
||||
try:
|
||||
mem.seek(va); return mem.read(n)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def i32(b):
|
||||
return struct.unpack('<i', b)[0] if b and len(b) == 4 else None
|
||||
|
||||
|
||||
def main():
|
||||
pid = find_pid()
|
||||
base = cardsdll_base(pid)
|
||||
gva = base + (SINGLETON_VA - IMG_BASE)
|
||||
print(f"FIFA pid={pid} CardsDLL base={base:#x} singleton@{gva:#x}")
|
||||
print("watching phase/ready/stackIdx — navigate FIFA now (Ctrl-C to stop)")
|
||||
mem = open(f'/proc/{pid}/mem', 'rb')
|
||||
last = None
|
||||
while True:
|
||||
p = rd(mem, gva, 8)
|
||||
mgr = struct.unpack('<Q', p)[0] if p else 0
|
||||
if mgr:
|
||||
phase = i32(rd(mem, mgr + OFF_PHASE, 4))
|
||||
ready = i32(rd(mem, mgr + OFF_READY, 4))
|
||||
stk = i32(rd(mem, mgr + OFF_STKIDX, 4))
|
||||
cur = (mgr, phase, ready, stk)
|
||||
else:
|
||||
cur = (0, None, None, None)
|
||||
if cur != last:
|
||||
rt = f"{ready:#x}" if ready is not None else "?"
|
||||
print(f"[{time.strftime('%H:%M:%S')}] mgr={mgr:#x} phase={phase} "
|
||||
f"ready={rt}{' <== READY!' if ready == 0x1fbd0 else ''} stackIdx={stk}")
|
||||
last = cur
|
||||
time.sleep(0.5)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except KeyboardInterrupt:
|
||||
print("\nstopped")
|
||||
Reference in New Issue
Block a user