From 795276935c5467cfdc947bb8d7ae7a8b4ff17c21 Mon Sep 17 00:00:00 2001 From: Shane McGovern Date: Wed, 30 Sep 2026 14:53:18 +0000 Subject: [PATCH] Performance pass: cut per-frame allocations and draw calls, fix frame-rate and batch bugs - Shared render: cache Font encode/width, skip unchanged palette/ShaderFX uniform sends, reuse quads and scratch tables, cheaper sprite palettes - Mods: fast path for loaded requires, memoized sandbox verdicts, fewer Hooks:call allocations, drop emptied hook chains - Audio: bulk PCM writes, cry/SFX prewarm on the worker, sliced sync fallback, cached Gen 3 SE sources - Gen 1: per-frame SGB battle palettes, hoisted overworld FX, cached menus - Gen 2: numeric attr grid, indexed anim cells, entity culling, memoized cache keys, palette shader set once per tile run - Gen 3: reuse native tile batches, per-cell metatile rebuilds, partial FRLG tile anim uploads, near-view ghost ticking, packed FRLG font atlas - Fixes: emergency quit timer, Gen 3 batch regression and stale sprite index, trainer card texture leak, missing pic re-reads, anim tint cache, Crystal ghost grass overdraw, refresh-rate dependent counters, pad swap --- main.lua | 23 +- src/battle/AnimPlayer.lua | 98 ++- src/battle/BattleState.lua | 215 ++++- src/battle/WideBattle.lua | 6 +- src/battle/gen2/AnimObjects.lua | 44 +- src/core/ChipAudio.lua | 250 +++++- src/core/ChipSynth.lua | 126 ++- src/core/Game.lua | 13 +- src/core/Game2.lua | 66 +- src/core/Input.lua | 43 +- src/core/PlatformHooks.lua | 5 +- src/core/Sound.lua | 39 + src/core/chip_worker.lua | 44 +- src/core/game3/audio.lua | 75 +- src/core/game3/bag.lua | 38 +- src/core/game3/battle/anim.lua | 12 + src/core/game3/battle/anim_pal.lua | 10 +- src/core/game3/battle/anim_port/g2_pret.lua | 9 +- src/core/game3/battle/anim_port/g3_pret.lua | 11 +- src/core/game3/battle/anim_vm.lua | 165 +++- src/core/game3/battle/healthbox.lua | 11 +- src/core/game3/battle/ui.lua | 23 +- src/core/game3/battle_transition.lua | 14 +- src/core/game3/collision.lua | 34 +- src/core/game3/dataset.lua | 7 +- src/core/game3/display.lua | 5 + src/core/game3/field.lua | 14 +- src/core/game3/field_view.lua | 230 +++++- src/core/game3/ghosts.lua | 89 ++- src/core/game3/layout_native.lua | 26 +- src/core/game3/map.lua | 32 + src/core/game3/oam.lua | 64 +- src/core/game3/objects.lua | 9 +- src/core/game3/pokemon.lua | 13 +- src/core/game3/rom_text.lua | 30 + src/core/game3/tileset_anim.lua | 56 +- src/core/game3/tileset_native.lua | 16 +- src/core/game3/trainer_sight.lua | 9 +- src/mods/Hooks.lua | 31 +- src/mods/Loader.lua | 13 + src/mods/Sandbox.lua | 36 +- src/pokemon/Sprites.lua | 4 +- src/render/Font.lua | 101 ++- src/render/GbcPalette.lua | 44 +- src/render/HudTiles.lua | 22 +- src/render/PaletteFX.lua | 177 ++++- src/render/ShaderFX.lua | 122 ++- src/render/SpriteRenderer.lua | 33 +- src/render/TextBox.lua | 36 +- src/ui/ListMenu.lua | 38 +- src/ui/Menu.lua | 22 +- src/ui/PartyMenu.lua | 49 +- src/ui/ShopMenu.lua | 11 +- src/ui/SummaryMenu.lua | 25 +- src/ui/TradeAnim.lua | 44 +- src/ui/game3/battle_chrome.lua | 16 +- src/ui/game3/frlg_font.lua | 109 ++- src/ui/game3/rse/pokedex.lua | 21 +- src/ui/game3/trainer_card.lua | 22 +- src/ui/gen2/BattleAnimView.lua | 105 ++- src/ui/gen2/BattleHud.lua | 39 +- src/ui/gen2/BattleState.lua | 76 +- src/ui/gen2/BoxMenu.lua | 90 ++- src/ui/gen2/Chrome.lua | 93 ++- src/ui/gen2/NamingScreen.lua | 16 +- src/ui/gen2/PartyMenu.lua | 66 +- src/ui/gen2/Pokegear.lua | 19 +- src/ui/gen2/SummaryMenu.lua | 23 +- src/ui/gen2/TileSheet.lua | 43 +- src/ui/gen2/TradeAnim.lua | 8 +- src/ui/gen2/Typer.lua | 19 +- src/ui/gen2/UnownPuzzle.lua | 23 +- src/world/OverworldController.lua | 709 +++++++++-------- src/world/Player.lua | 24 +- src/world/gen2/BorderFill.lua | 19 +- src/world/gen2/MapAttrGrid.lua | 39 +- src/world/gen2/World.lua | 740 +++++++++++++----- tests/chip_synth_bulk_pcm_test.lua | 273 +++++++ tests/drivers/em_textbox.lua | 2 + .../game3_ghost_trainer_sight_2460.lua | 3 + .../engine/gen2_world_overdraw_perf_test.lua | 233 ++++++ tests/engine/heal_machine_palette_bug1986.lua | 4 + tests/engine/refresh_rate_counters_test.lua | 158 ++++ tests/engine/render_hotpath_caches_test.lua | 319 ++++++++ tests/game3_native_batch_reuse_test.lua | 205 +++++ tests/game3_overworld_perf_caches_test.lua | 180 +++++ tests/game3_render_caches_test.lua | 296 +++++++ tests/game3_se_source_cache_test.lua | 132 ++++ 88 files changed, 5768 insertions(+), 1138 deletions(-) create mode 100644 tests/chip_synth_bulk_pcm_test.lua create mode 100644 tests/engine/gen2_world_overdraw_perf_test.lua create mode 100644 tests/engine/refresh_rate_counters_test.lua create mode 100644 tests/engine/render_hotpath_caches_test.lua create mode 100644 tests/game3_native_batch_reuse_test.lua create mode 100644 tests/game3_overworld_perf_caches_test.lua create mode 100644 tests/game3_render_caches_test.lua create mode 100644 tests/game3_se_source_cache_test.lua diff --git a/main.lua b/main.lua index adf21571..4f2bd5a9 100644 --- a/main.lua +++ b/main.lua @@ -12,9 +12,10 @@ local SwitchDiagnostics = require("src.debug.SwitchDiagnostics") -- Global emergency quit: holding Start + Select for 5 seconds forcefully terminates LOVE. local emergencyQuitTimer = 0 --- getJoysticks() allocates a fresh table every call; this runs once (twice) --- per frame, so cache the list and refresh it once a second instead. The 5s --- hold requirement makes a 1s hotplug delay irrelevant. +-- getJoysticks() allocates a fresh table every call; this runs once per +-- frame (from love.run, before love.update), so cache the list and refresh +-- it once a second instead. The 5s hold requirement makes a 1s hotplug +-- delay irrelevant. local cachedJoysticks = nil local joystickCacheAge = 1 @@ -929,7 +930,8 @@ function love.load(args) end function love.update(dt) - checkEmergencyQuit(dt) + -- checkEmergencyQuit runs from love.run each frame; calling it here too + -- would double-count dt and fire the 5s hold after 2.5s. HostDisplay.update(dt) SwitchDiagnostics.maybeFlush(false) -- NX only (no-op elsewhere): follow dock/undock without waiting for SDL. @@ -1242,8 +1244,20 @@ function love.joystickhat(joystick, hat, direction) Game:joystickhat(joystick, hat, direction) end +-- The joystick lists cached for polling (checkEmergencyQuit's, Input's +-- pollPads) are only refreshed on a count change or a timer otherwise, which +-- a controller swapped for another can slip past. +local function noteJoysticksChanged() + cachedJoysticks = nil + local input = Game and Game.input + if type(input) == "table" and input.joysticksChanged then + input:joysticksChanged() + end +end + function love.joystickadded(joystick) SwitchDiagnostics.onJoystickEvent("joystickadded", joystick) + noteJoysticksChanged() if editorMode or TouchEditor or Studio then return end if Importer then return end if not Game then return end @@ -1252,6 +1266,7 @@ end function love.joystickremoved(joystick) SwitchDiagnostics.onJoystickEvent("joystickremoved", joystick) + noteJoysticksChanged() if editorMode or TouchEditor or Studio then return end if Importer then return end if not Game then return end diff --git a/src/battle/AnimPlayer.lua b/src/battle/AnimPlayer.lua index 2ccc92f5..ade8dcff 100644 --- a/src/battle/AnimPlayer.lua +++ b/src/battle/AnimPlayer.lua @@ -722,6 +722,25 @@ local function sameColors(a, b) return true end +-- one OAM tile at its screen rect (rx, ry), honoring the X/Y flips +local function blitTile(g, img, quad, s, rx, ry) + g.draw(img, quad, + rx + (s.xf and 8 or 0), + ry + (s.yf and 8 or 0), + 0, + s.xf and -1 or 1, + s.yf and -1 or 1) +end + +-- the shade-remap uniforms; c0 is the transparent color-0 slot, so it just +-- takes color 1 +local function sendTriple(shader, colors) + shader:send("c0", colors[1]) + shader:send("c1", colors[1]) + shader:send("c2", colors[2]) + shader:send("c3", colors[3]) +end + -- Draw one compiled step's OAM sprites. With colorFn, each sprite is -- drawn through the PaletteFX shade-remap shader. The SGB colorized -- the finished DMG picture per 8x8 screen cell (the ATTR_BLK regions @@ -730,14 +749,22 @@ end -- once per attribute cell the 8x8 tile touches (up to 4), and cells -- that resolve to a different palette than the first are repainted -- through a scissor clipped to the cell. +-- +-- Per-frame cost: no closures per tile, and the uniforms are only re-sent +-- when the colors differ from the last ones this call sent (colorFn +-- memoizes, so equal colors are usually the same table). The first send +-- of every call is unconditional -- other passes share the shader. function AnimPlayer:drawSprites(sprites, colorFn) local g = love and love.graphics - local shader + local PaletteFX, shader if colorFn and g and g.setShader then - shader = require("src.render.PaletteFX").shader() + PaletteFX = require("src.render.PaletteFX") + shader = PaletteFX.shader() end local slices = shader and g.getScissor and g.intersectScissor and g.setScissor + local sent -- the triple the shader currently holds (this call) + local floor = math.floor for i = 1, #sprites do local s = sprites[i] -- hardware hides sprites at the OAM extremes (y=0/y>=160, x=0/x>=168); @@ -747,51 +774,48 @@ function AnimPlayer:drawSprites(sprites, colorFn) local quad = img and self:tileQuad(s.ts, s.tile) if quad then local rx, ry = s.x - 8, s.y - 16 -- screen-space rect of the tile - local function blit() - g.draw(img, quad, - rx + (s.xf and 8 or 0), - ry + (s.yf and 8 or 0), - 0, - s.xf and -1 or 1, - s.yf and -1 or 1) - end -- the attribute cell holding the tile's top-left pixel - local cx = math.floor(rx / 8) * 8 - local cy = math.floor(ry / 8) * 8 + local cx = floor(rx / 8) * 8 + local cy = floor(ry / 8) * 8 local colors = shader and colorFn(s, cx, cy) if colors then g.setShader(shader) - -- c0 is the transparent color-0 slot; send anything - shader:send("c0", colors[1]) - shader:send("c1", colors[1]) - shader:send("c2", colors[2]) - shader:send("c3", colors[3]) + if not (sent and sameColors(sent, colors)) then + sendTriple(shader, colors) + sent = colors + end end - blit() + blitTile(g, img, quad, s, rx, ry) if colors and slices and (cx ~= rx or cy ~= ry) then -- unaligned: the tile spills into up to 3 more cells; repaint -- the ones whose zone palette differs (opaque overdraw -- GB -- tiles have binary alpha) - local function slice(px, py) - if px < 0 or py < 0 or px >= 160 or py >= 144 then - return -- fully off-canvas + local cx2 = floor((rx + 7) / 8) * 8 + local cy2 = floor((ry + 7) / 8) * 8 + for n = 1, 3 do + local px, py + if n == 1 then + if cx2 ~= cx then px, py = cx2, cy end + elseif n == 2 then + if cy2 ~= cy then px, py = cx, cy2 end + elseif cx2 ~= cx and cy2 ~= cy then + px, py = cx2, cy2 + end + -- skip cells fully off-canvas + if px and px >= 0 and py >= 0 and px < 160 and py < 144 then + local cc = colorFn(s, px, py) + if cc and not sameColors(cc, colors) then + local s1, s2, s3, s4 = g.getScissor() + g.intersectScissor(px, py, 8, 8) + if not sameColors(sent, cc) then + sendTriple(shader, cc) + sent = cc + end + blitTile(g, img, quad, s, rx, ry) + if s1 then g.setScissor(s1, s2, s3, s4) else g.setScissor() end + end end - local cc = colorFn(s, px, py) - if not cc or sameColors(cc, colors) then return end - local s1, s2, s3, s4 = g.getScissor() - g.intersectScissor(px, py, 8, 8) - shader:send("c0", cc[1]) - shader:send("c1", cc[1]) - shader:send("c2", cc[2]) - shader:send("c3", cc[3]) - blit() - if s1 then g.setScissor(s1, s2, s3, s4) else g.setScissor() end end - local cx2 = math.floor((rx + 7) / 8) * 8 - local cy2 = math.floor((ry + 7) / 8) * 8 - if cx2 ~= cx then slice(cx2, cy) end - if cy2 ~= cy then slice(cx, cy2) end - if cx2 ~= cx and cy2 ~= cy then slice(cx2, cy2) end end if colors then g.setShader() @@ -799,6 +823,8 @@ function AnimPlayer:drawSprites(sprites, colorFn) end end end + -- the direct sends above bypass sendColors' last-sent record + if sent then PaletteFX.forgetSent(shader) end end return AnimPlayer diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 47e0cd1e..0b61ea3c 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -247,6 +247,14 @@ local imagePadLeft = setmetatable({}, WEAK_KEYS) -- image -> { path, pal } so palette-fade variants (see fadeImage) can be -- rebuilt for any battle pic, whatever code loaded it local imageMeta = setmetatable({}, WEAK_KEYS) +-- image -> memoized fadeImage / blackImage variants (the per-frame picImage +-- lookups). They cache what getImage returned, so invalidate() drops them +-- together with imageCache. +local function newDerivedMemo() + return { fade = setmetatable({}, WEAK_KEYS), + black = setmetatable({}, WEAK_KEYS) } +end +local derivedMemo = newDerivedMemo() local function mattedPic(path) return path:sub(1, 17) == "assets/generated/" or path:sub(1, 17) == "save/mod-derived/" @@ -326,6 +334,7 @@ end -- are collected on their own rather than leaking. function BattleState.invalidate() imageCache = {} + derivedMemo = newDerivedMemo() end Assets.register(BattleState.invalidate) @@ -434,12 +443,28 @@ local function fadeImage(img, bgp) if not meta then return img end -- a full-color pic has no DMG shades to remap if meta.trueColor then return img end + -- picImage asks for this every drawn frame of a fade: memoize per + -- (pic, shade map) instead of rebuilding the name string + palette table + -- (meta is fixed per image; the map's four 0..3 shades are the rest of + -- getImage's key, packed into one number) + local mkey = bgp[0] * 64 + bgp[1] * 16 + bgp[2] * 4 + bgp[3] + local row = derivedMemo.fade[img] + local hit = row and row[mkey] + if hit then return hit end local PaletteFX = require("src.render.PaletteFX") local base = meta.pal and meta.pal.colors or PaletteFX.GRAYS local name = (meta.pal and meta.pal.name or "GB") .. "&" .. bgp[0] .. bgp[1] .. bgp[2] .. bgp[3] - return getImage(meta.path, - { name = name, colors = PaletteFX.permute(base, bgp) }) + local out = getImage(meta.path, + { name = name, colors = PaletteFX.permute(base, bgp) }) + if out then + if not row then + row = {} + derivedMemo.fade[img] = row + end + row[mkey] = out + end + return out end -- the raw DMG-gray build of a colored pic (SE_WAVY_SCREEN bakes the @@ -463,7 +488,15 @@ local function blackImage(data, img) local colors = PaletteFX.usesYellowCgb() and pals.BLACK or PaletteFX.pal(data, "BLACK") or pals.BLACK local name = PaletteFX.usesGbcPack() and "redpp:BLACK" or "BLACK" - return getImage(meta.path, { name = name, colors = colors }) or img + -- drawn every frame of the intro slide / blackout text: reuse the last + -- bake while the resolved palette and cache name are unchanged + local memo = derivedMemo.black[img] + if memo and memo.colors == colors and memo.name == name then + return memo.out + end + local out = getImage(meta.path, { name = name, colors = colors }) or img + derivedMemo.black[img] = { colors = colors, name = name, out = out } + return out end -- the asset path a loaded battle image came from (nil for the headless @@ -767,6 +800,18 @@ function BattleState:playerPartyView() end -- opts.hooked: rod encounter, announced with _HookedMonAttackedText +-- Queue the battle's first two cries on the chip audio worker while the +-- transition runs, so the entrance cries don't render on the frame they +-- play. A no-op without a worker; playCry renders as before on a miss. +local function prewarmCries(self) + local Sound = package.loaded["src.core.Sound"] + if not (Sound and Sound.prewarmCry) then return end + for _, battler in ipairs({ self.enemy, self.player }) do + local mon = battler and battler.mon + if mon and mon.species then pcall(Sound.prewarmCry, self.data, mon.species) end + end +end + function BattleState.newWild(game, species, level, opts) local self = newBattle(game) self.kind = "wild" @@ -784,6 +829,7 @@ function BattleState.newWild(game, species, level, opts) else self.introText = self:romText("_WildMonAppearedText", "Wild %s\nappeared!", self.enemy.name) end + prewarmCries(self) return self end @@ -917,6 +963,7 @@ function BattleState.newTrainer(game, oppClass, partyIndex, opts) self.trainerPic = BattleState.trainerSprite( game.data, self.trainer, oppClass, partyIndex) self.introText = Strings("%s wants\nto fight!", self.trainer.name) + prewarmCries(self) return self end @@ -2101,9 +2148,17 @@ end -- flag survived into the next turn, ate a move the real players saw -- land, and from there the replay was watching a different battle. -- LinkBattle.newSpectator calls this at the head of every turn. +local function clearFlinch(b) + if not (b.mustRecharge or b.rageMove) then b.flinched = false end +end + function BattleState:clearTurnFlinches() - for _, b in ipairs({ self.player, self.enemy }) do - if b and not (b.mustRecharge or b.rageMove) then b.flinched = false end + -- ipairs({ self.player, self.enemy }) without the per-call table: the + -- enemy is only visited when the player slot is set, like ipairs did + local p = self.player + if p then + clearFlinch(p) + if self.enemy then clearFlinch(self.enemy) end end end @@ -2305,16 +2360,37 @@ function BattleState:tickFx() self:updateFx() end +-- ScrollTextUpOneLine's pixel scroll (beginMsgLine sets scrollPx = 8): +-- 2px per logic step while the message box shows the rolling window. It +-- used to count down inside drawTextArea / WideBattle's drawMessageBox, so +-- a 144Hz display scrolled 2.4x too fast; update() now runs this once per +-- step AFTER the queue typed, which is the order the draw-side decrement +-- saw it (8 is set and the box already draws at 6 on that frame). +function BattleState:tickTextScroll() + local px = self.scrollPx + if px and px > 0 and self.phase == "messages" + and (self.current or self.animPlaying or self.msgHold) then + px = px - 2 + self.scrollPx = px > 0 and px or nil + end +end + +local function snapIdleBar(b) + if b.shownHP then + b.shownHP = b.mon.hp + b.shownPx = Timing.hpBarPixels(b.mon.hp, math.max(1, b.mon.stats.hp)) + end + b.drainFloor = nil + b.shownStatus = b.mon.status +end + +-- runs every menu-phase step: no per-call { player, enemy } table (the +-- enemy is only visited when the player slot is set, like ipairs did) function BattleState:snapIdleBars() - for _, b in ipairs({ self.player, self.enemy }) do - if b then - if b.shownHP then - b.shownHP = b.mon.hp - b.shownPx = Timing.hpBarPixels(b.mon.hp, math.max(1, b.mon.stats.hp)) - end - b.drainFloor = nil - b.shownStatus = b.mon.status - end + local p = self.player + if p then + snapIdleBar(p) + if self.enemy then snapIdleBar(self.enemy) end end end @@ -2357,6 +2433,7 @@ function BattleState:update(dt) self:finish() end end + self:tickTextScroll() return end @@ -5820,6 +5897,26 @@ local HudTiles = require("src.render.HudTiles") local hudTile = HudTiles.tile local drawHPBar = HudTiles.drawHPBar +-- One reusable row-clip quad per battler for the faint / slide pic effects +-- (drawn every frame they run): re-aimed with setViewport instead of a new +-- Quad per frame. A quad without setViewport (headless stubs) is simply +-- rebuilt, which is what every frame used to do. +function BattleState:picQuad(battler, x, y, w, h, sw, sh) + local quads = self.picQuads + if not quads then + quads = setmetatable({}, WEAK_KEYS) + self.picQuads = quads + end + local q = quads[battler] + if q and q.setViewport then + q:setViewport(x, y, w, h, sw, sh) + return q + end + q = love.graphics.newQuad(x, y, w, h, sw, sh) + quads[battler] = q + return q +end + -- CenterMonName: 1-2 letter names print two tiles right, 3-4 one tile. -- Counted in glyphs, not bytes: a nickname carrying "é" or "♂" is one -- charmap sequence per glyph, and byte length would push it a tile left. @@ -6084,8 +6181,8 @@ function BattleState:drawBattlerPic(battler, x, y, scale, shakeX, shakeY) end local visible = img:getHeight() - math.floor(off / scale) if visible > 0 then - local quad = love.graphics.newQuad(0, 0, img:getWidth(), visible, - img:getWidth(), img:getHeight()) + local quad = self:picQuad(battler, 0, 0, img:getWidth(), visible, + img:getWidth(), img:getHeight()) love.graphics.draw(img, quad, x, y + off, 0, scale, scale) end return @@ -6170,7 +6267,7 @@ function BattleState:drawBattlerPic(battler, x, y, scale, shakeX, shakeY) -- sink below the baseline (AnimationSlideMonDown-style row clip) local visible = h - math.floor(oy / scale) if visible > 0 then - local quad = love.graphics.newQuad(0, 0, w, visible, w, h) + local quad = self:picQuad(battler, 0, 0, w, visible, w, h) love.graphics.draw(img, quad, x + ox, y + oy, 0, scale, scale) end elseif k == "slideUp" then @@ -6181,7 +6278,7 @@ function BattleState:drawBattlerPic(battler, x, y, scale, shakeX, shakeY) local step = math.min(7, math.floor((t - 1) / 2) + 1) local visible = math.floor(h * step / 7) if visible > 0 then - local quad = love.graphics.newQuad(0, h - visible, w, visible, w, h) + local quad = self:picQuad(battler, 0, h - visible, w, visible, w, h) love.graphics.draw(img, quad, x + ox, y + (h - visible) * scale, 0, scale, scale) end @@ -6291,9 +6388,10 @@ function BattleState:sgbBattlePals() return out end --- the SGB palette covering a screen pixel (BlkPacket_Battle regions) -function BattleState:zoneColorsAt(x, y) - local pals = self:sgbBattlePals() +-- the SGB palette covering a screen pixel (BlkPacket_Battle regions). +-- pals: an sgbBattlePals() result the caller already resolved this frame. +function BattleState:zoneColorsAt(x, y, pals) + pals = pals or self:sgbBattlePals() if not pals then return nil end local tx = math.floor(x / 8) local ty = math.floor(y / 8) @@ -6358,13 +6456,32 @@ function BattleState:drawZonePass(src, sx, sy) love.graphics.setColor(1, 1, 1, 1) love.graphics.setShader(shader) local shaking = sx ~= 0 or sy ~= 0 + -- the six zones share four palettes (one gray ramp in mono): permute each + -- once per pass instead of once per zone + local permuted = self.zonePermuted + if not permuted then + permuted = {} + self.zonePermuted = permuted + end + permuted[0], permuted[1], permuted[2], permuted[3], permuted.gray = + nil, nil, nil, nil, nil for _, z in ipairs(BATTLE_ZONES) do if mono then -- the BGP fade still runs, just in gray: the frame-level pass colors -- whatever DMG shade this leaves behind - PaletteFX.sendShades(shader, PaletteFX.permute(PaletteFX.GRAYS, bgp)) + local shades = permuted.gray + if not shades then + shades = PaletteFX.permute(PaletteFX.GRAYS, bgp) + permuted.gray = shades + end + PaletteFX.sendShades(shader, shades) else - PaletteFX.sendColors(shader, PaletteFX.permute(pals[z.pal], bgp)) + local colors = permuted[z.pal] + if not colors then + colors = PaletteFX.permute(pals[z.pal], bgp) + permuted[z.pal] = colors + end + PaletteFX.sendColors(shader, colors) end local zx, zy = z[1] * 8, z[2] * 8 local zw, zh = (z[3] - z[1] + 1) * 8, (z[4] - z[2] + 1) * 8 @@ -6390,7 +6507,18 @@ local OBJ_SHADES = { e4x = { 2, 1, 3 }, -- $e4 xor %00111100 = $d8 obp1 = { 3, 2, 1 }, -- $6c } -function BattleState:animSpriteColors(s, px, py) +local function shadeTriple(P, shade) + local col = P[shade + 1] + return { col[1] / 255, col[2] / 255, col[3] / 255 } +end + +-- memo (optional): a per-frame table from drawAnimLayer. It holds the +-- frame's sgbBattlePals() (memo.pals, false when there are none) so the +-- palette set is resolved once per frame instead of once per attribute +-- cell of every OAM tile, and the finished triples per (palette, obp key) +-- so tiles in the same zone share one table (AnimPlayer then sees equal +-- colors by identity). +function BattleState:animSpriteColors(s, px, py, memo) local PaletteFX = require("src.render.PaletteFX") local key = s.obp or "f0" local P @@ -6399,16 +6527,32 @@ function BattleState:animSpriteColors(s, px, py) -- engine/battle/init_battle_variables.asm:18 P = PaletteFX.ogObjBase() if key == "f0" then key = "e4" elseif key == "f0x" then key = "e4x" end + elseif memo then + local pals = memo.pals + if pals == nil then + pals = self:sgbBattlePals() or false + memo.pals = pals + end + P = pals and self:zoneColorsAt(px or (s.x - 8 + 4), py or (s.y - 16 + 4), + pals) or nil else P = self:zoneColorsAt(px or (s.x - 8 + 4), py or (s.y - 16 + 4)) end if not P then return nil end + local byKey = memo and memo[P] + local hit = byKey and byKey[key] + if hit then return hit end local m = OBJ_SHADES[key] or OBJ_SHADES.f0 - local function c(shade) - local col = P[shade + 1] - return { col[1] / 255, col[2] / 255, col[3] / 255 } + local out = { shadeTriple(P, m[1]), shadeTriple(P, m[2]), + shadeTriple(P, m[3]) } + if memo then + if not byKey then + byKey = {} + memo[P] = byKey + end + byKey[key] = out end - return { c(m[1]), c(m[2]), c(m[3]) } + return out end -- the OAM anim layer (subanimation sprites / the resting caught ball) @@ -6417,7 +6561,16 @@ function BattleState:drawAnimLayer(colorized) if self.fieldCleared then return end local colorFn if colorized then - colorFn = function(s, px, py) return self:animSpriteColors(s, px, py) end + -- fresh per-frame memo (palette set + color triples), one long-lived + -- closure: colorFn runs up to four times per OAM tile + self.animColorMemo = {} + colorFn = self.animColorFn + if not colorFn then + colorFn = function(s, px, py) + return self:animSpriteColors(s, px, py, self.animColorMemo) + end + self.animColorFn = colorFn + end end if self.animPlaying and self.animPlayer then love.graphics.setColor(1, 1, 1, 1) @@ -6807,10 +6960,6 @@ function BattleState:drawTextArea() -- text uses every other tile row, hlcoord *,14 / *,16). scrollPx animates -- the lines up one row (ScrollTextUpOneLine) so a 3rd line scrolls into -- view instead of drawing off-screen at y=144 (#216). - if self.scrollPx and self.scrollPx > 0 then - self.scrollPx = self.scrollPx - 2 - if self.scrollPx <= 0 then self.scrollPx = nil end - end local off = self.scrollPx or 0 local ys = { 112, 128 } for li, line in ipairs(self.shown or {}) do diff --git a/src/battle/WideBattle.lua b/src/battle/WideBattle.lua index 008780d4..14332147 100644 --- a/src/battle/WideBattle.lua +++ b/src/battle/WideBattle.lua @@ -183,10 +183,8 @@ end local function drawMessageBox(battle) Font.drawBox(0, 13, 38, 5) love.graphics.setColor(0, 0, 0, 1) - if battle.scrollPx and battle.scrollPx > 0 then - battle.scrollPx = battle.scrollPx - 2 - if battle.scrollPx <= 0 then battle.scrollPx = nil end - end + -- scrollPx counts down in BattleState:tickTextScroll (logic step), not + -- here, so the scroll speed does not follow the display refresh rate local off = battle.scrollPx or 0 local ys = { 112, 128 } for li, line in ipairs(battle.shown or {}) do diff --git a/src/battle/gen2/AnimObjects.lua b/src/battle/gen2/AnimObjects.lua index 717a3a4d..e47333f8 100644 --- a/src/battle/gen2/AnimObjects.lua +++ b/src/battle/gen2/AnimObjects.lua @@ -42,6 +42,7 @@ local NUM_STRUCTS = 10 -- NUM_BATTLE_ANIM_STRUCTS -- wShadowOAM is 40 objects; BattleAnimOAMUpdate returns carry once it is full -- and BattleAnim_UpdateOAM_All stops walking the structs. local OAM_LIMIT = 40 +local EMPTY = {} local OAM_PRIO, OAM_YFLIP, OAM_XFLIP = 0x80, 0x40, 0x20 local OAM_FLAG_MASK = 0xe0 @@ -332,22 +333,31 @@ function Pool:updateOam(st) local tileId = u8(buf.tileId + (set.vtile or 0)) local yFlip = bit.band(buf.oamFlags, OAM_YFLIP) ~= 0 local xFlip = bit.band(buf.oamFlags, OAM_XFLIP) ~= 0 - for _, entry in ipairs(set.sprites or {}) do - if #self.oam >= OAM_LIMIT then return true end + local oam = self.oam + local pool = self.oamPool + for _, entry in ipairs(set.sprites or EMPTY) do + local n = #oam + if n >= OAM_LIMIT then return true end -- GetSpriteOAMAttr: the frame's flip/priority flags toggle the entry's; -- OAM_PAL1 passes through from the entry, and the palette slot comes from -- the struct. local attr = bit.band(bit.bxor(entry.attr or 0, buf.oamFlags), OAM_FLAG_MASK) attr = attr + bit.band(entry.attr or 0, OAM_PAL1) - self.oam[#self.oam + 1] = { - y = u8(buf.y + buf.yOffset + mirror(entry.y or 0, yFlip)), - x = u8(buf.x + buf.xOffset + mirror(entry.x or 0, xFlip)), - -- BATTLEANIM_BASE_TILE is added here on the cart and subtracted again by - -- every sheet lookup, so the port keeps tiles in sheet-relative space. - tile = u8(tileId + (entry.tile or 0)), - attr = attr, - palette = buf.palette, - } + -- Shadow OAM slots are recycled frame to frame (playFrame), the way the + -- cart rewrites wShadowOAM in place: the 40 tables are made once. + local out = pool and pool[n + 1] + if not out then + out = {} + if pool then pool[n + 1] = out end + end + out.y = u8(buf.y + buf.yOffset + mirror(entry.y or 0, yFlip)) + out.x = u8(buf.x + buf.xOffset + mirror(entry.x or 0, xFlip)) + -- BATTLEANIM_BASE_TILE is added here on the cart and subtracted again by + -- every sheet lookup, so the port keeps tiles in sheet-relative space. + out.tile = u8(tileId + (entry.tile or 0)) + out.attr = attr + out.palette = buf.palette + oam[n + 1] = out end return false end @@ -356,8 +366,18 @@ end -- write its OAM entries. A struct that deinitialises itself inside its -- function still draws this frame, because the ASM calls BattleAnimOAMUpdate -- unconditionally. +-- +-- The OAM list and its entries are reused from frame to frame: what a frame +-- returns is valid until the next playFrame, which is all the draw needs. function Pool:playFrame() - self.oam = {} + local oam = self.oamList + if not oam then + oam = {} + self.oamList = oam + self.oamPool = {} + end + for i = #oam, 1, -1 do oam[i] = nil end + self.oam = oam for slot = 1, NUM_STRUCTS do local st = self.structs[slot] if st.index ~= 0 then diff --git a/src/core/ChipAudio.lua b/src/core/ChipAudio.lua index 5ff23152..1bfa58cb 100644 --- a/src/core/ChipAudio.lua +++ b/src/core/ChipAudio.lua @@ -84,6 +84,16 @@ local lastCosted local worker, cmdCh, outCh local workerReady -- nil = untried, true = running, false = unavailable +-- SFX/cry prewarm state (see "one-shot effects" below): results come back on +-- their own channel so a music stop/clear never drops them +local fxCh +local fxEpoch = 0 -- bumped by invalidate: drops results from a stale def +local fxReady = {} -- key -> SoundData | false (renders to nothing) +local fxPending = {} -- key -> true while a request is in flight +local fxReadyCount = 0 +local FX_READY_MAX = 64 +local drainEffects, dropEffects + local function ensureWorker() if workerReady ~= nil then return workerReady end if not (love.thread and love.thread.newThread and love.audio) then @@ -147,25 +157,70 @@ end -- The queue is deep (MUSIC_BUFFER_COUNT, ~6s) for stall tolerance, but -- synthesizing all of it on the frame a song starts renders ~6s of audio at --- once. Cap how many buffers each fill renders; playback drains ~1 buffer --- every ~11 frames while update() tops up a few per frame, so the deep queue --- still ramps to full within a fraction of a second. +-- once. A (re)start renders MUSIC_FILL_INITIAL whole buffers so playback has +-- lookahead; after that each update() renders a flat slice of samples into a +-- staging buffer and queues it once all MUSIC_BUFFER_SAMPLES are in. Whole +-- 8192-sample buffers rendered on the frame a queue slot freed (every ~11 +-- frames) read as a periodic stutter; ~1024 samples per 60 Hz tick +-- (playback drains ~735) keeps the per-frame cost flat while the queue still +-- creeps up to full. Below MUSIC_FILL_LOW_WATER queued buffers (a long +-- stall, or update() called well under 60 Hz) a tick renders a whole buffer's +-- worth so the queue cannot run dry. The sample stream is unchanged: every +-- buffer is still MUSIC_BUFFER_SAMPLES consecutive engine samples, and a +-- buffer begun before the song ended is completed exactly as before. local MUSIC_FILL_INITIAL = 4 -local MUSIC_FILL_PER_CALL = 3 +local MUSIC_FILL_TICK_SAMPLES = 1024 -- at 44100 Hz; scaled with the rate +local MUSIC_FILL_LOW_WATER = 2 -local function fillSync(limit) +local function tickSamples() + return math.max(256, + math.floor(MUSIC_FILL_TICK_SAMPLES * sampleRate() / 44100 + 0.5)) +end + +-- render up to `budget` samples of the fallback song, queueing each staging +-- buffer as it fills. A new buffer is only begun while the Source has a free +-- slot and the song has not finished (the old whole-buffer loop's gates). +local function renderSync(music, budget) + while budget > 0 do + if not music.staging then + if music.engine:finished() then return end + local ok, free = pcall(music.source.getFreeBufferCount, music.source) + if not ok or type(free) ~= "number" or free <= 0 then return end + music.staging = ChipSynth.newBuffer(MUSIC_BUFFER_SAMPLES, 2) + music.stagingFill = 0 + end + local count = math.min(budget, MUSIC_BUFFER_SAMPLES - music.stagingFill) + ChipSynth.renderInto(music.engine, music.staging, music.stagingFill, + count, 2) + music.stagingFill = music.stagingFill + count + budget = budget - count + if music.stagingFill >= MUSIC_BUFFER_SAMPLES then + local sd = music.staging + music.staging, music.stagingFill = nil, 0 + if not pcall(music.source.queue, music.source, sd) then return end + end + end +end + +-- `buffers`: render that many whole buffers now (song start / restart); +-- omitted: one per-frame tick +local function fillSync(buffers) if suspended then return end local music = currentMusic - if not music or not music.engine or music.engine:finished() then return end - limit = limit or MUSIC_FILL_PER_CALL - local ok, free = pcall(music.source.getFreeBufferCount, music.source) - if not ok or type(free) ~= "number" then return end - while free > 0 and limit > 0 and not music.engine:finished() do - local sd = ChipSynth.soundData(music.engine, MUSIC_BUFFER_SAMPLES, 2) - if not pcall(music.source.queue, music.source, sd) then return end - free = free - 1 - limit = limit - 1 + if not music or not music.engine then return end + if not music.staging and music.engine:finished() then return end + local budget + if buffers then + budget = buffers * MUSIC_BUFFER_SAMPLES - (music.stagingFill or 0) + else + budget = tickSamples() + local ok, free = pcall(music.source.getFreeBufferCount, music.source) + if ok and type(free) == "number" + and MUSIC_BUFFER_COUNT - free < MUSIC_FILL_LOW_WATER then + budget = MUSIC_BUFFER_SAMPLES + end end + renderSync(music, budget) end local function playMusicSync(data, header, allowLoops) @@ -360,6 +415,7 @@ end function ChipAudio.update() if suspended then return end + if fxCh then drainEffects() end local m = currentMusic if not m then return end if m.threaded then @@ -446,6 +502,7 @@ end function ChipAudio.invalidate() ChipAudio.stopMusic() ChipSynth.invalidateBanks() + dropEffects() if workerReady and cmdCh then cmdCh:push({ cmd = "invalidate" }) end end @@ -458,6 +515,8 @@ function ChipAudio.shutdown() if worker then pcall(function() worker:wait() end) end worker, cmdCh, outCh = nil, nil, nil workerReady = nil + dropEffects() + fxCh = nil end function ChipAudio.currentSource() @@ -500,6 +559,8 @@ function ChipAudio.setStereo(enabled) local m = currentMusic if m and m.engine then ChipSynth.applyStereo(m.engine) + -- a half-rendered fallback buffer holds the previous pan mix (#1471) + m.staging, m.stagingFill = nil, 0 end if workerReady and cmdCh then cmdCh:push({ cmd = "channelMix", @@ -619,31 +680,161 @@ Assets.register(ChipAudio.invalidate) -- one-shot effects (SFX, cries, low-health alarm): synchronous static Sources -- --------------------------------------------------------------------------- +-- Prewarm (#first-cry hitch): the first play of an SFX/cry synthesizes its +-- whole PCM on the main thread (a cry is ~10-25 ms of Lua synthesis, a long +-- jingle 150-350 ms). ChipAudio.prewarmSfx / prewarmCry hand that render to +-- the music worker ahead of time -- e.g. while a battle transition runs, for +-- the two species about to cry -- and the next newSfx/newCry with the same +-- def, modifiers and channel mix takes the finished SoundData instead of +-- rendering. Playback timing is unchanged: a play never waits on the worker +-- (a request still in flight is rendered synchronously as before and its +-- late result dropped), and the worker runs the same ChipSynth code with the +-- same rate/mix, so the PCM is identical. No worker (headless, love.thread +-- unavailable or dead): prewarm is a no-op and plays render as before. + +local function effectKey(data, header, options) + if type(header) ~= "table" then return nil end + local volumes = ChipSynth.getChannelVolumes() + local pitches = ChipSynth.getChannelPitches() + -- table identities: a reloaded def (or dataset) is a new table + return table.concat({ + tostring(data and data.audio), tostring(header.chip or header), + tostring(options.frequencyOffset), tostring(options.frameTicks), + tostring(options.plainFrames), tostring(options.cryLength), + tostring(options.maxSeconds), + sampleRate(), ChipSynth.getStereo() and 1 or 0, + volumes[1], volumes[2], volumes[3], volumes[4], + pitches[1], pitches[2], pitches[3], pitches[4], fxEpoch, + }, "|") +end + +function drainEffects() + if not fxCh then return end + while true do + local result = fxCh:pop() + if not result then return end + if result.epoch == fxEpoch and fxPending[result.key] then + fxPending[result.key] = nil + if result.error then + require("src.core.Logger").warn("chip audio prewarm: %s", + tostring(result.error)) + elseif fxReadyCount < FX_READY_MAX then + fxReady[result.key] = result.sd or false + fxReadyCount = fxReadyCount + 1 + end + end + end +end + +function dropEffects() + fxEpoch = fxEpoch + 1 + fxReady, fxPending, fxReadyCount = {}, {}, 0 + if fxCh then fxCh:clear() end +end + +local function requestEffect(data, header, options) + if not ensureWorker() or not workerAlive() then return false end + local key = effectKey(data, header, options) + if not key then return false end + drainEffects() + if fxReady[key] ~= nil or fxPending[key] then return true end + fxCh = fxCh or love.thread.getChannel("chipaudio_fx") + local pushed = pcall(cmdCh.push, cmdCh, { + cmd = "effect", key = key, epoch = fxEpoch, header = header, + options = { + frequencyOffset = options.frequencyOffset, + frameTicks = options.frameTicks, + plainFrames = options.plainFrames, + cryLength = options.cryLength, + maxSeconds = options.maxSeconds, + }, + audio = slimAudio(data), + channelVolumes = ChipSynth.getChannelVolumes(), + channelPitches = ChipSynth.getChannelPitches(), + stereo = ChipSynth.getStereo(), + sampleRate = sampleRate(), + }) + if not pushed then return false end + fxPending[key] = true + return true +end + +-- a finished prewarm for exactly this render, or nil +local function takeEffect(data, header, options) + if not fxCh then return nil end + local key = effectKey(data, header, options) + if not key then return nil end + drainEffects() + local sd = fxReady[key] + if sd ~= nil then + fxReady[key] = nil + fxReadyCount = fxReadyCount - 1 + return sd + end + -- about to render it here; the worker's copy would arrive unused + fxPending[key] = nil + return nil +end + local function renderEffect(data, header, options) - local sd = ChipSynth.renderEffectData(data, header, options) + local sd = takeEffect(data, header, options) + if sd == nil then sd = ChipSynth.renderEffectData(data, header, options) end if not sd then return nil end return love.audio.newSource(sd, "static") end -function ChipAudio.newSfx(data, name, pitch, tempo, header, plainFrames) - header = header or data.audio.sfx[name] - return renderEffect(data, header, { +local function sfxOptions(pitch, tempo, plainFrames) + return { frequencyOffset = pitch or 0, frameTicks = 0x80 + (tempo or 0x80), plainFrames = plainFrames, - }) + } +end + +local function cryDef(data, species, resolved) + return resolved or (data.audio.cries and data.audio.cries[species]) +end + +local function cryOptions(cry) + return { frequencyOffset = cry.pitch, cryLength = cry.length } +end + +function ChipAudio.newSfx(data, name, pitch, tempo, header, plainFrames) + header = header or data.audio.sfx[name] + return renderEffect(data, header, sfxOptions(pitch, tempo, plainFrames)) end -- `resolved` is a {header|chip, pitch, length} def the caller already worked -- out -- a derived cry borrowing another species' header with its own -- modifiers, which no registry lookup under `species` could find function ChipAudio.newCry(data, species, resolved) - local cry = resolved or (data.audio.cries and data.audio.cries[species]) + local cry = cryDef(data, species, resolved) if not cry then return nil end - return renderEffect(data, cry.chip and cry or cry.header, { - frequencyOffset = cry.pitch, - cryLength = cry.length, - }) + return renderEffect(data, cry.chip and cry or cry.header, cryOptions(cry)) +end + +-- Same arguments as newSfx / newCry; returns true when a render was queued +-- on the worker (or is already queued / done), false when there is no worker +-- to do it. Never renders on the calling thread. +function ChipAudio.prewarmSfx(data, name, pitch, tempo, header, plainFrames) + header = header or (data.audio and data.audio.sfx and data.audio.sfx[name]) + if not header then return false end + return requestEffect(data, header, sfxOptions(pitch, tempo, plainFrames)) +end + +function ChipAudio.prewarmCry(data, species, resolved) + local cry = data.audio and cryDef(data, species, resolved) + if not cry then return false end + local header = cry.chip and cry or cry.header + if not header then return false end + return requestEffect(data, header, cryOptions(cry)) +end + +-- test hook: prewarm bookkeeping +function ChipAudio._effectStateForTest() + local pending = 0 + for _ in pairs(fxPending) do pending = pending + 1 end + return { ready = fxReadyCount, pending = pending, epoch = fxEpoch } end -- Two channels for the same reason ChipSynth.renderEffectData renders stereo: @@ -659,6 +850,7 @@ function ChipAudio.newLowHealthAlarm() local SAMPLE_RATE = sampleRate() local samples = math.floor(SAMPLE_RATE * 60 / 60) local data = love.sound.newSoundData(samples, SAMPLE_RATE, 16, 2) + local pointer = ChipSynth._int16Pointer(data) local phase = 0 for index = 0, samples - 1 do local frame = math.floor(index * 60 / SAMPLE_RATE) % 30 @@ -666,8 +858,14 @@ function ChipAudio.newLowHealthAlarm() local frequency = 131072 / (2048 - register) phase = (phase + frequency / SAMPLE_RATE) % 1 local value = (phase < 0.5 and 1 or -1) * 0.25 - data:setSample(index, 1, value) - data:setSample(index, 2, value) + if pointer then + -- same int16 setSample stores (ChipSynth bulk PCM writes) + pointer[index * 2] = value * 32767 + pointer[index * 2 + 1] = value * 32767 + else + data:setSample(index, 1, value) + data:setSample(index, 2, value) + end end return love.audio.newSource(data, "static") end diff --git a/src/core/ChipSynth.lua b/src/core/ChipSynth.lua index fad01d35..69b6c798 100644 --- a/src/core/ChipSynth.lua +++ b/src/core/ChipSynth.lua @@ -1407,12 +1407,68 @@ function Engine:sampleChannel(number) return analogOut(self, selected, "hpfCap", "lpf") end --- render `samples` frames into a fresh SoundData (mono or stereo). love.sound --- is available on worker threads, so this is the hand-off unit the worker --- produces and the main thread queues. -local function soundData(engine, samples, channels) - local result = love.sound.newSoundData(samples, SAMPLE_RATE, 16, channels) - for index = 0, samples - 1 do +-- Bulk PCM writes. SoundData:setSample is a C call per sample (two per +-- stereo frame); at 44100 Hz that was a large slice of every rendered buffer. +-- Where LuaJIT's ffi and Data:getFFIPointer exist, samples go straight into +-- the SoundData's int16 storage instead. LOVE 11.5's 16-bit setSample stores +-- (int16)(value * 32767) in double precision (truncating, no clamp), which is +-- exactly what an ffi int16 store of `value * 32767` does, so the PCM is +-- bit-identical to the setSample path (tests/chip_synth_bulk_pcm_test.lua, +-- plus a real-LOVE comparison over 2M values incl. out-of-range and NaN). +-- The setSample path stays as the fallback (headless stub, no ffi). +local ffiOk, ffi = pcall(require, "ffi") +local bulkWrites = true + +function ChipSynth._setBulkWritesForTest(enabled) + bulkWrites = not not enabled +end + +local function int16Pointer(sd) + if not (bulkWrites and ffiOk and ffi and sd.getFFIPointer) then return nil end + if sd.getBitDepth then + local okDepth, depth = pcall(sd.getBitDepth, sd) + if not okDepth or depth ~= 16 then return nil end + end + local ok, pointer = pcall(sd.getFFIPointer, sd) + if not ok or pointer == nil then return nil end + return ffi.cast("int16_t *", pointer) +end +ChipSynth._int16Pointer = int16Pointer + +-- does this runtime's SoundData expose writable int16 storage? Probed once +-- (a 1-frame buffer) so renderEffectData can pick its path before rendering. +local pointerSupport + +local function bulkAvailable() + if not (bulkWrites and ffiOk and ffi) then return false end + if pointerSupport == nil then + local ok, probe = pcall(love.sound.newSoundData, 1, SAMPLE_RATE, 16, 2) + pointerSupport = (ok and probe and int16Pointer(probe)) and true or false + end + return pointerSupport +end + +-- render `count` frames from `engine` into an existing SoundData starting at +-- frame `offset`. The synchronous music fallback fills one buffer a slice at +-- a time across frames (ChipAudio fillSync) so its per-frame cost is flat. +local function renderInto(engine, result, offset, count, channels) + local last = offset + count - 1 + local pointer = int16Pointer(result) + if pointer then + if channels == 2 then + for index = offset, last do + local left, right = engine:sampleStereo() + pointer[index * 2] = left * 32767 + pointer[index * 2 + 1] = right * 32767 + end + else + for index = offset, last do + pointer[index] = engine:sample() * 32767 + end + end + return result + end + for index = offset, last do if channels == 2 then local left, right = engine:sampleStereo() result:setSample(index, 1, left) @@ -1424,6 +1480,34 @@ local function soundData(engine, samples, channels) return result end +local function newBuffer(samples, channels) + return love.sound.newSoundData(samples, SAMPLE_RATE, 16, channels) +end + +-- render `samples` frames into a fresh SoundData (mono or stereo). love.sound +-- is available on worker threads, so this is the hand-off unit the worker +-- produces and the main thread queues. +local function soundData(engine, samples, channels) + local result = newBuffer(samples, channels) + return renderInto(engine, result, 0, samples, channels) +end + +-- reusable int16 scratch for renderEffectData's bulk path: the effect length +-- is only known once the engine finishes, so samples are converted as they +-- are produced and copied (duplicated to both channels) at the end, instead +-- of growing a Lua table of doubles per render +local scratch, scratchSize = nil, 0 + +local function ensureScratch(size) + if scratchSize >= size then return scratch end + local grown = math.max(size, scratchSize * 2, 4096) + local fresh = ffi.new("int16_t[?]", grown) + if scratch and scratchSize > 0 then + ffi.copy(fresh, scratch, scratchSize * 2) + end + scratch, scratchSize = fresh, grown + return scratch +end local function renderEffectData(data, header, options) if not header then return nil end @@ -1433,14 +1517,38 @@ local function renderEffectData(data, header, options) local engine = Engine.new(data, header, options) -- audio/sfx/pokeflute.asm:20 local maximum = SAMPLE_RATE * (options.maxSeconds or 12) + local minimum = math.floor(SAMPLE_RATE / 100) + if bulkAvailable() then + local values = ensureScratch(SAMPLE_RATE) + local count = 0 + while count < maximum and not engine:finished() do + if count >= scratchSize then values = ensureScratch(count + 1) end + values[count] = engine:sample() * 32767 + count = count + 1 + end + if count < minimum then return nil end + local result = newBuffer(count, 2) + local pointer = int16Pointer(result) + if pointer then + for index = 0, count - 1 do + local value = values[index] + pointer[index * 2] = value + pointer[index * 2 + 1] = value + end + return result + end + -- the probe said yes but this buffer has no pointer: re-render through + -- setSample below (truncated int16s cannot be turned back into floats) + engine = Engine.new(data, header, options) + end local values = {} local count = 0 while count < maximum and not engine:finished() do count = count + 1 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, 2) + if count < minimum then return nil end + local result = newBuffer(count, 2) for index = 1, count do local value = values[index] result:setSample(index - 1, 1, value) @@ -1451,6 +1559,8 @@ end ChipSynth.newEngine = Engine.new ChipSynth.soundData = soundData +ChipSynth.renderInto = renderInto +ChipSynth.newBuffer = newBuffer ChipSynth.renderEffectData = renderEffectData function ChipSynth.applyStereo(engine) diff --git a/src/core/Game.lua b/src/core/Game.lua index 237042c7..91f1ba95 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -309,6 +309,10 @@ function Game:breakLink(err, source) end function Game:step(dt) + -- A monotonic count of fixed logic steps, for presentation counters that + -- are sampled from draw code but must advance at the 60Hz step rate + -- rather than the display refresh (Player:pose's surf bob). + Game.logicStep = (Game.logicStep or 0) + 1 -- Tool mods (autoplay, accessibility drivers, input visualizers) act on -- the same fixed-step boundary as a physical controller. Run them before -- Input:step promotes queued edges so a button chosen here is visible to @@ -413,6 +417,13 @@ function Game:logicSpeed() resolveLogicSpeedVanilla, self)) end +-- Discord Rich Presence tick, pcall'd by Game:update: a named function so +-- the per-frame call builds no closure (the require stays inside the pcall, +-- so a missing/broken module still soft-fails exactly as before) +local function discordPresenceUpdate(dt) + require("src.core.DiscordPresence").update(dt) +end + function Game:update(dt) -- Fast-forward scales only the logic clock (see src/core/GameSpeed.lua). -- Give the accumulator room for one full frame at the current speed, @@ -437,7 +448,7 @@ function Game:update(dt) -- mod render pipelines tween on the same real-frame clock, for the same -- reason: they are presentational, so fast-forward must not speed them up require("src.render.Pipelines").update(dt) - pcall(function() require("src.core.DiscordPresence").update(dt) end) + pcall(discordPresenceUpdate, dt) self:updateSync(dt) -- Steady-state memory backstop: advance the incremental collector one -- small step every few rendered frames. The heavy GPU objects are now diff --git a/src/core/Game2.lua b/src/core/Game2.lua index 60be24c1..43230876 100644 --- a/src/core/Game2.lua +++ b/src/core/Game2.lua @@ -55,6 +55,36 @@ local SaveData = require("src.core.SaveData") -- had when they required the module by hand. local Screens = require("src.ui.Screens") +-- Already loaded by src/world/gen2/World.lua above; bound here so the +-- per-frame update/draw paths do not call require() every frame. +local GbcPalette = require("src.render.GbcPalette") +local Pipelines = require("src.render.Pipelines") +local Tilt = require("src.render.Tilt") + +-- Resolved on first use rather than at load, keeping this file's load order. +local lazyModules = {} +local function lazy(name) + local mod = lazyModules[name] + if mod == nil then + mod = require(name) + lazyModules[name] = mod + end + return mod +end + +-- DiscordPresence is optional: a require that fails once is not retried +-- every frame, and no closure is minted per frame to guard the call. +local discordPresence = nil +local function discordUpdate(dt) + if discordPresence == nil then + local ok, mod = pcall(require, "src.core.DiscordPresence") + discordPresence = (ok and type(mod) == "table") and mod or false + end + if discordPresence and discordPresence.update then + pcall(discordPresence.update, dt) + end +end + local Game2 = {} Game2.__index = Game2 @@ -1254,6 +1284,11 @@ function Game2:load(opts) self:softReset() return end + -- LoadPoisonBGPals' four-frame hold and the void-fill dissolve are spent + -- here, on the logic clock and whatever is on top (the faint text can + -- open the same frame), rather than once per render frame in World:draw. + local world = self.world + if world and world.tickFrameClocks then world:tickFrameClocks() end -- Not the audio tick: _UpdateSound runs once per frame off VBlank -- (audio/engine.asm:84, home/vblank.asm:141-143), never off the logic clock. local top = self.stack:top() @@ -1340,12 +1375,12 @@ function Game2:update(dt) end -- TILT eases toward its new angle in real time, not on the logic clock, so -- fast-forward does not fling the camera over. - require("src.render.Tilt").update(dt) + Tilt.update(dt) -- Mod render pipelines tween on the same real-frame clock, for the same -- reason and at the same place Gen 1 ticks them (src/core/Game.lua:265): -- they are presentational, so fast-forward must not speed them up. - require("src.render.Pipelines").update(dt) - pcall(function() require("src.core.DiscordPresence").update(dt) end) + Pipelines.update(dt) + discordUpdate(dt) -- GAME SPEED scales the logic clock only, exactly as the Gen 1 path does: -- audio runs off its own real-time accumulator, so music and sfx keep their -- tempo at every multiplier (#1990/#1991/#1997). speedOverride is the @@ -1488,7 +1523,6 @@ end -- present pass has always been. function Game2:blitZones(canvas, zones, w, h) local G = love.graphics - local GbcPalette = require("src.render.GbcPalette") local px, py, pw, ph = Playfield.rect(w, h) local sx, sy = pw / 160, ph / 144 G.setColor(1, 1, 1, 1) @@ -1599,7 +1633,7 @@ end -- the grid itself. function Game2:drawViewportFrame() local G = love.graphics - local serial = require("src.core.FaithfulRes").modeSerial + local serial = lazy("src.core.FaithfulRes").modeSerial if serial ~= (self.modeSerial or 0) then if self.world and self.world.map then self.world:dropBakes() @@ -1607,9 +1641,7 @@ function Game2:drawViewportFrame() self.modeSerial = serial end local w, h = GameViewport.dimensions() - local ShaderFX = require("src.render.ShaderFX") - local GbcPalette = require("src.render.GbcPalette") - local Pipelines = require("src.render.Pipelines") + local ShaderFX = lazy("src.render.ShaderFX") -- Same dispatch src/render/Renderer.lua:1185 already uses for Gen 1 -- (ShaderFX replaced GBCFX's slot; GBCFX.lua itself is removed). local shaderfx = ShaderFX.active() @@ -1626,10 +1658,24 @@ function Game2:drawViewportFrame() -- lighting mod written against Gen 1 tints Gold through the same seam. local zones = nil local classic = GbcPalette.available() and GbcPalette.presentColors() or nil + local zoneHook = ModRuntime.wantsHook("render.zones") if classic then - zones = { { x = 0, y = 0, w = 160, h = 144, colors = classic } } + if zoneHook or ModRuntime.wantsHook("render.compose") then + -- A mod may keep or edit the list it is handed: give it a fresh one. + zones = { { x = 0, y = 0, w = 160, h = 144, colors = classic } } + else + -- Nobody else sees it, so the one list is reused frame to frame. + local zone = self._classicZone + if not zone then + zone = { x = 0, y = 0, w = 160, h = 144 } + self._classicZone = zone + self._classicZones = { zone } + end + zone.colors = classic + zones = self._classicZones + end end - if ModRuntime.wantsHook("render.zones") then + if zoneHook then zones = ModRuntime.call("render.zones", sameZones, self, zones) end local zoned = type(zones) == "table" and zones[1] ~= nil diff --git a/src/core/Input.lua b/src/core/Input.lua index 17d78872..88c5acd8 100644 --- a/src/core/Input.lua +++ b/src/core/Input.lua @@ -331,6 +331,11 @@ local function anyPadDown(pads, button) return false end +-- a joystick was added or removed: rebuild pollPads' list on the next poll +function Input:joysticksChanged() + self._pollPads = nil +end + -- pokefirered/src/main.c:296 function Input:pollPads() local js = love and love.joystick @@ -341,7 +346,24 @@ function Input:pollPads() return end local pads = self._pollPads - if not pads or self._pollPadCount ~= count then + -- The count alone cannot see one controller swapped for another between + -- two polls; a cached pad that has gone away forces the rebuild too (and + -- main.lua's joystickadded/removed drop the cache via + -- Input:joysticksChanged). + local stale = not pads or self._pollPadCount ~= count + if not stale then + for k = 1, #pads do + local j = pads[k] + if j.isConnected then + local okConn, connected = pcall(j.isConnected, j) + if okConn and not connected then + stale = true + break + end + end + end + end + if stale then pads = {} local ok, list = pcall(js.getJoysticks) if ok and type(list) == "table" then @@ -390,9 +412,24 @@ function Input:pollPads() end end +-- step's two per-step tables are recycled rather than reallocated, but only +-- while they are still the ones step itself installed: a test or driver that +-- assigns its own `pressed` / `pressQueue` gets a fresh table afterwards, +-- exactly as before, and never sees its own table emptied. +local function ownedCleared(self, field, ownKey) + local own = self[ownKey] + if own ~= nil and self[field] == own then + for k in pairs(own) do own[k] = nil end + else + own = {} + self[ownKey] = own + end + self[field] = own +end + function Input:step() self:pollPads() - self.pressed = {} + ownedCleared(self, "pressed", "_ownPressed") for _, btn in ipairs(self.pressQueue) do self.pressed[btn] = true local sources = self.sources[btn] @@ -409,7 +446,7 @@ function Input:step() self.sources[btn] = nil end end - self.pressQueue = {} + ownedCleared(self, "pressQueue", "_ownPressQueue") -- pokefirered/src/main.c:325 local aliases = self.aliases local held = nil diff --git a/src/core/PlatformHooks.lua b/src/core/PlatformHooks.lua index 77bf83f5..bf8050ac 100644 --- a/src/core/PlatformHooks.lua +++ b/src/core/PlatformHooks.lua @@ -7,8 +7,11 @@ local ModRuntime = require("src.mods.Runtime") local PlatformHooks = {} +-- hoisted: update runs every frame, and the vanilla step closes over nothing +local function vanillaUpdate(g, d) g:update(d) end + function PlatformHooks.update(game, dt) - return ModRuntime.call("core.update", function(g, d) g:update(d) end, game, dt) + return ModRuntime.call("core.update", vanillaUpdate, game, dt) end function PlatformHooks.quitToLauncher(vanilla) diff --git a/src/core/Sound.lua b/src/core/Sound.lua index 4ffbcef5..7de89337 100644 --- a/src/core/Sound.lua +++ b/src/core/Sound.lua @@ -827,6 +827,45 @@ function Sound.playCry(data, species, pikaClip) return src end +-- Prewarm: the first play of a chip cry/SFX synthesizes it on the calling +-- frame (~10-25 ms for a cry, far more for a long jingle), which is the hitch +-- at a battle's first cry. These hand that render to the chip audio worker +-- ahead of time (ChipAudio.prewarmCry/prewarmSfx); the play that follows +-- builds its Source from the finished PCM, identical to a fresh render. Call +-- at an idle point before the sound is needed (battle creation / the +-- transition, for both species). Returns true when a render was queued or +-- the sound is already cached; never renders on the calling thread, and a +-- play that comes first simply renders as before. +function Sound.prewarmCry(data, species) + if not love.audio or deviceSuspended() then return false end + if cache["cry:" .. tostring(species)] ~= nil then return true end + local audio = data and data.audio + local def = audio and audio.cries and audio.cries[species] + if not def then return false end + -- pokeyellow/home/pokemon.asm:140 -- voiced by a PCM clip instead + if species == "PIKACHU" and audio.pikaCries then return false end + local resolved = resolveCry(data, def, 0) + if type(resolved) ~= "table" or not (resolved.header or resolved.chip) then + return false + end + local ok, queued = pcall( + require("src.core.ChipAudio").prewarmCry, data, species, resolved) + return ok and queued or false +end + +-- the plain Sound.play variant of `name` (no pitch/tempo modifiers) +function Sound.prewarmSfx(data, name) + if not love.audio or deviceSuspended() then return false end + name = Sound.resolve(data, name) + if cache[name] ~= nil then return true end + local sfx = data and data.audio and data.audio.sfx + local def = sfx and sfx[name] + if not isChipDef(def) then return false end + local ok, queued = pcall( + require("src.core.ChipAudio").prewarmSfx, data, name, nil, nil, def, nil) + return ok and queued or false +end + -- GROWL/ROAR are the only two moves that play a cry (IsCryMove checks -- wAnimationID); GetMoveSound still adds their own MoveSoundTable pitch/ -- tempo bytes on top of the cry's species modifiers before the tempo diff --git a/src/core/chip_worker.lua b/src/core/chip_worker.lua index 8e902e1e..f2219426 100644 --- a/src/core/chip_worker.lua +++ b/src/core/chip_worker.lua @@ -11,6 +11,11 @@ -- cmd = "channelMix" { volumes, pitches, stereo?, stereoEpoch? } -- stereoEpoch present: live SOUND toggle; drop lookahead -- cmd = "invalidate" drop the bank cache +-- cmd = "effect" { key, epoch, header, options, audio, channelVolumes, +-- channelPitches, stereo, sampleRate } +-- prewarm one SFX/cry (ChipSynth.renderEffectData); the +-- result goes to "chipaudio_fx" as +-- { key, epoch, sd = SoundData|nil } or { key, epoch, error } -- cmd = "quit" end the thread -- out buffers are tagged with the play's `gen` so the main thread can -- discard anything left over from a superseded song: @@ -37,6 +42,7 @@ local ChipSynth = assert(love.filesystem.load("src/core/ChipSynth.lua"))() local cmdCh = love.thread.getChannel("chipaudio_cmd") local outCh = love.thread.getChannel("chipaudio_out") +local fxCh = love.thread.getChannel("chipaudio_fx") local BUF = ChipSynth.MUSIC_BUFFER_SAMPLES local BUF_SECONDS = BUF / ChipSynth.SAMPLE_RATE @@ -50,10 +56,37 @@ local engine = nil -- the ChipSynth engine producing the current song local finished = false -- the current song ran out (non-looping) local data = nil -- { audio = } for ROM bank/wave reads local stereoEpoch = 0 -- matches ChipAudio; stale pan buffers are dropped +local effects = {} -- queued "effect" prewarm requests, oldest first +local produced = 0 -- buffers rendered for the current song so far + +-- a song start outranks a prewarm: the first MUSIC_PREROLL buffers gate when +-- the song is heard, a prewarmed cry only has to be done before its play +local PREROLL = 4 + +local function renderEffect(req) + if req.sampleRate ~= nil then + BUF_SECONDS = BUF / ChipSynth.setSampleRate(req.sampleRate) + end + if req.channelVolumes ~= nil then + ChipSynth.setChannelVolumes(req.channelVolumes) + end + if req.channelPitches ~= nil then + ChipSynth.setChannelPitches(req.channelPitches) + end + if req.stereo ~= nil then ChipSynth.setStereo(req.stereo) end + local ok, sd = pcall(ChipSynth.renderEffectData, { audio = req.audio }, + req.header, req.options or {}) + if ok then + fxCh:push({ key = req.key, epoch = req.epoch, sd = sd or nil }) + else + fxCh:push({ key = req.key, epoch = req.epoch, error = tostring(sd) }) + end +end local function handle(cmd) if cmd.cmd == "play" then gen = cmd.gen + produced = 0 finished = false engine = nil outCh:clear() -- drop any buffers left from the previous song @@ -95,6 +128,9 @@ local function handle(cmd) end elseif cmd.cmd == "invalidate" then ChipSynth.invalidateBanks() + effects = {} + elseif cmd.cmd == "effect" then + effects[#effects + 1] = cmd elseif cmd.cmd == "quit" then return true end @@ -113,7 +149,12 @@ while true do end if quit then break end - if engine and not finished and gen and outCh:getCount() < LOOKAHEAD then + local musicWants = engine and not finished and gen + and outCh:getCount() < LOOKAHEAD + if #effects > 0 and not (musicWants and produced < PREROLL) then + idleWait = false + renderEffect(table.remove(effects, 1)) + elseif musicWants then idleWait = false local activeGen = gen local began = love.timer.getTime() @@ -123,6 +164,7 @@ while true do stereoEpoch = stereoEpoch, jit = jitEnabled }) finished = true else + produced = produced + 1 outCh:push({ gen = activeGen, sd = sd, stereoEpoch = stereoEpoch, jit = jitEnabled, xrt = (love.timer.getTime() - began) / BUF_SECONDS }) diff --git a/src/core/game3/audio.lua b/src/core/game3/audio.lua index e4a687fa..26035ed4 100644 --- a/src/core/game3/audio.lua +++ b/src/core/game3/audio.lua @@ -803,6 +803,7 @@ function Audio._seRawClear() Audio._seRaw = {} Audio._seRawFrames = 0 Audio._seRawTick = 0 + if Audio._seSourceClear then Audio._seSourceClear() end end function Audio._seRawGet(id) @@ -835,6 +836,72 @@ function Audio._seRawPut(id, loop, rawL, rawR, loopStart) end end +-- Static Sources for one-shot / looping SEs, keyed by everything baked into +-- their PCM (id, pan, master gain, mono, loop). A repeat SE (wall bump, ball +-- placement, menu cursor) used to rebuild its SoundData sample by sample and +-- upload a new Source on every play; now a cached Source is rewound and +-- replayed, or cloned (shares the uploaded buffer) while the cached one is +-- still live on another player slot. Entries are tied to the memoized raw +-- PCM tables, so an evicted/rebaked SE never reuses a stale Source. +Audio.SE_SOURCE_CACHE_MAX = 32 + +function Audio._seSourceClear() + Audio._seSrcCache = {} + Audio._seSrcCount = 0 + Audio._seSrcTick = 0 +end + +function Audio._seSourceFor(id, rawL, rawR, master, pan, mono, loop) + if not (love and love.audio and love.audio.newSource) then return nil end + local hit = Audio._seRaw and Audio._seRaw[id] + local cacheable = hit and hit.rawL == rawL and hit.rawR == rawR + local key = cacheable and table.concat({ tostring(id), tostring(pan), + tostring(master), mono and "m" or "s", loop and "l" or "o" }, ":") + if not Audio._seSrcCache then Audio._seSourceClear() end + local entry = key and Audio._seSrcCache[key] + if entry and entry.rawL == rawL then + Audio._seSrcTick = Audio._seSrcTick + 1 + entry.tick = Audio._seSrcTick + local src = entry.src + if Audio._seMeta[src] then + -- still tracked as a live SE: play a copy rather than cut it off + local ok, copy = pcall(function() return src:clone() end) + src = ok and copy or nil + end + if src then + pcall(function() src:stop() end) + if loop then pcall(function() src:setLooping(true) end) end + return src + end + key = nil -- no clone support: build a one-off, keep the cached entry + end + local sd = Audio._buildSeSoundData(rawL, rawR, master, pan, mono) + if not sd then return nil end + local src = love.audio.newSource(sd, "static") + if loop then + pcall(function() src:setLooping(true) end) + end + if key then + if not Audio._seSrcCache[key] then + Audio._seSrcCount = Audio._seSrcCount + 1 + end + Audio._seSrcTick = Audio._seSrcTick + 1 + Audio._seSrcCache[key] = { src = src, rawL = rawL, tick = Audio._seSrcTick } + while Audio._seSrcCount > Audio.SE_SOURCE_CACHE_MAX do + local oldKey, oldTick + for k, e in pairs(Audio._seSrcCache) do + if k ~= key and (oldTick == nil or e.tick < oldTick) then + oldKey, oldTick = k, e.tick + end + end + if oldKey == nil then break end + Audio._seSrcCache[oldKey] = nil + Audio._seSrcCount = Audio._seSrcCount - 1 + end + end + return src +end + function Audio.playSe(id, opts) opts = opts or {} id = SE.resolve(id) @@ -898,12 +965,8 @@ function Audio.playSe(id, opts) if loop and loopStart and love and love.audio and love.audio.newQueueableSource then src, body = Audio._newIntroLoopSource(rawL, rawR, loopStart, master, pan, Audio._mono) end - local sd = not src and Audio._buildSeSoundData(rawL, rawR, master, pan, Audio._mono) - if sd and love and love.audio and love.audio.newSource then - src = love.audio.newSource(sd, "static") - if loop then - pcall(function() src:setLooping(true) end) - end + if not src then + src = Audio._seSourceFor(id, rawL, rawR, master, pan, Audio._mono, loop) end if src then src:setVolume(1) diff --git a/src/core/game3/bag.lua b/src/core/game3/bag.lua index 14b91f1e..331f7580 100644 --- a/src/core/game3/bag.lua +++ b/src/core/game3/bag.lua @@ -517,12 +517,34 @@ function Bag.set(bag, id, qty) return Bag.get(bag, id) end +-- listPocket rows per bag and pocket, rebuilt only when the pocket's +-- (id, qty) slots or the loaded item pack change. Bag menus call listPocket +-- every frame; each row costs three ItemsData.info lookups. +local rowCache = setmetatable({}, { __mode = "k" }) + +local function rows_match(entry, slots, byId) + if entry.byId ~= byId then return false end + local ids, qtys, n = entry.ids, entry.qtys, entry.n + for i = 1, n do + local slot = slots[i] + if slot == nil or slot.id ~= ids[i] or slot.qty ~= qtys[i] then return false end + end + return slots[n + 1] == nil +end + --- Ordered list of { id, qty, name, info } for a pocket (bag UI). +-- The returned rows are shared between calls until the pocket changes, so +-- callers must not modify them. function Bag.listPocket(bag, pocket) bag = ensure(bag) pocket = pocket or "ITEMS" + local slots = bag.pockets[pocket] or {} + local byId = ItemsData._byId + local perBag = rowCache[bag] + local entry = perBag and perBag[pocket] + if entry and byId and rows_match(entry, slots, byId) then return entry.rows end local rows = {} - for _, slot in ipairs(bag.pockets[pocket] or {}) do + for _, slot in ipairs(slots) do local qty = tonumber(slot.qty) or 0 if slot.id and qty > 0 then rows[#rows + 1] = { @@ -534,6 +556,20 @@ function Bag.listPocket(bag, pocket) } end end + byId = ItemsData._byId + if byId then + local n = 0 + local ids, qtys = {}, {} + for i, slot in ipairs(slots) do + n = i + ids[i], qtys[i] = slot.id, slot.qty + end + if not perBag then + perBag = {} + rowCache[bag] = perBag + end + perBag[pocket] = { byId = byId, n = n, ids = ids, qtys = qtys, rows = rows } + end return rows end diff --git a/src/core/game3/battle/anim.lua b/src/core/game3/battle/anim.lua index 2b8c05b0..e0d14a55 100644 --- a/src/core/game3/battle/anim.lua +++ b/src/core/game3/battle/anim.lua @@ -1066,4 +1066,16 @@ function Anim.drawParticles(minZ, maxZ) if Anim._vm then Anim._vm:draw(minZ, maxZ) end end +--- Bracket one frame's z-band drawParticles calls so the VM sorts its +--- sprites once per frame instead of once per band. +function Anim.beginParticleFrame() + local vm = Anim._vm + if vm and vm.beginDrawFrame then vm:beginDrawFrame() end +end + +function Anim.endParticleFrame() + local vm = Anim._vm + if vm and vm.endDrawFrame then vm:endDrawFrame() end +end + return Anim diff --git a/src/core/game3/battle/anim_pal.lua b/src/core/game3/battle/anim_pal.lua index d53f5a71..ce1a6829 100644 --- a/src/core/game3/battle/anim_pal.lua +++ b/src/core/game3/battle/anim_pal.lua @@ -17,9 +17,17 @@ AnimPal.loaded = {} AnimPal.idxOf = setmetatable({}, { __mode = "k" }) AnimPal._pack = nil +-- Normalised tags by raw tag (strings and numbers only); tags come from a +-- fixed set, and norm runs for every sprite drawn. +local _normCache = {} local function norm(tag) if tag == nil then return nil end - return (tostring(tag):upper():gsub("^ANIM_TAG_", "")) + local hit = _normCache[tag] + if hit then return hit end + local out = (tostring(tag):upper():gsub("^ANIM_TAG_", "")) + local t = type(tag) + if t == "string" or t == "number" then _normCache[tag] = out end + return out end AnimPal.norm = norm diff --git a/src/core/game3/battle/anim_port/g2_pret.lua b/src/core/game3/battle/anim_port/g2_pret.lua index a91dc011..e722d72d 100644 --- a/src/core/game3/battle/anim_port/g2_pret.lua +++ b/src/core/game3/battle/anim_port/g2_pret.lua @@ -745,7 +745,14 @@ P.tagInfo = tag_info local function quad_for(img, sx, sy, w, h) local iw, ih = img:getDimensions() - local key = sx .. ":" .. sy .. ":" .. w .. ":" .. h + -- Numeric key for whole-pixel rects (13+13+10+10 bits), string otherwise. + local key + if sx >= 0 and sx < 8192 and sy >= 0 and sy < 8192 and w >= 0 and w < 1024 and h >= 0 and h < 1024 + and sx % 1 == 0 and sy % 1 == 0 and w % 1 == 0 and h % 1 == 0 then + key = ((sx * 8192 + sy) * 1024 + w) * 1024 + h + else + key = sx .. ":" .. sy .. ":" .. w .. ":" .. h + end local c = SHEET_CACHE[img] if not c then c = {}; SHEET_CACHE[img] = c end local q = c[key] diff --git a/src/core/game3/battle/anim_port/g3_pret.lua b/src/core/game3/battle/anim_port/g3_pret.lua index f9619bb9..a519abe4 100644 --- a/src/core/game3/battle/anim_port/g3_pret.lua +++ b/src/core/game3/battle/anim_port/g3_pret.lua @@ -1416,13 +1416,22 @@ local function overlayResources() return overlayRes end +-- One quad re-pointed per draw (draw reads the viewport immediately). +local windowSpriteQuad = nil + local function drawWindowSprite(sp) local img = sp.image if not img or sp.invisible or not sp.active then return end local bw = sp._baseW or sp.w or 32 local bh = sp._baseH or sp.h or 32 local iw, ih = img:getDimensions() - local q = love.graphics.newQuad(sp.quadX or 0, sp.quadY or 0, bw, bh, iw, ih) + local q = windowSpriteQuad + if q then + q:setViewport(sp.quadX or 0, sp.quadY or 0, bw, bh, iw, ih) + else + q = love.graphics.newQuad(sp.quadX or 0, sp.quadY or 0, bw, bh, iw, ih) + windowSpriteQuad = q + end local sx = (sp.scaleX or 1) * (sp.hFlip and -1 or 1) local sy = (sp.scaleY or 1) * (sp.vFlip and -1 or 1) love.graphics.draw(img, q, math.floor(sp.x + (sp.ox or 0) + 0.5), math.floor(sp.y + (sp.oy or 0) + 0.5), diff --git a/src/core/game3/battle/anim_vm.lua b/src/core/game3/battle/anim_vm.lua index 05188ed5..0a2967b0 100644 --- a/src/core/game3/battle/anim_vm.lua +++ b/src/core/game3/battle/anim_vm.lua @@ -613,6 +613,26 @@ local function effective_z(vm, s) return AnimCoords.layerZ(sub, vm._monbg, vm._bgPrio) end +-- Last uniforms sent to the shared blend shader. Every send goes through +-- send_blend so the cache always matches what the shader holds (the anim bg +-- and sprites share one shader). +local _lastBlendShader = nil +local _lastBlendCoeff = nil +local _lastBlendR = nil +local _lastBlendG = nil +local _lastBlendB = nil + +local function send_blend(sh, coeff, r, g, b) + if sh ~= _lastBlendShader or coeff ~= _lastBlendCoeff or r ~= _lastBlendR + or g ~= _lastBlendG or b ~= _lastBlendB then + _lastBlendShader = sh + _lastBlendCoeff, _lastBlendR, _lastBlendG, _lastBlendB = coeff, r, g, b + pcall(sh.send, sh, "coeff", coeff) + pcall(sh.send, sh, "target", { r, g, b }) + end +end +AnimVm._sendBlend = send_blend + local function draw_anim_bg(vm) local id = vm._animBgId local tintBg = vm._animBgBlend @@ -638,10 +658,7 @@ local function draw_anim_bg(vm) local sh = tint and (tint.coeff or 0) > 0 and blend_shader() if sh then local c = tonumber(tint.color) or 0 - pcall(function() - sh:send("coeff", tint.coeff) - sh:send("target", { band(c, 31), band(rshift(c, 5), 31), band(rshift(c, 10), 31) }) - end) + send_blend(sh, tint.coeff, band(c, 31), band(rshift(c, 5), 31), band(rshift(c, 10), 31)) love.graphics.setShader(sh) end love.graphics.draw(img, vm._bgQuad, 0, 0) @@ -657,11 +674,6 @@ local function draw_anim_bg(vm) love.graphics.setColor(1, 1, 1, 1) end -local _lastBlendCoeff = nil -local _lastBlendR = nil -local _lastBlendG = nil -local _lastBlendB = nil - local function particle_sort_cmp(a, b) if a._drawZ ~= b._drawZ then return a._drawZ < b._drawZ end return (a._poolIndex or 0) > (b._poolIndex or 0) @@ -694,12 +706,7 @@ local function draw_sprite(self, s, a) local sh = (not pimg) and tint and blend_shader() if sh then local r, g, b = band(tint.color, 31), band(rshift(tint.color, 5), 31), band(rshift(tint.color, 10), 31) - local coeff = tint.coeff - if coeff ~= _lastBlendCoeff or r ~= _lastBlendR or g ~= _lastBlendG or b ~= _lastBlendB then - _lastBlendCoeff, _lastBlendR, _lastBlendG, _lastBlendB = coeff, r, g, b - pcall(sh.send, sh, "coeff", coeff) - pcall(sh.send, sh, "target", { r, g, b }) - end + send_blend(sh, tint.coeff, r, g, b) love.graphics.setShader(sh) end local q = sprite_source_quad(s, bw, bh) @@ -712,44 +719,115 @@ local function draw_sprite(self, s, a) end end +-- Blend mode currently set by AnimVm:draw (reset to "alpha" per call). +local _activeBlend = "alpha" +local function set_blend_mode(mode) + if mode ~= _activeBlend then + if mode == "add" then + love.graphics.setBlendMode("add", "alphamultiply") + else + love.graphics.setBlendMode("alpha", "alphamultiply") + end + _activeBlend = mode + end +end + +-- Every active, visible sprite with its effective z, sorted once. The sort +-- is a strict total order (z, then pool index), so slicing this list by a z +-- band gives the same order as sorting that band on its own. +local function build_sorted_sprites(self, list) + for i = #list, 1, -1 do list[i] = nil end + AnimSprites.init() + local pool = AnimSprites._pool + local n = 0 + for i = 1, AnimSprites.MAX do + local s = pool[i] + s._poolIndex = i + if s.active and s.visible ~= false then + s._drawZ = effective_z(self, s) + n = n + 1 + list[n] = s + end + end + if n > 1 then table.sort(list, particle_sort_cmp) end + return list +end + +local function any_task_draw() + if not (AnimTasks and AnimTasks._pool) then return false end + local pool = AnimTasks._pool + for i = 1, AnimTasks.MAX do + local t = pool[i] + if t and t.active and t.draw then return true end + end + return false +end + +--- Bracket the z-band draws of one frame (battle ui). Between begin and end +--- the sorted sprite list and the task scan are built once and reused by +--- every AnimVm:draw band call; outside a bracket each call rebuilds them. +function AnimVm:beginDrawFrame() + self._inDrawFrame = true + self._frameSorted = false + self._frameTasks = nil +end + +function AnimVm:endDrawFrame() + self._inDrawFrame = false + self._frameSorted = false + self._frameTasks = nil +end + function AnimVm:draw(minZ, maxZ) if not (love and love.graphics) then return end if (minZ or 0) <= 0 then draw_anim_bg(self) end if AnimTasks and AnimTasks.draw then - AnimTasks.draw(minZ, maxZ, self) + local hasTasks = true + if self._inDrawFrame then + if self._frameTasks == nil then self._frameTasks = any_task_draw() end + hasTasks = self._frameTasks + end + if hasTasks then AnimTasks.draw(minZ, maxZ, self) end end - local list = self._drawList - for i = #list, 1, -1 do list[i] = nil end - AnimSprites.init() - for i = 1, AnimSprites.MAX do - local s = AnimSprites._pool[i] - s._poolIndex = i - if s.active and s.visible ~= false then - local z = effective_z(self, s) + local sorted = self._drawList + if not (self._inDrawFrame and self._frameSorted) then + build_sorted_sprites(self, sorted) + self._frameSorted = self._inDrawFrame and true or false + end + if #sorted == 0 then + love.graphics.setColor(1, 1, 1, 1) + return + end + local list = sorted + if minZ or maxZ then + list = self._bandList + if not list then + list = {} + self._bandList = list + end + for i = #list, 1, -1 do list[i] = nil end + local n = 0 + for i = 1, #sorted do + local s = sorted[i] + local z = s._drawZ if (not minZ or z >= minZ) and (not maxZ or z <= maxZ) then - s._drawZ = z - list[#list + 1] = s + n = n + 1 + list[n] = s end end + if n == 0 then + love.graphics.setColor(1, 1, 1, 1) + return + end end - table.sort(list, particle_sort_cmp) - local activeBlend = "alpha" + _activeBlend = "alpha" local bld = self.bldAlpha - local function setMode(mode) - if mode ~= activeBlend then - if mode == "add" then - love.graphics.setBlendMode("add", "alphamultiply") - else - love.graphics.setBlendMode("alpha", "alphamultiply") - end - activeBlend = mode - end - end - for _, s in ipairs(list) do + for i = 1, #list do + local s = list[i] local a = s.alpha or 1 local eva, evb = nil, nil if s.objBlend and bld then @@ -758,12 +836,12 @@ function AnimVm:draw(minZ, maxZ) end if eva and eva + evb ~= 16 and s.image and AnimPal.indexImage(s.image) then -- pokefirered/src/battle_anim.c:630 - setMode("alpha") + set_blend_mode("alpha") AnimPal.blackPass = true s._drawAlpha = a * (1 - evb / 16) draw_sprite(self, s, s._drawAlpha) AnimPal.blackPass = nil - setMode("add") + set_blend_mode("add") s._drawAlpha = a * eva / 16 draw_sprite(self, s, s._drawAlpha) else @@ -772,14 +850,15 @@ function AnimVm:draw(minZ, maxZ) if evb >= 16 and eva < 16 then desiredBlend = "add" end a = a * eva / 16 end - setMode(desiredBlend) + set_blend_mode(desiredBlend) s._drawAlpha = a draw_sprite(self, s, a) end end - if activeBlend ~= "alpha" then + if _activeBlend ~= "alpha" then love.graphics.setBlendMode("alpha", "alphamultiply") + _activeBlend = "alpha" end love.graphics.setColor(1, 1, 1, 1) end diff --git a/src/core/game3/battle/healthbox.lua b/src/core/game3/battle/healthbox.lua index 648c09d6..ff2bf5a1 100644 --- a/src/core/game3/battle/healthbox.lua +++ b/src/core/game3/battle/healthbox.lua @@ -136,8 +136,17 @@ local function display_hp_nums(side, battler) return math.floor(hp), math.floor(maxHp) end +-- One read-only FrlgFont opts table per colour set (FrlgFont never writes +-- to opts), so per-frame healthbox text does not allocate. +local _smallOpts = setmetatable({}, { __mode = "k" }) local function small_opts(colors) - return { small = true, colors = colors or HB_TEXT } + colors = colors or HB_TEXT + local o = _smallOpts[colors] + if not o then + o = { small = true, colors = colors } + _smallOpts[colors] = o + end + return o end local function erase_placeholder_ink(boxX, boxY, pts) diff --git a/src/core/game3/battle/ui.lua b/src/core/game3/battle/ui.lua index ea40e583..51457039 100644 --- a/src/core/game3/battle/ui.lua +++ b/src/core/game3/battle/ui.lua @@ -2130,6 +2130,19 @@ local function rse_window_colors(fgIdx, shadowIdx) } end +-- The default (13, 15) window colours, rebuilt only when the manifest's +-- palette table changes. Callers must not modify the returned table +-- (rse_pp_colors, which does, builds its own with rse_window_colors). +local _defaultWinColors, _defaultWinPal = nil, nil +local function rse_default_colors() + local pal = BattleChrome.manifest().windowTextPal or nil + if not _defaultWinColors or pal ~= _defaultWinPal then + _defaultWinColors = rse_window_colors() + _defaultWinPal = pal + end + return _defaultWinColors +end + -- pokeemerald/src/battle_message.c:3033 local function rse_pp_colors(pp, maxPp) local pp2 = BattleChrome.manifest().ppTextPal or {} @@ -2147,7 +2160,7 @@ local function rse_text(win, text, dx, opts) local useNarrow = opts.narrow == nil and narrow or opts.narrow F.draw(tostring(text or ""), x + (dx or 0), y, { font = useNarrow and "narrow" or nil, - colors = opts.colors or rse_window_colors(), + colors = opts.colors or rse_default_colors(), }) end @@ -2161,10 +2174,10 @@ local function draw_action_menu_rse(st) local mx, my = BattleChrome.textOrigin(W.ACTION_MENU) local c = Ui._menuIndex - 1 local col, row = c % 2, math.floor(c / 2) - Window.cursorPx(8 * (7 * col + 16), my + 16 * row, { colors = rse_window_colors() }) + Window.cursorPx(8 * (7 * col + 16), my + 16 * row, { colors = rse_default_colors() }) for i = 1, 4 do local cc, rr = (i - 1) % 2, math.floor((i - 1) / 2) - battle_font().draw(tostring(labels[i] or ""), mx + 56 * cc, my + 16 * rr, { colors = rse_window_colors() }) + battle_font().draw(tostring(labels[i] or ""), mx + 56 * cc, my + 16 * rr, { colors = rse_default_colors() }) end end @@ -2175,7 +2188,7 @@ local function draw_move_menu_rse(st) local mon = ab and ab.mon local c = Ui._moveIndex - 1 local _, cy = BattleChrome.textOrigin(W.MOVE_NAME_1) - Window.cursorPx(8 * (9 * (c % 2) + 1), cy + 16 * math.floor(c / 2), { colors = rse_window_colors() }) + Window.cursorPx(8 * (9 * (c % 2) + 1), cy + 16 * math.floor(c / 2), { colors = rse_default_colors() }) for i = 1, 4 do local mv = mon and mon.moves and mon.moves[i] local label = "-" @@ -2537,6 +2550,7 @@ function Ui.draw(w, h) -- 4. Player Mon (Z: 200) -- 5. In front of Player & Global Foreground (Z: 201 .. 999) local dbl = st and is_double(st) + if Anim.beginParticleFrame then Anim.beginParticleFrame() end if dbl then draw_double_mons(st, stage, Anim, screenFxActive) else @@ -2556,6 +2570,7 @@ function Ui.draw(w, h) if screenFxActive then Anim.beginScreenEffect() end Anim.drawParticles(201, 999) end + if Anim.endParticleFrame then Anim.endParticleFrame() end draw_intro_ball(stage) -- pokefirered/src/pokeball.c:770 BallOpen.draw() diff --git a/src/core/game3/battle_transition.lua b/src/core/game3/battle_transition.lua index 56c60bde..54e480b9 100644 --- a/src/core/game3/battle_transition.lua +++ b/src/core/game3/battle_transition.lua @@ -1673,15 +1673,21 @@ local function drawFieldRows(R, G, fx) local bx0, by0 = floor(R.X0 / m), floor(R.Y0 / m) local cw = floor((R.X1 - 1) / m) - bx0 + 1 local ch = floor((R.Y1 - 1) / m) - by0 + 1 - local key = cw .. "x" .. ch + -- One canvas per transition, grown only when a level needs more room; + -- each level draws just its cw x ch corner through a quad. local small = BattleTransition._mosaicCanvas - if not small or BattleTransition._mosaicKey ~= key then + local key = BattleTransition._mosaicKey + if not small or type(key) ~= "table" or key.w < cw or key.h < ch then if small and small.release then pcall(small.release, small) end - small = G.newCanvas(cw, ch, { dpiscale = 1 }) + local nw = math.max(cw, type(key) == "table" and small and key.w or 0) + local nh = math.max(ch, type(key) == "table" and small and key.h or 0) + small = G.newCanvas(nw, nh, { dpiscale = 1 }) small:setFilter("nearest", "nearest") + key = { w = nw, h = nh, quad = G.newQuad(0, 0, cw, ch, nw, nh) } BattleTransition._mosaicCanvas = small BattleTransition._mosaicKey = key end + key.quad:setViewport(0, 0, cw, ch, key.w, key.h) G.push("all") G.origin() G.setCanvas(small) @@ -1690,7 +1696,7 @@ local function drawFieldRows(R, G, fx) G.draw(tex, -bx0 + 0.5 - (R.gx + 0.5) / m, -by0 + 0.5 - (R.gy + 0.5) / m, 0, 1 / m, 1 / m) G.pop() G.setColor(1, 1, 1, 1) - G.draw(small, bx0 * m, by0 * m, 0, m, m) + G.draw(small, key.quad, bx0 * m, by0 * m, 0, m, m) return end G.draw(tex, R.X0, R.Y0) diff --git a/src/core/game3/collision.lua b/src/core/game3/collision.lua index 2d0d2a37..fe5f831b 100644 --- a/src/core/game3/collision.lua +++ b/src/core/game3/collision.lua @@ -32,9 +32,14 @@ local function log(msg) print("[game3/collision] " .. tostring(msg)) end +local permsLoaded, permsMod local function permissions() - local ok, P = pcall(require, "src.world.gen2.Permissions") - return ok and P or nil + if not permsLoaded then + local ok, P = pcall(require, "src.world.gen2.Permissions") + permsMod = ok and P or nil + permsLoaded = true + end + return permsMod end local function resolveTileset(game, mapDef) @@ -193,6 +198,31 @@ function Collision.installWarps(mapDef) end end +--- Patch one cell of the bound native grid after a metatile write (what a +-- full bindMap would rebuild for it) without re-deriving the whole map. +-- Returns false when the caller must bindMap instead: another map is bound, +-- the cell is off the grid, or a warp sits on it (installWarps derives warp +-- activity and door repairs from the cell). +function Collision.patchCell(mapId, mapDef, x, y) + if not (Collision._grid and mapDef and mapDef.midLayout) then return false end + if Collision._mapId ~= mapId or Collision._mapDef ~= mapDef then return false end + local layout = mapDef.midLayout + x, y = tonumber(x), tonumber(y) + if not (x and y) or x ~= math.floor(x) or y ~= math.floor(y) then return false end + local w, h = Collision._widthCells, Collision._heightCells + if w ~= layout.width or h ~= layout.height then return false end + if x < 0 or y < 0 or x >= w or y >= h then return false end + if x >= (layout.trueWidth or w) or y >= (layout.trueHeight or h) then return false end + for _, warp in ipairs(mapDef.warps or {}) do + if tonumber(warp.x) == x and tonumber(warp.y) == y then return false end + end + -- same value LayoutNative:collArray yields for this cell + local ov = layout.overrides and layout.overrides[y * 1024 + x] + local c = layout.cells and layout.cells[y * w + x + 1] + Collision._grid[y * w + x + 1] = (ov and ov.coll) or (c and c.coll) or 0xff + return true +end + function Collision.clear() Collision._grid = nil Collision._mapId = nil diff --git a/src/core/game3/dataset.lua b/src/core/game3/dataset.lua index af7009fe..1d859e76 100644 --- a/src/core/game3/dataset.lua +++ b/src/core/game3/dataset.lua @@ -92,9 +92,14 @@ local function loveCache() } end +-- The cache object is stateless (every read resolves CacheFs, love.filesystem +-- and Dataset.cacheRootOverride at call time), so one instance is shared. +local sharedCache = nil + --- Shared firered CacheFs-backed cache for standalone Game3 (mod.cache is nil). function Dataset.cache() - return loveCache() + if not sharedCache then sharedCache = loveCache() end + return sharedCache end local dsLoadWarned = false diff --git a/src/core/game3/display.lua b/src/core/game3/display.lua index 48e01ff8..d29c017e 100644 --- a/src/core/game3/display.lua +++ b/src/core/game3/display.lua @@ -355,7 +355,12 @@ local function presentPlanes(game) Display.mirrorFlatFrame(Renderer) end +-- The plane path presents straight from Renderer's canvases; the flat 240x160 +-- copy is only for test drivers that read Display._canvas back. +Display.mirrorForTests = false + function Display.mirrorFlatFrame(Renderer) + if not Display.mirrorForTests then return end local canvas = Display.ensureCanvas("main") if not canvas then return end local world = Renderer.worldCanvas diff --git a/src/core/game3/field.lua b/src/core/game3/field.lua index 707ae5a1..8268e32f 100644 --- a/src/core/game3/field.lua +++ b/src/core/game3/field.lua @@ -207,6 +207,9 @@ function Field.update(_dt) local Ghosts = require("src.core.game3.ghosts") Ghosts.sync() Ghosts.update(game) + -- pre-load tileset pairs queued by a seamless world refresh, one per frame + local MapMod = package.loaded["src.core.game3.map"] + if MapMod and MapMod._warmQueue and MapMod.stepWarm then MapMod.stepWarm() end Field.pollMapChange(game) -- pokefirered/src/safari_zone.c:60 CB2_EndSafariBattle @@ -2056,9 +2059,16 @@ function Field.setMetatile(x, y, metatile, isImpassable) layout:applyOverride(x, y, mid, coll, layout:elevAt(x, y)) Field._overrideLayouts[mapId] = layout local Collision = require("src.core.game3.collision") - Collision.bindMap(game, mapId, mapDef) + if not (Collision.patchCell and Collision.patchCell(mapId, mapDef, x, y)) then + Collision.bindMap(game, mapId, mapDef) + end + -- applyOverride already invalidated the view cell (FieldView.invalidateLayoutCell). local FieldView = package.loaded["src.core.game3.field_view"] - if FieldView then FieldView._nativeDirty = true end + local LayoutNative = package.loaded["src.core.game3.layout_native"] + if FieldView and not (FieldView.invalidateLayoutCell and LayoutNative + and layout.applyOverride == LayoutNative.applyOverride) then + FieldView._nativeDirty = true + end end local world = game and (game.overworld or game.world) if world and world.map and world.map.setBlock then diff --git a/src/core/game3/field_view.lua b/src/core/game3/field_view.lua index f8649fa6..eb0eac60 100644 --- a/src/core/game3/field_view.lua +++ b/src/core/game3/field_view.lua @@ -840,14 +840,27 @@ local function washColor(bgSet) return 0.12, 0.28, 0.22 end +local function releaseBatch(batch) + if batch and batch.release then pcall(batch.release, batch) end +end + +-- Outside a full reset, visible cells hold sprite indices into the batch +-- stored under `srcPair`, so a texture swap cannot replace it in place (the +-- next releaseNativeSprite would index a smaller batch). Report the swap and +-- let drawNativeTiles rebuild everything this frame instead. local function ensure_batch(store, srcPair, texture, capacity) local batch = store[srcPair] + if batch and batch.getTexture and batch:getTexture() ~= texture then + if not FieldView._nativeResetting then + FieldView._nativeSwap = true + return nil + end + releaseBatch(batch) + batch = nil + end if not batch then batch = love.graphics.newSpriteBatch(texture, capacity) store[srcPair] = batch - elseif batch.getTexture and batch:getTexture() ~= texture then - batch = love.graphics.newSpriteBatch(texture, capacity) - store[srcPair] = batch end return batch end @@ -864,8 +877,12 @@ local function nativeAtlas(NativeTileset, pair) return ts end -local function acquireNativeSprite(store, free, key, texture, capacity, quad, x, y) +-- Physical sprite slots per layer (free-list reuse does not grow them). +local nativeSlots = { under = 0, over = 0 } + +local function acquireNativeSprite(store, free, key, texture, capacity, quad, x, y, layer) local batch = ensure_batch(store, key, texture, capacity) + if not batch then return nil end local available = free[key] local index if available and #available > 0 then @@ -874,7 +891,9 @@ local function acquireNativeSprite(store, free, key, texture, capacity, quad, x, batch:set(index, quad, x, y) else index = batch:add(quad, x, y) - FieldView._nativeSpriteSlots = FieldView._nativeSpriteSlots + 1 + local n = nativeSlots[layer] + 1 + nativeSlots[layer] = n + if n > FieldView._nativeSpriteSlots then FieldView._nativeSpriteSlots = n end end return { key = key, index = index, quad = quad } end @@ -883,6 +902,7 @@ local function releaseNativeSprite(store, free, sprite) if not sprite then return true end local batch = store[sprite.key] if not (batch and batch.set) then return false end + if batch.getCount and sprite.index > batch:getCount() then return false end batch:set(sprite.index, sprite.quad, HIDDEN_SPRITE_X, HIDDEN_SPRITE_Y) local available = free[sprite.key] if not available then @@ -893,6 +913,80 @@ local function releaseNativeSprite(store, free, sprite) return true end +-- Clear every native batch for a full rebuild, keeping the SpriteBatch +-- objects (and their GPU buffers) for reuse. Batches that cannot be cleared +-- (test doubles) are dropped. +local function clearNativeStore(store) + for k, batch in pairs(store) do + if batch.clear then + batch:clear() + else + releaseBatch(batch) + store[k] = nil + end + end +end + +-- After a rebuild, release batches no visible cell uses any more. +local function pruneNativeStore(store) + for k, batch in pairs(store) do + if batch.getCount and batch:getCount() == 0 then + releaseBatch(batch) + store[k] = nil + end + end +end + +local function releaseVoidFrom(from) + if not from then return end + for _, t in ipairs({ from.under, from.over }) do + for _, batch in pairs(t or {}) do releaseBatch(batch) end + end +end + +--- Mark one cell of the drawn map's layout for re-sampling on the next draw +-- (metatile writes). x, y are layout (current-map) cell coordinates. Cells +-- that are not visible need nothing: they are sampled when they scroll in. +function FieldView.invalidateCell(x, y, layout) + if FieldView._nativeDirty or type(FieldView._nativeVisibleCells) ~= "table" then return end + x, y = tonumber(x), tonumber(y) + if not (x and y) or (layout ~= nil and layout ~= FieldView._nativeLayout) then + FieldView._nativeDirty = true + return + end + local cells = FieldView._nativeDirtyCells + if not cells then + cells = {} + FieldView._nativeDirtyCells = cells + end + local n = #cells + if n >= 1024 then + FieldView._nativeDirty = true + return + end + cells[n + 1] = x + cells[n + 2] = y +end + +--- Metatile write on `layout` at its own (x, y). Only the drawn map's own +-- layout maps 1:1 onto view cells; anything else (neighbour maps, a layout +-- shared with a neighbour) falls back to a full rebuild. +function FieldView.invalidateLayoutCell(layout, x, y) + if FieldView._nativeDirty then return end + if layout == nil or layout ~= FieldView._nativeLayout then + FieldView._nativeDirty = true + return + end + local Map = package.loaded["src.core.game3.map"] + for _, entry in ipairs((Map and Map.world) or {}) do + if entry.def and entry.def.midLayout == layout then + FieldView._nativeDirty = true + return + end + end + FieldView.invalidateCell(x, y, layout) +end + FieldView.VOID_FADE_FRAMES = 20 local voidKeys = setmetatable({}, { __index = function(t, pair) local k = "void|" .. pair @@ -969,7 +1063,8 @@ local function drawNativeTiles(mapDef, camX, camY, canvasW, canvasH) end return false end - if not NativeTileset.get(pair) then return false end + local mainTs = nativeAtlas(NativeTileset, pair) + if not mainTs then return false end love.graphics.setColor(0, 0, 0, 1) love.graphics.rectangle("fill", 0, 0, canvasW, canvasH) @@ -986,22 +1081,39 @@ local function drawNativeTiles(mapDef, camX, camY, canvasW, canvasH) local voidMode = VoidFill.normalize(VoidFill.mode) beginVoidFade(Map, mapDef, camX, camY, voidMode) + local underStore = FieldView._nativeBatches + if not underStore then + underStore = {} + FieldView._nativeBatches = underStore + end + local overStore = FieldView._nativeOverBatches + if not overStore then + overStore = {} + FieldView._nativeOverBatches = overStore + end + local mainBatch = underStore[pair] + local moved = FieldView._nativeBx ~= cx0 or FieldView._nativeBy ~= cy0 local reset = FieldView._nativeDirty or FieldView._nativeVoid ~= voidMode or FieldView._nativePair ~= pair + or FieldView._nativeLayout ~= layout or FieldView._nativeViewCols ~= cols or FieldView._nativeViewRows ~= rows or type(FieldView._nativeVisibleCells) ~= "table" + or type(FieldView._nativeFreeUnder) ~= "table" + or type(FieldView._nativeFreeOver) ~= "table" or (FieldView._nativeSpriteSlots or 0) > capacity * 4 + -- primary atlas reloaded (tileset reinstall): cells index the old texture + or (mainBatch ~= nil and mainBatch.getTexture ~= nil and mainBatch:getTexture() ~= mainTs.image) if not reset and moved then local dx = math.abs(cx0 - (FieldView._nativeBx or cx0)) local dy = math.abs(cy0 - (FieldView._nativeBy or cy0)) if dx >= cols or dy >= rows then reset = true else - for _, store in ipairs({ FieldView._nativeBatches, FieldView._nativeOverBatches }) do - for _, batch in pairs(store or {}) do + for _, store in ipairs({ underStore, overStore }) do + for _, batch in pairs(store) do if type(batch.set) ~= "function" then reset = true; break end end if reset then break end @@ -1040,16 +1152,16 @@ local function drawNativeTiles(mapDef, camX, camY, canvasW, canvasH) local slot = NativeTileset.slotFor(ts, mid) local q = NativeTileset.quad(ts, slot) if q then - info.under = acquireNativeSprite(FieldView._nativeBatches, + info.under = acquireNativeSprite(underStore, FieldView._nativeFreeUnder, key, ts.image, capacity, q, - (wx - baseBx) * CELL, (wy - baseBy) * CELL) + (wx - baseBx) * CELL, (wy - baseBy) * CELL, "under") end if ts.layered and ts.overImage then local oq = NativeTileset.overQuad(ts, slot) if oq then - info.over = acquireNativeSprite(FieldView._nativeOverBatches, + info.over = acquireNativeSprite(overStore, FieldView._nativeFreeOver, key, ts.overImage, capacity, oq, - (wx - baseBx) * CELL, (wy - baseBy) * CELL) + (wx - baseBx) * CELL, (wy - baseBy) * CELL, "over") end end end @@ -1057,12 +1169,16 @@ local function drawNativeTiles(mapDef, camX, camY, canvasW, canvasH) return info end + local function releaseCell(info) + return releaseNativeSprite(underStore, FieldView._nativeFreeUnder, info.under) + and releaseNativeSprite(overStore, FieldView._nativeFreeOver, info.over) + end + if not reset and moved then for wy, row in pairs(visibleCells) do for wx, info in pairs(row) do if wx < cx0 or wx >= cx0 + cols or wy < cy0 or wy >= cy0 + rows then - if not releaseNativeSprite(FieldView._nativeBatches, FieldView._nativeFreeUnder, info.under) - or not releaseNativeSprite(FieldView._nativeOverBatches, FieldView._nativeFreeOver, info.over) then + if not releaseCell(info) then reset = true break end @@ -1074,39 +1190,89 @@ local function drawNativeTiles(mapDef, camX, camY, canvasW, canvasH) end end - if reset then - FieldView._nativeBatches, FieldView._nativeOverBatches = {}, {} + -- Metatile writes since the last draw: drop just those cells so the fill + -- loop below re-samples them (FieldView.invalidateCell). + local dirtyCells = FieldView._nativeDirtyCells + if dirtyCells then + FieldView._nativeDirtyCells = nil + if not reset then + for i = 1, #dirtyCells - 1, 2 do + local wx, wy = dirtyCells[i], dirtyCells[i + 1] + local row = visibleCells[wy] + local info = row and row[wx] + if info then + if not releaseCell(info) then + reset = true + break + end + row[wx] = nil + end + end + end + end + + local function resetNative() + clearNativeStore(underStore) + clearNativeStore(overStore) FieldView._nativeFreeUnder, FieldView._nativeFreeOver = {}, {} visibleCells = {} FieldView._nativeVisibleCells = visibleCells visiblePairs = {} FieldView._nativeVisiblePairs = visiblePairs + nativeSlots.under, nativeSlots.over = 0, 0 FieldView._nativeSpriteSlots = 0 FieldView._nativeBaseBx, FieldView._nativeBaseBy = cx0, cy0 FieldView._nativeViewCols, FieldView._nativeViewRows = cols, rows baseBx, baseBy = cx0, cy0 + end + + if reset then + resetNative() elseif moved then for k in pairs(visiblePairs) do visiblePairs[k] = nil end end - for wy = cy0, cy0 + rows - 1 do - local row = visibleCells[wy] - if not row then - row = {} - visibleCells[wy] = row - end - for wx = cx0, cx0 + cols - 1 do - local info = row[wx] - if not info then - info = addCell(wx, wy) - row[wx] = info + local function fill() + for wy = cy0, cy0 + rows - 1 do + local row = visibleCells[wy] + if not row then + row = {} + visibleCells[wy] = row + end + for wx = cx0, cx0 + cols - 1 do + local info = row[wx] + if not info then + info = addCell(wx, wy) + if FieldView._nativeSwap then return false end + row[wx] = info + end + if info.draw then visiblePairs[info.pair] = true end end - if info.draw then visiblePairs[info.pair] = true end end + return true + end + + FieldView._nativeSwap = false + FieldView._nativeResetting = reset + if not fill() then + -- An atlas was reloaded while its cells were live: rebuild everything + -- this frame rather than swap the batch under their sprite indices. + FieldView._nativeSwap = false + reset = true + resetNative() + FieldView._nativeResetting = true + fill() + end + FieldView._nativeResetting = false + FieldView._nativeSwap = false + if reset then + pruneNativeStore(underStore) + pruneNativeStore(overStore) end FieldView._nativeBx, FieldView._nativeBy = cx0, cy0 FieldView._nativePair = pair + FieldView._nativeLayout = layout FieldView._nativeVoid = voidMode FieldView._nativeDirty = false if reset or moved then @@ -1128,7 +1294,10 @@ local function drawNativeTiles(mapDef, camX, camY, canvasW, canvasH) drawLayer(FieldView._nativeBatches, pair, ox, oy, from and from.under, camX, camY) if from then from.t = from.t + 1 - if from.t >= FieldView.VOID_FADE_FRAMES then FieldView._voidFrom = nil end + if from.t >= FieldView.VOID_FADE_FRAMES then + releaseVoidFrom(from) + FieldView._voidFrom = nil + end end FieldView._lastCamX, FieldView._lastCamY = camX, camY @@ -1634,6 +1803,9 @@ function FieldView.invalidate() FieldView._nativeVisiblePairs = nil FieldView._nativeFreeUnder, FieldView._nativeFreeOver = nil, nil FieldView._nativeSpriteSlots = 0 + nativeSlots.under, nativeSlots.over = 0, 0 + FieldView._nativeDirtyCells = nil + FieldView._nativeLayout = nil FieldView._voidFrom, FieldView._voidMapDef = nil, nil FieldView._lastCamX, FieldView._lastCamY = nil, nil FieldView._nativeBx = nil diff --git a/src/core/game3/ghosts.lua b/src/core/game3/ghosts.lua index 7243d137..cbc79492 100644 --- a/src/core/game3/ghosts.lua +++ b/src/core/game3/ghosts.lua @@ -69,23 +69,48 @@ Ghosts._contextFor = contextFor local ctxCache = setmetatable({}, { __mode = "k" }) -function Ghosts.sync() - local M = Map() - local placed = {} - for _, entry in ipairs(M.world or {}) do - placed[entry.id] = true - if not Ghosts._pools[entry.id] then - local defs = defsFor(entry.id, entry.def) - if defs then - Ghosts._pools[entry.id] = Objects().spawnFromDefs(defs, entry.def, entry.id) - end - end +local EMPTY = {} +local syncPlaced = {} + +-- True when the last rebuild still describes Map.world and the pool table: +-- same world list, a pool for every placed map and no extra pools. +local function syncCurrent(world) + if Ghosts._syncWorld ~= world or Ghosts._syncPools ~= Ghosts._pools then return false end + local n = 0 + for _, entry in ipairs(world) do + if not Ghosts._pools[entry.id] then return false end + n = n + 1 end for id in pairs(Ghosts._pools) do - if not placed[id] and id ~= Ghosts._held then - Ghosts._pools[id] = nil - end + if not syncPlaced[id] and id ~= Ghosts._held then return false end + n = n - 1 end + return n == 0 or (Ghosts._held ~= nil and n == -1) +end + +function Ghosts.sync() + local M = Map() + local world = M.world or EMPTY + if not syncCurrent(world) then + local placed = syncPlaced + for id in pairs(placed) do placed[id] = nil end + for _, entry in ipairs(world) do + placed[entry.id] = true + if not Ghosts._pools[entry.id] then + local defs = defsFor(entry.id, entry.def) + if defs then + Ghosts._pools[entry.id] = Objects().spawnFromDefs(defs, entry.def, entry.id) + end + end + end + for id in pairs(Ghosts._pools) do + if not placed[id] and id ~= Ghosts._held then + Ghosts._pools[id] = nil + end + end + Ghosts._syncWorld, Ghosts._syncPools = world, Ghosts._pools + end + local placed = syncPlaced if Ghosts._held and not placed[Ghosts._held] then Ghosts._heldGrace = (Ghosts._heldGrace or 0) + 1 if Ghosts._heldGrace > 2 then @@ -98,12 +123,44 @@ function Ghosts.sync() end end +-- Neighbour NPCs only step while their map is near the camera (one screen of +-- margin past the view); farther pools hold still (pret only runs object +-- events spawned around the camera, TrySpawnObjectEvents). Raise +-- TICK_MARGIN_SCREENS (math.huge = tick every pool) to widen it. +Ghosts.TICK_MARGIN_SCREENS = 1 + +local function tickRect() + local P = package.loaded["src.core.game3.player"] + local px, py = P and tonumber(P.cellX), P and tonumber(P.cellY) + if not (px and py) then return nil end + local FieldView = package.loaded["src.core.game3.field_view"] + local Display = package.loaded["src.core.game3.display"] + local vw = (FieldView and FieldView._viewW) or (Display and Display.W) or 240 + local vh = (FieldView and FieldView._viewH) or (Display and Display.H) or 160 + local cols, rows = math.ceil(vw / 16), math.ceil(vh / 16) + local mx = math.ceil(cols / 2) + 1 + cols * Ghosts.TICK_MARGIN_SCREENS + local my = math.ceil(rows / 2) + 1 + rows * Ghosts.TICK_MARGIN_SCREENS + -- cull rect in current-map cells + return px - mx, py - my, px + mx, py + my +end + +local function nearView(entry, x0, y0, x1, y1) + if not x0 then return true end + local layout = entry.def and entry.def.midLayout + local w = layout and layout.width + local h = layout and layout.height + if not (w and h) then return true end + local ox, oy = entry.ox or 0, entry.oy or 0 + return ox + w > x0 and ox <= x1 and oy + h > y0 and oy <= y1 +end + function Ghosts.update(game) local M = Map() local Obj = Objects() - for _, entry in ipairs(M.world or {}) do + local x0, y0, x1, y1 = tickRect() + for _, entry in ipairs(M.world or EMPTY) do local pool = Ghosts._pools[entry.id] - if pool then + if pool and nearView(entry, x0, y0, x1, y1) then local c = ctxCache[entry] if not c or c.pool ~= pool or c.def ~= entry.def or c.layout ~= (entry.def and entry.def.midLayout) or c.ox ~= entry.ox or c.oy ~= entry.oy then c = { pool = pool, def = entry.def, layout = entry.def and entry.def.midLayout, ox = entry.ox, oy = entry.oy, ctx = contextFor(entry, pool) } diff --git a/src/core/game3/layout_native.lua b/src/core/game3/layout_native.lua index c7ebdf5e..8401a6c0 100644 --- a/src/core/game3/layout_native.lua +++ b/src/core/game3/layout_native.lua @@ -109,7 +109,11 @@ function LayoutNative:applyOverride(x, y, mid, coll, elev) } local FieldView = package.loaded["src.core.game3.field_view"] if FieldView then - FieldView._nativeDirty = true + if FieldView.invalidateLayoutCell then + FieldView.invalidateLayoutCell(self, x, y) + else + FieldView._nativeDirty = true + end end end @@ -120,6 +124,22 @@ local function markDirty() end end +-- Re-sample only the written cells when there are few of them (per-frame +-- metatile animations); bulk writes rebuild the whole view. +local MAX_CELL_INVALIDATIONS = 64 + +local function markCellsDirty(layout, cells, n) + local FieldView = package.loaded["src.core.game3.field_view"] + if not FieldView then return end + if not FieldView.invalidateLayoutCell or n > MAX_CELL_INVALIDATIONS then + FieldView._nativeDirty = true + return + end + for i = 1, n * 2, 2 do + FieldView.invalidateLayoutCell(layout, cells[i], cells[i + 1]) + end +end + -- pokeemerald/src/battle_pyramid.c:1523 function LayoutNative:stamp(src, ox, oy) if type(src) ~= "table" or type(src.cells) ~= "table" then return 0 end @@ -145,6 +165,7 @@ end -- pokeemerald/src/fieldmap.c:357 function LayoutNative:setMetatiles(rows) local n = 0 + local cells = {} for _, r in ipairs(rows or {}) do local x, y = tonumber(r.x) or 0, tonumber(r.y) or 0 local cur = self:cellAt(x, y) @@ -154,8 +175,9 @@ function LayoutNative:setMetatiles(rows) elev = r.elev ~= nil and r.elev or cur.elev or 0, } n = n + 1 + cells[n * 2 - 1], cells[n * 2] = x, y end - if n > 0 then markDirty() end + if n > 0 then markCellsDirty(self, cells, n) end return n end diff --git a/src/core/game3/map.lua b/src/core/game3/map.lua index 8b60bd30..039f37bf 100644 --- a/src/core/game3/map.lua +++ b/src/core/game3/map.lua @@ -151,10 +151,24 @@ function Map.refreshWorld(game, reachW, reachH, rootId) local NativeTileset = package.loaded["src.core.game3.tileset_native"] if Map._warmPairs and NativeTileset and NativeTileset.get then Map._warmPairs = nil + Map._warmQueue = nil for _, entry in ipairs(Map.world) do local pair = entry.def and (entry.def.pair or (entry.def.midLayout and entry.def.midLayout.pair)) if pair and NativeTileset.ready(pair) then pcall(NativeTileset.get, pair) end end + elseif NativeTileset and NativeTileset.get then + -- Seamless crossing: queue the new world's unloaded pairs and load one per + -- frame (Map.stepWarm) so a pair is ready before it scrolls into view. + local queue, seen = {}, {} + for _, entry in ipairs(Map.world) do + local pair = entry.def and (entry.def.pair or (entry.def.midLayout and entry.def.midLayout.pair)) + if type(pair) == "string" and not seen[pair] + and not (NativeTileset._pairs and NativeTileset._pairs[pair]) then + seen[pair] = true + queue[#queue + 1] = pair + end + end + Map._warmQueue = queue[1] and queue or nil end Map._worldRoot = rootId Map._worldReachW = reachW @@ -164,6 +178,24 @@ function Map.refreshWorld(game, reachW, reachH, rootId) return Map.world end +--- Load at most one queued tileset pair (see refreshWorld). +function Map.stepWarm() + local queue = Map._warmQueue + if not queue then return false end + local NativeTileset = package.loaded["src.core.game3.tileset_native"] + while queue[1] do + local pair = table.remove(queue, 1) + if NativeTileset and not (NativeTileset._pairs and NativeTileset._pairs[pair]) + and NativeTileset.ready and NativeTileset.ready(pair) then + pcall(NativeTileset.get, pair) + if not queue[1] then Map._warmQueue = nil end + return true + end + end + Map._warmQueue = nil + return false +end + function Map.overscanSlices() local slices = {} for _, n in ipairs(Map.neighborList or {}) do diff --git a/src/core/game3/oam.lua b/src/core/game3/oam.lua index 52c56850..d60e098e 100644 --- a/src/core/game3/oam.lua +++ b/src/core/game3/oam.lua @@ -119,6 +119,7 @@ function Oam.reset() s.anims, s.animQuads, s.affineAnim = nil, nil, nil end Oam._buffer = nil + Oam._sorted = nil Oam._clip = nil Oam._fx = nil Oam._blend = nil @@ -295,7 +296,14 @@ end --- Begin frame (pret: clear shadow OAM build). function Oam.resetFrame() ensure_pool() - Oam._buffer = {} + -- Empty this frame's draw list in place; the sorted order survives in + -- Oam._sorted so buildOamBuffer can skip re-sorting an unchanged scene. + local buf = Oam._buffer + if buf == nil or buf == Oam._sorted then + Oam._buffer = {} + else + for i = #buf, 1, -1 do buf[i] = nil end + end end Oam._layer = "ui" @@ -504,25 +512,44 @@ function Oam.buildOamBuffer(pretOrder) end end local cmp = pretOrder and sort_sprites_pret or sort_sprites - local cached = Oam._buffer - if cached and #cached == n then - local ok = true + -- Cache: last frame's sorted list. Same length, every entry still drawable + -- and still in order means it holds exactly this frame's sprites (entries + -- are distinct pool slots), so the sort can be skipped. + local sorted = Oam._sorted + local ok = false + if sorted and #sorted == n and Oam._sortedPool == Oam._sprites then + ok = true for i = 1, n do - local s = cached[i] + local s = sorted[i] if not (s.inUse and not s.invisible and s.image) then ok = false break end - if i < n and cmp(cached[i + 1], s) then ok = false break end - end - if ok then return cached end - end - local buf = {} - for i = 0, Oam.MAX_SPRITES - 1 do - local s = Oam._sprites[i] - if s.inUse and not s.invisible and s.image then - buf[#buf + 1] = s + local nx = sorted[i + 1] + if nx then + if cmp(nx, s) then ok = false break end + -- equal keys: keep pool order (pret's insertion sort is stable) + if not cmp(s, nx) and (s._id or 0) > (nx._id or 0) then ok = false break end + end end end - table.sort(buf, cmp) - Oam._buffer = buf + if not ok then + sorted = sorted or {} + for i = #sorted, 1, -1 do sorted[i] = nil end + for i = 0, Oam.MAX_SPRITES - 1 do + local s = Oam._sprites[i] + if s.inUse and not s.invisible and s.image then + sorted[#sorted + 1] = s + end + end + table.sort(sorted, cmp) + Oam._sorted = sorted + Oam._sortedPool = Oam._sprites + end + local buf = Oam._buffer + if buf == nil or buf == sorted then + buf = {} + Oam._buffer = buf + end + for i = 1, n do buf[i] = sorted[i] end + for i = #buf, n + 1, -1 do buf[i] = nil end return buf end @@ -560,11 +587,12 @@ local function blit_sprite(s) local blend = s.blend or Oam._blend local clip = s.clip or Oam._clip if clip and (clip.w <= 0 or clip.h <= 0) then return end - local draw = s.affineScale and function() blit_affine(s) end or function() blit_plain(s) end + local affine = s.affineScale if not fx and not blend and not clip then - draw() + if affine then blit_affine(s) else blit_plain(s) end return end + local draw = affine and function() blit_affine(s) end or function() blit_plain(s) end Fx.withClip(clip, function() Fx.draw(draw, fx, blend) end) end diff --git a/src/core/game3/objects.lua b/src/core/game3/objects.lua index f8966994..99d889e2 100644 --- a/src/core/game3/objects.lua +++ b/src/core/game3/objects.lua @@ -1514,6 +1514,13 @@ local function pick(t) return t[(idleRng.Random() % #t) + 1] end +-- tostring(movement):upper(), memoised (idleTick runs per object per frame). +local UPPER_MOVEMENT = setmetatable({}, { __index = function(t, k) + local v = tostring(k):upper() + t[k] = v + return v +end }) + local function idleTick(eo, game, ctx) if eo.frozen or eo.scriptBusy or eo.moving or eo.hidden or not eo.visible then return @@ -1527,7 +1534,7 @@ local function idleTick(eo, game, ctx) return end - local mv = tostring(eo.movement or "STAY"):upper() + local mv = UPPER_MOVEMENT[eo.movement or "STAY"] if mv == "STAY" then return end if mv == "IN_PLACE" then -- pokeemerald/src/event_object_movement.c:4422 diff --git a/src/core/game3/pokemon.lua b/src/core/game3/pokemon.lua index 0cc6d908..d542437b 100644 --- a/src/core/game3/pokemon.lua +++ b/src/core/game3/pokemon.lua @@ -1502,8 +1502,17 @@ local function pic(store, kind, species, form, shiny) if shiny and species ~= Pokemon.SPECIES_EGG then kind = kind .. "_shiny" end local key = form > 0 and (species .. "_" .. form) or species if kind:find("_shiny", 1, true) then key = "shiny:" .. key end - if store[key] then return store[key] end - return pic_entry(store, key, read_pic(pic_rel(kind, species, form))) + local hit = store[key] + if hit then return hit end + -- false marks a pic file known to be missing, so draw loops that probe + -- backPic then frontPic every frame do not re-read the filesystem. + if hit == false then return nil end + local rgba = read_pic(pic_rel(kind, species, form)) + if not rgba then + store[key] = false + return nil + end + return pic_entry(store, key, rgba) end Pokemon.SPECIES_SPINDA = 308 diff --git a/src/core/game3/rom_text.lua b/src/core/game3/rom_text.lua index 6fa06d50..285c475f 100644 --- a/src/core/game3/rom_text.lua +++ b/src/core/game3/rom_text.lua @@ -99,7 +99,37 @@ function RomText.box(key, ctx) return TextIR.toTextBox(RomText.translate(RomText.ir(key), ctx, key), ctx) end +-- Segment types whose expansion reads nothing but the segment itself. An IR +-- made only of these expands to the same plain string every time. +local PURE_SEG = { text = true, tag = true, nl = true, para = true, scroll = true, eos = true, ext = true } + +-- plain() results for pure IRs, keyed by the IR table itself (false = not +-- pure). Keying on the IR follows overrides, profile aliases and bundle +-- reloads for free: each resolves to a different table. +local plainCache = setmetatable({}, { __mode = "k" }) + +local function pure_ir(ir) + if type(ir) ~= "table" then return false end + for i = 1, #ir do + local seg = ir[i] + if type(seg) ~= "table" or not PURE_SEG[seg.t] then return false end + end + return true +end + function RomText.plain(key, ctx) + if ctx == nil and not Strings.active() then + local ir = RomText.ir(key) + local hit = plainCache[ir] + if hit then return hit end + if hit == nil then + local pure = pure_ir(ir) + local out = TextIR.toPlain(ir, {}) + plainCache[ir] = pure and out or false + return out + end + return TextIR.toPlain(ir, {}) + end ctx = ctx or {} return TextIR.toPlain(RomText.translate(RomText.ir(key), ctx, key), ctx) end diff --git a/src/core/game3/tileset_anim.lua b/src/core/game3/tileset_anim.lua index 269582fa..5ea5969a 100644 --- a/src/core/game3/tileset_anim.lua +++ b/src/core/game3/tileset_anim.lua @@ -320,27 +320,45 @@ function TilesetAnim.bindPair(pair, atlas) end TilesetAnim._visible[pair] = true if entry.rse then return true end - for _, kind in ipairs({ "water", "sand", "flower" }) do - TilesetAnim._applyKind(entry, kind, TilesetAnim["_" .. kind .. "Frame"]) - end + TilesetAnim._applyKind(entry, "water", TilesetAnim._waterFrame) + TilesetAnim._applyKind(entry, "sand", TilesetAnim._sandFrame) + TilesetAnim._applyKind(entry, "flower", TilesetAnim._flowerFrame) return true end function TilesetAnim.setVisiblePairs(visible) local set = TilesetAnim._visible - for pair in pairs(set) do set[pair] = nil end - for pair in pairs(visible) do set[pair] = true end + for pair in pairs(set) do + if not visible[pair] then set[pair] = nil end + end + for pair in pairs(visible) do + if not set[pair] then + set[pair] = true + -- a General atlas coming back into view catches up before it is drawn + local entry = TilesetAnim._pairs[pair] + if entry and not entry.rse then + TilesetAnim._applyKind(entry, "water", TilesetAnim._waterFrame) + TilesetAnim._applyKind(entry, "sand", TilesetAnim._sandFrame) + TilesetAnim._applyKind(entry, "flower", TilesetAnim._flowerFrame) + end + end + end end -local function get_frame_piece(bank, frame, mi, frameRgba) +local function get_frame_piece(bank, frame, mi, srcOff) if not (love and love.image and love.image.newImageData) then return nil end - bank.pieces = bank.pieces or {} + local pieces = bank.pieces + if not pieces then + pieces = {} + bank.pieces = pieces + end local pKey = frame * 1000 + mi - local piece = bank.pieces[pKey] + local piece = pieces[pKey] if piece then return piece end - local ok, imgData = pcall(love.image.newImageData, 16, 16, "rgba8", frameRgba) + local ok, imgData = pcall(love.image.newImageData, 16, 16, "rgba8", + bank.rgba:sub(srcOff + 1, srcOff + MID_RGBA)) if ok and imgData then - bank.pieces[pKey] = imgData + pieces[pKey] = imgData return imgData end return nil @@ -357,20 +375,29 @@ function TilesetAnim._applyKind(entry, kind, frame) local nMids = #bank.mids local cols = ts.cols or 16 + local image = ts.image + -- pokeemerald-style partial upload (see rse_paste): push only the 16x16 + -- metatiles this bank owns instead of re-uploading the whole atlas. + local partial = image ~= nil and image.replacePixels ~= nil + local full = false for mi, mid in ipairs(bank.mids) do local slot = ts.midToSlot[mid] if slot then local srcOff = (frame * nMids + (mi - 1)) * MID_RGBA local ax = (slot % cols) * 16 local ay = math.floor(slot / cols) * 16 - local frameRgba = bank.rgba:sub(srcOff + 1, srcOff + MID_RGBA) - local piece = get_frame_piece(bank, frame, mi, frameRgba) + local piece = get_frame_piece(bank, frame, mi, srcOff) local pasted = false if piece and ts.imageData.paste then ts.imageData:paste(piece, ax, ay) pasted = true + if partial and not full then + local ok = pcall(image.replacePixels, image, piece, 1, 1, ax, ay, false) + if not ok then full = true end + end end if not pasted then + local frameRgba = bank.rgba:sub(srcOff + 1, srcOff + MID_RGBA) local i = 1 for y = 0, 15 do for x = 0, 15 do @@ -382,11 +409,12 @@ function TilesetAnim._applyKind(entry, kind, frame) i = i + 4 end end + full = true end end end - if ts.image and ts.image.replacePixels then - ts.image:replacePixels(ts.imageData) + if partial then + if full then image:replacePixels(ts.imageData) end elseif ts.image and love and love.graphics then ts.image = love.graphics.newImage(ts.imageData) if ts.image.setFilter then ts.image:setFilter("nearest", "nearest") end diff --git a/src/core/game3/tileset_native.lua b/src/core/game3/tileset_native.lua index b80dcf04..1b2b98a7 100644 --- a/src/core/game3/tileset_native.lua +++ b/src/core/game3/tileset_native.lua @@ -161,9 +161,18 @@ function NativeTileset.ready(pair) and cache:exists(NATIVE .. "/" .. pair .. "/palettes.bin") end +local animMod +local function tilesetAnim() + if animMod == nil then + local okA, TilesetAnim = pcall(require, "src.core.game3.tileset_anim") + animMod = okA and TilesetAnim or false + end + return animMod or nil +end + local function bind_anim(pair, atlas) - local okA, TilesetAnim = pcall(require, "src.core.game3.tileset_anim") - if okA and TilesetAnim and TilesetAnim.bindPair then + local TilesetAnim = tilesetAnim() + if TilesetAnim and TilesetAnim.bindPair then TilesetAnim.bindPair(pair, atlas) end end @@ -172,6 +181,9 @@ function NativeTileset.get(pair) if not pair then return nil end local cached = NativeTileset._pairs[pair] if cached then + -- Cheap for a bound pair (no manifest read): marks it visible and + -- catches its atlas up to the current anim frames before a draw. + -- FieldView reads cached atlases directly and does not come here per frame. bind_anim(pair, cached) return cached end diff --git a/src/core/game3/trainer_sight.lua b/src/core/game3/trainer_sight.lua index 368644e4..aff85c89 100644 --- a/src/core/game3/trainer_sight.lua +++ b/src/core/game3/trainer_sight.lua @@ -78,6 +78,9 @@ function TrainerSight.getTrainerId(eo) local scripts = Sp and Sp.bundle and Sp.bundle.scripts local list = scripts and scripts[scriptKey] if type(list) == "table" then + -- negative cache: this exact script list was already scanned without a + -- trainerbattle (a script swapped in under the key rescans) + if eo._trainerIdMiss == list then return nil end for _, row in ipairs(list) do if row.op == "trainerbattle" or row.op == "dotrainerbattle" then local tid = tonumber(row.trainer or row[1]) @@ -88,6 +91,7 @@ function TrainerSight.getTrainerId(eo) end end end + eo._trainerIdMiss = list end return nil end @@ -633,6 +637,8 @@ end -- @param game game instance -- @param specificTrainer optional specific EventObject to check (e.g. from spinning NPC turn update) -- @return boolean true if an encounter was triggered +local approachFound = {} + function TrainerSight.check(game, specificTrainer) local F = Field() if F.locked then return false end @@ -681,7 +687,8 @@ function TrainerSight.check(game, specificTrainer) local order = Objs._order or {} if twoTrainerApproach() then -- pokeemerald/src/trainer_see.c:191 - local found = {} + local found = approachFound + for i = #found, 1, -1 do found[i] = nil end local doubles = canDouble() for _, lid in ipairs(order) do local eo = Objs.find(lid) diff --git a/src/mods/Hooks.lua b/src/mods/Hooks.lua index 0df909b3..e249a597 100644 --- a/src/mods/Hooks.lua +++ b/src/mods/Hooks.lua @@ -28,9 +28,30 @@ function Hooks:wrap(name, callback, priority, owner) for i, candidate in ipairs(chain) do if candidate == entry then table.remove(chain, i) break end end + -- an empty chain is dropped, as removeOwner does, so Runtime.wantsHook + -- goes back to false; only if it is still the live chain for the name + -- (a later wrap after removeOwner may have started a new one) + if #chain == 0 and self.chains[name] == chain then + self.chains[name] = nil + end end end +-- vanilla's pcall results, unpacked without a pack table: its values, or +-- the PASS-wrapped error +local function vanillaResult(ok, ...) + if ok then return ... end + error({ [PASS] = (...) }, 0) +end + +-- the outermost pcall's results: the chain's values, or its error unwrapped +local function finish(ok, ...) + if ok then return ... end + local err = (...) + if type(err) == "table" and err[PASS] ~= nil then error(err[PASS], 0) end + error(err, 0) +end + -- each link runs under pcall: a throwing wrapper is logged and skipped and -- the chain continues with the current arguments, so a broken mod degrades -- to "not installed for this call" instead of breaking the pipeline. @@ -46,9 +67,7 @@ function Hooks:call(name, vanilla, ...) local function run(index) if index > #chain then ranVanilla = true - local res = pack(pcall(vanilla, unpack(args, 1, args.n))) - if res[1] then return unpack(res, 2, res.n) end - error({ [PASS] = res[2] }, 0) + return vanillaResult(pcall(vanilla, unpack(args, 1, args.n))) end local entry = chain[index] local downstream @@ -81,11 +100,7 @@ function Hooks:call(name, vanilla, ...) tostring(entry.owner or "?"), name, tostring(err)) return run(index + 1) end - local res = pack(pcall(run, 1)) - if res[1] then return unpack(res, 2, res.n) end - local err = res[2] - if type(err) == "table" and err[PASS] ~= nil then error(err[PASS], 0) end - error(err, 0) + return finish(pcall(run, 1)) end -- drops every wrap a mod made; used by entry-chunk rollback diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index 749a202c..8892fd68 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -258,6 +258,13 @@ function Loader:_installDevShim() if devShim.installed then return end devShim.installed = true local delegate = require + -- The stock require answers an already-loaded module straight out of + -- package.loaded, so once every check below has passed the shim can do the + -- same without the pcall. Only when delegating to the stock C function: + -- a Lua wrapper in between may want to see every call. + local delegateIsStock = delegate == rawRequire + and type(delegate) == "function" + and (debug.getinfo(delegate, "S") or {}).what == "C" _G.require = function(name, ...) -- only the mod's own call is the mod's doing; whatever that module -- requires in turn is the engine wiring itself up @@ -295,6 +302,12 @@ function Loader:_installDevShim() end end end + if delegateIsStock then + -- a userdata here may be require's own "loading" sentinel, whose + -- loop error the stock path has to raise + local loaded = package.loaded[name] + if loaded and type(loaded) ~= "userdata" then return loaded end + end devShim.depth = devShim.depth + 1 local ok, result = pcall(delegate, name, ...) devShim.depth = devShim.depth - 1 diff --git a/src/mods/Sandbox.lua b/src/mods/Sandbox.lua index 81e25be7..d6f8b701 100644 --- a/src/mods/Sandbox.lua +++ b/src/mods/Sandbox.lua @@ -45,9 +45,14 @@ local function head(name) return (name:match("^([^%.]+)")) or name end --- nil when the require is allowed, else the message to fail it with. -function Sandbox.moduleDenial(name, permissionSet) - if type(name) ~= "string" then return nil end +-- The verdict for a name never changes (the tables above are fixed), and the +-- Loader's require shim asks for every require the engine makes while a mod +-- is enabled, so it is worked out once per name: false for allowed, the +-- denial message, or NETWORK_ONLY with the message kept alongside. +local verdicts, verdictCount = {}, 0 +local VERDICT_MAX = 4096 + +local function verdictFor(name) local root = head(name) local reason = DENIED[root] if reason then @@ -58,10 +63,29 @@ function Sandbox.moduleDenial(name, permissionSet) return ("%s is not available to mods; use mod.storage, mod:read, mod:list " .. "and the engine API instead"):format(name) end - if NETWORK[root] and not (permissionSet or {}).network then - return ("%s needs the \"network\" permission in manifest.json"):format(name) + if NETWORK[root] then + return { network = ("%s needs the \"network\" permission in manifest.json") + :format(name) } end - return nil + return false +end + +-- nil when the require is allowed, else the message to fail it with. +function Sandbox.moduleDenial(name, permissionSet) + if type(name) ~= "string" then return nil end + local verdict = verdicts[name] + if verdict == nil then + verdict = verdictFor(name) + if verdictCount >= VERDICT_MAX then verdicts, verdictCount = {}, 0 end + verdicts[name] = verdict + verdictCount = verdictCount + 1 + end + if not verdict then return nil end + if type(verdict) == "table" then + if (permissionSet or {}).network then return nil end + return verdict.network + end + return verdict end -- ------- the love facade diff --git a/src/pokemon/Sprites.lua b/src/pokemon/Sprites.lua index c849e4de..021b80dc 100644 --- a/src/pokemon/Sprites.lua +++ b/src/pokemon/Sprites.lua @@ -125,6 +125,9 @@ function Sprites.iconPath(data, mon, vanillaPath, opts) if not vanillaPath and not Runtime.wantsHook("pokemon.icon") then return vanillaPath, trueColor end + -- checked before the ctx table: party menus ask every drawn frame, and + -- an unhooked call must not allocate + if not Runtime.wantsHook("pokemon.icon") then return vanillaPath, trueColor end local species = mon and mon.species local ctx = { species = species, @@ -134,7 +137,6 @@ function Sprites.iconPath(data, mon, vanillaPath, opts) kind = "icon", trueColor = trueColor, } - if not Runtime.wantsHook("pokemon.icon") then return vanillaPath, trueColor end local hooked = Runtime.call("pokemon.icon", samePath, vanillaPath, ctx) trueColor = ctx.trueColor and true or false if type(hooked) == "string" and hooked ~= "" then return hooked, trueColor end diff --git a/src/render/Font.lua b/src/render/Font.lua index bb737baf..d538e786 100644 --- a/src/render/Font.lua +++ b/src/render/Font.lua @@ -100,8 +100,12 @@ local function tileSet(spec) return set end +local resetTextCaches, resetWidthCache + function Font.load(data) loadedFrom = data + -- a new charmap / page set / TTF: every cached encode and width is stale + if resetTextCaches then resetTextCaches() end local def = data.font state = { def = def, pages = {}, order = {}, byFirstByte = {} } for id, page in pairs(pagesOf(def)) do @@ -267,6 +271,8 @@ function Font.useBattleExtra(on) if not state then return false end local was = state.battleExtra or false state.battleExtra = on and true or false + -- the swapped page may carry its own advance + if state.battleExtra ~= was and resetWidthCache then resetWidthCache() end return was end @@ -278,7 +284,9 @@ end -- Module state, not `state`: applyOptions runs before Font.load on boot -- (src/core/Game2.lua Game2:load). function Font.setFrame(index) - currentFrame = math.floor(tonumber(index) or 1) + local frame = math.floor(tonumber(index) or 1) + if frame ~= currentFrame and resetWidthCache then resetWidthCache() end + currentFrame = frame end function Font.frameIndex() @@ -428,7 +436,7 @@ end -- Convert a text string into a list of glyph codes. Unknown characters -- render as space (and are reported once). local reported = {} -function Font.encode(text) +local function encodeUncached(text) local codes = {} for _, span in ipairs(Font.split(text)) do local code = span.code @@ -445,6 +453,65 @@ function Font.encode(text) return codes end +-- Font.width/Font.draw run for the same few strings every frame, and +-- Font.split allocates a table per glyph. The codes only depend on the +-- charmap and TTF that Font.load builds, so cache them per string until the +-- next load. Widths also depend on which page answers a code (the frame +-- row, the battle-extra swap), so that cache is also dropped when either of +-- those changes. Both are bounded: a cache past ENCODE_CACHE_MAX entries is +-- thrown away whole rather than tracked LRU. +local ENCODE_CACHE_MAX = 4096 +-- Bumped whenever a cached encode or width may have changed (Font.load, a +-- frame or battle-sheet swap), for callers that keep their own copies. +Font.revision = 0 +local encodeCache, encodeCount = {}, 0 +local stockSplit = Font.split +local widthCache, widthCount = {}, 0 + +function resetWidthCache() + widthCache, widthCount = {}, 0 + -- anything a caller derived from encode/width is stale too + Font.revision = Font.revision + 1 +end + +function resetTextCaches() + encodeCache, encodeCount = {}, 0 + resetWidthCache() +end + +-- Returns the SHARED cached array: read it, never modify or keep it. +local function cachedCodes(text) + -- a replaced split (a test double) is honoured and nothing is cached + if Font.split ~= stockSplit then return encodeUncached(text) end + local codes = encodeCache[text] + if codes then return codes end + codes = encodeUncached(text) + if type(text) == "string" then + if encodeCount >= ENCODE_CACHE_MAX then + encodeCache, encodeCount = {}, 0 + end + encodeCache[text] = codes + encodeCount = encodeCount + 1 + end + return codes +end + +-- Returns a fresh array: callers (TextBox's typewriter lines) append to +-- and keep the list, so they must never be handed the cached one. +function Font.encode(text) + local src = cachedCodes(text) + local codes = {} + for i = 1, #src do codes[i] = src[i] end + return codes +end + +local stockEncode = Font.encode + +-- Number of glyphs `text` draws as (#Font.split(text), without the spans). +function Font.glyphCount(text) + return #cachedCodes(text) +end + function Font.drawCode(code, x, y) local ttf = state and state.ttf if ttf and code >= TTF_BASE then @@ -481,13 +548,31 @@ function Font.advanceOf(code) return page and page.advance or GLYPH end +local stockAdvanceOf = Font.advanceOf + -- Pixel width of a string (glyph advances, not UTF-8 byte length). -- Multi-byte charmap entries like "¥" are one glyph; callers that -- right-align with `#text * 8` mis-place them. function Font.width(text) - local w = 0 - for _, code in ipairs(Font.encode(text)) do - w = w + Font.advanceOf(code) + if Font.encode ~= stockEncode or Font.advanceOf ~= stockAdvanceOf + or Font.split ~= stockSplit then + -- a replaced encode/advanceOf/split (a test double, a mod) is still + -- what gets measured, uncached + local w = 0 + for _, code in ipairs(Font.encode(text)) do w = w + Font.advanceOf(code) end + return w + end + local w = widthCache[text] + if w then return w end + w = 0 + local codes = cachedCodes(text) + for i = 1, #codes do + w = w + Font.advanceOf(codes[i]) + end + if type(text) == "string" then + if widthCount >= ENCODE_CACHE_MAX then resetWidthCache() end + widthCache[text] = w + widthCount = widthCount + 1 end return w end @@ -495,9 +580,11 @@ end -- Draw a plain single-line string at pixel (x, y). Returns the width -- drawn, which is #codes * 8 for every fixed-width page. function Font.draw(text, x, y) - local codes = Font.encode(text) + local codes = Font.encode == stockEncode and cachedCodes(text) + or Font.encode(text) local pen = x - for _, code in ipairs(codes) do + for i = 1, #codes do + local code = codes[i] Font.drawCode(code, pen, y) pen = pen + Font.advanceOf(code) end diff --git a/src/render/GbcPalette.lua b/src/render/GbcPalette.lua index 207f362f..0d8702f0 100644 --- a/src/render/GbcPalette.lua +++ b/src/render/GbcPalette.lua @@ -377,13 +377,42 @@ end -- The same, ignoring the COLOR mode. The present pass needs it: it IS the -- mode, so running its own palette back through resolve would flatten the -- green ramp to grey and the mode would do nothing. +-- The pal0..pal3 uniforms each shader last received, weak-keyed so a +-- recompiled shader (see compiler) starts with no record. Nothing outside +-- this file sends pal0..pal3 to these shaders, so a uniform whose value is +-- unchanged is skipped: a send to the active shader flushes LOVE's batch. +-- The {r,g,b} arrays are scratch; shader:send copies the values. +local PAL_UNIFORMS = { [0] = "pal0", "pal1", "pal2", "pal3" } +local palScratch = { [0] = { 0, 0, 0 }, { 0, 0, 0 }, { 0, 0, 0 }, { 0, 0, 0 } } +local palSent = setmetatable({}, { __mode = "k" }) + +local function sendPalette(sh, colors) + local rec = palSent[sh] + if not rec then + rec = { valid = false } + palSent[sh] = rec + end + -- only trusted once all four sends landed (an error propagates as before) + local valid = rec.valid + rec.valid = false + for i = 0, 3 do + local r, g, b = channel(colors, i + 1) + local k = i * 3 + if not (valid and rec[k + 1] == r and rec[k + 2] == g + and rec[k + 3] == b) then + local v = palScratch[i] + v[1], v[2], v[3] = r, g, b + sh:send(PAL_UNIFORMS[i], v) + rec[k + 1], rec[k + 2], rec[k + 3] = r, g, b + end + end + rec.valid = true +end + function GbcPalette.useRaw(colors) local sh = GbcPalette.shader() if not sh then return false end - for i = 0, 3 do - local r, g, b = channel(colors, i + 1) - sh:send("pal" .. i, { r, g, b }) - end + sendPalette(sh, colors) love.graphics.setShader(sh) return true end @@ -391,11 +420,8 @@ end function GbcPalette.useKeyed(colors) local sh = GbcPalette.keyedShader() if not sh then return false end - local resolved = GbcPalette.remap(GbcPalette.resolve(colors), GbcPalette.bgp) - for i = 0, 3 do - local r, g, b = channel(resolved, i + 1) - sh:send("pal" .. i, { r, g, b }) - end + sendPalette(sh, + GbcPalette.remap(GbcPalette.resolve(colors), GbcPalette.bgp)) love.graphics.setShader(sh) return true end diff --git a/src/render/HudTiles.lua b/src/render/HudTiles.lua index 80d112fe..7d016318 100644 --- a/src/render/HudTiles.lua +++ b/src/render/HudTiles.lua @@ -92,7 +92,11 @@ end local function put(t, x, y, tint) if not t then return end local r, g, b, a = love.graphics.getColor() - love.graphics.setColor(tint or { 1, 1, 1, 1 }) + if tint then + love.graphics.setColor(tint) + else + love.graphics.setColor(1, 1, 1, 1) + end love.graphics.draw(t.img, t.quad, x, y) love.graphics.setColor(r, g, b, a) end @@ -159,6 +163,19 @@ end -- caller that is animating the bar between two HP values -- (UpdateHPBar_AnimateHPBar); it scales with `segments` like the color -- thresholds do. Without it the length comes from mon.hp as before. +-- The fill tint for a bar colour, kept per colour table (the bar palettes +-- are a handful of fixed tables) and rebuilt if its values ever differ. +-- put() only reads it. +local fillTints = setmetatable({}, { __mode = "k" }) +local function fillTint(c) + local t = fillTints[c] + if t and t.r == c[1] and t.g == c[2] and t.b == c[3] then return t end + t = { math.min(1, c[1] / 170), math.min(1, c[2] / 170), + math.min(1, c[3] / 170), 1, r = c[1], g = c[2], b = c[3] } + fillTints[c] = t + return t +end + function HudTiles.drawHPBar(data, tx, ty, mon, barType, grayFill, segments, pixels) local x, y = tx * 8, ty * 8 segments = math.max(1, math.floor(segments or 6)) @@ -182,8 +199,7 @@ function HudTiles.drawHPBar(data, tx, ty, mon, barType, grayFill, segments, pixe local c = colors[3] -- GB color 2 is the fill shade -- the fill pixels are the 2/3-gray shade; divide so they land on -- the palette color exactly (the black outline stays black) - tint = { math.min(1, c[1] / 170), math.min(1, c[2] / 170), - math.min(1, c[3] / 170), 1 } + tint = fillTint(c) end end for i = 0, segments - 1 do diff --git a/src/render/PaletteFX.lua b/src/render/PaletteFX.lua index f991efaf..69e793f1 100644 --- a/src/render/PaletteFX.lua +++ b/src/render/PaletteFX.lua @@ -67,9 +67,37 @@ function PaletteFX.darkKey() return darkWorld and "#dark" or "" end -- black silhouettes until FLASH (#383). Applied to whatever 4-colour OBP the -- active mode resolved, with a distinct cache group so the lit and dark bakes -- of one sheet never collide in SpriteRenderer's obpCache. +-- +-- It runs per sprite per frame in a dark cave, so the permuted palette is +-- kept per source palette (and per map, should DARK_BGP ever be swapped) and +-- the group name per group; permute's result is only ever read. +local darkPermuted = setmetatable({}, { __mode = "k" }) +local darkGroupNames = {} + +-- permute(colors, map), remembered in memo[colors] while `map` stays the same +local function permuteMemo(memo, colors, map) + local hit = memo[colors] + if hit and hit.map == map then return hit.colors end + local out = PaletteFX.permute(colors, map) + if type(colors) == "table" then memo[colors] = { map = map, colors = out } end + return out +end + +-- tostring(group) .. suffix, remembered per group +local function suffixedGroup(names, group, suffix) + if group == nil or group ~= group then return tostring(group) .. suffix end + local name = names[group] + if not name then + name = tostring(group) .. suffix + names[group] = name + end + return name +end + function PaletteFX.darkObp(colors, group) if not (colors and darkWorld) then return colors, group end - return PaletteFX.permute(colors, PaletteFX.DARK_BGP), tostring(group) .. "dark" + return permuteMemo(darkPermuted, colors, PaletteFX.DARK_BGP), + suffixedGroup(darkGroupNames, group, "dark") end -- the same shift folded into a baked 8-group world palette array (ADVANCED) @@ -85,6 +113,9 @@ end -- home/fade.asm:52-58 local fadeObpMap = nil local fadeObpGroups = setmetatable({}, { __mode = "k" }) +-- per fade map: the permuted palettes and suffixed group names (see darkObp) +local fadePermuted = setmetatable({}, { __mode = "k" }) +local fadeGroupNames = setmetatable({}, { __mode = "k" }) function PaletteFX.setFadeObp(map) fadeObpMap = map @@ -98,7 +129,16 @@ function PaletteFX.fadeObp(colors, group) .. fadeObpMap[2] .. fadeObpMap[3] fadeObpGroups[fadeObpMap] = suffix end - return PaletteFX.permute(colors, fadeObpMap), tostring(group) .. suffix + local memo = fadePermuted[fadeObpMap] + local names = fadeGroupNames[fadeObpMap] + if not memo then + memo = setmetatable({}, { __mode = "k" }) + names = {} + fadePermuted[fadeObpMap] = memo + fadeGroupNames[fadeObpMap] = names + end + return permuteMemo(memo, colors, fadeObpMap), + suffixedGroup(names, group, suffix) end -- Classic DMG pea-soup greens (#9BBC0F / #8BAC0F / #306230 / #0F380F) @@ -369,6 +409,8 @@ end -- Active world bake tables for Advanced. Yellow merges gbc_yellow deltas -- via __index so shared Red tilesets stay identical byte-for-byte. +local yellowWorld, yellowWorldBase, yellowWorldDelta + function PaletteFX.worldPack() local pack = PaletteFX.gbcPack() local w = pack and pack.world @@ -377,7 +419,13 @@ function PaletteFX.worldPack() local y = PaletteFX.gbcYellowPack() local yw = y and y.world if not yw then return w end - return { + -- per sprite per frame on Yellow: build the merged view once per pair of + -- packs (a reload loads new tables, so identity is the whole key) + if yellowWorld and yellowWorldBase == w and yellowWorldDelta == yw then + return yellowWorld + end + yellowWorldBase, yellowWorldDelta = w, yw + yellowWorld = { tileGroups = setmetatable(yw.tileGroups or {}, { __index = w.tileGroups }), groupColors = setmetatable(yw.groupColors or {}, { __index = w.groupColors }), roofGroup = w.roofGroup, @@ -387,6 +435,7 @@ function PaletteFX.worldPack() and setmetatable(yw.spritePalettes, { __index = w.spritePalettes }) or w.spritePalettes, } + return yellowWorld end function PaletteFX.usesGbcPack(mode) @@ -775,27 +824,44 @@ end -- (`swap a; and 3` on the sprite's OAM offset, which has no equivalent -- here): a stable hash instead, so the same NPC instance always shows the -- same one of the 4 SPR_PAL_* colors. +local sourceKinds = {} +local seedGroups = setmetatable({}, { __mode = "k" }) + function PaletteFX.spriteObp(spriteDef, seed) local w = PaletteFX.worldPack() local src = spriteDef and (spriteDef.paletteSource or spriteDef.source) if not (w and src) then return nil end - local idx = tonumber(src:match("%[(%d+)%]")) + -- the parse depends only on the string (per sprite per frame otherwise) + local parsed = sourceKinds[src] + if not parsed then + local n = tonumber(src:match("%[(%d+)%]")) + parsed = n or (src:find("RedBikeSprite", 1, true) and "bike") + or (src:find("SurfingPikachuSprite", 1, true) and "surf") or "none" + sourceKinds[src] = parsed + end + local idx = type(parsed) == "number" and parsed or nil -- RedBikeSprite and SurfingPikachuSprite load outside -- SpriteSheetPointerTable, so their source has no bracketed index; -- they wear the player's OBP palette (spriteAssignment[0]) on Red/Blue. -- On Yellow, Surfing Pikachu uses the Pikachu yellow OBJ group (#1639). - if not idx and src:find("RedBikeSprite", 1, true) then + if parsed == "bike" then idx = 0 - elseif not idx and src:find("SurfingPikachuSprite", 1, true) then + elseif parsed == "surf" then idx = GameVersion.isYellow() and 60 or 0 end local group = idx and w.spriteAssignment[idx] if group == nil then return nil end if group == "random" then - local h = 0 - seed = tostring(seed or "") - for i = 1, #seed do h = (h * 31 + seed:byte(i)) % 4294967296 end - group = h % 4 + local key = seed == nil and "" or seed + local h = seedGroups[key] + if not h then + h = 0 + local str = tostring(seed or "") + for i = 1, #str do h = (h * 31 + str:byte(i)) % 4294967296 end + h = h % 4 + if key == key then seedGroups[key] = h end + end + group = h end return PaletteFX.darkObp(w.spritePalettes[group], group) end @@ -1009,15 +1075,89 @@ function PaletteFX.effectiveColors(c) return PaletteFX.permute(out, shadeMap) end +local stockEffectiveColors = PaletteFX.effectiveColors + +-- ------- uniform sends +-- +-- sendColors runs per zone per frame and per colored sprite, and a send to +-- the active shader flushes LOVE's draw batch. Remember what each shader +-- was last given (weak-keyed, so a released or replaced shader drops its +-- record) and only send a uniform whose value changed. The {r,g,b} arrays +-- are scratch: shader:send copies the values, so they are reused. +local UNIFORMS = { "c0", "c1", "c2", "c3" } +local scratch = { { 0, 0, 0 }, { 0, 0, 0 }, { 0, 0, 0 }, { 0, 0, 0 } } +local lastSent = setmetatable({}, { __mode = "k" }) + +-- A caller that sends c0..c3 itself (AnimPlayer's per-cell OBJ colors) has +-- to drop the record, or the next sendColors of the old colors would be +-- skipped. AnimPlayer:drawSprites does this after its direct sends. +function PaletteFX.forgetSent(sh) + local rec = sh and lastSent[sh] + if rec then rec.valid = false end +end + +local function sendOne(sh, rec, i, col, valid) + local r, g, b = col[1] / 255, col[2] / 255, col[3] / 255 + local k = i * 3 + if valid and rec[k - 2] == r and rec[k - 1] == g and rec[k] == b then + return + end + local v = scratch[i] + v[1], v[2], v[3] = r, g, b + sh:send(UNIFORMS[i], v) + rec[k - 2], rec[k - 1], rec[k] = r, g, b +end + +local function sendFour(sh, a, b, c, d) + local rec = lastSent[sh] + if not rec then + rec = { valid = false } + lastSent[sh] = rec + end + -- An error from send propagates as the unconditional sends' did; the + -- record only counts once all four landed, so a raise mid-way never + -- leaves a half-written palette looking current. + local valid = rec.valid + rec.valid = false + sendOne(sh, rec, 1, a, valid) + sendOne(sh, rec, 2, b, valid) + sendOne(sh, rec, 3, c, valid) + sendOne(sh, rec, 4, d, valid) + rec.valid = true +end + -- send a 4-color (0-255 RGB) palette to the shade-remap shader, after --- applying the active COLORS display mode +-- applying the active COLORS display mode. Resolves the same colors +-- effectiveColors returns, indexing through the mode and shade maps rather +-- than building the permuted tables. function PaletteFX.sendColors(shader, c) - c = PaletteFX.effectiveColors(c) if not c then return end - shader:send("c0", { c[1][1] / 255, c[1][2] / 255, c[1][3] / 255 }) - shader:send("c1", { c[2][1] / 255, c[2][2] / 255, c[2][3] / 255 }) - shader:send("c2", { c[3][1] / 255, c[3][2] / 255, c[3][3] / 255 }) - shader:send("c3", { c[4][1] / 255, c[4][2] / 255, c[4][3] / 255 }) + if PaletteFX.effectiveColors ~= stockEffectiveColors then + -- something replaced effectiveColors: honour it, as the send always did + c = PaletteFX.effectiveColors(c) + if not c then return end + return sendFour(shader, c[1], c[2], c[3], c[4]) + end + local mode = PaletteFX.mode or "gbc" + local base, modeMap = c, nil + if PaletteFX.customRamp and not PaletteFX.pickerActive() then + base = PaletteFX.customRamp + elseif mode == "og" then + base = PaletteFX.GRAYS + elseif mode == "og_inv" then + base, modeMap = PaletteFX.GRAYS, INV_MAP + elseif mode == "classic" then + base = PaletteFX.CLASSIC + elseif mode == "gbc_inv" then + modeMap = INV_MAP + end + local sm = shadeMap + local i0, i1, i2, i3 = 0, 1, 2, 3 + if sm then i0, i1, i2, i3 = sm[0], sm[1], sm[2], sm[3] end + if modeMap then + i0, i1, i2, i3 = modeMap[i0], modeMap[i1], modeMap[i2], modeMap[i3] + end + sendFour(shader, base[i0 + 1], base[i1 + 1], base[i2 + 1], base[i3 + 1]) end -- The same send with NO display-mode substitution and no shade map: the four @@ -1030,10 +1170,7 @@ function PaletteFX.sendShades(shader, c) -- headless (no love.graphics) leaves shader() nil; sendColors reaches the -- same no-op through effectiveColors returning nil for an absent palette if not shader or not c then return end - shader:send("c0", { c[1][1] / 255, c[1][2] / 255, c[1][3] / 255 }) - shader:send("c1", { c[2][1] / 255, c[2][2] / 255, c[2][3] / 255 }) - shader:send("c2", { c[3][1] / 255, c[3][2] / 255, c[3][3] / 255 }) - shader:send("c3", { c[4][1] / 255, c[4][2] / 255, c[4][3] / 255 }) + sendFour(shader, c[1], c[2], c[3], c[4]) end return PaletteFX diff --git a/src/render/ShaderFX.lua b/src/render/ShaderFX.lua index d67381e4..22acaf3c 100644 --- a/src/render/ShaderFX.lua +++ b/src/render/ShaderFX.lua @@ -673,6 +673,88 @@ local function sizeTable(state, i, dims, sizeUniforms, viewport, original) return values end +-- ------- per-pass uniform bookkeeping +-- +-- runPass used to rebuild and pcall-send every uniform every frame. Each +-- pass's shader is private to its chain state (built at activate, dropped +-- with the state) and nothing else sends to it, so `cached.sent` remembers +-- what the shader last received and a uniform is only re-sent when its value +-- changed. A failed send is remembered too: it would fail the same way again +-- for the same name and value. + +local textureUniformNames = {} +local function textureUniform(name) + local u = textureUniformNames[name] + if not u then + u = "LIBRA_TEXTURE_" .. name + textureUniformNames[name] = u + end + return u +end + +-- Fixup.packValues over `values` with ALL_DEFAULTS layered on top (a default +-- wins, exactly as copying it into `values` did), into tables reused across +-- frames: shader:send copies, so the vec4 groups need not be fresh. +local PACK_SLOT = { x = 1, y = 2, z = 3, w = 4 } +local function packPass(cached, manifest, defaults, values) + local packed = cached.packed + if not packed then + packed = {} + cached.packed = packed + cached.groups = {} + for _, entry in ipairs(manifest) do + if entry.component and not cached.groups[entry.uniform] then + cached.groups[entry.uniform] = { 0, 0, 0, 0 } + end + end + end + for uniform, group in pairs(cached.groups) do + group[1], group[2], group[3], group[4] = 0, 0, 0, 0 + packed[uniform] = group + end + for _, entry in ipairs(manifest) do + local value = defaults[entry.name] + if value == nil then value = values[entry.name] end + if entry.component then + packed[entry.uniform][PACK_SLOT[entry.component]] = value or 0 + else + packed[entry.uniform] = value + end + end + return packed +end + +-- Whether `value` differs from what `sent[name]` recorded. Arrays of plain +-- numbers/booleans are compared element-wise; anything else always sends. +local function changedSince(sent, name, value) + local last = sent[name] + if last == nil then return true end + if type(value) ~= "table" then return last ~= value end + if type(last) ~= "table" or last.n ~= #value then return true end + for k = 1, last.n do + local v = value[k] + local tv = type(v) + if (tv ~= "number" and tv ~= "boolean") or last[k] ~= v then return true end + end + return false +end + +local function remember(sent, name, value) + if type(value) ~= "table" then + sent[name] = value + return + end + local snap = sent[name] + if type(snap) ~= "table" then + snap = {} + sent[name] = snap + end + local n = #value + for k = 1, n do snap[k] = value[k] end + for k = n + 1, (snap.n or 0) do snap[k] = nil end + snap.n = n +end + -- Runs pass `i`, drawing `srcImg` into a freshly sized canvas. `lutByName` -- resolves a User-semantic sampler by the name the translation reported. local function runPass(state, i, pass, outputs, frameSource, lutByName, viewport, original, layer) @@ -684,6 +766,11 @@ local function runPass(state, i, pass, outputs, frameSource, lutByName, viewport local cached = assert(state.shaderCache[i], ("pass%d: shader was not built at activate"):format(i)) local shader, fragManifest = cached.shader, cached.fragManifest + local sent = cached.sent + if not sent then + sent = {} + cached.sent = sent + end for _, s in ipairs(pass.samplers) do local img @@ -694,13 +781,21 @@ local function runPass(state, i, pass, outputs, frameSource, lutByName, viewport elseif s.semantic == "User" then img = lutByName and lutByName[s.user_name] end assert(img, ("pass%d: no binding resolved for sampler %s (semantic=%s)") :format(i, s.name, tostring(s.semantic))) - pcall(shader.send, shader, "LIBRA_TEXTURE_" .. s.name, img) + local uniform = textureUniform(s.name) + if sent[uniform] ~= img then + pcall(shader.send, shader, uniform, img) + sent[uniform] = img + end end local values = sizeTable(state, i, dims, pass.size_uniforms, viewport, original) - for name, value in pairs(state.ALL_DEFAULTS) do values[name] = value end - local packed = Fixup.packValues(fragManifest, values) - for name, value in pairs(packed) do pcall(shader.send, shader, name, value) end + local packed = packPass(cached, fragManifest, state.ALL_DEFAULTS, values) + for name, value in pairs(packed) do + if changedSince(sent, name, value) then + pcall(shader.send, shader, name, value) + remember(sent, name, value) + end + end local srcImg = (i == 0) and frameSource or outputs[i - 1] local srcDims = passInputDims(state, i, original) @@ -1090,6 +1185,7 @@ end -- Crops the playfield rect out of `canvas` at `renderScale`. The output canvas -- is cached module-wide and reallocated only on a real size change. local cropCanvasCache = {} +local cropQuadCache = {} local function gridGeometry(rect, renderScale, originX, originY) local s = tonumber(rect.scale) @@ -1112,16 +1208,24 @@ local function cropToGbSource(canvas, rect, srcW, srcH, renderScale, layer, geo) -- Cheap insurance against a read-after-write hazard between this draw and -- whatever last rendered into `canvas`. love.graphics.flushBatch() - local outW, outH, quad + local outW, outH, qx, qy, qw, qh if geo then outW, outH = geo.crop.w, geo.crop.h - quad = love.graphics.newQuad(rect.x + geo.x0 + CROP_SAMPLE_BIAS, - rect.y + geo.y0 + CROP_SAMPLE_BIAS, geo.cols * geo.scale, geo.rows * geo.scale, - canvas:getPixelWidth(), canvas:getPixelHeight()) + qx, qy = rect.x + geo.x0 + CROP_SAMPLE_BIAS, rect.y + geo.y0 + CROP_SAMPLE_BIAS + qw, qh = geo.cols * geo.scale, geo.rows * geo.scale else outW, outH = roundDim(srcW * renderScale), roundDim(srcH * renderScale) - quad = love.graphics.newQuad(rect.x, rect.y, rect.w, rect.h, + qx, qy, qw, qh = rect.x, rect.y, rect.w, rect.h + end + -- One quad per layer, re-aimed each frame rather than allocated. A quad + -- without setViewport (a test double) is simply rebuilt. + local quad = cropQuadCache[layer] + if quad and quad.setViewport then + quad:setViewport(qx, qy, qw, qh, canvas:getPixelWidth(), canvas:getPixelHeight()) + else + quad = love.graphics.newQuad(qx, qy, qw, qh, canvas:getPixelWidth(), canvas:getPixelHeight()) + cropQuadCache[layer] = quad end local out local cached = cropCanvasCache[layer] diff --git a/src/render/SpriteRenderer.lua b/src/render/SpriteRenderer.lua index 9e48effe..a730c278 100644 --- a/src/render/SpriteRenderer.lua +++ b/src/render/SpriteRenderer.lua @@ -40,8 +40,21 @@ end -- hardware, where sprite palette index 0 is unconditionally transparent -- (same rule TileRenderer's getColor0KeyShader documents for tall grass). local obpCache = {} +-- obpCache is keyed by the string `path .. "#obp" .. group`; this indexes the +-- same entries by path then group, so a draw that hits the cache (every draw +-- after the first) builds no key string. A numeric group is looked up by +-- its string form, since the concatenation cannot tell 1 from "1" either. +local obpByPath = {} +local groupStrings = {} local function getObpImage(path, colors, group) + local byGroup = type(path) == "string" and obpByPath[path] + if byGroup then + local g = group + if type(g) == "number" then g = groupStrings[g] end + local hit = g ~= nil and byGroup[g] + if hit then return hit end + end local key = path .. "#obp" .. group if not obpCache[key] then local img @@ -59,7 +72,24 @@ local function getObpImage(path, colors, group) end obpCache[key] = img end - return obpCache[key] + local img = obpCache[key] + -- only string/number groups get here: concatenating anything else raised + if group ~= group or type(path) ~= "string" then return img end -- NaN key + if type(group) == "number" then + local g = groupStrings[group] + if not g then + g = "" .. group + groupStrings[group] = g + end + group = g + end + byGroup = obpByPath[path] + if not byGroup then + byGroup = {} + obpByPath[path] = byGroup + end + byGroup[group] = img + return img end SpriteRenderer.obpImage = getObpImage @@ -69,6 +99,7 @@ SpriteRenderer.obpImage = getObpImage function SpriteRenderer.invalidate() imageCache = {} obpCache = {} + obpByPath = {} end Assets.register(SpriteRenderer.invalidate) diff --git a/src/render/TextBox.lua b/src/render/TextBox.lua index cfe89c2d..155c6e84 100644 --- a/src/render/TextBox.lua +++ b/src/render/TextBox.lua @@ -437,7 +437,21 @@ function TextBox:moneyVisible() return not self.moneyWithChoice or not not self.choicePushed end +local step + +-- One logic frame. The ScrollTextUpOneLine slide advances here, after the +-- step (so a line begun this frame is first drawn 2px into its slide, as it +-- was when draw advanced it), and not in draw, whose rate follows the +-- display's refresh rather than the 60Hz logic clock. function TextBox:update(dt) + step(self, dt) + if self.scrollPx and self.scrollPx > 0 then + self.scrollPx = self.scrollPx - 2 + if self.scrollPx <= 0 then self.scrollPx = nil end + end +end + +function step(self, dt) local input = self.game.input self.blink = (self.blink + 1) % 480 -- home/text.asm:506 @@ -722,10 +736,6 @@ function TextBox:draw() Font.drawBox(self.boxTx, self.boxTy, self.boxTw, self.boxTh, paper) love.graphics.setColor(0, 0, 0, 1) end - if self.scrollPx and self.scrollPx > 0 then - self.scrollPx = self.scrollPx - 2 - if self.scrollPx <= 0 then self.scrollPx = nil end - end -- Only the retained line carries the offset: it slides up from where it -- already sat (line2Y) to line1Y. The incoming line is drawn at its home -- row instead, because offsetting it too put fresh glyphs 8px low -- on the @@ -757,14 +767,24 @@ function TextBox:draw() love.graphics.rectangle("fill", 13 * 8, 0, 5 * 8, 8) love.graphics.setColor(0, 0, 0, 1) local cap = 13 * 8 - for _, code in ipairs(Font.encode("MONEY")) do + if not self.moneyLabel or self.moneyRev ~= Font.revision then + self.moneyLabel, self.moneyAmount = Font.encode("MONEY"), nil + self.moneyRev = Font.revision + end + for _, code in ipairs(self.moneyLabel) do drawGlyph(code, cap, 0) cap = cap + Font.advanceOf(code) end end - local money = ("¥%d"):format(self.money() or 0) - local pen = 152 - Font.width(money) - for _, code in ipairs(Font.encode(money)) do + -- formatted and encoded only when the amount (or the font) changes + local amount = self.money() or 0 + if amount ~= self.moneyAmount or self.moneyRev ~= Font.revision then + local money = ("¥%d"):format(amount) + self.moneyAmount, self.moneyRev = amount, Font.revision + self.moneyCodes, self.moneyWidth = Font.encode(money), Font.width(money) + end + local pen = 152 - self.moneyWidth + for _, code in ipairs(self.moneyCodes) do drawGlyph(code, pen, 8) pen = pen + Font.advanceOf(code) end diff --git a/src/ui/ListMenu.lua b/src/ui/ListMenu.lua index 18829082..99111bec 100644 --- a/src/ui/ListMenu.lua +++ b/src/ui/ListMenu.lua @@ -45,6 +45,18 @@ local function fitLabel(text, pixels) return table.concat(out) .. "." end +-- item -> { label, budget, out }: fitLabel's result for a row, kept +-- until the row's label or pixel budget changes (draw asks every frame). +-- Weak-keyed and outside the item so caller-owned rows are never written. +local fitCache = setmetatable({}, { __mode = "k" }) +local function fitRowLabel(item, pixels) + local c = fitCache[item] + if c and c.label == item.label and c.budget == pixels then return c.out end + local out = fitLabel(item.label, pixels) + fitCache[item] = { label = item.label, budget = pixels, out = out } + return out +end + local ROWS = 7 -- LIST_MENU_BOX 4,2 - 19,12 (data/text_boxes.asm:13); 4 names from -- hlcoord 6,4 two rows apart (home/list_menu.asm:51-52, 364-365, 471-479) @@ -258,16 +270,27 @@ function ListMenu:close() if top == self then self.game.stack:pop() end end +-- self.footer paginated into one flat line list, re-run only when the +-- footer text changes (both footer draws below ask every frame) +local function footerLines(self) + local text = self.footer + if self.footerFlatFor ~= text or not self.footerFlat then + local flat = {} + for _, page in ipairs(require("src.render.TextBox").paginate(text)) do + for _, line in ipairs(page) do flat[#flat + 1] = line end + end + self.footerFlatFor, self.footerFlat = text, flat + end + return self.footerFlat +end + -- standard bottom text box (PrintText); long prompts wrap and keep -- their last two lines, like the GB's scrolled box (#115/#174) local function drawMessageBox(self) Font.drawBox(0, 12, 20, 6) love.graphics.setColor(0, 0, 0, 1) if not self.footer then return end - local flat = {} - for _, page in ipairs(require("src.render.TextBox").paginate(self.footer)) do - for _, line in ipairs(page) do flat[#flat + 1] = line end - end + local flat = footerLines(self) local y = 112 for i = math.max(1, #flat - 1), #flat do Font.draw(flat[i], 8, y) @@ -362,7 +385,7 @@ function ListMenu:draw() love.graphics.setColor(unpack(textColor)) local budget = ROW_RIGHT_MARGIN - ROW_LEFT if item.right then budget = budget - Font.width(item.right) - LABEL_GAP end - local label = fitLabel(item.label, budget) + local label = fitRowLabel(item, budget) Font.draw(label, 16, y) if item.ball then -- the Pokédex owned-ball marker tile -- one blank glyph after the name, measured in glyph advances rather @@ -400,10 +423,7 @@ function ListMenu:draw() drawMessageBox(self) elseif self.footer then -- bare footer (bag money line, etc.) - local flat = {} - for _, page in ipairs(require("src.render.TextBox").paginate(self.footer)) do - for _, line in ipairs(page) do flat[#flat + 1] = line end - end + local flat = footerLines(self) local y = (#flat >= 2) and 120 or 136 for i = math.max(1, #flat - 1), #flat do Font.draw(flat[i], 8, y) diff --git a/src/ui/Menu.lua b/src/ui/Menu.lua index 52e103e9..cf6fd34d 100644 --- a/src/ui/Menu.lua +++ b/src/ui/Menu.lua @@ -130,16 +130,24 @@ function Menu:draw() -- PlaceString at hlcoord 3,0 writes over the border row it was just -- drawn on (oak_speech2.asm:162-170) if self.title then - local title = self.title:gsub("{DONE}%s*$", "") - :gsub("{PROMPT}%s*$", "") - local lines = {} - for line in (title .. "\n"):gmatch("(.-)\n") do - lines[#lines + 1] = line + -- the title's lines and glyph counts, rebuilt only when it changes + -- (this draws every frame the menu is up) + local lines, glyphs = self.titleLines, self.titleGlyphs + if self.titleLinesFor ~= self.title or not lines then + local title = self.title:gsub("{DONE}%s*$", "") + :gsub("{PROMPT}%s*$", "") + lines, glyphs = {}, {} + for line in (title .. "\n"):gmatch("(.-)\n") do + lines[#lines + 1] = line + glyphs[#lines] = #Font.split(line) + end + self.titleLinesFor = self.title + self.titleLines, self.titleGlyphs = lines, glyphs end love.graphics.setColor(1, 1, 1, 1) - for i, line in ipairs(lines) do + for i = 1, #lines do love.graphics.rectangle("fill", (self.tx + 3) * 8, - (self.ty + i - 1) * 8, #Font.split(line) * 8, 8) + (self.ty + i - 1) * 8, glyphs[i] * 8, 8) end love.graphics.setColor(0, 0, 0, 1) for i, line in ipairs(lines) do diff --git a/src/ui/PartyMenu.lua b/src/ui/PartyMenu.lua index acf53f90..91453247 100644 --- a/src/ui/PartyMenu.lua +++ b/src/ui/PartyMenu.lua @@ -175,6 +175,29 @@ function PartyMenu.mirrorsIcon(name) end local iconImages = {} +-- the OBP0-baked copies: path -> ogGroup (or "") -> image, the "#obp" half +-- of the cache, nested so a drawn frame builds no key string per icon +local obpIconImages = {} +-- image -> { half = { [frame] = quad }, full = { [frame] = quad } }: icon +-- quads never change once built, so every drawn frame reuses them (they may +-- also sit in PaletteFX's UI redraw list, which only reads them) +local iconQuads = setmetatable({}, { __mode = "k" }) +local function iconQuad(img, kind, frame, w, iw, ih) + local byImg = iconQuads[img] + if not byImg then + byImg = { half = {}, full = {} } + iconQuads[img] = byImg + end + local byFrame = byImg[kind] + local q = byFrame[frame] + if not q then + q = love.graphics.newQuad(0, frame * 16, w, 16, iw, ih) + byFrame[frame] = q + end + return q +end +-- Sprites.iconPath's opts, reused (it only reads name / trueColor) +local iconPathOpts = {} -- engine/items/town_map.asm:514 local OAM_XFLIP = { sx = -1 } @@ -236,8 +259,10 @@ function PartyMenu.drawIcon(game, mon, x, y, selected, counter, forceAlt, obp) name = def and def.dex and icons.byDex and icons.byDex[def.dex] path = name and icons.icons and icons.icons[name] end + iconPathOpts.name, iconPathOpts.trueColor = name, trueColor path, trueColor = require("src.pokemon.Sprites") - .iconPath(game.data, mon, path, { name = name, trueColor = trueColor }) + .iconPath(game.data, mon, path, iconPathOpts) + iconPathOpts.name, iconPathOpts.trueColor = nil, nil if not path then return end -- Built-in icon classes are DMG 2bpp OBJ art and get the OBP0 bake; a -- mod's own image (an entry table rather than an icon name) is authored @@ -261,8 +286,18 @@ function PartyMenu.drawIcon(game, mon, x, y, selected, counter, forceAlt, obp) ogColors, ogGroup = PaletteFX.ogObjNormal() end end - local key = baked and (path .. "#obp" .. (ogGroup or "")) or path - if iconImages[key] == nil then + -- one cache, two halves: the plain image under its path, the baked one + -- under (path, ogGroup) -- the old path .. "#obp" .. group key, nested + local cache, key = iconImages, path + if baked then + cache = obpIconImages[path] + if not cache then + cache = {} + obpIconImages[path] = cache + end + key = ogGroup or "" + end + if cache[key] == nil then -- resolve through Assets so an overrides/ or transform-derived icon -- (e.g. a per-species image at assets/generated/icons/.png) is -- picked up the same way battle sprites are @@ -275,9 +310,9 @@ function PartyMenu.drawIcon(game, mon, x, y, selected, counter, forceAlt, obp) else ok, img = pcall(love.graphics.newImage, Assets.resolve(path)) end - iconImages[key] = ok and img or false + cache[key] = ok and img or false end - local img = iconImages[key] + local img = cache[key] if not img then return end local alt = forceAlt or false if selected then @@ -302,7 +337,7 @@ function PartyMenu.drawIcon(game, mon, x, y, selected, counter, forceAlt, obp) -- reuse overworld sheets whose walk-down frame is NOT symmetric, so -- drawing the raw 16x16 showed a tucked-back foot the hardware never -- displays (#276, absorbing #238). - local half = love.graphics.newQuad(0, frame * 16, 8, 16, iw, ih) + local half = iconQuad(img, "half", frame, 8, iw, ih) love.graphics.draw(img, half, x, y) -- sx = -1 about the block's right edge, so the flipped copy lands on -- x+8..x+16: the OAM_XFLIP half @@ -312,7 +347,7 @@ function PartyMenu.drawIcon(game, mon, x, y, selected, counter, forceAlt, obp) PaletteFX.markUiSpriteRedraw(img, half, x + 16, y, OAM_XFLIP) end elseif ih > 16 then - local quad = love.graphics.newQuad(0, frame * 16, 16, 16, iw, ih) + local quad = iconQuad(img, "full", frame, 16, iw, ih) love.graphics.draw(img, quad, x, y) if ogColors then PaletteFX.markUiSpriteRedraw(img, quad, x, y) end else diff --git a/src/ui/ShopMenu.lua b/src/ui/ShopMenu.lua index 4a8c4a48..fdafa897 100644 --- a/src/ui/ShopMenu.lua +++ b/src/ui/ShopMenu.lua @@ -285,9 +285,14 @@ local function drawClerk(menu) Font.drawBox(0, 12, 20, 6) love.graphics.setColor(0, 0, 0, 1) if menu.footer then - local flat = {} - for _, page in ipairs(TextBox.paginate(menu.footer)) do - for _, line in ipairs(page) do flat[#flat + 1] = line end + -- paginated once per footer text, not once per drawn frame + local flat = menu.shopFooterFlat + if menu.shopFooterFor ~= menu.footer or not flat then + flat = {} + for _, page in ipairs(TextBox.paginate(menu.footer)) do + for _, line in ipairs(page) do flat[#flat + 1] = line end + end + menu.shopFooterFor, menu.shopFooterFlat = menu.footer, flat end local y = 112 for i = math.max(1, #flat - 1), #flat do diff --git a/src/ui/SummaryMenu.lua b/src/ui/SummaryMenu.lua index af2c1918..3d7ca84a 100644 --- a/src/ui/SummaryMenu.lua +++ b/src/ui/SummaryMenu.lua @@ -17,6 +17,28 @@ local Strings = require("src.core.Strings") local Stats = require("src.pokemon.Stats") local Status = require("src.battle.Status") +-- front pics by path: opening the summary used to decode the PNG into a new +-- Image every time. Loaded exactly as before (the raw path, no Assets +-- resolution); a failed load is retried next open, as before. Hot reload +-- (Assets.flush) drops the cache so an edited pic is picked up again. +local frontPics = {} +local function frontPic(path) + local img = frontPics[path] + if img then return img end + local ok, loaded = pcall(love.graphics.newImage, path) + if ok and loaded then + frontPics[path] = loaded + return loaded + end + return nil +end +do + local Assets = require("src.render.Assets") + if Assets.register then -- test harnesses may stub Assets without it + Assets.register(function() frontPics = {} end) + end +end + local SummaryMenu = {} SummaryMenu.__index = SummaryMenu SummaryMenu.isOpaque = true @@ -48,8 +70,7 @@ function SummaryMenu.new(game, mon) local path, trueColor = Sprites.path(game.data, mon.species, "front", { mon = mon, kind = "summary" }) if path then - local ok, img = pcall(love.graphics.newImage, path) - self.sprite = ok and img or nil + self.sprite = frontPic(path) end self.spriteTrueColor = self.sprite and trueColor or false -- engine/pokemon/status_screen.asm:82,168-172 diff --git a/src/ui/TradeAnim.lua b/src/ui/TradeAnim.lua index a3e018aa..1ded53e3 100644 --- a/src/ui/TradeAnim.lua +++ b/src/ui/TradeAnim.lua @@ -328,6 +328,31 @@ local function skipAllowed() return envSkipAllowed or _G.POKEPORT_DEV_MODE == true end +-- sheet -> tile index -> 8x8 quad. The frame blocks redraw every frame +-- the trade machine is on screen; a tile's quad never changes, so it is +-- built once (it may also sit in PaletteFX's UI redraw list, read-only). +local tileQuads = setmetatable({}, { __mode = "k" }) +local function tileQuad(sheet, tile, iw, ih) + local byTile = tileQuads[sheet] + if not byTile then + byTile = {} + tileQuads[sheet] = byTile + end + local q = byTile[tile] + if not q then + q = love.graphics.newQuad((tile % 16) * 8, math.floor(tile / 16) * 8, + 8, 8, iw, ih) + byTile[tile] = q + end + return q +end + +-- markUiSpriteRedraw's flip opts (it only reads sx / sy off them) +local FLIP_OPTS = { + [1] = { [1] = { sx = 1, sy = 1 }, [-1] = { sx = 1, sy = -1 } }, + [-1] = { [1] = { sx = -1, sy = 1 }, [-1] = { sx = -1, sy = -1 } }, +} + -- engine/movie/trade.asm:20 function TradeAnim:skipHeld() if not skipAllowed() then return false end @@ -343,9 +368,7 @@ function TradeAnim:drawFrameBlock(blockId, x, y) local P = baked and require("src.render.PaletteFX") local iw, ih = sheet:getDimensions() for _, tile in ipairs(block) do - local tx = (tile.tile % 16) * 8 - local ty = math.floor(tile.tile / 16) * 8 - local quad = love.graphics.newQuad(tx, ty, 8, 8, iw, ih) + local quad = tileQuad(sheet, tile.tile, iw, ih) local sx = tile.xflip and -1 or 1 local sy = tile.yflip and -1 or 1 local ox = tile.xflip and 8 or 0 @@ -353,7 +376,7 @@ function TradeAnim:drawFrameBlock(blockId, x, y) love.graphics.draw(sheet, quad, x + tile.dx + ox, y + tile.dy + oy, 0, sx, sy) if P then P.markUiSpriteRedraw(sheet, quad, x + tile.dx + ox, y + tile.dy + oy, - { sx = sx, sy = sy }) + FLIP_OPTS[sx][sy]) end end else @@ -764,9 +787,20 @@ local function drawCableHoriz(self, y, x0, x1) love.graphics.setColor(1, 1, 1, 1) if self.img.cableHoriz then local iw, ih = self.img.cableHoriz:getDimensions() + -- one quad per run width, kept on the instance (the last run of a + -- span is usually the only partial one) + local quads = self.cableQuads + if not quads or quads.img ~= self.img.cableHoriz then + quads = { img = self.img.cableHoriz } + self.cableQuads = quads + end for x = x0, x1 - 1, iw do local drawW = math.min(iw, x1 - x) - local quad = love.graphics.newQuad(0, 0, drawW, ih, iw, ih) + local quad = quads[drawW] + if not quad then + quad = love.graphics.newQuad(0, 0, drawW, ih, iw, ih) + quads[drawW] = quad + end love.graphics.draw(self.img.cableHoriz, quad, x, y) end elseif self.img.cableSeg then diff --git a/src/ui/game3/battle_chrome.lua b/src/ui/game3/battle_chrome.lua index bc67feed..14e3b719 100644 --- a/src/ui/game3/battle_chrome.lua +++ b/src/ui/game3/battle_chrome.lua @@ -528,13 +528,21 @@ end local function elements_tile_quad(ti, sheet) sheet = sheet or BattleChrome._elements if not sheet or not love or not love.graphics then return nil end - local key = (sheet == BattleChrome._elementsExp and "exp_" or "elt_") .. tostring(ti) - if not BattleChrome._quads[key] then + -- Per-sheet subtables keyed by tile index (no per-tile string keys). + local subKey = sheet == BattleChrome._elementsExp and "exp_tiles" or "elt_tiles" + local sub = BattleChrome._quads[subKey] + if not sub then + sub = {} + BattleChrome._quads[subKey] = sub + end + local q = sub[ti] + if not q then local tw = 40 -- 320/8 local tx, ty = ti % tw, math.floor(ti / tw) - BattleChrome._quads[key] = love.graphics.newQuad(tx * 8, ty * 8, 8, 8, 320, 24) + q = love.graphics.newQuad(tx * 8, ty * 8, 8, 8, 320, 24) + sub[ti] = q end - return BattleChrome._quads[key] + return q end -- pokefirered/src/battle_interface.c:2050 diff --git a/src/ui/game3/frlg_font.lua b/src/ui/game3/frlg_font.lua index 9eee210b..bcf12b16 100644 --- a/src/ui/game3/frlg_font.lua +++ b/src/ui/game3/frlg_font.lua @@ -264,7 +264,7 @@ local function loadImage(candidates) if okId and id then local img = love.graphics.newImage(id) if img and img.setFilter then img:setFilter("nearest", "nearest") end - return img, path + return img, path, id end elseif love and love.filesystem and love.image and love.graphics then local okFd, fd = pcall(love.filesystem.newFileData, data, path) @@ -273,7 +273,7 @@ local function loadImage(candidates) if okId and id then local img = love.graphics.newImage(id) if img and img.setFilter then img:setFilter("nearest", "nearest") end - return img, path + return img, path, id end end end @@ -289,6 +289,42 @@ local function loadImage(candidates) return nil, nil end +-- Pack a sheet's fg and shadow images into one texture (fg on top, shadow +-- below a transparent gap) with matching quads. Each glyph still draws its +-- shadow then its fg, but from the same texture, so LOVE's autobatcher keeps +-- a whole string in one draw call instead of flushing on every texture swap. +-- Pixels are copied verbatim, so the output is unchanged. nil (draw from the +-- separate sheets) when either ImageData is missing or they do not match. +local ATLAS_GAP = 16 +local function pack_atlas(fgData, shData, quads) + if not (fgData and shData and quads and love and love.image and love.image.newImageData + and love.graphics and love.graphics.newImage and love.graphics.newQuad) then + return nil + end + local ok, atlas = pcall(function() + local w, h = fgData:getDimensions() + local sw, shh = shData:getDimensions() + if sw ~= w or shh ~= h then return nil end + if fgData.getFormat and shData.getFormat and fgData:getFormat() ~= shData:getFormat() then return nil end + local off = h + ATLAS_GAP + local fmt = fgData.getFormat and fgData:getFormat() or "rgba8" + local data = love.image.newImageData(w, off + h, fmt) + data:paste(fgData, 0, 0, 0, 0, w, h) + data:paste(shData, 0, off, 0, 0, w, h) + local img = love.graphics.newImage(data) + if img.setFilter then img:setFilter("nearest", "nearest") end + local aw, ah = img:getDimensions() + local fq, sq = {}, {} + for id, q in pairs(quads) do + local qx, qy, qw, qh = q:getViewport() + fq[id] = love.graphics.newQuad(qx, qy, qw, qh, aw, ah) + sq[id] = love.graphics.newQuad(qx, qy + off, qw, qh, aw, ah) + end + return { image = img, fg = fq, sh = sq, src = quads } + end) + return ok and atlas or nil +end + local function loadTable(path) local okC, CacheFs = pcall(require, "src.import.CacheFs") if not (okC and CacheFs and CacheFs.readActive) then return nil end @@ -387,8 +423,8 @@ local function loadFace(spec, name) error("FrlgFont: the active profile has no font face '" .. tostring(name) .. "'", 0) end local dir = spec.dir or "" - local fg, fgp = loadImage({ { path = dir .. fs.sheet .. "_fg.rgba", w = 256, h = 512 } }) - local sh = loadImage({ { path = dir .. fs.sheet .. "_shadow.rgba", w = 256, h = 512 } }) + local fg, fgp, fgData = loadImage({ { path = dir .. fs.sheet .. "_fg.rgba", w = 256, h = 512 } }) + local sh, _, shData = loadImage({ { path = dir .. fs.sheet .. "_shadow.rgba", w = 256, h = 512 } }) local widths = fs.widths and loadTable(dir .. fs.widths) local m = metricsFor(spec, fs.fontId) if not (fg and widths and m) then @@ -408,6 +444,7 @@ local function loadFace(spec, name) name = name, fg = fg, sh = sh, quads = quads, widths = widths, height = m.maxLetterHeight, pitch = m.maxLetterHeight + (m.lineSpacing or 0), letterSpacing = m.letterSpacing or 0, + atlas = pack_atlas(fgData, shData, quads), } FrlgFont._faces[name] = face log(fs.sheet .. " ready " .. tostring(fgp)) @@ -471,8 +508,8 @@ local function ensure() end FrlgFont._widths = widths or {} - local fg, fgp = loadImage(FG_PATHS) - local sh = loadImage(SH_PATHS) + local fg, fgp, fgData = loadImage(FG_PATHS) + local sh, _, shData = loadImage(SH_PATHS) if not fg then if not FrlgFont._logged then log("latin_normal font missing") @@ -490,6 +527,7 @@ local function ensure() quads[id] = love.graphics.newQuad(col * 16, row * 16, 16, 16, iw, ih) end FrlgFont._quads = quads + FrlgFont._atlas = pack_atlas(fgData, shData, quads) if not FrlgFont._logged then log("latin_normal ready " .. tostring(fgp)) FrlgFont._logged = true @@ -529,8 +567,8 @@ local function ensure_small() if chunk then widths = chunk() end end end - local fg, fgp = loadImage(SMALL_FG_PATHS) - local sh = loadImage(SMALL_SH_PATHS) + local fg, fgp, fgData = loadImage(SMALL_FG_PATHS) + local sh, _, shData = loadImage(SMALL_SH_PATHS) if not fg then return false end local iw, ih = fg:getDimensions() local quads = {} @@ -545,6 +583,7 @@ local function ensure_small() -- Sheets from extract_latin_small.py (ROM hwlat @ 0x1EAF00), CHARMAP-ordered. FrlgFont._small = { fg = fg, sh = sh, quads = quads, widths = widths or {}, _romBaked = true, + atlas = pack_atlas(fgData, shData, quads), } log("latin_small ready " .. tostring(fgp) .. " (ROM FONT_SMALL)") return true @@ -612,18 +651,18 @@ FrlgFont.JAPANESE_GLYPHS = japanese_glyphs() local function load_japanese(key, fgPaths, shPaths) if FrlgFont[key] ~= nil then return FrlgFont[key] or nil end - local fg = loadImage(fgPaths) + local fg, _, fgData = loadImage(fgPaths) if not fg then FrlgFont[key] = false return nil end - local sh = loadImage(shPaths) + local sh, _, shData = loadImage(shPaths) local iw, ih = fg:getDimensions() local quads = {} for code = 0, 511 do quads[code] = love.graphics.newQuad((code % 16) * 16, math.floor(code / 16) * 16, 16, 16, iw, ih) end - FrlgFont[key] = { fg = fg, sh = sh, quads = quads } + FrlgFont[key] = { fg = fg, sh = sh, quads = quads, atlas = pack_atlas(fgData, shData, quads) } return FrlgFont[key] end @@ -649,7 +688,7 @@ local function japanese_quad(glyphId, small) local sheet = small and load_japanese("_jpSmall", JP_SMALL_FG_PATHS, JP_SMALL_SH_PATHS) or load_japanese("_jpNormal", JP_FG_PATHS, JP_SH_PATHS) if not sheet then return nil end - return sheet.fg, sheet.sh, sheet.quads[glyphId - FrlgFont.JAPANESE_BASE] + return sheet.fg, sheet.sh, sheet.quads[glyphId - FrlgFont.JAPANESE_BASE], sheet.atlas end -- The remaining single characters of the Latin block of pret @@ -1162,6 +1201,14 @@ end local ADVANCE_SMALL = { small = true } local ADVANCE_NORMAL = {} +local function set_col(c) + if type(c) == "table" then + love.graphics.setColor(c[1] or 1, c[2] or 1, c[3] or 1, c[4] or 1) + else + love.graphics.setColor(1, 1, 1, 1) + end +end + --- Draw full string at pixel (x,y). -- opts.maxWidth clips (CopyGlyphToWindow). opts.colors = COLOR.NORMAL etc. -- opts.limitChars: only draw first N printable characters (typewriter). @@ -1210,22 +1257,15 @@ function FrlgFont.draw(text, x, y, opts) local drawn = 0 local pitch = opts.linePitch or (face and face.pitch) or (useSmall and FrlgFont.SMALL_LINE_PITCH or FrlgFont.LINE_PITCH) - local fg, sh, quads + local fg, sh, quads, atlas if face then - fg, sh, quads = face.fg, face.sh, face.quads + fg, sh, quads, atlas = face.fg, face.sh, face.quads, face.atlas elseif useSmall then - fg, sh, quads = FrlgFont._small.fg, FrlgFont._small.sh, FrlgFont._small.quads + fg, sh, quads, atlas = FrlgFont._small.fg, FrlgFont._small.sh, FrlgFont._small.quads, FrlgFont._small.atlas else - fg, sh, quads = FrlgFont._fg, FrlgFont._sh, FrlgFont._quads - end - - local function set_col(c) - if type(c) == "table" then - love.graphics.setColor(c[1] or 1, c[2] or 1, c[3] or 1, c[4] or 1) - else - love.graphics.setColor(1, 1, 1, 1) - end + fg, sh, quads, atlas = FrlgFont._fg, FrlgFont._sh, FrlgFont._quads, FrlgFont._atlas end + if atlas and (atlas.src ~= quads or not sh) then atlas = nil end for ttype, val, curCol in FrlgFont.scanTokens(text, baseColors) do if limit and drawn >= limit then break end @@ -1262,9 +1302,12 @@ function FrlgFont.draw(text, x, y, opts) end if penX + adv <= maxW or penX == 0 then local dx, dy = x + penX, y + penY - local gfg, gsh, q = fg, sh, quads[id] + local gfg, gsh, q, gat = fg, sh, quads[id], atlas + local aid = id if id >= FrlgFont.JAPANESE_BASE then - gfg, gsh, q = japanese_quad(id, useSmall) + gfg, gsh, q, gat = japanese_quad(id, useSmall) + aid = id - FrlgFont.JAPANESE_BASE + if not gsh then gat = nil end end if q then -- Draw background / highlight fill if bg is not transparent @@ -1272,15 +1315,24 @@ function FrlgFont.draw(text, x, y, opts) set_col(curCol.bg) love.graphics.rectangle("fill", dx, dy, adv, pitch) end + local aq = gat and gat.fg[aid] -- Draw shadow if gsh and curCol.shadow and (not curCol.shadow[4] or curCol.shadow[4] > 0) then set_col(curCol.shadow) - love.graphics.draw(gsh, q, dx, dy) + if aq then + love.graphics.draw(gat.image, gat.sh[aid], dx, dy) + else + love.graphics.draw(gsh, q, dx, dy) + end end -- Draw foreground if curCol.fg and (not curCol.fg[4] or curCol.fg[4] > 0) then set_col(curCol.fg) - love.graphics.draw(gfg, q, dx, dy) + if aq then + love.graphics.draw(gat.image, aq, dx, dy) + else + love.graphics.draw(gfg, q, dx, dy) + end end end penX = penX + japanese_step(id, adv, minW, jpn, opts, useSmall) @@ -1381,6 +1433,7 @@ function FrlgFont.invalidate() FrlgFont._fg = nil FrlgFont._sh = nil FrlgFont._quads = nil + FrlgFont._atlas = nil FrlgFont._small = nil FrlgFont._keypad = nil FrlgFont._jpNormal = nil diff --git a/src/ui/game3/rse/pokedex.lua b/src/ui/game3/rse/pokedex.lua index 34892900..cafa5747 100644 --- a/src/ui/game3/rse/pokedex.lua +++ b/src/ui/game3/rse/pokedex.lua @@ -213,8 +213,25 @@ local function bgPal(s) return Gfx.bgPalette(paletteSet(s)) end +-- Read-only bg palettes for per-frame draws, rebuilt when the manifest +-- changes. bgPal returns a fresh copy for callers that edit it. +local _readPals, _readPalsMan = {}, nil +local function bgPalRead(s) + local man = Gfx.manifest() + if man ~= _readPalsMan then + _readPals, _readPalsMan = {}, man + end + local set = paletteSet(s) + local pal = _readPals[set] + if not pal then + pal = Gfx.bgPalette(set) + _readPals[set] = pal + end + return pal +end + local function textColors(s, fgIdx, shIdx) - local pal = bgPal(s) + local pal = bgPalRead(s) return { fg = Gfx.color(pal[fgIdx or 15]), shadow = Gfx.color(pal[shIdx or 3]), bg = { 0, 0, 0, 0 } } end @@ -2130,7 +2147,7 @@ end local function listRowsText(s) local colors = textColors(s, 15, 3) local vofs = s.bg2vofs or 0 - local pal = bgPal(s) + local pal = bgPalRead(s) local ball = Gfx.sprite("caught_ball", 0, 8, 16, { pal[0], pal[1], pal[2], pal[3], pal[4], pal[5], pal[6], pal[7], pal[8], pal[9], pal[10], pal[11], pal[12], pal[13], pal[14], pal[15] }) -- pokeemerald/src/pokedex.c:847 diff --git a/src/ui/game3/trainer_card.lua b/src/ui/game3/trainer_card.lua index ca5440f2..e4d5b984 100644 --- a/src/ui/game3/trainer_card.lua +++ b/src/ui/game3/trainer_card.lua @@ -28,6 +28,11 @@ local _cards = nil local _cardEdge = {} local _screens = nil local _assetsTried = false +-- RSE front pics keyed by trainer pic id (false = load failed), and the +-- badge quads keyed by the badge sheet they were built for. +local _rsePics = {} +local _rseBadgeQuads = nil +local _rseBadgeQuadsImg = nil local function read_cache_file(path) local ok, CacheFs = pcall(require, "src.import.CacheFs") @@ -997,7 +1002,11 @@ local function drawRse(c) else -- pokeemerald/src/trainer_card.c:1895 local picId = c.female and m.pics.female or m.pics.male - local pic = load_rgba_image({ string.format("data/generated/gba/trainers/front/%d.rgba", picId) }, 64, 64) + local pic = _rsePics[picId] + if pic == nil then + pic = load_rgba_image({ string.format("data/generated/gba/trainers/front/%d.rgba", picId) }, 64, 64) or false + _rsePics[picId] = pic + end if pic then love.graphics.draw(pic, (19 + m.picOffset[1]) * 8, (5 + m.picOffset[2]) * 8) end @@ -1009,10 +1018,17 @@ local function drawRse(c) end local badges = Kit.image(m.badges.png) if badges then - local bw, bh = badges:getDimensions() + if _rseBadgeQuadsImg ~= badges then + local bw, bh = badges:getDimensions() + _rseBadgeQuads = {} + for i = 1, 8 do + _rseBadgeQuads[i] = love.graphics.newQuad((i - 1) * 16, 0, 16, 16, bw, bh) + end + _rseBadgeQuadsImg = badges + end for i = 1, 8 do if c.badges[i] then - love.graphics.draw(badges, love.graphics.newQuad((i - 1) * 16, 0, 16, 16, bw, bh), (4 + (i - 1) * 3) * 8, 15 * 8) + love.graphics.draw(badges, _rseBadgeQuads[i], (4 + (i - 1) * 3) * 8, 15 * 8) end end end diff --git a/src/ui/gen2/BattleAnimView.lua b/src/ui/gen2/BattleAnimView.lua index a8a82a96..b3ff952c 100644 --- a/src/ui/gen2/BattleAnimView.lua +++ b/src/ui/gen2/BattleAnimView.lua @@ -25,6 +25,8 @@ local Palettes = require("src.world.gen2.Palettes") local BattleAnimView = {} BattleAnimView.__index = BattleAnimView +local EMPTY = {} + local SCREEN_W, SCREEN_H = 160, 144 -- An OBJ at OAM (x, y) draws at (x - 8, y - 16). @@ -64,14 +66,19 @@ function BattleAnimView:image(path) return cached or nil end +-- Nested by sheet then index, so a lookup builds no string. function BattleAnimView:quad(sheetName, index, wide, image) - local key = sheetName .. ":" .. index - local quad = self.quads[key] + local bySheet = self.quads[sheetName] + if not bySheet then + bySheet = {} + self.quads[sheetName] = bySheet + end + local quad = bySheet[index] if not quad then local w, h = image:getDimensions() quad = love.graphics.newQuad( (index % wide) * 8, math.floor(index / wide) * 8, 8, 8, w, h) - self.quads[key] = quad + bySheet[index] = quad end return quad end @@ -93,30 +100,55 @@ end -- colours; the other six are the fixed block from -- gfx/battle_anims/battle_anims.pal, which the extractor reads into -- palettes.battleObjects. +-- Palettes.monColors, memoized per (palettes, species, shiny): it builds five +-- tables a call and this runs per OAM object per frame. The answer is a pure +-- function of those three, and the shader only reads it. +function BattleAnimView:monColors(species, shiny) + local cache = self.monColorCache + if not cache or cache.palettes ~= self.palettes then + cache = { palettes = self.palettes, [true] = {}, [false] = {} } + self.monColorCache = cache + end + local byShiny = cache[shiny and true or false] + local colors = byShiny[species] + if colors == nil then + if species == nil then return Palettes.monColors(self.palettes, species, shiny) end + colors = Palettes.monColors(self.palettes, species, shiny) or false + byShiny[species] = colors + end + return colors or nil +end + function BattleAnimView:objPalette(name, battle) if name == "PAL_BATTLE_OB_ENEMY" then local enemy = battle and battle.enemy - return enemy and Palettes.monColors(self.palettes, enemy.species, enemy.shiny) + return enemy and self:monColors(enemy.species, enemy.shiny) end if name == "PAL_BATTLE_OB_PLAYER" then local player = battle and battle.player - return player and Palettes.monColors(self.palettes, player.species, player.shiny) + return player and self:monColors(player.species, player.shiny) end local set = self.palettes and self.palettes.battleObjects return set and set[name] or nil end --- One frame's OBJ layer. +-- One frame's OBJ layer. The palette shader is set once per run of objects +-- sharing a palette and the caller's shader restored once at the end: what +-- GbcPalette.with did per object, without the closure and pcall each. function BattleAnimView:drawObjects(runner, battle) local G = love.graphics G.setColor(1, 1, 1, 1) + local shaded = GbcPalette.available() + local previous = shaded and G.getShader and G.getShader() or nil + local active = nil + local gfx = self.data.gfx or EMPTY for _, obj in ipairs(runner:oam()) do local entry, index = sheetForTile(runner, obj.tile) -- The two battler-pic pseudo-sheets are the mons' own tiles; nothing in -- the cache holds them as a sheet, so they are simply not drawn rather -- than drawn from the wrong image. if entry and not entry.battler then - local sheet = (self.data.gfx or {})[entry.gfx] + local sheet = gfx[entry.gfx] local image = sheet and self:image(sheet.image) if image then local wide = sheet.wide or 8 @@ -132,19 +164,17 @@ function BattleAnimView:drawObjects(runner, battle) local syScale = bit.band(obj.attr, OAM_YFLIP) ~= 0 and -1 or 1 local ox = sxScale < 0 and 8 or 0 local oy = syScale < 0 and 8 or 0 - local colors = self:objPalette(obj.palette, battle) - local function body() - G.draw(image, quad, x + ox, y + oy, 0, sxScale, syScale) - end - if colors and GbcPalette.available() then - GbcPalette.with(colors, body) - else - body() + local colors = shaded and self:objPalette(obj.palette, battle) or nil + if colors ~= active then + if colors then GbcPalette.use(colors) else G.setShader(previous) end + active = colors end + G.draw(image, quad, x + ox, y + oy, 0, sxScale, syScale) end end end end + if active then G.setShader(previous) end end -- True when the BG layer needs the scanline treatment at all; a plain @@ -269,24 +299,34 @@ end BattleAnimView.bgpBands = bgpBands -- engine/battle_anims/bg_effects.asm:2638 +-- +-- One scanline's source row and x shift, or nil when the row shows nothing. +local function scanline(bg, scy, row) + local dx, src = 0, row + scy + -- home/lcd.asm:12 + local inWindow = bg.lcdc and bg.lcdc ~= "BGP" + and row > bg.lyStart and row <= bg.lyEnd + local byte = inWindow and (bg.lyBackup[row - 1] or 0) or 0 + if inWindow then + local value = signed(byte) + if bg.lcdc == "SCX" then dx = -value else src = src + value end + -- home/lcd.asm:3 + if byte ~= 0x90 and scy == 0 then + if src < 0 then src = 0 elseif src >= SCREEN_H then src = SCREEN_H - 1 end + end + end + if (not inWindow or byte ~= 0x90) and src >= 0 and src < SCREEN_H then + return src, dx + end + return nil +end + function BattleAnimView.scanlines(bg) local lines = {} local scy = signed(bg.scy) for row = 0, SCREEN_H - 1 do - local dx, src = 0, row + scy - -- home/lcd.asm:12 - local inWindow = bg.lcdc and bg.lcdc ~= "BGP" - and row > bg.lyStart and row <= bg.lyEnd - local byte = inWindow and (bg.lyBackup[row - 1] or 0) or 0 - if inWindow then - local value = signed(byte) - if bg.lcdc == "SCX" then dx = -value else src = src + value end - -- home/lcd.asm:3 - if byte ~= 0x90 and scy == 0 then - if src < 0 then src = 0 elseif src >= SCREEN_H then src = SCREEN_H - 1 end - end - end - if (not inWindow or byte ~= 0x90) and src >= 0 and src < SCREEN_H then + local src, dx = scanline(bg, scy, row) + if src then lines[#lines + 1] = { src = src, dest = row, dx = dx } end end @@ -339,8 +379,11 @@ function BattleAnimView:present(runner, drawBg, battle) -- hSCX / hSCY move the whole background; the per-scanline overrides only -- apply inside the effect's own window. local baseX = -signed(bg.scx) - for _, line in ipairs(BattleAnimView.scanlines(bg)) do - self:blitRowAt(line.src, line.dest, baseX + line.dx) + -- BattleAnimView.scanlines row by row, without a table per scanline. + local scy = signed(bg.scy) + for row = 0, SCREEN_H - 1 do + local src, dx = scanline(bg, scy, row) + if src then self:blitRowAt(src, row, baseX + dx) end end -- Shaderless boot: the panel is raw grayscale, so there are no palettes to -- permute and the entry's BRIGHTNESS is the only thing left to reproduce. diff --git a/src/ui/gen2/BattleHud.lua b/src/ui/gen2/BattleHud.lua index e51c8a7f..57af128d 100644 --- a/src/ui/gen2/BattleHud.lua +++ b/src/ui/gen2/BattleHud.lua @@ -82,13 +82,18 @@ function BattleHud:image(key) end -- One 8x8 tile out of a horizontal strip, cached per (sheet, index). +-- Nested by image then index, so a lookup builds no string. function BattleHud:quad(image, index) - local key = tostring(image) .. ":" .. index - local quad = self.quads[key] + local byImage = self.quads[image] + if not byImage then + byImage = {} + self.quads[image] = byImage + end + local quad = byImage[index] if not quad then local w, h = image:getDimensions() quad = love.graphics.newQuad(index * 8, 0, 8, 8, w, h) - self.quads[key] = quad + byImage[index] = quad end return quad end @@ -120,20 +125,26 @@ function BattleHud:drawTile(key, firstTile, tile, tx, ty, colors, mirror) if index < 0 then return false end local G = love.graphics G.setColor(1, 1, 1, 1) - local function body() - if mirror then - -- Flip in place: the origin moves a tile right and x scales by -1. - G.draw(image, self:quad(image, index), tx * 8 + 8, ty * 8, 0, -1, 1) - else - G.draw(image, self:quad(image, index), tx * 8, ty * 8) - end - end + local quad = self:quad(image, index) -- home/fade.asm:35 (RotateThreePalettesRight) - if GbcPalette.available() then - GbcPalette.with(colors or GbcPalette.DMG_SHADES, body) + -- GbcPalette.with without the per-tile closure: set, draw, restore. + local shaded = GbcPalette.available() + local previous + if shaded then + previous = G.getShader and G.getShader() or nil + GbcPalette.use(colors or GbcPalette.DMG_SHADES) else G.setColor(0, 0, 0, 1) - body() + end + if mirror then + -- Flip in place: the origin moves a tile right and x scales by -1. + G.draw(image, quad, tx * 8 + 8, ty * 8, 0, -1, 1) + else + G.draw(image, quad, tx * 8, ty * 8) + end + if shaded then + G.setShader(previous) + else G.setColor(1, 1, 1, 1) end return true diff --git a/src/ui/gen2/BattleState.lua b/src/ui/gen2/BattleState.lua index 47aecf5a..7dc175e8 100644 --- a/src/ui/gen2/BattleState.lua +++ b/src/ui/gen2/BattleState.lua @@ -622,6 +622,12 @@ function BattleState.new(game, opts) species = enemy and enemy.species, level = enemy and enemy.level, }) + -- queue both entrance cries on the chip audio worker during the intro so + -- playCry doesn't render them on the frame they play (no-op without one) + local data = game and game.data + for _, mon in ipairs({ enemy or false, player or false }) do + if mon and mon.species then pcall(Sound.prewarmCry, data, mon.species) end + end return self end @@ -750,7 +756,10 @@ function BattleState:pic(mon, back) -- picked -- resolving the species row again would throw the form away. The -- two extra keys are what Gen 2 genuinely carries more of: the Unown letter -- and the shiny flag that decides the palette. - if path then + -- Unhooked, Sprites.pic hands back exactly (path, trueColor); the ctx is + -- only built when a subscriber is there to read it (drawPic asks twice a + -- frame). + if path and Runtime.wantsHook("pokemon.sprite") then path, trueColor = Sprites.pic(path, { species = mon.species, side = back and "back" or "front", @@ -937,6 +946,42 @@ function BattleState:isUnderground(side, mon) and (self.vanishSeen and self.vanishSeen[side]) and true or false end +-- Palettes.monColors / trainerColors, memoized per palettes table: each +-- builds five tables a call and drawPic asks twice a frame. Both are pure +-- functions of the palette data, and the shader only reads the answer. +local function colorMemo(self) + local memo = self.picColorMemo + if not memo or memo.palettes ~= self.palettes then + memo = { palettes = self.palettes, [true] = {}, [false] = {}, trainers = {} } + self.picColorMemo = memo + end + return memo +end + +function BattleState:cachedMonColors(species, shiny) + if species == nil then + return Palettes.monColors(self.palettes, species, shiny) + end + local bucket = colorMemo(self)[shiny and true or false] + local colors = bucket[species] + if colors == nil then + colors = Palettes.monColors(self.palettes, species, shiny) or false + bucket[species] = colors + end + return colors or nil +end + +function BattleState:cachedTrainerColors(row) + local key = row or "PLAYER" + local bucket = colorMemo(self).trainers + local colors = bucket[key] + if colors == nil then + colors = Palettes.trainerColors(self.palettes, row) or false + bucket[key] = colors + end + return colors or nil +end + function BattleState:drawPic(mon, back) -- During the intro slide the player-side pic belongs to presentSlide's -- backpic overlay, not to the baked bands (see BattleAnimView). @@ -1041,17 +1086,17 @@ function BattleState:drawPic(mon, back) -- No mon on this side at all in the catching tutorial, where the box holds -- the DUDE's back-pic and nothing else for the whole battle. local colors = self.palettes and mon - and Palettes.monColors(self.palettes, mon.species, mon.shiny) + and self:cachedMonColors(mon.species, mon.shiny) if trainerBack then -- PAL_BATTLE_OB_PLAYER: the player's own colours, which are row 0 of -- TrainerPalettes (Chris shares Cal's). -- engine/gfx/color.asm:683-696 local row = Gen2Save.isFemale(self.save) and "FALKNER" or "PLAYER" - colors = Palettes.trainerColors(self.palettes, row) - or Palettes.trainerColors(self.palettes, "PLAYER") or colors + colors = self:cachedTrainerColors(row) + or self:cachedTrainerColors("PLAYER") or colors elseif enemyTrainer then -- The opponent's class row out of the same TrainerPalettes table. - colors = Palettes.trainerColors(self.palettes, self.enemyTrainerClass) + colors = self:cachedTrainerColors(self.enemyTrainerClass) or colors end -- ../pokecrystal/engine/gfx/cgb_layouts.asm:67 @@ -4374,14 +4419,23 @@ end -- The four labels the battle menu draws. Inside the contest the third one -- carries the park ball count, which .PrintParkBallsRemaining writes with -- PRINTNUM_LEADINGZEROS over two digits. +-- +-- The list is refilled in place each call (the menu asks every frame); the +-- lookups themselves still run, so a catalog a mod edits live is followed. function BattleState:menuLabels() - if not self.contest then - return { Strings(MENU[1]), Strings(MENU[2]), - Strings(MENU[3]), Strings(MENU[4]) } + local labels = self.menuLabelList + if not labels then + labels = {} + self.menuLabelList = labels end - return { Strings(MENU[1]), Strings(MENU[2]), - Strings(CONTEST_BALL_LABEL, BugContest.ballsLeft(self.save)), - Strings(MENU[4]) } + labels[1], labels[2], labels[4] = + Strings(MENU[1]), Strings(MENU[2]), Strings(MENU[4]) + if not self.contest then + labels[3] = Strings(MENU[3]) + else + labels[3] = Strings(CONTEST_BALL_LABEL, BugContest.ballsLeft(self.save)) + end + return labels end -- The message on the cart's own two rows: 14 and 16, with 15 blank between diff --git a/src/ui/gen2/BoxMenu.lua b/src/ui/gen2/BoxMenu.lua index 0644d3eb..56332e9b 100644 --- a/src/ui/gen2/BoxMenu.lua +++ b/src/ui/gen2/BoxMenu.lua @@ -852,6 +852,39 @@ end -- GetFrontpic's `cp EGG / jr nz, .not_egg` arm hands back EggPic, never the -- hatchling's pic (engine/gfx/load_pics.asm:88-91); it rides menu_gfx.eggHatch, -- with the party list's ICON_EGG standing in for a cache built before that. +-- One reusable quad per role, re-aimed per draw rather than a new Quad a +-- frame. nil when the backend cannot make one (the old pcall's degrade). +local function reusedQuad(self, slot, x, y, w, h, sw, sh) + local quads = self.reusedQuads + if not quads then + quads = {} + self.reusedQuads = quads + end + local quad = quads[slot] + if quad then + quad:setViewport(x, y, w, h, sw, sh) + return quad + end + local ok, made = pcall(love.graphics.newQuad, x, y, w, h, sw, sh) + if not ok then return nil end + quads[slot] = made + return made +end + +-- GbcPalette.with without a closure: set `colors` (when there is a shader), +-- returning what finishShade needs to put the caller's shader back. +local function startShade(colors) + if not (colors and GbcPalette.available()) then return false end + local G = love.graphics + local previous = G.getShader and G.getShader() or nil + GbcPalette.use(colors) + return true, previous +end + +local function finishShade(shaded, previous) + if shaded then love.graphics.setShader(previous) end +end + function BoxMenu:drawEggPic(mon) local G = love.graphics local colors = self:panelColors("EGG", mon and mon.shiny) @@ -866,18 +899,15 @@ function BoxMenu:drawEggPic(mon) local w = entry.width or 16 local h = math.min(entry.height or 16, image:getHeight()) if (entry.frames or 1) > 1 then h = math.floor(h / entry.frames) end - local ok, quad = pcall(love.graphics.newQuad, 0, 0, w, h, + local quad = reusedQuad(self, "egg", 0, 0, w, h, image:getWidth(), image:getHeight()) - if not ok then return end + if not quad then return end local x = PIC_X * 8 + math.floor((7 * 8 - w * 2) / 2) local y = PIC_Y * 8 + math.floor((7 * 8 - h * 2) / 2) G.setColor(1, 1, 1, 1) - local function body() G.draw(image, quad, x, y, 0, 2, 2) end - if colors and GbcPalette.available() then - GbcPalette.with(colors, body) - else - body() - end + local shaded, previous = startShade(colors) + G.draw(image, quad, x, y, 0, 2, 2) + finishShade(shaded, previous) G.setColor(1, 1, 1, 1) end @@ -889,18 +919,14 @@ function BoxMenu:drawHeldIcon(mon) local gfx = (self.menuGfx or {}).billsPc local image = self:image(gfx and gfx.icons) if not image then return end - local ok, quad = pcall(love.graphics.newQuad, row * 8, 0, 8, 8, - image:getDimensions()) - if not ok then return end + local iw, ih = image:getDimensions() + local quad = reusedQuad(self, "held", row * 8, 0, 8, 8, iw, ih) + if not quad then return end local G = love.graphics G.setColor(1, 1, 1, 1) - local function body() G.draw(image, quad, ICON_X * 8, ICON_Y * 8) end - local colors = gfx and gfx.palette - if colors and GbcPalette.available() then - GbcPalette.with(colors, body) - else - body() - end + local shaded, previous = startShade(gfx and gfx.palette) + G.draw(image, quad, ICON_X * 8, ICON_Y * 8) + finishShade(shaded, previous) G.setColor(1, 1, 1, 1) end @@ -911,25 +937,17 @@ function BoxMenu:drawBoxArrows() local image = self:image(gfx and gfx.icons) if not image then return end local G = love.graphics - local quads = {} - for _, arrow in ipairs({ ARROW_LEFT, ARROW_RIGHT }) do - local ok, quad = pcall(love.graphics.newQuad, arrow[1] * 8, 0, 8, 8, - image:getDimensions()) - if not ok then return end - quads[#quads + 1] = { quad, arrow[2] } - end + local iw, ih = image:getDimensions() + local left = reusedQuad(self, "arrowLeft", ARROW_LEFT[1] * 8, 0, 8, 8, iw, ih) + if not left then return end + local right = reusedQuad(self, "arrowRight", ARROW_RIGHT[1] * 8, 0, 8, 8, + iw, ih) + if not right then return end G.setColor(1, 1, 1, 1) - local function body() - for _, entry in ipairs(quads) do - G.draw(image, entry[1], entry[2] * 8, ARROW_ROW * 8) - end - end - local colors = gfx and gfx.palette - if colors and GbcPalette.available() then - GbcPalette.with(colors, body) - else - body() - end + local shaded, previous = startShade(gfx and gfx.palette) + G.draw(image, left, ARROW_LEFT[2] * 8, ARROW_ROW * 8) + G.draw(image, right, ARROW_RIGHT[2] * 8, ARROW_ROW * 8) + finishShade(shaded, previous) G.setColor(1, 1, 1, 1) end diff --git a/src/ui/gen2/Chrome.lua b/src/ui/gen2/Chrome.lua index be2228a2..077561ab 100644 --- a/src/ui/gen2/Chrome.lua +++ b/src/ui/gen2/Chrome.lua @@ -13,6 +13,9 @@ local Font = require("src.render.Font") local GbcPalette = require("src.render.GbcPalette") +local WHITE = { 255, 255, 255 } +local BLACK = { 0, 0, 0 } + local Chrome = {} -- charmap.asm: ▶ is the menu cursor, ▷ its hollow "held" form, ▼ the @@ -32,7 +35,11 @@ function Chrome.paletteFill(px, py, pw, ph, palette) local G = love.graphics G.setColor(1, 1, 1, 1) if GbcPalette.available() then - GbcPalette.with(palette, function() G.rectangle("fill", px, py, pw, ph) end) + -- GbcPalette.with without the closure: set, draw, restore. + local previous = G.getShader and G.getShader() or nil + GbcPalette.use(palette) + G.rectangle("fill", px, py, pw, ph) + G.setShader(previous) else G.rectangle("fill", px, py, pw, ph) end @@ -160,8 +167,14 @@ Chrome.DEFAULT_BOX_PALETTE = { function Chrome.paletteBox(tx, ty, tw, th, palette) palette = palette or Chrome.DEFAULT_BOX_PALETTE if GbcPalette.available() then - love.graphics.setColor(1, 1, 1, 1) - GbcPalette.with(palette, function() Font.drawBox(tx, ty, tw, th) end) + local G = love.graphics + G.setColor(1, 1, 1, 1) + -- GbcPalette.with, restoring the caller's shader even if the box throws. + local previous = G.getShader and G.getShader() or nil + GbcPalette.use(palette) + local ok, err = pcall(Font.drawBox, tx, ty, tw, th) + G.setShader(previous) + if not ok then error(err, 0) end else Font.drawBox(tx, ty, tw, th, palette[1]) end @@ -278,19 +291,56 @@ function Chrome.paletteGlyphs(palette, invert, raw) return pal, drawGlyph, finish end -function Chrome.printThrough(text, tx, ty, palette, invert, raw) - local pal, drawGlyph, finish = Chrome.paletteGlyphs(palette, invert, raw) - if not pal then return Chrome.print(text, tx, ty) end - local width = Font.width(text) - local paper = pal[1] or { 255, 255, 255 } - love.graphics.setColor(paper[1] / 255, paper[2] / 255, paper[3] / 255, 1) - love.graphics.rectangle("fill", tx * 8, ty * 8, width, 8) - local pen = tx * 8 - for _, code in ipairs(Font.encode(text)) do - drawGlyph(code, pen, ty * 8) +-- printThrough's resolved palette, or nil when it has to take the plain +-- degrade: paletteGlyphs' own test and fold, without its two closures. +local function glyphPalette(palette, invert, raw) + if not (palette and GbcPalette.available()) then return nil end + return raw and Chrome.rawPalette(palette, invert) + or Chrome.throughPalette(palette, invert) +end + +-- The string's glyph codes and its width, off one encode: the width is the +-- sum of the same advances the pen walks, which is all Font.width is. +local function measure(text) + local codes = Font.encode(text) + local width = 0 + for i = 1, #codes do width = width + Font.advanceOf(codes[i]) end + return codes, width +end + +-- paper rect, then every glyph through `pal` -- paletteGlyphs' drawGlyph and +-- finish inlined (see there for the TTF split, gen1recomp#1642). +local function drawThrough(codes, width, x, y, pal) + local G = love.graphics + local paper = pal[1] or WHITE + local ink = pal[4] or BLACK + local previous = G.getShader() + G.setColor(paper[1] / 255, paper[2] / 255, paper[3] / 255, 1) + G.rectangle("fill", x, y, width, 8) + local shaded = false + local pen = x + for i = 1, #codes do + local code = codes[i] + if code >= Font.TTF_BASE then + if shaded then G.setShader(previous); shaded = false end + G.setColor(ink[1] / 255, ink[2] / 255, ink[3] / 255, 1) + elseif not shaded then + G.setColor(1, 1, 1, 1) + GbcPalette.useRaw(pal) + shaded = true + end + Font.drawCode(code, pen, y) pen = pen + Font.advanceOf(code) end - finish() + if shaded then G.setShader(previous) end + G.setColor(0, 0, 0, 1) +end + +function Chrome.printThrough(text, tx, ty, palette, invert, raw) + local pal = glyphPalette(palette, invert, raw) + if not pal then return Chrome.print(text, tx, ty) end + local codes, width = measure(text) + drawThrough(codes, width, tx * 8, ty * 8, pal) return width end @@ -317,19 +367,10 @@ function Chrome.printRight(text, txEnd, ty) end function Chrome.printRightThrough(text, txEnd, ty, palette, invert, raw) - local pal, drawGlyph, finish = Chrome.paletteGlyphs(palette, invert, raw) + local pal = glyphPalette(palette, invert, raw) if not pal then return Chrome.printRight(text, txEnd, ty) end - local width = Font.width(text) - local tx = txEnd * 8 - width - local paper = pal[1] or { 255, 255, 255 } - love.graphics.setColor(paper[1] / 255, paper[2] / 255, paper[3] / 255, 1) - love.graphics.rectangle("fill", tx, ty * 8, width, 8) - local pen = tx - for _, code in ipairs(Font.encode(text)) do - drawGlyph(code, pen, ty * 8) - pen = pen + Font.advanceOf(code) - end - finish() + local codes, width = measure(text) + drawThrough(codes, width, txEnd * 8 - width, ty * 8, pal) return width end diff --git a/src/ui/gen2/NamingScreen.lua b/src/ui/gen2/NamingScreen.lua index f5cc0087..6e01ea5f 100644 --- a/src/ui/gen2/NamingScreen.lua +++ b/src/ui/gen2/NamingScreen.lua @@ -501,11 +501,19 @@ function NamingScreen:drawPanel() if self.iconImage then G.setColor(1, 1, 1, 1) local w, h = self.iconImage:getDimensions() - local quad = love.graphics.newQuad(0, 0, math.min(16, w), math.min(16, h), - w, h) + -- Cut once per icon image rather than every frame. + local quad = self.iconQuad + if not quad or self.iconQuadImage ~= self.iconImage then + quad = love.graphics.newQuad(0, 0, math.min(16, w), math.min(16, h), + w, h) + self.iconQuad, self.iconQuadImage = quad, self.iconImage + end if self.iconColors and GbcPalette.available() then - GbcPalette.with(self.iconColors, - function() G.draw(self.iconImage, quad, 16, 16) end) + -- GbcPalette.with without the closure: set, draw, restore. + local previous = G.getShader and G.getShader() or nil + GbcPalette.use(self.iconColors) + G.draw(self.iconImage, quad, 16, 16) + G.setShader(previous) else G.draw(self.iconImage, quad, 16, 16) end diff --git a/src/ui/gen2/PartyMenu.lua b/src/ui/gen2/PartyMenu.lua index fe3913de..d47080ec 100644 --- a/src/ui/gen2/PartyMenu.lua +++ b/src/ui/gen2/PartyMenu.lua @@ -869,6 +869,24 @@ function PartyMenu:iconBob(index) return (math.floor(self.clock / 16) % 2 == 1) and -2 or 0 end +-- One reusable quad per role, re-aimed per draw: an icon draws every frame +-- and a fresh Quad per tile would churn the GC. +local function reusedQuad(self, slot, x, y, w, h, sw, sh) + local quads = self.iconQuads + if not quads then + quads = {} + self.iconQuads = quads + end + local quad = quads[slot] + if quad then + quad:setViewport(x, y, w, h, sw, sh) + else + quad = love.graphics.newQuad(x, y, w, h, sw, sh) + quads[slot] = quad + end + return quad +end + function PartyMenu:drawIcon(mon, px, py) local image, frame = self:iconFor(mon) if not image then return end @@ -876,7 +894,19 @@ function PartyMenu:drawIcon(mon, px, py) local iw, ih = image:getDimensions() local markerRow = PartyMenu.heldMarkerRow(mon) local marker = markerRow and self:heldMarkerImage() or nil - local paint + G.setColor(1, 1, 1, 1) + -- Every party icon OAM entry is PAL_OW_RED (data/sprite_anims/oam.asm:315-355) + -- and InitPartyMenuOBPals loads PartyMenuOBPals into OBJ 0 for the whole list, + -- species and EGG alike (engine/gfx/color.asm:593-598, :1228-1229). + local pals = self.palettes and self.palettes.partyMenu + local colors = pals and pals[1] or nil + local shaded = colors and GbcPalette.available() + local previous + if shaded then + -- GbcPalette.with without the closure: set, draw, restore. + previous = G.getShader and G.getShader() or nil + GbcPalette.use(colors) + end if marker then -- The _WITH_ITEM / _WITH_MAIL OAM sets (data/sprite_anims/oam.asm) are the -- ordinary four quadrants with the `dbsprite -1, 0` entry -- the bottom @@ -886,31 +916,19 @@ function PartyMenu:drawIcon(mon, px, py) -- Both of the frameset's oamframes name the same marker tile, so it must -- not be indexed by `frame`: the icon bobs, the marker does not. local mw, mh = marker:getDimensions() - local topLeft = G.newQuad(0, frame * 16, 8, 8, iw, ih) - local topRight = G.newQuad(8, frame * 16, 8, 8, iw, ih) - local bottomRight = G.newQuad(8, frame * 16 + 8, 8, 8, iw, ih) - local held = G.newQuad(0, markerRow * 8, 8, 8, mw, mh) - paint = function() - G.draw(image, topLeft, px, py) - G.draw(image, topRight, px + 8, py) - G.draw(image, bottomRight, px + 8, py + 8) - G.draw(marker, held, px, py + 8) - end + G.draw(image, reusedQuad(self, "topLeft", 0, frame * 16, 8, 8, iw, ih), + px, py) + G.draw(image, reusedQuad(self, "topRight", 8, frame * 16, 8, 8, iw, ih), + px + 8, py) + G.draw(image, reusedQuad(self, "bottomRight", 8, frame * 16 + 8, 8, 8, + iw, ih), px + 8, py + 8) + G.draw(marker, reusedQuad(self, "held", 0, markerRow * 8, 8, 8, mw, mh), + px, py + 8) else - local quad = G.newQuad(0, frame * 16, 16, 16, iw, ih) - paint = function() G.draw(image, quad, px, py) end - end - G.setColor(1, 1, 1, 1) - -- Every party icon OAM entry is PAL_OW_RED (data/sprite_anims/oam.asm:315-355) - -- and InitPartyMenuOBPals loads PartyMenuOBPals into OBJ 0 for the whole list, - -- species and EGG alike (engine/gfx/color.asm:593-598, :1228-1229). - local pals = self.palettes and self.palettes.partyMenu - local colors = pals and pals[1] or nil - if colors and GbcPalette.available() then - GbcPalette.with(colors, paint) - else - paint() + G.draw(image, reusedQuad(self, "whole", 0, frame * 16, 16, 16, iw, ih), + px, py) end + if shaded then G.setShader(previous) end end -- PlacePartyHPBar calls DrawBattleHPBar (home/pokemon.asm) with `ld d, $6`, and diff --git a/src/ui/gen2/Pokegear.lua b/src/ui/gen2/Pokegear.lua index f9ac73e4..b0caf656 100644 --- a/src/ui/gen2/Pokegear.lua +++ b/src/ui/gen2/Pokegear.lua @@ -1919,6 +1919,10 @@ end local SPACE_TILE = 0x7f function Pokegear:tile(id, tx, ty) + -- A flat cell draws under the caller's shader, not a batched tile palette. + if (id == SPACE_TILE or id == BLANK_TILE) and self.sheet then + self.sheet:suspend() + end if id == SPACE_TILE then local paper = self:paperColor() local G = love.graphics @@ -1938,8 +1942,7 @@ function Pokegear:tile(id, tx, ty) if self.sheet then self.sheet:draw(id, tx, ty) end end -function Pokegear:drawTilemap(cells) - if not cells then return end +local function drawTilemapCells(self, cells) for index = 1, SCREEN_W * SCREEN_H do local tile = cells[index] if tile then @@ -1949,6 +1952,18 @@ function Pokegear:drawTilemap(cells) end end +-- 360 cells a frame: batched so the palette shader is set once per run of +-- same-palette tiles rather than once per tile (TileSheet:begin). +function Pokegear:drawTilemap(cells) + if not cells then return end + local sheet = self.sheet + if not sheet then return drawTilemapCells(self, cells) end + sheet:begin() + local ok, err = pcall(drawTilemapCells, self, cells) + sheet:finish() + if not ok then error(err, 0) end +end + -- Pokegear_FinishTilemap. -- PokegearSpritesGFX, shared by the mode indicator arrow ($00) and the town -- map cursor ($04). Loaded once and remembered as `false` when there is no diff --git a/src/ui/gen2/SummaryMenu.lua b/src/ui/gen2/SummaryMenu.lua index 856aa59f..e10186d5 100644 --- a/src/ui/gen2/SummaryMenu.lua +++ b/src/ui/gen2/SummaryMenu.lua @@ -1126,6 +1126,25 @@ function SummaryMenu:drawEggPic() self:drawEggIconFallback(colors) end +-- One reusable quad per role, re-aimed per draw rather than a new Quad each +-- frame (which churns the GC). nil only when the backend cannot make one. +local function reusedQuad(owner, slot, x, y, w, h, sw, sh) + local quads = owner.reusedQuads + if not quads then + quads = {} + owner.reusedQuads = quads + end + local quad = quads[slot] + if quad then + quad:setViewport(x, y, w, h, sw, sh) + return quad + end + local ok, made = pcall(love.graphics.newQuad, x, y, w, h, sw, sh) + if not ok then return nil end + quads[slot] = made + return made +end + -- The ICON_EGG sheet is two 16x16 frames stacked into one 16x32 image -- (src/ui/gen2/PartyMenu.lua reads the same entry); the first frame is the -- egg at rest, which is the one the party list shows while nothing is moving. @@ -1141,9 +1160,9 @@ function SummaryMenu:drawEggIconFallback(colors) local w = entry.width or 16 local h = math.min(entry.height or 16, image:getHeight()) if (entry.frames or 1) > 1 then h = math.floor(h / entry.frames) end - local ok, quad = pcall(love.graphics.newQuad, 0, 0, w, h, + local quad = reusedQuad(self, "egg", 0, 0, w, h, image:getWidth(), image:getHeight()) - if not ok then return end + if not quad then return end -- Centred in the block at 2x: a 16x16 icon inside 7x7 tiles. local x = math.floor((7 * 8 - w * 2) / 2) local y = math.floor((7 * 8 - h * 2) / 2) diff --git a/src/ui/gen2/TileSheet.lua b/src/ui/gen2/TileSheet.lua index d264e258..bbf785bf 100644 --- a/src/ui/gen2/TileSheet.lua +++ b/src/ui/gen2/TileSheet.lua @@ -77,19 +77,52 @@ function TileSheet:draw(tile, tx, ty) G.setColor(1, 1, 1, 1) local colors = self.palette if self.paletteFor then colors = self.paletteFor(tile, tx, ty) or colors end - local function body() G.draw(image, quad, tx * 8, ty * 8) end + local batch = self.batch if colors and GbcPalette.available() then - if self.raw then - GbcPalette.withRaw(colors, body) + if batch then + -- Inside begin/finish: the shader only changes when the palette does. + if batch.active ~= colors then + if self.raw then GbcPalette.useRaw(colors) else GbcPalette.use(colors) end + batch.active = colors + end + G.draw(image, quad, tx * 8, ty * 8) else - GbcPalette.with(colors, body) + -- GbcPalette.with / withRaw without the closure: set, draw, restore. + local previous = G.getShader and G.getShader() or nil + if self.raw then GbcPalette.useRaw(colors) else GbcPalette.use(colors) end + G.draw(image, quad, tx * 8, ty * 8) + G.setShader(previous) end else - body() + if batch then self:suspend() end + G.draw(image, quad, tx * 8, ty * 8) end return true end +-- Batch a pass of draws: between begin and finish the palette shader is set +-- once per run of tiles sharing a palette rather than once per tile, and the +-- caller's shader comes back at finish. Anything drawn between two tiles +-- that is not a sheet tile (a flat rect, text) calls suspend first so it +-- draws under the caller's shader, as it would have outside the batch. +function TileSheet:begin() + local G = love.graphics + self.batch = { previous = G.getShader and G.getShader() or nil, active = nil } +end + +function TileSheet:suspend() + local batch = self.batch + if batch and batch.active then + love.graphics.setShader(batch.previous) + batch.active = nil + end +end + +function TileSheet:finish() + self:suspend() + self.batch = nil +end + -- A run of consecutive ids, left to right: the shape most of these routines -- use for a header strip or a caption. function TileSheet:run(first, count, tx, ty) diff --git a/src/ui/gen2/TradeAnim.lua b/src/ui/gen2/TradeAnim.lua index bde6bc73..c92a9ef9 100644 --- a/src/ui/gen2/TradeAnim.lua +++ b/src/ui/gen2/TradeAnim.lua @@ -831,7 +831,13 @@ function TradeAnimView:drawIcon(record, x, y) end if not cached then return end local G = love.graphics - local quad = love.graphics.newQuad(0, 0, 16, 16, cached:getDimensions()) + -- One quad per icon sheet, cut once rather than every frame. + self.iconQuads = self.iconQuads or {} + local quad = self.iconQuads[cached] + if not quad then + quad = love.graphics.newQuad(0, 0, 16, 16, cached:getDimensions()) + self.iconQuads[cached] = quad + end G.setColor(1, 1, 1, 1) local colors = Palettes.monColors(self.palettes, record.species, record.shiny) diff --git a/src/ui/gen2/Typer.lua b/src/ui/gen2/Typer.lua index aec86c02..2fcff053 100644 --- a/src/ui/gen2/Typer.lua +++ b/src/ui/gen2/Typer.lua @@ -56,16 +56,22 @@ end -- ../pokecrystal/home/text.asm:520 _ContTextNoPause function Typer:start(page) - local out, total, kept = {}, 0, 0 + local out, spans, total, kept = {}, {}, 0, 0 local scrolled = type(page) == "table" and page.scrolled for i, line in ipairs(linesOf(page)) do local text = self.expand and self.expand(line) or line out[i] = text - local n = #spansOf(text) + -- Split once here: lines() runs every frame while the page types out. + local lineSpans = spansOf(text) + spans[i] = lineSpans + local n = #lineSpans total = total + n if scrolled and i == 1 then kept = n end end self.page = out + self.spans = spans + self.spansPage = out + self.linesOut, self.linesShown = nil, nil self.total = total self.shown = self.instant and total or kept self.timer = 0 @@ -108,9 +114,16 @@ end function Typer:lines() if self:done() then return self.page end + -- The same partial page is asked for every frame between two letters. + if self.linesOut and self.linesShown == self.shown + and self.linesPage == self.page then + return self.linesOut + end + local cached = self.spansPage == self.page and self.spans or nil local out, left = {}, self.shown + self.linesOut, self.linesShown, self.linesPage = out, self.shown, self.page for i, line in ipairs(self.page) do - local spans = spansOf(line) + local spans = cached and cached[i] or spansOf(line) if left >= #spans then out[i] = line elseif left <= 0 then diff --git a/src/ui/gen2/UnownPuzzle.lua b/src/ui/gen2/UnownPuzzle.lua index 83e8f3cd..5b525dc2 100644 --- a/src/ui/gen2/UnownPuzzle.lua +++ b/src/ui/gen2/UnownPuzzle.lua @@ -438,6 +438,25 @@ function UnownPuzzle:fillCell(cell, tile) end end +-- One reusable quad per role, re-aimed per draw rather than a new Quad each +-- frame (which churns the GC). nil only when the backend cannot make one. +local function reusedQuad(owner, slot, x, y, w, h, sw, sh) + local quads = owner.reusedQuads + if not quads then + quads = {} + owner.reusedQuads = quads + end + local quad = quads[slot] + if quad then + quad:setViewport(x, y, w, h, sw, sh) + return quad + end + local ok, made = pcall(love.graphics.newQuad, x, y, w, h, sw, sh) + if not ok then return nil end + quads[slot] = made + return made +end + -- .Corners is `piece -> corner tile` on a 12-tile-wide sheet; as a quad that is -- just the panel's row and column in the 4x4 picture. function UnownPuzzle:drawPiece(piece, tx, ty) @@ -447,7 +466,7 @@ function UnownPuzzle:drawPiece(piece, tx, ty) local sx = (index % UnownPuzzle.PIECES_WIDE) * size local sy = math.floor(index / UnownPuzzle.PIECES_WIDE) * size local w, h = self.picture:getDimensions() - local quad = love.graphics.newQuad(sx, sy, size, size, w, h) + local quad = reusedQuad(self, "piece", sx, sy, size, size, w, h) love.graphics.setColor(1, 1, 1, 1) -- The four pictures are drawn out of colours 1 and 2 alone, so binding BG -- palette 0 here is what turns the panels tan and dark brown. @@ -496,7 +515,7 @@ function UnownPuzzle:drawCursor(tx, ty) for _, spec in ipairs(CURSOR_CELLS) do local col, tile, row, flipX, flipY = spec[1], spec[2], spec[3], spec[4], spec[5] - local quad = love.graphics.newQuad(tile * 8, 0, 8, 8, w, h) + local quad = reusedQuad(self, "cursor", tile * 8, 0, 8, 8, w, h) local sx, sy = flipX and -1 or 1, flipY and -1 or 1 local ox = (tx + col) * 8 + (flipX and 8 or 0) local oy = (ty + row) * 8 + (flipY and 8 or 0) diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index 7bdcc5d3..1225d07b 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -63,6 +63,9 @@ OverworldState.withOverrideMirror = withOverrideMirror local Game -- set on enter (avoids circular require at load time) +-- handleInput's d-pad poll order (a constant: it runs every frame) +local INPUT_DIRS = { "up", "down", "left", "right" } + local mapScripts -- registry of hand-ported map scripts local COMPASS = { up = "north", down = "south", left = "west", right = "east" } @@ -535,7 +538,12 @@ function OverworldState:setMap(mapId, x, y, facing, opts) elseif fromMapId ~= mapId then for _, obj in ipairs(self.map.def.objects or {}) do local npc = self.npcPool[mapId .. "_obj_" .. obj.index] - if npc then npc.pendingSpawnReset = true end + if npc then + npc.pendingSpawnReset = true + -- lets applyPendingSpawnResets skip its whole-pool scan while + -- nothing is armed (it runs every frame) + self.spawnResetsArmed = true + end end end self.npcs = {} @@ -794,6 +802,9 @@ end -- the deferred half of the seam re-seed armed in setMap (#1755): the cart's -- connected map has no sprites to be seen snapping -- home/overworld.asm:2133 function OverworldState:applyPendingSpawnResets() + -- setMap is the only place that arms a re-seed; nothing armed since the + -- last scan came up empty -> no pool walk this frame + if not self.spawnResetsArmed then return end local pool = self.npcPool if not (pool and self.camera) then return end local due @@ -803,7 +814,10 @@ function OverworldState:applyPendingSpawnResets() due[npc] = true end end - if not due then return end + if not due then + self.spawnResetsArmed = nil + return + end local cam = self.camera local vw, vh = Game.renderer:worldViewSize() local function onCamera(px, py) @@ -1545,7 +1559,6 @@ function OverworldState:update(dt) or (self.hopLand or 0) > 0 or self.engaging or self.emote or self.teleportOut or self.flyAnim or self.flyArrive or self.spinArrive - or self.holeFall or self.holeArrive or self.holeFall or self.holeArrive or self.cutAnim or (self.dustAnim and self.dustAnim.boulder) @@ -1719,7 +1732,7 @@ function OverworldState:handleInput() return end - for _, dir in ipairs({ "up", "down", "left", "right" }) do + for _, dir in ipairs(INPUT_DIRS) do if input:isDown(dir) then if not self.player.moving and self.player.facing == dir then if self:checkEdgeExit(dir) then return end @@ -1785,13 +1798,25 @@ function OverworldState:handleInput() end -- JoypadOverworld both make -- home/overworld.asm:382 +-- slopeMaps list -> { n = #list, ids = { [mapId] = true } }: update() asks +-- every frame, so the list is indexed once instead of scanned per call. +-- Keyed weakly on the list itself (a data reload brings a new one) and +-- rebuilt if its length changes. +local slopeSets = setmetatable({}, { __mode = "k" }) + function OverworldState:onSlopeMap(mapId) local fm = Game.data.field.forcedMovement mapId = mapId or (self.map and self.map.id) - for _, m in ipairs((fm and fm.slopeMaps) or {}) do - if m == mapId then return true end + local list = fm and fm.slopeMaps + if not list or mapId == nil then return false end + local entry = slopeSets[list] + if not entry or entry.n ~= #list then + local ids = {} + for _, m in ipairs(list) do ids[m] = true end + entry = { n = #list, ids = ids } + slopeSets[list] = entry end - return false + return entry.ids[mapId] == true end -- Strength boulders (engine/overworld/push_boulder.asm TryPushingBoulder): @@ -5829,7 +5854,7 @@ end -- sprite through the tile's white gaps) over the plain one (sprites, FX -- overlays). drawFn issues the actual draws in flat world-canvas coordinates; -- the transform just slides them from the flat foot onto the projected anchor. -function OverworldState:billboard(fx, fy, vw, vh, colors, keyed, drawFn) +function OverworldState:billboard(fx, fy, vw, vh, colors, keyed, drawFn, ...) local sx, sy = Tilt.groundPoint(fx, fy, vw, vh) local shader = colors and (keyed and PaletteFX.keyedShader() or PaletteFX.shader()) or nil @@ -5839,11 +5864,321 @@ function OverworldState:billboard(fx, fy, vw, vh, colors, keyed, drawFn) end love.graphics.push() love.graphics.translate(sx - fx, sy - fy) - drawFn() + drawFn(...) -- extra billboard args (the FX bodies take self, cam) love.graphics.pop() if shader then love.graphics.setShader() end end +-- === shared FX draw bodies ========================================== +-- Each draws at flat world-canvas offsets; the tilt path wraps the +-- standing ones in an upright billboard, the flat path calls them inline +-- in their historical order. (Bodies are byte-identical to the pre-tilt +-- inline code, so the flat draw sequence is unchanged.) Module-level and +-- handed (self, cam) so drawWorld does not build six closures per frame; +-- the pipeline path wraps them for its no-argument ctx.fx contract. + +-- the Pokémon Center heal machine (PokeCenterOAMData): the monitor +-- tile over the machine's screen and one ball per healed mon in two +-- mirrored columns, all blinking during the jingle flash. The GB +-- draws it at fixed screen coords with the player's cell BG-aligned +-- at (64,64); anchoring those coords to where the player stood keeps +-- the overlay on the machine at any zoom. +local function fxHeal(self, cam) + if not self.healAnim then return end + local ha = self.healAnim + local fxDef = Game.data.field.overworldFx + if self.healMachineImg == nil and fxDef and fxDef.healMachine then + local ok, img = pcall(love.graphics.newImage, fxDef.healMachine.path) + self.healMachineImg = ok and img or false + end + local img = self.healMachineImg + if img then + if not self.healMachineQuads then + local w, h = img:getWidth(), img:getHeight() + self.healMachineQuads = { + love.graphics.newQuad(0, 0, 8, 8, w, h), -- monitor ($7c) + love.graphics.newQuad(0, 8, 8, 8, w, h), -- ball ($7d) + } + end + local shader, redrawColors = healMachineShader(ha.visible) + local mark = redrawColors and PaletteFX.spriteRedrawPassActive() + -- 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) + if mark then + PaletteFX.markSpriteRedraw(img, self.healMachineQuads[1], + ox + 44, oy + 20, 1, redrawColors) + end + for i = 1, math.min(ha.lit, #HEAL_BALL_XY) do + local b = HEAL_BALL_XY[i] + if b[3] then -- right column: OAM_XFLIP + love.graphics.draw(img, self.healMachineQuads[2], + ox + b[1] + 8, oy + b[2], 0, -1, 1) + if mark then + PaletteFX.markSpriteRedraw(img, self.healMachineQuads[2], + ox + b[1] + 8, oy + b[2], -1, redrawColors) + end + else + love.graphics.draw(img, self.healMachineQuads[2], + ox + b[1], oy + b[2]) + if mark then + PaletteFX.markSpriteRedraw(img, self.healMachineQuads[2], + ox + b[1], oy + b[2], 1, redrawColors) + end + end + end + if shader then love.graphics.setShader() end + end +end + +-- the Cut/boulder dust puff: the smoke tile drawn 2x2 over the cell, +-- flickering (AnimateBoulderDust XORs the OBJ palette every step) +local function fxDust(self, cam) + if not self.dustAnim then return end + local fxDef = Game.data.field.overworldFx + local smoke = fxDef and fxDef.smoke + if smoke then + if self.smokeImg == nil then + local ok, img = pcall(love.graphics.newImage, smoke.path) + self.smokeImg = ok and img or false + end + if self.smokeImg then + local da = self.dustAnim + local dx = da.x * 16 + (da.ox or 0) - cam.x + local dy = da.y * 16 + (da.oy or 0) - cam.y + local flicker = math.floor(da.frames / 4) % 2 == 0 + if da.boulder then flicker = not da.faded end + love.graphics.setColor(1, 1, 1, flicker and 1 or 0.55) + for i = 0, 1 do + for j = 0, 1 do + love.graphics.draw(self.smokeImg, dx + i * 8, dy + j * 8) + end + end + love.graphics.setColor(1, 1, 1, 1) + end + end +end + +-- the cut tree splitting apart (AnimCut): top half slides right, +-- bottom half slides left, 1px per frame, flickering as they go +local function fxCutTree(self, cam) + if not self.cutAnim then return end + local fxDef = Game.data.field.overworldFx + local tree = fxDef and fxDef.cutTree + if not tree then return end + if self.cutTreeImg == nil then + local ok, img = pcall(love.graphics.newImage, tree.path) + self.cutTreeImg = ok and img or false + end + local img = self.cutTreeImg + if not img then return end + if not self.cutTreeQuads then + local w, h = img:getWidth(), img:getHeight() + self.cutTreeQuads = { + love.graphics.newQuad(0, 0, 16, 8, w, h), -- top half + love.graphics.newQuad(0, 8, 16, 8, w, h), -- bottom half + } + end + local ca = self.cutAnim + local off = (ca.total or 8) - ca.frames + local dx = ca.x * 16 - cam.x + local dy = ca.y * 16 - cam.y + local flicker = ca.frames % 2 == 0 + love.graphics.setColor(1, 1, 1, flicker and 1 or 0.55) + love.graphics.draw(img, self.cutTreeQuads[1], dx + off, dy) + love.graphics.draw(img, self.cutTreeQuads[2], dx - off, dy + 8) + love.graphics.setColor(1, 1, 1, 1) +end + +-- the "!" bubble above a trainer who spotted the player +local function fxEmote(self, cam) + if not (self.emote and self.emote.npc) then return end + -- bubble = false is a silent hold (a Pikachu emotion that plays a + -- cry with no bubble still pauses the world for its beat) + if self.emote.bubble == false then return end + local npc = self.emote.npc + -- engine/overworld/emotion_bubbles.asm:41 + local ex = npc.px - cam.x + local ey = npc.py - cam.y - 20 + local bubble = Game.data.field.emotionBubbles + local drawn = false + if bubble and bubble.path then + local ok, img = pcall(function() + self.emoteImg = self.emoteImg or obpEmoteImage(bubble.path) + return self.emoteImg + end) + -- EXCLAMATION_BUBBLE is index 0 -> first crop; the emote command + -- picks question/happy crops instead + local bi = self.emote.bubble or 1 + local rect = bubble.bubbles and bubble.bubbles[bi] + if ok and img and rect then + love.graphics.setColor(1, 1, 1, 1) + -- one Quad per bubble crop, cached: this draws every frame the "!" + -- (or the emote-command crops) is up, so a fresh Quad here churned + -- the GC. The bubble set is small and fixed, so the cache is bounded. + self.emoteQuads = self.emoteQuads or {} + local q = self.emoteQuads[bi] + if not q then + q = love.graphics.newQuad(rect.x, rect.y, rect.w, rect.h, + img:getDimensions()) + self.emoteQuads[bi] = q + end + love.graphics.draw(img, q, ex, ey) + -- engine/overworld/emotion_bubbles.asm:18 + if PaletteFX.usesSpriteObp() and PaletteFX.spriteRedrawPassActive() then + PaletteFX.markSpriteRedraw(img, q, ex, ey, 1) + end + drawn = true + end + end + if not drawn then + local Font = require("src.render.Font") + love.graphics.setColor(1, 1, 1, 1) + love.graphics.rectangle("fill", ex, ey, 10, 12) + love.graphics.setColor(0, 0, 0, 1) + love.graphics.rectangle("line", ex + 0.5, ey + 0.5, 10, 12) + Font.draw("!", ex + 1, ey + 2) + love.graphics.setColor(1, 1, 1, 1) + end +end + +-- the FLY bird sweeping off with the player +local function fxBird(self, cam) + local anim = self.flyAnim or self.flyArrive + if not anim then return end + local birdId = FieldDefaults.fieldValue(Game.data, "playerSprites", "fly") + if not self.birdSprite and birdId and Game.data.sprites[birdId] then + local SR = require("src.render.SpriteRenderer") + self.birdSprite = SR.new(Game.data.sprites[birdId]) + end + if not self.birdSprite then return end + -- DoFlyAnimation: the bird flaps its wings every Delay3; each path is + -- anchored on the player's cell (FLY_ANCHOR / FLY_ARRIVE_ANCHOR) so + -- the flight rides any screen position, and it faces its travel + -- direction (rightward travel flips the left-drawn sheet) + local phase = anim.phase or "arrive" + if phase == "hold" then return end -- parked off screen between paths + local path, anchor, facing + if phase == "path1" then + path, anchor, facing = FLY_PATH1, FLY_ANCHOR, "right" + elseif phase == "path2" then + path, anchor, facing = FLY_PATH2, FLY_ANCHOR, "left" + elseif phase == "arrive" then + path, anchor, facing = FLY_PATH_IN, FLY_ARRIVE_ANCHOR, "left" + end + local step = math.floor(anim.t / 3) + local sx, sy + if path then + local pair = path[math.min(#path, step + 1)] + sx, sy = pair[2] - anchor[2], pair[1] - anchor[1] + else + sx, sy = 0, 0 -- the in-place flap sits on the player + facing = "right" + end + love.graphics.setColor(1, 1, 1, 1) + self.birdSprite:draw(self.player.px + sx, self.player.py + sy, + cam.x, cam.y, facing, step % 2, false) +end + +-- fishing pose: the rod tile over the faced water (gfx/fishing.asm) +local function fxRod(self, cam) + if not self.fishing then return end + -- engine/overworld/player_animations.asm:424 + if self.fishing.hideRod then return end + local fx = Game.data.field.overworldFx + local rod = fx and fx.fishingRod + if rod then + if self.rodImg == nil then + local ok, img = pcall(love.graphics.newImage, rod.path) + self.rodImg = ok and img or false + end + if self.rodImg then + local p = self.player + local oam = ROD_OAM[self.fishing.facing] or ROD_OAM.down + if not self.rodQuads then + -- one quad per 8x8 tile of the stacked sheet (ROD_OAM.tile) + local iw, ih = self.rodImg:getDimensions() + self.rodQuads = {} + for i = 0, math.floor(ih / 8) - 1 do + self.rodQuads[i] = love.graphics.newQuad(0, i * 8, 8, 8, iw, ih) + end + end + local quad = self.rodQuads[oam.tile] + -- Place the rod against the active sprite's anchored top-left. The + -- vanilla result is still (px-cam, py-cam-4), while custom larger + -- sheets keep the rod attached to their feet. + -- Fishing always uses the on-foot player sheet; read its fields + -- directly so this FX pass does not advance pose-side animation. + local sprite, px, py = p.sprite, p.px, p.py + local sx, sy = sprite:getScreenOrigin(px, py, cam.x, cam.y) + local rx = sx + oam.dx + -- engine/overworld/player_animations.asm:453 + local ry = sy + oam.dy + (p.fishShakeDy or 0) + love.graphics.setColor(1, 1, 1, 1) + if quad and oam.flip then + love.graphics.draw(self.rodImg, quad, rx + 8, ry, 0, -1, 1) + elseif quad then + love.graphics.draw(self.rodImg, quad, rx, ry) + end + end + end +end + +-- drawWorld's y-sort orders, hoisted so a frame builds no comparators +local function ghostDrawOrder(a, b) + return a.npc.py + a.oy < b.npc.py + b.oy +end + +local function entityDrawOrder(a, b) + if a.py ~= b.py then return a.py < b.py end + -- a fresh warp spawn parks the follower on the player's own cell + -- until it trails out; the tie must draw it under him, never on + -- top (#863) + return a.pikachuFollower == true and b.pikachuFollower ~= true +end + +-- Tall-grass feet overdraw queue for the flat path: each distinct grass +-- cell an entity stands on or steps into, in first-queued order. A +-- scratch pair of coordinate arrays reused every frame (the queue holds a +-- few cells, so the duplicate check is a short linear scan) instead of a +-- "cx:cy" string key and a { cx, cy } table per cell per frame. +local function queueGrass(self, q, cx, cy) + if cx == nil or cy == nil or not self.map:isGrassCell(cx, cy) then + return + end + local n = q.n + local xs, ys = q.x, q.y + for i = 1, n do + if xs[i] == cx and ys[i] == cy then return end + end + n = n + 1 + xs[n], ys[n] = cx, cy + q.n = n +end + +-- Is a neighbour map's ghost NPC wholly outside the vw x vh view? The +-- rect is the sprite's frame at its anchored origin, padded by a tile on +-- every side (hop shadows, multi-part cells), so only NPCs that could not +-- put a pixel on screen are skipped. +local GHOST_CULL_PAD = 16 +local function ghostOffscreen(g, camX, camY, vw, vh) + local npc = g.npc + local sprite = npc and npc.sprite + if not (sprite and sprite.getScreenOrigin and vw and vh) then + return false + end + local sx, sy = sprite:getScreenOrigin(npc.px, npc.py, camX, camY) + local fw = sprite.frameWidth or 16 + local fh = sprite.frameHeight or 16 + local pad = GHOST_CULL_PAD + return sx + fw + pad < 0 or sy + fh + pad < 0 + or sx - pad > vw or sy - pad > vh +end + function OverworldState:drawWorld() -- Dark-map BG shade shift, armed for the whole frame before anything draws. -- home/fade.asm's LoadGBPal writes ONE rBGP for the screen, so terrain, the @@ -5908,11 +6243,12 @@ function OverworldState:drawWorld() -- advance the water/flower tile animation (runs under dialogs too). -- TileRenderer.tick uses wall-clock 60Hz steps so display refresh rate -- does not speed or slow the cycle (issue #4). - require("src.render.TileRenderer").tick() + local TileRenderer = require("src.render.TileRenderer") + TileRenderer.tick() -- let the renderer know whether a spinner puzzle is currently sliding -- the player, so it can flicker the arrow tiles between the blur and -- static graphic (engine/overworld/spinners.asm LoadSpinnerArrowTiles) - require("src.render.TileRenderer").setSpinning(self.spinnerSliding or false) + TileRenderer.setSpinning(self.spinnerSliding or false) local cam = self.camera -- ShakeElevator's oscillation (engine/overworld/elevator.asm) writes -- hSCY, which scrolls the BG layer only -- tiles bounce while OAM @@ -5957,272 +6293,8 @@ function OverworldState:drawWorld() local zones = tilt and self.sgbWorldZones and self:sgbWorldZones() or nil -- ghost NPCs on neighbor maps, y-sorted among themselves - table.sort(self.ghosts, - function(a, b) return a.npc.py + a.oy < b.npc.py + b.oy end) - table.sort(self.entities, function(a, b) - if a.py ~= b.py then return a.py < b.py end - -- a fresh warp spawn parks the follower on the player's own cell - -- until it trails out; the tie must draw it under him, never on - -- top (#863) - return a.pikachuFollower == true and b.pikachuFollower ~= true - end) - - -- === shared FX draw bodies ========================================== - -- Each draws at flat world-canvas offsets; the tilt path wraps the - -- standing ones in an upright billboard, the flat path calls them inline - -- in their historical order. (Bodies are byte-identical to the pre-tilt - -- inline code, so the flat draw sequence is unchanged.) - - -- the Pokémon Center heal machine (PokeCenterOAMData): the monitor - -- tile over the machine's screen and one ball per healed mon in two - -- mirrored columns, all blinking during the jingle flash. The GB - -- draws it at fixed screen coords with the player's cell BG-aligned - -- at (64,64); anchoring those coords to where the player stood keeps - -- the overlay on the machine at any zoom. - local function fxHeal() - if not self.healAnim then return end - local ha = self.healAnim - local fxDef = Game.data.field.overworldFx - if self.healMachineImg == nil and fxDef and fxDef.healMachine then - local ok, img = pcall(love.graphics.newImage, fxDef.healMachine.path) - self.healMachineImg = ok and img or false - end - local img = self.healMachineImg - if img then - if not self.healMachineQuads then - local w, h = img:getWidth(), img:getHeight() - self.healMachineQuads = { - love.graphics.newQuad(0, 0, 8, 8, w, h), -- monitor ($7c) - love.graphics.newQuad(0, 8, 8, 8, w, h), -- ball ($7d) - } - end - local shader, redrawColors = healMachineShader(ha.visible) - local mark = redrawColors and PaletteFX.spriteRedrawPassActive() - -- 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) - if mark then - PaletteFX.markSpriteRedraw(img, self.healMachineQuads[1], - ox + 44, oy + 20, 1, redrawColors) - end - for i = 1, math.min(ha.lit, #HEAL_BALL_XY) do - local b = HEAL_BALL_XY[i] - if b[3] then -- right column: OAM_XFLIP - love.graphics.draw(img, self.healMachineQuads[2], - ox + b[1] + 8, oy + b[2], 0, -1, 1) - if mark then - PaletteFX.markSpriteRedraw(img, self.healMachineQuads[2], - ox + b[1] + 8, oy + b[2], -1, redrawColors) - end - else - love.graphics.draw(img, self.healMachineQuads[2], - ox + b[1], oy + b[2]) - if mark then - PaletteFX.markSpriteRedraw(img, self.healMachineQuads[2], - ox + b[1], oy + b[2], 1, redrawColors) - end - end - end - if shader then love.graphics.setShader() end - end - end - - -- the Cut/boulder dust puff: the smoke tile drawn 2x2 over the cell, - -- flickering (AnimateBoulderDust XORs the OBJ palette every step) - local function fxDust() - if not self.dustAnim then return end - local fxDef = Game.data.field.overworldFx - local smoke = fxDef and fxDef.smoke - if smoke then - if self.smokeImg == nil then - local ok, img = pcall(love.graphics.newImage, smoke.path) - self.smokeImg = ok and img or false - end - if self.smokeImg then - local da = self.dustAnim - local dx = da.x * 16 + (da.ox or 0) - cam.x - local dy = da.y * 16 + (da.oy or 0) - cam.y - local flicker = math.floor(da.frames / 4) % 2 == 0 - if da.boulder then flicker = not da.faded end - love.graphics.setColor(1, 1, 1, flicker and 1 or 0.55) - for i = 0, 1 do - for j = 0, 1 do - love.graphics.draw(self.smokeImg, dx + i * 8, dy + j * 8) - end - end - love.graphics.setColor(1, 1, 1, 1) - end - end - end - - -- the cut tree splitting apart (AnimCut): top half slides right, - -- bottom half slides left, 1px per frame, flickering as they go - local function fxCutTree() - if not self.cutAnim then return end - local fxDef = Game.data.field.overworldFx - local tree = fxDef and fxDef.cutTree - if not tree then return end - if self.cutTreeImg == nil then - local ok, img = pcall(love.graphics.newImage, tree.path) - self.cutTreeImg = ok and img or false - end - local img = self.cutTreeImg - if not img then return end - if not self.cutTreeQuads then - local w, h = img:getWidth(), img:getHeight() - self.cutTreeQuads = { - love.graphics.newQuad(0, 0, 16, 8, w, h), -- top half - love.graphics.newQuad(0, 8, 16, 8, w, h), -- bottom half - } - end - local ca = self.cutAnim - local off = (ca.total or 8) - ca.frames - local dx = ca.x * 16 - cam.x - local dy = ca.y * 16 - cam.y - local flicker = ca.frames % 2 == 0 - love.graphics.setColor(1, 1, 1, flicker and 1 or 0.55) - love.graphics.draw(img, self.cutTreeQuads[1], dx + off, dy) - love.graphics.draw(img, self.cutTreeQuads[2], dx - off, dy + 8) - love.graphics.setColor(1, 1, 1, 1) - end - - -- the "!" bubble above a trainer who spotted the player - local function fxEmote() - if not (self.emote and self.emote.npc) then return end - -- bubble = false is a silent hold (a Pikachu emotion that plays a - -- cry with no bubble still pauses the world for its beat) - if self.emote.bubble == false then return end - local npc = self.emote.npc - -- engine/overworld/emotion_bubbles.asm:41 - local ex = npc.px - cam.x - local ey = npc.py - cam.y - 20 - local bubble = Game.data.field.emotionBubbles - local drawn = false - if bubble and bubble.path then - local ok, img = pcall(function() - self.emoteImg = self.emoteImg or obpEmoteImage(bubble.path) - return self.emoteImg - end) - -- EXCLAMATION_BUBBLE is index 0 -> first crop; the emote command - -- picks question/happy crops instead - local bi = self.emote.bubble or 1 - local rect = bubble.bubbles and bubble.bubbles[bi] - if ok and img and rect then - love.graphics.setColor(1, 1, 1, 1) - -- one Quad per bubble crop, cached: this draws every frame the "!" - -- (or the emote-command crops) is up, so a fresh Quad here churned - -- the GC. The bubble set is small and fixed, so the cache is bounded. - self.emoteQuads = self.emoteQuads or {} - local q = self.emoteQuads[bi] - if not q then - q = love.graphics.newQuad(rect.x, rect.y, rect.w, rect.h, - img:getDimensions()) - self.emoteQuads[bi] = q - end - love.graphics.draw(img, q, ex, ey) - -- engine/overworld/emotion_bubbles.asm:18 - if PaletteFX.usesSpriteObp() and PaletteFX.spriteRedrawPassActive() then - PaletteFX.markSpriteRedraw(img, q, ex, ey, 1) - end - drawn = true - end - end - if not drawn then - local Font = require("src.render.Font") - love.graphics.setColor(1, 1, 1, 1) - love.graphics.rectangle("fill", ex, ey, 10, 12) - love.graphics.setColor(0, 0, 0, 1) - love.graphics.rectangle("line", ex + 0.5, ey + 0.5, 10, 12) - Font.draw("!", ex + 1, ey + 2) - love.graphics.setColor(1, 1, 1, 1) - end - end - - -- the FLY bird sweeping off with the player - local function fxBird() - local anim = self.flyAnim or self.flyArrive - if not anim then return end - local birdId = FieldDefaults.fieldValue(Game.data, "playerSprites", "fly") - if not self.birdSprite and birdId and Game.data.sprites[birdId] then - local SR = require("src.render.SpriteRenderer") - self.birdSprite = SR.new(Game.data.sprites[birdId]) - end - if not self.birdSprite then return end - -- DoFlyAnimation: the bird flaps its wings every Delay3; each path is - -- anchored on the player's cell (FLY_ANCHOR / FLY_ARRIVE_ANCHOR) so - -- the flight rides any screen position, and it faces its travel - -- direction (rightward travel flips the left-drawn sheet) - local phase = anim.phase or "arrive" - if phase == "hold" then return end -- parked off screen between paths - local path, anchor, facing - if phase == "path1" then - path, anchor, facing = FLY_PATH1, FLY_ANCHOR, "right" - elseif phase == "path2" then - path, anchor, facing = FLY_PATH2, FLY_ANCHOR, "left" - elseif phase == "arrive" then - path, anchor, facing = FLY_PATH_IN, FLY_ARRIVE_ANCHOR, "left" - end - local step = math.floor(anim.t / 3) - local sx, sy - if path then - local pair = path[math.min(#path, step + 1)] - sx, sy = pair[2] - anchor[2], pair[1] - anchor[1] - else - sx, sy = 0, 0 -- the in-place flap sits on the player - facing = "right" - end - love.graphics.setColor(1, 1, 1, 1) - self.birdSprite:draw(self.player.px + sx, self.player.py + sy, - cam.x, cam.y, facing, step % 2, false) - end - - -- fishing pose: the rod tile over the faced water (gfx/fishing.asm) - local function fxRod() - if not self.fishing then return end - -- engine/overworld/player_animations.asm:424 - if self.fishing.hideRod then return end - local fx = Game.data.field.overworldFx - local rod = fx and fx.fishingRod - if rod then - if self.rodImg == nil then - local ok, img = pcall(love.graphics.newImage, rod.path) - self.rodImg = ok and img or false - end - if self.rodImg then - local p = self.player - local oam = ROD_OAM[self.fishing.facing] or ROD_OAM.down - if not self.rodQuads then - -- one quad per 8x8 tile of the stacked sheet (ROD_OAM.tile) - local iw, ih = self.rodImg:getDimensions() - self.rodQuads = {} - for i = 0, math.floor(ih / 8) - 1 do - self.rodQuads[i] = love.graphics.newQuad(0, i * 8, 8, 8, iw, ih) - end - end - local quad = self.rodQuads[oam.tile] - -- Place the rod against the active sprite's anchored top-left. The - -- vanilla result is still (px-cam, py-cam-4), while custom larger - -- sheets keep the rod attached to their feet. - -- Fishing always uses the on-foot player sheet; read its fields - -- directly so this FX pass does not advance pose-side animation. - local sprite, px, py = p.sprite, p.px, p.py - local sx, sy = sprite:getScreenOrigin(px, py, cam.x, cam.y) - local rx = sx + oam.dx - -- engine/overworld/player_animations.asm:453 - local ry = sy + oam.dy + (p.fishShakeDy or 0) - love.graphics.setColor(1, 1, 1, 1) - if quad and oam.flip then - love.graphics.draw(self.rodImg, quad, rx + 8, ry, 0, -1, 1) - elseif quad then - love.graphics.draw(self.rodImg, quad, rx, ry) - end - end - end - end + table.sort(self.ghosts, ghostDrawOrder) + table.sort(self.entities, entityDrawOrder) if pipelineId then -- === PIPELINE PATH: a mod owns the world pass. ====================== @@ -6234,6 +6306,13 @@ function OverworldState:drawWorld() -- copy of every effect: the closures above are the ones that run. local _, _, pw, ph = Game.renderer:playfieldRect() local pscale = Zoom.scale(Game.renderer:fitScale()) + -- the mod-facing ctx.fx draw bodies take no arguments + local fxHealFn = function() return fxHeal(self, cam) end + local fxDustFn = function() return fxDust(self, cam) end + local fxCutTreeFn = function() return fxCutTree(self, cam) end + local fxEmoteFn = function() return fxEmote(self, cam) end + local fxBirdFn = function() return fxBird(self, cam) end + local fxRodFn = function() return fxRod(self, cam) end local ctx = { state = self, cam = cam, vw = vw, vh = vh, bgY = bgY, width = pw, height = ph, scale = pscale, @@ -6247,8 +6326,8 @@ function OverworldState:drawWorld() if PaletteFX.usesGbcPack() then return nil end return PaletteFX.pal(Game.data, self:paletteNameFor(map or self.map)) end, - fx = { heal = fxHeal, dust = fxDust, cutTree = fxCutTree, - emote = fxEmote, bird = fxBird, rod = fxRod }, + fx = { heal = fxHealFn, dust = fxDustFn, cutTree = fxCutTreeFn, + emote = fxEmoteFn, bird = fxBirdFn, rod = fxRodFn }, } -- Draw every active field FX into the finished scene. `project(wx, wy)` -- maps a world point to canvas pixels (nil when it is behind the @@ -6283,23 +6362,23 @@ function OverworldState:drawWorld() -- ground-hugging effects sit on the cell they belong to if self.dustAnim then local da = self.dustAnim - at(fxDust, da.x * 16 + 8 + (da.ox or 0), da.y * 16 + 8 + (da.oy or 0)) + at(fxDustFn, da.x * 16 + 8 + (da.ox or 0), da.y * 16 + 8 + (da.oy or 0)) end if self.cutAnim then - at(fxCutTree, self.cutAnim.x * 16 + 8, self.cutAnim.y * 16 + 16) + at(fxCutTreeFn, self.cutAnim.x * 16 + 8, self.cutAnim.y * 16 + 16) end if self.healAnim then - at(fxHeal, self.healAnim.px + 8, self.healAnim.py + 16) + at(fxHealFn, self.healAnim.px + 8, self.healAnim.py + 16) end -- standing effects anchor at the foot of whoever they belong to if self.emote and self.emote.npc then - at(fxEmote, self.emote.npc.px + 8, self.emote.npc.py + 16) + at(fxEmoteFn, self.emote.npc.px + 8, self.emote.npc.py + 16) end if self.flyAnim then - at(fxBird, self.player.px + 8, self.player.py + 16) + at(fxBirdFn, self.player.px + 8, self.player.py + 16) end if self.fishing then - at(fxRod, self.player.px + 8, self.player.py + 16) + at(fxRodFn, self.player.px + 8, self.player.py + 16) end end) end @@ -6337,44 +6416,45 @@ function OverworldState:drawWorld() local grassColors = PaletteFX.usesSpriteObp() and PaletteFX.pal(Game.data, self:paletteNameFor(self.map)) or nil local boulderDust = self.dustAnim and self.dustAnim.boulder - local grassCells, grassSeen = {}, {} - local function queueGrass(cx, cy) - if cx == nil or cy == nil or not self.map:isGrassCell(cx, cy) then - return - end - local key = tostring(cx) .. ":" .. tostring(cy) - if grassSeen[key] then return end - grassSeen[key] = true - grassCells[#grassCells + 1] = { cx, cy } + local grass = self.grassScratch + if not grass then + grass = { n = 0, x = {}, y = {} } + self.grassScratch = grass end - if boulderDust then fxDust() end + grass.n = 0 + if boulderDust then fxDust(self, cam) end for _, g in ipairs(self.ghosts) do if self.battleOamKeep == nil then - g.npc:draw(cam.x - g.ox, cam.y - g.oy) + local gx, gy = cam.x - g.ox, cam.y - g.oy + if not ghostOffscreen(g, gx, gy, vw, vh) then + g.npc:draw(gx, gy) + end end end for _, e in ipairs(self.entities) do if not ((self.flyAnim or self.flyArrive or self.playerHidden) and e == self.player) and not self:oamCulled(e) then e:draw(cam.x, cam.y) - queueGrass(e.cellX, e.cellY) - queueGrass(e.targetX, e.targetY) + queueGrass(self, grass, e.cellX, e.cellY) + queueGrass(self, grass, e.targetX, e.targetY) end end love.graphics.setColor(1, 1, 1, 1) - for _, cell in ipairs(grassCells) do - self.map.renderer:drawCellBottom(cell[1], cell[2], cam.x, bgY) + local gxs, gys = grass.x, grass.y + for i = 1, grass.n do + local cx, cy = gxs[i], gys[i] + self.map.renderer:drawCellBottom(cx, cy, cam.x, bgY) if grassColors then - self.map.renderer:markCellBottomRedraw(cell[1], cell[2], + self.map.renderer:markCellBottomRedraw(cx, cy, cam.x, bgY, grassColors) end end - fxHeal() - if not boulderDust then fxDust() end - fxCutTree() - fxEmote() - fxBird() - fxRod() + fxHeal(self, cam) + if not boulderDust then fxDust(self, cam) end + fxCutTree(self, cam) + fxEmote(self, cam) + fxBird(self, cam) + fxRod(self, cam) else -- === TILT PATH: ground-hugging FX stay on the projected ground, all -- standing things billboard upright over it in a separate pass. ====== @@ -6384,9 +6464,9 @@ function OverworldState:drawWorld() -- draws them last, over the sprites, in the same canvas; here the two -- layers are separate and composited ground-under-upright, so drawing -- them now into the still-active ground canvas is order-equivalent. - fxHeal() - fxDust() - fxCutTree() + fxHeal(self, cam) + fxDust(self, cam) + fxCutTree(self, cam) Game.renderer:beginUprightPass() @@ -6451,17 +6531,20 @@ function OverworldState:drawWorld() 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 - self:billboard(fx, fy, vw, vh, zoneColorsAt(zones, fx, fy), false, fxEmote) + self:billboard(fx, fy, vw, vh, zoneColorsAt(zones, fx, fy), false, fxEmote, + self, cam) end if self.flyAnim or self.flyArrive then local fx = self.player.px - cam.x + 8 local fy = self.player.py - cam.y + 16 - self:billboard(fx, fy, vw, vh, zoneColorsAt(zones, fx, fy), false, fxBird) + self:billboard(fx, fy, vw, vh, zoneColorsAt(zones, fx, fy), false, fxBird, + self, cam) end if self.fishing then local fx = self.player.px - cam.x + 8 local fy = self.player.py - cam.y + 16 - self:billboard(fx, fy, vw, vh, zoneColorsAt(zones, fx, fy), false, fxRod) + self:billboard(fx, fy, vw, vh, zoneColorsAt(zones, fx, fy), false, fxRod, + self, cam) end Game.renderer:endUprightPass() diff --git a/src/world/Player.lua b/src/world/Player.lua index 8e3480a8..c547c952 100644 --- a/src/world/Player.lua +++ b/src/world/Player.lua @@ -275,6 +275,28 @@ function Player:walkPhase() return (p >= 4 and p < 12) and 1 or 0 end +-- The surf bob is sampled by pose() (draw code) but must run at the fixed +-- logic rate: it used to tick once per pose() call, so a 144Hz display +-- bobbed 2.4x too fast and the battle-transition wipe, which draws the +-- player twice a frame, doubled it. It now advances by the number of +-- Game:step logic steps since the last pose(), i.e. exactly once per step at +-- 60Hz and never twice for one step. Without a running Game (headless +-- callers) there is no step clock and each call advances once, as before. +function Player:advanceBob() + local Game = package.loaded["src.core.Game"] + local step = type(Game) == "table" and Game.logicStep or nil + local ticks = 1 + if step then + local last = self.bobStep + ticks = last and step - last or 1 + self.bobStep = step + end + if ticks > 0 then + self.bobTimer = ((self.bobTimer or 0) + ticks) % 32 + end + return self.bobTimer or 0 +end + local SPIN_ORDER = { "down", "left", "up", "right" } -- constants/sprite_data_constants.asm:3 local IMAGE_FACING = { [0] = "down", [1] = "up", [2] = "left", [3] = "right" } @@ -298,7 +320,7 @@ function Player:pose() py = py - math.floor(10 * math.sin(t * math.pi) + 0.5) hopping = true elseif self.surfing then - self.bobTimer = ((self.bobTimer or 0) + 1) % 32 + self:advanceBob() py = py + (self.bobTimer < 16 and 0 or 1) end -- engine/overworld/player_animations.asm:453 diff --git a/src/world/gen2/BorderFill.lua b/src/world/gen2/BorderFill.lua index 64541fd2..8c26fd5f 100644 --- a/src/world/gen2/BorderFill.lua +++ b/src/world/gen2/BorderFill.lua @@ -259,17 +259,25 @@ BorderFill.CROSSFADE_FRAMES = 20 -- The bookkeeping half, love-free so it can be checked without a canvas. -- Returns the image to draw underneath (nil on the first fill and once the -- dissolve is over) and the alpha the incoming image draws at. -function BorderFill.crossfade(owner, image, key) +-- +-- `advance` is how many 60Hz frames the dissolve moves on by (default 1, one +-- per call). An owner that keeps `borderTicks` (World counts its logic steps +-- there) has BorderFill.draw pass those instead, so the fade runs on the +-- logic clock rather than once per render frame. +function BorderFill.crossfade(owner, image, key, advance) if not owner or key == nil then return nil, 1 end if owner.borderKey ~= key then -- Nothing to dissolve from on the first map of a session. owner.borderFrom = (owner.borderKey ~= nil) and owner.borderLast or nil owner.borderKey = key owner.borderFade = owner.borderFrom and 0 or nil + -- Ticks banked before the swap (a stretch with the fill not drawn at all) + -- belong to no dissolve: a new one starts at its first frame. + if advance and advance > 1 then advance = 1 end end owner.borderLast = image if not owner.borderFade then return nil, 1 end - owner.borderFade = owner.borderFade + 1 + owner.borderFade = owner.borderFade + (advance or 1) if owner.borderFade >= BorderFill.CROSSFADE_FRAMES then owner.borderFade, owner.borderFrom = nil, nil return nil, 1 @@ -291,7 +299,12 @@ function BorderFill.draw(owner, image, camX, camY, w, h, s, key) BorderFill.SIZE, BorderFill.SIZE) if owner then owner.borderQuad = q end end - local from, alpha = BorderFill.crossfade(owner, image, key) + local advance = nil + if owner and owner.borderTicks ~= nil then + advance = owner.borderTicks + owner.borderTicks = 0 + end + local from, alpha = BorderFill.crossfade(owner, image, key, advance) if from then love.graphics.setColor(1, 1, 1, 1) love.graphics.draw(from, q, sx, sy, 0, s, s) diff --git a/src/world/gen2/MapAttrGrid.lua b/src/world/gen2/MapAttrGrid.lua index 9349387c..db359d5e 100644 --- a/src/world/gen2/MapAttrGrid.lua +++ b/src/world/gen2/MapAttrGrid.lua @@ -58,15 +58,30 @@ function MapAttrGrid.cellAt(map, tileset, mx, my) return { tileId = tileId, rawTileId = raw, attr = attr } end --- Full map grid keyed by "mx,my" for fast lookup during overdraw. +-- Full map grid for fast lookup during overdraw: a flat array indexed by +-- 8x8 cell (row-major, `cols` cells to a row), so a lookup is two divides and +-- an index rather than a string key built per tile per entity per frame. +-- Cells with the same raw tile id share one read-only cell table: the +-- normalization depends only on (raw id, tileset). function MapAttrGrid.build(map, tileset) - local grid = {} + local grid = { cols = 0, rows = 0, cells = {} } if not (map and tileset) then return grid end - local pw, ph = map.width * 32, map.height * 32 - for my = 0, ph - 1, 8 do - for mx = 0, pw - 1, 8 do - local cell = MapAttrGrid.cellAt(map, tileset, mx, my) - if cell then grid[mx .. "," .. my] = cell end + local cols, rows = map.width * 4, map.height * 4 + grid.cols, grid.rows = cols, rows + local cells = grid.cells + local byRaw = {} + for cy = 0, rows - 1 do + for cx = 0, cols - 1 do + local raw = MapAttrGrid.tileAt(map, tileset, cx * 8, cy * 8) + if raw ~= nil then + local cell = byRaw[raw] + if not cell then + local tileId, attr = MapAttrGrid.normalizeTile(raw, tileset) + cell = { tileId = tileId, rawTileId = raw, attr = attr } + byRaw[raw] = cell + end + cells[cy * cols + cx + 1] = cell + end end end return grid @@ -74,9 +89,13 @@ end function MapAttrGrid.lookup(grid, mx, my) if not grid then return nil end - local tx = math.floor(mx / 8) * 8 - local ty = math.floor(my / 8) * 8 - return grid[tx .. "," .. ty] + local cx = math.floor(mx / 8) + local cy = math.floor(my / 8) + local cols = grid.cols + if not cols or cx < 0 or cy < 0 or cx >= cols or cy >= grid.rows then + return nil + end + return grid.cells[cy * cols + cx + 1] end return MapAttrGrid diff --git a/src/world/gen2/World.lua b/src/world/gen2/World.lua index 33a5277a..df56e818 100644 --- a/src/world/gen2/World.lua +++ b/src/world/gen2/World.lua @@ -1796,15 +1796,23 @@ end -- Script_checkver: 0 for Gold, 1 for Silver (constants/misc_constants.asm -- GS_VERSION). +-- A loaded module straight out of package.loaded, falling back to require: +-- the same answer require gives, without the call on the per-frame paths. +-- Looked up each time rather than bound once, because tests swap +-- src.core.GameVersion in package.loaded under a live World. +local function loaded(name) + return package.loaded[name] or require(name) +end + function World:gsVersion() - local GameVersion = require("src.core.GameVersion") + local GameVersion = loaded("src.core.GameVersion") local save = self.game and self.game.save local version = (save and save.version) or GameVersion.get() return version == "silver" and 1 or 0 end function World:isCrystal() - local GameVersion = require("src.core.GameVersion") + local GameVersion = loaded("src.core.GameVersion") -- Rendering follows the loaded ROM column (BorderFill / MapPreview do the -- same), not save.version — a Crystal save under the Gold column still bakes -- and draws with Gold's single-bank tilesets. @@ -2364,6 +2372,28 @@ function World:poisonBGFlash() self.poisonFlash = 4 end +-- One frame of LoadPoisonBGPals' DelayFrames, spent once per fixed logic +-- step. Game2 ticks it whatever state is on top, the way the draw-side +-- countdown used to run under a text box, just no longer once per render. +function World:tickPoisonFlash() + if self.poisonFlash and self.poisonFlash > 0 then + self.poisonFlash = self.poisonFlash - 1 + end +end + +-- The presentation clocks that used to advance once per World:draw, moved +-- onto the 60Hz logic step so they last the same time at any refresh rate. +-- Game2 calls this every fixed step whatever state is on top -- a text box +-- over the world must not freeze them, which it did not when they ran off +-- the draw either. +function World:tickFrameClocks() + self:tickPoisonFlash() + -- The void-fill dissolve: BorderFill.draw spends these (banked up to one + -- whole dissolve while nothing draws the fill). + self.borderTicks = math.min((self.borderTicks or 0) + 1, + BorderFill.CROSSFADE_FRAMES) +end + -- Script_warp / Script_warpfacing: a raw destination CELL, distinct from the -- warp_events World:takeWarp follows. `facing` is nil for `warp` and a -- Movement direction for `warpfacing` (PLAYERSPRITESETUP_CUSTOM_FACING). A @@ -8428,9 +8458,13 @@ function World:refuseTrainer(record) end function World:trainerRefused(record) + -- Checked first: the key is only worth building when something was refused, + -- and the sight check asks this for every trainer every idle frame. + local refused = self.refusedTrainers + if not refused then return false end local key = trainerKey(record) - if not (key and self.refusedTrainers) then return false end - return self.refusedTrainers[key] and true or false + if not key then return false end + return refused[key] and true or false end -- _CheckTrainerBattle: every visible, unbeaten trainer object that is facing @@ -8737,16 +8771,35 @@ local ROOF_TILESETS = { TILESET_JOHTO_MODERN = true, } +-- Weak-keyed per-table caches for the draw pass (one local, not three: this +-- chunk sits near Lua's 200-locals limit). Filled lazily below. +local drawCaches = { + animIndexes = setmetatable({}, { __mode = "k" }), + animRows = setmetatable({}, { __mode = "k" }), + atlasKeys = setmetatable({}, { __mode = "k" }), +} + +-- atlasFor's cache key per map def, built once: the draw pass asks for the +-- atlas per standing entity per frame, and the roof name only depends on the +-- def's tileset and group and on self.roofs. function World:atlasFor(mapDef) local tileset = self.tilesets[mapDef.tileset] if not tileset then return nil end - local cacheKey = mapDef.tileset - local roofName = nil - if ROOF_TILESETS[mapDef.tileset] then - roofName = self.roofs and self.roofs.mapGroupRoofs - and self.roofs.mapGroupRoofs[mapDef.group] + local memo = drawCaches.atlasKeys[mapDef] + local cacheKey, roofName + if memo and memo.tileset == mapDef.tileset and memo.group == mapDef.group + and memo.roofs == self.roofs then + cacheKey, roofName = memo.key, memo.roofName + else + cacheKey = mapDef.tileset + if ROOF_TILESETS[mapDef.tileset] then + roofName = self.roofs and self.roofs.mapGroupRoofs + and self.roofs.mapGroupRoofs[mapDef.group] + end + if roofName then cacheKey = cacheKey .. "|" .. roofName end + drawCaches.atlasKeys[mapDef] = { tileset = mapDef.tileset, group = mapDef.group, + roofs = self.roofs, key = cacheKey, roofName = roofName } end - if roofName then cacheKey = cacheKey .. "|" .. roofName end local cached = self.atlasCache[cacheKey] if cached then return cached, tileset end @@ -8975,12 +9028,35 @@ end -- The key imageFor caches a map's bake under, shared with the anim cell lists -- and the palettes so the draw pass can find them from a map id alone. -function World:mapCacheKey(mapId) +-- +-- Memoized: the draw pass asks for it per map and per standing entity every +-- frame, and it only changes when one of its inputs does. The memo is +-- dropped whenever the daytime, COLOR mode, custom ramp (by identity, the +-- same thing tostring keys on) or flicker phase moves, so a stale key is +-- never handed out. +local function mapKeyMemo(self) local daytime = self.daytime local flicker = (daytime == "DARK") and self.flickerPhase or 1 - return mapId .. "|" .. tostring(daytime) + local mode, ramp = GbcPalette.mode, GbcPalette.customRamp + local memo = self._mapKeyMemo + if not (memo and memo.daytime == daytime and memo.flicker == flicker + and memo.mode == mode and memo.ramp == ramp) then + memo = { daytime = daytime, flicker = flicker, mode = mode, ramp = ramp, + keys = {}, border = {} } + self._mapKeyMemo = memo + end + return memo, daytime, flicker +end + +function World:mapCacheKey(mapId) + local memo, daytime, flicker = mapKeyMemo(self) + local key = memo.keys[mapId] + if key then return key end + key = mapId .. "|" .. tostring(daytime) .. "|" .. tostring(GbcPalette.mode) .. "|" .. tostring(GbcPalette.customRamp) .. "|" .. tostring(flicker) + memo.keys[mapId] = key + return key end function World:imageFor(mapId) @@ -9094,10 +9170,7 @@ local function playfieldOrigin() return 0, 0 end -local function intersectScissor(x, y, w, h, prev) - if not prev then return x, y, w, h end - local px, py, pw, ph = prev[1], prev[2], prev[3], prev[4] - if px == nil then px, py, pw, ph = prev.x, prev.y, prev.width, prev.height end +local function intersectScissor(x, y, w, h, px, py, pw, ph) if not (px and py and pw and ph) then return x, y, w, h end local x2 = math.max(x, px) local y2 = math.max(y, py) @@ -9108,6 +9181,11 @@ local function intersectScissor(x, y, w, h, prev) return x2, y2, iw, ih end +-- The two tile filters the overdraw passes use, hoisted so a draw does not +-- mint a closure per entity per frame. +local function anyTile() return true end +local function priorityTile(info) return info.attr.priority end + function World:feetCompositeCanvas(w, h) local G = love.graphics if not (G and G.newCanvas) then return nil end @@ -9126,6 +9204,11 @@ function World:feetCompositeCanvas(w, h) end -- Blit BG tiles over a map-pixel region already in the current transform. +-- +-- The palette shader is set once per run of tiles sharing a palette set and +-- the caller's shader restored once at the end -- the same shader +-- GbcPalette.with / keyedWith would pick per tile, without the closure and +-- pcall per 8x8 cell. function World:blitBgOverRegionLocal(mapDef, originX, originY, rx0, ry0, rx1, ry1, keyed, tileFilter, scale) local atlas, tileset = self:atlasFor(mapDef) @@ -9135,10 +9218,14 @@ function World:blitBgOverRegionLocal(mapDef, originX, originY, rx0, ry0, rx1, ry local bgSet = self.bgSets[cacheKey] or nil local animCells = self.animCells and self.animCells[cacheKey] or nil local tilesPerRow = tileset.tilesPerRow or 16 - self.bgOverQuads = self.bgOverQuads or {} + local quads = self:bgOverQuadsFor(atlas) local map = self.map scale = scale or 1 + tileFilter = tileFilter or anyTile G.setColor(1, 1, 1, 1) + local paletteOn = bgSet and GbcPalette.available() + local previous = paletteOn and G.getShader and G.getShader() or nil + local active = nil for ty = math.floor(ry0 / 8) * 8, math.floor((ry1 - 1) / 8) * 8, 8 do for tx = math.floor(rx0 / 8) * 8, math.floor((rx1 - 1) / 8) * 8, 8 do @@ -9149,64 +9236,114 @@ function World:blitBgOverRegionLocal(mapDef, originX, originY, rx0, ry0, rx1, ry local drawY = math.floor(originY + (ty - ry0) * scale) local animList = self:animListAt(animCells, tx, ty) - local function blitTile(img, quad, drawAttr) - TileAttrs.drawFlippedTile(img, quad, drawX, drawY, drawAttr, scale, scale) - end - local paletteSlot = attr.palette if animList and animList.slot then paletteSlot = animList.slot end local set = bgSet and bgSet[paletteSlot] - local function runBlit(img, quad) - local function body() blitTile(img, quad, attr) end - if set and GbcPalette.available() then - if keyed then GbcPalette.keyedWith(set, body) - else GbcPalette.with(set, body) end - else - body() - end - end - + local img, quad = nil, nil if animList then local layer = animList.layer local sheet if layer.kind == "scroll" then - sheet = self:scrollStrip(mapDef, tileset, animList.tile, layer.scroll) + sheet = self:scrollStrip(mapDef, tileset, animList.tile, layer.scroll, + World.stripKeyFor(animList, mapDef, layer.scroll)) else sheet = self:animSheet(layer.sheet) end if sheet then - local row = self:animRow(layer) - local quad = self:animQuad( - layer.sheet or ("scroll|" .. animList.tile), row, layer.frames) - runBlit(sheet, quad) - else - local tile = info.tileId - local quad = TileAttrs.quadFor( - atlas, tile, attr, tilesPerRow, self.bgOverQuads) - runBlit(atlas, quad) + img = sheet + quad = self:animQuad(World.animQuadKeyFor(animList), + self:animRow(layer), layer.frames) end - else - local tile = info.tileId - local quad = TileAttrs.quadFor( - atlas, tile, attr, tilesPerRow, self.bgOverQuads) - runBlit(atlas, quad) end + if not img then + img = atlas + quad = TileAttrs.quadFor(atlas, info.tileId, attr, tilesPerRow, quads) + end + + if not paletteOn then set = nil end + if set ~= active then + if set then + if keyed then GbcPalette.useKeyed(set) else GbcPalette.use(set) end + else + G.setShader(previous) + end + active = set + end + TileAttrs.drawFlippedTile(img, quad, drawX, drawY, attr, scale, scale) end end end + if active then G.setShader(previous) end +end + +-- TileAttrs.quadFor's cache, one per atlas image: the quads carry the atlas' +-- own dimensions, so two tilesets (or a roof composite) sharing one table +-- would hand each other quads cut for the wrong sheet. Weak keys, so an +-- atlas dropped with atlasCache takes its quads with it. +function World:bgOverQuadsFor(atlas) + local byAtlas = self.bgOverQuads + if not byAtlas then + byAtlas = setmetatable({}, { __mode = "k" }) + self.bgOverQuads = byAtlas + end + local quads = byAtlas[atlas] + if not quads then + quads = {} + byAtlas[atlas] = quads + end + return quads +end + +-- Per-cell index of an anim cell table: cell (tx, ty) -> its list. Built +-- once per table (the tables live as long as their bake) instead of a scan +-- of every animated cell on the map per 8x8 tile per entity per frame. +local function animIndexFor(animCells) + local index = drawCaches.animIndexes[animCells] + if index then return index end + index = {} + for _, list in pairs(animCells) do + local xy = list.cells + for i = 1, #xy, 2 do + local id = xy[i + 1] * 65536 + xy[i] + -- First list wins, the same answer the linear scan gave. + if index[id] == nil then index[id] = list end + end + end + drawCaches.animIndexes[animCells] = index + return index end -- Whether (tx, ty) is repainted by this map's tileset anim program. function World:animListAt(animCells, tx, ty) if not animCells then return nil end - for _, list in pairs(animCells) do - local xy = list.cells - for i = 1, #xy, 2 do - if xy[i] == tx and xy[i + 1] == ty then return list end - end + return animIndexFor(animCells)[ty * 65536 + tx] +end + +-- The scrollStrip cache key for one anim list, built once per list rather +-- than concatenated per frame. +function World.stripKeyFor(list, mapDef, scroll) + local ts = mapDef.tileset + local key = list._stripKey + if key == nil or list._stripKeyTs ~= ts or list._stripKeyScroll ~= scroll then + key = tostring(ts) .. "|" .. list.tile .. "|" + .. tostring(scroll.h) .. "," .. tostring(scroll.v) + list._stripKey, list._stripKeyTs, list._stripKeyScroll = key, ts, scroll end - return nil + return key +end + +-- animQuad's key for one anim list: the layer's sheet path, or the scroll +-- strip's stand-in name, cached on the list. +function World.animQuadKeyFor(list) + local layer = list.layer + if layer.sheet then return layer.sheet end + local key = list._quadKey + if not key then + key = "scroll|" .. list.tile + list._quadKey = key + end + return key end -- Shared BG-over-OBJ blit. Each intersecting 8x8 cell is drawn whole; the @@ -9222,9 +9359,9 @@ function World:blitBgOverRegion(mapDef, ox, oy, s, rx0, ry0, rx1, ry1, keyed, ti local scissorH = math.ceil((ry1 - ry0) * s) local psx, psy, psw, psh if G.getScissor then psx, psy, psw, psh = G.getScissor() end - local prevScissor = psx and { psx, psy, psw, psh } or nil local clipX, clipY, clipW, clipH = - intersectScissor(scissorX, scissorY, scissorW, scissorH, prevScissor) + intersectScissor(scissorX, scissorY, scissorW, scissorH, + psx, psy, psw, psh) if not clipX then return end if G.setScissor then G.setScissor(clipX, clipY, clipW, clipH) end @@ -9237,7 +9374,7 @@ function World:blitBgOverRegion(mapDef, ox, oy, s, rx0, ry0, rx1, ry1, keyed, ti rx0, ry0, rx1, ry1, keyed, tileFilter, s) if G.setScissor then - if prevScissor then G.setScissor(psx, psy, psw, psh) else G.setScissor() end + if psx then G.setScissor(psx, psy, psw, psh) else G.setScissor() end end end @@ -9251,7 +9388,7 @@ function World:drawFeetComposite(entity, ox, oy, s, drawBottomOam) if not canvas then drawBottomOam() self:blitBgOverRegion(self.map.def, ox, oy, s, - x0, y0, x1, y1, true, function() return true end) + x0, y0, x1, y1, true, anyTile) return end @@ -9263,7 +9400,7 @@ function World:drawFeetComposite(entity, ox, oy, s, drawBottomOam) G.translate(-x0, -y0) drawBottomOam() self:blitBgOverRegionLocal(self.map.def, 0, 0, x0, y0, x1, y1, - true, function() return true end, 1) + true, anyTile, 1) G.setCanvas(prev) G.pop() @@ -9286,6 +9423,11 @@ function World:drawGrassOverGoldSilver(entity, ox, oy, s) self.grassQuad = self.grassQuad or G.newQuad(0, 0, 8, 8, aw, ah) local quad = self.grassQuad G.setColor(1, 1, 1, 1) + -- One palette run per set, the caller's shader restored once at the end: + -- what GbcPalette.with did per tile, without a closure and pcall each. + local paletteOn = bgSet and GbcPalette.available() + local previous = paletteOn and G.getShader and G.getShader() or nil + local active = nil for ty = math.floor(ry / 8) * 8, math.floor((ry + 7) / 8) * 8, 8 do for tx = math.floor(rx / 8) * 8, math.floor((rx + 15) / 8) * 8, 8 do local tile = self:bgTileAt(map, tileset, tx, ty) @@ -9298,21 +9440,19 @@ function World:drawGrassOverGoldSilver(entity, ox, oy, s) (tile % tilesPerRow) * 8 + (cx0 - tx), math.floor(tile / tilesPerRow) * 8 + (cy0 - ty), cx1 - cx0, cy1 - cy0, aw, ah) - local set = bgSet and bgSet[tilePalettes - and tilePalettes[tile + 1] or 1] - local function blit() - G.draw(atlas, quad, - math.floor(ox + cx0 * s), math.floor(oy + cy0 * s), 0, s, s) - end - if set and GbcPalette.available() then - GbcPalette.with(set, blit) - else - blit() + local set = paletteOn and bgSet[tilePalettes + and tilePalettes[tile + 1] or 1] or nil + if set ~= active then + if set then GbcPalette.use(set) else G.setShader(previous) end + active = set end + G.draw(atlas, quad, + math.floor(ox + cx0 * s), math.floor(oy + cy0 * s), 0, s, s) end end end end + if active then G.setShader(previous) end end -- Crystal: attrmap tiles, full 8x8 cells, keyed BG-over-OAM in the feet strip. @@ -9320,7 +9460,7 @@ function World:drawGrassOverCrystal(entity, ox, oy, s) if not (entity and entity.inGrass and self.map) then return end local x0, y0, x1, y1 = OamFootprint.feetStrip(entity) self:blitBgOverRegion(self.map.def, ox, oy, s, - x0, y0, x1, y1, true, function() return true end) + x0, y0, x1, y1, true, anyTile) end function World:drawGrassOver(entity, ox, oy, s) @@ -9336,7 +9476,7 @@ function World:drawBgPriorityOver(entity, ox, oy, s) if not self:isCrystal() then return end local x0, y0, x1, y1 = OamFootprint.spriteBBox(entity) self:blitBgOverRegion(self.map.def, ox, oy, s, - x0, y0, x1, y1, false, function(info) return info.attr.priority end) + x0, y0, x1, y1, false, priorityTile) end function World:drawPriorityOver(entity, ox, oy, s) @@ -9351,6 +9491,26 @@ end -- the tile at (0,+8) and (+8,+8) from the sprite's origin, FacingGrass2 at -- (-1,+9) and (+9,+9), and SetFacingGrassShake's `and 4` alternates them every -- four frames (data/sprites/facings.asm:230-239, map_object_action.asm:257). +-- The PAL_OW_* ids the standing overlays look up, as constant sprite defs +-- rather than a table per draw. +local OW_PALS = { [5] = { paletteId = 5 }, [6] = { paletteId = 6 } } + +-- Draw two OBJ halves under one palette: GbcPalette.use + restore rather +-- than GbcPalette.with and a closure per call. +local function drawObjPair(colors, sheet, x1, x2, y, s) + local G = love.graphics + local previous + local applied = colors and GbcPalette.available() + if applied then + previous = G.getShader and G.getShader() or nil + GbcPalette.use(colors) + end + G.setColor(1, 1, 1, 1) + G.draw(sheet, x1, y, 0, s, s) + G.draw(sheet, x2, y, 0, -s, s) + if applied then G.setShader(previous) end +end + function World:drawGrassShake(entity, ox, oy, s) local sheet = self.grassRustleImage if not (sheet and entity and entity.grassShake and entity.moving) then @@ -9361,85 +9521,80 @@ function World:drawGrassShake(entity, ox, oy, s) -- MovementFunction_ShakingGrass takes the tracked object's STEP_DURATION -- minus one (map_objects.asm:965), so it dies a frame before the step lands. if progress >= frames then return end - local G = love.graphics local x1, x2, dy = 0, 8, 4 if progress % 8 >= 4 then x1, x2, dy = -1, 9, 5 end local colors = Palettes.spritePalette(self.palettes, self.daytime or Palettes.daytimeFor(self.map and self.map.def, self:hour(), self.flashUsed), - { paletteId = 6 }) - local function blit() - G.setColor(1, 1, 1, 1) - local y = math.floor(oy + (entity.py + dy) * s) - G.draw(sheet, math.floor(ox + (entity.px + x1) * s), y, 0, s, s) - -- OAM_XFLIP on the second entry of both facings, so it draws right to left. - G.draw(sheet, math.floor(ox + (entity.px + x2 + 8) * s), y, 0, -s, s) - end - if colors and GbcPalette.available() then - GbcPalette.with(colors, blit) - else - blit() - end + OW_PALS[6]) + local y = math.floor(oy + (entity.py + dy) * s) + -- OAM_XFLIP on the second entry of both facings, so it draws right to left. + drawObjPair(colors, sheet, math.floor(ox + (entity.px + x1) * s), + math.floor(ox + (entity.px + x2 + 8) * s), y, s) end -- engine/overworld/map_objects.asm:1995, :879-893, facings.asm:161-164 function World:drawJumpShadow(entity, ox, oy, s) local sheet = self.jumpShadowImage if not (sheet and entity and entity.jumping) then return end - local G = love.graphics local facing = entity.facing local dy = (facing == "left" or facing == "right") and 8 or 10 local colors = Palettes.spritePalette(self.palettes, self.daytime or Palettes.daytimeFor(self.map and self.map.def, self:hour(), self.flashUsed), - { paletteId = 5 }) - local function blit() - G.setColor(1, 1, 1, 1) - local y = math.floor(oy + (entity.py + dy) * s) - G.draw(sheet, math.floor(ox + entity.px * s), y, 0, s, s) - G.draw(sheet, math.floor(ox + (entity.px + 16) * s), y, 0, -s, s) - end - if colors and GbcPalette.available() then - GbcPalette.with(colors, blit) - else - blit() - end + OW_PALS[5]) + local y = math.floor(oy + (entity.py + dy) * s) + drawObjPair(colors, sheet, math.floor(ox + entity.px * s), + math.floor(ox + (entity.px + 16) * s), y, s) end -- This frame's AnimateWaterTile graphic for a map's border block, or nil when -- the block holds no water at all (engine/tilesets/tileset_anims.asm:167). +-- +-- Everything but the row is static per (map, tileset, fill block), so the +-- scan runs once per change of those and the descriptor table is reused: +-- this is asked every overworld frame. The table is only read by the bake +-- the same call makes, so handing out the one copy is safe. function World:borderWaterFrame(def, tileset) local anim = tileset and tileset.anim if not (anim and anim.frames) then return nil end - local tile - for _, frame in ipairs(anim.frames) do - if frame.func == "AnimateWaterTile" and frame.tile then - tile = frame.tile - break - end - end - if not tile then return nil end local fill = BorderFill.fillBlock(def) if fill == false then return nil end - local block = tileset.blocks - and tileset.blocks[BorderFill.blockFor(0, fill) + 1] - if not block then return nil end - local found = false - for i = 1, 16 do - if block[i] == tile then - found = true - break + local memo = self._borderWater + if not (memo and memo.def == def and memo.tileset == tileset + and memo.fill == fill and memo.frames == anim.frames) then + local frame = false + local tile + for _, f in ipairs(anim.frames) do + if f.func == "AnimateWaterTile" and f.tile then + tile = f.tile + break + end end + local block = tile and tileset.blocks + and tileset.blocks[BorderFill.blockFor(0, fill) + 1] + if block then + for i = 1, 16 do + if block[i] == tile then + frame = { + tile = tile, + slot = tileset.tilePalettes and tileset.tilePalettes[tile + 1] or 1, + } + break + end + end + end + memo = { def = def, tileset = tileset, fill = fill, frames = anim.frames, + frame = frame } + self._borderWater = memo end - if not found then return nil end + local frame = memo.frame + if not frame then return nil end local sheets = self:animSheets() if not (sheets and sheets.water) then return nil end - return { - image = sheets.water, - row = World.waterFrameFor(self.animTimer), - tile = tile, - slot = tileset.tilePalettes and tileset.tilePalettes[tile + 1] or 1, - } + frame.image = sheets.water + frame.row = World.waterFrameFor(self.animTimer) + return frame end -- The 32x32 border-block bake for a map, cached under the same key its canvas @@ -9463,12 +9618,26 @@ function World:borderImageFor(mapId) -- FILL mode is in the key so switching FADE/WATER/TREES does not keep a -- stale bake (#1418). local waterFrame = self:borderWaterFrame(def, tileset) - local cacheKey = BorderFill.cacheKey(mapId .. "|" .. tostring(daytime) - .. "|" .. tostring(GbcPalette.mode) .. "|" .. tostring(GbcPalette.customRamp) - .. "|" .. tostring(flicker) - .. "|" .. tostring(BorderFill.voidFill or "fade") - .. "|" .. tostring(blockId) - .. "|" .. tostring(waterFrame and waterFrame.row or 0)) + local row = waterFrame and waterFrame.row or 0 + -- Memoized beside mapCacheKey's own memo, which is dropped on any change of + -- daytime / COLOR mode / ramp / flicker; the fill and block are checked here. + local memo = mapKeyMemo(self) + local voidFill = BorderFill.voidFill or "fade" + local entry = memo.border[mapId] + if not (entry and entry.voidFill == voidFill and entry.blockId == blockId) then + entry = { voidFill = voidFill, blockId = blockId, keys = {} } + memo.border[mapId] = entry + end + local cacheKey = entry.keys[row] + if not cacheKey then + cacheKey = BorderFill.cacheKey(mapId .. "|" .. tostring(daytime) + .. "|" .. tostring(GbcPalette.mode) .. "|" .. tostring(GbcPalette.customRamp) + .. "|" .. tostring(flicker) + .. "|" .. tostring(voidFill) + .. "|" .. tostring(blockId) + .. "|" .. tostring(row)) + entry.keys[row] = cacheKey + end local cached = self.mapImages[cacheKey] if cached ~= nil then return cached or nil end local atlas = self:atlasFor(def) @@ -9595,9 +9764,9 @@ local SCROLL_H = { [0] = 0, 1, 2, 3, 2, 1, 0, -1 } -- rotates the tile in VRAM (ScrollTileRightLeft, ScrollTileDown :139) and the -- map canvas is already baked, so the rotations are baked off the atlas once -- and drawn over it like any other frame strip. -function World:scrollStrip(mapDef, tileset, tile, scroll) - local key = tostring(mapDef.tileset) .. "|" .. tile .. "|" - .. tostring(scroll.h) .. "," .. tostring(scroll.v) +function World:scrollStrip(mapDef, tileset, tile, scroll, key) + key = key or (tostring(mapDef.tileset) .. "|" .. tile .. "|" + .. tostring(scroll.h) .. "," .. tostring(scroll.v)) self.scrollStrips = self.scrollStrips or {} local cached = self.scrollStrips[key] if cached ~= nil then return cached or nil end @@ -9643,17 +9812,47 @@ end -- One 8x8 row of a frame strip, reused: this runs every overworld frame and a -- fresh Quad per cell would churn the GC. +-- +-- Nested by key then row so the lookup builds no string. function World:animQuad(key, row, frames) - self.animQuads = self.animQuads or {} - local id = key .. "#" .. row - local q = self.animQuads[id] + local byKey = self.animQuads + if not byKey then + byKey = {} + self.animQuads = byKey + end + local rows = byKey[key] + if not rows then + rows = {} + byKey[key] = rows + end + local q = rows[row] if not q then q = love.graphics.newQuad(0, (row - 1) * 8, 8, 8, 8, (frames or 4) * 8) - self.animQuads[id] = q + rows[row] = q end return q end +-- One anim list's cells bucketed by 8px row, built once per list, so the +-- view cull walks only the rows on screen instead of every cell on the map. +local function animRowsFor(list) + local xy = list.cells + local rows = drawCaches.animRows[xy] + if rows then return rows end + rows = {} + for i = 1, #xy, 2 do + local r = math.floor(xy[i + 1] / 8) + local bucket = rows[r] + if not bucket then + bucket = {} + rows[r] = bucket + end + bucket[#bucket + 1] = xy[i] + end + drawCaches.animRows[xy] = rows + return rows +end + -- Draw this frame's animated tiles over one baked canvas. Culled to the -- view: a sea route names every water cell on the map and only a screenful of -- them is ever visible. @@ -9671,39 +9870,43 @@ function World:drawAnimCells(mapId, ox, oy, s) local top = cam.y - oy - 8 local right = left + (self.viewW or 160) + 16 local bottom = top + (self.viewH or 144) + 16 + local paletteOn = bgSet and GbcPalette.available() + local previous = paletteOn and G.getShader and G.getShader() or nil + local rowFirst = math.ceil(top / 8) + local rowLast = math.floor(bottom / 8) + G.setColor(1, 1, 1, 1) for _, list in pairs(cells) do local layer = list.layer local sheet if layer.kind == "scroll" then sheet = tileset - and self:scrollStrip(def, tileset, list.tile, layer.scroll) + and self:scrollStrip(def, tileset, list.tile, layer.scroll, + World.stripKeyFor(list, def, layer.scroll)) else sheet = self:animSheet(layer.sheet) end if sheet then local row = self:animRow(layer) - local quad = self:animQuad( - layer.sheet or ("scroll|" .. list.tile), row, layer.frames) - local xy = list.cells - local function blit() - G.setColor(1, 1, 1, 1) - for i = 1, #xy, 2 do - local tx, ty = xy[i], xy[i + 1] - if tx >= left and tx <= right and ty >= top and ty <= bottom then - G.draw(sheet, quad, - math.floor((tx + ox - cam.x) * s), - math.floor((ty + oy - cam.y) * s), 0, s, s) + local quad = self:animQuad(World.animQuadKeyFor(list), row, layer.frames) + -- All cells of one tile id share a PalMap slot, so the palette is set + -- once per id rather than once per cell. + local set = paletteOn and bgSet[list.slot] or nil + if set then GbcPalette.use(set) end + local rows = animRowsFor(list) + for r = rowFirst, rowLast do + local xs = rows[r] + if xs then + local ty = r * 8 + local dy = math.floor((ty + oy - cam.y) * s) + for i = 1, #xs do + local tx = xs[i] + if tx >= left and tx <= right then + G.draw(sheet, quad, math.floor((tx + ox - cam.x) * s), dy, 0, s, s) + end end end end - -- All cells of one tile id share a PalMap slot, so the palette is set - -- once per id rather than once per cell. - local set = bgSet and bgSet[list.slot] - if set and GbcPalette.available() then - GbcPalette.with(set, blit) - else - blit() - end + if set then G.setShader(previous) end end end end @@ -10659,28 +10862,46 @@ end -- The objects handed over are the port's NPCs, wearing the CART's object ids -- (`object_const_def` is `const_def 2`, and `disappear` already speaks that -- numbering), because those are what a stonetable row names. +local EMPTY_WARPS = {} + function World:handleCmdQueue() if not (self.map and self.vm) then return false end if self:busy() then return false end if CmdQueue.count(self.cmdQueue) == 0 then return false end - local objects = {} + -- Scratch reused frame to frame: on a boulder map this polls every frame. + -- CmdQueue.poll only reads it, and the row it returns is its own data. + local scratch = self._cmdQueueScratch + if not scratch then + scratch = { objects = {}, pool = {} } + scratch.ctx = { + objects = scratch.objects, + collisionAt = function(x, y) return scratch.map:cellCollision(x, y) end, + } + self._cmdQueueScratch = scratch + end + local objects, pool = scratch.objects, scratch.pool + local n = 0 for _, npc in ipairs(self.npcs) do local obj = npc.def if obj and obj.index then - objects[#objects + 1] = { - id = obj.index + 1, - movement = obj.movement, - cellX = npc.cellX, cellY = npc.cellY, - moving = npc.moving and true or false, - } + n = n + 1 + local entry = pool[n] + if not entry then + entry = {} + pool[n] = entry + end + entry.id = obj.index + 1 + entry.movement = obj.movement + entry.cellX, entry.cellY = npc.cellX, npc.cellY + entry.moving = npc.moving and true or false + objects[n] = entry end end + for i = #objects, n + 1, -1 do objects[i] = nil end local map = self.map - local row = CmdQueue.poll(self.cmdQueue, { - objects = objects, - warps = (map.def and map.def.warps) or {}, - collisionAt = function(x, y) return map:cellCollision(x, y) end, - }) + scratch.map = map + scratch.ctx.warps = (map.def and map.def.warps) or EMPTY_WARPS + local row = CmdQueue.poll(self.cmdQueue, scratch.ctx) if not row then return false end -- CallMapScript + EnableScriptMode: the row's script runs like any other. return self.vm:start(row.script) @@ -11268,6 +11489,24 @@ end -- The world pass, split in two because TILT projects only one of them: the -- ground (the neighbor strips and this map) goes onto the perspective plane, -- while everything standing on it draws upright. +-- BorderFill.fillKey, rebuilt only when the map or the VOID FILL mode moves +-- (its only inputs) rather than concatenated every frame. +function World:borderFillKey(def) + local mode = BorderFill.voidFill or "fade" + local memo = self._fillKeyMemo + if memo and memo.def == def and memo.mode == mode + and memo.tileset == (def and def.tileset) + and memo.id == (def and def.id) + and memo.borderBlock == (def and def.borderBlock) then + return memo.key + end + local key = BorderFill.fillKey(def) + self._fillKeyMemo = { def = def, mode = mode, key = key, + tileset = def and def.tileset, id = def and def.id, + borderBlock = def and def.borderBlock } + return key +end + function World:drawGround(s) local G = love.graphics local cam = self.camera @@ -11297,7 +11536,7 @@ function World:drawGround(s) G.setColor(1, 1, 1, 1) else BorderFill.draw(self, self:borderImageFor(self.map.id), - cam.x, cam.y, bw, bh, s, BorderFill.fillKey(self.map.def)) + cam.x, cam.y, bw, bh, s, self:borderFillKey(self.map.def)) end end for _, nb in ipairs(self.neighbors) do @@ -11328,9 +11567,15 @@ end -- GoldSilverIntro order for one standing map object: optional jump shadow, -- bottom OAM (when IN_GRASS), keyed grass feet, top OAM, then BG_PRIO + shake. -- Exposed for World:drawPipeline mods so 3D passes reuse the same compositor. -function World:drawEntityComposite(entity, ox, oy, s, drawSpriteFn, withExtras) +-- +-- `offMap` is true for a ghost standing on a neighbouring map: its px/py are +-- that map's own coordinates, so sampling THIS map's attrmap under its feet +-- would paint the wrong tiles over them. Gold/Silver skips the grass pass +-- for those the same way (World:drawPeople's `onMap` gate). +function World:drawEntityComposite(entity, ox, oy, s, drawSpriteFn, withExtras, + offMap) if not self:isCrystal() then return end - local grassComposite = entity.inGrass + local grassComposite = entity.inGrass and not offMap and not (entity.grassShake and entity.moving) if grassComposite then -- Pret / GoldSilverIntro: composite on the framebuffer so keyed grass shade @@ -11349,77 +11594,134 @@ function World:drawEntityComposite(entity, ox, oy, s, drawSpriteFn, withExtras) end end +-- Draw order: Y, then the order drawPeople listed them in (player, NPCs, +-- ghosts), so two sprites on one row never swap as others enter the view. +local function drawOrder(a, b) + local ay, by = a.py, b.py + if ay ~= by then return ay < by end + return a.seq < b.seq +end + +-- A person more than this far outside the view cannot put a pixel on screen: +-- 32x32 big objects, the jump shadow and the grass rustle all stay inside it. +World.PEOPLE_CULL_MARGIN = 48 + +-- One standing map object, in GoldSilverIntro order. `entry` is a pooled +-- drawPeople slot; its sx/sy are the screen offset of the object's map. +function World:drawPersonEntry(entry, s) + local entity = entry.entity + local ox, oy = entry.sx, entry.sy + -- map_objects.asm:221-227 + self:drawJumpShadow(entity, ox, oy, s) + local onMap = entry.ox == 0 and entry.oy == 0 + if self:isCrystal() then + self:drawEntityComposite(entity, ox, oy, s, entry.drawSprite, onMap, + not onMap) + else + entity:draw(ox, oy, s) + if onMap then + if entity.inGrass and not (entity.grassShake and entity.moving) then + self:drawGrassOver(entity, ox, oy, s) + end + self:drawGrassShake(entity, ox, oy, s) + end + end +end + +-- A pooled drawPeople slot, with its two callbacks bound once for its life +-- rather than minted per person per frame. +local function newPersonEntry(world) + local entry = {} + entry.drawSprite = function(oamRow, localOx, localOy, localS) + entry.entity:draw(localOx or entry.sx, localOy or entry.sy, + localS or entry.s, oamRow) + end + entry.body = function() + world:drawPersonEntry(entry, entry.s) + end + return entry +end + function World:drawPeople(s, billboard) - local G = love.graphics local p = self.player local cam = self.camera local hideAll, hidePlayer = self:flyHides() -- ../pokecrystal/engine/overworld/map_objects.asm:2191-2205 local filter = self.spriteFilter - local drawList = {} + local pool = self._peoplePool + if not pool then + pool = {} + self._peoplePool = pool + end + local drawList = self._peopleList + if not drawList then + drawList = {} + self._peopleList = drawList + end + local n = 0 + -- The flat path culls to the view; TILT's billboard pass shows more of + -- the plane than the view rect, so it keeps everyone. + local cull = not billboard and cam and self.viewW and self.viewH + local left, top, right, bottom + if cull then + left = cam.x - World.PEOPLE_CULL_MARGIN + top = cam.y - World.PEOPLE_CULL_MARGIN + right = cam.x + self.viewW + World.PEOPLE_CULL_MARGIN + bottom = cam.y + self.viewH + World.PEOPLE_CULL_MARGIN + end + local function add(entity, ox, oy) + if cull then + local wx, wy = ox + (entity.px or 0), oy + (entity.py or 0) + if wx < left or wx > right or wy < top or wy > bottom then return end + end + n = n + 1 + local entry = pool[n] + if not entry then + entry = newPersonEntry(self) + pool[n] = entry + end + entry.entity, entry.ox, entry.oy = entity, ox, oy + entry.py = oy + entity.py + entry.seq = n + drawList[n] = entry + end if not hideAll then if not hidePlayer and not self.playerMasked and not self.playerHidden then - drawList[1] = { kind = "player", py = p.py, ox = 0, oy = 0 } + -- The player is never culled: the camera follows it. + local keep = cull + cull = false + add(p, 0, 0) + cull = keep end for _, npc in ipairs(self.npcs) do if (not filter or filter(npc)) and not npc.hiddenByMovement then - drawList[#drawList + 1] = { - kind = "npc", npc = npc, ox = 0, oy = 0, py = npc.py, - } + add(npc, 0, 0) end end for _, g in ipairs(self.ghosts) do if not filter or filter(g.npc) then - drawList[#drawList + 1] = { - kind = "npc", npc = g.npc, ox = g.ox, oy = g.oy, - py = g.oy + g.npc.py, - } + add(g.npc, g.ox, g.oy) end end end - table.sort(drawList, function(a, b) return a.py < b.py end) + for i = #drawList, n + 1, -1 do drawList[i] = nil end + table.sort(drawList, drawOrder) - for _, entry in ipairs(drawList) do + for i = 1, n do + local entry = drawList[i] + local entity = entry.entity local ox = math.floor((entry.ox - cam.x) * s) local oy = math.floor((entry.oy - cam.y) * s) - local entity = entry.kind == "player" and p or entry.npc - local function body() - -- map_objects.asm:221-227 - self:drawJumpShadow(entity, ox, oy, s) - local onMap = entry.ox == 0 and entry.oy == 0 - if self:isCrystal() then - local function drawSprite(oamRow, localOx, localOy, localS) - local lx = localOx or ox - local ly = localOy or oy - local ls = localS or s - if entry.kind == "player" then - self.player:draw(lx, ly, ls, oamRow) - else - entry.npc:draw(lx, ly, ls, oamRow) - end - end - self:drawEntityComposite(entity, ox, oy, s, drawSprite, onMap) - else - if entry.kind == "player" then - self.player:draw(ox, oy, s) - else - entry.npc:draw(ox, oy, s) - end - if onMap then - if entity.inGrass and not (entity.grassShake and entity.moving) then - self:drawGrassOver(entity, ox, oy, s) - end - self:drawGrassShake(entity, ox, oy, s) - end - end - end + entry.sx, entry.sy, entry.s = ox, oy, s if billboard then -- The foot is the baseline centre of the sprite's own cell. - billboard(ox + (entity.px + 8) * s, oy + (entity.py + 16) * s, body) + billboard(ox + (entity.px + 8) * s, oy + (entity.py + 16) * s, entry.body) else - body() + self:drawPersonEntry(entry, s) end end + -- Drop the references so a pooled slot does not pin a despawned NPC. + for i = 1, n do drawList[i].entity = nil end self:drawEmote(s, billboard) self:drawHealAnim(s, billboard) @@ -11447,7 +11749,7 @@ function World:drawEmote(s, billboard) local emoteColors = Palettes.spritePalette(self.palettes, self.daytime or Palettes.daytimeFor(self.map and self.map.def, self:hour(), self.flashUsed), - { paletteId = 5 }) + OW_PALS[5]) local function blit() G.setColor(1, 1, 1, 1) G.draw(e.image, ex, ey, 0, s, s) @@ -11500,8 +11802,10 @@ function World:drawPipeline(id, w, h, s) bird = function() self:drawFlyAnim(1, nil) end, }, -- Crystal-only: IN_GRASS OAM split + attrmap BG_PRIO (not on Gold/Silver). - drawEntity = self:isCrystal() and function(entity, ox, oy, scale, drawSpriteFn, withExtras) - self:drawEntityComposite(entity, ox, oy, scale or s, drawSpriteFn, withExtras) + drawEntity = self:isCrystal() and function(entity, ox, oy, scale, drawSpriteFn, + withExtras, offMap) + self:drawEntityComposite(entity, ox, oy, scale or s, drawSpriteFn, + withExtras, offMap) end or nil, } -- `project(wx, wy)` -> canvas pixels, nil behind the camera. s = 1 lays the @@ -11730,7 +12034,7 @@ function World:draw() if self.shake then self.camera.y = self.camera.y + (self.shake.phase or 0) end - local ScreenPosition = require("src.core.ScreenPosition") + local ScreenPosition = loaded("src.core.ScreenPosition") local posLift = 0 if not ScreenPosition.skinActive(w, h) then posLift = ScreenPosition.lift(h, 144 * self:fitScale(), @@ -11805,8 +12109,10 @@ function World:draw() if uiLayer then G.setCanvas(worldCanvas) end -- engine/events/poisonstep_pals.asm:9-42 + -- Drawn only: the four frames are spent on the logic clock + -- (World:tickFrameClocks from Game2's fixed step), so the flash lasts four + -- 60Hz frames whatever the display's refresh rate. if self.poisonFlash and self.poisonFlash > 0 then - self.poisonFlash = self.poisonFlash - 1 if GbcPalette.mode == "gbc" then G.setColor(28 / 31, 21 / 31, 1, 0.55) else diff --git a/tests/chip_synth_bulk_pcm_test.lua b/tests/chip_synth_bulk_pcm_test.lua new file mode 100644 index 00000000..7197ed70 --- /dev/null +++ b/tests/chip_synth_bulk_pcm_test.lua @@ -0,0 +1,273 @@ +-- ChipSynth bulk PCM writes (ffi int16 stores through Data:getFFIPointer) +-- must be bit-identical to the per-sample SoundData:setSample path they +-- replace, for streamed music buffers, SFX/cries (renderEffectData) and the +-- synchronous music fallback's sliced staging fill (ChipAudio fillSync). +-- luajit tests/chip_synth_bulk_pcm_test.lua +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("chip synth bulk pcm") +local check, eq = S.check, S.eq + +love = require("tests.love_stub") +local ffi = require("ffi") + +-- ffi-backed SoundData. setSample reproduces LOVE 11.5's 16-bit store, +-- (int16)(value * 32767) in double precision with no clamp -- verified +-- against a real LOVE 11.5 SoundData over 2M values including +-- out-of-range and NaN. getFFIPointer exposes the same storage. +local FfiSoundData = {} +FfiSoundData.__index = FfiSoundData +function FfiSoundData:setSample(index, a, b) + if b == nil then + self.buf[index * self.channels] = a * 32767 + else + self.buf[index * self.channels + (a - 1)] = b * 32767 + end +end +function FfiSoundData:getSample(index, channel) + return self.buf[index * self.channels + ((channel or 1) - 1)] / 32767 +end +function FfiSoundData:getFFIPointer() return self.buf end +function FfiSoundData:getSampleCount() return self.samples end +function FfiSoundData:getSampleRate() return self.rate end +function FfiSoundData:getBitDepth() return 16 end +function FfiSoundData:getChannelCount() return self.channels end + +love.sound = love.sound or {} +love.sound.newSoundData = function(samples, rate, bits, channels) + local sd = setmetatable({ + samples = samples, rate = rate, channels = channels, + buf = ffi.new("int16_t[?]", samples * channels), + }, FfiSoundData) + return sd +end + +-- the conversion itself, including values the synth never produces +do + local a = love.sound.newSoundData(8, 44100, 16, 1) + local values = { 0, 1, -1, 1.5, -1.5, 0.99999, 1.5 / 32767, 0 / 0 } + local expect = { 0, 32767, -32767, -16386, 16386, 32766, 1, 0 } + for i, v in ipairs(values) do a:setSample(i - 1, v) end + for i = 1, #expect do + eq(a.buf[i - 1], expect[i], "LOVE 11.5 int16 store of " .. tostring(values[i])) + end +end + +local ChipSynth = require("src.core.ChipSynth") +local ChipAsm = require("src.audio.ChipAsm") +ChipSynth.setChannelVolumes({ 1, 1, 1, 1 }) +ChipSynth.setChannelPitches({ 1, 1, 1, 1 }) + +-- the fake SoundData really takes the bulk path (and the switch disables it) +check(ChipSynth._int16Pointer(love.sound.newSoundData(1, 44100, 16, 2)), + "bulk path active on an ffi SoundData") +ChipSynth._setBulkWritesForTest(false) +check(not ChipSynth._int16Pointer(love.sound.newSoundData(1, 44100, 16, 2)), + "bulk switch off falls back to setSample") +ChipSynth._setBulkWritesForTest(true) + +local function same(a, b) + if a.samples ~= b.samples or a.channels ~= b.channels then + return false, "shape " .. a.samples .. "x" .. a.channels + .. " vs " .. b.samples .. "x" .. b.channels + end + local n = a.samples * a.channels + for i = 0, n - 1 do + if a.buf[i] ~= b.buf[i] then + return false, ("sample %d: %d vs %d"):format(i, a.buf[i], b.buf[i]) + end + end + return true +end + +local function nonzero(sd) + local count = 0 + for i = 0, sd.samples * sd.channels - 1 do + if sd.buf[i] ~= 0 then count = count + 1 end + end + return count +end + +local data = { audio = {} } +local song = ChipAsm.song{ + tempo = 0x100, + channels = { + { hw = 1, program = { + { duty = 2 }, + { notetype = { speed = 12, volume = 12, fade = 0 } }, + { octave = 4 }, + { label = "body" }, + { note = "C", len = 8 }, { note = "E", len = 8 }, { note = "G", len = 4 }, + { loop = { count = 0, to = "body" } }, + } }, + { hw = 2, program = { + { duty = 1 }, + { notetype = { speed = 12, volume = 10, fade = 2 } }, + { octave = 3 }, + { label = "body" }, + { note = "G", len = 4 }, { note = "A", len = 12 }, + { loop = { count = 0, to = "body" } }, + } }, + { hw = 4, program = { + { label = "body" }, + { drum = 3, len = 4 }, + { loop = { count = 0, to = "body" } }, + } }, + }, + drums = { [3] = { { len = 4, volume = 13, fade = 2, parameter = 0x42 } } }, +} + +local function render(bulk, fn) + ChipSynth._setBulkWritesForTest(bulk) + local ok, result = pcall(fn) + ChipSynth._setBulkWritesForTest(true) + assert(ok, result) + return result +end + +-- streamed music buffers (worker + sync fallback), stereo and mono +for _, channels in ipairs({ 2, 1 }) do + local function music() + local engine = ChipSynth.newEngine(data, song, { allowLoops = true }) + local first = ChipSynth.soundData(engine, 8192, channels) + local second = ChipSynth.soundData(engine, 8192, channels) + return { first, second } + end + local slow, fast = render(false, music), render(true, music) + for i = 1, 2 do + local ok, why = same(slow[i], fast[i]) + check(ok, ("music buffer %d (%dch) bit-identical %s") + :format(i, channels, why or "")) + end + check(nonzero(fast[1]) > 1000, "music buffer carries audio") +end + +-- sliced staging fill == one whole-buffer render +do + local whole = render(false, function() + local engine = ChipSynth.newEngine(data, song, { allowLoops = true }) + ChipSynth.soundData(engine, 1000, 2) + return ChipSynth.soundData(engine, 8192, 2) + end) + local sliced = render(true, function() + local engine = ChipSynth.newEngine(data, song, { allowLoops = true }) + ChipSynth.soundData(engine, 1000, 2) + local sd = ChipSynth.newBuffer(8192, 2) + local fill = 0 + for _, n in ipairs({ 1024, 1024, 777, 1024, 3000, 1343 }) do + ChipSynth.renderInto(engine, sd, fill, n, 2) + fill = fill + n + end + assert(fill == 8192) + return sd + end) + local ok, why = same(whole, sliced) + check(ok, "sliced staging fill bit-identical " .. (why or "")) +end + +-- SFX / cries +local sfx = ChipAsm.sfx{ channels = { + { hw = 1, program = { + { squareNote = { len = 8, volume = 15, fade = 1, frequency = 0x600 } }, + { squareNote = { len = 16, volume = 12, fade = 3, frequency = 0x700 } }, + } }, + { hw = 4, program = { + { noiseNote = { len = 16, volume = 14, fade = 2, parameter = 0x33 } }, + } }, +} } + +for _, opts in ipairs({ + function() return {} end, + function() return { frequencyOffset = 0x40, cryLength = 0x60 } end, + function() return { frequencyOffset = -0x20, frameTicks = 0xC0 } end, +}) do + local slow = render(false, function() + return ChipSynth.renderEffectData(data, sfx, opts()) + end) + local fast = render(true, function() + return ChipSynth.renderEffectData(data, sfx, opts()) + end) + check(slow and fast, "effect renders both ways") + if slow and fast then + local ok, why = same(slow, fast) + check(ok, "effect bit-identical " .. (why or "")) + check(nonzero(fast) > 100, "effect carries audio") + end +end + +-- a second, longer effect after a short one: the reused scratch grows +do + local notes = {} + for i = 1, 24 do + notes[i] = { squareNote = { len = 16, volume = 15, fade = 0, + frequency = 0x600 + i * 8 } } + end + local long = ChipAsm.sfx{ channels = { { hw = 1, program = notes } } } + local slow = render(false, function() + return ChipSynth.renderEffectData(data, long, {}) + end) + local fast = render(true, function() + return ChipSynth.renderEffectData(data, long, {}) + end) + check(slow and fast and slow.samples > 44100, + "long effect renders past the initial scratch") + if slow and fast then + local ok, why = same(slow, fast) + check(ok, "long effect bit-identical " .. (why or "")) + end +end + +-- synchronous music fallback: the sliced per-tick staging fill queues the +-- same 8192-sample buffers, in order, as whole-buffer renders did +do + love.thread = nil + local queued = {} + local source = { free = 32, playing = false } + function source:getFreeBufferCount() return self.free end + function source:queue(sd) self.free = self.free - 1; queued[#queued + 1] = sd end + function source:play() self.playing = true end + function source:stop() self.playing = false end + function source:isPlaying() return self.playing end + function source:setVolume() end + love.audio = love.audio or {} + love.audio.newQueueableSource = function() return source end + local ChipAudio = require("src.core.ChipAudio") + ChipAudio.playMusic(data, song, true) + eq(#queued, 4, "song start queues the initial buffers") + local maxTick = 0 + local consumed = 0 + for _ = 1, 400 do + consumed = consumed + ChipSynth.SAMPLE_RATE / 60 + while consumed >= 8192 and source.free < 32 do + consumed = consumed - 8192 + source.free = source.free + 1 + end + local before = #queued + ChipAudio.update() + maxTick = math.max(maxTick, #queued - before) + end + check(#queued > 40, "the queue keeps filling (" .. #queued .. " buffers)") + check(maxTick <= 1, "at most one buffer completes per tick") + local engine = ChipSynth.newEngine(data, song, { allowLoops = true }) + local allSame = true + for i = 1, #queued do + local ref = ChipSynth.soundData(engine, 8192, 2) + local ok = same(ref, queued[i]) + if not ok then allSame = false; break end + end + check(allSame, "sliced fallback stream bit-identical to whole buffers") + -- a starved queue (below the low-water mark) renders a whole buffer's + -- worth in one tick + source.free = 31 + local before = #queued + ChipAudio.update() + check(#queued - before >= 1, "low queue catches up within one tick") + ChipAudio.stopMusic() + -- no worker: prewarm declines rather than rendering on this thread + local fx = { audio = { sfx = { Beep = sfx }, cries = {} } } + check(ChipAudio.prewarmSfx(fx, "Beep") == false, + "prewarm is a no-op without a worker") + eq(ChipAudio._effectStateForTest().pending, 0, "nothing left pending") +end + +S.finish() diff --git a/tests/drivers/em_textbox.lua b/tests/drivers/em_textbox.lua index 0c043aac..624b6eee 100644 --- a/tests/drivers/em_textbox.lua +++ b/tests/drivers/em_textbox.lua @@ -92,6 +92,8 @@ local function compareBox(ours, ref, mask, diffPath) end return function(game) + -- nativeFrame() reads the flat 240x160 mirror of the plane path + require("src.core.game3.display").mirrorForTests = true os.execute('mkdir -p "' .. DIR .. '" 2>/dev/null') for _ = 1, 900 do if game.phase == "boot" and game.boot then break end diff --git a/tests/drivers/game3_ghost_trainer_sight_2460.lua b/tests/drivers/game3_ghost_trainer_sight_2460.lua index 0a02bf90..8cb14f17 100644 --- a/tests/drivers/game3_ghost_trainer_sight_2460.lua +++ b/tests/drivers/game3_ghost_trainer_sight_2460.lua @@ -19,6 +19,9 @@ local function finish() end return function(game) + -- the Fuchsia check waits on a Route 17 biker ~150 cells away wandering + -- into line; tick every ghost pool, not just those near the view + require("src.core.game3.ghosts").TICK_MARGIN_SCREENS = math.huge for _ = 1, 600 do if game.phase == "boot" and game.boot then break end U.wait(1) diff --git a/tests/engine/gen2_world_overdraw_perf_test.lua b/tests/engine/gen2_world_overdraw_perf_test.lua new file mode 100644 index 00000000..c9f40144 --- /dev/null +++ b/tests/engine/gen2_world_overdraw_perf_test.lua @@ -0,0 +1,233 @@ +-- Gen 2 overworld draw-path fixes and the caches that replaced per-frame work: +-- * Crystal ghost NPCs (neighbour-map objects) skip the grass composite, +-- as Gold/Silver already did -- their px/py are the neighbour's. +-- * bgOverQuads is one quad cache per atlas, not one shared by every atlas. +-- * the poison flash and the void-fill dissolve run on the logic clock. +-- * MapAttrGrid's numeric grid, the anim-cell index and the people cull. +-- luajit tests/engine/gen2_world_overdraw_perf_test.lua +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end + +local T = require("tests.harness") +local check, eq = T.check, T.eq + +local World = require("src.world.gen2.World") +local BorderFill = require("src.world.gen2.BorderFill") +local MapAttrGrid = require("src.world.gen2.MapAttrGrid") + +local function crystalWorld(fields) + local world = setmetatable(fields, { __index = World }) + function world:isCrystal() return true end + return world +end + +-- ------------------------------------------------ ghost grass (Crystal) +do + local grass = 0 + local world = crystalWorld({}) + function world:drawGrassOver() grass = grass + 1 end + function world:drawBgPriorityOver() end + function world:drawGrassShake() end + local rows = {} + local function sprite(row) rows[#rows + 1] = row or "full" end + local entity = { px = 0, py = 0, inGrass = true } + + world:drawEntityComposite(entity, 0, 0, 1, sprite, true, false) + eq(grass, 1, "an on-map object in grass gets the grass composite") + eq(rows[1], "bottom", "bottom OAM first") + + grass, rows = 0, {} + world:drawEntityComposite(entity, 0, 0, 1, sprite, false, true) + eq(grass, 0, "BUG FIX: a neighbour-map ghost never samples this map's grass") + eq(#rows, 1, "the ghost draws once, whole") + eq(rows[1], "full", "as a single full sprite") + + -- The old six-argument call (drawPipeline mods) keeps its behaviour. + grass = 0 + world:drawEntityComposite(entity, 0, 0, 1, sprite, false) + eq(grass, 1, "no offMap argument still composites") +end + +-- drawPeople passes offMap for ghosts. +do + local composites = {} + local world = crystalWorld({ + player = { px = 0, py = 0, draw = function() end }, + npcs = {}, camera = { x = 0, y = 0 }, viewW = 160, viewH = 144, + }) + local ghostNpc = { px = 16, py = 16, inGrass = true, draw = function() end } + world.ghosts = { { npc = ghostNpc, ox = 320, oy = 0 } } + world.camera.x = 300 + function world:flyHides() return false, false end + function world:drawJumpShadow() end + function world:drawEntityComposite(entity, _, _, _, _, withExtras, offMap) + composites[#composites + 1] = { entity = entity, extras = withExtras, + offMap = offMap } + end + function world:drawEmote() end + function world:drawHealAnim() end + function world:drawFlyAnim() end + world:drawPeople(1) + eq(#composites, 2, "player and ghost both drawn") + local ghost + for _, c in ipairs(composites) do + if c.entity == ghostNpc then ghost = c end + end + check(ghost ~= nil, "the ghost reached the compositor") + eq(ghost and ghost.offMap, true, "drawPeople flags the ghost as off-map") + eq(ghost and ghost.extras, false, "and without the on-map extras") +end + +-- ------------------------------------------------ people cull + reuse +do + local drawn = {} + local function person(name, px, py) + return { name = name, px = px, py = py, + draw = function(self) drawn[#drawn + 1] = self.name end } + end + local world = setmetatable({ + player = person("player", 5000, 5000), + npcs = { person("near", 64, 32), person("far", 2000, 32), + person("row", 80, 32) }, + ghosts = {}, camera = { x = 0, y = 0 }, viewW = 160, viewH = 144, + }, { __index = World }) + function world:isCrystal() return false end + function world:flyHides() return false, false end + function world:drawJumpShadow() end + function world:drawGrassShake() end + function world:drawEmote() end + function world:drawHealAnim() end + function world:drawFlyAnim() end + world:drawPeople(1) + eq(#drawn, 3, "the far NPC is culled, the player never is") + eq(drawn[1], "near", "equal rows draw in list order") + eq(drawn[2], "row", "equal rows draw in list order (second)") + eq(drawn[3], "player", "Y order holds") + local list = world._peopleList + drawn = {} + world:drawPeople(1) + eq(#drawn, 3, "a second frame draws the same people") + check(world._peopleList == list, "and reuses the draw list") + + -- The billboard (TILT) path shows more than the view: no cull there. + drawn = {} + world:drawPeople(1, function(_, _, body) body() end) + eq(#drawn, 4, "the tilt billboard pass keeps everyone") +end + +-- ------------------------------------------------ bgOverQuads per atlas +do + local world = crystalWorld({}) + local a = love.graphics.newImage("a.png") + local b = love.graphics.newImage("b.png") + local qa = world:bgOverQuadsFor(a) + local qb = world:bgOverQuadsFor(b) + check(qa ~= qb, "BUG FIX: two atlases get two quad caches") + check(world:bgOverQuadsFor(a) == qa, "one atlas keeps its cache") +end + +-- ------------------------------------------------ poison flash clock +do + local world = setmetatable({}, { __index = World }) + world:poisonBGFlash() + eq(world.poisonFlash, 4, "the flash arms four frames") + for i = 3, 0, -1 do + world:tickPoisonFlash() + eq(world.poisonFlash, i, "one logic tick spends one frame") + end + world:tickPoisonFlash() + eq(world.poisonFlash, 0, "and it stops at zero") + + -- Game2's fixed step drives both clocks through tickFrameClocks. + world:poisonBGFlash() + world:tickFrameClocks() + eq(world.poisonFlash, 3, "tickFrameClocks spends a poison frame") + eq(world.borderTicks, 1, "and banks one dissolve tick") + for _ = 1, 100 do world:tickFrameClocks() end + eq(world.borderTicks, BorderFill.CROSSFADE_FRAMES, + "banked ticks cap at one whole dissolve") +end + +-- ------------------------------------------------ void dissolve clock +do + local owner = { borderTicks = 0 } + BorderFill.crossfade(owner, "water", "A") + local from, alpha = BorderFill.crossfade(owner, "trees", "B", 1) + eq(from, "water", "a new fill dissolves from the old one") + local a1 = alpha + from, alpha = BorderFill.crossfade(owner, "trees", "B", 0) + eq(alpha, a1, "BUG FIX: a render frame with no logic tick does not advance") + from, alpha = BorderFill.crossfade(owner, "trees", "B", 2) + check(alpha > a1, "two banked ticks advance two frames") + + -- Ticks banked before a swap do not skip the new dissolve. + local o2 = {} + BorderFill.crossfade(o2, "water", "A") + local f2, al2 = BorderFill.crossfade(o2, "trees", "B", 15) + eq(f2, "water", "a swap after a long gap still dissolves") + check(al2 <= 1 / BorderFill.CROSSFADE_FRAMES + 1e-9, + "from its first frame") +end + +-- ------------------------------------------------ MapAttrGrid numeric +do + local block = {} + for i = 1, 16 do block[i] = i - 1 end + block[16] = 0x85 + local tileset = { blocks = { block, block } } + local map = { width = 2, height = 1, blocks = { 1, 0 }, borderBlock = 1 } + local grid = MapAttrGrid.build(map, tileset) + for my = 0, 31, 8 do + for mx = 0, 63, 8 do + local want = MapAttrGrid.cellAt(map, tileset, mx, my) + local got = MapAttrGrid.lookup(grid, mx + 3, my + 5) + eq(got and got.tileId, want.tileId, "lookup tile id at " .. mx .. "," .. my) + eq(got and got.rawTileId, want.rawTileId, "raw id at " .. mx .. "," .. my) + eq(got and got.attr.vramBank, want.attr.vramBank, + "bank at " .. mx .. "," .. my) + end + end + eq(MapAttrGrid.lookup(grid, -8, 0), nil, "left of the map is nothing") + eq(MapAttrGrid.lookup(grid, 64, 0), nil, "right of the map does not wrap") + eq(MapAttrGrid.lookup(grid, 0, 32), nil, "below the map is nothing") + eq(MapAttrGrid.lookup(nil, 0, 0), nil, "no grid is nothing") +end + +-- ------------------------------------------------ anim cell index +do + local world = setmetatable({}, { __index = World }) + local water = { layer = {}, tile = 1, cells = { 0, 0, 8, 0, 32, 64 } } + local flower = { layer = {}, tile = 2, cells = { 16, 8 } } + local cells = { [1] = water, [2] = flower } + eq(world:animListAt(cells, 8, 0), water, "indexed water cell") + eq(world:animListAt(cells, 32, 64), water, "indexed far water cell") + eq(world:animListAt(cells, 16, 8), flower, "indexed flower cell") + eq(world:animListAt(cells, 8, 8), nil, "a plain cell is not animated") + eq(world:animListAt(nil, 0, 0), nil, "no anim table") + eq(world:animListAt(false, 0, 0), nil, "a map with no anims") +end + +-- ------------------------------------------------ memoized keys +do + local GbcPalette = require("src.render.GbcPalette") + local fake = { daytime = "DAY", flickerPhase = 1 } + local k1 = World.mapCacheKey(fake, "M") + eq(k1, "M|DAY|" .. tostring(GbcPalette.mode) .. "|nil|1", "key format") + check(World.mapCacheKey(fake, "M") == k1, "memoized") + fake.daytime = "NITE" + check(World.mapCacheKey(fake, "M") ~= k1, "a daytime change rebuilds it") + fake.daytime = "DARK" + local dark1 = World.mapCacheKey(fake, "M") + fake.flickerPhase = 2 + check(World.mapCacheKey(fake, "M") ~= dark1, "a flicker change rebuilds it") + + BorderFill.setVoidFill("fade") + local def = { id = "M", tileset = "TILESET_JOHTO", borderBlock = 5 } + local w = setmetatable({}, { __index = World }) + eq(w:borderFillKey(def), BorderFill.fillKey(def), "fill key matches") + BorderFill.setVoidFill("water") + eq(w:borderFillKey(def), BorderFill.fillKey(def), "fill key follows mode") + BorderFill.setVoidFill("fade") +end + +T.finish("gen2 world overdraw perf") diff --git a/tests/engine/heal_machine_palette_bug1986.lua b/tests/engine/heal_machine_palette_bug1986.lua index a65cfc60..842b301a 100644 --- a/tests/engine/heal_machine_palette_bug1986.lua +++ b/tests/engine/heal_machine_palette_bug1986.lua @@ -51,7 +51,11 @@ for i = 1, 4 do "shade " .. (i - 1) .. " is not a DMG gray") end +-- the heal overlay body is a module-level function drawWorld calls +-- (fxHeal), not an inline closure; look through it as well local healShader = upvalue(OW.drawWorld, "healMachineShader") + or upvalue(upvalue(OW.drawWorld, "fxHeal") or function() end, + "healMachineShader") check(type(healShader) == "function", "the heal overlay picks its palette through healMachineShader") diff --git a/tests/engine/refresh_rate_counters_test.lua b/tests/engine/refresh_rate_counters_test.lua new file mode 100644 index 00000000..ad7b211a --- /dev/null +++ b/tests/engine/refresh_rate_counters_test.lua @@ -0,0 +1,158 @@ +-- Presentation counters that draw code samples must advance at the fixed +-- 60Hz logic rate, not once per rendered frame: +-- * Player:pose's surf bob (a 144Hz display bobbed 2.4x too fast, and the +-- battle-transition wipe, which draws the player twice a frame, doubled +-- it) -- now counted in Game:step logic steps (Game.logicStep); +-- * the battle text box's ScrollTextUpOneLine offset (scrollPx), which +-- BattleState:drawTextArea / WideBattle counted down per draw -- now +-- BattleState:tickTextScroll, run once per update step. +-- Also pins the per-frame caching in the battle OAM colorizer: one +-- sgbBattlePals() per frame, shared color triples, and AnimPlayer's +-- uniform sends always matching the colors of the cell being painted. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") + +-- ---------------------------------------------------------------- surf bob +do + local prevGame = package.loaded["src.core.Game"] + local fakeGame = { logicStep = 10 } + package.loaded["src.core.Game"] = fakeGame + local Player = require("src.world.Player") + local p = setmetatable({ surfing = true, px = 0, py = 0, facing = "down", + progress = 0, animClock = 0 }, Player) + -- the wipe's double draw (and any 120/144Hz frame without a new step) + p:pose(); p:pose(); p:pose() + T.eq(p.bobTimer, 1, "three draws inside one logic step advance the bob once") + fakeGame.logicStep = 11 + p:pose(); p:pose() + T.eq(p.bobTimer, 2, "the next step advances it exactly once more") + fakeGame.logicStep = 14 + p:pose() + T.eq(p.bobTimer, 5, "steps run between draws (fast-forward) all count") + -- the 32-step cycle: low for bob 0..15, one pixel down for 16..31 + local lows, highs = 0, 0 + for _ = 1, 32 do + fakeGame.logicStep = fakeGame.logicStep + 1 + local _, _, y = p:pose() + local y2 = select(3, p:pose()) -- a second draw in the same step + T.eq(y2, y, "a repeat draw in the same step shows the same bob") + if y == 0 then lows = lows + 1 else highs = highs + 1 end + end + T.eq(lows, 16, "16 steps of the cycle sit on the baseline") + T.eq(highs, 16, "16 steps of the cycle sit a pixel down") + -- no running Game (headless callers): the old per-call tick + fakeGame.logicStep = nil + local before = p.bobTimer + p:pose(); p:pose() + T.eq(p.bobTimer, (before + 2) % 32, "without a step clock each pose ticks") + package.loaded["src.core.Game"] = prevGame +end + +-- --------------------------------------------------------- battle scroll +local BattleState = require("src.battle.BattleState") +do + local b = setmetatable({ phase = "messages", current = {}, scrollPx = 8 }, + { __index = BattleState }) + local seen = {} + for i = 1, 5 do + b:tickTextScroll() + seen[i] = tostring(b.scrollPx) + end + T.eq(table.concat(seen, ","), "6,4,2,nil,nil", + "2px per logic step, cleared at 0 (the draws saw 6,4,2,0 before)") + b.scrollPx = 8 + b.phase = "menu" + b:tickTextScroll() + T.eq(b.scrollPx, 8, "no count-down while the message box is not shown") + b.phase, b.current, b.animPlaying, b.msgHold = "messages", nil, nil, true + b:tickTextScroll() + T.eq(b.scrollPx, 6, "msgHold keeps the box (and its scroll) live") +end + +-- ------------------------------------------- battle OAM colorizer caching +do + local zoneA = { { 255, 255, 255 }, { 200, 0, 0 }, { 100, 0, 0 }, { 0, 0, 0 } } + local zoneB = { { 255, 255, 255 }, { 0, 200, 0 }, { 0, 100, 0 }, { 0, 0, 0 } } + local palsCalls = 0 + local b = setmetatable({}, { __index = BattleState }) + b.sgbBattlePals = function() + palsCalls = palsCalls + 1 + return { [0] = zoneA, [1] = zoneB, [2] = zoneA, [3] = zoneB } + end + local PaletteFX = require("src.render.PaletteFX") + local realObp = PaletteFX.usesSpriteObp + PaletteFX.usesSpriteObp = function() return false end + local memo = {} + local c1 = b:animSpriteColors({ obp = "e4", x = 20, y = 20 }, 0, 0, memo) + local c2 = b:animSpriteColors({ obp = "e4", x = 20, y = 28 }, 0, 8, memo) + local c3 = b:animSpriteColors({ obp = "e4", x = 20, y = 20 }, 8, 8, memo) + T.eq(palsCalls, 1, "one sgbBattlePals per frame memo") + T.check(c1 == c2, "same zone + obp shares one triple table") + T.check(c3 ~= c1, "the enemy-HUD zone resolves its own triple") + T.eq(math.floor(c1[1][1] * 255 + 0.5), 200, "e4 color 1 is the zone's shade 1") + T.eq(math.floor(c3[1][2] * 255 + 0.5), 200, "enemy HUD wears zone 1's shade 1") + local fresh = b:animSpriteColors({ obp = "e4", x = 20, y = 20 }, 0, 0) + T.eq(palsCalls, 2, "without a memo it resolves the palettes itself") + T.same(fresh, c1, "memoized and unmemoized colors agree") + PaletteFX.usesSpriteObp = realObp + + -- AnimPlayer.drawSprites: every blit must see the uniforms of the cell + -- it paints, whatever sends were skipped as redundant + local AnimPlayer = require("src.battle.AnimPlayer") + local g = love.graphics + local saved = {} + for _, k in ipairs({ "draw", "setShader", "getScissor", "intersectScissor", + "setScissor" }) do saved[k] = g[k] end + local shader = { u = {} } + function shader:send(name, v) self.u[name] = v end + local realShaderFn = PaletteFX.shader + PaletteFX.shader = function() return shader end + local scissor + local blits = {} + g.draw = function() + blits[#blits + 1] = { c1 = shader.u.c1, c2 = shader.u.c2, + c3 = shader.u.c3, clip = scissor } + end + g.setShader = function() end + g.getScissor = function() + if scissor then return scissor[1], scissor[2], scissor[3], scissor[4] end + end + g.intersectScissor = function(x, y, w, h) scissor = { x, y, w, h } end + g.setScissor = function(x, y, w, h) scissor = x and { x, y, w, h } or nil end + local tri = {} + local function colorAt(px, py) + -- a vertical zone boundary at x = 16 + local key = px < 16 and "L" or "R" + tri[key] = tri[key] or { { px < 16 and 1 or 0, 0, 0 }, { 0, 0, 0 }, + { 0, 0, 0 } } + return tri[key] + end + local ap = setmetatable({ sheetImage = function() return {} end, + tileQuad = function() return {} end }, + { __index = AnimPlayer }) + local sprites = { + { x = 8 + 4, y = 16, tile = 1 }, -- rx 4: fully in L + { x = 8 + 12, y = 16, tile = 1 }, -- rx 12: straddles L|R + { x = 8 + 20, y = 16 + 3, tile = 1 }, -- rx 20, ry 3: R only (2 rows) + { x = 8 + 5, y = 16, tile = 1 }, -- back to L + } + ap:drawSprites(sprites, function(_, px, py) return colorAt(px, py) end) + -- expected paint order: L, L+R(slice), R, R(slice, same colors -> no + -- repaint), L + local want = { "L", "L", "R", "R", "L" } + T.eq(#blits, #want, "one blit per tile plus one per differing cell") + for i, w in ipairs(want) do + local bl = blits[i] + if bl then + T.eq(bl.c1 and bl.c1[1], w == "L" and 1 or 0, + ("blit %d paints with its cell's colors"):format(i)) + end + end + T.check(blits[3] and blits[3].clip ~= nil, + "the straddling tile's second cell is scissored") + for k, v in pairs(saved) do g[k] = v end + PaletteFX.shader = realShaderFn +end + +T.finish("refresh-rate-independent counters") diff --git a/tests/engine/render_hotpath_caches_test.lua b/tests/engine/render_hotpath_caches_test.lua new file mode 100644 index 00000000..4f044818 --- /dev/null +++ b/tests/engine/render_hotpath_caches_test.lua @@ -0,0 +1,319 @@ +-- Hot-path caches and the bugs fixed alongside them: Font's encode/width +-- memo, PaletteFX/GbcPalette last-sent uniform detection, SpriteRenderer's +-- obp bake lookup, Hooks' unwrap of the last link, Sandbox's per-name +-- verdict, Input's recycled step tables and stale-pad rebuild, and +-- TextBox's scroll slide running on the logic step instead of draw. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq +love = love or require("tests.love_stub") + +package.loaded["src.core.Logger"] = { + info = function() end, warn = function() end, error = function() end, + debug = function() end, +} + +-- ------------------------------------------------------------- Font +do + local Font = require("src.render.Font") + local a = Font.encode("ABC") + a[1], a[4] = -1, -2 + local b = Font.encode("ABC") + eq(#b, 3, "encode hands out a copy: a caller's append does not stick") + check(b[1] ~= -1, "encode hands out a copy: a caller's write does not stick") + check(a ~= b, "each encode is a fresh table") + eq(Font.glyphCount("ABC"), #Font.split("ABC"), "glyphCount matches split") + local w = Font.width("ABC") + eq(Font.width("ABC"), w, "cached width is stable") + local rev = Font.revision + Font.setFrame(Font.frameIndex()) + eq(Font.revision, rev, "re-selecting the same frame keeps the caches") + Font.setFrame(Font.frameIndex() + 1) + check(Font.revision > rev, "a frame change drops cached widths") + Font.setFrame(1) +end + +-- ------------------------------------------------------------- PaletteFX +local function recordingShader() + local sh = { sends = {} } + function sh.send(self, name, value) + self.sends[#self.sends + 1] = { name = name, value = { value[1], value[2], value[3] } } + end + return sh +end + +do + local PaletteFX = require("src.render.PaletteFX") + PaletteFX.pickerActive = function() return false end + local pal = { { 255, 0, 0 }, { 0, 255, 0 }, { 0, 0, 255 }, { 0, 0, 0 } } + local sh = recordingShader() + + PaletteFX.mode = "gbc" + PaletteFX.sendColors(sh, pal) + eq(#sh.sends, 4, "first send sets all four colors") + PaletteFX.sendColors(sh, pal) + eq(#sh.sends, 4, "identical colors to the same shader are not re-sent") + local changed = { pal[1], pal[2], { 10, 20, 30 }, pal[4] } + PaletteFX.sendColors(sh, changed) + eq(#sh.sends, 5, "only the changed uniform is re-sent") + eq(sh.sends[5].name, "c2", "and it is the one that changed") + + local other = recordingShader() + PaletteFX.sendColors(other, changed) + eq(#other.sends, 4, "the record is per shader") + + PaletteFX.forgetSent(sh) + PaletteFX.sendColors(sh, changed) + eq(#sh.sends, 9, "forgetSent makes the next send go out in full") + + PaletteFX.sendShades(sh, pal) + PaletteFX.sendColors(sh, changed) + eq(sh.sends[#sh.sends].name, "c2", "sendShades shares the record with sendColors") + + -- the index arithmetic must land on exactly what effectiveColors resolves + local function lastFour(s) + local out = {} + for i = #s.sends, 1, -1 do + local e = s.sends[i] + local k = tonumber(e.name:sub(2)) + 1 + if not out[k] then out[k] = e.value end + end + return out + end + for _, mode in ipairs({ "gbc", "gbc_inv", "og", "og_inv", "classic" }) do + for _, map in ipairs({ false, PaletteFX.DARK_BGP, PaletteFX.POISON_BGP }) do + PaletteFX.mode = mode + PaletteFX.setShadeMap(map or nil) + local s = recordingShader() + PaletteFX.sendColors(s, pal) + local want = PaletteFX.effectiveColors(pal) + local got = lastFour(s) + local same = true + for i = 1, 4 do + for c = 1, 3 do + if math.abs(got[i][c] - want[i][c] / 255) > 1e-12 then same = false end + end + end + check(same, ("sendColors matches effectiveColors (%s, map %s)") + :format(mode, tostring(map and true))) + end + end + PaletteFX.mode = "gbc" + PaletteFX.setShadeMap(nil) +end + +-- ------------------------------------------------------------- GbcPalette +do + local GbcPalette = require("src.render.GbcPalette") + local sh = recordingShader() + love.graphics.newShader = function() return sh end + love.graphics.setShader = function() end + local pal = { { 255, 255, 255 }, { 170, 170, 170 }, { 85, 85, 85 }, { 0, 0, 0 } } + check(GbcPalette.useRaw(pal), "useRaw binds") + eq(#sh.sends, 4, "useRaw sends pal0..pal3 once") + GbcPalette.useRaw(pal) + eq(#sh.sends, 4, "an unchanged palette is not re-sent") + GbcPalette.useRaw({ pal[1], { 1, 2, 3 }, pal[3], pal[4] }) + eq(#sh.sends, 5, "one changed entry is one send") + eq(sh.sends[5].name, "pal1", "to the changed uniform") +end + +-- ------------------------------------------------------------- SpriteRenderer +do + local Assets = require("src.render.Assets") + local loads = 0 + Assets.image = function() loads = loads + 1; return { tag = loads } end + local SpriteRenderer = require("src.render.SpriteRenderer") + SpriteRenderer.invalidate() + local savedImage = love.image + love.image = nil -- headless bake path + local colors = { {}, {}, {}, {} } + local one = SpriteRenderer.obpImage("sheet.png", colors, 1) + eq(SpriteRenderer.obpImage("sheet.png", colors, 1), one, "a cache hit returns the bake") + eq(SpriteRenderer.obpImage("sheet.png", colors, "1"), one, + "number and string groups share a key, as the concatenated key did") + check(SpriteRenderer.obpImage("sheet.png", colors, "dark") ~= nil, "a new group bakes") + SpriteRenderer.invalidate() + check(SpriteRenderer.obpImage("sheet.png", colors, 1) ~= one, "invalidate drops the lookup too") + love.image = savedImage +end + +-- ------------------------------------------------------------- Hooks +do + local Hooks = require("src.mods.Hooks") + local h = Hooks.new() + local unwrap = h:wrap("x", function(next, v) return next(v + 1) end) + eq(h:call("x", function(v) return v * 2 end, 1), 4, "a single link runs") + unwrap() + eq(h.chains.x, nil, "unwrapping the last link drops the chain (wantsHook false)") + eq(h:call("x", function(v) return v * 2 end, 1), 2, "and the call is plain vanilla") + local u1 = h:wrap("y", function(next, ...) return next(...) end) + local u2 = h:wrap("y", function(next, ...) return next(...) end) + u1() + check(h.chains.y ~= nil, "a chain with links left stays") + u2() + eq(h.chains.y, nil, "and goes once empty") + local stale = h:wrap("z", function(next, ...) return next(...) end) + h:removeOwner(nil) + h.chains.z = nil + local fresh = h:wrap("z", function(next, ...) return next(...) end) + stale() + check(h.chains.z ~= nil, "a stale unwrap never drops a newer chain") + fresh() + local ok, err = pcall(function() + return h:call("none", function() error("boom", 0) end) + end) + check(not ok and err == "boom", "vanilla errors propagate without a chain") + h:wrap("w", function(next, ...) return next(...) end) + ok, err = pcall(function() + return h:call("w", function() error("deep", 0) end) + end) + check(not ok and err == "deep", "vanilla errors propagate through a chain unwrapped") + local n, a, b, c = select("#", h:call("w", function() return 1, nil, 3 end)), + h:call("w", function() return 1, nil, 3 end) + eq(n, 3, "trailing results keep their count") + check(a == 1 and b == nil and c == 3, "and their values") +end + +-- ------------------------------------------------------------- Sandbox +do + local Sandbox = require("src.mods.Sandbox") + for _ = 1, 2 do + check(Sandbox.moduleDenial("io") ~= nil, "io denied") + check(Sandbox.moduleDenial("love.filesystem") ~= nil, "love.* denied") + eq(Sandbox.moduleDenial("love"), nil, "bare love allowed") + check(Sandbox.moduleDenial("socket.http") ~= nil, "network needs the permission") + eq(Sandbox.moduleDenial("socket.http", { network = true }), nil, "and passes with it") + eq(Sandbox.moduleDenial("src.core.Game"), nil, "engine modules allowed") + end +end + +-- ------------------------------------------------------------- Input +do + local Input = require("src.core.Input") + Input:init() + Input:keypressed("up") + Input:step() + local pressed, queue = Input.pressed, Input.pressQueue + check(Input:wasPressed("up"), "press edges") + Input:keyreleased("up") + Input:step() + eq(Input.pressed, pressed, "step recycles its own pressed table") + eq(Input.pressQueue, queue, "and its own queue") + check(not Input:wasPressed("up"), "cleared in place") + local mine = { a = true } + Input.pressed = mine + Input:step() + check(Input.pressed ~= mine and mine.a == true, + "a table a test installed is replaced, never emptied") + + -- a pad swapped for another between two polls, same count + local function pad(name, connected) + return { name = name, connected = connected, + isGamepadDown = function() return false end, + isConnected = function(self) return self.connected end, + isGamepad = function() return true end, + getName = function(self) return self.name end, + getGUID = function() return "" end, + getID = function() return 1 end, + getButtonCount = function() return 12 end, + getAxisCount = function() return 4 end } + end + local p1, p2 = pad("one", true), pad("two", true) + local current = { p1 } + love.joystick = { + getJoystickCount = function() return #current end, + getJoysticks = function() return current end, + } + local GamepadMap = require("src.core.GamepadMap") + GamepadMap.ignoreRawForJoystick = function() return true end + GamepadMap.isAccelerometer = function() return false end + Input:pollPads() + eq(Input._pollPads[1], p1, "first poll caches the pad") + p1.connected = false + current = { p2 } + Input:pollPads() + eq(Input._pollPads[1], p2, "a disconnected cached pad forces a rebuild") + current = { p1 } + p1.connected = true + Input:joysticksChanged() + Input:pollPads() + eq(Input._pollPads[1], p1, "joysticksChanged drops the cache") + love.joystick = nil +end + +-- ------------------------------------------------------------- TextBox +do + local TextBox = require("src.render.TextBox") + local input = { + wasPressed = function() return false end, + isDown = function() return false end, + } + local game = { data = { text = {} }, input = input, + save = { generation = 2, options = { textSpeed = 1 } } } + local box = TextBox.new(game, "a\nb\nc") + local steps = 0 + while not box.scrollPx and steps < 200 do + box:update(1 / 60) + steps = steps + 1 + end + eq(box.scrollPx, 6, "the frame that scrolls ends 2px into the slide") + local drew = pcall(box.draw, box) + if drew then + pcall(box.draw, box) + eq(box.scrollPx, 6, "draw no longer advances the slide") + end + box:update(1 / 60) + eq(box.scrollPx, 4, "each logic step moves it 2px") + box:update(1 / 60) + box:update(1 / 60) + eq(box.scrollPx, nil, "and it ends after four steps") +end + +-- ------------------------------------------------------------- ShaderFX +do + love.filesystem.write = function() return true end + love.graphics.getRendererInfo = function() return "OpenGL", "4.1 stub", "stub", "stub" end + love.graphics.getSupported = function() return { glsl3 = true } end + love.graphics.flushBatch = function() end + love.graphics.validateShader = function() return true end + love.timer = love.timer or {} + love.timer.getDelta = love.timer.getDelta or function() return 1 / 60 end + local sends = {} + love.graphics.newShader = function(frag, vert) + local sh = { frag = frag, vert = vert } + function sh.send(self, name, value) sends[#sends + 1] = name end + return sh + end + local quads = 0 + love.graphics.newQuad = function(x, y, w, h, sw, sh) + quads = quads + 1 + local q = { x = x, y = y, w = w, h = h } + function q.setViewport(self, nx, ny, nw, nh) self.x, self.y, self.w, self.h = nx, ny, nw, nh end + return q + end + local ShaderFX = require("src.render.ShaderFX") + local ok = ShaderFX.activate("main", { name = "lcd3x.slangp", + fullPath = "tests/data/shaderfx/gl/lcd3x.slangp", converted = true }) + eq(ok, true, "lcd3x activates under the stub") + local canvas = setmetatable({ w = 800, h = 720 }, { __index = { + getWidth = function(self) return self.w end, + getHeight = function(self) return self.h end, + getPixelWidth = function(self) return self.w end, + getPixelHeight = function(self) return self.h end, + } }) + local rect = { x = 0, y = 0, w = 800, h = 720, scale = 5 } + ShaderFX.render(canvas, rect, { w = 160, h = 144 }, 1, 1) + local first = #sends + check(first > 0, "the first frame sends the pass uniforms") + ShaderFX.render(canvas, rect, { w = 160, h = 144 }, 1, 1) + eq(#sends, first, "an identical frame re-sends nothing") + eq(quads, 1, "the crop quad is re-aimed, not rebuilt") + ShaderFX.render(canvas, { x = 0, y = 0, w = 640, h = 576, scale = 4 }, + { w = 160, h = 144 }, 1, 1) + check(#sends > first, "a size change re-sends what changed") + ShaderFX.deactivate("main") +end + +T.finish("render_hotpath_caches") diff --git a/tests/game3_native_batch_reuse_test.lua b/tests/game3_native_batch_reuse_test.lua new file mode 100644 index 00000000..41808df4 --- /dev/null +++ b/tests/game3_native_batch_reuse_test.lua @@ -0,0 +1,205 @@ +-- FieldView native tile SpriteBatches: a full reset reuses (clears) the +-- existing batches instead of allocating new ones, an atlas texture swap +-- mid-scroll rebuilds instead of indexing a replaced batch, and a single +-- metatile write re-samples one cell without a full rebuild. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local failures = 0 +local function check(ok, label) + print((ok and "PASS " or "FAIL ") .. label) + if not ok then failures = failures + 1 end +end + +local created, released = 0, 0 +local function newBatch(texture) + created = created + 1 + local b = { texture = texture, sprites = {}, released = false } + local function live(self) + if self.released then error("use of released SpriteBatch", 2) end + end + function b:add(q, x, y) + live(self) + self.sprites[#self.sprites + 1] = { q = q, x = x, y = y } + return #self.sprites + end + function b:set(i, q, x, y) + live(self) + if i < 1 or i > #self.sprites then error("Invalid sprite index: " .. tostring(i), 2) end + self.sprites[i] = { q = q, x = x, y = y } + end + function b:clear() live(self); self.sprites = {} end + function b:getCount() return #self.sprites end + function b:getTexture() return self.texture end + function b:release() released = released + 1; self.released = true end + return b +end + +local noop = function() end +love = love or {} +love.graphics = love.graphics or {} +love.graphics.newSpriteBatch = function(texture) return newBatch(texture) end +love.graphics.setColor = noop +love.graphics.rectangle = noop +love.graphics.draw = noop + +local Versions = require("src.import.gba.versions") +Versions.NATIVE_RENDER = true + +-- Fake native atlases: quad = { pair, slot } so tests can read back mids. +local Native = { _pairs = {} } +local function atlas(pair) + local ts = { pair = pair, image = { name = pair .. "#" .. tostring(math.random()) }, + layered = false, quads = {} } + Native._pairs[pair] = ts + return ts +end +function Native.ready() return true end +function Native.get(pair) return Native._pairs[pair] end +function Native.slotFor(_, mid) return mid end +function Native.hasMid() return true end +function Native.quad(ts, slot) + local q = ts.quads[slot] + if not q then q = { pair = ts.pair, slot = slot }; ts.quads[slot] = q end + return q +end +function Native.overQuad() return nil end +package.loaded["src.core.game3.tileset_native"] = Native +package.loaded["src.core.game3.tileset_anim"] = { setVisiblePairs = noop } + +-- Primary map "A" (pair a) covers x >= 0; everything west of it is pair b. +local overrides = {} +local layout = { width = 64, height = 64, pair = "a" } +function layout:midAt(x, y) return overrides[y * 1024 + x] or ((x + y) % 7) end +local mapDef = { midLayout = layout, pair = "a" } +local Map = { world = {}, neighborList = {} } +function Map.worldMidAt(wx, wy, def) + if wx < 0 then return (wx * 3 + wy) % 5, "b" end + return def.midLayout:midAt(wx, wy), "a" +end +package.loaded["src.core.game3.map"] = Map + +local FieldView = require("src.core.game3.field_view") +local drawNativeTiles +for i = 1, 200 do + local name, fn = debug.getupvalue(FieldView.draw, i) + if not name then break end + if name == "drawNativeTiles" then drawNativeTiles = fn break end +end +check(type(drawNativeTiles) == "function", "found drawNativeTiles") +if not drawNativeTiles then os.exit(1) end + +local W, H = 240, 160 +local function cellSprite(wx, wy) + local info = FieldView._nativeVisibleCells[wy] and FieldView._nativeVisibleCells[wy][wx] + local batch = info and info.under and FieldView._nativeBatches[info.under.key] + return batch and batch.sprites[info.under.index], info +end + +local function allCellsValid() + for wy, row in pairs(FieldView._nativeVisibleCells) do + for wx, info in pairs(row) do + if info.under then + local batch = FieldView._nativeBatches[info.under.key] + if not batch or batch.released or info.under.index > #batch.sprites then return false end + local s = batch.sprites[info.under.index] + local mid = Map.worldMidAt(wx, wy, mapDef) + if s.q.slot ~= mid or s.x ~= (wx - FieldView._nativeBaseBx) * 16 then return false end + end + end + end + return true +end + +atlas("a") +atlas("b") +check(drawNativeTiles(mapDef, 0, 0, W, H) == true, "first native draw") +local first = FieldView._nativeBatches +local batchA, batchB = first.a, first.b +local createdAfterFirst = created +check(batchA and batchB, "one batch per visible pair") +check(allCellsValid(), "first build samples every visible cell") + +-- 1. full reset keeps the same SpriteBatch objects +FieldView._nativeDirty = true +drawNativeTiles(mapDef, 0, 0, W, H) +check(created == createdAfterFirst, "dirty reset allocates no new SpriteBatch") +check(FieldView._nativeBatches == first and first.a == batchA and first.b == batchB, + "dirty reset reuses the batch store and batches") +check(allCellsValid(), "reset rebuild samples every visible cell") +local cols, rows = math.ceil(W / 16) + 3, math.ceil(H / 16) + 3 +check(batchA:getCount() + batchB:getCount() == cols * rows, "reset clears before refilling") + +-- 2. scrolling reuses freed slots; the slot counter stays bounded +for step = 1, 40 do drawNativeTiles(mapDef, step * 4, 0, W, H) end +for step = 40, 1, -1 do drawNativeTiles(mapDef, step * 4, step, W, H) end +check(allCellsValid(), "scrolling keeps every cell's sprite valid") +check(FieldView._nativeSpriteSlots <= cols * rows * 4, "slot counter bounded by the reset heuristic") + +-- 3. a pair scrolled fully out of view is pruned (released) on the next reset +local goneB = FieldView._nativeBatches.b +FieldView._nativeDirty = true +drawNativeTiles(mapDef, 400, 0, W, H) +check(goneB and FieldView._nativeBatches.b == nil and goneB.released, + "reset releases a batch no visible cell uses") + +-- 4. neighbour atlas reloaded mid-scroll: rebuild, never index a new batch +drawNativeTiles(mapDef, -8, 0, W, H) +local oldB = FieldView._nativeBatches.b +check(oldB ~= nil, "pair b batch back in view") +local freshB = atlas("b") +local ok, err = pcall(drawNativeTiles, mapDef, -40, 0, W, H) +check(ok, "texture swap mid-scroll does not crash (" .. tostring(err) .. ")") +check(FieldView._nativeBatches.b ~= oldB and FieldView._nativeBatches.b:getTexture() == freshB.image, + "swapped pair gets a batch on the new texture") +check(oldB.released, "old batch released after the swap") +ok, err = pcall(function() + for step = 1, 30 do drawNativeTiles(mapDef, -40 + step * 8, step, W, H) end +end) +check(ok, "scrolling after the swap releases cells safely (" .. tostring(err) .. ")") +check(allCellsValid(), "cells valid after swap + scroll") + +-- 5. primary atlas reloaded with no scroll: rebuild onto the new texture +local freshA = atlas("a") +drawNativeTiles(mapDef, 200, 40, W, H) +check(FieldView._nativeBatches.a:getTexture() == freshA.image, "primary atlas swap rebuilds") +check(allCellsValid(), "cells valid after primary swap") + +-- 6. one metatile write re-samples just that cell +local clearsBefore = created +local base = FieldView._nativeBaseBx +local cx, cy = 20, 6 +overrides[cy * 1024 + cx] = 99 +FieldView.invalidateLayoutCell(layout, cx, cy) +check(FieldView._nativeDirty == false, "cell write does not force a full rebuild") +local countBefore = FieldView._nativeBatches.a:getCount() +drawNativeTiles(mapDef, 200, 40, W, H) +local s = cellSprite(cx, cy) +check(s and s.q.slot == 99, "written cell shows the new metatile") +check(FieldView._nativeBaseBx == base and FieldView._nativeBatches.a:getCount() == countBefore + and created == clearsBefore, "cell write reused a freed slot without a rebuild") +check(allCellsValid(), "all cells valid after cell write") + +-- a layout that is not the drawn map's falls back to a full rebuild +FieldView.invalidateLayoutCell({}, 1, 1) +check(FieldView._nativeDirty == true, "foreign layout write forces a full rebuild") +drawNativeTiles(mapDef, 200, 40, W, H) + +-- LayoutNative wiring: applyOverride invalidates a single cell +local LayoutNative = require("src.core.game3.layout_native") +local real = LayoutNative.fromDecoded({ width = 64, height = 64, cells = {} }, "T", "a") +local realDef = { midLayout = real, pair = "a" } +drawNativeTiles(realDef, 0, 0, W, H) +real:applyOverride(3, 4, 42, 0, 0) +check(FieldView._nativeDirty == false and FieldView._nativeDirtyCells + and FieldView._nativeDirtyCells[1] == 3 and FieldView._nativeDirtyCells[2] == 4, + "applyOverride queues one dirty cell") +Map.worldMidAt = function(wx, wy, def) return def.midLayout:midAt(wx, wy), "a" end +drawNativeTiles(realDef, 0, 0, W, H) +local info = FieldView._nativeVisibleCells[4][3] +check(info and FieldView._nativeBatches.a.sprites[info.under.index].q.slot == 42, + "applyOverride cell redrawn with the new metatile") +real:clearOverrides() +check(FieldView._nativeDirty == true, "clearOverrides still rebuilds everything") + +print(failures == 0 and "PASS game3_native_batch_reuse_test" or ("FAIL game3_native_batch_reuse_test failures=" .. failures)) +os.exit(failures == 0 and 0 or 1) diff --git a/tests/game3_overworld_perf_caches_test.lua b/tests/game3_overworld_perf_caches_test.lua new file mode 100644 index 00000000..e1d37d26 --- /dev/null +++ b/tests/game3_overworld_perf_caches_test.lua @@ -0,0 +1,180 @@ +-- Per-frame caches in the game3 overworld: OAM sort reuse, ghost pool sync / +-- camera-range ticking, one-cell collision patch, trainer id negative cache +-- and seamless tileset pair warming. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local failures = 0 +local function check(ok, label) + print((ok and "PASS " or "FAIL ") .. label) + if not ok then failures = failures + 1 end +end + +-- OAM --------------------------------------------------------------------- +do + local Oam = require("src.core.game3.oam") + Oam.reset() + local img = {} + local function spawn(y, pri) + local id, s = Oam.createSprite({ oam = { shape = 0, size = 1, priority = pri or 0 } }, 10, y, 0) + s.image = img + return id, s + end + local _, a = spawn(40) + local _, b = spawn(20) + local _, c = spawn(30, 1) + Oam.resetFrame() + check(#Oam._buffer == 0, "resetFrame empties the frame buffer") + local buf = Oam.buildOamBuffer() + check(#buf == 3 and buf[1] == b and buf[2] == a and buf[3] == c, "sorted by priority then y") + local sorted = Oam._sorted + Oam.resetFrame() + check(Oam._buffer == buf and #buf == 0, "resetFrame clears in place") + local buf2 = Oam.buildOamBuffer() + check(buf2 == buf and Oam._sorted == sorted and buf2[1] == b and buf2[3] == c, + "unchanged scene reuses the sorted cache") + a.y = 0 + Oam.resetFrame() + buf2 = Oam.buildOamBuffer() + check(buf2[1] == a and buf2[2] == b and buf2[3] == c, "moved sprite re-sorts") + a.invisible = true + Oam.resetFrame() + buf2 = Oam.buildOamBuffer() + check(#buf2 == 2 and buf2[1] == b, "hidden sprite leaves the buffer") + a.invisible = false + c.image = nil + local _, d = spawn(20) + Oam.resetFrame() + buf2 = Oam.buildOamBuffer() + local seen = {} + for _, s in ipairs(buf2) do seen[s] = true end + check(#buf2 == 3 and seen[d] and seen[a] and seen[b] and not seen[c], + "swapped sprite with equal count rebuilds the set") + Oam.reset() + check(Oam._sorted == nil, "reset drops the sort cache") +end + +-- Ghosts ------------------------------------------------------------------ +do + local spawned, ticked = 0, {} + local Objects = { + spawnFromDefs = function(defs, def, id) + spawned = spawned + 1 + return { order = {}, byId = {}, id = id } + end, + tickPool = function(pool) ticked[pool.id] = (ticked[pool.id] or 0) + 1 end, + playerBlocks = function() return false end, + } + local Map = { world = {} } + local Player = { cellX = 5, cellY = 5 } + package.loaded["src.core.game3.objects"] = Objects + package.loaded["src.core.game3.map"] = Map + package.loaded["src.core.game3.player"] = Player + package.loaded["src.core.game3.field_view"] = { _viewW = 240, _viewH = 160 } + local Ghosts = require("src.core.game3.ghosts") + local function entry(id, ox, oy) + return { id = id, ox = ox, oy = oy, + def = { objects = { {} }, midLayout = { width = 20, height = 20 } } } + end + Map.world = { entry("NEAR", 20, 0), entry("FAR", 200, 0), entry("NORTH", 0, -20) } + Ghosts.sync() + check(spawned == 3, "sync spawns a pool per world map") + for _ = 1, 5 do Ghosts.sync() end + check(spawned == 3, "sync with an unchanged world does not respawn") + Ghosts._pools.NORTH = nil + Ghosts.sync() + check(spawned == 4 and Ghosts._pools.NORTH, "externally dropped pool is respawned") + Ghosts._pools.EXTRA = { order = {}, byId = {} } + Ghosts.sync() + check(Ghosts._pools.EXTRA == nil, "pool outside the world is dropped") + Map.world = { Map.world[1] } + Ghosts.sync() + check(Ghosts._pools.FAR == nil and Ghosts._pools.NEAR, "new world table resyncs") + Map.world = { entry("NEAR", 20, 0), entry("FAR", 200, 0) } + Ghosts.clear() + Ghosts.sync() + check(Ghosts._pools.NEAR and Ghosts._pools.FAR, "clear then sync respawns") + Ghosts.update({}) + check(ticked.NEAR == 1 and ticked.FAR == nil, "only pools near the camera tick") + Player.cellX = 190 + Ghosts.update({}) + check(ticked.FAR == 1, "a pool ticks again once the camera is back in range") + Ghosts.clear() + package.loaded["src.core.game3.objects"] = nil + package.loaded["src.core.game3.map"] = nil + package.loaded["src.core.game3.player"] = nil + package.loaded["src.core.game3.field_view"] = nil +end + +-- Collision.patchCell ----------------------------------------------------- +do + local Collision = require("src.core.game3.collision") + local LayoutNative = require("src.core.game3.layout_native") + local cells = {} + for i = 1, 16 do cells[i] = { mid = 1, coll = 0, elev = 0 } end + local layout = LayoutNative.fromDecoded({ width = 4, height = 4, cells = cells }, "T", "p") + local def = { midLayout = layout, warps = { { x = 2, y = 2 } } } + Collision.bindMap(nil, "T", def) + local grid = Collision._grid + layout:applyOverride(1, 1, 5, 0x07, 0) + check(Collision.patchCell("T", def, 1, 1) and Collision._grid == grid and grid[1 * 4 + 1 + 1] == 0x07, + "patchCell updates one grid entry in place") + local full = layout:collArray() + local same = true + for i = 1, 16 do if full[i] ~= grid[i] then same = false end end + check(same, "patched grid matches a full rebuild") + check(not Collision.patchCell("T", def, 2, 2), "warp cell falls back to bindMap") + check(not Collision.patchCell("OTHER", def, 1, 1), "other bound map falls back") + check(not Collision.patchCell("T", def, 9, 1), "off-grid cell falls back") +end + +-- TrainerSight negative cache ---------------------------------------------- +do + local Space = require("src.core.game3.scripting.space") + local prevBundle = Space.bundle + local list = { { op = "msgbox" } } + Space.bundle = { scripts = { S = list } } + local TrainerSight = require("src.core.game3.trainer_sight") + local eo = { scriptKey = "S" } + check(TrainerSight.getTrainerId(eo) == nil and eo._trainerIdMiss == list, "first scan misses") + -- same list table: the cached miss stands without rescanning + list[2] = { op = "trainerbattle", trainer = 3 } + check(TrainerSight.getTrainerId(eo) == nil, "repeat lookups hit the negative cache") + Space.bundle.scripts.S = { { op = "trainerbattle", trainer = 7 } } + check(TrainerSight.getTrainerId(eo) == 7, "replaced script is rescanned") + Space.bundle = prevBundle +end + +-- Map warm queue ---------------------------------------------------------- +do + local loads = {} + local Native = { _pairs = { loaded = {} } } + function Native.ready() return true end + function Native.get(pair) loads[#loads + 1] = pair; Native._pairs[pair] = {}; return {} end + package.loaded["src.core.game3.tileset_native"] = Native + local Map = require("src.core.game3.map") + local function def(pair) return { pair = pair, midLayout = { pair = pair } } end + Map.computeWorld = function() + return { { id = "A", def = def("loaded") }, { id = "B", def = def("p1") }, + { id = "C", def = def("p2") }, { id = "D", def = def("p1") } } + end + Map.ensureMidLayout = function() end + Map._warmPairs = nil + Map._worldRoot = nil + Map.refreshWorld({ data = { maps = {} } }, 10, 10, "ROOT") + check(#loads == 0 and Map._warmQueue and #Map._warmQueue == 2, "seamless refresh queues unloaded pairs") + Map.stepWarm() + check(#loads == 1 and loads[1] == "p1", "stepWarm loads one pair per call") + Map.stepWarm() + check(#loads == 2 and loads[2] == "p2" and Map._warmQueue == nil, "queue drains") + Map._warmPairs = true + Map._worldRoot = nil + Native._pairs = {} + loads = {} + Map.refreshWorld({ data = { maps = {} } }, 10, 10, "ROOT") + check(#loads == 4 and Map._warmQueue == nil, "non-seamless load still warms synchronously") + package.loaded["src.core.game3.tileset_native"] = nil +end + +print(failures == 0 and "PASS game3_overworld_perf_caches_test" + or ("FAIL game3_overworld_perf_caches_test failures=" .. failures)) +os.exit(failures == 0 and 0 or 1) diff --git a/tests/game3_render_caches_test.lua b/tests/game3_render_caches_test.lua new file mode 100644 index 00000000..394143f7 --- /dev/null +++ b/tests/game3_render_caches_test.lua @@ -0,0 +1,296 @@ +-- tests/game3_render_caches_test.lua +-- Per-frame render caches in the Gen 3 battle system and UI: each one must +-- stay correct when what it caches changes. +-- * anim_vm: the anim bg and sprites share one blend shader; a sprite drawn +-- after the bg must re-send its own tint (the uniform cache used to miss +-- the bg's send, so the sprite drew with the bg's tint). +-- * anim_vm: band draws inside a frame bracket reuse one sorted list. +-- * pokemon.lua: a missing pic file is probed once, not every frame. +-- * trainer_card RSE: the front pic and badge quads are built once. +-- * rom_text: plain() memoises pure IRs and follows overrides. +-- * bag: listPocket rows are reused until the pocket changes. + +package.path = package.path .. ";./?.lua" + +local passed, failed = 0, 0 +local function test(name, fn) + local ok, err = pcall(fn) + if ok then + passed = passed + 1 + print(" PASS: " .. name) + else + failed = failed + 1 + print(" FAIL: " .. name .. " -> " .. tostring(err)) + end +end +local function eq(a, b, msg) + if a ~= b then + error(string.format("%s: expected %s, got %s", msg or "eq", tostring(b), tostring(a)), 2) + end +end + +-- Minimal love.graphics that records shader uniforms and draws. +local counts = { newImageData = 0, newImage = 0, newQuad = 0, draws = 0, fsRead = 0 } +local Image = {} +Image.__index = Image +function Image:getDimensions() return self.w, self.h end +function Image:setFilter() end +function Image:setWrap() end +local Quad = {} +Quad.__index = Quad +function Quad:setViewport(x, y, w, h) self.x, self.y, self.w, self.h = x, y, w, h end +function Quad:getViewport() return self.x, self.y, self.w, self.h end +local shaders = {} +local drawLog = {} +local currentShader = nil +love = { + graphics = { + newImage = function(data) + counts.newImage = counts.newImage + 1 + return setmetatable({ w = data and data.w or 64, h = data and data.h or 64 }, Image) + end, + newQuad = function(x, y, w, h) + counts.newQuad = counts.newQuad + 1 + return setmetatable({ x = x, y = y, w = w, h = h }, Quad) + end, + newShader = function() + local sh = { uniforms = {}, sends = 0 } + function sh:send(name, v) + self.sends = self.sends + 1 + if type(v) == "table" then v = { v[1], v[2], v[3] } end + self.uniforms[name] = v + end + shaders[#shaders + 1] = sh + return sh + end, + setShader = function(sh) currentShader = sh end, + getShader = function() return currentShader end, + draw = function(img) + counts.draws = counts.draws + 1 + local sh = currentShader + drawLog[#drawLog + 1] = { img = img, coeff = sh and sh.uniforms.coeff, + target = sh and sh.uniforms.target and table.concat(sh.uniforms.target, ",") } + end, + setColor = function() end, + getColor = function() return 1, 1, 1, 1 end, + setBlendMode = function() end, + rectangle = function() end, + push = function() end, + pop = function() end, + translate = function() end, + scale = function() end, + }, + image = { + newImageData = function(w, h) + counts.newImageData = counts.newImageData + 1 + return { w = w, h = h } + end, + }, + filesystem = { + read = function(path) + counts.fsRead = counts.fsRead + 1 + if path:find("trainers/front/", 1, true) then return string.rep("\0", 64 * 64 * 4) end + return nil + end, + }, + timer = { getTime = function() return 0 end }, +} + +-- anim_vm ----------------------------------------------------------------- +local AnimVm = require("src.core.game3.battle.anim_vm") +local AnimSprites = require("src.core.game3.battle.anim_sprites") +local AnimTasks = require("src.core.game3.battle.anim_tasks") +local AnimPal = require("src.core.game3.battle.anim_pal") + +local function reset_anim() + AnimSprites.reset() + AnimTasks.reset() + for i = #drawLog, 1, -1 do drawLog[i] = nil end +end + +test("sprite drawn after a tinted anim bg re-sends its own blend", function() + reset_anim() + local vm = AnimVm.new() + local bgImg = setmetatable({ w = 256, h = 256, name = "bg" }, Image) + local spriteImg = setmetatable({ w = 32, h = 32, name = "sprite" }, Image) + local realBgImage, realBgColors = AnimVm.animBgImage, AnimPal.bgColors + AnimVm.animBgImage = function() return bgImg end + AnimPal.bgColors = function() return nil end + local ok, err = pcall(function() + vm._animBgId = 1 + vm._animBgBlend = { coeff = 12, color = 0x001F } -- red, from the bg + local s = AnimSprites._pool[1] + s.active, s.visible, s.image = true, true, spriteImg + s.x, s.y, s.z = 100, 50, 150 + s.palBlend = { coeff = 6, color = 0x7C00 } -- blue, the sprite's own + for _ = 1, 3 do vm:draw(0, 999) end + local sprites = 0 + for _, d in ipairs(drawLog) do + if d.img == spriteImg then + sprites = sprites + 1 + eq(d.coeff, 6, "sprite coeff") + eq(d.target, "0,0,31", "sprite target") + elseif d.img == bgImg then + eq(d.coeff, 12, "bg coeff") + eq(d.target, "31,0,0", "bg target") + end + end + eq(sprites, 3, "sprite draws") + end) + AnimVm.animBgImage, AnimPal.bgColors = realBgImage, realBgColors + vm._animBgId = nil + if not ok then error(err, 0) end +end) + +test("band draws in a frame bracket slice one sorted list", function() + reset_anim() + local vm = AnimVm.new() + local imgs = {} + local zs = { 150, 50, 250, 120, 50 } + for i, z in ipairs(zs) do + local s = AnimSprites._pool[i] + imgs[i] = setmetatable({ w = 8, h = 8, id = i }, Image) + s.active, s.visible, s.image, s.x, s.y, s.z = true, true, imgs[i], 0, 0, z + end + local function order(bracket) + for i = #drawLog, 1, -1 do drawLog[i] = nil end + if bracket then vm:beginDrawFrame() end + vm:draw(0, 99) + vm:draw(101, 199) + vm:draw(201, 999) + if bracket then vm:endDrawFrame() end + local out = {} + for _, d in ipairs(drawLog) do out[#out + 1] = d.img.id end + return table.concat(out, ",") + end + local plain = order(false) + -- z ascending, ties by higher pool index first (battle_anim.c:630 order) + eq(plain, "5,2,4,1,3", "unbracketed order") + eq(order(true), plain, "bracketed order") + -- a change between frames is picked up by the next bracket + AnimSprites._pool[3].z = 10 + eq(order(true), "3,5,2,4,1", "next frame sees the new z") + -- z 100 / 200 stay outside every band, as before + AnimSprites._pool[1].z = 100 + eq(order(true), "3,5,2,4", "z=100 is still not drawn by the bands") +end) + +-- pokemon pics -------------------------------------------------------------- +local Pokemon = require("src.core.game3.pokemon") + +test("missing pic is probed once and re-probed after invalidate", function() + local reads = 0 + local savedCache = Pokemon._cache + Pokemon._cache = { read = function() reads = reads + 1 return nil end } + Pokemon._back = {} + Pokemon._front = {} + eq(Pokemon.backPic(25), nil, "missing back pic") + eq(Pokemon.backPic(25), nil, "missing back pic again") + eq(Pokemon.frontPic(25), nil, "missing front pic") + eq(Pokemon.frontPic(25), nil, "missing front pic again") + eq(reads, 2, "one read per missing pic") + Pokemon._front = {} + Pokemon._back = nil + eq(Pokemon.backPic(25), nil, "after reset") + eq(reads, 3, "a cache reset probes again") + Pokemon._cache = savedCache + Pokemon._front = {} + Pokemon._back = nil +end) + +test("present pic is still loaded and cached", function() + local reads = 0 + local savedCache = Pokemon._cache + Pokemon._cache = { read = function() reads = reads + 1 return string.rep("\0", 64 * 64 * 4) end } + Pokemon._front = {} + local a = Pokemon.frontPic(4) + local b = Pokemon.frontPic(4) + if not a then error("expected a pic entry", 0) end + eq(a, b, "same entry") + eq(reads, 1, "one read") + Pokemon._cache = savedCache + Pokemon._front = {} +end) + +-- trainer card (RSE) ---------------------------------------------------------- +test("RSE trainer card builds its pic and badge quads once", function() + local realKit = package.loaded["src.ui.game3.rse.scene_kit"] + local badgeImg = setmetatable({ w = 128, h = 16 }, Image) + package.loaded["src.ui.game3.rse.scene_kit"] = { + manifest = function() + return { layers = {}, pics = { male = 71, female = 72 }, picOffset = { 0, 0 }, + star = { png = "star" }, badges = { png = "badges" } } + end, + image = function(name) return name == "badges" and badgeImg or nil end, + } + local TrainerCard = require("src.ui.game3.trainer_card") + local realFront = TrainerCard.frontTextsRse + TrainerCard.frontTextsRse = function() return {} end + local ok, err = pcall(function() + TrainerCard.open, TrainerCard._rse, TrainerCard.side, TrainerCard._flip = true, true, "front", nil + TrainerCard._card = { female = false, stars = 0, + badges = { true, false, true, true, false, false, false, true } } + local before = { img = counts.newImageData, quad = counts.newQuad } + for _ = 1, 5 do TrainerCard.draw() end + eq(counts.newImageData - before.img, 1, "pic decoded once") + eq(counts.newQuad - before.quad, 8, "badge quads built once") + TrainerCard._card.female = true + TrainerCard.draw() + TrainerCard.draw() + eq(counts.newImageData - before.img, 2, "the other pic decoded once") + end) + TrainerCard.frontTextsRse = realFront + TrainerCard.open, TrainerCard._rse, TrainerCard._card = false, nil, nil + package.loaded["src.ui.game3.rse.scene_kit"] = realKit + if not ok then error(err, 0) end +end) + +-- rom_text ------------------------------------------------------------------ +local RomText = require("src.core.game3.rom_text") +local TextIR = require("src.core.game3.scripting.text_ir") + +test("plain() follows overrides and ignores cached text for impure IRs", function() + RomText.overrides.TEST_CACHE_KEY = TextIR.fromAscii("HELLO") + eq(RomText.plain("TEST_CACHE_KEY"), "HELLO", "first") + eq(RomText.plain("TEST_CACHE_KEY"), "HELLO", "memoised") + RomText.overrides.TEST_CACHE_KEY = TextIR.fromAscii("WORLD") + eq(RomText.plain("TEST_CACHE_KEY"), "WORLD", "override replaced") + local ir = { { t = "text", s = "A " }, { t = "strvar", n = 1 }, { t = "eos" } } + RomText.overrides.TEST_CACHE_KEY = ir + eq(RomText.plain("TEST_CACHE_KEY", { stringVars = { "X" } }), "A X", "with ctx") + eq(RomText.plain("TEST_CACHE_KEY", { stringVars = { "Y" } }), "A Y", "ctx still honoured") + RomText.overrides.TEST_CACHE_KEY = nil +end) + +-- bag rows -------------------------------------------------------------------- +local okData = pcall(function() + require("tests.game3_cache").root("items/pack.lua") +end) +local ItemsData = require("src.core.game3.items_data") +local Bag = require("src.core.game3.bag") +local haveItems = okData and pcall(ItemsData.ensureLoaded) +if haveItems then + test("listPocket reuses rows until the pocket changes", function() + local bag = Bag.new and Bag.new() or {} + Bag.add(bag, 13, 3) -- POTION + local a = Bag.listPocket(bag, "ITEMS") + local b = Bag.listPocket(bag, "ITEMS") + eq(a, b, "same rows while unchanged") + eq(#a, 1, "one row") + eq(a[1].qty, 3, "qty") + Bag.add(bag, 13, 2) + local c = Bag.listPocket(bag, "ITEMS") + if c == a then error("rows not rebuilt after add", 0) end + eq(c[1].qty, 5, "qty after add") + eq(a[1].qty, 3, "old rows untouched") + bag.pockets.ITEMS[1].qty = 7 -- direct slot edit (save load, scripts) + eq(Bag.listPocket(bag, "ITEMS")[1].qty, 7, "direct edit seen") + Bag.remove(bag, 13, 7) + eq(#Bag.listPocket(bag, "ITEMS"), 0, "empty after remove") + end) +else + print(" SKIP: listPocket (items pack not in the cache)") +end + +print(string.format("game3_render_caches_test: %d passed, %d failed", passed, failed)) +if failed > 0 then os.exit(1) end diff --git a/tests/game3_se_source_cache_test.lua b/tests/game3_se_source_cache_test.lua new file mode 100644 index 00000000..c3b27195 --- /dev/null +++ b/tests/game3_se_source_cache_test.lua @@ -0,0 +1,132 @@ +#!/usr/bin/env luajit +-- Gen 3 Audio.playSe caches the static Source per (id, pan, gain, mono, +-- loop): a repeat SE neither re-bakes nor rebuilds SoundData / Source, the +-- player-slot bookkeeping (_seSources/_seByPlayer/_seMeta) behaves as with a +-- fresh Source, and anything baked into the PCM (pan, volume) still misses. +-- luajit tests/game3_se_source_cache_test.lua +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("game3 se source cache") +local check, eq = S.check, S.eq + +love = require("tests.love_stub") + +local built, sources = 0, 0 +local Source = {} +Source.__index = Source +function Source:play() self.playing = true; self.plays = self.plays + 1 end +function Source:stop() self.playing = false; self.stops = self.stops + 1 end +function Source:isPlaying() return self.playing end +function Source:setVolume(v) self.volume = v end +function Source:getVolume() return self.volume or 1 end +function Source:setLooping(v) self.looping = v end +function Source:clone() + sources = sources + 1 + return setmetatable({ sd = self.sd, plays = 0, stops = 0, clone = true }, + Source) +end +love.audio = love.audio or {} +love.audio.newSource = function(sd) + sources = sources + 1 + return setmetatable({ sd = sd, plays = 0, stops = 0 }, Source) +end +local realNewSoundData = love.sound.newSoundData +love.sound.newSoundData = function(...) + built = built + 1 + return realNewSoundData(...) +end + +local Player = require("src.core.game3.m4a_player") +local bakes = 0 +Player.songInfo = function(_, id) + return { kind = "se", player = (id == 7) and 2 or 1 } +end +Player.start = function() return true end +Player.bakeSlot = function() + bakes = bakes + 1 + local L, R = {}, {} + for i = 1, 64 do L[i] = math.sin(i) * 0.5; R[i] = math.cos(i) * 0.5 end + return L, R +end + +local Audio = require("src.core.game3.audio") +Audio._ready = true +Audio._pack = {} +Audio._cache = {} +Audio._seRawClear() + +local function live(id) + local n = 0 + for _, src in ipairs(Audio._seSources) do + local meta = Audio._seMeta[src] + if meta and meta.id == id then n = n + 1 end + end + return n +end + +check(Audio.playSe(5), "first play") +local first = Audio._seByPlayer[1] +eq(bakes, 1, "first play bakes") +eq(sources, 1, "first play builds one Source") +check(first and first.playing, "first play is sounding") + +check(Audio.playSe(5), "repeat play") +eq(bakes, 1, "repeat does not re-bake") +eq(sources, 1, "repeat reuses the cached Source") +check(Audio._seByPlayer[1] == first, "same Source on its player slot") +check(first.playing and first.plays == 2, "cached Source restarted") +eq(live(5), 1, "one live entry for the SE (the previous play was replaced)") +check(Audio._seMeta[first] and Audio._seMeta[first].id == 5 + and Audio._seMeta[first].player == 1, "meta rebuilt for the replay") + +-- a different SE on the same player replaces it, then the first comes back +local builtBefore = built +Audio.playSe(6) +check(not first.playing, "player 1 SE replaced stops the cached Source") +check(Audio._seMeta[first] == nil, "replaced Source forgotten") +Audio.playSe(5) +check(Audio._seByPlayer[1] == first and first.playing, + "cached Source reused after being replaced") +eq(built - builtBefore, 1, "only the new SE built SoundData") + +-- PCM-baked parameters miss the cache +local n = sources +Audio.playSe(5, { pan = 63 }) +eq(sources, n + 1, "a different pan builds its own Source") +Audio.playSe(5, { pan = 63 }) +eq(sources, n + 1, "and that one is cached too") +Audio.playSe(5, { volume = 0.5 }) +eq(sources, n + 2, "a different volume builds its own Source") + +-- stopSe still stops and forgets it +Audio.playSe(5) +local cached = Audio._seByPlayer[1] +Audio.stopSe(5) +check(not cached.playing and Audio._seMeta[cached] == nil + and Audio._seByPlayer[1] == nil, "stopSe stops and forgets a cached Source") + +-- a cached Source still tracked live (not replaced) is cloned, not cut off +Audio._seSources[#Audio._seSources + 1] = cached +Audio._seMeta[cached] = { id = 5, player = 3 } +cached.playing = true +Audio.playSe(5) +local now = Audio._seByPlayer[1] +check(now ~= cached and now.clone and now.playing, + "live cached Source is cloned for the new play") +check(cached.playing, "the live one keeps playing") +Audio.stopSe() + +-- a re-install / session end drops the cache with the raw memo +Audio._seRawClear() +n = sources +Audio.playSe(5) +eq(sources, n + 1, "cache cleared with the raw memo") +eq(bakes, 3, "and the SE is baked again (5 and 6 before)") + +-- non-memoable plays (explicit loop/maxSec) never touch the cache +n = sources +Audio.playSe(5, { maxSec = 1 }) +Audio.playSe(5, { maxSec = 1 }) +eq(sources, n + 2, "maxSec plays build fresh Sources") + +S.finish()