From 77400b1d788e8be046567672082a34e90d70ff8f Mon Sep 17 00:00:00 2001 From: redsti Date: Sat, 22 Aug 2026 13:56:35 +0100 Subject: [PATCH 1/2] restart using love.event.restart --- bbp/utils.lua | 5 +++++ lovely/restart-game.toml | 32 -------------------------------- states/Mods.lua | 6 +++--- 3 files changed, 8 insertions(+), 35 deletions(-) delete mode 100644 lovely/restart-game.toml diff --git a/bbp/utils.lua b/bbp/utils.lua index ac40502..35eb60f 100644 --- a/bbp/utils.lua +++ b/bbp/utils.lua @@ -208,4 +208,9 @@ function utils.setRestartRequired() cs._restartRequired = true end +function utils.restart() + lovely.reload_patches() + love.event.restart() +end + return utils diff --git a/lovely/restart-game.toml b/lovely/restart-game.toml deleted file mode 100644 index 2f09c9a..0000000 --- a/lovely/restart-game.toml +++ /dev/null @@ -1,32 +0,0 @@ -[manifest] -version = "1.0.0" -dump_lua = true -priority = 2147483647 - -[[patches]] -[patches.pattern] -target = "main.lua" -pattern = """ -function love.update(d) -""" -position = "after" -payload = ''' -if BBP_doRestart then - local launchArgs = table.concat(arg, " ") - local osName = love.system.getOS() - local command - - if osName == "Windows" then - command = "start beatblock.exe " .. launchArgs - elseif osName == "OS X" then - command = "open beatblock.app " .. launchArgs .. " &" - else -- assume Linux - command = "./beatblock " .. launchArgs .. " &" - end - - love.window.close() - os.execute(command) - love.event.quit() -end -''' -match_indent = true diff --git a/states/Mods.lua b/states/Mods.lua index 54cde9d..538ce9a 100644 --- a/states/Mods.lua +++ b/states/Mods.lua @@ -481,7 +481,7 @@ st:setFgDraw(function(self) imgui.Separator() if imgui.Button("Yes") then - BBP_doRestart = true + bbp.utils.restart() end imgui.SameLine() @@ -503,7 +503,7 @@ st:setFgDraw(function(self) imgui.Separator() if imgui.Button("Yes, restart now") then - BBP_doRestart = true + bbp.utils.restart() end imgui.SameLine() @@ -525,7 +525,7 @@ st:setFgDraw(function(self) imgui.Separator() if imgui.Button("Yes, restart now") then - BBP_doRestart = true + bbp.utils.restart() end imgui.SameLine() From 1553b7f74e8d8af07cfcae8fd643e088c167a3d4 Mon Sep 17 00:00:00 2001 From: redsti Date: Tue, 25 Aug 2026 14:50:28 +0100 Subject: [PATCH 2/2] basic dependency and incompatibility checking --- README.md | 2 +- bbp/loader.lua | 318 ++++++++++++++++++++++++++++++++++-------------- bbp/utils.lua | 5 + states/Mods.lua | 39 +++++- 4 files changed, 270 insertions(+), 94 deletions(-) diff --git a/README.md b/README.md index c6d00c7..f2d285d 100644 --- a/README.md +++ b/README.md @@ -5,4 +5,4 @@ Beatblock Plus offers an in-game mod menu for players and modding utilities for Please see the [wiki](https://beatblocktools.github.io/docs/intro) for installation and mod development guides. ## Discord Community -Join our [discord community](https://discord.gg/nqz73zSqNe) if you want to get support, browse mods and guides, and share your own mods! +Join our [discord community](https://discord.gg/nqz73zSqNe) if you want to get support, browse mods and guides, and share your own mods! diff --git a/bbp/loader.lua b/bbp/loader.lua index 9fcad40..ed85fe1 100644 --- a/bbp/loader.lua +++ b/bbp/loader.lua @@ -146,116 +146,233 @@ function loader.deleteOldLogs() log("took "..duration.." seconds to delete "..deletedCount.." old log files", "BBP_silent") end -function loader.loadMods() -- loads mod data, assets, mod icons etc. - loader.mods = {} - loader.activeMods = {} - - -- TODO: remove this later due to deprecation - mods = loader.mods +local function loadUtilitoolsMetadata(modDir) + if not love.filesystem.getInfo(modDir .. "/utilitools.json", "file") then + return {} + end + local utilitoolsJson = dpf.loadJson(modDir .. "/utilitools.json") + local modUtilitools = { + depends = {}, + conflicts = {}, + } + + for to,from in pairs({ + [modUtilitools.depends] = utilitoolsJson.dependencies, + [modUtilitools.conflicts] = utilitoolsJson.incompatibilities, + }) do + for id,v in pairs(from or {}) do + if not v.versions then + table.insert(to, { id = id }) + else + for _,v in ipairs(v.versions) do + if v[1] == "equalTo" then v[1] = "=" end + if v[1] == "lessThan" then v[1] = "<" end + if v[1] == "greaterThan" then v[1] = ">" end + if v[1] == "lessThanOrEqualTo" then v[1] = "<=" end + if v[1] == "moreThanOrEqualTo" then v[1] = ">=" end + if v[1] == "fromTil" then error(("BBP: '%s/utilitools.json': version specifier 'fromTil' not supported in BBP.")) end + if v[1] == "between" then error(("BBP: '%s/utilitools.json': version specifier 'between' not supported in BBP.")) end + table.insert(to, { id = id, version = v[1] .. " " .. v[2]}) + end + end + end + end + return modUtilitools +end - local modsPath = "Mods" - local success = love.filesystem.getInfo(modsPath, 'directory') +local function loadModMetadata(modDir) + if not love.filesystem.getInfo(modDir .. "/mod.json", "file") then return end + local modJson = dpf.loadJson(modDir .. "/mod.json") - if not success then - error("BBP failed to find the Mods directory.") - return + if not (modJson.depends or modJson.conflicts) then -- try utilitools.json + local modUtilitools = loadUtilitoolsMetadata(modDir) + modJson.depends = modUtilitools.depends + modJson.conflicts = modUtilitools.conflicts end - for _, modDir in ipairs(love.filesystem.getDirectoryItems(modsPath)) do - if not love.filesystem.getInfo(modsPath .. "/" .. modDir .. "/mod.json", 'file') then - goto continue + local mod = { + path = modDir, + id = assert(modJson.id, ("'%s/mod.json': missing mandatory field 'id'"):format(modDir)), + name = modJson.name or modJson.id, + author = modJson.author or "Unknown", + description = modJson.description or "", + version = modJson.version or "1.0.0", + icon = nil, + defaultConfig = modJson.config or {}, + config = helpers.copytable(modJson.config or {}), + depends = modJson.depends or {}, + conflicts = modJson.conflicts or {}, + } + setmetatable(mod, { + __index = function(t, k) + if k == "enabled" then + return t._enabled + elseif k == "configRenderer" then + return getModConfigRenderer(t) + end + return rawget(t, k) + end, + __newindex = function(t, k, v) + if k == "enabled" then + return setModEnabled(t, v) + end + error(("Attmepted to create new field '%s' on mod"):format(k)) end - - local mod = { - path = modsPath .. "/" .. modDir, - id = modDir, - name = modDir, - author = "Unknown", - description = "", - version = "1.0.0", - icon = nil, - defaultConfig = {}, - config = {} - } - setmetatable(mod, { - __index = function(t, k) - if k == "enabled" then - return t._enabled - elseif k == "configRenderer" then - return getModConfigRenderer(t) - end - return rawget(t, k) - end, - __newindex = function(t, k, v) - if k == "enabled" then - setModEnabled(t, v) - end + }) + mod.enabled = love.filesystem.getInfo(mod.path .. "/.lovelyignore", 'file') == nil + if modJson.enabled ~= nil then log("'" .. modDir .. "/mod.json': 'enabled' is deprecated in favor of the .lovelyignore file","BBP") end + + -- load mod config if it exists + if love.filesystem.getInfo(mod.path .. "/config.json", 'file') then + local modConfig = dpf.loadJson(mod.path .. "/config.json") + if modConfig then + -- a shallow copy is enough in this case + for k, v in pairs(modConfig) do + mod.config[k] = v end - }) - - if not love.filesystem.getInfo(mod.path, 'directory') then - goto continue end + end - local lovelyignore = love.filesystem.getInfo(mod.path .. "/.lovelyignore", 'file') - local nolovelyignore = love.filesystem.getInfo(mod.path .. "/.nolovelyignore", 'file') - if lovelyignore ~= nil then - mod.enabled = false - elseif nolovelyignore ~= nil then - mod.enabled = true - end + -- load mod icon if it exists + if love.filesystem.getInfo(mod.path .. "/icon.png", 'file') then + local modIcon = love.graphics.newImage(mod.path .. "/icon.png") + local width, height = modIcon:getDimensions() + assert(width == 73 and height == 33, ("Mod icon '%s' has invalid size. Mod icons must be 73x33."):format(mod.path.."/icon.png")) + rawset(mod, "icon", modIcon) + end - -- load mod data - local modData = dpf.loadJson(mod.path .. "/mod.json") - mod.id = modData.id or mod.id - mod.name = modData.name or mod.name - mod.author = modData.author or mod.author - mod.description = modData.description or mod.description - mod.version = modData.version or mod.version - mod.defaultConfig = modData.config or mod.defaultConfig - mod.config = helpers.copytable(mod.defaultConfig) - -- TODO: deprecated - if modData.enabled ~= nil then - log("'" .. mod.path .. "/mod.json" .. "': 'enabled' is deprecated in favor of the .lovelyignore file","BBP") - if mod.enabled == nil then - mod.enabled = modData.enabled - if modData.enabled == false then - local disabledPath = "Mods/disabled/" .. mod.id .. "/lovely/" - if love.filesystem.getInfo(disabledPath, 'directory') then - bbp.utils.moveDirectory(disabledPath, mod.path .. "/lovely/") - end - end - end + return mod +end + +local function checkVersion(ver, versions) -- check if `ver` is covered by `versions` + if versions == nil then + return true + end + + local function splitVersion(v) + local r = {} + for n in string.gmatch(v, "([^.]+)%.?") do + table.insert(r, tonumber(n:match("%d+"))) end + return r + end - if mod.enabled == nil then - mod.enabled = true + local function cmp(v1, v2, func, last) -- func should return true, false or nil + v1 = type(v1) == "table" and v1 or splitVersion(v1) + v2 = type(v2) == "table" and v2 or splitVersion(v2) + for i=1,math.max(#v1,#v2) do + local c = func(v1[i] or 0, v2[i] or 0) + if c ~= nil then return c end end + return last + end - loader.activeMods[mod.id] = mod.enabled or nil -- not including disabled mods + local function eq(v1, v2) return cmp(v1, v2, function(a, b) if a == b then return nil else return false end end, true) end + local function lt(v1, v2) return cmp(v1, v2, function(a, b) if a == b then return nil else return a < b end end, false) end + local function gt(v1, v2) return cmp(v1, v2, function(a, b) if a == b then return nil else return a > b end end, false) end + + assert(type(versions) == "string", "version specifier must be a string!") + + if versions:startsWith("=") then + local ver2 = versions:sub(2) + return eq(ver, ver2) + elseif versions:startsWith("<=") then + local ver2 = versions:sub(3) + return not gt(ver, ver2) + elseif versions:startsWith(">=") then + local ver2 = versions:sub(3) + return not lt(ver, ver2) + elseif versions:startsWith("<") then + local ver2 = versions:sub(2) + return lt(ver, ver2) + elseif versions:startsWith(">") then + local ver2 = versions:sub(2) + return gt(ver, ver2) + end - -- load mod config if it exists - if love.filesystem.getInfo(mod.path .. "/config.json", 'file') then - local modConfig = dpf.loadJson(mod.path .. "/config.json") - if modConfig then - -- a shallow copy is enough in this case - for k, v in pairs(modConfig) do - mod.config[k] = v - end + error(("Invalid version specifier '%s'"):format(versions)) +end + +local function checkModDependsConflicts(mod) + local problems = {} + for _,dep in ipairs(mod.depends) do + if #dep == 0 then dep = {dep} end -- each entry is either {id,version}, or a list of options + + local optionProblems = {} + for _,dep in ipairs(dep) do + assert(dep.id, ("Malformed dependencies in '%s': missing 'id'"):format(mod.id)) + local other = loader.mods[dep.id] + if not other then + table.insert(optionProblems, ("'%s': not installed"):format(dep.id)) + elseif not other.enabled then + table.insert(optionProblems, ("'%s': not enabled"):format(dep.id)) + elseif not checkVersion(other.version, dep.version) then + table.insert(optionProblems, ("'%s': wrong version (%s), expected %s"):format(dep.id, other.version, dep.version)) + else + optionProblems = nil + break end end - - -- load mod icon if it exists - if love.filesystem.getInfo(mod.path .. "/icon.png", 'file') then - local modIcon = love.graphics.newImage(mod.path .. "/icon.png") - local width, height = modIcon:getDimensions() - if width ~= 73 or height ~= 33 then - log("Mod icon of " .. mod.id .. " has invalid size. Mod icons must be 73x33.","BBP") + if optionProblems then + if #dep == 1 then + table.insert(problems, ("'%s': missing dependency: %s"):format(mod.id, optionProblems[1])) else - rawset(mod, "icon", modIcon) + local sep = "\n " + table.insert(problems, ("'%s': missing dependency: one of: %s"):format(mod.id, sep .. table.concat(optionProblems, sep))) end end + end + + for _,conflict in pairs(mod.conflicts) do + assert(conflict.id, ("Malformed conflicts in '%s': missing 'id'"):format(mod.id)) + local other = loader.mods[conflict.id] + if other and other.enabled and checkVersion(other.version, conflict.version) then + table.insert(problems, ("'%s': incompatible with '%s'"):format(mod.id, conflict.id)) + end + end + + return problems +end + +-- returns a (multiline) string with problems, or nil +function loader.checkDependsConflicts() + local problems = {} + for id,mod in pairs(loader.mods) do + if not mod.enabled then goto continue end + + local p = checkModDependsConflicts(mod) + if #p > 0 then + table.insert(problems, table.concat(p, "\n")) + end + + ::continue:: + end + if #problems == 0 then + return nil + end + return table.concat(problems, "\n\n") +end + +function loader.loadMods() -- loads mod data, assets, mod icons etc. + loader.mods = {} + loader.activeMods = {} + + -- TODO: remove this later due to deprecation + mods = loader.mods + + local modsPath = "Mods" + local success = love.filesystem.getInfo(modsPath, 'directory') + + if not success then + error("BBP failed to find the Mods directory.") + return + end + for _, modDir in ipairs(love.filesystem.getDirectoryItems(modsPath)) do + local mod = loadModMetadata(modsPath.."/"..modDir) + if not mod then goto continue end + + loader.activeMods[mod.id] = mod.enabled or nil -- not including disabled mods loader.mods[mod.id] = mod log("Registered mod '" .. mod.name .. "' by " .. mod.author .. ".","BBP_silent") @@ -344,6 +461,27 @@ function loader.loadMods() -- loads mod data, assets, mod icons etc. log("Finished loading all mods! :D","BBP") + local problems = loader.checkDependsConflicts() + if problems then + print("Incompatible mods!\n"..problems) + + local buttons = { + "Open mod menu", + "Continue anyway (don't do this!)", + "Exit", + escapebutton = 3, + enterbutton = 1, + } + local pressed = love.window.showMessageBox("Incompatible mods!", problems, buttons, "error", false) + if pressed == 1 then + project.initState = 'Mods' + elseif pressed == 2 then + -- do nothing + else + love.event.quit() + end + end + if log.display.BBP_silent > 0 then bbp.utils.printTable(animations, "Animations:") bbp.utils.printTable(sprites, "Sprites:") diff --git a/bbp/utils.lua b/bbp/utils.lua index 35eb60f..7e44500 100644 --- a/bbp/utils.lua +++ b/bbp/utils.lua @@ -121,6 +121,11 @@ function string:endsWith(ending) return ending == "" or self:sub(- #ending) == ending end +-- Checks if a string starts with another string +function string:startsWith(start) + return start == "" or self:sub(1, #start) == start +end + -- Gets a list of all mod names, their versions and authors function utils.getModList() if not (bbp and bbp.mods) or next(bbp.mods) == nil then diff --git a/states/Mods.lua b/states/Mods.lua index 538ce9a..c1249db 100644 --- a/states/Mods.lua +++ b/states/Mods.lua @@ -115,7 +115,7 @@ st.loadMainMenu = function(self) end cs = bs.load('Menu') - self.menuMusicManager:clearOnBeatHooks() + if self.menuMusicManager then self.menuMusicManager:clearOnBeatHooks() end cs.menuMusicManager = self.menuMusicManager cs:init() @@ -125,6 +125,16 @@ st.loadMainMenu = function(self) end end +function st:tryExit() + local problems = bbp.loader.checkDependsConflicts() + if not problems then + self:loadMainMenu() + else + maininput:update() + openPopup("incompatible mods", {problems = problems}) + end +end + -- look for directory/folder-name/mod.json and return folder-name local function findModFolder(directory) local modFolder @@ -209,7 +219,7 @@ st:setUpdate(function(self, dt) -- clear config renderer cache rawset(bbp.mods[self.selectedModId], '_configRenderer', nil) elseif maininput:pressed("back") then - self.loadMainMenu(self) + self:tryExit() end end) @@ -292,7 +302,7 @@ st:setFgDraw(function(self) imgui.Separator() if imgui.Button("Go Back") then - self.loadMainMenu(self) + self:tryExit() end imgui.SameLine() @@ -537,6 +547,29 @@ st:setFgDraw(function(self) imgui.EndPopup() end + if imgui.BeginPopupModal("incompatible mods", nil, popupFlags) then + imgui.TextUnformatted("You have incompatible mods!\n" .. self.popupData.problems) + imgui.Separator() + + if imgui.Button("Return to mod menu") then + imgui.CloseCurrentPopup() + end + imgui.SetItemDefaultFocus() + + imgui.SameLine() + if imgui.Button("Continue anyway (don't do this!)") then + imgui.CloseCurrentPopup() + st:loadMainMenu() + end + + imgui.SameLine() + if imgui.Button("Close beatblock") then + love.event.quit() + end + + imgui.EndPopup() + end + imgui.End() if appliedBBPTheme then bbp.gui.popStyle()