#!/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 [] [] # 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//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()