From 4bd44050ec2745deff824b25446ca87c135dd998 Mon Sep 17 00:00:00 2001 From: Code-Grub <34581585+Code-Grub@users.noreply.github.com> Date: Fri, 11 Sep 2026 10:58:18 -0400 Subject: [PATCH] Give mon menu icons the trueColor opt-out A full-colour icon pack renders white with purple blotches on any screen that declares an SGB palette zone, and only in ADVANCED. Colour there is applied after the frame is drawn: a shader reads each pixel's red channel and swaps in one of four palette colours. That suits the game's own art, which is already four greys. Full-colour art is not. A yellow body is nearly all high red, so it clears 0.83 and comes back as colour 0, white in every palette, while the darker pixels land in colour 2, which MEWMON paints {115,33,165}. Art that should be left alone says so with a trueColor flag and is re-blit unshaded. Battle pics, trainer portraits and overworld sprites all carry one. Menu icons had no way to. The icons record has no such field, and Sprites.iconPath returned a path alone where Sprites.path and Sprites.playerPic both return path, trueColor. Nowhere to put the flag and nothing to read it, so the art always met the shader. R.sprites is the precedent for allowing it. Overworld walkers are OBJ art drawn through an OBJ palette, the same hardware class as a menu icon, and they carry the flag already. iconPath now takes the record's flag through opts and returns it, and a pokemon.icon hook may set ctx.trueColor for art it substitutes, which is the contract pokemon.sprite already has. PartyMenu.drawIcon skips the OBP bake for flagged art and reports its covering rect. The bake matters as much as the shader does: obpIcon is itself a four-shade remap on the red channel, so it flattens full-colour art before the zone pass ever runs. The flag overrides `name` rather than being read beside it, because a hook can substitute full-colour art for a species that resolved to a built-in class through icons.byDex, leaving name set. The entry-shape split from #274 does not catch that case. The reported rect is 16x16 for the two OAM-block branches and the file's own size for single-frame art, matching what each branch actually draws. No vanilla icon record sets the flag, so both rect buckets stay empty on a vanilla boot and the zone lists stay exactly the ones the states returned. --- src/mods/Schemas.lua | 9 +++++-- src/pokemon/Sprites.lua | 20 +++++++++----- src/ui/PartyMenu.lua | 27 ++++++++++++++++--- tests/mod_graphics_tests.lua | 49 ++++++++++++++++++++++++++++++++++- tests/mod_qol_hooks_tests.lua | 23 ++++++++++++++++ 5 files changed, 115 insertions(+), 13 deletions(-) diff --git a/src/mods/Schemas.lua b/src/mods/Schemas.lua index af9e8921..8f71680a 100644 --- a/src/mods/Schemas.lua +++ b/src/mods/Schemas.lua @@ -1819,14 +1819,19 @@ end R.icons = { semantics = "record", target = "icons.bySpecies", - value = f.union{ f.str, f.rec{ image = f.path, frames = f.opt(f.int(1)) } }, + -- trueColor is the same 4-shade opt-out the pokemon and trainers records + -- carry: flagged art skips the OBP bake and reports its rect so the SGB + -- pass re-blits it unshaded, instead of being bucketed by red channel. + value = f.union{ f.str, f.rec{ image = f.path, frames = f.opt(f.int(1)), + trueColor = f.opt(f.bool) } }, gen2Value = f.union{ -- the assignment form: a species id mapped to a sheet name f.str, -- the sheet form: width/height are the sheet's pixel size, and every -- vanilla sheet is a 16x32 two-frame strip f.rec{ id = f.opt(f.str), index = f.opt(f.int(0, 255)), image = f.path, - width = f.int(1), height = f.int(1), frames = f.int(1) }, + width = f.int(1), height = f.int(1), frames = f.int(1), + trueColor = f.opt(f.bool) }, }, gen2Extra = function(id, value) if gen2IconIsSheet(id) then diff --git a/src/pokemon/Sprites.lua b/src/pokemon/Sprites.lua index 3b30819d..c849e4de 100644 --- a/src/pokemon/Sprites.lua +++ b/src/pokemon/Sprites.lua @@ -113,11 +113,17 @@ end -- Resolve a party-menu icon image path for `mon`. -- vanillaPath is the path PartyMenu already picked from icons.bySpecies / -- def.icon / icons.byDex; the hook may replace it. --- Returns path (possibly nil). +-- opts.trueColor is the icon record's own flag: an icons.bySpecies table +-- entry may carry one, the way a pokemon record carries it for a battle pic. +-- A hook may also set ctx.trueColor, to flag art it substitutes for art that +-- carried no flag -- the same ctx contract pokemon.sprite has, and the reason +-- the flag is read back off ctx rather than trusted from opts alone. +-- Returns path (possibly nil), trueColor. function Sprites.iconPath(data, mon, vanillaPath, opts) opts = opts or {} + local trueColor = opts.trueColor and true or false if not vanillaPath and not Runtime.wantsHook("pokemon.icon") then - return vanillaPath + return vanillaPath, trueColor end local species = mon and mon.species local ctx = { @@ -126,12 +132,14 @@ function Sprites.iconPath(data, mon, vanillaPath, opts) name = opts.name, data = data, kind = "icon", + trueColor = trueColor, } - if not Runtime.wantsHook("pokemon.icon") then return vanillaPath end + if not Runtime.wantsHook("pokemon.icon") then return vanillaPath, trueColor end local hooked = Runtime.call("pokemon.icon", samePath, vanillaPath, ctx) - if type(hooked) == "string" and hooked ~= "" then return hooked end - if hooked == nil or hooked == false then return nil end - return vanillaPath + trueColor = ctx.trueColor and true or false + if type(hooked) == "string" and hooked ~= "" then return hooked, trueColor end + if hooked == nil or hooked == false then return nil, false end + return vanillaPath, trueColor end return Sprites diff --git a/src/ui/PartyMenu.lua b/src/ui/PartyMenu.lua index 21699a14..a0e9809b 100644 --- a/src/ui/PartyMenu.lua +++ b/src/ui/PartyMenu.lua @@ -221,18 +221,20 @@ function PartyMenu.drawIcon(game, mon, x, y, selected, counter, forceAlt) -- change its menu icon. local entry = (icons.bySpecies and icons.bySpecies[mon.species]) or (def and def.icon) - local name, path + local name, path, trueColor if type(entry) == "string" then name = entry path = icons.icons and icons.icons[entry] elseif type(entry) == "table" then path = entry.image + trueColor = entry.trueColor end if not path then name = def and def.dex and icons.byDex and icons.byDex[def.dex] path = name and icons.icons and icons.icons[name] end - path = require("src.pokemon.Sprites").iconPath(game.data, mon, path, { name = name }) + path, trueColor = require("src.pokemon.Sprites") + .iconPath(game.data, mon, path, { name = name, trueColor = trueColor }) if not path then return end -- Built-in icon classes are DMG 2bpp OBJ art and get the OBP0 bake; a -- mod's own image (an entry table rather than an icon name) is authored @@ -240,13 +242,20 @@ function PartyMenu.drawIcon(game, mon, x, y, selected, counter, forceAlt) -- split PartyMenu.mirrorsIcon makes for the OAM mirror. Both live in one -- cache under different keys, so a mod pointing a table entry at a -- built-in path still gets its unbaked copy. #274 - local key = name and (path .. "#obp") or path + -- + -- trueColor art is unbaked for the same reason it is unshaded: obpIcon is + -- itself a 4-shade remap keyed off the red channel, so running it over + -- full-colour art destroys exactly what the flag asks to keep. The flag + -- overrides `name`, because a pokemon.icon hook can substitute full-colour + -- art for a path that resolved to a built-in class and still carries one. + local baked = name ~= nil and not trueColor + local key = baked and (path .. "#obp") or path if iconImages[key] == nil then -- resolve through Assets so an overrides/ or transform-derived icon -- (e.g. a per-species image at assets/generated/icons/.png) is -- picked up the same way battle sprites are local ok, img - if name then + if baked then ok, img = pcall(obpIcon, path) else ok, img = pcall(love.graphics.newImage, Assets.resolve(path)) @@ -290,6 +299,16 @@ function PartyMenu.drawIcon(game, mon, x, y, selected, counter, forceAlt) -- whatever size the file is (unchanged path) love.graphics.draw(img, x, y) end + -- Report the covering rect so Renderer:endFrame can re-blit it unshaded + -- over the colorized pass. Every branch above lays a frame into a 16x16 + -- OAM block except the last, which draws the file at its own size. + -- No vanilla icon record sets the flag, so this stays dead code without a + -- mod and the zone lists are exactly the ones the states returned. + if trueColor then + local mw, mh = 16, 16 + if not (PartyMenu.mirrorsIcon(name) or ih > 16) then mw, mh = iw, ih end + require("src.render.PaletteFX").markTrueColor(x, y, mw, mh) + end return true end diff --git a/tests/mod_graphics_tests.lua b/tests/mod_graphics_tests.lua index 8c56df3d..65f2c5dc 100644 --- a/tests/mod_graphics_tests.lua +++ b/tests/mod_graphics_tests.lua @@ -143,7 +143,8 @@ local savedLoaded = {} for _, name in ipairs({ "src.render.PaletteFX", "src.render.Renderer", "src.render.Font", "src.render.Assets", "src.render.SpriteRenderer", - "src.render.TileRenderer", "src.mods.Loader" }) do + "src.render.TileRenderer", "src.mods.Loader", + "src.ui.PartyMenu" }) do savedLoaded[name] = package.loaded[name] package.loaded[name] = nil end @@ -157,6 +158,7 @@ local Font = require("src.render.Font") local Hooks = require("src.mods.Hooks") local HudTiles = require("src.render.HudTiles") local PaletteFX = require("src.render.PaletteFX") +local PartyMenu = require("src.ui.PartyMenu") local Registry = require("src.mods.Registry") local Renderer = require("src.render.Renderer") local Runtime = require("src.mods.Runtime") @@ -409,6 +411,51 @@ check(BattleState.trainerTrueColor(trainerPicData, "a basePic reuse inherits the base portrait's trueColor flag") PaletteFX.setMode(savedColors) +-- ------- trueColor: mon menu icons take the same opt-out +-- Icons were the one art class that never had it. Seven records in +-- Schemas carry trueColor; icons.bySpecies did not, and Sprites.iconPath +-- returned a path alone where every sibling returns path, trueColor. So +-- no icon draw site could know its art was full colour, none reported a +-- rect, and on any screen declaring an SGB zone the art met the shade-remap +-- shader -- which buckets on the RED channel, so a warm pixel clears 0.83 +-- and comes back c0, white in every named palette. +do + local iconData = { + pokemon = { PLAINMON = { dex = 1 }, FULLMON = { dex = 2 } }, + icons = { + icons = { QUADRUPED = "assets/generated/icons/mon/quadruped.png" }, + byDex = { [1] = "QUADRUPED", [2] = "QUADRUPED" }, + bySpecies = { + FULLMON = { image = "mods/skin/full_icon.png", trueColor = true }, + }, + }, + } + local game = { data = iconData } + + PaletteFX.clearTrueColor() + PaletteFX.setPass("ui") + + PartyMenu.drawIcon(game, { species = "PLAINMON" }, 24, 40, false, 0, false) + check(#PaletteFX.trueColorRects("ui") == 0, + "a built-in icon class reports no trueColor rect") + + resetLog() + PartyMenu.drawIcon(game, { species = "FULLMON" }, 24, 40, false, 0, false) + local rects = PaletteFX.trueColorRects("ui") + check(#rects == 1 and rects[1].x == 24 and rects[1].y == 40 + and rects[1].w == 16 and rects[1].h == 16, + "a trueColor icon reports its rectangle so the shader skips it") + -- the OBP bake is itself a 4-shade remap (obpIcon keys off the red + -- channel exactly as the shader does), so full-colour art must not take + -- it either. A baked icon is built from ImageData and carries .data; + -- art loaded straight from its path does not. + check(log.draws[1] and log.draws[1].what and log.draws[1].what.data == nil, + "and it is not run through the OBP bake") + + PaletteFX.setPass(nil) + PaletteFX.clearTrueColor() +end + -- ------- trueColor: the colors == false zone sentinel check(PaletteFX.zone(nil, 0, 0, 1, 1) == nil, "nil colors is still no zone") diff --git a/tests/mod_qol_hooks_tests.lua b/tests/mod_qol_hooks_tests.lua index ba37e3a1..97ac1e3a 100644 --- a/tests/mod_qol_hooks_tests.lua +++ b/tests/mod_qol_hooks_tests.lua @@ -448,6 +448,29 @@ do check(Sprites.iconPath(data, mon, "assets/generated/icons/mon/quadruped.png") == "assets/generated/icons/mon/quadruped.png", "unwrapped pokemon.icon is vanilla") + + -- the icon seam carries the same trueColor contract the sprite seam does: + -- a hook substituting full-colour art flags it on ctx, and the flag comes + -- back beside the path so the draw site can skip the OBP bake and report + -- the rect. Without it a pack's icons are bucketed by red channel and + -- come out white under any screen that declares an SGB zone. + unsub = wrap("pokemon.icon", function(next, p, ctx) + if ctx.mon and ctx.mon.skin == "alt" then + ctx.trueColor = true + return "mods/skinpicker/assets/pika_icon_hd.png" + end + return next(p, ctx) + end) + local hdIcon, hdTC = Sprites.iconPath(data, mon, + "assets/generated/icons/mon/quadruped.png", { name = "QUADRUPED" }) + check(hdIcon == "mods/skinpicker/assets/pika_icon_hd.png" and hdTC == true, + "pokemon.icon can swap path + trueColor from mon state") + local plainIcon, plainTC = Sprites.iconPath(data, { species = "PIKACHU" }, + "assets/generated/icons/mon/quadruped.png", { name = "QUADRUPED" }) + check(plainIcon == "assets/generated/icons/mon/quadruped.png" + and plainTC == false, + "and an unflagged icon stays unflagged through the same hook") + unsub() end