From b775c758529ff295fc47d35a4b2fc1fce48204d5 Mon Sep 17 00:00:00 2001 From: seedds Date: Mon, 9 Nov 2026 19:59:50 -0800 Subject: [PATCH] Read compiled shaders from Larian v13 archives The shader split scanned compressed LSPK files as raw bytes and missed their .bshd programs. Read the bounded v13 file list and multipart members, decode both compression layers, and retain GLSLC stage pairs. Synthetic archives exercise the real parser and CLI without game files. Refs #1679. The title-screen loading loop remains unresolved. Co-authored-by: Zatteo <64840342+zatteogit@users.noreply.github.com> --- dm/gfx/tools/shader_split.py | 118 ++++++++++++++++++- dm/tools/tests/test_split_lspk.py | 190 ++++++++++++++++++++++++++++++ docs/agents/symptoms.md | 1 + docs/development.md | 1 + docs/reference/shaders.md | 7 ++ 5 files changed, 315 insertions(+), 2 deletions(-) create mode 100644 dm/tools/tests/test_split_lspk.py diff --git a/dm/gfx/tools/shader_split.py b/dm/gfx/tools/shader_split.py index 45b9abfde2..8a367c2482 100644 --- a/dm/gfx/tools/shader_split.py +++ b/dm/gfx/tools/shader_split.py @@ -616,6 +616,116 @@ def linkdata_shaders(path, rel, rng, out): off, n, stored, comp = struct.unpack_from(' LSPK_LIMIT: red(f'{label}: LSPK decoded size {size:#x} exceeds the byte budget') + try: + if method == 0: out = data + elif method == 1: + stream = zlib.decompressobj() + out = stream.decompress(data, size + 1) + if not stream.eof or stream.unused_data or stream.unconsumed_tail: + red(f'{label}: LSPK zlib stream is truncated, oversized or has trailing bytes') + elif method == 2: + # A raw LZ4 block expands by less than 255 bytes per input byte; + # reject an impossible declared size before the decoder allocates it. + if size > 255 * len(data): red(f'{label}: LSPK LZ4 decoded size cannot fit its compressed block') + out = lz4.block.decompress(data, uncompressed_size=size) + else: red(f'{label}: LSPK compression {method} is not read') + except (ValueError, lz4.block.LZ4BlockError, zlib.error) as e: red(f'{label}: invalid LSPK compressed block ({e})') + if len(out) != size: red(f'{label}: LSPK block decoded {len(out)} bytes, expected {size}') + return out + +def lspk_entries(path): + """Larian v13: footer {u32 header size, LSPK}, 32-byte header, LZ4 list of 280-byte FileEntry10 records. + Layout: Norbyte/lslib PackageFormat.cs LSPKHeader13/FileEntry10; .bshd wrapper reported in issue #1679.""" + with open(path, 'rb') as f: + f.seek(0, 2); length = f.tell() + if length < 8: return None + f.seek(length - 8); hsize, magic = struct.unpack(' length - hsize: red(f'{path}: LSPK file list lies outside the data area') + if size > LSPK_LIMIT: red(f'{path}: LSPK file list exceeds the byte budget') + f.seek(off); count = struct.unpack('= parts: red(f'{path}#{name}: LSPK part {part} exceeds the part count {parts}') + if part == 0 and at + stored > off: red(f'{path}#{name}: LSPK entry overlaps its file list or header') + entries.append((name, at, stored, decoded, part, method)) + return parts, entries + +def lspk_part(path, part): + stem, ext = os.path.splitext(path) + return f'{stem}_{part}{ext}' if part else path + +def lspk_jobs(files, parts=64): + """Split only .bshd entries into jobs; archive parts and shaderless archives are not scanned whole.""" + jobs, owned, archives = [], set(), [] + for path, rel in files: + if not path.lower().endswith('.pak'): continue + try: info = lspk_entries(path) + except (Exception, SystemExit): + # Preserve scan_file's unreadable accounting and filename diagnostics. + jobs.append((path, rel, (LSPK, None))); owned.add(path); continue + if info is None: continue + count, entries = info + owned.update(lspk_part(path, i) for i in range(count)) + archives.append((path, rel, [e for e in entries if e[0].lower().endswith('.bshd')])) + step = max(1, sum(e[2] for _, _, entries in archives for e in entries) // max(1, parts)) + for path, rel, entries in archives: + group, size = [], 0 + for e in entries: + group.append(e); size += e[2] + if size >= step: jobs.append((path, rel, (LSPK, tuple(group)))); group, size = [], 0 + if group: jobs.append((path, rel, (LSPK, tuple(group)))) + return jobs, owned + +def lspk_shaders(path, rel, entries, out): + """Archive-member compression, then a .bshd's big-endian size/flag and zlib-wrapped GLSLC program.""" + import contextlib + if entries is None: + info = lspk_entries(path) # reproduce a malformed list's diagnostic inside scan_file + if info is None: red(f'/{rel}: LSPK header disappeared') + entries = [e for e in info[1] if e[0].lower().endswith('.bshd')] + with contextlib.ExitStack() as stack: + handles = {} + for name, off, stored, decoded, part, flags in entries: + label = f'/{rel}#{name}' + if stored > LSPK_LIMIT: red(f'{label}: LSPK stored size exceeds the byte budget') + src = lspk_part(path, part) + try: + if src not in handles: handles[src] = stack.enter_context(open(src, 'rb')) + f = handles[src]; f.seek(0, 2) + if off + stored > f.tell(): red(f'{label}: LSPK entry lies outside archive part {part}') + f.seek(off); raw = f.read(stored) + except OSError as e: red(f'{label}: cannot read LSPK part {part} ({e})') + how = flags & 15 + data = lspk_block(raw, decoded if how else stored, how, label) + if len(data) < 4: red(f'{label}: truncated LSPK .bshd header') + size = int.from_bytes(data[:4], 'big') + if not size & 0x80000000: red(f'{label}: LSPK .bshd is not zlib-wrapped') + data = lspk_block(data[4:], size & 0x7fffffff, 1, label) + found = glslc_shaders(data, label, need=False) + if sorted(st for _, st, _, _ in found) == ['ps', 'vs']: + found = [(f'{label}#GLSLC#var', st, ctl, code) for _, st, ctl, code in found] + out.extend(found) + + KPKA = b'KPKA' KPKA_BLOCKS = 0x20 # header feature: a block table follows the entries KPKA_IN_BLOCKS = 0x1000000 # entry attribute: the entry's data is in the block table's blocks @@ -3222,6 +3332,9 @@ def scan_file_in(args): if ext in ('.data', '.patch', '.debug', '.nxpc') and os.path.exists(stem + '.dict'): return rel, {}, set(), {} # read through its Next Level Games index (nlg_shaders) with open(path, 'rb') as f: head = f.read(16) found = []; trees = {} + if len(args) > 2 and args[2][0] == LSPK: + lspk_shaders(path, rel, args[2][1], found) + return rel, uniq_keys(found), var_pairs(found), trees if len(args) > 2 and args[2][0] == b'ARH1': arh_shaders(path, rel, args[2][1], found) return rel, uniq_keys(found), var_pairs(found), trees @@ -3455,8 +3568,9 @@ def main(): fb_jobs, fbs = frostbite_jobs(files) # EA SPORTS FC 27's Frostbite bundles, Oodle Leviathan decoded by the game's own decoder in the Mac host pd_jobs, pdats = pdat_jobs(files) # Kirby Air Riders' PDAT archives: the PMAP records in byte ranges rp_jobs, rpfs = rpf_jobs(files, main_v) # Red Dead Redemption's RPF6 archives, their tables decrypted with the key in main (rpf6.py) - big += pak_jobs + tr_jobs + ue_jobs + bd_jobs + arc_jobs + pz_jobs + ck_jobs + d3_jobs + ca_jobs + cr_jobs + zp_jobs + ah_jobs + fb_jobs + pd_jobs + rp_jobs - files = [(p, r) for p, r in files if not linkdata_jobs(p, r) and p not in paks and p not in trp and p not in uep and p not in bdts and p not in arcs and p not in pkzs and p not in cpks and p not in d3s and p not in cascs and p not in cras and p not in zips and p not in arhs and p not in fbs and p not in pdats and p not in rpfs] + ls_jobs, lspks = lspk_jobs(files) # Divinity: Original Sin 2's Larian archives: only the compiled .bshd shaders + big += pak_jobs + tr_jobs + ue_jobs + bd_jobs + arc_jobs + pz_jobs + ck_jobs + d3_jobs + ca_jobs + cr_jobs + zp_jobs + ah_jobs + fb_jobs + pd_jobs + rp_jobs + ls_jobs + files = [(p, r) for p, r in files if not linkdata_jobs(p, r) and p not in paks and p not in lspks and p not in trp and p not in uep and p not in bdts and p not in arcs and p not in pkzs and p not in cpks and p not in d3s and p not in cascs and p not in cras and p not in zips and p not in arhs and p not in fbs and p not in pdats and p not in rpfs] with cf.ProcessPoolExecutor(J, initializer=_pool_init, initargs=(common, {}, zdicts)) as ex: n = len(big) + len(files) results += pooled(ex, big, n) + pooled(ex, files, n, len(big), chunk=16) diff --git a/dm/tools/tests/test_split_lspk.py b/dm/tools/tests/test_split_lspk.py new file mode 100644 index 0000000000..9685d9e037 --- /dev/null +++ b/dm/tools/tests/test_split_lspk.py @@ -0,0 +1,190 @@ +"""Larian v13 archives deliver real synthetic GLSLC programs to the shader split (#1679).""" +import contextlib +import io +import os +import struct +import subprocess +import sys +import tempfile +import unittest +import zlib +from pathlib import Path +from unittest import mock + +import lz4.block + +REPO = Path(__file__).resolve().parents[3] +sys.path.insert(0, str(REPO / 'dm/tools/fuzz')) +import fuzz_shader_split as fz +ss = fz.ss +import romfs_image + + +def bshd(data): + return struct.pack('>I', 0x80000000 | len(data)) + zlib.compress(data) + + +def archive(files, *, flags=0): + """Independent v13 fixture: (name, decoded bytes, compression flags, part).""" + count = max((part for _, _, _, part in files), default=0) + 1 + parts = [bytearray() for _ in range(count)] + table = bytearray() + for name, raw, method, part in files: + packed = zlib.compress(raw) if method & 15 == 1 else lz4.block.compress(raw, store_size=False) if method & 15 == 2 else raw + table += struct.pack('<256s6I', name.encode(), len(parts[part]), len(packed), len(raw) if method & 15 else 0, part, method, zlib.crc32(packed)) + parts[part] += packed + offset = len(parts[0]) + listing = struct.pack('I', 0x80000000 | (len(self.program) - 1)) + packed[4:], + struct.pack('>I', 0xffffffff) + packed[4:]] + for bad in cases: + with self.subTest(size=len(bad), header=bad[:4].hex()): + path = self.write(archive([('good.bshd', packed, 0, 0), ('bad.bshd', bad, 0, 0)])) + (_, keys, pairs, extra), log, _ = self.scan(path) + self.assertEqual((keys, pairs), ({}, set())) + self.assertEqual(extra[0], 'unreadable') + self.assertIn('bad.bshd', log) + + def test_missing_or_truncated_part_is_unreadable(self): + for missing in (True, False): + with self.subTest(missing=missing): + path = self.write(archive([('shader.bshd', bshd(self.program), 2, 1)])) + part = self.root / 'Materials_1.pak' + if missing: part.unlink() + else: part.write_bytes(part.read_bytes()[:-1]) + (_, keys, pairs, extra), log, _ = self.scan(path) + self.assertEqual((keys, pairs), ({}, set())) + self.assertEqual(extra[0], 'unreadable') + self.assertIn('shader.bshd', log) + + def test_bad_member_ranges_sizes_and_compression_are_unreadable(self): + for field, value in [(1, 0xffffffff), (2, 0xffffffff), (3, 1), (4, 2), (5, 3)]: + with self.subTest(field=field): + data = archive([('shader.bshd', bshd(self.program), 1, 0)])[0] + off = struct.unpack_from('.bea: entry 0 @0x4854535341 is not an ASST entry (its shaders are left out)` for every archive (`… file(s) could not be read`); the "offset" is the bytes `ASSTH`. → [Bezel Engine 2.9 archives keep their entries inline](../reference/shaders.md#bezel-engine-29-archives-keep-their-entries-inline) - a provisional split finds only a fraction of the shaders (3,126 of Pokémon Sword's 41,516). → [BNSH entries of GFPAK archives](../reference/shaders.md#bnsh-entries-of-gfpak-archives) - the split counts hundreds of files as unreadable (678 on Pokémon Legends: Arceus), each with `[RED] .gfpak: entry 0 has compression 3 (Oodle), which this reader does not decode`, and leaves their shaders out. → [Oodle entries of GFPAK archives](../reference/shaders.md#oodle-entries-of-gfpak-archives) +- the shader split finds only shaders in `main`, followed by `[RED] shader ... is not in the shader map` and missing pictures. → [GLSLC shaders inside Larian LSPK archives](../reference/shaders.md#glslc-shaders-inside-larian-lspk-archives) - the split found no shader in the game (0 shaders). → [GLSLC outputs holding several stages](../reference/shaders.md#glslc-outputs-holding-several-stages) - an NVN game's split finds none of its shipped shaders, and the ones it compiles at run time were not captured: the game's renderer is in NROs that call `glslcCompile`, and its whole RomFS is one zip. → [GLSLC outputs in a zip archive, and those NROs compile at run time](../reference/shaders.md#glslc-outputs-in-a-zip-archive-and-those-nros-compile-at-run-time) - a Switch 2 game whose split finds no shader: the RomFS holds `Asset/assets_a000.dat` (magic `PDAT`, 16.6 GB) and `Asset/assets.cat` (magic `PARC`, a table of 64-bit ID hashes), and the first boot stops on `cannot read the shader map`. → [PMAP records in phx's PDAT archives](../reference/shaders.md#pmap-records-in-phxs-pdat-archives) diff --git a/docs/development.md b/docs/development.md index 44c96be81d..d13b4aa159 100644 --- a/docs/development.md +++ b/docs/development.md @@ -431,6 +431,7 @@ What the script does: So does an OpenGL game with no GLSL source in its RomFS or `main` (Balatro: LÖVE puts its shaders together at run time, in Lua): `DM_GL_CAPTURE` at `_build/extract//gl_capture` until every file in the profile's `gl_capture.tsv` is there (Balatro: 35, made before its first frame). Without the profile's tables (a provisional profile) a capture stops once nothing has been added for 20 s. The Vulkan and GL captures also run on a Mac whose GPU has no Metal 4, such as a virtual Mac (GitHub's `macos-26` runner). Those two layers draw on classic Metal queues of their own, so there the NVN layer starts only its classic part and drops every present, which a headless capture does not need (`nvn_mtl.m` `mtl_device_classic`; `DM_TEST_NO_METAL4=1` does the same on any Mac). The GLSLC capture boots an NVN game, which needs Metal 4. 5. It splits out the shaders from the game and every extracted DLC RomFS and checks them against the profile's tables (a Vulkan profile: it translates the RomFS's SPIR-V and the capture with `vk_shaders.py` instead; an OpenGL one: the GLSL sources and the capture with `gl_shaders.py`). Optional DLC that changes only the NVN shader union gets new tables in an external profile after the non-DLC shaders and facts have matched; registered module builds and fixes stay in use. + Larian version 13 `.pak` archives contribute their compiled `.bshd` shaders, including numbered archive parts. The reader supports stored, zlib and LZ4 entries; solid archives and other versions remain unsupported and are reported as unreadable ([Larian LSPK shaders](reference/shaders.md#glslc-shaders-inside-larian-lspk-archives)). Captured `.glslc` and decoded Unreal `.ushaderbytecode` inputs exclude only macOS AppleDouble `._` metadata, including `DM_GLSLC_CAPTURE` and `DM_UE_SHADERS` overrides. Other hidden names remain inputs and malformed shader files still stop the build ([external shader metadata](reference/packing.md#external-shader-directories-contain-appledouble-metadata)). 6. It builds the iOS shader libraries with the in-repo SPIRV-Cross (`dm/gfx/tools/spirv_cross_bin`) and the Metal compiler in `dmsh_chain.lock` (Xcode 27.2). Another Metal compiler stops an NVN game's build before its shader steps; the compiler is the selected Xcode's, so a Mac with a newer Xcode installs 27.2 beside it and selects it ([Metal compiler of a newer Xcode](reference/shaders.md#metal-compiler-of-a-newer-xcode)). Issue #242 reported corruption with an untested compiler; its role remains unconfirmed. `--allow-other-metal` (`ALLOW_OTHER_METAL = True` in Spyder; `DM_ALLOW_OTHER_METAL=1` for `own_compilers.py`) permits it and reports the actual version. If a registered profile's `dmsh_chain.h` does not hold the version the source tree gives (a chain change left it behind), the script writes it again before any host is built, so it shows as changed in the checkout (`dmsh_build.py` would stop otherwise). Then the script makes the profile's `c1_full.tsv` and `cb_extent.tsv` from this build's Mac outputs when they are missing or name another chain. These two tables are not committed and git ignores them; `build_ipa.sh` stops without a current `c1_full.tsv`. Each line in the output names the file. It then compares the build with the game's previous one, the newest other `b-*/` in `_build/shader/cache/` (the one the last chain change left: a build that succeeds removes the older chain folders and their keys, so each game keeps one) (`dm/gfx/tools/shader_regressions.py`, which reads the two `_b_build.json` reports). The translation chain is one for every game, so a translator change is tested only on the games its author owns; shaders that translated before and fail now are listed in `logs/shader_regressions.log` and stop the build ([GitHub issue #272](https://github.com/seedds/nxnative/issues/272)). Report them; `--allow-shader-regressions` (`ALLOW_SHADER_REGRESSIONS = True` in Spyder) packs the app without them. diff --git a/docs/reference/shaders.md b/docs/reference/shaders.md index bb823289e6..5cfdb3edd4 100644 --- a/docs/reference/shaders.md +++ b/docs/reference/shaders.md @@ -157,6 +157,13 @@ Finding, keying and translating shaders (`dm/gfx/tools/shader_split.py`, `dmsh_b - [Absolum](../../dm/games/0100A83022A22000/README.md): 163 GLSLC outputs in `shaders.nx.bin` (`rom:/shaders.nx.bin`: a u32 count, then per entry a u32 hash, a u32 size and one whole output): 162 unique shaders, 83 pixel and 79 vertex, every vertex shader paired with the single-stage cover ([Single-stage programs paired with a cover](#single-stage-programs-paired-with-a-cover)). Its `.xnb` effects are XNA's D3D9 effects, MOJOSHADER linked in, which the GPU does not run. - [Catherine: Full Body](../../dm/games/0100BF00112C0000/README.md): 3,050 loose files in `data/shaders_binary` (`*.vs` / `*.ps`, one stage each, one output per file), read like FINAL FANTASY TACTICS' technique files; its vertex shaders are in two parts ([VertexA and VertexB parts merged into one vertex shader](#vertexa-and-vertexb-parts-merged-into-one-vertex-shader)). +### GLSLC shaders inside Larian LSPK archives + +- **Symptom:** the shader split finds only shaders in `main`, followed by `[RED] shader ... is not in the shader map` and missing pictures. +- **Cause:** Larian's version 13 `.pak` archives keep their file list in an LZ4 block addressed by a footer header. The compiled `.bshd` entries can be stored, zlib or LZ4 blocks, including in numbered archive parts; each then holds a big-endian size with bit 31 set and a zlib-wrapped GLSLC program. A raw scan cannot see the compressed shaders. +- **Fix:** `lspk_jobs` selects only `.bshd` entries and excludes the archive parts from loose-file scanning. `lspk_shaders` decodes both compression layers and gives the result to the existing GLSLC reader, retaining a vertex/pixel program's pairing. File-list ranges, part references, decoded lengths and one-GiB per-block budgets are checked before scanning. Damaged inputs, unsupported versions, solid archives and unsupported compression are reported as unreadable rather than silently treated as shaderless files. +- **Found on:** [Divinity: Original Sin 2 - Definitive Edition, issue #1679](https://github.com/seedds/nxnative/issues/1679); the reporter supplied the archive and `.bshd` layout and an initial reader. The archive layout also matches [LSLib's LSPKHeader13 and FileEntry10](https://github.com/Norbyte/lslib/blob/6f5f6987d10d8485876b8368431c948e4dea4f83/LSLib/LS/PackageFormat.cs). No repository profile exists yet. The title-screen loading loop is a separate unresolved problem, and this extraction fix does not establish gameplay support. + ### GLSLC outputs holding several stages - **Symptom:** the split found no shader in the game (0 shaders).