Merge origin/dev (v0.1.74) into feat/switch-ota-unified-launcher.

Keep Switch version chip alongside upstream's new quit button in the launcher header.
This commit is contained in:
Andrew Quenehen
2026-08-06 09:28:26 -03:00
115 changed files with 10474 additions and 2013 deletions
+48 -2
View File
@@ -202,6 +202,30 @@ local function headerChannels(banks, header)
return channels
end
-- Which software channels (CHAN5-8) an sfx occupies: its header carries one
-- 3-byte descriptor per channel. Audio2_PlaySound walks exactly this list to
-- decide whether a new sfx may start at all (audio/engine_2.asm
-- .sfxChannelLoop), so Sound.playMove needs the set to reproduce that gate.
-- nil = not knowable here (a file def, or the banks are not readable yet),
-- which callers read as "no conflict".
function ChipSynth.effectChannels(data, def)
if type(def) ~= "table" then return nil end
local chip = def.chip
local specs = chip and chip.channels
if not specs then
if not def.address then return nil end
local ok, banks = pcall(engineBanks, data, chip)
if not ok then return nil end
local read
ok, read = pcall(headerChannels, banks, def)
if not ok then return nil end
specs = read
end
local channels = {}
for _, spec in ipairs(specs) do channels[#channels + 1] = spec.number end
return channels
end
local function fadeValue(nibble)
if bit.band(nibble, 8) ~= 0 then return -bit.band(nibble, 7) end
return nibble
@@ -406,7 +430,15 @@ function Channel:nextEvent()
elseif command == 0xEC then
self.duty = bit.band(self:byte(), 3)
elseif command == 0xED then
self.engine.tempo = self:byte() * 0x100 + self:byte()
local high = self:byte()
local low = self:byte()
-- a header carrying its own tempo is one of audio/alternate_tempo.asm's
-- Music_*AlternateTempo entry points, which re-point channel 1 at a
-- stub that sets the tempo and jumps into the normal body -- the body's
-- own tempo command never runs there, so ignore it here (#847)
if not self.engine.tempoLocked then
self.engine.tempo = high * 0x100 + low
end
elseif command == 0xEE then
self.engine.pan = self:byte()
elseif command == 0xEF or command == 0xF0 then
@@ -458,7 +490,15 @@ function Channel:nextEvent()
local volume = bit.rshift(packed, 4)
local fade = fadeValue(bit.band(packed, 0x0F))
if self.noise then
local parameter = self:byte()
-- Audio2_ApplyWavePatternAndFrequency adds wFrequencyModifier to the
-- frequency low byte for every channel at or past CHAN5, the noise
-- channel included (audio/engine_2.asm Audio2_ApplyFrequencyModifier).
-- On CHAN8 that byte is the polynomial counter, so the modifier moves
-- the noise pitch; it wraps at 8 bits, the carry landing in the high
-- byte that noise does not use for frequency. Dropping it left the
-- battle hit sounds at their unmodified pitches, where super effective
-- reads as the duller of the two (#826).
local parameter = bit.band(self:byte() + self.frequencyOffset, 0xFF)
return self:noiseEvent(
self:durationTicks(length), volume, fade, parameter)
end
@@ -753,6 +793,12 @@ function Engine.new(data, header, options)
noiseInstruments = {},
channels = {},
}, Engine)
-- header.tempo: the Music_*AlternateTempo override Music.play stamps onto
-- a copy of the song def (audio/alternate_tempo.asm) (#847)
if header.tempo then
engine.tempo = header.tempo
engine.tempoLocked = true
end
for _, spec in ipairs(chip and chip.channels
or headerChannels(banks, header)) do
local frameTicks = options.frameTicks
+6 -2
View File
@@ -16,6 +16,10 @@ local Screens = require("src.ui.Screens")
local Game = {}
local function renderVisible(stack, state)
return state and (not stack.renderVisible or stack:renderVisible(state))
end
-- dev-mode gate for the F5/backtick hotkeys; false keeps every src/dev
-- module unloaded, so a player boot never touches a byte of dev code
local devMode = os.getenv("POKEPORT_DEV") == "1" or _G.POKEPORT_DEV_MODE == true
@@ -460,7 +464,7 @@ function Game:draw()
local state = self.stack.states[i]
local wideState = state and state.isWideBattleLayout
and state:isWideBattleLayout()
if state and state.draw then
if renderVisible(self.stack, state) and state.draw then
if classicOffset ~= 0 and not wideState then
love.graphics.push()
love.graphics.translate(classicOffset, 0)
@@ -484,7 +488,7 @@ function Game:draw()
local zones, worldZones, zoneOwner
for i = #self.stack.states, 1, -1 do
local s = self.stack.states[i]
if s.sgbPalettes then
if renderVisible(self.stack, s) and s.sgbPalettes then
zones = s:sgbPalettes(self)
zoneOwner = s
break
+35 -1
View File
@@ -62,8 +62,35 @@ function HostShell.hideHostConsole()
return consoleHidden
end
-- #254 was fixed inside the launcher and nowhere else: a native dialog opened
-- while a mouse button is still down blocks the whole loop in io.popen, so SDL
-- never processes the button-up and never drops the pointer capture it took
-- for the press (on X11 an XGrabPointer with owner_events). The grab outlives
-- the click, every pointer event over the child dialog is still routed to our
-- window, and the dialog draws and keyboard-navigates but ignores the mouse.
-- src/import/RomImporter.lua owns the launcher's copy; hoisting it here means
-- every host spawn inherits it, including one a mod reaches through HostShell.
-- Pump until nothing is held so SDL sees the release first; bounded, so a
-- stuck button costs a moment and never the game. pump() drains OS events
-- into LOVE's queue and dispatches nothing, so there is no reentry. Worker
-- threads load neither love.mouse nor love.event, so the guard below makes
-- this a no-op off the main thread.
function HostShell.releasePointerGrab()
if not (love and love.mouse and love.mouse.isDown and love.event
and love.event.pump and love.timer) then
return
end
local deadline = love.timer.getTime() + 1
while love.mouse.isDown(1, 2, 3) do
love.event.pump()
if love.timer.getTime() > deadline then break end
love.timer.sleep(0.005)
end
end
-- Wraps io.popen with the AppImage env fix applied and lua errors swallowed
function HostShell.popen(command, mode)
HostShell.releasePointerGrab()
local ok, pipe = pcall(io.popen, HostShell.envPrefix() .. command, mode or "r")
if not ok or not pipe then return nil end
return pipe
@@ -154,8 +181,15 @@ local function haveBridge()
if not (love and love.system and type(love.system.httpDownload) == "function") then
return false
end
-- The OS allowlist is deliberate: the bridge is a per-port native addition,
-- not part of LOVE, so a build that exports the name on a platform we never
-- wired one for is a name collision, not a transport. UWP is listed because
-- Xbox has no curl and no way to spawn one (Platform.canSpawnProcess is
-- false there), so the bridge is its only possible transport (#876). Its
-- LOVE backend does not export it today and this still returns false, but
-- the gate is no longer the thing in the way.
local osName = love.system.getOS and love.system.getOS()
return osName == "Android" or osName == "iOS"
return osName == "Android" or osName == "iOS" or osName == "UWP"
end
-- Is any transport available at all? Callers gate on this, never on curl.
+11 -5
View File
@@ -1,10 +1,16 @@
-- Launch options: boot straight into a game, skipping the launcher.
--
-- love . --game red -- boot Red
-- love . --game yellow --slot 2 -- boot Yellow on save slot 2
-- love . --game red --launcher -- open the launcher anyway (a shortcut
-- the player wants to edit)
-- POKEPORT_GAME=blue love . -- same, for launchers that only pass env
-- love . --game=red -- boot Red
-- love . --game=yellow --slot=2 -- boot Yellow on save slot 2
-- love . --game=red --launcher -- open the launcher anyway (a shortcut
-- the player wants to edit)
-- POKEPORT_GAME=blue love . -- same, for launchers that only pass env
--
-- The "--flag value" spelling parses here (argValue reads argv[i + 1]), but it
-- does not survive LOVE: boot.lua takes the first bare argument as a path to a
-- game to run, so `--game red` dies with "Cannot load game at path .../red"
-- before love.load is ever called, fused or not. Only the "=" spelling is
-- reachable, so that is the one the docs quote.
--
-- This exists for the click-once cases: a desktop shortcut per game, a Steam
-- entry, an EmulationStation/Playnite entry, a handheld frontend. Those all
+16 -1
View File
@@ -82,6 +82,7 @@ state = {
fanfare = nil, -- fanfare SFX source; the song pauses while it plays
fanfareResume = false, -- start/resume state.source when the fanfare ends
fade = nil, -- active volume-ramp fade-out (see Music.fadeOut)
tempo = nil, -- alternate-tempo override in force for `current`
failed = {}, -- labels whose def could not be started; logged once
}
@@ -234,11 +235,23 @@ function Music.play(data, song, loop, ctx)
if not song then return end
if not love.audio then return end -- headless test stub
song = selectSong(song, ctx)
-- ctx.tempo is a Music_*AlternateTempo cue (audio/alternate_tempo.asm):
-- the same song restarted with channel 1 re-pointed at a stub whose only
-- difference is its `tempo`, so the same label at a different tempo is a
-- different cue and must not be deduped away (#847)
local tempo = ctx and ctx.tempo or nil
-- a hook may silence the cue outright, or swap in a label the dedupe
-- below has to compare against
if not song or song == state.current then return end
if not song or (song == state.current and tempo == state.tempo) then return end
local def = songDef(data, song)
if not def or state.failed[song] then return end
if tempo then
-- shallow copy: the registry def is shared, only this playback is slowed
local slowed = {}
for key, value in pairs(def) do slowed[key] = value end
slowed.tempo = tempo
def = slowed
end
local wantLoop = loop ~= false
local src, loopSrc, isChip, err = startSong(data, def, wantLoop)
if not src then
@@ -274,6 +287,7 @@ function Music.play(data, song, loop, ctx)
local previous = state.current
state.source, state.loopSource, state.chip = src, loopSrc, isChip
state.current = song
state.tempo = tempo
if Runtime.wants("music.started") then
Runtime.emit("music.started", {
song = song, previous = previous, chip = isChip,
@@ -288,6 +302,7 @@ function Music.stop()
stopSource(state.loopSource)
require("src.core.ChipAudio").stopMusic()
state.current, state.source, state.loopSource, state.fade = nil, nil, nil, nil
state.tempo = nil
state.chip = false
state.pendingRestore = nil
if previous and Runtime.wants("music.stopped") then
+17
View File
@@ -12,6 +12,8 @@ local function compute()
local mobile = osName == "Android" or osName == "iOS"
local nativePicker = love and love.system
and type(love.system.pickFile) == "function"
local nativeHttp = love and love.system
and type(love.system.httpDownload) == "function"
return {
os = osName,
nx = nx,
@@ -24,6 +26,17 @@ local function compute()
or (nativePicker and "native-picker")
or "desktop",
networkValidated = not nx and not uwp,
-- networkValidated is the self-updater's gate and stays a per-platform
-- policy call: a console package cannot replace itself on disk, so that
-- answer never depends on whether a transport exists. Fetching a mod
-- index or a mod zip is the narrower question, and #876 showed the two
-- had been conflated, so Xbox lost the mod catalog for the updater's
-- reason. Desktop answers it with curl through HostShell; the mobile and
-- console ports answer it with the native love.system.httpDownload bridge
-- (#597). The UWP LOVE backend does not export that bridge yet, so this
-- still resolves false on Xbox and the launcher still says so, but the
-- day the backend grows one, nothing here or in RomImporter has to change.
canFetchRemote = (not nx and not uwp) or nativeHttp,
}
end
@@ -52,6 +65,10 @@ function Platform.networkValidated()
return Platform.detect().networkValidated
end
function Platform.canFetchRemote()
return Platform.detect().canFetchRemote
end
-- Tests may swap love.system between cases.
function Platform._resetForTests()
cached = nil
+18
View File
@@ -28,6 +28,24 @@ function SafeArea.rect()
return 0, 0, ww, wh
end
-- A safe rect that cannot fit the window's unit space is a backend
-- reporting framebuffer PIXELS -- the iOS build (LOVE 12 + SDL3) did this
-- in portrait on iOS 16, and clamping it as-is kept a DPI-inflated top
-- inset that pushed the whole launcher a band down the screen (#810).
-- Convert back to units with per-axis ratios; the axes can disagree on
-- forced-rotation devices (see displayMetrics in src/render/Renderer.lua,
-- #208).
if (w > ww + 0.5 or h > wh + 0.5)
and love.graphics.getPixelDimensions then
local pw, ph = love.graphics.getPixelDimensions()
local dx = (pw and pw > 0) and (pw / ww) or 1
local dy = (ph and ph > 0) and (ph / wh) or 1
if dx > 1.01 or dy > 1.01 then
x, w = x / dx, w / dx
y, h = y / dy, h / dy
end
end
-- Clamp to the drawable window so a bad / mid-rotation backend cannot
-- push layout outside the surface.
x = math.max(0, math.min(x, ww))
+81 -2
View File
@@ -30,6 +30,15 @@ local SaveData = {}
-- deliberately shared across versions (it holds global preferences and the
-- mod enable-state, not per-playthrough data).
local OPTIONS_FILENAME = "options.lua"
-- #828: options.lua is rewritten whole on every write (see saveOptions), and
-- unlike the progress files it had no staged copy, so a write interrupted
-- between the truncate and the flush -- the process replaced by
-- HostShell.restart on the way back to the launcher, an Android
-- external-storage volume that never flushed -- left a truncated or empty
-- file that loadOptions could only answer with defaults: every setting
-- "reset" at once. Same .bak/.tmp witness names the save files use.
local OPTIONS_BACKUP_FILENAME = OPTIONS_FILENAME .. ".bak"
local OPTIONS_TMP_FILENAME = OPTIONS_FILENAME .. ".tmp"
-- Main / backup / staged-witness names for a version (defaults to the active
-- one). The backup is a rolling copy and .tmp is the staged-write witness;
@@ -305,6 +314,13 @@ function SaveData.defaultOptions()
-- layout (#633). Pre-#633 files stored one top-level positions table;
-- TouchControls.normalizeConfig folds it into both orientations on load.
touchControls = { enabled = true },
-- Haptic feedback level for on-screen pad presses (#806):
-- off | light | medium | heavy, mapped to a love.system.vibrate
-- duration in src/core/TouchControls.lua. LIGHT by default, like the
-- overlay itself defaulting on, so an options.lua predating this key
-- gets the tick without going looking for the row. Inert wherever the
-- overlay never appears (desktop) or LOVE has no vibrator.
haptics = "light",
}
end
@@ -368,11 +384,54 @@ function SaveData.saveOptions(opts, fs)
end
opts.modOptions = merged
end
local ok, err = fs.write(OPTIONS_FILENAME, SaveSerializer.encode(opts))
local encoded = SaveSerializer.encode(opts)
-- Stage the new bytes and roll the last good file aside BEFORE the main
-- write truncates it, the same tmp/bak dance SaveData.save uses for
-- progress: whatever ends the process mid-write, one of the three copies
-- is complete and loadOptions promotes it instead of falling back to
-- defaults (#828).
local ok, err = fs.write(OPTIONS_TMP_FILENAME, encoded)
if not ok then
Logger.error("options save failed: %s", tostring(err))
return nil
end
return ok and opts or nil
local prev = fs.getInfo(OPTIONS_FILENAME) and fs.read(OPTIONS_FILENAME)
if type(prev) == "string" and prev ~= "" and prev ~= encoded then
fs.write(OPTIONS_BACKUP_FILENAME, prev)
end
ok, err = fs.write(OPTIONS_FILENAME, encoded)
if not ok then
Logger.error("options save failed: %s", tostring(err))
return nil
end
-- #828: settings "reset" on Android and Steam Deck with nothing in the log.
-- Every options write is a WHOLE-FILE rewrite, so a write that reports
-- success without the bytes landing (an external-storage volume that went
-- away mid-session, a read-only or full save dir) is indistinguishable from
-- "the launcher never saved". Read the file back and fail loudly instead:
-- callers already treat nil as a failed write, and the log line is what the
-- next report from those platforms needs to carry.
local wrote = fs.getInfo(OPTIONS_FILENAME) and fs.read(OPTIONS_FILENAME)
if wrote ~= encoded then
Logger.error("options save did not land (%d bytes written, %s on disk)",
#encoded, type(wrote) == "string" and tostring(#wrote) or "nothing")
return nil
end
-- #828: roll the backup FORWARD to the bytes just verified. The
-- pre-write roll above only preserves the previous file for a death
-- during this rewrite; at rest the backup must hold the newest verified
-- state, because the hard teardown out of a game session (HostShell's
-- restartApp kill on Android, execv on a SteamOS AppImage) can eat the
-- main file outright and loadOptions then promotes this copy. The
-- encoder is key-sorted, so the follow-up rewrites a play session makes
-- (play()'s lastVersion stamp, the in-game save flush) are byte-identical
-- and skip the conditional roll -- without this line the backup still
-- held the file from BEFORE the launcher's change, and recovery reverted
-- the just-changed setting (BATTLE LAYOUT back to OG).
fs.write(OPTIONS_BACKUP_FILENAME, encoded)
-- the staged witness has served its purpose; the main file is verified
remove(fs, OPTIONS_TMP_FILENAME)
return opts
end
function SaveData.loadOptions(fs)
@@ -382,6 +441,26 @@ function SaveData.loadOptions(fs)
if fs.getInfo(OPTIONS_FILENAME) then
Logger.error("options load failed: %s", tostring(err))
end
-- #828: answering defaults here is what "closing the game reset all my
-- settings" looked like -- one interrupted whole-file rewrite and every
-- preference, the mod enable-state and the slot registry were gone.
-- Promote the staged copy, then the rolled-aside backup, exactly as
-- SaveData.load does for progress, and heal the main file from whichever
-- one parsed.
local recovered = readTable(fs, OPTIONS_TMP_FILENAME)
local from = "tmp"
if not recovered then
recovered = readTable(fs, OPTIONS_BACKUP_FILENAME)
from = "bak"
end
if recovered then
Logger.warn("options.lua %s; recovered from %s copy",
fs.getInfo(OPTIONS_FILENAME) and "corrupt" or "missing", from)
if fs.write then
fs.write(OPTIONS_FILENAME, SaveSerializer.encode(recovered))
end
return SaveData.mergeOptions(recovered)
end
return SaveData.defaultOptions()
end
return SaveData.mergeOptions(data)
+84 -15
View File
@@ -208,29 +208,91 @@ end
-- sfx table; older audio.lua builds without the variants fall back to
-- the unmodified sound.
-- anim: a moves.lua anim table { sound, pitch, tempo }.
--
-- Whether a row sound is heard at all is Audio2_PlaySound's channel gate
-- (audio/engine_2.asm .playSfx/.sfxChannelLoop): for every channel the new
-- sfx wants, a channel still busy with a LOWER sound id aborts the whole
-- request (`cp [hl] / jr z,.playChannel / jr c,.playChannel / ret`), while
-- an equal or lower id takes those channels over. A sound id is
-- (header address - SFX_Headers_1) / 3 (constants/music_constants.asm
-- music_const), so a def's header address orders ids inside one engine
-- bank. Blizzard's animation is two rows, BLIZZARD then HYDRO_PUMP
-- (data/moves/animations.asm BlizzardAnim), and SFX_BATTLE_29 (CHAN5+8) is
-- still sounding when the second row starts, so the original never plays
-- SFX_BATTLE_2A (CHAN5+6+8) at all -- unguarded, its tail is heard running
-- past the end of the animation (#844).
local lastMoveSfx -- { src, rank, engine, channels } of the last row sound
local function channelsOverlap(a, b)
if not (a and b) then return false end
for _, x in ipairs(a) do
for _, y in ipairs(b) do
if x == y then return true end
end
end
return false
end
-- would PlaySound start this def now? Taking a channel over also stops the
-- sound that held it, the way .playChannel resets the channel.
local function sfxChannelGate(data, def)
local cur = lastMoveSfx
if not cur then return true end
local ok, playing = pcall(cur.src.isPlaying, cur.src)
if not (ok and playing) then
lastMoveSfx = nil
return true
end
-- an unrankable def (file asset, or another engine's bank) has no
-- comparable sound id: leave it to the mixer, as before
if type(def) ~= "table" or not def.address or def.engine ~= cur.engine then
return true
end
local channels = require("src.core.ChipSynth").effectChannels(data, def)
if not channelsOverlap(channels, cur.channels) then return true end
if def.address > cur.rank then return false end
pcall(cur.src.stop, cur.src)
lastMoveSfx = nil
return true
end
local function noteMoveSfx(data, def, src)
if not src or type(def) ~= "table" or not def.address then
lastMoveSfx = nil
return
end
lastMoveSfx = {
src = src, rank = def.address, engine = def.engine,
channels = require("src.core.ChipSynth").effectChannels(data, def),
}
end
function Sound.playMove(data, anim)
if not anim or not anim.sound then return end
local sfx = data.audio and data.audio.sfx
if not sfx then return end
local name = anim.sound
local pitch, tempo = anim.pitch or 0, anim.tempo or 0x80
local def = sfx[name]
if not sfxChannelGate(data, def) then return end
local src
-- a chip program synthesizes the modified variant on demand; a file def
-- can only reach for a pre-rendered one
if isChipDef(sfx[name]) then
if playPath(data, ("%s@%02x%02x"):format(name, pitch, tempo),
sfx[name], pitch, tempo) then
played("move", name)
end
return
end
if pitch ~= 0 or tempo ~= 0x80 then
if isChipDef(def) then
src = playPath(data, ("%s@%02x%02x"):format(name, pitch, tempo),
def, pitch, tempo)
else
local key = ("%s@%02x%02x"):format(name, pitch, tempo)
if sfx[key] then
if playPath(data, key, sfx[key]) then played("move", name) end
return
if (pitch ~= 0 or tempo ~= 0x80) and sfx[key] then
src = playPath(data, key, sfx[key])
else
src = playPath(data, name, def)
end
end
if playPath(data, name, sfx[name]) then played("move", name) end
if src then
played("move", name)
noteMoveSfx(data, def, src)
end
end
-- A derived cry ({ base = "RHYDON", pitch, length }) borrows another
@@ -304,12 +366,18 @@ end
-- returns the source (nil headless) so callers that block on the cry
-- like the original's PlayCry -> WaitForSoundToFinish can poll it
function Sound.playCry(data, species)
function Sound.playCry(data, species, pikaClip)
if not love.audio then return nil end
-- Yellow voices every Pikachu cry with the PCM clips (the chip cry is
-- never used for the species there); clip 1 is the everyday "Pika!"
-- never used for the species there). Which clip is a property of the
-- call site in the original -- every caller of PlayPikachuSoundClip sets
-- its own `ldpikacry e, PikachuCryN` -- so pikaClip carries that choice
-- in; it is ignored for every other species. Clip 1 is the LONG
-- title-screen "Pikachuuu" (engine/movie/title.asm:146), kept as the
-- default only for the sites that have not been given their own clip
-- yet; battle entrances pass 11/37 (#837).
if species == "PIKACHU" then
local src = Sound.playPikaCry(data, 1)
local src = Sound.playPikaCry(data, pikaClip or 1)
if src then return src end
end
local cries = data.audio and data.audio.cries
@@ -451,6 +519,7 @@ end
-- hot reload / jukebox A-B: drop one key's sources (its pitch-tempo
-- variants included) or all of them, so the next play re-resolves the def
function Sound.invalidate(name)
lastMoveSfx = nil -- its source is about to be dropped or stopped
local function evict(store, key)
local src = store[key]
if src then pcall(src.stop, src) end
+14 -2
View File
@@ -39,17 +39,29 @@ function StateStack:update(dt)
if top and top.update then top:update(dt) end
end
local function visibleByDefault() return true end
-- A mod may mirror a state elsewhere and hide only its main-screen render.
-- The state stays on the stack, so update and input ownership do not move.
function StateStack:renderVisible(state)
if not state then return false end
if not Runtime.wantsHook("screen.render_visible") then return true end
return Runtime.call("screen.render_visible", visibleByDefault, state) ~= false
end
-- index of the lowest state drawn this frame (highest opaque, else 1)
function StateStack:visibleBase()
for i = #self.states, 1, -1 do
if self.states[i].isOpaque then return i end
local state = self.states[i]
if self:renderVisible(state) and state.isOpaque then return i end
end
return 1
end
function StateStack:draw()
for i = self:visibleBase(), #self.states do
if self.states[i].draw then self.states[i]:draw() end
local state = self.states[i]
if self:renderVisible(state) and state.draw then state:draw() end
end
end
+64 -1
View File
@@ -85,6 +85,55 @@ local function clampScale(v)
return v
end
-- Haptic feedback (#806): a short vibration the instant a control takes a GB
-- button, the way every mobile emulator front-end does it -- the pad has no
-- edges under a thumb, so the buzz is the only confirmation a press landed.
-- Persisted as options.haptics (src/core/SaveData.lua defaultOptions), NOT
-- under options.touchControls: TouchControls:config() is the launcher
-- editor's save snapshot and only emits enabled + layouts, so a nested key
-- would be dropped on every editor save.
-- love.system.vibrate takes a duration and nothing else, so "intensity" is a
-- duration preset: Android runs the platform vibrator for exactly that long,
-- while iOS ignores the duration and fires the fixed system vibration, so
-- there the three levels all read as simply on.
TouchControls.HAPTICS = { "off", "light", "medium", "heavy" }
TouchControls.HAPTIC_DEFAULT = "light"
local HAPTIC_SECONDS = { off = 0, light = 0.012, medium = 0.025, heavy = 0.045 }
local HAPTIC_LABELS = {
off = "OFF", light = "LIGHT", medium = "MEDIUM", heavy = "HEAVY",
}
function TouchControls.normalizeHaptics(level)
if HAPTIC_SECONDS[level] then return level end
return TouchControls.HAPTIC_DEFAULT
end
function TouchControls.hapticLabel(level)
return HAPTIC_LABELS[TouchControls.normalizeHaptics(level)]
end
function TouchControls.cycleHaptics(level, dir)
local cur, idx = TouchControls.normalizeHaptics(level), 1
for i, m in ipairs(TouchControls.HAPTICS) do
if m == cur then idx = i break end
end
local n = #TouchControls.HAPTICS
return TouchControls.HAPTICS[(idx - 1 + (dir or 1)) % n + 1]
end
-- One pulse at the given level. Feature-guarded rather than platform-gated:
-- love.system.vibrate is a no-op on desktop and absent from the headless love
-- stubs, so the press path below stays identical everywhere and the tests
-- never reach a vibrator.
function TouchControls.buzz(level)
local secs = HAPTIC_SECONDS[TouchControls.normalizeHaptics(level)]
if not secs or secs <= 0 then return false end
if not (love and love.system and love.system.vibrate) then return false end
pcall(love.system.vibrate, secs)
return true
end
-- Copy a persisted positions table, dropping unknown / non-numeric entries.
-- Always a fresh table: two orientations seeded from the same pre-#633
-- layout must not alias, or dragging one would still move the other.
@@ -174,6 +223,10 @@ end
function TouchControls:init()
self.active = wantsOverlay()
self.enabled = true
-- vibration level for presses (#806); applyOptions overwrites it from
-- options.haptics, this is the value a harness that never applies options
-- runs with
self.haptics = TouchControls.HAPTIC_DEFAULT
-- per-orientation buckets (#633); self.positions / self.scale mirror the
-- one currently on screen so layout(), the editor and the tests keep a
-- single lookup
@@ -210,6 +263,9 @@ end
function TouchControls:applyOptions(opts)
local cfg = TouchControls.normalizeConfig(opts and opts.touchControls)
self.enabled = cfg.enabled
-- haptics is a plain top-level option, not part of the layout config the
-- launcher editor round-trips through config() (#806)
self.haptics = TouchControls.normalizeHaptics(opts and opts.haptics)
self.layouts = cfg.layouts
self.layoutW, self.layoutH = nil, nil
self.layoutOx, self.layoutOy = nil, nil
@@ -401,7 +457,14 @@ end
local function pressBtn(self, btn)
local n = (self.held[btn] or 0) + 1
self.held[btn] = n
if n == 1 then Input:overlayPressed(btn) end
-- Buzz only on the 0 -> 1 edge, the same edge that presses the GB button:
-- a second finger landing on a button that is already held, and a d-pad
-- finger resting inside one direction, must not retrigger it. Sliding the
-- d-pad to a new direction does, which is the point (#806).
if n == 1 then
Input:overlayPressed(btn)
TouchControls.buzz(self.haptics)
end
end
local function releaseBtn(self, btn)