mirror of
https://github.com/valentderah/totk-pc-recomp.git
synced 2026-11-11 01:41:02 +00:00
141 lines
5.5 KiB
Python
141 lines
5.5 KiB
Python
"""Fold the loose files of a shader cache into <cache>/shader-cache.pack and delete them.
|
|
|
|
The translator writes four files per shader into the cache (runtime/gfx_shader.c): <key>.spv, <key>.res.json,
|
|
<name>.use and in/<input id>.in, about 117,000 files after an install, which are slow to copy, back up, scan and sync.
|
|
totk.exe reads the pack first and the loose files after it, and keeps writing new translations as loose files, so
|
|
this tool can run again at any time the game is closed: it merges the existing pack with the loose files (a loose file
|
|
wins over a packed one of the same name) and writes the pack anew. The installer runs it after preparing the shaders.
|
|
|
|
Layout, little-endian (gfx_shader.c, section "cache pack"): b'TKSPACK1', u32 count, u32 0, then count entries of 48
|
|
bytes sorted by (kind, name): name (the 32 bytes the file name's 64 hex digits spell), u32 kind (1 .spv, 2 .res.json,
|
|
3 .use, 4 in/*.in), u32 size, u64 offset; then the data.
|
|
|
|
python tools\\pack_shader_cache.py <install folder>\\shader-cache [--keep]
|
|
|
|
--keep leaves the loose files in place. Close the game first: a running totk.exe has read the old pack's index.
|
|
"""
|
|
import argparse
|
|
import os
|
|
import re
|
|
import struct
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
MAGIC = b'TKSPACK1'
|
|
PACK = 'shader-cache.pack'
|
|
KINDS = {'.spv': 1, '.res.json': 2, '.use': 3, '.in': 4}
|
|
NAME = re.compile(r'([0-9a-f]{64})(\.spv|\.res\.json|\.use|\.in)')
|
|
USE_SIZE = 20 # gfx_shader.c use_store: magic, version, three masks
|
|
|
|
|
|
def loose_files(cache):
|
|
"""{(kind, name): path} of the cache's loose files; *.in only in in/."""
|
|
found = {}
|
|
for folder, kinds in ((cache, (1, 2, 3)), (cache / 'in', (4,))):
|
|
if not folder.is_dir():
|
|
continue
|
|
with os.scandir(folder) as it:
|
|
for e in it:
|
|
m = NAME.fullmatch(e.name)
|
|
if not m or not e.is_file():
|
|
continue
|
|
kind = KINDS[m.group(2)]
|
|
if kind in kinds:
|
|
found[(kind, bytes.fromhex(m.group(1)))] = Path(e.path)
|
|
return found
|
|
|
|
|
|
def read_pack(path):
|
|
"""{(kind, name): (offset, size)} of an existing pack; {} when there is none or it fails a check."""
|
|
try:
|
|
f = open(path, 'rb')
|
|
except FileNotFoundError:
|
|
return {}
|
|
with f:
|
|
size = os.fstat(f.fileno()).st_size
|
|
head = f.read(16)
|
|
if len(head) != 16 or head[:8] != MAGIC:
|
|
print(f'{path}: not a cache pack, rebuilt from the loose files only', flush=True)
|
|
return {}
|
|
count = struct.unpack_from('<I', head, 8)[0]
|
|
raw = f.read(48 * count)
|
|
if len(raw) != 48 * count:
|
|
print(f'{path}: truncated index, rebuilt from the loose files only', flush=True)
|
|
return {}
|
|
out = {}
|
|
for i in range(count):
|
|
name, kind, length, offset = struct.unpack_from('<32sIIQ', raw, 48 * i)
|
|
if offset + length <= size:
|
|
out[(kind, name)] = (offset, length)
|
|
return out
|
|
|
|
|
|
def valid(kind, data):
|
|
return len(data) == USE_SIZE if kind == 3 else len(data) > 0
|
|
|
|
|
|
def pack(cache, keep=False):
|
|
cache = Path(cache)
|
|
out = cache / PACK
|
|
packed = read_pack(out)
|
|
loose = loose_files(cache)
|
|
keys = sorted(set(packed) | set(loose))
|
|
tmp = out.with_name(PACK + '.tmp')
|
|
index, written, skipped, offset = [], [], 0, 16 + 48 * len(keys)
|
|
old = open(out, 'rb') if packed else None
|
|
try:
|
|
with open(tmp, 'wb') as f:
|
|
f.write(bytes(offset)) # header and index, written once the offsets are known
|
|
for key in keys:
|
|
data = None
|
|
if key in loose: # a valid loose file, else the packed copy, else nothing
|
|
try:
|
|
data = loose[key].read_bytes()
|
|
except OSError:
|
|
pass
|
|
if data is not None and valid(key[0], data):
|
|
written.append(loose[key])
|
|
else:
|
|
data = None
|
|
if data is None and key in packed:
|
|
old.seek(packed[key][0])
|
|
data = old.read(packed[key][1])
|
|
if data is None or not valid(key[0], data):
|
|
skipped += 1
|
|
continue
|
|
f.write(data)
|
|
index.append(struct.pack('<32sIIQ', key[1], key[0], len(data), offset))
|
|
offset += len(data)
|
|
f.seek(0)
|
|
f.write(MAGIC + struct.pack('<II', len(index), 0) + b''.join(index))
|
|
f.flush()
|
|
os.fsync(f.fileno())
|
|
finally:
|
|
if old:
|
|
old.close()
|
|
os.replace(tmp, out)
|
|
if not keep:
|
|
for p in written:
|
|
p.unlink(missing_ok=True)
|
|
try:
|
|
(cache / 'in').rmdir() # only when empty
|
|
except OSError:
|
|
pass
|
|
return len(index), len(written), skipped, offset
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser(description='Fold a shader cache into shader-cache.pack.')
|
|
ap.add_argument('cache', type=Path, help='the shader-cache folder next to totk.exe')
|
|
ap.add_argument('--keep', action='store_true', help='keep the loose files')
|
|
a = ap.parse_args()
|
|
if not a.cache.is_dir():
|
|
sys.exit(f'{a.cache} is not a folder')
|
|
n, new, skipped, size = pack(a.cache, a.keep)
|
|
print(f'{a.cache / PACK}: {n} files, {size / 2**20:.0f} MiB ({new} loose files folded in'
|
|
f'{", " + str(skipped) + " unreadable or empty skipped" if skipped else ""})', flush=True)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|