271 lines
15 KiB
Python
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()
|