mirror of
https://github.com/ElHombreAlex/FTL-Faster-Than-Light-VR.git
synced 2026-10-06 01:00:06 +02:00
215 lines
9.6 KiB
Python
215 lines
9.6 KiB
Python
"""Read a user's FTL PKG and write local-only data for the tabletop prototype.
|
|
|
|
This intentionally does not patch the game or copy game data into the repository.
|
|
Works with a Steam or GOG ftl.dat that uses the 1.6.x PKG layout.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import struct
|
|
import xml.etree.ElementTree as ET
|
|
from pathlib import Path
|
|
|
|
|
|
class Pkg:
|
|
def __init__(self, path: Path):
|
|
self.path = path
|
|
self.entries: dict[str, tuple[int, int, int, int]] = {}
|
|
with path.open("rb") as source:
|
|
header = source.read(16)
|
|
if len(header) != 16 or header[:4] != b"PKG\n":
|
|
raise ValueError("Not an FTL PKG archive")
|
|
header_size, entry_size, count, names_size = struct.unpack(">HHII", header[4:])
|
|
if header_size != 16 or entry_size != 20 or count > 100_000:
|
|
raise ValueError("Unsupported FTL PKG index")
|
|
records = []
|
|
for _ in range(count):
|
|
record = source.read(20)
|
|
if len(record) != 20:
|
|
raise ValueError("Truncated FTL PKG index")
|
|
_hash = int.from_bytes(record[0:4], "big")
|
|
flags = record[4]
|
|
name_offset = int.from_bytes(record[5:8], "big")
|
|
offset, size, raw_size = struct.unpack(">III", record[8:20])
|
|
records.append((flags, name_offset, offset, size, raw_size))
|
|
names = source.read(names_size)
|
|
source.seek(0, 2)
|
|
archive_size = source.tell()
|
|
for flags, name_offset, offset, size, raw_size in records:
|
|
if name_offset >= len(names) or offset + size > archive_size:
|
|
raise ValueError("FTL PKG index points outside archive")
|
|
end = names.find(b"\x00", name_offset)
|
|
if end < 0:
|
|
raise ValueError("Unterminated FTL PKG path")
|
|
name = names[name_offset:end].decode("ascii")
|
|
self.entries[name] = (flags, offset, size, raw_size)
|
|
|
|
def read(self, name: str) -> bytes:
|
|
flags, offset, size, raw_size = self.entries[name]
|
|
if flags != 0 or raw_size != size:
|
|
raise ValueError(f"Compressed or unsupported PKG entry: {name}")
|
|
with self.path.open("rb") as source:
|
|
source.seek(offset)
|
|
data = source.read(size)
|
|
if len(data) != size:
|
|
raise ValueError(f"Truncated PKG entry: {name}")
|
|
return data
|
|
|
|
|
|
def parse_layout(raw: bytes) -> dict:
|
|
tokens = raw.decode("utf-8-sig").split()
|
|
counts = {"X_OFFSET": 1, "Y_OFFSET": 1, "VERTICAL": 1, "ELLIPSE": 4,
|
|
"ROOM": 5, "DOOR": 5}
|
|
layout: dict = {"rooms": [], "doors": []}
|
|
cursor = 0
|
|
while cursor < len(tokens):
|
|
tag = tokens[cursor].upper()
|
|
cursor += 1
|
|
if tag not in counts:
|
|
raise ValueError(f"Unknown layout token: {tag}")
|
|
count = counts[tag]
|
|
if cursor + count > len(tokens):
|
|
raise ValueError(f"Truncated {tag} record")
|
|
values = [int(value) for value in tokens[cursor:cursor + count]]
|
|
cursor += count
|
|
if tag == "ROOM":
|
|
room_id, x, y, w, h = values
|
|
layout["rooms"].append({"id": room_id, "x": x, "y": y, "w": w, "h": h})
|
|
elif tag == "DOOR":
|
|
x, y, a, b, vertical = values
|
|
layout["doors"].append({"x": x, "y": y, "a": a, "b": b,
|
|
"vertical": bool(vertical)})
|
|
else:
|
|
layout[tag.lower()] = values[0] if count == 1 else values
|
|
if not layout["rooms"]:
|
|
raise ValueError("Layout contains no rooms")
|
|
return layout
|
|
|
|
|
|
def system_rooms(blueprints: bytes, layout_name: str) -> tuple[str, dict[str, str]]:
|
|
root = ET.fromstring(blueprints)
|
|
candidates = [node for node in root.findall(".//shipBlueprint")
|
|
if node.get("layout") == layout_name]
|
|
if not candidates:
|
|
return "", {}
|
|
preferred = "PLAYER_SHIP_HARD" if layout_name == "kestral" else ""
|
|
blueprint = next((node for node in candidates if node.get("name") == preferred), candidates[0])
|
|
systems = {}
|
|
for node in blueprint.findall("./systemList/*"):
|
|
if node.get("start", "true") == "false":
|
|
continue
|
|
room = node.get("room")
|
|
if room is not None:
|
|
systems[room] = node.tag
|
|
return blueprint.get("name", ""), systems
|
|
|
|
|
|
def png_size(raw: bytes) -> tuple[int, int]:
|
|
if len(raw) < 24 or raw[:8] != b"\x89PNG\r\n\x1a\n" or raw[12:16] != b"IHDR":
|
|
raise ValueError("Invalid ship PNG header")
|
|
return struct.unpack(">II", raw[16:24])
|
|
|
|
|
|
UI_FONTS = ("c&c.font", "JustinFont10.font", "JustinFont11Bold.font")
|
|
|
|
|
|
def extract_fonts(pkg: Pkg, destination: Path) -> list[str]:
|
|
"""Keep original SIL bitmap fonts local; the runtime decodes their atlas.
|
|
|
|
No font converter, system font installation or Godot import is required.
|
|
"""
|
|
written = []
|
|
for name in UI_FONTS:
|
|
source = f"fonts/{name}"
|
|
if source not in pkg.entries:
|
|
continue
|
|
raw = pkg.read(source)
|
|
if len(raw) < 24 or raw[:5] != b"FONT\x01":
|
|
raise ValueError(f"Unsupported native bitmap font: {source}")
|
|
path = destination / source
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
# SIL .font is not Godot's BMFont .font. Load it via our runtime decoder
|
|
# and keep the editor from trying to import it as an unrelated format.
|
|
(path.parent / ".gdignore").write_text("", encoding="utf-8")
|
|
path.write_bytes(raw)
|
|
written.append(source)
|
|
return written
|
|
|
|
|
|
def build_ship(pkg: Pkg, name: str, destination: Path, image_name: str | None = None,
|
|
*, native_image_rect: dict | None = None) -> dict:
|
|
import re
|
|
image_name = image_name or name
|
|
if not all(re.fullmatch(r'[A-Za-z0-9_-]+', part) for part in (name, image_name)):
|
|
raise ValueError('Invalid ship asset identifier')
|
|
key = name if image_name == name else f'{name}__{image_name}'
|
|
layout = parse_layout(pkg.read(f"data/{name}.txt"))
|
|
root = ET.fromstring(pkg.read(f"data/{name}.xml"))
|
|
image_node = root.find("img")
|
|
if image_node is None:
|
|
raise ValueError(f"No ship image rectangle for {name}")
|
|
layout["name"] = name
|
|
layout["image_rect"] = {key: int(image_node.attrib[key]) for key in ("x", "y", "w", "h")}
|
|
if native_image_rect:
|
|
rectangle = {key: int(native_image_rect[key]) for key in ("x", "y", "w", "h")}
|
|
if not (0 < rectangle["w"] <= 8192 and 0 < rectangle["h"] <= 8192):
|
|
raise ValueError("Invalid native ship image dimensions")
|
|
layout["image_rect"] = rectangle
|
|
layout["image_rect_source"] = "native_ship_image"
|
|
layout["blueprint"], layout["systems"] = system_rooms(pkg.read("data/blueprints.xml"), name)
|
|
layout["weapon_mounts"] = [dict(node.attrib) for node in root.findall("./weaponMounts/mount")]
|
|
candidates = [f"img/ship/{image_name}_base.png",
|
|
f"img/ships_noglow/{image_name}_base.png",
|
|
f"img/ships_glow/{image_name}_base.png"]
|
|
available = [(path, pkg.read(path)) for path in candidates if path in pkg.entries]
|
|
if not available:
|
|
raise ValueError(f"No base image for {name}")
|
|
# Glow and noglow sprites often have different transparent borders. Match
|
|
# the dimensions FTL actually renders instead of stretching a cropped PNG
|
|
# over the larger XML rectangle and extruding a second, smaller silhouette.
|
|
rectangle = layout["image_rect"]
|
|
expected_size = (rectangle["w"], rectangle["h"])
|
|
image_path, image_bytes = next((entry for entry in available if png_size(entry[1]) == expected_size), available[0])
|
|
layout["texture_size"] = list(png_size(image_bytes))
|
|
layout["image_source_path"] = image_path
|
|
destination.mkdir(parents=True, exist_ok=True)
|
|
(destination / f"{key}.png").write_bytes(image_bytes)
|
|
(destination / f"{key}.json").write_text(json.dumps(layout, indent=2), encoding="utf-8")
|
|
return {"name": name, "rooms": len(layout["rooms"]), "doors": len(layout["doors"]),
|
|
"blueprint": layout["blueprint"],
|
|
"image_path": image_path}
|
|
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("archive", type=Path, help="Path to your Steam or GOG ftl.dat")
|
|
parser.add_argument("--output", type=Path,
|
|
default=Path(__file__).resolve().parents[1] / "local_game_data")
|
|
parser.add_argument("--ship", action="append", dest="ships",
|
|
help="Ship layout name; repeat for more ships")
|
|
args = parser.parse_args()
|
|
pkg = Pkg(args.archive)
|
|
ships = args.ships or ["kestral", "rebel_long"]
|
|
manifest = {"source": str(args.archive), "ships": []}
|
|
for ship in ships:
|
|
manifest["ships"].append(build_ship(pkg, ship, args.output))
|
|
ui_paths = [name for name in pkg.entries if name.endswith(".png") and (
|
|
name.startswith(("img/statusUI/", "img/systemUI/", "img/icons/", "img/combatUI/"))
|
|
or name in {"img/box_subsystems4.png", "img/box_weapons_bottom4.png",
|
|
"img/box_weapons_bottom_label.png", "img/box_weapons_autofire_base.png",
|
|
"img/Text_pause1.png", "img/Text_pause2.png"})]
|
|
for name in ui_paths:
|
|
destination = args.output / "ui" / name
|
|
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
destination.write_bytes(pkg.read(name))
|
|
manifest["ui_assets"] = len(ui_paths)
|
|
manifest["fonts"] = extract_fonts(pkg, args.output)
|
|
(args.output / "manifest.json").write_text(json.dumps(manifest, indent=2), encoding="utf-8")
|
|
print(json.dumps(manifest, indent=2))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|