From a910b654342f7afa991fd1b36ba78df35f6782f8 Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Sun, 16 Aug 2026 10:04:54 -0400 Subject: [PATCH] CLOSES #1231, CLOSES #1269, CLOSES #1301 --- src/battle/gen2/BgEffects.lua | 22 ++--- src/ui/gen2/BattleAnimView.lua | 70 +++++++++++---- src/ui/gen2/BattleState.lua | 72 ++++++++++++++-- src/world/gen2/MapPreview.lua | 4 +- src/world/gen2/World.lua | 12 +-- .../engine/gen2_battler_row_lift_bug1231.lua | 68 +++++++++++++++ tests/engine/gen2_map_bake_dpi.lua | 54 ++++++++++++ tests/engine/gen2_shadow_ball_bgp_bug1269.lua | 42 +++++++++ .../gen2_shadow_ball_bgp_view_bug1269.lua | 86 +++++++++++++++++++ 9 files changed, 390 insertions(+), 40 deletions(-) create mode 100644 tests/engine/gen2_battler_row_lift_bug1231.lua create mode 100644 tests/engine/gen2_map_bake_dpi.lua create mode 100644 tests/engine/gen2_shadow_ball_bgp_view_bug1269.lua diff --git a/src/battle/gen2/BgEffects.lua b/src/battle/gen2/BgEffects.lua index 6a90356b..f4b6f1a2 100644 --- a/src/battle/gen2/BgEffects.lua +++ b/src/battle/gen2/BgEffects.lua @@ -72,11 +72,11 @@ function Pool:reset() for row = 0, SCREEN_ROWS do self.lyBackup[row] = 0 end -- wBGP / wOBP0 / wOBP1, as DMG palette bytes. self.bgp, self.obp0, self.obp1 = NORMAL_PAL, NORMAL_PAL, NORMAL_PAL - -- Per-battler state the CGB paths write instead of touching wBGP: a DMG - -- shade byte the view remaps that battler's pic through, whether the pic is - -- hidden outright, and which of the six BG squares it is drawn at. + -- Per-battler state the CGB paths write instead of touching wBGP: shade + -- byte, hidden flag, lifted tile rows, and which BG square it is drawn at. self.monShade = { player = NORMAL_PAL, enemy = NORMAL_PAL } self.hidden = { player = false, enemy = false } + self.liftedRows = { player = nil, enemy = nil } self.picSize = { player = nil, enemy = nil } self.slide = { player = 0, enemy = 0 } -- wSurfWaveBGEffect: the $40-byte rolling wave Surf keeps beside the @@ -488,6 +488,7 @@ local function runPicResize(self, st, script) self.picSize[side] = step self.hidden[side] = false end + self.liftedRows[side] = nil incJt(st) elseif jt >= 1 and jt <= 2 then incJt(st) @@ -545,7 +546,7 @@ end -- The two battler-pic objects: the animation borrows the mon's own tiles as -- an OBJ so it can be moved without touching the tilemap. -local function battlerObj(self, st, objectPlayer, objectEnemy, clearRows) +local function battlerObj(self, st, objectPlayer, objectEnemy, rows) local jt = st.jt if jt == 0 then if self:flyDig(st) then @@ -562,25 +563,26 @@ local function battlerObj(self, st, objectPlayer, objectEnemy, clearRows) } elseif jt == 1 then incJt(st) - -- The rows the OBJ now covers are cleared out of the tilemap so the mon - -- is not drawn twice. - self.hidden[self:sideKey(st)] = clearRows + -- engine/battle_anims/bg_effects.asm:448-465: the rows the OBJ now covers + -- come out of the tilemap, and .five never puts them back. + self.liftedRows[self:sideKey(st)] = rows[self:sideKey(st)] elseif jt >= 2 and jt <= 4 then incJt(st) elseif jt == 5 then - self.hidden[self:sideKey(st)] = false endEffect(st) end end E.BATTLE_BG_EFFECT_BATTLEROBJ_1ROW = function(self, st) battlerObj(self, st, "BATTLE_ANIM_OBJ_PLAYERHEAD_1ROW", - "BATTLE_ANIM_OBJ_ENEMYFEET_1ROW", true) + "BATTLE_ANIM_OBJ_ENEMYFEET_1ROW", + { player = { 0, 1 }, enemy = { 6, 1 } }) end E.BATTLE_BG_EFFECT_BATTLEROBJ_2ROW = function(self, st) battlerObj(self, st, "BATTLE_ANIM_OBJ_PLAYERHEAD_2ROW", - "BATTLE_ANIM_OBJ_ENEMYFEET_2ROW", true) + "BATTLE_ANIM_OBJ_ENEMYFEET_2ROW", + { player = { 0, 2 }, enemy = { 5, 2 } }) end -- BGEffect_RapidCyclePals. On a CGB the palette is applied to ONE battler diff --git a/src/ui/gen2/BattleAnimView.lua b/src/ui/gen2/BattleAnimView.lua index 38f848dd..13991c53 100644 --- a/src/ui/gen2/BattleAnimView.lua +++ b/src/ui/gen2/BattleAnimView.lua @@ -150,6 +150,10 @@ end -- animation (most of them) skips the canvas entirely. local function needsCanvas(runner) local bg = runner.bg + -- engine/battle_anims/bg_effects.asm:448-465: a lifted battler row stays + -- out of the BG until the next pic redraw, so those frames stay baked too. + local lifted = bg.liftedRows + if lifted and (lifted.player or lifted.enemy) then return true end if bg.scx ~= 0 or bg.scy ~= 0 then return true end if not bg.lcdc then return false end if bg.lyEnd <= bg.lyStart then return false end @@ -229,13 +233,12 @@ end -- times, and the grouping is by VALUE so a table that happens to repeat costs -- nothing extra. local function bgpBands(bg) + local base = bg.bgp or GbcPalette.BGP_IDENTITY local order, bands = {}, {} for row = 0, SCREEN_H - 1 do local inWindow = row >= bg.lyStart and row < bg.lyEnd - -- Outside the window the register still reads whatever wBGP holds, which - -- for every effect that aims hLCDCPointer at rBGP is the identity. - local byte = inWindow and (bg.lyBackup[row] or GbcPalette.BGP_IDENTITY) - or GbcPalette.BGP_IDENTITY + -- Outside the window the register still reads whatever wBGP holds. + local byte = inWindow and (bg.lyBackup[row] or base) or base local band = bands[byte] if not band then band = { byte = byte, rows = {} } @@ -244,24 +247,56 @@ local function bgpBands(bg) end band.rows[#band.rows + 1] = row end - -- Identity first so the fillBackground below it happens before any blit and - -- the common band is the one drawn from the first bake. + -- The base band first so the fillBackground below it happens before any blit + -- and the common band is the one drawn from the first bake. table.sort(order, function(a, b) if a.byte == b.byte then return false end - if a.byte == GbcPalette.BGP_IDENTITY then return true end - if b.byte == GbcPalette.BGP_IDENTITY then return false end + if a.byte == base then return true end + if b.byte == base then return false end return a.rows[1] < b.rows[1] end) return order end --- Runs `drawBg` (the battle panel) and then puts it on screen through the --- animation's BG registers. Returns without a canvas when nothing is --- displacing anything, which is the common case and costs nothing. -function BattleAnimView:present(runner, drawBg) +-- engine/battle_anims/anim_commands.asm:1293 BattleAnim_SetBGPals +function BattleAnimView:panelPalettes(battle) + local list = {} + local shades = {} + for index = 1, 4 do shades[index] = GbcPalette.color(nil, index) end + list[#list + 1] = shades + local function bracket(pair) + if not (pair and pair[1] and pair[2]) then return end + list[#list + 1] = { + { 255, 255, 255 }, + { pair[1][1], pair[1][2], pair[1][3] }, + { pair[2][1], pair[2][2], pair[2][3] }, + { 0, 0, 0 }, + } + end + for _, side in ipairs({ "player", "enemy" }) do + local mon = battle and battle[side] + local colors = mon + and Palettes.monColors(self.palettes, mon.species, mon.shiny) + if colors then list[#list + 1] = colors end + end + local hpBar = self.palettes and self.palettes.hpBar + if hpBar then + bracket(hpBar.green) + bracket(hpBar.yellow) + bracket(hpBar.red) + end + bracket(self.palettes and self.palettes.expBar) + return list +end + +-- Runs `drawBg` (the battle panel) and puts it on screen through the +-- animation's BG registers; skips the canvas when nothing needs one. +function BattleAnimView:present(runner, drawBg, battle) if not (love and love.graphics) then return end local bg = runner.bg - if not needsCanvas(runner) then + local invert = bg.bgp and bg.bgp ~= GbcPalette.BGP_IDENTITY + and bg.lcdc ~= "BGP" and GbcPalette.remapShader() ~= nil + if not invert and not needsCanvas(runner) then drawBg() return end @@ -292,9 +327,10 @@ function BattleAnimView:present(runner, drawBg) self:bake(drawBg, nil) - -- A shifted scanline exposes whatever the BG map holds beside the pic, which - -- outside the two pic boxes is the blank tile. Without this the exposed - -- strip is the canvas's own transparency and every shake shows a seam. + local remapped = invert + and GbcPalette.useRemap(self:panelPalettes(battle), bg.bgp) + -- A shifted scanline exposes the blank tile beside the pic boxes; without + -- this the exposed strip is the canvas's own transparency. self:fillBackground() G.setColor(1, 1, 1, 1) -- hSCX / hSCY move the whole background; the per-scanline overrides only @@ -314,6 +350,7 @@ function BattleAnimView:present(runner, drawBg) self:blitRow(row, dx, dy) end end + if remapped then GbcPalette.clear() 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. if bg.lcdc == "BGP" then @@ -417,5 +454,6 @@ end BattleAnimView.SCREEN_W = SCREEN_W BattleAnimView.SCREEN_H = SCREEN_H +BattleAnimView.needsCanvas = needsCanvas return BattleAnimView diff --git a/src/ui/gen2/BattleState.lua b/src/ui/gen2/BattleState.lua index 67d534c8..1db75a55 100644 --- a/src/ui/gen2/BattleState.lua +++ b/src/ui/gen2/BattleState.lua @@ -661,7 +661,7 @@ function BattleState:drawPic(mon, back) -- the mon drawn at this frame. local scale = self:picScale(path, mon, back) if anim then - px = px + (anim.slide or 0) + if not self.liftedPass then px = px + (anim.slide or 0) end local resized = anim.size and PIC_RESIZE_TILES[anim.size] if resized then scale = scale * (resized / boxTiles) end end @@ -714,11 +714,39 @@ function BattleState:drawPic(mon, back) -- A mod-supplied pic that says it is already coloured is drawn as it is: -- pokemon.sprite's ctx.trueColor, the same flag Gen 1's Sprites.path hands -- back to its own draw site. - if colors and not trueColor and GbcPalette.available() then - GbcPalette.with(colors, body) - else - body() + local function paint() + if colors and not trueColor and GbcPalette.available() then + GbcPalette.with(colors, body) + else + body() + end end + local lifted = anim and anim.lifted + if not lifted then + paint() + return + end + -- engine/battle_anims/bg_effects.asm:448-465: the ClearBoxed band is off the BG. + local bandY = (back and BattleState.PLAYER_PIC_TILE_Y + or BattleState.ENEMY_PIC_TILE_Y) * 8 + lifted[1] * 8 + local bandH = lifted[2] * 8 + local psx, psy, psw, psh + if G.getScissor then psx, psy, psw, psh = G.getScissor() end + if self.liftedPass then + G.setScissor(0, bandY, 160, bandH) + paint() + else + if bandY > 0 then + G.setScissor(0, 0, 160, bandY) + paint() + end + local below = 144 - bandY - bandH + if below > 0 then + G.setScissor(0, bandY + bandH, 160, below) + paint() + end + end + if psx then G.setScissor(psx, psy, psw, psh) else G.setScissor() end end -- MonsterSpriteGFX (gfx/sprites.asm:82): the facing-DOWN 16x16 frame for the @@ -1137,6 +1165,7 @@ function BattleState:animPicState(side) local bg = self.anim.bg return { hidden = bg.hidden[side], + lifted = bg.liftedRows and bg.liftedRows[side] or nil, size = bg.picSize[side], slide = bg.slide[side] or 0, shade = bg.monShade[side], @@ -3436,6 +3465,36 @@ function BattleState:drawScene() end end +-- data/battle_anims/objects.asm:390-397: the lifted band rides at ABSOLUTE_X, +-- outside the scanline blit, so the attacker's SCX never moves it. +function BattleState:drawLiftedRows() + local battle = self.battle + if not battle then return end + local enemy = self:animPicState("enemy") + local player = self:animPicState("player") + local enemyLift = enemy and enemy.lifted + local playerLift = player and player.lifted + if not (enemyLift or playerLift) then return end + local G = love.graphics + if not self.liftCanvas then + self.liftCanvas = G.newCanvas(160, 144) + self.liftCanvas:setFilter("nearest", "nearest") + end + local previous = G.getCanvas() + G.setCanvas(self.liftCanvas) + G.clear(0, 0, 0, 0) + G.push() + G.origin() + self.liftedPass = true + if enemyLift then self:drawPic(battle.enemy, false) end + if playerLift then self:drawPic(battle.player, true) end + self.liftedPass = nil + G.pop() + G.setCanvas(previous) + G.setColor(1, 1, 1, 1) + G.draw(self.liftCanvas, 0, 0) +end + function BattleState:drawSceneBody() local panel = function() self:drawPanel() end if self.animView and self.slideFrame < BattleAnimView.SLIDE_FRAMES then @@ -3456,7 +3515,8 @@ function BattleState:drawSceneBody() return end if self.anim and self.animView then - self.animView:present(self.anim, panel) + self.animView:present(self.anim, panel, self.battle) + self:drawLiftedRows() self.animView:drawObjects(self.anim, self.battle) return end diff --git a/src/world/gen2/MapPreview.lua b/src/world/gen2/MapPreview.lua index 27847d45..d02411ed 100644 --- a/src/world/gen2/MapPreview.lua +++ b/src/world/gen2/MapPreview.lua @@ -6,6 +6,7 @@ local Assets = require("src.render.Assets") local BorderFill = require("src.world.gen2.BorderFill") local GbcPalette = require("src.render.GbcPalette") local Palettes = require("src.world.gen2.Palettes") +local PixelCanvas = require("src.render.PixelCanvas") local MapPreview = {} @@ -96,9 +97,8 @@ function MapPreview.bake(baker, map, daytime) local blocks = tileset.blocks local tilesPerRow = tileset.tilesPerRow or 16 local pw, ph = map.width * 32, map.height * 32 - local okCanvas, canvas = pcall(love.graphics.newCanvas, pw, ph) + local okCanvas, canvas = pcall(PixelCanvas.new, pw, ph, "nearest") if not okCanvas or not canvas then return nil end - if canvas.setFilter then canvas:setFilter("nearest", "nearest") end local quads = {} local function quadFor(tile) local q = quads[tile] diff --git a/src/world/gen2/World.lua b/src/world/gen2/World.lua index 46aca5d5..a62f677f 100644 --- a/src/world/gen2/World.lua +++ b/src/world/gen2/World.lua @@ -47,6 +47,7 @@ local NPC = require("src.world.gen2.Npc") local Party = require("src.pokemon.Party") local Permissions = require("src.world.gen2.Permissions") local Pipelines = require("src.render.Pipelines") +local PixelCanvas = require("src.render.PixelCanvas") local Player = require("src.world.gen2.Player") local Pokerus = require("src.core.gen2.Pokerus") local Roamers = require("src.core.gen2.Roamers") @@ -7612,8 +7613,9 @@ function World:bakeMapImage(map, daytime, flicker) local blocks = tileset.blocks local tilesPerRow = tileset.tilesPerRow or 16 local pw, ph = map.width * 32, map.height * 32 - local canvas = love.graphics.newCanvas(pw, ph) - canvas:setFilter("nearest", "nearest") + -- Map pixels, not the screen's: a DPI-scaled canvas bakes them non-square + -- (#208, see src/render/PixelCanvas.lua). + local canvas = PixelCanvas.new(pw, ph, "nearest") local quads = {} local function quadFor(tile) local q = quads[tile] @@ -8140,12 +8142,11 @@ function World:scrollStrip(mapDef, tileset, tile, scroll) local cached = self.scrollStrips[key] if cached ~= nil then return cached or nil end local atlas = self:atlasFor(mapDef) - local ok, canvas = pcall(love.graphics.newCanvas, 8, 8 * 8) + local ok, canvas = pcall(PixelCanvas.new, 8, 8 * 8, "nearest") if not (atlas and ok and canvas) then self.scrollStrips[key] = false return nil end - canvas:setFilter("nearest", "nearest") local perRow = tileset.tilesPerRow or 16 local sx, sy = (tile % perRow) * 8, math.floor(tile / perRow) * 8 local aw, ah = atlas:getDimensions() @@ -9945,8 +9946,7 @@ function World:drawTilted(w, h, s, gw, gh) if self.tiltCanvas and self.tiltCanvas.release then self.tiltCanvas:release() end - self.tiltCanvas = G.newCanvas(gw, gh) - self.tiltCanvas:setFilter("linear", "linear") + self.tiltCanvas = PixelCanvas.new(gw, gh, "linear") end local previous = G.getCanvas() diff --git a/tests/engine/gen2_battler_row_lift_bug1231.lua b/tests/engine/gen2_battler_row_lift_bug1231.lua new file mode 100644 index 00000000..687be2ba --- /dev/null +++ b/tests/engine/gen2_battler_row_lift_bug1231.lua @@ -0,0 +1,68 @@ +-- engine/battle_anims/bg_effects.asm:406-471 BattleBGEffect_BattlerObj_1Row + +package.path = "./?.lua;./?/init.lua;" .. package.path + +love = require("tests.love_stub") + +local T = require("tests.harness") +local BgEffects = require("src.battle.gen2.BgEffects") +local BattleAnimView = require("src.ui.gen2.BattleAnimView") + +do + local bg = BgEffects.new(nil, { battleTurn = 0 }) + bg:queue("BATTLE_BG_EFFECT_BATTLEROBJ_1ROW", 0, 0, 0) + bg:playFrame() + local spawns = bg:takeSpawns() + T.eq(spawns[1] and spawns[1].object, "BATTLE_ANIM_OBJ_ENEMYFEET_1ROW", + "player attacking: the enemy's feet row becomes an OBJ") + T.eq(spawns[1] and spawns[1].x, 16 * 8 + 4, "at the asm's fixed x") + T.eq(bg.liftedRows.enemy, nil, "the tilemap row is intact on frame one") + T.eq(BattleAnimView.needsCanvas({ bg = bg }), false, + "an intact tilemap with no scroll skips the bake canvas") + bg:playFrame() + local lifted = bg.liftedRows.enemy + T.check(lifted and lifted[1] == 6 and lifted[2] == 1, + "frame two ClearBoxes row 6 of the enemy box (hlcoord 12, 6)") + T.eq(bg.hidden.enemy, false, "the rest of the pic stays on the BG") + T.eq(BattleAnimView.needsCanvas({ bg = bg }), true, + "a lifted row keeps the panel on the bake canvas even with scx 0 and no" + .. " lcdc pointer, so drawPic's 160x144 scissor stays in canvas space") + for _ = 1, 4 do bg:playFrame() end + T.eq(bg:activeCount(), 0, ".five ends the effect") + lifted = bg.liftedRows.enemy + T.check(lifted and lifted[1] == 6 and lifted[2] == 1, + ".five never restores the row") + T.eq(BattleAnimView.needsCanvas({ bg = bg }), true, + "and the wait frames after .five stay baked as well") + bg:queue("BATTLE_BG_EFFECT_SHOW_MON", 0, 0, 0) + bg:playFrame() + T.eq(bg.liftedRows.enemy, nil, "SHOW_MON's box redraw puts the row back") + T.eq(BattleAnimView.needsCanvas({ bg = bg }), false, + "after which the plain no-canvas path returns") +end + +do + local bg = BgEffects.new(nil, { battleTurn = 1 }) + bg:queue("BATTLE_BG_EFFECT_BATTLEROBJ_2ROW", 0, 0, 0) + bg:playFrame() + local spawns = bg:takeSpawns() + T.eq(spawns[1] and spawns[1].object, "BATTLE_ANIM_OBJ_PLAYERHEAD_2ROW", + "enemy attacking: the player's head rows become an OBJ") + T.eq(spawns[1] and spawns[1].x, 6 * 8, "at the asm's fixed x") + bg:playFrame() + local lifted = bg.liftedRows.player + T.check(lifted and lifted[1] == 0 and lifted[2] == 2, + "rows 0-1 of the player box (hlcoord 2, 6, two rows)") + T.eq(bg.liftedRows.enemy, nil, "the attacker keeps its own rows") +end + +do + local bg = BgEffects.new(nil, { battleTurn = 0, flying = { enemy = true } }) + bg:queue("BATTLE_BG_EFFECT_BATTLEROBJ_1ROW", 0, 0, 0) + bg:playFrame() + T.eq(#bg:takeSpawns(), 0, "a flying target spawns nothing") + T.eq(bg:activeCount(), 0, "and the effect ends at once") + T.eq(bg.liftedRows.enemy, nil, "with no row lifted") +end + +T.finish("gen2 battler row lift bug 1231") diff --git a/tests/engine/gen2_map_bake_dpi.lua b/tests/engine/gen2_map_bake_dpi.lua new file mode 100644 index 00000000..b53c2aba --- /dev/null +++ b/tests/engine/gen2_map_bake_dpi.lua @@ -0,0 +1,54 @@ +-- Gen 2 map bakes take dpiscale 1 so map pixels stay square LCD pixels +-- (#208 #1301, constants/hardware.inc:932; see src/render/PixelCanvas.lua). +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") + +local g = love.graphics +local seen = {} +local realNewCanvas = g.newCanvas +g.newCanvas = function(w, h, settings) + seen[#seen + 1] = { w = w, h = h, dpiscale = settings and settings.dpiscale } + local c = realNewCanvas(w, h) + c.renderTo = function(_, fn) fn() end + c.setFilter = function() end + return c +end + +local World = require("src.world.gen2.World") +local MapPreview = require("src.world.gen2.MapPreview") + +local atlas = { + getDimensions = function() return 128, 128 end, + setFilter = function() end, +} +local tileset = { blocks = { [1] = {} }, tilesPerRow = 16 } +local map = { def = { tileset = "TILESET_JOHTO" }, width = 20, height = 18, + blocks = {}, borderBlock = 0 } + +local world = setmetatable({ + atlasCache = {}, + atlasFor = function() return atlas, tileset end, +}, { __index = World }) + +World.bakeMapImage(world, map, nil, nil) +local bakeCount = #seen +T.check(bakeCount >= 1, "the map bake allocated a canvas") + +World.scrollStrip(world, map.def, tileset, 3, { h = 1, v = 0 }) +T.check(#seen > bakeCount, "the scroll strip allocated a canvas") +local stripCount = #seen + +local origAtlasFor = MapPreview.atlasFor +MapPreview.atlasFor = function() return atlas, tileset end +MapPreview.bake({ tilesets = {}, atlasCache = {}, mapImages = {} }, map, "DAY") +MapPreview.atlasFor = origAtlasFor +T.check(#seen > stripCount, "the save-editor bake allocated a canvas") + +for i, c in ipairs(seen) do + T.eq(c.dpiscale, 1, + ("canvas %d (%dx%d) is allocated at dpiscale 1"):format(i, c.w, c.h)) +end + +g.newCanvas = realNewCanvas +T.finish("gen2 map bake dpi") diff --git a/tests/engine/gen2_shadow_ball_bgp_bug1269.lua b/tests/engine/gen2_shadow_ball_bgp_bug1269.lua index e1287c1b..82b07123 100644 --- a/tests/engine/gen2_shadow_ball_bgp_bug1269.lua +++ b/tests/engine/gen2_shadow_ball_bgp_bug1269.lua @@ -44,4 +44,46 @@ do "the identity byte returns the palette untouched") end +-- engine/battle_anims/anim_commands.asm:1293 BattleAnim_SetBGPals +do + local BattleAnimView = require("src.ui.gen2.BattleAnimView") + local palettes = { + pokemon = { + [6] = { normal = { { 200, 100, 50 }, { 80, 40, 20 } } }, + [25] = { normal = { { 230, 200, 40 }, { 150, 90, 20 } } }, + }, + hpBar = { + green = { { 100, 220, 100 }, { 30, 160, 30 } }, + yellow = { { 230, 220, 90 }, { 180, 150, 20 } }, + red = { { 230, 100, 90 }, { 180, 30, 20 } }, + }, + expBar = { { 120, 140, 230 }, { 40, 60, 160 } }, + } + local view = BattleAnimView.new({}, palettes) + local battle = { player = { species = 6 }, enemy = { species = 25 } } + local list = view:panelPalettes(battle) + T.eq(#list, 7, "shades + two mons + three hp bars + exp bar") + local src, dst, count, ambiguous = GbcPalette.remapTable(list, 0x1b) + T.check(count > 0 and count <= GbcPalette.REMAP_MAX, + "the table fits the shader array") + T.eq(ambiguous, 0, "no colour maps two ways") + local function mapped(from) + for i = 1, count do + if src[i][1] == from[1] and src[i][2] == from[2] + and src[i][3] == from[3] then + return dst[i] + end + end + end + local black = mapped({ 255, 255, 255 }) + T.check(black and black[1] == 0 and black[2] == 0 and black[3] == 0, + "white inverts to black") + local white = mapped({ 0, 0, 0 }) + T.check(white and white[1] == 255 and white[2] == 255 and white[3] == 255, + "black inverts to white") + local mid = mapped({ 200, 100, 50 }) + T.check(mid and mid[1] == 80 and mid[2] == 40 and mid[3] == 20, + "the mon's colour 1 shows its colour 2") +end + T.finish("gen2 shadow ball bgp bug 1269") diff --git a/tests/engine/gen2_shadow_ball_bgp_view_bug1269.lua b/tests/engine/gen2_shadow_ball_bgp_view_bug1269.lua new file mode 100644 index 00000000..132f006f --- /dev/null +++ b/tests/engine/gen2_shadow_ball_bgp_view_bug1269.lua @@ -0,0 +1,86 @@ +-- engine/battle_anims/anim_commands.asm:1293 BattleAnim_SetBGPals +-- +-- gen2_shadow_ball_bgp_bug1269.lua proves the runner lands bg.bgp and that +-- panelPalettes/remapTable would invert correctly; it never calls +-- BattleAnimView:present, which is where #1269 actually lived (the byte +-- was landed but nothing read it). This suite drives present() itself and +-- watches the shader binding around the backdrop draw. + +package.path = "./?.lua;./?/init.lua;" .. package.path + +love = require("tests.love_stub") + +-- A remap shader that never touches the GPU: present() only needs +-- love.graphics.newShader to succeed so GbcPalette.remapShader() is +-- non-nil, which is the gate `present` checks before it will bake+remap. +local sentShader = { calls = {} } +function sentShader:send(name, ...) self.calls[#self.calls + 1] = name end +love.graphics.newShader = function() return sentShader end + +-- The stub's stand-in Quad has no setViewport/getViewport, which blitRow +-- (the scanline blit present() drives 144 times a frame) needs; the real +-- love.graphics.Quad has both. +love.graphics.newQuad = function(x, y, w, h) + local q = { x = x, y = y, w = w, h = h } + function q:setViewport(x2, y2, w2, h2) self.x, self.y, self.w, self.h = x2, y2, w2, h2 end + function q:getViewport() return self.x, self.y, self.w, self.h end + return q +end + +local T = require("tests.harness") +local GbcPalette = require("src.render.GbcPalette") +local BattleAnimView = require("src.ui.gen2.BattleAnimView") + +local shaderDuringFill = "unset" +local realRectangle = love.graphics.rectangle +love.graphics.rectangle = function(...) + if shaderDuringFill == "unset" then + shaderDuringFill = love.graphics.getShader() + end + return realRectangle(...) +end + +local view = BattleAnimView.new({}, nil) +-- data/moves/animations.asm:4509 BattleAnim_ShadowBall's anim_bgp $1b, with +-- no scroll and no rBGP window queued, is exactly the frame that used to +-- fall through the old `needsCanvas`-only early-out untouched. +local runner = { + bg = { + bgp = 0x1b, + lcdc = nil, + scx = 0, + scy = 0, + lyStart = 0, + lyEnd = 0, + lyBackup = {}, + }, +} + +T.check(love.graphics.getShader() == nil, "no shader bound before present") + +view:present(runner, function() end, nil) + +T.check(shaderDuringFill == sentShader, + "the panel backdrop is drawn through the remap shader, not plainly") +T.check(love.graphics.getShader() == nil, + "the shader is unbound again once present returns, so drawObjects is unaffected") + +local sawRemapSend = false +for _, name in ipairs(sentShader.calls) do + if name == "remapSrc" then sawRemapSend = true end +end +T.check(sawRemapSend, "GbcPalette.useRemap actually sent a remap table, not just bound the shader") + +-- The identity byte must take the plain path: no bake, no shader, ever. +shaderDuringFill = "unset" +local identityRunner = { + bg = { bgp = GbcPalette.BGP_IDENTITY, lcdc = nil, scx = 0, scy = 0, + lyStart = 0, lyEnd = 0, lyBackup = {} }, +} +local plainDrawCalled = false +view:present(identityRunner, function() plainDrawCalled = true end, nil) +T.check(plainDrawCalled, "identity rBGP takes the plain drawBg() path") +T.check(shaderDuringFill == "unset", + "identity rBGP never touches the remap shader") + +T.finish("gen2 shadow ball bgp view bug 1269")