Merge pull request #574 from andrewqsantos/feat/switch-nx

Nintendo Switch / love-nx support (#531)
This commit is contained in:
bryanthaboi
2026-08-03 17:08:54 -04:00
committed by GitHub
87 changed files with 8915 additions and 276 deletions
+13
View File
@@ -100,14 +100,27 @@ end
-- play so a hot-reloaded dataset (or a mod's audio) always reaches the worker
local function slimAudio(data)
local audio = data.audio or {}
-- NX-only: resolve the versioned cache prefix on the main thread and hand
-- it to the worker, which runs in a fresh Lua state without GameVersion.
local programPrefix
if require("src.core.Platform").isNX() then
local prefix = require("src.core.GameVersion").cachePrefix()
if prefix ~= "" then programPrefix = prefix end
end
return {
programFile = audio.programFile,
programPrefix = programPrefix,
bankOrder = audio.bankOrder,
waveBanks = audio.waveBanks,
noiseHeaders = audio.noiseHeaders,
}
end
-- test-only: expose slimAudio so the NX prefix hand-off is verifiable
function ChipAudio._slimAudioForTest(data)
return slimAudio(data)
end
-- If the worker died (a malformed def that errors mid-synth), fall back to the
-- synchronous path for the rest of the session instead of going silent.
local function workerAlive()
+17 -1
View File
@@ -123,7 +123,18 @@ local function loadBanks(data)
if cachedProgramFile == audio.programFile and cachedBanks then
return cachedBanks
end
local raw, readError = love.filesystem.read(audio.programFile)
local raw, readError
-- The chip worker runs in a separate Lua state without the NX overlay;
-- ChipAudio hands it the versioned cache prefix explicitly. On the main
-- thread the NX overlay (or desktop mountVersion) makes the plain read
-- resolve, so no platform branching belongs here.
local prefix = audio.programPrefix
if prefix and prefix ~= "" then
raw, readError = love.filesystem.read(prefix .. audio.programFile)
end
if not raw then
raw, readError = love.filesystem.read(audio.programFile)
end
if not raw then error("could not read sound programs: " .. tostring(readError)) end
local banks = {}
for index, bank in ipairs(audio.bankOrder) do
@@ -140,6 +151,11 @@ function ChipSynth.invalidateBanks()
cachedProgramFile, cachedBanks = nil, nil
end
-- test-only: exercise loadBanks without building a full engine
function ChipSynth._loadBanksForTest(data)
return loadBanks(data)
end
-- A def-local program (ChipAsm output) is mounted as pseudo-bank 0 next to
-- the ROM banks, so the 0x4000-window byte reader and every call/loop
-- target work unchanged. The ROM's own cached bank table is never touched
+14 -1
View File
@@ -203,7 +203,20 @@ local function loadModule(dir, name)
if not chunk then return false, err end
return pcall(chunk)
end
return pcall(require, "data.generated." .. name)
local ok, mod = pcall(require, "data.generated." .. name)
if ok then return true, mod end
-- Fused PhysFS / Blue|Yellow prefix: load bytes from the active version's
-- cache explicitly when require cannot see the mounted tree.
local CacheFs = require("src.import.CacheFs")
local GameVersion = require("src.core.GameVersion")
local path = "data/generated/" .. name .. ".lua"
local bytes = CacheFs.readActive(path)
if type(bytes) == "string" then
local chunk, err = loadstring(bytes, "@" .. GameVersion.cachePrefix() .. path)
if not chunk then return false, err or mod end
return pcall(chunk)
end
return false, mod
end
function Data:load()
+67 -8
View File
@@ -9,6 +9,7 @@ local Renderer = require("src.render.Renderer")
local SaveData = require("src.core.SaveData")
local StateStack = require("src.core.StateStack")
local TouchControls = require("src.core.TouchControls")
local GamepadMap = require("src.core.GamepadMap")
local ModLoader = require("src.mods.Loader")
local ModRuntime = require("src.mods.Runtime")
local Screens = require("src.ui.Screens")
@@ -656,14 +657,26 @@ function Game:gamepadpressed(joystick, button)
-- a controller is being used: the touch overlay steps aside until the
-- next screen touch (mobile only; a no-op elsewhere)
TouchControls:noteGamepad()
-- Select held? Needed both to suppress shoulder speed hotkeys (Select+L
-- is a display chord on NX) and for the chord path below.
local selectHeld = Input:isDown("select")
if not selectHeld and joystick and joystick.isGamepadDown then
local ok, down = pcall(function()
return joystick:isGamepadDown("back")
end)
selectHeld = ok and down == true
end
-- shoulder buttons cycle GAME SPEED (R2/rightshoulder = faster,
-- L2/leftshoulder = slower; same as keyboard hotkey 1)
if button == "rightshoulder" then
self:_cycleSpeed(1)
return
elseif button == "leftshoulder" then
self:_cycleSpeed(-1)
return
-- L2/leftshoulder = slower; same as keyboard hotkey 1). Skip while
-- Select is held so Select+L can reach displayChordDigit ("7").
if not selectHeld then
if button == "rightshoulder" then
self:_cycleSpeed(1)
return
elseif button == "leftshoulder" then
self:_cycleSpeed(-1)
return
end
end
-- BindingsMenu's pad capture rides the same top-state routing as keys
local top = self.stack and self.stack:top()
@@ -671,6 +684,16 @@ function Game:gamepadpressed(joystick, button)
top:onGamepadPressed(button)
return
end
-- Select+face display chords → same digit path as Game:keypressed
-- (COLORS/TILT/pipelines). Intercept before Input so face does not
-- also fire GB A/B. Dual-path: raw already ignored when isGamepad().
if selectHeld then
local digit = GamepadMap.displayChordDigit(button)
if digit then
self:keypressed(digit)
return
end
end
Input:gamepadpressed(joystick, button)
end
@@ -754,15 +777,51 @@ function Game:focus(f)
end
function Game:visible(v)
if v then
self:onResume()
else
Input:reset()
TouchControls:reset()
end
end
function Game:onResume()
Input:reset()
TouchControls:reset()
-- Chip music may survive NX suspend as a duplicate stream; stop it and let
-- the active screen re-cue on the next frame (hardware audio check: T19).
-- Desktop/mobile window-visible flips must not kill overworld music.
if require("src.core.Platform").isNX() then
require("src.core.ChipAudio").stopMusic()
end
local SwitchDiagnostics = require("src.debug.SwitchDiagnostics")
if SwitchDiagnostics.isEnabled() then
SwitchDiagnostics.onEvent("lifecycle", { event = "resume" })
end
end
function Game:recoverInput(event, joystick)
Input:reset()
TouchControls:reset()
local SwitchDiagnostics = require("src.debug.SwitchDiagnostics")
if SwitchDiagnostics.isEnabled() then
if joystick then
SwitchDiagnostics.onJoystickEvent(event, joystick)
else
SwitchDiagnostics.onEvent("lifecycle", { event = event })
end
end
end
function Game:joystickadded(joystick)
self:recoverInput("joystickadded", joystick)
end
-- A disconnected/dropped controller can't send the button-up for whatever
-- it was holding, so drop all input state rather than try to guess which
-- flags it owned.
function Game:joystickremoved(joystick)
Input:reset()
self:recoverInput("joystickremoved", joystick)
TouchControls:joystickremoved()
end
+125
View File
@@ -0,0 +1,125 @@
-- Shared gamepad + raw joystick button tables for launcher and gameplay.
-- Hardware-measured NX overrides live in NX_* tables (see docs/switch-development.md).
local GamepadMap = {}
-- LÖVE SDL game-controller mapping (D-pad / face / menu) — desktop/mobile.
GamepadMap.DEFAULT_GAMEPAD_BINDINGS = {
dpup = "up", dpdown = "down", dpleft = "left", dpright = "right",
a = "a", b = "b",
start = "start", back = "select",
}
-- Switch: LÖVE/SDL labels south as "a" and east as "b", but Nintendo UX is
-- physical A (east) = confirm (GB A), physical B (south) = cancel (GB B).
GamepadMap.NX_GAMEPAD_BINDINGS = {
dpup = "up", dpdown = "down", dpleft = "left", dpright = "right",
a = "b", -- SDL south = Nintendo B → GB B
b = "a", -- SDL east = Nintendo A → GB A
start = "start", back = "select",
}
-- Generic SDL joysticks without a game-controller DB entry (Linux handhelds).
-- Desktop XInput order only: raw numbering is per-driver, and SDL's iOS/MFi
-- driver packs only the buttons a pad reports, which slides the D-pad onto
-- 7..10 (#620). These are defaults; Input:applyBindings layers "joyN" pad
-- rebinds over them (#632). Only sticks SDL does NOT recognize as gamepads
-- are served from this table (see GamepadMap.ignoreRawForJoystick).
GamepadMap.RAW_BUTTON_BINDINGS = {
[1] = "a", [2] = "b",
[7] = "select", [8] = "start", [9] = "select", [10] = "start",
}
-- Switch OLED raw indices (1-based). Only when NOT isGamepad() — love-nx
-- also emits gamepadpressed; dual-path face presses break NamingScreen.
-- #1 = Nintendo B, #2 = Nintendo A (probe); Y/X left unmapped for naming.
GamepadMap.NX_RAW_BUTTON_BINDINGS = {
[1] = "b", [2] = "a",
[9] = "select", [10] = "start",
}
-- Raw index -> gamepad button *name* for RomImporter (then NX face swap applies).
GamepadMap.RAW_TO_GAMEPAD_BUTTON = {
[1] = "a", [2] = "b",
[7] = "back", [8] = "start", [9] = "back", [10] = "start",
}
GamepadMap.NX_RAW_TO_GAMEPAD_BUTTON = {
[1] = "a", [2] = "b", -- SDL south/east names; NX_GAMEPAD_BINDINGS swaps to GB
[9] = "back", [10] = "start",
}
-- Test hook: force NX tables without stubbing love.
GamepadMap._forceNXForTests = false
function GamepadMap._setForceNXForTests(v)
GamepadMap._forceNXForTests = not not v
end
local function nxActive()
if GamepadMap._forceNXForTests then return true end
if love and love._os == "NX" then return true end
if love and love.system and love.system.getOS() == "NX" then return true end
return false
end
function GamepadMap.gamepadBindings()
if nxActive() then return GamepadMap.NX_GAMEPAD_BINDINGS end
return GamepadMap.DEFAULT_GAMEPAD_BINDINGS
end
-- Whole raw-index table for Input:applyBindings joyBindings seeding (#632).
function GamepadMap.rawBindings()
if nxActive() then return GamepadMap.NX_RAW_BUTTON_BINDINGS end
return GamepadMap.RAW_BUTTON_BINDINGS
end
function GamepadMap.mapGamepadButton(button)
return GamepadMap.gamepadBindings()[button]
end
-- Select+face display chords (docs / Nintendo UX):
-- Select+A → "2" (COLORS), Select+B → "3" (TILT),
-- Select+Y → "5", Select+X → "6", Select+L (leftshoulder) → "7".
-- For a/b: resolve through mapGamepadButton then GB a→"2", b→"3" so NX
-- Nintendo physical A/B match the docs despite SDL face-label swap.
-- Caller (Game:gamepadpressed) must require Select held; this is map-only.
function GamepadMap.displayChordDigit(gamepadButton)
if gamepadButton == "y" then return "5" end
if gamepadButton == "x" then return "6" end
if gamepadButton == "leftshoulder" then return "7" end
if gamepadButton == "a" or gamepadButton == "b" then
local gb = GamepadMap.mapGamepadButton(gamepadButton)
if gb == "a" then return "2" end
if gb == "b" then return "3" end
end
return nil
end
-- love-nx / SDL: when isGamepad(), face+menu already arrive via gamepad*.
-- Applying joystickpressed raw on top double-fires GB A/B in one frame.
function GamepadMap.ignoreRawForJoystick(joystick)
if not joystick then return false end
local ok, isPad = pcall(function()
return joystick.isGamepad and joystick:isGamepad()
end)
return ok and isPad == true
end
function GamepadMap.mapRawButton(index)
if nxActive() then
local nx = GamepadMap.NX_RAW_BUTTON_BINDINGS[index]
if nx then return nx end
end
return GamepadMap.RAW_BUTTON_BINDINGS[index]
end
function GamepadMap.mapRawToGamepadButton(index)
if nxActive() then
local nx = GamepadMap.NX_RAW_TO_GAMEPAD_BUTTON[index]
if nx then return nx end
end
return GamepadMap.RAW_TO_GAMEPAD_BUTTON[index]
end
return GamepadMap
+18 -34
View File
@@ -1,6 +1,8 @@
-- Input abstraction: maps keyboard to Game Boy buttons.
-- `down` = held this frame; `pressed` = edge, consumed per fixed step.
local GamepadMap = require("src.core.GamepadMap")
local Input = {}
local DEFAULT_BINDINGS = {
@@ -22,37 +24,14 @@ local DEFAULT_BINDINGS = {
-- keys that map to "start" but also to "a" would conflict; keep Enter = a,
-- Escape = start for desktop friendliness.
-- LÖVE's standard gamepad mapping (SDL game controller DB), consistent
-- across Xbox/PlayStation/generic controllers on desktop and mobile. Some
-- third-party pads report their own SDL mapping for a given physical
-- button (e.g. Select/Back/View on off-brand XInput pads), which is what
-- src/ui/BindingsMenu.lua's rebinding is for -- see applyBindings below.
local DEFAULT_GAMEPAD_BINDINGS = {
dpup = "up", dpdown = "down", dpleft = "left", dpright = "right",
a = "a", b = "b",
start = "start", back = "select",
}
-- left-stick deadzones: press past STICK_ON, release once back under
-- STICK_OFF. The gap (hysteresis) stops the direction from flickering
-- while the stick sits near the threshold.
local STICK_ON = 0.5
local STICK_OFF = 0.3
-- Generic SDL joysticks expose the left stick as the first two numbered
-- axes and the D-pad as a hat. This is common on Linux handhelds whose
-- controller has no game-controller database entry. The indices below are
-- the desktop XInput order and are only meaningful for such pads: raw
-- numbering is per-driver, and SDL's iOS/MFi driver packs only the buttons
-- a pad actually reports, which slides the D-pad down onto 7..10 (#620).
-- These are the raw DEFAULTS only: applyBindings layers the player's
-- "joyN" pad bindings over them (#632), and only a stick SDL does NOT
-- recognize as a gamepad is ever served out of this table -- see
-- joystickpressed below.
local RAW_BUTTON_BINDINGS = {
[1] = "a", [2] = "b",
[7] = "select", [8] = "start", [9] = "select", [10] = "start",
}
-- Raw joystick defaults + NX overrides live in src/core/GamepadMap.lua
-- (see RAW_BUTTON_BINDINGS / NX_RAW_BUTTON_BINDINGS and #620 / #632).
local HAT_DIRECTIONS = {
u = { "up" }, d = { "down" }, l = { "left" }, r = { "right" },
@@ -75,8 +54,15 @@ end
function Input:applyBindings(overlay)
local keys, pads, joys = {}, {}, {}
for key, action in pairs(DEFAULT_BINDINGS) do keys[key] = action end
for button, action in pairs(DEFAULT_GAMEPAD_BINDINGS) do pads[button] = action end
for index, action in pairs(RAW_BUTTON_BINDINGS) do joys[index] = action end
for button, action in pairs(GamepadMap.gamepadBindings()) do
pads[button] = action
end
-- Seed raw defaults from GamepadMap (desktop XInput order or NX OLED
-- indices) so joyN rebinds (#632) and dual-path guards (#620) share one
-- table with the Switch face-label remap.
for index, action in pairs(GamepadMap.rawBindings()) do
joys[index] = action
end
for actionId, binding in pairs(overlay or {}) do
if type(binding) == "table" then
if binding.key then keys[binding.key] = actionId end
@@ -225,18 +211,16 @@ end
-- alone; the raw path exists for sticks with no game-controller-database
-- entry. A nil joystick is a raw stick: that is how
-- tests/input_hold_test.lua and the drivers drive this path.
local function isRawStick(joystick)
return not (joystick and joystick.isGamepad and joystick:isGamepad())
end
-- Gate: GamepadMap.ignoreRawForJoystick (pcall-safe isGamepad check).
function Input:joystickpressed(joystick, button)
if not isRawStick(joystick) then return end
if GamepadMap.ignoreRawForJoystick(joystick) then return end
local btn = self.joyBindings[button]
if btn then press(self, btn, "joy:" .. button) end
end
function Input:joystickreleased(joystick, button)
if not isRawStick(joystick) then return end
if GamepadMap.ignoreRawForJoystick(joystick) then return end
local btn = self.joyBindings[button]
if btn then release(self, btn, "joy:" .. button) end
end
@@ -277,7 +261,7 @@ function Input:gamepadaxis(joystick, axis, value)
end
function Input:joystickaxis(joystick, axis, value)
if not isRawStick(joystick) then return end
if GamepadMap.ignoreRawForJoystick(joystick) then return end
if axis == 1 then
self:gamepadaxis(joystick, "leftx", value)
elseif axis == 2 then
@@ -290,7 +274,7 @@ end
-- gamepad map, so letting the hat answer too would re-assert the factory
-- directions on top of a direction rebind.
function Input:joystickhat(joystick, hat, direction)
if not isRawStick(joystick) then return end
if GamepadMap.ignoreRawForJoystick(joystick) then return end
local source = "hat:" .. hat
for _, btn in ipairs(self.hatDirs[hat] or {}) do
release(self, btn, source)
+107
View File
@@ -0,0 +1,107 @@
-- NX-only asset overlay: fused love-nx cannot reliably mount
-- blue|yellow/assets/generated onto the un-prefixed assets/generated, so
-- instead of teaching every call site about versioned caches, this module
-- wraps EVERY read-side love entry point that accepts a filesystem path
-- once at boot: any string path under assets/generated/ that does not
-- resolve falls back to the active version's prefixed copy
-- (yellow|blue/assets/generated/...). Covering the whole read surface --
-- not just the loaders we happened to need -- is what keeps future states
-- and mods inside the fallback without anyone updating this file.
--
-- main.lua installs it only when Platform.isNX(); desktop/Android/iOS never
-- install it, so their mountVersion overlay stays the single mechanism and
-- their loaders keep stock behavior. Write-side functions (write, remove,
-- createDirectory, mount, ...) are deliberately NOT wrapped: the importer
-- must keep targeting the versioned tree explicitly.
--
-- Two intentional exceptions stay outside this module:
-- * the chip-audio worker (src/core/chip_worker.lua) is a separate Lua
-- state without these wrappers; ChipAudio.slimAudio hands it the prefix
-- explicitly as audio.programPrefix.
-- * data/generated module loads go through CacheFs.readActive, which
-- already implements the same fallback for require bytes.
local GameVersion = require("src.core.GameVersion")
local GENERATED = "assets/generated/"
local NxAssetOverlay = {}
local originals -- raw love functions, non-nil while installed
-- Resolve `path` to the versioned copy when the un-prefixed file is missing
-- and the active version (Blue/Yellow) carries it. Returns nil when the
-- caller's path should be used untouched (non-generated path, Red, the real
-- file exists, or no versioned copy).
local function versioned(path)
if type(path) ~= "string" then return nil end
if path:sub(1, #GENERATED) ~= GENERATED then return nil end
local prefix = GameVersion.cachePrefix()
if prefix == "" then return nil end
if originals.getInfo(path) then return nil end
local candidate = prefix .. path
if originals.getInfo(candidate) then return candidate end
return nil
end
local function wrapLoader(fn)
return function(path, ...)
local alt = versioned(path)
if alt then return fn(alt, ...) end
return fn(path, ...)
end
end
-- Every read-side love function that can take an assets/generated path.
-- getInfo is wrapped separately (it must return the versioned file's info,
-- not just forward a rewritten argument list).
local WRAP_SPEC = {
{ "filesystem", "read" },
{ "filesystem", "load" },
{ "filesystem", "lines" },
{ "filesystem", "newFileData" },
{ "graphics", "newImage" },
{ "graphics", "newFont" },
{ "image", "newImageData" },
{ "audio", "newSource" },
{ "sound", "newSoundData" },
{ "font", "newFontData" },
}
function NxAssetOverlay.isInstalled()
return originals ~= nil
end
function NxAssetOverlay.install()
if originals then return end
if not (love and love.filesystem) then return end
originals = {}
for _, spec in ipairs(WRAP_SPEC) do
local ns, name = spec[1], spec[2]
local fn = love[ns] and love[ns][name]
if fn then
originals[ns .. "." .. name] = fn
love[ns][name] = wrapLoader(fn)
end
end
originals.getInfo = love.filesystem.getInfo
love.filesystem.getInfo = function(path, ...)
local alt = versioned(path)
if alt then return originals.getInfo(alt, ...) end
return originals.getInfo(path, ...)
end
end
-- Tests restore the stock loaders between cases; the game never uninstalls.
function NxAssetOverlay.uninstall()
if not originals then return end
for _, spec in ipairs(WRAP_SPEC) do
local ns, name = spec[1], spec[2]
local key = ns .. "." .. name
if originals[key] then love[ns][name] = originals[key] end
end
love.filesystem.getInfo = originals.getInfo
originals = nil
end
return NxAssetOverlay
+105
View File
@@ -0,0 +1,105 @@
-- Switch-only display size: handheld 1280x720, docked (TV) 1920x1080.
-- love-nx's SDL backend can auto-resize on dock/undock when the window is
-- resizable; this module also syncs on boot and when the operation mode
-- changes so a docked launch is not stuck at the conf.lua 720p hint.
--
-- Important: only call love.window.setMode when width/height must change.
-- Re-applying every frame (e.g. to "fix" fullscreen/resizable flags that
-- love-nx reports differently) recreates the EGL surface and flickers the launcher.
local Platform = require("src.core.Platform")
local NxDisplay = {}
NxDisplay.HANDHELD_W, NxDisplay.HANDHELD_H = 1280, 720
NxDisplay.DOCKED_W, NxDisplay.DOCKED_H = 1920, 1080
-- AppletOperationMode from libnx: Handheld = 0, Console (docked) = 1.
local MODE_HANDHELD = 0
local MODE_CONSOLE = 1
-- Test hooks (nil = use live Platform / FFI / love.window).
NxDisplay._forceNXForTests = nil
NxDisplay._operationModeForTests = nil
local ffiOk, ffiC
local function ensureFfi()
if ffiOk ~= nil then return ffiOk end
ffiOk = false
local ok, ffi = pcall(require, "ffi")
if not ok or not ffi then return false end
-- Redefinition is fine across hot reload / tests; we only need the symbol.
pcall(ffi.cdef, [[
unsigned char appletGetOperationMode(void);
]])
local probeOk = pcall(function()
return ffi.C.appletGetOperationMode
end)
if not probeOk then return false end
ffiC = ffi.C
ffiOk = true
return true
end
local function isNX()
if NxDisplay._forceNXForTests ~= nil then
return not not NxDisplay._forceNXForTests
end
return Platform.isNX()
end
-- Returns AppletOperationMode or nil when unavailable.
function NxDisplay.operationMode()
if NxDisplay._operationModeForTests ~= nil then
return NxDisplay._operationModeForTests
end
if not ensureFfi() or not ffiC then return nil end
local ok, mode = pcall(function()
return tonumber(ffiC.appletGetOperationMode())
end)
if not ok then return nil end
return mode
end
-- Map operation mode → framebuffer size.
-- Unknown / nil → nil,nil (do not fight SDL or force a wrong size).
function NxDisplay.desiredSize(mode)
if mode == nil then mode = NxDisplay.operationMode() end
if mode == MODE_CONSOLE then
return NxDisplay.DOCKED_W, NxDisplay.DOCKED_H
end
if mode == MODE_HANDHELD then
return NxDisplay.HANDHELD_W, NxDisplay.HANDHELD_H
end
return nil
end
-- Apply handheld/dock size when on NX and the window size differs.
-- Never setMode just to tweak flags — that flickers on love-nx.
-- Returns true when setMode ran.
function NxDisplay.sync()
if not isNX() then return false end
if not (love and love.window and love.window.getMode and love.window.setMode) then
return false
end
local wantW, wantH = NxDisplay.desiredSize()
if not wantW or not wantH then return false end
local curW, curH, flags = love.window.getMode()
if curW == wantW and curH == wantH then
return false
end
flags = flags or {}
flags.fullscreen = false
flags.resizable = true
love.window.setMode(wantW, wantH, flags)
return true
end
function NxDisplay._resetForTests()
NxDisplay._forceNXForTests = nil
NxDisplay._operationModeForTests = nil
ffiOk, ffiC = nil, nil
end
return NxDisplay
+54
View File
@@ -0,0 +1,54 @@
-- Platform capability detection for NX / mobile / desktop.
local Platform = {}
local cached
local function compute()
local osName = (love and love.system and love.system.getOS and love.system.getOS())
or "Unknown"
local nx = osName == "NX"
local mobile = osName == "Android" or osName == "iOS"
local nativePicker = love and love.system
and type(love.system.pickFile) == "function"
return {
os = osName,
nx = nx,
mobile = mobile,
console = nx,
hasNativePicker = nativePicker,
canSpawnProcess = osName == "OS X" or osName == "Windows" or osName == "Linux",
romImportMode = nx and "save-directory"
or (nativePicker and "native-picker")
or "desktop",
networkValidated = not nx,
}
end
function Platform.detect()
if not cached then cached = compute() end
return cached
end
function Platform.isNX()
return Platform.detect().nx
end
function Platform.romImportMode()
return Platform.detect().romImportMode
end
function Platform.canSpawnProcess()
return Platform.detect().canSpawnProcess
end
function Platform.networkValidated()
return Platform.detect().networkValidated
end
-- Tests may swap love.system between cases.
function Platform._resetForTests()
cached = nil
end
return Platform
+25 -11
View File
@@ -92,19 +92,30 @@ end
-- Chip SFX and cries are already stereo at the source (ChipSynth
-- renderEffectData); this covers file defs, i.e. Yellow's 8-bit mono PCM
-- Pikachu clips (RomExtractor extractPikachuCries) and mod-supplied wav/ogg
-- SFX. Every step is guarded: a headless love stub without love.sound, or a
-- decoder that will not hand back SoundData, keeps the original Source.
-- SFX.
--
-- Decode the FILE (not Source:getChannelCount): love-nx/audren has reported
-- channel counts that skip this widen silently, and preserving 8-bit depth
-- into a stereo buffer also sounds wrong on that backend. Always emit
-- 16-bit stereo like ChipSynth. Failure keeps the original Source and logs.
local function widenMono(source, file)
if not (source and love.sound and love.sound.newSoundData) then
if type(file) ~= "string" then return source end
if not (love.sound and love.sound.newSoundData and love.audio
and love.audio.newSource) then
return source
end
-- Quiet skip when the path is unreadable (headless stub SFX keys, missing
-- files). On NX, overlay-wrapped getInfo makes the yellow|blue copy visible
-- at the bare assets/generated path so the widen still runs.
local fs = love.filesystem
if not (fs and fs.getInfo and fs.getInfo(file)) then
return source
end
local ok, channels = pcall(function() return source:getChannelCount() end)
if not ok or channels ~= 1 then return source end
local built, widened = pcall(function()
local mono = love.sound.newSoundData(file)
if mono:getChannelCount() ~= 1 then return source end
local frames = mono:getSampleCount()
local stereo = love.sound.newSoundData(frames, mono:getSampleRate(),
mono:getBitDepth(), 2)
local stereo = love.sound.newSoundData(frames, mono:getSampleRate(), 16, 2)
for index = 0, frames - 1 do
local value = mono:getSample(index)
stereo:setSample(index, 1, value)
@@ -112,7 +123,10 @@ local function widenMono(source, file)
end
return love.audio.newSource(stereo, "static")
end)
if built and widened then return widened end
if built and widened and widened ~= source then return widened end
if not built then
Logger.warn("sound: widenMono failed for %s: %s", file, tostring(widened))
end
return source
end
@@ -274,9 +288,9 @@ function Sound.playPikaCry(data, n)
cache[key] = false
return nil
end
-- the importer writes these clips as 8-bit mono (RomExtractor
-- extractPikachuCries), so they need the same widening as the chip
-- effects to stay off a multi-output device's surround channels (#626)
-- importer historically wrote these as 8-bit mono (RomExtractor
-- extractPikachuCries); widenMono re-decodes to 16-bit stereo so they
-- stay off surround outputs (#626). Fresh extracts are already stereo.
s = widenMono(s, path)
s:setVolume(volumeFor(key))
cache[key] = s