# SPDX-License-Identifier: GPL-2.0-or-later """Local, version-gated asset preparation for the engine features: the Dream mirror's layout, the effect fixes and the native menu assets, from the player's own dump. Game files stay local. """ import hashlib import shutil import os import json import subprocess from pathlib import Path import struct import zlib LAYOUT = Path('dvdroot_ps4/map/mapstudio/m21_00_00_00.msb.dcx') INPUT_SHA = '8e1de0ee223cd5137a93383cfc7bb1d27964bc480c63e35013674717b403ef8f' OUTPUT_SHA = '99d5c7b7f52307344c6e8296b499d5e57969f21c443fcc607cb42cb2c38c8afa' EBOOT_SHA = {'941f887a562aae054fac35af8cc8f27cf075f3d4cc2e029fb5ae2a663aaa5ae7', 'cec1b276e7f9e4db978e57f524f41fbaac594530a3437b002e23f3fab14b4f86'} IMAGE_SHA = '071df19c8880086d97182dbc057bc8cb37badaca57d9112683836b24a0444c0a' HOOKS = ((0x1c1cce0, bytes.fromhex('554889e54157415653504d89ce4889fb')), (0x2034770, bytes.fromhex('554889e54157415641554154534883ec38'))) def sha(data): return hashlib.sha256(data).hexdigest() DCX_HEADER = bytes.fromhex( '44435800000100000000001800000024000000440000004c' '4443530000000000000000004443500044464c5400000020' '09000000000000000000000000000000000001014443410000000008') DCX_LARGEST = 32 << 20 def dcx_unpack(data): """The file inside a DCX container; anything else is returned as it is.""" if data[:4] != b'DCX\0': return data if len(data) < 0x4c or data[0x28:0x2c] != b'DFLT': raise ValueError('Unsupported DCX format') dca = data.find(b'DCA\0', 0x2c) if dca < 0 or dca + 8 > len(data): raise ValueError('DCX without a valid DCA header') begin = dca + struct.unpack_from('>I', data, dca + 4)[0] size = struct.unpack_from('>I', data, 0x1c)[0] if not dca + 8 <= begin < len(data) or not 0 < size <= DCX_LARGEST: raise ValueError('Invalid DCX header') inflater = zlib.decompressobj() raw = inflater.decompress(data[begin:], size + 1) if len(raw) != size or not inflater.eof or inflater.unused_data: raise ValueError('Invalid DCX compressed data or size') return raw def dcx_pack(raw): packed = zlib.compress(raw, 9) header = bytearray(DCX_HEADER) struct.pack_into('>II', header, 0x1c, len(raw), len(packed)) return bytes(header) + packed MIRROR_PART, MIRROR_SLOT, FIRST_ENEMY = 807, 742, 736 KIND_ENEMY, KIND_DUMMY_ENEMY = 2, 10 EVENT_PART_FIELDS = (0x48, 0x50, 0x5c) PART_PART_FIELDS = (0x128, 0x13c, 0x144, 0x178, 0x180, 0x188) def mirror_layout(raw): """The Dream's layout with the mirror stand-in as an enemy; only from the verified 1.09 file.""" if sha(raw) != INPUT_SHA: raise ValueError('Dream mirror requires the original 1.09 Dream layout') b = bytearray(raw) tables, at = [], 0x10 for n in range(4): if not 0 <= at <= len(b) - 16: raise ValueError('Invalid MSB list header') count = struct.unpack_from(' entry) for field in fields: if entry + field + 4 <= end: index = struct.unpack_from('= 0 and moved(index) != index: struct.pack_into('= MIRROR_SLOT: struct.pack_into(' MIRROR_PART: struct.pack_into(' len(bnd): raise ValueError('Unexpected effect binder entry') end = name_at while bnd[end:end + 2] != b'\0\0': end += 2 entries.append((at, bnd[name_at:end].decode('utf-16-le'), offset, packed)) return entries def replace_entry(bnd, name, data): """The binder with `name` (its file name) holding `data`: appended 16-byte aligned, its entry's sizes and offset pointed at it. The other entries and the name table stay put.""" found = [e for e in binder_entries(bnd) if e[1].replace('\\', '/').rsplit('/', 1)[-1] == name] if len(found) != 1: raise ValueError(f'{name} is not in the effect binder once') at = found[0][0] offset = (len(bnd) + 15) & ~15 out = bytearray(bnd) + bytes(offset - len(bnd)) + data struct.pack_into('.sha256, of its .dcx).""" game = Path(game) root = Path(cache) / 'effect-fixes' source, source_name, source_sha = EFFECT_SOURCE replacement = None fixed = [] for relative, packed_sha, input_sha, output_sha in EFFECT_FIXES: try: packed = (game / relative).read_bytes() except OSError: continue raw = None if sha(packed) != packed_sha: raw = dcx_unpack(packed) if sha(raw) != input_sha: log(f'Engine: Ebrietas effect fix: {relative.name} is not the 1.09 file (a mod changes it): left as it is') continue destination = root / relative stamp = destination.with_name(destination.name + '.sha256') try: if stamp.read_text(encoding='ascii').strip() == sha(destination.read_bytes()): fixed.append((relative, destination.resolve())) continue except OSError: pass raw = raw if raw is not None else dcx_unpack(packed) if sha(binder_file(raw, EFFECT_NAME)) != EFFECT_SHA: raise ValueError(f'Unexpected {EFFECT_NAME} in {relative.name}') if replacement is None: replacement = binder_file(dcx_unpack((game / source).read_bytes()), source_name) if sha(replacement) != source_sha: raise ValueError(f'Unexpected {source_name} in {source.name}') result = replace_entry(raw, EFFECT_NAME, replacement) if sha(result) != output_sha: raise ValueError(f'{relative.name} does not match the verified result') out = dcx_pack(result) destination.parent.mkdir(parents=True, exist_ok=True) temporary = destination.with_name(destination.name + f'.{os.getpid()}.tmp') temporary.write_bytes(out) os.replace(temporary, destination) stamp.write_text(sha(out) + '\n', encoding='ascii') fixed.append((relative, destination.resolve())) return fixed def prepare_lamp2lamp(game, update, patch, cache): file = Path(patch).read_bytes() if file[:4] != b'L2LP': raise RuntimeError('not a Lamp2Lamp patch file') body = zlib.decompress(file[8:]) at = 0 def take(count): nonlocal at if count > len(body) - at: raise RuntimeError('damaged Lamp2Lamp patch file') at += count return body[at - count:at] def u32(): return struct.unpack(' 24: raise ValueError('Native options require valid menu bundles for every installed language') if rebirth: sources.append((Path('dvdroot_ps4/script/talk/m24_02_00_00.talkesdbnd.dcx'), 'rebirth')) generator = sha(tool.read_bytes()) manifest_path = root / 'manifest.json' try: previous = json.loads(manifest_path.read_text(encoding='utf-8')) except (OSError, ValueError): previous = {} rows = {} for relative, kind in sources: source, destination = game / relative, root / relative if source.stat().st_size > 64 * 1024 * 1024: raise ValueError(f'Native menu source is oversized: {relative}') digest = sha(source.read_bytes()) prior = previous.get('files', {}).get(relative.as_posix(), {}) current = None if (previous.get('generator') == generator and prior.get('input') == digest and (kind != 'messages' or prior.get('plates') == plates)): try: current = sha(destination.read_bytes()) if current != prior.get('output'): current = None except OSError: pass if current is None: destination.parent.mkdir(parents=True, exist_ok=True) temporary = destination.with_name(destination.name + f'.{os.getpid()}.tmp') try: result = subprocess.run([str(tool), kind, str(source), str(temporary)], capture_output=True, text=True, errors='replace') if result.returncode: raise ValueError(result.stderr.strip() or f'Menu generator failed: {relative}') current = sha(temporary.read_bytes()) os.replace(temporary, destination) finally: temporary.unlink(missing_ok=True) rows[relative.as_posix()] = {'input': digest, 'output': current, 'plates': plates} root.mkdir(parents=True, exist_ok=True) temporary = manifest_path.with_suffix(f'.{os.getpid()}.tmp') temporary.write_text(json.dumps({'generator': generator, 'files': rows}, indent=2), encoding='utf-8') os.replace(temporary, manifest_path) return root.resolve() def prepare_wide_movies(game, option_movie, cache, tool, width, height): """Widescreen: the game's menu movies whose full-screen dims, fades and bands stop at the 16:9 stage's edges, widened by tool (bb-engine-assets wide-movies) for the UI viewport width x height (BB_UI_SIZE) into cache/wide-ui-py/WxH, and kept while the movies, the tool and the size stay the same. option_movie, when given, is the PC menus' optionsetting.gfx, widened in place of the game's. Returns (relative path, file, what changed) for each movie made. As start/assets.cpp PrepareWideMovies.""" game, tool = Path(game), Path(tool) size = f'{width}x{height}' # Apart from bb-start.exe's cache/wide-ui: the two take a file's time from different clocks, and # in one folder each would make the other's movies again at every start. root = Path(cache) / 'wide-ui-py' / size option = 'dvdroot_ps4/menu/optionsetting.gfx' sources = sorted((f'dvdroot_ps4/menu/{p.name}', Path(option_movie) if p.name == 'optionsetting.gfx' and option_movie else p) for p in (game / 'dvdroot_ps4/menu').glob('*.gfx') if p.is_file()) if not sources: raise ValueError(f'no menu movies in {game / "dvdroot_ps4/menu"}') # Told by each file's size and time: hashing some 70 movies at every start took longer than the # check is worth. def stamp(path): try: info = path.stat() except OSError: return 'missing' return f'{info.st_size}:{info.st_mtime_ns}' key = sha(f'{stamp(tool)} {size}'.encode() + b''.join(f'\n{relative} {stamp(source)}'.encode() for relative, source in sources)) manifest_path = root / 'manifest.json' try: previous = json.loads(manifest_path.read_text(encoding='utf-8')) except (OSError, ValueError): previous = {} if previous.get('key') == key and 'files' in previous: made = [(f['relative'], root / f['relative'], f.get('note', '')) for f in previous['files']] try: if all(stamp(path) == f['output'] for (_, path, _), f in zip(made, previous['files'])): return made except (OSError, KeyError): pass # Only the movies this made before are removed: a movie that needs nothing now must not stay. for f in previous.get('files', []): if f.get('relative'): (root / f['relative']).unlink(missing_ok=True) root.mkdir(parents=True, exist_ok=True) listing = root / 'sources.txt' listing.write_text(''.join(f'{relative}\t{source}\n' for relative, source in sources), encoding='utf-8') result = subprocess.run([str(tool), 'wide-movies', size, str(listing), str(root)], capture_output=True, text=True, encoding='utf-8', errors='replace') if result.returncode: raise ValueError(f'the menu movie tool failed: {(result.stderr or result.stdout).strip()}') made, rows = [], [] for line in result.stdout.splitlines(): relative, tab, note = line.partition('\t') if not tab or not relative: continue if relative.startswith('!'): print(f'Widescreen: {relative[1:]} left as it is: {note}', flush=True) continue path = root / relative rows.append({'relative': relative, 'output': stamp(path), 'note': note}) made.append((relative, path, note)) temporary = manifest_path.with_suffix(f'.{os.getpid()}.tmp') temporary.write_text(json.dumps({'key': key, 'files': rows}, indent=2), encoding='utf-8') os.replace(temporary, manifest_path) return made