--[[ * * Singe 3 * Copyright (C) 2006-2026 Scott Duensing * * 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...--. 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, }