Compare commits

...

2 Commits

Author SHA1 Message Date
bryanthaboi c04b93ce1c pokecenter fix (#126) 2026-07-23 15:17:20 -04:00
bryanthaboi 2c9970a643 Frame Cap (#124) 2026-07-23 14:35:26 -04:00
12 changed files with 311 additions and 30 deletions
+7 -3
View File
@@ -42,8 +42,10 @@ Game Boy equivalent:
rows above the player recede and rows below come toward the viewer. Only
things that actually *stand* on the ground draw as upright billboards,
unscaled and pixel-identical to flat mode: the player, NPCs, item balls,
and the screen-anchored FX attached to them (heal machine glow, emote
bubbles, the fishing rod, the FLY bird). An earlier revision tried
and the standing FX attached to them (emote bubbles, the fishing rod,
the FLY bird). The Poké Center heal-machine overlay stays on the ground
plane with the machine tiles (it is OAM glued to a BG graphic, not a
standing sprite). An earlier revision tried
billboarding buildings/trees/signs too (cutting them out of the ground
per hand-curated per-tileset tables); that chased an endless tail of
special cases, dense tree canopy, fences fused into grass, building
@@ -148,4 +150,6 @@ migrated once into `options.lua` on load.
hotkey `2` (OG RED = GBC boot-ROM look; RED++ uses pokered-gbc
SuperPalettes + per-species mon colors)
- TILT (OFF / 15 / 35 / 50), also hotkey `3` while free-roaming
- GBC FX (OFF / 1 / 2 / 3 / 4), also hotkey `5`
- GBC FX (OFF / 1 / 2 / 3 / 4), also hotkey `5`
- MAX FPS (30 / 40 / 50 / 60 / 75 / 90 / 100 / 120 / 144 / 160, default 60),
a hard render frame-rate cap (`save.options.fpsCap`).
+75
View File
@@ -299,3 +299,78 @@ function love.filedropped(file)
end
if Importer then Importer:filedropped(file) end
end
local function pacingEnabled()
if os.getenv("POKEPORT_AUTOPILOT") then return false end
if os.getenv("POKEPORT_DRIVER") then return false end
if os.getenv("POKEPORT_IMPORT_ONLY") == "1" then return false end
return true
end
function love.run()
if love.load then love.load(love.arg.parseGameArguments(arg), arg) end
-- don't let love.load's cost land in the first frame's dt
if love.timer then love.timer.step() end
local FrameCap = require("src.core.FrameCap")
local paced = pacingEnabled()
-- The deadline the next present() should not beat. Carried forward one
-- budget per frame so pacing stays even instead of drifting with the
-- per-frame sleep-granularity jitter.
local nextFrame = love.timer and love.timer.getTime() or 0
local dt = 0
return function()
-- process events
if love.event then
love.event.pump()
for name, a, b, c, d, e, f in love.event.poll() do
if name == "quit" then
if not love.quit or not love.quit() then
return a or 0
end
end
love.handlers[name](a, b, c, d, e, f)
end
end
-- update dt
if love.timer then dt = love.timer.step() end
-- call update and draw
if love.update then love.update(dt) end
if love.graphics and love.graphics.isActive() then
love.graphics.origin()
love.graphics.clear(love.graphics.getBackgroundColor())
if love.draw then love.draw() end
love.graphics.present()
end
if love.timer then
if paced then
-- Sleep out the remainder of the frame budget, measured from the
-- carried deadline, in small chunks so the OS timer stays
-- responsive. vsync is untouched: when it already paces slower
-- than the cap the remainder is <= 0 and this rounds to a no-op.
local budget = 1 / FrameCap.current
nextFrame = nextFrame + budget
local now = love.timer.getTime()
-- A stall (alt-tab, a GC pause, a blocked import) can leave the
-- deadline more than a full budget in the past; re-anchor to now so
-- we pace the next frame rather than burst uncapped to catch up.
if now - nextFrame > budget then
nextFrame = now
end
while true do
local remaining = nextFrame - love.timer.getTime()
if remaining <= 0 then break end
love.timer.sleep(remaining < 0.001 and remaining or 0.001)
end
else
love.timer.sleep(0.001)
end
end
end
end
+34
View File
@@ -230,6 +230,25 @@ function ChipAudio.ensureMusicPlaying()
end
end
-- Threaded playMusic returns an empty QueueableSource and only calls
-- Source:play once the first worker buffer lands (~1 frame later). Until
-- then Source:isPlaying is false -- callers that treat that as "song over"
-- (Music.oneShotPlaying / pendingRestore) must wait here instead, or a
-- playOnce jingle like Music_PkmnHealed is cut off before it starts.
local forceAwaitingFirstBuffer -- test-only override (see _simulate*)
function ChipAudio.awaitingFirstBuffer()
if forceAwaitingFirstBuffer then return true end
local m = currentMusic
if not (m and m.threaded and not m.started and not m.finished) then
return false
end
-- a dead worker will never deliver the first buffer
if workerReady == false then return false end
if worker and worker.getError and worker:getError() then return false end
return true
end
function ChipAudio.stopMusic()
if currentMusic and currentMusic.source then
pcall(currentMusic.source.stop, currentMusic.source)
@@ -240,6 +259,7 @@ function ChipAudio.stopMusic()
end
pendingBuf = nil
currentMusic = nil
forceAwaitingFirstBuffer = nil
end
-- hot reload: the next play re-reads programs.bin (a mod may have swapped the
@@ -302,6 +322,20 @@ end
-- test hooks (headless): synchronous synthesis straight through ChipSynth
-- ---------------------------------------------------------------------------
-- Force the "threaded, first buffer not yet queued" window so Music's
-- playOnce / pendingRestore race can be asserted without love.thread.
-- Returns a clear() that drops the override (call after the assertion).
function ChipAudio._simulateAwaitingFirstBufferForTest()
local m = currentMusic
if not m or not m.source then return nil end
m.threaded = true
m.started = false
m.finished = false
pcall(function() m.source.playing = false end)
forceAwaitingFirstBuffer = true
return function() forceAwaitingFirstBuffer = nil end
end
function ChipAudio._renderMusicForTest(data, header, seconds)
local engine = ChipSynth.newEngine(data, header, { allowLoops = true })
return ChipSynth.soundData(engine, math.floor(seconds * SAMPLE_RATE), 2)
+72
View File
@@ -0,0 +1,72 @@
-- Render frame-rate cap. With a driver control panel forcing
-- vsync off, the 160x144 game is trivially cheap and love.run will present
-- thousands of frames a second; over hours that cooks the graphics driver
-- until a restart, and it wastes power whenever the window is left open in
-- the background. A hard cap bounds the present rate. Render-only: game
-- logic is fixed-step off dt (src/core/FixedStep.lua), so pacing present()
-- changes nothing about timing, audio, or determinism.
--
-- Persisted as save.options.fpsCap; applied from OptionsMenu and on boot
-- via Game:applyOptions. main.lua's love.run reads FrameCap.current each
-- frame for its sleep budget. The module never touches love.timer itself,
-- so it stays safe under the headless test stub.
local FrameCap = {}
-- Selectable steps: the normal framerate stops between the floor and the
-- ceiling. STEPS[1] == MIN and STEPS[#STEPS] == MAX, so the nearest-step
-- snap in normalize doubles as the clamp. Cycling past the last wraps.
FrameCap.STEPS = { 30, 40, 50, 60, 75, 90, 100, 120, 144, 160 }
FrameCap.MIN = 30
FrameCap.MAX = 160
FrameCap.DEFAULT = 60
-- The live cap the run loop paces to. Defaults so the launcher and the
-- save editor are paced before any save applies its stored option.
FrameCap.current = FrameCap.DEFAULT
-- Nearest valid step for an arbitrary value (a hand-edited options.lua or
-- an old save with no fpsCap key), so a bad number degrades to something
-- sane; nil / non-numbers fall back to the default. A value below MIN or
-- above MAX snaps to that end, since MIN/MAX are the first/last steps.
function FrameCap.normalize(value)
value = tonumber(value)
if not value then return FrameCap.DEFAULT end
local best, bestDiff = FrameCap.DEFAULT, math.huge
for _, step in ipairs(FrameCap.STEPS) do
local diff = math.abs(step - value)
if diff < bestDiff then best, bestDiff = step, diff end
end
return best
end
-- plain numeric text for the options row (e.g. "60")
function FrameCap.label(value)
return tostring(FrameCap.normalize(value))
end
-- cycle to the next/previous step, wrapping (the options row idiom)
function FrameCap.cycle(value, dir)
local steps = FrameCap.STEPS
local snapped = FrameCap.normalize(value)
local cur = 1
for i, step in ipairs(steps) do
if step == snapped then cur = i break end
end
local nextIdx = (cur - 1 + (dir or 1)) % #steps + 1
return steps[nextIdx]
end
-- Store the chosen cap as the live value the run loop paces to. Never
-- touches love.timer, so it is safe headless -- the loop just reads the
-- number back. Returns the normalized value it stored.
function FrameCap.apply(value)
FrameCap.current = FrameCap.normalize(value)
return FrameCap.current
end
function FrameCap.applyOptions(opts)
FrameCap.apply(opts and opts.fpsCap)
end
return FrameCap
+3
View File
@@ -445,6 +445,9 @@ function Game:applyOptions(opts)
require("src.render.Tilt").applyOptions(opts)
require("src.render.GBCFX").applyOptions(opts)
require("src.core.VideoMode").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)
Input:applyBindings(opts.bindings)
end
+14 -1
View File
@@ -254,6 +254,7 @@ function Music.stop()
require("src.core.ChipAudio").stopMusic()
state.current, state.source, state.loopSource, state.fade = nil, nil, nil, nil
state.chip = false
state.pendingRestore = nil
if previous and Runtime.wants("music.stopped") then
Runtime.emit("music.stopped", { song = previous })
end
@@ -347,14 +348,24 @@ end
function Music.playOnce(data, song)
if not songDef(data, song) then return false end
Music.play(data, song, false, { reason = "once" })
-- play() can no-op (hook silence, failed def); only arm restore when
-- the jingle actually became current
if state.current ~= song then return false end
state.pendingRestore = true
return true
end
local function chipAwaitingFirstBuffer()
return state.chip
and require("src.core.ChipAudio").awaitingFirstBuffer()
end
-- is a playOnce jingle still sounding? (AnimateHealingMachine's
-- .waitLoop2 holds the healing machine until MUSIC_PKMN_HEALED ends)
function Music.oneShotPlaying()
if not state.pendingRestore then return false end
-- threaded chip songs start silent for ~1 frame; that gap is not "over"
if chipAwaitingFirstBuffer() then return true end
local src = state.source
if not src then return false end
local ok, playing = pcall(src.isPlaying, src)
@@ -441,8 +452,10 @@ function Music.update(data)
state.source = loopSrc
pcall(loopSrc.play, loopSrc)
end
-- do not treat "threaded source still waiting on its first buffer" as
-- ended, or playOnce jingles get restored over before they can sound
if state.pendingRestore and sourceStopped(state.source)
and not state.loopSource then
and not state.loopSource and not chipAwaitingFirstBuffer() then
Music.restoreMap(data)
end
end
+2
View File
@@ -192,6 +192,8 @@ function SaveData.defaultOptions()
gbcfx = 0,
-- windowed | borderless (desktop fullscreen); ignored on mobile
videoMode = "windowed",
-- hard render frame-rate cap; render-only pacing (issue #88, FrameCap.lua)
fpsCap = 60,
-- Native mod enablement is an installation option, not save-slot data.
-- Missing entries mean enabled so newly installed mods work by default.
mods = {},
+14
View File
@@ -13,6 +13,7 @@ local Tilt = require("src.render.Tilt")
local GBCFX = require("src.render.GBCFX")
local GameSpeed = require("src.core.GameSpeed")
local VideoMode = require("src.core.VideoMode")
local FrameCap = require("src.core.FrameCap")
local Logger = require("src.core.Logger")
local Runtime = require("src.mods.Runtime")
local OptionRows = require("src.ui.OptionRows")
@@ -202,6 +203,19 @@ local function buildRows(game)
VideoMode.apply(o.videoMode)
return true
end },
-- hard render cap (issue #88): bounds the present rate so a
-- driver-forced vsync-off run cannot spin at thousands of FPS. Logic
-- is fixed-step off dt, so this touches presentation only.
{ id = "fpsCap", label = "MAX FPS",
value = function(g)
return FrameCap.label(g.save.options.fpsCap)
end,
step = function(g, dir)
local o = g.save.options
o.fpsCap = FrameCap.cycle(o.fpsCap, dir)
FrameCap.apply(o.fpsCap)
return true
end },
-- fast-forward the logic clock only; music and sfx keep their tempo
-- (src/core/GameSpeed.lua), so this is safe to leave on
{ id = "speed", label = "GAME SPEED",
+14 -16
View File
@@ -3415,8 +3415,11 @@ function OverworldState:drawWorld()
love.graphics.setShader(shader)
end
end
local ox = ha.px - 64 - cam.x
local oy = ha.py - 64 - cam.y
-- TileRenderer windows with -floor(cam), so the overlay must use the
-- same snap or a fractional camera (odd fill/tilt view sizes) parks
-- the balls a pixel off the machine tiles
local ox = ha.px - 64 - math.floor(cam.x)
local oy = ha.py - 64 - math.floor(cam.y)
love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(img, self.healMachineQuads[1], ox + 44, oy + 20)
for i = 1, math.min(ha.lit, #HEAL_BALL_XY) do
@@ -3632,11 +3635,13 @@ function OverworldState:drawWorld()
else
-- === TILT PATH: ground-hugging FX stay on the projected ground, all
-- standing things billboard upright over it in a separate pass. ======
-- Dust is ground-hugging smoke -> ground canvas (puts it
-- with the flat layer, so it projects with the ground). Flat mode
-- draws it last, over the sprites, in the same canvas; here the two
-- Dust / cut / the Poké Center heal overlay hug the BG (the heal
-- machine is a tileset graphic; its OAM balls must ride that plane or
-- they float off the machine once the ground foreshortens). Flat mode
-- draws them last, over the sprites, in the same canvas; here the two
-- layers are separate and composited ground-under-upright, so drawing
-- it now into the still-active ground canvas is order-equivalent.
-- them now into the still-active ground canvas is order-equivalent.
fxHeal()
fxDust()
fxCutTree()
@@ -3692,18 +3697,11 @@ function OverworldState:drawWorld()
end
end
-- Screen-anchored world FX : each billboards at the
-- ground foot of the character it belongs to, so it stands upright and
-- scales with that character's depth.
-- heal machine -> the healed player's foot (the machine stands on
-- the ground in front of where the player was)
-- Standing world FX: each billboards at the ground foot of the
-- character it belongs to, so it stays upright over the tilted ground.
-- emote bubble -> the spotting NPC's foot (rides above its head)
-- fly bird, rod -> the player's foot
if self.healAnim then
local fx = self.healAnim.px - cam.x + 8
local fy = self.healAnim.py - cam.y + 16
self:billboard(fx, fy, vw, vh, zoneColorsAt(zones, fx, fy), false, fxHeal)
end
-- (heal machine is ground-hugging -- drawn above with dust/cut)
if self.emote and self.emote.npc then
local fx = self.emote.npc.px - cam.x + 8
local fy = self.emote.npc.py - cam.y + 16
+18
View File
@@ -344,6 +344,24 @@ check(lastSource().queueable and lastSource().playing,
"a chip song still plays after a file song")
check(not body.playing, "the outgoing file song was stopped")
-- playOnce must survive the threaded "empty QueueableSource" window:
-- Source:isPlaying is false until the first worker buffer lands, and that
-- gap must not look like the jingle already ended (Poké Center heal).
data = reset(fixtureData())
Music.playMap(data, "PALLET_TOWN", false, false)
check(Music.playOnce(data, "Music_Chip"), "playOnce starts a chip jingle")
local jingle = lastSource()
local clearAwait = ChipAudio._simulateAwaitingFirstBufferForTest()
check(clearAwait ~= nil, "test can force the awaiting-first-buffer window")
check(Music.oneShotPlaying(),
"oneShotPlaying stays true while the first buffer is still in flight")
Music.update(data)
check(jingle == lastSource() and jingle.queueable,
"pendingRestore does not swap the map theme over a pending chip jingle")
check(ChipAudio.awaitingFirstBuffer(),
"awaitingFirstBuffer reports the forced window")
clearAwait()
-- sfx shape dispatch
check(Sound.play(data, "Beep") == nil, "Sound.play returns nothing")
check(lastSource().file == "assets/beep.wav", "a bare string sfx is a static source")
+41 -3
View File
@@ -22,6 +22,7 @@ local StateStack = require("src.core.StateStack")
local ManagerState = require("src.mods.ManagerState")
local ModUI = require("src.ui.ModUI")
local Theme = require("src.ui.Theme")
local FrameCap = require("src.core.FrameCap")
local savedEvents, savedHooks, savedErrors =
Runtime.events, Runtime.hooks, Runtime.errors
@@ -205,7 +206,7 @@ end
local om = OptionsMenu.new(optGame())
local WANT_IDS = { "textSpeed", "animations", "battleStyle", "ruleset",
"musicVol", "sfxVol", "musicFilter", "colors", "tilt",
"gbcfx", "videoMode", "speed", "mods", "controls" }
"gbcfx", "videoMode", "fpsCap", "speed", "mods", "controls" }
check(#om.rows == #WANT_IDS, "vanilla options row count (plus MODS/CONTROLS)")
for i, id in ipairs(WANT_IDS) do
check(om.rows[i].id == id, "options row order: " .. id)
@@ -235,10 +236,47 @@ check(om.game.save.options.musicVol == 6, "music volume steps down")
for _ = 1, 10 do om.rows[5].step(om.game, -1) end
check(om.game.save.options.musicVol == 0, "music volume clamps at 0")
-- the MAX FPS row cycles the render-cap steps and shows the value plain
om.game.save.options.fpsCap = nil
check(om.rows[12].value(om.game) == "60",
"MAX FPS row defaults to 60 with no saved cap")
om.rows[12].step(om.game, 1)
check(om.game.save.options.fpsCap == 75, "MAX FPS steps up from 60 to 75")
check(om.rows[12].value(om.game) == "75", "the MAX FPS row renders the cap")
om.game.save.options.fpsCap = 160
om.rows[12].step(om.game, 1)
check(om.game.save.options.fpsCap == 30, "MAX FPS wraps past the ceiling to 30")
om.rows[12].step(om.game, -1)
check(om.game.save.options.fpsCap == 160, "MAX FPS wraps back down to the ceiling")
-- ------- FrameCap normalize / cycle (issue #88)
check(FrameCap.normalize(nil) == 60, "FrameCap defaults nil to 60")
check(FrameCap.normalize("junk") == 60, "FrameCap defaults garbage to 60")
check(FrameCap.normalize(60) == 60, "FrameCap keeps an exact step")
check(FrameCap.normalize(58) == 60, "FrameCap snaps 58 to the nearest step 60")
check(FrameCap.normalize(72) == 75, "FrameCap snaps 72 to the nearest step 75")
check(FrameCap.normalize(0) == 30, "FrameCap clamps below the floor to 30")
check(FrameCap.normalize(9999) == 160, "FrameCap clamps above the ceiling to 160")
check(FrameCap.normalize(30) == 30 and FrameCap.normalize(160) == 160,
"FrameCap keeps the exact floor and ceiling")
check(FrameCap.label(nil) == "60" and FrameCap.label(144) == "144",
"FrameCap.label renders the normalized cap as plain text")
check(FrameCap.cycle(60, 1) == 75, "FrameCap cycles 60 up to 75")
check(FrameCap.cycle(60, -1) == 50, "FrameCap cycles 60 down to 50")
check(FrameCap.cycle(160, 1) == 30, "FrameCap cycle wraps the ceiling to the floor")
check(FrameCap.cycle(30, -1) == 160, "FrameCap cycle wraps the floor to the ceiling")
check(FrameCap.cycle(nil, 1) == 75,
"FrameCap cycle normalizes a nil cap (60) before stepping")
-- apply drives the live value the run loop paces to; never touches love.timer
FrameCap.apply(144)
check(FrameCap.current == 144, "FrameCap.apply stores the live cap")
FrameCap.applyOptions({})
check(FrameCap.current == 60, "FrameCap.applyOptions defaults a missing key to 60")
-- the MODS row is the manager's discoverable home
local mgGame = optGame()
om = OptionsMenu.new(mgGame)
om.rows[13].activate(mgGame)
om.rows[14].activate(mgGame)
check(getmetatable(mgGame.stack:top()) == ManagerState,
"the MODS row opens the manager")
check(mgGame.stack:top().screenId == "ManagerState",
@@ -248,7 +286,7 @@ check(mgGame.stack:top().screenId == "ManagerState",
local BindingsMenu = require("src.ui.BindingsMenu")
local cbGame = optGame()
om = OptionsMenu.new(cbGame)
om.rows[14].activate(cbGame)
om.rows[15].activate(cbGame)
local bm = cbGame.stack:top()
check(getmetatable(bm) == BindingsMenu,
"the CONTROLS row opens the rebind list")
+17 -7
View File
@@ -2091,6 +2091,7 @@ do
local GBCFX = require("src.render.GBCFX")
local GameSpeed = require("src.core.GameSpeed")
local VideoMode = require("src.core.VideoMode")
local FrameCap = require("src.core.FrameCap")
local SD = require("src.core.SaveData")
-- Isolate from earlier save/options writes in this suite
SD.saveOptions(SD.defaultOptions())
@@ -2155,7 +2156,16 @@ do
eq(og.save.options.videoMode, "windowed",
"VIDEO MODE wraps back to WINDOWED")
press("down")
eq(om.index, 12, "cursor reaches GAME SPEED")
eq(om.index, 12, "cursor reaches MAX FPS")
press("a")
eq(og.save.options.fpsCap, 75, "A cycles MAX FPS up from 60 to 75")
eq(FrameCap.current, 75, "the live render cap tracks the MAX FPS option")
-- Driven by the step list rather than a literal press count, like GAME
-- SPEED below: a full loop of #STEPS presses returns to the 60 default.
for _ = 1, #FrameCap.STEPS - 1 do press("a") end
eq(og.save.options.fpsCap, 60, "MAX FPS wraps back to 60")
press("down")
eq(om.index, 13, "cursor reaches GAME SPEED")
press("a")
eq(og.save.options.speed, 2, "A cycles GAME SPEED to 2X")
-- Driven by the level list rather than a literal press count: adding a
@@ -2164,19 +2174,19 @@ do
for _ = 1, #GameSpeed.LEVELS - 1 do press("a") end
eq(og.save.options.speed, 1, "GAME SPEED wraps back to NORMAL")
press("down")
eq(om.index, 13, "cursor reaches MODS")
eq(om.index, 14, "cursor reaches MODS")
press("down")
eq(om.index, 14, "cursor reaches CONTROLS")
eq(om.index, 15, "cursor reaches CONTROLS")
press("down")
eq(om.index, 15, "CANCEL stays the fixed final row")
eq(om.scroll, 10, "CANCEL keeps the last option boxes on screen")
eq(om.index, 16, "CANCEL stays the fixed final row")
eq(om.scroll, 11, "CANCEL keeps the last option boxes on screen")
om:draw() -- smoke: scrolled layout draws under the headless stub
press("a")
check(popped, "A on CANCEL closes the options menu")
local om2 = OptionsMenu.new(og)
OInput.pressed = { up = true }; om2:update(1 / 60); OInput.pressed = {}
eq(om2.index, 15, "up from the top wraps to CANCEL")
eq(om2.scroll, 10, "wrapping to CANCEL scrolls to the tail")
eq(om2.index, 16, "up from the top wraps to CANCEL")
eq(om2.scroll, 11, "wrapping to CANCEL scrolls to the tail")
-- headless-safe: no love.audio, setters only update internal state
require("src.core.Music").applyOptions(og.save.options)
require("src.core.Sound").applyOptions(og.save.options)