From 53e1a535ddea86c8d3fe44c59be0a36f06abd2ec Mon Sep 17 00:00:00 2001 From: johnjohto Date: Wed, 29 Jul 2026 23:51:05 -0400 Subject: [PATCH 1/2] Support custom trainer palettes and base portraits --- src/battle/BattleState.lua | 24 +++++++++++++++++++++++- src/mods/Schemas.lua | 9 +++++++++ src/render/PaletteFX.lua | 2 +- 3 files changed, 33 insertions(+), 2 deletions(-) diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index ad0e5e2d..b6eb4821 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -187,6 +187,27 @@ local function namedPalette(data, name) return { name = key, colors = colors } end +-- Custom trainer portraits can opt into the same Advanced OBJ palette source +-- as their overworld walker. Vanilla trainers preserve the hardware-faithful +-- MEWMON fallback used during the battle introduction. +function BattleState.trainerPalette(data, trainer) + local source = trainer and trainer.paletteSource + if source then + local PaletteFX = require("src.render.PaletteFX") + local colors, group = PaletteFX.spriteObp({ paletteSource = source }, trainer.id) + if colors then + return { name = "trainer:" .. source .. ":" .. tostring(group), colors = colors } + end + end + return namedPalette(data, "MEWMON") +end + +function BattleState.trainerPicPath(data, trainer) + if trainer and trainer.pic then return trainer.pic end + local base = trainer and trainer.basePic and data.trainers[trainer.basePic] + return base and base.pic or nil +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 @@ -607,7 +628,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(self.trainer.pic, namedPalette(game.data, "MEWMON")) + self.trainerPic = getImage(BattleState.trainerPicPath(game.data, self.trainer), + BattleState.trainerPalette(game.data, self.trainer)) self.introText = Strings("%s wants\nto fight!", self.trainer.name) return self end diff --git a/src/mods/Schemas.lua b/src/mods/Schemas.lua index c73aa09b..7e0f7490 100644 --- a/src/mods/Schemas.lua +++ b/src/mods/Schemas.lua @@ -555,6 +555,11 @@ 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), + -- 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), + -- Reuse a base trainer class's portrait without redistributing its asset. + basePic = f.opt(f.id("trainers")), baseMoney = f.opt(f.int(0)), parties = f.list(f.list(f.rec{ level = f.int(1), species = f.id("pokemon") })), @@ -574,6 +579,10 @@ R.sprites = { frames = f.int(1), walker = f.opt(f.bool), trueColor = f.opt(f.bool), + -- Mod art can opt into an existing ROM sprite's Advanced-mode OBJ + -- palette assignment without claiming that the image itself came from + -- the ROM (which is what `source` documents on imported records). + paletteSource = f.opt(f.str), }, example = 'mod.content.sprites:register("SPRITE_HERO", { image = "...", frames = 6 })', } diff --git a/src/render/PaletteFX.lua b/src/render/PaletteFX.lua index 4891f446..da89c91a 100644 --- a/src/render/PaletteFX.lua +++ b/src/render/PaletteFX.lua @@ -518,7 +518,7 @@ end function PaletteFX.spriteObp(spriteDef, seed) local pack = PaletteFX.gbcPack() local w = pack and pack.world - local src = spriteDef and spriteDef.source + local src = spriteDef and (spriteDef.paletteSource or spriteDef.source) if not (w and src) then return nil end local idx = tonumber(src:match("%[(%d+)%]")) -- RedBikeSprite loads outside SpriteSheetPointerTable From 7b208043b913de0892a95bbfb471655f23ecc0dc Mon Sep 17 00:00:00 2001 From: johnjohto Date: Thu, 30 Jul 2026 10:17:30 -0400 Subject: [PATCH 2/2] Fix Yellow NPC trades --- src/core/Data.lua | 23 +++++++++++++ tests/parity_yellow_trades.lua | 59 ++++++++++++++++++++++++++++++++++ tests/run_tests.lua | 1 + tools/make_yellow_manifest.py | 1 + tools/rom_manifest_yellow.json | 54 +++++++++++++++---------------- 5 files changed, 111 insertions(+), 27 deletions(-) create mode 100644 tests/parity_yellow_trades.lua diff --git a/src/core/Data.lua b/src/core/Data.lua index 8ca5a734..a68c050d 100644 --- a/src/core/Data.lua +++ b/src/core/Data.lua @@ -38,6 +38,22 @@ local CONSTANT_DEFAULTS = { }, } +-- data/events/trades.asm in Pokemon Yellow has its own TradeMons table. +-- Old Yellow imports were built from the Red manifest, so correct that +-- table after loading as well as in the fixed import manifest below. +local YELLOW_TRADES = { + { give = "LICKITUNG", get = "DUGTRIO", dialogset = 1, nickname = "GURIO" }, + { give = "CLEFAIRY", get = "MR_MIME", dialogset = 1, nickname = "MILES" }, + { give = "BUTTERFREE", get = "BEEDRILL", dialogset = 3, nickname = "STINGER" }, + { give = "KANGASKHAN", get = "MUK", dialogset = 1, nickname = "STICKY" }, + { give = "MEW", get = "MEW", dialogset = 3, nickname = "BART" }, + { give = "TANGELA", get = "PARASECT", dialogset = 1, nickname = "SPIKE" }, + { give = "PIDGEOT", get = "PIDGEOT", dialogset = 2, nickname = "MARTY" }, + { give = "GOLDUCK", get = "RHYDON", dialogset = 2, nickname = "BUFFY" }, + { give = "GROWLITHE", get = "DEWGONG", dialogset = 3, nickname = "CEZANNE" }, + { give = "CUBONE", get = "MACHOKE", dialogset = 3, nickname = "RICKY" }, +} + -- field.boot is the total-conversion override point for the new game; the -- values match what SaveData.newGame and the Oak speech used to inline. local BOOT_DEFAULTS = { @@ -58,6 +74,12 @@ local function copy(value) return out end +function Data:applyVersionedFieldData() + if require("src.core.GameVersion").isYellow() then + self.field.trades = copy(YELLOW_TRADES) + end +end + -- Fills only what the cache is missing, so an importer that learns to -- stamp one of these keys silently takes over from the engine. function Data:seedDefaults() @@ -77,6 +99,7 @@ function Data:seedDefaults() if constants.dexDigits == nil then constants.dexDigits = math.max(3, #tostring(constants.dexSize)) end + self:applyVersionedFieldData() local boot = self.field.boot if boot == nil then boot = {} diff --git a/tests/parity_yellow_trades.lua b/tests/parity_yellow_trades.lua new file mode 100644 index 00000000..95e34bda --- /dev/null +++ b/tests/parity_yellow_trades.lua @@ -0,0 +1,59 @@ +-- Pokemon Yellow has a different TradeMons table from Red and Blue. +-- The import manifest must agree, and Data:seedDefaults repairs Yellow +-- caches made before that manifest carried the Yellow table (#453). + +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end + +local Data = require("src.core.Data") +if not (Data.field and Data.field.trades) then Data:load() end +local GameVersion = require("src.core.GameVersion") +local S = require("tests.harness").suite("parity Yellow trades") +local check, eq = S.check, S.eq + +local oldVersion = GameVersion.get() +local oldTrades = {} +for i, trade in ipairs(Data.field.trades) do + oldTrades[i] = {} + for key, value in pairs(trade) do oldTrades[i][key] = value end +end + +GameVersion.set("yellow") +Data:applyVersionedFieldData() + +local expected = { + { "LICKITUNG", "DUGTRIO", "GURIO" }, + { "CLEFAIRY", "MR_MIME", "MILES" }, + { "BUTTERFREE", "BEEDRILL", "STINGER" }, + { "KANGASKHAN", "MUK", "STICKY" }, + { "MEW", "MEW", "BART" }, + { "TANGELA", "PARASECT", "SPIKE" }, + { "PIDGEOT", "PIDGEOT", "MARTY" }, + { "GOLDUCK", "RHYDON", "BUFFY" }, + { "GROWLITHE", "DEWGONG", "CEZANNE" }, + { "CUBONE", "MACHOKE", "RICKY" }, +} + +local manifestFile = assert(io.open("tools/rom_manifest_yellow.json", "r")) +local manifest = manifestFile:read("*a") +manifestFile:close() + +eq(#Data.field.trades, #expected, "Yellow has ten in-game trade rows") +for i, want in ipairs(expected) do + local got = Data.field.trades[i] + check(got.give == want[1] and got.get == want[2] and got.nickname == want[3], + ("Yellow trade %d is %s for %s (%s)"):format(i, want[1], want[2], want[3])) + local row = ('"get": "%s",%%s+"give": "%s",%%s+"nickname": "%s"') + :format(want[2], want[1], want[3]) + check(manifest:find(row) ~= nil, + ("Yellow manifest carries trade %d"):format(i)) +end + +local ricky = Data.field.trades[10] +check(ricky.give == "CUBONE" and ricky.get == "MACHOKE", + "the Underground Path trade is Cubone for Machoke") + +Data.field.trades = oldTrades +GameVersion.set(oldVersion) + +return S:finish() diff --git a/tests/run_tests.lua b/tests/run_tests.lua index 274789d1..0bf52a1c 100644 --- a/tests/run_tests.lua +++ b/tests/run_tests.lua @@ -3277,6 +3277,7 @@ runSuites(orderedGlob("tests/parity_*.lua", { "tests/parity_flavor.lua", "tests/parity_trainer_sight.lua", "tests/parity_static.lua", "tests/parity_trashcans.lua", "tests/parity_hof.lua", "tests/parity_trade_gift.lua", + "tests/parity_yellow_trades.lua", "tests/parity_intro.lua", "tests/parity_tilt.lua", "tests/parity_gbcfx.lua", })) diff --git a/tools/make_yellow_manifest.py b/tools/make_yellow_manifest.py index f1527e2b..aca456e9 100755 --- a/tools/make_yellow_manifest.py +++ b/tools/make_yellow_manifest.py @@ -382,6 +382,7 @@ def derive(red, pokeyellow, symbols_path): except SystemExit as exc: print(f"warning: parse_credits failed ({exc}); keeping Red credits") # TODO: hand-author a Yellow credits banner if pret layout drifts. + yellow["field"]["trades"] = field.parse_trades(pokeyellow) finally: util.ASM_DEFINES = saved diff --git a/tools/rom_manifest_yellow.json b/tools/rom_manifest_yellow.json index 9d565854..00c35a7a 100644 --- a/tools/rom_manifest_yellow.json +++ b/tools/rom_manifest_yellow.json @@ -10552,63 +10552,63 @@ "trades": [ { "dialogset": 1, - "get": "NIDORINA", - "give": "NIDORINO", - "nickname": "TERRY" + "get": "DUGTRIO", + "give": "LICKITUNG", + "nickname": "GURIO" }, { "dialogset": 1, "get": "MR_MIME", - "give": "ABRA", - "nickname": "MARCEL" + "give": "CLEFAIRY", + "nickname": "MILES" }, { "dialogset": 3, "get": "BEEDRILL", "give": "BUTTERFREE", - "nickname": "CHIKUCHIKU" + "nickname": "STINGER" }, { "dialogset": 1, - "get": "SEEL", - "give": "PONYTA", - "nickname": "SAILOR" + "get": "MUK", + "give": "KANGASKHAN", + "nickname": "STICKY" }, { "dialogset": 3, - "get": "FARFETCHD", - "give": "SPEAROW", - "nickname": "DUX" + "get": "MEW", + "give": "MEW", + "nickname": "BART" }, { "dialogset": 1, - "get": "LICKITUNG", - "give": "SLOWBRO", - "nickname": "MARC" + "get": "PARASECT", + "give": "TANGELA", + "nickname": "SPIKE" }, { "dialogset": 2, - "get": "JYNX", - "give": "POLIWHIRL", - "nickname": "LOLA" + "get": "PIDGEOT", + "give": "PIDGEOT", + "nickname": "MARTY" }, { "dialogset": 2, - "get": "ELECTRODE", - "give": "RAICHU", - "nickname": "DORIS" + "get": "RHYDON", + "give": "GOLDUCK", + "nickname": "BUFFY" }, { "dialogset": 3, - "get": "TANGELA", - "give": "VENONAT", - "nickname": "CRINKLES" + "get": "DEWGONG", + "give": "GROWLITHE", + "nickname": "CEZANNE" }, { "dialogset": 3, - "get": "NIDORAN_F", - "give": "NIDORAN_M", - "nickname": "SPOT" + "get": "MACHOKE", + "give": "CUBONE", + "nickname": "RICKY" } ], "warpCarpets": {