From 795276935c5467cfdc947bb8d7ae7a8b4ff17c21 Mon Sep 17 00:00:00 2001 From: Shane McGovern Date: Wed, 30 Sep 2026 14:53:18 +0000 Subject: [PATCH 1/5] 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() From b694d67d8563a51f53873a96c8595251e5868e4e Mon Sep 17 00:00:00 2001 From: 1jamie Date: Wed, 30 Sep 2026 10:31:53 -0500 Subject: [PATCH 2/5] fix(emerald): fix party item selection input routing in battle --- src/core/game3/battle/init.lua | 20 ++++---- tests/emerald_battle_rules_test.lua | 75 +++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+), 10 deletions(-) diff --git a/src/core/game3/battle/init.lua b/src/core/game3/battle/init.lua index e0cc7631..0c41df24 100644 --- a/src/core/game3/battle/init.lua +++ b/src/core/game3/battle/init.lua @@ -2958,16 +2958,16 @@ function D.commandUpdate(input) return end if Battle._refuseLinkItem(input) then return end - local BagMenu = require("src.ui.game3.bag_menu") - if BagMenu.isOpen and BagMenu.isOpen() then - if input then require("src.ui.game3.screens").handleInput("bag", BagMenu, input) end - return - end local PartyMenu = require("src.ui.game3.party_menu") if PartyMenu.isOpen and PartyMenu.isOpen() then if input then party_menu_input(PartyMenu, input) end return end + local BagMenu = require("src.ui.game3.bag_menu") + if BagMenu.isOpen and BagMenu.isOpen() then + if input then require("src.ui.game3.screens").handleInput("bag", BagMenu, input) end + return + end if Ui._mode == "bag" or Ui._mode == "party" then Ui._mode = "menu" end @@ -3782,16 +3782,16 @@ update_body = function(dt, game) if Battle._phase == "command" and not Battle._auto then if refuse_link_item(input) then return end - local BagMenu = require("src.ui.game3.bag_menu") - if BagMenu.isOpen and BagMenu.isOpen() then - if input then require("src.ui.game3.screens").handleInput("bag", BagMenu, input) end - return - end local PartyMenu = require("src.ui.game3.party_menu") if PartyMenu.isOpen and PartyMenu.isOpen() then if input then party_menu_input(PartyMenu, input) end return end + local BagMenu = require("src.ui.game3.bag_menu") + if BagMenu.isOpen and BagMenu.isOpen() then + if input then require("src.ui.game3.screens").handleInput("bag", BagMenu, input) end + return + end if Ui._mode == "bag" or Ui._mode == "party" then Ui._mode = "menu" end diff --git a/tests/emerald_battle_rules_test.lua b/tests/emerald_battle_rules_test.lua index 708c2d84..173027aa 100644 --- a/tests/emerald_battle_rules_test.lua +++ b/tests/emerald_battle_rules_test.lua @@ -127,6 +127,81 @@ eq(pyramidBagOpts and pyramidBagOpts.location, "battle", "Pyramid battle BAG ope eq(pyramidBagOpts and pyramidBagOpts.session, registeredSession, "Pyramid battle BAG uses the active challenge state") BattleUi._session, BattleUi._st, BattleUi._mode = nil, nil, "none" +local Bag = require("src.core.game3.bag") +local BagMenu = require("src.ui.game3.bag_menu") +local Battle = require("src.core.game3.battle") +local State = require("src.core.game3.battle.state") +local Screens = require("src.ui.game3.screens") + +local testBag = Bag.new() +Bag.add(testBag, C.items.byName.ITEM_POTION, 2) +local testParty = { + { species = C.species.byName.SPECIES_TREECKO, hp = 10, maxHp = 20, moves = {} }, + { species = C.species.byName.SPECIES_TORCHIC, hp = 5, maxHp = 20, moves = {} }, +} +local testSession = { bag = testBag, party = testParty } +local bst = { + wild = true, + turn = 1, + player = State.makeBattler(testParty[1], "player", { partyIndex = 1 }), + playerParty = testParty, +} +Battle._active = true +Battle._phase = "command" +Battle._st = bst +Battle._auto = false +BattleUi._session = testSession +BattleUi._st = bst +BattleUi._mode = "command" + +BattleUi.openBattleBag() +eq(BagMenu.isOpen(), true, "Emerald battle BagMenu is open") +BagMenu.settle() +Screens.handleInput("bag", BagMenu, pressed("a"), testSession) +eq(BagMenu.mode, "action", "BagMenu mode is action") +Screens.handleInput("bag", BagMenu, pressed("a"), testSession) +eq(PartyMenu.isOpen(), true, "PartyMenu is open for in-battle item use") +eq(PartyMenu.mode, "use", "PartyMenu is in use mode") +eq(PartyMenu.cursor, 1, "PartyMenu initially selects slot 1") + +Battle.update(0, { input = pressed("down") }) +eq(PartyMenu.cursor, 2, "PartyMenu cursor moves to slot 2 in Emerald battle (not stuck on active mon)") + +Battle.update(0, { input = pressed("a") }) +-- Press A to fast-forward HP animation and advance message +PartyMenu.handleInput(pressed("a")) +eq(PartyMenu.mode, "message", "PartyMenu displays recovery message") +PartyMenu.handleInput(pressed("a")) +eq(PartyMenu.isOpen(), false, "PartyMenu closes after message dismissal") +BagMenu.settle() +eq(BagMenu.isOpen(), false, "BagMenu closes after committing battle item") +eq(BattleUi._pendingCommand and BattleUi._pendingCommand.partySlot, 2, "Pending battle command targets slot 2") + +Battle._active = false +BattleUi._session, BattleUi._st, BattleUi._mode = nil, nil, "none" + +-- Also test cancelling back from PartyMenu to BagMenu +Battle._active = true +Battle._phase = "command" +Battle._st = bst +BattleUi._session = testSession +BattleUi._st = bst +BattleUi._mode = "command" +BattleUi.openBattleBag() +BagMenu.settle() +Screens.handleInput("bag", BagMenu, pressed("a"), testSession) +Screens.handleInput("bag", BagMenu, pressed("a"), testSession) +eq(PartyMenu.isOpen(), true, "PartyMenu reopened for item use") +Battle.update(0, { input = pressed("b") }) +eq(PartyMenu.isOpen(), false, "PartyMenu closed on B press") +eq(BagMenu.isOpen(), true, "BagMenu remains open after PartyMenu cancel") +eq(BagMenu.mode, "list", "BagMenu returned to list mode") +Screens.handleInput("bag", BagMenu, pressed("b"), testSession) +BagMenu.settle() +eq(BagMenu.isOpen(), false, "BagMenu closed on B press") +Battle._active = false +BattleUi._session, BattleUi._st, BattleUi._mode = nil, nil, "none" + local Trainers = require("src.core.game3.scripting.trainers") local calvin = C.trainers.byName.TRAINER_CALVIN_1 local row = Trainers.get(calvin) From 932a075496afd0fb497022ecfd41f0d227da73fb Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Wed, 30 Sep 2026 12:18:24 -0400 Subject: [PATCH 3/5] CLOSES #2564, CLOSES #2570 --- .github/ISSUE_TEMPLATE/bug_report.yml | 1 + README.md | 9 +- src/core/Game3.lua | 6 +- src/core/game3/audio.lua | 24 +- src/core/game3/field.lua | 46 ++++ src/core/game3/ghosts.lua | 2 +- src/core/game3/objects.lua | 18 +- src/import/LauncherView.lua | 1 + src/import/RomImporter.lua | 11 + src/import/SecretGames.lua | 11 +- src/ui/game3/message.lua | 7 +- src/ui/game3/start_menu.lua | 2 +- tests/drivers/em_briney_boat.lua | 152 ++++++++++-- tests/drivers/em_start_menu_exit_90001.lua | 113 +++++++++ tests/drivers/game3_collision_npc_edge.lua | 4 +- tests/drivers/game3_cycling_fly_bug2564.lua | 122 ++++++++++ tests/drivers/game3_message_speed_2570.lua | 109 +++++++++ tests/drivers/game3_music_transition.lua | 207 ++++++++++++++++ tests/drivers/game3_surf_connections.lua | 2 +- tests/drivers/launch_update_mods_bug2037.lua | 25 +- .../launcher_emerald_release_90005.lua | 134 +++++++++++ tests/engine/game3_audio_transition_test.lua | 204 ++++++++++++++++ tests/engine/launcher_secret_emerald_test.lua | 85 +++++++ .../launcher_skins_options_reads_bug2038.lua | 4 +- tests/game3_carried_actor_ghost_test.lua | 225 ++++++++++++++++++ tests/game3_cycling_road_bike_test.lua | 23 +- tests/game3_fly_cycling_state_test.lua | 72 ++++++ tests/game3_message_speed_test.lua | 75 ++++++ tests/game3_pause_and_main_menu_exit_test.lua | 62 ++++- 29 files changed, 1686 insertions(+), 70 deletions(-) create mode 100644 tests/drivers/em_start_menu_exit_90001.lua create mode 100644 tests/drivers/game3_cycling_fly_bug2564.lua create mode 100644 tests/drivers/game3_message_speed_2570.lua create mode 100644 tests/drivers/game3_music_transition.lua create mode 100644 tests/drivers/launcher_emerald_release_90005.lua create mode 100644 tests/engine/game3_audio_transition_test.lua create mode 100644 tests/game3_carried_actor_ghost_test.lua create mode 100644 tests/game3_fly_cycling_state_test.lua create mode 100644 tests/game3_message_speed_test.lua diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index b8d85b9c..de98d99f 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -36,6 +36,7 @@ body: - Silver - Crystal - FireRed + - Emerald - N/A validations: required: true diff --git a/README.md b/README.md index e4999481..0f8d53de 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # G1R Deluxe aka Gen1Recomp -A native LÖVE2D recreation of Poke Red, Blue, Yellow, Gold, Silver, Crystal, FireRed, and LeafGreen. +A native LÖVE2D recreation of Poke Red, Blue, Yellow, Gold, Silver, Crystal, FireRed, LeafGreen, and Emerald. The engine and map behavior are hand-written Lua; game data and graphics are decoded from a ROM supplied by the player. @@ -61,7 +61,7 @@ And before you say, "that's not a recomp", you're wrong. Recomp is an acronym. * ## What this is G1R Deluxe aka Gen1Recomp is a native LÖVE2D recreation of Pokemon Red, Blue, Yellow, Gold, -Silver, Crystal, FireRed, and LeafGreen. The engine and map behavior are +Silver, Crystal, FireRed, LeafGreen, and Emerald. The engine and map behavior are hand-written Lua, ported from the [pret](https://github.com/pret) disassemblies & C. Game data, graphics, and audio programs are decoded on first launch from a ROM you supply. @@ -70,7 +70,7 @@ The project does not include a ROM, emulate the Game Boy, transpile assembly, or download a disassembly. Your ROM is verified, used during import, and released from memory. It is never copied into the cache, and later launches load the private generated cache without asking for it again. Music, sound -effects, and cries are synthesized while the game runs. All eight games can be +effects, and cries are synthesized while the game runs. All nine games can be imported side by side. Gen 2 support is still under construction. ## Quick Start @@ -84,7 +84,7 @@ imported side by side. Gen 2 support is still under construction. 4. Repeat for any other game you own. Each one gets its own tab in the launcher. Only the canonical US English ROMs below are accepted. The importer checks the -SHA-1 before creating any game data. FireRed and LeafGreen support is in beta. +SHA-1 before creating any game data. FireRed, LeafGreen, and Emerald support is in beta. | Game | Revision | ROM size | SHA-1 | | --- | --- | --- | --- | @@ -99,6 +99,7 @@ SHA-1 before creating any game data. FireRed and LeafGreen support is in beta. | FireRed | 1.1 | 16 MiB | `dd5945db9b930750cb39d00c84da8571feebf417` | | LeafGreen | 1.0 | 16 MiB | `574fa542ffebb14be69902d1d36f1ec0a4afd71e` | | LeafGreen | 1.1 | 16 MiB | `7862c67bdecbe21d1d69ce082ce34327e1c6ed5e` | +| Emerald | 1.0 | 16 MiB | `f3ae088181bf583e55daf962a92bb46f4f1d07b7` | **Platform notes:** [Linux](docs/platforms/linux.md), [iOS](docs/platforms/ios.md), [Xbox Dev Mode](docs/platforms/xbox.md), diff --git a/src/core/Game3.lua b/src/core/Game3.lua index b66cf65f..43fe50d6 100644 --- a/src/core/Game3.lua +++ b/src/core/Game3.lua @@ -1246,7 +1246,8 @@ local function clearFieldScreens() FieldView.setCameraPanning(0, 0) end -function Game3:returnToTitle() +function Game3:returnToTitle(opts) + opts = opts or {} self.questPlayback=nil Help.reset() Audio.stopAll() @@ -1285,7 +1286,8 @@ function Game3:returnToTitle() end Boot.setSaveStatus(self.boot, saveStatus) Boot.setTextSpeed(self.boot, Options.block(self.options).textSpeed) - if not self.boot.custom then + if not self.boot.custom or opts.skipIntro then + if self.boot.custom then self.boot.custom.coldBoot = false end self.boot.phase = Boot.PHASE.TITLE self.boot.timer = 0 Boot.enterTitle(self.boot) diff --git a/src/core/game3/audio.lua b/src/core/game3/audio.lua index e4a687fa..d504a20c 100644 --- a/src/core/game3/audio.lua +++ b/src/core/game3/audio.lua @@ -421,6 +421,8 @@ function Audio.playSong(id, opts) opts = opts or {} id = Audio.resolveSong(id) if id == nil or id == 0 or id == 0xFFFF then + Audio._fadeOut, Audio._fadeIn = nil, nil + Audio._fanfareRestore, Audio._fanfareDeferred = nil, nil stop_bgm_source() Audio._currentSong = nil return true @@ -428,7 +430,8 @@ function Audio.playSong(id, opts) if opts.fanfare or (Audio.songInfo(id) and Audio.songInfo(id).kind == "fanfare") or (fanfare_entry(id) ~= nil) then return Audio.playFanfare(id) end - if not opts.restart and Audio._currentSong and Audio._currentSong.id == id then + if not opts.restart and not Audio._fadeOut and Audio._currentSong and Audio._currentSong.id == id then + if Audio._fanfareActive then Audio._fanfareDeferred = nil end return true end local info = Audio.songInfo(id) or {} @@ -488,8 +491,11 @@ function Audio.playSong(id, opts) end function Audio.currentMapMusic() - local pending = Audio._fadeOut and Audio._fadeOut.nextSong - return pending or (Audio._currentSong and Audio._currentSong.id) or 0 + if Audio._fanfareActive and Audio._fanfareDeferred ~= nil then + return Audio._fanfareDeferred + end + if Audio._fadeOut then return Audio._fadeOut.nextSong or 0 end + return (Audio._currentSong and Audio._currentSong.id) or 0 end local function play_policy_song(id, fadeOut, fadeIn) @@ -630,9 +636,7 @@ function Audio.changeMusicTo(id) if t then return Audio.fadeOutAndPlay(t.song, t.fadeOut) end return true end - local cur = Audio._currentSong and Audio._currentSong.id - local pending = Audio._fadeOut and Audio._fadeOut.nextSong - if (pending or cur) == id then return true end + if Audio.currentMapMusic() == id then return true end return Audio.fadeOutAndPlay(id, 8) end @@ -671,9 +675,8 @@ function Audio.bikeMusic(on, forced) end Audio.setSavedSong(nil) local id = Audio.specialMapSong() - local pending = Audio._fadeOut and Audio._fadeOut.nextSong - if id and id ~= (pending or (Audio._currentSong and Audio._currentSong.id)) then - Audio.playSong(id) + if id and id ~= Audio.currentMapMusic() then + play_policy_song(id) end end @@ -693,7 +696,8 @@ end function Audio.fadeDefaultBgm(speed) if rse_policy() then return Audio.changeMusicToDefault() end local id = Audio._mapSong - if id and Audio._currentSong and Audio._currentSong.id == id then return true end + if id and Audio.currentMapMusic() == id then return true end + if Audio._fanfareActive then return play_policy_song(id) end Audio.fadeOutBgm(speed) if id then return Audio.playSong(id) end return true diff --git a/src/core/game3/field.lua b/src/core/game3/field.lua index 707ae5a1..1ce7d48f 100644 --- a/src/core/game3/field.lua +++ b/src/core/game3/field.lua @@ -1702,6 +1702,50 @@ function Field.flyDestination(section) return baked[id] end +-- pokefirered/src/overworld.c:289 +local function resetCyclingRoadAfterTravel() + local session = Field._session + if not session then return end + local Profile = require("src.core.game3.profile") + local Flags = require("src.core.game3.scripting.flags") + local Runtime = package.loaded["src.core.game3.runtime"] + local Space = package.loaded["src.core.game3.scripting.space"] + local profile = Profile.forSession(session) + local defs = Flags.forVersion(profile.id) + local roadName = profile.family == "rse" + and "FLAG_SYS_CYCLING_ROAD" or "FLAG_SYS_ON_CYCLING_ROAD" + local road = assert(defs.IDS[roadName]) + local scene = profile.family ~= "rse" and assert(defs.VAR_IDS.VAR_MAP_SCENE_ROUTE16) + local live = Runtime and Runtime.getSession and Runtime.getSession() + local seen = {} + for _, store in ipairs({ session, session.store or false, Space and Space.store or false, + live or false, live and live.store or false }) do + if store and not seen[store] then + seen[store] = true + Flags.setFlag(store, nil, road, false) + if store.flags then store.flags[roadName] = nil end + if scene then + Flags.setVar(store, nil, scene, 0) + if store.vars then + store.vars[tostring(scene)] = nil + store.vars[string.format("0x%X", scene)] = nil + store.vars.VAR_MAP_SCENE_ROUTE16 = nil + end + end + end + end + Player.biking, Player.bikeType = false, nil + Player.surfing, Player.surfHopping = false, false + for _, s in ipairs({ session, live or false }) do + if s then s.biking, s.bikeType = false, nil end + end + local save = Field._game and Field._game.save + if save then + save.biking, save.bikeType = false, nil + if save.position then save.position.biking = false end + end +end + -- pokefirered/src/field_effect.c:1065 ReturnToFieldFromFlyMapSelect function Field.flyTo(section, mon, info) local dest = (info and info.dest) @@ -1716,6 +1760,7 @@ function Field.flyTo(section, mon, info) local FieldEffects = require("src.core.game3.field_effects") local function land() local Map = require("src.core.game3.map") + resetCyclingRoadAfterTravel() Map.load(Field._mod, Field._game, dest.map, { x = dest.x, y = dest.y, facing = "down", depth1Connections = true, }) @@ -1895,6 +1940,7 @@ end function Field.respawnAtHeal(opts) local session = Field._session if not session then return end + resetCyclingRoadAfterTravel() local HealLocations = require("src.core.game3.heal_locations") HealLocations.normalizeSession(session) local whiteOut = not (opts and opts.fieldMove) diff --git a/src/core/game3/ghosts.lua b/src/core/game3/ghosts.lua index 7243d137..63e81928 100644 --- a/src/core/game3/ghosts.lua +++ b/src/core/game3/ghosts.lua @@ -146,7 +146,7 @@ function Ghosts.capture(mapId) local pool = { byId = {}, order = {}, bounds = snap.bounds } for _, lid in ipairs(snap.order or {}) do local eo = snap.byId[lid] - if eo then + if eo and eo.foreignMap == nil then pool.byId[lid] = eo pool.order[#pool.order + 1] = lid eo.scriptBusy = false diff --git a/src/core/game3/objects.lua b/src/core/game3/objects.lua index f8966994..6f432e2e 100644 --- a/src/core/game3/objects.lua +++ b/src/core/game3/objects.lua @@ -804,7 +804,7 @@ function Objects.forDraw() local eo = Objects._byId[lid] -- src/event_object_movement.c:8014 if eo and eo.visible and not eo.hidden and not eo.invisible - and not offMap(Objects._bounds, eo) then + and (eo.foreignMap ~= nil or not offMap(Objects._bounds, eo)) then n = n + 1 list[n] = eo end @@ -1622,10 +1622,22 @@ local function idleTick(eo, game, ctx) end end +local trackIds, trackRefs, trackActors = {}, {}, {} + function Objects.update(game) - -- Advance script tracks then motion + idle. + local count = 0 for lid, tr in pairs(Objects._tracks) do - advanceTrack(lid, tr, game) + count = count + 1 + trackIds[count], trackRefs[count], trackActors[count] = lid, tr, Objects.find(lid) + end + -- pokeemerald/src/event_object_movement.c:2167 + for i = 1, count do + local tr, eo = trackRefs[i], trackActors[i] + local lid = (eo and eo.localId) or trackIds[i] + trackIds[i], trackRefs[i], trackActors[i] = nil, nil, nil + if Objects._tracks[lid] == tr and (not eo or Objects.find(lid) == eo) then + advanceTrack(lid, tr, game) + end end for _, lid in ipairs(Objects._order) do local eo = Objects._byId[lid] diff --git a/src/import/LauncherView.lua b/src/import/LauncherView.lua index 934f8abe..b4117110 100644 --- a/src/import/LauncherView.lua +++ b/src/import/LauncherView.lua @@ -1547,6 +1547,7 @@ end -- one visited, else Red. Kept as a function so the mods/find/skins panels -- still answer "for which game" without a game tab being open. local function gameTabs(imp) + SecretGames.update() local launcher = type(imp) ~= "table" or imp.launcher and true or false local c = imp and imp._gameTabs if c and c.rev == SecretGames.rev and c.launcher == launcher then diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index 048c6f0e..bcfa96d5 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -1961,6 +1961,11 @@ function RomImporter:_versionForSha1(hash) end function RomImporter:_logoTap() + if SecretGames.update() then + self._secretPopup = nil + self._logoTaps = nil + return + end if self._secretPopup or not SecretGames.anyLocked() then return end self._logoTaps = (self._logoTaps or 0) + 1 if self._logoTaps >= SecretGames.TAPS then @@ -3452,6 +3457,12 @@ function RomImporter:_pollPickedFiles(dt) end function RomImporter:update(dt) + local released, changed = SecretGames.update() + if released then + self._secretPopup = nil + self._logoTaps = nil + if changed then self._headerChrome = nil end + end self.pulse = self.pulse + dt if self._importerJob then self:_stepImporter() end if not Transition.armed then diff --git a/src/import/SecretGames.lua b/src/import/SecretGames.lua index 8770e3dc..172d696d 100644 --- a/src/import/SecretGames.lua +++ b/src/import/SecretGames.lua @@ -4,6 +4,7 @@ local SecretGames = {} SecretGames.GAMES = { emerald = "secretEmerald" } SecretGames.TAPS = 20 +SecretGames.EMERALD_RELEASE_AT = 1790856000 SecretGames.rev = 0 local unlocked @@ -25,7 +26,7 @@ end function SecretGames.visible(id) if not SecretGames.GAMES[id] then return true end - if not unlocked then load() end + SecretGames.update() return unlocked[id] == true end @@ -60,4 +61,12 @@ function SecretGames.unlock() return saved end +function SecretGames.update(now) + if not unlocked then load() end + local due = (now or os.time()) >= SecretGames.EMERALD_RELEASE_AT + local changed = due and unlocked.emerald ~= true + if changed then SecretGames.unlock() end + return due, changed +end + return SecretGames diff --git a/src/ui/game3/message.lua b/src/ui/game3/message.lua index 1740d8a2..24072afc 100644 --- a/src/ui/game3/message.lua +++ b/src/ui/game3/message.lua @@ -167,6 +167,7 @@ function Message.show(text, opts) end -- Prefer session options text speed when not overridden. + local instant = tonumber(opts.speed) == 0 local speed = opts.speed if speed == nil then local ok, Options = pcall(require, "src.core.game3.options") @@ -182,10 +183,6 @@ function Message.show(text, opts) Message._speedIdx = tonumber(speed) or 1 if Message._speedIdx < 0 then Message._speedIdx = 0 end if Message._speedIdx > 2 then Message._speedIdx = 2 end - if tonumber(speed) == 0 then - -- Instant print (Oak repeat question) - Message._speedIdx = 0 - end local _, _, dlgW = Chrome.dialogueWindow() local maxW = (opts.frame == "battle" or opts.battle) and 212 or dlgW * Display.TILE @@ -204,7 +201,7 @@ function Message.show(text, opts) Message._pages = split_pages(plain) Message._page = 1 beginPage() - if tonumber(speed) == 0 then + if instant then Message.skipReveal() end return Message diff --git a/src/ui/game3/start_menu.lua b/src/ui/game3/start_menu.lua index 479f804d..5a7fabf3 100644 --- a/src/ui/game3/start_menu.lua +++ b/src/ui/game3/start_menu.lua @@ -253,7 +253,7 @@ function StartMenu.confirm() or (Runtime and Runtime._game) or StartMenu._game if game and game.returnToTitle then - game:returnToTitle() + game:returnToTitle({ skipIntro = true }) end else -- NO StartMenu._confirmExit = false diff --git a/tests/drivers/em_briney_boat.lua b/tests/drivers/em_briney_boat.lua index 4629b2fb..5695cb8e 100644 --- a/tests/drivers/em_briney_boat.lua +++ b/tests/drivers/em_briney_boat.lua @@ -5,37 +5,143 @@ local d = X.new("em_briney_boat", "/tmp/em_briney_boat") return function(game) if not X.newGame(d, game, 0) then return d.finish() end + local Objects = require("src.core.game3.objects") + local Player = require("src.core.game3.player") + local Ghosts = require("src.core.game3.ghosts") + local Space = require("src.core.game3.scripting.space") + local Message = require("src.ui.game3.message") + local Fade = require("src.ui.game3.fade") + local state + local function monitor() + if not state then return end + local s = X.session() + if s and not state.maps[s.map] then + state.maps[s.map] = true + d.note(state.name .. " map " .. s.map .. " VM " .. X.vmWhere()) + end + local boat, lid + for id, eo in pairs(Objects._byId) do + if eo.originMapId == state.origin and eo.originLocalId == state.localId then + boat, lid = eo, id + break + end + end + if not boat then return end + for _, pool in pairs(Ghosts._pools) do + for _, ghost in pairs(pool.byId) do + if ghost == boat then state.aliases = state.aliases + 1 end + end + end + local bt = Objects._tracks[lid] + local pt = Objects._tracks[Objects.PLAYER_LOCAL_ID] + if Player.visible == false and bt and pt and not bt.done and not pt.done then + local dx, dy = boat.px - Player.px, boat.py - Player.py + if state.dx == nil then state.dx, state.dy = dx, dy end + state.samples = state.samples + 1 + if dx ~= state.dx or dy ~= state.dy then + state.drift = state.drift + 1 + if state.drift == 1 then + d.note(string.format("%s drift at %s: expected %s,%s actual %s,%s", + state.name, s.map, state.dx, state.dy, dx, dy)) + end + end + end + end + local function waitFor(pred, frames) + for i = 1, frames do + if pred() then return true end + if i % 6 == 0 then table.insert(game.input.pressQueue, "a") end + U.wait(1) + game.input.state.a = false + monitor() + end + return pred() + end + local function begin(name, origin, localId) + state = { name = name, origin = origin, localId = localId, maps = {}, + samples = 0, drift = 0, aliases = 0 } + end + local function moment(label, file, pred, frames) + if not d.check(waitFor(pred, frames or 18000), label .. " (VM " .. X.vmWhere() .. ")") then + return false + end + d.check(d.still(game, file), label .. " screenshot") + return true + end + local function finishTrip(mapId, file) + moment(state.name .. " landing", file, function() + return not X.scriptRunning() and X.session().map == mapId and Player.visible ~= false + and not Fade.isActive() and not Fade.lockInput and (Fade.t or 0) <= 0 + end) + d.check(state.samples > 0, state.name .. " samples sailing every simulation frame (" .. state.samples .. ")") + d.check(state.drift == 0, state.name .. " boat/player relative pixels remain constant (" .. state.drift .. ")") + d.check(state.aliases == 0, state.name .. " carried boat absent from every ghost pool (" .. state.aliases .. ")") + d.check(Player.visible ~= false, state.name .. " player visible after landing") + state = nil + end + local function start(label, localId, facing) + local key = Space.bundle.labels[label] + return d.check(key and Space.startScript(key, localId, facing), "cached " .. label .. " starts") + end X.setFlag("FLAG_MR_BRINEY_SAILING_INTRO", true) - X.setFlag("FLAG_ENABLE_NORMAN_MATCH_CALL", true) + X.setFlag("FLAG_ENABLE_NORMAN_MATCH_CALL", false) X.setFlag("FLAG_HIDE_BRINEYS_HOUSE_MR_BRINEY", false) X.setFlag("FLAG_HIDE_BRINEYS_HOUSE_PEEKO", false) X.setVar("VAR_BRINEY_HOUSE_STATE", 0) d.check(X.goTo(d, game, "EM_ROUTE104_MR_BRINEYS_HOUSE", 5, 6, "up"), "Mr. Briney's house loads") - local briney = require("src.core.game3.objects").find(1) + local briney = Objects.find(1) local bx, by = briney and briney.cellX or 5, briney and briney.cellY or 3 X.goTo(d, game, "EM_ROUTE104_MR_BRINEYS_HOUSE", bx, by + 1, "up") - local seen = {} - local finished = X.mash(game, function() - local s = X.session() - if s and s.map and not seen[s.map] then - seen[s.map] = true - d.note("map " .. s.map .. " VM " .. X.vmWhere()) - end - return X.flag("FLAG_HIDE_ROUTE_104_MR_BRINEY_BOAT") and X.var("VAR_BOARD_BRINEY_BOAT_STATE") == 0 - and not X.scriptRunning() - end, 6000, "a", 4) - d.check(seen.EM_ROUTE104, "SailToDewford warps to Route 104 and ON_FRAME boards the boat") - d.check(finished, "the sailing script runs every boat/player movement to the landing (VM " .. X.vmWhere() .. ")") + begin("Route104 Dad call", "EM_ROUTE104", 7) + table.insert(game.input.pressQueue, "a") + moment("Route104 boarded", "90002_route104_boarded.png", function() + return X.session().map == "EM_ROUTE104" and Player.visible == false + end) + moment("Route105 first seam", "90002_route105_first_seam.png", function() + return X.session().map == "EM_ROUTE105" + end) + moment("Dad call fully revealed", "90002_dad_call.png", function() + return Player.visible == false and Message.isOpen() and not Message.isTyping() + end) + moment("Route106 second seam", "90002_route106_second_seam.png", function() + return X.session().map == "EM_ROUTE106" + end) + finishTrip("EM_DEWFORD_TOWN", "90002_dewford_landed.png") + d.check(X.flag("FLAG_ENABLE_NORMAN_MATCH_CALL"), "first sailing trip enables Dad match call") d.check(X.flag("FLAG_HIDE_MR_BRINEY_DEWFORD_TOWN") == false, "Dewford Briney shown after landing") - local s = X.session() - local Player = require("src.core.game3.player") - if s and s.map == "EM_DEWFORD_TOWN" then - d.check(true, "player ends in Dewford Town") - else - d.note(string.format("NOTE scripted movement crossed the Route 104 -> Dewford connection but the map did not switch " .. - "(still %s at %s,%s); connection transition during applymovement is a field/connections owner item", - tostring(s and s.map), tostring(Player.cellX), tostring(Player.cellY))) + for _, trip in ipairs({ + { name = "Dewford Petalburg", label = "DewfordTown_EventScript_SailToPetalburg", + seam = "EM_ROUTE106", landing = "EM_ROUTE104_MR_BRINEYS_HOUSE", prefix = "90002_petalburg" }, + { name = "Dewford Slateport", label = "DewfordTown_EventScript_SailToSlateport", + seam = "EM_ROUTE107", landing = "EM_ROUTE109", prefix = "90002_slateport" }, + }) do + X.setFlag("FLAG_HIDE_MR_BRINEY_DEWFORD_TOWN", false) + X.setFlag("FLAG_HIDE_MR_BRINEY_BOAT_DEWFORD_TOWN", false) + d.check(X.goTo(d, game, "EM_DEWFORD_TOWN", 11, 9, "right"), trip.name .. " departure loads") + begin(trip.name, "EM_DEWFORD_TOWN", 4) + if start(trip.label, 2, 4) then + moment(trip.name .. " first seam", trip.prefix .. "_first_seam.png", function() + return X.session().map == trip.seam and Player.visible == false + end) + finishTrip(trip.landing, trip.prefix .. "_landed.png") + end + end + for _, approach in ipairs({ + { name = "south", x = 21, y = 23, facing = "down", dir = 1 }, + { name = "east", x = 20, y = 24, facing = "right", dir = 4 }, + { name = "west", x = 22, y = 24, facing = "left", dir = 3 }, + }) do + X.setFlag("FLAG_HIDE_ROUTE_109_MR_BRINEY", false) + X.setFlag("FLAG_HIDE_ROUTE_109_MR_BRINEY_BOAT", false) + d.check(X.goTo(d, game, "EM_ROUTE109", approach.x, approach.y, approach.facing), + "Route109 " .. approach.name .. " departure loads") + begin("Route109 " .. approach.name, "EM_ROUTE109", 1) + if start("Route109_EventScript_StartDepartForDewford", 2, approach.dir) then + moment(state.name .. " first seam", "90002_return_" .. approach.name .. "_first_seam.png", function() + return X.session().map == "EM_ROUTE108" and Player.visible == false + end) + finishTrip("EM_DEWFORD_TOWN", "90002_return_" .. approach.name .. "_landed.png") + end end - d.shot(game, "01_landed_in_dewford.png") d.finish() end diff --git a/tests/drivers/em_start_menu_exit_90001.lua b/tests/drivers/em_start_menu_exit_90001.lua new file mode 100644 index 00000000..66f0b4c4 --- /dev/null +++ b/tests/drivers/em_start_menu_exit_90001.lua @@ -0,0 +1,113 @@ +local U = require("tests.drivers.util") +local DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/em_start_menu_exit_90001" + +return function(game) + local failures = 0 + local overallDeadline = love.timer.getTime() + 25 + local SaveData, originalSave + local function check(ok, label) + print((ok and "PASS " or "FAIL ") .. label) + if not ok then failures = failures + 1 end + return ok + end + local function waitUntil(pred, seconds) + local deadline = math.min(overallDeadline, love.timer.getTime() + seconds) + while love.timer.getTime() < deadline do + if pred() then return true end + U.wait(1) + end + return pred() + end + local ok, err = xpcall(function() + local identity = os.getenv("POKEPORT_IDENTITY") or "" + if not check(identity:match("^bsa0930%-") ~= nil, "isolated campaign identity") then return end + if not check(waitUntil(function() return game.boot ~= nil end, 5), "Emerald boot ready") then return end + local Boot = require("src.ui.game3.boot") + local StartMenu = require("src.ui.game3.start_menu") + local Runtime = require("src.core.game3.runtime") + SaveData = require("src.core.SaveData") + if not check(not SaveData.isPortable(), "save fixtures use isolated identity storage") then return end + originalSave = SaveData.save + local saves = 0 + SaveData.save = function(...) saves = saves + 1; return originalSave(...) end + local Message = require("src.ui.game3.message") + local Field = require("src.core.game3.field") + local Fade = require("src.ui.game3.fade") + local Hud = require("src.ui.game3.hud") + local Space = require("src.core.game3.scripting.space") + local Warp = require("src.core.game3.warp") + local Forced = require("src.core.game3.forced_movement") + local Player = require("src.core.game3.player") + local function idle() + if Message.isOpen() then U.tap(game, "b") end + local running = Space.vm and Space.vm.isRunning and Space.vm:isRunning() + return game.phase == "field" and not Hud.busy() and not Fade.isActive() and not Fade.lockInput + and not Field.locked and not running and not Warp.isBusy() and not Forced.isForced() + and not Player.moving and Player.boulderPush == nil + end + for caseIndex, saved in ipairs({ false, true }) do + local prefix = saved and "saved" or "unsaved" + local slot = SaveData.createSlot("emerald") + if not check(slot and SaveData.setActiveSlot("emerald", slot) == slot, prefix .. " fresh save slot selected") then return end + game:_handleBootAction({ action = "new_game", name = "BRENDAN", + start = { map = "EM_LITTLEROOT_TOWN", x = 5, y = 9, facing = "down" } }) + print("INFO " .. prefix .. " initial fade=" .. tostring(Fade.isActive()) .. " hudBusy=" .. tostring(Hud.busy())) + if not check(waitUntil(idle, 4), prefix .. " field ready after fade and script completion") then return end + if saved then + if not check(game:saveGame(), "saved fixture writes a real Emerald save") then return end + end + if not check(game:_hasContinueSave() == saved, prefix .. " expected continue availability before EXIT") then return end + local session, saveCount = game.session, saves + local rawBefore = SaveData.readSlotSource("emerald", slot) + U.tap(game, "start") + if not check(StartMenu.isOpen(), prefix .. " START opens Emerald menu") then return end + local exitIndex + for i, entry in ipairs(StartMenu.ENTRIES) do if entry.id == "exit" then exitIndex = i end end + if not check(exitIndex ~= nil, prefix .. " Emerald EXIT present") then return end + for _ = 1, #StartMenu.ENTRIES do + if StartMenu.cursor == exitIndex then break end + U.tap(game, "down") + end + U.tap(game, "a") + check(StartMenu._confirmExit and StartMenu._confirmCursor == 2, prefix .. " EXIT confirmation defaults NO") + check(U.still(game, DIR .. "/" .. string.format("%02d", (caseIndex - 1) * 3 + 1) .. "_" .. prefix .. "_exit_default_no.png"), prefix .. " default NO screenshot") + U.tap(game, "b") + check(StartMenu.isOpen() and game.session == session, prefix .. " EXIT B preserves field session") + U.tap(game, "a") + U.tap(game, "a") + check(StartMenu.isOpen() and game.session == session, prefix .. " EXIT NO preserves field session") + U.tap(game, "a") + U.tap(game, "up") + U.tap(game, "a") + local direct = game.boot and game.boot.phase == Boot.PHASE.TITLE and game.boot.custom.title + and not game.boot.custom.intro + if not check(direct, prefix .. " EXIT YES enters title directly") then return end + check(game.session == nil and not Runtime.isActive(), prefix .. " EXIT YES stops field runtime") + check(saves == saveCount and SaveData.readSlotSource("emerald", slot) == rawBefore, prefix .. " EXIT does not implicitly save") + check(game.boot.hasContinue == saved, prefix .. " EXIT preserves existing continue availability") + check(game.boot.custom.coldBoot == false, prefix .. " EXIT consumes cold boot") + if not check(waitUntil(function() + local title = game.boot.custom.title + if not title or title.phase ~= "phase3" then return false end + for _, sprite in pairs(title.m.ppu.sprites.sprites) do + if sprite.inUse and sprite.y == 108 and sprite.data[0] == 1 and not sprite.invisible then return true end + end + return false + end, 5), prefix .. " Emerald title settled") then return end + check(U.still(game, DIR .. "/" .. string.format("%02d", (caseIndex - 1) * 3 + 2) .. "_" .. prefix .. "_exit_title.png"), prefix .. " settled title screenshot") + U.tap(game, "start") + if not check(waitUntil(function() + local menu = game.boot.custom.menu + return game.boot.phase == Boot.PHASE.MENU and menu and menu.state == "input" + end, 4), prefix .. " title START reaches settled main menu") then return end + local menu = game.boot.custom.menu + check(menu.hasContinue == saved and menu.items[1] == (saved and "CONTINUE" or "NEW_GAME"), prefix .. " main menu offers correct first action") + if saved then check(menu.info and menu.info.name == "BRENDAN", "saved main menu retains trainer metadata") end + check(U.still(game, DIR .. "/" .. string.format("%02d", (caseIndex - 1) * 3 + 3) .. "_" .. prefix .. "_main_menu.png"), prefix .. " main menu screenshot") + end + end, debug.traceback) + if SaveData and originalSave then SaveData.save = originalSave end + if not ok then check(false, tostring(err)) end + print((failures == 0 and "PASS" or "FAIL") .. " em_start_menu_exit_90001 failures=" .. failures) + love.event.quit(failures == 0 and 0 or 1) +end diff --git a/tests/drivers/game3_collision_npc_edge.lua b/tests/drivers/game3_collision_npc_edge.lua index 5b2f1b6e..309ee114 100644 --- a/tests/drivers/game3_collision_npc_edge.lua +++ b/tests/drivers/game3_collision_npc_edge.lua @@ -90,7 +90,7 @@ return function(game) "Naomi wanders up and down from the sealed band at (14,5)") U.wait(20) - U.shot(game, DIR .. "/collision_npc_edge_01_naomi_on_the_band.png") + U.still(game, DIR .. "/collision_npc_edge_01_naomi_on_the_band.png") local visited = {} local shotSouth = false @@ -99,7 +99,7 @@ return function(game) visited[naomi.cellX .. "," .. naomi.cellY] = true visited[naomi.targetX .. "," .. naomi.targetY] = true if not shotSouth and naomi.cellY == 6 and not naomi.moving then - U.shot(game, DIR .. "/collision_npc_edge_02_naomi_stepped_south.png") + U.still(game, DIR .. "/collision_npc_edge_02_naomi_stepped_south.png") shotSouth = true end end diff --git a/tests/drivers/game3_cycling_fly_bug2564.lua b/tests/drivers/game3_cycling_fly_bug2564.lua new file mode 100644 index 00000000..d9f942f4 --- /dev/null +++ b/tests/drivers/game3_cycling_fly_bug2564.lua @@ -0,0 +1,122 @@ +local U = require("tests.drivers.util") +local DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/game3_cycling_fly_bug2564" +local failures = 0 +local function check(ok, label) + print((ok and "PASS " or "FAIL ") .. label) + if not ok then failures = failures + 1 end + return ok +end +local function finish() + print((failures == 0 and "PASS " or "FAIL ") .. "cycling_fly_bug2564") + love.event.quit(failures == 0 and 0 or 1) +end + +return function(game) + for _ = 1, 900 do + if game.phase == "boot" and game.boot then break end + U.wait(1) + end + if not check(game.boot ~= nil, "cycling Fly boot reached") then return finish() end + game:_handleBootAction({ action = "new_game", name = "RED", gender = 0 }) + U.wait(240) + local Runtime = require("src.core.game3.runtime") + local session = Runtime.getSession() + if not check(session ~= nil, "cycling Fly field reached") then return finish() end + local Map = require("src.core.game3.map") + local Player = require("src.core.game3.player") + local Field = require("src.core.game3.field") + local Flags = require("src.core.game3.scripting.flags") + local Space = require("src.core.game3.scripting.space") + local Message = require("src.ui.game3.message") + local Fade = require("src.ui.game3.fade") + local ItemUse = require("src.core.game3.item_use") + local Bag = require("src.core.game3.bag") + local Party = require("src.core.game3.party") + local C = require("src.core.game3.constants").of(session.version) + local rse = session.version == "emerald" + local defs = Flags.forVersion(session.version) + local road = assert(defs.IDS[rse and "FLAG_SYS_CYCLING_ROAD" or "FLAG_SYS_ON_CYCLING_ROAD"]) + local scene = not rse and assert(defs.VAR_IDS.VAR_MAP_SCENE_ROUTE16) + local bike = C:require("items", rse and "ITEM_MACH_BIKE" or "ITEM_BICYCLE") + Bag.add(session.bag, bike, 1) + if not rse then Flags.setFlag(Space.store, nil, assert(defs.IDS.FLAG_GOT_BICYCLE), true) end + local function waitFor(pred, limit) + for _ = 1, limit do + if pred() then return true end + U.wait(1) + end + return pred() + end + local function settle() + return waitFor(function() + if Message.isOpen() then + if Message.isTyping() then Message.skipReveal() end + table.insert(game.input.pressQueue, "a") + end + return not Field.locked and not Fade.active and not Player.moving + and not Message.isOpen() and not (Space.vm and Space.vm:isRunning()) + end, 600) + end + local function holdUntil(dir, pred, limit) + local reached = false + for _ = 1, limit do + if pred() then reached = true break end + table.insert(game.input.pressQueue, dir) + game.input.state[dir] = true + U.wait(1) + end + game.input.state[dir] = false + return reached or pred() + end + local gate = rse and "EM_ROUTE110_SEASIDE_CYCLING_ROAD_NORTH_ENTRANCE" + or "FR_ROUTE_16_NORTH_ENTRANCE_1F" + Map.load(nil, game, gate, { x = rse and 6 or 3, y = rse and 4 or 12, + facing = rse and "right" or "left" }) + if not check(settle(), "cycling gate scripts settle") then return finish() end + check(game.data.maps[gate].bikingAllowed == 1, "cycling gate permits riding indoors") + check(ItemUse.useBike(session, bike) and Player.biking, "cycling gate bike mounts") + U.still(game, DIR .. "/cycling_gate_bike.png") + if rse then + if not check(holdUntil("right", function() + return Player.targetX == 12 and Player.targetY == 4 + end, 240), "Emerald gate crosses BikeCheck and reaches east exit column") then return finish() end + end + if not check(holdUntil(rse and "down" or "left", function() return session.map ~= gate end, 600), + "cycling gate enters road through coordinate script and warp") then return finish() end + if not check(settle(), "cycling road arrival settles") then return finish() end + check(Flags.getFlag(Space.store, nil, road), "cycling road flag armed by real gate scripts") + if scene then check(Flags.getVar(Space.store, nil, scene) == 1, "Route 16 scene armed") end + check(Player.biking, "cycling road requires bike before Fly") + for _, eo in ipairs(require("src.core.game3.objects").forDraw()) do eo.sight = 0 end + session.party = {} + Party.giveMon(session, C:require("species", rse and "SPECIES_SWELLOW" or "SPECIES_CHARIZARD"), 40) + local section = rse and "MAPSEC_MAUVILLE_CITY" or "MAPSEC_CELADON_CITY" + local dest = assert(Field.flyDestination(section)) + check(Field.flyTo(section, session.party[1]), "cycling road Fly starts") + if not check(waitFor(function() + return session.map == dest.map and not Field._flyLanding and not Field.locked and not Fade.active + end, 2400), "cycling Fly landing finishes") then return finish() end + check(not Flags.getFlag(Space.store, nil, road), "Fly clears cycling flag") + if scene then check(Flags.getVar(Space.store, nil, scene) == 0, "Fly clears Route 16 scene") end + check(not Player.biking and not session.biking and not game.save.biking, "Fly lands on foot in every state mirror") + U.still(game, DIR .. "/fly_city_on_foot.png") + local center = rse and "EM_MAUVILLE_CITY_POKEMON_CENTER_1F" or "FR_CELADON_CITY_POKEMON_CENTER_1F" + if not check(holdUntil("up", function() return session.map == center end, 300), + "walking north enters real Center door") then return finish() end + if not check(settle(), "Center warp finishes") then return finish() end + check(game.data.maps[center].bikingAllowed == 0 and not Player.biking and not session.biking, + "Center remains on foot after cycling road Fly") + U.still(game, DIR .. "/center_on_foot.png") + if not check(holdUntil("down", function() return session.map == dest.map end, 300), + "Center exit returns to city") then return finish() end + if not check(settle(), "Center exit settles") then return finish() end + check(ItemUse.useBike(session, bike) and Player.biking, "city bike mounts after cycling road Fly") + U.still(game, DIR .. "/city_bike_toggle.png") + check(ItemUse.useBike(session, bike) and not Player.biking, "city bike dismounts after cycling road Fly") + if rse then + local acro = C:require("items", "ITEM_ACRO_BIKE") + check(ItemUse.useBike(session, acro) and Player.biking and Player.bikeType == "acro", "city Acro Bike mounts") + check(ItemUse.useBike(session, acro) and not Player.biking, "city Acro Bike dismounts") + end + finish() +end diff --git a/tests/drivers/game3_message_speed_2570.lua b/tests/drivers/game3_message_speed_2570.lua new file mode 100644 index 00000000..950436b8 --- /dev/null +++ b/tests/drivers/game3_message_speed_2570.lua @@ -0,0 +1,109 @@ +local U = require("tests.drivers.util") +local DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/game3_message_speed_2570" +local failures = 0 +local function check(ok, label) + print((ok and "PASS " or "FAIL ") .. label) + if not ok then failures = failures + 1 end + return ok +end +local function finish() + print((failures == 0 and "PASS " or "FAIL ") .. "message_speed_2570") + love.event.quit(failures == 0 and 0 or 1) +end + +return function(game) + for _ = 1, 900 do + if game.phase == "boot" and game.boot then break end + U.wait(1) + end + if not check(game.boot ~= nil, "message speed boot reached") then return finish() end + game:_handleBootAction({ action = "new_game", name = "RED", gender = 0 }) + U.wait(240) + local Runtime = require("src.core.game3.runtime") + local session = Runtime.getSession() + if not check(session ~= nil, "message speed field reached") then return finish() end + local Options = require("src.core.game3.options") + local Message = require("src.ui.game3.message") + local Objects = require("src.core.game3.objects") + local Player = require("src.core.game3.player") + local Field = require("src.core.game3.field") + local Space = require("src.core.game3.scripting.space") + local Map = require("src.core.game3.map") + local Flags = require("src.core.game3.scripting.flags") + local Fade = require("src.ui.game3.fade") + local rse = session.version == "emerald" + local defs = Flags.forVersion(session.version) + local mapId = rse and "EM_OLDALE_TOWN_POKEMON_CENTER_1F" or "FR_VIRIDIAN_CITY_POKEMON_CENTER_1F" + local function waitFor(pred, limit) + for _ = 1, limit do + if pred() then return true end + U.wait(1) + end + return pred() + end + local realShow = Message.show + local observed + Message.show = function(text, opts) + local result = realShow(text, opts) + observed = { first = Message._pages[1], count = #Message._pages, typing = Message.isTyping() } + return result + end + local cases = rse and { + { id = 2, name = "oldale_gentleman" }, + { id = 3, name = "oldale_boy" }, + { id = 4, name = "oldale_girl_unavailable", dex = false }, + { id = 4, name = "oldale_girl_available", dex = true }, + } or { + { id = 2, name = "viridian_gentleman" }, + { id = 3, name = "viridian_boy" }, + } + for _, case in ipairs(cases) do + for speed = 0, 2 do + Options.set(session, "textSpeed", speed) + if rse and case.dex ~= nil then + Flags.setFlag(Space.store, nil, assert(defs.IDS.FLAG_SYS_POKEDEX_GET), case.dex) + end + Map.load(nil, game, mapId, { x = 7, y = 7, facing = "up" }) + if not check(waitFor(function() + return not Field.locked and not Fade.active and not (Space.vm and Space.vm:isRunning()) + end, 600), case.name .. " map settles") then Message.show = realShow return finish() end + local eo = Objects.find(case.id) + if not check(eo ~= nil, case.name .. " NPC exists") then Message.show = realShow return finish() end + eo.frozen = true + Player.reset(eo.cellX, eo.cellY + 1, "up") + Player.syncToHost(game) + observed = nil + U.tap(game, "a") + if not check(waitFor(function() return observed ~= nil and Message.isOpen() end, 120), + case.name .. " opens from one A") then Message.show = realShow return finish() end + check(observed.count >= 2, case.name .. " cached ROM text contains multiple paragraphs") + check(observed.typing, case.name .. " option " .. speed .. " starts by typing") + check(Message._page == 1 and Message.currentPage() == observed.first, + case.name .. " option " .. speed .. " preserves first paragraph after opening A") + if not check(waitFor(Message.isWaiting, 1600), case.name .. " first paragraph prints") then + Message.show = realShow return finish() + end + if speed == 0 then U.still(game, DIR .. "/" .. case.name .. "_first.png") end + for page = 2, observed.count do + U.tap(game, "a") + check(Message._page == page, case.name .. " fresh A advances to paragraph " .. page) + if not check(waitFor(Message.isWaiting, 1600), case.name .. " paragraph " .. page .. " prints") then + Message.show = realShow return finish() + end + end + if speed == 0 then U.still(game, DIR .. "/" .. case.name .. "_last.png") end + for _ = 1, 120 do + if not Message.isOpen() and not (Space.vm and Space.vm:isRunning()) and not Field.locked then break end + U.tap(game, "a") + U.wait(2) + end + check(not Message.isOpen() and not (Space.vm and Space.vm:isRunning()) and not Field.locked, + case.name .. " script terminates and unlocks after final paragraph") + end + end + Message.show = realShow + Message.show("Instant", { speed = 0 }) + check(Message.isWaiting(), "explicit speed zero remains instant in live field printer") + Message.close() + finish() +end diff --git a/tests/drivers/game3_music_transition.lua b/tests/drivers/game3_music_transition.lua new file mode 100644 index 00000000..f91bb843 --- /dev/null +++ b/tests/drivers/game3_music_transition.lua @@ -0,0 +1,207 @@ +local U = require("tests.drivers.util") +local DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/game3_music_transition" + +local function le16(n) + return string.char(n % 256, math.floor(n / 256) % 256) +end +local function le32(n) + return le16(n % 65536) .. le16(math.floor(n / 65536)) +end +local function writeWav(path, sd) + local data, rate, channels = sd:getString(), sd:getSampleRate(), sd:getChannelCount() + local file = assert(io.open(path, "wb")) + file:write("RIFF", le32(36 + #data), "WAVEfmt ", le32(16), le16(1), le16(channels), + le32(rate), le32(rate * channels * 2), le16(channels * 2), le16(16), "data", le32(#data), data) + file:close() +end +local function peak(sd) + local value = 0 + for i = 0, sd:getSampleCount() - 1 do + for ch = 1, sd:getChannelCount() do value = math.max(value, math.abs(sd:getSample(i, ch))) end + end + return value +end + +return function(game) + local failures = 0 + local deadline = love.timer.getTime() + 25 + local Audio, realOut, realCmd, realSource + local function check(ok, label) + print((ok and "PASS " or "FAIL ") .. label) + if not ok then failures = failures + 1 end + return ok + end + local function waitUntil(pred, seconds) + local untilTime = math.min(deadline, love.timer.getTime() + seconds) + while love.timer.getTime() < untilTime do + if pred() then return true end + U.wait(1) + end + return pred() + end + local ok, err = xpcall(function() + if not check((os.getenv("POKEPORT_IDENTITY") or ""):match("^bsa0930%-") ~= nil, + "isolated campaign identity") then return end + game.driverSpeed = 1 + game.speedOverride = 1 + if not check(waitUntil(function() return game.boot ~= nil end, 4), "Gen3 boot ready") then return end + local Version = require("src.core.GameVersion") + local version = Version.get() + local emerald = version == "emerald" + local mapA, mapB = emerald and "EM_LITTLEROOT_TOWN" or "FR_PALLET_TOWN", + emerald and "EM_ROUTE101" or "FR_ROUTE_1" + local x, y = emerald and 5 or 10, emerald and 9 or 8 + game:_handleBootAction({ action = "new_game", name = "RED", + start = { map = mapA, x = x, y = y, facing = "down" } }) + Audio = require("src.core.game3.audio") + local Map = require("src.core.game3.map") + local Runtime = require("src.core.game3.runtime") + local Message = require("src.ui.game3.message") + local Field = require("src.core.game3.field") + local Hud = require("src.ui.game3.hud") + local Fade = require("src.ui.game3.fade") + local adapter = require("src.core.game3.scripting.adapters").host(nil, game, nil) + if not check(waitUntil(function() + if Message.isOpen() then U.tap(game, "b") end + return not Field.locked and not Message.isOpen() and not Hud.busy() and not Fade.active + end, 3), "Gen3 field settled") then return end + print(string.format("AUDIO ready=%s root=%s pack=%s worker=%s", tostring(Audio._ready), + tostring(Audio._root), tostring(Audio._pack ~= nil), tostring(Audio._worker ~= nil and Audio._worker ~= false))) + if not check(waitUntil(Audio.isReady, 3), "real audio pack ready") then return end + local defs = game.data and game.data.maps + local rawA, rawB = defs and defs[mapA] and defs[mapA].music, defs and defs[mapB] and defs[mapB].music + local a, b = Audio.resolveSong(rawA), Audio.resolveSong(rawB) + print(string.format("MAP SONGS A=%s raw=%s id=%s B=%s raw=%s id=%s", mapA, tostring(rawA), + tostring(a), mapB, tostring(rawB), tostring(b))) + if not check(type(a) == "number" and type(b) == "number" and a ~= b and Audio.songInfo(a) + and Audio.songInfo(b), "cached neighboring maps resolve distinct BGM") then return end + local ff = Audio.songs().MUS_LEVEL_UP + if not check(ff and waitUntil(function() return Audio._fanfareSd[ff] ~= nil end, 4), + "real fanfare PCM baked") then return end + + local captured, commands = {}, {} + local messages = setmetatable({}, { __mode = "k" }) + realOut, realCmd, realSource = Audio._outCh, Audio._cmdCh, Audio._bgmSource + if not check(realOut and realCmd and realSource, "real worker and queueable source ready") then return end + Audio._outCh = setmetatable({}, { __index = function(_, key) + if key == "pop" then return function() + local msg = realOut:pop() + if type(msg) == "table" and msg.data then messages[msg.data] = msg end + return msg + end end + return function(_, ...) return realOut[key](realOut, ...) end + end }) + Audio._cmdCh = setmetatable({}, { __index = function(_, key) + if key == "push" then return function(_, msg) + commands[#commands + 1] = msg + return realCmd:push(msg) + end end + return function(_, ...) return realCmd[key](realCmd, ...) end + end }) + Audio._bgmSource = setmetatable({}, { __index = function(_, key) + if key == "queue" then return function(_, sd) + local queued = realSource:queue(sd) + local msg = messages[sd] + if queued ~= false and msg then + captured[#captured + 1] = { gen = msg.gen, epoch = msg.epoch, at = msg.at, sd = sd, peak = peak(sd) } + end + return queued + end end + local value = realSource[key] + if type(value) ~= "function" then return value end + return function(_, ...) return value(realSource, ...) end + end }) + local function go(map) + Map.load(nil, game, map, { x = x, y = y, facing = "down" }) + return Runtime.getSession().map == map + end + local function pcm(label, from) + local packet + local found = waitUntil(function() + for i = (from or 0) + 1, #captured do + local c = captured[i] + if c.gen == a and c.epoch == Audio._bgmEpoch and c.peak > 0 then packet = c; return true end + end + return false + end, 3) + check(found and Audio._bgmGen == a and Audio._bgmSource:isPlaying(), label .. " queues audible latest A PCM") + if packet then + writeWav(DIR .. "/" .. label .. ".wav", packet.sd) + print(string.format("PCM %s gen=%s epoch=%s at=%s samples=%d peak=%.6f", + label, tostring(packet.gen), tostring(packet.epoch), tostring(packet.at), packet.sd:getSampleCount(), packet.peak)) + end + return found + end + check(U.still(game, DIR .. "/01_initial_map.png"), "initial map screenshot") + for _, speed in ipairs({ 1, 200 }) do + game.speedOverride = speed + local prefix = version .. "_" .. speed .. "x" + check(game:logicSpeed() == speed, prefix .. " uses requested simulation speed") + check(go(mapA), prefix .. " starts on map A") + Audio.playSong(a, { restart = true }) + if not pcm(prefix .. "_before", #captured) then return end + local epoch, count = Audio._bgmEpoch, #commands + Audio.playSong(a) + check(Audio._bgmEpoch == epoch and #commands == count, prefix .. " stable same-song avoids restart") + + local from = #captured + Audio.fadeOutAndPlay(b, 1) + if not emerald then + Audio.fadeDefaultBgm(1) + check(Audio._fadeOut == nil and Audio._currentSong.id == a, prefix .. " default map replaces pending B fade") + end + check(go(mapA), prefix .. " reenters A during pending fade to B") + if not pcm(prefix .. "_fade_return", from) then return end + check(Audio._fadeOut == nil and Audio._currentSong.id == a, prefix .. " newer A owns interrupted fade") + + from = #captured + Audio.playFanfare(ff) + check(Audio._fanfareActive and Audio._fanfareSource and Audio._fanfareSource:isPlaying(), prefix .. " real fanfare starts") + if not emerald then + Audio.changeMusicTo(b) + Audio.changeMusicTo(a) + check(Audio._fanfareDeferred == a, prefix .. " changeMusicTo replaces deferred B with A") + Audio.bikeMusic(true, true) + Audio.bikeMusic(false) + check(Audio._fanfareDeferred == a, prefix .. " bike dismount replaces deferred cycling with A") + Audio.changeMusicTo(b) + Audio.fadeDefaultBgm(1) + check(Audio._fanfareDeferred == a and Audio._fanfareActive and Audio._bgmPaused, + prefix .. " default map replaces deferred B while preserving fanfare") + end + check(go(mapB) and go(mapA), prefix .. " crosses B and returns A during fanfare") + check(Audio._fanfareDeferred == a, prefix .. " latest map A replaces deferred B") + Audio.fadeOutAndPlay(b, 1) + check(Audio.currentMapMusic() == b, prefix .. " script fixture defers older B during fanfare") + epoch, count = Audio._bgmEpoch, #commands + adapter.playBgm(a) + check(Audio.currentMapMusic() == a and Audio._fanfareDeferred == nil, + prefix .. " actual script playBgm(A) supersedes fanfare-deferred B") + check(Audio._fanfareActive and Audio._bgmPaused and Audio._bgmEpoch == epoch and #commands == count, + prefix .. " actual script same-A preserves fanfare and physical continuation") + if not check(waitUntil(function() return not Audio._fanfareActive end, 4), prefix .. " fanfare completes") then return end + check(Audio.currentMapMusic() == a, prefix .. " actual script fanfare finishes on latest A") + if not pcm(prefix .. "_fanfare_return", from) then return end + check(U.still(game, DIR .. "/" .. prefix .. "_returned_map.png"), prefix .. " returned map screenshot") + + from = #captured + Audio.fadeOutBgm(1) + Audio.playSong(a) + if not pcm(prefix .. "_standalone_fade", from) then return end + check(Audio._fadeOut == nil and Audio._currentSong.id == a, prefix .. " same-song cancels standalone fade") + + Audio.playFanfare(ff) + Audio.playSong(0) + count = #commands + if not check(waitUntil(function() return not Audio._fanfareActive end, 4), prefix .. " stopped-BGM fanfare completes") then return end + local revived = false + for i = count + 1, #commands do if commands[i].cmd == "play" then revived = true end end + check(Audio._bgmGen == nil and Audio._currentSong == nil and not Audio._bgmSource:isPlaying() + and not revived, prefix .. " explicit stop stays silent without revived play") + end + end, debug.traceback) + if Audio and realOut then Audio._outCh, Audio._cmdCh, Audio._bgmSource = realOut, realCmd, realSource end + if not ok then check(false, tostring(err)) end + print((failures == 0 and "PASS" or "FAIL") .. " game3_music_transition failures=" .. failures) + love.event.quit(failures == 0 and 0 or 1) +end diff --git a/tests/drivers/game3_surf_connections.lua b/tests/drivers/game3_surf_connections.lua index 87018211..c0f11318 100644 --- a/tests/drivers/game3_surf_connections.lua +++ b/tests/drivers/game3_surf_connections.lua @@ -61,7 +61,7 @@ return function(game) and session.x == x and session.y == y, label) end local function shot(name) - return result(U.shot(game, DIR .. "/2394_" .. name .. ".png"), "shot_" .. name) + return result(U.still(game, DIR .. "/2394_" .. name .. ".png"), "shot_" .. name) end local function phases(name) U.wait(90) diff --git a/tests/drivers/launch_update_mods_bug2037.lua b/tests/drivers/launch_update_mods_bug2037.lua index 59f6124a..3a4a4e81 100644 --- a/tests/drivers/launch_update_mods_bug2037.lua +++ b/tests/drivers/launch_update_mods_bug2037.lua @@ -84,12 +84,15 @@ return function(game) end local imp - local pending = nil + local drawSerial = 0 + local Transition = require("src.ui.kit.Transition") + local realDraw = love.draw love.draw = function() if imp then imp:draw() end - if pending then - local path = pending - pending = nil + drawSerial = drawSerial + 1 + if game.capturePath then + local path = game.capturePath + game.capturePath = nil love.graphics.captureScreenshot(function(imagedata) local f = io.open(path, "wb") if f then f:write(imagedata:encode("png"):getString()) f:close() end @@ -107,15 +110,10 @@ return function(game) end end local function shot(name) - pending = dir .. "/" .. name - for _ = 1, 30 do - if not pending then break end - step(1) - end - step(2) - local f = io.open(dir .. "/" .. name, "rb") - U.log(f and "shot" or "FAIL shot", name) - if f then f:close() end + local before, deadline = drawSerial, love.timer.getTime() + 3 + while love.timer.getTime() < deadline and (drawSerial == before or Transition.active()) do U.wait(1) end + expect(drawSerial > before and not Transition.active(), "shot draw settled " .. name) + expect(U.still(game, dir .. "/" .. name), "shot " .. name) end imp = newLauncher() @@ -182,6 +180,7 @@ return function(game) LauncherMods.checkDependencies = realDeps Platform.canFetchRemote = realRemote imp = nil + love.draw = realDraw U.log("done") love.event.quit(failed and 1 or 0) end diff --git a/tests/drivers/launcher_emerald_release_90005.lua b/tests/drivers/launcher_emerald_release_90005.lua new file mode 100644 index 00000000..6a5fa705 --- /dev/null +++ b/tests/drivers/launcher_emerald_release_90005.lua @@ -0,0 +1,134 @@ +return function(game) + local U = require("tests.drivers.util") + local identity = os.getenv("POKEPORT_IDENTITY") + if not identity or identity:lower():find("pokemon-love2d", 1, true) then + print("FAIL launcher_emerald_release_90005 requires an isolated identity") + love.event.quit(1) + return + end + + local SaveData = require("src.core.SaveData") + local SecretGames = require("src.import.SecretGames") + local RomImporter = require("src.import.RomImporter") + local LauncherView = require("src.import.LauncherView") + local GameVersion = require("src.core.GameVersion") + local Kit = require("src.ui.kit.Kit") + local dir = assert(os.getenv("POKEPORT_SHOT_DIR"), "POKEPORT_SHOT_DIR is required") + local originalSecret = SaveData.loadOptions().secretEmerald == true + local realTime, now = os.time, 1790855999 + local realSaveOptions = SaveData.saveOptions + local originalDraw, originalUpdate = rawget(game, "draw"), rawget(game, "update") + local failures, drawn, writes = 0, 0, 0 + local imp + + local function check(ok, label) + print((ok and "PASS " or "FAIL ") .. label) + if not ok then failures = failures + 1 end + return ok + end + + local function finish() + os.time = realTime + SaveData.saveOptions = realSaveOptions + SaveData.saveOptions({ secretEmerald = originalSecret }) + game.draw, game.update = originalDraw, originalUpdate + print((failures == 0 and "PASS " or "FAIL ") .. "launcher_emerald_release_90005") + love.event.quit(failures == 0 and 0 or 1) + end + + os.time = function(t) + if t then return realTime(t) end + return now + end + SaveData.saveOptions({ secretEmerald = false }) + SecretGames.reload() + imp = RomImporter.new(function() end, { launcher = true, reduceMotion = true }) + if not check(imp.ready.emerald == true, "existing Emerald cache is ready") then + finish() + return + end + + local flags = { resizable = true, highdpi = true, fullscreen = false } + if os.getenv("POKEPORT_BACKGROUND") == "1" then + flags.x, flags.y, flags.borderless = -30000, -30000, true + end + love.window.setMode(1280, 720, flags) + game.draw = function() + imp:draw() + drawn = drawn + 1 + end + game.update = function(_, dt) imp:update(dt) end + + local function drawFrames(n) + for _ = 1, n do + local seen = drawn + for _ = 1, 4000 do + U.wait(1) + if drawn > seen then break end + end + if not check(drawn > seen, "launcher frame reached the renderer") then return false end + end + return true + end + + local function navHas(id) + for i = 1, Kit._navPrevN or 0 do + local slot = Kit._nav[i] + if slot and slot.id == id then return true end + end + return false + end + + local function shot(name) + if not drawFrames(2) then return false end + return check(U.still(game, dir .. "/" .. name .. ".png"), "capture " .. name) + end + + imp._gamePopup = true + local lockedTabs = LauncherView.gameTabs(imp) + check(#lockedTabs == 8 and not SecretGames.visible("emerald"), "before deadline Emerald remains hidden") + if not shot("90005_before_deadline_choose_game") then finish() return end + check(not navHas("gamepop-emerald"), "before deadline Choose game has no Emerald row") + + SaveData.saveOptions = function(...) + writes = writes + 1 + return realSaveOptions(...) + end + now = 1790856000 + imp:update(1 / 60) + local releasedTabs = LauncherView.gameTabs(imp) + check(#releasedTabs == 9 and releasedTabs ~= lockedTabs, "deadline refreshes the same launcher's cached tabs") + check(SaveData.loadOptions().secretEmerald == true and writes == 1, "deadline persists once") + check(imp._secretPopup == nil and imp._logoTaps == nil, "deadline opens no BLITZ popup") + if not shot("90005_deadline_choose_game_emerald") then finish() return end + check(navHas("gamepop-emerald") and not navHas("secret-ok"), "deadline Choose game includes Emerald without confirmation") + for _ = 1, 20 do imp:update(1 / 60) imp:_logoTap() end + check(writes == 1 and imp._secretPopup == nil, "repeated released updates and taps remain silent") + check(imp:_versionForSha1(GameVersion.info("emerald").sha1) == "emerald", "deadline recognizes Emerald ROM") + + SaveData.saveOptions = realSaveOptions + now = 1790855999 + SaveData.saveOptions({ secretEmerald = false }) + SecretGames.reload() + imp = RomImporter.new(function() end, { launcher = true, reduceMotion = true }) + for _ = 1, SecretGames.TAPS do imp:_logoTap() end + check(imp._secretPopup == true, "pre-deadline manual BLITZ mechanism is preserved") + if not shot("90005_before_deadline_manual_blitz") then finish() return end + check(navHas("secret-ok"), "manual BLITZ confirmation is rendered before release") + + now = 1790856000 + LauncherView.gameTabs(imp) + imp:update(1 / 60) + check(imp._secretPopup == nil and imp._logoTaps == nil, "deadline clears an already-open BLITZ even after another reader unlocks") + if not shot("90005_deadline_manual_blitz_dismissed") then finish() return end + check(not navHas("secret-ok"), "scheduled release leaves no BLITZ confirmation control") + + imp:_switchTab("emerald") + check(imp.tab == "emerald", "released launcher selects Emerald normally") + if not shot("90005_deadline_emerald_panel") then finish() return end + check(navHas("play-emerald"), "released Emerald panel has its Play control") + now = 1790855999 + SecretGames.reload() + check(SecretGames.visible("emerald"), "persisted release survives clock rollback") + finish() +end diff --git a/tests/engine/game3_audio_transition_test.lua b/tests/engine/game3_audio_transition_test.lua new file mode 100644 index 00000000..516f880e --- /dev/null +++ b/tests/engine/game3_audio_transition_test.lua @@ -0,0 +1,204 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.harness") +local check, eq = T.check, T.eq +love = require("tests.love_stub") +local Version = require("src.core.GameVersion") +local Audio = require("src.core.game3.audio") +local Song = require("src.core.game3.song_ids") +local Player = require("src.core.game3.m4a_player") +local Adapters = require("src.core.game3.scripting.adapters") + +local function channel() + local ch = { items = {} } + function ch:push(v) self.items[#self.items + 1] = v end + function ch:pop() return table.remove(self.items, 1) end + function ch:clear() self.items = {} end + return ch +end + +local function source() + local src = { playing = true, volume = 1, queued = {}, stops = 0 } + function src:stop() self.playing = false; self.stops = self.stops + 1; self.queued = {} end + function src:play() self.playing = true end + function src:isPlaying() return self.playing end + function src:setVolume(v) self.volume = v end + function src:getFreeBufferCount() return Player.BUFFER_COUNT end + function src:queue(data) self.queued[#self.queued + 1] = data; return true end + return src +end + +local function setup(version) + Version.set(version) + local songs = Song.forVersion(version) + local a = songs.MUS_LITTLEROOT or songs.MUS_PALLET + local b = songs.MUS_OLDALE or songs.MUS_VIRIDIAN_FOREST + assert(type(a) == "number" and type(b) == "number" and a ~= b) + Audio._ready, Audio._worker = true, true + Audio._pack = { index = { songs = {} } } + Audio._meta, Audio._fanfareCh, Audio._statusCh = nil, nil, nil + Audio._cmdCh, Audio._outCh = channel(), channel() + Audio._bgmSource, Audio._fanfareSource = source(), source() + Audio._currentSong, Audio._bgmGen = { id = a }, a + Audio._bgmEpoch, Audio._bgmQueuedAt, Audio._bgmBaseAt = 3, {}, 0 + Audio._bgmLocal, Audio._pendingBgm, Audio._bgmPaused = nil, nil, false + Audio._fadeOut, Audio._fadeIn = nil, nil + Audio._fanfareActive, Audio._fanfareFrames = false, 0 + Audio._fanfareRestore, Audio._fanfareDeferred, Audio._fanfarePending = nil, nil, nil + Audio._waitFanfareCb, Audio._suspended, Audio._mapSong = nil, false, a + return a, b +end + +local function fanfare(a) + Audio.pauseBgm() + Audio._fanfareActive, Audio._fanfareFrames, Audio._fanfareRestore = true, 1, a +end + +local function lastPlay() + for i = #Audio._cmdCh.items, 1, -1 do + local m = Audio._cmdCh.items[i] + if m.cmd == "play" then return m end + end +end + +for _, version in ipairs({ "emerald", "firered", "leafgreen" }) do + local a, b = setup(version) + Audio.playSong(a) + eq(Audio._bgmEpoch, 3, version .. " stable same-song keeps epoch") + eq(#Audio._cmdCh.items, 0, version .. " stable same-song sends no restart") + eq(Audio._bgmSource.stops, 0, version .. " stable same-song preserves source") + + a, b = setup(version) + fanfare(a) + local adapter = Adapters.host(nil, { data = { maps = {} } }, nil) + local epoch, count, callback = Audio._bgmEpoch, #Audio._cmdCh.items, 0 + local sourceStops = Audio._bgmSource.stops + Audio._waitFanfareCb = function() callback = callback + 1 end + adapter.playBgm(a) + eq(Audio._bgmEpoch, epoch, version .. " stable script same-A during fanfare preserves epoch") + eq(#Audio._cmdCh.items, count, version .. " stable script same-A during fanfare sends no restart") + Audio.fadeOutAndPlay(b, 1) + eq(Audio.currentMapMusic(), b, version .. " actual script fixture has fanfare-deferred B") + adapter.playBgm(a) + eq(Audio.currentMapMusic(), a, version .. " actual script playBgm(A) supersedes fanfare-deferred B") + check(Audio._fanfareDeferred == nil, version .. " actual script A clears obsolete deferred B") + check(Audio._fanfareActive and Audio._bgmPaused and Audio._fanfareSource:isPlaying(), + version .. " actual script same-A preserves fanfare lifetime and BGM pause") + eq(Audio._bgmEpoch, epoch, version .. " actual script same-A preserves physical epoch") + eq(#Audio._cmdCh.items, count, version .. " actual script same-A sends no restart") + eq(Audio._bgmSource.stops, sourceStops, version .. " actual script same-A preserves physical source") + Audio.update(1) + eq(Audio.currentMapMusic(), a, version .. " actual script fanfare resumes latest A") + eq(lastPlay(), nil, version .. " actual script fanfare never starts obsolete B") + check(Audio._bgmSource:isPlaying() and not Audio._bgmPaused, + version .. " actual script fanfare resumes audible A") + eq(callback, 1, version .. " actual script same-A completes fanfare wait once") + + a, b = setup(version) + Audio.fadeOutAndPlay(b, 1) + Audio.playSong(a) + Audio.update(1) + eq(Audio._currentSong and Audio._currentSong.id, a, version .. " newer A cancels pending B") + eq(lastPlay() and lastPlay().id, a, version .. " worker plays latest A") + check(Audio._fadeOut == nil, version .. " interrupted fade removed") + local stops = Audio._bgmSource.stops + Audio._outCh:push({ gen = a, epoch = Audio._bgmEpoch, data = "A PCM", at = 0, n = 16 }) + Audio.update(1) + check(Audio._bgmSource:isPlaying() and Audio._bgmSource.volume > 0, version .. " replacement source remains audible") + eq(Audio._bgmSource.stops, stops, version .. " old fade cannot stop replacement") + + a, b = setup(version) + Audio.fadeOutBgm(1) + eq(Audio.currentMapMusic(), 0, version .. " fade-to-stop reports logical silence") + Audio.playSong(a) + Audio.update(1) + eq(Audio._currentSong and Audio._currentSong.id, a, version .. " explicit same A cancels standalone fade") + Audio._outCh:push({ gen = a, epoch = Audio._bgmEpoch, data = "resumed PCM", at = 0, n = 16 }) + Audio.update(1) + check(Audio._bgmSource:isPlaying() and Audio._bgmSource.volume > 0, version .. " standalone fade replacement is audible") + + a, b = setup(version) + fanfare(a) + local callback = 0 + Audio._waitFanfareCb = function() callback = callback + 1 end + Audio._fanfareDeferred = b + Audio.playSong(0) + check(Audio._fanfareActive and Audio._fanfareSource:isPlaying(), version .. " BGM stop preserves fanfare lifetime") + Audio.update(1) + eq(Audio._currentSong, nil, version .. " stop during fanfare stays silent") + eq(Audio._bgmGen, nil, version .. " stop during fanfare clears generation") + check(not Audio._bgmSource:isPlaying(), version .. " stop during fanfare stops source") + eq(lastPlay(), nil, version .. " fanfare completion sends no revived play") + eq(callback, 1, version .. " stopped BGM still completes fanfare wait") + + a, b = setup(version) + Audio.fadeOutAndPlay(b, 1) + Audio.playSong(0) + Audio._outCh:push({ gen = a, epoch = Audio._bgmEpoch, data = "stale PCM", at = 0, n = 16 }) + Audio.update(1) + eq(Audio._currentSong, nil, version .. " stop during fade stays silent") + check(not Audio._bgmSource:isPlaying() and #Audio._bgmSource.queued == 0, version .. " explicit stop rejects old queued PCM") + + a, b = setup(version) + Audio._outCh:push({ gen = a, epoch = 2, data = "old epoch", at = 0, n = 16 }) + Audio._outCh:push({ gen = b, epoch = 3, data = "old song", at = 0, n = 16 }) + Audio._outCh:push({ gen = a, epoch = 3, data = "current PCM", at = 16, n = 16 }) + Audio.pumpBgm() + eq(#Audio._bgmSource.queued, 1, version .. " pump rejects stale generation and epoch") + eq(Audio._bgmSource.queued[1], "current PCM", version .. " pump queues current PCM") + if version ~= "emerald" then + a, b = setup(version) + fanfare(a) + Audio.changeMusicTo(b) + Audio.changeMusicTo(a) + eq(Audio._fanfareDeferred, a, version .. " changeMusicTo replaces deferred B with A") + Audio.update(1) + eq(Audio._currentSong and Audio._currentSong.id, a, version .. " changeMusicTo fanfare resumes latest A") + + a, b = setup(version) + fanfare(a) + Audio.bikeMusic(true, true) + Audio.bikeMusic(false) + eq(Audio._fanfareDeferred, a, version .. " bike dismount replaces deferred cycling with A") + Audio.update(1) + eq(Audio._currentSong and Audio._currentSong.id, a, version .. " dismount fanfare resumes map A") + + a, b = setup(version) + Audio.fadeOutAndPlay(b, 1) + Audio.fadeDefaultBgm(1) + Audio.update(1) + eq(Audio._currentSong and Audio._currentSong.id, a, version .. " fadeDefaultBgm replaces pending B with map A") + + a, b = setup(version) + fanfare(a) + Audio.changeMusicTo(b) + Audio.fadeDefaultBgm(1) + eq(Audio._fanfareDeferred, a, version .. " fadeDefaultBgm replaces fanfare-deferred B with map A") + check(Audio._fanfareActive and Audio._bgmPaused, version .. " default map request preserves active fanfare pause") + Audio.update(1) + eq(Audio._currentSong and Audio._currentSong.id, a, version .. " default map request fanfare resumes A") + end +end + +do + local a, b = setup("emerald") + local session = { version = "emerald", map = "EM_LITTLEROOT_TOWN", x = 5, y = 5 } + package.loaded["src.core.game3.runtime"] = { getSession = function() return session end, + _game = { data = { maps = { EM_LITTLEROOT_TOWN = { music = a }, EM_OLDALE_TOWN = { music = b } } } } } + Audio.fadeOutAndPlay(b, 1) + Audio.mapLoadMusic({ mapId = session.map }) + Audio.update(1) + eq(Audio._currentSong and Audio._currentSong.id, a, "Emerald actual map policy replaces pending fade B with A") + a, b = setup("emerald") + fanfare(a) + session.map = "EM_OLDALE_TOWN" + Audio.mapLoadMusic({ mapId = session.map }) + eq(Audio.currentMapMusic(), b, "Emerald logical music reflects fanfare-deferred B") + session.map = "EM_LITTLEROOT_TOWN" + Audio.mapLoadMusic({ mapId = session.map }) + eq(Audio._fanfareDeferred, a, "Emerald return map replaces deferred B with A") + Audio.update(1) + eq(Audio._currentSong and Audio._currentSong.id, a, "Emerald fanfare resumes latest map A") + check(Audio._bgmSource:isPlaying() and not Audio._bgmPaused, "Emerald fanfare restores audible A") +end + +T.finish("game3_audio_transition_test") diff --git a/tests/engine/launcher_secret_emerald_test.lua b/tests/engine/launcher_secret_emerald_test.lua index 7d74f7a6..f7014ea5 100644 --- a/tests/engine/launcher_secret_emerald_test.lua +++ b/tests/engine/launcher_secret_emerald_test.lua @@ -4,6 +4,12 @@ local T = require("tests.harness") local check = T.check love = love or require("tests.love_stub") +local realTime, now = os.time, 1790855999 +os.time = function(t) + if t then return realTime(t) end + return now +end + love.graphics.setLineJoin = love.graphics.setLineJoin or function() end love.graphics.newShader = love.graphics.newShader or function() return {} end @@ -121,4 +127,83 @@ check(ids(LauncherView.gameTabs(fresh)):find(" emerald ", 1, true) ~= nil, "a new launcher lists emerald after unlock") check(fresh._logoTaps == nil, "the tap counter starts over with each launcher") +T.eq(SecretGames.EMERALD_RELEASE_AT, 1790856000, "release is October 1 at 12 UTC") + +SaveData.saveOptions({ secretEmerald = false, lastVersion = "blue" }) +SecretGames.reload() +local timed = newLauncher() +local lockedTabs = LauncherView.gameTabs(timed) +local lockedRev = SecretGames.rev +check(not ids(lockedTabs):find(" emerald ", 1, true), "one second early stays locked") +for _ = 1, SecretGames.TAPS do timed:_logoTap() end +check(timed._secretPopup == true, "a pre-release BLITZ popup can be open at the deadline") + +local realSaveOptions, writes = SaveData.saveOptions, 0 +SaveData.saveOptions = function(...) + writes = writes + 1 + return realSaveOptions(...) +end +now = 1790856000 +local releasedTabs = LauncherView.gameTabs(timed) +check(releasedTabs ~= lockedTabs, "deadline invalidates the already-cached game list") +T.eq(#releasedTabs, 9, "deadline exposes all nine games") +check(ids(releasedTabs):find(" emerald ", 1, true) ~= nil, "deadline includes Emerald") +T.eq(SecretGames.rev, lockedRev + 1, "scheduled unlock advances the revision once") +T.eq(writes, 1, "scheduled unlock persists once") +local releasedOptions = SaveData.loadOptions() +check(releasedOptions.secretEmerald == true, "scheduled unlock persists secretEmerald") +T.eq(releasedOptions.lastVersion, "blue", "unlock preserves unrelated launcher options") + +timed:update(1 / 60) +check(timed._secretPopup == nil and timed._logoTaps == nil, + "active launcher silently clears an already-open BLITZ popup and tap counter") +timed._gamePopup = true +rects = drawRects(timed) +check(rects["gamepop-emerald"] ~= nil and rects["secret-ok"] == nil, + "the active launcher draws Emerald without a BLITZ confirmation") +timed._gamePopup = nil +check(timed:_versionForSha1(emeraldSha) == "emerald", "release recognizes Emerald immediately") +for _ = 1, 10 do + timed:update(1 / 60) + timed:_logoTap() + T.eq(LauncherView.gameTabs(timed), releasedTabs, "released game list remains cached") +end +T.eq(writes, 1, "repeated frames and logo taps do not repeat persistence") +T.eq(SecretGames.rev, lockedRev + 1, "repeated frames do not repeat cache invalidation") +check(timed._secretPopup == nil and timed._logoTaps == nil, "released logo taps stay silent") + +now = 1790856001 +local releaseBoot = RomImporter.new(function() end, { launcher = true, initialTab = "emerald" }) +T.eq(releaseBoot.tab, "emerald", "a launcher opened after release accepts its initial Emerald tab") +SecretGames.reload() +check(SecretGames.visible("emerald"), "scheduled unlock survives a settings reload") +T.eq(writes, 1, "reloading the persisted unlock does not rewrite options") +now = 1790855999 +SecretGames.reload() +check(SecretGames.visible("emerald"), "a clock rollback cannot relock a persisted unlock") +T.eq(writes, 1, "clock rollback does not rewrite options") +check(ids(SecretGames.order({ launcher = false })):find(" emerald ", 1, true) ~= nil, + "scheduled availability preserves nonlauncher import recognition") + +SaveData.saveOptions = realSaveOptions +SaveData.saveOptions({ secretEmerald = false }) +SecretGames.reload() +local polling = newLauncher() +LauncherView.gameTabs(polling) +now = 1790856000 +polling:update(1 / 60) +check(SaveData.loadOptions().secretEmerald == true, + "an already-open launcher update alone applies the scheduled unlock") +check(ids(LauncherView.gameTabs(polling)):find(" emerald ", 1, true) ~= nil, + "an already-open launcher needs no restart to expose Emerald") + +now = 1790855999 +SaveData.saveOptions({ secretEmerald = false }) +SecretGames.reload() +now = 1790856000 +local initial = RomImporter.new(function() end, { launcher = true, initialTab = "emerald" }) +T.eq(initial.tab, "emerald", "a fresh deadline boot unlocks before initial tab validation") +check(SaveData.loadOptions().secretEmerald == true, "a fresh deadline boot persists the unlock") + +os.time = realTime T.finish("launcher secret emerald") diff --git a/tests/engine/launcher_skins_options_reads_bug2038.lua b/tests/engine/launcher_skins_options_reads_bug2038.lua index f43be47c..e2cc8f5a 100644 --- a/tests/engine/launcher_skins_options_reads_bug2038.lua +++ b/tests/engine/launcher_skins_options_reads_bug2038.lua @@ -21,8 +21,8 @@ SaveData.loadOptions = function() end SaveData.saveOptions = function(opts) local tc = type(opts) == "table" and type(opts.touchControls) == "table" - and opts.touchControls or {} - stored.enabled, stored.skin = tc.enabled, tc.skin + and opts.touchControls + if tc then stored.enabled, stored.skin = tc.enabled, tc.skin end return true end diff --git a/tests/game3_carried_actor_ghost_test.lua b/tests/game3_carried_actor_ghost_test.lua new file mode 100644 index 00000000..8c824c2b --- /dev/null +++ b/tests/game3_carried_actor_ghost_test.lua @@ -0,0 +1,225 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local Objects = require("src.core.game3.objects") +local Ghosts = require("src.core.game3.ghosts") +local Player = require("src.core.game3.player") +local Map = require("src.core.game3.map") +local Runtime = require("src.core.game3.runtime") +local Space = require("src.core.game3.scripting.space") +Space.bundle = { events = {} } +local game = { data = { maps = {} } } +Runtime.session = { version = "firered", map = "TEST_A", flags = {}, vars = {} } +Runtime._game = game +local layout = { width = 64, height = 64, collAt = function() return 0 end } +local first = { midLayout = layout, objects = { + { localId = 7, x = 10, y = 10 }, + { localId = 8, x = 12, y = 10 }, + { localId = 9, x = 15, y = 10 }, +} } +local empty = { midLayout = layout, objects = {} } +Objects.clear() +Ghosts.clear() +Objects.loadMap(game, "TEST_A", first) +Player.reset(10, 9, "down") +local boat, actor, npc = Objects.find(7), Objects.find(8), Objects.find(9) +local callbacks = { boat = 0, actor = 0, player = 0 } +local actions = { + { kind = "step", dir = "down", fast = true }, + { kind = "step", dir = "down", fast = true }, + { kind = "step", dir = "down", fast = true }, +} +Objects.startTrack(7, actions, function() callbacks.boat = callbacks.boat + 1 end) +Objects.startTrack(8, actions, function() callbacks.actor = callbacks.actor + 1 end) +Objects.startTrack(255, actions, function() callbacks.player = callbacks.player + 1 end) +npc.moving, npc.targetX, npc.targetY = true, npc.cellX, npc.cellY + 1 +npc.progress, npc.animClock, npc.stepFrames = 0, 0, 16 +npc.scriptBusy, npc.frozen = true, true + +local function shiftPlayer(dx, dy) + Player.cellX, Player.cellY = Player.cellX + dx, Player.cellY + dy + Player.targetX, Player.targetY = Player.targetX + dx, Player.targetY + dy + Player.px, Player.py = Player.px + dx * 16, Player.py + dy * 16 +end +local function transfer(from, to, dx, dy) + local carry = Objects.carryOut(dx, dy) + shiftPlayer(dx, dy) + Ghosts.capture(from) + local pool = Ghosts._pools[from] + for _, eo in pairs(pool.byId) do + T.check(eo ~= boat and eo ~= actor, "carried actors absent from departing ghost pool " .. from) + end + T.check(boat.scriptBusy and boat.frozen and actor.scriptBusy and actor.frozen, + "capture preserves held movement state " .. from) + Objects.loadMap(game, to, empty) + Objects.carryIn(carry) + Runtime.session.map = to + Map.world = { { id = "TEST_A", def = first, ox = 0, oy = 0 } } + if from ~= "TEST_A" then Map.world[#Map.world + 1] = { id = from, def = empty, ox = 0, oy = 0 } end + T.eq(Objects.find(Objects.foreignKey("TEST_A", 7)), boat, "foreign boat lookup survives " .. to) + T.eq(Objects.find(Objects.foreignKey("TEST_A", 8)), actor, "generic foreign lookup survives " .. to) +end +transfer("TEST_A", "TEST_B", 2, 0) +T.eq(Ghosts._pools.TEST_A.byId[9], npc, "ordinary departing NPC remains captured") +T.check(not npc.scriptBusy and not npc.frozen, "ordinary departing NPC is released") +local firstNpcY = npc.py +local function frame() + local before = boat.py + Objects.update(game) + local afterLive = boat.py + Ghosts.update(game) + T.eq(boat.py, afterLive, "ghost pass never moves live carried boat twice") + Player.tick(game) + T.eq(boat.py - Player.py, 16, "boat and player retain relative sailing pixels") + return boat.py - before +end +frame() +T.check(npc.py > firstNpcY, "ordinary ghost movement still advances") +transfer("TEST_B", "TEST_C", 3, 0) +for _ = 1, 30 do frame() end +T.eq(callbacks.boat, 1, "boat completion callback fires once") +T.eq(callbacks.actor, 1, "generic actor completion callback fires once") +T.eq(callbacks.player, 1, "player completion callback fires once") +T.check(Objects._tracks[Objects.foreignKey("TEST_A", 7)].done, "foreign boat track completes") +Ghosts.clear() +Objects.clear() + +local Collision = require("src.core.game3.collision") +local realConnection, realPairs = Collision.scriptConnection, pairs +Runtime.session.version = "emerald" +Collision._grid, Collision._widthCells, Collision._heightCells = { 0 }, 4, 8 +local seamLayout = { width = 4, height = 8, collAt = function() return 0 end } +local function contains(list, actor) + for _, eo in ipairs(list) do if eo == actor then return true end end + return false +end +for _, playerFirst in ipairs({ true, false }) do + Objects.clear() + Ghosts.clear() + local source = { midLayout = seamLayout, objects = { + { localId = 7, x = 3, y = 2 }, { localId = 9, x = -1, y = 4 }, + } } + local destination = { midLayout = seamLayout, objects = { { localId = 9, x = -1, y = 4 } } } + local from, seams = "SEAM_A", 0 + Runtime.session.map = from + Objects.loadMap(game, from, source) + Player.reset(3, 1, "right") + local vessel, outside = Objects.find(7), Objects.find(9) + T.check(not contains(Objects.forDraw(), outside), "ordinary off-map EO remains filtered") + local movement = { { kind = "sleep", frames = 1 } } + for _ = 1, 9 do movement[#movement + 1] = { kind = "step", dir = "right", fast = true } end + local done = { boat = 0, player = 0 } + Objects.startTrack(7, movement, function() done.boat = done.boat + 1 end) + Objects.startTrack(255, movement, function() done.player = done.player + 1 end) + local boatTrack, playerTrack = Objects._tracks[7], Objects._tracks[255] + pairs = function(t) + if t ~= Objects._tracks then return realPairs(t) end + local order = playerFirst and { 255, vessel.localId } or { vessel.localId, 255 } + local index = 0 + return function() + index = index + 1 + local id = order[index] + if id then return id, t[id] end + end + end + Collision.scriptConnection = function(_, fx, fy, dir) + T.eq(dir, "right", "script seam uses current movement direction") + seams = seams + 1 + local to = "SEAM_" .. seams + local carry = Objects.carryOut(-4, 0) + Ghosts.capture(from) + Objects.loadMap(game, to, destination) + Objects.carryIn(carry) + Runtime.session.map, from = to, to + Player.cellX, Player.cellY = fx - 4, fy + Player.px, Player.py = Player.cellX * 16, Player.cellY * 16 + Player.targetX, Player.targetY = Player.cellX, Player.cellY + return 0, fy + end + for frame = 1, 80 do + Objects.update(game) + Player.tick(game) + T.eq(vessel.px - Player.px, 0, "mid-track seam keeps horizontal boat/player pixels") + T.eq(vessel.py - Player.py, 16, "mid-track seam keeps vertical boat/player pixels") + if frame == 1 then + T.check(vessel.cellX == -1 and contains(Objects.forDraw(), vessel), + "visible foreign EO draws outside host map bounds") + T.check(not contains(Objects.forDraw(), Objects.find(9)), "ordinary destination off-map EO remains filtered") + vessel.hidden = true + T.check(not contains(Objects.forDraw(), vessel), "hidden foreign EO remains filtered") + vessel.hidden, vessel.invisible = false, true + T.check(not contains(Objects.forDraw(), vessel), "invisible foreign EO remains filtered") + vessel.invisible = false + T.eq(boatTrack.i, playerTrack.i, "seam advances each original track once in either order") + end + end + T.eq(seams, 3, "script track crosses three repeated seams") + T.eq(done.boat, 1, "seam boat callback fires once") + T.eq(done.player, 1, "seam player callback fires once") + T.check(boatTrack.done and playerTrack.done, "seam keeps both held tracks to completion") + pairs = realPairs +end +for _, mode in ipairs({ "removed", "track_replaced", "actor_replaced" }) do + Objects.clear() + Ghosts.clear() + Objects.loadMap(game, "GUARD_A", { midLayout = seamLayout, objects = { + { localId = 7, x = 3, y = 2 }, { localId = 8, x = 3, y = 4 }, + } }) + Runtime.session.map = "GUARD_A" + Player.reset(3, 1, "right") + local vessel, other = Objects.find(7), Objects.find(8) + local movement = { { kind = "sleep", frames = 1 }, { kind = "step", dir = "right", fast = true } } + local called = 0 + Objects.startTrack(7, movement) + Objects.startTrack(8, movement, function() called = called + 1 end) + Objects.startTrack(255, movement) + local original = Objects._tracks[8] + local replacement + pairs = function(t) + if t ~= Objects._tracks then return realPairs(t) end + local order, index = { 255, vessel.localId, other.localId }, 0 + return function() + index = index + 1 + local id = order[index] + if id then return id, t[id] end + end + end + Collision.scriptConnection = function(_, fx, fy) + local carry = Objects.carryOut(-4, 0) + Ghosts.capture("GUARD_A") + Objects.loadMap(game, "GUARD_B", { midLayout = seamLayout, objects = {} }) + Objects.carryIn(carry) + Runtime.session.map = "GUARD_B" + Player.cellX, Player.cellY = fx - 4, fy + Player.px, Player.py = Player.cellX * 16, Player.cellY * 16 + Player.targetX, Player.targetY = Player.cellX, Player.cellY + if mode == "removed" then + Objects.removeObject(other.localId) + elseif mode == "track_replaced" then + Objects.startTrack(other.localId, { { kind = "sleep", frames = 3 } }) + replacement = Objects._tracks[other.localId] + else + local copy = {} + for key, value in realPairs(other) do copy[key] = value end + Objects._byId[other.localId] = copy + end + return 0, fy + end + Objects.update(game) + Player.tick(game) + T.eq(original.i, 2, mode .. " snapshot never advances obsolete actor track") + T.eq(original.sleep, 1, mode .. " snapshot never consumes obsolete actor sleep") + T.eq(called, 0, mode .. " snapshot never fires obsolete actor callback") + if replacement then T.eq(replacement.sleep, 3, "replacement track waits until next frame") end + pairs = realPairs +end +Objects.clear() +Objects.startTrack(31, { { kind = "sleep", frames = 1 }, { kind = "sleep", frames = 1 } }, + function() callbacks.actor = callbacks.actor + 1 end) +Objects.update(game) +Objects.update(game) +T.eq(callbacks.actor, 2, "initially missing optional EO track still completes") +pairs, Collision.scriptConnection = realPairs, realConnection +Ghosts.clear() +Objects.clear() +T.finish("game3_carried_actor_ghost") diff --git a/tests/game3_cycling_road_bike_test.lua b/tests/game3_cycling_road_bike_test.lua index 0d5838ed..edc0b4f2 100644 --- a/tests/game3_cycling_road_bike_test.lua +++ b/tests/game3_cycling_road_bike_test.lua @@ -221,7 +221,28 @@ local dismountAction = Gen2Bike.tryBike({ }) check(dismountAction == "dismount", "Gen 2 Bike.tryBike allows dismount in INDOOR environment") +local Field = require("src.core.game3.field") +session = Runtime.getSession() or session +session.version = "firered" +session.flags = session.flags or {} +session.vars = session.vars or {} +session.store = session.store or { flags = {}, vars = {} } +Space.store = session.store +Field._game, Field._session = game, session +local defs = Flags.forVersion("firered") +local road, scene = defs.IDS.FLAG_SYS_ON_CYCLING_ROAD, defs.VAR_IDS.VAR_MAP_SCENE_ROUTE16 +Flags.setFlag(session.store, nil, road, true) +Flags.setFlag(session, nil, road, true) +Flags.setVar(session.store, nil, scene, 1) +Player.biking, session.biking, game.save.biking = true, true, true +Field.flyTo(nil, nil, { dest = { map = "FR_SAFFRON_CITY", x = 18, y = 22 } }) +check(not Flags.getFlag(session.store, nil, road) and not Flags.getFlag(session, nil, road), + "Cycling Road Fly clears every road flag store") +check(Flags.getVar(session.store, nil, scene) == 0, "Cycling Road Fly resets Route 16 scene") +Map.load(nil, game, "FR_SILPH_CO_1F", { x = 18, y = 21, facing = "up" }) +check(not Player.biking and not session.biking and not game.save.biking, + "indoor entry after Cycling Road Fly keeps avatar session and save on foot") + print(string.format("=== RESULTS: %d passed, %d failed ===", passed, failed)) if failed > 0 then os.exit(1) end - diff --git a/tests/game3_fly_cycling_state_test.lua b/tests/game3_fly_cycling_state_test.lua new file mode 100644 index 00000000..a9ebf646 --- /dev/null +++ b/tests/game3_fly_cycling_state_test.lua @@ -0,0 +1,72 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local GameVersion = require("src.core.GameVersion") +local Field = require("src.core.game3.field") +local Player = require("src.core.game3.player") +local Flags = require("src.core.game3.scripting.flags") +local Runtime = require("src.core.game3.runtime") +local Space = require("src.core.game3.scripting.space") +local Map = require("src.core.game3.map") +local realLoad = Map.load +local realHeal = package.loaded["src.core.game3.heal_locations"] +package.loaded["src.core.game3.heal_locations"] = { + normalizeSession = function() end, + model = function() return "heal_row" end, +} +Space.runImmediately = function() end + +for _, version in ipairs({ "firered", "leafgreen", "emerald" }) do + GameVersion.set(version) + local defs = Flags.forVersion(version) + local roadName = version == "emerald" and "FLAG_SYS_CYCLING_ROAD" or "FLAG_SYS_ON_CYCLING_ROAD" + local road, scene = assert(defs.IDS[roadName]), defs.VAR_IDS.VAR_MAP_SCENE_ROUTE16 + for _, mode in ipairs({ "fly", "field_move", "whiteout" }) do + local s = { version = version, flags = {}, vars = {}, party = {}, + store = { flags = {}, vars = {} }, biking = true, bikeType = "mach" } + local live = { version = version, flags = {}, vars = {}, + store = { flags = {}, vars = {} }, biking = true, bikeType = "acro" } + local script = { flags = {}, vars = {} } + local g = { session = s, save = { biking = true, bikeType = "mach", position = { biking = true } } } + Field._session, Field._game, Runtime.session, Runtime._game, Space.store = s, g, live, g, script + for _, store in ipairs({ s, s.store, script, live, live.store }) do + Flags.setFlag(store, nil, road, true) + store.flags[roadName] = true + if scene then + Flags.setVar(store, nil, scene, 1) + store.vars[tostring(scene)], store.vars.VAR_MAP_SCENE_ROUTE16 = 1, 1 + end + end + Player.biking, Player.bikeType, Player.surfing = true, "mach", true + local loads = 0 + Map.load = function(_, _, mapId) + loads = loads + 1 + for _, store in ipairs({ s, s.store, script, live, live.store }) do + T.check(not Flags.getFlag(store, nil, road) and store.flags[roadName] == nil, + version .. " " .. mode .. " clears road flag before map load") + if scene then + T.eq(Flags.getVar(store, nil, scene), 0, version .. " " .. mode .. " resets scene before map load") + T.eq(store.vars[tostring(scene)], nil, "scene decimal alias cleared") + T.eq(store.vars.VAR_MAP_SCENE_ROUTE16, nil, "scene symbolic alias cleared") + end + end + T.check(not Player.biking and not s.biking and not live.biking and not g.save.biking + and not g.save.position.biking, version .. " " .. mode .. " on foot at map load") + T.check(Player.bikeType == nil and s.bikeType == nil and live.bikeType == nil + and g.save.bikeType == nil, version .. " " .. mode .. " clears bike type") + s.map, live.map = mapId, mapId + end + local dest = { map = version == "emerald" and "EM_MAUVILLE_CITY" or "FR_CELADON_CITY", x = 1, y = 1 } + if mode == "fly" then + Field.flyTo(nil, nil, { dest = dest }) + else + Field.respawnAtHeal({ fieldMove = mode == "field_move", warp = dest }) + end + T.eq(loads, 1, version .. " " .. mode .. " loads one destination") + T.check(not Player.biking and not live.biking and not g.save.biking, + version .. " " .. mode .. " remains on foot after landing") + end +end +Map.load = realLoad +package.loaded["src.core.game3.heal_locations"] = realHeal +T.finish("game3_fly_cycling_state") diff --git a/tests/game3_message_speed_test.lua b/tests/game3_message_speed_test.lua new file mode 100644 index 00000000..ec32f36e --- /dev/null +++ b/tests/game3_message_speed_test.lua @@ -0,0 +1,75 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local GameVersion = require("src.core.GameVersion") +local Runtime = require("src.core.game3.runtime") +local Options = require("src.core.game3.options") +local Message = require("src.ui.game3.message") +local Hud = require("src.ui.game3.hud") +local TextIR = require("src.core.game3.scripting.text_ir") +local input = { + wasPressed = function(_, key) return key == "a" end, + isDown = function() return false end, +} +local game = { input = input } +local ir = TextIR.fromAscii("First paragraph.\\pSecond paragraph.") + +local function exercise(version, speed, text) + GameVersion.set(version) + local session = { version = version } + Runtime.session = session + Options.set(session, "textSpeed", speed) + Message.showStay(text) + T.check(Message.isTyping() and Message._page == 1 and Message._revealed == 0, + version .. " implicit option " .. speed .. " starts typing first paragraph") + local first = Message.currentPage() + Message.tick() + Hud.update(game, 1 / 60) + T.eq(Message._page, 1, version .. " opening A preserves first paragraph at option " .. speed) + T.eq(Message.currentPage(), first, "opening A keeps first paragraph text") + T.check(Message.isWaiting(), "opening A finishes first paragraph reveal") + Message.advance() + T.eq(Message._page, 2, "fresh A advances exactly once") + T.check(Message.isTyping(), "second paragraph starts typing") + Message.close() +end + +for _, version in ipairs({ "firered", "leafgreen", "emerald" }) do + for speed = 0, 2 do + exercise(version, speed, ir) + Message.showStay("ABC") + Message.tick() + T.eq(Message._revealed, 1, "first glyph prints on first tick") + local delay = ({ 8, 4, 1 })[speed + 1] + for _ = 1, delay - 1 do Message.tick() end + T.eq(Message._revealed, 1, version .. " no-input glyph interval " .. delay) + Message.tick() + T.eq(Message._revealed, 2, version .. " next glyph after " .. delay .. " ticks") + Message.showStay(ir, { speed = 0 }) + T.check(Message.isWaiting() and Message._revealed == Message._total, + version .. " explicit speed zero stays instant at option " .. speed) + Message.close() + end +end + +GameVersion.set("firered") +local bundle = require("tests.game3_cache").bundle("scripts/events.lua") +if bundle then + local event = assert(bundle.events.FR_VIRIDIAN_CITY_POKEMON_CENTER_1F) + for _, npc in ipairs({ { id = 2, name = "Viridian gentleman" }, { id = 3, name = "Viridian boy" } }) do + local scriptKey + for _, object in ipairs(event.objects) do + if object.localId == npc.id then scriptKey = object.scriptKey break end + end + local script = assert(bundle.scripts[assert(scriptKey, npc.name .. " event script")]) + local command = assert(script[1]) + assert(command.op == "loadword" and command.dest == 0, npc.name .. " cached message pointer") + local text = assert(bundle.text[command.value], npc.name .. " cached text IR") + T.eq(#TextIR.splitPages(TextIR.toTextBox(text, { maxWidth = 208 })), 2, + npc.name .. " cached literal text has two paragraphs") + exercise("firered", 0, text) + end +else + print("[skip] cached ROM message acceptance: " .. tostring(require("tests.game3_cache").reason)) +end +T.finish("game3_message_speed") diff --git a/tests/game3_pause_and_main_menu_exit_test.lua b/tests/game3_pause_and_main_menu_exit_test.lua index 68a7c501..eabfbfce 100644 --- a/tests/game3_pause_and_main_menu_exit_test.lua +++ b/tests/game3_pause_and_main_menu_exit_test.lua @@ -24,9 +24,11 @@ local Game3 = require("src.core.Game3") -- Test 1: StartMenu EXIT flow and confirmation do local returnToTitleCalled = false + local returnToTitleOptions local dummyGame = { - returnToTitle = function() + returnToTitle = function(_, opts) returnToTitleCalled = true + returnToTitleOptions = opts end, } local session = { name = "RED", game = dummyGame } @@ -85,6 +87,7 @@ do check(not StartMenu._confirmExit, "Exited confirm mode on YES") check(not StartMenu.isOpen(), "StartMenu closed on YES") check(returnToTitleCalled, "returnToTitle was called on YES") + check(returnToTitleOptions and returnToTitleOptions.skipIntro, "EXIT YES requests direct title") end -- Test 2: Game3:returnToTitle cleans up and sets boot to title phase @@ -271,4 +274,61 @@ do Runtime.stop(nil, game) end +do + local Version = require("src.core.GameVersion") + local Runtime = require("src.core.game3.runtime") + local oldVersion = Version.get() + local titlePath = "src.ui.game3.rse.title_rse" + local introPath = "src.ui.game3.rse.intro_emerald" + local oldTitle, oldIntro = package.loaded[titlePath], package.loaded[introPath] + local titleCalls, introCalls, introColdBoot = 0, 0, nil + package.loaded[titlePath] = { new = function() + titleCalls = titleCalls + 1 + return { update = function(self) return self.result end } + end } + package.loaded[introPath] = { new = function(_, opts) + introCalls, introColdBoot = introCalls + 1, opts.coldBoot + return { update = function() end } + end } + Version.set("emerald") + local game = Game3.new() + game.phase = "field" + game.session = { version = "emerald", map = "EM_LITTLEROOT_TOWN", x = 5, y = 5, game = game } + Runtime.start(nil, game, game.session, { alreadyOnMap = true }) + StartMenu.show({ session = game.session, game = game }) + for i, entry in ipairs(StartMenu.ENTRIES) do + if entry.id == "exit" then StartMenu.cursor = i break end + end + StartMenu.confirm() + eq(StartMenu._confirmCursor, 2, "Emerald EXIT defaults to NO") + StartMenu.cancel() + eq(game.phase, "field", "Emerald EXIT B preserves field") + StartMenu.confirm() + StartMenu.confirm() + eq(game.phase, "field", "Emerald EXIT NO preserves field") + StartMenu.confirm() + StartMenu.move(1) + StartMenu.confirm() + eq(game.phase, "boot", "Emerald EXIT tears down field") + eq(game.session, nil, "Emerald EXIT clears session") + check(not Runtime.isActive(), "Emerald EXIT stops runtime") + eq(Stack.depth(), 0, "Emerald EXIT clears screen stack") + eq(game.boot.phase, Boot.PHASE.TITLE, "Emerald EXIT enters TITLE directly") + eq(titleCalls, 1, "Emerald EXIT constructs title") + eq(introCalls, 0, "Emerald EXIT bypasses intro constructor") + eq(game.boot.custom.coldBoot, false, "Emerald EXIT consumes cold boot") + if game.boot.custom.title then + game.boot.custom.title.result = "copyright" + Boot.update(game.boot, {}, 1 / 60) + eq(introColdBoot, false, "Title idle copyright uses noncold intro") + end + game:returnToTitle() + eq(game.boot.phase, Boot.PHASE.INTRO, "Actual soft reset retains Emerald intro") + eq(game.boot.custom.coldBoot, true, "Actual soft reset retains cold boot") + Boot.update(game.boot, {}, 1 / 60) + eq(introColdBoot, true, "Actual soft reset constructs cold intro") + package.loaded[titlePath], package.loaded[introPath] = oldTitle, oldIntro + Version.set(oldVersion) +end + T.finish("game3_pause_and_main_menu_exit_test") From 6806d5a235e8a24ad2c84d8ea1849e9a2aae1350 Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Wed, 30 Sep 2026 13:54:23 -0400 Subject: [PATCH 4/5] CLOSES #2577, CLOSES #2573, CLOSES #2572 --- src/core/Game.lua | 5 +- src/core/Game3.lua | 5 +- src/core/game3/battle/init.lua | 26 +++-- src/core/game3/collision.lua | 12 ++ src/core/game3/field_effects.lua | 57 +++++++-- src/core/game3/field_effects_rse.lua | 109 +++++++++++++++--- src/core/game3/map.lua | 94 +++++++++++---- src/core/game3/palette.lua | 64 ++++++++++- src/core/game3/tileset_anim.lua | 17 ++- src/import/gba/native_pack.lua | 20 ++-- src/ui/game3/rse/birch_speech.lua | 2 +- src/ui/game3/rse/scene_kit.lua | 9 ++ tests/drivers/em_neighbor_reflection.lua | 42 +++++++ tests/drivers/game3_speed_hotkey_h1.lua | 4 +- tests/engine/game3_speed_hotkey_test.lua | 5 +- tests/engine/game_speed_categories_test.lua | 22 +++- tests/engine/gate_gen3_mod_api.lua | 1 + tests/game3_low_hp_hook_test.lua | 71 ++++++++++++ tests/game3_printer_clip_test.lua | 79 +++++++++++++ tests/game3_reflection_neighbor_test.lua | 77 +++++++++++++ tests/game3_seam_warm_budget_test.lua | 121 ++++++++++++++++++++ 21 files changed, 760 insertions(+), 82 deletions(-) create mode 100644 tests/drivers/em_neighbor_reflection.lua create mode 100644 tests/game3_low_hp_hook_test.lua create mode 100644 tests/game3_printer_clip_test.lua create mode 100644 tests/game3_reflection_neighbor_test.lua create mode 100644 tests/game3_seam_warm_budget_test.lua diff --git a/src/core/Game.lua b/src/core/Game.lua index 91f1ba95..8addc853 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -823,7 +823,10 @@ function Game:_cycleSpeed(dir) if busy then return end local GameSpeed = require("src.core.GameSpeed") local key = GameSpeed.optionKey(Game.speedCategoryInStack(self.stack)) - self.save.options[key] = GameSpeed.cycle(self.save.options[key], dir) + local nextSpeed = GameSpeed.cycle(self.save.options[key], dir) + for _, c in ipairs(GameSpeed.CATEGORIES) do + self.save.options[GameSpeed.optionKey(c)] = nextSpeed + end self:writeOptions() end diff --git a/src/core/Game3.lua b/src/core/Game3.lua index 43fe50d6..fd37eb81 100644 --- a/src/core/Game3.lua +++ b/src/core/Game3.lua @@ -677,7 +677,10 @@ function Game3:_cycleSpeed(dir) if self:speedLocked() then return end local GameSpeed = require("src.core.GameSpeed") local key = GameSpeed.optionKey(self:speedCategory()) - self.options[key] = GameSpeed.cycle(self.options[key], dir) + local nextSpeed = GameSpeed.cycle(self.options[key], dir) + for _, c in ipairs(GameSpeed.CATEGORIES) do + self.options[GameSpeed.optionKey(c)] = nextSpeed + end self:writeOptions() end diff --git a/src/core/game3/battle/init.lua b/src/core/game3/battle/init.lua index e0cc7631..90cdde87 100644 --- a/src/core/game3/battle/init.lua +++ b/src/core/game3/battle/init.lua @@ -93,6 +93,17 @@ local function stop_low_hp_song() end end +local function apply_low_hp(on) + if on and not Battle._lowHpSong then + Battle._lowHpSong = true + if Audio and Audio.playSe and SE then + Audio.playSe(SE.SE_LOW_HEALTH, { loop = true }) + end + elseif not on then + stop_low_hp_song() + end +end + --- pret HandleLowHpMusicChange / HandleBattleLowHpMusicChange local function update_low_hp_music() local st = Battle._st @@ -106,16 +117,17 @@ local function update_low_hp_music() local hp = math.floor(tonumber(pres and pres.displayHp) or tonumber(mon.hp) or 0) local maxHp = math.floor(tonumber(pres and pres.displayMaxHp) or tonumber(mon.maxHp) or 0) local red = hp_bar_red(hp, maxHp) - if red and not Battle._lowHpSong then - Battle._lowHpSong = true - if Audio and Audio.playSe and SE then - Audio.playSe(SE.SE_LOW_HEALTH, { loop = true }) - end - elseif not red then - stop_low_hp_song() + if ModRuntime.wantsHook("battle.low_health_alarm") then + ModRuntime.call("battle.low_health_alarm", function(ctx) + apply_low_hp(ctx.on) + end, { on = red, battle = st }) + else + apply_low_hp(red) end end +Battle._updateLowHpMusic = update_low_hp_music + local function party_menu_input(PartyMenu, input) local SummaryMenu = package.loaded["src.ui.game3.summary_menu"] if SummaryMenu and SummaryMenu.isOpen and SummaryMenu.isOpen() then diff --git a/src/core/game3/collision.lua b/src/core/game3/collision.lua index fe5f831b..7acc45f3 100644 --- a/src/core/game3/collision.lua +++ b/src/core/game3/collision.lua @@ -250,6 +250,18 @@ function Collision.behavior(cx,cy) return Collision.behaviorOn(Collision._mapDef, cx, cy) end +local worldMap + +-- pokefirered/src/fieldmap.c:129 +function Collision.worldBehavior(cx, cy) + local def = Collision._mapDef + if not (def and def.midLayout) then return nil end + worldMap = worldMap or require("src.core.game3.map") + local mid, pair = worldMap.worldMidAt(cx, cy, def) + local behaviors = InteractionScripts.behaviors[pair] + return behaviors and behaviors[mid] +end + -- pokefirered/include/constants/metatile_behaviors.h:39 local MB_IMPASSABLE_EAST = 0x30 local MB_IMPASSABLE_WEST = 0x31 diff --git a/src/core/game3/field_effects.lua b/src/core/game3/field_effects.lua index 9debabef..df207d5f 100644 --- a/src/core/game3/field_effects.lua +++ b/src/core/game3/field_effects.lua @@ -46,6 +46,7 @@ local FLY_BIRD_W, FLY_BIRD_H, FLY_BIRD_FRAMES = 64, 64, 5 local frlgReflective = nil local frlgReflectionQuad = nil local frlgModules = nil +local FRLG_REFL_CULL = 64 local objectPoseOpts = {} local playerPoseOpts = {} local function isFrlgReflective(behavior) @@ -99,6 +100,7 @@ local function drawFrlgReflections(camX, camY) if not modules then return end local Collision, Ow = modules.Collision, modules.Ow local Objects, P, Runtime = modules.Objects, modules.Player, modules.Runtime + local behaviorAt = Collision.worldBehavior or Collision.behavior local drawn = 0 if Objects.forDraw then for _, obj in ipairs(Objects.forDraw()) do @@ -109,7 +111,45 @@ local function drawFrlgReflections(camX, camY) local frame, flip = Ow.pose(spr, obj.facing, Objects.walkPhase(obj), obj.stepFlip, objectPoseOpts) if drawFrlgReflection(obj, obj.graphicsId, frame, flip, camX, camY, - Collision.behavior, Ow) then drawn = drawn + 1 end + behaviorAt, Ow) then drawn = drawn + 1 end + end + end + end + end + local Map = package.loaded["src.core.game3.map"] + local Ghosts = package.loaded["src.core.game3.ghosts"] + local world = Map and Map.world + if Ghosts and Ghosts.forDraw and type(world) == "table" then + local host = Collision._mapDef + local FV = package.loaded["src.core.game3.field_view"] + local Display = require("src.core.game3.display") + local vw = FV and FV._viewW or Display.W + local vh = FV and FV._viewH or Display.H + local x0, y0 = camX - FRLG_REFL_CULL, camY - FRLG_REFL_CULL + local x1, y1 = camX + vw + FRLG_REFL_CULL, camY + vh + FRLG_REFL_CULL + for i = 1, #world do + local entry = world[i] + local ox, oy = entry.ox or 0, entry.oy or 0 + local L = entry.def and entry.def.midLayout + local ex, ey = ox * CELL, oy * CELL + if entry.def ~= host and L + and ex + (L.width or 0) * CELL > x0 and ex < x1 + and ey + (L.height or 0) * CELL > y0 and ey < y1 then + local live = Ghosts.forDraw(entry.id) + if live then + for j = 1, #live do + local obj = live[j] + if not obj.hideReflection and obj.graphicsId and not obj.virtualId then + local spr = Ow.getDraw(obj.graphicsId) + if spr then + objectPoseOpts.frame = obj.customFrame + local frame, flip = Ow.pose(spr, obj.facing, Objects.walkPhase(obj), obj.stepFlip, + objectPoseOpts) + if drawFrlgReflection(obj, obj.graphicsId, frame, flip, camX, camY, + behaviorAt, Ow, ox, oy) then drawn = drawn + 1 end + end + end + end end end end @@ -122,22 +162,23 @@ local function drawFrlgReflections(camX, camY) local frame, flip = Ow.pose(spr, P.facing, P.walkPhase and P.walkPhase() or 0, P.drawFlip and P.drawFlip() or false, playerPoseOpts) if drawFrlgReflection(P, gid, frame, flip, camX, camY, - Collision.behavior, Ow) then drawn = drawn + 1 end + behaviorAt, Ow) then drawn = drawn + 1 end end end FieldEffects.lastFrlgReflections = drawn end -drawFrlgReflection = function(obj, graphicsId, frame, hflip, camX, camY, behaviorAt, Ow) +drawFrlgReflection = function(obj, graphicsId, frame, hflip, camX, camY, behaviorAt, Ow, ox, oy) local spr = Ow.getReflectionDraw and Ow.getReflectionDraw(graphicsId) if not (spr and spr.quads and spr.quads[frame]) then return false end - local cx = obj.moving and obj.targetX or obj.cellX - local cy = obj.moving and obj.targetY or obj.cellY - local pcx, pcy = obj.cellX, obj.cellY + ox, oy = ox or 0, oy or 0 + local cx = (obj.moving and obj.targetX or obj.cellX) + ox + local cy = (obj.moving and obj.targetY or obj.cellY) + oy + local pcx, pcy = obj.cellX + ox, obj.cellY + oy if not frlgReflectionType(cx, cy, pcx, pcy, spr.width, spr.height, behaviorAt) then return false end local w, h = spr.width, spr.height - local left = (obj.px or cx * CELL) + (16 - w) / 2 - local top = (obj.py or cy * CELL) + 14 + local left = (obj.px or (cx - ox) * CELL) + ox * CELL + (16 - w) / 2 + local top = (obj.py or (cy - oy) * CELL) + oy * CELL + 14 local x0, x1 = math.floor(left / CELL), math.floor((left + w - 1) / CELL) local y0, y1 = math.floor(top / CELL), math.floor((top + h - 1) / CELL) local q = frlgReflectionQuad or love.graphics.newQuad(0, 0, 1, 1, spr.width, spr.height * spr.frameCount) diff --git a/src/core/game3/field_effects_rse.lua b/src/core/game3/field_effects_rse.lua index adf8c083..7efc0412 100644 --- a/src/core/game3/field_effects_rse.lua +++ b/src/core/game3/field_effects_rse.lua @@ -144,6 +144,12 @@ local function behaviorAt(x, y) return C.behavior(x, y) end +local function worldBehaviorAt(x, y) + local C = Collision() + if not (C and C.worldBehavior) or x == nil or y == nil then return nil end + return C.worldBehavior(x, y) +end + function FxRse.animCmds(name, idx) local o = FE().manifestObject(name) @@ -574,7 +580,7 @@ end -- pokeemerald/src/event_object_movement.c:7625 local function reflectionAt(x, y) - local b = behaviorAt(x, y) + local b = worldBehaviorAt(x, y) if b == nil then return 0 end if B.ice(b) then return 1 end if B.reflective(b) then return 2 end @@ -645,19 +651,23 @@ end local reflQuad local coverCells, coverN = {}, 0 +local coverBuckets, coverPairs = {}, {} +local REFL_CULL = 64 +local ghostPose = {} -local function drawReflection(obj, gid, frame, hflip, x2, y2, camX, camY) +local function drawReflection(obj, gid, frame, hflip, x2, y2, camX, camY, ox, oy) local Ow = package.loaded["src.core.game3.ow_sprites"] local spr = Ow and Ow.getDraw and Ow.getDraw(gid) if not (spr and spr.quads and spr.quads[frame]) then return false end - local cx = obj.moving and obj.targetX or obj.cellX - local cy = obj.moving and obj.targetY or obj.cellY - local pcx, pcy = obj.cellX, obj.cellY - if obj == Player() then pcx, pcy = obj.prevCellX or cx, obj.prevCellY or cy end + ox, oy = ox or 0, oy or 0 + local cx = (obj.moving and obj.targetX or obj.cellX) + ox + local cy = (obj.moving and obj.targetY or obj.cellY) + oy + local pcx, pcy = obj.cellX + ox, obj.cellY + oy + if obj == Player() then pcx, pcy = (obj.prevCellX or cx - ox) + ox, (obj.prevCellY or cy - oy) + oy end local rtype = FxRse.reflectionType(cx, cy, pcx, pcy, spr.width, spr.height) if rtype == 0 then return false end - local bridge = B.bridgeType(behaviorAt(pcx, pcy)) - if bridge == 0 then bridge = B.bridgeType(behaviorAt(cx, cy)) end + local bridge = B.bridgeType(worldBehaviorAt(pcx, pcy)) + if bridge == 0 then bridge = B.bridgeType(worldBehaviorAt(cx, cy)) end local info = fcGfx(gid) local tag = FxRse.reflectionPaletteTag(gid, bridge) local src = (tag and recolored(gid, spr, tag)) or spr @@ -667,8 +677,8 @@ local function drawReflection(obj, gid, frame, hflip, x2, y2, camX, camY) local offs = { [1] = 12, [2] = 28, [3] = 44 } local extra = (not (info and info.noReflectionLoad)) and offs[bridge] or 0 local w, h = spr.width, spr.height - local left = (obj.px or cx * CELL) + (16 - w) / 2 + (x2 or 0) - local top = (obj.py or cy * CELL) + 16 - h + (h - 2) + extra - (y2 or 0) + local left = (obj.px or (cx - ox) * CELL) + ox * CELL + (16 - w) / 2 + (x2 or 0) + local top = (obj.py or (cy - oy) * CELL) + oy * CELL + 16 - h + (h - 2) + extra - (y2 or 0) local iw, ih = src.image:getDimensions() reflQuad = reflQuad or love.graphics.newQuad(0, 0, 1, 1, iw, ih) local fy = frame * h @@ -677,7 +687,7 @@ local function drawReflection(obj, gid, frame, hflip, x2, y2, camX, camY) love.graphics.setColor(1, 1, 1, 1) for ty = c0y, c1y do for tx = c0x, c1x do - local b = behaviorAt(tx, ty) + local b = worldBehaviorAt(tx, ty) if b and B.reflective(b) then local ix0, ix1 = math.max(left, tx * CELL), math.min(left + w, tx * CELL + CELL) local iy0, iy1 = math.max(top, ty * CELL), math.min(top + h, ty * CELL + CELL) @@ -723,17 +733,45 @@ local function coverReflections(camX, camY) local mapDef = C and C._mapDef local layout = mapDef and mapDef.midLayout local NT = package.loaded["src.core.game3.tileset_native"] - if not (layout and NT) then return end - local ts = NT.get(mapDef.pair or layout.pair) - local shader = ts and ts.midImage and getCoverShader() + local Map = package.loaded["src.core.game3.map"] + if not (layout and NT and Map) then return end + local shader = getCoverShader() if not shader then return end - shader:send("mask", ts.midImage) love.graphics.setShader(shader) love.graphics.setColor(1, 1, 1, 1) + local loaded = NT._pairs or {} + local np = 0 for i = 1, n, 2 do local tx, ty = coverCells[i], coverCells[i + 1] - local q = NT.quad(ts, NT.slotFor(ts, layout:midAt(tx, ty))) - if q then love.graphics.draw(ts.image, q, tx * CELL - camX, ty * CELL - camY) end + local mid, pair = Map.worldMidAt(tx, ty, mapDef) + local ts = pair and loaded[pair] + if ts and ts.midImage then + local bucket = coverBuckets[pair] + if not bucket then + bucket = { n = 0 } + coverBuckets[pair] = bucket + end + if bucket.n == 0 then + np = np + 1 + coverPairs[np] = pair + end + local b = bucket.n + bucket[b + 1], bucket[b + 2], bucket[b + 3] = tx, ty, mid + bucket.n = b + 3 + end + end + for p = 1, np do + local pair = coverPairs[p] + local bucket = coverBuckets[pair] + local ts = loaded[pair] + shader:send("mask", ts.midImage) + for i = 1, bucket.n, 3 do + local tx, ty = bucket[i], bucket[i + 1] + local q = NT.quad(ts, NT.slotFor(ts, bucket[i + 2])) + if q then love.graphics.draw(ts.image, q, tx * CELL - camX, ty * CELL - camY) end + end + bucket.n = 0 + coverPairs[p] = nil end love.graphics.setShader() end @@ -755,6 +793,43 @@ local function drawReflections(camX, camY) end end end + local Map = package.loaded["src.core.game3.map"] + local Ghosts = package.loaded["src.core.game3.ghosts"] + local world = Map and Map.world + if O and Ghosts and Ghosts.forDraw and type(world) == "table" then + local C = Collision() + local host = C and C._mapDef + local FV = package.loaded["src.core.game3.field_view"] + local Display = require("src.core.game3.display") + local vw = FV and FV._viewW or Display.W + local vh = FV and FV._viewH or Display.H + local x0, y0 = camX - REFL_CULL, camY - REFL_CULL + local x1, y1 = camX + vw + REFL_CULL, camY + vh + REFL_CULL + for i = 1, #world do + local entry = world[i] + local ox, oy = entry.ox or 0, entry.oy or 0 + local L = entry.def and entry.def.midLayout + local ex, ey = ox * CELL, oy * CELL + if entry.def ~= host and L + and ex + (L.width or 0) * CELL > x0 and ex < x1 + and ey + (L.height or 0) * CELL > y0 and ey < y1 then + local live = Ghosts.forDraw(entry.id) + if live then + for j = 1, #live do + local eo = live[j] + if not eo.hideReflection and eo.graphicsId and not eo.virtualId then + local spr = Ow.getDraw(eo.graphicsId) + if spr then + ghostPose.frame = eo.customFrame + local frame, flip = Ow.pose(spr, eo.facing, O.walkPhase(eo), eo.stepFlip, ghostPose) + drawReflection(eo, eo.graphicsId, frame, flip, eo.raiseX, eo.raiseY, camX, camY, ox, oy) + end + end + end + end + end + end + end local P = Player() if P and P.isVisible and P.isVisible() and not P.hideReflection then local rt = package.loaded["src.core.game3.runtime"] diff --git a/src/core/game3/map.lua b/src/core/game3/map.lua index 039f37bf..0f688092 100644 --- a/src/core/game3/map.lua +++ b/src/core/game3/map.lua @@ -149,26 +149,27 @@ function Map.refreshWorld(game, reachW, reachH, rootId) Map._loadedLayouts[entry.id] = true end local NativeTileset = package.loaded["src.core.game3.tileset_native"] - if Map._warmPairs and NativeTileset and NativeTileset.get then + if NativeTileset and NativeTileset.get then + local sync = Map._warmPairs Map._warmPairs = nil - Map._warmQueue = nil + local x0, y0, x1, y1 = Map.warmRect() + local queue, seen, entries = {}, {}, {} 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 + if sync and pair and Map.warmNear(entry, x0, y0, x1, y1) then + if NativeTileset.ready(pair) then pcall(NativeTileset.get, pair) end + elseif type(pair) == "string" and not (NativeTileset._pairs and NativeTileset._pairs[pair]) then + if not seen[pair] then + seen[pair] = true + queue[#queue + 1] = pair + entries[pair] = {} + end + local list = entries[pair] + list[#list + 1] = entry end end Map._warmQueue = queue[1] and queue or nil + Map._warmEntries = queue[1] and entries or nil end Map._worldRoot = rootId Map._worldReachW = reachW @@ -178,21 +179,68 @@ function Map.refreshWorld(game, reachW, reachH, rootId) return Map.world end ---- Load at most one queued tileset pair (see refreshWorld). +Map.WARM_MARGIN_SCREENS = 1 +Map.WARM_BUDGET_SEC = 0.030 +Map.WARM_MAX_DEFER = 3 +Map._warmDefer = 0 + +function Map.warmRect() + 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 * Map.WARM_MARGIN_SCREENS + local my = math.ceil(rows / 2) + 1 + rows * Map.WARM_MARGIN_SCREENS + return px - mx, py - my, px + mx, py + my +end + +function Map.warmNear(entry, x0, y0, x1, y1) + if not x0 then return true end + local layout = entry.def and entry.def.midLayout + local w, h = layout and layout.width, 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 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 + local timer = love and love.timer + if timer and timer.getDelta and timer.getDelta() > Map.WARM_BUDGET_SEC + and Map._warmDefer < Map.WARM_MAX_DEFER then + Map._warmDefer = Map._warmDefer + 1 + return false + end + Map._warmDefer = 0 + local entries = Map._warmEntries or {} + local x0, y0, x1, y1 = Map.warmRect() + local i = 1 + while queue[i] do + local pair = queue[i] + if NativeTileset and (NativeTileset._pairs and NativeTileset._pairs[pair]) + or not (NativeTileset and NativeTileset.ready and NativeTileset.ready(pair)) then + table.remove(queue, i) + else + local near = not entries[pair] + for _, entry in ipairs(entries[pair] or {}) do + if Map.warmNear(entry, x0, y0, x1, y1) then near = true break end + end + if near then + table.remove(queue, i) + pcall(NativeTileset.get, pair) + if not queue[1] then Map._warmQueue = nil end + return true + end + i = i + 1 end end - Map._warmQueue = nil + if not queue[1] then Map._warmQueue = nil end return false end diff --git a/src/core/game3/palette.lua b/src/core/game3/palette.lua index f8a86b57..5a75121c 100644 --- a/src/core/game3/palette.lua +++ b/src/core/game3/palette.lua @@ -21,6 +21,62 @@ function Palette.load(blob) return NativePack.palsToRgb8(bgr), bgr end +local bit = require("bit") +local band, bor, bxor, bnot = bit.band, bit.bor, bit.bxor, bit.bnot +local lshift, rshift, rol = bit.lshift, bit.rshift, bit.rol + +local MD5_K, MD5_S = {}, { + 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, + 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20, + 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, + 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21, +} +for i = 0, 63 do + MD5_K[i + 1] = math.floor(math.abs(math.sin(i + 1)) * 4294967296) % 4294967296 +end + +local function md5hex(msg) + local len = #msg + local padLen = (55 - len) % 64 + msg = msg .. "\128" .. string.rep("\0", padLen) + .. string.char( + (len * 8) % 256, math.floor(len * 8 / 256) % 256, + math.floor(len * 8 / 65536) % 256, math.floor(len * 8 / 16777216) % 256, + 0, 0, 0, 0) + local a0, b0, c0, d0 = 0x67452301, 0xefcdab89, 0x98badcfe, 0x10325476 + local M = {} + for off = 1, #msg, 64 do + for j = 0, 15 do + local b1, b2, b3, b4 = msg:byte(off + j * 4, off + j * 4 + 3) + M[j] = bor(b1, lshift(b2, 8), lshift(b3, 16), lshift(b4, 24)) + end + local A, B, C, D = a0, b0, c0, d0 + for i = 0, 63 do + local F, g + if i < 16 then + F = bor(band(B, C), band(bnot(B), D)); g = i + elseif i < 32 then + F = bor(band(D, B), band(bnot(D), C)); g = (5 * i + 1) % 16 + elseif i < 48 then + F = bxor(B, C, D); g = (3 * i + 5) % 16 + else + F = bxor(C, bor(B, bnot(D))); g = (7 * i) % 16 + end + F = F + A + MD5_K[i + 1] + M[g] + A = D; D = C; C = B + B = B + rol(F, MD5_S[i + 1]) + end + a0, b0, c0, d0 = a0 + A, b0 + B, c0 + C, d0 + D + end + local out = {} + for _, v in ipairs({ a0, b0, c0, d0 }) do + for k = 0, 3 do out[#out + 1] = string.format("%02x", band(rshift(v, k * 8), 255)) end + end + return table.concat(out) +end + +Palette._md5hex = md5hex + --- Stable hash of BGR555 pals (+ optional extraBlob) for RGBA disk cache keys. function Palette.hash(bgrPals, extraBlob) local parts = {} @@ -33,13 +89,11 @@ function Palette.hash(bgrPals, extraBlob) if extraBlob and type(extraBlob) == "string" then parts[#parts + 1] = extraBlob end - -- FNV-1a 32-bit over hex stream (stable, no bit lib). - local h = 2166136261 local s = table.concat(parts) - for i = 1, #s do - h = (h * 16777619 + s:byte(i)) % 4294967296 + if love and love.data and love.data.hash then + return (love.data.encode("string", "hex", love.data.hash("md5", s)):sub(1, 16)) end - return string.format("%08x", h) + return md5hex(s):sub(1, 16) end --- Identity tint hook (weather / time later). diff --git a/src/core/game3/tileset_anim.lua b/src/core/game3/tileset_anim.lua index 5ea5969a..cf788842 100644 --- a/src/core/game3/tileset_anim.lua +++ b/src/core/game3/tileset_anim.lua @@ -28,6 +28,7 @@ function TilesetAnim.install(cache) end function TilesetAnim.invalidate() + TilesetAnim._manifests = nil TilesetAnim._pairs = {} TilesetAnim._visible = {} TilesetAnim.counter = 0 @@ -42,9 +43,18 @@ local function native_root() end local function read_manifest(cache, pair) + local memo = TilesetAnim._manifests + if not memo then + memo = {} + TilesetAnim._manifests = memo + end + local hit = memo[pair] + if hit ~= nil then return hit or nil end local src = cache:read(native_root() .. pair .. "/anim_manifest.lua") local chunk = src and load(src, "@anim_manifest.lua", "t", {}) - return chunk and chunk() or nil + local man = chunk and chunk() or nil + memo[pair] = man or false + return man end local function rse_luts(cache, pair) @@ -295,10 +305,7 @@ function TilesetAnim.bindPair(pair, atlas) local entry = TilesetAnim._pairs[pair] if entry == false then return false end if not entry then - local src = cache:read( - (Extract.CACHE_ROOT or "data/generated/gba") .. "/native/" .. pair .. "/anim_manifest.lua") - local chunk = src and load(src, "@anim_manifest.lua", "t", {}) - local man = chunk and chunk() + local man = read_manifest(cache, pair) if not man then TilesetAnim._pairs[pair] = false return false diff --git a/src/import/gba/native_pack.lua b/src/import/gba/native_pack.lua index 7d958678..4febe655 100644 --- a/src/import/gba/native_pack.lua +++ b/src/import/gba/native_pack.lua @@ -134,20 +134,26 @@ function NativePack.decodeIdx(blob) midIds[i] = read_u16(blob, off) off = off + 2 end - local pixels = {} + local dataOff = off local n = midCount * 256 - for i = 1, n do - pixels[i] = blob:byte(off + i - 1) or 0 - end - return { + return setmetatable({ formatVersion = formatVersion, flags = flags, midCount = midCount, atlasCols = atlasCols, atlasRows = atlasRows, midIds = midIds, - pixels = pixels, - } + }, { + __index = function(self, k) + if k ~= "pixels" then return nil end + local pixels = {} + for i = 1, n do + pixels[i] = blob:byte(dataOff + i - 1) or 0 + end + rawset(self, "pixels", pixels) + return pixels + end, + }) end function NativePack.encodePalettes(mapPals) diff --git a/src/ui/game3/rse/birch_speech.lua b/src/ui/game3/rse/birch_speech.lua index c837d55e..2353f7e0 100644 --- a/src/ui/game3/rse/birch_speech.lua +++ b/src/ui/game3/rse/birch_speech.lua @@ -991,7 +991,7 @@ function Birch:draw() if self.showDialogue then local w = WIN_TEXT Kit.birchDialogueFrame(w.left, w.top, w.width, w.height) - if self.printer then self.printer:draw(w.left * 8, w.top * 8 + 1, { colors = Kit.messageColors() }) end + if self.printer then self.printer:draw(w.left * 8, w.top * 8 + 1, { colors = Kit.messageColors(), clip = { w.left * 8, w.top * 8, w.width * 8, w.height * 8 } }) end end if self.genderMenu then local w = WIN_GENDER diff --git a/src/ui/game3/rse/scene_kit.lua b/src/ui/game3/rse/scene_kit.lua index a3c2516b..e3f6776a 100644 --- a/src/ui/game3/rse/scene_kit.lua +++ b/src/ui/game3/rse/scene_kit.lua @@ -461,6 +461,12 @@ function Printer:draw(x, y, opts) opts = opts or {} local colors = opts.colors or Kit.messageColors() local cx, cy = x, y + local clip = opts.clip + local sx, sy, sw, sh + if clip then + sx, sy, sw, sh = love.graphics.getScissor() + love.graphics.intersectScissor(clip[1], clip[2], clip[3], clip[4]) + end for i, line in ipairs(self.lines) do local ly = y + (i - 1) * self.pitch - self.scrollY if line ~= "" then @@ -471,6 +477,9 @@ function Printer:draw(x, y, opts) end cy = ly end + if clip then + if sx then love.graphics.setScissor(sx, sy, sw, sh) else love.graphics.setScissor() end + end if self.arrowFrame then local img = Kit.rgbaImage("data/generated/gba/chrome/fonts/down_arrow.rgba", 8, 48) if img then diff --git a/tests/drivers/em_neighbor_reflection.lua b/tests/drivers/em_neighbor_reflection.lua new file mode 100644 index 00000000..9d1996d5 --- /dev/null +++ b/tests/drivers/em_neighbor_reflection.lua @@ -0,0 +1,42 @@ +local U = require("tests.drivers.util") + +local DIR = os.getenv("POKEPORT_SHOT_DIR") or "shots" + +local function fail(msg) + print("FAIL " .. msg) + love.event.quit(1) +end + +return function(game) + for _ = 1, 900 do + if game.phase == "boot" and game.boot then break end + U.wait(1) + end + game:_handleBootAction({ action = "new_game", name = "RED" }) + U.wait(240) + local Map = require("src.core.game3.map") + local FxRse = require("src.core.game3.field_effects_rse") + local Ghosts = require("src.core.game3.ghosts") + local Objects = require("src.core.game3.objects") + local r104 = game.data.maps["EM_ROUTE104"] + if not r104 then return fail("EM_ROUTE104 missing") end + + Map.load(nil, game, "EM_ROUTE104", { x = 28, y = 1, facing = "down" }) + U.wait(60) + U.still(game, DIR .. "/em_control_player_on_own_map_reflects.png") + if (FxRse.lastReflections or 0) < 1 then return fail("control player has no reflection") end + + Map.load(nil, game, "EM_RUSTBORO_CITY", { x = 28, y = 57, facing = "down" }) + U.wait(60) + U.still(game, DIR .. "/em_rustboro_view_route104_no_npc.png") + + local defs = { { localId = 1, index = 1, x = 28, y = 1, graphicsId = 5, movementType = 8, elevation = 3, flag = 0 } } + Ghosts._pools["EM_ROUTE104"] = Objects.spawnFromDefs(defs, r104, "EM_ROUTE104") + U.wait(30) + U.still(game, DIR .. "/em_rustboro_view_route104_ghost_npc_on_shore.png") + local n = FxRse.lastReflections or 0 + print("GHOST_VIEW_REFL", n) + if n < 1 then return fail("neighbor NPC has no reflection") end + print("PASS") + love.event.quit(0) +end diff --git a/tests/drivers/game3_speed_hotkey_h1.lua b/tests/drivers/game3_speed_hotkey_h1.lua index 0cb9d189..38e0f4a5 100644 --- a/tests/drivers/game3_speed_hotkey_h1.lua +++ b/tests/drivers/game3_speed_hotkey_h1.lua @@ -38,8 +38,8 @@ return function(game) U.wait(20) expect(require("src.ui.game3.stack").busy(), "h1_start_menu_open") key("1") - expect(game.options.speedOverworld == 2 and game.options.speedMenu == 1, - "h1_start_menu_1_bumps_overworld") + expect(game.options.speedOverworld == 2 and game.options.speedMenu == 2 + and game.options.speedBattle == 2, "h1_start_menu_1_syncs_all_categories") game.speedOverride = 1 U.still(game, ".bazinga/BSA/09-24-26-01-followup/shots/game3_speed_hotkey_h1/h1_start_menu_speed2.png") game.speedOverride = nil diff --git a/tests/engine/game3_speed_hotkey_test.lua b/tests/engine/game3_speed_hotkey_test.lua index 9ca29c62..404ad6ea 100644 --- a/tests/engine/game3_speed_hotkey_test.lua +++ b/tests/engine/game3_speed_hotkey_test.lua @@ -33,7 +33,8 @@ do eq(g:speedCategory(), "overworld", "menu over the field inherits overworld") g:keypressed("1") eq(g.options.speedOverworld, 2, "1 with a field menu open bumps OVERWORLD SPEED") - eq(g.options.speedMenu, 1, "1 with a field menu open leaves MENU SPEED alone") + eq(g.options.speedMenu, 2, "1 with a field menu open syncs MENU SPEED") + eq(g.options.speedBattle, 2, "and syncs BATTLE SPEED") stackBusy = false eq(g:logicSpeed(), 2, "walk speed sticks after the menu closes") battleActive = true @@ -42,7 +43,7 @@ do eq(g:logicSpeed(), 10, "a local gen3 battle runs at BATTLE SPEED") g:keypressed("1") check(g.options.speedBattle ~= 10, "1 in battle cycles BATTLE SPEED") - eq(g.options.speedOverworld, 2, "and leaves OVERWORLD SPEED alone") + eq(g.options.speedOverworld, g.options.speedBattle, "and syncs OVERWORLD SPEED") battleActive = false eq(newGame3("boot"):speedCategory(), "menu", "boot phase stays menu") eq(newGame3("quest_log"):speedCategory(), "menu", "quest log stays menu") diff --git a/tests/engine/game_speed_categories_test.lua b/tests/engine/game_speed_categories_test.lua index 85c8ad05..0cfbb990 100644 --- a/tests/engine/game_speed_categories_test.lua +++ b/tests/engine/game_speed_categories_test.lua @@ -180,8 +180,8 @@ do function g:writeOptions() writeOptions.calls = writeOptions.calls + 1 end g:_cycleSpeed(1) eq(g.save.options.speedBattle, 2, "cycling during battle bumps speedBattle") - eq(g.save.options.speedOverworld, 1, "...and leaves speedOverworld alone") - eq(g.save.options.speedMenu, 1, "...and leaves speedMenu alone") + eq(g.save.options.speedOverworld, 2, "...and syncs speedOverworld") + eq(g.save.options.speedMenu, 2, "...and syncs speedMenu") eq(writeOptions.calls, 1, "a successful cycle persists the option") end do @@ -200,7 +200,8 @@ do function g:writeOptions() end g:_cycleSpeed(1) eq(g.save.options.speedOverworld, 2, "cycling on the overworld bumps speedOverworld") - eq(g.save.options.speedBattle, 1, "...and leaves speedBattle alone") + eq(g.save.options.speedBattle, 2, "...and syncs speedBattle") + eq(g.save.options.speedMenu, 2, "...and syncs speedMenu") end do local g = gameWith({ overlay }, @@ -208,6 +209,21 @@ do function g:writeOptions() end g:_cycleSpeed(1) eq(g.save.options.speedMenu, 2, "cycling in a menu bumps speedMenu") + eq(g.save.options.speedOverworld, 2, "...and syncs speedOverworld") +end +do + local g = gameWith({ overworld }, + { speedOverworld = 3, speedBattle = 10, speedMenu = 1 }) + function g:writeOptions() end + g:_cycleSpeed(-1) + eq(g.save.options.speedOverworld, 2, "speed down steps the active category") + eq(g.save.options.speedBattle, 2, "speed down syncs battle") + eq(g.save.options.speedMenu, 2, "speed down syncs menu") + g:_cycleSpeed(-1) + g:_cycleSpeed(-1) + eq(g.save.options.speedOverworld, GameSpeed.LEVELS[#GameSpeed.LEVELS], + "wrapping down from NORMAL lands on the top level for every category") + eq(g.save.options.speedMenu, GameSpeed.LEVELS[#GameSpeed.LEVELS], "including menu") end -- A cart may narrow the ladder (CartManifest's `speeds`), and returning to diff --git a/tests/engine/gate_gen3_mod_api.lua b/tests/engine/gate_gen3_mod_api.lua index 92209c7e..cb1f4d15 100644 --- a/tests/engine/gate_gen3_mod_api.lua +++ b/tests/engine/gate_gen3_mod_api.lua @@ -181,6 +181,7 @@ local GEN3_HOOKS = { "trainer.party", "catch.rate", "exp.gain", "evolution.check", "battle.damage", "battle.crit", "battle.accuracy", "battle.charge_required", "battle.run", "battle.turn_order", "battle.enemy_action", + "battle.low_health_alarm", } local function assertShared(name, sites, kind) diff --git a/tests/game3_low_hp_hook_test.lua b/tests/game3_low_hp_hook_test.lua new file mode 100644 index 00000000..db4e4b2e --- /dev/null +++ b/tests/game3_low_hp_hook_test.lua @@ -0,0 +1,71 @@ +#!/usr/bin/env luajit + +package.path = "./?.lua;./?/init.lua;" .. package.path +require("tests.game3_cache").requireData("game3_low_hp_hook_test") + +local failed = 0 +local function check(cond, msg) + if cond then + print("[ok] " .. msg) + else + failed = failed + 1 + print("[FAIL] " .. msg) + end +end + +local Audio = require("src.core.game3.audio") +local SE = require("src.core.game3.se_ids") +local ModRuntime = require("src.mods.Runtime") +local Battle = require("src.core.game3.battle") + +local plays, stops = 0, 0 +Audio.playSe = function(id) + if id == SE.SE_LOW_HEALTH then plays = plays + 1 end +end +Audio.stopSe = function(id) + if id == SE.SE_LOW_HEALTH then stops = stops + 1 end +end + +local function reset() + plays, stops = 0, 0 + Battle._lowHpSong = false + Battle._st = { player = { mon = { hp = 1, maxHp = 100 } } } +end + +local function step(n) + for _ = 1, n do Battle._updateLowHpMusic() end +end + +reset() +step(3) +check(plays == 1, "no hook: siren starts once") + +reset() +ModRuntime.hooks = { + chains = { ["battle.low_health_alarm"] = {} }, + call = function(_, _, vanilla, ctx) + ctx.on = false + return vanilla(ctx) + end, +} +step(3) +check(plays == 0, "hook forcing on=false suppresses siren") + +reset() +local sawBattle = false +ModRuntime.hooks = { + chains = { ["battle.low_health_alarm"] = {} }, + call = function(_, _, vanilla, ctx) + sawBattle = ctx.battle == Battle._st and ctx.on == true + return vanilla(ctx) + end, +} +step(2) +check(plays == 1 and sawBattle, "pass-through hook sees on=true and battle, siren plays once") + +if failed > 0 then + print(failed .. " failed") + os.exit(1) +end +print("all passed") +os.exit(0) diff --git a/tests/game3_printer_clip_test.lua b/tests/game3_printer_clip_test.lua new file mode 100644 index 00000000..24fadb6f --- /dev/null +++ b/tests/game3_printer_clip_test.lua @@ -0,0 +1,79 @@ +package.path = package.path .. ";./?.lua" + +local calls = {} +local scissor = nil + +love = love or {} +love.graphics = love.graphics or {} +love.graphics.getScissor = function() + if scissor then return scissor[1], scissor[2], scissor[3], scissor[4] end + return nil +end +love.graphics.setScissor = function(x, y, w, h) + if x then scissor = { x, y, w, h } else scissor = nil end +end +love.graphics.intersectScissor = function(x, y, w, h) + if not scissor then + scissor = { x, y, w, h } + return + end + local x1 = math.max(scissor[1], x) + local y1 = math.max(scissor[2], y) + local x2 = math.min(scissor[1] + scissor[3], x + w) + local y2 = math.min(scissor[2] + scissor[4], y + h) + scissor = { x1, y1, math.max(0, x2 - x1), math.max(0, y2 - y1) } +end +love.graphics.setColor = function() end +love.graphics.draw = function() end + +local okKit, Kit = pcall(require, "src.ui.game3.rse.scene_kit") +if not okKit then + print("[skip] scene_kit needs runtime: " .. tostring(Kit)) + os.exit(0) +end +local FrlgFont = require("src.ui.game3.frlg_font") +FrlgFont.draw = function(line, x, y) + calls[#calls + 1] = { y = y, scissor = scissor and { scissor[1], scissor[2], scissor[3], scissor[4] } or nil } + return x + 8, nil +end +FrlgFont.measure = function() return 8 end +Kit.messageColors = function() return {} end + +local okP, p = pcall(Kit.printer, {}, { speed = 0 }) +if not okP then + print("[skip] printer needs cache: " .. tostring(p)) + os.exit(0) +end + +local failed = 0 +local function check(cond, msg) + if cond then + print(" PASS: " .. msg) + else + failed = failed + 1 + print(" FAIL: " .. msg) + end +end + +p.lines = { "aaa", "bbb", "" } +p.scrollY = 5 +p.arrowFrame = nil +local clip = { 16, 120, 216, 32 } +p:draw(16, 121, { clip = clip }) +check(#calls == 2, "lines drawn") +local s = calls[1].scissor +check(s and s[1] == 16 and s[2] == 120 and s[3] == 216 and s[4] == 32, "scissor equals clip while drawing") +check(scissor == nil, "scissor restored to none") + +scissor = { 0, 0, 240, 100 } +calls = {} +p:draw(16, 121, { clip = clip }) +check(calls[1].scissor[2] == 120 and calls[1].scissor[4] == 0 + 100 - 120 + 0 or calls[1].scissor[4] == 0, "clip intersects outer scissor") +check(scissor and scissor[4] == 100, "outer scissor restored") + +scissor = nil +calls = {} +p:draw(16, 121) +check(calls[1].scissor == nil, "no clip leaves scissor untouched") + +os.exit(failed == 0 and 0 or 1) diff --git a/tests/game3_reflection_neighbor_test.lua b/tests/game3_reflection_neighbor_test.lua new file mode 100644 index 00000000..f73d053f --- /dev/null +++ b/tests/game3_reflection_neighbor_test.lua @@ -0,0 +1,77 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +local failed = 0 +local function check(ok, msg) + if not ok then + failed = failed + 1 + print("FAIL " .. msg) + end +end + +love = love or {} +local draws = 0 +love.graphics = love.graphics or {} +love.graphics.setColor = function() end +love.graphics.newQuad = function() return { setViewport = function() end } end +love.graphics.draw = function() draws = draws + 1 end + +local okMap, Map = pcall(require, "src.core.game3.map") +if not okMap then + print("[skip] map needs runtime: " .. tostring(Map)) + os.exit(0) +end +local Collision = require("src.core.game3.collision") +local Scripts = require("src.core.game3.scripting.interaction_scripts") +local MB = require("src.core.game3.mb") +local FxRse = require("src.core.game3.field_effects_rse") + +local WATER = MB.id("POND_WATER") +local function layout(pair, mid) + return { width = 4, height = 4, pair = pair, midAt = function() return mid end } +end + +local host = { midLayout = layout("test_host", 1), pair = "test_host" } +local south = { midLayout = layout("test_south", 7), pair = "test_south" } +Scripts.behaviors.test_host = {} +Scripts.behaviors.test_south = { [7] = WATER } + +Collision._mapDef = host +Map.neighborList = { { dir = "south", offset = 0, def = south } } +Map.world = { + { id = "HOST", def = host, ox = 0, oy = 0 }, + { id = "SOUTH", def = south, ox = 0, oy = 4 }, +} + +check(Collision.behavior(1, 5) == nil, "gameplay behavior stays bounded to the current map") +check(Collision.worldBehavior and Collision.worldBehavior(1, 5) == WATER, + "worldBehavior resolves a neighbor cell across the seam") +check(Collision.worldBehavior and Collision.worldBehavior(1, 1) == nil, + "worldBehavior keeps current-map cells") + +local spr = { + width = 16, height = 32, frameCount = 1, quads = { [0] = true }, + image = { getDimensions = function() return 16, 32 end }, +} +package.loaded["src.core.game3.ow_sprites"] = { + getDraw = function() return spr end, + pose = function() return 0, false end, +} +FxRse._fc = { gfx = {}, palTagNone = 0x11FF } + +local ghost = { cellX = 1, cellY = 0, px = 16, py = 0, moving = false } +draws = 0 +check(FxRse.drawReflection(ghost, 5, 0, false, 0, 0, 0, 0, 0, 4) == true, + "ghost object on a neighbor map reflects off neighbor water") +check(draws > 0, "ghost reflection draws quads") + +local near = { cellX = 1, cellY = 2, px = 16, py = 32, moving = false } +draws = 0 +check(FxRse.drawReflection(near, 5, 0, false, 0, 0, 0, 0) == true, + "current-map object next to the seam reflects off neighbor water") + +local dry = { cellX = 1, cellY = 0, px = 16, py = 0, moving = false } +check(FxRse.drawReflection(dry, 5, 0, false, 0, 0, 0, 0) == false, + "object with no water below does not reflect") + +if failed > 0 then os.exit(1) end +print("game3_reflection_neighbor_test ok") diff --git a/tests/game3_seam_warm_budget_test.lua b/tests/game3_seam_warm_budget_test.lua new file mode 100644 index 00000000..e89155af --- /dev/null +++ b/tests/game3_seam_warm_budget_test.lua @@ -0,0 +1,121 @@ +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 NativePack = require("src.import.gba.native_pack") +local Palette = require("src.core.game3.palette") + +do + local pixels = {} + for i = 1, 512 do pixels[i] = (i * 7) % 256 end + local blob = NativePack.encodeIdx({ + midCount = 2, atlasCols = 16, atlasRows = 1, midIds = { 0, 5 }, pixels = pixels, + }) + local t = NativePack.decodeIdx(blob) + check(rawget(t, "pixels") == nil, "decodeIdx leaves pixels undecoded") + check(t.midCount == 2 and t.midIds[2] == 5, "header and midIds decode eagerly") + local px = t.pixels + check(#px == 512 and px[1] == pixels[1] and px[512] == pixels[512], "pixels decode on first access") + check(rawget(t, "pixels") == px and t.pixels == px, "pixels decode once") + local rgb = {} + for p = 0, 15 do + rgb[p] = {} + for c = 0, 15 do rgb[p][c] = { p * 10, c * 10, 3 } end + end + local rgba, w, h = NativePack.bakeRgba(NativePack.decodeIdx(blob), rgb, {}) + check(#rgba == w * h * 4, "bakeRgba works on a lazy table") +end + +do + check(Palette._md5hex("") == "d41d8cd98f00b204e9800998ecf8427e", "md5 empty vector") + check(Palette._md5hex("The quick brown fox jumps over the lazy dog") == "9e107d9d372bb6826bd81d3542a419d6", + "md5 fox vector") + local bgr = {} + for p = 0, 15 do + bgr[p] = {} + for c = 0, 15 do bgr[p][c] = (p * 977 + c * 31) % 32768 end + end + local chunks = {} + for i = 1, 4099 do chunks[i] = string.char((i * 13 + i % 7) % 256) end + local extra = table.concat(chunks) + local key = Palette.hash(bgr, extra) + check(key:match("^%x+$") and #key == 16, "Palette.hash is 16 hex chars") + check(Palette.hash(bgr, extra) == key, "Palette.hash is deterministic") + local tweaked = extra:sub(1, 2000) .. "\255" .. extra:sub(2002) + check(Palette.hash(bgr, tweaked) ~= key, "one byte of extra changes the key") + bgr[3][4] = (bgr[3][4] + 1) % 32768 + check(Palette.hash(bgr, extra) ~= key, "one palette color changes the key") + bgr[3][4] = (bgr[3][4] - 1) % 32768 + check(Palette.hash(bgr, extra) == key, "restoring the color restores the key") + local savedLove = love + love = { data = { + hash = function(_, s) return (Palette._md5hex(s):gsub("%x%x", function(h) return string.char(tonumber(h, 16)) end)) end, + encode = function(_, _, s) return (s:gsub(".", function(c) return string.format("%02x", c:byte()) end)) end, + } } + check(Palette.hash(bgr, extra) == key, "love.data path yields the same key as the fallback") + love = savedLove +end + +do + local loads = {} + local Native = { _pairs = {} } + 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 + package.loaded["src.core.game3.player"] = { cellX = 10, cellY = 10 } + local Map = require("src.core.game3.map") + local function def(pair, w, h) return { pair = pair, midLayout = { pair = pair, width = w, height = h } } end + Map.computeWorld = function() + return { + { id = "NEAR", def = def("pnear", 20, 20), ox = 20, oy = 0 }, + { id = "FAR", def = def("pfar", 20, 20), ox = 400, oy = 400 }, + } + end + Map.ensureMidLayout = function() end + Map._warmPairs = nil + Map._worldRoot = nil + Map.refreshWorld({ data = { maps = {} } }, 10, 10, "ROOT") + check(#Map._warmQueue == 2, "both pairs queued") + Map.stepWarm() + check(#loads == 1 and loads[1] == "pnear", "near pair loads first") + Map.stepWarm() + check(#loads == 1 and Map._warmQueue and #Map._warmQueue == 1, "far pair is not loaded") + package.loaded["src.core.game3.player"].cellX = 400 + package.loaded["src.core.game3.player"].cellY = 400 + Map.stepWarm() + check(#loads == 2 and loads[2] == "pfar" and Map._warmQueue == nil, "far pair loads once in range") + + Native._pairs = {} + loads = {} + package.loaded["src.core.game3.player"].cellX = 10 + package.loaded["src.core.game3.player"].cellY = 10 + Map._warmPairs = true + Map._worldRoot = nil + Map.refreshWorld({ data = { maps = {} } }, 10, 10, "ROOT") + check(#loads == 1 and loads[1] == "pnear" and Map._warmQueue and #Map._warmQueue == 1, + "warp preload only loads near pairs synchronously") + + love = { timer = { getDelta = function() return 0.2 end } } + Map._warmDefer = 0 + loads = {} + Native._pairs = {} + Map._warmQueue = { "pnear" } + Map._warmEntries = {} + Map.stepWarm() + check(#loads == 0, "slow frame defers the load") + Map.stepWarm() + Map.stepWarm() + Map.stepWarm() + check(#loads == 1, "deferral is bounded") + love = nil + package.loaded["src.core.game3.tileset_native"] = nil + package.loaded["src.core.game3.player"] = nil +end + +print(failures == 0 and "PASS game3_seam_warm_budget_test" + or ("FAIL game3_seam_warm_budget_test failures=" .. failures)) +os.exit(failures == 0 and 0 or 1) From 2e8332abc7ffb8ad81411a2019804a38f256e888 Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Wed, 30 Sep 2026 13:58:55 -0400 Subject: [PATCH 5/5] Fix Palette.hash MD5 fallback on long input --- src/core/game3/palette.lua | 10 +++++----- tests/game3_seam_warm_budget_test.lua | 1 + 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/core/game3/palette.lua b/src/core/game3/palette.lua index 5a75121c..f6bf27cc 100644 --- a/src/core/game3/palette.lua +++ b/src/core/game3/palette.lua @@ -23,7 +23,7 @@ end local bit = require("bit") local band, bor, bxor, bnot = bit.band, bit.bor, bit.bxor, bit.bnot -local lshift, rshift, rol = bit.lshift, bit.rshift, bit.rol +local lshift, rshift, rol, tobit = bit.lshift, bit.rshift, bit.rol, bit.tobit local MD5_K, MD5_S = {}, { 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, @@ -43,7 +43,7 @@ local function md5hex(msg) (len * 8) % 256, math.floor(len * 8 / 256) % 256, math.floor(len * 8 / 65536) % 256, math.floor(len * 8 / 16777216) % 256, 0, 0, 0, 0) - local a0, b0, c0, d0 = 0x67452301, 0xefcdab89, 0x98badcfe, 0x10325476 + local a0, b0, c0, d0 = 0x67452301, tobit(0xefcdab89), tobit(0x98badcfe), 0x10325476 local M = {} for off = 1, #msg, 64 do for j = 0, 15 do @@ -62,11 +62,11 @@ local function md5hex(msg) else F = bxor(C, bor(B, bnot(D))); g = (7 * i) % 16 end - F = F + A + MD5_K[i + 1] + M[g] + F = tobit(F + A + MD5_K[i + 1] + M[g]) A = D; D = C; C = B - B = B + rol(F, MD5_S[i + 1]) + B = tobit(B + rol(F, MD5_S[i + 1])) end - a0, b0, c0, d0 = a0 + A, b0 + B, c0 + C, d0 + D + a0, b0, c0, d0 = tobit(a0 + A), tobit(b0 + B), tobit(c0 + C), tobit(d0 + D) end local out = {} for _, v in ipairs({ a0, b0, c0, d0 }) do diff --git a/tests/game3_seam_warm_budget_test.lua b/tests/game3_seam_warm_budget_test.lua index e89155af..2eb17779 100644 --- a/tests/game3_seam_warm_budget_test.lua +++ b/tests/game3_seam_warm_budget_test.lua @@ -42,6 +42,7 @@ do local chunks = {} for i = 1, 4099 do chunks[i] = string.char((i * 13 + i % 7) % 256) end local extra = table.concat(chunks) + check(Palette._md5hex(extra) == "44146ace629d923823fd580cbed8c018", "md5 multi-block vector") local key = Palette.hash(bgr, extra) check(key:match("^%x+$") and #key == 16, "Palette.hash is 16 hex chars") check(Palette.hash(bgr, extra) == key, "Palette.hash is deterministic")