diff --git a/data/palettes_gbc_yellow.lua b/data/palettes_gbc_yellow.lua new file mode 100644 index 00000000..7b4c341c --- /dev/null +++ b/data/palettes_gbc_yellow.lua @@ -0,0 +1,270 @@ +-- Yellow-only Advanced (redpp) deltas. Merged on top of data/palettes_gbc.lua +-- world tables when GameVersion.isYellow(); Red/Blue never load this file's +-- world path. Named SuperPalettes stay washed on Yellow -- PaletteFX.pal +-- prefers CGBBase under Advanced instead (title MEWMON/LOGO, YELLOWMON). +-- See #1639. + +return { + world = { + -- Summer Beach House: Yellow-only tileset. Tile→group map mirrors HOUSE + -- (closest Advanced indoor profile); colors lean sand / wood / water. + tileGroups = { + BEACH_HOUSE = { + [0] = 5, + [1] = 0, + [2] = 0, + [3] = 0, + [4] = 1, + [5] = 0, + [6] = 3, + [7] = 3, + [8] = 2, + [9] = 2, + [10] = 2, + [11] = 2, + [12] = 2, + [13] = 2, + [14] = 5, + [15] = 5, + [16] = 0, + [17] = 0, + [18] = 0, + [19] = 0, + [20] = 1, + [21] = 0, + [22] = 3, + [23] = 3, + [24] = 5, + [25] = 5, + [26] = 5, + [27] = 5, + [28] = 0, + [29] = 0, + [30] = 5, + [31] = 5, + [32] = 0, + [33] = 0, + [34] = 3, + [35] = 5, + [36] = 5, + [37] = 5, + [38] = 5, + [39] = 5, + [40] = 5, + [41] = 5, + [42] = 2, + [43] = 2, + [44] = 5, + [45] = 5, + [46] = 5, + [47] = 5, + [48] = 5, + [49] = 5, + [50] = 5, + [51] = 5, + [52] = 5, + [53] = 5, + [54] = 5, + [55] = 5, + [56] = 5, + [57] = 5, + [58] = 5, + [59] = 5, + [60] = 5, + [61] = 5, + [62] = 5, + [63] = 5, + [64] = 0, + [65] = 0, + [66] = 0, + [67] = 0, + [68] = 0, + [69] = 0, + [70] = 5, + [71] = 5, + [72] = 5, + [73] = 5, + [74] = 5, + [75] = 5, + [76] = 5, + [77] = 5, + [78] = 5, + [79] = 5, + [80] = 5, + [81] = 5, + [82] = 5, + [83] = 5, + [84] = 5, + [85] = 5, + [86] = 5, + [87] = 5, + [88] = 5, + [89] = 5, + [90] = 5, + [91] = 5, + [92] = 5, + [93] = 5, + [94] = 5, + [95] = 5, + }, + }, + groupColors = { + BEACH_HOUSE = { + -- sand / wood floor + { + { 255, 239, 198 }, + { 206, 173, 115 }, + { 156, 123, 74 }, + { 58, 58, 58 }, + }, + -- signs / accents (warm coral) + { + { 255, 239, 198 }, + { 255, 156, 148 }, + { 230, 82, 66 }, + { 58, 58, 58 }, + }, + -- foliage + { + { 255, 239, 198 }, + { 123, 189, 82 }, + { 66, 123, 41 }, + { 58, 58, 58 }, + }, + -- water / windows + { + { 255, 239, 198 }, + { 99, 189, 230 }, + { 41, 115, 189 }, + { 58, 58, 58 }, + }, + -- yellow props + { + { 255, 239, 198 }, + { 255, 255, 82 }, + { 230, 148, 25 }, + { 58, 58, 58 }, + }, + -- wood walls / furniture + { + { 255, 239, 198 }, + { 189, 140, 74 }, + { 140, 99, 41 }, + { 58, 58, 58 }, + }, + -- sky / cool trim + { + { 255, 239, 198 }, + { 148, 189, 255 }, + { 99, 148, 230 }, + { 58, 58, 58 }, + }, + -- text + { + { 255, 255, 255 }, + { 255, 255, 255 }, + { 255, 255, 255 }, + { 0, 0, 0 }, + }, + }, + }, + + -- Yellow spriteOrder is 82 entries; Red Advanced assignment is 72. + -- Indices 60-69 are Yellow inserts (Pikachu, Jenny, mons, Jessie/James); + -- Red's ball/fossil/snorlax/... shift to 70-81. + spriteAssignment = { + [0] = 0, + [1] = 1, + [2] = 3, + [3] = "random", + [4] = 0, + [5] = "random", + [6] = "random", + [7] = "random", + [8] = 0, + [9] = "random", + [10] = "random", + [11] = "random", + [12] = "random", + [13] = "random", + [14] = "random", + [15] = 1, + [16] = 1, + [17] = "random", + [18] = "random", + [19] = "random", + [20] = "random", + [21] = 2, + [22] = 1, + [23] = 3, + [24] = "random", + [25] = "random", + [26] = "random", + [27] = "random", + [28] = "random", + [29] = 0, + [30] = 3, + [31] = 3, + [32] = "random", + [33] = "random", + [34] = "random", + [35] = "random", + [36] = "random", + [37] = "random", + [38] = "random", + [39] = "random", + [40] = 0, + [41] = 2, + [42] = "random", + [43] = "random", + [44] = "random", + [45] = "random", + [46] = "random", + [47] = "random", + [48] = "random", + [49] = "random", + [50] = "random", + [51] = "random", + [52] = "random", + [53] = "random", + [54] = "random", + [55] = 0, + [56] = 1, + [57] = 3, + [58] = 0, + [59] = 0, + [60] = 4, -- SPRITE_PIKACHU + [61] = 1, -- SPRITE_OFFICER_JENNY + [62] = 3, -- SPRITE_SANDSHREW + [63] = 2, -- SPRITE_ODDISH + [64] = 2, -- SPRITE_BULBASAUR + [65] = "random", -- SPRITE_JIGGLYPUFF + [66] = "random", -- SPRITE_CLEFAIRY + [67] = "random", -- SPRITE_CHANSEY + [68] = "random", -- SPRITE_JESSIE + [69] = "random", -- SPRITE_JAMES + [70] = 0, -- SPRITE_POKE_BALL + [71] = 0, -- SPRITE_FOSSIL + [72] = 3, -- SPRITE_BOULDER + [73] = 3, -- SPRITE_PAPER + [74] = 0, -- SPRITE_POKEDEX + [75] = 3, -- SPRITE_CLIPBOARD + [76] = 0, -- SPRITE_SNORLAX + [77] = 3, -- SPRITE_UNUSED_OLD_AMBER + [78] = 3, -- SPRITE_OLD_AMBER + [79] = "random", + [80] = "random", + [81] = 3, -- SPRITE_GAMBLER_ASLEEP + }, + + -- Group 4 is Pikachu yellow on Yellow Advanced (Red keeps its own [4]). + spritePalettes = { + [4] = { + { 222, 255, 222 }, + { 255, 255, 0 }, + { 230, 115, 0 }, + { 0, 0, 0 }, + }, + }, + }, +} diff --git a/scripts/test.sh b/scripts/test.sh index 71760e19..966b6e3e 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -114,6 +114,7 @@ fi run_tier "T0 ROM builder version routing" python3 tests/build_rom_data_cli_test.py run_tier "T0 ROM manifest generator pin/overrides" python3 tests/rom_manifest_generator_test.py +run_tier "T0 Yellow title OBP eye remap" python3 tests/title_pikachu_obp_test.py run_tier "T0 Crystal manifest + specials coverage" "$LUA" tests/crystal_import_test.lua run_tier "T0 switch CI workflow content gate" "$LUA" tests/switch_ci_workflows_test.lua run_tier "T0 switch transfer docs gate" "$LUA" tests/switch_transfer_docs_test.lua diff --git a/src/core/Data.lua b/src/core/Data.lua index 935739a9..5ba18879 100644 --- a/src/core/Data.lua +++ b/src/core/Data.lua @@ -170,6 +170,10 @@ function Data:seedDefaults(version) -- writes no header for him -- seed one so he engages on sight and has -- his defeat / re-talk lines (same idea as the Cinnabar seed above). Data.seedFightingDojoKarateMaster(self) + -- #1743: Mt. Moon B2F Super Nerd is text_asm (no def_trainers), so Yellow's + -- extractor never writes his header -- seed one so engageTrainer finds + -- battle/won/after text instead of the "I like shorts!" fallback. + Data.seedMtMoonB2FSuperNerd(self) -- #189: 1F cabin door order vs rooms map (survey zoom) require("src.world.SsAnneLayout").apply(self.maps) end @@ -201,6 +205,25 @@ function Data:seedFightingDojoKarateMaster() } end +-- Mt. Moon B2F Super Nerd (object index 1) is text_asm with no def_trainers +-- row, so Yellow never gets a trainerHeaders.MtMoonB2F[1] entry (Red/Blue +-- pin one in make_rom_manifest.py). Without it, engageTrainer falls through +-- to the hard-coded "I like shorts!" fallback (#1743). trainerDefeated still +-- tracks him via defeatedTrainers[npc.id]; the fabricated event name matches +-- the shipped Red/Blue manifest pin for consistency with fossil drivers. +function Data:seedMtMoonB2FSuperNerd() + local headers = self.trainer_headers + if not headers then return end + headers.MtMoonB2F = headers.MtMoonB2F or {} + if headers.MtMoonB2F[1] then return end + headers.MtMoonB2F[1] = { + battle = "_MtMoonB2FSuperNerdTheyreBothMineText", + won = "_MtMoonB2FSuperNerdOkIllShareText", + after = "_MtMoonB2FSuperNerdTheresAPokemonLabText", + event = "EVENT_BEAT_MT_MOON_3_SUPER_NERD", + } +end + function Data:seedCinnabarGymTrainerHeaders() local headers = self.trainer_headers if not headers or headers.CinnabarGym then return end diff --git a/src/import/ImageWriter.lua b/src/import/ImageWriter.lua index 3b57e347..633b41c7 100644 --- a/src/import/ImageWriter.lua +++ b/src/import/ImageWriter.lua @@ -7,6 +7,28 @@ local SHADES = { { 0, 0, 0, 1 }, } +ImageWriter.SHADES = SHADES + +-- Title screen rOBP0 = %11100000 ($E0): OBJ shades 1 and 2 draw as white, +-- shade 3 as black (pokeyellow engine/movie/title.asm after PlacePikachu). +-- Eye OAM is baked into the MEWMON-colored BG PNG; without this remap the +-- shade-1 glints become body yellow under the title palette. +function ImageWriter.applyTitleObp0(image) + local mid, dark = SHADES[2][1], SHADES[3][1] + local w, h = image:getWidth(), image:getHeight() + for y = 0, h - 1 do + for x = 0, w - 1 do + local r, g, b, a = image:getPixel(x, y) + if a ~= 0 then + if math.abs(r - mid) < 0.02 or math.abs(r - dark) < 0.02 then + image:setPixel(x, y, 1, 1, 1, 1) + end + end + end + end + return image +end + local function assertDimensions(raw, width, height, bits) assert(width % 8 == 0 and height % 8 == 0, ("%dbpp dimensions must be tile-aligned: %dx%d") diff --git a/src/import/RomExtractor.lua b/src/import/RomExtractor.lua index a1b59884..0ab57381 100644 --- a/src/import/RomExtractor.lua +++ b/src/import/RomExtractor.lua @@ -1535,6 +1535,14 @@ function RomExtractor:extractYellowTitleArt() local bg = sheetTiles("TitlePikachuBGGraphics", 64) local ob = sheetTiles("TitlePikachuOBGraphics", 12) local obClear = sheetTiles("TitlePikachuOBGraphics", 12, true) + -- Title rOBP0=$E0: remap eye OAM shades 1/2 → white before baking into the + -- MEWMON BG composition (otherwise glints read as body yellow). + local eyeOb = {} + for i, tile in ipairs(obClear) do + local copy = ImageWriter.blank(8, 8, 0, 0, 0, 0) + ImageWriter.blit(copy, tile, 0, 0) + eyeOb[i] = ImageWriter.applyTitleObp0(copy) + end local function tileFor(id) if id < 0x80 then return logo[id + 1] end if id < 0xF0 then return bg[id - 0x80 + 1] end @@ -1623,13 +1631,13 @@ function RomExtractor:extractYellowTitleArt() local overlay = ImageWriter.blank(48, 16, 1, 1, 1, 0) ImageWriter.blit(overlay, pikachu, 0, 0, 24, 16, 48, 16) for _, e in ipairs(EYE_LAYOUT) do - blitSprite(overlay, obClear[base + e[1]], e[2] - 24, e[3] - 16, e[4]) + blitSprite(overlay, eyeOb[base + e[1]], e[2] - 24, e[3] - 16, e[4]) end overlays[suffix] = overlay end -- open eyes bake into pikachu.png AFTER the blank-face crops for _, e in ipairs(EYE_LAYOUT) do - blitSprite(pikachu, obClear[e[1]], e[2], e[3], e[4]) + blitSprite(pikachu, eyeOb[e[1]], e[2], e[3], e[4]) end self:save(pikachu, "title/pikachu.png") for suffix, overlay in pairs(overlays) do diff --git a/src/inventory/ItemEffects.lua b/src/inventory/ItemEffects.lua index f0ecadcd..6fa8ab07 100644 --- a/src/inventory/ItemEffects.lua +++ b/src/inventory/ItemEffects.lua @@ -184,6 +184,14 @@ local function adjacentSleepingSnorlax(save, ow) return nil end +local function itemUseLine(data, save, name) + return romText(data, "_ItemUseText001", "%s used\n%s!", save.player.name, name) +end + +-- PrintItemUseTextAndRemoveItem (item_effects.asm): used-line + SFX_HEAL_AILMENT. +-- BagMenu plays Heal_Ailment via TextBox.soundOpts when extra.useJingle is set. +local USE_JINGLE = { useJingle = true } + -- Use an item on a target party mon (target may be nil for targetless -- items). data = generated data tables; battle = BattleState when used -- mid-battle; ow = the overworld (OverworldState), needed only to check @@ -280,7 +288,7 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow) "All sleeping\nPOKéMON woke up!") } end - -- battle-only items + -- battle-only items (PrintItemUseTextAndRemoveItem + Heal_Ailment, #1635) if X_ITEMS[itemId] or itemId == "DIRE_HIT" or itemId == "GUARD_SPEC" or itemId == "POKE_DOLL" then if not battle then @@ -293,21 +301,23 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow) require("src.world.PikachuFollower") .modifyHappiness(save, "USEDXITEM", b and b.mon) end + local used = itemUseLine(data, save, name) if itemId == "X_ACCURACY" then -- ItemUseXAccuracy sets USING_X_ACCURACY: moves never miss - -- (not an accuracy stage) + -- (not an accuracy stage); vanilla prints only the used line b.xAccuracy = true - return "consumed", { Strings("%s's\nhits will never\nmiss!", b.name) } + return "consumed", { used }, USE_JINGLE end if X_ITEMS[itemId] then local stat = X_ITEMS[itemId] local cur = b.stages[stat] or 0 - -- ItemUseXStat removes the item BEFORE running the stat-up - -- effect, so at +6 it is still consumed and StatModifierUpEffect - -- just prints "Nothing happened!" + -- ItemUseXStat: PrintItemUseTextAndRemoveItem, then StatModifierUpEffect if cur >= 6 then - return "consumed", { romText(data, "_NothingHappenedText", - "Nothing happened!") } + return "consumed", { used }, { + useJingle = true, + afterMessages = { romText(data, "_NothingHappenedText", + "Nothing happened!") }, + } end b.stages[stat] = cur + 1 b.hazeStatReset = nil @@ -315,26 +325,29 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow) and battle.kind ~= "link" then require("src.battle.Damage").reapplyBadgeBoosts(b, stat) end - return "consumed", { Strings("%s's\n%s rose!", b.name, Strings(STAT_LABEL[stat])) } + return "consumed", { used }, { + useJingle = true, + afterMessages = { Strings("%s's\n%s rose!", b.name, + Strings(STAT_LABEL[stat])) }, + } end -- ItemUseDireHit/ItemUseGuardSpec always set the bit and consume - -- the item, even when it is already active + -- the item, even when it is already active; vanilla prints only used if itemId == "DIRE_HIT" then b.focusEnergy = true - return "consumed", { romText(data, "_GettingPumpedText", - "%s's\ngetting pumped!", b.name) } + return "consumed", { used }, USE_JINGLE end if itemId == "GUARD_SPEC" then b.mist = true - return "consumed", { Strings("%s's\nprotected against\nstat changes!", b.name) } + return "consumed", { used }, USE_JINGLE end if itemId == "POKE_DOLL" then if battle.kind ~= "wild" then -- ItemUsePokeDoll jumps to ItemUseNotTime in trainer battles return "failed", { notTime(data, save) } end - return "consumed_escape", { romText(data, "_WildRanText", - "The wild POKéMON\nran away!", battle.enemy and battle.enemy.name) } + -- PrintItemUseTextAndRemoveItem then escape + return "consumed_escape", { used }, USE_JINGLE end end @@ -613,7 +626,7 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow) if REPELS[itemId] then local steps = itemId == "REPEL" and 100 or itemId == "SUPER_REPEL" and 200 or 250 save.repelSteps = steps - return "consumed", { Strings("%s used\n%s!", save.player.name, name) } + return "consumed", { itemUseLine(data, save, name) }, USE_JINGLE end return "failed", { notTime(data, save) } diff --git a/src/render/PaletteFX.lua b/src/render/PaletteFX.lua index b00d65c3..f94ff2ca 100644 --- a/src/render/PaletteFX.lua +++ b/src/render/PaletteFX.lua @@ -20,6 +20,8 @@ local PaletteFX = {} local shader -- false = unavailable (headless / no shader support) local gbcPack -- false = missing; nil = not loaded yet local yellowPack -- false = missing; nil = not loaded yet +local gbcYellowPack -- Yellow Advanced deltas; false = missing; nil = not loaded yet + -- Cycle order matches OptionsMenu / hotkey 2. The three real colorizations -- come first (OG RED/BLUE/YELLOW = GBC hardware, SGB = per-map Super Game Boy, @@ -300,6 +302,38 @@ function PaletteFX.yellowPack() return yellowPack or nil end +-- Yellow-only Advanced deltas (BEACH_HOUSE, sprite remap, YELLOWMON). Never +-- consulted on Red/Blue (#1639). +function PaletteFX.gbcYellowPack() + if gbcYellowPack == nil then + local ok, pack = pcall(require, "data.palettes_gbc_yellow") + gbcYellowPack = ok and pack or false + end + return gbcYellowPack or nil +end + +-- Active world bake tables for Advanced. Yellow merges gbc_yellow deltas +-- via __index so shared Red tilesets stay identical byte-for-byte. +function PaletteFX.worldPack() + local pack = PaletteFX.gbcPack() + local w = pack and pack.world + if not w then return nil end + if not GameVersion.isYellow() then return w end + local y = PaletteFX.gbcYellowPack() + local yw = y and y.world + if not yw then return w end + return { + tileGroups = setmetatable(yw.tileGroups or {}, { __index = w.tileGroups }), + groupColors = setmetatable(yw.groupColors or {}, { __index = w.groupColors }), + roofGroup = w.roofGroup, + roofByMapIndex = w.roofByMapIndex, + spriteAssignment = yw.spriteAssignment or w.spriteAssignment, + spritePalettes = yw.spritePalettes + and setmetatable(yw.spritePalettes, { __index = w.spritePalettes }) + or w.spritePalettes, + } +end + function PaletteFX.usesGbcPack(mode) mode = mode or PaletteFX.mode return mode == "redpp" @@ -453,7 +487,10 @@ function PaletteFX.pal(data, name) if fromRom then return fromRom end end if GameVersion.isYellow() then - if PaletteFX.usesYellowCgb() then + -- OG YELLOW and Advanced both use CGBBase for named pals: SuperPalettes + -- wash out yellows (title MEWMON/LOGO, YELLOWMON) to pale cream. World + -- tile bake still comes from the Advanced GBC pack via worldPack (#1639). + if PaletteFX.usesYellowCgb() or PaletteFX.usesGbcPack() then local fromCgb = yellowCgbNamedPal(data, name) if fromCgb then return fromCgb end end @@ -511,7 +548,8 @@ function PaletteFX.monPal(data, species, transformed) if pal then return pal end end local name = p.pokemon[species] or "MEWMON" - if PaletteFX.usesYellowCgb() then + if PaletteFX.usesYellowCgb() + or (GameVersion.isYellow() and PaletteFX.usesGbcPack()) then local yc = PaletteFX.pal(data, name) if yc then return yc end end @@ -618,16 +656,14 @@ local ROUTE_6_SAFFRON = { mapId = "ROUTE_6", useMapId = "SAFFRON_CITY", cellYBel -- (false for a mod tileset with no pokered-gbc counterpart, or when the -- pack failed to load at all) function PaletteFX.hasWorldTileset(tileset) - local pack = PaletteFX.gbcPack() - local w = pack and pack.world + local w = PaletteFX.worldPack() return (w and w.tileGroups[tileset]) ~= nil end -- the palette-group (0-7) a tile GRAPHIC id resolves to in this tileset, -- with the current map's tile-id exceptions (if any) applied first function PaletteFX.worldGroupAt(tileset, mapId, tileId) - local pack = PaletteFX.gbcPack() - local w = pack and pack.world + local w = PaletteFX.worldPack() local groups = w and w.tileGroups[tileset] if not groups then return nil end local exc = TILE_GROUP_EXCEPTIONS[mapId] @@ -642,8 +678,7 @@ end -- Saffron's roof colors while the player stands in its top 2 cell rows, -- like pokered's wYCoord check -- data is Game.data, for the map lookup) function PaletteFX.worldGroupColors(data, tileset, mapId, playerCellY) - local pack = PaletteFX.gbcPack() - local w = pack and pack.world + local w = PaletteFX.worldPack() local base = w and w.groupColors[tileset] if not base then return nil end if not w.roofGroup[tileset] then return darkGroups(base) end @@ -678,17 +713,18 @@ end -- here): a stable hash instead, so the same NPC instance always shows the -- same one of the 4 SPR_PAL_* colors. function PaletteFX.spriteObp(spriteDef, seed) - local pack = PaletteFX.gbcPack() - local w = pack and pack.world + local w = PaletteFX.worldPack() 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 and SurfingPikachuSprite load outside -- SpriteSheetPointerTable, so their source has no bracketed index; - -- they wear the player's OBP palette (spriteAssignment[0]). - if not idx and (src:find("RedBikeSprite", 1, true) - or src:find("SurfingPikachuSprite", 1, true)) then + -- they wear the player's OBP palette (spriteAssignment[0]) on Red/Blue. + -- On Yellow, Surfing Pikachu uses the Pikachu yellow OBJ group (#1639). + if not idx and src:find("RedBikeSprite", 1, true) then idx = 0 + elseif not idx and src:find("SurfingPikachuSprite", 1, true) then + idx = GameVersion.isYellow() and 60 or 0 end local group = idx and w.spriteAssignment[idx] if group == nil then return nil end diff --git a/src/render/Renderer.lua b/src/render/Renderer.lua index 6b956889..4c795875 100644 --- a/src/render/Renderer.lua +++ b/src/render/Renderer.lua @@ -338,6 +338,7 @@ function Renderer:beginFrame(transparent) -- warp-fade overlay from Transition (issue #121); cleared each frame so -- a popped transition cannot leave a sticky black veil self.worldFadeAlpha = nil + self.worldFadeColor = nil -- battle-transition wipe, drawn over the whole surface (BattleTransition) self.battleWipe = nil -- whole-surface veil in screen space (battle-transition flash, the @@ -1045,7 +1046,8 @@ function Renderer:endFrame(zones, worldZones) -- the screen-space overlays the flat path draws over its composite local fade = self.worldFadeAlpha if fade and fade > 0 then - love.graphics.setColor(0, 0, 0, fade) + local c = self.worldFadeColor or { 0, 0, 0 } + love.graphics.setColor(c[1], c[2], c[3], fade) love.graphics.rectangle("fill", vux, vuy, vuw, vuh) love.graphics.setColor(1, 1, 1, 1) end @@ -1132,7 +1134,8 @@ function Renderer:endFrame(zones, worldZones) -- composite normally if one is ever stacked that way. local fade = self.worldFadeAlpha if fade and fade > 0 then - love.graphics.setColor(0, 0, 0, fade) + local c = self.worldFadeColor or { 0, 0, 0 } + love.graphics.setColor(c[1], c[2], c[3], fade) love.graphics.rectangle("fill", vux, vuy, vuw, vuh) love.graphics.setColor(1, 1, 1, 1) end diff --git a/src/render/Transition.lua b/src/render/Transition.lua index 4080afd7..7c955a3b 100644 --- a/src/render/Transition.lua +++ b/src/render/Transition.lua @@ -65,16 +65,24 @@ end -- (ViridianGym.asm .afterBeat, RocketHideoutB4F BeatGiovanniScript) call -- GBFadeOutToBlack -> GBFadeInFromBlack instead, so the default keeps the -- symmetric 32-frame fade back in (home/fade.asm:21, b = 4). -function Transition.new(game, onMidpoint, onDone, warp) +-- +-- opts.color = {r,g,b} in 0..1 (default black). opts.frames / opts.framesIn +-- override duration. Fly/Teleport/Dig/Escape Rope use white +-- GBFadeOutToWhite / GBFadeInFromWhite (#1644). +function Transition.new(game, onMidpoint, onDone, warp, opts) + opts = opts or {} local self = setmetatable({}, Transition) self.game = game self.onMidpoint = onMidpoint self.onDone = onDone self.t = 0 self.phase = "out" + self.color = opts.color or { 0, 0, 0 } local style = styleOf(game, "warp_fade") - self.frames = style.frames or FRAMES - if warp then + self.frames = opts.frames or style.frames or FRAMES + if opts.framesIn ~= nil then + self.framesIn = opts.framesIn + elseif warp then -- a style may still ask for a fade in (mods, and the record is -- data-driven); the built-in warp is 0, matching hardware self.framesIn = style.framesIn or FRAMES_IN @@ -125,6 +133,7 @@ end function Transition:draw() local alpha = self:alpha() + local c = self.color or { 0, 0, 0 } -- Survey zoom draws the overworld into a window-filling world canvas -- while the UI pass stays the classic 160x144 letterbox. A rect on the -- UI canvas only darkens that center box (issue #121); when the world @@ -133,9 +142,10 @@ function Transition:draw() local r = self.game and self.game.renderer if r and r.worldActive then r.worldFadeAlpha = alpha + r.worldFadeColor = c return end - love.graphics.setColor(0, 0, 0, alpha) + love.graphics.setColor(c[1], c[2], c[3], alpha) love.graphics.rectangle("fill", 0, 0, 160, 144) love.graphics.setColor(1, 1, 1, 1) end diff --git a/src/ui/BagMenu.lua b/src/ui/BagMenu.lua index 949dcfcc..8b5a6c1b 100644 --- a/src/ui/BagMenu.lua +++ b/src/ui/BagMenu.lua @@ -54,6 +54,21 @@ local function showMessages(game, msgs, onDone, opts) game.stack:push(TextBox.new(game, table.concat(msgs, "\f"), onDone, opts)) end +-- PrintItemUseTextAndRemoveItem: used-line TextBox carries Heal_Ailment, then +-- optional afterMessages (X Stat effect line) before onDone (#1635). +local function showUseMessages(game, msgs, onDone, extra) + local opts = (extra and extra.useJingle) + and TextBox.soundOpts(game, "Heal_Ailment") or nil + local after = extra and extra.afterMessages + if after and #after > 0 then + showMessages(game, msgs, function() + showMessages(game, after, onDone) + end, opts) + else + showMessages(game, msgs, onDone, opts) + end +end + -- run the use-flow for an item on a chosen target. `picker` is the party -- menu when it was opened with keepOpen (HP medicine only): it is still on -- the stack, so every exit that prints has to close it afterwards. For @@ -115,7 +130,7 @@ local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker) if result == "consumed_escape" then -- Poké Doll consume(game, id, list) list:close() - showMessages(game, payload, function() + showUseMessages(game, payload, function() -- ItemUsePokeDoll sets wEscapedFromBattle and never touches -- wBattleResult, so a script that reads the result afterwards sees -- 0 -- "defeated". The ghost MAROWAK's script keys on exactly that @@ -125,7 +140,7 @@ local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker) battle.result = "run" battle.afterQueue = "finish" battle.phase = "messages" - end) + end, extra) return end @@ -403,9 +418,9 @@ local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker) end if battle then list:close() - showMessages(game, payload, function() battle:itemUsed({}) end) + showUseMessages(game, payload, function() battle:itemUsed({}) end, extra) else - showMessages(game, payload, closePicker) + showUseMessages(game, payload, closePicker, extra) end return end diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index 4554dc5d..6616c0bb 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -4818,6 +4818,15 @@ function OverworldState:startWarpTo(mapId, x, y, facing, onDone, opts) require("src.core.Sound").play(Game.data, outdoor and "Go_Outside" or "Go_Inside") end + -- Fly/Teleport/Dig/Escape Rope: GBFadeOutToWhite / GBFadeInFromWhite + -- (player_animations.asm). Door warps stay black with no fade-in (#1644). + local Timing = require("src.core.Timing") + local specialWarp = arriveWarp == "fly" or arriveWarp == "teleport" + local fadeOpts = specialWarp and { + color = { 1, 1, 1 }, + frames = Timing.FADE_OUT_TO_WHITE, + framesIn = Timing.FADE_IN_FROM_WHITE, + } or nil Game.stack:push(Transition.new(Game, function() self:setMap(mapId, x, y, facing or "down", opts) -- the departure-side hide from flyAnim/teleportOut ends here, on the new @@ -4884,7 +4893,7 @@ function OverworldState:startWarpTo(mapId, x, y, facing, onDone, opts) end, function() self.transitioning = false if onDone then onDone() end - end, true)) -- warp shape: no fade back in (LoadGBPal restores in one write) + end, not specialWarp, fadeOpts)) end -- Re-read a map record after its data changed (WorldAPI:invalidateMap, diff --git a/tests/engine/item_use_jingle_bug1635.lua b/tests/engine/item_use_jingle_bug1635.lua new file mode 100644 index 00000000..ee8660b6 --- /dev/null +++ b/tests/engine/item_use_jingle_bug1635.lua @@ -0,0 +1,62 @@ +-- #1635: PrintItemUseTextAndRemoveItem items must request Heal_Ailment +-- via extra.useJingle after the "X used Y!" line. +-- +-- luajit tests/engine/item_use_jingle_bug1635.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end + +local S = require("tests.harness").suite("item use Heal_Ailment jingle #1635") +local check, eq = S.check, S.eq + +local Data = require("src.core.Data") +local ItemEffects = require("src.inventory.ItemEffects") +local SaveData = require("src.core.SaveData") + +if not Data.maps then Data:load() end + +local save = SaveData.newGame() +save.player.name = "RED" + +local function battleStub(playerName) + return { + kind = "wild", + player = { + name = playerName or "PIKACHU", + stages = { attack = 0, defense = 0, speed = 0, special = 0, accuracy = 0 }, + mon = {}, + }, + enemy = { name = "RATTATA" }, + } +end + +local function assertJingle(itemId, battle) + local result, msgs, extra = ItemEffects.use(Data, save, itemId, nil, battle) + check(result == "consumed" or result == "consumed_escape", + itemId .. " succeeds (" .. tostring(result) .. ")") + check(extra and extra.useJingle, itemId .. " sets useJingle") + check(msgs and msgs[1] and msgs[1]:find("used"), + itemId .. " prints used line") + return result, msgs, extra +end + +for _, id in ipairs({ "REPEL", "SUPER_REPEL", "MAX_REPEL" }) do + assertJingle(id, nil) +end + +local b = battleStub() +for _, id in ipairs({ + "X_ATTACK", "X_DEFEND", "X_SPEED", "X_SPECIAL", + "X_ACCURACY", "DIRE_HIT", "GUARD_SPEC", +}) do + local _, _, extra = assertJingle(id, b) + if id == "X_ATTACK" then + check(extra.afterMessages and #extra.afterMessages > 0, + "X_ATTACK follows used line with effect text") + end +end + +local _, _, dollExtra = assertJingle("POKE_DOLL", battleStub()) +eq(dollExtra.afterMessages, nil, "Poké Doll is used-line only") + +S.finish() diff --git a/tests/engine/mt_moon_super_nerd_header_bug1743.lua b/tests/engine/mt_moon_super_nerd_header_bug1743.lua new file mode 100644 index 00000000..7344c8c6 --- /dev/null +++ b/tests/engine/mt_moon_super_nerd_header_bug1743.lua @@ -0,0 +1,39 @@ +-- #1743: Mt. Moon B2F Super Nerd is text_asm with no def_trainers row, so +-- Yellow never gets trainerHeaders.MtMoonB2F[1]. Without a seed, +-- engageTrainer falls through to the "I like shorts!" fallback. +-- +-- luajit tests/engine/mt_moon_super_nerd_header_bug1743.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end + +local S = require("tests.harness").suite("mt moon super nerd header #1743") +local check, eq = S.check, S.eq + +local Data = require("src.core.Data") + +local empty = { trainer_headers = {} } +Data.seedMtMoonB2FSuperNerd(empty) +local seeded = empty.trainer_headers.MtMoonB2F[1] +check(seeded ~= nil, "seed fills MtMoonB2F[1] when missing") +eq(seeded.battle, "_MtMoonB2FSuperNerdTheyreBothMineText", + "pre-battle text is They're both mine") +eq(seeded.won, "_MtMoonB2FSuperNerdOkIllShareText", "won text seeded") +eq(seeded.after, "_MtMoonB2FSuperNerdTheresAPokemonLabText", "after text seeded") +eq(seeded.event, "EVENT_BEAT_MT_MOON_3_SUPER_NERD", + "event name matches shipped Red/Blue pin") + +-- idempotent: existing header wins +empty.trainer_headers.MtMoonB2F[1] = { battle = "KEEP" } +Data.seedMtMoonB2FSuperNerd(empty) +eq(empty.trainer_headers.MtMoonB2F[1].battle, "KEEP", + "seed does not overwrite an existing header") + +-- live Data load (Red/Blue pin or Yellow seed) must expose the battle label +if not Data.maps then Data:load() end +local header = Data:trainerHeader("MtMoonB2F", 1) +check(header ~= nil, "loaded Data has MtMoonB2F[1]") +eq(header.battle, "_MtMoonB2FSuperNerdTheyreBothMineText", + "engageTrainer can resolve real pre-battle text") + +S.finish() diff --git a/tests/engine/special_warp_white_fade_bug1644.lua b/tests/engine/special_warp_white_fade_bug1644.lua new file mode 100644 index 00000000..2f09c69c --- /dev/null +++ b/tests/engine/special_warp_white_fade_bug1644.lua @@ -0,0 +1,32 @@ +-- #1644: Fly/Teleport special warps fade white (GBFadeOutToWhite / +-- GBFadeInFromWhite), not black door-warp shape. +-- +-- luajit tests/engine/special_warp_white_fade_bug1644.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end + +local S = require("tests.harness").suite("special warp white fade #1644") +local check, eq = S.check, S.eq + +local Timing = require("src.core.Timing") +local Transition = require("src.render.Transition") + +local white = { 1, 1, 1 } +local t = Transition.new(nil, function() end, nil, false, { + color = white, + frames = Timing.FADE_OUT_TO_WHITE, + framesIn = Timing.FADE_IN_FROM_WHITE, +}) +eq(t.color[1], 1, "special warp veil is white R") +eq(t.color[2], 1, "special warp veil is white G") +eq(t.color[3], 1, "special warp veil is white B") +eq(t.frames, Timing.FADE_OUT_TO_WHITE, "fade out uses FADE_OUT_TO_WHITE") +eq(t.framesIn, Timing.FADE_IN_FROM_WHITE, "fade in uses FADE_IN_FROM_WHITE") + +local door = Transition.new(nil, function() end, nil, true) +eq(door.color[1], 0, "door warp stays black") +eq(door.framesIn, 0, "door warp has no fade in") +check(door.frames == Timing.WARP_FADE_OUT, "door warp uses WARP_FADE_OUT") + +S.finish() diff --git a/tests/engine/stat_rise_message_translation_test.lua b/tests/engine/stat_rise_message_translation_test.lua index 83b4da0c..943d785e 100644 --- a/tests/engine/stat_rise_message_translation_test.lua +++ b/tests/engine/stat_rise_message_translation_test.lua @@ -31,16 +31,19 @@ local save = SaveData.newGame() local player = { name = "FIXMON", stages = {} } local xBattle = { player = player, kind = "wild" } -local _, baseline = ItemEffects.use(Data, save, "X_ATTACK", nil, xBattle) -T.check(baseline[1]:find("ATTACK", 1, true) ~= nil, +local _, baseline, baselineExtra = ItemEffects.use(Data, save, "X_ATTACK", nil, xBattle) +local baselineRose = baselineExtra and baselineExtra.afterMessages + and baselineExtra.afterMessages[1] or baseline[1] +T.check(baselineRose:find("ATTACK", 1, true) ~= nil, "X ATTACK's rose! message names the stat in English with no catalog") withCatalog({ ATTACK = "ATTAQUE" }, function() player.stages.attack = nil - local _, msgs = ItemEffects.use(Data, save, "X_ATTACK", nil, xBattle) - T.check(msgs[1]:find("ATTAQUE", 1, true) ~= nil, + local _, msgs, extra = ItemEffects.use(Data, save, "X_ATTACK", nil, xBattle) + local rose = extra and extra.afterMessages and extra.afterMessages[1] or msgs[1] + T.check(rose:find("ATTAQUE", 1, true) ~= nil, "a catalog translating ATTACK reaches the X ATTACK rose! message") - T.check(msgs[1]:find("ATTACK", 1, true) == nil, + T.check(rose:find("ATTACK", 1, true) == nil, "...and the untranslated English stat name is gone") end) diff --git a/tests/engine/title_pikachu_obp_bug.lua b/tests/engine/title_pikachu_obp_bug.lua new file mode 100644 index 00000000..21152dc5 --- /dev/null +++ b/tests/engine/title_pikachu_obp_bug.lua @@ -0,0 +1,19 @@ +-- Title eye OBP remap lives in ImageWriter.applyTitleObp0; pixel-level +-- coverage is tests/title_pikachu_obp_test.py (love_stub ImageData is a +-- no-op). This file only pins the export so the Lua bake path stays wired. +-- +-- luajit tests/engine/title_pikachu_obp_bug.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end + +local S = require("tests.harness").suite("title pikachu OBP export") +local check = S.check + +local ImageWriter = require("src.import.ImageWriter") +check(type(ImageWriter.applyTitleObp0) == "function", + "ImageWriter.applyTitleObp0 is exported for RomExtractor") +check(type(ImageWriter.SHADES) == "table" and #ImageWriter.SHADES == 4, + "ImageWriter.SHADES exposes the 4 DMG ramps") + +S.finish() diff --git a/tests/engine/yellow_advanced_palette_bug1639.lua b/tests/engine/yellow_advanced_palette_bug1639.lua new file mode 100644 index 00000000..0263f772 --- /dev/null +++ b/tests/engine/yellow_advanced_palette_bug1639.lua @@ -0,0 +1,79 @@ +-- #1639: Yellow-only Advanced overlay — BEACH_HOUSE world bake, sprite +-- coverage past Red's 72 entries, saturated YELLOWMON — without changing +-- Red/Blue Advanced. +-- +-- luajit tests/engine/yellow_advanced_palette_bug1639.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end + +local S = require("tests.harness").suite("yellow advanced palette #1639") +local check, eq = S.check, S.eq + +local GameVersion = require("src.core.GameVersion") +local PaletteFX = require("src.render.PaletteFX") + +local prevVer = GameVersion.get() +local prevMode = PaletteFX.mode + +PaletteFX.setMode("redpp") + +-- Red: no BEACH_HOUSE, shared HOUSE still present +GameVersion.set("red") +check(not PaletteFX.hasWorldTileset("BEACH_HOUSE"), + "Red Advanced has no BEACH_HOUSE tileset") +check(PaletteFX.hasWorldTileset("HOUSE"), "Red Advanced still has HOUSE") +local redHouse = PaletteFX.worldGroupColors(nil, "HOUSE", "PALLET_TOWN", 0) + +-- Blue unchanged for BEACH_HOUSE +GameVersion.set("blue") +check(not PaletteFX.hasWorldTileset("BEACH_HOUSE"), + "Blue Advanced has no BEACH_HOUSE tileset") + +-- Yellow: BEACH_HOUSE present, HOUSE still inherits from Red pack +GameVersion.set("yellow") +check(PaletteFX.hasWorldTileset("BEACH_HOUSE"), + "Yellow Advanced profiles BEACH_HOUSE") +check(PaletteFX.hasWorldTileset("HOUSE"), + "Yellow Advanced still sees shared HOUSE") +local beach = PaletteFX.worldGroupColors(nil, "BEACH_HOUSE", "SUMMER_BEACH_HOUSE", 0) +check(beach ~= nil and #beach == 8, "BEACH_HOUSE has 8 group colors") +eq(beach[1][1][1], 255, "BEACH_HOUSE sand group is warm (R=255)") + +-- Sprite: Pikachu @60 and high indices resolve; Red path for bike stays 0 +local pika = PaletteFX.spriteObp({ source = "ROM:SpriteSheetPointerTable[60]" }, "pika") +check(pika ~= nil, "Yellow SPRITE_PIKACHU gets an OBP") +eq(pika[2][2], 255, "Pikachu OBP mid shade is saturated yellow G=255") +local boulder = PaletteFX.spriteObp({ source = "ROM:SpriteSheetPointerTable[72]" }, "b") +check(boulder ~= nil, "Yellow boulder (index 72) gets an OBP") + +GameVersion.set("red") +local redBall = PaletteFX.spriteObp({ source = "ROM:SpriteSheetPointerTable[60]" }, "ball") +check(redBall ~= nil, "Red poke-ball @60 still resolves") +-- Red group 0 mid is orange skin, not pure yellow +check(redBall[2][2] < 200, "Red @60 is not Yellow's Pikachu ramp") + +-- YELLOWMON / MEWMON / LOGO: Advanced uses CGBBase (saturated), not washed +-- SuperPalettes +GameVersion.set("yellow") +local yellowMon = PaletteFX.pal(nil, "YELLOWMON") +eq(yellowMon[2][2], 255, "Advanced Yellow YELLOWMON mid is saturated yellow G") +eq(yellowMon[2][3], 0, "Advanced Yellow YELLOWMON mid is pure yellow B=0") +local mewmon = PaletteFX.pal(nil, "MEWMON") +eq(mewmon[2][1], 255, "MEWMON stays Yellow title yellow (not Red++ purple)") +eq(mewmon[2][2], 255, "MEWMON body is saturated yellow G=255 (not washed 156)") +eq(mewmon[2][3], 0, "MEWMON body is pure yellow B=0") +check(mewmon[3][3] < 150, "MEWMON cheek stays red, not purple") +local logo2 = PaletteFX.pal(nil, "LOGO2") +eq(logo2[2][2], 255, "LOGO2 fill is saturated yellow under Advanced") +eq(logo2[2][3], 0, "LOGO2 fill is pure yellow B=0") + +-- Red HOUSE colors identical before/after Yellow merge path +GameVersion.set("red") +local redHouse2 = PaletteFX.worldGroupColors(nil, "HOUSE", "PALLET_TOWN", 0) +eq(redHouse2[1][1][1], redHouse[1][1][1], "Red HOUSE colors unchanged") + +PaletteFX.setMode(prevMode) +GameVersion.set(prevVer) + +S.finish() diff --git a/tests/rom_manifest_generator_test.py b/tests/rom_manifest_generator_test.py index 07a88d1c..99dd053e 100644 --- a/tests/rom_manifest_generator_test.py +++ b/tests/rom_manifest_generator_test.py @@ -102,5 +102,44 @@ class ApplyKnownNonreproducibleOverridesTest(TestCase): texts, field_data) +class YellowSuperRodParseTest(TestCase): + """#1074: Yellow's inline Super Rod table (species, level), not Red's.""" + + def test_parse_super_rod_yellow_safari_dragonair(self): + import json + import tempfile + from extract import field + + asm = ( + "SuperRodFishingSlots::\n" + "\tdb SAFARI_ZONE_CENTER, MAGIKARP, 5, MAGIKARP, 10, " + "DRATINI, 10, DRAGONAIR, 15\n" + "\tdb SAFARI_ZONE_EAST, MAGIKARP, 5, MAGIKARP, 10, " + "MAGIKARP, 15, DRATINI, 15\n" + "\tdb -1 ; end\n" + ) + with tempfile.TemporaryDirectory() as td: + wild = Path(td) / "data" / "wild" + wild.mkdir(parents=True) + (wild / "super_rod.asm").write_text(asm) + parsed = field.parse_super_rod_yellow(td) + + self.assertEqual(parsed["SAFARI_ZONE_CENTER"], [ + {"level": 5, "species": "MAGIKARP"}, + {"level": 10, "species": "MAGIKARP"}, + {"level": 10, "species": "DRATINI"}, + {"level": 15, "species": "DRAGONAIR"}, + ]) + self.assertNotIn("DRAGONAIR", + [s["species"] for s in parsed["SAFARI_ZONE_EAST"]]) + + def test_shipped_yellow_manifest_has_safari_dragonair(self): + import json + path = Path(__file__).resolve().parents[1] / "tools" / "rom_manifest_yellow.json" + data = json.loads(path.read_text(encoding="utf-8")) + center = data["field"]["superRod"]["SAFARI_ZONE_CENTER"] + self.assertIn({"level": 15, "species": "DRAGONAIR"}, center) + + if __name__ == "__main__": main() diff --git a/tests/title_pikachu_obp_test.py b/tests/title_pikachu_obp_test.py new file mode 100644 index 00000000..a0d47a25 --- /dev/null +++ b/tests/title_pikachu_obp_test.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python3 +"""Title rOBP0=$E0 remaps eye OAM shades 1/2 to white (#1639 pupils).""" + +from pathlib import Path +from unittest import TestCase, main +import sys +import tempfile + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "tools")) +import build_rom_data as b # noqa: E402 +from PIL import Image # noqa: E402 + + +class TitleObp0Test(TestCase): + def test_mid_and_dark_become_white(self): + img = Image.new("RGBA", (4, 1), (0, 0, 0, 0)) + img.putpixel((0, 0), b.GB_SHADES[1]) + img.putpixel((1, 0), b.GB_SHADES[2]) + img.putpixel((2, 0), b.GB_SHADES[3]) + img.putpixel((3, 0), b.GB_SHADES[0]) + b._apply_title_obp0(img) + self.assertEqual(img.getpixel((0, 0)), b.GB_SHADES[0]) + self.assertEqual(img.getpixel((1, 0)), b.GB_SHADES[0]) + self.assertEqual(img.getpixel((2, 0)), b.GB_SHADES[3]) + self.assertEqual(img.getpixel((3, 0)), b.GB_SHADES[0]) + + def test_transparent_untouched(self): + img = Image.new("RGBA", (1, 1), (0, 0, 0, 0)) + b._apply_title_obp0(img) + self.assertEqual(img.getpixel((0, 0))[3], 0) + + +if __name__ == "__main__": + main() diff --git a/tests/yellow_title_palette_test.lua b/tests/yellow_title_palette_test.lua index 33c5d0db..5fdcf044 100644 --- a/tests/yellow_title_palette_test.lua +++ b/tests/yellow_title_palette_test.lua @@ -22,9 +22,12 @@ eq(mewmonSgb[3][1], 222, "SGB: Color 2 is red R=222") PaletteFX.setMode("redpp") local mewmonAdv = PaletteFX.pal(nil, "MEWMON") check(mewmonAdv ~= nil, "MEWMON palette exists in ADVANCED mode on Yellow") --- Must NOT be Red++'s Mew purple {115, 33, 165}! It must be Yellow's Pikachu palette! +-- Must NOT be Red++'s Mew purple {115, 33, 165}! It must be Yellow's Pikachu +-- CGBBase yellow {255,255,0}, not washed SuperPalette cream {255,255,156}. eq(mewmonAdv[1][1], 255, "ADVANCED: Color 0 is white R=255 (Pikachu eye sclera)") eq(mewmonAdv[2][1], 255, "ADVANCED: Color 1 is yellow R=255 (Pikachu body)") +eq(mewmonAdv[2][2], 255, "ADVANCED: Color 1 is saturated yellow G=255") +eq(mewmonAdv[2][3], 0, "ADVANCED: Color 1 is pure yellow B=0 (not washed cream)") check(mewmonAdv[3][3] < 150, "ADVANCED: Color 2 is not purple (B < 150, red cheeks)") -- Test 3: OG YELLOW mode (ogred on Yellow) diff --git a/tools/build_rom_data.py b/tools/build_rom_data.py index 2e1f876f..86f21b85 100755 --- a/tools/build_rom_data.py +++ b/tools/build_rom_data.py @@ -77,6 +77,26 @@ GB_SHADES = ( ) +def _apply_title_obp0(image): + """Title rOBP0=%11100000 ($E0): OBJ shades 1 and 2 → white, 3 → black. + + Eye OAM is baked into the MEWMON-colored BG PNG; without this remap the + shade-1 glints become body yellow under the title palette + (pokeyellow engine/movie/title.asm after PlacePikachu). + """ + pixels = image.load() + w, h = image.size + mid, dark = GB_SHADES[1][0], GB_SHADES[2][0] + for y in range(h): + for x in range(w): + r, g, b, a = pixels[x, y] + if a == 0: + continue + if abs(r - mid) <= 2 or abs(r - dark) <= 2: + pixels[x, y] = GB_SHADES[0] + return image + + def _symbol(symbols, name): try: return symbols[name] @@ -1880,12 +1900,33 @@ def extract_field(rom, symbols, manifest, out_dir, assets_dir): (3, 24, 24, True), (2, 32, 24, True), (0, 56, 16, False), (1, 64, 16, False), (2, 56, 24, False), (3, 64, 24, False)): - eye = ob_clear[ob_index] + eye = ob_clear[ob_index].copy() + _apply_title_obp0(eye) if flip: eye = eye.transpose(Image.Transpose.FLIP_LEFT_RIGHT) pikachu.paste(eye, (px, py), eye) _save_png(pikachu, os.path.join(assets_dir, "title/pikachu.png")) + # Blink overlays (half/closed) — same OBP remap as open eyes. + eye_layout = ( + (1, 24, 16, True), (0, 32, 16, True), + (3, 24, 24, True), (2, 32, 24, True), + (0, 56, 16, False), (1, 64, 16, False), + (2, 56, 24, False), (3, 64, 24, False), + ) + # Re-compose blank-face pika for overlays (open eyes already baked). + blank_face = matte_color0(compose(13, 9, pika_cells)) + for suffix, base in (("eyes_half", 4), ("eyes_closed", 8)): + overlay = Image.new("RGBA", (48, 16), (255, 255, 255, 0)) + overlay.paste(blank_face.crop((24, 16, 72, 32)), (0, 0)) + for ob_index, px, py, flip in eye_layout: + eye = ob_clear[base + ob_index].copy() + _apply_title_obp0(eye) + if flip: + eye = eye.transpose(Image.Transpose.FLIP_LEFT_RIGHT) + overlay.paste(eye, (px - 24, py - 16), eye) + _save_png(overlay, os.path.join(assets_dir, f"title/{suffix}.png")) + falling_star = raw_2bpp( "FallingStar", 8, 8, "intro/falling_star.png", transparent=True) diff --git a/tools/extract/field.py b/tools/extract/field.py index 839be555..0f0224db 100644 --- a/tools/extract/field.py +++ b/tools/extract/field.py @@ -86,6 +86,43 @@ def parse_super_rod(pokered): return out +def parse_super_rod_yellow(pokeyellow): + """pokeyellow data/wild/super_rod.asm: inline species,level rows. + + Yellow stores four (species, level) pairs per map on one `db` line + (`db MAP, SPECIES, LEVEL, SPECIES, LEVEL, ...`), unlike Red's + `dbw MAP, .Group` + `db level, species` groups. Slot order is kept + so Super Rod weighted rolls and DexNav lists stay faithful (#1074). + """ + out = {} + path = os.path.join(pokeyellow, "data/wild/super_rod.asm") + for lineno, line in read_asm(path): + s = line.strip() + if not s.startswith("db ") or s == "db -1" or s.startswith("db -1 ;"): + continue + # db MAP, SPECIES, LEVEL, SPECIES, LEVEL, SPECIES, LEVEL, SPECIES, LEVEL + parts = [p.strip() for p in s[3:].split(",")] + if len(parts) < 3 or not re.match(r"^[A-Z][A-Z0-9_]*$", parts[0]): + continue + map_id = parts[0] + slots = [] + rest = parts[1:] + i = 0 + while i + 1 < len(rest): + species, level = rest[i], rest[i + 1] + if not re.match(r"^[A-Z][A-Z0-9_]*$", species): + break + try: + level_n = int(level) + except ValueError: + break + slots.append({"level": level_n, "species": species}) + i += 2 + if slots: + out[map_id] = slots + return out + + def parse_trades(pokered): """data/events/trades.asm: npctrade give, get, dialogset, nickname.""" # TRADE_DIALOGSET_* order (constants/script_constants.asm) indexes diff --git a/tools/make_yellow_manifest.py b/tools/make_yellow_manifest.py index bcf70752..10988433 100755 --- a/tools/make_yellow_manifest.py +++ b/tools/make_yellow_manifest.py @@ -453,6 +453,7 @@ def derive(red, pokeyellow, symbols_path): 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) + yellow["field"]["superRod"] = field.parse_super_rod_yellow(pokeyellow) finally: util.ASM_DEFINES = saved diff --git a/tools/rom_manifest_yellow.json b/tools/rom_manifest_yellow.json index ed4d489c..e6da0bdb 100644 --- a/tools/rom_manifest_yellow.json +++ b/tools/rom_manifest_yellow.json @@ -8360,94 +8360,74 @@ "superRod": { "CELADON_CITY": [ { - "level": 23, - "species": "POLIWHIRL" + "level": 5, + "species": "GOLDEEN" + }, + { + "level": 10, + "species": "GOLDEEN" }, { "level": 15, - "species": "SLOWPOKE" + "species": "GOLDEEN" + }, + { + "level": 20, + "species": "GOLDEEN" } ], "CERULEAN_CAVE_1F": [ { - "level": 23, - "species": "SLOWBRO" + "level": 25, + "species": "GOLDEEN" }, { - "level": 23, + "level": 35, "species": "SEAKING" }, { - "level": 23, - "species": "KINGLER" - }, - { - "level": 23, - "species": "SEADRA" - } - ], - "CERULEAN_CAVE_2F": [ - { - "level": 23, - "species": "SLOWBRO" - }, - { - "level": 23, + "level": 45, "species": "SEAKING" }, { - "level": 23, - "species": "KINGLER" - }, - { - "level": 23, - "species": "SEADRA" + "level": 55, + "species": "SEAKING" } ], "CERULEAN_CAVE_B1F": [ { - "level": 23, - "species": "SLOWBRO" + "level": 30, + "species": "GOLDEEN" }, { - "level": 23, + "level": 40, "species": "SEAKING" }, { - "level": 23, - "species": "KINGLER" + "level": 50, + "species": "SEAKING" }, { - "level": 23, - "species": "SEADRA" + "level": 60, + "species": "SEAKING" } ], "CERULEAN_CITY": [ { - "level": 15, - "species": "PSYDUCK" - }, - { - "level": 15, + "level": 25, "species": "GOLDEEN" }, { - "level": 15, - "species": "KRABBY" - } - ], - "CERULEAN_GYM": [ - { - "level": 15, - "species": "PSYDUCK" - }, - { - "level": 15, + "level": 30, "species": "GOLDEEN" }, { - "level": 15, - "species": "KRABBY" + "level": 30, + "species": "SEAKING" + }, + { + "level": 40, + "species": "SEAKING" } ], "CINNABAR_ISLAND": [ @@ -8457,99 +8437,123 @@ }, { "level": 15, - "species": "HORSEA" + "species": "TENTACOOL" }, { - "level": 15, - "species": "SHELLDER" + "level": 10, + "species": "STARYU" }, { - "level": 15, - "species": "GOLDEEN" + "level": 30, + "species": "TENTACOOL" } ], "FUCHSIA_CITY": [ { - "level": 23, - "species": "SEAKING" + "level": 5, + "species": "MAGIKARP" }, + { + "level": 10, + "species": "MAGIKARP" + }, + { + "level": 15, + "species": "MAGIKARP" + }, + { + "level": 15, + "species": "GYARADOS" + } + ], + "PALLET_TOWN": [ + { + "level": 10, + "species": "STARYU" + }, + { + "level": 10, + "species": "TENTACOOL" + }, + { + "level": 5, + "species": "STARYU" + }, + { + "level": 20, + "species": "TENTACOOL" + } + ], + "ROUTE_10": [ { "level": 15, "species": "KRABBY" }, { - "level": 15, - "species": "GOLDEEN" + "level": 20, + "species": "KRABBY" }, { - "level": 15, - "species": "MAGIKARP" - } - ], - "PALLET_TOWN": [ - { - "level": 15, - "species": "TENTACOOL" + "level": 10, + "species": "HORSEA" }, { - "level": 15, - "species": "POLIWAG" - } - ], - "ROUTE_10": [ - { - "level": 23, - "species": "POLIWHIRL" - }, - { - "level": 15, - "species": "SLOWPOKE" + "level": 25, + "species": "KINGLER" } ], "ROUTE_11": [ { "level": 15, - "species": "KRABBY" + "species": "TENTACOOL" }, { - "level": 15, - "species": "SHELLDER" + "level": 20, + "species": "TENTACOOL" + }, + { + "level": 10, + "species": "TENTACOOL" + }, + { + "level": 5, + "species": "HORSEA" } ], "ROUTE_12": [ { - "level": 5, - "species": "TENTACOOL" + "level": 20, + "species": "HORSEA" }, { - "level": 15, - "species": "KRABBY" + "level": 25, + "species": "HORSEA" }, { - "level": 15, - "species": "GOLDEEN" + "level": 25, + "species": "SEADRA" }, { - "level": 15, - "species": "MAGIKARP" + "level": 35, + "species": "SEADRA" } ], "ROUTE_13": [ { - "level": 5, + "level": 15, + "species": "HORSEA" + }, + { + "level": 20, + "species": "HORSEA" + }, + { + "level": 10, "species": "TENTACOOL" }, { - "level": 15, - "species": "KRABBY" - }, - { - "level": 15, - "species": "GOLDEEN" - }, - { - "level": 15, - "species": "MAGIKARP" + "level": 20, + "species": "SEADRA" } ], "ROUTE_17": [ @@ -8559,304 +8563,356 @@ }, { "level": 15, - "species": "KRABBY" + "species": "TENTACOOL" }, { - "level": 15, - "species": "GOLDEEN" + "level": 25, + "species": "SHELLDER" }, { - "level": 15, - "species": "MAGIKARP" + "level": 35, + "species": "SHELLDER" } ], "ROUTE_18": [ { - "level": 5, + "level": 15, "species": "TENTACOOL" }, { - "level": 15, - "species": "KRABBY" + "level": 20, + "species": "SHELLDER" }, { - "level": 15, - "species": "GOLDEEN" + "level": 30, + "species": "SHELLDER" }, { - "level": 15, - "species": "MAGIKARP" + "level": 40, + "species": "SHELLDER" } ], "ROUTE_19": [ { "level": 15, + "species": "TENTACOOL" + }, + { + "level": 20, "species": "STARYU" }, { - "level": 15, - "species": "HORSEA" + "level": 30, + "species": "TENTACOOL" }, { - "level": 15, - "species": "SHELLDER" - }, - { - "level": 15, - "species": "GOLDEEN" + "level": 30, + "species": "TENTACRUEL" } ], "ROUTE_20": [ { - "level": 15, + "level": 20, + "species": "TENTACOOL" + }, + { + "level": 20, + "species": "TENTACRUEL" + }, + { + "level": 30, "species": "STARYU" }, { - "level": 15, - "species": "HORSEA" - }, - { - "level": 15, - "species": "SHELLDER" - }, - { - "level": 15, - "species": "GOLDEEN" + "level": 40, + "species": "TENTACRUEL" } ], "ROUTE_21": [ { "level": 15, + "species": "TENTACOOL" + }, + { + "level": 20, "species": "STARYU" }, { - "level": 15, - "species": "HORSEA" + "level": 30, + "species": "TENTACOOL" }, { - "level": 15, - "species": "SHELLDER" - }, - { - "level": 15, - "species": "GOLDEEN" + "level": 30, + "species": "TENTACRUEL" } ], "ROUTE_22": [ { - "level": 15, - "species": "GOLDEEN" + "level": 5, + "species": "POLIWAG" + }, + { + "level": 10, + "species": "POLIWAG" }, { "level": 15, "species": "POLIWAG" + }, + { + "level": 15, + "species": "POLIWHIRL" } ], "ROUTE_23": [ { - "level": 23, - "species": "SLOWBRO" + "level": 25, + "species": "POLIWAG" }, { - "level": 23, - "species": "SEAKING" + "level": 30, + "species": "POLIWAG" }, { - "level": 23, - "species": "KINGLER" + "level": 30, + "species": "POLIWHIRL" }, { - "level": 23, - "species": "SEADRA" + "level": 40, + "species": "POLIWHIRL" } ], "ROUTE_24": [ { - "level": 15, - "species": "PSYDUCK" - }, - { - "level": 15, + "level": 20, "species": "GOLDEEN" }, { - "level": 15, - "species": "KRABBY" + "level": 25, + "species": "GOLDEEN" + }, + { + "level": 30, + "species": "GOLDEEN" + }, + { + "level": 30, + "species": "SEAKING" } ], "ROUTE_25": [ { - "level": 15, - "species": "PSYDUCK" - }, - { - "level": 15, - "species": "GOLDEEN" + "level": 10, + "species": "KRABBY" }, { "level": 15, "species": "KRABBY" + }, + { + "level": 15, + "species": "KINGLER" + }, + { + "level": 25, + "species": "KINGLER" } ], "ROUTE_4": [ { - "level": 15, - "species": "PSYDUCK" - }, - { - "level": 15, + "level": 20, "species": "GOLDEEN" }, { - "level": 15, - "species": "KRABBY" + "level": 25, + "species": "GOLDEEN" + }, + { + "level": 30, + "species": "GOLDEEN" + }, + { + "level": 30, + "species": "SEAKING" } ], "ROUTE_6": [ { - "level": 15, - "species": "KRABBY" + "level": 5, + "species": "GOLDEEN" + }, + { + "level": 10, + "species": "GOLDEEN" }, { "level": 15, - "species": "SHELLDER" + "species": "GOLDEEN" + }, + { + "level": 20, + "species": "GOLDEEN" } ], "SAFARI_ZONE_CENTER": [ { - "level": 15, + "level": 5, + "species": "MAGIKARP" + }, + { + "level": 10, + "species": "MAGIKARP" + }, + { + "level": 10, "species": "DRATINI" }, { "level": 15, - "species": "KRABBY" - }, - { - "level": 15, - "species": "PSYDUCK" - }, - { - "level": 15, - "species": "SLOWPOKE" + "species": "DRAGONAIR" } ], "SAFARI_ZONE_EAST": [ + { + "level": 5, + "species": "MAGIKARP" + }, + { + "level": 10, + "species": "MAGIKARP" + }, + { + "level": 15, + "species": "MAGIKARP" + }, { "level": 15, "species": "DRATINI" - }, - { - "level": 15, - "species": "KRABBY" - }, - { - "level": 15, - "species": "PSYDUCK" - }, - { - "level": 15, - "species": "SLOWPOKE" } ], "SAFARI_ZONE_NORTH": [ + { + "level": 5, + "species": "MAGIKARP" + }, + { + "level": 10, + "species": "MAGIKARP" + }, + { + "level": 15, + "species": "MAGIKARP" + }, { "level": 15, "species": "DRATINI" - }, - { - "level": 15, - "species": "KRABBY" - }, - { - "level": 15, - "species": "PSYDUCK" - }, - { - "level": 15, - "species": "SLOWPOKE" } ], "SAFARI_ZONE_WEST": [ + { + "level": 5, + "species": "MAGIKARP" + }, + { + "level": 10, + "species": "MAGIKARP" + }, + { + "level": 15, + "species": "MAGIKARP" + }, { "level": 15, "species": "DRATINI" - }, - { - "level": 15, - "species": "KRABBY" - }, - { - "level": 15, - "species": "PSYDUCK" - }, - { - "level": 15, - "species": "SLOWPOKE" } ], "SEAFOAM_ISLANDS_B3F": [ { - "level": 15, + "level": 25, + "species": "KRABBY" + }, + { + "level": 20, "species": "STARYU" }, { - "level": 15, - "species": "HORSEA" + "level": 35, + "species": "KINGLER" }, { - "level": 15, - "species": "SHELLDER" - }, - { - "level": 15, - "species": "GOLDEEN" + "level": 40, + "species": "STARYU" } ], "SEAFOAM_ISLANDS_B4F": [ { - "level": 15, + "level": 25, + "species": "KRABBY" + }, + { + "level": 20, "species": "STARYU" }, { - "level": 15, - "species": "HORSEA" + "level": 35, + "species": "KINGLER" }, { - "level": 15, - "species": "SHELLDER" - }, - { - "level": 15, - "species": "GOLDEEN" + "level": 40, + "species": "STARYU" } ], "VERMILION_CITY": [ { "level": 15, - "species": "KRABBY" + "species": "TENTACOOL" }, { - "level": 15, - "species": "SHELLDER" + "level": 20, + "species": "TENTACOOL" + }, + { + "level": 10, + "species": "TENTACOOL" + }, + { + "level": 5, + "species": "HORSEA" } ], "VERMILION_DOCK": [ { - "level": 15, - "species": "KRABBY" + "level": 10, + "species": "TENTACOOL" }, - { - "level": 15, - "species": "SHELLDER" - } - ], - "VIRIDIAN_CITY": [ { "level": 15, "species": "TENTACOOL" }, { "level": 15, + "species": "STARYU" + }, + { + "level": 10, + "species": "SHELLDER" + } + ], + "VIRIDIAN_CITY": [ + { + "level": 5, + "species": "POLIWAG" + }, + { + "level": 10, + "species": "POLIWAG" + }, + { + "level": 15, + "species": "POLIWAG" + }, + { + "level": 10, "species": "POLIWAG" } ]