Engine changes vs the vendored upstream (full ledger in docs/upstream-patches.md): - Assets: replace the encrypted FILE000N.GLB archives with a catalog of pre-decrypted loose files (data/INDEX); GLB_SlotItems() drives mission-pack / registered-version detection; payloads never run the GLB cipher at runtime. - Platform: Linux/SDL2/ALSA only; Android, MSVC/MinGW, macOS packaging, bundled Windows SDL2 SDKs and the ENDOOM exit screen are gone; EXIT_Error now prints before exiting. - Rendering: decouple presentation from simulation -- sim stays at the original 70/3 Hz, frames render interpolated at display rate (player, map scroll, enemies, shots, bonuses, anims, shadows); menus keep 70 Hz pacing; vsync with a delay-guard fallback. - QoL: borderless window mode (Video/borderless), no menu cursor snapping onto default buttons. Fix four regressions from the render split: - Shield bars never redrew: RAP_ThinkStats latches g_oldshield each sim step before RAP_DisplayStats' dirty check could fire. Bars now draw unconditionally per rendered frame; g_oldsuper deleted, g_oldshield kept as sim-side damage-detection state only. - Dying ships despawned without exploding: the render loop never called ANIMS_DisplayHigh(); most air-death explosions register HIGH_AIR. Restored between the player and enemy shots, as upstream orders it. - Laser turret broke: reusing doneflag as a draw marker (it is hit/retire game state, and the turret retire path never frees its shot) hid the beam on hitting ticks and leaked one SHOTS pool slot per fire until no weapon could shoot. S_LINE tracers now carry an age counter: one Think pass, every render of their birth step, purged at the top of the next Think. - Muzzle flash was cleared per rendered frame, making it invisible at display refresh rates; now cleared once per simulation step.
196 lines
6.4 KiB
Python
Executable File
196 lines
6.4 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Extract Raptor: Call of the Shadows .GLB archives into a modern asset tree.
|
|
|
|
Source format (reverse-engineered from the GLib library shipped with the
|
|
game, see raptor/src/glbapi.cpp):
|
|
|
|
- A GLB file is a sequence of 28-byte records:
|
|
uint32 opt 0 = plain, 1 = encrypted
|
|
uint32 offset payload offset (header record: item count)
|
|
uint32 filesize payload size (header record: unused)
|
|
char name[16] NUL-padded item name
|
|
- Record 0 is the header; its `offset` field holds the item count.
|
|
- Records 1..count are the item descriptor table, starting at byte 28.
|
|
- Every record and every encrypted payload is scrambled with the same
|
|
rolling cipher keyed on "32768GLB", seeded at 25 (SEED in glbapi.h).
|
|
|
|
Output layout (OUTDIR, default ./data):
|
|
|
|
INDEX catalog: "<hex asset id> <size> <relative path>"
|
|
music/ *_MUS
|
|
sound/ *_FX, *_SFX
|
|
sprites/ *_PIC, *_AGX
|
|
tiles/ *_BLK, *TILES
|
|
maps/ *_MAP
|
|
fonts/ *_FNT
|
|
text/ *_TXT, *_DIZ, *_DIX, *_ERR, *_HELP
|
|
ui/ *_SWD (window layouts)
|
|
movies/ *_MDS
|
|
demos/ *_REC
|
|
misc/ everything else
|
|
|
|
Asset ids are the engine handles: (archive slot << 16) | item index, so
|
|
FILE0001 item 5 is 0x10005. The INDEX file is sorted by id and is what the
|
|
engine loads at startup (GLB_LoadCatalog in glbapi.cpp).
|
|
|
|
Usage:
|
|
glbextract.py list FILE0000.GLB [...]
|
|
glbextract.py extract -o OUTDIR FILE0000.GLB [...]
|
|
"""
|
|
|
|
import argparse
|
|
import re
|
|
import struct
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
KEY = b"32768GLB"
|
|
SEED = 25 # SEED in glbapi.h
|
|
RECORD = struct.Struct("<III16s") # opt, offset, filesize, name[16]
|
|
NORMAL, ENCODED = 0, 1
|
|
|
|
# name suffix -> category directory
|
|
CATEGORIES = [
|
|
("MUS", "music"),
|
|
("SFX", "sound"), ("FX", "sound"),
|
|
("PIC", "sprites"), ("AGX", "sprites"),
|
|
("BLK", "tiles"), ("TILES", "tiles"),
|
|
("MAP", "maps"),
|
|
("FNT", "fonts"),
|
|
("TXT", "text"), ("DIZ", "text"), ("DIX", "text"),
|
|
("ERR", "text"), ("HELP", "text"),
|
|
("SWD", "ui"),
|
|
("MDS", "movies"),
|
|
("REC", "demos"),
|
|
]
|
|
|
|
|
|
def classify(name: str) -> str:
|
|
for suffix, cat in CATEGORIES:
|
|
if name.endswith("_" + suffix) or name.endswith(suffix):
|
|
return cat
|
|
return "misc"
|
|
|
|
|
|
def crypt(data: bytes, key: bytes = KEY) -> bytes:
|
|
"""GLB_DeCrypt (its inverse, GLB_EnCrypt, differs only by +/- sign).
|
|
|
|
Byte-exact despite the original's signed-char arithmetic: the stored
|
|
byte is the mathematical result mod 256 either way. The chain state is
|
|
the previous *ciphertext* byte, as in GLB_DeCrypt.
|
|
"""
|
|
out = bytearray(len(data))
|
|
kidx = SEED % len(key)
|
|
prev = key[kidx]
|
|
for i, b in enumerate(data):
|
|
out[i] = (b - key[kidx] - prev) % 256
|
|
prev = b
|
|
kidx = (kidx + 1) % len(key)
|
|
return bytes(out)
|
|
|
|
|
|
def read_entries(buf: bytes, source: str):
|
|
if len(buf) < RECORD.size:
|
|
sys.exit(f"{source}: too small to hold a GLB header")
|
|
|
|
_opt, count, _size, _name = RECORD.unpack(crypt(buf[: RECORD.size]))
|
|
entries = []
|
|
pos = RECORD.size
|
|
for _ in range(count):
|
|
if pos + RECORD.size > len(buf):
|
|
sys.exit(f"{source}: descriptor table truncated")
|
|
opt, off, size, name = RECORD.unpack(crypt(buf[pos : pos + RECORD.size]))
|
|
entries.append((opt, off, size, name.split(b"\0", 1)[0].decode("ascii", "replace")))
|
|
pos += RECORD.size
|
|
return entries
|
|
|
|
|
|
def sanitize(name: str) -> str:
|
|
clean = re.sub(r"[^A-Za-z0-9._$-]", "_", name)
|
|
return clean or "unnamed"
|
|
|
|
|
|
def extract(glb: Path, outdir: Path, seen: dict):
|
|
"""Write one archive's items into category dirs.
|
|
|
|
`seen` is shared across archives so identical names landing in the
|
|
same category get distinct suffixes (last-writer-wins would corrupt
|
|
the catalog sizes otherwise).
|
|
|
|
Returns catalog rows: (asset_id, size, relative path).
|
|
"""
|
|
slot = int(glb.stem[4:]) # FILE000N -> N
|
|
buf = glb.read_bytes()
|
|
entries = read_entries(buf, str(glb))
|
|
|
|
rows = []
|
|
for idx, (opt, off, size, name) in enumerate(entries):
|
|
data = buf[off : off + size]
|
|
if len(data) != size:
|
|
print(f" warning: {glb.name}[{idx}] {name}: "
|
|
f"payload {off}+{size} exceeds file size {len(buf)}", file=sys.stderr)
|
|
if opt == ENCODED:
|
|
data = crypt(data)
|
|
elif opt != NORMAL:
|
|
print(f" warning: {glb.name}[{idx}] {name}: unknown opt={opt}", file=sys.stderr)
|
|
|
|
cat = classify(name)
|
|
fname = sanitize(name)
|
|
key = f"{cat}/{fname}"
|
|
if key in seen:
|
|
seen[key] += 1
|
|
fname = f"{fname}.{seen[key]}"
|
|
else:
|
|
seen[key] = 0
|
|
(outdir / cat).mkdir(parents=True, exist_ok=True)
|
|
(outdir / cat / fname).write_bytes(data)
|
|
rows.append(((slot << 16) | idx, len(data), f"{cat}/{fname}"))
|
|
|
|
return rows
|
|
|
|
def listing(glb: Path) -> None:
|
|
entries = read_entries(glb.read_bytes(), str(glb))
|
|
print(f"{glb.name}: {len(entries)} items")
|
|
for idx, (opt, off, size, name) in enumerate(entries):
|
|
enc = " enc" if opt == ENCODED else ""
|
|
print(f" [{idx:4d}] {name:<16} {size:>10} bytes @ {off:#08x}{enc}")
|
|
|
|
|
|
def main() -> None:
|
|
ap = argparse.ArgumentParser(description=__doc__,
|
|
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
ap.add_argument("command", choices=["list", "extract"])
|
|
ap.add_argument("-o", "--out", default="data", help="asset root (default: ./data)")
|
|
ap.add_argument("files", nargs="+", type=Path, help=".GLB archives")
|
|
args = ap.parse_args()
|
|
|
|
outdir = None
|
|
if args.command == "extract":
|
|
outdir = Path(args.out)
|
|
outdir.mkdir(parents=True, exist_ok=True)
|
|
|
|
total = 0
|
|
rows = []
|
|
seen = {}
|
|
for glb in args.files:
|
|
if not glb.is_file():
|
|
sys.exit(f"{glb}: not found")
|
|
if args.command == "list":
|
|
listing(glb)
|
|
else:
|
|
r = extract(glb, outdir, seen)
|
|
rows.extend(r)
|
|
total += len(r)
|
|
print(f"{glb.name}: wrote {len(r)} items")
|
|
|
|
if rows:
|
|
rows.sort()
|
|
(outdir / "INDEX").write_text(
|
|
"".join(f"{uid:x} {size} {path}\n" for uid, size, path in rows),
|
|
encoding="ascii")
|
|
print(f"total: {total} items -> {outdir}/INDEX")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|