More work on the menu system and Forge.

This commit is contained in:
Scott Duensing 2026-09-22 18:32:07 -05:00
parent 79e5fce23e
commit 34fc72ae5d
139 changed files with 15296 additions and 708 deletions

125
CHANGELOG
View file

@ -7,6 +7,10 @@ SINGE 3.00
API Changes API Changes
----------- -----------
- onInputPressed and onInputReleased receive a second argument when the
switch came from a mouse or a light gun: the device's index, as
Hypseus passes it, so a two-gun game can tell its players apart. A
switch from the keyboard or a pad passes nothing there.
- singeScreenshot takes an optional file name, so a script that makes - singeScreenshot takes an optional file name, so a script that makes
pictures on purpose can name them. pictures on purpose can name them.
@ -21,6 +25,22 @@ API Changes
- In MODE_FULL a pad or mouse button reaches the script as the switch it - In MODE_FULL a pad or mouse button reaches the script as the switch it
is mapped to, as the manual always said; it arrived as a keysym of 0. is mapped to, as the manual always said; it arrived as a keysym of 0.
- A sprite scaled to nothing (a bullet shrinking to zero) draws as a
pixel instead of ending the engine with "Unable to transform sprite".
- --deterministic stepped the disc one video frame per engine frame,
whatever the step, and the overlay throttle's strict comparison ran
the script every other iteration of the virtual clock, so a game that
reads the same frame on two consecutive updates (as a game paced for
a faster overlay does) never saw it twice; the disc now follows the
virtual clock like everything else, each frame lasting the time it is
worth, and the script runs every step. A bare os.time() answers
with the virtual clock too, so a game that seeds its generator from
the wall clock when a game starts (the one-player Space Pirates)
repeats; os.time with a date table is Lua's own.
- fontSelect given no font (nil, or an index that was never loaded)
keeps the current font and warns instead of ending the script, as
Hypseus lets it pass; The Last Bounty Hunter names a font it never
loaded at its first board.
- guiSetHandler set again after a document's elements were rebuilt (a - guiSetHandler set again after a document's elements were rebuilt (a
list through inner_rml) now attaches to the new element with that id. list through inner_rml) now attaches to the new element with that id.
@ -58,7 +78,42 @@ API Changes
by count and interval at a moment, a disc frame, or a rail camera's by count and interval at a moment, a disc frame, or a rail camera's
stop, from an entity or a list of them in turn, or at a point; and stop, from an entity or a list of them in turn, or at a point; and
soundDone says when a clip playSound or a sound behaviour started has soundDone says when a clip playSound or a sound behaviour started has
ended. Flat ended. Ports of the test library began (FORGE.md section 14): the
five ActionMax games are Forge descriptions written by
util/forgePortActionMax.py, and play the same as the emulator under
the same inputs (testScripts/ports/). On the way: a lightSensor
behaviour, a pointer behaviour, fonts and anchors on the text look,
sprites drawn in copies, discPlay and discPause, pointerX and pointerY
in expressions, an event belonging to the room it happened in, and a
built game finding files beside its description. A game can bring a
vocabulary of its own: a description names a file of Lua
(vocabulary = "Vocabulary.singe") that adds looks, behaviours,
conditions, actions, layers, or events to the manifest for that game
alone; the editor loads it when the game opens and forgets it when
the game closes, the compiler checks against it, and the built game
carries it into a release. The ActionMax light sensor lives in one
now, beside those five games, and not in Forge. The Hypseus library's
eleven Karis Framework 3.32b titles (Arcade Xperience 1 to 3, Astroboy,
Danmachi, Freddy, Starship Troopers, Sugar Rush, Survival, Esh's
Aurunmilla, and Badlands Lite) are ported by the same converter, which
finds the framework where a game keeps it and notes its version; the
rest of that library is surveyed in FORGE.md 14.9. A branch on the
branching track can want its move mashed (mash = 6) or held (hold =
20). The seven Kimmy Script Engine titles (Brain Dead 13, Future Boy
Conan, Mazinga Z, Fuma Conspiracy, Sonic 1996, Sintel, Ninja Hayate
1080) are ported by the same converter and a kimmy behaviour that
plays that engine's loop, and play as their originals do (FORGE.md
14.11).
util/forgePorts.py
assembles the ported games into a project folder of their own
(~/claude/singePorts), one game to a folder with its description, the
compiled port, the runtime, its files, and a games.dat. A description is a
.forge file now (it was .game, which is the packed release's, and the
chooser had to sniff which was which). The seventeen KarisFramework
games are ported too: util/forgePortKaris.lua reads a game's own
script, and the qte behaviour plays the framework's loop over the
framework's tables, running the game's script and add-ons in a shim;
every one plays as its original under the same driver. Flat
bodies are the size their looks are drawn at (they were half), and a bodies are the size their looks are drawn at (they were half), and a
platformer stands on its point. platformer stands on its point.
@ -494,8 +549,12 @@ API Changes
- Compressed textures. KTX2 files (Basis Universal, from - Compressed textures. KTX2 files (Basis Universal, from
util/compressTextures.py) stay compressed on the GPU in the block util/compressTextures.py) stay compressed on the GPU in the block
format the machine has (BC7, ASTC, BC3, or plain RGBA where nothing format the machine has (BC7, ASTC, ETC2, BC3, or plain RGBA where
else exists), a quarter of the memory of PNGs or less. A model whose nothing else exists), a quarter of the memory of PNGs or less. ETC2
is the one block format OpenGL ES 3 guarantees, so a handheld's Mali
without ASTC keeps its textures compressed rather than falling to RGBA
on the machine with the least memory; the GLES backend takes ETC2
always and ASTC 4x4 where the driver offers it. A model whose
file uses KHR_texture_basisu loads them on its own; materialSetTexture file uses KHR_texture_basisu loads them on its own; materialSetTexture
and the map calls take a .ktx2 name in place of a sprite; sceneGetStats and the map calls take a .ktx2 name in place of a sprite; sceneGetStats
reports the texture memory in use. The Sponza test scene went from reports the texture memory in use. The Sponza test scene went from
@ -1113,6 +1172,25 @@ API Changes
Fixes Fixes
----- -----
- The menu's grid, horizon and sun were drawn black and unlit through the
intro, which kept the lights off them but not the blast's glow behind
them: a black line across a flash is a line, and the whole grid showed
through the explosion as its outline. They are not drawn at all until
the grid comes up out of the dark. The recording of the backdrop is
re-made with it.
- A PNG with 16-bit samples and no alpha channel (crate.png in the test
scripts is one) drew with coloured fringes: SDL_image put libpng's
big-endian 48-bit pixels into a surface the machine reads
little-endian, swapping only the 64-bit kind, so every sample's low
byte was taken for its colour. The vendored loader swaps both now.
- musicPlay answers the handle it was given, as Hypseus's does. Its
games keep that (sndHandle = musicPlay(music)) to stop the piece, and
three of them (Freddy, Starship Troopers, Sugar Rush) died on the map
screen with "musicStop: Argument 1 must be a number" because Singe's
answered nothing. musicStop takes a nil handle as none.
- A machine with no enumerable mouse keeps its pointer. ManyMouse reads - A machine with no enumerable mouse keeps its pointer. ManyMouse reads
/dev/input/event* directly where there is no X server, and those nodes /dev/input/event* directly where there is no X server, and those nodes
are root:input, so an account that was never added to the "input" group are root:input, so an account that was never added to the "input" group
@ -1151,13 +1229,40 @@ Fixes
clips the old video was cut from are gone, one of which nobody could clips the old video was cut from are gone, one of which nobody could
account for. account for.
- The menu has a sound. util/makeMenuSound.py writes it -- every noise in - util/renderMenuAd.py records the menu's intro with its sound as one
it is an oscillator, a burst of noise or an envelope over one of the two, H.264 file at any size and rate (1920x1080 at 50 fps by default), for
in a room that is a convolution with a tail of decaying noise, so there is use outside the engine; the backdrop's tagline is laid out in step with
nothing in it that cannot be accounted for either. The charge, the rush the overlay it is drawn on, so a larger recording gets a larger tagline
of the logo arriving, the wingbeats (on the same curve the animation flaps rather than the menu's scaled up.
to), the flame, and a riser as the grid comes up, over a bed whose four
bars land exactly where the menu takes the screen. - The service tools are dressed like the rest of the menu. Each tool was
a sheet of white text on black; it is now a card over the moving
backdrop in the game page's own colours -- the panel gradient, the
blue-grey border, the title in gold on the selection's gradient bar, a
chosen row on the same bar, and the key hints in a footer strip. The
list of tools says what the chosen tool is for in a column beside the
list rather than under it, and the two tools that draw their own
picture (Light Gun and Display) keep a strip of the card along the
bottom, out of the way of the targets. Both renderers draw it the same
way: the RmlUi document, and the plain overlay a machine without a GPU
falls back to. Rows line up: a tool no longer pads columns into its
text with spaces, which a proportional font never honoured; a pair is
a label column and a value column, and a list item carries its detail
(a size, a state, a field's value) against the right edge of the row.
util/shootMenu.py runs the menu headless through both renderers and
shoots every page into screenshots/menu, with a contact sheet each.
- The menu has a sound. util/makeMenuSound.py writes it. The three things
the picture shows are recordings now: the charge is Mark DiAngelo's
"Explosion Ultra Bass", the logo bursting out of it Mike Koenig's "Large
Fireball", and the dragon Daniel Simon's "Dragon Fire Breath and Roar",
laid so the moment its roar turns to fire is the moment the animation
breathes -- three sounds from SoundBible under Creative Commons
Attribution 3.0, kept under assets/samples and credited in LICENSES.
The rest is still made in the script -- the wingbeats (on the same curve
the animation flaps to), a riser as the grid comes up, the logo leaving,
and a bed whose four bars land exactly where the menu takes the screen --
from oscillators and noise, in a room that is a convolution.
The charge and the flame are built the way the things themselves are The charge and the flame are built the way the things themselves are
heard rather than the way they are drawn. Both were made of smooth heard rather than the way they are drawn. Both were made of smooth
envelopes over steady noise -- the flame of a band of noise wobbled by two envelopes over steady noise -- the flame of a band of noise wobbled by two

View file

@ -69,6 +69,18 @@ Fonts
FreeSansBold GPL-3.0 with font exception https://www.gnu.org/software/freefont FreeSansBold GPL-3.0 with font exception https://www.gnu.org/software/freefont
Sounds
------
The menu's intro (assets/menuIntro.flac, written by util/makeMenuSound.py) is built on three
recordings from SoundBible, each under the Creative Commons Attribution 3.0 licence
(https://creativecommons.org/licenses/by/3.0/) and kept as FLAC under assets/samples:
Explosion Ultra Bass Mark DiAngelo CC BY 3.0 https://soundbible.com/1807-Explosion-Ultra-Bass.html
Large Fireball Mike Koenig CC BY 3.0 https://soundbible.com/1348-Large-Fireball.html
Dragon Fire Breath and Roar Daniel Simon CC BY 3.0 https://soundbible.com/2127-Dragon-Fire-Breath-and-Roar.html
Data Data
---- ----

View file

@ -135,6 +135,8 @@ local backdropTagX = 0
local backdropTagY = 0 local backdropTagY = 0
local blown = false -- Whether the charge has gone off. local blown = false -- Whether the charge has gone off.
local gridLit = false -- Whether the grid has finished coming up and its colours are final. local gridLit = false -- Whether the grid has finished coming up and its colours are final.
local gridShown = false -- Whether the grid, the horizon and the sun are drawn at all yet.
local backdropParts = {} -- Those nodes: hidden through the intro, shown as the grid comes up.
local backdropFont = nil local backdropFont = nil
-- ===== The backdrop ========================================================================== -- ===== The backdrop ==========================================================================
@ -425,12 +427,14 @@ local grid = nodeNew()
nodeSetMesh(grid, gridMesh(), gridMat) nodeSetMesh(grid, gridMesh(), gridMat)
nodeSetParent(grid, backdrop) nodeSetParent(grid, backdrop)
backdropParts[#backdropParts + 1] = grid
local horizon = nodeNew() local horizon = nodeNew()
nodeSetMesh(horizon, meshPlane(HORIZ_W * 2, HORIZ_H), horizMat) nodeSetMesh(horizon, meshPlane(HORIZ_W * 2, HORIZ_H), horizMat)
nodeSetRotation(horizon, 90, 0, 0) nodeSetRotation(horizon, 90, 0, 0)
nodeSetPosition(horizon, 0, horizonY(HORIZ_Z), HORIZ_Z) nodeSetPosition(horizon, 0, horizonY(HORIZ_Z), HORIZ_Z)
nodeSetParent(horizon, backdrop) nodeSetParent(horizon, backdrop)
backdropParts[#backdropParts + 1] = horizon
local sunCut = horizonY(SUN_Z) - SUN_Y local sunCut = horizonY(SUN_Z) - SUN_Y
local backSun = nodeNew() local backSun = nodeNew()
@ -438,6 +442,7 @@ local backSun = nodeNew()
nodeSetMesh(backSun, sunMesh(sunCut), sunMat2) nodeSetMesh(backSun, sunMesh(sunCut), sunMat2)
nodeSetPosition(backSun, 0, SUN_Y, SUN_Z) nodeSetPosition(backSun, 0, SUN_Y, SUN_Z)
nodeSetParent(backSun, backdrop) nodeSetParent(backSun, backdrop)
backdropParts[#backdropParts + 1] = backSun
for n = 1, SUN_SLICES do for n = 1, SUN_SLICES do
local slice = nodeNew() local slice = nodeNew()
@ -448,6 +453,7 @@ for n = 1, SUN_SLICES do
nodeSetMesh(slice, meshPlane(w * 2, height), sliceMat) nodeSetMesh(slice, meshPlane(w * 2, height), sliceMat)
nodeSetRotation(slice, 90, 0, 0) nodeSetRotation(slice, 90, 0, 0)
nodeSetPosition(slice, 0, SUN_Y + y, SUN_Z + 0.4) nodeSetPosition(slice, 0, SUN_Y + y, SUN_Z + 0.4)
backdropParts[#backdropParts + 1] = slice
nodeSetParent(slice, backdrop) nodeSetParent(slice, backdrop)
end end
@ -483,6 +489,15 @@ function backdropFrame()
-- there is no moment where one thing has ended and the next has not begun. -- there is no moment where one thing has ended and the next has not begun.
local lit = span(t, GRID_UP, GRID_LIT) local lit = span(t, GRID_UP, GRID_LIT)
-- Until then the grid, the horizon and the sun are not drawn at all. Black and unlit, they
-- were still there: a black line across the blast's glow is a line, and the whole grid showed
-- through the explosion as its outline.
if (lit > 0) and not gridShown then
gridShown = true
for _, part in ipairs(backdropParts) do
nodeSetVisible(part, true)
end
end
if (lit > 0) and not gridLit then if (lit > 0) and not gridLit then
materialSetColor(gridMat, 255 * lit, 45 * lit, 190 * lit) materialSetColor(gridMat, 255 * lit, 45 * lit, 190 * lit)
materialSetColor(horizMat, 255 * lit, 150 * lit, 235 * lit) materialSetColor(horizMat, 255 * lit, 150 * lit, 235 * lit)
@ -566,7 +581,10 @@ end
function backdropBegin(showIntro) function backdropBegin(showIntro)
-- A font of its own. The document renderer draws everything else through RmlUi and never -- A font of its own. The document renderer draws everything else through RmlUi and never
-- selects one, so the tagline -- the only thing put straight into the overlay -- brings it. -- selects one, so the tagline -- the only thing put straight into the overlay -- brings it.
backdropFont = fontLoad("Singe/FreeSansBold.ttf", 30) -- Thirty points on the menu's 480 rows, and in step with the overlay elsewhere: a recording
-- made at a larger size (util/renderMenuAd.py) lays the tagline out at that size rather than
-- scaling the menu's up.
backdropFont = fontLoad("Singe/FreeSansBold.ttf", math.floor(30 * overlayGetHeight() / 480 + 0.5))
fontSelect(backdropFont) fontSelect(backdropFont)
fontQuality(FONT_QUALITY_BLENDED) fontQuality(FONT_QUALITY_BLENDED)
@ -582,6 +600,10 @@ function backdropBegin(showIntro)
backdropStarted = singeGetTicks() - (showIntro and 0 or (LIFT_TO * 1000)) backdropStarted = singeGetTicks() - (showIntro and 0 or (LIFT_TO * 1000))
blown = not showIntro blown = not showIntro
gridLit = false gridLit = false
gridShown = false
for _, part in ipairs(backdropParts) do
nodeSetVisible(part, false)
end
if not showIntro then if not showIntro then
nodeSetVisible(logo, false) nodeSetVisible(logo, false)
end end

File diff suppressed because it is too large Load diff

View file

@ -63,7 +63,9 @@ local FUNCTIONS = {
max = "math.max", max = "math.max",
floor = "math.floor", floor = "math.floor",
time = "authorTime", time = "authorTime",
frame = "discGetFrame" frame = "discGetFrame",
pointerX = "authorPointerX",
pointerY = "authorPointerY"
} }
local NAMES = { local NAMES = {
@ -781,6 +783,21 @@ function authorCompile(game)
data[key] = value data[key] = value
end end
end end
-- A type's empty vars table says nothing the runtime does not assume; left out, so a
-- description that has one and one that has none compile the same.
if game.types then
data.types = {}
for name, entityType in pairs(game.types) do
local copy = {}
for key, value in pairs(entityType) do
if not ((key == "vars") and (type(value) == "table") and (next(value) == nil)) then
copy[key] = value
end
end
data.types[name] = copy
end
end
out[#out + 1] = "-- Generated by Forge/AuthorCompile.singe from " .. (game.source or "a game description") out[#out + 1] = "-- Generated by Forge/AuthorCompile.singe from " .. (game.source or "a game description")
out[#out + 1] = "-- " .. (game.title or "Untitled") out[#out + 1] = "-- " .. (game.title or "Untitled")
out[#out + 1] = "--" out[#out + 1] = "--"
@ -796,6 +813,17 @@ function authorCompile(game)
out[#out + 1] = 'local here = ((debug.getinfo(1, "S").source:gsub("^@", "")):match("^(.*[/\\\\])") or "")' out[#out + 1] = 'local here = ((debug.getinfo(1, "S").source:gsub("^@", "")):match("^(.*[/\\\\])") or "")'
out[#out + 1] = 'dofile(here .. "Author.singe")' out[#out + 1] = 'dofile(here .. "Author.singe")'
out[#out + 1] = "AUTHOR_DIR = here -- Files the description names are looked for here first." out[#out + 1] = "AUTHOR_DIR = here -- Files the description names are looked for here first."
-- A description built where it lives -- a port beside its game's own files, a test -- names
-- files beside itself, which is not where the built script sits; they are looked for there
-- second. A released game has them beside the script, and a packed one inside the pack.
local sourceDir = game.source and string.match(game.source, "^(.*[/\\])") or ""
if sourceDir ~= "" then
out[#out + 1] = "AUTHOR_SOURCE_DIR = " .. string.format("%q", sourceDir) .. " -- Then beside the description this was built from."
end
if game.vocabulary then
out[#out + 1] = "authorVocabulary(" .. string.format("%q", game.vocabulary) .. ") -- The game's own looks, behaviours, conditions, and actions."
end
out[#out + 1] = "" out[#out + 1] = ""
out[#out + 1] = "-- The description, as the runtime wants it: everything but the rules, which follow as code." out[#out + 1] = "-- The description, as the runtime wants it: everything but the rules, which follow as code."
out[#out + 1] = "authorBegin(" .. valueSource(data, 0) .. ")" out[#out + 1] = "authorBegin(" .. valueSource(data, 0) .. ")"
@ -852,6 +880,10 @@ function authorBuild(descriptionFile, outputFile)
local file local file
game.source = descriptionFile game.source = descriptionFile
-- The game's own vocabulary, before the checker reads the manifest.
if game.vocabulary then
authorVocabulary(game.vocabulary, string.match(descriptionFile, "^(.*[/\\])") or "")
end
for _, problem in ipairs(authorCheck(game)) do for _, problem in ipairs(authorCheck(game)) do
debugPrint("Author: " .. problem) debugPrint("Author: " .. problem)
end end

View file

@ -102,7 +102,7 @@ local NEW_H = 40
local STRIP_H = 34 -- The timeline along the bottom of the canvas. local STRIP_H = 34 -- The timeline along the bottom of the canvas.
local STRIP_PAD = 8 local STRIP_PAD = 8
local MODES = { "entities", "types", "rules", "tracks", "dialogues" } local MODES = { "entities", "types", "rules", "tracks", "dialogues" }
local FILE_KINDS = { png = true, jpg = true, jpeg = true, svg = true, glb = true, wav = true, ogg = true, mp3 = true, flac = true, mkv = true, mp4 = true, mpg = true, rml = true, hdr = true, ttf = true } local FILE_KINDS = { png = true, jpg = true, jpeg = true, svg = true, glb = true, wav = true, ogg = true, mp3 = true, flac = true, mkv = true, mp4 = true, mpg = true, rml = true, hdr = true, ttf = true, singe = true }
local FILE_DEPTH = 2 -- How far under the game's directory the file picker looks. local FILE_DEPTH = 2 -- How far under the game's directory the file picker looks.
local FILE_NONE = "(none)" -- The file picker's first entry: no file. local FILE_NONE = "(none)" -- The file picker's first entry: no file.
local FRAME_STEP = 1 local FRAME_STEP = 1
@ -1024,6 +1024,44 @@ end
-- The disc layer's file, if the description has one, loaded and paused so the timeline can show -- The disc layer's file, if the description has one, loaded and paused so the timeline can show
-- the frame under the cursor. -- the frame under the cursor.
-- A frame file names several videos, each from a disc frame: the one with the longest run of
-- frames is the game's own, and is what the editor scrubs, its first frame remembered so the
-- cursor's disc frame lands on the right picture. Answers the video's path and its first frame.
local function frameFileVideo(path)
local folder = path:match("^(.*[/\\])") or ""
local entries = {}
local best = nil
for line in io.lines(path) do
local start, name = line:match("^%s*(%d+)%s+(.-)%s*$")
if start then
entries[#entries + 1] = { start = tonumber(start), name = name }
elseif (#entries == 0) and (line:match("%S")) and not line:match("^%s*%d") then
-- The first line is the directory the videos are in; its own is the usual.
local where = line:gsub("%s+$", ""):gsub("\\", "/")
if (where ~= ".") and (where ~= "./") then
folder = folder .. where:gsub("/?$", "/")
end
end
end
for index, entry in ipairs(entries) do
local following = entries[index + 1]
entry.span = following and (following.start - entry.start) or 0
if (best == nil) or (entry.span > best.span) then
best = entry
end
end
if best == nil then
return nil, 0
end
return folder .. best.name, best.start
end
local function loadVideo() local function loadVideo()
local file local file
@ -1037,11 +1075,19 @@ local function loadVideo()
videoUnload(FORGE.video) videoUnload(FORGE.video)
FORGE.video = nil FORGE.video = nil
end end
FORGE.videoFile = file FORGE.videoFile = file
FORGE.videoStart = 0
if file and (lfs.attributes(forgeFile(file), "mode") == "file") then if file and (lfs.attributes(forgeFile(file), "mode") == "file") then
FORGE.video = videoLoad(forgeFile(file)) local path = forgeFile(file)
videoPause(FORGE.video)
videoSeek(FORGE.video, FORGE.cursor) if path:lower():match("%.txt$") then
path, FORGE.videoStart = frameFileVideo(path)
end
if path and (lfs.attributes(path, "mode") == "file") then
FORGE.video = videoLoad(path)
videoPause(FORGE.video)
videoSeek(FORGE.video, math.max(0, FORGE.cursor - FORGE.videoStart))
end
end end
end end
end end
@ -1053,6 +1099,11 @@ function forgeBegin(path)
if FORGE.game == nil then if FORGE.game == nil then
return false return false
end end
-- The game's own vocabulary, in place of the last game's, before the panels read the manifest.
authorVocabularyForget()
if FORGE.game.vocabulary then
authorVocabulary(FORGE.game.vocabulary, string.match(path, "^(.*[/\\])") or "")
end
FORGE.game.types = FORGE.game.types or {} FORGE.game.types = FORGE.game.types or {}
FORGE.game.rules = FORGE.game.rules or {} FORGE.game.rules = FORGE.game.rules or {}
FORGE.game.layers = FORGE.game.layers or {} FORGE.game.layers = FORGE.game.layers or {}
@ -1117,6 +1168,7 @@ function forgeClose()
if FORGE.gui ~= nil then if FORGE.gui ~= nil then
guiDelete(FORGE.gui) guiDelete(FORGE.gui)
end end
authorVocabularyForget()
for _, cache in ipairs({ FORGE.sprites, FORGE.sheets }) do for _, cache in ipairs({ FORGE.sprites, FORGE.sheets }) do
for _, sprite in pairs(cache) do for _, sprite in pairs(cache) do
if sprite then if sprite then
@ -1239,7 +1291,7 @@ function forgeExport(folder, name)
end end
-- The description travels with the game, so the release can be opened and edited again. -- The description travels with the game, so the release can be opened and edited again.
authorSave(FORGE.game, folder .. "/" .. script .. ".game") authorSave(FORGE.game, folder .. "/" .. script .. ".forge")
-- A games.dat so the menu lists it, written from what the description already knows. Names -- A games.dat so the menu lists it, written from what the description already knows. Names
-- in it are as the engine sees them from the games directory, the folder's own name first, -- in it are as the engine sees them from the games directory, the folder's own name first,
@ -1304,6 +1356,7 @@ function forgeFilesNamed(game)
end end
end end
take(game.vocabulary)
for _, layer in ipairs(game.layers or {}) do for _, layer in ipairs(game.layers or {}) do
fromParams(layer, (AUTHOR.layers[layer.kind] or {}).params) fromParams(layer, (AUTHOR.layers[layer.kind] or {}).params)
end end
@ -3323,7 +3376,7 @@ end
function forgeCursorTo(at) function forgeCursorTo(at)
FORGE.cursor = math.max(0, math.floor(at)) FORGE.cursor = math.max(0, math.floor(at))
if FORGE.video then if FORGE.video then
videoSeek(FORGE.video, FORGE.cursor) videoSeek(FORGE.video, math.max(0, FORGE.cursor - (FORGE.videoStart or 0)))
end end
forgeRefresh() forgeRefresh()
end end
@ -3578,6 +3631,8 @@ function forgeFieldKind(name)
param = key param = key
elseif (name == "reset") or (name == "depthSort") then elseif (name == "reset") or (name == "depthSort") then
return "flag" return "flag"
elseif name == "vocabulary" then
return "file"
end end
elseif FORGE.mode == "tracks" then elseif FORGE.mode == "tracks" then
local track = forgeRoom().tracks[FORGE.track] local track = forgeRoom().tracks[FORGE.track]
@ -4220,7 +4275,7 @@ end
-- The fields with nothing selected: the game's -- its title, players, verbs, and layers with -- The fields with nothing selected: the game's -- its title, players, verbs, and layers with
-- what each layer takes, named layer.parameter as a behaviour's are -- and then the room's. -- what each layer takes, named layer.parameter as a behaviour's are -- and then the room's.
function forgeRoomFieldNames() function forgeRoomFieldNames()
local names = { "title", "players", "vars", "verbs", "layers" } local names = { "title", "players", "vars", "verbs", "layers", "vocabulary" }
for _, layer in ipairs(FORGE.game.layers or {}) do for _, layer in ipairs(FORGE.game.layers or {}) do
local entry = AUTHOR.layers[layer.kind] local entry = AUTHOR.layers[layer.kind]
@ -4263,6 +4318,8 @@ function forgeRoomGet(room, key)
return table.concat(FORGE.game.verbs or {}, ", ") return table.concat(FORGE.game.verbs or {}, ", ")
elseif key == "layers" then elseif key == "layers" then
return listText(FORGE.game.layers) return listText(FORGE.game.layers)
elseif key == "vocabulary" then
return FORGE.game.vocabulary
elseif group ~= nil then elseif group ~= nil then
local layer = layerOf(group) local layer = layerOf(group)
@ -4301,6 +4358,13 @@ function forgeRoomSet(room, key, value)
if key == "title" then if key == "title" then
FORGE.game.title = tostring(value) FORGE.game.title = tostring(value)
elseif key == "vocabulary" then
-- The game's own vocabulary, loaded as it is named so the panels offer what it adds.
FORGE.game.vocabulary = ((value ~= nil) and (value ~= "")) and tostring(value) or nil
authorVocabularyForget()
if FORGE.game.vocabulary then
authorVocabulary(FORGE.game.vocabulary, string.match(FORGE.path or "", "^(.*[/\\])") or "")
end
elseif key == "players" then elseif key == "players" then
if type(value) == "number" then if type(value) == "number" then
FORGE.game.players = (value ~= 1) and math.floor(value) or nil FORGE.game.players = (value ~= 1) and math.floor(value) or nil
@ -4435,7 +4499,11 @@ function forgeEditCancel()
if FORGE.editing == nil then if FORGE.editing == nil then
return false return false
end end
FORGE.editing.set(FORGE.editing.key, FORGE.editing.was) -- Only a value the typing changed is put back: a field passed over is left exactly as it
-- was, not written again (writing "" to a type's vars used to make it an empty table).
if FORGE.editing.changed then
FORGE.editing.set(FORGE.editing.key, FORGE.editing.was)
end
FORGE.editing = nil FORGE.editing = nil
FORGE.lastField = nil -- The next ENTER starts the form over rather than moving on. FORGE.lastField = nil -- The next ENTER starts the form over rather than moving on.
FORGE.message = "unchanged" FORGE.message = "unchanged"
@ -4602,9 +4670,10 @@ end
-- ===== Files ================================================================================== -- ===== Files ==================================================================================
-- --
-- Descriptions live in Forge's data directory, which is the one place a packed Forge can write. -- Descriptions are .forge files -- Lua text; .game is a packed release, which the engine's menu
-- Ones dropped into Forge's own directory are offered too, and opened as a copy in the data -- lists and Forge does not. They live in Forge's data directory, which is the one place a packed
-- directory, since inside a .game they are read only. -- Forge can write. Ones dropped into Forge's own directory are offered too, and opened as a copy
-- in the data directory, since inside a .game they are read only.
-- A game to start from: ground, a hero that runs and jumps, and a readout. Enough to press play -- A game to start from: ground, a hero that runs and jumps, and a readout. Enough to press play
-- on, which is the point; everything in it can be changed or deleted. -- on, which is the point; everything in it can be changed or deleted.
@ -4637,22 +4706,7 @@ function forgeTemplate()
end end
-- Whether a .game file is a packed game rather than a description: the database's own header. -- Every .forge description in a directory, sorted. A directory that is not there lists nothing.
local function packedGame(path)
local file = io.open(path, "rb")
local head
if file == nil then
return false
end
head = file:read(15)
file:close()
return head == "SQLite format 3"
end
-- Every .game description in a directory, sorted. A directory that is not there lists nothing.
local function descriptionsIn(directory) local function descriptionsIn(directory)
local found = {} local found = {}
@ -4660,10 +4714,8 @@ local function descriptionsIn(directory)
return found return found
end end
for name in lfs.dir(directory) do for name in lfs.dir(directory) do
-- A description is Lua text; a packed game is a database with the same extension, and one if string.sub(name, -6) == ".forge" then
-- released beside the descriptions is not offered as one. found[#found + 1] = { name = string.sub(name, 1, -7), path = directory .. name }
if (string.sub(name, -5) == ".game") and not packedGame(directory .. name) then
found[#found + 1] = { name = string.sub(name, 1, -6), path = directory .. name }
end end
end end
table.sort(found, function(a, b) return a.name < b.name end) table.sort(found, function(a, b) return a.name < b.name end)
@ -4696,14 +4748,14 @@ local function freshPath(stem)
local base = singeGetDataPath() .. stem local base = singeGetDataPath() .. stem
local n = 1 local n = 1
if lfs.attributes(base .. ".game") == nil then if lfs.attributes(base .. ".forge") == nil then
return base .. ".game" return base .. ".forge"
end end
while lfs.attributes(base .. n .. ".game") ~= nil do while lfs.attributes(base .. n .. ".forge") ~= nil do
n = n + 1 n = n + 1
end end
return base .. n .. ".game" return base .. n .. ".forge"
end end

View file

@ -1,5 +1,167 @@
-- Written by Forge/AuthorCompile.singe. Edit by hand or in the editor; either is fine. -- Written by Forge/AuthorCompile.singe. Edit by hand or in the editor; either is fine.
return { return {
layers = {
{ fov = 60, kind = "scene3d" },
{ kind = "overlay" }
},
rooms = {
{
entities = {
{ id = "readout", type = "readout", x = 12, y = 12 },
{ id = "floor", type = "floor", x = 0.0, y = -0.1, z = -20.0 },
{ id = "painting", type = "painting", x = 0.0, y = 6.0, z = -30.0 },
{ id = "pillar", type = "pillar", x = 0.0, y = 4.0, z = -22.0 },
{ id = "camera", type = "camera", x = 0.0, y = 1.6, z = 0.0 },
{ id = "gun", type = "gun", x = 0.0, y = 0.0, z = 0.0 }
},
name = "start",
tracks = {
{
key = "time",
name = "rail",
points = {
{
at = 0,
look = { 0, 1.2, -10 },
x = 0,
y = 1.6,
z = 0
},
{
at = 4,
look = { 0, 1.2, -16 },
stop = "gate",
x = 0,
y = 1.6,
z = -6
},
{
at = 10,
look = { 2, 1.2, -24 },
stop = "pillar",
x = 2,
y = 1.6,
z = -14
},
{
at = 14,
look = { 2, 1.2, -28 },
x = 2,
y = 1.6,
z = -18
}
}
}
}
}
},
rules = {
{
act = {
{ "setText", entity = "readout", text = "\"score \" .. score .. \" health \" .. health .. \" ammo \" .. gun.ammo" }
},
note = "The readout, every frame",
on = "frame"
},
{
act = {
{ "spawn", type = "zombie", x = -2, y = 0, z = -14 },
{ "spawn", type = "zombie", x = 2, y = 0, z = -15 }
},
name = "gate",
note = "The gate: two come out of the dark",
on = "stopped",
when = {}
},
{
act = {
{ "spawn", type = "zombie", x = -1, y = 0, z = -23 },
{ "spawn", type = "zombie", x = 0, y = 0, z = -26 },
{ "spawn", type = "zombie", x = 4, y = 0, z = -24 }
},
name = "pillar",
note = "The pillar: three more, one from behind it",
on = "stopped",
when = {}
},
{
act = {
{ "setState", entity = "self", state = "walk" }
},
note = "A zombie walks the moment it is made",
on = "spawn",
type = "zombie",
when = {}
},
{
act = {
{ "damage", amount = 1, entity = "self" },
{ "addScore", amount = 100 },
{ "playSound", file = "Forge/kit/hit.wav" }
},
note = "A shot lands",
on = "hit",
type = "zombie",
when = {}
},
{
act = {
{ "setState", entity = "self", state = "attack" },
{ "timerStart", after = 1.5, entity = "self", name = "bite" }
},
note = "It reaches the camera and rears up",
on = "arrived",
type = "zombie",
when = {}
},
{
act = {
{ "addVar", amount = -1, name = "health" },
{ "flash", b = 40, g = 40, r = 255, seconds = 0.3 },
{ "shake", amount = 0.3, seconds = 0.4 },
{ "timerStart", after = 1.5, entity = "self", name = "bite" }
},
name = "bite",
note = "The bite lands unless it was killed in time",
on = "timer",
type = "zombie",
when = {
{ "inState", entity = "self", state = "attack" }
}
},
{
act = {
{ "pathNext", entity = "camera" }
},
note = "The stop is clear: move on",
on = "frame",
when = {
{ "waiting", entity = "camera" },
{ "test", expr = "count(\"zombie\") == 0" }
}
},
{
act = {
{ "say", seconds = 2, text = "\"YOU ARE DEAD\"" },
{ "gameOver" }
},
note = "Out of health",
on = "frame",
when = {
{ "test", expr = "health <= 0" },
{ "once", scope = "game", tag = "over" }
}
},
{
act = {
{ "say", seconds = 2, text = "\"STAGE CLEAR\"" }
},
note = "The end of the rail",
on = "railEnd",
when = {}
}
},
title = "The Dead Yard",
types = { types = {
camera = { camera = {
behaviours = { behaviours = {
@ -47,169 +209,5 @@ return {
vars = { health = 2 } vars = { health = 2 }
} }
}, },
layers = {
{ fov = 60, kind = "scene3d" },
{ kind = "overlay" }
},
rooms = {
{
entities = {
{ id = "readout", type = "readout", x = 12, y = 12 },
{ id = "floor", type = "floor", x = 0.0, y = -0.1, z = -20.0 },
{ id = "painting", type = "painting", x = 0.0, y = 6.0, z = -30.0 },
{ id = "pillar", type = "pillar", x = 0.0, y = 4.0, z = -22.0 },
{ id = "camera", type = "camera", x = 0.0, y = 1.6, z = 0.0 },
{ id = "gun", type = "gun", x = 0.0, y = 0.0, z = 0.0 }
},
name = "start",
tracks = {
{
key = "time",
name = "rail",
points = {
{
at = 0,
look = { 0, 1.2, -10 },
stop = "nil",
x = 0,
y = 1.6,
z = 0
},
{
at = 4,
look = { 0, 1.2, -16 },
stop = "gate",
x = 0,
y = 1.6,
z = -6
},
{
at = 10,
look = { 2, 1.2, -24 },
stop = "pillar",
x = 2,
y = 1.6,
z = -14
},
{
at = 14,
look = { 2, 1.2, -28 },
stop = "nil",
x = 2,
y = 1.6,
z = -18
}
}
}
}
}
},
rules = {
{
act = {
{ "setText", entity = "readout", text = "\"score \" .. score .. \" health \" .. health .. \" ammo \" .. gun.ammo" }
},
note = "The readout, every frame",
on = "frame"
},
{
act = {
{ "spawn", type = "zombie", x = -2, y = 0, z = -14 },
{ "spawn", type = "zombie", x = 2, y = 0, z = -15 }
},
name = "gate",
note = "The gate: two come out of the dark",
on = "stopped",
when = {}
},
{
act = {
{ "spawn", type = "zombie", x = -1, y = 0, z = -23 },
{ "spawn", type = "zombie", x = 0, y = 0, z = -26 },
{ "spawn", type = "zombie", x = 4, y = 0, z = -24 }
},
name = "pillar",
note = "The pillar: three more, one from behind it",
on = "stopped",
when = {}
},
{
act = {
{ "setState", entity = "self", state = "walk" }
},
type = "zombie",
note = "A zombie walks the moment it is made",
on = "spawn",
when = {}
},
{
act = {
{ "damage", amount = 1, entity = "self" },
{ "addScore", amount = 100 },
{ "playSound", file = "Forge/kit/hit.wav" }
},
type = "zombie",
note = "A shot lands",
on = "hit",
when = {}
},
{
act = {
{ "setState", entity = "self", state = "attack" },
{ "timerStart", after = 1.5, entity = "self", name = "bite" }
},
type = "zombie",
note = "It reaches the camera and rears up",
on = "arrived",
when = {}
},
{
act = {
{ "addVar", amount = -1, name = "health" },
{ "flash", b = 40, g = 40, r = 255, seconds = 0.3 },
{ "shake", amount = 0.3, seconds = 0.4 },
{ "timerStart", after = 1.5, entity = "self", name = "bite" }
},
type = "zombie",
name = "bite",
note = "The bite lands unless it was killed in time",
on = "timer",
when = {
{ "inState", entity = "self", state = "attack" }
}
},
{
act = {
{ "pathNext", entity = "camera" }
},
note = "The stop is clear: move on",
on = "frame",
when = {
{ "waiting", entity = "camera" },
{ "test", expr = "count(\"zombie\") == 0" }
}
},
{
act = {
{ "say", seconds = 2, text = "\"YOU ARE DEAD\"" },
{ "gameOver" }
},
note = "Out of health",
on = "frame",
when = {
{ "test", expr = "health <= 0" },
{ "once", scope = "game", tag = "over" }
}
},
{
act = {
{ "say", seconds = 2, text = "\"STAGE CLEAR\"" }
},
note = "The end of the rail",
on = "railEnd",
when = {}
}
},
title = "The Dead Yard",
vars = { health = 3, score = 0 } vars = { health = 3, score = 0 }
} }

View file

@ -32,10 +32,18 @@
</div> </div>
</div> </div>
<!-- Every service tool draws through this one page: Singe/Tools.singe hands the menu a list of <!-- Every service tool draws through this one page: Singe/Tools.singe hands the menu a list of
rows and Singe/MenuDocument.singe turns them into the divs below it. --> rows and Singe/MenuDocument.singe turns them into the divs below it -- the rows into the
body, the key hints into the footer, and on the list of tools the chosen tool's note into
the column beside the list. -->
<div id="tool" class="tool hidden"> <div id="tool" class="tool hidden">
<h1 id="toolTitle"></h1> <div id="toolCard" class="card">
<div id="toolBody"></div> <h1 id="toolTitle"></h1>
<div id="toolColumns" class="columns">
<div id="toolBody"></div>
<div id="toolNote" class="hidden"></div>
</div>
<div id="toolKeys"></div>
</div>
</div> </div>
</body> </body>
</rml> </rml>

View file

@ -177,7 +177,9 @@ local function toolRow(row)
elseif row.kind == "keys" then elseif row.kind == "keys" then
return "<p class='keys'>" .. menuSpaces(row.text) .. "</p>" return "<p class='keys'>" .. menuSpaces(row.text) .. "</p>"
elseif row.kind == "item" then elseif row.kind == "item" then
return string.format("<div class='%s'>%s</div>", row.selected and "item selected" or "item", menuSpaces(row.text)) local detail = row.detail and ("<span class='detail'>" .. menuSpaces(tostring(row.detail)) .. "</span>") or ""
return string.format("<div class='%s'><span class='text'>%s</span>%s</div>", row.selected and "item selected" or "item", menuSpaces(row.text), detail)
elseif row.kind == "blank" then elseif row.kind == "blank" then
return "<div class='blank'></div>" return "<div class='blank'></div>"
end end
@ -377,6 +379,11 @@ MENU_RENDER.tools = function()
-- of them quietly showing more. The style sheet can scroll this box, but nothing drives a -- of them quietly showing more. The style sheet can scroll this box, but nothing drives a
-- scroll bar from a joystick: windowing is what actually keeps the chosen row on screen. -- scroll bar from a joystick: windowing is what actually keeps the chosen row on screen.
local visible, more, below = toolsVisible(TOOL_ROWS, toolsCapacity()) local visible, more, below = toolsVisible(TOOL_ROWS, toolsCapacity())
-- The key hints go to the card's footer and a note to the column beside the list;
-- everything else is the body.
local keys = {}
local note = {}
if more then if more then
rows[#rows + 1] = "<div class='sub'>...</div>" rows[#rows + 1] = "<div class='sub'>...</div>"
end end
@ -390,13 +397,22 @@ MENU_RENDER.tools = function()
if row.kind == "item" then if row.kind == "item" then
seenItem = true seenItem = true
end end
rows[#rows + 1] = toolRow(row) if row.kind == "keys" then
keys[#keys + 1] = toolRow(row)
elseif row.kind == "note" then
note[#note + 1] = toolRow(row)
else
rows[#rows + 1] = toolRow(row)
end
end end
if below then if below then
rows[#rows + 1] = "<div class='sub'>...</div>" rows[#rows + 1] = "<div class='sub'>...</div>"
end end
guiSetValue(GUI, DOCUMENT, "toolTitle", menuEscape(TOOL_TITLE)) guiSetValue(GUI, DOCUMENT, "toolTitle", menuEscape(TOOL_TITLE))
guiSetValue(GUI, DOCUMENT, "toolBody", table.concat(rows)) guiSetValue(GUI, DOCUMENT, "toolBody", table.concat(rows))
guiSetValue(GUI, DOCUMENT, "toolNote", table.concat(note))
guiSetValue(GUI, DOCUMENT, "toolKeys", table.concat(keys))
menuElement("toolNote"):SetClass("hidden", #note == 0)
menuElement("games"):SetClass("hidden", toolsActive()) menuElement("games"):SetClass("hidden", toolsActive())
menuElement("tool"):SetClass("hidden", not toolsActive()) menuElement("tool"):SetClass("hidden", not toolsActive())
menuElement("tool"):SetClass("clear", TOOL_CLEAR) menuElement("tool"):SetClass("clear", TOOL_CLEAR)

View file

@ -36,8 +36,6 @@
-- whoever does have a keyboard. -- whoever does have a keyboard.
local MARGIN_X = 25
local MARGIN_Y = 25
local MARGIN = 10 -- Around the page, and between the regions on it. local MARGIN = 10 -- Around the page, and between the regions on it.
local GAP = 8 local GAP = 8
local HINT_H = 24 -- The line of key hints along the bottom. local HINT_H = 24 -- The line of key hints along the bottom.
@ -49,6 +47,24 @@ local VIDEO_ASPECT_H = 200
local LINE_HEIGHT = 24 local LINE_HEIGHT = 24
local LABEL_W = 150 local LABEL_W = 150
local SUB_INDENT = 20 local SUB_INDENT = 20
local CARD_PAD = 14 -- The card the tools draw on: inside its border to its text,
local TITLE_H = 36 -- its title bar, and its footer of key hints, one line each plus
local KEY_LINE = 18 -- its padding. The colours are the game page's: the panel, the
local KEY_PAD = 6 -- selection's gradient, the blue-grey border, the gold, the quiet
local BORDER = 2 -- blue-grey of a label.
local ITEM_INSET = 8
local CLEAR_FRAC = 0.70 -- How wide a strip a page that lets the overlay through may take.
local NOTE_FRAC = 0.42 -- How much of the card a note beside the list takes.
local PANEL = { 24, 38, 64, 216 }
local PANEL_DEEP = { 12, 20, 36, 216 }
local FOOT = { 16, 24, 40, 208 }
local TITLE_BAR = { 51, 80, 122, 255 }
local TITLE_BAR_L = { 74, 110, 168, 255 }
local EDGE = { 106, 134, 184, 255 }
local GOLD = { 255, 211, 90, 255 }
local INK = { 240, 240, 240, 255 }
local QUIET = { 143, 162, 196, 255 }
local SOFT = { 200, 210, 230, 255 }
local PAGE_ROWS = 10 local PAGE_ROWS = 10
local WRAP_BREAK = " [!wb!] " local WRAP_BREAK = " [!wb!] "
local HINTS = "Up / Down: select Left / Right: page PgUp / PgDn: details Start or button: play Service: tools" local HINTS = "Up / Down: select Left / Right: page PgUp / PgDn: details Start or button: play Service: tools"
@ -56,6 +72,7 @@ local HINTS = "Up / Down: select Left / Right: page PgUp / PgDn: det
local font = nil local font = nil
local hintFont = nil -- Smaller: the hints are a footnote, and at the body's size the line local hintFont = nil -- Smaller: the hints are a footnote, and at the body's size the line
-- of them ran off the end of a 720 pixel overlay. -- of them ran off the end of a 720 pixel overlay.
local titleFont = nil -- Larger: a tool's title on its bar.
local introDone = false local introDone = false
local videoX, videoY, videoW, videoH = 0, 0, 0, 0 local videoX, videoY, videoW, videoH = 0, 0, 0, 0
local marqueeX, marqueeY, marqueeW, marqueeH = 0, 0, 0, 0 local marqueeX, marqueeY, marqueeW, marqueeH = 0, 0, 0, 0
@ -146,6 +163,7 @@ end
-- rather than values because the audio delay tool inverts the whole page for a frame, and fontPrint -- rather than values because the audio delay tool inverts the whole page for a frame, and fontPrint
-- takes the colour at the moment it draws -- unlike fontToSprite, which bakes it in. -- takes the colour at the moment it draws -- unlike fontToSprite, which bakes it in.
local toolLines = {} local toolLines = {}
local bodyX, bodyW = 0, 0 -- The card's body, where wrapped text is measured against.
local function toolLine(pieces) local function toolLine(pieces)
@ -160,66 +178,120 @@ local function toolWrapped(text, x, role)
toolLine({}) toolLine({})
return return
end end
for _, line in ipairs(fitLines(text, overlayGetWidth() - MARGIN_X - x)) do for _, line in ipairs(fitLines(text, bodyX + bodyW - x)) do
toolLine({ { x = x, text = line, role = role } }) toolLine({ { x = x, text = line, role = role } })
end end
end end
-- The card the tools draw on: its edges, and where its body and its columns begin. A page that
-- lets the overlay through has no card; its strip is measured once its lines are known.
local toolKeyLines = {} -- The key hints, for the card's footer.
local toolNoteLines = {} -- What the chosen tool is for, beside the list of tools.
local cardX, cardY, cardW, cardH = 0, 0, 0, 0
local noteX, noteW = 0, 0
local function toolNote(pieces)
toolNoteLines[#toolNoteLines + 1] = pieces
end
local function toolsLayout() local function toolsLayout()
local valueX = MARGIN_X + LABEL_W local width = overlayGetWidth()
local wasKeys = false local height = overlayGetHeight()
-- Only the rows that fit, with the chosen one kept in view; a catalogue is as long as the -- Only the rows that fit, with the chosen one kept in view; a catalogue is as long as the
-- service is big and would otherwise run off the bottom of the screen. -- service is big and would otherwise run off the bottom of the screen.
local rows, more, below = toolsVisible(TOOL_ROWS, toolsCapacity()) local rows, more, below = toolsVisible(TOOL_ROWS, toolsCapacity())
toolLines = {} cardX, cardY, cardW, cardH = MARGIN, MARGIN, width - MARGIN * 2, height - MARGIN * 2
if TOOL_CLEAR then
cardW = math.floor(width * CLEAR_FRAC)
cardX = math.floor((width - cardW) / 2)
end
bodyX = cardX + BORDER + CARD_PAD
bodyW = cardW - (BORDER + CARD_PAD) * 2
local fullW = bodyW
-- A page with a note has a second column for it beside the list.
noteX, noteW = nil, 0
for _, row in ipairs(rows) do
if row.kind == "note" then
noteW = math.floor(cardW * NOTE_FRAC) - (BORDER + CARD_PAD) * 2
noteX = cardX + cardW - BORDER - CARD_PAD - noteW
bodyW = noteX - CARD_PAD - BORDER - bodyX
end
end
local valueX = bodyX + LABEL_W
toolLines = {}
toolKeyLines = {}
toolNoteLines = {}
if more then if more then
toolLine({ { x = MARGIN_X, text = " ...", role = "quiet" } }) toolLine({ { x = bodyX, text = " ...", role = "quiet" } })
end end
local seenItem = false local seenItem = false
for _, row in ipairs(rows) do for _, row in ipairs(rows) do
-- The "more below" marker belongs immediately under the last item, not at the very bottom -- The "more below" marker belongs immediately under the last item, not at the very bottom
-- of the page under the key hints, which are not part of the list. -- of the page under the key hints, which are not part of the list.
if below and seenItem and row.kind ~= "item" then if below and seenItem and row.kind ~= "item" then
toolLine({ { x = MARGIN_X, text = " ...", role = "quiet" } }) toolLine({ { x = bodyX, text = " ...", role = "quiet" } })
below = false below = false
end end
if row.kind == "item" then if row.kind == "item" then
seenItem = true seenItem = true
end end
-- The key hints stand away from whatever the tool put above them, as they do in the
-- document, but sit together when a tool needs more than one line of them.
if row.kind == "keys" and not wasKeys then
toolLine({})
end
wasKeys = (row.kind == "keys")
if row.kind == "blank" then if row.kind == "blank" then
toolLine({}) toolLine({})
elseif row.kind == "pair" then elseif row.kind == "pair" then
-- The label and the first line of the value share a line; the rest of the value wraps -- The label and the first line of the value share a line; the rest of the value wraps
-- under itself, in its own column. -- under itself, in its own column.
local lines = fitLines(row.value, overlayGetWidth() - MARGIN_X - valueX) local lines = fitLines(row.value, bodyX + bodyW - valueX)
toolLine({ { x = MARGIN_X, text = row.label, role = "quiet" }, toolLine({ { x = bodyX, text = row.label, role = "quiet" },
{ x = valueX, text = lines[1] or "", role = "ink" } }) { x = valueX, text = lines[1] or "", role = "ink" } })
for index = 2, #lines do for index = 2, #lines do
toolLine({ { x = valueX, text = lines[index], role = "ink" } }) toolLine({ { x = valueX, text = lines[index], role = "ink" } })
end end
elseif row.kind == "sub" then elseif row.kind == "sub" then
toolWrapped(row.text, MARGIN_X + SUB_INDENT, "quiet") for _, line in ipairs(fitLines(row.text, bodyW - SUB_INDENT)) do
toolLine({ { x = bodyX + SUB_INDENT, text = line, role = "soft" } })
end
elseif row.kind == "keys" then elseif row.kind == "keys" then
toolWrapped(row.text, MARGIN_X, "quiet") -- The footer, in the smaller font, across the whole card.
fontSelect(hintFont)
for _, line in ipairs(fitLines(row.text, fullW)) do
toolKeyLines[#toolKeyLines + 1] = line
end
fontSelect(font)
elseif row.kind == "item" then elseif row.kind == "item" then
toolLine({ { x = MARGIN_X, text = (row.selected and "> " or " ") .. row.text, local pieces = { { x = bodyX + ITEM_INSET, text = row.text, role = row.selected and "pick" or "ink", bar = row.selected } }
role = row.selected and "pick" or "ink" } })
-- The detail against the right edge of the row, so a column of them lines up. An
-- empty one -- a field not filled in yet -- is nothing to measure or draw.
local detail = row.detail and tostring(row.detail) or ""
if detail ~= "" then
local measured = fontToSprite(detail)
pieces[#pieces + 1] = { x = bodyX + bodyW - ITEM_INSET - spriteGetWidth(measured), text = detail, role = pieces[1].role }
spriteUnload(measured)
end
toolLine(pieces)
elseif row.kind == "note" then
for _, line in ipairs(fitLines(row.text, noteW)) do
toolNote({ { x = noteX, text = line, role = "soft" } })
end
else else
toolWrapped(row.text, MARGIN_X, "ink") toolWrapped(row.text, bodyX, "ink")
end end
end end
-- A list that runs to the very end of the page has no row after it to hang the marker on. -- A list that runs to the very end of the page has no row after it to hang the marker on.
if below then if below then
toolLine({ { x = MARGIN_X, text = " ...", role = "quiet" } }) toolLine({ { x = bodyX, text = " ...", role = "quiet" } })
end
-- A strip along the bottom is as tall as its lines.
if TOOL_CLEAR then
cardH = BORDER * 2 + TITLE_H + CARD_PAD + #toolLines * LINE_HEIGHT + CARD_PAD + KEY_PAD * 2 + #toolKeyLines * KEY_LINE
cardY = height - MARGIN - cardH
end end
end end
@ -228,22 +300,21 @@ MENU_RENDER = {}
MENU_RENDER.begin = function() MENU_RENDER.begin = function()
font = fontLoad("Singe/FreeSansBold.ttf", 18) font = fontLoad("Singe/FreeSansBold.ttf", 18)
hintFont = fontLoad("Singe/FreeSansBold.ttf", 13) hintFont = fontLoad("Singe/FreeSansBold.ttf", 13)
titleFont = fontLoad("Singe/FreeSansBold.ttf", 22)
fontQuality(FONT_QUALITY_BLENDED) fontQuality(FONT_QUALITY_BLENDED)
fontSelect(font) fontSelect(font)
end end
MENU_RENDER.finish = function() MENU_RENDER.finish = function()
if font then for _, handle in ipairs({ font, hintFont, titleFont }) do
fontUnload(font) if handle then
font = nil fontUnload(handle)
end end
if hintFont then
fontUnload(hintFont)
hintFont = nil
end end
font, hintFont, titleFont = nil, nil, nil
end end
@ -489,17 +560,45 @@ MENU_RENDER.frame = function()
end end
MENU_RENDER.tools = function() -- A filled rectangle: the overlay has no fill of its own, so it is lines, one a row. The colour
local y = MARGIN_Y -- carries its alpha, which is what makes the card a panel over the moving grid and not a slab.
-- Given a second colour it runs from the first at the top to the second at the bottom: every
-- row is its own line already, so the gradient costs nothing more.
local function fillRect(x, y, w, h, colour, toColour)
local last = math.max(1, h - 1)
-- A tool that draws its own picture into the overlay has already done so, and the page has to for row = 0, h - 1 do
-- let it through; every other tool gets a page painted over everything. if toColour then
if not TOOL_CLEAR then local t = row / last
if TOOL_FLASH then
colorBackground(255, 255, 255, 255) colorForeground(math.floor(colour[1] + (toColour[1] - colour[1]) * t + 0.5),
else math.floor(colour[2] + (toColour[2] - colour[2]) * t + 0.5),
colorBackground(0, 0, 0, 255) math.floor(colour[3] + (toColour[3] - colour[3]) * t + 0.5),
math.floor(colour[4] + (toColour[4] - colour[4]) * t + 0.5))
elseif row == 0 then
colorForeground(colour[1], colour[2], colour[3], colour[4])
end end
overlayLine(x, y + row, x + w - 1, y + row)
end
end
local function ink(colour)
colorForeground(colour[1], colour[2], colour[3], colour[4])
end
-- The page: the card with its title bar, the rows in their columns, the chosen row on its bar,
-- and the key hints in the footer. A page that draws its own picture gets a strip along the
-- bottom instead; the audio delay tool's flash is the whole screen white and nothing else.
MENU_RENDER.tools = function()
if TOOL_FLASH then
colorBackground(255, 255, 255, 255)
overlayClear()
return
end
if not TOOL_CLEAR then
colorBackground(0, 0, 0, 0)
overlayClear() overlayClear()
end end
@ -509,29 +608,43 @@ MENU_RENDER.tools = function()
toolsLayout() toolsLayout()
end end
local ink = TOOL_FLASH and 0 or 255 local footH = KEY_PAD * 2 + #toolKeyLines * KEY_LINE
local quiet = TOOL_FLASH and 90 or 160 local footY = cardY + cardH - BORDER - footH
local y = cardY + BORDER + TITLE_H + CARD_PAD
-- A page that lets the overlay through gathers its text along the bottom, out of the way of -- The card: the panel, its border twice for the width, the title bar, and the footer.
-- whatever the tool drew: the light gun's corner targets in particular. fillRect(cardX, cardY, cardW, cardH, PANEL, PANEL_DEEP)
if TOOL_CLEAR then fillRect(cardX + BORDER, cardY + BORDER, cardW - BORDER * 2, TITLE_H, TITLE_BAR_L, TITLE_BAR)
y = overlayGetHeight() - MARGIN_Y - (#toolLines + 1) * LINE_HEIGHT fillRect(cardX + BORDER, cardY + BORDER + TITLE_H, cardW - BORDER * 2, BORDER, EDGE)
if #toolKeyLines > 0 then
fillRect(cardX + BORDER, footY, cardW - BORDER * 2, footH, FOOT)
fillRect(cardX + BORDER, footY - BORDER, cardW - BORDER * 2, BORDER, EDGE)
end end
colorForeground(ink, ink, ink, 255) ink(EDGE)
fontPrint(MARGIN_X, y, TOOL_TITLE) overlayBox(cardX, cardY, cardX + cardW - 1, cardY + cardH - 1)
y = y + LINE_HEIGHT overlayBox(cardX + 1, cardY + 1, cardX + cardW - 2, cardY + cardH - 2)
if not TOOL_CLEAR then if noteX then
y = y + LINE_HEIGHT fillRect(noteX - CARD_PAD - BORDER, cardY + BORDER + TITLE_H + BORDER, BORDER, footY - BORDER - (cardY + BORDER + TITLE_H + BORDER), EDGE)
end end
fontSelect(titleFont)
ink(GOLD)
fontPrint(cardX + BORDER + CARD_PAD, cardY + BORDER + math.floor((TITLE_H - 26) / 2), TOOL_TITLE)
fontSelect(font)
for _, line in ipairs(toolLines) do for _, line in ipairs(toolLines) do
for _, piece in ipairs(line) do for _, piece in ipairs(line) do
if piece.bar then
fillRect(bodyX, y - 3, bodyW, LINE_HEIGHT - 2, TITLE_BAR_L, TITLE_BAR)
end
if piece.role == "quiet" then if piece.role == "quiet" then
colorForeground(quiet, quiet, quiet, 255) ink(QUIET)
elseif piece.role == "soft" then
ink(SOFT)
elseif piece.role == "pick" then elseif piece.role == "pick" then
colorForeground(255, 211, 90, 255) ink(GOLD)
else else
colorForeground(ink, ink, ink, 255) ink(INK)
end end
-- fontPrint refuses an empty string, and a tool legitimately produces one: a status -- fontPrint refuses an empty string, and a tool legitimately produces one: a status
-- line with nothing to say yet is still a line, and it still takes up its height. -- line with nothing to say yet is still a line, and it still takes up its height.
@ -541,4 +654,25 @@ MENU_RENDER.tools = function()
end end
y = y + LINE_HEIGHT y = y + LINE_HEIGHT
end end
y = cardY + BORDER + TITLE_H + CARD_PAD
for _, line in ipairs(toolNoteLines) do
for _, piece in ipairs(line) do
ink(SOFT)
if piece.text ~= "" then
fontPrint(piece.x, y, piece.text)
end
end
y = y + LINE_HEIGHT
end
fontSelect(hintFont)
ink(QUIET)
y = footY + KEY_PAD
for _, line in ipairs(toolKeyLines) do
if line ~= "" then
fontPrint(bodyX, y, line)
end
y = y + KEY_LINE
end
fontSelect(font)
end end

View file

@ -28,7 +28,7 @@
-- tool leaves out simply does not happen. -- tool leaves out simply does not happen.
-- --
-- Nothing here draws. A tool builds a list of rows with the toolsText, toolsPair, toolsSub, -- Nothing here draws. A tool builds a list of rows with the toolsText, toolsPair, toolsSub,
-- toolsKeys, toolsItem and toolsBlank constructors and hands it to toolsShow; the menu that loaded -- toolsKeys, toolsNote, toolsItem, and toolsBlank constructors and hands it to toolsShow; the menu that loaded
-- this file draws it whichever way it draws everything else. That is what lets the same ten tools -- this file draws it whichever way it draws everything else. That is what lets the same ten tools
-- run over the RmlUi document and over the plain overlay on a machine with no GPU. -- run over the RmlUi document and over the plain overlay on a machine with no GPU.
@ -78,8 +78,17 @@ function toolsKeys(text)
end end
function toolsItem(text, selected) -- A note beside the page's list rather than under it: what the chosen tool is for.
return { kind = "item", text = text, selected = selected } function toolsNote(text)
return { kind = "note", text = text }
end
-- A line to choose between, with an optional detail the menu sets against the right edge of the
-- row -- a size, a state, a value. Never padded into the text with spaces: the menu's fonts are
-- proportional and nothing lines up that way.
function toolsItem(text, selected, detail)
return { kind = "item", text = text, selected = selected, detail = detail }
end end
@ -96,11 +105,11 @@ function toolsShow(rows)
end end
-- How many rows of a page fit on screen. -- How many rows of a page's body fit on screen.
-- --
-- Derived from the overlay, which is the size both renderers draw into, so the two show the same -- Derived from the overlay, which is the size both renderers draw into, so the two show the same
-- thing rather than one of them quietly showing more. The subtraction is the title and the couple -- thing rather than one of them quietly showing more. The subtraction is the card around the
-- of lines of key hints that live below every list. -- body: its margins, its title bar, and the footer of key hints under every page.
function toolsCapacity() function toolsCapacity()
return math.max(4, math.floor(overlayGetHeight() / TOOL_LINE_HEIGHT) - TOOL_CHROME_LINES) return math.max(4, math.floor(overlayGetHeight() / TOOL_LINE_HEIGHT) - TOOL_CHROME_LINES)
end end
@ -126,14 +135,21 @@ function toolsVisible(rows, capacity)
end end
end end
end end
-- The key hints and a note are drawn beside the body, not in it, so they take none of its room.
local body = 0
for _, row in ipairs(rows) do
if row.kind ~= "keys" and row.kind ~= "note" then
body = body + 1
end
end
-- Nothing to scroll: a page with no list, or one whose list already fits. -- Nothing to scroll: a page with no list, or one whose list already fits.
if #items == 0 or #rows <= capacity then if #items == 0 or body <= capacity then
return rows, false, false return rows, false, false
end end
-- Keep the chosen row in the middle of the window where there is room either side of it, so -- Keep the chosen row in the middle of the window where there is room either side of it, so
-- moving down does not pin it to the last line. -- moving down does not pin it to the last line.
local room = math.max(1, capacity - (#rows - #items)) local room = math.max(1, capacity - (body - #items))
local first = math.max(1, math.min((chosen or 1) - math.floor(room / 2), #items - room + 1)) local first = math.max(1, math.min((chosen or 1) - math.floor(room / 2), #items - room + 1))
local last = math.min(#items, first + room - 1) local last = math.min(#items, first + room - 1)
@ -191,8 +207,7 @@ function toolsShowList()
for i, tool in ipairs(TOOLS) do for i, tool in ipairs(TOOLS) do
rows[#rows + 1] = toolsItem(tool.name, i == TOOL_SELECTED) rows[#rows + 1] = toolsItem(tool.name, i == TOOL_SELECTED)
end end
rows[#rows + 1] = toolsBlank() rows[#rows + 1] = toolsNote(TOOLS[TOOL_SELECTED].help or "")
rows[#rows + 1] = toolsText(TOOLS[TOOL_SELECTED].help or "")
rows[#rows + 1] = toolsKeys("Up / Down: choose Button 1: open Service: back to the games") rows[#rows + 1] = toolsKeys("Up / Down: choose Button 1: open Service: back to the games")
toolsShow(rows) toolsShow(rows)
end end
@ -414,7 +429,8 @@ TOOLS[#TOOLS + 1] = {
update = function() update = function()
local rows = {} local rows = {}
rows[#rows + 1] = toolsPair("Last switch", string.format("%s count: %d", inputLast, inputCount)) rows[#rows + 1] = toolsPair("Last switch", inputLast)
rows[#rows + 1] = toolsPair("Count", inputCount)
rows[#rows + 1] = toolsPair("Devices", string.format("%d gamepads, %d mice, stick drives mouse: %s", rows[#rows + 1] = toolsPair("Devices", string.format("%d gamepads, %d mice, stick drives mouse: %s",
controllerHowMany(), mouseHowMany(), tostring(joyMouseIsEnabled()))) controllerHowMany(), mouseHowMany(), tostring(joyMouseIsEnabled())))
for slot = 0, SINGE_MAX_CONTROLLERS - 1 do for slot = 0, SINGE_MAX_CONTROLLERS - 1 do
@ -432,8 +448,8 @@ TOOLS[#TOOLS + 1] = {
end end
end end
rows[#rows + 1] = toolsText(string.format("%d: %s", slot, controllerGetName(slot))) rows[#rows + 1] = toolsText(string.format("%d: %s", slot, controllerGetName(slot)))
rows[#rows + 1] = toolsSub(string.format("axes %s buttons %s", rows[#rows + 1] = toolsSub("axes " .. table.concat(axes, ", "))
table.concat(axes, ", "), (#buttons > 0) and table.concat(buttons, " ") or "-")) rows[#rows + 1] = toolsSub("buttons " .. ((#buttons > 0) and table.concat(buttons, " ") or "-"))
end end
end end
for m = 0, mouseHowMany() - 1 do for m = 0, mouseHowMany() - 1 do
@ -494,7 +510,8 @@ local gunLast = "none yet"
local function gunShow() local function gunShow()
toolsShow({ toolsShow({
toolsPair("Last shot", string.format("%s shots shown: %d", gunLast, #gunShots)), toolsPair("Last shot", gunLast),
toolsPair("Shots shown", #gunShots),
toolsKeys("Button 1: fire Start: clear Button 2: back"), toolsKeys("Button 1: fire Start: clear Button 2: back"),
}) })
end end
@ -642,8 +659,9 @@ local dispSaved = ""
local function displayShow() local function displayShow()
local x, y = vldpGetShift() local x, y = vldpGetShift()
toolsShow({ toolsShow({
toolsPair("Picture", string.format("scale %d%% shift %d, %d rotation %d", toolsPair("Scale", vldpGetScale() .. "%"),
vldpGetScale(), x, y, vldpGetRotate())), toolsPair("Shift", string.format("%d, %d", x, y)),
toolsPair("Rotation", vldpGetRotate()),
toolsPair("Kept", dispSaved), toolsPair("Kept", dispSaved),
toolsKeys("Arrows: move Button 3 / 4: size Coin 1: rotate Start: reset"), toolsKeys("Arrows: move Button 3 / 4: size Coin 1: rotate Start: reset"),
toolsKeys("Button 1: keep for this machine Button 2: back"), toolsKeys("Button 1: keep for this machine Button 2: back"),
@ -833,7 +851,7 @@ local function savedShow()
rows[#rows + 1] = toolsText("No game has saved anything yet.") rows[#rows + 1] = toolsText("No game has saved anything yet.")
end end
for i, entry in ipairs(saveDirs) do for i, entry in ipairs(saveDirs) do
rows[#rows + 1] = toolsItem(string.format("%s %d bytes", entry.name, entry.size), i == saveSelected) rows[#rows + 1] = toolsItem(entry.name, i == saveSelected, entry.size .. " bytes")
end end
rows[#rows + 1] = toolsKeys("Up / Down: choose Button 3: delete this game's save Button 2: back") rows[#rows + 1] = toolsKeys("Up / Down: choose Button 3: delete this game's save Button 2: back")
toolsShow(rows) toolsShow(rows)
@ -912,11 +930,12 @@ local function midiShow()
local rows = {} local rows = {}
rows[#rows + 1] = toolsPair("Ports", string.format("%d in, %d out", midiInputCount(), midiOutputCount())) rows[#rows + 1] = toolsPair("Ports", string.format("%d in, %d out", midiInputCount(), midiOutputCount()))
-- The port in use is starred.
for i = 0, midiOutputCount() - 1 do for i = 0, midiOutputCount() - 1 do
rows[#rows + 1] = toolsSub(string.format("out %d%s %s", i, (i == midiOut) and " *" or "", midiOutputName(i))) rows[#rows + 1] = toolsPair(string.format("Out %d%s", i, (i == midiOut) and " *" or ""), midiOutputName(i))
end end
for i = 0, midiInputCount() - 1 do for i = 0, midiInputCount() - 1 do
rows[#rows + 1] = toolsSub(string.format("in %d%s %s", i, (i == midiIn) and " *" or "", midiInputName(i))) rows[#rows + 1] = toolsPair(string.format("In %d%s", i, (i == midiIn) and " *" or ""), midiInputName(i))
end end
for _, m in ipairs(midiSeen) do for _, m in ipairs(midiSeen) do
rows[#rows + 1] = toolsSub("received " .. m) rows[#rows + 1] = toolsSub("received " .. m)
@ -1042,7 +1061,7 @@ local function accountShow()
if name == "server" and shown == "" then if name == "server" and shown == "" then
shown = MASTER.url shown = MASTER.url
end end
rows[#rows + 1] = toolsItem(string.format("%-9s %s", name, shown), index == accountField) rows[#rows + 1] = toolsItem(name, index == accountField, shown)
end end
rows[#rows + 1] = toolsBlank() rows[#rows + 1] = toolsBlank()
rows[#rows + 1] = toolsText(accountSaying) rows[#rows + 1] = toolsText(accountSaying)
@ -1198,7 +1217,7 @@ local function shopShow()
elseif held ~= nil then elseif held ~= nil then
state = (held >= game.version) and "installed" or ("update to " .. game.version) state = (held >= game.version) and "installed" or ("update to " .. game.version)
end end
rows[#rows + 1] = toolsItem(string.format("%-28s %s", game.title, state), index == shopSelected) rows[#rows + 1] = toolsItem(game.title, index == shopSelected, state)
end end
if #shopGames == 0 then if #shopGames == 0 then
rows[#rows + 1] = toolsText("Nothing listed yet.") rows[#rows + 1] = toolsText("Nothing listed yet.")

View file

@ -123,8 +123,10 @@ body {
display: none; display: none;
} }
/* The service tools: one opaque page over everything, the same for all of them. The audio delay /* The service tools: one page over everything, the backdrop still moving behind it, and on it
tool alone turns the page white for the frame of its flash, so the colours are named twice. */ a card in the game page's own dress -- the panel gradient, the blue-grey border, a title bar in
the selection's gradient with the title in the game list's gold, and the key hints in a footer
strip like the game page's. Nothing on it says black. */
.tool { .tool {
position: absolute; position: absolute;
left: 0; left: 0;
@ -132,60 +134,107 @@ body {
width: 100%; width: 100%;
height: 100%; height: 100%;
z-index: 1; z-index: 1;
padding: 25dp; padding: 10dp;
box-sizing: border-box; box-sizing: border-box;
background-color: #000000; display: flex;
color: #ffffff; flex-direction: column;
overflow-y: auto; color: #f0f0f0;
font-size: 15dp;
} }
.tool .card {
flex: 1 1 0;
display: flex;
flex-direction: column;
background-color: #0a1220d8;
decorator: linear-gradient(180deg, #182640d8, #0c1424d8);
border: 2dp #6a86b8;
border-radius: 6dp;
box-shadow: 0 6dp 14dp #00000090;
overflow: hidden;
}
/* The audio delay tool alone turns the whole screen white for the frame of its flash; the card
steps out of the way so the flash is the flash and nothing else. */
.tool.flash { .tool.flash {
background-color: #ffffff; background-color: #ffffff;
color: #000000; }
.tool.flash .card {
visibility: hidden;
} }
.tool h1 { .tool h1 {
font-size: 24dp; font-size: 20dp;
color: #ffffff; color: #ffd35a;
margin-bottom: 20dp; margin: 0;
padding: 8dp 14dp;
background-color: #33507a;
decorator: linear-gradient(90deg, #4a6ea8, #33507a);
border-bottom: 2dp #6a86b8;
} }
.tool.flash h1 { .tool .columns {
color: #000000; flex: 1 1 0;
display: flex;
flex-direction: row;
overflow: hidden;
}
/* The style sheet can scroll this box, but nothing drives a scroll bar from a joystick: the
windowing in Singe/Tools.singe is what keeps the chosen row on screen. */
.tool #toolBody {
flex: 1 1 0;
padding: 12dp 14dp;
overflow-y: auto;
}
/* The list of tools has a second column: what the chosen tool is for, beside the list. */
.tool #toolNote {
flex: 0 0 42%;
padding: 12dp 16dp;
border-left: 2dp #6a86b8;
color: #c8d2e6;
font-size: 14dp;
}
.tool #toolNote.hidden {
display: none;
}
.tool #toolKeys {
padding: 6dp 14dp;
border-top: 2dp #6a86b8;
background-color: #101828d0;
color: #8fa2c4;
font-size: 13dp;
} }
.tool p { .tool p {
margin-bottom: 20dp; margin: 0;
} }
.tool p.value { /* The key hints, close together when a tool needs more than one line of them. */
margin-bottom: 4dp;
}
/* The key hints stand away from whatever the tool put above them, and close together among
themselves when a tool needs more than one line of them. */
.tool p.keys { .tool p.keys {
margin-top: 20dp; margin: 0;
margin-bottom: 4dp;
color: #8fa2c4;
} }
.tool p.keys + p.keys { .tool p.keys + p.keys {
margin-top: 0; margin-top: 2dp;
} }
/* The rows a tool fills in for itself: a heading row and an indented detail row under it. Text /* The rows a tool fills in for itself: a heading row and an indented detail row under it. Text
with nowhere to break -- a long path -- wraps mid-word rather than running off the edge, but an with nowhere to break -- a long path -- wraps mid-word rather than running off the edge, but an
ordinary word is left whole. */ ordinary word is left whole. */
.tool .row { .tool .row {
margin-bottom: 2dp; margin-bottom: 4dp;
word-break: break-word; word-break: break-word;
} }
.tool .sub { .tool .sub {
margin-left: 20dp; margin-left: 20dp;
margin-bottom: 2dp; margin-bottom: 3dp;
font-size: 12dp; font-size: 13dp;
color: #c8d2e6; color: #c8d2e6;
word-break: break-word; word-break: break-word;
} }
@ -194,11 +243,11 @@ body {
for the line wraps under itself instead of under the label. */ for the line wraps under itself instead of under the label. */
.tool .pair { .tool .pair {
display: flex; display: flex;
margin-bottom: 2dp; margin-bottom: 4dp;
} }
.tool .pair .label { .tool .pair .label {
flex: 0 0 110dp; flex: 0 0 130dp;
color: #8fa2c4; color: #8fa2c4;
} }
@ -207,13 +256,29 @@ body {
word-break: break-word; word-break: break-word;
} }
/* A line a tool offers to choose between, and the chosen one in the game list's gold -- the same /* A line a tool offers to choose between, and the chosen one the way the game list shows its
way the game list itself shows a selection, since RCSS has no generated content to mark it with. */ selection: gold on the gradient bar. A detail the row carries -- a size, a state, a value --
sits against the right edge, so a column of them lines up whatever the text beside it. */
.tool .item { .tool .item {
margin-bottom: 2dp; display: flex;
padding: 3dp 8dp;
margin: 0 0 1dp -8dp;
border-radius: 3dp;
}
.tool .item .text {
flex: 1 1 0;
}
.tool .item .detail {
flex: 0 0 auto;
margin-left: 16dp;
text-align: right;
} }
.tool .item.selected { .tool .item.selected {
background-color: #33507a;
decorator: linear-gradient(90deg, #4a6ea8, #33507a);
color: #ffd35a; color: #ffd35a;
} }
@ -223,31 +288,35 @@ body {
} }
/* Two tools draw their own picture into the overlay, which is underneath the document: the light /* Two tools draw their own picture into the overlay, which is underneath the document: the light
gun's targets and the display's test pattern. Those pages let it through and gather their text gun's targets and the display's test pattern. Those pages let it through, and the card becomes
into a strip along the bottom, between the two lower targets, so nothing worth aiming at is a strip along the bottom between the two lower targets, so nothing worth aiming at is covered.
covered. */ Its width is fixed: left to fit its content, the value column of a pair had no room and broke
.tool.clear { its words one letter to a line. */
background-color: transparent; .tool.clear .card {
display: flex; flex: 0 0 auto;
flex-direction: column; margin-top: auto;
justify-content: flex-end; align-self: center;
align-items: center; width: 70%;
} }
.tool.clear h1 { .tool.clear h1 {
font-size: 16dp; font-size: 16dp;
margin-bottom: 4dp; padding: 4dp 12dp;
} }
.tool.clear h1, /* The strip is as tall as its rows: the columns, which fill the page otherwise, take what they
.tool.clear .row, need here. The body inside them keeps its flex, which in their row is its width; without it
.tool.clear .pair, a pair's value column had no room. */
.tool.clear .sub, .tool.clear .columns {
.tool.clear .item, flex: 0 0 auto;
.tool.clear p { }
background-color: #000000c0;
padding: 1dp 8dp; .tool.clear #toolBody {
margin-bottom: 4dp; padding: 6dp 12dp;
}
.tool.clear #toolKeys {
padding: 4dp 12dp;
} }
/* The help under the list of tools, away from the list itself. */ /* The help under the list of tools, away from the list itself. */

BIN
assets/menuBackground.mkv (Stored with Git LFS)

Binary file not shown.

BIN
assets/menuIntro.flac (Stored with Git LFS)

Binary file not shown.

BIN
assets/samples/dragonFireBreathAndRoar.flac (Stored with Git LFS) Normal file

Binary file not shown.

BIN
assets/samples/explosionUltraBass.flac (Stored with Git LFS) Normal file

Binary file not shown.

BIN
assets/samples/largeFireball.flac (Stored with Git LFS) Normal file

Binary file not shown.

View file

@ -476,10 +476,10 @@ if((KANGAROO_OS STREQUAL "linux") AND (CMAKE_HOST_SYSTEM_NAME STREQUAL "Linux"))
COMMAND ${CMAKE_COMMAND} -E copy ${forgeManual} ${forgeStage}/Forge/Forge.pdf COMMAND ${CMAKE_COMMAND} -E copy ${forgeManual} ${forgeStage}/Forge/Forge.pdf
# The samples that need no art beyond what the engine ships, so the chooser has something to # The samples that need no art beyond what the engine ships, so the chooser has something to
# open on a fresh install. They come from the test set, which is the one copy of them. # open on a fresh install. They come from the test set, which is the one copy of them.
COMMAND ${CMAKE_COMMAND} -E copy ${CMAKE_SOURCE_DIR}/testScripts/author/platformer.game ${CMAKE_SOURCE_DIR}/testScripts/author/breakout.game ${CMAKE_SOURCE_DIR}/testScripts/author/qte.game ${CMAKE_SOURCE_DIR}/testScripts/author/fmv.game ${forgeStage}/Forge/ COMMAND ${CMAKE_COMMAND} -E copy ${CMAKE_SOURCE_DIR}/testScripts/author/platformer.forge ${CMAKE_SOURCE_DIR}/testScripts/author/breakout.forge ${CMAKE_SOURCE_DIR}/testScripts/author/qte.forge ${CMAKE_SOURCE_DIR}/testScripts/author/fmv.forge ${forgeStage}/Forge/
# Run it somewhere harmless: the engine writes Singe/ and data/ into the working directory. # Run it somewhere harmless: the engine writes Singe/ and data/ into the working directory.
COMMAND ${CMAKE_COMMAND} -E chdir ${forgeStage} ${CMAKE_SOURCE_DIR}/.builddir/${singeBinaryName} --pack ${forgeStage}/Forge ${forgePack} COMMAND ${CMAKE_COMMAND} -E chdir ${forgeStage} ${CMAKE_SOURCE_DIR}/.builddir/${singeBinaryName} --pack ${forgeStage}/Forge ${forgePack}
DEPENDS singe ${forgeSources} ${forgeManual} ${CMAKE_SOURCE_DIR}/testScripts/author/platformer.game ${CMAKE_SOURCE_DIR}/testScripts/author/breakout.game ${CMAKE_SOURCE_DIR}/testScripts/author/qte.game ${CMAKE_SOURCE_DIR}/testScripts/author/fmv.game DEPENDS singe ${forgeSources} ${forgeManual} ${CMAKE_SOURCE_DIR}/testScripts/author/platformer.forge ${CMAKE_SOURCE_DIR}/testScripts/author/breakout.forge ${CMAKE_SOURCE_DIR}/testScripts/author/qte.forge ${CMAKE_SOURCE_DIR}/testScripts/author/fmv.forge
COMMENT "Packing Forge into .builddir/Forge.game" COMMENT "Packing Forge into .builddir/Forge.game"
VERBATIM VERBATIM
) )

View file

@ -121,12 +121,14 @@ types and the rest are explained in
* Give the ground a colour of its own. * Give the ground a colour of its own.
* Drag the hero somewhere else with the mouse, and play again. * Drag the hero somewhere else with the mouse, and play again.
**The description.** What kbd:[S] wrote, in full. `Forge/tutorials/01-tenMinutes.game` **The description.** What kbd:[S] wrote, in full. A description is a
`.forge` file -- Lua text -- and never a `.game`, which is a packed release
the Singe menu lists. `Forge/tutorials/01-tenMinutes.forge`
in the chooser is this file. in the chooser is this file.
[source,lua] [source,lua]
---- ----
include::../assets/Forge/tutorials/01-tenMinutes.game[] include::../assets/Forge/tutorials/01-tenMinutes.forge[]
---- ----
=== Tutorial 2: A platformer === Tutorial 2: A platformer
@ -227,7 +229,7 @@ is the three types, their entities, the three rules, and the second room.
[source,lua] [source,lua]
---- ----
include::../assets/Forge/tutorials/02-platformer.game[] include::../assets/Forge/tutorials/02-platformer.forge[]
---- ----
=== Tutorial 3: Sprites and sound === Tutorial 3: Sprites and sound
@ -307,7 +309,7 @@ states are in <<_instances_vars_and_states>> and
[source,lua] [source,lua]
---- ----
include::../assets/Forge/tutorials/03-sprites.game[] include::../assets/Forge/tutorials/03-sprites.forge[]
---- ----
=== Tutorial 4: A shoot-em-up === Tutorial 4: A shoot-em-up
@ -408,7 +410,7 @@ runs once per instance of a type, with that instance as `self`, and a
[source,lua] [source,lua]
---- ----
include::../assets/Forge/tutorials/04-shmup.game[] include::../assets/Forge/tutorials/04-shmup.forge[]
---- ----
=== Tutorial 5: A quick-time event === Tutorial 5: A quick-time event
@ -476,7 +478,7 @@ keeps a rule from firing every frame the window is open, and `keyPressed`
is the frame a key goes down, where `keyHeld` is every frame it is down. is the frame a key goes down, where `keyHeld` is every frame it is down.
`setVar` and `test` on a var of your own are how a rule remembers what `setVar` and `test` on a var of your own are how a rule remembers what
another did. The `branching` behaviour does all of this in one track for a another did. The `branching` behaviour does all of this in one track for a
game of many branches; `Forge/fmv.game` in the chooser is one. In a game of many branches; `Forge/fmv.forge` in the chooser is one. In a
released game, `games.dat` names the video, as it does for any Singe game. released game, `games.dat` names the video, as it does for any Singe game.
**Try this.** **Try this.**
@ -489,7 +491,7 @@ released game, `games.dat` names the video, as it does for any Singe game.
[source,lua] [source,lua]
---- ----
include::../assets/Forge/tutorials/05-qte.game[] include::../assets/Forge/tutorials/05-qte.forge[]
---- ----
=== Tutorial 6: A light gun game === Tutorial 6: A light gun game
@ -575,7 +577,7 @@ with a light gun needs -- calibration, two guns, the recoil -- is in
[source,lua] [source,lua]
---- ----
include::../assets/Forge/tutorials/06-gun.game[] include::../assets/Forge/tutorials/06-gun.forge[]
---- ----
=== Tutorial 7: A point-and-click adventure === Tutorial 7: A point-and-click adventure
@ -702,7 +704,7 @@ off. All of it is in
[source,lua] [source,lua]
---- ----
include::../assets/Forge/tutorials/07-adventure.game[] include::../assets/Forge/tutorials/07-adventure.forge[]
---- ----
=== Tutorial 8: Into 3D === Tutorial 8: Into 3D
@ -797,7 +799,7 @@ viewport in <<_the_3d_viewport>>.
[source,lua] [source,lua]
---- ----
include::../assets/Forge/tutorials/08-scene.game[] include::../assets/Forge/tutorials/08-scene.forge[]
---- ----
=== Tutorial 9: A rail shooter === Tutorial 9: A rail shooter
@ -916,7 +918,7 @@ in <<_games_in_the_scene>>; painted rooms in
[source,lua] [source,lua]
---- ----
include::../assets/Forge/tutorials/09-rail.game[] include::../assets/Forge/tutorials/09-rail.forge[]
---- ----
=== Tutorial 10: Releasing your game === Tutorial 10: Releasing your game
@ -956,7 +958,7 @@ image::tutorials/10-arcadeRules.png[The arcade rules, 600]
. kbd:[S], then kbd:[X]: the game is *released* into a folder named for . kbd:[S], then kbd:[X]: the game is *released* into a folder named for
it in Forge's data directory -- `CoinRun` -- and the message says where. it in Forge's data directory -- `CoinRun` -- and the message says where.
In it: `CoinRun.singe`, the compiled game; `Author.singe`, the runtime In it: `CoinRun.singe`, the compiled game; `Author.singe`, the runtime
it needs; `CoinRun.game`, the description, so it can be opened again; it needs; `CoinRun.forge`, the description, so it can be opened again;
`games.dat`, so the menu lists it, naming the script as the menu will see `games.dat`, so the menu lists it, naming the script as the menu will see
it, `CoinRun/CoinRun.singe`; and every file the description named, at the it, `CoinRun/CoinRun.singe`; and every file the description named, at the
same relative name -- here the kit's hero, coins, and sounds under same relative name -- here the kit's hero, coins, and sounds under
@ -989,7 +991,7 @@ is what a cabinet's coin door shows. Releasing is in
**Try this.** **Try this.**
* Open `CoinRun.game` from the release folder in Forge -- drop it in the * Open `CoinRun.forge` from the release folder in Forge -- drop it in the
chooser's data directory -- change something, and release again. chooser's data directory -- change something, and release again.
* Two players: `players` `2`, a second hero on the other keys, and * Two players: `players` `2`, a second hero on the other keys, and
`score2` and `lives2` for the bezel's other side. `score2` and `lives2` for the bezel's other side.
@ -999,7 +1001,7 @@ is what a cabinet's coin door shows. Releasing is in
[source,lua] [source,lua]
---- ----
include::../assets/Forge/tutorials/10-release.game[] include::../assets/Forge/tutorials/10-release.forge[]
---- ----
=== Recipes === Recipes
@ -1008,7 +1010,7 @@ The rest of the catalogue, a paragraph each, with a sample to open from the
chooser. A recipe names the behaviours; the tutorials above have shown how chooser. A recipe names the behaviours; the tutorials above have shown how
each is typed. each is typed.
*Racing* (`racing.game`). A `scene3d` room, a `vehicle` behaviour on a *Racing* (`racing.forge`). A `scene3d` room, a `vehicle` behaviour on a
mesh -- `type` `car`, `motorcycle`, `tank`, or `boat`, with its `mass`, mesh -- `type` `car`, `motorcycle`, `tank`, or `boat`, with its `mass`,
`thrust`, `torque`, and wheel sizes -- driven by `keys` through the vars `dy` `thrust`, `torque`, and wheel sizes -- driven by `keys` through the vars `dy`
(throttle) and `dx` (steering), a `camera` in `follow` mode behind it, and (throttle) and `dx` (steering), a `camera` in `follow` mode behind it, and
@ -1016,50 +1018,50 @@ opponents on `racer`, which drives a vehicle round a track of points on its
own. A `trigger` across the finish line, a `lap` var, and a `timer` for the own. A `trigger` across the finish line, a `lap` var, and a `timer` for the
clock. clock.
*Tower defence* (`towerdefence.game`). Creeps on `patrol` along a list of *Tower defence* (`towerdefence.forge`). Creeps on `patrol` along a list of
points to the base, a `spawner` making waves of them, towers with `turret` points to the base, a `spawner` making waves of them, towers with `turret`
-- `targets` the creep type, a `range`, a `rate`, and a `spawns` projectile -- `targets` the creep type, a `range`, a `rate`, and a `spawns` projectile
or plain `damage` -- placed by `spawnAtPointer` on a click while the gold or plain `damage` -- placed by `spawnAtPointer` on a click while the gold
var allows, and `patrolEnd` costing a life. var allows, and `patrolEnd` costing a life.
*Pool and water* (`pool.game`). A `mesh` with the `water` behaviour is a *Pool and water* (`pool.forge`). A `mesh` with the `water` behaviour is a
pool: bodies inside it float or sink by their `buoyancy`, and a `soft` pool: bodies inside it float or sink by their `buoyancy`, and a `soft`
mesh is a cloth or a balloon. `body` gives anything mass, bounce, and mesh is a cloth or a balloon. `body` gives anything mass, bounce, and
friction; `push` shoves it; `joint` hangs one body from another by a friction; `push` shoves it; `joint` hangs one body from another by a
hinge, a ball, or a slider. hinge, a ball, or a slider.
*Bowling* (`bowling.game`). Ten `body` pins and a ball pushed along the *Bowling* (`bowling.forge`). Ten `body` pins and a ball pushed along the
lane, a `trigger` behind the pins that counts what falls through, and a lane, a `trigger` behind the pins that counts what falls through, and a
`frame` rule that resets the rack with `moveTo` when the ball is past them. `frame` rule that resets the rack with `moveTo` when the ball is past them.
*The maze* (`maze.game`). A `grid` look draws a map of tiles from a sheet *The maze* (`maze.forge`). A `grid` look draws a map of tiles from a sheet
-- `columns` wide, `tile` pixels square, the `map` rows of tile numbers -- -- `columns` wide, `tile` pixels square, the `map` rows of tile numbers --
and `solid` walls are entities placed on it; `keys` and `mover` with and `solid` walls are entities placed on it; `keys` and `mover` with
`clamp` walk the maze, a `timer` runs the clock, and `enter` on the exit `clamp` walk the maze, a `timer` runs the clock, and `enter` on the exit
`trigger` ends it. `trigger` ends it.
*Third person* (`thirdperson.game`). Tutorial 8's fox with an `orbit` *Third person* (`thirdperson.forge`). Tutorial 8's fox with an `orbit`
camera: the mouse turns the view, the arrows walk relative to it, and camera: the mouse turns the view, the arrows walk relative to it, and
`camera.eye` raises the viewpoint. `animator` with `attack` and `hit` `camera.eye` raises the viewpoint. `animator` with `attack` and `hit`
clips, `health` on what it fights, and `animationDone` to return to `idle`. clips, `health` on what it fights, and `animationDone` to return to `idle`.
*Space* (`space.game`). `thrust` pushes a body along its nose by `dy` and *Space* (`space.forge`). `thrust` pushes a body along its nose by `dy` and
turns it by `dx` -- a ship without lift -- with `projectile`s that `aim` turns it by `dx` -- a ship without lift -- with `projectile`s that `aim`
along their flight and a `spawner` of rocks that `drift`. along their flight and a `spawner` of rocks that `drift`.
*Rhythm and MIDI* (`rhythm.game`). A game that opened a MIDI port hears *Rhythm and MIDI* (`rhythm.forge`). A game that opened a MIDI port hears
`midi` events with `event.pitch` and `event.velocity`; a `timeBetween` `midi` events with `event.pitch` and `event.velocity`; a `timeBetween`
window round each beat, and `once` per note, scores the hit. A `music` window round each beat, and `once` per note, scores the hit. A `music`
layer plays the track from the start. layer plays the track from the start.
*The 3D adventure* (`adventure3d.game`). Tutorial 7's rooms in a scene: *The 3D adventure* (`adventure3d.forge`). Tutorial 7's rooms in a scene:
`walker` on the floors named in the room's `navFrom`, hotspots with a `walker` on the floors named in the room's `navFrom`, hotspots with a
`walkX`, `walkY`, `walkZ`, a painted backdrop on an unlit mesh, and `walkX`, `walkY`, `walkZ`, a painted backdrop on an unlit mesh, and
`occluder` boxes where the painting's furniture stands, so the hero walks `occluder` boxes where the painting's furniture stands, so the hero walks
behind it. The verbs, the inventory, and the dialogues are exactly the flat behind it. The verbs, the inventory, and the dialogues are exactly the flat
ones. ones.
*The board and the bezel* (`fmv.game`). `bezel` round any game, `credit` *The board and the bezel* (`fmv.forge`). `bezel` round any game, `credit`
on the coin switch, `restart` on start with credits, and `submitScore` at on the coin switch, `restart` on start with credits, and `submitScore` at
the end; `branching` for a Dragon's Lair of windows, moves, and jumps. the end; `branching` for a Dragon's Lair of windows, moves, and jumps.
@ -1139,7 +1141,7 @@ Compile it and run what comes out:
[source,lua] [source,lua]
---- ----
dofile("Forge/AuthorCompile.singe") dofile("Forge/AuthorCompile.singe")
dofile(authorBuild("mygame.game", singeGetDataPath() .. "mygame.singe")) dofile(authorBuild("mygame.forge", singeGetDataPath() .. "mygame.singe"))
---- ----
`testScripts/author/` holds the worked examples -- a platformer, a QTE over `testScripts/author/` holds the worked examples -- a platformer, a QTE over
@ -1273,7 +1275,7 @@ with `ragdoll` lets a model fall limp on death; `solid`, `body`, and
pointer, so the same `hit` and `miss` rules serve a light gun over video and pointer, so the same `hit` and `miss` rules serve a light gun over video and
a rail shooter. a rail shooter.
`testScripts/author/railshooter.game` is House of the Dead in a table: a `testScripts/author/railshooter.forge` is House of the Dead in a table: a
rail with two stops, zombies that spawn there, walk to the camera, and bite rail with two stops, zombies that spawn there, walk to the camera, and bite
on a timer unless they are killed, headshots through a zone on the head bone, on a timer unless they are killed, headshots through a zone on the head bone,
and the rail moving on when the stop is clear. and the rail moving on when the stop is clear.
@ -1298,13 +1300,73 @@ picture is drawn over what is higher, and `scaleBy = { { y = 300, scale = 0.6
smaller; looks with `anchor = "feet"` stand on their position rather than smaller; looks with `anchor = "feet"` stand on their position rather than
being centred on it, which is what a walking character wants. being centred on it, which is what a walking character wants.
*Overlay bits.* A `text` look may have a `font` file and `size` of its own
and an `anchor` -- `centre`, `feet`, or `top` -- instead of its top left; a
`sprite` look draws the var `copies` copies of itself `step` pixels apart, a
row of bullets or of hearts; the `pointer` behaviour keeps an instance on
the player's pointer, a crosshair, and hides it when the engine says a real
gun wants none drawn; `discPlay` and `discPause` let the disc go and hold
it, a still behind a title; `pointerX(1)` and `pointerY(1)` in an
expression say where the pointer is. The ActionMax way of judging a shot
over video -- the picture under the gun agreeing with a sensor spot across
two frames -- is a `lightSensor` behaviour those five games carry in a
vocabulary of their own (see <<own-vocabulary>>), not one of Forge's.
*A QTE game from the KarisFramework.* The `qte` behaviour plays a
laserdisc quick-time-event game as that framework does -- attract loop,
credits, levels of scenes with moves to answer in a window of frames, death
clips, continues, the dips -- from the framework's own tables under
`qte` in the description, which `util/forgePortKaris.lua` writes from a
game's script. The game's script and its `Script/addons.singe` run inside
the behaviour, so a game keeps its own scoring and level order. The
seventeen games of the test library and eleven of the Hypseus library are
ported this way and play as their originals do (FORGE.md section 14), and
so is LINEA, the one game on the MazescaterFramework -- that framework
with compound moves (sequences of two or three inputs, circles from any
start, mashes of a direction or a button, a direction with the second or
third button), which the behaviour judges from tables of its own once the
converter has marked the description with the framework's name. The
`kimmy` behaviour plays the same author's 2024 engine, whose moves are
judged by what is held together -- combos, mashes in three variants,
holds, sequences, paths, timed and yes/no branches -- with tilt, a life
bar, a die-and-retry count, a new-game menu, and a level select; the same
converter writes its description, and the seven Kimmy games are ported
with it. The `rdg` behaviour plays the map-mode lineage those frameworks
grew from (RDG's LUA SINGE 1.0 and 1.1): a game of that kind keeps its
levels, moves, death clips, order, and map as functions of its own
main.singe, and the behaviour runs those files as they are, beside the
description, over its own loop -- levels of segments, a move an input or
a mash or a pair of inputs in a window, a shot at a zone of the picture
where the game carries one, a map between levels -- with the copies'
drift (the later generation's level order, game type, and state numbers)
gated by what the game's script declares, and what a title does without
declaring it named in a `tweaks` table the converter writes. The
`timegal` behaviour plays the same author's Time Gal remake, a compact
loop of its own (checkpoints, a time-stop choice of three, a tally by
deaths, a life per quota, a level order by tiers), over the game's own
files in the same way; and the `sdq` behaviour plays Super Don Quixote,
the one hybrid of map-mode data under the Karis framework's judging,
with its difficulty select, level clear, and trophies.
*A hand-written game.* A game that was a program of its own rather than
a framework's tables -- the American Laser Games titles, Mad Dog McCree
first among them and the typing edition of Mad Dog II the ninth, and
Platoon, a prototype in their shape -- is ported as a vocabulary of its own beside
its description (see
<<own-vocabulary>>): a behaviour that is the game's loop rewritten in
Forge's terms, its screens and each level's flow, over the game's own
data files (its level setups, its hitbox tables, its hit tests, its
clips) loaded through the same shim the framework ports use. A press
carries the device it came from, so two guns are told apart, and the
hitbox behaviour's `step` keeps a box only on the frames a track keys.
*3D over a painting.* A room in the scene may be a picture on a flat `mesh` *3D over a painting.* A room in the scene may be a picture on a flat `mesh`
(its `texture`, `unlit`) with 3D characters in front of it. Whatever stands (its `texture`, `unlit`) with 3D characters in front of it. Whatever stands
in the picture -- a pillar, a doorway, a table -- that a character has to walk in the picture -- a pillar, a doorway, a table -- that a character has to walk
behind is a `mesh` look with `occluder = true`: an invisible box that hides behind is a `mesh` look with `occluder = true`: an invisible box that hides
what is behind it while the picture shows through it. The editor's 3D what is behind it while the picture shows through it. The editor's 3D
viewport keeps occluders visible so they can be placed; the game hides them. viewport keeps occluders visible so they can be placed; the game hides them.
`testScripts/author/painted.game` and scene 79 show a character crossing a `testScripts/author/painted.forge` and scene 79 show a character crossing a
painted room behind such a pillar. painted room behind such a pillar.
*Hotspots.* A type with `{ kind = "hotspot", name = "door", walkX = 600, *Hotspots.* A type with `{ kind = "hotspot", name = "door", walkX = 600,
@ -1374,8 +1436,8 @@ slot, and `loadGame` brings it back. A save taken while a sequence runs
records the world as it stands; the sequence does not resume. `die` is the records the world as it stands; the sequence does not resume. `die` is the
Sierra death: a line, a fade, and the game over from the start. Sierra death: a line, a fade, and the game over from the start.
`testScripts/author/adventure2d.game` is the two-room adventure in a painted `testScripts/author/adventure2d.forge` is the two-room adventure in a painted
room, and `adventure3d.game` is the same game in a modelled room from a fixed room, and `adventure3d.forge` is the same game in a modelled room from a fixed
camera -- it takes the 2D description's rules, dialogue, verbs, and parser as camera -- it takes the 2D description's rules, dialogue, verbs, and parser as
they are and supplies only types and rooms. Scenes 64 and 65 play them. they are and supplies only types and rooms. Scenes 64 and 65 play them.
@ -1405,8 +1467,8 @@ body along its nose by `dy` and turns it by `dx` (a hovercraft, a ship);
or a slider through a point along an axis; and `push` shoves a body, or a or a slider through a point along an axis; and `push` shoves a body, or a
ragdoll's bone. ragdoll's bone.
`testScripts/author/racing.game` (a car, a rival on a track, checkpoint `testScripts/author/racing.forge` (a car, a rival on a track, checkpoint
triggers, a hinged bar, a hovercraft) and `towerdefence.game` (creeps in triggers, a hinged bar, a hovercraft) and `towerdefence.forge` (creeps in
waves on a lane, turrets placed by clicks, gold and lives) are the worked waves on a lane, turrets placed by clicks, gold and lives) are the worked
examples; scenes 66 and 67 play them. examples; scenes 66 and 67 play them.
@ -1416,14 +1478,17 @@ A `branches` track is Dragon's Lair: each branch is a window of disc frames,
the `move` (a key) or `switch` it wants, and the frames the disc goes to on the `move` (a key) or `switch` it wants, and the frames the disc goes to on
`success` and on `fail`; a type with `{ kind = "branching", track = "moves" }` `success` and on `fail`; a type with `{ kind = "branching", track = "moves" }`
watches it and raises `branchOpen` (with `event.move`), `branchTaken`, and watches it and raises `branchOpen` (with `event.move`), `branchTaken`, and
`branchMissed`. Lives, credits, and continues are vars and rules -- `credit` `branchMissed`. A branch with `mash = 6` wants the move six times before the
window closes, and one with `hold = 20` wants it held for twenty frames of
the window; `branchOpen` carries `event.mash` and `event.hold` so a prompt
can say so. Lives, credits, and continues are vars and rules -- `credit`
on `SWITCH_COIN1`, a `restart` on `SWITCH_START1` when `credits > 0` -- and a on `SWITCH_COIN1`, a `restart` on `SWITCH_START1` when `credits > 0` -- and a
`bezel` layer shows `score`, `lives`, and `credits` (and a second player's `bezel` layer shows `score`, `lives`, and `credits` (and a second player's
`score2` and `lives2` with `twin`) on the arcade panel around the picture. `score2` and `lives2` with `twin`) on the arcade panel around the picture.
`gameOver` ends the rules, keeps the best score across runs, and shows the `gameOver` ends the rules, keeps the best score across runs, and shows the
results card; `submitScore` sends the score to the master service's board results card; `submitScore` sends the score to the master service's board
for the game, queued until it can go. `testScripts/author/fmv.game` is the for the game, queued until it can go. `testScripts/author/fmv.forge` is the
worked example and scene 68 plays it: one window answered, three missed, the worked example and scene 68 plays it: one window answered, three missed, the
lives spent, the score sent, a coin, and a continue. lives spent, the score sent, a coin, and a continue.
@ -1464,7 +1529,7 @@ In the scene, a mesh look with `water` is filled with water: its top is the
surface, and a `body` with `buoyancy` floats in it; a `vehicle` of type surface, and a `body` with `buoyancy` floats in it; a `vehicle` of type
`boat` with `thrust` is pushed by its propeller while that sits under the `boat` with `thrust` is pushed by its propeller while that sits under the
surface; and `soft` makes a mesh look a cloth or an inflated body. surface; and `soft` makes a mesh look a cloth or an inflated body.
`testScripts/author/tail.game` and `pool.game` are the worked examples; `testScripts/author/tail.forge` and `pool.forge` are the worked examples;
scenes 70 and 71 play them. scenes 70 and 71 play them.
=== The way out === The way out
@ -1496,6 +1561,73 @@ empty, is reported by the checker as *empty* and compiles to `nil`.
include::ForgeVocabulary.adoc[] include::ForgeVocabulary.adoc[]
[[own-vocabulary]]
=== Your own vocabulary
A game can bring words of its own. A description may name a file of Lua --
[source,lua]
----
vocabulary = "Vocabulary.singe",
----
-- kept beside the description, and everything that reads the description loads
that file before it reads the manifest: the editor when the game is opened
(and forgets it again when the game is closed, so the next game's panels do
not offer it), the compiler before it checks the description, and the built
game itself, which carries the file into a release like any other file the
description names and loads it from beside itself. Nothing in Forge changes;
the words exist while that game is open and nowhere else.
The file adds entries to the `AUTHOR` table exactly as `Author.singe` does
for its own -- a look, a behaviour, a condition, an action, a layer, or an
event -- and may define any Lua the entries emit or call. A behaviour's
`attach`, `step`, and `on` are called as Forge calls them; a condition's or
action's `emit` returns the Lua a rule compiles to. A behaviour that reads a
key or switch resolves the name with `authorKeyValue` or `authorSwitchValue`,
and one that wants the presses about nothing in particular -- a trigger --
sets `instance.hearsAll` and reads them in `on`. The five ActionMax games are
the working example: their `lightSensor` behaviour, which judges a shot the
way the emulator did by the disc's pixels under the gun, lives in a
`Vocabulary.singe` beside them and in no game that is not theirs.
[source,lua]
----
-- A bob: the entity rises and falls on a sine wave about where it was put.
AUTHOR.behaviours.bob = {
help = "Rises and falls about its place.",
params = { height = "number", speed = "number" },
attach = function(instance, params)
local _, y = authorPosition(instance)
instance.bob = { height = params.height or 20, speed = params.speed or 2, base = y, at = 0 }
end,
step = function(instance, dt)
local bob = instance.bob
local x, _, z = authorPosition(instance)
bob.at = bob.at + dt * bob.speed
nodeSetPosition(instance.node, x, bob.base + math.sin(bob.at) * bob.height, z)
end
}
-- Whether an instance bobs: a condition of this game's own.
AUTHOR.conditions.bobbing = {
help = "An instance has a bob.",
params = { entity = "entity" },
emit = function(p) return "vocabBobbing(" .. p.entity .. ")" end
}
function vocabBobbing(instance)
return (instance ~= nil) and (instance.bob ~= nil)
end
----
The game's form has a `vocabulary` field, so the file can be named from the
editor; naming it loads it at once, and the type and rule pickers offer what
it added.
== The Editor == The Editor
`Forge/Forge.singe` edits a description, and it is itself a Singe game. It has `Forge/Forge.singe` edits a description, and it is itself a Singe game. It has
@ -1522,7 +1654,7 @@ A script can drive the editor instead, which is how the test scenes do it:
---- ----
FORGE_LIBRARY = true FORGE_LIBRARY = true
dofile("Forge/Forge.singe") dofile("Forge/Forge.singe")
forgeBegin("mygame.game") forgeBegin("mygame.forge")
function onOverlayUpdate() function onOverlayUpdate()
local x, y = mouseGetPosition(0) local x, y = mouseGetPosition(0)
@ -1579,8 +1711,9 @@ it. In a 3D room `A` puts the new entity on the floor under the middle of
the viewport, and positions are world units to a tenth. `PgUp` and `PgDn` the viewport, and positions are world units to a tenth. `PgUp` and `PgDn`
move between rooms; `N` adds a room. With nothing selected, `Enter` edits move between rooms; `N` adds a room. With nothing selected, `Enter` edits
the game and then the room: the game's `title`, `players`, `vars` (as the game and then the room: the game's `title`, `players`, `vars` (as
`score=0, lives=3`), `verbs`, and `layers` (a list of names, as `disc, `score=0, lives=3`), `verbs`, `layers` (a list of names, as `disc,
overlay`) with each layer's own parameters as `disc.file` or `scene3d.fov` overlay`) with each layer's own parameters as `disc.file` or `scene3d.fov`,
and `vocabulary` (a file of the game's own words, <<own-vocabulary>>)
-- a table parameter is typed as text: a map such as `scene3d.ambient` as -- a table parameter is typed as text: a map such as `scene3d.ambient` as
`r=80, g=80, b=100`, a list of points such as `spawner.points` as `r=80, g=80, b=100`, a list of points such as `spawner.points` as
`100,-20; 300,-20`, a list of records such as `target.zones` as `100,-20; 300,-20`, a list of records such as `target.zones` as
@ -1737,7 +1870,7 @@ own, `CoinRun/CoinRun.singe`, which is how both the menu and a packed copy
address them), the description it was built from address them), the description it was built from
so it can be opened again, **a copy of the runtime**, taken out of Forge, and so it can be opened again, **a copy of the runtime**, taken out of Forge, and
**every file the description names** -- looks, sounds, music, the disc, models, **every file the description names** -- looks, sounds, music, the disc, models,
a HUD document -- at the same relative name it had, so the kit's a HUD document, the game's own vocabulary -- at the same relative name it had, so the kit's
`Forge/kit/hero.png` lands in `Forge/kit/` under the release. The engine's `Forge/kit/hero.png` lands in `Forge/kit/` under the release. The engine's
own files (`Singe/...`) and absolute paths are left where they are. The own files (`Singe/...`) and absolute paths are left where they are. The
compiled script notes its own directory in `AUTHOR_DIR`, and the runtime looks compiled script notes its own directory in `AUTHOR_DIR`, and the runtime looks

View file

@ -79,12 +79,12 @@
| `b` (number), `g` (number), `gravity` (number), `life` (number), `r` (number), `rate` (number), `size` (number), `speed` (number), `spread` (number) | `b` (number), `g` (number), `gravity` (number), `life` (number), `r` (number), `rate` (number), `size` (number), `speed` (number), `spread` (number)
| `sprite` | `sprite`
| An image, centred on the instance -- or standing on it, with anchor feet. frames splits a sheet into that many columns; the var frame picks one. The var angle turns it, starting from the entity's rz. faces says which way the art looks (right unless said, or none): while the var facing is the other way the image is mirrored, so art drawn one way walks both. | An image, centred on the instance -- or standing on it, with anchor feet. frames splits a sheet into that many columns; the var frame picks one. The var angle turns it, starting from the entity's rz. faces says which way the art looks (right unless said, or none): while the var facing is the other way the image is mirrored, so art drawn one way walks both. The var copies draws it that many times in a row, step pixels apart (a row of bullets, of hearts): negative steps go left.
| `anchor` (feet), `faces` (right, left, none), `file` (file), `frames` (number) | `anchor` (feet), `faces` (right, left, none), `file` (file), `frames` (number), `step` (number)
| `text` | `text`
| A line of text, its top left at the instance. The var text is what it says. | A line of text, its top left at the instance -- or centred on it, standing on it (feet), or hanging from it (top). The var text is what it says. A font file and size of its own, or the game's.
| `b` (number), `g` (number), `r` (number), `text` (string) | `anchor` (centre, feet, top), `b` (number), `font` (file), `g` (number), `r` (number), `size` (number), `text` (string)
| `text3d` | `text3d`
| A line of text standing in the scene. The var text is what it says. | A line of text standing in the scene. The var text is what it says.
@ -107,7 +107,7 @@
| `bounce` (number), `buoyancy` (number), `friction` (number), `mass` (number), `shape` (box, sphere) | `bounce` (number), `buoyancy` (number), `friction` (number), `mass` (number), `shape` (box, sphere)
| `branching` | `branching`
| Dragon's Lair: a track of branches, each a disc frame window, the move it wants, and where the disc goes on success and on failure. Raises branchTaken and branchMissed. | Dragon's Lair: a track of branches, each a disc frame window, the move it wants, and where the disc goes on success and on failure. A branch with mash wants the move that many times inside the window; one with hold wants it held that many frames. Raises branchOpen, branchTaken, and branchMissed.
| `track` (track) | `track` (track)
| `camera` | `camera`
@ -135,7 +135,7 @@
| `keep` (boolean), `linger` (number), `max` (number), `ragdoll` (boolean) | `keep` (boolean), `linger` (number), `max` (number), `ragdoll` (boolean)
| `hitbox` | `hitbox`
| The instance's hit shape comes from a track of boxes keyed by disc frame or time; between keys it is interpolated, outside them there is none. | The instance's hit shape comes from a track of boxes keyed by disc frame or time; between keys it is interpolated, outside them there is none. A track with step = true keeps a box only on the frames it keys.
| `track` (track) | `track` (track)
| `hotspot` | `hotspot`
@ -150,6 +150,10 @@
| Reads a player's keys into the vars dx and dy (-1 to 1) and fire, so rules and movers read intent rather than hardware. | Reads a player's keys into the vars dx and dy (-1 to 1) and fire, so rules and movers read intent rather than hardware.
| `down` (scancode), `fire` (scancode), `left` (scancode), `player` (number), `right` (scancode), `up` (scancode) | `down` (scancode), `fire` (scancode), `left` (scancode), `player` (number), `right` (scancode), `up` (scancode)
| `kimmy`
| Plays a laserdisc quick-time-event game the Kimmy Script Engine way (Karis's 2024 framework), from the framework's own tables under game.qte: the attract loop, a new-game menu, levels of scenes with moves judged by what is held -- combos, mashes, holds, sequences, paths, timed and yes/no branches -- tilt, the life bar, the die-and-retry count, and the game's own add-ons. Written by util/forgePortKaris.lua from a game's script.
| --
| `mover` | `mover`
| Moves by the vars dx and dy at a speed, kept on the screen when clamp is set. Pair it with keys. | Moves by the vars dx and dy at a speed, kept on the screen when clamp is set. Pair it with keys.
| `clamp` (boolean), `speed` (number) | `clamp` (boolean), `speed` (number)
@ -162,14 +166,30 @@
| Runs, falls, and jumps: the engine's character controller in 2D. Rules say which way with run and jump. Its point is its feet, so its look stands on it. Sets the var facing, and the var state to idle, walk, jump, or fall unless a rule set another. | Runs, falls, and jumps: the engine's character controller in 2D. Rules say which way with run and jump. Its point is its feet, so its look stands on it. Sets the var facing, and the var state to idle, walk, jump, or fall unless a rule set another.
| `jump` (number), `speed` (number) | `jump` (number), `speed` (number)
| `pointer`
| Follows a player's pointer: a crosshair, a hand. Hidden when the engine says a real gun wants no crosshair drawn.
| `player` (number)
| `projectile` | `projectile`
| Flies at a velocity and is dropped past the edge (or far away, in 3D) or after life seconds. With aim, a sprite turns to point the way it flies. | Flies at a velocity and is dropped past the edge (or far away, in 3D) or after life seconds. With aim, a sprite turns to point the way it flies.
| `aim` (boolean), `life` (number), `vx` (number), `vy` (number), `vz` (number) | `aim` (boolean), `life` (number), `vx` (number), `vy` (number), `vz` (number)
| `qte`
| Plays a laserdisc quick-time-event game the KarisFramework way (or the MazescaterFramework's, with its compound moves), from the framework's own tables under game.qte: attract, credits, levels of scenes with moves to answer in a window of frames, death clips, continues, the dips, and the game's own add-ons. Written by util/forgePortKaris.lua from a game's script.
| --
| `racer` | `racer`
| Drives a vehicle round a track of points on its own: steering toward the next, throttling by how straight the road is. | Drives a vehicle round a track of points on its own: steering toward the next, throttling by how straight the road is.
| `reach` (number), `throttle` (number), `track` (track) | `reach` (number), `throttle` (number), `track` (track)
| `rdg`
| Plays a laserdisc quick-time-event game of RDG's map-mode lineage (the ancestor of the Karis framework): levels of segments, a move an input or a mash or a pair of inputs in a window, a map between levels. The game's own files are loaded and its level, move, death-clip, order, and map functions run as they are; the loop is this. Written by util/forgePortKaris.lua from a game's script.
| --
| `sdq`
| Plays Super Don Quixote: map-mode levels, scenes, death clips, order, and map from the game's own files, judged as the Karis 3.31c framework judges, with its difficulty select, level clear, finish, and trophies. Written by util/forgePortKaris.lua from the game's script.
| --
| `seek` | `seek`
| Moves straight toward a target -- an entity's id, or "camera" -- and raises arrived within stopAt of it. | Moves straight toward a target -- an entity's id, or "camera" -- and raises arrived within stopAt of it.
| `fly` (boolean), `speed` (number), `stopAt` (number), `target` (string) | `fly` (boolean), `speed` (number), `stopAt` (number), `target` (string)
@ -187,8 +207,8 @@
| `moving` (boolean) | `moving` (boolean)
| `sound` | `sound`
| A clip per event: spawn, hit, death, or any event the instance sees. Values are file names. volume is the clips' own, 0 to 100, under the game's. | A clip per event: spawn, hit, miss, death, fire (a gun's trigger), or any event the instance sees. Values are file names. volume is the clips' own, 0 to 100, under the game's.
| `death` (file), `hit` (file), `pressed` (file), `spawn` (file), `volume` (number) | `death` (file), `fire` (file), `hit` (file), `miss` (file), `pressed` (file), `spawn` (file), `volume` (number)
| `spawner` | `spawner`
| Makes instances of a type every so many seconds at its own position or at one of its points, up to max alive at once and total in all. | Makes instances of a type every so many seconds at its own position or at one of its points, up to max alive at once and total in all.
@ -206,6 +226,10 @@
| A body pushed along its nose by the var dy and turned by dx: a hovercraft, a ship, a plane without lift. | A body pushed along its nose by the var dy and turned by dx: a hovercraft, a ship, a plane without lift.
| `force` (number), `turn` (number) | `force` (number), `turn` (number)
| `timegal`
| Plays RDG2010's Time Gal (Singe Edition): levels of moves from a difficulty array (mirrored by a random draw), checkpoints, a time-stop choice of three, a tally by deaths, a life per quota, and a level order by tiers, at random, or in sequence. The game's own files are loaded and its data and order functions run as they are; the loop is this. Written by util/forgePortKaris.lua from the game's script.
| --
| `timer` | `timer`
| Raises the timer event with its name after some seconds, or every so many. start false waits for timerStart. | Raises the timer event with its name after some seconds, or every so many. start false waits for timerStart.
| `after` (number), `every` (number), `name` (string), `start` (boolean) | `after` (number), `every` (number), `name` (string), `start` (boolean)
@ -251,7 +275,7 @@
| -- | --
| `branchOpen` | `branchOpen`
| A branch's window opened; event.move says what it wants. | A branch's window opened; event.move says what it wants, event.mash how many times, event.hold for how many frames.
| -- | --
| `branchTaken` | `branchTaken`
@ -477,6 +501,16 @@
| `text` (expression) | `text` (expression)
| yes | yes
| `discPause`
| Hold the disc on its frame: a still behind a title.
| --
|
| `discPlay`
| Play the disc from where it stands.
| --
|
| `discTo` | `discTo`
| Send the disc to a frame -- the branch a QTE takes. | Send the disc to a frame -- the branch a QTE takes.
| `frame` (number) | `frame` (number)

View file

@ -563,7 +563,7 @@ name and any extension FFmpeg can demux, then for a `.txt` framefile.
| `--altaudio=SUFFIX` | Play `<base><SUFFIX>.ogg` beside the disc's video instead of the audio inside it, for a release whose other languages ship as separate files: `--altaudio=-es` next to `lair.m2v` plays `lair-es.ogg`. Every segment of a framefile is switched together. A file that is not there leaves the game's own audio playing and prints a warning. The `AUDIO_SUFFIX` key in `games.dat` does the same for one game, and a script changes it while running with `discAudioSuffix`. Default: none. | `--altaudio=SUFFIX` | Play `<base><SUFFIX>.ogg` beside the disc's video instead of the audio inside it, for a release whose other languages ship as separate files: `--altaudio=-es` next to `lair.m2v` plays `lair-es.ogg`. Every segment of a framefile is switched together. A file that is not there leaves the game's own audio playing and prints a warning. The `AUDIO_SUFFIX` key in `games.dat` does the same for one game, and a script changes it while running with `discAudioSuffix`. Default: none.
| `--apiversion` | Print one machine readable line describing this build to standard output and exit, for front ends. See <<apiversion,The Version Line>>. Nothing else is printed. | `--apiversion` | Print one machine readable line describing this build to standard output and exit, for front ends. See <<apiversion,The Version Line>>. Nothing else is printed.
| `--deinterlace=MODE` | What an interlaced picture gets: `auto` (the default) deinterlaces only the frames the file marks interlaced, `on` deinterlaces every frame for a file whose flags are wrong, and `off` never touches the picture. A laserdisc held interlaced fields and a rip that kept them combs on a progressive display; a rip that was deinterlaced when it was made needs nothing here. One picture comes out for each one that goes in, so frame numbers never move. Default: `auto`. | `--deinterlace=MODE` | What an interlaced picture gets: `auto` (the default) deinterlaces only the frames the file marks interlaced, `on` deinterlaces every frame for a file whose flags are wrong, and `off` never touches the picture. A laserdisc held interlaced fields and a rip that kept them combs on a progressive display; a rip that was deinterlaced when it was made needs nothing here. One picture comes out for each one that goes in, so frame numbers never move. Default: `auto`.
| `--deterministic[=MS]` | For testing only: ignore real time and run the whole engine on a virtual clock that moves `MS` milliseconds every frame, so the same frame number always means the same moment. The disc steps one video frame a frame with it, and the random generators are seeded from the same number, so a run repeats to the pixel. The value is optional and is both the step and the seed, `1` to `1000`. Default when given without one: `15`, the frame time the engine's own rate implies. Audio and pacing are meaningless in this mode; see <<deterministic,Deterministic Test Mode>>. Default: off. | `--deterministic[=MS]` | For testing only: ignore real time and run the whole engine on a virtual clock that moves `MS` milliseconds every frame, so the same frame number always means the same moment. The disc follows the virtual clock too, its frames lasting the time they are worth, and the random generators are seeded from the same number, so a run repeats to the pixel. The value is optional and is both the step and the seed, `1` to `1000`. Default when given without one: `15`, the frame time the engine's own rate implies. Audio and pacing are meaningless in this mode; see <<deterministic,Deterministic Test Mode>>. Default: off.
| `--fvalue=NUMBER` | One number handed from the launcher to the game, which reads it with `getFValue()`. Singe does nothing with it. `0` to `100000`, kept to three decimals as Hypseus keeps it. Default: `0`. | `--fvalue=NUMBER` | One number handed from the launcher to the game, which reads it with `getFValue()`. Singe does nothing with it. `0` to `100000`, kept to three decimals as Hypseus keeps it. Default: `0`.
| `--gamepad_reorder=DIGITS` | Which physical pad fills which gamepad slot, as enumeration positions counting from `0`, one for each slot in turn: `--gamepad_reorder=10` makes the second pad found player one and the first player two. Written as bare digits (`3210`) or separated by commas or spaces, as Hypseus writes it; a repeated position is refused. Positions not named fill the slots that are left, in the order SDL found them, and a position with no pad behind it leaves its slot empty. Default: SDL's own order. | `--gamepad_reorder=DIGITS` | Which physical pad fills which gamepad slot, as enumeration positions counting from `0`, one for each slot in turn: `--gamepad_reorder=10` makes the second pad found player one and the first player two. Written as bare digits (`3210`) or separated by commas or spaces, as Hypseus writes it; a repeated position is refused. Positions not named fill the slots that are left, in the order SDL found them, and a position with no pad behind it leaves its slot empty. Default: SDL's own order.
| `--haptic=STEP` | The strongest rumble step a game may use, `0` to `4`. `0` turns rumble off altogether, and a lower number quietens a game that asks for more: `controllerDoRumble` never rumbles harder than this. Default: `4`, which is every step a game asks for. | `--haptic=STEP` | The strongest rumble step a game may use, `0` to `4`. `0` turns rumble off altogether, and a lower number quietens a game that asks for more: `controllerDoRumble` never rumbles harder than this. Default: `4`, which is every step a game asks for.
@ -930,10 +930,14 @@ ignore real time altogether: instead of asking the machine what time it is, the
engine runs on a virtual clock that moves a fixed step -- 15 milliseconds by engine runs on a virtual clock that moves a fixed step -- 15 milliseconds by
default -- exactly once a frame. Everything paced by time follows that clock: default -- exactly once a frame. Everything paced by time follows that clock:
physics steps, sprite and model animation, particles, navigation, the GUI's physics steps, sprite and model animation, particles, navigation, the GUI's
animations and transitions, `os.clock()` and `singeGetTicks()`. The disc follows animations and transitions, `os.clock()`, `singeGetTicks()`, and a bare
it too: while it is playing it advances exactly one video frame for every frame `os.time()` (a game that seeds its generator from the wall clock when a game
the engine draws, from wherever it was last parked, and a paused or searched starts seeds it from the virtual one, so it draws the same numbers every run;
disc stays where it was put. Lua's generator and the engine's own are seeded `os.time` given a date table converts it as Lua always has). The disc follows
it too: while it is playing, each video frame stays on for as many engine frames
as its presentation time is worth on that clock (two or three at the default
step for a thirty-frame disc), as in real play, and a paused or searched disc
stays where it was put. Lua's generator and the engine's own are seeded
from a fixed number. Nothing waits for anything, so the game runs as fast as the machine from a fixed number. Nothing waits for anything, so the game runs as fast as the machine
will let it. will let it.
@ -1230,21 +1234,25 @@ function onControllerMoved(axis, value, which)
--]] --]]
end end
function onInputPressed(what) function onInputPressed(what, device)
--[[ --[[
When in keyboard MODE_NORMAL, input events are reported here when the When in keyboard MODE_NORMAL, input events are reported here when the
key or button is first depressed. For a full list of keys, buttons, key or button is first depressed. For a full list of keys, buttons,
and controllers, see Singe/Framework.singe. For MODE_FULL, this event and controllers, see Singe/Framework.singe. For MODE_FULL, this event
will be called with the keysym of the key pressed. will be called with the keysym of the key pressed. When the switch
came from a mouse or a light gun, device is that device's index
(0 for the first), as Hypseus passes it, so a two-gun game can tell
its players apart; for a key or a pad it is nil.
--]] --]]
end end
function onInputReleased(what) function onInputReleased(what, device)
--[[ --[[
When in keyboard MODE_NORMAL, input events are reported here when the When in keyboard MODE_NORMAL, input events are reported here when the
key or button is released. For a full list of keys, buttons, and key or button is released. For a full list of keys, buttons, and
controllers, see Singe/Framework.singe. For MODE_FULL, this event controllers, see Singe/Framework.singe. For MODE_FULL, this event
will be called with the keysym of the key released. will be called with the keysym of the key released. device is as
for onInputPressed.
--]] --]]
end end
@ -2220,7 +2228,8 @@ more than a Raspberry Pi 4 shares with its display. Textures shipped as KTX2
files (Basis Universal, made by `util/compressTextures.py` from PNGs or from files (Basis Universal, made by `util/compressTextures.py` from PNGs or from
a whole `.gltf`) stay compressed on the GPU at a quarter of the size or a whole `.gltf`) stay compressed on the GPU at a quarter of the size or
less, in whichever block format the machine has: BC7 on desktops, ASTC on less, in whichever block format the machine has: BC7 on desktops, ASTC on
Apple GPUs and the Pi 4, BC3 on older cards, and plain RGBA where nothing Apple GPUs and the Pi 4, ETC2 on any OpenGL ES 3 part (every handheld's
Mali, the Pi under GLES), BC3 on older cards, and plain RGBA where nothing
else exists, all from the one file. Give a `.ktx2` name in place of a sprite else exists, all from the one file. Give a `.ktx2` name in place of a sprite
to `materialSetTexture` and the map calls, and a model whose file uses to `materialSetTexture` and the map calls, and a model whose file uses
`KHR_texture_basisu` loads them on its own. `sceneGetStats` reports the `KHR_texture_basisu` loads them on its own. `sceneGetStats` reports the
@ -7027,7 +7036,7 @@ fontQuality(FONT_QUALITY_BLENDED)
fontSelect(id) fontSelect(id)
---- ----
Makes a loaded font the one used by `fontPrint` and `fontToSprite`. The selection persists until the next `fontSelect` or `fontLoad`, since loading also selects. It can be changed as often as needed, including several times within one `onOverlayUpdate`. An unknown or unloaded handle ends the script with an error. Makes a loaded font the one used by `fontPrint` and `fontToSprite`. The selection persists until the next `fontSelect` or `fontLoad`, since loading also selects. It can be changed as often as needed, including several times within one `onOverlayUpdate`. An unknown or unloaded handle (or no number at all) leaves the current font selected and prints a warning, as Hypseus Singe lets it pass.
*Since:* 1.x *Since:* 1.x
*See also:* <<fontload,fontLoad>>, <<fontprint,fontPrint>>, <<fonttosprite,fontToSprite>> *See also:* <<fontload,fontLoad>>, <<fontprint,fontPrint>>, <<fonttosprite,fontToSprite>>
@ -10052,10 +10061,10 @@ musicPause()
[source,text] [source,text]
---- ----
musicPlay(id [, loops]) id = musicPlay(id [, loops])
---- ----
Hypseus extension. Plays a track from its beginning. `loops` means what <<soundplay,`soundPlay`>>'s does: `0`, the default, plays once, `-1` plays forever, and `N` repeats `N` more times. The count is clamped to `-1` to `64`, as Hypseus clamps it, so a game asking for more gets what it would have got there. Hypseus extension. Plays a track from its beginning and answers its handle, which is how Hypseus's games keep it (`sndHandle = musicPlay(music)`) to stop the piece later. `loops` means what <<soundplay,`soundPlay`>>'s does: `0`, the default, plays once, `-1` plays forever, and `N` repeats `N` more times. The count is clamped to `-1` to `64`, as Hypseus clamps it, so a game asking for more gets what it would have got there.
Playing a track that is already playing starts it again from the beginning. Playing a track that is already playing starts it again from the beginning.
@ -10064,7 +10073,7 @@ Playing a track that is already playing starts it again from the beginning.
* `id` -- number; a music handle. * `id` -- number; a music handle.
* `loops` -- optional number; `-1` to `64`. Default `0`. * `loops` -- optional number; `-1` to `64`. Default `0`.
*Returns:* nothing. *Returns:* number; the handle it was given.
*Since:* 3.00, as a Hypseus Singe extension. *Since:* 3.00, as a Hypseus Singe extension.
*See also:* <<musicstop,musicStop>>, <<musicsetvolume,musicSetVolume>> *See also:* <<musicstop,musicStop>>, <<musicsetvolume,musicSetVolume>>
@ -10138,7 +10147,7 @@ musicSetVolume(soundGetVolume() * 2)
musicStop([id [, fadeMs]]) musicStop([id [, fadeMs]])
---- ----
Hypseus extension. Stops that track, or every piece of music when no handle is given. `fadeMs` fades it out over that many milliseconds instead of cutting it; `0`, the default, stops at once. A negative fade terminates the script. Hypseus extension. Stops that track, or every piece of music when no handle is given (a `nil` handle counts as none). `fadeMs` fades it out over that many milliseconds instead of cutting it; `0`, the default, stops at once. A negative fade terminates the script.
*Parameters:* *Parameters:*

View file

@ -35,7 +35,8 @@ typedef enum Ktx2FormatE {
KTX2_RGBA = 0, // Uncompressed, the fallback KTX2_RGBA = 0, // Uncompressed, the fallback
KTX2_BC7 = 1, // Desktop GPUs KTX2_BC7 = 1, // Desktop GPUs
KTX2_ASTC = 2, // ASTC 4x4: Apple GPUs, the Raspberry Pi 4 and phones KTX2_ASTC = 2, // ASTC 4x4: Apple GPUs, the Raspberry Pi 4 and phones
KTX2_BC3 = 3 // Older desktop GPUs KTX2_BC3 = 3, // Older desktop GPUs
KTX2_ETC2 = 4 // ETC2 with EAC alpha: the one block format OpenGL ES 3 guarantees (handhelds, the Pi under GLES)
} Ktx2FormatE; } Ktx2FormatE;
typedef struct Ktx2LevelS { typedef struct Ktx2LevelS {

View file

@ -48,6 +48,8 @@ static basist::transcoder_texture_format _target(Ktx2FormatE format) {
return basist::transcoder_texture_format::cTFASTC_4x4_RGBA; return basist::transcoder_texture_format::cTFASTC_4x4_RGBA;
case KTX2_BC3: case KTX2_BC3:
return basist::transcoder_texture_format::cTFBC3_RGBA; return basist::transcoder_texture_format::cTFBC3_RGBA;
case KTX2_ETC2:
return basist::transcoder_texture_format::cTFETC2_RGBA;
default: default:
return basist::transcoder_texture_format::cTFRGBA32; return basist::transcoder_texture_format::cTFRGBA32;
} }

View file

@ -332,6 +332,11 @@ bool rgpuSubmitCommandBuffer(SDL_GPUCommandBuffer *commandBuffer) {
bool rgpuTextureSupportsFormat(SDL_GPUDevice *device, SDL_GPUTextureFormat format, SDL_GPUTextureType type, SDL_GPUTextureUsageFlags usage) { bool rgpuTextureSupportsFormat(SDL_GPUDevice *device, SDL_GPUTextureFormat format, SDL_GPUTextureType type, SDL_GPUTextureUsageFlags usage) {
// A format of our own naming (ETC2) is the GLES backend's alone; SDL's function would index
// its tables past the end with it.
if ((format >= RGPU_TEXTUREFORMAT_PRIVATE_FIRST) && (_api != RENDER_GLES)) {
return false;
}
return _backend->textureSupportsFormat(device, format, type, usage); return _backend->textureSupportsFormat(device, format, type, usage);
} }

View file

@ -53,6 +53,13 @@ extern "C" {
// code pointer is then the ES source string rather than a blob, and its size is ignored. // code pointer is then the ES source string rather than a blob, and its size is ignored.
#define RGPU_SHADERFORMAT_ESSL (1u << 16) #define RGPU_SHADERFORMAT_ESSL (1u << 16)
// Texture formats SDL_GPU has no name for, kept past the end of its enum: ETC2 with EAC alpha,
// the one block format OpenGL ES 3 guarantees. The GLES backend offers them; the SDL_GPU backend
// never sees them (render.c answers no before asking it).
#define RGPU_TEXTUREFORMAT_PRIVATE_FIRST ((SDL_GPUTextureFormat)(SDL_GPU_TEXTUREFORMAT_ASTC_12x12_FLOAT + 1))
#define RGPU_TEXTUREFORMAT_ETC2_RGBA8_UNORM ((SDL_GPUTextureFormat)(SDL_GPU_TEXTUREFORMAT_ASTC_12x12_FLOAT + 1))
#define RGPU_TEXTUREFORMAT_ETC2_RGBA8_UNORM_SRGB ((SDL_GPUTextureFormat)(SDL_GPU_TEXTUREFORMAT_ASTC_12x12_FLOAT + 2))
typedef struct RenderBackendS { typedef struct RenderBackendS {
SDL_GPUCommandBuffer * (*acquireCommandBuffer)(SDL_GPUDevice *device); SDL_GPUCommandBuffer * (*acquireCommandBuffer)(SDL_GPUDevice *device);

View file

@ -58,6 +58,8 @@
#define GLES_FRAMEBUFFER_CACHE 32 #define GLES_FRAMEBUFFER_CACHE 32
#define GLES_LOG_BYTES 1024 // A driver's compile or link log, which is the only clue it gives #define GLES_LOG_BYTES 1024 // A driver's compile or link log, which is the only clue it gives
#define GLES_NAME_BYTES 128 // A uniform block's name as SPIRV-Cross emits it #define GLES_NAME_BYTES 128 // A uniform block's name as SPIRV-Cross emits it
#define GLES_BLOCK_SIDE 4 // Pixels along a block's side, ETC2 and ASTC 4x4 alike
#define GLES_BLOCK_BYTES 16 // Bytes a block takes in either
// A texture's storage. A multisampled colour target is a renderbuffer rather than a texture: the // A texture's storage. A multisampled colour target is a renderbuffer rather than a texture: the
// scene only ever resolves it and never samples it, and multisampled textures are ES 3.1 with an // scene only ever resolves it and never samples it, and multisampled textures are ES 3.1 with an
@ -153,6 +155,7 @@ typedef struct GlesDeviceS {
uint32_t uniformOffset; uint32_t uniformOffset;
int32_t uniformAlignment; int32_t uniformAlignment;
GLfloat maxAnisotropy; // 0 when the driver has no GL_EXT_texture_filter_anisotropic GLfloat maxAnisotropy; // 0 when the driver has no GL_EXT_texture_filter_anisotropic
bool astc; // GL_KHR_texture_compression_astc_ldr: ASTC 4x4 textures
bool uniformWrapped; bool uniformWrapped;
bool ready; bool ready;
} GlesDeviceT; } GlesDeviceT;
@ -165,6 +168,7 @@ static void _bindTextureUnit(uint32_t unit, GlesTextureT *te
static GLenum _blendFactor(SDL_GPUBlendFactor factor); static GLenum _blendFactor(SDL_GPUBlendFactor factor);
static GLenum _blendOp(SDL_GPUBlendOp op); static GLenum _blendOp(SDL_GPUBlendOp op);
static GLenum _compare(SDL_GPUCompareOp op); static GLenum _compare(SDL_GPUCompareOp op);
static uint32_t _compressedBytes(SDL_GPUTextureFormat format, uint32_t width, uint32_t height);
static void _formatToGl(SDL_GPUTextureFormat format, GLenum *internalFormat, GLenum *layout, GLenum *type); static void _formatToGl(SDL_GPUTextureFormat format, GLenum *internalFormat, GLenum *layout, GLenum *type);
static GLuint _framebufferFor(const SDL_GPUColorTargetInfo *colour, uint32_t colourCount, const SDL_GPUDepthStencilTargetInfo *depth, uint32_t *width, uint32_t *height); static GLuint _framebufferFor(const SDL_GPUColorTargetInfo *colour, uint32_t colourCount, const SDL_GPUDepthStencilTargetInfo *depth, uint32_t *width, uint32_t *height);
static SDL_GPUCommandBuffer * _glesAcquireCommandBuffer(SDL_GPUDevice *device); static SDL_GPUCommandBuffer * _glesAcquireCommandBuffer(SDL_GPUDevice *device);
@ -332,13 +336,42 @@ static GLenum _compare(SDL_GPUCompareOp op) {
} }
// The bytes a block-compressed level of the given size takes, or zero for a format that is not
// block compressed. ETC2 with EAC alpha and ASTC 4x4 both pack a 4x4 block into sixteen bytes.
static uint32_t _compressedBytes(SDL_GPUTextureFormat format, uint32_t width, uint32_t height) {
bool block = (format == RGPU_TEXTUREFORMAT_ETC2_RGBA8_UNORM) || (format == RGPU_TEXTUREFORMAT_ETC2_RGBA8_UNORM_SRGB) || (format == SDL_GPU_TEXTUREFORMAT_ASTC_4x4_UNORM) || (format == SDL_GPU_TEXTUREFORMAT_ASTC_4x4_UNORM_SRGB);
if (!block) {
return 0;
}
return ((width + GLES_BLOCK_SIDE - 1) / GLES_BLOCK_SIDE) * ((height + GLES_BLOCK_SIDE - 1) / GLES_BLOCK_SIDE) * GLES_BLOCK_BYTES;
}
// The formats the engine actually asks for. Anything else reports zero, and rgpuTextureSupportsFormat // The formats the engine actually asks for. Anything else reports zero, and rgpuTextureSupportsFormat
// turns that into a plain no so the caller falls back the way it already does on a weak device. // turns that into a plain no so the caller falls back the way it already does on a weak device.
// The block formats: ETC2 is every ES 3 part's, ASTC 4x4 the extension's.
static void _formatToGl(SDL_GPUTextureFormat format, GLenum *internalFormat, GLenum *layout, GLenum *type) { static void _formatToGl(SDL_GPUTextureFormat format, GLenum *internalFormat, GLenum *layout, GLenum *type) {
*internalFormat = 0; *internalFormat = 0;
*layout = GL_RGBA; *layout = GL_RGBA;
*type = GL_UNSIGNED_BYTE; *type = GL_UNSIGNED_BYTE;
// (The ETC2 names are ours, past SDL's enum, so they are not cases of its switch.)
if (format == RGPU_TEXTUREFORMAT_ETC2_RGBA8_UNORM) {
*internalFormat = GL_COMPRESSED_RGBA8_ETC2_EAC;
return;
}
if (format == RGPU_TEXTUREFORMAT_ETC2_RGBA8_UNORM_SRGB) {
*internalFormat = GL_COMPRESSED_SRGB8_ALPHA8_ETC2_EAC;
return;
}
switch (format) { switch (format) {
case SDL_GPU_TEXTUREFORMAT_ASTC_4x4_UNORM:
*internalFormat = _device.astc ? GL_COMPRESSED_RGBA_ASTC_4x4_KHR : 0;
break;
case SDL_GPU_TEXTUREFORMAT_ASTC_4x4_UNORM_SRGB:
*internalFormat = _device.astc ? GL_COMPRESSED_SRGB8_ALPHA8_ASTC_4x4_KHR : 0;
break;
case SDL_GPU_TEXTUREFORMAT_R8G8B8A8_UNORM: case SDL_GPU_TEXTUREFORMAT_R8G8B8A8_UNORM:
*internalFormat = GL_RGBA8; *internalFormat = GL_RGBA8;
break; break;
@ -1307,13 +1340,20 @@ static void _glesUploadToTexture(SDL_GPUCopyPass *copyPass, const SDL_GPUTexture
GLenum internalFormat; GLenum internalFormat;
GLenum layout; GLenum layout;
GLenum type; GLenum type;
uint32_t compressed;
(void)copyPass; (void)copyPass;
(void)cycle; (void)cycle;
_formatToGl(texture->format, &internalFormat, &layout, &type); _formatToGl(texture->format, &internalFormat, &layout, &type);
glBindTexture(texture->target, texture->name); glBindTexture(texture->target, texture->name);
glPixelStorei(GL_UNPACK_ALIGNMENT, 1); glPixelStorei(GL_UNPACK_ALIGNMENT, 1);
if (texture->target == GL_TEXTURE_2D) { compressed = _compressedBytes(texture->format, destination->w, destination->h);
if (compressed > 0) {
// A block-compressed level arrives whole from the transcoder; its size is its block count.
GLenum face = (texture->target == GL_TEXTURE_CUBE_MAP) ? (GL_TEXTURE_CUBE_MAP_POSITIVE_X + destination->layer) : GL_TEXTURE_2D;
glCompressedTexSubImage2D(face, (GLint)destination->mip_level, (GLint)destination->x, (GLint)destination->y, (GLsizei)destination->w, (GLsizei)destination->h, internalFormat, (GLsizei)compressed, transfer->data + source->offset);
} else if (texture->target == GL_TEXTURE_2D) {
glTexSubImage2D(GL_TEXTURE_2D, (GLint)destination->mip_level, (GLint)destination->x, (GLint)destination->y, (GLsizei)destination->w, (GLsizei)destination->h, layout, type, transfer->data + source->offset); glTexSubImage2D(GL_TEXTURE_2D, (GLint)destination->mip_level, (GLint)destination->x, (GLint)destination->y, (GLsizei)destination->w, (GLsizei)destination->h, layout, type, transfer->data + source->offset);
} else if (texture->target == GL_TEXTURE_CUBE_MAP) { } else if (texture->target == GL_TEXTURE_CUBE_MAP) {
glTexSubImage2D(GL_TEXTURE_CUBE_MAP_POSITIVE_X + destination->layer, (GLint)destination->mip_level, (GLint)destination->x, (GLint)destination->y, (GLsizei)destination->w, (GLsizei)destination->h, layout, type, transfer->data + source->offset); glTexSubImage2D(GL_TEXTURE_CUBE_MAP_POSITIVE_X + destination->layer, (GLint)destination->mip_level, (GLint)destination->x, (GLint)destination->y, (GLsizei)destination->w, (GLsizei)destination->h, layout, type, transfer->data + source->offset);
@ -1565,6 +1605,8 @@ bool renderGlesStart(void) {
glGetFloatv(GL_MAX_TEXTURE_MAX_ANISOTROPY_EXT, &_device.maxAnisotropy); glGetFloatv(GL_MAX_TEXTURE_MAX_ANISOTROPY_EXT, &_device.maxAnisotropy);
} }
utilTrace("Gles: anisotropy %s", _device.maxAnisotropy > 1.0f ? "available" : "not offered"); utilTrace("Gles: anisotropy %s", _device.maxAnisotropy > 1.0f ? "available" : "not offered");
_device.astc = (extensions != NULL) && (strstr(extensions, "GL_KHR_texture_compression_astc_ldr") != NULL);
utilTrace("Gles: block textures: ETC2, %s", _device.astc ? "and ASTC 4x4" : "no ASTC");
} }
glGetIntegerv(GL_UNIFORM_BUFFER_OFFSET_ALIGNMENT, &_device.uniformAlignment); glGetIntegerv(GL_UNIFORM_BUFFER_OFFSET_ALIGNMENT, &_device.uniformAlignment);
if (_device.uniformAlignment <= 0) { if (_device.uniformAlignment <= 0) {

View file

@ -73,6 +73,10 @@ typedef intptr_t GLsizeiptr;
#define GL_COLOR_BUFFER_BIT 0x00004000 #define GL_COLOR_BUFFER_BIT 0x00004000
#define GL_COMPARE_REF_TO_TEXTURE 0x884E #define GL_COMPARE_REF_TO_TEXTURE 0x884E
#define GL_COMPILE_STATUS 0x8B81 #define GL_COMPILE_STATUS 0x8B81
#define GL_COMPRESSED_RGBA8_ETC2_EAC 0x9278
#define GL_COMPRESSED_RGBA_ASTC_4x4_KHR 0x93B0
#define GL_COMPRESSED_SRGB8_ALPHA8_ASTC_4x4_KHR 0x93D0
#define GL_COMPRESSED_SRGB8_ALPHA8_ETC2_EAC 0x9279
#define GL_CONSTANT_COLOR 0x8001 #define GL_CONSTANT_COLOR 0x8001
#define GL_CULL_FACE 0x0B44 #define GL_CULL_FACE 0x0B44
#define GL_CW 0x0900 #define GL_CW 0x0900
@ -220,6 +224,7 @@ typedef void (GL_APIENTRYP GlesClearDepthfFn)(GLfloat d);
typedef void (GL_APIENTRYP GlesClearStencilFn)(GLint s); typedef void (GL_APIENTRYP GlesClearStencilFn)(GLint s);
typedef void (GL_APIENTRYP GlesColorMaskFn)(GLboolean red, GLboolean green, GLboolean blue, GLboolean alpha); typedef void (GL_APIENTRYP GlesColorMaskFn)(GLboolean red, GLboolean green, GLboolean blue, GLboolean alpha);
typedef void (GL_APIENTRYP GlesCompileShaderFn)(GLuint shader); typedef void (GL_APIENTRYP GlesCompileShaderFn)(GLuint shader);
typedef void (GL_APIENTRYP GlesCompressedTexSubImage2DFn)(GLenum target, GLint level, GLint xoffset, GLint yoffset, GLsizei width, GLsizei height, GLenum format, GLsizei imageSize, const void *data);
typedef void (GL_APIENTRYP GlesCopyTexSubImage2DFn)(GLenum target, GLint level, GLint xoffset, GLint yoffset, GLint x, GLint y, GLsizei width, GLsizei height); typedef void (GL_APIENTRYP GlesCopyTexSubImage2DFn)(GLenum target, GLint level, GLint xoffset, GLint yoffset, GLint x, GLint y, GLsizei width, GLsizei height);
typedef GLuint (GL_APIENTRYP GlesCreateProgramFn)(void); typedef GLuint (GL_APIENTRYP GlesCreateProgramFn)(void);
typedef GLuint (GL_APIENTRYP GlesCreateShaderFn)(GLenum type); typedef GLuint (GL_APIENTRYP GlesCreateShaderFn)(GLenum type);
@ -313,6 +318,7 @@ typedef struct GlesApiS {
GlesClearStencilFn clearStencil; GlesClearStencilFn clearStencil;
GlesColorMaskFn colorMask; GlesColorMaskFn colorMask;
GlesCompileShaderFn compileShader; GlesCompileShaderFn compileShader;
GlesCompressedTexSubImage2DFn compressedTexSubImage2D;
GlesCopyTexSubImage2DFn copyTexSubImage2D; GlesCopyTexSubImage2DFn copyTexSubImage2D;
GlesCreateProgramFn createProgram; GlesCreateProgramFn createProgram;
GlesCreateShaderFn createShader; GlesCreateShaderFn createShader;
@ -413,6 +419,7 @@ bool renderGlesLoad(void);
#define glClearStencil _glesApi.clearStencil #define glClearStencil _glesApi.clearStencil
#define glColorMask _glesApi.colorMask #define glColorMask _glesApi.colorMask
#define glCompileShader _glesApi.compileShader #define glCompileShader _glesApi.compileShader
#define glCompressedTexSubImage2D _glesApi.compressedTexSubImage2D
#define glCopyTexSubImage2D _glesApi.copyTexSubImage2D #define glCopyTexSubImage2D _glesApi.copyTexSubImage2D
#define glCreateProgram _glesApi.createProgram #define glCreateProgram _glesApi.createProgram
#define glCreateShader _glesApi.createShader #define glCreateShader _glesApi.createShader

View file

@ -54,6 +54,7 @@ bool renderGlesLoad(void) {
_glesApi.clearStencil = (GlesClearStencilFn)SDL_GL_GetProcAddress("glClearStencil"); _glesApi.clearStencil = (GlesClearStencilFn)SDL_GL_GetProcAddress("glClearStencil");
_glesApi.colorMask = (GlesColorMaskFn)SDL_GL_GetProcAddress("glColorMask"); _glesApi.colorMask = (GlesColorMaskFn)SDL_GL_GetProcAddress("glColorMask");
_glesApi.compileShader = (GlesCompileShaderFn)SDL_GL_GetProcAddress("glCompileShader"); _glesApi.compileShader = (GlesCompileShaderFn)SDL_GL_GetProcAddress("glCompileShader");
_glesApi.compressedTexSubImage2D = (GlesCompressedTexSubImage2DFn)SDL_GL_GetProcAddress("glCompressedTexSubImage2D");
_glesApi.copyTexSubImage2D = (GlesCopyTexSubImage2DFn)SDL_GL_GetProcAddress("glCopyTexSubImage2D"); _glesApi.copyTexSubImage2D = (GlesCopyTexSubImage2DFn)SDL_GL_GetProcAddress("glCopyTexSubImage2D");
_glesApi.createProgram = (GlesCreateProgramFn)SDL_GL_GetProcAddress("glCreateProgram"); _glesApi.createProgram = (GlesCreateProgramFn)SDL_GL_GetProcAddress("glCreateProgram");
_glesApi.createShader = (GlesCreateShaderFn)SDL_GL_GetProcAddress("glCreateShader"); _glesApi.createShader = (GlesCreateShaderFn)SDL_GL_GetProcAddress("glCreateShader");
@ -148,6 +149,7 @@ bool renderGlesLoad(void) {
"glClearStencil", "glClearStencil",
"glColorMask", "glColorMask",
"glCompileShader", "glCompileShader",
"glCompressedTexSubImage2D",
"glCopyTexSubImage2D", "glCopyTexSubImage2D",
"glCreateProgram", "glCreateProgram",
"glCreateShader", "glCreateShader",

View file

@ -3560,6 +3560,9 @@ static SDL_GPUTexture *_uploadCompressed(const Ktx2ImageT *image, bool srgb) {
case KTX2_BC3: case KTX2_BC3:
info.format = srgb ? SDL_GPU_TEXTUREFORMAT_BC3_RGBA_UNORM_SRGB : SDL_GPU_TEXTUREFORMAT_BC3_RGBA_UNORM; info.format = srgb ? SDL_GPU_TEXTUREFORMAT_BC3_RGBA_UNORM_SRGB : SDL_GPU_TEXTUREFORMAT_BC3_RGBA_UNORM;
break; break;
case KTX2_ETC2:
info.format = srgb ? RGPU_TEXTUREFORMAT_ETC2_RGBA8_UNORM_SRGB : RGPU_TEXTUREFORMAT_ETC2_RGBA8_UNORM;
break;
default: default:
info.format = srgb ? SDL_GPU_TEXTUREFORMAT_R8G8B8A8_UNORM_SRGB : SDL_GPU_TEXTUREFORMAT_R8G8B8A8_UNORM; info.format = srgb ? SDL_GPU_TEXTUREFORMAT_R8G8B8A8_UNORM_SRGB : SDL_GPU_TEXTUREFORMAT_R8G8B8A8_UNORM;
break; break;
@ -5441,15 +5444,20 @@ bool sceneInit(SDL_GPUDevice *device, SDL_Renderer *renderer) {
_scene.depthFormat = _depthFormat(); _scene.depthFormat = _depthFormat();
_scene.shadowFormat = _shadowFormat(); _scene.shadowFormat = _shadowFormat();
_scene.hdrFormat = _hdrFormat(); _scene.hdrFormat = _hdrFormat();
// What KTX2 textures become: the best block format the device has, else plain RGBA. // What KTX2 textures become: the best block format the device has, else plain RGBA. ETC2
// comes before BC3 because every OpenGL ES 3 part has it, and a Mali without ASTC would
// otherwise fall all the way to RGBA on the devices with the least memory.
_scene.compressedFormat = KTX2_RGBA; _scene.compressedFormat = KTX2_RGBA;
if (rgpuTextureSupportsFormat(device, SDL_GPU_TEXTUREFORMAT_BC7_RGBA_UNORM_SRGB, SDL_GPU_TEXTURETYPE_2D, SDL_GPU_TEXTUREUSAGE_SAMPLER)) { if (rgpuTextureSupportsFormat(device, SDL_GPU_TEXTUREFORMAT_BC7_RGBA_UNORM_SRGB, SDL_GPU_TEXTURETYPE_2D, SDL_GPU_TEXTUREUSAGE_SAMPLER)) {
_scene.compressedFormat = KTX2_BC7; _scene.compressedFormat = KTX2_BC7;
} else if (rgpuTextureSupportsFormat(device, SDL_GPU_TEXTUREFORMAT_ASTC_4x4_UNORM_SRGB, SDL_GPU_TEXTURETYPE_2D, SDL_GPU_TEXTUREUSAGE_SAMPLER)) { } else if (rgpuTextureSupportsFormat(device, SDL_GPU_TEXTUREFORMAT_ASTC_4x4_UNORM_SRGB, SDL_GPU_TEXTURETYPE_2D, SDL_GPU_TEXTUREUSAGE_SAMPLER)) {
_scene.compressedFormat = KTX2_ASTC; _scene.compressedFormat = KTX2_ASTC;
} else if (rgpuTextureSupportsFormat(device, RGPU_TEXTUREFORMAT_ETC2_RGBA8_UNORM_SRGB, SDL_GPU_TEXTURETYPE_2D, SDL_GPU_TEXTUREUSAGE_SAMPLER)) {
_scene.compressedFormat = KTX2_ETC2;
} else if (rgpuTextureSupportsFormat(device, SDL_GPU_TEXTUREFORMAT_BC3_RGBA_UNORM_SRGB, SDL_GPU_TEXTURETYPE_2D, SDL_GPU_TEXTUREUSAGE_SAMPLER)) { } else if (rgpuTextureSupportsFormat(device, SDL_GPU_TEXTUREFORMAT_BC3_RGBA_UNORM_SRGB, SDL_GPU_TEXTURETYPE_2D, SDL_GPU_TEXTUREUSAGE_SAMPLER)) {
_scene.compressedFormat = KTX2_BC3; _scene.compressedFormat = KTX2_BC3;
} }
utilTrace("Scene: KTX2 textures as %s", (const char *[]){ "RGBA", "BC7", "ASTC 4x4", "BC3", "ETC2" }[_scene.compressedFormat]);
if (!_createShaders()) { if (!_createShaders()) {
sceneQuit(); sceneQuit();
return false; return false;

View file

@ -1271,6 +1271,7 @@ static int32_t apiNodeSetSpriteFrame(lua_State *L);
static int32_t apiNodeSetText(lua_State *L); static int32_t apiNodeSetText(lua_State *L);
static int32_t apiNodeSetVisible(lua_State *L); static int32_t apiNodeSetVisible(lua_State *L);
static int32_t apiOsClock(lua_State *L); static int32_t apiOsClock(lua_State *L);
static int32_t apiOsTime(lua_State *L);
static int32_t apiOverlayBox(lua_State *L); static int32_t apiOverlayBox(lua_State *L);
static int32_t apiOverlayCircle(lua_State *L); static int32_t apiOverlayCircle(lua_State *L);
static int32_t apiOverlayClear(lua_State *L); static int32_t apiOverlayClear(lua_State *L);
@ -2631,7 +2632,13 @@ static void _deliverKey(bool down, int32_t keysym, int32_t scancode) {
_global.switchHeld[move] = down; _global.switchHeld[move] = down;
// A pad or mouse switch steers a GUI that takes input; a key already reached it as a key. // A pad or mouse switch steers a GUI that takes input; a key already reached it as a key.
if ((scancode < CODE_GAMEPAD_BASE) || !_guiSwitch((InputE)move, down)) { if ((scancode < CODE_GAMEPAD_BASE) || !_guiSwitch((InputE)move, down)) {
_callLua(down ? "onInputPressed" : "onInputReleased", "i", move); // A switch from a mouse or gun names its device too, as Hypseus does, so a
// two-gun game can tell its players apart.
if (scancode >= CODE_MOUSE_BASE) {
_callLua(down ? "onInputPressed" : "onInputReleased", "ii", move, (scancode - CODE_MOUSE_BASE) / CODE_MOUSE_STRIDE);
} else {
_callLua(down ? "onInputPressed" : "onInputReleased", "i", move);
}
} }
break; break;
} }
@ -7503,7 +7510,12 @@ static SDL_Surface *_spriteTransform(SpriteT *sprite, SDL_Surface *source) {
if ((sprite->angle == 0.0) && (sprite->scaleX == 1.0) && (sprite->scaleY == 1.0)) { if ((sprite->angle == 0.0) && (sprite->scaleX == 1.0) && (sprite->scaleY == 1.0)) {
copy = SDL_DuplicateSurface(source); copy = SDL_DuplicateSurface(source);
} else { } else {
copy = rotoZoomSurface(source, -sprite->angle, sprite->scaleX, sprite->scaleY, sprite->smooth != 0); // A scale that would leave no pixels (a bullet shrinking to nothing) draws as one pixel
// rather than failing the transform; the scale the script set is kept as it reads back.
double scaleX = fmax(fabs(sprite->scaleX), 1.0 / (double)source->w);
double scaleY = fmax(fabs(sprite->scaleY), 1.0 / (double)source->h);
copy = rotoZoomSurface(source, -sprite->angle, scaleX, scaleY, sprite->smooth != 0);
} }
if ((copy != NULL) && (sprite->flip != SDL_FLIP_NONE)) { if ((copy != NULL) && (sprite->flip != SDL_FLIP_NONE)) {
SDL_FlipSurface(copy, sprite->flip); SDL_FlipSurface(copy, sprite->flip);
@ -7577,6 +7589,13 @@ static void _startLuaContext(lua_State *L) {
lua_getglobal(L, "os"); lua_getglobal(L, "os");
lua_pushcfunction(L, apiOsClock); lua_pushcfunction(L, apiOsClock);
lua_setfield(L, -2, "clock"); lua_setfield(L, -2, "clock");
// A deterministic run reads the same wall clock every time it plays: games seed their
// generator from os.time() when a game starts, and the run must repeat.
if (_global.conf->deterministic) {
lua_getfield(L, -1, "time");
lua_pushcclosure(L, apiOsTime, 1);
lua_setfield(L, -2, "time");
}
lua_pop(L, 1); lua_pop(L, 1);
// Games written for 2.x seed the generator with fractional numbers; Lua 5.4 refuses them. // Games written for 2.x seed the generator with fractional numbers; Lua 5.4 refuses them.
@ -9856,10 +9875,22 @@ static int32_t apiFontQuality(lua_State *L) {
} }
// fontSelect(id) // fontSelect(id) A font that was never loaded (a script naming one it misspelled) leaves the
// current font as it is with a warning, as Hypseus lets it pass, instead of ending the script.
static int32_t apiFontSelect(lua_State *L) { static int32_t apiFontSelect(lua_State *L) {
FontT *font = NULL;
int32_t id = 0;
_argCheck(L, "fontSelect", 1, 1); _argCheck(L, "fontSelect", 1, 1);
_global.fontCurrent = _argFont(L, "fontSelect", 1); if (lua_isnumber(L, 1)) {
id = (int32_t)lua_tointeger(L, 1);
HASH_FIND_INT(_global.fontList, &id, font);
}
if (!font) {
utilSay("Warning: fontSelect names no font (%s); the current font stays.", lua_isnumber(L, 1) ? "a bad index" : "not a number");
return 0;
}
_global.fontCurrent = font;
_luaTrace(L, "fontSelect", "%d", _global.fontCurrent->id); _luaTrace(L, "fontSelect", "%d", _global.fontCurrent->id);
return 0; return 0;
@ -11306,9 +11337,10 @@ static int32_t apiMusicPause(lua_State *L) {
} }
// musicPlay(id [, loops]) Hypseus extension. loops means what soundPlay means: 0 (the default) // id = musicPlay(id [, loops]) Hypseus extension. loops means what soundPlay means: 0 (the
// plays once, -1 forever, N repeats N more times. Hypseus clamps the count to -1 to 64 and so does // default) plays once, -1 forever, N repeats N more times. Hypseus clamps the count to -1 to 64 and
// this, so a game asking for more gets what it would have got there. // so does this, so a game asking for more gets what it would have got there. Answers the handle it
// was given, as Hypseus does: the games keep it (sndHandle = musicPlay(music)) to stop the piece.
static int32_t apiMusicPlay(lua_State *L) { static int32_t apiMusicPlay(lua_State *L) {
MusicT *music = NULL; MusicT *music = NULL;
int32_t loops = 0; int32_t loops = 0;
@ -11329,8 +11361,9 @@ static int32_t apiMusicPlay(lua_State *L) {
} }
SDL_DestroyProperties(options); SDL_DestroyProperties(options);
_luaTrace(L, "musicPlay", "%d loops %d", music->id, loops); _luaTrace(L, "musicPlay", "%d loops %d", music->id, loops);
lua_pushinteger(L, music->id);
return 0; return 1;
} }
@ -11375,12 +11408,13 @@ static int32_t apiMusicSetVolume(lua_State *L) {
} }
// musicStop([id [, fadeMs]]) Hypseus extension. With no handle every piece of music stops, as // musicStop([id [, fadeMs]]) Hypseus extension. With no handle (or a nil one) every piece of
// Hypseus stops whatever its music mixer is playing. A fade is in milliseconds. // music stops, as Hypseus stops whatever its music mixer is playing. A fade is in milliseconds.
static int32_t apiMusicStop(lua_State *L) { static int32_t apiMusicStop(lua_State *L) {
MusicT *music = NULL; MusicT *music = NULL;
MusicT *next = NULL; MusicT *next = NULL;
int32_t fade = 0; int32_t fade = 0;
bool one = false;
_argCheck(L, "musicStop", 0, 2); _argCheck(L, "musicStop", 0, 2);
if (lua_gettop(L) >= 2) { if (lua_gettop(L) >= 2) {
@ -11389,7 +11423,8 @@ static int32_t apiMusicStop(lua_State *L) {
_luaDie(L, "musicStop", "A fade cannot be negative: %d", fade); _luaDie(L, "musicStop", "A fade cannot be negative: %d", fade);
} }
} }
if (lua_gettop(L) >= 1) { one = !lua_isnoneornil(L, 1);
if (one) {
music = _argMusic(L, "musicStop", 1); music = _argMusic(L, "musicStop", 1);
MIX_StopTrack(music->track, MIX_TrackMSToFrames(music->track, fade)); MIX_StopTrack(music->track, MIX_TrackMSToFrames(music->track, fade));
} else { } else {
@ -11397,7 +11432,7 @@ static int32_t apiMusicStop(lua_State *L) {
MIX_StopTrack(music->track, MIX_TrackMSToFrames(music->track, fade)); MIX_StopTrack(music->track, MIX_TrackMSToFrames(music->track, fade));
} }
} }
_luaTrace(L, "musicStop", "%d fade %d", (lua_gettop(L) >= 1) ? music->id : -1, fade); _luaTrace(L, "musicStop", "%d fade %d", one ? music->id : -1, fade);
return 0; return 0;
} }
@ -12224,6 +12259,21 @@ static int32_t apiOsClock(lua_State *L) {
} }
// os.time([table]) In a deterministic run the bare call answers with the virtual clock's seconds
// from the option's seed, so a game seeding from the wall clock lands on the same numbers every
// run; a call with a table is a date conversion, and Lua's own answers it.
static int32_t apiOsTime(lua_State *L) {
if (lua_gettop(L) == 0) {
lua_pushinteger(L, (lua_Integer)_global.conf->deterministicStep + (lua_Integer)(utilTicks() / MS_PER_SECOND));
return 1;
}
lua_pushvalue(L, lua_upvalueindex(1));
lua_insert(L, 1);
lua_call(L, lua_gettop(L) - 1, LUA_MULTRET);
return lua_gettop(L);
}
// overlayBox(x1, y1, x2, y2) Outline only. // overlayBox(x1, y1, x2, y2) Outline only.
static int32_t apiOverlayBox(lua_State *L) { static int32_t apiOverlayBox(lua_State *L) {
int32_t x1 = 0; int32_t x1 = 0;
@ -16836,7 +16886,10 @@ void singe(SDL_Window *window, SDL_Renderer *renderer, SDL_GPUDevice *device, Co
// refreshes continuously. Emptying them per rendered frame therefore drew the GUIs and // refreshes continuously. Emptying them per rendered frame therefore drew the GUIs and
// the 2D particles only on the frames the script happened to run on, which is a menu that // the 2D particles only on the frames the script happened to run on, which is a menu that
// blinks over a grid that does not. // blinks over a grid that does not.
if (!_global.frozen && (utilTicks() > frameClock)) { //
// The throttle is inclusive: on the virtual clock every loop iteration is exactly one
// step, and a strict comparison ran the script every other iteration, a disc frame apart.
if (!_global.frozen && (utilTicks() >= frameClock)) {
intReturn = OVERLAY_NOT_UPDATED; intReturn = OVERLAY_NOT_UPDATED;
_global.guiDrawCount = 0; _global.guiDrawCount = 0;
_global.overlayLayerCount = 0; _global.overlayLayerCount = 0;

View file

@ -2600,36 +2600,28 @@ int64_t videoUpdate(int32_t playerHandle, SDL_Texture **texture) {
_resetClock(v, now); _resetClock(v, now);
} else { } else {
if (v->playing) { if (v->playing) {
if (utilIsDeterministic()) { // Where in the video should we be? Follow the audio when there is any. A
// Decoding has a clock of its own, which no virtual clock reaches, so a // deterministic run follows the virtual clock instead: the audio's own clock is
// reproducible run steps the disc a frame at a time from wherever it is // real time, which no virtual clock reaches, and the frame loop is paced as a
// parked. A paused disc and a search still settle where they always did. // real one is, so a frame stays on for as many frames as its time is worth.
v->frame++; if (utilIsDeterministic() || (v->audioSourceCount == 0)) {
if (v->frame >= count) { elapsed = (int64_t)(now - v->startTicks) + v->startTime;
} else {
elapsed = _audioClock(v, now);
}
// Advance to the last frame whose presentation time has arrived.
next = v->frame + 1;
while ((next < count) && (_frameTime(v, next) <= elapsed)) {
v->frame = next;
next++;
}
// Past the end of the last frame? Loop.
if (next >= count) {
lastDuration = (int64_t)(MS_PER_SECOND * (double)v->fps.den / (double)v->fps.num);
if (elapsed >= _frameTime(v, count - 1) + lastDuration) {
v->frame = 0; v->frame = 0;
_resetClock(v, now); _resetClock(v, now);
} }
} else {
// Where in the video should we be? Follow the audio when there is any.
if (v->audioSourceCount > 0) {
elapsed = _audioClock(v, now);
} else {
elapsed = (int64_t)(now - v->startTicks) + v->startTime;
}
// Advance to the last frame whose presentation time has arrived.
next = v->frame + 1;
while ((next < count) && (_frameTime(v, next) <= elapsed)) {
v->frame = next;
next++;
}
// Past the end of the last frame? Loop.
if (next >= count) {
lastDuration = (int64_t)(MS_PER_SECOND * (double)v->fps.den / (double)v->fps.num);
if (elapsed >= _frameTime(v, count - 1) + lastDuration) {
v->frame = 0;
_resetClock(v, now);
}
}
} }
} }
} }

4
testScripts/Menu.sh Executable file
View file

@ -0,0 +1,4 @@
#!/usr/bin/env bash
cd "$(dirname "$0")"
"/home/scott/claude/singe/.builddir/linux-gcc/x86_64/singe/Singe-v3.00-Linux-x86_64" -k -w -v Singe/menuBackground.mkv Singe/Menu.singe

View file

@ -68,3 +68,4 @@ screenshot or two and quits by itself; the results are in screenshots/.
| scene49.singe | 49 | 48.5 | Input over the disc: every device the engine can see on one page -- the four gamepad slots with the name, the six axis values and the fifteen button states of whatever is in each, the mice ManyMouse found with their names, the last mouse position and how many times `SWITCH_MOUSE_DISCONNECT` has fired, the keyboard mode, and the switches the mappings are currently letting through -- with `DEAD_ZONE`, `TRIGGER_THRESHOLD` (raw and as a per cent of full travel) and `joyMouseIsEnabled()` across the top. The phases are timed on the wall clock (shots at 2, 6, 10 and 16 seconds, quitting at 18) so a device can be attached, pressed and unplugged while it runs; the LD_PRELOAD harness in the scratchpad drives an SDL virtual joystick through exactly that. Run plain, with `--joymouse --js_range 20`, with `--gamepad_reorder 10`, with `--trigger_threshold 99.5`, with `--mapjoysticks=false`, with `--manymouse --absolutes_only`, with a `gamecontrollerdb.txt` beside it, and with a `controls.cfg` setting `DEAD_ZONES` | | scene49.singe | 49 | 48.5 | Input over the disc: every device the engine can see on one page -- the four gamepad slots with the name, the six axis values and the fifteen button states of whatever is in each, the mice ManyMouse found with their names, the last mouse position and how many times `SWITCH_MOUSE_DISCONNECT` has fired, the keyboard mode, and the switches the mappings are currently letting through -- with `DEAD_ZONE`, `TRIGGER_THRESHOLD` (raw and as a per cent of full travel) and `joyMouseIsEnabled()` across the top. The phases are timed on the wall clock (shots at 2, 6, 10 and 16 seconds, quitting at 18) so a device can be attached, pressed and unplugged while it runs; the LD_PRELOAD harness in the scratchpad drives an SDL virtual joystick through exactly that. Run plain, with `--joymouse --js_range 20`, with `--gamepad_reorder 10`, with `--trigger_threshold 99.5`, with `--mapjoysticks=false`, with `--manymouse --absolutes_only`, with a `gamecontrollerdb.txt` beside it, and with a `controls.cfg` setting `DEAD_ZONES` |
| scene50.singe | 50 | 48.6 | Subtitles and the disc picture over the disc: `testScripts/subtitles.srt` (four cues, one of them two lines and one carrying a `<bracket>` and an `&` that must reach the screen as text) parsed into disc frames at the disc's own frame rate and shown through the shipped `Singe/subtitle.rml`, with `testScripts/broken.srt` refused (`srtLoad` answers false and loads nothing); the disc searched to a known frame before every shot so a cue is read off the disc's frame number and not the wall clock; `srtPosition` at 15 and 80 per cent; `overlayBanner` taking the cue's place for thirty drawn frames and then giving it back; `srtEnable` off and on; `srtClear`; `vldpSetLuma` at 0, 8, 4 and off; `vldpSetBlend` on, off and together with the luma; and `vldpFlash`, asked for and shot in the same tick because it lasts one drawn frame. 23 screenshots | | scene50.singe | 50 | 48.6 | Subtitles and the disc picture over the disc: `testScripts/subtitles.srt` (four cues, one of them two lines and one carrying a `<bracket>` and an `&` that must reach the screen as text) parsed into disc frames at the disc's own frame rate and shown through the shipped `Singe/subtitle.rml`, with `testScripts/broken.srt` refused (`srtLoad` answers false and loads nothing); the disc searched to a known frame before every shot so a cue is read off the disc's frame number and not the wall clock; `srtPosition` at 15 and 80 per cent; `overlayBanner` taking the cue's place for thirty drawn frames and then giving it back; `srtEnable` off and on; `srtClear`; `vldpSetLuma` at 0, 8, 4 and off; `vldpSetBlend` on, off and together with the luma; and `vldpFlash`, asked for and shot in the same tick because it lasts one drawn frame. 23 screenshots |
| scene51.singe | 51 | 48.6 | Sprites, in-memory assets and music over the disc: `spriteRotateFrame` turning frames 1 to 4 of `testScripts/arrows.png` (a four frame strip whose arrows have a white notch, so a rotation is readable) at 0, 45, 135 and 225 degrees, drawn by `spriteDrawRotatedFrame` plain, at 1.5 and at 2 by 0.75, beside the same frame through `spriteDrawFrame` which ignores both; `spriteScale` before a rotation, so the pair honours the sprite's own scale; an out of range frame falling back to frame 1; `spriteDrawGrid` taking three rectangles out of the same file loaded as one wide still and a fourth out of a sprite `spriteLoadData` built from the file's bytes; `spriteGetFrames` on all three; `controllerHowMany`; `setOverlayOpacity` at 96 and back to 255; and the music family over `testScripts/tune.mp3` (six seconds of 330 Hz, so the built-in mp3 decoder is exercised) -- play looping, `musicSetVolume` at 128 and 16, `soundSetVolume` at 8 with a `soundLoadData` sound played through it to show the two volumes do not move each other, pause, resume, a 250 ms fade out, a play once and an unload. 16 screenshots | | scene51.singe | 51 | 48.6 | Sprites, in-memory assets and music over the disc: `spriteRotateFrame` turning frames 1 to 4 of `testScripts/arrows.png` (a four frame strip whose arrows have a white notch, so a rotation is readable) at 0, 45, 135 and 225 degrees, drawn by `spriteDrawRotatedFrame` plain, at 1.5 and at 2 by 0.75, beside the same frame through `spriteDrawFrame` which ignores both; `spriteScale` before a rotation, so the pair honours the sprite's own scale; an out of range frame falling back to frame 1; `spriteDrawGrid` taking three rectangles out of the same file loaded as one wide still and a fourth out of a sprite `spriteLoadData` built from the file's bytes; `spriteGetFrames` on all three; `controllerHowMany`; `setOverlayOpacity` at 96 and back to 255; and the music family over `testScripts/tune.mp3` (six seconds of 330 Hz, so the built-in mp3 decoder is exercised) -- play looping, `musicSetVolume` at 128 and 16, `soundSetVolume` at 8 with a `soundLoadData` sound played through it to show the two volumes do not move each other, pause, resume, a 250 ms fade out, a play once and an unload. 16 screenshots |
| scene99.singe | 52 | 50 | Formats: a lossless JPEG XL of crate.png (crate.jxl) drawn beside the PNG, which it must match, and the chiptune and tracker decoders played as music and as a sound (chip.nsf, tracker.xm, tracker.it, tracker.s3m); the run's log says what played |

View file

@ -2,7 +2,7 @@
-- Fandango's shape. The rules, the dialogue, the verbs, and the parser are the 2D game's own, -- Fandango's shape. The rules, the dialogue, the verbs, and the parser are the 2D game's own,
-- taken from its description; only the types and the rooms are new. That the rules do not -- taken from its description; only the types and the rooms are new. That the rules do not
-- change between a painting and a scene is the point. -- change between a painting and a scene is the point.
local base = dofile("testScripts/author/adventure2d.game") local base = dofile("testScripts/author/adventure2d.forge")
return { return {
title = "Author test: adventure in 3D", title = "Author test: adventure in 3D",

View file

@ -0,0 +1,34 @@
-- Mash and hold on the branching track: one window wants RIGHT six times, the next wants UP
-- held for twenty frames, the last wants RIGHT mashed again and gets too few.
return {
title = "Author test: mash and hold",
layers = { { kind = "disc", file = "Singe/menuBackground.mkv", start = 100 }, { kind = "overlay" } },
vars = { taken = 0, missed = 0, wanted = 0, opened = "" },
types = {
dirk = { look = { kind = "none" }, behaviours = { { kind = "branching", track = "moves" } } }
},
rooms = {
{ name = "cavern",
entities = { { type = "dirk", id = "dirk", x = 0, y = 0 } },
tracks = {
{ name = "moves", key = "frame",
branches = { { from = 120, to = 160, move = "RIGHT", mash = 6, success = 200, fail = 200 },
{ from = 200, to = 240, move = "UP", hold = 20, success = 280, fail = 280 },
{ from = 280, to = 320, move = "RIGHT", mash = 6, success = 340, fail = 340 } } }
} }
},
rules = {
{ note = "A window opens: remember what it wants",
on = "branchOpen",
act = { { "setVar", name = "wanted", value = "event.mash or event.hold or 0" }, { "setVar", name = "opened", value = "opened .. event.move .. \" \"" } } },
{ note = "Made it",
on = "branchTaken",
act = { { "addVar", name = "taken", amount = 1 } } },
{ note = "Missed it",
on = "branchMissed",
act = { { "addVar", name = "missed", amount = 1 } } }
}
}

View file

@ -0,0 +1,47 @@
-- The pieces the ActionMax port asked for, over the menu video: a text look with a font, size,
-- and anchor of its own; a sprite drawn in copies (a row of coins); a crosshair on the pointer;
-- the disc paused and played by rules; and pointerX in an expression.
return {
title = "Author test: overlay bits",
players = 1,
size = { w = 720, h = 480 },
layers = { { kind = "disc", file = "Singe/menuBackground.mkv", start = 100 }, { kind = "overlay" } },
vars = { coins = 3, side = "", held = 0 },
types = {
title = { look = { kind = "text", text = "OVERLAY BITS", r = 255, g = 220, b = 80, font = "Singe/FreeSansBold.ttf", size = 36, anchor = "top" } },
foot = { look = { kind = "text", text = "standing on the bottom", r = 200, g = 200, b = 255, anchor = "feet" } },
mid = { look = { kind = "text", text = "", r = 255, g = 255, b = 255, anchor = "centre" } },
coins = { look = { kind = "sprite", file = "Forge/kit/coin.png", step = 40 }, vars = { copies = 3 } },
cross = { look = { kind = "box", w = 9, h = 9, r = 255, g = 80, b = 80 }, behaviours = { { kind = "pointer", player = 1 } } }
},
rooms = {
{ name = "board",
entities = {
{ type = "title", id = "title", x = 360, y = 10 },
{ type = "foot", id = "foot", x = 360, y = 480 },
{ type = "mid", id = "mid", x = 360, y = 240 },
{ type = "coins", id = "coins", x = 40, y = 40 },
{ type = "cross", id = "cross", x = 0, y = 0 }
} }
},
rules = {
{ note = "The coins follow the var",
on = "frame",
act = { { "setVar", entity = "coins", name = "copies", value = "coins" } } },
{ note = "Which side the pointer is on",
on = "frame", when = { { "test", expr = "pointerX(1) < 360" } },
act = { { "setVar", name = "side", value = "\"left\"" }, { "setText", entity = "mid", text = "\"pointer on the \" .. side" } } },
{ note = "Or the other",
on = "frame", when = { { "test", expr = "pointerX(1) >= 360" } },
act = { { "setVar", name = "side", value = "\"right\"" }, { "setText", entity = "mid", text = "\"pointer on the \" .. side" } } },
{ note = "SPACE holds the disc",
on = "pressed", key = "SPACE",
act = { { "discPause" }, { "setVar", name = "held", value = "frame" } } },
{ note = "RETURN lets it go",
on = "pressed", key = "RETURN",
act = { { "discPlay" } } }
}
}

View file

@ -0,0 +1,32 @@
-- A game with a vocabulary of its own: the bob behaviour, the bobbing condition, and the setBob
-- action live in vocab.singe beside this file, not in Forge.
return {
title = "Author test: a game's own vocabulary",
players = 1,
size = { w = 720, h = 480 },
layers = { { kind = "overlay" } },
vocabulary = "vocab.singe",
vars = { seen = 0 },
types = {
buoy = { look = { kind = "box", w = 30, h = 30, r = 80, g = 200, b = 255 }, behaviours = { { kind = "bob", height = 40, speed = 3 } } },
rock = { look = { kind = "box", w = 30, h = 30, r = 120, g = 120, b = 120 } }
},
rooms = {
{ name = "sea",
entities = {
{ type = "buoy", id = "buoy", x = 200, y = 240 },
{ type = "rock", id = "rock", x = 500, y = 240 }
} }
},
rules = {
{ note = "Counts the frames the buoy bobs",
on = "frame", when = { { "bobbing", entity = "buoy" } },
act = { { "setVar", name = "seen", value = "seen + 1" } } },
{ note = "SPACE makes it bob higher",
on = "pressed", key = "SPACE",
act = { { "setBob", entity = "buoy", height = 100 } } }
}
}

View file

@ -0,0 +1,47 @@
-- A game's own vocabulary: a behaviour, a condition, and an action this game alone knows,
-- written as Author.singe writes its own manifest entries. The description names this file
-- (vocabulary = "vocab.singe"); nothing else in Forge has heard of a bob.
-- A bob: the entity rises and falls on a sine wave about where it was put.
AUTHOR.behaviours.bob = {
help = "Rises and falls about its place, height pixels either way, speed times a second.",
params = { height = "number", speed = "number" },
attach = function(instance, params)
local _, y = authorPosition(instance)
instance.bob = { height = params.height or 20, speed = params.speed or 2, base = y, at = 0 }
end,
step = function(instance, dt)
local bob = instance.bob
local x, _, z = authorPosition(instance)
bob.at = bob.at + dt * bob.speed
nodeSetPosition(instance.node, x, bob.base + math.sin(bob.at) * bob.height, z)
end
}
-- Whether an instance bobs: a condition of this game's own.
AUTHOR.conditions.bobbing = {
help = "An instance has a bob.",
params = { entity = "entity" },
emit = function(p) return "vocabBobbing(" .. p.entity .. ")" end
}
-- Sets how high an instance bobs: an action of this game's own.
AUTHOR.actions.setBob = {
help = "Changes how high an instance bobs.",
params = { entity = "entity", height = "number" },
emit = function(p) return "vocabSetBob(" .. p.entity .. ", " .. p.height .. ")" end
}
function vocabBobbing(instance)
return (instance ~= nil) and (instance.bob ~= nil)
end
function vocabSetBob(instance, height)
if instance and instance.bob then
instance.bob.height = height
end
end

BIN
testScripts/chip.nsf (Stored with Git LFS) Normal file

Binary file not shown.

BIN
testScripts/crate.jxl (Stored with Git LFS) Normal file

Binary file not shown.

View file

@ -541,4 +541,15 @@ GAMES = {
DEVELOPER = "Test", DEVELOPER = "Test",
PUBLISHER = "Test", PUBLISHER = "Test",
}, },
{
TITLE = "Formats: JPEG XL and Chiptunes",
SCRIPT = "testScripts/scene99.singe",
VIDEO = "Singe/menuBackground.mkv",
DESCRIPTION = "A JPEG XL picture (crate.jxl, lossless from crate.png) drawn beside its PNG, which it must match pixel for pixel, and the chiptune and tracker decoders exercised as music and as a sound: game-music-emu's NSF (chip.nsf), libxmp's XM, IT and S3M (tracker.*).",
YEAR = 2026,
GENRE = "Test",
PLATFORM = "Singe",
DEVELOPER = "Test",
PUBLISHER = "Test",
},
} }

30
testScripts/ports/README Normal file
View file

@ -0,0 +1,30 @@
Port comparisons (FORGE.md section 14): each pair runs an original game from the test library
and its Forge port with the same scripted inputs, on the deterministic clock, and prints TRACE
lines; the port passes when the two traces agree. They need the library beside the repo
(SINGE_LIBRARY, or ~/claude/singetest) and are not part of the reference sweep.
ActionMax: actionMaxOriginal.singe and actionMaxPort.singe, with
-v <library>/ActionMax/frame_<game>.txt and PORT_GAME=<game>
(38AmbushAlley, BlueThunder, Hydrosub2021, PopsGhostly, SonicFury);
the descriptions come from util/forgePortActionMax.py.
compare.sh <family> <game> runs a pair and says whether the traces agree:
testScripts/ports/compare.sh actionMax 38AmbushAlley
Karis: karisOriginal.singe and karisPort.singe, driven by karisDrive.singe, with
-v the video the game's games.dat names, PORT_GAME=<directory> and
PORT_SCRIPT=<main script>; the descriptions come from util/forgePortKaris.lua:
testScripts/ports/compare.sh karis TimeGal/Timegal.singe
ALG: algOriginal.singe and algPort.singe, driven by algDrive.singe, for the American
Laser Games titles of the Hypseus library (Mad Dog McCree, Mad Dog II, Crime
Patrol, Drug Wars, The Last Bounty Hunter, Space Pirates, both Who Shot Johnny
Rocks, and the typing edition of Mad Dog II), with the title's folder as the game:
testScripts/ports/compare.sh alg maddog-hd
PORT_FRAMES=<n> sets the run's length in engine frames (15000 by default) and
PORT_PERFECT=1 plays without a wrong or a missed shot, to walk the whole game.
The views hand the drive each level's shoot window and the first hitbox of the
frame (aimed at the overlap with the next frame's box, since a press is judged a
frame later), the targets of the games' menus and puzzles, and, for the typing
edition, the key the game waits for; the drive shoots, misses on schedule, coins
and starts, continues once, and types.

View file

@ -0,0 +1,36 @@
-- The inputs both the original ActionMax emulator and its Forge port are driven with, by frame
-- of the script's own clock, so the two can be compared move for move (FORGE.md section 14.1).
-- A shot is a pointer position and a trigger pull. The title takes one pull, the menu one on
-- the left half (a standard game), and then the picture is shot at from a dozen places while
-- it plays.
PORT_INPUTS = {
{ at = 40, x = 300, y = 200 }, -- Title: pull to start.
{ at = 140, x = 100, y = 240 }, -- Menu: the left half, a standard game.
}
-- In .38 Ambush Alley the picture flickers with the sensor -- targets -- at 240,140 from disc
-- frame 2968, at 220,140 from 2591, and at 220,80 from 2577, all left of the sensor's own corner,
-- which the game reaches about 2740 frames in. Most shots go there, a few elsewhere, so good
-- and bad judgments both come up.
local TARGETS = { { x = 240, y = 140 }, { x = 220, y = 140 }, { x = 220, y = 80 } }
for shot = 1, 40 do
local target = TARGETS[(shot % 3) + 1]
local input = { at = 2740 + shot * 28, x = target.x, y = target.y }
if shot % 4 == 0 then
input.x, input.y = 40 + (shot * 97) % 240, 20 + (shot * 53) % 150
end
PORT_INPUTS[#PORT_INPUTS + 1] = input
end
PORT_LAST = 2740 + 41 * 28 + 200
-- The input due this frame, if any.
function portInputAt(frame)
for _, input in ipairs(PORT_INPUTS) do
if input.at == frame then
return input
end
end
return nil
end

View file

@ -0,0 +1,52 @@
-- The original ActionMax emulator, driven by the shared inputs, tracing what a port must match:
-- its state, its counts, and the disc frame at every input and every change of state. Run
-- with -v <library>/ActionMax/frame_38AmbushAlley.txt; PORT_GAME names another of the five.
local library = os.getenv("SINGE_LIBRARY") or (os.getenv("HOME") .. "/claude/singetest/ported")
local game = os.getenv("PORT_GAME") or "38AmbushAlley"
local script = library .. "/ActionMax/" .. game .. ".singe"
-- The inputs sit beside this script, wherever the engine was started from.
local here = ((debug.getinfo(1, "S").source:gsub("^@", "")):match("^(.*[/\\])") or "")
dofile(here .. "actionMaxInputs.singe")
-- The emulator finds its files by its own script's path, and draws sprites in the 2.10 order,
-- which its games.dat entry would have said.
SINGE_LEGACY_SPRITE_ARGS = true
local realScriptPath = singeGetScriptPath
singeGetScriptPath = function() return script end
dofile(script)
singeGetScriptPath = realScriptPath
local realUpdate = onOverlayUpdate
local frames = 0
local lastState = nil
function onOverlayUpdate()
frames = frames + 1
local input = portInputAt(frames)
if input then
onMouseMoved(input.x, input.y, 0, 0)
onInputPressed(SWITCH_BUTTON3)
debugPrint(string.format("TRACE input %d at %d,%d: state %d frame %d shots %d good %d bad %d", frames, input.x, input.y, currentState, discGetFrame(), shotsFired, shotsGood, shotsBad))
end
local r = realUpdate()
if os.getenv("PORT_SENSOR_TRACE") and (frames >= tonumber(os.getenv("PORT_SENSOR_TRACE"))) and (frames < tonumber(os.getenv("PORT_SENSOR_TRACE")) + 12) then
debugPrint(string.format("SENSOR frame %d disc %d pulled %d spot %s/%s gun %s/%s mouse %d,%d", frames, discGetFrame(), triggerPulled, tostring(sensorLastState), tostring(sensorState), tostring(gunLastState), tostring(gunState), mouseX, mouseY))
end
-- The startup and setup states are the emulator's own housekeeping; the title is the first
-- state a port has.
if (currentState ~= lastState) and (currentState >= 2) then
lastState = currentState
debugPrint(string.format("TRACE state %d from frame %d: disc %d shots %d good %d bad %d", currentState, frames, discGetFrame(), shotsFired, shotsGood, shotsBad))
end
if frames >= PORT_LAST then
debugPrint(string.format("TRACE end: state %d disc %d shots %d good %d bad %d", currentState, discGetFrame(), shotsFired, shotsGood, shotsBad))
singeQuit()
end
return r
end

View file

@ -0,0 +1,52 @@
-- The Forge port of an ActionMax game, driven by the same inputs as the original and tracing
-- the same things, in the original's terms: its state numbers stand for the port's rooms.
local library = os.getenv("SINGE_LIBRARY") or (os.getenv("HOME") .. "/claude/singetest/ported")
local game = os.getenv("PORT_GAME") or "38AmbushAlley"
local STATES = { startup = 0, title = 2, menu = 3, intro = 4, playing = 5, gameOver = 6 }
dofile("Forge/AuthorCompile.singe")
-- The inputs sit beside this script, wherever the engine was started from.
local here = ((debug.getinfo(1, "S").source:gsub("^@", "")):match("^(.*[/\\])") or "")
dofile(here .. "actionMaxInputs.singe")
dofile(authorBuild(library .. "/ActionMax/" .. game .. ".forge", singeGetDataPath() .. game .. ".singe"))
local realUpdate = onOverlayUpdate
local frames = 0
local lastRoom = nil
local function counts()
return string.format("shots %d good %d bad %d", AUTHOR_VARS.shots, AUTHOR_VARS.good, AUTHOR_VARS.bad)
end
function onOverlayUpdate()
frames = frames + 1
local input = portInputAt(frames)
if input then
authorSetPointer(1, input.x, input.y, true)
authorSwitchDown(SWITCH_BUTTON3)
authorSwitchUp(SWITCH_BUTTON3)
debugPrint(string.format("TRACE input %d at %d,%d: state %d frame %d %s", frames, input.x, input.y, STATES[AUTHOR_ROOM.name] or -1, discGetFrame(), counts()))
end
local r = realUpdate()
if os.getenv("PORT_SENSOR_TRACE") and (frames >= tonumber(os.getenv("PORT_SENSOR_TRACE"))) and (frames < tonumber(os.getenv("PORT_SENSOR_TRACE")) + 12) then
local sensor = authorEntity("sensor")
local gx, gy = authorPointer(1)
debugPrint(string.format("SENSOR frame %d disc %d pulled %d spot %s gun %s now %s/%s pointer %d,%d", frames, discGetFrame(), sensor.pulled, tostring(sensor.spotWas), tostring(sensor.gunWas), authorPixelState(312, 205, 127, 20), authorPixelState(gx, gy, 127, 20), gx, gy))
end
if AUTHOR_ROOM.name ~= lastRoom then
lastRoom = AUTHOR_ROOM.name
debugPrint(string.format("TRACE state %d from frame %d: disc %d %s", STATES[AUTHOR_ROOM.name] or -1, frames, discGetFrame(), counts()))
end
if frames >= PORT_LAST then
debugPrint(string.format("TRACE end: state %d disc %d %s", STATES[AUTHOR_ROOM.name] or -1, discGetFrame(), counts()))
singeQuit()
end
return r
end

View file

@ -0,0 +1,263 @@
-- Drives an American Laser Games title (the original or its port) from what the game shows,
-- the same way for both. The driver file defines what it takes: a view() of the game's state
-- and algPress(switch, device, down) / algPointer(device, x, y) to press a gun's trigger or
-- put its pointer; this file decides. The trace lines it prints are what compare.sh diffs.
--
-- The policy: the first gun skips the intro on frame 300 of the run and joins at the player
-- select; at the town select it points at the first unbeaten quadrant and shoots; in a level
-- it shoots the first hitbox of the current window's frame three frames into the window (a
-- miss well outside it on every fifth move, no shot on every seventh), reloads by pointing
-- offscreen when its ammo is out, and takes the continue.
ALG_FRAMES = tonumber(os.getenv("PORT_FRAMES")) or 60000
local answered = {} -- Windows shot at, by "level/move/lives".
local tried = {} -- Moves met before, by "level/move".
local frames = 0
local joined = false
local coined = false
local continued = false
local continueCoined = false
local seeks = 0
SWITCH_RETURN_KEY = 13 -- The return key's keysym, as the typing edition names it.
local wrongTyped = {} -- Moves already given their wrong letter.
local pendingRelease = nil -- { switch, device } pressed this frame, let go the next.
function algTraceSeek(frame)
seeks = seeks + 1
debugPrint(string.format("TRACE seek %d", frame))
end
-- PORT_PERFECT=1 plays without a wrong or a missed shot, to walk the whole game in one run.
local perfect = (os.getenv("PORT_PERFECT") == "1")
-- PORT_SERVICE=1 opens the service screen at the attract loop before the game, steps the
-- pointer down it a row at a time with a shot on each (or, on a screen the stick drives, a
-- press and a step down in turn), then leaves it; the trace carries the option under the
-- cursor and every dip while the screen shows. PORT_SHOTS=1 takes a screenshot at the
-- attract loop, on the service screen, in a level just after a shot, at the continue, and
-- at the board, each once, named for the moment.
local walkService = (os.getenv("PORT_SERVICE") == "1")
local shots = (os.getenv("PORT_SHOTS") == "1")
local walked = false
local walkStep = 0
local walkAt = 0
local taken = {}
local shotAt = nil
local function shot(name)
if shots and not taken[name] then
taken[name] = true
singeScreenshot("shot-" .. name)
end
end
local function wrongMove(view)
return (not perfect) and (view.move % 5 == 0) and not view.retry
end
local function missedMove(view)
return (not perfect) and (view.move % 7 == 0) and (view.move % 5 ~= 0) and not view.retry
end
-- Presses a gun's trigger for one frame.
local function tap(switch, device)
algPress(switch, device, true)
pendingRelease = { switch, device }
end
-- view: { screen, step, move, frame, frames, score, lives, ammo, active, window = { from, to },
-- box = { x1, y1, x2, y2 } (the first hitbox of this frame, in the game's pointer
-- coordinates), quadrants = { {x, y}, ... } (the town select's unbeaten choices),
-- offscreen = { x, y } (a pointer position that counts as off the picture),
-- spare (bullets the game never judges, kept back), reloadSwitch (what reloads) }
function algDrive(view)
if pendingRelease then
algPress(pendingRelease[1], pendingRelease[2], false)
pendingRelease = nil
end
frames = frames + 1
local line = string.format("TRACE screen %s step %s move %s score %s lives %s ammo %s", tostring(view.screen), tostring(view.step), tostring(view.move), tostring(view.score), tostring(view.lives), tostring(view.ammo))
if view.service then
line = line .. " opt " .. tostring(view.opt) .. " dips " .. tostring(view.dips)
end
if line ~= lastLine then
local detail = ""
-- PORT_DEBUG=1 adds what the drive decides by: the window, the spare, the free shot, and
-- the pause.
if os.getenv("PORT_DEBUG") == "1" then
detail = string.format(" win %s spare %s free %s paused %s", view.window and (view.window[1] .. "-" .. view.window[2]) or "nil", tostring(view.spare), tostring(view.free), tostring(view.paused))
end
debugPrint(line .. detail .. " disc " .. tostring(view.frame))
lastLine = line
end
if frames >= ALG_FRAMES then
debugPrint("TRACE end after " .. frames .. " frames")
singeQuit()
return
end
-- A typing game (the typing edition of Mad Dog II): the key the view names is released, one
-- a frame; with misses on, every fifth move gets a wrong letter first and every seventh is
-- left untyped (a death), each once per move. The continue is taken once, by the return key.
if view.typed then
if view.continuing then
if (not continued) and (frames % 30 == 0) then
continued = true
algKey(SWITCH_RETURN_KEY)
end
return
end
if view.typing then
local moveKey = tostring(view.screen) .. "/" .. tostring(view.move)
if (not perfect) and (view.move % 7 == 0) and (view.move % 5 ~= 0) and (view.typing ~= SWITCH_RETURN_KEY) then
-- Untyped: the game holds the disc and takes the life.
return
end
if (not perfect) and (view.move % 5 == 0) and (view.typing ~= SWITCH_RETURN_KEY) and not wrongTyped[moveKey] then
wrongTyped[moveKey] = true
algKey((view.typing == 122) and 121 or 122)
return
end
algKey(view.typing)
end
return
end
-- The service screen, walked once before the game.
if walkService and not walked then
if view.service then
if (frames - walkAt) >= 30 then
walkAt = frames
walkStep = walkStep + 1
if walkStep == 3 then
shot("service")
end
if view.serviceKeys then
-- The stick's screen: a press on the option, then a step down; the last option
-- is the way out.
if walkStep % 2 == 1 then
tap(SWITCH_BUTTON1, 0)
else
tap(SWITCH_DOWN, 0)
end
if walkStep > 26 then
walked = true
end
elseif walkStep <= 9 then
algPointer(0, view.width * 0.5, view.height * (walkStep - 0.5) / 9)
tap(SWITCH_BUTTON3, 0)
else
walked = true
tap(SWITCH_BUTTON1, 0)
end
end
return
elseif view.intro and (frames > 200) then
walkAt = frames
tap(SWITCH_SERVICE, nil)
return
end
end
if shots and view.intro and (frames == 330) then
shot("attract")
end
if shots and view.continuing then
shot("continue")
end
if shots and view.board then
shot("board")
end
if shotAt and (frames == shotAt + 3) then
shot("level")
end
-- The player select: START for the first gun, once.
if (view.screen == -2) and not joined and (frames > 60) then
joined = true
tap(SWITCH_START1, 0)
return
end
-- The intro: a coin, then START begins a game (the tutorial or the first level by dip).
-- (A game with no player select, the one-player Johnny Rock, counts as joined from the start.)
if view.intro and (joined or view.noSelect) then
if not coined and (frames > 200) then
coined = true
tap(SWITCH_COIN1, nil)
return
end
if coined and (frames > 300) and (frames % 40 == 0) then
tap(SWITCH_START1, 0)
end
return
end
-- The continue: taken once, with a coin first (Mad Dog II's continue wants a credit).
if view.continuing and not continued and (frames % 30 == 0) then
if not continueCoined then
continueCoined = true
tap(SWITCH_COIN1, nil)
return
end
continued = true
tap(SWITCH_START1, 0)
return
end
-- The town select: the pointer to the first unbeaten quadrant, then the trigger.
if (view.screen == 100) and view.quadrants and view.quadrants[1] and (view.step == 2) and (frames % 20 == 0) then
algPointer(0, view.quadrants[1][1], view.quadrants[1][2])
tap(SWITCH_BUTTON3, 0)
return
end
-- A screen with targets and no windows (the signpost, the mine, Mad Dog II's menu, item, and
-- skull): the first target shot, every twenty frames, while its state waits for shots (the
-- running state unless the view names another).
if view.targets and view.targets[1] and (view.step == (view.targetStep or 2)) and (frames % 20 == 0) then
if (view.ammo ~= nil) and (view.ammo <= (view.spare or 0)) and view.offscreen then
algPointer(0, view.offscreen[1], view.offscreen[2])
tap(view.reloadSwitch or SWITCH_BUTTON3, 0)
else
algPointer(0, view.targets[1][1], view.targets[1][2])
tap(SWITCH_BUTTON3, 0)
end
return
end
-- A level: ammo out (or down to the spare an edition never judges) means a reload by pointing
-- offscreen and pressing the switch the view names (the trigger, or a reload button).
if view.window and (view.ammo ~= nil) and (view.ammo <= (view.spare or 0)) and view.offscreen then
algPointer(0, view.offscreen[1], view.offscreen[2])
tap(view.reloadSwitch or SWITCH_BUTTON3, 0)
return
end
if view.window and view.box then
-- (A move is answered once per life, and once more per life in the game the continue
-- starts over.)
local moveKey = tostring(view.screen) .. "/" .. tostring(view.move)
local key = moveKey .. "/" .. tostring(view.lives) .. (continued and "/c" or "")
view.retry = (tried[moveKey] ~= nil)
if (view.frame >= view.window[1] + 3) and (view.frame <= view.window[2]) and not answered[key] then
answered[key] = true
tried[moveKey] = true
if missedMove(view) then
return
end
local cx, cy = (view.box[1] + view.box[3]) / 2, (view.box[2] + view.box[4]) / 2
if wrongMove(view) then
cx, cy = view.box[3] + 60, view.box[4] + 60
end
algPointer(0, cx, cy)
tap(SWITCH_BUTTON3, 0)
if shotAt == nil then
shotAt = frames
end
end
end
end

View file

@ -0,0 +1,515 @@
-- An American Laser Games title as it ships, driven by algDrive.singe and traced. PORT_GAME
-- names the title's folder under the library and PORT_SCRIPT its script under it (the Hypseus
-- layout: singe/<Title>/<Title>.singe); run with -v the title's framefile.
local here = ((debug.getinfo(1, "S").source:gsub("^@", "")):match("^(.*[/\\])") or "")
local library = os.getenv("SINGE_LIBRARY") or (os.getenv("HOME") .. "/claude/singetest/hypseus")
local game = os.getenv("PORT_GAME") or "maddog-hd"
local script = library .. "/" .. game .. "/" .. (os.getenv("PORT_SCRIPT") or ("singe/" .. game .. "/" .. game .. ".singe"))
dofile(here .. "algDrive.singe")
SINGE_LEGACY_SPRITE_ARGS = true
local realTime = os.time
os.time = function(...) return 12345 end
local realScriptPath = singeGetScriptPath
singeGetScriptPath = function() return script end
local realSkip = discSkipToFrame
discSkipToFrame = function(frame)
algTraceSeek(frame)
return realSkip(frame)
end
local realSearch = discSearch
discSearch = function(frame)
algTraceSeek(frame)
return realSearch(frame)
end
-- One mouse: a single gun joins by START, as the game does with one mouse.
local realMice = mouseHowMany
mouseHowMany = function() return 1 end
dofile(script)
singeGetScriptPath = realScriptPath
local realUpdate = onOverlayUpdate
-- PORT_HOOK=1 names the line the script is on every ten million instructions: where a run
-- that never ends is spinning.
if os.getenv("PORT_HOOK") == "1" then
debug.sethook(function()
local where = debug.getinfo(2, "Sl")
debugPrint("HOOK " .. tostring(where and where.short_src) .. ":" .. tostring(where and where.currentline))
end, "", 10000000)
end
function algPress(switch, device, down)
if down then
onInputPressed(switch, device)
else
onInputReleased(switch, device)
end
end
-- A key released, as the typing edition reads it (its keysym, in MODE_FULL).
function algKey(keysym)
onInputReleased(keysym)
end
-- The pointer as the game's onMouseMoved computes it: a raw position that lands on the point
-- asked for, in the game's own coordinates.
function algPointer(device, x, y)
local rx = (x + (ratioxOffset or 0)) / ((ratiox or 1) * (gunscale or 1))
local ry = (y + (ratioyOffset or 0)) / ((ratioy or 1) * (gunscale or 1))
onMouseMoved(math.floor(rx), math.floor(ry), 0, 0, device)
end
-- The hitmap's coordinates scaled as the game scales them, where it does (the multiplayer
-- editions); the one-player editions aim in the hitmap's own.
local function hmx(v)
if ResHMx then
return ResHMx(v)
end
return v
end
local function hmy(v)
if ResHMy then
return ResHMy(v)
end
return v
end
-- The hitboxes of a frame in the current move's hitmap range, in pointer coordinates.
local function boxesAt(rel)
local boxes = {}
for k = (move[currentMove][hitmapStart or hitboxStart] or 1), (hitmapTotal or 0) do
local row = hitmap[k]
if row and (row[hitmapFrame] == rel) and (row[hitmapCount] > 0) then
for j = row[hitmapIndex], row[hitmapIndex] + row[hitmapCount] - 1 do
local b = hitbox[j]
-- (A box given back to front -- Platoon's hitbox lines are, often -- can never be
-- hit by the game's own test, so it is no aim.)
if b and (b[bbx1] <= b[bbx2]) and (b[bby1] <= b[bby2]) then
boxes[#boxes + 1] = { hmx(b[bbx1]), hmy(b[bby1]), hmx(b[bbx2]), hmy(b[bby2]) }
end
end
break
end
end
return boxes
end
local function firstBox()
-- The first hitbox of this frame, aimed at a point that also lies inside a box of the next
-- frame when the two overlap: the press the drive makes now is judged on this frame or the
-- next, as the clock falls. Without an overlap (a fast target) the box of this frame stands.
if (move == nil) or (move[currentMove] == nil) or (hitmap == nil) then
return nil
end
local rel = currentFrame - (thisOffset or 0)
local now = boxesAt(rel)
local b = now[1]
if b == nil then
return nil
end
for _, n in ipairs(boxesAt(rel + 1)) do
local x1, y1, x2, y2 = math.max(b[1], n[1]), math.max(b[2], n[2]), math.min(b[3], n[3]), math.min(b[4], n[4])
if (x1 <= x2) and (y1 <= y2) then
return { x1, y1, x2, y2 }
end
end
return b
end
local function view()
local window = nil
-- A level of Mad Dog's (101 to 121) or of Mad Dog II's (its trails, and levelStart); Mad Dog II's
-- move rows have no shoot window of their own, so the move's frames stand in.
local inLevel = (currentLevel ~= nil) and (((frameShootStart ~= nil) and (currentLevel >= 101) and (currentLevel <= 121)) or ((frameShootStart == nil) and (((currentLevel >= 1) and (currentLevel <= 30)) or (currentLevel == levelStart))))
if ((practiceHit ~= nil) or (fRndPopOrder ~= nil)) and (currentLevel == levelPractice) then
inLevel = false
end
if (levelSigns ~= nil) and (currentLevel == levelSigns) then
inLevel = false
end
if (levelTieIntro ~= nil) and ((currentLevel == levelTie) or (currentLevel == levelTieIntro)) then
inLevel = false
end
-- Space Pirates's puzzles and crystals are targets, not windows: the band and Ursula's colour
-- at the doors, Fellina's skull and arrow, the sockets of the crystal build.
if (levelCrystalBuild ~= nil) and ((currentLevel == levelCrystalBuild) or ((currentLevel == levelDoor2) and (lvlState == lvlPlayClip4)) or ((currentLevel == levelDoor3) and (lvlState == lvlPlayClip3)) or ((currentLevel == levelFellina1) and ((lvlState == lvlPlayPuzzle1) or (lvlState == lvlPlayPuzzle2)))) then
inLevel = false
end
-- Who Shot Johnny Rock's store, clue items, and safe are targets, not windows.
if (GetMapArray01 ~= nil) and ((currentLevel == levelStore) or (currentLevel == levelMansion2) or (currentLevel == levelMansion4)) then
inLevel = false
end
-- Space Pirates's Reaper wants exactly as many hits as he laughed: once the drive has them,
-- the moves left are let pass.
if (levelReaper2 ~= nil) and (currentLevel == levelReaper2) and (currentMove > 1) and (iReaperHits >= iReaperLaughs) then
inLevel = false
end
-- The typing edition of Mad Dog II plays on the keyboard: the key the game waits for now (the
-- next letter of the word, a sprinkled letter, the return key at a menu), and no gun at all.
local typing = nil
if validKey ~= nil then
inLevel = false
local function nextOf(word)
if word and (iKeyIndex or 1) <= string.len(word) then
return string.byte(word, iKeyIndex or 1)
end
return nil
end
-- (The game starts with the key slot at zero, not at none, and never clears it.)
local waiting = (iKey == SWITCH_NONE) or (iKey == 0)
if not waiting then
typing = nil
elseif (currentLevel == levelTitle) and (lvlState == lvlRunning) then
typing = SWITCH_RETURN
elseif (currentLevel == levelMenu) and (lvlState == lvlRunning) then
typing = SWITCH_RETURN
elseif (currentLevel == levelHighScore) and (lvlState == lvlRunning) then
-- (Once, before the name's frames run out; the return key seeks to the entry's end.)
if discGetFrame() < offsetMenus + 3275 then
typing = SWITCH_RETURN
end
elseif bShowSprinkles and sprinkles then
for k = 1, (iSprinkleMax or 0) do
if sprinkles[k] and (sprinkles[k][4] == true) then
typing = string.byte(sprinkles[k][3])
break
end
end
elseif (currentLevel == levelItem1) and (lvlState == lvlPlayClip3) then
typing = nextOf(sMagicWord)
elseif move and move[currentMove] and ((lvlState == lvlPauseAction) or ((lvlState == lvlRunning) and (discGetFrame() >= move[currentMove][moveFrameStart]) and (discGetFrame() < move[currentMove][moveFrameEnd]))) then
typing = nextOf(move[currentMove][(currentLevel == levelItem2) and moveType or wordval])
end
end
-- Platoon's patrols open with a clip the first press of the trigger jumps past, whatever the
-- state: no window until that press is in, and a target anywhere to make it.
local skipping = (levelCharlie ~= nil) and bOnce and (currentLevel ~= nil) and (currentLevel >= 1) and (currentLevel <= 6) and (lvlState == lvlRunning)
if skipping then
inLevel = false
end
-- A good guy's move (the Crime Patrol family marks the row) is no window: shooting him is a
-- death, and the drive would take it at every level that has one.
if inLevel and move and move[currentMove] and (rndMoveType ~= nil) and (GOODGUY ~= nil) and (move[currentMove][rndMoveType] == GOODGUY) then
inLevel = false
end
if inLevel and move and move[currentMove] then
window = { move[currentMove][frameShootStart or moveFrameStart], move[currentMove][frameShootEnd or moveFrameEnd] }
end
local quadrants = nil
if (currentLevel == levelTownSelect) and stage then
quadrants = {}
local w, h = resX or 360, resY or 240
-- In the order the game itself takes when the select times out: the saloon before the
-- sheriff, whose level needs the key from it.
if not stage[STAGE_CORRAL] then quadrants[#quadrants + 1] = { w * 0.25, h * 0.25 } end
if not stage[STAGE_SALOON] then quadrants[#quadrants + 1] = { w * 0.75, h * 0.25 } end
if not stage[STAGE_SHERIFF] then quadrants[#quadrants + 1] = { w * 0.25, h * 0.75 } end
if not stage[STAGE_BANK] then quadrants[#quadrants + 1] = { w * 0.75, h * 0.75 } end
end
-- The signpost's sign and the mine's lantern and item, as a list of points to shoot in
-- order (the middle line of each, scaled as the game scales them).
local targets = nil
local targetStep = nil
local function lineMiddle(rows, count)
local row = rows and rows[math.floor((count + 1) / 2)]
if row then
return { hmx((row[lineStartX] + row[lineEndX]) / 2), hmy(row[lineY]) }
end
return nil
end
if skipping then
targets = { { 180, 120 } }
targetStep = lvlRunning
elseif (currentLevel == levelSignSelect) and sign then
targets = {}
if stage[STAGE_FUSE] == false then
targets[1] = lineMiddle(sign[SIGN_PROSPECTOR], 48)
elseif stage[STAGE_MINE] == false then
targets[1] = lineMiddle(sign[SIGN_MINE], 28)
else
targets[1] = lineMiddle(sign[SIGN_HIDEOUT], 58)
end
elseif (currentLevel == levelMine) and mine then
targets = {}
local counts = { [MINE_SIGN] = 21, [MINE_LANTERN] = 20, [MINE_PAN] = 12, [MINE_PITCHFORK] = 23, [MINE_SKULL] = 25 }
if not bLanternHit then targets[#targets + 1] = lineMiddle(mine[MINE_LANTERN], counts[MINE_LANTERN]) end
if not bItemHit then targets[#targets + 1] = lineMiddle(mine[iWhatToHit], counts[iWhatToHit]) end
elseif (currentLevel == levelMaze) and maze and maze[curMazePos] and (maze[curMazePos][mazeType] > 0) and sign then
-- A maze sign: the sign for the way to go.
local frame, dir = maze[curMazePos][signType], maze[curMazePos][signDirection]
local counts = { [32] = { 51, 49 }, [175] = { 59, 17 }, [340] = { 67, 90 }, [500] = { 24, 42 }, [630] = { 11, 30 }, [446] = { 59, 64 } }
local pair = counts[frame]
if pair and sign[dir] then
targets = { lineMiddle(sign[dir], pair[dir]) }
end
elseif (levelItem1 ~= nil) and (ResHMx ~= nil) and (currentLevel == levelItem1) and (lvlState == lvlPlayClip3) then
-- Mad Dog II's first item level: the item the guide asked for, from the game's own
-- rectangles.
local boxes = { [ITEM_LANTERN] = { 57, 66, 117, 129 }, [ITEM_AMMOBOX] = { 120, 148, 111, 132 }, [ITEM_MOONSHINE] = { 260, 272, 60, 72 } }
local b = boxes[iThisItem]
if b then
targets = { { hmx((b[1] + b[2]) / 2), hmy((b[3] + b[4]) / 2) } }
targetStep = lvlPlayClip3
end
elseif (levelProspector1 ~= nil) and (ResHMx ~= nil) and (currentLevel == levelProspector1) and (lvlState == lvlPlayClip4) then
-- Mad Dog II's prospector: the skull he chose, from the game's own rectangles.
local boxes = { { 305, 315, 59, 76 }, { 68, 75, 73, 82 }, { 147, 154, 58, 67 }, { 119, 127, 101, 115 } }
local b = boxes[iSkull] or boxes[4]
targets = { { hmx((b[1] + b[2]) / 2), hmy((b[3] + b[4]) / 2) } }
targetStep = lvlPlayClip4
elseif (panelHit ~= nil) and (currentLevel == levelMenu) and (lvlState == lvlRunning) and area then
-- Crime Patrol's mission menus: the first panel whose stage is open, by the middle line
-- of its area (the count of lines is the game's own).
local MENUS = {}
if MENU_SIERRA ~= nil then
MENUS[MENU_SIERRA] = { { PANEL_PRACTICE, STAGE_PRACTICE, 99 }, { PANEL_BAR, STAGE_BAR, 99 }, { PANEL_CARCHASE, STAGE_CARCHASE, 99 }, { PANEL_HIDEOUT, STAGE_HIDEOUT, 99 } }
MENUS[MENU_CHICAGO] = { { PANEL_HELI, STAGE_HELI, 111 }, { PANEL_COURTROOM, STAGE_COURTROOM, 111 }, { PANEL_BUSCHASE, STAGE_BUSCHASE, 111 } }
MENUS[MENU_BORDER] = { { PANEL_SHIPYARD, STAGE_SHIPYARD, 111 }, { PANEL_BOATHOUSE, STAGE_BOATHOUSE, 111 }, { PANEL_BEACH, STAGE_BEACH, 111 } }
MENUS[MENU_SA] = { { PANEL_BASE, STAGE_BASE, 111 }, { PANEL_NIGHTVISION, STAGE_NIGHTVISION, 111 }, { PANEL_TOWN, STAGE_TOWN, 111 } }
end
if MENU_ROOKIE ~= nil then
MENUS = { [MENU_ROOKIE] = { { PANEL_PRACTICE, STAGE_PRACTICE, 98 }, { PANEL_STORE, STAGE_STORE, 99 }, { PANEL_GANG, STAGE_GANG, 99 }, { PANEL_WAREHOUSE, STAGE_WAREHOUSE, 97 } },
[MENU_UNDERCOVER] = { { PANEL_AIRPORT, STAGE_AIRPORT, 104 }, { PANEL_GARAGE, STAGE_GARAGE, 104 }, { PANEL_STRIPCLUB, STAGE_STRIPCLUB, 104 } },
[MENU_SWAT] = { { PANEL_DRUGS1, STAGE_DRUGS1, 111 }, { PANEL_DRUGS2, STAGE_DRUGS2, 111 }, { PANEL_BANK, STAGE_ROBBERY, 103 } },
[MENU_DELTA] = { { PANEL_HANGAR, STAGE_HANGAR, 108 }, { PANEL_AMBUSH, STAGE_AMBUSH, 103 }, { PANEL_AIRTERRORISTS, STAGE_AIRTERRORISTS, 109 } } }
end
for _, entry in ipairs(MENUS[iCurrentMenu] or {}) do
if not panel[entry[2]] then
targets = { lineMiddle(area[entry[1]], entry[3]) }
break
end
end
elseif (GetArrayPanels ~= nil) and (currentLevel == levelMenu) and (lvlState == lvlRunning) and area then
-- The Last Bounty Hunter's wanted posters: the first outlaw still at large, by the middle of
-- his panel's rectangle.
for _, stage in ipairs({ STAGE_HARRY, STAGE_DAN, STAGE_LOCO, STAGE_CACTUS }) do
local r = (panel[stage] == ATLARGE) and area[stage] and area[stage][1]
if r then
targets = { { hmx((r[lineStartX] + r[lineEndX]) / 2), hmy((r[lineStartY] + r[lineEndY]) / 2) } }
break
end
end
elseif (levelSigns ~= nil) and (currentLevel == levelSigns) and (lvlState == lvlEnd) then
-- The Last Bounty Hunter's signs: the practice range, in the game's own pointer coordinates.
targets = { { 175, 101 } }
targetStep = lvlEnd
elseif (iLastMove ~= nil) and (levelFinal ~= nil) and (currentLevel == levelFinal) and (lvlState == lvlPlayClip2) then
-- Drug Wars's last stand: the tank, the blanket, or the gas cans, in the game's own
-- pointer coordinates.
local STAND = { { 262, 329, 133, 172 }, { 101, 111, 160, 188 }, { 209, 243, 201, 215 } }
local b = STAND[iLastMove] or STAND[3]
targets = { { (b[1] + b[2]) / 2, (b[3] + b[4]) / 2 } }
targetStep = lvlPlayClip2
elseif (practiceHit ~= nil) and (currentLevel == levelPractice) and (lvlState == lvlRunning) then
-- Crime Patrol's practice range: the first standing target of the still, in the game's
-- own pointer coordinates, or the cutout of the move (the middle line of its outline).
if currentMove == 0 then
local STILL = { { "bTopRight", 261, 300, 9, 60 }, { "bTopLeft", 78, 119, 70, 120 }, { "bMiddle", 205, 243, 70, 123 }, { "bBottomLeft", 120, 158, 124, 181 }, { "bBottomRight", 268, 297, 132, 183 } }
for _, t in ipairs(STILL) do
if _G[t[1]] then
targets = { { (t[2] + t[3]) / 2, (t[4] + t[5]) / 2 } }
break
end
end
elseif (currentMove >= 1) and move and move[currentMove] and target then
local kind = move[currentMove][rndMoveType]
local counts = { [BADGUY01] = 98, [CIV01] = 82, [BADGUY02] = 97, [CIV02] = 107, [BADGUY03] = 79, [CIV03] = 148 }
if kind and counts[kind] and target[kind] then
targets = { lineMiddle(target[kind], counts[kind]) }
end
end
elseif (levelFirePractice ~= nil) and (currentLevel == levelMenu) and (lvlState == lvlRunning) and panel then
-- Space Pirates's planet menu: the first panel not yet done, in the order the game takes
-- when the clip runs out, by the middle of its quadrant (pointer coordinates scaled as the
-- game scales them).
local PANELS = { { STAGE_FELLINA, 807, 1280, 565, 916 }, { STAGE_REAPER, 245, 720, 565, 916 }, { STAGE_SCRAPYARD, 807, 1280, 100, 450 }, { STAGE_MOUNTAIN, 245, 720, 100, 450 } }
for _, p in ipairs(PANELS) do
if panel[p[1]] == false then
targets = { { UnX * (p[2] + p[3]) / 2, UnY * (p[4] + p[5]) / 2 } }
break
end
end
elseif (levelDoor2 ~= nil) and (currentLevel == levelDoor2) and (lvlState == lvlPlayClip4) and band then
-- Space Pirates's band of colour: the right one, within its frames, by the middle of its
-- line.
local row = band[iBand] and band[iBand][1]
if row and (discGetFrame() >= bandFrameStart + 3) and (discGetFrame() < bandFrameEnd) then
targets = { { hmx((row[lineStartX] + row[lineEndX]) / 2), hmy((row[lineStartY] + row[lineEndY]) / 2) } }
targetStep = lvlPlayClip4
end
elseif (levelDoor3 ~= nil) and (currentLevel == levelDoor3) and (lvlState == lvlPlayClip3) and (ursie or (ursulaHit ~= nil)) then
-- Space Pirates's commander: Ursula shot within her colour's frames.
local WINDOWS = { [COLOR_RED] = { 2243, 2306 }, [COLOR_GREEN] = { 2312, 2371 }, [COLOR_BLUE] = { 2377, 2439 } }
local w = WINDOWS[iUrsulaColor]
local rel = discGetFrame() - thisOffset
if w and (rel >= w[1] + 3) and (rel <= w[2] - 3) then
-- (The one-player edition builds her rectangles inside its hit test, so before the first
-- shot the game's own numbers stand in.)
local box = (ursie and ursie[1]) or { 152, 137, 210, 238 }
targets = { { hmx((box[1] + box[3]) / 2), hmy((box[2] + box[4]) / 2) } }
targetStep = lvlPlayClip3
end
elseif (levelFellina1 ~= nil) and (currentLevel == levelFellina1) and ((lvlState == lvlPlayPuzzle1) or (lvlState == lvlPlayPuzzle2)) and thingie then
-- Space Pirates's Fellina puzzles: the skull she chose, then the arrow, by the middle of
-- the game's own line.
local which = (lvlState == lvlPlayPuzzle1) and iFellinaSkull or iFellinaArrow
local row = thingie[which] and thingie[which][1]
if row and ((lvlState == lvlPlayPuzzle2) or (discGetFrame() >= iSkullStart + 3)) then
targets = { { hmx((row[lineStartX] + row[lineEndX]) / 2), hmy((row[lineStartY] + row[lineEndY]) / 2) } }
targetStep = lvlState
end
elseif (levelCrystalBuild ~= nil) and (currentLevel == levelCrystalBuild) and (lvlState == lvlRunning) and hole and socket then
-- Space Pirates's crystal build: the next socket in the order shown.
local ORDERS = { [ORDER_RGB] = { SOCKET_RED, SOCKET_GREEN, SOCKET_BLUE }, [ORDER_GBR] = { SOCKET_GREEN, SOCKET_BLUE, SOCKET_RED }, [ORDER_BRG] = { SOCKET_BLUE, SOCKET_RED, SOCKET_GREEN } }
for _, which in ipairs(ORDERS[iCrystalOrder] or {}) do
if not socket[which] then
local row = hole[which] and hole[which][1]
if row then
targets = { { hmx((row[lineStartX] + row[lineEndX]) / 2), hmy((row[lineStartY] + row[lineEndY]) / 2) } }
end
break
end
end
elseif (GetMapArray01 ~= nil) and (currentLevel == levelMenu) and (lvlState == lvlRunning) and stage then
-- Who Shot Johnny Rock's city map: the killer's place once revealed, the mansion once the
-- clues are all had, else the first place still open (the casino, the pool hall, the
-- warehouse, the garage), by the middle of its panel.
local which = nil
if bKillerRevealed then
which = ({ [JILL] = PLACE_CASINO, [MUMPS] = PLACE_POOL, [MEASLES] = PLACE_WAREHOUSE, [POX] = PLACE_GARAGE })[iKiller]
elseif bGotAllClues then
which = PLACE_MANSION
elseif not stage[levelCasino] then
which = PLACE_CASINO
elseif not stage[levelPool] then
which = PLACE_POOL
elseif not (stage[levelWarehouse1] and stage[levelWarehouse2] and stage[levelWarehouse3]) then
which = PLACE_WAREHOUSE
elseif not stage[levelGarage] then
which = PLACE_GARAGE
end
if which then
GetMapArray01()
local row = place[which] and place[which][1]
if row then
targets = { { hmx((row[lineStartX] + row[lineEndX]) / 2), hmy((row[lineStartY] + row[lineEndY]) / 2) } }
end
end
elseif (GetMapArray01 ~= nil) and (currentLevel == levelMansion2) and (lvlState == lvlRunning) and clue and thisItem then
-- Who Shot Johnny Rock's clue items: the item of the clue asked for, in the game's second
-- scale.
GetArrayItems()
local row = item[clue[thisItem]] and item[clue[thisItem]][1]
if row then
targets = { { ResHMx2((row[lineStartX] + row[lineEndX]) / 2), ResHMy2((row[lineStartY] + row[lineEndY]) / 2) } }
end
elseif (GetMapArray01 ~= nil) and (currentLevel == levelMansion4) and (lvlState == lvlRunning) and combination and iSafeNumber then
-- Who Shot Johnny Rock's safe: the dial shot while it shows the combination's next digit
-- (on this frame and the next, since the press is judged on the next).
local digit = tonumber(string.sub(combination, iSafeNumber, iSafeNumber))
local rel = discGetFrame() - thisOffset
if (getSafeNumber(rel) == digit) and (getSafeNumber(rel + 1) == digit) then
targets = { { hmx((151 + 207) / 2), hmy((64 + 88) / 2) } }
end
elseif (GUIDE_BEAVER ~= nil) and (ResHMx ~= nil) and (currentLevel == levelMenu) and panel then
-- Mad Dog II's guide menu: the first guide whose trail is not done, by its panel.
targets = {}
local x = (panel[GUIDE_BEAVER] == false) and 90 or ((panel[GUIDE_BONNIE] == false) and 186 or ((panel[GUIDE_PROF] == false) and 282 or nil))
if x then
targets[1] = { hmx(x), hmy(121) }
end
end
-- At the attract loop: Mad Dog's state machine names it currentState, Mad Dog II's gameflow.
local playing = ((currentState or gameflow) == statePlaying)
local intro = playing and (currentLevel == (lvlIntro or levelIntro))
-- The service screen: the option under the cursor and every dip, for the trace; a screen
-- the stick drives rather than the pointer (Hologram Time Traveler).
local dips = {}
for key, value in pairs(_G) do
if (type(key) == "string") and (key:sub(1, 4) == "dip_") and (type(value) ~= "function") then
dips[#dips + 1] = key:sub(5) .. "=" .. tostring(value)
end
end
table.sort(dips)
return { screen = currentLevel, step = lvlState, move = currentMove, frame = discGetFrame(), frames = frames, score = iScore, lives = iLives or iMoney, ammo = iBullets, active = p1Active, intro = intro,
service = (levelService ~= nil) and (currentLevel == levelService), serviceKeys = (TURNRIGHTSHOOT ~= nil), opt = optSel, dips = table.concat(dips, ","), width = overlayGetWidth(), height = overlayGetHeight(),
board = (levelHighScore ~= nil) and (currentLevel == levelHighScore),
window = window, box = window and firstBox() or nil, quadrants = quadrants, targets = targets, targetStep = targetStep, continuing = (currentLevel == levelContinue), noSelect = (mouseIndexSelect == nil), typing = typing, typed = (validKey ~= nil), offscreen = { -5, -5 }, free = bAmmo, paused = bPause,
-- The one-player editions (no player select, lives rather than money) spend a shot
-- before the level tests for ammo, so their last shot is never judged: a spare to keep.
spare = ((mouseIndexSelect == nil) and (iMoney == nil) and (iBullets ~= nil) and ((panelHit == nil) or (resX ~= nil)) and (levelCharlie == nil)) and 1 or 0,
-- The library's one-player Crime Patrol family judges a shot before the release takes its
-- bullet (its HD twins take it on the press, as the others do), and reloads by its second
-- button (immediately, as its config ships); so does Platoon.
reloadSwitch = (((panelHit ~= nil) and (mouseIndexSelect == nil)) or (levelCharlie ~= nil)) and SWITCH_BUTTON1 or SWITCH_BUTTON3 }
end
onOverlayUpdate = function(...)
local result = realUpdate(...)
algDrive(view())
return result
end

View file

@ -0,0 +1,503 @@
-- The Forge port of an American Laser Games title, driven and traced as the original is.
local here = ((debug.getinfo(1, "S").source:gsub("^@", "")):match("^(.*[/\\])") or "")
local library = os.getenv("SINGE_LIBRARY") or (os.getenv("HOME") .. "/claude/singetest/hypseus")
local game = os.getenv("PORT_GAME") or "maddog-hd"
local name = (os.getenv("PORT_SCRIPT") or ("singe/" .. game .. "/" .. game .. ".singe")):gsub("%.singe$", "")
local stem = name:match("([^/\\]+)$")
dofile("Forge/AuthorCompile.singe")
dofile(here .. "algDrive.singe")
-- The game seeds its random draws from os.time at load; the original's driver pins it, and so
-- does this, to the same number.
os.time = function(...) return 12345 end
local realSkip = discSkipToFrame
discSkipToFrame = function(frame)
algTraceSeek(frame)
return realSkip(frame)
end
local realSearch = discSearch
discSearch = function(frame)
algTraceSeek(frame)
return realSearch(frame)
end
dofile(authorBuild(library .. "/" .. game .. "/" .. name .. ".forge", singeGetDataPath() .. stem .. ".port.singe"))
local realUpdate = onOverlayUpdate
function algPress(switch, device, down)
if down then
authorSwitchDown(switch, device)
else
authorSwitchUp(switch, device)
end
end
-- A key released, as the typing edition reads it: the runtime hands a keysym over as a switch.
function algKey(keysym)
authorSwitchUp(keysym)
end
function algPointer(device, x, y)
local q = authorEntity("game").q
local rx = (x + (q.ratioxOffset or 0)) / ((q.ratiox or 1) * (q.gunscale or 1))
local ry = (y + (q.ratioyOffset or 0)) / ((q.ratioy or 1) * (q.gunscale or 1))
onMouseMoved(math.floor(rx), math.floor(ry), 0, 0, device)
end
-- The hitmap's coordinates scaled as the game scales them, where it does (the multiplayer
-- editions); the one-player editions aim in the hitmap's own.
local function hmx(v)
local q = authorEntity("game").q
if q.ResHMx then
return q.ResHMx(v)
end
return v
end
local function hmy(v)
local q = authorEntity("game").q
if q.ResHMy then
return q.ResHMy(v)
end
return v
end
-- The hitboxes of a frame in the current move's hitmap range, in pointer coordinates.
local function boxesAt(q, rel)
local boxes = {}
for k = (q.move[q.currentMove][q.hitmapStart or q.hitboxStart] or 1), (q.hitmapTotal or 0) do
local row = q.hitmap[k]
if row and (row[q.hitmapFrame] == rel) and (row[q.hitmapCount] > 0) then
for j = row[q.hitmapIndex], row[q.hitmapIndex] + row[q.hitmapCount] - 1 do
local b = q.hitbox[j]
-- (A box given back to front -- Platoon's hitbox lines are, often -- can never be
-- hit by the game's own test, so it is no aim.)
if b and (b[q.bbx1] <= b[q.bbx2]) and (b[q.bby1] <= b[q.bby2]) then
boxes[#boxes + 1] = { hmx(b[q.bbx1]), hmy(b[q.bby1]), hmx(b[q.bbx2]), hmy(b[q.bby2]) }
end
end
break
end
end
return boxes
end
local function firstBox(q)
-- The first hitbox of this frame, aimed at a point that also lies inside a box of the next
-- frame when the two overlap: the press the drive makes now is judged on this frame or the
-- next, as the clock falls. Without an overlap (a fast target) the box of this frame stands.
if (q.move == nil) or (q.move[q.currentMove] == nil) or (q.hitmap == nil) then
return nil
end
local rel = q.currentFrame - (q.thisOffset or 0)
local now = boxesAt(q, rel)
local b = now[1]
if b == nil then
return nil
end
for _, n in ipairs(boxesAt(q, rel + 1)) do
local x1, y1, x2, y2 = math.max(b[1], n[1]), math.max(b[2], n[2]), math.min(b[3], n[3]), math.min(b[4], n[4])
if (x1 <= x2) and (y1 <= y2) then
return { x1, y1, x2, y2 }
end
end
return b
end
local function view()
local q = authorEntity("game").q
local window = nil
-- A level of Mad Dog's (101 to 121) or of Mad Dog II's (its trails, and levelStart); Mad Dog II's
-- move rows have no shoot window of their own, so the move's frames stand in.
local inLevel = (q.currentLevel ~= nil) and (((q.frameShootStart ~= nil) and (q.currentLevel >= 101) and (q.currentLevel <= 121)) or ((q.frameShootStart == nil) and (((q.currentLevel >= 1) and (q.currentLevel <= 30)) or (q.currentLevel == q.levelStart))))
if ((q.practiceHit ~= nil) or (q.fRndPopOrder ~= nil)) and (q.currentLevel == q.levelPractice) then
inLevel = false
end
if (q.levelSigns ~= nil) and (q.currentLevel == q.levelSigns) then
inLevel = false
end
if (q.levelTieIntro ~= nil) and ((q.currentLevel == q.levelTie) or (q.currentLevel == q.levelTieIntro)) then
inLevel = false
end
-- Space Pirates's puzzles and crystals are targets, not windows: the band and Ursula's colour
-- at the doors, Fellina's skull and arrow, the sockets of the crystal build.
if (q.levelCrystalBuild ~= nil) and ((q.currentLevel == q.levelCrystalBuild) or ((q.currentLevel == q.levelDoor2) and (q.lvlState == q.lvlPlayClip4)) or ((q.currentLevel == q.levelDoor3) and (q.lvlState == q.lvlPlayClip3)) or ((q.currentLevel == q.levelFellina1) and ((q.lvlState == q.lvlPlayPuzzle1) or (q.lvlState == q.lvlPlayPuzzle2)))) then
inLevel = false
end
-- Who Shot Johnny Rock's store, clue items, and safe are targets, not windows.
if (q.GetMapArray01 ~= nil) and ((q.currentLevel == q.levelStore) or (q.currentLevel == q.levelMansion2) or (q.currentLevel == q.levelMansion4)) then
inLevel = false
end
-- Space Pirates's Reaper wants exactly as many hits as he laughed: once the drive has them,
-- the moves left are let pass.
if (q.levelReaper2 ~= nil) and (q.currentLevel == q.levelReaper2) and (q.currentMove > 1) and (q.iReaperHits >= q.iReaperLaughs) then
inLevel = false
end
-- The typing edition of Mad Dog II plays on the keyboard: the key the game waits for now (the
-- next letter of the word, a sprinkled letter, the return key at a menu), and no gun at all.
local typing = nil
if q.validKey ~= nil then
inLevel = false
local function nextOf(word)
if word and (q.iKeyIndex or 1) <= string.len(word) then
return string.byte(word, q.iKeyIndex or 1)
end
return nil
end
-- (The game starts with the key slot at zero, not at none, and never clears it.)
local waiting = (q.iKey == q.SWITCH_NONE) or (q.iKey == 0)
if not waiting then
typing = nil
elseif (q.currentLevel == q.levelTitle) and (q.lvlState == q.lvlRunning) then
typing = q.SWITCH_RETURN
elseif (q.currentLevel == q.levelMenu) and (q.lvlState == q.lvlRunning) then
typing = q.SWITCH_RETURN
elseif (q.currentLevel == q.levelHighScore) and (q.lvlState == q.lvlRunning) then
-- (Once, before the name's frames run out; the return key seeks to the entry's end.)
if discGetFrame() < q.offsetMenus + 3275 then
typing = q.SWITCH_RETURN
end
elseif q.bShowSprinkles and q.sprinkles then
for k = 1, (q.iSprinkleMax or 0) do
if q.sprinkles[k] and (q.sprinkles[k][4] == true) then
typing = string.byte(q.sprinkles[k][3])
break
end
end
elseif (q.currentLevel == q.levelItem1) and (q.lvlState == q.lvlPlayClip3) then
typing = nextOf(q.sMagicWord)
elseif q.move and q.move[q.currentMove] and ((q.lvlState == q.lvlPauseAction) or ((q.lvlState == q.lvlRunning) and (discGetFrame() >= q.move[q.currentMove][q.moveFrameStart]) and (discGetFrame() < q.move[q.currentMove][q.moveFrameEnd]))) then
typing = nextOf(q.move[q.currentMove][(q.currentLevel == q.levelItem2) and q.moveType or q.wordval])
end
end
-- Platoon's patrols open with a clip the first press of the trigger jumps past, whatever the
-- state: no window until that press is in, and a target anywhere to make it.
local skipping = (q.levelCharlie ~= nil) and q.bOnce and (q.currentLevel ~= nil) and (q.currentLevel >= 1) and (q.currentLevel <= 6) and (q.lvlState == q.lvlRunning)
if skipping then
inLevel = false
end
-- A good guy's move (the Crime Patrol family marks the row) is no window: shooting him is a
-- death, and the drive would take it at every level that has one.
if inLevel and q.move and q.move[q.currentMove] and (q.rndMoveType ~= nil) and (q.GOODGUY ~= nil) and (q.move[q.currentMove][q.rndMoveType] == q.GOODGUY) then
inLevel = false
end
if inLevel and q.move and q.move[q.currentMove] then
window = { q.move[q.currentMove][q.frameShootStart or q.moveFrameStart], q.move[q.currentMove][q.frameShootEnd or q.moveFrameEnd] }
end
local quadrants = nil
if (q.currentLevel == q.levelTownSelect) and q.stage then
quadrants = {}
local w, h = q.resX or 360, q.resY or 240
-- In the order the game itself takes when the select times out: the saloon before the
-- sheriff, whose level needs the key from it.
if not q.stage[q.STAGE_CORRAL] then quadrants[#quadrants + 1] = { w * 0.25, h * 0.25 } end
if not q.stage[q.STAGE_SALOON] then quadrants[#quadrants + 1] = { w * 0.75, h * 0.25 } end
if not q.stage[q.STAGE_SHERIFF] then quadrants[#quadrants + 1] = { w * 0.25, h * 0.75 } end
if not q.stage[q.STAGE_BANK] then quadrants[#quadrants + 1] = { w * 0.75, h * 0.75 } end
end
-- The signpost's sign and the mine's lantern and item, as a list of points to shoot in
-- order (the middle line of each, scaled as the game scales them).
local targets = nil
local targetStep = nil
local function lineMiddle(rows, count)
local row = rows and rows[math.floor((count + 1) / 2)]
if row then
return { hmx((row[q.lineStartX] + row[q.lineEndX]) / 2), hmy(row[q.lineY]) }
end
return nil
end
if skipping then
targets = { { 180, 120 } }
targetStep = q.lvlRunning
elseif (q.currentLevel == q.levelSignSelect) and q.sign then
targets = {}
if q.stage[q.STAGE_FUSE] == false then
targets[1] = lineMiddle(q.sign[q.SIGN_PROSPECTOR], 48)
elseif q.stage[q.STAGE_MINE] == false then
targets[1] = lineMiddle(q.sign[q.SIGN_MINE], 28)
else
targets[1] = lineMiddle(q.sign[q.SIGN_HIDEOUT], 58)
end
elseif (q.currentLevel == q.levelMine) and q.mine then
targets = {}
local counts = { [q.MINE_SIGN] = 21, [q.MINE_LANTERN] = 20, [q.MINE_PAN] = 12, [q.MINE_PITCHFORK] = 23, [q.MINE_SKULL] = 25 }
if not q.bLanternHit then targets[#targets + 1] = lineMiddle(q.mine[q.MINE_LANTERN], counts[q.MINE_LANTERN]) end
if not q.bItemHit then targets[#targets + 1] = lineMiddle(q.mine[q.iWhatToHit], counts[q.iWhatToHit]) end
elseif (q.currentLevel == q.levelMaze) and q.maze and q.maze[q.curMazePos] and (q.maze[q.curMazePos][q.mazeType] > 0) and q.sign then
-- A maze sign: the sign for the way to go.
local frame, dir = q.maze[q.curMazePos][q.signType], q.maze[q.curMazePos][q.signDirection]
local counts = { [32] = { 51, 49 }, [175] = { 59, 17 }, [340] = { 67, 90 }, [500] = { 24, 42 }, [630] = { 11, 30 }, [446] = { 59, 64 } }
local pair = counts[frame]
if pair and q.sign[dir] then
targets = { lineMiddle(q.sign[dir], pair[dir]) }
end
elseif (q.levelItem1 ~= nil) and (q.ResHMx ~= nil) and (q.currentLevel == q.levelItem1) and (q.lvlState == q.lvlPlayClip3) then
-- Mad Dog II's first item level: the item the guide asked for, from the game's own
-- rectangles.
local boxes = { [q.ITEM_LANTERN] = { 57, 66, 117, 129 }, [q.ITEM_AMMOBOX] = { 120, 148, 111, 132 }, [q.ITEM_MOONSHINE] = { 260, 272, 60, 72 } }
local b = boxes[q.iThisItem]
if b then
targets = { { hmx((b[1] + b[2]) / 2), hmy((b[3] + b[4]) / 2) } }
targetStep = q.lvlPlayClip3
end
elseif (q.levelProspector1 ~= nil) and (q.ResHMx ~= nil) and (q.currentLevel == q.levelProspector1) and (q.lvlState == q.lvlPlayClip4) then
-- Mad Dog II's prospector: the skull he chose, from the game's own rectangles.
local boxes = { { 305, 315, 59, 76 }, { 68, 75, 73, 82 }, { 147, 154, 58, 67 }, { 119, 127, 101, 115 } }
local b = boxes[q.iSkull] or boxes[4]
targets = { { hmx((b[1] + b[2]) / 2), hmy((b[3] + b[4]) / 2) } }
targetStep = q.lvlPlayClip4
elseif (q.panelHit ~= nil) and (q.currentLevel == q.levelMenu) and (q.lvlState == q.lvlRunning) and q.area then
-- Crime Patrol's mission menus: the first panel whose stage is open, by the middle line
-- of its area (the count of lines is the game's own).
local MENUS = {}
if q.MENU_SIERRA ~= nil then
MENUS[q.MENU_SIERRA] = { { q.PANEL_PRACTICE, q.STAGE_PRACTICE, 99 }, { q.PANEL_BAR, q.STAGE_BAR, 99 }, { q.PANEL_CARCHASE, q.STAGE_CARCHASE, 99 }, { q.PANEL_HIDEOUT, q.STAGE_HIDEOUT, 99 } }
MENUS[q.MENU_CHICAGO] = { { q.PANEL_HELI, q.STAGE_HELI, 111 }, { q.PANEL_COURTROOM, q.STAGE_COURTROOM, 111 }, { q.PANEL_BUSCHASE, q.STAGE_BUSCHASE, 111 } }
MENUS[q.MENU_BORDER] = { { q.PANEL_SHIPYARD, q.STAGE_SHIPYARD, 111 }, { q.PANEL_BOATHOUSE, q.STAGE_BOATHOUSE, 111 }, { q.PANEL_BEACH, q.STAGE_BEACH, 111 } }
MENUS[q.MENU_SA] = { { q.PANEL_BASE, q.STAGE_BASE, 111 }, { q.PANEL_NIGHTVISION, q.STAGE_NIGHTVISION, 111 }, { q.PANEL_TOWN, q.STAGE_TOWN, 111 } }
end
if q.MENU_ROOKIE ~= nil then
MENUS = { [q.MENU_ROOKIE] = { { q.PANEL_PRACTICE, q.STAGE_PRACTICE, 98 }, { q.PANEL_STORE, q.STAGE_STORE, 99 }, { q.PANEL_GANG, q.STAGE_GANG, 99 }, { q.PANEL_WAREHOUSE, q.STAGE_WAREHOUSE, 97 } },
[q.MENU_UNDERCOVER] = { { q.PANEL_AIRPORT, q.STAGE_AIRPORT, 104 }, { q.PANEL_GARAGE, q.STAGE_GARAGE, 104 }, { q.PANEL_STRIPCLUB, q.STAGE_STRIPCLUB, 104 } },
[q.MENU_SWAT] = { { q.PANEL_DRUGS1, q.STAGE_DRUGS1, 111 }, { q.PANEL_DRUGS2, q.STAGE_DRUGS2, 111 }, { q.PANEL_BANK, q.STAGE_ROBBERY, 103 } },
[q.MENU_DELTA] = { { q.PANEL_HANGAR, q.STAGE_HANGAR, 108 }, { q.PANEL_AMBUSH, q.STAGE_AMBUSH, 103 }, { q.PANEL_AIRTERRORISTS, q.STAGE_AIRTERRORISTS, 109 } } }
end
for _, entry in ipairs(MENUS[q.iCurrentMenu] or {}) do
if not q.panel[entry[2]] then
targets = { lineMiddle(q.area[entry[1]], entry[3]) }
break
end
end
elseif (q.GetArrayPanels ~= nil) and (q.currentLevel == q.levelMenu) and (q.lvlState == q.lvlRunning) and q.area then
-- The Last Bounty Hunter's wanted posters: the first outlaw still at large, by the middle of
-- his panel's rectangle.
for _, stage in ipairs({ q.STAGE_HARRY, q.STAGE_DAN, q.STAGE_LOCO, q.STAGE_CACTUS }) do
local r = (q.panel[stage] == q.ATLARGE) and q.area[stage] and q.area[stage][1]
if r then
targets = { { hmx((r[q.lineStartX] + r[q.lineEndX]) / 2), hmy((r[q.lineStartY] + r[q.lineEndY]) / 2) } }
break
end
end
elseif (q.levelSigns ~= nil) and (q.currentLevel == q.levelSigns) and (q.lvlState == q.lvlEnd) then
-- The Last Bounty Hunter's signs: the practice range, in the game's own pointer coordinates.
targets = { { 175, 101 } }
targetStep = q.lvlEnd
elseif (q.iLastMove ~= nil) and (q.levelFinal ~= nil) and (q.currentLevel == q.levelFinal) and (q.lvlState == q.lvlPlayClip2) then
-- Drug Wars's last stand: the tank, the blanket, or the gas cans, in the game's own
-- pointer coordinates.
local STAND = { { 262, 329, 133, 172 }, { 101, 111, 160, 188 }, { 209, 243, 201, 215 } }
local b = STAND[q.iLastMove] or STAND[3]
targets = { { (b[1] + b[2]) / 2, (b[3] + b[4]) / 2 } }
targetStep = q.lvlPlayClip2
elseif (q.practiceHit ~= nil) and (q.currentLevel == q.levelPractice) and (q.lvlState == q.lvlRunning) then
-- Crime Patrol's practice range: the first standing target of the still, in the game's
-- own pointer coordinates, or the cutout of the move (the middle line of its outline).
if q.currentMove == 0 then
local STILL = { { "bTopRight", 261, 300, 9, 60 }, { "bTopLeft", 78, 119, 70, 120 }, { "bMiddle", 205, 243, 70, 123 }, { "bBottomLeft", 120, 158, 124, 181 }, { "bBottomRight", 268, 297, 132, 183 } }
for _, t in ipairs(STILL) do
if q[t[1]] then
targets = { { (t[2] + t[3]) / 2, (t[4] + t[5]) / 2 } }
break
end
end
elseif (q.currentMove >= 1) and q.move and q.move[q.currentMove] and q.target then
local kind = q.move[q.currentMove][q.rndMoveType]
local counts = { [q.BADGUY01] = 98, [q.CIV01] = 82, [q.BADGUY02] = 97, [q.CIV02] = 107, [q.BADGUY03] = 79, [q.CIV03] = 148 }
if kind and counts[kind] and q.target[kind] then
targets = { lineMiddle(q.target[kind], counts[kind]) }
end
end
elseif (q.levelFirePractice ~= nil) and (q.currentLevel == q.levelMenu) and (q.lvlState == q.lvlRunning) and q.panel then
-- Space Pirates's planet menu: the first panel not yet done, in the order the game takes
-- when the clip runs out, by the middle of its quadrant (pointer coordinates scaled as the
-- game scales them).
local PANELS = { { q.STAGE_FELLINA, 807, 1280, 565, 916 }, { q.STAGE_REAPER, 245, 720, 565, 916 }, { q.STAGE_SCRAPYARD, 807, 1280, 100, 450 }, { q.STAGE_MOUNTAIN, 245, 720, 100, 450 } }
for _, p in ipairs(PANELS) do
if q.panel[p[1]] == false then
targets = { { q.UnX * (p[2] + p[3]) / 2, q.UnY * (p[4] + p[5]) / 2 } }
break
end
end
elseif (q.levelDoor2 ~= nil) and (q.currentLevel == q.levelDoor2) and (q.lvlState == q.lvlPlayClip4) and q.band then
-- Space Pirates's band of colour: the right one, within its frames, by the middle of its
-- line.
local row = q.band[q.iBand] and q.band[q.iBand][1]
if row and (discGetFrame() >= q.bandFrameStart + 3) and (discGetFrame() < q.bandFrameEnd) then
targets = { { hmx((row[q.lineStartX] + row[q.lineEndX]) / 2), hmy((row[q.lineStartY] + row[q.lineEndY]) / 2) } }
targetStep = q.lvlPlayClip4
end
elseif (q.levelDoor3 ~= nil) and (q.currentLevel == q.levelDoor3) and (q.lvlState == q.lvlPlayClip3) and (q.ursie or (q.ursulaHit ~= nil)) then
-- Space Pirates's commander: Ursula shot within her colour's frames.
local WINDOWS = { [q.COLOR_RED] = { 2243, 2306 }, [q.COLOR_GREEN] = { 2312, 2371 }, [q.COLOR_BLUE] = { 2377, 2439 } }
local w = WINDOWS[q.iUrsulaColor]
local rel = discGetFrame() - q.thisOffset
if w and (rel >= w[1] + 3) and (rel <= w[2] - 3) then
-- (The one-player edition builds her rectangles inside its hit test, so before the first
-- shot the game's own numbers stand in.)
local box = (q.ursie and q.ursie[1]) or { 152, 137, 210, 238 }
targets = { { hmx((box[1] + box[3]) / 2), hmy((box[2] + box[4]) / 2) } }
targetStep = q.lvlPlayClip3
end
elseif (q.levelFellina1 ~= nil) and (q.currentLevel == q.levelFellina1) and ((q.lvlState == q.lvlPlayPuzzle1) or (q.lvlState == q.lvlPlayPuzzle2)) and q.thingie then
-- Space Pirates's Fellina puzzles: the skull she chose, then the arrow, by the middle of
-- the game's own line.
local which = (q.lvlState == q.lvlPlayPuzzle1) and q.iFellinaSkull or q.iFellinaArrow
local row = q.thingie[which] and q.thingie[which][1]
if row and ((q.lvlState == q.lvlPlayPuzzle2) or (discGetFrame() >= q.iSkullStart + 3)) then
targets = { { hmx((row[q.lineStartX] + row[q.lineEndX]) / 2), hmy((row[q.lineStartY] + row[q.lineEndY]) / 2) } }
targetStep = q.lvlState
end
elseif (q.levelCrystalBuild ~= nil) and (q.currentLevel == q.levelCrystalBuild) and (q.lvlState == q.lvlRunning) and q.hole and q.socket then
-- Space Pirates's crystal build: the next socket in the order shown.
local ORDERS = { [q.ORDER_RGB] = { q.SOCKET_RED, q.SOCKET_GREEN, q.SOCKET_BLUE }, [q.ORDER_GBR] = { q.SOCKET_GREEN, q.SOCKET_BLUE, q.SOCKET_RED }, [q.ORDER_BRG] = { q.SOCKET_BLUE, q.SOCKET_RED, q.SOCKET_GREEN } }
for _, which in ipairs(ORDERS[q.iCrystalOrder] or {}) do
if not q.socket[which] then
local row = q.hole[which] and q.hole[which][1]
if row then
targets = { { hmx((row[q.lineStartX] + row[q.lineEndX]) / 2), hmy((row[q.lineStartY] + row[q.lineEndY]) / 2) } }
end
break
end
end
elseif (q.GetMapArray01 ~= nil) and (q.currentLevel == q.levelMenu) and (q.lvlState == q.lvlRunning) and q.stage then
-- Who Shot Johnny Rock's city map: the killer's place once revealed, the mansion once the
-- clues are all had, else the first place still open (the casino, the pool hall, the
-- warehouse, the garage), by the middle of its panel.
local which = nil
if q.bKillerRevealed then
which = ({ [q.JILL] = q.PLACE_CASINO, [q.MUMPS] = q.PLACE_POOL, [q.MEASLES] = q.PLACE_WAREHOUSE, [q.POX] = q.PLACE_GARAGE })[q.iKiller]
elseif q.bGotAllClues then
which = q.PLACE_MANSION
elseif not q.stage[q.levelCasino] then
which = q.PLACE_CASINO
elseif not q.stage[q.levelPool] then
which = q.PLACE_POOL
elseif not (q.stage[q.levelWarehouse1] and q.stage[q.levelWarehouse2] and q.stage[q.levelWarehouse3]) then
which = q.PLACE_WAREHOUSE
elseif not q.stage[q.levelGarage] then
which = q.PLACE_GARAGE
end
if which then
q.GetMapArray01()
local row = q.place[which] and q.place[which][1]
if row then
targets = { { hmx((row[q.lineStartX] + row[q.lineEndX]) / 2), hmy((row[q.lineStartY] + row[q.lineEndY]) / 2) } }
end
end
elseif (q.GetMapArray01 ~= nil) and (q.currentLevel == q.levelMansion2) and (q.lvlState == q.lvlRunning) and q.clue and q.thisItem then
-- Who Shot Johnny Rock's clue items: the item of the clue asked for, in the game's second
-- scale.
q.GetArrayItems()
local row = q.item[q.clue[q.thisItem]] and q.item[q.clue[q.thisItem]][1]
if row then
targets = { { q.ResHMx2((row[q.lineStartX] + row[q.lineEndX]) / 2), q.ResHMy2((row[q.lineStartY] + row[q.lineEndY]) / 2) } }
end
elseif (q.GetMapArray01 ~= nil) and (q.currentLevel == q.levelMansion4) and (q.lvlState == q.lvlRunning) and q.combination and q.iSafeNumber then
-- Who Shot Johnny Rock's safe: the dial shot while it shows the combination's next digit
-- (on this frame and the next, since the press is judged on the next).
local digit = tonumber(string.sub(q.combination, q.iSafeNumber, q.iSafeNumber))
local rel = discGetFrame() - q.thisOffset
if (q.getSafeNumber(rel) == digit) and (q.getSafeNumber(rel + 1) == digit) then
targets = { { hmx((151 + 207) / 2), hmy((64 + 88) / 2) } }
end
elseif (q.GUIDE_BEAVER ~= nil) and (q.ResHMx ~= nil) and (q.currentLevel == q.levelMenu) and q.panel then
-- Mad Dog II's guide menu: the first guide whose trail is not done, by its panel.
targets = {}
local x = (q.panel[q.GUIDE_BEAVER] == false) and 90 or ((q.panel[q.GUIDE_BONNIE] == false) and 186 or ((q.panel[q.GUIDE_PROF] == false) and 282 or nil))
if x then
targets[1] = { hmx(x), hmy(121) }
end
end
local playing = ((q.currentState or q.gameflow) == q.statePlaying)
local intro = playing and (q.currentLevel == (q.lvlIntro or q.levelIntro))
-- The service screen: the option under the cursor and every dip, for the trace; a screen
-- the stick drives rather than the pointer (Hologram Time Traveler).
local dips = {}
for key, value in pairs(q) do
if (type(key) == "string") and (key:sub(1, 4) == "dip_") and (type(value) ~= "function") then
dips[#dips + 1] = key:sub(5) .. "=" .. tostring(value)
end
end
table.sort(dips)
return { screen = q.currentLevel, step = q.lvlState, move = q.currentMove, frame = discGetFrame(), frames = frames, score = q.iScore, lives = q.iLives or q.iMoney, ammo = q.iBullets, active = q.p1Active, intro = intro,
service = (q.levelService ~= nil) and (q.currentLevel == q.levelService), serviceKeys = (q.TURNRIGHTSHOOT ~= nil), opt = q.optSel, dips = table.concat(dips, ","), width = overlayGetWidth(), height = overlayGetHeight(),
board = (q.levelHighScore ~= nil) and (q.currentLevel == q.levelHighScore),
window = window, box = window and firstBox(q) or nil, quadrants = quadrants, targets = targets, targetStep = targetStep, continuing = (q.currentLevel == q.levelContinue), noSelect = (q.mouseIndexSelect == nil), typing = typing, typed = (q.validKey ~= nil), offscreen = { -5, -5 }, free = q.bAmmo, paused = q.bPause,
-- The one-player editions (no player select, lives rather than money) spend a shot
-- before the level tests for ammo, so their last shot is never judged: a spare to keep.
spare = ((q.mouseIndexSelect == nil) and (q.iMoney == nil) and (q.iBullets ~= nil) and ((q.panelHit == nil) or (q.resX ~= nil)) and (q.levelCharlie == nil)) and 1 or 0,
-- The library's one-player Crime Patrol family judges a shot before the release takes its
-- bullet (its HD twins take it on the press, as the others do), and reloads by its second
-- button (immediately, as its config ships); so does Platoon.
reloadSwitch = (((q.panelHit ~= nil) and (q.mouseIndexSelect == nil)) or (q.levelCharlie ~= nil)) and SWITCH_BUTTON1 or SWITCH_BUTTON3 }
end
onOverlayUpdate = function(...)
local result = realUpdate(...)
algDrive(view())
return result
end

208
testScripts/ports/compare.sh Executable file
View file

@ -0,0 +1,208 @@
#!/bin/bash
# Runs an original game from the test library and its Forge port on the same inputs, headless
# and on the deterministic clock, and says whether their traces agree (FORGE.md section 14.1).
#
# testScripts/ports/compare.sh <family> <game>
#
# SINGE the engine binary (default: the newest build in .builddir)
# SINGE_LIBRARY the test library (default: ~/claude/singetest/ported, where the
# ported originals live; the hypseus families look under its hypseus/)
# SINGE_RUN a directory to run in, with Singe/ and Forge/ beside each other
# (default: a fresh one under /tmp, with assets/Forge copied in)
#
# Families: actionMax (games 38AmbushAlley, BlueThunder, Hydrosub2021, PopsGhostly, SonicFury);
# karis (a game as directory/script, e.g. TimeGal/Timegal.singe);
# hypseus (a Karis-framework title from the Hypseus library by its folder, e.g.
# Astroboy: the script is the description's namesake under singe/, the video its
# framefile);
# rdg (a map-mode title from the Hypseus library, laid out the same way; the engine's
# deterministic clock gives both runs the same seed for the game's random draws);
# alg (an American Laser Games title, Hypseus layout or the library's);
# videoland (Adventures in Videoland: Rollercoaster, by its folder);
# timetraveler (Hologram Time Traveler, by its folder).
set -u
family=${1:?usage: compare.sh <family> <game>}
game=${2:?usage: compare.sh <family> <game>}
repo=$(cd "$(dirname "$0")/../.." && pwd)
library=${SINGE_LIBRARY:-$HOME/claude/singetest/ported}
binary=${SINGE:-$(ls -t "$repo"/.builddir/linux-gcc/*/singe/Singe-v* "$repo"/.builddir/linux/x86_64/singe/Singe-v* 2>/dev/null | head -1)}
run=${SINGE_RUN:-}
if [[ -z "$run" ]]; then
run=$(mktemp -d /tmp/forgePort.XXXXXX)
fi
# The run directory gets Forge fresh from the repository every time, so an edit to Author.singe
# is what the port plays.
mkdir -p "$run"
rm -rf "$run/Forge"
cp -r "$repo/assets/Forge" "$run/Forge"
if [[ -z "$binary" || ! -x "$binary" ]]; then
echo "compare.sh: no engine binary; set SINGE" >&2
exit 2
fi
case "$family" in
actionMax)
video="$library/ActionMax/frame_$game.txt"
original="$repo/testScripts/ports/actionMaxOriginal.singe"
port="$repo/testScripts/ports/actionMaxPort.singe"
;;
karis)
# The game's directory and its main script, as "TimeGal/Timegal.singe"; the video is
# what its games.dat names.
export PORT_SCRIPT=${game#*/}
game=${game%%/*}
video=$library/$(grep -o 'VIDEO *= *"[^"]*"' "$library/$game/games.dat" | head -1 | sed 's/.*"\(.*\)"/\1/')
original="$repo/testScripts/ports/karisOriginal.singe"
port="$repo/testScripts/ports/karisPort.singe"
;;
hypseus)
# A Hypseus title's script hardcodes BASEDIR = "singe", so the run directory gets the
# title's singe/ beside it, and its own copy of Forge.
library=${SINGE_LIBRARY:-$HOME/claude/singetest/ported/hypseus}
# The script is the description's namesake, wherever under singe/ the title keeps it
# (Sintel's folder is not named as its title is).
desc=$(find -L "$library/$game/singe" -mindepth 2 -maxdepth 2 -name "*.forge" 2>/dev/null | head -1)
if [[ -n "$desc" ]]; then
export PORT_SCRIPT=${desc#"$library/$game/"}
PORT_SCRIPT=${PORT_SCRIPT%.forge}.singe
else
export PORT_SCRIPT=singe/$game/$game.singe
fi
# The framefile is at the top of the title, or beside the script.
video=$library/$game/$game.txt
[[ -f "$video" ]] || video=$library/$game/${PORT_SCRIPT%.singe}.txt
original="$repo/testScripts/ports/karisOriginal.singe"
port="$repo/testScripts/ports/karisPort.singe"
run=$run/$game
mkdir -p "$run"
rm -rf "$run/Forge"
cp -r "$repo/assets/Forge" "$run/Forge"
ln -sfn "$library/$game/singe" "$run/singe"
;;
rdg)
library=${SINGE_LIBRARY:-$HOME/claude/singetest/ported/hypseus}
desc=$(find -L "$library/$game/singe" -mindepth 2 -maxdepth 2 -name "*.forge" 2>/dev/null | head -1)
export PORT_SCRIPT=${desc#"$library/$game/"}
PORT_SCRIPT=${PORT_SCRIPT%.forge}.singe
video=$library/$game/$game.txt
[[ -f "$video" ]] || video=$library/$game/${PORT_SCRIPT%.singe}.txt
original="$repo/testScripts/ports/karisOriginal.singe"
port="$repo/testScripts/ports/karisPort.singe"
run=$run/$game
mkdir -p "$run"
rm -rf "$run/Forge"
cp -r "$repo/assets/Forge" "$run/Forge"
ln -sfn "$library/$game/singe" "$run/singe"
;;
alg)
# An American Laser Games title from the Hypseus library: the same layout as the
# map-mode ones, its own drivers.
library=${SINGE_LIBRARY:-$HOME/claude/singetest/hypseus}
if [[ -d "$library/$game/Script" ]]; then
# The library's own copy (Script/, Cfg/, Video/ with the framefile beside the video;
# Platoon keeps its Video/ under Script/): the script is the description's namesake,
# the video the framefile in Video/.
desc=$(ls "$library/$game/Script"/*.forge 2>/dev/null | head -1)
export PORT_SCRIPT=Script/$(basename "${desc%.forge}").singe
video=$(ls "$library/$game/Video"/*.txt "$library/$game/Script/Video"/*.txt 2>/dev/null | head -1)
else
desc=$(find -L "$library/$game/singe" -mindepth 2 -maxdepth 2 -name "*.forge" 2>/dev/null | head -1)
if [[ -n "$desc" ]]; then
export PORT_SCRIPT=${desc#"$library/$game/"}
PORT_SCRIPT=${PORT_SCRIPT%.forge}.singe
else
export PORT_SCRIPT=singe/$game/$game.singe
fi
video=$library/$game/$game.txt
fi
original="$repo/testScripts/ports/algOriginal.singe"
port="$repo/testScripts/ports/algPort.singe"
run=$run/$game
mkdir -p "$run"
rm -rf "$run/Forge"
cp -r "$repo/assets/Forge" "$run/Forge"
ln -sfn "$library/$game/singe" "$run/singe"
;;
videoland|timetraveler)
# A one-off of the library (Adventures in Videoland: Rollercoaster; Hologram Time
# Traveler): its games.dat names the video and the script; the description is beside
# the script; the drivers are the family's own.
library=${SINGE_LIBRARY:-$HOME/claude/singetest}
export PORT_SCRIPT=$(grep -o 'SCRIPT *= *"[^"]*"' "$library/$game/games.dat" | head -1 | sed 's/.*"\(.*\)"/\1/')
video=$library/$(grep -o 'VIDEO *= *"[^"]*"' "$library/$game/games.dat" | head -1 | sed 's/.*"\(.*\)"/\1/')
original="$repo/testScripts/ports/${family}Original.singe"
port="$repo/testScripts/ports/${family}Port.singe"
run=$run/$game
mkdir -p "$run"
rm -rf "$run/Forge"
cp -r "$repo/assets/Forge" "$run/Forge"
;;
*)
echo "compare.sh: no family called $family" >&2
exit 2
;;
esac
export SINGE_LIBRARY=$library PORT_GAME=$game SDL_VIDEODRIVER=${SDL_VIDEODRIVER:-offscreen} SDL_AUDIO_DRIVER=dummy
trace() {
local name=$1 script=$2 work
work=$(mktemp -d "$run/$name.XXXXXX")
( cd "$run" && timeout 3600 "$binary" -w -p -k --deterministic -d "$work" -v "$video" "$script" ) > "$work/run.log" 2>&1
grep -a '^TRACE' "$work/run.log" | grep -v 'state 2 from' > "$work/trace"
grep -a 'rror\|Author:' "$work/run.log" | head -3 >&2
# The screenshots a drive took (PORT_SHOTS=1) land in the run's data directory; with
# SINGE_SHOTS set they are gathered there, named for the run.
if [[ -n "${SINGE_SHOTS:-}" ]]; then
mkdir -p "$SINGE_SHOTS"
for png in "$work"/*.png; do
[[ -f "$png" ]] && cp "$png" "$SINGE_SHOTS/$name-$(basename "$png")"
done
fi
echo "$work/trace"
}
# The originals autosave into their own Cfg folder as they play, which would give the port's
# snapshot and the next run different saves to read: the folder is put back as it was.
cfgDir=""
if [[ -n "${PORT_SCRIPT:-}" ]]; then
cfgDir=$library/$game/$(dirname "$PORT_SCRIPT")/Cfg
[[ -d "$cfgDir" ]] || cfgDir=$library/$game/Cfg
[[ -d "$cfgDir" ]] || cfgDir=""
fi
if [[ -n "$cfgDir" ]]; then
cfgKeep=$(mktemp -d "$run/cfg.XXXXXX")
cp -r "$cfgDir/." "$cfgKeep/"
fi
# An American Laser Games title keeps its dips and board in a .cfg beside its script, which
# the original writes at every board: kept and put back the same way.
cfgFiles=""
if [[ "$family" == "alg" ]]; then
cfgFiles=$(ls "$library/$game/$(dirname "$PORT_SCRIPT")"/*.cfg 2>/dev/null)
if [[ -n "$cfgFiles" ]]; then
cfgKeepFiles=$(mktemp -d "$run/cfgfiles.XXXXXX")
cp $cfgFiles "$cfgKeepFiles/"
fi
fi
# The originals' files go back however the comparison ends, a kill included: a run cut short
# would otherwise leave the game's own writes (its board, its pool index) for the next run and
# the description to disagree on.
restoreConfig() {
if [[ -n "$cfgDir" ]]; then
rm -rf "$cfgDir"
cp -r "$cfgKeep" "$cfgDir"
fi
if [[ -n "$cfgFiles" ]]; then
cp "$cfgKeepFiles"/*.cfg "$library/$game/$(dirname "$PORT_SCRIPT")/"
fi
}
trap restoreConfig EXIT
a=$(trace original "$original")
b=$(trace port "$port")
if [[ ! -s "$a" || ! -s "$b" ]]; then
echo "$family $game: a run produced no trace; see $(dirname "$a")/run.log and $(dirname "$b")/run.log" >&2
exit 2
fi
if diff -q "$a" "$b" > /dev/null; then
echo "$family $game: the port plays as the original does ($(wc -l < "$a") trace lines; $(tail -1 "$a"))"
exit 0
fi
echo "$family $game: the traces differ"
diff "$a" "$b" | head -20
exit 1

View file

@ -0,0 +1,498 @@
-- How a KarisFramework game and its Forge port are driven for comparison (FORGE.md section
-- 14.1): the same policy, read off the same state in either. A run presses START on the
-- attract loop, then answers each move a few frames into its window -- the right answer, or a
-- wrong one for every fifth move so deaths and continues are exercised -- and takes the one
-- continue offered. Both scripts hand this file a view of their state (karisView) and a way
-- to press and release a switch (karisPress); this file does the deciding.
--
-- The trace both print: every disc seek, and every change of the framework's screen, step,
-- level, scene, move, score, and lives, in the framework's numbers.
KARIS_SEED = 20260914
local lastLine = nil
local pressed = nil -- The switch held down this frame, released the next.
local started = false
local coined = false
local continued = false
local answered = {} -- Windows already answered, by "level/scene/move/lives".
local tried = {} -- Moves met before, by "level/scene/move": a retry after a death.
-- What the drive does with a move, so that every path of a loop is walked: most moves are
-- answered right; every fifth is answered wrong (a stray input, a wrong option, a mash
-- broken by a stray input); every seventh is left unanswered (the window runs out). A move
-- met again after a death is answered right. PORT_WRONG_EVERY and PORT_MISS_EVERY set the
-- two counts, for a run that goes deeper into a game with few lives.
local WRONG_EVERY = tonumber(os.getenv("PORT_WRONG_EVERY")) or 5
local MISS_EVERY = tonumber(os.getenv("PORT_MISS_EVERY")) or 7
local function wrongMove(view)
return (view.move % WRONG_EVERY == 0) and not view.retry
end
local function missedMove(view)
return (view.move % MISS_EVERY == 0) and (view.move % WRONG_EVERY ~= 0) and not view.retry
end
local seeks = 0
local plan = nil -- Kimmy: the presses planned for the window being answered.
local held = {} -- Kimmy: the switches the plan holds down, by switch.
local menuAt = 0 -- Kimmy: the frame a menu was last answered on.
KARIS_FRAMES = tonumber(os.getenv("PORT_FRAMES")) or 60000
-- The framework's move numbers as switches; Kimmy's diagonals are two of them.
local SWITCH_OF = { [1] = SWITCH_UP, [2] = SWITCH_DOWN, [3] = SWITCH_LEFT, [4] = SWITCH_RIGHT, [5] = SWITCH_BUTTON1, [6] = SWITCH_BUTTON2, [7] = SWITCH_BUTTON3 }
local PAIR_OF = { [9] = { 1, 3 }, [10] = { 1, 4 }, [11] = { 2, 3 }, [12] = { 2, 4 } }
local KIMMY = { COMBO = 20, MULTI = 21, LOOP = 22, MASH = 23, HOLD = 24, LETGO = 25, PATH = 51, YESNO = 52, TIMED = 53, ANYTHING = 54, NOTHING = 55, UD = 151, LR = 153, B1B2 = 155 }
-- The KarisFramework's compound kinds, and the MazescaterFramework's (LINEA), as what they
-- take, by the framework's numbers: a sequence's inputs in turn (a circle is five, back to
-- its start), a mash's input (or the two it alternates), a hold's input, and the inputs a
-- combination wants together.
local SEQ_OF = { [35] = { 3, 2, 4, 1, 3 }, [36] = { 4, 2, 3, 1, 4 },
[156] = { 1, 3 }, [157] = { 1, 4 }, [158] = { 2, 3 }, [159] = { 2, 4 }, [160] = { 3, 1 }, [161] = { 4, 1 }, [162] = { 3, 2 }, [163] = { 4, 2 },
[170] = { 4, 3 }, [171] = { 3, 4 }, [172] = { 1, 2 }, [173] = { 2, 1 },
[149] = { 2, 3, 5 }, [150] = { 2, 4, 5 }, [151] = { 1, 3, 5 }, [152] = { 1, 4, 5 }, [164] = { 3, 1, 5 }, [165] = { 4, 1, 5 }, [166] = { 4, 2, 5 }, [167] = { 3, 2, 5 },
[168] = { 2, 3, 6 }, [169] = { 2, 4, 6 }, [174] = { 1, 3, 6 }, [175] = { 1, 4, 6 }, [176] = { 3, 1, 6 }, [177] = { 4, 1, 6 }, [178] = { 4, 2, 6 }, [179] = { 3, 2, 6 },
[180] = { 2, 3, 1 }, [181] = { 2, 4, 1 }, [182] = { 3, 2, 4 }, [183] = { 3, 1, 4 }, [184] = { 4, 2, 3 }, [185] = { 4, 1, 3 }, [186] = { 1, 3, 2 }, [187] = { 1, 4, 2 },
[216] = { 3, 2, 4, 1, 3 }, [219] = { 2, 4, 1, 3, 2 }, [218] = { 4, 1, 3, 2, 4 }, [217] = { 1, 3, 2, 4, 1 },
[212] = { 3, 1, 4, 2, 3 }, [213] = { 1, 4, 2, 3, 1 }, [214] = { 4, 2, 3, 1, 4 }, [215] = { 2, 3, 1, 4, 2 } }
local MASH_OF = { [17] = { 5 }, [18] = { 5 }, [19] = { 5 }, [27] = { 3, 4 }, [28] = { 3, 4 }, [29] = { 3, 4 }, [30] = { 5, 6 }, [31] = { 5, 6 }, [32] = { 5, 6 },
[153] = { 5, 7 }, [154] = { 5, 7 }, [155] = { 5, 7 }, [188] = { 6 }, [189] = { 6 }, [190] = { 6 }, [192] = { 7 }, [193] = { 7 }, [194] = { 7 },
[196] = { 3 }, [197] = { 3 }, [198] = { 3 }, [200] = { 4 }, [201] = { 4 }, [202] = { 4 }, [204] = { 1 }, [205] = { 1 }, [206] = { 1 }, [208] = { 2 }, [209] = { 2 }, [210] = { 2 } }
local HOLD_OF = { [21] = 1, [22] = 2, [23] = 3, [24] = 4, [25] = 5 }
local TOGETHER = { [9] = { 1, 3 }, [10] = { 1, 4 }, [11] = { 2, 3 }, [12] = { 2, 4 }, [13] = { 5, 1 }, [14] = { 5, 2 }, [15] = { 5, 3 }, [16] = { 5, 4 },
[220] = { 6, 1 }, [221] = { 6, 2 }, [222] = { 6, 3 }, [223] = { 6, 4 }, [224] = { 7, 1 }, [225] = { 7, 2 }, [226] = { 7, 3 }, [227] = { 7, 4 },
[228] = { 5, 1, 3 }, [229] = { 5, 1, 4 }, [230] = { 5, 2, 3 }, [231] = { 5, 2, 4 }, [232] = { 6, 1, 3 }, [233] = { 6, 1, 4 }, [234] = { 6, 2, 3 }, [235] = { 6, 2, 4 },
[236] = { 7, 1, 3 }, [237] = { 7, 1, 4 }, [238] = { 7, 2, 3 }, [239] = { 7, 2, 4 } }
local KARIS_LETGO, KARIS_MULTI = 20, 33
-- The switches an input number stands for.
local function switchesOf(kind)
if PAIR_OF[kind] then
return { SWITCH_OF[PAIR_OF[kind][1]], SWITCH_OF[PAIR_OF[kind][2]] }
end
return { SWITCH_OF[kind] }
end
-- A plan's steps: what to press and let go on which frame.
local function planPress(steps, frame, switches)
steps[frame] = steps[frame] or { press = {}, release = {} }
for _, s in ipairs(switches) do
table.insert(steps[frame].press, s)
end
end
local function planRelease(steps, frame, switches)
steps[frame] = steps[frame] or { press = {}, release = {} }
for _, s in ipairs(switches) do
table.insert(steps[frame].release, s)
end
end
local function planTap(steps, frame, switches)
planPress(steps, frame, switches)
planRelease(steps, frame + 1, switches)
end
-- A switch a move does not accept, for a wrong answer.
local function wrongFor(accepted)
for _, candidate in ipairs({ 2, 1, 3, 4, 6 }) do
local ok = true
for _, a in ipairs(accepted) do
if a == candidate then
ok = false
end
if PAIR_OF[a] and ((PAIR_OF[a][1] == candidate) or (PAIR_OF[a][2] == candidate)) then
ok = false
end
end
if ok then
return { SWITCH_OF[candidate] }
end
end
return { SWITCH_BUTTON2 }
end
-- A plan: what to press and let go on which frames, for one window.
local function planFor(view)
local w = view.window
local at = w.from + 3
local steps = {}
local wrong = wrongMove(view)
local kind = w.kind
if missedMove(view) then
-- Left unanswered: the window runs out.
elseif ((kind >= 1) and (kind <= 7)) or ((kind >= 9) and (kind <= 12)) then
if wrong then
planTap(steps, at, wrongFor({ kind, w.alt }))
else
planTap(steps, at, switchesOf(kind))
end
elseif (kind == KIMMY.MASH) and wrong then
-- A mash broken by a stray input.
planTap(steps, at, wrongFor({ w.alt or 5 }))
elseif kind == KIMMY.COMBO then
local both = switchesOf(w.alt)
for _, s in ipairs(switchesOf(w.alt2)) do
both[#both + 1] = s
end
planTap(steps, at, both)
elseif kind == KIMMY.HOLD then
planPress(steps, at, switchesOf(w.alt))
planRelease(steps, w.to, switchesOf(w.alt))
elseif kind == KIMMY.LETGO then
planPress(steps, w.from - 4, { SWITCH_BUTTON1 })
planRelease(steps, w.from + 2, { SWITCH_BUTTON1 })
elseif kind == KIMMY.MASH then
local turn = 0
for frame = at, w.to - 2, 2 do
local which
if w.alt == KIMMY.UD then
which = (turn % 2 == 0) and { SWITCH_UP } or { SWITCH_DOWN }
elseif w.alt == KIMMY.LR then
which = (turn % 2 == 0) and { SWITCH_LEFT } or { SWITCH_RIGHT }
elseif w.alt == KIMMY.B1B2 then
which = (turn % 2 == 0) and { SWITCH_BUTTON1 } or { SWITCH_BUTTON2 }
else
which = switchesOf(w.alt or 5)
end
planTap(steps, frame, which)
turn = turn + 1
end
elseif (kind == KIMMY.MULTI) or (kind == KIMMY.LOOP) then
local start = at + ((kind == KIMMY.MULTI) and (w.alt2 or 0) or 0)
for i, step in ipairs(view.multi or {}) do
planTap(steps, start + 2 * (i - 1), switchesOf(step))
end
elseif kind == KIMMY.PATH then
local p = view.path or {}
if wrong then
planTap(steps, at, wrongFor({ p[1], p[3], p[5], p[7] }))
elseif p[1] and (p[1] > 0) then
planTap(steps, at, switchesOf(p[1]))
end
elseif kind == KIMMY.YESNO then
if view.move % 2 == 0 then
planTap(steps, at, { SWITCH_BUTTON1 })
end
elseif kind == KIMMY.TIMED then
local first = (view.timed or {})[w.alt or view.move]
if first and (first[2] + 1 >= w.from) and (first[2] + 1 <= w.to) then
planTap(steps, first[2] + 1, switchesOf(first[1]))
end
elseif kind == KIMMY.ANYTHING then
planTap(steps, at, { SWITCH_BUTTON1 })
elseif kind == KIMMY.NOTHING then
if wrong then
planTap(steps, at, { SWITCH_BUTTON1 })
end
end
return { steps = steps, from = w.from - 6, to = w.to + 2 }
end
-- A map-mode plan: the ancestor's kinds. A single input tapped; MASH (8) tapped every other
-- frame; an ACT combo (9 to 12) as the button with a direction; a diagonal (13 to 16) as two
-- directions; SKIP (17) left alone. Every fifth move is answered wrong.
local RDG_ACT = { [9] = { 5, 1 }, [10] = { 5, 2 }, [11] = { 5, 3 }, [12] = { 5, 4 }, [13] = { 1, 3 }, [14] = { 1, 4 }, [15] = { 2, 3 }, [16] = { 2, 4 } }
local function planForRdg(view)
local w = view.window
local at = w.from + 3
local steps = {}
local wrong = wrongMove(view)
local kind = w.kind
if missedMove(view) then
-- Left unanswered: the window runs out.
elseif (kind >= 1) and (kind <= 7) then
if wrong then
planTap(steps, at, { (kind == 1) and SWITCH_DOWN or SWITCH_UP })
else
planTap(steps, at, { SWITCH_OF[kind] })
end
elseif (kind == (view.mashKind or 8)) and wrong then
-- A mash broken by a stray direction.
planTap(steps, at, { SWITCH_UP })
elseif kind == (view.mashKind or 8) then
for frame = at, w.to - 2, 2 do
planTap(steps, frame, { SWITCH_BUTTON1 })
end
elseif (view.pairs or RDG_ACT)[kind] and view.zone then
-- A shot: the pointer into the zone (or well outside it) the frame before the button,
-- in the raw coordinates the game scales by its ratios.
local z = view.zone
local r = view.mouse or { 1, 1, 0, 0 }
local mx = wrong and (z[3] + 200) or ((z[1] + z[3]) / 2)
local my = wrong and (z[4] + 200) or ((z[2] + z[4]) / 2)
steps[at - 1] = steps[at - 1] or { press = {}, release = {} }
steps[at - 1].mouse = { math.floor((mx + r[3]) / r[1]), math.floor((my + r[4]) / r[2]) }
planTap(steps, at, { SWITCH_BUTTON1 })
elseif (view.pairs or RDG_ACT)[kind] then
local pair = (view.pairs or RDG_ACT)[kind]
if wrong then
planTap(steps, at, { SWITCH_BUTTON2 })
else
planTap(steps, at, { SWITCH_OF[pair[1]], SWITCH_OF[pair[2]] })
end
end
return { steps = steps, from = w.from - 6, to = w.to + 2 }
end
-- A KarisFramework plan for a compound move: a hold pressed from three frames into the
-- window to its end; a let-go held from before the window and released within it; a mash
-- tapped every other frame (a run, MASH2, and MASH3 alternating their two); a sequence or a
-- circle a step every other frame; a multi tapped as many times as its row asks; a
-- combination pressed together. Every fifth move is answered wrong (a stray input, or
-- nothing for a let-go); every seventh is left unanswered.
local function planForKaris(view)
local w = view.window
local at = w.from + 3
local steps = {}
local wrong = wrongMove(view)
local kind = w.kind
local function switches(list)
local result = {}
for _, m in ipairs(list) do
result[#result + 1] = SWITCH_OF[m]
end
return result
end
if missedMove(view) then
-- Left unanswered: the window runs out.
elseif HOLD_OF[kind] then
if wrong then
planTap(steps, at, wrongFor({ HOLD_OF[kind] }))
else
planPress(steps, at, { SWITCH_OF[HOLD_OF[kind]] })
planRelease(steps, w.to, { SWITCH_OF[HOLD_OF[kind]] })
end
elseif kind == KARIS_LETGO then
if not wrong then
planPress(steps, w.from - 4, { SWITCH_BUTTON1 })
planRelease(steps, w.from + 2, { SWITCH_BUTTON1 })
end
elseif MASH_OF[kind] then
if wrong then
planTap(steps, at, wrongFor(MASH_OF[kind]))
else
local turn = 0
for frame = at, w.to - 2, 2 do
planTap(steps, frame, { SWITCH_OF[MASH_OF[kind][(turn % #MASH_OF[kind]) + 1]] })
turn = turn + 1
end
end
elseif SEQ_OF[kind] then
if wrong then
planTap(steps, at, wrongFor(SEQ_OF[kind]))
else
for i, m in ipairs(SEQ_OF[kind]) do
planTap(steps, at + 2 * (i - 1), { SWITCH_OF[m] })
end
end
elseif kind == KARIS_MULTI then
if wrong then
planTap(steps, at, wrongFor({ w.alt }))
else
for i = 1, (w.alt2 or 1) do
planTap(steps, at + 2 * (i - 1), { SWITCH_OF[w.alt] })
end
end
elseif TOGETHER[kind] then
if wrong then
planTap(steps, at, wrongFor(TOGETHER[kind]))
else
planTap(steps, at, switches(TOGETHER[kind]))
end
end
return { steps = steps, from = w.from - 6, to = w.to + 2 }
end
-- A choice: step down to the right option (a wrong one every fifth move), then take it.
function answerChoice(view, key, w)
if (view.frame >= w.from + 3) and (view.frame <= w.to) then
local want = nil
local wrong = wrongMove(view)
for at, index in ipairs(view.order or {}) do
if view.choices[index] and (view.choices[index].right ~= wrong) and (want == nil or not wrong) then
want = at
end
end
if want and (view.choice < want) then
pressed = SWITCH_DOWN
karisPress(pressed, true)
elseif want and (view.choice == want) and not answered[key] then
answered[key] = true
pressed = SWITCH_BUTTON1
karisPress(pressed, true)
end
end
end
-- Runs the plan for this frame.
local function followPlan(frame)
local step = plan and plan.steps[frame]
if step then
if step.mouse then
karisMouse(step.mouse[1], step.mouse[2])
end
for _, s in ipairs(step.press) do
if not held[s] then
held[s] = true
karisPress(s, true)
end
end
for _, s in ipairs(step.release) do
if held[s] then
held[s] = nil
karisPress(s, false)
end
end
end
if plan and (frame > plan.to) then
for s in pairs(held) do
karisPress(s, false)
end
held = {}
plan = nil
end
end
function karisTraceSeek(frame)
seeks = seeks + 1
debugPrint(string.format("TRACE seek %d", frame))
end
-- Presses what the policy says this frame. view: { screen, step, level, scene, move, frame,
-- window = { from, to, kind, death, alt }, choices = { { right }, ... }, order, choice }.
function karisDrive(view)
if pressed then
karisPress(pressed, false)
pressed = nil
end
local line = string.format("TRACE screen %s step %s level %s scene %s move %s score %s lives %s", tostring(view.screen), tostring(view.step), tostring(view.level), tostring(view.scene), tostring(view.move), tostring(view.score), tostring(view.lives))
if line ~= lastLine then
lastLine = line
debugPrint(line .. " disc " .. tostring(view.frame))
end
-- A coin on the attract loop when the game is not free play, then START once it has begun
-- playing.
if (view.screen == 0) and not started and (view.frame >= 150) and (not view.freeplay) and (view.credits == 0) and not coined then
coined = true
pressed = SWITCH_COIN1
karisPress(pressed, true)
return
end
if (view.screen == 0) and not started and (view.frame >= 160) and (view.freeplay or (view.credits > 0)) then
started = true
pressed = SWITCH_START1
karisPress(pressed, true)
return
end
-- Kimmy's menus: the new-game menu (a level the framework never numbered, so nil) takes
-- the button for NEW GAME; the level select takes the button for the level shown.
if view.kimmy and started and ((view.screen == nil) or (view.screen == 117)) and (view.frame ~= menuAt) and (view.frames > menuAt + 30) then
menuAt = view.frames
pressed = SWITCH_BUTTON1
karisPress(pressed, true)
return
end
-- The continue screen: taken once.
if (view.screen == 100) and (view.step == 1) and not continued then
continued = true
pressed = SWITCH_START1
karisPress(pressed, true)
return
end
if (view.screen ~= 103) or (view.window == nil) then
if plan then
followPlan(view.frame)
end
return
end
-- A move is answered once per life: a game that rewinds to the move after a death
-- (the later map-mode copies) asks it again, and the drive answers it right that time.
local moveKey = tostring(view.level) .. "/" .. tostring(view.scene) .. "/" .. tostring(view.move)
local key = moveKey .. "/" .. tostring(view.lives)
local w = view.window
view.retry = (tried[moveKey] ~= nil)
if view.kimmy or view.rdg then
-- A Kimmy or map-mode move: a plan of presses for the window, made when it comes into
-- view.
if (view.step == 1) and (plan == nil) and not answered[key] and (view.frame >= w.from - 6) and (view.frame <= w.to) then
answered[key] = true
tried[moveKey] = true
plan = view.rdg and planForRdg(view) or planFor(view)
elseif view.rdg and (view.step == ((view.timegal and 12) or 11)) and view.choices then
answerChoice(view, key, w)
end
followPlan(view.frame)
elseif view.step == 1 then
local kind = w.kind
if (kind >= 1) and (kind <= 7) then
-- Waiting for a single input: answer three frames into the window.
if (view.frame >= w.from + 3) and (view.frame <= w.to) and not answered[key] then
answered[key] = true
tried[moveKey] = true
if not missedMove(view) then
local wrong = wrongMove(view)
local press = wrong and ((kind == 1) and SWITCH_DOWN or SWITCH_UP) or SWITCH_OF[kind]
pressed = press
karisPress(press, true)
end
end
elseif (plan == nil) and not answered[key] and (view.frame >= w.from - 6) and (view.frame <= w.to) and (HOLD_OF[kind] or MASH_OF[kind] or SEQ_OF[kind] or TOGETHER[kind] or (kind == KARIS_LETGO) or (kind == KARIS_MULTI)) then
-- A compound move: a plan of presses over the window, made as it comes into view.
answered[key] = true
tried[moveKey] = true
plan = planForKaris(view)
end
followPlan(view.frame)
elseif (view.step == 19) and view.choices then
answerChoice(view, key, w)
elseif plan then
-- The move is judged: what the plan still holds is let go, and the rest of it dropped.
for s in pairs(held) do
karisPress(s, false)
end
held, plan = {}, nil
end
end
-- The run is long enough when the game has been played through a continue and out.
function karisDone(view)
return continued and (view.screen == 0) and (seeks > 0)
end

View file

@ -0,0 +1,106 @@
-- A KarisFramework game as it ships, driven by karisDrive.singe and traced. PORT_GAME names
-- the game's directory in the library and PORT_SCRIPT its main script; run with -v the video
-- its games.dat names.
local here = ((debug.getinfo(1, "S").source:gsub("^@", "")):match("^(.*[/\\])") or "")
local library = os.getenv("SINGE_LIBRARY") or (os.getenv("HOME") .. "/claude/singetest/ported")
local game = os.getenv("PORT_GAME") or "TimeGal"
local script = library .. "/" .. game .. "/" .. (os.getenv("PORT_SCRIPT") or "Timegal.singe")
dofile(here .. "karisDrive.singe")
-- What its launcher entry and the engine would have given it, and a seed both runs share.
SINGE_LEGACY_SPRITE_ARGS = true
local realTime = os.time
os.time = function(...) return KARIS_SEED end
local realScriptPath = singeGetScriptPath
singeGetScriptPath = function() return script end
local realSkip = discSkipToFrame
discSkipToFrame = function(frame)
karisTraceSeek(frame)
return realSkip(frame)
end
dofile(script)
singeGetScriptPath = realScriptPath
local realUpdate = onOverlayUpdate
local frames = 0
function karisPress(switch, down)
if down then
onInputPressed(switch)
else
onInputReleased(switch)
end
end
function karisMouse(x, y)
if onMouseMoved then
onMouseMoved(x, y, 0, 0, 0)
end
end
local function view()
local m = move and move[currentMove or 0] or nil
local window = nil
local chosen = nil
-- A Kimmy game (acombo is its own) hands over the sequences and branches of the move too;
-- a map-mode game (segment is its own) says so, for its own numbering of the kinds.
local kimmy = (acombo ~= nil)
local timegal = (STAGEMAX ~= nil)
local rdg = ((segment ~= nil) or timegal) and (acombo == nil)
if timegal then
-- Time Gal's row: clip start, window start, window end, clip end, death clip, kind.
if (currentLevel == levelNormal) and m and (m[2] ~= nil) then
window = { from = m[2], to = m[3], kind = m[7], death = 0, alt = 0, alt2 = 0 }
end
elseif (currentLevel == levelNormal) and m and (m[1] ~= nil) then
window = { from = m[1], to = m[2], kind = m[3], death = m[4], alt = m[5], alt2 = m[6] }
end
-- A map-mode shot: a down action whose row carries a zone, and the game's pointer ratios.
local zone = (m and (m[3] == ACTDOWN) and (m[7] ~= nil)) and { m[7], m[8], m[9], m[10] } or nil
local mouse = ratiox and { ratiox, ratioy, ratioxOffset, ratioyOffset } or nil
-- The pair kinds by the game's own numbers (Super Don Quixote numbers them as Karis does).
local pairs = nil
if rdg and ACTUP then
pairs = { [ACTUP] = { 5, 1 }, [ACTDOWN] = { 5, 2 }, [ACTLEFT] = { 5, 3 }, [ACTRIGHT] = { 5, 4 }, [UPLEFT] = { 1, 3 }, [UPRIGHT] = { 1, 4 }, [DOWNLEFT] = { 2, 3 }, [DOWNRIGHT] = { 2, 4 } }
end
-- A choice: the framework's at branch10, the later map-mode copies' at branch02.
if ((lvlState == branch10) or (rdg and (lvlState == branch02))) and choice then
chosen = {}
for index, c in ipairs(choice) do
chosen[index] = { right = c[2] }
end
end
if timegal and (lvlState == branch03) and opt then
chosen = {}
for index, c in ipairs(opt) do
chosen[index] = { right = c[2] }
end
end
return { screen = currentLevel, step = lvlState, level = iLevel or iCurPos or iStageIndex, scene = iScene or iSegPointer, move = currentMove, frame = discGetFrame(), frames = frames, score = iScore, lives = iLives, window = window, choices = chosen, order = optorder, choice = iChoice,
kimmy = kimmy, rdg = rdg, timegal = timegal, multi = kimmy and multi and multi[currentMove] or nil, path = kimmy and path and path[currentMove] or nil, timed = kimmy and timed or nil,
freeplay = (dip_CoinsPerCredit == DOPT_FREEPLAY), credits = iCredits, zone = zone, mouse = mouse, pairs = pairs, mashKind = rdg and MASH or nil }
end
function onOverlayUpdate()
frames = frames + 1
if gameflow == flow_GameRunning then
karisDrive(view())
end
local r = realUpdate()
if (frames > 100) and karisDone(view()) or (frames > KARIS_FRAMES) then
debugPrint("TRACE end after " .. frames .. " frames")
singeQuit()
end
return r
end

View file

@ -0,0 +1,101 @@
-- The Forge port of a KarisFramework game, driven and traced as the original is.
local here = ((debug.getinfo(1, "S").source:gsub("^@", "")):match("^(.*[/\\])") or "")
QTE_LEVEL_FINISH = 110
local library = os.getenv("SINGE_LIBRARY") or (os.getenv("HOME") .. "/claude/singetest/ported")
local game = os.getenv("PORT_GAME") or "TimeGal"
local name = (os.getenv("PORT_SCRIPT") or "Timegal.singe"):gsub("%.singe$", "")
local stem = name:match("([^/\\]+)$")
dofile("Forge/AuthorCompile.singe")
dofile(here .. "karisDrive.singe")
local realSkip = discSkipToFrame
discSkipToFrame = function(frame)
karisTraceSeek(frame)
return realSkip(frame)
end
dofile(authorBuild(library .. "/" .. game .. "/" .. name .. ".forge", singeGetDataPath() .. stem .. ".port.singe"))
local realUpdate = onOverlayUpdate
local frames = 0
function karisPress(switch, down)
if down then
authorSwitchDown(switch)
else
authorSwitchUp(switch)
end
end
function karisMouse(x, y)
onMouseMoved(x, y, 0, 0, 0)
end
local function view()
local q = authorEntity("game").q
local m = q.move[q.currentMove]
local window = nil
local chosen = nil
if q.timegal then
if (q.currentLevel == 103) and m and (m[2] ~= nil) then
window = { from = m[2], to = m[3], kind = m[7], death = 0, alt = 0, alt2 = 0 }
end
elseif (q.currentLevel == 103) and m and (m[1] ~= nil) then
window = { from = m[1], to = m[2], kind = m[3], death = m[4], alt = m[5], alt2 = m[6] }
end
local zone = (m and (m[3] == q.ACTDOWN) and (m[7] ~= nil)) and { m[7], m[8], m[9], m[10] } or nil
local mouse = q.ratiox and { q.ratiox, q.ratioy, q.ratioxOffset, q.ratioyOffset } or nil
local pairs = nil
if q.rdg and q.ACTUP then
pairs = { [q.ACTUP] = { 5, 1 }, [q.ACTDOWN] = { 5, 2 }, [q.ACTLEFT] = { 5, 3 }, [q.ACTRIGHT] = { 5, 4 }, [q.UPLEFT] = { 1, 3 }, [q.UPRIGHT] = { 1, 4 }, [q.DOWNLEFT] = { 2, 3 }, [q.DOWNRIGHT] = { 2, 4 } }
end
if ((q.lvlState == 19) or (q.rdg and (q.lvlState == 11))) and q.choice then
chosen = {}
for index, c in ipairs(q.choice) do
chosen[index] = { right = c[2] }
end
end
if q.timegal and (q.lvlState == 12) and q.opt then
chosen = {}
for index, c in ipairs(q.opt) do
chosen[index] = { right = c[2] }
end
end
-- Kimmy's new-game menu is a level the framework never numbered (nil there); the port
-- numbers it, and reports it as the original does.
local screen = q.currentLevel
if q.kimmy and (screen == q.levelNG) then
screen = nil
end
-- The map-mode lineage never numbered its finish screen either.
if q.rdg and (screen == QTE_LEVEL_FINISH) then
screen = nil
end
return { screen = screen, step = q.lvlState, level = q.iLevel or q.iCurPos or q.iStageIndex, scene = q.iScene or q.iSegPointer, move = q.currentMove, frame = discGetFrame(), frames = frames, score = q.iScore, lives = q.iLives, window = window, choices = chosen, order = q.optorder, choice = q.iChoice,
kimmy = q.kimmy, rdg = q.rdg, timegal = q.timegal, multi = q.kimmy and q.multi[q.currentMove] or nil, path = q.kimmy and q.path[q.currentMove] or nil, timed = q.kimmy and q.timed or nil,
freeplay = (q.dip_CoinsPerCredit == q.DOPT_FREEPLAY), credits = q.iCredits, zone = zone, mouse = mouse, pairs = pairs, mashKind = q.rdg and q.MASH or nil }
end
function onOverlayUpdate()
frames = frames + 1
if authorEntity("game").q.gameflow == "running" then
karisDrive(view())
end
local r = realUpdate()
if (frames > 100) and karisDone(view()) or (frames > KARIS_FRAMES) then
debugPrint("TRACE end after " .. frames .. " frames")
singeQuit()
end
return r
end

49
testScripts/ports/launch.sh Executable file
View file

@ -0,0 +1,49 @@
#!/bin/bash
# Launches every port in the ports folder from its games.dat, headless on a virtual screen and
# the deterministic clock, for a while each, and counts the runs that printed no error
# (FORGE.md section 14.8). Coverage, not a comparison: compare.sh is the comparison.
#
# testScripts/ports/launch.sh [<ports folder>]
#
# SINGE the engine binary (default: the newest build in .builddir)
# SINGE_RUN a directory to run in (default: a fresh one under /tmp)
# SECONDS_EACH how long each port runs (default: 25)
# JOBS ports run at once (default: 2; four 1080p games on Xvfb ran a
# 30 GB machine out of memory)
set -u
ports=${1:-$HOME/claude/singePorts}
repo=$(cd "$(dirname "$0")/../.." && pwd)
binary=${SINGE:-$(ls -t "$repo"/.builddir/linux-gcc/*/singe/Singe-v* "$repo"/.builddir/linux/x86_64/singe/Singe-v* 2>/dev/null | head -1)}
run=${SINGE_RUN:-$(mktemp -d /tmp/forgePorts.XXXXXX)}
each=${SECONDS_EACH:-25}
jobs=${JOBS:-2}
if [[ -z "$binary" || ! -x "$binary" ]]; then
echo "launch.sh: no engine binary; set SINGE" >&2
exit 2
fi
binary=$(realpath "$binary") # The runs change directory.
# SDL's offscreen driver has a 1024x768 desktop, smaller than the KarisFramework games ask for.
export SDL_VIDEODRIVER=x11 SDL_AUDIO_DRIVER=dummy
one() {
local script=$1 video=$2
local stem=$(basename "$script" .port.singe)
local work=$run/$stem
rm -rf "$work"
mkdir -p "$work"
( cd "$run" && timeout -s INT "$each" xvfb-run -a -s "-screen 0 1920x1200x24" "$binary" -w -p -k --deterministic -d "$work" -v "$ports/$video" "$ports/$script" ) > "$work/run.log" 2>&1
local rc=$?
# ffmpeg's hwaccel fallback notice and the X server going away under the timeout's INT are
# the harness, not the game.
local bad=$(grep -a "rror\|Author:\|raceback\|FAIL\|does not exist\|Unable to" "$work/run.log" | grep -vc "hwaccel\|XIO:")
if [[ "$bad" = 0 && ( $rc = 124 || $rc = 0 ) ]]; then
echo "$stem clean"
else
echo "$stem rc=$rc problems=$bad: $(grep -a "rror\|Author:\|raceback\|does not exist\|Unable to" "$work/run.log" | grep -v "hwaccel\|XIO:" | head -2 | tr '\n' ' ')"
fi
}
export -f one
export run each binary ports
for dat in "$ports"/*/games.dat; do
paste -d' ' <(grep -o 'SCRIPT *= *"[^"]*"' "$dat" | sed 's/.*"\(.*\)"/\1/') <(grep -o 'VIDEO *= *"[^"]*"' "$dat" | sed 's/.*"\(.*\)"/\1/')
done | xargs -P "$jobs" -L 1 bash -c 'one $0 $1' | tee "$run/results.txt"
echo "launch.sh: $(grep -c ' clean$' "$run/results.txt") of $(wc -l < "$run/results.txt") ports ran clean; logs under $run"

22
testScripts/ports/shots.sh Executable file
View file

@ -0,0 +1,22 @@
#!/bin/bash
# Runs an original and its port through compare.sh with the drive's screenshots and service walk
# on, and gathers the pictures side by side under screenshots/ports/<game>/ in the repository:
# original-shot-<moment>.png beside port-shot-<moment>.png for the attract loop, the service
# screen, a level just after a shot, the continue, and the board (FORGE.md section 14.15).
#
# testScripts/ports/shots.sh <family> <game> [frames]
#
# The comparison's own verdict (the traces agreeing, service walk included) is printed as well.
set -u
family=${1:?usage: shots.sh <family> <game> [frames]}
game=${2:?usage: shots.sh <family> <game> [frames]}
frames=${3:-8000}
repo=$(cd "$(dirname "$0")/../.." && pwd)
name=${game##*/}
name=${name%.singe}
out=$repo/screenshots/ports/$name
rm -rf "$out"
SINGE_SHOTS=$out PORT_SHOTS=1 PORT_SERVICE=1 PORT_FRAMES=$frames "$repo/testScripts/ports/compare.sh" "$family" "$game"
status=$?
ls "$out" 2>/dev/null | sed "s|^|$out/|"
exit $status

68
testScripts/ports/sweep.sh Executable file
View file

@ -0,0 +1,68 @@
#!/bin/bash
# Compares every port with its original for a while each (FORGE.md section 14.1), two pairs at
# a time, and prints one line per title: the ActionMax five, the KarisFramework games and the
# map-mode games of the library, and the Hypseus titles (Karis 3.32b, Kimmy, map-mode, and Time
# Gal HD), each through compare.sh's family for it.
#
# testScripts/ports/sweep.sh [<frames>] (default 6000; 0 for the full length)
#
# SINGE, SINGE_RUN as compare.sh takes them; the library is ~/claude/singetest.
set -u
frames=${1:-6000}
repo=$(cd "$(dirname "$0")/../.." && pwd)
library=$HOME/claude/singetest
run=${SINGE_RUN:-$(mktemp -d /tmp/forgeSweep.XXXXXX)}
export SINGE_RUN=$run
if [[ "$frames" != "0" ]]; then
export PORT_FRAMES=$frames
fi
jobs=()
# One pair: family, game, and the library it lives in.
pair() {
local family=$1 game=$2 lib=$3
local out
# Each pair runs in a directory of its own: compare.sh refreshes Forge into its run
# directory at every start, which would pull it from under a pair already running there.
out=$(SINGE_RUN=$run/${game//\//_} SINGE_LIBRARY=$lib "$repo/testScripts/ports/compare.sh" "$family" "$game" 2>&1 | grep -a -v "hwaccel\|XIO" | grep -a "plays as\|differ\|no trace\|compare.sh" | tail -1)
echo "${out:-$family $game: no result}"
}
# Two at a time: three engines have run the machine out of memory.
throttle() {
while (( $(jobs -rp | wc -l) >= 2 )); do
sleep 5
done
}
for game in 38AmbushAlley BlueThunder Hydrosub2021 PopsGhostly SonicFury; do
throttle
pair actionMax "$game" "$library/ported" &
done
for desc in "$library"/ported/*/*.forge; do
folder=$(basename "$(dirname "$desc")")
[[ "$folder" == "ActionMax" || "$folder" == "hypseus" ]] && continue
throttle
pair karis "$folder/$(basename "${desc%.forge}").singe" "$library/ported" &
done
# The map-mode games keep their script in Script/ (in ported/ once they agree, in the library
# root until then).
for lib in "$library/ported" "$library"; do
for desc in "$lib"/*/Script/*.forge; do
[[ -f "$desc" ]] || continue
folder=$(basename "$(dirname "$(dirname "$desc")")")
throttle
pair karis "$folder/Script/$(basename "${desc%.forge}").singe" "$lib" &
done
done
for title in "$library"/ported/hypseus/*/; do
title=$(basename "$title")
desc=$(find -L "$library/ported/hypseus/$title/singe" -mindepth 2 -maxdepth 2 -name "*.forge" 2>/dev/null | head -1)
[[ -n "$desc" ]] || continue
family=hypseus
if grep -a -q 'kind = "rdg"\|kind = "timegal"' "$desc"; then
family=rdg
elif grep -a -q 'kind = "alg"' "$desc"; then
family=alg
fi
throttle
pair "$family" "$title" "$library/ported/hypseus" &
done
wait

View file

@ -0,0 +1,200 @@
-- Drives Hologram Time Traveler (the original or its port) from what the game shows, the same
-- way for both. The driver file defines what it takes: a view() of the game's state and
-- ttPress(switch, down) to press or let go of a switch; this file decides. The trace lines
-- it prints are what compare.sh diffs.
--
-- The policy: a coin and START at the attract loop; in a level, the move the row asks for
-- three frames into its window (a turn first where the row wants a turn and a shot), a wrong
-- one on every fifth move and none on every seventh with misses on; a cube against the first
-- death with misses on; at the map, the trader once with misses on (a coin buys a cube) and
-- the timer's own choice otherwise; a pull at the hellgate's lever; a coin and START at the
-- continue; a letter at the board.
TT_FRAMES = tonumber(os.getenv("PORT_FRAMES")) or 60000
local perfect = (os.getenv("PORT_PERFECT") == "1")
local frames = 0
local lastLine = nil
local pending = nil -- { switch } pressed this frame, let go the next.
local answered = {} -- Windows answered, by level/segment/move/lives.
local tried = {} -- Moves met before, by level/segment/move.
local coined = false
local started = false
local cubeUsed = false
local traded = false
local pulled = false
local lettered = false
local continues = 0
local turnAt = nil -- The frame the turn went in, for the shot after it.
local SWITCH_OF = nil
function ttTraceSeek(frame)
debugPrint(string.format("TRACE seek %d", frame))
end
local function tap(switch)
ttPress(switch, true)
pending = switch
end
-- view: { flow, screen, step, alt, level, seg, move, moves, frame, score, lives, cubes, credits,
-- window = { from, to }, correct (the row's move, or nil for the tutorial's choice),
-- turned (the turn is in), dying, wizard, mapWait, mapCursor, trader, hellgate,
-- continuing, board }
function ttDrive(view)
if pending then
ttPress(pending, false)
pending = nil
end
frames = frames + 1
if SWITCH_OF == nil then
SWITCH_OF = { [view.UP] = SWITCH_UP, [view.DOWN] = SWITCH_DOWN, [view.LEFT] = SWITCH_LEFT, [view.RIGHT] = SWITCH_RIGHT, [view.ACTION] = SWITCH_BUTTON1 }
end
-- (altState is left out: the original's LCD drawing steps it while the attract loop shows.)
local line = string.format("TRACE flow %s screen %s step %s level %s seg %s move %s score %s lives %s cubes %s credits %s",
tostring(view.flow), tostring(view.screen), tostring(view.step), tostring(view.level), tostring(view.seg),
tostring(view.move), tostring(view.score), tostring(view.lives), tostring(view.cubes), tostring(view.credits))
if line ~= lastLine then
debugPrint(line .. " disc " .. tostring(view.frame))
lastLine = line
end
if frames >= TT_FRAMES then
debugPrint("TRACE end after " .. frames .. " frames")
singeQuit()
return
end
-- The attract loop: a coin, then START once the credit shows.
if view.intro then
if (frames > 200) and not coined then
coined = true
tap(SWITCH_COIN1)
return
end
if coined and (frames > 300) and (frames % 40 == 0) and (view.credits > 0) then
tap(SWITCH_START1)
end
return
end
-- The continue: a coin, then START, taken once.
if view.continuing then
if (continues < 1) and (frames % 30 == 0) then
if view.credits == 0 then
tap(SWITCH_COIN1)
else
continues = continues + 1
tap(SWITCH_START1)
end
end
return
end
-- The map: the trader once (with misses on), else the timer's own choice.
if view.mapWait then
if (not perfect) and (not traded) and (view.mapCursor == view.TRADER) then
traded = true
tap(SWITCH_BUTTON1)
end
return
end
-- The trader: a coin buys a cube.
if view.trader then
if (frames % 20 == 0) then
tap(SWITCH_COIN1)
end
return
end
-- The hellgate: one pull at the lever with misses on (a roll can take every life); the
-- perfect run lets the clip pass.
if view.hellgate then
if (not perfect) and not pulled then
pulled = true
tap(SWITCH_BUTTON1)
end
return
end
pulled = false
-- The board: one letter, and the rest to the clip's end.
if view.board then
if not lettered then
lettered = true
tap(SWITCH_BUTTON1)
end
return
end
-- A death: a cube against the first, with misses on.
if view.dying then
if (not perfect) and (not cubeUsed) and (view.cubes > 0) then
cubeUsed = true
tap(SWITCH_BUTTON2)
end
return
end
-- A level's window.
if view.window then
local moveKey = tostring(view.level) .. "/" .. tostring(view.seg) .. "/" .. tostring(view.move)
local key = moveKey .. "/" .. tostring(view.lives)
local retry = (tried[moveKey] ~= nil)
local at = view.frame
if (at >= view.window[1] + 3) and (at <= view.window[2]) and not answered[key] then
local correct = view.correct
if correct == nil then
-- The tutorial's choice: the sign post's right skips it, nothing plays it.
answered[key] = true
tried[moveKey] = true
if perfect then
tap(SWITCH_RIGHT)
end
return
end
if correct == view.NOMOVE then
-- A row that wants nothing.
answered[key] = true
return
end
local missed = (not perfect) and (view.move % 7 == 0) and (view.move % 5 ~= 0) and not retry
local wrong = (not perfect) and (view.move % 5 == 0) and not retry
if missed then
answered[key] = true
tried[moveKey] = true
return
end
if (correct == view.TURNRIGHTSHOOT) or (correct == view.TURNLEFTSHOOT) then
-- A turn, then the shot two frames on.
if not view.turned then
if turnAt == nil then
turnAt = at
tap(wrong and SWITCH_UP or ((correct == view.TURNRIGHTSHOOT) and SWITCH_RIGHT or SWITCH_LEFT))
if wrong then
answered[key] = true
tried[moveKey] = true
turnAt = nil
end
end
elseif at >= turnAt + 2 then
answered[key] = true
tried[moveKey] = true
turnAt = nil
tap(SWITCH_BUTTON1)
end
return
end
answered[key] = true
tried[moveKey] = true
local switch = SWITCH_OF[correct]
if wrong then
switch = (correct == view.UP) and SWITCH_DOWN or SWITCH_UP
end
if switch then
tap(switch)
end
end
end
end

View file

@ -0,0 +1,66 @@
-- Hologram Time Traveler as it ships, driven by timetravelerDrive.singe and traced. PORT_GAME
-- names the title's folder under the library and PORT_SCRIPT its script under it; run with -v
-- the title's video.
local here = ((debug.getinfo(1, "S").source:gsub("^@", "")):match("^(.*[/\\])") or "")
local library = os.getenv("SINGE_LIBRARY") or (os.getenv("HOME") .. "/claude/singetest")
local game = os.getenv("PORT_GAME") or "HologramTimeTraveler"
local script = library .. "/" .. (os.getenv("PORT_SCRIPT") or (game .. "/Script/timetraveler.singe"))
dofile(here .. "timetravelerDrive.singe")
SINGE_LEGACY_SPRITE_ARGS = true
local realScriptPath = singeGetScriptPath
singeGetScriptPath = function() return script end
local realSkip = discSkipToFrame
discSkipToFrame = function(frame)
ttTraceSeek(frame)
return realSkip(frame)
end
local realSearch = discSearch
discSearch = function(frame)
ttTraceSeek(frame)
return realSearch(frame)
end
dofile(script)
singeGetScriptPath = realScriptPath
local realUpdate = onOverlayUpdate
function ttPress(switch, down)
if down then
onInputPressed(switch)
else
onInputReleased(switch)
end
end
local function view()
local window, correct = nil, nil
if (currentLevel == levelNormal) and (lvlState == lvlRunning) and move and move[currentMove] and (currentMove <= totalMoves) then
window = { move[currentMove][inputFrmStart], move[currentMove][inputFrmEnd] }
correct = move[currentMove][correctMove]
end
return { flow = gameflow, screen = currentLevel, step = lvlState, alt = altState, level = iCurPos, seg = segID, move = currentMove, moves = totalMoves,
frame = discGetFrame(), score = iScore, lives = iLives, cubes = iRevCubes, credits = iCredits,
window = window, correct = correct, turned = bTurn,
intro = (gameflow == flow_GameRunning) and (currentLevel == levelIntro),
dying = (currentLevel == levelNormal) and (lvlState == lvlPlayDeath),
mapWait = (currentLevel == levelMenuScreen) and (lvlState == lvlRunning), mapCursor = iCurPos,
trader = (currentLevel == levelTrader) and ((lvlState == lvlRunning) or (lvlState == branch01)),
hellgate = (currentLevel == levelHellgate) and (lvlState == branch01),
continuing = (currentLevel == levelContinue) and (lvlState == lvlRunning),
board = (currentLevel == levelHighScore) and (lvlState == lvlRunning),
UP = UP, DOWN = DOWN, LEFT = LEFT, RIGHT = RIGHT, ACTION = ACTION, NOMOVE = NOMOVE, TURNRIGHTSHOOT = TURNRIGHTSHOOT, TURNLEFTSHOOT = TURNLEFTSHOOT, TRADER = levelTrader }
end
onOverlayUpdate = function(...)
local result = realUpdate(...)
ttDrive(view())
return result
end

View file

@ -0,0 +1,63 @@
-- The Forge port of Hologram Time Traveler, driven and traced as the original is.
local here = ((debug.getinfo(1, "S").source:gsub("^@", "")):match("^(.*[/\\])") or "")
local library = os.getenv("SINGE_LIBRARY") or (os.getenv("HOME") .. "/claude/singetest")
local game = os.getenv("PORT_GAME") or "HologramTimeTraveler"
local name = (os.getenv("PORT_SCRIPT") or (game .. "/Script/timetraveler.singe")):gsub("%.singe$", "")
local stem = name:match("([^/\\]+)$")
dofile("Forge/AuthorCompile.singe")
dofile(here .. "timetravelerDrive.singe")
local realSkip = discSkipToFrame
discSkipToFrame = function(frame)
ttTraceSeek(frame)
return realSkip(frame)
end
local realSearch = discSearch
discSearch = function(frame)
ttTraceSeek(frame)
return realSearch(frame)
end
dofile(authorBuild(library .. "/" .. name .. ".forge", singeGetDataPath() .. stem .. ".port.singe"))
local realUpdate = onOverlayUpdate
function ttPress(switch, down)
if down then
authorSwitchDown(switch, nil)
else
authorSwitchUp(switch, nil)
end
end
local function view()
local q = authorEntity("game").q
local window, correct = nil, nil
if (q.currentLevel == q.levelNormal) and (q.lvlState == q.lvlRunning) and q.move and q.move[q.currentMove] and (q.currentMove <= q.totalMoves) then
window = { q.move[q.currentMove][q.inputFrmStart], q.move[q.currentMove][q.inputFrmEnd] }
correct = q.move[q.currentMove][q.correctMove]
end
return { flow = q.gameflow, screen = q.currentLevel, step = q.lvlState, alt = q.altState, level = q.iCurPos, seg = q.segID, move = q.currentMove, moves = q.totalMoves,
frame = discGetFrame(), score = q.iScore, lives = q.iLives, cubes = q.iRevCubes, credits = q.iCredits,
window = window, correct = correct, turned = q.bTurn,
intro = (q.gameflow == q.flow_GameRunning) and (q.currentLevel == q.levelIntro),
dying = (q.currentLevel == q.levelNormal) and (q.lvlState == q.lvlPlayDeath),
mapWait = (q.currentLevel == q.levelMenuScreen) and (q.lvlState == q.lvlRunning), mapCursor = q.iCurPos,
trader = (q.currentLevel == q.levelTrader) and ((q.lvlState == q.lvlRunning) or (q.lvlState == q.branch01)),
hellgate = (q.currentLevel == q.levelHellgate) and (q.lvlState == q.branch01),
continuing = (q.currentLevel == q.levelContinue) and (q.lvlState == q.lvlRunning),
board = (q.currentLevel == q.levelHighScore) and (q.lvlState == q.lvlRunning),
UP = q.UP, DOWN = q.DOWN, LEFT = q.LEFT, RIGHT = q.RIGHT, ACTION = q.ACTION, NOMOVE = q.NOMOVE, TURNRIGHTSHOOT = q.TURNRIGHTSHOOT, TURNLEFTSHOOT = q.TURNLEFTSHOOT, TRADER = q.levelTrader }
end
onOverlayUpdate = function(...)
local result = realUpdate(...)
ttDrive(view())
return result
end

View file

@ -0,0 +1,120 @@
-- Drives Adventures in Videoland: Rollercoaster (the original or its port) from what the game
-- shows, the same way for both. The driver file defines what it takes: a view() of the game's
-- state and videolandKey(keysym, scancode) to press a key; this file decides. The trace lines
-- it prints are what compare.sh diffs.
--
-- The policy: a name at the name prompt, any key where the game waits for one, and the
-- walkthrough's lines typed one character a frame whenever the game waits for a line; with
-- PORT_PERFECT=1 the walkthrough is the solution alone, otherwise it opens with the unknown
-- words, the inventory play, and two of the deaths (the restaurant's meal and the coaster's
-- car) before solving the game from the start again.
VIDEOLAND_FRAMES = tonumber(os.getenv("PORT_FRAMES")) or 60000
local perfect = (os.getenv("PORT_PERFECT") == "1")
-- The solution: the coins, the bear from the gallery, the uniform from the closet, the toolkit,
-- the book past the guard, the bear to the dancer for the ticket, the radio from the booth, the
-- batteries out of the bear, the jammer made and powered, and the tower.
local SOLUTION = { "E", "TAKE COINS", "W", "W", "S", "SHOOT", "N", "N", "E", "TAKE UNIFORM", "WEAR UNIFORM", "W", "S", "W", "TAKE TOOLKIT",
"E", "E", "S", "S", "TAKE BOOK", "READ BOOK", "N", "N", "E", "S", "YES", "S", "TAKE TICKET", "W",
"E", "E", "PLAY", "RADIO", "OPEN BEAR", "TAKE BATTERIES", "MAKE JAMMER", "PUT BATTERIES", "IN JAMMER",
"W", "W", "W", "W", "S", "S", "JAM" }
-- The detours: words the game does not know, the inventory, a meal that ends the game and a
-- yes to play again, a walk to the top of the coaster and the car, and a yes again.
local DETOURS = { "DANCE", "HELP", "I", "LOOK", "TAKE ALL", "FIND BATTERIES", "KILL SABOTEUR", "N", "MAYBE", "YES", "HUH", "YES",
"E", "TAKE COINS", "DROP COINS", "TAKE COINS", "PUT COINS", "DOWN", "TAKE COINS", "GO NORTH", "W", "W", "N",
"EXAMINE BOX", "BREAK DOOR", "VISIT DANCER", "READ TICKET", "S", "W", "N", "N", "YES" }
local lines = {}
local at = 1
local typing = nil -- The line being typed, and how much of it is in.
local typed = 0
local settle = 0 -- Frames to wait before the next line.
local frames = 0
local pressedFor = nil -- The wait for any key already answered, by what follows it.
local lastLine = nil
if not perfect then
for _, line in ipairs(DETOURS) do
lines[#lines + 1] = line
end
end
for _, line in ipairs(SOLUTION) do
lines[#lines + 1] = line
end
table.insert(lines, 1, "SCOTT")
-- The states that wait for a typed line (the game's numbers): the name, the restaurant, the
-- tent, the parser, the play-again, the prize, and the put-where.
local WAITING = { [2] = true, [10] = true, [11] = true, [15] = true, [17] = true, [20] = true, [23] = true }
function videolandTraceSeek(frame)
debugPrint(string.format("TRACE seek %d", frame))
end
-- view: { state, next, room, turns, keyboard, keyhit, delay, still, endFrame, frame, prints,
-- last (the newest line of the screen), input }
function videolandDrive(view)
frames = frames + 1
local line = string.format("TRACE state %s next %s room %s turns %s kb %s hit %s delay %s still %s end %s prints %s last %s",
tostring(view.state), tostring(view.next), tostring(view.room), tostring(view.turns), tostring(view.keyboard), tostring(view.keyhit),
tostring(view.delay), tostring(view.still), tostring(view.endFrame), tostring(view.prints), tostring(view.last))
if line ~= lastLine then
debugPrint(line .. " disc " .. tostring(view.frame))
lastLine = line
end
if frames >= VIDEOLAND_FRAMES then
debugPrint("TRACE end after " .. frames .. " frames")
singeQuit()
return
end
-- Any key where the game waits for one, once per wait (two waits follow each other at the
-- story's end, told apart by what comes next). A full stop: the keyboard is still on for
-- the frame the wait begins in, and a letter or a space pressed then would go into the line.
if view.keyhit then
if pressedFor ~= view.next then
pressedFor = view.next
videolandKey(46, SCANCODE.PERIOD.value)
end
return
end
pressedFor = nil
if typing then
-- The line in, a character a frame, then the return key.
typed = typed + 1
if typed <= string.len(typing) then
local c = typing:sub(typed, typed)
if c == " " then
videolandKey(32, SCANCODE.SPACE.value)
else
videolandKey(string.byte(string.lower(c)), SCANCODE[c].value)
end
else
videolandKey(13, SCANCODE.RETURN.value)
typing = nil
settle = 3
end
return
end
if settle > 0 then
settle = settle - 1
return
end
if os.getenv("PORT_DEBUG") == "1" and (frames % 100 == 0) then
debugPrint(string.format("DRIVE probe kb %s input [%s] next %s at %s line %s typing %s settle %s", tostring(view.keyboard), tostring(view.input), tostring(view.next), tostring(at), tostring(lines[at]), tostring(typing), tostring(settle)))
end
-- (Not while a delay runs: the keyboard is on through the bomb's, and a line typed then
-- is taken before the play-again prompt and leaves the game in no state at all.)
if view.keyboard and (view.delay == 0) and (view.input == "") and WAITING[view.next] and lines[at] then
typing = lines[at]
typed = 0
at = at + 1
debugPrint("DRIVE line " .. typing)
end
end

View file

@ -0,0 +1,58 @@
-- Adventures in Videoland: Rollercoaster as it ships, driven by videolandDrive.singe and
-- traced. PORT_GAME names the title's folder under the library and PORT_SCRIPT its script
-- under it; run with -v the title's framefile.
local here = ((debug.getinfo(1, "S").source:gsub("^@", "")):match("^(.*[/\\])") or "")
local library = os.getenv("SINGE_LIBRARY") or (os.getenv("HOME") .. "/claude/singetest")
local game = os.getenv("PORT_GAME") or "Rollercoaster"
local script = library .. "/" .. (os.getenv("PORT_SCRIPT") or (game .. "/" .. game .. ".singe"))
dofile(here .. "videolandDrive.singe")
SINGE_LEGACY_SPRITE_ARGS = true
-- The game seeds its random draws from os.time at load: pinned, as the port's is.
os.time = function(...) return 12345 end
local realScriptPath = singeGetScriptPath
singeGetScriptPath = function() return script end
local realSkip = discSkipToFrame
discSkipToFrame = function(frame)
videolandTraceSeek(frame)
return realSkip(frame)
end
local realSearch = discSearch
discSearch = function(frame)
videolandTraceSeek(frame)
return realSearch(frame)
end
dofile(script)
singeGetScriptPath = realScriptPath
-- Every line the game prints, counted (the wrapped lines too).
local prints = 0
local realPrint = screenPrint
screenPrint = function(what)
prints = prints + 1
return realPrint(what)
end
local realUpdate = onOverlayUpdate
-- A key, as the engine hands one to a script in the whole keyboard mode.
function videolandKey(keysym, scancode)
onKeyPressed(keysym, scancode)
end
local function view()
return { state = STATE, next = NEXT_STATE, room = L, turns = T, keyboard = (KEYBOARD_ON == ON), keyhit = (KEYHIT_ON == ON), delay = DELAY,
still = (SHOW_JPG ~= -1), endFrame = (END_FRAME ~= -1) and END_FRAME or nil, frame = discGetFrame(), prints = prints, last = SCREEN[SCREEN_LINES], input = INPUT }
end
onOverlayUpdate = function(...)
local result = realUpdate(...)
videolandDrive(view())
return result
end

View file

@ -0,0 +1,57 @@
-- The Forge port of Adventures in Videoland: Rollercoaster, driven and traced as the original
-- is.
local here = ((debug.getinfo(1, "S").source:gsub("^@", "")):match("^(.*[/\\])") or "")
local library = os.getenv("SINGE_LIBRARY") or (os.getenv("HOME") .. "/claude/singetest")
local game = os.getenv("PORT_GAME") or "Rollercoaster"
local name = (os.getenv("PORT_SCRIPT") or (game .. "/" .. game .. ".singe")):gsub("%.singe$", "")
local stem = name:match("([^/\\]+)$")
dofile("Forge/AuthorCompile.singe")
dofile(here .. "videolandDrive.singe")
-- The game seeds its random draws from os.time at load; the original's driver pins it, and so
-- does this, to the same number.
os.time = function(...) return 12345 end
local realSkip = discSkipToFrame
discSkipToFrame = function(frame)
videolandTraceSeek(frame)
return realSkip(frame)
end
local realSearch = discSearch
discSearch = function(frame)
videolandTraceSeek(frame)
return realSearch(frame)
end
dofile(authorBuild(library .. "/" .. name .. ".forge", singeGetDataPath() .. stem .. ".port.singe"))
local realUpdate = onOverlayUpdate
-- A key, as the engine hands one to the runtime in the whole keyboard mode: its keysym on the
-- switch channel, then the key itself. The return and backspace keys' keysyms are switch
-- numbers too, and the runtime drops those while the key is down, which a driven run cannot
-- show it; they go by the key alone, as they arrive in play.
function videolandKey(keysym, scancode)
if (keysym ~= 13) and (keysym ~= 8) then
authorSwitchDown(keysym, nil)
end
authorKeyDown(keysym, scancode)
end
local function view()
local q = authorEntity("game").q
return { state = q.state, next = q.next, room = q.L, turns = q.T, keyboard = q.keyboard, keyhit = q.keyhit, delay = q.delay,
still = (q.still ~= nil), endFrame = q.endFrame, frame = discGetFrame(), prints = q.prints, last = q.screen[q.lines], input = q.input }
end
onOverlayUpdate = function(...)
local result = realUpdate(...)
videolandDrive(view())
return result
end

View file

@ -15,10 +15,10 @@ fontQuality(FONT_QUALITY_BLENDED)
overlaySetResolution(720, 480) overlaySetResolution(720, 480)
for _, name in ipairs({ "platformer", "qte", "shmup", "gunvideo" }) do for _, name in ipairs({ "platformer", "qte", "shmup", "gunvideo" }) do
local game = authorLoad("testScripts/author/" .. name .. ".game") local game = authorLoad("testScripts/author/" .. name .. ".forge")
local problems = authorCheck(game) local problems = authorCheck(game)
built[name] = authorBuild("testScripts/author/" .. name .. ".game", out .. name .. ".singe") built[name] = authorBuild("testScripts/author/" .. name .. ".forge", out .. name .. ".singe")
debugPrint("AUTHOR built " .. built[name] .. " with " .. #problems .. " problems") debugPrint("AUTHOR built " .. built[name] .. " with " .. #problems .. " problems")
for _, problem in ipairs(problems) do for _, problem in ipairs(problems) do
debugPrint("AUTHOR FAIL " .. name .. ": " .. problem) debugPrint("AUTHOR FAIL " .. name .. ": " .. problem)

View file

@ -10,7 +10,7 @@ local font = fontLoad("Singe/FreeSansBold.ttf", 18)
fontSelect(font) fontSelect(font)
fontQuality(FONT_QUALITY_BLENDED) fontQuality(FONT_QUALITY_BLENDED)
dofile(authorBuild("testScripts/author/qte.game", singeGetDataPath() .. "qte.singe")) dofile(authorBuild("testScripts/author/qte.forge", singeGetDataPath() .. "qte.singe"))
local gameUpdate = onOverlayUpdate local gameUpdate = onOverlayUpdate
local frames = 0 local frames = 0

View file

@ -12,7 +12,7 @@ dofile("Forge/Forge.singe")
local font = fontLoad("Singe/FreeSansBold.ttf", 16) local font = fontLoad("Singe/FreeSansBold.ttf", 16)
local out = singeGetDataPath() local out = singeGetDataPath()
local work = out .. "edited.game" local work = out .. "edited.forge"
fontSelect(font) fontSelect(font)
fontQuality(FONT_QUALITY_BLENDED) fontQuality(FONT_QUALITY_BLENDED)
@ -20,7 +20,7 @@ overlaySetResolution(720, 480)
-- Work on a copy; an editor that writes back to the file it was handed is right, but a test that -- Work on a copy; an editor that writes back to the file it was handed is right, but a test that
-- edits the repository's own sample is not. -- edits the repository's own sample is not.
local source = assert(io.open("testScripts/author/platformer.game", "r")) local source = assert(io.open("testScripts/author/platformer.forge", "r"))
local copy = assert(io.open(work, "w")) local copy = assert(io.open(work, "w"))
copy:write(source:read("a")) copy:write(source:read("a"))
@ -94,8 +94,8 @@ local function step()
-- The invariant the whole editor rests on: a description that has been through load and -- The invariant the whole editor rests on: a description that has been through load and
-- save has to compile to the same game as the one that has not. Without it, opening a -- save has to compile to the same game as the one that has not. Without it, opening a
-- game in the editor and saving it unchanged could quietly alter it. -- game in the editor and saving it unchanged could quietly alter it.
local original = authorLoad("testScripts/author/platformer.game") local original = authorLoad("testScripts/author/platformer.forge")
local trip = out .. "trip.game" local trip = out .. "trip.forge"
authorSave(original, trip) authorSave(original, trip)
local again = authorLoad(trip) local again = authorLoad(trip)

View file

@ -10,13 +10,13 @@ dofile("Forge/Forge.singe")
local font = fontLoad("Singe/FreeSansBold.ttf", 15) local font = fontLoad("Singe/FreeSansBold.ttf", 15)
local out = singeGetDataPath() local out = singeGetDataPath()
local work = out .. "rules.game" local work = out .. "rules.forge"
fontSelect(font) fontSelect(font)
fontQuality(FONT_QUALITY_BLENDED) fontQuality(FONT_QUALITY_BLENDED)
overlaySetResolution(720, 480) overlaySetResolution(720, 480)
local source = assert(io.open("testScripts/author/platformer.game", "r")) local source = assert(io.open("testScripts/author/platformer.forge", "r"))
local copy = assert(io.open(work, "w")) local copy = assert(io.open(work, "w"))
copy:write(source:read("a")) copy:write(source:read("a"))

View file

@ -9,14 +9,14 @@ dofile("Forge/Forge.singe")
local font = fontLoad("Singe/FreeSansBold.ttf", 15) local font = fontLoad("Singe/FreeSansBold.ttf", 15)
local out = singeGetDataPath() local out = singeGetDataPath()
local work = out .. "release.game" local work = out .. "release.forge"
local dest = out .. "Released" local dest = out .. "Released"
fontSelect(font) fontSelect(font)
fontQuality(FONT_QUALITY_BLENDED) fontQuality(FONT_QUALITY_BLENDED)
overlaySetResolution(720, 480) overlaySetResolution(720, 480)
local source = assert(io.open("testScripts/author/platformer.game", "r")) local source = assert(io.open("testScripts/author/platformer.forge", "r"))
local copy = assert(io.open(work, "w")) local copy = assert(io.open(work, "w"))
copy:write(source:read("a")) copy:write(source:read("a"))
@ -40,7 +40,7 @@ local function step()
elseif stage == 2 then elseif stage == 2 then
-- Everything a standalone game needs has to be there. -- Everything a standalone game needs has to be there.
for _, name in ipairs({ "Platformer.singe", "Author.singe", "games.dat", "Platformer.game" }) do for _, name in ipairs({ "Platformer.singe", "Author.singe", "games.dat", "Platformer.forge" }) do
local f = io.open(dest .. "/" .. name, "r") local f = io.open(dest .. "/" .. name, "r")
if f == nil then if f == nil then

View file

@ -9,7 +9,7 @@ fontSelect(font)
fontQuality(FONT_QUALITY_BLENDED) fontQuality(FONT_QUALITY_BLENDED)
overlaySetResolution(720, 480) overlaySetResolution(720, 480)
dofile(authorBuild("testScripts/author/shmup.game", singeGetDataPath() .. "shmup.singe")) dofile(authorBuild("testScripts/author/shmup.forge", singeGetDataPath() .. "shmup.singe"))
local gameUpdate = onOverlayUpdate local gameUpdate = onOverlayUpdate
local frames = 0 local frames = 0

View file

@ -9,7 +9,7 @@ local font = fontLoad("Singe/FreeSansBold.ttf", 18)
fontSelect(font) fontSelect(font)
fontQuality(FONT_QUALITY_BLENDED) fontQuality(FONT_QUALITY_BLENDED)
dofile(authorBuild("testScripts/author/gunvideo.game", singeGetDataPath() .. "gunvideo.singe")) dofile(authorBuild("testScripts/author/gunvideo.forge", singeGetDataPath() .. "gunvideo.singe"))
local gameUpdate = onOverlayUpdate local gameUpdate = onOverlayUpdate
local frames = 0 local frames = 0

View file

@ -7,13 +7,13 @@ dofile("Forge/Forge.singe")
local font = fontLoad("Singe/FreeSansBold.ttf", 15) local font = fontLoad("Singe/FreeSansBold.ttf", 15)
local out = singeGetDataPath() local out = singeGetDataPath()
local work = out .. "timeline.game" local work = out .. "timeline.forge"
fontSelect(font) fontSelect(font)
fontQuality(FONT_QUALITY_BLENDED) fontQuality(FONT_QUALITY_BLENDED)
overlaySetResolution(720, 480) overlaySetResolution(720, 480)
local source = assert(io.open("testScripts/author/gunvideo.game", "r")) local source = assert(io.open("testScripts/author/gunvideo.forge", "r"))
local copy = assert(io.open(work, "w")) local copy = assert(io.open(work, "w"))
copy:write(source:read("a")) copy:write(source:read("a"))

View file

@ -9,7 +9,7 @@ local font = fontLoad("Singe/FreeSansBold.ttf", 18)
fontSelect(font) fontSelect(font)
fontQuality(FONT_QUALITY_BLENDED) fontQuality(FONT_QUALITY_BLENDED)
dofile(authorBuild("testScripts/author/railshooter.game", singeGetDataPath() .. "railshooter.singe")) dofile(authorBuild("testScripts/author/railshooter.forge", singeGetDataPath() .. "railshooter.singe"))
local gameUpdate = onOverlayUpdate local gameUpdate = onOverlayUpdate
local frames = 0 local frames = 0

Some files were not shown because too many files have changed in this diff Show more