-- 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
[] -- 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 .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// 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")