Version 3.2.0: fast lists with loading spinners, names from the game files, INT docs and screenshots, cleanup
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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)
|
||||
|
||||
19063
ackit/data/hashlist.txt
19063
ackit/data/hashlist.txt
File diff suppressed because it is too large
Load Diff
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user