mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-19 12:15:31 +02:00
Merge pull request #574 from andrewqsantos/feat/switch-nx
Nintendo Switch / love-nx support (#531)
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user