mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 00:10:56 +02:00
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:
+12
-2
@@ -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
@@ -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`
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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
@@ -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
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user