This commit is contained in:
bryanthaboi
2026-08-18 10:49:04 -04:00
parent c2c7fdafcf
commit d5ad830fb8
11 changed files with 331 additions and 14 deletions
+1
View File
@@ -1968,6 +1968,7 @@ function Game2:applyOptions()
})
require("src.core.VideoMode").applyOptions(options)
require("src.core.FrameCap").applyOptions(options)
require("src.world.gen2.BorderFill").applyOptions(options)
local GBCFX = require("src.render.GBCFX")
if GBCFX.applyOptions(options) and self.save then
-- applyOptions returns true when it had to clear an unsupported level.
+3
View File
@@ -274,6 +274,9 @@ Save.DEFAULT_OPTIONS = {
color = "gbc",
videoMode = "windowed",
fpsCap = 60,
-- VOID FILL: fade | water | trees | black. fade is each map header's own
-- border block with the dissolve across a boundary (#1418).
voidFill = "fade",
musicVol = 7, -- 0-7, like the GB's NR50 master volume
sfxVol = 7, -- 0-7
musicFilter = 0, -- low-pass steps, 0 = off
+18 -3
View File
@@ -529,9 +529,9 @@ end
-- Gold reads NONE of the rows above. Its OPTION screen writes a different
-- set of names, several of which collide with Gen 1's at a different TYPE
-- (battleStyle "SHIFT" vs "shift", textSpeed a label vs a frame delay), and
-- its renderer has no battle layout, no SGB palette packs and no void fill --
-- so a gear opened on the Gold tab used to offer a dozen controls that did
-- nothing and hide the seven that the cart itself has.
-- its renderer has no battle layout and no SGB palette packs -- so a gear
-- opened on the Gold tab used to offer a dozen controls that did nothing
-- and hide the seven that the cart itself has.
--
-- The block lives in options.lua under `gold`, which is exactly where
-- src/core/gen2/Save.lua loadOptions reads it, so an edit here is live on the
@@ -617,6 +617,21 @@ local function gen2Rows(opts, hooks)
end)
end
local okFill, BorderFill = pcall(require, "src.world.gen2.BorderFill")
if okFill and BorderFill.VOID_FILLS then
add(Strings("VOID FILL"),
function() return BorderFill.voidFillLabel(opts.voidFill) end,
function(dir)
local modes = BorderFill.VOID_FILLS
local cur, idx = opts.voidFill or "fade", 1
for i, m in ipairs(modes) do
if m == cur then idx = i break end
end
opts.voidFill = modes[wrapIndex(idx - 1 + dir, #modes) + 1]
return true
end)
end
-- Same #136 gate as the Gen 1 row and the in-game one.
local okFx, GBCFX = pcall(require, "src.render.GBCFX")
if okFx and GBCFX.isSupported() then
+13
View File
@@ -134,6 +134,19 @@ local ROWS = {
text = function(options)
return require("src.render.Zoom").offsetLabel(options.zoom or 0)
end },
-- VOID FILL: FADE is each map's own border block with the dissolve across
-- a boundary; WATER / TREES force one outdoor block; BLACK is a flat void.
-- #1418. Same key the Gen 1 OPTION screen uses, different ladder (FADE
-- is Gold's default because that is already what the maps call for).
{ label = "VOID FILL", key = "voidFill", port = true,
cycle = function(options, delta)
local BorderFill = require("src.world.gen2.BorderFill")
BorderFill.setVoidFill(options.voidFill or "fade")
options.voidFill = BorderFill.cycle(delta)
end,
text = function(options)
return require("src.world.gen2.BorderFill").voidFillLabel(options.voidFill)
end },
{ label = "TILT", key = "tilt", port = true,
cycle = function(options, delta)
local Tilt = require("src.render.Tilt")
+83 -4
View File
@@ -152,16 +152,95 @@ function BorderFill.bake(atlas, tileset, blockId, bgSet, waterFrame)
return img
end
-- VOID FILL (#1418): the beyond-edge scenery under survey zoom. FADE (the
-- default) is each map header's own border block with the dissolve below;
-- WATER / TREES force one block on outdoor tilesets that actually have that
-- scenery; BLACK is a flat void. Indoor and cave tilesets have no tree wall
-- or water metatile, so water/trees fall through to the map's own border
-- rather than painting a random block.
--
-- Canonical ids are the wMapBorderBlock values already used as those fills
-- in data/maps/attributes.asm: New Bark / Route 30 $05 trees, Cherrygrove
-- $35 water, Pallet $0f trees, Cinnabar $43 water, Ilex Forest $05 trees.
BorderFill.VOID_FILLS = { "fade", "water", "trees", "black" }
BorderFill.voidFill = "fade"
local FILL_BLOCKS = {
TILESET_JOHTO = { trees = 0x05, water = 0x35 },
TILESET_JOHTO_MODERN = { trees = 0x05, water = 0x35 },
TILESET_KANTO = { trees = 0x0f, water = 0x43 },
TILESET_FOREST = { trees = 0x05 },
}
function BorderFill.setVoidFill(mode)
local ok = false
for _, name in ipairs(BorderFill.VOID_FILLS) do
if name == mode then ok = true; break end
end
BorderFill.voidFill = ok and mode or "fade"
end
function BorderFill.cycle(delta)
local cur = BorderFill.voidFill or "fade"
local at = 1
for i, name in ipairs(BorderFill.VOID_FILLS) do
if name == cur then at = i; break end
end
local n = #BorderFill.VOID_FILLS
at = (at - 1 + (delta or 1)) % n + 1
BorderFill.setVoidFill(BorderFill.VOID_FILLS[at])
return BorderFill.voidFill
end
function BorderFill.applyOptions(opts)
BorderFill.setVoidFill(opts and opts.voidFill or "fade")
end
function BorderFill.voidFillLabel(mode)
mode = mode or BorderFill.voidFill or "fade"
if mode == "water" then return "WATER" end
if mode == "trees" then return "TREES" end
if mode == "black" then return "BLACK" end
-- trailing space blanks the 5-char WATER/TREES/BLACK from the value column
return "FADE "
end
-- The metatile the void should bake, or false when BLACK skips tiling.
function BorderFill.fillBlock(def)
local mode = BorderFill.voidFill or "fade"
if mode == "black" then return false end
if (mode == "water" or mode == "trees") and def then
local fills = FILL_BLOCKS[def.tileset]
local block = fills and fills[mode]
if block ~= nil then return block end
end
return def and def.borderBlock or 0
end
-- Crossfade identity: FADE dissolves per map (Cherrygrove water -> Route 30
-- trees). WATER/TREES dissolve only when the forced block itself changes
-- (Johto water -> Kanto water), so walking two Johto routes does not fade
-- identical water against itself. BLACK is one sheet everywhere.
function BorderFill.fillKey(def)
local mode = BorderFill.voidFill or "fade"
if mode == "black" then return "black" end
local block = BorderFill.fillBlock(def)
if mode == "water" or mode == "trees" then
return mode .. "|" .. tostring(def and def.tileset) .. "|" .. tostring(block)
end
return "fade|" .. tostring(def and def.id)
end
-- Each map header carries its OWN border block, so crossing a boundary swaps
-- the whole void from one block to another: Cherrygrove's water becomes Route
-- 30's trees between one frame and the next. On a 20x18 viewport that is a few
-- pixels at the screen edge and nobody sees it; under survey zoom the void is
-- most of the window, and the swap reads as the background popping.
--
-- So the swap is dissolved rather than cut. `key` is the map the image belongs
-- to, not the image itself: the same block gets re-baked by the daytime
-- rollover, the COLOR option and the two-frame cave flicker, and a dissolve on
-- any of those would smear the flicker into mush.
-- So the swap is dissolved rather than cut. `key` is the fill identity
-- (BorderFill.fillKey), not the image itself: the same block gets re-baked by
-- the daytime rollover, the COLOR option and the two-frame cave flicker, and a
-- dissolve on any of those would smear the flicker into mush.
BorderFill.CROSSFADE_FRAMES = 20
-- The bookkeeping half, love-free so it can be checked without a canvas.
+23 -5
View File
@@ -7900,8 +7900,10 @@ function World:borderWaterFrame(def, tileset)
end
end
if not tile then return nil end
local fill = BorderFill.fillBlock(def)
if fill == false then return nil end
local block = tileset.blocks
and tileset.blocks[BorderFill.blockFor(0, def.borderBlock) + 1]
and tileset.blocks[BorderFill.blockFor(0, fill) + 1]
if not block then return nil end
local found = false
for i = 1, 16 do
@@ -7933,11 +7935,19 @@ function World:borderImageFor(mapId)
if not def then return nil end
local tileset = self.tilesets and self.tilesets[def.tileset]
if not tileset then return nil end
-- VOID FILL black skips the tiled bake; drawGround paints a flat void.
local fill = BorderFill.fillBlock(def)
if fill == false then return nil end
local blockId = BorderFill.blockFor(0, fill)
-- A border block made of water animates with the rest of the map, so this
-- frame's row joins the key: four bakes per map instead of one.
-- frame's row joins the key: four bakes per map instead of one. The VOID
-- FILL mode is in the key so switching FADE/WATER/TREES does not keep a
-- stale bake (#1418).
local waterFrame = self:borderWaterFrame(def, tileset)
local cacheKey = BorderFill.cacheKey(mapId .. "|" .. tostring(daytime)
.. "|" .. tostring(GbcPalette.mode) .. "|" .. tostring(flicker)
.. "|" .. tostring(BorderFill.voidFill or "fade")
.. "|" .. tostring(blockId)
.. "|" .. tostring(waterFrame and waterFrame.row or 0))
local cached = self.mapImages[cacheKey]
if cached ~= nil then return cached or nil end
@@ -7949,7 +7959,7 @@ function World:borderImageFor(mapId)
bgSet = Palettes.withCaveFlicker(bgSet, flicker or 1)
end
local ok, img = pcall(BorderFill.bake, atlas, tileset,
BorderFill.blockFor(0, def.borderBlock), bgSet, waterFrame)
blockId, bgSet, waterFrame)
-- `false` rather than nil: a bake that cannot be made (a headless run with
-- no canvas support) must not be retried once per frame forever.
self.mapImages[cacheKey] = (ok and img) or false
@@ -9719,8 +9729,16 @@ function World:drawGround(s)
else
bw, bh = GameViewport.dimensions()
end
BorderFill.draw(self, self:borderImageFor(self.map.id),
cam.x, cam.y, bw, bh, s, self.map.id)
if BorderFill.fillBlock(self.map.def) == false then
-- BLACK: World:draw clears to a brown letterbox, so the void itself
-- has to be an actual black sheet or the map sits on that colour.
G.setColor(0, 0, 0, 1)
G.rectangle("fill", 0, 0, bw, bh)
G.setColor(1, 1, 1, 1)
else
BorderFill.draw(self, self:borderImageFor(self.map.id),
cam.x, cam.y, bw, bh, s, BorderFill.fillKey(self.map.def))
end
end
for _, nb in ipairs(self.neighbors) do
G.draw(nb.image,
@@ -0,0 +1,79 @@
-- Gold VOID FILL (#1418): FADE (each map's own border, dissolving across a
-- boundary), WATER, TREES, or BLACK. Parks you on Cherrygrove zoomed out so
-- the void is most of the window, cycles the four modes, then hands the pad
-- over. Do not add POKEPORT_SPEED: the water anim and the dissolve both run
-- on the real clock.
--
-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold \
-- POKEPORT_DRIVER=tests/drivers/gold_void_fill_bug1418_test.lua love .
local U = require("tests.drivers.util")
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-void-fill"
local BorderFill = require("src.world.gen2.BorderFill")
local Tilt = require("src.render.Tilt")
local Zoom = require("src.render.Zoom")
local failures = 0
local function ok(label, condition, detail)
if condition then
print("[void] ok " .. label)
else
failures = failures + 1
print("[void] FAIL " .. label .. " " .. tostring(detail))
end
end
U.wait(45)
local world = game.world
assert(world and world.map, "gold world did not boot")
-- Cherrygrove's header border is water ($35). FADE shows that water,
-- TREES replaces it, BLACK drops the sheet. attributes.asm:123.
world:setMap("CHERRYGROVE_CITY", 22, 16, "down")
U.wait(10)
Zoom.offset = -3
world:rebuildNeighbors()
world:rebuildPeople({ seamless = true })
Tilt.setLevel(3)
for _ = 1, 40 do
Tilt.update(1 / 60)
U.wait(1)
end
local function show(mode, file, label)
BorderFill.setVoidFill(mode)
if game.options then game.options.voidFill = mode end
if game.save and game.save.options then
game.save.options.voidFill = mode
end
U.wait(8)
ok(label, BorderFill.voidFill == mode, BorderFill.voidFill)
U.shot(game, out .. "/" .. file)
end
show("fade", "01-fade.png", "FADE is the live fill")
show("water", "02-water.png", "WATER forces the water block")
show("trees", "03-trees.png", "TREES forces the tree wall")
show("black", "04-black.png", "BLACK drops the tiled void")
show("fade", "05-fade-again.png", "and FADE restores the map's own border")
Tilt.setLevel(0)
Zoom.offset = 0
world:rebuildNeighbors()
world:rebuildPeople({ seamless = true })
if failures > 0 then
print(("[driver] FAIL gold void fill: %d check(s)"):format(failures))
return
end
print("[driver] PASS gold void fill in " .. out)
U.log("cycled fade/water/trees/black on cherrygrove (#1418).")
U.log("OPTION > VOID FILL, zoom out (-) to see the void.")
while true do
coroutine.yield()
end
end
@@ -43,6 +43,15 @@ local gold = LauncherSettings.open(hooks, "gold")
check(has(gold, "TOUCH PAD"), "Gold's gear offers TOUCH PAD")
check(has(gold, "VIBRATION"), "and VIBRATION")
check(has(gold, "TOUCH CONTROLS"), "and the layout editor")
check(has(gold, "VOID FILL"), "and VOID FILL")
local voidFill = findRow(gold, "VOID FILL")
eq(voidFill.value(), "FADE ", "VOID FILL defaults to FADE")
voidFill.step(1)
eq(gold.opts.gold.voidFill, "water", "right stores water in the gold block")
eq(voidFill.value(), "WATER", "and the row reads WATER")
voidFill.step(-1)
eq(gold.opts.gold.voidFill, "fade", "left restores fade")
-- Every write has to land in the gold block: the flat keys beside it are
-- Red's, and Gold's boot never reads them (src/core/gen2/Save.lua:299).
+57
View File
@@ -84,6 +84,63 @@ end
local _, _, vw0, vh0 = BorderFill.viewport(0, 0, 160, 144, 0)
check(vw0 > 0 and vh0 > 0, "scale 0 falls back to 1 rather than collapsing")
-- ---------------------------------------------------------------------------
-- VOID FILL (#1418): FADE / WATER / TREES / BLACK
-- ---------------------------------------------------------------------------
eq(BorderFill.voidFill, "fade", "the live fill defaults to FADE")
eq(BorderFill.voidFillLabel(), "FADE ", "and prints with a trailing space")
local johto = { id = "CHERRYGROVE_CITY", tileset = "TILESET_JOHTO",
borderBlock = 0x35 }
local route = { id = "ROUTE_30", tileset = "TILESET_JOHTO", borderBlock = 0x05 }
local kanto = { id = "PALLET_TOWN", tileset = "TILESET_KANTO",
borderBlock = 0x0f }
local cave = { id = "UNION_CAVE_1F", tileset = "TILESET_CAVE", borderBlock = 0x09 }
local house = { id = "PLAYERS_HOUSE_1F", tileset = "TILESET_PLAYERS_HOUSE",
borderBlock = 0x00 }
eq(BorderFill.fillBlock(johto), 0x35, "FADE keeps Cherrygrove's water")
eq(BorderFill.fillBlock(route), 0x05, "and Route 30's trees")
eq(BorderFill.fillKey(johto), "fade|CHERRYGROVE_CITY",
"FADE dissolves per map")
eq(BorderFill.fillKey(route) ~= BorderFill.fillKey(johto), true,
"so the two maps are different fills")
BorderFill.setVoidFill("water")
eq(BorderFill.fillBlock(johto), 0x35, "WATER uses Cherrygrove's water block")
eq(BorderFill.fillBlock(route), 0x35, "on Route 30 too")
eq(BorderFill.fillBlock(kanto), 0x43, "and Cinnabar's water on Kanto")
eq(BorderFill.fillBlock(cave), 0x09, "but a cave keeps its own border")
eq(BorderFill.fillBlock(house), 0x00, "and so does a house")
eq(BorderFill.fillKey(johto), BorderFill.fillKey(route),
"two Johto maps share one WATER fill")
eq(BorderFill.fillKey(johto) ~= BorderFill.fillKey(kanto), true,
"Kanto water is a different sheet")
BorderFill.setVoidFill("trees")
eq(BorderFill.fillBlock(johto), 0x05, "TREES uses New Bark's tree wall")
eq(BorderFill.fillBlock(kanto), 0x0f, "and Pallet's on Kanto")
eq(BorderFill.fillBlock(cave), 0x09, "caves still keep their own")
BorderFill.setVoidFill("black")
eq(BorderFill.fillBlock(johto), false, "BLACK skips the tiled bake")
eq(BorderFill.fillBlock(house), false, "indoors too")
eq(BorderFill.fillKey(johto), "black", "and is one sheet everywhere")
BorderFill.setVoidFill("nope")
eq(BorderFill.voidFill, "fade", "an unknown mode falls back to FADE")
BorderFill.setVoidFill("fade")
eq(BorderFill.cycle(1), "water", "cycle steps FADE to WATER")
eq(BorderFill.cycle(1), "trees", "then TREES")
eq(BorderFill.cycle(1), "black", "then BLACK")
eq(BorderFill.cycle(1), "fade", "and wraps to FADE")
eq(BorderFill.cycle(-1), "black", "left wraps the other way")
BorderFill.applyOptions({ voidFill = "trees" })
eq(BorderFill.voidFill, "trees", "applyOptions pushes the saved mode")
BorderFill.applyOptions({})
eq(BorderFill.voidFill, "fade", "and a missing key restores FADE")
-- ---------------------------------------------------------------------------
-- The real cache: the map the bug was reported on
-- ---------------------------------------------------------------------------
+16
View File
@@ -213,6 +213,22 @@ previous, alpha = BorderFill.crossfade(owner, trees, "ROUTE_30")
check("then it is over", previous, nil)
check("and the new block owns the void", alpha, 1)
-- Forced WATER on two Johto maps shares a fill key, so the dissolve does
-- not run identical water against itself.
BorderFill.setVoidFill("water")
local johtoWater = { id = "CHERRYGROVE_CITY", tileset = "TILESET_JOHTO",
borderBlock = 0x35 }
local routeTrees = { id = "ROUTE_30", tileset = "TILESET_JOHTO",
borderBlock = 0x05 }
check("WATER fill keys match across Johto",
BorderFill.fillKey(johtoWater), BorderFill.fillKey(routeTrees))
local forced = {}
BorderFill.crossfade(forced, "water", BorderFill.fillKey(johtoWater))
local fromForced = BorderFill.crossfade(forced, "water",
BorderFill.fillKey(routeTrees))
check("so walking Route 30 does not start a fade", fromForced, nil)
BorderFill.setVoidFill("fade")
-- No key at all (an old caller) is the plain single draw.
local bare = {}
previous, alpha = BorderFill.crossfade(bare, trees, nil)
+29 -2
View File
@@ -358,7 +358,7 @@ local options = OptionsMenu.new(optionsGame, {
})
-- The cart's seven rows, then the port's: CONTROLS, audio, speed, display,
-- video mode, the mobile-gated touch three (buildRows), MAX FPS and CANCEL.
check("twenty-two rows", #OptionsMenu.ROWS, 22)
check("twenty-three rows", #OptionsMenu.ROWS, 23)
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")
@@ -861,10 +861,15 @@ check("GBC leaves a palette alone",
1)[1], 1)
check("and has no present pass", GbcPalette.presentColors(), nil)
local gbcfxIndex
local zoomIndex, gbcfxIndex
for i, row in ipairs(OptionsMenu.ROWS) do
if row.label == "ZOOM" then zoomIndex = i end
if row.label == "GBC FX" then gbcfxIndex = i end
end
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("VIDEO MODE follows GBC FX", OptionsMenu.ROWS[gbcfxIndex + 1].label,
"VIDEO MODE")
check("and TOUCH PAD follows it", OptionsMenu.ROWS[gbcfxIndex + 2].label,
@@ -897,6 +902,28 @@ scrollOptions:cycle(videoRow, -1)
check("left toggles back to windowed",
scrollOptions.options.videoMode, "windowed")
local voidRow = select(2, rowNamed("VOID FILL"))
check("VOID FILL is a row", voidRow ~= nil, true)
check("and it defaults to FADE", Save.DEFAULT_OPTIONS.voidFill, "fade")
scrollOptions.options.voidFill = "fade"
local BorderFill = require("src.world.gen2.BorderFill")
BorderFill.setVoidFill("fade")
scrollOptions:cycle(voidRow, 1)
check("right steps to WATER", scrollOptions.options.voidFill, "water")
check("and the live fill tracks it", BorderFill.voidFill, "water")
scrollOptions:cycle(voidRow, 1)
check("then TREES", scrollOptions.options.voidFill, "trees")
scrollOptions:cycle(voidRow, 1)
check("then BLACK", scrollOptions.options.voidFill, "black")
scrollOptions:cycle(voidRow, 1)
check("and wraps back to FADE", scrollOptions.options.voidFill, "fade")
check("FADE blanks the longer labels", voidRow.text(scrollOptions.options),
"FADE ")
scrollOptions.options.voidFill = "water"
check("WATER fits the value column", voidRow.text(scrollOptions.options),
"WATER")
BorderFill.setVoidFill("fade")
check("text reads WINDOWED", videoRow.text(scrollOptions.options), "WINDOWED")
scrollOptions.options.videoMode = "borderless"
check("text reads FULL, not BORDERLESS", videoRow.text(scrollOptions.options),