forge/util/forgePortKaris.lua
2026-10-06 16:57:52 -05:00

1177 lines
43 KiB
Lua

-- Writes a Forge description for a KarisFramework game from the game's own script: the script is
-- Lua, so it is loaded with the engine stubbed out, its settings and tables read, and setupMoves
-- called for every level and scene. Nothing is transcribed by hand.
--
-- lua5.4 util/forgePortKaris.lua <game directory> <main script> [<name>]
-- lua5.4 util/forgePortKaris.lua ~/claude/singetest/ported/TimeGal Timegal.singe
-- lua5.4 util/forgePortKaris.lua ~/claude/singetest/ported/hypseus/Astroboy/singe/Astroboy Astroboy.singe
-- lua5.4 util/forgePortKaris.lua ~/claude/singetest/hypseus/starblazers/singe/starblazers starblazers.singe
--
-- The description goes beside the script as <name>.forge (the script's stem unless given). It is
-- data for the qte behaviour in Author.singe, which plays the framework's game loop.
--
-- The framework is found where the game keeps it: KarisFramework/Script beside the script (the
-- library's games), Structure or Script beside it, or Framework or FrameworkKimmy beside the
-- game's folder (the Hypseus zip-ROM layout, singe/<Title>/ and singe/Framework/). Its
-- version is written into the description, since 3.32b differs from 3.31c in a few things a
-- player can see (and MazescaterFramework beside the game, LINEA's Karis 3.31c with compound
-- moves, is marked as such there); a Kimmy Script Engine game gets the kimmy behaviour, and its saves and its
-- die-and-retry records are snapshotted too, since its new-game menu reads them. A game of
-- RDG's map-mode lineage (no framework folder: its globals.singe is beside its script and its
-- levels are functions of its main.singe) gets the rdg behaviour, which runs those functions
-- itself; its moves are not snapshotted, since the game draws some of them at random at play.
local dir = arg[1] or error("a game directory")
local main = arg[2] or error("the main script")
local name = arg[3] or main:gsub("%.singe$", "")
-- A game that draws at random as it loads (a scene swap, a level's variant) draws the same every
-- time, so a description made twice is made the same.
local CONVERT_SEED = 12345
math.randomseed(CONVERT_SEED)
-- ===== Loading the game's script ==============================================================
local env = setmetatable({}, { __index = _G })
local function nothing() return 0 end
for _, key in ipairs({ "soundLoad", "spriteLoad", "fontLoad", "fontSelect", "fontQuality", "colorForeground", "colorBackground", "discGetWidth", "discGetHeight", "overlaySetResolution", "discSetFPS", "debugPrint", "discSkipToFrame", "discPause", "discPlay", "singeSetGameName", "keyboardSetMode", "overlayClear", "singeGetDataPath", "spriteGetWidth", "spriteGetHeight", "setOverlaySize", "setOverlayResolution", "scoreBezelEnable", "scoreBezelGetState", "discAudioSuffix", "singeSetQuitKeyEnabled", "keyboardCatchQuit", "ratioGetX", "ratioGetY", "vldpGetScale", "overlayGetWidth", "overlayGetHeight", "fontGetHeight", "fontGetWidth", "spriteLoadFrames", "mouseGetPosition", "keyboardCatchQuit", "singeSetQuitKeyEnabled" }) do
env[key] = nothing
end
-- Where the framework's globals are, and its version from their header.
local frameworkFile
local frameworkVersion
local kimmy = false
local rdg = false
local timegal = false
local sdq = false
local mazescater = false
-- An American Laser Games title (Mad Dog McCree and its kin): no framework at all, its
-- hitbox files beside the script; the alg behaviour plays it over the level flows written
-- by hand in <stem>.levels.lua beside the script.
local alg = (function()
local handle = io.open(dir .. "/" .. main, "rb")
local text = handle and handle:read("a") or ""
if handle then
handle:close()
end
-- The family's mark: a shot judged against a hitmap of the frame (shooterHit), its
-- hitbox files loaded by name, or the typing edition's own globals.
return (text:find("function shooterHit", 1, true) ~= nil) or (text:find('dofile(MYDIR .. "hitbox-', 1, true) ~= nil) or (text:find("te-globals.singe", 1, true) ~= nil)
end)()
-- Adventures in Videoland (Rollercoaster): a text adventure over the disc, its rooms, objects,
-- and furniture tables in the script; the videoland behaviour plays its loop over them.
local videoland = (function()
local handle = io.open(dir .. "/" .. main, "rb")
local text = handle and handle:read("a") or ""
if handle then
handle:close()
end
return (text:find("STATE_GET_NAME", 1, true) ~= nil) and (text:find("ROOMS", 1, true) ~= nil)
end)()
-- Hologram Time Traveler (RDG2010's Singe edition): its own loop over segment tables shuffled
-- at start, read like the American Laser Games titles (the script is its data) and played by
-- the timetraveler behaviour in the vocabulary beside it.
local timetraveler = (function()
local handle = io.open(dir .. "/" .. main, "rb")
local text = handle and handle:read("a") or ""
if handle then
handle:close()
end
return text:find("TIME TRAVELER (SINGE EDITION)", 1, true) ~= nil
end)()
if videoland or timetraveler then
alg = true
end
-- (hq-globals.singe, Time Gal HD's, comes before a plain globals.singe beside it: that title
-- keeps its Singe 1 ancestor's files too.)
for _, candidate in ipairs({ dir .. "/MazescaterFramework/Script/globals.singe", dir .. "/KarisFramework/Script/globals.singe", dir .. "/Structure/globals.singe", dir .. "/Script/globals.singe", dir .. "/../Framework/globals.singe", dir .. "/../FrameworkKimmy/globals.singe", dir .. "/hq-globals.singe", dir .. "/globals.singe" }) do
local handle = io.open(candidate, "r")
if handle then
local text = handle:read("a") or ""
-- The MazescaterFramework (LINEA): Karis 3.31c with compound moves, its version
-- marked as the framework's own so the loop tells the two apart.
mazescater = (candidate == dir .. "/MazescaterFramework/Script/globals.singe")
frameworkFile = candidate
frameworkVersion = (mazescater and "Mazescater " or "") .. (text:match("VERSION:%s*([%w%.]+)") or "")
kimmy = text:find("KIMMY SCRIPT ENGINE", 1, true) ~= nil
timegal = (candidate == dir .. "/hq-globals.singe")
rdg = (candidate == dir .. "/globals.singe") or timegal
-- Super Don Quixote: map-mode data under a Karis 3.31c judging loop, whose globals
-- name the Karis kinds (HOLDUP and the rest).
sdq = rdg and (not timegal) and (text:find("HOLDUP", 1, true) ~= nil)
handle:close()
break
end
end
if alg then
-- The game's own script is its globals: everything it declares at load is its data.
frameworkFile = dir .. "/" .. main
frameworkVersion = nil
rdg = true
end
assert(frameworkFile, "no framework globals beside " .. dir)
env.singeGetScriptPath = function() return dir .. "/" .. main end
env.random = { new = function() return 0 end }
-- The map-mode scripts seed from os.clock at load; plain Lua wants a whole number for that.
env.os = setmetatable({ clock = function() return 1 end }, { __index = os })
-- A game that asks the filesystem whether its saved config exists (cfgReadPath, through lfs)
-- hears that it does not, and reads the shipped copy.
env.require = function() return { attributes = function() return nil end } end
-- A game that reads its config at load (Mad Dog McCree's readConfig runs as the script does)
-- reads the real file when the path names one, and nothing otherwise.
local ioCurrent = nil
env.io = { open = function() return nil end,
input = function(path)
-- The path as given, else its name under the game's Cfg/ folder (the script's
-- MYDIR is a stub here).
local base = (type(path) == "string") and path:match("([^/\\]+)$") or nil
ioCurrent = nil
for _, candidate in ipairs({ path, base and (dir .. "/../Cfg/" .. base), base and (dir .. "/Cfg/" .. base), base and (dir .. "/" .. base) }) do
if (type(candidate) == "string") and (ioCurrent == nil) then
ioCurrent = io.open(candidate, "r")
end
end
return ioCurrent
end,
output = function() return nil end,
read = function(what)
if ioCurrent then
return ioCurrent:read(what or "l")
end
return nil
end,
write = function() end,
close = function(handle)
if handle and (handle == ioCurrent) then
ioCurrent:close()
ioCurrent = nil
end
end,
lines = function() return function() return nil end end }
env.mouseHowMany = function() return 0 end
env.discSearch = function() end
env.discSetFPS = function() end
env.singeWantsCrosshairs = function() return false end
-- A setupMoves that reads the game's state (Space Ace picks a scene by which levels are
-- beaten) sees nothing beaten here; the qte behaviour runs the script itself as it plays.
env.stage = setmetatable({}, { __index = function() return { false, false, 0 } end })
env.scene = setmetatable({}, { __index = function() return setmetatable({}, { __index = function() return { 0, false } end }) end })
env.Tiers = {}
env.Level = {}
env.Death = {}
env.move = {}
env.choice = {}
env.path = {}
env.timed = {}
env.dofile = function(path)
if alg and (path ~= frameworkFile) then
-- The title's own files (its hitbox tables, its board, its service) run here too,
-- under the stubs: the script calls what they define at load (readConfig).
-- (Beside the script, or in a Script/ folder beside it, as the HD copies keep theirs.)
local base = path:match("([^/\\]+)$")
local own = loadfile(dir .. "/" .. base, "t", env) or loadfile(dir .. "/Script/" .. base, "t", env)
if own then
own()
end
return
end
if path:match("globals%.singe$") then
-- The framework's globals name the moves; what it loads beyond that is stubbed.
local chunk = assert(loadfile(frameworkFile, "t", env))
local inner = env.dofile
env.dofile = function() end
chunk()
env.dofile = inner
end
end
if alg then
-- A hand-written game touches whatever engine calls it likes at load (videoGetVolume, ...):
-- any name the stubs do not know answers as one that does nothing.
for _, name in ipairs({ "pairs", "ipairs", "next", "type", "tonumber", "tostring", "select", "pcall", "error", "assert", "setmetatable", "getmetatable", "rawget", "rawset", "rawequal", "print", "unpack" }) do
if rawget(env, name) == nil then
env[name] = _G[name] or (name == "unpack" and table.unpack) or nil
end
end
setmetatable(env, { __index = function(_, key)
local real = _G[key]
if real ~= nil then
return real
end
return nothing
end })
-- Its framework.singe defines the switch names and the defaults the script reads.
local frame = loadfile(dir .. "/framework.singe", "t", env)
if frame then
frame()
end
end
-- The game's own directory (the Script/ folder's parent when it has one), for the files the
-- script joins onto MYDIR at load.
env.MYDIR = (io.open(dir .. "/../Cfg/", "r") or io.open(dir .. "/../Script/", "r")) and (dir .. "/..") or dir
assert(loadfile(dir .. "/" .. main, "t", env))()
-- ===== The settings ===========================================================================
-- The dips, by name, from the game's config files in Cfg/, the first to name one winning.
local function configFiles(names)
local found = {}
for _, name in ipairs(names) do
local handle = io.open(dir .. "/Cfg/" .. name, "r")
if handle then
for line in handle:lines() do
local key, value = line:match("^%s*([%w_]+)%s*=%s*(-?%d+)")
if key and (found[key] == nil) then
found[key] = tonumber(value)
end
end
handle:close()
end
end
return found
end
-- What the game was last saved with or shipped with: the framework reads Cfg/game.cfg and falls
-- back to Cfg/default.cfg.
local function settings()
return configFiles({ "game.cfg", "default.cfg" })
end
-- What the service screen's "Default" restores: Cfg/default.cfg alone.
local function defaults()
return configFiles({ "default.cfg" })
end
-- ===== The moves ==============================================================================
local function copy(t)
local out = {}
for key, value in pairs(t or {}) do
-- A function is not data (a stub of the converter's, a game's own helper).
if type(value) == "table" then
out[key] = copy(value)
elseif type(value) ~= "function" then
out[key] = value
end
end
return out
end
-- A game's moves may differ by difficulty (Space Ace's do: setupMoves reads dip_Difficulty):
-- each scene is read at every difficulty, and kept once when they agree.
local function scenesAt(level, difficulty)
local scenes = {}
env.dip_Difficulty = difficulty
for scene = 1, env.Level[level][4] do
env.move = {}
env.choice = {}
env.path = {}
env.timed = {}
env.sceneStart = nil
env.sceneEnd = nil
env.totalMoves = 0
env.setupMoves(level, scene)
local moves = {}
for index = 1, env.totalMoves or 0 do
moves[index] = copy(env.move[index])
end
scenes[scene] = { start = env.sceneStart, finish = env.sceneEnd, moves = moves, choices = copy(env.choice), paths = copy(env.path), timed = copy(env.timed) }
end
return scenes
end
local function same(a, b)
if type(a) ~= type(b) then
return false
end
if type(a) ~= "table" then
return a == b
end
for key, value in pairs(a) do
if not same(value, b[key]) then
return false
end
end
for key in pairs(b) do
if a[key] == nil then
return false
end
end
return true
end
-- The scenes of a level: play, or playBy difficulty when the difficulties differ.
local function scenesOf(level)
local by = {}
local differ = false
for difficulty = 0, 3 do
by[difficulty] = scenesAt(level, difficulty)
if not same(by[difficulty], by[0]) then
differ = true
end
end
env.dip_Difficulty = nil
if differ then
return nil, by
end
return by[0], nil
end
-- A game's swapScene is a function of the difficulty, the level, and the scene: Tron plays a
-- different scene of five levels on the two easier settings. Nothing else goes into it, so every
-- answer can be read here and written as a table -- difficulty, then level, then the scene asked
-- for, giving the scene played -- and a game whose only hook is this one needs no Lua at play.
-- Only the answers that differ are kept, so a game that swaps nothing writes nothing.
local function swaps()
if type(env.swapScene) ~= "function" then
return nil
end
local out = {}
local found = false
for difficulty = 0, 3 do
local byLevel = {}
for level = 1, (env.finalstage or 0) do
local scenes = (env.Level[level] or {})[4] or 0
local byScene = {}
for scene = 1, scenes do
env.dip_Difficulty = difficulty
env.iLevel = level
env.iScene = scene
env.bSwap = false
local ok = pcall(env.swapScene)
if ok and (type(env.iScene) == "number") and (env.iScene ~= scene) then
byScene[scene] = env.iScene
found = true
end
end
if next(byScene) then
byLevel[level] = byScene
end
end
if next(byLevel) then
out[difficulty] = byLevel
end
end
env.dip_Difficulty = nil
env.iLevel = nil
env.iScene = nil
env.bSwap = nil
return found and out or nil
end
-- ===== Writing ================================================================================
local out = {}
-- The original author's credits, from the game's own games.dat and script header (forgeCredits.lua
-- beside this file), so a port names whoever made the game.
dofile((arg[0]:match("^(.*[/\\])") or "") .. "forgeCredits.lua")
local function put(line)
out[#out + 1] = line
end
local function putCredits()
local line = creditsFor(dir, main)
if line then
put(line)
end
end
local function lua(value, indent)
indent = indent or ""
if type(value) == "table" then
local keys = {}
local isList = (#value > 0)
local parts = {}
for key in pairs(value) do
keys[#keys + 1] = key
end
table.sort(keys, function(a, b)
if type(a) == type(b) then
return a < b
end
return type(a) == "number"
end)
for _, key in ipairs(keys) do
local item = lua(value[key], indent .. "\t")
if isList and (type(key) == "number") and (key >= 1) and (key <= #value) and (math.floor(key) == key) then
parts[#parts + 1] = item
elseif type(key) == "string" and key:match("^[%a_][%w_]*$") then
parts[#parts + 1] = key .. " = " .. item
else
parts[#parts + 1] = "[" .. lua(key) .. "] = " .. item
end
end
local text = "{ " .. table.concat(parts, ", ") .. " }"
if #text > 110 then
text = "{\n" .. indent .. "\t" .. table.concat(parts, ",\n" .. indent .. "\t") .. "\n" .. indent .. "}"
end
return text
elseif type(value) == "string" then
return string.format("%q", value)
end
return tostring(value)
end
local levels = {}
-- A level row as the description keeps it: the one place its fields are written down, the
-- runtime building the loops' positional rows from it. play and playBy are its scenes, for a
-- level the game can reach.
local function levelOf(level, play, playBy)
return { title = level[1], intro = level[2], introEnd = level[3], scenes = level[4], mirror = level[5], deathMirror = level[6], replay = level[7], selectStill = level[8], play = play, playBy = playBy }
end
-- Every row the game declares, those past its last stage and a secret one it cannot reach
-- included, since the game's own Lua may read them. A map-mode game's levels are functions of
-- its main.singe, run at play; nothing to read here.
if not rdg then
for index, level in pairs(env.Level) do
if (type(index) == "number") and (index ~= env.levelSecret) then
local play, playBy = nil, nil
if index <= env.finalstage then
play, playBy = scenesOf(index)
end
levels[index] = levelOf(level, play, playBy)
end
end
if env.Level[env.levelSecret] then
local play, playBy = nil, nil
if env.AllowSecret then
play, playBy = scenesOf(env.levelSecret)
end
levels.secret = levelOf(env.Level[env.levelSecret], play, playBy)
end
end
local tiers = {}
for index = 0, (env.Tiers and env.Tiers[0] and env.Tiers[0][1]) or 0 do
tiers[index + 1] = copy(env.Tiers[index])
end
local dips = settings()
-- A map-mode game keeps its dips and its board in one file, the one its service.singe reads
-- (game.cfg or the game's own name beside the script; the later copies read theirs from Cfg/
-- beside the Script/ folder, through cfgReadPath): the dips as name = value lines (the names
-- are the game's own), a blank, then ten lines of NAME,score.
local function mapModeConfigFile()
local handle = (timegal and io.open(dir .. "/hq-service.singe", "rb")) or io.open(dir .. "/service.singe", "rb") or io.open(dir .. "/Script/service.singe", "rb") or io.open(dir .. "/te-service.singe", "rb")
local name = "game.cfg"
if handle then
local text = handle:read("a")
handle:close()
-- The config the game reads first: through cfgReadPath (the later copies, whose
-- MYDIR reads name a default.cfg fallback too), else joined onto MYDIR.
name = text:match('io%.input%(cfgReadPath%("([^"]+)"%)') or text:match('io%.input%(MYDIR%s*%.%.%s*"([^"]+)"') or text:match('io%.input,%s*MYDIR%s*%.%.%s*"([^"]+)"') or text:match('io%.input%("[^"]-([^"/]+%.cfg)"%)') or name
name = name:gsub("^/", "")
end
for _, candidate in ipairs({ dir .. "/" .. name, dir .. "/../Cfg/" .. name, dir .. "/../" .. name }) do
local found = io.open(candidate, "r")
if found then
found:close()
return candidate
end
end
return dir .. "/" .. name
end
local function mapModeConfig()
local found = {}
local board = {}
local extra = {} -- Super Don Quixote's best percent per level, after its board.
local handle = io.open(mapModeConfigFile(), "r")
if handle then
for line in handle:lines() do
local key, value = line:match("^%s*([%w_]+)%s*=%s*(-?%d+)")
local who, score = line:match("^%s*([^,=]+),(%d+)%s*$")
if key then
found[key] = tonumber(value)
elseif who and (#board < 10) then
board[#board + 1] = { who, tonumber(score) }
elseif who then
extra[#extra + 1] = { who, tonumber(score) }
end
end
handle:close()
end
return found, board, extra
end
-- What a map-mode title does differently from the lineage, by the script's stem: the sound of
-- a right move when it is not sndright, and how long the intro's command screen holds.
local RDG_TWEAKS = {
freedomfighter = { rightSound = "sndshot", introHold = 15 }
}
if rdg then
local rdgDips, rdgBoard, rdgExtra = mapModeConfig()
dips = rdgDips
RDG_BOARD = rdgBoard
RDG_EXTRA = rdgExtra
end
-- Super Don Quixote's level order is two tables hardcoded in its doLevel: the level after a
-- beaten one, and the level after a scene that did not beat its level (the game alternates
-- two levels' scenes); read from the script's text as the game's data.
local function sdqOrder()
local handle = io.open(dir .. "/main.singe", "rb")
local beaten = {}
local unfinished = {}
if handle == nil then
return nil
end
local text = handle:read("a")
handle:close()
local body = text:match("function doLevel%(%).-\nend") or ""
local split = body:find("if CompleteLevel then", 1, true) or #body
local before, after = body:sub(1, split), body:sub(split)
for from, to in before:gmatch("if%s*%(thisLevel == level(%d+)%)%s*then%s*iCurPos%s*=%s*level(%d+)") do
beaten[tonumber(from)] = tonumber(to)
end
for from, to in after:gmatch("if%s*%(thisLevel == level(%d+)%)%s*then%s*iCurPos%s*=%s*level(%d+)") do
unfinished[tonumber(from)] = tonumber(to)
end
return { beaten = beaten, unfinished = unfinished }
end
-- The high score board the game ships with (Cfg/hscore.cfg: ten lines of NAME,score): a score
-- that reaches it goes to the board's screens at the end.
-- The game's boards from Cfg/hscore.cfg, as the framework reads them: ten lines each, a blank
-- line between, for the normal game, the life-bar game, and the survivor game; then, after the
-- trophy counts, each level's best percent ("name,percent!difficulty").
local function boards()
local found = { normal = {}, lifeBar = {}, survivor = {}, percent = {} }
local order = { "normal", "lifeBar", "survivor" }
local handle = io.open(dir .. "/Cfg/hscore.cfg", "r")
local which = 1
if handle then
for line in handle:lines() do
local who, score = line:match("^%s*([^,]+),(%d+)%s*$")
local board = found[order[which]]
local best, percent, difficulty = line:match("^%s*([^,]+),(%d+)!(%a)%s*$")
if board and who then
board[#board + 1] = { who, tonumber(score) }
if #board == 10 then
which = which + 1
end
elseif best then
found.percent[#found.percent + 1] = { best, tonumber(percent), difficulty }
end
end
handle:close()
end
return found
end
-- Kimmy's "die and retry" records, one per difficulty: the four lines after the three boards
-- and the two trophy blocks of Cfg/hscore.cfg, read as the framework reads them (a line
-- without a comma-and-name counts 100).
local function dieAndRetry()
local records = { 100, 100, 100, 100 }
local handle = io.open(dir .. "/Cfg/hscore.cfg", "r")
if handle == nil then
return records
end
local lines = {}
for line in handle:lines() do
lines[#lines + 1] = line
end
handle:close()
-- 10 + blank, 10 + blank, 10 + blank, 4 + blank, 4 + blank: the records begin at line 44.
for k = 1, 4 do
local line = lines[43 + k]
local score = line and (line:find("!", 1, true) == nil) and line:match("^[^,]*,(.*)$")
records[k] = tonumber(score) or 100
end
return records
end
-- Kimmy's save slots, Cfg/s1.cfg to s6.cfg: a line of eleven fields in the framework's
-- punctuation, then a line per level (its order, started, beaten, deaths).
local function saves()
local slots = {}
for slot = 1, 6 do
local handle = io.open(dir .. "/Cfg/s" .. slot .. ".cfg", "r")
if handle then
local first = handle:read("l") or ""
local line = {}
local rest = first
for _, mark in ipairs({ ",", "!", "?", ";", ":", "A", "B", "C", "D", "E", "F" }) do
local at = rest:find(mark, 1, true)
if at == nil then
break
end
line[#line + 1] = rest:sub(1, at - 1)
rest = rest:sub(at + 1)
end
local levels = {}
for entry in handle:lines() do
local order, started, beaten, deaths = entry:match("^(%d+)A(%a+)B(%a+)C(%d+)D")
if order then
levels[#levels + 1] = { tonumber(order), started == "true", beaten == "true", tonumber(deaths) }
end
end
handle:close()
slots[slot] = { line = line, levels = levels }
end
end
return slots
end
-- Everything the game's script declared, under its own names: the scores, the offsets, the
-- flags, the Level and Death tables, the play order and the tiers. The qte behaviour takes
-- these as its state, and runs the script itself when it can, for a setupMoves that reads the
-- state as it plays.
local settings = {}
for key, value in pairs(env) do
local kind = type(value)
if (kind == "number") or (kind == "string") or (kind == "boolean") or (kind == "table") then
if not (key == "move" or key == "choice" or key == "path" or key == "timed" or key == "io" or key == "os" or key == "string" or key == "math" or key == "table" or key == "sceneStart" or key == "sceneEnd" or key == "totalMoves" or key == "dip_Difficulty" or key == "MYDIR" or key == "BASEDIR" or key == "SINGE_LEGACY_SPRITE_ARGS" or key == "random" or key == "os") then
settings[key] = (kind == "table") and copy(value) or value
end
end
end
-- A table literal of Author.singe's, by its local's name: the runtime's own names and constants,
-- read from the runtime so they are written once.
local authorSource = nil
local function authorLiteral(name)
if authorSource == nil then
local path = (arg[0]:match("^(.*[/\\])") or "") .. "../forge/Author.singe"
local handle = io.open(path, "rb") or error("cannot read " .. path)
authorSource = handle:read("a")
handle:close()
end
local body = authorSource:match("\nlocal " .. name .. " = (%b{})") or error("no " .. name .. " in Author.singe")
return load("return " .. body, name, "t", {})()
end
-- The clips and stills under Forge's names, out of the settings: the names are Author.singe's
-- own QTE_CLIPS table, read from there so they are written once; a still is a frame global
-- named by what follows "frame"; the attract steps are offsetIntro01, 02, and on.
local clips, stills = {}, {}
do
local names = authorLiteral("QTE_CLIPS")
if not rdg then
for _, clip in ipairs(names) do
if settings[clip[2]] ~= nil then
clips[clip[1]] = { settings[clip[2]], settings[clip[3]] }
settings[clip[2]] = nil
settings[clip[3]] = nil
end
end
local attract = {}
while settings[string.format("offsetIntro%02d", #attract + 1)] ~= nil do
local step = #attract + 1
attract[step] = { settings[string.format("offsetIntro%02d", step)], settings[string.format("offsetIntro%02dend", step)] }
settings[string.format("offsetIntro%02d", step)] = nil
settings[string.format("offsetIntro%02dend", step)] = nil
end
if #attract > 0 then
clips.attract = attract
end
for key, value in pairs(settings) do
local name = key:match("^frame(%u%w*)$")
if name and (type(value) == "number") then
stills[name:sub(1, 1):lower() .. name:sub(2)] = value
settings[key] = nil
end
end
end
end
-- The other named fields, by Author.singe's QTE_FIELDS: each value leaves the settings for its
-- group (scoring, lifeBar, windowDelay, order, deaths, secret, video).
local groups = {}
if not rdg then
for _, field in ipairs(authorLiteral("QTE_FIELDS")) do
local group, name = field[1]:match("^(%w+)%.(%w+)$")
if settings[field[2]] ~= nil then
groups[group] = groups[group] or {}
groups[group][name] = settings[field[2]]
settings[field[2]] = nil
end
end
end
-- A setting equal to the constant the runtime lays down before the settings (Author.singe's QTE
-- table, and HELD over it for a held game) is a copy of it, and is left out.
do
local constants = {}
for _, tableName in ipairs(kimmy and { "QTE", "HELD" } or { "QTE" }) do
for key, value in pairs(authorLiteral(tableName)) do
if (key ~= "FLAG_OF") and (key ~= "BRANCH") then
constants[key] = value
end
end
end
for key, value in pairs(settings) do
if (type(value) ~= "table") and (constants[key] == value) then
settings[key] = nil
end
end
end
-- The segmented lineage's runtime reads no settings (its game's own Lua declares them all), so
-- its descriptions carry none (see the qte table below).
-- One source for each: the levels are in levels, and a dip is in dips (the runtime lays the dips
-- over the settings, so a copy here would only look like it mattered).
if not rdg then
settings.Level = nil
end
for key in pairs(dips or {}) do
settings[key] = nil
end
-- (A game that sets its rate by number rather than by name has no MovieFPS; under the
-- hand-written games' catch-all stubs the name answers as a function, which is not data.)
settings.MovieFPS = (type(env.MovieFPS) == "number") and env.MovieFPS or nil
-- The level flows of an American Laser Games title, written by hand beside its script.
local function algLevels()
local chunk = loadfile(dir .. "/" .. main:gsub("%.singe$", "") .. ".levels.lua", "t", {})
return chunk and chunk() or nil
end
-- The add-ons, last of all: they carry the hooks the loop calls, and a hook that is a function
-- of the state can be read here rather than run at play (see swaps). They are loaded after the
-- settings are taken, because their own load-time globals (a sound handle, a map cursor) belong
-- to them and not to the game's declarations -- a snapshot that carried them would describe a
-- state the game never starts in. A file that will not load under the stubs is no loss; the
-- game still names it and the loop still runs it.
-- The functions they define or replace are the hooks the loop calls into the game's own Lua, which
-- the ledger names (FORGE_LEDGER, below).
local hooks = {}
do
local chunk = loadfile(dir .. "/Script/addons.singe", "t", env)
if chunk then
local before = {}
for key, value in pairs(env) do
before[key] = value
end
pcall(chunk)
-- A hook whose body is only blank lines and comments is a stub, and does nothing.
local source = {}
local handle = io.open(dir .. "/Script/addons.singe", "rb")
for line in (handle and handle:read("a") or ""):gmatch("([^\n]*)\n?") do
source[#source + 1] = line
end
if handle then
handle:close()
end
for key, value in pairs(env) do
if (type(value) == "function") and (before[key] ~= value) then
local info = debug.getinfo(value, "S")
local body = {}
for at = info.linedefined + 1, info.lastlinedefined - 1 do
body[#body + 1] = (source[at] or ""):gsub("%-%-.*$", "")
end
-- The one-line form, "function name() end", has its body on its own line.
if info.linedefined == info.lastlinedefined then
body[1] = (source[info.linedefined] or ""):gsub("%-%-.*$", ""):gsub("^.-%b()", ""):gsub("end%s*$", "")
end
hooks[#hooks + 1] = { name = key, empty = not table.concat(body, " "):find("%S") }
end
end
table.sort(hooks, function(a, b) return a.name < b.name end)
end
end
-- Where the games the qte behaviour plays differ, the description says which way, by name
-- (FORGE.md 15.4): the runtime never asks which framework or version a game came from. Only
-- the qte loop reads these; the other loops' games carry none.
local function optionsFor()
if alg or timetraveler then
return nil
end
local options = {}
-- How the qte behaviour judges and runs this game; the default, windows, is left unsaid.
options.style = (timegal and "checkpoints") or (sdq and "trophies") or (rdg and "segments") or (kimmy and "held") or nil
if frameworkVersion == "3.32b" then
options.gameOverAfterContinue = "seek"
options.secretInputs = { "p1BUTTON4", "p1UP" }
end
if mazescater then
options.hintSeconds = 4
options.alternateMashRate = 6
options.mashAnySwitch = true
options.doubleKind = 34
end
return (next(options) ~= nil) and options or nil
end
local shipped = defaults()
local qte = {
script = main,
addons = (not rdg) and "Script/addons.singe" or nil,
options = optionsFor(),
hsDR = kimmy and dieAndRetry() or nil,
saves = (not rdg) and saves() or nil,
settings = (not rdg) and settings or nil,
clips = next(clips) and clips or nil,
stills = next(stills) and stills or nil,
scoring = groups.scoring,
lifeBar = groups.lifeBar,
windowDelay = groups.windowDelay,
order = groups.order,
deaths = groups.deaths,
secret = groups.secret,
video = groups.video,
dips = dips,
defaults = (not rdg) and next(shipped) and shipped or nil,
swaps = (not rdg) and swaps() or nil,
highScores = rdg and RDG_BOARD or nil,
boards = (not rdg) and boards() or nil,
tweaks = rdg and RDG_TWEAKS[name] or nil,
trophyOrder = sdq and sdqOrder() or nil,
percents = sdq and RDG_EXTRA or nil,
flows = alg and algLevels() or nil,
levels = (not rdg) and levels or nil
}
-- ===== The ledger ===============================================================================
--
-- FORGE_LEDGER=<file> writes, instead of the description, what the title uses and how this
-- converter maps each thing (FORGE.md section 17), one line each: a status, a capability, and a
-- detail. "forge" is a field of Forge's own model; "framework" is something Forge plays under
-- the framework's names or numbers (a dip, a leftover global, a move kind, a style); "lua" is
-- something the game's own Lua still does. util/forgeLedger.py gathers these over the library.
-- The judge (FORGE.md 15.4) each framework move kind is, by the kind's name in Author.singe's QTE
-- table, or HELD's for a held game.
local KIND_JUDGES = {
UP = "press.direction", DOWN = "press.direction", LEFT = "press.direction", RIGHT = "press.direction",
BUTTON1 = "press.button", BUTTON2 = "press.button", BUTTON3 = "press.button", BUTTON4 = "press.button",
UPLEFT = "press.diagonal", UPRIGHT = "press.diagonal", DOWNLEFT = "press.diagonal", DOWNRIGHT = "press.diagonal",
ACTUP = "chord.buttonDirection", ACTDOWN = "chord.buttonDirection", ACTLEFT = "chord.buttonDirection", ACTRIGHT = "chord.buttonDirection",
COMBO = "chord", MASH = "mash.one", MASHMIN = "mash.one", MASHMAX = "mash.one",
MASH2 = "mash.two", MASH2MIN = "mash.two", MASH2MAX = "mash.two", RUN = "mash.run", RUNMIN = "mash.run", RUNMAX = "mash.run",
HOLDUP = "hold.direction", HOLDDOWN = "hold.direction", HOLDLEFT = "hold.direction", HOLDRIGHT = "hold.direction",
HOLDBUT = "hold.button", HOLD = "hold", LETGO = "letGo", DOUBLE = "double", MULTI = "count",
LOOP = "circle", LOOPLEFT = "circle", LOOPRIGHT = "circle", UD = "sequence", DU = "sequence", LR = "sequence", RL = "sequence", B1B2 = "sequence",
CHOOSE = "choose", PATH = "path", YESNO = "yesNo", TIMED = "timed", MTIMED = "timed.multi",
ANYTHING = "anything", NOTHING = "nothing", SKIP = "skip",
WAY = "jump", WAYOUT = "jump", TOLEVEL = "jump", TOSCENE = "jump"
}
local function ledger(path)
local lines = {}
local seen = {}
local function use(status, capability, detail)
local key = status .. "\t" .. capability .. "\t" .. (detail or "")
if not seen[key] then
seen[key] = true
lines[#lines + 1] = key
end
end
local family = (videoland and "videoland") or (timetraveler and "timetraveler") or (alg and "alg") or ("qte." .. ((qte.options and qte.options.style) or "windows"))
use("forge", "family." .. family, frameworkVersion)
-- The moves: every row's kind, as the judge it is.
local names = {}
for _, tableName in ipairs(kimmy and { "QTE", "HELD" } or { "QTE" }) do
for key, value in pairs(authorLiteral(tableName)) do
if KIND_JUDGES[key] and (type(value) == "number") then
names[value] = key
end
end
end
local function moves(value)
for key, item in pairs(value) do
if (key == "moves") and (type(item) == "table") then
for _, row in ipairs(item) do
if (type(row) == "table") and (type(row[3]) == "number") then
local kind = names[row[3]]
if kind then
use("framework", "judge." .. KIND_JUDGES[kind], kind)
else
-- Not a kind Forge names: the framework's own constants of that value,
-- as candidates for what it is.
local candidates = {}
for key, value in pairs(env) do
if (value == row[3]) and (type(key) == "string") and key:match("^%u[%u%d]+$") then
candidates[#candidates + 1] = key
end
end
table.sort(candidates)
use("framework", "judge.unnamed", "kind " .. row[3] .. ((#candidates > 0) and (" (" .. table.concat(candidates, " or ") .. ")") or ""))
end
end
end
elseif type(item) == "table" then
moves(item)
end
end
end
moves(levels)
if qte.swaps then
use("forge", "scene.swaps")
end
-- The screens and the named groups.
for clip in pairs(clips) do
use("forge", "screen." .. clip)
end
for still in pairs(stills) do
use("forge", "still." .. still)
end
for group, fields in pairs(groups) do
for field in pairs(fields) do
use("forge", group .. "." .. field)
end
end
-- Persistence.
if qte.boards or qte.highScores then
use("forge", "persist.boards")
end
if qte.saves then
use("forge", "persist.saves")
end
if qte.hsDR then
use("framework", "persist.dieAndRetryRecords")
end
-- What is still the framework's: its settings, the globals nothing names, its style, and the
-- per-title tweaks and tables of the map-mode lineage.
for dip in pairs(dips or {}) do
use("framework", "setting." .. dip)
end
for key in pairs(qte.settings or {}) do
use("framework", "global." .. key)
end
for option in pairs(qte.options or {}) do
use((option == "style") and "framework" or "forge", "option." .. option, (option == "style") and qte.options.style or nil)
end
for tweak in pairs(qte.tweaks or {}) do
use("framework", "tweak." .. tweak)
end
if qte.trophyOrder then
use("framework", "order.trophies")
end
if qte.percents then
use("framework", "scoring.percents")
end
if qte.flows then
use("framework", "flows.handWritten", main:gsub("%.singe$", "") .. ".levels.lua")
end
-- What the game's own Lua still does.
if alg or videoland or timetraveler then
use("lua", "vocabulary", "Vocabulary.singe")
elseif rdg then
use("lua", "levelFunctions", main)
end
-- swapScene is run here, over every level, scene, and difficulty, into scene.swaps (see swaps).
for _, hook in ipairs(hooks) do
if hook.empty then
use("framework", "hook." .. hook.name, "empty")
elseif hook.name == "swapScene" then
use("forge", "scene.swaps", "read from swapScene")
else
use("lua", "hook." .. hook.name, "Script/addons.singe")
end
end
if qte.script and not (alg or videoland or timetraveler) then
use("lua", "script", qte.script)
end
table.sort(lines)
local handle = assert(io.open(path, "w"))
handle:write(table.concat(lines, "\n") .. "\n")
handle:close()
end
if os.getenv("FORGE_LEDGER") then
ledger(os.getenv("FORGE_LEDGER"))
return
end
if videoland then
-- The objects are set up by the game's own reset; the rooms, furniture, and directions
-- stand as the script declares them.
env.resetGameData()
put("-- " .. name .. ", ported to Forge from its original script by the port converter:")
put("-- the game's rooms, objects, and furniture as data for the videoland behaviour, which plays its loop")
put("-- under the console look; both live in Vocabulary.singe beside this file.")
put("return {")
put("\ttitle = " .. lua(name) .. ",")
putCredits()
put("\tvocabulary = \"Vocabulary.singe\",")
put("\tplayers = 1,")
put("\tlayers = { { kind = \"disc\" }, { kind = \"overlay\" } },")
put("\tvars = { room = 1, turns = 0 },")
put("")
put("\ttypes = {")
put("\t\tgame = { look = { kind = \"console\" }, behaviours = { { kind = \"videoland\" } } }")
put("\t},")
put("")
put("\trooms = {")
put("\t\t{ name = \"play\", entities = { { type = \"game\", id = \"game\", x = 0, y = 0 } } }")
put("\t},")
put("")
put("\trules = {},")
put("")
put("\tvideoland = " .. lua({ script = main, rooms = env.ROOMS, objects = env.OBJECTS, furniture = env.FURNITURE, directions = env.DIRECTIONS, turns = 150 }, "\t"))
put("}")
local path = dir .. "/" .. name .. ".forge"
local file = assert(io.open(path, "w"))
file:write(table.concat(out, "\n") .. "\n")
file:close()
print("wrote " .. path .. ": " .. #env.ROOMS .. " rooms, " .. #env.OBJECTS .. " objects")
return
end
local loop = (timetraveler and "timetraveler") or (alg and "alg") or "qte"
put("-- " .. (rawget(env, "Title") or name) .. ", ported to Forge from its original script by the port converter:")
put("-- the game's tables as data for the " .. loop .. " behaviour, which plays the game's loop.")
put("return {")
put("\ttitle = " .. lua(name) .. ",")
putCredits()
if alg then
-- The title's mechanics and screens are a vocabulary beside its script.
put("\tvocabulary = \"Vocabulary.singe\",")
end
put("\tplayers = 1,")
put("\tlayers = { { kind = \"disc\" }, { kind = \"overlay\" } },")
put("\tvars = { score = 0, lives = 0, credits = 0, level = 0, prompt = \"\"" .. (kimmy and ", deaths = 0, lifeBar = 0, tilt = 0" or "") .. " },")
put("")
put("\ttypes = {")
-- The qte behaviour's games draw their HUD with the qteHud look (FORGE.md 14.15), from the game's
-- own pictures and font; the other loops' games draw none of it yet.
put("\t\tgame = { look = { kind = \"" .. ((loop == "qte") and "qteHud" or "none") .. "\" }, behaviours = { { kind = \"" .. loop .. "\" } } }")
-- The HUD's second font (the options' Font row other than the game's own) is the framework's
-- Media/Font/default.ttf, which the port cannot reach: it travels as the title's Fonts/hudDefault.ttf.
if loop == "qte" then
local font, found = frameworkFile:gsub("/Script/globals%.singe$", "/Media/Font/default.ttf")
local from = (found == 1) and io.open(font, "rb") or nil
local into = env.MYDIR .. "/Fonts/hudDefault.ttf"
if from and not io.open(into, "rb") then
local bytes = from:read("a")
local handle = io.open(into, "wb") or error("cannot write " .. into)
handle:write(bytes)
handle:close()
end
if from then
from:close()
end
end
put("\t},")
put("")
put("\trooms = {")
put("\t\t{ name = \"play\", entities = { { type = \"game\", id = \"game\", x = 0, y = 0 } } }")
put("\t},")
put("")
put("\trules = {},")
put("")
put("\tqte = " .. lua(qte, "\t"))
put("}")
local path = dir .. "/" .. name .. ".forge"
local file = assert(io.open(path, "w"))
file:write(table.concat(out, "\n") .. "\n")
file:close()
print("wrote " .. path .. ": " .. #levels .. " levels, " .. #env.Death .. " deaths")