mirror of
https://github.com/bryanthaboi/gen1recomp
synced 2026-09-26 13:33:27 -04:00
6d3af84fde
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>
289 lines
11 KiB
Lua
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
|