legcompat

This commit is contained in:
bryanthaboi
2026-08-15 06:04:04 -04:00
parent 7804ef9793
commit 43cbc554c3
8 changed files with 1274 additions and 76 deletions
+943
View File
@@ -0,0 +1,943 @@
local Logger = require("src.core.Logger")
local SafePath = require("src.mods.SafePath")
local okSave, SaveData = pcall(require, "src.core.SaveData")
if not okSave then SaveData = nil end
local okStorage, Storage = pcall(require, "src.mods.Storage")
if not okStorage then Storage = nil end
local LegacyCompat = {}
local ROOT = "mod_compat"
local OWN = "_own"
local MAX_SEGMENTS = 24
LegacyCompat.reports = {}
function LegacyCompat.reset()
LegacyCompat.reports = {}
end
function LegacyCompat.report(modId)
if modId then
local entry = LegacyCompat.reports[modId]
return entry and entry.order or {}
end
local out = {}
for id, entry in pairs(LegacyCompat.reports) do
out[id] = entry.order
end
return out
end
local function note(ctx, call, advice)
local entry = LegacyCompat.reports[ctx.modId]
if not entry then
entry = { calls = {}, order = {} }
LegacyCompat.reports[ctx.modId] = entry
end
local row = entry.calls[call]
if row then
row.count = row.count + 1
return
end
row = { call = call, advice = advice, count = 1 }
entry.calls[call] = row
entry.order[#entry.order + 1] = row
Logger.warn("[%s] %s was removed from the mod sandbox; %s", ctx.modId, call,
advice)
end
local function refuse(ctx, call, advice)
note(ctx, call, advice)
return nil, ("%s is not available to mods; %s"):format(call, advice)
end
-- ------- path routing
local function normalize(path)
if type(path) ~= "string" or path == "" then return nil end
path = path:gsub("\\", "/")
while path:find("//", 1, true) do path = (path:gsub("//", "/")) end
if path ~= "/" then path = (path:gsub("/$", "")) end
return path
end
local function flatten(path)
local parts = {}
for segment in path:gmatch("[^/]+") do
if segment ~= "." and segment ~= ".." then
parts[#parts + 1] = (segment:gsub("[^%w_%.%-]", "_"))
end
end
if #parts == 0 then return nil end
while #parts > MAX_SEGMENTS do table.remove(parts, 1) end
return table.concat(parts, "/")
end
local function storageKey(key)
local parts = {}
for segment in key:gmatch("[^/]+") do parts[#parts + 1] = segment end
if #parts == 0 then return nil end
parts[#parts] = (parts[#parts]:gsub("%.[^%.]*$", ""))
for i = 1, #parts do
local cleaned = (parts[i]:gsub("[^%w_%-]", "_"))
if cleaned == "" then return nil end
parts[i] = cleaned
end
return table.concat(parts, "/")
end
local function ownRelative(ctx, path)
if not ctx.modPath then return nil end
if path == ctx.modPath then return "" end
if path:sub(1, #ctx.modPath + 1) == ctx.modPath .. "/" then
return path:sub(#ctx.modPath + 2)
end
return nil
end
local function ownFull(ctx, rel)
if rel == "" then return ctx.modPath end
local safe = SafePath.safe(rel)
if not safe then return nil end
return ctx.modPath .. "/" .. safe
end
local function ownExists(ctx, rel)
local full = ownFull(ctx, rel)
local fs = ctx.fs
if not (full and fs and fs.getInfo) then return nil end
return fs.getInfo(full)
end
local function classify(ctx, path)
path = normalize(path)
if not path then return nil end
local root = ctx.virtualRoot
if path == root then return { key = "", dir = true } end
if path:sub(1, #root + 1) == root .. "/" then
return { key = flatten(path:sub(#root + 2)) }
end
local rel = ownRelative(ctx, path)
if not rel and path:sub(1, 1) ~= "/" and not path:match("^%a:")
and ownExists(ctx, path) then
rel = path
end
if rel then
if rel == "" then return { key = OWN, rel = "", dir = true } end
local flat = flatten(rel)
return { key = flat and (OWN .. "/" .. flat) or nil, rel = rel }
end
return { key = flatten(path) }
end
-- ------- the overlay
local function persistFs(ctx)
if SaveData and SaveData.persistenceFs then
return SaveData.persistenceFs(ctx.fs)
end
return ctx.fs
end
local function overlayPath(ctx, key)
if key == nil or key == "" then return ROOT .. "/" .. ctx.modId end
return ROOT .. "/" .. ctx.modId .. "/" .. key
end
local function ensureParent(fs, path)
if not fs.createDirectory then return end
local dir = path:match("^(.*)/[^/]+$")
if not dir then return end
local built = nil
for segment in dir:gmatch("[^/]+") do
built = built and (built .. "/" .. segment) or segment
fs.createDirectory(built)
end
end
local function overlayRead(ctx, key)
local fs = persistFs(ctx)
if not (fs and fs.read and fs.getInfo) then return nil end
local path = overlayPath(ctx, key)
local info = fs.getInfo(path)
if not info or info.type == "directory" then return nil end
local body = fs.read(path)
if type(body) ~= "string" then return nil end
return body
end
local function overlayWrite(ctx, key, data)
local fs = persistFs(ctx)
if not (fs and fs.write) then
return false, "no writable filesystem is available"
end
local path = overlayPath(ctx, key)
ensureParent(fs, path)
local ok, err = fs.write(path, data)
if ok == false then return false, err or "write failed" end
return true
end
local function overlayRemove(ctx, key)
local fs = persistFs(ctx)
if not (fs and fs.remove and fs.getInfo) then return false end
local path = overlayPath(ctx, key)
if not fs.getInfo(path) then return false end
fs.remove(path)
return true
end
local function overlayInfo(ctx, key)
local fs = persistFs(ctx)
if not (fs and fs.getInfo) then return nil end
return fs.getInfo(overlayPath(ctx, key))
end
local function storageRead(ctx, key)
if not (Storage and key and key ~= "") then return nil end
if key:sub(1, #OWN + 1) == OWN .. "/" then return nil end
local game = ctx.game and ctx.game()
if not game then return nil end
local sk = storageKey(key)
if not sk then return nil end
ctx.storage = ctx.storage or Storage.new(ctx.modId, ctx.fs)
local ok, bytes = pcall(ctx.storage.readBytes, ctx.storage, game, sk)
if ok and type(bytes) == "string" then return bytes end
return nil
end
local function readPath(ctx, path)
local at = classify(ctx, path)
if not at then return nil, "invalid path" end
local body = at.key and overlayRead(ctx, at.key)
if body then return body end
if at.rel then
local full = ownFull(ctx, at.rel)
local fs = ctx.fs
if full and fs and fs.read then
local packaged = fs.read(full)
if type(packaged) == "string" then return packaged end
end
end
body = at.key and storageRead(ctx, at.key)
if body then return body end
return nil, "could not open " .. tostring(path)
end
local function writePath(ctx, path, data)
local at = classify(ctx, path)
if not (at and at.key) then return false, "invalid path" end
return overlayWrite(ctx, at.key, data)
end
local function infoPath(ctx, path)
local at = classify(ctx, path)
if not at then return nil end
if at.key then
local info = overlayInfo(ctx, at.key)
if info then
return { type = info.type, size = info.size, modtime = info.modtime }
end
end
if at.rel then
local info = ownExists(ctx, at.rel)
if info then
return { type = info.type, size = info.size, modtime = info.modtime }
end
end
local stored = at.key and storageRead(ctx, at.key)
if stored then return { type = "file", size = #stored } end
return nil
end
local function listPath(ctx, path)
local at = classify(ctx, path)
local seen, out = {}, {}
local function add(name)
if name and name ~= "" and not seen[name] then
seen[name] = true
out[#out + 1] = name
end
end
if at and at.rel then
local full = ownFull(ctx, at.rel)
local fs = ctx.fs
if full and fs and fs.getDirectoryItems then
for _, name in ipairs(fs.getDirectoryItems(full) or {}) do add(name) end
end
end
if at and at.key then
local fs = persistFs(ctx)
if fs and fs.getDirectoryItems then
for _, name in ipairs(fs.getDirectoryItems(overlayPath(ctx, at.key)) or {}) do
add(name)
end
end
end
table.sort(out)
return out
end
-- ------- buffered file handles
local function openBuffer(ctx, path, mode)
mode = tostring(mode or "r"):gsub("b", "")
local writable = mode:find("[wa+]") ~= nil
local body
if mode:sub(1, 1) == "w" then
body = ""
else
body = readPath(ctx, path)
if not body then
if not writable then return nil, "could not open " .. tostring(path) end
body = ""
end
end
local state = {
ctx = ctx, path = path, buf = body, pos = 1,
writable = writable, closed = false, dirty = false,
}
if mode:sub(1, 1) == "a" then state.pos = #body + 1 end
return state
end
local function bufferFlush(state)
if not (state.writable and state.dirty) then return true end
local ok, err = writePath(state.ctx, state.path, state.buf)
if ok then state.dirty = false end
return ok, err
end
local function bufferWrite(state, text)
if not state.writable then return false, "file is not open for writing" end
local head = state.buf:sub(1, state.pos - 1)
if #head < state.pos - 1 then head = head .. string.rep("\0", state.pos - 1 - #head) end
local tail = state.buf:sub(state.pos + #text)
state.buf = head .. text .. tail
state.pos = state.pos + #text
state.dirty = true
return true
end
local function bufferRead(state, fmt)
if fmt == nil then fmt = "*l" end
if type(fmt) == "number" then
if fmt == 0 then return state.pos <= #state.buf and "" or nil end
if state.pos > #state.buf then return nil end
local chunk = state.buf:sub(state.pos, state.pos + fmt - 1)
state.pos = state.pos + #chunk
return chunk
end
fmt = tostring(fmt):gsub("^%*", "")
if fmt == "a" then
local rest = state.buf:sub(state.pos)
state.pos = #state.buf + 1
return rest
end
if fmt == "l" or fmt == "L" then
if state.pos > #state.buf then return nil end
local nl = state.buf:find("\n", state.pos, true)
local line
if nl then
line = state.buf:sub(state.pos, fmt == "L" and nl or nl - 1)
state.pos = nl + 1
else
line = state.buf:sub(state.pos)
state.pos = #state.buf + 1
end
return line
end
if fmt == "n" then
local rest = state.buf:sub(state.pos)
local text, after = rest:match("^%s*(%-?%d+%.?%d*[eE]?[-+]?%d*)()")
if not text then return nil end
state.pos = state.pos + after - 1
return tonumber(text)
end
return nil
end
local function bufferSeek(state, whence, offset)
whence = whence or "cur"
offset = offset or 0
if whence == "set" then state.pos = offset + 1
elseif whence == "cur" then state.pos = state.pos + offset
elseif whence == "end" then state.pos = #state.buf + offset + 1
else return nil, "bad seek base" end
if state.pos < 1 then state.pos = 1 end
return state.pos - 1
end
local function bufferClose(state)
if state.closed then return true end
local ok, err = bufferFlush(state)
state.closed = true
return ok, err
end
local function ioFile(ctx, path, mode)
local state, err = openBuffer(ctx, path, mode)
if not state then return nil, err end
local file = {}
function file:read(...)
local count = select("#", ...)
if count == 0 then return bufferRead(state, "*l") end
local out = {}
for i = 1, count do out[i] = bufferRead(state, (select(i, ...))) end
return unpack(out, 1, count)
end
function file:write(...)
for i = 1, select("#", ...) do
local value = select(i, ...)
local ok, writeErr = bufferWrite(state, tostring(value))
if not ok then return nil, writeErr end
end
return file
end
function file:lines(fmt)
return function() return bufferRead(state, fmt or "*l") end
end
function file:seek(whence, offset) return bufferSeek(state, whence, offset) end
function file:flush() bufferFlush(state) return file end
function file:close() return bufferClose(state) end
function file:setvbuf() return true end
return file
end
local function loveFile(ctx, path, mode)
local state = nil
local file = {}
function file:open(openMode)
local opened, err = openBuffer(ctx, path, openMode or mode or "r")
if not opened then return false, err end
state = opened
return true
end
function file:isOpen() return state ~= nil and not state.closed end
function file:read(bytes)
if not state and not file:open("r") then return nil, 0 end
local body = bytes and bufferRead(state, bytes) or bufferRead(state, "*a")
if not body then return nil, 0 end
return body, #body
end
function file:write(data, size)
if not state and not file:open(mode or "w") then return false end
data = tostring(data)
if size then data = data:sub(1, size) end
local ok, err = bufferWrite(state, data)
if not ok then return false, err end
bufferFlush(state)
return true
end
function file:lines()
if not state then file:open("r") end
return function() return state and bufferRead(state, "*l") or nil end
end
function file:seek(offset)
if not state then return false end
bufferSeek(state, "set", offset or 0)
return true
end
function file:tell() return state and (state.pos - 1) or 0 end
function file:getSize()
if state then return #state.buf end
local body = readPath(ctx, path)
return body and #body or 0
end
function file:flush() if state then bufferFlush(state) end return true end
function file:close()
if state then bufferClose(state) end
state = nil
return true
end
function file:getFilename() return path end
function file:getMode() return mode or "c" end
function file:setBuffer() return true end
if mode and mode ~= "c" then file:open(mode) end
return file
end
-- ------- the love.filesystem stand-in
local function realFilesystem()
return _G.love and _G.love.filesystem or nil
end
local function filesystemShim(ctx)
local fsShim = {}
local function readAdvice() return "reads now come from mod:read and mod.storage" end
function fsShim.read(a, b, c)
local path, size = a, b
if (a == "string" or a == "data") and type(b) == "string" then
path, size = b, c
end
note(ctx, "love.filesystem.read", readAdvice())
local body, err = readPath(ctx, path)
if not body then return nil, err end
if size and size >= 0 then body = body:sub(1, size) end
return body, #body
end
function fsShim.write(path, data, size)
note(ctx, "love.filesystem.write",
"writes are rerouted to this mod's private compat storage; migrate to mod.storage")
data = tostring(data)
if size then data = data:sub(1, size) end
local ok, err = writePath(ctx, path, data)
if not ok then return false, err end
return true
end
function fsShim.append(path, data, size)
note(ctx, "love.filesystem.append",
"writes are rerouted to this mod's private compat storage; migrate to mod.storage")
data = tostring(data)
if size then data = data:sub(1, size) end
local existing = readPath(ctx, path) or ""
local ok, err = writePath(ctx, path, existing .. data)
if not ok then return false, err end
return true
end
function fsShim.lines(path)
note(ctx, "love.filesystem.lines", readAdvice())
local body = readPath(ctx, path)
if not body then error("could not open " .. tostring(path), 2) end
local pos = 1
return function()
if pos > #body then return nil end
local nl = body:find("\n", pos, true)
local line
if nl then
line = body:sub(pos, nl - 1)
pos = nl + 1
else
line = body:sub(pos)
pos = #body + 1
end
return line
end
end
function fsShim.load(path)
note(ctx, "love.filesystem.load",
"the chunk is compiled into this mod's sandbox; prefer require or mod:read plus load")
local body = readPath(ctx, path)
if not body then return nil, "could not open " .. tostring(path) end
if not ctx.compile then return nil, "no sandbox is bound yet" end
return ctx.compile(body, "@" .. tostring(path))
end
function fsShim.getInfo(path, a, b)
local info = infoPath(ctx, path)
local filter = type(a) == "string" and a or nil
local into = type(a) == "table" and a or (type(b) == "table" and b or nil)
if not info then return nil end
if filter and info.type ~= filter then return nil end
if into then
into.type, into.size, into.modtime = info.type, info.size, info.modtime
return into
end
return info
end
function fsShim.getDirectoryItems(path)
note(ctx, "love.filesystem.getDirectoryItems", "use mod.assets:list")
return listPath(ctx, path)
end
function fsShim.createDirectory(path)
local at = classify(ctx, path)
if not (at and at.key) then return false end
local fs = persistFs(ctx)
if not (fs and fs.createDirectory) then return false end
ensureParent(fs, overlayPath(ctx, at.key) .. "/.")
fs.createDirectory(overlayPath(ctx, at.key))
return true
end
function fsShim.remove(path)
local at = classify(ctx, path)
if not (at and at.key) then return false end
return overlayRemove(ctx, at.key)
end
function fsShim.exists(path) return infoPath(ctx, path) ~= nil end
function fsShim.isFile(path)
local info = infoPath(ctx, path)
return info ~= nil and info.type == "file"
end
function fsShim.isDirectory(path)
local info = infoPath(ctx, path)
return info ~= nil and info.type == "directory"
end
function fsShim.getSize(path)
local info = infoPath(ctx, path)
if not info then return nil, "could not open " .. tostring(path) end
return info.size or 0
end
function fsShim.getLastModified(path)
local info = infoPath(ctx, path)
if not info then return nil, "could not open " .. tostring(path) end
return info.modtime or 0
end
local function virtual(call)
note(ctx, call, "paths are virtual now; everything under the returned root "
.. "lands in this mod's private compat storage")
return ctx.virtualRoot
end
function fsShim.getSaveDirectory() return virtual("love.filesystem.getSaveDirectory") end
function fsShim.getWorkingDirectory() return virtual("love.filesystem.getWorkingDirectory") end
function fsShim.getUserDirectory() return virtual("love.filesystem.getUserDirectory") end
function fsShim.getAppdataDirectory() return virtual("love.filesystem.getAppdataDirectory") end
function fsShim.getSourceBaseDirectory() return virtual("love.filesystem.getSourceBaseDirectory") end
function fsShim.getRealDirectory() return ctx.virtualRoot end
function fsShim.getIdentity() return ctx.modId end
function fsShim.setIdentity()
note(ctx, "love.filesystem.setIdentity",
"a mod cannot repoint the save directory; the call does nothing")
return false
end
function fsShim.getRequirePath() return "" end
function fsShim.setRequirePath() return false end
function fsShim.getCRequirePath() return "" end
function fsShim.setCRequirePath() return false end
function fsShim.mount()
note(ctx, "love.filesystem.mount",
"mounting is refused; ship the files inside your mod and use mod:read")
return false
end
fsShim.unmount = fsShim.mount
function fsShim.newFile(path, mode) return loveFile(ctx, path, mode) end
function fsShim.newFileData(a, b)
local real = realFilesystem()
if type(a) == "string" and b == nil then
note(ctx, "love.filesystem.newFileData", readAdvice())
local body = readPath(ctx, a)
if not body then return nil, "could not open " .. tostring(a) end
if real and real.newFileData then return real.newFileData(body, a) end
return nil, "no filesystem"
end
if real and real.newFileData then return real.newFileData(a, b) end
return nil, "no filesystem"
end
function fsShim.isFused()
local real = realFilesystem()
return real and real.isFused and real.isFused() or false
end
function fsShim.areSymlinksEnabled() return false end
function fsShim.setSymlinksEnabled() return false end
function fsShim.init() return false end
function fsShim.setSource() return false end
return setmetatable(fsShim, { __index = function(_, key)
note(ctx, "love.filesystem." .. tostring(key),
"there is no compat stand-in for it; use mod.storage or mod:read")
return nil
end })
end
-- ------- the rest of the removed surface
local function systemShim(ctx)
local function real() return _G.love and _G.love.system or nil end
return {
getOS = function()
local sys = real()
return sys and sys.getOS and sys.getOS() or "Unknown"
end,
getPowerInfo = function()
local sys = real()
if not (sys and sys.getPowerInfo) then return "unknown", nil, nil end
return sys.getPowerInfo()
end,
getProcessorCount = function()
local sys = real()
return sys and sys.getProcessorCount and sys.getProcessorCount() or 1
end,
getClipboardText = function()
note(ctx, "love.system.getClipboardText",
"clipboard access stays sandboxed; the call returns an empty string")
return ""
end,
setClipboardText = function()
note(ctx, "love.system.setClipboardText",
"clipboard access stays sandboxed; the call does nothing")
return false
end,
openURL = function()
note(ctx, "love.system.openURL",
"launching a URL stays sandboxed; the call does nothing")
return false
end,
vibrate = function(...)
local sys = real()
if sys and sys.vibrate then return sys.vibrate(...) end
return false
end,
}
end
local function eventShim(ctx)
local function real() return _G.love and _G.love.event or nil end
local shim = {
quit = function()
note(ctx, "love.event.quit",
"a mod cannot close the game out from under the player; the call does nothing")
return false
end,
}
shim.push = function(name, ...)
if name == "quit" then return shim.quit() end
local ev = real()
if ev and ev.push then return ev.push(name, ...) end
return false
end
return setmetatable(shim, { __index = function(_, key)
local ev = real()
return ev and ev[key] or nil
end })
end
local function ioShim(ctx)
local stream = {
write = function(self, ...)
for i = 1, select("#", ...) do io.write(tostring((select(i, ...)))) end
return self
end,
flush = function(self) return self end,
close = function() return true end,
read = function() return nil end,
lines = function() return function() return nil end end,
seek = function() return 0 end,
setvbuf = function() return true end,
}
local out = setmetatable({}, { __index = stream })
local shim = {
open = function(path, mode)
note(ctx, "io.open",
"the handle is backed by mod:read and this mod's private compat storage")
local file, err = ioFile(ctx, path, mode)
if not file then return nil, err, 2 end
return file
end,
lines = function(path, fmt)
if path == nil then return function() return nil end end
note(ctx, "io.lines",
"the handle is backed by mod:read and this mod's private compat storage")
local file, err = ioFile(ctx, path, "r")
if not file then error(err, 2) end
return file:lines(fmt)
end,
close = function(file) if file and file.close then return file:close() end return true end,
type = function(file)
if type(file) == "table" and file.read and file.close then return "file" end
return nil
end,
read = function() return nil end,
write = function(...) return out:write(...) end,
input = function() return out end,
output = function() return out end,
stdout = out,
stderr = out,
stdin = setmetatable({}, { __index = stream }),
popen = function()
return refuse(ctx, "io.popen", "spawning a process is refused")
end,
tmpfile = function()
return ioFile(ctx, ctx.virtualRoot .. "/io_tmpfile", "w+")
end,
}
return shim
end
local function osShim(ctx)
local HOME = { HOME = true, APPDATA = true, LOCALAPPDATA = true,
USERPROFILE = true, XDG_DATA_HOME = true,
XDG_CONFIG_HOME = true, TMPDIR = true, TEMP = true, TMP = true }
return {
getenv = function(name)
note(ctx, "os.getenv",
"the environment is hidden; home-like names answer with this mod's virtual root")
if type(name) == "string" and HOME[name:upper()] then
return ctx.virtualRoot
end
return nil
end,
remove = function(path)
note(ctx, "os.remove", "the delete lands in this mod's private compat storage")
local at = classify(ctx, path)
if not (at and at.key and overlayRemove(ctx, at.key)) then
return nil, tostring(path) .. ": no such file"
end
return true
end,
rename = function(from, to)
note(ctx, "os.rename", "the move lands in this mod's private compat storage")
local body = readPath(ctx, from)
if not body then return nil, tostring(from) .. ": no such file" end
local ok, err = writePath(ctx, to, body)
if not ok then return nil, err end
local at = classify(ctx, from)
if at and at.key then overlayRemove(ctx, at.key) end
return true
end,
tmpname = function()
return ctx.virtualRoot .. "/tmp/os_tmpname"
end,
execute = function()
note(ctx, "os.execute", "running a command is refused")
return false
end,
exit = function()
note(ctx, "os.exit",
"a mod cannot end the process; the call does nothing")
return false
end,
setlocale = function() return "C" end,
}
end
local function packageShim(ctx)
local shim = { path = "", cpath = "", preload = {}, loaded = {}, loaders = {} }
return setmetatable(shim, {
__index = function(_, key)
note(ctx, "package." .. tostring(key),
"the module loader is sandboxed; use require for the supported engine modules")
return nil
end,
})
end
-- ------- love callbacks
local CALLBACKS = {
load = true, update = true, draw = true, quit = true, lowmemory = true,
threaderror = true, keypressed = true, keyreleased = true,
textinput = true, textedited = true, mousemoved = true,
mousepressed = true, mousereleased = true, wheelmoved = true,
mousefocus = true, touchpressed = true, touchreleased = true,
touchmoved = true, joystickadded = true, joystickremoved = true,
joystickpressed = true, joystickreleased = true, joystickaxis = true,
joystickhat = true, gamepadpressed = true, gamepadreleased = true,
gamepadaxis = true, focus = true, visible = true, resize = true,
filedropped = true, directorydropped = true, displayrotated = true,
audiodevicechanged = true, localechanged = true,
}
local REFUSED = {
run = "love.run is the engine's fixed-step loop; use mod.hooks and mod.events",
errorhandler = "love.errorhandler is how a crash reaches the player; "
.. "use mod.events",
}
-- ------- assembly
function LegacyCompat.new(opts)
opts = opts or {}
local ctx = {
modId = opts.modId or "mod",
modPath = opts.modPath,
fs = opts.fs,
game = opts.game,
compile = nil,
}
ctx.virtualRoot = "/pokeport/" .. ctx.modId
local filesystem = filesystemShim(ctx)
local io_ = ioShim(ctx)
local system = systemShim(ctx)
local event = eventShim(ctx)
local compat = {
ctx = ctx,
love = { filesystem = filesystem, system = system, event = event },
os = osShim(ctx),
globals = {
io = io_,
package = packageShim(ctx),
loadfile = function(path)
note(ctx, "loadfile",
"the chunk is compiled into this mod's sandbox; prefer require or mod:read")
local body = readPath(ctx, path)
if not body then return nil, "could not open " .. tostring(path) end
if not ctx.compile then return nil, "no sandbox is bound yet" end
return ctx.compile(body, "@" .. tostring(path))
end,
},
modules = {
io = io_,
["love.filesystem"] = filesystem,
["love.system"] = system,
["love.event"] = event,
},
}
compat.globals.dofile = function(path)
local chunk, err = compat.globals.loadfile(path)
if not chunk then error(err or ("could not open " .. tostring(path)), 2) end
return chunk()
end
function compat.module(name)
if type(name) ~= "string" then return nil end
local substitute = compat.modules[name]
if substitute then
note(ctx, ('require("%s")'):format(name),
"it now answers with the compat stand-in, not the real module")
return substitute
end
if name == "os" then
note(ctx, 'require("os")',
"it now answers with the compat stand-in, not the real module")
return compat.osTable
end
return nil
end
-- true when the assignment is a callback chain, which is what a mod
-- wrapping love.mousemoved did before the sandbox; false plus a reason for
-- the two names the engine owns, false alone for a module table.
function compat.assign(key, value)
if REFUSED[key] then
note(ctx, ("love.%s assignment"):format(key), REFUSED[key])
return false, ("[%s] %s"):format(ctx.modId, REFUSED[key])
end
if not CALLBACKS[key] then return false end
if value ~= nil and type(value) ~= "function" then return false end
note(ctx, ("love.%s assignment"):format(key),
"the callback lands on the real love table the way it did before the "
.. "sandbox; prefer mod.hooks and mod.events")
return true
end
function compat.bind(env, compile)
ctx.compile = compile
compat.osTable = env and env.os or nil
end
return compat
end
return LegacyCompat
+14 -1
View File
@@ -20,6 +20,7 @@ local Semver = require("src.mods.Semver")
local Events = require("src.mods.Events")
local Gen2Compat = require("src.mods.Gen2Compat")
local Hooks = require("src.mods.Hooks")
local LegacyCompat = require("src.mods.LegacyCompat")
local Runtime = require("src.mods.Runtime")
local Steps = require("src.mods.Steps")
@@ -1288,12 +1289,24 @@ function Loader:_modEnv(mod)
local id = mod.manifest.id
local env = self.modEnv[id]
if not env then
env = Sandbox.envFor({ modId = id, permissions = mod.manifest.permissionSet })
local loader = self
local compat = LegacyCompat.new({
modId = id, modPath = mod.path, fs = self.fs,
game = function() return loader:_game() end,
})
env = Sandbox.envFor({ modId = id, permissions = mod.manifest.permissionSet,
compat = compat })
self.modEnv[id] = env
end
return env
end
-- Which pre-sandbox calls each loaded mod actually took, for the manager's
-- "needs updating" badge; nil id answers for every mod.
function Loader:legacyReport(modId)
return LegacyCompat.report(modId)
end
function Loader:_loadMod(mod)
local path = SafePath.join(mod.path, mod.manifest.entry, "manifest entry")
local chunk, err = Sandbox.loadFile(self.fs, path, self:_modEnv(mod))
+34 -9
View File
@@ -73,11 +73,15 @@ local BLOCKED_LOVE = {
.. "the step bridge", event = true,
}
local loveProxy
local function loveFacade()
if loveProxy or not _G.love then return loveProxy end
loveProxy = setmetatable({}, {
-- Per-mod, because the compat overrides (src/mods/LegacyCompat.lua) are backed
-- by that mod's own overlay and must not be shared.
local function loveFacade(compat)
if not _G.love then return nil end
local overrides = compat and compat.love
return setmetatable({}, {
__index = function(_, key)
local override = overrides and overrides[key]
if override ~= nil then return override end
local hint = BLOCKED_LOVE[key]
if hint then
error(("love.%s is not available to mods%s"):format(key,
@@ -85,11 +89,20 @@ local function loveFacade()
end
return _G.love[key]
end,
__newindex = function(_, key)
-- a callback chain lands on the real table (compat.assign decides which
-- names qualify); a module table never does
__newindex = function(_, key, value)
if compat then
local allowed, reason = compat.assign(key, value)
if allowed then
_G.love[key] = value
return
end
if reason then error(reason, 2) end
end
error(("mods cannot assign love.%s"):format(tostring(key)), 2)
end,
})
return loveProxy
end
-- ------- the environment
@@ -177,8 +190,12 @@ end
-- Runtime.modRequire is how the loader's gate identifies the caller for the
-- Gen 2 facade once Runtime.currentMod has gone back to nil (a mod requiring
-- lazily from an event handler).
local function sandboxedRequire(modId, permissionSet)
local function sandboxedRequire(modId, permissionSet, compat)
return function(name, ...)
-- the compat stand-in answers first, so a legacy require("io") gets the
-- rerouted table instead of the denial below (src/mods/LegacyCompat.lua)
local substitute = compat and compat.module(name)
if substitute ~= nil then return substitute end
local denial = Sandbox.moduleDenial(name, permissionSet)
if denial then error(("[%s] %s"):format(modId or "mod", denial), 2) end
local previous = Runtime.modRequire
@@ -192,15 +209,23 @@ end
function Sandbox.envFor(opts)
opts = opts or {}
local compat = opts.compat
local env = baseGlobals()
env.love = loveFacade()
env.require = sandboxedRequire(opts.modId, opts.permissions)
env.love = loveFacade(compat)
env.require = sandboxedRequire(opts.modId, opts.permissions, compat)
local loader = sandboxedLoad(env)
env.load = loader
env.loadstring = loader
-- a mod's globals are its own: two mods no longer share a namespace, and
-- neither can reach the engine's
env._G = env
if compat then
for key, value in pairs(compat.globals) do env[key] = value end
for key, value in pairs(compat.os) do env.os[key] = value end
compat.bind(env, function(source, chunkname)
return Sandbox.compile(source, chunkname, env)
end)
end
return env
end