Created
August 9, 2026 08:07
-
-
Save CypherpunkSamurai/19e238c3d1c5432ca87199726458466f to your computer and use it in GitHub Desktop.
UnityExtract64
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| """Source-based Unity NSISBI extractor for legacy and Unity 6000 installers. | |
| Supported layouts discovered from Unity installers: | |
| * Legacy 2020-2022: 4-byte flagged raw-LZMA1 chunks. The decoded NSIS | |
| instruction table starts at 0x1b74, uses 36-byte records with opcode in | |
| field 0, and field 3 is the compressed data-chunk offset. Each data chunk | |
| expands directly to one file; there is no extra record-size word. | |
| * Unity 6000: 3-byte raw-LZMA1 chunks, an eight-byte declared-size header | |
| prefix, and a 36-byte Unity command table whose data stream contains | |
| 8-byte record headers. | |
| All progress/diagnostics belong on stderr; stdout is JSON. Extraction uses | |
| same-directory temporary files and atomic replacement; listing and verification | |
| never create a temporary workspace. | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import hashlib | |
| import json | |
| import logging | |
| import lzma | |
| import os | |
| import re | |
| import struct | |
| import sys | |
| import tempfile | |
| import time | |
| from dataclasses import dataclass | |
| from pathlib import Path | |
| from typing import BinaryIO | |
| MAGIC = b"NullsoftInst" | |
| SIG = 0xDEADBEEF | |
| FLAG_EXTERNAL = 0x20 | |
| FLAG_STUB = 0x40 | |
| MAX_CHUNK_OUTPUT = 256 * 1024 * 1024 | |
| MAX_RECORD_SIZE = 1 << 42 | |
| LOGGER = logging.getLogger("unity-nsisbi") | |
| @dataclass | |
| class Header: | |
| offset: int | |
| flags: int | |
| header_size: int | |
| following: int | |
| low: int | |
| high: int | |
| chunk_width: int | |
| @property | |
| def external(self) -> bool: | |
| return bool(self.flags & FLAG_EXTERNAL) | |
| @dataclass | |
| class FilePlan: | |
| offset: int | |
| paths: list[str] | |
| def u32(data: bytes, at: int) -> int: | |
| return struct.unpack_from("<I", data, at)[0] | |
| def u16(data: bytes, at: int) -> int: | |
| return struct.unpack_from("<H", data, at)[0] | |
| def u64(data: bytes, at: int) -> int: | |
| return struct.unpack_from("<Q", data, at)[0] | |
| def find_header(f: BinaryIO) -> Header: | |
| f.seek(0); carry = b""; absolute = 0 | |
| while True: | |
| block = f.read(1024 * 1024) | |
| if not block: break | |
| hay = carry + block; at = hay.find(MAGIC) | |
| if at >= 0: | |
| marker = absolute - len(carry) + at; off = marker - 8 | |
| f.seek(off); raw = f.read(36) | |
| if len(raw) == 36 and u32(raw, 4) == SIG and raw[8:20] == MAGIC: | |
| f.seek(off + 36); probe = f.read(9); f.seek(off + 36) | |
| width = 4 if len(probe) >= 9 and (u32(probe, 0) & 0x80000000) else 3 | |
| header = Header(off, u32(raw, 0), u32(raw, 20), u32(raw, 24), u32(raw, 28), u32(raw, 32), width) | |
| LOGGER.debug( | |
| "header: offset=0x%x flags=0x%x declared_size=%d following=%d low=0x%x high=0x%x chunk_width=%d", | |
| header.offset, header.flags, header.header_size, header.following, | |
| header.low, header.high, header.chunk_width, | |
| ) | |
| return header | |
| carry = hay[-(len(MAGIC) - 1):]; absolute += len(block) | |
| raise ValueError("NSISBI first header not found") | |
| def lzma_filter(props: bytes) -> dict[str, int]: | |
| if len(props) != 5: raise ValueError("truncated LZMA properties") | |
| p = props[0] | |
| if p >= 225: raise ValueError(f"invalid LZMA property 0x{p:02x}") | |
| rem = p // 9 | |
| dictionary = int.from_bytes(props[1:5], "little") | |
| if dictionary < 4096 or dictionary > 1 << 30: raise ValueError("invalid LZMA dictionary") | |
| return {"id": lzma.FILTER_LZMA1, "dict_size": dictionary, "lc": p % 9, "lp": rem % 5, "pb": rem // 5} | |
| def decode_chunk(f: BinaryIO, width: int, expected: int | None = None) -> tuple[bytes, int]: | |
| start = f.tell() | |
| raw = f.read(width) | |
| if len(raw) != width: raise EOFError("truncated chunk length") | |
| word = int.from_bytes(raw, "little") | |
| length = (word & 0x7fffffff) if width == 4 else word | |
| if length < 5: raise ValueError(f"invalid chunk length {length}") | |
| props = f.read(5); compressed = f.read(length - 5) | |
| if len(props) != 5 or len(compressed) != length - 5: raise EOFError("truncated chunk") | |
| decoder = lzma.LZMADecompressor(format=lzma.FORMAT_RAW, filters=[lzma_filter(props)]) | |
| output = decoder.decompress(compressed) | |
| if not decoder.eof or decoder.unused_data: raise ValueError("LZMA chunk did not terminate cleanly") | |
| if len(output) > MAX_CHUNK_OUTPUT: raise ValueError("chunk output exceeds safety limit") | |
| if expected is not None and len(output) != expected: raise ValueError(f"chunk produced {len(output)}, expected {expected}") | |
| LOGGER.debug( | |
| "chunk: file_offset=0x%x width=%d stored_length=%d compressed=%d decoded=%d next=0x%x", | |
| start, width, length, length - 5, len(output), f.tell(), | |
| ) | |
| return output, width + length | |
| def decode_header(f: BinaryIO, h: Header) -> tuple[bytes, int]: | |
| f.seek(h.offset + 36); result = bytearray(); chunk_index = 0 | |
| # Unity 6000 stores an eight-byte logical-size prefix in the decoded | |
| # header. The NSISBI field excludes that prefix, so its final chunk ends | |
| # eight decoded bytes after h.header_size. Older layouts do not have it. | |
| first, _ = decode_chunk(f, h.chunk_width) | |
| result.extend(first); chunk_index += 1 | |
| modern_prefix = len(first) >= 8 and int.from_bytes(first[:8], "little") == h.header_size | |
| target = h.header_size + 8 if modern_prefix else h.header_size | |
| LOGGER.debug( | |
| "header stream: target=%d (declared=%d, modern_size_prefix=%s)", | |
| target, h.header_size, modern_prefix, | |
| ) | |
| while len(result) < target: | |
| chunk, _ = decode_chunk(f, h.chunk_width) | |
| result.extend(chunk); chunk_index += 1 | |
| if len(result) != target: | |
| raise ValueError( | |
| f"header stream decoded {len(result)} bytes, expected {target} " | |
| f"(declared {h.header_size}{' + 8-byte Unity 6000 prefix' if modern_prefix else ''})" | |
| ) | |
| LOGGER.debug("header decoded: chunks=%d bytes=%d data_start=0x%x", chunk_index, len(result), f.tell()) | |
| return bytes(result), f.tell() | |
| def decode_string(data: bytes, base: int, end: int, reference: int, legacy: bool) -> str: | |
| # References are UTF-16 code-unit offsets from the first string unit. | |
| # Legacy strings begin directly at the block boundary; Unity 6000 has an | |
| # eight-byte block header before its first string unit. | |
| at = base + (0 if legacy else 8) + reference * 2 | |
| if not base <= at < end - 1: return "" | |
| out: list[str] = [] | |
| while at + 1 < end: | |
| unit = u16(data, at) | |
| at += 2 | |
| if unit == 0: break | |
| if unit == 3 and at + 1 < end: | |
| encoded = u16(data, at) | |
| at += 2 | |
| var_id = (encoded & 0x7f) | (((encoded >> 8) & 0x7f) << 7) | |
| variables = { | |
| 20: "$CMDLINE", 21: "$INSTDIR", 22: "$OUTDIR", | |
| 23: "$EXEDIR", 24: "$LANGUAGE", 25: "$TEMP", | |
| 26: "$PLUGINSDIR", 27: "$EXEFILE", 28: "$EXEPATH", | |
| 29: "$HWNDPARENT", 31: "$INSTDIR", | |
| } | |
| if var_id < 10: out.append(f"${var_id}") | |
| elif var_id < 20: out.append(f"$R{var_id - 10}") | |
| else: out.append(variables.get(var_id, f"$VAR{var_id}")) | |
| continue | |
| if unit < 32 and unit not in (9, 10, 13): return "" | |
| out.append(chr(unit)) | |
| return "".join(out) | |
| WINDOWS_RESERVED_NAMES = { | |
| "CON", "PRN", "AUX", "NUL", | |
| *(f"COM{index}" for index in range(1, 10)), | |
| *(f"LPT{index}" for index in range(1, 10)), | |
| } | |
| def safe_name(name: str) -> str: | |
| name = name.replace("\\", "/") | |
| name = re.sub(r"^\$(?:INSTDIR|OUTDIR|EXEDIR|TEMP|PLUGINSDIR|EXEFILE|EXEPATH|VAR\d+)/?", "", name, flags=re.I) | |
| name = re.sub(r"^[A-Za-z]:/?", "", name).lstrip("/") | |
| parts = [] | |
| for part in name.split("/"): | |
| if part in ("", "."): continue | |
| if part == "..": | |
| if parts: parts.pop() | |
| continue | |
| parts.append(part) | |
| sanitized: list[str] = [] | |
| for part in parts: | |
| value = re.sub(r"[^A-Za-z0-9._-]+", "_", part) | |
| stem = value.rstrip(" .").split(".", 1)[0].upper() | |
| if stem in WINDOWS_RESERVED_NAMES: | |
| value = f"_{value}" | |
| sanitized.append(value) | |
| return "/".join(sanitized) or "unnamed.bin" | |
| def destination_key(relative: str) -> str: | |
| """Return a filesystem-insensitive key for collision detection.""" | |
| return relative.replace("\\", "/").casefold() | |
| SPECIAL_ROOT_RE = re.compile( | |
| r"^\$(INSTDIR|OUTDIR|EXEDIR|TEMP|PLUGINSDIR|EXEFILE|EXEPATH|VAR\d+)(?:[\\\\/]|$)", | |
| re.IGNORECASE, | |
| ) | |
| def join_path(folder: str, filename: str) -> str: | |
| # NSIS can put an absolute special-folder token in the filename itself. | |
| # Preserve PLUGINSDIR as a visible tree root; the other installer-root | |
| # tokens intentionally resolve relative to the requested output root. | |
| special = SPECIAL_ROOT_RE.match(filename) | |
| if special: | |
| token = special.group(1).upper() | |
| remainder = filename[special.end():] | |
| if token == "PLUGINSDIR": | |
| return safe_name(f"PLUGINSDIR/{remainder}") | |
| return safe_name(remainder) | |
| filename = safe_name(filename) | |
| folder = safe_name(folder) if folder else "" | |
| return safe_name(f"{folder}/{filename}" if folder else filename) | |
| def legacy_plans(header: bytes) -> tuple[list[FilePlan], dict[str, object]]: | |
| entries = u32(header, 0x14); count = u32(header, 0x18); entries_end = u32(header, 0x1c) | |
| strings = entries_end | |
| strings_end = u32(header, 0x24) | |
| if not (entries == 0x1b74 and entries_end == entries + count * 36 and entries_end <= strings < strings_end <= len(header)): | |
| raise ValueError("unsupported legacy NSISBI table boundaries") | |
| folder = "PLUGINSDIR"; by_offset: dict[int, list[str]] = {}; commands = 0; mapped = 0 | |
| for i in range(count): | |
| fields = struct.unpack_from("<9I", header, entries + i * 36); opcode = fields[0] | |
| if opcode == 11: | |
| value = decode_string(header, strings, strings_end, fields[1], True) | |
| if value: folder = value | |
| elif opcode == 20: | |
| commands += 1 | |
| filename = decode_string(header, strings, strings_end, fields[2], True) | |
| if not filename: continue | |
| destination = join_path(folder, filename); mapped += 1 | |
| paths = by_offset.setdefault(fields[3], []) | |
| if destination not in paths: paths.append(destination) | |
| plans = [FilePlan(offset, paths) for offset, paths in by_offset.items()] | |
| meta = {"format": "legacy-36-byte-opcode0-4byte-chunks", "entry_offset": entries, "entry_count": count, "entry_end": entries_end, "string_offset": strings, "string_end": strings_end, "extract_command_count": commands, "mapped_extract_command_count": mapped, "named_data_offsets": len(by_offset), "mapped_destination_count": sum(len(x) for x in by_offset.values())} | |
| if commands < 32 or mapped * 100 < commands * 90: raise ValueError("legacy table did not pass mapping sanity checks") | |
| return plans, meta | |
| def modern_plans(header: bytes) -> tuple[list[FilePlan], dict[str, object]]: | |
| entries = u32(header, 0x1c); strings = u32(header, 0x24); strings_end = u32(header, 0x2c) | |
| if not (0 < entries < strings < strings_end <= len(header) and (strings - entries) % 36 == 0): raise ValueError("unsupported Unity 6000 table boundaries") | |
| folder = "PLUGINSDIR"; by_offset: dict[int, list[str]] = {}; commands = mapped = 0 | |
| for i in range((strings - entries) // 36): | |
| fields = struct.unpack_from("<9I", header, entries + i * 36); opcode = fields[2] | |
| if opcode == 11: | |
| value = decode_string(header, strings, strings_end, fields[3], False) | |
| if value: folder = value | |
| elif opcode == 20: | |
| commands += 1; filename = decode_string(header, strings, strings_end, fields[4], False) | |
| if not filename: continue | |
| destination = join_path(folder, filename); mapped += 1 | |
| paths = by_offset.setdefault(fields[5], []) | |
| if destination not in paths: paths.append(destination) | |
| meta = {"format": "unity6000-36-byte-opcode2-3byte-records", "entry_offset": entries, "string_offset": strings, "string_end": strings_end, "extract_command_count": commands, "mapped_extract_command_count": mapped, "named_data_offsets": len(by_offset), "mapped_destination_count": sum(len(x) for x in by_offset.values())} | |
| if commands < 32 or mapped * 100 < commands * 90: raise ValueError("Unity 6000 table did not pass mapping sanity checks") | |
| return [FilePlan(k, v) for k, v in by_offset.items()], meta | |
| def validate_destinations(plans: list[FilePlan]) -> None: | |
| seen: dict[str, str] = {} | |
| for plan in plans: | |
| for relative in plan.paths: | |
| key = destination_key(relative) | |
| previous = seen.setdefault(key, relative) | |
| if previous != relative: | |
| raise ValueError( | |
| f"destination paths collide on a case-insensitive filesystem: " | |
| f"{previous!r} and {relative!r}" | |
| ) | |
| def tree(paths: list[str]) -> dict[str, object]: | |
| root = {"directories": {}, "files": []} | |
| for path in paths: | |
| node = root; parts = [x for x in path.split("/") if x] | |
| for part in parts[:-1]: node = node["directories"].setdefault(part, {"directories": {}, "files": []}) | |
| if parts: node["files"].append(parts[-1]) if parts[-1] not in node["files"] else None | |
| return root | |
| def magic(data: bytes) -> str: | |
| if data.startswith(b"MZ"): return "MZ/PE" | |
| if data.startswith(b"PK"): return "ZIP" | |
| if data.startswith(b"UnityFS"): return "UnityFS" | |
| if data.startswith(b"\x7fELF"): return "ELF" | |
| if data.startswith(b"\x89PNG"): return "PNG" | |
| return data[:8].hex() if data else "empty" | |
| def destination_path(root: Path, relative: str) -> Path: | |
| """Resolve a planned path without following an output-tree symlink.""" | |
| parts = [part for part in relative.replace("\\", "/").split("/") if part] | |
| if not parts: | |
| raise ValueError("empty extraction path") | |
| candidate = root.joinpath(*parts) | |
| current = root | |
| for part in parts: | |
| current = current / part | |
| if current.is_symlink(): | |
| raise ValueError(f"refusing symlinked extraction path: {relative}") | |
| root_resolved = root.resolve() | |
| resolved = candidate.resolve(strict=False) | |
| try: | |
| resolved.relative_to(root_resolved) | |
| except ValueError as exc: | |
| raise ValueError(f"extraction path escapes output directory: {relative}") from exc | |
| return candidate | |
| class ModernPartialRecord(EOFError): | |
| def __init__(self, offset: int, expected: int, available: int) -> None: | |
| super().__init__( | |
| f"modern record at virtual offset {offset} is truncated: " | |
| f"expected {expected} bytes, have {available}" | |
| ) | |
| self.offset = offset | |
| self.expected = expected | |
| self.available = available | |
| class ModernDataStream: | |
| """Sequentially expose Unity 6000's virtual record stream. | |
| Modern command offsets address the decompressed stream, not installer file | |
| offsets. Each record is encoded as an eight-byte little-endian payload | |
| size followed by that many bytes of payload. Compressed chunks are decoded | |
| only as needed, keeping memory bounded to the current chunks and record. | |
| """ | |
| def __init__(self, file: BinaryIO, width: int, data_start: int) -> None: | |
| self.file = file | |
| self.width = width | |
| self.data_start = data_start | |
| self.buffer = bytearray() | |
| self.position = 0 | |
| self.file.seek(data_start) | |
| def _fill(self, required: int) -> None: | |
| while len(self.buffer) < required: | |
| chunk, _ = decode_chunk(self.file, self.width) | |
| self.buffer.extend(chunk) | |
| def read(self, size: int) -> bytes: | |
| if size < 0 or size > MAX_RECORD_SIZE: | |
| raise ValueError(f"invalid modern record read size {size}") | |
| try: | |
| self._fill(size) | |
| except EOFError as exc: | |
| raise EOFError( | |
| f"modern data stream truncated at virtual offset {self.position}" | |
| ) from exc | |
| value = bytes(self.buffer[:size]) | |
| del self.buffer[:size] | |
| self.position += size | |
| return value | |
| def iter_record(self) -> tuple[int, bool, int, object]: | |
| """Read the next sequential Unity 6000 record. | |
| The command-table offsets are lookup keys. They are not a promise that | |
| every command offset is a top-level record boundary (package entries | |
| can refer into a containing payload), so records must be consumed in | |
| stream order rather than sought by plan offset. | |
| """ | |
| offset = self.position | |
| raw = int.from_bytes(self.read(8), "little") | |
| compressed = bool(raw & (1 << 63)) | |
| size = raw & ((1 << 63) - 1) | |
| if size > MAX_RECORD_SIZE: | |
| raise ValueError(f"modern record at virtual offset {offset} has unreasonable size {size}") | |
| def chunks(): | |
| remaining = size | |
| while remaining: | |
| part = min(remaining, 8 * 1024 * 1024) | |
| try: | |
| yield self.read(part) | |
| except EOFError as exc: | |
| available = len(self.buffer) | |
| raise ModernPartialRecord(offset, size, size - remaining + available) from exc | |
| remaining -= part | |
| return offset, compressed, size, chunks() | |
| def replace_with_retry(source: Path, destination: Path) -> None: | |
| for attempt in range(6): | |
| try: | |
| os.replace(source, destination) | |
| return | |
| except PermissionError: | |
| if attempt == 5: | |
| raise | |
| time.sleep(0.25 * (attempt + 1)) | |
| def atomic_write(path: Path, payload: bytes) -> None: | |
| """Write a payload through a same-directory temporary file.""" | |
| temporary: Path | None = None | |
| try: | |
| with tempfile.NamedTemporaryFile( | |
| mode="wb", prefix=f".{path.name}.", suffix=".part", | |
| dir=path.parent, delete=False, | |
| ) as stream: | |
| temporary = Path(stream.name) | |
| stream.write(payload) | |
| stream.flush() | |
| try: | |
| os.fsync(stream.fileno()) | |
| except OSError as exc: | |
| LOGGER.debug("filesystem sync unavailable for %s: %s", path, exc) | |
| for attempt in range(6): | |
| try: | |
| os.replace(temporary, path) | |
| temporary = None | |
| break | |
| except PermissionError: | |
| if attempt == 5: | |
| raise | |
| time.sleep(0.25 * (attempt + 1)) | |
| finally: | |
| if temporary is not None: | |
| try: | |
| temporary.unlink() | |
| except FileNotFoundError: | |
| pass | |
| def run(installer: Path, output: Path | None, list_only: bool, verify: bool, limit: int | None) -> dict[str, object]: | |
| with installer.open("rb") as f: | |
| h = find_header(f); header, data_start = decode_header(f, h) | |
| modern = len(header) >= 8 and int.from_bytes(header[:8], "little") == h.header_size | |
| LOGGER.debug("layout: %s", "Unity 6000" if modern else "legacy 2020-2022") | |
| plans, table = modern_plans(header) if modern else legacy_plans(header) | |
| LOGGER.debug( | |
| "table: format=%s extract_commands=%s mapped=%s named_offsets=%s", | |
| table.get("format"), table.get("extract_command_count"), | |
| table.get("mapped_extract_command_count"), table.get("named_data_offsets"), | |
| ) | |
| validate_destinations(plans) | |
| paths = sorted({p for plan in plans for p in plan.paths}) | |
| result: dict[str, object] = { | |
| "installer": str(installer), | |
| "header_offset": hex(h.offset), | |
| "flags": hex(h.flags), | |
| "header_size": h.header_size, | |
| "chunk_width": h.chunk_width, | |
| "header_prefix_size": 8 if modern else 0, | |
| "data_start": hex(data_start), | |
| "external_data": h.external, | |
| "table": table, | |
| } | |
| if list_only: | |
| listed = paths if limit is None else paths[:limit] | |
| result.update({"tree": tree(paths), "paths": listed, "tree_file_count": len(paths), "listed_tree_file_count": len(listed), "data_stream_scanned": False, "records": []}) | |
| return result | |
| if not verify and output is None: raise ValueError("output required unless --list or --verify") | |
| output and output.mkdir(parents=True, exist_ok=True) | |
| records: list[dict[str, object]] = []; unmapped = 0; partial_record = None | |
| if modern: | |
| stream = ModernDataStream(f, h.chunk_width, data_start) | |
| paths_by_offset = {plan.offset: plan.paths for plan in plans} | |
| index = 0 | |
| while limit is None or len(records) < limit: | |
| try: | |
| offset, compressed, payload_size, payload_chunks = stream.iter_record() | |
| relatives = paths_by_offset.get(offset) | |
| if not relatives: | |
| for _ in payload_chunks: | |
| pass | |
| unmapped += 1 | |
| index += 1 | |
| continue | |
| digest = hashlib.sha256(); prefix = bytearray(); actual = [] | |
| streams = [] | |
| if output is not None: | |
| for rel in relatives: | |
| path = destination_path(output, rel) | |
| path.parent.mkdir(parents=True, exist_ok=True) | |
| path = destination_path(output, rel) | |
| temporary = tempfile.NamedTemporaryFile( | |
| mode="wb", prefix=f".{path.name}.", suffix=".part", | |
| dir=path.parent, delete=False, | |
| ) | |
| streams.append((path, Path(temporary.name), temporary)) | |
| try: | |
| for part in payload_chunks: | |
| digest.update(part) | |
| if len(prefix) < 8: | |
| prefix.extend(part[:8 - len(prefix)]) | |
| for _, _, target in streams: | |
| target.write(part) | |
| for path, temporary, target in streams: | |
| target.flush() | |
| try: | |
| os.fsync(target.fileno()) | |
| except OSError as exc: | |
| LOGGER.debug("filesystem sync unavailable for %s: %s", path, exc) | |
| target.close() | |
| replace_with_retry(temporary, path) | |
| actual.append(str(path.relative_to(output)).replace("\\", "/")) | |
| streams.clear() | |
| finally: | |
| for _, temporary, target in streams: | |
| try: | |
| target.close() | |
| finally: | |
| try: | |
| temporary.unlink() | |
| except FileNotFoundError: | |
| pass | |
| records.append({"index": index, "offset": offset, "stored_size": payload_size, "compressed": compressed, "paths": relatives, "path": relatives[0], "magic": magic(bytes(prefix)), "sha256": digest.hexdigest(), "written_paths": actual}) | |
| index += 1 | |
| except ModernPartialRecord as exc: | |
| partial_record = {"offset": exc.offset, "expected_bytes": exc.expected, "available_bytes": exc.available} | |
| if not h.external: | |
| raise | |
| break | |
| except EOFError: | |
| if h.external: | |
| partial_record = {"offset": stream.position, "expected_bytes": None, "available_bytes": 0} | |
| break | |
| raise | |
| else: | |
| for index, plan in enumerate(plans): | |
| if limit is not None and len(records) >= limit: break | |
| f.seek(data_start + plan.offset) | |
| payload, _ = decode_chunk(f, h.chunk_width) | |
| sha = hashlib.sha256(payload).hexdigest(); actual = [] | |
| if output is not None: | |
| for rel in plan.paths: | |
| path = destination_path(output, rel) | |
| path.parent.mkdir(parents=True, exist_ok=True) | |
| path = destination_path(output, rel) | |
| atomic_write(path, payload) | |
| actual.append(rel) | |
| records.append({"index": index, "offset": plan.offset, "stored_size": len(payload), "paths": plan.paths, "path": plan.paths[0], "magic": magic(payload), "sha256": sha, "written_paths": actual}) | |
| result.update({"data_stream_scanned": True, "record_count": len(records), "records": records, "unmapped_record_offsets": unmapped, "partial_record": partial_record, "external_data_required": bool(h.external and partial_record)}) | |
| if output is not None: | |
| atomic_write( | |
| output / "manifest.json", | |
| (json.dumps(result, indent=2) + "\n").encode("utf-8"), | |
| ) | |
| return result | |
| def main(argv: list[str] | None = None) -> int: | |
| p = argparse.ArgumentParser(description="Extract Unity NSISBI legacy and Unity 6000 installers") | |
| p.add_argument("installer", type=Path); p.add_argument("output", type=Path, nargs="?"); p.add_argument("--list", action="store_true"); p.add_argument("--verify", action="store_true"); p.add_argument("--limit", type=int) | |
| p.add_argument("-v", "--verbose", action="store_true", help="write parser diagnostics to stderr") | |
| a = p.parse_args(argv) | |
| if a.verbose: | |
| logging.basicConfig(level=logging.DEBUG, format="[nsisbi] %(message)s", stream=sys.stderr) | |
| if a.list and a.verify: p.error("--list and --verify cannot be combined") | |
| if a.verify and a.output: p.error("--verify does not take an output directory") | |
| try: print(json.dumps(run(a.installer, a.output, a.list, a.verify, a.limit), indent=2)); return 0 | |
| except (OSError, ValueError, EOFError, lzma.LZMAError) as exc: print(f"error: {exc}", file=sys.stderr); return 2 | |
| if __name__ == "__main__": raise SystemExit(main()) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment