Files
UmaViewer/tools/uma_common.py
T
ritenseki fbdc97c164 tools: rebuild the asset dump toolchain in-repo
The previous toolchain (C:\Users\riten\Desktop\UmaCrack\) was deleted, taking
with it the only way to read authoritative field names out of the game bundles.
Reimplemented from the decryption logic already in Assets/Scripts.

- uma_common.py        key constants, bundle XOR, MetaDb (path->hash, UnityPy load)
- dump_meta.py         decrypt meta -> meta_plain.db; find / info / songs
- dump_bundle.py       list / tree / json / save / gameobjects for any bundle
- dump_cutt_typetree.py  authoritative LiveTimelineWorkSheet schema per song
- scan_live_tracks.py  all 59 songs -> out/scan_{keys,groups}.csv, scan_summary.txt

cutt bundles ship full TypeTrees, so field names and keyframe counts read back
out of them are ground truth. All 70 worksheets across all 59 songs share one
128-field schema.

Needs UnityPy + apsw-sqlite3mc (not plain apsw); see tools/requirements.txt.
Generated output under tools/out/ is gitignored (meta_plain.db alone is 151 MB).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 01:10:07 +08:00

437 lines
16 KiB
Python

"""Shared library for the UmaViewer Python asset tools.
This is a Python re-implementation of the decryption logic that lives in the
C# side of this repository:
* ``Assets/Scripts/Config.cs`` -- the key constants
* ``Assets/Scripts/UmaAssetBundleStream.cs`` -- asset bundle XOR cipher
* ``Assets/Scripts/UmaDatabase/UmaDatabaseEntry.cs``-- per-bundle key expansion
* ``Assets/Scripts/UmaDatabase/UmaDatabaseController.cs`` -- meta DB cipher
Nothing here writes to the game folder; every file is opened read-only.
----------------------------------------------------------------------------
Setup
----------------------------------------------------------------------------
python3 -m venv ~/.venvs/umatools
~/.venvs/umatools/bin/python -m pip install -r tools/requirements.txt
Then run any tool with that interpreter, e.g.
~/.venvs/umatools/bin/python tools/dump_cutt_typetree.py 1157
----------------------------------------------------------------------------
Game folder
----------------------------------------------------------------------------
The tools look for the game's ``Persistent`` folder (the one containing
``meta`` and ``dat/``) in this order:
1. ``--game-path`` command line option
2. ``$UMA_GAME_PATH`` environment variable
3. the paths in :data:`DEFAULT_GAME_PATHS`
----------------------------------------------------------------------------
How the encryption works
----------------------------------------------------------------------------
meta database
A SQLite database encrypted with SQLite3 Multiple Ciphers, cipher index 3
(= ChaCha20 / "sqleet" scheme, non-legacy defaults). The passphrase is
``DBKey[i] XOR DBBaseKey[i % 13]`` (32 raw bytes, see :func:`meta_key`).
We hand those raw bytes to sqlite3mc through ``PRAGMA hexkey`` which is
the exact equivalent of the C# ``sqlite3_key(db, bytes, 32)`` call.
asset bundles
``dat/<first 2 chars of hash>/<hash>``. The first 256 bytes are stored in
the clear; every byte at file offset ``p >= 256`` is XORed with
``FKey[p % 88]`` where ``FKey`` is built from the 11-byte ``ABKey`` and the
per-bundle 64-bit key stored in the meta table (column ``e``):
FKey[i * 8 + j] = ABKey[i] XOR little_endian_int64(key)[j]
A key of 0 means the bundle is not encrypted.
"""
from __future__ import annotations
import os
import shutil
import struct
import sys
import tempfile
from dataclasses import dataclass
from typing import Iterable, Iterator, Optional
# --------------------------------------------------------------------------
# dependency check (friendly message instead of a bare ImportError)
# --------------------------------------------------------------------------
_MISSING: list[str] = []
try:
import apsw # provided by the `apsw-sqlite3mc` wheel
except ImportError: # pragma: no cover
apsw = None
_MISSING.append("apsw-sqlite3mc")
try:
import UnityPy
except ImportError: # pragma: no cover
UnityPy = None
_MISSING.append("UnityPy")
def require(*modules: str) -> None:
"""Abort with an actionable message if a needed dependency is absent."""
needed = [m for m in modules if m in _MISSING]
if not needed:
return
sys.stderr.write(
"\nMissing Python package(s): %s\n\n"
"Install them into a virtualenv, e.g.\n"
" python3 -m venv ~/.venvs/umatools\n"
" ~/.venvs/umatools/bin/python -m pip install -r %s\n"
"and re-run this script with ~/.venvs/umatools/bin/python\n\n"
% (", ".join(needed), os.path.join(os.path.dirname(__file__), "requirements.txt"))
)
raise SystemExit(2)
def has_sqlite3mc() -> bool:
"""True when the installed apsw build actually carries the MC ciphers."""
if apsw is None:
return False
try:
con = apsw.Connection(":memory:")
try:
list(con.execute("PRAGMA cipher"))
return True
finally:
con.close()
except Exception:
return False
# --------------------------------------------------------------------------
# keys -- verbatim from Assets/Scripts/Config.cs
# --------------------------------------------------------------------------
DB_BASE_KEY = bytes(
[0xF1, 0x70, 0xCE, 0xA4, 0xDF, 0xCE, 0xA3, 0xE1,
0xA5, 0xD8, 0xC7, 0x0B, 0xD1, 0x00, 0x00, 0x00]
)
DB_KEY = bytes(
[0x6D, 0x5B, 0x65, 0x33, 0x63, 0x36,
0x63, 0x25, 0x54, 0x71, 0x2D, 0x73,
0x50, 0x53, 0x63, 0x38, 0x6D, 0x34,
0x37, 0x7B, 0x35, 0x63, 0x70, 0x23,
0x37, 0x34, 0x53, 0x29, 0x73, 0x43,
0x36, 0x33]
)
GLOBAL_DB_KEY = bytes(
[0x56, 0x63, 0x6B, 0x63, 0x42, 0x72, 0x37, 0x76, 0x65, 0x70, 0x41, 0x62]
)
AB_KEY = bytes([0x53, 0x2B, 0x46, 0x31, 0xE4, 0xA7, 0xB9, 0x47, 0x3E, 0x7C, 0xFB])
HEADER_SIZE = 256
FKEY_LEN = len(AB_KEY) * 8 # 88
#: sqlite3mc cipher index used by UmaDatabaseController.ReadMetaFromEncryptedDb
META_CIPHER = "chacha20" # == sqlite3mc_config(db, "cipher", 3)
DEFAULT_GAME_PATHS = (
"/mnt/d/Umamusume/umamusume_Data/Persistent",
os.path.expandvars(r"%USERPROFILE%\AppData\LocalLow\Cygames\umamusume"),
)
def meta_key(region: str = "jp") -> bytes:
"""Final meta-DB passphrase: ``DBKey[i] XOR DBBaseKey[i % 13]``.
Mirrors ``UmaDatabaseController.GenFinalKey``.
"""
key = GLOBAL_DB_KEY if region.lower() in ("global", "en") else DB_KEY
return bytes(key[i] ^ DB_BASE_KEY[i % 13] for i in range(len(key)))
# --------------------------------------------------------------------------
# asset bundle cipher
# --------------------------------------------------------------------------
def build_fkey(key: int) -> bytes:
"""Expand a per-bundle 64-bit key into the 88-byte XOR keystream.
Mirrors ``UmaDatabaseEntry.FKey``.
"""
key_bytes = struct.pack("<q", key) # signed, little endian
out = bytearray(FKEY_LEN)
for i, b in enumerate(AB_KEY):
base = i << 3
for j in range(8):
out[base + j] = b ^ key_bytes[j]
return bytes(out)
def decrypt_bundle_bytes(data: bytes, key: int) -> bytes:
"""Decrypt a whole asset bundle image held in memory."""
if not key or len(data) <= HEADER_SIZE:
return bytes(data)
fk = build_fkey(key)
body = data[HEADER_SIZE:]
n = len(body)
# byte at file offset p uses fk[p % 88]; body[0] is at offset HEADER_SIZE
off = HEADER_SIZE % FKEY_LEN
rotated = fk[off:] + fk[:off]
stream = (rotated * (n // FKEY_LEN + 1))[:n]
plain = (int.from_bytes(body, "big") ^ int.from_bytes(stream, "big")).to_bytes(n, "big")
return bytes(data[:HEADER_SIZE]) + plain
def decrypt_bundle_file(path: str, key: int) -> bytes:
with open(path, "rb") as fh:
return decrypt_bundle_bytes(fh.read(), key)
# --------------------------------------------------------------------------
# meta database
# --------------------------------------------------------------------------
@dataclass
class MetaEntry:
"""One row of the meta ``a`` table (see UmaDatabaseEntry.cs)."""
type: str # column m, e.g. "_3d_cutt"
name: str # column n, the asset path, e.g. cutt/cutt_son1157/type_01
url: str # column h, the hashed filename under dat/
checksum: str # column c
prerequisites: str # column d, ';' separated dependency asset paths
key: int # column e, per-bundle cipher key (0 = plaintext bundle)
@property
def is_encrypted(self) -> bool:
return self.key != 0
def path(self, game_path: str) -> str:
return os.path.join(game_path, "dat", self.url[:2], self.url)
def deps(self) -> list[str]:
return [d for d in (self.prerequisites or "").split(";") if d]
def find_game_path(explicit: Optional[str] = None) -> str:
candidates = []
if explicit:
candidates.append(explicit)
env = os.environ.get("UMA_GAME_PATH")
if env:
candidates.append(env)
candidates.extend(DEFAULT_GAME_PATHS)
for c in candidates:
if c and os.path.isfile(os.path.join(c, "meta")):
return c
raise SystemExit(
"Could not locate the game's Persistent folder (needs a 'meta' file).\n"
"Tried: %s\nPass --game-path or set $UMA_GAME_PATH." % ", ".join(filter(None, candidates))
)
class MetaDb:
"""Read-only access to the encrypted ``meta`` SQLite database.
The game keeps ``meta`` open, so we work on a temporary copy by default.
"""
def __init__(self, game_path: Optional[str] = None, region: str = "jp", copy: bool = True,
readonly: bool = True):
require("apsw-sqlite3mc")
self.game_path = find_game_path(game_path)
self.meta_path = os.path.join(self.game_path, "meta")
self._tmp = None
src = self.meta_path
if copy:
fd, tmp = tempfile.mkstemp(prefix="uma_meta_", suffix=".db")
os.close(fd)
shutil.copyfile(self.meta_path, tmp)
self._tmp = tmp
src = tmp
elif not readonly:
raise ValueError("refusing to open the game's meta file writable; use copy=True")
flags = apsw.SQLITE_OPEN_READONLY if readonly else (
apsw.SQLITE_OPEN_READWRITE | apsw.SQLITE_OPEN_CREATE
)
self.con = apsw.Connection(src, flags=flags)
key = meta_key(region)
try:
list(self.con.execute("PRAGMA cipher='%s'" % META_CIPHER))
list(self.con.execute("PRAGMA hexkey='%s'" % key.hex()))
list(self.con.execute("SELECT name FROM sqlite_master LIMIT 1"))
except Exception as exc:
self.close()
raise SystemExit(
"Failed to decrypt the meta database (%s).\n"
"Either the keys in Assets/Scripts/Config.cs are out of date, or the\n"
"installed apsw build has no SQLite3MultipleCiphers support "
"(need the 'apsw-sqlite3mc' wheel, not plain 'apsw')." % exc
)
# -- lifecycle ---------------------------------------------------------
def close(self) -> None:
try:
self.con.close()
except Exception:
pass
if self._tmp and os.path.exists(self._tmp):
try:
os.remove(self._tmp)
except OSError:
pass
self._tmp = None
def __enter__(self) -> "MetaDb":
return self
def __exit__(self, *exc) -> None:
self.close()
# -- queries -----------------------------------------------------------
_COLS = "m,n,h,c,d,e"
def _rows(self, where: str, args: Iterable = ()) -> Iterator[MetaEntry]:
sql = "SELECT %s FROM a %s" % (self._COLS, where)
for m, n, h, c, d, e in self.con.execute(sql, tuple(args)):
yield MetaEntry(m or "", n or "", h or "", c or "", d or "", e or 0)
def get(self, name: str) -> Optional[MetaEntry]:
for e in self._rows("WHERE n = ?", (name,)):
return e
return None
def like(self, pattern: str) -> list[MetaEntry]:
return list(self._rows("WHERE n LIKE ? ORDER BY n", (pattern,)))
def all(self) -> list[MetaEntry]:
return list(self._rows("ORDER BY n"))
def song_ids(self) -> list[str]:
"""All live song ids that have a ``cutt/cutt_son<id>/`` bundle group."""
import re
ids = set()
for (n,) in self.con.execute("SELECT n FROM a WHERE n LIKE 'cutt/cutt_son%'"):
m = re.match(r"cutt/cutt_son(\d+)/", n or "")
if m:
ids.add(m.group(1))
return sorted(ids, key=int)
# -- bundle loading ----------------------------------------------------
def bundle_bytes(self, name_or_entry) -> bytes:
entry = name_or_entry if isinstance(name_or_entry, MetaEntry) else self.get(name_or_entry)
if entry is None:
raise KeyError("no meta entry named %r" % name_or_entry)
p = entry.path(self.game_path)
if not os.path.isfile(p):
raise FileNotFoundError(
"%s -> %s (not downloaded; open it once in the game or in UmaViewer)" % (entry.name, p)
)
return decrypt_bundle_file(p, entry.key)
def load(self, name_or_entry, with_deps: bool = False):
"""Decrypt and open a bundle with UnityPy; returns an Environment."""
require("UnityPy")
import io
entry = name_or_entry if isinstance(name_or_entry, MetaEntry) else self.get(name_or_entry)
if entry is None:
raise KeyError("no meta entry named %r" % name_or_entry)
env = UnityPy.Environment()
if with_deps:
for dep in entry.deps():
try:
env.load_file(io.BytesIO(self.bundle_bytes(dep)), name=dep, is_dependency=True)
except Exception as exc:
sys.stderr.write(" warning: dependency %s not loaded (%s)\n" % (dep, exc))
env.load_file(io.BytesIO(self.bundle_bytes(entry)), name=entry.name)
return env
# --------------------------------------------------------------------------
# helpers shared by the cutt tools
# --------------------------------------------------------------------------
#: A MonoBehaviour is a LiveTimelineWorkSheet when its TypeTree has this field.
WORKSHEET_MARKER = "cameraPosKeys"
def is_worksheet(obj) -> bool:
node = getattr(getattr(obj, "serialized_type", None), "node", None)
if node is None:
return False
return any(ch.m_Name == WORKSHEET_MARKER for ch in node.m_Children)
def worksheets(env):
"""Yield ``(name, obj, typetree_root_node)`` for each WorkSheet in *env*."""
for obj in env.objects:
if obj.type.name != "MonoBehaviour" or not is_worksheet(obj):
continue
node = obj.serialized_type.node
try:
name = obj.read_typetree().get("m_Name") or "?"
except Exception:
name = "?"
yield name, obj, node
def count_keyframes(value) -> int:
"""Total number of timeline keyframes reachable from *value*.
Every timeline key list is serialized as ``{_attribute, _playMode,
thisList}``; we sum the ``thisList`` lengths and never descend into the
keyframes themselves.
"""
if isinstance(value, dict):
if "thisList" in value:
tl = value["thisList"]
return len(tl) if isinstance(tl, list) else 0
return sum(count_keyframes(v) for v in value.values() if isinstance(v, (dict, list)))
if isinstance(value, list):
return sum(count_keyframes(v) for v in value if isinstance(v, (dict, list)))
return 0
def count_groups(value) -> int:
"""Size of the top-level container of a track field.
* key list (``thisList``) -> number of keyframes
* list of named track groups -> number of groups
* struct of several key lists -> number of sub key lists
"""
if isinstance(value, list):
return len(value)
if isinstance(value, dict):
if "thisList" in value:
tl = value["thisList"]
return len(tl) if isinstance(tl, list) else 0
return sum(1 for v in value.values() if isinstance(v, (dict, list)))
return 0
def group_names(value) -> list[str]:
"""Names of the track groups inside a list-style field, if any."""
if isinstance(value, list):
return [v.get("name", "") for v in value if isinstance(v, dict) and "name" in v]
return []
def add_common_args(parser) -> None:
parser.add_argument(
"--game-path",
default=None,
help="game Persistent folder (default: $UMA_GAME_PATH or %s)" % DEFAULT_GAME_PATHS[0],
)
parser.add_argument(
"--region", default="jp", choices=("jp", "global"), help="which DB key to use (default: jp)"
)