big palette energy

This commit is contained in:
bryanthaboi
2026-08-26 12:04:06 -04:00
parent 522d8cecf3
commit 3e4772abf4
99 changed files with 4884 additions and 438 deletions
@@ -20,8 +20,8 @@ return function(game)
end
assert(layoutRow and hudRow, "battle layout/HUD rows are present")
menu.index = 6
menu.scroll = 3
-- BATTLE HUD lives on the BATTLE OPTIONS page now, so focus it there.
assert(menu:focusRow("battleHud"), "BATTLE HUD row is reachable")
assert(hudRow.value(game) == "EXTENDED", "WIDE displays EXTENDED")
assert(U.shot(game, BEFORE), "WIDE/EXTENDED screenshot was written")
+46
View File
@@ -0,0 +1,46 @@
-- Diagnostic probe: forces a loud custom COLORS ramp (hot pink paper, blue
-- ink) and shoots StartMenu, PartyMenu, and PackMenu, so a gap between
-- those screens and the rest of the UI can be checked by eye.
--
-- POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/custom_ramp_probe.lua love .
local U = require("tests.drivers.util")
local GbcPalette = require("src.render.GbcPalette")
local StartMenu = require("src.ui.gen2.StartMenu")
local PartyMenu = require("src.ui.gen2.PartyMenu")
local PackMenu = require("src.ui.gen2.PackMenu")
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/custom-ramp-probe"
local function shot(name)
U.wait(3)
U.shot(game, ("%s/%s.png"):format(out, name))
end
local function show(name, state)
game.stack:push(state)
shot(name)
game.stack:pop()
end
U.wait(45)
assert(game.world and game.world.map, "gold world did not boot")
print("[driver] GbcPalette.available() = " .. tostring(GbcPalette.available()))
GbcPalette.setCustomRamp({
{ 255, 100, 220 }, { 220, 60, 180 }, { 140, 20, 120 }, { 20, 0, 60 },
})
print("[driver] customRamp set, mode=" .. tostring(GbcPalette.mode))
local save = game.save
save.party = save.party or {}
save.inventory = { POTION = 5, SUPER_POTION = 2, ANTIDOTE = 1 }
show("01-startmenu-custom-ramp", StartMenu.new(game, { save = save }))
show("02-party-custom-ramp", PartyMenu.new(game, { prompt = "choose" }))
show("03-pack-items-custom-ramp", PackMenu.new(game, { pocket = "ITEM" }))
print("[driver] PASS custom ramp probe shots in " .. out)
end
@@ -28,8 +28,8 @@ return function(game)
assert(bgRow.step(game, 1) == false, "AUTO background row is locked")
assert(options.battleBg == "black", "locked AUTO does not overwrite the stored value")
menu.index = bgIndex
menu.scroll = math.max(0, bgIndex - 5)
-- BATTLE BG lives on the BATTLE OPTIONS page now, so focus it there.
assert(menu:focusRow("battleBg"), "BATTLE BG row is reachable")
U.wait(2)
local autoPath = DIR .. "/fill_extended_auto_menu.png"
os.remove(autoPath)
@@ -0,0 +1,30 @@
-- The grouped Gen 2 OPTION screen: the top level, one page, and a long
-- COLOR value scrolling under the cursor.
--
-- POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/gold_options_groups_shots.lua love .
local U = require("tests.drivers.util")
return function(game)
local OptionsMenu = require("src.ui.gen2.OptionsMenu")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
U.wait(5)
local menu = OptionsMenu.new(game, { options = game.options })
game.stack:push(menu)
U.wait(3)
U.shot(game, DIR .. "/g2_0_top.png")
local page = menu:focusRow("color")
U.wait(2)
U.shot(game, DIR .. "/g2_1_graphics.png")
game.options.palette = "1-A (Default) (BGB)"
U.wait(2)
U.shot(game, DIR .. "/g2_2_color_start.png")
U.wait(85)
U.shot(game, DIR .. "/g2_3_color_scrolled.png")
page.index = #page.view
U.wait(3)
U.shot(game, DIR .. "/g2_4_back.png")
U.log("GOLD_OPTIONS_GROUPS_DONE")
game.driverDone = true
end
+33
View File
@@ -0,0 +1,33 @@
-- Driver: the grouped OPTION screen -- the top level with its group openers,
-- one group's page, and the COLORS row scrolling its overlong value.
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
U.teleport(game, "PALLET_TOWN", 10, 8, "down")
local OptionsMenu = require("src.ui.OptionsMenu")
local menu = OptionsMenu.new(game)
game.stack:push(menu)
U.wait(5)
U.shot(game, DIR .. "/opt_0_top.png")
menu:focusRow("group.battle")
U.wait(2)
U.shot(game, DIR .. "/opt_1_battle_opener.png")
U.tap(game, "a"); U.wait(3)
U.shot(game, DIR .. "/opt_2_battle_page.png")
U.tap(game, "b"); U.wait(3)
game.save.options.palette = "1-A (Default) (BGB)"
-- COLORS is on the GRAPHICS page, so focusRow hands back that page.
local graphics = menu:focusRow("colors")
U.wait(2)
U.shot(game, DIR .. "/opt_3_colors_start.png")
U.wait(85)
U.shot(game, DIR .. "/opt_4_colors_scrolled.png")
graphics.index = #graphics.rows + 1 -- the page's own BACK
U.wait(3)
U.shot(game, DIR .. "/opt_5_back.png")
U.log("OPTIONS_GROUPS_SHOTS_DONE")
game.driverDone = true
end
+12 -8
View File
@@ -1,18 +1,21 @@
-- Driver: the options screen with the port's audio rows (MUSIC VOL /
-- SFX VOL / MUSIC FILTER) to prove the 4-box viewport, the ▼ scroll
-- marker, and CANCEL fixed on the bottom line. The menu is pushed
-- directly (title-menu row order shifts when a save file exists, so
-- blind taps are unreliable).
-- marker, and BACK fixed on the bottom line. The audio rows live on the
-- AUDIO page now, so the menu focuses its way there rather than tapping
-- down a fixed number of times. The menu is pushed directly (title-menu
-- row order shifts when a save file exists, so blind taps are unreliable).
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
U.teleport(game, "PALLET_TOWN", 10, 8, "down")
local OptionsMenu = require("src.ui.OptionsMenu")
game.stack:push(OptionsMenu.new(game))
local menu = OptionsMenu.new(game)
game.stack:push(menu)
U.wait(5)
U.shot(game, DIR .. "/options_0_top.png")
for _ = 1, 4 do U.tap(game, "down"); U.wait(2) end
U.shot(game, DIR .. "/options_1_musicvol.png") -- scrolled, ▼ visible
local audio = menu:focusRow("musicVol")
U.wait(2)
U.shot(game, DIR .. "/options_1_musicvol.png") -- the AUDIO page
U.tap(game, "left"); U.wait(2)
U.tap(game, "left"); U.wait(2)
U.shot(game, DIR .. "/options_2_musicvol_5.png")
@@ -20,6 +23,7 @@ return function(game)
U.tap(game, "down"); U.wait(2)
U.tap(game, "right"); U.wait(2) -- MUSIC FILTER -> 1X
U.shot(game, DIR .. "/options_3_filter_1x.png")
U.tap(game, "down"); U.wait(2) -- CANCEL, tail rows behind it
U.shot(game, DIR .. "/options_4_cancel.png")
audio.index = #audio.view + 1 -- BACK, the page's rows behind it
U.wait(2)
U.shot(game, DIR .. "/options_4_back.png")
end
+17
View File
@@ -0,0 +1,17 @@
-- The COLORS picker after the palette rename: the root folder list and one
-- pack's contents.
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
U.teleport(game, "PALLET_TOWN", 10, 8, "down")
local Screens = require("src.ui.Screens")
Screens.push(game, "PaletteScreen")
U.wait(5)
U.shot(game, DIR .. "/pal_0_root.png")
for _ = 1, 6 do U.tap(game, "down"); U.wait(1) end
U.shot(game, DIR .. "/pal_1_scrolled.png")
U.tap(game, "a"); U.wait(4)
U.shot(game, DIR .. "/pal_2_inside.png")
U.log("PALETTE_PICKER_SHOTS_DONE")
game.driverDone = true
end
@@ -0,0 +1,52 @@
-- BattleAnimView:fillBackground() used to draw a flat literal white
-- rectangle with no GbcPalette involvement, leaving a stark white gap
-- during the battle intro's slide-in under a custom COLORS ramp.
--
-- No real shader runs headless, so this asserts on which seam gets called
-- rather than on rendered pixels, same as every palette-seam test here.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
love = require("tests.love_stub")
require("src.core.Logger").warn = function() end
local GbcPalette = require("src.render.GbcPalette")
local BattleAnimView = require("src.ui.gen2.BattleAnimView")
local calls
local function spy(name)
local original = GbcPalette[name]
GbcPalette[name] = function(...)
calls[name] = (calls[name] or 0) + 1
return original(...)
end
end
do
GbcPalette.available = function() return true end
GbcPalette.setCustomRamp({ { 255, 0, 255 }, { 200, 0, 200 }, { 100, 0, 100 },
{ 0, 0, 0 } })
calls = {}
spy("resolve"); spy("use"); spy("with")
local view = BattleAnimView.new({}, {})
view:fillBackground()
T.check((calls.resolve or 0) > 0 or (calls.use or 0) > 0 or (calls.with or 0) > 0,
"BattleAnimView:fillBackground() reaches the GbcPalette seam")
end
-- Unthemed / no-shader boot must still degrade to the plain literal white
-- fill, byte-identical to before this fix.
do
GbcPalette.available = function() return false end
calls = {}
spy("resolve"); spy("use"); spy("with")
local view = BattleAnimView.new({}, {})
local ok = pcall(function() view:fillBackground() end)
T.check(ok, "BattleAnimView:fillBackground() does not error with no shader")
T.eq((calls.resolve or 0) + (calls.use or 0) + (calls.with or 0), 0,
"BattleAnimView:fillBackground() does not touch GbcPalette with no shader")
end
T.finish()
@@ -0,0 +1,58 @@
-- BattleState:drawTextArea drew the FIGHT/ITEM/RUN command box with a flat
-- setColor, the same gap TextBox.lua had. Gen 1 gets colour from
-- PaletteFX's whole-frame remap, but Gen 2 has no such pass outside
-- CLASSIC mode, so the command box needed its own fix.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
love = require("tests.love_stub")
require("src.core.Logger").warn = function() end
local GbcPalette = require("src.render.GbcPalette")
local BattleState = require("src.battle.BattleState")
GbcPalette.available = function() return true end
GbcPalette.setCustomRamp({ { 255, 0, 255 }, { 200, 0, 200 }, { 100, 0, 100 },
{ 0, 0, 0 } })
local calls
local function spy(name)
local original = GbcPalette[name]
GbcPalette[name] = function(...)
calls[name] = (calls[name] or 0) + 1
return original(...)
end
end
local function fakeState()
return {
phase = "menu", menuIndex = 1, safari = nil, demo = false,
bottomUIVisible = function() return true end,
game = { save = { generation = 2, version = "gold" } },
}
end
do
calls = {}
spy("resolve"); spy("use"); spy("with")
BattleState.drawTextArea(fakeState())
T.check((calls.resolve or 0) > 0 or (calls.use or 0) > 0 or (calls.with or 0) > 0,
"Gen 2 battle command box reaches the GbcPalette seam")
end
-- Gen 1 recolours through PaletteFX's whole-frame zone remap, not per draw
-- call, so this must stay untouched. Same guard gen2_textbox_palette_test
-- checks for TextBox.
do
calls = {}
spy("resolve"); spy("use"); spy("with")
local state = fakeState()
state.game = { save = { generation = 1, version = "red" } }
BattleState.drawTextArea(state)
T.eq((calls.resolve or 0) + (calls.use or 0) + (calls.with or 0), 0,
"Gen 1 battle command box is untouched, it never calls GbcPalette")
end
T.finish()
@@ -0,0 +1,69 @@
-- src/ui/gen2/BattleState.lua is the real Gen 2 battle screen (wired in
-- through Gen2Compat.lua's facade); it drew every label through flat
-- Chrome.print/cursor/printRight, never touching GbcPalette. Added
-- Chrome.printRightThrough alongside this fix since printRight had no
-- palette-aware counterpart.
--
-- No real shader runs headless, so this asserts on which seam gets called
-- rather than on rendered pixels. Exercises drawMoveInfoBox, drawStatsBox,
-- and drawPanel's no-battle fallback as representative cases.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
love = require("tests.love_stub")
require("src.core.Logger").warn = function() end
local GbcPalette = require("src.render.GbcPalette")
local BattleState = require("src.ui.gen2.BattleState")
GbcPalette.available = function() return true end
GbcPalette.setCustomRamp({ { 255, 0, 255 }, { 200, 0, 200 }, { 100, 0, 100 },
{ 0, 0, 0 } })
local calls
local function spy(name)
local original = GbcPalette[name]
GbcPalette[name] = function(...)
calls[name] = (calls[name] or 0) + 1
return original(...)
end
end
local function hits()
return (calls.resolve or 0) + (calls.use or 0) + (calls.with or 0)
end
do
calls = {}
spy("resolve"); spy("use"); spy("with")
BattleState.drawMoveInfoBox({}, { id = "TACKLE", pp = 10, maxPp = 35 })
T.check(hits() > 0, "drawMoveInfoBox's TYPE/PP labels reach the GbcPalette seam")
end
do
calls = {}
spy("resolve"); spy("use"); spy("with")
BattleState.drawMoveInfoBox({}, nil)
T.eq(hits(), 0, "drawMoveInfoBox with no move draws nothing")
end
do
local mon = { species = "RATTATA",
stats = { attack = 10, defense = 9, specialAttack = 8, specialDefense = 8,
speed = 12 } }
calls = {}
spy("resolve"); spy("use"); spy("with")
BattleState.drawStatsBox({}, mon)
T.check(hits() > 0, "drawStatsBox's stat names/numbers reach the GbcPalette seam")
end
do
calls = {}
spy("resolve"); spy("use"); spy("with")
BattleState.drawPanel({ battle = nil, tutorial = false })
T.check(hits() > 0, "drawPanel's NO BATTLE fallback reaches the GbcPalette seam")
end
T.finish()
@@ -0,0 +1,56 @@
-- Chrome.clear() is the base background wipe ~30 opaque Gen 2 menu screens
-- start their frame with. It used to draw a flat literal white rectangle,
-- never touching GbcPalette, so every screen's background stayed white
-- regardless of a picked COLORS palette even though its boxes and text
-- already recoloured correctly.
--
-- No real shader runs headless, so this can't check a rendered pixel, same
-- limitation gen2_textbox_palette_test notes: force GbcPalette.available()
-- true and assert on which seam gets called, not on pixels.
-- tests/drivers/gold_menu_shots.lua is where a real rendered screen gets
-- checked by eye.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
love = require("tests.love_stub")
require("src.core.Logger").warn = function() end
local GbcPalette = require("src.render.GbcPalette")
local Chrome = require("src.ui.gen2.Chrome")
local calls
local function spy(name)
local original = GbcPalette[name]
GbcPalette[name] = function(...)
calls[name] = (calls[name] or 0) + 1
return original(...)
end
end
do
GbcPalette.available = function() return true end
GbcPalette.setCustomRamp({ { 255, 0, 255 }, { 200, 0, 200 }, { 100, 0, 100 },
{ 0, 0, 0 } })
calls = {}
spy("resolve"); spy("use"); spy("with")
Chrome.clear()
T.check((calls.resolve or 0) > 0 or (calls.use or 0) > 0 or (calls.with or 0) > 0,
"Chrome.clear() reaches the GbcPalette seam when a shader is available")
end
-- Unthemed / no-shader boot must still degrade to the plain literal white
-- wipe, byte-identical to before this fix, same guard Chrome.paletteBox
-- already takes.
do
GbcPalette.available = function() return false end
calls = {}
spy("resolve"); spy("use"); spy("with")
local ok = pcall(Chrome.clear)
T.check(ok, "Chrome.clear() does not error with no shader available")
T.eq((calls.resolve or 0) + (calls.use or 0) + (calls.with or 0), 0,
"Chrome.clear() does not touch GbcPalette when no shader is available")
end
T.finish()
@@ -0,0 +1,73 @@
-- Chrome.print / Chrome.cursor / Chrome.printRight are the flat,
-- hardcoded-black primitives ~40 Gen 2 screens still call directly, so any
-- glyph through them rendered solid black regardless of COLORS. Made the
-- primitives themselves palette-aware instead of migrating 40 files.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
love = require("tests.love_stub")
require("src.core.Logger").warn = function() end
local GbcPalette = require("src.render.GbcPalette")
local Chrome = require("src.ui.gen2.Chrome")
local calls
local function spy(name)
local original = GbcPalette[name]
GbcPalette[name] = function(...)
calls[name] = (calls[name] or 0) + 1
return original(...)
end
end
local function reset()
calls = {}
spy("resolve"); spy("use"); spy("useRaw"); spy("with")
end
local function touched()
return (calls.resolve or 0) + (calls.use or 0) + (calls.useRaw or 0) + (calls.with or 0)
end
do
GbcPalette.available = function() return true end
GbcPalette.setCustomRamp({ { 255, 0, 255 }, { 200, 0, 200 }, { 100, 0, 100 },
{ 0, 0, 0 } })
reset()
Chrome.print("HELLO", 1, 1)
T.check(touched() > 0, "Chrome.print() reaches the GbcPalette seam when a shader is available")
reset()
Chrome.cursor(1, 1)
T.check(touched() > 0, "Chrome.cursor() reaches the GbcPalette seam when a shader is available")
reset()
Chrome.printRight("42", 10, 1)
T.check(touched() > 0, "Chrome.printRight() reaches the GbcPalette seam when a shader is available")
end
-- Unthemed / no-shader boot must still degrade to the plain literal black
-- glyph draw, byte-identical to before this fix.
do
GbcPalette.available = function() return false end
reset()
local ok = pcall(Chrome.print, "HELLO", 1, 1)
T.check(ok, "Chrome.print() does not error with no shader available")
T.eq(touched(), 0, "Chrome.print() does not touch GbcPalette when no shader is available")
reset()
ok = pcall(Chrome.cursor, 1, 1)
T.check(ok, "Chrome.cursor() does not error with no shader available")
T.eq(touched(), 0, "Chrome.cursor() does not touch GbcPalette when no shader is available")
reset()
ok = pcall(Chrome.printRight, "42", 10, 1)
T.check(ok, "Chrome.printRight() does not error with no shader available")
T.eq(touched(), 0, "Chrome.printRight() does not touch GbcPalette when no shader is available")
end
T.finish()
@@ -0,0 +1,64 @@
-- Chrome.List:draw() backs nearly every Gen 2 menu's row text, but drew
-- each row through flat, hardcoded-black Chrome.print/cursor, so rows
-- stayed black-on-white even though the box behind them recoloured. Fixed
-- by giving List a `palette` option that routes rows through
-- printThrough/cursorThrough.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
love = require("tests.love_stub")
require("src.core.Logger").warn = function() end
local GbcPalette = require("src.render.GbcPalette")
local Chrome = require("src.ui.gen2.Chrome")
GbcPalette.available = function() return true end
GbcPalette.setCustomRamp({ { 255, 0, 255 }, { 200, 0, 200 }, { 100, 0, 100 },
{ 0, 0, 0 } })
local calls
local function spy(name)
local original = GbcPalette[name]
GbcPalette[name] = function(...)
calls[name] = (calls[name] or 0) + 1
return original(...)
end
end
do
local list = Chrome.List.new({ items = { "POKEMON", "PACK", "QUIT" }, x = 2, y = 2 })
calls = {}
spy("resolve"); spy("use"); spy("with")
list:draw()
T.check((calls.resolve or 0) > 0 or (calls.use or 0) > 0 or (calls.with or 0) > 0,
"Chrome.List:draw() reaches the GbcPalette seam by default")
end
-- A caller that explicitly opts out (palette = false) keeps the old flat
-- black draw untouched by GbcPalette.
do
local list = Chrome.List.new({ items = { "A", "B" }, x = 2, y = 2, palette = false })
calls = {}
spy("resolve"); spy("use"); spy("with")
list:draw()
T.eq((calls.resolve or 0) + (calls.use or 0) + (calls.with or 0), 0,
"Chrome.List:draw() with palette = false does not touch GbcPalette")
end
-- The scroll-hint down arrow, drawn only when the list is longer than its
-- visible rows, must also go through the palette rather than a bare
-- Font.drawCode.
do
local items = {}
for i = 1, 5 do items[i] = "ITEM " .. i end
local list = Chrome.List.new({ items = items, x = 2, y = 2, rows = 2 })
calls = {}
spy("resolve"); spy("use"); spy("with")
list:draw()
T.check((calls.resolve or 0) > 0 or (calls.use or 0) > 0 or (calls.with or 0) > 0,
"Chrome.List:draw()'s scroll-hint arrow reaches the GbcPalette seam")
end
T.finish()
@@ -0,0 +1,109 @@
-- World's per-map colour bake keyed its cache on GbcPalette.mode alone, so
-- two different picked packs (both mode == "custom") kept showing
-- whichever was baked first until a full reload.
-- luajit tests/engine/gen2_color_cache_invalidation_test.lua
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 GbcPalette = require("src.render.GbcPalette")
local BorderFill = require("src.world.gen2.BorderFill")
local World = require("src.world.gen2.World")
GbcPalette.setMode("gbc")
GbcPalette.setCustomRamp(nil)
-- ------- mapCacheKey: must depend on customRamp identity, not just mode
do
local fakeSelf = { daytime = "DAY", flickerPhase = 1 }
local key1 = World.mapCacheKey(fakeSelf, "map1")
GbcPalette.setCustomRamp(
{ { 1, 1, 1 }, { 2, 2, 2 }, { 3, 3, 3 }, { 4, 4, 4 } })
local key2 = World.mapCacheKey(fakeSelf, "map1")
check(key1 ~= key2,
"BUG FIX: mapCacheKey differs once a custom ramp is active, even " ..
"though GbcPalette.mode ('gbc') never changed")
GbcPalette.setCustomRamp(
{ { 9, 9, 9 }, { 8, 8, 8 }, { 7, 7, 7 }, { 6, 6, 6 } })
local key3 = World.mapCacheKey(fakeSelf, "map1")
check(key2 ~= key3,
"BUG FIX: mapCacheKey differs again for a second, different ramp " ..
"under the same mode")
GbcPalette.setCustomRamp(nil)
eq(World.mapCacheKey(fakeSelf, "map1"), key1,
"clearing the ramp returns to the original no-ramp key")
end
-- ------- refreshColorMode: must refetch on a customRamp-only change
do
local fakeSelf = { daytime = "DAY", flickerPhase = 1 }
local calls = 0
fakeSelf.imageFor = function(_, id) calls = calls + 1; return "img:" .. id end
fakeSelf.rebuildNeighbors = function() end
fakeSelf.map = { id = "map1" }
World.refreshColorMode(fakeSelf)
eq(calls, 1, "first refreshColorMode call bakes/fetches once")
World.refreshColorMode(fakeSelf)
eq(calls, 1, "a second call with nothing changed does not refetch")
GbcPalette.setCustomRamp(
{ { 5, 5, 5 }, { 4, 4, 4 }, { 3, 3, 3 }, { 2, 2, 2 } })
World.refreshColorMode(fakeSelf)
eq(calls, 2,
"BUG FIX: a customRamp change alone (mode unchanged) still refetches")
GbcPalette.setCustomRamp(nil)
World.refreshColorMode(fakeSelf)
eq(calls, 3, "clearing the ramp (mode still unchanged) refetches too")
end
-- ------- World:borderImageFor: the void/border bake had the identical gap
-- in its own separately-inlined cache key (it doesn't call mapCacheKey). A
-- border baked while GEN 2 (no ramp) was active stayed cached forever,
-- since picking a pack never showed up in this key, only the mode did.
do
local def = { id = "map1", tileset = "TILESET_JOHTO", borderBlock = 0 }
local fakeSelf = {
daytime = "DAY", flickerPhase = 1,
maps = { map1 = def },
tilesets = { TILESET_JOHTO = { blocks = { [1] = {} }, tilesPerRow = 16 } },
mapImages = {},
borderWaterFrame = function() return nil end,
atlasFor = function() return nil end,
}
GbcPalette.setCustomRamp(nil)
-- Seed the cache under exactly the key borderImageFor computes today for
-- "no ramp active", with a sentinel, proving the fix without touching
-- the real love.graphics bake pipeline.
local keyNoRamp =
BorderFill.cacheKey("map1|DAY|gbc|nil|1|fade|0|0")
fakeSelf.mapImages[keyNoRamp] = "SENTINEL-no-ramp"
eq(World.borderImageFor(fakeSelf, "map1"), "SENTINEL-no-ramp",
"sanity: the no-ramp key matches what borderImageFor builds today")
GbcPalette.setCustomRamp(
{ { 1, 1, 1 }, { 2, 2, 2 }, { 3, 3, 3 }, { 4, 4, 4 } })
local got = World.borderImageFor(fakeSelf, "map1")
check(got ~= "SENTINEL-no-ramp",
"BUG FIX: borderImageFor must not reuse the no-ramp cached void " ..
"bake once a custom ramp is active")
GbcPalette.setCustomRamp(nil)
end
GbcPalette.setCustomRamp(nil)
GbcPalette.setMode("gbc")
T.finish("gen2 color cache invalidation")
@@ -54,13 +54,41 @@ local function rowIndex(rows, id)
end
end
-- TEXT SPEED and the other cart rows live on a group page now, so the
-- screen a row draws on is the page its group opens, not the top level.
local function pageFor(menu, id)
for _, row in ipairs(menu.view) do
if row.group then
row.activate(menu.game)
local page = menu.game.stack:top()
if rowIndex(page.view, id) then return page end
menu.game.stack:pop()
end
end
end
local function fakeGame()
local stack = { items = {} }
function stack:push(s) self.items[#self.items + 1] = s; return s end
function stack:pop() self.items[#self.items] = nil end
function stack:top() return self.items[#self.items] end
return { stack = stack }
end
-- ---------------------------------------------- vanilla: no mod catalog
do
local menu = OptionsMenu.new({})
local menu = OptionsMenu.new(fakeGame())
drawn = {}
menu:drawPanel()
T.eq(drawnAt(LABEL_X, 2 * 8), "SPEED",
"row 1 is the SPEED group with no mod loaded")
local page = pageFor(menu, "textSpeed")
T.check(page ~= nil, "TEXT SPEED is on a group page")
drawn = {}
page:drawPanel()
T.eq(drawnAt(LABEL_X, 2 * 8), "TEXT SPEED",
"row 1's label draws in English with no mod loaded")
"the page's first label draws in English")
-- Save.DEFAULT_OPTIONS.textSpeed is "MID", the cart's own default.
T.eq(drawnAt(VALUE_X, 3 * 8), "MID ",
"and its cart-original display value too")
@@ -73,13 +101,20 @@ do
["TEXT SPEED"] = "VITESSE TEXTE",
["MID "] = "MOY ",
["CONTROLS"] = "COMMANDES",
["CANCEL"] = "ANNULER",
["SPEED"] = "VITESSE",
["BACK"] = "RETOUR",
},
})
local menu = OptionsMenu.new({})
local menu = OptionsMenu.new(fakeGame())
drawn = {}
menu:drawPanel()
T.eq(drawnAt(LABEL_X, 2 * 8), "VITESSE",
"a mod catalog reaches a group opener's label too")
local page = pageFor(menu, "textSpeed")
drawn = {}
page:drawPanel()
T.eq(drawnAt(LABEL_X, 2 * 8), "VITESSE TEXTE",
"a mod catalog reaches a cart-original row's label")
T.eq(drawnAt(VALUE_X, 3 * 8), "MOY ",
@@ -87,7 +122,7 @@ do
-- CONTROLS is the first port-added row; scroll to it so it lands in the
-- VISIBLE_ROWS=7 window drawPanel actually draws.
local index = rowIndex(menu.rows, "controls")
local index = rowIndex(menu.view, "controls")
T.check(index ~= nil, "CONTROLS is one of the rows")
menu.index = index
menu:ensureVisible()
@@ -97,17 +132,17 @@ do
T.eq(drawnAt(LABEL_X, (2 + (slot - 1) * 2) * 8), "COMMANDES",
"and a port-added row's label is translated too")
-- CANCEL is the last row, built into ROWS like any other -- there is no
-- BACK is the last row, built into ROWS like any other -- there is no
-- separate hook to fall through if this one row is missed.
local cancelMenu = OptionsMenu.new({})
local cancelIndex = #cancelMenu.rows
T.check(cancelMenu.rows[cancelIndex].cancel, "the last row is CANCEL")
local cancelMenu = OptionsMenu.new(fakeGame())
local cancelIndex = #cancelMenu.view
T.check(cancelMenu.view[cancelIndex].cancel, "the last row is BACK")
cancelMenu.index = cancelIndex
cancelMenu:ensureVisible()
drawn = {}
cancelMenu:drawPanel()
local cancelSlot = cancelIndex - cancelMenu.scroll
T.eq(drawnAt(LABEL_X, (2 + (cancelSlot - 1) * 2) * 8), "ANNULER",
T.eq(drawnAt(LABEL_X, (2 + (cancelSlot - 1) * 2) * 8), "RETOUR",
"CANCEL, the way out of the menu, is translated too")
-- Module state is process-global (see tests/gen2_clock_test.lua's own
@@ -0,0 +1,65 @@
-- PackMenu:drawList and PackGfx:draw stayed flat white/black regardless of
-- COLORS, since PackGfx only repaints its left five columns and the
-- item-list columns kept their initial bare-rectangle fill. Fixed by
-- routing that fill through Chrome.paletteFill and PackMenu's prints
-- through printThrough/cursorThrough.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
love = require("tests.love_stub")
require("src.core.Logger").warn = function() end
local GbcPalette = require("src.render.GbcPalette")
local PackGfx = require("src.ui.gen2.PackGfx")
local PackMenu = require("src.ui.gen2.PackMenu")
GbcPalette.available = function() return true end
GbcPalette.setCustomRamp({ { 255, 0, 255 }, { 200, 0, 200 }, { 100, 0, 100 },
{ 0, 0, 0 } })
local calls
local function spy(name)
local original = GbcPalette[name]
GbcPalette[name] = function(...)
calls[name] = (calls[name] or 0) + 1
return original(...)
end
end
do
-- An empty gfx.pack table (no header/background/pack-image paths): draw()
-- still runs its header/pattern loops, they just find nothing to blit, so
-- only the base fill itself is exercised.
local gfx = PackGfx.new({ pack = {} })
calls = {}
spy("resolve"); spy("use"); spy("with")
gfx:draw("ITEMS")
T.check((calls.resolve or 0) > 0 or (calls.use or 0) > 0 or (calls.with or 0) > 0,
"PackGfx:draw()'s base fill reaches the GbcPalette seam")
end
local function fakePackMenu()
return {
rows = {
{ id = "POTION", name = "POTION", showCount = true, count = 1 },
},
scroll = 0,
index = 1,
switching = nil,
gfx = PackGfx.new(nil),
cursorAt = PackMenu.cursorAt,
total = function() return 2 end, -- 1 row + CANCEL
}
end
do
calls = {}
spy("resolve"); spy("use"); spy("with")
PackMenu.drawList(fakePackMenu(), 5, 4)
T.check((calls.resolve or 0) > 0 or (calls.use or 0) > 0 or (calls.with or 0) > 0,
"PackMenu:drawList() reaches the GbcPalette seam for item labels")
end
T.finish()
+275
View File
@@ -0,0 +1,275 @@
-- Gen 2's answer to tests/engine/palette_test.lua: the same COLORS picker,
-- wired onto Gold's COLOR row and its own substitution seam
-- (src/render/GbcPalette.lua) instead of Gen 1's PaletteFX.
-- luajit tests/engine/gen2_palette_picker_test.lua
--
-- Covers GbcPalette.customRamp/setCustomRamp (the seam every Gen 2 draw call
-- shares), that a stored palette beats CLASSIC's present pass the same way
-- it beats OG/OG INV/CLASSIC on Gen 1, and that the COLOR row opens the
-- picker while GBC/DMG/CLASSIC stay reachable inside it.
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 GbcPalette = require("src.render.GbcPalette")
-- ------- GbcPalette seam: a custom ramp wins outright
do
GbcPalette.setCustomRamp(nil)
GbcPalette.setMode("gbc")
local cartColors = { { 9, 9, 9 }, { 8, 8, 8 }, { 7, 7, 7 }, { 6, 6, 6 } }
eq(GbcPalette.resolve(cartColors), cartColors,
"no custom ramp: GBC mode passes the cart's own colours through")
local id = Palette.packId("GalleryWebApp Palettes", "Original GB")
local ramp = Palette.ramp(id)
GbcPalette.setCustomRamp(ramp)
local out = GbcPalette.resolve(cartColors)
for i = 1, 4 do
eq(hex(out[i]), hex(ramp[i]),
"resolve[" .. i .. "] is the custom ramp, not the cart's own colours")
end
-- it should win even over DMG, since DMG's whole effect lives at this seam
GbcPalette.setMode("dmg")
local out2 = GbcPalette.resolve(cartColors)
eq(hex(out2[1]), hex(ramp[1]), "a custom ramp beats DMG too")
GbcPalette.setMode("gbc")
GbcPalette.setCustomRamp(nil)
eq(GbcPalette.resolve(cartColors), cartColors,
"clearing the ramp restores the cart's own colours")
end
-- ------- CLASSIC is a present pass, same as Gen 1: it has to be dropped
do
GbcPalette.setCustomRamp(nil)
GbcPalette.setMode("classic")
check(GbcPalette.presentColors() ~= nil,
"CLASSIC has a present pass before any palette is picked")
local id = Palette.packId("BGB", "Red")
local ramp = Palette.ramp(id)
GbcPalette.setCustomRamp(ramp)
-- resolve() alone can't save CLASSIC: presentColors bypasses resolve
-- entirely, so the picker's opts have to drop the mode itself. Covered
-- below via the real row wiring.
GbcPalette.setMode("gbc")
GbcPalette.setCustomRamp(nil)
end
-- ------- applyOptions: a saved palette re-applies on load, like Gen 1's
do
GbcPalette.setCustomRamp(nil)
local id = Palette.packId("BGB", "Blue")
GbcPalette.applyOptions({ color = "dmg", palette = id })
eq(GbcPalette.mode, "dmg", "applyOptions still sets the mode")
check(GbcPalette.customRamp ~= nil, "applyOptions applied the saved palette")
eq(hex(GbcPalette.customRamp[1]), hex(Palette.ramp(id)[1]),
"and it's the right ramp")
GbcPalette.applyOptions({ color = "gbc", palette = "" })
check(GbcPalette.customRamp == nil,
"applyOptions with no palette clears the custom ramp")
end
-- ------- the COLOR row's real wiring: activate opens the picker
local OptionsMenu = require("src.ui.gen2.OptionsMenu")
local function rowNamed(rows, label)
for _, row in ipairs(rows) do
if row.label == label then return row end
end
return nil
end
do
local colorRow = rowNamed(OptionsMenu.ROWS, "COLOR")
check(colorRow ~= nil, "COLOR is still a row")
check(colorRow.cycle == nil, "COLOR no longer cycles in place")
check(type(colorRow.activate) == "function",
"COLOR opens the picker instead")
GbcPalette.setCustomRamp(nil)
GbcPalette.setMode("classic")
local pushed
local game = {
options = { color = "classic", palette = "" },
stack = { states = {},
push = function(self, inst) pushed = inst end },
persistOptions = function() end,
}
colorRow.activate(game)
check(pushed ~= nil, "activate pushed a screen")
check(pushed.view == "root", "the pushed screen is a real PaletteScreen")
eq(game.options.color, "gbc",
"opening the picker while CLASSIC was active dropped it to GBC")
eq(GbcPalette.mode, "gbc", "and the live mode followed")
-- the root offers the engine's own GBC/DMG/CLASSIC ladder alongside the
-- 699-pack folders and MONOCHROME: "the original options" still work
local sawModes = {}
for _, row in ipairs(pushed.rows) do
if row.mode then sawModes[row.mode] = true end
end
check(sawModes.gbc and sawModes.dmg and sawModes.classic,
"GBC, DMG and CLASSIC are all still reachable from the picker")
local id = Palette.packId("BGB", "Blue")
pushed.set(id)
eq(game.options.palette, id, "storing a palette writes options.palette")
check(GbcPalette.customRamp ~= nil, "and GbcPalette.setCustomRamp actually ran")
eq(hex(GbcPalette.customRamp[1]), hex(Palette.ramp(id)[1]), "with the right ramp")
pushed.set("")
check(GbcPalette.customRamp == nil, "storing empty clears the custom ramp")
eq(game.options.color, "gbc",
"clearing a palette lands back on GEN 2, not whatever mode was active")
eq(GbcPalette.mode, "gbc", "and the live mode followed")
-- picking an engine mode through the picker still works and clears the
-- stored palette, exactly like Gen 1's picker
pushed.set(id)
pushed.setMode("dmg")
eq(game.options.color, "dmg", "setMode still writes options.color")
eq(GbcPalette.mode, "dmg", "and drives the live module")
GbcPalette.setCustomRamp(nil)
GbcPalette.setMode("gbc")
end
-- ------- GBC/GEN 2 relabel: GbcPalette.MODES keeps its id "gbc", but the
-- label the player sees for it moved to "GEN 2", freeing "GBC" up as
-- CUSTOM_MODE's own label, the state a stored palette actually lands on.
do
eq(GbcPalette.MODE_LABELS.gbc, "GEN 2",
"GEN 2 is the engine's own unique-colour mode's display name now")
eq(GbcPalette.MODE_LABELS[GbcPalette.CUSTOM_MODE], "GBC",
"GBC is the label for a stored custom palette's state")
check(not (function()
for _, id in ipairs(GbcPalette.MODES) do
if id == GbcPalette.CUSTOM_MODE then return true end
end
return false
end)(), "CUSTOM_MODE is deliberately not one of the root ladder's own modes")
end
-- ------- the bug this session's relabel fixes: picking a palette from a
-- non-GEN-2 mode used to strand the player there once the palette cleared,
-- because only CLASSIC ever got dropped back. Now ANY mode lands on
-- CUSTOM_MODE the moment a palette is picked, so clearing it always finds
-- its way back to GEN 2's own unique colours.
do
local OptionsMenu = require("src.ui.gen2.OptionsMenu")
local colorRow = nil
for _, r in ipairs(OptionsMenu.ROWS) do
if r.key == "color" then colorRow = r end
end
check(colorRow ~= nil, "found the COLOR row")
GbcPalette.setCustomRamp(nil)
GbcPalette.setMode("dmg")
local pushed
local game = {
options = { color = "dmg", palette = "" },
stack = { states = {}, push = function(self, inst) pushed = inst end },
persistOptions = function() end,
}
colorRow.activate(game)
-- opening the picker from DMG (not CLASSIC) doesn't touch the mode yet:
-- dropClassic only fires for CLASSIC, exactly as before.
eq(game.options.color, "dmg", "opening the picker from DMG leaves it alone")
local id = Palette.packId("BGB", "Blue")
pushed.set(id)
eq(game.options.color, GbcPalette.CUSTOM_MODE,
"picking a palette from DMG now lands on CUSTOM_MODE, not DMG")
eq(GbcPalette.mode, GbcPalette.CUSTOM_MODE, "and the live mode followed")
pushed.set("")
eq(game.options.color, "gbc",
"clearing it lands on GEN 2 (the bug: this used to stay on DMG, " ..
"with no selectable row left to get back to GEN 2's unique colours)")
eq(GbcPalette.mode, "gbc", "and the live mode followed")
GbcPalette.setCustomRamp(nil)
GbcPalette.setMode("gbc")
end
-- ------- TitleState's two baked art sets: GEN 2 and a picked custom
-- palette both want the colour set (there's no live recolour of this
-- screen's art either way); only DMG/CLASSIC want the grey set.
do
local TitleState = require("src.ui.gen2.TitleState")
GbcPalette.setMode("gbc")
check(not TitleState.gray(), "GEN 2 shows the colour art")
GbcPalette.setMode(GbcPalette.CUSTOM_MODE)
check(not TitleState.gray(), "a picked custom palette also shows the colour art")
GbcPalette.setMode("dmg")
check(TitleState.gray(), "DMG shows the grey art")
GbcPalette.setMode("classic")
check(TitleState.gray(), "CLASSIC shows the grey art (then the green present pass)")
GbcPalette.setMode("gbc")
end
-- ------- the "2" hotkey ladder (Game2:hotkey) and GbcPalette.cycle(): the
-- bug this session fixes. Unlike the picker's own row.mode branch (tested
-- above), this ladder used to change GbcPalette.mode/options.color without
-- touching customRamp or options.palette, so resolve() stayed locked onto
-- the old pack's colours no matter how many times "2" landed back on "gbc".
do
local Game2 = require("src.core.Game2")
local cartColors = { { 9, 9, 9 }, { 8, 8, 8 }, { 7, 7, 7 }, { 6, 6, 6 } }
GbcPalette.setCustomRamp(nil)
GbcPalette.setMode("gbc")
local id = Palette.packId("BGB", "Blue")
local ramp = Palette.ramp(id)
local game = {
options = { color = GbcPalette.CUSTOM_MODE, palette = id },
persistOptions = function() end,
}
GbcPalette.setMode(GbcPalette.CUSTOM_MODE)
GbcPalette.setCustomRamp(ramp)
-- cycle() isn't in GbcPalette.MODES so a custom-mode start is treated as
-- index 1: three "2" presses walk dmg -> classic -> gbc.
for _ = 1, 3 do Game2.hotkey(game, "2") end
eq(game.options.color, "gbc", "three presses land back on GEN 2's label")
eq(game.options.palette, "",
"BUG FIX: the hotkey ladder clears options.palette too, not just color")
check(GbcPalette.customRamp == nil,
"BUG FIX: GbcPalette.cycle() drops the live custom ramp")
eq(GbcPalette.resolve(cartColors), cartColors,
"BUG FIX: resolve() answers with the cart's own colours again, not " ..
"the stale custom ramp")
GbcPalette.setCustomRamp(nil)
GbcPalette.setMode("gbc")
end
T.finish("gen2 palette picker")
@@ -0,0 +1,83 @@
-- Follow-up to gen2_pokegear_native_colors_test.lua's "keep the gear
-- vanilla" fix: CLASSIC swept the native-coloured gear for free, so this
-- extends the same whole-panel tint to a picked custom COLORS ramp, with
-- Pokegear.CUSTOM_RAMP_FILM = false as an easy revert.
--
-- No real shader/asset loads headless, so this isolates drawPanel()'s new
-- branch with a minimal fake self, same "stub the rest, assert the seam"
-- approach as the TrainerCard drawPanel() test.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
love = require("tests.love_stub")
require("src.core.Logger").warn = function() end
local GbcPalette = require("src.render.GbcPalette")
local Pokegear = require("src.ui.gen2.Pokegear")
GbcPalette.available = function() return true end
local calls
local function spy(name)
local original = GbcPalette[name]
GbcPalette[name] = function(...)
calls[name] = (calls[name] or 0) + 1
return original(...)
end
end
local function hits()
return (calls.resolve or 0) + (calls.use or 0) + (calls.useRaw or 0)
+ (calls.with or 0)
end
local function fakeGear()
return {
styled = function() return true end,
groundColor = function() return { 0, 0, 0 } end,
card = function() return { id = "clock" } end,
drawClock = function() end,
drawMap = function() end,
drawRadio = function() end,
drawPhone = function() end,
drawModeArrow = function() end,
}
end
do
Pokegear.CUSTOM_RAMP_FILM = true
GbcPalette.setCustomRamp({ { 227, 249, 227 }, { 180, 200, 180 },
{ 90, 100, 90 }, { 0, 0, 0 } })
calls = {}
spy("resolve"); spy("use"); spy("useRaw"); spy("with")
Pokegear.drawPanel(fakeGear())
T.check(hits() > 0,
"with the film on and a custom ramp active, drawPanel() reaches GbcPalette")
end
do
Pokegear.CUSTOM_RAMP_FILM = false
GbcPalette.setCustomRamp({ { 227, 249, 227 }, { 180, 200, 180 },
{ 90, 100, 90 }, { 0, 0, 0 } })
calls = {}
spy("resolve"); spy("use"); spy("useRaw"); spy("with")
Pokegear.drawPanel(fakeGear())
T.eq(hits(), 0,
"Pokegear.CUSTOM_RAMP_FILM = false is a one-line revert to fully native")
end
do
Pokegear.CUSTOM_RAMP_FILM = true
GbcPalette.setCustomRamp(nil)
calls = {}
spy("resolve"); spy("use"); spy("useRaw"); spy("with")
Pokegear.drawPanel(fakeGear())
T.eq(hits(), 0,
"with no custom ramp picked, the film never engages -- native GEN 2 stays untouched")
end
Pokegear.CUSTOM_RAMP_FILM = true
T.finish()
@@ -0,0 +1,66 @@
-- The Pokegear's six hardware palette banks guarantee a highlighted cell
-- contrasts with its text, a guarantee GbcPalette.resolve() breaks by
-- collapsing every bank onto the same 4 shades. Product decision: the
-- Pokegear stays on native hardware colours always, via a `raw` path
-- (GbcPalette.withRaw, Chrome.*Through's `raw` param, TileSheet's `raw`
-- option) that calls useRaw directly and skips resolve.
--
-- No real shader runs headless, so this asserts on which entry point gets
-- called rather than on rendered pixels.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
love = require("tests.love_stub")
require("src.core.Logger").warn = function() end
local GbcPalette = require("src.render.GbcPalette")
local Chrome = require("src.ui.gen2.Chrome")
local TileSheet = require("src.ui.gen2.TileSheet")
local Pokegear = require("src.ui.gen2.Pokegear")
GbcPalette.available = function() return true end
GbcPalette.setCustomRamp({ { 255, 0, 255 }, { 200, 0, 200 }, { 100, 0, 100 },
{ 0, 0, 0 } })
local calls
local function spy(name)
local original = GbcPalette[name]
GbcPalette[name] = function(...)
calls[name] = (calls[name] or 0) + 1
return original(...)
end
end
local BANK_1 = { { 227, 249, 227 }, { 180, 200, 180 }, { 90, 100, 90 },
{ 0, 0, 0 } }
do
calls = {}
spy("resolve"); spy("useRaw")
Pokegear.text({ pals = function() return { BANK_1 } end }, "SUNDAY", 6, 6)
T.eq(calls.resolve or 0, 0,
"Pokegear:text() never substitutes the active COLORS ramp")
T.check((calls.useRaw or 0) > 0,
"Pokegear:text() still shades its own native colours through the shader")
end
do
local sheet = TileSheet.new({ palette = BANK_1, raw = true })
-- No real image asset in the headless harness, so this exercises the
-- option's plumbing (raw stored, read by :draw) rather than a full
-- render; gen2_trainer_card_text_palette_test.lua and friends take the
-- same "assert the seam, not the pixel" approach one level up.
T.check(sheet.raw == true, "TileSheet:new stores the raw option")
end
-- A sheet built without raw must still go through the normal substituting
-- seam, guarding the new option from silently becoming the default for
-- every other Gen 2 screen's tile art.
do
local sheet = TileSheet.new({ palette = BANK_1 })
T.eq(sheet.raw, nil, "TileSheet:new defaults raw to unset")
end
T.finish()
@@ -0,0 +1,66 @@
-- StartMenu:draw drew the OPTION row's description tooltip, the "Change
-- settings" box under the list, with a raw love.graphics.rectangle at a
-- flat literal white, never touching GbcPalette. Every other box on this
-- screen already recoloured correctly with a picked COLORS palette, so
-- this one sub-region stood out as structurally white/black regardless.
--
-- No real shader runs headless, so this can't check a rendered pixel, same
-- limitation every palette test in this directory notes: force
-- GbcPalette.available() true and assert on which seam gets called, not
-- on pixels. tests/drivers/custom_ramp_probe.lua is where a real rendered
-- screen gets checked by eye.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
love = require("tests.love_stub")
require("src.core.Logger").warn = function() end
local GbcPalette = require("src.render.GbcPalette")
local StartMenu = require("src.ui.gen2.StartMenu")
local calls
local function spy(name)
local original = GbcPalette[name]
GbcPalette[name] = function(...)
calls[name] = (calls[name] or 0) + 1
return original(...)
end
end
-- Minimal fake: draw() only needs #self.items (box height), a list stub,
-- and a current item with a desc, the same shape start_menu.asm carries
-- for every row (see StartMenu.lua's ITEMS table).
local function fakeMenu()
return {
items = { { id = "option" } },
list = {
draw = function() end,
current = function() return { desc = { "Change", "settings" } } end,
},
phase = nil,
showDescription = true,
}
end
do
GbcPalette.available = function() return true end
GbcPalette.setCustomRamp({ { 255, 0, 255 }, { 200, 0, 200 }, { 100, 0, 100 },
{ 0, 0, 0 } })
calls = {}
spy("resolve"); spy("use"); spy("with")
StartMenu.draw(fakeMenu())
T.check((calls.resolve or 0) > 0 or (calls.use or 0) > 0 or (calls.with or 0) > 0,
"StartMenu's description tooltip reaches the GbcPalette seam")
end
do
GbcPalette.available = function() return false end
calls = {}
spy("resolve"); spy("use"); spy("with")
local ok = pcall(StartMenu.draw, fakeMenu())
T.check(ok, "StartMenu:draw() does not error with no shader available")
end
T.finish()
@@ -0,0 +1,63 @@
-- TextBox drew its box border and glyphs through Font.drawBox/drawCode
-- with a flat setColor, never touching GbcPalette, so dialogue text never
-- picked up a COLORS palette even though every other Chrome.lua-based
-- screen did.
--
-- No real shader runs headless, so this asserts on which seam gets called
-- rather than checking a rendered pixel; tests/drivers/gold_menu_shots.lua
-- is where a real box gets checked by eye.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
love = require("tests.love_stub")
require("src.core.Logger").warn = function() end
local GbcPalette = require("src.render.GbcPalette")
local TextBox = require("src.render.TextBox")
GbcPalette.available = function() return true end
GbcPalette.setCustomRamp({ { 255, 0, 255 }, { 200, 0, 200 }, { 100, 0, 100 },
{ 0, 0, 0 } })
local calls
local function spy(name)
local original = GbcPalette[name]
GbcPalette[name] = function(...)
calls[name] = (calls[name] or 0) + 1
return original(...)
end
end
local function newGame(generation)
return {
save = { player = { name = "RED" }, generation = generation,
version = generation == 2 and "gold" or "red", options = {} },
stringBuffer = "", boxNumString = "", boxMonNicks = {},
data = {},
}
end
do
calls = {}
spy("resolve"); spy("use"); spy("with")
local box = TextBox.new(newGame(2), "Hello world!", nil, {})
box:draw()
T.check((calls.resolve or 0) > 0 or (calls.use or 0) > 0 or (calls.with or 0) > 0,
"Gen 2 dialogue box reaches the GbcPalette seam")
end
-- Gen 1 recolours through a whole-frame zone remap upstream of this file
-- (PaletteFX), not per draw call, so TextBox must not start touching
-- GbcPalette for a Gen 1 game.
do
calls = {}
spy("resolve"); spy("use"); spy("with")
local box = TextBox.new(newGame(1), "Hello world!", nil, {})
box:draw()
T.eq((calls.resolve or 0) + (calls.use or 0) + (calls.with or 0), 0,
"Gen 1 dialogue box is untouched, it never calls GbcPalette")
end
T.finish()
@@ -0,0 +1,69 @@
-- TrainerCard had two palette gaps: its own text drew through flat
-- Chrome.print/cursor, fixed with a print/:cursor pair routed through
-- printThrough/cursorThrough via self:colorsAt; and drawPanel() painted
-- the screen with a flat white rectangle before anything else, leaving the
-- NAME/ID/MONEY/STATUS/BADGES interiors white, fixed by routing that fill
-- through Chrome.paletteFill.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
love = require("tests.love_stub")
require("src.core.Logger").warn = function() end
local GbcPalette = require("src.render.GbcPalette")
local TrainerCard = require("src.ui.gen2.TrainerCard")
GbcPalette.available = function() return true end
GbcPalette.setCustomRamp({ { 255, 0, 255 }, { 200, 0, 200 }, { 100, 0, 100 },
{ 0, 0, 0 } })
local calls
local function spy(name)
local original = GbcPalette[name]
GbcPalette[name] = function(...)
calls[name] = (calls[name] or 0) + 1
return original(...)
end
end
local function fakeCard()
return {
colorsAt = function() return { { 255, 255, 255 }, { 200, 0, 200 },
{ 100, 0, 100 }, { 0, 0, 0 } } end,
}
end
do
calls = {}
spy("resolve"); spy("use"); spy("with")
TrainerCard.print(fakeCard(), "NAME/", 2, 2)
T.check((calls.resolve or 0) > 0 or (calls.use or 0) > 0 or (calls.with or 0) > 0,
"TrainerCard:print() reaches the GbcPalette seam via self:colorsAt")
end
do
calls = {}
spy("resolve"); spy("use"); spy("with")
TrainerCard.cursor(fakeCard(), 18, 15)
T.check((calls.resolve or 0) > 0 or (calls.use or 0) > 0 or (calls.with or 0) > 0,
"TrainerCard:cursor() reaches the GbcPalette seam via self:colorsAt")
end
-- drawPanel() painted the whole screen with a flat white rectangle before
-- drawing the card, and self:frame only draws border tiles, so panel
-- interiors stayed white regardless of the picked palette. drawCard is
-- stubbed here to isolate that base fill from the print/cursor seam
-- covered above.
do
local fake = { styled = function() return true end, page = 1,
drawCard = function() end }
calls = {}
spy("resolve"); spy("use"); spy("with")
TrainerCard.drawPanel(fake)
T.check((calls.resolve or 0) > 0 or (calls.use or 0) > 0 or (calls.with or 0) > 0,
"TrainerCard:drawPanel()'s base fill reaches the GbcPalette seam")
end
T.finish()
@@ -0,0 +1,46 @@
-- UnownWords:draw already routed its tile art through GbcPalette.with, but
-- drew the frame around it with a plain Font.drawBox, so the puzzle's own
-- glyphs picked up a selected COLORS palette while the box border stayed
-- white/black, the same shape of gap gen2_textbox_palette_test.lua found
-- in TextBox.lua. Fixed by routing the box through Chrome.paletteBox.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
love = require("tests.love_stub")
require("src.core.Logger").warn = function() end
local GbcPalette = require("src.render.GbcPalette")
local UnownWords = require("src.world.gen2.UnownWords")
GbcPalette.available = function() return true end
GbcPalette.setCustomRamp({ { 255, 0, 255 }, { 200, 0, 200 }, { 100, 0, 100 },
{ 0, 0, 0 } })
local calls
local function spy(name)
local original = GbcPalette[name]
GbcPalette[name] = function(...)
calls[name] = (calls[name] or 0) + 1
return original(...)
end
end
-- data/events/unown_walls.asm:15 MenuHeaders_UnownWalls's ESCAPE row, the
-- same fixture tests/gen2_unown_words_test.lua uses for boxRect.
local ESCAPE_WALL = { x1 = 3, y1 = 4, x2 = 16, y2 = 9,
chars = { 0x08, 0x44, 0x04, 0x00, 0x2e, 0x08 } }
do
calls = {}
spy("resolve"); spy("use"); spy("with")
-- no opts.world -> :tileset() returns nil -> draw() exits right after the
-- box, isolating the box's own palette seam from the tile-art body wrap
local words = UnownWords.new({}, { wall = ESCAPE_WALL })
words:draw()
T.check((calls.resolve or 0) > 0 or (calls.use or 0) > 0 or (calls.with or 0) > 0,
"Unown wall box reaches the GbcPalette seam")
end
T.finish()
@@ -0,0 +1,152 @@
-- The OPTION screen's grouped pages and its scrolling text.
--
-- The flat row list outgrew the four-box viewport, so the battle, audio,
-- video and speed rows collapse into one opener each. Grouping happens after
-- the ui.options.rows hook and only builds self.view, so self.rows stays the
-- flat list a mod reads and edits.
--
-- Text longer than its line used to run off the 160px panel with no clipping
-- (COLORS showed "1-A (Default) (B"); the highlighted row now scrolls instead.
-- luajit tests/engine/options_submenu_marquee_test.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local OptionsMenu = require("src.ui.OptionsMenu")
local Marquee = require("src.ui.Marquee")
local function stubGame()
local stack = { items = {} }
function stack:push(s) self.items[#self.items + 1] = s; return s end
function stack:pop() self.items[#self.items] = nil end
function stack:top() return self.items[#self.items] end
return {
data = { rulesets = { gen1_faithful = { name = "GEN 1" } }, constants = {} },
save = { options = {} },
stack = stack,
modStatus = { available = {} },
}
end
local function ids(rows)
local out = {}
for i, row in ipairs(rows) do out[i] = row.id end
return table.concat(out, ",")
end
local function find(rows, id)
for i, row in ipairs(rows) do if row.id == id then return row, i end end
end
-- ------------------------------------------------------------- grouping
local game = stubGame()
local menu = OptionsMenu.new(game)
T.check(find(menu.rows, "battleBg"), "self.rows keeps the flat list for mods")
T.check(find(menu.rows, "musicVol"), "self.rows keeps the audio rows too")
for _, id in ipairs({ "battleBg", "speedBattle", "uiLayout", "tilt", "zoom",
"voidFill", "colors", "shaderfx", "shaderfx2",
"musicVol", "videoMode" }) do
T.check(not find(menu.view, id), id .. " moved onto a page")
end
for _, spec in ipairs({
{ "group.battle", 6 }, { "group.audio", 3 }, { "group.video", 5 },
{ "group.speed", 4 }, { "group.graphics", 4 }, { "group.extras", 3 },
}) do
local row = find(menu.view, spec[1])
T.check(row, spec[1] .. " has an opener row")
T.eq(row.group, true, spec[1] .. " is marked as a group")
end
-- Six battle rows on this build (PIKACHU VOL is Yellow-only, so AUDIO is 3).
T.eq(select(1, find(menu.view, "group.battle")).value(game), "6 OPTIONS",
"BATTLE OPTIONS counts its six rows")
-- The top level is explicitly ordered, groups and singles interleaved.
T.eq(ids(menu.view):gsub("group%.", ""),
"speed,video,graphics,audio,performance,ruleset,battle,extras,mods," ..
"controls,dateFormat,timeFormat",
"the top level reads in the order ORDER names")
T.check(not find(menu.view, "textSpeed"), "TEXT SPEED moved onto the SPEED page")
T.check(#menu.view < #menu.rows, "the view is shorter than the flat list")
-- A row in no group is untouched and keeps its order.
T.check(find(menu.view, "ruleset"), "RULESET stays on the top level")
T.check(find(menu.view, "performance"), "so does PERFORMANCE")
T.check(find(menu.view, "mods"), "and MODS")
-- ------------------------------------------------------------- submenus
local graphics = find(menu.view, "group.graphics")
graphics.activate(game)
T.eq(ids(game.stack:top().view), "colors,uiLetterbox,shaderfx,shaderfx2",
"GRAPHICS carries COLORS, UI LETTERBOX and both SHADER FX slots")
game.stack:pop()
local extras = find(menu.view, "group.extras")
extras.activate(game)
T.eq(ids(game.stack:top().view), "tilt,zoom,voidFill",
"EXTRAS carries TILT, ZOOM and VOID FILL")
game.stack:pop()
local video = find(menu.view, "group.video")
video.activate(game)
T.eq(ids(game.stack:top().view):match("^uiLayout"), "uiLayout",
"UI LAYOUT heads the VIDEO page")
game.stack:pop()
local speed = find(menu.view, "group.speed")
speed.activate(game)
T.eq(ids(game.stack:top().view),
"textSpeed,speedOverworld,speedBattle,speedMenu",
"TEXT SPEED heads the SPEED page")
game.stack:pop()
local battle, at = find(menu.view, "group.battle")
menu.index = at
battle.activate(game)
local sub = game.stack:top()
T.check(sub ~= menu, "opening a group pushes a screen")
T.eq(ids(sub.view),
"animations,battleStyle,battleLayout,battleFit,battleHud,battleBg",
"the page carries exactly its six battle rows, in order")
T.eq(sub.sub, true, "the page knows it is a submenu")
T.eq(sub.onCancel, nil, "BACK out of a page never fires the caller's close hook")
-- focusRow reaches a grouped row by opening its page.
local game2 = stubGame()
local menu2 = OptionsMenu.new(game2)
local landed = menu2:focusRow("battleHud")
T.check(landed and landed ~= menu2, "focusRow opens the page a grouped row lives on")
T.eq(landed.view[landed.index].id, "battleHud", "and lands the cursor on it")
T.eq(menu2:focusRow("ruleset"), menu2, "an ungrouped row focuses in place")
T.eq(menu2.view[menu2.index].id, "ruleset", "on the row asked for")
T.eq(menu2:focusRow("nope"), nil, "an unknown id focuses nothing")
-- ------------------------------------------------------------- marquee
-- 16 characters fit beside a value's x=24 before the box frame at x=152;
-- the label's x=16 fits 17.
local long = "1-A (Default) (BGB)"
T.check(#long > 16, "the COLORS value really is too long for the line")
-- 19 characters, 16 fit: three steps of overflow. Hold 1.0s, one char per
-- 0.25s, hold 1.0s on the tail, then repeat -- a 2.75s cycle.
T.eq(Marquee.at(long, 16, 0), long:sub(1, 16), "it holds at the start")
T.eq(Marquee.at(long, 16, 1.3), long:sub(2, 17), "then steps one char")
T.eq(Marquee.at(long, 16, 1.6), long:sub(3, 18), "then another")
T.eq(Marquee.at(long, 16, 2.4), long:sub(4, 19), "then holds on the tail")
T.eq(Marquee.at(long, 16, 2.8), long:sub(1, 16), "before wrapping back")
T.eq(Marquee.at(long, 16, 2.4):sub(-4), "BGB)",
"so the tail the panel used to clip becomes readable")
for _, at2 in ipairs({ 0, 1.3, 1.6, 2.4, 2.8 }) do
T.eq(#Marquee.at(long, 16, at2), 16,
"every frame fills the line exactly")
end
T.eq(Marquee.at("SHORT", 16, 2.0), "SHORT",
"text that fits is never scrolled")
T.finish("options submenu + marquee")
+447
View File
@@ -0,0 +1,447 @@
-- 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")
+52
View File
@@ -180,6 +180,58 @@ do
T.eq(eng.phase, "idle", "and the sync ends idle")
end
-- Identical playtime is not a fork. The Gold prompt the player saw offered
-- "GOLD - 8 badges - 3:46 - 9 seen" against "GOLD - 8 badges - 3:46 - 9 seen":
-- the same minute of the same playthrough, two saves that differ only in
-- their savedAt stamp. Nothing to choose between, so nothing to ask.
do
T.eq(SyncEngine.samePlaytime(
{ summary = { timeText = "3:46" } },
{ summary = { timeText = "3:46" } }), true,
"the same H:MM is the same point in the playthrough")
T.eq(SyncEngine.samePlaytime(
{ summary = { timeText = "3:46" } },
{ summary = { timeText = "3:47" } }), false,
"one minute apart is a real fork")
T.eq(SyncEngine.samePlaytime({ playTime = 13560 }, { playTime = 13599 }),
true, "Gen 1's seconds count compares to the minute, not the second")
T.eq(SyncEngine.samePlaytime({ playTime = 13560 }, { playTime = 13620 }),
false, "and a whole minute apart still forks")
-- Gen 2 stores playTime as a table, so meta.playTime is nil on a Gold
-- save and only timeText survives; a missing time must never read as a
-- match, or an unknowable conflict would be silently discarded.
T.eq(SyncEngine.samePlaytime({}, {}), false,
"two unknown playtimes are not a match")
T.eq(SyncEngine.samePlaytime({ summary = { timeText = "3:46" } }, {}), false,
"and neither is one unknown side")
end
do
local state = linkedState()
SyncState.setRev(state, "red/abc", 7, 500)
local entry = saveEntry("red", "abc", 700, 650)
entry.meta.summary.timeText = "3:46"
local eng, transport = engine({
["GET /sync/state"] = { code = 200,
body = '{"saves":{"red/abc":{"rev":9,"meta":{"savedAt":760,' ..
'"sessionStart":600,"summary":{"name":"ASH","timeText":"3:46"}}}}}' },
["PUT /sync/save"] = { code = 200, body = '{"ok":true,"rev":10}' },
}, { entry }, state)
eng:syncNow()
pump(eng)
T.eq(#eng.conflicts, 0, "matching playtime raises no conflict")
T.eq(#eng.state.pendingConflicts, 0, "and leaves nothing pending")
T.neq(eng.phase, "conflict", "so the player is never prompted")
local put
for _, req in ipairs(transport.sent) do
if req.method == "PUT" then put = req end
end
T.check(put ~= nil, "this device's copy is pushed instead")
T.check(put and put.body:find('"force":true', 1, true) ~= nil,
"forced past the moved rev, since there is nothing to preserve")
end
local function conflictEngine()
local state = linkedState()
SyncState.setRev(state, "red/abc", 7, 500)
+64
View File
@@ -0,0 +1,64 @@
-- UI LETTERBOX (save.options.uiLetterbox): what fills the window around a
-- 160x144 screen. AUTO keeps what each screen was authored with, so the
-- default is byte-identical to the look before the option existed.
-- luajit tests/engine/ui_letterbox_test.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Letterbox = require("src.render.Letterbox")
T.eq(require("src.core.SaveData").newGame().options.uiLetterbox, "auto",
"Gen 1 starts on AUTO")
T.eq(require("src.core.gen2.Save").DEFAULT_OPTIONS.uiLetterbox, "auto",
"so does Gen 2")
-- AUTO hands back the colour the caller was authored with, untouched: the
-- Pokedex stays black, a menu stays white, the naming screen stays paper.
Letterbox.setMode("auto")
T.same({ Letterbox.fill(0, 0, 0) }, { 0, 0, 0 }, "AUTO keeps an authored black")
T.same({ Letterbox.fill(1, 1, 1) }, { 1, 1, 1 }, "AUTO keeps an authored white")
T.same({ Letterbox.fill(0.5, 0.25, 0.75) }, { 0.5, 0.25, 0.75 },
"AUTO keeps an authored paper colour")
-- PALETTE reads the caller's paper, because the two generations keep a picked
-- ramp in different modules. Gen 1 used to come back white here: anything that
-- sniffed for GbcPalette found it present on Gen 1 too and answered with Gen
-- 2's white default instead of PaletteFX's actual paper.
Letterbox.setMode("palette")
T.same({ Letterbox.fill(0, 0, 0, function() return 0.25, 0.5, 1 end) },
{ 0.25, 0.5, 1 }, "PALETTE takes the caller's paper")
T.same({ Letterbox.fill(0, 0, 0) }, { 0, 0, 0 },
"with no paper reader it falls back to the authored colour, never to white")
T.same({ Letterbox.fill(1, 1, 1, function() return nil end) }, { 1, 1, 1 },
"and a reader that cannot answer falls back too")
Letterbox.setMode("black")
T.same({ Letterbox.fill(1, 1, 1) }, { 0, 0, 0 }, "BLACK overrides a white screen")
Letterbox.setMode("white")
T.same({ Letterbox.fill(0, 0, 0) }, { 1, 1, 1 }, "WHITE overrides a black screen")
-- An unknown or absent value degrades to AUTO rather than to a colour, so a
-- save written before the option existed keeps its look.
T.eq(Letterbox.normalize(nil), "auto", "no stored value is AUTO")
T.eq(Letterbox.normalize("nonsense"), "auto", "and so is a value we cannot read")
Letterbox.setMode(nil)
T.same({ Letterbox.fill(0, 0, 0) }, { 0, 0, 0 }, "so an old save is unchanged")
-- The ladder wraps both ways, like every other option row.
T.eq(Letterbox.cycle("auto", 1), "black", "right steps off AUTO")
T.eq(Letterbox.cycle("palette", 1), "auto", "and wraps at the end")
T.eq(Letterbox.cycle("auto", -1), "palette", "left wraps back")
for _, id in ipairs(Letterbox.MODES) do
T.check(Letterbox.label(id) ~= nil, id .. " has a label")
end
T.eq(Letterbox.label("uiLetterbox"), "AUTO", "an unknown id still labels")
-- applyOptions is the seam both Game:applyOptions and Game2:applyOptions call.
Letterbox.applyOptions({ uiLetterbox = "white" })
T.eq(Letterbox.mode, "white", "applyOptions installs the saved mode")
Letterbox.applyOptions({})
T.eq(Letterbox.mode, "auto", "and an options table without one is AUTO")
Letterbox.setMode("auto")
T.finish("ui letterbox")
+52 -36
View File
@@ -360,33 +360,51 @@ local options = OptionsMenu.new(optionsGame, {
-- speed, display, SHADER FX + SHADER FX 2 (the second slot added alongside
-- the dual-shader feature), video mode, screen position, the mobile-gated
-- touch three (buildRows), MAX FPS, BATTLE BG and CANCEL.
check("twenty-seven rows", #OptionsMenu.ROWS, 27)
check("twenty-eight rows", #OptionsMenu.ROWS, 28)
check("the cart's rows come first", OptionsMenu.ROWS[7].key, "frame")
check("then the rebind screen", OptionsMenu.ROWS[8].id, "controls")
check("then the port's audio group", OptionsMenu.ROWS[9].key, "musicVol")
check("last row is CANCEL", OptionsMenu.ROWS[#OptionsMenu.ROWS].cancel, true)
check("starts on TEXT SPEED", options:row().key, "textSpeed")
check("default text speed", options.options.textSpeed, "MID")
check("last row is BACK", OptionsMenu.ROWS[#OptionsMenu.ROWS].cancel, true)
-- PRINT is wGBPrinterBrightness and there is no Game Boy Printer here, so
-- buildRows hides it: the descriptor and the save key survive, the row does
-- not reach the screen.
local function hasRow(rows, key)
for _, row in ipairs(rows) do if row.key == key then return true end end
return false
end
check("PRINT is still a descriptor", hasRow(OptionsMenu.ROWS, "print"), true)
check("but never reaches the screen", hasRow(options.rows, "print"), false)
check("and the save keeps its value",
Save.defaultOptions().print ~= nil, true)
-- The rows are grouped into pages now, so the top level opens on the SPEED
-- group and TEXT SPEED is the first row of the page it opens.
check("starts on the SPEED group", options:row().id, "group.speed")
local speedPage = options:focusRow("textSpeed")
check("TEXT SPEED is on a page", speedPage ~= options, true)
check("and the cursor lands on it", speedPage:row().key, "textSpeed")
check("default text speed", speedPage.options.textSpeed, "MID")
optionsInput:press("right")
options:update(0)
speedPage:update(0)
check("right cycles forward", options.options.textSpeed, "SLOW")
optionsInput:press("right")
options:update(0)
speedPage:update(0)
check("right wraps", options.options.textSpeed, "FAST")
optionsInput:press("left")
options:update(0)
speedPage:update(0)
check("left wraps back", options.options.textSpeed, "SLOW")
check("a page edits the caller's own options table",
speedPage.options == options.options, true)
optionsInput:press("down")
options:update(0)
check("down moves a row", options:row().key, "battleScene")
local battlePage = options:focusRow("battleScene")
check("BATTLE SCENE is on the battle page", battlePage:row().key, "battleScene")
optionsInput:press("right")
options:update(0)
battlePage:update(0)
check("battle scene toggles off", options.options.battleScene, false)
-- FRAME is 1-8 and wraps.
options.index = 7
check("frame row", options:row().frame, true)
local framePage = options:focusRow("frame")
check("frame row", framePage:row().frame, true)
options = framePage
options.options.frame = 8
optionsInput:press("right")
options:update(0)
@@ -406,10 +424,10 @@ local exiting = OptionsMenu.new(exitGame, {
})
-- The screen's own rows, not ROWS: buildRows drops the touch three off a
-- desktop, so the raw descriptor count overshoots CANCEL.
exiting.index = #exiting.rows
exiting.index = #exiting.view
exitInput:press("a")
exiting:update(0)
check("CANCEL leaves", savedOptions ~= nil, true)
check("BACK leaves", savedOptions ~= nil, true)
-- --------------------------------------------------------- start menu
@@ -934,16 +952,13 @@ local colorRow = select(2, rowNamed("COLOR"))
check("COLOR is a row", colorRow ~= nil, true)
check("and it defaults to the cart's own colour",
Save.DEFAULT_OPTIONS.color, "gbc")
scrollOptions.options.color = "gbc"
scrollOptions:cycle(colorRow, 1)
check("right steps to DMG", scrollOptions.options.color, "dmg")
scrollOptions:cycle(colorRow, 1)
check("then CLASSIC", scrollOptions.options.color, "classic")
scrollOptions:cycle(colorRow, 1)
check("and wraps back to GBC", scrollOptions.options.color, "gbc")
scrollOptions:cycle(colorRow, -1)
check("left walks the ladder the other way", scrollOptions.options.color,
"classic")
-- COLOR no longer cycles in place (tests/engine/gen2_palette_picker_test.lua
-- covers the picker it opens instead, end to end); the ladder itself still
-- steps via the `2` hotkey (src/core/Game2.lua:hotkey), untouched here.
check("COLOR no longer cycles in place", colorRow.cycle, nil)
check("COLOR opens the picker instead", type(colorRow.activate), "function")
scrollOptions.options.color = "classic"
GbcPalette.setMode("classic")
check("CLASSIC is the only mode with a present pass",
GbcPalette.presentColors() ~= nil, true)
GbcPalette.setMode("dmg")
@@ -965,16 +980,16 @@ check("VOID FILL follows ZOOM", OptionsMenu.ROWS[zoomIndex + 1].label,
"VOID FILL")
check("and TILT follows VOID FILL", OptionsMenu.ROWS[zoomIndex + 2].label,
"TILT")
check("SHADER FX follows COLOR follows TILT", OptionsMenu.ROWS[tiltIndex + 2].label,
"SHADER FX")
check("SHADER FX 2 follows SHADER FX", OptionsMenu.ROWS[tiltIndex + 3].label,
"SHADER FX 2")
check("VIDEO MODE follows SHADER FX 2", OptionsMenu.ROWS[tiltIndex + 4].label,
"VIDEO MODE")
check("and SCREEN POS follows it", OptionsMenu.ROWS[tiltIndex + 5].label,
"SCREEN POS")
check("and TOUCH PAD follows that", OptionsMenu.ROWS[tiltIndex + 6].label,
"TOUCH PAD")
-- By sequence rather than by offset, so inserting a row in the display block
-- moves the whole run instead of breaking six separate index assertions.
do
local run = { "TILT", "COLOR", "UI LETTERBOX", "SHADER FX",
"SHADER FX 2", "VIDEO MODE", "SCREEN POS", "TOUCH PAD" }
for at, want in ipairs(run) do
check("display block order: " .. want,
OptionsMenu.ROWS[tiltIndex + at - 1].label, want)
end
end
local videoRow = select(2, rowNamed("VIDEO MODE"))
check("VIDEO MODE is a row", videoRow ~= nil, true)
@@ -2518,7 +2533,8 @@ local function modRowChecks()
local og, oi = newGame(nil)
local om = OptionsMenu.new(og, { options = Save.defaultOptions() })
check("gen2 OPTION takes the hook's row", om.rows[#om.rows].id, "modrow")
om.index = #om.rows
-- A hook row is in no group, so it stays on the top level.
check("and keeps it reachable there", om:focusRow("modrow"), om)
oi:press("a")
om:update(0)
check("A on a mod row calls activate", fired, 1)
+17 -2
View File
@@ -146,8 +146,23 @@ Chrome.cursor = function(tx, ty) cursorAt = { x = tx, y = ty } end
Chrome.clear = function() end
Chrome.textbox = function() end
local options = OptionsMenu.new({}, { options = {} })
options:drawPanel()
-- TEXT SPEED and FRAME sit on group pages now; the pages are the same
-- screen and draw at the same StringOptions coordinates, which is what this
-- pins. A stack, because opening a page pushes one.
local optStack = { items = {} }
function optStack:push(s) self.items[#self.items + 1] = s; return s end
function optStack:pop() self.items[#self.items] = nil end
function optStack:top() return self.items[#self.items] end
local options = OptionsMenu.new({ stack = optStack }, { options = {} })
options:focusRow("textSpeed"):drawPanel()
local framePage = options:focusRow("frame")
framePage.index = 1
for i, row in ipairs(framePage:visible()) do
if row.frame then framePage.index = i end
end
framePage:ensureVisible()
framePage:drawPanel()
Chrome.print, Chrome.cursor = realPrint, realCursor
Chrome.clear, Chrome.textbox = realClear, realBox
+2 -3
View File
@@ -293,9 +293,8 @@ do
end },
stack = { pop = function() end } }
local menu = OptionsMenu.new(game)
for i, row in ipairs(menu.rows) do
if row.id == "ruleset" then menu.index = i end
end
-- RULESET is in no group, so this focuses it on the top level.
check(menu:focusRow("ruleset") == menu, "RULESET focuses in place")
local function press(key)
pressed = { [key] = true }
menu:update(1 / 60)
+8 -8
View File
@@ -285,7 +285,7 @@ local WANT_IDS = { "textSpeed", "animations", "battleStyle", "battleLayout",
"battleFit", "battleHud", "battleBg", "uiLayout",
"ruleset", "musicVol", "sfxVol", "musicFilter",
"performance", "colors",
"tilt", "shaderfx", "shaderfx2", "zoom", "voidFill",
"tilt", "uiLetterbox", "shaderfx", "shaderfx2", "zoom", "voidFill",
"videoMode", "faithfulRes", "screenPos", "fpsCap",
"speedOverworld", "speedBattle", "speedMenu",
"mods", "controls", "dateFormat", "timeFormat" }
@@ -369,10 +369,10 @@ check(om.game.save.options.voidFill == "trees", "VOID FILL wraps to TREES")
-- mirrors it one row down, opening the same shared screen on
-- "secondary" instead.
local ShaderFX = require("src.render.ShaderFX")
check(om.rows[16].id == "shaderfx", "row 16 is the SHADER FX row")
check(om.rows[16].value(om.game) == "OFF", "SHADER FX shows OFF with no presets")
check(om.rows[16].step == nil, "SHADER FX row has no step() any more")
om.rows[16].activate(om.game)
local sfx = orow(om, "shaderfx")
check(sfx.value(om.game) == "OFF", "SHADER FX shows OFF with no presets")
check(sfx.step == nil, "SHADER FX row has no step() any more")
sfx.activate(om.game)
local sfxScreen = om.game.stack:top()
check(sfxScreen and sfxScreen.title == "SHADER FX",
"SHADER FX row.activate() pushes a ShaderFXScreen")
@@ -383,9 +383,9 @@ sfxScreen.onChoose(sfxScreen.items[1])
check(ShaderFX.active("main") == false, "choosing OFF on an empty list stays a safe no-op")
check(om.game.stack:top() == nil, "ShaderFXScreen pops itself after onChoose")
check(om.rows[17].id == "shaderfx2", "row 17 is the SHADER FX 2 row")
check(om.rows[17].value(om.game) == "OFF", "SHADER FX 2 shows OFF with no presets")
om.rows[17].activate(om.game)
local sfx2 = orow(om, "shaderfx2")
check(sfx2.value(om.game) == "OFF", "SHADER FX 2 shows OFF with no presets")
sfx2.activate(om.game)
local sfx2Screen = om.game.stack:top()
check(sfx2Screen and sfx2Screen.title == "SHADER FX 2",
"SHADER FX 2 row.activate() pushes the shared ShaderFXScreen on the secondary slot")