# Copyright (c) 2026 Dominic Masters # # This software is released under the MIT License. # https://opensource.org/licenses/MIT """ Packs the assets directory into a dusk.dsk (DSK2) archive. A small fixed header (magic/version/size/checksum) followed by a single uncompressed (ZIP_STORED) zip archive of every asset. Uncompressed rather than DEFLATEd because the bulk of what's packed (audio, images) is already compressed at the file level, so a second, zip-level DEFLATE pass buys negligible space back for real CPU cost, and it lets every entry - not just a hand-picked subset - support the reliable repeated seeking/re-opening (e.g. locale line-by-line reads, MP3 loop restarts) that libzip only supports for uncompressed entries. DSK2 format: Bytes 0-3: "DSK2" (magic) Bytes 4-7: uint32_t version = 2 (little-endian) Bytes 8-11: uint32_t size (byte length of the zip archive that follows) Bytes 12-15: uint32_t checksum (CRC32 of the zip archive that follows) Bytes 16+: the zip archive itself Usage: python3 -m tools.asset.pack --input --output Every file under is added, using its path relative to as the zip entry name. Called unconditionally on every CMake build (see the DUSK_ASSETS_BUILT target) rather than gated behind file-level build-system dependencies - CMake's CONFIGURE_DEPENDS glob only notices added/removed files on the NEXT configure, so a plain custom_command DEPENDS list can miss edits to existing files or lag a build behind on adds/removes. Instead this script itself decides whether anything actually needs repacking: it writes a manifest of every input file's (mtime, size) next to the output on each successful pack, and skips the real work if a fresh manifest compares equal to it and the output still exists. The manifest also carries the format version, so a manifest written by an older version of this script (a stale single/dual-archive dusk.dsk sitting from before a format change) never compares equal and always triggers a fresh pack. """ import argparse import json import os import struct import zipfile import zlib import io MAGIC = b'DSK' VERSION = 2 HEADER_FORMAT = '<4sIII' HEADER_SIZE = struct.calcsize(HEADER_FORMAT) def build_zip_blob(root, relative_paths): buf = io.BytesIO() with zipfile.ZipFile(buf, 'w', compression=zipfile.ZIP_STORED) as zf: for relative_path in sorted(relative_paths): zf.write(os.path.join(root, relative_path), arcname=relative_path) return buf.getvalue() def manifest_path(output_path): return output_path + '.manifest.json' def compute_manifest(input_dir, relative_paths): entries = {} for relative_path in relative_paths: st = os.stat(os.path.join(input_dir, relative_path)) entries[relative_path] = [st.st_mtime_ns, st.st_size] return {'version': VERSION, 'files': entries} def load_manifest(path): try: with open(path, 'r', encoding='utf-8') as f: return json.load(f) except (OSError, ValueError): return None def pack(input_dir, output_path): relative_paths = [] for dirpath, _dirnames, filenames in os.walk(input_dir): for filename in filenames: full_path = os.path.join(dirpath, filename) relative_paths.append( os.path.relpath(full_path, input_dir).replace(os.sep, '/') ) manifest = compute_manifest(input_dir, relative_paths) manifest_file = manifest_path(output_path) if os.path.isfile(output_path) and load_manifest(manifest_file) == manifest: print(f'{output_path} is up to date, skipping pack') return blob = build_zip_blob(input_dir, relative_paths) header = struct.pack( HEADER_FORMAT, MAGIC, VERSION, len(blob), zlib.crc32(blob) & 0xFFFFFFFF ) with open(output_path, 'wb') as f: f.write(header) f.write(blob) with open(manifest_file, 'w', encoding='utf-8') as f: json.dump(manifest, f) print( f'Wrote {output_path}: {len(relative_paths)} file(s) ' f'({len(blob)} bytes), {len(header) + len(blob)} bytes total' ) def main(): parser = argparse.ArgumentParser( description='Pack the assets directory into a dusk.dsk (DSK2) archive.' ) parser.add_argument('--input', required=True, help='Assets directory to pack') parser.add_argument('--output', required=True, help='Path to write dusk.dsk to') args = parser.parse_args() pack(args.input, args.output) if __name__ == '__main__': main()