singe/util/forgePorts.py
2026-09-22 18:32:07 -05:00

271 lines
15 KiB
Python

#!/usr/bin/env python3
# Assembles the Forge ports of the test library (its ported originals, singetest/ported) into a project folder of their own, one game to a
# folder, each a complete Singe game: the description, the files it plays with, the compiled
# port beside a copy of the runtime, and a games.dat so the menu lists it (FORGE.md section 14.8).
#
# python3 util/forgePorts.py [<library>] [<out>]
# python3 util/forgePorts.py ~/claude/singetest/ported ~/claude/singePorts
#
# SINGE the engine binary that compiles the descriptions (default: the newest build)
#
# A folder is named as the library names it (TimeGal, ChantzesStone with both of its games), and
# the five ActionMax games, which share one folder there, get one each. A Hypseus title
# (hypseus/<Title>/singe/<Title>/) is flattened to <Title>/ with its framefile and Video/ beside
# the scripts, and given the games.dat it never had. Nothing that belongs to the original
# implementation travels: not the KarisFramework copies (nor LINEA's MazescaterFramework), not
# the ActionMax emulator, and not the original games.dat. A game's own script does travel, since the Karis port plays it.
# Video is hard-linked rather than copied (the library's videos are not edited, and copying them
# would be some forty gigabytes); cp --remove-destination materialises one.
#
# The compiled port is <Stem>.port.singe, never the game's own name: a built script that shared
# the script's name would be loading the game inside itself.
import glob
import os
import re
import shutil
import subprocess
import sys
import tempfile
LIBRARY = os.path.expanduser(sys.argv[1] if len(sys.argv) > 1 else "~/claude/singetest/ported")
OUT = os.path.expanduser(sys.argv[2] if len(sys.argv) > 2 else "~/claude/singePorts")
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
LINKED = {".mkv", ".mp4", ".m4v", ".mpg", ".mpeg", ".avi", ".m2v", ".ogg"} # Video: hard-linked, not copied.
HYPSEUS = "hypseus"
ACTIONMAX = "ActionMax"
# What a Karis port never reads: the framework (the qte loop plays its part), the original
# games.dat, and the folders only the framework's own drawing opened -- the overlay pictures
# and fonts (the loop draws its own text), the launcher art in Assets/ (the menu takes its
# pictures from Menu/, which games.dat names), and a Hypseus title's Structure/ (its framework).
NOT_KARIS = {"KarisFramework", "MazescaterFramework", "games.dat", "Assets", "Structure"}
def engine():
given = os.environ.get("SINGE")
if given:
return os.path.abspath(given)
found = sorted(glob.glob(os.path.join(REPO, ".builddir", "linux-gcc", "*", "singe", "Singe-v*")) + glob.glob(os.path.join(REPO, ".builddir", "linux", "*", "singe", "Singe-v*")), key=os.path.getmtime)
if not found:
sys.exit("forgePorts: no engine binary; set SINGE")
return found[-1]
def entries(datPath):
# The GAMES entries of a games.dat, each as its text, by the script it names.
text = open(datPath).read()
found = {}
depth = 0
start = None
for at, char in enumerate(text):
if char == "{":
depth += 1
if depth == 2:
start = at
elif char == "}":
if depth == 2 and start is not None:
entry = text[start:at + 1]
script = re.search(r'SCRIPT\s*=\s*"([^"]*)"', entry)
if script:
found[os.path.basename(script.group(1))] = entry
depth -= 1
return found
def framefileVideos(path):
# The files a framefile names, relative to its own directory: its first line is the
# directory the videos are in (".", "Video/"), the rest "frame name" lines. A video's
# audio track sits beside it with the same stem and an .ogg or .wav suffix.
with open(path, errors="replace") as handle:
lines = [line.strip() for line in handle]
if not lines:
return []
base = lines[0].replace("\\", "/").rstrip("/")
videos = []
for line in lines[1:]:
parts = line.split()
if len(parts) >= 2 and re.match(r"^\d+$", parts[0]):
name = parts[1] if base in ("", ".") else os.path.join(base, parts[1])
videos.append(name)
stem = os.path.splitext(name)[0]
for suffix in (".ogg", ".wav"):
if os.path.exists(os.path.join(os.path.dirname(path), stem + suffix)):
videos.append(stem + suffix)
return videos
def isFramefile(path):
# A framefile opens with the directory its videos are in, then lines of frame and file.
with open(path, errors="replace") as handle:
first = handle.readline().strip()
rest = handle.read()
return (first == "." or first.endswith(("/", "\\"))) and re.search(r"^\d+\s+\S+\.(m2v|mkv|mp4|mpg)", rest, re.M) is not None
def luaString(text):
return '"' + text.replace("\\", "\\\\").replace('"', '\\"') + '"'
def place(source, target):
# A file into the project: video by hard link, the rest by copy.
os.makedirs(os.path.dirname(target), exist_ok=True)
if os.path.exists(target):
os.remove(target)
if os.path.splitext(source)[1].lower() in LINKED:
try:
os.link(source, target)
return
except OSError:
pass
shutil.copy2(source, target)
def placeTree(source, target, skip, skipExtensions=()):
# A symbolic link to a directory is not followed: a flat Hypseus title links singe/<Title>
# back to itself.
for name in sorted(os.listdir(source)):
if name in skip or name.endswith(skipExtensions):
continue
path = os.path.join(source, name)
if os.path.isdir(path) and not os.path.islink(path):
placeTree(path, os.path.join(target, name), set())
elif not os.path.isdir(path):
place(path, os.path.join(target, name))
def portEntry(entry, folder, stem, fromFolder):
# The original's entry, made the port's: the compiled port as its script, paths under the
# new folder, and the engine's own sprite calls rather than the legacy ones.
entry = entry.replace('"%s/' % fromFolder, '"%s/' % folder)
entry = re.sub(r'(SCRIPT\s*=\s*)"[^"]*"', r'\1"%s/%s.port.singe"' % (folder, stem), entry)
entry = re.sub(r'(DATA\s*=\s*)"[^"]*"', r'\1"%s"' % folder, entry)
entry = re.sub(r'(LEGACY_SPRITE_ARGS\s*=\s*)true', r'\1false', entry)
return entry
def writeDat(folder, ports):
with open(os.path.join(OUT, folder, "games.dat"), "w") as dat:
dat.write("-- Written by util/forgePorts.py, from the original's games.dat where it had one; the scripts are the Forge ports.\n")
dat.write("GAMES = {\n")
dat.write(",\n".join("\t" + entry.strip() for entry in ports))
dat.write("\n}\n")
def main():
builds = [] # (description, compiled port)
# The ActionMax games: one folder each, with what the game and the family share.
source = os.path.join(LIBRARY, ACTIONMAX)
shared = [name for name in os.listdir(source) if re.match(r"^(font|sound|sprite|video|marquee)_", name) or name == "Vocabulary.singe"]
games = sorted(os.path.splitext(os.path.basename(path))[0] for path in glob.glob(os.path.join(source, "*.forge")))
dat = entries(os.path.join(source, "games.dat"))
for game in games:
folder = os.path.join(OUT, game)
shutil.rmtree(folder, ignore_errors=True) # Built afresh every time.
for name in shared:
own = re.match(r"^[a-z]+_(.+)\.\w+$", name)
if own and own.group(1) in games and own.group(1) != game:
continue
place(os.path.join(source, name), os.path.join(folder, name))
for name in [game + ".forge", "frame_%s.txt" % game, "cabinet_%s.png" % game]:
place(os.path.join(source, name), os.path.join(folder, name))
writeDat(game, [portEntry(dat[game + ".singe"], game, game, ACTIONMAX)])
builds.append((os.path.join(folder, game + ".forge"), os.path.join(folder, game + ".port.singe")))
print("assembled %s" % game)
# The KarisFramework games: the folder as the library has it, less the framework. A
# description under a Script/ folder (the map-mode games kept as Script, Cfg, Overlay,
# Fonts, and Sounds) takes the whole game folder, since the game reads all of it.
for path in sorted(glob.glob(os.path.join(LIBRARY, "*", "*.forge")) + glob.glob(os.path.join(LIBRARY, "*", "Script", "*.forge"))):
inner = "Script" if os.path.basename(os.path.dirname(path)) == "Script" else ""
name = os.path.basename(os.path.dirname(os.path.dirname(path)) if inner else os.path.dirname(path))
if name in (ACTIONMAX, HYPSEUS):
continue
folder = os.path.join(OUT, name)
stems = sorted(os.path.splitext(os.path.basename(desc))[0] for desc in glob.glob(os.path.join(os.path.dirname(path), "*.forge")))
first = (os.path.basename(path) == stems[0] + ".forge")
if first:
shutil.rmtree(folder, ignore_errors=True) # Built afresh every time.
# A hand-written title (one with a vocabulary beside its description) loads its own
# fonts, which a framework title's port never does.
leave = set(NOT_KARIS) - ({"Fonts"} if os.path.isfile(os.path.join(os.path.dirname(path), "Vocabulary.singe")) else set())
placeTree(os.path.join(LIBRARY, name), folder, {"games.dat"} if inner else leave)
if os.path.isfile(os.path.join(LIBRARY, name, "games.dat")):
dat = entries(os.path.join(LIBRARY, name, "games.dat"))
else:
# A game that never had a games.dat (the library's American Laser Games titles):
# its launcher names the video and the script.
dat = {}
for launcher in glob.glob(os.path.join(LIBRARY, "*.sh")):
for video, script in re.findall(r'-v\s+(\S+)\s+(%s/\S+\.singe)' % re.escape(name), open(launcher).read()):
stem = os.path.splitext(os.path.basename(script))[0]
if stem + ".singe" not in dat:
dat[stem + ".singe"] = '{\n\t\tTITLE = "%s",\n\t\tSCRIPT = "%s",\n\t\tVIDEO = "%s",\n\t\tDATA = "%s"\n\t}' % (name, script, video, name)
# A title without a launcher of its own (the library starts SpacePiratesHD from
# SpacePirates.sh and never the other): the one video under its Video/.
for stem in stems:
videos = glob.glob(os.path.join(LIBRARY, name, "Video", "*.mp4"))
if (stem + ".singe" not in dat) and (len(videos) == 1):
dat[stem + ".singe"] = '{\n\t\tTITLE = "%s",\n\t\tSCRIPT = "%s/Script/%s.singe",\n\t\tVIDEO = "%s/Video/%s",\n\t\tDATA = "%s"\n\t}' % (name, name, stem, name, os.path.basename(videos[0]), name)
if first:
writeDat(name, [portEntry(dat[stem + ".singe"], name, (inner + "/" + stem) if inner else stem, name) for stem in stems if stem + ".singe" in dat])
print("assembled %s (%s)" % (name, ", ".join(stems)))
builds.append((os.path.join(folder, inner, os.path.basename(path)), os.path.join(folder, inner, os.path.splitext(os.path.basename(path))[0] + ".port.singe")))
# The Hypseus titles: the game's folder flattened, the framefile and its Video/ beside it.
for path in sorted(glob.glob(os.path.join(LIBRARY, HYPSEUS, "*", "singe", "*", "*.forge"))):
name = os.path.basename(os.path.dirname(path))
title = os.path.basename(os.path.dirname(os.path.dirname(os.path.dirname(path))))
stem = os.path.splitext(os.path.basename(path))[0]
folder = os.path.join(OUT, title)
# A flat title links singe/<Title> back to itself: its files are the title's own folder,
# less what Hypseus put there (the launcher, its data, the archive, and the links).
flat = os.path.islink(os.path.dirname(path))
source = os.path.realpath(os.path.dirname(path))
shutil.rmtree(folder, ignore_errors=True)
# A hand-written title (one with a vocabulary beside its description) loads its own
# fonts, which a framework title's port never does.
leave = set(NOT_KARIS) - ({"Fonts"} if os.path.isfile(os.path.join(os.path.dirname(path), "Vocabulary.singe")) else set())
placeTree(source, folder, leave | ({"singe", "Singe", "data", "Menu.sh", "MANIFEST.md"} if flat else set()), (".zip",) if flat else ())
# The framefile at the top of the title (with exactly the videos it names, loose beside
# it or under Video/), or the one beside the script, already copied with the game.
frame = None
for candidate in glob.glob(os.path.join(LIBRARY, HYPSEUS, title, "*.txt")):
if isFramefile(candidate):
place(candidate, os.path.join(folder, os.path.basename(candidate)))
for video in framefileVideos(candidate):
if os.path.isfile(os.path.join(LIBRARY, HYPSEUS, title, video)):
place(os.path.join(LIBRARY, HYPSEUS, title, video), os.path.join(folder, video))
frame = os.path.basename(candidate)
if frame is None and os.path.isfile(os.path.join(folder, stem + ".txt")):
frame = stem + ".txt"
for video in framefileVideos(os.path.join(folder, stem + ".txt")):
source = os.path.join(os.path.dirname(path), video)
if os.path.isfile(source) and not os.path.exists(os.path.join(folder, video)):
place(source, os.path.join(folder, video))
shown = re.search(r'singeSetGameName\("([^"]*)"\)', open(os.path.join(os.path.dirname(path), stem + ".singe"), errors="replace").read())
writeDat(title, ['{\n\t\tTITLE = "%s",\n\t\tSCRIPT = "%s/%s.port.singe",\n\t\tVIDEO = "%s/%s",\n\t\tDATA = "%s"\n\t}' % (shown.group(1) if shown else title, title, stem, title, frame or (title + ".txt"), title)])
builds.append((os.path.join(folder, stem + ".forge"), os.path.join(folder, stem + ".port.singe")))
print("assembled %s (Hypseus)" % title)
# The compiler is Forge's, so the engine runs it: every description built into its folder,
# from a directory with Forge beside Singe, never the repository.
run = tempfile.mkdtemp(prefix="forgePorts.")
shutil.copytree(os.path.join(REPO, "assets", "Forge"), os.path.join(run, "Forge"))
with open(os.path.join(run, "build.singe"), "w") as script:
script.write('dofile("Forge/AuthorCompile.singe")\n')
for description, port in builds:
script.write('if authorBuild(%s, %s) == nil then debugPrint("PORTS FAIL %s") end\n' % (luaString(description), luaString(port), os.path.basename(port)))
script.write('singeQuit()\n')
log = subprocess.run([engine(), "-w", "-p", "-k", "-d", os.path.join(run, "data"), os.path.join(run, "build.singe")], cwd=run, env=dict(os.environ, SDL_VIDEODRIVER=os.environ.get("SDL_VIDEODRIVER", "offscreen"), SDL_AUDIO_DRIVER="dummy"), capture_output=True, text=True, timeout=600)
problems = [line for line in (log.stdout + log.stderr).splitlines() if "FAIL" in line or "rror" in line or "Author:" in line]
for line in problems:
print(line)
missing = [port for _, port in builds if not os.path.exists(port)]
shutil.rmtree(run, ignore_errors=True)
if missing or problems:
sys.exit("forgePorts: %d of %d ports did not build" % (len(missing), len(builds)))
print("built %d ports into %s" % (len(builds), OUT))
main()