From c8f6c7241b64e318034fc5c668f8d6f78cc32e5b Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Fri, 14 Aug 2026 17:07:06 -0400 Subject: [PATCH] i did it for greg --- docs/modding/reference/registries.md | 7 +++ src/battle/BattleState.lua | 26 +++++++- src/mods/Gen2Compat.lua | 6 +- src/mods/Schemas.lua | 8 +++ src/ui/OakSpeech.lua | 10 ++- src/ui/gen2/BattleState.lua | 27 ++++++-- tests/engine/trainer_true_color.lua | 92 ++++++++++++++++++++++++++++ tests/mod_battle_tests.lua | 15 +++++ tests/mod_graphics_tests.lua | 26 ++++++++ 9 files changed, 205 insertions(+), 12 deletions(-) create mode 100644 tests/engine/trainer_true_color.lua diff --git a/docs/modding/reference/registries.md b/docs/modding/reference/registries.md index 58fbc831..929299f7 100644 --- a/docs/modding/reference/registries.md +++ b/docs/modding/reference/registries.md @@ -929,6 +929,10 @@ mod.content.sfx:register("SFX_MOD_CHIME", { file = "chime.ogg" }) | field | type | required | |---|---|---| +| `anchorX` | number | no | +| `anchorY` | number | no | +| `frameHeight` | integer >= 1 | no | +| `frameWidth` | integer >= 1 | no | | `frames` | integer >= 1 | yes | | `id` | string | no | | `image` | file path | yes | @@ -1100,6 +1104,7 @@ mod.content.tokens:register("CLOCK", function(game) return "12" end) | `paletteSource` | string | no | | `parties` | list of list of {level, species} | yes | | `pic` | file path | no | +| `trueColor` | boolean | no | ```lua mod.content.trainers:patch("OPP_BROCK", { baseMoney = 99 }) @@ -1122,7 +1127,9 @@ do not. | `index` | integer 0..255 | no | | `items` | list of items id | no | | `name` | string | yes | +| `pic` | file path | no | | `trainers` | list of {id?, index?, name, party, trainerType?} | yes | +| `trueColor` | boolean | no | ```lua mod.content.trainers:patch("BEAUTY", { baseMoney = 99 }) diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 2a23d96c..90fe49b8 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -316,6 +316,27 @@ function BattleState.trainerPicPath(data, trainer, oppClass, partyIndex) return base and base.pic or nil end +-- trueColor on the trainer record, or on the basePic it reuses when the +-- subclass does not set the flag itself. Explicit false stays false. +function BattleState.trainerTrueColor(data, trainer) + if not trainer then return false end + if trainer.trueColor ~= nil then + return trainer.trueColor and true or false + end + local base = trainer.basePic and data and data.trainers + and data.trainers[trainer.basePic] + return (base and base.trueColor) and true or false +end + +-- Load a trainer frontpic through getImage so a trueColor portrait skips +-- the 4-shade quantize the same way a species pic does. +function BattleState.trainerSprite(data, trainer, oppClass, partyIndex) + return getImage( + BattleState.trainerPicPath(data, trainer, oppClass, partyIndex), + BattleState.trainerPalette(data, trainer), + BattleState.trainerTrueColor(data, trainer)) +end + -- The battle-BGP fade variant of a pic (AnimationFlashScreen and the -- SetAnimationBGPalette effects remap the four BG shades; on the SGB -- the colorizer then colors the REMAPPED shade, so a faded pic shows @@ -756,9 +777,8 @@ function BattleState.newTrainer(game, oppClass, partyIndex) -- MonsterPalettes[0] = PAL_MEWMON -- InitBattleCommon zeroes -- wEnemyMonSpecies2 before the intro's SET_PAL_BATTLE -- (engine/battle/core.asm:6682, engine/gfx/palettes.asm SetPal_Battle) - self.trainerPic = getImage( - BattleState.trainerPicPath(game.data, self.trainer, oppClass, partyIndex), - BattleState.trainerPalette(game.data, self.trainer)) + self.trainerPic = BattleState.trainerSprite( + game.data, self.trainer, oppClass, partyIndex) self.introText = Strings("%s wants\nto fight!", self.trainer.name) return self end diff --git a/src/mods/Gen2Compat.lua b/src/mods/Gen2Compat.lua index aa319967..22a03c07 100644 --- a/src/mods/Gen2Compat.lua +++ b/src/mods/Gen2Compat.lua @@ -1797,7 +1797,8 @@ local function buildBattleState() "throwBall", "ballChain", "tossAnimFor", "ballFlicker", "ballMissMessage", "storeCaughtMon", "safariAction", "safariEnemyTurn", "drawBallRow", "drawClassic", "isWideBattleLayout", "wideLayout", "bgMode", "uiSize", - "sgbPalettes", "trainerPalette", "trainerPicPath", "invalidate", + "sgbPalettes", "trainerPalette", "trainerPicPath", "trainerTrueColor", + "trainerSprite", "invalidate", "imageBattleScale", "resolveBattleScale", "backPlacement", "frontPlacement", "StatBox", "enter", "exit", }) do @@ -1884,7 +1885,8 @@ COVERAGE["src.battle.BattleState"] = { absent = "newWild newTrainer makeSafari makeGhost makeBattler resolveTurn " .. "computeDamage catchAttempt runRoll enter exit sgbPalettes " .. "isWideBattleLayout wideLayout bgMode uiSize letterboxWhite " - .. "holdsUIAnchors BG_WORLD_DIM trainerPalette trainerPicPath invalidate " + .. "holdsUIAnchors BG_WORLD_DIM trainerPalette trainerPicPath " + .. "trainerTrueColor trainerSprite invalidate " .. "backPlacement frontPlacement StatBox drawClassic drawBallRow " .. "safariAction safariEnemyTurn throwBall storeCaughtMon field ruleset " .. "rng oppClass partyIndex aiUses introText dead", diff --git a/src/mods/Schemas.lua b/src/mods/Schemas.lua index b72b85b0..23d34b37 100644 --- a/src/mods/Schemas.lua +++ b/src/mods/Schemas.lua @@ -1017,6 +1017,9 @@ R.trainers = { index = f.opt(f.int(0, 255)), -- unused vanilla classes ship without a pic, so it cannot be required pic = f.opt(f.path), + -- Full-color portrait: skip the 4-shade SGB/GBC remap, same flag pokemon + -- and sprites already carry. + trueColor = f.opt(f.bool), -- Optional Advanced-mode OBJ palette source for a custom trainer portrait. -- It follows the same ROM crosswalk form as sprites.paletteSource. paletteSource = f.opt(f.str), @@ -1068,6 +1071,11 @@ R.trainers = { gen2Fields = { id = f.opt(f.str), name = f.str, index = f.opt(f.int(0, 255)), + -- class frontpic; when set, this wins over menu_gfx.battleHud.trainerPics + pic = f.opt(f.path), + -- Full-color portrait: skip the GBC 4-shade remap, same flag Gen 1 + -- trainers and pokemon already carry. + trueColor = f.opt(f.bool), baseMoney = f.opt(f.int(0)), -- the class's battle theme; Gen 1 spells the same idea `battleTheme`, -- but this is the extractor's own key and a strict rename would reject diff --git a/src/ui/OakSpeech.lua b/src/ui/OakSpeech.lua index e09077d6..5da99051 100644 --- a/src/ui/OakSpeech.lua +++ b/src/ui/OakSpeech.lua @@ -98,14 +98,14 @@ function OakSpeech.resolvePic(game, desc, speech) local t = desc.type if t == "trainer" then if speech and desc.id == "OPP_PROF_OAK" and speech.oakPic then - return speech.oakPic, false, false + return speech.oakPic, false, speech.oakTrueColor or false end if speech and desc.id == "OPP_RIVAL1" and speech.rivalPic then - return speech.rivalPic, false, false + return speech.rivalPic, false, speech.rivalTrueColor or false end local trainers = game.data.trainers or {} local tr = trainers[desc.id] - return tryImage(tr and tr.pic), false, false + return tryImage(tr and tr.pic), false, tr and tr.trueColor or false elseif t == "pokemon" then if speech and desc.id == speech.demoSpecies and speech.demoPic then return speech.demoPic, desc.flip and true or false, speech.demoTrueColor @@ -241,7 +241,11 @@ function OakSpeech.new(game, onDone) self.answers = {} local trainers = game.data.trainers or {} self.oakPic = tryImage(trainers.OPP_PROF_OAK and trainers.OPP_PROF_OAK.pic) + self.oakTrueColor = self.oakPic + and trainers.OPP_PROF_OAK and trainers.OPP_PROF_OAK.trueColor or false self.rivalPic = tryImage(trainers.OPP_RIVAL1 and trainers.OPP_RIVAL1.pic) + self.rivalTrueColor = self.rivalPic + and trainers.OPP_RIVAL1 and trainers.OPP_RIVAL1.trueColor or false local oakGfx = (game.data.field and game.data.field.oakSpeech) or {} self.cfg = oakGfx -- the show-off mon and the name length cap come from data; the vanilla diff --git a/src/ui/gen2/BattleState.lua b/src/ui/gen2/BattleState.lua index 5c78e670..1e118fcd 100644 --- a/src/ui/gen2/BattleState.lua +++ b/src/ui/gen2/BattleState.lua @@ -185,6 +185,19 @@ function BattleState:statusHUDVisible() self) ~= false end +-- Class frontpic for the battle intro. A trainers-registry `pic` wins over +-- the extracted menu_gfx sheet; `trueColor` skips the GBC 4-shade remap. +-- Returns path, trueColor. +function BattleState.trainerArt(data, classId) + if not classId then return nil, false end + local classes = data and data.gen2Trainers and data.gen2Trainers.classes + local classDef = classes and classes[classId] + local hud = data and data.gen2MenuGfx and data.gen2MenuGfx.battleHud + local path = (classDef and classDef.pic) + or (hud and hud.trainerPics and hud.trainerPics[classId]) + return path, (classDef and classDef.trueColor) and true or false +end + -- opts: battle (a Battle), onDone(outcome), save function BattleState.new(game, opts) opts = opts or {} @@ -308,6 +321,7 @@ function BattleState.new(game, opts) -- pic is a cache asset, so an import made before the extractor grew that -- stage has none and the mon stands in for the whole intro. self.showEnemyTrainer = false + self.enemyTrainerTrueColor = false -- The CLASS CONSTANT (BUG_CATCHER), which is what both tables this looks the -- pic up in are keyed by: menu_gfx's trainerPics is written out of -- constants.trainerClassOrder, and palettes.trainers out of the same names. @@ -317,17 +331,19 @@ function BattleState.new(game, opts) -- no palette for every trainer the world starts, which is all of them. -- `classId` is the trainers.lua key, i.e. the constant; `className` is the -- DISPLAY name ("BUG CATCHER", with the space) and is not a key at all. + -- A class record's own `pic` / `trueColor` (the trainers registry) wins + -- over the extracted sheet, so a mod can drop in full-color art. local enemyTrainer = self.battle and self.battle.trainer self.enemyTrainerClass = enemyTrainer and (enemyTrainer.classId or enemyTrainer.class) - local trainerPics = hudGfx and hudGfx.trainerPics - local trainerPath = self.enemyTrainerClass and trainerPics - and trainerPics[self.enemyTrainerClass] + local trainerPath, trainerTrueColor = + BattleState.trainerArt(data, self.enemyTrainerClass) if trainerPath then local ok, image = pcall(Assets.image, trainerPath) if ok and image then self.enemyTrainerImage = image self.enemyTrainerPath = trainerPath + self.enemyTrainerTrueColor = trainerTrueColor and true or false self.showEnemyTrainer = true end end @@ -588,7 +604,10 @@ function BattleState:drawPic(mon, back) -- slides it out (InitEnemyTrainer, engine/battle/core.asm:7848). local enemyTrainer = (not back) and self.showEnemyTrainer and self.enemyTrainerImage - if enemyTrainer then image, path = enemyTrainer, self.enemyTrainerPath end + if enemyTrainer then + image, path = enemyTrainer, self.enemyTrainerPath + trueColor = self.enemyTrainerTrueColor + end if not image then return end local side = back and "player" or "enemy" local anim = self:animPicState(side) diff --git a/tests/engine/trainer_true_color.lua b/tests/engine/trainer_true_color.lua new file mode 100644 index 00000000..91743287 --- /dev/null +++ b/tests/engine/trainer_true_color.lua @@ -0,0 +1,92 @@ +-- trainers.trueColor: the same 4-shade opt-out pokemon and sprites already +-- carry, now on the trainers registry. ROM-free. +-- luajit tests/engine/trainer_true_color.lua +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local BattleState = require("src.battle.BattleState") +local Gen2Battle = require("src.ui.gen2.BattleState") +local Schemas = require("src.mods.Schemas") +local OakSpeech = require("src.ui.OakSpeech") + +local spec = Schemas.REGISTRIES.trainers +T.check(spec.fields.trueColor ~= nil, + "Gen 1 trainers schema lists trueColor") +T.check(Schemas.check(spec, "trainers", "OPP_BROCK", + { trueColor = true }, "patch"), + "a trueColor patch validates") +T.check(not Schemas.check(spec, "trainers", "OPP_BROCK", + { trueColor = "yes" }, "patch"), + "trueColor rejects a non-boolean") + +local gen2 = Schemas.shapeFor("trainers", spec, 2) +T.check(gen2.fields.trueColor ~= nil and gen2.fields.pic ~= nil, + "Gold trainers schema lists pic and trueColor") +T.check(Schemas.check(spec, "trainers", "BEAUTY", + { pic = "mods/x/beauty.png", trueColor = true }, + "patch", 2), + "a Gold pic+trueColor patch validates") + +T.eq(BattleState.trainerTrueColor(nil, nil), false, + "no trainer is not trueColor") +T.eq(BattleState.trainerTrueColor(nil, { pic = "a.png" }), false, + "a vanilla portrait is not trueColor") +T.eq(BattleState.trainerTrueColor(nil, { trueColor = true }), true, + "the record's own flag wins") +T.eq(BattleState.trainerTrueColor(nil, { trueColor = false }), false, + "explicit false stays false") + +local data = { + trainers = { + BASE = { pic = "base.png", trueColor = true }, + VANILLA = { pic = "vanilla.png" }, + }, +} +T.eq(BattleState.trainerTrueColor(data, { basePic = "BASE" }), true, + "a basePic reuse inherits the base flag") +T.eq(BattleState.trainerTrueColor(data, + { basePic = "BASE", trueColor = false }), false, + "an explicit false on the subclass beats the base") +T.eq(BattleState.trainerTrueColor(data, { basePic = "VANILLA" }), false, + "reusing a vanilla base stays unshaded-off") + +local goldData = { + gen2Trainers = { + classes = { + BEAUTY = { pic = "mods/x/beauty.png", trueColor = true }, + BUG_CATCHER = {}, + }, + }, + gen2MenuGfx = { + battleHud = { + trainerPics = { + BEAUTY = "assets/generated/trainers/beauty.png", + BUG_CATCHER = "assets/generated/trainers/bug_catcher.png", + }, + }, + }, +} +local beautyPath, beautyTc = Gen2Battle.trainerArt(goldData, "BEAUTY") +T.eq(beautyPath, "mods/x/beauty.png", + "a class pic wins over the extracted sheet") +T.eq(beautyTc, true, "and keeps trueColor") +local bugPath, bugTc = Gen2Battle.trainerArt(goldData, "BUG_CATCHER") +T.eq(bugPath, "assets/generated/trainers/bug_catcher.png", + "a class without pic keeps the extracted sheet") +T.eq(bugTc, false, "and is not trueColor") +T.eq(select(1, Gen2Battle.trainerArt(goldData, nil)), nil, + "no class is no pic") + +local game = { + data = { + trainers = { + OPP_BROCK = { pic = "brock.png", trueColor = true }, + OPP_PROF_OAK = { pic = "oak.png", trueColor = true }, + }, + }, +} +local _, _, oakTc = OakSpeech.resolvePic(game, + { type = "trainer", id = "OPP_BROCK" }) +T.eq(oakTc, true, "OakSpeech reports a trainer record's trueColor") + +T.finish("trainer true color") diff --git a/tests/mod_battle_tests.lua b/tests/mod_battle_tests.lua index e0c74e04..4c310756 100644 --- a/tests/mod_battle_tests.lua +++ b/tests/mod_battle_tests.lua @@ -23,6 +23,7 @@ local MoveEffects = require("src.battle.MoveEffects") local Pokemon = require("src.pokemon.Pokemon") local Runtime = require("src.mods.Runtime") local SaveData = require("src.core.SaveData") +local Schemas = require("src.mods.Schemas") local Status = require("src.battle.Status") local TrainerAI = require("src.battle.TrainerAI") local TurnOrder = require("src.battle.TurnOrder") @@ -346,6 +347,20 @@ do check(BattleState.trainerPicPath(Data, { basePic = "OPP_ENGINEER" }) == Data.trainers.OPP_ENGINEER.pic, "a custom trainer can reuse a base trainer portrait by id") + check(BattleState.trainerTrueColor(Data, { trueColor = true }) == true, + "a trainer record's trueColor flag is readable") + check(BattleState.trainerTrueColor(Data, { trueColor = false }) == false, + "explicit false stays false") + check(BattleState.trainerTrueColor(Data, { basePic = "OPP_ENGINEER" }) + == false, + "a vanilla base portrait is not trueColor") + check(Schemas.check(Schemas.REGISTRIES.trainers, "trainers", "OPP_BROCK", + { trueColor = true }, "patch"), + "a trueColor trainers patch validates against the catalog schema") + check(Schemas.check(Schemas.REGISTRIES.trainers, "trainers", "BEAUTY", + { pic = "mods/x/beauty.png", trueColor = true }, + "patch", 2), + "a Gold trainers patch can carry pic and trueColor") end do diff --git a/tests/mod_graphics_tests.lua b/tests/mod_graphics_tests.lua index df7fcbb5..37ccc2a1 100644 --- a/tests/mod_graphics_tests.lua +++ b/tests/mod_graphics_tests.lua @@ -370,6 +370,32 @@ check(math.abs(r - 0.4) < 1e-6 and math.abs(g - 0.7) < 1e-6 and math.abs(b - 0.9) < 1e-6, "a trueColor pic keeps a pixel no 4-shade palette contains") +-- trainers.trueColor is the same opt-out on a class portrait +BattleState.invalidate() +local trainerPicData = { + trainers = { + SHADED = { pic = "assets/generated/battle/front/shaded.png" }, + FULLCOLOR = { pic = "assets/generated/battle/front/full.png", + trueColor = true }, + REUSED = { basePic = "FULLCOLOR" }, + }, + palettes = { palettes = { MEWMON = monPalette }, pokemon = {} }, +} +local shadedTrainer = BattleState.trainerSprite(trainerPicData, + trainerPicData.trainers.SHADED) +r, g, b = shadedTrainer.data:getPixel(0, 0) +check(r == 0 and g == 0 and b == 1, + "a 4-shade trainer pic is palette-quantized onto its shade bucket") +local fullTrainer = BattleState.trainerSprite(trainerPicData, + trainerPicData.trainers.FULLCOLOR) +r, g, b = fullTrainer.data:getPixel(0, 0) +check(math.abs(r - 0.4) < 1e-6 and math.abs(g - 0.7) < 1e-6 + and math.abs(b - 0.9) < 1e-6, + "a trueColor trainer pic keeps a pixel no 4-shade palette contains") +check(BattleState.trainerTrueColor(trainerPicData, + trainerPicData.trainers.REUSED) == true, + "a basePic reuse inherits the base portrait's trueColor flag") + -- ------- trueColor: the colors == false zone sentinel check(PaletteFX.zone(nil, 0, 0, 1, 1) == nil, "nil colors is still no zone")