Files
gen1recomp/tests/drivers/game3_deoxys_visual.lua
Shane McGovern 6d3af84fde fix(game3): make the Deoxys rock redden and its shards visible
Two Birth Island visuals were broken; both are now verified on screen.

1. The meteorite never reddened as the triangle puzzle progressed.
   OwSprites.recolour_sprite called Image:newImageData, which LOVE 11 does
   not have, so the pcall always failed and setObjectPalette silently
   returned false for all ten steps.  Keep the ImageData the sheet was
   decoded from in load_one and clone() it before mapPixel (which mutates
   in place); the override copy keeps its imageData in step too.

2. The shatter happened, but the shards were invisible.  The flash was a
   whole-screen Renderer.screenVeil painted after the composite, and the
   shards only live ~8 frames, so they were drawn underneath it.  pret
   (field_effect.c:3946) blends PALETTES_BG only -- BlendPalettes plus
   BeginNormalPaletteFade -- so the OBJ shards, which share the
   meteorite's palette tag 4371, keep their colours against a white map.
   Replace startWhiteFlash with startBgFlash, drawn by FieldView.draw
   between the last map layer and the actors.  Also match pret's shake
   cadence (flip every second frame) and its `++tTimer > 120` test.

Adds tests/drivers/game3_deoxys_visual.lua, which walks all eleven puzzle
positions, shatters the rock, and counts shard-coloured pixels in the
saved frames: 492 px at the shatter frame, 192 px a few frames later
(0 before this fix).

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
2026-09-21 19:39:12 +01:00

289 lines
11 KiB
Lua

-- Visual reproduction harness for the Birth Island Deoxys triangle.
--
-- Boots a fresh FireRed game, warps to Birth Island, walks the meteorite through
-- all eleven puzzle positions and then shatters it. A PNG is captured at every
-- stage and the rock's *drawn* colours are sampled straight out of the sprite
-- the renderer would use, so the palette ramp can be checked as data rather
-- than by eye alone.
local U = require("tests.drivers.util")
local DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/deoxys_visual"
local MAP = "FR_BIRTH_ISLAND_EXTERIOR"
local METEORITE = 106
local failures = 0
local function result(ok, label)
print((ok and "PASS " or "FAIL ") .. label)
if not ok then failures = failures + 1 end
return ok
end
local function finish()
if failures == 0 then
print("PASS deoxys_visual")
love.event.quit(0)
else
print("FAIL deoxys_visual failures=" .. failures)
love.event.quit(1)
end
end
--- Count pixels in a saved shot that match one of the rock fragment's palette
--- colours. The fragment sheet is imported with sDeoxysObjectPals[10] (the
--- awakened red ramp) and the map never contains those exact reds, so any hit
--- is a flying shard actually reaching the screen. This is what catches the
--- shards being buried under the shatter's white flash: with a whole-screen
--- veil the count is exactly 0.
local FRAG_RGB = { { 206, 33, 33 }, { 255, 82, 82 }, { 255, 206, 156 } }
--- love.graphics.captureScreenshot writes the PNG with a plain io.open, which
--- can reach any absolute path -- but love.image.newImageData only reads paths
--- inside LOVE's sandboxed source/save filesystems, so it cannot open SHOT_DIR.
--- Read the bytes ourselves and hand LOVE a FileData instead. U.shot has
--- already confirmed the file is on disk, so this never needs to yield frames
--- (yielding here would let the shards fly off screen before they are measured).
local function fragPixels(path)
local f = io.open(path, "rb")
if not f then return -1 end
local bytes = f:read("*a")
f:close()
local ok, data = pcall(function()
return love.image.newImageData(love.filesystem.newFileData(bytes, "shot.png"))
end)
if not ok or not data then return -1 end
local w, h = data:getWidth(), data:getHeight()
local hits = 0
for y = 0, h - 1, 2 do
for x = 0, w - 1, 2 do
local r, g, b = data:getPixel(x, y)
r, g, b = r * 255, g * 255, b * 255
for _, c in ipairs(FRAG_RGB) do
if math.abs(r - c[1]) < 6 and math.abs(g - c[2]) < 6
and math.abs(b - c[3]) < 6 then
hits = hits + 1
break
end
end
end
end
return hits
end
--- The distinct opaque colours actually present in the sprite the renderer
--- would draw for `graphicsId`, most common first. This reads the sprite the
--- renderer uses (so it reflects the palette override, not the module's own
--- tables), and the override keeps its ImageData in step with its texture.
local function drawnColours(OwSprites, graphicsId)
local spr = OwSprites.getDraw and OwSprites.getDraw(graphicsId)
if not spr then return nil end
local data
if spr.imageData then
local ok, copy = pcall(function() return spr.imageData:clone() end)
if ok then data = copy end
end
if not data then return nil end
local counts = {}
local okMap = pcall(function()
data:mapPixel(function(_, _, r, g, b, a)
if a > 0 then
local key = string.format("%d,%d,%d",
math.floor(r * 255 + 0.5), math.floor(g * 255 + 0.5),
math.floor(b * 255 + 0.5))
counts[key] = (counts[key] or 0) + 1
end
return r, g, b, a
end)
end)
if not okMap then return nil end
local list = {}
for key, n in pairs(counts) do list[#list + 1] = { key = key, n = n } end
table.sort(list, function(x, y)
if x.n == y.n then return x.key < y.key end
return x.n > y.n
end)
return list
end
--- Comma-joined colour set, for set comparisons.
local function colourSet(list)
if not list then return nil end
local keys = {}
for _, e in ipairs(list) do keys[#keys + 1] = e.key end
table.sort(keys)
return table.concat(keys, " ")
end
local function rampSet(pal)
if not pal then return nil end
local keys = {}
for _, c in ipairs(pal) do
keys[#keys + 1] = string.format("%d,%d,%d", c[1], c[2], c[3])
end
table.sort(keys)
return table.concat(keys, " ")
end
local function dominantRed(list)
if not (list and list[1]) then return nil end
return tonumber(list[1].key:match("^(%d+),"))
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 Runtime = require("src.core.game3.runtime")
local Map = require("src.core.game3.map")
local Space = require("src.core.game3.scripting.space")
local Objects = require("src.core.game3.objects")
local Player = require("src.core.game3.player")
local Deoxys = require("src.core.game3.deoxys")
local FieldEffects = require("src.core.game3.field_effects")
local OwSprites = require("src.core.game3.ow_sprites")
local session = Runtime.getSession()
if not result(session ~= nil, "new game reached the game3 field") then
return finish()
end
local function goTo(mapId, x, y, facing)
Map.load(nil, game, mapId, { x = x, y = y, facing = facing or "up" })
if game.session then
game.session.x, game.session.y, game.session.facing = x, y, facing or "up"
end
Player.cellX, Player.cellY = x, y
Player.px, Player.py = x * 16, y * 16
Player.targetX, Player.targetY = x, y
Player.facing = facing or "up"
U.wait(30)
local Preview = package.loaded["src.ui.game3.map_preview_screen"]
for _ = 1, 240 do
if not (Preview and Preview.isActive and Preview.isActive()) then break end
U.wait(5)
end
U.wait(60)
end
-- Stand one tile south of the rock's starting position (field_specials.c
-- COORDS[1] == {15,12}) so it is on camera.
goTo(MAP, 15, 13, "up")
local rock, rockId = Deoxys.resolveRockObject()
result(rock ~= nil, "the Birth Island meteorite object is on the map")
if not rock then
U.shot(game, DIR .. "/00_no_rock.png")
return finish()
end
U.log("rock localId=" .. tostring(rockId) ..
" graphicsId=" .. tostring(rock.graphicsId) ..
" cell=(" .. tostring(rock.cellX or rock.x) .. "," ..
tostring(rock.cellY or rock.y) .. ")")
result(tonumber(rock.graphicsId) == METEORITE,
"the rock uses OBJ_EVENT_GFX_METEORITE (106), got " ..
tostring(rock.graphicsId))
U.shot(game, DIR .. "/01_position_0.png")
local gid = tonumber(rock.graphicsId)
local base = OwSprites.get(gid)
result(base ~= nil and base.imageData ~= nil,
"the meteorite sprite keeps its decoded pixels for palette swaps")
local before = drawnColours(OwSprites, gid)
U.log("position 0 drawn colours: " .. tostring(colourSet(before)))
result(colourSet(before) == rampSet(Deoxys.sourcePalette()),
"position 0 draws the source grey ramp " ..
tostring(rampSet(Deoxys.sourcePalette())) ..
", got " .. tostring(colourSet(before)))
-- ---------------------------------------------------------------- the ramp
-- Ten successful interactions walk the rock to position 10; the eleventh
-- awakens Deoxys (handled by the script, not here).
local seen, reds = {}, {}
for step = 1, 10 do
Deoxys.interact(session)
U.wait(45) -- let the move field effect finish
local colours = drawnColours(OwSprites, gid)
seen[step] = colourSet(colours)
reds[step] = dominantRed(colours)
U.log(string.format("position %d: key=%s colours=%s", step,
tostring(OwSprites.objectPaletteKey(gid)), tostring(seen[step])))
U.shot(game, string.format("%s/02_position_%02d.png", DIR, step))
end
result(seen[1] ~= seen[10],
"the rock's drawn colours change across the puzzle")
local monotonic = true
for step = 2, 10 do
if not (reds[step] and reds[step - 1] and reds[step] >= reds[step - 1]) then
monotonic = false
U.log(string.format("red went backwards at step %d: %s -> %s",
step, tostring(reds[step - 1]), tostring(reds[step])))
end
end
result(monotonic, "the rock reddens monotonically across the ten steps")
result(seen[10] == rampSet(Deoxys.palette(10)),
"position 10 draws the awakened red ramp " ..
tostring(rampSet(Deoxys.palette(10))) ..
", got " .. tostring(seen[10]))
-- ------------------------------------------------------------- the shatter
local anim = FieldEffects.startDestroyDeoxysRock(rockId)
result(anim ~= nil, "startDestroyDeoxysRock created the shatter animation")
if anim then
U.wait(30)
result(anim.state == "shake",
"the rock shakes before breaking, got " .. tostring(anim.state))
U.shot(game, DIR .. "/03_shatter_shake.png")
-- Task_DeoxysRockCameraShake runs for 120 frames, then the shards fly for
-- only ~16 frames, so poll instead of guessing at a delay.
for _ = 1, 200 do
if anim.state ~= "shake" then break end
U.wait(1)
end
result(anim.state == "shatter",
"the animation reached the shatter state, got " .. tostring(anim.state))
local sheet = FieldEffects._sheets["deoxys_rock_fragments"]
result(sheet ~= nil and sheet ~= false,
"the rock fragment sheet loaded (4 x 8x8)")
U.shot(game, DIR .. "/04_shatter_fragments.png")
-- The white flash must be a *background* veil (pret blends PALETTES_BG
-- only), so the shards stay visible on top of it.
local frag04 = fragPixels(DIR .. "/04_shatter_fragments.png")
U.log(" fragment pixels on screen at the shatter frame: " .. frag04)
result(frag04 > 0,
"the shards are visible through the shatter flash, got " ..
frag04 .. " px (0 means the flash covered them)")
local alive = 0
for _, f in ipairs(anim.frags or {}) do
if not f.off then alive = alive + 1 end
U.log(string.format(" frag frame=%s at (%d,%d) off=%s",
tostring(f.frame), math.floor(f.x), math.floor(f.y), tostring(f.off)))
end
result(#(anim.frags or {}) == 4 and alive > 0,
"four shards are flying, " .. alive .. " still on screen")
-- Only a frame or two later: pret destroys a fragment once it leaves the
-- 244x168 display, and at 16px/frame a shard crosses the whole screen in
-- ~8 frames, so a longer wait would correctly find an empty screen.
U.wait(1)
U.shot(game, DIR .. "/05_shatter_spread.png")
local frag05 = fragPixels(DIR .. "/05_shatter_spread.png")
U.log(" fragment pixels on screen once they have spread: " .. frag05)
result(frag05 > 0,
"the shards are still flying a few frames later, got " .. frag05 .. " px")
end
finish()
end