diff --git a/NOTES.md b/NOTES.md index 0468ced..4d89c40 100644 --- a/NOTES.md +++ b/NOTES.md @@ -107,9 +107,23 @@ the loose/embedded bank-name overlap, REPORT's test count, wem.md's SFX claim (626 / 651 rebuild, not "all but 4 looping tracks": 14 music seek tables, 5 music sample counts, 6 ambience `smpl` loops) and its extra-chunk count (722 WEMs, not 37). New test: cross-references between the shipped files. -- NEXT (needs a person): play a mission with a replaced footstep/foley sound and an edited subtitle (REPORT.md). -- OPEN: channel order for >2 channels unverified; speech and in-memory sounds *heard* in-game (need gameplay; - posted keys do not drive the menu). +- 2026-10-10 user gameplay test: replaced voice lines and subtitles WORK in game. Batman footsteps do NOT: + all 568 BANK sounds of the CHR_BatmanFootsteps* banks were replaced with Downloads\boom.mp3 (trimmed per sound, + still APPLIED, with the user's own test subtitle SUB:ITA:Speech_CHURCH_B1_CH2_HQ_INTRO2_08; revert with + `ackit revert --all` when the user says so). In game the boom was heard once, briefly; otherwise the original + steps. Checked: every copy (40 files) holds the new bytes; replacements are loud (-8.8 dB RMS vs -20..-33); + no other media ID has the same original audio; no game data outside CookedPCConsole. The named footstep + events (Play_BM_Footstep_Walk/Run/Land/...) reach only the 46 media of bank CHR_BatmanFootsteps (replaced); + the 522 surface media (CHR_BatmanFootsteps__Media, loose SFX\*.bnk) are reached by no named event + (switch containers not followed by event_objects?). NEXT: find where the game gets the step audio it plays + (switch/random containers and their children, banks loaded at runtime, a memory capture of a step in game). +- 2026-10-10 footsteps solved (findings 24): in-memory replacements were zero-padded in their slot and the game + plays that as silence; ackit now fills each in-memory copy exactly with a LIST/adtl/labl chunk (`wem.fill`, + `Workspace.fits/fill_sizes/prepare`). Verified by the user in game (a beep on every step), e2e_features 89/89. + Version 3.2.2. The footstep test was reverted (game original). +- DONE by the user in game: replaced voice lines, edited subtitles and replaced footsteps (after the fill fix). +- OPEN: channel order for >2 channels unverified; 19 speaker labels to check by listening; the Linux build was + never built or run (no Linux here). ## Environment facts - Game: Steam AppID 200260 "Batman: Arkham City GOTY", `C:\Program Files (x86)\Steam\steamapps\common\Batman Arkham diff --git a/REPORT.md b/REPORT.md index fee2772..64ba4c3 100644 --- a/REPORT.md +++ b/REPORT.md @@ -42,7 +42,7 @@ editor Unheard City (mission 2). - **Safety**: every changed file (WAD, bank, package, localization file) is rebuilt from a verified original copy in the store + the edits applied now; reverting the last edit restores it byte-for-byte; files changed by another tool or Steam are detected (whole-file SHA-1) and not overwritten without confirmation. -- **Tests**: 133 automated tests (unit, synthetic game with packages/banks/dialogues/localization, a +- **Tests**: 135 automated tests (unit, synthetic game with packages/banks/dialogues/localization, a two-language game for the lines (incl. a recording also stored whole in a bank), the shipped catalog's cross-references, real-game read-only (every name of every language's catalog resolves to its own sound), offscreen GUI incl. list order, selection and loading overlays) pass. `ackit verify` PASS (incl. @@ -52,21 +52,21 @@ editor Unheard City (mission 2). language's localized line, prefetch heads, a line also stored whole in banks, a multi-copy subtitle, a scripted subtitle, a menu text, sound + subtitle in one package, a mod moved to another voice language and matched to the same line at another position); `ackit verify --ingame` PASS (music heard, ncc 1.0); - `--banks` PASS (77 in-memory sounds of the front-end packages replaced in 54 files: the game loads them, 68 + `--banks` PASS (77 in-memory sounds of the front-end packages replaced in 54 files: the game loads them, 71 probes found in its memory, no crash); `--texts` PASS (12/12 menu texts and 23/23 subtitles of Startup.upk found in the game's memory, no crash); `tools/e2e_editor.py` PASS. Game files original at the end (`ackit status`). The catalog builder refuses to run on a modified game, so the catalog always describes the original files. -- **Package**: `dist/UnheardCity-3.2.1-windows-amd64.zip` (86 MB, catalogs included). With `PATH` reduced to +- **Package**: `dist/UnheardCity-3.2.2-windows-amd64.zip` (86 MB, catalogs included). With `PATH` reduced to the Windows folders the packaged exe passes its smoke test (in the game's language - Italian on the test PC: 21,015 sounds loaded, encode + inject, 9,377 subtitles and 7,020 menu texts loaded, an in-memory sound decoded from a package) and the GUI starts and quits cleanly. The installer is unchanged since mission 2 (install/upgrade/uninstall verified then). ## Unresolved / limitations -- **Heard in game, not proven automatically**: in-memory sounds and subtitles. The game loads the patched - packages (probe data and texts found in its memory, no crash), but nothing of this plays/shows on the title - screen without input, and keys posted to the game window do not reach the menu (findings 18). Speech lines - were already in this situation; the decoder path is the same one proven for music. +- **Checked by a person in game** (2026-10-10), not automatically (nothing of this plays or shows on the title + screen without input, findings 18): replaced voice lines and edited subtitles are heard and shown; replaced + footsteps play on every step. That test found the one defect the automatic checks could not: in-memory + replacements followed by zero padding in their slot played as silence (fixed in 3.2.2, findings 24). - **Size limits**: in-memory sounds and speech must fit their original size; subtitles must fit the original text's length ("room"); cp1252 subtitles accept cp1252 characters only. Lifting these limits means moving objects inside the packages (new export offsets/sizes, added chunks): designed but not done, because it could @@ -92,8 +92,8 @@ editor Unheard City (mission 2). cached); it gets the same line identities, but no original checksums. ## Next steps -1. Gameplay check (a person): a replaced footstep/foley sound and an edited subtitle in a mission. -2. Optional: lift the subtitle length limit by relocating dialogue objects (needs that gameplay check). +1. Optional: lift the subtitle length limit by relocating dialogue objects (checked in game by a person). +2. Build and smoke-test the Linux package once on a Linux machine. 3. Listen to the 19 lines whose speaker label differs from the game's dialogue speaker field (`docs/categories.md`) and fix the curated lists if needed; extend them (`data/catalog/source//*.txt`) and rebuild the catalog. diff --git a/ackit/__init__.py b/ackit/__init__.py index d2d1743..469f2ef 100644 --- a/ackit/__init__.py +++ b/ackit/__init__.py @@ -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.2.1" +__version__ = "3.2.2" diff --git a/ackit/modpack.py b/ackit/modpack.py index bf3992e..0c9008e 100644 --- a/ackit/modpack.py +++ b/ackit/modpack.py @@ -140,8 +140,7 @@ def prepare_import(ws, path, items, progress=None): continue orig = ws.original_bytes(im.sound) wem = z.read(im.item["wem"]) - lim = ws.limit(im.sound) - if im.how == "exact" and sha1_16(orig) == im.item.get("original_sha1") and (lim is None or len(wem) <= lim): + if im.how == "exact" and sha1_16(orig) == im.item.get("original_sha1") and ws.fits(im.sound, wem): im.wem, im.note = wem, "exact copy" else: x, sr = sf.read(io.BytesIO(z.read(im.item["audio"])), dtype="float32", always_2d=True) diff --git a/ackit/verify/ingame.py b/ackit/verify/ingame.py index cdd689a..eb39229 100644 --- a/ackit/verify/ingame.py +++ b/ackit/verify/ingame.py @@ -200,10 +200,10 @@ def bank_probes(ws, sounds): secs = min(10.0, max(0.4, s.seconds or 1.0)) while secs >= 0.1: wem, _ = encode(x[:int(secs * sr)], sr, PROBE_RATE, 1, PROBE_Q) - if len(wem) <= lim: + if ws.fits(s, wem): # within the slot, and able to fill it exactly (wem.fill) out.append((s, wem)) break - secs *= 0.7 + secs *= 0.7 if len(wem) > lim else 0.98 return out diff --git a/ackit/wem.py b/ackit/wem.py index 0cff018..e3767e2 100644 --- a/ackit/wem.py +++ b/ackit/wem.py @@ -101,6 +101,60 @@ class Wem: return b"RIFF" + struct.pack("= FILL_MIN and pad % 2 == 0) + + +def fill(wem: bytes, size: int) -> bytes: + """`wem` grown to exactly `size` bytes (see above). ValueError if `fillable` is False.""" + if not fillable(len(wem), size): + raise ValueError("a %d-byte WEM cannot fill a %d-byte slot exactly" % (len(wem), size)) + pad = size - len(wem) + if not pad: + return wem + i = _data_chunk(wem) + text = pad - 8 - 4 - 8 - 4 + chunk = (b"LIST" + struct.pack(" bytes: + """The WEM without a filler chunk added by `fill` (a game WEM is returned unchanged).""" + i = _data_chunk(wem) + if i is None: + return wem + for j in range(12, i, 2): + if wem[j:j + 4] == b"LIST" and wem[j + 8:j + 16] == b"adtllabl": + n = 8 + struct.unpack_from("= orig}) + + def fits(self, s, wem): + """True if `wem` can be written for `s`: within its size limit and able to fill its in-memory slots.""" + lim = self.limit(s) + return (lim is None or len(wem) <= lim) and all(fillable(len(wem), n) for n in self.fill_sizes(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) + """Encode PCM for sound `s` (its rate/channels, fitted to its size limit) -> (wem, info). A sound with + in-memory slots gets a WEM that fills them exactly (`fits`): at least FILL_MIN bytes smaller, and an + even number of bytes smaller; the end is shortened by a few samples until the size allows that.""" + sizes = self.fill_sizes(s) + lim = self.limit(s) + if sizes: + lim = min(([lim] if lim is not None else []) + sizes) - FILL_MIN + rate, ch = s.rate or src_rate, s.channels or x.shape[1] + for k in range(32): + wem, info = encode_fit(x[:len(x) - 16 * k] if k else x, src_rate, rate, ch, lim, quality, progress) + if self.fits(s, wem): + return wem, info + raise EncodeError("could not encode a size that fills this sound's slots exactly; shorten the audio") # writing ----------------------------------------------------------------------------------- def _backup(self, wad, pack_hashes): @@ -225,11 +250,14 @@ class Workspace: locs = [l for l in self.locations(key) if l.file == rel] if not locs: continue - data = self.store.get_blob(e["applied"]) + data = unfill(self.store.get_blob(e["applied"])) for l in locs: - if l.kind == banks.MEMORY: + whole = l.kind == banks.MEMORY or l.size >= e["orig_size"] # a whole WEM in memory + if whole and fillable(len(data), l.size): + payload = fill(data, l.size) + elif whole: # recorded by a version before 3.2.2 (zero padding: the game plays silence) payload = data + bytes(l.size - len(data)) - else: # head of a WAD line, which is stored zero-padded to its original size + else: # prefetch head: the start of the WAD recording as it is stored there full = data + bytes(max(0, e["orig_size"] - len(data))) payload = (full + bytes(l.size))[:l.size] out.append((l.offset, payload)) @@ -286,6 +314,12 @@ class Workspace: """changes: [(sound, wem_bytes, meta dict)]. One atomic rewrite per WAD / bank file. A recording stored in two places of a WAD gets the same bytes in both (Workspace.copies).""" keys = {s.key for s, _, _ in changes} + changes = [(s, unfill(data), meta) for s, data, meta in changes] + for s, data, _ in changes: + if not self.fits(s, data) and len(data) <= (self.limit(s) or len(data)): + raise GameError("%s: a %d-byte WEM cannot fill this sound's in-memory slots (%s bytes) exactly; " + "encode it for this sound (Workspace.prepare / the editor / ackit replace)" + % (s.name, len(data), ", ".join(map(str, self.fill_sizes(s))))) changes = list(changes) + [(c, data, meta) for s, data, meta in changes for c in self.copies(s) if c.key not in keys] conflicts = [] diff --git a/docs/decisions.md b/docs/decisions.md index 981a185..52abd6f 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -65,8 +65,11 @@ tables): high risk for little gain, not done. - **Model:** one catalog row per Wwise media ID (archive `BANK`, target = media ID), not per bank: the same media is stored in up to dozens of bank copies (banks are cooked into every package that uses them), and the game may load any of them. A replacement is written into **every** copy (`BANK_locations.csv.gz`). -- **In place:** each copy keeps its size (zero-padded, like WAD speech); DIDX/HIRC offsets and the package - layout never change, so nothing else in the engine data has to be rewritten. +- **In place:** each copy keeps its size; DIDX/HIRC offsets and the package layout never change, so nothing + else in the engine data has to be rewritten. Unlike WAD speech, an in-memory copy is never zero-padded: + the replacement fills its slot exactly with a label list chunk (the game plays a padded in-memory WEM as + silence; `docs/formats/banks.md`). Shrinking the declared sizes (DIDX and every Sound object's in-memory + size) would also work, but needs every HIRC source of the media in every bank copy located and rewritten. - **Packages:** ackit reads the LZO packages itself (pure Python, ~47 MB/s, block-level access) and writes only the changed 128 KiB blocks, stored as a single LZO literal run (valid LZO1X without matches), then fixes the chunk table. No compressor and no external tool are needed; untouched blocks stay byte-identical. diff --git a/docs/formats/banks.md b/docs/formats/banks.md index c5bd471..0f94e47 100644 --- a/docs/formats/banks.md +++ b/docs/formats/banks.md @@ -111,10 +111,16 @@ and the game ties the recordings together itself: - Catalog archive `BANK` (one row per media ID) + `data/catalog/BANK_locations.csv.gz` (every copy, and the heads and whole copies of WAD speech recordings); built by `tools/build_catalog.py`, checked by `ackit verify --catalog`. -- A replacement is written **in place into every copy** (no larger than the smallest copy, zero-padded), so - DIDX/HIRC offsets and sizes and the package layout never change. Loose banks are patched directly; in - packages only the touched blocks are re-encoded (`upk.md`). A sound left in one package only would let the - game load the original from another one. -- Replacing a speech line that has prefetch heads or whole copies rewrites them (the first bytes of the new, - zero-padded line; for a whole copy, all of it). +- A replacement is written **in place into every copy** (no larger than the smallest copy), so DIDX/HIRC + offsets and sizes and the package layout never change. Loose banks are patched directly; in packages only + the touched blocks are re-encoded (`upk.md`). A sound left in one package only would let the game load the + original from another one. +- **Each copy is filled exactly**, never zero-padded: every in-memory WEM the game ships ends exactly at its + slot's end, and the game plays a replacement followed by zero padding there as **silence** (findings 24: + footsteps replaced that way were silent in game; filled, they play). A smaller replacement is grown to the + slot size with a label list chunk before `data` (`LIST{adtl, labl}`, the form the game's own WEMs use for + markers; `ackit/wem.py: fill`), so the encoder leaves at least 26 spare bytes, an even number. +- Replacing a speech line that has prefetch heads or whole copies rewrites them: a head gets the first bytes + of the new, zero-padded line (the start of the streamed WEM as the WAD stores it); a whole copy is filled + like an in-memory sound. - Design and revert rules: `docs/decisions.md` (4b). The same WEM rules apply (`wem.md`). diff --git a/docs/reverse-engineering/findings.md b/docs/reverse-engineering/findings.md index e633e52..a4cc9c3 100644 --- a/docs/reverse-engineering/findings.md +++ b/docs/reverse-engineering/findings.md @@ -125,6 +125,15 @@ Conclusion: approach A works end-to-end in-game. Use >= 30 s probes for the intr catalog had the same `_` name as an SFX WAD sound of the same bank (`CHR_RasBossFight_1`, `SEQ_DemonTrials_1`); they now take `_`, and a line's own name wins over another recording's position alias (`CHR_RadioScanner#1` in FRA). Every name of every catalog resolves to its own sound. +24. (2026-10-10, gameplay test by the user) **Padded in-memory replacements play as silence.** All 568 Batman + footsteps were replaced; in game the original steps were heard and the replaced cape-open sound was silent. + Memory scan while walking: no original footstep WEM loaded, 137 replacements loaded; debugger: the + replacements' Vorbis setups are created and parsed OK (ret 0). Every untouched in-memory WEM fills its slot + exactly (RIFF size = DIDX size, 500 / 500 sampled), while the replacements were zero-padded. Filled with a + `LIST{adtl, labl}` chunk before `data` (55 in-memory originals carry cue+LIST chunks), every step plays the + replacement. ackit now fills every in-memory copy and every whole bank copy of a WAD recording + (`wem.fill`); WAD entries and prefetch heads keep zero padding (streamed playback is unaffected). The + remaining sound after each step is Batman's gear and cape foley (`CHR_BatmanFoleyBelt`, `..Cape`). ## Old path -> new path `scripts/harness.py` -> `ackit verify --ingame` (ackit/verify/ingame.py); `scripts/validate_builder.py` -> diff --git a/pyproject.toml b/pyproject.toml index d4ee5fc..416abf1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "ackit" -version = "3.2.1" +version = "3.2.2" description = "Batman: Arkham City (PC) audio toolkit and editor" readme = "README.md" requires-python = ">=3.10" diff --git a/tests/synth.py b/tests/synth.py index 0282f4a..4bf8f53 100644 --- a/tests/synth.py +++ b/tests/synth.py @@ -25,6 +25,16 @@ def wem(seconds, freq, rate=24000, ch=1): return encoder.encode(x, sr, rate, ch)[0] +def fitting_wem(ws, s, seconds, freq): + """A tone WEM that `ws` accepts for `s` (Workspace.fits: in-memory slots are filled exactly, which needs + an even number of spare bytes): the tone is shortened a little until the size allows it.""" + for k in range(40): + w = wem(seconds * (1 - 0.01 * k), freq) + if ws.fits(s, w): + return w + raise AssertionError("no fitting WEM for %s" % s.key) + + def _a(x, n): return (x + n - 1) // n * n diff --git a/tests/test_banks.py b/tests/test_banks.py index 8da971e..208c928 100644 --- a/tests/test_banks.py +++ b/tests/test_banks.py @@ -9,6 +9,7 @@ from ackit import banks from ackit.game import Game from ackit.inject import SizeLimitError from ackit.upk import Package +from ackit.wem import fill, unfill from ackit.workspace import Workspace, ConflictError, MODIFIED, ORIGINAL @@ -65,12 +66,13 @@ def test_bank_apply_every_copy_and_revert_byte_identical(synth_game, tmp_path): files = [os.path.join(cooked, f) for f in ("Synth_A.upk", "Synth_B.upk")] before = {f: _sha(f) for f in files} s = ws.catalog.by_key["BANK:%08X" % synth.FOLEY[0]] - new = synth.wem(0.2, 1500) + new = synth.fitting_wem(ws, s, 0.2, 1500) assert len(new) < ws.limit(s) ws.apply([(s, new, {"source": "test"})]) - assert ws.state(s) == MODIFIED - for l in ws.locations(s): - assert _copy(ws, l) == new + bytes(l.size - len(new)) + assert ws.state(s) == MODIFIED and ws.current_bytes(s) == new + for l in ws.locations(s): # every copy filled exactly: no zero padding after the WEM + assert _copy(ws, l) == fill(new, l.size) and len(_copy(ws, l)) == l.size + assert unfill(_copy(ws, l)) == new other = ws.catalog.by_key["BANK:%08X" % synth.FOLEY[1]] assert all(_copy(ws, l) == originals[other.target] for l in ws.locations(other)) assert all(_sha(f) != before[f] for f in files) @@ -79,13 +81,49 @@ def test_bank_apply_every_copy_and_revert_byte_identical(synth_game, tmp_path): assert ws.state(s) == ORIGINAL and {f: _sha(f) for f in files} == before +def test_fill_slot_exactly(): + """The game plays an in-memory WEM followed by zero padding as silence: a smaller replacement is grown to + its slot with a label list chunk (wem.fill), which the game's own WEMs use too.""" + from ackit import audio + from ackit.wem import FILL_MIN, Wem, fillable + w = synth.wem(0.2, 1500) + for size in (len(w), len(w) + FILL_MIN, len(w) + 1000): + f = fill(w, size) + assert len(f) == size and 8 + int.from_bytes(f[4:8], "little") == size # RIFF covers the slot + tags = [b"fmt ", b"data"] if size == len(w) else [b"fmt ", b"LIST", b"data"] + assert unfill(f) == w and [c.tag for c in Wem.parse(f).chunks] == tags + assert (audio.decode_wem(f)[0] == audio.decode_wem(w)[0]).all() + assert not fillable(len(w), len(w) + 3) and not fillable(len(w), len(w) + FILL_MIN - 2) + with pytest.raises(ValueError): + fill(w, len(w) + 1) + f = fill(w, len(w) + 40) + a = f.index(b"adtllabl") + game_like = f[:a + 12] + (1).to_bytes(4, "little") + b"END\0" + f[a + 20:] # a real marker label: kept + assert len(game_like) == len(f) and unfill(game_like) == game_like + + +def test_bank_apply_refuses_unfillable_and_prepare_fits(synth_game, tmp_path): + from ackit.game import GameError + root, _ = synth_game + ws = _ws(root, tmp_path) + s = ws.catalog.by_key["BANK:%08X" % synth.FOOT[1]] + bad = next(w for w in (synth.wem(0.1 * (1 - 0.01 * k), 2000) for k in range(40)) if not ws.fits(s, w) + and len(w) <= ws.limit(s)) + with pytest.raises(GameError): + ws.apply([(s, bad, {})]) + assert ws.store.entries == {} + x, sr = synth.tone(0.1, 24000, 2000, 1) + wem, _ = ws.prepare(s, x, sr) + assert ws.fits(s, wem) + + def test_loose_bank_in_place(synth_game, tmp_path): root, originals = synth_game ws = _ws(root, tmp_path) s = ws.catalog.by_key["BANK:%08X" % synth.FOOT[1]] path = ws.files(s)[0] before, size = _sha(path), os.path.getsize(path) - ws.apply([(s, synth.wem(0.1, 2000), {})]) + ws.apply([(s, synth.fitting_wem(ws, s, 0.1, 2000), {})]) assert os.path.getsize(path) == size and _sha(path) != before ws.revert_all() assert _sha(path) == before @@ -98,11 +136,11 @@ def test_partial_revert_keeps_other_changes(synth_game, tmp_path): before = _sha(path) a = ws.catalog.by_key["BANK:%08X" % synth.FOLEY[0]] b = ws.catalog.by_key["BANK:%08X" % synth.GUI[0]] - na, nb = synth.wem(0.2, 1500), synth.wem(0.2, 1700) + na, nb = synth.fitting_wem(ws, a, 0.2, 1500), synth.fitting_wem(ws, b, 0.2, 1700) ws.apply([(a, na, {}), (b, nb, {})]) ws.revert([a]) assert ws.current_bytes(a) == originals[a.target] - assert ws.current_bytes(b).startswith(nb) and ws.state(b) == MODIFIED + assert ws.current_bytes(b) == nb and ws.state(b) == MODIFIED ws.revert([b]) assert _sha(path) == before @@ -136,15 +174,15 @@ def test_bank_file_changed_underneath(synth_game, tmp_path): root, originals = synth_game ws = _ws(root, tmp_path) s = ws.catalog.by_key["BANK:%08X" % synth.GUI[0]] - ws.apply([(s, synth.wem(0.2, 1500), {})]) + ws.apply([(s, synth.fitting_wem(ws, s, 0.2, 1500), {})]) path = ws.files(s)[0] # another tool rewrites the package (different bytes elsewhere in it) pk = Package(path) other = pk.patched([(pk.header_size + 10, b"OTHER-TOOL")]) open(path, "wb").write(other) with pytest.raises(ConflictError): - ws.apply([(s, synth.wem(0.2, 1600), {})]) - ws.apply([(s, synth.wem(0.2, 1600), {})], force=True) + ws.apply([(s, synth.fitting_wem(ws, s, 0.2, 1600), {})]) + ws.apply([(s, synth.fitting_wem(ws, s, 0.2, 1600), {})], force=True) ws.revert([s]) assert _sha(path) == _sha(ws.store.full_copy(ws.game.rel(path))) diff --git a/tests/test_lines.py b/tests/test_lines.py index 3cb82b8..30683cd 100644 --- a/tests/test_lines.py +++ b/tests/test_lines.py @@ -9,6 +9,7 @@ from ackit import banks from ackit.bnk import fnv1 from ackit.catalog import build_lines from ackit.game import Game +from ackit.wem import fill from ackit.workspace import ORIGINAL, MODIFIED, Workspace import synth @@ -138,8 +139,9 @@ def test_whole_recording_in_a_bank_is_written_with_its_line(tmp_path): assert l.kind == banks.PREFETCH_HEAD and l.size == len(a["INT"]) and l.file == "Lines.upk" pkg = os.path.join(ws.game.cooked, "Lines.upk") before = hashlib.sha1(open(pkg, "rb").read()).hexdigest() - new = synth.wem(0.3, 900) + new = synth.fitting_wem(ws, s, 0.3, 900) ws.apply([(s, new, {})]) - assert banks.open_file(pkg).read(l.offset, l.size) == new + bytes(l.size - len(new)) + assert banks.open_file(pkg).read(l.offset, l.size) == fill(new, l.size) # filled exactly, not padded + assert ws.current_bytes(s) == new ws.revert([s]) assert hashlib.sha1(open(pkg, "rb").read()).hexdigest() == before diff --git a/tests/test_texts.py b/tests/test_texts.py index 819d61e..56e6c51 100644 --- a/tests/test_texts.py +++ b/tests/test_texts.py @@ -94,7 +94,7 @@ def test_subtitle_and_sound_in_one_package(synth_game, tmp_path): ws = _ws(root, tmp_path) before = _files(root) s = ws.catalog.by_key["BANK:%08X" % synth.DLG_MEDIA] - ws.apply([(s, synth.wem(0.1, 1900), {})]) + ws.apply([(s, synth.fitting_wem(ws, s, 0.1, 1900), {})]) ws.texts.apply({SUB_KEY: "Both."}) assert ws.texts.current(SUB_KEY) == "Both." and ws.state(s) == "modified" ws.revert([s]) diff --git a/tools/e2e_features.py b/tools/e2e_features.py index 700fc93..f4893a7 100644 --- a/tools/e2e_features.py +++ b/tools/e2e_features.py @@ -35,6 +35,7 @@ import numpy as np # noqa: E402 from ackit import banks, settings, subtitles # noqa: E402 from ackit.game import Game, default_game_dir # noqa: E402 from ackit.modpack import export_mod, import_mod, match_mod # noqa: E402 +from ackit.wem import fill # noqa: E402 from ackit.workspace import Workspace, MODIFIED, ORIGINAL # noqa: E402 RESULTS = [] @@ -80,11 +81,12 @@ def sound_round_trip(ws, s, label): before = snapshot(files) ws.apply([(s, wem, {"source": "e2e"})]) ok = ws.state(s) == MODIFIED and ws.current_bytes(s) == wem + orig_size = ws.store.get(s.key)["orig_size"] for l, b in copies_of(ws, s): - if l.kind == banks.MEMORY: - ok &= b == wem + bytes(l.size - len(wem)) - else: # head or whole copy of a WAD line (zero-padded to its size) - full = wem + bytes(max(0, ws.store.get(s.key)["orig_size"] - len(wem))) + if l.kind == banks.MEMORY or l.size >= orig_size: # a whole WEM in memory: fills its slot exactly + ok &= b == fill(wem, l.size) + else: # prefetch head: the start of the WAD recording (zero-padded) + full = wem + bytes(max(0, orig_size - len(wem))) ok &= b == (full + bytes(l.size))[:l.size] ws.revert([s]) ok &= ws.state(s) == ORIGINAL and snapshot(files) == before and not ws.store.entries