CLOSES #644, CLOSES #703, CLOSES #726, CLOSES #737, CLOSES #750, CLOSES #752, CLOSES #764, CLOSES #765, CLOSES #768, CLOSES #773, CLOSES #774, CLOSES #775, CLOSES #777, CLOSES #780, CLOSES #782

This commit is contained in:
bryanthaboi
2026-08-04 09:25:28 -04:00
parent 6cfc85ca83
commit f2d9b74490
52 changed files with 3099 additions and 252 deletions
+12 -2
View File
@@ -49,7 +49,7 @@ local function gift(opts)
end
end)
end
if opts.pre then say(opts.pre, "", give) else give() end
if opts.pre then say(opts.pre, opts.preFallback or "", give) else give() end
end
end
@@ -118,11 +118,21 @@ M.CINNABAR_LAB_METRONOME_ROOM = {
},
}
-- TM42 Dream Eater (scripts/ViridianCity.asm, the fisher; no pre text)
-- TM42 Dream Eater (scripts/ViridianCity.asm, the fisher). The fisher's
-- YouCanHaveThisText prints before GiveItem, so this gift needs a pre
-- text (#775). Like the SilphCo2F worker (#393) that label carries no
-- leading underscore, and on Red it sits outside the extractor's symbol
-- set, so the literal from text/ViridianCity.asm rides along as the
-- fallback; Yellow resolves the ROM string instead.
M.VIRIDIAN_CITY = {
talk = {
TEXT_VIRIDIANCITY_FISHER = gift({
flag = "EVENT_GOT_TM42", item = "TM_DREAM_EATER",
pre = "ViridianCityFisherYouCanHaveThisText",
preFallback = "Yawn!\nI must have dozed\voff in the sun."
.. "\fI had this dream\nabout a DROWZEE\veating my dream."
.. "\vWhat's this?\vWhere did this TM\vcome from?"
.. "\fThis is spooky!\nHere, you can\vhave this TM.",
received = "_ViridianCityFisherReceivedTM42Text",
explain = "_ViridianCityFisherTM42ExplanationText",
noRoom = "_ViridianCityFisherTM42NoRoomText",
+7 -5
View File
@@ -195,11 +195,13 @@ through `src/import/SaveFileIO.lua`, which sits on top of
(checked against `listSlots`). `SaveFileIO.exportActiveSlot` loads the active
slot, encodes it back with `SaveConvert.exportSav` (a slot never keeps
`rawImport`, so this is a zero-filled template export, which is valid), and
writes `exports/<version>/gen1recomp-<version>-<slotId>.sav` in the save
directory (`exports/` and `exports/<version>/` are created as needed). On
desktop it returns the absolute path (`love.filesystem.getSaveDirectory()`),
which the notice line shows with an "Open folder" affordance
(`love.system.openURL("file://" .. dir)`).
writes `exports/<version>/gen1recomp-<version>-<slotId>.sav` under the same
root `persistFs` writes slots to: the portable game folder when `portable.txt`
marks the install, otherwise the save directory (`exports/` and
`exports/<version>/` are created as needed; #752). On desktop it returns the
absolute path (`SaveData.portableBaseDir()` when portable, else
`love.filesystem.getSaveDirectory()`), which the notice line shows with an
"Open folder" affordance (`love.system.openURL("file://" .. dir)`).
On Android the bytes are also staged as `pending_export.sav` and
`love.system.createFile(suggestedName)` opens `ACTION_CREATE_DOCUMENT` so the
player can save to Downloads / Drive / etc.; on return `export_done.flag`
+11 -6
View File
@@ -212,14 +212,19 @@ pack_game_love() {
tools/rom_manifest_yellow.json \
-x '*.DS_Store' -x '*/.git/*' -x '*/.DS_Store' \
-x 'data/generated/*' -x 'assets/generated/*')
if unzip -Z1 "$LOVE_FILE" \
| grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/'; then
fail "game.love unexpectedly contains generated ROM data"
fi
# Do not pipe unzip straight into grep here: on a large archive grep can
# finish early and make unzip report SIGPIPE under `set -o pipefail`.
# List once and match against the captured text: piping unzip straight into
# grep under `set -o pipefail` SIGPIPEs unzip as soon as grep exits early,
# and the pipeline's 141 outranks grep's own status. For the generated-data
# guard that inverted the test -- an archive that really did carry generated
# ROM data made grep match, killed unzip, and the `if` read the 141 as "no
# match" and let the build through (#774). Same listing feeds the
# required-file gates below, as in scripts/build.sh and scripts/pack_love.sh.
local archive_entries
archive_entries="$(unzip -Z1 "$LOVE_FILE")"
if grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/' \
<<< "$archive_entries"; then
fail "game.love unexpectedly contains generated ROM data"
fi
grep -qx 'tools/save-editor/App.lua' <<< "$archive_entries" \
|| fail "game.love is missing the save editor (Edit on a save row would crash)"
grep -qx "$YELLOW_MANIFEST_RELATIVE" <<< "$archive_entries" \
+85 -14
View File
@@ -528,6 +528,10 @@ BattleState.StatBox = StatBox -- the level-up stat window (PrintStatsBox)
local function newBattle(game)
local self = setmetatable({}, BattleState)
self.game = game
-- InitBattleVariables (engine/battle/init_battle_variables.asm) zeroes
-- wPartyAndBillsPCSavedMenuItem, so entering a battle drops the party
-- cursor the field menu has been carrying (src/ui/PartyMenu.lua). #768
game.partyMenuSavedIndex = nil
self.data = game.data
-- ruleset from the merged registry (the requires above are the same
-- records on a mod-free boot); an unknown save value falls back to the
@@ -644,6 +648,9 @@ function BattleState.newTrainer(game, oppClass, partyIndex)
local self = newBattle(game)
self.kind = "trainer"
self.oppClass = oppClass
-- the object_event trainer arg (roster index). computeMusicKind keys
-- data/scripts/victories.lua on class#party, so keep it on the battle (#782).
self.partyIndex = partyIndex or 1
self.trainer = game.data.trainers[oppClass]
assert(self.trainer, "unknown trainer class " .. tostring(oppClass))
-- pret GetTrainerName_: RIVAL1/2/3 copy wRivalName into wTrainerName
@@ -824,6 +831,16 @@ function BattleState:say(text)
table.insert(self.queue, { text = text })
end
-- A message whose ROM tail is `text_end` / `done` rather than `prompt`:
-- NextTextCommand returns straight out of PrintText on TX_END
-- (home/text.asm:328-334) and only TX_PROMPT_BUTTON blinks the arrow and
-- runs ManualTextScroll (home/text.asm:434-446), so these pages never wait
-- on the player. autoDelay is the frame hold before the queue moves on
-- (0 = the next row starts immediately, as PrintText returning does) (#765).
function BattleState:sayAuto(text, delay)
table.insert(self.queue, { text = text, auto = true, autoDelay = delay or 0 })
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)
@@ -864,6 +881,13 @@ function BattleState:sayNext(text)
table.insert(self.queue, self.nextInsert, { text = text })
end
-- sayNext for a page that ends in `text_end` (see sayAuto) (#765)
function BattleState:sayNextAuto(text, delay)
self.nextInsert = (self.nextInsert or 0) + 1
table.insert(self.queue, self.nextInsert,
{ text = text, auto = true, autoDelay = delay or 0 })
end
-- insert a UI push right after the current queue item (dex page, the
-- level-up stat box -- anything that must keep queue order)
function BattleState:uiNext(factory)
@@ -1014,6 +1038,8 @@ function BattleState:startMessage(item)
self.charIndex = 0
self.msgWaiting = nil
self.msgPrompt = nil
self.msgAutoWait = nil
self.msgHold = nil
self.scrollPx = nil
self:beginMsgLine()
end
@@ -1268,7 +1294,25 @@ function BattleState:updateQueue()
end))
return true
end
if not (item and item.choice) then
if item and item.auto then
-- No prompt: this page's ROM tail is `text_end`, so PrintText is
-- already back with the box still on screen -- pokered's used-move
-- line (engine/battle/used_move_text.asm EndUsedMove1Text..
-- EndUsedMove5Text) and the item-use line (ItemUseText00,
-- engine/items/item_effects.asm) are both of that kind. Only
-- TX_PROMPT_BUTTON waits on A/B (home/text.asm:434-446) (#765).
self.msgAutoWait = self.msgAutoWait or item.autoDelay or 0
if self.msgAutoWait > 0 then
self.msgAutoWait = self.msgAutoWait - 1
else
self.msgAutoWait = nil
-- the typed page stays drawn behind whatever runs next (the move
-- animation, the ball toss): PrintText leaves the textbox tilemap
-- alone and animations only touch sprites (#296)
self.msgHold = true
self.current = nil
end
elseif not (item and item.choice) then
-- The page is typed out and waiting on the player: PromptText
-- (home/text.asm:209-217) writes '▼' at (18,16) and ManualTextScroll
-- blinks it until A/B, so the arrow belongs on a finished page and not
@@ -1320,14 +1364,17 @@ end
-- gets the final-battle theme
function BattleState:computeMusicKind()
local isBoss = false
if self.kind == "trainer" and self.trainer then
if self.kind == "trainer" and self.oppClass then
-- wGymLeaderNo is written only by the eight gym scripts
-- (scripts/PewterGym.asm .. ViridianGym.asm), so the badge rosters in
-- victories.lua are exactly the fights that set it. The lookup must
-- include the party index: a class-wide prefix match also caught
-- Giovanni's Rocket Hideout (#1) and Silph Co (#2) battles, which never
-- touch wGymLeaderNo and take MUSIC_TRAINER_BATTLE like any other
-- trainer (#782).
local victories = require("data.scripts.victories")
for key, reward in pairs(victories) do
if reward.badge and key:find(self.trainer.id .. "#", 1, true) == 1 then
isBoss = true
break
end
end
local reward = victories[self.oppClass .. "#" .. tostring(self.partyIndex or 1)]
isBoss = reward ~= nil and reward.badge ~= nil
end
-- init_battle.asm: challenging a gym leader (wGymLeaderNo, the badge
-- fights only -- not Lance or the Champion) bumps the companion's
@@ -1575,6 +1622,9 @@ end
-- (end_of_battle.asm clears wLowHealthAlarm when a battle ends)
function BattleState:exit()
require("src.core.Sound").stopLoop("Low_Health_Alarm")
-- end_of_battle.asm clears wPartyAndBillsPCSavedMenuItem as well, so the
-- field party menu comes back on slot 1 after a battle. #768
self.game.partyMenuSavedIndex = nil
-- Free this battle's own GPU objects now rather than waiting on a GC
-- finalizer: the two full-screen wavy-effect canvases (colorMode) and
-- the AnimPlayer's per-instance tilesheet images/quads. The shared
@@ -1601,6 +1651,19 @@ local function clearTrapping(battler)
battler.trapDamage = nil
end
-- SendOutMon (core.asm:1733-1735) clears both battle cursors, though the
-- disassembly only names one of them: `ld hl, wBattleAndStartSavedMenuItem /
-- ld [hli], a / ld [hl], a` writes zero to that byte AND to the byte behind
-- it, which is wPlayerMoveListIndex (wram.asm:242-244). So every player
-- send-out puts the main menu back on FIGHT and the move list back on the
-- first slot; the cursors are only remembered across sub-menus of the mon
-- that is already out (#737). Enemy send-outs run EnemySendOutFirstMon,
-- not SendOutMon, and leave both alone.
local function sendOutMonCursors(self)
self.menuIndex = 1
self.moveIndex = 1
end
-- core.asm:297-300: both sides' FLINCHED bits are cleared as a turn's move
-- selection opens, but the clear is skipped for a mon that must recharge or
-- is locked into Rage (core.asm:293-295 -- the Hyper Beam flinch-recharge
@@ -2084,7 +2147,7 @@ function BattleState:oldManThrow()
self.phase = "messages"
self.afterQueue = "finish"
self.result = "run" -- nothing is kept; wBattleResult only ends the demo
self:say(Strings("%s used\nPOKé BALL!", self.demoName or "OLD MAN"))
self:sayAuto(Strings("%s used\nPOKé BALL!", self.demoName or "OLD MAN"))
self:act(function()
require("src.core.Sound").play(self.data, "Ball_Toss")
-- ItemUseBall's beat before the toss chain (like throwBall)
@@ -2278,6 +2341,7 @@ function BattleState:resolveSwitch(newMon)
previous = previous,
})
self:markParticipant()
sendOutMonCursors(self)
self.sendingOut = true
self:sayNext(self:sendOutText(self.player.name))
self:animNext("POOF_ANIM", false)
@@ -3252,9 +3316,13 @@ end
-- damaging pipeline (EffectRegistry.runDamaging).
-- Gen 1 status/stat primary effects call PlayCurrentMoveAnimation only
-- after they land; these failure texts print with no animation.
-- after they land; these failure texts print with no animation. Failures
-- whose text is an ordinary sentence rather than one of the shared fail
-- lines set msgs.failed instead of relying on this sniffer -- Substitute's
-- two failure lines name the move, not the failure (#644).
local function primaryEffectFailed(msgs)
if not msgs or #msgs == 0 then return true end
if msgs.failed then return true end
local m = msgs[1]
if m == "But, it failed!" or m == "Nothing happened!" then return true end
if m:find("didn't affect", 1, true) then return true end
@@ -3300,7 +3368,7 @@ function BattleState:performMove(user, target, moveInst, isCalled)
self.moveAnimRow = nil
if not (user.thrashTurns and moveInst == user.thrashMove and user.thrashAnnounced) then
self:sayNext(self:romText("_ItemUseText001", "%s\nused %s!", displayName(user), move.name))
self:sayNextAuto(self:romText("_ItemUseText001", "%s\nused %s!", displayName(user), move.name))
-- the move's animation plays right after the announcement; the
-- damage path attaches the target's hit blink to this row so the
-- blink follows the animation (pokered's order). Mimic is the
@@ -3796,6 +3864,7 @@ function BattleState:enemyMonFainted()
self.participants = {}
self:markParticipant()
self.nextInsert = 0
sendOutMonCursors(self)
self.sendingOut = true
self:sayNext(self:sendOutText(self.player.name))
self:animNext("POOF_ANIM", false)
@@ -3988,6 +4057,7 @@ function BattleState:openReplacementMenu()
})
self:markParticipant()
self.nextInsert = 0
sendOutMonCursors(self)
self.sendingOut = true
self:sayNext(self:sendOutText(self.player.name))
self:animNext("POOF_ANIM", false)
@@ -4026,7 +4096,7 @@ function BattleState:safariAction(choice)
if choice == "ball" then
st.balls = st.balls - 1
self:say(Strings("%s used\nSAFARI BALL!", playerName))
self:sayAuto(Strings("%s used\nSAFARI BALL!", playerName))
self:act(function()
require("src.core.Sound").play(self.data, "Ball_Toss")
self.lastBall = "SAFARI_BALL"
@@ -4348,7 +4418,7 @@ function BattleState:throwBall(ball)
-- "<PLAYER> used <ITEM>!" line (#291). Safari and the old man demo are
-- still wIsInBattle == 1, and this port models both as kind == "wild".
if self.kind == "wild" then
self:say(self:romText("_ItemUseText001", "%s used\n%s!", self.game.save.player.name,
self:sayAuto(self:romText("_ItemUseText001", "%s used\n%s!", self.game.save.player.name,
self.data.items[ball].name))
end
self:act(function()
@@ -5348,7 +5418,8 @@ end
function BattleState:drawTextArea()
Font.drawBox(0, 12, 20, 6)
love.graphics.setColor(0, 0, 0, 1)
if self.phase == "messages" and (self.current or self.animPlaying) then
if self.phase == "messages"
and (self.current or self.animPlaying or self.msgHold) then
-- during the move animation self.current is nil but shown still holds
-- the "used X!" lines; keep drawing them like pokered, whose move
-- animations only touch sprites and never the textbox tilemap (#296)
+16 -2
View File
@@ -259,15 +259,29 @@ MoveEffects.primary = {
return { romText(battle.data, "_StatusChangesEliminatedText", "All STATUS changes\nare eliminated!") }
end,
-- substitute.asm reaches its PlayCurrentMoveAnimation / AnimationSubstitute
-- Bankswitch only inside the success branch, after `set HAS_SUBSTITUTE_UP`;
-- .alreadyHasSubstitute and .notEnoughHP fall straight through to PrintText,
-- so both failures print with no animation at all. That is load bearing
-- here: the SUBSTITUTE animation opens with SE_SLIDE_MON_OFF, which leaves
-- the user's pic hidden (BattleState.lua slideOff end state) until the doll
-- is drawn in its place -- and with no substituteHP raised there is no doll,
-- so a failed Substitute used to erase the user's sprite for the rest of the
-- battle (#644). The failed flag rides the message list so performMove can
-- peel the announcement-time anim row without matching on printed text.
SUBSTITUTE_EFFECT = function(battle, user)
if user.substituteHP then return { romText(battle.data, "_HasSubstituteText", "%s\nhas a SUBSTITUTE!", displayName(user)) } end
if user.substituteHP then
return { romText(battle.data, "_HasSubstituteText", "%s\nhas a SUBSTITUTE!", displayName(user)),
failed = true }
end
local cost = math.floor(user.mon.stats.hp / 4)
-- substitute.asm only fails on subtraction underflow (current HP
-- strictly below maxHP/4); at equality the substitute is built and
-- the user is left standing on exactly 0 HP (it faints only when
-- the engine next checks HP, not here)
if user.mon.hp < cost then
return { romText(battle.data, "_TooWeakSubstituteText", "Too weak to make\na SUBSTITUTE!") }
return { romText(battle.data, "_TooWeakSubstituteText", "Too weak to make\na SUBSTITUTE!"),
failed = true }
end
user.mon.hp = user.mon.hp - cost
user.substituteHP = cost + 1
+15
View File
@@ -279,6 +279,21 @@ function Game.worldBgBattleDim(stack)
return nil
end
-- Is a BATTLE BG "world" battle composing itself over the live map right now?
-- Same whole-stack walk as worldBgBattleDim, asked for a different reason: the
-- dark-cave shade shift (wMapPalOffset) must not reach a frame a battle is
-- drawing in. InitBattleCommon (engine/battle/core.asm) pushes wMapPalOffset,
-- InitBattleVariables (engine/battle/init_battle_variables.asm) writes 0 over
-- it and core.asm pops it back when the battle ends, so a battle in an
-- un-flashed Rock Tunnel is lit on hardware. Every other BATTLE BG gets that
-- for free -- no map draws beneath an opaque battle, so nothing re-arms the
-- per-frame shade map -- but "world" keeps the overworld drawing underneath,
-- and its arming then darkened the battle's own pics, HUD and text at colorize
-- time (#773).
function Game.worldBgBattleInStack(stack)
return Game.worldBgBattleDim(stack) ~= nil
end
-- Does anything on the stack want the surface scaled to FILL the window
-- (aspect preserved, bars on the long axis) rather than sit at the fixed
-- integer scale?
+10 -8
View File
@@ -218,14 +218,13 @@ function LauncherView.update(imp, dt)
if not imp._flex then return end
FlexLove.update(dt)
-- Drain the action queue OUTSIDE FlexLove's dispatch, so an action is free
-- to destroy the view (Play/Edit) or block in a native picker.
-- to destroy the view (Play/Edit) or block in a native picker. The batch
-- is resolved by RomImporter:runActions so the drop/disarm rules are
-- testable without a live FlexLove tree (#780).
local queue = imp._uiActions
if queue and #queue > 0 then
imp._uiActions = {}
for _, fn in ipairs(queue) do
local ok, err = pcall(fn)
if not ok then print("launcher action error: " .. tostring(err)) end
end
imp:runActions(queue)
end
end
@@ -292,9 +291,12 @@ local function queueAction(imp, key, fn, keepArm)
if last and now - last < ACT_DEDUP then return end
imp._actAt[key] = now
-- Any press that is not a Delete's own second click disarms the pending
-- delete confirm (#433's rule, preserved from the hit-rect launcher).
if not keepArm then imp._confirmDelete = nil end
imp._uiActions[#imp._uiActions + 1] = fn
-- delete confirm (#433's rule, preserved from the hit-rect launcher). The
-- disarm itself is applied by RomImporter:runActions when the batch drains,
-- not here: one touch lands on a row AND on the chip inside it, and
-- clearing the arm as the row queued left Delete stuck on its first press
-- (#780).
imp._uiActions[#imp._uiActions + 1] = { key = key, fn = fn, keepArm = keepArm }
end
local function handler(imp, key, action, keepArm)
+95 -1
View File
@@ -1639,9 +1639,100 @@ function RomExtractor:raw1bpp(label, width, height, relative, transparent)
return image
end
-- Trading animation art: gfx/trade.asm TradingAnimationGraphics is one
-- 49-tile atlas (game_boy.2bpp, built with --remove-duplicates, then
-- link_cable.2bpp), and the Game Boy and open-cable plates are painted out
-- of it through the tilemaps in data/tilemaps.asm (GameBoyTiles 6x8,
-- LinkCableTiles 12x3), whose ids are absolute vChars2 ids starting at $31
-- because trade.asm reaches them through
-- CopyTileIDsFromList_ZeroBaseTileID. Only the developer-only Python path
-- ever wrote these files, so an imported cache had none of them and
-- TradeAnim drew the whole cinematic as plain rectangles (#750).
function RomExtractor:extractTradeArt()
local BASE, COUNT = 0x31, 49
local gfx = self:symbol("TradingAnimationGraphics")
local atlas = ImageWriter.decode2bpp(
self.rom:bytes(gfx.bank, gfx.address, COUNT * 16), COUNT * 8, 8)
local function tileX(id)
local index = id - BASE
assert(index >= 0 and index < COUNT,
("trade tile $%02X is outside the animation atlas"):format(id))
return index * 8
end
local function plate(label, tilesWide, tilesHigh, relative, matte)
local map = self:symbol(label)
local ids = self.rom:bytes(map.bank, map.address, tilesWide * tilesHigh)
local image = ImageWriter.blank(tilesWide * 8, tilesHigh * 8, 1, 1, 1, 1)
for index, id in ipairs(ids) do
ImageWriter.blit(image, atlas,
(index - 1) % tilesWide * 8,
math.floor((index - 1) / tilesWide) * 8, tileX(id), 0, 8, 8)
end
if matte then image = ImageWriter.matteColor0(image) end
self:save(image, relative)
end
plate("GameBoyTiles", 6, 8, "trade/game_boy.png", true)
plate("LinkCableTiles", 12, 3, "trade/open_cable.png", false)
for _, spec in ipairs({
{ 0x5D, "cable_conn" }, { 0x5E, "cable_seg" }, { 0x5F, "cable_corner" },
{ 0x60, "cable_end" }, { 0x61, "cable_vert" },
}) do
local tile = ImageWriter.blank(8, 8, 1, 1, 1, 1)
ImageWriter.blit(tile, atlas, 0, 0, tileX(spec[1]), 0, 8, 8)
self:save(tile, "trade/" .. spec[2] .. ".png")
end
-- Trade_DrawCableAcrossScreen fills a whole 20-tile row with tile $5e.
local horizontal = ImageWriter.blank(160, 8, 1, 1, 1, 1)
for column = 0, 19 do
ImageWriter.blit(horizontal, atlas, column * 8, 0, tileX(0x5E), 0, 8, 8)
end
self:save(horizontal, "trade/cable_horiz.png")
-- Trade_BallInsideLinkCableOAMBlock draws one tile four times with the
-- X/Y flips, so each of the two frames -- $7e travelling, $7f bulging,
-- the bottom row of TradingAnimationGraphics2 -- makes a 16x16 ball.
local ball = self:symbol("TradingAnimationGraphics2")
local frames = ImageWriter.decode2bpp(
self.rom:bytes(ball.bank, ball.address, 64), 16, 16, true)
for index, name in ipairs({ "cable_ball", "cable_ball_alt" }) do
local image = ImageWriter.blank(16, 16, 1, 1, 1, 0)
for y = 0, 7 do
for x = 0, 7 do
local r, g, b, a = frames:getPixel((index - 1) * 8 + x, 8 + y)
image:setPixel(x, y, r, g, b, a)
image:setPixel(15 - x, y, r, g, b, a)
image:setPixel(x, 15 - y, r, g, b, a)
image:setPixel(15 - x, 15 - y, r, g, b, a)
end
end
self:save(image, "trade/" .. name .. ".png")
end
-- The ring around the travelling mon: one 16x16 quadrant per animation
-- frame (engine/gfx/mon_icons.asm TradeBubbleIconGFX), mirrored into a
-- 32x32 circle by the OAM attributes in Trade_CircleOAMBlocks.
local bubble = self:symbol("TradeBubbleIconGFX")
self:write2bpp(self.rom:bytes(bubble.bank, bubble.address, 128),
16, 32, "trade/bubble.png", true)
return {
gameBoy = "assets/generated/trade/game_boy.png",
openCable = "assets/generated/trade/open_cable.png",
cableHoriz = "assets/generated/trade/cable_horiz.png",
cableConn = "assets/generated/trade/cable_conn.png",
cableVert = "assets/generated/trade/cable_vert.png",
cableCorner = "assets/generated/trade/cable_corner.png",
cableEnd = "assets/generated/trade/cable_end.png",
cableBall = "assets/generated/trade/cable_ball.png",
cableBallAlt = "assets/generated/trade/cable_ball_alt.png",
bubble = "assets/generated/trade/bubble.png",
source = "ROM:TradingAnimationGraphics + ROM:TradeBubbleIconGFX"
.. " (engine/movie/trade.asm InternalClockTradeAnim)",
}
end
function RomExtractor:extractField()
self:beginStage("Interface artwork")
local done, total = 0, 49
local done, total = 0, 50
local function tick()
done = done + 1
self:tick("Interface artwork", math.min(done, total), total)
@@ -1832,6 +1923,8 @@ function RomExtractor:extractField()
end
self:save(emotes, "emotes.png"); tick()
local tradeArt = self:extractTradeArt(); tick()
-- Yellow-only: the Surfing Pikachu minigame sheets
-- (gfx/surfing_pikachu.asm) at pret's canvas widths, so
-- src/ui/SurfingMinigame.lua's quads can be read off the source pngs.
@@ -1962,6 +2055,7 @@ function RomExtractor:extractField()
local converted = {}
for index, values in pairs(adjacency) do converted[tonumber(index)] = values end
data.hiddenExtras.trashCans.adjacent = converted
data.tradeArt = tradeArt
data.source = "canonical Pokemon Red ROM + bundled port metadata"
self:write("field", data)
self:tick("Interface artwork", total, total)
+37
View File
@@ -57,6 +57,10 @@ local REQUIRED_FILES = {
"assets/generated/battle/anims/move_anim_0.png",
"assets/generated/battle/anims/move_anim_1.png",
"assets/generated/audio/programs.bin",
-- The trade cinematic's Game Boy / cable art. Caches built before #750
-- carry none of it and fall back to plain rectangles, so listing one of
-- the files re-imports them without a CACHE_FORMAT bump.
"assets/generated/trade/game_boy.png",
}
-- Files only one version's cache carries. A version that predates one of
@@ -2242,6 +2246,39 @@ function RomImporter:pressDelete(kind, id, version, commit)
return false
end
-- Drain one frame's queued launcher actions; LauncherView.update hands the
-- batch straight over. A touch tap fires on EVERY element whose bounds hold
-- the finger, not only the topmost one: FlexLove gates its mouse path on
-- Context.findInteractiveAtPosition (libs/flexlove/modules/behaviors/
-- Clickable.lua) but polls touches per element with a bare bounds test
-- (EventHandler:processTouchEvents), so a phone tap on a save row's Delete
-- chip also lands on the row behind it. Control keys inside a row are the
-- row's key plus "-<what>", so a row's own action is dropped whenever a
-- control inside that row queued in the same batch, and #433's disarm runs
-- here instead of at queue time. Without both halves an Android tap on
-- Delete selected the slot and wiped the arm it had just set, so a secondary
-- slot became the loaded one and could never be deleted (#780).
function RomImporter:runActions(queue)
for i = 1, #queue do
local entry = queue[i]
local key = type(entry.key) == "string" and entry.key or ""
local superseded = false
for j = 1, #queue do
local other = queue[j]
if j ~= i and type(other.key) == "string"
and other.key:sub(1, #key + 1) == key .. "-" then
superseded = true
break
end
end
if not superseded then
if not entry.keepArm then self._confirmDelete = nil end
local ok, err = pcall(entry.fn)
if not ok then print("launcher action error: " .. tostring(err)) end
end
end
end
-- Clicks are polled inside FlexLove (mouse + love.touch); host-forwarded
-- mousepressed stays inert so Android's synthesized mouse path cannot
-- double-fire a tap (#553). Touch move/press/release must still reach
+24 -6
View File
@@ -6,8 +6,10 @@
-- bytes), runs them through SaveConvert.importSav (32768-byte + checksum
-- validated), then registers a fresh slot, writes it, and makes it active.
-- Export loads the active slot, encodes it back to a 32768-byte SRAM image, and
-- drops it in the save directory's exports/<version>/ folder, returning the
-- absolute path so the launcher can offer an "open folder" affordance.
-- drops it in exports/<version>/ under the same root SaveData's persistFs
-- writes slots to -- the portable game folder when portable.txt marks the
-- install, otherwise the LOVE save directory (#752) -- returning the absolute
-- path so the launcher can offer an "open folder" affordance.
--
-- Every failure returns false + a friendly one-line message (never raises), so
-- the card can surface it as a red notice line rather than crashing.
@@ -99,9 +101,10 @@ end
-- exportActiveSlot(version) -> ok, pathOrErr
-- Loads the version's active slot save (SaveData.load semantics), encodes it
-- back to a 32768-byte SRAM image, and writes it to
-- exports/<version>/gen1recomp-<version>-<slotId>.sav in the save directory
-- (created if absent). Returns true + the absolute path on success, false + a
-- friendly message otherwise.
-- exports/<version>/gen1recomp-<version>-<slotId>.sav under the portable game
-- folder when portable mode is on, otherwise the save directory (created if
-- absent). Returns true + the absolute path on success, false + a friendly
-- message otherwise.
function SaveFileIO.exportActiveSlot(version)
version = version or GameVersion.get()
local save = SaveData.load(version)
@@ -109,7 +112,14 @@ function SaveFileIO.exportActiveSlot(version)
local bytes, exportErr = SaveConvert.exportSav(save, version)
if not bytes then return false, exportErr end
local slotId = SaveData.activeSlot(version) or "save"
local fs = love and love.filesystem
-- Portable mode is the same seam SaveData's own persistFs uses: when
-- portable.txt marks the install every persistent write leaves the OS save
-- directory for the game folder, and an export is no exception. Writing
-- through love.filesystem here dropped the .sav in AppData while the slots
-- it came from lived on the stick, and the desktop "Open folder" affordance
-- (RomImporter:exportSave) followed the returned path straight there (#752).
local portableFs = SaveData.portableFs()
local fs = portableFs or (love and love.filesystem)
if not (fs and fs.write) then return false, "no filesystem available to export to" end
if fs.createDirectory then
fs.createDirectory("exports")
@@ -119,6 +129,14 @@ function SaveFileIO.exportActiveSlot(version)
local rel = ("exports/%s/gen1recomp-%s-%s.sav"):format(version, version, slotId)
local ok, writeErr = fs.write(rel, bytes)
if not ok then return false, "could not write the export: " .. tostring(writeErr) end
-- Absolute path for the notice line, resolved against whichever root took
-- the write. Portable paths use the OS separator (slotDiskPath does the
-- same); LOVE save-directory paths stay "/"-joined as before.
local portableBase = SaveData.portableBaseDir()
if portableBase then
local sep = package.config:sub(1, 1)
return true, portableBase .. sep .. rel:gsub("/", sep)
end
local base = fs.getSaveDirectory and fs.getSaveDirectory() or ""
if base ~= "" then return true, base .. "/" .. rel end
return true, rel
+14 -1
View File
@@ -941,9 +941,22 @@ function Renderer:endFrame(zones, worldZones)
-- reads as the foreground instead of competing with a fully lit map. Goes
-- here rather than in the letterbox clear because with the world pass
-- active there is no clear -- the world already covers the surface.
--
-- The veil covers the voids ONLY, never the battle's own letterbox: "world"
-- changes what surrounds the battle and leaves the battle screen alone
-- (BattleState:bgMode). On hardware there is no "behind the battle" to dim
-- at all -- _InitBattleCommon calls ClearScreen (pokered home/copy2.asm),
-- which blanks the whole tilemap before the battle draws. A whole-surface
-- fill was invisible only because the classic battle paints an opaque paper
-- field over it a few lines below; a render pipeline that stages the fight
-- on the map and keys that field out got the veil straight onto its
-- sprites, HP bars and HUD, which is the 55% whole-window dim of #777
-- (and its duplicate #772).
if self.battleDim and self.battleDim > 0 then
love.graphics.setColor(0, 0, 0, self.battleDim)
love.graphics.rectangle("fill", 0, 0, ww, wh)
for _, r in ipairs(subtractRect({ { 0, 0, ww, wh } }, uox, uoy, uvpw, uvph)) do
love.graphics.rectangle("fill", r[1], r[2], r[3], r[4])
end
love.graphics.setColor(1, 1, 1, 1)
end
+21 -6
View File
@@ -9,8 +9,9 @@
-- CRED_TEXT_MON text appears at once, hold 110, mon wipe
-- CRED_TEXT_FADE fade in, hold 120, next screen replaces the text
-- CRED_TEXT text appears at once, hold 140
-- The mon wipe is DisplayCreditsMon: the middle band scrolls left 8px per
-- frame for 27 frames (ScrollCreditsMonLeft x7 then x20) while the next
-- The mon wipe is DisplayCreditsMon: three CreditsCopyTileMapToVRAM copies
-- (9 frames of Delay3, text still up), then the middle band scrolls left 8px
-- per frame for 27 frames (ScrollCreditsMonLeft x7 then x20) while the next
-- CreditsMons entry crosses right-to-left as a black silhouette
-- (BGP %11111100), leaving the band blank; BGP is left at %11000000, which
-- is why every post-wipe screen is a FADE variant. CRED_COPYRIGHT
@@ -52,6 +53,14 @@ local HOLD_FADE = 120
local HOLD_TEXT = 140
local WIPE_FRAMES = 27 -- ScrollCreditsMonLeft: 7 + 20 calls, 8px/frame
-- DisplayCreditsMon runs three CreditsCopyTileMapToVRAM calls (vBGMap0+$c,
-- vBGMap0, vBGMap1) before the first scroll, and each one ends in `jp Delay3`
-- (home/palettes.asm), so the credits text sits still for 9 more frames on
-- every mon screen. Dropping them ran the 15 mon screens 135 frames short and
-- brought THE END up 2.2s early against a credits theme whose length is fixed
-- by the ROM program (Music_Credits is 5880 frames and does not loop), which
-- is what made the song look like it overran the roll (#703).
local MON_PREP_FRAMES = 9
-- LoadCopyrightTiles (engine/movie/title.asm CopyrightTextString): tile
-- sequences into the extracted title/copyright.png strip (tiles $60-$72:
@@ -204,12 +213,17 @@ function Credits:update(dt)
self.timer = self.screen.mon and HOLD_FADE_MON or HOLD_FADE
elseif self.phase == "hold" then
if self.screen.mon then
self.phase = "wipe"
self.timer = WIPE_FRAMES
self.monImg, self.monTint = self:monSprite(self.screen.mon)
-- the text stays up through DisplayCreditsMon's VRAM copies; the
-- silhouette only starts moving once ScrollCreditsMonLeft does
self.phase = "mon_prep"
self.timer = MON_PREP_FRAMES
else
self:nextScreen()
end
elseif self.phase == "mon_prep" then
self.phase = "wipe"
self.timer = WIPE_FRAMES
self.monImg, self.monTint = self:monSprite(self.screen.mon)
elseif self.phase == "wipe" then
self.monImg = nil
self:nextScreen()
@@ -313,7 +327,8 @@ function Credits:draw()
love.graphics.rectangle("fill", 0, 0, 160, 32)
love.graphics.rectangle("fill", 0, 112, 160, 32)
love.graphics.setColor(1, 1, 1, 1)
if self.phase == "fade" or self.phase == "hold" then
if self.phase == "fade" or self.phase == "hold"
or self.phase == "mon_prep" then
self:drawPage(self.screen, 0, self.shade)
elseif self.phase == "wipe" then
-- ScrollCreditsMonLeft: the middle band scrolls left 8px/frame while
+36 -8
View File
@@ -155,7 +155,11 @@ local function obpIcon(path)
return love.graphics.newImage(id)
end
local function drawIcon(game, mon, x, y, selected, counter)
-- `forceAlt` picks the second animation frame outright, for callers with no
-- selection cursor of their own: Trade_AnimCircledMon
-- (engine/movie/trade.asm) cycles the party sprite's two frames the whole
-- time the mon rides the link cable (#750).
function PartyMenu.drawIcon(game, mon, x, y, selected, counter, forceAlt)
local icons = game.data.icons
if not icons then return end
local def = game.data.pokemon[mon.species]
@@ -202,7 +206,7 @@ local function drawIcon(game, mon, x, y, selected, counter)
end
local img = iconImages[key]
if not img then return end
local alt = false
local alt = forceAlt or false
if selected then
local px = math.floor(mon.hp * 48 / math.max(1, mon.stats.hp))
local speed = px >= 27 and 5 or px >= 10 and 16 or 32
@@ -237,13 +241,24 @@ local function drawIcon(game, mon, x, y, selected, counter)
-- whatever size the file is (unchanged path)
love.graphics.draw(img, x, y)
end
return true
end
function PartyMenu.new(game, opts)
opts = opts or {}
local self = setmetatable({}, PartyMenu)
self.game = game
self.index = 1
-- PartyMenuInit (home/pokemon.asm) seeds the cursor from
-- wPartyAndBillsPCSavedMenuItem rather than from zero, and
-- HandlePartyMenuInput writes wCurrentMenuItem back into it on every
-- input, so the party cursor survives closing and reopening the menu.
-- Only a battle clears it -- InitBattleVariables and end_of_battle.asm
-- both zero the byte, which BattleState mirrors. The clamp covers a
-- party that shrank (deposit / release) while the saved index was
-- pointing past the end. #768
local count = #(opts.party or (game.save and game.save.party) or {})
self.index = math.min(math.max(1, game.partyMenuSavedIndex or 1),
math.max(1, count))
self.onSwitch = opts.onSwitch
self.onCancel = opts.onCancel
self.pickOnly = opts.pickOnly
@@ -518,8 +533,10 @@ function PartyMenu:update(dt)
if input:wasPressed("up") then
self.index = self.index > 1 and self.index - 1 or math.max(1, #party)
self.game.partyMenuSavedIndex = self.index -- HandlePartyMenuInput #768
elseif input:wasPressed("down") then
self.index = self.index < #party and self.index + 1 or 1
self.game.partyMenuSavedIndex = self.index -- HandlePartyMenuInput #768
elseif input:wasPressed("b") then
self.game.stack:pop()
if self.onCancel then self.onCancel() end
@@ -567,10 +584,17 @@ function PartyMenu:update(dt)
{ label = Strings("STATS"), action = "stats" },
{ label = Strings("CANCEL"), action = "cancel" } }
else
-- STATS/SWITCH plus this mon's field moves (start_sub_menus.asm
-- builds the same dynamic list)
items = { { label = Strings("STATS"), action = "stats" },
{ label = Strings("SWITCH"), action = "switch" } }
-- This mon's field moves FIRST, then STATS/SWITCH
-- (start_sub_menus.asm builds the same dynamic list). The order is
-- load bearing: DisplayFieldMoveMonMenu (engine/menus/text_box.asm)
-- grows the box upward one row per field move and prints the field
-- move names ABOVE PokemonMenuEntries ("STATS/SWITCH/CANCEL"), and
-- StartMenu_Pokemon .choseOutOfBattleMove indexes wFieldMoves with
-- menu items 0..n-1 while STATS/SWITCH sit at the bottom of the
-- list. GetMonFieldMoves walks wPartyMon1Moves in slot order, so
-- the field moves keep the mon's move-list order -- which the loop
-- below already does. #768
items = {}
-- Field moves (HMs/TMs) are usable out of battle even when the mon
-- is fainted -- Gen 1 does not require HP for Cut/Fly/Surf/etc.
-- Battle still excludes this list via `not self.battle`. Softboiled
@@ -615,6 +639,10 @@ function PartyMenu:update(dt)
end
end
end
-- PokemonMenuEntries always closes the list, under the field moves
-- (text_box.asm .donePrintingNames). #768
items[#items + 1] = { label = Strings("STATS"), action = "stats" }
items[#items + 1] = { label = Strings("SWITCH"), action = "switch" }
end
local ctx = { battle = self.battle, overworld = ow }
local hooked = Runtime.call("ui.party.submenu", sameItems,
@@ -685,7 +713,7 @@ function PartyMenu:draw()
local def = self.game.data.pokemon[mon.species]
local y = PartyMenu.entryY(i)
love.graphics.setColor(1, 1, 1, 1)
drawIcon(self.game, mon, 8, y, i == self.index, self.blink or 0)
PartyMenu.drawIcon(self.game, mon, 8, y, i == self.index, self.blink or 0)
love.graphics.setColor(0, 0, 0, 1)
Font.draw(mon.nickname or def.name, 24, y)
-- level at column 13 (<LV> tile + digits, PrintLevel) AND the
+15 -5
View File
@@ -66,14 +66,24 @@ function StartMenu.new(game)
panel .. Strings("\fWould you like to\nSAVE the game?"), nil, {
choice = function(yes)
if not yes then return end
-- "Now saving..." beat before the write (save.asm
-- NowSavingString), then GameSavedText + SFX_SAVE
-- SaveMenu .save (engine/menus/save.asm:164-181): "Now saving..."
-- is a bare PlaceString held by DelayFrames 120, then GameSavedText,
-- which ends in `done` and so never reaches TX_PROMPT_BUTTON.
-- Neither page takes a button press (#765); the second waits on
-- SFX_SAVE (PlaySoundWaitForCurrent + WaitForSoundToFinish) and then
-- DelayFrames 30. The write itself is invisible either side of the
-- "Now saving..." hold, so it stays on that box's onDone.
game.stack:push(TextBox.new(game, Strings("Now saving..."), function()
game:writeSave()
require("src.core.Sound").play(game.data, "Save")
game.stack:push(TextBox.new(game,
Strings("%s saved\nthe game!", game.save.player.name or "RED")))
end))
Strings("%s saved\nthe game!", game.save.player.name or "RED"),
nil, { auto = {
sound = function()
return require("src.core.Sound").play(game.data, "Save")
end,
delay = 30,
} }))
end, { auto = { delay = 120 } }))
end,
}))
end })
+281 -102
View File
@@ -3,14 +3,21 @@
-- spin in the air and land flat for points; a crooked landing wipes out
-- and ends the run. The scene is built from the real ROM sheets
-- (gfx/surfing_pikachu.asm, ripped at import to
-- assets/generated/minigame/surf_1a/1b.png): the scalloped water tiles,
-- the beach with the palm and the doll hut, the "HP:" score strip with
-- the sheet digits, the cloud, and the OAM Pikachu poses -- the air
-- tricks quantize to the sheet's rotation frames like the original's
-- sprite anims, instead of free-rotating one pose. The original drew
-- the big wave with per-scanline scroll tricks (wLYOverrides); here the
-- crest profile is a curve filled with the sheet's foam/shade tiles.
-- Score model keeps the original's shape (ride ticks + airtime + full
-- assets/generated/minigame/surf_1a/1b.png).
--
-- #726: the background is the original's own metatile scroller, not a
-- procedural stand-in. SurfingPikachu1Graphics1 is copied to vChars2
-- with LCDC's BG char base unset, so BG tile id N is simply tile N of
-- surf_1a (5 tiles per row). SurfingMinigame_ScrollAndGenerateBGMap
-- walks a jumptable of wave states, each of which hands back one
-- 8-metatile column (2x2 tiles each, so 16px wide by the 128px the BG
-- shows above the HP window) plus the two Pikachu ride heights for that
-- column. Porting those tables verbatim is what makes the water read as
-- water: the earlier stand-in tiled the wave-face tiles ($02/$07) over
-- the whole sea and drew the swell as a LOVE ellipse, which is the
-- "messed up graphics" in the report.
--
-- Score model keeps the port's shape (ride ticks + airtime + full
-- rotations); high score persists in save.surfingHighScore for the
-- beach-house printer.
@@ -23,10 +30,13 @@ local SurfingMinigame = {}
SurfingMinigame.__index = SurfingMinigame
SurfingMinigame.isOpaque = true
local PIKA_X = 44 -- fixed screen x while riding
local RUN_DISTANCE = 3200 -- scroll px from paddle-out to the beach
-- SURFING_MINIGAME_CENTER_X/FLAT_WATER_Y (surfing_pikachu.asm:1-2) are OAM
-- coordinates; screen x/y are those minus OAM_X_OFS/OAM_Y_OFS.
local FLAT_WATER_Y = 116
local PIKA_X = 68 -- fixed screen x while riding (center 80, 24px pose)
local RUN_DISTANCE = 3072 -- 24 sections of 8 metatile columns
local GRAVITY = 0.14
local HORIZON = 24 -- sea starts under the sky strip
local BG_HEIGHT = 128 -- rows the BG shows; the HP window covers the rest
-- surf_1b quads: {x, y, w, h} in sheet pixels (pose pitch is 24x24)
local B = {
@@ -50,18 +60,158 @@ local POSES = {
[315] = { 0, 0, 24, 24 }, -- tail down
}
-- surf_1a quads (BG tiles)
local A = {
scallop = { 16, 0, 8, 8 }, -- open-water pattern, row A
scallop2 = { 16, 8, 8, 8 }, -- row B variant
shade = { 8, 16, 8, 8 }, -- gray dither, wave belly
lip = { 24, 0, 8, 8 }, -- foam curl for the crest edge
palm = { 8, 32, 8, 8 }, -- palm fronds
beach = { 24, 32, 16, 8 }, -- black shore silhouette
hut = { 8, 40, 16, 8 }, -- the Pikachu doll hut on the sand
hp = { 20, 40, 20, 8 }, -- "HP:" score label
-- surf_1a is 5 tiles wide, so BG tile id N lives at (N%5*8, N/5*8). The
-- only quad the scene needs by hand is the window's "HP:" label, which
-- straddles a tile boundary in the sheet.
local HP_LABEL = { 20, 40, 20, 8 }
-- SurfingMinigame_BGMetatileTable (surfing_pikachu.asm): 2x2 tiles each,
-- stored top-left, top-right, bottom-left, bottom-right.
local BG_METATILES = {
[0x00] = { 0x00, 0x00, 0x00, 0x00 }, -- sky block (blank)
[0x01] = { 0x0b, 0x0b, 0x0b, 0x0b }, -- open water
[0x02] = { 0x0b, 0x02, 0x02, 0x06 },
[0x03] = { 0x03, 0x0b, 0x07, 0x03 },
[0x04] = { 0x06, 0x06, 0x06, 0x06 },
[0x05] = { 0x07, 0x07, 0x07, 0x07 },
[0x06] = { 0x06, 0x04, 0x04, 0x08 },
[0x07] = { 0x05, 0x07, 0x08, 0x05 },
[0x08] = { 0x0b, 0x0b, 0x11, 0x12 },
[0x09] = { 0x0b, 0x0b, 0x13, 0x03 },
[0x0a] = { 0x14, 0x12, 0x04, 0x08 },
[0x0b] = { 0x13, 0x07, 0x08, 0x05 },
[0x0c] = { 0x06, 0x14, 0x06, 0x14 }, -- unused, identical to 11
[0x0d] = { 0x13, 0x07, 0x13, 0x07 },
[0x0e] = { 0x08, 0x08, 0x08, 0x08 }, -- solid blue
[0x0f] = { 0x14, 0x12, 0x14, 0x12 },
[0x10] = { 0x0b, 0x11, 0x02, 0x14 },
[0x11] = { 0x06, 0x14, 0x06, 0x14 },
[0x12] = { 0x0c, 0x0c, 0x0d, 0x0d }, -- beach top block
[0x13] = { 0x0d, 0x0d, 0x0d, 0x0d }, -- beach sand block
[0x14] = { 0x0e, 0x0f, 0x10, 0x0b }, -- beach shore block
[0x15] = { 0x12, 0x13, 0x12, 0x13 },
}
-- SurfingMinigameWavePattern00..1C plus SurfingMinigameBeachPattern: one
-- column of 8 metatiles, top to bottom.
local WAVE_PATTERNS = {
[0x00] = { 0x00, 0x00, 0x00, 0x01, 0x01, 0x01, 0x01, 0x01 },
[0x01] = { 0x00, 0x00, 0x00, 0x01, 0x01, 0x02, 0x04, 0x06 },
[0x02] = { 0x00, 0x00, 0x00, 0x01, 0x02, 0x04, 0x06, 0x0e },
[0x03] = { 0x00, 0x00, 0x00, 0x10, 0x11, 0x06, 0x0e, 0x0e },
[0x04] = { 0x00, 0x00, 0x00, 0x15, 0x15, 0x0e, 0x0e, 0x0e },
[0x05] = { 0x00, 0x00, 0x00, 0x03, 0x05, 0x07, 0x0e, 0x0e },
[0x06] = { 0x00, 0x00, 0x00, 0x01, 0x03, 0x05, 0x07, 0x0e },
[0x07] = { 0x00, 0x00, 0x00, 0x01, 0x01, 0x03, 0x05, 0x07 },
[0x08] = { 0x00, 0x00, 0x00, 0x01, 0x01, 0x02, 0x04, 0x06 },
[0x09] = { 0x00, 0x00, 0x00, 0x01, 0x02, 0x04, 0x06, 0x0e },
[0x0a] = { 0x00, 0x00, 0x00, 0x08, 0x0f, 0x0a, 0x0e, 0x0e },
[0x0b] = { 0x00, 0x00, 0x00, 0x09, 0x0d, 0x0b, 0x0e, 0x0e },
[0x0c] = { 0x00, 0x00, 0x00, 0x01, 0x03, 0x05, 0x07, 0x0e },
[0x0d] = { 0x00, 0x00, 0x00, 0x01, 0x01, 0x03, 0x05, 0x07 },
[0x0e] = { 0x00, 0x00, 0x00, 0x01, 0x01, 0x02, 0x04, 0x06 },
[0x0f] = { 0x00, 0x00, 0x00, 0x01, 0x10, 0x11, 0x06, 0x0e },
[0x10] = { 0x00, 0x00, 0x00, 0x01, 0x15, 0x15, 0x0e, 0x0e },
[0x11] = { 0x00, 0x00, 0x00, 0x01, 0x03, 0x05, 0x07, 0x0e },
[0x12] = { 0x00, 0x00, 0x00, 0x01, 0x01, 0x03, 0x05, 0x07 },
[0x13] = { 0x00, 0x00, 0x00, 0x01, 0x01, 0x02, 0x04, 0x06 },
[0x14] = { 0x00, 0x00, 0x00, 0x01, 0x08, 0x0f, 0x0a, 0x0e },
[0x15] = { 0x00, 0x00, 0x00, 0x01, 0x09, 0x0d, 0x0b, 0x0e },
[0x16] = { 0x00, 0x00, 0x00, 0x01, 0x01, 0x03, 0x05, 0x07 },
[0x17] = { 0x00, 0x00, 0x00, 0x01, 0x01, 0x10, 0x11, 0x06 },
[0x18] = { 0x00, 0x00, 0x00, 0x01, 0x01, 0x15, 0x15, 0x0e },
[0x19] = { 0x00, 0x00, 0x00, 0x01, 0x01, 0x03, 0x05, 0x07 },
[0x1a] = { 0x00, 0x00, 0x00, 0x01, 0x01, 0x08, 0x0f, 0x0a },
[0x1b] = { 0x00, 0x00, 0x00, 0x01, 0x01, 0x09, 0x0d, 0x0b },
[0x1c] = { 0x00, 0x00, 0x00, 0x14, 0x14, 0x14, 0x14, 0x14 },
beach = { 0x00, 0x00, 0x00, 0x12, 0x13, 0x13, 0x13, 0x13 },
}
-- RunSurfingMinigameRoutine's .WaveFunctions jumptable, flattened:
-- { pattern, left ride height, right ride height, what to do next }.
-- next: 0 = advance one state, 1 = reset to the chooser, 2 = stay put.
-- State 0 is SurfingMinigame_ChooseNextWaveSequence and is handled in
-- code because it rolls Random and forces the Big Kahuna near the goal.
local ADV, RESET, STAY = 0, 1, 2
local WAVE_STEPS = {
[0x01] = { 0x13, 116, 108, ADV }, [0x02] = { 0x14, 100, 92, ADV },
[0x03] = { 0x15, 92, 92, ADV }, [0x04] = { 0x16, 100, 108, ADV },
[0x05] = { 0x00, 116, 116, ADV }, [0x06] = { 0x17, 116, 108, ADV },
[0x07] = { 0x18, 100, 100, ADV }, [0x08] = { 0x19, 100, 108, ADV },
[0x09] = { 0x00, 116, 116, ADV }, [0x0a] = { 0x00, 116, 116, ADV },
[0x0b] = { 0x00, 116, 116, ADV }, [0x0c] = { 0x00, 116, 116, ADV },
[0x0d] = { 0x00, 116, 116, RESET },
[0x0e] = { 0x08, 116, 108, ADV }, [0x0f] = { 0x09, 100, 92, ADV },
[0x10] = { 0x0a, 84, 76, ADV }, [0x11] = { 0x0b, 76, 76, ADV },
[0x12] = { 0x0c, 84, 92, ADV }, [0x13] = { 0x0d, 100, 108, ADV },
[0x14] = { 0x00, 116, 116, ADV }, [0x15] = { 0x00, 116, 116, ADV },
[0x16] = { 0x00, 116, 116, ADV }, [0x17] = { 0x00, 116, 116, ADV },
[0x18] = { 0x00, 116, 116, ADV }, [0x19] = { 0x00, 116, 116, RESET },
[0x1a] = { 0x0e, 116, 108, ADV }, [0x1b] = { 0x0f, 100, 92, ADV },
[0x1c] = { 0x10, 84, 84, ADV }, [0x1d] = { 0x11, 84, 92, ADV },
[0x1e] = { 0x12, 100, 108, ADV }, [0x1f] = { 0x0e, 116, 108, ADV },
[0x20] = { 0x0f, 100, 92, ADV }, [0x21] = { 0x10, 84, 84, ADV },
[0x22] = { 0x11, 84, 92, ADV }, [0x23] = { 0x12, 100, 108, ADV },
[0x24] = { 0x00, 116, 116, ADV }, [0x25] = { 0x00, 116, 116, ADV },
[0x26] = { 0x00, 116, 116, ADV }, [0x27] = { 0x00, 116, 116, ADV },
[0x28] = { 0x00, 116, 116, RESET },
[0x29] = { 0x13, 116, 108, ADV }, [0x2a] = { 0x14, 100, 92, ADV },
[0x2b] = { 0x15, 92, 92, ADV }, [0x2c] = { 0x16, 100, 108, ADV },
[0x2d] = { 0x00, 116, 116, ADV }, [0x2e] = { 0x00, 116, 116, ADV },
[0x2f] = { 0x00, 116, 116, ADV }, [0x30] = { 0x00, 116, 116, ADV },
[0x31] = { 0x00, 116, 116, RESET },
[0x32] = { 0x17, 116, 108, ADV }, [0x33] = { 0x18, 100, 100, ADV },
[0x34] = { 0x19, 100, 108, ADV }, [0x35] = { 0x17, 116, 108, ADV },
[0x36] = { 0x18, 100, 100, ADV }, [0x37] = { 0x19, 100, 108, ADV },
[0x38] = { 0x17, 116, 108, ADV }, [0x39] = { 0x18, 100, 100, ADV },
[0x3a] = { 0x19, 100, 108, ADV }, [0x3b] = { 0x00, 116, 116, ADV },
[0x3c] = { 0x00, 116, 116, ADV }, [0x3d] = { 0x00, 116, 116, ADV },
[0x3e] = { 0x00, 116, 116, ADV }, [0x3f] = { 0x00, 116, 116, RESET },
[0x40] = { 0x1a, 116, 108, ADV }, [0x41] = { 0x1b, 108, 108, ADV },
[0x42] = { 0x0e, 116, 108, ADV }, [0x43] = { 0x0f, 100, 92, ADV },
[0x44] = { 0x10, 84, 84, ADV }, [0x45] = { 0x11, 84, 92, ADV },
[0x46] = { 0x12, 100, 108, ADV }, [0x47] = { 0x1a, 116, 108, ADV },
[0x48] = { 0x1b, 108, 108, ADV }, [0x49] = { 0x00, 116, 116, ADV },
[0x4a] = { 0x00, 116, 116, ADV }, [0x4b] = { 0x00, 116, 116, ADV },
[0x4c] = { 0x00, 116, 116, RESET },
[0x4d] = { 0x08, 116, 108, ADV }, [0x4e] = { 0x09, 100, 92, ADV },
[0x4f] = { 0x0a, 84, 76, ADV }, [0x50] = { 0x0b, 76, 76, ADV },
[0x51] = { 0x0c, 84, 92, ADV }, [0x52] = { 0x0d, 100, 108, ADV },
[0x53] = { 0x00, 116, 116, ADV }, [0x54] = { 0x1a, 116, 108, ADV },
[0x55] = { 0x1b, 108, 108, ADV }, [0x56] = { 0x1a, 116, 108, ADV },
[0x57] = { 0x1b, 108, 108, ADV }, [0x58] = { 0x00, 116, 116, ADV },
[0x59] = { 0x00, 116, 116, ADV }, [0x5a] = { 0x00, 116, 116, ADV },
[0x5b] = { 0x00, 116, 116, RESET },
[0x5c] = { 0x0e, 116, 108, ADV }, [0x5d] = { 0x0f, 100, 92, ADV },
[0x5e] = { 0x10, 84, 84, ADV }, [0x5f] = { 0x11, 84, 92, ADV },
[0x60] = { 0x12, 100, 108, ADV }, [0x61] = { 0x13, 116, 108, ADV },
[0x62] = { 0x14, 100, 92, ADV }, [0x63] = { 0x15, 92, 92, ADV },
[0x64] = { 0x16, 100, 108, ADV }, [0x65] = { 0x00, 116, 116, ADV },
[0x66] = { 0x00, 116, 116, ADV }, [0x67] = { 0x00, 116, 116, ADV },
[0x68] = { 0x00, 116, 116, ADV }, [0x69] = { 0x00, 116, 116, RESET },
-- 6a..71: the forced "Big Kahuna" finale; 71 holds flat water (its
-- loader just rets, so the state never advances on its own).
[0x6a] = { 0x01, 116, 108, ADV }, [0x6b] = { 0x02, 100, 92, ADV },
[0x6c] = { 0x03, 84, 76, ADV }, [0x6d] = { 0x04, 68, 68, ADV },
[0x6e] = { 0x05, 68, 76, ADV }, [0x6f] = { 0x06, 84, 92, ADV },
[0x70] = { 0x07, 100, 108, ADV }, [0x71] = { 0x00, 116, 116, STAY },
-- 72..7b: the run-out to the beach, entered by hand at the goal
-- (SurfingMinigame_WaitToShowResults writes $72).
[0x72] = { 0x00, 116, 116, ADV }, [0x73] = { 0x1c, 116, 116, ADV },
[0x74] = { "beach", 116, 116, ADV }, [0x75] = { "beach", 116, 116, ADV },
[0x76] = { "beach", 116, 116, ADV }, [0x77] = { "beach", 116, 116, ADV },
[0x78] = { "beach", 116, 116, ADV }, [0x79] = { "beach", 116, 116, ADV },
[0x7a] = { "beach", 116, 116, ADV }, [0x7b] = { "beach", 116, 116, RESET },
}
-- SurfingMinigame_WaveSequenceStarts
local SEQ_STARTS = { 0x01, 0x0e, 0x1a, 0x29, 0x32, 0x40, 0x4d, 0x5c }
-- the #726 table-integrity check in tests/drivers reads these; nothing
-- else should
SurfingMinigame.BG_METATILES = BG_METATILES
SurfingMinigame.WAVE_PATTERNS = WAVE_PATTERNS
SurfingMinigame.WAVE_STEPS = WAVE_STEPS
-- SGB-style zones: one sea palette over the frame plus a yellow
-- OBJ-flavored palette tracking Pikachu's tiles (rectangular attribute
-- blocks are all the SGB could do, bleed and all)
@@ -91,6 +241,17 @@ function SurfingMinigame.new(game, onDone)
self.resultShown = 0
self.banner = nil -- {quad, frames}: GOOD!/YEAH-/Oh no..
-- SurfingPikachuMinigame_LoadGFXAndLayout prefills the BG with flat
-- water and only starts generating $a0 pixels (ten metatile columns)
-- ahead of the viewport, so the run opens on calm sea.
self.waveFn = 0
self.cols = {}
for c = 0, 10 do
self.cols[c] = { pat = WAVE_PATTERNS[0x00],
hl = FLAT_WATER_Y, hr = FLAT_WATER_Y }
end
self.colTail = 10
local function sheet(path)
local ok, img = pcall(love.graphics.newImage, path)
return ok and img or nil
@@ -98,8 +259,12 @@ function SurfingMinigame.new(game, onDone)
self.bg = sheet("assets/generated/minigame/surf_1a.png")
self.ob = sheet("assets/generated/minigame/surf_1b.png")
if self.bg then
self.aq = {}
for k, spec in pairs(A) do self.aq[k] = newQuad(spec, self.bg) end
self.tq = {}
for n = 0, 64 do
self.tq[n] = love.graphics.newQuad((n % 5) * 8, math.floor(n / 5) * 8,
8, 8, self.bg:getDimensions())
end
self.hpq = newQuad(HP_LABEL, self.bg)
end
if self.ob then
self.bq = {}
@@ -122,11 +287,53 @@ function SurfingMinigame.new(game, onDone)
return self
end
-- crest height at screen x for the current scroll (two sines so the
-- wave rolls instead of looping visibly)
-- SurfingMinigame_ChooseNextWaveSequence: past section $16 the finale is
-- forced, otherwise a nonzero Random picks one of eight sequence starts.
-- Either way this column itself is flat water.
function SurfingMinigame:chooseSequence()
if math.floor(self.distance / 128) >= 0x16 then
self.waveFn = 0x6a
else
local r = math.random(0, 255)
if r ~= 0 then self.waveFn = SEQ_STARTS[((r - 1) % 8) + 1] end
end
return WAVE_PATTERNS[0x00], FLAT_WATER_Y, FLAT_WATER_Y
end
-- one 16px metatile column, appended on the right as the sea scrolls
function SurfingMinigame:pushColumn()
local pat, hl, hr
if self.waveFn == 0 then
pat, hl, hr = self:chooseSequence()
else
local step = WAVE_STEPS[self.waveFn]
if not step then
self.waveFn = 0
pat, hl, hr = WAVE_PATTERNS[0x00], FLAT_WATER_Y, FLAT_WATER_Y
else
pat, hl, hr = WAVE_PATTERNS[step[1]], step[2], step[3]
if step[4] == ADV then self.waveFn = self.waveFn + 1
elseif step[4] == RESET then self.waveFn = 0 end
end
end
self.colTail = self.colTail + 1
self.cols[self.colTail] = { pat = pat, hl = hl, hr = hr }
self.cols[self.colTail - 24] = nil -- columns behind the viewport
end
-- keep the generated columns covering the viewport plus the lookahead
function SurfingMinigame:generateAhead()
while self.colTail * 16 < self.distance + 176 do self:pushColumn() end
end
-- Pikachu's screen y for a screen x, from the per-tile-column ride
-- heights the wave states hand back (SurfingMinigame_SetPikachuHeight
-- samples the same array either side of the scroll's low bit).
function SurfingMinigame:seaY(x)
local s = self.distance + x
return 92 - 14 * math.sin(s / 26) - 6 * math.sin(s / 9.5)
local tile = math.floor((self.distance + x) / 8)
local col = self.cols[math.floor(tile / 2)]
if not col then return FLAT_WATER_Y - 16 end
return (tile % 2 == 0 and col.hl or col.hr) - 16
end
function SurfingMinigame:finishRun()
@@ -146,6 +353,13 @@ function SurfingMinigame:update()
if self.banner.frames <= 0 then self.banner = nil end
end
if self.phase == "results" then
-- the sea keeps sliding under the card for a beat so the beach
-- run-out (states $72..$7b) actually crosses the screen, like
-- SurfingMinigame_WaitToShowResults scrolling to the sand
if self.resultShown < 150 then
self.distance = self.distance + 1.2
self:generateAhead()
end
self.resultShown = self.resultShown + 1
if self.resultShown > 30
and (input:wasPressed("a") or input:wasPressed("b")) then
@@ -162,9 +376,13 @@ function SurfingMinigame:update()
-- the wave scrolls by the current speed; the beach ends the run
self.distance = self.distance + 0.8 + self.speed * 0.35
self:generateAhead()
if self.distance >= RUN_DISTANCE then
-- rode it all the way in: distance bonus like the original's goal
self.score = self.score + 500
-- SurfingMinigame_WaitToShowResults hands the generator state $72 so
-- the sand runs out under the coast-in
self.waveFn = 0x72
self:finishRun()
return
end
@@ -218,16 +436,11 @@ function SurfingMinigame:update()
end
end
-- draw one 8x8 sheet tile quad at x, y
function SurfingMinigame:tile(q, x, y)
love.graphics.draw(self.bg, self.aq[q], x, y)
end
function SurfingMinigame:sgbPalettes()
local P = require("src.render.PaletteFX")
local zones = { P.whole(SEA_PAL) }
if self.phase ~= "wipeout" and self.phase ~= "results" then
local tx = math.floor((PIKA_X - 12) / 8)
local tx = math.floor(PIKA_X / 8)
local ty = math.floor(math.max(0, self.pikaScreenY or 60) / 8)
zones[#zones + 1] = P.zone(PIKA_PAL, tx, ty, tx + 3, ty + 3)
end
@@ -242,87 +455,52 @@ function SurfingMinigame:drawScore(x, y, n)
end
end
-- the BG map: metatile columns from the wave generator, scrolled by
-- distance (SurfingMinigame_ScrollAndGenerateBGMap)
function SurfingMinigame:drawBackground()
local scx = math.floor(self.distance)
local first = math.floor(scx / 16)
for c = first, first + 10 do
local col = self.cols[c]
if col then
local x = c * 16 - scx
for i = 1, 8 do
local mt = BG_METATILES[col.pat[i]]
if mt then
local y = (i - 1) * 16
love.graphics.draw(self.bg, self.tq[mt[1]], x, y)
love.graphics.draw(self.bg, self.tq[mt[2]], x + 8, y)
love.graphics.draw(self.bg, self.tq[mt[3]], x, y + 8)
love.graphics.draw(self.bg, self.tq[mt[4]], x + 8, y + 8)
end
end
end
end
end
function SurfingMinigame:draw()
local haveSheets = self.bg and self.ob
-- sky
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 0, 0, 160, 144)
if not haveSheets then
-- cache predates the surf sheets: plain shapes keep it playable
love.graphics.setColor(0, 0, 0, 1)
Font.draw(Strings("SCORE %d", self.score), 4, 4)
love.graphics.rectangle("fill", PIKA_X - 8,
self:seaY(PIKA_X) - 16 - self.y, 16, 16)
love.graphics.rectangle("fill", PIKA_X, self:seaY(PIKA_X) - self.y, 16, 16)
love.graphics.setColor(1, 1, 1, 1)
return
end
self:drawBackground()
-- cloud in the sky strip
love.graphics.draw(self.ob, self.bq.cloud, 112, 8)
-- open water: the scalloped pattern tiles the whole sea, phase-locked
-- to the scroll so the surface slides
local shift = math.floor(self.distance) % 8
for ty = HORIZON, 136, 8 do
local alt = (ty / 8) % 2 == 0
for tx = -8, 160, 8 do
self:tile(alt and "scallop" or "scallop2", tx - shift, ty)
end
end
-- the wave face: a white patch hugging the ride line (the original
-- carved it with per-scanline scroll; the ellipse stands in), with a
-- few scallops floating inside and the foam lip along its upper edge
local faceY = self:seaY(56) + 10
love.graphics.setColor(1, 1, 1, 1)
love.graphics.ellipse("fill", 56, faceY, 46, 30)
love.graphics.ellipse("fill", 100, faceY + 16, 40, 22)
for _, spot in ipairs({ { 30, 8 }, { 70, 16 }, { 48, 22 } }) do
self:tile("scallop", 56 - 46 + spot[1] - shift, faceY - 24 + spot[2])
end
local pikaY = self:seaY(PIKA_X) - 20 - self.y
for a = 205, 335, 18 do
local r = math.rad(a)
local lx = 56 + math.cos(r) * 44 - 4
local ly = faceY + math.sin(r) * 28 - 4
-- foam that would land inside Pikachu's SGB zone comes out orange;
-- leave that patch to the spray ellipse instead
if math.abs(lx - PIKA_X) > 28 or math.abs(ly - (pikaY + 12)) > 26 then
self:tile("lip", lx, ly)
end
end
self:tile("shade", 92 - shift, faceY + 20)
self:tile("shade", 116 - shift, faceY + 24)
-- beach slides through at the start and again before the goal
local beachX
if self.distance < 160 then
beachX = -self.distance
elseif self.distance > RUN_DISTANCE - 200 then
beachX = 160 - (self.distance - (RUN_DISTANCE - 200))
end
if beachX then
for tx = 0, 32, 8 do
self:tile("beach", beachX + tx, 128)
self:tile("beach", beachX + tx, 136)
end
love.graphics.setColor(0, 0, 0, 1)
love.graphics.rectangle("fill", beachX + 9, 118, 2, 10)
love.graphics.setColor(1, 1, 1, 1)
self:tile("palm", beachX + 6, 112)
self:tile("hut", beachX + 20, 118)
end
-- Pikachu. The white spray patch under him doubles as the yellow SGB
-- zone's backdrop: shade 0 maps to white in both palettes, so the
-- attribute-block bleed never shows on the water pattern.
love.graphics.setColor(1, 1, 1, 1)
local py = self:seaY(PIKA_X) - 20 - self.y
-- Pikachu rides at the height his tile column reports
local py = self:seaY(PIKA_X) - self.y
self.pikaScreenY = py -- the yellow SGB zone tracks this
love.graphics.ellipse("fill", PIKA_X, py + 12, 25, 21)
if self.phase == "wipeout" then
love.graphics.draw(self.ob, self.bq.splash, PIKA_X - 16,
self:seaY(PIKA_X) - 16)
love.graphics.draw(self.ob, self.bq.splash, PIKA_X - 4, py)
else
local quad
if self.phase == "ride" and self.speed <= 2
@@ -332,7 +510,7 @@ function SurfingMinigame:draw()
local bucket = math.floor(((self.rot % 360) + 22.5) / 45) % 8 * 45
quad = self.bq.poses[bucket] or self.bq.poses[0]
end
love.graphics.draw(self.ob, quad, PIKA_X - 12, py)
love.graphics.draw(self.ob, quad, PIKA_X, py)
end
-- banner beats: GOOD! / YEAH- / Oh no..
@@ -340,11 +518,12 @@ function SurfingMinigame:draw()
love.graphics.draw(self.ob, self.bq[self.banner.quad], 60, 40)
end
-- score strip, bottom right: HP: + sheet digits
-- the HP window sits under the BG rows ($7e into hWY puts it at y 126;
-- tile-aligned here): "HP:" plus the sheet digits over plain white
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 100, 134, 60, 10)
love.graphics.draw(self.bg, self.aq.hp, 102, 135)
self:drawScore(126, 135, self.score)
love.graphics.rectangle("fill", 0, BG_HEIGHT, 160, 144 - BG_HEIGHT)
love.graphics.draw(self.bg, self.hpq, 8, BG_HEIGHT + 4)
self:drawScore(32, BG_HEIGHT + 4, self.score)
if self.phase == "results" then
love.graphics.setColor(1, 1, 1, 1)
+35 -20
View File
@@ -10,8 +10,16 @@ local TradeAnim = {}
TradeAnim.__index = TradeAnim
TradeAnim.isOpaque = true
-- Trade_LoadMonSprite runs SET_PAL_POKEMON_WHOLE_SCREEN for the mon it puts
-- on screen; every other step of the sequence runs SET_PAL_GENERIC, which is
-- PAL_MEWMON (data/sgb/sgb_packets.asm PalPacket_Generic). #750
function TradeAnim:sgbPalettes(game)
return require("src.render.PaletteFX").wholeNamed(game.data, "MEWMON")
local P = require("src.render.PaletteFX")
local mon = (self.phase == "show_player" and self.sent)
or (self.phase == "show_enemy" and self.received)
local colors = mon and P.monPal(game.data, mon.species)
if colors then return { P.whole(colors) } end
return P.wholeNamed(game.data, "MEWMON")
end
local DEFAULT_ART = {
@@ -350,17 +358,17 @@ function TradeAnim:drawMonInfo(mon, ot, otId, boxTy)
love.graphics.setColor(1, 1, 1, 1)
end
function TradeAnim:drawIconInBubble(sprite, x, y)
local spr = sprite
if spr then
local sw, sh = spr:getDimensions()
local s = 16 / math.max(sw, sh)
love.graphics.draw(spr, x, y, 0, s, s)
else
love.graphics.setColor(0, 0, 0, 1)
love.graphics.rectangle("fill", x + 4, y + 4, 8, 8)
love.graphics.setColor(1, 1, 1, 1)
end
-- Trade_WriteCircledMonOAM: the mon crosses the cable as its party-menu
-- sprite (wMonPartySpriteSpecies -> WriteMonPartySpriteOAMBySpecies), not as
-- its battle pic, and Trade_AnimCircledMon flips both it and the ring to
-- their second frame every step. The ring is four OAM blocks --
-- Trade_CircleOAMBlocks .OAMBlock0-3 at (8,8) (24,8) (8,24) (24,24) with the
-- X/Y flips -- so the 16x32 bubble sheet holds one quadrant per frame and the
-- circle it makes is 32x32 around the 16x16 icon. The icon rides OAM
-- block 0 and the circle blocks 1-4 (Trade_WriteCircleOAMBlock counts a up
-- from 1), and the lower OAM index wins overlap on DMG, so the icon draws
-- on top of the circle's filled interior. #750
function TradeAnim:drawIconInBubble(mon, x, y)
if self.img.bubble then
if not self.bubbleQuad then
local iw, ih = self.img.bubble:getDimensions()
@@ -370,7 +378,19 @@ function TradeAnim:drawIconInBubble(sprite, x, y)
or self.bubbleQuad
end
local q = self.cableFlash and self.bubbleQuadAlt or self.bubbleQuad
love.graphics.draw(self.img.bubble, q, x - 8, y - 8)
local left, top = x - 8, y - 8
local right, bottom = left + 32, top + 32
love.graphics.draw(self.img.bubble, q, left, top)
love.graphics.draw(self.img.bubble, q, right, top, 0, -1, 1)
love.graphics.draw(self.img.bubble, q, left, bottom, 0, 1, -1)
love.graphics.draw(self.img.bubble, q, right, bottom, 0, -1, -1)
end
local drawn = mon and require("src.ui.PartyMenu").drawIcon(
self.game, mon, x, y, false, 0, self.cableFlash)
if not drawn then
love.graphics.setColor(0, 0, 0, 1)
love.graphics.rectangle("fill", x + 4, y + 4, 8, 8)
love.graphics.setColor(1, 1, 1, 1)
end
end
@@ -471,13 +491,8 @@ function TradeAnim:draw()
love.graphics.translate(160, 0)
self:drawRightGB()
love.graphics.pop()
local sprite
if p == "transfer_lr" then
sprite = self.sentSprite
else
sprite = self.recvSprite
end
self:drawIconInBubble(sprite, self.monX, self.monY)
local mon = p == "transfer_lr" and self.sent or self.received
self:drawIconInBubble(mon, self.monX, self.monY)
if self.cableFlash then
love.graphics.setColor(1, 1, 1, 0.15)
love.graphics.rectangle("fill", 0, 32, 160, 8)
+61 -21
View File
@@ -2915,6 +2915,34 @@ function OverworldState:trainerDefeated(npc)
return false
end
-- data/trainers/encounter_types.asm
local FEMALE_TRAINERS = {
OPP_LASS = true, OPP_JR_TRAINER_F = true, OPP_BEAUTY = true,
OPP_COOLTRAINER_F = true,
}
local EVIL_TRAINERS = {
OPP_UNUSED_JUGGLER = true, OPP_GAMBLER = true, OPP_ROCKER = true,
OPP_JUGGLER = true, OPP_CHIEF = true, OPP_SCIENTIST = true,
OPP_GIOVANNI = true, OPP_ROCKET = true,
}
-- PlayTrainerMusic (home/trainers.asm:399) picks the encounter sting from
-- the engaged class: evil list, then female list, then male by default.
-- The rivals `ret z` out of it and keep the MUSIC_MEET_RIVAL their own
-- scripts start (data/scripts/oaks_lab.lua, story5.lua). Its other gate,
-- wGymLeaderNo, is not a leader test: that byte aliases wLoneAttackNo
-- (ram/wram.asm:1264), is cleared on every map entry
-- (engine/overworld/clear_variables.asm:8), and each gym script writes it
-- only AFTER its own `call EngageMapTrainer` (scripts/PewterGym.asm:122),
-- so leaders do get the sting and nothing on a map can be suppressed by it
-- before the leader is beaten. Returns nil when the class gets no sting.
local function meetTrainerTheme(cls)
if not cls or cls:find("RIVAL") then return nil end
return EVIL_TRAINERS[cls] and "Music_MeetEvilTrainer"
or FEMALE_TRAINERS[cls] and "Music_MeetFemaleTrainer"
or "Music_MeetMaleTrainer"
end
-- Run the pre-battle text -> battle -> won text -> flags sequence.
function OverworldState:engageTrainer(npc, onDone)
local d = npc.def
@@ -2930,6 +2958,20 @@ function OverworldState:engageTrainer(npc, onDone)
local BattleState = require("src.battle.BattleState")
Game.stack:push(TextBox.new(Game, battleText, function()
-- TalkToTrainer (home/trainers.asm:88) prints the before-battle text
-- FIRST and only then runs `call EngageMapTrainer` / `jp
-- StartTrainerBattle`, so a trainer challenged on foot gets the sting
-- over the battle transition rather than under the dialogue. Its
-- `bit BIT_SEEN_BY_TRAINER, [hl] / ret nz` guard is self.engaging
-- here: TrainerEngage (engine/overworld/trainer_sight.asm:224) already
-- started the sting before the "!" bubble on the sight path, so it
-- must not restart. Script-driven challenges (gyms.lua leaders,
-- scripts/SilphCo11F.asm:269 Giovanni, scripts/FightingDojo.asm:122)
-- all `call EngageMapTrainer` too, and reach this same path (#764).
if not self.engaging then
local theme = meetTrainerTheme(d.trainerClass)
if theme then require("src.core.Music").play(Game.data, theme) end
end
local battle = BattleState.newTrainer(Game, d.trainerClass, d.trainerParty)
-- PrintEndBattleText (home/trainers.asm:341) is called from
-- TrainerBattleVictory (engine/battle/core.asm:942), i.e. ON the battle
@@ -3101,29 +3143,15 @@ function OverworldState:checkTrainerSight()
end
end
-- data/trainers/encounter_types.asm
local FEMALE_TRAINERS = {
OPP_LASS = true, OPP_JR_TRAINER_F = true, OPP_BEAUTY = true,
OPP_COOLTRAINER_F = true,
}
local EVIL_TRAINERS = {
OPP_UNUSED_JUGGLER = true, OPP_GAMBLER = true, OPP_ROCKER = true,
OPP_JUGGLER = true, OPP_CHIEF = true, OPP_SCIENTIST = true,
OPP_GIOVANNI = true, OPP_ROCKET = true,
}
function OverworldState:startTrainerApproach(npc, dist)
self.engaging = true
npc.frozen = true
-- the encounter sting (PlayTrainerMusic): evil / female / male by
-- class; rivals and gym leaders keep their own music
local cls = npc.def.trainerClass
if cls and not cls:find("RIVAL") then
local theme = EVIL_TRAINERS[cls] and "Music_MeetEvilTrainer"
or FEMALE_TRAINERS[cls] and "Music_MeetFemaleTrainer"
or "Music_MeetMaleTrainer"
require("src.core.Music").play(Game.data, theme)
end
-- TrainerEngage (engine/overworld/trainer_sight.asm:224) sets
-- BIT_SEEN_BY_TRAINER and calls EngageMapTrainer before the "!" bubble,
-- so the sighting sting starts ahead of the walk-up; engageTrainer sees
-- self.engaging and does not restart it (#764)
local theme = meetTrainerTheme(npc.def.trainerClass)
if theme then require("src.core.Music").play(Game.data, theme) end
local function fight()
self:engageTrainer(npc, function()
npc.frozen = false
@@ -4228,7 +4256,19 @@ function OverworldState:drawWorld()
-- Renderer:beginFrame cleared it, so a battle or a full-screen menu -- which
-- draws with no map beneath it -- stays lit exactly like
-- init_battle_variables.asm's `ld [wMapPalOffset], a` leaves the original.
PaletteFX.setShadeMap(self.dark and PaletteFX.DARK_BGP or nil)
--
-- BATTLE BG "world" is the one case where a map DOES draw in a battle's
-- frame (Game.drawBaseInStack), and the shift armed here reached the
-- battle's own colorize pass, so an un-flashed Rock Tunnel battle came out
-- with FadePal2 over its pics, HUD and text (#773). The battle zeroes
-- wMapPalOffset for its whole run and restores it on the way out
-- (engine/battle/core.asm InitBattleCommon push/pop), so the map behind it
-- goes lit too for as long as the battle is up -- which is what the
-- original's saved offset means.
local battleOverWorld = Game and Game.stack
and Game.worldBgBattleInStack(Game.stack)
PaletteFX.setShadeMap((self.dark and not battleOverWorld)
and PaletteFX.DARK_BGP or nil)
-- advance the water/flower tile animation (runs under dialogs too).
-- TileRenderer.tick uses wall-clock 60Hz steps so display refresh rate
-- does not speed or slow the cycle (issue #4).
@@ -0,0 +1,184 @@
-- Eye check: BATTLE BG = WORLD dims the surround only, never the battle's own
-- 160x144 field (#777, dup #772). Renderer:endFrame used to fill the WHOLE
-- window with the 55% veil; the classic battle hid that under its opaque paper
-- field, but a pipeline that stages the fight on the map and keys the field
-- out (the Dramatic Shape Voxel Mod) got the veil straight onto its sprites
-- and HP boxes. On hardware there is nothing behind the battle to dim at all:
-- _InitBattleCommon calls ClearScreen (pokered home/copy2.asm) over the whole
-- tilemap before the battle draws.
-- POKEPORT_DRIVER=tests/drivers/battle_bg_world_dim_bug777_test.lua POKEPORT_IDENTITY=bug777 POKEPORT_TOUCH=0 SHOT_DIR=/tmp/shots love .
-- POKEPORT_TOUCH=0 matters: the on-screen controls draw after endFrame and
-- would land in the void samples. No POKEPORT_SPEED around the shots.
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local Game = require("src.core.Game")
local Renderer = require("src.render.Renderer")
local Pokemon = require("src.pokemon.Pokemon")
local BattleState = require("src.battle.BattleState")
-- pokered data/maps/objects/Route1.asm puts both youngsters at (5,24) and
-- (15,13) and the sign at (9,27), so the top of the road is empty; the
-- battle is pushed straight in, the cell is only somewhere to stand.
local MAP = "ROUTE_1"
local STAND = { x = 5, y = 6, facing = "down" }
local PARTY = { { "BULBASAUR", 12 }, { "PIDGEOTTO", 18 } }
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
return ok
end
-- Load a captured PNG back as ImageData. love.image cannot read absolute
-- paths, so go through io.open + newFileData.
local function loadShot(path)
local f = io.open(path, "rb")
if not f then return nil end
local bytes = f:read("*a")
f:close()
local ok, img = pcall(function()
return love.image.newImageData(
love.filesystem.newFileData(bytes, "shot.png"))
end)
return ok and img or nil
end
-- Mean brightness of a pixel rect. Whole-region averages, so NPC steps and
-- flower/water animation between two shots wash out instead of flipping a
-- single-pixel compare.
local function regionMean(img, x, y, w, h)
local sum, n = 0, 0
local x2 = math.min(x + w, img:getWidth()) - 1
local y2 = math.min(y + h, img:getHeight()) - 1
for yy = math.max(y, 0), y2 do
for xx = math.max(x, 0), x2 do
local r, g, b = img:getPixel(xx, yy)
sum = sum + (r + g + b) / 3
n = n + 1
end
end
return n > 0 and sum / n or 0, n
end
-- The UI letterbox in framebuffer pixels, the same math endFrame uses for
-- uox/uoy/uvpw/uvph (screenshots are framebuffer-sized, so no dpi divide).
local function uiBox(img)
local pw, ph = img:getWidth(), img:getHeight()
local uiw, uih = Renderer:uiSize()
local Up = Renderer:uiScale()
local bw, bh = math.floor(uiw * Up + 0.5), math.floor(uih * Up + 0.5)
local bx = math.floor((pw - bw) / 2)
local by = math.floor((ph - bh) / 2)
return bx, by, bw, bh, pw, ph
end
game.save.options = game.save.options or {}
game.save.options.battleFit = nil -- classic letterbox, the geometry under test
game.save.party = {}
for _, slot in ipairs(PARTY) do
table.insert(game.save.party, Pokemon.new(game.data, slot[1], slot[2]))
end
U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing)
U.wait(30)
local ow = game.overworld
check("the overworld is up on " .. MAP, ow ~= nil and ow.map.id == MAP)
-- ---- machine half: veil geometry, no battle needed -----------------------
-- In the overworld the UI canvas is transparent over the map, so the veil
-- is not hidden by an opaque field the way the classic battle hides it.
-- Arm the dim through the real per-frame wiring (Game:draw reads
-- Game.worldBgBattleDim every frame) and compare against an unarmed shot:
-- the surround must darken, the letterbox interior must not.
local baseShot = DIR .. "/bug777_0_overworld.png"
local veilShot = DIR .. "/bug777_0_overworld_veiled.png"
U.shot(game, baseShot)
local realDim = Game.worldBgBattleDim
Game.worldBgBattleDim = function() return BattleState.BG_WORLD_DIM end
U.wait(2)
U.shot(game, veilShot)
Game.worldBgBattleDim = realDim
U.wait(2)
local base, veil = loadShot(baseShot), loadShot(veilShot)
if check("both overworld shots decoded", base ~= nil and veil ~= nil) then
local bx, by, bw, bh, pw = uiBox(base)
local inBase = regionMean(base, bx, by, bw, bh)
local inVeil = regionMean(veil, bx, by, bw, bh)
U.log((" letterbox %dx%d at (%d, %d), interior mean %.3f -> %.3f")
:format(bw, bh, bx, by, inBase, inVeil))
-- 0.75 sits between "unchanged" and the 0.55 veil's 0.45x; whole-box
-- means make a frame of tile animation worth far less than that gap.
check("the veil leaves the battle box alone (#777)",
inVeil >= inBase * 0.75)
if bx >= 8 then
local outBase = regionMean(base, 0, 0, bx, base:getHeight())
local outVeil = regionMean(veil, 0, 0, bx, veil:getHeight())
U.log((" left void strip mean %.3f -> %.3f"):format(outBase, outVeil))
check("the veil still dims the surround", outVeil <= outBase * 0.75)
else
U.log(" window has no side voids at this scale, surround check skipped")
end
end
-- ---- the real battle, all three BATTLE BG modes --------------------------
local battle = BattleState.newWild(game, "RATTATA", 5)
battle.onFinish = function() end
ow:pushBattle(battle)
for _ = 1, 400 do
if game.stack:top() == battle and (battle.introSlide or 0) == 0 then break end
U.wait(1)
end
check("the battle reached the screen", game.stack:top() == battle)
for _ = 1, 120 do
if battle.phase == "menu" then break end
U.tap(game, "a")
U.wait(6)
end
check("the battle reached its FIGHT/PKMN/ITEM/RUN menu",
battle.phase == "menu")
-- bgMode reads save.options.battleBg per frame, so one battle covers all
-- three modes; the menu is idle between shots.
local shots = {}
for _, mode in ipairs({ "white", "black", "world" }) do
game.save.options.battleBg = mode
U.wait(5)
local path = DIR .. "/bug777_" .. mode .. ".png"
U.shot(game, path)
shots[mode] = loadShot(path)
end
if check("all three battle shots decoded",
shots.white ~= nil and shots.black ~= nil and shots.world ~= nil) then
local bx, by, bw, bh = uiBox(shots.white)
local inWhite = regionMean(shots.white, bx, by, bw, bh)
local inWorld = regionMean(shots.world, bx, by, bw, bh)
U.log((" battle box mean, WHITE %.3f vs WORLD %.3f"):format(inWhite, inWorld))
check("WORLD leaves the battle's own screen at WHITE's brightness",
math.abs(inWhite - inWorld) < 0.02)
if bx >= 8 then
local outWhite = regionMean(shots.white, 0, 0, bx, shots.white:getHeight())
local outWorld = regionMean(shots.world, 0, 0, bx, shots.world:getHeight())
U.log((" void strip mean, WHITE %.3f vs WORLD %.3f"):format(outWhite, outWorld))
check("WORLD's surround is the dimmed map, not paper",
outWorld < outWhite - 0.05)
end
end
-- ---- over to you ---------------------------------------------------------
U.log("You are at the battle menu with BATTLE BG = WORLD: the frozen Route 1")
U.log("map sits around the battle at 55% brightness. Put bug777_white.png and")
U.log("bug777_world.png side by side: inside the battle box they must match")
U.log("exactly, same paper, same pics, same HP bars; only the frame around it")
U.log("changes. #777 dimmed the whole window instead, which with a mod that")
U.log("stages the fight on the map dropped the veil straight over the sprites")
U.log("and health boxes. The near miss to look for is the opposite failure:")
U.log("a surround that is NOT dimmed at all, which means the veil got lost")
U.log("rather than scoped.")
U.log("Shots: " .. DIR .. "/bug777_*.png")
while true do
coroutine.yield()
end
end
+111
View File
@@ -0,0 +1,111 @@
-- Manual check that Silph Co Giovanni gets the ordinary trainer theme (#782).
-- PlayBattleMusic (audio/play_battle_music.asm) only picks
-- MUSIC_GYM_LEADER_BATTLE when wGymLeaderNo is set, and scripts/SilphCo11F.asm
-- never writes it -- only the eight gym scripts do. The port keyed the boss
-- check on the trainer class alone, so this fight (OPP_GIOVANNI#2) borrowed
-- the Viridian Gym roster's theme, victory jingle, and Pikachu happiness bump.
-- The data half is asserted in tests/parity_battle_music_bug782.lua.
-- POKEPORT_DRIVER=tests/drivers/battle_music_bug782_test.lua POKEPORT_IDENTITY=bug782 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local Pokemon = require("src.pokemon.Pokemon")
local Music = require("src.core.Music")
local BattleState = require("src.battle.BattleState")
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
return ok
end
local vol = game.save.options and game.save.options.musicVol
if vol == 0 then
U.log("music volume is 0 in options; raise it or nothing will be audible")
end
-- record what the engine asks the music system for; the real playback
-- still happens underneath, so the listening half is unaffected
local played = {}
local realPlayBattle = Music.playBattle
Music.playBattle = function(data, kind, trainerId)
played[#played + 1] = { call = "battle", kind = kind }
return realPlayBattle(data, kind, trainerId)
end
local realPlayVictory = Music.playVictory
Music.playVictory = function(data, kind, trainerId)
played[#played + 1] = { call = "victory", kind = kind }
return realPlayVictory(data, kind, trainerId)
end
-- a party that can win this quickly, so the victory jingle is reachable
game.save.party = {
Pokemon.new(game.data, "MEWTWO", 90),
Pokemon.new(game.data, "CHARIZARD", 80),
}
game.save.player.name = "RED"
-- Giovanni's fight is a coordinate trigger, not a talk:
-- SilphCo11FDefaultScript (pokered scripts/SilphCo11F.asm
-- .PlayerCoordsArray) fires on (6,13) or (7,12), walks him three tiles
-- down from his object_event spot at (6,9)
-- (pokered data/maps/objects/SilphCo11F.asm), shows his speech and starts
-- the battle. The (6,13) pad sits behind the 11F card key door, which this
-- teleported-in save has not opened (no CARD KEY, so tryCardKeyDoor never
-- swaps the door block), so take the pad on the open side: stand on (6,12)
-- and step east onto (7,12).
U.teleport(game, "SILPH_CO_11F", 6, 12, "right")
U.wait(10)
U.hold(game, "right", 40)
U.wait(10)
-- Giovanni's approach, then his pre-battle text box: mash A until the
-- battle state is on top of the stack
local battle
for _ = 1, 400 do
local top = game.stack:top()
if getmetatable(top) == BattleState and top.kind == "trainer" then
battle = top
break
end
U.tap(game, "a")
U.wait(3)
end
check("the coordinate trigger engaged a trainer battle", battle ~= nil)
if battle then
check("the opponent is Giovanni (OPP_GIOVANNI#2)",
battle.oppClass == "OPP_GIOVANNI" and battle.partyIndex == 2)
check("musicKind is \"trainer\", not \"gym\"",
battle.musicKind == "trainer")
check("isGymLeader is unset (no Pikachu GYMLEADER happiness bump)",
not battle.isGymLeader)
local battleCall
for _, p in ipairs(played) do
if p.call == "battle" then battleCall = p.kind end
end
check("Music.playBattle was asked for the trainer theme",
battleCall == "trainer")
end
U.log("You are in the Silph Co Giovanni fight. The theme playing now")
U.log("should be the ordinary Vs. Trainer battle music, not the gym-leader")
U.log("theme this fight used to borrow. Win it (MEWTWO 90 vs his level")
U.log("~40 party) and the jingle at \"defeated GIOVANNI\" should be the")
U.log("plain trainer victory fanfare, again not the gym-leader one.")
U.log("For the correct-by-contrast case, the Viridian Gym rematch")
U.log("(OPP_GIOVANNI#3) still keeps the gym-leader theme.")
-- report the victory request when the win lands, then keep idling
local reported = false
while true do
if not reported then
for _, p in ipairs(played) do
if p.call == "victory" then
check("Music.playVictory was asked for the trainer jingle",
p.kind == "trainer")
reported = true
end
end
end
coroutine.yield()
end
end
@@ -0,0 +1,95 @@
-- Real-time check of the credits roll vs Music_Credits (#703).
-- The song is a fixed 5880-frame program (audio/music/credits.asm: tempo 140,
-- no loop), so on hardware it outlasts THE END by ~12s. Our roll ran 135
-- frames short of pokered because Credits:update skipped DisplayCreditsMon's
-- three CreditsCopyTileMapToVRAM calls (each `jp Delay3`, 9 frames per mon
-- screen, 15 mon screens); that stretched the overhang to ~14.3s and made the
-- music look too fast. This driver plays the whole roll in real time (~98s,
-- do NOT set POKEPORT_SPEED: the ear half needs the real clock), counts the
-- fixed frames itself, and leaves the screen on THE END with the theme still
-- going so a listener can judge the tail.
-- SHOT_DIR=/tmp/shots POKEPORT_DRIVER=tests/drivers/credits_overhang_bug703_test.lua POKEPORT_IDENTITY=bug703 POKEPORT_TOUCH=0 love .
return function(game)
local U = dofile("tests/drivers/util.lua")
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
if os.getenv("POKEPORT_SPEED") then
U.log("warning: POKEPORT_SPEED is set; the listening half of this run",
"is meaningless at any speed but 1")
end
local opts = game.save and game.save.options
if opts and opts.musicVolume == 0 then
U.log("warning: options music volume is 0, nothing will be audible")
end
-- the roll ends in an autosave; put the user's save back afterwards
local prevSave = love.filesystem.read("save.lua")
U.teleport(game, "HALL_OF_FAME", 4, 2, "right")
game.save.party = { { species = "PIKACHU", level = 81 } }
game.overworld.runner:run({ { "record_hall_of_fame" } })
U.wait(2)
-- A through the induction until the credits state is on top; the full
-- hall-of-fame walk takes well over 60 taps, so give it real room
local Credits = require("src.ui.Credits")
local credits
for _ = 1, 2000 do
local top = game.stack:top()
if getmetatable(top) == Credits then credits = top break end
U.tap(game, "a")
U.wait(2)
end
if not check("credits state reached", credits ~= nil) then
while true do coroutine.yield() end
end
-- Frame accounting, one fixed step per sample. From the frame Music_Credits
-- starts (phase leaves "white") pokered reaches the end of THE END's fade at
-- 128 + 35 screens + 16 + 20 = 5154 frames; the 15 mon screens each spend
-- 9 frames in mon_prep (the Delay3 x3) before their 27-frame wipe.
-- no screenshots inside this loop: U.shot yields extra fixed steps of its
-- own and would silently skew the count
local musicStart, theEndAt, prepFrames = nil, nil, 0
for f = 1, 7000 do
U.wait(1)
local phase = credits.phase
if not musicStart and phase ~= "white" then musicStart = f end
if phase == "mon_prep" then prepFrames = prepFrames + 1 end
if phase == "end_hold" then theEndAt = f break end
end
check("mon_prep ran 9 frames on each of the 15 mon screens (135 total)",
prepFrames == 135)
check("THE END finishes fading 5154 frames after the music starts",
musicStart ~= nil and theEndAt ~= nil
and theEndAt - musicStart == 5154)
U.log("music started at driver frame", musicStart,
"THE END done at", theEndAt, "mon_prep frames", prepFrames)
U.shot(game, DIR .. "/bug703_the_end.png")
-- Music_Credits is 5880 frames long, so from here the theme has
-- 5880 - 5154 = 726 frames (~12.1s) left. That overhang is authentic:
-- the original does the same on hardware, and this fix only removed the
-- extra 2.2s our shortened roll had added on top of it.
U.log("listen: the theme should keep playing about 12 seconds past this")
U.wait(726)
U.log("the song should be ending right about now; silence after this",
"point is correct, the program has no loop")
U.wait(120)
if prevSave then
love.filesystem.write("save.lua", prevSave)
else
love.filesystem.remove("save.lua")
end
U.log("done; screen stays on THE END (A or B would soft-reset)")
while true do
coroutine.yield()
end
end
+2 -6
View File
@@ -68,14 +68,10 @@ return function(game)
U.wait(4)
-- open the party menu and pick DIG on slot 1
-- (submenu order for a DIG-only mon: STATS / SWITCH / DIG)
-- (submenu order for a DIG-only mon: DIG / STATS / SWITCH -- #768)
Screens.push(game, "PartyMenu")
U.wait(5)
U.tap(game, "a") -- open the per-mon submenu
U.wait(2)
U.tap(game, "down") -- STATS -> SWITCH
U.wait(2)
U.tap(game, "down") -- SWITCH -> DIG
U.tap(game, "a") -- open the per-mon submenu, cursor on DIG
U.wait(2)
U.tap(game, "a") -- choose DIG
U.wait(2)
+3 -6
View File
@@ -38,14 +38,11 @@ return function(game)
U.teleport(game, "PALLET_TOWN", 10, 8, "down")
U.wait(5)
-- open the party menu and pick FLY on slot 1 (submenu: STATS / SWITCH / FLY)
-- open the party menu and pick FLY on slot 1 (submenu: FLY / STATS / SWITCH,
-- field moves on top like start_sub_menus.asm -- #768)
Screens.push(game, "PartyMenu")
U.wait(5)
U.tap(game, "a") -- open the per-mon submenu
U.wait(2)
U.tap(game, "down") -- STATS -> SWITCH
U.wait(2)
U.tap(game, "down") -- SWITCH -> FLY
U.tap(game, "a") -- open the per-mon submenu, cursor on FLY
U.wait(2)
U.tap(game, "a") -- choose FLY
U.wait(5)
+3 -6
View File
@@ -30,14 +30,11 @@ return function(game)
U.teleport(game, "PALLET_TOWN", 10, 8, "down")
U.wait(5)
-- open the party menu and pick FLY on slot 1 (submenu: STATS / SWITCH / FLY)
-- open the party menu and pick FLY on slot 1 (submenu: FLY / STATS / SWITCH,
-- field moves on top like start_sub_menus.asm -- #768)
Screens.push(game, "PartyMenu")
U.wait(5)
U.tap(game, "a") -- open the per-mon submenu
U.wait(2)
U.tap(game, "down") -- STATS -> SWITCH
U.wait(2)
U.tap(game, "down") -- SWITCH -> FLY
U.tap(game, "a") -- open the per-mon submenu, cursor on FLY
U.wait(2)
U.tap(game, "a") -- choose FLY
U.wait(5)
@@ -0,0 +1,76 @@
-- Manual check for #768: the field party menu keeps its cursor across
-- close/reopen (wPartyAndBillsPCSavedMenuItem: PartyMenuInit reads it,
-- HandlePartyMenuInput writes it back, only a battle zeroes it via
-- InitBattleVariables / end_of_battle.asm), and the per-mon submenu lists
-- field moves ABOVE STATS/SWITCH (DisplayFieldMoveMonMenu prints the move
-- names above PokemonMenuEntries, engine/menus/text_box.asm).
-- POKEPORT_DRIVER=tests/drivers/party_cursor_bug768_test.lua POKEPORT_IDENTITY=bug768 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local Pokemon = require("src.pokemon.Pokemon")
local Screens = require("src.ui.Screens")
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
-- Slot 1 knows FLY (movesAtLevel never grants it, so inject it, same as
-- the #203 driver); the badge gates the submenu entry.
local flyer = Pokemon.new(game.data, "PIDGEOT", 40)
flyer.moves[1] = { id = "FLY", pp = 15 }
game.save.party = {
flyer,
Pokemon.new(game.data, "PIKACHU", 30),
Pokemon.new(game.data, "SNORLAX", 77),
}
game.save.player.name = "bryan"
game.save.inventory = game.save.inventory or {}
game.save.inventory.THUNDERBADGE = true
-- Pallet Town is OVERWORLD, so FLY is listed (CheckIfInOutsideMap)
U.teleport(game, "PALLET_TOWN", 10, 8, "down")
U.wait(10)
-- half 1: the submenu puts the field move on top
Screens.push(game, "PartyMenu")
U.wait(5)
local pm = game.stack:top()
U.tap(game, "a") -- open the per-mon submenu on the FLY mon
U.wait(2)
local items = pm.subItems or {}
check("submenu row 1 is FLY, not STATS",
items[1] ~= nil and items[1].action == "fly")
check("STATS/SWITCH close the list under the field move",
#items == 3 and items[2].action == "stats"
and items[3].action == "switch")
U.shot(game, DIR .. "/bug768_submenu.png")
U.tap(game, "b") -- back out of the submenu
U.wait(2)
-- half 2: the cursor survives closing and reopening the menu
U.tap(game, "down")
U.wait(2)
U.tap(game, "down")
U.wait(2)
check("cursor moved to slot 3", pm.index == 3)
U.tap(game, "b") -- close the party menu entirely
U.wait(5)
Screens.push(game, "PartyMenu")
U.wait(5)
local pm2 = game.stack:top()
check("reopened menu is still on slot 3 (SNORLAX)",
pm2 ~= pm and pm2.index == 3)
U.shot(game, DIR .. "/bug768_reopened.png")
U.log("The party menu on screen was just reopened; the cursor should sit")
U.log("on slot 3 (SNORLAX), not slot 1. A on slot 1 shows FLY above")
U.log("STATS/SWITCH. Input is yours now: walk north into the Route 1")
U.log("grass, win or run from a wild battle, then reopen the party menu --")
U.log("the cursor should be back on slot 1 (the battle cleared it).")
while true do
coroutine.yield()
end
end
@@ -0,0 +1,84 @@
-- Driver: BATTLE BG "world" plus an un-flashed Rock Tunnel put a dark map in
-- the same frame as the battle, and OverworldState:drawWorld's rBGP shift then
-- coloured the battle itself (#773). On hardware InitBattleCommon
-- (engine/battle/core.asm) pushes wMapPalOffset, InitBattleVariables zeroes it
-- and core.asm pops it back after EndOfBattle, so the battle is lit.
-- POKEPORT_DRIVER=tests/drivers/rock_tunnel_dark_battle_bug773_test.lua \
-- POKEPORT_IDENTITY=bug773 POKEPORT_TOUCH=0 SHOT_DIR=/tmp/shots love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local Probe = dofile("tests/drivers/shot_probe.lua")
local PaletteFX = require("src.render.PaletteFX")
local BattleState = require("src.battle.BattleState")
local Pokemon = require("src.pokemon.Pokemon")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local fails = 0
local function check(ok, msg)
U.log(ok and "PASS" or "FAIL", msg)
if not ok then fails = fails + 1 end
return ok
end
game.save.flags.EVENT_GOT_STARTER = true
if #game.save.party == 0 then
table.insert(game.save.party, Pokemon.new(game.data, "CHARMANDER", 20))
end
game.save.options = game.save.options or {}
game.save.options.textSpeed = 1
game.save.options.colors = "gbc"
game.save.options.battleBg = "world"
PaletteFX.setMode("gbc")
local CAVE = PaletteFX.pal(game.data, "CAVE")
local caveDark = PaletteFX.permute(CAVE, PaletteFX.DARK_BGP)
-- data/maps/objects/RockTunnel1F.asm: the Route 10 entrance warp is 15, 3,
-- so two cells south of it is floor that does not re-trigger the warp.
game.save.flashLit = nil
U.teleport(game, "ROCK_TUNNEL_1F", 15, 5, "down")
U.wait(20)
local ow = game.overworld
check(ow ~= nil and ow.dark == true, "standing in an un-flashed ROCK_TUNNEL_1F")
U.shot(game, DIR .. "/bug773_1_dark_map.png")
check(PaletteFX.shadeMap() == PaletteFX.DARK_BGP,
"the map frame really is drawn with DARK_BGP armed")
local ok = pcall(function()
local battle = BattleState.newWild(game, "ZUBAT", 15)
battle.onFinish = function() end
game.overworld:pushBattle(battle)
end)
if not ok then
U.log("WARN could not force a wild battle; nothing to judge")
while true do coroutine.yield() end
end
U.wait(120) -- through the transition wipe and the intro slide-in
U.shot(game, DIR .. "/bug773_2_battle_world_bg.png")
check(PaletteFX.shadeMap() == nil,
"no shade map is armed while the world-bg battle draws (#773)")
-- The battle keeps its own 160x144 field in the middle of the window; the
-- dimmed map only fills the surround, so probe the centre.
local CENTRE = { 0.4, 0.4, 0.6, 0.6 }
local shot = Probe.grab()
if shot then
local c = Probe.count(shot, { litPaper = CAVE[1], darkPaper = caveDark[1] },
3, CENTRE)
check(c.litPaper > 0,
"the battle screen keeps its paper white -- it is not FadePal2'd")
local top = Probe.top(shot, 5, 3, CENTRE)
U.log("battle centre top colours:", Probe.fmt(top))
else
U.log("WARN pixel probe unavailable; judge the shots by eye")
end
U.log(fails == 0 and "#773 checks passed" or (fails .. " #773 check(s) FAILED"))
U.log("Look at " .. DIR .. "/bug773_2_battle_world_bg.png: the battle screen")
U.log("should read exactly like any other battle -- white paper, normal HUD")
U.log("and pic colours -- with the dimmed tunnel only in the surround.")
U.log("The separate uniform dim of the world backdrop is #777, not this.")
while true do coroutine.yield() end
end
+148
View File
@@ -0,0 +1,148 @@
-- Manual check that the Surfing Pikachu minigame background is the
-- ROM's metatile scroller, not the old procedural stand-in (#726).
-- The stand-in tiled wave-face foam tiles ($02/$07) over the whole sea
-- and drew the swell as two LOVE ellipses, which read as zigzag noise
-- with white blobs. The fix ports SurfingMinigame_BGMetatileTable, the
-- WavePattern columns and the .WaveFunctions state table from
-- ../pokeyellow/engine/minigame/surfing_pikachu.asm; this driver
-- machine-checks the transcribed tables and the ride heights, then
-- parks a human in front of the water for the part only eyes can judge.
-- Needs a Yellow cache in the active identity (a sandboxed
-- POKEPORT_IDENTITY starts empty and would sit on the launcher).
-- SHOT_DIR=/tmp/shots POKEPORT_VERSION=yellow POKEPORT_TOUCH=0 POKEPORT_DRIVER=tests/drivers/surfing_bg_bug726_test.lua love .
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 SurfingMinigame = require("src.ui.SurfingMinigame")
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
return ok
end
-- table integrity first, it needs no ROM state at all. surf_1a is
-- ripped as 65 tiles (src/import/RomExtractor.lua), so every tile id
-- a metatile names must be 0..64, every pattern entry must name a
-- metatile, and every wave state must name a pattern with plausible
-- ride heights (the asm range is FLAT_WATER_Y $74 down to -6 tiles).
local ok = true
for id, mt in pairs(SurfingMinigame.BG_METATILES) do
for i = 1, 4 do
if type(mt[i]) ~= "number" or mt[i] < 0 or mt[i] > 64 then
U.log("metatile", id, "slot", i, "has bad tile id", tostring(mt[i]))
ok = false
end
end
end
check("every metatile tile id lands inside the 65-tile surf_1a sheet", ok)
ok = true
for id, pat in pairs(SurfingMinigame.WAVE_PATTERNS) do
if #pat ~= 8 then ok = false U.log("pattern", id, "is not 8 rows") end
for i = 1, 8 do
if not SurfingMinigame.BG_METATILES[pat[i]] then
U.log("pattern", id, "row", i, "names missing metatile",
tostring(pat[i]))
ok = false
end
end
end
check("every wave pattern row names a real metatile", ok)
ok = true
for id, step in pairs(SurfingMinigame.WAVE_STEPS) do
if not SurfingMinigame.WAVE_PATTERNS[step[1]] then
U.log("wave state", id, "names missing pattern", tostring(step[1]))
ok = false
end
for i = 2, 3 do
if step[i] < 116 - 6 * 8 or step[i] > 116 then
U.log("wave state", id, "ride height", step[i], "out of range")
ok = false
end
end
end
check("every wave state names a real pattern with sane ride heights", ok)
-- pokeyellow data/maps/objects/SummerBeachHouse.asm: the Surfin' Dude
-- is object_event 2, 3, so (2, 2) facing down talks to him. He only
-- offers the run to a party Pikachu that knows SURF.
game.save.party = { Pokemon.new(game.data, "PIKACHU", 50) }
game.save.party[1].moves = { { id = "SURF", pp = 15 } }
U.teleport(game, "SUMMER_BEACH_HOUSE", 2, 2, "down")
U.wait(5)
-- mash A through the pitch and the YES into the game itself
local mg
for _ = 1, 300 do
local top = game.stack:top()
if top and top.seaY then mg = top break end
U.tap(game, "a")
U.wait(4)
end
check("the minigame opened", mg ~= nil)
if not mg then
U.log("could not reach the minigame; nothing more to show")
while true do coroutine.yield() end
end
-- the run opens on prefilled flat water: every visible column should
-- be the open-water pattern, whose bottom rows are metatile $01
-- (tile $0b everywhere), and Pikachu should sit on the flat waterline
ok = true
for c = 0, 10 do
local col = mg.cols[c]
if not (col and col.pat[8] == 0x01 and col.hl == 116) then ok = false end
end
check("the opening sea is flat open water, pattern 00 all the way", ok)
check("Pikachu's ride line starts on the flat waterline",
mg:seaY(68) == 100)
U.shot(game, DIR .. "/bug726_1_flat.png")
-- paddle up and ride until the generator has rolled some swells; the
-- chooser leaves flat water only on a nonzero roll, so give it room
for _ = 1, 8 do U.tap(game, "a") U.wait(3) end
local sawSwell, seaTracks = false, true
for _ = 1, 1500 do
if mg.phase ~= "ride" and mg.phase ~= "air" then break end
for c, col in pairs(mg.cols) do
if col.hl < 116 then sawSwell = true end
end
-- the ride height must always come from the column under Pikachu
local tile = math.floor((mg.distance + 68) / 8)
local col = mg.cols[math.floor(tile / 2)]
if col then
local want = (tile % 2 == 0 and col.hl or col.hr) - 16
if mg:seaY(68) ~= want then seaTracks = false end
end
if sawSwell and mg.distance > 400 then break end
U.wait(1)
if (U.frame() % 5) == 0 then U.tap(game, "a") end
end
check("the wave generator produced swells (ride heights above flat)",
sawSwell)
check("seaY always follows the generated column heights", seaTracks)
U.shot(game, DIR .. "/bug726_2_swell.png")
-- one jump for the air shot, then hand it over
if mg.phase == "ride" then
U.tap(game, "up")
U.hold(game, "right", 20)
U.shot(game, DIR .. "/bug726_3_air.png")
end
U.log("shots in", DIR, "- bug726_1_flat, bug726_2_swell, bug726_3_air")
U.log("What to look for: the open sea is the flat speckled water tile,")
U.log("not a diagonal zigzag field; no white ellipse blobs and no loose")
U.log("blue squares floating on the surface; swells build from the left,")
U.log("crest with foam and flatten out again; Pikachu sits on the wave")
U.log("at every point of the swell; the HP strip sits in the bottom band")
U.log("over unbroken white. The game is still live: keep riding to the")
U.log("goal and the sand should slide in under the coast-in before the")
U.log("results card.")
while true do
coroutine.yield()
end
end
@@ -0,0 +1,86 @@
-- Manual check that a voluntary switch resets both battle cursors (#737).
-- SendOutMon (pokered engine/battle/core.asm:1733-1735) zeroes
-- wBattleAndStartSavedMenuItem and, via the same hli/hl pair, the
-- wPlayerMoveListIndex byte behind it (wram.asm:242-244), so after any
-- player send-out the main menu reopens on FIGHT and the move list on
-- slot 1. The port used to keep both cursors where they were.
-- SHOT_DIR=/tmp/shots POKEPORT_DRIVER=tests/drivers/switch_cursor_bug737_test.lua love .
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")
-- CHARMANDER at 12 knows Scratch/Growl/Ember, so the move cursor can be
-- parked on slot 3 before the switch
game.save.party = {
Pokemon.new(game.data, "CHARMANDER", 12),
Pokemon.new(game.data, "SQUIRTLE", 10),
}
U.teleport(game, "ROUTE_1", 5, 5, "down")
local ow = game.overworld
local battle = BattleState.newWild(game, "PIDGEY", 4)
battle.onFinish = function() end
ow:pushBattle(battle)
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
return ok
end
local function mashUntil(cond, max)
for _ = 1, max or 120 do
if cond() then return true end
U.tap(game, "a")
U.wait(4)
end
return false
end
check("intro drains to the battle menu",
mashUntil(function() return battle.phase == "menu" end))
-- open FIGHT and park the cursor on move slot 3, then back out
U.tap(game, "a"); U.wait(6)
U.tap(game, "down"); U.wait(4)
U.tap(game, "down"); U.wait(4)
check("move cursor parked on slot 3", battle.moveIndex == 3)
U.tap(game, "b"); U.wait(6)
-- FIGHT/PKMN/ITEM/RUN: right to PKMN, A opens the party
U.tap(game, "right"); U.wait(4)
check("battle menu parked on PKMN", battle.menuIndex == 2)
U.tap(game, "a"); U.wait(12)
local pm = game.stack:top()
check("party menu opened", pm ~= nil and pm.onSwitch ~= nil)
-- pick SQUIRTLE, then SWITCH from the SWITCH/STATS/CANCEL submenu
U.tap(game, "down"); U.wait(4)
U.tap(game, "a"); U.wait(8)
U.tap(game, "a"); U.wait(8)
-- the switch queues "Come back!" / "Go!" plus the enemy's free move;
-- drain back to the next command menu
check("switch turn drains back to the menu",
mashUntil(function() return battle.phase == "menu" end, 300))
check("player is now SQUIRTLE", battle.player.mon.species == "SQUIRTLE")
-- the machine-checkable half of #737
check("battle menu is back on FIGHT", battle.menuIndex == 1)
check("move cursor is back on slot 1", battle.moveIndex == 1)
U.shot(game, DIR .. "/bug737_menu_after_switch.png")
U.tap(game, "a"); U.wait(8)
U.shot(game, DIR .. "/bug737_moves_after_switch.png")
U.log("captured", DIR .. "/bug737_menu_after_switch.png",
"and", DIR .. "/bug737_moves_after_switch.png")
U.log("The fight menu on screen is SQUIRTLE's, opened right after the")
U.log("switch. The cursor should sit on the first move; before #737 it")
U.log("kept CHARMANDER's old slot (3), and the main menu reopened on PKMN.")
while true do
coroutine.yield()
end
end
+161
View File
@@ -0,0 +1,161 @@
-- Manual check for the pages that must NOT wait on a button (#765).
-- Only TX_PROMPT_BUTTON blinks the arrow and waits (home/text.asm:434-446);
-- the used-move line (engine/battle/used_move_text.asm) ends in `text_end`
-- and both save pages come from SaveMenu .save (engine/menus/save.asm:164-181),
-- where "Now saving..." is a bare PlaceString + DelayFrames 120 and
-- GameSavedText ends in `done`. Ordering is asserted headlessly in
-- tests/parity_battle_auto_text_bug765.lua; this run is for pacing.
-- No POKEPORT_SPEED: the save beat and the SFX_SAVE hold are the moment.
-- POKEPORT_DRIVER=tests/drivers/text_advance_bug765_test.lua POKEPORT_IDENTITY=bug765 POKEPORT_TOUCH=0 love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local Pokemon = require("src.pokemon.Pokemon")
local BattleState = require("src.battle.BattleState")
local TextBox = require("src.render.TextBox")
local Menu = require("src.ui.Menu")
local ChoiceBox = require("src.ui.ChoiceBox")
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
return ok
end
game.save.party = { Pokemon.new(game.data, "BULBASAUR", 50) }
game.save.player.name = "RED"
U.teleport(game, "PALLET_TOWN", 10, 8, "down")
U.wait(10)
-- ---- part 1: START -> SAVE -> YES, hands off the pad -------------------
U.tap(game, "start")
U.wait(10)
local menu = game.stack:top()
check("START opened the menu", getmetatable(menu) == Menu)
if getmetatable(menu) == Menu then
-- walk the cursor onto SAVE by label: which rows exist depends on
-- story flags, so counting from the top is not stable
local target
for i, item in ipairs(menu.items) do
if tostring(item.label) == "SAVE" then target = i end
end
check("the menu lists SAVE", target ~= nil)
-- the cursor position survives closing the menu
-- (wBattleAndStartSavedMenuItem), so it may start above OR below SAVE
for _ = 1, #menu.items do
if not target or menu.index == target then break end
U.tap(game, menu.index < target and "down" or "up")
U.wait(4)
end
U.tap(game, "a")
U.wait(10)
end
-- the player/badges/dex/time panel types out, page-breaks (\f) into the
-- confirmation, then the YES/NO box opens; A through all of it, YES last
local chose = false
for _ = 1, 30 do
local top = game.stack:top()
if getmetatable(top) == ChoiceBox then
U.tap(game, "a")
chose = true
break
end
U.tap(game, "a")
U.wait(20)
end
check("the SAVE confirmation was reached and answered YES", chose)
-- the answered box holds 15 frames before it pops and runs the choice
-- (DisplayTwoOptionMenu, engine/menus/text_box.asm:322-334), so wait it out
for _ = 1, 60 do
if getmetatable(game.stack:top()) ~= ChoiceBox then break end
U.wait(1)
end
-- from here NOTHING is pressed: both boxes must clear themselves
U.wait(2)
local saving = game.stack:top()
check("the Now saving... box is up", getmetatable(saving) == TextBox)
local savingPopped
for f = 1, 300 do
if game.stack:top() ~= saving then savingPopped = f break end
U.wait(1)
end
check("it held about 2s and popped with no button (DelayFrames 120)",
savingPopped ~= nil and savingPopped > 60)
local savedPopped = false
for _ = 1, 600 do
local top = game.stack:top()
if getmetatable(top) ~= TextBox and getmetatable(top) ~= Menu then
savedPopped = true
break
end
U.wait(1)
end
check("the saved-the-game box cleared itself after SFX_SAVE", savedPopped)
-- ---- part 2: a wild battle's used-move line ----------------------------
local wild = BattleState.newWild(game, "RATTATA", 2)
wild.onFinish = function() end
local ow = game.overworld
if ow then ow:pushBattle(wild) end
for _ = 1, 400 do
if game.stack:top() == wild and (wild.introSlide or 0) == 0 then break end
U.wait(1)
end
check("the wild battle reached the screen", game.stack:top() == wild)
-- the intro page ends in `prompt` (WildMonAppearedText), so it still
-- waits; A through it and the send-out, then pick FIGHT + first move
for _ = 1, 600 do
if wild.phase == "menu" then break end
if wild.msgPrompt then U.tap(game, "a") end
U.wait(1)
end
check("the intro still holds on its arrow and A walks it to the menu",
wild.phase == "menu")
U.tap(game, "a") -- FIGHT
U.wait(10)
U.tap(game, "a") -- first move
-- from here NOTHING is pressed: "BULBASAUR used X!" must flow straight
-- into its animation with the line still on screen and no arrow
local sawUsed, promptedOnUsed, handedOff = false, false, false
for _ = 1, 900 do
local cur = wild.current
local t = cur and cur.text
if t and t:find("used", 1, true) then
sawUsed = true
if wild.msgPrompt then promptedOnUsed = true end
elseif sawUsed then
handedOff = true
break
end
U.wait(1)
end
check("the used-move line reached the screen", sawUsed)
check("it never raised the prompt arrow", not promptedOnUsed)
check("it handed off by itself, no A pressed", handedOff)
check("the line stays drawn under the animation (msgHold)",
wild.msgHold == true or wild.animPlaying == true)
-- ...and the pages after it still wait: a level-50 BULBASAUR one-shots a
-- level-2 RATTATA, so the faint line (BattleMonFaintedText class, ends in
-- `prompt`) comes up next and must hold on its arrow
local promptAfter = false
for _ = 1, 900 do
if wild.msgPrompt then promptAfter = true break end
U.wait(1)
end
check("the page after it still holds on the arrow", promptAfter)
U.log("Handing off. What just happened, and what to look for on a replay:")
U.log("the save flow ran with no button after YES: \"Now saving...\" held")
U.log("about 2 seconds, then \"RED saved the game!\" played the save jingle")
U.log("and cleared itself. In the battle, \"BULBASAUR used <MOVE>!\" flowed")
U.log("straight into the move animation with the line still up and no")
U.log("blinking arrow; the faint line after it is waiting on A right now.")
while true do
coroutine.yield()
end
end
+152
View File
@@ -0,0 +1,152 @@
-- Manual check that the trade cinematic draws real art, not rectangles (#750).
-- The ROM importer never wrote assets/generated/trade/*, so on a player's
-- cache every tryImage in TradeAnim.new returned nil and the whole
-- InternalClockTradeAnim sequence fell back to love.graphics.rectangle:
-- an outlined box for the Game Boy, a flat bar for the cable, a blank
-- screen during the open-cable phase. The machine half below asserts the
-- ten art files are in the cache at the sizes trade.asm implies and that
-- the running TradeAnim actually loaded them; the shots are for the human
-- half. A cache imported before this fix re-imports on launch (the
-- REQUIRED_FILES entry), so a FAIL on the file checks means the re-import
-- has not happened yet.
-- SHOT_DIR=/tmp/trade750 POKEPORT_DRIVER=tests/drivers/trade_anim_bug750_test.lua POKEPORT_IDENTITY=bug750 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/trade750"
local Pokemon = require("src.pokemon.Pokemon")
local TradeAnim = require("src.ui.TradeAnim")
local TextBox = require("src.render.TextBox")
local PartyMenu = require("src.ui.PartyMenu")
local ChoiceBox = require("src.ui.ChoiceBox")
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
return ok
end
-- Expected sizes: GameBoyTiles is a 6x8-tile plate and LinkCableTiles a
-- 12x3 one (pokered data/tilemaps.asm), Trade_DrawCableAcrossScreen fills
-- a 20-tile row, the ball and its bulge frame are one tile mirrored into
-- 2x2 OAM blocks, and TradeBubbleIconGFX is two 16x16 quadrant frames.
local FILES = {
{ "assets/generated/trade/game_boy.png", 48, 64 },
{ "assets/generated/trade/open_cable.png", 96, 24 },
{ "assets/generated/trade/cable_horiz.png", 160, 8 },
{ "assets/generated/trade/cable_conn.png", 8, 8 },
{ "assets/generated/trade/cable_seg.png", 8, 8 },
{ "assets/generated/trade/cable_corner.png", 8, 8 },
{ "assets/generated/trade/cable_end.png", 8, 8 },
{ "assets/generated/trade/cable_vert.png", 8, 8 },
{ "assets/generated/trade/cable_ball.png", 16, 16 },
{ "assets/generated/trade/cable_ball_alt.png", 16, 16 },
{ "assets/generated/trade/bubble.png", 16, 32 },
}
for _, spec in ipairs(FILES) do
local ok, image = pcall(love.graphics.newImage, spec[1])
if check(spec[1] .. " is in the cache", ok and image ~= nil) then
local w, h = image:getDimensions()
check(("%s is %dx%d"):format(spec[1], spec[2], spec[3]),
w == spec[2] and h == spec[3])
end
end
check("field.lua carries tradeArt paths",
game.data.field and game.data.field.tradeArt
and game.data.field.tradeArt.gameBoy ~= nil)
local function topIs(cls)
return getmetatable(game.stack:top()) == cls
end
-- pokered data/maps/objects/VermilionTradeHouse.asm: the LITTLE_GIRL
-- (SPEAROW -> DUX FARFETCH'D) stands at (3, 5) facing up, so (3, 6)
-- facing up puts the player in front of her.
game.save.party = { Pokemon.new(game.data, "SPEAROW", 10) }
U.teleport(game, "VERMILION_TRADE_HOUSE", 3, 6, "up")
U.wait(5)
U.tap(game, "a")
U.wait(10)
for _ = 1, 200 do
if topIs(ChoiceBox) then break end
U.tap(game, "a")
U.wait(2)
end
check("trade offer choice appeared", topIs(ChoiceBox))
U.tap(game, "a") -- YES
U.wait(6)
for _ = 1, 60 do
if topIs(PartyMenu) then break end
U.wait(1)
end
check("party menu opened", topIs(PartyMenu))
U.tap(game, "a") -- pick the SPEAROW
U.wait(6)
for _ = 1, 200 do
if topIs(TradeAnim) then break end
U.tap(game, "a")
U.wait(2)
end
local anim = game.stack:top()
if not check("TradeAnim is on the stack", getmetatable(anim) == TradeAnim) then
U.log("cannot reach the cinematic; nothing more to verify")
while true do coroutine.yield() end
end
-- the running state loaded the art rather than falling back
check("TradeAnim loaded the Game Boy plate", anim.img.gameBoy ~= nil)
check("TradeAnim loaded the open cable plate", anim.img.openCable ~= nil)
check("TradeAnim loaded the cable ball", anim.img.cableBall ~= nil)
check("TradeAnim loaded the bubble ring", anim.img.bubble ~= nil)
-- step the phases at real speed and shoot the moments the reporter's
-- video shows broken
local function ffUntil(phase, cap)
for _ = 1, cap or 3000 do
if anim.phase == phase or anim.phase == "done" then break end
if anim.waitingText or topIs(TextBox) then
U.wait(1)
else
anim:update(1 / 60)
end
end
U.wait(1)
end
ffUntil("open_cable", 800)
while anim.phase == "open_cable" and anim.scx > 0 do
anim:update(1 / 60)
end
U.wait(1)
U.shot(game, DIR .. "/bug750_open_cable.png")
ffUntil("ball_enter", 200)
while anim.phase == "ball_enter" and anim.ballX < 0x80 do
anim:update(1 / 60)
end
U.wait(1)
U.shot(game, DIR .. "/bug750_ball_enter.png")
ffUntil("transfer_lr", 400)
for _ = 1, 24 do anim:update(1 / 60) end
U.wait(1)
U.shot(game, DIR .. "/bug750_transfer_lr.png")
ffUntil("transfer_rl", 4000)
for _ = 1, 140 do
if anim.phase ~= "transfer_rl" then break end
anim:update(1 / 60)
end
U.wait(1)
U.shot(game, DIR .. "/bug750_transfer_rl.png")
U.log("shots in", DIR)
U.log("open_cable should be the link cable plate with its open end, not a")
U.log("blank screen; ball_enter a small ball riding the cable; the two")
U.log("transfer shots a real Game Boy body with the cable plugged in and")
U.log("the mon's 16x16 party icon inside a round 32x32 ring -- no outlined")
U.log("rectangles, no flat gray bar, no squashed battle pic.")
while true do
coroutine.yield()
end
end
@@ -0,0 +1,89 @@
-- Manual check that challenging a trainer by talking to them plays the
-- encounter sting (#764). TalkToTrainer (pokered home/trainers.asm:88)
-- prints the before-battle text and then EngageMapTrainer ->
-- PlayTrainerMusic; the port only did that on the sight-line path, so a
-- trainer approached from the side or back went into battle in map music.
-- POKEPORT_DRIVER=tests/drivers/trainer_fanfare_bug764_test.lua POKEPORT_IDENTITY=bug764 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
return function(game)
local U = dofile("tests/drivers/util.lua")
-- pokered data/maps/objects/ViridianForest.asm: YOUNGSTER2 (the first
-- Bug Catcher) stands at (30, 33) facing LEFT, so his sight line runs
-- west; the cell below him, (30, 34), is outside it and lets us talk
-- our way into the battle instead of being spotted.
local MAP = "VIRIDIAN_FOREST"
local TRAINER = "VIRIDIANFOREST_YOUNGSTER2"
local STAND = { x = 30, y = 34, facing = "up" }
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
return ok
end
-- record every song the engine starts; wrapping keeps real playback so
-- the human half of this check still has something to hear
local Music = require("src.core.Music")
local played = {}
local realPlay = Music.play
Music.play = function(data, song, ...)
played[#played + 1] = song
return realPlay(data, song, ...)
end
U.newGame(game)
check("music volume is audible (save.options.musicVol)",
(game.save.options.musicVol or 0) > 0)
U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing)
U.wait(30)
local ow = game.overworld
local npc
for _, n in ipairs(ow and ow.npcs or {}) do
if n.def and n.def.name == TRAINER then npc = n end
end
check("Bug Catcher object loaded on " .. MAP, npc ~= nil)
if npc then
check("standing on his blind side, facing him",
ow:npcAtCell(ow.player:facingCell()) == npc)
check("he did not spot us on the way in", not ow.engaging)
end
-- talk; the sting must start only once the before-battle text closes
-- (TalkToTrainer prints first, then engages)
played = {}
U.tap(game, "a")
U.wait(30)
check("no sting while the dialogue is up", #played == 0)
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
U.shot(game, SHOT_DIR .. "/bug764_dialogue.png")
-- close the text; a Bug Catcher is neither female-list nor evil-list,
-- so PlayTrainerMusic lands on the male sting. A presses both finish
-- the typewriter and turn pages, so keep tapping until the sting lands
-- or the drain gives up.
local sting
for _ = 1, 8 do
U.tap(game, "a")
U.wait(30)
for _, song in ipairs(played) do
if song:find("Music_Meet", 1, true) then sting = song end
end
if sting then break end
end
check("closing the text started an encounter sting", sting ~= nil)
check("it is the male trainer sting", sting == "Music_MeetMaleTrainer")
U.shot(game, SHOT_DIR .. "/bug764_transition.png")
U.log("songs started since the A press:", table.concat(played, ", "))
U.log("The Bug Catcher's line has just closed and the battle is opening.")
U.log("You should have heard the male trainer sting begin the moment the")
U.log("text box shut, carrying over the battle transition. Before #764")
U.log("the forest theme played straight through into the fight. To hear")
U.log("the sight path for comparison, lose or run, step west across his")
U.log("eyeline, and the same sting should fire once at the \"!\" bubble.")
while true do
coroutine.yield()
end
end
@@ -0,0 +1,81 @@
-- Manual check of the Viridian fisher's TM42 gift pre text (#775).
-- pokered ViridianCityFisherText (scripts/ViridianCity.asm) prints
-- .YouCanHaveThisText ("Yawn! I must have dozed off...") before GiveItem;
-- the port had no pre entry, so A jumped straight to "received TM42!".
-- POKEPORT_DRIVER=tests/drivers/viridian_fisher_bug775_test.lua POKEPORT_IDENTITY=bug775 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
return function(game)
local U = dofile("tests/drivers/util.lua")
-- pokered data/maps/objects/ViridianCity.asm: the FISHER stays at (6, 23)
-- facing down, so stand one cell below him and look up
local MAP = "VIRIDIAN_CITY"
local STAND = { x = 6, y = 24, facing = "up" }
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
return ok
end
U.newGame(game)
U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing)
U.wait(10)
local TextBox = require("src.render.TextBox")
local function boxText()
local top = game.stack:top()
if getmetatable(top) ~= TextBox then return nil end
local lines = {}
for _, page in ipairs(top.pages or {}) do
for _, line in ipairs(page) do lines[#lines + 1] = line end
end
return table.concat(lines, " / ")
end
check("no TM42 in the bag before talking",
(game.save.inventory.TM_DREAM_EATER or 0) == 0)
U.tap(game, "a")
U.wait(30)
local first = boxText()
check("pressing A opened a text box", first ~= nil)
U.log("first box reads:", first or "(none)")
check("it opens on the pre text, not the receipt",
first ~= nil and first:find("Yawn!", 1, true) ~= nil)
check("the DROWZEE dream paragraph is in it",
first ~= nil and first:find("DROWZEE", 1, true) ~= nil)
check("the receipt has not fired yet",
first == nil or first:find("received", 1, true) == nil)
check("the flag is still unset mid pre text",
not game.save.flags.EVENT_GOT_TM42)
U.shot(game, SHOT_DIR .. "/bug775_pre.png")
-- type out and dismiss every page of the pre text, then the receipt and
-- the explanation behind it
for _ = 1, 40 do
if not boxText() then break end
U.tap(game, "a")
U.wait(15)
end
check("TM42 reached the bag", (game.save.inventory.TM_DREAM_EATER or 0) == 1)
check("EVENT_GOT_TM42 is set", game.save.flags.EVENT_GOT_TM42 == true)
-- second talk: the flag routes to the DREAM EATER explanation, no re-gift
U.tap(game, "a")
U.wait(30)
local again = boxText()
U.log("second talk reads:", again or "(none)")
check("a second talk shows the explanation, not Yawn! again",
again ~= nil and again:find("Yawn!", 1, true) == nil)
U.shot(game, SHOT_DIR .. "/bug775_repeat.png")
U.log("The screen is on the fisher's repeat-visit line now. The first")
U.log("talk should have read three pages: Yawn / the DROWZEE dream /")
U.log("\"Here, you can have this TM.\", and only then the TM42 receipt.")
U.log("Shots are in " .. SHOT_DIR .. " as bug775_pre.png / bug775_repeat.png.")
while true do
coroutine.yield()
end
end
+13
View File
@@ -75,6 +75,19 @@ T.eq(Game.worldBgBattleDim(stack(overworld, battleBg("world"), partyMenu)),
T.eq(Game.worldBgBattleDim(stack(overworld)), nil, "no battle, no dim")
T.eq(Game.worldBgBattleDim(nil), nil, "and no stack is safe")
-- #773: the same walk decides whether the dark-cave shade shift may be armed
-- for this frame. A battle zeroes wMapPalOffset (init_battle_variables.asm),
-- so a world-bg battle over an un-flashed Rock Tunnel must suppress it.
T.check(Game.worldBgBattleInStack(stack(overworld, battleBg("world"))),
"a world-bg battle claims the frame, so the dark shift stays off it")
T.check(not Game.worldBgBattleInStack(stack(overworld, battleBg("white"))),
"a white-bg battle draws with no map under it and claims nothing")
T.check(Game.worldBgBattleInStack(stack(overworld, battleBg("world"), partyMenu)),
"a menu opened over the world-bg battle does not hand the shift back")
T.check(not Game.worldBgBattleInStack(stack(overworld)),
"a plain dark map still arms it")
T.check(not Game.worldBgBattleInStack(nil), "and no stack is safe")
T.check(BattleState.BG_WORLD_DIM > 0 and BattleState.BG_WORLD_DIM < 1,
"the dim is a fraction, not a full blackout")
@@ -0,0 +1,117 @@
-- #774: packager archive checks must never pipe an `unzip -Z1` listing
-- straight into `grep -q`. grep -q exits on the first match, unzip takes
-- SIGPIPE (141), and under the scripts' `set -o pipefail` the pipeline
-- reports 141 -- so an `if` guard reads a real match as "no match". For
-- build_android.sh's generated-data guard that failed open on exactly the
-- archive it exists to reject (one carrying the user's extracted ROM
-- data). The fix everywhere is to capture the listing once and grep the
-- captured text (build.sh, pack_love.sh, build_ios.sh, build_android.sh
-- all do); this suite keeps the class of bug from regressing. The
-- `unzip -p ... Version.lua | grep` readbacks are fine -- a 1.4 KB single
-- write fits the pipe buffer, so unzip returns before grep can close the
-- read end -- and the scan below deliberately matches only -Z1 listings.
-- Self-contained: luajit tests/engine/build_zip_pipe_guard_bug774.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check = T.check
local function readFile(path)
local f = io.open(path, "rb")
if not f then return nil end
local body = f:read("*a")
f:close()
return body
end
local function listShellFiles()
local out = {}
local p = io.popen('find scripts -name "*.sh" -type f')
if not p then return out end
for line in p:lines() do
out[#out + 1] = line
end
p:close()
table.sort(out)
return out
end
-- ------------------------------------------------------------- static scan
-- Join backslash continuations first: the buggy form in build_android.sh
-- spread the pipeline across two lines.
local scripts = listShellFiles()
check(#scripts > 0, "found shell scripts under scripts/ to scan")
local violations = {}
for _, file in ipairs(scripts) do
local body = readFile(file)
if body then
local joined = body:gsub("\\\n%s*", " ")
if joined:find("unzip %-Z1[^\n|]*|%s*grep %-%a*q") then
violations[#violations + 1] = file
end
end
end
check(#violations == 0,
"no script pipes an unzip -Z1 listing into grep -q (#774: pipefail turns"
.. " the SIGPIPE into an inverted guard)"
.. (#violations > 0 and (":\n " .. table.concat(violations, "\n ")) or ""))
-- --------------------------------------------------------- replay the guard
-- Run build_android.sh's own forbidden-content pattern, in the captured
-- form the script now uses, over a throwaway archive that really does
-- carry a data/generated entry, and over a clean one. This pins the
-- capture-then-grep idiom's behavior rather than trusting the scan alone.
local androidBody = readFile("scripts/build_android.sh") or ""
local pattern = androidBody:match("grep %-Eq '([^']*generated[^']*)'")
check(pattern ~= nil,
"build_android.sh still greps a generated-data pattern over the listing")
local function haveCommand(name)
local probe = io.popen("command -v " .. name .. " 2>/dev/null")
if not probe then return false end
local out = probe:read("*a")
probe:close()
return out ~= nil and out:match("%S") ~= nil
end
if pattern and haveCommand("zip") and haveCommand("unzip")
and haveCommand("bash") then
local tmpDir = (os.getenv("TMPDIR") or "/tmp"):gsub("[/\\]+$", "")
local stage = ("%s/pokeport_bug774_%d_%d"):format(
tmpDir, os.time(), math.random(1, 999999))
os.execute(('mkdir -p "%s/pay/data/generated"'):format(stage))
os.execute(('touch "%s/pay/data/generated/x.lua" "%s/pay/main.lua"')
:format(stage, stage))
os.execute(('cd "%s/pay" && zip -qr ../bad.love .'):format(stage))
os.execute(('cd "%s/pay" && zip -qr ../clean.love main.lua'):format(stage))
local function guardVerdict(archive)
-- exactly the script's shape: pipefail on, listing captured once,
-- grep runs over the captured text so nothing can take a SIGPIPE
local cmd = ("bash -c 'set -euo pipefail\n"
.. 'archive_entries="$(unzip -Z1 "%s")"\n'
.. "if grep -Eq '\\''%s'\\'' <<< \"$archive_entries\"; then"
.. " echo CAUGHT; else echo CLEAN; fi' 2>/dev/null"):format(
archive, pattern)
local p = io.popen(cmd)
if not p then return nil end
local out = p:read("*a") or ""
p:close()
return out:match("%S+")
end
check(guardVerdict(stage .. "/bad.love") == "CAUGHT",
"the captured-listing guard rejects an archive carrying data/generated"
.. " (#774: the piped form let this ship in an APK)")
check(guardVerdict(stage .. "/clean.love") == "CLEAN",
"the captured-listing guard passes an archive without generated data")
os.execute(('rm -rf "%s"'):format(stage))
else
print("[#774] zip/unzip/bash not all present: guard replay skipped,"
.. " the static scan above still ran")
end
T.finish()
+2 -2
View File
@@ -1,8 +1,8 @@
-- Launcher Delete affordance (src/import/RomImporter.lua): the two-click arm
-- that guards both save-slot and mod deletes (#433). Every Delete control in
-- the FlexLove view routes through RomImporter:pressDelete, and every other
-- queued action clears self._confirmDelete (LauncherView's queueAction), so
-- the guarantees live on this seam: the first press only arms, the second
-- queued action clears self._confirmDelete (RomImporter:runActions as the
-- batch drains, #780), so the guarantees live on this seam: the first press only arms, the second
-- press on the SAME target commits, any other target or a cleared arm asks
-- again, and a stale arm expires instead of committing much later.
-- luajit tests/engine/launcher_delete_confirm.lua
@@ -0,0 +1,102 @@
-- Launcher action drain (src/import/RomImporter.lua): on a phone one tap
-- lands on a save row AND on the chip drawn inside it, because FlexLove's
-- touch path has no topmost gate (EventHandler:processTouchEvents) while its
-- mouse path does. The drain therefore drops a row's own action when a
-- control inside that row fired in the same batch, and applies #433's disarm
-- as it runs each action rather than as the view queues them -- otherwise the
-- row's select cleared the arm the same tap had set and Delete never reached
-- its second press (#780).
-- luajit tests/engine/launcher_touch_dispatch_bug780.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
love = love or require("tests.love_stub")
local clock = 1000
love.timer.getTime = function() return clock end
local RomImporter = require("src.import.RomImporter")
local function launcher()
local self = setmetatable({}, RomImporter)
self.ran = {}
return self
end
local function tapDeleteChip(self, rowKey)
-- what one Android tap on a row's Delete chip queues: the row itself, then
-- the chip drawn on top of it
return {
{ key = rowKey, keepArm = false, fn = function()
table.insert(self.ran, "select")
end },
{ key = rowKey .. "-del", keepArm = true, fn = function()
table.insert(self.ran, "delete")
self:pressDelete("slot", "slot2", "red", function()
table.insert(self.ran, "deleted")
end)
end },
}
end
-- ------- the chip wins the tap, and two taps delete
do
local self = launcher()
self:runActions(tapDeleteChip(self, "slot-red-slot2"))
eq(self.ran[1], "delete", "the chip's action runs, not the row's select")
eq(#self.ran, 1, "the row behind the chip is dropped from the batch")
check(self._confirmDelete ~= nil, "the first tap leaves Delete armed")
self:runActions(tapDeleteChip(self, "slot-red-slot2"))
eq(self.ran[3], "deleted", "the second tap on the same chip commits")
eq(self._confirmDelete, nil, "the arm is spent")
end
-- ------- a tap on the row itself still selects, and still disarms
do
local self = launcher()
self:pressDelete("slot", "slot2", "red", function() end)
check(self._confirmDelete ~= nil, "armed")
self:runActions({
{ key = "slot-red-slot2", keepArm = false, fn = function()
table.insert(self.ran, "select")
end },
})
eq(self.ran[1], "select", "a tap on empty row area selects the slot")
eq(self._confirmDelete, nil, "and disarms the pending Delete (#433)")
end
-- ------- a sibling row's chip does not swallow another row
do
local self = launcher()
self:runActions({
{ key = "slot-red-slot1", keepArm = false, fn = function()
table.insert(self.ran, "row1")
end },
{ key = "slot-red-slot10-del", keepArm = true, fn = function()
table.insert(self.ran, "del10")
end },
})
eq(self.ran[1], "row1", "slot1 is not a prefix-key parent of slot10")
eq(self.ran[2], "del10", "and slot10's chip still runs")
end
-- ------- a failing action does not sink the rest of the batch
do
local self = launcher()
self:runActions({
{ key = "a", keepArm = false, fn = function() error("boom") end },
{ key = "b", keepArm = false, fn = function()
table.insert(self.ran, "b")
end },
})
eq(self.ran[1], "b", "the queue drains past a handler that threw")
end
print("launcher touch dispatch (#780) ok")
@@ -0,0 +1,92 @@
-- Portable-mode save export (#752): with portable.txt beside the game the
-- launcher's Export save must land in the game folder, never in the OS save
-- directory LOVE hands out. SaveConvert is stubbed so this stays ROM-free;
-- what is under test is only which filesystem SaveFileIO.exportActiveSlot
-- writes through and which root the returned path reports.
-- luajit tests/engine/save_export_portable_bug752.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
love = love or require("tests.love_stub")
local SEP = package.config:sub(1, 1)
local tmp = os.getenv("TMPDIR") or os.getenv("TEMP") or "/tmp"
tmp = tmp:gsub("[/\\]$", "")
local base = tmp .. SEP .. "pokeport752"
if SEP == "\\" then
os.execute('rmdir /s /q "' .. base .. '" 2>nul')
os.execute('mkdir "' .. base .. '" 2>nul')
else
os.execute('rm -rf "' .. base .. '" && mkdir -p "' .. base .. '"')
end
local marker = io.open(base .. SEP .. "portable.txt", "wb")
check(marker ~= nil, "the temp portable folder is writable")
if not marker then T.finish("save_export_portable_bug752") return end
marker:write("portable\n")
marker:close()
-- The love surface portable detection reads: a desktop OS plus a source
-- folder holding the marker (SaveData.gameFolders). A memfs stands in for
-- the OS save directory so a stray write there is visible in the test rather
-- than silently landing on the real machine.
local strayFiles = {}
love.system = love.system or {}
love.system.getOS = function() return "Linux" end
love.filesystem = {
getSource = function() return base end,
getSourceBaseDirectory = function() return base end,
getSaveDirectory = function() return "/fake/save" end,
getInfo = function() return nil end,
read = function() return nil end,
write = function(path, content) strayFiles[path] = content return true end,
remove = function() return true end,
createDirectory = function() return true end,
}
-- SaveConvert stubbed before SaveFileIO requires it: the codec needs
-- data/generated/ crosswalks, and the byte content is irrelevant here.
package.loaded["src.save_convert.SaveConvert"] = {
SAVE_SIZE = 32768,
exportSav = function() return string.rep("\0", 32768) end,
importSav = function() return nil, "not used here" end,
}
local SaveData = require("src.core.SaveData")
local GameVersion = require("src.core.GameVersion")
GameVersion.set("red")
check(SaveData.isPortable(), "portable.txt beside the game turns portable mode on")
local slotId = SaveData.createSlot("red")
check(slotId ~= nil, "a slot registers in the portable folder")
SaveData.setActiveSlot("red", slotId)
check(SaveData.writeSlot("red", slotId, SaveData.newGame()), "the slot writes")
local SaveFileIO = require("src.import.SaveFileIO")
local ok, path = SaveFileIO.exportActiveSlot("red")
eq(ok, true, "exportActiveSlot succeeds in portable mode")
local expected = base .. SEP .. "exports" .. SEP .. "red" .. SEP
.. "gen1recomp-red-" .. tostring(slotId) .. ".sav"
eq(path, expected, "the reported path is inside the portable game folder")
local f = io.open(expected, "rb")
check(f ~= nil, "the export file exists in the portable exports/ folder")
if f then
local bytes = f:read("*a")
f:close()
eq(#bytes, 32768, "the export is exactly 32768 bytes")
end
for name in pairs(strayFiles) do
check(not name:find("^exports"),
"no export leaked into the OS save directory: " .. name)
end
if SEP == "\\" then
os.execute('rmdir /s /q "' .. base .. '" 2>nul')
else
os.execute('rm -rf "' .. base .. '"')
end
T.finish("save_export_portable_bug752")
+98
View File
@@ -0,0 +1,98 @@
-- The trade cinematic's Game Boy / cable / ball / bubble art must come out
-- of the ROM importer, not just the developer-only Python path (#750).
-- RomExtractor:extractTradeArt reads five symbols -- gfx/trade.asm
-- TradingAnimationGraphics(+2), engine/gfx/mon_icons.asm TradeBubbleIconGFX,
-- and the data/tilemaps.asm GameBoyTiles / LinkCableTiles id lists -- so
-- every shipped manifest has to carry them, the manifest generator has to
-- keep them on a regen, and RomImporter has to force pre-#750 caches to
-- re-import. Addresses below were byte-verified against the canonical
-- Red/Blue/Yellow ROMs (each payload occurs exactly once) and match
-- pokered.sym / pokeblue.sym.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local function readFile(path)
local handle = io.open(path, "r")
if not handle then return nil end
local text = handle:read("*a")
handle:close()
return text
end
-- Red and Blue place the trade art identically; Yellow shifted it.
local RED_BLUE = {
GameBoyTiles = { 30, 23584 }, -- 1e:5c20
LinkCableTiles = { 30, 23632 }, -- 1e:5c50
TradeBubbleIconGFX = { 28, 23129 }, -- 1c:5a59
TradingAnimationGraphics = { 14, 27070 }, -- 0e:69be
TradingAnimationGraphics2 = { 14, 27854 }, -- 0e:6cce
}
local YELLOW = {
GameBoyTiles = { 30, 23932 },
LinkCableTiles = { 30, 23980 },
TradeBubbleIconGFX = { 28, 23302 },
TradingAnimationGraphics = { 14, 27240 },
TradingAnimationGraphics2 = { 14, 28024 },
}
local MANIFESTS = {
{ "tools/rom_manifest.json", RED_BLUE },
{ "tools/rom_manifest_blue.json", RED_BLUE },
{ "tools/rom_manifest_yellow.json", YELLOW },
}
for _, spec in ipairs(MANIFESTS) do
local path, expected = spec[1], spec[2]
local text = readFile(path)
T.check(text ~= nil, path .. " is readable")
if text then
for name, location in pairs(expected) do
local bank, addr = text:match(
'"' .. name .. '"%s*:%s*%[%s*(%d+)%s*,%s*(%d+)%s*%]')
T.eq(bank, tostring(location[1]),
path .. ": " .. name .. " bank")
T.eq(addr, tostring(location[2]),
path .. ": " .. name .. " address")
end
end
end
-- the manifests are generated, so the generator has to keep asking for the
-- symbols or the next regen silently drops the trade art again
local gen = readFile("tools/make_rom_manifest.py")
T.check(gen ~= nil, "tools/make_rom_manifest.py is readable")
if gen then
for name in pairs(RED_BLUE) do
T.check(gen:find('"' .. name .. '"', 1, true) ~= nil,
"a regenerated manifest keeps " .. name)
end
end
-- extractTradeArt exists, extractField calls it, and the field table
-- publishes the paths the same way the Python path's field.py does, so
-- TradeAnim's `game.data.field.tradeArt` lookup lands on both build paths
local extractor = readFile("src/import/RomExtractor.lua")
T.check(extractor ~= nil, "src/import/RomExtractor.lua is readable")
if extractor then
T.check(extractor:find("function RomExtractor:extractTradeArt", 1, true) ~= nil,
"RomExtractor has extractTradeArt")
T.check(extractor:find("self:extractTradeArt()", 1, true) ~= nil,
"extractField runs it")
T.check(extractor:find("data.tradeArt = tradeArt", 1, true) ~= nil,
"field.lua publishes tradeArt")
end
-- a cache imported before #750 has none of the art; listing one of the
-- files in REQUIRED_FILES is what makes it re-import
local importer = readFile("src/import/RomImporter.lua")
T.check(importer ~= nil, "src/import/RomImporter.lua is readable")
if importer then
local required = importer:match("local REQUIRED_FILES = {(.-)\n}")
T.check(required ~= nil, "REQUIRED_FILES parses")
T.check(required ~= nil and required:find(
'"assets/generated/trade/game_boy.png"', 1, true) ~= nil,
"REQUIRED_FILES makes pre-#750 caches re-import the trade art")
end
T.finish("trade art import")
+104
View File
@@ -0,0 +1,104 @@
-- Talking a trainer into battle must start the encounter sting (#764).
-- TalkToTrainer (pokered home/trainers.asm:88) prints the before-battle
-- text and then `call EngageMapTrainer` -> PlayTrainerMusic
-- (home/trainers.asm:399): evil list, female list, male by default, rivals
-- excluded. The port only ran the sting on the sight-line path
-- (startTrainerApproach), so a trainer challenged from the side or back --
-- and every scripted battle routed through ow:engageTrainer -- went into
-- the battle in map music. Asserts the talk path now plays the class
-- sting, skips rivals, and does not restart it when the sight path
-- (self.engaging) already did.
-- ROM-free: stubs Game/TextBox/BattleState/Music around engageTrainer.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local OW = require("src.world.OverworldController")
local function setUpvalue(fn, name, val)
local i = 1
while true do
local n = debug.getupvalue(fn, i)
if not n then return false end
if n == name then debug.setupvalue(fn, i, val); return true end
i = i + 1
end
end
local pushed = {}
local stackStub = {
push = function(_, item) pushed[#pushed + 1] = item end,
}
local textBoxStub = {
new = function(_, text, onDone) return { text = text, onDone = onDone } end,
substitute = function(_, text) return text end,
}
-- Music / BattleState are required lazily at the call site; stub via
-- package.loaded (same trick as tests/engine/oaks_pc_flow.lua)
local plays = {}
local realMusic = package.loaded["src.core.Music"]
package.loaded["src.core.Music"] = {
play = function(_, song) plays[#plays + 1] = song end,
}
local realBattle = package.loaded["src.battle.BattleState"]
package.loaded["src.battle.BattleState"] = {
newTrainer = function() return {} end,
}
local fakeGame = {
data = {
text = {},
trainerHeader = function() return nil end,
resolveText = function() return "You looked at me\nfunny!" end,
},
stack = stackStub,
}
T.check(setUpvalue(OW.engageTrainer, "Game", fakeGame),
"Game upvalue on engageTrainer")
T.check(setUpvalue(OW.engageTrainer, "TextBox", textBoxStub),
"TextBox upvalue on engageTrainer")
-- pushBattle would touch the real stack machinery; the engagement is over
-- by the time it runs, so a no-op keeps the test on the music question
local fakeSelf = setmetatable({
map = { def = { label = "Route24" } },
pushBattle = function() end,
}, { __index = OW })
-- run engageTrainer for one class and return what the sting played
local function stingFor(cls, engaging)
pushed, plays = {}, {}
fakeSelf.engaging = engaging
fakeSelf:engageTrainer({ id = "npc#1", def = { trainerClass = cls,
trainerParty = 1, index = 1 } })
T.eq(#pushed, 1, "engageTrainer pushes the before-battle text")
T.eq(#plays, 0, "no sting while the dialogue is still up (" .. cls .. ")")
pushed[1].onDone() -- close the box; TalkToTrainer engages here
return plays[1], #plays
end
-- PlayTrainerMusic's three buckets (data/trainers/encounter_types.asm)
T.eq(stingFor("OPP_LASS"), "Music_MeetFemaleTrainer",
"female-list class plays the female sting")
T.eq(stingFor("OPP_ROCKET"), "Music_MeetEvilTrainer",
"evil-list class plays the evil sting")
T.eq(stingFor("OPP_YOUNGSTER"), "Music_MeetMaleTrainer",
"any other class defaults to the male sting")
-- the rivals `ret z` out of PlayTrainerMusic; their scripts run
-- MUSIC_MEET_RIVAL themselves (data/scripts/oaks_lab.lua)
local _, rivalCount = stingFor("OPP_RIVAL1")
T.eq(rivalCount, 0, "rival classes play no encounter sting here")
-- sight path already engaged: TrainerEngage started the sting before the
-- "!" bubble, and TalkToTrainer's BIT_SEEN_BY_TRAINER guard keeps the
-- talk path from restarting it
local _, seenCount = stingFor("OPP_LASS", true)
T.eq(seenCount, 0, "self.engaging suppresses a second sting")
if realMusic ~= nil then package.loaded["src.core.Music"] = realMusic
else package.loaded["src.core.Music"] = nil end
if realBattle ~= nil then package.loaded["src.battle.BattleState"] = realBattle
else package.loaded["src.battle.BattleState"] = nil end
T.finish("trainer_talk_sting_bug764")
@@ -0,0 +1,89 @@
-- Headless regression: the Viridian fisher's TM42 gift skipped his pre
-- text and jumped straight to "received TM42!" (#775). pokered's
-- ViridianCityFisherText (scripts/ViridianCity.asm) prints
-- .YouCanHaveThisText before GiveItem; on Red that label sits outside the
-- extractor's symbol set (no leading underscore, same class as the
-- SilphCo2F worker in #393), so the ported literal has to carry the flow
-- when the text table has no entry. ROM-free: the gift closure only
-- touches text/items/flags, so TextBox, Sound and Bag are stubbed and the
-- boxes are advanced by hand.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
-- story5's gift() requires these at call time, so preloading stubs is
-- enough; each tier suite is its own process, nothing leaks
local boxes = {}
package.loaded["src.render.TextBox"] = {
new = function(_, s, done) return { text = s, onDone = done } end,
}
package.loaded["src.core.Sound"] = { play = function() end }
package.loaded["src.inventory.Bag"] = {
add = function(save, item, n)
save.inventory[item] = (save.inventory[item] or 0) + n
return true
end,
}
local story5 = require("data.scripts.story5")
local fisher = story5.VIRIDIAN_CITY.talk.TEXT_VIRIDIANCITY_FISHER
T.check(type(fisher) == "function", "the fisher talk entry is a gift closure")
local function newGame(textTable)
boxes = {}
return {
data = {
text = textTable,
items = { TM_DREAM_EATER = { name = "TM42" } },
},
save = {
flags = {}, inventory = {}, player = { name = "RED" },
},
stack = {
push = function(_, box) boxes[#boxes + 1] = box end,
},
}
end
-- Red-like: empty text table, the fallback literal must carry the scene
local game = newGame({})
local finished = false
fisher(game, nil, nil, function() finished = true end)
T.eq(#boxes, 1, "talking opens exactly one box before any A press")
local pre = boxes[1].text
T.check(type(pre) == "string" and pre:sub(1, 5) == "Yawn!",
"the first box is the fisher's pre text, not the receipt")
T.check(pre:find("DROWZEE", 1, true) ~= nil,
"the fallback carries the DROWZEE dream paragraph")
T.check(pre:find("have this TM.", 1, true) ~= nil,
"and ends on the hand-over line")
T.check(not game.save.flags.EVENT_GOT_TM42,
"the flag stays unset until the pre text is dismissed")
boxes[1].onDone()
T.eq(#boxes, 2, "dismissing the pre text opens the received box")
T.eq(boxes[2].text, "RED received\nTM42!",
"the received fallback is filled with player and item")
T.eq(game.save.inventory.TM_DREAM_EATER, 1, "TM42 reached the bag")
T.check(game.save.flags.EVENT_GOT_TM42 == true, "the event flag is set")
boxes[2].onDone()
T.eq(#boxes, 3, "the explanation box follows the receipt")
boxes[3].onDone()
T.check(finished, "the talk chain hands control back")
-- Yellow-like: the extracted string exists, so it wins over the fallback
game = newGame({ ViridianCityFisherYouCanHaveThisText = "ROM STRING" })
fisher(game, nil, nil, function() end)
T.eq(boxes[1].text, "ROM STRING",
"an extracted ViridianCityFisherYouCanHaveThisText beats the fallback")
-- repeat visit: the flag routes straight to the explanation, no re-gift
game = newGame({ _ViridianCityFisherTM42ExplanationText = "EXPLAIN" })
game.save.flags.EVENT_GOT_TM42 = true
fisher(game, nil, nil, function() end)
T.eq(#boxes, 1, "a second talk opens a single box")
T.eq(boxes[1].text, "EXPLAIN", "and it is the TM42 explanation")
T.eq(game.save.inventory.TM_DREAM_EATER, nil, "no duplicate TM42")
T.finish("viridian_fisher_pre_bug775")
+18 -3
View File
@@ -510,6 +510,21 @@ check(#pm.subItems == 2, "a non-table submenu result keeps the vanilla list")
hooks:removeOwner("bad")
pm.submenu = nil
-- ------- #768: the party cursor persists until a battle
-- (PartyMenuInit reads wPartyAndBillsPCSavedMenuItem, HandlePartyMenuInput
-- writes it back; InitBattleVariables / end_of_battle.asm zero it)
pgame.save.party[2] = { species = "PIKACHU", hp = 10, stats = { hp = 10 },
level = 5, moves = { { id = "TACKLE" } } }
press(pm, "down")
check(pgame.partyMenuSavedIndex == 2, "the party cursor is saved on move")
local pm2 = PartyMenu.new(pgame)
check(pm2.index == 2, "reopening the party menu keeps the cursor (#768)")
pgame.save.party[2] = nil
check(PartyMenu.new(pgame).index == 1,
"a shrunken party clamps the saved cursor back into range")
pgame.partyMenuSavedIndex = nil -- a battle clears it (InitBattleVariables)
check(PartyMenu.new(pgame).index == 1, "a battle resets the party cursor")
-- ------- battle PKMN: SWITCH / STATS / CANCEL (#180)
local switched
local bgame = partyGame()
@@ -555,9 +570,9 @@ do
pm.game = sgame
sgame.stack:push(pm)
press(pm, "a") -- open the submenu
check(pm.subItems[#pm.subItems].action == "strength",
"the strength row is listed with badge + move")
pm.subIndex = #pm.subItems
check(pm.subItems[1].action == "strength",
"the strength row is listed with badge + move, above STATS/SWITCH (#768)")
pm.subIndex = 1
press(pm, "a") -- run STRENGTH
local states = sgame.stack.states
check(#states == 2 and states[1] == pm and states[2].pages ~= nil,
+15 -15
View File
@@ -114,10 +114,10 @@ eq(ow:checkBoulderPush("right"), false, "no push before activation (bump 1)")
eq(ow:checkBoulderPush("right"), false, "no push before activation (bump 2)")
eq(boulder.cellX, 18, "boulder unmoved while STRENGTH is inactive")
-- activate via the party menu STRENGTH action (submenu {STATS,SWITCH,STRENGTH})
-- activate via the party menu STRENGTH action (submenu {STRENGTH,STATS,SWITCH})
clearCaptured()
local pmStr = PartyMenu.new(Game)
selectSubItem(pmStr, 3)
selectSubItem(pmStr, 1)
eq(Game.overworld.strengthActive, true, "party-menu STRENGTH sets strengthActive")
check(onStack(pmStr), "party menu stays under the STRENGTH texts (#385)")
check(sawText("used") and sawText("STRENGTH"), "_UsedStrengthText shown")
@@ -156,7 +156,7 @@ Game.save.inventory.SOULBADGE = true
ow.player.facing = "up"; ow.player.surfing = false
clearCaptured()
local pmSurfFail = PartyMenu.new(Game)
selectSubItem(pmSurfFail, 3)
selectSubItem(pmSurfFail, 1)
check(sawText("No SURFing"), "_NoSurfingHereText when not facing water")
check(pmSurfFail.submenu == true, "party menu stays open after a failed SURF")
eq(ow.player.surfing, false, "no mount when SURF fails")
@@ -166,7 +166,7 @@ popToOW()
ow.player.facing = "down"; ow.player.surfing = false
clearCaptured()
local pmSurf = PartyMenu.new(Game)
selectSubItem(pmSurf, 3)
selectSubItem(pmSurf, 1)
-- the got-on text prints over the menu (#385); dismissing it closes the
-- menu and mounts, and the blink that follows carries the step
check(onStack(pmSurf), "party menu stays under the got-on text")
@@ -228,7 +228,7 @@ ow = pushOW("CERULEAN_CITY", 19, 27, "down")
-- success path: facing the tree -> _UsedCutText, menu closes, tree replaced
clearCaptured()
local pmCut = PartyMenu.new(Game)
selectSubItem(pmCut, 3)
selectSubItem(pmCut, 1)
check(not onStack(pmCut), "party menu closes after a successful CUT")
check(sawText("CUT"), "_UsedCutText shown on a successful CUT")
drainText() -- the tree swap is deferred until the message is dismissed
@@ -239,7 +239,7 @@ popToOW()
ow.player.facing = "up"
clearCaptured()
local pmCutFail = PartyMenu.new(Game)
selectSubItem(pmCutFail, 3)
selectSubItem(pmCutFail, 1)
check(sawText("anything to CUT"), "_NothingToCutText when not facing a tree")
check(pmCutFail.submenu == true, "party menu stays open after a failed CUT")
ow.player.facing = "right"
@@ -276,7 +276,7 @@ Game.save.forcedBike = true
eq(ow:useSurfFieldMove(), "forced_bike", "forced bike refuses SURF (even facing water)")
clearCaptured()
local pmBike = PartyMenu.new(Game)
selectSubItem(pmBike, 3)
selectSubItem(pmBike, 1)
check(sawText("Cycling is fun!\nForget SURFing!"), "_CyclingIsFunText verbatim")
check(pmBike.submenu == true, "party menu stays open (.loop) after the bike refusal")
eq(ow.player.surfing, false, "no mount on the Cycling Road")
@@ -320,7 +320,7 @@ check(ow.map:isWaterCell(7, 12), "water south of the B4F stairs square")
eq(ow:useSurfFieldMove(), "current", "B4F stairs square refuses SURF pre-boulders")
clearCaptured()
local pmCur = PartyMenu.new(Game)
selectSubItem(pmCur, 3)
selectSubItem(pmCur, 1)
check(sawText("The current is\nmuch too fast!"), "_CurrentTooFastText verbatim")
check(pmCur.submenu == true, "party menu stays open (.loop) after the current refusal")
eq(ow.player.surfing, false, "no mount against the current")
@@ -356,7 +356,7 @@ table.remove(ow.entities)
-- .goBackToMap) and the simulated pad press steps the player ashore
clearCaptured()
local pmOff = PartyMenu.new(Game)
selectSubItem(pmOff, 3)
selectSubItem(pmOff, 1)
check(not onStack(pmOff), "party menu closes on dismount")
eq(ow.player.surfing, false, ".stopSurfing returns to walking before the step")
eq(#captured, 0, "no message on a successful dismount")
@@ -375,7 +375,7 @@ ow.player.px, ow.player.py = 4 * 16, 15 * 16
ow.player.facing = "down"
clearCaptured()
local pmNoOff = PartyMenu.new(Game)
selectSubItem(pmNoOff, 3)
selectSubItem(pmNoOff, 1)
check(sawText("There's no place\nto get off!"), "_SurfingNoPlaceToGetOffText verbatim")
check(onStack(pmNoOff), "the menu stays under the message (#385)")
eq(ow.player.surfing, true, "still surfing after a blocked dismount")
@@ -394,7 +394,7 @@ Game.save.inventory = { RAINBOWBADGE = true }
ow = pushOW("SEAFOAM_ISLANDS_1F", 17, 10, "right")
clearCaptured()
local pmStr2 = PartyMenu.new(Game)
selectSubItem(pmStr2, 3)
selectSubItem(pmStr2, 1)
local page1 = Game.stack:top()
check(page1 ~= nil and page1.pages ~= nil and page1.auto ~= nil,
"_UsedStrengthText box is a no-prompt (auto) page")
@@ -486,8 +486,8 @@ Game.save.inventory = {
ow = pushOW("SEAFOAM_ISLANDS_1F", 17, 10, "right")
clearCaptured()
local pmFaintStr = PartyMenu.new(Game)
-- Seafoam is not OVERWORLD, so FLY is omitted: STATS, SWITCH, CUT, STRENGTH, SURF
selectSubItem(pmFaintStr, 4)
-- Seafoam is not OVERWORLD, so FLY is omitted: CUT, STRENGTH, SURF, STATS, SWITCH
selectSubItem(pmFaintStr, 2)
eq(Game.overworld.strengthActive, true,
"fainted mon can activate STRENGTH from the party menu")
check(sawText("used") and sawText("STRENGTH"),
@@ -501,9 +501,9 @@ ow = pushOW("PALLET_TOWN", 4, 13, "down")
ow.player.surfing = false
eq(ow:useSurfFieldMove(), "ok", "useSurfFieldMove ok with only a fainted SURF mon")
clearCaptured()
-- submenu order: STATS, SWITCH, FLY, CUT, STRENGTH, SURF (move order on mon)
-- submenu order: FLY, CUT, STRENGTH, SURF (move order on mon), then STATS, SWITCH
local pmFaintSurf = PartyMenu.new(Game)
selectSubItem(pmFaintSurf, 6)
selectSubItem(pmFaintSurf, 4)
Game.stack:pop().onDone() -- dismiss the text: menu closes, mount (#320, #385)
eq(ow.player.surfing, true, "fainted mon can SURF from the party menu")
check(not onStack(pmFaintSurf), "party menu closes after fainted SURF")
+115
View File
@@ -0,0 +1,115 @@
-- Parity test: battle pages whose ROM tail is `text_end` / `done` hand off
-- with no button press (#765). Only TX_PROMPT_BUTTON writes the '▼' and
-- runs ManualTextScroll (home/text.asm:434-446); a TX_END tail returns
-- straight out of PrintText (home/text.asm:328-334). The used-move line
-- (engine/battle/used_move_text.asm EndUsedMove1Text..EndUsedMove5Text) and
-- the item-use line (ItemUseText00, engine/items/item_effects.asm) are both
-- of that kind, so a sayAuto row must flow into the next queue row untouched
-- while a plain say page still waits on A/B like PromptText.
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local S = require("tests.harness").suite("parity battle auto text (#765)")
local check, eq = S.check, S.eq
local Data = require("src.core.Data")
if not Data.maps then Data:load() end
local Font = require("src.render.Font")
Font.load(Data)
local BattleState = require("src.battle.BattleState")
local Pokemon = require("src.pokemon.Pokemon")
local SaveData = require("src.core.SaveData")
local Sound = require("src.core.Sound")
local Music = require("src.core.Music")
local Timing = require("src.core.Timing")
-- Silence audio: BattleState reaches both modules through require() at the
-- call site, so patching the fields here is what the battle ends up calling.
Sound.playCry = function() end
Sound.play = function() end
Sound.playMove = function() end
Sound.playMoveCry = function() end
Sound.stopLoop = function() end
Music.playBattle = function() end
Music.play = function() end
-- stub stack + input, like the other headless battle probes
local press = {}
local function makeGame(party)
local save = SaveData.newGame()
save.party = party
local stack = { states = {} }
function stack:push(state) self.states[#self.states + 1] = state end
function stack:pop() return table.remove(self.states) end
function stack:top() return self.states[#self.states] end
return { data = Data, save = save, stack = stack,
input = { wasPressed = function(_, b) return press[b] == true end,
isDown = function(_, b) return press[b] == true end } }
end
local game = makeGame({ Pokemon.new(Data, "BULBASAUR", 50) })
local battle = BattleState.newWild(game, "RATTATA", 2)
battle.onFinish = function() end
battle:enter()
-- strip the intro so the queue under test is exactly what gets inserted;
-- afterQueue is cleared so a drained queue between probes cannot flip the
-- phase to "menu" and stop update() from pumping messages
battle.queue = {}
battle.current = nil
battle.introSlide = 0
battle.phase = "messages"
battle.afterQueue = nil
-- ------------------------------------------------- auto page, no delay
local ran = false
battle:sayAuto("AUTO PAGE")
battle:act(function() ran = true end)
local promptedDuringAuto = false
for _ = 1, 300 do
if ran then break end
if battle.msgPrompt then promptedDuringAuto = true end
battle:update(1 / 60)
end
check(ran, "an auto page hands off to the next row with no button")
check(not promptedDuringAuto, "the prompt flag never rises on an auto page")
eq(battle.msgHold, true,
"the finished auto page stays held for drawTextArea (#296)")
-- ------------------------------------------------- auto page, autoDelay
local ran2, typedFrame, ranFrame = false, nil, nil
battle:sayAuto("HELD PAGE", 30)
battle:act(function() ran2 = true end)
for f = 1, 600 do
battle:update(1 / 60)
if not typedFrame and battle.current
and battle.charIndex >= battle.total then
typedFrame = f
end
if ran2 then ranFrame = f break end
end
check(ran2, "the delayed auto page still hands off by itself")
check(typedFrame ~= nil and ranFrame ~= nil
and ranFrame - typedFrame >= 30,
"autoDelay holds the finished page for its frame count first")
-- ------------------------------------------------- plain page still prompts
battle:say("PROMPT PAGE")
local prompted = false
for _ = 1, 300 do
battle:update(1 / 60)
if battle.msgPrompt then prompted = true break end
end
check(prompted, "a plain page still raises the blinking prompt (#317)")
-- PromptText runs ProtectedDelay3 before ManualTextScroll watches the
-- joypad (home/text.asm:213-217), so pay that hold before pressing
for _ = 1, Timing.TEXT_PRE_ADVANCE do battle:update(1 / 60) end
check(battle.current ~= nil, "and the page holds on screen with no button")
press.a = true
battle:update(1 / 60)
press.a = false
eq(battle.msgPrompt, nil, "the A press clears the prompt")
eq(battle.current, nil, "and dismisses the page")
S.finish()
+72
View File
@@ -0,0 +1,72 @@
-- Parity: which trainers get the gym-leader battle theme (#782).
-- PlayBattleMusic (audio/play_battle_music.asm) picks MUSIC_GYM_LEADER_BATTLE
-- only when wGymLeaderNo is set, and the eight gym scripts
-- (scripts/PewterGym.asm .. ViridianGym.asm) are its only writers; Lance
-- shares the theme by opponent class and the Champion (OPP_RIVAL3) takes
-- MUSIC_FINAL_BATTLE. Giovanni's Rocket Hideout (OPP_GIOVANNI#1) and Silph
-- Co (OPP_GIOVANNI#2) fights never touch the byte, so they must play
-- MUSIC_TRAINER_BATTLE. The port keyed the boss check on the trainer CLASS
-- alone, so every Giovanni battle borrowed the Earth Badge roster's theme,
-- the gym victory jingle, and the Pikachu GYMLEADER happiness bump.
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local S = require("tests.harness").suite("parity battle music bug782")
local check, eq = S.check, S.eq
local Data = require("src.core.Data")
if not Data.maps then Data:load() end
local Font = require("src.render.Font")
Font.load(Data)
local BattleState = require("src.battle.BattleState")
local Pokemon = require("src.pokemon.Pokemon")
local SaveData = require("src.core.SaveData")
local function makeGame()
local save = SaveData.newGame()
save.party = { Pokemon.new(Data, "BULBASAUR", 60) }
return { data = Data, save = save,
input = { wasPressed = function() return false end,
isDown = function() return false end },
stack = { top = function() return nil end,
push = function() end, pop = function() end } }
end
local function kindOf(oppClass, partyIndex)
local battle = BattleState.newTrainer(makeGame(), oppClass, partyIndex)
return battle:computeMusicKind(), battle.isGymLeader
end
-- the two non-gym Giovanni fights: plain trainer theme, no gym-leader flag
do
local kind, gym = kindOf("OPP_GIOVANNI", 1) -- Rocket Hideout B4F
eq(kind, "trainer", "Rocket Hideout Giovanni plays the trainer theme")
check(not gym, "Rocket Hideout Giovanni is not a gym leader")
kind, gym = kindOf("OPP_GIOVANNI", 2) -- Silph Co 11F
eq(kind, "trainer", "Silph Co Giovanni plays the trainer theme")
check(not gym, "Silph Co Giovanni is not a gym leader")
end
-- the badge fight itself keeps the gym theme and the happiness bump
do
local kind, gym = kindOf("OPP_GIOVANNI", 3) -- Viridian Gym
eq(kind, "gym", "Viridian Gym Giovanni plays the gym-leader theme")
check(gym, "Viridian Gym Giovanni sets isGymLeader")
end
-- regression guards around the branch below the badge lookup
do
local kind, gym = kindOf("OPP_BROCK", 1)
eq(kind, "gym", "Brock plays the gym-leader theme")
check(gym, "Brock sets isGymLeader")
kind, gym = kindOf("OPP_LANCE", 1)
eq(kind, "gym", "Lance shares the gym-leader theme")
check(not gym, "Lance is not a wGymLeaderNo writer (no happiness bump)")
kind = kindOf("OPP_RIVAL3", 1)
eq(kind, "final", "the Champion plays the final-battle theme")
kind, gym = kindOf("OPP_YOUNGSTER", 1)
eq(kind, "trainer", "an ordinary trainer plays the trainer theme")
check(not gym, "an ordinary trainer is not a gym leader")
end
S.finish()
+4 -4
View File
@@ -111,7 +111,7 @@ Game.save.party = { mkMon("MACHOP", "STRENGTH") }
Game.save.inventory = { RAINBOWBADGE = true }
local ow = pushOW("SEAFOAM_ISLANDS_1F", 17, 10, "right")
local pmStr = PartyMenu.new(Game)
selectSubItem(pmStr, 3)
selectSubItem(pmStr, 1)
check(isText(Game.stack:top()), "STRENGTH opens _UsedStrengthText")
eq(backdrop(), pmStr, "the party menu is the backdrop of _UsedStrengthText")
drainOne()
@@ -133,7 +133,7 @@ Game.save.inventory = { SOULBADGE = true }
ow = pushOW("PALLET_TOWN", 4, 13, "down")
ow.player.surfing = false
local pmSurf = PartyMenu.new(Game)
selectSubItem(pmSurf, 3)
selectSubItem(pmSurf, 1)
check(isText(Game.stack:top()), "SURF opens _SurfingGotOnText")
eq(backdrop(), pmSurf, "the party menu is the backdrop of _SurfingGotOnText")
drainOne()
@@ -148,7 +148,7 @@ eq(Game.stack:top(), ow, "SURF ends on the map")
ow = pushOW("PALLET_TOWN", 4, 15, "down")
ow.player.surfing = true
local pmNoOff = PartyMenu.new(Game)
selectSubItem(pmNoOff, 3)
selectSubItem(pmNoOff, 1)
check(isText(Game.stack:top()), "a blocked dismount opens _SurfingNoPlaceToGetOffText")
eq(backdrop(), pmNoOff, "the party menu is the backdrop of the no-place message")
drainOne()
@@ -172,7 +172,7 @@ Game.save.inventory = { BOULDERBADGE = true }
ow = pushOW("ROCK_TUNNEL_1F", 15, 4, "down")
eq(ow.dark, true, "ROCK_TUNNEL_1F loads dark before FLASH")
local pmFlash = PartyMenu.new(Game)
selectSubItem(pmFlash, 3)
selectSubItem(pmFlash, 1)
check(isText(Game.stack:top()), "FLASH opens _FlashLightsAreaText")
eq(backdrop(), pmFlash, "the party menu is the backdrop of _FlashLightsAreaText")
eq(ow.dark, true, "the tunnel is still dark while the message is up")
+1 -2
View File
@@ -66,8 +66,7 @@ eq(ow.dark, true, "ROCK_TUNNEL_1F loads dark before FLASH")
local pm = PartyMenu.new(Game)
Game.stack:push(pm)
frame({ "a" }) -- open the field-move submenu on PIKACHU
for _ = 2, 3 do frame({ "down" }) end
frame({ "a" }) -- FLASH
frame({ "a" }) -- FLASH is the top row now (#768)
drainOne() -- dismiss _FlashLightsAreaText
local blink = Game.stack:top()
+3 -1
View File
@@ -62,7 +62,9 @@ local expected = 100 + 128 + 16 + 20 + 600
for _, s in ipairs(credits.screens) do
expected = expected + (s.fade and 20 or 0)
+ (s.mon and (s.fade and 90 or 110) or (s.fade and 120 or 140))
+ (s.mon and 27 or 0)
-- DisplayCreditsMon: 3 x CreditsCopyTileMapToVRAM (Delay3) then 27 scroll
-- frames (#703)
+ (s.mon and (9 + 27) or 0)
end
while roll.phase ~= "end_wait" and frame < expected + 120 do
frame = frame + 1
+90
View File
@@ -0,0 +1,90 @@
-- Parity test: Substitute's failure branches play no animation (#644).
-- SUBSTITUTE_EFFECT is a ResidualEffects1 entry
-- (data/battle/residual_effects_1.asm), so the caller never plays the move
-- animation; SubstituteEffect_ (engine/battle/move_effects/substitute.asm)
-- reaches PlayCurrentMoveAnimation / AnimationSubstitute only after
-- `set HAS_SUBSTITUTE_UP, [hl]`, while .alreadyHasSubstitute and
-- .notEnoughHP jump straight to PrintText. The animation opens with
-- SE_SLIDE_MON_OFF, which hides the user's pic until the doll replaces it,
-- so a failed Substitute that still animated left the user invisible.
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local Data = require("src.core.Data")
if not (Data.moves and Data.moves.SUBSTITUTE) then Data:load() end
local TypeChart = require("src.battle.TypeChart")
TypeChart.load(Data)
local Font = require("src.render.Font")
if not pcall(Font.encode, "A") then Font.load(Data) end
local Game = require("src.core.Game")
Game.data = Data
Game.save = require("src.core.SaveData").newGame()
local Pokemon = require("src.pokemon.Pokemon")
local BattleState = require("src.battle.BattleState")
local S = require("tests.harness").suite("parity substitute anim")
local check, eq = S.check, S.eq
local function freshBattle()
Game.save.party = { Pokemon.new(Data, "BULBASAUR", 20) }
local tb = BattleState.newWild(Game, "PIDGEY", 10)
tb.queue, tb.nextInsert = {}, 0
return tb
end
local function anyAnim(tb, name)
for _, row in ipairs(tb.queue) do
if row.anim == name then return true end
end
return false
end
local function anyText(tb, needle)
for _, row in ipairs(tb.queue) do
if row.text and row.text:gsub("\n", " "):find(needle, 1, true) then return true end
end
return false
end
do
local anims = Data.battle_anims and Data.battle_anims.moveAnims
check(anims ~= nil, "battle_anims carries moveAnims")
check(anims == nil or anims.SUBSTITUTE ~= nil, "SUBSTITUTE has an animation")
end
-- success: the doll animation plays and the substitute stands
do
local tb = freshBattle()
tb.enemy.mon.hp = tb.enemy.mon.stats.hp
tb:performMove(tb.enemy, tb.player, { id = "SUBSTITUTE", pp = 10 }, false)
check(tb.enemy.substituteHP ~= nil, "a healthy user builds its substitute")
check(anyAnim(tb, "SUBSTITUTE"), "and the doll animation plays")
end
-- .notEnoughHP: text only, no animation, and no dangling row
do
local tb = freshBattle()
tb.enemy.mon.hp = math.floor(tb.enemy.mon.stats.hp / 4) - 1
tb:performMove(tb.enemy, tb.player, { id = "SUBSTITUTE", pp = 10 }, false)
check(tb.enemy.substituteHP == nil, "too little HP fails the substitute")
check(not anyAnim(tb, "SUBSTITUTE"),
"a failed substitute plays no animation (#644)")
check(tb.moveAnimRow == nil, "the peeled move-anim row is not left dangling")
check(anyText(tb, "SUBSTITUTE"), "the failure text still prints")
end
-- .alreadyHasSubstitute: same, with a doll already standing
do
local tb = freshBattle()
tb.enemy.mon.hp = tb.enemy.mon.stats.hp
tb.enemy.substituteHP = 10
tb:performMove(tb.enemy, tb.player, { id = "SUBSTITUTE", pp = 10 }, false)
eq(tb.enemy.substituteHP, 10, "the standing substitute is untouched")
check(not anyAnim(tb, "SUBSTITUTE"),
"a second substitute plays no animation (#644)")
check(tb.moveAnimRow == nil, "and peels its anim row")
end
S.finish()
+49
View File
@@ -0,0 +1,49 @@
-- Parity: a player send-out zeroes both battle cursors (#737). SendOutMon
-- (engine/battle/core.asm:1733-1735) clears wBattleAndStartSavedMenuItem and,
-- with the same hli/hl pair, wPlayerMoveListIndex behind it (wram.asm:242-244),
-- so the menu reopens on FIGHT and the move list on the first slot.
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local Data = require("src.core.Data")
if not Data.maps then Data:load() end
local TypeChart = require("src.battle.TypeChart")
TypeChart.load(Data)
local Pokemon = require("src.pokemon.Pokemon")
local SaveData = require("src.core.SaveData")
local BattleState = require("src.battle.BattleState")
local S = require("tests.harness").suite("parity switch cursor reset")
local eq = S.eq
local pressed = {}
local save = SaveData.newGame()
save.party = {
Pokemon.new(Data, "BULBASAUR", 10),
Pokemon.new(Data, "PIDGEY", 10),
}
local game = {
data = Data,
save = save,
input = {
wasPressed = function(_, key) return pressed[key] == true end,
isDown = function(_, key) return pressed[key] == true end,
},
stack = { push = function() end, pop = function() end, top = function() end },
}
local battle = BattleState.newWild(game, "RATTATA", 3)
battle.phase = "menu"
battle.menuIndex = 4
battle.moveIndex = 3
battle:resolveSwitch(save.party[2])
for i = 1, 4000 do
if battle.phase == "menu" then break end
pressed.a = (i % 4 == 0)
battle:update(1 / 60)
pressed.a = nil
end
eq(battle.moveIndex, 1, "the move cursor is back on the first slot")
eq(battle.menuIndex, 1, "the battle menu is back on FIGHT")
S.finish()
+5
View File
@@ -567,6 +567,7 @@ FIELD_ASSET_SYMBOLS = {
"FightIntroFrontMon",
"FightIntroFrontMon2",
"FightIntroFrontMon3",
"GameBoyTiles",
"GameFreakIntro",
"GameFreakLogoGraphics",
"GengarIntroTiles1",
@@ -575,6 +576,7 @@ FIELD_ASSET_SYMBOLS = {
"HappyEmote",
"HpBarAndStatusGraphics",
"LedgeHoppingShadow",
"LinkCableTiles",
"MoveAnimationTiles1",
"NintendoCopyrightLogoGraphics",
"PlayerCharacterTitleGraphics",
@@ -593,6 +595,9 @@ FIELD_ASSET_SYMBOLS = {
"SlotMachineTiles2",
"TheEndGfx",
"TownMapCursor",
"TradeBubbleIconGFX",
"TradingAnimationGraphics",
"TradingAnimationGraphics2",
"Version_GFX",
"WorldMapTileGraphics",
}
+20
View File
@@ -20442,6 +20442,10 @@
19,
21537
],
"GameBoyTiles": [
30,
23584
],
"GameCornerPrizeRoom_h": [
18,
20708
@@ -20818,6 +20822,10 @@
9,
21671
],
"LinkCableTiles": [
30,
23632
],
"LoreleiPic": [
19,
30585
@@ -22122,10 +22130,22 @@
28,
20288
],
"TradeBubbleIconGFX": [
28,
23129
],
"TradeCenter_h": [
19,
32004
],
"TradingAnimationGraphics": [
14,
27070
],
"TradingAnimationGraphics2": [
14,
27854
],
"TrainerAI": [
14,
25902
+20
View File
@@ -20419,6 +20419,10 @@
19,
21537
],
"GameBoyTiles": [
30,
23584
],
"GameCornerPrizeRoom_h": [
18,
20708
@@ -20795,6 +20799,10 @@
9,
21671
],
"LinkCableTiles": [
30,
23632
],
"LoreleiPic": [
19,
30585
@@ -22099,10 +22107,22 @@
28,
20288
],
"TradeBubbleIconGFX": [
28,
23129
],
"TradeCenter_h": [
19,
32004
],
"TradingAnimationGraphics": [
14,
27070
],
"TradingAnimationGraphics2": [
14,
27854
],
"TrainerAI": [
14,
25902
+20
View File
@@ -21703,6 +21703,10 @@
19,
21537
],
"GameBoyTiles": [
30,
23932
],
"GameCornerBeauty1Text": [
18,
19656
@@ -22251,6 +22255,10 @@
9,
21604
],
"LinkCableTiles": [
30,
23980
],
"LoreleiPic": [
19,
30454
@@ -26371,6 +26379,10 @@
28,
20420
],
"TradeBubbleIconGFX": [
28,
23302
],
"TradeCenterOpponentText": [
19,
32451
@@ -26379,6 +26391,14 @@
19,
32377
],
"TradingAnimationGraphics": [
14,
27240
],
"TradingAnimationGraphics2": [
14,
28024
],
"TrainerAI": [
14,
26034