mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-27 08:51:27 +02:00
448 lines
16 KiB
Lua
448 lines
16 KiB
Lua
-- Tests the COLORS picker's 699-palette pack (src/render/Palette.lua) and
|
|
-- its substitution seam in src/render/PaletteFX.lua. ROM-free, since the
|
|
-- pack is committed data rather than ROM-imported.
|
|
-- luajit tests/engine/palette_test.lua
|
|
--
|
|
-- Covers the ramp ordering flip in Palette.ramp() (the pack stores darkest
|
|
-- first, PaletteFX wants lightest first), the substitution seam itself (a
|
|
-- custom palette has to win outright and get a zone invented for it, same
|
|
-- as OG/OG INV/CLASSIC), and that BattleState/WideBattle's mono checks stay
|
|
-- in sync with that seam.
|
|
|
|
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 function hex(c)
|
|
return type(c) == "table" and ("%02x%02x%02x"):format(c[1], c[2], c[3]) or "?"
|
|
end
|
|
|
|
local Palette = require("src.render.Palette")
|
|
local PaletteFX = require("src.render.PaletteFX")
|
|
|
|
-- ------- the pack
|
|
|
|
do
|
|
eq(#Palette.packs(), 32, "32 groups")
|
|
local total = 0
|
|
for _, group in ipairs(Palette.packs()) do
|
|
total = total + #(group.palettes or {})
|
|
end
|
|
eq(total, 699, "699 palettes")
|
|
end
|
|
|
|
-- ------- the folders
|
|
|
|
do
|
|
local seen, total = {}, 0
|
|
for _, category in ipairs(Palette.categories()) do
|
|
for _, item in ipairs(category.palettes) do
|
|
total = total + 1
|
|
seen[item.id] = category.key
|
|
end
|
|
end
|
|
eq(total, 699, "every palette is filed")
|
|
|
|
local counted = 0
|
|
for _ in pairs(seen) do counted = counted + 1 end
|
|
eq(counted, 699, "no palette is filed twice")
|
|
|
|
-- the DMG ramp is the case HUE_ARC was tuned around
|
|
eq(seen[Palette.packId("GalleryWebApp Palettes", "Original GB")],
|
|
"single", "the DMG green is one colour")
|
|
eq(seen[Palette.packId("BGB", "Grey")], "grey", "BGB Grey is grey")
|
|
eq(seen[Palette.packId("GB Color (Unique Palettes)", "GBC Palette 119 (World)")],
|
|
"full", "a yellow and red cart palette is full colour")
|
|
-- the GBC boot palette for Red is white/pink/dark red/black: a red ramp
|
|
eq(seen[Palette.packId("GB Color (Unique Palettes)",
|
|
"GBC Palette 093 (USA, Europe)")],
|
|
"single", "a tinted grey ramp is one colour")
|
|
|
|
local first = Palette.categories()[1].palettes[1]
|
|
check(type(first.id) == "string" and Palette.ramp(first.id) ~= nil,
|
|
"a bucket entry carries a resolvable id")
|
|
check(type(first.pack) == "string" and first.pack ~= "",
|
|
"a bucket entry remembers its source pack")
|
|
|
|
local key, at = Palette.locate("p:BGB/Blue")
|
|
eq(key, "single", "locate answers the bucket")
|
|
eq(Palette.categories()[2].palettes[at].id, "p:BGB/Blue",
|
|
"and the tile")
|
|
check(Palette.locate("p:Nope/Nothing") == nil, "an unknown id is nowhere")
|
|
end
|
|
|
|
-- ------- classifying a ramp
|
|
|
|
do
|
|
local function cat(a, b, c, d) return Palette.categoryOf({ a, b, c, d }) end
|
|
eq(cat({ 255, 255, 255 }, { 170, 170, 170 }, { 85, 85, 85 }, { 0, 0, 0 }),
|
|
"grey", "four greys are greyscale")
|
|
eq(cat({ 155, 188, 15 }, { 139, 172, 15 }, { 48, 98, 48 }, { 15, 56, 15 }),
|
|
"single", "one hue at four strengths is a single colour")
|
|
eq(cat({ 255, 255, 255 }, { 255, 132, 132 }, { 148, 58, 58 }, { 0, 0, 0 }),
|
|
"single", "greys among one hue do not make it full colour")
|
|
eq(cat({ 255, 0, 0 }, { 0, 255, 0 }, { 0, 0, 255 }, { 0, 0, 0 }),
|
|
"full", "three hues is full colour")
|
|
eq(cat({ 255, 40, 20 }, { 255, 10, 40 }, { 128, 5, 20 }, { 40, 2, 6 }),
|
|
"single", "a ramp straddling 0 degrees is one colour")
|
|
check(Palette.categoryOf(nil) == nil, "nothing classifies as nothing")
|
|
end
|
|
|
|
-- ------- hex decoding
|
|
|
|
do
|
|
local c = Palette.unhex("0f380f30623077a1129bbc0f")
|
|
check(c and hex(c[1]) == "0f380f", "first colour is the string's first")
|
|
check(c and hex(c[4]) == "9bbc0f", "last colour is the string's last")
|
|
check(Palette.unhex("abc") == nil, "short string rejected")
|
|
check(Palette.unhex("zz380f30623077a1129bbc0f") == nil, "non-hex rejected")
|
|
check(Palette.unhex(nil) == nil, "nil rejected")
|
|
end
|
|
|
|
-- ------- ordering
|
|
--
|
|
-- #9BBC0F is the PAPER and #0F380F is the INK, so ramp()[1] must be pale.
|
|
|
|
do
|
|
local id = Palette.packId("GalleryWebApp Palettes", "Original GB")
|
|
local ramp = Palette.ramp(id)
|
|
check(ramp ~= nil, "Original GB resolves")
|
|
eq(hex(ramp[1]), "9bbc0f", "ramp[1] is the lightest (paper)")
|
|
eq(hex(ramp[4]), "0f380f", "ramp[4] is the darkest (ink)")
|
|
|
|
local sw = Palette.swatch(id)
|
|
eq(hex(sw[1]), "0f380f", "swatch[1] is the darkest")
|
|
eq(hex(sw[4]), "9bbc0f", "swatch[4] is the lightest")
|
|
end
|
|
|
|
-- cross-check against PaletteFX.GBC_BG, the same palette hardcoded there
|
|
do
|
|
local ramp = Palette.ramp(Palette.packId(
|
|
"GB Color (Unique Palettes)", "GBC Palette 093 (USA, Europe)"))
|
|
for i = 1, 4 do
|
|
eq(hex(ramp[i]), hex(PaletteFX.GBC_BG[i]),
|
|
"pack Red ramp[" .. i .. "] agrees with PaletteFX.GBC_BG")
|
|
end
|
|
end
|
|
|
|
-- ------- monochrome
|
|
|
|
do
|
|
local id = Palette.monoId(0x9b, 0xbc, 0x0f)
|
|
eq(id, "m:9bbc0f", "id encodes as hex")
|
|
local ramp = Palette.ramp(id)
|
|
eq(hex(ramp[1]), "9bbc0f", "lightest is the chosen colour")
|
|
check(ramp[4][1] < ramp[1][1], "darkest is darker than lightest")
|
|
eq(Palette.label(id), "GREEN", "a known colour gets its name")
|
|
eq(Palette.label("m:123456"), "MONO", "an unknown one falls back")
|
|
end
|
|
|
|
-- ------- ids
|
|
|
|
do
|
|
check(Palette.label("") == nil, "empty id has no label")
|
|
eq(Palette.label("p:BGB/Blue"), "Blue", "pack label is the name")
|
|
check(Palette.ramp("") == nil, "empty id resolves to nothing")
|
|
check(Palette.ramp("p:Nope/Nothing") == nil, "missing palette resolves to nothing")
|
|
check(Palette.ramp("garbage") == nil, "malformed id resolves to nothing")
|
|
end
|
|
|
|
-- ------- every palette in the pack parses
|
|
|
|
do
|
|
local bad, checked = {}, 0
|
|
for _, group in ipairs(Palette.packs()) do
|
|
for _, entry in ipairs(group.palettes or {}) do
|
|
local ramp = Palette.ramp(Palette.packId(group.name, entry[1]))
|
|
checked = checked + 1
|
|
if not ramp then bad[#bad + 1] = group.name .. "/" .. tostring(entry[1]) end
|
|
end
|
|
end
|
|
eq(checked, 699, "checked every palette")
|
|
eq(#bad, 0, "all resolve (" .. table.concat(bad, ", ", 1, math.min(#bad, 3)) .. ")")
|
|
end
|
|
|
|
-- ------- the picker's state machine
|
|
--
|
|
-- update() touches no love API, so we can drive it with a stub game.
|
|
|
|
local PaletteScreen = require("src.ui.PaletteScreen")
|
|
|
|
do
|
|
local pressed = {}
|
|
|
|
local function open(stored)
|
|
local state = { stored = stored or "", popped = 0, mode = "redpp" }
|
|
state.screen = PaletteScreen.new({
|
|
input = { wasPressed = function(_, k) return pressed[k] == true end },
|
|
stack = { states = {},
|
|
pop = function() state.popped = state.popped + 1 end },
|
|
}, {
|
|
palette = Palette,
|
|
get = function() return state.stored end,
|
|
set = function(v) state.stored = v end,
|
|
modes = { { "SGB", "gbc" }, { "ADVANCED", "redpp" } },
|
|
getMode = function() return state.mode end,
|
|
setMode = function(v) state.mode = v end,
|
|
})
|
|
return state
|
|
end
|
|
|
|
local function press(state, key)
|
|
pressed = { [key] = true }
|
|
state.screen:update()
|
|
pressed = {}
|
|
end
|
|
|
|
local fresh = open()
|
|
eq(fresh.screen.view, "root", "a fresh profile opens at the root")
|
|
eq(#fresh.screen.rows, 6, "four folders and two modes")
|
|
eq(fresh.screen.rows[4].label, "MONOCHROME")
|
|
check(fresh.screen.rows[4].folder, "MONOCHROME is the fourth folder")
|
|
eq(fresh.screen.rows[5].mode, "gbc")
|
|
check(not fresh.screen.rows[5].folder, "an engine mode is a row and not a folder")
|
|
|
|
press(fresh, "a")
|
|
eq(fresh.screen.view, "tiles", "A on a folder opens its grid one level down")
|
|
eq(#fresh.screen.stackTrail, 1)
|
|
press(fresh, "b")
|
|
eq(fresh.screen.view, "root", "B steps back up to the root")
|
|
eq(fresh.popped, 0)
|
|
press(fresh, "b")
|
|
eq(fresh.popped, 1, "B at the root closes the screen")
|
|
|
|
-- a stored selection should open straight into its grid
|
|
local stored = open("p:BGB/Blue")
|
|
eq(stored.screen.view, "tiles", "a stored palette opens in its folder")
|
|
eq(stored.screen.group.key, "single", "and in the RIGHT folder")
|
|
eq(stored.screen:idAt(stored.screen.index), "p:BGB/Blue",
|
|
"with the cursor on the stored tile")
|
|
eq(#stored.screen.stackTrail, 1, "the skipped level is on the trail")
|
|
press(stored, "b")
|
|
eq(stored.screen.view, "root", "B steps up instead of closing")
|
|
eq(stored.popped, 0)
|
|
eq(stored.screen.index, 2, "and lands on the folder it came from")
|
|
|
|
-- an engine mode and a palette share the same slot
|
|
local modes = open("p:BGB/Blue")
|
|
press(modes, "b")
|
|
for _ = 1, 3 do press(modes, "down") end
|
|
eq(modes.screen.rows[modes.screen.index].mode, "gbc", "walked to the SGB row")
|
|
press(modes, "a")
|
|
eq(modes.stored, "", "picking an engine mode clears the palette")
|
|
eq(modes.mode, "gbc", "and sets the mode")
|
|
|
|
local pick = open()
|
|
press(pick, "a")
|
|
press(pick, "right")
|
|
local wanted = pick.screen:idAt(pick.screen.index)
|
|
press(pick, "a")
|
|
eq(pick.stored, wanted, "A on a tile stores that tile's id")
|
|
check(wanted ~= nil)
|
|
end
|
|
|
|
-- ------- the picker's REAL wiring: PaletteScreen.new(game) with no opts
|
|
--
|
|
-- Everything above injects its own opts to keep update() engine-free, so
|
|
-- none of it would catch a bug in liveOpts/dropConflictingMode. This
|
|
-- builds the screen the way OptionsMenu actually does, with no opts at all.
|
|
|
|
do
|
|
local game = {
|
|
input = { wasPressed = function() return false end },
|
|
stack = { states = {}, pop = function() end },
|
|
save = { options = { colors = "redpp" } },
|
|
}
|
|
PaletteFX.mode = "redpp"
|
|
local screen = PaletteScreen.new(game)
|
|
check(screen.view == "root", "a live-opts screen opens at the root")
|
|
check(type(screen.get) == "function" and type(screen.set) == "function",
|
|
"liveOpts wired real get/set functions")
|
|
eq(game.save.options.colors, "gbc",
|
|
"opening the picker while ADVANCED was active dropped it to SGB "
|
|
.. "(dropConflictingMode ran)")
|
|
eq(PaletteFX.mode, "gbc", "and the live mode followed")
|
|
|
|
local id = Palette.packId("BGB", "Blue")
|
|
screen.set(id)
|
|
eq(game.save.options.palette, id, "storing a palette writes save.options.palette")
|
|
eq(hex(PaletteFX.customRamp[1]), hex(Palette.ramp(id)[1]),
|
|
"and PaletteFX.setCustomRamp actually ran")
|
|
|
|
screen.set("")
|
|
check(PaletteFX.customRamp == nil, "storing empty clears the custom ramp")
|
|
PaletteFX.mode = "gbc"
|
|
end
|
|
|
|
-- ------- PaletteFX seam: a custom ramp wins outright
|
|
|
|
do
|
|
PaletteFX.setCustomRamp(nil)
|
|
PaletteFX.mode = "gbc"
|
|
check(not PaletteFX.forcesRawGrays(), "no custom ramp: forcesRawGrays is false")
|
|
|
|
local id = Palette.packId("GalleryWebApp Palettes", "Original GB")
|
|
local ramp = Palette.ramp(id)
|
|
PaletteFX.setCustomRamp(ramp)
|
|
check(PaletteFX.forcesRawGrays(), "a stored custom ramp: forcesRawGrays is true")
|
|
|
|
local someZoneColors = { { 9, 9, 9 }, { 8, 8, 8 }, { 7, 7, 7 }, { 6, 6, 6 } }
|
|
local out = PaletteFX.effectiveColors(someZoneColors)
|
|
for i = 1, 4 do
|
|
eq(hex(out[i]), hex(ramp[i]),
|
|
"effectiveColors[" .. i .. "] is the custom ramp, not the zone's own colours")
|
|
end
|
|
|
|
-- it should win even over a mode with its own fixed palette
|
|
PaletteFX.mode = "classic"
|
|
local out2 = PaletteFX.effectiveColors(someZoneColors)
|
|
eq(hex(out2[1]), hex(ramp[1]), "a custom ramp beats CLASSIC too")
|
|
PaletteFX.mode = "gbc"
|
|
end
|
|
|
|
-- ------- picker immunity
|
|
--
|
|
-- The picker's swatches are real RGB, so effectiveColors and ensureZones
|
|
-- both have to back off while it's on top or it would recolour its own
|
|
-- swatches.
|
|
|
|
do
|
|
local Game = require("src.core.Game")
|
|
|
|
Game.stack = nil
|
|
check(not PaletteFX.pickerActive(), "no stack: pickerActive is false")
|
|
|
|
Game.stack = { states = { { screenId = "OptionsMenu" } } }
|
|
check(not PaletteFX.pickerActive(), "some other screen on top: pickerActive is false")
|
|
|
|
Game.stack = { states = { { screenId = "OptionsMenu" },
|
|
{ screenId = "PaletteScreen" } } }
|
|
check(PaletteFX.pickerActive(), "PaletteScreen on top of the stack: pickerActive is true")
|
|
|
|
local id = Palette.packId("BGB", "Red")
|
|
local ramp = Palette.ramp(id)
|
|
PaletteFX.setCustomRamp(ramp)
|
|
PaletteFX.mode = "gbc"
|
|
|
|
local someZoneColors = { { 9, 9, 9 }, { 8, 8, 8 }, { 7, 7, 7 }, { 6, 6, 6 } }
|
|
local out = PaletteFX.effectiveColors(someZoneColors)
|
|
eq(hex(out[1]), hex(someZoneColors[1]),
|
|
"with the picker on top, effectiveColors does NOT substitute the custom ramp")
|
|
|
|
check(PaletteFX.ensureZones(nil) == nil,
|
|
"with the picker on top, ensureZones invents nothing for the custom ramp")
|
|
|
|
-- closing the picker should restore the substitution immediately
|
|
Game.stack = { states = { { screenId = "OptionsMenu" } } }
|
|
check(not PaletteFX.pickerActive(), "closing the picker: pickerActive is false again")
|
|
local out2 = PaletteFX.effectiveColors(someZoneColors)
|
|
eq(hex(out2[1]), hex(ramp[1]), "and the custom ramp substitutes again")
|
|
local invented = PaletteFX.ensureZones(nil)
|
|
check(invented and #invented == 1, "and ensureZones invents its zone again")
|
|
|
|
Game.stack = nil
|
|
PaletteFX.setCustomRamp(nil)
|
|
end
|
|
|
|
-- ------- PaletteFX seam: the invented zone, end to end
|
|
--
|
|
-- A state with no SGB zones of its own gets one invented by ensureZones,
|
|
-- and effectiveColors then has to turn that zone's colours into the
|
|
-- chosen ramp.
|
|
|
|
do
|
|
PaletteFX.setCustomRamp(nil)
|
|
check(PaletteFX.ensureZones(nil) == nil,
|
|
"no custom ramp, no mono mode: ensureZones invents nothing")
|
|
|
|
local id = Palette.packId("BGB", "Red")
|
|
local ramp = Palette.ramp(id)
|
|
PaletteFX.setCustomRamp(ramp)
|
|
|
|
local invented = PaletteFX.ensureZones(nil)
|
|
check(invented and #invented == 1, "a custom ramp invents one zone")
|
|
eq(invented[1].w, 160, "the invented zone is whole-screen (w)")
|
|
eq(invented[1].h, 144, "the invented zone is whole-screen (h)")
|
|
for i = 1, 4 do
|
|
eq(hex(invented[1].colors[i]), hex(PaletteFX.GRAYS[i]),
|
|
"the invented zone is plain DMG grays, not the ramp itself")
|
|
end
|
|
|
|
-- run it back through effectiveColors, the way Renderer.sendColors would
|
|
local final = PaletteFX.effectiveColors(invented[1].colors)
|
|
for i = 1, 4 do
|
|
eq(hex(final[i]), hex(ramp[i]),
|
|
"the invented zone resolves to the custom ramp at send time")
|
|
end
|
|
|
|
local realZones = { { colors = { { 1, 1, 1 } }, x = 0, y = 0, w = 40, h = 40 } }
|
|
check(PaletteFX.ensureZones(realZones) == realZones,
|
|
"a state with real zones is left alone")
|
|
|
|
PaletteFX.setCustomRamp(nil)
|
|
check(not PaletteFX.forcesRawGrays(), "clearing the ramp clears forcesRawGrays")
|
|
end
|
|
|
|
-- ------- the four sync points
|
|
--
|
|
-- BattleState:picImage, BattleState:drawZonePass, WideBattle's monoMode and
|
|
-- PaletteFX.ensureZones all gate on the same "forced raw grays" condition.
|
|
-- This is a structural check (source-level, no ROM) that none of the four
|
|
-- got missed when forcesRawGrays was added.
|
|
|
|
do
|
|
local function read(path)
|
|
local f = assert(io.open(path, "r"))
|
|
local src = f:read("*a")
|
|
f:close()
|
|
return src
|
|
end
|
|
|
|
local battleSrc = read("src/battle/BattleState.lua")
|
|
local picImage = battleSrc:match("function BattleState:picImage%(img%)(.-)\nend")
|
|
check(picImage and picImage:find("forcesRawGrays", 1, true) ~= nil,
|
|
"picImage's mono check includes forcesRawGrays")
|
|
|
|
local drawZonePass = battleSrc:match(
|
|
"function BattleState:drawZonePass%(src, sx, sy%)(.-)\nend")
|
|
check(drawZonePass and drawZonePass:find("forcesRawGrays", 1, true) ~= nil,
|
|
"drawZonePass's mono check includes forcesRawGrays")
|
|
|
|
local wideSrc = read("src/battle/WideBattle.lua")
|
|
local monoMode = wideSrc:match("local function monoMode%(%)(.-)\nend")
|
|
check(monoMode and monoMode:find("forcesRawGrays", 1, true) ~= nil,
|
|
"WideBattle's monoMode includes forcesRawGrays")
|
|
|
|
local fxSrc = read("src/render/PaletteFX.lua")
|
|
local ensureZones = fxSrc:match("function PaletteFX.ensureZones%(zones%)(.-)\nend")
|
|
check(ensureZones and ensureZones:find("forcesRawGrays", 1, true) ~= nil,
|
|
"ensureZones itself includes forcesRawGrays")
|
|
end
|
|
|
|
-- ------- COLORS row wiring
|
|
--
|
|
-- OptionsMenu's colors row should open the picker instead of cycling in place.
|
|
|
|
do
|
|
local optSrc = (function()
|
|
local f = assert(io.open("src/ui/OptionsMenu.lua", "r"))
|
|
local s = f:read("*a")
|
|
f:close()
|
|
return s
|
|
end)()
|
|
-- bounded to the row itself, or the scan would run into TILT's own step
|
|
local block = optSrc:match('{ id = "colors",(.-)\n { id =')
|
|
check(block ~= nil, "colors row block found")
|
|
check(block:find("activate = function%(g%)") ~= nil,
|
|
"colors row has an activate")
|
|
check(block:find('Screens"%).push%(g, "PaletteScreen"%)', 1, false) ~= nil
|
|
or block:find('Screens").push(g, "PaletteScreen")', 1, true) ~= nil,
|
|
"colors row's activate pushes PaletteScreen")
|
|
check(block:find("step = function") == nil,
|
|
"colors row no longer has a step")
|
|
end
|
|
|
|
T.finish("palette")
|