yellow alpha

This commit is contained in:
bryanthaboi
2026-07-29 11:46:32 -04:00
parent d6e36d457f
commit dde25ec7d0
54 changed files with 55593 additions and 698 deletions
+33
View File
@@ -18,6 +18,19 @@ body:
validations:
required: true
- type: dropdown
id: game
attributes:
label: Which game were you playing
description: Pick every version you saw the bug in.
multiple: true
options:
- Red
- Blue
- Yellow
validations:
required: true
- type: dropdown
id: os
attributes:
@@ -31,6 +44,26 @@ body:
validations:
required: true
- type: dropdown
id: mods_enabled
attributes:
label: Were any mods on
description: Check the MODS tab in the launcher if you're not sure.
options:
- "No"
- "Yes"
validations:
required: true
- type: input
id: mods_which
attributes:
label: Which mods (if any were on)
description: List the enabled mods. Leave blank if none were on.
placeholder: nuzlocke 1.0.0, running-shoes 0.3
validations:
required: false
- type: input
id: version
attributes:
@@ -26,6 +26,20 @@ body:
validations:
required: true
- type: dropdown
id: game
attributes:
label: Which game is this about
description: Pick every version it applies to.
multiple: true
options:
- Red
- Blue
- Yellow
- Not version-specific
validations:
required: true
- type: input
id: discord
attributes:
+14
View File
@@ -23,6 +23,20 @@ body:
validations:
required: true
- type: dropdown
id: game
attributes:
label: Which game is this for
description: Pick every version the mod should cover.
multiple: true
options:
- Red
- Blue
- Yellow
- Not version-specific
validations:
required: true
- type: input
id: discord
attributes:
+79 -24
View File
@@ -1,33 +1,88 @@
-- Celadon Mansion 3F Game Designer (pokered/scripts/CeladonMansion3F.asm
-- CeladonMansion3FGameDesignerText): text_asm counts set bits in
-- wPokedexOwned and compares against NUM_POKEMON - 1 (discounts Mew).
-- If the player owns >= 150 species, shows the "completed" text
-- (originally followed by DisplayDiploma, which has no equivalent UI
-- in this port, so we just show the congratulatory text); otherwise
-- shows the normal encouragement text.
-- Celadon Mansion 3F, the GAME FREAK dev floor
-- (pokered+pokeyellow/scripts/CeladonMansion3F.asm). Every dev's
-- text_asm counts set bits in wPokedexOwned against NUM_POKEMON - 1
-- (150, discounting Mew). The game designer shows the diploma
-- (DisplayDiploma -> src/ui/Diploma.lua) on a completed dex; Yellow's
-- graphic artist then offers the printed copy (PrintDiploma, stood in
-- by src/core/Printer.lua's PNG export like the Pokédex PRNT item).
local function ownedCount(game)
local dex = game.save.pokedex
local owned = 0
if dex and dex.owned then
for _ in pairs(dex.owned) do owned = owned + 1 end
end
return owned
end
return {
CELADON_MANSION_3F = {
talk = {
TEXT_CELADONMANSION3F_GAME_DESIGNER = function(game, ow, npc, done)
local t = game.data.text
local dex = game.save.pokedex
local owned = 0
if dex and dex.owned then
for _ in pairs(dex.owned) do
owned = owned + 1
end
end
-- NUM_POKEMON - 1 = 150 (discounts Mew, per pokered)
local label, fallback
if owned >= 150 then
label, fallback = "_CeladonMansion3FGameDesignerCompletedDexText",
"Wow! Excellent!\nYou completed\nyour POKeDEX!\nCongratulations!"
else
label, fallback = "_CeladonMansion3FGameDesignerText",
"Is that right?\nI'm the game\ndesigner!\nFilling up your\nPOKeDEX is tough,\nbut don't quit!\nWhen you finish,\ncome tell me!"
end
local TextBox = require("src.render.TextBox")
game.stack:push(TextBox.new(game, t[label] or fallback, done))
if ownedCount(game) < 150 then
game.stack:push(TextBox.new(game,
t._CeladonMansion3FGameDesignerText
or "Is that right?\nI'm the game\ndesigner!\fFilling up your\nPOKéDEX is tough,\nbut don't quit!",
done))
return
end
game.stack:push(TextBox.new(game,
t._CeladonMansion3FGameDesignerCompletedDexText
or "Wow! Excellent!\nYou completed\nyour POKéDEX!\nCongratulations!",
function()
local Diploma = require("src.ui.Diploma")
game.stack:push(Diploma.new(game, function()
-- Yellow tags on the unlocked-printing line (CompletedDexText2)
local after = require("src.core.GameVersion").isYellow()
and (t._CeladonMansion3FGameDesignerCompletedDexText2
or "You can print out\nyour diploma with\nthe GAME BOY\nPrinter!")
or nil
if after then
game.stack:push(TextBox.new(game, after, done))
else
done()
end
end))
end))
end,
TEXT_CELADONMANSION3F_GRAPHIC_ARTIST = function(game, ow, npc, done)
local t = game.data.text
local TextBox = require("src.render.TextBox")
local yellow = require("src.core.GameVersion").isYellow()
if not (yellow and ownedCount(game) >= 150) then
game.stack:push(TextBox.new(game,
t._CeladonMansion3FGraphicArtistText
or "I'm the graphic\nartist!\nI drew you!", done))
return
end
-- _CeladonMansion3FGraphicArtistText2: offer to print the diploma
game.stack:push(TextBox.new(game,
t._CeladonMansion3FGraphicArtistText2
or "I'm the graphic\nartist!\fShould I print\nyour diploma?",
function()
local ChoiceBox = require("src.ui.ChoiceBox")
game.stack:push(ChoiceBox.new(game, function(yes)
if not yes then
game.stack:push(TextBox.new(game,
t._CeladonMansion3FGraphicArtistText3
or "Oh. But it's a\nspecial diploma!", done))
return
end
local Printer = require("src.core.Printer")
local Diploma = require("src.ui.Diploma")
local Strings = require("src.core.Strings")
local saved, err = Printer.save("diploma", 160, 144, function()
Diploma.render(game)
end)
-- the PRNT stand-in always reports where the PNG landed
game.stack:push(TextBox.new(game, saved
and Strings("There you go!\fSaved as\n%s\vin the save\nfolder.", saved)
or Strings("Printer error!\n%s", tostring(err)), done))
end))
end))
end,
},
},
+20 -1
View File
@@ -12,11 +12,18 @@
-- different files can each add NPCs to the same map), and mod
-- contributions from the map_scripts registry compose on top of it.
local GameVersion = require("src.core.GameVersion")
local MapScripts = require("src.script.MapScripts")
-- OaksLab is a full Yellow rewrite (one Eevee ball + forced Pikachu);
-- Red/Blue keep the three-starter choose flow.
local oaksLab = GameVersion.isYellow()
and "data.scripts.oaks_lab_yellow"
or "data.scripts.oaks_lab"
for _, mapEntry in ipairs({
{ "PALLET_TOWN", "data.scripts.pallet_town" },
{ "OAKS_LAB", "data.scripts.oaks_lab" },
{ "OAKS_LAB", oaksLab },
{ "REDS_HOUSE_1F", "data.scripts.reds_house" },
{ "CELADON_MANSION_ROOF_HOUSE", "data.scripts.celadon_eevee" },
}) do
@@ -36,6 +43,18 @@ for _, file in ipairs({ "data.scripts.story", "data.scripts.story2",
end
end
-- Yellow-only content on top of the shared tables (talk keys merge per
-- TEXT constant): the Kanto-starter gift quests and Jessie & James.
if GameVersion.isYellow() then
for _, file in ipairs({ "data.scripts.yellow_gifts",
"data.scripts.yellow_jessie_james",
"data.scripts.yellow_beach_house" }) do
for mapId, mod in pairs(require(file)) do
MapScripts.attachBase(mapId, mod)
end
end
end
local M = {}
function M.get(mapId)
+296
View File
@@ -0,0 +1,296 @@
-- Hand-ported from pret/pokeyellow scripts/OaksLab.asm.
-- Yellow: one Eevee ball on the table. Rival snatches it; Oak then
-- gives the player the wild Pikachu he caught earlier (STARTER_PIKACHU).
-- Object indices differ from Red (no Charmander/Squirtle/Bulbasaur balls):
-- 1 RIVAL (4,3), 2 EEVEE_POKE_BALL (7,3), 3 OAK1 (5,2),
-- 4-5 POKEDEX (2,1)/(3,1), 6 OAK2 (door), 7 GIRL, 8-9 SCIENTIST.
--
-- wRivalStarter rides in save.rivalStarter (RIVAL_STARTER_* 1 JOLTEON /
-- 2 FLAREON / 3 VAPOREON): JOLTEON baseline at the snatch
-- (OaksLabRivalTakesPokeballScript), FLAREON on a lab win / VAPOREON on
-- a lab loss (OaksLabRivalEndBattleScript), and Route 22's first battle
-- upgrades FLAREON back to JOLTEON (Route22Rival1AfterBattleScript).
local OAK1 = 3
local RIVAL = 1
return {
talk = {
TEXT_OAKSLAB_OAK1 = {
{ "face_player" },
-- Yellow's OaksLabOak1Text leads with the dex-rating branch: once
-- EVENT_PALLET_AFTER_GETTING_POKEBALLS is set (converted saves) or
-- 2+ species are owned, Oak asks how the Pokédex is coming and
-- rates it (predef DisplayDexRating)
{ "check_flag", "EVENT_PALLET_AFTER_GETTING_POKEBALLS" },
{ "jump_if_true", "dex_rating" },
{ "check_dex_owned", 2 },
{ "jump_if_true", "dex_rating" },
{ "check_item", "POKE_BALL" },
{ "jump_if_true", "come_see" },
{ "check_flag", "EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE" },
{ "jump_if_true", "give_balls" },
{ "check_flag", "EVENT_GOT_POKEDEX" },
{ "jump_if_true", "around_world" },
{ "check_flag", "EVENT_BATTLED_RIVAL_IN_OAKS_LAB" },
{ "jump_if_false", "pre_lab_battle" },
{ "check_item", "OAKS_PARCEL" },
{ "jump_if_false", "raise_young" },
-- .DeliverParcelText: parcel handover, then the Pokédex scene
-- (OaksLabRivalArrivesAtOaksRequestScript -> OakGivesPokedexScript)
{ "show_text", "_OaksLabOak1DeliverParcelText" },
{ "play_sound", "Get_Key_Item" },
{ "show_text", "_OaksLabOak1ParcelThanksText" },
{ "take_item", "OAKS_PARCEL", 1 },
{ "stop_music" },
{ "play_music", "Music_MeetRival" },
{ "show_text", "_OaksLabRivalGrampsText" },
{ "show_object", "OAKS_LAB", "OAKSLAB_RIVAL" },
{ "place_npc", RIVAL, 4, 7, "up" },
{ "move_npc_to", RIVAL, 4, 3 },
{ "play_music", "Music_OaksLab" },
{ "face_object", RIVAL, "up" },
{ "face_object", OAK1, "down" },
-- Yellow opens with the rival bragging, not Red's "what did you
-- call me for" (OaksLabOakGivesPokedexScript text order)
{ "show_text", "_OaksLabRivalMyPokemonHasGrownStrongerText" },
{ "face_object", RIVAL, "up" },
{ "face_object", OAK1, "down" },
{ "show_text", "_OaksLabOakIHaveARequestText" },
{ "face_object", RIVAL, "up" },
{ "face_object", OAK1, "down" },
{ "show_text", "_OaksLabOakMyInventionPokedexText" },
{ "show_text", "_OaksLabOakGotPokedexText" },
{ "play_sound", "Get_Key_Item" },
{ "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX1" },
{ "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX2" },
{ "face_object", RIVAL, "up" },
{ "face_object", OAK1, "down" },
{ "show_text", "_OaksLabOakThatWasMyDreamText" },
{ "face_object", RIVAL, "right" },
{ "show_text", "_OaksLabRivalLeaveItAllToMeText" },
{ "set_flag", "EVENT_GOT_POKEDEX" },
{ "set_flag", "EVENT_OAK_GOT_PARCEL" },
{ "hide_object", "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN_SLEEPY" },
{ "show_object", "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN" },
{ "stop_music" },
{ "play_music", "Music_MeetRival" },
{ "move_npc_to", RIVAL, 4, 7 },
{ "hide_object", "OAKS_LAB", "OAKSLAB_RIVAL" },
{ "play_music", "Music_OaksLab" },
{ "set_flag", "EVENT_1ST_ROUTE22_RIVAL_BATTLE" },
{ "clear_flag", "EVENT_2ND_ROUTE22_RIVAL_BATTLE" },
{ "set_flag", "EVENT_ROUTE22_RIVAL_WANTS_BATTLE" },
{ "show_object", "ROUTE_22", "ROUTE22_RIVAL1" },
{ "jump", "end" },
{ "label", "raise_young" },
-- Yellow: talk-to-it (starter Pikachu) instead of Red raise-young line.
{ "show_text", "_OaksLabOak1YouShouldTalkToIt" },
{ "jump", "end" },
{ "label", "pre_lab_battle" },
{ "check_flag", "EVENT_GOT_STARTER" },
{ "jump_if_true", "can_fight" },
{ "show_text", "_OaksLabOak1GoAheadItsYours" },
{ "jump", "end" },
{ "label", "can_fight" },
{ "show_text", "_OaksLabOak1YourPokemonCanFightText" },
{ "jump", "end" },
{ "label", "around_world" },
{ "show_text", "_OaksLabOak1PokemonAroundTheWorldText" },
{ "jump", "end" },
{ "label", "give_balls" },
{ "check_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" },
{ "jump_if_true", "come_see" },
{ "set_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" },
{ "give_item", "POKE_BALL", 5, false },
{ "show_text", "_OaksLabOak1ReceivedPokeballsText" },
{ "play_sound", "Get_Key_Item" },
{ "show_text", "_OaksLabGivePokeballsExplanationText" },
{ "jump", "end" },
{ "label", "come_see" },
{ "show_text", "_OaksLabOak1ComeSeeMeSometimesText" },
{ "jump", "end" },
{ "label", "dex_rating" },
{ "show_text", "_OaksLabOak1HowIsYourPokedexComingText" },
{ "dex_rating" },
},
-- OaksLabEeveePokeBallText / OaksLabRivalExclamationScript ->
-- OaksLabChoseStarterScript..OaksLabPlayerReceivesPikachuScript:
-- before Oak's choose speech the ball is just flavor; after it, the
-- rival "!"s, shoves the player off the table, snatches the ball,
-- then the player is walked over to Oak and handed Pikachu.
TEXT_OAKSLAB_EEVEE_POKE_BALL = function(game, ow, npc, done)
local flags = game.save.flags
if flags.EVENT_GOT_STARTER then
done()
return
end
if not flags.EVENT_OAK_ASKED_TO_CHOOSE_MON then
ow.runner:run({
{ "show_text", "_OaksLabThatsAPokeball" },
}, { onDone = done })
return
end
local px, py = ow.player.cellX, ow.player.cellY
local rows = {
-- OaksLabRivalExclamationScript: "!" over the rival
{ "emote", RIVAL, "shock" },
}
-- .RivalPushesPlayerAwayFromEeveeBall + the PAD_RIGHT x2 shove:
-- the rival cuts across to the ball WHILE the player standing
-- below it is bumped two tiles right (both movements run in the
-- same beat, so the walk overlaps the shove like the original)
if py == 4 then
rows[#rows + 1] = { "walk_npc", RIVAL,
{ "down", "right", "right", "right" }, { wait = false } }
rows[#rows + 1] = { "face_player_dir", "left" }
rows[#rows + 1] = { "move_player", "right", 2 }
-- let the rival finish the last stretch to (7,4)
rows[#rows + 1] = { "wait", 40 }
else
rows[#rows + 1] = { "move_npc_to", RIVAL, 7, 4 }
end
rows[#rows + 1] = { "face_object", RIVAL, "up" }
rows[#rows + 1] = { "hide_object", "OAKS_LAB", "OAKSLAB_EEVEE_POKE_BALL" }
-- rival starter baseline (RIVAL_STARTER_JOLTEON) at snatch time
rows[#rows + 1] = { "set_field", "rivalStarter", 1 }
rows[#rows + 1] = { "show_text", "_OaksLabRivalTakesText1" }
rows[#rows + 1] = { "play_sound", "Get_Key_Item" }
rows[#rows + 1] = { "show_text", "_OaksLabRivalTakesText2" }
rows[#rows + 1] = { "show_text", "_OaksLabRivalTakesText3" }
rows[#rows + 1] = { "show_text", "_OaksLabRivalTakesText4" }
rows[#rows + 1] = { "show_text", "_OaksLabRivalTakesText5" }
-- OaksLabRLE_PlayerWalksToOak: the simulated-joypad buffer plays
-- its RLE list BACKWARDS (StartSimulatingJoypadStates consumes
-- from wSimulatedJoypadStatesEnd), so the real order is LEFT 1,
-- DOWN 1, LEFT 3, UP 2 -- from the shove spot (9,4) the player
-- rounds the BOTTOM of the table to (5,3), directly below Oak
if py == 4 then
rows[#rows + 1] = { "walk_npc", "player",
{ "left", "down", "left", "left", "left", "up", "up" } }
else
rows[#rows + 1] = { "walk_npc", "player", { "left" } }
end
rows[#rows + 1] = { "face_player_dir", "up" }
rows[#rows + 1] = { "face_object", OAK1, "down" }
-- OaksLabPlayerReceivedMonText: no nickname prompt -- the starter
-- Pikachu keeps its species name
rows[#rows + 1] = { "show_text", "_OaksLabOakGivesText" }
rows[#rows + 1] = { "play_sound", "Get_Key_Item" }
rows[#rows + 1] = { "show_text", "_OaksLabReceivedText", { RAM = "PIKACHU" } }
rows[#rows + 1] = { "give_pokemon", "PIKACHU", 5, true }
rows[#rows + 1] = { "set_flag", "EVENT_GOT_STARTER" }
rows[#rows + 1] = { "set_flag", "EVENT_CHOSE_PIKACHU" }
ow.runner:run(rows, { npc = npc, onDone = done })
end,
TEXT_OAKSLAB_RIVAL = {
{ "face_player" },
{ "check_flag", "EVENT_GOT_STARTER" },
{ "jump_if_false", "pre_starter" },
{ "show_text", "_OaksLabRivalMyPokemonLooksStrongerText" },
{ "jump", "end" },
{ "label", "pre_starter" },
{ "check_flag", "EVENT_FOLLOWED_OAK_INTO_LAB_2" },
{ "jump_if_false", "gramps_gone" },
{ "show_text", "_OaksLabRivalIllGetABetterPokemonThanYou" },
{ "jump", "end" },
{ "label", "gramps_gone" },
{ "show_text", "_OaksLabRivalGrampsIsntAroundText" },
},
},
onEnter = function(game, ow)
if not (game.save.flags and game.save.flags.EVENT_GOT_POKEDEX) then
return
end
local Commands = require("src.script.Commands")
local ctx = { save = game.save, game = game, overworld = ow }
Commands.hide_object(ctx, "OAKS_LAB", "OAKSLAB_POKEDEX1")
Commands.hide_object(ctx, "OAKS_LAB", "OAKSLAB_POKEDEX2")
end,
onStep = function(game, ow, x, y)
local flags = game.save.flags
-- OaksLabPlayerDontGoAwayScript: y==6 without the starter walks the
-- player back up a tile
if flags.EVENT_FOLLOWED_OAK_INTO_LAB and not flags.EVENT_GOT_STARTER
and y >= 6 then
ow.runner:run({
{ "face_object", OAK1, "down" },
{ "face_object", RIVAL, "down" },
{ "show_text", "_OaksLabOakDontGoAwayYetText" },
{ "move_player", "up", 1 },
}, {})
return true
end
-- OaksLabRivalChallengesPlayerScript..OaksLabPikachuDislikesPokeballsScript:
-- heading for the door with Pikachu starts the rival battle, his
-- exit walk, then Pikachu pops out of its ball.
if flags.EVENT_GOT_STARTER and not flags.EVENT_BATTLED_RIVAL_IN_OAKS_LAB
and y >= 6 then
local rival = ow:npcByIndex(RIVAL)
if not rival then return false end
local rows = {
{ "face_player_dir", "up" },
{ "stop_music" },
{ "play_music", "Music_MeetRival" },
{ "show_text", "_OaksLabRivalIllTakeYouOnText" },
}
-- FindPathToPlayer with the Y distance decremented: the rival
-- stops one tile above the player
local target
for _, c in ipairs({ { x, y - 1 }, { x - 1, y }, { x + 1, y },
{ x, y + 1 } }) do
if ow.map:inBounds(c[1], c[2]) and ow.map:isWalkableCell(c[1], c[2]) then
target = c
break
end
end
if target then
table.insert(rows, { "move_npc_to", RIVAL, target[1], target[2] })
end
table.insert(rows, { "face_object", RIVAL,
target and target[2] < y and "down"
or target and target[2] > y and "up"
or target and target[1] < x and "right" or "left" })
table.insert(rows, { "start_battle", "trainer", "OPP_RIVAL1", 1 })
table.insert(rows, { "heal_party" })
table.insert(rows, { "set_flag", "EVENT_BATTLED_RIVAL_IN_OAKS_LAB" })
-- OaksLabRivalEndBattleScript: his Eevee's future evolution is
-- decided here -- FLAREON if the player won, VAPOREON otherwise
table.insert(rows, { "check_battle_result", "win" })
table.insert(rows, { "jump_if_false", "lost_lab" })
table.insert(rows, { "set_field", "rivalStarter", 2 })
table.insert(rows, { "jump", "exit" })
table.insert(rows, { "label", "lost_lab" })
table.insert(rows, { "set_field", "rivalStarter", 3 })
table.insert(rows, { "label", "exit" })
-- OaksLabRivalStartsExitScript: parting shot, walk out past the
-- player, restore the lab theme
table.insert(rows, { "wait", 20 })
table.insert(rows, { "show_text", "_OaksLabRivalSmellYouLaterText" })
table.insert(rows, { "move_npc_to", RIVAL, 4, 11 })
table.insert(rows, { "hide_object", "OAKS_LAB", "OAKSLAB_RIVAL" })
table.insert(rows, { "play_music", "Music_OaksLab" })
-- OaksLabPikachuEscapesPokeballScript: Pikachu hates its ball.
-- The overworld follower itself is still an open port
-- (docs/yellow-version.md runtime backlog); the story beat plays.
table.insert(rows, { "play_cry", "PIKACHU" })
table.insert(rows, { "show_text", "_OaksLabPikachuDislikesPokeballsText1" })
table.insert(rows, { "show_text", "_OaksLabPikachuDislikesPokeballsText2" })
ow.runner:run(rows, { npc = rival })
return true
end
return false
end,
}
+51 -5
View File
@@ -19,19 +19,60 @@ local FEE = 500
local BALLS = 30
local STEPS = 502
local function startGame(game, t, done)
game.save.money = game.save.money - FEE
game.save.safari = { balls = BALLS, steps = STEPS }
local function startGame(game, t, done, balls, introText)
game.save.safari = { balls = balls or BALLS, steps = STEPS }
game.save.safariNags = nil
local TextBox = require("src.render.TextBox")
local paid = (t._SafariZoneGateSafariZoneWorker1ThatllBe500PleaseText
local paid = introText
or (t._SafariZoneGateSafariZoneWorker1ThatllBe500PleaseText
or "That'll be ¥500\nplease!\f{PLAYER} received\n30 SAFARI BALLs!")
:gsub("{PLAYER}", game.save.player.name)
:gsub("{NUM:[^}]*}", "500")
paid = paid:gsub("{PLAYER}", game.save.player.name)
local pa = t._SafariZoneGateSafariZoneWorker1CallYouOnThePAText
or "\fWe'll call you on\nthe PA when you\nrun out of time\nor SAFARI BALLs!"
local luck = t._SafariZoneGateSafariZoneWorker1GoodLuckText or "Good Luck!"
game.stack:push(TextBox.new(game, paid .. pa .. "\f" .. luck, done))
end
-- Yellow's soft-lock fix (scripts/SafariZoneGate_2.asm): a player short of
-- the full fee still gets in.
-- 0 < money < 500: SafariZoneEntranceCalculateLowCostAdmission takes
-- everything and hands over min(money/23 + 1, 29) balls.
-- money == 0: SafariZoneEntranceGetLowCostAdmissionText nags four times
-- (LowCostText5/6/7/8), then relents -- free entry, one ball.
local function yellowLowCost(game, ow, t, done, back)
local TextBox = require("src.render.TextBox")
if game.save.money > 0 then
local balls = math.min(math.floor(game.save.money / 23) + 1, 29)
game.save.money = 0
local intro =
(t._SafariZoneGateSafariZoneWorker1NotEnoughMoneyText
or "Oops! Not enough\nmoney!")
.. (t._SafariZoneLowCostText1
or "\fOh, all right, pay\nme what you have.")
.. "\f" .. (t._SafariZoneLowCostText2
or "But, I can't give\nyou all 30 BALLs.")
startGame(game, t, done, balls, intro)
return
end
local nag = game.save.safariNags or 0
game.save.safariNags = nag + 1
if nag >= 3 then
local intro =
(t._SafariZoneLowCostText8 or "Read my lips, NO!\nGet it?")
.. (t._SafariZoneLowCostText3
or "\fYou're persistent,\naren't you?\fOK, you can go in\nfor free, but\njust this once!")
startGame(game, t, done, 1, intro)
return
end
local nags = {
t._SafariZoneLowCostText5 or "I'm sorry, but you\nhave to pay to\nenter.",
t._SafariZoneLowCostText6 or "You can't enter\nwithout paying!",
t._SafariZoneLowCostText7 or "I said, no money,\nno entry!",
}
back(nags[nag + 1])
end
local function joinPrompt(game, ow, done)
done = done or function() end
local TextBox = require("src.render.TextBox")
@@ -51,9 +92,14 @@ local function joinPrompt(game, ow, done)
back(t._SafariZoneGateSafariZoneWorker1PleaseComeAgainText
or "OK! Please come\nagain!")
elseif game.save.money < FEE then
if require("src.core.GameVersion").isYellow() then
yellowLowCost(game, ow, t, done, back)
else
back(t._SafariZoneGateSafariZoneWorker1NotEnoughMoneyText
or "Oops! Not enough\nmoney!")
end
else
game.save.money = game.save.money - FEE
startGame(game, t, done)
end
end))
+100 -86
View File
@@ -1,6 +1,10 @@
-- More hand-ported events: the Pallet Town intro, the thirsty Saffron
-- gate guards, the Bike Voucher chain, fossils and the day-care.
-- Registered via data/scripts/init.lua; each cites its pokered source.
-- Registered via data/scripts/init.lua; Red/Blue cite pokered, Yellow
-- cites pokeyellow (Pallet stop row, Oak spawn, walk RLE, and the
-- wild-Pikachu beat before the lab escort).
local GameVersion = require("src.core.GameVersion")
local M = {}
@@ -35,72 +39,79 @@ function escort.findPath(fromX, fromY, toX, toY)
return path
end
-- PalletTownOakWalksToPlayerScript: Oak appears at his object spot
-- (8,5) and zigzags to one tile below the player (hNPCPlayerYDistance
-- is pre-decremented before predef FindPathToPlayer).
-- Red: Oak object (8,5) -> one tile below the player at y=1.
-- Yellow: Oak object (10,4); stop fires at y=0 (pokeyellow PalletTown).
function escort.oakApproach(playerX)
if GameVersion.isYellow() then
return escort.findPath(10, 4, playerX, 1)
end
return escort.findPath(8, 5, playerX, 2)
end
-- RLEList_ProfOakWalkToLab (engine/overworld/auto_movement.asm):
-- DOWN x5, LEFT, DOWN x5, RIGHT x3, UP -- Oak's last step lands on the
-- lab door (12,11). (The trailing NPC_CHANGE_FACING is a march-in-
-- place beat on the mat; here Oak just stands his final beat.)
escort.oakSteps = {
-- RLEList_ProfOakWalkToLab (engine/overworld/auto_movement.asm).
-- Yellow differs: first DOWN is x6 (Oak starts one tile farther north).
local function buildOakSteps()
if GameVersion.isYellow() then
return {
"down", "down", "down", "down", "down", "down",
"left",
"down", "down", "down", "down", "down",
"right", "right", "right",
"up",
}
end
return {
"down", "down", "down", "down", "down",
"left",
"down", "down", "down", "down", "down",
"right", "right", "right",
"up",
}
end
-- RLEList_PlayerWalkToLab decodes to UP x2, RIGHT x3, DOWN x5, LEFT,
-- DOWN x6 and plays in REVERSE buffer order (wSimulatedJoypadStatesEnd
-- grows downward): DOWN x6, LEFT, DOWN x5, RIGHT x3, UP x2 -- Oak's
-- exact path one step behind. The 17th press (the second UP) is eaten
-- by the door-warp frame (WarpFound clears hJoyHeld via EnterMap), so
-- only 16 real steps happen; the walk ends on the door at (12,11).
escort.oakSteps = buildOakSteps()
-- Player stays one step behind Oak (simplified reverse-RLE port).
escort.playerSteps = { "down" }
for _, d in ipairs(escort.oakSteps) do
escort.playerSteps[#escort.playerSteps + 1] = d
end
local function npcNamed(ow, name)
for _, n in ipairs(ow.npcs or {}) do
if n.def and n.def.name == name then return n end
end
return nil
end
M.PALLET_TOWN = {
talk = require("data.scripts.pallet_town").talk,
escort = escort,
-- Oak stops you at the north row (PalletTownDefaultScript's
-- `wYCoord == 1` check), walks up from (8,5), and leads you to his
-- lab with the player one step behind (scripts/PalletTown.asm +
-- PalletMovementScriptPointerTable in
-- engine/overworld/auto_movement.asm), then the lab walk-in and the
-- choose-mon exchange (scripts/OaksLab.asm OaksLabDefaultScript ..
-- OaksLabOakChooseMonSpeechScript).
-- Red: stop at y==1 from (8,5). Yellow: stop at y==0 from (10,4),
-- then a wild Pikachu battle before the lab escort (pokeyellow
-- PalletTownPikachuBattleScript).
onStep = function(game, ow, x, y)
if y ~= 1 or game.save.flags.EVENT_FOLLOWED_OAK_INTO_LAB
local yellow = GameVersion.isYellow()
local stopY = yellow and 0 or 1
if y ~= stopY or game.save.flags.EVENT_FOLLOWED_OAK_INTO_LAB
or game.save.flags.EVENT_GOT_STARTER then
return false
end
local TextBox = require("src.render.TextBox")
local Commands = require("src.script.Commands")
local Music = require("src.core.Music")
local BattleState = require("src.battle.BattleState")
local t = game.data.text
local ctx = { save = game.save, game = game, overworld = ow }
-- PalletTownDefaultScript: stop the player, turn them around
-- (wPlayerMovingDirection = PLAYER_DIR_DOWN applies on the very
-- next frame, before the text box opens) and strike up the "oak
-- appears" theme (MUSIC_MEET_PROF_OAK)
ow.player.facing = "down"
-- Red turns the player down; Yellow faces up at the north exit.
ow.player.facing = yellow and "up" or "down"
Music.play(game.data, "Music_MeetProfOak")
-- DelayFrames-style hold: the world pauses (input stays locked)
-- for `frames` frames, then cb runs. Reuses the emote pause slot;
-- with an `npc` the "!" bubble draws above it (EmotionBubble).
local function hold(frames, npc, cb)
ow.emote = { frames = frames, npc = npc, onDone = cb }
end
-- chain single-tile scriptMoves through a direction list
local function walkList(entity, steps, done)
local i = 0
local function nextStep()
@@ -114,10 +125,8 @@ M.PALLET_TOWN = {
nextStep()
end
-- ---- Oak's Lab side (scripts/OaksLab.asm) ----------------------
-- OaksLabOakChooseMonSpeechScript: the fed-up / choose-mon /
-- what-about-me / be-patient exchange, Delay3 between boxes
-- OaksLabOakChooseMonSpeechScript. Yellow's OakChooseMon text is
-- the single-ball speech, not Red's "there are 3 POKéMON".
local function chooseMonSpeech()
local function say(key, fb, next)
game.stack:push(TextBox.new(game, t[key] or fb, next))
@@ -126,7 +135,9 @@ M.PALLET_TOWN = {
"{RIVAL}: Gramps!\nI'm fed up with\nwaiting!", function()
hold(3, nil, function()
say("_OaksLabOakChooseMonText",
"OAK: Here, {PLAYER}!\fThere are 3\nPOKéMON here!\fYou can have one!\nChoose!", function()
yellow
and "OAK: Look, {PLAYER}! Do\nyou see that ball\non the table?"
or "OAK: Here, {PLAYER}!\fThere are 3\nPOKéMON here!\fYou can have one!\nChoose!", function()
hold(3, nil, function()
say("_OaksLabRivalWhatAboutMeText",
"{RIVAL}: Hey!\nGramps! What\nabout me?", function()
@@ -143,30 +154,18 @@ M.PALLET_TOWN = {
end)
end
-- entering the lab: the door Oak (OAKSLAB_OAK2, (5,10)) walks up 3
-- ahead of the player (OaksLabOakEntersLabScript OakEntryMovement),
-- swaps for the desk Oak (OAKSLAB_OAK1, (5,2)), then the player
-- walks up 8 from the mat (PlayerEntryMovementRLE) while the rival
-- and Oak turn with them (OaksLabPlayerEntersLabScript /
-- OaksLabFollowedOakScript)
-- Door Oak is OAKSLAB_OAK2: Red index 8, Yellow index 6 (one ball).
local function labWalkIn()
local oak2 = ow:npcByIndex(8)
local oak2 = npcNamed(ow, "OAKSLAB_OAK2") or ow:npcByIndex(yellow and 6 or 8)
local function swapOaks()
Commands.hide_object(ctx, "OAKS_LAB", "OAKSLAB_OAK2")
Commands.show_object(ctx, "OAKS_LAB", "OAKSLAB_OAK1")
hold(3, nil, function() -- Delay3
Commands.face_object(ctx, 1, "down") -- rival watches you pass
hold(3, nil, function()
Commands.face_object(ctx, 1, "down")
ow:scriptMove(ow.player, "up", 8, function()
-- OaksLabFollowedOakScript: flags only after the walk-in, so a
-- stray step on the door mat can't fire the "don't go away"
-- push-up (oaks_lab.lua onStep) mid-cutscene. Outdoor escort
-- still re-arms on F1 mid-escort -- these flags stay clear
-- until the lab walk finishes.
game.save.flags.EVENT_FOLLOWED_OAK_INTO_LAB = true
game.save.flags.EVENT_FOLLOWED_OAK_INTO_LAB_2 = true
Commands.face_object(ctx, 1, "up")
-- res BIT_NO_MAP_MUSIC + PlayDefaultMusic: the lab theme
-- only starts once the walk-in is done
Music.playMap(game.data, "OAKS_LAB")
chooseMonSpeech()
end)
@@ -179,9 +178,6 @@ M.PALLET_TOWN = {
end
end
-- PalletMovementScript_Done + the door warp: Oak is hidden as the
-- player steps into the doorway; PALLET_TOWN warp 3 -> OAKS_LAB
-- warp 2 = (5,11), with the door SFX (WarpFound -> SFX_GO_INSIDE)
local function enterLab()
Commands.hide_object(ctx, "PALLET_TOWN", "PALLETTOWN_OAK")
Commands.show_object(ctx, "OAKS_LAB", "OAKSLAB_OAK2")
@@ -190,10 +186,6 @@ M.PALLET_TOWN = {
{ keepMusic = true })
end
-- PalletMovementScript_WalkToLab: Oak's NPC movement and the
-- player's simulated joypad run simultaneously, in lockstep; the
-- player retraces Oak's path one step behind and follows him into
-- the doorway on the final beat
local function walkToLab(oak)
local i = 0
local function tick()
@@ -206,9 +198,6 @@ M.PALLET_TOWN = {
if oak and escort.oakSteps[i] then
ow:scriptMove(oak, escort.oakSteps[i], 1)
elseif oak then
-- RLEList_ProfOakWalkToLab's trailing NPC_CHANGE_FACING beat:
-- Oak marches in place on the door mat while the player takes
-- the final step up behind him (movement.asm ChangeFacingDirection)
ow:marchInPlace(oak)
end
ow:scriptMove(ow.player, playerStep, 1, tick)
@@ -216,9 +205,6 @@ M.PALLET_TOWN = {
tick()
end
-- PalletMovementScript_OakMoveLeft/_PlayerMoveLeft: from the right
-- tile (x == 11) Oak sidesteps left first, then the player follows
-- left (wNumStepsToTake = wXCoord - 10), and only then both walk
local function escortToLab(oak)
local numSteps = x - 10
if oak and numSteps > 0 then
@@ -232,37 +218,65 @@ M.PALLET_TOWN = {
end
end
-- ---- Pallet Town side (scripts/PalletTown.asm) -----------------
-- PalletTownOakWalksToPlayerScript: Oak appears at (8,5), faces up
-- (SetSpriteFacingDirectionAndDelay + Delay3), then zigzags to the
-- player; the "It's unsafe!" text follows and the escort begins
local function oakAppearsAndWalks()
Commands.show_object(ctx, "PALLET_TOWN", "PALLETTOWN_OAK")
local oak = ow:npcByIndex(1)
if oak then oak.facing = "up" end
hold(6, nil, function()
walkList(oak, escort.oakApproach(x), function()
-- PalletTownOakNotSafeComeWithMeScript: the second text waits
-- for a button, then the escort starts
-- After Oak reaches the player: Red goes straight to "It's unsafe!".
-- Yellow (PalletTownOakGreetsPlayerScript..AfterPikachuBattleScript):
-- ThatWasClose -> Oak faces the grass patch -> BATTLE_TYPE_PIKACHU
-- (the old-man-style simulated battle where PROF.OAK throws the ball
-- and always catches the lv5 Pikachu) -> Whew -> ComeWithMe.
local function afterOakArrives(oak)
if not yellow then
game.stack:push(TextBox.new(game,
t._PalletTownOakItsUnsafeText
or "OAK: It's unsafe!\nWild POKéMON\nlive in tall grass!",
function() escortToLab(oak) end))
return
end
local function comeWithMe()
game.stack:push(TextBox.new(game,
t._PalletTownOakComeWithMe
or "OAK: Here, come with\nme!",
function() escortToLab(oak) end))
end
local function afterPikaBattle()
if oak then oak.facing = "up" end
game.stack:push(TextBox.new(game,
t._PalletTownOakWhewText or "OAK: Whew...",
comeWithMe))
end
game.stack:push(TextBox.new(game,
t._PalletTownOakThatWasCloseText
or "OAK: That was\nclose!\fWild POKéMON live\nin tall grass!",
function()
-- Oak turns toward the horizontally adjacent grass (left exit
-- looks right, right exit looks left -- the
-- EVENT_PLAYER_AT_RIGHT_EXIT_TO_PALLET_TOWN branch)
if oak then oak.facing = x == 10 and "right" or "left" end
local battle = BattleState.newWild(game, "PIKACHU", 5)
battle:makeOldManDemo("PROF.OAK")
battle.onFinish = function()
afterPikaBattle()
end
game.stack:push(battle)
end))
end
local function oakAppearsAndWalks()
Commands.show_object(ctx, "PALLET_TOWN", "PALLETTOWN_OAK")
local oak = npcNamed(ow, "PALLETTOWN_OAK") or ow:npcByIndex(1)
if oak then oak.facing = "up" end
hold(6, nil, function()
walkList(oak, escort.oakApproach(x), function()
afterOakArrives(oak)
end)
end)
end
-- The "Hey! Wait!" box ends without a button wait (auto), then the
-- "!" bubble shows over the player WHILE the box is still on screen
-- (PalletTownOakText: DelayFrames 10 then EmotionBubble, box not yet
-- cleared). onOverlap sets the bubble during the box's last frames;
-- the box pops after `overlap`, and the bubble's 60-frame hold then
-- runs to Oak's appearance (the bubble is static while the box is up,
-- since the overworld pauses under it, so 10 overlap + 50 = 60).
game.stack:push(TextBox.new(game,
t._PalletTownOakHeyWaitDontGoOutText or "OAK: Hey! Wait!\nDon't go out!",
nil, { auto = { delay = 10, overlap = 10, onOverlap = function()
-- .HeyWaitDontGoOutText turns the player to face down (toward
-- the approaching Oak) before the exclamation bubble
ow.player.facing = "down"
ow.emote = { npc = ow.player, frames = 50, onDone = oakAppearsAndWalks }
end } }))
return true
+39
View File
@@ -173,7 +173,46 @@ local function syncGymGatesAfterBattle(game, ow)
applyGymGates(game, ow)
end
-- Yellow's quiz-first rule (scripts/CinnabarGym.asm SuperNerd2..7): a
-- gate guardian refuses to battle until his quiz was attempted -- a
-- wrong answer sics him on you (the onInteract path below), a right one
-- opens his gate; walking up and talking first just gets the room's
-- CinnabarGymText_N lecture (Func_f2150's pointer table).
local function yellowQuizTalk(machineIndex)
return function(game, ow, npc, done)
local yellow = require("src.core.GameVersion").isYellow()
local defeated = ow:trainerDefeated(npc)
local gateOpen = game.save.flags[gymGateFlag(machineIndex)]
if yellow and not defeated and not gateOpen then
local TextBox = require("src.render.TextBox")
local t = game.data.text
game.stack:push(TextBox.new(game,
t["_CinnabarGymText_" .. machineIndex]
or "You have to take\nthe quiz first!", done))
return
end
-- gate open (or Red/Blue): the ordinary trainer engagement /
-- after-battle line, mirroring talkTo's generic trainer branch
npc:facePlayer(ow.player)
if not defeated then
ow:engageTrainer(npc, done)
return
end
local header = game.data:trainerHeader(ow.map.def.label, npc.def.index)
local after = header and header.after and game.data.text[header.after]
local TextBox = require("src.render.TextBox")
game.stack:push(TextBox.new(game, after or "...", done))
end
end
M.CINNABAR_GYM = {
talk = (function()
local talk = {}
for i = 1, 6 do
talk["TEXT_CINNABARGYM_SUPER_NERD" .. (i + 1)] = yellowQuizTalk(i)
end
return talk
end)(),
onEnter = applyGymGates,
onVictory = syncGymGatesAfterBattle,
onInteract = function(game, ow, fx, fy)
+131
View File
@@ -0,0 +1,131 @@
-- Summer Beach House (scripts/SummerBeachHouse.asm), Yellow's Route 19
-- surf shack. The Surfin' Dude only lets a party Pikachu that knows
-- SURF ride (IsSurfingPikachuInParty, home/map_objects.asm); saying yes
-- runs the Surfing Pikachu minigame (src/ui/SurfingMinigame.lua). The
-- corner printer shows/prints the high score once you have surfed this
-- visit (BIT_PIKACHU_MAP_SURF_SELECT is a per-map-load flag, so the
-- session markers live on the overworld state, not the save).
local function surfingPikachu(game)
for _, mon in ipairs(game.save.party or {}) do
if mon.species == "PIKACHU" then
for _, mv in ipairs(mon.moves or {}) do
if mv.id == "SURF" then return mon end
end
end
end
return nil
end
local function push(game, text, done)
local TextBox = require("src.render.TextBox")
game.stack:push(TextBox.new(game, text, done))
end
-- the two-variant posters: the surf-capable line once a surfing
-- Pikachu is along, the plain one otherwise
local function poster(n)
return function(game, ow, npc, done)
local t = game.data.text
local key = ("_SummerBeachHousePoster%dText%d"):format(
n, surfingPikachu(game) and 1 or 2)
push(game, t[key] or "A surfing poster.", done)
end
end
return {
SUMMER_BEACH_HOUSE = {
talk = {
TEXT_SUMMERBEACHHOUSE_SURFINDUDE = function(game, ow, npc, done)
local t = game.data.text
if not surfingPikachu(game) then
push(game, t._SummerBeachHouseSurfinDudeText4
or "Dogs and burgers\non special today!", done)
return
end
-- Text1 on the first ask each visit, the short Text3 after
local ask = ow.surfinDudeAsked
and (t._SummerBeachHouseSurfinDudeText3 or "Wanna go SURF?")
or (t._SummerBeachHouseSurfinDudeText1
or "Whoa!\nYour PIKACHU knows\nhow to SURF!\fGive it a go?")
ow.surfinDudeAsked = true
push(game, ask, function()
local ChoiceBox = require("src.ui.ChoiceBox")
game.stack:push(ChoiceBox.new(game, function(yes)
if not yes then
push(game, t._SummerBeachHouseSurfinDudeText2
or "Come SURF anytime,\nmy friend!", done)
return
end
local SurfingMinigame = require("src.ui.SurfingMinigame")
game.stack:push(SurfingMinigame.new(game, function()
ow.surfedThisVisit = true -- BIT_PIKACHU_MAP_SURF_SELECT
require("src.core.Music").playMap(game.data, ow.map.id)
done()
end))
end))
end)
end,
TEXT_SUMMERBEACHHOUSE_PIKACHU = function(game, ow, npc, done)
local t = game.data.text
push(game, t._SummerBeachHousePikachuText or "PIKACHU: Pikaa!",
function()
require("src.core.Sound").playCry(game.data, "PIKACHU")
done()
end)
end,
TEXT_SUMMERBEACHHOUSE_POSTER1 = poster(1),
TEXT_SUMMERBEACHHOUSE_POSTER2 = poster(2),
TEXT_SUMMERBEACHHOUSE_POSTER3 = poster(3),
TEXT_SUMMERBEACHHOUSE_PRINTER = function(game, ow, npc, done)
local t = game.data.text
if not surfingPikachu(game) then
push(game, t._SummerBeachHousePrinterText1
or "It's some sort of\na machine...", done)
return
end
push(game, t._SummerBeachHousePrinterText2
or "SUMMER BEACH HOUSE\nPRINTER, it says.", function()
if not ow.surfedThisVisit then
done()
return
end
push(game, t._SummerBeachHousePrinterText3
or "The Hi-Score is\nshown.\fPRINT it out?", function()
local ChoiceBox = require("src.ui.ChoiceBox")
game.stack:push(ChoiceBox.new(game, function(yes)
if not yes then
done()
return
end
-- PrintSurfingMinigameHighScore -> PNG stand-in
local Printer = require("src.core.Printer")
local Font = require("src.render.Font")
local Strings = require("src.core.Strings")
local hi = game.save.surfingHighScore or 0
local name = game.save.player.name or "RED"
local saved, err = Printer.save("surf_hiscore", 160, 64,
function()
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 0, 0, 160, 64)
love.graphics.setColor(0, 0, 0, 1)
love.graphics.rectangle("line", 2.5, 2.5, 155, 59)
Font.draw(Strings("SUMMER BEACH HOUSE"), 8, 10)
Font.draw(Strings("SURFING Hi-Score"), 8, 24)
Font.draw(name, 8, 40)
Font.draw(Strings("%d pts", hi), 96, 40)
end)
push(game, saved
and Strings("Printed!\fSaved as\n%s\vin the save\nfolder.",
saved)
or Strings("Printer error!\n%s", tostring(err)), done)
end))
end)
end)
end,
},
},
}
+102
View File
@@ -0,0 +1,102 @@
-- Yellow's three Kanto-starter side-quests, hand-ported from
-- scripts/CeruleanMelaniesHouse.asm, scripts/Route24.asm
-- (Route24CooltrainerM4Text) and scripts/VermilionCity_2.asm
-- (VermilionCityPrintOfficerJennyText). Registered on top of the shared
-- Red map scripts by data/scripts/init.lua on a Yellow boot only, so the
-- table keys compose with story.lua / story4.lua's existing entries.
local M = {}
-- Melanie hands over her Bulbasaur once the starter Pikachu trusts you
-- (wPikachuHappiness >= 147).
M.CERULEAN_MELANIES_HOUSE = {
talk = {
TEXT_CERULEANMELANIESHOUSE_MELANIE = function(game, ow, npc, done)
local rows = { { "face_player" } }
if game.save.flags.EVENT_GOT_BULBASAUR_IN_CERULEAN then
rows[#rows + 1] = { "show_text", "MelanieText4" }
elseif (game.save.pikachuHappiness or 90) < 147 then
rows[#rows + 1] = { "show_text", "MelanieText1" }
else
rows[#rows + 1] = { "show_text", "MelanieText1" }
rows[#rows + 1] = { "ask", "MelanieText2" }
rows[#rows + 1] = { "jump_if_false", "declined" }
rows[#rows + 1] = { "give_pokemon", "BULBASAUR", 10 }
rows[#rows + 1] = { "jump_if_false", "end" } -- party + box full
rows[#rows + 1] = { "show_text", "MelanieText3" }
rows[#rows + 1] = { "hide_object", "CERULEAN_MELANIES_HOUSE",
"CERULEANMELANIESHOUSE_BULBASAUR" }
rows[#rows + 1] = { "set_flag", "EVENT_GOT_BULBASAUR_IN_CERULEAN" }
rows[#rows + 1] = { "jump", "end" }
rows[#rows + 1] = { "label", "declined" }
rows[#rows + 1] = { "show_text", "MelanieText5" }
end
ow.runner:run(rows, { npc = npc, onDone = done })
end,
-- pet flavor: the text with the species' cry over it
TEXT_CERULEANMELANIESHOUSE_BULBASAUR = {
{ "play_cry", "BULBASAUR" },
{ "show_text", "MelanieBulbasaurText" },
},
TEXT_CERULEANMELANIESHOUSE_ODDISH = {
{ "play_cry", "ODDISH" },
{ "show_text", "MelanieOddishText" },
},
TEXT_CERULEANMELANIESHOUSE_SANDSHREW = {
{ "play_cry", "SANDSHREW" },
{ "show_text", "MelanieSandshrewText" },
},
},
}
-- Damian gives away the Charmander he thinks is too weak (EVENT_54F).
M.ROUTE_24 = {
talk = {
TEXT_ROUTE24_COOLTRAINER_M4 = {
{ "face_player" },
{ "check_flag", "EVENT_54F" },
{ "jump_if_true", "after" },
{ "ask", "_Route24DamianText1" },
{ "jump_if_false", "declined" },
{ "give_pokemon", "CHARMANDER", 10 },
{ "jump_if_false", "end" },
{ "show_text", "_Route24DamianText2" },
{ "set_flag", "EVENT_54F" },
{ "jump", "end" },
{ "label", "declined" },
{ "show_text", "_Route24DamianText3" },
{ "jump", "end" },
{ "label", "after" },
{ "show_text", "_Route24DamianText4" },
},
},
}
-- Officer Jenny's Squirtle: kept until you carry the Thunder Badge.
M.VERMILION_CITY = {
talk = {
TEXT_VERMILIONCITY_OFFICER_JENNY = function(game, ow, npc, done)
local rows = { { "face_player" } }
if game.save.flags.EVENT_GOT_SQUIRTLE_FROM_OFFICER_JENNY then
rows[#rows + 1] = { "show_text", "_OfficerJennyText5" }
elseif not (game.save.inventory
and game.save.inventory.THUNDERBADGE) then
rows[#rows + 1] = { "show_text", "_OfficerJennyText1" }
else
rows[#rows + 1] = { "ask", "_OfficerJennyText2" }
rows[#rows + 1] = { "jump_if_false", "declined" }
rows[#rows + 1] = { "give_pokemon", "SQUIRTLE", 10 }
rows[#rows + 1] = { "jump_if_false", "end" }
rows[#rows + 1] = { "show_text", "_OfficerJennyText3" }
rows[#rows + 1] = { "set_flag",
"EVENT_GOT_SQUIRTLE_FROM_OFFICER_JENNY" }
rows[#rows + 1] = { "jump", "end" }
rows[#rows + 1] = { "label", "declined" }
rows[#rows + 1] = { "show_text", "_OfficerJennyText4" }
end
ow.runner:run(rows, { npc = npc, onDone = done })
end,
},
}
return M
+261
View File
@@ -0,0 +1,261 @@
-- Jessie & James, Yellow's Team Rocket duo, at all four ambush sites:
-- Mt Moon B2F (scripts/MtMoonB2F.asm), Rocket Hideout B4F
-- (scripts/RocketHideoutB4F.asm), Pokemon Tower 7F
-- (scripts/PokemonTower7F.asm) and Silph Co 11F (scripts/SilphCo11F.asm).
-- Every site shares one shape: a coordinate trigger swaps the map theme
-- for Music_MeetJessieJames, the motto plays, the duo closes in, one
-- battle against the shared OPP_ROCKET party fights them both, and after
-- their parting lines they vanish together under a second sting of the
-- theme before the map theme resumes (PlayDefaultMusic ->
-- play_default_music).
--
-- Registered on top of the shared tables by data/scripts/init.lua on a
-- Yellow boot; MT_MOON_B2F's onStep chains story2's Super Nerd / fossil
-- trigger and SILPH_CO_11F's chains story's Giovanni trigger, since
-- non-talk hooks replace rather than merge.
local M = {}
-- Capture the FUNCTION, not the table: attachBase stores the module
-- table itself, so once this file's onStep is attached the table's slot
-- points back here -- delegating through the table would self-recurse.
local baseMtMoonStep = require("data.scripts.story2").MT_MOON_B2F.onStep
local baseSilph11Step = require("data.scripts.story").SILPH_CO_11F.onStep
M.MT_MOON_B2F = {
talk = {
TEXT_MTMOONB2F_JESSIE = {
{ "face_player" }, { "show_text", "_MtMoonJessieJamesText1" },
},
TEXT_MTMOONB2F_JAMES = {
{ "face_player" }, { "show_text", "_MtMoonJessieJamesText1" },
},
},
onStep = function(game, ow, x, y)
if baseMtMoonStep and baseMtMoonStep(game, ow, x, y) then
return true
end
local f = game.save.flags
if x ~= 3 or y ~= 5 then return false end
if f.EVENT_BEAT_MT_MOON_3_JESSIE_JAMES then return false end
if not (f.EVENT_GOT_DOME_FOSSIL or f.EVENT_GOT_HELIX_FOSSIL) then
return false
end
ow.runner:run({
{ "stop_music" },
{ "play_music", "Music_MeetJessieJames" },
{ "show_object", "MT_MOON_B2F", "MTMOONB2F_JESSIE" },
{ "show_object", "MT_MOON_B2F", "MTMOONB2F_JAMES" },
{ "show_text", "_MtMoonJessieJamesText1" },
{ "face_player_dir", "right" },
{ "show_text", "_MtMoonJessieJamesText2" },
{ "start_battle", "trainer", "OPP_ROCKET", 42 },
{ "check_battle_result", "win" },
{ "jump_if_false", "end" },
{ "show_text", "_MtMoonJessieJamesText3" },
{ "show_text", "_MtMoonJessieJamesText4" },
{ "stop_music" },
{ "play_music", "Music_MeetJessieJames" },
{ "hide_object", "MT_MOON_B2F", "MTMOONB2F_JESSIE" },
{ "hide_object", "MT_MOON_B2F", "MTMOONB2F_JAMES" },
{ "play_default_music" },
{ "set_flag", "EVENT_BEAT_MT_MOON_3_JESSIE_JAMES" },
}, {})
return true
end,
}
-- -------------------------------------------------------------------
-- Rocket Hideout B4F (RocketHideoutB4FScript_455a5..Script13): the
-- motto plays from off-screen FIRST, then the duo pops in at (25,10) /
-- (24,10) and whichever of them shares the player's column ($18=24 or
-- $19=25, EVENT_ROCKET_HIDEOUT_4_JESSIE_JAMES_ON_LEFT) walks the three
-- tiles down to loom over the player while the other steps one. A loss
-- re-hides them (RocketHideoutB4FResetScripts via EVENT_6A0), so the
-- trigger re-arms clean.
-- -------------------------------------------------------------------
M.ROCKET_HIDEOUT_B4F = {
talk = {
TEXT_ROCKETHIDEOUTB4F_JESSIE = {
{ "face_player" }, { "show_text", "_RocketHideoutJessieJamesText1" },
},
TEXT_ROCKETHIDEOUTB4F_JAMES = {
{ "face_player" }, { "show_text", "_RocketHideoutJessieJamesText1" },
},
},
onStep = function(game, ow, x, y)
local f = game.save.flags
if y ~= 14 or (x ~= 24 and x ~= 25) then return false end
if f.EVENT_BEAT_ROCKET_HIDEOUT_4_JESSIE_JAMES then return false end
-- ON_LEFT: player under James's column (25); movement data pairs
-- RocketHideoutB4FJessieJamesMovementData_45605/45606 swap so the
-- column-mate walks 3, the other 1.
local onLeft = (x == 25)
ow.runner:run({
{ "stop_music" },
{ "play_music", "Music_MeetJessieJames" },
{ "show_text", "_RocketHideoutJessieJamesText1" },
{ "face_player_dir", "up" },
{ "emote", "player", "shock", 30 },
{ "show_object", "ROCKET_HIDEOUT_B4F", "ROCKETHIDEOUTB4F_JAMES" },
{ "show_object", "ROCKET_HIDEOUT_B4F", "ROCKETHIDEOUTB4F_JESSIE" },
-- James (object 2) then Jessie (object 3), Script4..Script9 order
{ "walk_npc", 2, onLeft and { "down", "down", "down" } or { "down" } },
{ "face_object", 2, onLeft and "down" or "left" },
{ "walk_npc", 3, onLeft and { "down" } or { "down", "down", "down" } },
{ "face_object", 3, onLeft and "right" or "down" },
{ "show_text", "_RocketHideoutJessieJamesText2" },
{ "start_battle", "trainer", "OPP_ROCKET", 43 },
{ "check_battle_result", "win" },
{ "jump_if_false", "lost" },
{ "show_text", "_RocketHideoutJessieJamesText3" },
{ "show_text", "_RocketHideoutJessieJamesText4" },
{ "stop_music" },
{ "play_music", "Music_MeetJessieJames" },
{ "fade", "out" },
{ "hide_object", "ROCKET_HIDEOUT_B4F", "ROCKETHIDEOUTB4F_JAMES" },
{ "hide_object", "ROCKET_HIDEOUT_B4F", "ROCKETHIDEOUTB4F_JESSIE" },
{ "fade", "in" },
{ "play_default_music" },
{ "set_flag", "EVENT_BEAT_ROCKET_HIDEOUT_4_JESSIE_JAMES" },
{ "jump", "end" },
{ "label", "lost" },
{ "hide_object", "ROCKET_HIDEOUT_B4F", "ROCKETHIDEOUTB4F_JAMES" },
{ "hide_object", "ROCKET_HIDEOUT_B4F", "ROCKETHIDEOUTB4F_JESSIE" },
}, {})
return true
end,
}
-- -------------------------------------------------------------------
-- Pokemon Tower 7F (PokemonTower7FScript_60d2a..Script10): same beat
-- one floor below Fuji, except the duo pops in BEFORE the motto and
-- Jessie ($a=10 column) leads the walk-down; ON_LEFT ($b=11) hands the
-- three-tile walk to James instead. On a loss vanilla only resets the
-- script counter (the blackout warp reloads the map anyway).
-- -------------------------------------------------------------------
M.POKEMON_TOWER_7F = {
talk = {
TEXT_POKEMONTOWER7F_JESSIE = {
{ "face_player" }, { "show_text", "_PokemonTowerJessieJamesText1" },
},
TEXT_POKEMONTOWER7F_JAMES = {
{ "face_player" }, { "show_text", "_PokemonTowerJessieJamesText1" },
},
},
onStep = function(game, ow, x, y)
local f = game.save.flags
if y ~= 12 or (x ~= 10 and x ~= 11) then return false end
if f.EVENT_BEAT_POKEMONTOWER_7_JESSIE_JAMES then return false end
local onLeft = (x == 11) -- EVENT_POKEMONTOWER_7_JESSIE_JAMES_ON_LEFT
ow.runner:run({
{ "stop_music" },
{ "play_music", "Music_MeetJessieJames" },
{ "show_object", "POKEMON_TOWER_7F", "POKEMONTOWER7F_JESSIE" },
{ "show_object", "POKEMON_TOWER_7F", "POKEMONTOWER7F_JAMES" },
{ "show_text", "_PokemonTowerJessieJamesText1" },
{ "face_player_dir", "up" },
{ "emote", "player", "shock", 30 },
-- Jessie (object 1) then James (object 2), Script1..Script6 order
{ "walk_npc", 1, onLeft and { "down" } or { "down", "down", "down" } },
{ "face_object", 1, onLeft and "right" or "down" },
{ "walk_npc", 2, onLeft and { "down", "down", "down" } or { "down" } },
{ "face_object", 2, onLeft and "down" or "left" },
{ "show_text", "_PokemonTowerJessieJamesText2" },
{ "start_battle", "trainer", "OPP_ROCKET", 44 },
{ "check_battle_result", "win" },
{ "jump_if_false", "end" },
{ "show_text", "_PokemonTowerJessieJamesText3" },
{ "show_text", "_PokemonTowerJessieJamesText4" },
{ "stop_music" },
{ "play_music", "Music_MeetJessieJames" },
{ "fade", "out" },
{ "hide_object", "POKEMON_TOWER_7F", "POKEMONTOWER7F_JESSIE" },
{ "hide_object", "POKEMON_TOWER_7F", "POKEMONTOWER7F_JAMES" },
{ "fade", "in" },
{ "play_default_music" },
{ "set_flag", "EVENT_BEAT_POKEMONTOWER_7_JESSIE_JAMES" },
}, {})
return true
end,
}
-- -------------------------------------------------------------------
-- Silph Co 11F (SilphCo11FScript_6229c..Script14): the only site where
-- the duo starts VISIBLE (toggleable_objects.asm keeps SILPHCO11F_JAMES
-- / _JESSIE ON), flanking Giovanni at (2,8)/(3,8), so they are talkable
-- before the ambush (SilphCo11FJessieJamesText = the full motto). The
-- trigger is the top row (y=3, x<4); EVENT_780/EVENT_781 pick one of
-- three approach paths (SilphCo11FMovementData_622f5..62311, $5=up
-- $6=left) that route James then Jessie up to the player without ever
-- crossing the player's tile. Their duo text lives in
-- text/SilphCo10F.asm (_SilphCoJessieJamesText*).
-- -------------------------------------------------------------------
M.SILPH_CO_11F = {
talk = {
TEXT_SILPHCO11F_JESSIE = {
{ "face_player" }, { "show_text", "_SilphCoJessieJamesText1" },
},
TEXT_SILPHCO11F_JAMES = {
{ "face_player" }, { "show_text", "_SilphCoJessieJamesText1" },
},
},
onStep = function(game, ow, x, y)
if baseSilph11Step and baseSilph11Step(game, ow, x, y) then
return true
end
local f = game.save.flags
if y ~= 3 or x > 3 then return false end
if f.EVENT_BEAT_SILPH_CO_11F_JESSIE_JAMES then return false end
-- x==3 -> base path, x==2 -> EVENT_780 variant, x<=1 -> EVENT_781
local jamesDirs, jamesFace, jessieDirs, jessieFace
if x == 3 then
jamesDirs, jamesFace = { "up", "up", "up", "up", "up" }, "right"
jessieDirs, jessieFace = { "up", "up", "up", "up" }, "up"
elseif x == 2 then
jamesDirs, jamesFace = { "up", "up", "up", "up" }, "up"
jessieDirs, jessieFace = { "up", "up", "up", "up", "up" }, "left"
else
jamesDirs = { "up", "up", "left", "up", "up" }
jamesFace = "up"
jessieDirs = { "up", "up", "up", "left", "up", "up" }
jessieFace = "left"
end
ow.runner:run({
{ "stop_music" },
{ "play_music", "Music_MeetJessieJames" },
{ "show_text", "_SilphCoJessieJamesText1" },
{ "face_player_dir", "down" },
{ "emote", "player", "shock", 30 },
-- James (object 4) then Jessie (object 6), Script5..Script10 order
{ "walk_npc", 4, jamesDirs },
{ "face_object", 4, jamesFace },
{ "walk_npc", 6, jessieDirs },
{ "face_object", 6, jessieFace },
{ "show_text", "_SilphCoJessieJamesText2" },
{ "start_battle", "trainer", "OPP_ROCKET", 45 },
{ "check_battle_result", "win" },
{ "jump_if_false", "end" },
{ "show_text", "_SilphCoJessieJamesText3" },
{ "show_text", "_SilphCoJessieJamesText4" },
{ "stop_music" },
{ "play_music", "Music_MeetJessieJames" },
{ "fade", "out" },
{ "hide_object", "SILPH_CO_11F", "SILPHCO11F_JAMES" },
{ "hide_object", "SILPH_CO_11F", "SILPHCO11F_JESSIE" },
{ "fade", "in" },
{ "play_default_music" },
{ "set_flag", "EVENT_BEAT_SILPH_CO_11F_JESSIE_JAMES" },
}, {})
return true
end,
}
return M
-131
View File
@@ -1,131 +0,0 @@
# Building a Blue Version of the port
pokered builds Red and Blue from one source tree: the Makefile
assembles everything twice with `rgbasm -D _RED` or `-D _BLUE`, and
every in-game difference sits in an `IF DEF(_RED)` / `IF DEF(_BLUE)`
block. Our extraction pipeline resolves those conditionals the same
way (`tools/extract/util.py` `ASM_DEFINES`), so most of a Blue build
falls out of re-running the extractors. Only three things are
hand-ported on the Lua side and need swapping by hand.
## What differs between Red and Blue
| Where (pokered) | What |
| --- | --- |
| `data/wild/maps/*.asm` (34 files) | Version-exclusive encounters (Red: Ekans, Oddish, Growlithe, Mankey, Scyther, Electabuzz, Blue: Sandshrew, Bellsprout, Vulpix, Meowth, Pinsir, Magmar) |
| `data/pokemon/title_mons.asm` | The 16 title-screen Pokémon |
| `engine/movie/title.asm` + `gfx/version.asm` | The "Red Version" / "Blue Version" ribbon |
| `constants/player_constants.asm` | Preset names (Red: RED/ASH/JACK + BLUE/GARY/JOHN) |
| `data/sgb/sgb_palettes.asm`, `sgb_border.asm` | Super Game Boy palettes and border |
| `data/events/prizes.asm`, `prize_mon_levels.asm` | Game Corner prize mons, costs and levels |
| `engine/movie/intro.asm`, `data/credits/credits_text.asm`, `engine/slots/slot_machine.asm`, `engine/battle/animations.asm`, `audio/sfx/save_3.asm` | Small gated tweaks (credits say "BLUE VERSION STAFF", etc.) |
## Step 1, flip the extraction define
`tools/extract/util.py`:
```python
ASM_DEFINES = {"_RED"} # -> {"_BLUE"}
```
Then regenerate everything (same as scripts/setup.sh does):
```sh
cd tools
../.venv/bin/python3 build_data.py \
--pokered /Users/bryanbassett/Documents/development/pokered \
--out ../data/generated --assets ../assets/generated
```
This alone switches the wild encounters, preset names, SGB palettes
and credits text. **Caveat:** `parse_preset_names` sanity-checks in
`tools/extract/field.py:1193` expect "RED" in the player presets,
relax that check for a Blue build (Blue's presets are BLUE/GARY/JOHN
for the player and RED/ASH/JACK for the rival).
`tools/extract/palettes.py` uses its own raw reader with hardcoded
`IF DEF(_BLUE)` skipping (around line 44), invert that too, or port
it to `read_asm` so `ASM_DEFINES` covers it.
## Step 2, title screen (hand-ported)
`src/ui/TitleState.lua`:
1. **Ribbon art.** The extractor writes
`assets/generated/title/red_version.png`; add `blue_version.png`
to `gfx.extract_title` in `tools/extract/gfx.py` (source:
`gfx/title/blue_version.png`, 64×8). In `TitleState:draw()`, the
Red strip needs two quads (tiles 01 "Red", skip, tiles 59
"Version", `title.asm` `VersionOnTitleScreenText`). Blue's strip
prints its tiles contiguously (`db $61..$68`), so draw the whole
64×8 image at px (56, 64) and verify with the title driver
screenshot.
2. **Title mons.** Replace `CYCLE_SPECIES` with Blue's list from
`data/pokemon/title_mons.asm`:
```lua
local CYCLE_SPECIES = {
"SQUIRTLE", "CHARMANDER", "BULBASAUR", "MANKEY", "HITMONLEE",
"VULPIX", "CHANSEY", "AERODACTYL", "JOLTEON", "SNORLAX",
"GLOOM", "POLIWAG", "DODUO", "PORYGON", "GENGAR", "RAICHU",
}
```
## Step 3, Game Corner prizes (hand-ported)
`data/scripts/story3.lua` (~line 209) carries the Red prize tables.
Blue's values (`prizes.asm` + `prize_mon_levels.asm`):
| Prize | Cost | Level |
| --- | --- | --- |
| ABRA | 120 | 6 |
| CLEFAIRY | 750 | 12 |
| NIDORINO | 1200 | 17 |
| PINSIR | 2500 | 20 |
| DRATINI | 4600 | 24 |
| PORYGON | 6500 | 18 |
(TM prizes are identical in both versions.)
## Step 4, verify
```sh
luajit tests/run_tests.lua
```
Plus two spot checks:
```sh
# every grass table must have exactly 10 slots (a conditional-parsing
# regression shows up as 1920 slots)
luajit -e 'local e=dofile("data/generated/encounters.lua")
for m,d in pairs(e) do if type(d)=="table" and d.grass and #d.grass.slots>0
and #d.grass.slots~=10 then print("BAD",m,#d.grass.slots) end end'
# a Blue exclusive should now appear (and Growlithe should not)
grep -c VULPIX data/generated/encounters.lua
grep -c GROWLITHE data/generated/encounters.lua
```
Title screenshot: run the driver in
`tests/drivers/` style (`SHOT_DIR=... POKEPORT_DRIVER=... love .`) and
eyeball the ribbon, title mon, and copyright row.
## What you get for free / what to skip
- Free after re-extraction: encounters (incl. Super Rod groups), SGB
palettes, preset names, credits text, the gated sfx/animation
tweaks.
- Trades, gift Pokémon, story scripts, gym data: identical in Western
Red/Blue, nothing to touch.
- Save files: a Red save loads fine, but dex AREA nests and new
encounters will be Blue's. Trainer parties are identical.
## Making it a runtime toggle instead
If you want one build with both versions, extraction would need to
emit both branches keyed by version (e.g. `slots` / `slotsBlue`) and
the three hand-ported spots would read a `save.version` or
`conf.lua` flag. That is a bigger change than the rebuild above,
the flip-and-regenerate route needs no engine changes at all.
+18
View File
@@ -259,6 +259,7 @@ stick push hides it, the next screen touch brings it back, and unplugging
the last controller restores it immediately. Layout re-derives from the
window size on rotation. Desktop testing: `POKEPORT_TOUCH=1 love .` forces
the overlay on and lets the mouse act as a finger (`=0` forces it off).
## Translation support
Every string the player can read is now reachable from a mod, so a
@@ -344,3 +345,20 @@ quarantine on load; clicking it jumps to the tab holding the first problem.
`tools/tiled_export.py` turns the imported ROM cache into a Tiled workspace,
so maps can be edited in a real map editor and exported back out as a mod.
It has its own document: docs/tiled-map-editing.md.
## Pokédex diploma (both versions)
The Celadon Mansion 3F game designer shows the dex-completion diploma
once 150 species are owned. On Yellow, the graphic artist next to him
then offers to print it, saving the certificate as a PNG under `prints/`
in the save directory, and Bill's PC gains Yellow's PRINT BOX item which
exports the current box list the same way.
## Pokédex printing (Yellow)
Yellow's Game Boy Printer PRNT option in the Pokédex side menu is stood in
for by an image export: choosing PRNT renders the mon's entry page (sprite,
kind, number, height/weight, dex text) to a PNG at 4x scale under
`prints/` in the save directory, then reports the filename in a dialog.
No printer hardware or link cable emulation involved; the file is the
printout.
+9 -9
View File
@@ -135,10 +135,10 @@ function closeEditor()
end
local function bootGame(version)
-- The launcher hands us the chosen game (Red / Blue); scripted and headless
-- runs fall back to POKEPORT_VERSION, then Red. Set the active version and
-- overlay its extracted cache BEFORE anything requires generated data, so
-- data/generated + assets/generated resolve to that version's files.
-- The launcher hands us the chosen game (Red / Blue / Yellow); scripted and
-- headless runs fall back to POKEPORT_VERSION, then Red. Set the active
-- version and overlay its extracted cache BEFORE anything requires generated
-- data, so data/generated + assets/generated resolve to that version's files.
local GameVersion = require("src.core.GameVersion")
GameVersion.set(version or os.getenv("POKEPORT_VERSION") or "red")
require("src.import.CacheFs").mountVersion(GameVersion.get())
@@ -231,11 +231,11 @@ function love.load(args)
return
end
-- Interactive: the launcher always runs. Red and Blue are each live: a
-- column shows Play when that game's ROM is already imported, or Choose ROM
-- / drag-drop when it is not (Yellow is still a placeholder). Any dropped
-- .gb is routed to Red or Blue by its SHA-1; pressing Play boots that game.
-- Edit on a save row opens the bundled editor on that slot (openEditor).
-- Interactive: the launcher always runs. Red, Blue, and Yellow are each
-- live: a column shows Play when that game's ROM is already imported, or
-- Choose ROM / drag-drop when it is not. Any dropped .gb is routed by its
-- SHA-1 (GameVersion.forSha1); pressing Play boots that game. Edit on a
-- save row opens the bundled editor on that slot (openEditor).
Importer = RomImporter.new(function(version)
Importer = nil
bootGame(version)
+7 -4
View File
@@ -1,11 +1,12 @@
#!/usr/bin/env bash
# Build game data from a user-provided Pokemon Red ROM and install LÖVE.
# Build game data from a user-provided Pokemon Red/Blue/Yellow ROM and install LÖVE.
#
# Usage:
# scripts/setup.sh --rom /path/to/pokemon-red.gb
# scripts/setup.sh --rom /path/to/pokemon-yellow.gbc
# ROM_PATH=/path/to/pokemon-red.gb scripts/setup.sh
#
# With no explicit path, the first *.gb file in the project root is used.
# With no explicit path, the first *.gb / *.gbc file in the project root is used.
set -euo pipefail
@@ -34,15 +35,17 @@ command -v python3 >/dev/null 2>&1 \
|| fail "Python 3 is required to decode the ROM"
if [ -z "$ROM" ]; then
for candidate in "$ROOT"/*.gb; do
shopt -s nullglob
for candidate in "$ROOT"/*.gb "$ROOT"/*.gbc; do
if [ -f "$candidate" ]; then
ROM="$candidate"
break
fi
done
shopt -u nullglob
fi
[ -n "$ROM" ] && [ -f "$ROM" ] \
|| fail "Pokemon Red ROM not found. Put your .gb file in $ROOT or pass --rom /path/to/file.gb"
|| fail "Pokemon ROM not found. Put a .gb or .gbc in $ROOT or pass --rom /path/to/file"
if [ ! -x "$VENV/bin/python3" ]; then
say "creating Python environment"
+1 -1
View File
@@ -22,7 +22,7 @@ local NOTES = {
-- mirrors ChipAudio's snapTicks so authored drums land on the same sample
-- grid as the ROM's own drum tables
local function snapTicks(ticks)
return math.floor((ticks * 735 + 256) / 512)
return math.floor((ticks * 1470 + 256) / 512)
end
-- ------- validation
+47 -6
View File
@@ -630,8 +630,22 @@ end
-- player mon; the battle menu appears under the OLD MAN's name and a
-- scripted cursor hovers FIGHT, hops to ITEM and forces the item menu
-- (one POKé BALL x50). The throw always catches; nothing is kept.
function BattleState:makeOldManDemo()
-- Yellow's Pallet intro (BATTLE_TYPE_PIKACHU) is the same simulated
-- script under "PROF.OAK" (pokeyellow core.asm .profOakName), so the
-- displayed thrower name is a parameter.
function BattleState:makeOldManDemo(name)
self.demo = true
self.demoName = name or "OLD MAN"
-- Yellow's Pallet intro runs this before the player owns any mon
-- (BATTLE_TYPE_PIKACHU precedes the lab gift), so newWild flagged the
-- battle dead for lack of a party. The demo never sends out, draws, or
-- acts with the player side; a hidden placeholder battler keeps the
-- shared battle phases nil-safe.
if not self.player then
self.dead = false
self.player = makeBattler(self.game.data,
Pokemon.new(self.game.data, self.enemy.mon.species, 5), true)
end
end
-- Safari Zone battles (engine/battle/core.asm safari sections +
@@ -1046,6 +1060,10 @@ function BattleState:computeMusicKind()
end
end
end
-- init_battle.asm: challenging a gym leader (wGymLeaderNo, the badge
-- fights only -- not Lance or the Champion) bumps the companion's
-- happiness the moment the battle starts
self.isGymLeader = isBoss
if self.kind == "trainer" and self.trainer
and self.trainer.id == "OPP_RIVAL3" then
return "final"
@@ -1086,6 +1104,10 @@ function BattleState:enter()
end
local Music = require("src.core.Music")
self.musicKind = self:computeMusicKind()
if self.isGymLeader then
require("src.world.PikachuFollower")
.modifyHappiness(self.game.save, "GYMLEADER")
end
-- normally already playing: the transition wipe starts the theme
-- (audio/play_battle_music.asm runs before the transition, and
-- Music.play no-ops on the same song); this covers battles pushed
@@ -1711,7 +1733,7 @@ function BattleState:oldManThrow()
self.phase = "messages"
self.afterQueue = "finish"
self.result = "run" -- nothing is kept; wBattleResult only ends the demo
self:say(Strings("OLD MAN used\nPOKé BALL!"))
self:say(Strings("%s used\nPOKé BALL!", self.demoName or "OLD MAN"))
self:act(function()
require("src.core.Sound").play(self.data, "Ball_Toss")
-- ItemUseBall's beat before the toss chain (like throwBall)
@@ -2986,6 +3008,17 @@ function BattleState:onFaint(battler)
self.participants[battler.mon] = nil
end
Runtime.emit("battle.fainted", { battle = self, battler = battler })
if battler.isPlayer then
-- HandlePlayerMonFainted (core.asm:1070-1085): the companion loses
-- happiness on its own faint; an enemy 30+ levels above it makes
-- that the CARELESSTRAINER hit instead
local enemyLevel = self.enemy and self.enemy.mon
and self.enemy.mon.level or 0
local reason = (enemyLevel - (battler.mon.level or 0)) >= 30
and "CARELESSTRAINER" or "FAINTED"
require("src.world.PikachuFollower")
.modifyHappiness(self.game.save, reason, battler.mon)
end
-- the faint slide + cry ride the queue (after the move animation and
-- the HP-bar drain, pokered's order); the slide finishes before the
-- faint text via a queued hold
@@ -3070,6 +3103,9 @@ function BattleState:enemyMonFainted()
-- the move-learn checks (experience.asm:245-256)
local game = self.game
for _, lv in ipairs(levels) do
-- experience.asm:248 fires per grew-level text
require("src.world.PikachuFollower")
.modifyHappiness(game.save, "LEVELUP", mon)
self:sayNext(Strings("%s grew\nto level %d!", name, lv))
self:uiNext(function()
require("src.core.Sound").play(game.data, "Level_Up")
@@ -3890,7 +3926,10 @@ function BattleState:finish()
-- way back -- an unrecoverable state, not merely a wrong one.
-- playerMonFainted is the path that should have caught this; if we land
-- here it did not, so say so rather than silently papering over it.
if self.result ~= "lose" and not Party.firstHealthy(self.game.save.party) then
-- The old-man / PROF.OAK demo also skips it: the party never fought
-- (Yellow's Pallet intro runs before the player owns a mon at all).
if self.result ~= "lose" and not self.demo
and not Party.firstHealthy(self.game.save.party) then
Logger.warn("battle finished %s with no healthy party; forcing blackout",
tostring(self.result))
self.result = "lose"
@@ -4802,7 +4841,9 @@ function BattleState:drawTextArea()
Font.drawCode(Font.BORDER.br, 80, 96)
love.graphics.setColor(0, 0, 0, 1)
for i, mv in ipairs(self.player.curMoves) do
Font.draw(self.data.moves[mv.id].name, 48, 96 + i * 8)
-- unknown ids (mod-injected moves) print raw instead of crashing
local def = self.data.moves[mv.id]
Font.draw(def and def.name or tostring(mv.id), 48, 96 + i * 8)
end
Font.drawCode((self.moveSwapIndex == self.moveIndex) and 0xEC or 0xED,
40, 96 + self.moveIndex * 8)
@@ -4811,10 +4852,10 @@ function BattleState:drawTextArea()
end
local sel = self.player.curMoves[self.moveIndex]
if sel then
local def = self.data.moves[sel.id]
if self.player.disabledSlot == self.moveIndex then
Font.draw(Strings("disabled!"), 8, 80)
else
local def = self.data.moves[sel.id]
elseif def then
Font.draw(Strings("TYPE/"), 8, 72)
-- the type record's display name (a mod type shows its name, and
-- PSYCHIC_TYPE prints PSYCHIC like the original)
+41 -34
View File
@@ -14,15 +14,15 @@ local bit = require("bit")
local ChipSynth = {}
local SAMPLE_RATE = 22050
local SAMPLE_RATE = 44100
local TICKS_PER_SECOND = 15360
local FRAME_TICKS = 256
local GB_CLOCK = 4194304
-- one 4096-sample stereo SoundData is the unit both the worker hands off and
-- one 8192-sample stereo SoundData is the unit both the worker hands off and
-- the synchronous fallback queues; the source keeps MUSIC_BUFFER_COUNT of them
-- (~6s) for stall tolerance (window resize, a long GC pause)
local MUSIC_BUFFER_SAMPLES = 4096
-- (~6s at 44100) for stall tolerance (window resize, a long GC pause)
local MUSIC_BUFFER_SAMPLES = 8192
local MUSIC_BUFFER_COUNT = 32
ChipSynth.SAMPLE_RATE = SAMPLE_RATE
@@ -33,7 +33,13 @@ local PITCHES = {
0xF82C, 0xF89D, 0xF907, 0xF96B, 0xF9CA, 0xFA23,
0xFA77, 0xFAC7, 0xFB12, 0xFB58, 0xFB9B, 0xFBDA,
}
local DUTY = { [0] = 0.125, [1] = 0.25, [2] = 0.5, [3] = 0.75 }
-- LuaGB / DMG 8-step duty tables (index 0-3); stored on channels as that index
local WAVE_PATTERN_TABLES = {
[0] = {0, 0, 0, 0, 0, 0, 0, 1},
[1] = {1, 0, 0, 0, 0, 0, 0, 1},
[2] = {1, 0, 0, 0, 0, 1, 1, 1},
[3] = {0, 1, 1, 1, 1, 1, 1, 0},
}
local WAVE_LEVEL = { [0] = 0, [1] = 1, [2] = 0.5, [3] = 0.25 }
local NOISE_DIVISORS = {
[0] = 8, [1] = 16, [2] = 32, [3] = 48,
@@ -41,7 +47,7 @@ local NOISE_DIVISORS = {
}
local function snapTicks(ticks)
return math.floor((ticks * 735 + 256) / 512)
return math.floor((ticks * 1470 + 256) / 512)
end
local cachedProgramFile
@@ -143,7 +149,7 @@ function Channel.new(engine, spec, options)
speed = 12,
volume = 12,
fade = 0,
duty = 0.5,
duty = 2,
octave = 4,
waveInstrument = 0,
waveLevel = 1,
@@ -317,7 +323,7 @@ function Channel:nextEvent()
target = self:frequency(bit.band(packed, 0x0F), octave),
}
elseif command == 0xEC then
self.duty = DUTY[bit.band(self:byte(), 3)] or 0.5
self.duty = bit.band(self:byte(), 3)
elseif command == 0xED then
self.engine.tempo = self:byte() * 0x100 + self:byte()
elseif command == 0xEE then
@@ -329,10 +335,10 @@ function Channel:nextEvent()
elseif command == 0xFC then
local packed = self:byte()
self.duty = {
DUTY[bit.band(bit.rshift(packed, 6), 3)],
DUTY[bit.band(bit.rshift(packed, 4), 3)],
DUTY[bit.band(bit.rshift(packed, 2), 3)],
DUTY[bit.band(packed, 3)],
bit.band(bit.rshift(packed, 6), 3),
bit.band(bit.rshift(packed, 4), 3),
bit.band(bit.rshift(packed, 2), 3),
bit.band(packed, 3),
}
elseif command == 0xFD then
self.callStack[#self.callStack + 1] = self.address + 2
@@ -423,18 +429,13 @@ function Channel:sampleNoise(parameter)
parameter = parameter or 0
local divisor = NOISE_DIVISORS[bit.band(parameter, 7)]
local shift = bit.rshift(parameter, 4)
local output = bit.band(self.noiseLfsr, 1) == 0 and 1 or -1
if shift >= 14 then return output end
if shift < 14 then
local cycles = GB_CLOCK / divisor / (2 ^ shift) / SAMPLE_RATE
local width7 = bit.band(parameter, 8) ~= 0
local remaining = cycles
local area = 0
while remaining > 0 do
local untilClock = 1 - self.noiseClock
local span = math.min(remaining, untilClock)
output = bit.band(self.noiseLfsr, 1) == 0 and 1 or -1
area = area + output * span
self.noiseClock = self.noiseClock + span
remaining = remaining - span
if self.noiseClock >= 1 - 1e-12 then
@@ -442,8 +443,9 @@ function Channel:sampleNoise(parameter)
self:clockNoise(width7)
end
end
return area / cycles
end
-- LuaGB: instantaneous inverted LFSR LSB (high when bit0 == 0)
return bit.band(self.noiseLfsr, 1) == 0 and 1 or -1
end
local function sweepCalculation(register, sweep)
@@ -481,7 +483,7 @@ function Channel:sampleDrum(event, sampleIndex)
end
local elapsed = (sampleIndex - segment.startSample) / SAMPLE_RATE
local volume = envelopeVolume(segment.volume, segment.fade, elapsed)
return self:sampleNoise(segment.parameter) * volume / 15 * 0.35
return self:sampleNoise(segment.parameter) * volume / 15
end
function Channel:sample()
@@ -502,7 +504,7 @@ function Channel:sample()
local volume = envelopeVolume(
event.volume or 0, event.fade or 0, event.elapsed)
if event.noise then
return self:sampleNoise(event.noiseParameter) * volume / 15 * 0.35
return self:sampleNoise(event.noiseParameter) * volume / 15
end
local register = event.register
@@ -537,13 +539,18 @@ function Channel:sample()
-- a def-local program may omit its wave table entirely
if not wave then return 0 end
local index = math.min(32, math.floor(phase * 32) + 1)
return wave[index] * event.waveLevel * 0.55
return wave[index] * event.waveLevel
end
local duty = event.duty
if type(duty) == "table" then
duty = duty[frame % 4 + 1]
end
return (phase < duty and 1 or -1) * volume / 15 * 0.5
local pattern = WAVE_PATTERN_TABLES[duty or 2] or WAVE_PATTERN_TABLES[2]
local step = math.floor(phase * 8) % 8
if pattern[step + 1] == 0 then
return -volume / 15
end
return volume / 15
end
local Engine = {}
@@ -597,8 +604,8 @@ local function readWaves(banks, audio, engineNumber)
for byteIndex = 0, 15 do
local packed = romByte(
banks, spec.bank, spec.address + wave * 16 + byteIndex)
values[#values + 1] = (bit.rshift(packed, 4) - 7.5) / 7.5
values[#values + 1] = (bit.band(packed, 0x0F) - 7.5) / 7.5
values[#values + 1] = (bit.rshift(packed, 4) - 8) / 8
values[#values + 1] = (bit.band(packed, 0x0F) - 8) / 8
end
waves[#waves + 1] = values
end
@@ -606,8 +613,8 @@ local function readWaves(banks, audio, engineNumber)
for byteIndex = 0, 15 do
local packed = romByte(
banks, spec.bank, spec.address + 5 * 16 + byteIndex)
values[#values + 1] = (bit.rshift(packed, 4) - 7.5) / 7.5
values[#values + 1] = (bit.band(packed, 0x0F) - 7.5) / 7.5
values[#values + 1] = (bit.rshift(packed, 4) - 8) / 8
values[#values + 1] = (bit.band(packed, 0x0F) - 8) / 8
end
for _ = 1, 4 do waves[#waves + 1] = values end
return waves
@@ -615,7 +622,7 @@ end
-- def-local waves are authored either as raw 0-15 nibbles (the ROM's own
-- units) or as the -1..1 samples readWaves produces; the synth wants the
-- latter
-- latter (LuaGB: (nibble - 8) / 8)
local function normalizeWaves(source)
local waves = {}
for index, values in ipairs(source) do
@@ -625,7 +632,7 @@ local function normalizeWaves(source)
end
local wave = {}
for position, value in ipairs(values) do
wave[position] = nibbles and (value - 7.5) / 7.5 or value
wave[position] = nibbles and (value - 8) / 8 or value
end
waves[index] = wave
end
@@ -690,7 +697,7 @@ end
function Engine:sample()
local value = 0
for _, channel in ipairs(self.channels) do value = value + channel:sample() end
return math.max(-1, math.min(1, value * 0.5))
return math.max(-1, math.min(1, value / 4))
end
function Engine:sampleStereo()
@@ -701,8 +708,8 @@ function Engine:sampleStereo()
if not event or event.panLeft ~= false then left = left + value end
if not event or event.panRight ~= false then right = right + value end
end
return math.max(-1, math.min(1, left * 0.5)),
math.max(-1, math.min(1, right * 0.5))
return math.max(-1, math.min(1, left / 4)),
math.max(-1, math.min(1, right / 4))
end
function Engine:sampleChannel(number)
@@ -711,7 +718,7 @@ function Engine:sampleChannel(number)
local value = channel:sample()
if channel.number == number then selected = value end
end
return math.max(-1, math.min(1, selected * 0.5))
return math.max(-1, math.min(1, selected / 4))
end
-- render `samples` frames into a fresh SoundData (mono or stereo). love.sound
+7
View File
@@ -85,6 +85,13 @@ function Data:seedDefaults()
for key, value in pairs(BOOT_DEFAULTS) do
if boot[key] == nil then boot[key] = copy(value) end
end
-- Yellow boots its own attract movie (engine/movie/intro_yellow.asm);
-- only the un-overridden default flips, so a total conversion that set
-- field.boot.screens.splash keeps its choice on any version.
if boot.screens.splash == BOOT_DEFAULTS.screens.splash
and require("src.core.GameVersion").isYellow() then
boot.screens.splash = "YellowIntro"
end
-- the naming screen presets the importer already extracts but nothing
-- ever read (field.presetNames)
if boot.namePresets == nil then
+7 -4
View File
@@ -90,10 +90,13 @@ function Game:load()
self.save.player.x, self.save.player.y, self.save.player.facing)
else
local titleState = self:makeTitleState()
-- the copyright splash + Nidorino-vs-Gengar attract movie plays
-- before the title (engine/movie/splash.asm + intro.asm); the ids come
-- from field.boot.screens so a total conversion owns the whole boot
Screens.push(self, bootScreens(self).splash or "IntroMovie", function()
-- the copyright splash + attract movie plays before the title
-- (engine/movie/splash.asm + intro.asm; Yellow swaps in its own
-- 18-scene movie, engine/movie/intro_yellow.asm); the ids come from
-- field.boot.screens so a total conversion owns the whole boot
local splash = require("src.core.GameVersion").isYellow()
and "YellowIntro" or "IntroMovie"
Screens.push(self, bootScreens(self).splash or splash, function()
StateStack:push(titleState)
end)
end
+25 -8
View File
@@ -1,12 +1,13 @@
-- Which Gen-1 game this process is running: Red (the historical default) or
-- Blue. One source of truth for everything that differs by version -- the
-- accepted ROM hash, the import manifest, where the extracted cache lives,
-- and the save-file suffix -- so the importer, cache mount, SaveData, title
-- screen and palette all agree.
-- Which Gen-1 game this process is running: Red (the historical default),
-- Blue, or Yellow. One source of truth for everything that differs by
-- version -- the accepted ROM hash, the import manifest, where the
-- extracted cache lives, and the save-file suffix -- so the importer,
-- cache mount, SaveData, title screen and palette all agree.
--
-- Red keeps every un-suffixed path it always used (save.lua, the root cache),
-- so existing installs are untouched; Blue is namespaced under blue/ and
-- _blue so both can be imported and played side by side.
-- _blue, Yellow under yellow/ and _yellow, so all three can be imported and
-- played side by side.
--
-- Zero requires, so it loads during love.conf and under plain Lua for tools
-- and tests. The active version is a process-global set once at boot from
@@ -19,6 +20,7 @@ GameVersion.VERSIONS = {
id = "red",
label = "Red",
displayName = "Pokemon Red",
launcherName = "Red", -- game-panel header in the launcher
sha1 = "ea9bcae617fdf159b045185467ae58b2e4a48b9a",
manifest = "tools/rom_manifest.json",
cachePrefix = "", -- Red owns the cache root (backwards compatible)
@@ -28,15 +30,26 @@ GameVersion.VERSIONS = {
id = "blue",
label = "Blue",
displayName = "Pokemon Blue",
launcherName = "Blue",
sha1 = "d7037c83e1ae5b39bde3c30787637ba1d4c48ce2",
manifest = "tools/rom_manifest_blue.json",
cachePrefix = "blue/", -- blue/data/generated, blue/assets/generated
saveSuffix = "_blue", -- save_blue.lua / .bak / .tmp
},
yellow = {
id = "yellow",
label = "Yellow",
displayName = "Pokemon Yellow",
launcherName = "Yellow (alpha)",
sha1 = "cc7d03262ebfaf2f06772c1a480c7d9d5f4a38e1",
manifest = "tools/rom_manifest_yellow.json",
cachePrefix = "yellow/", -- yellow/data/generated, yellow/assets/generated
saveSuffix = "_yellow", -- save_yellow.lua / .bak / .tmp
},
}
-- Launcher column order (Yellow is still a placeholder, handled by the UI).
GameVersion.ORDER = { "red", "blue" }
-- Launcher column order.
GameVersion.ORDER = { "red", "blue", "yellow" }
GameVersion.current = "red"
@@ -53,6 +66,10 @@ function GameVersion.isBlue()
return GameVersion.current == "blue"
end
function GameVersion.isYellow()
return GameVersion.current == "yellow"
end
-- Metadata for a version id, defaulting to the active one.
function GameVersion.info(id)
return GameVersion.VERSIONS[id or GameVersion.current]
+44
View File
@@ -0,0 +1,44 @@
-- Game Boy Printer stand-in. Yellow's printer jobs
-- (engine/printer/printer.asm: PrintPokedexEntry and friends) drove a
-- serial thermal printer; this port renders the same printout into a PNG
-- under prints/ in the save directory instead, and the caller shows a
-- dialog with where it landed. Scaled up 4x so the "print" is legible
-- on a modern screen.
local Logger = require("src.core.Logger")
local Printer = {}
local SCALE = 4
-- Render drawFn (which draws a w x h GB-pixel image at 0,0) into
-- prints/<name>_<stamp>.png. Returns the save-dir-relative path, or nil
-- and an error string (headless / no canvas support degrades gracefully).
function Printer.save(name, w, h, drawFn)
if not (love.graphics and love.graphics.newCanvas) then
return nil, "no graphics"
end
local ok, canvas = pcall(love.graphics.newCanvas, w * SCALE, h * SCALE)
if not ok then return nil, tostring(canvas) end
love.graphics.push("all")
love.graphics.setCanvas(canvas)
love.graphics.origin()
love.graphics.scale(SCALE, SCALE)
love.graphics.clear(1, 1, 1, 1)
love.graphics.setColor(1, 1, 1, 1)
local drawOk, drawErr = pcall(drawFn)
love.graphics.pop()
if not drawOk then return nil, tostring(drawErr) end
local data
ok, data = pcall(canvas.newImageData, canvas)
if not ok then return nil, tostring(data) end
love.filesystem.createDirectory("prints")
local path = ("prints/%s_%s.png"):format(name, os.date("%Y-%m-%d_%H%M%S"))
local encOk, err = pcall(data.encode, data, "png", path)
if not encOk then return nil, tostring(err) end
Logger.info("printed %s -> %s/%s",
name, love.filesystem.getSaveDirectory(), path)
return path
end
return Printer
+15 -12
View File
@@ -24,10 +24,11 @@ local GameVersion = require("src.core.GameVersion")
local SaveData = {}
-- Progress files carry the game-version suffix so Red and Blue saves coexist:
-- Red keeps save.lua / .bak / .tmp exactly as before; Blue is save_blue.lua
-- (+ .bak/.tmp). options.lua is deliberately shared across versions (it holds
-- global preferences and the mod enable-state, not per-playthrough data).
-- Progress files carry the game-version suffix so Red / Blue / Yellow saves
-- coexist: Red keeps save.lua / .bak / .tmp exactly as before; Blue is
-- save_blue.lua and Yellow is save_yellow.lua (+ .bak/.tmp). options.lua is
-- deliberately shared across versions (it holds global preferences and the
-- mod enable-state, not per-playthrough data).
local OPTIONS_FILENAME = "options.lua"
-- Main / backup / staged-witness names for a version (defaults to the active
@@ -323,8 +324,9 @@ end
-- under options.saveSlots[version]; the active slot is also cached
-- process-wide (like GameVersion.current) so the hot saveNames path does
-- not re-read options every call. A false cache entry means "no slot in
-- use" and the flat legacy path (save.lua / save_blue.lua) is used, which
-- keeps a brand-new install and every pre-slots caller working unchanged.
-- use" and the flat legacy path (save.lua / save_blue.lua / save_yellow.lua)
-- is used, which keeps a brand-new install and every pre-slots caller
-- working unchanged.
local activeSlotCache = {} -- version -> slotId in use, or false when none
local slotsChecked = {} -- version -> true once resolved this process
@@ -336,17 +338,18 @@ local function slotNames(version, id)
end
-- the pre-slots flat names a version always used (save.lua for Red,
-- save_blue.lua for Blue); still the destination before any slot exists
-- save_blue.lua / save_yellow.lua for the others); still the destination
-- before any slot exists
local function legacyNames(version)
local main = "save" .. GameVersion.saveSuffix(version) .. ".lua"
return main, main .. ".bak", main .. ".tmp"
end
-- Slot resolution is only meaningful for versions GameVersion actually knows
-- (red/blue). The launcher also renders a locked placeholder tab ("yellow")
-- that has no info entry and therefore no saveSuffix; resolving its legacy
-- names would index a nil info table and crash. Treat any unknown version as
-- having no slots so the slot APIs degrade to empty/no-op instead.
-- (red / blue / yellow). An unknown id has no info entry and therefore no
-- saveSuffix; resolving its legacy names would index a nil info table and
-- crash. Treat any unknown version as having no slots so the slot APIs
-- degrade to empty/no-op instead.
local function knownVersion(version)
return GameVersion.info(version) ~= nil
end
@@ -892,7 +895,7 @@ end)
-- a .tmp witness before the swap, so a crash mid-write is recoverable.
function SaveData.save(data, mods)
-- write to the file matching this save's own version, not just the active
-- one, so a Blue playthrough always lands in save_blue.lua
-- one, so Blue/Yellow playthroughs land in save_blue.lua / save_yellow.lua
local FILENAME, BACKUP_FILENAME, TMP_FILENAME = saveNames(data.version)
if data.options then
SaveData.saveOptions(data.options)
+35
View File
@@ -192,10 +192,45 @@ local function newCrySource(data, species, def)
return newFileSource(resolved)
end
-- Yellow's voiced Pikachu clips (audio/pikachu_pcm.asm
-- PlayPikachuSoundClip): 1-bit PCM decoded to WAVs at import
-- (data.audio.pikaCries = clip count). Returns the source, nil when the
-- cache carries no clips (Red/Blue) or headless.
function Sound.playPikaCry(data, n)
if not love.audio then return nil end
local count = data.audio and data.audio.pikaCries
if not count then return nil end
n = math.max(1, math.min(count, n or 1))
local key = "pikacry:" .. n
local src = cache[key]
if src == false then return nil end
if not src then
local ok, s = pcall(love.audio.newSource,
("assets/generated/audio/pika_cries/cry_%02d.wav"):format(n), "static")
if not ok then
cache[key] = false
return nil
end
s:setVolume(BASE_VOLUME * volumeScale)
cache[key] = s
src = s
end
src:stop()
src:play()
played("cry", "PIKACHU_PCM_" .. n, "PIKACHU")
return src
end
-- returns the source (nil headless) so callers that block on the cry
-- like the original's PlayCry -> WaitForSoundToFinish can poll it
function Sound.playCry(data, species)
if not love.audio then return nil end
-- Yellow voices every Pikachu cry with the PCM clips (the chip cry is
-- never used for the species there); clip 1 is the everyday "Pika!"
if species == "PIKACHU" then
local src = Sound.playPikaCry(data, 1)
if src then return src end
end
local cries = data.audio and data.audio.cries
local def = cries and cries[species]
if not def then return nil end
+17 -17
View File
@@ -32,11 +32,11 @@ local CacheFs = {}
local SEP = package.config:sub(1, 1)
-- Cache-relative paths are prefixed with this before every read/write, so a
-- Blue import lands in blue/ (see src.core.GameVersion) while a Red import
-- keeps the historical root. The launcher sets it per import / per readiness
-- check; it stays "" for Red. Runtime *reads* (require / newImage) do NOT go
-- through here -- CacheFs.mountVersion overlays the active version's subtree
-- onto the un-prefixed paths instead.
-- Blue/Yellow import lands under its GameVersion.cachePrefix (blue/, yellow/)
-- while a Red import keeps the historical root. The launcher sets it per
-- import / per readiness check; it stays "" for Red. Runtime *reads*
-- (require / newImage) do NOT go through here -- CacheFs.mountVersion overlays
-- the active version's subtree onto the un-prefixed paths instead.
CacheFs.prefix = ""
local function withPrefix(rel)
@@ -330,16 +330,16 @@ end
-- Overlay the active version's extracted cache onto the un-prefixed read
-- paths, so require("data.generated.*") and love.graphics.newImage(
-- "assets/generated/*") resolve to that version's files. Red lives at the
-- cache root and needs nothing; Blue lives under blue/ and is *prepended* so
-- it wins over any Red copy at the root and over the game source. Called
-- once at boot, before Game:load (main.lua). Returns true when nothing was
-- needed or the mount succeeded.
-- cache root and needs nothing; non-Red versions (blue/, yellow/, …) are
-- *prepended* so they win over any Red copy at the root and over the game
-- source. Called once at boot, before Game:load (main.lua). Returns true
-- when nothing was needed or the mount succeeded.
function CacheFs.mountVersion(version)
local prefix = require("src.core.GameVersion").cachePrefix(version)
if prefix == "" then return true end -- Red: already at the root
local sub = prefix:gsub("/+$", "") -- "blue/" -> "blue"
local sub = prefix:gsub("/+$", "") -- "blue/" / "yellow/" -> bare dir
-- The cache root is the portable game folder when active, else LÖVE's OS
-- save directory (where love.filesystem wrote blue/...).
-- save directory (where love.filesystem wrote blue/... or yellow/...).
local base = CacheFs.root()
if not base and love.filesystem.getSaveDirectory then
base = love.filesystem.getSaveDirectory()
@@ -355,12 +355,12 @@ function CacheFs.mountVersion(version)
end
-- Undo mountVersion. A process normally mounts exactly one version and then
-- boots it, but the launcher can open the save editor on a Blue save, close
-- it, and press Play on Red: with blue/ still prepended, Red's
-- require("data.generated.*") and its generated art would silently resolve to
-- Blue's files. Callers must also drop the generated modules from
-- package.loaded (src.core.Data:unloadGenerated) -- unmounting alone only
-- fixes the read path, not what require already cached.
-- boots it, but the launcher can open the save editor on a Blue/Yellow save,
-- close it, and press Play on Red: with that version's subtree still
-- prepended, Red's require("data.generated.*") and its generated art would
-- silently resolve to the other game's files. Callers must also drop the
-- generated modules from package.loaded (src.core.Data:unloadGenerated) --
-- unmounting alone only fixes the read path, not what require already cached.
--
-- Returns true when nothing was mounted or the unmount took. Red is a no-op
-- because its cache lives at the root and was never overlaid.
+312 -16
View File
@@ -163,8 +163,12 @@ function RomExtractor:extractTilesets()
for pos = offset, offset + 15 do block[#block + 1] = blocksRaw[pos] end
blocks[#blocks + 1] = block
end
-- Red/Blue keep collision lists in ROM0; Yellow moved them to bank 1
-- (pokeyellow Overworld_Coll at 01:4ac2). Pointers in $4000-$7FFF are
-- banked; treat ROM0-range pointers as bank 0.
local collBank = collisionPointer < 0x4000 and 0 or 1
local walkable = sorted(self:readTerminated(
0, collisionPointer, 0xFF))
collBank, collisionPointer, 0xFF))
local warpPointer = self.rom:word(
warpPointers.bank, warpPointers.address + (index - 1) * 2)
local warpTiles = unique(self:readTerminated(
@@ -447,14 +451,26 @@ function RomExtractor:extractSprites()
local pointer = self.rom:word(pointerTable.bank, address)
local firstHalf = self.rom:byte(pointerTable.bank, address + 2)
local bank = self.rom:byte(pointerTable.bank, address + 3)
local byteLength = spec.imageWidth * spec.imageHeight / 4
local frames = spec.imageHeight / 16
local width = spec.imageWidth
local height = spec.imageHeight
local byteLength = width * height / 4
local frames = height / 16
local expected = firstHalf * (frames >= 6 and 2 or 1)
if byteLength ~= expected then
-- Commercial ROM sheet length wins over pret PNG atlases (Yellow nurse
-- PNG is taller than the 12-tile SpriteSheetPointerTable entry).
byteLength = expected
assert(byteLength * 4 % width == 0,
constName .. ": ROM sprite length not tile-aligned")
height = byteLength * 4 / width
frames = height / 16
expected = firstHalf * (frames >= 6 and 2 or 1)
assert(byteLength == expected, constName .. ": sprite length mismatch")
end
local base = spec.imageBase
if not written[base] then
self:write2bpp(self.rom:bytes(bank, pointer, byteLength),
spec.imageWidth, spec.imageHeight,
width, height,
"sprites/" .. base .. ".png", true)
written[base] = true
end
@@ -862,20 +878,24 @@ function RomExtractor:extractPalettes()
local order = self.manifest.paletteOrder
local paletteTable = self:symbol("SuperPalettes")
local function scale5(value) return round(value * 255 / 31) end
local palettes = {}
for index, name in ipairs(order) do
local function readTable(symbol, names)
local out = {}
for index, name in ipairs(names) do
local colors = {}
for color = 0, 3 do
local value = self.rom:word(paletteTable.bank,
paletteTable.address + (index - 1) * 8 + color * 2)
local value = self.rom:word(symbol.bank,
symbol.address + (index - 1) * 8 + color * 2)
colors[#colors + 1] = {
scale5(bit.band(value, 0x1F)),
scale5(bit.band(bit.rshift(value, 5), 0x1F)),
scale5(bit.band(bit.rshift(value, 10), 0x1F)),
}
end
palettes[name] = colors
out[name] = colors
end
return out
end
local palettes = readTable(paletteTable, order)
local monsterTable = self:symbol("MonsterPalettes")
local monsterPalettes = {}
for index, species in ipairs(self.manifest.dexOrder) do
@@ -887,6 +907,11 @@ function RomExtractor:extractPalettes()
source = "ROM:SuperPalettes + MonsterPalettes",
palettes = palettes, order = order, pokemon = monsterPalettes,
}
-- Yellow (and GBC carts) also carry CGBBasePalettes beside SuperPalettes.
if self.symbols["CGBBasePalettes"] then
data.cgbBase = readTable(self:symbol("CGBBasePalettes"), order)
data.source = data.source .. " + CGBBasePalettes"
end
self:write("palettes", data)
self:tick("Color palettes", 1, 1)
return data
@@ -915,6 +940,10 @@ function RomExtractor:extractIcons()
GRASS = "assets/generated/icons/plant.png",
SNAKE = "assets/generated/icons/snake.png",
QUADRUPED = "assets/generated/icons/quadruped.png",
-- Yellow's ICON_PIKACHU draws from the overworld PikachuSprite sheet
-- (data/icon_pointers.asm mon_icon_header PikachuSprite, 0/12);
-- only referenced when the manifest's iconOrder includes it
PIKACHU = "assets/generated/sprites/pikachu.png",
}
local frames = {
{ "bug", "BugIconFrame1", "BugIconFrame2" },
@@ -1048,7 +1077,9 @@ function RomExtractor:extractPokemon()
local typeById = self:typesById()
local names = self:symbol("MonsterNames")
local baseStats = self:symbol("BaseStats")
local mewStats = self:symbol("MewBaseStats")
-- Red/Blue keep Mew outside BaseStats (pret pokered MewBaseStats).
-- Yellow stores Mew as dex 151 inside BaseStats (pret/pokeyellow).
local mewStats = self.symbols["MewBaseStats"] and self:symbol("MewBaseStats")
local decodedNames = {}
for index = 1, #speciesOrder do
decodedNames[index] = self.rom:decodeText(
@@ -1067,7 +1098,7 @@ function RomExtractor:extractPokemon()
local dex = assert(dexBySpecies[species],
"missing dex number for " .. species)
local row
if species == "MEW" then
if species == "MEW" and mewStats then
row = self.rom:bytes(mewStats.bank, mewStats.address, 28)
else
row = self.rom:bytes(
@@ -1410,6 +1441,142 @@ function RomExtractor:extractText()
}
end
function RomExtractor:extractYellowTitleArt()
-- pret/pokeyellow engine/movie/title_yellow.asm: the Yellow title is a
-- tilemap composition over BOTH tile banks. LoadYellowTitleScreenGFX
-- loads PokemonLogoGraphics into vChars2 (BG ids $00-$7F),
-- TitlePikachuBGGraphics into vChars1 (ids $80-$EF),
-- TitlePikachuOBGraphics at vChars1 tile $70 (ids $F0-$FC, also the eye
-- OAM tiles), and PokemonLogoCornerGraphics at vChars1 tile $7D (ids
-- $FD-$FF). Every tilemap mixes ids from several of those sheets, so a
-- single-sheet lookup shows checkerboard garbage where a foreign-bank id
-- lands (e.g. blank id $00 = logo tile 0, not Pikachu BG tile 0).
if not self.symbols["TitlePikachuBGGraphics"] then return end
-- raw sheets, kept for debugging / mod reference
self:raw2bpp("TitlePikachuBGGraphics", 128, 32,
"title/pikachu_bg.png", { transparent = true })
self:raw2bpp("TitlePikachuOBGraphics", 96, 8,
"title/pikachu_ob.png", { transparent = true })
-- Tile counts are the Graphics..GraphicsEnd symbol gaps in pokeyellow.sym.
local function sheetTiles(label, count, transparent)
local symbol = self:symbol(label)
local raw = self.rom:bytes(symbol.bank, symbol.address, count * 16)
local tiles = {}
for offset = 1, #raw, 16 do
local one = {}
for i = offset, offset + 15 do one[#one + 1] = raw[i] end
tiles[#tiles + 1] = ImageWriter.decode2bpp(one, 8, 8, transparent)
end
return tiles
end
local logo = sheetTiles("PokemonLogoGraphics", 115)
local corner = sheetTiles("PokemonLogoCornerGraphics", 3)
local bg = sheetTiles("TitlePikachuBGGraphics", 64)
local ob = sheetTiles("TitlePikachuOBGraphics", 12)
local obClear = sheetTiles("TitlePikachuOBGraphics", 12, true)
local function tileFor(id)
if id < 0x80 then return logo[id + 1] end
if id < 0xF0 then return bg[id - 0x80 + 1] end
if id < 0xFD then return ob[id - 0xF0 + 1] end
return corner[id - 0xFD + 1]
end
-- OAM-style blit: color-0 pixels stay whatever the target already holds
-- (ImageWriter.blit copies alpha-0 pixels wholesale, which would punch
-- holes into the face under the eye sprites).
local function blitSprite(target, tile, tx, ty, flipX)
for y = 0, 7 do
for x = 0, 7 do
local sx = flipX and 7 - x or x
local r, g, b, a = tile:getPixel(sx, y)
if a ~= 0 then target:setPixel(tx + x, ty + y, r, g, b, a) end
end
end
end
-- cells = { {id, col, row}, ... }; untouched cells stay transparent
local function compose(cols, rows, cells)
local pose = ImageWriter.blank(cols * 8, rows * 8, 1, 1, 1, 0)
for _, cell in ipairs(cells) do
local tile = tileFor(cell[1])
if tile then ImageWriter.blit(pose, tile, cell[2] * 8, cell[3] * 8) end
end
return pose
end
local function mapCells(map, cols, rows)
local ids = self.rom:bytes(map.bank, map.address, cols * rows)
local cells = {}
for index, id in ipairs(ids) do
cells[#cells + 1] =
{ id, (index - 1) % cols, math.floor((index - 1) / cols) }
end
return cells
end
-- TitleScreen_PlacePokemonLogo: 16x7 box at (2,1). Yellow's logo sheet
-- is deduplicated (unlike Red's sequential rip), so the raw2bpp
-- pokemon_logo.png from extractField is scrambled; overwrite it with the
-- tilemap composition. Kept opaque: TitleState clears to white behind it.
self:save(compose(16, 7,
mapCells(self:symbol("TitleScreenPokemonLogoTilemap"), 16, 7)),
"title/pokemon_logo.png")
-- TitleScreen_PlacePikaSpeechBubble: 7x4 box at (6,4) plus the two tail
-- tiles $64/$65 the routine pokes at (9,8) -- one row below the box, over
-- blank cells of the Pikachu row. Composed 7x5 with the tail at (3,4);
-- matteColor0 clears the outside-the-balloon whites, the outline protects
-- the interior.
local bubbleCells = mapCells(
self:symbol("TitleScreenPikaBubbleTilemap"), 7, 4)
bubbleCells[#bubbleCells + 1] = { 0x64, 3, 4 }
bubbleCells[#bubbleCells + 1] = { 0x65, 4, 4 }
self:save(ImageWriter.matteColor0(compose(7, 5, bubbleCells)),
"title/pika_bubble.png")
-- TitleScreen_PlacePikachu: 12x9 box at (4,8) plus the right-ear edge
-- tiles it pokes down column 16 (rows 10-13) -- composed 13x9 with those
-- at relative column 12, rows 2-5. The open eyes are OAM
-- (TitleScreenPikachuEyesOAMData, copied at place time): OB tiles 0-3 at
-- screen (56,80)/(88,80) blocks, the left eye x-flipped (attr $22); baked
-- into the composition relative to the box origin px(32,64).
local pikaCells = mapCells(self:symbol("TitleScreenPikachuTilemap"), 12, 9)
pikaCells[#pikaCells + 1] = { 0x96, 12, 2 }
pikaCells[#pikaCells + 1] = { 0x9d, 12, 3 }
pikaCells[#pikaCells + 1] = { 0xa7, 12, 4 }
pikaCells[#pikaCells + 1] = { 0xb1, 12, 5 }
local pikachu = ImageWriter.matteColor0(compose(13, 9, pikaCells))
-- DoTitleScreenFunction's blink rewrites the eye OAM tile ids with
-- `and $f3 / or e` (e = 0 open / 4 half / 8 closed), so the OB sheet
-- holds three 4-tile eye sets. Bake the open set into pikachu.png and
-- save half/closed as standalone overlays for TitleState's blink.
local EYE_LAYOUT = {
{ 2, 24, 16, true }, { 1, 32, 16, true },
{ 4, 24, 24, true }, { 3, 32, 24, true },
{ 1, 56, 16 }, { 2, 64, 16 },
{ 3, 56, 24 }, { 4, 64, 24 },
}
-- Blink overlays for the (24,16)-(71,31) eye band: the BG face is
-- eyeless (the eyes are OAM), so each overlay = the blank-face crop
-- with the half (+4) / closed (+8) tile set composited color-0
-- transparent -- exactly what the hardware shows mid-blink.
local overlays = {}
for suffix, base in pairs({ eyes_half = 4, eyes_closed = 8 }) do
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])
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])
end
self:save(pikachu, "title/pikachu.png")
for suffix, overlay in pairs(overlays) do
self:save(overlay, "title/" .. suffix .. ".png")
end
end
function RomExtractor:raw2bpp(label, width, height, relative, options)
options = options or {}
local expected = width * height / 4
@@ -1454,6 +1621,8 @@ function RomExtractor:extractField()
"title/copyright.png"); tick()
self:raw2bpp("GameFreakLogoGraphics", 72, 8,
"title/gamefreak_inc.png"); tick()
-- Yellow fixed Pikachu title art (no-op on Red/Blue manifests).
self:extractYellowTitleArt(); tick()
local fallingStar = self:raw2bpp(
"FallingStar", 8, 8, "intro/falling_star.png",
@@ -1502,6 +1671,10 @@ function RomExtractor:extractField()
end
self:save(star, "intro/big_star.png"); tick()
-- Yellow has no FightIntro Gengar/Nidorino fight (pret/pokeyellow
-- engine/movie/intro_yellow.asm); write blank placeholders so Title/
-- Intro still find the expected paths. Red/Blue keep the tilemap rip.
if self.symbols["FightIntroBackMon"] then
local gengar = self:symbol("FightIntroBackMon")
local gengarRaw = self.rom:bytes(
gengar.bank, gengar.address, 96 * 16)
@@ -1522,7 +1695,14 @@ function RomExtractor:extractField()
pose = ImageWriter.matteColor0(pose)
self:save(pose, "intro/gengar_" .. number .. ".png"); tick()
end
else
for number = 1, 3 do
self:save(ImageWriter.blank(56, 56, 0, 0, 0, 0),
"intro/gengar_" .. number .. ".png"); tick()
end
end
if self.symbols["FightIntroFrontMon"] then
for number, label in ipairs({
"FightIntroFrontMon", "FightIntroFrontMon2", "FightIntroFrontMon3",
}) do
@@ -1531,6 +1711,31 @@ function RomExtractor:extractField()
{ transparent = true, columns = true })
tick()
end
else
for number = 1, 3 do
self:save(ImageWriter.blank(48, 48, 1, 1, 1, 0),
"intro/red_nidorino_" .. number .. ".png"); tick()
end
end
-- Optional Yellow-only intro atlas (pret/pokeyellow gfx/yellow_intro.asm).
if self.symbols["YellowIntroGraphics1"] then
self:raw2bpp("YellowIntroGraphics1", 128, 64,
"intro/yellow_intro_1.png")
end
if self.symbols["YellowIntroGraphics2"] then
-- atlas2 doubles as the intro's OBJ tile bank (vChars0); OBJ color 0
-- is hardware-transparent, and the BG draws it over a white clear so
-- BG cells lose nothing
self:raw2bpp("YellowIntroGraphics2", 128, 128,
"intro/yellow_intro_2.png", { transparent = true })
end
-- Yellow intro clouds (intro_yellow.asm YellowIntroCloudGFX): 8 tiles,
-- two 4-tile animation frames -- saved 32x16, one frame per row.
if self.symbols["YellowIntroCloudGFX"] then
self:raw2bpp("YellowIntroCloudGFX", 32, 16, "intro/clouds.png")
end
for number = 1, 2 do
self:writeCompressedPic(
"ShrinkPic" .. number, "intro/shrink" .. number .. ".png")
@@ -1563,10 +1768,27 @@ function RomExtractor:extractField()
end
self:save(symbolSheet, "slots/symbols.png"); tick()
local emotes = ImageWriter.blank(48, 16, 1, 1, 1, 0)
for index, label in ipairs({
"ShockEmote", "QuestionEmote", "HappyEmote",
}) do
-- Emote sheet layout comes from manifest.field.emotionBubbles so the
-- versions can differ: Red ships the three shared bubbles, Yellow adds
-- the five Pikachu-only ones (emotion_bubbles.asm Skull/Heart/Bolt/
-- Zzz/FishEmote, used by the PikachuEmotionTable reactions).
local EMOTE_SYMBOLS = {
EXCLAMATION_BUBBLE = "ShockEmote", QUESTION_BUBBLE = "QuestionEmote",
SMILE_BUBBLE = "HappyEmote", SKULL_BUBBLE = "SkullEmote",
HEART_BUBBLE = "HeartEmote", BOLT_BUBBLE = "BoltEmote",
ZZZ_BUBBLE = "ZzzEmote", FISH_BUBBLE = "FishEmote",
}
local bubbleDefs = self.manifest.field.emotionBubbles
and self.manifest.field.emotionBubbles.bubbles
local emoteLabels = {}
for _, b in ipairs(bubbleDefs or {}) do
emoteLabels[#emoteLabels + 1] = EMOTE_SYMBOLS[b.name]
end
if #emoteLabels == 0 then
emoteLabels = { "ShockEmote", "QuestionEmote", "HappyEmote" }
end
local emotes = ImageWriter.blank(#emoteLabels * 16, 16, 1, 1, 1, 0)
for index, label in ipairs(emoteLabels) do
local symbol = self:symbol(label)
local image = ImageWriter.decode2bpp(
self.rom:bytes(symbol.bank, symbol.address, 64), 16, 16, true)
@@ -1574,6 +1796,26 @@ function RomExtractor:extractField()
end
self:save(emotes, "emotes.png"); tick()
-- Yellow-only: the Surfing Pikachu minigame sheets
-- (gfx/surfing_pikachu.asm) at pret's canvas widths, so
-- src/ui/SurfingMinigame.lua's quads can be read off the source pngs.
-- 1a is the BG set (water/beach/score tiles, opaque); 1b the OAM pose
-- sheet and 1c the intro set (both color-0 transparent).
for _, spec in ipairs({
{ "SurfingPikachu1Graphics1", 65, 40, false, "minigame/surf_1a.png" },
{ "SurfingPikachu1Graphics2", 256, 128, true, "minigame/surf_1b.png" },
{ "SurfingPikachu1Graphics3", 144, 96, true, "minigame/surf_1c.png" },
}) do
if self.symbols[spec[1]] then
local symbol = self:symbol(spec[1])
local tilesPerRow = spec[3] / 8
local image = ImageWriter.decode2bpp(
self.rom:bytes(symbol.bank, symbol.address, spec[2] * 16),
spec[3], spec[2] / tilesPerRow * 8, spec[4])
self:save(image, spec[5])
end
end
self:raw1bpp("LedgeHoppingShadow", 8, 8,
"fx/shadow.png", true); tick()
for _, spec in ipairs({
@@ -1664,7 +1906,9 @@ end
function RomExtractor:extractAudio()
self:beginStage("Sound programs")
local metadata = copy(self.manifest.audio)
local bankOrder = { 2, 8, 31 }
-- Yellow adds a fourth music bank ($20: Jessie & James, Surfing
-- Pikachu, GB Printer); the manifest names the pack when it needs it.
local bankOrder = metadata.programBanks or { 2, 8, 31 }
local chunks = {}
for index, bank in ipairs(bankOrder) do
local first = Rom.offset(bank, 0x4000) + 1
@@ -1683,6 +1927,7 @@ function RomExtractor:extractAudio()
for name, header in pairs(metadata.musicHeaders) do
songs[name] = header
end
metadata.pikaCries = self:extractPikachuCries()
local cries = {}
local cryData = metadata.cryData
for index, species in ipairs(self.manifest.constants.speciesOrder) do
@@ -1709,6 +1954,57 @@ function RomExtractor:extractAudio()
return metadata
end
-- Yellow's voiced Pikachu clips (audio/pikachu_cries_pointers.asm
-- PikachuCriesPointerTable, 42 `dba` rows; each clip is `dw length` then
-- 1-bit PCM, MSB first -- home/pikachu_cries.asm PlayPikachuPCM toggles
-- rAUD3LEVEL per bit at roughly 190 CPU cycles a sample). Decoded to
-- plain 8-bit mono WAVs; returns the clip count for data.audio.pikaCries,
-- or nil when the manifest has no pointer table (Red/Blue).
function RomExtractor:extractPikachuCries()
if not self.symbols["PikachuCriesPointerTable"] then return nil end
local NUM = 42 -- NUM_PIKA_CRIES
local RATE = 22050 -- ~4.19 MHz / ~190 cycles per sample
-- byte -> 8 samples, MSB first (LoadNextSoundClipSample: `and $80`)
local lut = {}
for byte = 0, 255 do
local out = {}
for bit = 7, 0, -1 do
local on = math.floor(byte / 2 ^ bit) % 2 == 1
out[#out + 1] = string.char(on and 0xE0 or 0x20)
end
lut[byte] = table.concat(out)
end
local function u16(v)
return string.char(v % 256, math.floor(v / 256) % 256)
end
local function u32(v)
return string.char(v % 256, math.floor(v / 256) % 256,
math.floor(v / 65536) % 256, math.floor(v / 16777216) % 256)
end
local CacheFs = require("src.import.CacheFs")
local pointers = self:symbol("PikachuCriesPointerTable")
for index = 0, NUM - 1 do
local row = self.rom:bytes(pointers.bank, pointers.address + index * 3, 3)
local bank, address = row[1], row[2] + row[3] * 256
local header = self.rom:bytes(bank, address, 2)
local length = header[1] + header[2] * 256
local raw = self.rom:bytes(bank, address + 2, length)
local samples = {}
for i, byte in ipairs(raw) do samples[i] = lut[byte] end
local pcm = table.concat(samples)
local wav = "RIFF" .. u32(36 + #pcm) .. "WAVEfmt " .. u32(16)
.. u16(1) .. u16(1) .. u32(RATE) .. u32(RATE) .. u16(1) .. u16(8)
.. "data" .. u32(#pcm) .. pcm
local ok, err = CacheFs.write(
("assets/generated/audio/pika_cries/cry_%02d.wav"):format(index + 1),
wav)
if not ok then
error("could not write pika cry " .. (index + 1) .. ": " .. tostring(err))
end
end
return NUM
end
function RomExtractor:run()
local results = {}
results.constants = self:extractConstants()
+62 -51
View File
@@ -37,8 +37,8 @@ local REQUIRED_FILES = {
-- "Split-screen ROM selector" first-run palette (matches FirstRun.dc.html from
-- the Claude Design project): a dark neon arcade panel, one column per game.
-- Red is live; Blue and Yellow are lit placeholders until those games are
-- supported. Values are 0-255 RGB; alpha is applied per draw.
-- Red, Blue, and Yellow share the same importer flow once listed in
-- GameVersion.VERSIONS. Values are 0-255 RGB; alpha is applied per draw.
local PAL = {
-- radial background gradient (bright navy at top-centre -> near black)
bgTop = { 22, 34, 74 }, -- #16224a
@@ -302,13 +302,13 @@ end
-- it directly through love.filesystem -- already mounted at the physfs
-- root, so no io.* absolute-path handling is needed.
--
-- Only a .gb whose SHA maps to a version that is not yet ready counts as
-- Only a .gb/.gbc whose SHA maps to a version that is not yet ready counts as
-- pending. GameActivity always writes the SAF pick to picked_rom.gb, so a
-- naive "first .gb wins" scan would re-import Red when the player tries to
-- add Blue (issue #167).
-- naive "first ROM wins" scan would re-import Red when the player tries to
-- add Blue (issue #167). Yellow carts are typically .gbc.
local function findPendingRom(ready)
for _, name in ipairs(love.filesystem.getDirectoryItems("")) do
if name:lower():match("%.gb$") and love.filesystem.getInfo(name, "file") then
if name:lower():match("%.gbc?$") and love.filesystem.getInfo(name, "file") then
local data = love.filesystem.read(name)
if type(data) == "string" and #data == 1024 * 1024 then
local version = GameVersion.forSha1(sha1(data))
@@ -360,14 +360,14 @@ local function chooseRom(promptName)
local platform = love.system.getOS()
if platform == "OS X" then
return commandOutput(
([[osascript -e 'POSIX path of (choose file with prompt "%s" of type {"gb"})' 2>/dev/null]])
([[osascript -e 'POSIX path of (choose file with prompt "%s" of type {"gb", "gbc"})' 2>/dev/null]])
:format(prompt))
elseif platform == "Windows" then
local script = table.concat({
"Add-Type -AssemblyName System.Windows.Forms;",
"$d=New-Object System.Windows.Forms.OpenFileDialog;",
"$d.Title='" .. prompt .. "';",
"$d.Filter='Game Boy ROM (*.gb)|*.gb|All files (*.*)|*.*';",
"$d.Filter='Game Boy ROM (*.gb;*.gbc)|*.gb;*.gbc|All files (*.*)|*.*';",
-- write the pick as UTF-8: the console's OEM codepage would mangle
-- non-ASCII names (Pokémon -> Pok\x82mon) and crash any text draw
-- that shows them (#325)
@@ -377,11 +377,11 @@ local function chooseRom(promptName)
'powershell -NoProfile -STA -Command "' .. script .. '"')
elseif platform == "Linux" then
local path = commandOutput(
([[zenity --file-selection --title="%s" --file-filter="Game Boy ROM | *.gb" 2>/dev/null]])
([[zenity --file-selection --title="%s" --file-filter="Game Boy ROM | *.gb *.gbc" 2>/dev/null]])
:format(prompt))
if path then return path end
return commandOutput(
[[kdialog --getopenfilename "$HOME" "*.gb|Game Boy ROM" 2>/dev/null]])
[[kdialog --getopenfilename "$HOME" "*.gb *.gbc|Game Boy ROM" 2>/dev/null]])
end
return nil
end
@@ -470,10 +470,11 @@ local function updaterAllowed()
return true
end
-- The launcher runs Red and Blue as two independent columns. Each dropped or
-- The launcher runs each GameVersion as an independent tab. Each dropped or
-- chosen ROM is routed to its version by SHA-1, extracted into that version's
-- own cache (Red at the root, Blue under blue/), so both can be imported and
-- played side by side. onComplete(version) hands the chosen game off to boot.
-- own cache (Red at the root, Blue under blue/, Yellow under yellow/), so all
-- can be imported and played side by side. onComplete(version) hands the
-- chosen game off to boot.
-- opts: launcher (a fresh import stays on the launcher instead of auto-booting),
-- forceImport (treat every version as not-yet-imported, so re-import is forced),
-- onEditSave(version, slotId) (host handler for the Edit affordance on a save
@@ -542,13 +543,18 @@ function RomImporter.new(onComplete, opts)
CacheFs.prefix = saved
self.returning[version] =
(not ready) and marker ~= nil and marker ~= markerFor(version)
self.romName[version] = "pokemon_" .. info.id .. ".gb"
self.romName[version] = "pokemon_" .. info.id
.. (info.id == "yellow" and ".gbc" or ".gb")
end
-- Android: import a save-dir .gb that is not yet ready (USB drop or a
-- Android: import a save-dir .gb/.gbc that is not yet ready (USB drop or a
-- leftover SAF pick), routed by SHA-1. Already-imported carts are skipped
-- so a stale picked_rom.gb cannot block the opposite version.
if android and not (self.ready.red and self.ready.blue) then
-- so a stale picked_rom.gb cannot block another version.
local needRom = false
for _, version in ipairs(GameVersion.ORDER) do
if not self.ready[version] then needRom = true; break end
end
if android and needRom then
local name, data = findPendingRom(self.ready)
if name then self:startData(data, name) end
end
@@ -608,7 +614,7 @@ function RomImporter:focus(f)
local version = self.androidPendingExportVersion or self:_savedropTarget()
self.androidPendingExportVersion = nil
self.saveNotice[version] = { ok = true, text = "Save exported." }
if self.tab == "mods" or self.tab == "yellow" then self.tab = version end
if self.tab == "mods" then self.tab = version end
return
end
local modName = findPendingMod(false)
@@ -629,9 +635,13 @@ function RomImporter:focus(f)
end
return
end
if self.ready.red and self.ready.blue then return end
for _, v in ipairs(GameVersion.ORDER) do
if not self.ready[v] then
local name, data = findPendingRom(self.ready)
if name then self:startData(data, name) end
return
end
end
end
function RomImporter:setError(message, version)
@@ -660,7 +670,8 @@ local function resetPointerCursor(self)
end
-- Verify + extract a ROM. The version is decided by the ROM's own SHA-1, so
-- dropping a Red or Blue cart into either column always lands in the right one.
-- dropping a Red, Blue, or Yellow cart into any column always lands in the
-- right one.
function RomImporter:startData(data, displayName)
if self.workState == "working" then return end
if type(data) ~= "string" then
@@ -676,14 +687,14 @@ function RomImporter:startData(data, displayName)
local version = GameVersion.forSha1(actualHash)
if not version then
self:setError(("Unsupported ROM (SHA-1 %s). Use an unmodified US Pokemon "
.. "Red or Blue ROM."):format(actualHash))
.. "Red, Blue, or Yellow ROM."):format(actualHash))
return
end
local info = GameVersion.info(version)
-- Bring the launcher to this version's tab so its progress bar is on screen
-- (a dropped cart is routed by SHA-1 regardless of which tab was showing).
if self.tab == "red" or self.tab == "blue" or self.tab == "yellow" then
if GameVersion.VERSIONS[self.tab] then
self.tab = version
end
self.importing = version
@@ -731,7 +742,7 @@ function RomImporter:startData(data, displayName)
self.returning[version] = false
self.romName[version] = (displayName
and (displayName:match("[^/\\]+$") or displayName)) or self.romName[version]
-- Android: drop the consumed save-dir .gb (picked_rom.gb or a USB copy)
-- Android: drop the consumed save-dir .gb/.gbc (picked_rom.gb or a USB copy)
-- so the next Choose / focus cannot treat it as a fresh pending ROM.
if self.android and type(displayName) == "string"
and not displayName:find("[/\\]") then
@@ -845,12 +856,12 @@ function RomImporter:chooseMod()
end
-- Which game a dropped .sav imports into: a .sav has no version signature of
-- its own, so it lands on the active game tab. When a non-game tab (mods, or
-- the locked yellow placeholder) is showing, default to red -- the always-
-- present first game -- rather than guess.
-- its own, so it lands on the active game tab. When a non-game tab (mods) is
-- showing, default to red -- the always-present first game -- rather than
-- guess.
function RomImporter:_savedropTarget()
local v = self.tab
if v == "red" or v == "blue" then return v end
if GameVersion.VERSIONS[v] then return v end
return "red"
end
@@ -861,8 +872,7 @@ end
-- playable with its game's data present.
function RomImporter:_importSave(version, source)
if self.workState == "working" then return end
if self.tab == "red" or self.tab == "blue" or self.tab == "mods"
or self.tab == "yellow" then
if GameVersion.VERSIONS[self.tab] or self.tab == "mods" then
self.tab = version
end
if not self.ready[version] then
@@ -971,8 +981,8 @@ function RomImporter:choose(version)
if self.workState == "working" then return end
self.chooseVersion = version or "red"
if self.android then
-- Prefer a not-yet-imported .gb already in the save dir (USB copy, or a
-- fresh SAF pick). Never reuse an already-imported cart's file -- that
-- Prefer a not-yet-imported .gb/.gbc already in the save dir (USB copy, or
-- a fresh SAF pick). Never reuse an already-imported cart's file -- that
-- was the #167 failure mode (second Choose just re-extracted Red).
local name, data = findPendingRom(self.ready)
if name then
@@ -995,8 +1005,8 @@ function RomImporter:choose(version)
return
end
-- Handheld Linux (Anbernic stock OS / PortMaster) rarely has zenity or
-- kdialog. Fall back to the same "drop a .gb next to the game" scan used
-- on Android, which works when the game is launched as an unpacked
-- kdialog. Fall back to the same "drop a .gb/.gbc next to the game" scan
-- used on Android, which works when the game is launched as an unpacked
-- directory (see build-rg34xxsp.sh).
local name, data = findPendingRom(self.ready)
if name then
@@ -1010,13 +1020,13 @@ function RomImporter:choose(version)
or "the game folder"
self.notice = {
version = self.chooseVersion,
status = "No file picker. Copy your .gb into:",
status = "No file picker. Copy your .gb/.gbc into:",
detail = where,
}
return
end
if love.system.getOS() ~= "OS X" and love.system.getOS() ~= "Windows" then
self:setError("File selection is unavailable here. Drop the .gb file onto the window.")
self:setError("File selection is unavailable here. Drop the .gb/.gbc file onto the window.")
end
end
@@ -1112,7 +1122,7 @@ function RomImporter:_updatePadCursor(dt)
local next = (self.modScroll or 0) + step
self.modScroll = math.max(0, math.min(maxS, next))
end
elseif self.tab == "red" or self.tab == "blue" then
elseif GameVersion.VERSIONS[self.tab] then
local maxS = (self._slotMax and self._slotMax[self.tab]) or 0
if maxS > 0 then
local next = (self.slotScroll[self.tab] or 0) + step
@@ -1138,7 +1148,7 @@ function RomImporter:gamepadpressed(_, button)
-- Start / Select: Play if ready, else Choose ROM on the active game tab.
if self.workState == "working" then return end
local version = self.tab
if version == "red" or version == "blue" then
if GameVersion.VERSIONS[version] then
if self.ready[version] then self:play(version) else self:choose(version) end
end
end
@@ -1972,9 +1982,9 @@ function RomImporter:keypressed(key)
if self.workState == "working" then return end
if key == "return" or key == "space" or key == "kpenter" then
-- Enter acts on the visible game tab: Play if its ROM is ready, otherwise
-- open its picker. The mods / placeholder tabs have no keyboard action.
-- open its picker. The mods tab has no keyboard action.
local version = self.tab
if version == "red" or version == "blue" then
if GameVersion.VERSIONS[version] then
if self.ready[version] then self:play(version) else self:choose(version) end
end
end
@@ -2132,7 +2142,7 @@ function RomImporter:_drawTabBar(x, y, w, h, chip)
end
cursorX = segEnd + gap
end
-- "N of 3 ready" (Red + Blue count; Yellow never ready), hidden if no room
-- "N of 3 ready" (Red + Blue + Yellow once in GameVersion.ORDER)
local ready = 0
for _, v in ipairs(GameVersion.ORDER) do if self.ready[v] then ready = ready + 1 end end
love.graphics.setFont(self.readyFont)
@@ -2152,9 +2162,12 @@ end
function RomImporter:_drawGamePanel(version, x, y, w, h)
local s, pulse = self._s, self.pulse
self.panelVersion = version
local locked = version == "yellow"
local info = (not locked) and GameVersion.info(version) or nil
local gameName = locked and "Pokemon Yellow" or info.displayName
-- Defensive: only lock when the version is absent from GameVersion (never
-- solely because id == "yellow").
local info = GameVersion.info(version)
local locked = info == nil
local gameName = info and (info.launcherName or info.displayName)
or tostring(version)
local ready = (not locked) and self.ready[version] or false
-- header: name + status pill
@@ -2191,12 +2204,13 @@ function RomImporter:_drawGamePanel(version, x, y, w, h)
local rightX = twoCol and (x + colW + colGap) or x
-- ROM card contents by state (rehomes the existing import flow)
local dropHint = self.android and "Copy the .gb via USB."
or Strings("Or drop the .gb file here.")
local accent = locked and PAL.gold or (version == "red" and PAL.red or PAL.blue)
local dropHint = self.android and "Copy the .gb/.gbc via USB."
or Strings("Or drop the .gb/.gbc file here.")
local accent = version == "yellow" and PAL.gold
or (version == "red" and PAL.red or PAL.blue)
local romState, romDetail, romBtnLabel, romBtnEnabled, romProgress
if locked then
romState, romDetail = "Not supported yet", "Yellow support is on the way."
romState, romDetail = "Not supported yet", "Support for this game is on the way."
romBtnLabel, romBtnEnabled = "Import unavailable", false
else
local importing = self.importing == version
@@ -2245,7 +2259,6 @@ function RomImporter:_drawGamePanel(version, x, y, w, h)
-- SAVE FILES card: Import save is live once the ROM is imported (playable);
-- Export save is live only when the active slot actually holds a save. The
-- locked yellow placeholder has no save backend, so both stay disabled. The
-- hint line doubles as the last import/export outcome (green ok / red error).
local sfImportEnabled, sfExportEnabled = false, false
if not locked then
@@ -2358,9 +2371,7 @@ function RomImporter:_drawGamePanel(version, x, y, w, h)
self:_playButton(leftX, playY, colW, playH, gameName, ready, locked)
-- SAVE SLOT card (right column, or stacked below Play when single-column).
-- The locked Yellow placeholder has no save backend (no GameVersion entry, so
-- no slots can exist); skip the panel entirely rather than draw an empty,
-- non-functional "+ New save slot" on a COMING SOON game.
-- Skip only when the version is absent from GameVersion (no save backend).
if not locked then
if twoCol then
self:_drawSaveSlotPanel(version, rightX, bodyTop, colW, bodyH)
+35
View File
@@ -183,6 +183,12 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
return "failed", { Strings("OAK: %s!\nThis isn't the\ntime to use that!", save.player.name) }
end
local b = battle.player
-- PIKAHAPPY_USEDXITEM (item_effects.asm ItemUseXAccuracy /
-- GuardSpec / DireHit / XStat) on the active companion
if itemId ~= "POKE_DOLL" then
require("src.world.PikachuFollower")
.modifyHappiness(save, "USEDXITEM", b and b.mon)
end
if itemId == "X_ACCURACY" then
-- ItemUseXAccuracy sets USING_X_ACCURACY: moves never miss
-- (not an accuracy stage)
@@ -253,6 +259,18 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
return "consumed", { Strings("%s's PP\nwas restored!", monName(data, target)) }
end
-- PIKAHAPPY_USEDITEM (item_effects.asm ItemUseMedicine, item id up to
-- CALCIUM): fires once a medicine has a target, before the effect
-- resolves -- potions, status cures, revives and vitamins all count,
-- RARE_CANDY does not (its success is a LEVELUP bump instead)
if target and (HEAL_AMOUNT[itemId] or STATUS_HEAL[itemId]
or itemId == "MAX_POTION" or itemId == "FULL_RESTORE"
or itemId == "REVIVE" or itemId == "MAX_REVIVE"
or VITAMINS[itemId]) then
require("src.world.PikachuFollower")
.modifyHappiness(save, "USEDITEM", target)
end
local heal = HEAL_AMOUNT[itemId]
if heal or itemId == "MAX_POTION" or itemId == "FULL_RESTORE" then
-- a FULL RESTORE on a statused mon already at full HP acts as a
@@ -323,12 +341,29 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
local old = target.stats
target.stats = Stats.calc(speciesDef, target.level, target.dvs, target.statExp)
target.hp = math.min(target.stats.hp, target.hp + (target.stats.hp - old.hp))
-- PIKAHAPPY_LEVELUP on a candy level (item_effects.asm:1540)
require("src.world.PikachuFollower")
.modifyHappiness(save, "LEVELUP", target)
return "consumed", { Strings("%s grew\nto level %d!", monName(data, target), target.level) },
{ leveledTo = target.level }
end
if STONES[itemId] then
if not target then return "failed", { Strings("It won't have\nany effect.") } end
-- Yellow's starter Pikachu never evolves: ItemUseEvoStone runs
-- IsThisPartyMonStarterPikachu (OT identity match) before
-- TryEvolvingMon and bails with the voiced cry + RefusingText.
-- The stone is NOT consumed on the refuse path.
if target.species == "PIKACHU"
and require("src.core.GameVersion").isYellow()
and target.ot == save.player.name
and target.otId == save.player.id then
require("src.core.Sound").playCry(data, "PIKACHU")
local raw = data.text and data.text._RefusingText
local line = raw and raw:gsub("{RAM:[^}]*}", monName(data, target))
or Strings("%s\nis refusing!", monName(data, target))
return "failed", { line }
end
local speciesDef = data.pokemon[target.species]
for _, evo in ipairs(speciesDef.evolutions) do
if evo.method == "ITEM" and evo.item == itemId then
+6
View File
@@ -382,6 +382,12 @@ function TradeSession:apply(game)
Runtime.emit("pokemon.received",
{ mon = received, from = "link", peerName = self.peerName })
self.party[self.myPick] = received
-- PIKAHAPPY_TRADE (engine/link/cable_club.asm:801): trading the
-- companion away is the biggest happiness hit and zeroes the mood
if game and sent then
require("src.world.PikachuFollower")
.modifyHappiness(game.save, "TRADE", sent)
end
if game and game.save.pokedex then
game.save.pokedex.seen[received.species] = true
game.save.pokedex.owned[received.species] = true
+22
View File
@@ -78,8 +78,11 @@ PaletteFX.GBC_OBJ_BLUE = {
-- playthrough, red otherwise. White (index 1) and black (index 4) are
-- identical across versions, so callers that only touch the endpoints
-- (e.g. BattleState's zone white/black snap) need no version branch.
-- Yellow is CGB-enhanced (pokeyellow CGBBasePalettes) and has no extracted
-- boot-ROM auto-palette here; keep the Red ramp -- never Blue's GBC_BG_BLUE.
function PaletteFX.ogBg()
if GameVersion.isBlue() then return PaletteFX.GBC_BG_BLUE end
if GameVersion.isYellow() then return PaletteFX.GBC_BG end
return PaletteFX.GBC_BG
end
@@ -88,8 +91,10 @@ end
-- version-distinct cache-group string, because SpriteRenderer.getObpImage keys
-- its baked-image cache by (image path, group): a shared group would collide a
-- Red bake with a Blue one and one version would show the other's colors.
-- Yellow: same Red OBJ green as above until Yellow-specific tables land.
function PaletteFX.ogObj()
if GameVersion.isBlue() then return PaletteFX.GBC_OBJ_BLUE, "gbcobj_blue" end
if GameVersion.isYellow() then return PaletteFX.GBC_OBJ, "gbcobj" end
return PaletteFX.GBC_OBJ, "gbcobj"
end
@@ -311,6 +316,9 @@ end
-- Red-derived pokered-gbc pack, so under RED++ a Blue playthrough must
-- read these from the ROM-imported table or the title ribbon stays red
-- and the Game Corner reels keep Red's pink (issue #128).
-- Yellow is intentionally NOT in BLUE_VERSIONED: skip Blue LOGO1/SLOTS*
-- recolors. When CGBBasePalettes were imported (palettes.cgbBase), Yellow
-- prefers those over SGB SuperPalettes for named zones.
local BLUE_VERSIONED = {
LOGO1 = true, SLOTS2 = true, SLOTS3 = true, SLOTS4 = true,
}
@@ -320,6 +328,11 @@ local function romNamedPal(data, name)
return p and p.palettes and p.palettes[name]
end
local function yellowCgbNamedPal(data, name)
local p = data and data.palettes
return p and p.cgbBase and p.cgbBase[name]
end
-- named palette from the active pack (nil on stale builds / missing name).
-- RED++ falls back to the ROM pack for names the gbc table omits (rare).
-- OG RED short-circuits EVERY name to the one global GBC boot-ROM BG palette
@@ -329,10 +342,16 @@ end
-- GBC_OBJ green), so this stays a BG-only hook.
function PaletteFX.pal(data, name)
if PaletteFX.mode == "ogred" then return PaletteFX.ogBg() end
-- Blue-only ROM override for versioned SuperPals. Yellow (isYellow) and
-- Red keep the active pack / Red-like path -- do not apply Blue recolors.
if GameVersion.isBlue() and BLUE_VERSIONED[name] then
local fromRom = romNamedPal(data, name)
if fromRom then return fromRom end
end
if GameVersion.isYellow() then
local fromCgb = yellowCgbNamedPal(data, name)
if fromCgb then return fromCgb end
end
local p = PaletteFX.pack(data)
local c = p and p.palettes[name]
if c then return c end
@@ -644,7 +663,10 @@ function PaletteFX.modeLabel(mode)
mode = mode or PaletteFX.mode
-- The GBC boot-ROM mode wears the running game's name: it is red for Red and
-- blue for Blue (see ogBg), so a Blue playthrough shows "OG BLUE".
-- Yellow still uses the Red boot-ROM ramp (no Yellow table yet), so keep
-- the "OG RED" label rather than inventing an "OG YELLOW" without colors.
if mode == "ogred" and GameVersion.isBlue() then return "OG BLUE" end
if mode == "ogred" and GameVersion.isYellow() then return "OG RED" end
return PaletteFX.MODE_LABELS[mode] or "GBC"
end
+76 -2
View File
@@ -173,6 +173,27 @@ function Commands.check_item(ctx, itemId)
ctx.lastCheck = (ctx.save.inventory[itemId] or 0) > 0
end
-- check_dex_owned <n>: lastCheck = the player owns at least n species
-- (the CountSetBits-over-wPokedexOwned gate in Yellow's OaksLabOak1Text)
function Commands.check_dex_owned(ctx, n)
local owned = 0
for _ in pairs(ctx.save.pokedex and ctx.save.pokedex.owned or {}) do
owned = owned + 1
end
ctx.lastCheck = owned >= (n or 1)
end
-- dex_rating: DisplayDexRating (engine/events/pokedex_rating.asm) --
-- Oak's seen/owned tally plus the per-decade rating line; blocks until
-- the box closes. Headless-safe no-op without an overworld.
function Commands.dex_rating(ctx)
local ow = ctx.overworld
if not ow then return end
local runner = ctx.runner
ow:dexRating(function() runner:resume() end)
runner:yield()
end
function Commands.jump_if_true(ctx, target)
if ctx.lastCheck then return target end
end
@@ -561,7 +582,10 @@ end
-- AskName runs for party (AddPartyMon) and box (SendNewMonToBox) when a
-- script runner is present; mods that pre-set gift.nickname skip it.
-- Box deposits also print SentToBoxText (give_pokemon.asm:36-37).
function Commands.give_pokemon(ctx, species, level)
-- skipNickname suppresses the AskName prompt: Yellow's lab Pikachu is
-- added straight through AddPartyMon (pokeyellow scripts/OaksLab.asm
-- OaksLabPlayerReceivedMonText) -- the starter Pikachu keeps its name.
function Commands.give_pokemon(ctx, species, level, skipNickname)
-- Native mods can transform a gift before the Pokémon object is created.
-- This is intentionally an event rather than a special-case starter hook:
-- mods can use the same seam for story gifts, fossils, or custom scripts.
@@ -597,7 +621,7 @@ function Commands.give_pokemon(ctx, species, level)
ctx.boxNum = boxNum
-- AskName: both AddPartyMon and SendNewMonToBox; skip mod-set nicks
-- and callback-style callers with no script runner to yield on.
if not gift.nickname and ctx.runner then
if not gift.nickname and not skipNickname and ctx.runner then
askNickname(ctx, mon)
end
if boxNum then
@@ -748,7 +772,46 @@ end
-- player CHARMANDER -> base+0, SQUIRTLE -> base+1, BULBASAUR -> base+2.
-- offsets (flag -> party offset) lets a modded roster remap the pick;
-- field.starterCounterpicks is the data-side default when stamped.
-- Yellow's rival parties key off wRivalStarter (save.rivalStarter,
-- 1 JOLTEON / 2 FLAREON / 3 VAPOREON -- set in oaks_lab_yellow.lua), not
-- the player's starter counterpick. Keyed by the Red call-site party so
-- the shared story scripts need no version branches:
-- Route 22 #1 RIVAL1 4 -> party 2 (fixed; Route22Script_50ed6), and
-- a win upgrades FLAREON to JOLTEON (Route22Rival1AfterBattleScript)
-- Cerulean RIVAL1 7 -> party 3 (fixed; CeruleanCity.asm:143)
-- S.S. Anne RIVAL2 1 -> party 1 (fixed; SSAnne2F.asm:98)
-- Tower 2F RIVAL2 4 -> 1 + starter (PokemonTower2F.asm:148)
-- Silph 7F RIVAL2 7 -> 4 + starter (SilphCo7F.asm:185)
-- Route 22 #2 RIVAL2 10 -> 7 + starter (Route22Script_50ee1)
-- Champion RIVAL3 1 -> 0 + starter (ChampionsRoom.asm:69)
local YELLOW_RIVAL_PARTIES = {
OPP_RIVAL1 = {
[4] = { party = 2, upgradeOnWin = { from = 2, to = 1 } },
[7] = { party = 3 },
},
OPP_RIVAL2 = {
[1] = { party = 1 }, [4] = { base = 1 },
[7] = { base = 4 }, [10] = { base = 7 },
},
OPP_RIVAL3 = { [1] = { base = 0 } },
}
function Commands.rival_battle(ctx, oppClass, baseParty, offsets)
local GameVersion = require("src.core.GameVersion")
if GameVersion.isYellow() then
local spec = YELLOW_RIVAL_PARTIES[oppClass]
and YELLOW_RIVAL_PARTIES[oppClass][baseParty]
if spec then
local starter = ctx.save.rivalStarter or 1
local party = spec.party or (spec.base + starter)
Commands.start_battle(ctx, "trainer", oppClass, party)
if spec.upgradeOnWin and ctx.lastBattleResult == "win"
and ctx.save.rivalStarter == spec.upgradeOnWin.from then
ctx.save.rivalStarter = spec.upgradeOnWin.to
end
return
end
end
offsets = offsets
or (ctx.game.data.field and ctx.game.data.field.starterCounterpicks)
local offset = 0
@@ -951,6 +1014,17 @@ function Commands.stop_music(ctx)
require("src.core.Music").stop()
end
-- play_default_music: PlayDefaultMusic -- resume the current map's own
-- theme (data.audio.mapSongs) after a cutscene override, keeping the
-- bike/surf substitution rules. Headless-safe no-op without an overworld.
function Commands.play_default_music(ctx)
local ow = ctx.overworld
if not ow then return end
require("src.core.Music").playMap(ctx.game.data, ow.map.id,
ctx.save and ctx.save.onBike,
ow.player and ow.player.surfing)
end
-- replace_block <bx> <by> <blockId>: the Cut-tree/card-key-door idiom,
-- on the current map
function Commands.replace_block(ctx, bx, by, blockId)
+16 -4
View File
@@ -191,8 +191,18 @@ function ScriptRunner:yield()
end
function ScriptRunner:resume(...)
if not self.co then return end
local ok, err = coroutine.resume(self.co, ...)
local co = self.co
if not co then return end
-- A completion callback can fire synchronously from inside the running
-- coroutine (e.g. a battle that finishes during its own stack push when
-- the party is already fainted). Resuming a running coroutine is an
-- error that would kill the whole script, so land the pending yield
-- first and continue on the next update tick instead.
if coroutine.status(co) == "running" then
self.waitingFrames = 1
return
end
local ok, err = coroutine.resume(co, ...)
if not ok then
local source = self.ctx and self.ctx.source
local where = source
@@ -209,8 +219,10 @@ function ScriptRunner:resume(...)
self.co = nil
self.waitingFrames = nil
self.waitingCheck = nil
elseif coroutine.status(self.co) == "dead" then
self.co = nil
-- status via the captured co: a nested resume during the call above may
-- already have torn self.co down, and status(nil) would throw
elseif coroutine.status(co) == "dead" then
if self.co == co then self.co = nil end
end
end
+7
View File
@@ -158,15 +158,22 @@ local function useOn(game, battle, id, target, list, moveIndex, picker)
local moveId = payload
local mdef = game.data.moves[moveId]
local function teach()
-- PIKAHAPPY_USEDTMHM on a successful teach (item_effects.asm:2500)
local function taught()
require("src.world.PikachuFollower")
.modifyHappiness(game.save, "USEDTMHM", target)
end
if #target.moves < 4 then
table.insert(target.moves, { id = moveId, pp = mdef.pp })
showMessages(game, { Strings("%s learned\n%s!", target.nickname or
game.data.pokemon[target.species].name, mdef.name) })
if result == "learn" then consume(game, id) end
taught()
else
require("src.ui.Screens").push(game, "MoveLearnMenu", target, moveId,
function(learned)
if learned and result == "learn" then consume(game, id) end
if learned then taught() end
end)
end
end
+45 -3
View File
@@ -127,6 +127,9 @@ local function deposit(game)
end
table.remove(game.save.party, item.value)
table.insert(active, mon)
-- PIKAHAPPY_DEPOSITED (engine/pokemon/bills_pc.asm:247)
require("src.world.PikachuFollower")
.modifyHappiness(game.save, "DEPOSITED", mon)
local name = monName(game, mon)
game.stringBuffer = name
game.boxNumString = tostring(game.save.currentBox)
@@ -220,13 +223,43 @@ local function drawChrome(game)
love.graphics.setColor(1, 1, 1, 1)
end
-- PrintPCBox (engine/printer/printer.asm): Yellow's box-list print job,
-- box number plus each stored mon's name, level and dex number; the PNG
-- under prints/ stands in for the printer paper.
local function printBox(game)
local box = Boxes.active(game.save)
local Printer = require("src.core.Printer")
local TextBox = require("src.render.TextBox")
local h = 32 + math.max(1, #box) * 10
local saved, err = Printer.save("box_" .. (game.save.currentBox or 1),
160, h, function()
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 0, 0, 160, h)
love.graphics.setColor(0, 0, 0, 1)
Font.draw(Strings("BOX No.%d", game.save.currentBox or 1), 8, 8)
if #box == 0 then Font.draw(Strings("Empty."), 8, 24) end
for i, mon in ipairs(box) do
local def = game.data.pokemon[mon.species]
Font.draw(mon.nickname or (def and def.name) or tostring(mon.species),
8, 14 + i * 10)
Font.draw(Strings(":L%d No.%03d", mon.level or 0,
def and def.dex or 0), 88, 14 + i * 10)
end
love.graphics.setColor(1, 1, 1, 1)
end)
game.stack:push(TextBox.new(game, saved
and Strings("Printed BOX %d!\fSaved as\n%s\vin the save\nfolder.",
game.save.currentBox or 1, saved)
or Strings("Printer error!\n%s", tostring(err))))
end
function BoxMenu.new(game)
Boxes.ensure(game.save)
-- bills_pc.asm BillsPCMenu: TextBoxBorder at (0,0) with interior
-- 12x10 → total 14x12. "CHANGE BOX" / "WITHDRAW <PK><MN>" need the
-- full interior (cursor col + label). keepOpen so WITHDRAW/DEPOSIT/
-- RELEASE/CHANGE BOX leave this menu underneath (jp BillsPCMenu).
local menu = Menu.new(game, {
local items = {
{ label = Strings("WITHDRAW <PK><MN>"), keepOpen = true,
onSelect = function() withdraw(game) end },
{ label = Strings("DEPOSIT <PK><MN>"), keepOpen = true,
@@ -235,10 +268,19 @@ function BoxMenu.new(game)
onSelect = function() release(game) end },
{ label = Strings("CHANGE BOX"), keepOpen = true,
onSelect = function() changeBox(game) end },
{ label = Strings("SEE YA!") },
}
-- Yellow's PRINT BOX item (bills_pc.asm _YELLOW -> PrintPCBox): the
-- Game Boy Printer box list becomes a PNG under prints/, like the
-- Pokédex PRNT stand-in
if require("src.core.GameVersion").isYellow() then
items[#items + 1] = { label = Strings("PRINT BOX"), keepOpen = true,
onSelect = function() printBox(game) end }
end
items[#items + 1] = { label = Strings("SEE YA!") }
local menu = Menu.new(game, items,
-- Bill's PC runs silent end to end (BIT_NO_MENU_BUTTON_SOUND,
-- engine/menus/pokemon_pc.asm)
}, { tx = 0, ty = 0, tw = 14, th = 12, noSound = true })
{ tx = 0, ty = 0, tw = 14, th = #items * 2 + 2, noSound = true })
local baseDraw = menu.draw
function menu:draw()
baseDraw(self)
+13 -7
View File
@@ -55,11 +55,17 @@ function DexEntryMenu:update(dt)
end
function DexEntryMenu:draw()
DexEntryMenu.render(self.game, self.def, self.sprite, self.forceOwned)
end
-- Static entry-page renderer, shared with the printer stand-in
-- (src/core/Printer.lua renders the same page into a PNG the way
-- PrintPokedexEntry rendered it to the Game Boy Printer).
function DexEntryMenu.render(game, def, sprite, forceOwned)
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 0, 0, 160, 144)
local def = self.def
if self.sprite then
love.graphics.draw(self.sprite, 8, math.max(0, 60 - self.sprite:getHeight()))
if sprite then
love.graphics.draw(sprite, 8, math.max(0, 60 - sprite:getHeight()))
end
love.graphics.setColor(0, 0, 0, 1)
Font.draw(def.name, 72, 8)
@@ -70,10 +76,10 @@ function DexEntryMenu:draw()
Font.draw(e.kind or "?", 72, 20)
-- same number width as the list (constants.dexDigits), so a dex past 999
-- prints the extra digit everywhere at once
local digits = (self.game.data.constants or {}).dexDigits or 3
local digits = (game.data.constants or {}).dexDigits or 3
Font.draw(("No.%0" .. digits .. "d"):format(def.dex or 0), 72, 32)
local owned = self.forceOwned
or (self.game.save.pokedex and self.game.save.pokedex.owned[def.id])
local owned = forceOwned
or (game.save.pokedex and game.save.pokedex.owned[def.id])
-- height/weight print only once owned, like the description
-- (pokedex.asm: "if the pokemon has not been owned, don't print the
-- height, weight, or description")
@@ -84,7 +90,7 @@ function DexEntryMenu:draw()
Font.draw(Strings("HT %d%02d″", e.heightFt, e.heightIn or 0), 72, 44)
Font.draw(Strings("WT %.1flb", (e.weight or 0) / 10), 72, 54)
end
local text = owned and e.text and self.game.data.text[e.text] or nil
local text = owned and e.text and game.data.text[e.text] or nil
local y = 72
if text then
for line in (text:gsub("\v", "\n"):gsub("\f", "\n") .. "\n"):gmatch("(.-)\n") do
+50
View File
@@ -0,0 +1,50 @@
-- The dex-completion diploma (engine/events/diploma.asm DisplayDiploma /
-- diploma2.asm DisplayDiplomaTop): a bordered certificate page with the
-- player's name, shown by the Celadon Mansion 3F game designer once 150
-- species are owned. Diploma.render also backs the Yellow-only printed
-- copy (engine/printer/printer.asm PrintDiploma -> src/core/Printer.lua).
local Font = require("src.render.Font")
local Strings = require("src.core.Strings")
local Diploma = {}
Diploma.__index = Diploma
Diploma.isOpaque = true
function Diploma.new(game, onDone)
return setmetatable({ game = game, onDone = onDone }, Diploma)
end
function Diploma:update()
local input = self.game.input
if input:wasPressed("a") or input:wasPressed("b") then
self.game.stack:pop()
if self.onDone then self.onDone() end
end
end
-- the DisplayDiplomaTop layout, hlcoord tiles kept as x*8 / y*8 pixels
function Diploma.render(game)
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 0, 0, 160, 144)
love.graphics.setColor(0, 0, 0, 1)
love.graphics.rectangle("line", 2.5, 2.5, 155, 139)
Font.draw(Strings("<Diploma>"), 40, 16) -- hlcoord 5,2
Font.draw(Strings("Player"), 24, 32) -- hlcoord 3,4
Font.draw(game.save.player.name or "RED", 80, 32) -- hlcoord 10,4
local congrats = { -- hlcoord 2,6
"Congrats! This", "diploma certifies", "that you have",
"completed your", "POKéDEX.",
}
for i, line in ipairs(congrats) do
Font.draw(Strings(line), 16, 48 + (i - 1) * 10)
end
Font.draw(Strings("GAME FREAK"), 72, 128) -- hlcoord 9,16
love.graphics.setColor(1, 1, 1, 1)
end
function Diploma:draw()
Diploma.render(self.game)
end
return Diploma
+1
View File
@@ -104,6 +104,7 @@ PartyMenu.iconFrames = {
FAIRY = { rest = 3, alt = 0 }, -- FairySprite tile 12 <-> tile 0
BIRD = { rest = 3, alt = 0 }, -- BirdSprite tile 12 <-> tile 0
WATER = { rest = 0, alt = 3 }, -- SeelSprite tile 0 <-> tile 12
PIKACHU = { rest = 0, alt = 3 }, -- Yellow: PikachuSprite tile 0 <-> 12
}
-- Which 16x16 frame of `name`'s sheet to draw; `ih` (sheet pixel
+29 -3
View File
@@ -57,7 +57,7 @@ function PokedexMenu.new(game, opts)
-- original, QUIT returns to the list
local Menu = require("src.ui.Menu")
local Screens = require("src.ui.Screens")
game.stack:push(Menu.new(game, {
local entries = {
{ label = Strings("DATA"), onSelect = function()
Screens.push(game, "DexEntryMenu", item.value)
end },
@@ -67,8 +67,34 @@ function PokedexMenu.new(game, opts)
{ label = Strings("AREA"), onSelect = function()
Screens.push(game, "TownMap", { nestSpecies = item.value })
end },
{ label = Strings("QUIT") },
}, { tx = 12, ty = 8, tw = 8, th = 10 }))
}
-- Yellow's PRNT item (engine/menus/pokedex.asm PokedexMenuItemsText
-- _YELLOW branch -> PrintPokedexEntry): the Game Boy Printer job is
-- stood in for by a PNG of the entry page saved under prints/.
if require("src.core.GameVersion").isYellow() then
entries[#entries + 1] = { label = Strings("PRNT"), onSelect = function()
local DexEntryMenu = require("src.ui.DexEntryMenu")
local Printer = require("src.core.Printer")
local TextBox = require("src.render.TextBox")
local def = game.data.pokemon[item.value]
local path = require("src.pokemon.Sprites").path(
game.data, item.value, "front", { kind = "dex" })
local ok, sprite = false, nil
if path then ok, sprite = pcall(love.graphics.newImage, path) end
local saved, err = Printer.save("dex_" .. item.value, 160, 144,
function()
DexEntryMenu.render(game, def, ok and sprite or nil, false)
end)
game.stack:push(TextBox.new(game, saved
and Strings("Printed %s's\ndata!\fSaved as\n%s\vin the save\nfolder.",
def.name, saved)
or Strings("Printer error!\n%s", tostring(err))))
end }
end
entries[#entries + 1] = { label = Strings("QUIT") }
game.stack:push(Menu.new(game, entries,
{ tx = 12, ty = 8, tw = 8,
th = #entries * 2 + 2 }))
end,
})
list.sgbPalettes = PokedexMenu.sgbPalettes
+368
View File
@@ -0,0 +1,368 @@
-- Surfing Pikachu minigame (engine/minigame/surfing_pikachu.asm): the
-- Summer Beach House wave run. Paddle for speed, launch off the wave,
-- spin in the air and land flat for points; a crooked landing wipes out
-- and ends the run. The scene is built from the real ROM sheets
-- (gfx/surfing_pikachu.asm, ripped at import to
-- assets/generated/minigame/surf_1a/1b.png): the scalloped water tiles,
-- the beach with the palm and the doll hut, the "HP:" score strip with
-- the sheet digits, the cloud, and the OAM Pikachu poses -- the air
-- tricks quantize to the sheet's rotation frames like the original's
-- sprite anims, instead of free-rotating one pose. The original drew
-- the big wave with per-scanline scroll tricks (wLYOverrides); here the
-- crest profile is a curve filled with the sheet's foam/shade tiles.
-- Score model keeps the original's shape (ride ticks + airtime + full
-- rotations); high score persists in save.surfingHighScore for the
-- beach-house printer.
local Font = require("src.render.Font")
local Strings = require("src.core.Strings")
local Music = require("src.core.Music")
local Sound = require("src.core.Sound")
local SurfingMinigame = {}
SurfingMinigame.__index = SurfingMinigame
SurfingMinigame.isOpaque = true
local PIKA_X = 44 -- fixed screen x while riding
local RUN_DISTANCE = 3200 -- scroll px from paddle-out to the beach
local GRAVITY = 0.14
local HORIZON = 24 -- sea starts under the sky strip
-- surf_1b quads: {x, y, w, h} in sheet pixels (pose pitch is 24x24)
local B = {
digits = { x = 0, y = 104 }, -- "0123456789", 8x8 each
good = { 0, 72, 32, 8 },
yeah = { 32, 72, 32, 8 },
ohno = { 80, 96, 48, 24 },
splash = { 48, 80, 32, 24 },
cloud = { 96, 112, 32, 8 },
paddle = { { 0, 80, 24, 24 }, { 24, 80, 24, 24 } },
}
-- rotation frames, 45-degree buckets clockwise from upright
local POSES = {
[0] = { 48, 0, 24, 24 }, -- upright ride
[45] = { 24, 0, 24, 24 }, -- nose down
[90] = { 0, 48, 24, 24 }, -- board vertical
[135] = { 48, 48, 24, 24 }, -- tumbling
[180] = { 72, 48, 24, 24 }, -- upside down
[225] = { 48, 48, 24, 24 },
[270] = { 0, 48, 24, 24 },
[315] = { 0, 0, 24, 24 }, -- tail down
}
-- surf_1a quads (BG tiles)
local A = {
scallop = { 16, 0, 8, 8 }, -- open-water pattern, row A
scallop2 = { 16, 8, 8, 8 }, -- row B variant
shade = { 8, 16, 8, 8 }, -- gray dither, wave belly
lip = { 24, 0, 8, 8 }, -- foam curl for the crest edge
palm = { 8, 32, 8, 8 }, -- palm fronds
beach = { 24, 32, 16, 8 }, -- black shore silhouette
hut = { 8, 40, 16, 8 }, -- the Pikachu doll hut on the sand
hp = { 20, 40, 20, 8 }, -- "HP:" score label
}
-- SGB-style zones: one sea palette over the frame plus a yellow
-- OBJ-flavored palette tracking Pikachu's tiles (rectangular attribute
-- blocks are all the SGB could do, bleed and all)
local SEA_PAL = { { 255, 255, 255 }, { 112, 184, 248 },
{ 56, 120, 216 }, { 0, 0, 0 } }
local PIKA_PAL = { { 255, 255, 255 }, { 248, 216, 64 },
{ 224, 144, 32 }, { 0, 0, 0 } }
local function newQuad(spec, img)
return love.graphics.newQuad(spec[1], spec[2], spec[3], spec[4],
img:getDimensions())
end
function SurfingMinigame.new(game, onDone)
local self = setmetatable({ game = game, onDone = onDone }, SurfingMinigame)
self.phase = "ride" -- ride | air | wipeout | results
self.t = 0
self.distance = 0
self.speed = 2
self.score = 0
self.rideTick = 0
self.y = 0 -- air offset above the wave (positive = up)
self.vy = 0
self.rot = 0 -- degrees, accumulates through the air
self.spins = 0
self.airFrames = 0
self.resultShown = 0
self.banner = nil -- {quad, frames}: GOOD!/YEAH-/Oh no..
local function sheet(path)
local ok, img = pcall(love.graphics.newImage, path)
return ok and img or nil
end
self.bg = sheet("assets/generated/minigame/surf_1a.png")
self.ob = sheet("assets/generated/minigame/surf_1b.png")
if self.bg then
self.aq = {}
for k, spec in pairs(A) do self.aq[k] = newQuad(spec, self.bg) end
end
if self.ob then
self.bq = {}
for k, spec in pairs(B) do
if spec[3] then self.bq[k] = newQuad(spec, self.ob) end
end
self.bq.paddle = { newQuad(B.paddle[1], self.ob),
newQuad(B.paddle[2], self.ob) }
self.bq.poses = {}
for deg, spec in pairs(POSES) do
self.bq.poses[deg] = newQuad(spec, self.ob)
end
self.bq.digit = {}
for d = 0, 9 do
self.bq.digit[d] = love.graphics.newQuad(B.digits.x + d * 8,
B.digits.y, 8, 8, self.ob:getDimensions())
end
end
Music.play(game.data, "Music_SurfingPikachu")
return self
end
-- crest height at screen x for the current scroll (two sines so the
-- wave rolls instead of looping visibly)
function SurfingMinigame:seaY(x)
local s = self.distance + x
return 92 - 14 * math.sin(s / 26) - 6 * math.sin(s / 9.5)
end
function SurfingMinigame:finishRun()
self.phase = "results"
local save = self.game.save
self.newRecord = self.score > (save.surfingHighScore or 0)
if self.newRecord then save.surfingHighScore = self.score end
Music.stop()
Sound.play(self.game.data, self.newRecord and "Get_Item1" or "Ball_Poof")
end
function SurfingMinigame:update()
local input = self.game.input
self.t = self.t + 1
if self.banner then
self.banner.frames = self.banner.frames - 1
if self.banner.frames <= 0 then self.banner = nil end
end
if self.phase == "results" then
self.resultShown = self.resultShown + 1
if self.resultShown > 30
and (input:wasPressed("a") or input:wasPressed("b")) then
self.game.stack:pop()
if self.onDone then self.onDone(self.score) end
end
return
end
if self.phase == "wipeout" then
self.splash = (self.splash or 0) + 1
if self.splash > 70 then self:finishRun() end
return
end
-- the wave scrolls by the current speed; the beach ends the run
self.distance = self.distance + 0.8 + self.speed * 0.35
if self.distance >= RUN_DISTANCE then
-- rode it all the way in: distance bonus like the original's goal
self.score = self.score + 500
self:finishRun()
return
end
if self.phase == "ride" then
-- paddling: mash A for speed, it bleeds off on its own
if input:wasPressed("a") and self.speed < 8 then
self.speed = self.speed + 1
end
if self.t % 45 == 0 and self.speed > 2 then
self.speed = self.speed - 1
end
self.rideTick = self.rideTick + 1
if self.rideTick % 12 == 0 then self.score = self.score + 1 end
-- launch off the lip
if input:wasPressed("up") then
self.phase = "air"
self.vy = 1.6 + self.speed * 0.45
self.rot, self.spins, self.airFrames = 0, 0, 0
Sound.play(self.game.data, "Ledge_Jump")
end
elseif self.phase == "air" then
self.airFrames = self.airFrames + 1
self.vy = self.vy - GRAVITY
self.y = self.y + self.vy
-- tricks: hold either direction to spin
local spin = (input:isDown("left") and -6 or 0)
+ (input:isDown("right") and 6 or 0)
self.rot = self.rot + spin
if math.abs(self.rot) >= (self.spins + 1) * 360 then
self.spins = self.spins + 1
end
if self.y <= 0 and self.vy < 0 then
self.y = 0
local tilt = math.abs(self.rot) % 360
if tilt <= 60 or tilt >= 300 then
-- clean landing: airtime + full rotations pay out
self.score = self.score + self.spins * 100
+ math.floor(self.airFrames / 4)
self.phase = "ride"
self.banner = { quad = self.spins > 0 and "yeah" or "good",
frames = 50 }
Sound.play(self.game.data, "Cut")
else
self.phase = "wipeout"
self.splash = 0
self.banner = { quad = "ohno", frames = 70 }
Sound.play(self.game.data, "Faint_Fall")
end
end
end
end
-- draw one 8x8 sheet tile quad at x, y
function SurfingMinigame:tile(q, x, y)
love.graphics.draw(self.bg, self.aq[q], x, y)
end
function SurfingMinigame:sgbPalettes()
local P = require("src.render.PaletteFX")
local zones = { P.whole(SEA_PAL) }
if self.phase ~= "wipeout" and self.phase ~= "results" then
local tx = math.floor((PIKA_X - 12) / 8)
local ty = math.floor(math.max(0, self.pikaScreenY or 60) / 8)
zones[#zones + 1] = P.zone(PIKA_PAL, tx, ty, tx + 3, ty + 3)
end
return zones
end
function SurfingMinigame:drawScore(x, y, n)
local s = tostring(n)
for i = 1, #s do
love.graphics.draw(self.ob, self.bq.digit[tonumber(s:sub(i, i))],
x + (i - 1) * 8, y)
end
end
function SurfingMinigame:draw()
local haveSheets = self.bg and self.ob
-- sky
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 0, 0, 160, 144)
if not haveSheets then
-- cache predates the surf sheets: plain shapes keep it playable
love.graphics.setColor(0, 0, 0, 1)
Font.draw(Strings("SCORE %d", self.score), 4, 4)
love.graphics.rectangle("fill", PIKA_X - 8,
self:seaY(PIKA_X) - 16 - self.y, 16, 16)
love.graphics.setColor(1, 1, 1, 1)
return
end
-- cloud in the sky strip
love.graphics.draw(self.ob, self.bq.cloud, 112, 8)
-- open water: the scalloped pattern tiles the whole sea, phase-locked
-- to the scroll so the surface slides
local shift = math.floor(self.distance) % 8
for ty = HORIZON, 136, 8 do
local alt = (ty / 8) % 2 == 0
for tx = -8, 160, 8 do
self:tile(alt and "scallop" or "scallop2", tx - shift, ty)
end
end
-- the wave face: a white patch hugging the ride line (the original
-- carved it with per-scanline scroll; the ellipse stands in), with a
-- few scallops floating inside and the foam lip along its upper edge
local faceY = self:seaY(56) + 10
love.graphics.setColor(1, 1, 1, 1)
love.graphics.ellipse("fill", 56, faceY, 46, 30)
love.graphics.ellipse("fill", 100, faceY + 16, 40, 22)
for _, spot in ipairs({ { 30, 8 }, { 70, 16 }, { 48, 22 } }) do
self:tile("scallop", 56 - 46 + spot[1] - shift, faceY - 24 + spot[2])
end
local pikaY = self:seaY(PIKA_X) - 20 - self.y
for a = 205, 335, 18 do
local r = math.rad(a)
local lx = 56 + math.cos(r) * 44 - 4
local ly = faceY + math.sin(r) * 28 - 4
-- foam that would land inside Pikachu's SGB zone comes out orange;
-- leave that patch to the spray ellipse instead
if math.abs(lx - PIKA_X) > 28 or math.abs(ly - (pikaY + 12)) > 26 then
self:tile("lip", lx, ly)
end
end
self:tile("shade", 92 - shift, faceY + 20)
self:tile("shade", 116 - shift, faceY + 24)
-- beach slides through at the start and again before the goal
local beachX
if self.distance < 160 then
beachX = -self.distance
elseif self.distance > RUN_DISTANCE - 200 then
beachX = 160 - (self.distance - (RUN_DISTANCE - 200))
end
if beachX then
for tx = 0, 32, 8 do
self:tile("beach", beachX + tx, 128)
self:tile("beach", beachX + tx, 136)
end
love.graphics.setColor(0, 0, 0, 1)
love.graphics.rectangle("fill", beachX + 9, 118, 2, 10)
love.graphics.setColor(1, 1, 1, 1)
self:tile("palm", beachX + 6, 112)
self:tile("hut", beachX + 20, 118)
end
-- Pikachu. The white spray patch under him doubles as the yellow SGB
-- zone's backdrop: shade 0 maps to white in both palettes, so the
-- attribute-block bleed never shows on the water pattern.
love.graphics.setColor(1, 1, 1, 1)
local py = self:seaY(PIKA_X) - 20 - self.y
self.pikaScreenY = py -- the yellow SGB zone tracks this
love.graphics.ellipse("fill", PIKA_X, py + 12, 25, 21)
if self.phase == "wipeout" then
love.graphics.draw(self.ob, self.bq.splash, PIKA_X - 16,
self:seaY(PIKA_X) - 16)
else
local quad
if self.phase == "ride" and self.speed <= 2
and self.distance < 120 then
quad = self.bq.paddle[math.floor(self.t / 8) % 2 + 1]
else
local bucket = math.floor(((self.rot % 360) + 22.5) / 45) % 8 * 45
quad = self.bq.poses[bucket] or self.bq.poses[0]
end
love.graphics.draw(self.ob, quad, PIKA_X - 12, py)
end
-- banner beats: GOOD! / YEAH- / Oh no..
if self.banner and self.bq[self.banner.quad] then
love.graphics.draw(self.ob, self.bq[self.banner.quad], 60, 40)
end
-- score strip, bottom right: HP: + sheet digits
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 100, 134, 60, 10)
love.graphics.draw(self.bg, self.aq.hp, 102, 135)
self:drawScore(126, 135, self.score)
if self.phase == "results" then
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 20, 48, 120, 48)
love.graphics.setColor(0, 0, 0, 1)
love.graphics.rectangle("line", 20.5, 48.5, 119, 47)
Font.draw(Strings("SCORE %d", self.score), 32, 56)
if self.newRecord then
Font.draw(Strings("New record!"), 32, 68)
else
Font.draw(Strings("HI %d", self.game.save.surfingHighScore or 0),
32, 68)
end
if self.resultShown > 30 then
Font.draw(Strings("A: done"), 32, 82)
end
love.graphics.setColor(1, 1, 1, 1)
end
end
return SurfingMinigame
+190 -22
View File
@@ -33,11 +33,25 @@ end
function TitleState:sgbPalettes(game)
local P = require("src.render.PaletteFX")
local z = {
local z
if self.yellowLayout then
-- Yellow's BlkPacket_Titlescreen (pokeyellow data/sgb/sgb_packets.asm):
-- rows 0-7 logo band pal 0 (PAL_LOGO2), rows 8-17 Pikachu + copyright
-- pal 2 (PAL_MEWMON), then the two bubble-tail cells at (9,8)-(10,8)
-- back on pal 0. No Red/Blue LOGO1 ribbon band.
local logoPal = P.pal(game.data, "LOGO2")
z = {
P.zone(logoPal, 0, 0, 19, 7),
P.zone(P.pal(game.data, "MEWMON"), 0, 8, 19, 17),
P.zone(logoPal, 9, 8, 10, 8),
}
else
z = {
P.zone(P.pal(game.data, "LOGO2"), 0, 0, 19, 7),
P.zone(withPureWhite(P.pal(game.data, "LOGO1")), 0, 8, 19, 9),
P.zone(P.pal(game.data, "MEWMON"), 0, 10, 19, 17),
}
end
local top = game.stack and game.stack:top()
local box = top and top.titleUiBox
if box then
@@ -61,6 +75,14 @@ local BLUE_CYCLE_SPECIES = {
"VULPIX", "CHANSEY", "AERODACTYL", "JOLTEON", "SNORLAX",
"GLOOM", "POLIWAG", "DODUO", "PORYGON", "GENGAR", "RAICHU",
}
-- Yellow has no TitleMons table (engine/movie/title_yellow.asm is a fixed
-- Pikachu title). Until field.title.cycleSpecies is imported, keep a short
-- Pikachu-centric list so the Red/Blue cycling UI still has something to show.
local YELLOW_CYCLE_SPECIES = {
"PIKACHU", "EEVEE", "BULBASAUR", "CHARMANDER", "SQUIRTLE",
"JIGGLYPUFF", "MEOWTH", "PSYDUCK", "VULPIX", "ABRA",
"GROWLITHE", "CUBONE", "GASTLY", "HITMONLEE", "SNORLAX", "DRAGONITE",
}
local CYCLE_FRAMES = 240 -- the original waits ~4s between picks
local function tryImage(path)
@@ -93,9 +115,36 @@ function TitleState.new(game, opts)
or "assets/generated/title/red_version.png")
self.player = tryImage("assets/generated/title/player.png")
self.blue = GameVersion.isBlue()
-- Blue cycles its own title mons and prints its ribbon contiguously; a
-- field.title.cycleSpecies override (mods / total conversions) still wins.
local defaultCycle = self.blue and BLUE_CYCLE_SPECIES or CYCLE_SPECIES
self.yellow = GameVersion.isYellow()
or title.layout == "yellow_pikachu"
-- Yellow title is a fixed Pikachu composition (title_yellow.asm), not
-- TitleMons cycling. Prefer composed pikachu.png from the Yellow import.
self.yellowPikachu = self.yellow and tryImage(imagePath(title.pikachu)
or "assets/generated/title/pikachu.png") or nil
self.yellowBubble = self.yellow and tryImage(imagePath(title.pikaBubble)
or "assets/generated/title/pika_bubble.png") or nil
self.yellowLayout = self.yellow and self.yellowPikachu ~= nil
if self.yellowLayout then
-- title.asm boot: hSCY starts at $40 with the logo parked above the
-- viewport; .bouncePokemonLogoLoop drops it in with an overshoot
-- bounce, then the whoosh, the speech bubble, and PikachuCry1 before
-- the title music starts. Blink overlays are the OB tile swaps of
-- DoTitleScreenFunction.
self.eyesHalf = tryImage("assets/generated/title/eyes_half.png")
self.eyesClosed = tryImage("assets/generated/title/eyes_closed.png")
self.scy = 0x40
self.phase = "drop"
self.dropStep, self.dropLeft = 1, nil
self.showBubble = false
self.blinkTimer = 0
self.blinkAt = nil
else
self.phase = "loop"
self.showBubble = true
end
local defaultCycle = self.yellowLayout and { "PIKACHU" }
or (self.yellow and YELLOW_CYCLE_SPECIES)
or (self.blue and BLUE_CYCLE_SPECIES or CYCLE_SPECIES)
self.cycleSpecies = (type(title.cycleSpecies) == "table"
and #title.cycleSpecies > 0)
and title.cycleSpecies or defaultCycle
@@ -107,6 +156,14 @@ function TitleState.new(game, opts)
end
function TitleState:enter()
-- Yellow defers the title theme until after the logo drop and
-- Pikachu's cry (title.asm plays MUSIC_TITLE_SCREEN only after
-- WaitForSoundToFinish on PikachuCry1)
if self.yellowLayout then return end
self:startMusic()
end
function TitleState:startMusic()
local data = self.game.data
local song = self.title.music or "Music_TitleScreen"
if data.audio and data.audio.songs and data.audio.songs[song] then
@@ -114,6 +171,83 @@ function TitleState:enter()
end
end
-- .TitleScreenPokemonLogoYScrolls: { dy per frame, frames }; the -3
-- rebound step lands with SFX_INTRO_CRASH
local DROP_STEPS = {
{ -4, 16 }, { 3, 4 }, { -3, 4 }, { 2, 2 }, { -2, 2 }, { 1, 2 }, { -1, 2 },
}
-- the boot cinematic up to the interactive loop; one call per frame
function TitleState:updateSequence()
local Sound = require("src.core.Sound")
local data = self.game.data
if self.phase == "drop" then
local step = DROP_STEPS[self.dropStep]
if not step then
self.phase = "settle"
self.timer = 0
return
end
if self.dropLeft == nil then
self.dropLeft = step[2]
if step[1] == -3 then Sound.play(data, "Intro_Crash") end
end
self.scy = self.scy + step[1]
self.dropLeft = self.dropLeft - 1
if self.dropLeft <= 0 then
self.dropStep = self.dropStep + 1
self.dropLeft = nil
end
elseif self.phase == "settle" then
-- ld c, 36 / DelayFrames, then the whoosh and the bubble
self.timer = self.timer + 1
if self.timer >= 36 then
Sound.play(data, "Intro_Whoosh")
self.showBubble = true
self.phase = "bubble"
self.timer = 0
end
elseif self.phase == "bubble" then
self.timer = self.timer + 1
if self.timer >= 3 then
self.crySrc = Sound.playPikaCry(data, 1)
self.phase = "cry"
self.timer = 0
end
elseif self.phase == "cry" then
-- WaitForSoundToFinish before the music starts
self.timer = self.timer + 1
local playing = self.crySrc and self.crySrc.isPlaying
and self.crySrc:isPlaying()
if not playing or self.timer > 180 then
self.crySrc = nil
self:startMusic()
self.phase = "loop"
self.blinkTimer = 0
end
end
end
-- DoTitleScreenFunction.CheckTimer: an 8-bit frame counter blinks at 0,
-- $80 and $90; the blink itself runs half/closed/half over 9 frames
function TitleState:updateBlink()
local t = self.blinkTimer
self.blinkTimer = (t + 1) % 256
if t == 0 or t == 0x80 or t == 0x90 then self.blinkAt = 0 end
if self.blinkAt then
self.blinkAt = self.blinkAt + 1
if self.blinkAt > 9 then self.blinkAt = nil end
end
end
-- the blink overlay for this frame (nil = open eyes)
function TitleState:blinkOverlay()
local at = self.blinkAt
if not at then return nil end
if at <= 3 or at > 6 then return self.eyesHalf end
return self.eyesClosed
end
function TitleState:currentSprite()
local species = self.cycleSpecies[self.cycleIndex]
local cached = self.sprites[species]
@@ -219,9 +353,26 @@ function TitleState:openMenu()
end
function TitleState:update(dt)
if self.yellowLayout then
if self.phase ~= "loop" then
self:updateSequence()
return -- input is ignored until the cinematic lands (title.asm)
end
self:updateBlink()
local input = self.game.input
if input:wasPressed("start") or input:wasPressed("a") then
-- .go_to_main_menu voices PikachuCry11 on the way out
local Sound = require("src.core.Sound")
if not Sound.playPikaCry(self.game.data, 11) then
Sound.playCry(self.game.data, "PIKACHU")
end
self:openMenu()
end
return
end
self.timer = self.timer + 1
self.blink = (self.blink + 1) % 60
if self.timer >= CYCLE_FRAMES then
if not self.yellowLayout and self.timer >= CYCLE_FRAMES then
self.timer = 0
-- random pick that never repeats the current one
if #self.cycleSpecies > 1 then
@@ -238,9 +389,11 @@ function TitleState:update(dt)
end
local input = self.game.input
if input:wasPressed("start") or input:wasPressed("a") then
-- the title mon cries when you leave the title (.finishedWaiting)
-- the title mon cries when you leave the title (.finishedWaiting);
-- Yellow's fixed Pikachu title always cries Pikachu.
require("src.core.Sound").playCry(self.game.data,
self.cycleSpecies[self.cycleIndex])
self.yellowLayout and "PIKACHU"
or self.cycleSpecies[self.cycleIndex])
self:openMenu()
end
end
@@ -248,28 +401,45 @@ end
-- The original tilemap (engine/movie/title.asm): logo at tile (2,1),
-- the version ribbon at (7,8), Red's title art as OAM at px (82,80),
-- the title mon in the 7x7 box at tile (5,10), copyright on row 17.
-- Yellow (title_yellow.asm): logo (2,1), speech bubble (6,4), Pikachu
-- (4,8) 12x9 — no version ribbon, no cycling mon, no Red OAM.
function TitleState:draw()
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 0, 0, 160, 144)
local scrollY = self.yellowLayout and -(self.scy or 0) or 0
if self.logo then
love.graphics.draw(self.logo, 16, 8)
love.graphics.draw(self.logo, 16, 8 + scrollY)
else
love.graphics.setColor(0, 0, 0, 1)
Font.draw(self.blue and "POKéMON BLUE" or Strings("POKéMON RED"),
(160 - 12 * 8) / 2, 24)
local brand = self.yellow and "POKéMON YELLOW"
or (self.blue and "POKéMON BLUE" or Strings("POKéMON RED"))
Font.draw(brand, (160 - 12 * 8) / 2, 24 + scrollY)
love.graphics.setColor(1, 1, 1, 1)
end
if self.version then
if self.yellowLayout then
-- everything scrolls together through the logo drop (rSCY): screen
-- y = BG y - SCY, so the composition rides at -scy until it lands
local dy = scrollY
if self.yellowBubble and self.showBubble then
love.graphics.draw(self.yellowBubble, 48, 32 + dy)
end
-- hlcoord 4,8 → px (32, 64); composed 13x9 tile sprite
love.graphics.draw(self.yellowPikachu, 32, 64 + dy)
local overlay = self:blinkOverlay()
if overlay then
-- the eye OAM band sits at (56,80) on the landed screen
love.graphics.draw(overlay, 32 + 24, 64 + 16 + dy)
end
else
-- Yellow's Version_GFX slot holds a leftover "Blue Version" ribbon
-- (pokeyellow gfx/title/blue_version.png, unreferenced by title code);
-- the Yellow fallback layout draws no ribbon at all.
if self.version and not self.yellow then
local iw, ih = self.version:getDimensions()
if self.blue then
-- Blue prints its ribbon contiguously ("Blue Version", hlcoord 7,8).
-- The extracted strip packs those eight glyph tiles into image tiles
-- 0..7 (tiles 8..9 are blank), so draw that 64px run at px (56, 64).
love.graphics.draw(self.version,
love.graphics.newQuad(0, 0, 64, 8, iw, ih), 56, 64)
else
-- Red's strip holds Red+Green+Version glyphs; the tilemap prints
-- tiles $60,$61 ("Red"), a space, then $65-$69 ("Version").
love.graphics.draw(self.version,
love.graphics.newQuad(0, 0, 16, 8, iw, ih), 56, 64)
love.graphics.draw(self.version,
@@ -279,19 +449,17 @@ function TitleState:draw()
local sprite = self:currentSprite()
if sprite then
local w, h = sprite:getDimensions()
local slide = (self.slideIn or 0) * 8 -- scroll in from the right
-- bottom-aligned and centered in the (5,10)-(11,16) tile box
local slide = (self.slideIn or 0) * 8
love.graphics.draw(sprite, 40 + math.floor((56 - w) / 2) + slide,
136 - h)
end
-- Red is OAM in the original: he draws over the mon's box edge
if self.player then
love.graphics.draw(self.player, 82, 80)
end
end
love.graphics.setColor(0, 0, 0, 1)
-- the copyright row (tile 2,17); copyrightText because field.title's
-- copyright key already names the extracted image strip
Font.draw(self.title.copyrightText or Strings("2026 bois club games"), 1, 136)
Font.draw(self.title.copyrightText or Strings("2026 bois club games"),
1, 136 + scrollY)
love.graphics.setColor(1, 1, 1, 1)
end
+740
View File
@@ -0,0 +1,740 @@
-- Yellow's boot attract movie, a faithful port of PlayIntroScene
-- (pokeyellow engine/movie/intro_yellow.asm) over the extracted atlases
-- (gfx/intro/yellow_intro_1.2bpp -> intro/yellow_intro_1.png, atlas1,
-- 16x8 tiles; yellow_intro_2.2bpp -> yellow_intro_2.png, atlas2, 16x16
-- tiles; clouds.2bpp -> intro/clouds.png, two 4-tile frames).
--
-- Faithful pieces: the 18-scene jumptable with its 128/88-frame timers,
-- the animated-object system (YellowIntro_AnimatedObjectSpawnStateData /
-- Jumptable / FramesData / OAMData, data/sprite_anims/intro_frames.asm +
-- intro_oam.asm), the scene-7 per-scanline SCY sine wave
-- (YellowIntro_Copy8BitSineWave, +-4px period 32, rotated 1 line/frame),
-- the scene-3 SCX ramp to $68, the scene-11 cloud tile flip every 8
-- frames, and the scene-14/15/16 BGP strobe / fade sequences
-- (YellowIntroPalSequence_f9dd6 / _f9e0a). BGP composes with the SGB
-- colorization through sgbPalettes (PalPacket_Generic = MEWMON,
-- PalPacket_PikachusBeach = PIKACHUS_BEACH), like the title screen.
--
-- Deliberately dropped: the CGB-only OBJ-palette pokes of scenes 7/11
-- (Func_f98a2 / Func_f98cb recolor 5-6 tiles of the surf/fly sprite),
-- OBP-vs-BGP divergence during the strobes (one whole-screen shade map
-- stands in for both), and the never-spawned objects $0/$4 (dead code,
-- intro_yellow.asm:173).
--
-- Any of A/B/START skips the whole movie (PlayIntroScene:16-19). Pops
-- itself and calls onDone() when finished or skipped.
local Music = require("src.core.Music")
local YellowIntro = {}
YellowIntro.__index = YellowIntro
YellowIntro.isOpaque = true
-- ------- data tables (data/sprite_anims/intro_oam.asm) ----------------
-- OAM lists: rows of { dy, dx, tileDelta, flip }
local function grid(rows, cols, dy0, dx0, tileForRC)
local list = {}
for r = 0, rows - 1 do
for c = 0, cols - 1 do
list[#list + 1] = { dy0 + r * 8, dx0 + c * 8, tileForRC(r, c), false }
end
end
return list
end
local OAM = {}
-- Unkn_fa17e: 2x2, tiles +0/+1 over +$10/+$11
OAM.fa17e = grid(2, 2, -8, -8, function(r, c) return r * 0x10 + c end)
-- Unkn_fa18f: 16x32; bottom two rows mirror their left half
OAM.fa18f = {
{ -16, -8, 0x00 }, { -16, 0, 0x01 },
{ -8, -8, 0x10 }, { -8, 0, 0x11 },
{ 0, -8, 0x20 }, { 0, 0, 0x20, true },
{ 8, -8, 0x21 }, { 8, 0, 0x21, true },
}
-- Unkn_fa1b0: 32x40 (16-wide head rows, 32-wide mirrored body rows)
OAM.fa1b0 = {
{ -24, -8, 0x00 }, { -24, 0, 0x01 },
{ -16, -8, 0x02 }, { -16, 0, 0x03 },
{ -8, -16, 0x04 }, { -8, -8, 0x05 }, { -8, 0, 0x06 }, { -8, 8, 0x04, true },
{ 0, -16, 0x07 }, { 0, -8, 0x08 }, { 0, 0, 0x08, true }, { 0, 8, 0x07, true },
{ 8, -16, 0x09 }, { 8, -8, 0x0a }, { 8, 0, 0x0a, true }, { 8, 8, 0x09, true },
{ 16, -16, 0x0b }, { 16, -8, 0x0c }, { 16, 0, 0x0c, true }, { 16, 8, 0x0b, true },
}
-- Unkn_fa201: 6x6 = 48x48, row r uses tiles +$r0..+$r5
OAM.fa201 = grid(6, 6, -24, -24, function(r, c) return r * 0x10 + c end)
-- Unkn_fa292: 5x5 = 40x40, row bases $00,$05,$10,$15,$20
local FA292_ROW = { 0x00, 0x05, 0x10, 0x15, 0x20 }
OAM.fa292 = {}
for r = 0, 4 do
for c = 0, 4 do
OAM.fa292[#OAM.fa292 + 1] =
{ -20 + r * 8, -16 + c * 8, FA292_ROW[r + 1] + c, false }
end
end
-- Unkn_fa2f7: 32x8 mirrored streak
OAM.fa2f7 = {
{ -4, -16, 0x00 }, { -4, -8, 0x01 },
{ -4, 0, 0x01, true }, { -4, 8, 0x00, true },
}
-- Unkn_fa308: two mirrored 16x16 clusters, 32px apart
OAM.fa308 = {
{ -8, -24, 0x00 }, { -8, -16, 0x01 },
{ 0, -24, 0x02 }, { 0, -16, 0x03 },
{ -8, 8, 0x01, true }, { -8, 16, 0x00, true },
{ 0, 8, 0x03, true }, { 0, 16, 0x02, true },
}
-- Unkn_fa329: two mirrored 24x16 clusters
OAM.fa329 = {
{ -8, -40, 0x00 }, { -8, -32, 0x01 }, { -8, -24, 0x02 },
{ 0, -40, 0x10 }, { 0, -32, 0x11 }, { 0, -24, 0x12 },
{ -8, 16, 0x02, true }, { -8, 24, 0x01, true }, { -8, 32, 0x00, true },
{ 0, 16, 0x12, true }, { 0, 24, 0x11, true }, { 0, 32, 0x10, true },
}
-- frameId -> { atlas2 tile offset, OAM list }
local FRAMES = {
[0x01] = { 0x96, OAM.fa17e }, [0x02] = { 0x98, OAM.fa17e },
[0x03] = { 0x9a, OAM.fa17e },
[0x04] = { 0x0c, OAM.fa18f }, [0x05] = { 0x0e, OAM.fa18f },
[0x06] = { 0x3c, OAM.fa18f },
[0x07] = { 0x60, OAM.fa1b0 }, [0x08] = { 0x70, OAM.fa1b0 },
[0x09] = { 0x80, OAM.fa1b0 },
[0x0a] = { 0x90, OAM.fa201 }, [0x0b] = { 0x00, OAM.fa201 },
[0x0c] = { 0x06, OAM.fa201 },
[0x0d] = { 0xc6, OAM.fa292 },
[0x0e] = { 0x6d, OAM.fa2f7 },
[0x0f] = { 0xf0, OAM.fa308 }, [0x10] = { 0xf4, OAM.fa308 },
[0x11] = { 0xf8, OAM.fa308 },
[0x12] = { 0x9c, OAM.fa329 }, [0x13] = { 0xec, OAM.fa329 },
}
-- frame scripts (intro_frames.asm): { {frameId, duration}, ..., loop=bool }
local FRAMESETS = {
[1] = { { 0x01, 4 }, { 0x02, 4 }, { 0x03, 4 }, loop = true },
[2] = { { 0x04, 4 }, { 0x05, 4 }, { 0x06, 4 }, loop = true },
[3] = { { 0x07, 4 }, { 0x08, 4 }, { 0x09, 4 }, loop = true },
[5] = { { 0x0b, 32 } },
[6] = { { 0x0c, 32 } },
[7] = { { 0x0d, 32 } },
[8] = { { 0x0e, 32 } },
[9] = { { 0x0f, 31 }, { 0x11, 2 }, { 0x0f, 2 }, { 0x11, 2 },
{ 0x0f, 31 }, { 0x11, 2 }, { 0x0f, 23 }, { 0x10, 32 } },
[10] = { { 0x12, 4 }, { 0x13, 4 }, loop = true },
}
-- object id -> { frameset, seq } (YellowIntro_AnimatedObjectSpawnStateData;
-- seq indexes the movement jumptable)
local SPAWN = {
[1] = { 1, "static" }, [2] = { 2, "static" }, [3] = { 3, "static" },
[5] = { 5, "surf" }, [6] = { 6, "fly" }, [7] = { 7, "static" },
[8] = { 8, "bar" }, [9] = { 9, "static" }, [10] = { 10, "static" },
}
-- speed-bar spawn rows (YellowIntroFlyingSpeedBarData; first byte is X --
-- the source's "; y, x, speed" comment is wrong)
local SPEED_BARS = {
{ 0xD0, 0x20, 2 }, { 0xF0, 0x30, 4 }, { 0xD0, 0x40, 6 },
{ 0xC0, 0x50, 8 }, { 0xE0, 0x60, 8 }, { 0xC0, 0x70, 6 },
{ 0xE0, 0x80, 4 }, { 0xF0, 0x90, 2 },
}
-- scene-6 sine (YellowIntro_Copy8BitSineWave.SineWave), signed SCY deltas
local WAVE = { 0, 0, 1, 2, 2, 3, 3, 3, 4, 3, 3, 3, 2, 2, 1, 0,
0, 0, -1, -2, -2, -3, -3, -3, -4, -3, -3, -3, -2, -2, -1, 0 }
-- scene-10 BG tilemaps (gfx/intro/unknown_f9b6e/f9be6/f9bf2.tilemap)
local SKY_MAP = {
{ 0x02,0x02,0x02,0x02,0x02,0x02,0x02,0x02,0x02,0x02,0x02,0x02,0x02,0x02,0x02,0x02,0x02,0x60,0x61,0x62 },
{ 0x02,0x02,0x02,0x02,0x02,0x02,0x02,0x02,0x02,0x02,0x02,0x02,0x02,0x02,0x02,0x02,0x62,0x00,0x00,0x00 },
{ 0x02,0x02,0x02,0x02,0x02,0x02,0x02,0x02,0x02,0x02,0x02,0x02,0x02,0x02,0x02,0x62,0x00,0x00,0x00,0x00 },
{ 0x60,0x61,0x02,0x02,0x02,0x02,0x02,0x02,0x02,0x02,0x02,0x02,0x02,0x02,0x62,0x00,0x00,0x00,0x00,0x00 },
{ 0x00,0x00,0x63,0x60,0x61,0x60,0x61,0x02,0x02,0x02,0x02,0x60,0x61,0x62,0x00,0x00,0x00,0x00,0x00,0x00 },
{ 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x63,0x62,0x63,0x62,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00 },
}
local BADGE_MAP = {
{ 0x30, 0x31, 0x32, 0x33 }, { 0x40, 0x41, 0x42, 0x43 },
{ 0x50, 0x51, 0x52, 0x53 },
}
local MARK_MAP = { { 0x12, 0x13 }, { 0x22, 0x23 } }
-- scene-14 strobe (YellowIntroPalSequence_f9dd6): 13 groups of
-- $e4,$c0,$c0,$e4 with the 52nd byte replaced by the terminator
local STROBE_SEQ = {}
for i = 1, 51 do
local m = (i - 1) % 4
STROBE_SEQ[i] = (m == 1 or m == 2) and 0xC0 or 0xE4
end
-- scene-16 fade to white (YellowIntroPalSequence_f9e0a)
local FADE_SEQ = { 0xE4, 0x90, 0x90, 0x40, 0x40, 0x00, 0x00 }
-- Func_fa079's sine bob (Unkn_fa0aa, sine_table 32). The ROM table's
-- `dw sin(x)` truncates the 1.0 peak to $0000, which pops the sprite 8px
-- for one frame at every crest (a=16 / a=48) on hardware; the true peak
-- is restored here so the balloon glide loops smoothly.
local function bobOffset(phase)
local a = phase % 64
local half = a % 32
local v = math.floor(8 * math.sin(math.pi * half / 32))
return a < 32 and v or -v
end
local function tryImage(path)
local ok, img = pcall(love.graphics.newImage, path)
return ok and img or nil
end
-- ------- state --------------------------------------------------------
function YellowIntro.new(game, onDone)
local self = setmetatable({}, YellowIntro)
self.game = game
self.onDone = onDone
self.finished = false
self.scene = 0
self.timer = 0
self.seqIndex = 0
self.scx = 0
self.bgp = 0xE4
self.palName = "MEWMON" -- PalPacket_Generic
self.objects = {}
self.cloudFrame = 0
self.atlas1 = tryImage("assets/generated/intro/yellow_intro_1.png")
self.atlas2 = tryImage("assets/generated/intro/yellow_intro_2.png")
self.clouds = tryImage("assets/generated/intro/clouds.png")
self.quads = {}
-- 32x32 BG tile grid (vBGMap0); signed addressing: id < $80 -> atlas1,
-- id >= $80 -> atlas2 (LCDC $e3, bit4 = 0)
self.bg = {}
self:bgLetterbox()
self.bgDirty = true
self.wave = nil
local ok, canvas = pcall(love.graphics.newCanvas, 256, 256)
self.bgCanvas = ok and canvas or nil
-- Yellow boots exactly like Red up to the attract movie: the copyright
-- card and the GAME FREAK shooting-star splash play first. Reuse
-- IntroMovie's phases 1-2 and take over where its Gengar fight (phase
-- 3) would begin; a skip press during the pre-roll skips everything.
local IntroMovie = require("src.ui.IntroMovie")
local pre = IntroMovie.new(game, nil)
local baseStart = pre.startPhase
pre.finish = function(m)
if m.finished then return end
m.finished = true
self.pre = nil
self:finish()
end
pre.startPhase = function(m, phase)
if phase == 3 then
m.finished = true
self.pre = nil
self:beginScenes()
else
baseStart(m, phase)
end
end
self.pre = pre
return self
end
function YellowIntro:sgbPalettes(game)
if self.pre then return self.pre:sgbPalettes(game) end
local P = require("src.render.PaletteFX")
local pal = P.pal(game.data, self.palName)
if not pal then return nil end
-- rBGP composed with the SGB colors: shade i displays palette color
-- ((bgp >> 2i) & 3), whole screen (one map stands in for BGP and OBP)
local bgp = self.bgp
local map = {}
for i = 0, 3 do
map[i] = math.floor(bgp / 4 ^ i) % 4
end
return { P.whole(P.permute(pal, map)) }
end
-- ------- BG helpers ---------------------------------------------------
function YellowIntro:bgFill(id)
for y = 0, 31 do
local row = self.bg[y] or {}
self.bg[y] = row
for x = 0, 31 do row[x] = id end
end
self.bgDirty = true
end
-- Func_f9e5f: rows 0-3 / 14-17 tile $01, rows 4-13 tile $00
function YellowIntro:bgLetterbox()
self:bgFill(0x01)
for y = 4, 13 do
for x = 0, 31 do self.bg[y][x] = 0x00 end
end
for y = 18, 31 do
for x = 0, 31 do self.bg[y][x] = 0x00 end
end
self.bgDirty = true
end
function YellowIntro:bgBlit(col, row, map)
for r, line in ipairs(map) do
for c, id in ipairs(line) do
self.bg[(row + r - 1) % 32][(col + c - 1) % 32] = id
end
end
self.bgDirty = true
end
function YellowIntro:quadFor(image, tile)
local key = image
local cacheByImage = self.quads[key]
if not cacheByImage then
cacheByImage = {}
self.quads[key] = cacheByImage
end
local quad = cacheByImage[tile]
if not quad then
local iw, ih = image:getDimensions()
quad = love.graphics.newQuad(
(tile % 16) * 8, math.floor(tile / 16) * 8, 8, 8, iw, ih)
cacheByImage[tile] = quad
end
return quad
end
function YellowIntro:rebuildBgCanvas()
if not self.bgCanvas then return end
love.graphics.push("all")
love.graphics.setCanvas(self.bgCanvas)
love.graphics.clear(1, 1, 1, 1)
love.graphics.setColor(1, 1, 1, 1)
for y = 0, 31 do
for x = 0, 31 do
local id = self.bg[y][x]
local image, tile
if id < 0x80 then
image, tile = self.atlas1, id
else
image, tile = self.atlas2, id
end
if image then
-- scene-11 cloud animation retargets BG tiles $60-$63 at the
-- clouds sheet (VBlank copy to $9600); frame = clouds row 0/1
if self.clouds and id >= 0x60 and id <= 0x63 then
local cw, ch = self.clouds:getDimensions()
love.graphics.draw(self.clouds,
love.graphics.newQuad((id - 0x60) * 8, self.cloudFrame * 8,
8, 8, cw, ch), x * 8, y * 8)
else
love.graphics.draw(image, self:quadFor(image, tile), x * 8, y * 8)
end
end
end
end
love.graphics.pop()
self.bgDirty = false
end
-- ------- objects ------------------------------------------------------
function YellowIntro:spawn(id, x, y)
local spec = SPAWN[id]
local obj = {
id = id, frameset = spec[1], seq = spec[2],
x = x, y = y, xoff = 0, yoff = 0,
step = 1, wait = 0, held = false,
fieldB = 0, fieldC = 0,
}
local script = FRAMESETS[obj.frameset]
obj.wait = script[1][2]
self.objects[#self.objects + 1] = obj
return obj
end
function YellowIntro:clearObjects()
self.objects = {}
end
local function updateFrameScript(obj)
if obj.held then return end
obj.wait = obj.wait - 1
if obj.wait > 0 then return end
local script = FRAMESETS[obj.frameset]
if obj.step >= #script then
if script.loop then
obj.step = 1
obj.wait = script[1][2]
else
obj.held = true -- endanim: hold last frame forever
end
return
end
obj.step = obj.step + 1
obj.wait = script[obj.step][2]
end
function YellowIntro:updateObjects()
for _, obj in ipairs(self.objects) do
if obj.seq == "bar" then
-- Func_fa062: constant velocity, 8-bit wrap
obj.x = (obj.x + obj.fieldB) % 256
elseif obj.seq == "surf" then
-- Func_fa014, including the original's Y = X + 1 quirk: the
-- comparison register still holds X when Y is written, so the
-- sprite rides a 45-degree diagonal until X parks at $58
if obj.x ~= 0x58 then
obj.x = (obj.x + 4) % 256
obj.y = (obj.x + 1) % 256
end
elseif obj.seq == "fly" then
-- Func_fa02b: rise 2px/frame to Y=$58, then a +-8px sine bob
-- with a 64-frame period (and the truncated-peak notch)
if obj.fieldB == 0 then
if obj.y ~= 0x58 then
obj.y = (obj.y - 2) % 256
else
obj.fieldB = 1
end
end
if obj.fieldB == 1 then
obj.yoff = bobOffset(obj.fieldC)
obj.fieldC = obj.fieldC + 1
end
end
updateFrameScript(obj)
end
end
function YellowIntro:drawObjects()
if not self.atlas2 then return end
for _, obj in ipairs(self.objects) do
local frameId = FRAMESETS[obj.frameset][obj.step][1]
local frame = FRAMES[frameId]
if frame then
local base, list = frame[1], frame[2]
for _, entry in ipairs(list) do
local dy, dx, delta, flip = entry[1], entry[2], entry[3], entry[4]
-- OAM position: screen = (X + dx - 8, Y + dy - 16)
local px = (obj.x + obj.xoff + dx - 8) % 256
local py = (obj.y + obj.yoff + dy - 16) % 256
if px < 160 and py < 144 then
local quad = self:quadFor(self.atlas2, base + delta)
if flip then
love.graphics.draw(self.atlas2, quad, px + 8, py, 0, -1, 1)
else
love.graphics.draw(self.atlas2, quad, px, py)
end
end
end
end
end
end
-- ------- scenes -------------------------------------------------------
-- setup scenes run once and advance immediately; wait scenes count their
-- timer down (YellowIntro_CheckFrameTimerDecrement: N running frames,
-- expiry actions on frame N+1)
function YellowIntro:startScene(scene)
self.scene = scene
local t = self
if scene == 0 then
-- running pika 1 over the boot letterbox
t.palName = "MEWMON"
t.scx = 0
t:bgLetterbox()
t:spawn(1, 0x58, 0x58)
t.timer = 130
t.scene = 1
elseif scene == 2 then
-- pikachu kick: 6x6 atlas2 block parked at BG col 20 row 6 (scrolled
-- in by scene 3) + 8 speed bars
t:bgFill(0x00)
local block = {}
for r = 0, 5 do
local line = {}
for c = 0, 5 do line[c + 1] = 0x90 + r * 0x10 + c end
block[r + 1] = line
end
t:bgBlit(20, 6, block)
for _, bar in ipairs(SPEED_BARS) do
local obj = t:spawn(8, bar[1], bar[2])
obj.fieldB = bar[3]
end
t.palName = "PIKACHUS_BEACH"
t.timer = 128
t.scene = 3
elseif scene == 4 then
-- running pika 2
t:clearObjects()
t.scx = 0
t:bgLetterbox()
t:spawn(2, 0x58, 0x58)
t.palName = "MEWMON"
t.timer = 128
t.scene = 5
elseif scene == 6 then
-- surfing pika over the wavy sea (per-scanline SCY sine)
t.scx = 0
t.wave = {}
for i = 0, 255 do t.wave[i] = WAVE[i % 32 + 1] end
t:bgFill(0x10)
for y = 0, 2 do
for x = 0, 31 do t.bg[y][x] = 0x00 end
end
for x = 0, 31 do t.bg[3][x] = x % 2 == 0 and 0x20 or 0x21 end
t:spawn(5, 0xF8, 0x40)
t.palName = "PIKACHUS_BEACH"
t.bgDirty = true
t.timer = 88
t.scene = 7
elseif scene == 8 then
-- running pika 3
t:clearObjects()
t.wave = nil
t.scx = 0
t:bgLetterbox()
t:spawn(3, 0x58, 0x58)
t.palName = "MEWMON"
t.timer = 128
t.scene = 9
elseif scene == 10 then
-- flying pika over clouds + badge + mark
t:clearObjects()
t.scx = 0
t:bgFill(0x00)
for y = 0, 7 do
for x = 0, 31 do t.bg[y][x] = 0x02 end
end
t:bgBlit(0, 8, SKY_MAP)
t:bgBlit(12, 4, BADGE_MAP)
t:bgBlit(3, 7, MARK_MAP)
t:spawn(6, 0x58, 0x98)
t.palName = "PIKACHUS_BEACH"
t.timer = 128
t.scene = 11
elseif scene == 12 then
-- pika close-up: 12x8 atlas1 paste at BG (5,6) + fixups
t:clearObjects()
t.scx = 0
t:bgLetterbox()
local paste = {}
for r = 0, 7 do
local line = {}
for c = 0, 11 do line[c + 1] = 0x04 + r * 0x10 + c end
paste[r + 1] = line
end
t:bgBlit(5, 6, paste)
t.bg[6][4] = 0x03
t.bg[7][4] = 0x74
t.bg[13][5] = 0x00
t:spawn(9, 0x58, 0x60)
t.palName = "MEWMON"
t.timer = 128
t.scene = 13
elseif scene == 14 then
-- thunderbolt strobe; timer reused as the sequence index
t.seqIndex = 0
t.scene = 14
elseif scene == 15 then
t.timer = 40
t.scene = 15
elseif scene == 16 then
t.seqIndex = 0
t.scene = 16
elseif scene == 17 then
t.timer = 64
t.scene = 17
end
end
function YellowIntro:enter()
if self.pre then return end -- pre-roll first; beginScenes takes over
self:beginScenes()
end
-- InitYellowIntroGFXAndMusic: the movie's own music starts with scene 0
function YellowIntro:beginScenes()
local data = self.game.data
local songs = data.audio and data.audio.songs
local song = songs and (songs.Music_YellowIntro and "Music_YellowIntro"
or songs.Music_IntroBattle and "Music_IntroBattle")
if song then pcall(Music.play, data, song, false) end
self:startScene(0)
end
function YellowIntro:finish()
if self.finished then return end
self.finished = true
pcall(Music.stop)
self.game.stack:pop()
if self.onDone then self.onDone() end
end
function YellowIntro:update(dt)
if self.finished then return end
if self.pre then
self.pre:update(dt)
return
end
local input = self.game.input
if input:wasPressed("a") or input:wasPressed("b")
or input:wasPressed("start") then
self:finish()
return
end
local scene = self.scene
if scene == 1 or scene == 5 or scene == 9 then
if self.timer > 0 then
self.timer = self.timer - 1
else
self:clearObjects()
self:startScene(scene + 1)
end
elseif scene == 3 then
if self.timer > 0 then
self.timer = self.timer - 1
if self.scx ~= 0x68 then self.scx = self.scx + 4 end
else
self:clearObjects()
self:startScene(4)
end
elseif scene == 7 then
if self.timer > 0 then
self.timer = self.timer - 1
self.scx = (self.scx + 2) % 256
-- rotate the sine phase 1 scanline per frame
local first = self.wave[0]
for i = 0, 254 do self.wave[i] = self.wave[i + 1] end
self.wave[255] = first
else
self:clearObjects()
self:startScene(8)
end
elseif scene == 11 then
if self.timer > 0 then
-- cloud tiles swap every 8 frames (YellowIntroScene11)
if self.timer % 8 == 0 then
local frame = math.floor(self.timer / 8) % 2
if frame ~= self.cloudFrame then
self.cloudFrame = frame
self.bgDirty = true
end
end
self.timer = self.timer - 1
else
self:clearObjects()
self:startScene(12)
end
elseif scene == 13 then
if self.timer > 0 then
self.timer = self.timer - 1
else
-- spawn the thunderbolt over the close-up (object $A stays with $9)
self:spawn(10, 0x58, 0x68)
self:startScene(14)
end
elseif scene == 14 then
self.seqIndex = self.seqIndex + 1
local v = STROBE_SEQ[self.seqIndex]
if v then
self.bgp = v
else
-- .expired: everything despawns, letterbox returns, logo/face
-- object $7 appears for the strobe scene
self:clearObjects()
self:bgLetterbox()
self.bgp = 0xE4
self:spawn(7, 0x58, 0x58)
self:startScene(15)
end
elseif scene == 15 then
if self.timer > 0 then
if self.timer % 4 == 0 then
-- rBGP ^= $03: flips how the two lightest shades display
self.bgp = self.bgp == 0xE4 and 0xE7 or 0xE4
end
self.timer = self.timer - 1
else
self.bgp = 0xE4
self:startScene(16)
end
elseif scene == 16 then
self.seqIndex = self.seqIndex + 1
local v = FADE_SEQ[self.seqIndex]
if v then
self.bgp = v
else
self:startScene(17)
end
elseif scene == 17 then
if self.timer > 0 then
self.timer = self.timer - 1
else
self:finish()
return
end
end
self:updateObjects()
if self.bgDirty then self:rebuildBgCanvas() end
end
function YellowIntro:draw()
if self.pre then
self.pre:draw()
return
end
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 0, 0, 160, 144)
if self.bgCanvas then
if self.wave then
-- per-scanline SCY override, LY $10-$7F only (scene 7's VBlank
-- copy covers just that band; the rest render unshifted). Each
-- strip wraps horizontally like the BG map: SCX climbs past 96
-- during the scene and a single quad would clamp at the canvas
-- edge and smear the right of the screen.
local cw, ch = self.bgCanvas:getDimensions()
local sx = self.scx % 256
local w1 = math.min(160, 256 - sx)
for ly = 0, 143 do
local dy = (ly >= 16 and ly < 128) and self.wave[ly] or 0
local sy = (ly + dy) % 256
love.graphics.draw(self.bgCanvas,
love.graphics.newQuad(sx, sy, w1, 1, cw, ch), 0, ly)
if w1 < 160 then
love.graphics.draw(self.bgCanvas,
love.graphics.newQuad(0, sy, 160 - w1, 1, cw, ch), w1, ly)
end
end
else
local cw, ch = self.bgCanvas:getDimensions()
local sx = self.scx % 256
love.graphics.draw(self.bgCanvas,
love.graphics.newQuad(sx, 0, math.min(160, 256 - sx), 144, cw, ch),
0, 0)
if sx > 96 then
-- horizontal wrap (scene 3 scrolls the kick block in from col 20)
love.graphics.draw(self.bgCanvas,
love.graphics.newQuad(0, 0, 160 - (256 - sx), 144, cw, ch),
256 - sx, 0)
end
end
end
self:drawObjects()
love.graphics.setColor(1, 1, 1, 1)
end
return YellowIntro
+3 -1
View File
@@ -15,9 +15,11 @@ end
-- entities: array of anything with cellX/cellY (and optional targetX/targetY
-- while mid-step, so nobody walks into a cell being entered).
-- e.passable entities never block (Yellow's companion Pikachu: the player
-- walks straight through and it re-trails, pikachu_follow.asm).
function Collision.occupied(entities, cx, cy, ignore)
for _, e in ipairs(entities) do
if e ~= ignore then
if e ~= ignore and not e.passable then
if (e.cellX == cx and e.cellY == cy) or
(e.targetX == cx and e.targetY == cy) then
return e
+19 -2
View File
@@ -343,6 +343,9 @@ function OverworldState:setMap(mapId, x, y, facing, opts)
self.pendingSeamMusic = nil
self.entities = { self.player }
for _, n in ipairs(self.npcs) do table.insert(self.entities, n) end
-- Yellow's companion Pikachu trails the player (never in
-- self.entities: it does not block movement, pikachu_follow.asm)
require("src.world.PikachuFollower").onMapEntered(Game, self)
-- opts.keepMusic: the Oak-escort warp keeps MUSIC_MEET_PROF_OAK
-- playing into the lab (BIT_NO_MAP_MUSIC in wStatusFlags7);
@@ -870,6 +873,7 @@ function OverworldState:update(dt)
for _, npc in ipairs(self.npcs) do
npc:update(self.map, self.entities)
end
require("src.world.PikachuFollower").update(Game, self)
for _, g in ipairs(self.ghosts) do
g.npc:update(g.map, g.peers)
@@ -1494,8 +1498,13 @@ function OverworldState:interact()
end
if npc then
if not npc.moving then
if npc.pikachuFollower then
-- the companion answers directly (TalkToPikachu), no map text id
require("src.world.PikachuFollower").talk(Game, self, npc)
else
self:talkTo(npc)
end
end
interacted(self, fx, fy, "npc", npc)
return
end
@@ -2431,7 +2440,7 @@ end
-- Prof. Oak's dex rating service (engine/events/pokedex_rating.asm):
-- the completion line with seen AND owned counts, then the per-decade
-- rating text.
function OverworldState:dexRating()
function OverworldState:dexRating(onDone)
require("src.core.Sound").play(Game.data, "Pokedex_Rating")
local seen, owned = 0, 0
for _ in pairs(Game.save.pokedex.seen or {}) do seen = seen + 1 end
@@ -2449,7 +2458,7 @@ function OverworldState:dexRating()
completion = completion
:gsub("{NUM:hDexRatingNumMonsSeen[^}]*}", tostring(seen))
:gsub("{NUM:hDexRatingNumMonsOwned[^}]*}", tostring(owned))
Game.stack:push(TextBox.new(Game, completion .. "\f" .. rating))
Game.stack:push(TextBox.new(Game, completion .. "\f" .. rating, onDone))
end
-- AnimateHealingMachine (engine/overworld/healing_machine.asm): balls
@@ -2874,6 +2883,9 @@ function OverworldState:applyFieldPoison()
mon.hp = 0
mon.status = nil -- the original clears status on the faint
table.insert(fainted, mon)
-- callfar_ModifyPikachuHappiness PIKAHAPPY_PSNFNT (poison.asm)
require("src.world.PikachuFollower")
.modifyHappiness(save, "PSNFNT", mon)
end
end
end
@@ -2942,6 +2954,8 @@ end
function OverworldState:onStepComplete()
local p = self.player
self.todSteps = (self.todSteps or 0) + 1
-- UpdatePikachuHappinessAndMood rides the step counter (poison.asm)
require("src.world.PikachuFollower").onStep(Game.save)
-- re-evaluate day/night so a step-based clock can fire world.tod_changed;
-- paletteNameFor reads self.tod on the next paint
if Runtime.wantsHook("world.tod") then
@@ -4032,6 +4046,9 @@ function OverworldState:drawWorld()
-- the "!" bubble above a trainer who spotted the player
local function fxEmote()
if not (self.emote and self.emote.npc) then return end
-- bubble = false is a silent hold (a Pikachu emotion that plays a
-- cry with no bubble still pauses the world for its beat)
if self.emote.bubble == false then return end
local npc = self.emote.npc
local ex = npc.px - cam.x + 4
local ey = npc.py - cam.y - 14
+375
View File
@@ -0,0 +1,375 @@
-- Yellow's overworld companion Pikachu (pokeyellow engine/pikachu/
-- pikachu_follow.asm ShouldPikachuSpawn / SpawnPikachu_, plus the
-- talk-to-it mood beat of engine/pikachu/pikachu_emotions.asm
-- TalkToPikachu). The follower is an NPC-shaped entity that lives in
-- ow.npcs (so the standard update/draw walk cycle runs) but never in
-- ow.entities -- like the original it does not block the player: walk
-- onto its cell and it simply trails to the cell you vacated.
--
-- Happiness rides in save.pikachuHappiness (wPikachuHappiness, seeded 90
-- by init_player_data.asm) and mood in save.pikachuMood (wPikachuMood,
-- neutral 128). modifyHappiness below is the full ModifyPikachuHappiness
-- port (engine/events/pikachu_happiness.asm): the HappinessChangeTable
-- delta picked by the current happiness hundred-band, then the
-- PikachuMoods byte nudging the mood; onStep is poison.asm's
-- UpdatePikachuHappinessAndMood (256-step coin-flip WALKING bump, mood
-- converging by 1 per step toward 128).
local GameVersion = require("src.core.GameVersion")
local PikachuFollower = {}
local INDEX = 99 -- synthetic object index, clear of any map's real objects
local OPPOSITE = { up = "down", down = "up", left = "right", right = "left" }
-- wPikachuHappiness boot value (engine/movie/oak_speech/
-- init_player_data.asm: happiness = 90)
local function happiness(save)
if save.pikachuHappiness == nil then save.pikachuHappiness = 90 end
return save.pikachuHappiness
end
function PikachuFollower.bumpHappiness(save, delta)
save.pikachuHappiness =
math.max(0, math.min(255, happiness(save) + delta))
end
-- HappinessChangeTable (engine/events/pikachu_happiness.asm): delta by
-- happiness band (<100 / <200 / rest), plus the PikachuMoods target byte
-- ($80 leaves the mood alone). Keys mirror the PIKAHAPPY_* constants.
local HAPPINESS_CHANGES = {
LEVELUP = { 5, 3, 2, mood = 0x8a },
USEDITEM = { 5, 3, 2, mood = 0x83 },
USEDXITEM = { 1, 1, 0, mood = 0x80 },
GYMLEADER = { 3, 2, 1, mood = 0x80 },
USEDTMHM = { 1, 1, 0, mood = 0x94 },
WALKING = { 2, 1, 1, mood = 0x80 },
DEPOSITED = { -3, -3, -5, mood = 0x62 },
FAINTED = { -1, -1, -1, mood = 0x6c },
PSNFNT = { -5, -5, -10, mood = 0x62 },
CARELESSTRAINER = { -5, -5, -10, mood = 0x6c },
TRADE = { -10, -10, -20, mood = 0x00 },
}
-- the companion mon: a healthy (or any) party PIKACHU stands in for the
-- original's OT-checked starter, same approximation as shouldSpawn
function PikachuFollower.starterInParty(save, needHealthy)
for _, mon in ipairs(save.party or {}) do
if mon.species == "PIKACHU"
and (not needHealthy or (mon.hp or 0) > 0) then
return mon
end
end
return nil
end
-- ModifyPikachuHappiness. mon is the party mon the event applied to for
-- the per-mon reasons (IsThisPartyMonStarterPikachu); GYMLEADER and
-- WALKING instead require any healthy starter in the party
-- (IsStarterPikachuAliveInOurParty).
function PikachuFollower.modifyHappiness(save, reason, mon)
if not GameVersion.isYellow() then return end
local row = HAPPINESS_CHANGES[reason]
if not row then return end
if reason == "GYMLEADER" or reason == "WALKING" then
if not PikachuFollower.starterInParty(save, true) then return end
elseif not (mon and mon.species == "PIKACHU") then
return
end
local h = happiness(save)
local band = h < 100 and 1 or h < 200 and 2 or 3
save.pikachuHappiness = math.max(0, math.min(255, h + row[band]))
-- PikachuMoods: bytes above $80 only ever raise the mood (and defer to
-- a pending scripted emotion modifier), bytes below only lower it
local b = row.mood
if b ~= 0x80 then
local mood = save.pikachuMood or 128
if b > 0x80 then
if mood < b and not save.pikachuEmotionModifier then
save.pikachuMood = b
end
elseif mood > b then
save.pikachuMood = b
end
end
end
-- UpdatePikachuHappinessAndMood (engine/events/poison.asm): every 256th
-- step a coin flip on the WALKING bump; every step the mood converges by
-- 1 toward the neutral 128.
function PikachuFollower.onStep(save)
if not GameVersion.isYellow() then return end
save.pikachuWalkSteps = ((save.pikachuWalkSteps or 0) + 1) % 256
local rand = love and love.math and love.math.random or math.random
if save.pikachuWalkSteps == 0 and rand(0, 1) == 1 then
PikachuFollower.modifyHappiness(save, "WALKING")
end
local mood = save.pikachuMood or 128
if mood < 128 then
save.pikachuMood = mood + 1
elseif mood > 128 then
save.pikachuMood = mood - 1
end
end
-- ShouldPikachuSpawn, approximated: Yellow, the lab gift happened, and a
-- healthy Pikachu is in the party (the original checks the starter's OT
-- identity; a traded second Pikachu standing in is accepted here).
-- Surfing and biking hide the follower (BIT_PIKACHU_SPAWN flags).
local function shouldSpawn(game, ow)
if not GameVersion.isYellow() then return false end
local save = game.save
if not (save.flags and save.flags.EVENT_GOT_STARTER) then return false end
if save.onBike or (ow.player and ow.player.surfing) then return false end
if not (game.data.sprites and game.data.sprites.SPRITE_PIKACHU) then
return false
end
for _, mon in ipairs(save.party or {}) do
if mon.species == "PIKACHU" and (mon.hp or 0) > 0 then return true end
end
return false
end
local function makeFollower(game, ow, x, y, facing)
local NPC = require("src.world.NPC")
local npc = NPC.new(game.data, ow.map.id, {
index = INDEX, name = "PIKACHU_FOLLOWER", sprite = "SPRITE_PIKACHU",
movement = "STAY", range = "NONE", x = x, y = y,
})
npc.pikachuFollower = true
npc.passable = true -- never blocks a step (Collision.occupied)
npc.facing = facing or "down"
return npc
end
local function findFollower(ow)
for i, npc in ipairs(ow.npcs or {}) do
if npc.pikachuFollower then return npc, i end
end
return nil
end
local function remove(ow)
local npc, i = findFollower(ow)
if not npc then return end
table.remove(ow.npcs, i)
for j, e in ipairs(ow.entities or {}) do
if e == npc then table.remove(ow.entities, j) break end
end
end
-- spawn cell: directly behind the player's facing when that cell is
-- walkable, else the player's own cell (it trails out on the next step)
local function spawnCell(ow)
local p = ow.player
local dx = p.facing == "left" and 1 or p.facing == "right" and -1 or 0
local dy = p.facing == "up" and 1 or p.facing == "down" and -1 or 0
local bx, by = p.cellX + dx, p.cellY + dy
if ow.map:inBounds(bx, by) and ow.map:isWalkableCell(bx, by) then
return bx, by
end
return p.cellX, p.cellY
end
function PikachuFollower.onMapEntered(game, ow)
remove(ow)
if not shouldSpawn(game, ow) then return end
local x, y = spawnCell(ow)
local npc = makeFollower(game, ow, x, y, ow.player.facing)
table.insert(ow.npcs, npc)
-- entities is the draw list; passable keeps it out of collision
table.insert(ow.entities, npc)
ow.pikachuTrail = { x = ow.player.cellX, y = ow.player.cellY }
end
-- one follow step per frame: chase the cell the player last vacated
-- (pikachu_follow.asm keeps it one walk step behind)
function PikachuFollower.update(game, ow)
local npc = findFollower(ow)
if not npc then
if shouldSpawn(game, ow) then PikachuFollower.onMapEntered(game, ow) end
return
end
if not shouldSpawn(game, ow) then
remove(ow)
return
end
local p = ow.player
local trail = ow.pikachuTrail
if not trail then
trail = { x = p.cellX, y = p.cellY }
ow.pikachuTrail = trail
end
-- the player left the trailing cell: it becomes Pikachu's next goal
if p.cellX ~= trail.x or p.cellY ~= trail.y then
npc.goalX, npc.goalY = trail.x, trail.y
trail.x, trail.y = p.cellX, p.cellY
end
if npc.moving or not npc.goalX then return end
local gx, gy = npc.goalX, npc.goalY
if npc.cellX == gx and npc.cellY == gy then
npc.goalX, npc.goalY = nil, nil
return
end
-- fell more than a screen behind (forced movement, warp math): snap
local far = math.abs(npc.cellX - gx) + math.abs(npc.cellY - gy)
if far > 6 then
npc.cellX, npc.cellY = gx, gy
npc.px, npc.py = gx * 16, gy * 16
npc.goalX, npc.goalY = nil, nil
return
end
local dir
if npc.cellX < gx then dir = "right"
elseif npc.cellX > gx then dir = "left"
elseif npc.cellY < gy then dir = "down"
else dir = "up" end
npc.facing = dir
npc.targetX = npc.cellX + (dir == "right" and 1 or dir == "left" and -1 or 0)
npc.targetY = npc.cellY + (dir == "down" and 1 or dir == "up" and -1 or 0)
npc.moving = true
npc.progress = 0
end
-- ---------------------------------------------------------------------
-- TalkToPikachu (engine/pikachu/pikachu_emotions.asm + data/pikachu/
-- pikachu_emotions.asm): pick a scripted emotion, then play its bubble
-- and voiced PCM clip. The face-pic animation half of each emotion
-- (pikaemotion_pikapic) has no port; the bubble + clip carry the beat.
-- ---------------------------------------------------------------------
-- PikachuEmotionTable, reduced to each entry's bubble + pikaemotion_pcm
-- clip (bubble names are the *_BUBBLE constants; nil cry = silent).
-- turnAway is pikaemotion_9 (face away from the player, emotion 30).
local EMOTIONS = {
[1] = {},
[2] = { bubble = "SMILE_BUBBLE", cry = 35 },
[3] = { cry = 40 },
[4] = { cry = 29 },
[5] = { cry = 31 },
[6] = { bubble = "SKULL_BUBBLE" },
[7] = { cry = 1 },
[8] = { cry = 39 },
[9] = { bubble = "SKULL_BUBBLE", cry = 6 },
[10] = { bubble = "HEART_BUBBLE", cry = 5 },
[11] = { bubble = "ZZZ_BUBBLE", cry = 37 },
[12] = {},
[13] = {},
[14] = { bubble = "BOLT_BUBBLE", cry = 10 },
[15] = { cry = 34 },
[16] = { cry = 33 },
[17] = { cry = 13 },
[18] = {},
[19] = { bubble = "HEART_BUBBLE", cry = 33 },
[20] = { bubble = "HEART_BUBBLE", cry = 5 },
[21] = { bubble = "FISH_BUBBLE" },
[22] = { cry = 4 },
[23] = { cry = 19 },
[24] = { bubble = "EXCLAMATION_BUBBLE" },
[25] = { bubble = "BOLT_BUBBLE", cry = 35 },
[26] = { bubble = "ZZZ_BUBBLE", cry = 37 },
[27] = { cry = 9 },
[28] = { cry = 15 },
[29] = { cry = 5 },
[30] = { bubble = "HEART_BUBBLE", cry = 5, turnAway = true },
[31] = { cry = 19 },
[32] = { cry = 26 },
}
-- GetPikaPicAnimationScriptIndex (engine/pikachu/pikachu_pic_animation
-- .asm): mood picks the column (PikachuMoodLookupTable), happiness the
-- row (PikaPicAnimationScriptPointerLookupTable); the cell is the
-- emotion index.
local MOOD_THRESHOLDS = { 40, 127, 128, 210, 255 }
local MOOD_MATRIX = {
{ limit = 50, 14, 14, 6, 13, 13 },
{ limit = 100, 9, 9, 5, 12, 12 },
{ limit = 130, 3, 3, 1, 8, 8 },
{ limit = 160, 3, 3, 4, 15, 15 },
{ limit = 200, 17, 17, 7, 2, 2 },
{ limit = 250, 17, 17, 16, 10, 10 },
{ limit = 255, 17, 17, 19, 20, 20 },
}
-- wPikachuEmotionModifier values 1-5 (MapSpecificPikachuExpression
-- .Emotions): scripted one-shots -- 21 is the fishing-rod reaction
local MODIFIER_EMOTIONS = { 18, 21, 23, 24, 25 }
local function moodEmotion(save)
local mood = save.pikachuMood or 128
local column = 5
for i, threshold in ipairs(MOOD_THRESHOLDS) do
if mood <= threshold then column = i break end
end
local h = happiness(save)
local row = MOOD_MATRIX[#MOOD_MATRIX]
for _, r in ipairs(MOOD_MATRIX) do
if h <= r.limit then row = r break end
end
return row[column]
end
-- MapSpecificPikachuExpression + TalkToPikachu's selection order
local function selectEmotion(game, ow, save)
local mapId = ow.map.id
-- Fan Club / Pewter Center map beats (the Bill's-house event variant
-- is owned by that map's script)
if mapId == "POKEMON_FAN_CLUB" then return 30 end
if mapId == "PEWTER_POKECENTER" then return 26 end
local starter = PikachuFollower.starterInParty(save)
if starter then
if starter.status == "SLP" then return 11 end
if starter.status then return 28 end
end
if mapId:find("POKEMON_TOWER_", 1, true) == 1 then return 22 end
local modifier = save.pikachuEmotionModifier
if modifier and MODIFIER_EMOTIONS[modifier] then
save.pikachuEmotionModifier = nil
return MODIFIER_EMOTIONS[modifier]
end
return moodEmotion(save)
end
local function bubbleIndex(game, name)
local sheet = game.data.field and game.data.field.emotionBubbles
for i, b in ipairs(sheet and sheet.bubbles or {}) do
if b.name == name then return i end
end
return nil
end
function PikachuFollower.talk(game, ow, npc, done)
npc:facePlayer(ow.player)
ow.player.facing = OPPOSITE[npc.facing] or ow.player.facing
local save = game.save
local emotion = selectEmotion(game, ow, save)
local e = EMOTIONS[emotion] or EMOTIONS[1]
if e.turnAway then
npc.facing = ow.player.facing -- pikaemotion_9: back to the player
end
local Sound = require("src.core.Sound")
if e.cry then
if not Sound.playPikaCry(game.data, e.cry) then
Sound.playCry(game.data, "PIKACHU")
end
end
-- caches built before the Yellow bubble sheet only carry the three
-- shared bubbles; a missing crop degrades to a silent hold
local bi = e.bubble and bubbleIndex(game, e.bubble)
ow.emote = {
npc = npc, frames = 50, bubble = bi or false,
onDone = done,
}
end
-- npc the player is facing, when it is the follower (interact hook)
function PikachuFollower.at(ow, cx, cy)
local npc = findFollower(ow)
if npc and not npc.moving and npc.cellX == cx and npc.cellY == cy then
return npc
end
return nil
end
return PikachuFollower
+83
View File
@@ -0,0 +1,83 @@
-- Driver: the three post-Mt-Moon Jessie & James ambushes
-- (data/scripts/yellow_jessie_james.lua). Yellow only:
-- POKEPORT_VERSION=yellow POKEPORT_DRIVER=tests/drivers/jessie_james_sites_test.lua love .
-- For each site: step onto the trigger tile, A-mash through motto /
-- challenge / battle / parting lines, then confirm the beat flag is set
-- and both duo objects are gone.
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local Pokemon = require("src.pokemon.Pokemon")
-- A-mash needs a damaging first move (a stat move stalls the mash)
game.save.party = {}
local mon = Pokemon.new(game.data, "MEWTWO", 100)
mon.moves = { { id = "TACKLE", pp = 35 } }
table.insert(game.save.party, mon)
local sites = {
{ tag = "hideout", map = "ROCKET_HIDEOUT_B4F", tx = 24, ty = 14,
beat = "EVENT_BEAT_ROCKET_HIDEOUT_4_JESSIE_JAMES",
james = "ROCKETHIDEOUTB4F_JAMES", jessie = "ROCKETHIDEOUTB4F_JESSIE" },
{ tag = "tower", map = "POKEMON_TOWER_7F", tx = 10, ty = 12,
beat = "EVENT_BEAT_POKEMONTOWER_7_JESSIE_JAMES",
james = "POKEMONTOWER7F_JAMES", jessie = "POKEMONTOWER7F_JESSIE" },
{ tag = "silph", map = "SILPH_CO_11F", tx = 3, ty = 3,
beat = "EVENT_BEAT_SILPH_CO_11F_JESSIE_JAMES",
james = "SILPHCO11F_JAMES", jessie = "SILPHCO11F_JESSIE" },
}
local function visible(ow, name)
for _, n in ipairs(ow.npcs) do
if n.def and n.def.name == name then return true end
end
return false
end
for _, s in ipairs(sites) do
-- heal between fights and re-arm the site
for _, m in ipairs(game.save.party) do
m.hp = m.stats.hp
m.moves[1].pp = 35
end
game.save.flags[s.beat] = nil
-- step onto the trigger from below; fall back to above if blocked
U.teleport(game, s.map, s.tx, s.ty + 1, "up")
local ow = game.overworld
U.hold(game, "up", 20)
U.wait(10)
if not ow.runner:isRunning()
and (ow.player.cellX ~= s.tx or ow.player.cellY ~= s.ty) then
U.teleport(game, s.map, s.tx, s.ty - 1, "down")
ow = game.overworld
U.hold(game, "down", 20)
U.wait(10)
end
U.log(s.tag, "player at", ow.player.cellX, ow.player.cellY,
"runner:", tostring(ow.runner:isRunning()))
U.shot(game, DIR .. ("/jj_%s_0_trigger.png"):format(s.tag))
local settled = false
for i = 1, 3000 do
if game.stack:top() == ow and not ow.runner:isRunning()
and #ow.scriptMoves == 0 and game.save.flags[s.beat] then
settled = true
break
end
if i == 120 then
U.shot(game, DIR .. ("/jj_%s_1_scene.png"):format(s.tag))
end
U.tap(game, "a")
U.wait(3)
end
U.shot(game, DIR .. ("/jj_%s_2_done.png"):format(s.tag))
U.log(s.tag, "settled:", tostring(settled),
"flag:", tostring(game.save.flags[s.beat]),
"james visible:", tostring(visible(ow, s.james)),
"jessie visible:", tostring(visible(ow, s.jessie)))
end
U.log("DONE")
love.event.quit()
end
+92
View File
@@ -0,0 +1,92 @@
-- Driver: Summer Beach House gate + Surfing Pikachu minigame
-- (data/scripts/yellow_beach_house.lua, src/ui/SurfingMinigame.lua).
-- POKEPORT_VERSION=yellow POKEPORT_DRIVER=tests/drivers/surfing_minigame_test.lua love .
-- Talks to the Surfin' Dude without a surfing Pikachu (burger line),
-- then with one: plays a run -- paddle, jump, spin, land -- rides to
-- the results card, and checks the high score persisted; finally pokes
-- the printer for the hi-score print offer.
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local Pokemon = require("src.pokemon.Pokemon")
game.save.party = { Pokemon.new(game.data, "PIKACHU", 50) }
U.teleport(game, "SUMMER_BEACH_HOUSE", 2, 2, "up")
local ow = game.overworld
-- dude is at (2,3); stand at (2,2)... face down instead
ow.player.facing = "down"
U.wait(5)
U.tap(game, "a")
U.wait(30)
U.shot(game, DIR .. "/surf_0_no_surf.png")
-- close the burger line fully
for _ = 1, 40 do
if game.stack:top() == ow then break end
U.tap(game, "a")
U.wait(4)
end
U.log("gate without SURF done")
-- now teach SURF and retry: mash A through pitch + YES into the game
game.save.party[1].moves = { { id = "SURF", pp = 15 } }
local offerShot = false
for _ = 1, 300 do
local top = game.stack:top()
if top and top.seaY then break end
if not offerShot and top ~= ow and top and top.pages then
U.shot(game, DIR .. "/surf_1_offer.png")
offerShot = true
end
U.tap(game, "a")
U.wait(4)
end
local mg = game.stack:top()
U.log("minigame running:", tostring(mg and mg.seaY ~= nil))
if mg and mg.seaY then
for _ = 1, 8 do U.tap(game, "a") U.wait(3) end -- paddle
U.shot(game, DIR .. "/surf_2_ride.png")
U.tap(game, "up") -- launch
U.hold(game, "right", 30) -- spin
U.shot(game, DIR .. "/surf_3_air.png")
-- let it land and ride out the rest of the run
for _ = 1, 4000 do
if mg.phase == "results" then break end
if mg.phase == "ride" and (U.frame() % 4) == 0 then
U.tap(game, "a")
end
U.wait(1)
end
U.shot(game, DIR .. "/surf_4_results.png")
U.log("phase:", mg.phase, "score:", mg.score,
"hi:", tostring(game.save.surfingHighScore))
U.tap(game, "a") -- dismiss results
U.wait(20)
end
-- printer: should offer the hi-score print after surfing this visit
U.teleport(game, "SUMMER_BEACH_HOUSE", 6, 2, "up")
ow = game.overworld
ow.surfedThisVisit = true
U.wait(5)
-- printer is a bg event on the top wall; poke the talk script directly
local MapScripts = require("data.scripts.init")
local handler = MapScripts.talkScript("SUMMER_BEACH_HOUSE",
"TEXT_SUMMERBEACHHOUSE_PRINTER")
U.log("printer handler:", type(handler))
if type(handler) == "function" then
local finished = false
handler(game, ow, nil, function() finished = true end)
for _ = 1, 200 do
if finished then break end
U.tap(game, "a")
U.wait(4)
end
U.shot(game, DIR .. "/surf_5_printer.png")
U.log("printer flow finished:", tostring(finished))
end
U.log("DONE")
love.event.quit()
end
+95 -105
View File
@@ -1,8 +1,7 @@
-- Audio modding (M9): per-definition shape dispatch in Music/Sound, failure
-- isolation instead of a latching global disable, the granular
-- sfx/cries/map_songs merge, the ChipAsm assembler and its def-local blob
-- mode in ChipAudio, the song-literal tables and their fallbacks, and the
-- music.select hook plus the audio events.
-- Audio modding (M9): ChipAsm assembler and its def-local blob mode in
-- ChipAudio, chip/wav shape dispatch in Music/Sound, failure isolation, the
-- granular sfx/cries/map_songs merge, song-literal tables and their
-- fallbacks, and the music.select hook plus the audio events.
package.path = "./?.lua;./?/init.lua;" .. package.path
local S = require("tests.harness").suite("mod audio")
@@ -18,12 +17,8 @@ _G.love = love
local savedAudio, savedSound = love.audio, love.sound
local assets = {
["assets/theme.ogg"] = true,
["assets/theme_loop.ogg"] = true,
["assets/other.ogg"] = true,
["assets/beep.wav"] = true,
["assets/chime.ogg"] = true,
["assets/cry.ogg"] = true,
["assets/chime.wav"] = true,
}
local sources = {}
@@ -254,14 +249,14 @@ local waveSong = ChipAsm.song{
waves = { flatWave },
}
local waveTrace = ChipAudio._traceFirstMusicSampleForTest(blobData, waveSong)
check(math.abs(waveTrace[1].value - 0.55) < 1e-9,
check(math.abs(waveTrace[1].value - 1) < 1e-9,
"def-local waves drive the wave channel")
-- def-local drums are honored over the ROM's noise headers
local drumTrace = ChipAudio._traceFirstMusicSampleForTest(blobData, drumDef)
check(drumTrace[1].drumSegments == 1, "def-local drums reach the noise channel")
-- ------- data fixtures
-- ------- data fixtures (chip + wav only)
local chipSong = ChipAsm.song{
channels = { { hw = 1, program = {
@@ -272,31 +267,39 @@ local chipSong = ChipAsm.song{
} } },
}
local chipSongB = ChipAsm.song{
channels = { { hw = 1, program = {
{ notetype = { speed = 12, volume = 10, fade = 0 } },
{ octave = 5 },
{ note = "G", len = 8 },
{ loop = { count = 0, to = 1 } },
} } },
}
local chipSfx = ChipAsm.sfx{ channels = { { hw = 1, program = {
{ squareNote = { len = 8, volume = 15, fade = 1, frequency = 0x600 } },
} } } }
local function fixtureData()
return {
audio = {
songs = {
Music_Chip = chipSong,
Music_File = { file = "assets/theme.ogg" },
Music_Split = { file = "assets/theme.ogg",
loopFile = "assets/theme_loop.ogg" },
Music_Other = { file = "assets/other.ogg" },
Music_Broken = { file = "assets/missing.ogg" },
Music_BikeRiding = { file = "assets/other.ogg" },
Music_PalletTown = { file = "assets/theme.ogg" },
Music_Other = chipSongB,
Music_Broken = { file = "assets/missing.wav" },
Music_BikeRiding = chipSongB,
Music_PalletTown = chipSong,
},
sfx = {
Beep = "assets/beep.wav",
Chime = { file = "assets/chime.ogg", fanfare = true },
Chime = { file = "assets/chime.wav", fanfare = true },
Level_Up = "assets/beep.wav",
Broken = { file = "assets/missing.ogg" },
Chip_Sfx = ChipAsm.sfx{ channels = { { hw = 1, program = {
{ squareNote = { len = 8, volume = 15, fade = 1, frequency = 0x600 } },
} } } },
Broken = { file = "assets/missing.wav" },
Chip_Sfx = chipSfx,
},
cries = {},
mapSongs = { PALLET_TOWN = "Music_PalletTown" },
battle = { wild = "Music_Chip", wildWin = "Music_File" },
battle = { wild = "Music_Chip", wildWin = "Music_Other" },
},
}
end
@@ -308,7 +311,7 @@ local function reset(data)
return data
end
-- ------- dispatch: the branch follows the definition, not a global flag
-- ------- dispatch: chip and wav branches
local data = reset(fixtureData())
@@ -318,38 +321,17 @@ check(lastSource() and lastSource().queueable,
check(lastSource().playing, "the chip song started")
local chipSource = lastSource()
Music.play(data, "Music_File")
check(lastSource() and not lastSource().queueable
and lastSource().file == "assets/theme.ogg",
"a file def becomes a stream source")
Music.play(data, "Music_Other")
check(lastSource() and lastSource().queueable and lastSource().playing,
"a second chip song streams through ChipAudio")
check(not chipSource.playing, "the outgoing chip song was stopped")
check(lastSource().looping == true, "a looping file song loops")
-- intro/loop chaining now works regardless of import mode
Music.play(data, "Music_Split")
local intro = sources[#sources - 1]
local body = sources[#sources]
check(intro.file == "assets/theme.ogg" and body.file == "assets/theme_loop.ogg",
"a split def loads both files")
check(intro.looping == false and body.looping == true,
"the intro plays once and the body loops")
check(intro.playing and not body.playing, "the loop body waits for the intro")
intro.playing = false
Music.update(data)
check(body.playing, "update() chains the intro into the loop body")
-- a file song never latches chip playback off for the songs around it
Music.play(data, "Music_Chip")
check(lastSource().queueable and lastSource().playing,
"a chip song still plays after a file song")
check(not body.playing, "the outgoing file song was stopped")
-- playOnce must survive the threaded "empty QueueableSource" window:
-- Source:isPlaying is false until the first worker buffer lands, and that
-- gap must not look like the jingle already ended (Poké Center heal).
data = reset(fixtureData())
Music.playMap(data, "PALLET_TOWN", false, false)
check(Music.playOnce(data, "Music_Chip"), "playOnce starts a chip jingle")
check(Music.playOnce(data, "Music_Other"), "playOnce starts a chip jingle")
local jingle = lastSource()
local clearAwait = ChipAudio._simulateAwaitingFirstBufferForTest()
check(clearAwait ~= nil, "test can force the awaiting-first-buffer window")
@@ -374,17 +356,17 @@ check(lastSource() and lastSource().mode == "static"
-- ------- failure isolation: a bad def costs one log line, nothing else
data = reset(fixtureData())
Music.play(data, "Music_File")
Music.play(data, "Music_Chip")
local playing = lastSource()
local before = loggedCount("bad song def")
Music.play(data, "Music_Broken")
Music.play(data, "Music_File")
Music.play(data, "Music_Chip")
Music.play(data, "Music_Broken")
check(loggedCount("bad song def") == before + 1,
"a broken song def is logged exactly once")
check(playing.playing, "the previous song keeps playing through a bad def")
Music.play(data, "Music_Other")
check(lastSource().file == "assets/other.ogg" and lastSource().playing,
check(lastSource().queueable and lastSource().playing,
"a bad def does not disable the rest of the music")
local sfxBefore = loggedCount("bad sfx def")
@@ -397,32 +379,26 @@ Sound.play(data, "Beep")
check(lastSource() and lastSource().file == "assets/beep.wav",
"a bad sfx does not disable the rest of the effects")
-- ------- cries: every authoring variant plays
-- ------- cries: chip and derived variants
data = reset(fixtureData())
data.audio.cries.RHYDON = {
header = { address = 0x4000, bank = 2, engine = 1 }, pitch = 0, length = 0,
}
data.audio.cries.CHIPMON = { chip = ChipAsm.sfx{ channels = { { hw = 1,
program = { { squareNote = { len = 8, volume = 15, fade = 1,
frequency = 0x600 } } } } } }.chip,
pitch = 0, length = 0 }
data.audio.cries.CHIPMON = { chip = chipSfx.chip, pitch = 0, length = 0 }
data.audio.cries.SHELLORD = { base = "CHIPMON", pitch = 0x2A, length = 0x50 }
data.audio.cries.FILEMON = { file = "assets/cry.ogg", pitch = 1.1 }
data.audio.cries.CHAINMON = { base = "SHELLORD" }
check(Sound.playCry(data, "CHIPMON"), "a chip cry plays")
check(Sound.playCry(data, "SHELLORD"), "a derived cry plays")
check(Sound.playCry(data, "CHAINMON"), "a derived cry chain resolves")
local fileCry = Sound.playCry(data, "FILEMON")
check(fileCry and fileCry.file == "assets/cry.ogg", "a file cry plays")
check(fileCry.pitch == 1.1, "a file cry honors its playback rate")
check(Sound.playCry(data, "NOBODY") == nil, "an unregistered species is silent")
-- GROWL/ROAR layer their own tempo shift on top of any cry shape
Sound.playMoveCry(data, "FILEMON", 0xC0)
check(math.abs(fileCry.pitch - 256 / (128 + 0xC0)) < 1e-9,
"playMoveCry layers the move's tempo shift onto a file cry")
local chipCry = Sound.playCry(data, "CHIPMON")
Sound.playMoveCry(data, "CHIPMON", 0xC0)
check(math.abs(chipCry.pitch - 256 / (128 + 0xC0)) < 1e-9,
"playMoveCry layers the move's tempo shift onto a chip cry")
data.audio.cries.ORPHAN = { base = "MISSING" }
local cryBefore = loggedCount("bad cry def")
@@ -438,14 +414,14 @@ check(Music.special(data, "title") == "Music_TitleScreen",
check(Music.special(data, "bike") == "Music_BikeRiding",
"the bike role falls back to Music_BikeRiding")
Music.playMap(data, "PALLET_TOWN", true, false)
check(lastSource().file == "assets/other.ogg",
check(lastSource().queueable,
"the fallback outdoor set engages the bike theme")
data = reset(fixtureData())
data.audio.special = { bike = "Music_File" }
data.audio.special = { bike = "Music_Other" }
data.audio.outdoorSongs = { Music_PalletTown = true }
Music.playMap(data, "PALLET_TOWN", true, false)
check(lastSource().file == "assets/theme.ogg",
check(lastSource().queueable,
"a renamed bike theme engages on outdoor maps")
check(Music.special(data, "title") == "Music_TitleScreen",
"roles the data table omits still fall back")
@@ -453,7 +429,7 @@ check(Music.special(data, "title") == "Music_TitleScreen",
data = reset(fixtureData())
data.audio.outdoorSongs = {}
Music.playMap(data, "PALLET_TOWN", true, false)
check(lastSource().file == "assets/theme.ogg",
check(lastSource().queueable,
"a map outside the outdoor set keeps its own theme on the bike")
-- fanfare ducking: the shared table or the definition's own flag
@@ -487,12 +463,12 @@ check(lastSource() ~= firstBeep, "Sound.invalidate drops the cached source")
data = reset(fixtureData())
Music.play(data, "Music_Broken")
check(#sources == 0, "a broken def creates no source")
data.audio.songs.Music_Broken = { file = "assets/other.ogg" }
data.audio.songs.Music_Broken = chipSongB
Music.play(data, "Music_Broken")
check(#sources == 0, "a failed label stays negatively cached")
Music.reload()
Music.play(data, "Music_Broken")
check(lastSource() and lastSource().file == "assets/other.ogg",
check(lastSource() and lastSource().queueable,
"Music.reload re-resolves a repaired def")
ChipAudio.invalidate()
@@ -527,7 +503,7 @@ check(seen[3].reason == "victory" and seen[3].kind == "wild",
"playVictory reaches the hook")
Music.playOnce(data, "Music_Other")
check(seen[4].reason == "once", "playOnce reaches the hook")
Music.play(data, "Music_Split")
Music.play(data, "Music_Chip")
check(seen[5].reason == "direct", "a direct play defaults to the direct reason")
-- returning nil silences the cue; returning a label plays that label
@@ -535,27 +511,25 @@ Music.reload()
resetSources()
hooks:removeOwner("test")
hooks:wrap("music.select", function() return nil end, nil, "silencer")
Music.play(data, "Music_File")
Music.play(data, "Music_Chip")
check(#sources == 0, "a hook returning nil silences the cue")
hooks:removeOwner("silencer")
hooks:wrap("music.select", function(nextLink, song, ctx)
if song == "Music_File" then return nextLink("Music_Other", ctx) end
if song == "Music_Chip" then return nextLink("Music_Other", ctx) end
return nextLink(song, ctx)
end, nil, "swap")
Music.play(data, "Music_File")
check(lastSource().file == "assets/other.ogg", "a hook may swap the label")
-- the swapped label is what dedupe compares, so re-asking still restarts
-- nothing but a genuinely different choice does
Music.play(data, "Music_Chip")
check(lastSource().queueable, "a hook may swap the label")
Music.play(data, "Music_Other")
check(lastSource().queueable, "an unswapped label still plays")
-- a throwing wrapper is skipped and the chain continues
hooks:wrap("music.select", function() error("boom", 0) end, nil, "thrower")
Music.reload()
resetSources()
Music.play(data, "Music_File")
check(lastSource() and lastSource().file == "assets/other.ogg",
Music.play(data, "Music_Chip")
check(lastSource() and lastSource().queueable,
"a throwing wrapper is skipped and the surviving chain still runs")
hooks:removeOwner("thrower")
hooks:removeOwner("swap")
@@ -570,13 +544,13 @@ events:on("sound.played", function(p) played[#played + 1] = p end, nil, "test")
Music.playMap(data, "PALLET_TOWN", false, false)
check(started[1] and started[1].song == "Music_PalletTown"
and started[1].reason == "map" and started[1].chip == false,
and started[1].reason == "map" and started[1].chip == true,
"music.started carries the song, reason and chip flag")
Music.play(data, "Music_Chip")
Music.play(data, "Music_Other")
check(started[2].previous == "Music_PalletTown" and started[2].chip == true,
"music.started names the song it replaced")
Music.stop()
check(#stopped == 1 and stopped[1].song == "Music_Chip",
check(#stopped == 1 and stopped[1].song == "Music_Other",
"music.stopped names the song that was playing")
Music.stop()
check(#stopped == 1, "stopping silence emits nothing")
@@ -587,9 +561,9 @@ check(played[1] and played[1].kind == "sfx" and played[1].name == "Beep",
Sound.playMove(data, { sound = "Chip_Sfx", pitch = 0x10, tempo = 0x90 })
check(played[2].kind == "move" and played[2].name == "Chip_Sfx",
"sound.played fires for a move sound")
data.audio.cries.FILEMON = { file = "assets/cry.ogg" }
Sound.playCry(data, "FILEMON")
check(played[3].kind == "cry" and played[3].species == "FILEMON",
data.audio.cries.CHIPMON = { chip = chipSfx.chip, pitch = 0, length = 0 }
Sound.playCry(data, "CHIPMON")
check(played[3].kind == "cry" and played[3].species == "CHIPMON",
"sound.played fires for a cry")
Runtime.install(savedEvents, savedHooks)
@@ -639,14 +613,22 @@ end
local granularFiles = {
["mods/coast/manifest.json"] = manifestJson("coast", 2),
["mods/coast/main.lua"] = [[
local ChipAsm = require("src.audio.ChipAsm")
return function(mod)
mod.content.music:register("Music_CoastTown", {
file = "assets/theme.ogg", loopFile = "assets/theme_loop.ogg" })
mod.content.music:register("Music_CoastTown", ChipAsm.song{
channels = { { hw = 1, program = {
{ notetype = { speed = 12, volume = 12, fade = 0 } },
{ octave = 4 }, { note = "C", len = 8 },
{ loop = { count = 0, to = 1 } },
} } },
})
mod.content.sfx:register("Shell_Found", {
file = "assets/chime.ogg", fanfare = true })
file = "assets/chime.wav", fanfare = true })
mod.content.cries:register("SHELLORD", {
header = { address = 16696, bank = 2, engine = 1 }, pitch = 42, length = 80 })
mod.content.cries:register("REEFMON", { file = "assets/cry.ogg" })
mod.content.cries:register("REEFMON", ChipAsm.sfx{ channels = { { hw = 1,
program = { { squareNote = { len = 4, volume = 15, fade = 1,
frequency = 0x500 } } } } } })
mod.content.map_songs:override("PALLET_TOWN", "Music_CoastTown")
mod.content.cries:patch("RHYDON", { pitch = 200 })
mod.content.sfx:remove("Beep")
@@ -660,7 +642,8 @@ merged.audio.cries.RHYDON = {
local granular = Loader.new({ fs = memfs(granularFiles) })
check(granular:load(merged) == true,
"the granular audio mod loads: " .. table.concat(granular.errors, "; "))
check(merged.audio.songs.Music_CoastTown.loopFile == "assets/theme_loop.ogg",
check(merged.audio.songs.Music_CoastTown
and merged.audio.songs.Music_CoastTown.chip,
"music merges into data.audio.songs")
check(merged.audio.sfx.Shell_Found.fanfare == true,
"sfx merges into data.audio.sfx")
@@ -676,12 +659,12 @@ check(merged.audio.sfx.Beep == nil, "remove tombstones an sfx")
-- the merged map song plays through the ordinary map path
reset(merged)
Music.playMap(merged, "PALLET_TOWN", false, false)
check(sources[1] and sources[1].file == "assets/theme.ogg" and sources[1].playing,
check(sources[1] and sources[1].queueable and sources[1].playing,
"a mod's map song plays on the map it claims")
-- a brand-new species sounds everywhere a vanilla one does
local reefCry = Sound.playCry(merged, "REEFMON")
check(reefCry and reefCry.file == "assets/cry.ogg" and reefCry.playing,
check(reefCry and reefCry.playing,
"a species the mod invented plays its registered cry")
-- and the hook can still take the map theme away from it
@@ -693,7 +676,7 @@ mapHooks:wrap("music.select", function(nextLink, song, ctx)
end, nil, "night")
reset(merged)
Music.playMap(merged, "PALLET_TOWN", false, false)
check(lastSource().file == "assets/other.ogg",
check(lastSource().queueable,
"music.select overrides the track for a map")
Runtime.install(savedEvents, savedHooks)
@@ -701,8 +684,15 @@ Runtime.install(savedEvents, savedHooks)
local bootstrapFiles = {
["mods/tc/manifest.json"] = manifestJson("tc", 2),
["mods/tc/main.lua"] = [[
local ChipAsm = require("src.audio.ChipAsm")
return function(mod)
mod.content.music:register("Music_TC", { file = "assets/theme.ogg" })
mod.content.music:register("Music_TC", ChipAsm.song{
channels = { { hw = 1, program = {
{ notetype = { speed = 12, volume = 12, fade = 0 } },
{ octave = 4 }, { note = "C", len = 8 },
{ loop = { count = 0, to = 1 } },
} } },
})
mod.content.map_songs:register("TC_TOWN", "Music_TC")
end
]],
@@ -722,14 +712,14 @@ local v1Files = {
["mods/legacy/manifest.json"] = manifestJson("legacy", 1),
["mods/legacy/main.lua"] = [[
return function(mod)
mod.content.audio:override("sfx", { Beep = "assets/other.ogg",
mod.content.audio:override("sfx", { Beep = "assets/chime.wav",
Legacy_Only = "assets/beep.wav" })
end
]],
["mods/modern/manifest.json"] = manifestJson("modern", 2, '["legacy"]'),
["mods/modern/main.lua"] = [[
return function(mod)
mod.content.sfx:override("Beep", "assets/chime.ogg")
mod.content.sfx:override("Beep", "assets/beep.wav")
end
]],
}
@@ -739,7 +729,7 @@ check(v1Loader:load(v1Data) == true,
"the v1 audio registry still loads: " .. table.concat(v1Loader.errors, "; "))
check(v1Data.audio.sfx.Legacy_Only == "assets/beep.wav",
"the v1 whole-table replacement still applies")
check(v1Data.audio.sfx.Beep == "assets/chime.ogg",
check(v1Data.audio.sfx.Beep == "assets/beep.wav",
"a granular registration beats a v1 whole-table replacement")
check(v1Data.audio._owners.sfx.Beep == "modern",
"the granular writer owns the id even when a v1 table landed on it first")
@@ -761,11 +751,11 @@ local badFiles = {
["mods/coast/manifest.json"] = manifestJson("coast", 2),
["mods/coast/main.lua"] = [[
return function(mod)
mod.content.music:register("Music_Bad", { file = "assets/missing.ogg" })
mod.content.sfx:register("Sfx_Bad", { file = "assets/missing.ogg" })
mod.content.sfx:register("Loop_Bad", { file = "assets/missing.ogg" })
mod.content.cries:register("BADMON", { file = "assets/missing.ogg" })
mod.content.sfx:register("Gone", { file = "assets/missing.ogg" })
mod.content.music:register("Music_Bad", { file = "assets/missing.wav" })
mod.content.sfx:register("Sfx_Bad", { file = "assets/missing.wav" })
mod.content.sfx:register("Loop_Bad", { file = "assets/missing.wav" })
mod.content.cries:register("BADMON", { file = "assets/missing.wav" })
mod.content.sfx:register("Gone", { file = "assets/missing.wav" })
mod.content.sfx:remove("Gone")
end
]],
@@ -812,7 +802,7 @@ check(#badLoader.errors == errorsBefore + 4,
"a known-bad def reports to Loader.errors once, not per play")
-- an engine-owned def has no mod to blame, so it stays a console line
badData.audio.songs.Music_BaseBad = { file = "assets/missing.ogg" }
badData.audio.songs.Music_BaseBad = { file = "assets/missing.wav" }
Music.play(badData, "Music_BaseBad")
check(loggedCount("bad song def") > 0 and #badLoader.errors == errorsBefore + 4,
"a base-owned failure never lands in Loader.errors")
+235 -20
View File
@@ -1,9 +1,9 @@
#!/usr/bin/env python3
"""Build game data directly from a canonical Pokemon Red ROM.
"""Build game data directly from a canonical Pokemon Red/Blue/Yellow ROM.
It accepts one user-provided, canonical US Pokemon Red ROM. Symbol
addresses and assembly-erased names are bundled as non-ROM metadata, so no
pret/pokered checkout, RGBDS build, or external .sym file is required.
It accepts one user-provided, canonical US Gen-1 ROM. Symbol addresses and
assembly-erased names are bundled as non-ROM metadata, so no pret checkout,
RGBDS build, or external .sym file is required for the public ROM path.
"""
from __future__ import annotations
@@ -21,8 +21,11 @@ from PIL import Image
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from extract import util # noqa: E402
from rom_data import (RomImage, SymbolTable, bcd, decode_text, # noqa: E402
decompress_pic, load_manifest, read_string)
from rom_data import ( # noqa: E402
CANONICAL_BLUE_SHA1, CANONICAL_RED_SHA1, CANONICAL_YELLOW_SHA1,
RomImage, SymbolTable, bcd, decode_text, decompress_pic, load_manifest,
read_string,
)
DATASETS = (
@@ -31,6 +34,19 @@ DATASETS = (
"text", "field", "battle_anims",
)
_TOOLS_DIR = os.path.dirname(os.path.abspath(__file__))
VERSION_MANIFESTS = {
"red": os.path.join(_TOOLS_DIR, "rom_manifest.json"),
"blue": os.path.join(_TOOLS_DIR, "rom_manifest_blue.json"),
"yellow": os.path.join(_TOOLS_DIR, "rom_manifest_yellow.json"),
}
VERSION_SHA1 = {
"red": CANONICAL_RED_SHA1,
"blue": CANONICAL_BLUE_SHA1,
"yellow": CANONICAL_YELLOW_SHA1,
}
SHA1_TO_VERSION = {sha1: version for version, sha1 in VERSION_SHA1.items()}
GB_SHADES = (
(255, 255, 255, 255),
(170, 170, 170, 255),
@@ -46,6 +62,36 @@ def _symbol(symbols, name):
raise ValueError(f"required symbol {name!r} is missing") from exc
def _has_symbol(symbols, name):
return name in symbols.by_name
def resolve_manifest_path(version, manifest_arg):
"""Pick the shipped manifest for --version, or an explicit --manifest path."""
if manifest_arg is not None:
return manifest_arg
path = VERSION_MANIFESTS[version]
if not os.path.isfile(path):
raise ValueError(
f"manifest for version {version!r} is missing: {path} "
f"(generate it before extracting)")
return path
def version_for_manifest(manifest, requested_version=None, manifest_explicit=False):
"""Resolve game version from an explicit flag or the manifest romSha1."""
rom_sha1 = manifest.get("romSha1")
detected = SHA1_TO_VERSION.get(rom_sha1)
if manifest_explicit:
# Explicit --manifest wins: hash/version come from the file itself.
return detected or requested_version or "red"
if requested_version and detected and requested_version != detected:
raise ValueError(
f"--version {requested_version} does not match manifest romSha1 "
f"{rom_sha1} ({detected})")
return detected or requested_version or "red"
def extract_constants(manifest, out_dir):
data = manifest["constants"]
util.write_lua(
@@ -227,8 +273,12 @@ def extract_tilesets(rom, symbols, manifest, out_dir, assets_dir):
list(blocks_raw[offset:offset + 16])
for offset in range(0, len(blocks_raw), 16)
]
# Red/Blue keep collision lists in ROM0; Yellow moved them to bank 1
# (pokeyellow Overworld_Coll at 01:4ac2). Pointers in $4000-$7FFF are
# banked; treat ROM0-range pointers as bank 0.
coll_bank = 0 if collision_pointer < 0x4000 else 1
walkable = sorted(_read_terminated(
rom, 0, collision_pointer, 0xFF))
rom, coll_bank, collision_pointer, 0xFF))
warp_pointer = rom.word(
warp_pointers.bank, warp_pointers.address + index * 2)
warp_tiles = sorted(set(_read_terminated(
@@ -356,19 +406,33 @@ def extract_sprites(rom, symbols, manifest, out_dir, assets_dir):
pointer = rom.word(table.bank, address)
first_half_length = rom.byte(table.bank, address + 2)
bank = rom.byte(table.bank, address + 3)
byte_length = spec["imageWidth"] * spec["imageHeight"] // 4
frames = spec["imageHeight"] // 16
width = spec["imageWidth"]
height = spec["imageHeight"]
byte_length = width * height // 4
frames = height // 16
expected_length = first_half_length * (2 if frames >= 6 else 1)
if byte_length != expected_length:
# Commercial ROM sheet length wins over pret PNG atlases (Yellow's
# nurse.png is 16x64 but SpriteSheetPointerTable still stores 12
# tiles / 192 bytes).
byte_length = expected_length
if byte_length * 4 % width:
raise ValueError(
f"{const_name}: ROM sprite length {byte_length} is not "
f"tile-aligned for width {width}")
height = byte_length * 4 // width
frames = height // 16
expected_length = first_half_length * (2 if frames >= 6 else 1)
if byte_length != expected_length:
raise ValueError(
f"{const_name}: ROM sprite length {expected_length} does not "
f"match atlas length {byte_length}")
f"match atlas length {width * spec['imageHeight'] // 4}")
base = spec["imageBase"]
if base not in written:
_write_2bpp_png(
rom.bytes(bank, pointer, byte_length),
spec["imageWidth"], spec["imageHeight"],
width, height,
os.path.join(assets_dir, "sprites", base + ".png"),
transparent_color0=True)
written.add(base)
@@ -1040,9 +1104,25 @@ def extract_palettes(rom, symbols, manifest, out_dir):
"order": order,
"pokemon": mon_pals,
}
if _has_symbol(symbols, "CGBBasePalettes"):
cgb_table = _symbol(symbols, "CGBBasePalettes")
cgb = {}
for index, name in enumerate(order):
colors = []
for color in range(4):
value = rom.word(
cgb_table.bank, cgb_table.address + index * 8 + color * 2)
colors.append([
_scale5(value & 0x1F),
_scale5((value >> 5) & 0x1F),
_scale5((value >> 10) & 0x1F),
])
cgb[name] = colors
data["cgbBase"] = cgb
data["source"] = data["source"] + " + CGBBasePalettes"
util.write_lua(
os.path.join(out_dir, "palettes.lua"), data,
header="Source: canonical Pokemon Red ROM; 4 RGB colors per palette")
header="Source: canonical Gen-1 ROM; 4 RGB colors per palette")
return data
@@ -1216,7 +1296,9 @@ def extract_pokemon(rom, symbols, manifest, out_dir, assets_dir):
type_by_id = _types_by_id(manifest)
names = _symbol(symbols, "MonsterNames")
base_stats = _symbol(symbols, "BaseStats")
mew_stats = _symbol(symbols, "MewBaseStats")
# Red/Blue keep Mew outside BaseStats at MewBaseStats; Yellow folds Mew
# into BaseStats at dex 151 (pret/pokeyellow), so the symbol is optional.
mew_stats = symbols.by_name.get("MewBaseStats")
decoded_names = []
for index in range(len(species_order)):
@@ -1232,7 +1314,7 @@ def extract_pokemon(rom, symbols, manifest, out_dir, assets_dir):
("MISSINGNO", "UNUSED", "FOSSIL_", "MON_GHOST")):
continue
dex = dex_by_species[species]
if species == "MEW":
if species == "MEW" and mew_stats is not None:
row = rom.bytes(mew_stats.bank, mew_stats.address, 28)
else:
row = rom.bytes(
@@ -1634,6 +1716,111 @@ def extract_field(rom, symbols, manifest, out_dir, assets_dir):
raw_2bpp(
"GameFreakLogoGraphics", 72, 8, "title/gamefreak_inc.png")
# Yellow fixed Pikachu title (pret/pokeyellow title_yellow.asm): tilemap
# composition over both tile banks -- PokemonLogoGraphics in vChars2
# (BG ids $00-$7F), TitlePikachuBGGraphics in vChars1 (ids $80-$EF),
# TitlePikachuOBGraphics at vChars1 tile $70 (ids $F0-$FC, also the eye
# OAM tiles), PokemonLogoCornerGraphics at vChars1 tile $7D ($FD-$FF).
# Mirrors RomExtractor:extractYellowTitleArt.
if _has_symbol(symbols, "TitlePikachuBGGraphics"):
raw_2bpp(
"TitlePikachuBGGraphics", 128, 32, "title/pikachu_bg.png",
transparent=True)
raw_2bpp(
"TitlePikachuOBGraphics", 96, 8, "title/pikachu_ob.png",
transparent=True)
def sheet_tiles(label, count, transparent=False):
symbol = _symbol(symbols, label)
raw = rom.bytes(symbol.bank, symbol.address, count * 16)
return [
_decode_2bpp(raw[index:index + 16], 8, 8, transparent)
for index in range(0, len(raw), 16)
]
# tile counts = Graphics..GraphicsEnd symbol gaps in pokeyellow.sym
logo_tiles = sheet_tiles("PokemonLogoGraphics", 115)
corner_tiles = sheet_tiles("PokemonLogoCornerGraphics", 3)
bg_tiles = sheet_tiles("TitlePikachuBGGraphics", 64)
ob_tiles = sheet_tiles("TitlePikachuOBGraphics", 12)
ob_clear = sheet_tiles("TitlePikachuOBGraphics", 12, True)
def tile_for(tid):
if tid < 0x80:
return logo_tiles[tid]
if tid < 0xF0:
return bg_tiles[tid - 0x80]
if tid < 0xFD:
return ob_tiles[tid - 0xF0]
return corner_tiles[tid - 0xFD]
def matte_color0(pose):
from collections import deque
w, h = pose.size
seen = set()
q = deque()
def add(x, y):
if (x, y) in seen or not (0 <= x < w and 0 <= y < h):
return
if pose.getpixel((x, y)) == (255, 255, 255, 255):
seen.add((x, y))
q.append((x, y))
for x in range(w):
add(x, 0); add(x, h - 1)
for y in range(h):
add(0, y); add(w - 1, y)
while q:
x, y = q.popleft()
pose.putpixel((x, y), (255, 255, 255, 0))
add(x - 1, y); add(x + 1, y); add(x, y - 1); add(x, y + 1)
return pose
def compose(cols, rows, cells):
pose = Image.new("RGBA", (cols * 8, rows * 8), (255, 255, 255, 0))
for tid, cx, cy in cells:
pose.paste(tile_for(tid), (cx * 8, cy * 8))
return pose
def map_cells(label, cols, rows):
loc = _symbol(symbols, label)
ids = list(rom.bytes(loc.bank, loc.address, cols * rows))
return [
(tid, index % cols, index // cols)
for index, tid in enumerate(ids)
]
# 16x7 logo box at (2,1); overwrites the scrambled sequential rip
# above (Yellow's logo sheet is deduplicated, Red's is not)
_save_png(
compose(16, 7, map_cells("TitleScreenPokemonLogoTilemap", 16, 7)),
os.path.join(assets_dir, "title/pokemon_logo.png"))
# 7x4 bubble at (6,4) + the two tail tiles poked at (9,8)
bubble_cells = map_cells("TitleScreenPikaBubbleTilemap", 7, 4)
bubble_cells += [(0x64, 3, 4), (0x65, 4, 4)]
_save_png(
matte_color0(compose(7, 5, bubble_cells)),
os.path.join(assets_dir, "title/pika_bubble.png"))
# 12x9 Pikachu at (4,8) + right-ear edge tiles down column 16 and
# the baked OAM eyes (TitleScreenPikachuEyesOAMData, left eye
# x-flipped, attr $22)
pika_cells = map_cells("TitleScreenPikachuTilemap", 12, 9)
pika_cells += [(0x96, 12, 2), (0x9d, 12, 3),
(0xa7, 12, 4), (0xb1, 12, 5)]
pikachu = matte_color0(compose(13, 9, pika_cells))
for ob_index, px, py, flip in (
(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)):
eye = ob_clear[ob_index]
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"))
falling_star = raw_2bpp(
"FallingStar", 8, 8, "intro/falling_star.png",
transparent=True)
@@ -1684,6 +1871,9 @@ def extract_field(rom, symbols, manifest, out_dir, assets_dir):
(8, row * 8))
_save_png(star, os.path.join(assets_dir, "intro/big_star.png"))
# Yellow replaces the Gengar/Nidorino fight intro (no FightIntro* symbols).
# Still emit the Red/Blue asset paths so Title/Intro loaders stay happy.
if _has_symbol(symbols, "FightIntroBackMon"):
gengar = _symbol(symbols, "FightIntroBackMon")
gengar_raw = rom.bytes(gengar.bank, gengar.address, 96 * 16)
gengar_tiles = [
@@ -1702,13 +1892,26 @@ def extract_field(rom, symbols, manifest, out_dir, assets_dir):
_save_png(
pose, os.path.join(
assets_dir, "intro", f"gengar_{number}.png"))
else:
blank = Image.new("RGBA", (56, 56), (0, 0, 0, 0))
for number in (1, 2, 3):
_save_png(
blank, os.path.join(
assets_dir, "intro", f"gengar_{number}.png"))
if _has_symbol(symbols, "FightIntroFrontMon"):
for number, label in enumerate((
"FightIntroFrontMon", "FightIntroFrontMon2",
"FightIntroFrontMon3"), start=1):
raw_2bpp(
label, 48, 48, f"intro/red_nidorino_{number}.png",
transparent=True, columns=True)
else:
blank = Image.new("RGBA", (48, 48), (255, 255, 255, 0))
for number in (1, 2, 3):
_save_png(
blank, os.path.join(
assets_dir, "intro", f"red_nidorino_{number}.png"))
for number in (1, 2):
_write_compressed_pic(
@@ -1864,10 +2067,16 @@ def build(rom, symbols, manifest, out_dir, assets_dir, datasets):
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--rom", required=True, help="canonical Pokemon Red ROM")
parser.add_argument(
"--manifest",
default=os.path.join(os.path.dirname(__file__), "rom_manifest.json"))
"--rom", required=True,
help="canonical US Pokemon Red, Blue, or Yellow ROM")
parser.add_argument(
"--version", choices=sorted(VERSION_MANIFESTS), default="red",
help="select the shipped manifest for this version (default: red)")
parser.add_argument(
"--manifest", default=None,
help="explicit manifest path (overrides --version default path; "
"RomImage hash still comes from the file's romSha1)")
parser.add_argument("--out", default="data/generated")
parser.add_argument("--assets", default="assets/generated")
parser.add_argument("--clean", action="store_true")
@@ -1877,8 +2086,13 @@ def main():
args = parser.parse_args()
try:
manifest = load_manifest(args.manifest)
rom = RomImage(args.rom, manifest["romSha1"])
manifest_explicit = args.manifest is not None
manifest_path = resolve_manifest_path(args.version, args.manifest)
manifest = load_manifest(manifest_path)
version = version_for_manifest(
manifest, args.version, manifest_explicit=manifest_explicit)
expected_sha1 = manifest.get("romSha1") or VERSION_SHA1[version]
rom = RomImage(args.rom, expected_sha1)
symbols = SymbolTable(manifest["symbols"])
except (OSError, ValueError, KeyError, json.JSONDecodeError) as exc:
print(f"error: {exc}", file=sys.stderr)
@@ -1896,7 +2110,8 @@ def main():
except (ValueError, KeyError, IndexError) as exc:
print(f"error: {exc}", file=sys.stderr)
return 1
print(f"\ndone: decoded {', '.join(datasets)} from ROM {rom.sha1}")
print(
f"\ndone: decoded {', '.join(datasets)} from {version} ROM {rom.sha1}")
return 0
+22 -18
View File
@@ -755,14 +755,14 @@ def parse_badge_gates(pokered):
def parse_preset_names(pokered):
"""constants/player_constants.asm: the _RED preset name menus.
"""constants/player_constants.asm: preset name menus.
The naming menus (engine/movie/oak_speech/oak_speech2.asm with
data/player/names.asm / names_list.asm) offer NEW NAME plus these
three presets each.
three presets each. Red/Blue gate the lists with IF DEF(_RED)/_BLUE;
Yellow ships a single ungated set (YELLOW/ASH/JACK, BLUE/GARY/JOHN).
read_asm resolves version conditionals via util.ASM_DEFINES.
"""
# read_asm resolves the version conditionals (util.ASM_DEFINES), so
# only the _RED name set reaches us
player, rival = [], []
path = os.path.join(pokered, "constants/player_constants.asm")
for lineno, line in read_asm(path):
@@ -1116,24 +1116,16 @@ def parse_credits(pokered):
os.path.join(pokered, "constants/credits_constants.asm"),
stop_at="NUM_CRED_STRINGS")
# CreditsTextPointers: CRED_* value -> string label
# CreditsTextPointers: CRED_* value -> string label.
# Version-gated CredVersion / CreditsText_Version bodies (Red/Blue IF
# DEF) are resolved by read_asm via util.ASM_DEFINES; Yellow has no
# gates and a single "YELLOW VERSION" string.
pointers = []
strings = {}
skip = False
label = None
path = os.path.join(pokered, "data/credits/credits_text.asm")
for lineno, line in read_asm(path):
s = line.strip()
if re.match(r"IF\s+DEF\(_RED\)", s):
continue
if re.match(r"IF\s+DEF\(", s):
skip = True
continue
if s == "ENDC":
skip = False
continue
if skip:
continue
m = re.match(r"dw\s+(\w+)$", s)
if m:
pointers.append(m.group(1))
@@ -1142,6 +1134,7 @@ def parse_credits(pokered):
if m and m.group(1) != "CreditsTextPointers":
label = m.group(1)
continue
# Optional trailing @ terminator (Yellow omits it on some lines).
m = re.match(r'db\s+(-\d+),\s*"([^"]*)"$', s)
if m and label:
strings[label] = {
@@ -1445,8 +1438,19 @@ def extract(pokered, out_dir):
or badge_gates["ROUTE_23"]["guards"][-1]["badge"] != "CASCADEBADGE" \
or len(badge_gates["ROUTE_22_GATE"]["coords"]) != 2:
util.die("badge gate extraction sanity check failed")
if "RED" not in preset_names["player"] or "BLUE" not in preset_names["rival"] \
or len(preset_names["player"]) != 3 or len(preset_names["rival"]) != 3:
if len(preset_names["player"]) != 3 or len(preset_names["rival"]) != 3:
util.die("preset name extraction sanity check failed")
# Red expects RED/ASH/JACK + BLUE/GARY/JOHN. Yellow ships YELLOW/... with
# no IF DEF gates; Blue swaps player/rival. Only enforce the Red pair when
# building Red (ASM_DEFINES has _RED) or when RED already appears.
if "_RED" in util.ASM_DEFINES or "RED" in preset_names["player"]:
if "YELLOW" in preset_names["player"]:
pass # pokeyellow ungated presets; Red name check does not apply
elif "RED" not in preset_names["player"] \
or "BLUE" not in preset_names["rival"]:
util.die("preset name extraction sanity check failed")
elif "YELLOW" in preset_names["player"]:
if "BLUE" not in preset_names["rival"]:
util.die("preset name extraction sanity check failed")
if "ROCK_TUNNEL_1F" not in dark_maps["maps"]:
util.die("dark map extraction sanity check failed")
+478
View File
@@ -0,0 +1,478 @@
#!/usr/bin/env python3
"""Derive the Pokemon Yellow import manifest from the shipped Red manifest.
Yellow is a separate pret tree (pokeyellow), not a `_YELLOW` flip of pokered.
Most of the ~3268 Red manifest symbols still exist under the same names in
pokeyellow.sym (~3123 with shifted addresses). The remainder need aliases,
synthetic addresses (Mew in BaseStats), or omission (FightIntro* Yellow's
intro movie is different; RomExtractor must skip those).
Map/object/sprite/tileset/text metadata diverge enough that those sections are
rebuilt from pokeyellow source (same helpers as make_rom_manifest.py), while
ROM-address tables and other Red-shaped sections keep the derive-and-remap
path.
Usage mirrors make_blue_manifest.py: deep-copy Red, remap symbols, override
version-gated field bits, write tools/rom_manifest_yellow.json.
"""
from __future__ import annotations
import argparse
import copy
import json
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from extract import constants, field, util # noqa: E402
from make_rom_manifest import ( # noqa: E402
_music_label,
map_metadata,
simple_constants,
sprite_metadata,
text_metadata,
tileset_metadata,
)
import re # noqa: E402
from rom_data import SymbolTable # noqa: E402
from yellow_symbol_aliases import ( # noqa: E402
FAN_CLUB_ID_RENAMES,
GAME_CORNER_ID_RENAMES,
MAP_CONST_RENAMES,
MAP_LABEL_RENAMES,
OMIT_INTRO_SYMBOLS,
SYMBOL_ALIASES,
)
try:
from rom_data import CANONICAL_YELLOW_SHA1
except ImportError: # pragma: no cover — constant lands with GameVersion work
CANONICAL_YELLOW_SHA1 = "cc7d03262ebfaf2f06772c1a480c7d9d5f4a38e1"
DEV = "/Users/bryanbassett/Documents/development"
DEFAULT_RED = os.path.join(os.path.dirname(__file__), "rom_manifest.json")
DEFAULT_OUT = os.path.join(os.path.dirname(__file__), "rom_manifest_yellow.json")
DEFAULT_POKEYELLOW = os.path.join(DEV, "pokeyellow")
DEFAULT_SYMBOLS = os.path.join(DEV, "pokeyellow-symbols/pokeyellow.sym")
BASE_STATS_ENTRY_SIZE = 28
MEW_DEX_NUMBER = 151
# Yellow-only symbols Red never referenced. Title / intro / CGB tables must
# be injected so RomExtractor can rip them (make_yellow_manifest only remaps
# Red's name set by default).
YELLOW_EXTRA_SYMBOLS = (
"TitlePikachuBGGraphics",
"TitlePikachuOBGraphics",
"TitleScreenPikachuTilemap",
"TitleScreenPikaBubbleTilemap",
"TitleScreenPokemonLogoTilemap",
"PokemonLogoCornerGraphics",
"YellowIntroGraphics1",
"YellowIntroGraphics2",
"YellowIntroCloudGFX",
"PikachuCriesPointerTable",
"CGBBasePalettes",
# the five Pikachu-only emotion bubbles (emotion_bubbles.asm)
"SkullEmote",
"HeartEmote",
"BoltEmote",
"ZzzEmote",
"FishEmote",
# Surfing Pikachu minigame sheets (gfx/surfing_pikachu.asm)
"SurfingPikachu1Graphics1",
"SurfingPikachu1Graphics2",
"SurfingPikachu1Graphics3",
)
# Yellow-only dialogue whose bank labels carry no leading underscore, so
# the text-label scan misses them (scripts/CeruleanMelaniesHouse.asm --
# the Bulbasaur gift and the pet flavor lines).
YELLOW_EXTRA_TEXT_LABELS = (
"MelanieText1", "MelanieText2", "MelanieText3",
"MelanieText4", "MelanieText5",
"MelanieBulbasaurText", "MelanieOddishText", "MelanieSandshrewText",
)
YELLOW_EXTRA_PALETTES = (
"PIKACHUS_BEACH",
"PIKACHU_PORTRAIT",
"PIKACHUS_BEACH_TITLE",
)
def _rename_keys(obj, renames):
"""Recursively rename dict keys (and rewrite matching string values)."""
if isinstance(obj, dict):
out = {}
for key, value in obj.items():
new_key = renames.get(key, key)
out[new_key] = _rename_keys(value, renames)
return out
if isinstance(obj, list):
return [_rename_keys(item, renames) for item in obj]
if isinstance(obj, str):
return renames.get(obj, obj)
return obj
def _replace_strings(obj, renames):
"""Recursively replace string values (and list entries) via renames."""
if isinstance(obj, dict):
return {k: _replace_strings(v, renames) for k, v in obj.items()}
if isinstance(obj, list):
return [_replace_strings(item, renames) for item in obj]
if isinstance(obj, str):
return renames.get(obj, obj)
return obj
def _drop_strings(obj, dropped):
"""Remove dropped names from lists and as dict keys / string values."""
if isinstance(obj, dict):
out = {}
for key, value in obj.items():
if key in dropped:
continue
if isinstance(value, str) and value in dropped:
continue
out[key] = _drop_strings(value, dropped)
return out
if isinstance(obj, list):
result = []
for item in obj:
if isinstance(item, str) and item in dropped:
continue
result.append(_drop_strings(item, dropped))
return result
return obj
def _resolve_mew_base_stats(yellow_symbols):
base = yellow_symbols.by_name.get("BaseStats")
if base is None:
raise SystemExit("pokeyellow.sym missing BaseStats (needed for Mew)")
return [base.bank, base.address + (MEW_DEX_NUMBER - 1) * BASE_STATS_ENTRY_SIZE]
def _rebuild_map_songs(pokeyellow, map_order, music_headers):
"""Zip pokeyellow data/maps/songs.asm onto the Yellow mapOrder."""
map_song_consts = []
path = os.path.join(pokeyellow, "data/maps/songs.asm")
for _, line in util.read_asm(path):
match = re.match(r"db\s+(MUSIC_\w+),", line.strip())
if match:
map_song_consts.append(match.group(1))
if len(map_song_consts) != len(map_order):
raise SystemExit(
f"Yellow songs.asm has {len(map_song_consts)} entries but "
f"mapOrder has {len(map_order)}")
out = {}
missing = []
for map_name, const_name in zip(map_order, map_song_consts):
label = _music_label(const_name, music_headers)
if label not in music_headers:
missing.append(f"{map_name}:{const_name}->{label}")
continue
out[map_name] = label
if missing:
raise SystemExit(
"Yellow map songs could not resolve music headers: "
+ ", ".join(missing[:12])
+ (" ..." if len(missing) > 12 else ""))
return out
def _rebuild_yellow_sourced(yellow, pokeyellow):
"""Replace sections that Yellow authors differently from Red."""
map_order, map_dims = constants.extract_map_constants(pokeyellow)
tileset_order = [
n for n in simple_constants(
pokeyellow, "constants/tileset_constants.asm") if n
]
sprite_consts = simple_constants(
pokeyellow, "constants/sprite_constants.asm")
sprite_order = [n or "UNUSED" for n in sprite_consts[1:]]
tile_animations = [
name or "UNUSED"
for name in simple_constants(
pokeyellow, "constants/map_data_constants.asm")
if name and name.startswith("TILEANIM_")
]
yellow["constants"]["mapOrder"] = map_order
yellow["constants"]["maps"] = map_dims
yellow["constants"]["tilesetOrder"] = tileset_order
yellow["constants"]["spriteOrder"] = sprite_order
yellow["maps"] = map_metadata(pokeyellow, map_dims)
yellow["tilesets"] = tileset_metadata(pokeyellow, tileset_order)
yellow["sprites"] = sprite_metadata(pokeyellow, sprite_order)
yellow["text"] = text_metadata(pokeyellow)
yellow["tileAnimations"] = tile_animations
yellow["audio"]["mapSongs"] = _rebuild_map_songs(
pokeyellow, map_order, yellow["audio"]["musicHeaders"])
# Red's positional header mapping lands Yellow's intro song
# (pokeyellow Music_YellowIntro, 1f:4294) under the name
# Music_IntroBattle; expose it under its own name too so the Yellow
# intro movie state can ask for the right label.
headers = yellow["audio"]["musicHeaders"]
if "Music_YellowIntro" not in headers and "Music_IntroBattle" in headers:
headers["Music_YellowIntro"] = dict(headers["Music_IntroBattle"])
return {
"mapCount": len(map_order),
"mapMeta": len(yellow["maps"]),
"tilesets": len(tileset_order),
"sprites": len(sprite_order),
"textLabels": len(yellow["text"]["labels"]),
}
def derive(red, pokeyellow, symbols_path):
"""Return the Yellow manifest derived from the Red manifest dict."""
yellow = copy.deepcopy(red)
yellow["romSha1"] = CANONICAL_YELLOW_SHA1
yellow_symbols = SymbolTable(symbols_path)
omit = set(OMIT_INTRO_SYMBOLS)
dropped = {name for name, alias in SYMBOL_ALIASES.items() if alias is None}
dropped |= omit
symbol_renames = {
name: alias for name, alias in SYMBOL_ALIASES.items() if alias
}
# Structural renames for Red-shaped leftovers (field townMap, etc.) before
# Yellow-sourced sections overwrite maps/text/sprites/tilesets.
structural = {}
structural.update(MAP_CONST_RENAMES)
structural.update(MAP_LABEL_RENAMES)
structural.update(GAME_CORNER_ID_RENAMES)
structural.update(FAN_CLUB_ID_RENAMES)
structural.update(symbol_renames)
yellow = _rename_keys(yellow, structural)
yellow = _replace_strings(yellow, structural)
yellow = _drop_strings(yellow, dropped)
rebuilt = _rebuild_yellow_sourced(yellow, pokeyellow)
for label in YELLOW_EXTRA_TEXT_LABELS:
if label not in yellow["text"]["labels"]:
yellow["text"]["labels"].append(label)
# Rebuild symbols from Red's name set with Yellow addresses / aliases,
# then ensure every Yellow-sourced label/header is present.
resolved = {}
missing = []
alias_hits = 0
for name in red["symbols"]:
if name in omit or name in dropped:
continue
if name == "MewBaseStats":
resolved["MewBaseStats"] = _resolve_mew_base_stats(yellow_symbols)
alias_hits += 1
continue
target = symbol_renames.get(name, name)
if name in symbol_renames:
alias_hits += 1
# Structural map-header rename may already have changed the key.
target = MAP_LABEL_RENAMES.get(target, target)
if target.endswith("_h"):
base = target[:-2]
target = MAP_LABEL_RENAMES.get(base, base) + "_h" \
if base in MAP_LABEL_RENAMES else target
symbol = yellow_symbols.by_name.get(target)
if symbol is None:
# Drop Red-only symbols that Yellow-sourced sections no longer need.
continue
resolved[target] = [symbol.bank, symbol.address]
print(
"warning: omitting Yellow-incompatible intro symbols "
f"(RomExtractor must skip): {', '.join(OMIT_INTRO_SYMBOLS)}"
)
for label in yellow["text"]["labels"]:
if label in resolved:
continue
symbol = yellow_symbols.by_name.get(label)
if symbol is None:
missing.append(label)
continue
resolved[label] = [symbol.bank, symbol.address]
for spec in yellow["maps"].values():
header = spec["label"] + "_h"
if header in resolved:
continue
symbol = yellow_symbols.by_name.get(header)
if symbol is None:
missing.append(header)
continue
resolved[header] = [symbol.bank, symbol.address]
# Pointer asm labels (ViridianPokeCenterChanseyText etc.) also need symbols
# when extract_text resolves through them.
for pointers in yellow["text"]["pointers"].values():
for spec in pointers.values():
for key in ("label", "text"):
name = spec.get(key)
if not name or name in resolved:
continue
symbol = yellow_symbols.by_name.get(name)
if symbol is not None:
resolved[name] = [symbol.bank, symbol.address]
yellow["symbols"] = resolved
for name in YELLOW_EXTRA_SYMBOLS:
symbol = yellow_symbols.by_name.get(name)
if symbol is None:
raise SystemExit(f"pokeyellow.sym missing Yellow extra symbol {name}")
yellow["symbols"][name] = [symbol.bank, symbol.address]
# Yellow-only songs live in music bank $20, which Red's engine never
# had; ship the bank in the audio pack and add their headers.
yellow["audio"]["programBanks"] = [2, 8, 31, 32]
for name in ("Music_MeetJessieJames", "Music_SurfingPikachu",
"Music_GBPrinter"):
symbol = yellow_symbols.by_name.get(name)
if symbol is None:
raise SystemExit(f"pokeyellow.sym missing Yellow song {name}")
yellow["audio"]["musicHeaders"][name] = {
"bank": symbol.bank, "address": symbol.address, "engine": 3,
}
# SuperPalettes grows by three Yellow-only SGB entries after GAMEFREAK.
order = list(yellow.get("paletteOrder") or [])
for name in YELLOW_EXTRA_PALETTES:
if name not in order:
order.append(name)
yellow["paletteOrder"] = order
# Yellow appends ICON_PIKACHU ($a) after Red's ten party icons
# (constants/icon_constants.asm); MonPartyData nybble $a resolves to
# it, drawn from the overworld PikachuSprite sheet.
icons = list(yellow.get("iconOrder") or [])
if "PIKACHU" not in icons:
icons.append("PIKACHU")
yellow["iconOrder"] = icons
still_missing = []
for name in yellow["symbols"]:
if name == "MewBaseStats":
continue
if name not in yellow_symbols.by_name:
still_missing.append(name)
if still_missing or missing:
raise SystemExit(
"pokeyellow.sym is missing symbols the manifest needs: "
+ ", ".join(sorted(set(still_missing + missing))[:20])
+ (" ..." if len(set(still_missing + missing)) > 20 else ""))
# Version-gated field bits from pokeyellow.
saved = util.ASM_DEFINES
util.ASM_DEFINES = set()
try:
yellow["field"]["presetNames"] = field.parse_preset_names(pokeyellow)
try:
yellow["field"]["credits"] = field.parse_credits(pokeyellow)
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.
finally:
util.ASM_DEFINES = saved
presets = yellow["field"]["presetNames"]
if "YELLOW" not in presets["player"] or "BLUE" not in presets["rival"]:
raise SystemExit(
f"Yellow preset-name parse unexpected: {presets!r}")
# Fixed Pikachu title (no TitleMons cycle); extractor fills image paths.
title = yellow["field"].setdefault("title", {})
title["layout"] = "yellow_pikachu"
title["cycleSpecies"] = []
title["music"] = title.get("music") or "Music_TitleScreen"
title["pikachuBg"] = {
"path": "assets/generated/title/pikachu_bg.png",
"width": 128, "height": 32,
}
title["pikachuOb"] = {
"path": "assets/generated/title/pikachu_ob.png",
"width": 96, "height": 8,
}
title["pikachu"] = {
"path": "assets/generated/title/pikachu.png",
"width": 96, "height": 72,
}
title["pikaBubble"] = {
"path": "assets/generated/title/pika_bubble.png",
"width": 56, "height": 32,
}
# Yellow's emote sheet grows the five Pikachu-only bubbles
# (engine/overworld/emotion_bubbles.asm Skull/Heart/Bolt/Zzz/FishEmote,
# constants/script_constants.asm order); RomExtractor rips whatever
# this bubble list names.
yellow_bubbles = ["EXCLAMATION_BUBBLE", "QUESTION_BUBBLE", "SMILE_BUBBLE",
"SKULL_BUBBLE", "HEART_BUBBLE", "BOLT_BUBBLE",
"ZZZ_BUBBLE", "FISH_BUBBLE"]
yellow["field"]["emotionBubbles"] = {
"path": "assets/generated/emotes.png",
"width": 16 * len(yellow_bubbles), "height": 16,
"bubbles": [{"name": name, "x": i * 16, "y": 0, "w": 16, "h": 16}
for i, name in enumerate(yellow_bubbles)],
}
# Ensure Melanie / Summer Beach town-map entries exist after rebuild.
locations = yellow["field"]["townMap"]["locations"]
if "CERULEAN_MELANIES_HOUSE" not in locations \
and "CERULEAN_CITY" in locations:
locations["CERULEAN_MELANIES_HOUSE"] = dict(locations["CERULEAN_CITY"])
if "SUMMER_BEACH_HOUSE" not in locations and "ROUTE_19" in locations:
locations["SUMMER_BEACH_HOUSE"] = dict(locations["ROUTE_19"])
meta = {
"aliasCount": alias_hits,
"omittedIntro": list(OMIT_INTRO_SYMBOLS),
"droppedSymbols": sorted(dropped - omit),
"rebuilt": rebuilt,
}
return yellow, meta
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--red", default=DEFAULT_RED,
help="shipped Red manifest to derive from")
parser.add_argument("--pokeyellow", default=DEFAULT_POKEYELLOW,
help="pokeyellow source checkout")
parser.add_argument("--symbols", default=DEFAULT_SYMBOLS,
help="pokeyellow.sym symbol file")
parser.add_argument("--out", default=DEFAULT_OUT)
args = parser.parse_args()
pokeyellow = os.path.abspath(args.pokeyellow)
if not os.path.isfile(os.path.join(pokeyellow, "main.asm")):
raise SystemExit(f"{pokeyellow} is not a pokeyellow checkout")
with open(args.red, encoding="utf-8") as f:
red = json.load(f)
yellow, meta = derive(red, pokeyellow, os.path.abspath(args.symbols))
with open(args.out, "w", encoding="utf-8", newline="\n") as f:
json.dump(yellow, f, ensure_ascii=False, indent=2, sort_keys=True)
f.write("\n")
print(f"wrote {args.out}")
print(f"symbols: {len(yellow['symbols'])}")
print(f"aliases applied: {meta['aliasCount']}")
print(f"omitted intro: {meta['omittedIntro']}")
print(f"dropped: {len(meta['droppedSymbols'])} "
f"({', '.join(meta['droppedSymbols'][:8])}...)")
print(f"rebuilt: {meta['rebuilt']}")
if __name__ == "__main__":
main()
+1
View File
@@ -10,6 +10,7 @@ from dataclasses import dataclass
CANONICAL_RED_SHA1 = "ea9bcae617fdf159b045185467ae58b2e4a48b9a"
CANONICAL_BLUE_SHA1 = "d7037c83e1ae5b39bde3c30787637ba1d4c48ce2"
CANONICAL_YELLOW_SHA1 = "cc7d03262ebfaf2f06772c1a480c7d9d5f4a38e1"
ROM_BANK_SIZE = 0x4000
File diff suppressed because it is too large Load Diff
+223
View File
@@ -0,0 +1,223 @@
"""Red→Yellow symbol / label remaps for make_yellow_manifest.py.
Names present in both pokered and pokeyellow.sym are remapped by address
only. This table covers Red symbol names that do not exist in Yellow:
either an equivalent Yellow label, or None to drop the symbol (and strip
text.labels / metadata references).
"""
from __future__ import annotations
# Red symbol name -> Yellow symbol name, or None to omit.
SYMBOL_ALIASES: dict[str, str | None] = {
# Map header
"CeruleanTradeHouse_h": "CeruleanMelaniesHouse_h",
# Cerulean City Slowbro -> Electrode
"_CeruleanCityCooltrainerF1SlowbroPunchText":
"_CeruleanCityCooltrainerF1ElectrodePunchText",
"_CeruleanCityCooltrainerF1SlowbroUseSonicboomText":
"_CeruleanCityCooltrainerF1ElectrodeUseSonicboomText",
"_CeruleanCityCooltrainerF1SlowbroWithdrawText":
"_CeruleanCityCooltrainerF1ElectrodeWithdrawText",
"_CeruleanCitySlowbroIgnoredOrdersText":
"_CeruleanCityElectrodeIgnoredOrdersText",
"_CeruleanCitySlowbroIsLoafingAroundText":
"_CeruleanCityElectrodeIsLoafingAroundText",
"_CeruleanCitySlowbroTookASnoozeText":
"_CeruleanCityElectrodeTookASnoozeText",
"_CeruleanCitySlowbroTurnedAwayText":
"_CeruleanCityElectrodeTurnedAwayText",
# Cerulean Trade House granny -> Melanie house
"_CeruleanTradeHouseGrannyText": "MelanieText1",
# Game Corner clerk / NPC renames
"_GameCornerClerk1CantAffordTheCoinsText":
"_GameCornerClerkCantAffordTheCoinsText",
"_GameCornerClerk1CoinCaseIsFullText":
"_GameCornerClerkCoinCaseIsFullText",
"_GameCornerClerk1DoYouNeedSomeGameCoinsText":
"_GameCornerClerkDoYouNeedSomeGameCoinsText",
"_GameCornerClerk1DontHaveCoinCaseText":
"_GameCornerClerkDontHaveCoinCaseText",
"_GameCornerClerk1PleaseComePlaySometimeText":
"_GameCornerClerkPleaseComePlaySometimeText",
"_GameCornerClerk1ThanksHereAre50CoinsText":
"_GameCornerClerkThanksHereAre50CoinsText",
"_GameCornerClerk2INeedMoreCoinsText":
"_GameCornerMiddleAgedMan2INeedMoreCoinsText",
"_GameCornerClerk2Received20CoinsText":
"_GameCornerMiddleAgedMan2Received20CoinsText",
"_GameCornerClerk2WantSomeCoinsText":
"_GameCornerMiddleAgedMan2WantSomeCoinsText",
"_GameCornerClerk2YouHaveLotsOfCoinsText":
"_GameCornerMiddleAgedMan2YouHaveLotsOfCoinsText",
"_GameCornerFishingGuruDontNeedMyCoinsText":
"_GameCornerFishingGuru1DontNeedMyCoinsText",
"_GameCornerFishingGuruReceived10CoinsText":
"_GameCornerFishingGuru1Received10CoinsText",
"_GameCornerFishingGuruWantToPlayText":
"_GameCornerFishingGuru1WantToPlayText",
"_GameCornerFishingGuruWinsComeAndGoText":
"_GameCornerFishingGuru1WinsComeAndGoText",
"_GameCornerGentlemanCloselyWatchTheReelsText":
"_GameCornerFishingGuru2CloselyWatchTheReelsText",
"_GameCornerGentlemanReceived20CoinsText":
"_GameCornerFishingGuru2Received20CoinsText",
"_GameCornerGentlemanThrowingMeOffText":
"_GameCornerFishingGuru2ThrowingMeOffText",
"_GameCornerGentlemanYouGotYourOwnCoinsText":
"_GameCornerFishingGuru2YouGotYourOwnCoinsText",
# Link / cable-club prompts (Yellow folds these into Colosseum texts)
"_LinkCanceledText": "_ColosseumCanceledText",
"_PleaseWaitText": "_ColosseumPleaseWaitText",
"_WhereWouldYouLikeText": "_ColosseumWhereToText",
# Mt. Moon Rocket1 -> Jessie/James
"_MtMoonB2FRocket1AfterBattleText": "_MtMoonJessieJamesText4",
"_MtMoonB2FRocket1BattleText": "_MtMoonJessieJamesText1",
"_MtMoonB2FRocket1EndBattleText": "_MtMoonJessieJamesText3",
# Oak's Lab starter-choice texts -> Yellow Pikachu/Eevee flow
"_OaksLabLastMonText": None,
"_OaksLabMonEnergeticText": None,
"_OaksLabOak1RaiseYourYoungPokemonText":
"_OaksLabOak1YouShouldTalkToIt",
"_OaksLabOak1WhichPokemonDoYouWantText":
"_OaksLabOak1GoAheadItsYours",
"_OaksLabReceivedMonText": "_OaksLabReceivedText",
"_OaksLabRivalGoAheadAndChooseText": None,
"_OaksLabRivalIllTakeThisOneText": "_OaksLabRivalTakesText1",
"_OaksLabRivalReceivedMonText": None,
"_OaksLabRivalWhatDidYouCallMeForText":
"_OaksLabRivalWhatAboutMeText",
"_OaksLabThoseArePokeBallsText": "_OaksLabThatsAPokeball",
"_OaksLabYouWantBulbasaurText": None,
"_OaksLabYouWantCharmanderText": None,
"_OaksLabYouWantSquirtleText": None,
# Pallet Town Oak
"_PalletTownOakItsUnsafeText": "_PalletTownOakHeyWaitDontGoOutText",
# Fan Club: Pikachu fan -> Clefairy fan; signs removed in Yellow
"_PokemonFanClubPikachuFanBetterText":
"_PokemonFanClubClefairyFanBetterText",
"_PokemonFanClubPikachuFanNormalText":
"_PokemonFanClubClefairyFanNormalText",
"_PokemonFanClubPikachuText": "_PokemonFanClubClefairyText",
"_PokemonFanClubSign1Text": None,
"_PokemonFanClubSign2Text": None,
# Pokemon Tower 7F Rockets -> Jessie/James
"_PokemonTower7FRocket1AfterBattleText":
"_PokemonTowerJessieJamesText4",
"_PokemonTower7FRocket1BattleText":
"_PokemonTowerJessieJamesText1",
"_PokemonTower7FRocket1EndBattleText":
"_PokemonTowerJessieJamesText3",
"_PokemonTower7FRocket2AfterBattleText":
"_PokemonTowerJessieJamesText4",
"_PokemonTower7FRocket2BattleText":
"_PokemonTowerJessieJamesText2",
"_PokemonTower7FRocket2EndBattleText":
"_PokemonTowerJessieJamesText3",
"_PokemonTower7FRocket3AfterBattleText":
"_PokemonTowerJessieJamesText4",
"_PokemonTower7FRocket3BattleText":
"_PokemonTowerJessieJamesText1",
"_PokemonTower7FRocket3EndBattleText":
"_PokemonTowerJessieJamesText3",
# Rocket Hideout B4F: Rocket1/2 -> Jessie/James; Rocket3 -> remaining Rocket
"_RocketHideoutB4FRocket1AfterBattleText":
"_RocketHideoutJessieJamesText4",
"_RocketHideoutB4FRocket1BattleText":
"_RocketHideoutJessieJamesText1",
"_RocketHideoutB4FRocket1EndBattleText":
"_RocketHideoutJessieJamesText3",
"_RocketHideoutB4FRocket2AfterBattleText":
"_RocketHideoutJessieJamesText4",
"_RocketHideoutB4FRocket2BattleText":
"_RocketHideoutJessieJamesText2",
"_RocketHideoutB4FRocket2EndBattleText":
"_RocketHideoutJessieJamesText3",
"_RocketHideoutB4FRocket3AfterBattleText":
"_RocketHideoutB4FRocketAfterBattleText",
"_RocketHideoutB4FRocket3BattleText":
"_RocketHideoutB4FRocketBattleText",
"_RocketHideoutB4FRocket3EndBattleText":
"_RocketHideoutB4FRocketEndBattleText",
# Route 6 shared after-battle -> M1-specific (F1 updated in manifest)
"_Route6CooltrainerAfterBattleText":
"_Route6CooltrainerM1AfterBattleText",
# Route 9 CooltrainerM1 -> AJ
"_Route9CooltrainerM1AfterBattleText": "_Route9AJAfterBattleText",
"_Route9CooltrainerM1BattleText": "_Route9AJBattleText",
"_Route9CooltrainerM1EndBattleText": "_Route9AJEndBattleText",
# Silph Co. unreferenced Porygon text
"_SilphCo10FPorygonText": None,
# Silph Co. 11F Rocket1 -> Jessie/James
"_SilphCo11FRocket1AfterBattleText": "_SilphCoJessieJamesText4",
"_SilphCo11FRocket1BattleText": "_SilphCoJessieJamesText1",
"_SilphCo11FRocket1EndBattleText": "_SilphCoJessieJamesText3",
# Viridian City old man post-training lines
"_ViridianCityOldManKnowHowToCatchPokemonText":
"_ViridianCityOldManHadMyCoffeeNowText",
"_ViridianCityOldManTimeIsMoneyText":
"_ViridianCityOldManLosingMyTouchText",
# Viridian Forest Youngster5 is a trainer in Yellow (no talk far-text)
"_ViridianForestYoungster5Text": None,
# Celadon Mansion granny (Yellow uses happiness-gated Text2..)
"_CeladonMansion1FGrannyText": "_CeladonMansion1Text2",
}
# Red FightIntro Gengar/Nidorino 2bpp labels — Yellow uses a different intro.
# Omitted from symbols; RomExtractor must skip (see field.intro paths).
OMIT_INTRO_SYMBOLS = (
"FightIntroBackMon",
"FightIntroFrontMon",
"FightIntroFrontMon2",
"FightIntroFrontMon3",
)
# Map-constant / label renames applied throughout the manifest.
MAP_CONST_RENAMES = {
"CERULEAN_TRADE_HOUSE": "CERULEAN_MELANIES_HOUSE",
}
MAP_LABEL_RENAMES = {
"CeruleanTradeHouse": "CeruleanMelaniesHouse",
}
# Game Corner object / text-pointer id renames (Yellow single clerk + gurus).
GAME_CORNER_ID_RENAMES = {
"TEXT_GAMECORNER_CLERK1": "TEXT_GAMECORNER_CLERK",
"TEXT_GAMECORNER_CLERK2": "TEXT_GAMECORNER_MIDDLE_AGED_MAN2",
"TEXT_GAMECORNER_FISHING_GURU": "TEXT_GAMECORNER_FISHING_GURU1",
"TEXT_GAMECORNER_GENTLEMAN": "TEXT_GAMECORNER_FISHING_GURU2",
"GAMECORNER_CLERK1": "GAMECORNER_CLERK",
"GAMECORNER_CLERK2": "GAMECORNER_MIDDLE_AGED_MAN2",
"GAMECORNER_FISHING_GURU": "GAMECORNER_FISHING_GURU1",
"GAMECORNER_GENTLEMAN": "GAMECORNER_FISHING_GURU2",
"GameCornerClerk1Text": "GameCornerClerkText",
"GameCornerClerk2Text": "GameCornerMiddleAgedMan2Text",
"GameCornerFishingGuruText": "GameCornerFishingGuru1Text",
"GameCornerGentlemanText": "GameCornerFishingGuru2Text",
}
FAN_CLUB_ID_RENAMES = {
"TEXT_POKEMONFANCLUB_PIKACHU_FAN": "TEXT_POKEMONFANCLUB_CLEFAIRY_FAN",
"TEXT_POKEMONFANCLUB_PIKACHU": "TEXT_POKEMONFANCLUB_CLEFAIRY",
"POKEMONFANCLUB_PIKACHU_FAN": "POKEMONFANCLUB_CLEFAIRY_FAN",
"POKEMONFANCLUB_PIKACHU": "POKEMONFANCLUB_CLEFAIRY",
"PokemonFanClubPikachuFanText": "PokemonFanClubClefairyFanText",
"PokemonFanClubPikachuText": "PokemonFanClubClefairyText",
}