singe/zbstudio/packages/singe.lua

627 lines
23 KiB
Lua

--[[
*
* Singe 3
* Copyright (C) 2006-2026 Scott Duensing <scott@kangaroopunch.com>
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License
* as published by the Free Software Foundation; either version 3
* of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
* 02110-1301, USA.
*
*
--]]
-- Singe for ZeroBrane Studio. Adds Singe as an interpreter, so Project > Run (F6) and Project >
-- Start Debugging (F5) launch the game, with breakpoints, stepping, the Stack and Watch windows, and
-- a stop in the IDE when an error is about to end the game; autocompletion and tooltips for the whole
-- Singe API, in ZeroBrane Studio's language when the Reference is kept in it; .singe files edited as
-- Lua, .rml as XML and .rcss as CSS; a toolbar button; and a way into the Reference from the Help
-- menu and from the word under the mouse.
--
-- Install by adding one line to ZeroBrane Studio's user.lua (Edit > Preferences > Settings: User),
-- pointing at the copy Singe unpacks into its own folder, which stays current as Singe is updated:
--
-- package "/path/to/Singe/Singe/ZeroBrane/singe.lua"
--
-- or by copying singe.lua and the singe folder beside it into ZeroBrane Studio's packages folder.
--
-- Settings, all optional, also in user.lua:
--
-- path.singe = "/path/to/Singe-v3.00-Linux-x86_64" -- otherwise found, or asked for once
-- singe = { reload = false } -- do not pass --reload
-- singe = { reference = "/path/to/Reference.html" } -- otherwise found beside Singe
local MENU_CHOOSE = ID("singe.choose")
local MENU_DEBUG = ID("singe.debug")
local MENU_LOOKUP = ID("singe.lookup")
local MENU_REFERENCE = ID("singe.reference")
local MENU_RELOAD = ID("singe.reload")
local INTERPRETER = "singe"
local STOP_FILE = "Debugger.singe" -- Where Singe stops when an error is about to end the game
local LOOPBACK = "127.0.0.1"
-- What each platform's Singe is called: Singe-v3.00b1-Linux-x86_64, Singe-v3.00-Windows-x86_64.exe.
local PLATFORM = {
Windows = { name = "Windows", suffix = "%.exe$" },
Unix = { name = "Linux", suffix = "$" },
Macintosh = { name = "Macos", suffix = "$" },
}
-- ZeroBrane Studio's own word for a Linux machine's architecture, from uname, as Singe spells it.
local ARCHITECTURE = { x64 = "x86_64", x86 = "x86", aarch64 = "aarch64", armhf = "armhf" }
-- The package's own words, in ZeroBrane Studio's language when the Reference is kept in it.
-- Written in decimal escapes, so this file stays ASCII: \195\169 is a UTF-8 e acute.
local WORDS = {
en = {
debug = "Debug With Singe",
choose = "Choose Singe Executable...",
reload = "Reload Singe When a Script Changes",
reference = "Singe Reference",
lookUp = "Look Up in Singe Reference",
chooseTitle = "Choose the Singe executable",
noReference = "The Singe Reference was not found. Unpack the documentation download (Singe-Docs) beside Singe, or set singe = { reference = \"/path/to/Reference.html\" } in user.lua.",
stopped = "Singe stopped at an error: %s",
stoppedHelp = "The Stack window shows where it happened; Continue (F5) lets the game end as it would have.",
nothingRun = "Singe: no executable chosen, so nothing was run.",
using = "Singe: using %s.",
},
fr = {
debug = "D\195\169boguer avec Singe",
choose = "Choisir l'ex\195\169cutable de Singe...",
reload = "Relancer Singe quand un script change",
reference = "R\195\169f\195\169rence de Singe",
lookUp = "Chercher dans la R\195\169f\195\169rence de Singe",
chooseTitle = "Choisissez l'ex\195\169cutable de Singe",
noReference = "La R\195\169f\195\169rence de Singe est introuvable. D\195\169compressez le t\195\169l\195\169chargement de la documentation (Singe-Docs) \195\160 c\195\180t\195\169 de Singe, ou indiquez singe = { reference = \"/chemin/vers/Reference.html\" } dans user.lua.",
stopped = "Singe s'est arr\195\170t\195\169 sur une erreur : %s",
stoppedHelp = "La fen\195\170tre Pile d'ex\195\169cution montre o\195\185 elle s'est produite ; Continuer (F5) laisse le jeu se terminer comme il l'aurait fait.",
nothingRun = "Singe : aucun ex\195\169cutable choisi, rien n'a \195\169t\195\169 lanc\195\169.",
using = "Singe : utilise %s.",
},
it = {
debug = "Debug con Singe",
choose = "Scegli l'eseguibile di Singe...",
reload = "Ricarica Singe quando cambia uno script",
reference = "Riferimento di Singe",
lookUp = "Cerca nel Riferimento di Singe",
chooseTitle = "Scegli l'eseguibile di Singe",
noReference = "Il Riferimento di Singe non \195\168 stato trovato. Decomprimi il download della documentazione (Singe-Docs) accanto a Singe, oppure imposta singe = { reference = \"/percorso/di/Reference.html\" } in user.lua.",
stopped = "Singe si \195\168 fermato su un errore: %s",
stoppedHelp = "La finestra Stack mostra dove \195\168 successo; Continua (F5) lascia che il gioco finisca come avrebbe fatto.",
nothingRun = "Singe: nessun eseguibile scelto, quindi non \195\168 stato avviato nulla.",
using = "Singe: uso %s.",
},
de = {
debug = "Mit Singe debuggen",
choose = "Singe-Programm w\195\164hlen...",
reload = "Singe neu laden, wenn sich ein Skript \195\164ndert",
reference = "Singe-Referenz",
lookUp = "In der Singe-Referenz nachschlagen",
chooseTitle = "Das Singe-Programm w\195\164hlen",
noReference = "Die Singe-Referenz wurde nicht gefunden. Entpacke den Dokumentations-Download (Singe-Docs) neben Singe, oder setze singe = { reference = \"/pfad/zu/Reference.html\" } in user.lua.",
stopped = "Singe hat bei einem Fehler angehalten: %s",
stoppedHelp = "Das Fenster Stack zeigt, wo er passiert ist; Fortsetzen (F5) l\195\164sst das Spiel enden, wie es geendet h\195\164tte.",
nothingRun = "Singe: kein Programm gew\195\164hlt, daher wurde nichts gestartet.",
using = "Singe: verwende %s.",
},
es = {
debug = "Depurar con Singe",
choose = "Elegir el ejecutable de Singe...",
reload = "Recargar Singe cuando cambie un script",
reference = "Referencia de Singe",
lookUp = "Buscar en la Referencia de Singe",
chooseTitle = "Elige el ejecutable de Singe",
noReference = "No se encontr\195\179 la Referencia de Singe. Descomprime la descarga de la documentaci\195\179n (Singe-Docs) junto a Singe, o pon singe = { reference = \"/ruta/a/Reference.html\" } en user.lua.",
stopped = "Singe se detuvo en un error: %s",
stoppedHelp = "La ventana Pila muestra d\195\179nde ocurri\195\179; Continuar (F5) deja que el juego termine como lo habr\195\173a hecho.",
nothingRun = "Singe: no se eligi\195\179 ning\195\186n ejecutable, as\195\173 que no se ejecut\195\179 nada.",
using = "Singe: se usa %s.",
},
}
-- The sizes of the dragon Singe unpacks beside this file, smallest first.
local ICON_SIZES = { 16, 24, 32, 48 }
local api = {} -- The loaded API, which also says where Look Up opens the Reference
local apiTable = nil
local architectures = nil
local findSinge = nil
local gameScript = nil
local isStopFile = nil
local language = nil
local lookUp = nil
local lookUpPosition = nil -- Where the editor's context menu was opened
local openReference = nil
local packageDir = nil
local parentFolder = nil
local plugin = nil
local referencePage = nil
local reloading = nil
local saveSetting = nil
local singeFolder = nil
local singeVersion = nil
local specmapOld = {}
local subfolders = nil
local toolbarBitmap = nil
local withSeparator = nil
local word = nil
-- The API for ZeroBrane Studio's language, or English: api-fr.lua beside api.lua, and so on.
function apiTable()
local folder = packageDir() .. "singe/"
local file = folder .. "api-" .. language() .. ".lua"
local chunk = nil
if (language() == "en") or not wx.wxFileExists(file) then
file = folder .. "api.lua"
end
chunk = loadfile(file)
if not chunk then
ide:Print(("Singe: could not read the API file %s."):format(file))
return {}
end
return chunk()
end
-- The builds of Singe this machine runs, its own first: Singe-v...-<OS>-<this>. ZeroBrane Studio's
-- ide.osarch is no help outside Linux: on Windows it holds the WINDIR path, and on a Mac it says x64
-- on Apple silicon too, since ZeroBrane Studio is itself an Intel program there. Windows 11 on ARM
-- and Rosetta both run the x86_64 build when there is no native one.
function architectures()
local cpu = ""
local probe = nil
if ide.osname == "Windows" then
-- ZeroBrane Studio is a 32-bit program: the machine's own architecture is in
-- PROCESSOR_ARCHITEW6432, and PROCESSOR_ARCHITECTURE is its own.
cpu = (os.getenv("PROCESSOR_ARCHITEW6432") or os.getenv("PROCESSOR_ARCHITECTURE") or ""):upper()
return (cpu == "ARM64") and { "aarch64", "x86_64" } or { "x86_64" }
end
if ide.osname == "Macintosh" then
-- Answers 1 on Apple silicon even to a program running under Rosetta.
probe = io.popen("sysctl -n hw.optional.arm64 2>/dev/null")
if probe then
cpu = probe:read("*l") or ""
probe:close()
end
return (cpu == "1") and { "aarch64", "x86_64" } or { "x86_64" }
end
return { ARCHITECTURE[ide.osarch] or "x86_64" }
end
-- Singe's executable: path.singe if set, then the one chosen before, then the newest for this
-- machine in the project's folder or the folders above it, then on the PATH. With ask, a missing
-- one is asked for and remembered.
function findSinge(ask)
local platform = PLATFORM[ide.osname] or PLATFORM.Unix
local kinds = architectures()
local settings = plugin:GetSettings() or {}
local folder = ide:GetProject()
local chosen = nil
if ide.config.path.singe and wx.wxFileExists(ide.config.path.singe) then
return ide.config.path.singe
end
if settings.singe and wx.wxFileExists(settings.singe) then
return settings.singe
end
for _ = 1, 4 do
if not folder then
break
end
-- The machine's own build first, newest of it; one it runs under emulation only failing that.
local files = ide:GetFileList(folder, false, "Singe*")
for _, kind in ipairs(kinds) do
local wanted = "^Singe%-v[%d%.]+b?%d*%-" .. platform.name .. "%-" .. kind .. platform.suffix
local best = nil
local bestKey = -1
for _, file in ipairs(files) do
local name = file:match("[^/\\]+$")
if name:match(wanted) and (singeVersion(name) > bestKey) then
best = file
bestKey = singeVersion(name)
end
end
if best then
return best
end
end
folder = parentFolder(folder)
end
for directory in (os.getenv("PATH") or ""):gmatch("[^" .. ((ide.osname == "Windows") and ";" or ":") .. "]+") do
for _, name in ipairs({ "Singe", "singe", "Singe.exe" }) do
if wx.wxFileExists(withSeparator(directory) .. name) then
return withSeparator(directory) .. name
end
end
end
if not ask then
return nil
end
chosen = wx.wxFileSelector(word("chooseTitle"), ide:GetProject() or "", "", "", "*", wx.wxFD_OPEN + wx.wxFD_FILE_MUST_EXIST, ide:GetMainFrame())
if chosen and (chosen ~= "") then
saveSetting("singe", chosen)
return chosen
end
return nil
end
-- The script to run. A .singe file is a game; anything else is one of its modules, so the
-- project's only .singe file runs instead, when it has exactly one.
function gameScript(wfilename)
local file = wfilename:GetFullPath()
local project = ide:GetProject()
local scripts = nil
if file:lower():match("%.singe$") or not project then
return file
end
scripts = ide:GetFileList(project, false, "*.singe")
if #scripts == 1 then
return scripts[1]
end
return file
end
function isStopFile(file)
return (file ~= nil) and (file:gsub("\\", "/"):match("Singe/" .. STOP_FILE:gsub("%.", "%%.") .. "$") ~= nil)
end
-- ZeroBrane Studio's language as two letters, English when it has none set.
function language()
local code = tostring(ide.config.language or ""):lower():sub(1, 2)
return (code ~= "") and code or "en"
end
-- The Reference at the word under the mouse: every entry's anchor is its name in lower case.
function lookUp(editor, position)
local first = editor:WordStartPosition(position, true)
local last = editor:WordEndPosition(position, true)
local word = editor:GetTextRange(first, last)
if word:match("^[%a_][%w_]*$") then
openReference((api[word] and api[word].anchor) or word:lower())
else
openReference()
end
end
function openReference(anchor)
local page = referencePage()
if not page then
wx.wxMessageBox(word("noReference"), word("reference"), wx.wxICON_INFORMATION + wx.wxOK + wx.wxCENTRE, ide:GetMainFrame())
return
end
wx.wxLaunchDefaultBrowser("file://" .. page:gsub("\\", "/"):gsub("^([^/])", "/%1") .. (anchor and ("#" .. anchor) or ""), 0)
end
-- This file's folder, wherever ZeroBrane Studio loaded it from.
function packageDir()
return GetPathWithSep(plugin:GetFilePath())
end
-- The folder above, normalized, or nil at the top.
function parentFolder(folder)
local name = wx.wxFileName.DirName(folder)
name:Normalize()
if name:GetDirCount() == 0 then
return nil
end
name:RemoveLastDir()
return name:GetPath(wx.wxPATH_GET_VOLUME)
end
-- Reference.html: the configured one, or the newest documentation download beside Singe, in
-- ZeroBrane Studio's language when there is one in it.
function referencePage()
local configured = (plugin:GetConfig() or {}).reference
local folder = singeFolder()
local best = nil
local bestKey = -1
if configured and wx.wxFileExists(configured) then
return configured
end
if not folder then
return nil
end
for _, suffix in ipairs({ "-" .. language(), "" }) do
for _, directory in ipairs(subfolders(folder, "Singe-Docs-v*")) do
local name = directory:match("[^/\\]+$")
local page = withSeparator(directory) .. "Reference.html"
if (name:match("^Singe%-Docs%-v[%d%.]+b?%d*" .. suffix:gsub("%-", "%%-") .. "$")) and wx.wxFileExists(page) and (singeVersion(name) > bestKey) then
best = page
bestKey = singeVersion(name)
end
end
if best then
return best
end
end
if wx.wxFileExists(folder .. "Reference.html") then
return folder .. "Reference.html"
end
return nil
end
function reloading()
local config = plugin:GetConfig() or {}
local settings = plugin:GetSettings() or {}
if config.reload ~= nil then
return config.reload
end
return settings.reload ~= false
end
function saveSetting(key, value)
local settings = plugin:GetSettings() or {}
settings[key] = value
plugin:SetSettings(settings)
end
-- The folder Singe runs in: the one holding the executable, where its Singe folder and games are.
function singeFolder()
local singe = findSinge(false)
return singe and wx.wxFileName(singe):GetPath(wx.wxPATH_GET_VOLUME + wx.wxPATH_GET_SEPARATOR) or nil
end
-- A number that sorts versions the way Singe does: v3.00 above v3.00b2 above v3.00b1.
function singeVersion(name)
local major, minor, beta = name:match("v(%d+)%.(%d+)b?(%d*)")
if not major then
return 0
end
return tonumber(major) * 1000000 + tonumber(minor) * 1000 + ((beta ~= "") and tonumber(beta) or 999)
end
-- The folders directly inside folder whose names match the wildcard mask, as full paths.
function subfolders(folder, mask)
local found = {}
local dir = wx.wxDir(folder)
local more = false
local name = nil
if not dir:IsOpened() then
return found
end
more, name = dir:GetFirst(mask, wx.wxDIR_DIRS)
while more do
found[#found + 1] = withSeparator(folder) .. name
more, name = dir:GetNext()
end
return found
end
-- A folder with its separator on the end: ZeroBrane Studio's GetPathWithSep takes a bare folder
-- for a file and answers its parent.
function withSeparator(folder)
if folder:match("[/\\]$") then
return folder
end
return folder .. GetPathSeparator()
end
-- The dragon at the toolbar's size: from beside this file, or from Singe's own folder when the
-- package was loaded from somewhere else, the nearest size up scaled to fit when none is exact. A
-- stock picture stands in when neither has it.
function toolbarBitmap(size)
local folders = { packageDir() .. "singe/" }
local folder = singeFolder()
if folder then
folders[#folders + 1] = folder .. "Singe/ZeroBrane/singe/"
end
for _, directory in ipairs(folders) do
local found = nil
for _, have in ipairs(ICON_SIZES) do
local file = directory .. "icon-" .. have .. ".png"
if wx.wxFileExists(file) then
found = { file = file, size = have }
if have >= size then
break
end
end
end
if found then
local image = wx.wxImage(found.file, wx.wxBITMAP_TYPE_PNG)
if found.size ~= size then
image:Rescale(size, size, wx.wxIMAGE_QUALITY_HIGH)
end
return wx.wxBitmap(image)
end
end
return wx.wxArtProvider.GetBitmap(wx.wxART_EXECUTABLE_FILE, wx.wxART_TOOLBAR, wx.wxSize(size, size))
end
-- One of the package's own words, in ZeroBrane Studio's language or English.
function word(key)
return (WORDS[language()] or WORDS.en)[key] or WORDS.en[key]
end
local interpreter = {
name = "Singe",
description = "Singe, the engine for laserdisc and video games",
api = { "baselib", "singe" },
luaversion = "5.4",
fname = INTERPRETER,
hasdebugger = true,
skipcompile = true, -- ZeroBrane Studio's own Lua is older than Singe's 5.4
takeparameters = true,
unhideanywindow = true,
fexepath = function(self) return findSinge(false) end,
fworkdir = function(self, wfilename) return singeFolder() or self:fprojdir(wfilename) end,
frun = function(self, wfilename, rundebug)
local singe = findSinge(true)
local script = gameScript(wfilename)
local params = self:GetCommandLineArg()
local folder = nil
local cmd = nil
local debug = ""
if not singe then
ide:Print(word("nothingRun"))
return
end
folder = wx.wxFileName(singe):GetPath(wx.wxPATH_GET_VOLUME + wx.wxPATH_GET_SEPARATOR)
if rundebug then
local debugger = ide:GetDebugger()
-- Names are reported relative to where Singe runs, so breakpoints are matched from there.
debugger:SetOptions({ runstart = ide.config.debugger.runonstart ~= false, basedir = folder })
debug = (" --debug=%s:%d"):format(ide.config.debugger.hostname or LOOPBACK, debugger:GetPortNumber())
end
cmd = ('"%s"%s%s%s "%s"'):format(singe, debug, reloading() and " --reload" or "", (params and (#params > 0)) and (" " .. params) or "", script)
return CommandLineRun(cmd, folder, true, true)
end,
}
return {
name = "Singe",
description = "Runs and debugs Singe games, with autocompletion and tooltips for the Singe API.",
author = "Scott Duensing",
version = 2.0,
dependencies = "1.70",
onRegister = function(self)
local project = ide:FindTopMenu("&Project")
local help = ide:FindTopMenu("&Help")
local frame = ide:GetMainFrame()
local toolbar = ide:GetToolBar()
local specmap = ide.config.editor.specmap
plugin = self
api = apiTable()
ide:AddAPI("lua", "singe", api)
ide:AddInterpreter(INTERPRETER, interpreter)
for extension, spec in pairs({ singe = "lua", rml = "xml", rcss = "css" }) do
specmapOld[extension] = specmap[extension]
specmap[extension] = specmap[extension] or spec
end
project:AppendSeparator()
project:Append(MENU_DEBUG, word("debug"))
project:Append(MENU_CHOOSE, word("choose"))
project:AppendCheckItem(MENU_RELOAD, word("reload"))
if help then
help:Append(MENU_REFERENCE, word("reference"))
end
frame:Connect(MENU_DEBUG, wx.wxEVT_COMMAND_MENU_SELECTED, function()
if ide:GetInterpreter() ~= ide:GetInterpreter(INTERPRETER) then
ide:SetInterpreter(INTERPRETER)
end
ProjectDebug()
end)
frame:Connect(MENU_CHOOSE, wx.wxEVT_COMMAND_MENU_SELECTED, function()
local chosen = wx.wxFileSelector(word("chooseTitle"), singeFolder() or "", "", "", "*", wx.wxFD_OPEN + wx.wxFD_FILE_MUST_EXIST, frame)
if chosen and (chosen ~= "") then
saveSetting("singe", chosen)
ide:Print(word("using"):format(chosen))
end
end)
frame:Connect(MENU_RELOAD, wx.wxEVT_COMMAND_MENU_SELECTED, function(event)
saveSetting("reload", event:IsChecked())
end)
frame:Connect(MENU_RELOAD, wx.wxEVT_UPDATE_UI, function(event)
event:Check(reloading())
end)
frame:Connect(MENU_REFERENCE, wx.wxEVT_COMMAND_MENU_SELECTED, function()
openReference()
end)
frame:Connect(MENU_LOOKUP, wx.wxEVT_COMMAND_MENU_SELECTED, function()
local editor = ide:GetEditor()
if editor then
lookUp(editor, lookUpPosition or editor:GetCurrentPos())
end
end)
toolbar:AddTool(MENU_DEBUG, "Singe", toolbarBitmap(toolbar:GetToolBitmapSize():GetWidth()), word("debug"))
toolbar:Realize()
end,
onUnRegister = function(self)
local toolbar = ide:GetToolBar()
local specmap = ide.config.editor.specmap
toolbar:DeleteTool(MENU_DEBUG)
toolbar:Realize()
for _, item in ipairs({ MENU_DEBUG, MENU_CHOOSE, MENU_RELOAD, MENU_REFERENCE }) do
ide:RemoveMenuItem(item)
end
for extension, spec in pairs(specmapOld) do
specmap[extension] = spec
end
ide:RemoveInterpreter(INTERPRETER)
ide:RemoveAPI("lua", "singe")
end,
-- The word under the mouse, looked up in the Reference; the caret stays where it was.
onMenuEditor = function(self, menu, editor, event)
local point = editor:ScreenToClient(event:GetPosition())
local position = editor:PositionFromPoint(point)
lookUpPosition = ((point:GetX() >= 0) and (point:GetY() >= 0)) and position or nil
menu:AppendSeparator()
menu:Append(MENU_LOOKUP, word("lookUp"))
end,
-- Singe stops in its own Debugger.singe when an error is about to end the game. Opened here, so
-- the IDE shows the stop rather than stepping out of a file it has not got open.
onDebuggerPreActivate = function(self, debugger, file, line)
-- Not opened here: this runs inside the debugger's coroutine, where an editor cannot be made.
-- ZeroBrane Studio opens a pending activation on its next update, still stopped meanwhile.
if isStopFile(file) and wx.wxFileExists(file) and not ide:FindDocument(file) then
debugger.activate = { file, line }
return false
end
end,
-- And says what the error was, which Debugger.singe holds as "message".
onDebuggerActivate = function(self, debugger, file, line)
if not isStopFile(file) then
return
end
debugger:EvalAsync("message", function(value)
local chunk = (loadstring or load)("return " .. tostring(value))
local ok, text = pcall(chunk or function() return value end)
ide:Print(word("stopped"):format(ok and tostring(text) or tostring(value)))
ide:Print(word("stoppedHelp"))
end)
end,
}