mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 00:10:56 +02:00
318 lines
13 KiB
Lua
318 lines
13 KiB
Lua
-- Driver: in SGB mode a character wears the palette of the map it stands on,
|
|
-- not the GBC boot ROM's object palette (#301). pokered never sends OBJ_TRN
|
|
-- (data/sgb/sgb_packets.asm), and home/fade.asm:68 FadePal4 leaves rOBP0 = $D0,
|
|
-- so OBJ colours lift to DMG shades 0/1/3 and the zone shader owns the colour.
|
|
-- POKEPORT_DRIVER=tests/drivers/sgb_people_palette_bug301_test.lua \
|
|
-- POKEPORT_IDENTITY=bug301 POKEPORT_VERSION=red SHOT_DIR=/tmp/shots love .
|
|
return function(game)
|
|
local U = dofile("tests/drivers/util.lua")
|
|
local Probe = dofile("tests/drivers/shot_probe.lua")
|
|
local PaletteFX = require("src.render.PaletteFX")
|
|
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
|
|
|
local fails = 0
|
|
local function check(ok, msg)
|
|
U.log(ok and "PASS" or "FAIL", msg)
|
|
if not ok then fails = fails + 1 end
|
|
return ok
|
|
end
|
|
local function rgb(c)
|
|
return c and ("(%d,%d,%d)"):format(c[1], c[2], c[3]) or "nil"
|
|
end
|
|
local function ramp(p)
|
|
if not p then return "nil" end
|
|
local s = {}
|
|
for i = 1, 4 do s[i] = rgb(p[i]) end
|
|
return table.concat(s, " ")
|
|
end
|
|
|
|
-- a party + starter flag so the overworld behaves like a real save
|
|
game.save.flags.EVENT_GOT_STARTER = true
|
|
local Pokemon = require("src.pokemon.Pokemon")
|
|
if #game.save.party == 0 then
|
|
table.insert(game.save.party, Pokemon.new(game.data, "CHARMANDER", 5))
|
|
end
|
|
-- Game:applyOptions re-reads save.options.colors every frame's worth of
|
|
-- option handling, so a bare setMode() would be reverted under us.
|
|
game.save.options = game.save.options or {}
|
|
game.save.options.colors = "gbc"
|
|
PaletteFX.setMode("gbc")
|
|
|
|
-- ---- Part 1: the render decisions no screenshot can spell out -----------
|
|
check(PaletteFX.usesSpriteObp("gbc") == false,
|
|
"SGB bakes NO object palette (it cannot colour an OBJ apart from the BG)")
|
|
check(PaletteFX.usesSpriteObp("ogred") == true,
|
|
"OG RED still does (the GBC boot ROM really does hand out one OBJ palette)")
|
|
check(PaletteFX.usesSpriteObp("og") == false
|
|
and PaletteFX.usesSpriteObp("og_inv") == false
|
|
and PaletteFX.usesSpriteObp("gbc_inv") == false
|
|
and PaletteFX.usesSpriteObp("classic") == false
|
|
and PaletteFX.usesSpriteObp("redpp") == false,
|
|
"no other mode bakes one either")
|
|
|
|
local obp = PaletteFX.dmgObj()
|
|
U.log("rOBP0 bake ramp:", ramp(obp))
|
|
-- FadePal4's second entry, `dc 3,1,0,0` = $D0. Index 1 is never read (OBJ
|
|
-- colour 0 is keyed to alpha); 2..4 are OBJ colours 1..3 as shades 0/1/3.
|
|
check(obp ~= nil and obp[2][1] == 255, "OBJ colour 1 -> DMG shade 0 (255)")
|
|
check(obp ~= nil and obp[3][1] == 170, "OBJ colour 2 -> DMG shade 1 (170)")
|
|
check(obp ~= nil and obp[4][1] == 0, "OBJ colour 3 -> DMG shade 3 (0)")
|
|
local grey = true
|
|
for i = 1, 4 do
|
|
if obp[i][1] ~= obp[i][2] or obp[i][2] ~= obp[i][3] then grey = false end
|
|
end
|
|
check(grey, "the bake stays in DMG GREYS, so the zone shader still owns the colour")
|
|
|
|
local OGOBJ = PaletteFX.ogObj()
|
|
local clash = false
|
|
for i = 1, 4 do
|
|
for j = 1, 4 do
|
|
if obp[i][1] == OGOBJ[j][1] and obp[i][2] == OGOBJ[j][2]
|
|
and obp[i][3] == OGOBJ[j][3] and OGOBJ[j][1] ~= OGOBJ[j][2] then
|
|
clash = true
|
|
end
|
|
end
|
|
end
|
|
check(not clash, "no boot-ROM object colour is baked into an SGB sprite")
|
|
|
|
-- ---- Part 2: stand next to a person, on two maps with unlike palettes ---
|
|
local function npcNamed(ow, name)
|
|
for _, n in ipairs(ow.npcs or {}) do
|
|
if n.def and n.def.name == name then return n end
|
|
end
|
|
return nil
|
|
end
|
|
|
|
-- Teleport to `stand`; if a map edit or a mod moved the NPC out of reach,
|
|
-- take any free walkable neighbour of where it actually is and face back.
|
|
local function standNextTo(mapId, npcName, stand)
|
|
U.teleport(game, mapId, stand.x, stand.y, stand.facing)
|
|
U.wait(12)
|
|
local ow = game.overworld
|
|
local npc = ow and npcNamed(ow, npcName)
|
|
if not npc then
|
|
check(false, npcName .. " object loaded on " .. mapId)
|
|
return ow, nil
|
|
end
|
|
local fx, fy = ow.player:facingCell()
|
|
if ow:npcAtCell(fx, fy) ~= npc then
|
|
local sides = {
|
|
{ 0, 1, "up" }, { 0, -1, "down" }, { 1, 0, "left" }, { -1, 0, "right" },
|
|
}
|
|
for _, s in ipairs(sides) do
|
|
local cx, cy = npc.cellX + s[1], npc.cellY + s[2]
|
|
if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then
|
|
U.log(("stand cell (%d,%d) is no longer beside %s -- using")
|
|
:format(stand.x, stand.y, npcName), cx, cy, "facing", s[3])
|
|
U.teleport(game, mapId, cx, cy, s[3])
|
|
U.wait(12)
|
|
ow = game.overworld
|
|
npc = npcNamed(ow, npcName)
|
|
break
|
|
end
|
|
end
|
|
end
|
|
local ax, ay = ow.player:facingCell()
|
|
check(npc ~= nil and ow:npcAtCell(ax, ay) == npc,
|
|
"player is standing face to face with " .. npcName .. " on " .. mapId)
|
|
return ow, npc
|
|
end
|
|
|
|
-- Every colour a correct SGB frame may contain: the map's own four plus the
|
|
-- letterbox black. Before the fix the boot-ROM greens were on screen and
|
|
-- belonged to no palette the map ever asked for.
|
|
local function paletteAudit(label, palette)
|
|
local shot = Probe.grab()
|
|
if not shot then
|
|
U.log("WARN pixel probe unavailable; judge", label, "by eye only")
|
|
return
|
|
end
|
|
local top, total = Probe.top(shot, 8)
|
|
U.log(("probe[%s] %d px sampled, top colours: %s")
|
|
:format(label, total, Probe.fmt(top)))
|
|
local allowed = { { 0, 0, 0 } }
|
|
for i = 1, 4 do allowed[#allowed + 1] = palette[i] end
|
|
local strays = {}
|
|
for _, c in ipairs(top) do
|
|
local ok = false
|
|
for _, a in ipairs(allowed) do
|
|
if c[1] == a[1] and c[2] == a[2] and c[3] == a[3] then ok = true end
|
|
end
|
|
-- ignore the thin filtered edges: only a colour with real area on
|
|
-- screen is evidence of a wrong palette
|
|
if not ok and c.share > 0.002 then
|
|
strays[#strays + 1] = ("(%d,%d,%d) %.1f%%")
|
|
:format(c[1], c[2], c[3], c.share * 100)
|
|
end
|
|
end
|
|
check(#strays == 0, label
|
|
.. ": every colour with real area comes from the map's own palette"
|
|
.. (#strays > 0 and (" -- strays: " .. table.concat(strays, ", ")) or ""))
|
|
return shot
|
|
end
|
|
|
|
local function countIn(shot, wanted)
|
|
if not shot then return nil end
|
|
local counts = Probe.count(shot, wanted)
|
|
local parts = {}
|
|
for name, n in pairs(counts) do parts[#parts + 1] = ("%s=%d"):format(name, n) end
|
|
table.sort(parts)
|
|
U.log(" ", table.concat(parts, " "))
|
|
return counts
|
|
end
|
|
|
|
-- OBJ colour 0 is transparent on the hardware and the bake keys it to alpha.
|
|
-- A love Image exposes no pixels, so the only way to read that back is to
|
|
-- draw it into a cleared canvas. A guard, not a gate: the extracted sheets
|
|
-- already ship a tRNS key, so this only fires if a later change hands a
|
|
-- pipeline or the tilt pass an unkeyed image and the sprite grows a backdrop.
|
|
local function transparentShare(spr)
|
|
if not (spr and spr.resolveImage and love.graphics.newCanvas) then return nil end
|
|
local ok, share = pcall(function()
|
|
local img = spr:resolveImage()
|
|
local w, h = img:getWidth(), img:getHeight()
|
|
local cv = love.graphics.newCanvas(w, h)
|
|
love.graphics.setCanvas(cv)
|
|
love.graphics.clear(0, 0, 0, 0)
|
|
love.graphics.setColor(1, 1, 1, 1)
|
|
love.graphics.draw(img, 0, 0)
|
|
love.graphics.setCanvas()
|
|
local id = cv:newImageData()
|
|
local clear = 0
|
|
for y = 0, h - 1 do
|
|
for x = 0, w - 1 do
|
|
local _, _, _, a = id:getPixel(x, y)
|
|
if a < 0.02 then clear = clear + 1 end
|
|
end
|
|
end
|
|
return clear / (w * h)
|
|
end)
|
|
love.graphics.setCanvas()
|
|
return ok and share or nil
|
|
end
|
|
|
|
local GBC_OBJ = PaletteFX.ogObj()
|
|
local BOOT = { bootGreen = GBC_OBJ[2], bootDarkGreen = GBC_OBJ[3] }
|
|
|
|
-- ---- ROUTE_1, beside a YOUNGSTER, in grass -----------------------------
|
|
-- ../pokered/data/maps/objects/Route1.asm: ROUTE1_YOUNGSTER2 walks
|
|
-- left/right on (15, 13), so (14, 13) facing right is beside it.
|
|
local ROUTE = PaletteFX.pal(game.data, "ROUTE")
|
|
U.log("ROUTE palette:", ramp(ROUTE))
|
|
local ow = standNextTo("ROUTE_1", "ROUTE1_YOUNGSTER2",
|
|
{ x = 14, y = 13, facing = "right" })
|
|
U.wait(40) -- let the grass / flower tile animation cycle
|
|
U.shot(game, DIR .. "/bug301_1_route1_sgb.png")
|
|
local shot = paletteAudit("ROUTE_1 / SGB", ROUTE)
|
|
local c = countIn(shot, {
|
|
bootGreen = BOOT.bootGreen, bootDarkGreen = BOOT.bootDarkGreen,
|
|
routeGrassGreen = ROUTE[2], routeLightBlue = ROUTE[3],
|
|
})
|
|
if c then
|
|
check(c.bootGreen == 0 and c.bootDarkGreen == 0,
|
|
"no Game Boy Color boot-ROM green anywhere on the SGB screen (#301)")
|
|
check(c.routeGrassGreen > 0,
|
|
"ROUTE's own grass green IS on screen (the cap and the grass share it, #150)")
|
|
end
|
|
|
|
local spr = ow and ow.player and ow.player.sprite
|
|
check(spr ~= nil and spr.image ~= nil and spr:resolveImage() ~= spr.image,
|
|
"the player's sprite resolves to a BAKED image in SGB, not the raw sheet")
|
|
local share = transparentShare(spr)
|
|
if share == nil then
|
|
U.log("WARN could not read the baked sprite back; judge transparency by eye")
|
|
else
|
|
U.log(("baked player sheet is %.1f%% fully transparent"):format(share * 100))
|
|
check(share > 0.1,
|
|
"OBJ colour 0 stays keyed to alpha, so a character has no backdrop")
|
|
end
|
|
|
|
-- ---- LAVENDER_TOWN, beside a COOLTRAINER, on a PINK palette ------------
|
|
-- ../pokered/data/maps/objects/LavenderTown.asm: LAVENDERTOWN_COOLTRAINER_M
|
|
-- stands on (9, 10), so (9, 9) facing down is beside it. No green anywhere
|
|
-- in this palette, so a green person here is unmistakable.
|
|
local LAVENDER = PaletteFX.pal(game.data, "LAVENDER")
|
|
U.log("LAVENDER palette:", ramp(LAVENDER))
|
|
standNextTo("LAVENDER_TOWN", "LAVENDERTOWN_COOLTRAINER_M",
|
|
{ x = 9, y = 9, facing = "down" })
|
|
U.wait(24)
|
|
U.shot(game, DIR .. "/bug301_2_lavender_sgb.png")
|
|
shot = paletteAudit("LAVENDER_TOWN / SGB", LAVENDER)
|
|
c = countIn(shot, {
|
|
bootGreen = BOOT.bootGreen, bootDarkGreen = BOOT.bootDarkGreen,
|
|
lavenderPink = LAVENDER[2], routeGrassGreen = ROUTE[2],
|
|
})
|
|
if c then
|
|
check(c.bootGreen == 0 and c.bootDarkGreen == 0,
|
|
"no boot-ROM green in LAVENDER TOWN either")
|
|
check(c.lavenderPink > 0,
|
|
"the characters here wear LAVENDER's pink -- they changed with the map")
|
|
check(c.routeGrassGreen == 0,
|
|
"and no ROUTE green followed them over (nothing is palette-pinned)")
|
|
end
|
|
|
|
-- ---- OG RED, the mode that IS supposed to have an object palette -------
|
|
-- A GBC boots the cartridge with one BG and one OBJ palette, so there the
|
|
-- people really are green. Losing that green means the fix went too far.
|
|
game.save.options.colors = "ogred"
|
|
PaletteFX.setMode("ogred")
|
|
standNextTo("ROUTE_1", "ROUTE1_YOUNGSTER2",
|
|
{ x = 14, y = 13, facing = "right" })
|
|
U.wait(30)
|
|
U.shot(game, DIR .. "/bug301_3_route1_ogred.png")
|
|
local ogShot = Probe.grab()
|
|
c = countIn(ogShot, {
|
|
bootGreen = BOOT.bootGreen, bootDarkGreen = BOOT.bootDarkGreen,
|
|
ogBgRed = PaletteFX.GBC_BG[3], ogBgPink = PaletteFX.GBC_BG[2],
|
|
})
|
|
if c then
|
|
check(c.bootGreen + c.bootDarkGreen > 0,
|
|
"OG RED keeps its boot-ROM green characters (regression guard)")
|
|
check(c.ogBgRed + c.ogBgPink > 0, "over OG RED's red terrain")
|
|
end
|
|
game.save.options.colors = "gbc"
|
|
PaletteFX.setMode("gbc")
|
|
U.wait(20)
|
|
|
|
-- ---- tilt mode, as a guard rather than a gate --------------------------
|
|
-- Tilt colorizes each billboard on its own, outside the whole-canvas zone
|
|
-- pass, so it is the path most likely to disagree with the flat one. Pure
|
|
-- white and raw DMG grey belong to no SGB palette (they all open on paper,
|
|
-- 255,239,255), so either one here means an uncolorized sheet got through.
|
|
standNextTo("ROUTE_1", "ROUTE1_YOUNGSTER2",
|
|
{ x = 14, y = 13, facing = "right" })
|
|
game.save.options.tilt = 2
|
|
require("src.render.Tilt").setLevel(2)
|
|
U.wait(70) -- the tilt tween is presentational and runs on real time
|
|
U.shot(game, DIR .. "/bug301_4_route1_tilt_sgb.png")
|
|
local tiltShot = Probe.grab()
|
|
c = countIn(tiltShot, {
|
|
pureWhite = { 255, 255, 255 }, rawGrey170 = { 170, 170, 170 },
|
|
bootGreen = BOOT.bootGreen, paper = ROUTE[1],
|
|
})
|
|
if c then
|
|
check(c.pureWhite == 0 and c.rawGrey170 == 0,
|
|
"tilt billboards carry no uncolorized sheet pixels")
|
|
check(c.bootGreen == 0, "and no boot-ROM green in tilt mode either")
|
|
end
|
|
game.save.options.tilt = 0
|
|
require("src.render.Tilt").setLevel(0)
|
|
U.wait(50)
|
|
|
|
U.log(fails == 0 and "all #301 preconditions passed"
|
|
or (fails .. " #301 precondition(s) FAILED -- read up"))
|
|
|
|
-- ---- Part 3: hand off, standing next to somebody, in the default mode ---
|
|
standNextTo("ROUTE_1", "ROUTE1_YOUNGSTER2",
|
|
{ x = 14, y = 13, facing = "right" })
|
|
|
|
U.log("You are on ROUTE 1 in SGB colour, face to face with a YOUNGSTER. Both")
|
|
U.log("of you should take colour from the ground's own palette (Red's cap on")
|
|
U.log("ROUTE's grass green) and change with every map you walk onto. #301")
|
|
U.log("was everyone painted boot-ROM lime green everywhere. Shots in " .. DIR)
|
|
|
|
while true do
|
|
coroutine.yield()
|
|
end
|
|
end
|