From a140980b1d97c292dbbc5cb1bfe486329d7d255f Mon Sep 17 00:00:00 2001 From: DramaticShape Date: Sat, 1 Aug 2026 15:19:26 -0400 Subject: [PATCH] seal transparent back sprites --- CHANGELOG.md | 30 +++++++ lib/BattlePics.lua | 131 +++++++++++++++++++++++------ lib/OverworldBattle.lua | 33 +++++++- tests/dramatic_shape_test.lua | 152 +++++++++++++++++++++++++++++++--- 4 files changed, 310 insertions(+), 36 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 172bfd4..21ab6e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -216,6 +216,36 @@ depth16 as the floor every GLES3 device can read. Refused all four, the reflections are lost and nothing else, exactly as before. +- **Under BACK SPRITES some of your own Pokemon were see-through -- Pikachu, + Seel, Dewgong, Chansey, Jigglypuff -- with the arena showing through the + middle of them.** Those back pics are drawn as OUTLINES: everything inside + the ink is the lightest shade, the decoder keys that shade to nothing, and + on hardware it did not matter because the field behind them was white too. + + BattlePics already put that paper back by flooding the background inward and + filling whatever it could not reach, and along the bottom of a figure it told + a narrow opening (a belly the drawing ran out of, sealed) from a wide one (a + stride, left open for the world to show through). Right for a mon standing + on the map -- but the pinned back pic is not on the map, it is on the text + box with its feet on row 96, and there is white box under its lowest row + rather than arena. Every one of those mons leaks out through an opening far + too wide to read as a drain, so the flood walked straight up inside them. + + A pic on the box is now told so, and its bottom edge seals: nothing reaches + it from below at any width, and the rule stops being a heuristic -- paper is + whatever the background cannot walk to from the left, the right or the top. + Twelve of the game's 151 back pics turn on this; the other 139 come back + byte-identical, and no front pic is touched at all. + + **And a hole is filled with the pic's own paper rather than with white.** + Shade 0 is only white while the pic is still grays, and pics arrive here + after the bake -- a species SGB colour, a BGP fade mid-animation, PAL_BLACK + across the whole screen while the blackout text is up. A hardcoded white + belly would have been the one lit thing on a blacked-out mon. The lightest + shade still standing in the pic is that colour, and every one of the game's + battler pics keeps at least one such pixel -- an eye, a highlight down a + cheek -- so what goes back is the baked shade itself. + ### Known - Screen-space reflections can only reflect what is in the frame. A tree just diff --git a/lib/BattlePics.lua b/lib/BattlePics.lua index d74ec24..bca2b53 100644 --- a/lib/BattlePics.lua +++ b/lib/BattlePics.lua @@ -57,6 +57,26 @@ -- and they come back untouched because that is what their own shape says, not -- because they were special-cased. -- +-- The drain/mouth cut is for a pic STANDING ON THE MAP, where a mouth is a +-- real hole with real ground behind it. A pic PINNED TO THE MENU has no such +-- hole to be: under BACK SPRITES the player's mon is drawn in the GB's own +-- slot with its feet flush on the text box (BattleState.backPlacement pins +-- row 96), so the only thing under its lowest row is white box. Nothing can +-- reach it from below, whatever the opening's width, and the caller says so +-- by asking for a SEALED BOTTOM -- for which the rule stops being a heuristic +-- and becomes exact: paper is whatever the background cannot walk to from the +-- left, the right or the top. +-- +-- That is the difference between a Pikachu that reads as a mon and one that +-- reads as wireframe. The pale-bodied back pics -- Pikachu, Seel, Dewgong, +-- Chansey, Jigglypuff -- are drawn as OUTLINES: everything inside the ink is +-- shade 0 and every one of them is keyed away, so the figure is a rim with the +-- arena showing through it. Each one also has a wide opening along its bottom, +-- which the drain cut correctly reads as a mouth and the sealed bottom +-- correctly does not. Twelve of this game's 151 back pics turn on it; the +-- other 139 come back byte-identical either way, because they had nothing +-- under them the flood was getting in through. +-- -- The silhouette is untouched, so the mon still cuts cleanly against the -- world; only its insides stop being see-through. -- @@ -71,14 +91,23 @@ local V = ... local BattlePics = {} --- Cached by the image the engine handed over. Weak keys, so a pic that goes --- out of scope takes its filled twin with it rather than pinning a texture --- for the session. -local cache = setmetatable({}, { __mode = "k" }) +-- Cached by the image the engine handed over, one table per bottom rule -- +-- the same pic answers differently sealed and unsealed, and a single table +-- would hand the wrong twin back to whichever caller asked second. Weak keys, +-- so a pic that goes out of scope takes its filled twin with it rather than +-- pinning a texture for the session. +local function newCache() + return { + [false] = setmetatable({}, { __mode = "k" }), + [true] = setmetatable({}, { __mode = "k" }), + } +end +local cache = newCache() --- What an enclosed hole is filled with. White, because white is what the --- battle field was: this restores the pixel the artist drew and the engine --- then keyed away, it does not invent a new one. +-- What an enclosed hole is filled with when the pic itself offers nothing +-- better. White, because white is what the battle field was: this restores the +-- pixel the artist drew and the engine then keyed away, it does not invent a +-- new one. BattlePics.FILL = { 1, 1, 1, 1 } -- Anything at or under this alpha counts as keyed-out rather than drawn. @@ -154,6 +183,42 @@ local function inkBounds(data, w, h) return x0, y0, x1, y1 end +-- The colour the keyed-away shade would have had: the LIGHTEST colour still +-- standing in the pic. +-- +-- Pure white is only the right answer while the pic is still grays, and by the +-- time it reaches here it usually is not. picImage hands a pic over AFTER the +-- bake -- a species SGB colour, a BGP fade mid-animation, PAL_BLACK for the +-- whole screen while the blackout text is up -- and shade 0 travels with the +-- rest. A white belly inside a blacked-out mon would be the one lit thing on a +-- dark screen; inside a warm-palette mon it would be a cold patch the artist +-- never drew. +-- +-- So the paper is read off the pic rather than assumed, which needs shade 0 to +-- have survived somewhere in it. It always has: every one of this game's 151 +-- back pics keeps at least one opaque shade-0 pixel -- a highlight down a +-- cheek, the white of an eye -- because only the shade-0 pixels the decoder +-- could reach were keyed. So what comes back is the baked shade 0 itself, not +-- an approximation of it, and it tracks every palette the engine picks without +-- being told which one that was. +-- +-- Ranked by channel sum, which orders four DMG shades exactly: a palette maps +-- all three channels monotonically, so lightest by sum is lightest full stop. +local function paperColor(data, x0, y0, x1, y1) + local best, pr, pg, pb = -1, nil, nil, nil + for y = y0, y1 do + for x = x0, x1 do + local r, g, b, a = data:getPixel(x, y) + if a > CUT then + local lum = r + g + b + if lum > best then best, pr, pg, pb = lum, r, g, b end + end + end + end + if best < 0 then return nil end + return pr, pg, pb +end + -- The widest opening along the bottom of a figure that still counts as a drain -- rather than a mouth. See the header for the measurements either side of it. BattlePics.DRAIN = 6 @@ -161,7 +226,9 @@ BattlePics.DRAIN = 6 -- Mark every transparent pixel the BACKGROUND can reach, flooding inward from -- the edges of the artwork's box: the left, the right and the top whole, and -- along the bottom only those openings wide enough to be background rather --- than the underside of a figure the drawing ran out of. +-- than the underside of a figure the drawing ran out of -- or none of them at +-- all, for a pic whose feet are on the text box and which therefore has +-- nothing behind its lowest row to let in. -- -- Confined to the box as well as seeded from it, so the empty frame under a -- short pic cannot walk around a sealed drain and come back up through it. @@ -169,7 +236,7 @@ BattlePics.DRAIN = 6 -- An explicit stack rather than recursion: a 56x56 pic is three thousand -- pixels and a keyed-out background is most of them, which is a deeper call -- chain than is worth risking for no gain. -local function markOutside(data, w, h, x0, y0, x1, y1) +local function markOutside(data, w, h, x0, y0, x1, y1, sealBottom) local outside = {} local stack, top = {}, 0 local function clear(x, y) @@ -191,17 +258,21 @@ local function markOutside(data, w, h, x0, y0, x1, y1) push(x1, y) end -- the bottom, run by run: a wide one is the gap between two legs and lets - -- the world through, a narrow one is where a belly ran out and is sealed - local x = x0 - while x <= x1 do - if clear(x, y1) then - local from = x - while x <= x1 and clear(x, y1) do x = x + 1 end - if (x - from) > BattlePics.DRAIN then - for k = from, x - 1 do push(k, y1) end + -- the world through, a narrow one is where a belly ran out and is sealed. + -- Skipped whole for a pic on the box, where even the widest of them has + -- white paper behind it rather than arena. + if not sealBottom then + local x = x0 + while x <= x1 do + if clear(x, y1) then + local from = x + while x <= x1 and clear(x, y1) do x = x + 1 end + if (x - from) > BattlePics.DRAIN then + for k = from, x - 1 do push(k, y1) end + end + else + x = x + 1 end - else - x = x + 1 end end while top > 0 do @@ -219,9 +290,15 @@ end -- The pic with its enclosed holes filled, or the pic itself when that could -- not be done (no pixel access, a driver that refused the readback). Never -- nil for a non-nil argument: a caller must always have something to draw. -function BattlePics.filled(img) +-- +-- sealBottom for a pic pinned to the text box rather than standing on the map: +-- see the header. A caller that does not say defaults to the map, which is +-- where all but one of this mod's pics are. +function BattlePics.filled(img, sealBottom) if not img then return img end - local hit = cache[img] + sealBottom = sealBottom and true or false + local slot = cache[sealBottom] + local hit = slot[img] if hit ~= nil then return hit or img end local made = nil @@ -231,8 +308,12 @@ function BattlePics.filled(img) local w, h = data:getDimensions() local x0, y0, x1, y1 = inkBounds(data, w, h) if not x0 then return end -- a pic with nothing drawn in it - local outside = markOutside(data, w, h, x0, y0, x1, y1) + local outside = markOutside(data, w, h, x0, y0, x1, y1, sealBottom) local fill = BattlePics.FILL + local pr, pg, pb = paperColor(data, x0, y0, x1, y1) + local fr = pr or fill[1] + local fg = pg or fill[2] + local fb = pb or fill[3] local changed = false -- only inside the box: everything beyond it is frame the artist never -- reached, and filling that would put the mon in a white rectangle @@ -242,7 +323,7 @@ function BattlePics.filled(img) if not outside[row + x] then local _, _, _, a = data:getPixel(x, y) if a <= CUT then - data:setPixel(x, y, fill[1], fill[2], fill[3], fill[4]) + data:setPixel(x, y, fr, fg, fb, fill[4]) changed = true end end @@ -255,12 +336,12 @@ function BattlePics.filled(img) made = out end) - cache[img] = (ok and made) or false + slot[img] = (ok and made) or false return made or img end function BattlePics.invalidate() - cache = setmetatable({}, { __mode = "k" }) + cache = newCache() end return BattlePics diff --git a/lib/OverworldBattle.lua b/lib/OverworldBattle.lua index dddf97d..78fdfff 100644 --- a/lib/OverworldBattle.lua +++ b/lib/OverworldBattle.lua @@ -104,6 +104,31 @@ function OverworldBattle.backPinned() return OverworldBattle.backSetting:get() and true or false end +-- Whether a pic is the one drawn in the GB's own slot with its feet on the +-- text box, rather than geometry standing out on the map. +-- +-- Exactly the player's side under BACK SPRITES -- its mon, or the trainer back +-- that holds the slot until "Go!" -- because that is the only pic this mod +-- ever leaves flat (see drawPicsLayer below). The foe is a billboard on its +-- tile whichever mode is on, and with the mode off the player's side is one +-- too, so both of those keep the open bottom that lets the arena through a +-- stride. What the answer buys is in BattlePics: a pic on the box has nothing +-- behind its lowest row, so its bottom edge seals. +-- Read by TRUTHINESS rather than against nil, because sideTexture blanks the +-- side it is not rendering by setting the field to FALSE (see OFF) and holds +-- it that way for the whole render -- during which the pic layer runs, and +-- picImage asks this. A nil test passes a `false` straight through to the +-- index below, and the error comes out of sideTexture into the pcall that +-- calls it: the foe's billboard is dropped for the frame and the Pokemon +-- simply is not there. +function OverworldBattle.pinnedPic(battle, img) + if not (battle and img) then return false end + if not OverworldBattle.backPinned() then return false end + if img == battle.playerBackPic then return true end + local player = battle.player + return (player and img == player.sprite) and true or false +end + -- ------- both mons face you -- -- Standing on a map, seen from in front, a Pokemon showing you its BACK is @@ -831,11 +856,17 @@ function OverworldBattle.install() -- behind it. There is a world back there now, so they are filled here -- instead -- see BattlePics, which puts the paper back without touching -- the silhouette. + -- + -- The pinned pic is told that its feet are on the box, which is what lets + -- the pale-bodied back sprites be filled at all: their bellies leak out + -- through an opening too wide to read as a drain, and only the box under + -- them settles that it is not a hole. Passed the pre-bake image, because + -- that is the one the battle holds a reference to. local innerPic = BattleState.picImage function BattleState:picImage(img) local out = innerPic(self, img) if not OverworldBattle.shot() then return out end - return BattlePics.filled(out) + return BattlePics.filled(out, OverworldBattle.pinnedPic(self, img)) end -- While a billboard texture is being rendered both pics are put in the same diff --git a/tests/dramatic_shape_test.lua b/tests/dramatic_shape_test.lua index 6bdfb73..70b37fc 100644 --- a/tests/dramatic_shape_test.lua +++ b/tests/dramatic_shape_test.lua @@ -2476,7 +2476,35 @@ T.check(onAt["DRAMATIC_SHAPE:battleBack"], "switched back on, so is the row") T.eq(onAt["DRAMATIC_SHAPE:battleBack"] - onAt["DRAMATIC_SHAPE:battles"], 1, "directly under the row it belongs to") +-- ------- and which pic is the pinned one is asked with the other side BLANKED +-- +-- picImage asks this so BattlePics knows whether a pic's feet are on the text +-- box -- where its bottom edge seals, and its belly stops being see-through -- +-- and it is asked DURING the billboard render, inside which sideTexture has +-- switched the side it is not drawing off by setting the field to FALSE rather +-- than to nil (see OFF). +-- +-- So the read has to be by truthiness. A test against nil passes that `false` +-- through to the index below it, the error comes back out of sideTexture into +-- the pcall that calls it, textures() reports no card for the side -- and the +-- foe is simply not on the field. Which is the whole bug: fixing the player's +-- back pic took the enemy's billboard out. +local mine, theirs = {}, {} +local live = { player = { sprite = mine }, enemy = { sprite = theirs } } +T.eq(Battles.pinnedPic(live, mine), true, + "the player's own mon is the pic on the box") +T.eq(Battles.pinnedPic(live, theirs), false, + "and the foe is geometry out on the map, whatever the mode") +T.eq(Battles.pinnedPic({ player = false, enemy = { sprite = theirs } }, theirs), + false, "asking about the foe while the player is blanked answers, not throws") +T.eq(Battles.pinnedPic({ playerBackPic = mine }, mine), true, + "the trainer back holds the slot until Go!, on the box like the mon") +T.eq(Battles.pinnedPic({ player = false, playerBackPic = false }, mine), false, + "and with the side blanked outright nothing of it is pinned") + Battles.backSetting:setIndex(1, backGame) -- and off for the rows below +T.eq(Battles.pinnedPic(live, mine), false, + "with BACK SPRITES off the player's mon is out on the map with the foe") end -- ------- the hour reaches the FLAT world too @@ -2611,21 +2639,35 @@ local BattlePics = run.loader.exports.DRAMATIC_SHAPE.lib.require("BattlePics") -- reader over what came out. The pic is faked at the readback seam, which is -- the only thing between this and the pixels the engine would have blitted. local lastCanvas = nil -- what the readback asked newCanvas for -local function fill(rows) +-- '#' is ink and '.' the keyed-out nothing. 'W' is ink too, of the pic's +-- LIGHTEST shade -- a highlight the decoder happened not to key -- which is +-- what the paper a hole gets filled with is read off. +local SHADE = { ["#"] = 0.25, ["W"] = 0.75 } +-- reuse hands the SAME pic back through, which is how the two bottom rules +-- can be asked of one image the way a running battle would ask them +local function fill(rows, sealBottom, reuse) local W, H = #rows[1], #rows local built = nil local function fakeData() - local px = {} + local px, sh = {}, {} for y = 0, H - 1 do for x = 0, W - 1 do - px[y * W + x] = rows[y + 1]:sub(x + 1, x + 1) == "#" and 1 or 0 + local shade = SHADE[rows[y + 1]:sub(x + 1, x + 1)] + px[y * W + x] = shade and 1 or 0 + sh[y * W + x] = shade or 0 end end return { - px = px, + px = px, sh = sh, getDimensions = function() return W, H end, - getPixel = function(self, x, y) return 0, 0, 0, self.px[y * W + x] end, - setPixel = function(self, x, y, r, g, b, a) self.px[y * W + x] = a end, + getPixel = function(self, x, y) + local k = y * W + x + return self.sh[k], self.sh[k], self.sh[k], self.px[k] + end, + setPixel = function(self, x, y, r, g, b, a) + self.px[y * W + x] = a + self.sh[y * W + x] = r + end, } end @@ -2639,12 +2681,14 @@ local function fill(rows) built = data return { setFilter = function() end } end - local pic = { getDimensions = function() return W, H end } - local out = BattlePics.filled(pic) + local pic = reuse or { getDimensions = function() return W, H end } + local out = BattlePics.filled(pic, sealBottom) love.graphics.newCanvas, love.graphics.newImage = realNewCanvas, realNewImage -- deliberately NOT invalidated: each figure brings its own pic, and the -- cache check at the bottom needs one of them still in there - return out, pic, built and function(x, y) return built.px[y * W + x] > 0.5 end + return out, pic, + built and function(x, y) return built.px[y * W + x] > 0.5 end, + built and function(x, y) return built.sh[y * W + x] end end -- ------- the cut at the feet, which is what the closed bottom edge is for @@ -2727,6 +2771,75 @@ local drainOut, drainPic, drain = fill({ T.check(drainOut ~= drainPic and drain and drain(8, 4), "a narrow one is where the drawing ran out, and is paper") +-- ------- but a pic ON THE MENU has no mouth at all +-- +-- The drain/mouth cut is for a pic standing on the MAP, where a wide opening +-- along the bottom is a stride with real ground behind it. Under BACK SPRITES +-- the player's mon is drawn in the GB's own slot with its feet flush on the +-- text box, and the only thing under its lowest row is white box -- so nothing +-- reaches it from below, whatever the opening's width, and the rule stops +-- being a heuristic: paper is whatever the background cannot walk to from the +-- left, the right or the top. +-- +-- Which is the difference between a Pikachu and a wireframe. The pale-bodied +-- back pics -- Pikachu, Seel, Dewgong, Chansey, Jigglypuff -- are drawn as +-- OUTLINES, every shade-0 pixel inside the ink keyed away, and each of them +-- leaks out through a bottom opening far too wide to read as a drain. On the +-- map that reading is right; on the box it left the mon a rim with the arena +-- showing through it. +-- +-- The same stride figure the map rule leaves open, now standing on the box. +local boxOut, boxPic, boxOpaque = fill({ + "..##############..", + "..##############..", + "..##############..", + "..###........###..", + "..###........###..", + "..###........###..", +}, true) +T.check(boxOut ~= boxPic and boxOpaque, + "the gap a stride would have shown the world through is paper on the box") +T.check(boxOpaque(8, 3) and boxOpaque(8, 5), + "and it fills right down to the row the feet are on") + +-- the seal is the BOTTOM alone: the sides and the top still let the background +-- in, which is what keeps the silhouette cutting against the arena instead of +-- standing the mon in a white block +local boxGapOut, boxGapPic = fill({ + "..#####.", + "..#...#.", + "..#...#.", + "....###.", -- opens at the left, and drains out that way + "..#####.", + "..#####.", +}, true) +T.eq(boxGapOut, boxGapPic, + "a pocket that drains out to the side is background on the box too") + +-- ------- and a hole is filled with the pic's OWN paper, not with white +-- +-- Shade 0 is white only while the pic is still grays, and by the time one +-- reaches here it usually is not: picImage hands it over after the bake -- a +-- species SGB colour, a BGP fade mid-animation, PAL_BLACK across the whole +-- screen while the blackout text is up -- and shade 0 travels with the rest. +-- A hardcoded white belly would be the one lit thing on a blacked-out mon. +-- +-- So the paper is read off the pic: the lightest shade still standing in it, +-- which is shade 0 wherever the decoder could not reach one. It never has to +-- guess -- all 151 of this game's back pics keep at least one, an eye or a +-- highlight down a cheek. +local _, _, _, paperShade = fill({ + "..####..", + "..#WW#..", -- a highlight the decoder did not key: this is the paper + "..#..#..", + "..#..#..", + "..####..", +}) +T.eq(paperShade(3, 2), paperShade(3, 1), + "the hole takes the lightest shade the pic still has") +T.check(paperShade(3, 2) ~= paperShade(2, 2), + "which is not the ink beside it") + -- ------- and the readback is measured in PIXELS, which is what kept the mons -- the size of the squares they stand on -- @@ -2743,12 +2856,31 @@ T.check(drainOut ~= drainPic and drain and drain(8, 4), -- scale that was wrong everywhere. T.check(lastCanvas and lastCanvas.opts and lastCanvas.opts.dpiscale == 1, "the readback canvas is one texel per pic pixel, on a highdpi phone too") -T.eq(lastCanvas.w, 18, "and it is the size of the pic, in those pixels") +T.eq(lastCanvas.w, 8, "and it is the size of the pic, in those pixels") -- the answer is cached on the image, so a pic costs one readback a session -- rather than one a frame -- checked on the first figure, which is still in -- there because fill() does not clear it T.eq(BattlePics.filled(pic), out, "the rebuilt pic is cached on the original") +-- and cached PER BOTTOM RULE, because one image answers differently on the map +-- and on the box. A single table for both would hand whichever caller asked +-- second the other one's answer -- the map's stencil to the menu, or a menu +-- fill to a mon standing on grass -- which is this section's bug arriving +-- through the cache rather than through the flood. +-- +-- The stride figure again, on the SAME pic the map rule already answered for. +local reOut = fill({ + "..##############..", + "..##############..", + "..##############..", + "..###........###..", + "..###........###..", + "..###........###..", +}, true, stridePic) +T.check(reOut ~= stridePic, + "the pic the map left open still fills when the box asks for it") +T.eq(BattlePics.filled(stridePic), stridePic, + "and the map's own answer for it is still the pic itself") BattlePics.invalidate() end