mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-15 07:41:21 +02:00
296 lines
11 KiB
Lua
296 lines
11 KiB
Lua
-- Engine-owned import handling for files declared by a mod's required or
|
|
-- optional import arrays. Mods never receive host paths or broader
|
|
-- filesystem access: accepted bytes are copied into their own
|
|
-- mods/<id>/baseroms/ tree, where the existing mod:read sandbox can see them.
|
|
|
|
local CacheFs = require("src.import.CacheFs")
|
|
|
|
local RequiredImports = {}
|
|
|
|
local function allSpecs(manifest)
|
|
local out = {}
|
|
for _, spec in ipairs((manifest and manifest.required_imports) or {}) do
|
|
out[#out + 1] = spec
|
|
end
|
|
for _, spec in ipairs((manifest and manifest.optional_imports) or {}) do
|
|
out[#out + 1] = spec
|
|
end
|
|
return out
|
|
end
|
|
|
|
local function isRequired(spec)
|
|
return spec.required ~= false
|
|
end
|
|
|
|
RequiredImports.specs = allSpecs
|
|
RequiredImports.MAX_BYTES = 128 * 1024 * 1024
|
|
|
|
local function sizeLabel(bytes)
|
|
return ("%.1f MiB"):format(bytes / (1024 * 1024))
|
|
end
|
|
|
|
-- Check size before a caller reads an external or stored file into one large
|
|
-- Lua string. N64 sources may carry a 512-byte copier header, while stored
|
|
-- files are always canonical and therefore must match the declared size.
|
|
function RequiredImports.sizeError(spec, size, stored)
|
|
if type(size) ~= "number" then return nil end
|
|
if size > RequiredImports.MAX_BYTES then
|
|
return ("file is too large (%s; hard limit is %s)")
|
|
:format(sizeLabel(size), sizeLabel(RequiredImports.MAX_BYTES))
|
|
end
|
|
local headerAllowance = not stored and spec and spec.format == "n64" and 512 or 0
|
|
if spec and spec.size then
|
|
if size ~= spec.size and size ~= spec.size + headerAllowance then
|
|
return ("wrong file size (expected %d bytes%s, got %d)")
|
|
:format(spec.size, headerAllowance > 0 and " or a 512-byte header" or "", size)
|
|
end
|
|
end
|
|
if spec and spec.max_size and size > spec.max_size + headerAllowance then
|
|
return ("file is too large for this import (maximum %d bytes, got %d)")
|
|
:format(spec.max_size, size)
|
|
end
|
|
return nil
|
|
end
|
|
|
|
local N64_MAGIC = {
|
|
["\128\55\18\64"] = "z64", -- big endian / canonical
|
|
["\55\128\64\18"] = "v64", -- byte-swapped
|
|
["\64\18\55\128"] = "n64", -- little endian words
|
|
}
|
|
|
|
local function n64KindAt(data, offset)
|
|
return N64_MAGIC[data:sub(offset, offset + 3)]
|
|
end
|
|
|
|
-- Return canonical big-endian N64 bytes. A 512-byte copier header is
|
|
-- recognized only when valid N64 magic follows it, so arbitrary data is never
|
|
-- shortened just because its size happens to line up.
|
|
function RequiredImports.normalizeN64(data)
|
|
if type(data) ~= "string" then return nil, "selected file could not be read" end
|
|
local offset, kind = 1, n64KindAt(data, 1)
|
|
if not kind then
|
|
kind = n64KindAt(data, 513)
|
|
if kind then offset = 513 end
|
|
end
|
|
if not kind then
|
|
return nil, "expected an N64 ROM (.z64/.v64/.n64); file signature was not recognized"
|
|
end
|
|
data = data:sub(offset)
|
|
if kind == "z64" then return data end
|
|
|
|
if kind == "v64" then
|
|
if #data % 2 ~= 0 then return nil, "byte-swapped N64 ROM has an odd size" end
|
|
return (data:gsub("(.)(.)", "%2%1"))
|
|
else
|
|
if #data % 4 ~= 0 then return nil, "little-endian N64 ROM size is not word aligned" end
|
|
return (data:gsub("(.)(.)(.)(.)", "%4%3%2%1"))
|
|
end
|
|
end
|
|
|
|
function RequiredImports.normalize(spec, data)
|
|
if spec and spec.format == "n64" then
|
|
return RequiredImports.normalizeN64(data)
|
|
end
|
|
if type(data) ~= "string" then return nil, "selected file could not be read" end
|
|
return data
|
|
end
|
|
|
|
local function hexDigest(data, hashFn)
|
|
if hashFn then return hashFn(data):lower() end
|
|
if not (love and love.data and love.data.hash and love.data.encode) then
|
|
return nil, "MD5 support is unavailable in this build"
|
|
end
|
|
local digest = love.data.hash("md5", data)
|
|
if type(digest) == "userdata" and digest.getString then
|
|
digest = digest:getString()
|
|
end
|
|
return love.data.encode("string", "hex", digest):lower()
|
|
end
|
|
|
|
local function accepts(spec, digest)
|
|
for _, wanted in ipairs((spec and spec.md5) or {}) do
|
|
if wanted == digest then return true end
|
|
end
|
|
return false
|
|
end
|
|
|
|
function RequiredImports.path(manifest, spec)
|
|
return manifest.path .. "/baseroms/" .. spec.file
|
|
end
|
|
|
|
local function removedMarker(manifest, spec)
|
|
return manifest.path .. "/baseroms/.required-import-" .. spec.id .. ".removed"
|
|
end
|
|
|
|
local function receiptPath(manifest, spec)
|
|
return manifest.path .. "/baseroms/.required-import-" .. spec.id .. ".validated"
|
|
end
|
|
|
|
RequiredImports.receiptPath = receiptPath
|
|
|
|
local function parseReceipt(raw)
|
|
if type(raw) ~= "string" then return nil end
|
|
local digest, size, modtime = raw:match("^v1\n([%x]+)\n(%d+)\n([^\n]+)\n?$")
|
|
if not digest then return nil end
|
|
return digest:lower(), tonumber(size), tonumber(modtime)
|
|
end
|
|
|
|
local function cachedDigest(manifest, spec, fs, info)
|
|
-- A size alone cannot detect a same-length replacement. Require modtime as
|
|
-- well; filesystems that do not expose it simply take the safe hash path.
|
|
if not (fs and fs.read and info and info.size and info.modtime) then return nil end
|
|
local digest, size, modtime = parseReceipt(fs.read(receiptPath(manifest, spec)))
|
|
if digest and size == info.size and modtime == info.modtime
|
|
and accepts(spec, digest) then
|
|
return digest
|
|
end
|
|
return nil
|
|
end
|
|
|
|
local function writeReceipt(manifest, spec, digest, info, fs)
|
|
if not (digest and info and info.size and info.modtime) then return end
|
|
local path = receiptPath(manifest, spec)
|
|
local body = ("v1\n%s\n%d\n%s\n")
|
|
:format(digest, info.size, tostring(info.modtime))
|
|
if love and fs == love.filesystem then
|
|
local savedPrefix = CacheFs.prefix
|
|
CacheFs.prefix = ""
|
|
CacheFs.write(path, body)
|
|
CacheFs.prefix = savedPrefix
|
|
elseif fs and fs.write then
|
|
fs.write(path, body)
|
|
end
|
|
end
|
|
|
|
local function removeReceipt(manifest, spec, fs)
|
|
local path = receiptPath(manifest, spec)
|
|
if love and fs == love.filesystem then
|
|
local savedPrefix = CacheFs.prefix
|
|
CacheFs.prefix = ""
|
|
CacheFs.remove(path)
|
|
CacheFs.prefix = savedPrefix
|
|
elseif fs and fs.remove then
|
|
fs.remove(path)
|
|
end
|
|
end
|
|
|
|
-- Validate bytes against a declaration. The returned data is canonicalized
|
|
-- (notably for N64 byte order/header variants) and is what must be stored.
|
|
function RequiredImports.validateData(spec, data, hashFn)
|
|
local sourceSizeErr = type(data) == "string"
|
|
and RequiredImports.sizeError(spec, #data, false)
|
|
if sourceSizeErr then return nil, sourceSizeErr end
|
|
local normalized, normalizeErr = RequiredImports.normalize(spec, data)
|
|
if not normalized then return nil, normalizeErr end
|
|
local storedSizeErr = RequiredImports.sizeError(spec, #normalized, true)
|
|
if storedSizeErr then return nil, storedSizeErr end
|
|
local digest, hashErr = hexDigest(normalized, hashFn)
|
|
if not digest then return nil, hashErr end
|
|
if not accepts(spec, digest) then
|
|
return nil, ("MD5 mismatch (got %s)"):format(digest)
|
|
end
|
|
return normalized, digest
|
|
end
|
|
|
|
-- Validate one installed import without reading it when the engine-authored
|
|
-- receipt still matches the file's size and modification time.
|
|
function RequiredImports.validateStored(manifest, spec, fs, hashFn)
|
|
fs = fs or (love and love.filesystem)
|
|
if not (fs and fs.getInfo) then return nil, "filesystem is unavailable" end
|
|
local path = RequiredImports.path(manifest, spec)
|
|
local info = fs.getInfo(path, "file")
|
|
if not info then
|
|
removeReceipt(manifest, spec, fs)
|
|
return nil, "file is missing"
|
|
end
|
|
local sizeErr = RequiredImports.sizeError(spec, info.size, true)
|
|
if sizeErr then
|
|
removeReceipt(manifest, spec, fs)
|
|
return nil, sizeErr
|
|
end
|
|
local cached = cachedDigest(manifest, spec, fs, info)
|
|
if cached then return true, cached, true end
|
|
removeReceipt(manifest, spec, fs)
|
|
if not fs.read then return nil, "file could not be read" end
|
|
local data = fs.read(path)
|
|
local normalized, detail = RequiredImports.validateStoredData(spec, data, hashFn)
|
|
if not normalized then return nil, detail end
|
|
info = fs.getInfo(path, "file") or info
|
|
info.size = info.size or #data
|
|
writeReceipt(manifest, spec, detail, info, fs)
|
|
return true, detail, false
|
|
end
|
|
|
|
function RequiredImports.validateStoredData(spec, data, hashFn)
|
|
local normalized, detail = RequiredImports.validateData(spec, data, hashFn)
|
|
if not normalized then return nil, detail end
|
|
if normalized ~= data then
|
|
return nil, "stored N64 ROM is not canonical; choose the source file again"
|
|
end
|
|
return normalized, detail
|
|
end
|
|
|
|
function RequiredImports.inspect(manifest, fs, hashFn)
|
|
fs = fs or (love and love.filesystem)
|
|
local rows, missing, missingOptional = {}, 0, 0
|
|
for _, spec in ipairs(allSpecs(manifest)) do
|
|
local path = RequiredImports.path(manifest, spec)
|
|
local suppressed = fs and fs.getInfo
|
|
and fs.getInfo(removedMarker(manifest, spec), "file") ~= nil
|
|
local exists = fs and fs.getInfo and fs.getInfo(path, "file") ~= nil
|
|
local valid, detail = RequiredImports.validateStored(manifest, spec, fs, hashFn)
|
|
local row = { id = spec.id, name = spec.name, file = spec.file,
|
|
description = spec.description, format = spec.format, path = path,
|
|
present = valid == true, digest = valid and detail or nil,
|
|
error = exists and not valid and detail or nil,
|
|
suppressed = suppressed, required = isRequired(spec), spec = spec }
|
|
if not row.present then
|
|
if row.required then missing = missing + 1
|
|
else missingOptional = missingOptional + 1 end
|
|
end
|
|
rows[#rows + 1] = row
|
|
end
|
|
return rows, missing, missingOptional
|
|
end
|
|
|
|
function RequiredImports.importData(manifest, importId, data, opts)
|
|
opts = opts or {}
|
|
local spec
|
|
for _, candidate in ipairs(allSpecs(manifest)) do
|
|
if candidate.id == importId then spec = candidate break end
|
|
end
|
|
if not spec then return nil, "unknown required import: " .. tostring(importId) end
|
|
local normalized, digest = RequiredImports.validateData(spec, data, opts.hash)
|
|
if not normalized then return nil, digest end
|
|
local savedPrefix = CacheFs.prefix
|
|
CacheFs.prefix = ""
|
|
CacheFs.remove(receiptPath(manifest, spec))
|
|
local ok, err = CacheFs.write(RequiredImports.path(manifest, spec), normalized)
|
|
if ok then CacheFs.remove(removedMarker(manifest, spec)) end
|
|
if ok and love and love.filesystem and love.filesystem.getInfo then
|
|
local info = love.filesystem.getInfo(RequiredImports.path(manifest, spec), "file")
|
|
writeReceipt(manifest, spec, digest, info, love.filesystem)
|
|
end
|
|
CacheFs.prefix = savedPrefix
|
|
if not ok then return nil, "could not copy import: " .. tostring(err) end
|
|
return true, digest
|
|
end
|
|
|
|
function RequiredImports.remove(manifest, importId)
|
|
for _, spec in ipairs(allSpecs(manifest)) do
|
|
if spec.id == importId then
|
|
local savedPrefix = CacheFs.prefix
|
|
CacheFs.prefix = ""
|
|
CacheFs.remove(RequiredImports.path(manifest, spec))
|
|
CacheFs.remove(receiptPath(manifest, spec))
|
|
local marked, markErr = CacheFs.write(removedMarker(manifest, spec), "removed\n")
|
|
CacheFs.prefix = savedPrefix
|
|
if not marked then return nil, "could not remember removal: " .. tostring(markErr) end
|
|
return true
|
|
end
|
|
end
|
|
return nil, "unknown required import: " .. tostring(importId)
|
|
end
|
|
|
|
return RequiredImports
|