Last active
May 16, 2026 03:58
-
-
Save snowyegret23/d1d5576f84eb2e18468dc59a38f0d1fa to your computer and use it in GitHub Desktop.
SRW_GC_FONT
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
| import argparse | |
| import hashlib | |
| import json | |
| import struct | |
| from pathlib import Path | |
| WINDOW_SIZE = 0x1000 | |
| WINDOW_START = 0xFEE | |
| MAX_MATCH = 18 | |
| MIN_MATCH = 3 | |
| def _read_u32le(data, offset): | |
| return struct.unpack_from("<I", data, offset)[0] | |
| def _read_u32be(data, offset): | |
| return struct.unpack_from(">I", data, offset)[0] | |
| def _sha256(data): | |
| return hashlib.sha256(data).hexdigest() | |
| def _manifest_name(input_path): | |
| return f"{input_path.stem}.manifest" | |
| def _inner_dump_name(input_path): | |
| return f"{input_path.name}_" | |
| def _find_manifest(input_dir): | |
| manifests = sorted(input_dir.glob("*.manifest")) | |
| if len(manifests) == 1: | |
| return manifests[0] | |
| if len(manifests) > 1: | |
| names = ", ".join(path.name for path in manifests) | |
| raise ValueError(f"multiple manifest files found: {names}") | |
| legacy = input_dir / "font_manifest.json" | |
| if legacy.exists(): | |
| return legacy | |
| raise FileNotFoundError(f"no .manifest file found in {input_dir}") | |
| class BitReader: | |
| def __init__(self, data): | |
| self.data = data | |
| self.offset = 0 | |
| self.bits_left = 0 | |
| self.current = 0 | |
| def bit(self): | |
| self.bits_left -= 1 | |
| if self.bits_left < 0: | |
| if self.offset >= len(self.data): | |
| raise EOFError("compressed stream ended early") | |
| self.current = self.data[self.offset] | |
| self.offset += 1 | |
| self.bits_left = 7 | |
| return (self.current >> 7) & 1 | |
| return (self.current >> self.bits_left) & 1 | |
| def bits(self, count): | |
| value = 0 | |
| for _ in range(count): | |
| value = (value << 1) | self.bit() | |
| return value | |
| class BitWriter: | |
| def __init__(self): | |
| self.out = bytearray() | |
| self.current = 0 | |
| self.count = 0 | |
| def bit(self, value): | |
| self.current = (self.current << 1) | (value & 1) | |
| self.count += 1 | |
| if self.count == 8: | |
| self.out.append(self.current) | |
| self.current = 0 | |
| self.count = 0 | |
| def bits(self, value, count): | |
| for shift in range(count - 1, -1, -1): | |
| self.bit((value >> shift) & 1) | |
| def finish(self): | |
| if self.count: | |
| self.out.append(self.current << (8 - self.count)) | |
| self.current = 0 | |
| self.count = 0 | |
| return bytes(self.out) | |
| def lzss_decompress(payload, output_size): | |
| reader = BitReader(payload) | |
| window = bytearray(WINDOW_SIZE + MAX_MATCH) | |
| write_pos = WINDOW_START | |
| out = bytearray() | |
| while len(out) < output_size: | |
| if reader.bit() == 0: | |
| value = reader.bits(8) | |
| out.append(value) | |
| window[write_pos] = value | |
| if write_pos < MAX_MATCH: | |
| window[write_pos + WINDOW_SIZE] = value | |
| write_pos = (write_pos + 1) & (WINDOW_SIZE - 1) | |
| else: | |
| copy_pos = reader.bits(12) | |
| length = reader.bits(4) + MIN_MATCH | |
| for _ in range(length): | |
| value = window[copy_pos] | |
| if len(out) < output_size: | |
| out.append(value) | |
| window[write_pos] = value | |
| if write_pos < MAX_MATCH: | |
| window[write_pos + WINDOW_SIZE] = value | |
| copy_pos = (copy_pos + 1) & (WINDOW_SIZE - 1) | |
| write_pos = (write_pos + 1) & (WINDOW_SIZE - 1) | |
| return bytes(out), reader.offset | |
| def _match_len(data, pos, prev): | |
| limit = min(MAX_MATCH, len(data) - pos) | |
| length = 0 | |
| while length < limit and data[prev + length] == data[pos + length]: | |
| length += 1 | |
| return length | |
| def lzss_compress(data): | |
| nil = WINDOW_SIZE | |
| text = bytearray(WINDOW_SIZE + MAX_MATCH - 1) | |
| left = [nil] * (WINDOW_SIZE + 1) | |
| right = [nil] * (WINDOW_SIZE + 257) | |
| parent = [nil] * (WINDOW_SIZE + 1) | |
| match_pos = 0 | |
| match_len = 0 | |
| for index in range(WINDOW_SIZE + 1, WINDOW_SIZE + 257): | |
| right[index] = nil | |
| for index in range(WINDOW_SIZE): | |
| parent[index] = nil | |
| def insert_node(node): | |
| nonlocal match_pos, match_len | |
| cmp = 1 | |
| root = WINDOW_SIZE + 1 + text[node] | |
| right[node] = nil | |
| left[node] = nil | |
| match_len = 0 | |
| while True: | |
| if cmp >= 0: | |
| if right[root] != nil: | |
| root = right[root] | |
| else: | |
| right[root] = node | |
| parent[node] = root | |
| return | |
| else: | |
| if left[root] != nil: | |
| root = left[root] | |
| else: | |
| left[root] = node | |
| parent[node] = root | |
| return | |
| length = 1 | |
| while length < MAX_MATCH and text[node + length] == text[root + length]: | |
| length += 1 | |
| if length > match_len: | |
| match_pos = root | |
| match_len = length | |
| if length >= MAX_MATCH: | |
| break | |
| if length >= MAX_MATCH: | |
| cmp = 0 | |
| else: | |
| cmp = text[node + length] - text[root + length] | |
| parent[node] = parent[root] | |
| left[node] = left[root] | |
| right[node] = right[root] | |
| parent[left[root]] = node | |
| parent[right[root]] = node | |
| if right[parent[root]] == root: | |
| right[parent[root]] = node | |
| else: | |
| left[parent[root]] = node | |
| parent[root] = nil | |
| def delete_node(node): | |
| if parent[node] == nil: | |
| return | |
| if right[node] == nil: | |
| replacement = left[node] | |
| elif left[node] == nil: | |
| replacement = right[node] | |
| else: | |
| replacement = right[node] | |
| if left[replacement] != nil: | |
| while left[replacement] != nil: | |
| replacement = left[replacement] | |
| left[parent[replacement]] = right[replacement] | |
| parent[right[replacement]] = parent[replacement] | |
| right[replacement] = right[node] | |
| parent[right[node]] = replacement | |
| left[replacement] = left[node] | |
| parent[left[node]] = replacement | |
| parent[replacement] = parent[node] | |
| if right[parent[node]] == node: | |
| right[parent[node]] = replacement | |
| else: | |
| left[parent[node]] = replacement | |
| parent[node] = nil | |
| writer = BitWriter() | |
| read_pos = 0 | |
| lookahead = 0 | |
| slide_pos = 0 | |
| ring_pos = WINDOW_START | |
| while lookahead < MAX_MATCH and read_pos < len(data): | |
| text[ring_pos + lookahead] = data[read_pos] | |
| read_pos += 1 | |
| lookahead += 1 | |
| if lookahead: | |
| insert_node(ring_pos) | |
| while lookahead > 0: | |
| length = match_len | |
| if length <= MIN_MATCH - 1: | |
| length = 1 | |
| writer.bit(0) | |
| writer.bits(text[ring_pos], 8) | |
| else: | |
| writer.bit(1) | |
| writer.bits(match_pos, 12) | |
| writer.bits(length - MIN_MATCH, 4) | |
| emitted = length | |
| advanced = 0 | |
| while advanced < emitted and read_pos < len(data): | |
| delete_node(slide_pos) | |
| value = data[read_pos] | |
| read_pos += 1 | |
| text[slide_pos] = value | |
| if slide_pos < MAX_MATCH - 1: | |
| text[slide_pos + WINDOW_SIZE] = value | |
| slide_pos = (slide_pos + 1) & (WINDOW_SIZE - 1) | |
| ring_pos = (ring_pos + 1) & (WINDOW_SIZE - 1) | |
| insert_node(ring_pos) | |
| advanced += 1 | |
| while advanced < emitted: | |
| delete_node(slide_pos) | |
| slide_pos = (slide_pos + 1) & (WINDOW_SIZE - 1) | |
| ring_pos = (ring_pos + 1) & (WINDOW_SIZE - 1) | |
| lookahead -= 1 | |
| if lookahead: | |
| insert_node(ring_pos) | |
| advanced += 1 | |
| return writer.finish() | |
| def parse_outer_fontpak(data): | |
| count = _read_u32le(data, 0) | |
| if count != 1: | |
| raise ValueError(f"expected one outer entry, got {count}") | |
| name_len = _read_u32le(data, 4) | |
| name = data[8:8 + name_len] | |
| pos = 8 + name_len | |
| compressed_size = _read_u32le(data, pos) | |
| decompressed_size = _read_u32le(data, pos + 4) | |
| payload = data[pos + 8:pos + 8 + compressed_size] | |
| return name, compressed_size, decompressed_size, payload | |
| def parse_inner_fontpak(data): | |
| count = _read_u32be(data, 0) | |
| entries = [] | |
| table_end = 4 + count * 0x28 | |
| for index in range(count): | |
| pos = 4 + index * 0x28 | |
| raw_name = data[pos:pos + 0x20] | |
| name = raw_name.split(b"\0", 1)[0].decode("ascii").strip() | |
| offset = _read_u32be(data, pos + 0x20) | |
| size = _read_u32be(data, pos + 0x24) | |
| if offset < table_end or offset + size > len(data): | |
| raise ValueError(f"bad inner entry {index}: {name}") | |
| entries.append({ | |
| "name": name, | |
| "offset": offset, | |
| "size": size, | |
| "raw_name_hex": raw_name.hex(), | |
| }) | |
| return entries | |
| def build_inner_fontpak(entries, source_dir): | |
| header_size = 4 + len(entries) * 0x28 | |
| current = header_size | |
| payloads = [] | |
| table = bytearray() | |
| table += len(entries).to_bytes(4, "big") | |
| for entry in entries: | |
| name = entry["name"] | |
| payload = (source_dir / name).read_bytes() | |
| if "raw_name_hex" in entry: | |
| raw_name = bytes.fromhex(entry["raw_name_hex"]) | |
| else: | |
| raw_name = name.encode("ascii").rjust(0x20, b" ") | |
| if len(raw_name) > 0x20: | |
| raise ValueError(f"inner name too long: {name}") | |
| table += raw_name | |
| table += current.to_bytes(4, "big") | |
| table += len(payload).to_bytes(4, "big") | |
| payloads.append(payload) | |
| current += len(payload) | |
| return bytes(table) + b"".join(payloads) | |
| def unpack_fontpak(input_path, output_dir): | |
| raw = input_path.read_bytes() | |
| outer_name, comp_size, out_size, payload = parse_outer_fontpak(raw) | |
| inner, consumed = lzss_decompress(payload, out_size) | |
| entries = parse_inner_fontpak(inner) | |
| inner_dump = _inner_dump_name(input_path) | |
| output_dir.mkdir(parents=True, exist_ok=True) | |
| (output_dir / inner_dump).write_bytes(inner) | |
| manifest = { | |
| "format": "srw_gc_pak_manifest_v2", | |
| "source_pak": input_path.name, | |
| "source_pak_path": str(input_path.resolve()), | |
| "source_pak_size": len(raw), | |
| "source_pak_sha256": _sha256(raw), | |
| "inner_dump": inner_dump, | |
| "inner_sha256": _sha256(inner), | |
| "outer_name": outer_name.decode("ascii"), | |
| "outer_compressed_size": comp_size, | |
| "outer_decompressed_size": out_size, | |
| "outer_lzss_consumed": consumed, | |
| "entries": entries, | |
| } | |
| (output_dir / _manifest_name(input_path)).write_text( | |
| json.dumps(manifest, indent=2), encoding="utf-8" | |
| ) | |
| for entry in entries: | |
| start = entry["offset"] | |
| end = start + entry["size"] | |
| (output_dir / entry["name"]).write_bytes(inner[start:end]) | |
| print(f"unpacked {len(entries)} entries to {output_dir}") | |
| def repack_fontpak(input_dir, output_path): | |
| manifest = json.loads(_find_manifest(input_dir).read_text(encoding="utf-8")) | |
| entries = manifest["entries"] | |
| outer_name = manifest.get("outer_name", "font.pak_").encode("ascii") | |
| inner = build_inner_fontpak(entries, input_dir) | |
| compressed = lzss_compress(inner) | |
| out = bytearray() | |
| out += (1).to_bytes(4, "little") | |
| out += len(outer_name).to_bytes(4, "little") | |
| out += outer_name | |
| out += len(compressed).to_bytes(4, "little") | |
| out += len(inner).to_bytes(4, "little") | |
| out += compressed | |
| output_path.write_bytes(out) | |
| print(f"repacked {len(entries)} entries to {output_path}") | |
| print(f"inner={len(inner)} compressed={len(compressed)} total={len(out)}") | |
| def main(): | |
| parser = argparse.ArgumentParser( | |
| description="Unpack/repack SRW GC one-entry LZSS PAK archives." | |
| ) | |
| sub = parser.add_subparsers(dest="command", required=True) | |
| unpack = sub.add_parser("unpack") | |
| unpack.add_argument("input", type=Path) | |
| unpack.add_argument("output_dir", type=Path) | |
| repack = sub.add_parser("repack") | |
| repack.add_argument("input_dir", type=Path) | |
| repack.add_argument("output", type=Path) | |
| args = parser.parse_args() | |
| if args.command == "unpack": | |
| unpack_fontpak(args.input, args.output_dir) | |
| else: | |
| repack_fontpak(args.input_dir, args.output) | |
| if __name__ == "__main__": | |
| main() |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
python .\srw_gc_fontpak.py unpack ".\font.pak" .\fontpak_unpackedpython .\srw_gc_fontpak.py repack .\fontpak_unpacked .\font.pak