CLOSES #339, CLOSES #354, CLOSES #360, CLOSES #372, CLOSES #373, CLOSES #374, CLOSES #375, CLOSES #378, CLOSES #379, CLOSES #383, CLOSES #384, CLOSES #385, CLOSES #391, CLOSES #392, CLOSES #393, CLOSES #394, CLOSES #395, CLOSES #396, CLOSES #397, CLOSES #398, CLOSES #413, CLOSES #420, CLOSES #423, CLOSES #424, CLOSES #425, CLOSES #426, CLOSES #427, CLOSES #429, CLOSES #430, CLOSES #431, CLOSES #433, CLOSES #435, CLOSES #436, CLOSES #438, CLOSES #439, CLOSES #441, CLOSES #442, CLOSES #444

This commit is contained in:
bryanthaboi
2026-07-30 11:14:51 -04:00
parent 0f581e2f69
commit 4b9a326f6b
46 changed files with 4171 additions and 173 deletions
+9 -4
View File
@@ -84,11 +84,16 @@ return {
{ "jump", 13 }, -- 4
{ "give_pokemon", "EEVEE", 25 }, -- 5
{ "jump_if_false", 12 }, -- 6 (party+box full)
{ "play_sound", "Get_Item1" }, -- 7 (GotMonText jingle)
{ "show_text", "_GotMonText", { RAM = "EEVEE" } }, -- 8
{ "set_flag", "EVENT_GOT_EEVEE" }, -- 9
-- flag + HideObject before the jingle and GotMonText: the nickname
-- prompt inside give_pokemon and the text row both yield, and a
-- script that dies there would leave the EEVEE taken with the ball
-- still on the table and the gift claimable again (#426). The row
-- count is unchanged, so the numeric jump targets still hold.
{ "set_flag", "EVENT_GOT_EEVEE" }, -- 7
{ "hide_object", "CELADON_MANSION_ROOF_HOUSE",
"CELADONMANSION_ROOF_HOUSE_EEVEE_POKEBALL" }, -- 10
"CELADONMANSION_ROOF_HOUSE_EEVEE_POKEBALL" }, -- 8
{ "play_sound", "Get_Item1" }, -- 9 (GotMonText jingle)
{ "show_text", "_GotMonText", { RAM = "EEVEE" } }, -- 10
{ "jump", 13 }, -- 11
{ "show_text", "_BoxIsFullText" }, -- 12
},
+5
View File
@@ -128,6 +128,11 @@ M.POKEMON_TOWER_6F = {
t._PokemonTower6FBeGoneText or "Be gone...\nIntruders...", function()
local BattleState = require("src.battle.BattleState")
local battle = BattleState.newWild(game, "MAROWAK", 30)
-- ItemUseBall .notOldManBattle (item_effects.asm:166-175): a ball
-- thrown on POKEMON_TOWER_6F at the RESTLESS SOUL is dodged whether
-- or not the scope revealed it, so the "can't be caught" state rides
-- the battle instead of IsGhostBattle alone (#444)
battle.noCatch = true
if not game.save.inventory.SILPH_SCOPE then
battle:makeGhost()
end
+15 -4
View File
@@ -23,10 +23,15 @@ M.CERULEAN_MELANIES_HOUSE = {
rows[#rows + 1] = { "jump_if_false", "declined" }
rows[#rows + 1] = { "give_pokemon", "BULBASAUR", 10 }
rows[#rows + 1] = { "jump_if_false", "end" } -- party + box full
rows[#rows + 1] = { "show_text", "MelanieText3" }
-- Event and HideObject before MelanieText3: give_pokemon's nickname
-- prompt and the received text both yield, and a script that dies
-- in that window used to leave the BULBASAUR given with the event
-- clear, so Melanie offered a second one (#426). The flag is only
-- read at script entry, so the order is invisible in play.
rows[#rows + 1] = { "set_flag", "EVENT_GOT_BULBASAUR_IN_CERULEAN" }
rows[#rows + 1] = { "hide_object", "CERULEAN_MELANIES_HOUSE",
"CERULEANMELANIESHOUSE_BULBASAUR" }
rows[#rows + 1] = { "set_flag", "EVENT_GOT_BULBASAUR_IN_CERULEAN" }
rows[#rows + 1] = { "show_text", "MelanieText3" }
rows[#rows + 1] = { "jump", "end" }
rows[#rows + 1] = { "label", "declined" }
rows[#rows + 1] = { "show_text", "MelanieText5" }
@@ -60,8 +65,13 @@ M.ROUTE_24 = {
{ "jump_if_false", "declined" },
{ "give_pokemon", "CHARMANDER", 10 },
{ "jump_if_false", "end" },
{ "show_text", "_Route24DamianText2" },
-- SetEvent hoisted ahead of the received text (Route24.asm writes it
-- after Route24Text_515e3, where nothing in between can fail): the
-- nickname prompt and the text both yield, and a script death in that
-- window left the CHARMANDER given with EVENT_54F clear, so Damian
-- handed out a second one on the next talk (#426).
{ "set_flag", "EVENT_54F" },
{ "show_text", "_Route24DamianText2" },
{ "jump", "end" },
{ "label", "declined" },
{ "show_text", "_Route24DamianText3" },
@@ -87,9 +97,10 @@ M.VERMILION_CITY = {
rows[#rows + 1] = { "jump_if_false", "declined" }
rows[#rows + 1] = { "give_pokemon", "SQUIRTLE", 10 }
rows[#rows + 1] = { "jump_if_false", "end" }
rows[#rows + 1] = { "show_text", "_OfficerJennyText3" }
-- event before the received text, as above (#426)
rows[#rows + 1] = { "set_flag",
"EVENT_GOT_SQUIRTLE_FROM_OFFICER_JENNY" }
rows[#rows + 1] = { "show_text", "_OfficerJennyText3" }
rows[#rows + 1] = { "jump", "end" }
rows[#rows + 1] = { "label", "declined" }
rows[#rows + 1] = { "show_text", "_OfficerJennyText4" }
+15 -1
View File
@@ -48,7 +48,19 @@ M.MT_MOON_B2F = {
{ "show_object", "MT_MOON_B2F", "MTMOONB2F_JESSIE" },
{ "show_object", "MT_MOON_B2F", "MTMOONB2F_JAMES" },
{ "show_text", "_MtMoonJessieJamesText1" },
{ "face_player_dir", "right" },
{ "face_player_dir", "up" },
{ "emote", "player", "shock", 30 },
-- MtMoonB2FScript_49e15 simulates PAD_UP for one player step, then
-- Script6/Script9 walk Jessie (object 2, MovementData_f9e65: six
-- $06) and James (object 6, f9e66: five $06); both objects' movement
-- byte 2 is LEFT, so .determineDirection makes those LEFT steps --
-- Jessie (9,3)->(3,3) above the player at (3,4), James (9,4)->(4,4)
-- beside him. #423
{ "walk_npc", "player", { "up" } },
{ "walk_npc", 2, { "left", "left", "left", "left", "left", "left" } },
{ "face_object", 2, "down" },
{ "walk_npc", 6, { "left", "left", "left", "left", "left" } },
{ "face_object", 6, "left" },
{ "show_text", "_MtMoonJessieJamesText2" },
{ "start_battle", "trainer", "OPP_ROCKET", 42 },
{ "check_battle_result", "win" },
@@ -57,8 +69,10 @@ M.MT_MOON_B2F = {
{ "show_text", "_MtMoonJessieJamesText4" },
{ "stop_music" },
{ "play_music", "Music_MeetJessieJames" },
{ "fade", "out" },
{ "hide_object", "MT_MOON_B2F", "MTMOONB2F_JESSIE" },
{ "hide_object", "MT_MOON_B2F", "MTMOONB2F_JAMES" },
{ "fade", "in" },
{ "play_default_music" },
{ "set_flag", "EVENT_BEAT_MT_MOON_3_JESSIE_JAMES" },
}, {})
@@ -78,6 +78,10 @@ public class GameActivity extends SDLActivity {
private static final String PICKED_SAVE_FILENAME = "picked_save.sav";
private static final String PENDING_EXPORT_FILENAME = "pending_export.sav";
private static final String EXPORT_DONE_FILENAME = "export_done.flag";
// Written when a SAF pick cannot be read at all, with the destination
// basename as its body, so RomImporter:focus can say so in the launcher
// instead of leaving the player on "No ROM imported" (issue #442).
private static final String PICK_ERROR_FILENAME = "pick_error.flag";
// Destination basename for the in-flight SAF pick (set by showFilePicker).
private String pendingPickFilename = PICKED_ROM_FILENAME;
// Suggested download name for the in-flight SAF create (set by showCreateDocument).
@@ -452,6 +456,17 @@ public class GameActivity extends SDLActivity {
return new File(new File(getExternalFilesDir(null), "save"), ROM_SAVE_IDENTITY);
}
/** Drops a small flag file in the save identity for Lua to consume on focus. */
private void writeSaveDirFlag(String name, String body) {
try {
FileOutputStream fos = new FileOutputStream(new File(saveIdentityDir(), name), false);
fos.write(body.getBytes());
fos.close();
} catch (IOException e) {
Log.d("GameActivity", "could not write " + name + ": " + e.getMessage());
}
}
private boolean copyFileToUri(File source, Uri destUri) {
InputStream in = null;
OutputStream out = null;
@@ -491,14 +506,7 @@ public class GameActivity extends SDLActivity {
Uri uri = data.getData();
if (copyFileToUri(source, uri)) {
// Signal Lua on next focus that the SAF export finished.
File flag = new File(saveIdentityDir(), EXPORT_DONE_FILENAME);
try {
FileOutputStream fos = new FileOutputStream(flag, false);
fos.write("ok".getBytes());
fos.close();
} catch (IOException e) {
Log.d("GameActivity", "could not write export_done flag: " + e.getMessage());
}
writeSaveDirFlag(EXPORT_DONE_FILENAME, "ok");
// Keep pending_export.sav so a retry still works; Lua may remove it.
} else {
Log.d("GameActivity", "could not write export to " + uri);
@@ -521,19 +529,36 @@ public class GameActivity extends SDLActivity {
? pendingPickFilename : PICKED_ROM_FILENAME;
File destFile = new File(destDir, destName);
InputStream source;
// ACTION_OPEN_DOCUMENT is meant to land in the system documents UI, but
// some OEM shells (ColorOS) offer third-party file managers in a
// chooser, and those hand back either a provider URI this app has no
// grant for (SecurityException / FileNotFoundException) or a bare
// file:// path (unreadable without storage permission on targetSdk 34).
// Try the resolver, then the path, then tell Lua why nothing imported.
InputStream source = null;
try {
source = getContentResolver().openInputStream(uri);
} catch (FileNotFoundException e) {
} catch (Exception e) {
Log.d("GameActivity", "could not open picked file: " + e.getMessage());
return;
}
if (source == null && "file".equals(uri.getScheme()) && uri.getPath() != null) {
try {
source = new FileInputStream(uri.getPath());
} catch (FileNotFoundException e) {
Log.d("GameActivity", "could not open picked path: " + e.getMessage());
}
}
if (source == null) {
Log.d("GameActivity", "ContentResolver returned no stream for picked file");
Log.d("GameActivity", "no readable stream for picked file " + uri);
writeSaveDirFlag(PICK_ERROR_FILENAME, destName);
return;
}
if (!copyAssetFile(source, destFile.getPath())) {
Log.d("GameActivity", "could not copy picked file to " + destFile);
// A truncated pick would only fail verification later, so drop it
// and report instead.
destFile.delete();
writeSaveDirFlag(PICK_ERROR_FILENAME, destName);
}
}
+64 -7
View File
@@ -4,7 +4,7 @@
-- Flow: intro -> menu (FIGHT/PKMN/ITEM/RUN) -> move select -> turn
-- resolution (a queue of messages/actions/UI pushes) -> back to menu,
-- until one side is out, then finish. Pops itself and calls
-- onFinish("win"|"lose"|"run"|"caught"|"skipped").
-- onFinish("win"|"lose"|"run"|"caught").
--
-- The Gen 1 move-effect pipeline (multi-hit, charge, trapping, thrash,
-- bide, recharge, confusion, screens, substitute, transform, ...) is
@@ -547,6 +547,19 @@ local function applySpecialMoves(data, oppClass, partyIndex, party)
end
end
-- Yellow only: ROCKET with wTrainerNo >= $2a is Jessie & James, who share
-- the class and the name "ROCKET" but battle behind their own pic
-- (home/trainers2.asm IsFightingJessieJames). picJessieJames exists only
-- in a Yellow cache extracted after #439, so an older cache keeps the
-- grunt pic until it is re-imported.
function BattleState.trainerPicPath(trainer, oppClass, partyIndex)
if oppClass == "OPP_ROCKET" and (partyIndex or 1) >= 42
and trainer.picJessieJames then
return trainer.picJessieJames
end
return trainer.pic
end
function BattleState.newTrainer(game, oppClass, partyIndex)
local self = newBattle(game)
self.kind = "trainer"
@@ -608,7 +621,9 @@ function BattleState.newTrainer(game, oppClass, partyIndex)
-- MonsterPalettes[0] = PAL_MEWMON -- InitBattleCommon zeroes
-- wEnemyMonSpecies2 before the intro's SET_PAL_BATTLE
-- (engine/battle/core.asm:6682, engine/gfx/palettes.asm SetPal_Battle)
self.trainerPic = getImage(self.trainer.pic, namedPalette(game.data, "MEWMON"))
self.trainerPic = getImage(
BattleState.trainerPicPath(self.trainer, oppClass, partyIndex),
namedPalette(game.data, "MEWMON"))
self.introText = Strings("%s wants\nto fight!", self.trainer.name)
return self
end
@@ -1115,10 +1130,34 @@ function BattleState:battleKind()
end
function BattleState:enter()
if self.dead then
-- Out of useable POKéMON before the battle even starts. pokered does not
-- skip the battle: .checkAnyPartyAlive (engine/battle/core.asm:158-162)
-- runs right after the intro and jumps to HandlePlayerBlackOut, so the
-- player blacks out and afterBattle's "lose" path revives the party at the
-- last heal point. Handing the map back with a 0 HP party instead bricked
-- the save: every later encounter aborted here too and sighted trainers
-- re-engaged forever (#425). self.dead alone is not the test --
-- makeOldManDemo and LinkBattle install a player battler after the
-- constructor flagged the battle dead.
if self.dead and not self.player then
local name = self.game.save.player.name
self.result = "lose"
self.game.stack:pop()
Runtime.emit("battle.ended", { battle = self, result = "skipped" })
if self.onFinish then self.onFinish("skipped") end
require("src.core.Music").restoreMap(self.data)
Runtime.emit("battle.ended", { battle = self, result = "lose", skipped = true })
local onFinish = self.onFinish
local function blackedOut()
if onFinish then onFinish("lose") end
end
-- the Oak's Lab starter rival returns above PlayerBlackedOutText2 and
-- afterBattle keeps the player in the lab, so print nothing there
if BattleState.isOaksLabStarterRival(self) then return blackedOut() end
-- _PlayerBlackedOutText2 (data/text/text_2.asm:896): the two paragraphs
-- playerMonFainted queues on the battle screen; there is no battle
-- screen to queue them on here, so they print over the map.
self.game.stack:push(require("src.render.TextBox").new(self.game,
Strings("%s is out of\nuseable POKéMON!", name) .. "\f"
.. Strings("%s blacked\nout!", name), blackedOut))
return
end
local Music = require("src.core.Music")
@@ -1956,6 +1995,15 @@ function BattleState:resolveSwitch(newMon)
end
function BattleState:endOfTurn()
-- the same ret: a decided battle never reaches HandlePoisonBurnLeechSeed
-- or CheckNumAttacksLeft (core.asm:417-421, 456-460), so the residual
-- sweep and the trapping-counter release are skipped on the turn a
-- Teleport escape (or a win/loss/capture) settles it (#441). The
-- turn_ended hook still fires: mods count turns, not residuals.
if self.result then
Runtime.emit("battle.turn_ended", { battle = self, turn = self.turnCount or 0 })
return
end
-- sideToxic mirrors w*ToxicCounter: it advances only while the
-- battler's badly-poisoned flag (toxicCounter) is set, an item/AI
-- cure clears the flag but NOT the side counter, and a fresh Toxic
@@ -2626,6 +2674,12 @@ function BattleState:syncShownStatus()
end
function BattleState:executeAction(user, target, action)
-- MainInBattleLoop reads wEscapedFromBattle right after Execute*Move and
-- rets (core.asm:417-421, 456-460): a Teleport/Roar/Whirlwind escape ends
-- the turn where it lands and the second mover never moves. self.result
-- is only ever set once the battle is over (run/win/lose/caught), and the
-- faint cases are already covered by the HP guard below (#441)
if self.result then return end
if user.mon.hp <= 0 or target.mon.hp <= 0 then return end
if not action then return end
@@ -3920,12 +3974,15 @@ function BattleState:throwBall(ball)
self:act(function() self:endOfTurn() end)
return
end
if self.ghost then
if self.ghost or self.noCatch then
-- ItemUseBall's can't-be-caught path (item_effects.asm:149-153):
-- the ball is thrown (TossBallAnimation still picks the arc from
-- wCurItem, so a Master/Ultra toss keeps its flicker), dodged
-- ($10 anim data, no wobbles), and the turn is spent like any
-- failed throw
-- failed throw. battle.noCatch is the .notOldManBattle half of the
-- same check (item_effects.asm:166-175): the POKEMON_TOWER_6F
-- RESTLESS SOUL dodges balls even once the scope has revealed it,
-- so it is not a ghost battle any more (#444)
self:animNext(self:tossAnimFor(ball), true, nil, ball)
self:sayNext(Strings("It dodged the\nthrown BALL!"))
self:sayNext(Strings("This POKéMON\ncan't be caught!"))
+5 -2
View File
@@ -32,17 +32,20 @@ local MUSIC_BUFFER_COUNT = ChipSynth.MUSIC_BUFFER_COUNT
-- [1] pulse 1 [2] pulse 2 [3] wave [4] noise / drums
-- Volume: 1 = authentic, 0 = mute, >1 boosts
-- Pitch: 1 = authentic, 2 = +1 octave, 0.5 = -1 octave
-- The shipped values stay at 1: 0.25 / 0.5 on the wave channel buried the Ch3
-- countermelodies an octave low (#429), and ChipSynth already applies the
-- wave channel's own hardware octave (frequency * 0.5).
-- ---------------------------------------------------------------------------
local CHANNEL_VOLUME = {
[1] = 1, -- pulse 1
[2] = 1, -- pulse 2
[3] = 0.25, -- wave
[3] = 1, -- wave
[4] = 1, -- noise / drums
}
local CHANNEL_PITCH = {
[1] = 1, -- pulse 1
[2] = 1, -- pulse 2
[3] = 0.5, -- wave
[3] = 1, -- wave
[4] = 1, -- noise / drums
}
ChipSynth.setChannelVolumes(CHANNEL_VOLUME)
+42 -3
View File
@@ -179,6 +179,38 @@ local function resolveUnmount()
return physfsUnmountFn
end
-- PHYSFS_getMountPoint, resolved the same way: a non-NULL return means `dir`
-- is already somewhere in the search path. withMounted needs it because
-- PHYSFS_mount reports success for an already-mounted directory without
-- adding a second entry, so its unmount would drop a mount it did not make
-- (#413).
local physfsMountPointFn = nil
local function resolveMountPoint()
if physfsMountPointFn ~= nil then return physfsMountPointFn end
physfsMountPointFn = false
local ok, ffi = pcall(require, "ffi")
if not ok then return physfsMountPointFn end
pcall(ffi.cdef, "const char *PHYSFS_getMountPoint(const char *dir);")
local libs = {
function() return ffi.C end,
function() return ffi.load("love") end,
}
for _, getlib in ipairs(libs) do
local okl, lib = pcall(getlib)
if okl and lib then
local oks, fn = pcall(function() return lib.PHYSFS_getMountPoint end)
if oks and fn then
physfsMountPointFn = function(d)
local okr, ret = pcall(fn, d)
return okr and ret ~= nil and ret ~= ffi.NULL
end
break
end
end
end
return physfsMountPointFn
end
-- The portable game folder when the cache should live there, else nil.
-- Resolved (and, for a fused build, mounted) once and cached. Requires a
-- desktop portable install (SaveData) and a working windowless mkdir.
@@ -398,13 +430,20 @@ end
-- resolves -- which is what makes it safe to point at a folder whose contents
-- nobody has validated.
--
-- Returns nil when the mount is unavailable (no ffi, no PHYSFS symbol, or the
-- mount was refused), which callers must treat as "could not look", not as
-- "nothing there". An error inside fn still unmounts before it propagates.
-- Returns nil when the mount is unavailable (no ffi, no PHYSFS symbol, the
-- mount was refused, or `dir` is already on the read path), which callers must
-- treat as "could not look", not as "nothing there". An error inside fn still
-- unmounts before it propagates.
function CacheFs.withMounted(dir, mountPoint, fn)
if not dir or dir == "" then return nil end
local mount, unmount = resolveMount(), resolveUnmount()
if not (mount and unmount) then return nil end
-- A directory already in the search path cannot be borrowed: PHYSFS_mount
-- returns success without adding an entry, and the unmount below would then
-- remove the mount somebody else is relying on -- for the portable game
-- folder, the one that makes its cache and mods readable at all (#413)
local mountedAt = resolveMountPoint()
if mountedAt and mountedAt(dir) then return nil end
if not mount(dir, mountPoint, true) then return nil end
local ok, res = pcall(fn)
unmount(dir)
+10
View File
@@ -1307,6 +1307,16 @@ function RomExtractor:extractTrainers()
}
self:tick("Trainers", index, #order)
end
-- Yellow's Jessie & James fight as OPP_ROCKET but carry their own pic
-- (home/trainers2.asm IsFightingJessieJames); only Yellow's manifest has
-- the symbol, so Red and Blue skip this (#439).
if self.symbols.JessieJamesPic then
local relative = "battle/trainers/jessie_james.png"
self:writeCompressedPic("JessieJamesPic", relative)
if out.OPP_ROCKET then
out.OPP_ROCKET.picJessieJames = "assets/generated/" .. relative
end
end
self:write("trainers", out)
return out
end
+172 -48
View File
@@ -35,6 +35,13 @@ local REQUIRED_FILES = {
"assets/generated/audio/programs.bin",
}
-- Files only one version's cache carries. A version that predates one of
-- them re-imports on its own, without dragging the other versions through a
-- CACHE_FORMAT bump.
local VERSION_REQUIRED_FILES = {
yellow = { "assets/generated/battle/trainers/jessie_james.png" }, -- #439
}
-- "Split-screen ROM selector" first-run palette (matches FirstRun.dc.html from
-- the Claude Design project): a dark neon arcade panel, one column per game.
-- Red, Blue, and Yellow share the same importer flow once listed in
@@ -99,6 +106,9 @@ local function allRequiredFilesExist(version)
for _, path in ipairs(REQUIRED_FILES) do
if not CacheFs.exists(path) then ok = false; break end
end
for _, path in ipairs(ok and VERSION_REQUIRED_FILES[version] or {}) do
if not CacheFs.exists(path) then ok = false; break end
end
CacheFs.prefix = saved
return ok
end
@@ -321,18 +331,46 @@ local function findPendingRom(ready)
return nil
end
-- GameActivity always writes the SAF pick to picked_rom.gb, so a leftover
-- under that exact basename is the file the player just chose and
-- findPendingRom silently refused: wrong size, or a hacked/overdumped cart
-- whose SHA-1 matches no known version ([b]/[BF] dumps never will). Route it
-- through startData so the launcher says which of the two it was instead of
-- staying on "No ROM imported" with no message at all (issue #442), and drop
-- the file so the next tap starts from a clean slate. A cart that is simply
-- already imported is not an error -- #167 skips it on purpose -- so leave
-- that one alone.
local function consumePickedRomError(self)
local preferred = "picked_rom.gb"
if not love.filesystem.getInfo(preferred, "file") then return false end
local data = love.filesystem.read(preferred)
if type(data) == "string" and #data == 1024 * 1024 then
local version = GameVersion.forSha1(sha1(data))
if version and self.ready[version] then return false end
end
love.filesystem.remove(preferred)
if type(data) ~= "string" then
self:setError("The picked file could not be read. Reopen the picker and "
.. "choose the ROM with the Files (Documents) app.")
return true
end
self:startData(data, preferred)
return true
end
-- Android SAF writes mod picks to picked_mod.zip; USB copies may use any
-- .zip basename at the save-dir root. preferAny=true also accepts those USB
-- copies (Choose / Import); focus only consumes the SAF basename so a random
-- leftover archive is never auto-installed on every refocus.
local function findPendingMod(preferAny)
local function findPendingMod(preferAny, skip)
local preferred = "picked_mod.zip"
if love.filesystem.getInfo(preferred, "file") then
return preferred
end
if not preferAny then return nil end
for _, name in ipairs(love.filesystem.getDirectoryItems("")) do
if name:lower():match("%.zip$") and love.filesystem.getInfo(name, "file") then
if name:lower():match("%.zip$") and not (skip and skip[name])
and love.filesystem.getInfo(name, "file") then
return name
end
end
@@ -340,20 +378,36 @@ local function findPendingMod(preferAny)
end
-- Same pattern as findPendingMod for battery saves (picked_save.sav / *.sav).
local function findPendingSav(preferAny)
local function findPendingSav(preferAny, skip)
local preferred = "picked_save.sav"
if love.filesystem.getInfo(preferred, "file") then
return preferred
end
if not preferAny then return nil end
for _, name in ipairs(love.filesystem.getDirectoryItems("")) do
if name:lower():match("%.sav$") and love.filesystem.getInfo(name, "file") then
if name:lower():match("%.sav$") and not (skip and skip[name])
and love.filesystem.getInfo(name, "file") then
return name
end
end
return nil
end
-- Retire an Android pick once it has been through the installer / importer,
-- whether or not it worked: a pick left on disk wins the scans above forever,
-- so the next tap re-runs the same failing file and the picker never reopens
-- (#420). The SAF basename is GameActivity's own copy of the pick and is
-- always deleted; a USB copy is the player's file, so a failed one is only
-- skipped for the rest of the session.
local function consumePick(self, name, safName, ok)
if ok or name == safName then
love.filesystem.remove(name)
return
end
self.pickSkip = self.pickSkip or {}
self.pickSkip[name] = true
end
local function chooseRom(promptName)
promptName = promptName or "Pokemon"
local prompt = "Choose your " .. promptName .. " ROM"
@@ -568,7 +622,13 @@ function RomImporter.new(onComplete, opts)
end
if android and needRom then
local name, data = findPendingRom(self.ready)
if name then self:startData(data, name) end
if name then
self:startData(data, name)
else
-- The picker runs as its own activity and Android may kill us while it
-- is up, so a rejected pick can outlive the focus handler (#442).
consumePickedRomError(self)
end
end
-- Mouse-wheel scroll for the save-slot / mods lists. main.lua (off limits)
@@ -629,28 +689,53 @@ function RomImporter:focus(f)
if self.tab == "mods" then self.tab = version end
return
end
local modName = findPendingMod(false)
if modName then
self:_installMod(modName)
if self.modNotice and self.modNotice.ok then
love.filesystem.remove(modName)
-- The SAF pick failed inside GameActivity, which wrote pick_error.flag with
-- the destination basename in it: some OEM shells (ColorOS) let a third-party
-- archive manager win the ACTION_OPEN_DOCUMENT chooser and hand back a URI
-- this app has no permission to read, and until #442 that returned to a
-- launcher that said nothing at all.
local pickError = love.filesystem.getInfo("pick_error.flag", "file")
and love.filesystem.read("pick_error.flag")
if pickError then
love.filesystem.remove("pick_error.flag")
local text = "Could not read the picked file. Reopen the picker and choose "
.. "it with the Files (Documents) app, or copy it into: "
.. love.filesystem.getSaveDirectory()
if pickError:find("picked_mod", 1, true) then
self.modNotice = { ok = false, text = text }
elseif pickError:find("picked_save", 1, true) then
local version = self.androidPendingVersion or self:_savedropTarget()
self.androidPendingVersion = nil
self.saveNotice[version] = { ok = false, text = text }
else
self:setError(text)
end
return
end
local savName = findPendingSav(false)
local modName = findPendingMod(false, self.pickSkip)
if modName then
self:_installMod(modName)
consumePick(self, modName, "picked_mod.zip",
self.modNotice and self.modNotice.ok)
return
end
local savName = findPendingSav(false, self.pickSkip)
if savName then
local version = self.androidPendingVersion or self:_savedropTarget()
self.androidPendingVersion = nil
self:_importSave(version, savName)
if self.saveNotice[version] and self.saveNotice[version].ok then
love.filesystem.remove(savName)
end
consumePick(self, savName, "picked_save.sav",
self.saveNotice[version] and self.saveNotice[version].ok)
return
end
for _, v in ipairs(GameVersion.ORDER) do
if not self.ready[v] then
local name, data = findPendingRom(self.ready)
if name then self:startData(data, name) end
if name then
self:startData(data, name)
else
consumePickedRomError(self)
end
return
end
end
@@ -698,8 +783,9 @@ function RomImporter:startData(data, displayName)
local actualHash = sha1(data)
local version = GameVersion.forSha1(actualHash)
if not version then
self:setError(("Unsupported ROM (SHA-1 %s). Use an unmodified US Pokemon "
.. "Red, Blue, or Yellow ROM."):format(actualHash))
self:setError(("Unsupported ROM (SHA-1 %s). This needs a clean US Pokemon "
.. "Red, Blue, or Yellow dump; patched, trimmed or \"fixed\" dumps "
.. "(tagged [b] or [BF]) never verify."):format(actualHash))
return
end
local info = GameVersion.info(version)
@@ -849,12 +935,11 @@ end
function RomImporter:chooseMod()
if self.workState == "working" then return end
if self.android then
local name = findPendingMod(true)
local name = findPendingMod(true, self.pickSkip)
if name then
self:_installMod(name)
if self.modNotice and self.modNotice.ok then
love.filesystem.remove(name)
end
consumePick(self, name, "picked_mod.zip",
self.modNotice and self.modNotice.ok)
return
end
if not love.system.pickFile("mod") then
@@ -908,13 +993,12 @@ end
function RomImporter:chooseSaveImport(version)
if self.workState == "working" then return end
if self.android then
local name = findPendingSav(true)
local name = findPendingSav(true, self.pickSkip)
if name then
self.androidPendingVersion = version
self:_importSave(version, name)
if self.saveNotice[version] and self.saveNotice[version].ok then
love.filesystem.remove(name)
end
consumePick(self, name, "picked_save.sav",
self.saveNotice[version] and self.saveNotice[version].ok)
return
end
self.androidPendingVersion = version
@@ -999,6 +1083,8 @@ function RomImporter:choose(version)
local name, data = findPendingRom(self.ready)
if name then
self:startData(data, name)
elseif consumePickedRomError(self) then
return -- a rejected pick explains itself instead of silently reopening
elseif not love.system.pickFile() then
-- Picker unavailable (API < 19, or no document-picker app installed):
-- fall back to the USB folder-drop path as a friendly notice, not an
@@ -1415,6 +1501,32 @@ function RomImporter.pageScrollFor(naturalH, viewportH, scroll)
return maxPage > 0, clamp(scroll or 0, 0, maxPage), maxPage
end
-- Every hit rect mousepressed dispatches on, cleared before any panel draws:
-- each rect is rebuilt only by the panel that draws its control, so whatever a
-- frame does not draw must not stay clickable. Missing the Delete rects here
-- let a click on the mods tab land on the game tab's save Delete label (#433).
function RomImporter:_resetFrameRects()
self.romButtonRect = nil
self.playButtonRect = nil
self.tabRects = {}
-- Rebuilt only by the active version's SAVE SLOT panel, so the mods tab (or a
-- version with no panel drawn this frame) cannot inherit last frame's rows.
self.slotRects = nil
self.slotEditRects = nil
self.slotDeleteRects = nil
self.newSlotRect = nil
-- Rebuilt only by the mods panel; nil elsewhere so a game tab cannot inherit
-- last frame's mod toggles / Delete labels / import button.
self.modRects = nil
self.modDeleteRects = nil
self.modImportRect = nil
-- Rebuilt only by the active game panel's SAVE FILES card; nil elsewhere so
-- the mods tab cannot inherit last frame's save Import/Export/open-folder hits.
self.saveImportRect = nil
self.saveExportRect = nil
self.saveFolderRect = nil
end
function RomImporter:draw()
local width, height = love.graphics.getDimensions()
local s = clamp(height / 768, 0.7, 1.6)
@@ -1433,23 +1545,7 @@ function RomImporter:draw()
end
self._hoverEnabled = self._padCursorActive or not self.android
self._anyHover = false
self.romButtonRect = nil
self.playButtonRect = nil
self.tabRects = {}
-- Rebuilt only by the active version's SAVE SLOT panel, so the mods tab (or a
-- version with no panel drawn this frame) cannot inherit last frame's rows.
self.slotRects = nil
self.slotEditRects = nil
self.newSlotRect = nil
-- Rebuilt only by the mods panel; nil elsewhere so a game tab cannot inherit
-- last frame's mod toggles / import button.
self.modRects = nil
self.modImportRect = nil
-- Rebuilt only by the active game panel's SAVE FILES card; nil elsewhere so
-- the mods tab cannot inherit last frame's save Import/Export/open-folder hits.
self.saveImportRect = nil
self.saveExportRect = nil
self.saveFolderRect = nil
self:_resetFrameRects()
-- Fonts + size-dependent scenery, rebuilt only when the window size changes.
local fontKey = ("%dx%d"):format(width, height)
@@ -1936,6 +2032,16 @@ local function inside(r, x, y)
return true
end
-- A Delete label is armed by one click and commits on a second one on the same
-- target; it disarms on any other press and after this many seconds, because
-- nothing in the launcher can undo a delete (#433).
local DELETE_CONFIRM_SECONDS = 4
local function armedDelete(a, kind, id, version)
return a ~= nil and a.kind == kind and a.id == id and a.version == version
and (love.timer.getTime() - a.t) <= DELETE_CONFIRM_SECONDS
end
function RomImporter:mousepressed(x, y, button)
if self._rename then return end -- the rename modal swallows all clicks
-- Whether a press can be ARMED and resolved on release, which needs a
@@ -1955,6 +2061,10 @@ function RomImporter:mousepressed(x, y, button)
return
end
if button ~= 1 then return end
-- Any press that is not the second click on an armed Delete disarms it, so
-- take the arm off self up front and let the Delete loops below re-arm.
local armed = self._confirmDelete
self._confirmDelete = nil
if inside(self.bcgButton, x, y) or inside(self.linkUrlRect, x, y) then
love.system.openURL(COMMUNITY_URL)
return
@@ -2019,7 +2129,12 @@ function RomImporter:mousepressed(x, y, button)
-- targets, no scroll conflict).
for _, r in ipairs(self.slotDeleteRects or {}) do
if inside(r, x, y) then
self:_deleteSlot(self.panelVersion, r.id)
if armedDelete(armed, "slot", r.id, self.panelVersion) then
self:_deleteSlot(self.panelVersion, r.id)
else
self._confirmDelete = { kind = "slot", id = r.id,
version = self.panelVersion, t = love.timer.getTime() }
end
return
end
end
@@ -2053,7 +2168,11 @@ function RomImporter:mousepressed(x, y, button)
end
for _, r in ipairs(self.modDeleteRects or {}) do
if inside(r, x, y) then
self:_deleteMod(r.id)
if armedDelete(armed, "mod", r.id, nil) then
self:_deleteMod(r.id)
else
self._confirmDelete = { kind = "mod", id = r.id, t = love.timer.getTime() }
end
return
end
end
@@ -2792,8 +2911,11 @@ function RomImporter:_drawSaveSlotPanel(version, x, y, w, h, paged)
local drect = { x = delX - 6 * s, y = delY - 4 * s,
width = delW + 12 * s, height = delH + 8 * s, id = slot.id }
local dhot = self:_hover(drect)
col(dhot and PAL.red or PAL.warning)
love.graphics.print(delText, delX, delY)
-- Armed by a first click, the label asks before a second one commits;
-- the box stays sized to "Delete" so the row never reflows (#433).
local darmed = armedDelete(self._confirmDelete, "slot", slot.id, version)
col((darmed or dhot) and PAL.red or PAL.warning)
love.graphics.printf(darmed and "Sure?" or delText, delX, delY, delW, "center")
local rightReserve = delW + 18 * s
-- Edit label, immediately left of Delete: opens the bundled save
@@ -3150,8 +3272,10 @@ function RomImporter:_drawModsPanel(x, y, w, h, paged)
width = L.delW + 12 * s, height = delH + 4 * s, id = m.id }
local dhot = self:_hover(drect)
love.graphics.setFont(self.hintFont)
col(dhot and PAL.red or PAL.warning)
love.graphics.print("Delete", delX, delY)
-- Same two-click arm as a save slot's Delete (#433).
local darmed = armedDelete(self._confirmDelete, "mod", m.id, nil)
col((darmed or dhot) and PAL.red or PAL.warning)
love.graphics.printf(darmed and "Sure?" or "Delete", delX, delY, L.delW, "center")
-- hit rects clipped to the visible list band
local vy = math.max(trect.y, top)
+5 -2
View File
@@ -75,7 +75,10 @@ function SaveFileIO.importToSlot(source, version)
return false, ("A save file must be %d bytes (32 KB); this one is %d.")
:format(SAVE_SIZE, #bytes)
end
local save, convertErr = SaveConvert.importSav(bytes, version)
-- 3rd arg: the crosswalk has to come from THIS game's ROM cache. The
-- launcher imports before the cache is mounted on the un-prefixed paths, so
-- SaveConvert cannot find the generated tables by itself here (#420).
local save, convertErr = SaveConvert.importSav(bytes, version, version)
if not save then return false, convertErr end
-- Tag the game version and normalize the meta stamp: SaveConvert leaves
-- meta.format = "gen1_import", but SaveData.load's migration pass compares
@@ -103,7 +106,7 @@ function SaveFileIO.exportActiveSlot(version)
version = version or GameVersion.get()
local save = SaveData.load(version)
if not save then return false, "this game has no save to export yet" end
local bytes, exportErr = SaveConvert.exportSav(save)
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
+22 -4
View File
@@ -168,6 +168,21 @@ function LauncherMods.pickStrays(found, installed)
return out
end
-- isReadableRoot(folder, source, cacheRoot) -> is folder already on the
-- physfs read path, pure. source is love.filesystem.getSource(), cacheRoot
-- the mounted portable game folder (CacheFs.root()); a fused build has both,
-- and they are different paths (the archive inside the executable vs the
-- folder beside it). Either one's mods/ is readable already, so nothing in
-- it is a stray -- and the portable folder must additionally never be handed
-- to CacheFs.withMounted: PHYSFS_mount reports success for a directory
-- already in the search path WITHOUT adding a second entry, so the paired
-- unmount tears down the one real mount and the panel loses every mod in the
-- game folder (#413).
function LauncherMods.isReadableRoot(folder, source, cacheRoot)
if not folder or folder == "" then return false end
return folder == source or folder == cacheRoot
end
-- ------- discovery (love.filesystem)
local function decodeManifest(raw, path)
@@ -349,17 +364,20 @@ end
local STRAY_MOUNT = "stray_scan"
-- Run fn(mountedModsRoot) for each game folder that has a readable mods/
-- directory, one mount at a time. Folders that are already the physfs source
-- are skipped: their mods/ is discoverable by definition, so anything there is
-- installed already and not a stray (this is every `love <gamedir>` dev run).
-- directory, one mount at a time. Folders already on the read path are
-- skipped (isReadableRoot): the physfs source, which is every `love <gamedir>`
-- dev run, and the portable game folder CacheFs mounted, where re-mounting is
-- what used to drop the mount (#413).
local function eachStrayRoot(fn)
local SaveData_ = require("src.core.SaveData")
local fs = love and love.filesystem
if not fs then return end
local source = fs.getSource and fs.getSource()
local cacheRoot = CacheFs.root()
local seen = {}
for _, folder in ipairs(SaveData_.gameFolders() or {}) do
if not seen[folder] and folder ~= source then
if not seen[folder]
and not LauncherMods.isReadableRoot(folder, source, cacheRoot) then
seen[folder] = true
CacheFs.withMounted(folder, STRAY_MOUNT, function()
local root = STRAY_MOUNT .. "/mods"
+73 -25
View File
@@ -11,9 +11,14 @@
-- tables load through `require`, exactly how src/core/Data.lua pulls the
-- generated modules -- which resolves under both plain luajit (package.path
-- "./?.lua") for the headless CLI/tests and love.filesystem for a fused
-- build, with an OS-path fallback for odd working directories. The only
-- place `love` is referenced is inside a guarded fallback, so running under
-- stock Lua never touches it.
-- build, with an OS-path fallback for odd working directories. `love` is only
-- ever referenced inside guarded fallbacks, so running under stock Lua never
-- touches it.
--
-- When a caller names the game a save belongs to, the generated tables come
-- out of that version's ROM cache through CacheFs instead: the launcher does
-- its importing before the cache is mounted onto the un-prefixed paths, so
-- require alone cannot see them there (#420).
local GenSave = require("src.save_convert.GenSave")
@@ -63,20 +68,59 @@ local function loadTable(requirePath, filePath)
:format(requirePath, requirePath, filePath)
end
local crosswalk -- { pokemon=, moves=, items=, maps=, eventFlags= }
-- The four generated tables live in one game's ROM cache, and the launcher
-- reaches this code before that cache is on the un-prefixed read path:
-- CacheFs.mountVersion only runs from main.lua's bootGame (after Play), and
-- Blue/Yellow keep their cache under GameVersion.cachePrefix. So a bare
-- require sees Red's copy at best, and nothing at all in a fused portable
-- build (the game folder is only readable through CacheFs's PhysFS mount) --
-- read the tables out of the cache whenever the caller names the game the
-- save belongs to, and let the require path above cover everything else
-- (#420).
local function loadCacheTable(gameVersion, filePath)
if not (gameVersion and love and love.filesystem) then return nil end
if not filePath:match("^data/generated/") then return nil end
local okc, CacheFs = pcall(require, "src.import.CacheFs")
local okg, GameVersion = pcall(require, "src.core.GameVersion")
if not (okc and okg and type(CacheFs) == "table") then return nil end
local info = GameVersion.VERSIONS[gameVersion]
if not info then return nil end
-- CacheFs.prefix is launcher-owned global state (it points at whatever
-- import last ran), so borrow it for this read and put it back.
local saved = CacheFs.prefix
CacheFs.prefix = info.cachePrefix
local okr, bytes = pcall(CacheFs.read, filePath)
CacheFs.prefix = saved
if not (okr and type(bytes) == "string") then return nil end
local chunk = loadstring(bytes, "@" .. info.cachePrefix .. filePath)
if not chunk then return nil end
local okx, mod = pcall(chunk)
if okx and type(mod) == "table" then return mod end
return nil
end
-- Crosswalk sets keyed by the game whose cache they came from ("*" for the
-- require-resolved set): Yellow's tables are not Red's, so one import must
-- never be handed the previous import's data (#420).
local crosswalks = {} -- [key] = { pokemon=, moves=, items=, maps=, eventFlags= }
local charmapReady
local function ensureData()
if not crosswalk then
local function ensureData(gameVersion)
local key = gameVersion or "*"
if not crosswalks[key] then
local data = {}
for key, spec in pairs(DATA_MODULES) do
if key ~= "charmap" then
local mod, e = loadTable(spec[1], spec[2])
if not mod then return nil, e end
data[key] = mod
for name, spec in pairs(DATA_MODULES) do
if name ~= "charmap" then
local mod = loadCacheTable(gameVersion, spec[2])
if not mod then
local e
mod, e = loadTable(spec[1], spec[2])
if not mod then return nil, e end
end
data[name] = mod
end
end
crosswalk = data
crosswalks[key] = data
end
if not charmapReady then
local cm, err = loadTable(DATA_MODULES.charmap[1], DATA_MODULES.charmap[2])
@@ -84,13 +128,14 @@ local function ensureData()
GenSave.setCharmap(cm)
charmapReady = true
end
return crosswalk
return crosswalks[key]
end
-- Exposed for the CLI/tests so they can share the exact data set the codec
-- uses (and so a caller can pre-warm the cache). Returns data, err.
function SaveConvert.loadData()
return ensureData()
-- uses (and so a caller can pre-warm the cache). gameVersion picks whose ROM
-- cache the generated tables come from. Returns data, err.
function SaveConvert.loadData(gameVersion)
return ensureData(gameVersion)
end
-- ------------------------------------------------------------------
@@ -138,20 +183,22 @@ SaveConvert.mergeDefaults = mergeDefaults
-- Public API
-- ------------------------------------------------------------------
-- importSav(bytes, version) -> saveTable, err
-- importSav(bytes, version, gameVersion) -> saveTable, err
-- bytes: the raw 32768-byte SRAM string. Validates size and the main-data
-- checksum, decodes through GenSave, and returns a save table fully merged
-- over the new-game defaults and tagged with `version`, ready to hand to
-- SaveSerializer.encode for a slot file. On any failure returns nil + a
-- message (never raises).
function SaveConvert.importSav(bytes, version)
-- SaveSerializer.encode for a slot file. gameVersion ("red"/"blue"/"yellow")
-- names the game the save is being imported for, which is what selects the
-- crosswalk tables; omit it to take whatever `require` resolves. On any
-- failure returns nil + a message (never raises).
function SaveConvert.importSav(bytes, version, gameVersion)
if type(bytes) ~= "string" then
return nil, "expected raw save bytes as a string"
end
if #bytes ~= GenSave.SAVE_SIZE then
return nil, ("save must be %d bytes, got %d"):format(GenSave.SAVE_SIZE, #bytes)
end
local data, derr = ensureData()
local data, derr = ensureData(gameVersion)
if not data then return nil, derr end
local ok, decoded = pcall(GenSave.decode, bytes, data)
@@ -170,16 +217,17 @@ function SaveConvert.importSav(bytes, version)
return mergeDefaults(decoded, version)
end
-- exportSav(saveTable) -> bytes, err
-- exportSav(saveTable, gameVersion) -> bytes, err
-- Encodes a save table back to a raw 32768-byte SRAM image. Template-aware:
-- if the table still carries the stashed import template (saveTable.rawImport)
-- GenSave reproduces every unmodeled region from it; otherwise those regions
-- are zero-filled. On failure returns nil + a message (never raises).
function SaveConvert.exportSav(saveTable)
-- are zero-filled. gameVersion selects the crosswalk tables exactly as in
-- importSav. On failure returns nil + a message (never raises).
function SaveConvert.exportSav(saveTable, gameVersion)
if type(saveTable) ~= "table" then
return nil, "expected a save table"
end
local data, derr = ensureData()
local data, derr = ensureData(gameVersion)
if not data then return nil, derr end
local ok, bytes = pcall(GenSave.encode, saveTable, data, nil)
if not ok then return nil, "encode failed: " .. tostring(bytes) end
+10 -3
View File
@@ -43,12 +43,13 @@ function SummaryMenu.new(game, mon)
Stats.ensure(game.data.pokemon[mon.species], mon)
local self = setmetatable({ game = game, mon = mon, page = 1 }, SummaryMenu)
local Sprites = require("src.pokemon.Sprites")
local path = Sprites.path(game.data, mon.species, "front",
local path, trueColor = Sprites.path(game.data, mon.species, "front",
{ mon = mon, kind = "summary" })
if path then
local ok, img = pcall(love.graphics.newImage, path)
self.sprite = ok and img or nil
end
self.spriteTrueColor = self.sprite and trueColor or false
require("src.core.Sound").playCry(game.data, mon.species)
return self
end
@@ -111,8 +112,14 @@ function SummaryMenu:draw()
-- the same routine the intro's NIDORINO show-off uses (OakSpeech picFlip:
-- negative x scale anchored at the pic's right edge). #280
if self.sprite then
love.graphics.draw(self.sprite, 8 + self.sprite:getWidth(),
math.max(0, 56 - self.sprite:getHeight()), 0, -1, 1)
local pw, ph = self.sprite:getDimensions()
local py = math.max(0, 56 - ph)
love.graphics.draw(self.sprite, 8 + pw, py, 0, -1, 1)
-- a full-color pic has to sit out the SGB monPal recolor, so mark the
-- rect the mirrored draw covers for the unshaded pass (#430)
if self.spriteTrueColor then
require("src.render.PaletteFX").markTrueColor(8, py, pw, ph)
end
end
local HudTiles = require("src.render.HudTiles")
love.graphics.setColor(0, 0, 0, 1)
+5 -1
View File
@@ -574,10 +574,14 @@ function YellowIntro:beginScenes()
self:startScene(0)
end
-- The movie's exit never stops the song (intro_yellow.asm PlayIntroScene
-- .go_to_title_screen, where the A/B/START skip lands too): Music_YellowIntro
-- outlasts the scenes and plays on into the title setup until title.asm's
-- StopAllMusic before MUSIC_TITLE_SCREEN, which TitleState:startMusic stands
-- in for (#436)
function YellowIntro:finish()
if self.finished then return end
self.finished = true
pcall(Music.stop)
self.game.stack:pop()
if self.onDone then self.onDone() end
end
+23 -7
View File
@@ -366,7 +366,7 @@ function OverworldState:setMap(mapId, x, y, facing, opts)
for _, n in ipairs(self.npcs) do table.insert(self.entities, n) end
-- Yellow's companion Pikachu trails the player (never in
-- self.entities: it does not block movement, pikachu_follow.asm)
require("src.world.PikachuFollower").onMapEntered(Game, self)
require("src.world.PikachuFollower").onMapEntered(Game, self, opts)
-- opts.keepMusic: the Oak-escort warp keeps MUSIC_MEET_PROF_OAK
-- playing into the lab (BIT_NO_MAP_MUSIC in wStatusFlags7);
@@ -899,7 +899,12 @@ function OverworldState:update(dt)
-- the emotion-bubble pause holds the world for a beat
if self.emote then
self.emote.frames = self.emote.frames - 1
if self.emote.frames <= 0 then
-- PikaPicAnimTimerAndJoypad (engine/pikachu/pikachu_pic_animation.asm)
-- cuts a pikapic beat short on A or B; the "!" bubble hold has no such
-- check, so only the pikapic marks itself skippable (#424)
local cut = self.emote.skippable
and (Game.input:wasPressed("a") or Game.input:wasPressed("b"))
if cut or self.emote.frames <= 0 then
local done = self.emote.onDone
self.emote = nil
if done then done() end
@@ -1291,7 +1296,14 @@ function OverworldState:crossConnection(dir, conn)
-- still protects the visible step when neighbor rebuild or the sync
-- fallback stalls, and avoids the rare one-frame volume spike from a
-- song swap mid-step.
self:setMap(conn.map, x, y, p.facing, { seamless = true, keepMusic = true })
-- Yellow's follower crosses the seam as one continuous walk, so hand the
-- live instance through setMap (which rebuilds self.npcs) instead of
-- letting it respawn behind the player (#427)
local PikachuFollower = require("src.world.PikachuFollower")
local pika = PikachuFollower.current(self)
local fromX, fromY = p.cellX, p.cellY
self:setMap(conn.map, x, y, p.facing,
{ seamless = true, keepMusic = true, keepPikachu = pika })
self.pendingSeamMusic = conn.map
-- place the player one cell before the seam (their old world spot,
-- which the neighbor strip renders identically) and start the step
@@ -1301,6 +1313,8 @@ function OverworldState:crossConnection(dir, conn)
local d = DIRVEC[dir]
p.cellX, p.cellY = x - d[1], y - d[2]
p.px, p.py = p.cellX * 16, p.cellY * 16
-- same translation for the follower and the cell it is chasing
PikachuFollower.rebase(self, p.cellX - fromX, p.cellY - fromY)
self.camera:follow(p.px, p.py)
p.facing = dir
p.targetX, p.targetY = x, y
@@ -4461,9 +4475,10 @@ function OverworldState:drawUI()
-- TalkToPikachu's picture box (engine/pikachu/pikachu_pic_animation.asm
-- PlacePikapicTextBoxBorder: TextBoxBorder at (6,5) with b,c = 5,5, so a
-- 7x7 box holding the 5x5 pic at (7,6) -- PikaAnimTilemap_1). The
-- per-emotion frame gfx (gfx/pikachu/unknown_*) are not extracted, so
-- the front pic holds for the whole beat while the cry and any emote
-- bubble play over the world below (#407).
-- per-emotion frame gfx (gfx/pikachu/unknown_*) are not extracted, so the
-- front pic stands in for every frame of the script; PikachuFollower
-- .picLift lifts it on the runs that draw the alternate pose, and the
-- script's own duration times the beat (#407, #424).
if self.emote and self.emote.pikaPic then
require("src.render.Font").drawBox(6, 5, 7, 7)
-- one image per path, cached: this draws every frame of the hold, and
@@ -4477,8 +4492,9 @@ function OverworldState:drawUI()
if img then
love.graphics.setColor(1, 1, 1, 1)
local w, h = img:getDimensions()
local lift = require("src.world.PikachuFollower").picLift(self.emote)
love.graphics.draw(img, math.floor(56 + (40 - w) / 2),
math.floor(48 + (40 - h) / 2))
math.floor(48 + (40 - h) / 2) - lift)
end
end
+160 -9
View File
@@ -181,9 +181,29 @@ local function spawnCell(ow)
return p.cellX, p.cellY
end
function PikachuFollower.onMapEntered(game, ow)
-- the live follower, for a caller that has to carry it across a setMap
-- that rebuilds ow.npcs (OverworldState:crossConnection, #427)
function PikachuFollower.current(ow)
local npc = findFollower(ow)
return npc
end
function PikachuFollower.onMapEntered(game, ow, opts)
remove(ow)
if not shouldSpawn(game, ow) then return end
-- opts.keepPikachu is the follower a connection crossing kept alive:
-- LoadMapHeader's connection path sets wPikachuSpawnState = 2 and bit 4
-- of wPikachuOverworldStateFlags, so SchedulePikachuSpawnForAfterText
-- takes .normal_spawn_state -- map coords rebased, sprite data and
-- follow command buffer left alone. Re-list the same instance and let
-- rebase() shift its cell; a warp arrives without it and respawns
-- behind the player, the full spawn path of that same routine.
local keep = opts and opts.keepPikachu
if keep then
table.insert(ow.npcs, keep)
table.insert(ow.entities, keep)
return
end
local x, y = spawnCell(ow)
local npc = makeFollower(game, ow, x, y, ow.player.facing)
table.insert(ow.npcs, npc)
@@ -271,6 +291,14 @@ end
local function idleTick(ow, npc)
-- Func_fc82e: a step in progress ends the idle state outright
if ow.player.moving then idleReset(npc) return end
-- Every counter below burns one unit per UpdateSprites call, and a
-- standing OverworldLoop spends two DelayFrames on each pass (home/
-- overworld.asm: OverworldLoop delays, falls into OverworldLoopLessDelay
-- which delays again, then .noDirectionButtonsPressed loops back), so the
-- whole Func_fc803 family runs at half this port's 60Hz fixed step: the
-- first glance is $20 CALLS, 64 frames, not 32 (#424).
npc.idleClock = ((npc.idleClock or 0) + 1) % 2
if npc.idleClock ~= 0 then return end
local idle = npc.idle
if not idle then
idle = { kind = "wait", frames = IDLE_LOOK }
@@ -339,6 +367,28 @@ local function ledgeStep(game, ow, cx, cy, dir)
return false
end
-- Slide the follower into the connected map's coordinate frame by the
-- delta crossConnection applied to the player: the two maps are one
-- continuous world, so a seam is a pure translation. MapX/MapY in
-- wSpritePikachuStateData2 are all .normal_spawn_state rewrites there,
-- never the pixel coords, which is why the original walks through a seam
-- instead of popping (#427).
function PikachuFollower.rebase(ow, dx, dy)
local npc = findFollower(ow)
if npc then
npc.cellX, npc.cellY = npc.cellX + dx, npc.cellY + dy
npc.px, npc.py = npc.px + dx * 16, npc.py + dy * 16
if npc.targetX then npc.targetX = npc.targetX + dx end
if npc.targetY then npc.targetY = npc.targetY + dy end
if npc.goalX then npc.goalX = npc.goalX + dx end
if npc.goalY then npc.goalY = npc.goalY + dy end
-- Func_fc82e: the player is taking a step, so any idle pose is over
if npc.idle then idleReset(npc) end
end
local trail = ow.pikachuTrail
if trail then trail.x, trail.y = trail.x + dx, trail.y + dy end
end
-- one follow step per frame: chase the cell the player last vacated
-- (pikachu_follow.asm keeps it one walk step behind)
function PikachuFollower.update(game, ow)
@@ -371,8 +421,27 @@ function PikachuFollower.update(game, ow)
local destX = p.targetX or p.cellX
local destY = p.targetY or p.cellY
if destX ~= trail.x or destY ~= trail.y then
npc.goalX, npc.goalY = trail.x, trail.y
trail.x, trail.y = destX, destY
local stepDir = destY > trail.y and "down" or destY < trail.y and "up"
or destX > trail.x and "right" or "left"
-- A ledge hop commits TWO steps (checkLedgeHop -> scriptMove(p, dir, 2),
-- the two simulated presses of HandleLedges) but only ONE follow
-- command: Func_fcc08 sees BIT_LEDGE_OR_FISHING and defers to
-- Func_fcc64, which appends the $5-$8 hop on the takeoff step and
-- appends nothing on the landing step (bit 6 of
-- wPikachuOverworldStateFlags toggles between the two). With no command
-- behind it the hop cannot leave the buffer -- Func_fcc92 only pops once
-- a second command is queued -- so Pikachu walks up to the cell the
-- player took off from, waits there two cells behind (the Func_fc842
-- idle rolls), and hops one player step later (#424, after #409).
if trail.ledgeHop == stepDir then
trail.ledgeHop = nil
trail.x, trail.y = destX, destY
else
trail.ledgeHop = ledgeStep(game, ow, trail.x, trail.y, stepDir)
and stepDir or nil
npc.goalX, npc.goalY = trail.x, trail.y
trail.x, trail.y = destX, destY
end
end
-- standing still with nothing to chase is the idle state (Func_fc803);
-- once a step is under way NPC:update owns px/py, so only the idle
@@ -404,7 +473,9 @@ function PikachuFollower.update(game, ow)
npc.targetX = npc.cellX + (dir == "right" and 1 or dir == "left" and -1 or 0)
npc.targetY = npc.cellY + (dir == "down" and 1 or dir == "up" and -1 or 0)
-- the cell ahead is the ledge the player hopped: clear both cells in one
-- step instead of stopping on the ledge (#409). pikachu_follow.asm
-- step instead of stopping on the ledge (#409). The trail above holds
-- this back until the player commits a further step, so it fires from the
-- cell on top of the ledge, a step late (#424). pikachu_follow.asm
-- Func_fcc08 appends the $5-$8 hop commands while BIT_LEDGE_OR_FISHING
-- is set, and Func_fca0a runs them as two AddPikachuStepVector cells over
-- one normal step's frames -- no arc and no shadow, the hop command only
@@ -422,7 +493,12 @@ function PikachuFollower.update(game, ow)
-- steps are queued (AreThereAtLeastTwoStepsInPikachuFollowCommandBuffer:
-- walk counter $4 instead of NormalPikachuFollow's $8).
local stepLen = p.stepFramesCur or p.stepFrames or 16
if far > 1 then stepLen = math.max(1, math.floor(stepLen / 2)) end
-- the hop is never a Fast step: Func_fc7aa jumps to Func_fca0a on the $4
-- movement status BEFORE it asks AreThereAtLeastTwoSteps..., so its two
-- cells ride one normal step's frames even though the goal is two away.
if far > 1 and not npc.hopStep then
stepLen = math.max(1, math.floor(stepLen / 2))
end
npc.stepFrames = stepLen
npc.moving = true
npc.progress = 0
@@ -500,6 +576,60 @@ local MOOD_MATRIX = {
-- .Emotions): scripted one-shots -- 21 is the fishing-rod reaction
local MODIFIER_EMOTIONS = { 18, 21, 23, 24, 25 }
-- ExecutePikaPicAnimScript spends a Delay3 on every pass of its loop
-- (pikachu_pic_animation.asm PikaPicAnimTimerAndJoypad), so one script tick
-- is three 60Hz frames: the flat 50 frame hold this port used was under a
-- third of even the shortest script (#424).
local PIKAPIC_TICK = 3
local PIKAPIC_LIFT = 4 -- px the stand-in pic rises on an overlay run
-- pikaemotion_pikapic's script id per emotion: emotion N takes
-- PikaPicAnimScript N, except the four listed here (data/pikachu/
-- pikachu_emotions.asm).
local PIKAPIC_SCRIPT = { [29] = 10, [30] = 20, [31] = 23, [32] = 23 }
-- Per script: pikapic_setduration's tick count, and for the scripts whose
-- overlay is a whole second pose, that frameset's run lengths in ticks
-- (data/pikachu/pikachu_pic_objects.asm PikaPicAnimBGFrames_*, which script
-- N reaches as frameset N+5, or N+6 from script 10 up). The list alternates
-- pikaframedelay (the base pic alone) and pikaframe (the overlay) starting
-- with a delay, so a frameset that opens on a pikaframe opens with a zero
-- here; the frameset restarts until pikapic_looptofinish runs the duration
-- out. Scripts 1, 2, 3, 5, 6, 8 and 9 are left without a list on purpose:
-- their overlays (PikaAnimTilemap_14 to _22) only paint a few tiles over a
-- pic that otherwise stands still, so with no tiles to paint the port has
-- nothing to show for them and must not bob the whole picture instead.
local PIKAPIC = {
[1] = { dur = 40 },
[2] = { dur = 44 },
[3] = { dur = 80 },
[4] = { dur = 70, seq = { 8, 8, 20, 8 } },
[5] = { dur = 32 },
[6] = { dur = 50 },
[7] = { dur = 58, seq = { 0, 8, 2, 8, 2, 8 } },
[8] = { dur = 44 },
[9] = { dur = 56 },
[10] = { dur = 56, seq = { 8, 11, 5 } },
[11] = { dur = 100, seq = { 20, 8, 20, 8 } },
[12] = { dur = 50, seq = { 13, 12, 100, 8 } },
[13] = { dur = 50, seq = { 5, 5, 5, 5, 100 } },
[14] = { dur = 40, seq = { 2, 2, 2, 2 } },
[15] = { dur = 50, seq = { 5, 5, 5, 5 } },
[16] = { dur = 32, seq = { 0, 8, 100 } },
[17] = { dur = 100, seq = { 10, 3, 3, 3, 100 } },
[18] = { dur = 32, seq = { 3, 100, 8, 8 } },
[19] = { dur = 44, seq = { 0, 6, 6, 6, 6 } },
[20] = { dur = 50, seq = { 8, 12, 8, 12 } },
[21] = { dur = 40, seq = { 8, 104 } },
[22] = { dur = 40, seq = { 8, 100 } },
[23] = { dur = 70, seq = { 16, 16, 16, 16 } },
[24] = { dur = 60, seq = { 6, 6, 6, 6, 100 } },
[25] = { dur = 50, seq = { 6, 106 } },
[26] = { dur = 100, seq = { 20, 8, 20, 116 } },
[27] = { dur = 30, seq = { 4, 100 } },
[28] = { dur = 64, seq = { 12, 12, 12, 100 } },
}
local function moodEmotion(save)
local mood = save.pikachuMood or 128
local column = 5
@@ -579,17 +709,38 @@ function PikachuFollower.talk(game, ow, npc, done)
-- ends with one, and its box is the only thing most of them put on
-- screen (emotion 5, the fresh-save cell, has no bubble at all). The
-- 40x40 front pic is the size of PikaAnimTilemap_1's 5x5 base frame;
-- Sprites.path keeps a mod's replacement skin in play. The scripts'
-- 32-58 frame durations bracket the hold below, so it stays at 50.
-- Sprites.path keeps a mod's replacement skin in play.
local Sprites = require("src.pokemon.Sprites")
local pic = Sprites.path(game.data, "PIKACHU", "front",
{ kind = "overworld" })
local anim = PIKAPIC[PIKAPIC_SCRIPT[emotion] or emotion] or PIKAPIC[1]
local hold = anim.dur * PIKAPIC_TICK
ow.emote = {
npc = npc, frames = 50, bubble = bi or false, pikaPic = pic,
onDone = done,
npc = npc, frames = hold, bubble = bi or false, pikaPic = pic,
pikaSeq = anim.seq, pikaTotal = hold, skippable = true, onDone = done,
}
end
-- Where the framed pic sits this frame. The overlay a pikaframe run draws
-- is a second full-body pose (PikaAnimTilemap_23 and up replace all 5x5
-- tiles) out of gfx/pikachu/unknown_*, which the cache does not carry, so
-- the port lifts the one pic it has for the length of those runs -- the jump
-- the happy emotions make inside the box (#424, still on #407's stand-in).
function PikachuFollower.picLift(emote)
local seq = emote and emote.pikaSeq
if not seq then return 0 end
local loop = 0
for _, run in ipairs(seq) do loop = loop + run end
if loop <= 0 then return 0 end
local elapsed = math.max(0, (emote.pikaTotal or 0) - (emote.frames or 0))
local tick = math.floor(elapsed / PIKAPIC_TICK) % loop
for i, run in ipairs(seq) do
if tick < run then return i % 2 == 0 and PIKAPIC_LIFT or 0 end
tick = tick - run
end
return 0
end
-- ---------------------------------------------------------------------
-- PikachuWalksToNurseJoy (engine/pikachu/pikachu_emotions.asm, run by
-- engine/events/pokecenter.asm once the heal is accepted): the companion
+71 -20
View File
@@ -102,6 +102,18 @@ return function(game)
battle.enemy.mon.hp = 400
battle.enemy.shownHP = 400
-- A PIDGEY that level knows WHIRLWIND, and in a wild battle that blows the
-- player out of the fight (result "run") the moment the foe picks it, which
-- takes the untested types down with it. Drop it and the foe keeps the
-- GUST / SAND-ATTACK / QUICK ATTACK the hand-off notes below describe.
-- curMoves aliases mon.moves, so one pass covers both.
for i = #battle.enemy.mon.moves, 1, -1 do
local id = battle.enemy.mon.moves[i].id
if id == "WHIRLWIND" or id == "ROAR" or id == "TELEPORT" then
table.remove(battle.enemy.mon.moves, i)
end
end
-- ---- watch the fx layer ------------------------------------------------
-- one entry per applying-attack row, labelled with the move that queued it
local rows, cur, using = {}, nil, nil
@@ -131,10 +143,13 @@ return function(game)
end
end
-- step n frames, sampling every one, pressing A every `mash` frames
-- step n frames, sampling every one, pressing A every `mash` frames -- but
-- only while a box is up. An A that lands on the FIGHT menu opens the move
-- list and fires the highlighted move, so mashing past the end of a turn
-- starts extra turns behind the driver's back and eventually ends the battle.
local function pump(n, mash, stop)
for i = 1, n do
if mash and i % mash == 0 then
if mash and i % mash == 0 and battle.phase == "messages" then
table.insert(game.input.pressQueue, "a")
end
U.wait(1)
@@ -144,16 +159,34 @@ return function(game)
end
end
-- a full turn is the player's announcement, animation and damage drain plus
-- the foe's, so budget frames for the slowest of them rather than the typical
-- one: a short budget hands the next move a battle still in `messages`
local function toMenu()
pump(400, 6, function() return battle.phase == "menu" and #battle.queue == 0 end)
pump(1200, 6, function() return battle.phase == "menu" and #battle.queue == 0 end)
return battle.phase == "menu"
end
-- FIGHT is menuIndex 1 of the 2x2 grid; A opens moveSelect, where up/down
-- walk the slots. A press that lands on a frame the battle is not reading
-- input is simply lost, so every step retries instead of assuming.
-- The move list is a flat column in the classic layout, but the wide one
-- lays the four slots out 2x2 (WideBattle.navigate): up/down swap rows and
-- left/right swap columns, so walking down the list there never reaches
-- slots 2 and 4. Return the press that closes the gap in either layout.
local function towardSlot(slot)
if battle.moveIndex == slot then return nil end
if battle:wideLayout() then
local row, col = math.floor((battle.moveIndex - 1) / 2), (battle.moveIndex - 1) % 2
local wantRow, wantCol = math.floor((slot - 1) / 2), (slot - 1) % 2
if row ~= wantRow then return row < wantRow and "down" or "up" end
return col < wantCol and "right" or "left"
end
return battle.moveIndex < slot and "down" or "up"
end
-- FIGHT is menuIndex 1 of the 2x2 grid; A opens moveSelect, where the presses
-- above walk the slots. A press that lands on a frame the battle is not
-- reading input is simply lost, so every step retries instead of assuming.
local function useMove(slot)
for _ = 1, 40 do
for _ = 1, 80 do
if battle.phase == "moveSelect" then break end
if battle.phase == "menu" then
if battle.menuIndex ~= 1 then
@@ -161,13 +194,17 @@ return function(game)
else
U.tap(game, "a")
end
else
-- the last turn's boxes are still up: keep turning pages
U.tap(game, "a")
end
U.wait(4)
end
if battle.phase ~= "moveSelect" then return false end
for _ = 1, 30 do
if battle.moveIndex == slot then break end
U.tap(game, battle.moveIndex < slot and "down" or "up")
local dir = towardSlot(slot)
if not dir then break end
U.tap(game, dir)
U.wait(3)
end
if battle.moveIndex ~= slot then return false end
@@ -193,15 +230,22 @@ return function(game)
check("the battle reached its FIGHT menu", toMenu())
for _, m in ipairs(MOVES) do
local sent = useMove(m.slot)
check("chose " .. m.id .. " from the move menu", sent)
if sent then
-- catch the shake mid-flight for the screenshot: the offset is live for
-- only a couple of dozen frames
local shotAt
-- catch the shake mid-flight for the screenshot: the offset is live for
-- only a couple of dozen frames
local shotAt
-- HYPNOSIS lands 6 times in 10 and TACKLE 19 in 20, and a miss plays no
-- applying-attack animation at all, so give each move a few turns to
-- connect instead of reading a whiffed one as a regression
local sent, r
for _ = 1, 8 do
-- the foe leans on SAND-ATTACK, and stacked accuracy drops make even
-- TACKLE whiff turn after turn, so hand the accuracy back each try
battle.player.stages.accuracy = 0
sent = useMove(m.slot)
if not sent then break end
-- A every 8 frames: the text box between the announcement and the
-- animation waits on the button like any other
pump(400, 8, function()
pump(1200, 8, function()
local live = battle.fx and (m.peak and (battle.fx.shakeX or 0) ~= 0
or (not m.peak and battle.fx.blink
and battle.fx.blink.frames > 0))
@@ -209,12 +253,19 @@ return function(game)
shotAt = true
U.shot(game, DIR .. "/" .. SHOTS[m.id])
end
local r = lastRowFor(m.id)
return r ~= nil and battle.fx.shakeProg == nil
and (r.frames > 0 or r.blinked)
local row = lastRowFor(m.id)
-- back at the menu with the row in hand: the turn is over either way
if row and battle.phase == "menu" and #battle.queue == 0 then return true end
return row ~= nil and battle.fx.shakeProg == nil
and (row.frames > 0 or row.blinked)
end)
toMenu()
local r = lastRowFor(m.id)
r = lastRowFor(m.id)
if r then break end
U.log(" " .. m.id .. " did not connect that turn; using it again")
end
check("chose " .. m.id .. " from the move menu", sent)
if sent then
check(m.id .. " queued an applying-attack row", r ~= nil)
if r then
check(("%s is animation type %d (got %s)")
@@ -0,0 +1,182 @@
-- Walking into grass with no useable POKeMON must black the player out, not
-- cancel the battle and hand the map back (#425). pokered core.asm:158-162
-- runs .checkAnyPartyAlive after the intro and jumps to HandlePlayerBlackOut
-- (core.asm:1145-1166, PlayerBlackedOutText2). Machine half also in
-- tests/parity_blackout_no_party.lua. Never under POKEPORT_SPEED: the wipe,
-- the theme change and the two text boxes come apart from each other.
-- POKEPORT_DRIVER=tests/drivers/blackout_no_party_bug425_test.lua POKEPORT_IDENTITY=bug425 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local Music = require("src.core.Music")
local BattleState = require("src.battle.BattleState")
local Pokemon = require("src.pokemon.Pokemon")
local TextBox = require("src.render.TextBox")
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local MAP = "ROUTE_1"
local pass, fail = 0, 0
local function check(label, ok)
if ok then pass = pass + 1 else fail = fail + 1 end
U.log(ok and "PASS" or "FAIL", label)
return ok
end
local function waitFor(fn, limit)
for _ = 1, limit do
if fn() then return true end
U.wait(1)
end
return false
end
-- every song request in order, so "the battle theme was never restored"
-- and "the map theme was never playing" read differently in the log
local songs = {}
local realPlay = Music.play
Music.play = function(data, song, loop, ctx)
songs[#songs + 1] = { song = song, frame = U.frame() }
U.log(("frame %d play %s"):format(U.frame(), tostring(song)))
return realPlay(data, song, loop, ctx)
end
local function playedAfter(song, frame)
for _, s in ipairs(songs) do
if s.song == song and s.frame >= frame then return s end
end
return nil
end
local opts = game.save.options
if (opts and opts.musicVol or 0) == 0 then
U.log("MUSIC VOLUME IS 0: the theme swap this bug is about is silent.")
U.log("Raise it in OPTION, quit and rerun; the log half still holds.")
end
if (opts and opts.sfxVol or 0) == 0 then
U.log("WARNING sfx volume is 0: the wipe and the text beeps will be")
U.log("silent too.")
end
-- MAGIKARP with SPLASH only would still be a battle; 0 HP is the state
-- under test, and money is the blackout's other visible half
game.save.party = { Pokemon.new(game.data, "MAGIKARP", 10) }
local mon = game.save.party[1]
local full = mon.stats.hp
mon.hp = 0
game.save.money = 3000
-- Route 1's first grass row. data/generated/maps.lua holds the tiles
-- (pokered data/maps/objects/Route1.asm has no grass in it), so the cell is
-- checked against the loaded map and rescanned if a map edit moved it.
local GRASS = { x = 10, y = 6 }
U.teleport(game, MAP, GRASS.x, GRASS.y, "down")
local ow = game.overworld
local function grassAt(cx, cy)
return ow.map:isGrassCell(cx, cy) and ow.map:isWalkableCell(cx, cy)
end
if not grassAt(GRASS.x, GRASS.y) then
local found
for y = 0, ow.map.heightCells - 1 do
for x = 0, ow.map.widthCells - 1 do
-- a neighbouring grass cell too, so the hand-off half below has
-- somewhere to step without leaving the patch
if grassAt(x, y) and grassAt(x + 1, y) then found = { x = x, y = y } break end
end
if found then break end
end
if found then
U.log(("(%d, %d) is not grass any more, standing on")
:format(GRASS.x, GRASS.y), found.x, found.y)
GRASS = found
U.teleport(game, MAP, found.x, found.y, "down")
ow = game.overworld
end
end
check("the player is standing in " .. MAP .. " grass",
grassAt(GRASS.x, GRASS.y))
local heal = ow:healPoint()
U.log("heal point:", heal.map, heal.x, heal.y)
local MAP_SONG = game.data.audio.mapSongs[MAP]
local HEAL_SONG = game.data.audio.mapSongs[heal.map]
local WILD_SONG = game.data.audio.battle.wild
U.log("songs:", tostring(MAP_SONG), tostring(WILD_SONG), tostring(HEAL_SONG))
-- the encounter OverworldState:checkEncounter would build for this cell
local encDef = game.data.encounters[MAP]
local slots = encDef and encDef.grass and encDef.grass.slots
check(MAP .. " has a grass encounter table", slots ~= nil and #slots > 0)
if not slots then
U.log("Nothing can be encountered here; rerun after a ROM re-import.")
while true do coroutine.yield() end
end
local start = U.frame()
local battle = BattleState.newWild(game, slots[1].species, slots[1].level)
check("the encounter starts out flagged dead (no healthy party)",
battle.dead == true)
local got
battle.onFinish = function(result)
got = result
ow:afterBattle(result, battle)
end
ow:pushBattle(battle)
check("the wipe started the battle theme",
waitFor(function() return playedAfter(WILD_SONG, start) ~= nil end, 120))
local function box()
local top = game.stack:top()
return getmetatable(top) == TextBox and top or nil
end
local shown = waitFor(function() return box() ~= nil end, 600)
check("the blackout text came up over the map", shown)
if shown then
local lines = {}
for _, page in ipairs(box().pages or {}) do
for _, line in ipairs(page) do lines[#lines + 1] = line end
end
local text = table.concat(lines, " / ")
U.log("box reads:", text)
check("it is PlayerBlackedOutText2, both paragraphs",
text:find("out of", 1, true) ~= nil
and text:find("blacked", 1, true) ~= nil)
U.wait(60)
U.shot(game, SHOT_DIR .. "/bug425_blackout.png")
U.log("captured", SHOT_DIR .. "/bug425_blackout.png")
end
-- page both paragraphs, then let the warp run
for _ = 1, 12 do
if not box() then break end
U.tap(game, "a")
U.wait(20)
end
local warped = waitFor(function()
return game.overworld and game.overworld.map
and game.overworld.map.id == heal.map
and not game.overworld.transitioning
end, 900)
check("the player was warped to the heal point map " .. heal.map, warped)
check("onFinish reported a loss, not a cancelled battle", got == "lose")
check("the party was revived", mon.hp == full)
check("half the money is gone (3000 -> 1500)", game.save.money == 1500)
if HEAL_SONG then
check("the heal point's own theme replaced the battle theme",
playedAfter(HEAL_SONG, start) ~= nil)
end
local stillBattle = songs[#songs] and songs[#songs].song == WILD_SONG
check("the battle theme is not what is still playing", not stillBattle)
U.log(("%d passed, %d failed"):format(pass, fail))
-- re-arm for the hand-off: same 0 HP party, back in the grass
mon.hp = 0
game.save.money = 3000
U.teleport(game, MAP, GRASS.x, GRASS.y, "down")
U.log("MAGIKARP is at 0 HP again and you are back in the Route 1 grass.")
U.log("Step around until an encounter rolls: the wipe and the battle theme,")
U.log("then the two boxes over the map with the route theme back under them,")
U.log("then the warp to Pallet Town. Landing back in the grass with the")
U.log("battle music looping, over and over, is the bug.")
while true do
coroutine.yield()
end
end
@@ -0,0 +1,171 @@
-- Eye check on the Mt Moon B2F Jessie & James ambush (#423): the duo walk up
-- to the player and leave behind a fade. pokeyellow scripts/MtMoonB2F.asm
-- MtMoonB2FScript_49e15 simulates PAD_UP, Script6/Script9 walk Jessie six and
-- James five LEFT steps, Script8/Script11 face them; Script14 hides them between
-- GBFadeOutToBlack / GBFadeInFromBlack. No POKEPORT_SPEED (the fade and the
-- theme sting ride the audio clock), and no POKEPORT_IDENTITY (it re-imports):
-- POKEPORT_VERSION=yellow SHOT_DIR=/tmp/shots POKEPORT_TOUCH=0 POKEPORT_DRIVER=tests/drivers/jessie_james_mtmoon_bug423_test.lua love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local Pokemon = require("src.pokemon.Pokemon")
local BattleState = require("src.battle.BattleState")
local GameVersion = require("src.core.GameVersion")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
pcall(function() io.stdout:setvbuf("no") end) -- LOVE block-buffers stdout
-- pokeyellow data/maps/objects/MtMoonB2F.asm: object 2 JESSIE at (9,3),
-- object 6 JAMES at (9,4). MtMoonB2FScript_49e15's trigger is (3,5), so the
-- PAD_UP step puts the player on (3,4) and the walks land (3,3) and (4,4).
local MAP = "MT_MOON_B2F"
local TRIGGER = { x = 3, y = 5 }
local JESSIE, JAMES = "MTMOONB2F_JESSIE", "MTMOONB2F_JAMES"
local BEAT = "EVENT_BEAT_MT_MOON_3_JESSIE_JAMES"
local WANT = {
player = { x = TRIGGER.x, y = TRIGGER.y - 1 },
jessie = { x = TRIGGER.x, y = TRIGGER.y - 2, facing = "down" },
james = { x = TRIGGER.x + 1, y = TRIGGER.y - 1, facing = "left" },
}
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
return ok
end
local function npcNamed(ow, name)
for _, n in ipairs(ow.npcs or {}) do
if n.def and n.def.name == name then return n end
end
return nil
end
-- ---- what the eye cannot check -----------------------------------------
local opts = game.save.options or {}
local sfxVol = opts.sfxVol or 7
if sfxVol == 0 then
U.log("FAIL sfx volume is 0: the theme sting that opens the ambush and the")
U.log(" bubble blip over the player are both silent, so the audio half")
U.log(" cannot be judged. Set SFX to 7 in OPTION first.")
end
check(("sfx volume %d"):format(sfxVol), sfxVol > 0)
check("running Yellow", GameVersion.isYellow())
-- one strong mon with one damaging move: a stat move stalls the A-mash
local mon = Pokemon.new(game.data, "MEWTWO", 100)
mon.moves = { { id = "TACKLE", pp = 35 } }
game.save.party = { mon }
game.save.flags[BEAT] = nil
game.save.flags.EVENT_GOT_HELIX_FOSSIL = true
-- ---- reach the trigger ---------------------------------------------------
U.teleport(game, MAP, TRIGGER.x, TRIGGER.y + 1, "up")
local ow = game.overworld
U.hold(game, "up", 20)
U.wait(10)
if not ow.runner:isRunning() then
-- a map or mod edit blocked the approach from below: step in from whatever
-- free walkable neighbour is left
local sides = { { 1, 0, "left" }, { -1, 0, "right" }, { 0, -1, "down" } }
for _, s in ipairs(sides) do
local cx, cy = TRIGGER.x + s[1], TRIGGER.y + s[2]
if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then
U.log("approach from below failed, stepping in from", cx, cy)
U.teleport(game, MAP, cx, cy, s[3])
ow = game.overworld
U.hold(game, s[3], 20)
U.wait(10)
if ow.runner:isRunning() then break end
end
end
end
check("the ambush script is running", ow.runner:isRunning())
U.shot(game, DIR .. "/jj423_0_trigger.png")
-- ---- the duo close in ----------------------------------------------------
-- Sample every mash: the closed-in positions only hold between the motto and
-- the challenge line, and the battle push ends the window.
local closed = nil
local battle = nil
for _ = 1, 1500 do
local top = game.stack:top()
if getmetatable(top) == BattleState then
battle = top
break
end
local jessie, james = npcNamed(ow, JESSIE), npcNamed(ow, JAMES)
if not closed and jessie and james and not jessie.moving and not james.moving
and jessie.cellX == WANT.jessie.x and james.cellX == WANT.james.x then
closed = {
px = ow.player.cellX, py = ow.player.cellY,
jx = jessie.cellX, jy = jessie.cellY, jf = jessie.facing,
mx = james.cellX, my = james.cellY, mf = james.facing,
}
U.shot(game, DIR .. "/jj423_1_scene.png")
end
U.tap(game, "a")
U.wait(3)
end
check("the duo walked in instead of staying at (9,3) and (9,4)", closed ~= nil)
closed = closed or {}
U.log("player", tostring(closed.px), tostring(closed.py),
"jessie", tostring(closed.jx), tostring(closed.jy), tostring(closed.jf),
"james", tostring(closed.mx), tostring(closed.my), tostring(closed.mf))
check("the player took the simulated PAD_UP step onto (3,4)",
closed.px == WANT.player.x and closed.py == WANT.player.y)
check("Jessie is standing directly above the player",
closed.jx == WANT.jessie.x and closed.jy == WANT.jessie.y)
check("Jessie is facing down at him", closed.jf == WANT.jessie.facing)
check("James is standing beside the player",
closed.mx == WANT.james.x and closed.my == WANT.james.y)
check("James is facing left at him", closed.mf == WANT.james.facing)
check("the challenge line opened the battle", battle ~= nil)
if battle then
for _ = 1, 240 do
if battle.showEnemyTrainer and (battle.introSlide or 0) <= 0 then break end
U.wait(1)
end
U.shot(game, DIR .. "/jj423_1b_battle.png")
end
-- ---- and leave behind a fade --------------------------------------------
local peakFade, fadeShot = 0, false
local settled = false
for i = 1, 6000 do
local overlay = ow.fadeOverlay
local alpha = overlay and overlay.alpha or 0
if alpha > peakFade then peakFade = alpha end
if game.stack:top() == ow and not ow.runner:isRunning()
and #ow.scriptMoves == 0 and game.save.flags[BEAT] then
settled = true
break
end
if alpha > 0.35 and not fadeShot then
fadeShot = true
U.shot(game, DIR .. "/jj423_2_fade.png")
end
U.tap(game, "a")
-- step frame by frame while the overlay lives: a 12-frame ramp sampled
-- every third frame reads as no fade at all
U.wait((alpha > 0 or i > 1200) and 1 or 3)
end
U.shot(game, DIR .. "/jj423_3_done.png")
check("the scene ran to its end", settled)
check("the beat flag is set", game.save.flags[BEAT] == true)
check("Jessie is gone", npcNamed(ow, JESSIE) == nil)
check("James is gone", npcNamed(ow, JAMES) == nil)
U.log(("peak fade alpha %.2f"):format(peakFade))
check("the exit dipped the screen toward black", peakFade > 0.3)
U.log("jj423_1_scene.png must show Jessie one cell ABOVE the player looking")
U.log("down at him and James on his right looking left, not the pair parked")
U.log("across the corridor; the exclamation bubble pops over the player just")
U.log("before they start walking, so watch the window live for that beat.")
U.log("jj423_2_fade.png is the room dimming with the duo still standing in")
U.log("it, and jj423_3_done.png is the same room lit and empty; the second")
U.log("Music_MeetJessieJames sting covers the fade, then the cave theme.")
while true do
coroutine.yield()
end
end
+125
View File
@@ -0,0 +1,125 @@
-- Eye check on the enemy pic in the Mt Moon Jessie & James fight (#439).
-- pokeyellow home/trainers2.asm:36-50 IsFightingJessieJames points
-- wTrainerPicPointer at JessieJamesPic when ROCKET fights with wTrainerNo
-- >= $2a, leaving GetTrainerName alone. Needs a Yellow cache re-imported
-- after #439. No POKEPORT_SPEED: the theme sting rides the audio clock.
-- POKEPORT_VERSION=yellow POKEPORT_DRIVER=tests/drivers/jj_battle_pic_bug439_test.lua POKEPORT_IDENTITY=bug439 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 GameVersion = require("src.core.GameVersion")
local CacheFs = require("src.import.CacheFs")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
-- pokeyellow data/maps/objects/MtMoonB2F.asm parks JESSIE at (9, 3) and
-- JAMES at (9, 4); MtMoonB2FScript's coordinate trigger is the (3, 5) tile
-- west of them, reachable from (3, 6) once a fossil is in the bag.
local MAP = "MT_MOON_B2F"
local TRIGGER = { x = 3, y = 5 }
local BEAT = "EVENT_BEAT_MT_MOON_3_JESSIE_JAMES"
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
return ok
end
-- ---- what the eye cannot check -----------------------------------------
local opts = game.save.options or {}
local sfxVol = opts.sfxVol or 7
if sfxVol == 0 then
U.log("FAIL sfx volume is 0: the theme sting that opens the ambush is")
U.log(" silent, so the scene's audio half cannot be judged. Set SFX")
U.log(" to 7 in OPTION first.")
end
check(("sfx volume %d"):format(sfxVol), sfxVol > 0)
check("running Yellow", GameVersion.isYellow())
local trainer = game.data.trainers.OPP_ROCKET
check("OPP_ROCKET is in the trainer table", trainer ~= nil)
trainer = trainer or {}
check("the name is still ROCKET, as GetTrainerName leaves it",
trainer.name == "ROCKET")
-- a Yellow cache built before #439 has no duo pic at all, and every check
-- below it would then be measuring the old grunt-only behaviour
if not trainer.picJessieJames then
U.log("FAIL the Yellow cache predates #439: no duo pic was extracted.")
U.log(" Re-import the Yellow ROM from the launcher, then run again.")
end
check("the cache carries a duo pic", trainer.picJessieJames ~= nil)
if trainer.picJessieJames then
check("the duo pic file is on the read path",
CacheFs.exists(trainer.picJessieJames)
or love.filesystem.getInfo(trainer.picJessieJames) ~= nil)
end
local picked = BattleState.trainerPicPath(trainer, "OPP_ROCKET", 42)
U.log("pic:", tostring(picked))
check("party 42 selects the duo pic", picked == trainer.picJessieJames)
check("a lone grunt party still selects the class pic",
BattleState.trainerPicPath(trainer, "OPP_ROCKET", 3) == trainer.pic)
-- ---- reach the ambush ---------------------------------------------------
-- one strong mon so the fight is survivable if the reader plays it out
game.save.party = { Pokemon.new(game.data, "MEWTWO", 100) }
game.save.flags[BEAT] = nil
game.save.flags.EVENT_GOT_HELIX_FOSSIL = true
U.teleport(game, MAP, TRIGGER.x, TRIGGER.y + 1, "up")
local ow = game.overworld
U.hold(game, "up", 20)
U.wait(10)
if not ow.runner:isRunning() then
-- a map or mod edit blocked the approach from below: come down onto the
-- trigger from whichever free walkable neighbour is left
local sides = {
{ 0, -1, "down" }, { 1, 0, "left" }, { -1, 0, "right" },
}
for _, s in ipairs(sides) do
local cx, cy = TRIGGER.x + s[1], TRIGGER.y + s[2]
if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then
U.log("approach from below failed, stepping in from", cx, cy)
U.teleport(game, MAP, cx, cy, s[3])
ow = game.overworld
U.hold(game, s[3], 20)
U.wait(10)
if ow.runner:isRunning() then break end
end
end
end
check("the ambush script is running", ow.runner:isRunning())
U.shot(game, DIR .. "/jj439_0_trigger.png")
-- A-mash the motto and the challenge line until the battle is pushed, then
-- stop: the trainer pic stays up until the intro text is dismissed
local battle = nil
for _ = 1, 1200 do
local top = game.stack:top()
if getmetatable(top) == BattleState then
battle = top
break
end
U.tap(game, "a")
U.wait(3)
end
check("the ambush opened a battle", battle ~= nil)
if battle then
for _ = 1, 240 do
if battle.showEnemyTrainer and (battle.introSlide or 0) <= 0 then break end
U.wait(1)
end
check("the enemy trainer pic is on screen", battle.showEnemyTrainer == true)
U.shot(game, DIR .. "/jj439_1_intro.png")
U.wait(40)
U.shot(game, DIR .. "/jj439_2_intro_late.png")
U.log("intro text:", tostring(battle.introText))
end
U.log("Both intro shots must show the two-character duo pic: a woman on the")
U.log("left and a man on the right, one 7x7 pic, no Meowth -- not the single")
U.log("crouching ROCKET grunt. The name in the intro line stays ROCKET.")
U.log("The theme under the trigger shot is Music_MeetJessieJames.")
while true do
coroutine.yield()
end
end
@@ -0,0 +1,90 @@
-- Manual check of the launcher's Delete labels (#433): a stale hit rect let a
-- click on the Mods tab delete a save slot, and Delete never asked first.
-- No pokered counterpart: src/import/RomImporter.lua is this port's own desktop
-- launcher, so hit-rect coords here come from the panel that draws them.
-- POKEPORT_DRIVER=tests/drivers/launcher_delete_bug433_test.lua POKEPORT_IDENTITY=bug433 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
-- Leave POKEPORT_SPEED unset: this one is clicked by hand, at real time.
return function(game)
local U = dofile("tests/drivers/util.lua")
local RomImporter = require("src.import.RomImporter")
local SaveData = require("src.core.SaveData")
local GameVersion = require("src.core.GameVersion")
local version = GameVersion.get()
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
return ok
end
-- A driver run boots straight past the launcher (main.lua: POKEPORT_DRIVER is
-- a scripted run), so bring a real interactive launcher up ourselves and take
-- over the frame + click handlers the launcher normally owns.
local imp = RomImporter.new(function() end, { launcher = true })
imp.play = function() U.log("Play is inert here: the game is already booted.") end
local prevDraw, prevPressed = love.draw, love.mousepressed
local prevKey, prevText = love.keypressed, love.textinput
love.draw = function()
imp:update(love.timer.getDelta())
imp:draw()
end
love.mousepressed = function(x, y, button) imp:mousepressed(x, y, button or 1) end
love.keypressed = function(key) imp:keypressed(key) end
love.textinput = function(t) imp:textinput(t) end
local function restore()
love.draw, love.mousepressed = prevDraw, prevPressed
love.keypressed, love.textinput = prevKey, prevText
end
local function slotCount()
return #(SaveData.listSlots(version) or {})
end
-- Two throwaway rows to click on, in this identity's save dir only.
while slotCount() < 2 do imp:_newSlot(version) end
imp.tab = version
imp.pageScroll = 0
U.wait(4)
-- The Delete rects exist only after the panel that owns them has drawn.
local del = (imp.slotDeleteRects or {})[1]
if not check("the save panel drew a Delete rect for a slot row", del ~= nil) then
U.log("Nothing to click: the", version, "tab drew no slot rows.")
restore()
while true do coroutine.yield() end
end
local spot = { x = del.x + del.width / 2, y = del.y + del.height / 2, id = del.id }
U.log(("Delete for %s sits at (%d, %d)."):format(spot.id, spot.x, spot.y))
imp.tab = "mods"
U.wait(4)
check("a Mods frame leaves no save Delete rect live",
imp.slotDeleteRects == nil)
local before = slotCount()
imp:mousepressed(spot.x, spot.y, 1)
U.wait(4)
check("the reporter's click on that spot from Mods deletes nothing",
slotCount() == before and imp._confirmDelete == nil)
imp.tab = version
U.wait(4)
imp:mousepressed(spot.x, spot.y, 1)
U.wait(2)
check("one click on Delete arms instead of deleting",
slotCount() == before and imp._confirmDelete ~= nil
and imp._confirmDelete.id == spot.id)
imp:mousepressed(4, 4, 1) -- a press anywhere else
U.wait(2)
check("a press elsewhere takes the arm back off", imp._confirmDelete == nil)
check("the slot survived the whole sequence", slotCount() == before)
U.log("The launcher on screen is live; the", version, "tab is up, disarmed.")
U.log("Clicking Delete once should turn that label red and read \"Sure?\"")
U.log("without the row moving, and go back to \"Delete\" after ~4 seconds or")
U.log("on any other click. A second click on \"Sure?\" removes the slot.")
while true do
coroutine.yield()
end
end
+22 -10
View File
@@ -105,11 +105,13 @@ return function(game)
speech.picFlip == true and speech.pic == speech.demoPic)
U.shot(game, DIR .. "/oak_nido_2a.png")
-- one A per page of 2A; picFlip must survive every one of them
-- one A per page of 2A; picFlip must survive every one of them. The page
-- count is not fixed (an A during the typewriter only finishes the line), so
-- keep turning until the step moves on rather than budgeting presses.
local held = true
for _ = 1, 8 do
for _ = 1, 60 do
U.tap(game, "a")
U.wait(10)
U.wait(12)
if speech.picFlip ~= true or speech.pic ~= speech.demoPic then
held = false
break
@@ -126,22 +128,32 @@ return function(game)
U.shot(game, DIR .. "/oak_nido_2b.png")
U.log("shots in", DIR)
-- put the beat back so the transition can be watched live: pop the box, rewind
-- the step counter and re-run the demo beat (wipe, cry, page A)
-- put the beat back so the page break can be watched live: pop the box and
-- rewind the step counter. The wipe and the cry are both skipped on the way
-- back in -- the sprite is already standing there and it already called once,
-- so replaying them reads as NIDORINO entering twice -- leaving just page A.
for _ = 1, 8 do
if top() == speech then break end
game.stack:pop()
end
if top() == speech then
speech.picReveal = nil
local Sound = require("src.core.Sound")
local realReveal, realCry = speech.revealPic, Sound.playCry
speech.revealPic = function(self, _, next)
self.picReveal = nil
if next then next() end
end
Sound.playCry = function() end
speech.step = demoIdx
speech:runStep(speech.steps[demoIdx])
U.wait(60)
speech.revealPic, Sound.playCry = realReveal, realCry
U.wait(30)
end
U.log("NIDORINO wipes in from the right facing LEFT, horn toward screen-left.")
U.log("Press A to turn the page: the sprite must not move or mirror, it stays")
U.log("facing left until the screen fades to white for the naming beat.")
U.log("NIDORINO is standing where the wipe left it, facing LEFT with its horn")
U.log("toward screen-left, and page A is back up. Press A to turn the page:")
U.log("the sprite must not move or mirror, it stays facing left until the")
U.log("screen fades to white for the naming beat.")
while true do
coroutine.yield()
@@ -0,0 +1,316 @@
-- Yellow follower cadence (#424): the ledge hop deferred a player step, the
-- standing idle counters at OverworldLoop's two DelayFrames per pass, and the
-- pikapic beat timed by its own pikapic_setduration (pokeyellow engine/pikachu/
-- pikachu_follow.asm Func_fc803/Func_fcc64/Func_fcc92, home/overworld.asm:43,
-- pikachu_pic_animation.asm ExecutePikaPicAnimScript). Never POKEPORT_SPEED:
-- it scales the logic clock alone and every number judged here is a frame count.
-- POKEPORT_DRIVER=tests/drivers/pikachu_cadence_bug424_test.lua POKEPORT_IDENTITY=bug424 POKEPORT_TOUCH=0 POKEPORT_VERSION=yellow love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local GameVersion = require("src.core.GameVersion")
local Pokemon = require("src.pokemon.Pokemon")
local PikachuFollower = require("src.world.PikachuFollower")
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local results = {}
local function check(label, ok)
results[#results + 1] = { label = label, ok = ok and true or false }
return ok
end
local function report()
for _, r in ipairs(results) do U.log(r.ok and "PASS" or "FAIL", r.label) end
end
local function idleForever()
while true do coroutine.yield() end
end
if not GameVersion.isYellow() then
check("running the Yellow cache (POKEPORT_VERSION=yellow)", false)
report()
U.log("Red and Blue have no follower, no idle rolls and no pikapic box.")
idleForever()
end
-- ShouldPikachuSpawn's preconditions (pikachu_follow.asm): the lab gift
-- happened and a healthy Pikachu leads the party. Without both there is no
-- follower and every count below would read as the bug.
game.save.flags = game.save.flags or {}
game.save.flags.EVENT_GOT_STARTER = true
game.save.party = { Pokemon.new(game.data, "PIKACHU", 20) }
game.save.onBike = false
local frames = 0
local function step(n)
for _ = 1, n do
coroutine.yield()
frames = frames + 1
end
end
-- ROUTE_1 (pokeyellow data/maps/objects/Route1.asm has no object near the
-- ledge run) carries the first plain south-facing ledge; (5, 4) is the cell
-- to hop from, re-derived below out of the cached blocks against
-- data.field.ledges so a map edit moves the test instead of breaking it.
local MAP = "ROUTE_1"
local WANT = { x = 5, y = 4 }
local function southLedge(map, cx, cy)
if not (map:inBounds(cx, cy) and map:inBounds(cx, cy + 3)) then return false end
-- the landing plus one more open cell below it: the queued hop only drains
-- on the player's next step, so the ledge needs room for that step
if not (map:isWalkableCell(cx, cy) and map:isWalkableCell(cx, cy - 1)
and map:isWalkableCell(cx, cy + 2)
and map:isWalkableCell(cx, cy + 3)) then
return false
end
local standing, front = map:cellTile(cx, cy), map:cellTile(cx, cy + 1)
for _, l in ipairs(game.data.field.ledges or {}) do
if (l.tileset or "OVERWORLD") == map.def.tileset
and l.facing == "down" and l.input == "down"
and l.standingTile == standing and l.ledgeTile == front then
return true
end
end
return false
end
U.teleport(game, MAP, WANT.x, WANT.y - 1, "down")
U.wait(20)
local ow = game.overworld
check("overworld is up on " .. MAP, ow ~= nil and ow.map.id == MAP)
if not ow then
report()
idleForever()
end
local hx, hy = WANT.x, WANT.y
if not southLedge(ow.map, hx, hy) then
for cy = 1, ow.map.heightCells - 4 do
for cx = 0, ow.map.widthCells - 1 do
if southLedge(ow.map, cx, cy) then hx, hy = cx, cy break end
end
if hx ~= WANT.x or hy ~= WANT.y then break end
end
U.log(("(%d, %d) is no longer a south ledge; using"):format(WANT.x, WANT.y),
hx, hy)
U.teleport(game, MAP, hx, hy - 1, "down")
U.wait(20)
ow = game.overworld
end
check(("a south ledge to hop at (%d, %d)"):format(hx, hy),
southLedge(ow.map, hx, hy))
local function follower()
for _, n in ipairs(ow.npcs or {}) do
if n.pikachuFollower then return n end
end
return nil
end
local npc = follower()
if not check("the follower spawned", npc ~= nil) then
report()
idleForever()
end
-- ---- the standing idle clock ----
-- Func_fc803 reloads $20 and decrements it once per UpdateSprites call, and
-- a standing OverworldLoop burns two DelayFrames per pass, so one counter
-- unit is two of this port's fixed steps: the glance lands on frame 64.
local function glanceLeft()
local idle = npc.idle
if idle and idle.kind == "wait" then return idle.frames end
return nil
end
for _ = 1, 90 do
if glanceLeft() then break end
step(1)
end
if not check("the follower is in the standing glance state", glanceLeft() ~= nil) then
report()
idleForever()
end
local last, lastAt = glanceLeft(), frames
-- the first change lands a partial unit after the sampling started, so it
-- only sets the baseline: every gap measured after it is a whole unit
local minGap, maxGap, seen, resetAt, period = 99, 0, 0, nil, nil
for _ = 1, 300 do
step(1)
local now = glanceLeft()
if now == nil then break end
if now ~= last then
seen = seen + 1
local gap = frames - lastAt
if seen > 1 then
if gap < minGap then minGap = gap end
if gap > maxGap then maxGap = gap end
end
if now > last then
if resetAt then period = period or frames - resetAt end
resetAt = frames
end
last, lastAt = now, frames
end
end
check("the idle counter loses one unit every two frames, not every frame",
minGap == 2 and maxGap == 2)
check("a full glance takes 64 frames ($20 units), not 32", period == 64)
U.log("idle counter gap", minGap, "to", maxGap, "frames; glance period",
tostring(period))
-- ---- the ledge hop, one player step late ----
-- Func_fcc08 hands a ledge step to Func_fcc64, which appends the $5-$8 hop
-- on the takeoff step and nothing on the landing step; Func_fcc92 cannot pop
-- a command with nothing queued behind it, so the hop waits for the player's
-- next step and Pikachu sits two cells back until then.
local sawHop, hopLen = false, nil
local function sample()
local f = follower()
if f and f.hopStep then
sawHop = true
hopLen = hopLen or f.stepFrames
end
end
for _ = 1, 200 do
table.insert(game.input.pressQueue, "down")
game.input.state["down"] = true
step(1)
sample()
if (ow.player.hopFrames or 0) > 0 then break end
end
game.input.state["down"] = false
check("the player's ledge hop fired", (ow.player.hopFrames or 0) > 0)
for _ = 1, 150 do
step(1)
sample()
end
npc = follower()
check("the follower waits on the cell the player took off from",
npc ~= nil and npc.cellX == hx and npc.cellY == hy)
check("it does not hop while the player stands still", not sawHop)
if npc then
U.log("player at", ow.player.cellX, ow.player.cellY,
"| follower waiting at", npc.cellX, npc.cellY)
end
-- one more player step drains the buffered hop
for _ = 1, 120 do
table.insert(game.input.pressQueue, "down")
game.input.state["down"] = true
step(1)
sample()
if ow.player.cellY > hy + 2 then break end
end
game.input.state["down"] = false
for _ = 1, 120 do
step(1)
sample()
end
check("the deferred hop then ran", sawHop)
-- Func_fc7aa jumps to Func_fca0a on the $4 movement status before it asks
-- AreThereAtLeastTwoStepsInPikachuFollowCommandBuffer, so a hop is never a
-- Fast step even though its goal is two cells off
local walkLen = ow.player.stepFramesCur or ow.player.stepFrames or 16
check("the hop takes a full step's frames, not a halved Fast step",
hopLen == walkLen)
U.log("hop step frames", tostring(hopLen), "against a walk's", walkLen)
npc = follower()
check("the follower cleared the ledge row",
npc ~= nil and npc.cellY > hy + 1
and ow.map:isWalkableCell(npc.cellX, npc.cellY))
-- ---- the pikapic beat ----
-- happiness 255 with mood 200 is the matrix cell for emotion 20 (a heart
-- bubble and PikaPicAnimScript20: 50 ticks, so 150 frames, with the second
-- full-body pose up for 24 of every 40 ticks).
game.save.pikachuHappiness = 255
game.save.pikachuMood = 200
local DIRS = { { "down", 0, 1 }, { "up", 0, -1 },
{ "left", -1, 0 }, { "right", 1, 0 } }
local function facingFollower()
local fx, fy = ow.player:facingCell()
return follower() ~= nil and ow:npcAtCell(fx, fy) == follower()
end
if not facingFollower() then
for _, d in ipairs(DIRS) do
if npc and npc.cellX == ow.player.cellX + d[2]
and npc.cellY == ow.player.cellY + d[3] then
U.tap(game, d[1])
U.wait(6)
break
end
end
end
check("player is facing the follower", facingFollower())
-- a muted run sounds like a beat that never started, so say so up front
local sfxVol = game.save.options and game.save.options.sfxVol
U.log("save.options.sfxVol:", tostring(sfxVol))
if (sfxVol or 0) == 0 then
U.log("WARNING sfx volume is 0: the squeak cannot be heard whether or not")
U.log("WARNING one plays. Raise it in OPTIONS before judging the sound.")
end
U.tap(game, "a")
U.wait(1)
local emote = ow.emote
if not check("the A press raised the framed pikapic", emote ~= nil) then
report()
idleForever()
end
check("the beat runs the script's 50 ticks at three frames each",
emote.pikaTotal == 150)
check("its length is not the old flat 50 frame hold", emote.pikaTotal ~= 50)
check("the pikapic marks itself skippable", emote.skippable == true)
check("emotion 20 carries an overlay frameset", type(emote.pikaSeq) == "table")
local lifted, grounded, liftShot = 0, 0, nil
for _ = 1, 60 do
if not ow.emote then break end
local lift = PikachuFollower.picLift(ow.emote)
if lift > 0 then
lifted = lifted + 1
if not liftShot then
liftShot = SHOT_DIR .. "/bug424_pikapic_lift.png"
if U.shot(game, liftShot) then U.log("captured", liftShot)
else liftShot = nil end
end
else
grounded = grounded + 1
end
step(1)
end
check("the pic sits on the box floor for part of the beat", grounded > 0)
check("and rises off it for another part", lifted > 0)
U.log("of the first 60 frames the pic was up for", lifted, "and down for",
grounded)
local leftWhenCut = ow.emote and ow.emote.frames or 0
U.tap(game, "a")
U.wait(2)
check("A cuts the beat short (PikaPicAnimTimerAndJoypad)",
leftWhenCut > 0 and ow.emote == nil)
U.log("cut with", leftWhenCut, "frames still on the clock")
report()
-- hand the pad back one cell above the same ledge so Down replays the hop
U.teleport(game, MAP, hx, hy - 1, "down")
U.wait(20)
U.log("Hold Down: Pikachu should walk up to the ledge lip and WAIT there,")
U.log("then clear both cells in one motion on your next step, at walking")
U.log("speed. Let go and count the first glance: about a second, not half.")
U.log("Face it, press A: the framed pic hops inside the box for the whole")
U.log("beat, and A or B cuts the beat off early.")
idleForever()
end
@@ -71,11 +71,14 @@ return function(game)
-- a cell the player can hop south from, with two walkable cells above it
-- to walk in from and a walkable landing two cells below
local function hopCellOk(map, cx, cy)
if not (map:inBounds(cx, cy) and map:inBounds(cx, cy + 2)) then
if not (map:inBounds(cx, cy) and map:inBounds(cx, cy + 3)) then
return false
end
if not map:isWalkableCell(cx, cy) then return false end
if not map:isWalkableCell(cx, cy + 2) then return false end
-- one more open cell below the landing: the queued hop only drains on
-- the player's NEXT step, so the ledge needs room to take it (#424)
if not map:isWalkableCell(cx, cy + 3) then return false end
if not (map:isWalkableCell(cx, cy - 1)
and map:isWalkableCell(cx, cy - 2)) then
return false
@@ -209,6 +212,26 @@ return function(game)
U.log("the arc was already over before the capture; no mid-air frame")
end
-- The hop command is buffered a step behind the player: Func_fcc64 appends
-- nothing for the landing half of the jump, so Func_fcc92 cannot pop the
-- hop until another command queues behind it. Pikachu therefore walks to
-- the cell the player took off from and waits there (#424).
step(120)
local waiting = follower()
check("the follower waits on the cell the player took off from",
waiting ~= nil and waiting.cellX == hx and waiting.cellY == hy)
check("it has not hopped while the player stood still", not sawHopStep)
-- one more player step drains the queued hop
for _ = 1, 90 do
table.insert(game.input.pressQueue, "down")
game.input.state["down"] = true
coroutine.yield()
sample()
if ow.player.cellY > hy + 2 then break end
end
game.input.state["down"] = false
-- let the follower finish crossing (its hop is one normal step's frames
-- with a doubled step vector, pikachu_follow.asm Func_fca0a)
step(120)
@@ -242,9 +265,11 @@ return function(game)
U.log("Hold Down to hop the ledge again from here.")
U.log("During the arc there should be one flat ellipse on the ground under")
U.log("the player, not a taller blob with a seam across its middle, and")
U.log("Pikachu should clear both cells in one motion. The near miss to")
U.log("watch for is Pikachu pausing a beat on the ledge tile itself.")
U.log("the player, not a taller blob with a seam across its middle.")
U.log("Pikachu should walk up to the cell you jumped from and STAY there")
U.log("until you take another step, then clear both cells in one motion.")
U.log("The near misses to watch for are Pikachu hopping alongside you and")
U.log("Pikachu pausing a beat on the ledge tile itself.")
while true do
coroutine.yield()
+189
View File
@@ -0,0 +1,189 @@
-- A connection crossing has to carry Yellow's follower, not respawn it (#427).
-- pokeyellow home/overworld.asm:648-655 sets bit 4 of
-- wPikachuOverworldStateFlags before LoadMapHeader, so pikachu_follow.asm's
-- SchedulePikachuSpawnForAfterText takes .normal_spawn_state: coords rebased,
-- sprite data left alone. Never POKEPORT_SPEED here, the offsets are pixels.
-- POKEPORT_DRIVER=tests/drivers/pikachu_seam_bug427_test.lua POKEPORT_TOUCH=0 POKEPORT_VERSION=yellow love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local Pokemon = require("src.pokemon.Pokemon")
local PikachuFollower = require("src.world.PikachuFollower")
local GameVersion = require("src.core.GameVersion")
local Map = require("src.world.Map")
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
if not GameVersion.isYellow() then
check("only Yellow has a follower; re-run with POKEPORT_VERSION=yellow", false)
while true do coroutine.yield() end
end
-- pokeyellow data/maps/objects/PalletTown.asm puts no object on the town's
-- north exit and PALLET_TOWN's north connection to ROUTE_1 carries offset 0,
-- so the landing column is the column walked off the edge.
local MAP = "PALLET_TOWN"
local NORTH = "ROUTE_1"
local COL = 10
local START_Y = 3
game.save.party = { Pokemon.new(game.data, "PIKACHU", 100) }
game.save.flags = game.save.flags or {}
game.save.flags.EVENT_GOT_STARTER = true
game.save.onBike = false
game.save.repelSteps = 9999
game.save.player.name = "bryan"
U.teleport(game, MAP, COL, START_Y, "up")
U.wait(10)
local ow = game.overworld
local map = ow.map
-- a usable exit column: four clear cells up to the edge, nothing standing
-- in them, and a landing the engine's own edge read accepts (Map.defPassable
-- is what crossConnection consults before it commits to the crossing)
local function exitOK(cx)
for cy = 0, START_Y do
if not map:isWalkableCell(cx, cy) then return false end
-- ow.npcs carries the follower too, and it is standing in this column
local at = ow:npcAtCell(cx, cy)
if at and not at.pikachuFollower then return false end
end
local p = ow.player
local keepX = p.cellX
p.cellX = cx
local dest, ts, lx, ly = ow:connectionLanding("up")
p.cellX = keepX
return dest ~= nil and dest.id == NORTH
and Map.defPassable(dest, ts, lx, ly, false)
end
if not exitOK(COL) then
local found
for cx = 0, map.widthCells - 1 do
if exitOK(cx) then found = cx break end
end
U.log(("column %d no longer walks off the north edge; using column %s")
:format(COL, tostring(found)))
COL = found or COL
U.teleport(game, MAP, COL, START_Y, "up")
U.wait(10)
ow = game.overworld
map = ow.map
end
check(("column %d walks off %s's north edge onto %s"):format(COL, MAP, NORTH),
exitOK(COL))
local pika = PikachuFollower.current(ow)
check("the follower spawned on " .. MAP, pika ~= nil)
-- exactly one step north, then stop: the follower has to be trailing and
-- settled, since a respawn on the cell it already occupies is invisible
local p = ow.player
local stopY = p.cellY - 1
for _ = 1, 60 do
if p.cellY <= stopY and not p.moving then break end
table.insert(game.input.pressQueue, "up")
game.input.state.up = true
coroutine.yield()
end
game.input.state.up = false
U.wait(20)
U.log(("before the seam: player (%d, %d), follower (%s, %s)")
:format(p.cellX, p.cellY, tostring(pika and pika.cellX),
tostring(pika and pika.cellY)))
check("the follower trails one cell behind before the seam",
pika ~= nil and pika.cellX == p.cellX and pika.cellY == p.cellY + 1)
if U.shot(game, SHOT_DIR .. "/bug427_before_seam.png") then
U.log("captured", SHOT_DIR .. "/bug427_before_seam.png")
end
-- Hold up through the crossing, sampling once per logic step (main.lua
-- resumes the driver exactly once per Game:update). The pixel offset
-- between the two sprites is the measurement: a respawn loses the in-flight
-- step, so the offset snaps even when the new instance lands on the right
-- cell. The mid-seam capture is armed inline instead of through U.shot so
-- the walk keeps running while it lands.
local samples, missing, maxJump = 0, 0, 0
local prevX, prevY
local crossedAt, pikaAfter
local frames = 0
while frames < 240 do
table.insert(game.input.pressQueue, "up")
game.input.state.up = true
frames = frames + 1
coroutine.yield()
local npc = PikachuFollower.current(ow)
if not npc then
missing = missing + 1
else
local pl = ow.player
local ox, oy = npc.px - pl.px, npc.py - pl.py
if prevX then
local jump = math.max(math.abs(ox - prevX), math.abs(oy - prevY))
if jump > maxJump then maxJump = jump end
end
prevX, prevY = ox, oy
samples = samples + 1
end
if not crossedAt and ow.map.def.id == NORTH then
crossedAt = frames
pikaAfter = npc
game.capturePath = SHOT_DIR .. "/bug427_mid_seam.png"
end
if crossedAt and frames >= crossedAt + 24 then break end
end
game.input.state.up = false
U.wait(30)
check("the player crossed the seam onto " .. NORTH,
crossedAt ~= nil and ow.map.def.id == NORTH)
check("the follower across the seam is the same instance",
pikaAfter ~= nil and pikaAfter == pika)
check("it is still the same instance once the walk settles",
PikachuFollower.current(ow) == pika)
check(("it never dropped out of ow.npcs (%d of %d frames)")
:format(missing, samples + missing), missing == 0 and samples > 0)
check(("its pixel offset to the player never jumped (max %dpx)")
:format(maxJump), maxJump <= 4)
local settled = PikachuFollower.current(ow)
p = ow.player
if settled then
U.log(("player (%d, %d) facing %s, follower (%d, %d)")
:format(p.cellX, p.cellY, tostring(p.facing),
settled.cellX, settled.cellY))
end
check("the follower came to rest one cell behind",
settled ~= nil and settled.cellX == p.cellX
and settled.cellY == p.cellY + 1)
local trail = ow.pikachuTrail
check("the chased trail cell was rebased into the new map",
trail ~= nil and ow.map:inBounds(trail.x, trail.y))
local mid = io.open(SHOT_DIR .. "/bug427_mid_seam.png", "rb")
if mid then
mid:close()
U.log("captured", SHOT_DIR .. "/bug427_mid_seam.png")
else
U.log("FAIL screenshot did not reach disk:", SHOT_DIR .. "/bug427_mid_seam.png")
end
if U.shot(game, SHOT_DIR .. "/bug427_after_seam.png") then
U.log("captured", SHOT_DIR .. "/bug427_after_seam.png")
end
U.log("You just walked north out of Pallet onto Route 1 with Pikachu behind")
U.log("you. In bug427_mid_seam.png it has to be below you and mid-stride, on")
U.log("the pixels the walk left it on; the bug put it squarely on a cell in")
U.log("front of you instead, once per seam, which is the pop in the report.")
U.log("Warps still respawn it behind you (walk through any door), and a")
U.log("fainted or deposited Pikachu still leaves no follower at a seam.")
while true do
coroutine.yield()
end
end
+13 -1
View File
@@ -196,6 +196,11 @@ return function(game)
type(emote.pikaPic) == "string"
and love.filesystem.getInfo(emote.pikaPic) ~= nil)
check("a cry source was created", reportCries(before))
-- the beat now runs the pikapic script's own pikapic_setduration, three
-- 60Hz frames per tick, so even the shortest script (32 ticks) outlasts
-- the 50 frame hold this port used to serve every emotion (#424)
check("the hold runs the script's own duration, not a flat 50 frames",
(emote.frames or 0) >= 80)
bubbleReport(emote)
U.log("happiness", tostring(game.save.pikachuHappiness or 90),
"mood", tostring(game.save.pikachuMood or 128))
@@ -209,7 +214,14 @@ return function(game)
-- wPikachuEmotionModifier 5 is MapSpecificPikachuExpression's fifth
-- entry, emotion 25: BOLT_BUBBLE plus PCM clip 35. Forcing it takes the
-- mood roll out of the picture, so a missing bubble here is a real fault.
U.wait(70) -- the 50 frame hold, plus slack, before input is looked at again
-- wait the pikapic beat out instead of counting frames: its length is the
-- script's now, and an A press during it would only cut it short
-- (PikaPicAnimTimerAndJoypad, #424)
for _ = 1, 400 do
if not ow.emote then break end
U.wait(1)
end
U.wait(6)
game.save.pikachuEmotionModifier = 5
check("still facing the follower for the second press", facingFollower())
before = #cries
+136
View File
@@ -0,0 +1,136 @@
-- Ear check: Route 8's wave-channel countermelody, at full volume and in the
-- right octave (#429). ROUTE_8 is MUSIC_ROUTES3 (pokered data/maps/songs.asm:22)
-- and Music_Routes3_Ch3 is a dense 2-frame-per-note line; ChipAudio shipped
-- ch3 at 0.25 volume and 0.5 pitch, a quarter as loud and an octave below the
-- hardware's own octave (audio/engine_1.asm:904-944 writes CHAN3's frequency
-- register unmodified). SELECT toggles the 0.1.38 mix back on to compare.
-- POKEPORT_DRIVER=tests/drivers/route8_music_bug429_test.lua POKEPORT_IDENTITY=bug429 POKEPORT_TOUCH=0 love .
-- No POKEPORT_SPEED: fast-forward scales the logic clock only and desyncs audio.
return function(game)
local U = dofile("tests/drivers/util.lua")
local ChipAudio = require("src.core.ChipAudio")
local Music = require("src.core.Music")
-- pokered data/maps/objects/Route8.asm keeps its trainers at (8,5), (13,9),
-- (26,3-6) and (42,6), and warps at x 1, 8 and 13, so the road east of the
-- gate is clear; (30, 8) is only a listening spot, walk anywhere from it.
local MAP = "ROUTE_8"
local SONG = "Music_Routes3"
local STAND = { x = 30, y = 8, facing = "down" }
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
return ok
end
-- ---- what the ear cannot check -----------------------------------------
local opts = game.save.options or {}
local musicVol = opts.musicVol or 7
local sfxVol = opts.sfxVol or 7
if musicVol == 0 then
U.log("FAIL music volume is 0: nothing below can be heard at all.")
U.log(" Set MUSIC to 7 in OPTION first.")
end
if sfxVol == 0 then
U.log("FAIL sfx volume is 0, so the step and menu sounds are gone too;")
U.log(" set SFX to 7 in OPTION if the run should sound normal.")
end
check(("music volume %d, sfx volume %d"):format(musicVol, sfxVol),
musicVol > 0)
check(MAP .. " plays " .. SONG,
(game.data.audio.mapSongs or {})[MAP] == SONG)
local vols = ChipAudio.getChannelVolumes()
local pitches = ChipAudio.getChannelPitches()
check(("the shipped wave mix is unity (volume %s, pitch %s)")
:format(tostring(vols[3]), tostring(pitches[3])),
vols[3] == 1 and pitches[3] == 1)
-- render the ch3 layer alone: a silent or near-silent wave track would make
-- every claim below unhearable no matter what the mix says
local song = (game.data.audio.songs or {})[SONG]
local function waveLayer()
return ChipAudio._renderMusicChannelForTest(game.data, song, 1.5, 3)
end
local function measure(sd)
local peak, crossings, prev = 0, 0, sd:getSample(0)
for index = 1, sd:getSampleCount() - 1 do
local sample = sd:getSample(index)
if math.abs(sample) > peak then peak = math.abs(sample) end
if prev * sample < 0 then crossings = crossings + 1 end
prev = sample
end
return peak, crossings
end
local peak, crossings = measure(waveLayer())
check(("the ch3 countermelody renders (peak %.3f, %d zero crossings)")
:format(peak, crossings), peak > 0.02 and crossings > 200)
ChipAudio.setChannelVolumes({ 1, 1, 0.25, 1 })
ChipAudio.setChannelPitches({ 1, 1, 0.5, 1 })
local brokenPeak, brokenCrossings = measure(waveLayer())
ChipAudio.setChannelVolumes({ 1, 1, 1, 1 })
ChipAudio.setChannelPitches({ 1, 1, 1, 1 })
check(("the 0.1.38 mix would render it at peak %.3f and %d crossings")
:format(brokenPeak, brokenCrossings),
brokenPeak < peak * 0.4 and brokenCrossings < crossings * 0.7)
-- the invariant tests/engine/wave_channel_mix_bug429.lua asserts on blobs;
-- this is the same statement measured on the ROM's own Music_Routes3
-- ---- stand on the road with the theme running --------------------------
U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing)
U.wait(20)
local ow = game.overworld
if not ow.map:isWalkableCell(STAND.x, STAND.y) then
-- a map edit or a mod blocked the cell: take any free neighbour, the
-- audio is what is being judged and any cell on Route 8 will do
for _, d in ipairs({ { 0, 1 }, { 0, -1 }, { 1, 0 }, { -1, 0 } }) do
local cx, cy = STAND.x + d[1], STAND.y + d[2]
if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then
U.log(("(%d, %d) is blocked, standing on"):format(STAND.x, STAND.y),
cx, cy)
U.teleport(game, MAP, cx, cy, STAND.facing)
U.wait(20)
ow = game.overworld
break
end
end
end
check("standing on " .. MAP, ow.map.id == MAP)
local function restart()
-- the playback queue holds ~6s of already-synthesized audio, so a mix
-- change is inaudible until the song is rebuilt from the new values
Music.stop()
Music.playMap(game.data, MAP)
end
restart()
U.wait(60)
U.log("Route 8's theme is running and the pad is yours; walking works.")
U.log("Under the lead there is a fast wave line, about two notes per beat,")
U.log("as loud as the melody and no lower than the octave it is written in.")
U.log("SELECT swaps in the 0.1.38 mix (quarter volume, a further octave")
U.log("down: a dull rumble that all but vanishes), SELECT again restores it.")
local broken = false
local held = false
while true do
local down = game.input:isDown("select")
if down and not held then
broken = not broken
if broken then
ChipAudio.setChannelVolumes({ 1, 1, 0.25, 1 })
ChipAudio.setChannelPitches({ 1, 1, 0.5, 1 })
U.log("0.1.38 mix: wave volume 0.25, one octave down")
else
ChipAudio.setChannelVolumes({ 1, 1, 1, 1 })
ChipAudio.setChannelPitches({ 1, 1, 1, 1 })
U.log("authentic mix: wave volume 1, pitch 1")
end
restart()
end
held = down
coroutine.yield()
end
end
+14 -5
View File
@@ -121,15 +121,24 @@ return function(game)
check("and the TM is still hers while it is up", not hasTm())
U.shot(game, DIR .. "/bug393_1_scared.png")
-- read the rest of the conversation the way a player does
for i = 2, 8 do
-- read the rest of the conversation the way a player does. The pleading runs
-- seven lines before GiveItem, and the gift box and the SELFDESTRUCT warning
-- follow it, so turn pages until the talk actually ends instead of counting
-- them out -- the bound is only there so a stuck box cannot hang the driver.
-- boxText reads the whole box, so several A presses walk one string: log a
-- box the first time it is seen, not once per page turn
local i, last = 1, first
for _ = 1, 40 do
U.tap(game, "a")
U.wait(40)
local t = boxText()
if not t then break end
U.log(("box %d reads:"):format(i), t)
if t:find("TM36", 1, true) then
U.shot(game, DIR .. "/bug393_2_tm36.png")
if t ~= last then
i, last = i + 1, t
U.log(("box %d reads:"):format(i), t)
if t:find("TM36", 1, true) then
U.shot(game, DIR .. "/bug393_2_tm36.png")
end
end
end
check("TM36 ended up in the bag", hasTm())
+10 -1
View File
@@ -30,7 +30,16 @@ return function(game)
local opts = game.save.options
local mode = opts and opts.colors or PaletteFX.mode
check("COLORS is SGB (the default this bug shows in)", mode == "gbc")
-- the seam only exists under the SGB zone clips, so put the renderer there
-- rather than trusting whatever COLORS the save was last left on (setMode
-- is display-only; the option itself is restored below)
if PaletteFX.mode ~= "gbc" then
U.log("COLORS is", tostring(mode) .. "; switching the view to SGB for the check")
PaletteFX.setMode("gbc")
U.wait(20)
end
check("the view is in SGB (the mode this bug shows in)",
PaletteFX.mode == "gbc")
if (opts and opts.musicVol or 0) == 0 then
U.log("WARNING music volume is 0: the title theme will be silent.")
end
@@ -0,0 +1,170 @@
-- Ear check on Music_YellowIntro across the cut to the title (#436): finish()
-- used to stop the song there, on both the scene-done exit and the A/B/START
-- skip. pokeyellow engine/movie/intro_yellow.asm PlayIntroScene
-- .go_to_title_screen (both exits land there) touches no audio at all; the
-- song only dies at engine/movie/title.asm:149 StopAllMusic, one instruction
-- before MUSIC_TITLE_SCREEN, which TitleState:startMusic stands in for.
-- POKEPORT_VERSION=yellow POKEPORT_DRIVER=tests/drivers/yellow_intro_music_bug436_test.lua POKEPORT_TOUCH=0 love .
-- SKIP=1 on the same command taps START 300 frames into the movie. Add
-- POKEPORT_IDENTITY only if that identity already carries a Yellow import;
-- an identity with no cache lands in the launcher and nothing runs.
-- Do not set POKEPORT_SPEED: it scales the logic clock only, so the movie and
-- the audio clock drift apart and the comparison means nothing.
return function(game)
local U = dofile("tests/drivers/util.lua")
local Music = require("src.core.Music")
local GameVersion = require("src.core.GameVersion")
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local SKIP = os.getenv("SKIP") == "1"
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
return ok
end
-- every music call in order, so "the song was cut" and "the song was never
-- started" read differently in the log; nothing under src/ changes
local events = {}
local realPlay, realStop = Music.play, Music.stop
Music.play = function(data, song, loop, ctx)
events[#events + 1] = { kind = "play", song = song, frame = U.frame() }
U.log(("frame %d play %s"):format(U.frame(), tostring(song)))
return realPlay(data, song, loop, ctx)
end
Music.stop = function(...)
events[#events + 1] = { kind = "stop", frame = U.frame() }
U.log(("frame %d stop"):format(U.frame()))
return realStop(...)
end
local function firstPlay(song)
for i, e in ipairs(events) do
if e.kind == "play" and e.song == song then return i, e end
end
return nil
end
local function stopBetween(a, b)
for i = a, b do
if events[i] and events[i].kind == "stop" then return events[i] end
end
return nil
end
local function waitFor(fn, limit)
for _ = 1, limit do
if fn() then return true end
U.wait(1)
end
return false
end
check("this build booted as Yellow", GameVersion.isYellow())
if not GameVersion.isYellow() then
U.log("Only Yellow plays this movie; rerun with POKEPORT_VERSION=yellow.")
while true do coroutine.yield() end
end
local opts = game.save.options
if (opts and opts.musicVol or 0) == 0 then
U.log("MUSIC VOLUME IS 0. There is nothing to hear. Raise it in OPTION,")
U.log("quit and rerun; the log half below still holds.")
end
if (opts and opts.sfxVol or 0) == 0 then
U.log("WARNING sfx volume is 0: the logo crash and the whoosh over the")
U.log("song will be silent, and Pikachu's cry with them.")
end
local songs = game.data.audio and game.data.audio.songs
local INTRO = songs and songs.Music_YellowIntro and "Music_YellowIntro"
or "Music_IntroBattle"
check("the movie's song is in the audio table", songs ~= nil
and songs[INTRO] ~= nil)
-- the copyright card and the GAME FREAK splash run before the scenes
-- (IntroMovie phases 1-2), so wait on the song rather than a frame count
local started = waitFor(function() return firstPlay(INTRO) ~= nil end, 900)
check("the movie started " .. INTRO, started)
if not started then
U.log("The movie never reached its own music; nothing below can run.")
while true do coroutine.yield() end
end
local introIdx = firstPlay(INTRO)
if SKIP then
-- PlayIntroScene:16-19, hJoyPressed & (PAD_A | PAD_B | PAD_START)
U.wait(300)
U.log("tapping START mid-movie (the skip exit)")
U.tap(game, "start")
end
local function titleState()
local top = game.stack:top()
if top and top.screenId == "TitleState" then return top end
return nil
end
-- the scene timers add up to about 1040 frames; the skip path lands sooner
local reached = waitFor(function() return titleState() ~= nil end, 1500)
check("the movie handed off to the title screen", reached)
if not reached then
U.log("The title never came up; nothing below can run.")
while true do coroutine.yield() end
end
local title = titleState()
local cut = U.frame()
U.log(("cut to the title at frame %d, %d frames after the song started")
:format(cut, cut - events[introIdx].frame))
-- the bug: a stop right here, at the cut
local cutStop = stopBetween(introIdx + 1, #events)
check("no music was stopped at the cut", cutStop == nil)
if cutStop then
U.log(("a stop landed on frame %d; before #436 that is finish() killing")
:format(cutStop.frame))
U.log("the song, and the title screen comes up silent until the cry.")
end
check("the title runs the Yellow logo-drop sequence, not the plain title",
title.yellowLayout == true and title.phase == "drop")
U.shot(game, SHOT_DIR .. "/bug436_logo_drop.png")
U.log("captured", SHOT_DIR .. "/bug436_logo_drop.png")
-- drop 32 frames, settle 36, bubble 3, then the cry (title.asm's
-- WaitForSoundToFinish) before MUSIC_TITLE_SCREEN
local seen = title.phase
for _ = 1, 600 do
if title.phase ~= seen then
seen = title.phase
U.log(("frame %d title phase %s"):format(U.frame(), seen))
if seen == "loop" then break end
end
U.wait(1)
end
check("the title sequence reached its interactive loop", title.phase == "loop")
-- read the song off the log rather than naming it: field.boot lets a mod
-- own the title theme (TitleState:startMusic, self.title.music)
local titleIdx, titlePlay
for i = introIdx + 1, #events do
if events[i].kind == "play" then titleIdx, titlePlay = i, events[i] break end
end
check("the title theme started", titlePlay ~= nil)
if titlePlay then
check("it started after the cut, not at it", titlePlay.frame > cut + 32)
U.log(("%s started %d frames after the cut"):format(
tostring(titlePlay.song), titlePlay.frame - cut))
check("nothing stopped the music between the two songs",
stopBetween(introIdx + 1, titleIdx - 1) == nil)
end
U.log("The title is up and the pad is yours. The one thing to hear: the")
U.log("intro song runs unbroken through the cut and on over the logo drop,")
U.log("with the crash and the whoosh layered on top, until the title theme")
U.log("takes over after Pikachu's cry. A break at the cut is the bug.")
U.log("Rerun with SKIP=1 for the START skip; it must sound the same.")
while true do
coroutine.yield()
end
end
+195
View File
@@ -0,0 +1,195 @@
-- The Mt Moon B2F Jessie & James ambush closes in, and vanishes behind a
-- fade (#423). pokeyellow MtMoonB2FScript_49e15 (scripts/MtMoonB2F.asm:225)
-- shows both objects, prints TEXT_MTMOONB2F_TEXT12, then simulates PAD_UP for
-- one player step; Script6/Script9 MoveSprite Jessie with MovementData_f9e65
-- (six $06) and James with f9e66 (its last five), and both objects carry
-- movement byte 2 = LEFT (data/maps/objects/MtMoonB2F.asm), which
-- .determineDirection (engine/overworld/movement.asm) turns into LEFT steps.
-- Script8/Script11 then face Jessie DOWN and James LEFT, and Script14 wraps
-- the two HideObjects in GBFadeOutToBlack / GBFadeInFromBlack. The port used
-- to jump from the motto straight to the challenge with the duo still parked
-- at (9,3)/(9,4), and popped them out with no fade.
-- ROM-free: reads data/scripts/ only.
-- luajit tests/engine/jessie_james_mtmoon_walk.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
love = require("tests.love_stub")
local sites = require("data.scripts.yellow_jessie_james")
-- pokeyellow data/maps/objects/MtMoonB2F.asm: object 2 is JESSIE at (9,3),
-- object 6 is JAMES at (9,4), both STAY LEFT. The trigger tile is (3,5).
local JESSIE, JAMES = 2, 6
local JESSIE_START = { x = 9, y = 3 }
local JAMES_START = { x = 9, y = 4 }
local TRIGGER = { x = 3, y = 5 }
-- captures the row list a site's onStep hands to the runner
local function rowsFor(site, x, y, flags)
local captured = nil
local ow = { runner = { run = function(_, rows) captured = rows end } }
local game = { save = { flags = flags or {} } }
local fired = sites[site].onStep(game, ow, x, y)
return captured, fired
end
local function indexOf(rows, pred, from)
for i = (from or 1), #rows do
if pred(rows[i]) then return i end
end
return nil
end
local function isRow(name, arg2)
return function(r)
return r[1] == name and (arg2 == nil or r[2] == arg2)
end
end
local function dirsMatch(dirs, count, want)
if type(dirs) ~= "table" or #dirs ~= count then return false end
for i = 1, count do
if dirs[i] ~= want then return false end
end
return true
end
-- ---- the ambush arms on the pokeyellow conditions ------------------------
check(type(sites.MT_MOON_B2F.onStep) == "function", "MT_MOON_B2F has an onStep")
local armed = { EVENT_GOT_HELIX_FOSSIL = true }
local rows, fired = rowsFor("MT_MOON_B2F", TRIGGER.x, TRIGGER.y, armed)
check(fired == true, "stepping on (3,5) with a fossil fires the ambush")
check(rows ~= nil, "the ambush handed a script to the runner")
rows = rows or {}
check(select(2, rowsFor("MT_MOON_B2F", TRIGGER.x, TRIGGER.y, {})) == false,
"no fossil in the bag, no ambush")
check(select(2, rowsFor("MT_MOON_B2F", TRIGGER.x, TRIGGER.y,
{ EVENT_GOT_HELIX_FOSSIL = true,
EVENT_BEAT_MT_MOON_3_JESSIE_JAMES = true })) == false,
"a beaten duo does not re-engage")
-- ---- the duo closes in ---------------------------------------------------
local motto = indexOf(rows, isRow("show_text", "_MtMoonJessieJamesText1"))
local challenge = indexOf(rows, isRow("show_text", "_MtMoonJessieJamesText2"))
local battle = indexOf(rows, isRow("start_battle"))
check(motto ~= nil, "the motto plays")
check(challenge ~= nil, "the challenge line plays")
check(battle ~= nil, "the fight starts")
local playerStep = indexOf(rows, function(r)
return r[1] == "walk_npc" and r[2] == "player"
end)
check(playerStep ~= nil, "the simulated PAD_UP step is walked")
if playerStep then
check(dirsMatch(rows[playerStep][3], 1, "up"),
"one step, since wSimulatedJoypadStatesIndex is 1")
check(motto and playerStep > motto,
"the step comes after TEXT12, as StartSimulatingJoypadStates does")
end
local jessieWalk = indexOf(rows, isRow("walk_npc", JESSIE))
local jamesWalk = indexOf(rows, isRow("walk_npc", JAMES))
check(jessieWalk ~= nil, "Jessie walks")
check(jamesWalk ~= nil, "James walks")
check(jessieWalk and jamesWalk and jessieWalk < jamesWalk,
"Script6 moves Jessie before Script9 moves James")
if jessieWalk then
check(dirsMatch(rows[jessieWalk][3], 6, "left"),
"Jessie walks the six $06 of MovementData_f9e65, resolved LEFT")
end
if jamesWalk then
check(dirsMatch(rows[jamesWalk][3], 5, "left"),
"James walks the five $06 of MovementData_f9e66, resolved LEFT")
end
-- where those step counts land them, against the object coordinates
if jessieWalk and jamesWalk and playerStep then
local playerX, playerY = TRIGGER.x, TRIGGER.y - 1
local jx = JESSIE_START.x - #rows[jessieWalk][3]
local jmx = JAMES_START.x - #rows[jamesWalk][3]
eq(jx, playerX, "Jessie ends in the player's column")
eq(JESSIE_START.y, playerY - 1, "Jessie ends one cell above the player")
eq(jmx, playerX + 1, "James ends one cell east of the player")
eq(JAMES_START.y, playerY, "James ends level with the player")
end
local faceJessie = indexOf(rows, isRow("face_object", JESSIE), jessieWalk)
local faceJames = indexOf(rows, isRow("face_object", JAMES), jamesWalk)
check(faceJessie ~= nil and rows[faceJessie][3] == "down",
"Script8 leaves Jessie facing DOWN at the player")
check(faceJames ~= nil and rows[faceJames][3] == "left",
"Script11 leaves James facing LEFT at the player")
local facePlayer = indexOf(rows, isRow("face_player_dir"))
check(facePlayer ~= nil and rows[facePlayer][2] == "up",
"the player turns up to the duo, not right")
local emote = indexOf(rows, isRow("emote", "player"))
check(emote ~= nil, "the player gets the exclamation bubble TEXT12 shows")
if challenge and jamesWalk and faceJames then
check(faceJames < challenge and challenge < (battle or math.huge),
"both walks finish before TEXT13, which precedes the fight")
end
-- ---- and leaves behind a fade -------------------------------------------
-- Script14: GBFadeOutToBlack, HideObject JESSIE, HideObject JAMES,
-- UpdateSprites, Delay3, GBFadeInFromBlack. Checked at all four sites: the
-- other three already faded, so they are the regression half.
local function checkFadedExit(site, label, x, y, flags)
local siteRows = rowsFor(site, x, y, flags)
check(siteRows ~= nil, label .. " handed a script to the runner")
if not siteRows then return end
local fadeOut = indexOf(siteRows, function(r)
return r[1] == "fade" and r[2] == "out"
end)
check(fadeOut ~= nil, label .. " fades out before the duo vanishes")
if not fadeOut then return end
local firstHide = indexOf(siteRows, isRow("hide_object"))
check(firstHide ~= nil and firstHide > fadeOut,
label .. " hides nobody while the screen is still lit")
local secondHide = indexOf(siteRows, isRow("hide_object"), (firstHide or 0) + 1)
check(secondHide ~= nil and secondHide == (firstHide or 0) + 1,
label .. " hides both of them inside the same fade")
local fadeIn = indexOf(siteRows, function(r)
return r[1] == "fade" and r[2] == "in"
end, (secondHide or 0) + 1)
check(fadeIn ~= nil, label .. " fades back in with the duo gone")
local music = indexOf(siteRows, isRow("play_default_music"))
check(music ~= nil and fadeIn and music > fadeIn,
label .. " resumes the map theme after the fade, as PlayDefaultMusic does")
end
checkFadedExit("MT_MOON_B2F", "Mt Moon B2F", TRIGGER.x, TRIGGER.y, armed)
checkFadedExit("ROCKET_HIDEOUT_B4F", "Rocket Hideout B4F", 24, 14, {})
checkFadedExit("POKEMON_TOWER_7F", "Pokemon Tower 7F", 10, 12, {})
checkFadedExit("SILPH_CO_11F", "Silph Co 11F", 3, 3,
{ EVENT_BEAT_SILPH_CO_GIOVANNI = true })
-- every site walks the duo in rather than fighting them from across the room
for _, site in ipairs({ "MT_MOON_B2F", "ROCKET_HIDEOUT_B4F",
"POKEMON_TOWER_7F", "SILPH_CO_11F" }) do
local siteRows = ({
MT_MOON_B2F = function() return rowsFor(site, TRIGGER.x, TRIGGER.y, armed) end,
ROCKET_HIDEOUT_B4F = function() return rowsFor(site, 25, 14, {}) end,
POKEMON_TOWER_7F = function() return rowsFor(site, 11, 12, {}) end,
SILPH_CO_11F = function()
return rowsFor(site, 2, 3, { EVENT_BEAT_SILPH_CO_GIOVANNI = true })
end,
})[site]()
local walk = siteRows and indexOf(siteRows, isRow("walk_npc"))
local start = siteRows and indexOf(siteRows, isRow("start_battle"))
check(walk ~= nil and start ~= nil and walk < start,
site .. " walks somebody before the battle begins")
end
T.finish("jessie james mt moon walk")
+73
View File
@@ -0,0 +1,73 @@
-- Yellow's Jessie & James battle behind their own pic while keeping the
-- ROCKET class and the name "ROCKET" (#439). pokeyellow
-- home/trainers2.asm:36-50 IsFightingJessieJames swaps wTrainerPicPointer
-- for JessieJamesPic when wTrainerClass is ROCKET and wTrainerNo >= $2a
-- (parties 42-45), and GetTrainerName right below it is untouched, so only
-- the pic changes. Red has no such symbol, so the swap must stay Yellow's.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local BattleState = require("src.battle.BattleState")
local CLASS_PIC = "assets/generated/battle/trainers/rocket.png"
local DUO_PIC = "assets/generated/battle/trainers/jessie_james.png"
-- a Yellow cache extracted after #439: OPP_ROCKET carries both pics
local yellow = { name = "ROCKET", pic = CLASS_PIC, picJessieJames = DUO_PIC }
-- Red/Blue, and any Yellow cache built before #439, carry only the class pic
local grunt = { name = "ROCKET", pic = CLASS_PIC }
for _, party in ipairs({ 42, 43, 44, 45 }) do
T.eq(BattleState.trainerPicPath(yellow, "OPP_ROCKET", party), DUO_PIC,
"party " .. party .. " fights behind the duo pic")
end
T.eq(yellow.name, "ROCKET", "the duo keeps the class name")
-- $2a is the first duo party; every grunt below it keeps the class pic
T.eq(BattleState.trainerPicPath(yellow, "OPP_ROCKET", 41), CLASS_PIC,
"party 41 is still a lone grunt")
T.eq(BattleState.trainerPicPath(yellow, "OPP_ROCKET", 1), CLASS_PIC,
"the first ROCKET party is still a lone grunt")
T.eq(BattleState.trainerPicPath(yellow, "OPP_ROCKET", nil), CLASS_PIC,
"an unnumbered ROCKET falls back to party 1")
T.eq(BattleState.trainerPicPath(
{ pic = "assets/generated/battle/trainers/super_nerd.png",
picJessieJames = DUO_PIC }, "OPP_SUPER_NERD", 42),
"assets/generated/battle/trainers/super_nerd.png",
"the class gate holds: only ROCKET swaps")
T.eq(BattleState.trainerPicPath(grunt, "OPP_ROCKET", 42), CLASS_PIC,
"a cache with no duo pic keeps the grunt instead of a nil pic")
-- The extractor writes jessie_james.png only when the symbol is in the
-- manifest, so the symbol table is the other half of the contract.
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
local yellowManifest = readFile("tools/rom_manifest_yellow.json")
T.check(yellowManifest ~= nil, "tools/rom_manifest_yellow.json is readable")
if yellowManifest then
local bank, addr = yellowManifest:match(
'"JessieJamesPic"%s*:%s*%[%s*(%d+)%s*,%s*(%d+)%s*%]')
T.eq(bank, "19", "JessieJamesPic sits in bank 0x13")
T.eq(addr, "31873", "JessieJamesPic sits at 0x7c81 in that bank")
end
for _, path in ipairs({ "tools/rom_manifest.json", "tools/rom_manifest_blue.json" }) do
local text = readFile(path)
T.check(text ~= nil, path .. " is readable")
T.check(text == nil or text:find("JessieJamesPic", 1, true) == nil,
path .. " has no such symbol, matching pokered")
end
-- the manifest is generated, so the generator has to keep asking for it
local gen = readFile("tools/make_yellow_manifest.py")
T.check(gen ~= nil, "tools/make_yellow_manifest.py is readable")
T.check(gen == nil or gen:find('"JessieJamesPic"', 1, true) ~= nil,
"a regenerated Yellow manifest keeps JessieJamesPic")
T.finish("jessie james pic")
+134
View File
@@ -0,0 +1,134 @@
-- Launcher Delete affordance (src/import/RomImporter.lua): the per-frame hit
-- rects a draw() clears, and the two-click arm that guards both save-slot and
-- mod deletes (#433). Drives RomImporter:mousepressed / :_resetFrameRects on a
-- bare instance, so no window, no cache and no real save files are involved.
-- luajit tests/engine/launcher_delete_confirm.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")
-- mousepressed timestamps the arm and expires it, so the clock has to move
local clock = 1000
love.timer.getTime = function() return clock end
local RomImporter = require("src.import.RomImporter")
local function rect(id, y)
return { x = 100, y = y or 200, width = 40, height = 14, id = id }
end
-- Only the fields mousepressed reads on its way to the Delete loops, plus
-- recorders in place of the two destructive calls.
local function launcher()
local self = setmetatable({}, RomImporter)
self.android = false
self.panelVersion = "red"
self.tab = "red"
self.slotScroll = {}
self.deletedSlots = {}
self.deletedMods = {}
self.selected = {}
self._deleteSlot = function(_, version, id)
table.insert(self.deletedSlots, version .. "/" .. id)
end
self._deleteMod = function(_, id) table.insert(self.deletedMods, id) end
self._selectSlot = function(_, version, id)
table.insert(self.selected, version .. "/" .. id)
end
return self
end
local function clickDelete(self, r)
self:mousepressed(r.x + 2, r.y + 2, 1)
end
-- ------- a frame that draws no panel leaves no Delete rect behind
do
local self = launcher()
self.slotDeleteRects = { rect("slot1") }
self.modDeleteRects = { rect("bigmod", 260) }
self.slotRects = { rect("slot1") }
self.modRects = { rect("bigmod", 260) }
self:_resetFrameRects()
eq(self.slotDeleteRects, nil, "a frame reset drops the save Delete rects")
eq(self.modDeleteRects, nil, "a frame reset drops the mod Delete rects")
eq(self.slotRects, nil, "and the slot rows they sit on")
eq(self.modRects, nil, "and the mod toggles")
-- the reporter's click: mods tab is up, the press lands where the game tab
-- drew Delete last time it was shown
self.tab = "mods"
clickDelete(self, rect("slot1"))
eq(#self.deletedSlots, 0, "a press on a stale Delete spot deletes nothing")
end
-- ------- a save Delete needs two clicks on the same row
do
local self = launcher()
local r = rect("slot1")
self.slotDeleteRects = { r }
clickDelete(self, r)
eq(#self.deletedSlots, 0, "the first click on Delete does not delete")
check(self._confirmDelete ~= nil and self._confirmDelete.id == "slot1",
"the first click arms that row")
clickDelete(self, r)
eq(self.deletedSlots[1], "red/slot1", "the second click on it deletes")
eq(self._confirmDelete, nil, "the arm is spent")
end
-- ------- the arm is per row, per version, and any other press clears it
do
local self = launcher()
local one, two = rect("slot1", 200), rect("slot2", 230)
self.slotDeleteRects = { one, two }
clickDelete(self, one)
clickDelete(self, two)
eq(#self.deletedSlots, 0, "a click on another row's Delete only arms that row")
eq(self._confirmDelete.id, "slot2", "the arm moved to the row just clicked")
self.slotRects = { rect("slot3", 260) }
clickDelete(self, one) -- re-arm slot1
self:mousepressed(102, 262, 1) -- press somewhere else entirely
clickDelete(self, one)
eq(#self.deletedSlots, 0, "a press elsewhere disarms, so Delete asks again")
self:mousepressed(102, 262, 1) -- clear the arm left by the pair above
clickDelete(self, one)
self.panelVersion = "blue"
clickDelete(self, one)
eq(#self.deletedSlots, 0, "an arm from one game's tab cannot fire on another")
end
-- ------- a stale arm expires instead of committing much later
do
local self = launcher()
local r = rect("slot1")
self.slotDeleteRects = { r }
clickDelete(self, r)
clock = clock + 30
clickDelete(self, r)
eq(#self.deletedSlots, 0, "an arm older than the confirm window is dead")
clickDelete(self, r)
eq(self.deletedSlots[1], "red/slot1", "and the fresh pair still deletes")
end
-- ------- mods delete arms the same way
do
local self = launcher()
local r = rect("bigmod", 260)
self.modDeleteRects = { r }
clickDelete(self, r)
eq(#self.deletedMods, 0, "the first click on a mod's Delete does not delete")
clickDelete(self, r)
eq(self.deletedMods[1], "bigmod", "the second click removes the mod")
end
T.finish("launcher delete confirm")
+88
View File
@@ -0,0 +1,88 @@
-- The launcher's stray-mod scan must not borrow a game folder that is already
-- on the physfs read path: PHYSFS_mount succeeds for such a folder without
-- adding a search-path entry, so the paired unmount drops the mount the
-- portable cache and its mods/ live on, and the MODS panel empties (#413).
-- Runs the real SaveData.gameFolders / LauncherMods scan over a fused macOS
-- layout, with a search path that copies those physfs semantics.
-- luajit tests/engine/launcher_stray_mount.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")
-- scripts/build.sh mac layout: the game is an archive inside the .app, the
-- player's portable folder is the one holding the .app.
local PORTABLE = "/Users/p/Games"
local APP = PORTABLE .. "/gen1recomp.app"
local SOURCE = APP .. "/Contents/Resources/game.love"
local BASE = APP .. "/Contents/MacOS"
love.system = love.system or {}
love.system.getOS = function() return "OS X" end
love.filesystem.getSource = function() return SOURCE end
love.filesystem.getSourceBaseDirectory = function() return BASE end
local CacheFs = require("src.import.CacheFs")
local SaveData = require("src.core.SaveData")
local LauncherMods = require("src.mods.LauncherMods")
-- ------- isReadableRoot: the two folders a fused build can already read
do
check(LauncherMods.isReadableRoot(SOURCE, SOURCE, PORTABLE),
"the physfs source is already readable")
check(LauncherMods.isReadableRoot(PORTABLE, SOURCE, PORTABLE),
"so is the portable folder CacheFs mounted")
check(not LauncherMods.isReadableRoot(BASE, SOURCE, PORTABLE),
"a folder beside them is not, so it still needs a scan mount")
check(not LauncherMods.isReadableRoot(nil, SOURCE, PORTABLE),
"no folder is not a readable folder")
check(not LauncherMods.isReadableRoot("", SOURCE, PORTABLE),
"nor is an empty path, which would match an unresolved cache root")
check(not LauncherMods.isReadableRoot(PORTABLE, SOURCE, nil),
"with no cache root mounted the portable folder is scannable again")
end
-- ------- withMounted answers "could not look", never "nothing there"
do
eq(CacheFs.withMounted("", "stray_scan", function() return "ran" end), nil,
"an empty dir is refused without running fn")
end
-- ------- the scan over that layout leaves the portable mount standing
do
eq(SaveData.gameFolders()[1], PORTABLE,
"the folder holding the .app is the first game folder")
-- PHYSFS_mount on a directory already in the search path bails with success
-- and adds nothing (physfs 2.x and 3.x alike), while PHYSFS_unmount removes
-- whatever entry is there -- so a borrowed mount of an already-mounted
-- folder costs the caller the real one.
local searchPath = { [PORTABLE] = true }
local mounted = {}
CacheFs.root = function() return PORTABLE end
CacheFs.withMounted = function(dir, mountPoint, fn)
mounted[#mounted + 1] = dir
if not searchPath[dir] then searchPath[dir] = true end
local res = fn()
searchPath[dir] = nil
return res
end
LauncherMods.strays()
check(searchPath[PORTABLE],
"the portable folder is still mounted after the stray scan")
for _, dir in ipairs(mounted) do
check(dir ~= PORTABLE, "the scan never remounts the portable folder")
check(dir ~= SOURCE, "nor the source, whose mods/ is readable already")
end
eq(mounted[1], BASE,
"the folders that do need a mount are still scanned")
end
T.finish("launcher stray scan mount")
+208
View File
@@ -0,0 +1,208 @@
-- #442: an Android pick that cannot be used must say so. GameActivity writes
-- pick_error.flag when it has no readable stream for the chosen URI, and a pick
-- it did copy but the importer refuses (wrong size, unknown SHA-1) has to reach
-- startData's messages instead of leaving the launcher silent with the file
-- stuck on disk. Covers the routing, the removal, and the #167 carve-out for a
-- cart that is merely already imported.
-- luajit tests/engine/rom_pick_error_bug442.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 GameVersion = require("src.core.GameVersion")
local RomImporter = require("src.import.RomImporter")
local MiB = 1024 * 1024
local redData = string.rep("R", MiB)
local hackData = string.rep("X", MiB) -- 1 MiB, matches no known cart
local shortData = string.rep("R", MiB / 2) -- trimmed dump
local UNKNOWN_SHA1 = "0000000000000000000000000000000000000000"
-- Map the fake blobs onto real version ids by first byte, so no crypto library
-- is needed headless (same trick as tests/rom_importer_android_pick_test.lua).
love.data = { hash = function(_, data) return { tag = data:sub(1, 1) } end }
love.data.encode = function(_, _, digest)
if type(digest) == "table" and digest.tag == "R" then
return GameVersion.info("red").sha1
end
return UNKNOWN_SHA1
end
love.filesystem.getSaveDirectory = function() return "/sdcard/pokeport/save" end
local pickCalls = {}
love.system = {
getOS = function() return "Android" end,
pickFile = function(kind)
pickCalls[#pickCalls + 1] = kind or "rom"
return true
end,
}
-- Pre-fix these paths leave the launcher untouched, so read the message
-- defensively: every check should report, not blow up on the first nil.
local function detail(ri) return tostring(ri.detail or "") end
local function clearSaveDir()
for _, name in ipairs(love.filesystem.getDirectoryItems("")) do
love.filesystem.remove(name)
end
end
-- Only the fields the Android focus / choose / chooseMod paths read. _installMod
-- is stubbed per case because the outcome it reports is what consumePick keys on.
local function freshImporter(opts)
opts = opts or {}
pickCalls = {}
return setmetatable({
android = true,
launcher = true,
workState = nil,
tab = opts.tab or "red",
ready = { red = opts.redReady and true or false, blue = false, yellow = false },
saveNotice = {},
modNotice = nil,
notice = nil,
slotScroll = {},
activeSlot = {},
_installMod = function(self, name)
self._installed = name
self.modNotice = { ok = opts.modOk and true or false, text = "install result" }
end,
_importSave = function(self, version, name)
self._imported = { version = version, name = name }
self.saveNotice[version] = { ok = false, text = "import result" }
end,
}, RomImporter)
end
-- ------- pick_error.flag: GameActivity could not read the chosen URI at all
do
clearSaveDir()
love.filesystem.write("pick_error.flag", "picked_rom.gb")
local ri = freshImporter({})
ri:focus(true)
eq(ri.workState, "error", "an unreadable ROM pick lands on the error state")
check(detail(ri):find("Could not read the picked file", 1, true),
"the ROM notice names the unreadable pick")
check(detail(ri):find("/sdcard/pokeport/save", 1, true),
"the notice offers the save dir as the copy-it-yourself fallback")
eq(love.filesystem.getInfo("pick_error.flag"), nil,
"the flag is consumed, so refocusing does not re-report it")
end
do
clearSaveDir()
love.filesystem.write("pick_error.flag", "picked_mod.zip")
local ri = freshImporter({})
ri:focus(true)
check(ri.modNotice ~= nil and ri.modNotice.ok == false,
"an unreadable mod pick reports on the mods panel")
eq(ri.workState, nil, "a failed mod pick does not error out the ROM panel")
end
do
clearSaveDir()
love.filesystem.write("pick_error.flag", "picked_save.sav")
local ri = freshImporter({ redReady = true, tab = "mods" })
ri.androidPendingVersion = "blue"
ri:focus(true)
check(ri.saveNotice.blue ~= nil and ri.saveNotice.blue.ok == false,
"an unreadable save pick reports on the game it was picked for")
eq(ri.androidPendingVersion, nil, "the pending save target is consumed with it")
end
-- ------- a pick that copied fine but the importer refuses
do
clearSaveDir()
love.filesystem.write("picked_rom.gb", shortData)
local ri = freshImporter({})
ri:focus(true)
eq(ri.workState, "error", "a trimmed pick reports instead of staying silent")
check(detail(ri):find("1 MiB", 1, true), "the wrong-size message names the size")
eq(love.filesystem.getInfo("picked_rom.gb"), nil,
"the refused pick is dropped so the next tap starts clean")
end
do
clearSaveDir()
love.filesystem.write("picked_rom.gb", hackData)
local ri = freshImporter({})
ri:focus(true)
eq(ri.workState, "error", "a cart matching no known SHA-1 reports")
check(detail(ri):find(UNKNOWN_SHA1, 1, true), "the message quotes the hash")
check(detail(ri):find("[b] or [BF]", 1, true),
"the message names the dump tags that can never verify")
eq(love.filesystem.getInfo("picked_rom.gb"), nil, "the bad dump is dropped")
end
do
clearSaveDir()
love.filesystem.write("picked_rom.gb", redData)
local ri = freshImporter({ redReady = true })
ri:focus(true)
eq(ri.workState, nil, "an already-imported cart is not an error (#167)")
check(love.filesystem.getInfo("picked_rom.gb") ~= nil,
"#167's leftover is left alone, not reported and deleted")
end
do
clearSaveDir()
love.filesystem.write("picked_rom.gb", redData)
local read = love.filesystem.read
love.filesystem.read = function(name)
if name == "picked_rom.gb" then return nil end
return read(name)
end
local ri = freshImporter({})
ri:focus(true)
love.filesystem.read = read
eq(ri.workState, "error", "a pick present but unreadable from Lua reports too")
check(detail(ri):find("could not be read", 1, true),
"the unreadable-file message points at the picker")
end
-- Choose must explain the refused pick rather than reopening the picker over it.
do
clearSaveDir()
love.filesystem.write("picked_rom.gb", hackData)
local ri = freshImporter({})
ri:choose("blue")
eq(#pickCalls, 0, "Choose reports the refused pick before reopening the picker")
eq(ri.workState, "error", "Choose surfaces the same message focus does")
end
-- ------- a rejected pick must not wall off the branches under it
do
clearSaveDir()
love.filesystem.write("picked_mod.zip", "not a zip")
love.filesystem.write("picked_rom.gb", shortData)
local ri = freshImporter({})
ri:focus(true)
eq(ri._installed, "picked_mod.zip", "the mod branch runs first")
eq(love.filesystem.getInfo("picked_mod.zip"), nil,
"a rejected SAF mod pick is retired, not left to win every refocus")
ri:focus(true)
eq(ri.workState, "error", "the next focus reaches the ROM pick under it")
end
-- A USB copy is the player's own file: keep it, but stop retrying it this session.
do
clearSaveDir()
love.filesystem.write("mymod.zip", "not a zip")
local ri = freshImporter({})
ri:chooseMod()
eq(ri._installed, "mymod.zip", "Import Mod picks up a USB copy")
check(love.filesystem.getInfo("mymod.zip") ~= nil, "the player's own file stays")
ri:chooseMod()
eq(#pickCalls, 1, "the second tap opens the picker instead of retrying it")
eq(pickCalls[1], "mod", "and asks for a mod archive")
end
clearSaveDir()
T.finish()
+249
View File
@@ -0,0 +1,249 @@
-- #420: importing a .sav on Android. Two halves: a pick that fails has to be
-- retired so the next tap reopens the picker instead of re-running the same
-- file forever, and the crosswalk tables have to come out of the target game's
-- ROM cache -- the launcher runs before CacheFs.mountVersion, so a bare require
-- sees Red's copy at best and nothing at all in a fused build.
-- luajit tests/engine/save_import_retry_bug420.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 GameVersion = require("src.core.GameVersion")
-- ------------------------------------------------------------------
-- Bug A: a failed Android save pick must not block the retry
-- ------------------------------------------------------------------
local RomImporter = require("src.import.RomImporter")
local pickCalls = {}
love.system = {
getOS = function() return "Android" end,
pickFile = function(kind)
pickCalls[#pickCalls + 1] = kind or "rom"
return true
end,
}
love.filesystem.getSaveDirectory = function() return "/sdcard/pokeport/save" end
local function clearSaveDir()
for _, name in ipairs(love.filesystem.getDirectoryItems("")) do
love.filesystem.remove(name)
end
end
-- Only the fields the Android save-import path reads. _importSave is stubbed
-- with the outcome consumePick keys on, since the whole question is what the
-- launcher does with the file afterwards.
local function freshImporter(importOk)
pickCalls = {}
return setmetatable({
android = true,
workState = nil,
tab = "red",
ready = { red = true, blue = false, yellow = false },
saveNotice = {},
notice = nil,
_importSave = function(self, version, name)
self._imports = self._imports or {}
self._imports[#self._imports + 1] = { version = version, name = name }
self.saveNotice[version] = { ok = importOk and true or false, text = "result" }
end,
}, RomImporter)
end
do
clearSaveDir()
love.filesystem.write("picked_save.sav", "too short to be SRAM")
local ri = freshImporter(false)
ri:chooseSaveImport("red")
eq(#ri._imports, 1, "the pending SAF pick is what Import save consumes")
eq(love.filesystem.getInfo("picked_save.sav"), nil,
"a rejected SAF pick is dropped: it is GameActivity's own copy")
ri:chooseSaveImport("red")
eq(#pickCalls, 1, "the second tap reopens the picker instead of retrying it")
eq(pickCalls[1], "sav", "and asks for a battery save")
eq(#ri._imports, 1, "the rejected file is not run through the importer twice")
end
do
clearSaveDir()
love.filesystem.write("picked_save.sav", "32768 bytes, as far as this test cares")
local ri = freshImporter(true)
ri:chooseSaveImport("blue")
eq(love.filesystem.getInfo("picked_save.sav"), nil,
"a pick that imported cleanly is still removed")
eq(#pickCalls, 0, "and the picker is not reopened over a successful import")
end
-- A USB copy is the player's own file sitting in the save dir, so a failed one
-- is skipped for the session rather than deleted.
do
clearSaveDir()
love.filesystem.write("pokemon_red.sav", "wrong size")
local ri = freshImporter(false)
ri:chooseSaveImport("red")
eq(ri._imports[1].name, "pokemon_red.sav", "Import save picks up a USB copy")
check(love.filesystem.getInfo("pokemon_red.sav") ~= nil,
"the player's own .sav stays on disk")
ri:chooseSaveImport("red")
eq(#pickCalls, 1, "the skipped USB copy no longer wins the scan")
eq(#ri._imports, 1, "and is not re-imported")
end
-- The focus path (SAF returns, GameActivity has written the pick) retires the
-- same way, so a refocus loop cannot re-run a rejected file.
do
clearSaveDir()
love.filesystem.write("picked_save.sav", "wrong size")
local ri = freshImporter(false)
ri.androidPendingVersion = "yellow"
ri:focus(true)
eq(ri._imports[1].version, "yellow", "focus imports for the game that was picked for")
eq(love.filesystem.getInfo("picked_save.sav"), nil, "and retires the rejected pick")
ri:focus(true)
eq(#ri._imports, 1, "the next focus has nothing left to re-import")
end
clearSaveDir()
-- ------------------------------------------------------------------
-- Bug B: the crosswalk comes from the target game's ROM cache
-- ------------------------------------------------------------------
-- Fake CacheFs, installed before SaveConvert is required so its lazy require
-- picks it up. Each read reports the prefix it was asked under and hands back
-- a module tagged with it, which is what makes "whose cache" observable.
local reads = {}
local SENTINEL = "SENTINEL/"
local cacheFails = false
local fakeCache = { prefix = SENTINEL }
function fakeCache.read(path)
reads[#reads + 1] = { path = path, prefix = fakeCache.prefix }
if cacheFails then error("no such cache entry: " .. path) end
return ("return { cacheTag = %q, path = %q }")
:format(tostring(fakeCache.prefix), path)
end
package.loaded["src.import.CacheFs"] = fakeCache
local SaveConvert = require("src.save_convert.SaveConvert")
local GENERATED = { "pokemon", "moves", "items", "maps" }
local function prefixes()
local seen = {}
for _, r in ipairs(reads) do seen[r.prefix] = (seen[r.prefix] or 0) + 1 end
return seen
end
do
reads = {}
local data, err = SaveConvert.loadData("blue")
check(type(data) == "table", "loadData(blue) resolves: " .. tostring(err))
for _, name in ipairs(GENERATED) do
eq(data and data[name] and data[name].cacheTag, GameVersion.VERSIONS.blue.cachePrefix,
name .. " comes out of Blue's cache, not the un-prefixed read path")
end
eq(prefixes()[GameVersion.VERSIONS.blue.cachePrefix], #GENERATED,
"all four generated tables are read under Blue's cache prefix")
eq(fakeCache.prefix, SENTINEL,
"CacheFs.prefix is launcher-owned state and is put back after the read")
check(data and data.eventFlags ~= nil,
"the save-convert-only crosswalks still load through require")
end
do
reads = {}
SaveConvert.loadData("blue")
eq(#reads, 0, "a second load for the same game reuses the cached set")
end
-- importSav's 2nd arg is the save-format stamp; the 3rd is what selects the
-- crosswalk. Red's cache prefix is the empty string, so the recorded prefix
-- doubles as proof the read went through CacheFs at all.
local zeros = string.rep("\0", SaveConvert.SAVE_SIZE)
do
reads = {}
SaveConvert.importSav(zeros, 2, "red")
eq(prefixes()[GameVersion.VERSIONS.red.cachePrefix], #GENERATED,
"importSav reads the crosswalk from the game named by its 3rd argument")
end
do
reads = {}
SaveConvert.importSav(zeros, 2)
eq(#reads, 0, "with no game named, the format stamp is not read as one")
end
do
reads = {}
local ok, _, err = pcall(SaveConvert.exportSav, { meta = {} }, "yellow")
check(ok, "exportSav takes the same game argument without raising: " .. tostring(err))
eq(prefixes()[GameVersion.VERSIONS.yellow.cachePrefix], #GENERATED,
"exportSav reads Yellow's crosswalk on the way back out")
end
do
reads = {}
local data = SaveConvert.loadData("yellow")
eq(data and data.maps and data.maps.cacheTag, GameVersion.VERSIONS.yellow.cachePrefix,
"Yellow gets Yellow's tables: the set is keyed per game, not global")
eq(#reads, 0, "and shares the set exportSav already warmed")
end
-- The require path still has to carry SaveConvert under plain luajit, where
-- there is no cache to read from.
do
reads = {}
cacheFails = true
local ok, data, err = pcall(SaveConvert.loadData, "red")
cacheFails = false
check(ok, "an unreadable cache falls back instead of raising: " .. tostring(data))
if loadfile("data/generated/maps.lua") then
check(type(data) == "table",
"the require path still resolves the tables headless: " .. tostring(err))
end
end
-- ------------------------------------------------------------------
-- The launcher's glue names the game it is importing for (cross-file contract)
-- ------------------------------------------------------------------
do
local seen = {}
package.loaded["src.save_convert.SaveConvert"] = {
SAVE_SIZE = 32768,
importSav = function(_, version, gameVersion)
seen.import = { version = version, gameVersion = gameVersion }
return nil, "stub"
end,
exportSav = function(_, gameVersion)
seen.export = { gameVersion = gameVersion }
return nil, "stub"
end,
}
package.loaded["src.core.SaveData"] = {
load = function() return { meta = {} } end,
activeSlot = function() return "slot1" end,
buildMeta = function() return {} end,
createSlot = function() return "slot1" end,
writeSlot = function() return true end,
setActiveSlot = function() return true end,
}
local SaveFileIO = require("src.import.SaveFileIO")
SaveFileIO.importToSlot(string.rep("\0", 32768), "yellow")
eq(seen.import and seen.import.gameVersion, "yellow",
"importToSlot tells SaveConvert whose cache to read")
eq(seen.import and seen.import.version, "yellow",
"and still passes the version stamp it always did")
SaveFileIO.exportActiveSlot("blue")
eq(seen.export and seen.export.gameVersion, "blue",
"exportActiveSlot names the game on the way back out")
end
T.finish()
+105
View File
@@ -0,0 +1,105 @@
-- A successful Teleport/Roar/Whirlwind ends the turn where it lands: the
-- second mover never moves and the residual sweep never runs (#438).
-- MainInBattleLoop reads wEscapedFromBattle after every Execute*Move and
-- `ret nz` (engine/battle/core.asm:419-457), leaving the loop before the
-- other side's move and before HandlePoisonBurnLeechSeed; the port queues
-- both moves plus endOfTurn up front, so each has to re-read battle.result.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Data = T.fixtures.fresh()
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 TypeChart = require("src.battle.TypeChart")
TypeChart.load(Data)
-- keyed TELEPORT because SwitchAndTeleportEffect picks its escape and
-- failure text off the move id (Roar and Whirlwind read differently)
Data.moves.TELEPORT = {
id = "TELEPORT", index = 97, name = "FIX TELEPORT",
type = "PSYCHIC", power = 0, accuracy = 100, pp = 20,
effect = "SWITCH_AND_TELEPORT_EFFECT",
}
Data.moves.FIX_SING = {
id = "FIX_SING", index = 98, name = "FIX SING",
type = "NORMAL", power = 0, accuracy = 55, pp = 15,
effect = "SLEEP_EFFECT",
}
local save = SaveData.newGame()
save.party = { Pokemon.new(Data, "FIXMON_A", 20) }
local game = { data = Data, save = save,
stack = { top = function() return nil end, push = function() end } }
-- foeLevel decides SwitchAndTeleportEffect's auto-success (effects.asm:
-- 810-909): at or above the player's level it always escapes, below it
-- the rng(0, sum) roll of 0 below foeLevel/4 always fails
local function setup(foeLevel)
local battle = BattleState.newWild(game, "FIXMON_C", foeLevel)
battle.rng = function() return 0 end
battle.enemyAction = function() return { id = "TELEPORT", pp = 20 } end
battle.enemy.curStats.speed = 200 -- the foe moves first
battle.player.curStats.speed = 1
battle.player.mon.status = "PSN" -- something for the residual sweep to do
battle.player.mon.hp = battle.player.curStats.hp
return battle, { id = "FIX_SING", pp = 15 }
end
-- consume the queue the way updateQueue does, minus the presentation
local function drain(battle)
local texts = {}
for _ = 1, 400 do
local item = table.remove(battle.queue, 1)
if not item then return texts end
if item.text then texts[#texts + 1] = item.text end
if item.fn then
battle.nextInsert = 0
item.fn()
end
end
error("the turn queue never drained")
end
local function saidWith(texts, needle)
for _, text in ipairs(texts) do
if text:find(needle, 1, true) then return true end
end
return false
end
local escaped, sing = setup(40)
local hpBefore = escaped.player.mon.hp
escaped:resolveTurn(sing)
local texts = drain(escaped)
T.eq(escaped.result, "run", "the escape decides the battle")
T.eq(escaped.afterQueue, "finish", "the queue closes the battle")
T.check(saidWith(texts, "ran from battle"), "the foe announces the escape")
T.check(not saidWith(texts, "FIX SING"), "the player's move never announces")
T.eq(texts[#texts]:find("ran from battle", 1, true) ~= nil, true,
"the escape line is the last thing printed")
T.eq(escaped.enemy.mon.status, nil, "the foe is gone, not asleep")
T.eq(sing.pp, 15, "the unspent move keeps its PP")
T.eq(escaped.player.mon.hp, hpBefore, "no residual poison tick after the escape")
-- the guard must not swallow the ordinary turn: a failed Teleport leaves
-- result nil, so the player still moves and the residual sweep still runs
local stayed, sing2 = setup(8)
local hpBefore2 = stayed.player.mon.hp
stayed:resolveTurn(sing2)
local texts2 = drain(stayed)
T.eq(stayed.result, nil, "a failed escape decides nothing")
T.eq(stayed.afterQueue, "menu", "the turn hands back to the menu")
T.check(saidWith(texts2, "But, it failed!"), "the failed Teleport says so")
T.check(saidWith(texts2, "FIX SING"), "the player still moves")
T.eq(stayed.enemy.mon.status, "SLP", "the foe still falls asleep")
T.eq(sing2.pp, 14, "the move that ran spent its PP")
T.check(stayed.player.mon.hp < hpBefore2, "the residual sweep still ticks poison")
Data.moves.TELEPORT = nil
Data.moves.FIX_SING = nil
T.finish("teleport ends turn")
+130
View File
@@ -0,0 +1,130 @@
-- A wild mon that escapes with TELEPORT ends the turn where it lands
-- (#441). MainInBattleLoop reads wEscapedFromBattle right after
-- ExecuteEnemyMove and rets (engine/battle/core.asm:417-421, 456-460), so
-- the second mover never announces, HandlePoisonBurnLeechSeed never runs
-- the residual sweep, and CheckNumAttacksLeft never releases a trapping
-- counter. resolveTurn queues both movers plus endOfTurn up front, so the
-- escape has to be observed by the rows themselves.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Data = T.fixtures.fresh()
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 TypeChart = require("src.battle.TypeChart")
TypeChart.load(Data)
Data.moves.TELEPORT = {
id = "TELEPORT", index = 100, name = "TELEPORT",
type = "PSYCHIC", power = 0, accuracy = 100, pp = 20,
effect = "SWITCH_AND_TELEPORT_EFFECT",
}
local function newGame()
local save = SaveData.newGame()
save.party = { Pokemon.new(Data, "FIXMON_A", 30) }
return { data = Data, save = save,
stack = { top = function() return nil end, push = function() end } }
end
-- enemy first: the escaping mon outspeeds, and it holds TELEPORT alone so
-- the wild success path (level 40 >= level 30) cannot roll a failure
local function setup(enemyMove)
local battle = BattleState.newWild(newGame(), "FIXMON_C", 40)
battle.player.mon.stats.speed = 1
battle.enemy.mon.stats.speed = 200
battle.enemy.mon.moves = { { id = enemyMove, pp = 20 } }
battle.enemy.curMoves = battle.enemy.mon.moves
battle.enemyAction = function() return battle.enemy.curMoves[1] end
battle.rng = function(lo) return lo end -- accuracy roll hits, damage roll floors
return battle
end
-- drain the queue the way updateQueue does (fn rows run with nextInsert
-- reset so sayNext/actNext land right behind the current row), collecting
-- every message the turn would have printed
local function pump(battle)
local said = {}
local guard = 0
while battle.queue[1] and guard < 400 do
guard = guard + 1
local item = table.remove(battle.queue, 1)
if item.fn then
battle.nextInsert = 0
item.fn()
elseif item.text then
said[#said + 1] = item.text
end
end
T.check(guard < 400, "the turn queue drained")
return said
end
local function saidWith(said, needle)
for _, line in ipairs(said) do
if line:find(needle, 1, true) then return line end
end
return nil
end
-- --- the escape turn -------------------------------------------------
local battle = setup("TELEPORT")
battle.player.mon.status = "PSN"
local tackle = battle.player.curMoves[1]
local startEnemyHP, startPlayerHP = battle.enemy.mon.hp, battle.player.mon.hp
local startPP = tackle.pp
battle:resolveTurn(tackle)
local said = pump(battle)
T.check(saidWith(said, "ran from battle!"), "the wild mon announces the escape")
T.eq(battle.result, "run", "the escape settles the battle as a run")
T.eq(battle.afterQueue, "finish", "the queue finishes instead of reopening the menu")
T.check(not saidWith(said, "FIX TACKLE"),
"the second mover never announces its move after the escape")
T.eq(battle.enemy.mon.hp, startEnemyHP, "the escaped mon takes no damage")
T.eq(tackle.pp, startPP, "the lost turn costs the second mover no PP")
T.check(not saidWith(said, "hurt by"), "the residual sweep is skipped")
T.eq(battle.player.mon.hp, startPlayerHP, "no poison tick on the escape turn")
-- --- the trapping counter, on its own -------------------------------
-- a counter sitting at 0 holds its victim until CheckNumAttacksLeft clears
-- it, so it rides a separate turn: the held player must not be what keeps
-- the second mover from moving above.
local trap = setup("TELEPORT")
trap.enemy.trappingTurns = 0
trap:resolveTurn(trap.player.curMoves[1])
pump(trap)
T.eq(trap.result, "run", "the escape still settles the trapped turn")
T.eq(trap.enemy.trappingTurns, 0,
"CheckNumAttacksLeft does not release the counter on the escape turn")
local trapOk = setup("FIX_TACKLE")
trapOk.enemy.trappingTurns = 0
trapOk:resolveTurn(trapOk.player.curMoves[1])
pump(trapOk)
T.eq(trapOk.enemy.trappingTurns, nil,
"an ordinary turn releases the spent counter")
-- --- a plain turn still does all of it ------------------------------
local ok = setup("FIX_TACKLE")
ok.player.mon.status = "PSN"
local scratch = ok.player.curMoves[1]
local okEnemyHP, okPlayerHP = ok.enemy.mon.hp, ok.player.mon.hp
local okPP = scratch.pp
ok:resolveTurn(scratch)
local okSaid = pump(ok)
T.eq(ok.result, nil, "an ordinary turn settles nothing")
T.check(saidWith(okSaid, "FIX TACKLE"), "both movers announce")
T.check(ok.enemy.mon.hp < okEnemyHP, "the second mover's move lands")
T.eq(scratch.pp, okPP - 1, "the second mover spends PP")
T.check(ok.player.mon.hp < okPlayerHP, "the poison tick runs")
T.check(saidWith(okSaid, "hurt by"), "the residual sweep prints")
Data.moves.TELEPORT = nil
T.finish("teleport escape ends turn")
+139
View File
@@ -0,0 +1,139 @@
-- The shipped per-channel mix must be authentic, and the wave channel must
-- sit exactly one octave below a pulse at the same note (#429). On hardware
-- ch3 counts 65536/(2048-x) against the pulses' 131072/(2048-x), and pokered
-- writes the frequency register for CHAN3 unmodified
-- (audio/engine_1.asm:904-944 Audio1_ApplyWavePatternAndFrequency loads only
-- the wave pattern), with no attenuation either
-- (Audio1_ApplyDutyCycleAndSoundLength:887 skips just the duty nibble). A
-- CHANNEL_VOLUME/CHANNEL_PITCH of 0.25/0.5 on ch3 therefore buried every Ch3
-- countermelody at quarter amplitude and a second octave down.
-- ROM-free: ChipAsm blobs, no data/generated/.
-- luajit tests/engine/wave_channel_mix_bug429.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check = T.check
love = require("tests.love_stub")
local ChipAsm = require("src.audio.ChipAsm")
local ChipSynth = require("src.core.ChipSynth")
-- requiring ChipAudio is the point of the first block: the module pushes its
-- shipped CHANNEL_VOLUME / CHANNEL_PITCH into ChipSynth at load
local ChipAudio = require("src.core.ChipAudio")
-- ------- the shipped defaults
local vols = ChipSynth.getChannelVolumes()
local pitches = ChipSynth.getChannelPitches()
for hw = 1, 4 do
check(vols[hw] == 1,
("channel %d ships at volume 1, not %s"):format(hw, tostring(vols[hw])))
check(pitches[hw] == 1,
("channel %d ships at pitch 1, not %s"):format(hw, tostring(pitches[hw])))
end
-- ChipAudio.playMusic forwards these to the worker with every play command
-- (ChipAudio.lua:190-191 -> chip_worker.lua:50-54), so a non-unity default
-- reaches the threaded path as well as the synchronous one
local forwarded = ChipAudio.getChannelVolumes()
check(forwarded[3] == 1, "the wave gain handed to the worker is unity")
check(ChipAudio.getChannelPitches()[3] == 1,
"the wave pitch handed to the worker is unity")
-- ------- fixtures: one note, once on a pulse and once on the wave channel
local data = { audio = {} }
-- +1 for the first half of the table and -1 for the second, so the wave
-- crosses zero twice per cycle exactly like a 50% duty pulse
local squareWave = {}
for index = 1, 32 do squareWave[index] = index <= 16 and 1 or -1 end
local function pulseSong(note, octave)
return ChipAsm.song{
channels = { { hw = 1, program = {
{ duty = 2 },
{ notetype = { speed = 12, volume = 15, fade = 0 } },
{ octave = octave },
{ note = note, len = 15 },
} } },
}
end
local function waveSong(note, octave)
return ChipAsm.song{
channels = { { hw = 3, program = {
{ notetype = { speed = 12, waveLevel = 1, waveInstrument = 0 } },
{ octave = octave },
{ note = note, len = 15 },
} } },
waves = { squareWave },
}
end
local function crossings(sd)
local count, prev = 0, sd:getSample(0)
for index = 1, sd:getSampleCount() - 1 do
local sample = sd:getSample(index)
if prev * sample < 0 then count = count + 1 end
prev = sample
end
return count
end
local function renderPulse(note, octave)
return crossings(ChipAudio._renderMusicChannelForTest(
data, pulseSong(note, octave), 0.25, 1))
end
local function renderWave(note, octave)
return crossings(ChipAudio._renderMusicChannelForTest(
data, waveSong(note, octave), 0.25, 3))
end
-- ------- one octave down, and only one
for _, spec in ipairs({ { "C", 4 }, { "G", 4 }, { "C", 5 } }) do
local note, octave = spec[1], spec[2]
local pulse = renderPulse(note, octave)
local wave = renderWave(note, octave)
check(pulse > 40, ("%s%d on the pulse channel sounds (%d crossings)")
:format(note, octave, pulse))
check(wave > 20 and math.abs(wave - pulse / 2) <= math.max(4, pulse * 0.06),
("%s%d on the wave channel is one octave down (%d vs %d crossings)")
:format(note, octave, wave, pulse))
end
-- the wave channel one octave up from a pulse note lands on that pulse note,
-- which is the same statement from the other side
local waveUp = renderWave("C", 5)
local pulseAt = renderPulse("C", 4)
check(math.abs(waveUp - pulseAt) <= math.max(4, pulseAt * 0.06),
("wave C5 matches pulse C4 (%d vs %d crossings)"):format(waveUp, pulseAt))
-- ------- the 0.1.38 mix, so the guard above is known to bite
local brokenPitch, brokenVol
local baseValue = ChipAudio._traceFirstMusicSampleForTest(
data, waveSong("C", 4))[1].value
ChipAudio.setChannelPitch(3, 0.5)
brokenPitch = renderWave("C", 4)
ChipAudio.setChannelVolume(3, 0.25)
brokenVol = ChipAudio._traceFirstMusicSampleForTest(
data, waveSong("C", 4))[1].value
ChipAudio.setChannelVolumes({ 1, 1, 1, 1 })
ChipAudio.setChannelPitches({ 1, 1, 1, 1 })
local authentic = renderWave("C", 4)
check(brokenPitch > 0 and math.abs(brokenPitch - authentic / 2)
<= math.max(4, authentic * 0.06),
("pitch 0.5 on ch3 drops a second octave (%d vs %d crossings)")
:format(brokenPitch, authentic))
check(math.abs(baseValue) > 0 and math.abs(brokenVol - baseValue * 0.25) < 1e-9,
"volume 0.25 on ch3 quarters the wave sample")
check(ChipSynth.getChannelVolumes()[3] == 1
and ChipSynth.getChannelPitches()[3] == 1,
"the mix is back at unity for the suites that follow")
T.finish("wave channel mix")
+163
View File
@@ -0,0 +1,163 @@
-- Parity: starting a battle with no useable POKeMON blacks the player out,
-- it does not cancel the battle (#425). pokered's .checkAnyPartyAlive
-- (engine/battle/core.asm:158-162) runs after EnemySendOutFirstMon and the
-- 40-frame delay -- the battle has already begun -- and jumps to
-- HandlePlayerBlackOut (core.asm:1145-1166), which prints
-- PlayerBlackedOutText2 (data/text/text_2.asm:896) and drops through to the
-- overworld blackout warp (home/overworld.asm:335-360). Cancelling instead
-- left the battle theme looping, the party at 0 HP and no warp, so every
-- later encounter took the same exit.
-- Self-contained; run via `luajit tests/parity_blackout_no_party.lua`.
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 and Data.maps.PALLET_TOWN) then Data:load() end
local S = require("tests.harness").suite("parity blackout no party")
local check, eq = S.check, S.eq
require("src.render.Font").load(Data)
local Game = require("src.core.Game")
local Input = require("src.core.Input")
local StateStack = require("src.core.StateStack")
local Renderer = require("src.render.Renderer")
local SaveData = require("src.core.SaveData")
local Pokemon = require("src.pokemon.Pokemon")
local Music = require("src.core.Music")
local Runtime = require("src.mods.Runtime")
local TextBox = require("src.render.TextBox")
local BattleState = require("src.battle.BattleState")
local OW = require("src.world.OverworldController")
local MAP = "ROUTE_1"
local MAP_SONG = Data.audio.mapSongs[MAP]
local WILD_SONG = Data.audio.battle.wild
-- every song request in order: "the theme was never restored" and "the theme
-- was never started" have to read differently. Music.playMap still sets the
-- state Music.restoreMap reads, so the real restore path is under test.
local songs = {}
Music.play = function(_, song) songs[#songs + 1] = song end
local function lastSong() return songs[#songs] end
-- run_tests.lua dofiles the parity files into one process and other suites
-- leave Music.playBattle a no-op, so stand in for its body here: one
-- Music.play of audio.battle[kind] (src/core/Music.lua:350-357)
Music.playBattle = function(data, kind)
local b = data.audio.battle
Music.play(data, b[kind] or b.wild)
end
local events = { listeners = {} }
function events:emit(name, payload)
self.listeners[name] = payload
end
function events:removeOwner() end
Runtime.install(events, Runtime.hooks)
Game.data = Data
Game.input = Input; Input:init()
Game.renderer = Renderer; Renderer:init()
Game.stack = StateStack; StateStack:init()
local warp
local function newWorld(mapId)
while Game.stack:top() do Game.stack:pop() end
Game.stack:push(OW, mapId, 5, 5, "down")
local ow = Game.stack:top()
Game.overworld = ow
-- the real one runs a Transition; only the destination matters here
ow.startWarpTo = function(self, map, x, y, facing)
warp = { map = map, x = x, y = y, facing = facing }
self.transitioning = false
end
Music.playMap(Data, mapId)
return ow
end
-- a wild encounter as OverworldState:checkEncounter builds it (:3182-3194)
local function encounter(ow)
local battle = BattleState.newWild(Game, "PIDGEY", 3)
local got
battle.onFinish = function(result)
got = result
ow:afterBattle(result, battle)
end
return battle, function() return got end
end
Game.save = SaveData.newGame()
Game.save.player.name = "RED"
Game.save.money = 3000
Game.save.party = { Pokemon.new(Data, "MAGIKARP", 10) }
Game.save.party[1].hp = 0
local full = Game.save.party[1].stats.hp
Game.save.lastHeal = { map = "VIRIDIAN_POKECENTER", x = 3, y = 3,
outdoor = { id = "VIRIDIAN_CITY", x = 17, y = 8 } }
local ow = newWorld(MAP)
eq(lastSong(), MAP_SONG, MAP .. " is playing its own theme before the step")
local battle, result = encounter(ow)
eq(battle.dead, true, "the constructor still flags a partyless wild battle")
eq(battle.player, nil, "and installs no player battler (Party.firstHealthy)")
-- pushBattle starts the theme with the wipe, before enter() ever runs
-- (:660-679, audio/play_battle_music.asm), which is why a cancelled battle
-- left the battle music looping over the map
ow:pushBattle(battle)
eq(lastSong(), WILD_SONG, "the wipe has already started the battle theme")
Game.stack:pop() -- the transition; its callback pushes the battle below
Game.stack:push(battle)
eq(Game.stack:top() ~= battle, true, "enter() takes the battle back off the stack")
eq(lastSong(), MAP_SONG, "and restores the map theme instead of looping the battle one")
eq(battle.result, "lose", "the battle resolves as a loss, not a skip")
local ended = events.listeners["battle.ended"]
check(ended ~= nil and ended.result == "lose",
"battle.ended reports a loss for mods too")
local box = Game.stack:top()
check(getmetatable(box) == TextBox, "the blackout text is on screen over the map")
local lines = {}
for _, page in ipairs(box and box.pages or {}) do
for _, line in ipairs(page) do lines[#lines + 1] = line end
end
local text = table.concat(lines, " / ")
check(text:find("RED", 1, true) ~= nil, "the text names the player")
check(text:find("out of", 1, true) ~= nil and text:find("POK", 1, true) ~= nil,
"PlayerBlackedOutText2 line 1: <PLAYER> is out of useable POKeMON!")
check(text:find("blacked", 1, true) ~= nil,
"PlayerBlackedOutText2 line 2: <PLAYER> blacked out!")
-- the cancel path called onFinish itself, with no box to dismiss
if box and box.onDone then box.onDone() end
eq(result(), "lose", "onFinish gets \"lose\", so afterBattle blacks out")
eq(Game.save.party[1].hp, full, "the party is revived at the heal point")
eq(Game.save.money, 1500, "half the money is lost, as on any blackout")
check(warp ~= nil and warp.map == "VIRIDIAN_POKECENTER",
"and the player is warped to the last heal point")
-- the brick: before #425 the party was still at 0 HP here, so this second
-- encounter took the same exit, and so did every one after it
local ow2 = newWorld(MAP)
local live = BattleState.newWild(Game, "PIDGEY", 3)
eq(live.dead, nil, "the next encounter is a real battle again")
check(live.player ~= nil, "with the revived MAGIKARP leading it")
-- Oak's Lab starter rival: HandlePlayerBlackOut rets above
-- PlayerBlackedOutText2 (core.asm:1147-1149) and the lab script heals, so
-- there is no text, no money loss and no warp.
Game.save.money = 3000
Game.save.party[1].hp = 0
local lab = newWorld("OAKS_LAB")
warp = nil
local labResult
local rival = BattleState.newTrainer(Game, "OPP_RIVAL1", 1)
rival.onFinish = function(r) labResult = r lab:afterBattle(r, rival) end
eq(rival.dead, true, "a partyless lab rival battle is flagged the same way")
Game.stack:push(rival)
check(getmetatable(Game.stack:top()) ~= TextBox,
"the lab rival prints no blackout text (core.asm:1147-1149)")
eq(labResult, "lose", "it still finishes as a loss")
eq(Game.save.money, 3000, "no money is lost in the lab")
eq(warp, nil, "and the player stays in the lab for OaksLabRivalEndBattleScript")
S.finish()
+185
View File
@@ -0,0 +1,185 @@
-- Parity test, gift atomicity: a mon handed over by give_pokemon and the
-- event that closes its offer must land in the same script step, so a
-- script torn down between the two cannot hand the gift out twice (#426).
--
-- asm sources:
-- pokeyellow scripts/Route24.asm (Route24CooltrainerM4Text: CheckEvent
-- EVENT_54F -> YesNoChoice -> GivePokemon -> `jp nc, TextScriptEnd`
-- (party + box full leaves the event clear so the offer repeats) ->
-- PrintText Route24Text_515e3 -> SetEvent EVENT_54F)
-- pokeyellow scripts/CeruleanMelaniesHouse.asm (same shape plus predef
-- HideObject TOGGLE_CERULEAN_BULBASAUR, then SetEvent
-- EVENT_GOT_BULBASAUR_IN_CERULEAN)
-- pokeyellow scripts/VermilionCity_2.asm (CheckEvent / SetEvent
-- EVENT_GOT_SQUIRTLE_FROM_OFFICER_JENNY)
-- scripts/CeladonMansionRoofHouse.asm (Eevee ball: GivePokemon with no
-- confirm, HideObject on success)
-- On hardware the event write trails the received text because no step in
-- between can abort. The port yields there (AskName, NamingScreen, the
-- text box) and wraps every row in the script.command mod hook, so the
-- write is hoisted ahead of the text: the event is only read at script
-- entry, and the failed-give path still leaves it clear.
--
-- Self-contained: run via `luajit tests/parity_gift_atomicity.lua`; also
-- dofile'd by tests/run_tests.lua's aggregator.
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 and Data.maps.PALLET_TOWN) then Data:load() end
local S = require("tests.harness").suite("parity gift atomicity")
local check, eq = S.check, S.eq
local Commands = require("src.script.Commands")
local Events = require("src.mods.Events")
local Flags = require("src.script.Flags")
local Game = require("src.core.Game")
local Hooks = require("src.mods.Hooks")
local Input = require("src.core.Input")
local Logger = require("src.core.Logger")
local Runtime = require("src.mods.Runtime")
local SaveData = require("src.core.SaveData")
local ScriptRunner = require("src.script.ScriptRunner")
local StateStack = require("src.core.StateStack")
Game.data = Data
Game.input = Input; Input:init()
Game.stack = StateStack; StateStack:init()
Game.save = SaveData.newGame()
require("src.render.Font").load(Data)
local gifts = require("data.scripts.yellow_gifts")
local eevee = require("data.scripts.celadon_eevee")
-- === 1) row-order audit: on every gift site the carry guard follows
-- give_pokemon immediately and the bookkeeping (event, and the
-- HideObject that clears a ball or a pen mon) comes before any
-- received text ===
local function audit(label, rows)
local give
for i, row in ipairs(rows) do
if row[1] == "give_pokemon" then give = i break end
end
if not give then
check(false, label .. ": has a give_pokemon row")
return
end
eq(rows[give + 1] and rows[give + 1][1], "jump_if_false",
label .. ": carry guard sits right after give_pokemon")
local flag, text, hide
for i = give + 2, #rows do
local name = rows[i][1]
if name == "set_flag" and not flag then flag = i end
if name == "hide_object" and not hide then hide = i end
if (name == "show_text" or name == "ask") and not text then text = i end
if name == "jump" and rows[i][2] ~= nil and text then break end
end
eq(flag, give + 2, label .. ": event write is the first row past the guard")
check(text and flag < text,
label .. ": event write precedes the received text")
if hide then
check(hide < text, label .. ": HideObject precedes the received text")
end
end
-- the two function-form scripts build their rows per talk; run them with
-- the gift branch's preconditions and keep what they hand the runner
local function capture(fn, save)
local rows
local ow = { runner = { run = function(_, r) rows = r end } }
fn({ save = save }, ow, { def = {}, facePlayer = function() end },
function() end)
return rows or {}
end
audit("Route 24 Damian",
gifts.ROUTE_24.talk.TEXT_ROUTE24_COOLTRAINER_M4)
audit("Melanie's BULBASAUR",
capture(gifts.CERULEAN_MELANIES_HOUSE.talk
.TEXT_CERULEANMELANIESHOUSE_MELANIE,
{ flags = {}, pikachuHappiness = 200 }))
audit("Officer Jenny's SQUIRTLE",
capture(gifts.VERMILION_CITY.talk.TEXT_VERMILIONCITY_OFFICER_JENNY,
{ flags = {}, inventory = { THUNDERBADGE = 1 } }))
audit("Celadon EEVEE ball",
eevee.talk.TEXT_CELADONMANSION_ROOF_HOUSE_EEVEE_POKEBALL)
-- === harness: run a row list headless, A-mashing through the yes/no,
-- the nickname prompt and every text box, recording show_text ids
-- (Yellow's gift text is not in a Red cache, so show_text takes
-- its literal-id fallback: the ids are still what we assert on) ===
local shown = {}
local origShow = Commands.show_text
Commands.show_text = function(ctx, textId, subs)
shown[#shown + 1] = textId
return origShow(ctx, textId, subs)
end
local function runRows(rows)
shown = {}
StateStack:init()
local ow = { map = { id = "ROUTE_24", def = { label = "ROUTE_24" } },
npcs = {}, entities = {} }
local r = ScriptRunner.new(Game, ow)
r:run(rows, { npc = { def = {}, facePlayer = function() end },
overworld = ow })
local guard = 0
while r:isRunning() and guard < 3000 do
guard = guard + 1
Input.pressed = { a = true }
StateStack:update(1 / 60)
r:update()
end
Input.pressed = {}
return not r:isRunning()
end
local DAMIAN = gifts.ROUTE_24.talk.TEXT_ROUTE24_COOLTRAINER_M4
-- === 2) plain accept: one CHARMANDER, EVENT_54F set, and the next talk
-- is Damian's after-text only ===
Game.save = SaveData.newGame()
check(runRows(DAMIAN), "Damian gift script completes")
eq(#Game.save.party, 1, "CHARMANDER joins the party")
eq(Game.save.party[1].species, "CHARMANDER", "gift species is CHARMANDER")
check(Flags.get(Game.save, "EVENT_54F"), "accepting sets EVENT_54F")
check(runRows(DAMIAN), "post-gift talk completes")
eq(table.concat(shown, ","), "_Route24DamianText4",
"a closed offer shows only the after-text")
eq(#Game.save.party, 1, "no second CHARMANDER")
-- === 3) the regression itself: every row runs inside the script.command
-- hook, and a mod that mishandles the row after the give (the
-- reporter was running a third-party UI mod) tears the coroutine
-- down mid-gift -- here by sending the pc at a label that is not
-- there. The mon is already in the party, so EVENT_54F has to be
-- set by then or the next talk re-runs the whole offer ===
local savedEvents, savedHooks, savedErrors =
Runtime.events, Runtime.hooks, Runtime.errors
local hooks = Hooks.new()
Runtime.install(Events.new(), hooks, {})
local remove = hooks:wrap("script.command", function(nextFn, _, name, args)
if name == "show_text" and args[1] == "_Route24DamianText2" then
return "no_such_label"
end
return nextFn()
end, 0, "t")
Game.save = SaveData.newGame()
local origError = Logger.error -- the tear-down logs; the test expects it
Logger.error = function() end
runRows(DAMIAN)
Logger.error = origError
eq(#Game.save.party, 1, "the killed script still handed the CHARMANDER over")
check(Flags.get(Game.save, "EVENT_54F"),
"EVENT_54F survives a tear-down after the give")
remove()
Runtime.install(savedEvents, savedHooks, savedErrors)
check(runRows(DAMIAN), "talk after the tear-down completes")
eq(table.concat(shown, ","), "_Route24DamianText4",
"the interrupted gift is not offered again")
eq(#Game.save.party, 1, "still exactly one CHARMANDER")
S.finish()
+184
View File
@@ -0,0 +1,184 @@
-- Parity test: a ball thrown at the POKEMON_TOWER_6F RESTLESS SOUL is
-- always dodged, scope or no scope.
--
-- ItemUseBall reaches the $10 "can't be caught" anim data by TWO
-- independent routes (engine/items/item_effects.asm):
--
-- :149-153 callfar IsGhostBattle / ld b, $10 / jp z, .setAnimData
-- :166-175 .notOldManBattle -- wCurMap == POKEMON_TOWER_6F and
-- wEnemyMonSpecies2 == RESTLESS_SOUL -> the same $10
--
-- The port only had the first, as the scope-less disguise flag
-- self.ghost. Once the SILPH_SCOPE revealed the MAROWAK the battle was
-- an ordinary wild one, so throwBall ran the capture roll and a MASTER
-- BALL caught it outright. That result is "caught", not "win" or the
-- POKE DOLL escape, so PokemonTower6F's script never set
-- EVENT_BEAT_GHOST_MAROWAK and the (10,16) trigger re-fired forever
-- (#444). The map+species half sits BEFORE .loop, hence before the
-- MASTER_BALL shortcut, so even a Master Ball is dodged.
--
-- Run-away parity is the other side of this: only IsGhostBattle grants
-- the free escape (engine/battle/core.asm TryRunningFromBattle), so a
-- revealed MAROWAK keeps normal flee rolls and self.ghost stays the sole
-- gate there.
--
-- Self-contained; run via `luajit tests/parity_marowak_ball.lua`.
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local S = require("tests.harness").suite("parity marowak ball")
local check, eq = S.check, S.eq
local BattleState = require("src.battle.BattleState")
-- ---- 1. the 6F script arms noCatch with and without the scope -----------
do
local realTextBox = package.loaded["src.render.TextBox"]
local realBattleState = package.loaded["src.battle.BattleState"]
package.loaded["src.render.TextBox"] = {
new = function(_, text, done) return { text = text, done = done } end,
}
local made = {}
package.loaded["src.battle.BattleState"] = {
newWild = function(_, species, level)
local b = { species = species, level = level, ghost = false }
b.makeGhost = function(self) self.ghost = true end
made[#made + 1] = b
return b
end,
}
local tower = dofile("data/scripts/story3.lua").POKEMON_TOWER_6F
local function trigger(inventory)
local pushed = {}
local game = {
save = { inventory = inventory, flags = {} },
data = { text = {} },
stack = { push = function(_, box) pushed[#pushed + 1] = box end },
}
local ow = {
player = {},
scriptMove = function() end,
afterBattle = function() end,
}
check(tower.onStep(game, ow, 10, 16), "the trigger fires on (10,16)")
pushed[1].done()
return made[#made]
end
local noScope = trigger({})
check(noScope.ghost, "without the scope the battle is still disguised")
check(noScope.noCatch, "and noCatch is set")
local withScope = trigger({ SILPH_SCOPE = 1 })
check(not withScope.ghost, "with the scope the disguise is gone")
check(withScope.noCatch,
"but noCatch survives it -- balls are dodged either way")
package.loaded["src.render.TextBox"] = realTextBox
package.loaded["src.battle.BattleState"] = realBattleState
end
-- ---- 2. throwBall takes the dodge branch on noCatch alone ---------------
local realSound = package.loaded["src.core.Sound"]
package.loaded["src.core.Sound"] = { play = function() end }
-- A real BattleState minus the pieces the decision does not touch: the
-- capture roll and the ball chain record that they were reached, which is
-- exactly the bug (a MASTER BALL catching the revealed MAROWAK).
local function throw(flags, ball)
local self = setmetatable({
kind = "wild",
ghost = flags.ghost or false,
noCatch = flags.noCatch or false,
queue = {},
rolled = false,
chained = false,
enemyMoved = false,
turnEnded = false,
data = { items = { MASTER_BALL = { name = "MASTER BALL" },
POKE_BALL = { name = "POKé BALL" } },
text = {} },
game = { save = { player = { name = "RED" } } },
player = {},
enemy = {},
}, BattleState)
self.ballDef = function() return nil end
self.catchAttempt = function(s) s.rolled = true return false, 3 end
self.ballChain = function(s) s.chained = true end
self.enemyAction = function() return {} end
self.executeAction = function(s) s.enemyMoved = true end
self.endOfTurn = function(s) s.turnEnded = true end
self:throwBall(ball)
-- the whole outcome lives in the act() closure throwBall queues, and
-- that closure queues more rows, so drain like updateQueue does: run
-- each fn row once, with nextInsert pointing at it.
local ran = {}
local more = true
while more do
more = false
for i, row in ipairs(self.queue) do
if row.fn and not ran[row] then
ran[row] = true
self.nextInsert = i
row.fn()
more = true
break
end
end
end
local texts = {}
for _, row in ipairs(self.queue) do
if row.text then texts[#texts + 1] = tostring(row.text) end
end
self.texts = table.concat(texts, "|")
return self
end
local function assertDodge(b, label)
check(not b.rolled, label .. ": no capture roll")
check(not b.chained, label .. ": no wobble chain")
check(b.texts:find("It dodged the", 1, true) ~= nil,
label .. ": ItemUseBallText00 line 1")
check(b.texts:find("can't be caught", 1, true) ~= nil,
label .. ": ItemUseBallText00 line 2")
check(b.enemyMoved, label .. ": the turn is spent, the foe moves")
check(b.turnEnded, label .. ": and the turn ends")
end
assertDodge(throw({ ghost = true }, "POKE_BALL"), "IsGhostBattle exit")
assertDodge(throw({ noCatch = true }, "POKE_BALL"), ".notOldManBattle exit")
-- the regression itself: revealed by the scope, so ghost is false
assertDodge(throw({ noCatch = true }, "MASTER_BALL"), "MASTER BALL")
do
local plain = throw({}, "MASTER_BALL")
check(plain.rolled,
"an ordinary wild mon still rolls -- the guard is not global")
end
-- The dodged toss keeps the arc the thrown ball picked (TossBallAnimation
-- reads wCurItem), so the Master Ball flicker is not lost.
do
local b = throw({ noCatch = true }, "MASTER_BALL")
local anim
for _, row in ipairs(b.queue) do
if row.anim then anim = row.anim break end
end
eq("ULTRATOSS_ANIM", anim, "a dodged MASTER BALL still tosses as ULTRATOSS")
end
package.loaded["src.core.Sound"] = realSound
-- ---- 3. noCatch grants no free escape ----------------------------------
do
local function roll(flags)
local b = { ghost = flags.ghost or false, noCatch = flags.noCatch or false,
runAttempts = 1, rng = function() return 255 end }
return BattleState.runRollVanilla(b, 10, 100)
end
check(roll({ ghost = true }), "IsGhostBattle still always escapes")
check(not roll({ noCatch = true }),
"a revealed MAROWAK takes the normal flee roll")
end
S.finish()
+101
View File
@@ -0,0 +1,101 @@
-- Parity test: the status screen's mon pic has to sit out the SGB recolor
-- when the sprite record is true-color (#430). SetPal_StatusScreen puts a
-- monPal zone over the pic rect (status_screen.asm, mirrored by
-- SummaryMenu:sgbPalettes), so a full-color pic drawn there was run through
-- the 4-shade remap the way battle and the Pokedex entry page (#350) already
-- avoid. Run with `luajit tests/parity_status_true_color.lua`.
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local S = require("tests.harness").suite("parity status true color")
local check, eq = S.check, S.eq
local Data = require("src.core.Data")
if not Data.maps then Data:load() end
require("src.render.Font").load(Data)
local PaletteFX = require("src.render.PaletteFX")
local Sprites = require("src.pokemon.Sprites")
local Sound = require("src.core.Sound")
local Pokemon = require("src.pokemon.Pokemon")
local SaveData = require("src.core.SaveData")
local SummaryMenu = require("src.ui.SummaryMenu")
local savedCry = Sound.playCry
Sound.playCry = function() end
-- the rects the frame reported on the UI canvas, the pass the status screen
-- draws into (Renderer appends them to that pass's zone list)
local function uiRects(draw)
PaletteFX.clearTrueColor()
PaletteFX.setPass("ui")
draw()
local rects = PaletteFX.trueColorRects("ui")
PaletteFX.setPass(nil)
return rects
end
local def = Data.pokemon.PIKACHU
local savedTrueColor = def.trueColor
local save = SaveData.newGame()
local mon = Pokemon.new(Data, "PIKACHU", 20)
local game = { data = Data, save = save, stack = { pop = function() end } }
-- the premise: this screen colorizes the pic through a species zone, so a
-- true-color pic needs an unshaded rect on top of it
do
local pals = SummaryMenu.sgbPalettes({ mon = mon }, game)
local monPal = PaletteFX.monPal(Data, "PIKACHU")
local zone
for _, z in ipairs(pals or {}) do
if z.colors == monPal and z.w < 160 then zone = z end
end
check(zone ~= nil, "SetPal_StatusScreen's monPal zone covers the pic")
if zone then
eq(zone.x .. "," .. zone.y, "8,0", "and it starts at the pic's corner")
end
end
def.trueColor = true
local _, pathTrueColor = Sprites.path(Data, "PIKACHU", "front",
{ mon = mon, kind = "summary" })
check(pathTrueColor == true,
"Sprites.path reports trueColor for the summary pic")
local screen = SummaryMenu.new(game, mon)
check(screen.spriteTrueColor == true,
"SummaryMenu keeps the sprite's trueColor flag (#430)")
local pw, ph = screen.sprite:getDimensions()
local py = math.max(0, 56 - ph)
-- LoadFlippedFrontSpriteByMonIndex: the draw is a negative x scale anchored
-- at 8 + pw, so the covered rect still starts at x = 8
local page1 = uiRects(function() screen:draw() end)
eq(#page1, 1, "page 1 reports one true-color rect")
if page1[1] then
eq(("%d,%d,%d,%d"):format(page1[1].x, page1[1].y, page1[1].w, page1[1].h),
("%d,%d,%d,%d"):format(8, py, pw, ph),
"and it is the mirrored draw's rect, not the unflipped one")
check(page1[1].colors == false,
"and it is an unshaded zone, not a palette")
end
screen.page = 2
local page2 = uiRects(function() screen:draw() end)
eq(#page2, 1, "page 2 (StatusScreen2 keeps the pic) reports it too")
-- vanilla records set no flag, so the zone list stays exactly what
-- sgbPalettes returned
def.trueColor = nil
local plain = SummaryMenu.new(game, mon)
check(plain.spriteTrueColor == false, "a plain pic reports no flag")
eq(#uiRects(function() plain:draw() end), 0,
"and marks no rect, leaving the SGB pass untouched")
def.trueColor = savedTrueColor
Sound.playCry = savedCry
PaletteFX.clearTrueColor()
S.finish()
+3
View File
@@ -75,6 +75,9 @@ YELLOW_EXTRA_SYMBOLS = (
"YellowIntroCloudGFX",
"PikachuCriesPointerTable",
"CGBBasePalettes",
# Jessie & James share the ROCKET trainer class but battle behind their
# own pic (home/trainers2.asm IsFightingJessieJames) (#439)
"JessieJamesPic",
# the five Pikachu-only emotion bubbles (emotion_bubbles.asm)
"SkullEmote",
"HeartEmote",
+4
View File
@@ -21981,6 +21981,10 @@
9,
20457
],
"JessieJamesPic": [
19,
31873
],
"JigglypuffPicBack": [
11,
25410