Files
gen1recomp/src/import/LauncherSettings.lua
T
2026-08-21 14:03:25 -04:00

764 lines
27 KiB
Lua
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
-- Launcher settings rows: the gear menu's model layer.
--
-- The in-game OPTION menu (src/ui/OptionsMenu.lua) mutates game.save.options
-- and live-applies each change to the running engine. The launcher has no
-- running engine, so this builds the same ladders against the persisted
-- options.lua table (src/core/SaveData.loadOptions/saveOptions) and lets the
-- next boot's applyOptions pick the values up. Every ladder mirrors
-- OptionsMenu's semantics and stored values; when editing one, keep the two
-- in sync. ZOOM uses the live window's integer fit (same 160×144 rule as
-- Renderer:fitScale) so the row can offer OUT/FIT/IN without a running game.
--
-- Rows are the same descriptor idiom OptionRows draws in game:
-- { label, value = fn() -> string, step = fn(dir) -> changed,
-- editText = { maxLen } } -- editText marks a free-text row; the view
-- opens its prompt and commits via setText.
--
-- Mod rows come from each enabled mod's options_schema (the manager's auto-UI
-- contract, src/mods/ManagerState.lua buildOptionRows) and persist in
-- options.modOptions[modId][key], the exact table the loader reads on boot.
local Strings = require("src.core.Strings")
local SaveData = require("src.core.SaveData")
local LauncherSettings = {}
local function wrapIndex(i, n)
i = i % n
if i < 0 then i = i + n end
return i
end
local function volLabel(v)
v = v or 7
return v == 0 and "OFF" or tostring(v)
end
local function stepVolume(v, dir)
return math.max(0, math.min(7, (v or 7) + dir))
end
-- Cycle a stored value through an ordered list of {stored, label} pairs.
local function ladder(opts, key, pairsList, default)
local function index()
local cur = opts[key]
if cur == nil then cur = default end
for i, p in ipairs(pairsList) do
if p[1] == cur then return i end
end
return 1
end
return function() return Strings(pairsList[index()][2]) end,
function(dir)
opts[key] = pairsList[wrapIndex(index() - 1 + (dir or 1), #pairsList) + 1][1]
return true
end
end
-- TextSpeedOptionData delays with the original labels (OptionsMenu SPEEDS).
local SPEEDS = { { 1, "FAST" }, { 3, "MEDIUM" }, { 5, "SLOW" } }
local FILTERS = { "OFF", "1X", "2X", "3X" }
-- The core rows. Helper modules are required lazily under pcall: they are
-- pure label/cycle tables, but the launcher must never die because a render
-- module grew a dependency on live game data.
-- TOUCH PAD, VIBRATION and the layout editor, shared by both row sets.
--
-- Gold reads these out of its own `gold` block (src/core/gen2/Save.lua:297),
-- so `opts` is whichever table the gear is editing and the rows never have to
-- know which game they belong to. #1100 / #1135: the Gold gear carried none
-- of them, so a phone player could turn the pad and the buzz off in Red and
-- had no way to reach either in Gold.
local function addTouchRows(rows, add, opts, hooks)
-- TOUCH PAD only where the overlay can appear, mirroring OptionsMenu's
-- gate (mobile, or desktop forced by POKEPORT_TOUCH=1).
local env = os.getenv("POKEPORT_TOUCH")
local osName = love.system and love.system.getOS and love.system.getOS()
local show = env == "1"
or (env ~= "0" and (osName == "Android" or osName == "iOS"))
if show then
add(Strings("TOUCH PAD"),
function()
local tc = opts.touchControls
local on = not (type(tc) == "table" and tc.enabled == false)
return on and Strings("ON") or Strings("OFF")
end,
function()
local tc = type(opts.touchControls) == "table" and opts.touchControls or {}
tc.enabled = tc.enabled == false
opts.touchControls = tc
return true
end)
-- VIBRATION sits with it (#806): same gate, same subsystem. Stepping
-- the row buzzes once at the level being selected.
local okTC, TC = pcall(require, "src.core.TouchControls")
if okTC then
add(Strings("VIBRATION"),
function() return Strings(TC.hapticLabel(opts.haptics)) end,
function(dir)
opts.haptics = TC.cycleHaptics(opts.haptics, dir)
TC.buzz(opts.haptics)
return true
end)
end
end
-- TOUCH CONTROLS, the on-screen pad's layout editor. It used to be a
-- button on the game panel, once per game -- but the overlay layout is
-- global (options.touchControls.layouts), so three tabs offered three
-- buttons that edited the same thing while crowding the column that has to
-- hold Play. It belongs with the other control rows, behind the gear.
-- The host owns the editor screen, so the row only fires when a hook was
-- supplied (the standalone save editor opens this model with none).
if hooks and hooks.editTouchControls then
rows[#rows + 1] = {
label = Strings("TOUCH CONTROLS"),
actionLabel = Strings("Edit"),
action = function()
hooks.editTouchControls()
-- The editor replaces the whole screen: nothing left to persist here
-- beyond what the caller already saved on the way out.
return false
end,
}
end
if hooks and hooks.openSkinStudio then
rows[#rows + 1] = {
label = Strings("SKIN STUDIO"),
actionLabel = Strings("Open"),
action = function()
hooks.openSkinStudio()
return false
end,
}
end
end
local function coreRows(opts, hooks)
local rows = {}
local function add(label, value, step)
rows[#rows + 1] = { label = label, value = value, step = step }
end
add(Strings("TEXT SPEED"), ladder(opts, "textSpeed", SPEEDS, 3))
add(Strings("BATTLE ANIMATION"),
ladder(opts, "animations",
{ { true, "ON" }, { false, "OFF" } }, true))
add(Strings("BATTLE STYLE"),
ladder(opts, "battleStyle",
{ { "shift", "SHIFT" }, { "set", "SET" } }, "shift"))
add(Strings("BATTLE LAYOUT"),
function()
return opts.battleLayout == "wide" and Strings("WIDE") or Strings("OG")
end,
function()
opts.battleLayout = opts.battleLayout == "wide" and "og" or "wide"
if opts.battleLayout ~= "wide" then
opts.battleHud = "standard"
elseif opts.battleFit == "fill" and opts.battleHud == "extended" then
opts.battleBg = "white"
end
return true
end)
add(Strings("BATTLE SIZE"),
function()
return opts.battleFit == "fill" and Strings("FILL") or Strings("FIXED")
end,
function()
opts.battleFit = opts.battleFit == "fill" and "fixed" or "fill"
if opts.battleFit == "fill" and opts.battleLayout == "wide"
and opts.battleHud == "extended" then
opts.battleBg = "white"
end
return true
end)
add(Strings("BATTLE HUD"),
function()
return opts.battleLayout == "wide" and opts.battleHud == "extended"
and Strings("EXTENDED")
or Strings("STANDARD")
end,
function()
if opts.battleLayout ~= "wide" then
opts.battleHud = "standard"
return false
end
opts.battleHud = opts.battleHud == "extended" and "standard" or "extended"
if opts.battleHud == "extended" and opts.battleFit == "fill" then
opts.battleBg = "white"
end
return true
end)
add(Strings("BATTLE BG"),
function()
if opts.battleLayout == "wide" and opts.battleFit == "fill"
and opts.battleHud == "extended" then
opts.battleBg = "white"
return Strings("AUTO")
end
if opts.battleBg == "black" then return Strings("BLACK") end
if opts.battleBg == "world" then return Strings("WORLD") end
return Strings("WHITE")
end,
function(dir)
if opts.battleLayout == "wide" and opts.battleFit == "fill"
and opts.battleHud == "extended" then
opts.battleBg = "white"
return false
end
local order = { "white", "black", "world" }
local cur = 1
for i, mode in ipairs(order) do
if opts.battleBg == mode then cur = i break end
end
opts.battleBg = order[wrapIndex(cur - 1 + (dir or 1), #order) + 1]
return true
end)
add(Strings("UI LAYOUT"),
ladder(opts, "uiLayout",
{ { "centered", "CENTERED" }, { "dynamic", "DYNAMIC" } }, "centered"))
add(Strings("MUSIC VOL"),
function() return volLabel(opts.musicVol) end,
function(dir) opts.musicVol = stepVolume(opts.musicVol, dir); return true end)
add(Strings("SFX VOL"),
function() return volLabel(opts.sfxVol) end,
function(dir) opts.sfxVol = stepVolume(opts.sfxVol, dir); return true end)
add(Strings("MUSIC FILTER"),
function() return FILTERS[(opts.musicFilter or 0) + 1] end,
function(dir)
opts.musicFilter = ((opts.musicFilter or 0) + dir) % #FILTERS
return true
end)
local okPerf, Performance = pcall(require, "src.core.Performance")
if okPerf then
add(Strings("PERFORMANCE"),
function() return Strings(Performance.label(opts.performance)) end,
function(dir)
opts.performance = Performance.cycle(opts.performance, dir)
return true
end)
end
local okPal, PaletteFX = pcall(require, "src.render.PaletteFX")
if okPal then
add(Strings("COLORS"),
function() return PaletteFX.modeLabel(opts.colors or "gbc") end,
function(dir)
local cur, idx = opts.colors or "gbc", 1
for i, m in ipairs(PaletteFX.MODES) do
if m == cur then idx = i break end
end
opts.colors = PaletteFX.MODES[wrapIndex(idx - 1 + dir, #PaletteFX.MODES) + 1]
return true
end)
end
local okTilt, Tilt = pcall(require, "src.render.Tilt")
if okTilt then
add(Strings("TILT"),
function() return Tilt.levelLabel(opts.tilt or 0) end,
function(dir)
opts.tilt = wrapIndex((opts.tilt or 0) + dir, 4)
return true
end)
end
-- issue #136: GBC FX soft-bricks the mobile present shader; same gate as
-- the in-game row.
local okFx, GBCFX = pcall(require, "src.render.GBCFX")
if okFx and GBCFX.isSupported() then
add(Strings("GBC FX"),
function() return GBCFX.levelLabel(opts.gbcfx or 0) end,
function(dir)
opts.gbcfx = wrapIndex((opts.gbcfx or 0) + dir, 5)
return true
end)
end
local okZ, Zoom = pcall(require, "src.render.Zoom")
if okZ then
add(Strings("ZOOM"),
function() return Zoom.offsetLabel(opts.zoom or 0) end,
function(dir)
Zoom.nudgeOptions(opts, dir, Zoom.windowFitScale())
return true
end)
end
local okTile, TileRenderer = pcall(require, "src.render.TileRenderer")
if okTile and TileRenderer.VOID_FILLS then
add(Strings("VOID FILL"),
function() return TileRenderer.voidFillLabel(opts.voidFill) end,
function(dir)
local modes = TileRenderer.VOID_FILLS
local cur, idx = opts.voidFill or "trees", 1
for i, m in ipairs(modes) do
if m == cur then idx = i break end
end
opts.voidFill = modes[wrapIndex(idx - 1 + dir, #modes) + 1]
return true
end)
end
local okVm, VideoMode = pcall(require, "src.core.VideoMode")
if okVm then
add(Strings("VIDEO MODE"),
function() return VideoMode.modeLabel(opts.videoMode) end,
function(dir)
opts.videoMode = VideoMode.cycle(opts.videoMode, dir)
return true
end)
end
-- ORIENTATION (#592, #1638): mobile only. Unlike the other launcher rows
-- this one live-applies: the window exists here too, and rotating under
-- the player's finger is the only feedback that reads.
do
local okOr, Orientation = pcall(require, "src.core.Orientation")
if okOr and (Orientation.isAndroid() or Orientation.isIOS()) then
add(Strings("ORIENTATION"),
function() return Strings(Orientation.modeLabel(opts.orientation)) end,
function(dir)
opts.orientation = Orientation.cycle(opts.orientation, dir)
Orientation.apply(opts.orientation)
return true
end)
end
end
local okFr, FaithfulRes = pcall(require, "src.core.FaithfulRes")
if okFr then
add(Strings("FAITHFUL RATIO"),
function() return FaithfulRes.label(opts.faithfulRes) end,
function(dir)
opts.faithfulRes = FaithfulRes.cycle(opts.faithfulRes, dir)
return true
end)
end
local okSp, ScreenPos = pcall(require, "src.core.ScreenPosition")
if okSp then
add(Strings("SCREEN POS"),
function() return Strings(ScreenPos.label(opts.screenPos)) end,
function(dir)
opts.screenPos = ScreenPos.cycle(opts.screenPos, dir)
ScreenPos.setMode(opts.screenPos)
return true
end)
end
local okCap, FrameCap = pcall(require, "src.core.FrameCap")
if okCap then
add(Strings("MAX FPS"),
function() return FrameCap.label(opts.fpsCap) end,
function(dir)
opts.fpsCap = FrameCap.cycle(opts.fpsCap, dir)
return true
end)
end
local okSpd, GameSpeed = pcall(require, "src.core.GameSpeed")
if okSpd then
-- Per-category (RFC 0007): overworld/battle/menu each cycle their own
-- multiplier, mirroring OptionsMenu.lua's three rows.
add(Strings("OVERWORLD SPEED"),
function() return GameSpeed.levelLabel(opts.speedOverworld) end,
function(dir)
opts.speedOverworld = GameSpeed.cycle(opts.speedOverworld, dir)
return true
end)
add(Strings("BATTLE SPEED"),
function() return GameSpeed.levelLabel(opts.speedBattle) end,
function(dir)
opts.speedBattle = GameSpeed.cycle(opts.speedBattle, dir)
return true
end)
add(Strings("MENU SPEED"),
function() return GameSpeed.levelLabel(opts.speedMenu) end,
function(dir)
opts.speedMenu = GameSpeed.cycle(opts.speedMenu, dir)
return true
end)
end
addTouchRows(rows, add, opts, hooks)
-- RESET REBINDS, directly under the touch-pad row. Rebinds are additive
-- (src/core/Input.lua:applyBindings layers options.bindings over the
-- defaults rather than replacing them), so a player who has bound
-- themselves into a corner has no in-game way back -- there is no "unbind"
-- gesture. Clearing the table restores the stock keyboard and pad layout
-- on the next Input:applyBindings, which the game does on its next start.
-- The dragged touch-overlay layout goes with it: it is the same class of
-- customisation and the same class of getting stuck.
rows[#rows + 1] = {
label = Strings("RESET REBINDS"),
actionLabel = Strings("Reset"),
danger = true,
action = function()
opts.bindings = nil
local tc = opts.touchControls
if type(tc) == "table" then tc.layouts = nil end
return true
end,
}
return rows
end
-- ------- per-mod options (the manager's options_schema auto-UI contract)
local OPTION_TYPES = { toggle = true, choice = true, number = true, text = true }
-- Enabled mods with a loadable options_schema, discovered the same way
-- LauncherMods discovers manifests (mods/ one level deep; the launcher's
-- readiness check has already mounted a portable install's game folder).
local function discoverModSchemas(opts)
local fs = love and love.filesystem
local out = {}
if not (fs and fs.getInfo and fs.getDirectoryItems) then return out end
if not fs.getInfo("mods") then return out end
local okJson, Json = pcall(require, "src.link.Json")
local okMan, Manifest = pcall(require, "src.mods.Manifest")
if not (okJson and okMan) then return out end
local seen = {}
for _, name in ipairs(fs.getDirectoryItems("mods")) do
local path = "mods/" .. name
local info = fs.getInfo(path)
if info and (info.type == "directory" or info.type == "symlink") then
local raw = fs.read(path .. "/manifest.json")
local data = raw and select(1, Json.decode(raw))
local okV, m = false, nil
if data then okV, m = pcall(Manifest.validate, data, path) end
if okV and m and not seen[m.id] and m.options_schema then
seen[m.id] = true
-- deriveList's enable resolution, through the one reader both mod
-- surfaces use (SaveData.modEnabled): unanswered means enabled,
-- 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 and not SaveData.isSafeMode(opts) then
local chunk = fs.load(path .. "/" .. m.options_schema)
if chunk then
local okR, schema = pcall(chunk)
if okR and type(schema) == "table" then
out[#out + 1] = { id = m.id, name = m.name or m.id, schema = schema }
end
end
end
end
end
end
table.sort(out, function(a, b) return a.id < b.id end)
return out
end
-- Rows for one mod's schema against options.modOptions (ManagerState's
-- persistence shape, so the game sees launcher edits on its next boot).
local function modRows(opts, mod)
local rows = {}
local modId = mod.id
local function stored()
local t = opts.modOptions
return t and t[modId] or nil
end
local function get(row)
local s = stored()
local v = s and s[row.key]
if v == nil then v = row.default end
return v
end
local function set(key, value)
opts.modOptions = opts.modOptions or {}
opts.modOptions[modId] = opts.modOptions[modId] or {}
opts.modOptions[modId][key] = value
end
for _, row in ipairs(mod.schema) do
if type(row) ~= "table" or type(row.key) ~= "string" or row.key == ""
or not OPTION_TYPES[row.type] then
-- malformed rows are skipped silently here; the in-game manager is
-- where schema errors are reported to the author
elseif row.type == "toggle" then
rows[#rows + 1] = { label = row.label or row.key,
value = function() return get(row) and Strings("ON") or Strings("OFF") end,
step = function()
set(row.key, not get(row))
return true
end }
elseif row.type == "choice" then
rows[#rows + 1] = { label = row.label or row.key,
value = function()
local cur = get(row)
for _, choice in ipairs(row.choices or {}) do
if choice[2] == cur then return tostring(choice[1]) end
end
local first = (row.choices or {})[1]
return first and tostring(first[1]) or "----"
end,
step = function(dir)
local choices = row.choices or {}
if #choices == 0 then return false end
local cur, index = get(row), 1
for i, choice in ipairs(choices) do
if choice[2] == cur then index = i break end
end
set(row.key, choices[wrapIndex(index - 1 + dir, #choices) + 1][2])
return true
end }
elseif row.type == "number" then
rows[#rows + 1] = { label = row.label or row.key,
value = function() return tostring(get(row) or 0) end,
step = function(dir)
local v = (tonumber(get(row)) or 0) + dir * (row.step or 1)
if row.min then v = math.max(row.min, v) end
if row.max then v = math.min(row.max, v) end
set(row.key, v)
return true
end }
elseif row.type == "text" then
rows[#rows + 1] = { label = row.label or row.key,
value = function() return tostring(get(row) or "") end,
editText = { maxLen = row.maxLen or 7 },
setText = function(text) set(row.key, text) end }
end
end
if #rows > 0 then
rows[#rows + 1] = { label = Strings("RESET DEFAULTS"),
value = function() return "" end,
step = function()
for _, row in ipairs(mod.schema) do
if type(row) == "table" and type(row.key) == "string"
and OPTION_TYPES[row.type] then
set(row.key, row.default)
end
end
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
-- ------- Gen 2 (Gold)
--
-- Gold reads NONE of the rows above. Its OPTION screen writes a different
-- set of names, several of which collide with Gen 1's at a different TYPE
-- (battleStyle "SHIFT" vs "shift", textSpeed a label vs a frame delay), and
-- its renderer has no battle layout and no SGB palette packs -- so a gear
-- opened on the Gold tab used to offer a dozen controls that did nothing
-- and hide the seven that the cart itself has.
--
-- The block lives in options.lua under `gold` (the historical key; Gold and
-- Silver share it the way the Gen 1 games share the flat namespace), which is
-- exactly where src/core/gen2/Save.lua loadOptions reads it, so an edit here
-- is live on the next boot the same way a Gen 1 edit is. Ladders mirror
-- src/ui/gen2/OptionsMenu.lua's ROWS; when editing one, keep the two in sync.
local GEN2_KEY = "gold"
local function gen2Rows(opts, hooks)
local rows = {}
local function add(label, value, step)
rows[#rows + 1] = { label = label, value = value, step = step }
end
-- The cart's own seven (engine/menus/options_menu.asm _Option).
add(Strings("TEXT SPEED"), ladder(opts, "textSpeed",
{ { "FAST", "FAST" }, { "MID", "MID" }, { "SLOW", "SLOW" } }, "MID"))
add(Strings("BATTLE SCENE"), ladder(opts, "battleScene",
{ { true, "ON" }, { false, "OFF" } }, true))
add(Strings("BATTLE STYLE"), ladder(opts, "battleStyle",
{ { "SHIFT", "SHIFT" }, { "SET", "SET" } }, "SHIFT"))
add(Strings("SOUND"), ladder(opts, "sound",
{ { "MONO", "MONO" }, { "STEREO", "STEREO" } }, "MONO"))
add(Strings("PRINT"), ladder(opts, "print", {
{ "LIGHTEST", "LIGHTEST" }, { "LIGHTER", "LIGHTER" },
{ "NORMAL", "NORMAL" }, { "DARKER", "DARKER" }, { "DARKEST", "DARKEST" },
}, "NORMAL"))
add(Strings("MENU ACCOUNT"), ladder(opts, "menuAccount",
{ { false, "OFF" }, { true, "ON" } }, true))
-- FRAME is the textbox border, 1-8, wrapping (UpdateFrame masks to 3 bits).
add(Strings("FRAME"),
function() return tostring(opts.frame or 1) end,
function(dir)
opts.frame = wrapIndex((opts.frame or 1) - 1 + (dir or 1), 8) + 1
return true
end)
-- ...then the port's, the same shared modules the Gen 1 rows drive.
add(Strings("MUSIC VOL"),
function() return volLabel(opts.musicVol) end,
function(dir) opts.musicVol = stepVolume(opts.musicVol, dir); return true end)
add(Strings("SFX VOL"),
function() return volLabel(opts.sfxVol) end,
function(dir) opts.sfxVol = stepVolume(opts.sfxVol, dir); return true end)
add(Strings("MUSIC FILTER"),
function() return FILTERS[(opts.musicFilter or 0) + 1] end,
function(dir)
opts.musicFilter = ((opts.musicFilter or 0) + dir) % #FILTERS
return true
end)
local okPal, GbcPalette = pcall(require, "src.render.GbcPalette")
if okPal then
add(Strings("COLOR"),
function() return GbcPalette.modeLabel(opts.color or "gbc") end,
function(dir)
local cur, idx = opts.color or "gbc", 1
for i, mode in ipairs(GbcPalette.MODES) do
if mode == cur then idx = i break end
end
opts.color =
GbcPalette.MODES[wrapIndex(idx - 1 + dir, #GbcPalette.MODES) + 1]
return true
end)
end
local okSpd, GameSpeed = pcall(require, "src.core.GameSpeed")
if okSpd then
add(Strings("GAME SPEED"),
function() return GameSpeed.levelLabel(opts.speed) end,
function(dir)
opts.speed = GameSpeed.cycle(opts.speed, dir)
return true
end)
end
local okTilt, Tilt = pcall(require, "src.render.Tilt")
if okTilt then
add(Strings("TILT"),
function() return Tilt.levelLabel(opts.tilt or 0) end,
function(dir)
opts.tilt = wrapIndex((opts.tilt or 0) + dir, 4)
return true
end)
end
local okZ, Zoom = pcall(require, "src.render.Zoom")
if okZ then
add(Strings("ZOOM"),
function() return Zoom.offsetLabel(opts.zoom or 0) end,
function(dir)
Zoom.nudgeOptions(opts, dir, Zoom.windowFitScale())
return true
end)
end
local okFill, BorderFill = pcall(require, "src.world.gen2.BorderFill")
if okFill and BorderFill.VOID_FILLS then
add(Strings("VOID FILL"),
function() return BorderFill.voidFillLabel(opts.voidFill) end,
function(dir)
local modes = BorderFill.VOID_FILLS
local cur, idx = opts.voidFill or "fade", 1
for i, m in ipairs(modes) do
if m == cur then idx = i break end
end
opts.voidFill = modes[wrapIndex(idx - 1 + dir, #modes) + 1]
return true
end)
end
-- Same #136 gate as the Gen 1 row and the in-game one.
local okFx, GBCFX = pcall(require, "src.render.GBCFX")
if okFx and GBCFX.isSupported() then
add(Strings("GBC FX"),
function() return GBCFX.levelLabel(opts.gbcfx or 0) end,
function(dir)
opts.gbcfx = wrapIndex((opts.gbcfx or 0) + dir, 5)
return true
end)
end
local okVm, VideoMode = pcall(require, "src.core.VideoMode")
if okVm then
add(Strings("VIDEO MODE"),
function() return VideoMode.modeLabel(opts.videoMode) end,
function(dir)
opts.videoMode = VideoMode.cycle(opts.videoMode, dir)
return true
end)
end
local okCap, FrameCap = pcall(require, "src.core.FrameCap")
if okCap then
add(Strings("MAX FPS"),
function() return FrameCap.label(opts.fpsCap) end,
function(dir)
opts.fpsCap = FrameCap.cycle(opts.fpsCap, dir)
return true
end)
end
addTouchRows(rows, add, opts, hooks)
return rows
end
-- Build the whole settings model: one options table (edited in place),
-- sections of rows, and a save() that persists it. The caller keeps the
-- model for as long as the panel is open; nothing else in the launcher
-- writes options while a modal covers it, so the cached table stays true.
-- `hooks` carries the host actions a row cannot perform itself:
-- editTouchControls() -- hand the screen to the touch-overlay editor
-- openSkinStudio() -- hand the screen to the desktop skin studio
--
-- `version` is the game the gear was opened on. It picks the row set, and
-- for Gold it also picks WHICH table the rows edit: the `gold` block inside
-- options.lua rather than the flat Gen 1 one.
function LauncherSettings.open(hooks, version)
local opts = SaveData.loadOptions()
local sections
local GameVersion = require("src.core.GameVersion")
if GameVersion.VERSIONS[version]
and GameVersion.generation(version) == 2 then
local block = opts[GEN2_KEY]
if type(block) ~= "table" then
block = {}
opts[GEN2_KEY] = block
end
sections = {
{ title = Strings("OPTIONS"), rows = gen2Rows(block, hooks) },
}
else
sections = {
{ title = Strings("OPTIONS"), rows = coreRows(opts, hooks) },
}
end
-- Mod options are generation-agnostic (the manager's options_schema
-- contract), so they ride along either way.
for _, mod in ipairs(discoverModSchemas(opts)) do
local rows = modRows(opts, mod)
if #rows > 0 then
sections[#sections + 1] = { title = mod.name, rows = rows }
end
end
return {
opts = opts,
version = version,
sections = sections,
save = function() SaveData.saveOptions(opts) end,
}
end
return LauncherSettings