CLOSES #1396, CLOSES #1398, CLOSES #1400, CLOSES #1401, CLOSES #1406, CLOSES #1407, CLOSES #1411, CLOSES #1413, CLOSES #1415, CLOSES #1416, CLOSES #1417, CLOSES #1419, CLOSES #1421, CLOSES #1422, CLOSES #1423, CLOSES #1424, CLOSES #1425, CLOSES #1427, CLOSES #1428, CLOSES #1429, CLOSES #1431, CLOSES #1432, CLOSES #1433, CLOSES #1435, CLOSES #1437, CLOSES #1440, CLOSES #1441, CLOSES #1442, CLOSES #1443, CLOSES #1447, CLOSES #1449, CLOSES #1456, CLOSES #1464, CLOSES #1465, CLOSES #1468, CLOSES #1469, CLOSES #1470

This commit is contained in:
bryanthaboi
2026-08-17 10:15:06 -04:00
parent 3cca70608f
commit 45519ad550
61 changed files with 2481 additions and 255 deletions
+17
View File
@@ -702,6 +702,23 @@ M.MT_MOON_B2F = {
return false
end,
talk = {
-- MtMoonB2FSuperNerdText: once beaten his line turns on the fossils
-- (scripts/MtMoonB2F.asm:187), which the header's flat `after` can't hold
TEXT_MTMOONB2F_SUPER_NERD = function(game, ow, npc, done)
if not superNerdBeaten(ow) then
engageSuperNerd(game, ow, done)
return
end
local TextBox = require("src.render.TextBox")
local t = game.data.text
local flags = game.save.flags
local line = (flags.EVENT_GOT_DOME_FOSSIL or flags.EVENT_GOT_HELIX_FOSSIL)
and (t._MtMoonB2FSuperNerdTheresAPokemonLabText
or "Far away, on\nCINNABAR ISLAND,\nthere's a POKéMON\nLAB.")
or (t._MtMoonB2fSuperNerdEachTakeOneText
or "We'll each take\none!\nNo being greedy!")
game.stack:push(TextBox.new(game, line, done))
end,
TEXT_MTMOONB2F_DOME_FOSSIL = mtMoonFossil(
"DOME_FOSSIL", "MTMOONB2F_HELIX_FOSSIL", "EVENT_GOT_DOME_FOSSIL"),
TEXT_MTMOONB2F_HELIX_FOSSIL = mtMoonFossil(
+30 -27
View File
@@ -118,33 +118,36 @@ M.ROUTE_15_GATE_2F = {
M.MT_MOON_POKECENTER = {
talk = {
TEXT_MTMOONPOKECENTER_MAGIKARP_SALESMAN = function(game, ow, npc, done)
local t = text(game)
if game.save.flags.EVENT_BOUGHT_MAGIKARP then
push(game, t._MtMoonPokecenterMagikarpSalesmanNoRefundsText
or "Well, I don't\ngive refunds!", done)
return
end
ask(game, t._MtMoonPokecenterMagikarpSalesmanOfferText
or "MAGIKARP! A\nsteal at ¥500!\nWant one?", function(yes)
if not yes then
push(game, t._MtMoonPokecenterMagikarpSalesmanNoText
or "No? I'm only\nselling today!", done)
return
end
if game.save.money < 500 then
push(game, t._MtMoonPokecenterMagikarpSalesmanNoMoneyText
or "You'll need more\nmoney than that!", done)
return
end
game.save.money = game.save.money - 500
game.save.flags.EVENT_BOUGHT_MAGIKARP = true
local Commands = require("src.script.Commands")
Commands.give_pokemon({ save = game.save, game = game, overworld = ow },
"MAGIKARP", 5)
push(game, t._GotMonText or "{PLAYER} got\n{RAM:wNameBuffer}!", done)
end)
end,
-- command rows, not a Lua handler: give_pokemon needs a runner to AskName (#1407)
TEXT_MTMOONPOKECENTER_MAGIKARP_SALESMAN = {
{ "check_flag", "EVENT_BOUGHT_MAGIKARP" },
{ "jump_if_true", "no_refunds" },
-- MONEY_BOX goes up between the offer and YesNoChoice -- MtMoonPokecenter.asm:31
{ "text_opts", { money = true } },
{ "ask", "_MtMoonPokecenterMagikarpSalesmanIGotADealText" },
{ "jump_if_false", "declined" },
{ "check_money", 500 },
{ "jump_if_false", "no_money" },
{ "give_pokemon", "MAGIKARP", 5 },
-- MtMoonPokecenter.asm:49 `jr nc, .done`: a refused gift is never charged
{ "jump_if_false", "box_full" },
{ "take_money", 500 },
{ "set_flag", "EVENT_BOUGHT_MAGIKARP" },
{ "text_sound", "Get_Item1" },
{ "show_text", "_GotMonText", { RAM = "MAGIKARP" } },
{ "jump", "end" },
{ "label", "box_full" },
{ "show_text", "_BoxIsFullText" },
{ "jump", "end" },
{ "label", "declined" },
{ "show_text", "_MtMoonPokecenterMagikarpSalesmanNoText" },
{ "jump", "end" },
{ "label", "no_money" },
{ "show_text", "_MtMoonPokecenterMagikarpSalesmanNoMoneyText" },
{ "jump", "end" },
{ "label", "no_refunds" },
{ "show_text", "_MtMoonPokecenterMagikarpSalesmanNoRefundsText" },
},
},
}
+76 -43
View File
@@ -24,6 +24,7 @@ local Runtime = require("src.mods.Runtime")
local BattleSafety = require("src.battle.BattleSafety")
local Screens = require("src.ui.Screens")
local Status = require("src.battle.Status")
local Theme = require("src.ui.Theme")
local Timing = require("src.core.Timing")
local TrainerAI = require("src.battle.TrainerAI")
local TurnOrder = require("src.battle.TurnOrder")
@@ -983,8 +984,8 @@ end
-- Message that opens YES/NO once typed out, keeping the text visible
-- underneath (pokered `done` + TWO_OPTION_MENU / TextBox opts.choice).
function BattleState:sayChoice(text, onChoose)
table.insert(self.queue, { text = text, choice = onChoose })
function BattleState:sayChoice(text, onChoose, opts)
table.insert(self.queue, { text = text, choice = onChoose, choiceOpts = opts })
end
function BattleState:act(fn)
@@ -1485,7 +1486,7 @@ function BattleState:updateQueue()
local fn = item.choice
battle.current = nil
fn(yes)
end))
end, item.choiceOpts))
return true
end
if item and item.auto then
@@ -1843,14 +1844,7 @@ function BattleState:enter()
self:say(self:sendOutText(self.player.name))
-- then the POOF plays and the mon appears with its cry
-- (SendOutMon: message -> AnimateSendingOutMon -> PlayCry)
table.insert(self.queue, { anim = "POOF_ANIM", attackerIsPlayer = false })
self:act(function()
self.sendingOut = false
-- SendOutMon (core.asm:1757-1762): after the poof the mon grows
-- out of the ball (AnimateSendingOutMon at hlcoord 4,11)
self:startGrowIn(self.player)
self:waitSfxNext(self:playEntranceCry(self.player))
end)
self:queueSendOutAnim(true)
self:markParticipant()
end
self.phase = "messages"
@@ -2680,13 +2674,7 @@ function BattleState:resolveSwitch(newMon)
sendOutMonCursors(self)
self.sendingOut = true
self:sayNext(self:sendOutText(self.player.name))
self:animNext("POOF_ANIM", false)
self:actNext(function()
self.sendingOut = false
-- SendOutMon (core.asm:1757-1762): poof, then the grow-in
self:startGrowIn(self.player)
self:waitSfxNext(self:playEntranceCry(self.player))
end)
self:queueSendOutAnim(false)
end)
self:act(function()
self:executeAction(self.enemy, self.player, self:enemyAction())
@@ -3231,6 +3219,50 @@ function BattleState:startGrowIn(battler)
table.insert(self.queue, self.nextInsert, { wait = 12 })
end
-- SendOutMon branches on IsThisPartyMonStarterPikachu before the animation:
-- the starter gets no ball and no grow-in -- pokeyellow core.asm:1798-1819
function BattleState:starterPikachuSendOut()
if not require("src.core.GameVersion").isYellow() then return false end
local mon = self.player and self.player.mon
return require("src.world.PikachuFollower")
.isStarterPikachu(self.game.save, mon)
end
-- StarterPikachuBattleEntranceAnimation: the back pic walks in from hlcoord
-- 0,5, one column every 2 frames -- engine/battle/pikachu_entrance_anim.asm:1
function BattleState:startPikachuEntrance()
self:slidePic("playerMon", -56, 0, 8, 2)
self.nextInsert = (self.nextInsert or 0) + 1
table.insert(self.queue, self.nextInsert, { wait = 16 })
self:actNext(function()
self:slidePic("playerMon")
self:waitSfxNext(self:playEntranceCry(self.player))
end)
end
-- Player send-out tail: POOF_ANIM + AnimateSendingOutMon (core.asm:1757-1762),
-- or the starter Pikachu entrance instead (pokeyellow core.asm:1798-1819)
function BattleState:queueSendOutAnim(append)
local pikachu = self:starterPikachuSendOut()
if not pikachu then
if append then
table.insert(self.queue, { anim = "POOF_ANIM", attackerIsPlayer = false })
else
self:animNext("POOF_ANIM", false)
end
end
local fn = function()
self.sendingOut = false
if pikachu then
self:startPikachuEntrance()
else
self:startGrowIn(self.player)
self:waitSfxNext(self:playEntranceCry(self.player))
end
end
if append then self:act(fn) else self:actNext(fn) end
end
-- Should the low-health alarm sound this frame? pokered keys it off
-- the drawn bar color: DrawPlayerHUDAndHPBar (core.asm:1846-1875) sets
-- wLowHealthAlarm bit 7 when GetHealthBarColor says the player bar is
@@ -3289,17 +3321,18 @@ end
-- _ScrollTrainerPicAfterBattle (engine/battle/scroll_draw_trainer_pic.asm)
-- brings the beaten foe back in from the right one column every 4 frames.
-- picOff holds the live programs by slot -- "foe" = the enemy trainer pic,
-- "back" = the player's back pic -- as a screen-pixel x offset stepped
-- toward `to`; updateFx advances them, drawPicsLayer adds them, and the
-- queue rows that start them park a { wait } of the matching length. Call
-- with no target to clear a slot (#317, #282).
function BattleState:slidePic(slot, from, to, step)
-- "back" = the player's back pic, "playerMon" = the player's mon back pic --
-- as a screen-pixel x offset stepped toward `to`; updateFx advances them,
-- drawPicsLayer adds them, and the queue rows that start them park a
-- { wait } of the matching length. Call with no target to clear
-- a slot (#317, #282).
function BattleState:slidePic(slot, from, to, step, hold)
self.picOff = self.picOff or {}
if to == nil then
self.picOff[slot] = nil
return
end
self.picOff[slot] = { x = from or 0, to = to, step = step or 4 }
self.picOff[slot] = { x = from or 0, to = to, step = step or 4, hold = hold }
end
-- the live x offset for a pic slot, 0 when nothing is sliding
@@ -3317,10 +3350,18 @@ function BattleState:updateFx()
-- the battle slot) until its owner clears the slot
if self.picOff then
for _, p in pairs(self.picOff) do
if p.x < p.to then
p.x = math.min(p.to, p.x + p.step)
elseif p.x > p.to then
p.x = math.max(p.to, p.x - p.step)
local move = true
if p.hold then
p.held = (p.held or 0) + 1
move = p.held >= p.hold
if move then p.held = 0 end
end
if move then
if p.x < p.to then
p.x = math.min(p.to, p.x + p.step)
elseif p.x > p.to then
p.x = math.max(p.to, p.x - p.step)
end
end
end
end
@@ -4231,7 +4272,7 @@ function BattleState:enemyMonFainted()
end
end,
})
end)
end, { box = Theme.trainerSwitchBox })
end
self:act(function()
local previous = self.enemy
@@ -4295,12 +4336,7 @@ function BattleState:enemyMonFainted()
sendOutMonCursors(self)
self.sendingOut = true
self:sayNext(self:sendOutText(self.player.name))
self:animNext("POOF_ANIM", false)
self:actNext(function()
self.sendingOut = false
self:startGrowIn(self.player)
self:waitSfxNext(self:playEntranceCry(self.player))
end)
self:queueSendOutAnim(false)
end)
return
end
@@ -4491,13 +4527,7 @@ function BattleState:openReplacementMenu()
sendOutMonCursors(self)
self.sendingOut = true
self:sayNext(self:sendOutText(self.player.name))
self:animNext("POOF_ANIM", false)
self:actNext(function()
self.sendingOut = false
-- SendOutMon (core.asm:1757-1762): poof, then the grow-in
self:startGrowIn(self.player)
self:waitSfxNext(self:playEntranceCry(self.player))
end)
self:queueSendOutAnim(false)
end,
})
end)
@@ -5749,7 +5779,10 @@ function BattleState:drawPicsLayer(slide, sx, sy, onlySide, skipMenuClip)
else
local dx, dy = BattleState.backPlacement(img:getWidth(),
img:getHeight(), pad, padL, s)
self:drawBattlerPic(self.player, dx + sx, dy + sy, s)
-- picOffset: StarterPikachuBattleEntranceAnimation walking the pic in
-- from the left -- engine/battle/pikachu_entrance_anim.asm:1
self:drawBattlerPic(self.player, dx + sx + self:picOffset("playerMon"),
dy + sy, s)
end
end
if clipped then
+3 -2
View File
@@ -243,8 +243,9 @@ end
C.incbgeffect = function(self, row) self.bg:incEffect(row[2]) end
C.battlergfx_1row = function(self) self:loadBattlerGfx(1) end
C.battlergfx_2row = function(self) self:loadBattlerGfx(2) end
-- anim_commands.asm:317: $d9 (anim_battlergfx_2row) dispatches to _1Row
C.battlergfx_1row = function(self) self:loadBattlerGfx(2) end
C.battlergfx_2row = function(self) self:loadBattlerGfx(1) end
-- GetPokeBallWobble's answer, which the ball's own script then branches on.
C.checkpokeball = function(self)
+24 -16
View File
@@ -3363,25 +3363,33 @@ function Battle:awardExperience(loser)
-- has no EXP.ALL (the EXP.SHARE pass below is its replacement), so it is
-- accepted and ignored rather than changing what is printed. `recipients`,
-- `holders` and `halved` are the Gen 2 additions.
if not Runtime.wantsHook("battle.exp_award") then return vanillaAward() end
local alive = {}
for _, index in ipairs(participants) do
local mon = self.party[index]
if mon and (mon.hp or 0) > 0 then alive[#alive + 1] = mon end
end
local function applyShare(mon, split)
for index, candidate in ipairs(self.party) do
if candidate == mon then
return self:giveExperiencePass(loser, def, { index },
math.max(1, split or 1), halved)
if Runtime.wantsHook("battle.exp_award") then
local alive = {}
for _, index in ipairs(participants) do
local mon = self.party[index]
if mon and (mon.hp or 0) > 0 then alive[#alive + 1] = mon end
end
local function applyShare(mon, split)
for index, candidate in ipairs(self.party) do
if candidate == mon then
return self:giveExperiencePass(loser, def, { index },
math.max(1, split or 1), halved)
end
end
end
Runtime.call("battle.exp_award", vanillaAward, {
battle = self, participants = #participants, alive = alive,
applyShare = applyShare, recipients = participants, holders = holders,
halved = halved, loser = loser,
})
else
vanillaAward()
end
Runtime.call("battle.exp_award", vanillaAward, {
battle = self, participants = #participants, alive = alive,
applyShare = applyShare, recipients = participants, holders = holders,
halved = halved, loser = loser,
})
-- GiveExperiencePoints .done falls through ResetBattleParticipants into
-- AddBattleParticipant (engine/battle/core.asm:7116 and :3033).
self.participants = {}
if self.playerIndex then self.participants[self.playerIndex] = true end
end
-- The answer to a `choose-forget`: drop the move in `slot` and put the
+7 -8
View File
@@ -347,14 +347,13 @@ function Catching.attempt(opts)
-- that edits o.catchRate or o.status changes the roll exactly as it does
-- on Red, and one that returns `caught, rate` replaces it outright.
--
-- `mon` and `def` are whatever the catch site supplied. Gold's battle
-- screen (src/ui/gen2/BattleState.lua) hands this module a FLAT opts table
-- -- hp, maxHp, catchRate, status, species -- rather than the mon and its
-- record, so both are nil there until it passes them; nil, not a stand-in
-- a mod would read as the real mon. Passing `battle = self.battle` and
-- `mon = enemy` with them is what puts the capture tail below on the real
-- catch: the Transform reload and the battle.catch_exp hook both hang off
-- the battle, and neither can be reached from a flat table.
-- `mon` and `def` are whatever the catch site supplied, and nil rather
-- than a stand-in a mod would read as the real mon when it supplied
-- neither. Gold's battle screen (src/ui/gen2/BattleState.lua) passes them
-- with `battle` alongside the flat hp/maxHp/catchRate/status fields, which
-- is what puts the capture tail below on the real catch: the Transform
-- reload and the battle.catch_exp hook both hang off the battle, and
-- neither can be reached from a flat table.
caught, rate = Runtime.call("catch.rate", function(_, _, _, o)
return Catching.vanillaAttempt(o)
end, opts.ball or "POKE_BALL", opts.mon, opts.def, opts)
+3
View File
@@ -939,6 +939,8 @@ function Game2:load()
self.data.gen2Maps = loadGenerated("data/generated/maps.lua")
self.data.gen2Tilesets = loadGenerated("data/generated/tilesets.lua")
self.data.gen2Roofs = loadGenerated("data/generated/roofs.lua")
-- engine/events/magnet_train.asm:165 DrawMagnetTrain
self.data.gen2Field = loadGenerated("data/generated/field.lua")
self.data.gen2Marts = loadGenerated("data/generated/marts.lua")
self.data.gen2Scripts = loadGenerated("data/generated/scripts.lua")
self.data.gen2StdScripts = loadGenerated("data/generated/std_scripts.lua")
@@ -1965,6 +1967,7 @@ function Game2:applyOptions()
haptics = options.haptics,
})
require("src.core.VideoMode").applyOptions(options)
require("src.core.FrameCap").applyOptions(options)
local GBCFX = require("src.render.GBCFX")
if GBCFX.applyOptions(options) and self.save then
-- applyOptions returns true when it had to clear an unsupported level.
+17 -5
View File
@@ -6,11 +6,14 @@ local HostShell = {}
-- spawn tries to link against the libraries we're shipping instead of the
-- system ones. We want to unset the var so that any system tools can find
-- their proper libraries. Only needed when running in an AppImage.
-- LD_PRELOAD is Steam's overlay (#1470): its 32-bit half cannot load into a
-- 64-bit child, so ld.so prints an error into that child's output.
function HostShell.envPrefix()
if os.getenv("APPIMAGE") then
return "env -u LD_LIBRARY_PATH "
end
return ""
local unset = ""
if os.getenv("APPIMAGE") then unset = unset .. "-u LD_LIBRARY_PATH " end
if os.getenv("LD_PRELOAD") then unset = unset .. "-u LD_PRELOAD " end
if unset == "" then return "" end
return "env " .. unset
end
-- Windows: every host tool we shell out to (curl for the update and mod-index
@@ -222,11 +225,20 @@ end
local HTTP_MARK = "\n__gen1recomp_http__"
local HTTP_MARK_FMT = "\\n__gen1recomp_http__%{http_code}"
local function stripLoaderNoise(out)
while out:find("^ERROR: ld%.so:") do
local nl = out:find("\n", 1, true)
if not nl then return "" end
out = out:sub(nl + 1)
end
return out
end
-- Split a curl pipe's output into (body, status, noise). `status` is nil
-- when curl never got far enough to have one (DNS failure, no route, a
-- timeout), in which case `noise` carries curl's own complaint.
local function splitCurlOutput(out)
out = tostring(out or "")
out = stripLoaderNoise(tostring(out or ""))
local at = nil
local from = 1
while true do
+3 -4
View File
@@ -666,11 +666,10 @@ function SaveData.setModEnabled(options, id, enabled, version)
options.modsByVersion = options.modsByVersion or {}
local bucket = options.modsByVersion[version] or {}
options.modsByVersion[version] = bucket
-- no shared flag reads as enabled, the same default the loader applies to a
-- missing entry, so a fresh install never fills the overlay with agreement
-- with no shared flag the default is the caller's (experimental mods read as
-- disabled), so the answer is stored outright rather than judged against one
local shared = options.mods and options.mods[id]
if type(shared) ~= "boolean" then shared = true end
if shared == enabled then
if type(shared) == "boolean" and shared == enabled then
bucket[id] = nil
else
bucket[id] = enabled
+30
View File
@@ -101,6 +101,14 @@ function Boxes.canDeposit(save, partyIndex, boxIndex)
return true
end
-- RestorePPOfDepositedPokemon (engine/pokemon/move_mon.asm:711-773): every
-- slot back to GetMaxPPOfMove, which already carries its own PP Up count.
function Boxes.restorePP(mon)
for _, move in ipairs((mon and mon.moves) or {}) do
if type(move) == "table" then move.pp = move.maxPp or move.pp end
end
end
function Boxes.deposit(save, partyIndex, boxIndex)
local ok, reason = Boxes.canDeposit(save, partyIndex, boxIndex)
if not ok then return false, reason end
@@ -112,6 +120,9 @@ function Boxes.deposit(save, partyIndex, boxIndex)
Mail.removeSlot(save, partyIndex)
local box = Boxes.box(save, boxIndex)
box[#box + 1] = mon
-- SendGetMonIntoFromBox's PC_DEPOSIT arm ends in RestorePPOfDepositedPokemon
-- (engine/pokemon/move_mon.asm:633-635, :696-700).
Boxes.restorePP(mon)
return true, mon
end
@@ -129,6 +140,15 @@ function Boxes.withdraw(save, boxIndex, slot)
local ok, reason = Boxes.canWithdraw(save, boxIndex, slot)
if not ok then return false, reason end
local mon = table.remove(Boxes.box(save, boxIndex), slot)
-- The `get mon into Party` arm alone heals: status cleared and MON_MAXHP
-- copied over MON_HP, an egg's staying 0 (move_mon.asm:666-693).
mon.status = nil
mon.statusTurns = nil
if mon.isEgg then
mon.hp = 0
else
mon.hp = mon.maxHp or mon.hp
end
save.party = save.party or {}
save.party[#save.party + 1] = mon
return true, mon
@@ -142,6 +162,16 @@ function Boxes.release(save, boxIndex, slot)
return true, table.remove(box, slot)
end
-- RELEASE off the DEPOSIT screen, whose list is the party: it is
-- RemoveMonFromPartyOrBox's REMOVE_PARTY arm (bills_pc.asm:204-207).
function Boxes.releaseFromParty(save, slot)
local party = (save and save.party) or {}
if not party[slot] then return false, "There is no POKéMON there." end
local mon = table.remove(party, slot)
Mail.removeSlot(save, slot)
return true, mon
end
-- Move a boxed mon to another box (MOVE PKMN W/O MAIL's box-to-box case).
function Boxes.move(save, fromBox, slot, toBox)
if fromBox == toBox then return false, "It's already there." end
+7
View File
@@ -159,6 +159,13 @@ function NpcTrade.perform(data, save, row, index)
-- that slot inheriting it (src/core/gen2/Mail.lua).
Mail.removeSlot(save, index)
party[#party + 1] = received
-- TryAddMonToParty's SetSeenAndCaughtMon (engine/pokemon/move_mon.asm:196),
-- which the `predef` at engine/events/npc_trade.asm:168 runs like any other.
save.pokedex = save.pokedex or {}
save.pokedex.seen = save.pokedex.seen or {}
save.pokedex.caught = save.pokedex.caught or {}
save.pokedex.seen[received.species] = true
save.pokedex.caught[received.species] = true
return given, received
end
+1
View File
@@ -273,6 +273,7 @@ Save.DEFAULT_OPTIONS = {
-- means something different, hence the different name.
color = "gbc",
videoMode = "windowed",
fpsCap = 60,
musicVol = 7, -- 0-7, like the GB's NR50 master volume
sfxVol = 7, -- 0-7
musicFilter = 0, -- low-pass steps, 0 = off
+10
View File
@@ -638,6 +638,16 @@ local function gen2Rows(opts, hooks)
end)
end
local okCap, FrameCap = pcall(require, "src.core.FrameCap")
if okCap then
add(Strings("MAX FPS"),
function() return FrameCap.label(opts.fpsCap) end,
function(dir)
opts.fpsCap = FrameCap.cycle(opts.fpsCap, dir)
return true
end)
end
addTouchRows(rows, add, opts, hooks)
return rows
+11
View File
@@ -49,6 +49,9 @@ local MAP_GROUP_COUNT = 26 -- constants/map_constants.asm NUM_MAP_GROUPS
-- EnvironmentColorsPointers, and the Tilesets row only stores a 16-bit
-- pointer, so the bank has to come from here.
local PAL_MAP_BANK = 0x02
-- LoadBallIconGFX.gfx (engine/battle/trainer_huds.asm:225-232); bank $0b
-- carries no manifest symbol to resolve it through.
local BALL_ICON_GFX = { 0x0b, 0x41a4 }
-- A tileset sheet is 96 tiles (128x48 at 8x8), and its PalMap packs two
-- tiles per byte: low nibble first tile, high nibble second (`dn` in the
-- tilepal macro). The high bit of each nibble is the VRAM bank, not colour.
@@ -4912,6 +4915,14 @@ function RomExtractorGen2:extractMenuGfx()
hud.expBarFirstTile = 0x55
hud.expBarCells = 9
-- Four OAM tiles at $31 -- normal, statused, fainted, empty -- and OBJ
-- colour 0 is transparent (engine/battle/trainer_huds.asm:47-99, :225-232).
local balls = self.symbols["LoadBallIconGFX.gfx"] or BALL_ICON_GFX
self:write2bpp(self.rom:bytes(balls[1], balls[2], 4 * 16), 32, 8,
"battle/hud/balls.png", true)
hud.balls = "assets/generated/battle/hud/balls.png"
hud.ballsFirstTile = 0x31
-- "HP:" and the bar cells, as a plain 2bpp sheet rather than the ink-on-
-- transparent font page: the bar's rule is shade 3 (black) while its fill is
-- shade 1/2 (the HP colour), so flattening it to one ink loses the very
+11 -1
View File
@@ -3594,7 +3594,17 @@ function RomImporter:_setAllMods(want, confirmed)
local LauncherMods = require("src.mods.LauncherMods")
local ids, experimental = {}, false
for _, m in ipairs(self.mods or {}) do
if m.enabled ~= want then
local mismatched = m.enabled ~= want
if not self.modScope and type(m.enabledByVersion) == "table" then
mismatched = false
for _, game in ipairs(GameVersion.ORDER) do
if m.enabledByVersion[game] ~= want then
mismatched = true
break
end
end
end
if mismatched then
ids[#ids + 1] = m.id
if want and m.experimental then experimental = true end
end
+31 -2
View File
@@ -68,14 +68,20 @@ end
-- Acquisition-ordered id list (wBagItems). Rebuilt sorted once for
-- saves from before the order existed, then maintained incrementally.
function Bag.order(save)
function Bag.order(save, data)
local order = save.bagOrder
if not order then
local items = (data or require("src.core.Data")).items
order = {}
for id in pairs(save.inventory) do
if not isBadge(id) then table.insert(order, id) end
end
table.sort(order)
table.sort(order, function(a, b)
local ia = (items and items[a] and items[a].index) or math.huge
local ib = (items and items[b] and items[b].index) or math.huge
if ia ~= ib then return ia < ib end
return a < b
end)
save.bagOrder = order
end
-- drop stale ids, append unknown ones (defensive against direct
@@ -95,6 +101,29 @@ function Bag.order(save)
return order
end
-- engine/items/switch_items.asm:38 SwitchItemsInBag .below / .above -- the
-- rotate the PACK's SELECT performs, over the rows of ONE pocket.
function Bag.move(save, id, pocket, toIndex, data)
local order = Bag.order(save, data)
local slots, ids = {}, {}
for i = 1, #order do
if pocketOf(order[i], data) == pocket then
slots[#slots + 1] = i
ids[#ids + 1] = order[i]
end
end
local from
for i = 1, #ids do
if ids[i] == id then from = i break end
end
if not from then return false end
local to = math.max(1, math.min(math.floor(tonumber(toIndex) or from), #ids))
if to == from then return false end
table.insert(ids, to, table.remove(ids, from))
for i = 1, #slots do order[slots[i]] = ids[i] end
return true
end
-- Add qty of an item; returns false (and adds nothing) when a new slot
-- is needed and the bag is full, or when the stack would pass 99
-- (AddItemToInventory's per-slot quantity cap).
+4
View File
@@ -26,6 +26,7 @@ local NAME_DELAYS = { FAST = 1, MID = 3, SLOW = 5 }
-- up over the still-visible text (YesNoChoicePokeCenter and friends);
-- the box then closes and choice(yes) runs instead of onDone.
-- opts.defaultNo starts the cursor on NO.
-- opts.choiceLabels / opts.choiceBox: data/yes_no_menu_strings.asm:16
-- opts.auto: texts with no `prompt` (a text_asm/text_end tail, like
-- _UsedStrengthText) never wait for a button: once the last page has
-- typed out, auto.sound() runs (returning an audio source blocks like
@@ -51,6 +52,8 @@ function TextBox.new(game, text, onDone, opts)
self.choice = opts and opts.choice
self.defaultNo = opts and opts.defaultNo
self.choiceNoSound = opts and opts.noSound
self.choiceLabels = opts and opts.choiceLabels
self.choiceBox = opts and opts.choiceBox
self.money = opts and opts.money
self.auto = opts and opts.auto
self.stay = opts and opts.stay
@@ -340,6 +343,7 @@ function TextBox:update(dt)
self.game.stack:pop() -- this text box, under the choice
self.choice(yes)
end, { defaultNo = self.defaultNo, noSound = self.choiceNoSound,
labels = self.choiceLabels, box = self.choiceBox,
-- this box is anchored below it; the pair moves together
anchor = "bottom" }))
end
+14
View File
@@ -134,6 +134,10 @@ function Commands.show_text(ctx, textId, subs, extraOpts)
opts[k] = v
end
end
-- MONEY_BOX (engine/menus/text_box.asm:133) reads the live wallet
if opts and opts.money == true then
opts.money = function() return ctx.save.money end
end
ctx.game.stack:push(TextBox.new(ctx.game, text, function()
runner:resume()
end, opts))
@@ -780,6 +784,16 @@ function Commands.give_money(ctx, amount)
ctx.save.money = math.max(0, ctx.save.money + amount)
end
-- check_money <amount>: HasEnoughMoney (home/money.asm:1)
function Commands.check_money(ctx, amount)
ctx.lastCheck = (ctx.save.money or 0) >= (amount or 0)
end
-- take_money <amount>: SubBCDPredef (engine/math/bcd.asm:193)
function Commands.take_money(ctx, amount)
ctx.save.money = math.max(0, (ctx.save.money or 0) - (amount or 0))
end
-- Point LAST_MAP exits at an outdoor door (pokered wLastMap). Keeps the
-- live overworld memory in sync so a scripted home warp from the HoF PC
-- does not leave Red's house mats aimed at Indigo Plateau (#103).
+5 -4
View File
@@ -300,7 +300,7 @@ local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker)
end
list.index = math.min(list.index, math.max(1, #list.items))
if extra and extra.evolveTo then
list:close()
-- engine/menus/start_sub_menus.asm:408 .useItem_partyMenu
local Evolution = require("src.pokemon.Evolution")
-- item_effects.asm ItemUseEvoStone sets wForceEvolution before
-- TryEvolvingMon, so a stone evolution's B press is read and
@@ -428,11 +428,12 @@ local function pickTargetAndUse(game, battle, id, list)
-- TM/HM: open the party menu in Gen 1's TM/HM display mode so each mon
-- shows ABLE / NOT ABLE from its learnset and the prompt reads "Use TM on
-- which POKeMON?" (engine/items/item_effects.asm ItemUseTMHM ->
-- party_menu.asm TM/HM type). Stones and other pickOnly items keep the
-- plain HP layout (Gen 1 shows no ABLE/NOT ABLE for them), so gate
-- strictly on def.machine. #210
-- party_menu.asm TM/HM type). #210 Stones get the same ABLE / NOT ABLE
-- column: ItemUseEvoStone sets EVO_STONE_PARTY_MENU (party_menu.asm:114).
if def and def.machine then
opts.tmhm = { move = def.machine.move, kind = def.machine.kind }
elseif ItemEffects.isStone(id) then
opts.evoStone = id
end
require("src.ui.Screens").push(game, "PartyMenu", opts)
end
+10 -4
View File
@@ -23,11 +23,15 @@ function ChoiceBox.new(game, onChoose, opts)
-- and docking it to the window edge instead tears it off that screen by
-- however far the letterbox sits from the edge.
self.anchor = opts and opts.anchor or nil
local box = Theme.choiceBox
local box = (opts and opts.box) or Theme.choiceBox
self.tx = (opts and opts.tx) or box.tx
self.ty = (opts and opts.ty) or box.ty
self.tw = (opts and opts.tw) or box.tw
self.th = (opts and opts.th) or box.th
-- TwoOptionMenuStrings rows carry their own labels and a "blank line
-- before first menu item?" flag (data/yes_no_menu_strings.asm:8-16)
self.labels = (opts and opts.labels) or { "YES", "NO" }
self.firstItem = (opts and opts.firstItem) or box.firstItem or 1
return self
end
@@ -81,10 +85,12 @@ function ChoiceBox:draw()
local paper = self.game and self.game.textboxPaper and self.game:textboxPaper()
Font.drawBox(tx, ty, tw, th, paper)
love.graphics.setColor(0, 0, 0, 1)
Font.draw(Strings("YES"), (tx + 2) * 8, (ty + 1) * 8)
Font.draw(Strings("NO"), (tx + 2) * 8, (ty + 3) * 8)
-- <NEXT> advances 2 * SCREEN_WIDTH -- home/text.asm:64
local row = self.firstItem
Font.draw(Strings(self.labels[1]), (tx + 2) * 8, (ty + row) * 8)
Font.draw(Strings(self.labels[2]), (tx + 2) * 8, (ty + row + 2) * 8)
Font.drawCode(Theme.cursor, (tx + 1) * 8,
(ty + (self.index == 1 and 1 or 3)) * 8)
(ty + row + (self.index == 1 and 0 or 2)) * 8)
love.graphics.setColor(1, 1, 1, 1)
end
+15 -8
View File
@@ -293,23 +293,30 @@ function IntroMovie:update(dt)
return
end
local input = self.game.input
if input:wasPressed("a") or input:wasPressed("start") then
-- CheckForUserInterruption (home/overworld.asm:2395) returns carry only
-- on a fresh START or A -- B alone never skips the intro.
-- PlayIntro still GBFadeOutToWhite's after an interrupted scene; the
-- white hold stands in for that beat before the title is built.
self:exitToTitle()
return
end
-- CheckForUserInterruption (home/overworld.asm:2395) returns carry only
-- on a fresh START or A -- B alone never skips the intro.
local skip = input:wasPressed("a") or input:wasPressed("start")
self.timer = self.timer + 1
if self.phase == 1 then
-- the copyright card is a bare DelayFrames, deaf to input (intro.asm:311)
if self.timer >= COPYRIGHT_FRAMES then self:startPhase(2) end
elseif self.phase == 2 then
if self.timer == STAR_START then
Sound.play(self.game.data, "Shooting_Star") -- splash.asm:29-30
end
-- intro.asm:325 `jr c, .next`
if skip and self.timer >= STAR_START and self.timer < WAVES_END then
self:startPhase(3)
return
end
if self.timer >= SPLASH_FRAMES then self:startPhase(3) end
else
-- PlayIntro still GBFadeOutToWhite's after an interrupted scene; the
-- white hold stands in for that beat before the title is built.
if skip then
self:exitToTitle()
return
end
-- PlayShootingStar ends `jp Delay3` once Music_IntroBattle is playing
-- (intro.asm:337), so PlayIntroScene's first op is not on the music's
-- own frame
+17
View File
@@ -313,6 +313,9 @@ function PartyMenu.new(game, opts)
-- TM/HM display (ABLE / NOT ABLE per mon instead of the HP bar, and the
-- "Use TM on which POKeMON?" prompt). Set by BagMenu.pickTargetAndUse. #210
self.tmhm = opts.tmhm
-- Evolution stones: opts.evoStone = item id gives Gen 1's
-- EVO_STONE_PARTY_MENU ABLE / NOT ABLE display (party_menu.asm:114). #1411
self.evoStone = opts.evoStone
self.forceSwitch = opts.forceSwitch
self.battle = opts.battle
self.party = party -- link/scoped battles pass their local party view
@@ -800,6 +803,20 @@ function PartyMenu:draw()
else
Font.draw(Strings("NOT ABLE"), 88, y + 8)
end
elseif self.evoStone then
-- party_menu.asm:114 .evolutionStoneMenu: an EVOLVE_ITEM row matching
-- wEvoStoneItemID, printed in the TM/HM strings' row+1 column+9 slot
local can = false
for _, evo in ipairs(def.evolutions or {}) do
if evo.method == "ITEM" and evo.item == self.evoStone then
can = true break
end
end
if can then
Font.draw(Strings("ABLE"), 120, y + 8)
else
Font.draw(Strings("NOT ABLE"), 88, y + 8)
end
else
if mon.hp <= 0 then
Font.draw(Strings("FNT"), 136, y)
+6
View File
@@ -17,6 +17,12 @@ local Theme = {
textBox = { tx = 0, ty = 12, tw = 20, th = 6, maxCols = 18 },
-- InitYesNoTextBoxParameters / AskName: hlcoord 14, 7 (YES_NO_MENU 4x3)
choiceBox = { tx = 14, ty = 7, tw = 6, th = 5 },
-- YesNoChoicePokeCenter: hlcoord 11, 6 (HEAL_CANCEL_MENU 7x4, blank line
-- before the first item) -- home/yes_no.asm:21, data/yes_no_menu_strings.asm:16
healCancelBox = { tx = 11, ty = 6, tw = 9, th = 6, firstItem = 2 },
-- EnemySendOutFirstMon inlines its own TWO_OPTION_MENU at hlcoord 0, 7
-- instead of the shared right-hand one -- engine/battle/core.asm:1378-1384
trainerSwitchBox = { tx = 0, ty = 7, tw = 6, th = 5 },
}
function Theme.load(data)
+6
View File
@@ -63,6 +63,7 @@ function Editor.load(opts)
Editor.hostPoll = opts.hostPoll == true
Editor.drag = nil
Editor.rects = {}
Editor._closed = false
Editor._hostMouse = false
Editor._hostTouches = nil
Editor.fonts = {
@@ -137,6 +138,7 @@ function Editor.unload()
Editor.drag = nil
Editor.onClose = nil
Editor.version = nil
Editor.rects = {}
Editor._hostMouse = false
Editor._hostTouches = nil
end
@@ -159,6 +161,8 @@ local function persist()
end
local function close()
if Editor._closed then return end
Editor._closed = true
persist()
local cb = Editor.onClose
Editor.unload()
@@ -427,6 +431,7 @@ function Editor.pollHostPointers()
if not Editor._hostMouse then
Editor._hostMouse = true
beginDrag("mouse", x, y)
if Editor._closed then return end
else
moveDrag("mouse", x, y)
end
@@ -446,6 +451,7 @@ function Editor.pollHostPointers()
if not Editor._hostTouches[id] then
Editor._hostTouches[id] = true
beginDrag(id, tx, ty)
if Editor._closed then return end
else
moveDrag(id, tx, ty)
end
+27 -1
View File
@@ -14,7 +14,9 @@
-- This is what the party-menu FLY field move opens (#195).
local Font = require("src.render.Font")
local PaletteFX = require("src.render.PaletteFX")
local Sound = require("src.core.Sound")
local SpriteRenderer = require("src.render.SpriteRenderer")
local TownMap = {}
TownMap.__index = TownMap
@@ -227,7 +229,21 @@ function TownMap.new(game, opts)
local sprites = game.data.sprites or {}
local red = sprites[playerSprites.walk or "SPRITE_RED"]
or sprites.SPRITE_RED
local ok, img = pcall(love.graphics.newImage, red and red.image)
-- the marker is the overworld walking sheet, so it wears that sheet's OBJ
-- palette and shade-0 keying -- engine/items/town_map.asm:342
local colors, group
if PaletteFX.usesGbcPack() then
colors, group = PaletteFX.spriteObp(red, "player")
end
if not colors then
if PaletteFX.usesSpriteObp() then
colors, group = PaletteFX.ogObj()
else
colors, group = PaletteFX.dmgObj()
end
end
local ok, img = pcall(SpriteRenderer.obpImage,
red and red.image, colors, group)
if ok and img then
self.playerSheet = img
self.playerQuad = love.graphics.newQuad(0, 0, 16, 16,
@@ -316,6 +332,13 @@ function TownMap:update(dt)
end
end
-- OG RED bakes the boot-ROM OBJ palette in, so the marker has to be replayed
-- over the screen-wide TOWNMAP zone pass the way every other OBJ is (#301)
function TownMap:markPlayerRedraw(x, y)
if not PaletteFX.usesSpriteObp() then return end
PaletteFX.markUiSpriteRedraw(self.playerSheet, self.playerQuad, x, y)
end
local function drawSquare(loc)
if isRoute(loc) then
love.graphics.setColor(0.62, 0.62, 0.62, 1) -- routes lighter
@@ -364,6 +387,7 @@ function TownMap:draw()
local x, y = markerXY(self.playerLoc)
if self.playerSheet then
love.graphics.draw(self.playerSheet, self.playerQuad, x - 4, y - 3)
self:markPlayerRedraw(x - 4, y - 3)
else
love.graphics.setColor(0, 0, 0, 1)
love.graphics.rectangle("fill", x + 2, y + 2, 4, 4)
@@ -406,6 +430,8 @@ function TownMap:draw()
love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(self.playerSheet, self.playerQuad,
self.playerLoc.x * 8 - 4, self.playerLoc.y * 8 - 3)
self:markPlayerRedraw(self.playerLoc.x * 8 - 4,
self.playerLoc.y * 8 - 3)
else
love.graphics.setColor(0, 0, 0, 1)
love.graphics.rectangle("fill", self.playerLoc.x * 8 + 2,
+3 -6
View File
@@ -256,10 +256,7 @@ function YellowIntro.new(game, onDone)
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.
-- intro.asm:311 copyright card, splash.asm:29 shooting star
local IntroMovie = require("src.ui.IntroMovie")
local pre = IntroMovie.new(game, nil)
local baseStart = pre.startPhase
@@ -644,8 +641,8 @@ end
-- Both scene-loop exits (scene 17 running out, and the A/B/START skip) land
-- on .go_to_title_screen, which calls YellowIntro_BlankPalettes and then
-- spends EXIT_FRAMES DelayFrame calls clearing the tilemap and the OAM
-- buffers before the title screen is built (#523). The pre-roll's own skip
-- is IntroMovie's path, not this one, and still finishes immediately.
-- buffers before the title screen is built (#523). A press during the
-- pre-roll splash starts the scenes instead of reaching here.
function YellowIntro:exitToTitle()
if self.finished or self.exiting then return end
self.exiting = true
+74 -18
View File
@@ -48,6 +48,18 @@ local TILE_PLAYER_BOTTOM_LEFT = 0x6f
-- (engine/battle/trainer_huds.asm:143-152).
local TILE_CAUGHT = 0x5d
-- The ball icons StageBallTilesData stages, one per party slot
-- (engine/battle/trainer_huds.asm:47-99).
local TILE_BALL_NORMAL = 0x31
local TILE_BALL_STATUSED = 0x32
local TILE_BALL_FAINTED = 0x33
local TILE_BALL_EMPTY = 0x34
-- DrawPlayerPartyIconHUDBorder's corner (trainer_huds.asm:118-132), which is
-- DrawPlayerHUDBorder's with $77 swapped for $5c.
local TILE_PARTY_ICON_BOTTOM_RIGHT = 0x5c
BattleHud.PARTY_LENGTH = 6
function BattleHud.new(menuGfx, palettes)
local self = setmetatable({}, BattleHud)
self.gfx = menuGfx and menuGfx.battleHud or nil
@@ -184,18 +196,25 @@ local TILE_EXP_FULL = 0x6a -- FontBattleExtra
local TILE_EXP_EMPTY = 0x62 -- FontBattleExtra
local EXP_PARTIAL_BASE = 0x54 -- $54 + remainder lands in ExpBarGFX
-- PAL_BATTLE_BG_EXP, which the attrmap lays over (10,11)..(18,11)
-- (engine/gfx/cgb_layouts.asm:142-145).
function BattleHud:expColors()
local pal = self.palettes and self.palettes.expBar
if not pal then return nil end
return {
{ 255, 255, 255 },
{ pal[1][1], pal[1][2], pal[1][3] },
{ pal[2][1], pal[2][2], pal[2][3] },
{ 0, 0, 0 },
}
end
function BattleHud:drawExpBar(fraction, tx, ty)
if not self:image("hpBar") then return false end
fraction = math.max(0, math.min(1, fraction or 0))
local pixels = math.floor(fraction * BattleHud.EXP_LENGTH_PX)
-- The whole row wears the exp bar's palette, full and empty cells included.
local pal = self.palettes and self.palettes.expBar
local colors = pal and {
{ 255, 255, 255 },
{ pal[1][1], pal[1][2], pal[1][3] },
{ pal[2][1], pal[2][2], pal[2][3] },
{ 0, 0, 0 },
} or nil
local colors = self:expColors()
local remaining = pixels
for cell = BattleHud.EXP_CELLS - 1, 0, -1 do
@@ -269,19 +288,56 @@ end
-- DrawPlayerHUDBorder: hlcoord 18, 10 stepping LEFT, tiles $73 / $77 / $6f /
-- $76, plus the extra vertical bar DrawPlayerHUD writes at (18,9) so the stub
-- is two rows tall.
local PLAYER_FRAME_TILES = {
sideSheet = "playerBorder", sideFirst = PLAYER_BORDER_FIRST,
cornerSheet = "playerBorder", cornerFirst = PLAYER_BORDER_FIRST,
-- $6f is the LAST tile of EnemyHPBarBorderGFX, not the player sheet
-- (engine/gfx/load_font.asm:57-65).
farSheet = "enemyBorder", farFirst = ENEMY_BORDER_FIRST,
side = TILE_PLAYER_RIGHT,
nearCorner = TILE_PLAYER_BOTTOM_RIGHT,
farCorner = TILE_PLAYER_BOTTOM_LEFT,
bottom = TILE_BOTTOM_SIDE,
}
function BattleHud:drawPlayerFrame()
self:drawTile("playerBorder", PLAYER_BORDER_FIRST, TILE_PLAYER_RIGHT, 18, 9)
self:placeBorder({
sideSheet = "playerBorder", sideFirst = PLAYER_BORDER_FIRST,
cornerSheet = "playerBorder", cornerFirst = PLAYER_BORDER_FIRST,
-- $6f is the LAST tile of EnemyHPBarBorderGFX, not the player sheet
-- (engine/gfx/load_font.asm:57-65).
farSheet = "enemyBorder", farFirst = ENEMY_BORDER_FIRST,
side = TILE_PLAYER_RIGHT,
nearCorner = TILE_PLAYER_BOTTOM_RIGHT,
farCorner = TILE_PLAYER_BOTTOM_LEFT,
bottom = TILE_BOTTOM_SIDE,
}, 18, 10, -1)
self:placeBorder(PLAYER_FRAME_TILES, 18, 10, -1)
end
-- DrawPlayerPartyIconHUDBorder (engine/battle/trainer_huds.asm:118-132): the
-- player border with $5c for the bottom right, and no bar at (18,9).
function BattleHud:drawPartyIconFrame()
self:placeBorder(PLAYER_FRAME_TILES, 18, 10, -1)
return self:drawTile("expBar", self.gfx and self.gfx.expBarFirstTile,
TILE_PARTY_ICON_BOTTOM_RIGHT, 18, 11, self:expColors())
end
-- StageBallTilesData's .GetHUDTile, and the $34 it stages past the party
-- count (engine/battle/trainer_huds.asm:47-100).
local function ballTile(mon)
if not mon then return TILE_BALL_EMPTY end
if (mon.hp or 0) <= 0 then return TILE_BALL_FAINTED end
return mon.status and TILE_BALL_STATUSED or TILE_BALL_NORMAL
end
-- PAL_BATTLE_OB_YELLOW (engine/battle/trainer_huds.asm:213-214).
function BattleHud:ballColors()
local pals = self.palettes and self.palettes.battleObjects
return pals and pals.PAL_BATTLE_OB_YELLOW or nil
end
-- LoadTrainerHudOAM (engine/battle/trainer_huds.asm:203-223): six sprites
-- from (tx, ty), each one tile further along `step`.
function BattleHud:drawBallRow(party, tx, ty, step)
if not self:image("balls") then return false end
local first = self.gfx.ballsFirstTile or TILE_BALL_NORMAL
local colors = self:ballColors()
for slot = 1, BattleHud.PARTY_LENGTH do
self:drawTile("balls", first, ballTile(party and party[slot]),
tx + (slot - 1) * step, ty, colors)
end
return true
end
BattleHud.TILE_HP_LABEL = TILE_HP_LABEL
+106 -10
View File
@@ -314,6 +314,13 @@ function BattleState.new(game, opts)
self.showEnemyHud = false
self.showPlayerHud = false
-- BattleStart_TrainerHuds, farcalled from BattleStartMessage before the
-- opening line (engine/battle/trainer_huds.asm:1-9, core.asm:8733).
self.ballRows = {
player = true,
enemy = not (self.battle and self.battle.wild),
}
-- InitEnemyTrainer (engine/battle/core.asm:7848) puts the CLASS's 7x7
-- frontpic in the enemy pic box BEFORE the intro slide, and it stays there
-- until ResetEnemyBattleVars slides it off; only then is the mon drawn. The
@@ -349,6 +356,7 @@ function BattleState.new(game, opts)
local enemy = self.battle and self.battle.enemy
self:noteFirstUnown(enemy)
self:markSeen(enemy)
if enemy then
if self.battle.wild then
-- BattleCheckEnemyShininess: a shiny wild mon gets ANIM_SEND_OUT_MON's
@@ -399,6 +407,13 @@ function BattleState.new(game, opts)
-- "X fainted!" is even displayed. The replacement arrives with its own
-- `send` event, which is where the cart's send-out animation sits.
self.shownMon = { player = player, enemy = enemy }
-- home/battle.asm:150 UpdateBattleHuds
self.shownStatus = {
player = (player and player.status) or false,
enemy = (enemy and enemy.status) or false,
}
-- engine/battle/trainer_huds.asm:142-151
self.caughtMark = self:dexCaught(enemy)
-- And the same for the two numbers AnimateExpBar walks: wBattleMonLevel is
-- only advanced inside its level loop, right after that level's bar has
-- crawled full (engine/battle/core.asm:7267-7274), so neither the level nor
@@ -443,6 +458,16 @@ function BattleState:noteFirstUnown(mon)
save.firstUnownSeen = Unown.monLetter(mon)
end
-- LoadEnemyMon's "Saw this mon" (engine/battle/core.asm:6203-6209): the seen
-- flag is stamped for every battle mode, so a trainer's mon counts too.
function BattleState:markSeen(mon)
local save = self.save
if not (save and mon and mon.species) then return end
save.pokedex = save.pokedex or { seen = {}, caught = {} }
save.pokedex.seen = save.pokedex.seen or {}
save.pokedex.seen[mon.species] = true
end
-- The DUDE answering a prompt. Every re-arm in the ASM sits at the moment the
-- cart starts WAITING for a button (`.wait_input` in home/joypad.asm, BattleMenu
-- before LoadBattleMenu, TutorialPack before its own loop), so each one goes
@@ -936,6 +961,25 @@ function BattleState:dexCaught(mon)
return (mon and caught and caught[mon.species]) and true or false
end
-- The status the HUD prints, one drain behind the engine the way shownHp is:
-- UpdateBattleHuds runs after the animation and its line (home/battle.asm:150).
function BattleState:hudStatus(mon, side)
local shown = side and self.shownStatus and self.shownStatus[side]
if shown == nil then return mon and mon.status or nil end
return shown or nil
end
-- home/battle.asm:150, for the clears no queued event carries (a mon waking,
-- a thaw, a bag cure): the tags catch up once the queue is idle.
function BattleState:syncShownStatus()
local shown, battle = self.shownStatus, self.battle
if not (shown and battle) then return end
for _, side in ipairs({ "player", "enemy" }) do
local mon = battle[side]
shown[side] = (mon and mon.status) or false
end
end
-- The low-HP alarm is not an SFX id at all. PlayDanger (audio/engine.asm:531)
-- runs every frame while DANGER_ON_F is set in wLowHealthAlarm and writes a
-- two-tone square straight to channel 1 -- DangerSoundHigh ($750) at counter 0,
@@ -1153,7 +1197,7 @@ function BattleState:stepAnim(input)
-- Cart still reaches the after-anim arm after a move script ends; a skip
-- of the move should not drop the hit shake that follows it.
if self:startPendingAfterAnim() then return end
return self:endSendOutAnim()
return self:endSendOutAnim(true)
end
if not self.anim:step() then
self:latchCaughtPic()
@@ -1170,10 +1214,17 @@ end
-- Whatever Call_PlayBattleAnim was standing in front of: a send-out's cry and
-- HUD update run the moment its animation is done, cut short or not.
function BattleState:endSendOutAnim()
function BattleState:endSendOutAnim(skipped)
local after = self.afterSendOut
if not after then return end
self.afterSendOut = nil
if after.shiny and not skipped then
after.shiny = nil
if self:animForId("ANIM_SEND_OUT_MON", after.side, 1) then
self.afterSendOut = after
return
end
end
self:finishSendOut(after)
end
@@ -1202,6 +1253,7 @@ function BattleState:advanceQueue()
self.introTextShown = nil
end
if not event then
self:syncShownStatus()
-- `jp PlayerSwitch`, which follows the enemy's own send-out and spends no
-- turn (engine/battle/core.asm:2955-2963).
if self.shiftSwitchIndex then
@@ -1315,6 +1367,14 @@ function BattleState:advanceQueue()
self.shownHp[event.side] = event.mon.hp or 0
if self.hpAnim and self.hpAnim.side == event.side then self.hpAnim = nil end
end
-- And the same lag for the status tag (home/battle.asm:150); a send snaps it
-- to the incoming mon.
if self.shownStatus and event.side
and (event.kind == "status"
or (event.kind == "send" and event.mon)) then
self.shownStatus[event.side] =
(event.kind == "send" and event.mon.status or event.status) or false
end
-- AnimateExpBar (engine/battle/core.asm:7191) is called from INSIDE
-- GiveExperiencePoints before the exp is committed (the call at :6888 sits
-- ahead of the commit at :6889-6901), so the bar crawls from the figures
@@ -1339,7 +1399,12 @@ function BattleState:advanceQueue()
-- is still on screen for its own line and the replacement arrives here.
if self.shownMon then self.shownMon[event.side] = event.mon end
-- The second of the cart's two wFirstUnownSeen writes (core.asm:3251).
if event.side == "enemy" then self:noteFirstUnown(event.mon) end
if event.side == "enemy" then
self:noteFirstUnown(event.mon)
self:markSeen(event.mon)
-- engine/battle/trainer_huds.asm:142-151
self.caughtMark = self:dexCaught(event.mon)
end
if event.side == "player" then
-- SendOutPlayerMon zeroes wBattleMenuCursorPosition and wCurMoveNum back
-- to back (engine/battle/core.asm:3809), so a switched-in mon opens on
@@ -1535,12 +1600,18 @@ end
-- SetEnemyTurn / SetPlayerTurn, then ANIM_SEND_OUT_MON. The cry and the HUD
-- come after the animation, not with it.
function BattleState:startSendOut(side, mon)
local after = { side = side, mon = mon }
-- BattleCheckPlayerShininess / BattleCheckEnemyShininess replay the anim's
-- `.Shiny` arm before the cry (core.asm:3826-3831, :3371-3377).
local after = { side = side, mon = mon, shiny = mon and mon.shiny and true }
-- ShowSetEnemyMonAndSendOutAnimation and SendOutPlayerMon both draw the pic
-- into the box before they play the animation, which is the one thing that
-- undoes a cleared box.
self.picHidden[side] = false
self.faintSlide = nil
-- The rows are shadow OAM (engine/battle/trainer_huds.asm:203-223), which
-- this animation's own sprites overwrite.
self.ballRows.player = false
self.ballRows.enemy = false
if self:animForId("ANIM_SEND_OUT_MON", side) then
self.afterSendOut = after
return true
@@ -2569,6 +2640,9 @@ function BattleState:pushCaught(enemy, itemId)
-- in the box", item_effects.asm:624). Boxes.deposit stays an append: the
-- PC's own move is InsertPokemonIntoBox, which inserts at the cursor.
table.insert(box, 1, enemy)
-- SendMonIntoBox refills the boxed slot's PP before it closes SRAM
-- (move_mon.asm:1062-1063).
Boxes.restorePP(enemy)
-- `.SendToPC` re-reads sBoxCount AFTER the insert and sets
-- BATTLERESULT_BOX_FULL when the box has just filled
-- (item_effects.asm:612-619); Script_reloadmapafterbattle tests that bit
@@ -2638,6 +2712,9 @@ end
-- (engine/battle/core.asm:3298-3304, data/text/battle.asm:222-231).
function BattleState:offerShiftSwitch(mon)
self.shiftIndex = 1
-- HandleEnemySwitch farcalls EnemySwitch_TrainerHud before the prompt
-- (engine/battle/core.asm:2246, engine/battle/trainer_huds.asm:11-15).
self.ballRows.enemy = true
local trainer = (self.battle.trainer and self.battle.trainer.name) or "Foe"
local player = (self.save and self.save.player and self.save.player.name)
or "GOLD"
@@ -2921,6 +2998,9 @@ function BattleState:useItem(itemId)
end
local player = self.battle.player
caught, rate = Catching.attempt({
battle = self.battle,
mon = enemy,
def = enemyDef,
maxHp = enemy.maxHp or (enemy.stats and enemy.stats.hp),
hp = enemy.hp,
catchRate = enemyDef and enemyDef.catchRate or 45,
@@ -3231,13 +3311,13 @@ function BattleState:drawHud()
-- PlaceNonFaintStatus (engine/pokemon/mon_stats.asm): a statused mon's tag
-- prints where the level goes, and DrawEnemyHUD's `.skip_level` arm drops
-- the level entirely while one is up.
Chrome.print(self:statusTag(enemy) or ("<LV>" .. tostring(enemy.level or 1)),
6, 1)
Chrome.print(self:statusTag(enemy, "enemy")
or ("<LV>" .. tostring(enemy.level or 1)), 6, 1)
local enemyGender = self:genderSymbol(enemy)
if enemyGender then Chrome.print(enemyGender, 9, 1) end
-- `ld a, [wBattleMode] / dec a / ret nz`, then CheckCaughtMon puts $5d at
-- (1,1) (engine/battle/trainer_huds.asm:140-152).
if self.battle and self.battle.wild and self:dexCaught(enemy) then
if self.battle and self.battle.wild and self.caughtMark then
self.hud:drawCaughtIcon(1, 1, self:hudHp(enemy, "enemy"),
enemy.maxHp or (enemy.stats and enemy.stats.hp))
end
@@ -3249,6 +3329,14 @@ function BattleState:drawHud()
self:drawFrame(1, 3, 10, false)
end
end
-- ShowOTTrainerMonsRemaining (engine/battle/trainer_huds.asm:32-45): balls
-- walking LEFT from OAM (72, 32), which the -8/-16 offset puts at (8, 2).
if showStatus and self.ballRows.enemy then
if not self.showEnemyHud and self.hud:available() then
self.hud:drawEnemyFrame()
end
self.hud:drawBallRow(self.battle and self.battle.enemyParty, 8, 2, -1)
end
self:drawPic(enemy, false)
@@ -3257,6 +3345,14 @@ function BattleState:drawHud()
-- the HP bar at (10,9), its numbers below; the vertical bar at (18,9), the
-- border from (18,10) going left, and the exp bar at (10,11).
self:drawPic(player, true)
-- ShowPlayerMonsRemaining (engine/battle/trainer_huds.asm:17-30): balls
-- walking RIGHT from OAM (96, 96), i.e. tile (11, 10).
if showStatus and self.ballRows.player then
if not self.showPlayerHud and self.hud:available() then
self.hud:drawPartyIconFrame()
end
self.hud:drawBallRow(self.battle and self.battle.party, 11, 10, 1)
end
-- No player HUD in the catching tutorial: DrawPlayerHUD lives in
-- SendOutPlayerMon, which BATTLETYPE_TUTORIAL jumps straight over, and
-- BattleMenu's own tutorial arm skips UpdateBattleHuds as well. The DUDE's
@@ -3270,7 +3366,7 @@ function BattleState:drawHud()
Chrome.print(self:name(player), 10, 7)
-- PrintPlayerHUD places the same status tag at (14,8) and skips the level
-- while it is up.
Chrome.print(self:statusTag(player)
Chrome.print(self:statusTag(player, "player")
or ("<LV>" .. tostring(self.shownLevel or player.level or 1)), 14, 8)
local playerGender = self:genderSymbol(player)
if playerGender then Chrome.print(playerGender, 17, 8) end
@@ -3303,8 +3399,8 @@ local STATUS_TAGS = {
paralyze = "PAR", sleep = "SLP",
}
function BattleState:statusTag(mon)
return mon and STATUS_TAGS[mon.status] or nil
function BattleState:statusTag(mon, side)
return mon and STATUS_TAGS[self:hudStatus(mon, side)] or nil
end
-- ♂ / ♀ after the level, or nil for a genderless species (PrintPlayerHUD
+47 -18
View File
@@ -78,8 +78,12 @@ local MOVE_SUBMENU = { "MOVE", "STATS", "CANCEL" }
-- engine/pokemon/bills_pc.asm:472-478: BillsPC_Withdraw's menu rows.
local WITHDRAW_SUBMENU = { "WITHDRAW", "STATS", "RELEASE", "CANCEL" }
-- BillsPCDepositMenuHeader's .MenuData (engine/pokemon/bills_pc.asm:234-240).
local DEPOSIT_SUBMENU = { "DEPOSIT", "STATS", "RELEASE", "CANCEL" }
function BoxMenu:submenuRows()
if self.mode == "move" then return MOVE_SUBMENU end
if self.mode == "deposit" then return DEPOSIT_SUBMENU end
return WITHDRAW_SUBMENU
end
@@ -219,21 +223,12 @@ function BoxMenu:act()
if self.onClose then self.onClose() end
return
end
-- engine/pokemon/bills_pc.asm:336-344: withdraw and move both PrepSubmenu.
if self.mode == "move" or self.mode == "withdraw" then
if not self:selected() then return end
self.phase = "submenu"
-- `ld a, $1 / ld [wMenuCursorY], a`: the submenu always opens on MOVE.
self.submenuIndex = 1
return
end
local ok, result = Boxes.deposit(self.save, self.index, self.boxIndex)
if not ok then
self.message = result
return
end
self.message = nil
self:clampIndex()
-- engine/pokemon/bills_pc.asm:336-344: withdraw and move both PrepSubmenu,
-- and _DepositPKMN's .a_button steps to .WhatsUp the same way (:94-102).
if not self:selected() then return end
self.phase = "submenu"
-- `ld a, $1 / ld [wMenuCursorY], a`: the submenu always opens on its top row.
self.submenuIndex = 1
end
-- ------------------------------------------------------------ MOVE, step 2
@@ -243,8 +238,9 @@ end
-- the PARTY, so a boxed mon walks straight past. Returns true, or false and
-- the string the cart places.
function BoxMenu:checkMailPreventBlackout()
-- `ld a, [wBillsPC_LoadedBox] / and a / jr nz, .Okay`.
if not self:isParty() then return true end
-- `ld a, [wBillsPC_LoadedBox] / and a / jr nz, .Okay`; _DepositPKMN zeroes
-- that byte too, so its list is the party (bills_pc.asm:17-18).
if not (self:isParty() or self.mode == "deposit") then return true end
local party = self.save.party or {}
-- `cp $3 / jr c, .ItsYourLastPokemon`: a party of one or two may not send
-- one away at all, however healthy the rest of it is.
@@ -311,10 +307,25 @@ function BoxMenu:doWithdraw()
self:clampIndex()
end
-- engine/pokemon/bills_pc.asm:155 BillsPCDepositFuncDeposit
function BoxMenu:doDeposit()
local ok, result = Boxes.deposit(self.save, self.index, self.boxIndex)
if not ok then
self.message = result
return
end
self.message = nil
self.phase = nil
self.index, self.scroll = 1, 0
self:clampIndex()
end
function BoxMenu:chooseSubmenu()
local row = self:submenuRows()[self.submenuIndex]
if row == "MOVE" then
self:beginMove()
elseif row == "DEPOSIT" then
self:doDeposit()
elseif row == "WITHDRAW" then
self:doWithdraw()
elseif row == "STATS" then
@@ -393,6 +404,9 @@ function BoxMenu:insertMon()
target = target - 1
end
table.insert(dest, math.max(1, math.min(target, #dest + 1)), mon)
-- .CopyToBox is InsertPokemonIntoBox, which tails into
-- RestorePPOfDepositedPokemon (engine/pokemon/move_mon_wo_mail.asm:35-37).
if not self:isParty(destIndex) then Boxes.restorePP(mon) end
self.phase = nil
self.moveFrom, self.backup = nil, nil
self.index, self.scroll = 1, 0
@@ -521,6 +535,16 @@ function BoxMenu:askRelease()
if self:isCancel() then return end
local mon = self:selected()
if not mon then return end
-- BillsPCDepositFuncRelease runs the mail/blackout net BEFORE the egg check
-- (engine/pokemon/bills_pc.asm:183-187).
if self.mode == "deposit" then
local allowed, refusal = self:checkMailPreventBlackout()
if not allowed then
self.phase = nil
self.message = refusal
return
end
end
-- Both release paths run BillsPC_IsMonAnEgg first, so the question is never
-- even asked over an egg (engine/pokemon/bills_pc.asm:186-187 and :427-428).
if mon.isEgg then
@@ -534,7 +558,12 @@ function BoxMenu:askRelease()
local name = mon.nickname or mon.name or mon.species or "?"
game.stack:push(ChoiceBox.new(game, function(yes)
if not yes then return end
local ok, err = Boxes.release(self.save, self.boxIndex, self.index)
local ok, err
if self.mode == "deposit" then
ok, err = Boxes.releaseFromParty(self.save, self.index)
else
ok, err = Boxes.release(self.save, self.boxIndex, self.index)
end
if not ok then
self.message = err
return
+3 -2
View File
@@ -518,8 +518,9 @@ function ItemPcMenu:drawList()
local entry = self.rows[i]
if i == self.listIndex then Chrome.cursor(5, ty) end
Chrome.print(entry.name, 6, ty)
-- PlaceMenuItemQuantity: the xNN is the entry's second line.
Chrome.print(TIMES .. tostring(entry.count), 7, ty + 1)
-- PlaceMenuItemQuantity (engine/menus/menu_2.asm:24): the xNN is the
-- entry's second line, right-aligned in a blank-padded 2-digit field.
Chrome.print(TIMES .. Chrome.number(entry.count, 2), 7, ty + 1)
elseif i == self:listTotal() then
if i == self.listIndex then Chrome.cursor(5, ty) end
Chrome.print("CANCEL", 6, ty)
+10 -10
View File
@@ -12,9 +12,9 @@
-- * The two tilemaps behind DrawMagnetTrain (MagnetTrainBGTiles, a 2x18
-- vertical strip repeated across all 32 columns, and MagnetTrainTilemap,
-- the 20x4 train laid over rows 6-9) come from the extracted cache at
-- data.field.magnetTrain. A cache built before the extractor learned to
-- follow them has neither, and then the ride runs with a blank screen
-- rather than with invented art.
-- data.gen2Field.magnetTrain. A cache built before the extractor
-- learned to follow them has neither, and then the ride runs with a
-- blank screen rather than with invented art.
-- * The background is baked into a 256x144 canvas once, because the only
-- thing that changes per frame is the per-band SCX.
-- * SetMagnetTrainPals gives the four bush rows and the four bottom rows
@@ -57,7 +57,7 @@ function MagnetTrainRide.new(game, opts)
self.onDone = opts.onDone
self.finished = false
local field = self.data and self.data.field
local field = self.data and self.data.gen2Field
local gfx = field and field.magnetTrain
self.ride = MagnetTrain.new({
toGoldenrod = opts.toGoldenrod,
@@ -65,8 +65,8 @@ function MagnetTrainRide.new(game, opts)
fgTilemap = gfx and gfx.tilemap,
})
self.tileset = self.data and self.data.tilesets
and self.data.tilesets.TILESET_TRAIN_STATION
self.tileset = self.data and self.data.gen2Tilesets
and self.data.gen2Tilesets.TILESET_TRAIN_STATION
self.palettes = self:bgPalettes()
self.spriteSheet = self:playerSheet()
@@ -84,7 +84,7 @@ end
-- even though both stations are INDOOR maps.
function MagnetTrainRide:bgPalettes()
local data = self.data
local palettes = data and data.palettes
local palettes = data and data.gen2Palettes
if not palettes then return nil end
return Palettes.bgSet(palettes, { environment = "TOWN" },
Palettes.clockDaytime())
@@ -129,7 +129,7 @@ end
-- are cut per 8x8 sub-tile rather than by the sheet's 16-pixel width, because
-- the OAM data addresses the four tiles of a frame individually.
function MagnetTrainRide:playerSheet()
local sprites = self.data and self.data.sprites
local sprites = self.data and self.data.gen2Sprites
local def = sprites and (sprites.SPRITE_CHRIS or sprites.SPRITE_KRIS)
local path = def and def.image
if not path then return nil end
@@ -259,8 +259,8 @@ end
-- MapObjectPals' PAL_OW_RED, the palette every .OAMData_MagnetTrainRed entry
-- names.
function MagnetTrainRide:playerPalette()
local palettes = self.data and self.data.palettes
local sprites = self.data and self.data.sprites
local palettes = self.data and self.data.gen2Palettes
local sprites = self.data and self.data.gen2Sprites
if not palettes then return nil end
return Palettes.spritePalette(palettes, Palettes.clockDaytime(),
sprites and sprites.SPRITE_CHRIS)
+9
View File
@@ -211,6 +211,15 @@ local ROWS = {
TC.buzz(options.haptics)
if game and game.persistOptions then game:persistOptions() end
end },
{ label = "MAX FPS", key = "fpsCap", port = true,
cycle = function(options, delta)
local FrameCap = require("src.core.FrameCap")
options.fpsCap = FrameCap.cycle(options.fpsCap, delta)
FrameCap.apply(options.fpsCap)
end,
text = function(options)
return require("src.core.FrameCap").label(options.fpsCap)
end },
{ label = "CANCEL", cancel = true },
}
+93 -27
View File
@@ -67,6 +67,10 @@ local SUBMENU_LABEL = {
-- (data/text/common_2.asm), the three lines TossMenu prints in order.
local TOSS_HOW_MANY = { "Throw away how", "many?" }
-- _AskItemMoveText (data/text/common_2.asm:322), printed while wSwitchItem
-- holds a row and the cursor is looking for its new home.
local ASK_ITEM_MOVE = { "Where should this", "be moved to?" }
-- _YouDontHaveAMonText and .AnEggCantHoldAnItemText, GiveItem's two refusals.
local NO_POKEMON = { "You don't have a", "#MON!" }
local EGG_CANT_HOLD = { "An EGG can't hold", "an item." }
@@ -127,6 +131,9 @@ function PackMenu.new(game, opts)
self.game = game
self.save = opts.save or (game and game.save)
self.items = opts.items or (game and game.data and game.data.items)
-- Bag.order / Bag.move read `.items` off an injectable data table, and the
-- one this screen draws from is not always the Data singleton.
self.bagData = { items = self.items }
self.world = opts.world or (game and game.world)
self.onChoose = opts.onChoose
self.onClose = opts.onClose
@@ -207,7 +214,13 @@ end
function PackMenu:rebuild()
local pocket = self:pocket().id
local rows = {}
for itemId, raw in pairs((self.save and self.save.inventory) or {}) do
local save = self.save
local inventory = (save and save.inventory) or {}
-- Row order IS wBagItems' order, which is what SELECT rewrites.
local order = (save and save.inventory)
and Bag.order(save, self.bagData) or {}
for _, itemId in ipairs(order) do
local raw = inventory[itemId]
-- A count that is not a number at all (a hand-written save, a mod, an old
-- migration) counts as one rather than raising out of the draw.
local count = tonumber(raw) or (raw and 1) or 0
@@ -219,18 +232,13 @@ function PackMenu:rebuild()
name = PackMenu.label(itemId, def),
teaches = self:moveLabel(def and def.teaches),
tmNumber = def and def.tmNumber,
-- KEY_ITEM and TM_HM rows do not show a quantity on the cart.
showCount = pocket == "ITEM" or pocket == "BALL",
index = def and def.index or math.huge,
-- A KEY_ITEM never shows one, and engine/items/tmhm.asm:390 skips the
-- count for an HM only -- a TM prints ×NN like any other stack.
showCount = pocket == "ITEM" or pocket == "BALL"
or (pocket == "TM_HM" and tostring(itemId):sub(1, 3) ~= "HM_"),
}
end
end
-- Bag order on the cart is acquisition order; without that recorded, item id
-- order is the stable, reproducible choice.
table.sort(rows, function(a, b)
if a.index ~= b.index then return a.index < b.index end
return a.id < b.id
end)
self.rows = rows
self.index = math.min(self.index, #rows + 1)
if self.index < 1 then self.index = 1 end
@@ -671,6 +679,11 @@ function PackMenu:update(_dt)
self:updateQuantity(input)
return
end
-- engine/items/pack.asm:1237 Pack_InterpretJoypad .switching_item
if self.switching then
self:updateSwitch(input)
return
end
if self.message then
if input:wasPressed("a") or input:wasPressed("b") then
self.message = nil
@@ -716,11 +729,52 @@ function PackMenu:update(_dt)
end
return
elseif input:wasPressed("select") then
self:registerSelected()
self:armSwitch()
return
end
end
-- engine/items/pack.asm:1290 Pack_InterpretJoypad .select
function PackMenu:armSwitch()
if self:isCancel() then return end
if not self.rows[self.index] then return end
self.switching = self.index
self.message = ASK_ITEM_MOVE
end
-- `.switching_item` (engine/items/pack.asm:1297): A or SELECT places, B backs
-- out, and left/right cannot leave the pocket mid-move.
function PackMenu:updateSwitch(input)
if input:wasPressed("up") then
self.index = self.index > 1 and self.index - 1 or self:total()
self:ensureVisible()
elseif input:wasPressed("down") then
self.index = self.index < self:total() and self.index + 1 or 1
self:ensureVisible()
elseif input:wasPressed("a") or input:wasPressed("select") then
self:placeSwitch()
elseif input:wasPressed("b") then
self:endSwitch()
end
end
-- engine/items/pack.asm:1307 .place_insert / .end_switch
function PackMenu:placeSwitch()
local from = self.switching
local row = self.rows[from]
if row and not self:isCancel() and self.index ~= from then
Bag.move(self.save, row.id, self:pocket().id, self.index, self.bagData)
self:rebuild()
self:storeCursor()
end
self:endSwitch()
end
function PackMenu:endSwitch()
self.switching = nil
self.message = nil
end
-- VerticalMenu over the submenu rows: up/down wrap, A picks, B is the carry
-- that ExitMenu answers with (`ret c`), which is QUIT by another name.
function PackMenu:updateSubmenu(input)
@@ -777,12 +831,10 @@ function PackMenu:updateConfirm(input)
end
end
-- RegisterItem (engine/items/pack.asm), the submenu's SEL row. SELECT on the
-- highlighted row reaches the same routine: the cart's SELECT is the bag's own
-- item shuffle, which this port does not have, so the button is free and a
-- player who knows Gen 1's registration shortcut gets it. World:registerItem
-- re-runs CheckSelectableItem's gate (TM/HM and anything CANT_SELECT_F
-- refuses), so neither door can register what the cart would not.
-- RegisterItem (engine/items/pack.asm), the submenu's SEL row and its ONLY
-- door -- the cart's SELECT is the bag's own item shuffle (see armSwitch).
-- World:registerItem re-runs CheckSelectableItem's gate (TM/HM and anything
-- CANT_SELECT_F refuses), so it cannot register what the cart would not.
function PackMenu:registerSelected()
if self:isCancel() then return end
local row = self.rows[self.index]
@@ -828,22 +880,38 @@ end
-- The list, description and cursor, on top of whatever chrome was drawn.
--
-- PlaceMenuItemQuantity (engine/menus/menu_2.asm) writes the ×N one row DOWN
-- and one column RIGHT of the name -- the quantity is the entry's second line,
-- not a right-aligned column, which is why every PACK row is two tiles tall.
-- PlaceMenuItemQuantity (engine/menus/menu_2.asm:10) writes the ×N one row
-- DOWN and one column RIGHT of the name -- the quantity is the entry's second
-- line, not a right-aligned column, which is why every PACK row is two tiles
-- tall. Its `lb bc, 1, 2` is a TWO-digit field with the leading digit blanked,
-- so the ones digit sits at name + 3 whether the count is 5 or 50.
--
-- ScrollingMenu_PlaceCursor (engine/menus/scrolling_menu.asm:438) marks the
-- row SELECT armed with the hollow ▷ while the solid ▶ goes on looking.
function PackMenu:drawList(listX, listY)
for row = 1, VISIBLE_ROWS do
local i = row + self.scroll
local ty = listY + (row - 1) * LIST_SPACING
if i <= #self.rows then
local entry = self.rows[i]
if i == self.index then Chrome.cursor(listX - 1, ty) end
if i == self.index then
Chrome.cursor(listX - 1, ty)
elseif i == self.switching then
Chrome.cursor(listX - 1, ty, true)
end
Chrome.print(entry.name, listX, ty)
if entry.teaches then
-- The TM pocket puts the move the TM teaches on that second line.
-- The TM pocket puts the move the TM teaches on that second line, and
-- its count at listX + 9 (engine/items/tmhm.asm:392) -- on the LABEL's
-- line here, since the move name owns the one below it.
Chrome.print(entry.teaches, listX + 1, ty + 1)
if entry.showCount then
Chrome.print("\xc3\x97" .. Chrome.number(entry.count, 2),
listX + 9, ty)
end
elseif entry.showCount then
Chrome.print("\xc3\x97" .. tostring(entry.count), listX + 1, ty + 1)
Chrome.print("\xc3\x97" .. Chrome.number(entry.count, 2),
listX + 1, ty + 1)
end
elseif i == self:total() then
if i == self.index then Chrome.cursor(listX - 1, ty) end
@@ -897,12 +965,10 @@ function PackMenu:drawSubmenu()
end
end
-- TossItem_MenuHeader is `menu_coords 15, 9, SCREEN_WIDTH - 1, TEXTBOX_Y - 1`
-- with NoPriceToDisplay behind it: a small box in the bottom right holding
-- nothing but the count.
-- engine/items/buy_sell_toss.asm:133 BuySellToss_UpdateQuantityDisplay
function PackMenu:drawQuantity()
Chrome.box(15, 9, 5, 3)
Chrome.print("\xc3\x97" .. tostring(self.qtyState.qty), 16, 10)
Chrome.print("\xc3\x97" .. Chrome.number(self.qtyState.qty, 2, true), 16, 10)
end
-- YesNoBox's own coords, the same box every other Gen 2 screen here draws.
+3 -7
View File
@@ -20,6 +20,7 @@ local Chrome = require("src.ui.gen2.Chrome")
local Font = require("src.render.Font")
local GbcPalette = require("src.render.GbcPalette")
local HpBar = require("src.battle.gen2.HpBar")
local ItemEffects = require("src.core.gen2.ItemEffects")
local Logger = require("src.core.Logger")
local Mail = require("src.core.gen2.Mail")
local Mon = require("src.battle.gen2.Mon")
@@ -668,17 +669,12 @@ end
-- PlaceStatusString (engine/pokemon/mon_stats.asm): three letters, and a mon
-- with no HP reads FNT whatever its status byte says.
local STATUS_STRING = {
slp = "SLP", psn = "PSN", brn = "BRN", frz = "FRZ", par = "PAR",
poison = "PSN", burn = "BRN", freeze = "FRZ", paralysis = "PAR",
sleep = "SLP", toxic = "PSN",
}
local function statusString(mon)
if (mon.hp or 0) <= 0 then return "FNT" end
local status = mon.status
if not status then return nil end
return STATUS_STRING[tostring(status):lower()]
local class = ItemEffects.STATUS_CLASS[tostring(status):lower()]
return class and class:upper()
end
-- One list row's strings, exactly what WritePartyMenuTilemap's quality
+127 -2
View File
@@ -24,6 +24,8 @@ local Chrome = require("src.ui.gen2.Chrome")
local Logger = require("src.core.Logger")
local Mail = require("src.core.gen2.Mail")
local Runtime = require("src.mods.Runtime")
local Save = require("src.core.gen2.Save")
local SaveMenu = require("src.ui.gen2.SaveMenu")
local Screens = require("src.ui.Screens")
local Strings = require("src.core.Strings")
@@ -36,6 +38,13 @@ local MON_HOLDING_MAIL = {
Strings.source("Please remove the\nMAIL."),
}
-- _ChangeBoxSaveText (data/text/common_2.asm:1306) is three lines whose `cont`
-- has already scrolled by the time YesNoBox goes up over its last two.
local CHANGE_BOX_SAVE = { "#MON BOX, data", "will be saved. OK?" }
-- YesNoBox's own `lb bc, SCREEN_WIDTH - 6, 7` (home/menu.asm:382-383).
local YESNO_X, YESNO_Y, YESNO_W, YESNO_H = 14, 7, 6, 5
local PcMenu = {}
PcMenu.__index = PcMenu
PcMenu.isOpaque = true
@@ -90,6 +99,13 @@ function PcMenu.new(game, opts)
self.onClose = opts.onClose
self.house = opts.house and true or false
self.events = opts.events
-- The same route the start menu's SAVE row takes (src/core/Game2.lua:435),
-- so the save.write veto and the save.writing event fire here too.
self.writer = opts.writer
or (game and type(game.writeSave) == "function"
and function() return game:writeSave() end)
or Save.save
self.saveExists = opts.saveExists
-- The folded MAIL BOX row belongs to the item PC, and the whose-PC menu
-- reaches that through <PLAYER>'s PC (src/ui/gen2/ItemPcMenu.lua), so
-- BILL's PC shows the cart's own five rows. The bedroom's PC keeps it: the
@@ -156,6 +172,91 @@ function PcMenu:close()
if self.onClose then self.onClose(self.changedDecorations) end
end
-- engine/pokemon/bills_pc.asm:2403 BillsPC_ChangeBoxSubmenu .Switch
-- engine/menus/save.asm:40 ChangeBoxSaveGame
function PcMenu:beginChangeBox(index)
self.changeBox = index
self.savePhase = "confirm"
self.saveChoice = 1
self.saveTimer = 0
self.saved = nil
local existed = self.saveExists
if existed == nil then existed = Save.exists("gold") end
self.existed = existed
end
-- .refused: `pop de / ret`, with wCurBox untouched and the picker still up.
function PcMenu:refuseChangeBox()
self.savePhase, self.changeBox = nil, nil
self.saveTimer = 0
end
function PcMenu:acceptChangeBox()
if self.saveChoice == 2 then return self:refuseChangeBox() end
if self.savePhase == "confirm" and self.existed then
self.savePhase = "overwrite"
self.saveChoice = 1
return
end
self.savePhase = "saving"
self.saveTimer = 0
end
-- `pop de / ld a, e / ld [wCurBox], a` sits between SaveBox and
-- SavingDontTurnOffThePower, so the new index rides the file that is written.
function PcMenu:writeChangeBox()
Boxes.setCurrent(self.save, self.changeBox)
local ok = self.writer(self.save)
self.saved = ok and true or false
if ok then SaveMenu.playSaveSfx(self.game, SaveMenu.SFX_SAVE) end
end
function PcMenu:savePrompt()
if self.savePhase == "overwrite" then return SaveMenu.OVERWRITE_PROMPT end
if self.savePhase == "saving" then return SaveMenu.SAVING_PROMPT end
if self.savePhase == "done" then
if self.saved then
local name = (self.save.player and self.save.player.name) or "GOLD"
return { name .. " saved", "the game." }
end
return { "Could not save.", "" }
end
return CHANGE_BOX_SAVE
end
function PcMenu:updateChangeBox()
-- SavingDontTurnOffThePower is DelayFrames, not a prompt: no button does
-- anything until the sequence runs out (engine/menus/save.asm:55).
if self.savePhase == "saving" then
self.saveTimer = self.saveTimer + 1
if self.saveTimer >= SaveMenu.SAVING_FRAMES then
self:writeChangeBox()
self.savePhase = "done"
self.saveTimer = 0
end
return
end
if self.savePhase == "done" then
self.saveTimer = self.saveTimer + 1
if self.saveTimer >= SaveMenu.SAVED_FRAMES then
self.savePhase, self.changeBox = nil, nil
self.picking = false
end
return
end
local input = self.game and self.game.input
if not input then return end
if input:wasPressed("up") or input:wasPressed("down") then
self.saveChoice = self.saveChoice == 1 and 2 or 1
elseif input:wasPressed("a") then
self:acceptChangeBox()
elseif input:wasPressed("b") then
-- B out of a yes/no is NO (InterpretTwoOptionMenu returns carry).
self:refuseChangeBox()
end
end
function PcMenu:choose()
local entry = self.entries[self.index]
if not entry then return end
@@ -231,6 +332,11 @@ function PcMenu:update(_dt)
return
end
if self.savePhase then
self:updateChangeBox()
return
end
if self.picking then
local total = Boxes.NUM_BOXES
if input:wasPressed("up") then
@@ -238,8 +344,11 @@ function PcMenu:update(_dt)
elseif input:wasPressed("down") then
self.pickIndex = self.pickIndex < total and self.pickIndex + 1 or 1
elseif input:wasPressed("a") then
Boxes.setCurrent(self.save, self.pickIndex)
self.picking = false
if self.pickIndex == (self.save.currentBox or 1) then
self.picking = false
else
self:beginChangeBox(self.pickIndex)
end
elseif input:wasPressed("b") then
self.picking = false
end
@@ -287,6 +396,22 @@ function PcMenu:drawPanel()
("%d/%d"):format(Boxes.count(self.save, i), Boxes.MONS_PER_BOX),
18, ty)
end
if self.savePhase then
-- ChangeBoxSaveGame's MenuTextbox, then YesNoBox over the box list.
Chrome.box(0, 12, 20, 6)
local lines = self:savePrompt()
Chrome.print(lines[1] or "", 1, 14)
Chrome.print(lines[2] or "", 1, 16)
if self.savePhase == "confirm" or self.savePhase == "overwrite" then
Chrome.box(YESNO_X, YESNO_Y, YESNO_W, YESNO_H)
Chrome.print("YES", YESNO_X + 2, YESNO_Y + 1)
Chrome.print("NO", YESNO_X + 2, YESNO_Y + 3)
Chrome.cursor(YESNO_X + 1,
YESNO_Y + (self.saveChoice == 1 and 1 or 3))
end
love.graphics.setColor(1, 1, 1, 1)
return
end
Chrome.box(0, 14, 20, 4)
Chrome.print("Which BOX?", 1, 16)
love.graphics.setColor(1, 1, 1, 1)
+13
View File
@@ -2096,10 +2096,23 @@ function Pokegear:mapCursorSprite(x, y)
Chrome.cursor(math.floor(x / 8), math.floor(y / 8))
end
-- PokegearRadio_Init's tile $08 at `depixel 4, 10, 4, 4`, three rows deep
-- (data/sprite_anims/oam.asm:588), x = knob (pokegear.asm:1355).
function Pokegear:drawTuningKnob()
self:loadArrowSheet()
if not (self.arrow and self.arrow:available()) then return end
local row = self:currentStation()
local tx = (72 + (row and row.knob or 0)) / 8
self.arrow:draw(0x08, tx, 1)
self.arrow:draw(0x08, tx, 2)
self.arrow:draw(0x08, tx, 3)
end
function Pokegear:drawRadio()
self:ensureTuned()
self:drawTilemap(self.gfx and self.gfx.cards and self.gfx.cards.radio)
self:drawStrip()
self:drawTuningKnob()
local station = self:currentStation()
-- UpdateRadioStation prints the tuned channel's name at (2,9). Dead air
-- prints nothing: NoRadioStation clears the box and leaves it clear.
+19 -4
View File
@@ -54,6 +54,11 @@ local TIME_X, TIME_Y = 13, 8
local YESNO_X, YESNO_Y, YESNO_W, YESNO_H = 0, 7, 6, 5
-- AlreadyASaveFileText (AskOverwriteSaveFile, engine/menus/save.asm:47) and
-- SavingDontTurnOffThePower's own line, shared with the PC's CHANGE BOX save.
local OVERWRITE_PROMPT = { "There is already a", "save file. Is it" }
local SAVING_PROMPT = { "SAVING… DON'T TURN", "OFF THE POWER." }
function SaveMenu:wantsFillScale() return true end
function SaveMenu:drawsWidescreen() return true end
@@ -75,14 +80,18 @@ function SaveMenu.new(game, opts)
return self
end
function SaveMenu:playSfx(id)
local data = self.game and self.game.data
function SaveMenu.playSaveSfx(game, id)
local data = game and game.data
local audio = data and data.audio
if not (audio and audio.sfxOrder) then return end
local name = audio.sfxOrder[id + 1]
if name and audio.sfx and audio.sfx[name] then Sound.play(data, name) end
end
function SaveMenu:playSfx(id)
SaveMenu.playSaveSfx(self.game, id)
end
function SaveMenu:finish(saved)
if self.onDone then self.onDone(saved) end
end
@@ -162,10 +171,10 @@ function SaveMenu:prompt()
if self.phase == "overwrite" then
-- AlreadyASaveFileText when the file is this player's; AnotherSaveFileText
-- when the ID differs. Only the first can happen here.
return { "There is already a", "save file. Is it" }
return OVERWRITE_PROMPT
end
if self.phase == "saving" then
return { "SAVING… DON'T TURN", "OFF THE POWER." }
return SAVING_PROMPT
end
if self.phase == "done" then
if self.saved then
@@ -226,4 +235,10 @@ function SaveMenu:drawWidescreen(winW, winH)
G.pop()
end
SaveMenu.SFX_SAVE = SFX_SAVE
SaveMenu.SAVING_FRAMES = SAVING_FRAMES
SaveMenu.SAVED_FRAMES = SAVED_FRAMES
SaveMenu.OVERWRITE_PROMPT = OVERWRITE_PROMPT
SaveMenu.SAVING_PROMPT = SAVING_PROMPT
return SaveMenu
+6 -10
View File
@@ -63,6 +63,7 @@ local Chrome = require("src.ui.gen2.Chrome")
local Font = require("src.render.Font")
local GbcPalette = require("src.render.GbcPalette")
local HpBar = require("src.battle.gen2.HpBar")
local ItemEffects = require("src.core.gen2.ItemEffects")
local Mon = require("src.battle.gen2.Mon")
local Palettes = require("src.world.gen2.Palettes")
local Pokerus = require("src.core.gen2.Pokerus")
@@ -109,15 +110,6 @@ local TYPE_NAMES = {
CURSE_TYPE = "???",
}
-- PlaceStatusString (engine/pokemon/mon_stats.asm): three letters, and a mon
-- with no HP reads FNT whatever its status byte says. Same table the party
-- list uses; both screens call the same routine on the cart.
local STATUS_STRING = {
slp = "SLP", psn = "PSN", brn = "BRN", frz = "FRZ", par = "PAR",
poison = "PSN", burn = "BRN", freeze = "FRZ", paralysis = "PAR",
sleep = "SLP", toxic = "PSN",
}
-- Gen 2 pics are 5x5, 6x6 or 7x7 and PadFrontpic centres the small ones in
-- the 7x7 block PrepMonFrontpic lays at hlcoord 0, 0. Same table the dex
-- uses, for the same reason.
@@ -208,11 +200,15 @@ local function levelText(level)
return "<LV>" .. tostring(level)
end
-- PlaceStatusString (engine/pokemon/mon_stats.asm): three letters, and a mon
-- with no HP reads FNT whatever its status byte says. Same lookup the party
-- list makes; both screens call the same routine on the cart.
local function statusText(mon)
if (mon.hp or 0) <= 0 then return "FNT" end
local status = mon.status
if not status then return nil end
return STATUS_STRING[tostring(status):lower()]
local class = ItemEffects.STATUS_CLASS[tostring(status):lower()]
return class and class:upper()
end
-- wTempMonPokerusStatus is one byte: the low nibble counts the days left and
+9 -1
View File
@@ -105,8 +105,16 @@ function NPC:update(map, entities)
end
end
-- UpdateNPCSprite branches to NotYetMoving while BIT_FONT_LOADED is set
-- -- engine/overworld/movement.asm:139
local function textBoxUp()
local stack = require("src.core.Game").stack
local top = stack and stack.top and stack:top()
return top ~= nil and not top.isOverworld
end
function NPC:walkPhase()
if not self.moving then return 0 end
if not self.moving or textBoxUp() then return 0 end
-- engine/overworld/movement.asm:301
local p = (self.animClock or 0) % 16
return (p >= 4 and p < 12) and 1 or 0
+42 -2
View File
@@ -20,6 +20,7 @@ local Player = require("src.world.Player")
local Runtime = require("src.mods.Runtime")
local Screens = require("src.ui.Screens")
local ScriptRunner = require("src.script.ScriptRunner")
local Theme = require("src.ui.Theme")
local Tilt = require("src.render.Tilt")
local TextBox = require("src.render.TextBox")
local Transition = require("src.render.Transition")
@@ -1109,6 +1110,13 @@ function OverworldState:update(dt)
end
end
-- EnterMapAnim's .done tail re-enables the companion once the swoop or the
-- spin-down has landed (player_animations.asm:40)
if self.pikachuWarpHidden and not (self.flyAnim or self.flyArrive
or self.teleportOut or self.transitioning or self.player.spinning) then
self:showPikachuAfterWarp()
end
-- Dig/Teleport/Escape-Rope departure spin (beginTeleportOut). The sprite
-- spins UP out of the map before the fade (player_animations.asm
-- _LeaveMapAnim -> PlayerSpinWhileMovingUp + SFX_TELEPORT_EXIT_1), the
@@ -1190,7 +1198,9 @@ function OverworldState:update(dt)
or self.engaging or self.emote or self.teleportOut
or self.flyAnim or self.flyArrive
end
if not scripted and not self.transitioning then
-- a scriptMove's onDone can push a text box on the frame it retires, and
-- DisplayTextID owns the loop from there (home/text_script.asm:3)
if not scripted and not self.transitioning and Game.stack:top() == self then
self:handleInput()
end
@@ -1716,6 +1726,28 @@ function OverworldState:syncSurfingPikachu()
p.surfingPikachu = mon ~= nil and mon.species == "PIKACHU" or false
end
-- _LeaveMapAnim drops the companion's sprite before the animation starts and
-- EnterMapAnim only puts it back once landed -- home/pikachu.asm:1, :10
function OverworldState:hidePikachuForWarp()
self.pikachuWarpHidden = true
require("src.world.PikachuFollower").setVisible(self, false)
end
function OverworldState:showPikachuAfterWarp()
self.pikachuWarpHidden = nil
self.pikachuTrail = { x = self.player.cellX, y = self.player.cellY }
local Follower = require("src.world.PikachuFollower")
local npc = Follower.current(self)
if npc then
npc.cellX, npc.cellY = self.player.cellX, self.player.cellY
npc.px, npc.py = npc.cellX * 16, npc.cellY * 16
npc.targetX, npc.targetY = nil, nil
npc.goalX, npc.goalY = nil, nil
npc.moving = false
end
Follower.setVisible(self, true)
end
-- The rejection loop shared by the Good and Super Rods
-- (item_effects.asm ItemUseGoodRod .RandomLoop / ReadSuperRodData): an
-- odd random byte is no bite; otherwise a 2-bit pick rerolls until it
@@ -1822,6 +1854,7 @@ function OverworldState:flyTo(mapId)
Game.save.forcedBike = nil -- HandleFlyWarpOrDungeonWarp res BIT_ALWAYS_ON_BIKE
self.player.surfing = false
self:syncSurfingPikachu()
self:hidePikachuForWarp()
-- _LeaveMapAnim .flyAnimation: the bird flaps in place (8 x Delay3),
-- then SFX_FLY and the up-right path, a 40-frame beat off screen, and
-- the exit over the top-left -- the warp fades only once the bird is
@@ -1852,6 +1885,7 @@ function OverworldState:beginTeleportOut(onDone)
require("src.core.Sound").play(Game.data, "Teleport_Exit1")
self.player.surfing = false
self:syncSurfingPikachu()
self:hidePikachuForWarp()
self.player.inputLocked = true
-- rising spin: the mirror of the arrival spin-drop set in startWarpTo, so
-- spinRise lifts the sprite (Player:pose) while spinFrames counts down
@@ -3045,6 +3079,7 @@ function OverworldState:nurseHeal(onDone, npc)
end
-- Yellow's companion has its own beat threaded through this sequence
local Follower = require("src.world.PikachuFollower")
-- YesNoChoicePokeCenter draws HEAL/CANCEL, not YES/NO (home/yes_no.asm:21)
Game.stack:push(TextBox.new(Game, hello, nil, { choice = function(yes)
if not yes then
Game.stack:push(TextBox.new(Game, bye, onDone))
@@ -3093,7 +3128,7 @@ function OverworldState:nurseHeal(onDone, npc)
end
end))
end)
end }))
end, choiceLabels = { "HEAL", "CANCEL" }, choiceBox = Theme.healCancelBox }))
end
-- pokecenter.asm bows the nurse between the two PrintText calls (#995)
@@ -4526,6 +4561,11 @@ function OverworldState:startWarpTo(mapId, x, y, facing, onDone, opts)
-- down, so the player is never drawable mid-fade nor standing bare on the
-- landing frame (#916)
self.playerHidden = false
-- setMap respawned a follower under the player; _LeaveMapAnim's Func_1510
-- suppression runs until EnterMapAnim lands (home/pikachu.asm:1)
if self.pikachuWarpHidden then
require("src.world.PikachuFollower").setVisible(self, false)
end
-- The warp we land ON stays inert for the completed-step check until we
-- physically step off it, so a warp whose destination cell is itself a
-- warp cannot bounce us straight back (elevator cars, stacked stair/door
+9
View File
@@ -226,7 +226,16 @@ function Player:facingCell()
return Collision.target(self.cellX, self.cellY, self.facing)
end
-- UpdatePlayerSprite jumps to .notMoving while BIT_FONT_LOADED is set
-- -- engine/overworld/movement.asm:57
local function textBoxUp()
local stack = require("src.core.Game").stack
local top = stack and stack.top and stack:top()
return top ~= nil and not top.isOverworld
end
function Player:walkPhase()
if textBoxUp() then return 0 end
-- moving, the land-frame after a completed step, or an active wall-bonk
-- (issue #230) animate; a standing sprite otherwise
if not self.moving and not self.stepLanded
+1 -1
View File
@@ -10,10 +10,10 @@ local PixelCanvas = require("src.render.PixelCanvas")
local MapPreview = {}
-- home/map.asm:1739-1748 LoadTilesetGFX
local ROOF_TILESETS = {
TILESET_JOHTO = true,
TILESET_JOHTO_MODERN = true,
TILESET_KANTO = true,
}
local function applyRoofOverlay(atlasPath, roofPath, tilesPerRow)
+58 -6
View File
@@ -117,6 +117,15 @@ local VAR = {
SPECIALPHONECALL = 0x14,
}
-- constants/ram_constants.asm:293 wPlayerState. PLAYER_SKATE (2) has no row:
-- nothing writes it, and FieldMoves has no string for it either.
local PLAYER_STATE_BY_ID = {
[0] = FieldMoves.PLAYER_NORMAL,
[1] = FieldMoves.PLAYER_BIKE,
[4] = FieldMoves.PLAYER_SURF,
[8] = FieldMoves.PLAYER_SURF_PIKA,
}
local BATTLETYPE = {
CANLOSE = 1,
FORCESHINY = 7,
@@ -5207,6 +5216,7 @@ function World:dropMapImages(mapId)
if key:sub(1, #prefix) == prefix then store[key] = nil end
end
end
if self.connectionMaps then self.connectionMaps[mapId] = nil end
end
-- LoadMapAttributes' refill, for every map the session has edited. Neighbour
@@ -7480,13 +7490,10 @@ function World:zoomScale()
return Zoom.scale(self:fitScale())
end
-- Outdoor Johto/Kanto town tilesets are the only ones that swap in roof
-- tiles $0a-$12 (mapgroup_roofs.asm). Applying roofs to indoor tilesets
-- in the same map group (lab, houses) corrupts their GFX.
-- home/map.asm:1739 LoadTilesetGFX
local ROOF_TILESETS = {
TILESET_JOHTO = true,
TILESET_JOHTO_MODERN = true,
TILESET_KANTO = true,
}
function World:atlasFor(mapDef)
@@ -8724,6 +8731,44 @@ function World:spawnFacing()
end
end
-- A neighbour map, built once and kept for its collision alone: the seam
-- queries below run on the per-step path.
function World:connectionMap(mapId)
self.connectionMaps = self.connectionMaps or {}
local cached = self.connectionMaps[mapId]
if cached ~= nil then return cached or nil end
local def = self.maps[mapId]
local tileset = def and self.tilesets[def.tileset]
local map = (def and tileset) and Map.new(def, tileset) or false
self.connectionMaps[mapId] = map
return map or nil
end
-- home/map.asm:1908 GetMovementPermissions
function World:cellCollisionAcross(map, cx, cy)
if map:inBounds(cx, cy) then return map:cellCollision(cx, cy) end
local dir
if cy < 0 then dir = "up"
elseif cy >= map.heightCells then dir = "down"
elseif cx < 0 then dir = "left"
elseif cx >= map.widthCells then dir = "right"
end
local conn = dir and map:connection(DIR_CONN[dir])
local dest = conn and conn.mapId and self.maps[conn.mapId]
local destMap = dest and self:connectionMap(conn.mapId)
if destMap then
local x, y = Map.connectionLanding(dest, conn, dir, cx, cy)
local vertical = dir == "up" or dir == "down"
local want = (vertical and cx or cy) - (conn.offset or 0) * 2
-- connectionLanding clamps into the destination; past the end of the strip
-- the buffer still holds this map's own border block
if x and (vertical and x or y) == want then
return destMap:cellCollision(x, y)
end
end
return map:cellCollision(cx, cy)
end
-- Seamless edge cross: swap map data, park the player one cell before the
-- landing (same world pixels the neighbor strip already showed), and keep
-- the step running so the seam does not hitch.
@@ -8736,7 +8781,13 @@ function World:tryConnection(dir)
local x, y = Map.connectionLanding(
dest, conn, dir, self.player.cellX, self.player.cellY)
if not x then return false end
local destMap = Map.new(dest, self.tilesets[dest.tileset])
local destMap = self:connectionMap(conn.mapId)
if not destMap then return false end
-- home/map.asm:1946 GetMovementPermissions side-wall arm
if dir == "down"
and Permissions.neighborBlocksDown(dir, destMap:cellCollision(x, y)) then
return false
end
-- A surfing crossing lands on water, which isWalkable refuses; the arm the
-- step would have taken is what decides, the same as it does inside the map.
local landable
@@ -8869,7 +8920,8 @@ function World:movePlayer(dir)
-- .CheckLandPerms and .CheckSurfPerms read the same wTilePermissions, so the
-- veto applies to walking and surfing alike.
local permitted = Permissions.stepPermitted(
function(x, y) return map:cellCollision(x, y) end, p.cellX, p.cellY, dir)
function(x, y) return self:cellCollisionAcross(map, x, y) end,
p.cellX, p.cellY, dir)
-- `.CheckNPC`'s IsNPCAtCoord answers for a BIG_OBJECT's whole 2x2 blob, and
-- Player:tryMove's entity scan only ever compares the one cell an object
-- stands on -- so the three cells the Vermilion Snorlax overhangs are vetoed
+112
View File
@@ -0,0 +1,112 @@
-- Driver: Fly / Teleport must take Pikachu with it (#1400). _LeaveMapAnim
-- drops the companion sprite before the bird flaps (home/pikachu.asm:1) and
-- EnterMapAnim only puts it back once the swoop or the spin has landed, so
-- Pikachu is invisible for the whole sequence and never stands on the landing
-- cell ahead of the player.
--
-- POKEPORT_DRIVER=tests/drivers/fly_pikachu_bug1400_test.lua \
-- POKEPORT_VERSION=yellow POKEPORT_TOUCH=0 SHOT_DIR=/tmp/shots love .
--
-- Never add POKEPORT_SPEED; the run needs an imported Yellow cache.
return function(game)
local U = dofile("tests/drivers/util.lua")
local Pokemon = require("src.pokemon.Pokemon")
local GameVersion = require("src.core.GameVersion")
local PF = require("src.world.PikachuFollower")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
return ok
end
check("running as Yellow (needs POKEPORT_VERSION=yellow)",
GameVersion.isYellow())
game.save.flags = game.save.flags or {}
game.save.flags.EVENT_GOT_STARTER = true
game.save.flags.EVENT_BATTLED_RIVAL_IN_OAKS_LAB = true
-- DisablePikachuOverworldSpriteDrawing is what keeps it in the ball
-- (pokeyellow scripts/OaksLab.asm); out of the ball is what follows (#1009)
game.save.pikachuInBall = false
game.save.party = { Pokemon.new(game.data, "PIKACHU", 20) }
game.save.onBike = false
local function drawn()
local ow = game.overworld
local npc = ow and PF.current(ow)
if not npc then return false end
for _, e in ipairs(ow.entities or {}) do
if e == npc then return true end
end
return false
end
U.teleport(game, "ROUTE_17", 4, 10, "down")
U.wait(30)
local ow = game.stack:top()
check("Pikachu is out and drawn before the flight", drawn())
U.shot(game, DIR .. "/bug1400_0_before.png")
ow:flyTo("PALLET_TOWN")
U.wait(12) -- mid in-place flap
check("hidden during the departure flap", not drawn())
U.shot(game, DIR .. "/bug1400_1_flap.png")
U.wait(60) -- the bird's path out
check("still hidden while the bird flies off", not drawn())
local guard = 0
while ow.map.id == "ROUTE_17" and guard < 900 do
guard = guard + 1
coroutine.yield()
end
guard = 0
while not ow.flyArrive and guard < 900 do
guard = guard + 1
coroutine.yield()
end
U.wait(12) -- mid swoop, the frame the old bug showed Pikachu already landed
check("hidden through the arrival swoop", not drawn())
U.shot(game, DIR .. "/bug1400_2_arrive.png")
guard = 0
while ow.flyArrive and guard < 900 do
guard = guard + 1
coroutine.yield()
end
U.wait(10)
check("back out once the bird has landed", drawn())
do
local npc = PF.current(ow)
local p = ow.player
check("and it comes back on the player's own cell",
npc and p and npc.cellX == p.cellX and npc.cellY == p.cellY)
end
U.shot(game, DIR .. "/bug1400_3_landed.png")
-- Dig / Teleport / Escape Rope take the same _LeaveMapAnim path
game.save.lastHeal = { map = "VIRIDIAN_CITY", x = 23, y = 26 }
ow:beginTeleportOut()
U.wait(20)
check("hidden during the teleport-out spin", not drawn())
U.shot(game, DIR .. "/bug1400_4_spin_out.png")
guard = 0
while (ow.teleportOut or ow.player.spinning or ow.transitioning)
and guard < 900 do
guard = guard + 1
coroutine.yield()
end
U.wait(10)
check("back out once the arrival spin has landed", drawn())
U.shot(game, DIR .. "/bug1400_5_spin_in.png")
U.log("Watch the shots in order: Pikachu stands beside the player before")
U.log("the flight, is nowhere on screen for the flap, the fade and the")
U.log("swoop, and only reappears under him once the bird sets him down.")
U.log("A Pikachu standing on the landing cell during the swoop is the bug.")
U.log("The pad is yours -- fly around and watch the departures.")
while true do coroutine.yield() end
end
+99
View File
@@ -0,0 +1,99 @@
-- #1401: $d9/$da battlergfx loaded the wrong row count
-- POKEPORT_GAME=gold POKEPORT_TOUCH=0 \
-- POKEPORT_DRIVER=tests/drivers/gold_bug1401_test.lua love .
local U = require("tests.drivers.util")
local Mon = require("src.battle.gen2.Mon")
-- data/moves/animations.asm:1840 Growl, :3605 Icy Wind
local CASES = {
{ move = "GROWL", cmd = "battlergfx_2row", rows = 1, head = 7, feet = 6 },
{ move = "ICY_WIND", cmd = "battlergfx_1row", rows = 2, head = 14, feet = 12 },
}
local function sheet(runner, gfx)
for _, entry in ipairs(runner.loaded or {}) do
if entry.gfx == gfx then return entry end
end
return nil
end
local function liftedStrip(runner)
local structs = runner.objects and runner.objects.structs or {}
for slot = 1, #structs do
local id = structs[slot].objectId
if type(id) == "string" and id:match("PLAYERHEAD") then return id end
if type(id) == "string" and id:match("ENEMYFEET") then return id end
end
return nil
end
return function(game)
U.wait(45)
local world = game.world
assert(world and world.map, "gold world did not boot")
game.save.party = { Mon.new(game.data, "CYNDAQUIL", 30) }
assert(world:startBattle({ wild = Mon.new(game.data, "PIDGEY", 30) }),
"startBattle failed")
local battle
for _ = 1, 600 do
local top = game.stack:top()
if top and top.battle then battle = top break end
U.wait(1)
end
assert(battle and battle.battle, "battle screen is not on the stack")
assert(battle.anims and battle.anims.scripts,
"battle_anims.lua has no scripts -- re-import Gold")
local failed = false
for _, case in ipairs(CASES) do
battle.anim = nil
if not battle:animForMove(case.move, "player") then
U.log("FAIL no animation for", case.move)
failed = true
else
local head, feet
for _ = 1, 400 do
local runner = battle.anim
if not runner then break end
head = head or sheet(runner, "BATTLE_ANIM_GFX_PLAYERHEAD")
feet = feet or sheet(runner, "BATTLE_ANIM_GFX_ENEMYFEET")
U.wait(1)
end
if not (head and feet) then
U.log("FAIL", case.move, "never loaded the battler sheets")
failed = true
else
U.log(("%-9s %-16s rows=%d head=%d feet=%d (want rows=%d head=%d feet=%d)")
:format(case.move, case.cmd, head.rows, head.tiles, feet.tiles,
case.rows, case.head, case.feet))
if head.rows ~= case.rows or head.tiles ~= case.head
or feet.tiles ~= case.feet then
U.log("FAIL", case.move, "loaded the wrong row count")
failed = true
end
end
end
end
U.log(failed and "FAIL #1401" or "PASS #1401 battlergfx rows follow the jumptable")
U.log("look: Growl lifts ONE row of the Cyndaquil's own tiles, not two")
battle.anim = nil
battle:animForMove("GROWL", "player")
for _ = 1, 400 do
if not battle.anim then break end
local strip = liftedStrip(battle.anim)
if strip then
U.log("parked on", strip)
break
end
U.wait(1)
end
while true do
coroutine.yield()
end
end
+161
View File
@@ -0,0 +1,161 @@
-- #1421: the Gold battle HUD ran ahead of its own animations.
--
-- POKEPORT_GAME=gold POKEPORT_TOUCH=0 \
-- POKEPORT_DRIVER=tests/drivers/gold_bug1421_test.lua love .
-- POKEPORT_SHOT_DIR=/tmp/gold-bug1421 (default)
--
-- Two symptoms, one seam, and neither can be asserted -- both are "what is on
-- screen while an animation runs":
--
-- caught a wild mon that is NOT in the dex is caught with a MASTER BALL.
-- The caught mark ($5d at (1,1)) must be absent for every frame of
-- the throw and every line after it, because DrawEnemyHUDBorder --
-- the only thing that paints it -- is never called again during a
-- capture (engine/battle/trainer_huds.asm:134-151). The control
-- run right after it re-enters a battle with the same species now
-- in the dex, where the mark IS there from the first frame.
--
-- status THUNDER WAVE on the enemy. PAR may not be on the HUD until the
-- animation and the "is paralyzed!" line are done with, which is
-- where UpdateBattleHuds runs (home/battle.asm:150).
--
-- Every frame of both animations is logged as `live` (the engine's byte, a
-- whole turn ahead) against `hud` (what the HUD is allowed to print), so the
-- lag is a column of text as well as a strip of pictures. The run ends in a
-- third wild battle with THUNDER WAVE under the cursor: press A, A, A and
-- watch the tag land with its own line.
local U = require("tests.drivers.util")
local Mon = require("src.battle.gen2.Mon")
local OUT = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-bug1421"
local WILD = "PIDGEY"
local hostVisible = love.visible
love.visible = function(v) if hostVisible then pcall(hostVisible, v) end end
local function tap(game, button, frames)
game.input.pressQueue[#game.input.pressQueue + 1] = button
game.input.state[button] = true
U.wait(2)
game.input.state[button] = false
U.wait(frames or 6)
end
local function openBattle(game, opts)
assert(game.world:startBattle(opts), "startBattle failed")
for _ = 1, 900 do
local top = game.stack:top()
if top and top.battle then return top end
U.wait(1)
end
error("battle screen never came up")
end
local function toMenu(game, screen)
for _ = 1, 400 do
if screen.phase == "menu" then return true end
if screen.battle.over then return false end
tap(game, "a", 2)
end
return screen.phase == "menu"
end
local function giveMoves(game, mon, moves)
mon.moves = {}
for i, id in ipairs(moves) do
local def = assert(game.data.moves[id], id .. " is not in moves.lua")
mon.moves[i] = { id = id, pp = def.pp, maxPp = def.pp }
end
return mon
end
-- Shoot and log while the screen is busy: `probe` returns the two values the
-- run is about, and the caller gets them for every frame.
local function watch(game, screen, prefix, label, probe, limit)
local frames = 0
while frames < (limit or 240) do
if frames % 3 == 0 then
U.shot(game, ("%s-%03d.png"):format(prefix, frames))
local live, hud = probe()
U.log(("[driver] %s f%03d live=%s hud=%s")
:format(label, frames, tostring(live), tostring(hud)))
end
if not screen.anim and screen.phase ~= "resolving" then break end
frames = frames + 1
U.wait(1)
end
return frames
end
return function(game)
U.wait(45)
assert(game.world and game.world.map, "gold world did not boot")
local save = game.save
save.pokedex = save.pokedex or { seen = {}, caught = {} }
save.pokedex.caught[WILD] = nil
save.inventory = { MASTER_BALL = 5, POKE_BALL = 10 }
save.boxes = nil
save.party = { giveMoves(game, Mon.new(game.data, "CYNDAQUIL", 30),
{ "THUNDER_WAVE", "TACKLE" }) }
------------------------------------------------------------------ caught
local screen = openBattle(game, { wild = Mon.new(game.data, WILD, 5) })
assert(toMenu(game, screen), "never reached the battle menu")
U.shot(game, OUT .. "/caught-00-menu.png")
U.log(("[driver] caught: dex before = %s, hud latch = %s")
:format(tostring(save.pokedex.caught[WILD]), tostring(screen.caughtMark)))
screen:useItem("MASTER_BALL")
watch(game, screen, OUT .. "/caught-01-throw", "caught",
function()
return save.pokedex.caught[WILD] and true or false, screen.caughtMark
end, 200)
-- The lines that follow the throw: "Gotcha!", the dex entry, the nickname
-- prompt. The mark may not appear on any of them either.
for i = 0, 7 do
U.shot(game, ("%s/caught-02-after-%d.png"):format(OUT, i))
if screen.phase == "ask-nickname" then tap(game, "b", 4)
else tap(game, "a", 4) end
if screen.phase == "done" or not game.stack:top() then break end
end
U.log(("[driver] caught: dex after = %s, hud latch = %s")
:format(tostring(save.pokedex.caught[WILD]), tostring(screen.caughtMark)))
for _ = 1, 300 do
if game.world and not (game.stack:top() or {}).battle then break end
tap(game, "a", 2)
end
------------------------------------------------------------------ control
-- Same species, now in the dex: the mark IS on the HUD from the first frame
-- the enemy HUD is drawn, because DrawEnemyHUDBorder runs at battle start.
save.party = { giveMoves(game, Mon.new(game.data, "CYNDAQUIL", 30),
{ "THUNDER_WAVE", "TACKLE" }) }
screen = openBattle(game, { wild = Mon.new(game.data, WILD, 5) })
assert(toMenu(game, screen), "never reached the battle menu")
U.shot(game, OUT .. "/control-00-mark.png")
U.log(("[driver] control: dex = %s, hud latch = %s")
:format(tostring(save.pokedex.caught[WILD]), tostring(screen.caughtMark)))
------------------------------------------------------------------ status
screen:submit({ kind = "move", move = "THUNDER_WAVE" })
watch(game, screen, OUT .. "/status-01-anim", "status",
function()
local enemy = screen.battle.enemy
return enemy and enemy.status, screen:hudStatus(enemy, "enemy")
end, 240)
U.shot(game, OUT .. "/status-02-line.png")
for i = 0, 4 do
tap(game, "a", 4)
U.shot(game, ("%s/status-03-after-%d.png"):format(OUT, i))
end
U.log(("[driver] status: live=%s hud=%s"):format(
tostring(screen.battle.enemy and screen.battle.enemy.status),
tostring(screen:hudStatus(screen.battle.enemy, "enemy"))))
U.log("[driver] shots in " .. OUT
.. " -- the battle is yours: FIGHT, THUNDER WAVE, watch the tag land")
end
+97
View File
@@ -0,0 +1,97 @@
-- #1425 (and #1424): the PACK's ×NN column.
--
-- POKEPORT_GAME=gold POKEPORT_TOUCH=0 \
-- POKEPORT_DRIVER=tests/drivers/gold_bug1425_test.lua love .
-- POKEPORT_SHOT_DIR=/tmp/gold-bug1425 (default)
--
-- Nothing here can be asserted: the bug IS the column a digit lands in.
-- PlaceMenuItemQuantity's `lb bc, 1, 2` (engine/menus/menu_2.asm:24) is a
-- two-digit field with the leading digit blanked, so a ×5 and a ×50 must have
-- their ones digit in the SAME column; the port printed "×5" hard against the
-- cross. Each pocket is seeded with a one-digit and a two-digit count stacked
-- next to each other so the two rows can be read off against one another, and
-- the TM pocket is here because its rows used to print no count at all.
--
-- The run ends with the PACK still open on the TM pocket, so a human takes the
-- controls exactly where the screenshots stop.
local U = require("tests.drivers.util")
local Bag = require("src.inventory.Bag")
local PackMenu = require("src.ui.gen2.PackMenu")
-- One-digit and two-digit counts side by side in every pocket that shows one.
local SEED = {
{ "POTION", 5 },
{ "SUPER_POTION", 50 },
{ "ANTIDOTE", 1 },
{ "FULL_HEAL", 99 },
{ "POKE_BALL", 7 },
{ "GREAT_BALL", 12 },
{ "TM_DYNAMICPUNCH", 1 },
{ "TM_HEADBUTT", 3 },
{ "TM_THUNDER", 24 },
{ "HM_CUT", 1 },
{ "HM_SURF", 1 },
}
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-bug1425"
local function shot(name)
U.wait(3)
U.shot(game, ("%s/%s.png"):format(out, name))
end
U.wait(45)
assert(game.world and game.world.map, "gold world did not boot")
local save = game.save
save.inventory = {}
save.bagOrder = {}
for _, entry in ipairs(SEED) do
local id, count = entry[1], entry[2]
if not (game.data.items and game.data.items[id]) then
U.log("[driver] SKIP", id, "-- not in this cache")
else
save.inventory[id] = count
table.insert(Bag.order(save), id)
end
end
local pack = PackMenu.new(game, { save = save, world = game.world,
onClose = function() end })
game.stack:push(pack)
shot("00-items") -- ×5 over ×50: the ones digits line up
U.tap(game, "right")
shot("01-balls") -- ×7 over ×12
U.tap(game, "right")
shot("02-key-items") -- no counts at all here
U.tap(game, "right")
shot("03-tmhm") -- TMs carry ×NN, the two HMs carry none
-- SELECT arms the row instead of registering it (#1427): the hollow ▷ marks
-- TM_HEADBUTT while "Where should this be moved to?" holds the box.
U.tap(game, "down")
U.tap(game, "select")
shot("04-tmhm-armed")
U.tap(game, "up")
shot("05-tmhm-destination")
U.tap(game, "a")
shot("06-tmhm-moved") -- HEADBUTT is now the first TM row
U.log("[driver] bag order:", table.concat(Bag.order(save), " "))
-- Back to the ITEM pocket and into a TOSS, whose own box prints the count
-- with leading zeros (×01, not × 1).
U.tap(game, "left")
U.tap(game, "left")
U.tap(game, "left")
U.tap(game, "a")
U.tap(game, "down")
U.tap(game, "down")
U.tap(game, "a")
shot("07-toss-quantity")
U.log("[driver] shots in " .. out .. " -- the PACK is yours")
end
+174
View File
@@ -0,0 +1,174 @@
-- #1428: the Gold battle HUD never drew the party ball rows.
--
-- POKEPORT_GAME=gold POKEPORT_TOUCH=0 \
-- POKEPORT_DRIVER=tests/drivers/gold_bug1428_test.lua love .
-- POKEPORT_SHOT_DIR=/tmp/gold-bug1428 (default)
--
-- BattleStart_TrainerHuds (engine/battle/trainer_huds.asm:1-9) runs from
-- inside BattleStartMessage, so the rows are on screen UNDER the opening line
-- and nowhere else: the player's six always, the opponent's only outside a
-- wild battle. EnemySwitch_TrainerHud (:11-15) brings the opponent's row
-- back for the "will you switch?" prompt after one of its mons drops.
--
-- The party is seeded so all four staged tiles are on screen at once
-- (StageBallTilesData, :47-99): healthy, statused, fainted, and the empty
-- slots past the party count.
--
-- trainer the intro line, both rows, then the shift prompt after the first
-- enemy mon faints -- the opponent's row again, one ball darkened
-- wild the same intro with only the player's row, per the `dec a / ret z`
--
-- Nothing here is assertable: the fix IS twelve sprites. The run ends on the
-- trainer battle's own menu with a human holding the controls.
local U = require("tests.drivers.util")
local Mon = require("src.battle.gen2.Mon")
local Trainers = require("src.world.gen2.Trainers")
local OUT = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-bug1428"
local hostVisible = love.visible
love.visible = function(v) if hostVisible then pcall(hostVisible, v) end end
local function tap(game, button, frames)
game.input.pressQueue[#game.input.pressQueue + 1] = button
game.input.state[button] = true
U.wait(2)
game.input.state[button] = false
U.wait(frames or 6)
end
local function openBattle(game, opts)
assert(game.world:startBattle(opts), "startBattle failed")
for _ = 1, 900 do
local top = game.stack:top()
if top and top.battle then return top end
U.wait(1)
end
error("battle screen never came up")
end
-- Four healthy, one poisoned, one fainted, and one slot left empty: every
-- tile StageBallTilesData can stage, in one row. The lead is handed a
-- damaging move outright, because slot 1 of its level-up set is LEER.
local function seedParty(game)
local party = {}
for i = 1, 5 do
party[i] = Mon.new(game.data, "CYNDAQUIL", 20 + i)
end
party[1].moves = {}
for i, id in ipairs({ "EMBER", "TACKLE" }) do
local def = assert(game.data.moves[id], id .. " is not in moves.lua")
party[1].moves[i] = { id = id, pp = def.pp, maxPp = def.pp }
end
party[4].status = "poison"
party[5].hp = 0
return party
end
local function rowState(screen)
local rows = screen.ballRows or {}
return ("player=%s enemy=%s balls=%s"):format(
tostring(rows.player), tostring(rows.enemy),
tostring(screen.hud and screen.hud:image("balls") ~= nil))
end
return function(game)
U.wait(45)
assert(game.world and game.world.map, "gold world did not boot")
local save = game.save
save.inventory = {}
save.party = seedParty(game)
--------------------------------------------------------------- trainer
local entry = game.world:trainerParty(36, 1) -- BUG_CATCHER member 1
assert(entry, "no BUG_CATCHER member 1 in trainers.lua")
entry.party = Trainers.party(game.data, entry)
local screen = openBattle(game, { trainer = entry })
-- The opening line: both rows, both borders, and no names or bars yet.
-- Not before the intro slide has settled, or the shots catch the pics
-- mid-transform instead of the HUD.
U.wait(150)
for i = 0, 6 do
U.shot(game, ("%s/trainer-00-intro-%d.png"):format(OUT, i))
U.log("[driver] intro " .. i .. ": " .. rowState(screen))
U.wait(6)
end
U.log(("[driver] OT party = %d"):format(#(screen.battle.enemyParty or {})))
-- Page through the pic slide and the send-out: the rows go with the OAM the
-- send-out animation takes over.
for i = 0, 7 do
tap(game, "a", 5)
U.shot(game, ("%s/trainer-01-sendout-%d.png"):format(OUT, i))
end
for _ = 1, 400 do
if screen.phase == "menu" then break end
tap(game, "a", 2)
end
U.shot(game, OUT .. "/trainer-02-menu.png")
U.log("[driver] menu: " .. rowState(screen))
--------------------------------------------------------------- shift prompt
-- Drop the enemy's lead so HandleEnemySwitch offers the switch: its row is
-- redrawn for the prompt, with the fainted ball darkened.
if #(screen.battle.enemyParty or {}) > 1 then
for _ = 1, 400 do
if screen.phase == "ask-shift" or screen.phase == "shift-intro" then
break
end
if screen.battle.over then break end
if screen.phase == "menu" then
local enemy = screen.battle.enemy
if enemy then enemy.hp = 1 end
screen:chooseMenu("fight")
U.wait(2)
screen:chooseMove(1)
U.wait(4)
else
tap(game, "a", 6)
end
end
U.log("[driver] shift prompt: phase=" .. tostring(screen.phase))
for i = 0, 5 do
U.shot(game, ("%s/trainer-03-shift-%d.png"):format(OUT, i))
U.log("[driver] shift " .. i .. ": phase=" .. tostring(screen.phase)
.. " " .. rowState(screen))
U.wait(6)
end
-- NO: the enemy sends its next mon and the row goes with the animation.
tap(game, "down", 4)
tap(game, "a", 4)
for i = 0, 5 do
U.shot(game, ("%s/trainer-04-after-shift-%d.png"):format(OUT, i))
tap(game, "a", 5)
end
else
U.log("[driver] this trainer has one mon; no shift prompt to show")
end
--------------------------------------------------------------- wild
-- ShowPlayerMonsRemaining runs for a wild battle too; the `dec a / ret z`
-- right after it is what keeps the OT row off.
for _ = 1, 600 do
if not (game.stack:top() or {}).battle then break end
tap(game, "a", 2)
end
save.party = seedParty(game)
screen = openBattle(game, { wild = Mon.new(game.data, "PIDGEY", 5) })
U.wait(150)
for i = 0, 6 do
U.shot(game, ("%s/wild-00-intro-%d.png"):format(OUT, i))
U.log("[driver] wild " .. i .. ": " .. rowState(screen))
U.wait(6)
end
for _ = 1, 400 do
if screen.phase == "menu" then break end
tap(game, "a", 2)
end
U.shot(game, OUT .. "/wild-01-menu.png")
U.log("[driver] shots in " .. OUT .. " -- the battle is yours")
end
+142
View File
@@ -0,0 +1,142 @@
-- Driver: the shiny sparkle on a SENT OUT mon (#1431).
--
-- POKEPORT_GAME=gold POKEPORT_TOUCH=0 \
-- POKEPORT_DRIVER=tests/drivers/gold_bug1431_test.lua love .
-- POKEPORT_SHOT_DIR=/tmp/gold-bug1431 (default)
--
-- SendOutPlayerMon calls BattleCheckPlayerShininess and replays
-- ANIM_SEND_OUT_MON with wBattleAnimParam 1 -- the script's `.Shiny` arm --
-- between the plain animation and the cry (engine/battle/core.asm:3820-3837);
-- ShowSetEnemyMonAndSendOutAnimation does the same for every enemy send-out
-- (:3364-3383). Only the wild-intro path (BattleStartMessage, :8701-8715) had
-- been ported, so a shiny lead-off, a shiny switch-in and a shiny enemy
-- replacement all came out silent.
--
-- Three send-outs to watch, none of them the wild intro: the player's lead-off,
-- the player's own switch, and the opponent's replacement after a faint.
local U = require("tests.drivers.util")
local Mon = require("src.battle.gen2.Mon")
local GbcPalette = require("src.render.GbcPalette")
-- constants/battle_constants.asm: the DV pair BATTLETYPE_FORCESHINY forces.
local SHINY_DVS = { attack = 14, defense = 10, speed = 10, special = 10 }
local OUT = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-bug1431"
local function tap(game, button, frames)
game.input.pressQueue[#game.input.pressQueue + 1] = button
game.input.state[button] = true
U.wait(2)
game.input.state[button] = false
U.wait(frames or 6)
end
local function openBattle(game, opts)
assert(game.world:startBattle(opts), "startBattle failed")
for _ = 1, 900 do
local top = game.stack:top()
if top and top.battle then return top end
U.wait(1)
end
error("battle screen never came up")
end
-- Stop on the frame the `.Shiny` arm is actually running: animId plus the
-- wBattleAnimParam the arm branches on (data/moves/animations.asm:414-417).
local function shinyArm(screen)
local anim = screen.anim
return anim ~= nil and anim.animId == "ANIM_SEND_OUT_MON"
and anim.param == 1
end
local function watch(game, screen, label, frames)
for _ = 1, (frames or 400) do
if shinyArm(screen) then
U.log("[driver] " .. label .. ": .Shiny arm running")
U.shot(game, ("%s/%s.png"):format(OUT, label))
return true
end
U.wait(1)
end
U.log("[driver] " .. label .. ": NO shiny arm seen")
return false
end
return function(game)
U.wait(45)
assert(game.world and game.world.map, "gold world did not boot")
-- A shiny only reads as one in colour, and the sparkle rides the same anim.
GbcPalette.setMode("gbc")
local save = game.save
save.inventory = {}
local lead = Mon.new(game.data, "CYNDAQUIL", 40, { dvs = SHINY_DVS })
local bench = Mon.new(game.data, "TOTODILE", 40, { dvs = SHINY_DVS })
assert(lead.shiny and bench.shiny, "the FORCESHINY DVs did not take")
local move = assert(game.data.moves.EMBER, "no EMBER in moves.lua")
lead.moves = { { id = "EMBER", pp = move.pp, maxPp = move.pp } }
save.party = { lead, bench }
local entry = game.world:trainerParty(36, 1) -- BUG_CATCHER member 1
assert(entry, "no BUG_CATCHER member 1 in trainers.lua")
local Trainers = require("src.world.gen2.Trainers")
entry.party = Trainers.party(game.data, entry)
for _, mon in ipairs(entry.party) do mon.shiny = true end
local screen = openBattle(game, { trainer = entry })
------------------------------------------------------------ enemy lead
-- ShowSetEnemyMonAndSendOutAnimation, out of the opening sequence.
watch(game, screen, "00-enemy-sendout", 900)
----------------------------------------------------------- player lead
-- SendOutPlayerMon behind the "Go!" line: this is the one the report is
-- about, and it never sparkled before the fix.
local sawPlayer = watch(game, screen, "01-player-sendout", 900)
for _ = 1, 600 do
if screen.phase == "menu" then break end
tap(game, "a", 2)
end
---------------------------------------------------------- player switch
-- The same routine again, this time as a voluntary mid-battle switch.
local sawSwitch = false
if screen.phase == "menu" and (screen.battle.party or {})[2] then
screen:submit({ kind = "switch", index = 2 })
sawSwitch = watch(game, screen, "02-player-switch", 900)
for _ = 1, 600 do
if screen.phase == "menu" or screen.battle.over then break end
tap(game, "a", 2)
end
end
------------------------------------------------------ enemy replacement
local sawReplace = false
if #(screen.battle.enemyParty or {}) > 1 then
for _ = 1, 600 do
if screen.battle.over then break end
if screen.phase == "menu" then
local enemy = screen.battle.enemy
if enemy then enemy.hp = 1 end
screen:chooseMenu("fight")
U.wait(2)
screen:chooseMove(1)
break
end
tap(game, "a", 2)
end
sawReplace = watch(game, screen, "03-enemy-replacement", 900)
end
U.log(("[driver] player lead-off %s, player switch %s, enemy replacement %s")
:format(tostring(sawPlayer), tostring(sawSwitch), tostring(sawReplace)))
U.log("[driver] shots in " .. OUT)
U.log("[driver] every send-out of a shiny must flash, sparkle and chime")
U.log("[driver] BEFORE its cry, not only the wild mon at battle start.")
while true do
coroutine.yield()
end
end
+72
View File
@@ -0,0 +1,72 @@
-- #1441: the Magnet Train ride drew a flat white screen.
--
-- POKEPORT_GAME=gold POKEPORT_TOUCH=0 \
-- POKEPORT_DRIVER=tests/drivers/gold_bug1441_test.lua love .
-- POKEPORT_SHOT_DIR=/tmp/gold-bug1441 (default)
--
-- Nothing here is assertable: the bug IS what the screen shows. The ride
-- bakes its background out of data.gen2Field.magnetTrain, TILESET_TRAIN_
-- STATION's sheet and the TOWN palettes, and every one of those was read
-- under its Gen 1 key, so all four came back nil and GbcPalette's fallback
-- filled the panel with DMG_SHADES[1].
--
-- The run boots into the Goldenrod station, prints whether the four tables
-- are actually there, shoots the Saffron-bound ride from JUMPTABLE_INIT
-- through to the arrival, then plays the Goldenrod-bound one back at 1x with
-- no screenshots in the way -- a human watching the window sees the whole
-- animation, which is the only place this bug ever showed.
local U = require("tests.drivers.util")
local STATION = "GOLDENROD_MAGNET_TRAIN_STATION"
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-bug1441"
U.wait(45)
local world = game.world
assert(world and world.map, "gold world did not boot")
world:setMap(STATION, 11, 8, "up")
U.wait(10)
local data = game.data or {}
local field = data.gen2Field and data.gen2Field.magnetTrain
U.log("gen2Field.magnetTrain:", field and #field.bgTiles or "MISSING")
U.log("gen2Tilesets.TILESET_TRAIN_STATION:",
data.gen2Tilesets and data.gen2Tilesets.TILESET_TRAIN_STATION
and data.gen2Tilesets.TILESET_TRAIN_STATION.image or "MISSING")
U.log("gen2Palettes:", data.gen2Palettes and "ok" or "MISSING")
U.log("gen2Sprites.SPRITE_CHRIS:",
data.gen2Sprites and data.gen2Sprites.SPRITE_CHRIS and "ok" or "MISSING")
local done = false
world:magnetTrain(true, function() done = true end)
U.wait(2)
local ride = game.stack:top()
U.log("ride background:", ride and ride.background
and (ride:background() and "baked" or "nil canvas") or "no screen")
-- SFX_TRAIN_ARRIVED lands a good while in; shoot across the whole ride so
-- the departure, the three scrolling bands and the stop are all on disk.
for index = 0, 11 do
U.shot(game, ("%s/%02d-ride.png"):format(out, index))
U.wait(20)
if done then break end
end
for _ = 1, 600 do
if done then break end
U.wait(5)
end
U.log("ride finished at frame", U.frame())
U.log("shots in " .. out)
-- The return leg, played out in full with nothing else on screen: watch the
-- window, not the PNGs. The ride reads no input, so it ends on its own.
local back = false
world:magnetTrain(false, function() back = true end)
for _ = 1, 900 do
if back then break end
U.wait(1)
end
U.wait(60)
end
+57
View File
@@ -0,0 +1,57 @@
-- Eyeball driver (#1442): the radio card's tuning knob. PokegearRadio_Init
-- spawns SPRITE_ANIM_OBJ_RADIO_TUNING_KNOB on tile $08 and AnimateTuningKnob
-- writes wRadioTuningKnob into its XOFFSET, so a red needle stands in the dial
-- box at screen x = 72 + knob and steps with every UP/DOWN. Before the fix the
-- card drew the dial art and nothing in it.
--
-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold \
-- POKEPORT_SHOTS=/tmp/radioknob \
-- POKEPORT_DRIVER=tests/drivers/gold_radio_knob_bug1442.lua \
-- perl -e 'alarm 300; exec @ARGV' \
-- python3 -c "import pty; pty.spawn(['love','.'])"
--
-- The run parks on the radio card with the knob on 08.5, so UP and DOWN move
-- the needle by hand.
local U = require("tests.drivers.util")
local SHOTS = os.getenv("POKEPORT_SHOTS") or "/tmp/radioknob"
return function(game)
U.wait(45)
local world = game.world
assert(world and world.map, "gold world did not boot")
-- Goldenrod, where the card is handed out, and the two engine flags the
-- START menu and the strip read: ENGINE_POKEGEAR and ENGINE_RADIO_CARD.
assert(world:setMap("GOLDENROD_CITY", 12, 20, "down"),
"setMap failed for GOLDENROD_CITY")
world:setEngineFlag(4, true) -- ENGINE_POKEGEAR
world:setEngineFlag(0, true) -- ENGINE_RADIO_CARD
U.wait(5)
game:openStartMenuItem("pokegear")
U.wait(5)
local gear = game.stack:top()
assert(gear and gear.cards, "the POKeGEAR did not open")
gear.mode = "card"
for index, card in ipairs(gear.cards) do
if card.id == "radio" then gear.cardIndex = index end
end
assert(gear:card().id == "radio", "the RADIO card is missing from the strip")
U.wait(5)
-- 04.5, the bottom of the dial: the needle sits at the left of the box.
U.shot(game, SHOTS .. "/01-radio-04.5.png")
U.log("knob", tostring(gear:currentStation().knob), "at 04.5")
U.tap(game, "up") U.wait(4)
U.tap(game, "up") U.wait(4)
U.shot(game, SHOTS .. "/02-radio-08.5.png")
U.log("knob", tostring(gear:currentStation().knob), "at 08.5")
U.log("compare 01 and 02: a red needle stands in the dial box and has",
"moved right; UP/DOWN now walk it by hand")
while true do
coroutine.yield()
end
end
@@ -0,0 +1,89 @@
-- Driver: the starter Pikachu's battle entrance (#1429).
--
-- POKEPORT_DRIVER=tests/drivers/pikachu_entrance_bug1429_test.lua \
-- POKEPORT_VERSION=yellow POKEPORT_IDENTITY=bug1429 POKEPORT_TOUCH=0 \
-- SHOT_DIR=/tmp/shots love . (never under POKEPORT_SPEED: audio-timed)
--
-- SendOutMon branches on IsThisPartyMonStarterPikachu (pokeyellow
-- engine/battle/core.asm:1798-1819): the starter never gets POOF_ANIM or
-- AnimateSendingOutMon. It walks in instead --
-- StarterPikachuBattleEntranceAnimation (engine/battle/pikachu_entrance_anim.asm)
-- paints the back pic one column at a time from hlcoord 0,5, eight columns two
-- frames apart, and only then does PlayPikachuSoundClip voice it.
--
-- The run halts in the middle of the walk-in, then again on the finished pic.
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")
local BattleState = require("src.battle.BattleState")
local GameVersion = require("src.core.GameVersion")
local PF = require("src.world.PikachuFollower")
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
return ok
end
check("running as Yellow (needs POKEPORT_VERSION=yellow)",
GameVersion.isYellow())
game.save.player.name = "bryan"
local pika = Pokemon.new(game.data, "PIKACHU", 20)
BattleState.stampOT(game.save, pika)
game.save.party = { pika, Pokemon.new(game.data, "CHARMANDER", 20) }
check("the lead reads as the starter Pikachu",
PF.isStarterPikachu(game.save, pika))
U.teleport(game, "ROUTE_1", 5, 20, "down")
U.wait(20)
local ow = game.overworld
check("overworld is up to push the battle from", ow ~= nil)
local battle = BattleState.newWild(game, "PIDGEY", 5)
battle.onFinish = function() end
ow:pushBattle(battle)
local function tapUntil(cond, taps, gap)
for _ = 1, (taps or 90) do
if cond() then return true end
U.tap(game, "a")
for _ = 1, (gap or 4) do
if cond() then return true end
U.wait(1)
end
end
return cond()
end
-- Poll every frame: the slide is 16 frames long and the ball poof, if the
-- bug were back, would never set this slot at all.
local sliding = tapUntil(function()
return battle.picOff ~= nil and battle.picOff.playerMon ~= nil
end, 120, 2)
check("the send-out started the entrance slide, not the ball poof", sliding)
check("no grow-in is running alongside it", battle.growIn == nil)
if sliding then
U.log("slide x =", tostring(battle.picOff.playerMon.x))
check("mid-slide screenshot reached disk",
U.shot(game, DIR .. "/bug1429_pikachu_sliding.png"))
U.log("captured", DIR .. "/bug1429_pikachu_sliding.png")
for _ = 1, 40 do
if battle:picOffset("playerMon") == 0 then break end
U.wait(1)
end
check("the pic landed on its own column", battle:picOffset("playerMon") == 0)
check("landed screenshot reached disk",
U.shot(game, DIR .. "/bug1429_pikachu_landed.png"))
U.log("captured", DIR .. "/bug1429_pikachu_landed.png")
end
U.log("Pikachu should have walked in from the LEFT edge of the screen,")
U.log("column by column, with no ball, no poof and no grow-out-of-the-ball,")
U.log("and voiced its PCM clip only once it was fully drawn (#1429).")
while true do
coroutine.yield()
end
end
@@ -0,0 +1,114 @@
-- Driver: where the "Will PLAYER change POKéMON?" YES/NO box sits (#1398).
--
-- POKEPORT_DRIVER=tests/drivers/shift_prompt_box_bug1398_test.lua \
-- POKEPORT_IDENTITY=bug1398 POKEPORT_TOUCH=0 POKEPORT_VERSION=red \
-- SHOT_DIR=/tmp/shots love .
--
-- EnemySendOutFirstMon (engine/battle/core.asm:1378-1384) does NOT go through
-- InitYesNoTextBoxParameters for this one prompt: it inlines its own
-- TWO_OPTION_MENU at hlcoord 0,7, i.e. hard against the LEFT edge, while every
-- other YES/NO in the game sits at hlcoord 14,7 on the right. The port only
-- had the shared right-hand box, so the SHIFT offer came up on the wrong side.
--
-- The run stops with the prompt on screen and the controls in a human's hands.
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")
local BattleState = require("src.battle.BattleState")
local Theme = require("src.ui.Theme")
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
return ok
end
-- roster 1 is RATTATA 11 / EKANS 11: two mons, so the KO of the first one
-- reaches EnemySendOutFirstMon with a reserve to announce.
local OPP, ROSTER = "OPP_YOUNGSTER", 1
check("trainer class " .. OPP .. " is in the data",
game.data.trainers ~= nil and game.data.trainers[OPP] ~= nil)
check("Theme carries the left-edge box",
Theme.trainerSwitchBox ~= nil and Theme.trainerSwitchBox.tx == 0
and Theme.trainerSwitchBox.ty == 7)
-- SHIFT is what makes the prompt appear at all (the BIT_BATTLE_SHIFT test at
-- core.asm:1375-1377 skips it under SET).
game.save.options = game.save.options or {}
game.save.options.battleStyle = "shift"
game.save.player.name = "bryan"
game.save.party = {
Pokemon.new(game.data, "MEWTWO", 70),
Pokemon.new(game.data, "CHARIZARD", 50),
}
U.teleport(game, "PALLET_TOWN", 10, 8, "down")
U.wait(20)
local ow = game.overworld
check("overworld is up to push the battle from", ow ~= nil)
local ok, battle = pcall(BattleState.newTrainer, game, OPP, ROSTER)
check("trainer battle constructed", ok and battle ~= nil)
if not ok then
U.log("could not start", OPP, "->", tostring(battle))
while true do coroutine.yield() end
end
check("the foe has a reserve to send out", #battle.enemyParty >= 2)
battle.onFinish = function() end
ow:pushBattle(battle)
local function tapUntil(cond, taps, gap)
for _ = 1, (taps or 60) do
if cond() then return true end
U.tap(game, "a")
for _ = 1, (gap or 6) do
if cond() then return true end
U.wait(1)
end
end
return cond()
end
if not check("reached the FIGHT/PKMN/ITEM/RUN menu",
tapUntil(function() return battle.phase == "menu" end, 200)) then
U.log("phase is", tostring(battle.phase))
end
-- FIGHT, first move: L70 MEWTWO one-shots the L11 lead.
U.tap(game, "a")
U.wait(10)
U.tap(game, "a")
U.wait(10)
-- Stop the moment the YES/NO goes up over the still-visible prompt page.
local function choiceBox()
local top = game.stack:top()
return (top and top.tx and top.labels and top.labels[1] == "YES") and top
or nil
end
local reached = tapUntil(function() return choiceBox() ~= nil end, 200, 4)
if not check("the SHIFT prompt opened its YES/NO box", reached) then
U.log("phase", tostring(battle.phase),
"enemy hp", tostring(battle.enemy and battle.enemy.mon.hp),
"top", tostring(game.stack:top()))
end
local box = choiceBox()
if box then
check("the box is at hlcoord 0,7 (core.asm:1378-1384)",
box.tx == 0 and box.ty == 7)
U.log("box tx=" .. tostring(box.tx) .. " ty=" .. tostring(box.ty))
check("prompt screenshot reached disk",
U.shot(game, DIR .. "/bug1398_shift_prompt.png"))
U.log("captured", DIR .. "/bug1398_shift_prompt.png")
end
U.log("The foe's lead is down and the SHIFT offer is on screen.")
U.log("YES/NO must sit against the LEFT edge, over the text box's left half,")
U.log("not on the right where every other YES/NO in the game lives (#1398).")
while true do
coroutine.yield()
end
end
+73
View File
@@ -0,0 +1,73 @@
-- Driver: nobody is caught mid-stride while a text box is up (#1435).
-- UpdatePlayerSprite and UpdateNPCSprite both jump to their standing frame
-- while BIT_FONT_LOADED is set (engine/overworld/movement.asm:57, :139), so a
-- box that opens on a walk frame freezes a standing sprite, never a leg out.
--
-- POKEPORT_DRIVER=tests/drivers/talk_pose_bug1435_test.lua \
-- POKEPORT_IDENTITY=bug1435 POKEPORT_TOUCH=0 POKEPORT_VERSION=red \
-- SHOT_DIR=/tmp/shots love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local TextBox = require("src.render.TextBox")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
return ok
end
-- Mom stands at (5,4) in Red's house (pokered
-- data/maps/objects/RedsHouse1F.asm); park the player beside her and push
-- into her so the walk-in-place cycle is running when the box opens.
U.teleport(game, "REDS_HOUSE_1F", 6, 4, "down")
U.wait(20)
local ow = game.overworld
local p = ow.player
local walking = false
for _ = 1, 90 do
table.insert(game.input.pressQueue, "left")
game.input.state.left = true
coroutine.yield()
if p:walkPhase() == 1 then walking = true break end
end
game.input.state.left = false
check("bumping into MOM shows the walk frame", walking)
U.shot(game, DIR .. "/bug1435_1_midstride.png")
U.tap(game, "a")
U.wait(4)
check("talking to MOM opened a box over the overworld",
game.stack:top() ~= ow)
check("the walk clock is still mid-cycle underneath",
(p.bumpFrames or 0) > 0 or p.moving)
check("but the player is drawn standing", p:walkPhase() == 0)
U.shot(game, DIR .. "/bug1435_2_talking.png")
-- The same gate for an NPC caught mid-step: Pallet Town's wanderers walk
-- on their own, so wait for one and open a box while it is between cells.
U.teleport(game, "PALLET_TOWN", 8, 8, "down")
U.wait(30)
ow = game.overworld
local mover
for _ = 1, 900 do
for _, npc in ipairs(ow.npcs) do
if npc.moving and npc:walkPhase() == 1 then mover = npc break end
end
if mover then break end
coroutine.yield()
end
if check("caught a Pallet Town NPC mid-step", mover ~= nil) then
game.stack:push(TextBox.new(game, "TESTING THE POSE."))
U.wait(4)
check("the NPC is still mid-step underneath", mover.moving)
check("but it is drawn standing too", mover:walkPhase() == 0)
U.shot(game, DIR .. "/bug1435_3_npc.png")
end
U.log("Look at bug1435_2_talking.png and bug1435_3_npc.png: with the box")
U.log("up, both sprites stand square on their feet. A leg out, or the")
U.log("split-stride frame held for the whole conversation, is the bug.")
while true do coroutine.yield() end
end
+176
View File
@@ -0,0 +1,176 @@
-- Manual check for #1453: the SGB zone scissors must stay contiguous in
-- framebuffer pixels on a fractional-DPI surface (Android), where LOVE 11
-- truncates the scissor to whole units before scaling it. Zones are pokered
-- data/sgb/sgb_packets.asm BlkPacket_Titlescreen (rows 0-7 / 8-9 / 10-17).
-- POKEPORT_DRIVER=tests/drivers/title_seam_bug1453_test.lua POKEPORT_TOUCH=0 SHOT_DIR=/tmp/shots love .
-- PROBE_DPI picks the density to emulate (default 2.625, the reporter's).
-- Do not set POKEPORT_SPEED: fast-forward desynchronizes the title music.
return function(game)
local U = dofile("tests/drivers/util.lua")
local PaletteFX = require("src.render.PaletteFX")
local Renderer = require("src.render.Renderer")
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local DPI = tonumber(os.getenv("PROBE_DPI") or "") or 2.625
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
return ok
end
-- past the copyright splash / attract movie (engine/movie/splash.asm)
local title
for _ = 1, 120 do
local top = game.stack:top()
if top and top.screenId == "TitleState" and top.sgbPalettes then
title = top
break
end
U.tap(game, "start")
U.wait(9)
end
check("the title screen is on top", title ~= nil)
if not (title and title.sgbPalettes) then
U.log("No title state to look at; nothing below can run.")
while true do coroutine.yield() end
end
local opts = game.save.options
local mode = opts and opts.colors or PaletteFX.mode
if PaletteFX.mode ~= "gbc" then
U.log("COLORS is", tostring(mode) .. "; switching the view to SGB for the check")
PaletteFX.setMode("gbc")
U.wait(20)
end
local zones = PaletteFX.ensureZones(title:sgbPalettes(game))
check("the title builds three SGB zones", zones ~= nil and #zones == 3)
-- A canvas carries its own dpiscale, so the emulated Android surface goes
-- through the same LOVE scissor path the phone does, on this desktop.
local probe = love.graphics.newCanvas(math.floor(1920 / DPI),
math.floor(1080 / DPI),
{ dpiscale = DPI })
local PW, PH = probe:getPixelWidth(), probe:getPixelHeight()
local Sp = math.max(1, math.floor(math.min(PW / 160, PH / 144)))
local Ux, Uy = Sp / DPI, Sp / DPI
local uox = math.floor((PW - 160 * Sp) / 2) / DPI
local uoy = math.floor((PH - 144 * Sp) / 2) / DPI
local uvpw, uvph = 160 * Ux, 144 * Uy
U.log(("emulating %dx%d px at dpi %.4f, fit scale %d"):format(PW, PH, DPI, Sp))
-- what the pre-fix Renderer handed LOVE: fractional units with a half
-- framebuffer pixel of bias, which LOVE 11 truncates away
local function scissorOld(x, y, w, h)
local x2, y2 = math.min(x + w, uox + uvpw), math.min(y + h, uoy + uvph)
x, y = math.max(x, uox), math.max(y, uoy)
if x2 <= x or y2 <= y then return false end
local px1, py1 = math.floor(x * DPI), math.floor(y * DPI)
local px2, py2 = math.ceil(x2 * DPI), math.ceil(y2 * DPI)
love.graphics.setScissor((px1 + 0.5) / DPI, (py1 + 0.5) / DPI,
(px2 - px1 + 0.5) / DPI, (py2 - py1 + 0.5) / DPI)
return true
end
local function renderProbe(old)
love.graphics.setCanvas(probe)
love.graphics.clear(0, 0, 0, 1)
love.graphics.setColor(1, 1, 1, 1)
if old then
local shader = PaletteFX.shader()
love.graphics.setShader(shader)
for _, z in ipairs(zones) do
PaletteFX.sendColors(shader, z.colors)
if scissorOld(uox + z.x * Ux, uoy + z.y * Uy, z.w * Ux, z.h * Uy) then
love.graphics.draw(Renderer.canvas, uox, uoy, 0, Ux, Uy)
end
end
love.graphics.setScissor()
love.graphics.setShader()
else
Renderer:blitCanvas(Renderer.canvas, Ux, Uy, zones, Ux, Uy,
uox, uoy, uox, uoy, uvpw, uvph, DPI, DPI)
end
love.graphics.setCanvas()
return probe:newImageData()
end
local top = math.floor(uoy * DPI)
local bottom = math.floor((uoy + uvph) * DPI)
local col = math.floor(uox * DPI) + 2
local function blackRows(data)
local rows = {}
for py = top, math.min(bottom - 1, data:getHeight() - 1) do
local r, g, b = data:getPixel(col, py)
if r < 0.02 and g < 0.02 and b < 0.02 then rows[#rows + 1] = py end
end
return rows
end
local dataNew = renderProbe(false)
local dataOld = renderProbe(true)
local rowsNew, rowsOld = blackRows(dataNew), blackRows(dataOld)
U.log(("picture rows %d-%d, sampling the background column at x=%d")
:format(top, bottom - 1, col))
U.log("pre-fix math left", #rowsOld, "black rows:",
#rowsOld > 0 and table.concat(rowsOld, ",") or "none")
check("no letterbox row shows through the zone boundaries", #rowsNew == 0)
check("the emulated surface reproduces the seam without the fix", #rowsOld > 0)
local ZOOM, CW, CH = 4, 220, 28
local cy = math.max(top, math.floor((uoy + 64 * Uy) * DPI) - math.floor(CH / 2))
local cx = math.floor(uox * DPI)
local function zoomOf(data)
local out = love.image.newImageData(CW * ZOOM, CH * ZOOM)
out:mapPixel(function(x, y)
return data:getPixel(cx + math.floor(x / ZOOM), cy + math.floor(y / ZOOM))
end)
return out
end
local function writePng(imageData, path)
local dir = path:match("^(.*)[/\\][^/\\]+$")
if dir and dir ~= "" then os.execute('mkdir -p "' .. dir .. '" 2>/dev/null') end
local f = io.open(path, "wb")
if not f then return false end
f:write(imageData:encode("png"):getString())
f:close()
return true
end
local zoomOld, zoomNew = zoomOf(dataOld), zoomOf(dataNew)
check("wrote " .. SHOT_DIR .. "/bug1453_before.png",
writePng(zoomOld, SHOT_DIR .. "/bug1453_before.png"))
check("wrote " .. SHOT_DIR .. "/bug1453_after.png",
writePng(zoomNew, SHOT_DIR .. "/bug1453_after.png"))
local imgOld = love.graphics.newImage(zoomOld)
local imgNew = love.graphics.newImage(zoomNew)
local baseDraw = love.draw
love.draw = function()
baseDraw()
local w, h = love.graphics.getDimensions()
local s = math.min((w - 24) / imgOld:getWidth(),
(h * 0.5 - 48) / (imgOld:getHeight() * 2))
local bw = imgOld:getWidth() * s + 16
local bh = imgOld:getHeight() * 2 * s + 56
local bx, by = (w - bw) / 2, h - bh - 8
love.graphics.setColor(0, 0, 0, 0.75)
love.graphics.rectangle("fill", bx, by, bw, bh)
love.graphics.setColor(1, 1, 1, 1)
love.graphics.print("BEFORE (dpi " .. DPI .. ")", bx + 8, by + 4)
love.graphics.draw(imgOld, bx + 8, by + 20, 0, s, s)
love.graphics.print("AFTER", bx + 8, by + 24 + imgOld:getHeight() * s)
love.graphics.draw(imgNew, bx + 8, by + 40 + imgOld:getHeight() * s, 0, s, s)
end
PaletteFX.setMode(mode)
U.wait(10)
U.shot(game, SHOT_DIR .. "/bug1453_title.png")
U.log("captured", SHOT_DIR .. "/bug1453_title.png")
U.log("The title is live and the pad is yours; the strip along the bottom is")
U.log("the row-64 zone boundary of an emulated " .. DPI .. "x Android surface,")
U.log("magnified " .. ZOOM .. "x: BEFORE carries the black hairline across the")
U.log("picture, AFTER is unbroken off-white. PROBE_DPI=2.75 tries another one.")
while true do
coroutine.yield()
end
end
@@ -0,0 +1,72 @@
-- Driver: the TOWN MAP player marker wears the OBJ palette (#1437).
-- LoadPlayerSpriteGraphics hands the town map the same walking sheet the
-- overworld draws (engine/items/town_map.asm:342), so the marker has to go
-- through the OBJ ramp and the shade-0 keying every other sprite gets --
-- green on Red, pink on Blue, never a raw white block on the BG ramp.
--
-- POKEPORT_DRIVER=tests/drivers/town_map_marker_bug1437_test.lua \
-- POKEPORT_IDENTITY=bug1437 POKEPORT_TOUCH=0 POKEPORT_VERSION=red \
-- SHOT_DIR=/tmp/shots love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local Screens = require("src.ui.Screens")
local PaletteFX = require("src.render.PaletteFX")
local SpriteRenderer = require("src.render.SpriteRenderer")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
return ok
end
game.save.player.name = "bryan"
game.save.visited = {
PALLET_TOWN = true, VIRIDIAN_CITY = true, PEWTER_CITY = true,
CERULEAN_CITY = true, CELADON_CITY = true,
}
U.teleport(game, "PALLET_TOWN", 10, 8, "down")
U.wait(10)
local function openMap(mode, tag)
game.save.options = game.save.options or {}
game.save.options.colors = mode
PaletteFX.setMode(mode)
U.wait(4)
Screens.push(game, "TownMap")
U.wait(20)
local top = game.stack:top()
check(tag .. ": the TOWN MAP is up", top and top.playerQuad ~= nil)
-- the marker must be a baked OBJ image, not a fresh newImage of the sheet
local sprites = game.data.sprites or {}
local red = sprites.SPRITE_RED
local colors, group
if PaletteFX.usesSpriteObp() then
colors, group = PaletteFX.ogObj()
else
colors, group = PaletteFX.dmgObj()
end
local want = red and SpriteRenderer.obpImage(red.image, colors, group)
check(tag .. ": the marker is the OBP-baked sheet",
top and want ~= nil and top.playerSheet == want)
-- hold the shot on a frame the blink is showing the marker
for _ = 1, 40 do
if top.blink < 16 then break end
coroutine.yield()
end
U.shot(game, DIR .. "/bug1437_" .. tag .. ".png")
U.tap(game, "b")
U.wait(10)
end
openMap("ogred", "ogred")
openMap("gbc", "gbc")
openMap("og", "og")
U.log("Open the shots: in OG RED the marker over PALLET TOWN is the green")
U.log("boot-ROM trainer, in the other modes it is coloured by the map zone")
U.log("like any overworld sprite. A white box, or a red/black marker on")
U.log("the BG ramp, is the bug. The pad is yours -- the TOWN MAP is one")
U.log("B away and the ITEM bag reopens it.")
while true do coroutine.yield() end
end
@@ -47,12 +47,32 @@ do
T.eq(feet.tile, (0x80 - 6 * 2) - 49, "2ROW base")
end
-- engine/battle_anims/anim_commands.asm:317
do
local runner = AnimRunner.new({})
runner:start(nil)
AnimRunner.COMMANDS.battlergfx_1row(runner)
local head = findLoaded(runner, "BATTLE_ANIM_GFX_PLAYERHEAD")
local feet = findLoaded(runner, "BATTLE_ANIM_GFX_ENEMYFEET")
T.eq(head and head.battler, "enemy", "the script command routes the same way")
T.eq(head.rows, 2, "$da's macro says 1row, its jumptable slot says _2Row")
T.eq(head.tiles, 14, "two enemy rows")
T.eq(head.tile, (0x80 - 6 * 2 - 7 * 2) - 49, "at the _2Row base")
T.eq(feet.tiles, 12, "two player rows")
T.eq(feet.tile, (0x80 - 6 * 2) - 49, "at the _2Row base")
end
do
local runner = AnimRunner.new({})
runner:start(nil)
AnimRunner.COMMANDS.battlergfx_2row(runner)
local head = findLoaded(runner, "BATTLE_ANIM_GFX_PLAYERHEAD")
local feet = findLoaded(runner, "BATTLE_ANIM_GFX_ENEMYFEET")
T.eq(head.rows, 1, "$d9's macro says 2row, its jumptable slot says _1Row")
T.eq(head.tiles, 7, "one enemy row")
T.eq(head.tile, (0x80 - 6 - 7) - 49, "at the _1Row base")
T.eq(feet.tiles, 6, "one player row")
T.eq(feet.tile, (0x80 - 6) - 49, "at the _1Row base")
end
T.finish("gen2 battler gfx row attribution bug 1231")
+2 -2
View File
@@ -151,8 +151,8 @@ do
local fresh = { mods = {} }
SaveData.setModEnabled(fresh, "b", true, "gold")
eq(fresh.modsByVersion.gold.b, nil,
"no shared flag reads as enabled, so agreeing with it stores nothing")
eq(fresh.modsByVersion.gold.b, true,
"with no shared flag the answer is stored, not dropped against an assumed default")
end
do
+2 -2
View File
@@ -357,8 +357,8 @@ local options = OptionsMenu.new(optionsGame, {
options = Save.defaultOptions(),
})
-- The cart's seven rows, then the port's: CONTROLS, audio, speed, display,
-- video mode, the mobile-gated touch three (buildRows) and CANCEL.
check("twenty-one rows", #OptionsMenu.ROWS, 21)
-- video mode, the mobile-gated touch three (buildRows), MAX FPS and CANCEL.
check("twenty-two rows", #OptionsMenu.ROWS, 22)
check("the cart's rows come first", OptionsMenu.ROWS[7].key, "frame")
check("then the rebind screen", OptionsMenu.ROWS[8].id, "controls")
check("then the port's audio group", OptionsMenu.ROWS[9].key, "musicVol")
+1 -1
View File
@@ -42464,7 +42464,7 @@
"after": "_MtMoonB2FSuperNerdTheresAPokemonLabText",
"battle": "_MtMoonB2FSuperNerdTheyreBothMineText",
"event": "EVENT_BEAT_MT_MOON_3_SUPER_NERD",
"won": "_MtMoonB2fSuperNerdEachTakeOneText"
"won": "_MtMoonB2FSuperNerdOkIllShareText"
},
"2": {
"after": "_MtMoonB2FRocket1AfterBattleText",
+1 -1
View File
@@ -42441,7 +42441,7 @@
"after": "_MtMoonB2FSuperNerdTheresAPokemonLabText",
"battle": "_MtMoonB2FSuperNerdTheyreBothMineText",
"event": "EVENT_BEAT_MT_MOON_3_SUPER_NERD",
"won": "_MtMoonB2fSuperNerdEachTakeOneText"
"won": "_MtMoonB2FSuperNerdOkIllShareText"
},
"2": {
"after": "_MtMoonB2FRocket1AfterBattleText",