Merge branch 'dev' into feat/switch-nx

Bring feat/switch-nx up to date with origin/dev (72 commits). Resolve
Input/RomImporter conflicts by keeping GamepadMap (NX face remap + dual-path
gate) while adopting upstream joyBindings rebinds (#632) and Enable-all mods
(#647). Gate shoulder GAME SPEED hotkeys when Select is held so Select+L
display chords still work.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Andrew Quenehen
2026-08-02 21:36:56 -03:00
154 changed files with 11958 additions and 742 deletions
+8 -2
View File
@@ -418,16 +418,22 @@ function ChipAudio.newCry(data, species, resolved)
})
end
-- Two channels for the same reason ChipSynth.renderEffectData renders stereo:
-- a mono Source is spatialized by OpenAL at the listener position and spreads
-- over every output an interface has (#626). The siren itself is unchanged,
-- both channels carry the same sample.
function ChipAudio.newLowHealthAlarm()
local samples = math.floor(SAMPLE_RATE * 62 / 60)
local data = love.sound.newSoundData(samples, SAMPLE_RATE, 16, 1)
local data = love.sound.newSoundData(samples, SAMPLE_RATE, 16, 2)
local phase = 0
for index = 0, samples - 1 do
local frame = math.floor(index * 60 / SAMPLE_RATE) % 31
local register = frame < 11 and 0x750 or 0x6EE
local frequency = 131072 / (2048 - register)
phase = (phase + frequency / SAMPLE_RATE) % 1
data:setSample(index, (phase < 0.5 and 1 or -1) * 0.25)
local value = (phase < 0.5 and 1 or -1) * 0.25
data:setSample(index, 1, value)
data:setSample(index, 2, value)
end
return love.audio.newSource(data, "static")
end
+22 -5
View File
@@ -808,9 +808,22 @@ local function soundData(engine, samples, channels)
return result
end
-- Render a one-shot effect (SFX/cry) to a mono SoundData, or nil when it is
-- too short to be audible. The caller wraps it in a static love.audio.Source
-- (a playback concern, hence not done here).
-- Render a one-shot effect (SFX/cry) to a two-channel SoundData, or nil when
-- it is too short to be audible. The caller wraps it in a static
-- love.audio.Source (a playback concern, hence not done here).
--
-- The synthesis is mono (one summed value per frame, unlike the music path's
-- sampleStereo), but the buffer is written stereo on purpose: OpenAL only
-- spatializes 1-channel Sources, and a Source left at the default (0,0,0)
-- position, exactly where the listener sits, is rendered as an ambient sound
-- spread over EVERY output channel the device exposes at gains that differ
-- from the front pair. On an interface with more than two outputs that put
-- the SFX on outputs 5+6 as well, while the 2-channel music source
-- (ChipAudio.playMusic) stayed on 1+2 (#626). Multi-channel buffers skip
-- spatialization entirely and map onto the front pair, so duplicating the
-- sample costs one buffer's memory and makes effects route exactly like
-- music. Deliberately not sampleStereo: that honors the NR51 panning byte
-- and would newly hard-pan any effect whose header issues command 0xEE.
local function renderEffectData(data, header, options)
if not header then return nil end
options = options or {}
@@ -825,8 +838,12 @@ local function renderEffectData(data, header, options)
values[count] = engine:sample()
end
if count < math.floor(SAMPLE_RATE / 100) then return nil end
local result = love.sound.newSoundData(count, SAMPLE_RATE, 16, 1)
for index = 1, count do result:setSample(index - 1, values[index]) end
local result = love.sound.newSoundData(count, SAMPLE_RATE, 16, 2)
for index = 1, count do
local value = values[index]
result:setSample(index - 1, 1, value)
result:setSample(index - 1, 2, value)
end
return result
end
+7
View File
@@ -77,6 +77,13 @@ end
function Data:applyVersionedFieldData()
if require("src.core.GameVersion").isYellow() then
self.field.trades = copy(YELLOW_TRADES)
-- The old man's catch demo is a RATTATA in Yellow
-- (scripts/ViridianCity.asm ViridianCityOldManStartCatchTrainingScript
-- .SetupBattle: ld a, RATTATA / ld [wCurOpponent], a) but the Yellow
-- manifest inherited Red's WEEDLE field.oldManBattle (#617), so old
-- Yellow caches carry the wrong demo species too. The fixed import
-- manifest below stamps RATTATA for fresh imports.
self.field.oldManBattle = { species = "RATTATA", level = 5 }
end
end
+220
View File
@@ -0,0 +1,220 @@
-- Faithful resolution: lock the window to an exact integer multiple of the
-- Game Boy's 160x144 screen, 1X through 4X.
--
-- At any other window size the renderer picks the largest integer scale that
-- fits and letterboxes the remainder (Renderer:fitScale), so the game is
-- already crisp -- what it is not is *exact*: there are bars, and at a wide
-- window a lot of them. Locking the window to 160*N x 144*N removes the
-- letterbox entirely, so the surface is the Game Boy screen and nothing else.
--
-- Persisted as save.options.faithfulRes (0 = OFF). Applied from OptionsMenu
-- and on boot via Game:applyOptions. No-ops in headless stubs that lack
-- love.window.
--
-- MOBILE takes the other route to the same place. There is no window to
-- resize -- the window IS the screen, and it rotates -- so the lock caps the
-- RENDER scale instead: the renderer draws the Game Boy screen at exactly N
-- physical pixels per GB pixel and centres it, and the rest of the display
-- stays black. Same promise as the desktop lock (a GB pixel is exactly N
-- screen pixels, no more) reached by moving the picture rather than the
-- window. This used to return false on the first line, so the row sat in
-- OPTIONS on Android and iOS doing nothing at all.
--
-- Scale, not size, is also what makes rotation free: Renderer:fitScale runs
-- every frame off the live drawable size, so portrait and landscape both get
-- the same locked scale with the bars falling wherever the screen is longer.
local FaithfulRes = {}
FaithfulRes.WIDTH, FaithfulRes.HEIGHT = 160, 144
FaithfulRes.LEVELS = { 0, 1, 2, 3, 4 }
FaithfulRes.DEFAULT = 0
-- mobile only: the locked scale in physical pixels per GB pixel, 0 for OFF.
-- Renderer:fitScale reads it through FaithfulRes.scaleCap.
FaithfulRes.mobileScale = 0
-- conf.lua's floor for the resizable desktop window, restored when the lock
-- is released. 1X and 2X are BELOW it, so the lock has to lower the minimum
-- as well as set the size or LOVE clamps the window back up.
FaithfulRes.MIN_W, FaithfulRes.MIN_H = 480, 360
-- whether this module currently owns the window size
FaithfulRes.locked = false
-- The highest level this display can actually show.
--
-- On desktop it is 4: the levels are window sizes, and 4X is the ceiling the
-- feature shipped with. On mobile there is no window to size, so a fixed
-- 1..4 ladder is meaningless -- 4X is a quarter of a 1080p phone, and the
-- levels the panel could really use are not on the list at all. Derive it
-- from the screen instead, so a 1080x2400 phone offers up to 6X and the top
-- of the ladder is the biggest exact-pixel picture it can draw.
--
-- OFF (0) is untouched by any of this and keeps doing exactly what it always
-- did: the renderer fits and letterboxes as usual.
function FaithfulRes.maxLevel()
-- Mobile is ON or OFF. A ladder of absolute multiples is a desktop idea --
-- there it names a window size you can see. On a phone the same number
-- means a different fraction of every device, and every level below the top
-- is just a smaller picture for no reason. ON means one thing instead:
-- lock the viewport to the Game Boy's 10:9 and size it to this screen.
if FaithfulRes.isMobile() then return 1 end
return 4
end
-- the selectable ladder for this display: OFF, then 1X..maxLevel
function FaithfulRes.levels()
local out = { 0 }
for i = 1, FaithfulRes.maxLevel() do out[#out + 1] = i end
return out
end
function FaithfulRes.normalize(v)
v = math.floor(tonumber(v) or FaithfulRes.DEFAULT)
if v < 0 then return 0 end
local max = FaithfulRes.maxLevel()
if v > max then return max end
return v
end
function FaithfulRes.label(v)
v = FaithfulRes.normalize(v)
if v == 0 then return "OFF" end
-- mobile has one ON: the level is chosen from the display, not the player
if FaithfulRes.isMobile() then return "ON" end
return tostring(v) .. "X"
end
function FaithfulRes.cycle(v, dir)
local levels = FaithfulRes.levels()
local cur = 1
for i, level in ipairs(levels) do
if level == FaithfulRes.normalize(v) then cur = i break end
end
return levels[(cur - 1 + (dir or 1)) % #levels + 1]
end
function FaithfulRes.isMobile()
-- POKEPORT_FORCE_MOBILE=1: take the mobile branch on a desktop build, so the
-- scale lock can be seen and driven without a device. The window is still
-- resizable, which is the point -- drag it to a phone aspect, rotate it by
-- dragging the other way, and the lock has to hold through both. Only this
-- module reads isMobile, so the override cannot leak into anything else.
if os.getenv("POKEPORT_FORCE_MOBILE") == "1" then return true end
if not love or not love.system or not love.system.getOS then return false end
local osName = love.system.getOS()
return osName == "Android" or osName == "iOS"
end
-- Physical pixels per LOVE unit for the CURRENT window.
--
-- Deliberately NOT love.window.getDPIScale: that reports the display's
-- scaling factor even when the window is not high-DPI aware, and conf.lua
-- only sets t.window.highdpi on mobile. On a plain desktop window a unit IS
-- a pixel, so dividing by the display scale just shrinks the window -- at
-- 125% scaling a 2X request became 256x230 pixels, which Renderer:fitScale
-- floors to 1, and 4X became 512x461, which floors to 3. That is exactly
-- the "2X renders at 1X, 4X renders at 3X" this shipped with.
--
-- Measuring the ratio the window actually reports is correct in both worlds:
-- 1 on a plain desktop window, the real scale on a high-DPI one.
local function pixelsPerUnit()
local g = love and love.graphics
if not (g and g.getDimensions and g.getPixelDimensions) then return 1 end
local uw = tonumber((g.getDimensions()))
local pw = tonumber((g.getPixelDimensions()))
if not uw or not pw or uw <= 0 or pw <= 0 then return 1 end
return pw / uw
end
-- The window size in LOVE UNITS that puts 160*v x 144*v PHYSICAL pixels on
-- screen.
function FaithfulRes.size(v)
v = FaithfulRes.normalize(v)
if v == 0 then return nil end
local ratio = pixelsPerUnit()
return math.floor(FaithfulRes.WIDTH * v / ratio + 0.5),
math.floor(FaithfulRes.HEIGHT * v / ratio + 0.5)
end
-- Push the lock into the live window. Returns true when the window is
-- locked afterwards.
-- The largest WHOLE multiple of the Game Boy screen this display can hold.
-- Integer, never fractional: a GB pixel has to be the same number of screen
-- pixels in both axes or it is not pixel perfect, it is resampled.
--
-- The leftover is black bars, and on a tall phone there is a lot of it
-- vertically -- that is simply what a 10:9 screen looks like on a 9:20
-- display, and it is what an emulator shows too.
function FaithfulRes.deviceScale()
local g = love and love.graphics
if not (g and g.getPixelDimensions) then return 1 end
local pw, ph = g.getPixelDimensions()
if not pw or not ph or pw <= 0 or ph <= 0 then return 1 end
return math.max(1, math.floor(math.min(pw / FaithfulRes.WIDTH,
ph / FaithfulRes.HEIGHT)))
end
-- The scale the renderer must lock to, or nil for "fit the window as usual".
-- Only ever set on mobile: on desktop the window itself is the lock, so
-- fitScale already lands on N and this would be a second, redundant one.
--
-- Always the device maximum. Anything less is a smaller picture for no gain,
-- which is how the first cut ended up showing a postage stamp on a 1080p
-- phone.
function FaithfulRes.scaleCap()
if not FaithfulRes.locked then return nil end
if not FaithfulRes.isMobile() then return nil end
return FaithfulRes.deviceScale()
end
function FaithfulRes.apply(v)
v = FaithfulRes.normalize(v)
-- Mobile: lock the render scale instead of the window. The scale itself
-- comes from the display (deviceScale), not from v -- v only says whether
-- the lock is on. Nothing to restore on release: the renderer simply goes
-- back to filling the display.
if FaithfulRes.isMobile() then
FaithfulRes.locked = v > 0
FaithfulRes.mobileScale = FaithfulRes.locked and FaithfulRes.deviceScale() or 0
return FaithfulRes.locked
end
if not love or not love.window or not love.window.setMode
or not love.window.getMode then
return false
end
local curW, curH, flags = love.window.getMode()
flags = flags or {}
if v == 0 then
-- only touch the window if we were the one holding it: an OFF setting on
-- boot must not resize a window the player sized themselves
if not FaithfulRes.locked then return false end
flags.resizable = true
flags.minwidth, flags.minheight = FaithfulRes.MIN_W, FaithfulRes.MIN_H
love.window.setMode(curW, curH, flags)
FaithfulRes.locked = false
return false
end
local w, h = FaithfulRes.size(v)
-- An exact size and a desktop-fullscreen mode cannot both hold. The lock
-- is the more specific request, so it wins and drops fullscreen; VIDEO MODE
-- reads BORDERLESS until the player changes it, which then releases this.
flags.fullscreen = false
-- resizing by hand would silently break the lock, and nothing re-applies it
-- (there is no love.resize handler -- the renderer re-reads the size every
-- frame), so the window is fixed while locked rather than left draggable.
flags.resizable = false
flags.minwidth, flags.minheight = w, h
love.window.setMode(w, h, flags)
FaithfulRes.locked = true
return true
end
function FaithfulRes.applyOptions(opts)
return FaithfulRes.apply(opts and opts.faithfulRes)
end
return FaithfulRes
+29 -4
View File
@@ -7,9 +7,22 @@ local FixedStep = {}
FixedStep.STEP = 1 / 60
local MAX_ACCUM = 0.25 -- avoid spiral of death after a stall
-- Phase the accumulator is re-seeded with once an absorbed hitch frame has
-- been paid for. Half a step is the balanced point: a frame has to come in
-- ~8ms short before it drops a step and ~8ms long before it doubles one up.
-- Zero -- what this used to leave behind -- has no margin at all on the long
-- side, so the accumulator settles a hair BELOW one step and parks there, and
-- ordinary sub-millisecond vsync wobble then flips it between 0 and 2 steps
-- every few frames for the rest of the session. That is why pacing stayed
-- visibly broken after a route/city seam and nowhere else: crossConnection in
-- src/world/OverworldController.lua is the only caller of discardCatchup, and
-- warps go through Transition instead (issue #487).
local RESEED_PHASE = 0.5
function FixedStep:init(callback)
self.accum = 0
self.callback = callback
self.suppressCatchup = false
end
-- The anti-spiral clamp doubles as a steps-per-frame ceiling (0.25s = 15
@@ -20,6 +33,16 @@ end
FixedStep.maxAccum = MAX_ACCUM
function FixedStep:update(dt)
-- A hitch's oversized dt lands on the frame AFTER discardCatchup was
-- called (the hitch itself already ran inside the current step); absorb
-- that one frame as a single step instead of the normal accumulator so
-- the burst it would otherwise release doesn't play out as a slide.
if self.suppressCatchup then
self.suppressCatchup = false
self.accum = self.STEP * RESEED_PHASE
self.callback(self.STEP)
return
end
self.accum = math.min(self.accum + dt, self.maxAccum or MAX_ACCUM)
while self.accum >= self.STEP do
self.accum = self.accum - self.STEP
@@ -27,12 +50,14 @@ function FixedStep:update(dt)
end
end
-- Drop any pending catch-up steps. A hitch inside one logic step (map
-- seam setMap / song start) makes the next real-time dt huge; without this
-- the while-loop above would advance many walk frames before the next
-- draw, which looks like a slide with no leg animation (issue #93).
-- Drop any pending catch-up steps and arm the one-frame clamp above. A
-- hitch inside one logic step (map seam setMap / song start) makes the
-- next real-time dt huge; without this the while-loop above would advance
-- many walk frames before the next draw, which looks like a slide with no
-- leg animation (issue #93).
function FixedStep:discardCatchup()
self.accum = 0
self.suppressCatchup = true
end
return FixedStep
+191 -9
View File
@@ -266,6 +266,38 @@ end
-- exactly as the owning state computed it
local function sameZones(_, zones) return zones end
-- Dim alpha for a BATTLE BG "world" battle anywhere in the stack, or nil.
-- Same whole-stack rule as fillScaleInStack: a party menu or text box opened
-- during the battle must not drop the dim for a frame.
function Game.worldBgBattleDim(stack)
for i = #(stack and stack.states or {}), 1, -1 do
local state = stack.states[i]
if state and state.bgMode and state:bgMode() == "world" then
return state.BG_WORLD_DIM or 0.55
end
end
return nil
end
-- Does anything on the stack want the surface scaled to FILL the window
-- (aspect preserved, bars on the long axis) rather than sit at the fixed
-- integer scale?
--
-- Asked of the WHOLE stack, not just the top. For BATTLE SIZE "fill" that is
-- because the party menu, bag and text boxes a battle opens must not snap the
-- surface back to the fixed scale for a frame; the title screen and intro want
-- it unconditionally, since neither has a world behind it and neither has any
-- reason to sit in a small box in the middle of a large window.
function Game.fillScaleInStack(stack)
for i = #(stack and stack.states or {}), 1, -1 do
local state = stack.states[i]
if state and state.wantsFillScale and state:wantsFillScale() then
return true
end
end
return false
end
-- A wide battle owns the surface until it leaves the stack. The party,
-- bag, choice and text states it opens still draw their original 160px UI,
-- but the canvas must not snap to 160px between those states.
@@ -279,6 +311,58 @@ function Game.wideBattleInStack(stack)
return nil
end
-- Whether a state on the stack composes its own screen and so wants the
-- edge anchors held off (BattleState.holdsUIAnchors). Whole-stack, like
-- everything else here: the text box and YES/NO a battle puts up are states
-- of their own sitting above it, and they are exactly the elements that must
-- stay inside the battle's composition rather than dock to the window.
-- UI LAYOUT: is edge docking switched on? Only the explicit "dynamic" turns
-- it on, so a save written before the option existed -- and any caller with no
-- save at all, which is most of the headless suites -- gets CENTERED, the
-- behaviour the port shipped with.
function Game.dynamicUI(save)
local options = save and save.options
return options ~= nil and options.uiLayout == "dynamic"
end
function Game.uiAnchorsHeldInStack(stack)
for i = #(stack and stack.states or {}), 1, -1 do
local state = stack.states[i]
if state and state.holdsUIAnchors then return true end
end
return false
end
-- Where Game:draw starts drawing this frame. Normally the topmost opaque
-- state (StateStack:visibleBase) -- but BATTLE BG "world" composes the battle
-- over the LIVE map, and an opaque state pushed on top of it (the party menu,
-- the bag) becomes that base, cutting the overworld -- and with it the world
-- pass -- out of the frame entirely. The backdrop the battle established
-- then collapses to endFrame's flat black clear for as long as the menu is
-- up. So a world-bg battle keeps the frame starting from underneath itself
-- until it leaves the stack, the same hold uiFill and the dim already use.
--
-- Only the START of the draw moves. The clear stays keyed to the real
-- visibleBase, so the menu still gets its opaque canvas and draws exactly as
-- before; what changes is the window AROUND its letterbox, which keeps
-- showing the map instead of going black. Both menus fill their own
-- 160x144 field first, so nothing beneath them shows through it.
function Game.drawBaseInStack(stack, visibleBase)
local states = stack and stack.states or {}
for i = visibleBase - 1, 1, -1 do
local state = states[i]
if state and state.bgMode and state:bgMode() == "world" then
-- restart the search from under the battle: the highest opaque state at
-- or below it (the overworld), not the menu sitting over it
for j = i, 1, -1 do
if states[j].isOpaque then return j end
end
return 1
end
end
return visibleBase
end
-- Shift classic SGB zones to the centred UI. A full-width base zone extends
-- into both margins, keeping the canvas' paper color continuous; narrower
-- sprite and status zones move with the classic UI content.
@@ -304,6 +388,10 @@ function Game:draw()
-- white clear
local base = self.stack:visibleBase()
local worldBelow = self.stack.states[base] == self.overworld
-- a world-bg battle keeps the map drawing under whatever it opened, so the
-- world pass can run for a frame whose CLEAR is still an opaque menu's
local drawFrom = Game.drawBaseInStack(self.stack, base)
local worldDrawn = self.stack.states[drawFrom] == self.overworld
-- A wide battle holds its 304px surface through every menu or prompt it
-- opens. States that do not draw the wide battle composition are centred
-- in that surface below, so their classic coordinates and hit testing stay
@@ -320,8 +408,33 @@ function Game:draw()
else
Renderer:setUISize(Renderer.WIDTH, Renderer.HEIGHT)
end
-- BATTLE SIZE: scale the battle surface to the window instead of the
-- classic integer letterbox. Read from the whole stack, not just the top,
-- so a party menu or text box opened mid-battle keeps the same surface.
Renderer.uiFill = Game.fillScaleInStack(self.stack)
-- BATTLE BG "world": dim the overworld the battle is drawn over. Read off
-- the stack for the same reason as uiFill above -- a prompt opened during
-- the battle must not drop the dim for a frame.
Renderer.battleDim = Game.worldBgBattleDim(self.stack)
-- ...and for the same reason the UI's own scale has to know the world is
-- still the backdrop while an opaque menu covers it. Renderer:uiScale
-- steps the UI down with the survey zoom only while a world is behind it,
-- gated on this frame's world pass -- which the party menu and the bag end
-- by being opaque. Without this hold they lose the step-down and blit at
-- full fit scale over a battle drawn at the zoomed-out one.
Renderer.uiWorldHold = Renderer.battleDim ~= nil
-- ...and a battle keeps its dialogue box and YES/NO inside its own screen
-- instead of letting them dock to the window edge.
-- UI LAYOUT: CENTERED (the default) is a fixed letterbox -- every element
-- stays inside the 160x144 canvas and the UI does not follow the survey
-- zoom, so the screen furniture never moves or resizes under the player.
-- That is the composition the port shipped with. DYNAMIC opts into both
-- halves: the dialogue box docks to the window's bottom edge, the START
-- menu to its top right, and the whole UI steps down with the zoom.
Renderer.uiCentered = not Game.dynamicUI(self.save)
Renderer.uiAnchorHold = Game.uiAnchorsHeldInStack(self.stack)
Renderer:beginFrame(worldBelow)
for i = self.stack:visibleBase(), #self.stack.states do
for i = drawFrom, #self.stack.states do
local state = self.stack.states[i]
local wideState = state and state.isWideBattleLayout
and state:isWideBattleLayout()
@@ -358,7 +471,13 @@ function Game:draw()
if ModRuntime.wantsHook("render.zones") then
zones = ModRuntime.call("render.zones", sameZones, self, zones)
end
if worldBelow and self.overworld.sgbWorldZones then
-- Keyed to whether the map actually DREW, not to whether it is the clear's
-- base: an opaque menu over a world-bg battle still renders the world pass
-- (drawBaseInStack), and leaving worldZones nil there drops endFrame's
-- world blit onto the UI zone list instead -- the party menu's own HP-bar
-- palettes, in 160x144 space, smeared across a world-canvas-sized image.
-- That is the offset, red-for-green map behind the menu.
if worldDrawn and self.overworld.sgbWorldZones then
worldZones = self.overworld:sgbWorldZones()
end
local viewport = Renderer:endFrame(zones, worldZones)
@@ -391,6 +510,24 @@ function Game:wheelmoved(_, dy)
end
end
function Game:_cycleSpeed(dir)
if not (self.save and self.save.options) then return end
local busy
local ow = self.overworld
if ow then
local top = self.stack:top()
busy = ow.transitioning
or (top == ow and (
(ow.runner and ow.runner.isRunning and ow.runner:isRunning())
or (ow.scriptMoves and #ow.scriptMoves > 0)
or ow.engaging or ow.emote))
end
if busy then return end
local GameSpeed = require("src.core.GameSpeed")
self.save.options.speed = GameSpeed.cycle(self.save.options.speed, dir)
self:writeOptions()
end
function Game:keypressed(key)
if self.stack and self.stack:top() and self.stack:top().onKeyPressed then
self.stack:top():onKeyPressed(key)
@@ -428,6 +565,11 @@ function Game:keypressed(key)
elseif key == "=" then
self:zoomStep(1)
return
elseif key == "1" then
-- cycle GAME SPEED (0.25X → 200X, logic only; audio unaffected);
-- R2/L2 on gamepad do the same (see gamepadpressed)
self:_cycleSpeed(1)
return
elseif key == "2" then
-- cycle COLORS (GBC / OG / OG INV / GBC INV / CLASSIC); the pack change
-- forces Game.overworld:reloadMap, which rebuilds the live NPC array, so
@@ -508,6 +650,27 @@ 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 / VoxelMod) 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). 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()
if top and top.onGamepadPressed then
@@ -517,13 +680,6 @@ function Game:gamepadpressed(joystick, button)
-- 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().
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
if selectHeld then
local digit = GamepadMap.displayChordDigit(button)
if digit then
@@ -558,15 +714,38 @@ local function isAccelerometer(joystick)
return name ~= nil and name:lower():find("accelerometer", 1, true) ~= nil
end
-- BindingsMenu's raw-stick capture rides the same top-state routing as the
-- keyboard and gamepad paths (#632). Only a stick SDL does not recognize
-- as a gamepad reaches the capture: a recognized pad raises BOTH
-- joystickpressed and gamepadpressed for one press, and the joystick half
-- would otherwise beat its own gamepadpressed to the armed row and record
-- "JOY1" for a button the player can plainly see is A. Same predicate as
-- Input's, kept local here so Game never reaches into Input's internals.
local function isRawStick(joystick)
return not (joystick and joystick.isGamepad and joystick:isGamepad())
end
function Game:joystickpressed(joystick, button)
if isAccelerometer(joystick) then return end
TouchControls:noteGamepad()
local top = self.stack and self.stack:top()
if isRawStick(joystick) and top and top.onJoystickPressed then
top:onJoystickPressed(button)
return
end
Input:joystickpressed(joystick, button)
end
function Game:joystickreleased(joystick, button)
if isAccelerometer(joystick) then return end
-- same observe-after-Input contract as Game:keyreleased (#589): the
-- capture watches the release, it never owns it, so a held-state flag
-- Input saw go down before the capture armed cannot be stranded
Input:joystickreleased(joystick, button)
local top = self.stack and self.stack:top()
if isRawStick(joystick) and top and top.onJoystickReleased then
top:onJoystickReleased(button)
end
end
function Game:joystickaxis(joystick, axis, value)
@@ -712,6 +891,9 @@ function Game:applyOptions(opts)
-- returns true when a persisted GBC FX level was cleared on mobile
local gbcCleared = require("src.render.GBCFX").applyOptions(opts)
require("src.core.VideoMode").applyOptions(opts)
-- after VideoMode: a faithful-resolution lock is an exact window size, so
-- it has to be the last word on the window (it drops fullscreen to hold)
require("src.core.FaithfulRes").applyOptions(opts)
-- normalizes a nil/garbage cap to the 60 default, so old saves with no
-- fpsCap key pace at the standard rate (issue #88)
require("src.core.FrameCap").applyOptions(opts)
+1 -1
View File
@@ -18,7 +18,7 @@ local GameSpeed = {}
-- attempt is long enough that the iteration loop, not the engine, is the
-- bottleneck. Vsync caps how much a real frame can do, so past 10X the
-- multiplier is increasingly a ceiling rather than a rate.
GameSpeed.LEVELS = { 1, 2, 4, 10, 20, 30, 50, 75, 100,200 }
GameSpeed.LEVELS = { 1, 2, 3, 4, 10, 20, 30, 50, 75, 100, 200 }
GameSpeed.DEFAULT = 1
function GameSpeed.levelLabel(v)
+11
View File
@@ -20,6 +20,11 @@ GamepadMap.NX_GAMEPAD_BINDINGS = {
}
-- 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",
@@ -63,6 +68,12 @@ function GamepadMap.gamepadBindings()
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
+166
View File
@@ -13,6 +13,55 @@ function HostShell.envPrefix()
return ""
end
-- Windows: every host tool we shell out to (curl for the update and mod-index
-- fetches, the PowerShell ROM picker, the update downloader's `start /b`) is
-- spawned through io.popen / os.execute, which run it under cmd.exe. A
-- GUI-subsystem process owns no console, so each of those children allocates
-- its own -- one console window flashing per call, several stacking up during
-- a mod install or an update (#606). #74 fixed the same storm for the
-- per-file cache mkdir by dropping the shell entirely (src/import/CacheFs.lua);
-- the callers above genuinely need one, so we do the other half: allocate a
-- single console for ourselves, once, and hide it. A child inherits the
-- parent's console when the parent has one, so every later spawn attaches to
-- that invisible console and pops up nothing. GUI dialogs the children raise
-- (the PowerShell OpenFileDialog) are desktop windows and still appear.
--
-- Skipped when a console already exists, which is the developer case
-- (lovec.exe, what scripts/run.ps1 prefers, or t.console), so printed output
-- keeps landing in the terminal the game was launched from. POKEPORT_CONSOLE=1
-- opts out entirely and restores the old behaviour. Memoized; non-Windows and
-- FFI-less builds no-op. Called once from love.load before anything shells
-- out (main.lua).
local consoleHidden = nil
function HostShell.hideHostConsole()
if consoleHidden ~= nil then return consoleHidden end
consoleHidden = false
if os.getenv("POKEPORT_CONSOLE") == "1" then return consoleHidden end
local okFfi, ffi = pcall(require, "ffi")
if not okFfi or ffi.os ~= "Windows" then return consoleHidden end
-- kernel32 (AllocConsole/GetConsoleWindow) and user32 (ShowWindow) are
-- already loaded in any LOVE process, so ffi.C resolves both -- the same
-- assumption CacheFs makes for CreateDirectoryA.
pcall(ffi.cdef, [[
void *GetConsoleWindow(void);
int AllocConsole(void);
int ShowWindow(void *hWnd, int nCmdShow);
]])
local ok, hidden = pcall(function()
if ffi.C.GetConsoleWindow() ~= nil then return false end
if ffi.C.AllocConsole() == 0 then return false end
local hwnd = ffi.C.GetConsoleWindow()
if hwnd == nil then return false end
ffi.C.ShowWindow(hwnd, 0) -- SW_HIDE
return true
end)
consoleHidden = (ok and hidden) or false
return consoleHidden
end
-- Wraps io.popen with the AppImage env fix applied and lua errors swallowed
function HostShell.popen(command, mode)
local ok, pipe = pcall(io.popen, HostShell.envPrefix() .. command, mode or "r")
@@ -69,4 +118,121 @@ function HostShell.restart()
ffi.C.execv(appimage, ffi.cast("char *const *", argv))
end
-- ------- HTTP transport ----------------------------------------------------
--
-- Every remote fetch (mod index, mod releases, thumbnails) used to shell out
-- to curl, which macOS / Windows 10+ / desktop Linux all ship and Android does
-- not: adding a mod index on Android died with "curl is not available on this
-- platform" (#597). Android goes through the GameActivity.httpDownload JNI
-- bridge instead (HttpsURLConnection, using the INTERNET permission link play
-- already needs), surfaced by our vendored liblove as
-- love.system.httpDownload(url, absPath, userAgent, accept). Both transports
-- block the calling thread and deal in whole files, so callers keep exactly
-- the contract they had with curl.
-- Shell quoting for one curl argument; cmd.exe has no single-quote form.
function HostShell.quote(s)
s = tostring(s)
if love and love.system and love.system.getOS
and love.system.getOS() == "Windows" then
return '"' .. s:gsub('"', '') .. '"'
end
return "'" .. s:gsub("'", "'\\''") .. "'"
end
function HostShell.haveCurl()
local pipe = HostShell.popen("curl --version")
if not pipe then return false end
local readOk, out = pcall(function() return pipe:read("*a") end)
pcall(function() pipe:close() end)
return readOk and out ~= nil and out:find("curl", 1, true) ~= nil
end
-- The bridge only exists in our Android liblove. An older APK reports nil
-- here and falls back to the "no transport" error the callers already show;
-- the iOS build compiles the same wrapper but always returns false, so gate
-- on the OS as well and keep its error message honest.
local function haveBridge()
if not (love and love.system and type(love.system.httpDownload) == "function") then
return false
end
return love.system.getOS and love.system.getOS() == "Android"
end
-- Is any transport available at all? Callers gate on this, never on curl.
function HostShell.canFetch()
return HostShell.haveCurl() or haveBridge()
end
-- Download url to an absolute host path. Returns true, or nil plus an error.
-- The curl branch deliberately ignores curl's exit code, as the download paths
-- always did: callers judge the result by the file they got.
function HostShell.httpDownload(url, absPath, userAgent, accept)
if type(url) ~= "string" or url == "" then return nil, "missing url" end
if type(absPath) ~= "string" or absPath == "" then return nil, "missing path" end
userAgent = userAgent or "gen1recomp"
if HostShell.haveCurl() then
local cmd = "curl -fsSL --connect-timeout 15 --max-time 300 "
.. "-H " .. HostShell.quote("User-Agent: " .. userAgent) .. " "
if accept then
cmd = cmd .. "-H " .. HostShell.quote("Accept: " .. accept) .. " "
end
cmd = cmd .. "-o " .. HostShell.quote(absPath) .. " " .. HostShell.quote(url)
local pipe = HostShell.popen(cmd)
if not pipe then return nil, "could not start download" end
pcall(function() pipe:read("*a") end)
pcall(function() pipe:close() end)
return true
end
if not haveBridge() then
return nil, "no network transport on this platform"
end
local ok, done = pcall(love.system.httpDownload, url, absPath, userAgent, accept)
if ok and done then return true end
return nil, "download failed"
end
-- GET returning the body. curl streams it through a pipe; the Android bridge
-- can only write a file, so there we fetch into the save directory (the only
-- writable root on Android) and read it back.
function HostShell.httpGet(url, userAgent, accept)
if type(url) ~= "string" or url == "" then return nil, "missing url" end
userAgent = userAgent or "gen1recomp"
if HostShell.haveCurl() then
local cmd = "curl -fsSL --connect-timeout 10 --max-time 40 "
.. "-H " .. HostShell.quote("User-Agent: " .. userAgent) .. " "
if accept then
cmd = cmd .. "-H " .. HostShell.quote("Accept: " .. accept) .. " "
end
cmd = cmd .. HostShell.quote(url)
local pipe = HostShell.popen(cmd)
if not pipe then return nil, "could not run curl" end
local readOk, out = pcall(function() return pipe:read("*a") end)
pcall(function() pipe:close() end)
if not readOk then return nil, "fetch failed: " .. tostring(out) end
if not out or out == "" then return nil, "empty response from " .. url end
return out
end
if not haveBridge() then
return nil, "no network transport on this platform"
end
if not (love.filesystem and love.filesystem.getSaveDirectory) then
return nil, "fetch needs LOVE"
end
local dirOk, saveDir = pcall(love.filesystem.getSaveDirectory)
if not dirOk or not saveDir or saveDir == "" then
return nil, "no save directory"
end
local name = "http_fetch.tmp"
pcall(love.filesystem.remove, name)
local ok, err = HostShell.httpDownload(url, saveDir .. "/" .. name, userAgent, accept)
if not ok then return nil, err end
local readOk, body = pcall(love.filesystem.read, name)
pcall(love.filesystem.remove, name)
if not readOk or type(body) ~= "string" or body == "" then
return nil, "empty response from " .. url
end
return body
end
return HostShell
+43 -3
View File
@@ -30,6 +30,9 @@ local DEFAULT_BINDINGS = {
local STICK_ON = 0.5
local STICK_OFF = 0.3
-- 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" },
lu = { "left", "up" }, ru = { "right", "up" },
@@ -49,11 +52,17 @@ end
-- BindingsMenu:storeBinding) -- without this the menu records a choice
-- that never actually reaches gameplay.
function Input:applyBindings(overlay)
local keys, pads = {}, {}
local keys, pads, joys = {}, {}, {}
for key, action in pairs(DEFAULT_BINDINGS) do keys[key] = 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
@@ -62,8 +71,20 @@ function Input:applyBindings(overlay)
keys[binding] = actionId
end
end
-- A pad binding named "joyN" is the Nth button of a stick SDL has no
-- game-controller-database entry for, captured on the joystick path by
-- src/ui/BindingsMenu.lua (#632). It deliberately rides the existing
-- pad slot: the CONTROLS row, the swap in BindingsMenu:storeBinding and
-- START's reset-all then all stay one code path, and this loop is the
-- only place that has to know what the name means. Laid over the raw
-- defaults AFTER them, so a rebind wins the button it claims.
for padName, action in pairs(pads) do
local n = tonumber(padName:match("^joy(%d+)$"))
if n then joys[n] = action end
end
self.keyBindings = keys
self.padBindings = pads
self.joyBindings = joys
end
-- Purely event-driven state (press sets true, release sets false) has no
@@ -179,15 +200,28 @@ function Input:gamepadreleased(joystick, button)
end
end
-- LOVE raises love.joystickpressed for EVERY stick, including ones SDL
-- recognizes as gamepads, which raise love.gamepadpressed for the same
-- physical press as well. Answering both meant the fixed raw table
-- re-asserted the factory A/B/START/SELECT map underneath the player's
-- rebinds, so swapping A and B in CONTROLS pressed both at once and any
-- controller rebind of those four looked ignored; on iOS the MFi driver's
-- packing put the D-pad on 7..10, so a D-pad press also fired SELECT or
-- START (#620, #632). A recognized pad is served by the gamepad path
-- 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.
-- Gate: GamepadMap.ignoreRawForJoystick (pcall-safe isGamepad check).
function Input:joystickpressed(joystick, button)
if GamepadMap.ignoreRawForJoystick(joystick) then return end
local btn = GamepadMap.mapRawButton(button)
local btn = self.joyBindings[button]
if btn then press(self, btn, "joy:" .. button) end
end
function Input:joystickreleased(joystick, button)
if GamepadMap.ignoreRawForJoystick(joystick) then return end
local btn = GamepadMap.mapRawButton(button)
local btn = self.joyBindings[button]
if btn then release(self, btn, "joy:" .. button) end
end
@@ -227,6 +261,7 @@ function Input:gamepadaxis(joystick, axis, value)
end
function Input:joystickaxis(joystick, axis, value)
if GamepadMap.ignoreRawForJoystick(joystick) then return end
if axis == 1 then
self:gamepadaxis(joystick, "leftx", value)
elseif axis == 2 then
@@ -234,7 +269,12 @@ function Input:joystickaxis(joystick, axis, value)
end
end
-- Same duplicate-event rule as joystickpressed (#620, #632): a recognized
-- pad's D-pad already arrived as dpup/dpdown/dpleft/dpright through the
-- 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 GamepadMap.ignoreRawForJoystick(joystick) then return end
local source = "hat:" .. hat
for _, btn in ipairs(self.hatDirs[hat] or {}) do
release(self, btn, source)
+35 -2
View File
@@ -219,6 +219,24 @@ function SaveData.defaultOptions()
-- battle screen composition: og (the 160x144 original) | wide
-- (304x144, src/battle/WideBattle.lua)
battleLayout = "og",
-- BATTLE SIZE: "fixed" = the classic integer-scaled letterbox; "fill" =
-- scale the battle surface to the window so it fills vertically. See
-- BattleState:wantsFillScale.
battleFit = "fixed",
-- BATTLE BG: what fills the screen behind and around the battle.
-- "white" = the display mode's paper shade (the classic look),
-- "black" = plain black bars, "world" = the frozen overworld showing
-- through, dimmed. See BattleState:bgMode.
battleBg = "white",
-- UI LAYOUT: "centered" = a fixed letterbox. Every element sits where it
-- was drawn in the 160x144 canvas and the UI does not follow the survey
-- zoom, so nothing moves or resizes under the player. The original
-- composition. "dynamic" = the dialogue box docks to the window's bottom
-- edge, the START menu to its top right, and the UI steps down with the
-- zoom. Centered is the default: dynamic reads better zoomed out, but it
-- moves the screen furniture, so it is opt-in.
-- See Game.dynamicUI, Renderer:setUIAnchor and Renderer:uiScale.
uiLayout = "centered",
ruleset = "gen1_faithful",
-- 0-7 like the GB's NR50 master volume
musicVol = 7,
@@ -239,6 +257,9 @@ function SaveData.defaultOptions()
voidFill = "trees",
-- windowed | borderless (desktop fullscreen); ignored on mobile
videoMode = "windowed",
-- lock the window to an exact 160x144 multiple, 1..4 (0 = OFF); see
-- src/core/FaithfulRes.lua. Ignored on mobile.
faithfulRes = 0,
-- hard render frame-rate cap; render-only pacing (issue #88, FrameCap.lua)
fpsCap = 60,
-- graphics performance tier: auto | high | balanced | low. "auto"
@@ -254,6 +275,15 @@ function SaveData.defaultOptions()
-- Native mod enablement is an installation option, not save-slot data.
-- Missing entries mean enabled so newly installed mods work by default.
mods = {},
-- Named setups the player can switch between (#593; src/mods/ModProfile.lua
-- owns the shape, src/mods/ManagerState.lua the UI): each row is
-- { name, enabled = {id=bool}, options = {id={k=v}}, slots = {version=slotId} }.
-- activeProfile names the row the live set currently matches (nil for
-- ad-hoc, so it has no default entry here; mergeOptions preserves it).
-- modProfilesSeeded records that the pre-profiles setup was already
-- migrated into PROFILE 1, so deleting every profile does not re-seed one.
modProfiles = {},
modProfilesSeeded = false,
-- GitHub release checks for mods with a manifest "github" field
-- (src/mods/ModUpdate.lua). Keyed by owner/repo; TTL is six hours.
modUpdateCache = {},
@@ -269,8 +299,11 @@ function SaveData.defaultOptions()
modIndexCache = {},
-- On-screen touch overlay (Android/iOS; see src/core/TouchControls.lua).
-- enabled=false hides it permanently (distinct from auto-hide-on-gamepad).
-- positions are optional normalized centers {x=0..1, y=0..1} per control
-- (dpad/a/b/start/select); nil means the default layout.
-- layouts.portrait / layouts.landscape each hold optional normalized
-- centers {x=0..1, y=0..1} per control (dpad/a/b/start/select) plus a
-- size scale; nil positions mean that orientation draws the default
-- layout (#633). Pre-#633 files stored one top-level positions table;
-- TouchControls.normalizeConfig folds it into both orientations on load.
touchControls = { enabled = true },
}
end
+42 -3
View File
@@ -82,6 +82,40 @@ local function isChipDef(def)
return type(def) == "table" and (def.chip ~= nil or def.address ~= nil)
end
-- OpenAL only spatializes 1-channel Sources, and one left at the default
-- (0,0,0) position sits on top of the listener, which OpenAL renders as an
-- ambient sound spread over every output channel the device has: on an
-- interface with more than two outputs the SFX also came out of outputs 5+6
-- while the 2-channel music stayed on 1+2 (#626). A Source cannot change its
-- channel count after the fact, so a mono file def is re-decoded and its
-- sample duplicated into a stereo buffer, which OpenAL never spatializes.
-- 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.
local function widenMono(source, file)
if not (source and love.sound and love.sound.newSoundData) 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)
local frames = mono:getSampleCount()
local stereo = love.sound.newSoundData(frames, mono:getSampleRate(),
mono:getBitDepth(), 2)
for index = 0, frames - 1 do
local value = mono:getSample(index)
stereo:setSample(index, 1, value)
stereo:setSample(index, 2, value)
end
return love.audio.newSource(stereo, "static")
end)
if built and widened then return widened end
return source
end
-- a file def carries an optional playback rate; a bare string is shorthand
-- for { file = <string> }
local function newFileSource(def)
@@ -89,6 +123,7 @@ local function newFileSource(def)
if type(file) ~= "string" then return nil, "no chip program and no file" end
local ok, s = pcall(love.audio.newSource, file, "static")
if not ok or not s then return nil, ok and "no source" or tostring(s) end
s = widenMono(s, file) -- keep mono defs off the surround channels (#626)
if type(def) == "table" and def.pitch then pcall(s.setPitch, s, def.pitch) end
return s
end
@@ -233,12 +268,16 @@ function Sound.playPikaCry(data, n)
local src = cache[key]
if src == false then return nil end
if not src then
local ok, s = pcall(love.audio.newSource,
("assets/generated/audio/pika_cries/cry_%02d.wav"):format(n), "static")
if not ok then
local path = ("assets/generated/audio/pika_cries/cry_%02d.wav"):format(n)
local ok, s = pcall(love.audio.newSource, path, "static")
if not ok or not s then
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)
s = widenMono(s, path)
s:setVolume(volumeFor(key))
cache[key] = s
src = s
+198
View File
@@ -0,0 +1,198 @@
-- Hardware frame budgets, in fixed 60Hz logic steps.
--
-- The original spends a large share of its running time inside DelayFrames
-- calls that produce no visible change -- the pause after a page break, the
-- beat before a status move resolves, the one-HP-at-a-time drain of an HP
-- bar. Porting the visible half of a sequence and dropping the wait is what
-- makes a port read as snappier than hardware, so every one of those waits
-- lives here with its asm citation instead of as a file-local constant.
--
-- See docs/timing-parity.md for the full catalog and the measurement method;
-- tools/scan_pokered_delays.ps1 regenerates the hardware side from a
-- disassembly checkout.
local Timing = {}
-- home/palettes.asm:14 -- three frames to let the bg map fully update
Timing.DELAY3 = 3
-- home/fade.asm: each fade is a loop of `ld c, 8 / call DelayFrames`
Timing.FADE_IN_FROM_BLACK = 32 -- fade.asm:21, b = 4
Timing.FADE_OUT_TO_BLACK = 32 -- fade.asm:43, b = 4
Timing.FADE_OUT_TO_WHITE = 24 -- fade.asm:26, b = 3
Timing.FADE_IN_FROM_WHITE = 24 -- fade.asm:48, b = 3
-- Overworld -----------------------------------------------------------------
-- home/overworld.asm:703 PlayMapChangeSound tail-calls GBFadeOutToBlack on
-- every map change. There is no matching fade in: the new map is drawn while
-- the palettes are still blacked out and LoadGBPal restores them in one write,
-- so the map appears instantly.
Timing.WARP_FADE_OUT = Timing.FADE_OUT_TO_BLACK
Timing.WARP_FADE_IN = 0
-- home/overworld.asm:351-352 -- after a battle, before EnterMap
Timing.POST_BATTLE_RETURN = 10
-- engine/overworld/player_animations.asm:5-7 -- EnterMapAnim, the fly /
-- teleport / dungeon-warp arrival: Delay3 then GBFadeInFromWhite
Timing.SPECIAL_WARP_ENTRY = Timing.DELAY3 + Timing.FADE_IN_FROM_WHITE
-- player_animations.asm:43 -- dungeon warp holds before handing back control
Timing.DUNGEON_WARP_ARRIVAL = 50
-- Text ----------------------------------------------------------------------
-- home/text.asm:283-307 ScrollTextUpOneLine is `ld b, 5` of DelayFrame, and
-- its own comment notes it is "always called twice in a row"
Timing.TEXT_SCROLL_LINE = 5
Timing.TEXT_SCROLL_PAIR = Timing.TEXT_SCROLL_LINE * 2
-- Both _ContText (home/text.asm:262-277) and Paragraph (:230-243) print the
-- â–¼ and call ProtectedDelay3 *before* ManualTextScroll starts watching the
-- joypad, so three frames pass with the arrow up and the button ignored.
Timing.TEXT_PRE_ADVANCE = Timing.DELAY3
-- Paragraph / PageChar clear the box and then hold (home/text.asm:239-240,
-- :254-255) before the next page starts typing.
Timing.TEXT_PAGE_CLEAR = 20
-- Totals, for the catalog and the parity tests.
Timing.TEXT_CONT = Timing.TEXT_PRE_ADVANCE + Timing.TEXT_SCROLL_PAIR
Timing.TEXT_PARAGRAPH = Timing.TEXT_PRE_ADVANCE + Timing.TEXT_PAGE_CLEAR
Timing.TEXT_PAGE = Timing.TEXT_PARAGRAPH
Timing.TEXT_PAUSE = 30 -- home/text.asm:500 TextCommand_PAUSE
Timing.TEXT_DOT = 10 -- home/text.asm:576 TextCommand_DOTS, per dot
-- Menus ---------------------------------------------------------------------
-- engine/menus/text_box.asm:322-323 / :333-334 -- both branches of a
-- two-option (yes/no) menu hold before restoring the screen tiles
Timing.YES_NO_ANSWER = 15
Timing.LIST_MENU_OPEN = 10 -- home/list_menu.asm:55-56
Timing.LIST_MENU_REDRAW = Timing.DELAY3 -- home/list_menu.asm:64
-- engine/menus/start_sub_menus.asm:224-225
Timing.FIELD_TELEPORT = 60 + Timing.DELAY3
-- Battle --------------------------------------------------------------------
-- SlidePlayerAndEnemySilhouettesOnScreen (engine/battle/core.asm:9-49):
-- the enemy comes in on BG SCX $90 -> $00 and the player's back pic on
-- decrementing OAM x, both 2 px per frame -- so 144 px over 72 frames. The
-- port ran 160 px at 4 px/frame (40 frames), a little under twice too fast.
Timing.BATTLE_SLIDE_IN_FRAMES = 72
Timing.BATTLE_SLIDE_PX_PER_FRAME = 2
-- PrintBeginningBattleText .trainerBattle (engine/battle/common_text.asm):
-- SFX_SILPH_SCOPE plays into a clear window (PlaySound then
-- WaitForSoundToFinish, which blocks), and only after `ld c, 20 /
-- DelayFrames` do DrawAllPokeballs and the "wants to fight!" text run.
Timing.TRAINER_INTRO_SFX_GAP = 20
Timing.BATTLE_START_SENDOUT = 40 -- engine/battle/core.asm:155-156
Timing.MOVE_ANIM_PRE = Timing.DELAY3 -- core.asm:6638 PlayMoveAnimation
-- core.asm:3185-3186 (player) / :5587-5588 (enemy). Reached when the move
-- has 0 BP (core.asm:3145 -- every status move) or missed (:3158), so this
-- beat is paid on a large fraction of all turns.
Timing.MOVE_STATUS_OR_MISS = 30
-- PlayApplyingAttackAnimation's six types (AnimationTypePointerTable,
-- engine/battle/animations.asm:490-524). The two shake families are
-- `AnimationShakeScreenHorizontallySlow`, whose double push/pop makes each
-- outer pass cost 4b frames and run c times -- so c * 4b.
Timing.SHAKE_VERTICAL = 48 -- type 1, b=8: 8 x 6
Timing.SHAKE_HORIZ_HEAVY = 72 -- type 2, b=8: 8 x 9
Timing.SHAKE_HORIZ_SLOW = 48 -- type 3, lb bc, 6, 2: 2 x 4x6
Timing.SHAKE_HORIZ_LIGHT = 18 -- type 5, b=2: 2 x 9
Timing.SHAKE_HORIZ_SLOW2 = 24 -- type 6, lb bc, 3, 2: 2 x 4x3
-- Type 4 -- the player's damaging move with no added effect, and so the
-- single most common animation in the game -- is AnimationBlinkMon
-- (animations.asm:1360-1376): `ld c, 6` iterations of hide + DelayFrames 5
-- + show + DelayFrames 5. The asm's own comment calls it "a second or
-- two"; the port ran it in 20 frames, three times too fast, which is a
-- large part of why trading blows felt hurried.
Timing.BLINK_MON = 60
-- SlideDownFaintedMonPic (engine/battle/core.asm:1181-1222): b = PIC_HEIGHT
-- (7) outer iterations, each closing with `ld c, 2 / call DelayFrames`.
-- This one the port ran SLOWER than hardware, at 30.
Timing.FAINT_SLIDE = 14
Timing.RESIDUAL_TICK = 20 -- core.asm:529-530 poison/burn/leech seed
Timing.CRIT_OHKO_TEXT = 20 -- core.asm:3813-3814
Timing.SWITCH_PLAYER_MON = 50 -- core.asm:2421-2422
Timing.NO_MOVES_LEFT = 60 -- core.asm:2753-2754
Timing.TRAINER_VICTORY = 40 -- core.asm:940-941
Timing.PLAYER_BLACKOUT = 40 -- core.asm:1143-1144
Timing.FAINT_SLIDE_ROW = 2 -- core.asm:1216-1217, per row
Timing.FAINT_SLIDE_STEP = 8 / Timing.FAINT_SLIDE_ROW -- 4px per frame at 1x
Timing.TRAINER_SLIDE_COL = 2 -- core.asm:1267-1268, per column
-- HP bar (engine/gfx/hp_bar.asm) ---------------------------------------------
--
-- UpdateHPBar steps ONE HP point per loop iteration (:81-120). Each
-- iteration pays:
-- * 1 frame in UpdateHPBar_PrintHPNumber's DelayFrame (:234) -- but only
-- when wHPBarType is nonzero (:207-209), i.e. the player's own HUD and
-- the party menu, never the enemy HUD; and
-- * 2 frames per pixel the bar actually moved, from
-- UpdateHPBar_AnimateHPBar's `ld c, 2 / call DelayFrames` (:147-148).
-- The drain closes with one more pixel step and a Delay3 (:133-135).
--
-- So a player-side drain of D HP across P pixels costs D + 2P + 6 frames,
-- while the same drain on the enemy HUD costs only 2P + 5. A 150 HP mon
-- losing everything takes 150 + 96 + 6 = 252 frames on hardware.
Timing.HP_BAR_PIXELS = 48 -- the bar is 48 px wide (GetHPBarLength)
Timing.HP_BAR_PIXEL_STEP = 2 -- frames per pixel of bar movement
Timing.HP_BAR_HP_STEP = 1 -- frames per HP point, player-side HUD only
-- Pixels the bar shows for `hp` out of `maxHP`. GetHPBarLength floors the
-- 48ths and clamps the result to at least 1 for any nonzero HP
-- (engine/gfx/hp_bar.asm:42-45); an empty bar is 0.
function Timing.hpBarPixels(hp, maxHP)
if not maxHP or maxHP <= 0 then return 0 end
if hp <= 0 then return 0 end
local px = math.floor(hp * Timing.HP_BAR_PIXELS / maxHP)
if px < 1 then px = 1 end
return px
end
-- Frames one single-HP step of the drain costs: the per-HP number print
-- (player side only) plus two frames for every pixel that step moved.
function Timing.hpDrainStepFrames(fromHP, toHP, maxHP, playerSide)
local pixels = math.abs(Timing.hpBarPixels(toHP, maxHP)
- Timing.hpBarPixels(fromHP, maxHP))
local frames = pixels * Timing.HP_BAR_PIXEL_STEP
if playerSide then frames = frames + Timing.HP_BAR_HP_STEP end
return frames
end
-- After the loop, .animateHPBarDone prints the number one last time, runs
-- AnimateHPBar for a single pixel and falls into Delay3 (hp_bar.asm:132-135)
-- -- so the tail costs 6 frames on the player's HUD and 5 on the enemy's.
function Timing.hpDrainClosingFrames(playerSide)
local frames = Timing.HP_BAR_PIXEL_STEP + Timing.DELAY3
if playerSide then frames = frames + Timing.HP_BAR_HP_STEP end
return frames
end
-- Total cost of draining `fromHP` to `toHP`, for tests and for anything that
-- needs to budget the whole animation up front.
function Timing.hpDrainFrames(fromHP, toHP, maxHP, playerSide)
local total = 0
local hp = fromHP
local dir = (toHP < fromHP) and -1 or 1
while hp ~= toHP do
local nextHP = hp + dir
total = total + Timing.hpDrainStepFrames(hp, nextHP, maxHP, playerSide)
hp = nextHP
end
return total + Timing.hpDrainClosingFrames(playerSide)
end
return Timing
+144 -29
View File
@@ -14,8 +14,12 @@
--
-- Player preferences (options.touchControls) can permanently disable the
-- overlay and/or override per-control positions as normalized window
-- fractions. The launcher editor (src/ui/TouchControlsEditor.lua) writes
-- those; applyOptions reads them at boot and whenever options change.
-- fractions. Positions and a size multiplier are stored per orientation
-- (#633): options.touchControls.layouts.portrait / .landscape, picked from
-- the safe rect's aspect, so laying the pad out in landscape never moves
-- the portrait one. The launcher editor (src/ui/TouchControlsEditor.lua)
-- writes those; applyOptions reads them at boot and whenever options
-- change.
--
-- Controls press GB buttons through Input:overlayPressed/Released -- their
-- own input source, not a keyboard alias -- so a held overlay direction
@@ -47,6 +51,15 @@ local SLOP = { a = 1.3, b = 1.3, start = 1.4, select = 1.4 }
local BUTTONS = { "a", "b", "start", "select" }
local CONTROLS = { "dpad", "a", "b", "start", "select" }
-- Per-orientation layout buckets (#633). Orientation comes from the safe
-- rect, not the device: sw > sh is landscape, so a resized desktop window
-- under POKEPORT_TOUCH exercises the same path a phone rotation does.
local ORIENTATIONS = { "portrait", "landscape" }
-- Control size multiplier bounds for the editor's -/+ (#633). 1.0 is the
-- historical size, so an install that never touches it draws as before.
local SCALE_MIN, SCALE_MAX, SCALE_STEP = 0.6, 1.6, 0.1
local IMAGES = {
dpad = "assets/touch/dpad.png",
dpad_up = "assets/touch/dpad_up.png",
@@ -65,6 +78,33 @@ local function clamp01(v)
return v
end
local function clampScale(v)
if type(v) ~= "number" or v ~= v then return 1 end
if v < SCALE_MIN then return SCALE_MIN end
if v > SCALE_MAX then return SCALE_MAX end
return v
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.
local function normalizePositions(src)
if type(src) ~= "table" then return nil end
local pos = {}
for _, name in ipairs(CONTROLS) do
local p = src[name]
if type(p) == "table" and type(p.x) == "number" and type(p.y) == "number" then
pos[name] = { x = clamp01(p.x), y = clamp01(p.y) }
end
end
if not next(pos) then return nil end
return pos
end
local function orientationFor(sw, sh)
return (sw or 0) > (sh or 0) and "landscape" or "portrait"
end
local function wantsOverlay()
local env = os.getenv("POKEPORT_TOUCH")
if env == "1" then return true end
@@ -73,22 +113,26 @@ local function wantsOverlay()
return osName == "Android" or osName == "iOS"
end
-- Normalize a persisted touchControls table into {enabled, positions}.
-- Normalize a persisted touchControls table into
-- {enabled, layouts = {portrait = {positions, scale}, landscape = {...}}}.
-- Unknown / garbage keys are dropped so a bad options.lua cannot brick
-- the overlay.
-- the overlay. Pre-#633 files stored one top-level positions table and no
-- scale; that layout seeds both orientations, so an upgrading player keeps
-- what they had until they edit one of them.
function TouchControls.normalizeConfig(tc)
local out = { enabled = true, positions = nil }
if type(tc) ~= "table" then return out end
local out = { enabled = true, layouts = { portrait = {}, landscape = {} } }
-- a nil / garbage table still yields full buckets (scale defaulted), so
-- no caller ever has to nil-check a bucket's scale
if type(tc) ~= "table" then tc = {} end
if tc.enabled == false then out.enabled = false end
if type(tc.positions) == "table" then
local pos = {}
for _, name in ipairs(CONTROLS) do
local p = tc.positions[name]
if type(p) == "table" and type(p.x) == "number" and type(p.y) == "number" then
pos[name] = { x = clamp01(p.x), y = clamp01(p.y) }
end
end
if next(pos) then out.positions = pos end
local saved = type(tc.layouts) == "table" and tc.layouts or nil
for _, o in ipairs(ORIENTATIONS) do
local b = saved and saved[o]
if type(b) ~= "table" then b = { positions = tc.positions, scale = tc.scale } end
out.layouts[o] = {
positions = normalizePositions(b.positions),
scale = clampScale(b.scale),
}
end
return out
end
@@ -96,11 +140,14 @@ end
-- Pure default layout in LOVE units for a usable rect of size ww x wh at
-- origin (ox, oy). Shared by layout() and the editor's Reset path so
-- defaults stay in one place. ox/oy default to 0 for the headless tests
-- and for callers that already pass a full-window size.
function TouchControls.defaultLayout(ww, wh, ox, oy)
-- and for callers that already pass a full-window size. scale (#633) is
-- the orientation's size multiplier: every width and the margin derive
-- from dpadW, so scaling it moves the default centers with the art
-- instead of letting bigger buttons hang off the edge.
function TouchControls.defaultLayout(ww, wh, ox, oy, scale)
ox, oy = ox or 0, oy or 0
local short = math.min(ww, wh)
local dpadW = math.min(180, short * 0.34)
local dpadW = math.min(180, short * 0.34) * clampScale(scale)
local abW = dpadW * 0.46
local ssW = dpadW * 0.30
local margin = dpadW * 0.12
@@ -127,7 +174,13 @@ end
function TouchControls:init()
self.active = wantsOverlay()
self.enabled = true
-- 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
self.layouts = { portrait = {}, landscape = {} }
self.orientation = nil
self.positions = nil
self.scale = 1
self.preview = false
self.controllerHidden = false
self.touches = {}
@@ -157,20 +210,30 @@ end
function TouchControls:applyOptions(opts)
local cfg = TouchControls.normalizeConfig(opts and opts.touchControls)
self.enabled = cfg.enabled
self.positions = cfg.positions
self.layouts = cfg.layouts
self.layoutW, self.layoutH = nil, nil
self.layoutOx, self.layoutOy = nil, nil
-- prime positions/scale for the orientation on screen so callers that
-- read them before the next layout() (editor chrome, tests) see the file
self:currentBucket()
if not self.enabled then
self.controllerHidden = false
self:reset()
end
end
-- Snapshot for the editor's save path: enabled plus both orientation
-- buckets, matching what options.lua stores (#633).
function TouchControls:config()
return {
enabled = self.enabled ~= false,
positions = self.positions,
}
local out = { enabled = self.enabled ~= false, layouts = {} }
for _, o in ipairs(ORIENTATIONS) do
local b = self.layouts and self.layouts[o] or nil
out.layouts[o] = {
positions = b and b.positions or nil,
scale = clampScale(b and b.scale),
}
end
return out
end
-- Preview mode: force-draw the overlay for the layout editor, ignoring
@@ -197,13 +260,33 @@ local function clampZone(zone, x0, y0, x1, y1)
zone.cy = math.max(y0 + half, math.min(y1 - half, zone.cy))
end
-- The bucket for the orientation currently on screen (#633), created on
-- demand. Mirrors it into self.orientation / self.positions / self.scale,
-- which layout(), the editor chrome and the tests read.
function TouchControls:currentBucket()
local _, _, sw, sh = SafeArea.rect()
local o = orientationFor(sw, sh)
self.layouts = self.layouts or { portrait = {}, landscape = {} }
local b = self.layouts[o]
if type(b) ~= "table" then
b = {}
self.layouts[o] = b
end
b.scale = clampScale(b.scale)
self.orientation = o
self.positions = b.positions
self.scale = b.scale
return b
end
-- Layout in LOVE units (density-independent on mobile), recomputed when
-- the window or safe area changes (rotation, resize, notch insets).
-- Default: d-pad bottom-left, B/A bottom-right with A above B (the Game Boy
-- diagonal), START/SELECT flanking the bottom center -- all inside the
-- device safe area so thumbs clear the home indicator / cutouts.
-- Custom positions (normalized 0..1 within the safe rect) override centers
-- while sizes stay derived from the short edge.
-- while sizes stay derived from the short edge, times the orientation's
-- size setting (#633).
function TouchControls:layout()
local ox, oy, sw, sh = SafeArea.rect()
if self.layoutW == sw and self.layoutH == sh
@@ -212,10 +295,13 @@ function TouchControls:layout()
end
self.layoutW, self.layoutH = sw, sh
self.layoutOx, self.layoutOy = ox, oy
self.L = TouchControls.defaultLayout(sw, sh, ox, oy)
if self.positions then
-- orientation picks which saved layout applies; rotating swaps buckets
-- because sw/sh swapped, which is already the cache key above (#633)
local bucket = self:currentBucket()
self.L = TouchControls.defaultLayout(sw, sh, ox, oy, bucket.scale)
if bucket.positions then
for _, name in ipairs(CONTROLS) do
local p = self.positions[name]
local p = bucket.positions[name]
local zone = self.L[name]
if p and zone then
zone.cx = ox + p.x * sw
@@ -242,19 +328,45 @@ function TouchControls:setControlCenter(name, cx, cy)
if not zone then return end
zone.cx, zone.cy = cx, cy
clampZone(zone, ox, oy, ox + sw, oy + sh)
self.positions = self.positions or {}
self.positions[name] = {
-- writes land in the orientation on screen only (#633)
local bucket = self:currentBucket()
bucket.positions = bucket.positions or {}
self.positions = bucket.positions
bucket.positions[name] = {
x = sw > 0 and (zone.cx - ox) / sw or 0,
y = sh > 0 and (zone.cy - oy) / sh or 0,
}
end
-- Editor Reset: defaults for the orientation on screen only (#633), so
-- resetting landscape never throws away the portrait layout.
function TouchControls:clearPositions()
local bucket = self:currentBucket()
bucket.positions = nil
bucket.scale = 1
self.positions = nil
self.scale = 1
self.layoutW, self.layoutH = nil, nil
self.layoutOx, self.layoutOy = nil, nil
end
-- Control size multiplier for the orientation on screen (#633). Widths and
-- the default centers both derive from it in defaultLayout; custom centers
-- keep their normalized spot and re-clamp inside the safe rect on the next
-- layout().
function TouchControls:setScale(scale)
local bucket = self:currentBucket()
bucket.scale = clampScale(scale)
self.scale = bucket.scale
self.layoutW, self.layoutH = nil, nil
self.layoutOx, self.layoutOy = nil, nil
return self.scale
end
function TouchControls:nudgeScale(delta)
return self:setScale((self.scale or 1) + delta)
end
local function inCircle(zone, x, y, slop)
local r = zone.w * 0.5 * slop
local dx, dy = x - zone.cx, y - zone.cy
@@ -446,5 +558,8 @@ function TouchControls:draw()
end
TouchControls.CONTROLS = CONTROLS
TouchControls.ORIENTATIONS = ORIENTATIONS
TouchControls.SCALE_MIN, TouchControls.SCALE_MAX = SCALE_MIN, SCALE_MAX
TouchControls.SCALE_STEP = SCALE_STEP
return TouchControls