9a0a76c9f4
The pool now comes from the game's OWN resident database, not from a rating index plus guesses. tools/db_dump.py walked the client's self-describing table catalog read-only and wrote data/tables/ (149 tables, 55MB); tools/build_player_facts.py turned it into data/player_facts.json; data/pool.json is the compact form fut_cards loads. MEASURED, per player: position (players.preferredposition1), nationality, teamid, leagueid (via leagueteamlinks), and the six card attributes. The six attributes are NOT columns -- they are a weighted sum of the 29 base attributes, and the weights are read out of the game's own playerattributesmapping table rather than from published formulas. Checked against real FIFA 17 cards: Messi 89/90/86/96/26/61 and Ibrahimovic 72/90/81/85/31/86 are EXACT, Suarez is one off on physical, Ronaldo within two on pace and shooting. Keepers come out directly from the gk* columns. What this fixes on screen: Kaka was a CDM, Bale a CM, Suarez a GK, and every attribute was derived from the rating. Now Bale is RW, Boateng is a CB with 90 defending, De Gea is a GK, and a bronze pack deals real bronze players in real positions. REVERSAL, deliberate: nation/team/league were being sent as ZERO so the client would fill its own values (the merge fills those three only when they arrive zero). Now that we hold the game's own numbers there is nothing to gain from zeros, and they actively hurt -- club-stats drill-downs bucket by the item's own nation and leagueId, so a club full of zeros would have quietly emptied the per-nation and per-league panels fixed the day before. Send the real values. The old rating-index path is kept as a fallback so the pool still builds without data/pool.json, and it now says out loud which of the two it used, because one is a measurement and the other is a guess. 439 + 61 checks green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VUT92pz6RWKih9dSr8ZpxW
570 lines
23 KiB
Python
570 lines
23 KiB
Python
#!/usr/bin/env python3
|
|
# -*- coding: utf-8 -*-
|
|
"""Dump FIFA 17's LOADED relational database (schema + every row) out of a live
|
|
FIFA17.exe, or out of a saved memory image.
|
|
|
|
READ-ONLY. /proc/PID/mem is opened 'rb' and only ever seek()/read(). There is
|
|
no write path in this file.
|
|
|
|
WHY THIS EXISTS
|
|
---------------
|
|
dbdata.dll on disk is packed (see dbdata_extract.py's docstring), so the game's
|
|
tables cannot be read off disk. They ARE fully resident in the running process,
|
|
in a self-describing form: the DB carries its own catalogue, its own column
|
|
names, and its own bit-level row layout. This tool walks that catalogue and
|
|
decodes the rows.
|
|
|
|
dbschema_probe.py got as far as the catalogue but had two defects, both fixed
|
|
here and both worth writing down because they are easy to re-introduce:
|
|
|
|
1. THE NAME IS AT anchor+0x08, NOT AT anchor-0x20. Every catalogue record --
|
|
table records and column records alike -- carries the constant qword
|
|
0x07c20760 (SHARED_ANCHOR). Reading the name from the start of the
|
|
32-byte-aligned slot picks up the PREVIOUS record's name, which silently
|
|
mislabels every table by one slot: what the old tool called "gkcoachcards"
|
|
is really `players`, what it called "teams" is really `teamplayerlinks`,
|
|
and so on. The mislabelling is invisible because both names are real.
|
|
The check that catches it: a table's column set must match its name
|
|
(`nations` must contain nationname, `players` must contain acceleration).
|
|
|
|
2. --list MIXED COLUMNS INTO THE TABLE LIST. Column records and table
|
|
records share the same anchor, so an anchor scan alone yields both. The
|
|
discriminator used here is the TABLE VTABLE: a record is a table if and
|
|
only if its descriptor's first qword is 0x07c31028. That takes 4126
|
|
anchor hits down to exactly 149 tables, with zero column names among them.
|
|
|
|
THE STRUCTURES (resolved live 2026-08-04, pid 52703, game in the FUT club UI)
|
|
------------------------------------------------------------------------------
|
|
CATALOGUE RECORD (table). Found by scanning for SHARED_ANCHOR; the record
|
|
starts 0x18 before it:
|
|
+0x00 void* descriptor
|
|
+0x08 u32 table name hash4 (repeated at descriptor+0x40)
|
|
+0x0c u32 column count
|
|
+0x10 void* column-definition array ("p2")
|
|
+0x18 u64 SHARED_ANCHOR 0x07c20760
|
|
+0x20 char* table name
|
|
(stride 0x30)
|
|
|
|
COLUMN-DEFINITION RECORD (0x30 bytes, p2[i]) -- the human-readable half:
|
|
+0x00 u32 kind: 1 = string, 2 = integer, 4 = date
|
|
+0x04 u32 name hash4
|
|
+0x08 u32 min value (as u32; may be negative, e.g. -1 for a position)
|
|
+0x0c u32 max value
|
|
+0x20 u64 SHARED_ANCHOR
|
|
+0x28 char* column name
|
|
|
|
TABLE DESCRIPTOR (at the record's descriptor pointer):
|
|
+0x00 u64 0x07c31028 (the table vtable -- the discriminator)
|
|
+0x30 void* ROW BLOCK
|
|
+0x40 u32 table name hash4 (self-identification)
|
|
+0x44 u32 row size in BYTES
|
|
+0x48 u32 max bit index (== rowsize*8 - 1)
|
|
+0x7c u32 ROW COUNT
|
|
+0x82 u16 column count
|
|
+0x88 ... column layout array, `ncol` records of 16 bytes:
|
|
u32 bit_offset, u32 name hash4, u32 bit_width, u32 flags
|
|
(sorted by hash4, so it needs sorting by bit_offset to read)
|
|
|
|
ROW BLOCK (pointed at by descriptor+0x30) is preceded by a 16-byte header:
|
|
-0x10 u32 byte size of the block
|
|
-0x08 u64 0x2c020e60 (a second constant, a useful check)
|
|
Rows are `rowsize` bytes, densely packed, no gaps.
|
|
|
|
FIELD DECODING
|
|
--------------
|
|
value = (int.from_bytes(row, 'little') >> bit_offset) & ((1 << width) - 1)
|
|
Integer columns then add `min`, so a column declared min=-1 max=32 stores 0..33
|
|
and reads back -1..32. This was confirmed on managercards.carddbid (bit 64,
|
|
width 24) reading 1000001 on row 0 -- the exact value docs/managercards_ids.txt
|
|
recorded from a completely independent live sweep.
|
|
|
|
STRINGS come in two forms and the tool decides per column:
|
|
* INLINE when bit_offset + width <= the next column's bit_offset: the field
|
|
is a fixed-size NUL-terminated char array inside the row. This is how
|
|
nations.nationname, leagues.leaguename and teams.teamname are stored, and
|
|
all three decode to real names.
|
|
* OFFSET otherwise: the field is a 32-bit offset into a string pool that is
|
|
NOT resident as a flat blob (searched for; not found). Those columns are
|
|
emitted as raw integers and flagged `"storage": "offset-unresolved"` in the
|
|
schema block. managercards.firstname/lastname and playernames.name are of
|
|
this kind. Player names are already available from data/roster.json via
|
|
dbdata_extract.py, so nothing depends on resolving them.
|
|
|
|
USAGE
|
|
-----
|
|
./db_dump.py --list # every table, row count, columns
|
|
./db_dump.py --schema players # one table's column layout
|
|
./db_dump.py --check # run the anchor checks, exit 1 on fail
|
|
./db_dump.py --dump players teams -o DIR # dump named tables as JSON
|
|
./db_dump.py --dump-all -o DIR # dump all 149
|
|
./db_dump.py --save-mem DIR # snapshot the process (do this FIRST;
|
|
# live memory is perishable)
|
|
./db_dump.py --mem DIR ... # work from a snapshot, no live game
|
|
|
|
Requires ptrace access to FIFA17.exe (ptrace_scope=1 + same uid is enough) when
|
|
reading live; --mem needs nothing but the snapshot directory.
|
|
"""
|
|
|
|
import argparse
|
|
import bisect
|
|
import json
|
|
import mmap
|
|
import os
|
|
import re
|
|
import struct
|
|
import sys
|
|
|
|
SHARED_ANCHOR = 0x07C20760 # on every catalogue record, at record+0x18/+0x20
|
|
TABLE_VTABLE = 0x07C31028 # descriptor[0] iff the record describes a table
|
|
BLOCK_MARK = 0x2C020E60 # rowblock[-0x08]
|
|
IDENT = re.compile(r'[A-Za-z][A-Za-z0-9_]*\Z')
|
|
|
|
# Anonymous mappings above this are the host libc arenas; the game DB is below.
|
|
MAX_VA = 0x200000000
|
|
|
|
KIND_STRING, KIND_INT, KIND_DATE = 1, 2, 4
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# memory access
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
def find_pid():
|
|
for d in os.listdir('/proc'):
|
|
if not d.isdigit():
|
|
continue
|
|
try:
|
|
if open('/proc/%s/comm' % d).read().strip() == 'FIFA17.exe':
|
|
return int(d)
|
|
except OSError:
|
|
pass
|
|
raise SystemExit("FIFA17.exe is not running (use --mem DIR to work offline)")
|
|
|
|
|
|
def anon_regions(pid):
|
|
out = []
|
|
for line in open('/proc/%d/maps' % pid):
|
|
p = line.split()
|
|
lo, hi = (int(x, 16) for x in p[0].split('-'))
|
|
perms = p[1]
|
|
path = p[5] if len(p) > 5 else ''
|
|
if 'r' not in perms or path or lo >= MAX_VA:
|
|
continue
|
|
out.append((lo, hi, perms))
|
|
return out
|
|
|
|
|
|
class Image(object):
|
|
"""Uniform read-only view over either a live process or a saved snapshot."""
|
|
|
|
def __init__(self, pid=None, memdir=None):
|
|
self.regions = [] # list of dicts lo/hi
|
|
self._buf = {}
|
|
if memdir:
|
|
idx = json.load(open(os.path.join(memdir, 'index.json')))
|
|
idx.sort(key=lambda r: r['lo'])
|
|
self.regions = idx
|
|
self.memdir = memdir
|
|
self.live = None
|
|
else:
|
|
self.live = open('/proc/%d/mem' % pid, 'rb', 0)
|
|
self.memdir = None
|
|
for lo, hi, perms in anon_regions(pid):
|
|
self.regions.append({'lo': lo, 'hi': hi, 'perms': perms})
|
|
self._los = [r['lo'] for r in self.regions]
|
|
|
|
# -- region buffers ----------------------------------------------------- #
|
|
def buf(self, i):
|
|
if i not in self._buf:
|
|
r = self.regions[i]
|
|
if self.memdir:
|
|
f = open(os.path.join(self.memdir, r['file']), 'rb')
|
|
self._buf[i] = mmap.mmap(f.fileno(), 0, prot=mmap.PROT_READ)
|
|
else:
|
|
self.live.seek(r['lo'])
|
|
self._buf[i] = self.live.read(r['hi'] - r['lo'])
|
|
return self._buf[i]
|
|
|
|
def _find(self, a):
|
|
i = bisect.bisect_right(self._los, a) - 1
|
|
if i >= 0 and self.regions[i]['lo'] <= a < self.regions[i]['hi']:
|
|
return i
|
|
return -1
|
|
|
|
def read(self, a, n):
|
|
if a is None or a <= 0:
|
|
return b''
|
|
i = self._find(a)
|
|
if i < 0:
|
|
return b''
|
|
o = a - self.regions[i]['lo']
|
|
return bytes(self.buf(i)[o:o + n])
|
|
|
|
def u32(self, a):
|
|
b = self.read(a, 4)
|
|
return struct.unpack('<I', b)[0] if len(b) == 4 else None
|
|
|
|
def u64(self, a):
|
|
b = self.read(a, 8)
|
|
return struct.unpack('<Q', b)[0] if len(b) == 8 else None
|
|
|
|
def cstr(self, a, maxlen=96):
|
|
b = self.read(a, maxlen)
|
|
j = b.find(b'\x00')
|
|
if j <= 0:
|
|
return None
|
|
s = b[:j]
|
|
return s.decode('latin1') if re.fullmatch(rb'[ -~]+', s) else None
|
|
|
|
def save(self, outdir):
|
|
"""Snapshot every region to disk (do this first: live data is perishable)."""
|
|
os.makedirs(outdir, exist_ok=True)
|
|
idx = []
|
|
for i, r in enumerate(self.regions):
|
|
d = self.buf(i)
|
|
fn = '%012x.bin' % r['lo']
|
|
with open(os.path.join(outdir, fn), 'wb') as fh:
|
|
fh.write(d)
|
|
idx.append({'lo': r['lo'], 'hi': r['lo'] + len(d),
|
|
'perms': r.get('perms', 'rw-p'), 'file': fn})
|
|
json.dump(idx, open(os.path.join(outdir, 'index.json'), 'w'), indent=1)
|
|
return sum(r['hi'] - r['lo'] for r in idx)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# catalogue walk
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
def s32(v):
|
|
return v - (1 << 32) if v is not None and v >= (1 << 31) else v
|
|
|
|
|
|
def anchor_hits(img):
|
|
"""Addresses of every SHARED_ANCHOR qword, 8-byte aligned."""
|
|
pat = struct.pack('<Q', SHARED_ANCHOR)
|
|
out = []
|
|
for i, r in enumerate(img.regions):
|
|
b = img.buf(i)
|
|
st = 0
|
|
while True:
|
|
j = b.find(pat, st)
|
|
if j < 0:
|
|
break
|
|
st = j + 1
|
|
if j % 8 == 0:
|
|
out.append(r['lo'] + j)
|
|
return out
|
|
|
|
|
|
def catalogue(img):
|
|
"""name -> table dict. Tables only: the descriptor vtable is the filter."""
|
|
tables = {}
|
|
for anch in anchor_hits(img):
|
|
name = img.cstr(img.u64(anch + 8) or 0, 64)
|
|
if not name or not IDENT.match(name):
|
|
continue
|
|
desc = img.u64(anch - 0x18)
|
|
head = img.read(desc or 0, 0x50)
|
|
if len(head) < 0x50 or struct.unpack_from('<Q', head, 0)[0] != TABLE_VTABLE:
|
|
continue
|
|
h4, ncol = struct.unpack_from('<II', img.read(anch - 0x10, 8), 0)
|
|
dh4, rowsz, maxbit, _ = struct.unpack_from('<IIII', head, 0x40)
|
|
if dh4 != h4: # descriptor must self-identify
|
|
continue
|
|
tables[name] = {
|
|
'name': name, 'anchor': anch, 'desc': desc, 'hash4': h4,
|
|
'ncol': ncol, 'p2': img.u64(anch - 8),
|
|
'rowsize': rowsz, 'maxbit': maxbit,
|
|
'rowcount': img.u32(desc + 0x7c),
|
|
'rowblock': img.u64(desc + 0x30),
|
|
}
|
|
return tables
|
|
|
|
|
|
def columns(img, t):
|
|
"""Full column list, sorted by bit offset, with names/kinds/ranges."""
|
|
defs = {}
|
|
for i in range(t['ncol']):
|
|
b = img.read(t['p2'] + i * 0x30, 0x30)
|
|
if len(b) < 0x30:
|
|
break
|
|
kind, h4 = struct.unpack_from('<II', b, 0)
|
|
mn, mx = struct.unpack_from('<II', b, 8)
|
|
nm = img.cstr(struct.unpack_from('<Q', b, 0x28)[0], 64)
|
|
if nm:
|
|
defs[h4] = (nm, kind, s32(mn), s32(mx))
|
|
lay = img.read(t['desc'] + 0x88, t['ncol'] * 16)
|
|
cols = []
|
|
for i in range(t['ncol']):
|
|
if (i + 1) * 16 > len(lay):
|
|
break
|
|
bit, h4, width, flags = struct.unpack_from('<IIII', lay, i * 16)
|
|
nm, kind, mn, mx = defs.get(h4, ('?%08x' % h4, 0, 0, 0))
|
|
cols.append({'name': nm, 'bit': bit, 'width': width, 'kind': kind,
|
|
'min': mn, 'max': mx, 'flags': flags})
|
|
cols.sort(key=lambda c: c['bit'])
|
|
# inline vs offset strings, decided by whether the declared width fits
|
|
for i, c in enumerate(cols):
|
|
nxt = cols[i + 1]['bit'] if i + 1 < len(cols) else t['rowsize'] * 8
|
|
if c['kind'] == KIND_STRING:
|
|
if c['bit'] + c['width'] <= nxt:
|
|
c['storage'] = 'inline-string'
|
|
else:
|
|
c['storage'] = 'offset-unresolved'
|
|
c['width'] = 32
|
|
else:
|
|
c['storage'] = 'int'
|
|
return cols
|
|
|
|
|
|
def read_rows(img, t, cols, limit=None):
|
|
n = t['rowcount'] or 0
|
|
if limit is not None:
|
|
n = min(n, limit)
|
|
rsz = t['rowsize']
|
|
if n == 0 or rsz == 0 or not t['rowblock']:
|
|
return []
|
|
blob = img.read(t['rowblock'], n * rsz)
|
|
if len(blob) < n * rsz:
|
|
raise RuntimeError('%s: row block short: got %d of %d bytes'
|
|
% (t['name'], len(blob), n * rsz))
|
|
out = []
|
|
for i in range(n):
|
|
row = blob[i * rsz:(i + 1) * rsz]
|
|
raw = int.from_bytes(row, 'little')
|
|
rec = {}
|
|
for c in cols:
|
|
if c['storage'] == 'inline-string':
|
|
lo = c['bit'] // 8
|
|
s = row[lo:lo + c['width'] // 8]
|
|
j = s.find(b'\x00')
|
|
rec[c['name']] = (s if j < 0 else s[:j]).decode('latin1')
|
|
else:
|
|
v = (raw >> c['bit']) & ((1 << c['width']) - 1)
|
|
rec[c['name']] = v + c['min'] if c['kind'] == KIND_INT else v
|
|
out.append(rec)
|
|
return out
|
|
|
|
|
|
def block_size(img, t):
|
|
h = img.read((t['rowblock'] or 0) - 0x10, 16)
|
|
if len(h) < 16:
|
|
return None, None
|
|
return struct.unpack_from('<I', h, 0)[0], struct.unpack_from('<Q', h, 8)[0]
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# checks -- every dump must be justified against something independent
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
CHECKS = []
|
|
|
|
|
|
def check(msg, cond):
|
|
CHECKS.append((msg, bool(cond)))
|
|
print(" [%s] %s" % ("PASS" if cond else "FAIL", msg))
|
|
return bool(cond)
|
|
|
|
|
|
def run_checks(img, tables):
|
|
print("sanity checks (independent ground truth):")
|
|
ok = True
|
|
|
|
# (1) the catalogue must not contain column names masquerading as tables
|
|
ok &= check("catalogue holds no known column name as a table "
|
|
"(acceleration/carddbid/assetid absent)",
|
|
not ({'acceleration', 'carddbid', 'assetid'} & set(tables)))
|
|
|
|
# (2) each table's columns must match its own name
|
|
for tn, must in (('nations', 'nationname'), ('leagues', 'leaguename'),
|
|
('teams', 'teamname'), ('players', 'acceleration'),
|
|
('managercards', 'carddbid'),
|
|
('teamplayerlinks', 'jerseynumber')):
|
|
t = tables.get(tn)
|
|
names = {c['name'] for c in columns(img, t)} if t else set()
|
|
ok &= check("table %-16s contains column %-14s" % (tn, must), must in names)
|
|
|
|
# (3) THE anchor: playerid 20801 is Cristiano Ronaldo, FIFA 17 -- 94 rated,
|
|
# a left winger, Portuguese, 185 cm, at Real Madrid.
|
|
t = tables['players']
|
|
cols = columns(img, t)
|
|
pl = {r['playerid']: r for r in read_rows(img, t, cols)}
|
|
r = pl.get(20801)
|
|
ok &= check("players has 20801", r is not None)
|
|
if r:
|
|
ok &= check(" 20801 overallrating == 94 (got %s)" % r['overallrating'],
|
|
r['overallrating'] == 94)
|
|
ok &= check(" 20801 preferredposition1 == 27 (LW) (got %s)"
|
|
% r['preferredposition1'], r['preferredposition1'] == 27)
|
|
ok &= check(" 20801 nationality == 38 (Portugal) (got %s)" % r['nationality'],
|
|
r['nationality'] == 38)
|
|
ok &= check(" 20801 height == 185 cm (got %s)" % r['height'], r['height'] == 185)
|
|
ok &= check(" 20801 preferredfoot == 1 (right) (got %s)" % r['preferredfoot'],
|
|
r['preferredfoot'] == 1)
|
|
|
|
# nation 38 must literally spell Portugal, from a different table
|
|
nt = tables['nations']
|
|
nat = {x['nationid']: x for x in read_rows(img, nt, columns(img, nt))}
|
|
ok &= check("nations[38].nationname == 'Portugal' (got %r)"
|
|
% (nat.get(38, {}).get('nationname')),
|
|
nat.get(38, {}).get('nationname') == 'Portugal')
|
|
|
|
# and teamplayerlinks must put him at Real Madrid
|
|
tp = tables['teamplayerlinks']
|
|
links = [x for x in read_rows(img, tp, columns(img, tp)) if x['playerid'] == 20801]
|
|
tt = tables['teams']
|
|
teams = {x['teamid']: x for x in read_rows(img, tt, columns(img, tt))}
|
|
names = sorted({teams.get(l['teamid'], {}).get('teamname') for l in links})
|
|
ok &= check("teamplayerlinks[20801] includes Real Madrid (got %s)" % (names,),
|
|
'Real Madrid' in names)
|
|
|
|
# (4) managercards' id space, measured independently in a previous session
|
|
# (docs/managercards_ids.txt: 416 ids from 1000001 upward)
|
|
mt = tables['managercards']
|
|
mc = read_rows(img, mt, columns(img, mt))
|
|
ids = sorted(x['carddbid'] for x in mc)
|
|
ok &= check("managercards row 0 carddbid == 1000001 (got %s)" % (ids[0] if ids else None),
|
|
ids and ids[0] == 1000001)
|
|
ok &= check("managercards ids all in [1000001, 1002000] (got %s..%s, n=%d)"
|
|
% (ids[0] if ids else None, ids[-1] if ids else None, len(ids)),
|
|
ids and 1000001 <= ids[0] and ids[-1] <= 1002000)
|
|
ok &= check("managercards assetid == carddbid on every row",
|
|
all(x['assetid'] == x['carddbid'] for x in mc))
|
|
|
|
# (5) every table's row block must carry the block marker, and every byte of
|
|
# rowcount*rowsize must actually be readable. (The u32 at block-0x10 is
|
|
# a byte count only for the heap-resident tables; for the ~17 small
|
|
# tables that live in the 0x07xxxxxx pool it is something else, so it is
|
|
# reported but not required.)
|
|
unmarked, short, oddsize = [], [], []
|
|
for n, t in tables.items():
|
|
if not t['rowcount'] or not t['rowblock']:
|
|
continue
|
|
need = t['rowcount'] * t['rowsize']
|
|
sz, mark = block_size(img, t)
|
|
if mark != BLOCK_MARK:
|
|
unmarked.append(n)
|
|
if len(img.read(t['rowblock'], need)) < need:
|
|
short.append(n)
|
|
if sz is None or sz < need:
|
|
oddsize.append(n)
|
|
ok &= check("every non-empty table's row block carries the 0x2c020e60 marker "
|
|
"(%d without)" % len(unmarked), not unmarked)
|
|
ok &= check("every non-empty table's rowcount*rowsize bytes are readable "
|
|
"(%d short)" % len(short), not short)
|
|
print(" [note] %d small pool-resident tables have a block-size field that is "
|
|
"not a byte count: %s" % (len(oddsize), ", ".join(sorted(oddsize))))
|
|
|
|
return ok
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
def dump_table(img, t, outdir, limit=None):
|
|
cols = columns(img, t)
|
|
rows = read_rows(img, t, cols, limit)
|
|
sz, mark = block_size(img, t)
|
|
doc = {
|
|
'table': t['name'],
|
|
'source': 'FIFA17.exe resident database (tools/db_dump.py)',
|
|
'rowcount': t['rowcount'],
|
|
'rowsize_bytes': t['rowsize'],
|
|
'rows_emitted': len(rows),
|
|
'rowblock': '0x%x' % (t['rowblock'] or 0),
|
|
'rowblock_bytes': sz,
|
|
'descriptor': '0x%x' % t['desc'],
|
|
'schema': [{'name': c['name'], 'bit': c['bit'], 'width': c['width'],
|
|
'kind': {1: 'string', 2: 'int', 4: 'date'}.get(c['kind'], 'unknown'),
|
|
'min': c['min'], 'max': c['max'], 'storage': c['storage']}
|
|
for c in cols],
|
|
'rows': rows,
|
|
}
|
|
os.makedirs(outdir, exist_ok=True)
|
|
p = os.path.join(outdir, t['name'] + '.json')
|
|
with open(p, 'w') as fh:
|
|
json.dump(doc, fh, ensure_ascii=False, separators=(',', ':'))
|
|
return p, len(rows), os.path.getsize(p)
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser(description=__doc__.split('\n')[0])
|
|
ap.add_argument('--pid', type=int)
|
|
ap.add_argument('--mem', help='work from a snapshot directory instead of a live game')
|
|
ap.add_argument('--save-mem', help='snapshot the process to this directory and exit')
|
|
ap.add_argument('--list', action='store_true')
|
|
ap.add_argument('--schema', action='append', default=[])
|
|
ap.add_argument('--dump', nargs='*')
|
|
ap.add_argument('--dump-all', action='store_true')
|
|
ap.add_argument('--check', action='store_true')
|
|
ap.add_argument('--limit', type=int)
|
|
ap.add_argument('-o', '--out', default='../data/tables')
|
|
a = ap.parse_args()
|
|
|
|
if a.mem:
|
|
img = Image(memdir=a.mem)
|
|
sys.stderr.write("snapshot %s: %d regions\n" % (a.mem, len(img.regions)))
|
|
else:
|
|
pid = a.pid or find_pid()
|
|
img = Image(pid=pid)
|
|
sys.stderr.write("FIFA17.exe pid %d: %d anonymous regions\n"
|
|
% (pid, len(img.regions)))
|
|
if a.save_mem:
|
|
n = img.save(a.save_mem)
|
|
sys.stderr.write("saved %.1f MB to %s\n" % (n / 1048576.0, a.save_mem))
|
|
return 0
|
|
|
|
tables = catalogue(img)
|
|
sys.stderr.write("catalogue: %d tables\n" % len(tables))
|
|
|
|
if a.list:
|
|
for n in sorted(tables):
|
|
t = tables[n]
|
|
cols = columns(img, t)
|
|
print("%-34s rows=%-7s rowsize=%-5d cols=%-4d | %s"
|
|
% (n, t['rowcount'], t['rowsize'], t['ncol'],
|
|
", ".join(c['name'] for c in cols[:6])))
|
|
return 0
|
|
|
|
for n in a.schema:
|
|
t = tables.get(n)
|
|
if not t:
|
|
print("%s: not in the catalogue" % n)
|
|
continue
|
|
print("=== %s rows=%s rowsize=%d bytes cols=%d rowblock=0x%x"
|
|
% (n, t['rowcount'], t['rowsize'], t['ncol'], t['rowblock'] or 0))
|
|
for c in columns(img, t):
|
|
print(" bit %4d w %-3d %-18s %-9s min %-8s max %s"
|
|
% (c['bit'], c['width'], c['name'], c['storage'], c['min'], c['max']))
|
|
|
|
rc = 0
|
|
if a.check:
|
|
rc = 0 if run_checks(img, tables) else 1
|
|
|
|
want = None
|
|
if a.dump_all:
|
|
want = sorted(tables)
|
|
elif a.dump is not None:
|
|
want = a.dump or sorted(tables)
|
|
if want:
|
|
total = 0
|
|
for n in want:
|
|
t = tables.get(n)
|
|
if not t:
|
|
sys.stderr.write(" %s: not in the catalogue\n" % n)
|
|
continue
|
|
try:
|
|
p, nr, nb = dump_table(img, t, a.out, a.limit)
|
|
except Exception as e: # noqa: BLE001
|
|
sys.stderr.write(" %-30s FAILED: %s\n" % (n, e))
|
|
rc = 1
|
|
continue
|
|
total += nb
|
|
sys.stderr.write(" %-34s %7d rows %9.1f KB %s\n"
|
|
% (n, nr, nb / 1024.0, p))
|
|
sys.stderr.write("wrote %.1f MB into %s\n" % (total / 1048576.0, a.out))
|
|
return rc
|
|
|
|
|
|
if __name__ == '__main__':
|
|
sys.exit(main())
|