Version 3.2.0: fast lists with loading spinners, names from the game files, INT docs and screenshots, cleanup

This commit is contained in:
2026-10-10 14:58:17 +02:00
parent fbde809370
commit 1586abb8e9
40 changed files with 449 additions and 19279 deletions

View File

@@ -8,4 +8,4 @@ Library layers (low -> high):
inject (WAD writing), originals (store), workspace (apply/revert), modpack (share)
tools cli (`ackit` command), verify/ (offline self-check, in-game test on Windows)
"""
__version__ = "3.1.0"
__version__ = "3.2.0"

View File

@@ -12,9 +12,6 @@ import subprocess
import numpy as np
import soundfile as sf
READABLE = (".wav", ".flac", ".ogg", ".oga", ".opus", ".mp3", ".aif", ".aiff", ".au", ".w64", ".caf")
FFMPEG_ONLY = (".m4a", ".aac", ".wma", ".mp4", ".webm", ".mkv", ".ac3")
class AudioError(Exception):
pass

View File

@@ -207,12 +207,6 @@ def _assign(positions, regions, issues, rel, what):
return out
def bank_languages(data, start, imports, exports, banks, issues=None, rel=""):
"""{bank offset in data: language} for localized banks (one bank per declared language, in that order).
Banks of other exports are language-neutral (not in the result)."""
return _assign(sorted(bk.at for bk in banks), localized_exports(data, start, imports, exports), issues, rel, "banks")
def bkpk_positions(data):
"""Offsets of the BKPK tables (docs/formats/wad.md) embedded in a package."""
out = []

View File

@@ -14,9 +14,6 @@ class BitReader:
self.pos += 1
return v
def bits_left(self) -> int:
return len(self.data) * 8 - self.pos
class BitWriter:
def __init__(self):
@@ -31,13 +28,6 @@ class BitWriter:
self.buf[self.pos >> 3] |= 1 << (self.pos & 7)
self.pos += 1
def put_bits_from(self, r: BitReader, n: int):
"""Copy n bits from a reader (fast path in 32-bit chunks)."""
while n > 0:
k = 32 if n >= 32 else n
self.put(k, r.get(k))
n -= k
def bytes(self) -> bytes:
return bytes(self.buf)

View File

@@ -5,7 +5,8 @@ Sources, in order:
1. data/catalog/<ARCHIVE>.csv -- shipped catalog (instant) when the WAD fingerprint matches;
2. otherwise a header scan of the WAD (a few seconds, cached in the user cache), classified with
`classify` and labelled with data/catalog/characters.csv (speaker per sound name).
Names follow the community hash list (ackit/data/hashlist.txt): an entry with one WEM is NAME
Names come from the game: a WAD entry ID is the Wwise ID (FNV-1) of a soundbank listed in the game's
CookedPCConsole/CookedBanks.txt (`bank_names`); an entry with one WEM is NAME
(alias NAME_1), entries with n variations are NAME_1..NAME_n (alias NAME#i); stream-pack media are
named by their ID or <Pack>_<ID>.
"""
@@ -21,10 +22,11 @@ from dataclasses import dataclass, asdict
from . import banks, classify, subtitles
from . import lines as lines_mod
from .game import wad_fingerprint
from .bnk import fnv1
from .lines import bank_langs
from .inject import pack_bnk_path
from .pack import Pack
from .paths import catalog_dir, package_data, user_dir
from .paths import catalog_dir, user_dir
from .wad import Wad
FIELDS = ["archive", "target", "name", "aliases", "group", "category", "bank", "language", "line", "character",
@@ -78,21 +80,28 @@ class Sound:
# --- static data ---------------------------------------------------------------------------
_names = None
_names = {} # cooked folder -> {Wwise ID: bank name}
def hash_names():
global _names
if _names is None:
_names = {}
for ln in open(package_data("hashlist.txt"), encoding="latin-1"):
p = ln.rstrip("\r\n").split("\t")
if len(p) >= 2 and p[1]:
try:
_names[int(p[0], 16)] = p[1]
except ValueError:
pass
return _names
def bank_names(cooked):
"""{Wwise ID: name} of the game's soundbanks, from its own CookedBanks.txt (ID = FNV-1 of the name). They
name the WAD entries (each entry holds the streamed media of one bank) and the stream packs."""
key = os.path.normcase(os.path.abspath(cooked))
if key not in _names:
out = {}
p = os.path.join(cooked, "CookedBanks.txt")
if os.path.exists(p):
for ln in open(p, encoding="latin-1"):
n = ln.strip()
if n:
out.setdefault(fnv1(n), n)
_names[key] = out
return _names[key]
def cooked_of(wad_path):
"""CookedPCConsole folder of a WAD (WADs are in CookedPCConsole/<language or SFX>/)."""
return os.path.dirname(os.path.dirname(os.path.abspath(wad_path)))
def load_characters(path=None, lang=None):
@@ -180,7 +189,7 @@ def _walk_entry(f, offset, size):
def scan_archive(archive, wad_path, characters=None, progress=None):
"""Header scan of one WAD -> [Sound]."""
names = hash_names()
names = bank_names(cooked_of(wad_path))
characters = characters if characters is not None else load_characters(lang=archive)
wad = Wad(wad_path)
out = []

View File

@@ -20,17 +20,6 @@ def load_library(path: str = DEFAULT_LIB) -> list:
return [b[offs[i]:offs[i + 1]] for i in range(n - 1)]
def save_library(path: str, books: list):
out = bytearray()
offs = []
for bk in books:
offs.append(len(out))
out += bk
offs.append(len(out)) # table start == end of last book
out += struct.pack("<%dI" % len(offs), *offs)
open(path, "wb").write(bytes(out))
def wwise_book_to_vorbis(blob: bytes, w: BitWriter):
"""Expand a compact Wwise codebook into a standard Vorbis codebook (ww2ogg rebuild)."""
r = BitReader(blob)

File diff suppressed because it is too large Load Diff

View File

@@ -11,7 +11,6 @@ import sys
APPID = 200260
FOLDER_NAMES = ("Batman Arkham City GOTY", "Batman Arkham City")
LANGS = ("INT", "ITA", "FRA", "DEU", "ESN", "ESM", "POL", "RUS", "JPN", "KOR", "POR")
class GameError(Exception):

View File

@@ -13,8 +13,6 @@ from collections import namedtuple
# key: "<LANG>:<File>:<Section>:<Key>" + ("#n" for the n-th repeat, n >= 2)
Entry = namedtuple("Entry", "key lang file section name occurrence value quoted line")
LANG_DIRS = ("INT", "ITA", "FRA", "DEU", "ESN", "ESM", "POL", "POR", "RUS", "JPN", "KOR")
class TextError(ValueError):
pass

View File

@@ -16,11 +16,6 @@ def resource_root():
return os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
def package_data(*parts):
"""Files shipped inside the package (codebooks, known setups, hash list)."""
return os.path.join(os.path.dirname(os.path.abspath(__file__)), "data", *parts)
def catalog_dir():
return os.path.join(resource_root(), "data", "catalog")

View File

@@ -17,7 +17,7 @@ import os
import random
from .. import banks, classify, subtitles
from ..catalog import (Catalog, apply_lines, available_archives, bank_sounds, build_lines, character_of, hash_names,
from ..catalog import (Catalog, apply_lines, available_archives, bank_names, bank_sounds, build_lines, character_of,
is_shipped, line_names, load_archive, load_banks, load_characters, read_csv, scan_archive)
from ..inject import read_target
from ..paths import catalog_dir
@@ -44,16 +44,16 @@ class Report:
return not self.issues
def _expected_category(s):
def _expected_category(s, names):
if s.archive == banks.ARCHIVE:
return classify.classify(s.name, bank=s.bank)
pack = None
if s.is_pack_media:
pack = hash_names().get(int(s.target.split("/")[0], 16), s.target.split("/")[0])
pack = names.get(int(s.target.split("/")[0], 16), s.target.split("/")[0])
return classify.classify(s.name, pack=pack)
def _check_rows(rep, archive, rows, characters):
def _check_rows(rep, archive, rows, characters, names):
seen_t, seen_n = set(), {}
for s in rows:
rep.checked[archive] += 1
@@ -66,7 +66,7 @@ def _check_rows(rep, archive, rows, characters):
if n in seen_n or (s.language and ("", n[1]) in seen_n):
rep.add(archive, "duplicate", s.key, "name %s (also %s)" % (s.name, seen_n.get(n) or seen_n[("", n[1])]))
seen_n[n] = s.key
g, c = _expected_category(s)
g, c = _expected_category(s, names)
if (s.group, s.category) != (g, c):
rep.add(archive, "category", s.key, "%s/%s, rules give %s/%s" % (s.group, s.category, g, c))
want = character_of(s, characters)
@@ -82,7 +82,7 @@ def check_wad(rep, game, archive, rows, characters, lines=None):
folder = os.path.basename(os.path.dirname(path)).upper()
if FOLDER_LANG.get(folder, archive) != archive:
rep.add(archive, "language", archive, "WAD in folder %s" % folder)
_check_rows(rep, archive, rows, characters)
_check_rows(rep, archive, rows, characters, bank_names(game.cooked))
fresh_list = scan_archive(archive, path, characters=characters)
if lines is not None and archive in lines.langs:
apply_lines(fresh_list, lines, lines.names)
@@ -117,7 +117,7 @@ def check_wad(rep, game, archive, rows, characters, lines=None):
def check_banks(rep, game, rows, locations, characters, quick=False, sample=40, progress=None):
"""BANK rows, copies, prefetch heads and subtitles vs a scan of the packages. The full check (not quick)
also derives the game's lines from that scan and returns them (else None)."""
_check_rows(rep, banks.ARCHIVE, rows, characters)
_check_rows(rep, banks.ARCHIVE, rows, characters, bank_names(game.cooked))
files = banks.bank_files(game.cooked)
if quick:
pk = [f for f in files if f.lower().endswith(".upk")]

View File

@@ -98,39 +98,6 @@ def search_memory(pid, needles, limit=1 << 32):
return found
u32 = ctypes.WinDLL("user32", use_last_error=True)
WM_KEYDOWN, WM_KEYUP = 0x0100, 0x0101
VK = dict(enter=0x0D, space=0x20, esc=0x1B, up=0x26, down=0x28, left=0x25, right=0x27, e=0x45)
def game_windows(pid):
"""Top-level windows of the game process."""
found = []
cb_t = ctypes.WINFUNCTYPE(wt.BOOL, wt.HWND, wt.LPARAM)
def cb(hwnd, _):
p = wt.DWORD()
u32.GetWindowThreadProcessId(hwnd, ctypes.byref(p))
if p.value == pid and u32.IsWindowVisible(hwnd):
found.append(hwnd)
return True
u32.EnumWindows(cb_t(cb), 0)
return found
def post_key(pid, key, hold=0.08):
"""Post a key press to the game's window only (never global SendInput)."""
vk = VK[key] if isinstance(key, str) else key
sc = u32.MapVirtualKeyW(vk, 0)
n = 0
for hwnd in game_windows(pid):
u32.PostMessageW(hwnd, WM_KEYDOWN, vk, 1 | (sc << 16))
time.sleep(hold)
u32.PostMessageW(hwnd, WM_KEYUP, vk, 1 | (sc << 16) | (3 << 30))
n += 1
return n
def running_game_pids():
out = subprocess.run(["tasklist", "/FI", "IMAGENAME eq BatmanAC.exe", "/FO", "CSV", "/NH"],
capture_output=True, text=True).stdout

View File

@@ -58,12 +58,6 @@ class Wad:
f.seek(e.offset)
return f.read(e.size)
def iter_entries(self):
with open(self.path, "rb") as f:
for e in self.entries:
f.seek(e.offset)
yield e, f.read(e.size)
def check(self):
"""Validate structural invariants; returns list of problems (empty = OK)."""
probs = []

View File

@@ -66,10 +66,6 @@ class Wem:
def data(self) -> bytes:
return self.get(b"data")
def seek_table(self):
n = self.vorb["seek_table_size"] // 4
return [struct.unpack_from("<HH", self.data, 4 * i) for i in range(n)]
def setup_packet(self) -> bytes:
d = self.data
off = self.vorb["seek_table_size"]

View File

@@ -133,19 +133,12 @@ class Workspace:
def original_audio(self, s):
return audio.decode_wem(self.original_bytes(s))
def current_audio(self, s):
return audio.decode_wem(self.current_bytes(s))
# encoding ----------------------------------------------------------------------------------
def prepare(self, s, x, src_rate, quality="auto", progress=None):
"""Encode PCM for sound `s` (its rate/channels, fitted to its size limit) -> (wem, info)."""
return encode_fit(x, src_rate, s.rate or src_rate, s.channels or x.shape[1], self.limit(s),
quality, progress)
def prepare_file(self, s, path, quality="auto", progress=None):
x, sr = audio.load(path)
return self.prepare(s, x, sr, quality, progress)
# writing -----------------------------------------------------------------------------------
def _backup(self, wad, pack_hashes):
if not self.full_backup: