skin studio updates, save sync CLOSES #1533

This commit is contained in:
bryanthaboi
2026-08-19 05:57:44 -04:00
parent fd73ab2a11
commit 93374fbbbb
54 changed files with 9762 additions and 271 deletions
+523
View File
@@ -0,0 +1,523 @@
local Json = require("src.link.Json")
local TouchSkin = require("src.core.TouchSkin")
local DeltaSkin = {}
DeltaSkin.INFO_NAME = "info.json"
DeltaSkin.MAX_INFO_BYTES = 4 * 1024 * 1024
DeltaSkin.GAME_TYPE_PREFIXES = {
"com.rileytestut.delta.game.",
"public.aoshuang.game.",
}
DeltaSkin.SYSTEMS = { gb = true, gbc = true }
DeltaSkin.LEGACY_EXTS = { gbcskin = true, gbaskin = true, gbskin = true }
DeltaSkin.DEVICE_ORDER = { "iphone", "ipad", "tv" }
DeltaSkin.DISPLAY_ORDER = { "edgeToEdge", "standard", "splitView" }
DeltaSkin.ORIENTATIONS = { "portrait", "landscape" }
DeltaSkin.SIDES = { "up", "down", "left", "right" }
DeltaSkin.ASSET_LADDER = { "small", "medium", "large" }
DeltaSkin.ASSET_WIDTHS = { small = 640, medium = 750, large = 1080 }
DeltaSkin.DEFAULT_TARGET_WIDTH = 1080
DeltaSkin.INPUTS = {
a = "a", b = "b", start = "start", select = "select",
up = "up", down = "down", left = "left", right = "right",
menu = "menu_toggle",
fastforward = "hold_fast_forward",
togglefastforward = "toggle_fast_forward",
}
DeltaSkin.OUTPUT_HOTKEYS = {
menu = "menu",
fast_forward_hold = "fastForward",
fast_forward_toggle = "toggleFastForward",
}
DeltaSkin.MAPPING = {
portrait = { width = 1080, height = 1920 },
landscape = { width = 1920, height = 1080 },
}
DeltaSkin.SCREEN_WIDTH = 160
DeltaSkin.SCREEN_HEIGHT = 144
local function pick(t, key)
if type(t) ~= "table" then return nil end
local direct = t[key]
if direct ~= nil then return direct end
local want = tostring(key):lower()
for k, v in pairs(t) do
if tostring(k):lower() == want then return v end
end
return nil
end
local function numOr(v, fallback)
local n = tonumber(v)
if not n or n ~= n then return fallback end
return n
end
local function round(n)
return math.floor(numOr(n, 0) + 0.5)
end
local function isArray(t)
return type(t) == "table" and t[1] ~= nil
end
local function addWarning(list, text)
if type(list) ~= "table" then return end
for _, existing in ipairs(list) do
if existing == text then return end
end
list[#list + 1] = text
end
function DeltaSkin.findInfo(root)
local direct = root .. "/" .. DeltaSkin.INFO_NAME
if TouchSkin.readFile(direct) then return direct, "" end
local items = TouchSkin.listDir(root)
for _, name in ipairs(items) do
if tostring(name):lower() == DeltaSkin.INFO_NAME then
return root .. "/" .. name, ""
end
end
table.sort(items)
for _, name in ipairs(items) do
local nested = root .. "/" .. name .. "/" .. DeltaSkin.INFO_NAME
if TouchSkin.readFile(nested) then return nested, name .. "/" end
end
return nil
end
function DeltaSkin.resolveName(name, opts)
name = tostring(name or ""):gsub("\\", "/"):gsub("^%./", "")
if name == "" then return nil end
local names = opts and opts.names
if type(names) == "table" then
local want = name:lower()
for _, entry in ipairs(names) do
if tostring(entry):lower() == want then
name = tostring(entry)
break
end
end
end
return ((opts and opts.prefix) or "") .. name
end
function DeltaSkin.pickAsset(assets, opts, pdfFiles)
if type(assets) ~= "table" then return nil end
pdfFiles = pdfFiles or {}
local raster = {}
for _, key in ipairs(DeltaSkin.ASSET_LADDER) do
local name = pick(assets, key)
if key == "medium" and type(name) ~= "string" then name = pick(assets, "normal") end
if type(name) == "string" and name ~= "" then
if name:lower():match("%.pdf$") then
pdfFiles[#pdfFiles + 1] = name
else
raster[#raster + 1] = { key = key, name = name }
end
end
end
local resizable = pick(assets, "resizable")
if type(resizable) == "string" and resizable ~= "" then
if resizable:lower():match("%.pdf$") then
pdfFiles[#pdfFiles + 1] = resizable
else
raster[#raster + 1] = { key = "large", name = resizable }
end
end
if #raster == 0 then return nil end
local target = numOr(opts and opts.targetWidth, DeltaSkin.DEFAULT_TARGET_WIDTH)
local chosen
for _, cand in ipairs(raster) do
if not chosen and (DeltaSkin.ASSET_WIDTHS[cand.key] or 0) >= target then
chosen = cand.name
end
end
if not chosen then chosen = raster[#raster].name end
return DeltaSkin.resolveName(chosen, opts)
end
function DeltaSkin.mergeEdges(base, item)
local out = { top = 0, bottom = 0, left = 0, right = 0 }
for _, side in ipairs({ "top", "bottom", "left", "right" }) do
local v = pick(item, side)
if v == nil then v = pick(base, side) end
out[side] = numOr(v, 0)
end
return out
end
function DeltaSkin.representation(reps, orient)
for _, device in ipairs(DeltaSkin.DEVICE_ORDER) do
local dev = pick(reps, device)
if type(dev) == "table" then
for _, display in ipairs(DeltaSkin.DISPLAY_ORDER) do
local shown = pick(dev, display)
if type(shown) == "table" then
local obj = pick(shown, orient)
if type(obj) == "table" then return obj, device, display end
end
end
local flat = pick(dev, orient)
if type(flat) == "table" and (pick(flat, "items") or pick(flat, "mappingSize")) then
return flat, device, nil
end
end
end
return nil
end
function DeltaSkin.directionalInputs(inputs)
if type(inputs) ~= "table" or isArray(inputs) then return nil end
local out, found = {}, 0
for _, side in ipairs(DeltaSkin.SIDES) do
local v = pick(inputs, side)
if type(v) == "string" then
local lower = v:lower()
local mapped = DeltaSkin.INPUTS[lower]
if not mapped and lower:find(side, 1, true) then mapped = side end
if mapped then
out[side] = mapped
found = found + 1
end
end
end
if found >= 2 then return out end
return nil
end
function DeltaSkin.specFor(inputs)
local parts = {}
local function add(v)
if type(v) ~= "string" then return end
local mapped = DeltaSkin.INPUTS[v:lower()]
if mapped then parts[#parts + 1] = mapped end
end
if type(inputs) == "string" then
add(inputs)
elseif type(inputs) == "table" then
if isArray(inputs) then
for _, v in ipairs(inputs) do add(v) end
else
local keys = {}
for k in pairs(inputs) do keys[#keys + 1] = tostring(k) end
table.sort(keys)
for _, k in ipairs(keys) do add(inputs[k]) end
end
end
if #parts == 0 then return "nul" end
return table.concat(parts, "|")
end
function DeltaSkin.screenRect(obj, mapW, mapH)
local frame
local screens = pick(obj, "screens")
if type(screens) == "table" and type(screens[1]) == "table" then
frame = pick(screens[1], "outputFrame")
end
if type(frame) ~= "table" then frame = pick(obj, "gameScreenFrame") end
if type(frame) ~= "table" then return nil end
local w = numOr(pick(frame, "width"), 0)
local h = numOr(pick(frame, "height"), 0)
if w <= 0 or h <= 0 then return nil end
return {
x = numOr(pick(frame, "x"), 0) / mapW,
y = numOr(pick(frame, "y"), 0) / mapH,
w = w / mapW, h = h / mapH,
}
end
function DeltaSkin.addItem(page, item, baseEdges, mapW, mapH)
if type(item) ~= "table" then return end
local frame = pick(item, "frame")
if type(frame) ~= "table" then return end
local fw = numOr(pick(frame, "width"), 0)
local fh = numOr(pick(frame, "height"), 0)
if fw <= 0 or fh <= 0 then return end
local fx = numOr(pick(frame, "x"), 0)
local fy = numOr(pick(frame, "y"), 0)
local edges = DeltaSkin.mergeEdges(baseEdges, pick(item, "extendedEdges"))
local cx, cy = (fx + fw * 0.5) / mapW, (fy + fh * 0.5) / mapH
local w, h = fw / mapW, fh / mapH
local reachLeft = 1 + edges.left / (fw * 0.5)
local reachRight = 1 + edges.right / (fw * 0.5)
local reachUp = 1 + edges.top / (fh * 0.5)
local reachDown = 1 + edges.bottom / (fh * 0.5)
local inputs = pick(item, "inputs")
local dirs = DeltaSkin.directionalInputs(inputs)
if dirs then
local base = {
x = cx, y = cy, rangeX = w * 0.5, rangeY = h * 0.5,
rangeMod = 1, alphaMod = page.alphaMod, shape = "rect",
reachLeft = reachLeft, reachRight = reachRight,
reachUp = reachUp, reachDown = reachDown,
}
for _, ctl in ipairs(TouchSkin.expandDirectional(base, dirs)) do
page.controls[#page.controls + 1] = ctl
end
return
end
local shape = tostring(pick(item, "mask") or ""):lower() == "circle" and "radial" or "rect"
local ctl = TouchSkin.newControl(DeltaSkin.specFor(inputs), cx, cy, w, h, shape)
ctl.alphaMod = page.alphaMod
ctl.reachLeft, ctl.reachRight = reachLeft, reachRight
ctl.reachUp, ctl.reachDown = reachUp, reachDown
page.controls[#page.controls + 1] = ctl
end
function DeltaSkin.buildPage(obj, orient, opts, warnings, pdfFiles)
local mapping = pick(obj, "mappingSize")
local mapW = numOr(pick(mapping, "width"), 0)
local mapH = numOr(pick(mapping, "height"), 0)
if mapW <= 0 or mapH <= 0 then
mapW, mapH = 320, 240
addWarning(warnings, orient .. " has no mappingSize; assuming 320x240")
end
local page = {
name = orient,
orient = orient,
imagePath = DeltaSkin.pickAsset(pick(obj, "assets"), opts, pdfFiles),
fullScreen = true,
normalized = true,
pixelCoords = false,
rangeMod = 1,
alphaMod = pick(obj, "translucent") == true and 0.7 or 1,
aspect = mapW / mapH,
aspectFromCfg = false,
rect = { x = 0, y = 0, w = 1, h = 1 },
mappingWidth = mapW,
mappingHeight = mapH,
controls = {},
}
local screen = DeltaSkin.screenRect(obj, mapW, mapH)
if screen then
page.viewport = screen
page.viewportFill = false
end
local baseEdges = pick(obj, "extendedEdges")
local items = pick(obj, "items")
if type(items) == "table" then
for _, item in ipairs(items) do
DeltaSkin.addItem(page, item, baseEdges, mapW, mapH)
end
end
return page
end
function DeltaSkin.systemOf(gameType)
if type(gameType) ~= "string" or gameType == "" then return nil end
for _, prefix in ipairs(DeltaSkin.GAME_TYPE_PREFIXES) do
if gameType:sub(1, #prefix) == prefix then
return gameType:sub(#prefix + 1):lower()
end
end
return nil
end
function DeltaSkin.parse(text, opts)
opts = opts or {}
local info, err = Json.decode(tostring(text or ""), DeltaSkin.MAX_INFO_BYTES)
if type(info) ~= "table" then
return nil, "info.json does not parse: " .. tostring(err)
end
local gameType = info.gameTypeIdentifier
if type(gameType) ~= "string" or gameType == "" then
return nil, "old GBA4iOS skin, not supported: info.json has no gameTypeIdentifier"
end
if gameType:lower():find("gba4ios", 1, true) then
return nil, "old GBA4iOS skin, not supported"
end
local system = DeltaSkin.systemOf(gameType)
if not system then
return nil, "not a Delta skin: unknown gameTypeIdentifier " .. gameType
end
local warnings = {}
if not DeltaSkin.SYSTEMS[system] then
addWarning(warnings, "this skin is for " .. system .. ", not Game Boy")
end
local reps = info.representations
if type(reps) ~= "table" then return nil, "info.json has no representations" end
local pdfFiles, pages = {}, {}
for _, orient in ipairs(DeltaSkin.ORIENTATIONS) do
local obj = DeltaSkin.representation(reps, orient)
if obj then
local page = DeltaSkin.buildPage(obj, orient, opts, warnings, pdfFiles)
page.index = #pages + 1
pages[#pages + 1] = page
end
end
if #pages == 0 then return nil, "info.json has no usable representation" end
if #pdfFiles > 0 then
addWarning(warnings, "PDF artwork cannot be imported yet")
end
return {
pages = pages,
name = info.name,
author = info.author,
notes = info.notes,
format = "delta",
system = system,
identifier = info.identifier,
warnings = warnings,
pdfFiles = pdfFiles,
}
end
function DeltaSkin.needsConversion(skin)
if type(skin) ~= "table" then return nil end
local files = skin.pdfFiles
if type(files) ~= "table" or #files == 0 then return nil end
for _, page in ipairs(skin.pages or {}) do
if page.imagePath then return nil end
end
return { pdfOnly = true, files = files }
end
function DeltaSkin.outputInputs(ctl)
local out = {}
for _, b in ipairs(ctl.buttons or {}) do out[#out + 1] = b end
for _, h in ipairs(ctl.hotkeys or {}) do
local mapped = DeltaSkin.OUTPUT_HOTKEYS[h]
if mapped then out[#out + 1] = mapped end
end
return out
end
function DeltaSkin.buildRepresentation(page, orient, warnings)
local map = DeltaSkin.MAPPING[orient] or DeltaSkin.MAPPING.portrait
local mapW, mapH = map.width, map.height
local items, files = {}, {}
for _, ctl in ipairs(page.controls or {}) do
local names = DeltaSkin.outputInputs(ctl)
if ctl.sector and ctl.sector ~= 1 then
names = {}
elseif ctl.sector and ctl.areaNames then
local TouchSkin = require("src.core.TouchSkin")
local dirs = {}
for _, side in ipairs({ "up", "down", "left", "right" }) do
local mapped = TouchSkin.GB_BUTTONS[tostring(ctl.areaNames[side]):lower()]
if mapped then dirs[side] = mapped end
end
names = next(dirs) and dirs or {}
end
if names.up or names.down or names.left or names.right or #names > 0 then
local item = {
inputs = names,
frame = {
x = round((ctl.x - ctl.rangeX) * mapW),
y = round((ctl.y - ctl.rangeY) * mapH),
width = round(ctl.rangeX * 2 * mapW),
height = round(ctl.rangeY * 2 * mapH),
},
}
if ctl.shape == "radial" then item.mask = "circle" end
local edges, any = {}, false
local pairsList = {
{ key = "left", reach = ctl.reachLeft, half = ctl.rangeX * mapW },
{ key = "right", reach = ctl.reachRight, half = ctl.rangeX * mapW },
{ key = "top", reach = ctl.reachUp, half = ctl.rangeY * mapH },
{ key = "bottom", reach = ctl.reachDown, half = ctl.rangeY * mapH },
}
for _, side in ipairs(pairsList) do
local reach = numOr(side.reach, 1)
if reach ~= 1 then
edges[side.key] = round((reach - 1) * side.half)
any = true
end
end
if any then item.extendedEdges = edges end
items[#items + 1] = item
elseif ctl.imagePath then
addWarning(warnings, "per-button art is dropped: Delta keeps all art in one image")
end
end
local obj = {
items = items,
mappingSize = { width = mapW, height = mapH },
extendedEdges = { top = 0, bottom = 0, left = 0, right = 0 },
translucent = false,
}
if page.imagePath then
obj.assets = {
small = page.imagePath, medium = page.imagePath, large = page.imagePath,
}
files[#files + 1] = page.imagePath
end
if page.viewport then
obj.screens = { {
inputFrame = { x = 0, y = 0,
width = DeltaSkin.SCREEN_WIDTH, height = DeltaSkin.SCREEN_HEIGHT },
outputFrame = {
x = round(page.viewport.x * mapW), y = round(page.viewport.y * mapH),
width = round(page.viewport.w * mapW), height = round(page.viewport.h * mapH),
},
} }
end
return obj, files
end
function DeltaSkin.build(skin, opts)
if type(skin) ~= "table" or not skin.pages or not skin.pages[1] then
return nil, "skin has no pages"
end
opts = opts or {}
local standard, edgeToEdge = {}, {}
local assets, warnings, used = {}, {}, {}
for _, page in ipairs(skin.pages) do
local orient = TouchSkin.pageOrient(page)
if orient ~= "portrait" and orient ~= "landscape" then
orient = (numOr(page.aspect, 1) < 1) and "portrait" or "landscape"
end
if not used[orient] then
used[orient] = true
local obj, files = DeltaSkin.buildRepresentation(page, orient, warnings)
standard[orient] = obj
edgeToEdge[orient] = obj
for _, rel in ipairs(files) do assets[#assets + 1] = rel end
end
end
local system = tostring(opts.system or "gbc")
local info = {
name = skin.name or skin.id or "skin",
identifier = opts.identifier
or ("com.gen1recomp.skin." .. tostring(skin.id or "skin")),
gameTypeIdentifier = DeltaSkin.GAME_TYPE_PREFIXES[1] .. system,
debug = false,
representations = { iphone = { standard = standard, edgeToEdge = edgeToEdge } },
}
return info, assets, warnings
end
function DeltaSkin.encodeInfo(skin, opts)
local info, assets, warnings = DeltaSkin.build(skin, opts)
if not info then return nil, assets end
return Json.encode(info), assets, warnings
end
return DeltaSkin
+36 -2
View File
@@ -34,6 +34,7 @@ end
function Game:load()
self.data = Data
self.sessionStartedAt = os.time()
Data:load()
-- Mods are a native engine subsystem. They load after the verified ROM
@@ -155,6 +156,7 @@ function Game:makeTitleState()
onNewGame = function()
while self.stack:top() do self.stack:pop() end
-- New Game keeps the standalone options.lua preferences
self.sessionStartedAt = os.time()
self.save = SaveData.newGame(self:bootConfig())
-- no bucket carry-over: mod state from an abandoned session must
-- not leak into a fresh slot; mods seed via save.created instead
@@ -356,6 +358,7 @@ function Game:update(dt)
-- reason: they are presentational, so fast-forward must not speed them up
require("src.render.Pipelines").update(dt)
pcall(function() require("src.core.DiscordPresence").update(dt) end)
self:updateSync(dt)
-- Steady-state memory backstop: advance the incremental collector one
-- small step every rendered frame. The heavy GPU objects are now freed
-- explicitly (map eviction, battle exit, canvas/renderer swaps), so this
@@ -1178,11 +1181,41 @@ function Game:writeSave()
-- stamp here so the save.writing payload carries the exact meta the
-- file gets; mods snapshot runtime state into their namespace now
self.save.meta = SaveData.buildMeta(
self.modStatus and self.modStatus.loaded, self.save.meta)
self.modStatus and self.modStatus.loaded, self.save.meta,
self.sessionStartedAt)
if ModRuntime.wants("save.writing") then
ModRuntime.emit("save.writing", { save = self.save, meta = self.save.meta })
end
return SaveData.save(self.save)
local written = SaveData.save(self.save)
if written then
local eng = self:syncEngine()
if eng then pcall(eng.noteSaveWritten, eng) end
end
return written
end
function Game:syncEngine()
if self._syncOff then return nil end
if self._syncEngineRef then return self._syncEngineRef end
local ok, SyncEngine = pcall(require, "src.sync.SyncEngine")
if not ok or type(SyncEngine) ~= "table" then
self._syncOff = true
return nil
end
local eng = SyncEngine.shared()
if not eng then
self._syncOff = true
return nil
end
self._syncEngineRef = eng
return eng
end
function Game:updateSync(dt)
local eng = self:syncEngine()
if not eng then return end
if not (eng.state.enabled and eng:linked()) and not eng:busy() then return end
pcall(eng.update, eng, dt)
end
-- Persist options.lua only (Options menu / hotkeys 2-5). Keeps settings
@@ -1241,6 +1274,7 @@ function Game:applyOptions(opts)
end
function Game:restoreSave(loaded, recovered, opts)
self.sessionStartedAt = os.time()
if ModRuntime.wants("save.loading") then
ModRuntime.emit("save.loading", { raw = loaded })
end
+23 -9
View File
@@ -36,6 +36,7 @@ local World = require("src.world.gen2.World")
-- other engine file, so a call site here is the same call site Gen 1 has.
local ModRuntime = require("src.mods.Runtime")
local GameViewport = require("src.render.GameViewport")
local Playfield = require("src.render.Playfield")
-- Only for the mod-supplied save migrations and the mods-changed report, which
-- are keyed off save.meta and know nothing about a generation; Gold's own save
-- IO is src/core/gen2/Save.lua.
@@ -195,6 +196,7 @@ end
function Game2:persistOptions()
pcall(Save.saveOptions, self.options)
end
Game2.writeOptions = Game2.persistOptions
-- Point the loader's mod.save backing at this save's modData so per-mod state
-- persists with the slot. Same contract and same three call sites as Gen 1
@@ -1160,7 +1162,8 @@ end
-- (Renderer:endFrame's Sp). Following the zoom used to shrink the LCD grid to
-- one screen pixel a cell out at survey range.
function Game2:pixelScale(w, h)
return math.max(1, math.floor(math.min(w / 160, h / 144)))
local _, _, pw, ph = Playfield.rect(w, h)
return math.max(1, math.floor(math.min(pw / 160, ph / 144)))
end
-- A window-sized canvas the whole frame is composed into, so the post passes
@@ -1277,7 +1280,8 @@ end
function Game2:blitZones(canvas, zones, w, h)
local G = love.graphics
local GbcPalette = require("src.render.GbcPalette")
local sx, sy = w / 160, h / 144
local px, py, pw, ph = Playfield.rect(w, h)
local sx, sy = pw / 160, ph / 144
G.setColor(1, 1, 1, 1)
for _, z in ipairs(zones) do
-- a colors == false zone is the true-colour opt-out; anything the shader
@@ -1296,11 +1300,11 @@ function Game2:blitZones(canvas, zones, w, h)
-- whose contract differs from Gen 1's. Whole-screen and half-screen zones
-- come out of this at exactly the pixels the plain floor/ceil pair gave
-- them, so the vanilla picture is untouched.
local zx, zy = (z.x or 0) * sx, (z.y or 0) * sy
local x1 = math.floor(math.max(zx, 0))
local y1 = math.floor(math.max(zy, 0))
local x2 = math.ceil(math.min(zx + (z.w or 160) * sx, w))
local y2 = math.ceil(math.min(zy + (z.h or 144) * sy, h))
local zx, zy = px + (z.x or 0) * sx, py + (z.y or 0) * sy
local x1 = math.floor(math.max(zx, px))
local y1 = math.floor(math.max(zy, py))
local x2 = math.ceil(math.min(zx + (z.w or 160) * sx, px + pw))
local y2 = math.ceil(math.min(zy + (z.h or 144) * sy, py + ph))
if x2 > x1 and y2 > y1 then
G.setScissor(x1, y1, x2 - x1, y2 - y1)
G.draw(canvas, 0, 0)
@@ -1402,7 +1406,7 @@ function Game2:drawViewportFrame()
scene = self:presentCanvas(1, w, h)
end
if not scene then
self:drawScene(w, h)
self:drawContained(w, h)
self:drawHud(w, h)
return
end
@@ -1413,7 +1417,7 @@ function Game2:drawViewportFrame()
G.origin()
G.setCanvas(scene)
G.clear(0, 0, 0, 1)
self:drawScene(w, h)
self:drawContained(w, h)
G.setCanvas(previous)
if composing and self:compose(scene, zones, w, h) then
@@ -1462,6 +1466,8 @@ function Game2:drawViewportFrame()
generation = 2,
}) == true
if not outputHandled then
local cx, cy, cw, ch = Playfield.cutout(w, h)
if cx then G.setScissor(cx, cy, cw, ch) end
if fx then
GBCFX.present(source, self:pixelScale(w, h))
else
@@ -1469,6 +1475,7 @@ function Game2:drawViewportFrame()
G.draw(source, 0, 0)
G.setShader()
end
if cx then G.setScissor() end
end
end
G.pop()
@@ -1499,6 +1506,13 @@ function Game2:textboxPaper()
return nil
end
function Game2:drawContained(w, h)
local pw, ph = Playfield.push(w, h)
local ok, err = pcall(self.drawScene, self, pw, ph)
Playfield.pop()
if not ok then error(err, 0) end
end
function Game2:drawScene(w, h)
local G = love.graphics
-- render.compose reads this after the scene is drawn; the plain overworld
+181
View File
@@ -350,11 +350,23 @@ local function haveBridge()
return osName == "Android" or osName == "iOS" or osName == "UWP"
end
local function haveRequestBridge()
if not (love and love.system and type(love.system.httpRequest) == "function") then
return false
end
local osName = love.system.getOS and love.system.getOS()
return osName == "Android" or osName == "iOS" or osName == "UWP"
end
-- Is any transport available at all? Callers gate on this, never on curl.
function HostShell.canFetch()
return HostShell.haveCurl() or haveBridge()
end
function HostShell.canHttpRequest()
return (HostShell.haveCurl() or haveRequestBridge()) and true or false
end
-- Download url to an absolute host path. Returns true, or nil plus an error.
-- The curl branch deliberately ignores curl's exit code, as the download paths
-- always did: callers judge the result by the file they got.
@@ -557,4 +569,173 @@ function HostShell.httpPost(url, body, contentType, userAgent, maxTime)
return nil, "no POST transport on this platform"
end
local function requestHeaderList(headers)
local out = {}
if type(headers) == "table" then
if #headers > 0 then
for _, line in ipairs(headers) do
if type(line) == "string" then out[#out + 1] = line end
end
else
local names = {}
for name in pairs(headers) do names[#names + 1] = tostring(name) end
table.sort(names)
for _, name in ipairs(names) do
out[#out + 1] = name .. ": " .. tostring(headers[name])
end
end
end
for _, line in ipairs(out) do
if line:find("[\r\n]") or not line:find(":", 1, true) then return nil end
end
return out
end
local BRIDGE_METHODS = { GET = true, POST = true, PUT = true, DELETE = true }
local function requestHeaderPairs(lines)
local out = {}
for _, line in ipairs(lines) do
local name, value = line:match("^%s*([^:]-)%s*:%s*(.-)%s*$")
if not name or name == "" then return nil end
if name:find("[\r\n]") or value:find("[\r\n]") then return nil end
out[#out + 1] = name
out[#out + 1] = value
end
return out
end
local function bridgeRequest(url, method, headers, body, userAgent)
if not BRIDGE_METHODS[method] then
return nil, "no request transport for " .. method .. " on this platform"
end
local fields = requestHeaderPairs(headers)
if not fields then return nil, "bad request header" end
local ok, envelope = pcall(love.system.httpRequest, url, method, fields,
body, userAgent)
if not ok or type(envelope) ~= "string" or envelope == "" then
return nil, "this app build cannot make signed requests: update the app to use save sync"
end
local head, rest = envelope:match("^([^\n]*)\n(.*)$")
if not head then
return nil, fetchError(url, nil, "unreadable reply from the network bridge")
end
local status = tonumber(head:match("^STATUS (%d+)$"))
if status then return rest or "", nil, status end
return nil, fetchError(url, nil, head:match("^ERROR (.*)$") or head)
end
local requestSeq = 0
local function requestStagingPath(kind)
local dir
if love and love.filesystem and love.filesystem.getSaveDirectory then
local ok, saveDir = pcall(love.filesystem.getSaveDirectory)
if ok and type(saveDir) == "string" and saveDir ~= "" then dir = saveDir end
end
if not dir then
dir = os.getenv("TEMP") or os.getenv("TMP")
if not dir or dir == "" then dir = os.getenv("TMPDIR") or "/tmp" end
end
local sep = dir:find("\\") and "\\" or "/"
requestSeq = requestSeq + 1
return dir .. sep .. ("gen1recomp-req-%s-%d-%d-%d.tmp"):format(
kind, os.time() % 1000000, requestSeq, math.random(0, 999999))
end
local function writeStagingFile(kind, text)
local path = requestStagingPath(kind)
local file, openErr = io.open(path, "wb")
if not file then
return nil, "could not create the request " .. kind .. ": " .. tostring(openErr)
end
local wrote, writeErr = pcall(function()
assert(file:write(text))
assert(file:close())
end)
if not wrote then
pcall(function() file:close() end)
pcall(os.remove, path)
return nil, "could not write the request " .. kind .. ": " .. tostring(writeErr)
end
return path
end
function HostShell.httpRequest(url, opts)
opts = type(opts) == "table" and opts or {}
if type(url) ~= "string" or url == "" then return nil, "missing url" end
local method = tostring(opts.method or "GET"):upper()
if not method:match("^%u+$") then return nil, "bad request method" end
local headers = requestHeaderList(opts.headers)
if not headers then return nil, "bad request header" end
local body = opts.body
if body ~= nil and type(body) ~= "string" then return nil, "bad request body" end
local userAgent = opts.userAgent or "gen1recomp"
local maxTime = tonumber(opts.maxTime) or 30
if not HostShell.haveCurl() then
if haveRequestBridge() then
return bridgeRequest(url, method, headers, body, userAgent)
end
if method == "GET" and #headers == 0 then
local got, err = HostShell.httpGet(url, userAgent, opts.accept, maxTime)
if not got then return nil, err end
return got, nil, 200
end
if haveBridge() then
return nil, "this app build cannot make signed requests: update the app to use save sync"
end
return nil, "no request transport on this platform"
end
local bodyPath, stageErr
if body then
bodyPath, stageErr = writeStagingFile("body", body)
if not bodyPath then return nil, stageErr end
end
local lines = { "User-Agent: " .. userAgent }
for _, line in ipairs(headers) do lines[#lines + 1] = line end
if body then
lines[#lines + 1] = "Content-Length: " .. tostring(#body)
end
local headerPath
headerPath, stageErr = writeStagingFile("head",
table.concat(lines, "\n") .. "\n")
if not headerPath then
if bodyPath then pcall(os.remove, bodyPath) end
return nil, stageErr
end
local function cleanup()
if bodyPath then pcall(os.remove, bodyPath) end
pcall(os.remove, headerPath)
end
local cmd = ("curl -sSL --proto =http,https --proto-redir =http,https "
.. "--connect-timeout 10 --max-time %d "):format(maxTime)
.. "-X " .. HostShell.quote(method) .. " "
.. "-H " .. HostShell.quote("@" .. headerPath) .. " "
if body then
cmd = cmd .. "--data-binary " .. HostShell.quote("@" .. bodyPath) .. " "
end
cmd = cmd .. "-w " .. HostShell.quote(HTTP_MARK_FMT) .. " "
.. HostShell.quote(url) .. " 2>&1"
local pipe = HostShell.popen(cmd)
if not pipe then
cleanup()
return nil, "could not run curl"
end
local readOk, out = pcall(function() return pipe:read("*a") end)
HostShell.pclose(pipe)
cleanup()
if not readOk then
return nil, fetchError(url, nil, tostring(out))
end
local respBody, status, noise = splitCurlOutput(out)
if not status then return nil, fetchError(url, nil, noise) end
return respBody or "", nil, status
end
return HostShell
+28 -2
View File
@@ -356,6 +356,8 @@ function SaveData.defaultOptions()
-- rewind presentation preferences.
dateFormat = "device", -- device | dmy | mdy | ymd
timeFormat = "device", -- device | 24h | 12h
saveSync = { enabled = false, lastSyncAt = 0, revs = {}, stamps = {},
pendingConflicts = {} },
}
end
@@ -1013,6 +1015,22 @@ function SaveData.listSlots(version)
return out
end
function SaveData.readSlotSource(version, slotId, injectedFs)
version = version or GameVersion.get()
if not knownVersion(version) or type(slotId) ~= "string" then return nil end
local fs = persistFs(injectedFs)
local main, bak, tmp = slotNames(version, slotId)
for _, name in ipairs({ main, tmp, bak }) do
if fs.getInfo(name) then
local body = fs.read(name)
if type(body) == "string" and body ~= "" then
if SaveSerializer.decode(body) then return body end
end
end
end
return nil
end
-- Give a registered slot a custom label (#205: "a way to name save slots so
-- you can see that in the launcher"). The label lives in the options
-- registry next to list/active, never in the save file itself, so renaming
@@ -1327,7 +1345,7 @@ end
-- loaded list sorted by id and is the ground truth for the load-time
-- mod-set diff. A nil mods list keeps the previous stamp's set so a
-- headless writer (the save editor) never wipes it.
function SaveData.buildMeta(mods, previous)
function SaveData.buildMeta(mods, previous, sessionStart)
local list
if mods ~= nil then
list = {}
@@ -1338,10 +1356,18 @@ function SaveData.buildMeta(mods, previous)
else
list = (type(previous) == "table" and previous.mods) or {}
end
local started = tonumber(sessionStart)
if not started or started ~= started or started <= 0
or started == math.huge then
started = type(previous) == "table" and tonumber(previous.sessionStart) or nil
end
local savedAt = os.time()
if started and started > savedAt then started = savedAt end
return {
format = Version.saveFormat,
engine = Version.engine,
savedAt = os.time(),
savedAt = savedAt,
sessionStart = started,
playthroughId = type(previous) == "table" and previous.playthroughId or nil,
mods = list,
}
+6 -4
View File
@@ -616,13 +616,15 @@ local function exitControl(self, ctl)
for _, action in ipairs(ctl.hotkeys) do fireHotkey(self, action, false, ctl) end
end
function skinHitSet(self, x, y)
function skinHitSet(self, x, y, prev)
local page = TouchSkin.page()
if not page then return nil end
local ww, wh, ox, oy = surfaceRect()
local set = nil
for _, ctl in ipairs(page.controls) do
if not ctl.decorative and TouchSkin.hits(page, ctl, ww, wh, x, y, ox, oy) then
local held = (prev and prev[ctl]) == true
if not ctl.decorative
and TouchSkin.hits(page, ctl, ww, wh, x, y, ox, oy, held) then
set = set or {}
set[ctl] = true
end
@@ -665,7 +667,7 @@ function TouchControls:touchpressed(id, x, y)
return
end
if TouchSkin.active then
local set = skinHitSet(self, x, y)
local set = skinHitSet(self, x, y, nil)
if not set then return end
local touch = { control = "skin" }
self.touches[id] = touch
@@ -698,7 +700,7 @@ function TouchControls:touchmoved(id, x, y)
local touch = self.touches[id]
if not touch then return end
if touch.control == "skin" then
applySkinSet(self, touch, skinHitSet(self, x, y))
applySkinSet(self, touch, skinHitSet(self, x, y, touch.set))
return
end
-- only the d-pad tracks movement (slide between directions without
+443 -46
View File
@@ -2,6 +2,7 @@ local TouchSkin = {}
TouchSkin.BUNDLED_ROOT = "assets/skins"
TouchSkin.USER_ROOT = "skins"
TouchSkin.EXPORT_ROOT = "skins/_export"
TouchSkin.GB_BUTTONS = {
a = "a", b = "b", start = "start", select = "select",
@@ -83,6 +84,110 @@ local function parseBinds(spec)
return buttons, hotkeys, keys, decorative
end
TouchSkin.AREA_DEFAULTS = {
dpad_area = { up = "up", down = "down", left = "left", right = "right" },
abxy_area = { up = "x", down = "b", left = "y", right = "a" },
analog_left = { up = "up", down = "down", left = "left", right = "right" },
analog_right = { up = "up", down = "down", left = "left", right = "right" },
}
local DIRECTIONAL_CELLS = {
{ col = 1, row = 1, h = "left", v = "up" },
{ col = 2, row = 1, v = "up" },
{ col = 3, row = 1, h = "right", v = "up" },
{ col = 1, row = 2, h = "left" },
{ col = 3, row = 2, h = "right" },
{ col = 1, row = 3, h = "left", v = "down" },
{ col = 2, row = 3, v = "down" },
{ col = 3, row = 3, h = "right", v = "down" },
}
local function outwardReach(reach)
return 1 + 3 * ((num(reach, 1)) - 1)
end
function TouchSkin.expandDirectional(base, names)
names = names or {}
local cellX = math.abs(num(base.rangeX, 0.05)) / 3
local cellY = math.abs(num(base.rangeY, 0.05)) / 3
local out = {}
for _, cell in ipairs(DIRECTIONAL_CELLS) do
local parts = {}
if cell.h and names[cell.h] then parts[#parts + 1] = names[cell.h] end
if cell.v and names[cell.v] then parts[#parts + 1] = names[cell.v] end
local spec = #parts > 0 and table.concat(parts, "|") or "nul"
local ctl = TouchSkin.newControl(spec,
num(base.x, 0.5) + (cell.col - 2) * cellX * 2,
num(base.y, 0.5) + (cell.row - 2) * cellY * 2,
cellX * 2, cellY * 2, "rect")
ctl.rangeMod = num(base.rangeMod, 1)
ctl.alphaMod = num(base.alphaMod, 1)
ctl.reachLeft = cell.col == 1 and outwardReach(base.reachLeft) or 1
ctl.reachRight = cell.col == 3 and outwardReach(base.reachRight) or 1
ctl.reachUp = cell.row == 1 and outwardReach(base.reachUp) or 1
ctl.reachDown = cell.row == 3 and outwardReach(base.reachDown) or 1
ctl.pixelCoords = base.pixelCoords
ctl.movable = base.movable
ctl.exclusive = base.exclusive
out[#out + 1] = ctl
end
return out
end
local SECTOR_CELLS = {
{ h = "right" },
{ h = "right", v = "down" },
{ v = "down" },
{ h = "left", v = "down" },
{ h = "left" },
{ h = "left", v = "up" },
{ v = "up" },
{ h = "right", v = "up" },
}
TouchSkin.SECTOR_SPAN = math.pi / 4
function TouchSkin.sectorHit(sector, dx, dy)
local span = TouchSkin.SECTOR_SPAN
local start = (sector - 1) * span - span * 0.5
local a = (math.atan2(dy, dx) - start) % (math.pi * 2)
return a < span
end
function TouchSkin.expandSectors(base, names)
names = names or {}
local out = {}
for i, cell in ipairs(SECTOR_CELLS) do
local parts = {}
if cell.h and names[cell.h] then parts[#parts + 1] = names[cell.h] end
if cell.v and names[cell.v] then parts[#parts + 1] = names[cell.v] end
local spec = #parts > 0 and table.concat(parts, "|") or "nul"
local ctl = TouchSkin.newControl(spec, num(base.x, 0.5), num(base.y, 0.5),
math.abs(num(base.rangeX, 0.05)) * 2, math.abs(num(base.rangeY, 0.05)) * 2,
base.shape)
ctl.sector = i
ctl.areaKind = base.areaKind
ctl.areaNames = base.areaNames
ctl.rangeMod = num(base.rangeMod, 1)
ctl.alphaMod = num(base.alphaMod, 1)
ctl.reachLeft = num(base.reachLeft, 1)
ctl.reachRight = num(base.reachRight, 1)
ctl.reachUp = num(base.reachUp, 1)
ctl.reachDown = num(base.reachDown, 1)
ctl.pixelCoords = base.pixelCoords
ctl.movable = base.movable
ctl.exclusive = base.exclusive
out[#out + 1] = ctl
end
return out
end
local function areaSide(kv, prefix, side, fallback)
local v = kv[prefix .. "_" .. side]
if v == nil or trim(v) == "" then return fallback end
return trim(v)
end
local function parseDesc(kv, prefix, page)
local spec = kv[prefix]
if not spec then return nil end
@@ -116,9 +221,28 @@ local function parseDesc(kv, prefix, page)
imagePath = kv[prefix .. "_overlay"],
pressedImagePath = kv[prefix .. "_overlay_pressed"],
nextTarget = kv[prefix .. "_next_target"],
movable = toBool(kv[prefix .. "_movable"]) or nil,
exclusive = (toBool(kv[prefix .. "_exclusive"])
or toBool(kv[prefix .. "_range_mod_exclusive"])) or nil,
saturatePct = num(kv[prefix .. "_saturate_pct"], nil),
}
if ctl.imagePath == "" then ctl.imagePath = nil end
if ctl.pressedImagePath == "" then ctl.pressedImagePath = nil end
local normalized = kv[prefix .. "_normalized"]
if normalized ~= nil then ctl.pixelCoords = not toBool(normalized) end
local areaKind = trim(t[1]):lower()
local defaults = TouchSkin.AREA_DEFAULTS[areaKind]
if defaults then
ctl.areaKind = areaKind
ctl.areaNames = {
up = areaSide(kv, prefix, "up", defaults.up),
down = areaSide(kv, prefix, "down", defaults.down),
left = areaSide(kv, prefix, "left", defaults.left),
right = areaSide(kv, prefix, "right", defaults.right),
}
end
return ctl
end
@@ -127,7 +251,14 @@ function TouchSkin.parse(text)
local count = math.floor(num(kv.overlays, 0))
if count <= 0 then return nil, "no overlays" end
local pages = {}
local pages, warnings = {}, {}
local function warn(text)
for _, existing in ipairs(warnings) do
if existing == text then return end
end
warnings[#warnings + 1] = text
end
for i = 0, count - 1 do
local p = "overlay" .. i
local page = {
@@ -166,10 +297,34 @@ function TouchSkin.parse(text)
page.viewportExpand = toBool(kv[p .. "_viewport_expand"])
end
page.pixelCoords = not page.normalized
if page.pixelCoords and not page.imagePath then
page.pixelCoords = false
warn(page.name .. " has no base image: desc coordinates read as normalized")
end
local descs = math.floor(num(kv[p .. "_descs"], 0))
for d = 0, descs - 1 do
local ctl = parseDesc(kv, p .. "_desc" .. d, page)
if ctl then page.controls[#page.controls + 1] = ctl end
if not ctl then
warn(page.name .. " is missing desc " .. d)
elseif ctl.areaKind then
if ctl.imagePath or ctl.pressedImagePath then
local art = TouchSkin.newControl("nul", ctl.x, ctl.y,
ctl.rangeX * 2, ctl.rangeY * 2, ctl.shape)
art.imagePath = ctl.imagePath
art.pressedImagePath = ctl.pressedImagePath
art.rangeMod, art.alphaMod = ctl.rangeMod, ctl.alphaMod
art.pixelCoords = ctl.pixelCoords
art.movable, art.exclusive = ctl.movable, ctl.exclusive
page.controls[#page.controls + 1] = art
end
for _, cell in ipairs(TouchSkin.expandSectors(ctl, ctl.areaNames)) do
page.controls[#page.controls + 1] = cell
end
else
page.controls[#page.controls + 1] = ctl
end
end
pages[#pages + 1] = page
end
@@ -180,7 +335,7 @@ function TouchSkin.parse(text)
if not page.orient then page.orient = TouchSkin.pageOrient(page) end
end
return { pages = pages }
return { pages = pages, warnings = warnings }
end
local function readFile(path)
@@ -219,18 +374,26 @@ end
TouchSkin.NATIVE_NAME = "skin.lua"
TouchSkin.readFile = readFile
TouchSkin.listDir = listDir
TouchSkin.isDir = isDir
local function findConfig(root)
if readFile(root .. "/" .. TouchSkin.NATIVE_NAME) then
return root .. "/" .. TouchSkin.NATIVE_NAME, "native"
return root .. "/" .. TouchSkin.NATIVE_NAME, "native", ""
end
local named = { "overlay.cfg", "skin.cfg", "layout.cfg" }
for _, name in ipairs(named) do
if readFile(root .. "/" .. name) then return root .. "/" .. name, "retroarch" end
if readFile(root .. "/" .. name) then
return root .. "/" .. name, "retroarch", ""
end
end
local infoPath, prefix = require("src.core.DeltaSkin").findInfo(root)
if infoPath then return infoPath, "delta", prefix end
local items = listDir(root)
table.sort(items)
for _, name in ipairs(items) do
if name:match("%.cfg$") then return root .. "/" .. name, "retroarch" end
if name:match("%.cfg$") then return root .. "/" .. name, "retroarch", "" end
end
return nil
end
@@ -262,6 +425,7 @@ function TouchSkin.parseNative(text)
imagePath = raw.image,
fullScreen = raw.fullScreen ~= false,
normalized = true,
pixelCoords = false,
rangeMod = num(raw.rangeMod, 1),
alphaMod = num(raw.alphaMod, 1),
aspect = num(raw.aspect, DEFAULT_ASPECT),
@@ -283,7 +447,24 @@ function TouchSkin.parseNative(text)
end
for _, c in ipairs(raw.controls or {}) do
local buttons, hotkeys, keys, decorative = parseBinds(c.bind or "nul")
local sector = tonumber(c.sector)
if sector then
sector = math.floor(sector)
if sector < 1 or sector > #SECTOR_CELLS then sector = nil end
end
local areaNames
if type(c.areaNames) == "table" then
areaNames = {}
for _, side in ipairs({ "up", "down", "left", "right" }) do
if type(c.areaNames[side]) == "string" then
areaNames[side] = c.areaNames[side]
end
end
end
page.controls[#page.controls + 1] = {
sector = sector,
areaKind = type(c.areaKind) == "string" and c.areaKind or nil,
areaNames = areaNames,
spec = tostring(c.bind or "nul"),
buttons = buttons, hotkeys = hotkeys, keys = keys,
decorative = decorative,
@@ -298,6 +479,8 @@ function TouchSkin.parseNative(text)
imagePath = c.image,
pressedImagePath = c.imagePressed,
nextTarget = c.nextTarget,
movable = c.movable == true or nil,
exclusive = c.exclusive == true or nil,
}
end
if not page.orient then page.orient = TouchSkin.pageOrient(page) end
@@ -355,6 +538,14 @@ function TouchSkin.toNative(skin)
image = ctl.imagePath,
imagePressed = ctl.pressedImagePath,
nextTarget = ctl.nextTarget,
movable = ctl.movable or nil,
exclusive = ctl.exclusive or nil,
sector = ctl.sector,
areaKind = ctl.areaKind,
areaNames = ctl.areaNames and {
up = ctl.areaNames.up, down = ctl.areaNames.down,
left = ctl.areaNames.left, right = ctl.areaNames.right,
} or nil,
}
end
out.pages[#out.pages + 1] = p
@@ -379,14 +570,44 @@ local function loadImage(path)
return img
end
local function pixelScalePending(page)
if page.pixelCoords then return true end
for _, ctl in ipairs(page.controls or {}) do
if ctl.pixelCoords then return true end
end
return false
end
local function applyPixelScale(page)
if not pixelScalePending(page) then return true end
if not page.image or not page.image.getDimensions then return false end
local iw, ih = page.image:getDimensions()
if not iw or not ih or iw <= 0 or ih <= 0 then return false end
for _, ctl in ipairs(page.controls or {}) do
local pixel = ctl.pixelCoords
if pixel == nil then pixel = page.pixelCoords end
if pixel then
ctl.x, ctl.y = ctl.x / iw, ctl.y / ih
ctl.rangeX, ctl.rangeY = ctl.rangeX / iw, ctl.rangeY / ih
ctl.pixelCoords = false
end
end
page.pixelCoords = false
return true
end
function TouchSkin.load(root, id)
local cfgPath, format = findConfig(root)
if not cfgPath then return nil, "no skin.lua or .cfg in " .. root end
local cfgPath, format, prefix = findConfig(root)
if not cfgPath then return nil, "no skin.lua, .cfg or info.json in " .. root end
local text = readFile(cfgPath)
if not text then return nil, "unreadable " .. cfgPath end
local skin, err
if format == "native" then
skin, err = TouchSkin.parseNative(text)
elseif format == "delta" then
local dir = cfgPath:match("^(.*)/[^/]+$") or root
skin, err = require("src.core.DeltaSkin").parse(text,
{ prefix = prefix or "", names = listDir(dir) })
else
skin, err = TouchSkin.parse(text)
end
@@ -402,6 +623,10 @@ function TouchSkin.load(root, id)
if page.imagePath then
page.image = loadImage(joinPath(root, page.imagePath))
end
if not applyPixelScale(page) then
return nil, "could not read " .. tostring(page.imagePath)
.. ", which " .. page.name .. " measures its coordinates against"
end
for _, ctl in ipairs(page.controls) do
if ctl.imagePath then ctl.image = loadImage(joinPath(root, ctl.imagePath)) end
if ctl.pressedImagePath then
@@ -418,14 +643,30 @@ local function mountZip(archive, point)
return ok and mounted == true
end
TouchSkin.ARCHIVE_EXTS = { zip = true, deltaskin = true }
TouchSkin.LEGACY_EXTS = { gbcskin = true, gbaskin = true, gbskin = true }
TouchSkin.PDF_ONLY_MESSAGE =
"This skin uses PDF artwork, which cannot be imported yet. "
.. "Ask the author for a PNG version."
function TouchSkin.archiveId(name)
name = tostring(name or "")
local ext = name:match("%.([%w]+)$")
if not ext or not TouchSkin.ARCHIVE_EXTS[ext:lower()] then return nil end
local id = name:sub(1, #name - #ext - 1)
if id == "" then return nil end
return id, ext:lower()
end
function TouchSkin.list()
local out, seen = {}, {}
local function scan(root, source)
for _, name in ipairs(listDir(root)) do
local id = name:gsub("%.zip$", "")
if not seen[id] then
local archiveId = TouchSkin.archiveId(name)
local id = archiveId or name
if not seen[id] and name:sub(1, 1) ~= "_" then
local path = root .. "/" .. name
if name:match("%.zip$") then
if archiveId then
local point = TouchSkin.USER_ROOT .. "/_mounted/" .. id
if mountZip(path, point) and findConfig(point) then
seen[id] = true
@@ -447,17 +688,20 @@ function TouchSkin.list()
return out
end
-- Drop a .zip into <save>/skins and report the id it will list under.
-- Drop a .zip or .deltaskin into <save>/skins and report the id it lists under.
function TouchSkin.installArchive(name, data)
if not data or data == "" then return nil, "empty archive" end
if not (love and love.filesystem and love.filesystem.write) then
return nil, "no writable filesystem"
end
name = tostring(name or ""):match("([^/\\]+)$") or ""
name = name:gsub("[^%w%._%-]", "_")
if not name:lower():match("%.zip$") then return nil, "not a .zip" end
local id = name:gsub("%.[Zz][Ii][Pp]$", "")
if id == "" then return nil, "bad archive name" end
name = name:gsub("[^%w%._%-]", "_"):gsub("^_+", "")
local legacy = name:match("%.([%w]+)$")
if legacy and TouchSkin.LEGACY_EXTS[legacy:lower()] then
return nil, "old GBA4iOS skin, not supported"
end
local id = TouchSkin.archiveId(name)
if not id then return nil, "not a .zip or .deltaskin" end
pcall(love.filesystem.createDirectory, TouchSkin.USER_ROOT)
local dest = TouchSkin.USER_ROOT .. "/" .. name
@@ -467,9 +711,14 @@ function TouchSkin.installArchive(name, data)
local entry = TouchSkin.find(id)
if not entry then
love.filesystem.remove(dest)
return nil, "no skin.lua or .cfg inside " .. name
return nil, "no skin.lua, .cfg or info.json inside " .. name
end
return id
local skin = TouchSkin.load(entry.root, entry.id)
if skin and require("src.core.DeltaSkin").needsConversion(skin) then
love.filesystem.remove(dest)
return nil, TouchSkin.PDF_ONLY_MESSAGE
end
return id, skin and skin.warnings or nil
end
function TouchSkin.find(id)
@@ -498,29 +747,13 @@ function TouchSkin.assetPaths(skin)
return out
end
function TouchSkin.export(skin, destPath)
if not skin then return nil, "no skin" end
local SkinZip = require("src.core.SkinZip")
local entries = { { name = TouchSkin.NATIVE_NAME, data = TouchSkin.serialize(skin) } }
local missing = {}
for _, rel in ipairs(TouchSkin.assetPaths(skin)) do
local data = readFile(joinPath(skin.root, rel))
if data then
entries[#entries + 1] = { name = rel, data = data }
else
missing[#missing + 1] = rel
end
end
if skin.configPath and skin.format == "retroarch" then
local cfg = readFile(skin.configPath)
if cfg then
entries[#entries + 1] =
{ name = skin.configPath:match("([^/]+)$") or "overlay.cfg", data = cfg }
end
end
local blob = SkinZip.encode(entries)
destPath = destPath or (TouchSkin.USER_ROOT .. "/" .. skin.id .. "-export.zip")
local function writeArchive(entries, destPath)
local blob = require("src.core.SkinZip").encode(entries)
local absolute = destPath:sub(1, 1) == "/" or destPath:match("^%a:[/\\]") ~= nil
if not absolute and love and love.filesystem and love.filesystem.createDirectory then
local dir = destPath:match("^(.*)/[^/]+$")
if dir then pcall(love.filesystem.createDirectory, dir) end
end
if not absolute and love and love.filesystem and love.filesystem.write then
local ok, err = love.filesystem.write(destPath, blob)
if not ok then return nil, tostring(err) end
@@ -530,9 +763,167 @@ function TouchSkin.export(skin, destPath)
handle:write(blob)
handle:close()
end
return destPath
end
local function collectAssets(skin, rels)
local entries, missing = {}, {}
for _, rel in ipairs(rels) do
local data = readFile(joinPath(skin.root, rel))
if data then
entries[#entries + 1] = { name = rel, data = data }
else
missing[#missing + 1] = rel
end
end
return entries, missing
end
function TouchSkin.export(skin, destPath)
if not skin then return nil, "no skin" end
local entries = { { name = TouchSkin.NATIVE_NAME, data = TouchSkin.serialize(skin) } }
local assets, missing = collectAssets(skin, TouchSkin.assetPaths(skin))
for _, entry in ipairs(assets) do entries[#entries + 1] = entry end
if skin.configPath and skin.format == "retroarch" then
local cfg = readFile(skin.configPath)
if cfg then
entries[#entries + 1] =
{ name = skin.configPath:match("([^/]+)$") or "overlay.cfg", data = cfg }
end
end
destPath = destPath or (TouchSkin.EXPORT_ROOT .. "/" .. skin.id .. "-export.zip")
local written, err = writeArchive(entries, destPath)
if not written then return nil, err end
return destPath, missing
end
local function fmtNum(n)
n = tonumber(n) or 0
if n == math.floor(n) then return string.format("%d", n) end
local s = string.format("%.6f", n):gsub("0+$", ""):gsub("%.$", "")
return s
end
local function fmtRect(r)
return ('"%s,%s,%s,%s"'):format(fmtNum(r.x), fmtNum(r.y), fmtNum(r.w), fmtNum(r.h))
end
local function cfgSpec(spec)
local parts = {}
for raw in tostring(spec or ""):gmatch("[^|]+") do
local name = trim(raw)
local key = name:lower():match("^key:(.+)$")
parts[#parts + 1] = key and ("retrok_" .. key) or name
end
return table.concat(parts, "|")
end
function TouchSkin.toRetroArchConfig(skin)
local pages = (skin and skin.pages) or {}
local out = { "overlays = " .. #pages }
for i, page in ipairs(pages) do
local p = "overlay" .. (i - 1)
out[#out + 1] = ""
out[#out + 1] = p .. '_name = "' .. tostring(page.name or ("overlay" .. (i - 1))) .. '"'
if page.imagePath then out[#out + 1] = p .. "_overlay = " .. page.imagePath end
out[#out + 1] = p .. "_full_screen = " .. (page.fullScreen ~= false and "true" or "false")
out[#out + 1] = p .. "_normalized = true"
if num(page.rangeMod, 1) ~= 1 then
out[#out + 1] = p .. "_range_mod = " .. fmtNum(page.rangeMod)
end
if num(page.alphaMod, 1) ~= 1 then
out[#out + 1] = p .. "_alpha_mod = " .. fmtNum(page.alphaMod)
end
if page.aspectFromCfg and page.aspect and page.aspect > 0 then
out[#out + 1] = p .. "_aspect_ratio = " .. fmtNum(page.aspect)
end
local r = page.rect
if r and (r.x ~= 0 or r.y ~= 0 or r.w ~= 1 or r.h ~= 1) then
out[#out + 1] = p .. "_rect = " .. fmtRect(r)
end
if page.viewport then
out[#out + 1] = p .. "_viewport = " .. fmtRect(page.viewport)
if page.viewportFill then out[#out + 1] = p .. "_viewport_fill = true" end
if page.viewportExpand then out[#out + 1] = p .. "_viewport_expand = true" end
end
local controls = {}
for _, ctl in ipairs(page.controls or {}) do
if not ctl.sector or ctl.sector == 1 then controls[#controls + 1] = ctl end
end
out[#out + 1] = p .. "_descs = " .. #controls
for j, ctl in ipairs(controls) do
local d = p .. "_desc" .. (j - 1)
local spec = ctl.areaKind and ctl.sector and ctl.areaKind
or cfgSpec(ctl.spec)
if spec == "" then spec = "nul" end
out[#out + 1] = ('%s = "%s,%s,%s,%s,%s,%s"'):format(d, spec,
fmtNum(ctl.x), fmtNum(ctl.y),
ctl.shape == "radial" and "radial" or "rect",
fmtNum(ctl.rangeX), fmtNum(ctl.rangeY))
if ctl.imagePath then out[#out + 1] = d .. "_overlay = " .. ctl.imagePath end
if ctl.pressedImagePath then
out[#out + 1] = d .. "_overlay_pressed = " .. ctl.pressedImagePath
end
if num(ctl.rangeMod, 1) ~= num(page.rangeMod, 1) then
out[#out + 1] = d .. "_range_mod = " .. fmtNum(ctl.rangeMod)
end
if num(ctl.alphaMod, 1) ~= num(page.alphaMod, 1) then
out[#out + 1] = d .. "_alpha_mod = " .. fmtNum(ctl.alphaMod)
end
for key, value in pairs({ up = ctl.reachUp, down = ctl.reachDown,
left = ctl.reachLeft, right = ctl.reachRight }) do
if num(value, 1) ~= 1 then
out[#out + 1] = d .. "_reach_" .. key .. " = " .. fmtNum(value)
end
end
if ctl.movable then out[#out + 1] = d .. "_movable = true" end
if ctl.exclusive then out[#out + 1] = d .. "_exclusive = true" end
if ctl.nextTarget then
out[#out + 1] = d .. '_next_target = "' .. tostring(ctl.nextTarget) .. '"'
end
if ctl.areaKind and ctl.sector and ctl.areaNames then
local defaults = TouchSkin.AREA_DEFAULTS[ctl.areaKind] or {}
for _, side in ipairs({ "up", "down", "left", "right" }) do
local name = ctl.areaNames[side]
if name and name ~= defaults[side] then
out[#out + 1] = d .. "_" .. side .. ' = "' .. name .. '"'
end
end
end
end
end
return table.concat(out, "\n") .. "\n"
end
function TouchSkin.exportRetroArch(skin, destPath)
if not skin then return nil, "no skin" end
local entries = { { name = "overlay.cfg", data = TouchSkin.toRetroArchConfig(skin) } }
local assets, missing = collectAssets(skin, TouchSkin.assetPaths(skin))
for _, entry in ipairs(assets) do entries[#entries + 1] = entry end
destPath = destPath or (TouchSkin.EXPORT_ROOT .. "/" .. skin.id .. "-retroarch.zip")
local written, err = writeArchive(entries, destPath)
if not written then return nil, err end
return destPath, missing
end
function TouchSkin.exportDelta(skin, opts)
if not skin then return nil, "no skin" end
opts = opts or {}
local DeltaSkin = require("src.core.DeltaSkin")
local info, assetRels, warnings = DeltaSkin.build(skin, opts)
if not info then return nil, assetRels end
local entries = {
{ name = DeltaSkin.INFO_NAME, data = require("src.link.Json").encode(info) },
}
local assets, missing = collectAssets(skin, assetRels)
for _, entry in ipairs(assets) do entries[#entries + 1] = entry end
local destPath = opts.path
or (TouchSkin.EXPORT_ROOT .. "/" .. skin.id .. ".deltaskin")
local written, err = writeArchive(entries, destPath)
if not written then return nil, err end
return destPath, missing, warnings
end
TouchSkin.BINDS = {
"nul",
"up", "down", "left", "right",
@@ -597,6 +988,7 @@ function TouchSkin.clone(skin)
id = skin.id, name = skin.name, root = skin.root, format = skin.format,
author = skin.author, notes = skin.notes, configPath = skin.configPath,
source = skin.source, pages = {},
warnings = skin.warnings and copyTable(skin.warnings) or nil,
}
for i, page in ipairs(skin.pages or {}) do
local p = copyTable(page)
@@ -676,7 +1068,8 @@ function TouchSkin.listImages(root)
local function scan(dir, prefix)
for _, name in ipairs(listDir(dir)) do
local path = dir .. "/" .. name
if name:lower():match("%.png$") or name:lower():match("%.jpg$") then
local lower = name:lower()
if lower:match("%.png$") or lower:match("%.jpg$") or lower:match("%.jpeg$") then
out[#out + 1] = prefix .. name
elseif isDir(path) and prefix == "" then
scan(path, name .. "/")
@@ -872,17 +1265,21 @@ function TouchSkin.controlGeometry(page, ctl, w, h, ox, oy)
return cx, cy, halfW, halfH
end
function TouchSkin.hits(page, ctl, w, h, px, py, ox, oy)
function TouchSkin.hits(page, ctl, w, h, px, py, ox, oy, held)
local cx, cy, halfW, halfH = TouchSkin.controlGeometry(page, ctl, w, h, ox, oy)
local left = halfW * ctl.reachLeft * ctl.rangeMod
local right = halfW * ctl.reachRight * ctl.rangeMod
local up = halfH * ctl.reachUp * ctl.rangeMod
local down = halfH * ctl.reachDown * ctl.rangeMod
local mod = held == false and 1 or ctl.rangeMod
local left = halfW * ctl.reachLeft * mod
local right = halfW * ctl.reachRight * mod
local up = halfH * ctl.reachUp * mod
local down = halfH * ctl.reachDown * mod
local dx = px - cx
local dy = py - cy
local rx = dx < 0 and left or right
local ry = dy < 0 and up or down
if rx <= 0 or ry <= 0 then return false end
if ctl.sector and not TouchSkin.sectorHit(ctl.sector, dx, dy) then
return false
end
if ctl.shape == "radial" then
return (dx * dx) / (rx * rx) + (dy * dy) / (ry * ry) <= 1
end
+29 -2
View File
@@ -301,6 +301,13 @@ end
-- gear edit these before the game starts (src/import/LauncherSettings.lua).
Save.OPTIONS_KEY = "gold"
local SHARED_KEYS = {
touchControls = true, haptics = true,
mods = true, modsByVersion = true, modsGen2 = true,
modOptions = true, modProfiles = true, modProfilesSeeded = true,
activeProfile = true,
}
function Save.loadOptions(fs)
local options = Save.defaultOptions()
local ok, SaveData = pcall(require, "src.core.SaveData")
@@ -308,7 +315,21 @@ function Save.loadOptions(fs)
local loaded = SaveData.loadOptions(fs)
local stored = loaded and loaded[Save.OPTIONS_KEY]
if type(stored) == "table" then
for key, value in pairs(stored) do options[key] = value end
for key, value in pairs(stored) do
if not SHARED_KEYS[key] then options[key] = value end
end
end
if type(loaded) == "table" then
for key in pairs(SHARED_KEYS) do
if loaded[key] ~= nil then options[key] = loaded[key] end
end
end
if type(stored) == "table" then
for key in pairs(SHARED_KEYS) do
if options[key] == nil and stored[key] ~= nil then
options[key] = stored[key]
end
end
end
return options
end
@@ -321,7 +342,13 @@ function Save.saveOptions(options, fs)
if not ok then return false end
local file = SaveData.loadOptions(fs) or {}
local block = {}
for key, value in pairs(options) do block[key] = value end
for key, value in pairs(options) do
if SHARED_KEYS[key] then
file[key] = value
else
block[key] = value
end
end
file[Save.OPTIONS_KEY] = block
SaveData.saveOptions(file, fs)
return true