Merge pull request #1553 from bryanthaboi/dev

fix stuff baby
This commit is contained in:
bryanthaboi
2026-08-19 06:10:44 -04:00
committed by GitHub
109 changed files with 11478 additions and 335 deletions
+1 -5
View File
@@ -2527,11 +2527,7 @@ end
-- the HUD label drawn in place of the level for a statused mon
function BattleState:statusLabel(mon)
local record = Status.recordFor(self.data.statuses, mon.status)
if record then
return record.hudLabel or record.label or mon.status
end
return mon.status
return Status.hudLabelFor(self.data.statuses, mon.status)
end
-- the one accuracy roll (MoveHitTest), hooked as battle.accuracy
+20 -5
View File
@@ -54,6 +54,13 @@ end
-- freeze the English. They are already translatable through the
-- statuses registry (mod.content.statuses:patch(id, { label = ... })).
--
-- Do not add a matching hudLabel = "..." below: Status.hudLabelFor reads
-- hudLabel before label, and Registry:patch only overrides the fields a
-- mod actually passes, so a label-only translation patch would be
-- shadowed by this hudLabel forever. Nothing in this codebase gives
-- hudLabel a value different from label -- setting it here only recreates
-- that trap for no observed benefit.
--
-- The five persistent conditions as records: the beforeMove gauntlet, the
-- residual sweep, the inflict text/immunities (StatusRegistry.inflict),
-- the catch/wobble bonuses (Catching.attempt), the HUD label, and the
@@ -61,7 +68,7 @@ end
-- read these fields, so a mod's sixth status plugs into every consumer.
Status.RECORDS = {
SLP = {
id = "SLP", label = "SLP", hudLabel = "SLP",
id = "SLP", label = "SLP",
catchBonus = 25, shakeBonus = 10,
beforeMovePriority = 40,
beforeMove = function(battler, _, battle)
@@ -82,7 +89,7 @@ Status.RECORDS = {
end,
},
FRZ = {
id = "FRZ", label = "FRZ", hudLabel = "FRZ",
id = "FRZ", label = "FRZ",
catchBonus = 25, shakeBonus = 10,
beforeMovePriority = 30,
beforeMove = function(battler, _, battle)
@@ -96,7 +103,7 @@ Status.RECORDS = {
end,
},
PSN = {
id = "PSN", label = "PSN", hudLabel = "PSN",
id = "PSN", label = "PSN",
catchBonus = 12, shakeBonus = 5,
residual = damageOverTime("_HurtByPoisonText",
Strings.source("%s's\nhurt by poison!")),
@@ -112,7 +119,7 @@ Status.RECORDS = {
end,
},
BRN = {
id = "BRN", label = "BRN", hudLabel = "BRN",
id = "BRN", label = "BRN",
catchBonus = 12, shakeBonus = 5,
statPenalty = { stat = "attack", div = 2 },
residual = damageOverTime("_HurtByBurnText",
@@ -124,7 +131,7 @@ Status.RECORDS = {
end,
},
PAR = {
id = "PAR", label = "PAR", hudLabel = "PAR",
id = "PAR", label = "PAR",
catchBonus = 12, shakeBonus = 5,
statPenalty = { stat = "speed", div = 4 },
beforeMovePriority = 10,
@@ -160,6 +167,14 @@ function Status.recordFor(statuses, id)
return (statuses or Status.RECORDS)[id]
end
-- the HUD label for a status id: a mod's patched hudLabel/label if the
-- merged registry has one, the raw id otherwise (BattleState.statusLabel,
-- SummaryMenu.draw and PartyMenu.draw all read this the same way)
function Status.hudLabelFor(statuses, id)
local record = Status.recordFor(statuses, id)
return record and (record.hudLabel or record.label) or id
end
local function battleStatuses(battle)
return battle and battle.data and battle.data.statuses
end
+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
+37 -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
@@ -174,6 +176,7 @@ function Game:makeTitleState()
self:restoreSave(loaded, recovered, { freshBoot = true })
end
end,
onExit = self.onExit,
})
title.screenId = title.screenId or "TitleState"
return title
@@ -356,6 +359,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 +1182,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 +1275,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
+24 -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
@@ -318,6 +320,7 @@ function Game2:showMainMenu()
onNewGame = function() self:newGame() end,
onContinue = function(save) self:continueGame(save) end,
onOption = function() self:showOptions(function() self:showMainMenu() end) end,
onExit = self.onExit,
})
end
@@ -1160,7 +1163,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 +1281,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 +1301,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 +1407,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 +1418,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 +1467,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 +1476,7 @@ function Game2:drawViewportFrame()
G.draw(source, 0, 0)
G.setShader()
end
if cx then G.setScissor() end
end
end
G.pop()
@@ -1499,6 +1507,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
+219
View File
@@ -0,0 +1,219 @@
local SaveData = require("src.core.SaveData")
local Version = require("src.core.Version")
local IssueReport = {}
local FORM_URL = "https://github.com/bryanthaboi/gen1recomp/issues/new"
local TEMPLATE = "bug_report.yml"
local function clean(value)
if value == nil then return nil end
local text = tostring(value):gsub("^%s+", ""):gsub("%s+$", "")
if text == "" or text == "unknown" or text == "Unknown" then return nil end
return text
end
local function call(fn, ...)
if type(fn) ~= "function" then return nil end
local ok, a, b, c, d, e = pcall(fn, ...)
if not ok then return nil end
return a, b, c, d, e
end
local function invoke(fn, ...)
if type(fn) ~= "function" then return false end
local ok, result = pcall(fn, ...)
return ok, result
end
local function commandValue(command)
if not io or type(io.popen) ~= "function" then return nil end
local ok, pipe = pcall(io.popen, command, "r")
if not ok or not pipe then return nil end
local readOK, value = pcall(pipe.read, pipe, "*l")
pcall(pipe.close, pipe)
if not readOK then return nil end
return clean(value)
end
local function percentEncode(value)
local text = tostring(value or "")
return (text:gsub("([^%w%-_%.~])", function(char)
return ("%%%02X"):format(char:byte())
end))
end
local function formOS(raw)
local values = {
["OS X"] = "macOS",
macOS = "macOS",
Windows = "Windows",
Linux = "Linux",
Android = "Android",
iOS = "iOS",
NX = "Nintendo Switch",
UWP = "Xbox",
Xbox = "Xbox",
}
return values[raw] or ""
end
local function loveVersion()
local major, minor, revision, codename = call(love and love.getVersion)
if not major then return "" end
local result = tostring(major) .. "." .. tostring(minor) .. "." .. tostring(revision)
if codename and codename ~= "" then result = result .. " (" .. tostring(codename) .. ")" end
return result
end
local function appVersion()
local version = clean(Version.engine)
if not version or version == "0.0.0" or version == "0.0.0-dev" then return "" end
return version
end
local function deviceModel(rawOS, system)
local model = clean(call(system.getModel))
if model then return model end
if rawOS == "OS X" or rawOS == "macOS" then
return commandValue("sysctl -n hw.model 2>/dev/null")
end
if rawOS == "Windows" then
return commandValue("powershell.exe -NoProfile -NonInteractive -Command \"(Get-CimInstance Win32_ComputerSystem).Model\" 2>NUL")
end
if rawOS == "Linux" then
return commandValue("cat /sys/devices/virtual/dmi/id/product_name 2>/dev/null")
or commandValue("cat /sys/devices/virtual/dmi/id/model 2>/dev/null")
end
if rawOS == "Android" then
return commandValue("getprop ro.product.model 2>/dev/null")
end
return nil
end
local function modRows(context)
if context and type(context.mods) == "table" then return context.mods end
local ok, LauncherMods = pcall(require, "src.mods.LauncherMods")
if ok and LauncherMods and LauncherMods.list then
local listed = call(LauncherMods.list)
if type(listed) == "table" then return listed end
end
return {}
end
local function modNames(rows, safeMode)
local enabled = {}
for _, mod in ipairs(rows or {}) do
if type(mod) == "table" then
local name = clean(mod.name or mod.id)
if name and not safeMode and mod.enabled == true then
enabled[#enabled + 1] = name
end
end
end
table.sort(enabled)
return enabled
end
local function metadata(options, context)
local system = love and love.system or {}
local graphics = love and love.graphics or {}
local window = love and love.window or {}
local rawOS = clean(call(system.getOS))
local model = deviceModel(rawOS, system)
local renderer, rendererVersion, _, rendererDevice = call(graphics.getRendererInfo)
local width, height = call(graphics.getDimensions)
local pixelWidth, pixelHeight = call(graphics.getPixelDimensions)
local modeWidth, modeHeight, flags = call(window.getMode)
local safeMode = SaveData.isSafeMode(options)
local rows = modRows(context or {})
local enabledMods = modNames(rows, safeMode)
local lines = { "Diagnostics:" }
local function add(label, value)
value = clean(value)
if value then lines[#lines + 1] = "- " .. label .. ": " .. value end
end
add("Platform", formOS(rawOS))
local hardware = model
if rendererDevice and rendererDevice ~= model then
hardware = hardware and (hardware .. " (" .. rendererDevice .. ")") or rendererDevice
end
add("Device", hardware)
local rendererDetails = clean(renderer)
if rendererDetails and clean(rendererVersion) then
rendererDetails = rendererDetails .. " " .. clean(rendererVersion)
end
add("Renderer", rendererDetails)
local displayWidth, displayHeight = width or modeWidth or pixelWidth, height or modeHeight or pixelHeight
if displayWidth and displayHeight then
add("Display", tostring(displayWidth) .. "x" .. tostring(displayHeight))
end
if pixelWidth and pixelHeight
and (pixelWidth ~= displayWidth or pixelHeight ~= displayHeight) then
add("Pixel display", tostring(pixelWidth) .. "x" .. tostring(pixelHeight))
end
if flags and flags.fullscreen == true then add("Fullscreen", "yes") end
local version = appVersion()
add("App", version ~= "" and Version.title() or "gen1recomp")
add("LÖVE", loveVersion())
if safeMode then add("Safe mode", "on") end
return {
rawOS = rawOS,
os = formOS(rawOS),
device = model,
version = version,
safeMode = safeMode,
enabledMods = enabledMods,
metadata = table.concat(lines, "\n"),
}
end
function IssueReport.build(options, context)
options = options or SaveData.loadOptions()
context = context or {}
local info = metadata(options, context)
local fields = {
summary = "",
mods_which = #info.enabledMods > 0 and table.concat(info.enabledMods, ", ") or "",
version = info.version or "",
location = "",
screenshot = "",
steps = "",
expected = "",
extra = info.metadata,
}
local params = {
"template=" .. percentEncode(TEMPLATE),
"title=" .. percentEncode("bug: replace this with a meaningful title"),
}
local order = { "summary", "mods_which",
"version", "location", "screenshot", "steps", "expected", "extra" }
for _, key in ipairs(order) do
params[#params + 1] = key .. "=" .. percentEncode(fields[key])
end
return FORM_URL .. "?" .. table.concat(params, "&"), fields, info
end
function IssueReport.open(options, context)
local url = IssueReport.build(options, context)
local system = love and love.system or {}
local opened, openResult = invoke(system.openURL, url)
if opened and openResult ~= false then
return true, url
end
local copied, copyResult = invoke(system.setClipboardText, url)
if copied and copyResult ~= false then
return true, url, "Issue URL copied to the clipboard."
end
local filesystem = love and love.filesystem or {}
local written, writeResult = invoke(filesystem.write, "issue-report-url.txt", url)
if written and writeResult ~= false then
return true, url, "Issue URL saved to issue-report-url.txt."
end
return false, url, "No browser, clipboard, or writable save directory is available for the issue report."
end
IssueReport.percentEncode = percentEncode
IssueReport.metadata = metadata
return IssueReport
+13
View File
@@ -67,10 +67,23 @@ local function argFlag(argv, name)
return false
end
local cachedIntentGame = nil
-- Returns version, slotId (either may be nil). Command line wins over env,
-- so a shortcut can override a machine-wide default.
function LaunchOptions.resolve(argv)
if cachedIntentGame == nil then
if love.system and love.system.getOS and love.system.getOS() == "Android"
and love.system.getLaunchGame then
cachedIntentGame = normalizeVersion(love.system.getLaunchGame()) or false
else
cachedIntentGame = false
end
end
local intentGame = cachedIntentGame or nil
local game = normalizeVersion(argValue(argv, "game"))
or intentGame
or normalizeVersion(os.getenv("POKEPORT_GAME"))
or normalizeVersion(os.getenv("POKEPORT_LAUNCH"))
local slot = argValue(argv, "slot") or os.getenv("POKEPORT_SLOT")
+39 -2
View File
@@ -300,6 +300,7 @@ function SaveData.defaultOptions()
-- Native mod enablement is an installation option, not save-slot data.
-- Missing entries mean enabled so newly installed mods work by default.
mods = {},
safeMode = false,
-- Mods the player forced past the target gate (Loader:_gateGeneration).
-- modsGen2[id][version] = true, one answer per game; a bare `true` is the
-- pre-per-game shape and means the Gen 2 games only (see modForced).
@@ -356,6 +357,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
@@ -384,6 +387,16 @@ function SaveData.mergeOptions(loaded)
return opts
end
function SaveData.isSafeMode(options)
return type(options) == "table" and options.safeMode == true
end
function SaveData.setSafeMode(options, enabled)
if type(options) ~= "table" then return false end
options.safeMode = enabled == true
return options.safeMode
end
function SaveData.encode(data)
return SaveSerializer.encode(data)
end
@@ -1013,6 +1026,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 +1356,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 +1367,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
+45 -1
View File
@@ -422,7 +422,7 @@ local function discoverModSchemas(opts)
-- except experimental mods, which stay off until opted in.
local flag = require("src.core.SaveData").modEnabled(opts, m.id)
local enabled = flag == true or (flag == nil and not m.experimental)
if enabled then
if enabled and not SaveData.isSafeMode(opts) then
local chunk = fs.load(path .. "/" .. m.options_schema)
if chunk then
local okR, schema = pcall(chunk)
@@ -521,9 +521,49 @@ local function modRows(opts, mod)
return true
end }
end
for _, row in ipairs(rows) do
row.safeModeBlocked = true
if row.step then
local step = row.step
row.step = function(dir)
if SaveData.isSafeMode(opts) then return false end
return step(dir)
end
end
if row.setText then
local setText = row.setText
row.setText = function(text)
if SaveData.isSafeMode(opts) then return false end
return setText(text)
end
end
end
return rows
end
local function troubleshootingRows(opts, hooks)
return {
{
label = Strings("SAFE MODE"),
actionLabel = function()
return SaveData.isSafeMode(opts) and Strings("Turn off") or Strings("Turn on")
end,
action = function()
SaveData.setSafeMode(opts, not SaveData.isSafeMode(opts))
return true
end,
},
{
label = Strings("REPORT ISSUE"),
actionLabel = Strings("Report bug"),
action = function()
if hooks and hooks.reportIssue then hooks.reportIssue(opts) end
return false
end,
},
}
end
-- ------- Gen 2 (Gold)
--
-- Gold reads NONE of the rows above. Its OPTION screen writes a different
@@ -704,6 +744,10 @@ function LauncherSettings.open(hooks, version)
sections[#sections + 1] = { title = mod.name, rows = rows }
end
end
sections[#sections + 1] = {
title = Strings("TROUBLESHOOTING"),
rows = troubleshootingRows(opts, hooks),
}
return {
opts = opts,
version = version,
File diff suppressed because it is too large Load Diff
+501 -15
View File
@@ -136,6 +136,9 @@ local VERSION_REQUIRED_FILES_OVERRIDE = {
-- costs nothing on a current cache and is the difference between every
-- trainer battle opening with a picture and opening with none.
"assets/generated/battle/trainers/falkner.png",
-- BattleStart_TrainerHuds cannot draw its party rows from a cache made
-- before the four ball tiles were extracted (#1502).
"assets/generated/battle/hud/balls.png",
"assets/generated/audio/programs.bin",
},
}
@@ -326,6 +329,32 @@ function RomImporter.isReady(version)
return marker == markerFor(version) and allRequiredFilesExist(version)
end
function RomImporter.syncAndroidShortcuts(activeVersion)
if not (love.system and love.system.getOS and love.system.getOS() == "Android"
and love.system.updateShortcuts) then
return false
end
local allVersions = { "red", "blue", "yellow", "gold" }
local ready = {}
local seen = {}
if activeVersion and RomImporter.isReady(activeVersion) then
table.insert(ready, activeVersion)
seen[activeVersion] = true
end
for _, v in ipairs(allVersions) do
if not seen[v] and RomImporter.isReady(v) then
table.insert(ready, v)
seen[v] = true
if #ready >= 4 then break end
end
end
return love.system.updateShortcuts(ready)
end
-- Load the import manifest for a version and confirm it matches that ROM.
local function sha1(data)
local digest = love.data.hash("sha1", data)
@@ -1111,18 +1140,18 @@ local function chooseZip()
end
local function chooseSkinZip()
local prompt = shellSafe(Strings("Choose a skin .zip"))
local prompt = shellSafe(Strings("Choose a skin .zip or .deltaskin"))
local platform = love.system.getOS()
if platform == "OS X" then
return commandOutput(
([[osascript -e 'POSIX path of (choose file with prompt "%s" of type {"zip"})' 2>/dev/null]])
([[osascript -e 'POSIX path of (choose file with prompt "%s" of type {"zip", "deltaskin"})' 2>/dev/null]])
:format(prompt))
elseif platform == "Windows" then
local script = table.concat({
"Add-Type -AssemblyName System.Windows.Forms;",
"$d=New-Object System.Windows.Forms.OpenFileDialog;",
"$d.Title='" .. prompt .. "';",
"$d.Filter='Skin archive (*.zip)|*.zip|All files (*.*)|*.*';",
"$d.Filter='Skin archive (*.zip;*.deltaskin)|*.zip;*.deltaskin|All files (*.*)|*.*';",
"if($d.ShowDialog() -eq 'OK'){",
"$n=[IO.Path]::GetFileName($d.FileName) -replace '[^\\x20-\\x7E]','_';",
"$t=Join-Path $env:TEMP $n;",
@@ -1134,11 +1163,11 @@ local function chooseSkinZip()
'powershell -NoProfile -STA -Command "' .. script .. '"')
elseif platform == "Linux" then
local path = commandOutput(
([[zenity --file-selection --title="%s" --file-filter="Skin archive | *.zip" 2>/dev/null]])
([[zenity --file-selection --title="%s" --file-filter="Skin archive | *.zip *.deltaskin" 2>/dev/null]])
:format(prompt))
if path then return path end
return commandOutput(
[[kdialog --getopenfilename "$HOME" "*.zip|Skin archive" 2>/dev/null]])
[[kdialog --getopenfilename "$HOME" "*.zip *.deltaskin|Skin archive" 2>/dev/null]])
end
return nil
end
@@ -1349,6 +1378,7 @@ function RomImporter.new(onComplete, opts)
findLoaded = false, findSources = nil, findIndex = nil,
findScroll = 0, findNotice = nil, findQuery = "", findCategory = nil,
_findSearchFocus = false, _findThumbs = nil,
skinUrl = "", _skinUrlFocus = false,
-- Page scroll offset (px) for the column under the tab bar -- panel, updater
-- banner and footer -- used only while that column is taller than the window
-- (see draw()). Clamped against content in draw, reset on a tab change.
@@ -1392,6 +1422,7 @@ function RomImporter.new(onComplete, opts)
self.romName[version] = "pokemon_" .. info.id
.. ((info.id == "yellow" or info.id == "gold") and ".gbc" or ".gb")
end
RomImporter.syncAndroidShortcuts()
self:_applyLastVersionTab()
self:_queueBaseRomScan()
@@ -1786,6 +1817,7 @@ function RomImporter:_completeImport(version, prefix, displayName)
self.workState = "complete"
self.completeVersion = version
self.status = "Ready"
RomImporter.syncAndroidShortcuts(version)
-- NX launcher stays put: keep the imports/ cleanup hint instead of
-- overwriting it with a "Starting…" line that never boots from here.
if self.launcher and self.isNX and type(displayName) == "string" then
@@ -1851,10 +1883,14 @@ end
function RomImporter:filedropped(file)
if self.workState == "working" then return end
-- A dropped .zip is a mod archive: hand it straight to the mods installer
-- (which mounts + validates it). Everything else is treated as a ROM. The
-- dropped file itself is passed through -- installZip opens it the same way
-- readDroppedFile does here.
-- (which mounts + validates it). A .deltaskin is only ever a skin, and
-- everything else is treated as a ROM. The dropped file itself is passed
-- through -- installZip opens it the same way readDroppedFile does here.
local name = file:getFilename() or ""
if name:lower():match("%.deltaskin$") then
self:_installSkinZip(file)
return
end
if name:lower():match("%.zip$") then
-- On the SKINS tab a zip is a skin; everywhere else it is a mod archive.
if self.tab == "skins" then
@@ -2481,6 +2517,8 @@ function RomImporter:update(dt)
self:_pumpModInfoFetch()
self:_pumpFindStats()
self:_pumpFindThumbs()
self:_pumpSkinFetch()
self:_pumpSync(dt)
self:_pumpModCheck()
self:_pumpModInstall()
self:_pumpExtract()
@@ -3082,6 +3120,8 @@ end
function RomImporter:_switchTab(id)
self.tab = id
self._findSearchFocus = false
self._skinUrlFocus = false
self._modScrollMax, self._modListRect = 0, nil
self:_disarmTextInput()
-- the skins list is cheap and can change behind the launcher's back
-- (an export, a hand-dropped folder), so re-read it on every visit
@@ -3107,6 +3147,7 @@ function RomImporter:_ensureSkins(force)
out[#out + 1] = {
id = entry.id,
source = entry.source,
format = skin and skin.format or nil,
pages = skin and #skin.pages or 0,
controls = controls,
screen = page ~= nil and page.viewport ~= nil,
@@ -3140,7 +3181,6 @@ end
function RomImporter:_installSkinZip(source)
if self.workState == "working" then return end
self.tab = "skins"
local TouchSkin = require("src.core.TouchSkin")
local name, data, readError
if type(source) == "string" then
name = source
@@ -3160,13 +3200,368 @@ function RomImporter:_installSkinZip(source)
.. tostring(readError or name) }
return
end
local id, err = TouchSkin.installArchive(name, data)
self:_installSkinData(name, data)
end
local MAX_SKIN_URL = 300
local SKIN_TEMP_DIR = "skins/_download"
function RomImporter.skinUrlName(url)
local path = tostring(url or ""):gsub("[?#].*$", "")
local base = (path:match("([^/\\]+)$") or ""):gsub("[^%w%._%-]", "_")
local ext = base:match("%.([%w]+)$")
if not ext then
return (base ~= "" and base or "skin") .. ".zip"
end
ext = ext:lower()
local TouchSkin = require("src.core.TouchSkin")
if TouchSkin.ARCHIVE_EXTS[ext] or ext == "cfg" then return base end
return (base:gsub("%.[%w]+$", "")) .. ".zip"
end
function RomImporter.wrapSkinPayload(name, data)
name = tostring(name or "")
if not name:lower():match("%.cfg$") then return name, data end
if not data then return name, data end
if data:sub(1, 2) == "PK" then
return (name:gsub("%.[Cc][Ff][Gg]$", "")) .. ".zip", data
end
local blob = require("src.core.SkinZip").encode({
{ name = "overlay.cfg", data = data },
})
return (name:gsub("%.[Cc][Ff][Gg]$", "")) .. ".zip", blob
end
function RomImporter:_installSkinData(name, data)
local TouchSkin = require("src.core.TouchSkin")
if not data or data == "" then
self._skinNotice = { ok = false, text = Strings("The skin file was empty.") }
return nil
end
local wrappedName, payload = RomImporter.wrapSkinPayload(name, data)
local id, note = TouchSkin.installArchive(wrappedName, payload)
self:_ensureSkins(true)
if not id then
self._skinNotice = { ok = false, text = "Import failed: " .. tostring(err) }
self._skinNotice = { ok = false, text = "Import failed: " .. tostring(note) }
return nil
end
local text = "Imported " .. id
if type(note) == "table" and note[1] then
text = text .. ": " .. tostring(note[1])
end
self._skinNotice = { ok = true, text = text }
return id
end
function RomImporter:_toggleSkinUrlFocus()
self._skinUrlFocus = not self._skinUrlFocus
if self._skinUrlFocus then
self:_armTextInput()
else
self:_disarmTextInput()
end
end
function RomImporter:_pasteSkinUrl()
local ok, text = pcall(love.system.getClipboardText)
if ok and type(text) == "string" then
self.skinUrl = utf8Cap((self.skinUrl or "") .. text:gsub("%s", ""),
MAX_SKIN_URL)
end
end
function RomImporter:_addSkinFromUrl(url)
if self._skinFetch then return false end
url = tostring(url or self.skinUrl or ""):gsub("%s", "")
if url == "" then
self._skinNotice = { ok = false,
text = Strings("Paste a link to a skin archive first.") }
return false
end
if not url:match("^https?://") then
self._skinNotice = { ok = false,
text = Strings("A skin link has to start with http:// or https://") }
return false
end
if not require("src.core.Platform").canFetchRemote() then
self._skinNotice = { ok = false,
text = Strings("Downloading needs a network transport this build has not got.") }
return false
end
local name = RomImporter.skinUrlName(url)
local Fetch = require("src.net.Fetch")
self._skinFetch = {
url = url, name = name, dest = SKIN_TEMP_DIR .. "/" .. name,
job = Fetch.download(url, SKIN_TEMP_DIR .. "/" .. name,
{ userAgent = "gen1recomp-skin", maxSeconds = 90 }),
}
self._skinNotice = { ok = true, text = Strings("Downloading %s...", name) }
return true
end
function RomImporter:_pumpSkinFetch()
local f = self._skinFetch
if not f then return end
local Fetch = require("src.net.Fetch")
local st = Fetch.poll(f.job)
if st.status == "pending" then
self._skinFetchProgress = st.progress
return
end
self._skinNotice = { ok = true, text = "Imported " .. id }
Fetch.release(f.job)
self._skinFetch, self._skinFetchProgress = nil, nil
if st.status ~= "ok" or not st.path then
self._skinNotice = { ok = false,
text = "Download failed: " .. tostring(st.err or "no data") }
return
end
local data = love.filesystem.read(st.path)
love.filesystem.remove(st.path)
if self:_installSkinData(f.name, data) then
self.skinUrl = ""
end
end
function RomImporter:_exportSkin(id, kind)
local TouchSkin = require("src.core.TouchSkin")
local entry = id and TouchSkin.find(id)
if not entry then
self._skinNotice = { ok = false, text = Strings("That skin is gone.") }
return nil
end
local skin = TouchSkin.load(entry.root, entry.id)
if not skin then
self._skinNotice = { ok = false,
text = Strings("Could not read %s", tostring(id)) }
return nil
end
local path, missing, warnings
if kind == "retroarch" then
path, missing = TouchSkin.exportRetroArch(skin)
elseif kind == "delta" then
path, missing, warnings = TouchSkin.exportDelta(skin)
else
path, missing = TouchSkin.export(skin)
end
if not path then
self._skinNotice = { ok = false,
text = "Export failed: " .. tostring(missing) }
return nil
end
local dir = love.filesystem.getSaveDirectory
and love.filesystem.getSaveDirectory() or nil
self._skinExport = { path = path, dir = dir }
local text = Strings("Exported to %s", (dir and (dir .. "/") or "") .. path)
if type(missing) == "table" and missing[1] then
text = text .. " (" .. #missing .. " image(s) missing)"
end
if type(warnings) == "table" and warnings[1] then
text = text .. " " .. tostring(warnings[1])
end
self._skinNotice = { ok = true, text = text }
return path
end
function RomImporter:_revealSkinExport()
local e = self._skinExport
if not e or not e.dir then return false end
if love.system and love.system.openURL then
pcall(love.system.openURL, fileUrl(e.dir))
end
return true
end
local MAX_SYNC_CODE = 8
local MAX_SHARE_CODE = 6
function RomImporter.syncDigits(text)
local digits = tostring(text or ""):gsub("[^%d]", "")
return digits:sub(1, MAX_SYNC_CODE)
end
function RomImporter.syncShareCode(text)
local out = tostring(text or ""):upper():gsub("[^A-Z2-9]", "")
return out:sub(1, MAX_SHARE_CODE)
end
function RomImporter:_syncDeviceLabel()
local name = love.system and love.system.getOS and love.system.getOS()
if type(name) ~= "string" or name == "" then return "device" end
return name
end
function RomImporter:_syncEngine()
if self._sync ~= nil then return self._sync or nil end
local ok, SyncEngine = pcall(require, "src.sync.SyncEngine")
if not ok or type(SyncEngine) ~= "table" then
self._sync = false
return nil
end
local made, eng = pcall(SyncEngine.shared)
if not made or type(eng) ~= "table" then
self._sync = false
return nil
end
self._sync = eng
return eng
end
function RomImporter:_syncSupported()
if self._syncTransportOk ~= nil then return self._syncTransportOk end
local ok, HostShell = pcall(require, "src.core.HostShell")
if not ok or type(HostShell) ~= "table"
or type(HostShell.canHttpRequest) ~= "function" then
self._syncTransportOk = true
return true
end
local asked, can = pcall(HostShell.canHttpRequest)
self._syncTransportOk = (not asked) or (can and true or false)
return self._syncTransportOk
end
function RomImporter:_pumpSync(dt)
if self._sync == nil then
if not self.launcher or self._syncBooted then return end
if not self:_syncSupported() then return end
self._syncBooted = true
local booted = self:_syncEngine()
if booted and booted.state.enabled and booted:linked() then
pcall(booted.syncNow, booted)
end
end
local eng = self._sync
if not eng then return end
pcall(eng.update, eng, dt)
if eng.phase == "conflict" and eng.conflicts and #eng.conflicts > 0 then
if not self._syncModal and not self._syncConflictShown then
self._syncConflictShown = true
self:_openSync()
end
else
self._syncConflictShown = nil
end
end
function RomImporter:_openSync()
self:_syncEngine()
self._syncModal = self._syncModal
or { view = "home", code1 = "", code2 = "", share = "" }
self._syncFocus = nil
self:_disarmTextInput()
end
function RomImporter:_closeSync()
self._syncModal = nil
self._syncFocus = nil
self:_disarmTextInput()
end
function RomImporter:_syncView(view)
if not self._syncModal then return end
self._syncModal.view = view
self._syncFocus = nil
self:_disarmTextInput()
end
function RomImporter:_syncFocusField(field)
if not self._syncModal then return end
if self._syncFocus == field then
self._syncFocus = nil
self:_disarmTextInput()
return
end
self._syncFocus = field
self:_armTextInput()
end
function RomImporter:_syncTypeInto(field, text)
local mo = self._syncModal
if not mo or not field then return end
if field == "share" then
mo.share = RomImporter.syncShareCode((mo.share or "") .. tostring(text or ""))
else
mo[field] = RomImporter.syncDigits((mo[field] or "") .. tostring(text or ""))
end
end
function RomImporter:_syncPaste()
local field = self._syncFocus
if not field then return end
local ok, text = pcall(love.system.getClipboardText)
if ok and type(text) == "string" then self:_syncTypeInto(field, text) end
end
function RomImporter:_syncCreate()
local eng = self:_syncEngine()
if not eng then return false end
return eng:createAccount(self:_syncDeviceLabel())
end
function RomImporter:_syncLink()
local eng, mo = self:_syncEngine(), self._syncModal
if not eng or not mo then return false end
local ok = eng:linkDevice(mo.code1, mo.code2, self:_syncDeviceLabel())
if ok then
mo.code1, mo.code2, mo.view = "", "", "home"
self._syncFocus = nil
self:_disarmTextInput()
end
return ok
end
function RomImporter:_syncNow()
local eng = self:_syncEngine()
if not eng then return false end
return eng:syncNow()
end
function RomImporter:_syncUnlink()
local eng = self:_syncEngine()
if not eng then return false end
eng:unlink()
if self._syncModal then self._syncModal.view = "home" end
return true
end
function RomImporter:_syncUnlinkDevice(deviceId)
local eng = self:_syncEngine()
if not eng or type(eng.unlinkDevice) ~= "function" then return false end
return eng:unlinkDevice(deviceId)
end
function RomImporter:_syncShareMods()
local eng = self:_syncEngine()
if not eng then return false end
return eng:shareMods()
end
function RomImporter:_syncGetShare()
local eng, mo = self:_syncEngine(), self._syncModal
if not eng or not mo then return false end
return eng:fetchShare(mo.share or "")
end
function RomImporter:_syncApplyMods()
local eng, mo = self:_syncEngine(), self._syncModal
if not eng then return false end
local ok, err = eng:applyModPlan(function(done, total, label, finished)
if not mo then return end
if finished then
mo.progress = nil
if self._refreshMods then self:_refreshMods() end
else
mo.progress = { done = done, total = total, label = label }
end
end)
if mo then mo.progress = nil end
if ok and self._refreshMods then self:_refreshMods() end
return ok, err
end
function RomImporter:_syncResolve(key, choice)
local eng = self:_syncEngine()
if not eng then return false end
return eng:resolveConflict(key, choice)
end
function RomImporter:_skinsImportButtonLabel()
@@ -3233,6 +3628,10 @@ function RomImporter:_openSettings()
-- The tab rides along: the editor persists the layout into that game's own
-- option block, and Gold's is not the flat Gen 1 one (#1100).
local hooks = {}
local version = self.tab
hooks.reportIssue = function(opts)
return self:_reportIssue(opts, version)
end
if self.onEditTouchControls then
local version = self.tab
hooks.editTouchControls = function()
@@ -3250,11 +3649,13 @@ function RomImporter:_openSettings()
-- The tab the gear was opened on decides the row set: Gold reads a
-- different option block entirely, and offering it Gen 1's rows meant a
-- dozen controls that changed nothing (see LauncherSettings.gen2Rows).
local version = self.tab
local ok, model = pcall(function()
return require("src.import.LauncherSettings").open(hooks, version)
end)
if ok and model then self._settings = model end
if ok and model then
self._settings = model
self._settingsSafeModeAtOpen = require("src.core.SaveData").isSafeMode(model.opts)
end
end
-- Quit from the launcher's own X. It goes through love.event.quit so main.lua's
@@ -3265,8 +3666,38 @@ function RomImporter:_quitApp()
end
function RomImporter:_closeSettings()
if self._settings then self._settings.save() end
local model = self._settings
if model then
model.save()
local safeMode = require("src.core.SaveData").isSafeMode(model.opts)
if safeMode ~= self._settingsSafeModeAtOpen then
self.mods = nil
self.safeMode = safeMode
self._modSortCache = nil
self._modInfoFetch = nil
end
end
self._settings = nil
self._settingsSafeModeAtOpen = nil
end
function RomImporter:_reportIssue(options, version)
local ok, IssueReport = pcall(require, "src.core.IssueReport")
if not ok then
self.modNotice = { ok = false, text = "Could not prepare the issue report." }
return false
end
local opened, url, reason = IssueReport.open(options, {
version = version,
mods = self.mods,
})
if not opened then
self.modNotice = { ok = false, text = reason or "Could not open the issue report." }
return false
end
self._lastIssueReportURL = url
if reason then self.modNotice = { ok = true, text = reason } end
return true
end
function RomImporter:_commitSettingsText()
@@ -3338,6 +3769,27 @@ function RomImporter:keypressed(key)
if key == "escape" then self:_closeSettings() end
return
end
if self._syncModal then
local field = self._syncFocus
if field then
local mo = self._syncModal
if key == "backspace" then
mo[field] = tostring(mo[field] or ""):sub(1, -2)
elseif key == "return" or key == "kpenter" or key == "escape" then
self._syncFocus = nil
self:_disarmTextInput()
elseif key == "v"
and love.keyboard.isDown("lctrl", "rctrl", "lgui", "rgui") then
self:_syncPaste()
end
return
end
if self._flex and require("src.import.LauncherView").keypressed(self, key) then
return
end
if key == "escape" then self:_closeSync() end
return
end
if self._rename then
if key == "backspace" then
self._rename.text = utf8Back(self._rename.text)
@@ -3387,6 +3839,21 @@ function RomImporter:keypressed(key)
end
return
end
if self._skinUrlFocus then
if key == "backspace" then
self.skinUrl = utf8Back(self.skinUrl or "")
elseif key == "return" or key == "kpenter" then
self._skinUrlFocus = false
self:_disarmTextInput()
self:_addSkinFromUrl()
elseif key == "escape" then
self._skinUrlFocus = false
self:_disarmTextInput()
elseif key == "v" and love.keyboard.isDown("lctrl", "rctrl", "lgui", "rgui") then
self:_pasteSkinUrl()
end
return
end
if self._findSearchFocus then
if key == "backspace" then
self.findQuery = utf8Back(self.findQuery or "")
@@ -3494,6 +3961,10 @@ function RomImporter:_commitRename()
end
function RomImporter:textinput(text)
if self._syncModal and self._syncFocus then
self:_syncTypeInto(self._syncFocus, text)
return
end
if self._profileSavePrompt then
self._profileSavePrompt.text = utf8Cap((self._profileSavePrompt.text or "") .. text, MAX_SLOT_LABEL)
return
@@ -3514,6 +3985,11 @@ function RomImporter:textinput(text)
utf8Cap(self._indexPrompt.text .. text:gsub("%s", ""), MAX_INDEX_URL)
return
end
if self._skinUrlFocus then
self.skinUrl = utf8Cap((self.skinUrl or "") .. text:gsub("%s", ""),
MAX_SKIN_URL)
return
end
if self._findSearchFocus then
self.findQuery = utf8Cap((self.findQuery or "") .. text, MAX_FIND_QUERY)
self.findScroll = 0
@@ -3561,6 +4037,8 @@ end
-- so a still list costs nothing after the first paint.
function RomImporter:_refreshMods()
local LauncherMods = require("src.mods.LauncherMods")
local SaveData = require("src.core.SaveData")
self.safeMode = SaveData.isSafeMode(SaveData.loadOptions())
-- Once per session, ahead of the first listing: pull in any mod the player
-- unzipped beside the executable, which an ordinary (non-portable) install
-- has no way to read. It happens here rather than behind a button because
@@ -3700,6 +4178,10 @@ end
-- so that game's checkbox and status chips reflect the new resolution.
-- Enabling an experimental mod arms a confirmation for that same game.
function RomImporter:_toggleMod(id, confirmed, version)
if self.safeMode then
self.modNotice = { ok = false, text = "Safe mode is active. Turn it off in Settings to change mods." }
return
end
local LauncherMods = require("src.mods.LauncherMods")
local cur, experimental = false, false
for _, m in ipairs(self.mods or {}) do
@@ -3740,6 +4222,10 @@ end
-- must not be the way around it. Disabling needs no confirm -- it is the
-- recovery action, and Delete is the only destructive one on this panel.
function RomImporter:_setAllMods(want, confirmed)
if self.safeMode then
self.modNotice = { ok = false, text = "Safe mode is active. Turn it off in Settings to change mods." }
return
end
local LauncherMods = require("src.mods.LauncherMods")
local ids, experimental = {}, false
for _, m in ipairs(self.mods or {}) do
+20 -7
View File
@@ -291,6 +291,7 @@ function LauncherMods.deriveList(manifests, options, version)
local ordered = {}
for _, m in ipairs(manifests) do ordered[#ordered + 1] = m end
table.sort(ordered, function(a, b) return a.id < b.id end)
local safeMode = SaveData.isSafeMode(options)
-- the override is one answer per game (SaveData.modForced), the same scope
-- the loader resolves it under
@@ -304,17 +305,24 @@ function LauncherMods.deriveList(manifests, options, version)
-- matching the loader -- except experimental mods, which stay off until
-- the player opts in. Scoped through modScope, so this reads exactly what
-- setEnabled writes and the loader loads for the selected game.
local decided = SaveData.modEnabled(options, m.id, SaveData.modScope(version))
if decided == nil then decided = not m.experimental end
if decided then enabledSet[m.id] = true end
if not safeMode then
local decided = SaveData.modEnabled(options, m.id, SaveData.modScope(version))
if decided == nil then decided = not m.experimental end
if decided then enabledSet[m.id] = true end
end
end
local out = {}
for _, m in ipairs(ordered) do
local enabled = enabledSet[m.id] == true
local enabled = not safeMode and enabledSet[m.id] == true
local forced = forcedFor(m.id)
local status, detail =
statusFor(byId, m.id, enabledSet, enabled, version, forcedFor)
local status, detail
if safeMode then
status, detail = "safe_mode", "Disabled by safe mode"
else
status, detail =
statusFor(byId, m.id, enabledSet, enabled, version, forcedFor)
end
-- nil, not false, when the panel is showing every game at once
local here = nil
if version then here = ModTargets.runsHere(m, version, nil, forced) end
@@ -332,7 +340,8 @@ function LauncherMods.deriveList(manifests, options, version)
local answers = {}
for _, game in ipairs(GameVersion.ORDER) do
local answer = SaveData.modEnabled(options, m.id, game)
answers[game] = answer == true or (answer == nil and not m.experimental)
answers[game] = not safeMode
and (answer == true or (answer == nil and not m.experimental))
end
return answers
end)(),
@@ -346,6 +355,7 @@ function LauncherMods.deriveList(manifests, options, version)
-- panel is showing (src/mods/ModTargets.lua)
targets = ModTargets.chip(m),
targetsHere = here,
safeMode = safeMode,
}
end
return out
@@ -586,6 +596,7 @@ end
-- answer. The loader and the in-game manager use the same scope on next boot.
function LauncherMods.setEnabled(id, enabled, version)
local options = SaveData.loadOptions()
if SaveData.isSafeMode(options) then return false end
SaveData.setModEnabled(options, id, enabled, SaveData.modScope(version))
SaveData.saveOptions(options)
LauncherMods.syncActiveProfile(options)
@@ -599,6 +610,7 @@ end
-- and leaves a half-applied state behind if one of them fails.
function LauncherMods.setAllEnabled(ids, enabled, version)
local options = SaveData.loadOptions()
if SaveData.isSafeMode(options) then return false end
local scope = SaveData.modScope(version)
for _, id in ipairs(ids or {}) do
if scope then
@@ -1184,6 +1196,7 @@ end
function LauncherMods.applyProfile(profileName, options)
options = options or SaveData.loadOptions()
if SaveData.isSafeMode(options) then return false end
local profiles = options.modProfiles or {}
local targetProfile
for _, p in ipairs(profiles) do
+10 -1
View File
@@ -259,6 +259,7 @@ function Loader.new(opts)
modInput = {}, modEnv = {}, stepsQueues = {},
fs = (opts and opts.fs) or (love and love.filesystem),
dev = dev,
safeMode = false,
-- Which generation this boot is (1 or 2). Fixed at construction: the
-- active version is set once in main.lua's bootGame before anything
-- builds a loader, and a run never changes generation underneath one.
@@ -300,6 +301,8 @@ end
function Loader:_loadState()
self.disabled = {}
local options = SaveData.loadOptions(self.fs)
self.safeMode = SaveData.isSafeMode(options)
Runtime.safeMode = self.safeMode
local scope = self:_enableScope()
local ids = {}
for id in pairs(options.mods or {}) do ids[id] = true end
@@ -415,6 +418,7 @@ function Loader:_writeOptionSchemas()
end
function Loader:setEnabled(id, enabled)
if self.safeMode then return false end
if not self.mods[id] then return false end
self.disabled[id] = not enabled
self.mods[id].enabled = enabled
@@ -427,6 +431,7 @@ end
-- choice could not be persisted for a game, so the caller does not promise a
-- restart will honour it.
function Loader:setGen2Forced(id, forced)
if self.safeMode then return false, false end
if not self.mods[id] then return false, false end
self.gen2Forced[id] = forced or nil
self:_saveState()
@@ -1539,6 +1544,9 @@ function Loader:load(data)
require("src.mods.Builtins").install(self.content, data, self.generation)
self:_loadState()
self:_discover()
if self.safeMode then
for id in pairs(self.mods) do self.disabled[id] = true end
end
-- Existing installs stored one shared answer. Once their manifests are
-- known, split that answer across every game before the next launcher/game
-- toggle can change one independently. _loadState already used the same
@@ -1574,7 +1582,7 @@ function Loader:load(data)
-- the one build where its env var is set.
for id, mod in pairs(self.mods) do
local envName = mod.manifest.force_enable_env
if envName and os.getenv(envName) == "1" then
if not self.safeMode and envName and os.getenv(envName) == "1" then
self.disabled[id] = nil
end
end
@@ -1740,6 +1748,7 @@ function Loader:status()
local manifest = {}
for key, value in pairs(mod.manifest) do manifest[key] = value end
manifest.enabled = mod.enabled ~= false
manifest.safeMode = self.safeMode == true
manifest.state = mod.state or (manifest.enabled and "loaded" or "disabled")
manifest.error = mod.failure
-- set instead of `error` when the mod was left out for a reason that is
+37 -4
View File
@@ -365,9 +365,13 @@ end
function ManagerState:detailRows(m)
local rows = {}
rows[#rows + 1] = { label = m.enabled and "DISABLE" or "ENABLE",
action = function() self:beginToggle(m) end }
if self:schemaFor(m) then
if Runtime.safeMode then
rows[#rows + 1] = { label = "SAFE MODE ACTIVE", inert = true }
else
rows[#rows + 1] = { label = m.enabled and "DISABLE" or "ENABLE",
action = function() self:beginToggle(m) end }
end
if not Runtime.safeMode and self:schemaFor(m) then
rows[#rows + 1] = { label = Strings("OPTIONS.."),
action = function() self:openOptions(m) end }
end
@@ -383,7 +387,8 @@ function ManagerState:detailRows(m)
-- what this mod does.
local loader = self.game.mods
local version, gen = self:targetGame()
if loader and loader.setGen2Forced and not ModTargets.supports(m, version, gen) then
if loader and loader.setGen2Forced and not Runtime.safeMode
and not ModTargets.supports(m, version, gen) then
rows[#rows + 1] = {
label = m.gen2Forced and Strings("DON'T TRY HERE") or Strings("TRY HERE ANYWAY"),
action = function() self:toggleGen2Force(m) end }
@@ -674,6 +679,10 @@ end
-- ------- the enable/disable flow
function ManagerState:beginToggle(m)
if Runtime.safeMode then
self:notify("SAFE MODE ACTIVE")
return
end
if not m then return end
local want = not m.enabled
local loader = self.game.mods
@@ -711,6 +720,10 @@ end
-- override is scoped to THIS game, and a boot that cannot name one keeps it in
-- memory only, which the notice says rather than promising a restart.
function ManagerState:toggleGen2Force(m)
if Runtime.safeMode then
self:notify("SAFE MODE ACTIVE")
return
end
local loader = self.game.mods
if not (loader and loader.setGen2Forced) then return end
local want = not m.gen2Forced
@@ -743,6 +756,10 @@ function ManagerState:enableScope()
end
function ManagerState:commitToggle(apply)
if Runtime.safeMode then
self:notify("SAFE MODE ACTIVE")
return
end
local loader = self.game.mods
local opts = self:optionsTable()
local scope = self:enableScope()
@@ -757,6 +774,10 @@ function ManagerState:commitToggle(apply)
end
function ManagerState:discardChanges()
if Runtime.safeMode then
self:notify("SAFE MODE ACTIVE")
return
end
local loader = self.game.mods
local opts = self:optionsTable()
local scope = self:enableScope()
@@ -802,6 +823,10 @@ function ManagerState:persistOptions()
end
function ManagerState:applyProfile(p)
if Runtime.safeMode then
self:notify("SAFE MODE ACTIVE")
return
end
local mods = self:manifestMap()
local set = self:enabledSet()
local combined = {}
@@ -963,6 +988,10 @@ function ManagerState:optionValue(modId, row)
end
function ManagerState:setOption(modId, key, value)
if Runtime.safeMode then
self:notify("SAFE MODE ACTIVE")
return false
end
local save = self.game.save
if save and save.options then
save.options.modOptions = save.options.modOptions or {}
@@ -1123,6 +1152,10 @@ function ManagerState:buildOptionRows(m, schema)
end
function ManagerState:openOptions(m)
if Runtime.safeMode then
self:notify("SAFE MODE ACTIVE")
return
end
local schema = self:schemaFor(m)
if not schema then
self:notify("NO OPTIONS")
+10
View File
@@ -33,11 +33,21 @@ Runtime.currentMod = nil
-- currentMod went back to nil (src/mods/Sandbox.lua)
Runtime.modRequire = nil
Runtime.safeMode = false
function Runtime.install(events, hooks, errors)
Runtime.events, Runtime.hooks = events, hooks
Runtime.errors = errors
end
function Runtime.reset()
Runtime.events = NullEvents
Runtime.hooks = NullHooks
Runtime.errors = nil
Runtime.currentMod = nil
Runtime.modRequire = nil
end
-- attribute a runtime failure to the mod that owns the offending record.
-- "base" is the engine's own owner id: a vanilla record that fails is a
-- console line, not something the manager can ask the player to disable.
+9
View File
@@ -86,6 +86,7 @@ local function drain()
else
j.status = msg.ok and "ok" or "error"
j.body, j.err, j.path = msg.body, msg.err, msg.path
j.code = msg.code
j.progress = msg.ok and 1 or j.progress
end
end
@@ -143,6 +144,14 @@ function Fetch.post(url, body, opts)
contentType = opts.contentType, maxSeconds = opts.maxSeconds })
end
function Fetch.request(url, opts)
opts = opts or {}
return submit({ kind = "request", url = url,
method = opts.method, body = opts.body, headers = opts.headers,
userAgent = opts.userAgent or "gen1recomp",
maxSeconds = opts.maxSeconds })
end
-- Download a URL to `saveRel`, a path relative to the LOVE save directory.
-- Progress is reported as a 0..1 fraction when `size` is known.
function Fetch.download(url, saveRel, opts)
+19
View File
@@ -113,6 +113,22 @@ local function doPost(job)
post({ id = job.id, ok = true, done = true })
end
local function doRequest(job)
if not HostShell then
post({ id = job.id, ok = false, err = "no transport" })
return
end
local body, err, code = HostShell.httpRequest(job.url, {
method = job.method, body = job.body, headers = job.headers,
userAgent = job.userAgent,
maxTime = tonumber(job.maxSeconds) or GET_MAX_SECONDS })
if not code then
post({ id = job.id, ok = false, err = err or "request failed" })
return
end
post({ id = job.id, ok = true, body = body or "", code = code, done = true })
end
while true do
local job = cmdCh:demand()
-- The flag is checked before the job's KIND, so a worker woken by a
@@ -131,6 +147,9 @@ while true do
elseif job.kind == "post" then
local ok, err = pcall(doPost, job)
if not ok then post({ id = job.id, ok = false, err = tostring(err) }) end
elseif job.kind == "request" then
local ok, err = pcall(doRequest, job)
if not ok then post({ id = job.id, ok = false, err = tostring(err) }) end
elseif job.kind == "download" then
local ok, err = pcall(doDownload, job)
if not ok then post({ id = job.id, ok = false, err = tostring(err) }) end
+81
View File
@@ -0,0 +1,81 @@
local GameViewport = require("src.render.GameViewport")
local TouchSkin = require("src.core.TouchSkin")
local Playfield = {}
Playfield.WIDTH, Playfield.HEIGHT = 160, 144
Playfield.entered = false
Playfield.box = nil
local function clampRect(x, y, w, h, sw, sh)
if type(w) ~= "number" or type(h) ~= "number" then return nil end
if w ~= w or h ~= h then return nil end
x = math.floor(tonumber(x) or 0)
y = math.floor(tonumber(y) or 0)
w, h = math.floor(w), math.floor(h)
if x < 0 then w, x = w + x, 0 end
if y < 0 then h, y = h + y, 0 end
if x + w > sw then w = sw - x end
if y + h > sh then h = sh - y end
if w < 1 or h < 1 then return nil end
return x, y, w, h
end
function Playfield.cutout(sw, sh)
if Playfield.entered then return nil end
if type(sw) ~= "number" or type(sh) ~= "number" then return nil end
if sw < 1 or sh < 1 then return nil end
if type(TouchSkin.viewport) ~= "function" then return nil end
local ok, x, y, w, h, fill, expand = pcall(TouchSkin.viewport, sw, sh)
if not ok then return nil end
local cx, cy, cw, ch = clampRect(x, y, w, h, sw, sh)
if not cx then return nil end
return cx, cy, cw, ch, fill == true, expand == true
end
function Playfield.rect(sw, sh)
local x, y, w, h, _, expand = Playfield.cutout(sw, sh)
if not x then return 0, 0, sw or 0, sh or 0, false end
if expand then return x, y, w, h, true end
local s = math.max(1, math.floor(math.min(w / Playfield.WIDTH,
h / Playfield.HEIGHT)))
local pw = math.min(w, Playfield.WIDTH * s)
local ph = math.min(h, Playfield.HEIGHT * s)
return x + math.floor((w - pw) / 2), y + math.floor((h - ph) / 2), pw, ph, true
end
function Playfield.enter(x, y, w, h)
Playfield.entered = true
Playfield.box = { x = x, y = y, w = w, h = h }
end
function Playfield.leave()
Playfield.entered = false
Playfield.box = nil
end
function Playfield.dimensions()
if Playfield.entered and Playfield.box then
return Playfield.box.w, Playfield.box.h
end
return GameViewport.dimensions()
end
function Playfield.push(sw, sh)
local x, y, w, h, active = Playfield.rect(sw, sh)
local G = love.graphics
G.push("all")
if active then G.setScissor(x, y, w, h) end
G.translate(x, y)
Playfield.enter(x, y, w, h)
return w, h, x, y, active
end
function Playfield.pop()
Playfield.leave()
love.graphics.setScissor()
love.graphics.pop()
end
return Playfield
+113 -61
View File
@@ -15,7 +15,7 @@ local Runtime = require("src.mods.Runtime")
local GameViewport = require("src.render.GameViewport")
-- leaf module (no renderer dependency), so requiring it here cannot cycle
local FaithfulRes = require("src.core.FaithfulRes")
local TouchSkin = require("src.core.TouchSkin")
local Playfield = require("src.render.Playfield")
local Renderer = {}
@@ -86,12 +86,12 @@ local function displayMetrics()
if dpiX < 1e-6 then dpiX = 1 end
if dpiY < 1e-6 then dpiY = 1 end
local vx, vy = 0, 0
local sx, sy, sw, sh = TouchSkin.viewport(pw, ph)
if sw and sw >= 1 and sh >= 1 then
vx, vy = math.floor(sx), math.floor(sy)
pw, ph = math.floor(sw), math.floor(sh)
local cut, grow = false, false
local sx, sy, sw, sh, _, expand = Playfield.cutout(pw, ph)
if sx then
vx, vy, pw, ph, cut, grow = sx, sy, sw, sh, true, expand
end
return ww, wh, pw, ph, dpiX, dpiY, vx, vy
return ww, wh, pw, ph, dpiX, dpiY, vx, vy, cut, grow
end
function Renderer:init()
@@ -269,7 +269,7 @@ end
-- corners; flat mode returns exactly today's size (growth factor is 1 when
-- tilt is inactive).
function Renderer:worldViewSize()
local _, _, pw, ph = displayMetrics()
local _, _, pw, ph, _, _, _, _, cut, grow = displayMetrics()
-- FAITHFUL RATIO on mobile. The world pass deliberately expands to cover the
-- WHOLE display, so letterbox voids become more map instead of black bars.
-- That is why the lock appeared to do nothing in the overworld: it shrank
@@ -281,13 +281,11 @@ function Renderer:worldViewSize()
-- this is the same sum with the viewport standing in for the window, so
-- both platforms show the same map area at the same zoom.
local cap = FaithfulRes.scaleCap()
if not cap and TouchSkin.hasViewport() then
local page = TouchSkin.page()
if not page.viewportExpand then cap = self:fitScale() end
end
if not cap and cut and not grow then cap = self:fitScale() end
if cap then
local uiw, uih = self:uiSize()
pw, ph = uiw * cap, uih * cap
pw = cut and math.min(pw, uiw * cap) or uiw * cap
ph = cut and math.min(ph, uih * cap) or uih * cap
end
local sp = Zoom.scale(self:fitScale())
local vw, vh = math.ceil(pw / sp), math.ceil(ph / sp)
@@ -346,19 +344,20 @@ end
-- window is the classic wipe unchanged.
--
-- Sx/Sy are LOVE-unit scales (Sy defaults to Sx on uniform surfaces).
function Renderer:drawBattleWipe(wipe, ww, wh, ox, oy, vpw, vph, Sx, Sy)
function Renderer:drawBattleWipe(wipe, ww, wh, ox, oy, vpw, vph, Sx, Sy, wx, wy)
if not wipe or not wipe.prog or wipe.prog <= 0 then return end
Sy = Sy or Sx
wx, wy = wx or 0, wy or 0
local TW, TH = 8 * Sx, 8 * Sy
if TW < 1 then TW = 1 end
if TH < 1 then TH = 1 end
local prog = math.min(1, wipe.prog)
love.graphics.setColor(0, 0, 0, 1)
love.graphics.setScissor(0, 0, ww, wh)
love.graphics.setScissor(wx, wy, ww, wh)
if prog >= 1 then
love.graphics.rectangle("fill", 0, 0, ww, wh)
love.graphics.rectangle("fill", wx, wy, ww, wh)
love.graphics.setScissor()
love.graphics.setColor(1, 1, 1, 1)
return
@@ -366,10 +365,10 @@ function Renderer:drawBattleWipe(wipe, ww, wh, ox, oy, vpw, vph, Sx, Sy)
-- whole-tile padding out to each window edge, keeping the grid in phase
-- with the letterbox's tiles
local padL = math.max(0, math.ceil(ox / TW))
local padT = math.max(0, math.ceil(oy / TH))
local padR = math.max(0, math.ceil((ww - ox - vpw) / TW))
local padB = math.max(0, math.ceil((wh - oy - vph) / TH))
local padL = math.max(0, math.ceil((ox - wx) / TW))
local padT = math.max(0, math.ceil((oy - wy) / TH))
local padR = math.max(0, math.ceil((wx + ww - ox - vpw) / TW))
local padB = math.max(0, math.ceil((wy + wh - oy - vph) / TH))
local lbCols = math.max(1, math.floor(vpw / TW + 0.5))
local lbRows = math.max(1, math.floor(vph / TH + 0.5))
local cols, rows = padL + lbCols + padR, padT + lbRows + padB
@@ -396,9 +395,9 @@ function Renderer:drawBattleWipe(wipe, ww, wh, ox, oy, vpw, vph, Sx, Sy)
for row = 0, rows - 1 do
local y = y0 + row * TH
if row % 2 == 0 then
love.graphics.rectangle("fill", 0, y, w, TH)
love.graphics.rectangle("fill", wx, y, w, TH)
else
love.graphics.rectangle("fill", ww - w, y, w, TH)
love.graphics.rectangle("fill", wx + ww - w, y, w, TH)
end
end
elseif style == "vstripes" then
@@ -406,21 +405,21 @@ function Renderer:drawBattleWipe(wipe, ww, wh, ox, oy, vpw, vph, Sx, Sy)
for col = 0, cols - 1 do
local x = x0 + col * TW
if col % 2 == 0 then
love.graphics.rectangle("fill", x, 0, TW, h)
love.graphics.rectangle("fill", x, wy, TW, h)
else
love.graphics.rectangle("fill", x, wh - h, TW, h)
love.graphics.rectangle("fill", x, wy + wh - h, TW, h)
end
end
elseif style == "shrink" then
local h, w = wh / 2 * prog, ww / 2 * prog
love.graphics.rectangle("fill", 0, 0, ww, h)
love.graphics.rectangle("fill", 0, wh - h, ww, h)
love.graphics.rectangle("fill", 0, 0, w, wh)
love.graphics.rectangle("fill", ww - w, 0, w, wh)
love.graphics.rectangle("fill", wx, wy, ww, h)
love.graphics.rectangle("fill", wx, wy + wh - h, ww, h)
love.graphics.rectangle("fill", wx, wy, w, wh)
love.graphics.rectangle("fill", wx + ww - w, wy, w, wh)
else -- split: a black cross growing out of the centre in both axes
local h, w = wh / 2 * prog, ww / 2 * prog
love.graphics.rectangle("fill", 0, wh / 2 - h, ww, h * 2)
love.graphics.rectangle("fill", ww / 2 - w, 0, w * 2, wh)
love.graphics.rectangle("fill", wx, wy + wh / 2 - h, ww, h * 2)
love.graphics.rectangle("fill", wx + ww / 2 - w, wy, w * 2, wh)
end
end
love.graphics.setScissor()
@@ -542,7 +541,8 @@ end
-- into (nil = default framebuffer; presentCanvas when CRT is on).
-- Returns true on success; false (no shader/mesh) tells endFrame to fall
-- back to the flat blit unchanged.
function Renderer:drawTiltedWorld(zoneList, sx, sy, wox, woy, target)
function Renderer:drawTiltedWorld(zoneList, sx, sy, wox, woy, target,
boxX, boxY, boxW, boxH)
local shader = self:tiltShader()
local mesh = self:tiltMesh()
if not (shader and mesh) then return false end
@@ -593,12 +593,16 @@ function Renderer:drawTiltedWorld(zoneList, sx, sy, wox, woy, target)
mesh:setTexture(self.tiltCanvas)
mesh:setVertices(Tilt.meshCorners(wvw, wvh))
love.graphics.push()
if boxW and boxH and boxW > 0 and boxH > 0 then
love.graphics.setScissor(boxX, boxY, boxW, boxH)
end
love.graphics.translate(wox, woy)
love.graphics.scale(sx, sy)
love.graphics.setColor(1, 1, 1, 1)
love.graphics.setShader(shader)
love.graphics.draw(mesh)
love.graphics.setShader()
love.graphics.setScissor()
love.graphics.pop()
return true
end
@@ -755,20 +759,23 @@ end
-- scissored through the shade-remap shader, later zones on top.
-- When GBC FX is active the composite is drawn into presentCanvas and
-- presented through the GBC FX shader as a final pass.
function Renderer:endFrame(zones, worldZones)
GameViewport.setTarget()
local ww, wh, pw, ph, dpiX, dpiY, vx, vy = displayMetrics()
local vux, vuy = vx / dpiX, vy / dpiY
local vuw, vuh = pw / dpiX, ph / dpiY
function Renderer:frameRects()
local ww, wh, pw, ph, dpiX, dpiY, vx, vy, cut = displayMetrics()
local r = {
ww = ww, wh = wh, pw = pw, ph = ph, dpiX = dpiX, dpiY = dpiY,
vx = vx, vy = vy, cut = cut,
vux = vx / dpiX, vuy = vy / dpiY, vuw = pw / dpiX, vuh = ph / dpiY,
}
-- Sp = integer framebuffer pixels per GB pixel;
-- Sx/Sy = LOVE-unit draw scales (may differ when dpiX ≠ dpiY).
local Sp = self:fitScale()
local Sx, Sy = Sp / dpiX, Sp / dpiY
r.Sp, r.Sx, r.Sy = Sp, Sp / dpiX, Sp / dpiY
local uiw, uih = self:uiSize()
local vpw, vph = uiw * Sx, uih * Sy
r.uiw, r.uih = uiw, uih
r.vpw, r.vph = uiw * r.Sx, uih * r.Sy
-- Snap the letterbox origin to a framebuffer pixel, then convert to units.
local ox = (vx + math.floor((pw - uiw * Sp) / 2)) / dpiX
local oy = (vy + math.floor((ph - uih * Sp) / 2)) / dpiY
r.ox = (vx + math.floor((pw - uiw * Sp) / 2)) / dpiX
r.oy = (vy + math.floor((ph - uih * Sp) / 2)) / dpiY
-- The UI has its own scale: it steps down as the survey zoom goes out (see
-- uiScale), so it can be smaller than the world letterbox. Un-zoomed these
-- are identical to Sp/ox/oy and every rect below is what it always was.
@@ -782,10 +789,38 @@ function Renderer:endFrame(zones, worldZones)
if self.uiFill then
Up = math.min(ph / uih, pw / uiw)
end
local Ux, Uy = Up / dpiX, Up / dpiY
local uvpw, uvph = uiw * Ux, uih * Uy
local uox = (vx + math.floor((pw - uiw * Up) / 2)) / dpiX
local uoy = (vy + math.floor((ph - uih * Up) / 2)) / dpiY
if uiw * Up > pw or uih * Up > ph then
Up = math.min(ph / uih, pw / uiw)
end
r.Up, r.Ux, r.Uy = Up, Up / dpiX, Up / dpiY
r.uvpw, r.uvph = uiw * r.Ux, uih * r.Uy
r.uox = (vx + math.floor((pw - uiw * Up) / 2)) / dpiX
r.uoy = (vy + math.floor((ph - uih * Up) / 2)) / dpiY
return r
end
function Renderer.clipToView(r, x, y, w, h)
local x2, y2 = math.min(x + w, r.vux + r.vuw), math.min(y + h, r.vuy + r.vuh)
x, y = math.max(x, r.vux), math.max(y, r.vuy)
return x, y, math.max(0, x2 - x), math.max(0, y2 - y)
end
function Renderer:playfieldRect()
local r = self:frameRects()
return r.vux, r.vuy, r.vuw, r.vuh, r.cut
end
function Renderer:endFrame(zones, worldZones)
GameViewport.setTarget()
local R = self:frameRects()
local ww, wh, pw, ph = R.ww, R.wh, R.pw, R.ph
local dpiX, dpiY, vx, vy, cut = R.dpiX, R.dpiY, R.vx, R.vy, R.cut
local vux, vuy, vuw, vuh = R.vux, R.vuy, R.vuw, R.vuh
local Sp, Sx, Sy = R.Sp, R.Sx, R.Sy
local uiw, uih = R.uiw, R.uih
local vpw, vph, ox, oy = R.vpw, R.vph, R.ox, R.oy
local Ux, Uy = R.Ux, R.Uy
local uvpw, uvph, uox, uoy = R.uvpw, R.uvph, R.uox, R.uoy
local GBCFX = require("src.render.GBCFX")
-- Forced mono/Classic modes still need a whole-screen zone when a state
-- exposes no SGB packets (raw DMG canvas), so sendColors can remap.
@@ -814,6 +849,7 @@ function Renderer:endFrame(zones, worldZones)
ww = ww, wh = wh, pw = pw, ph = ph, ox = ox, oy = oy,
vpw = vpw, vph = vph, uiw = uiw, uih = uih,
scale = Sp, Sx = Sx, Sy = Sy, dpiX = dpiX, dpiY = dpiY,
viewX = vux, viewY = vuy, viewWidth = vuw, viewHeight = vuh,
secondScreen = require("src.render.SecondScreen"),
}
if Runtime.call("render.compose", function() return false end, self, ctx) == true then
@@ -901,23 +937,29 @@ function Renderer:endFrame(zones, worldZones)
clearR, clearG, clearB = PaletteFX.paperShade(Game and Game.data)
end
end
if cut then
love.graphics.setColor(0, 0, 0, 1)
love.graphics.rectangle("fill", 0, 0, ww, wh)
end
love.graphics.setColor(clearR, clearG, clearB, 1)
love.graphics.rectangle("fill", 0, 0, ww, wh)
love.graphics.rectangle("fill", vux, vuy, vuw, vuh)
if extendedBlackBand then
love.graphics.setColor(bandR, bandG, bandB, 1)
love.graphics.rectangle("fill", uox, 0, uvpw, wh)
love.graphics.rectangle("fill", uox, vuy, uvpw, vuh)
end
love.graphics.setColor(1, 1, 1, 1)
-- render.letterbox: SGB borders / custom void art in the bars around the
-- 160x144 (or world) blit. Drawn after the clear and before the game
-- canvas so the playfield sits on top of the border.
if Runtime.wantsHook("render.letterbox") then
if cut then love.graphics.setScissor(vux, vuy, vuw, vuh) end
Runtime.call("render.letterbox", function() end, {
ww = ww, wh = wh, pw = pw, ph = ph,
ox = ox, oy = oy, vpw = vpw, vph = vph,
scale = Sp, dpiX = dpiX, dpiY = dpiY,
worldActive = self.worldActive and true or false,
})
if cut then love.graphics.setScissor() end
end
-- see Renderer:blitCanvas; bound here to the frame's dpi so the composite
@@ -928,6 +970,10 @@ function Renderer:endFrame(zones, worldZones)
bx, by, boxX, boxY, boxW, boxH, dpiX, dpiY)
end
local function clipToView(x, y, w, h)
return Renderer.clipToView(R, x, y, w, h)
end
if self.worldOverride then
-- A render pipeline already produced the whole world -- terrain,
-- characters and its own FX overlay -- as one window-resolution image,
@@ -938,9 +984,9 @@ function Renderer:endFrame(zones, worldZones)
love.graphics.setScissor(vux, vuy, vuw, vuh)
local loveMajor = love.getVersion()
if love.system and love.system.getOS and love.system.getOS() == "iOS" and loveMajor >= 12 then
love.graphics.draw(self.worldOverride, 0, wh, 0, 1 / dpiX, -1 / dpiY)
love.graphics.draw(self.worldOverride, vux, vuy + vuh, 0, 1 / dpiX, -1 / dpiY)
else
love.graphics.draw(self.worldOverride, 0, 0, 0, 1 / dpiX, 1 / dpiY)
love.graphics.draw(self.worldOverride, vux, vuy, 0, 1 / dpiX, 1 / dpiY)
end
love.graphics.setScissor()
-- the screen-space overlays the flat path draws over its composite
@@ -964,7 +1010,8 @@ function Renderer:endFrame(zones, worldZones)
-- falls through to the flat blit, keeping the flat frame byte-for-byte
-- identical to today.
local projected =
Tilt.active() and self:drawTiltedWorld(worldZones or zones, sx, sy, wox, woy, present)
Tilt.active() and self:drawTiltedWorld(worldZones or zones, sx, sy, wox, woy,
present, vux, vuy, vuw, vuh)
if not projected then
if worldZones then
blit(self.worldCanvas, sx, sy, worldZones, sx, sy, wox, woy, vux, vuy, vuw, vuh)
@@ -1061,7 +1108,7 @@ function Renderer:endFrame(zones, worldZones)
and not FaithfulRes.scaleCap() then
local ok, Game = pcall(require, "src.core.Game")
love.graphics.setColor(PaletteFX.paperShade(ok and Game and Game.data))
love.graphics.rectangle("fill", uox, 0, uvpw, wh)
love.graphics.rectangle("fill", uox, vuy, uvpw, vuh)
love.graphics.setColor(1, 1, 1, 1)
end
@@ -1070,9 +1117,9 @@ function Renderer:endFrame(zones, worldZones)
-- always been.
local anchors = self.uiAnchors
if not anchors or #anchors == 0 then
blit(self.canvas, Ux, Uy, zones, Ux, Uy, uox, uoy, uox, uoy, uvpw, uvph)
blit(self.canvas, Ux, Uy, zones, Ux, Uy, uox, uoy, clipToView(uox, uoy, uvpw, uvph))
else
local rest = { { uox, uoy, uvpw, uvph } }
local rest = { { clipToView(uox, uoy, uvpw, uvph) } }
local placed = {}
for _, a in ipairs(anchors) do
local dw, dh = a.w * Ux, a.h * Uy
@@ -1086,19 +1133,19 @@ function Renderer:endFrame(zones, worldZones)
local dx, dy
if a.anchor == "bottom" then
dx = uox + a.x * Ux -- horizontally it stays with the letterbox
dy = wh - gapB - dh
dy = vuy + vuh - gapB - dh
elseif a.anchor == "top" then
dx = uox + a.x * Ux -- horizontally it stays with the letterbox
dy = a.y * Uy
dy = vuy + a.y * Uy
elseif a.anchor == "topright" then
dx = ww - gapR - dw
dy = a.y * Uy
dx = vux + vuw - gapR - dw
dy = vuy + a.y * Uy
else -- unknown anchor: leave it where it is
dx, dy = uox + a.x * Ux, uoy + a.y * Uy
end
if a.windowClamped then
dx = math.max(0, math.min(math.max(0, ww - dw), dx))
dy = math.max(0, math.min(math.max(0, wh - dh), dy))
dx = math.max(vux, math.min(math.max(vux, vux + vuw - dw), dx))
dy = math.max(vuy, math.min(math.max(vuy, vuy + vuh - dh), dy))
end
placed[#placed + 1] = { a = a, dx = dx, dy = dy, dw = dw, dh = dh }
if a.extract then
@@ -1113,13 +1160,14 @@ function Renderer:endFrame(zones, worldZones)
-- The zone scissors are computed from the same origin, so an SGB
-- region travels with the element instead of staying in the letterbox.
blit(p.a.canvas or self.canvas, Ux, Uy, zones, Ux, Uy,
p.dx - p.a.x * Ux, p.dy - p.a.y * Uy, p.dx, p.dy, p.dw, p.dh)
p.dx - p.a.x * Ux, p.dy - p.a.y * Uy,
clipToView(p.dx, p.dy, p.dw, p.dh))
end
end
local uiRedraws = PaletteFX.uiSpriteRedraws()
if uiRedraws[1] then
love.graphics.setColor(1, 1, 1, 1)
love.graphics.setScissor(uox, uoy, uvpw, uvph)
love.graphics.setScissor(clipToView(uox, uoy, uvpw, uvph))
for _, r in ipairs(uiRedraws) do
if r.quad then
love.graphics.draw(r.image, r.quad, uox + r.x * Ux, uoy + r.y * Uy,
@@ -1135,7 +1183,8 @@ function Renderer:endFrame(zones, worldZones)
-- over the finished composite rather than under the UI blit. On hardware
-- it is the tilemap being overwritten -- there is nothing it does not cover.
if self.battleWipe then
self:drawBattleWipe(self.battleWipe, ww, wh, ox, oy, vpw, vph, Sx, Sy)
self:drawBattleWipe(self.battleWipe, vuw, vuh, ox, oy, vpw, vph, Sx, Sy,
vux, vuy)
end
-- Palette-register effects (BattleTransition_FlashScreen's rBGP writes, the
@@ -1157,7 +1206,7 @@ function Renderer:endFrame(zones, worldZones)
if FaithfulRes.scaleCap() then
love.graphics.rectangle("fill", ox, oy, vpw, vph)
else
love.graphics.rectangle("fill", 0, 0, ww, wh)
love.graphics.rectangle("fill", vux, vuy, vuw, vuh)
end
love.graphics.setColor(1, 1, 1, 1)
end
@@ -1181,6 +1230,7 @@ function Renderer:endFrame(zones, worldZones)
generation = 1,
}) == true
if not outputHandled then
if cut then love.graphics.setScissor(vux, vuy, vuw, vuh) end
if GBCFX.active() then
-- shader grid/shadow math is in framebuffer pixels
GBCFX.present(composed, Sp)
@@ -1190,6 +1240,7 @@ function Renderer:endFrame(zones, worldZones)
love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(composed, 0, 0)
end
if cut then love.graphics.setScissor() end
end
end
self.worldActive = false
@@ -1202,6 +1253,7 @@ function Renderer:endFrame(zones, worldZones)
gameWidth = vpw, gameHeight = vph,
scale = Sp,
dpiX = dpiX, dpiY = dpiY,
viewX = vux, viewY = vuy, viewWidth = vuw, viewHeight = vuh,
}
end
+197
View File
@@ -0,0 +1,197 @@
local Json = require("src.link.Json")
local SyncClient = {}
SyncClient.__index = SyncClient
SyncClient.DEFAULT_URL = os.getenv("POKEPORT_SYNC_URL")
or "https://sync.147.182.215.255.sslip.io"
SyncClient.MAX_BLOB = 2 * 1024 * 1024
SyncClient.MAX_RESPONSE = 4 * 1024 * 1024
SyncClient.TIMEOUT = 25
function SyncClient.normalizeCode(code)
if type(code) ~= "string" and type(code) ~= "number" then return nil end
local digits = tostring(code):gsub("[^%d]", "")
if #digits ~= 8 then return nil end
return digits
end
function SyncClient.formatCode(code)
local digits = SyncClient.normalizeCode(code)
if not digits then return nil end
return digits:sub(1, 4) .. "-" .. digits:sub(5, 8)
end
local function escape(s)
return (tostring(s):gsub("[^%w%-%._~]", function(c)
return ("%%%02X"):format(c:byte())
end))
end
local function query(params)
local names = {}
for name in pairs(params or {}) do names[#names + 1] = tostring(name) end
table.sort(names)
local out = {}
for _, name in ipairs(names) do
out[#out + 1] = escape(name) .. "=" .. escape(params[name])
end
if #out == 0 then return "" end
return "?" .. table.concat(out, "&")
end
function SyncClient.new(opts)
opts = opts or {}
local base = opts.baseUrl or SyncClient.DEFAULT_URL
base = tostring(base):gsub("/+$", "")
local transport = opts.transport
if not transport then
transport = require("src.sync.SyncTransport").new()
end
return setmetatable({
baseUrl = base,
transport = transport,
account = opts.account,
token = opts.token,
}, SyncClient)
end
function SyncClient:setAuth(account, token)
self.account = type(account) == "string" and account ~= "" and account or nil
self.token = type(token) == "string" and token ~= "" and token or nil
end
function SyncClient:clearAuth()
self.account, self.token = nil, nil
end
function SyncClient:isLinked()
return self.account ~= nil and self.token ~= nil
end
function SyncClient:send(method, path, body, opts)
opts = opts or {}
local headers = { ["Accept"] = "application/json" }
local payload
if body ~= nil then
local ok, encoded = pcall(Json.encode, body)
if not ok then return nil, "could not encode the request" end
payload = encoded
headers["Content-Type"] = "application/json"
end
if not opts.noAuth then
if not self:isLinked() then return nil, "this device is not linked" end
headers["x-sync-account"] = self.account
headers["x-sync-token"] = self.token
end
local url = self.baseUrl .. path .. query(opts.params)
local handle = self.transport:begin({
url = url, method = method, body = payload, headers = headers,
maxSeconds = opts.maxSeconds or SyncClient.TIMEOUT,
})
if handle == nil then return nil, "no network transport" end
return handle
end
function SyncClient:poll(handle)
if handle == nil then return { status = "error", err = "no request" } end
local res = self.transport:poll(handle)
if res.status == "pending" then return { status = "pending" } end
if res.status ~= "ok" then
return { status = "error", err = res.err or "sync request failed" }
end
local raw = res.body or ""
local code = tonumber(res.code) or 0
if #raw > SyncClient.MAX_RESPONSE then
return { status = "error", code = code, err = "the reply was too large" }
end
local data, decodeErr = Json.decode(raw, SyncClient.MAX_RESPONSE)
if type(data) ~= "table" then
local why = Json.describeUnexpected(raw) or decodeErr or "unreadable reply"
if code >= 400 then
return { status = "error", code = code,
err = ("the server answered %d"):format(code) }
end
return { status = "error", code = code, err = why }
end
if code >= 400 or data.error then
local err = data.error
if type(err) ~= "string" or err == "" then
err = ("the server answered %d"):format(code)
end
return { status = "error", code = code, data = data, err = err }
end
return { status = "ok", code = code, data = data }
end
function SyncClient:release(handle)
if handle ~= nil then self.transport:release(handle) end
end
function SyncClient:create(deviceLabel)
return self:send("POST", "/sync/create",
{ device = deviceLabel or "device" }, { noAuth = true })
end
function SyncClient:link(code1, code2, deviceLabel)
local a = SyncClient.normalizeCode(code1)
local b = SyncClient.normalizeCode(code2)
if not a or not b then return nil, "both codes are 8 digits" end
return self:send("POST", "/sync/link",
{ code1 = a, code2 = b, device = deviceLabel or "device" },
{ noAuth = true })
end
function SyncClient:fetchState()
return self:send("GET", "/sync/state")
end
function SyncClient:putSave(entry)
if type(entry) ~= "table" then return nil, "missing save entry" end
if type(entry.blob) ~= "string" or entry.blob == "" then
return nil, "missing save data"
end
if #entry.blob > SyncClient.MAX_BLOB then
return nil, "this save is too large to sync"
end
return self:send("PUT", "/sync/save", {
version = entry.version,
slot = entry.slot,
meta = entry.meta,
blob = entry.blob,
baseRev = entry.baseRev,
force = entry.force and true or nil,
})
end
function SyncClient:getSave(version, id)
return self:send("GET", "/sync/save", nil,
{ params = { version = version, id = id } })
end
function SyncClient:putMods(manifest)
return self:send("PUT", "/sync/mods", { manifest = manifest })
end
function SyncClient:getMods()
return self:send("GET", "/sync/mods")
end
function SyncClient:shareMods(manifest)
return self:send("POST", "/sync/modshare", { manifest = manifest })
end
function SyncClient:fetchShare(code)
local trimmed = tostring(code or ""):gsub("%s", ""):upper()
if not trimmed:match("^[A-Z2-9]+$") or #trimmed ~= 6 then
return nil, "share codes are 6 characters"
end
return self:send("GET", "/sync/modshare", nil,
{ noAuth = true, params = { code = trimmed } })
end
function SyncClient:unlink(device)
return self:send("POST", "/sync/unlink", { device = device })
end
return SyncClient
+690
View File
@@ -0,0 +1,690 @@
local SyncClient = require("src.sync.SyncClient")
local SyncState = require("src.sync.SyncState")
local SyncMods = require("src.sync.SyncMods")
local SyncEngine = {}
SyncEngine.__index = SyncEngine
SyncEngine.UPLOAD_DEBOUNCE = 5
SyncEngine.AUTO_INTERVAL = 300
SyncEngine.MAX_STEPS_PER_UPDATE = 8
local IDLE_STATUS = "Ready"
local UNLINKED_STATUS = "Not set up"
local function saveApi()
return require("src.core.SaveData")
end
local function gameVersions()
return require("src.core.GameVersion").ORDER
end
local function slotForPlaythrough(options, version, playthroughId)
local byVersion = options.playthroughIds and options.playthroughIds[version]
for slotId, id in pairs(byVersion or {}) do
if id == playthroughId then return slotId end
end
return nil
end
function SyncEngine.defaultSaves()
return {
list = function()
local SaveData = saveApi()
local options = SaveData.loadOptions()
local out = {}
for _, version in ipairs(gameVersions()) do
for _, slot in ipairs(SaveData.listSlots(version)) do
if slot.exists then
local source = SaveData.readSlotSource(version, slot.id)
local save = source and SaveData.decode(source)
if type(save) == "table" then
local meta = type(save.meta) == "table" and save.meta or {}
local id = meta.playthroughId
if type(id) ~= "string" or id == "" then
local byVersion = options.playthroughIds
and options.playthroughIds[version]
id = byVersion and byVersion[slot.id] or nil
end
if id then
local name, summary = SaveData.slotSummary(save)
out[#out + 1] = {
version = version,
slot = slot.id,
playthroughId = id,
blob = source,
meta = {
savedAt = tonumber(meta.savedAt),
sessionStart = tonumber(meta.sessionStart),
playthroughId = id,
format = meta.format,
engine = meta.engine,
playTime = tonumber(save.playTime),
summary = {
name = name,
badges = summary and summary.badges,
timeText = summary and summary.timeText,
dexCount = summary and summary.dexCount,
},
},
}
end
end
end
end
end
return out
end,
write = function(version, playthroughId, blob, mode)
local SaveData = saveApi()
local save = SaveData.decode(blob)
if type(save) ~= "table" then return nil, "the downloaded save is unreadable" end
save.version = save.version or version
local options = SaveData.loadOptions()
local slotId
if mode == "new" then
save.meta = type(save.meta) == "table" and save.meta or {}
save.meta.playthroughId = SaveData.newPlaythroughId()
else
slotId = slotForPlaythrough(options, version, playthroughId)
end
if not slotId then
slotId = SaveData.createSlot(version)
if not slotId then return nil, "could not make a save slot" end
end
local ok, err = SaveData.writeSlot(version, slotId, save)
if not ok then return nil, err or "could not write the save" end
options = SaveData.loadOptions()
options.playthroughIds = options.playthroughIds or {}
options.playthroughIds[version] = options.playthroughIds[version] or {}
options.playthroughIds[version][slotId] =
save.meta and save.meta.playthroughId or playthroughId
SaveData.saveOptions(options)
return slotId
end,
}
end
function SyncEngine.overlaps(a, b)
if type(a) ~= "table" or type(b) ~= "table" then return false end
local aStart, aEnd = tonumber(a.sessionStart), tonumber(a.savedAt)
local bStart, bEnd = tonumber(b.sessionStart), tonumber(b.savedAt)
if not (aStart and aEnd and bStart and bEnd) then return false end
return aStart <= bEnd and bStart <= aEnd
end
function SyncEngine.new(opts)
opts = opts or {}
local eng = setmetatable({}, SyncEngine)
eng.fs = opts.fs
eng.state = opts.state or SyncState.load(eng.fs)
eng.client = opts.client or SyncClient.new({
baseUrl = opts.baseUrl, transport = opts.transport })
eng.saves = opts.saves or SyncEngine.defaultSaves()
eng.modDeps = opts.modDeps
eng.now = opts.now or os.time
eng.persist = opts.persist ~= false
eng.phase = "idle"
eng.error = nil
eng.conflicts = {}
eng.codes = nil
eng.modPlan = nil
eng.shareCode = nil
eng.clock = 0
eng.queue = {}
eng.pending = nil
eng.uploadAt = nil
eng.client:setAuth(eng.state.account, eng.state.deviceToken)
eng.status = eng:defaultStatus()
return eng
end
function SyncEngine.shared(opts)
if SyncEngine._shared == nil then
local ok, eng = pcall(SyncEngine.new, opts or {})
SyncEngine._shared = (ok and type(eng) == "table") and eng or false
end
return SyncEngine._shared or nil
end
function SyncEngine.forgetShared()
SyncEngine._shared = nil
end
function SyncEngine:defaultStatus()
if not SyncState.linked(self.state) then return UNLINKED_STATUS end
return IDLE_STATUS
end
function SyncEngine:linked()
return SyncState.linked(self.state)
end
function SyncEngine:busy()
return self.pending ~= nil or #self.queue > 0 or self.modApply ~= nil
end
function SyncEngine:_persist()
if not self.persist then return end
SyncState.save(self.state, self.fs)
end
function SyncEngine:_fail(message)
self.phase = "error"
self.error = tostring(message or "sync failed")
self.status = "Sync failed: " .. self.error
self.queue = {}
self.pending = nil
end
function SyncEngine:_finish()
if #self.conflicts > 0 then
self.phase = "conflict"
local overlap = false
for _, row in ipairs(self.conflicts) do
if row.overlap then overlap = true end
end
self.status = overlap
and "These saves were played at the same time."
or "This save also changed on another device."
return
end
self.phase = "idle"
self.error = nil
self.state.lastSyncAt = self.now()
self.status = self:defaultStatus()
self:_persist()
end
function SyncEngine:_request(handle, err, onOk, onErr)
if not handle then
self:_fail(err or "could not start the request")
return false
end
self.pending = { handle = handle, onOk = onOk, onErr = onErr }
return true
end
function SyncEngine:_enqueue(fn)
self.queue[#self.queue + 1] = fn
end
function SyncEngine:cancel()
if self.pending then self.client:release(self.pending.handle) end
self.pending = nil
self.queue = {}
self.modApply = nil
self.uploadAt = nil
if self.phase ~= "conflict" then
self.phase = "idle"
self.status = self:defaultStatus()
end
end
function SyncEngine:noteSaveWritten()
if not (self.state.enabled and self:linked()) then return end
self.uploadAt = self.clock + SyncEngine.UPLOAD_DEBOUNCE
end
function SyncEngine:update(dt)
self.clock = self.clock + (tonumber(dt) or 0)
if self.pending then
local res = self.client:poll(self.pending.handle)
if res.status == "pending" then return end
local job = self.pending
self.pending = nil
self.client:release(job.handle)
if res.status == "ok" then
local ok, err = pcall(job.onOk, self, res)
if not ok then self:_fail(err) end
else
local handled = false
if job.onErr then
local ok, result = pcall(job.onErr, self, res)
if not ok then self:_fail(result) return end
handled = result == true
end
if not handled then self:_fail(res.err) end
end
end
if self.pending then return end
if self.modApply then
self:_stepModApply()
return
end
if self.uploadAt and self.clock >= self.uploadAt and not self:busy() then
self.uploadAt = nil
if self.state.enabled and self:linked() then self:syncNow() end
end
local steps = 0
while not self.pending and #self.queue > 0
and steps < SyncEngine.MAX_STEPS_PER_UPDATE do
steps = steps + 1
local task = table.remove(self.queue, 1)
local ok, err = pcall(task, self)
if not ok then self:_fail(err) return end
if not self.pending and #self.queue == 0 and self.phase ~= "error" then
self:_finish()
end
end
end
function SyncEngine:createAccount(label)
if self:busy() then return false, "sync is busy" end
self.phase = "checking"
self.status = "Creating a sync account..."
self.error = nil
local handle, err = self.client:create(label)
return self:_request(handle, err, function(eng, res)
local data = res.data or {}
if type(data.account) ~= "string" or type(data.deviceToken) ~= "string" then
eng:_fail("the server sent an unexpected reply")
return
end
eng.codes = {
code1 = SyncClient.formatCode(data.code1) or tostring(data.code1 or ""),
code2 = SyncClient.formatCode(data.code2) or tostring(data.code2 or ""),
}
eng.state.account = data.account
eng.state.deviceToken = data.deviceToken
eng.state.deviceId = type(data.device) == "string" and data.device or nil
eng.state.deviceLabel = label
eng.state.enabled = true
eng.client:setAuth(data.account, data.deviceToken)
eng.phase = "idle"
eng.status = "Sync account created"
eng:_persist()
end)
end
function SyncEngine:linkDevice(code1, code2, label)
if self:busy() then return false, "sync is busy" end
local a = SyncClient.normalizeCode(code1)
local b = SyncClient.normalizeCode(code2)
if not a or not b then
self:_fail("both codes are 8 digits")
return false, "both codes are 8 digits"
end
self.phase = "checking"
self.status = "Linking this device..."
self.error = nil
local handle, err = self.client:link(a, b, label)
return self:_request(handle, err, function(eng, res)
local data = res.data or {}
if type(data.account) ~= "string" or type(data.deviceToken) ~= "string" then
eng:_fail("the server sent an unexpected reply")
return
end
eng.state.account = data.account
eng.state.deviceToken = data.deviceToken
eng.state.deviceId = type(data.device) == "string" and data.device or nil
eng.state.deviceLabel = label
eng.state.enabled = true
eng.client:setAuth(data.account, data.deviceToken)
eng.status = "This device is linked"
eng:_persist()
eng:syncNow()
end)
end
function SyncEngine:_forgetLocal()
self.state = SyncState.defaults()
self.client:clearAuth()
self.codes = nil
self.conflicts = {}
self.devices = nil
self.phase = "idle"
self.status = UNLINKED_STATUS
self:_persist()
end
function SyncEngine:unlink()
if not self:linked() then
self:_forgetLocal()
return true
end
if self:busy() then return false, "sync is busy" end
self.phase = "checking"
self.status = "Unlinking this device..."
self.error = nil
local handle, err = self.client:unlink(self.state.deviceId)
return self:_request(handle, err, function(eng)
eng:_forgetLocal()
end, function(eng, res)
if res.code == 401 or res.code == 404 then
eng:_forgetLocal()
return true
end
return false
end)
end
function SyncEngine:unlinkDevice(deviceId)
if type(deviceId) ~= "string" or deviceId == "" then
return false, "no such device"
end
if not self:linked() then return false, "this device is not linked" end
if deviceId == self.state.deviceId then return self:unlink() end
if self:busy() then return false, "sync is busy" end
self.phase = "checking"
self.status = "Unlinking that device..."
self.error = nil
local handle, err = self.client:unlink(deviceId)
return self:_request(handle, err, function(eng)
eng.status = "That device was unlinked"
eng.phase = "idle"
eng:syncNow()
end)
end
function SyncEngine:setEnabled(enabled)
self.state.enabled = enabled and true or false
self:_persist()
return self.state.enabled
end
function SyncEngine:syncNow()
if not self:linked() then return false, "this device is not linked" end
if self.pending then return false, "sync is busy" end
self.queue = {}
self.conflicts = {}
self.state.pendingConflicts = {}
self.phase = "checking"
self.status = "Checking for changes..."
self.error = nil
local handle, err = self.client:fetchState()
return self:_request(handle, err, function(eng, res)
eng:_planFrom(res.data or {})
end)
end
function SyncEngine:_planFrom(remoteState)
self.devices = nil
if type(remoteState.devices) == "table" then
local list = {}
for _, row in ipairs(remoteState.devices) do
if type(row) == "table" and type(row.id) == "string" and row.id ~= "" then
list[#list + 1] = {
id = row.id,
label = type(row.label) == "string" and row.label ~= "" and row.label
or "device",
createdAt = tonumber(row.createdAt),
current = row.current == true or row.id == self.state.deviceId,
}
end
end
self.devices = list
end
local remote = type(remoteState.saves) == "table" and remoteState.saves or {}
local locals = self.saves.list() or {}
local seen = {}
for _, entry in ipairs(locals) do
local key = SyncState.key(entry.version, entry.playthroughId)
if key then
seen[key] = true
local row = remote[key]
local knownRev = SyncState.rev(self.state, key)
local stamp = SyncState.stamp(self.state, key)
local localChanged = stamp == nil
or tonumber(entry.meta and entry.meta.savedAt) ~= stamp
local remoteRev = row and tonumber(row.rev)
local remoteChanged = row ~= nil and remoteRev ~= knownRev
if not row then
self:_queueUpload(entry, key, false)
elseif localChanged and remoteChanged then
self:_addConflict(entry, key, row)
elseif localChanged then
self:_queueUpload(entry, key, false)
elseif remoteChanged then
self:_queueDownload(key, entry.version, entry.playthroughId, "replace")
end
end
end
for key, row in pairs(remote) do
if not seen[key] then
local version, id = SyncState.splitKey(key)
if version and id then
self:_queueDownload(key, version, id, "replace", tonumber(row.rev))
end
end
end
if #self.queue == 0 then self:_finish() end
end
function SyncEngine:_addConflict(entry, key, row)
local remoteMeta = row
if type(row.meta) == "table" then
remoteMeta = row.meta
elseif type(row.remoteMeta) == "table" then
remoteMeta = row.remoteMeta
end
self.conflicts[#self.conflicts + 1] = {
key = key,
version = entry.version,
playthroughId = entry.playthroughId,
slot = entry.slot,
entry = entry,
localMeta = entry.meta,
remoteMeta = remoteMeta,
remoteRev = tonumber(row.rev),
overlap = SyncEngine.overlaps(entry.meta, remoteMeta),
}
local pending = self.state.pendingConflicts or {}
self.state.pendingConflicts = pending
for _, row in ipairs(pending) do
if row.key == key then return end
end
pending[#pending + 1] = {
key = key,
version = entry.version,
playthroughId = entry.playthroughId,
overlap = SyncEngine.overlaps(entry.meta, remoteMeta),
}
end
function SyncEngine:_queueUpload(entry, key, force)
self:_enqueue(function(eng)
eng.phase = "uploading"
eng.status = "Uploading saves..."
local handle, err = eng.client:putSave({
version = entry.version,
slot = entry.slot,
meta = entry.meta,
blob = entry.blob,
baseRev = SyncState.rev(eng.state, key),
force = force,
})
eng:_request(handle, err, function(e, res)
local data = res.data or {}
SyncState.setRev(e.state, key, tonumber(data.rev),
entry.meta and entry.meta.savedAt)
e:_persist()
if not e:busy() then e:_finish() end
end, function(e, res)
if res.code == 409 then
local row = res.data or {}
e:_addConflict(entry, key, row)
if not e:busy() then e:_finish() end
return true
end
return false
end)
end)
end
function SyncEngine:_queueDownload(key, version, playthroughId, mode, knownRev)
self:_enqueue(function(eng)
eng.phase = "downloading"
eng.status = "Downloading saves..."
local handle, err = eng.client:getSave(version, playthroughId)
eng:_request(handle, err, function(e, res)
local data = res.data or {}
if type(data.blob) ~= "string" or data.blob == "" then
e:_fail("the server sent no save data")
return
end
local slotId, writeErr = e.saves.write(version, playthroughId, data.blob, mode)
if not slotId then
e:_fail(writeErr or "could not write the downloaded save")
return
end
if mode ~= "new" then
local meta = type(data.meta) == "table" and data.meta or {}
SyncState.setRev(e.state, key, tonumber(data.rev) or knownRev,
tonumber(meta.savedAt))
end
e:_persist()
if not e:busy() then e:_finish() end
end)
end)
end
function SyncEngine:resolveConflict(key, choice)
local index
for i, row in ipairs(self.conflicts) do
if row.key == key then index = i break end
end
if not index then return false, "no such conflict" end
local conflict = table.remove(self.conflicts, index)
local kept = {}
for _, row in ipairs(self.state.pendingConflicts or {}) do
if row.key ~= key then kept[#kept + 1] = row end
end
self.state.pendingConflicts = kept
if choice == "local" then
SyncState.setRev(self.state, key, conflict.remoteRev, nil)
self:_queueUpload(conflict.entry, key, true)
elseif choice == "remote" then
self:_queueDownload(key, conflict.version, conflict.playthroughId,
"replace", conflict.remoteRev)
elseif choice == "both" then
self:_queueDownload(key, conflict.version, conflict.playthroughId,
"new", conflict.remoteRev)
SyncState.setRev(self.state, key, conflict.remoteRev, nil)
self:_queueUpload(conflict.entry, key, true)
else
return false, "unknown resolution"
end
self.phase = "uploading"
self.status = "Applying your choice..."
return true
end
function SyncEngine:uploadMods()
if not self:linked() then return false, "this device is not linked" end
if self:busy() then return false, "sync is busy" end
local manifest = SyncMods.build(self.modDeps)
self.phase = "uploading"
self.status = "Uploading the mod list..."
local handle, err = self.client:putMods(manifest)
return self:_request(handle, err, function(eng)
eng.phase = "idle"
eng.status = "Mod list synced"
end)
end
function SyncEngine:fetchModPlan()
if not self:linked() then return false, "this device is not linked" end
if self:busy() then return false, "sync is busy" end
self.phase = "downloading"
self.status = "Reading the mod list..."
local handle, err = self.client:getMods()
return self:_request(handle, err, function(eng, res)
local data = res.data or {}
local manifest = type(data.manifest) == "table" and data.manifest or data
eng.modPlan = SyncMods.plan(manifest, eng.modDeps)
eng.phase = "idle"
eng.status = SyncMods.planEmpty(eng.modPlan)
and "Mods already match" or "Mod changes ready to apply"
end)
end
function SyncEngine:shareMods()
if not self:linked() then return false, "this device is not linked" end
if self:busy() then return false, "sync is busy" end
local manifest = SyncMods.build(self.modDeps)
self.phase = "uploading"
self.status = "Sharing the mod list..."
local handle, err = self.client:shareMods(manifest)
return self:_request(handle, err, function(eng, res)
local data = res.data or {}
eng.shareCode = type(data.code) == "string" and data.code or nil
eng.phase = "idle"
eng.status = eng.shareCode and ("Share code " .. eng.shareCode)
or "The server sent no share code"
end)
end
function SyncEngine:fetchShare(code)
if self:busy() then return false, "sync is busy" end
self.phase = "downloading"
self.status = "Fetching that mod list..."
local handle, err = self.client:fetchShare(code)
return self:_request(handle, err, function(eng, res)
local data = res.data or {}
local manifest = type(data.manifest) == "table" and data.manifest or data
eng.modPlan = SyncMods.plan(manifest, eng.modDeps)
eng.phase = "idle"
eng.status = SyncMods.planEmpty(eng.modPlan)
and "Mods already match" or "Mod changes ready to apply"
end)
end
function SyncEngine:applyModPlan(progress)
if not self.modPlan then return false, "no mod plan" end
if self.modApply then return false, "the mods are already being applied" end
local steps = SyncMods.steps(self.modPlan, self.modDeps)
if #steps == 0 then
self.modPlan = nil
self.status = "Mods already match"
if progress then progress(0, 0, nil, true) end
return true
end
self.modApply = { steps = steps, index = 0, failures = {},
progress = progress }
self.phase = "applying"
self.status = ("Applying mods... 0 of %d"):format(#steps)
return true
end
function SyncEngine:applyingMods()
return self.modApply ~= nil
end
function SyncEngine:_stepModApply()
local job = self.modApply
local step = job.steps[job.index + 1]
job.index = job.index + 1
local ok, res, why = pcall(step.run)
if not ok then
job.failures[#job.failures + 1] = tostring(res)
elseif not res then
job.failures[#job.failures + 1] = tostring(why or step.label)
end
local total = #job.steps
local done = job.index >= total
if not done then
self.status = ("Applying mods... %d of %d"):format(job.index, total)
if job.progress then
pcall(job.progress, job.index, total, step.label, false)
end
return
end
self.modApply = nil
self.modPlan = nil
self.phase = "idle"
if #job.failures > 0 then
self.status = "Some mods could not be applied: "
.. table.concat(job.failures, "; ")
else
self.status = "Mods applied"
end
if job.progress then
pcall(job.progress, job.index, total, step.label, true)
end
end
return SyncEngine
+196
View File
@@ -0,0 +1,196 @@
local SyncMods = {}
SyncMods.REV = 1
local function versions()
local ok, GameVersion = pcall(require, "src.core.GameVersion")
if ok and GameVersion and GameVersion.ORDER then return GameVersion.ORDER end
return { "red", "blue", "yellow", "gold" }
end
local function defaultDeps()
return {
installed = function()
return require("src.mods.LauncherMods").list()
end,
indexes = function()
return require("src.mods.ModIndex").sources()
end,
addIndex = function(url)
return require("src.mods.ModIndex").addSource(url)
end,
findEntry = function(id)
local ModIndex = require("src.mods.ModIndex")
for _, source in ipairs(ModIndex.sources()) do
local cached = ModIndex.readCache(source.feed)
for _, entry in ipairs((cached and cached.mods) or {}) do
if entry.id == id then return entry end
end
end
return nil
end,
install = function(entry)
return require("src.mods.LauncherMods").installFromIndex(entry)
end,
setEnabled = function(id, enabled, version)
return require("src.mods.LauncherMods").setEnabled(id, enabled, version)
end,
}
end
local function deps(given)
local out = defaultDeps()
if type(given) == "table" then
for k, v in pairs(given) do out[k] = v end
end
return out
end
local function sourceOf(row)
local github = row.github
or (type(row.manifest) == "table" and row.manifest.github)
if type(github) == "string" and github ~= "" then
return "github:" .. github
end
return "local"
end
function SyncMods.build(given)
local d = deps(given)
local manifest = { rev = SyncMods.REV, indexes = {}, mods = {} }
for _, row in ipairs(d.indexes() or {}) do
local url = row.url or row.feed
if type(url) == "string" and url ~= "" then
manifest.indexes[#manifest.indexes + 1] = url
end
end
table.sort(manifest.indexes)
for _, row in ipairs(d.installed() or {}) do
if type(row) == "table" and type(row.id) == "string" then
local enabledFor = {}
local answers = row.enabledByVersion or {}
for _, version in ipairs(versions()) do
if answers[version] then enabledFor[#enabledFor + 1] = version end
end
manifest.mods[#manifest.mods + 1] = {
id = row.id,
version = row.version,
source = sourceOf(row),
enabledFor = enabledFor,
}
end
end
table.sort(manifest.mods, function(a, b) return a.id < b.id end)
return manifest
end
function SyncMods.plan(manifest, given)
local d = deps(given)
local plan = { indexes = {}, toInstall = {}, toEnable = {}, missing = {} }
if type(manifest) ~= "table" then return plan end
local haveIndex = {}
for _, row in ipairs(d.indexes() or {}) do
if type(row.url) == "string" then haveIndex[row.url] = true end
if type(row.feed) == "string" then haveIndex[row.feed] = true end
end
for _, url in ipairs(manifest.indexes or {}) do
if type(url) == "string" and url ~= "" and not haveIndex[url] then
plan.indexes[#plan.indexes + 1] = url
haveIndex[url] = true
end
end
local installed = {}
for _, row in ipairs(d.installed() or {}) do
if type(row) == "table" and type(row.id) == "string" then
installed[row.id] = row
end
end
for _, mod in ipairs(manifest.mods or {}) do
if type(mod) == "table" and type(mod.id) == "string" then
local here = installed[mod.id]
local available = here ~= nil
if not here then
local entry = d.findEntry(mod.id)
if entry then
available = true
plan.toInstall[#plan.toInstall + 1] =
{ id = mod.id, version = mod.version, entry = entry }
else
plan.missing[#plan.missing + 1] =
{ id = mod.id, version = mod.version, source = mod.source }
end
end
if available then
local answers = (here and here.enabledByVersion) or {}
for _, version in ipairs(mod.enabledFor or {}) do
if answers[version] ~= true then
plan.toEnable[#plan.toEnable + 1] = { id = mod.id, version = version }
end
end
end
end
end
return plan
end
function SyncMods.planEmpty(plan)
if type(plan) ~= "table" then return true end
return #(plan.indexes or {}) == 0 and #(plan.toInstall or {}) == 0
and #(plan.toEnable or {}) == 0
end
function SyncMods.steps(plan, given)
local d = deps(given)
local out = {}
if type(plan) ~= "table" then return out end
local broken = {}
for _, url in ipairs(plan.indexes or {}) do
out[#out + 1] = { label = url, run = function()
local ok, err = d.addIndex(url)
if not ok then return nil, tostring(err or url) end
return true
end }
end
for _, mod in ipairs(plan.toInstall or {}) do
out[#out + 1] = { label = mod.id, run = function()
local ok, err = d.install(mod.entry)
if not ok then
broken[mod.id] = true
return nil, mod.id .. ": " .. tostring(err or "install failed")
end
return true
end }
end
for _, want in ipairs(plan.toEnable or {}) do
out[#out + 1] = { label = want.id, run = function()
if broken[want.id] then return true end
local ok, err = d.setEnabled(want.id, true, want.version)
if ok == false then
return nil, want.id .. ": " .. tostring(err or "could not enable")
end
return true
end }
end
return out
end
function SyncMods.apply(plan, progress, given)
if type(plan) ~= "table" then return false, "nothing to apply" end
local steps = SyncMods.steps(plan, given)
local failures = {}
for i, step in ipairs(steps) do
local ok, err = step.run()
if not ok then failures[#failures + 1] = err end
if progress then progress(i, #steps, step.label) end
end
if #failures > 0 then
return false, table.concat(failures, "; ")
end
return true
end
return SyncMods
+129
View File
@@ -0,0 +1,129 @@
local SaveData = require("src.core.SaveData")
local SyncState = {}
SyncState.KEY = "saveSync"
function SyncState.defaults()
return {
enabled = false,
lastSyncAt = 0,
revs = {},
stamps = {},
pendingConflicts = {},
}
end
local function str(v)
if type(v) == "string" and v ~= "" then return v end
return nil
end
local function num(v)
local n = tonumber(v)
if type(n) ~= "number" or n ~= n or n == math.huge or n == -math.huge then
return nil
end
return n
end
function SyncState.sanitize(raw)
local out = SyncState.defaults()
if type(raw) ~= "table" then return out end
out.enabled = raw.enabled == true
out.account = str(raw.account)
out.deviceToken = str(raw.deviceToken)
out.deviceId = str(raw.deviceId)
out.deviceLabel = str(raw.deviceLabel)
out.lastSyncAt = num(raw.lastSyncAt) or 0
if type(raw.revs) == "table" then
for key, rev in pairs(raw.revs) do
local n = num(rev)
if type(key) == "string" and n then out.revs[key] = n end
end
end
if type(raw.stamps) == "table" then
for key, at in pairs(raw.stamps) do
local n = num(at)
if type(key) == "string" and n then out.stamps[key] = n end
end
end
if type(raw.pendingConflicts) == "table" then
for _, row in ipairs(raw.pendingConflicts) do
if type(row) == "table" and str(row.key) then
out.pendingConflicts[#out.pendingConflicts + 1] = {
key = row.key,
version = str(row.version),
playthroughId = str(row.playthroughId),
overlap = row.overlap == true,
}
end
end
end
return out
end
function SyncState.load(fs)
local opts = SaveData.loadOptions(fs)
return SyncState.sanitize(opts and opts[SyncState.KEY])
end
function SyncState.save(state, fs)
local opts = SaveData.loadOptions(fs)
opts[SyncState.KEY] = SyncState.sanitize(state)
SaveData.saveOptions(opts, fs)
return opts[SyncState.KEY]
end
function SyncState.update(fn, fs)
local state = SyncState.load(fs)
fn(state)
return SyncState.save(state, fs)
end
function SyncState.clear(fs)
return SyncState.save(SyncState.defaults(), fs)
end
function SyncState.linked(state)
return type(state) == "table" and str(state.account) ~= nil
and str(state.deviceToken) ~= nil
end
function SyncState.key(version, playthroughId)
if type(version) ~= "string" or version == "" then return nil end
if type(playthroughId) ~= "string" or playthroughId == "" then return nil end
return version .. "/" .. playthroughId
end
function SyncState.splitKey(key)
if type(key) ~= "string" then return nil end
local version, id = key:match("^([^/]+)/(.+)$")
return version, id
end
function SyncState.rev(state, key)
if type(state) ~= "table" or type(state.revs) ~= "table" then return nil end
return state.revs[key]
end
function SyncState.stamp(state, key)
if type(state) ~= "table" or type(state.stamps) ~= "table" then return nil end
return state.stamps[key]
end
function SyncState.setRev(state, key, rev, savedAt)
if type(state) ~= "table" or type(key) ~= "string" then return end
state.revs = state.revs or {}
state.stamps = state.stamps or {}
state.revs[key] = num(rev)
state.stamps[key] = num(savedAt)
end
function SyncState.forget(state, key)
if type(state) ~= "table" or type(key) ~= "string" then return end
if type(state.revs) == "table" then state.revs[key] = nil end
if type(state.stamps) == "table" then state.stamps[key] = nil end
end
return SyncState
+41
View File
@@ -0,0 +1,41 @@
local Transport = {}
Transport.__index = Transport
function Transport.new(fetch)
return setmetatable({ fetch = fetch or require("src.net.Fetch") }, Transport)
end
function Transport:begin(req)
return self.fetch.request(req.url, {
method = req.method,
body = req.body,
headers = req.headers,
maxSeconds = req.maxSeconds,
})
end
function Transport:poll(handle)
local st = self.fetch.poll(handle)
if st.status == "pending" then return { status = "pending" } end
if st.status == "cancelled" then
return { status = "error", err = "sync request cancelled" }
end
if st.status ~= "ok" then
return { status = "error", err = st.err or "sync request failed" }
end
return { status = "ok", body = st.body or "", code = tonumber(st.code) }
end
function Transport:release(handle)
if handle ~= nil and self.fetch.release then self.fetch.release(handle) end
end
function Transport:cancel(handle)
if handle ~= nil and self.fetch.cancel then self.fetch.cancel(handle) end
end
function Transport:available()
return self.fetch.available and self.fetch.available() or false
end
return Transport
+2 -2
View File
@@ -180,8 +180,8 @@ local function release(game)
and mon.ot == game.save.player.name then
require("src.core.Sound").playCry(game.data, mon.species)
game.stack:push(TextBox.new(game,
(t._PikachuUnhappyText or Strings("%s looks\nunhappy about it!", name))
:gsub("{RAM:wNameBuffer}", name)))
((t._PikachuUnhappyText or Strings("%s looks\nunhappy about it!", name))
:gsub("{RAM:wNameBuffer}", name))))
return
end
game.stack:push(TextBox.new(game,
+2 -1
View File
@@ -18,6 +18,7 @@ local Theme = require("src.ui.Theme")
local FieldDefaults = require("src.world.FieldDefaults")
local Map = require("src.world.Map")
local Strings = require("src.core.Strings")
local Status = require("src.battle.Status")
local PartyMenu = {}
PartyMenu.__index = PartyMenu
@@ -821,7 +822,7 @@ function PartyMenu:draw()
if mon.hp <= 0 then
Font.draw(Strings("FNT"), 136, y)
elseif mon.status then
Font.draw(mon.status, 136, y)
Font.draw(Status.hudLabelFor(self.game.data.statuses, mon.status), 136, y)
end
-- the tile HP bar (DrawHP2 + SetPartyMenuHPBarColor). grayFill:
-- tinting the fill AND running it through the row's zone
+1032 -57
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -14,6 +14,7 @@ local Font = require("src.render.Font")
local TypeChart = require("src.battle.TypeChart")
local Strings = require("src.core.Strings")
local Stats = require("src.pokemon.Stats")
local Status = require("src.battle.Status")
local SummaryMenu = {}
SummaryMenu.__index = SummaryMenu
@@ -145,7 +146,7 @@ function SummaryMenu:draw()
HudTiles.drawHPBar(data, 11, 3, mon, 1, barZoned) -- wHPBarType 1
Font.draw(("%3d/%3d"):format(mon.hp, mon.stats.hp), 96, 32)
Font.draw(Strings("STATUS/"), 72, 48)
Font.draw(mon.status or "OK", 128, 48)
Font.draw(Status.hudLabelFor(data.statuses, mon.status) or "OK", 128, 48)
-- stats box (0,8) 10x10: names rows 9/11/13/15, values indented
Font.drawBox(0, 8, 10, 10)
+4 -1
View File
@@ -198,6 +198,7 @@ function TitleState.new(game, opts)
self.game = game
self.onNewGame = opts.onNewGame
self.onContinue = opts.onContinue
self.onExit = opts.onExit
-- branding comes from field.title with the shipped art as fallback, so
-- a total conversion rebrands the title without replacing the screen
local title = (game.data.field and game.data.field.title) or {}
@@ -514,7 +515,9 @@ function TitleState:openMenu()
require("src.ui.Screens").push(game, "OptionsMenu")
end })
table.insert(items, { label = Strings("EXIT GAME"), onSelect = function()
if love.event and love.event.quit then
if self.onExit then
self.onExit()
elseif love.event and love.event.quit then
love.event.quit()
end
end })
+3
View File
@@ -2311,6 +2311,7 @@ function BattleState:openParty(forced)
-- :2702; engine/pokemon/party_menu.asm:660-679). Only the voluntary list
-- carries BattleMonMenu; PickPartyMonInBattle has no submenu.
prompt = forced and "which" or "choose",
battle = true,
battleSubmenu = not forced,
onCancel = function()
stack:pop()
@@ -2783,6 +2784,7 @@ function BattleState:openShiftParty()
self.phase = "submenu"
Screens.push(self.game, "Gen2PartyMenu", {
prompt = "which",
battle = true,
onCancel = function()
stack:pop()
self.phase = "resolving"
@@ -3156,6 +3158,7 @@ function BattleState:useOnPartyMon(itemId, action)
self.phase = "submenu"
Screens.push(self.game, "Gen2PartyMenu", {
prompt = "useItem",
battle = true,
party = self.battle.party or (self.save and self.save.party),
onCancel = function()
stack:pop()
+2 -2
View File
@@ -28,7 +28,7 @@
-- covered by tests; the state at the bottom is the only part that draws.
local GbcPalette = require("src.render.GbcPalette")
local GameViewport = require("src.render.GameViewport")
local Playfield = require("src.render.Playfield")
local Palettes = require("src.world.gen2.Palettes")
local Runtime = require("src.mods.Runtime")
local SpriteAnims = require("src.ui.gen2.SpriteAnims")
@@ -510,7 +510,7 @@ function BattleTransition:blackAt(col, row)
end
function BattleTransition:draw()
local w, h = GameViewport.dimensions()
local w, h = Playfield.dimensions()
self:drawWidescreen(w, h)
end
+17 -4
View File
@@ -43,16 +43,29 @@ end
-- pixel rows out of glyphs. This is the same rule src/render/Renderer.lua
-- fitScale applies to the Gen 1 UI canvas; the surround a widescreen screen
-- paints still fills the window, the PANEL is what stays on the grid.
local function playfieldRect(winW, winH)
local ok, Playfield = pcall(require, "src.render.Playfield")
if ok and Playfield.rect then
local okv, x, y, w, h = pcall(Playfield.rect, winW, winH)
if okv and w and w >= 1 and h and h >= 1 then
return x, y, w, h
end
end
return 0, 0, winW or 0, winH or 0
end
function Chrome.fitScale(winW, winH)
return math.max(1, math.floor(math.min((winW or 0) / (Chrome.SCREEN_W * 8),
(winH or 0) / (Chrome.SCREEN_H * 8))))
local _, _, w, h = playfieldRect(winW, winH)
return math.max(1, math.floor(math.min(w / (Chrome.SCREEN_W * 8),
h / (Chrome.SCREEN_H * 8))))
end
-- The centred origin that goes with it, so a caller does not re-derive it.
function Chrome.fitOrigin(winW, winH, scale)
scale = scale or Chrome.fitScale(winW, winH)
return math.floor((winW - Chrome.SCREEN_W * 8 * scale) / 2),
math.floor((winH - Chrome.SCREEN_H * 8 * scale) / 2)
local x, y, w, h = playfieldRect(winW, winH)
return x + math.floor((w - Chrome.SCREEN_W * 8 * scale) / 2),
y + math.floor((h - Chrome.SCREEN_H * 8 * scale) / 2)
end
-- A bordered box, tile coords. Leaves the draw color black for text.
+37 -1
View File
@@ -76,6 +76,23 @@ local BATTLE_SUBMENU_LEFT, BATTLE_SUBMENU_TOP = 11, 11
-- HP bar is 6 tiles wide (48px) in the party list.
local function gridIndex(index, count, direction)
if count < 1 then return nil end
local row, col = math.floor((index - 1) / 2), (index - 1) % 2
if direction == "left" or direction == "right" then
local other = row * 2 + (1 - col) + 1
return other <= count and other or index
end
local step = direction == "up" and -1 or direction == "down" and 1
if not step then return nil end
local rows = math.ceil(count / 2)
for offset = 1, rows do
local other = ((row + step * offset) % rows) * 2 + col + 1
if other <= count then return other end
end
return index
end
function PartyMenu:wantsFillScale() return true end
function PartyMenu:drawsWidescreen() return true end
@@ -114,6 +131,7 @@ function PartyMenu.new(game, opts)
self.wantsSubmenu = opts.submenu == true
-- BattleMenu_PKMN's `callfar BattleMonMenu` (engine/battle/core.asm:4810).
self.wantsBattleSubmenu = opts.battleSubmenu == true
self.battle = opts.battle == true
self.submenu = nil
-- The held slot while SwitchPartyMons' second pick is open; nil otherwise.
self.switchFrom = nil
@@ -146,6 +164,13 @@ function PartyMenu:isCancel()
return self.index > #self.party
end
function PartyMenu:gridNavigation()
if not self.battle
or not Runtime.wantsHook("ui.party.grid_navigation") then return false end
return Runtime.call("ui.party.grid_navigation", function() return false end,
self) == true
end
-- ------------------------------------------------------------- mon submenu
-- GetMonSubmenuItems, in its own order: every field move the mon knows first,
@@ -477,7 +502,18 @@ function PartyMenu:update(_dt)
return
end
local total = self:count()
if input:wasPressed("up") then
local grid
if self:gridNavigation() then
local direction = input:wasPressed("left") and "left"
or input:wasPressed("right") and "right"
or input:wasPressed("up") and "up"
or input:wasPressed("down") and "down"
grid = gridIndex(self.index, #self.party, direction)
end
if grid then
self.index = grid
self:storeCursor()
elseif input:wasPressed("up") then
self.index = self.index > 1 and self.index - 1 or total
elseif input:wasPressed("down") then
self.index = self.index < total and self.index + 1 or 1
+60 -2
View File
@@ -856,8 +856,8 @@ end
-- -------------------------------------------------------------------- pager
-- Prev / Next / "1-12 of 151". Drawn even for a single page, so a list is
-- never silently truncated. This is the ONLY way the launcher moves through
-- a long list: no scrollbars, no momentum, bounded row count per frame.
-- never silently truncated. A long list still PAGES rather than scrolling:
-- no momentum, bounded row count per frame.
-- Returns the new page (1-based) and the row height consumed.
local pagerLabels = {}
@@ -930,6 +930,64 @@ function Kit.wheelPage(x, y, w, h, page, total, perPage)
return math.floor(moved)
end
function Kit.scrollExtent(contentH, viewH)
return math.max(0, (contentH or 0) - math.max(0, viewH or 0))
end
function Kit.scrollClamp(offset, maxScroll)
return math.max(0, math.min(offset or 0, math.max(0, maxScroll or 0)))
end
function Kit.scrollStep(scale)
return math.floor(48 * (scale or Kit.scale))
end
function Kit.scrollBarW(scale)
return math.max(2, math.floor(4 * (scale or Kit.scale)))
end
function Kit.scrollGutter(scale)
return Kit.scrollBarW(scale) + math.max(2, math.floor(4 * (scale or Kit.scale)))
end
function Kit.scrollHandoff(offset, maxScroll, delta)
local want = (offset or 0) + (delta or 0)
local at = Kit.scrollClamp(want, maxScroll)
return at, want - at
end
function Kit.scrollWheel(offset, maxScroll, x, y, w, h, step)
local at = Kit.scrollClamp(offset, maxScroll)
local wheel = Kit.wheelY or 0
if Kit.blockClicks or wheel == 0 or (maxScroll or 0) <= 0 then
return at, false
end
if not Kit.hit(x, y, w, h) then return at, false end
local moved = Kit.scrollClamp(at - wheel * (step or Kit.scrollStep()),
maxScroll)
if moved == at then return at, false end
Kit.wheelY = 0
return moved, true
end
function Kit.scrollBegin(x, y, w, h, offset, maxScroll)
Kit.pushClip(x, y, math.max(0, w or 0), math.max(0, h or 0))
return y - Kit.scrollClamp(offset, maxScroll)
end
function Kit.scrollEnd(x, y, w, h, offset, maxScroll)
Kit.popClip()
if (maxScroll or 0) <= 0 or (h or 0) <= 0 or (w or 0) <= 0 then return end
local barW = Kit.scrollBarW()
local barX = x + w - barW
local at = Kit.scrollClamp(offset, maxScroll)
local thumbH = math.max(math.floor(20 * Kit.scale),
math.floor(h * (h / (h + maxScroll))))
local thumbY = y + (h - thumbH) * (at / maxScroll)
Theme.fill(barX, y, barW, h, PAL.bg, 0.35)
Theme.fill(barX, thumbY, barW, thumbH, PAL.muted, 0.7)
end
-- ------------------------------------------------------------------ spinner
-- The one animated element in the UI: a rotating arc of ticks. Drawn as N
-- short lines at descending alpha, which needs no shader, no canvas and no
+1 -2
View File
@@ -9,7 +9,6 @@ local Collision = require("src.world.Collision")
local Encounter = require("src.world.Encounter")
local FieldDefaults = require("src.world.FieldDefaults")
local GameVersion = require("src.core.GameVersion")
local GameViewport = require("src.render.GameViewport")
local Logger = require("src.core.Logger")
local Map = require("src.world.Map")
local MapLoader = require("src.world.MapLoader")
@@ -5196,7 +5195,7 @@ function OverworldState:drawWorld()
-- point projects under the pipeline's own camera. That is the direct
-- analogue of what :billboard does for tilt, and it keeps exactly one
-- copy of every effect: the closures above are the ones that run.
local pw, ph = GameViewport.dimensions()
local _, _, pw, ph = Game.renderer:playfieldRect()
local pscale = Zoom.scale(Game.renderer:fitScale())
local ctx = {
state = self, cam = cam, vw = vw, vh = vh, bgY = bgY,
+5 -5
View File
@@ -34,7 +34,7 @@ local Font = require("src.render.Font")
-- a mod has taken a facade (src/mods/Gen2Compat.lua).
local Gen1Facade = require("src.mods.Gen2Compat")
local GbcPalette = require("src.render.GbcPalette")
local GameViewport = require("src.render.GameViewport")
local Playfield = require("src.render.Playfield")
local Gen2Save = require("src.core.gen2.Save")
local HallOfFame = require("src.core.gen2.HallOfFame")
local HiddenItems = require("src.world.gen2.HiddenItems")
@@ -7499,7 +7499,7 @@ function World:interactBody()
end
function World:fitScale()
local w, h = GameViewport.dimensions()
local w, h = Playfield.dimensions()
return math.max(1, math.floor(math.min(w / 160, h / 144)))
end
@@ -8336,7 +8336,7 @@ function World:rebuildNeighbors()
self.neighbors = {}
if not self.map then return end
local s = self:zoomScale()
local ww, wh = GameViewport.dimensions()
local ww, wh = Playfield.dimensions()
local vw = math.ceil(ww / s)
local vh = math.ceil(wh / s)
if vw % 2 ~= 0 then vw = vw + 1 end
@@ -9748,7 +9748,7 @@ function World:drawGround(s)
if canvas then
bw, bh = canvas:getDimensions()
else
bw, bh = GameViewport.dimensions()
bw, bh = Playfield.dimensions()
end
if BorderFill.fillBlock(self.map.def) == false then
-- BLACK: World:draw clears to a brown letterbox, so the void itself
@@ -10017,7 +10017,7 @@ end
function World:draw()
local G = love.graphics
local w, h = GameViewport.dimensions()
local w, h = Playfield.dimensions()
self:refreshColorMode()
G.clear(0.07, 0.05, 0.02, 1)