Files

217 lines
9.7 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"}
or name in {f"img/misc/crosshairs_placed{slot}{variant}.png"
for slot in range(1,5) for variant in ("", "_yellow")})]
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()