1fb664710a
Adds the app-centric FLE bridge direction (direction.md) replacing the Blaze-backend route as primary plan, plus a corrected foundational test procedure and snapshot/diff/apply tooling for reverse-engineering FLE's Freeze Lineup write mechanism. Also picks up prior untracked research docs (status-review, fut-integration-options, fifa23-startup-flow, track-c) and existing capture/export tooling that hadn't been committed yet.
171 lines
5.5 KiB
Python
171 lines
5.5 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
profile-exporter/export_profile.py
|
|
Reads FIFA 23 local profile/settings files and exports what can be read.
|
|
|
|
FIFA 23 uses the Frostbite FBCHUNKS container format for saves.
|
|
This exporter handles:
|
|
- fifasetup.ini → plain text, fully readable
|
|
- ProfileOptions → FBCHUNKS binary (partial parse of header + string scan)
|
|
- Settings* → FBCHUNKS binary (contains "Personal Settings 1" label)
|
|
- filesystemcache/ → JSON files, fully readable
|
|
- anadius.cfg → Valve KeyValues format, contains persona info
|
|
|
|
Usage:
|
|
python3 export_profile.py [--output out.json]
|
|
"""
|
|
|
|
import struct
|
|
import json
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
from datetime import datetime
|
|
|
|
STEAM_PFX = Path.home() / ".local/share/Steam/steamapps/compatdata/3887260930/pfx"
|
|
UMU_PFX = Path.home() / "Games/umu/fifa23-tools/pfx"
|
|
GAME_DIR = Path("/mnt/games/FIFA 23")
|
|
|
|
DOCS_PATHS = [
|
|
STEAM_PFX / "drive_c/users/steamuser/Documents/FIFA 23",
|
|
UMU_PFX / "drive_c/users/steamuser/Documents/FIFA 23",
|
|
]
|
|
|
|
def find_docs() -> Path | None:
|
|
for p in DOCS_PATHS:
|
|
if p.exists():
|
|
return p
|
|
return None
|
|
|
|
|
|
def read_ini(path: Path) -> dict:
|
|
out = {}
|
|
for line in path.read_text(errors="replace").splitlines():
|
|
line = line.strip()
|
|
if "=" in line and not line.startswith(";") and not line.startswith("#"):
|
|
k, _, v = line.partition("=")
|
|
out[k.strip()] = v.strip().strip('"')
|
|
return out
|
|
|
|
|
|
def parse_fbchunks_header(data: bytes) -> dict:
|
|
"""Extract what we can from the FBCHUNKS container without a full format spec."""
|
|
if not data[:8] == b"FBCHUNKS":
|
|
return {"error": "not FBCHUNKS"}
|
|
info: dict = {"magic": "FBCHUNKS", "size_bytes": len(data)}
|
|
# The label string appears at offset 0x12 in Settings files
|
|
# Try to pull the first null-terminated ASCII string after offset 8
|
|
try:
|
|
label_start = 0x12
|
|
end = data.index(b"\x00", label_start)
|
|
label = data[label_start:end].decode("ascii", errors="ignore")
|
|
if label:
|
|
info["label"] = label
|
|
except (ValueError, IndexError):
|
|
pass
|
|
# String-scan for recognisable names/values
|
|
strings = re.findall(rb"[ -~]{6,}", data)
|
|
info["readable_strings_sample"] = [s.decode("ascii") for s in strings[:40]]
|
|
return info
|
|
|
|
|
|
def read_filesystem_cache(docs: Path) -> dict:
|
|
cache_dir = docs / "filesystemcache"
|
|
out = {}
|
|
if not cache_dir.exists():
|
|
return out
|
|
for f in cache_dir.rglob("*"):
|
|
if f.is_file():
|
|
try:
|
|
out[str(f.relative_to(cache_dir))] = json.loads(f.read_text())
|
|
except Exception:
|
|
out[str(f.relative_to(cache_dir))] = f.read_text(errors="replace")
|
|
return out
|
|
|
|
|
|
def parse_anadius_cfg(path: Path) -> dict:
|
|
"""Parse Valve KeyValues (VDF) format anadius.cfg for persona/user info."""
|
|
out: dict = {}
|
|
current_section: str | None = None
|
|
for line in path.read_text(errors="replace").splitlines():
|
|
line = line.strip()
|
|
if line.startswith('"') and line.endswith('"') and "{" not in line:
|
|
# lone quoted string — section name coming next
|
|
current_section = line.strip('"')
|
|
elif line.startswith('"') and "\t" in line:
|
|
parts = [p.strip().strip('"') for p in line.split("\t") if p.strip()]
|
|
if len(parts) == 2:
|
|
key, val = parts[0], parts[1]
|
|
section_key = f"{current_section}.{key}" if current_section else key
|
|
out[section_key] = val
|
|
return out
|
|
|
|
|
|
def export_settings_files(docs: Path) -> list:
|
|
settings_dir = docs / "settings"
|
|
entries = []
|
|
if not settings_dir.exists():
|
|
return entries
|
|
for f in sorted(settings_dir.iterdir()):
|
|
if f.is_file():
|
|
data = f.read_bytes()
|
|
entry: dict = {
|
|
"file": f.name,
|
|
"size_bytes": len(data),
|
|
"mtime": datetime.fromtimestamp(f.stat().st_mtime).isoformat(),
|
|
}
|
|
if data[:8] == b"FBCHUNKS":
|
|
entry["format"] = "FBCHUNKS"
|
|
entry["header"] = parse_fbchunks_header(data)
|
|
elif data and all(0x20 <= b < 0x7F or b in (0x09, 0x0A, 0x0D) for b in data[:256]):
|
|
entry["format"] = "text"
|
|
entry["content"] = f.read_text(errors="replace")[:2000]
|
|
else:
|
|
entry["format"] = "binary"
|
|
entries.append(entry)
|
|
return entries
|
|
|
|
|
|
def main():
|
|
out_path = None
|
|
if "--output" in sys.argv:
|
|
idx = sys.argv.index("--output")
|
|
out_path = Path(sys.argv[idx + 1])
|
|
|
|
docs = find_docs()
|
|
result: dict = {
|
|
"exported_at": datetime.utcnow().isoformat() + "Z",
|
|
"docs_path": str(docs) if docs else None,
|
|
}
|
|
|
|
# fifasetup.ini
|
|
if docs:
|
|
ini_path = docs / "fifasetup.ini"
|
|
if ini_path.exists():
|
|
result["fifasetup_ini"] = read_ini(ini_path)
|
|
|
|
# anadius.cfg (persona / user info)
|
|
anadius_cfg = GAME_DIR / "anadius.cfg"
|
|
if anadius_cfg.exists():
|
|
result["anadius_cfg"] = parse_anadius_cfg(anadius_cfg)
|
|
|
|
# Settings/ save files
|
|
if docs:
|
|
result["settings_files"] = export_settings_files(docs)
|
|
|
|
# filesystemcache/
|
|
if docs:
|
|
result["filesystem_cache"] = read_filesystem_cache(docs)
|
|
|
|
output = json.dumps(result, indent=2, ensure_ascii=False)
|
|
|
|
if out_path:
|
|
out_path.write_text(output)
|
|
print(f"Written to {out_path}")
|
|
else:
|
|
print(output)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|