mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 00:10:56 +02:00
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:
@@ -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
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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!"))
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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",
|
||||
|
||||
@@ -21981,6 +21981,10 @@
|
||||
9,
|
||||
20457
|
||||
],
|
||||
"JessieJamesPic": [
|
||||
19,
|
||||
31873
|
||||
],
|
||||
"JigglypuffPicBack": [
|
||||
11,
|
||||
25410
|
||||
|
||||
Reference in New Issue
Block a user