Skip to content

Instantly share code, notes, and snippets.

@snowyegret23
Last active May 16, 2026 03:58
Show Gist options
  • Select an option

  • Save snowyegret23/d1d5576f84eb2e18468dc59a38f0d1fa to your computer and use it in GitHub Desktop.

Select an option

Save snowyegret23/d1d5576f84eb2e18468dc59a38f0d1fa to your computer and use it in GitHub Desktop.
SRW_GC_FONT
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()
@snowyegret23

Copy link
Copy Markdown
Author

python .\srw_gc_fontpak.py unpack ".\font.pak" .\fontpak_unpacked
python .\srw_gc_fontpak.py repack .\fontpak_unpacked .\font.pak

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment