#!/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('= (1 << 31) else v def anchor_hits(img): """Addresses of every SHARED_ANCHOR qword, 8-byte aligned.""" pat = struct.pack(' 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(' len(lay): break bit, h4, width, flags = struct.unpack_from('> 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('