mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 08:21:02 +02:00
Merge pull request #879 from bryanthaboi/dev
CLOSES #604, CLOSES #666, CLOSES #716, CLOSES #727, CLOSES #763, CLOS…
This commit is contained in:
+29
-18
@@ -800,9 +800,13 @@ M.SILPH_CO_11F = {
|
||||
-- line) would touch, and the whole Silph ending -- the flag, the Master
|
||||
-- Ball, the Saffron streets clearing -- silently never happened.
|
||||
--
|
||||
-- engageTrainer shows TEXT_SILPHCO11F_GIOVANNI as the battle text and,
|
||||
-- via victories.lua OPP_GIOVANNI#2, sets the event on a win; a loss
|
||||
-- sets nothing, so the trigger re-arms exactly as vanilla does.
|
||||
-- SilphCo11FDefaultScript orders it DisplayTextID TEXT_SILPHCO11F_GIOVANNI
|
||||
-- FIRST, then MoveSprite .GiovanniMovement: he speaks from behind the desk
|
||||
-- and only then walks the three tiles down. Moving him before the box made
|
||||
-- him cross the room in silence and deliver the speech point-blank (#869),
|
||||
-- so the box comes first here and engageTrainer skips its own battle text.
|
||||
-- victories.lua OPP_GIOVANNI#2 sets the event on a win; a loss sets
|
||||
-- nothing, so the trigger re-arms exactly as vanilla does.
|
||||
onStep = function(game, ow, x, y)
|
||||
if game.save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI then return false end
|
||||
if not ((x == 6 and y == 13) or (x == 7 and y == 12)) then return false end
|
||||
@@ -811,21 +815,28 @@ M.SILPH_CO_11F = {
|
||||
if npc.def and npc.def.name == "SILPHCO11F_GIOVANNI" then gio = npc break end
|
||||
end
|
||||
if not gio or ow:trainerDefeated(gio) then return false end
|
||||
ow:scriptMove(gio, "down", 3, function()
|
||||
gio:facePlayer(ow.player)
|
||||
ow:engageTrainer(gio, function()
|
||||
-- SilphCo11FGiovanniAfterBattleScript: the "Blast it all!" speech,
|
||||
-- then SilphCo11FTeamRocketLeavesScript behind a fade so every Silph
|
||||
-- rocket leaves off-screen (the street rockets are handled by
|
||||
-- M.SAFFRON_CITY.onEnter in story4.lua). Queued, not run here: the
|
||||
-- battle's own callbacks are still unwinding, so queueScript starts
|
||||
-- it on the first idle overworld frame -- after the end-battle
|
||||
-- "Arrgh!!" box victories.lua OPP_GIOVANNI#2 pushes (#722).
|
||||
if game.save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI then
|
||||
ow:queueScript(silphAftermathRows())
|
||||
end
|
||||
end)
|
||||
end)
|
||||
local TextBox = require("src.render.TextBox")
|
||||
game.stack:push(TextBox.new(game,
|
||||
game.data.text._SilphCo11FGiovanniText
|
||||
or "Ah {PLAYER}!\nSo we meet again!",
|
||||
function()
|
||||
ow:scriptMove(gio, "down", 3, function()
|
||||
gio:facePlayer(ow.player)
|
||||
ow:engageTrainer(gio, function()
|
||||
-- SilphCo11FGiovanniAfterBattleScript: the "Blast it all!"
|
||||
-- speech, then SilphCo11FTeamRocketLeavesScript behind a fade so
|
||||
-- every Silph rocket leaves off-screen (the street rockets are
|
||||
-- handled by M.SAFFRON_CITY.onEnter in story4.lua). Queued, not
|
||||
-- run here: the battle's own callbacks are still unwinding, so
|
||||
-- queueScript starts it on the first idle overworld frame --
|
||||
-- after the end-battle "Arrgh!!" box victories.lua OPP_GIOVANNI#2
|
||||
-- pushes (#722).
|
||||
if game.save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI then
|
||||
ow:queueScript(silphAftermathRows())
|
||||
end
|
||||
end, nil, true)
|
||||
end)
|
||||
end))
|
||||
return true
|
||||
end,
|
||||
onEnter = function(game, ow)
|
||||
|
||||
+21
-3
@@ -151,9 +151,27 @@ M.POKEMON_TOWER_6F = {
|
||||
-- trick, and the speedrun route this bot follows depends on it.
|
||||
if result == "win" or battle.pokeDollEscape then
|
||||
game.save.flags.EVENT_BEAT_GHOST_MAROWAK = true
|
||||
game.stack:push(TextBox.new(game,
|
||||
t._PokemonTower6FSoulWasCalmedText
|
||||
or "The mother's soul\nwas calmed.\012It departed to\nthe afterlife!"))
|
||||
-- PokemonTower6FMarowakDepartedText (scripts/PokemonTower6F.asm)
|
||||
-- is two texts, not one: the CUBONE's-mother line first, then
|
||||
-- PlayCry RESTLESS_SOUL (EQU MAROWAK, constants/pokemon_constants
|
||||
-- .asm:209) + WaitForSoundToFinish + DelayFrames 30 before the
|
||||
-- calmed line; the port dropped the first text and the cry
|
||||
-- (#867). play_cry arms the next show_text, so the cry rides
|
||||
-- the calmed box's open with the button prompt kept, and the
|
||||
-- wait row stands in for the asm's 30-frame gap.
|
||||
local rows = {
|
||||
{ "show_text", t._PokemonTower6FGhostWasCubonesMotherText
|
||||
or "The GHOST was the\nrestless soul of\vCUBONE's mother!" },
|
||||
{ "play_cry", "MAROWAK", true },
|
||||
{ "wait", 30 },
|
||||
{ "show_text", t._PokemonTower6FSoulWasCalmedText
|
||||
or "The mother's soul\nwas calmed.\012It departed to\nthe afterlife!" },
|
||||
}
|
||||
if ow.runner then
|
||||
ow.runner:run(rows)
|
||||
elseif ow.queueScript then
|
||||
ow:queueScript(rows)
|
||||
end
|
||||
elseif result ~= "lose" then
|
||||
-- .did_not_defeat: one simulated step right, off the trigger,
|
||||
-- so fleeing does not leave you standing on a cell that
|
||||
|
||||
@@ -667,7 +667,33 @@ function love.wheelmoved(x, y)
|
||||
Game:wheelmoved(x, y)
|
||||
end
|
||||
|
||||
-- #781: Linux X11 multi-monitor with the primary display away from desktop
|
||||
-- (0,0): SDL's polled mouse state can come back in desktop-virtual
|
||||
-- coordinates while the event stream stays window-relative, which strands
|
||||
-- every polled consumer (launcher Kit rising-edge clicks, the pad-cursor
|
||||
-- motion yield, PadCursor) on coordinates no hit test can match. Sanitize
|
||||
-- the poll once here: remember the last window-relative event coordinates
|
||||
-- and substitute them whenever the polled value falls outside the window.
|
||||
-- Linux only -- macOS / Windows / mobile keep the stock function, and the
|
||||
-- NX launcher shim still composes because it captures whatever
|
||||
-- love.mouse.getPosition is at bridge time (_ensureNxPointerBridge).
|
||||
local eventMouseX, eventMouseY
|
||||
if love.system and love.system.getOS() == "Linux"
|
||||
and love.mouse and love.mouse.getPosition then
|
||||
local polledGetPosition = love.mouse.getPosition
|
||||
love.mouse.getPosition = function()
|
||||
local x, y = polledGetPosition()
|
||||
local w, h = love.graphics.getDimensions()
|
||||
if x < 0 or y < 0 or x > w or y > h then
|
||||
if eventMouseX then return eventMouseX, eventMouseY end
|
||||
return math.max(0, math.min(x, w)), math.max(0, math.min(y, h))
|
||||
end
|
||||
return x, y
|
||||
end
|
||||
end
|
||||
|
||||
function love.mousepressed(x, y, button, istouch)
|
||||
if not istouch then eventMouseX, eventMouseY = x, y end
|
||||
if TouchEditor then
|
||||
-- Android primary touch already arrived via love.touchpressed; a second
|
||||
-- mouse path would double-fire Done / begin a second drag.
|
||||
@@ -720,6 +746,7 @@ function love.mousereleased(x, y, button, istouch)
|
||||
end
|
||||
|
||||
function love.mousemoved(x, y, dx, dy, istouch)
|
||||
if not istouch then eventMouseX, eventMouseY = x, y end
|
||||
if TouchEditor then
|
||||
if love.system.getOS() == "Android" then return end
|
||||
return TouchEditor.mousemoved(x, y)
|
||||
@@ -748,7 +775,13 @@ local quitToLauncher = false
|
||||
|
||||
function love.quit()
|
||||
if editorMode and EditorApp.quit then
|
||||
return EditorApp.quit() -- return true to abort quit
|
||||
-- true blocks the quit (unsaved-changes prompt). A quit that proceeds
|
||||
-- must fall through to the worker shutdowns below instead of returning:
|
||||
-- the bundled editor opens from a live launcher whose update-check and
|
||||
-- fetch-pool workers are still parked in Channel:demand(), and returning
|
||||
-- here skipped their "quit" push, so the process outlived the closed
|
||||
-- window and kept the install folder locked on Windows (#727).
|
||||
if EditorApp.quit() then return true end
|
||||
end
|
||||
-- Closing the window of a running game returns to the launcher instead of
|
||||
-- exiting the app, so testing a mod does not need a relaunch every time
|
||||
|
||||
@@ -37,6 +37,13 @@
|
||||
|
||||
#include "filesystem/physfs/PhysfsIo.h"
|
||||
|
||||
// #604 / #839: the SAF bridges below must hand GameActivity the exact
|
||||
// directory physfs mounted as the save dir -- the same contract the iOS
|
||||
// GRPickerBridge already gets (mobile/ios/patch_love_src.py,
|
||||
// gr_saveDirectory) -- instead of letting Java recompute the root on its
|
||||
// own, which can name a different volume on merged / adopted-SD storage.
|
||||
#include "filesystem/Filesystem.h"
|
||||
|
||||
namespace love
|
||||
{
|
||||
namespace android
|
||||
@@ -183,6 +190,19 @@ void vibrate(double seconds)
|
||||
env->DeleteLocalRef(activity);
|
||||
}
|
||||
|
||||
// The directory physfs actually mounted as the save dir, or "" before the
|
||||
// filesystem module is up. GameActivity must copy SAF picks HERE: its own
|
||||
// getExternalFilesDir(null) recomputation can disagree with the mounted
|
||||
// root on merged / adopted-SD storage (#604, #839).
|
||||
static const char *bridgeSaveDirectory()
|
||||
{
|
||||
auto fs = Module::getInstance<love::filesystem::Filesystem>(Module::M_FILESYSTEM);
|
||||
if (fs == nullptr)
|
||||
return "";
|
||||
const char *dir = fs->getSaveDirectory();
|
||||
return dir != nullptr ? dir : "";
|
||||
}
|
||||
|
||||
bool showFilePicker(const char *destFilename)
|
||||
{
|
||||
if (destFilename == nullptr || destFilename[0] == '\0')
|
||||
@@ -192,9 +212,11 @@ bool showFilePicker(const char *destFilename)
|
||||
jclass activity = env->FindClass("org/love2d/android/GameActivity");
|
||||
|
||||
jmethodID method = env->GetStaticMethodID(activity, "showFilePicker",
|
||||
"(Ljava/lang/String;)Z");
|
||||
"(Ljava/lang/String;Ljava/lang/String;)Z");
|
||||
jstring jname = env->NewStringUTF(destFilename);
|
||||
jboolean result = env->CallStaticBooleanMethod(activity, method, jname);
|
||||
jstring jsavedir = env->NewStringUTF(bridgeSaveDirectory());
|
||||
jboolean result = env->CallStaticBooleanMethod(activity, method, jname, jsavedir);
|
||||
env->DeleteLocalRef(jsavedir);
|
||||
env->DeleteLocalRef(jname);
|
||||
|
||||
env->DeleteLocalRef(activity);
|
||||
@@ -210,9 +232,11 @@ bool showCreateDocument(const char *suggestedName)
|
||||
jclass activity = env->FindClass("org/love2d/android/GameActivity");
|
||||
|
||||
jmethodID method = env->GetStaticMethodID(activity, "showCreateDocument",
|
||||
"(Ljava/lang/String;)Z");
|
||||
"(Ljava/lang/String;Ljava/lang/String;)Z");
|
||||
jstring jname = env->NewStringUTF(suggestedName);
|
||||
jboolean result = env->CallStaticBooleanMethod(activity, method, jname);
|
||||
jstring jsavedir = env->NewStringUTF(bridgeSaveDirectory());
|
||||
jboolean result = env->CallStaticBooleanMethod(activity, method, jname, jsavedir);
|
||||
env->DeleteLocalRef(jsavedir);
|
||||
env->DeleteLocalRef(jname);
|
||||
|
||||
env->DeleteLocalRef(activity);
|
||||
|
||||
@@ -116,6 +116,18 @@ public class GameActivity extends SDLActivity {
|
||||
// bad ROM instead of installing it (#553).
|
||||
private String pendingPickFilename = PICKED_ROM_FILENAME;
|
||||
private static final String STATE_PENDING_PICK = "pendingPickFilename";
|
||||
// Absolute save directory physfs actually mounted, as reported by the
|
||||
// native bridge call that opened the picker (love/src/common/android.cpp,
|
||||
// bridgeSaveDirectory). This activity used to recompute
|
||||
// getExternalFilesDir(null)/save/<identity> on its own at result time; on
|
||||
// merged / adopted-SD storage that can name a different volume than the
|
||||
// one LOVE mounted, so the copied pick (and pick_error.flag) landed where
|
||||
// Lua never scans -- the launcher then "did nothing" after a pick (#604)
|
||||
// and the folders a file manager can browse stayed empty while the game
|
||||
// saved fine elsewhere (#839). Empty string means "not told yet": fall
|
||||
// back to the historical computation.
|
||||
private String pendingPickSaveDir = "";
|
||||
private static final String STATE_PENDING_PICK_DIR = "pendingPickSaveDir";
|
||||
private static final String STATE_PENDING_CREATE = "pendingCreateSuggestedName";
|
||||
// Suggested download name for the in-flight SAF create (set by showCreateDocument).
|
||||
private String pendingCreateSuggestedName = "export.sav";
|
||||
@@ -186,6 +198,8 @@ public class GameActivity extends SDLActivity {
|
||||
// a recreated activity still lands under the basename it asked for.
|
||||
String pick = savedInstanceState.getString(STATE_PENDING_PICK);
|
||||
if (pick != null) pendingPickFilename = pick;
|
||||
String pickDir = savedInstanceState.getString(STATE_PENDING_PICK_DIR);
|
||||
if (pickDir != null) pendingPickSaveDir = pickDir;
|
||||
String create = savedInstanceState.getString(STATE_PENDING_CREATE);
|
||||
if (create != null) pendingCreateSuggestedName = create;
|
||||
}
|
||||
@@ -467,13 +481,23 @@ public class GameActivity extends SDLActivity {
|
||||
* @param destFilename basename under the app save identity (e.g.
|
||||
* picked_rom.gb, picked_mod.zip, picked_save.sav)
|
||||
*/
|
||||
/** Legacy single-argument entry; resolves the save dir itself. */
|
||||
@Keep
|
||||
public static boolean showFilePicker(String destFilename) {
|
||||
return showFilePicker(destFilename, null);
|
||||
}
|
||||
|
||||
@Keep
|
||||
public static boolean showFilePicker(String destFilename, String saveDir) {
|
||||
GameActivity self = (GameActivity) mSingleton;
|
||||
if (self == null) return false;
|
||||
if (destFilename == null || destFilename.length() == 0) {
|
||||
destFilename = PICKED_ROM_FILENAME;
|
||||
}
|
||||
// Remember where LOVE's filesystem is really mounted so
|
||||
// onActivityResult copies the pick there, not into a recomputed
|
||||
// (possibly different-volume) root (#604, #839).
|
||||
self.pendingPickSaveDir = (saveDir != null) ? saveDir : "";
|
||||
// Reject path separators so a hostile JNI caller cannot escape the
|
||||
// save identity directory.
|
||||
if (destFilename.indexOf('/') >= 0 || destFilename.indexOf('\\') >= 0) {
|
||||
@@ -644,8 +668,14 @@ public class GameActivity extends SDLActivity {
|
||||
* return degrades on the Lua side (RomImporter export) to "Exported
|
||||
* inside the app folder", which is the correct pre-KitKat behavior.
|
||||
*/
|
||||
/** Legacy single-argument entry; resolves the save dir itself. */
|
||||
@Keep
|
||||
public static boolean showCreateDocument(String suggestedName) {
|
||||
return showCreateDocument(suggestedName, null);
|
||||
}
|
||||
|
||||
@Keep
|
||||
public static boolean showCreateDocument(String suggestedName, String saveDir) {
|
||||
if (android.os.Build.VERSION.SDK_INT < 19) return false;
|
||||
// (see showFilePicker for why the import side got a pre-19 path)
|
||||
GameActivity self = (GameActivity) mSingleton;
|
||||
@@ -657,9 +687,12 @@ public class GameActivity extends SDLActivity {
|
||||
Log.d("GameActivity", "refusing unsafe create name: " + suggestedName);
|
||||
return false;
|
||||
}
|
||||
File source = new File(
|
||||
new File(self.getExternalFilesDir(null), "save"),
|
||||
ROM_SAVE_IDENTITY + "/" + PENDING_EXPORT_FILENAME);
|
||||
// Route through the mounted save dir (#604, #839): Lua staged
|
||||
// pending_export.sav where physfs writes, which is not necessarily
|
||||
// where a fresh getExternalFilesDir(null) points on merged /
|
||||
// adopted-SD storage.
|
||||
self.pendingPickSaveDir = (saveDir != null) ? saveDir : "";
|
||||
File source = new File(self.saveIdentityDir(), PENDING_EXPORT_FILENAME);
|
||||
if (!source.isFile()) {
|
||||
Log.d("GameActivity", "no pending export at " + source);
|
||||
return false;
|
||||
@@ -680,7 +713,21 @@ public class GameActivity extends SDLActivity {
|
||||
}
|
||||
|
||||
private File saveIdentityDir() {
|
||||
return new File(new File(getExternalFilesDir(null), "save"), ROM_SAVE_IDENTITY);
|
||||
// Prefer the mounted save dir the last bridge call reported: the
|
||||
// recomputation below can name a different volume than the one LOVE
|
||||
// mounted on merged / adopted-SD storage (#604, #839).
|
||||
if (pendingPickSaveDir != null && pendingPickSaveDir.length() > 0) {
|
||||
return new File(pendingPickSaveDir);
|
||||
}
|
||||
File ext = getExternalFilesDir(null);
|
||||
if (ext == null) {
|
||||
// Shared storage unavailable (ejected / mid-adoption): without
|
||||
// this guard File(null, "save") silently built the RELATIVE
|
||||
// path save/<identity>, mkdirs() failed against "/", and the
|
||||
// pick was dropped with no message at all (#604).
|
||||
ext = getFilesDir();
|
||||
}
|
||||
return new File(new File(ext, "save"), ROM_SAVE_IDENTITY);
|
||||
}
|
||||
|
||||
/** Drops a small flag file in the save identity for Lua to consume on focus. */
|
||||
@@ -875,6 +922,7 @@ public class GameActivity extends SDLActivity {
|
||||
protected void onSaveInstanceState(Bundle outState) {
|
||||
super.onSaveInstanceState(outState);
|
||||
outState.putString(STATE_PENDING_PICK, pendingPickFilename);
|
||||
outState.putString(STATE_PENDING_PICK_DIR, pendingPickSaveDir);
|
||||
outState.putString(STATE_PENDING_CREATE, pendingCreateSuggestedName);
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,24 @@ function SafeArea.rect()
|
||||
return 0, 0, ww, wh
|
||||
end
|
||||
|
||||
-- A safe rect that cannot fit the window's unit space is a backend
|
||||
-- reporting framebuffer PIXELS -- the iOS build (LOVE 12 + SDL3) did this
|
||||
-- in portrait on iOS 16, and clamping it as-is kept a DPI-inflated top
|
||||
-- inset that pushed the whole launcher a band down the screen (#810).
|
||||
-- Convert back to units with per-axis ratios; the axes can disagree on
|
||||
-- forced-rotation devices (see displayMetrics in src/render/Renderer.lua,
|
||||
-- #208).
|
||||
if (w > ww + 0.5 or h > wh + 0.5)
|
||||
and love.graphics.getPixelDimensions then
|
||||
local pw, ph = love.graphics.getPixelDimensions()
|
||||
local dx = (pw and pw > 0) and (pw / ww) or 1
|
||||
local dy = (ph and ph > 0) and (ph / wh) or 1
|
||||
if dx > 1.01 or dy > 1.01 then
|
||||
x, w = x / dx, w / dx
|
||||
y, h = y / dy, h / dy
|
||||
end
|
||||
end
|
||||
|
||||
-- Clamp to the drawable window so a bad / mid-rotation backend cannot
|
||||
-- push layout outside the surface.
|
||||
x = math.max(0, math.min(x, ww))
|
||||
|
||||
@@ -417,6 +417,18 @@ function SaveData.saveOptions(opts, fs)
|
||||
#encoded, type(wrote) == "string" and tostring(#wrote) or "nothing")
|
||||
return nil
|
||||
end
|
||||
-- #828: roll the backup FORWARD to the bytes just verified. The
|
||||
-- pre-write roll above only preserves the previous file for a death
|
||||
-- during this rewrite; at rest the backup must hold the newest verified
|
||||
-- state, because the hard teardown out of a game session (HostShell's
|
||||
-- restartApp kill on Android, execv on a SteamOS AppImage) can eat the
|
||||
-- main file outright and loadOptions then promotes this copy. The
|
||||
-- encoder is key-sorted, so the follow-up rewrites a play session makes
|
||||
-- (play()'s lastVersion stamp, the in-game save flush) are byte-identical
|
||||
-- and skip the conditional roll -- without this line the backup still
|
||||
-- held the file from BEFORE the launcher's change, and recovery reverted
|
||||
-- the just-changed setting (BATTLE LAYOUT back to OG).
|
||||
fs.write(OPTIONS_BACKUP_FILENAME, encoded)
|
||||
-- the staged witness has served its purpose; the main file is verified
|
||||
remove(fs, OPTIONS_TMP_FILENAME)
|
||||
return opts
|
||||
|
||||
@@ -2200,7 +2200,15 @@ end
|
||||
-- pinned Play block used to walk up over the cards on a short window, which
|
||||
-- is unusable, and the footer simply lives below the fold until scrolled to.
|
||||
local function minPanelHeight(m)
|
||||
return math.floor(460 * m.s)
|
||||
-- One column stacks the actions card, the slot card and the pinned Play
|
||||
-- block in a single pile, so it needs more room than the side-by-side
|
||||
-- layout: 460 was tuned for two columns, and on a squat one-column window
|
||||
-- (a 4:3 device, a phone held upright) it left the slot card clipped
|
||||
-- inert against the pinned buttons -- Kit's clip bounds hit-testing, so
|
||||
-- no slot could be picked at all (#852). 660 fits the actions card, one
|
||||
-- slot row with its pager and New button, and the pinned block; whatever
|
||||
-- the window cannot show, the page scroll above reaches.
|
||||
return math.floor((m.twoCol and 460 or 660) * m.s)
|
||||
end
|
||||
|
||||
function LauncherView.draw(imp)
|
||||
|
||||
@@ -2409,10 +2409,18 @@ function RomImporter:runActions(queue)
|
||||
end
|
||||
|
||||
-- Clicks are polled inside FlexLove (mouse + love.touch); host-forwarded
|
||||
-- mousepressed stays inert so Android's synthesized mouse path cannot
|
||||
-- double-fire a tap (#553). Touch move/press/release must still reach
|
||||
-- mousepressed mints no click, so Android's synthesized mouse path cannot
|
||||
-- double-fire a tap (#553). It DOES hand the pointer back from the pad
|
||||
-- cursor (#781): a Linux boot with a joystick present arms it (see the
|
||||
-- getJoystickCount block in new()), and while it is active
|
||||
-- LauncherView.update refuses to mint mouse clicks, so a real press must
|
||||
-- win the pointer back even when the polled motion yield misses (X11
|
||||
-- multi-monitor coords). Same contract as PadCursor.yieldToPointer for
|
||||
-- the overlay hosts. Touch move/press/release must still reach
|
||||
-- FlexLove.touch* or scroll containers never drag on phones.
|
||||
function RomImporter:mousepressed() end
|
||||
function RomImporter:mousepressed()
|
||||
self._padCursorActive = false
|
||||
end
|
||||
|
||||
function RomImporter:touchpressed(id, x, y, dx, dy, pressure)
|
||||
if not self._flex then return end
|
||||
|
||||
+47
-12
@@ -474,6 +474,33 @@ local function removeTree(path)
|
||||
fs.remove(path)
|
||||
end
|
||||
|
||||
-- Every mods/ folder currently holding this id, plus the bare mods/<id> tree
|
||||
-- even when its manifest is missing or unreadable. Second return: whether any
|
||||
-- of them carries a manifest the panel can actually list. An install names
|
||||
-- its dest after the manifest id, but a hand-unzipped copy keeps whatever
|
||||
-- folder name the archive carried, and discover()'s first-id-wins rule means
|
||||
-- whichever folder physfs happens to enumerate first is the one the panel and
|
||||
-- the loader really use. Replacing only mods/<id> let an update report
|
||||
-- success while the old copy kept winning that race (#801); and a
|
||||
-- manifest-less mods/<id> left by an interrupted copy blocked every re-import
|
||||
-- as "already installed" while showing nowhere the player could see (#834).
|
||||
local function sameIdTrees(fs, id)
|
||||
local out, installed = {}, false
|
||||
if not fs.getInfo("mods") then return out, installed end
|
||||
for _, name in ipairs(fs.getDirectoryItems("mods")) do
|
||||
local path = "mods/" .. name
|
||||
local raw = fs.read(path .. "/manifest.json")
|
||||
local manifest = raw and decodeManifest(raw, path)
|
||||
if manifest and manifest.id == id then
|
||||
out[#out + 1] = path
|
||||
installed = true
|
||||
elseif name == id and fs.getInfo(path) then
|
||||
out[#out + 1] = path
|
||||
end
|
||||
end
|
||||
return out, installed
|
||||
end
|
||||
|
||||
-- ------- strays: mods dropped beside the game that it cannot see
|
||||
|
||||
-- love.filesystem looks in two places for "mods/": the save directory, and --
|
||||
@@ -666,16 +693,21 @@ function LauncherMods._installZipInner(source, opts)
|
||||
end
|
||||
|
||||
local dest = "mods/" .. manifest.id
|
||||
if fs.getInfo(dest) then
|
||||
if not opts.replace then
|
||||
cleanup()
|
||||
return nil, "a mod named '" .. manifest.id .. "' is already installed"
|
||||
end
|
||||
-- drop the old tree before copy; enable-flag is preserved (uninstall
|
||||
-- would clear it, which would surprise an update)
|
||||
local existing, installedSomewhere = sameIdTrees(fs, manifest.id)
|
||||
if installedSomewhere and not opts.replace then
|
||||
cleanup()
|
||||
return nil, "a mod named '" .. manifest.id .. "' is already installed"
|
||||
end
|
||||
if #existing > 0 then
|
||||
-- drop every old tree before copy -- mods/<id> and any same-id folder
|
||||
-- under another name, or the survivor keeps winning discover()'s
|
||||
-- first-id-wins race after the "successful" update (#801). A tree with
|
||||
-- no readable manifest is debris from an interrupted copy: it never
|
||||
-- refuses the install, it only gets cleared (#834). Enable-flag is
|
||||
-- preserved (uninstall would clear it, which would surprise an update).
|
||||
local savedPrefix = CacheFs.prefix
|
||||
CacheFs.prefix = ""
|
||||
removeTree(dest)
|
||||
for _, path in ipairs(existing) do removeTree(path) end
|
||||
CacheFs.prefix = savedPrefix
|
||||
end
|
||||
|
||||
@@ -789,14 +821,17 @@ function LauncherMods.uninstall(id)
|
||||
return nil, "mod uninstall needs LOVE"
|
||||
end
|
||||
local fs = love.filesystem
|
||||
local dest = "mods/" .. id
|
||||
if not fs.getInfo(dest) then
|
||||
local trees = sameIdTrees(fs, id)
|
||||
if #trees == 0 then
|
||||
return nil, "mod '" .. id .. "' is not installed"
|
||||
end
|
||||
-- same root pin as installZip: the mods tree is not version-prefixed (#330)
|
||||
-- same root pin as installZip: the mods tree is not version-prefixed (#330).
|
||||
-- Every same-id tree goes, folder name notwithstanding, so Delete works on a
|
||||
-- hand-unzipped copy too and cannot leave a shadow copy for discover()'s
|
||||
-- first-id-wins rule to resurrect on the next boot (#801)
|
||||
local savedPrefix = CacheFs.prefix
|
||||
CacheFs.prefix = ""
|
||||
removeTree(dest)
|
||||
for _, path in ipairs(trees) do removeTree(path) end
|
||||
CacheFs.prefix = savedPrefix
|
||||
-- Drop the enable flag so a reinstall of the same id starts from the
|
||||
-- loader's default (enabled) rather than a stale false.
|
||||
|
||||
+17
-2
@@ -820,8 +820,13 @@ function Renderer:endFrame(zones, worldZones)
|
||||
-- non-battle state that opts in) uses the paper shade. "world" never
|
||||
-- reaches here -- it makes the battle non-opaque, so the world pass is
|
||||
-- active and this whole branch is skipped.
|
||||
-- FAITHFUL RATIO's mobile lock promises the display outside the GB
|
||||
-- screen stays black (src/core/FaithfulRes.lua); the paper surround
|
||||
-- painted the whole phone white on New Game and in battle (#864), so
|
||||
-- the lock keeps the default black bars.
|
||||
if state and state.letterboxWhite
|
||||
and not (state.bgMode and state:bgMode() == "black") then
|
||||
and not (state.bgMode and state:bgMode() == "black")
|
||||
and not FaithfulRes.scaleCap() then
|
||||
clearR, clearG, clearB = PaletteFX.paperShade(Game and Game.data)
|
||||
end
|
||||
end
|
||||
@@ -1029,7 +1034,17 @@ function Renderer:endFrame(zones, worldZones)
|
||||
local veil = self.screenVeil
|
||||
if veil and veil[2] > 0 then
|
||||
love.graphics.setColor(veil[1], veil[1], veil[1], veil[2])
|
||||
love.graphics.rectangle("fill", 0, 0, ww, wh)
|
||||
-- FAITHFUL RATIO's mobile lock: the surface the player sees is the
|
||||
-- locked viewport and the bars around it are dead display, not screen
|
||||
-- (src/core/FaithfulRes.lua). A whole-window veil lit the entire phone
|
||||
-- for the battle flash and the post-battle fade (#864), so under the
|
||||
-- lock the veil stops at the letterbox. The desktop lock is unaffected:
|
||||
-- there the window IS the viewport.
|
||||
if FaithfulRes.scaleCap() then
|
||||
love.graphics.rectangle("fill", ox, oy, vpw, vph)
|
||||
else
|
||||
love.graphics.rectangle("fill", 0, 0, ww, wh)
|
||||
end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
|
||||
@@ -93,6 +93,12 @@ O.statusFlags1 = O.townVisited + 29 -- 1B
|
||||
O.statusFlags4 = O.townVisited + 35 -- 1B
|
||||
O.elite4Flags = O.townVisited + 41 -- 1B
|
||||
O.tradeFlags = O.townVisited + 44 -- 2B (flag_array NUM_NPC_TRADES)
|
||||
-- wToggleableObjectFlags (ram/wram.asm, flag_array $100): the ShowObject/
|
||||
-- HideObject persistence, one bit per data/maps/toggleable_objects.asm entry,
|
||||
-- set = hidden (engine/overworld/toggleable_objects.asm IsObjectHidden).
|
||||
-- Sits 2 bytes (wPlayerCoins) past O.coins per the walk above; absolute
|
||||
-- 0x2852 (#763, #857).
|
||||
O.toggleObjectFlags = O.coins + 2 -- 32B
|
||||
-- Play time (wPlayTimeHours/Maxed/Minutes/Seconds/Frames) lives INSIDE the
|
||||
-- sMainData window (wMainDataStart..wMainDataEnd is copied verbatim into
|
||||
-- SRAM), 1866 bytes past wMainDataStart -- reached from the checksum-verified
|
||||
@@ -107,6 +113,15 @@ O.playTimeMaxed = O.mainData + 1867 -- 1B (set once past 2
|
||||
O.playTimeMinutes = O.mainData + 1868 -- 1B (0-59)
|
||||
O.playTimeSeconds = O.mainData + 1869 -- 1B (0-59)
|
||||
O.playTimeFrames = O.mainData + 1870 -- 1B (0-59, 1/60s ticks)
|
||||
-- wPikachuHappiness, Yellow only (pret/pokeyellow ram/wram.asm; no local
|
||||
-- pokeyellow checkout, so verified against the pokeyellow symbol file
|
||||
-- instead: d46f - wMainDataStart d2f6 = 377, the well-known absolute
|
||||
-- 0x271C). In Red/Blue this byte is current-map scratch the game
|
||||
-- regenerates on load, so the codec touches it only when the crosswalk
|
||||
-- data set names the game "yellow" (#763, #838). Every other modeled
|
||||
-- offset is identical between pokered and pokeyellow (same sram.asm, same
|
||||
-- wMainData field spacing per both symbol files).
|
||||
O.pikachuHappiness = O.mainData + 377
|
||||
O.mainDataSize = 1929 -- wMainDataEnd - wMainDataStart
|
||||
|
||||
O.spriteData = O.mainData + O.mainDataSize
|
||||
@@ -729,6 +744,25 @@ function GenSave.decode(bytes, data, opts)
|
||||
if save.flags[vanillaName] then save.flags[portName] = true end
|
||||
end
|
||||
|
||||
-- wToggleableObjectFlags -> save.objectToggles (bit set = hidden). A few
|
||||
-- of these are re-derived from flags on map entry (#106/#234 onEnter
|
||||
-- re-applies), but most ShowObject/HideObject state -- the Mt Moon
|
||||
-- fossils, the Cerulean guard swap -- has no flag to re-derive from, so
|
||||
-- an import that drops the array resurrects taken fossils and blocking
|
||||
-- guards (#763, #857).
|
||||
local toggles = data.toggleObjects
|
||||
if toggles then
|
||||
save.objectToggles = {}
|
||||
for bitIdx, e in pairs(toggles.byBit) do
|
||||
local mapToggles = save.objectToggles[e[1]]
|
||||
if not mapToggles then
|
||||
mapToggles = {}
|
||||
save.objectToggles[e[1]] = mapToggles
|
||||
end
|
||||
mapToggles[e[2]] = not bitGet(bytes, O.toggleObjectFlags, bitIdx)
|
||||
end
|
||||
end
|
||||
|
||||
-- FLY destinations. wTownVisitedFlag's bit index IS the town's map index:
|
||||
-- engine/items/town_map.asm BuildFlyLocationsList loads the 16-bit value
|
||||
-- into de and rotates it right one bit per iteration with b counting up
|
||||
@@ -774,6 +808,15 @@ function GenSave.decode(bytes, data, opts)
|
||||
+ u8(bytes, O.playTimeSeconds)
|
||||
+ u8(bytes, O.playTimeFrames) / 60
|
||||
|
||||
-- Yellow starter friendship (save.pikachuHappiness,
|
||||
-- src/world/PikachuFollower.lua reads it; pokeyellow's
|
||||
-- init_player_data.asm seeds 90 on a new game), gated on the data set's
|
||||
-- game because the byte is map scratch in Red/Blue (see
|
||||
-- O.pikachuHappiness) (#763, #838).
|
||||
if data.gameVersion == "yellow" then
|
||||
save.pikachuHappiness = u8(bytes, O.pikachuHappiness)
|
||||
end
|
||||
|
||||
save.warnings = warnings
|
||||
save.rawImport = bytes -- template for a later encode(); see file header
|
||||
return save
|
||||
@@ -868,6 +911,41 @@ function GenSave.encode(save, data, template)
|
||||
end
|
||||
end
|
||||
|
||||
-- wToggleableObjectFlags, written both ways like the #396 extras: this
|
||||
-- port's save is the authority, and vanilla folds three stores this port
|
||||
-- keeps separate into these same bits -- script ShowObject/HideObject
|
||||
-- (save.objectToggles), taken overworld items (engine/events/
|
||||
-- pick_up_item.asm -> save.itemsTaken) and beaten static encounters
|
||||
-- (home/trainers.asm HideObject after battle -> save.defeatedTrainers) --
|
||||
-- so all three fold back in here or an exported save resurrects them
|
||||
-- (#763, #857).
|
||||
local toggleData = data.toggleObjects
|
||||
if toggleData then
|
||||
local objectToggles = save.objectToggles or {}
|
||||
local itemsTaken = save.itemsTaken or {}
|
||||
local beaten = save.defeatedTrainers or {}
|
||||
for bitIdx, e in pairs(toggleData.byBit) do
|
||||
local mapId, objName, visible = e[1], e[2], e[3]
|
||||
local mapToggles = objectToggles[mapId]
|
||||
if mapToggles and mapToggles[objName] ~= nil then
|
||||
visible = mapToggles[objName]
|
||||
end
|
||||
if visible and data.maps and data.maps[mapId] then
|
||||
for _, obj in ipairs(data.maps[mapId].objects or {}) do
|
||||
if obj.name == objName then
|
||||
local key = mapId .. "_obj_" .. obj.index
|
||||
if (obj.item and itemsTaken[key])
|
||||
or (obj.pokemon and beaten[key]) then
|
||||
visible = false
|
||||
end
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
bitSet(buf, O.toggleObjectFlags, bitIdx, not visible)
|
||||
end
|
||||
end
|
||||
|
||||
-- FLY destinations back into wTownVisitedFlag (see the decode note), so a
|
||||
-- save exported from this port is flyable on hardware (#263). A save
|
||||
-- table with no `visited` key at all says nothing about the set, so leave
|
||||
@@ -967,6 +1045,16 @@ function GenSave.encode(save, data, template)
|
||||
setByte(buf, O.playTimeFrames, rem - secs * 60)
|
||||
end
|
||||
|
||||
-- Yellow starter friendship back out (see O.pikachuHappiness); Red/Blue
|
||||
-- data sets never reach this write. 90 is the fresh-game seed the
|
||||
-- follower system itself uses when the save has never tracked it.
|
||||
-- Placed before the checksum pass so the byte is covered by the
|
||||
-- main-data checksum automatically (#763, #838).
|
||||
if data.gameVersion == "yellow" then
|
||||
local h = tonumber(save.pikachuHappiness) or 90
|
||||
setByte(buf, O.pikachuHappiness, math.max(0, math.min(255, math.floor(h))))
|
||||
end
|
||||
|
||||
local out = table.concat(buf)
|
||||
-- checksums, computed last over the now-final bytes
|
||||
local outBuf = {}
|
||||
|
||||
@@ -44,6 +44,19 @@ local DATA_MODULES = {
|
||||
maps = { "data.generated.maps", "data/generated/maps.lua" },
|
||||
charmap = { "src.save_convert.data.charmap", "src/save_convert/data/charmap.lua" },
|
||||
eventFlags = { "src.save_convert.data.event_flags", "src/save_convert/data/event_flags.lua" },
|
||||
toggleObjects = { "src.save_convert.data.toggle_objects", "src/save_convert/data/toggle_objects.lua" },
|
||||
}
|
||||
|
||||
-- Yellow renumbers wEventFlags bits: pokeyellow's constants/event_constants.asm
|
||||
-- inserts events pokered does not have (the Jessie & James fights, catch
|
||||
-- training, the Officer Jenny Squirtle) and shifts the Mt Moon 3 / Silph Co
|
||||
-- 11F block, so writing a Yellow save through the Red table lands bits on the
|
||||
-- wrong events and drops every Yellow-only flag. Kept outside DATA_MODULES so
|
||||
-- the ensureData loop never loads it as a crosswalk of its own -- it
|
||||
-- substitutes for `eventFlags` when the caller names Yellow (#838).
|
||||
local YELLOW_EVENT_FLAGS = {
|
||||
"src.save_convert.data.event_flags_yellow",
|
||||
"src/save_convert/data/event_flags_yellow.lua",
|
||||
}
|
||||
|
||||
local function loadTable(requirePath, filePath)
|
||||
@@ -112,6 +125,9 @@ local function ensureData(gameVersion)
|
||||
local data = {}
|
||||
for name, spec in pairs(DATA_MODULES) do
|
||||
if name ~= "charmap" then
|
||||
if name == "eventFlags" and gameVersion == "yellow" then
|
||||
spec = YELLOW_EVENT_FLAGS -- Yellow's bit numbering differs (#838)
|
||||
end
|
||||
local mod = loadCacheTable(gameVersion, spec[2])
|
||||
if not mod then
|
||||
local e
|
||||
@@ -121,6 +137,10 @@ local function ensureData(gameVersion)
|
||||
data[name] = mod
|
||||
end
|
||||
end
|
||||
-- record which game's tables these are: the codec gates Yellow-only
|
||||
-- bytes (wPikachuFriendship) on it, since those offsets are map
|
||||
-- scratch in Red/Blue (#763, #838)
|
||||
data.gameVersion = gameVersion
|
||||
crosswalks[key] = data
|
||||
end
|
||||
if not charmapReady then
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,243 @@
|
||||
-- wToggleableObjectFlags bit index -> { map id, object_event name, default
|
||||
-- visible } for the Gen1 save codec (src/save_convert/GenSave.lua).
|
||||
-- Derived entry by entry from ../pokered/data/maps/toggleable_objects.asm
|
||||
-- (ToggleableObjectStates: three bytes per entry, blocks laid out in map-id
|
||||
-- order, so an entry's position in the table IS its wToggleableObjectFlags
|
||||
-- bit -- constants/toggle_constants.asm numbers the same list), with the
|
||||
-- default taken from each row's ON/OFF state. Bit set = hidden
|
||||
-- (engine/overworld/toggleable_objects.asm IsObjectHidden). Object names
|
||||
-- match data/generated/maps.lua object_event names one for one; the two
|
||||
-- placeholder bits with no object_event in this port stay as comments so
|
||||
-- the numbering remains auditable (#763, #857).
|
||||
return {
|
||||
byBit = {
|
||||
[0] = { "PALLET_TOWN", "PALLETTOWN_OAK", false },
|
||||
[1] = { "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN_SLEEPY", true },
|
||||
[2] = { "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN", false },
|
||||
[3] = { "PEWTER_CITY", "PEWTERCITY_SUPER_NERD1", true },
|
||||
[4] = { "PEWTER_CITY", "PEWTERCITY_YOUNGSTER", true },
|
||||
[5] = { "CERULEAN_CITY", "CERULEANCITY_RIVAL", false },
|
||||
[6] = { "CERULEAN_CITY", "CERULEANCITY_ROCKET", true },
|
||||
[7] = { "CERULEAN_CITY", "CERULEANCITY_GUARD1", false },
|
||||
[8] = { "CERULEAN_CITY", "CERULEANCITY_SUPER_NERD3", true },
|
||||
[9] = { "CERULEAN_CITY", "CERULEANCITY_GUARD2", true },
|
||||
[10] = { "SAFFRON_CITY", "SAFFRONCITY_ROCKET1", true },
|
||||
[11] = { "SAFFRON_CITY", "SAFFRONCITY_ROCKET2", true },
|
||||
[12] = { "SAFFRON_CITY", "SAFFRONCITY_ROCKET3", true },
|
||||
[13] = { "SAFFRON_CITY", "SAFFRONCITY_ROCKET4", true },
|
||||
[14] = { "SAFFRON_CITY", "SAFFRONCITY_ROCKET5", true },
|
||||
[15] = { "SAFFRON_CITY", "SAFFRONCITY_ROCKET6", true },
|
||||
[16] = { "SAFFRON_CITY", "SAFFRONCITY_ROCKET7", true },
|
||||
[17] = { "SAFFRON_CITY", "SAFFRONCITY_SCIENTIST", false },
|
||||
[18] = { "SAFFRON_CITY", "SAFFRONCITY_SILPH_WORKER_M", false },
|
||||
[19] = { "SAFFRON_CITY", "SAFFRONCITY_SILPH_WORKER_F", false },
|
||||
[20] = { "SAFFRON_CITY", "SAFFRONCITY_GENTLEMAN", false },
|
||||
[21] = { "SAFFRON_CITY", "SAFFRONCITY_PIDGEOT", false },
|
||||
[22] = { "SAFFRON_CITY", "SAFFRONCITY_ROCKER", false },
|
||||
[23] = { "SAFFRON_CITY", "SAFFRONCITY_ROCKET8", true },
|
||||
[24] = { "SAFFRON_CITY", "SAFFRONCITY_ROCKET9", false },
|
||||
[25] = { "ROUTE_2", "ROUTE2_MOON_STONE", true },
|
||||
[26] = { "ROUTE_2", "ROUTE2_HP_UP", true },
|
||||
[27] = { "ROUTE_4", "ROUTE4_TM_WHIRLWIND", true },
|
||||
[28] = { "ROUTE_9", "ROUTE9_TM_TELEPORT", true },
|
||||
[29] = { "ROUTE_12", "ROUTE12_SNORLAX", true },
|
||||
[30] = { "ROUTE_12", "ROUTE12_TM_PAY_DAY", true },
|
||||
[31] = { "ROUTE_12", "ROUTE12_IRON", true },
|
||||
[32] = { "ROUTE_15", "ROUTE15_TM_RAGE", true },
|
||||
[33] = { "ROUTE_16", "ROUTE16_SNORLAX", true },
|
||||
[34] = { "ROUTE_22", "ROUTE22_RIVAL1", false },
|
||||
[35] = { "ROUTE_22", "ROUTE22_RIVAL2", false },
|
||||
[36] = { "ROUTE_24", "ROUTE24_COOLTRAINER_M1", true },
|
||||
[37] = { "ROUTE_24", "ROUTE24_TM_THUNDER_WAVE", true },
|
||||
[38] = { "ROUTE_25", "ROUTE25_TM_SEISMIC_TOSS", true },
|
||||
[39] = { "BLUES_HOUSE", "BLUESHOUSE_DAISY1", true },
|
||||
[40] = { "BLUES_HOUSE", "BLUESHOUSE_DAISY2", false },
|
||||
[41] = { "BLUES_HOUSE", "BLUESHOUSE_TOWN_MAP", true },
|
||||
[42] = { "OAKS_LAB", "OAKSLAB_RIVAL", true },
|
||||
[43] = { "OAKS_LAB", "OAKSLAB_CHARMANDER_POKE_BALL", true },
|
||||
[44] = { "OAKS_LAB", "OAKSLAB_SQUIRTLE_POKE_BALL", true },
|
||||
[45] = { "OAKS_LAB", "OAKSLAB_BULBASAUR_POKE_BALL", true },
|
||||
[46] = { "OAKS_LAB", "OAKSLAB_OAK1", false },
|
||||
[47] = { "OAKS_LAB", "OAKSLAB_POKEDEX1", true },
|
||||
[48] = { "OAKS_LAB", "OAKSLAB_POKEDEX2", true },
|
||||
[49] = { "OAKS_LAB", "OAKSLAB_OAK2", false },
|
||||
[50] = { "VIRIDIAN_GYM", "VIRIDIANGYM_GIOVANNI", true },
|
||||
[51] = { "VIRIDIAN_GYM", "VIRIDIANGYM_REVIVE", true },
|
||||
[52] = { "MUSEUM_1F", "MUSEUM1F_OLD_AMBER", true },
|
||||
[53] = { "CERULEAN_CAVE_1F", "CERULEANCAVE1F_FULL_RESTORE", true },
|
||||
[54] = { "CERULEAN_CAVE_1F", "CERULEANCAVE1F_MAX_ELIXER", true },
|
||||
[55] = { "CERULEAN_CAVE_1F", "CERULEANCAVE1F_NUGGET", true },
|
||||
[56] = { "POKEMON_TOWER_2F", "POKEMONTOWER2F_RIVAL", true },
|
||||
[57] = { "POKEMON_TOWER_3F", "POKEMONTOWER3F_ESCAPE_ROPE", true },
|
||||
[58] = { "POKEMON_TOWER_4F", "POKEMONTOWER4F_ELIXER", true },
|
||||
[59] = { "POKEMON_TOWER_4F", "POKEMONTOWER4F_AWAKENING", true },
|
||||
[60] = { "POKEMON_TOWER_4F", "POKEMONTOWER4F_HP_UP", true },
|
||||
[61] = { "POKEMON_TOWER_5F", "POKEMONTOWER5F_NUGGET", true },
|
||||
[62] = { "POKEMON_TOWER_6F", "POKEMONTOWER6F_RARE_CANDY", true },
|
||||
[63] = { "POKEMON_TOWER_6F", "POKEMONTOWER6F_X_ACCURACY", true },
|
||||
[64] = { "POKEMON_TOWER_7F", "POKEMONTOWER7F_ROCKET1", true },
|
||||
[65] = { "POKEMON_TOWER_7F", "POKEMONTOWER7F_ROCKET2", true },
|
||||
[66] = { "POKEMON_TOWER_7F", "POKEMONTOWER7F_ROCKET3", true },
|
||||
[67] = { "POKEMON_TOWER_7F", "POKEMONTOWER7F_MR_FUJI", true },
|
||||
[68] = { "MR_FUJIS_HOUSE", "MRFUJISHOUSE_MR_FUJI", false },
|
||||
[69] = { "CELADON_MANSION_ROOF_HOUSE", "CELADONMANSION_ROOF_HOUSE_EEVEE_POKEBALL", true },
|
||||
[70] = { "GAME_CORNER", "GAMECORNER_ROCKET", true },
|
||||
[71] = { "WARDENS_HOUSE", "WARDENSHOUSE_RARE_CANDY", true },
|
||||
[72] = { "POKEMON_MANSION_1F", "POKEMONMANSION1F_ESCAPE_ROPE", true },
|
||||
[73] = { "POKEMON_MANSION_1F", "POKEMONMANSION1F_CARBOS", true },
|
||||
[74] = { "FIGHTING_DOJO", "FIGHTINGDOJO_HITMONLEE_POKE_BALL", true },
|
||||
[75] = { "FIGHTING_DOJO", "FIGHTINGDOJO_HITMONCHAN_POKE_BALL", true },
|
||||
[76] = { "SILPH_CO_1F", "SILPHCO1F_LINK_RECEPTIONIST", false },
|
||||
[77] = { "POWER_PLANT", "POWERPLANT_VOLTORB1", true },
|
||||
[78] = { "POWER_PLANT", "POWERPLANT_VOLTORB2", true },
|
||||
[79] = { "POWER_PLANT", "POWERPLANT_VOLTORB3", true },
|
||||
[80] = { "POWER_PLANT", "POWERPLANT_ELECTRODE1", true },
|
||||
[81] = { "POWER_PLANT", "POWERPLANT_VOLTORB4", true },
|
||||
[82] = { "POWER_PLANT", "POWERPLANT_VOLTORB5", true },
|
||||
[83] = { "POWER_PLANT", "POWERPLANT_ELECTRODE2", true },
|
||||
[84] = { "POWER_PLANT", "POWERPLANT_VOLTORB6", true },
|
||||
[85] = { "POWER_PLANT", "POWERPLANT_ZAPDOS", true },
|
||||
[86] = { "POWER_PLANT", "POWERPLANT_CARBOS", true },
|
||||
[87] = { "POWER_PLANT", "POWERPLANT_HP_UP", true },
|
||||
[88] = { "POWER_PLANT", "POWERPLANT_RARE_CANDY", true },
|
||||
[89] = { "POWER_PLANT", "POWERPLANT_TM_THUNDER", true },
|
||||
[90] = { "POWER_PLANT", "POWERPLANT_TM_REFLECT", true },
|
||||
[91] = { "VICTORY_ROAD_2F", "VICTORYROAD2F_MOLTRES", true },
|
||||
[92] = { "VICTORY_ROAD_2F", "VICTORYROAD2F_TM_SUBMISSION", true },
|
||||
[93] = { "VICTORY_ROAD_2F", "VICTORYROAD2F_FULL_HEAL", true },
|
||||
[94] = { "VICTORY_ROAD_2F", "VICTORYROAD2F_TM_MEGA_KICK", true },
|
||||
[95] = { "VICTORY_ROAD_2F", "VICTORYROAD2F_GUARD_SPEC", true },
|
||||
[96] = { "VICTORY_ROAD_2F", "VICTORYROAD2F_BOULDER3", true },
|
||||
[97] = { "BILLS_HOUSE", "BILLSHOUSE_BILL_POKEMON", true },
|
||||
[98] = { "BILLS_HOUSE", "BILLSHOUSE_BILL1", false },
|
||||
[99] = { "BILLS_HOUSE", "BILLSHOUSE_BILL2", false },
|
||||
[100] = { "VIRIDIAN_FOREST", "VIRIDIANFOREST_ANTIDOTE", true },
|
||||
[101] = { "VIRIDIAN_FOREST", "VIRIDIANFOREST_POTION", true },
|
||||
[102] = { "VIRIDIAN_FOREST", "VIRIDIANFOREST_POKE_BALL", true },
|
||||
[103] = { "MT_MOON_1F", "MTMOON1F_POTION1", true },
|
||||
[104] = { "MT_MOON_1F", "MTMOON1F_MOON_STONE", true },
|
||||
[105] = { "MT_MOON_1F", "MTMOON1F_RARE_CANDY", true },
|
||||
[106] = { "MT_MOON_1F", "MTMOON1F_ESCAPE_ROPE", true },
|
||||
[107] = { "MT_MOON_1F", "MTMOON1F_POTION2", true },
|
||||
[108] = { "MT_MOON_1F", "MTMOON1F_TM_WATER_GUN", true },
|
||||
[109] = { "MT_MOON_B2F", "MTMOONB2F_DOME_FOSSIL", true },
|
||||
[110] = { "MT_MOON_B2F", "MTMOONB2F_HELIX_FOSSIL", true },
|
||||
[111] = { "MT_MOON_B2F", "MTMOONB2F_HP_UP", true },
|
||||
[112] = { "MT_MOON_B2F", "MTMOONB2F_TM_MEGA_PUNCH", true },
|
||||
[113] = { "SS_ANNE_2F", "SSANNE2F_RIVAL", false },
|
||||
[114] = { "SS_ANNE_1F_ROOMS", "SSANNE1FROOMS_TM_BODY_SLAM", true },
|
||||
[115] = { "SS_ANNE_2F_ROOMS", "SSANNE2FROOMS_MAX_ETHER", true },
|
||||
[116] = { "SS_ANNE_2F_ROOMS", "SSANNE2FROOMS_RARE_CANDY", true },
|
||||
[117] = { "SS_ANNE_B1F_ROOMS", "SSANNEB1FROOMS_ETHER", true },
|
||||
[118] = { "SS_ANNE_B1F_ROOMS", "SSANNEB1FROOMS_TM_REST", true },
|
||||
[119] = { "SS_ANNE_B1F_ROOMS", "SSANNEB1FROOMS_MAX_POTION", true },
|
||||
[120] = { "VICTORY_ROAD_3F", "VICTORYROAD3F_MAX_REVIVE", true },
|
||||
[121] = { "VICTORY_ROAD_3F", "VICTORYROAD3F_TM_EXPLOSION", true },
|
||||
[122] = { "VICTORY_ROAD_3F", "VICTORYROAD3F_BOULDER4", true },
|
||||
[123] = { "ROCKET_HIDEOUT_B1F", "ROCKETHIDEOUTB1F_ESCAPE_ROPE", true },
|
||||
[124] = { "ROCKET_HIDEOUT_B1F", "ROCKETHIDEOUTB1F_HYPER_POTION", true },
|
||||
[125] = { "ROCKET_HIDEOUT_B2F", "ROCKETHIDEOUTB2F_MOON_STONE", true },
|
||||
[126] = { "ROCKET_HIDEOUT_B2F", "ROCKETHIDEOUTB2F_NUGGET", true },
|
||||
[127] = { "ROCKET_HIDEOUT_B2F", "ROCKETHIDEOUTB2F_TM_HORN_DRILL", true },
|
||||
[128] = { "ROCKET_HIDEOUT_B2F", "ROCKETHIDEOUTB2F_SUPER_POTION", true },
|
||||
[129] = { "ROCKET_HIDEOUT_B3F", "ROCKETHIDEOUTB3F_TM_DOUBLE_EDGE", true },
|
||||
[130] = { "ROCKET_HIDEOUT_B3F", "ROCKETHIDEOUTB3F_RARE_CANDY", true },
|
||||
[131] = { "ROCKET_HIDEOUT_B4F", "ROCKETHIDEOUTB4F_GIOVANNI", true },
|
||||
[132] = { "ROCKET_HIDEOUT_B4F", "ROCKETHIDEOUTB4F_HP_UP", true },
|
||||
[133] = { "ROCKET_HIDEOUT_B4F", "ROCKETHIDEOUTB4F_TM_RAZOR_WIND", true },
|
||||
[134] = { "ROCKET_HIDEOUT_B4F", "ROCKETHIDEOUTB4F_IRON", true },
|
||||
[135] = { "ROCKET_HIDEOUT_B4F", "ROCKETHIDEOUTB4F_SILPH_SCOPE", false },
|
||||
[136] = { "ROCKET_HIDEOUT_B4F", "ROCKETHIDEOUTB4F_LIFT_KEY", false },
|
||||
[137] = { "SILPH_CO_2F", "SILPHCO2F_SILPH_WORKER_F", true },
|
||||
[138] = { "SILPH_CO_2F", "SILPHCO2F_SCIENTIST1", true },
|
||||
[139] = { "SILPH_CO_2F", "SILPHCO2F_SCIENTIST2", true },
|
||||
[140] = { "SILPH_CO_2F", "SILPHCO2F_ROCKET1", true },
|
||||
[141] = { "SILPH_CO_2F", "SILPHCO2F_ROCKET2", true },
|
||||
[142] = { "SILPH_CO_3F", "SILPHCO3F_ROCKET", true },
|
||||
[143] = { "SILPH_CO_3F", "SILPHCO3F_SCIENTIST", true },
|
||||
[144] = { "SILPH_CO_3F", "SILPHCO3F_HYPER_POTION", true },
|
||||
[145] = { "SILPH_CO_4F", "SILPHCO4F_ROCKET1", true },
|
||||
[146] = { "SILPH_CO_4F", "SILPHCO4F_SCIENTIST", true },
|
||||
[147] = { "SILPH_CO_4F", "SILPHCO4F_ROCKET2", true },
|
||||
[148] = { "SILPH_CO_4F", "SILPHCO4F_FULL_HEAL", true },
|
||||
[149] = { "SILPH_CO_4F", "SILPHCO4F_MAX_REVIVE", true },
|
||||
[150] = { "SILPH_CO_4F", "SILPHCO4F_ESCAPE_ROPE", true },
|
||||
[151] = { "SILPH_CO_5F", "SILPHCO5F_ROCKET1", true },
|
||||
[152] = { "SILPH_CO_5F", "SILPHCO5F_SCIENTIST", true },
|
||||
[153] = { "SILPH_CO_5F", "SILPHCO5F_ROCKER", true },
|
||||
[154] = { "SILPH_CO_5F", "SILPHCO5F_ROCKET2", true },
|
||||
[155] = { "SILPH_CO_5F", "SILPHCO5F_TM_TAKE_DOWN", true },
|
||||
[156] = { "SILPH_CO_5F", "SILPHCO5F_PROTEIN", true },
|
||||
[157] = { "SILPH_CO_5F", "SILPHCO5F_CARD_KEY", true },
|
||||
[158] = { "SILPH_CO_6F", "SILPHCO6F_ROCKET1", true },
|
||||
[159] = { "SILPH_CO_6F", "SILPHCO6F_SCIENTIST", true },
|
||||
[160] = { "SILPH_CO_6F", "SILPHCO6F_ROCKET2", true },
|
||||
[161] = { "SILPH_CO_6F", "SILPHCO6F_HP_UP", true },
|
||||
[162] = { "SILPH_CO_6F", "SILPHCO6F_X_ACCURACY", true },
|
||||
[163] = { "SILPH_CO_7F", "SILPHCO7F_ROCKET1", true },
|
||||
[164] = { "SILPH_CO_7F", "SILPHCO7F_SCIENTIST", true },
|
||||
[165] = { "SILPH_CO_7F", "SILPHCO7F_ROCKET2", true },
|
||||
[166] = { "SILPH_CO_7F", "SILPHCO7F_ROCKET3", true },
|
||||
[167] = { "SILPH_CO_7F", "SILPHCO7F_RIVAL", true },
|
||||
[168] = { "SILPH_CO_7F", "SILPHCO7F_CALCIUM", true },
|
||||
[169] = { "SILPH_CO_7F", "SILPHCO7F_TM_SWORDS_DANCE", true },
|
||||
-- [170] SILPH_CO_7F (SILPHCO7F_UNUSED): placeholder entry, no object_event in this port
|
||||
[171] = { "SILPH_CO_8F", "SILPHCO8F_ROCKET1", true },
|
||||
[172] = { "SILPH_CO_8F", "SILPHCO8F_SCIENTIST", true },
|
||||
[173] = { "SILPH_CO_8F", "SILPHCO8F_ROCKET2", true },
|
||||
[174] = { "SILPH_CO_9F", "SILPHCO9F_ROCKET1", true },
|
||||
[175] = { "SILPH_CO_9F", "SILPHCO9F_SCIENTIST", true },
|
||||
[176] = { "SILPH_CO_9F", "SILPHCO9F_ROCKET2", true },
|
||||
[177] = { "SILPH_CO_10F", "SILPHCO10F_ROCKET", true },
|
||||
[178] = { "SILPH_CO_10F", "SILPHCO10F_SCIENTIST", true },
|
||||
[179] = { "SILPH_CO_10F", "SILPHCO10F_SILPH_WORKER_F", true },
|
||||
[180] = { "SILPH_CO_10F", "SILPHCO10F_TM_EARTHQUAKE", true },
|
||||
[181] = { "SILPH_CO_10F", "SILPHCO10F_RARE_CANDY", true },
|
||||
[182] = { "SILPH_CO_10F", "SILPHCO10F_CARBOS", true },
|
||||
[183] = { "SILPH_CO_11F", "SILPHCO11F_GIOVANNI", true },
|
||||
[184] = { "SILPH_CO_11F", "SILPHCO11F_ROCKET1", true },
|
||||
[185] = { "SILPH_CO_11F", "SILPHCO11F_ROCKET2", true },
|
||||
-- [186] UNUSED_MAP_F4 ($02): placeholder entry, no object_event in this port
|
||||
[187] = { "POKEMON_MANSION_2F", "POKEMONMANSION2F_CALCIUM", true },
|
||||
[188] = { "POKEMON_MANSION_3F", "POKEMONMANSION3F_MAX_POTION", true },
|
||||
[189] = { "POKEMON_MANSION_3F", "POKEMONMANSION3F_IRON", true },
|
||||
[190] = { "POKEMON_MANSION_B1F", "POKEMONMANSIONB1F_RARE_CANDY", true },
|
||||
[191] = { "POKEMON_MANSION_B1F", "POKEMONMANSIONB1F_FULL_RESTORE", true },
|
||||
[192] = { "POKEMON_MANSION_B1F", "POKEMONMANSIONB1F_TM_BLIZZARD", true },
|
||||
[193] = { "POKEMON_MANSION_B1F", "POKEMONMANSIONB1F_TM_SOLARBEAM", true },
|
||||
[194] = { "POKEMON_MANSION_B1F", "POKEMONMANSIONB1F_SECRET_KEY", true },
|
||||
[195] = { "SAFARI_ZONE_EAST", "SAFARIZONEEAST_FULL_RESTORE", true },
|
||||
[196] = { "SAFARI_ZONE_EAST", "SAFARIZONEEAST_MAX_RESTORE", true },
|
||||
[197] = { "SAFARI_ZONE_EAST", "SAFARIZONEEAST_CARBOS", true },
|
||||
[198] = { "SAFARI_ZONE_EAST", "SAFARIZONEEAST_TM_EGG_BOMB", true },
|
||||
[199] = { "SAFARI_ZONE_NORTH", "SAFARIZONENORTH_PROTEIN", true },
|
||||
[200] = { "SAFARI_ZONE_NORTH", "SAFARIZONENORTH_TM_SKULL_BASH", true },
|
||||
[201] = { "SAFARI_ZONE_WEST", "SAFARIZONEWEST_MAX_POTION", true },
|
||||
[202] = { "SAFARI_ZONE_WEST", "SAFARIZONEWEST_TM_DOUBLE_TEAM", true },
|
||||
[203] = { "SAFARI_ZONE_WEST", "SAFARIZONEWEST_MAX_REVIVE", true },
|
||||
[204] = { "SAFARI_ZONE_WEST", "SAFARIZONEWEST_GOLD_TEETH", true },
|
||||
[205] = { "SAFARI_ZONE_CENTER", "SAFARIZONECENTER_NUGGET", true },
|
||||
[206] = { "CERULEAN_CAVE_2F", "CERULEANCAVE2F_PP_UP", true },
|
||||
[207] = { "CERULEAN_CAVE_2F", "CERULEANCAVE2F_ULTRA_BALL", true },
|
||||
[208] = { "CERULEAN_CAVE_2F", "CERULEANCAVE2F_FULL_RESTORE", true },
|
||||
[209] = { "CERULEAN_CAVE_B1F", "CERULEANCAVEB1F_MEWTWO", true },
|
||||
[210] = { "CERULEAN_CAVE_B1F", "CERULEANCAVEB1F_ULTRA_BALL", true },
|
||||
[211] = { "CERULEAN_CAVE_B1F", "CERULEANCAVEB1F_MAX_REVIVE", true },
|
||||
[212] = { "VICTORY_ROAD_1F", "VICTORYROAD1F_TM_SKY_ATTACK", true },
|
||||
[213] = { "VICTORY_ROAD_1F", "VICTORYROAD1F_RARE_CANDY", true },
|
||||
[214] = { "CHAMPIONS_ROOM", "CHAMPIONSROOM_OAK", false },
|
||||
[215] = { "SEAFOAM_ISLANDS_1F", "SEAFOAMISLANDS1F_BOULDER1", true },
|
||||
[216] = { "SEAFOAM_ISLANDS_1F", "SEAFOAMISLANDS1F_BOULDER2", true },
|
||||
[217] = { "SEAFOAM_ISLANDS_B1F", "SEAFOAMISLANDSB1F_BOULDER1", false },
|
||||
[218] = { "SEAFOAM_ISLANDS_B1F", "SEAFOAMISLANDSB1F_BOULDER2", false },
|
||||
[219] = { "SEAFOAM_ISLANDS_B2F", "SEAFOAMISLANDSB2F_BOULDER1", false },
|
||||
[220] = { "SEAFOAM_ISLANDS_B2F", "SEAFOAMISLANDSB2F_BOULDER2", false },
|
||||
[221] = { "SEAFOAM_ISLANDS_B3F", "SEAFOAMISLANDSB3F_BOULDER2", true },
|
||||
[222] = { "SEAFOAM_ISLANDS_B3F", "SEAFOAMISLANDSB3F_BOULDER3", true },
|
||||
[223] = { "SEAFOAM_ISLANDS_B3F", "SEAFOAMISLANDSB3F_BOULDER5", false },
|
||||
[224] = { "SEAFOAM_ISLANDS_B3F", "SEAFOAMISLANDSB3F_BOULDER6", false },
|
||||
[225] = { "SEAFOAM_ISLANDS_B4F", "SEAFOAMISLANDSB4F_BOULDER1", false },
|
||||
[226] = { "SEAFOAM_ISLANDS_B4F", "SEAFOAMISLANDSB4F_BOULDER2", false },
|
||||
[227] = { "SEAFOAM_ISLANDS_B4F", "SEAFOAMISLANDSB4F_ARTICUNO", true },
|
||||
},
|
||||
}
|
||||
@@ -68,7 +68,14 @@ function TitleState:sgbPalettes(game)
|
||||
local top = game.stack and game.stack:top()
|
||||
local box = top and top.titleUiBox
|
||||
if box then
|
||||
z[#z + 1] = P.trueColorZone(box[1], box[2], box[3], box[4])
|
||||
-- A DMG-grays zone, not the trueColor opt-out: through the shade-remap
|
||||
-- shader GRAYS is the identity for the box's four shades, so SGB /
|
||||
-- ADVANCED / OG modes keep #133's white paper and black ink exactly,
|
||||
-- while effectiveColors still substitutes the mono and inverted display
|
||||
-- modes -- a trueColor rect skipped the shader entirely, leaving the
|
||||
-- main menu and CONTINUE info box a raw white hole over a CLASSIC
|
||||
-- pea-green title instead of matching it like the START menu does (#870).
|
||||
z[#z + 1] = P.zone(P.GRAYS, box[1], box[2], box[3], box[4])
|
||||
end
|
||||
return z[3] and z or nil
|
||||
end
|
||||
|
||||
@@ -234,6 +234,11 @@ local function launchDownload(url, partAbs, doneAbs)
|
||||
local batRel = "updates/dl.bat"
|
||||
love.filesystem.write(batRel,
|
||||
"@echo off\r\n"
|
||||
-- start /b hands the child our cwd, the install folder, and the
|
||||
-- detached cmd.exe held that folder un-movable for the rest of the
|
||||
-- transfer after the game exited (#727). Every path below is
|
||||
-- absolute, so park the child in its own directory (the save dir).
|
||||
.. "cd /d \"%~dp0\"\r\n"
|
||||
.. "curl -fsSL --connect-timeout 15 --max-time 900 -o \""
|
||||
.. partAbs .. "\" \"" .. url .. "\"\r\n"
|
||||
.. "type nul > \"" .. doneAbs .. "\"\r\n")
|
||||
@@ -271,6 +276,14 @@ local function doDownload()
|
||||
-- stalled or run-away transfer breaks out and lets verification fail cleanly
|
||||
local waited, lastSize, lastChange = 0, -1, 0
|
||||
while true do
|
||||
-- A queued quit means the window already closed. Bail so the join in
|
||||
-- Check.shutdown does not hold the dead window's process (and, on
|
||||
-- Windows, its folder) open for up to the whole transfer (#727). The
|
||||
-- quit stays on the channel for the command loop; the detached curl
|
||||
-- times out on its own and the next launch's doCheck verifies and
|
||||
-- re-offers whatever landed.
|
||||
local peeked = cmdCh:peek()
|
||||
if type(peeked) == "table" and peeked.cmd == "quit" then return end
|
||||
if love.filesystem.getInfo(doneRel) then break end
|
||||
local pinfo = love.filesystem.getInfo(partRel)
|
||||
local cur = (pinfo and pinfo.size) or 0
|
||||
|
||||
@@ -440,8 +440,10 @@ function OverworldState:setMap(mapId, x, y, facing, opts)
|
||||
self.entities = { self.player }
|
||||
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, opts)
|
||||
-- self.entities: it does not block movement, pikachu_follow.asm).
|
||||
-- true = fresh map entry: the follower spawns under the player and
|
||||
-- walks out of the warp, not beside him (#863)
|
||||
require("src.world.PikachuFollower").onMapEntered(Game, self, opts, true)
|
||||
|
||||
-- opts.keepMusic: the Oak-escort warp keeps MUSIC_MEET_PROF_OAK
|
||||
-- playing into the lab (BIT_NO_MAP_MUSIC in wStatusFlags7);
|
||||
@@ -1925,7 +1927,14 @@ function OverworldState:tryHiddenObject(fx, fy)
|
||||
save.hiddenTaken = save.hiddenTaken or {}
|
||||
if save.hiddenTaken[key] then return false end
|
||||
if not require("src.inventory.Bag").add(save, h.item, 1, Game.data) then
|
||||
Game.stack:push(TextBox.new(Game, romText(Game.data, "_CantCarryMoreText", "You can't carry\nany more items!")))
|
||||
-- hidden_items.asm FoundHiddenItemText: the find is announced first,
|
||||
-- then GiveItem's .bagFull branch prints _HiddenItemBagFullText and
|
||||
-- leaves the spot unfound; _CantCarryMoreText is the Toss line (#872)
|
||||
local name = Game.data.items[h.item] and Game.data.items[h.item].name or h.item
|
||||
Game.stack:push(TextBox.new(Game,
|
||||
Strings("%s found\n%s!", save.player.name, name) .. "\f"
|
||||
.. romText(Game.data, "_HiddenItemBagFullText",
|
||||
"But, {PLAYER} has\nno more room for\vother items!")))
|
||||
return true
|
||||
end
|
||||
save.hiddenTaken[key] = true
|
||||
@@ -2570,7 +2579,17 @@ function OverworldState:talkTo(npc)
|
||||
-- the string "0" as truthy, so screen it out and fall through to text.
|
||||
if d.item and d.item ~= "0" and d.item ~= 0 then
|
||||
if not require("src.inventory.Bag").add(Game.save, d.item, 1, Game.data) then
|
||||
Game.stack:push(TextBox.new(Game, romText(Game.data, "_CantCarryMoreText", "You can't carry\nany more items!")))
|
||||
-- pick_up_item.asm .BagFull prints _NoMoreRoomForItemText, not the
|
||||
-- Toss-screen _CantCarryMoreText; Yellow announces the find first,
|
||||
-- then the refusal (#872)
|
||||
local noRoom = romText(Game.data, "_NoMoreRoomForItemText",
|
||||
"No more room for\nitems!")
|
||||
if GameVersion.isYellow() then
|
||||
local name = Game.data.items[d.item] and Game.data.items[d.item].name or d.item
|
||||
noRoom = Strings("%s found\n%s!", Game.save.player.name, name)
|
||||
.. "\f" .. noRoom
|
||||
end
|
||||
Game.stack:push(TextBox.new(Game, noRoom))
|
||||
return
|
||||
end
|
||||
Game.save.itemsTaken = Game.save.itemsTaken or {}
|
||||
@@ -2984,7 +3003,12 @@ local function meetTrainerTheme(cls)
|
||||
end
|
||||
|
||||
-- Run the pre-battle text -> battle -> won text -> flags sequence.
|
||||
function OverworldState:engageTrainer(npc, onDone, endBattleText)
|
||||
-- skipBattleText is for map scripts shaped like SilphCo11FDefaultScript
|
||||
-- (scripts/SilphCo11F.asm), which DisplayTextID the challenge line BEFORE
|
||||
-- the approach walk and then EngageMapTrainer with no further text: the
|
||||
-- caller already showed the box, so the battle starts without a second
|
||||
-- one (#869).
|
||||
function OverworldState:engageTrainer(npc, onDone, endBattleText, skipBattleText)
|
||||
local d = npc.def
|
||||
Runtime.emit("world.trainer_engaged", { npc = npc, trainerClass = d.trainerClass,
|
||||
partyIndex = d.trainerParty })
|
||||
@@ -3005,7 +3029,7 @@ function OverworldState:engageTrainer(npc, onDone, endBattleText)
|
||||
or (header and header.won and Game.data.text[header.won])
|
||||
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
Game.stack:push(TextBox.new(Game, battleText, function()
|
||||
local function startBattle()
|
||||
-- TalkToTrainer (home/trainers.asm:88) prints the before-battle text
|
||||
-- FIRST and only then runs `call EngageMapTrainer` / `jp
|
||||
-- StartTrainerBattle`, so a trainer challenged on foot gets the sting
|
||||
@@ -3048,7 +3072,12 @@ function OverworldState:engageTrainer(npc, onDone, endBattleText)
|
||||
end
|
||||
end
|
||||
self:pushBattle(battle)
|
||||
end))
|
||||
end
|
||||
if skipBattleText then
|
||||
startBattle()
|
||||
else
|
||||
Game.stack:push(TextBox.new(Game, battleText, startBattle))
|
||||
end
|
||||
end
|
||||
|
||||
-- Shared GiveItem step for the victory rewards (pokered home/give.asm):
|
||||
@@ -4470,7 +4499,13 @@ function OverworldState:drawWorld()
|
||||
-- ghost NPCs on neighbor maps, y-sorted among themselves
|
||||
table.sort(self.ghosts,
|
||||
function(a, b) return a.npc.py + a.oy < b.npc.py + b.oy end)
|
||||
table.sort(self.entities, function(a, b) return a.py < b.py end)
|
||||
table.sort(self.entities, function(a, b)
|
||||
if a.py ~= b.py then return a.py < b.py end
|
||||
-- a fresh warp spawn parks the follower on the player's own cell
|
||||
-- until it trails out; the tie must draw it under him, never on
|
||||
-- top (#863)
|
||||
return a.pikachuFollower == true and b.pikachuFollower ~= true
|
||||
end)
|
||||
|
||||
-- === shared FX draw bodies ==========================================
|
||||
-- Each draws at flat world-canvas offsets; the tilt path wraps the
|
||||
|
||||
@@ -188,7 +188,7 @@ function PikachuFollower.current(ow)
|
||||
return npc
|
||||
end
|
||||
|
||||
function PikachuFollower.onMapEntered(game, ow, opts)
|
||||
function PikachuFollower.onMapEntered(game, ow, opts, viaMapLoad)
|
||||
-- Bill's House owns a short scripted scene that deliberately keeps
|
||||
-- Pikachu off the normal trailing loop. A new map instance ends it.
|
||||
ow.pikachuBillsScene = nil
|
||||
@@ -200,7 +200,8 @@ function PikachuFollower.onMapEntered(game, ow, opts)
|
||||
-- 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.
|
||||
-- under the player, the full spawn path of that same routine (the
|
||||
-- viaMapLoad spawn below, #863).
|
||||
local keep = opts and opts.keepPikachu
|
||||
if keep then
|
||||
table.insert(ow.npcs, keep)
|
||||
@@ -208,6 +209,12 @@ function PikachuFollower.onMapEntered(game, ow, opts)
|
||||
return
|
||||
end
|
||||
local x, y = spawnCell(ow)
|
||||
-- a fresh map entry (warp, boot) parks the follower ON the player's
|
||||
-- cell instead: it stays hidden under him (the draw-sort tie-break in
|
||||
-- OverworldController) and walks out of the warp behind him as the
|
||||
-- trail opens up. Mid-map respawns (bike dismount, revive) keep the
|
||||
-- behind-the-facing cell (#863)
|
||||
if viaMapLoad then x, y = ow.player.cellX, ow.player.cellY end
|
||||
local npc = makeFollower(game, ow, x, y, ow.player.facing)
|
||||
table.insert(ow.npcs, npc)
|
||||
-- entities is the draw list; passable keeps it out of collision
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
-- Manual check of both bag-full pickup refusals (#872): an item ball must
|
||||
-- refuse with _NoMoreRoomForItemText (pokered scripts/pick_up_item.asm
|
||||
-- .BagFull), never the Toss-screen _CantCarryMoreText, and a hidden item
|
||||
-- announces the find first, then _HiddenItemBagFullText (hidden_items.asm).
|
||||
-- Run without POKEPORT_SPEED -- the box paging under test is timing-honest.
|
||||
-- POKEPORT_DRIVER=tests/drivers/bag_full_pickup_bug872_test.lua POKEPORT_IDENTITY=bug872 POKEPORT_TOUCH=0 love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local Bag = require("src.inventory.Bag")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
local MAP = "VIRIDIAN_FOREST"
|
||||
|
||||
local function check(label, ok)
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
-- neither refusal plays a jingle, but the human will want the normal
|
||||
-- pickup sound as a contrast, so warn when it would be muted
|
||||
local opts = game.save.options or {}
|
||||
if (opts.sfxVol or 7) == 0 then
|
||||
U.log("note: sfxVol is 0, so a successful pickup afterwards will be",
|
||||
"silent; the refusal boxes themselves are unaffected")
|
||||
end
|
||||
|
||||
-- a real fresh game, so the save has a player name and clean flags
|
||||
U.newGame(game)
|
||||
local save = game.save
|
||||
|
||||
-- Empty the bag, then refill to exactly capacity with ids that are NOT
|
||||
-- POTION: Bag.add succeeds by quantity for an id already in a slot
|
||||
-- (src/inventory/Bag.lua), which would mask the bug, and both test
|
||||
-- targets below hand out POTION. Badges share save.inventory but are
|
||||
-- not bag slots, so they are left alone.
|
||||
for _, id in ipairs({ unpack(Bag.order(save)) }) do
|
||||
Bag.remove(save, id, save.inventory[id] or 1)
|
||||
end
|
||||
local ids = {}
|
||||
for id in pairs(game.data.items) do
|
||||
if not Bag.isBadge(id) and id ~= "POTION" then ids[#ids + 1] = id end
|
||||
end
|
||||
table.sort(ids)
|
||||
for _, id in ipairs(ids) do
|
||||
if Bag.slots(save) >= Bag.capacity(game.data) then break end
|
||||
Bag.add(save, id, 1, game.data)
|
||||
end
|
||||
check(("bag is full (%d/%d slots) and holds no POTION")
|
||||
:format(Bag.slots(save), Bag.capacity(game.data)),
|
||||
Bag.slots(save) >= Bag.capacity(game.data)
|
||||
and not save.inventory.POTION)
|
||||
|
||||
-- first target: the Potion item ball. pokered
|
||||
-- data/maps/objects/ViridianForest.asm:36 puts it at walk cell (12, 29)
|
||||
-- (object_event 12, 29, SPRITE_POKE_BALL ... POTION), free floor below.
|
||||
local BALL = { x = 12, y = 29 }
|
||||
U.teleport(game, MAP, BALL.x, BALL.y + 1, "left")
|
||||
U.wait(10)
|
||||
|
||||
local function isBall(n)
|
||||
return n and n.def and n.def.item and n.def.item ~= "0" and n.def.item ~= 0
|
||||
end
|
||||
local ow = game.overworld
|
||||
local ball = ow:npcAtCell(BALL.x, BALL.y)
|
||||
if not isBall(ball) then
|
||||
-- a map edit or mod moved the object: take any item ball on the map
|
||||
-- and stand on a free walkable neighbour instead
|
||||
ball = nil
|
||||
for _, n in ipairs(ow.npcs or {}) do
|
||||
if isBall(n) then ball = n break end
|
||||
end
|
||||
if ball then
|
||||
local sides = {
|
||||
{ 0, 1, "up" }, { 0, -1, "down" }, { 1, 0, "left" }, { -1, 0, "right" },
|
||||
}
|
||||
for _, s in ipairs(sides) do
|
||||
local cx, cy = ball.cellX + s[1], ball.cellY + s[2]
|
||||
if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then
|
||||
U.log(("ball not at (%d, %d); using the one at (%d, %d)")
|
||||
:format(BALL.x, BALL.y, ball.cellX, ball.cellY))
|
||||
-- teleport facing away, the tap below still does the turn
|
||||
U.teleport(game, MAP, cx, cy, s[3] == "left" and "right" or "left")
|
||||
U.wait(10)
|
||||
BALL.x, BALL.y = ball.cellX, ball.cellY
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
check("an item ball is loaded on " .. MAP, ball ~= nil)
|
||||
local ballItem = ball and ball.def.item or "POTION"
|
||||
|
||||
-- turn toward the ball ourselves (a one-frame press only turns when the
|
||||
-- player faces elsewhere, src/world/Player.lua), then trigger the talk
|
||||
ow = game.overworld
|
||||
local dx, dy = BALL.x - ow.player.cellX, BALL.y - ow.player.cellY
|
||||
local dir = (dy < 0 and "up") or (dy > 0 and "down")
|
||||
or (dx < 0 and "left") or "right"
|
||||
U.tap(game, dir)
|
||||
U.wait(10)
|
||||
local fx, fy = game.overworld.player:facingCell()
|
||||
check("player turned to face the ball",
|
||||
game.overworld:npcAtCell(fx, fy) == ball)
|
||||
U.tap(game, "a")
|
||||
U.wait(30)
|
||||
|
||||
local function readPages()
|
||||
local top = game.stack:top()
|
||||
if getmetatable(top) ~= TextBox then return nil end
|
||||
local pages = {}
|
||||
for _, page in ipairs(top.pages or {}) do
|
||||
pages[#pages + 1] = table.concat(page, " / ")
|
||||
end
|
||||
return pages
|
||||
end
|
||||
local function closeBox()
|
||||
for _ = 1, 8 do
|
||||
if getmetatable(game.stack:top()) ~= TextBox then break end
|
||||
U.tap(game, "a")
|
||||
U.wait(25)
|
||||
end
|
||||
end
|
||||
|
||||
local pages = readPages()
|
||||
check("A on the full-bag ball opened a text box", pages ~= nil)
|
||||
if pages then
|
||||
local all = table.concat(pages, " || ")
|
||||
U.log("ball refusal reads:", all)
|
||||
check("it is the pickup refusal, not the Toss line",
|
||||
all:find("No more room", 1, true) ~= nil
|
||||
and all:find("can't carry", 1, true) == nil)
|
||||
if GameVersion.isYellow() then
|
||||
check("Yellow announces the find, then refuses on page 2",
|
||||
#pages == 2
|
||||
and pages[1]:find(ballItem, 1, true) ~= nil
|
||||
and pages[2]:find("No more room", 1, true) ~= nil)
|
||||
else
|
||||
check("Red/Blue refuse in one page with no found line",
|
||||
#pages == 1 and pages[1]:find("found", 1, true) == nil)
|
||||
end
|
||||
U.shot(game, SHOT_DIR .. "/bug872_ball_refusal.png")
|
||||
end
|
||||
closeBox()
|
||||
|
||||
-- the refusal must leave the world untouched so the pickup can be
|
||||
-- retried after tossing something
|
||||
check("the ball is still standing there",
|
||||
game.overworld:npcAtCell(BALL.x, BALL.y) == ball)
|
||||
check("itemsTaken was not marked",
|
||||
not (save.itemsTaken and ball and save.itemsTaken[ball.id]))
|
||||
check("the item stayed out of the bag", not save.inventory[ballItem])
|
||||
|
||||
-- second target: the hidden POTION. pokered
|
||||
-- data/events/hidden_item_coords.asm:8 puts it at (x=1, y=18) on
|
||||
-- VIRIDIAN_FOREST; Game.data.field.hiddenItems carries the same spot.
|
||||
local hidden
|
||||
local list = (game.data.field.hiddenItems or {})[MAP] or {}
|
||||
for _, h in ipairs(list) do
|
||||
if h.x == 1 and h.y == 18 then hidden = h break end
|
||||
end
|
||||
hidden = hidden or list[1]
|
||||
check("a hidden item exists on " .. MAP, hidden ~= nil)
|
||||
|
||||
local stood = false
|
||||
if hidden then
|
||||
-- {dx, dy, facing} from the hidden cell to a stand cell that looks
|
||||
-- back at it; the spot itself is usually an unwalkable tree tile
|
||||
local sides = {
|
||||
{ 0, 1, "up" }, { 0, -1, "down" }, { 1, 0, "left" }, { -1, 0, "right" },
|
||||
}
|
||||
for _, s in ipairs(sides) do
|
||||
local cx, cy = hidden.x + s[1], hidden.y + s[2]
|
||||
local m = game.overworld.map
|
||||
if m:isWalkableCell(cx, cy) and not game.overworld:npcAtCell(cx, cy) then
|
||||
U.teleport(game, MAP, cx, cy, s[3] == "left" and "right" or "left")
|
||||
U.wait(10)
|
||||
U.tap(game, s[3])
|
||||
U.wait(10)
|
||||
local hfx, hfy = game.overworld.player:facingCell()
|
||||
if hfx == hidden.x and hfy == hidden.y then stood = true break end
|
||||
end
|
||||
end
|
||||
end
|
||||
check("standing against the hidden spot", stood)
|
||||
|
||||
U.tap(game, "a")
|
||||
U.wait(30)
|
||||
pages = readPages()
|
||||
check("A on the full-bag hidden spot opened a text box", pages ~= nil)
|
||||
if pages then
|
||||
local all = table.concat(pages, " || ")
|
||||
U.log("hidden refusal reads:", all)
|
||||
check("the found line comes first",
|
||||
#pages == 2 and pages[1]:find("found", 1, true) ~= nil
|
||||
and (not hidden or pages[1]:find(
|
||||
(game.data.items[hidden.item] or {}).name or hidden.item,
|
||||
1, true) ~= nil))
|
||||
check("then the hidden-item bag-full line, not the Toss line",
|
||||
#pages == 2
|
||||
and pages[2]:find("no more room", 1, true) ~= nil
|
||||
and pages[2]:find("other items", 1, true) ~= nil
|
||||
and all:find("can't carry", 1, true) == nil)
|
||||
U.shot(game, SHOT_DIR .. "/bug872_hidden_refusal.png")
|
||||
end
|
||||
closeBox()
|
||||
|
||||
local key = hidden and (MAP .. "_" .. hidden.x .. "_" .. hidden.y)
|
||||
check("the hidden spot can still be prompted again",
|
||||
not (key and save.hiddenTaken and save.hiddenTaken[key]))
|
||||
check("the hidden item stayed out of the bag",
|
||||
not (hidden and save.inventory[hidden.item]))
|
||||
|
||||
U.log("You are still facing the hidden spot with a full bag; pressing A")
|
||||
U.log("should say the item was found, then that there is no more room for")
|
||||
U.log("other items, and never the bag screen's \"can't carry\" wording.")
|
||||
U.log("Toss something and both spots should hand their item over normally.")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,296 @@
|
||||
-- Eye check: FAITHFUL RATIO's mobile scale lock keeps the display outside the
|
||||
-- locked 160x144 viewport black through the pre-battle flash (pokered
|
||||
-- BattleTransition_FlashScreen_, engine/battle/battle_transitions.asm), the
|
||||
-- post-battle fade (GBFadeInFromWhite, home/fade.asm) and Oak speech (#864).
|
||||
-- POKEPORT_DRIVER=tests/drivers/faithful_res_mobile_veil_bug864_test.lua POKEPORT_FORCE_MOBILE=1 POKEPORT_IDENTITY=bug864 POKEPORT_TOUCH=0 POKEPORT_VERSION=red SHOT_DIR=/tmp/shots love .
|
||||
-- No POKEPORT_SPEED anywhere: the flash and the fade ARE the frames under test.
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
local Renderer = require("src.render.Renderer")
|
||||
local FaithfulRes = require("src.core.FaithfulRes")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
|
||||
local function check(label, ok)
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
-- FaithfulRes.isMobile reads the env per call, so the desktop build only
|
||||
-- takes the scale-cap branch when the launcher command set it. Without it
|
||||
-- every check below tests the ordinary letterbox and proves nothing.
|
||||
if not check("POKEPORT_FORCE_MOBILE=1 is set (the branch under test)",
|
||||
os.getenv("POKEPORT_FORCE_MOBILE") == "1") then
|
||||
U.log("Re-run with POKEPORT_FORCE_MOBILE=1; nothing below is meaningful.")
|
||||
while true do coroutine.yield() end
|
||||
end
|
||||
|
||||
-- A phone-shaped window, so the locked viewport (160x144 at the largest
|
||||
-- whole multiple, here 3x = 480x432) leaves tall bars above and below --
|
||||
-- the "dead display" FaithfulRes.lua's contract says must stay black.
|
||||
-- 480x960 keeps the width an exact 3x so the bars are purely vertical.
|
||||
if love.window and love.window.setMode then
|
||||
love.window.setMode(480, 960, { resizable = true,
|
||||
minwidth = FaithfulRes.MIN_W,
|
||||
minheight = FaithfulRes.MIN_H })
|
||||
end
|
||||
U.wait(3)
|
||||
|
||||
-- New Game replaces game.save (and Game:applyOptions re-applies its fresh
|
||||
-- options, which releases the lock), so re-arm before every shot rather
|
||||
-- than trusting one application to survive the whole run.
|
||||
local function lock()
|
||||
game.save.options = game.save.options or {}
|
||||
game.save.options.faithfulRes = 1
|
||||
game.save.options.battleBg = "black"
|
||||
FaithfulRes.applyOptions(game.save.options)
|
||||
return FaithfulRes.scaleCap()
|
||||
end
|
||||
check("the mobile scale lock engaged (FaithfulRes.scaleCap ~= nil)",
|
||||
lock() ~= nil)
|
||||
|
||||
local opts = game.save.options
|
||||
if (opts.musicVol or 0) == 0 or (opts.sfxVol or 0) == 0 then
|
||||
U.log("WARN music/sfx volume is zero; the flash's battle theme will be silent")
|
||||
end
|
||||
|
||||
-- Load a captured PNG back as ImageData; love.image cannot read absolute
|
||||
-- paths, so go through io.open + newFileData.
|
||||
local function loadShot(path)
|
||||
local f = io.open(path, "rb")
|
||||
if not f then return nil end
|
||||
local bytes = f:read("*a")
|
||||
f:close()
|
||||
local ok, img = pcall(function()
|
||||
return love.image.newImageData(
|
||||
love.filesystem.newFileData(bytes, "shot.png"))
|
||||
end)
|
||||
return ok and img or nil
|
||||
end
|
||||
|
||||
local function regionMean(img, x, y, w, h)
|
||||
local sum, n = 0, 0
|
||||
local x2 = math.min(x + w, img:getWidth()) - 1
|
||||
local y2 = math.min(y + h, img:getHeight()) - 1
|
||||
for yy = math.max(y, 0), y2 do
|
||||
for xx = math.max(x, 0), x2 do
|
||||
local r, g, b = img:getPixel(xx, yy)
|
||||
sum = sum + (r + g + b) / 3
|
||||
n = n + 1
|
||||
end
|
||||
end
|
||||
return n > 0 and sum / n or 0, n
|
||||
end
|
||||
|
||||
-- The locked viewport in framebuffer pixels: the same uiSize * fitScale
|
||||
-- centring endFrame uses for ox/oy/vpw/vph, which is the rectangle the
|
||||
-- veil is clamped to under the lock. Screenshots are framebuffer-sized,
|
||||
-- so no dpi divide.
|
||||
local function viewBox(img)
|
||||
local pw, ph = img:getWidth(), img:getHeight()
|
||||
local uiw, uih = Renderer:uiSize()
|
||||
local S = Renderer:fitScale()
|
||||
local bw, bh = uiw * S, uih * S
|
||||
return math.floor((pw - bw) / 2), math.floor((ph - bh) / 2), bw, bh, pw, ph
|
||||
end
|
||||
|
||||
-- Mean over every bar strip the window has (top/bottom always here,
|
||||
-- left/right only if the width is not an exact multiple). Inset by 2px so
|
||||
-- the viewport's own edge pixels cannot bleed into the bar sample.
|
||||
local function barMean(img)
|
||||
local bx, by, bw, bh, pw, ph = viewBox(img)
|
||||
local sum, n = 0, 0
|
||||
local function add(x, y, w, h)
|
||||
if w < 1 or h < 1 then return end
|
||||
local m, c = regionMean(img, x, y, w, h)
|
||||
sum, n = sum + m * c, n + c
|
||||
end
|
||||
if by >= 8 then
|
||||
add(0, 0, pw, by - 2)
|
||||
add(0, by + bh + 2, pw, ph - (by + bh) - 2)
|
||||
end
|
||||
if bx >= 8 then
|
||||
add(0, by, bx - 2, bh)
|
||||
add(bx + bw + 2, by, bx - 2, bh)
|
||||
end
|
||||
return n > 0 and sum / n or -1, n
|
||||
end
|
||||
|
||||
local function innerMean(img)
|
||||
local bx, by, bw, bh = viewBox(img)
|
||||
return regionMean(img, bx + math.floor(bw / 4), by + math.floor(bh / 4),
|
||||
math.floor(bw / 2), math.floor(bh / 2))
|
||||
end
|
||||
|
||||
-- shot + the two-sided assertion every moment shares: bars dead black,
|
||||
-- viewport interior at least `bright` (the effect visibly inside the frame)
|
||||
local function shotAndCheck(name, bright)
|
||||
check("lock still held at " .. name, lock() ~= nil)
|
||||
local path = DIR .. "/bug864_" .. name .. ".png"
|
||||
U.shot(game, path)
|
||||
local img = loadShot(path)
|
||||
if not check(name .. " shot decoded", img ~= nil) then return nil end
|
||||
local bars, n = barMean(img)
|
||||
local inner = innerMean(img)
|
||||
U.log((" %s: bar mean %.3f over %d px, viewport interior %.3f")
|
||||
:format(name, bars, n, inner))
|
||||
check(name .. ": window has bars to sample", n > 0)
|
||||
check(name .. ": bars stay dead black (#864)", n > 0 and bars < 0.05)
|
||||
check(name .. ": the effect still lights the viewport", inner > bright)
|
||||
return img
|
||||
end
|
||||
|
||||
-- ---- (3rd symptom first: it is where a boot starts) New Game -----------
|
||||
-- OakSpeech sets letterboxWhite; before #864 that painted the WHOLE phone
|
||||
-- paper white, leaving the locked frame indistinguishable from its bars.
|
||||
U.wait(5)
|
||||
U.tap(game, "start") -- skip intro movie
|
||||
U.wait(10)
|
||||
U.tap(game, "a") -- title -> menu
|
||||
U.wait(5)
|
||||
U.tap(game, "a") -- NEW GAME (POKEPORT_IDENTITY=bug864 has no save)
|
||||
local oak
|
||||
for _ = 1, 300 do
|
||||
for i = #game.stack.states, 1, -1 do
|
||||
local s = game.stack.states[i]
|
||||
if s and s.letterboxWhite then oak = s break end
|
||||
end
|
||||
if oak then break end
|
||||
U.tap(game, "a")
|
||||
U.wait(2)
|
||||
end
|
||||
check("Oak speech reached (a letterboxWhite state is on the stack)",
|
||||
oak ~= nil)
|
||||
U.wait(40) -- let Oak's pic and a line of text land inside the frame
|
||||
shotAndCheck("oakspeech", 0.5)
|
||||
|
||||
-- mash through the rest of the speech into the overworld; the naming
|
||||
-- screens and the closing shrink-away beat (~103 unskippable frames) eat
|
||||
-- most of this, so the headroom is generous on purpose
|
||||
for _ = 1, 900 do
|
||||
U.tap(game, "a")
|
||||
U.wait(2)
|
||||
if game.overworld and game.stack:top() == game.overworld then break end
|
||||
end
|
||||
check("New Game landed in the overworld",
|
||||
game.overworld ~= nil and game.stack:top() == game.overworld)
|
||||
|
||||
-- ---- the pre-battle flash ----------------------------------------------
|
||||
-- pokered data/maps/objects/Route1.asm puts its youngsters at (5,24) and
|
||||
-- (15,13) and the sign at (9,27), so the top of the road is empty; the
|
||||
-- battle is pushed straight in, the cell is only somewhere to stand.
|
||||
local MAP = "ROUTE_1"
|
||||
local STAND = { x = 5, y = 6, facing = "down" }
|
||||
|
||||
game.save.party = { Pokemon.new(game.data, "BULBASAUR", 12) }
|
||||
U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing)
|
||||
U.wait(10)
|
||||
local ow = game.overworld
|
||||
check("the overworld is up on " .. MAP, ow ~= nil and ow.map.id == MAP)
|
||||
if ow and not ow.map:isWalkableCell(STAND.x, STAND.y) then
|
||||
-- a map edit moved the road: any free neighbour serves, the cell is not
|
||||
-- itself under test
|
||||
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(("stand cell (%d, %d) blocked, using (%d, %d)")
|
||||
:format(STAND.x, STAND.y, cx, cy))
|
||||
U.teleport(game, MAP, cx, cy, STAND.facing)
|
||||
U.wait(10)
|
||||
ow = game.overworld
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
U.shot(game, DIR .. "/bug864_route1_base.png") -- unlit reference frame
|
||||
lock()
|
||||
|
||||
-- wild, weaker (L5 vs the L12 lead), not a dungeon map: the 3-bit select
|
||||
-- (battle_transitions.asm) lands on %000 doublecircle, one of the two
|
||||
-- wipes that call BattleTransition_FlashScreen first
|
||||
local battle = BattleState.newWild(game, "RATTATA", 5)
|
||||
ow:pushBattle(battle)
|
||||
local trans = game.stack:top()
|
||||
check("the transition is a flashing wipe (doublecircle)",
|
||||
trans ~= nil and trans.def ~= nil and trans.def.flash == true)
|
||||
|
||||
-- screenVeil is written during draw and cleared at beginFrame, so at
|
||||
-- update time it holds the LAST rendered frame's veil; catch the white
|
||||
-- peak (shade 1, near-full alpha) and shoot the very next frames while
|
||||
-- the 2-frame palette holds keep it bright
|
||||
local caught = false
|
||||
for _ = 1, 400 do
|
||||
local v = game.renderer and game.renderer.screenVeil
|
||||
if v and v[1] == 1 and v[2] >= 0.9 then caught = true break end
|
||||
U.wait(1)
|
||||
end
|
||||
check("caught the flash at its white peak", caught)
|
||||
local flashImg = shotAndCheck("flash", 0.5)
|
||||
local baseImg = loadShot(DIR .. "/bug864_route1_base.png")
|
||||
if flashImg and baseImg then
|
||||
local lit, plain = innerMean(flashImg), innerMean(baseImg)
|
||||
U.log((" viewport interior %.3f unlit -> %.3f mid-flash")
|
||||
:format(plain, lit))
|
||||
check("the flash visibly brightens the viewport over the base frame",
|
||||
lit > plain + 0.15)
|
||||
end
|
||||
|
||||
-- ---- the post-battle fade in from white --------------------------------
|
||||
for _ = 1, 600 do
|
||||
if game.stack:top() == battle then break end
|
||||
U.wait(1)
|
||||
end
|
||||
check("the battle reached the screen", game.stack:top() == battle)
|
||||
for _ = 1, 200 do
|
||||
if battle.phase == "menu" then break end
|
||||
U.tap(game, "a")
|
||||
U.wait(6)
|
||||
end
|
||||
check("the battle reached its FIGHT/PKMN/ITEM/RUN menu",
|
||||
battle.phase == "menu")
|
||||
|
||||
-- run away (down+right lands on RUN from anywhere in the 2x2 grid; the
|
||||
-- L12 lead outspeeds the L5 wild mon, so the escape always succeeds),
|
||||
-- then watch for BattleReturn's white veil -- battle over, shade 1,
|
||||
-- alpha 1 through its hold frames
|
||||
local sawReturn = false
|
||||
for _ = 1, 1500 do
|
||||
local top = game.stack:top()
|
||||
local v = game.renderer and game.renderer.screenVeil
|
||||
if top ~= battle and v and v[1] == 1 and v[2] >= 0.9 then
|
||||
sawReturn = true
|
||||
break
|
||||
end
|
||||
if top == battle then
|
||||
if battle.phase == "menu" then
|
||||
U.tap(game, "down")
|
||||
U.wait(1)
|
||||
U.tap(game, "right")
|
||||
U.wait(1)
|
||||
U.tap(game, "a")
|
||||
U.wait(2)
|
||||
else
|
||||
U.tap(game, "a")
|
||||
U.wait(3)
|
||||
end
|
||||
else
|
||||
U.wait(1)
|
||||
end
|
||||
end
|
||||
check("caught the post-battle fade in from white", sawReturn)
|
||||
shotAndCheck("return", 0.8)
|
||||
|
||||
-- ---- over to you --------------------------------------------------------
|
||||
U.log("You are back on Route 1 with the picture locked to a 480x432 frame in")
|
||||
U.log("the middle of a tall window. Open the three shots in " .. DIR .. ":")
|
||||
U.log("bug864_oakspeech, bug864_flash and bug864_return should each show a lit")
|
||||
U.log("frame -- paper, white flash, white fade -- with dead-black bars above")
|
||||
U.log("and below. Before #864 the white spilled over the whole window and the")
|
||||
U.log("frame had no edge at all. Walking into grass here replays the flash live.")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,224 @@
|
||||
-- Giovanni's Silph Co 11F coordinate trigger speaks BEFORE he walks (#869).
|
||||
-- pokered scripts/SilphCo11F.asm SilphCo11FDefaultScript: DisplayTextID
|
||||
-- TEXT_SILPHCO11F_GIOVANNI first, then MoveSprite .GiovanniMovement (3x down)
|
||||
-- and EngageMapTrainer with no second box. Do not set POKEPORT_SPEED: the
|
||||
-- box-vs-walk ordering is exactly the moment under test.
|
||||
-- POKEPORT_DRIVER=tests/drivers/giovanni_silph11f_bug869_test.lua POKEPORT_IDENTITY=bug869 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local BattleTransition = require("src.render.BattleTransition")
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
|
||||
-- Positions from ../pokered/data/maps/objects/SilphCo11F.asm: Giovanni
|
||||
-- object_event (6, 9), SILPHCO11F_ROCKET1 (3, 16). Trigger tiles from
|
||||
-- ../pokered/scripts/SilphCo11F.asm .PlayerCoordsArray: (6, 13) and
|
||||
-- (7, 12). data/generated/maps.lua stores the same cells 1:1.
|
||||
local MAP = "SILPH_CO_11F"
|
||||
local GIO_HOME = { x = 6, y = 9 }
|
||||
local GIO_STOP = { x = 6, y = 12 } -- home + 3x NPC_MOVEMENT_DOWN
|
||||
|
||||
local failed = 0
|
||||
local function check(label, ok)
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
if not ok then failed = failed + 1 end
|
||||
return ok
|
||||
end
|
||||
|
||||
-- party strong enough that the human can win the OPP_GIOVANNI#2 fight
|
||||
-- and watch the unchanged aftermath (victories.lua "Arrgh!!", then the
|
||||
-- "Blast it all!" speech and the rockets leaving)
|
||||
game.save.party = {
|
||||
Pokemon.new(game.data, "MEWTWO", 80),
|
||||
Pokemon.new(game.data, "SNORLAX", 77),
|
||||
Pokemon.new(game.data, "CHARIZARD", 70),
|
||||
}
|
||||
game.save.player.name = "RED"
|
||||
|
||||
-- (6,13) and (7,13) sit in the card-key doorway of the boss room: block
|
||||
-- (3,6) stays the closed id 32 until EVENT_SILPH_CO_11_UNLOCKED_DOOR is
|
||||
-- set (stampClosedDoors, mirroring pokered engine/events/card_key.asm),
|
||||
-- and a closed door refuses the step this test needs. A real player has
|
||||
-- opened it before the trigger can fire, so open it here too.
|
||||
game.save.flags.EVENT_SILPH_CO_11_UNLOCKED_DOOR = true
|
||||
|
||||
check("EVENT_BEAT_SILPH_CO_GIOVANNI starts unset -- trigger is armed",
|
||||
not game.save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI)
|
||||
local opts = game.save.options or {}
|
||||
if (opts.sfxVol or 0) == 0 then
|
||||
U.log("sfxVol is 0 -- the evil-trainer sting will be inaudible")
|
||||
end
|
||||
if (opts.musicVol or 0) == 0 then
|
||||
U.log("musicVol is 0 -- the encounter sting and battle theme are muted")
|
||||
end
|
||||
|
||||
local function findNpc(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
|
||||
|
||||
local function topBox()
|
||||
local t = game.stack:top()
|
||||
if getmetatable(t) == TextBox then return t end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function boxText(box)
|
||||
local shown = {}
|
||||
for _, page in ipairs(box.pages or {}) do
|
||||
for _, line in ipairs(page) do shown[#shown + 1] = line end
|
||||
end
|
||||
return table.concat(shown, " / ")
|
||||
end
|
||||
|
||||
-- Stand next to (tx, ty) and take one real walking step onto it; onStep
|
||||
-- hooks fire on a finished step, so a bare teleport onto the tile would
|
||||
-- prove nothing. `sides` are {dx, dy, facing} in preference order, each
|
||||
-- checked for walkability so a map edit only degrades to the next side.
|
||||
local function stepOnto(tx, ty, sides)
|
||||
U.teleport(game, MAP, tx + sides[1][1], ty + sides[1][2], sides[1][3])
|
||||
U.wait(10)
|
||||
local ow = game.overworld
|
||||
for _, s in ipairs(sides) do
|
||||
local cx, cy = tx + s[1], ty + s[2]
|
||||
if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then
|
||||
if ow.player.cellX ~= cx or ow.player.cellY ~= cy then
|
||||
U.teleport(game, MAP, cx, cy, s[3])
|
||||
U.wait(10)
|
||||
end
|
||||
U.hold(game, s[3], 24)
|
||||
U.wait(10)
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- Regression on the shared engageTrainer path first: an ordinary trainer
|
||||
-- with no skipBattleText must still get its normal pre-battle box.
|
||||
-- ROCKET1 faces up, so talk to him from above.
|
||||
check("regression: reached ROCKET1's cell",
|
||||
stepOnto(3, 15, { { 0, 1, "up" }, { 0, -1, "down" },
|
||||
{ 1, 0, "left" }, { -1, 0, "right" } }))
|
||||
do
|
||||
local ow = game.overworld
|
||||
local rocket = findNpc(ow, "SILPHCO11F_ROCKET1")
|
||||
check("regression: ROCKET1 object loaded", rocket ~= nil)
|
||||
if rocket then
|
||||
-- walk down one so we face him from (3, 15)
|
||||
local fx, fy = ow.player:facingCell()
|
||||
if ow:npcAtCell(fx, fy) ~= rocket then
|
||||
-- stepOnto left us adjacent to (3, 15); face the rocket directly
|
||||
local dx = rocket.cellX - ow.player.cellX
|
||||
local dy = rocket.cellY - ow.player.cellY
|
||||
local face = (dy > 0 and "down") or (dy < 0 and "up")
|
||||
or (dx > 0 and "right") or "left"
|
||||
U.tap(game, face)
|
||||
U.wait(10)
|
||||
end
|
||||
U.tap(game, "a")
|
||||
U.wait(30)
|
||||
local box = topBox()
|
||||
check("regression: talking to ROCKET1 still opens the pre-battle box",
|
||||
box ~= nil)
|
||||
if box then
|
||||
local t = boxText(box)
|
||||
U.log("rocket box reads:", t)
|
||||
check("regression: it is his battle line (\"Stop right there!\")",
|
||||
t:find("Stop right there", 1, true) ~= nil)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Both trigger tiles must fire with Giovanni still at his desk. The
|
||||
-- (7, 12) probe is abandoned before the box is dismissed (the teleport
|
||||
-- rebuilds the map state), so the trigger re-arms for the main run --
|
||||
-- vanilla re-arms too, since only a win sets the event flag.
|
||||
do
|
||||
check("(7,12) approach: stepped onto the trigger from the right",
|
||||
stepOnto(7, 12, { { 1, 0, "left" }, { 0, 1, "up" },
|
||||
{ -1, 0, "right" } }))
|
||||
local box = topBox()
|
||||
check("(7,12) approach: intro box opened", box ~= nil)
|
||||
local gio = findNpc(game.overworld, "SILPHCO11F_GIOVANNI")
|
||||
check("(7,12) approach: Giovanni is still at his desk (6,9)",
|
||||
gio ~= nil and gio.cellX == GIO_HOME.x and gio.cellY == GIO_HOME.y)
|
||||
end
|
||||
|
||||
-- Main run, the route the issue screenshots show: (6,15) facing up, two
|
||||
-- steps onto (6,13).
|
||||
U.teleport(game, MAP, 6, 15, "up")
|
||||
U.wait(10)
|
||||
U.hold(game, "up", 24)
|
||||
U.hold(game, "up", 24)
|
||||
U.wait(15)
|
||||
if not topBox() then
|
||||
-- blocked approach fallback: one step onto (6,13) from any free side
|
||||
stepOnto(6, 13, { { 0, 1, "up" }, { -1, 0, "right" }, { 1, 0, "left" } })
|
||||
end
|
||||
|
||||
local ow = game.overworld
|
||||
local gio = findNpc(ow, "SILPHCO11F_GIOVANNI")
|
||||
check("Giovanni object loaded on " .. MAP, gio ~= nil)
|
||||
local box = topBox()
|
||||
check("stepping onto (6,13) opened a text box", box ~= nil)
|
||||
if box then
|
||||
local t = boxText(box)
|
||||
U.log("intro box reads:", t)
|
||||
check("it is the Giovanni intro (\"So we meet again!\")",
|
||||
t:find("So we meet again", 1, true) ~= nil)
|
||||
end
|
||||
check("the box opened with Giovanni STILL at his desk (6,9) -- the fix",
|
||||
gio ~= nil and gio.cellX == GIO_HOME.x and gio.cellY == GIO_HOME.y)
|
||||
U.shot(game, SHOT_DIR .. "/bug869_box_before_walk.png")
|
||||
U.wait(30)
|
||||
check("he holds the desk for the whole box, not just its first frame",
|
||||
gio ~= nil and gio.cellX == GIO_HOME.x and gio.cellY == GIO_HOME.y)
|
||||
|
||||
-- dismiss every page; the walk and the battle must follow with NO
|
||||
-- further dialogue box in between (EngageMapTrainer runs bare in the
|
||||
-- original, so engageTrainer is called with skipBattleText here)
|
||||
for _ = 1, 300 do
|
||||
if not topBox() then break end
|
||||
U.tap(game, "a")
|
||||
U.wait(5)
|
||||
end
|
||||
check("intro box dismissed", topBox() == nil)
|
||||
local sawSecondBox, battleReached = false, false
|
||||
for _ = 1, 900 do
|
||||
local t = game.stack:top()
|
||||
local mt = getmetatable(t)
|
||||
if mt == TextBox and not sawSecondBox then
|
||||
sawSecondBox = true
|
||||
U.log("unexpected box reads:", boxText(t))
|
||||
end
|
||||
if mt == BattleTransition or mt == BattleState then
|
||||
battleReached = true
|
||||
break
|
||||
end
|
||||
U.wait(1)
|
||||
end
|
||||
check("battle wipe started after the box", battleReached)
|
||||
check("no second dialogue box between the walk and the battle", not sawSecondBox)
|
||||
check("Giovanni walked the three tiles down to (6,12) first",
|
||||
gio ~= nil and gio.cellX == GIO_STOP.x and gio.cellY == GIO_STOP.y)
|
||||
|
||||
U.log(failed == 0 and "PASS all machine checks clean"
|
||||
or ("FAIL " .. failed .. " machine check(s) above"))
|
||||
|
||||
U.log("The battle wipe is running now; take the pad and win the fight.")
|
||||
U.log("Right looks like what just played: his speech opened while he was")
|
||||
U.log("behind the desk, then he walked down and the fight began straight")
|
||||
U.log("away, with the evil-trainer sting and no extra dialogue. After the")
|
||||
U.log("win you should get \"Arrgh!!\" on the battle screen, the \"Blast it")
|
||||
U.log("all!\" speech, a fade, and every rocket gone. Wrong is the old bug:")
|
||||
U.log("he crosses the room in silence and only then talks, point-blank.")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,188 @@
|
||||
-- Manual check of the ghost MAROWAK send-off on POKEMON_TOWER_6F (#867).
|
||||
-- PokemonTower6FMarowakDepartedText (pokered scripts/PokemonTower6F.asm) is
|
||||
-- two texts: the CUBONE's-mother line, then PlayCry RESTLESS_SOUL + 30 frames
|
||||
-- before the calmed line; the port showed only the calmed line and no cry.
|
||||
-- Never under POKEPORT_SPEED -- the cry-then-text beat is the thing under test.
|
||||
-- POKEPORT_DRIVER=tests/drivers/marowak_departed_bug867_test.lua POKEPORT_IDENTITY=bug867 POKEPORT_TOUCH=0 SHOT_DIR=/tmp/shots love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
|
||||
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
|
||||
|
||||
-- ---- machine checks ----------------------------------------------------
|
||||
-- Both keys come out of the stock extractor (data/generated/text.lua); a
|
||||
-- rename there would drop story3.lua onto its hand fallbacks, which still
|
||||
-- displays but is worth knowing about.
|
||||
local mother = game.data.text._PokemonTower6FGhostWasCubonesMotherText
|
||||
local calmed = game.data.text._PokemonTower6FSoulWasCalmedText
|
||||
check("_PokemonTower6FGhostWasCubonesMotherText extracted",
|
||||
type(mother) == "string" and mother ~= "")
|
||||
check("_PokemonTower6FSoulWasCalmedText extracted",
|
||||
type(calmed) == "string" and calmed ~= "")
|
||||
if type(mother) == "string" then
|
||||
U.log("first line reads:", (mother:gsub("[\n\011\012]", " / ")))
|
||||
end
|
||||
if type(calmed) == "string" then
|
||||
U.log("second line reads:", (calmed:gsub("[\n\011\012]", " / ")))
|
||||
end
|
||||
check("MAROWAK is a real species (RESTLESS_SOUL EQU MAROWAK)",
|
||||
game.data.pokemon.MAROWAK ~= nil)
|
||||
|
||||
local opts = game.save.options or {}
|
||||
U.log("sfxVol", tostring(opts.sfxVol), "musicVol", tostring(opts.musicVol))
|
||||
if opts.sfxVol == 0 then
|
||||
U.log("sfxVol is 0: raise it in OPTION or the cry cannot be judged")
|
||||
end
|
||||
|
||||
-- ---- reach the trigger -------------------------------------------------
|
||||
-- pokered scripts/PokemonTower6F.asm PokemonTower6FMarowakCoords:
|
||||
-- dbmapcoord 10, 16 (a coord array, not an object). Row 17 is solid wall
|
||||
-- and (9, 16) is the stairwell warp, so the approach is from (10, 15)
|
||||
-- facing down, same as tests/drivers/ghost_unveil_bug492_test.lua.
|
||||
local MAP = "POKEMON_TOWER_6F"
|
||||
local TRIGGER = { x = 10, y = 16 }
|
||||
local STAND = { x = 10, y = 15, facing = "down", step = "down" }
|
||||
|
||||
-- one mon, one damaging move: the A-mash below always picks FIGHT slot 1,
|
||||
-- and a stat move there stalls the run (route.lua learned this the hard way)
|
||||
local mon = Pokemon.new(game.data, "MEWTWO", 100)
|
||||
if game.data.moves.PSYCHIC_M then
|
||||
mon.moves = { { id = "PSYCHIC_M", pp = 99 } }
|
||||
end
|
||||
game.save.party = { mon }
|
||||
game.save.player.name = "RED"
|
||||
-- the scope buys the unveil so the ghost can be damaged at all (#492)
|
||||
game.save.inventory.SILPH_SCOPE = 1
|
||||
game.save.flags.EVENT_BEAT_GHOST_MAROWAK = nil
|
||||
|
||||
U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing)
|
||||
U.wait(10)
|
||||
local ow = game.overworld
|
||||
check("the overworld is up on " .. MAP, ow ~= nil and ow.map ~= nil)
|
||||
|
||||
if ow and ow.map and not ow.map:isWalkableCell(STAND.x, STAND.y) then
|
||||
-- a map edit moved the approach: stand on any walkable neighbour of the
|
||||
-- trigger that is not the stairwell warp and step back onto it.
|
||||
-- {dx, dy, facing} is the trigger-to-stand offset plus the direction
|
||||
-- that walks back onto the trigger cell.
|
||||
local sides = { { 0, -1, "down" }, { 1, 0, "left" },
|
||||
{ -1, 0, "right" }, { 0, 1, "up" } }
|
||||
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.map:warpAtCell(cx, cy) then
|
||||
U.log(("(%d, %d) is blocked, approaching from"):format(STAND.x, STAND.y),
|
||||
cx, cy, "stepping", s[3])
|
||||
STAND = { x = cx, y = cy, facing = s[3], step = s[3] }
|
||||
U.teleport(game, MAP, cx, cy, s[3])
|
||||
U.wait(10)
|
||||
ow = game.overworld
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- walk onto the trigger; MapScripts onStep fires on the completed step
|
||||
U.hold(game, STAND.step, 20)
|
||||
U.wait(20)
|
||||
check("the Be-gone text opened", game.stack:top() ~= game.overworld)
|
||||
|
||||
-- ---- win the battle ----------------------------------------------------
|
||||
local sawBattle = false
|
||||
for _ = 1, 1500 do
|
||||
-- onFinish sets the flag and queues the departed rows in the same call,
|
||||
-- so break on the flag BEFORE tapping: a stray A here would eat the
|
||||
-- CUBONE's-mother box before it is recorded
|
||||
if game.save.flags.EVENT_BEAT_GHOST_MAROWAK then break end
|
||||
if getmetatable(game.stack:top()) == BattleState then sawBattle = true end
|
||||
U.tap(game, "a")
|
||||
U.wait(4)
|
||||
end
|
||||
check("the MAROWAK battle opened", sawBattle)
|
||||
check("the battle was won (EVENT_BEAT_GHOST_MAROWAK set)",
|
||||
game.save.flags.EVENT_BEAT_GHOST_MAROWAK == true)
|
||||
|
||||
-- ---- record the send-off boxes -----------------------------------------
|
||||
-- Each box is sampled the frame it is first seen: the calmed box carries
|
||||
-- the armed cry as opts.auto {sound, wait} (src/script/Commands.lua), and
|
||||
-- TextBox clears .auto once the cry has played, so a late read looks like
|
||||
-- no cry at all.
|
||||
local boxes = {}
|
||||
local lastBox = nil
|
||||
local budget = 1200
|
||||
while budget > 0 do
|
||||
local top = game.stack:top()
|
||||
if getmetatable(top) == TextBox then
|
||||
if top ~= lastBox then
|
||||
lastBox = top
|
||||
local shown = {}
|
||||
for _, page in ipairs(top.pages or {}) do
|
||||
for _, line in ipairs(page) do shown[#shown + 1] = line end
|
||||
end
|
||||
boxes[#boxes + 1] = {
|
||||
text = table.concat(shown, " / "),
|
||||
cry = top.auto ~= nil and top.auto.sound ~= nil,
|
||||
}
|
||||
U.log(("box %d reads:"):format(#boxes), boxes[#boxes].text)
|
||||
-- let the typewriter finish before the shot so the capture shows the
|
||||
-- line; the auto sample above already happened on the open frame
|
||||
for _ = 1, 240 do
|
||||
if top.waiting or top.done or game.stack:top() ~= top then break end
|
||||
U.wait(1)
|
||||
budget = budget - 1
|
||||
end
|
||||
U.shot(game, DIR .. ("/bug867_box%d.png"):format(#boxes))
|
||||
end
|
||||
U.tap(game, "a")
|
||||
U.wait(3)
|
||||
budget = budget - 4
|
||||
else
|
||||
if #boxes >= 2 then break end
|
||||
U.wait(1)
|
||||
budget = budget - 1
|
||||
end
|
||||
end
|
||||
|
||||
check("the send-off is two boxes, not one (#867)", #boxes == 2)
|
||||
check("box 1 is the CUBONE's-mother line",
|
||||
boxes[1] ~= nil and boxes[1].text:find("CUBONE", 1, true) ~= nil)
|
||||
check("box 1 opens silent (the asm plays no cry before it)",
|
||||
boxes[1] ~= nil and not boxes[1].cry)
|
||||
check("box 2 is the calmed line",
|
||||
boxes[2] ~= nil and boxes[2].text:find("calmed", 1, true) ~= nil)
|
||||
check("box 2 opens with the MAROWAK cry armed",
|
||||
boxes[2] ~= nil and boxes[2].cry == true)
|
||||
|
||||
-- ---- the trigger is spent ----------------------------------------------
|
||||
-- step off and back onto (10, 16): with the flag set, onStep must pass
|
||||
local back = ({ down = "up", up = "down", left = "right", right = "left" })
|
||||
[STAND.step]
|
||||
U.hold(game, back, 20)
|
||||
U.wait(10)
|
||||
U.hold(game, STAND.step, 20)
|
||||
U.wait(20)
|
||||
check("re-stepping the trigger cell stays quiet",
|
||||
getmetatable(game.stack:top()) ~= TextBox)
|
||||
U.shot(game, DIR .. "/bug867_after.png")
|
||||
U.log(("machine checks: %d passed, %d failed"):format(pass, fail))
|
||||
|
||||
-- ---- hand off ----------------------------------------------------------
|
||||
U.log("The pad is yours on 6F with the ghost already sent off. What should")
|
||||
U.log("have happened: after the win, \"The GHOST was the / restless soul of /")
|
||||
U.log("CUBONE's mother!\" first, then the MAROWAK cry sounds as the second box")
|
||||
U.log("opens with \"The mother's soul / was calmed.\" The old bug jumped")
|
||||
U.log("straight to the calmed line, no mother line and no cry at all.")
|
||||
U.log("Screenshots: " .. DIR .. "/bug867_*.png")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,164 @@
|
||||
-- Manual check that a warp arrival hides Pikachu under the player (#863):
|
||||
-- pokeyellow spawns on the player's own coords and the follow buffer walks
|
||||
-- it out, but before the fix it popped in already beside him.
|
||||
-- Warp cells: pokered data/maps/objects/RedsHouse1F.asm / RedsHouse2F.asm.
|
||||
-- POKEPORT_DRIVER=tests/drivers/pikachu_warp_spawn_bug863_test.lua POKEPORT_IDENTITY=bug863 POKEPORT_TOUCH=0 POKEPORT_VERSION=yellow love .
|
||||
-- Never add POKEPORT_SPEED; the identity needs an imported Yellow cache.
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local PF = require("src.world.PikachuFollower")
|
||||
local 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
|
||||
|
||||
check("running as Yellow (needs POKEPORT_VERSION=yellow)",
|
||||
GameVersion.isYellow())
|
||||
|
||||
-- the follower only spawns behind EVENT_GOT_STARTER with a healthy
|
||||
-- PIKACHU in the party (PikachuFollower shouldSpawn)
|
||||
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
|
||||
game.save.player.name = "bryan"
|
||||
|
||||
-- pokered RedsHouse1F.asm: warp_event 7, 1 -> REDS_HOUSE_2F, and
|
||||
-- RedsHouse2F.asm: warp_event 7, 1 back down. Read the live map data
|
||||
-- so a hack or mod that moved the stairs still points us at them.
|
||||
local function warpTo(fromMap, destMap)
|
||||
local def = game.data.maps[fromMap]
|
||||
for _, w in ipairs(def and def.warps or {}) do
|
||||
if w.destMap == destMap then return w.x, w.y end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
local wx, wy = warpTo("REDS_HOUSE_1F", "REDS_HOUSE_2F")
|
||||
check("REDS_HOUSE_1F has a warp to REDS_HOUSE_2F", wx ~= nil)
|
||||
wx, wy = wx or 7, wy or 1
|
||||
|
||||
local follower = function() return PF.current(game.overworld) end
|
||||
|
||||
local function overlapsPlayer()
|
||||
local npc = follower()
|
||||
local p = game.overworld and game.overworld.player
|
||||
return npc and p and npc.cellX == p.cellX and npc.cellY == p.cellY
|
||||
end
|
||||
|
||||
-- press-and-hold dir until the map flips, releasing the instant it
|
||||
-- does so no queued step drags the player off the arrival warp cell
|
||||
local function walkUntilMap(dir, targetMap, maxFrames)
|
||||
for _ = 1, maxFrames do
|
||||
local ow = game.overworld
|
||||
if ow and ow.map and ow.map.id == targetMap then break end
|
||||
table.insert(game.input.pressQueue, dir)
|
||||
game.input.state[dir] = true
|
||||
coroutine.yield()
|
||||
end
|
||||
game.input.state[dir] = false
|
||||
U.wait(45) -- warp fade + arrival settle
|
||||
local ow = game.overworld
|
||||
return ow and ow.map and ow.map.id == targetMap
|
||||
end
|
||||
|
||||
-- stand two below the stairs facing up; fall back to any walkable cell
|
||||
-- below the warp if a mod reshaped the room
|
||||
local sx, sy = wx, wy + 2
|
||||
U.teleport(game, "REDS_HOUSE_1F", sx, sy, "up")
|
||||
U.wait(10)
|
||||
local ow = game.overworld
|
||||
if not ow.map:isWalkableCell(sx, sy) then
|
||||
for dy = 1, 3 do
|
||||
if ow.map:isWalkableCell(wx, wy + dy) then
|
||||
sx, sy = wx, wy + dy
|
||||
U.teleport(game, "REDS_HOUSE_1F", sx, sy, "up")
|
||||
U.wait(10)
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
-- U.teleport is itself a fresh map load, so the fixed spawn already
|
||||
-- parks Pikachu on the player's cell here
|
||||
check("follower spawned on map load", follower() ~= nil)
|
||||
check("map-load spawn is on the player's own cell", overlapsPlayer())
|
||||
|
||||
-- climb the stairs
|
||||
check("walked up into REDS_HOUSE_2F",
|
||||
walkUntilMap("up", "REDS_HOUSE_2F", 240))
|
||||
check("warp arrival upstairs: Pikachu hidden under the player",
|
||||
overlapsPlayer())
|
||||
U.shot(game, SHOT_DIR .. "/bug863_2f_arrival.png")
|
||||
|
||||
-- the draw sort tie-break (#863): sharing the player's py must list the
|
||||
-- follower first so he draws over it. The sort runs in draw, and the
|
||||
-- shot above rendered a frame, so entities order is post-sort here.
|
||||
do
|
||||
local npc = follower()
|
||||
local p = game.overworld.player
|
||||
if npc and p and npc.py == p.py then
|
||||
local ni, pi
|
||||
for i, e in ipairs(game.overworld.entities) do
|
||||
if e == npc then ni = i elseif e == p then pi = i end
|
||||
end
|
||||
check("draw sort puts the hidden follower under the player",
|
||||
ni ~= nil and pi ~= nil and ni < pi)
|
||||
end
|
||||
end
|
||||
|
||||
-- walk off the stairs: the trail should pull Pikachu out one behind
|
||||
U.hold(game, "down", 20)
|
||||
U.wait(20)
|
||||
U.hold(game, "down", 20)
|
||||
U.wait(30)
|
||||
do
|
||||
local npc = follower()
|
||||
local p = game.overworld.player
|
||||
check("two steps later Pikachu trails one cell behind",
|
||||
npc and p and npc.cellX == p.cellX and npc.cellY == p.cellY - 1)
|
||||
check("and it faces down, walking out of the stairwell",
|
||||
npc and npc.facing == "down")
|
||||
end
|
||||
U.shot(game, SHOT_DIR .. "/bug863_2f_trailing.png")
|
||||
|
||||
-- back down the same stairs: descent must hide it the same way
|
||||
check("walked back down into REDS_HOUSE_1F",
|
||||
walkUntilMap("up", "REDS_HOUSE_1F", 240))
|
||||
check("warp arrival downstairs: Pikachu hidden under the player",
|
||||
overlapsPlayer())
|
||||
U.shot(game, SHOT_DIR .. "/bug863_1f_return.png")
|
||||
|
||||
-- regression: a connection seam is the keepPikachu path (#427), not a
|
||||
-- respawn, so the follower must ride across it, not vanish or repark
|
||||
U.teleport(game, "PALLET_TOWN", 10, 2, "up")
|
||||
U.wait(10)
|
||||
check("crossed the Pallet Town north seam into ROUTE_1",
|
||||
walkUntilMap("up", "ROUTE_1", 300))
|
||||
do
|
||||
local npc = follower()
|
||||
local p = game.overworld.player
|
||||
check("follower survived the connection crossing", npc ~= nil)
|
||||
check("and stayed within trailing range of the player",
|
||||
npc and p and math.abs(npc.cellX - p.cellX)
|
||||
+ math.abs(npc.cellY - p.cellY) <= 2)
|
||||
end
|
||||
U.shot(game, SHOT_DIR .. "/bug863_route1_seam.png")
|
||||
|
||||
local sfx = game.save.options and game.save.options.sfxVol
|
||||
if sfx == 0 then
|
||||
U.log("note: sfxVol is 0, Pikachu's steps and voice will be silent")
|
||||
end
|
||||
|
||||
U.log("The shots above tell the story: on both stair arrivals only the")
|
||||
U.log("player should be visible, Pikachu is tucked under him until he")
|
||||
U.log("steps away, then it follows one cell behind facing his way.")
|
||||
U.log("If a Pikachu sits beside him the moment a warp lands, that is")
|
||||
U.log("the old bug. The pad is yours; warp around and watch spawns.")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,149 @@
|
||||
-- Manual check of the title main menu + CONTINUE info box colors (#870):
|
||||
-- both must follow the COLORS display mode (CLASSIC pea greens) instead of
|
||||
-- staying a raw white trueColor hole, while gbc keeps #133's white paper /
|
||||
-- black ink (pokered engine/menus/main_menu.asm RunDefaultPaletteCommand).
|
||||
-- Palette shading is the moment under test, so POKEPORT_SPEED stays unset.
|
||||
-- POKEPORT_DRIVER=tests/drivers/title_menu_palette_bug870_test.lua POKEPORT_IDENTITY=bug870 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local P = require("src.render.PaletteFX")
|
||||
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
|
||||
local fails = 0
|
||||
local function check(label, ok)
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
if not ok then fails = fails + 1 end
|
||||
return ok
|
||||
end
|
||||
|
||||
-- Count near-white pixels in a captured frame. CLASSIC's lightest shade
|
||||
-- is (155,188,15) and no blend of the four pea greens (or the letterbox)
|
||||
-- reaches 250+, so any white here can only be an unshaded region -- the
|
||||
-- exact white hole #870 is about. Reads the PNG back through
|
||||
-- love.image so the check sees what actually hit the window.
|
||||
local function whiteCount(path)
|
||||
local f = io.open(path, "rb")
|
||||
if not f then return nil end
|
||||
local bytes = f:read("*a")
|
||||
f:close()
|
||||
local ok, img = pcall(function()
|
||||
return love.image.newImageData(
|
||||
love.filesystem.newFileData(bytes, "shot.png"))
|
||||
end)
|
||||
if not ok or not img then return nil end
|
||||
local n = 0
|
||||
for y = 0, img:getHeight() - 1 do
|
||||
for x = 0, img:getWidth() - 1 do
|
||||
local r, g, b = img:getPixel(x, y)
|
||||
if r > 0.98 and g > 0.98 and b > 0.98 then n = n + 1 end
|
||||
end
|
||||
end
|
||||
return n
|
||||
end
|
||||
|
||||
-- A real save on disk first: hasSave in TitleState:openMenu checks the
|
||||
-- save FILE, not the in-memory table, and only then lists CONTINUE.
|
||||
-- No map coordinates anywhere in this test -- the bug lives on the title
|
||||
-- screen, before any map.
|
||||
U.newGame(game)
|
||||
check("reached the overworld", game.overworld ~= nil)
|
||||
check("save written so the menu lists CONTINUE",
|
||||
require("src.core.SaveData").save(game.save))
|
||||
|
||||
-- flip COLORS the way the options screen does, then power-cycle to the
|
||||
-- title (returnToTitle skips the intro movie, unlike a cold boot)
|
||||
game.save.options.colors = "classic"
|
||||
P.applyOptions(game.save.options)
|
||||
game:returnToTitle()
|
||||
U.wait(30)
|
||||
|
||||
local TitleState = require("src.ui.TitleState")
|
||||
local title = game.stack:top()
|
||||
check("back on the title screen", getmetatable(title) == TitleState)
|
||||
|
||||
U.tap(game, "start")
|
||||
U.wait(10)
|
||||
local menu = game.stack:top()
|
||||
check("main menu opened and set a titleUiBox",
|
||||
menu ~= title and menu ~= nil and menu.titleUiBox ~= nil)
|
||||
|
||||
-- The fix itself: the box overlay must be a GRAYS palette zone the shade
|
||||
-- shader runs on. A colors == false zone would make Renderer:blitCanvas
|
||||
-- re-blit the rect with NO shader, so effectiveColors never substitutes
|
||||
-- the mono/inverted modes there -- the pre-#870 white hole.
|
||||
local zones = title.sgbPalettes and title:sgbPalettes(game)
|
||||
local boxZone, bare = nil, false
|
||||
for _, z in ipairs(zones or {}) do
|
||||
if z.colors == false then bare = true end
|
||||
if z.colors == P.GRAYS then boxZone = z end
|
||||
end
|
||||
check("no trueColor (colors == false) zone over the menu box", not bare)
|
||||
check("the titleUiBox rides a GRAYS palette zone", boxZone ~= nil)
|
||||
-- effectiveColors under classic substitutes CLASSIC; the trailing
|
||||
-- permute is the identity while no shade map is armed, so == holds
|
||||
check("CLASSIC substitutes the GRAYS box zone",
|
||||
P.effectiveColors(P.GRAYS) == P.CLASSIC)
|
||||
|
||||
local shot1 = SHOT_DIR .. "/bug870_menu_classic.png"
|
||||
if U.shot(game, shot1) then
|
||||
local n = whiteCount(shot1)
|
||||
U.log("white pixels in the menu shot:", tostring(n))
|
||||
check("CLASSIC main menu shot has zero raw-white pixels",
|
||||
n ~= nil and n == 0)
|
||||
end
|
||||
|
||||
-- with a save present CONTINUE is first, so the cursor is already on it
|
||||
U.tap(game, "a")
|
||||
U.wait(10)
|
||||
local info = game.stack:top()
|
||||
check("CONTINUE info box open with its titleUiBox",
|
||||
info ~= nil and info ~= menu and info.titleUiBox ~= nil
|
||||
and info.titleUiBox[1] == 4 and info.titleUiBox[2] == 7)
|
||||
|
||||
local shot2 = SHOT_DIR .. "/bug870_info_classic.png"
|
||||
if U.shot(game, shot2) then
|
||||
local n = whiteCount(shot2)
|
||||
U.log("white pixels in the info shot:", tostring(n))
|
||||
check("CLASSIC CONTINUE info shot has zero raw-white pixels",
|
||||
n ~= nil and n == 0)
|
||||
end
|
||||
|
||||
-- #133 regression gate: under gbc the GRAYS zone must pass through the
|
||||
-- shader unchanged, so the box comes back white paper / black ink while
|
||||
-- the LOGO zones keep the title colored around it
|
||||
P.applyOptions({ colors = "gbc" })
|
||||
U.wait(5)
|
||||
check("gbc leaves the GRAYS box zone alone (the #133 white box)",
|
||||
P.effectiveColors(P.GRAYS) == P.GRAYS)
|
||||
local shot3 = SHOT_DIR .. "/bug870_info_gbc.png"
|
||||
if U.shot(game, shot3) then
|
||||
local n = whiteCount(shot3)
|
||||
U.log("white pixels in the gbc shot:", tostring(n))
|
||||
check("gbc info box paper is white again", n ~= nil and n > 0)
|
||||
end
|
||||
|
||||
-- the inverted modes must now invert the box with the screen too
|
||||
P.applyOptions({ colors = "og_inv" })
|
||||
U.wait(5)
|
||||
local inv = P.effectiveColors(P.GRAYS)
|
||||
check("OG INV inverts the box paper to black",
|
||||
inv ~= nil and inv[1] ~= nil and inv[1][1] == 0)
|
||||
U.shot(game, SHOT_DIR .. "/bug870_info_oginv.png")
|
||||
|
||||
-- hand over in the bug's own mode
|
||||
game.save.options.colors = "classic"
|
||||
P.applyOptions(game.save.options)
|
||||
U.wait(2)
|
||||
|
||||
U.log(fails == 0 and "PASS all machine checks"
|
||||
or ("FAIL " .. fails .. " machine check(s), see above"))
|
||||
U.log("The CONTINUE info box on screen is in CLASSIC now; its paper should")
|
||||
U.log("be the same pea green as the title behind it, with dark green ink,")
|
||||
U.log("just like the in-game START menu. Before #870 this box and the")
|
||||
U.log("main menu were a pure white rectangle over the green title.")
|
||||
U.log("B backs out to the menu; OPTION flips COLORS to eyeball the rest.")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,112 @@
|
||||
-- One-column launcher reach (#852) and the safe-area launcher anchor (#810).
|
||||
-- No pokered cite: the launcher is port-only chrome.
|
||||
--
|
||||
-- #852: minPanelHeight in src/import/LauncherView.lua was a flat 460*s tuned
|
||||
-- for the two-column layout. A one-column window (portrait phone, squat 4:3
|
||||
-- device) stacks title + actions card + slot card + the pinned
|
||||
-- Play/Reset-rebinds/Touch-Controls block, which needs more room; the flat
|
||||
-- threshold read "tall enough", so the short-window page scroll never
|
||||
-- engaged, buildSlotCard was cut by Kit.pushClip against the pinned block,
|
||||
-- and Kit's clip-bounded hit-testing (src/ui/kit/Kit.lua) left every slot
|
||||
-- row, the pager and "+ New save slot" drawn-but-inert. The fix makes the
|
||||
-- threshold column-aware, so those windows scroll instead of clipping.
|
||||
--
|
||||
-- The seam is LauncherView.draw itself: it publishes the page-scroll extent
|
||||
-- on the importer (imp._pageScroll / imp._pageScrollMax, the values the
|
||||
-- touch-drag and wheel paths feed), so a headless draw shows whether the
|
||||
-- scroll engaged without reading any file-local constant. The 480x900
|
||||
-- window below is the discriminator: its natural panel space satisfies the
|
||||
-- old flat threshold (inert, slot card clipped) but not the one-column one.
|
||||
--
|
||||
-- #810 gets its unit-conversion pin in tests/engine/safe_area_units_test.lua;
|
||||
-- here the complementary end-to-end anchor: Layout.metrics must place the
|
||||
-- launcher at the corrected safe-area origin, not a DPI-inflated band down
|
||||
-- the screen.
|
||||
-- luajit tests/engine/launcher_one_column_reach_bug852.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")
|
||||
|
||||
-- The launcher touches two graphics calls the shared stub does not carry
|
||||
-- (focus-ring joins, the footer's BCG invert shader); both are draw-only, so
|
||||
-- inert fills are enough for the layout arithmetic under test.
|
||||
love.graphics.setLineJoin = love.graphics.setLineJoin or function() end
|
||||
love.graphics.newShader = love.graphics.newShader or function() return {} end
|
||||
|
||||
local Layout = require("src.ui.kit.Layout")
|
||||
local RomImporter = require("src.import.RomImporter")
|
||||
local LauncherView = require("src.import.LauncherView")
|
||||
|
||||
local function window(w, h)
|
||||
love.graphics.getDimensions = function() return w, h end
|
||||
love.graphics.getPixelDimensions = function() return w, h end
|
||||
end
|
||||
|
||||
-- A fresh launcher on the Red tab; no cache exists headless, so every
|
||||
-- version sits in its "ROM required" state, which still lays out the full
|
||||
-- one-column pile (title, actions card, slot card, pinned block).
|
||||
local function freshLauncher()
|
||||
return RomImporter.new(function() end, { launcher = true })
|
||||
end
|
||||
|
||||
-- ------------------------------------------------ #852: the scroll engages
|
||||
-- 480x900 one column: enough room for the old flat 460*s threshold, not for
|
||||
-- the one-column stack. Before the fix draw() left _pageScrollMax at 0 here
|
||||
-- and the slot card sat clipped inert against the pinned buttons.
|
||||
window(480, 900)
|
||||
local m = Layout.metrics(1200)
|
||||
eq(m.twoCol, false, "480-wide window lays out one column")
|
||||
local imp = freshLauncher()
|
||||
LauncherView.draw(imp)
|
||||
check((imp._pageScrollMax or 0) > 0,
|
||||
"one-column window short of the stack engages the page scroll")
|
||||
eq(imp._pageScroll, 0, "a fresh page starts at the top")
|
||||
|
||||
-- The wheel moves the page (the same offset the touch drag feeds), and the
|
||||
-- offset clamps to the extent, so the whole stack down to "+ New save slot"
|
||||
-- and the footer is reachable rather than clipped away.
|
||||
local extent = imp._pageScrollMax
|
||||
imp._wheelY = -1
|
||||
LauncherView.draw(imp)
|
||||
eq(imp._pageScroll, math.min(math.floor(48 * m.s), extent),
|
||||
"one wheel notch scrolls the page down by its step")
|
||||
imp._pageScroll = 1e6
|
||||
LauncherView.draw(imp)
|
||||
eq(imp._pageScroll, imp._pageScrollMax,
|
||||
"an offset past the end clamps to the extent, so the bottom is reachable")
|
||||
|
||||
-- The reporter's portrait phone (360x780 units) is shorter still and must
|
||||
-- also scroll; before the fix its slot list was unreachable.
|
||||
window(360, 780)
|
||||
local phone = freshLauncher()
|
||||
LauncherView.draw(phone)
|
||||
check((phone._pageScrollMax or 0) > 0,
|
||||
"portrait-phone one-column window engages the page scroll")
|
||||
|
||||
-- A one-column window tall enough for the whole stack stays inert: the
|
||||
-- column-aware minimum is a floor, not a permanent scroll.
|
||||
window(480, 1200)
|
||||
local tall = freshLauncher()
|
||||
LauncherView.draw(tall)
|
||||
eq(tall._pageScrollMax, 0,
|
||||
"a tall one-column window does not scroll for nothing")
|
||||
|
||||
-- --------------------------------------- #810: launcher anchored in units
|
||||
-- Layout.metrics anchors the launcher at SafeArea.rect's origin. Feed it
|
||||
-- the iOS 16 portrait frame that reported the safe rect in framebuffer
|
||||
-- pixels (3x DPI): the launcher must start at the 44-unit notch inset, not
|
||||
-- 132 units down with the top of the window black (the #810 report). The
|
||||
-- rescale itself is pinned in tests/engine/safe_area_units_test.lua.
|
||||
love.graphics.getDimensions = function() return 375, 812 end
|
||||
love.graphics.getPixelDimensions = function() return 1125, 2436 end
|
||||
local oldSafe = love.window.getSafeArea
|
||||
love.window.getSafeArea = function() return 0, 132, 1125, 2232 end
|
||||
local ios = Layout.metrics(1200)
|
||||
eq(ios.top, 44, "launcher anchors at the unit-space notch inset")
|
||||
eq(ios.h, 744, "launcher gets the full unit-space safe height")
|
||||
love.window.getSafeArea = oldSafe
|
||||
|
||||
T.finish("launcher one-column reach")
|
||||
@@ -0,0 +1,109 @@
|
||||
-- #828, the revert half: after the launcher's OG -> WIDE toggle, a play
|
||||
-- session rewrites options.lua with byte-identical content (play() re-stamps
|
||||
-- an unchanged lastVersion, SaveData.save flushes the attached table, and
|
||||
-- SaveSerializer's key-sorted encode makes equal tables equal bytes), so
|
||||
-- saveOptions' conditional pre-write roll skips and options.lua.bak kept the
|
||||
-- PRE-change file all session. Android and Steam Deck end sessions with a
|
||||
-- hard teardown (HostShell.restart restartApp kill / AppImage execv) that can
|
||||
-- eat the main file, and loadOptions then promoted that stale backup: the
|
||||
-- reported "launcher-only persists, going in-game reverts". The fix rolls
|
||||
-- the backup forward to the just-verified bytes after every landed write;
|
||||
-- this suite pins that at-rest invariant. ROM-free (T2 engine tier), same
|
||||
-- injected-fs shape as tests/engine/options_write_readback_bug828.lua, where
|
||||
-- these checks should eventually fold in.
|
||||
-- luajit tests/engine/options_backup_rollforward_bug828.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 SaveData = require("src.core.SaveData")
|
||||
|
||||
local OPTIONS = "options.lua"
|
||||
local BAK = OPTIONS .. ".bak"
|
||||
local TMP = OPTIONS .. ".tmp"
|
||||
|
||||
-- In-memory love.filesystem stub, the { getInfo, read, write, remove } shape
|
||||
-- SaveData.persistFs accepts. `dropping` is mutable so one fs can serve a
|
||||
-- healthy session and then a write that reports success without landing.
|
||||
local function memfs()
|
||||
local files = {}
|
||||
local fs
|
||||
fs = {
|
||||
files = files,
|
||||
dropping = false,
|
||||
write = function(path, content)
|
||||
if fs.dropping then return true end
|
||||
files[path] = content
|
||||
return true
|
||||
end,
|
||||
read = function(path) return files[path] end,
|
||||
remove = function(path) files[path] = nil return true end,
|
||||
getInfo = function(path)
|
||||
if files[path] ~= nil then return { type = "file" } end
|
||||
return nil
|
||||
end,
|
||||
}
|
||||
return fs
|
||||
end
|
||||
|
||||
-- ---- at rest, the backup holds the newest verified bytes
|
||||
|
||||
local fs = memfs()
|
||||
SaveData.saveOptions({ battleLayout = "wide" }, fs)
|
||||
check(fs.files[BAK] ~= nil, "the very first verified write already leaves a backup")
|
||||
eq(fs.files[BAK], fs.files[OPTIONS],
|
||||
"after a verified write the backup equals the main file (#828 roll-forward)")
|
||||
check(fs.files[TMP] == nil, "the staged witness is still dropped after verification")
|
||||
|
||||
-- ---- the reported session, write for write
|
||||
-- Launcher toggles OG -> WIDE, then two rewrites whose bytes match the file
|
||||
-- on disk: RomImporter:play re-stamping the same lastVersion (#835) and the
|
||||
-- in-game SaveData.save flush of the attached, unchanged table. Both go
|
||||
-- through loadOptions first, exactly as the shipping callers do, so the
|
||||
-- encoder sees identical tables and the conditional pre-roll skips.
|
||||
local live = memfs()
|
||||
SaveData.saveOptions({ battleLayout = "og", lastVersion = "red" }, live)
|
||||
|
||||
local toggled = SaveData.loadOptions(live)
|
||||
toggled.battleLayout = "wide"
|
||||
SaveData.saveOptions(toggled, live)
|
||||
local wideBytes = live.files[OPTIONS]
|
||||
|
||||
local stamped = SaveData.loadOptions(live)
|
||||
stamped.lastVersion = "red"
|
||||
SaveData.saveOptions(stamped, live)
|
||||
eq(live.files[OPTIONS], wideBytes,
|
||||
"the play() lastVersion re-stamp is a byte-identical rewrite (sorted encode)")
|
||||
SaveData.saveOptions(SaveData.loadOptions(live), live)
|
||||
eq(live.files[OPTIONS], wideBytes, "the in-game flush is byte-identical too")
|
||||
|
||||
eq(live.files[BAK], wideBytes,
|
||||
"identical rewrites still carry the backup forward past the skipped pre-roll")
|
||||
|
||||
-- the hard teardown eats the main file; recovery must answer the toggle
|
||||
live.files[OPTIONS] = nil
|
||||
eq(SaveData.loadOptions(live).battleLayout, "wide",
|
||||
"a lost main file recovers to WIDE, not the pre-toggle OG backup (#828)")
|
||||
check(live.files[OPTIONS] ~= nil, "and the main file is healed from that copy")
|
||||
|
||||
-- ---- a write that does not land must not poison the backup
|
||||
-- The roll-forward has to sit AFTER the readback verification: if the bytes
|
||||
-- never reached disk (the #828 external-storage failure mode) the backup
|
||||
-- keeps the last state that verifiably did.
|
||||
local flaky = memfs()
|
||||
SaveData.saveOptions({ battleLayout = "wide" }, flaky)
|
||||
local verified = flaky.files[BAK]
|
||||
flaky.dropping = true
|
||||
eq(SaveData.saveOptions({ battleLayout = "og" }, flaky), nil,
|
||||
"the vanished write still reports failure")
|
||||
flaky.dropping = false
|
||||
eq(flaky.files[BAK], verified,
|
||||
"a write that never landed leaves the backup at the last verified bytes")
|
||||
flaky.files[OPTIONS] = nil
|
||||
eq(SaveData.loadOptions(flaky).battleLayout, "wide",
|
||||
"so recovery after the failed write still answers the verified state")
|
||||
|
||||
T.finish("options_backup_rollforward_bug828")
|
||||
@@ -131,6 +131,31 @@ eq(healed and healed.battleLayout, "wide",
|
||||
check(live.files[OPTIONS] ~= "return { battleLayout = ",
|
||||
"the main options file is healed from the copy that parsed")
|
||||
|
||||
-- ---- a lost main file must recover to the NEWEST verified write
|
||||
-- The platforms that lose options.lua do it on the hard teardown out of a
|
||||
-- game session (HostShell.restart's restartApp kill on Android, execv on a
|
||||
-- SteamOS AppImage), after rewrites whose bytes matched the file already on
|
||||
-- disk: play()'s lastVersion stamp and the in-game save flush re-encode the
|
||||
-- same table, and the key-sorted encoder makes those byte-identical, so the
|
||||
-- conditional pre-write roll skips them. The backup is therefore rolled
|
||||
-- forward after every verified write; otherwise recovery handed back the
|
||||
-- file from BEFORE the launcher's change, which is exactly the reported
|
||||
-- "set BATTLE LAYOUT to WIDE, go in game, close, and it is OG again" (#828).
|
||||
local lost = memfs("honest")
|
||||
SaveData.saveOptions({ battleLayout = "og", lastVersion = "red" }, lost)
|
||||
local editedOpts = SaveData.loadOptions(lost)
|
||||
editedOpts.battleLayout = "wide"
|
||||
SaveData.saveOptions(editedOpts, lost) -- the launcher's toggle
|
||||
local replay = SaveData.loadOptions(lost)
|
||||
replay.lastVersion = "red" -- play() re-stamps the same value
|
||||
SaveData.saveOptions(replay, lost) -- byte-identical rewrite
|
||||
SaveData.saveOptions(SaveData.loadOptions(lost), lost) -- in-game save flush, identical too
|
||||
lost.files[OPTIONS] = nil -- the platform ate the main file
|
||||
local promoted = SaveData.loadOptions(lost)
|
||||
eq(promoted.battleLayout, "wide",
|
||||
"a lost main file recovers to the newest verified write, not the "
|
||||
.. "pre-change backup (#828)")
|
||||
|
||||
local gone = memfs("honest")
|
||||
SaveData.saveOptions({ battleLayout = "wide" }, gone)
|
||||
gone.files[OPTIONS] = nil
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
-- SafeArea.rect unit sanity (#810): the iOS build reported the portrait
|
||||
-- safe rect in framebuffer PIXELS while love.graphics works in DPI-scaled
|
||||
-- units, and the old clamp kept the inflated top inset -- the launcher
|
||||
-- started a band down the screen and left the top of it black. A rect
|
||||
-- that cannot fit the unit window is converted back to units with
|
||||
-- per-axis ratios (the axes can disagree, #208). No pokered cite: the
|
||||
-- launcher is port-only chrome.
|
||||
-- luajit tests/engine/safe_area_units_test.lua
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.harness")
|
||||
local eq = T.eq
|
||||
love = love or require("tests.love_stub")
|
||||
|
||||
local SafeArea = require("src.core.SafeArea")
|
||||
|
||||
local oldDims = love.graphics.getDimensions
|
||||
local oldPix = love.graphics.getPixelDimensions
|
||||
local oldSafe = love.window.getSafeArea
|
||||
|
||||
local function frame(uw, uh, pw, ph, sx, sy, sw, sh)
|
||||
love.graphics.getDimensions = function() return uw, uh end
|
||||
love.graphics.getPixelDimensions = function() return pw, ph end
|
||||
love.window.getSafeArea = function() return sx, sy, sw, sh end
|
||||
return SafeArea.rect()
|
||||
end
|
||||
|
||||
-- a pixel-based rect on a 3x portrait phone comes back in units (#810)
|
||||
local x, y, w, h = frame(375, 812, 1125, 2436, 0, 132, 1125, 2232)
|
||||
eq(x, 0, "pixel-unit safe x rescales")
|
||||
eq(y, 44, "pixel-unit top inset rescales to the real notch")
|
||||
eq(w, 375, "pixel-unit safe width rescales")
|
||||
eq(h, 744, "pixel-unit safe height rescales")
|
||||
|
||||
-- a correct unit rect passes through untouched
|
||||
x, y, w, h = frame(375, 812, 1125, 2436, 0, 44, 375, 734)
|
||||
eq(y, 44, "a unit rect keeps its top inset")
|
||||
eq(h, 734, "a unit rect keeps its height")
|
||||
|
||||
-- dpi 1: no rescale, the oversized rect still clamps to the window
|
||||
x, y, w, h = frame(640, 576, 640, 576, 0, 100, 900, 900)
|
||||
eq(y, 100, "no rescale when units are pixels")
|
||||
eq(w, 640, "width clamps to the drawable window")
|
||||
eq(h, 476, "height clamps to the drawable window")
|
||||
|
||||
love.graphics.getDimensions = oldDims
|
||||
love.graphics.getPixelDimensions = oldPix
|
||||
love.window.getSafeArea = oldSafe
|
||||
|
||||
T.finish("safe area units")
|
||||
@@ -0,0 +1,187 @@
|
||||
-- Gen1 save codec (src/save_convert/GenSave.lua) for wToggleableObjectFlags
|
||||
-- (ram/wram.asm, flag_array $100): the ShowObject/HideObject persistence the
|
||||
-- codec used to skip entirely, so an import resurrected both Mt Moon fossils
|
||||
-- (#857) and reverted Cerulean's GUARD1/GUARD2/ROCKET swap so the officer
|
||||
-- blocked the robbed-house door again (#763). Bit numbering comes from
|
||||
-- ../pokered/data/maps/toggleable_objects.asm entry order (bit set = hidden,
|
||||
-- engine/overworld/toggleable_objects.asm IsObjectHidden), and the offset is
|
||||
-- re-derived here from the wram walk rather than read out of GenSave.OFFSETS.
|
||||
-- Also covers the Yellow-only wPikachuHappiness byte (#763, #838): the
|
||||
-- 0x271C offset is pinned from the pokeyellow symbol file, not a local
|
||||
-- pokeyellow checkout, so it still wants a confirmation against a real
|
||||
-- emulator-written Yellow .sav.
|
||||
-- luajit tests/engine/save_convert_toggle_objects.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 bit = require("bit")
|
||||
local GenSave = require("src.save_convert.GenSave")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
|
||||
-- the codec crosswalks need the real dataset; CI has no ROM
|
||||
local loadPokemon = loadfile("data/generated/pokemon.lua")
|
||||
if not loadPokemon then
|
||||
print("save_convert_toggle_objects skipped (needs data/generated/ for the Gen1 save codec)")
|
||||
os.exit(0)
|
||||
end
|
||||
|
||||
GenSave.setCharmap(loadfile("src/save_convert/data/charmap.lua")())
|
||||
local toggles = loadfile("src/save_convert/data/toggle_objects.lua")()
|
||||
local data = {
|
||||
pokemon = loadPokemon(),
|
||||
moves = loadfile("data/generated/moves.lua")(),
|
||||
items = loadfile("data/generated/items.lua")(),
|
||||
maps = loadfile("data/generated/maps.lua")(),
|
||||
eventFlags = loadfile("src/save_convert/data/event_flags.lua")(),
|
||||
toggleObjects = toggles,
|
||||
}
|
||||
|
||||
-- ------------------------------------------------------------------
|
||||
-- offset pins, independent of the codec's own arithmetic: wram.asm places
|
||||
-- wToggleableObjectFlags 2 bytes (wPlayerCoins) past wPlayerCoins' label,
|
||||
-- i.e. sav absolute 0x2852; the pokeyellow symbol file places
|
||||
-- wPikachuHappiness at d46f - wMainDataStart d2f6 = 377, absolute 0x271C
|
||||
-- ------------------------------------------------------------------
|
||||
|
||||
local OFF = GenSave.OFFSETS
|
||||
eq(OFF.toggleObjectFlags, OFF.coins + 2,
|
||||
"wToggleableObjectFlags sits 2 bytes (wPlayerCoins) past O.coins")
|
||||
eq(OFF.toggleObjectFlags, 0x2852, "wToggleableObjectFlags is sav byte 0x2852")
|
||||
eq(OFF.pikachuHappiness, 0x271C, "wPikachuHappiness is sav byte 0x271C")
|
||||
|
||||
-- independent flag_array read (byte = index / 8, bit = index % 8), so nothing
|
||||
-- below trusts the writer it is checking
|
||||
local function flagGet(bytes, base, index)
|
||||
local byte = bytes:byte(base + math.floor(index / 8) + 1)
|
||||
return bit.band(bit.rshift(byte, index % 8), 1) == 1
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------------------
|
||||
-- crosswalk <-> maps.lua contract: every named toggle entry must resolve
|
||||
-- to a real object_event, or encode's itemsTaken/defeatedTrainers fold
|
||||
-- (which looks the object up by name) silently misses it
|
||||
-- ------------------------------------------------------------------
|
||||
|
||||
local entries = 0
|
||||
for _, e in pairs(toggles.byBit) do
|
||||
entries = entries + 1
|
||||
local found
|
||||
for _, obj in ipairs((data.maps[e[1]] or {}).objects or {}) do
|
||||
if obj.name == e[2] then found = obj break end
|
||||
end
|
||||
check(found ~= nil, e[1] .. " has an object_event named " .. e[2])
|
||||
end
|
||||
-- toggleable_objects.asm has 228 rows; two are placeholders with no
|
||||
-- object_event in this port (SILPHCO7F_UNUSED, the UNUSED_MAP_F4 entry)
|
||||
eq(entries, 226, "the crosswalk carries every real toggle bit and no more")
|
||||
|
||||
-- the bits under test, straight from the crosswalk's own numbering
|
||||
eq(toggles.byBit[109][2], "MTMOONB2F_DOME_FOSSIL", "bit 109 is the dome fossil")
|
||||
eq(toggles.byBit[110][2], "MTMOONB2F_HELIX_FOSSIL", "bit 110 is the helix fossil")
|
||||
eq(toggles.byBit[7][2], "CERULEANCITY_GUARD1", "bit 7 is the door guard")
|
||||
eq(toggles.byBit[9][2], "CERULEANCITY_GUARD2", "bit 9 is the roof guard")
|
||||
eq(toggles.byBit[104][2], "MTMOON1F_MOON_STONE", "bit 104 is the moon stone")
|
||||
|
||||
-- ------------------------------------------------------------------
|
||||
-- round trip: templateless export of a save past the fossil pickup
|
||||
-- (data/scripts/story2.lua hides both balls) and the Cerulean robbery
|
||||
-- resolution (data/scripts/story5.lua rocketRows shows GUARD1, hides
|
||||
-- GUARD2/ROCKET), plus a taken overworld item, which vanilla folds into
|
||||
-- these same bits (engine/events/pick_up_item.asm)
|
||||
-- ------------------------------------------------------------------
|
||||
|
||||
local function seedSave()
|
||||
local save = SaveData.newGame({ playerName = "RED", rivalName = "BLUE" })
|
||||
save.party = { {
|
||||
species = "SQUIRTLE", level = 6, exp = 200,
|
||||
dvs = { hp = 1, attack = 2, defense = 3, speed = 4, special = 5 },
|
||||
statExp = { hp = 0, attack = 0, defense = 0, speed = 0, special = 0 },
|
||||
stats = { hp = 22, attack = 12, defense = 13, speed = 11, special = 12 },
|
||||
hp = 22,
|
||||
moves = { { id = "TACKLE", pp = 35, ppUps = 0 } },
|
||||
nickname = "SQ", ot = "RED", otId = save.player.id, catchRate = 45,
|
||||
} }
|
||||
return save
|
||||
end
|
||||
|
||||
local set = seedSave()
|
||||
set.objectToggles = {
|
||||
MT_MOON_B2F = {
|
||||
MTMOONB2F_DOME_FOSSIL = false,
|
||||
MTMOONB2F_HELIX_FOSSIL = false,
|
||||
},
|
||||
CERULEAN_CITY = {
|
||||
CERULEANCITY_GUARD1 = true,
|
||||
CERULEANCITY_GUARD2 = false,
|
||||
},
|
||||
}
|
||||
-- the moon stone rides itemsTaken (src/world/OverworldController.lua
|
||||
-- force-hides picked items), never objectToggles, so encode must fold it in
|
||||
set.itemsTaken = { MT_MOON_1F_obj_9 = true }
|
||||
|
||||
local setBytes = GenSave.encode(set, data, nil)
|
||||
eq(#setBytes, GenSave.SAVE_SIZE, "the export is a 32768-byte save")
|
||||
|
||||
local TOG = OFF.toggleObjectFlags
|
||||
check(flagGet(setBytes, TOG, 109), "the taken dome fossil is hidden (bit 109)")
|
||||
check(flagGet(setBytes, TOG, 110), "the taken helix fossil is hidden (bit 110)")
|
||||
check(flagGet(setBytes, TOG, 9), "the swapped-out roof guard is hidden (bit 9)")
|
||||
check(not flagGet(setBytes, TOG, 7),
|
||||
"the officer now beside the door stays visible (bit 7 clear)")
|
||||
check(flagGet(setBytes, TOG, 104),
|
||||
"the taken moon stone folds from itemsTaken into bit 104")
|
||||
|
||||
-- untouched entries fall back to their compiled-in defaults, not to zero
|
||||
check(flagGet(setBytes, TOG, 0), "PALLETTOWN_OAK defaults hidden (bit 0 set)")
|
||||
check(not flagGet(setBytes, TOG, 1),
|
||||
"VIRIDIANCITY_OLD_MAN_SLEEPY defaults visible (bit 1 clear)")
|
||||
|
||||
local back = GenSave.decode(setBytes, data)
|
||||
eq(#(back.warnings or {}), 0, "the export decodes with no warnings")
|
||||
local reTog = back.objectToggles
|
||||
check(type(reTog) == "table", "an import populates save.objectToggles")
|
||||
eq(reTog.MT_MOON_B2F.MTMOONB2F_DOME_FOSSIL, false,
|
||||
"the dome fossil stays taken across export -> import")
|
||||
eq(reTog.MT_MOON_B2F.MTMOONB2F_HELIX_FOSSIL, false,
|
||||
"the helix fossil stays taken across export -> import")
|
||||
eq(reTog.CERULEAN_CITY.CERULEANCITY_GUARD1, true,
|
||||
"the officer stays beside the door across export -> import")
|
||||
eq(reTog.CERULEAN_CITY.CERULEANCITY_GUARD2, false,
|
||||
"the roof guard stays gone across export -> import")
|
||||
eq(reTog.PALLET_TOWN.PALLETTOWN_OAK, false,
|
||||
"Oak's roaming sprite imports at its hidden default")
|
||||
eq(reTog.VIRIDIAN_CITY.VIRIDIANCITY_OLD_MAN_SLEEPY, true,
|
||||
"the sleepy old man imports at his visible default")
|
||||
eq(reTog.MT_MOON_1F.MTMOON1F_MOON_STONE, false,
|
||||
"the folded moon stone imports hidden too")
|
||||
|
||||
-- ------------------------------------------------------------------
|
||||
-- Yellow starter friendship: gated on the data set's game because the
|
||||
-- byte is current-map scratch in Red/Blue (see O.pikachuHappiness)
|
||||
-- ------------------------------------------------------------------
|
||||
|
||||
check(not flagGet(setBytes, OFF.pikachuHappiness, 0)
|
||||
and setBytes:byte(OFF.pikachuHappiness + 1) == 0,
|
||||
"a Red/Blue export leaves the scratch byte at 0x271C zeroed")
|
||||
eq(back.pikachuHappiness, nil, "a Red/Blue import never invents a happiness")
|
||||
|
||||
local dataYellow = {
|
||||
pokemon = data.pokemon, moves = data.moves, items = data.items,
|
||||
maps = data.maps, toggleObjects = toggles,
|
||||
eventFlags = loadfile("src/save_convert/data/event_flags_yellow.lua")(),
|
||||
gameVersion = "yellow",
|
||||
}
|
||||
local ySave = seedSave()
|
||||
ySave.pikachuHappiness = 200
|
||||
local yBytes = GenSave.encode(ySave, dataYellow, nil)
|
||||
eq(yBytes:byte(OFF.pikachuHappiness + 1), 200,
|
||||
"pikachuHappiness = 200 reaches sav byte 0x271C")
|
||||
local yBack = GenSave.decode(yBytes, dataYellow)
|
||||
eq(yBack.pikachuHappiness, 200,
|
||||
"the follower's happiness survives export -> import on Yellow")
|
||||
|
||||
T.finish("save_convert_toggle_objects")
|
||||
@@ -188,6 +188,43 @@ local leftover = 0
|
||||
for _ in pairs(stagedTemps) do leftover = leftover + 1 end
|
||||
eq(leftover, 0, "fallback cleans staged temp after install")
|
||||
|
||||
-- #801: a same-id copy under a different folder name is replaced too, so the
|
||||
-- update cannot leave a shadow copy for discover()'s first-id-wins race
|
||||
resetFs()
|
||||
files["mods/WildsOfKanto-1.5.0/manifest.json"] =
|
||||
('{"id":"%s","name":"Old Copy","version":"0.9.0","entry":"main.lua"}')
|
||||
:format(MOD_ID)
|
||||
files["mods/WildsOfKanto-1.5.0/main.lua"] = "return function() end\n"
|
||||
files["imports/mods/update.zip"] = "PK\3\4update"
|
||||
ok, err = LauncherMods.installZip("imports/mods/update.zip",
|
||||
{ replace = true, expectId = MOD_ID })
|
||||
check(ok == true, "replace install succeeds over an odd-named copy ("
|
||||
.. tostring(err) .. ")")
|
||||
check(files["mods/WildsOfKanto-1.5.0/manifest.json"] == nil,
|
||||
"odd-named same-id folder is removed by the replace")
|
||||
check(files["mods/" .. MOD_ID .. "/manifest.json"] ~= nil,
|
||||
"replace still lands in mods/<id>")
|
||||
|
||||
-- #834: a manifest-less mods/<id> tree (interrupted copy debris) must not
|
||||
-- block a plain re-import as "already installed"
|
||||
resetFs()
|
||||
files["mods/" .. MOD_ID .. "/gfx/a.bin"] = "x"
|
||||
files["imports/mods/again.zip"] = "PK\3\4again"
|
||||
ok, err = LauncherMods.installZip("imports/mods/again.zip")
|
||||
check(ok == true, "debris tree does not block re-import ("
|
||||
.. tostring(err) .. ")")
|
||||
check(files["mods/" .. MOD_ID .. "/gfx/a.bin"] == nil,
|
||||
"debris is cleared by the re-import")
|
||||
|
||||
-- a real installed copy still refuses a plain duplicate import
|
||||
resetFs()
|
||||
files["mods/" .. MOD_ID .. "/manifest.json"] = ARCHIVE[MOD_ID .. "/manifest.json"]
|
||||
files["imports/mods/dup.zip"] = "PK\3\4dup"
|
||||
ok, err = LauncherMods.installZip("imports/mods/dup.zip")
|
||||
check(not ok, "a listed install still refuses a plain duplicate import")
|
||||
check(tostring(err):find("already installed", 1, true),
|
||||
"duplicate refusal still names already installed")
|
||||
|
||||
-- Restore
|
||||
love.filesystem = savedFs
|
||||
SaveData.portableBaseDir = savedSaveDataPortable
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
-- #801 / #834: what the PLAYER sees after an update over a shadow copy.
|
||||
-- The tier file (launcher_mods_install_zip_test.lua) asserts which folders
|
||||
-- survive installZip; this one asserts the panel-facing contracts built on
|
||||
-- top of them: LauncherMods.list() must report the NEW version after a
|
||||
-- replace even when a same-id copy sits under an archive-named folder that
|
||||
-- enumerates first (#801, "Updated ... to X" yet the old version kept
|
||||
-- loading), and uninstall()/re-import must both recover a manifest-less
|
||||
-- mods/<id> debris tree left by an interrupted copy (#834, "already
|
||||
-- installed" with nothing showing in the panel).
|
||||
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("launcher mods shadow copy #801/#834")
|
||||
local eq = S.eq
|
||||
local check = S.check
|
||||
|
||||
-- The real-world shape from the #801 report: a hand-unzipped copy kept the
|
||||
-- archive's folder name. "W" sorts before "o" in the stub's sorted
|
||||
-- enumeration, so the shadow folder enumerates first -- the exact ordering
|
||||
-- that made discover()'s first-id-wins dedupe resolve the stale copy.
|
||||
local MOD_ID = "overworld_wild_spawns"
|
||||
local SHADOW = "mods/WildsOfKanto-1.5.0"
|
||||
local NEW_VERSION = "1.7.1"
|
||||
|
||||
local ARCHIVE = {
|
||||
[MOD_ID .. "/manifest.json"] =
|
||||
('{"id":"%s","name":"Wilds of Kanto","version":"%s","entry":"main.lua"}')
|
||||
:format(MOD_ID, NEW_VERSION),
|
||||
[MOD_ID .. "/main.lua"] = "return function() end\n",
|
||||
}
|
||||
|
||||
local files, dirs, arch = {}, {}, {}
|
||||
|
||||
local function resetFs()
|
||||
for k in pairs(files) do files[k] = nil end
|
||||
for k in pairs(dirs) do dirs[k] = nil end
|
||||
for k in pairs(arch) do arch[k] = nil end
|
||||
end
|
||||
|
||||
local function dirChild(key, name)
|
||||
if name == nil or name == "" then return key:match("^[^/]+") end
|
||||
local prefix = name .. "/"
|
||||
if key:sub(1, #prefix) ~= prefix then return nil end
|
||||
return key:sub(#prefix + 1):match("^[^/]+")
|
||||
end
|
||||
|
||||
local function mapInfo(map, name, kind)
|
||||
if map[name] ~= nil then return { type = kind or "file" } end
|
||||
for key in pairs(map) do
|
||||
if dirChild(key, name) then return { type = "directory" } end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local vfs = {}
|
||||
|
||||
function vfs.write(name, data)
|
||||
files[name] = data
|
||||
return true
|
||||
end
|
||||
|
||||
function vfs.read(name)
|
||||
if arch[name] ~= nil then return arch[name] end
|
||||
return files[name]
|
||||
end
|
||||
|
||||
function vfs.remove(name)
|
||||
files[name] = nil
|
||||
dirs[name] = nil
|
||||
return true
|
||||
end
|
||||
|
||||
function vfs.createDirectory(name)
|
||||
dirs[name] = true
|
||||
return true
|
||||
end
|
||||
|
||||
function vfs.getInfo(name, kind)
|
||||
local info = mapInfo(arch, name)
|
||||
or mapInfo(files, name)
|
||||
or mapInfo(dirs, name, "directory")
|
||||
if info and kind and info.type ~= kind then return nil end
|
||||
return info
|
||||
end
|
||||
|
||||
function vfs.getDirectoryItems(name)
|
||||
local seen, items = {}, {}
|
||||
local function add(child)
|
||||
if child and not seen[child] then
|
||||
seen[child] = true
|
||||
items[#items + 1] = child
|
||||
end
|
||||
end
|
||||
for key in pairs(arch) do add(dirChild(key, name)) end
|
||||
for key in pairs(files) do add(dirChild(key, name)) end
|
||||
for key in pairs(dirs) do add(dirChild(key, name)) end
|
||||
table.sort(items)
|
||||
return items
|
||||
end
|
||||
|
||||
function vfs.mount(_, point)
|
||||
for rel, body in pairs(ARCHIVE) do
|
||||
arch[point .. "/" .. rel] = body
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
function vfs.unmount()
|
||||
for k in pairs(arch) do arch[k] = nil end
|
||||
return true
|
||||
end
|
||||
|
||||
function vfs.newFileData(data, name)
|
||||
return { __filedata = true, data = data, name = name }
|
||||
end
|
||||
|
||||
function vfs.getSaveDirectory()
|
||||
return "/tmp/pokeport-shadow-copy-test"
|
||||
end
|
||||
|
||||
function vfs.getSource()
|
||||
return nil
|
||||
end
|
||||
|
||||
local savedFs = love.filesystem
|
||||
local savedCacheFs = package.loaded["src.import.CacheFs"]
|
||||
local savedLauncherMods = package.loaded["src.mods.LauncherMods"]
|
||||
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local savedPortableBase = SaveData.portableBaseDir
|
||||
local savedPortableFs = SaveData.portableFs
|
||||
|
||||
love.filesystem = vfs
|
||||
package.loaded["src.import.CacheFs"] = nil
|
||||
package.loaded["src.mods.LauncherMods"] = nil
|
||||
-- Portable mode off: loadOptions/uninstall must stay on the stub vfs, or the
|
||||
-- checkout's real save directory would leak into the test.
|
||||
SaveData.portableBaseDir = function() return nil end
|
||||
SaveData.portableFs = function() return nil end
|
||||
local LauncherMods = require("src.mods.LauncherMods")
|
||||
|
||||
local function versionOf(id)
|
||||
for _, row in ipairs(LauncherMods.list()) do
|
||||
if row.id == id then return row.version end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- #801: the shadow copy alone resolves as the mod (sanity for the setup),
|
||||
-- and after a replace-install the panel row flips to the zip's version.
|
||||
-- Pre-fix, installZip returned success but only rewrote mods/<id>; the
|
||||
-- shadow folder enumerated first and list() kept answering 1.5.0 forever.
|
||||
resetFs()
|
||||
files[SHADOW .. "/manifest.json"] =
|
||||
('{"id":"%s","name":"Wilds of Kanto","version":"1.5.0","entry":"main.lua"}')
|
||||
:format(MOD_ID)
|
||||
files[SHADOW .. "/main.lua"] = "return function() end\n"
|
||||
eq(versionOf(MOD_ID), "1.5.0", "shadow copy resolves before the update")
|
||||
|
||||
files["imports/mods/update.zip"] = "PK\3\4update"
|
||||
local ok, err = LauncherMods.installZip("imports/mods/update.zip",
|
||||
{ replace = true, expectId = MOD_ID })
|
||||
check(ok == true, "replace over a shadow copy succeeds (" .. tostring(err) .. ")")
|
||||
eq(err, MOD_ID, "replace reports the manifest id")
|
||||
eq(versionOf(MOD_ID), NEW_VERSION,
|
||||
"list() reports the zip's version after the replace")
|
||||
check(files[SHADOW .. "/manifest.json"] == nil,
|
||||
"shadow folder is gone, so no stale copy can win first-id-wins later")
|
||||
eq(#LauncherMods.list(), 1, "the update leaves exactly one panel row")
|
||||
|
||||
-- #801: Delete from the panel must take the shadow copy with it, or the
|
||||
-- next boot resurrects the mod from the odd-named folder.
|
||||
resetFs()
|
||||
files[SHADOW .. "/manifest.json"] = ARCHIVE[MOD_ID .. "/manifest.json"]
|
||||
files["mods/" .. MOD_ID .. "/manifest.json"] = ARCHIVE[MOD_ID .. "/manifest.json"]
|
||||
ok, err = LauncherMods.uninstall(MOD_ID)
|
||||
check(ok == true, "uninstall succeeds with a shadow copy present ("
|
||||
.. tostring(err) .. ")")
|
||||
check(files[SHADOW .. "/manifest.json"] == nil,
|
||||
"uninstall removes the same-id shadow folder too")
|
||||
check(files["mods/" .. MOD_ID .. "/manifest.json"] == nil,
|
||||
"uninstall removes mods/<id>")
|
||||
eq(#LauncherMods.list(), 0, "nothing is left for the panel to show")
|
||||
|
||||
-- #834: interrupted-copy debris (mods/<id> with no manifest.json) is
|
||||
-- invisible to list() yet used to hard-block every plain re-import with
|
||||
-- "a mod named '<id>' is already installed". Both recovery paths the
|
||||
-- player can reach must work: plain re-import, and Delete.
|
||||
resetFs()
|
||||
files["mods/" .. MOD_ID .. "/gfx/a.bin"] = "x"
|
||||
eq(#LauncherMods.list(), 0, "debris tree shows no panel row")
|
||||
files["imports/mods/again.zip"] = "PK\3\4again"
|
||||
ok, err = LauncherMods.installZip("imports/mods/again.zip")
|
||||
check(ok == true, "plain re-import over debris succeeds ("
|
||||
.. tostring(err) .. ")")
|
||||
check(files["mods/" .. MOD_ID .. "/gfx/a.bin"] == nil,
|
||||
"re-import clears the debris file")
|
||||
eq(versionOf(MOD_ID), NEW_VERSION, "re-import yields a listable mod")
|
||||
|
||||
resetFs()
|
||||
files["mods/" .. MOD_ID .. "/gfx/a.bin"] = "x"
|
||||
ok, err = LauncherMods.uninstall(MOD_ID)
|
||||
check(ok == true, "uninstall clears a debris-only tree ("
|
||||
.. tostring(err) .. ")")
|
||||
check(files["mods/" .. MOD_ID .. "/gfx/a.bin"] == nil,
|
||||
"debris file is gone after uninstall")
|
||||
|
||||
-- guard rail: with a healthy install and NO debris, the duplicate gate
|
||||
-- still refuses a plain import with the same wording the panel shows
|
||||
resetFs()
|
||||
files["mods/" .. MOD_ID .. "/manifest.json"] = ARCHIVE[MOD_ID .. "/manifest.json"]
|
||||
files["imports/mods/dup.zip"] = "PK\3\4dup"
|
||||
ok, err = LauncherMods.installZip("imports/mods/dup.zip")
|
||||
check(not ok, "healthy duplicate import is still refused")
|
||||
check(tostring(err):find("already installed", 1, true),
|
||||
"refusal keeps the already installed wording")
|
||||
|
||||
-- Restore
|
||||
love.filesystem = savedFs
|
||||
SaveData.portableBaseDir = savedPortableBase
|
||||
SaveData.portableFs = savedPortableFs
|
||||
package.loaded["src.import.CacheFs"] = savedCacheFs
|
||||
package.loaded["src.mods.LauncherMods"] = savedLauncherMods
|
||||
|
||||
S.finish()
|
||||
+11
-5
@@ -700,8 +700,14 @@ do
|
||||
end
|
||||
|
||||
-- issue #133: title menu / continue overlays must not inherit LOGO2/LOGO1
|
||||
-- (blue/red UI ink). A trailing trueColor zone covers the overlay box.
|
||||
-- (blue/red UI ink). A trailing GRAYS zone covers the overlay box: through
|
||||
-- the shade-remap shader it is the identity for the box's DMG shades, so
|
||||
-- pass-through modes keep #133's white paper / black ink, while the mono
|
||||
-- and inverted display modes still recolor it with the rest of the screen
|
||||
-- (a trueColor rect skipped the shader and left a raw white hole over a
|
||||
-- CLASSIC pea-green title, #870).
|
||||
do
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
local logo2 = {
|
||||
{ 255, 255, 255 }, { 230, 197, 0 }, { 148, 156, 148 }, { 41, 99, 181 },
|
||||
}
|
||||
@@ -730,8 +736,8 @@ do
|
||||
menu.titleUiBox = { 0, 0, 12, 3 }
|
||||
game.stack:push(menu)
|
||||
local withMenu = TitleState.sgbPalettes(title, game)
|
||||
check(withMenu and #withMenu == 4 and withMenu[4].colors == false,
|
||||
"title menu adds a trueColor overlay zone")
|
||||
check(withMenu and #withMenu == 4 and withMenu[4].colors == PaletteFX.GRAYS,
|
||||
"title menu adds a DMG-grays overlay zone (#870)")
|
||||
check(withMenu[4].x == 0 and withMenu[4].y == 0
|
||||
and withMenu[4].w == 13 * 8 and withMenu[4].h == 4 * 8,
|
||||
"menu overlay covers the CONTINUE/NEW GAME box")
|
||||
@@ -739,8 +745,8 @@ do
|
||||
game.stack:pop()
|
||||
game.stack:push({ titleUiBox = { 4, 7, 19, 16 } })
|
||||
local withCont = TitleState.sgbPalettes(title, game)
|
||||
check(withCont and #withCont == 4 and withCont[4].colors == false,
|
||||
"continue-info overlay adds a trueColor zone")
|
||||
check(withCont and #withCont == 4 and withCont[4].colors == PaletteFX.GRAYS,
|
||||
"continue-info overlay adds a DMG-grays zone (#870)")
|
||||
check(withCont[4].x == 4 * 8 and withCont[4].y == 7 * 8
|
||||
and withCont[4].w == 16 * 8 and withCont[4].h == 10 * 8,
|
||||
"continue overlay matches DisplayContinueGameInfo's box")
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
-- #781: Linux launcher mouse-dead behind the pad cursor. Reproduces the
|
||||
-- X11 multi-monitor failure mode (polled love.mouse.getPosition frozen on
|
||||
-- desktop-virtual coords, so the motion yield in _updatePadCursor never
|
||||
-- fires) and asserts a host-forwarded mousepressed reclaims the pointer.
|
||||
-- Self-contained: `luajit tests/rom_importer_cursor_bug781_test.lua`.
|
||||
-- Should eventually merge into tests/rom_importer_cursor_test.lua (dofile'd
|
||||
-- by tests/run_tests.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("rom importer pad cursor #781")
|
||||
local eq = S.eq
|
||||
|
||||
local RomImporter = require("src.import.RomImporter")
|
||||
|
||||
-- Bare importer with just the pad-cursor state new() would build; isNX
|
||||
-- false keeps _updatePadCursor on the desktop path (polled motion yield),
|
||||
-- _flex nil keeps the right-stick branch out of LauncherView.
|
||||
local function makeImporter()
|
||||
return setmetatable({
|
||||
android = false,
|
||||
isNX = false,
|
||||
_flex = nil,
|
||||
_padCursor = { x = 320, y = 260 },
|
||||
_padCursorActive = false,
|
||||
_padAxis = { leftx = 0, lefty = 0, righty = 0 },
|
||||
_padDir = {},
|
||||
_padInited = true,
|
||||
}, RomImporter)
|
||||
end
|
||||
|
||||
-- Failure mode: SDL's polled mouse state stuck on coordinates outside the
|
||||
-- window (primary display away from desktop 0,0). Successive samples are
|
||||
-- identical, so the motion yield sees zero delta and never releases the
|
||||
-- pad cursor no matter how much the real mouse moves.
|
||||
local ri = makeImporter()
|
||||
love.mouse.getPosition = function() return 2960, 4130 end
|
||||
ri._padCursorActive = true
|
||||
ri:_updatePadCursor(1 / 60) -- seeds _lastMouseX/_lastMouseY
|
||||
ri:_updatePadCursor(1 / 60)
|
||||
ri:_updatePadCursor(1 / 60)
|
||||
eq(ri._padCursorActive, true,
|
||||
"frozen polled coords starve the motion yield (the #781 trap)")
|
||||
|
||||
-- The fix: the host-forwarded real press must win the pointer back, same
|
||||
-- contract as PadCursor.yieldToPointer in the overlay hosts. This is the
|
||||
-- half that un-gates LauncherView.update's click minting.
|
||||
ri:mousepressed(10, 10, 1)
|
||||
eq(ri._padCursorActive, false,
|
||||
"mousepressed reclaims the pointer even when the yield is starved (#781)")
|
||||
|
||||
-- A reclaimed pointer must stay reclaimed: the next pad-cursor tick with
|
||||
-- still-frozen polled coords may not re-arm it by itself.
|
||||
ri:_updatePadCursor(1 / 60)
|
||||
eq(ri._padCursorActive, false,
|
||||
"an idle pad tick does not re-steal the pointer after reclaim")
|
||||
|
||||
-- Regression guard for the healthy desktop path: when polled coords do
|
||||
-- move (window-relative, single monitor), the existing motion yield still
|
||||
-- releases the pad cursor without needing a click.
|
||||
local ri2 = makeImporter()
|
||||
local px = 100
|
||||
love.mouse.getPosition = function() return px, 100 end
|
||||
ri2._padCursorActive = true
|
||||
ri2:_updatePadCursor(1 / 60)
|
||||
px = 140
|
||||
ri2:_updatePadCursor(1 / 60)
|
||||
eq(ri2._padCursorActive, false,
|
||||
"real mouse motion still yields the pad cursor on sane polled coords")
|
||||
|
||||
S.finish()
|
||||
@@ -45,4 +45,12 @@ ri:play("red")
|
||||
eq(booted, "red", "unsupported system cursors still allow boot")
|
||||
eq(currentCursor, "hand", "unsupported system cursors leave the existing cursor alone")
|
||||
|
||||
-- #781: a host-forwarded real mouse press must win the pointer back from
|
||||
-- the pad cursor. While it is active LauncherView.update refuses to mint
|
||||
-- mouse clicks, so a stuck motion yield (X11 multi-monitor polled coords)
|
||||
-- left the Linux launcher mouse-dead until this reclaim existed.
|
||||
ri._padCursorActive = true
|
||||
ri:mousepressed(10, 10, 1)
|
||||
eq(ri._padCursorActive, false, "mouse press yields the pad cursor (#781)")
|
||||
|
||||
S.finish()
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
-- Yellow save export/import checks for #838: the codec used to run the
|
||||
-- Red/Blue tables unmodified for Yellow, so (1) event flags went through
|
||||
-- pokered's bit numbering even though pokeyellow renumbers wEventFlags,
|
||||
-- and (2) wPikachuHappiness (pokeyellow d46f, absolute 0x271C in SRAM)
|
||||
-- was never encoded or decoded. Yellow offsets are verified against the
|
||||
-- pokeyellow symbol file -- no local pokeyellow checkout exists, so
|
||||
-- ../pokered can only vouch for the shared R/B layout, which pokeyellow's
|
||||
-- sram.asm matches byte for byte. Needs data/generated/, same as
|
||||
-- tests/save_convert_tests.lua (its natural eventual home).
|
||||
--
|
||||
-- Run: luajit tests/save_convert_yellow_bug838_test.lua
|
||||
|
||||
package.path = "./?.lua;" .. package.path
|
||||
_G.love = require("tests.love_stub")
|
||||
|
||||
local GenSave = require("src.save_convert.GenSave")
|
||||
local SaveConvert = require("src.save_convert.SaveConvert")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
|
||||
local checks, failures = 0, 0
|
||||
local function check(cond, msg)
|
||||
checks = checks + 1
|
||||
if not cond then
|
||||
failures = failures + 1
|
||||
print("FAIL: " .. msg)
|
||||
end
|
||||
end
|
||||
|
||||
GenSave.setCharmap(loadfile("src/save_convert/data/charmap.lua")())
|
||||
|
||||
local redFlags = loadfile("src/save_convert/data/event_flags.lua")()
|
||||
local yellowFlags = loadfile("src/save_convert/data/event_flags_yellow.lua")()
|
||||
|
||||
-- Red/Blue and Yellow crosswalk sets over the same generated tables; the
|
||||
-- only differences the codec keys off are the event-flag numbering and the
|
||||
-- gameVersion tag (SaveConvert.ensureData stamps the same shape, #838).
|
||||
local shared = {
|
||||
pokemon = loadfile("data/generated/pokemon.lua")(),
|
||||
moves = loadfile("data/generated/moves.lua")(),
|
||||
items = loadfile("data/generated/items.lua")(),
|
||||
maps = loadfile("data/generated/maps.lua")(),
|
||||
}
|
||||
local redData = {
|
||||
pokemon = shared.pokemon, moves = shared.moves, items = shared.items,
|
||||
maps = shared.maps, eventFlags = redFlags,
|
||||
}
|
||||
local yellowData = {
|
||||
pokemon = shared.pokemon, moves = shared.moves, items = shared.items,
|
||||
maps = shared.maps, eventFlags = yellowFlags, gameVersion = "yellow",
|
||||
}
|
||||
|
||||
local OFF = GenSave.OFFSETS
|
||||
|
||||
-- ------------------------------------------------------------------
|
||||
-- the Yellow event-flag table itself: pokeyellow's renumbering, not a
|
||||
-- copy of the Red table under a new filename
|
||||
-- ------------------------------------------------------------------
|
||||
|
||||
check(yellowFlags.count == 2560,
|
||||
"yellow table covers the full 2560-bit wEventFlags array")
|
||||
check(type(yellowFlags.byName) == "table" and type(yellowFlags.byBit) == "table",
|
||||
"yellow table has the byName/byBit shape the codec reads")
|
||||
|
||||
-- shared names on DIFFERENT bits: Yellow inserts events ahead of them
|
||||
check(redFlags.byName.EVENT_GOT_DOME_FOSSIL == 1406
|
||||
and yellowFlags.byName.EVENT_GOT_DOME_FOSSIL == 1400,
|
||||
"EVENT_GOT_DOME_FOSSIL sits on red bit 1406 vs yellow bit 1400")
|
||||
check(redFlags.byName.EVENT_BEAT_MT_MOON_3_TRAINER_0 == 1402
|
||||
and yellowFlags.byName.EVENT_BEAT_MT_MOON_3_TRAINER_0 == 1403,
|
||||
"the Mt Moon 3 trainer block shifts +1 in yellow (Jessie & James insert)")
|
||||
check(redFlags.byName.EVENT_BEAT_SILPH_CO_11F_TRAINER_0 == 1924
|
||||
and yellowFlags.byName.EVENT_BEAT_SILPH_CO_11F_TRAINER_0 == 1925,
|
||||
"the Silph Co 11F trainer block shifts +1 in yellow")
|
||||
|
||||
-- yellow-only names the port's Yellow scripts set (data/scripts/
|
||||
-- yellow_jessie_james.lua and the catch-training tutorial): absent from
|
||||
-- the Red table, so exporting through it silently dropped them
|
||||
check(yellowFlags.byName.EVENT_BEAT_MT_MOON_3_JESSIE_JAMES == 1402
|
||||
and redFlags.byName.EVENT_BEAT_MT_MOON_3_JESSIE_JAMES == nil,
|
||||
"EVENT_BEAT_MT_MOON_3_JESSIE_JAMES is yellow bit 1402, unknown to red")
|
||||
check(yellowFlags.byName.EVENT_COMPLETED_CATCH_TRAINING == 45
|
||||
and redFlags.byName.EVENT_COMPLETED_CATCH_TRAINING == nil,
|
||||
"EVENT_COMPLETED_CATCH_TRAINING is yellow bit 45, unknown to red")
|
||||
check(yellowFlags.byName.EVENT_GOT_SQUIRTLE_FROM_OFFICER_JENNY ~= nil
|
||||
and redFlags.byName.EVENT_GOT_SQUIRTLE_FROM_OFFICER_JENNY == nil,
|
||||
"the Officer Jenny Squirtle event exists only in the yellow table")
|
||||
|
||||
-- byBit/byName agree on the renumbered entries
|
||||
check(yellowFlags.byBit[1400] == "EVENT_GOT_DOME_FOSSIL"
|
||||
and yellowFlags.byBit[1402] == "EVENT_BEAT_MT_MOON_3_JESSIE_JAMES",
|
||||
"yellow byBit resolves the renumbered bits back to their names")
|
||||
|
||||
-- ------------------------------------------------------------------
|
||||
-- wPikachuHappiness offset: d46f - wMainDataStart d2f6 = 377 past
|
||||
-- sMainData, absolute 0x271C (per the pokeyellow symbol file)
|
||||
-- ------------------------------------------------------------------
|
||||
|
||||
check(OFF.pikachuHappiness == 10012,
|
||||
"OFFSETS.pikachuHappiness is absolute 0x271C (got "
|
||||
.. tostring(OFF.pikachuHappiness) .. ")")
|
||||
check(OFF.pikachuHappiness == OFF.mainData + 377,
|
||||
"pikachuHappiness sits 377 bytes past sMainData (wram d46f - d2f6)")
|
||||
-- the byte is INSIDE the checksummed main-data window, so writing it
|
||||
-- without recomputing the checksum would brick the save on a cartridge
|
||||
check(OFF.pikachuHappiness >= OFF.checksumStart
|
||||
and OFF.pikachuHappiness < OFF.checksumEnd,
|
||||
"pikachuHappiness lies inside the main checksum window")
|
||||
|
||||
-- ------------------------------------------------------------------
|
||||
-- encode/decode gate: yellow data writes and reads the byte, R/B data
|
||||
-- leaves it alone (in Red/Blue it is current-map scratch)
|
||||
-- ------------------------------------------------------------------
|
||||
|
||||
local save = SaveData.newGame({ playerName = "RED", rivalName = "BLUE" })
|
||||
save.pikachuHappiness = 200
|
||||
-- the follower seeds happiness at 90 (src/world/PikachuFollower.lua), so
|
||||
-- 200 can only come from this table -- no default could fake the check
|
||||
|
||||
local yBytes = GenSave.encode(save, yellowData, nil)
|
||||
check(#yBytes == GenSave.SAVE_SIZE, "yellow encode produces exactly 32768 bytes")
|
||||
check(yBytes:byte(OFF.pikachuHappiness + 1) == 200,
|
||||
"yellow encode writes pikachuHappiness to 0x271C (got "
|
||||
.. yBytes:byte(OFF.pikachuHappiness + 1) .. ")")
|
||||
check(GenSave.mainChecksumValid(yBytes),
|
||||
"yellow encode still emits a valid main-data checksum")
|
||||
local yDec = GenSave.decode(yBytes, yellowData)
|
||||
check(yDec.pikachuHappiness == 200,
|
||||
"yellow decode reads pikachuHappiness back (got "
|
||||
.. tostring(yDec.pikachuHappiness) .. ")")
|
||||
|
||||
-- a yellow save whose table never held the field falls back to the
|
||||
-- follower's seed value instead of exporting friendship 0
|
||||
local noHap = SaveData.newGame({ playerName = "RED", rivalName = "BLUE" })
|
||||
noHap.pikachuHappiness = nil
|
||||
local seedBytes = GenSave.encode(noHap, yellowData, nil)
|
||||
check(seedBytes:byte(OFF.pikachuHappiness + 1) == 90,
|
||||
"a missing pikachuHappiness exports as the follower seed 90, not 0")
|
||||
|
||||
-- R/B output unchanged: same save through the red data set leaves the
|
||||
-- scratch byte zero-filled and decode never invents the field
|
||||
local rBytes = GenSave.encode(save, redData, nil)
|
||||
check(rBytes:byte(OFF.pikachuHappiness + 1) == 0,
|
||||
"red/blue encode leaves the 0x271C scratch byte zero-filled")
|
||||
check(GenSave.decode(rBytes, redData).pikachuHappiness == nil,
|
||||
"red/blue decode does not fabricate a pikachuHappiness field")
|
||||
|
||||
-- ------------------------------------------------------------------
|
||||
-- event flags land on pokeyellow bits. Independent LSB-first flag_array
|
||||
-- read (pokered home FlagAction convention: byte N/8, bit N%8) so the
|
||||
-- assertions cannot inherit a codec bit-order bug.
|
||||
-- ------------------------------------------------------------------
|
||||
|
||||
local bit = require("bit")
|
||||
local function flagBit(bytes, index)
|
||||
local b = bytes:byte(OFF.eventFlags + math.floor(index / 8) + 1)
|
||||
return bit.band(bit.rshift(b, index % 8), 1) == 1
|
||||
end
|
||||
|
||||
local fsave = SaveData.newGame({ playerName = "ASH", rivalName = "GARY" })
|
||||
fsave.flags = {
|
||||
EVENT_GOT_DOME_FOSSIL = true,
|
||||
EVENT_BEAT_MT_MOON_3_JESSIE_JAMES = true,
|
||||
EVENT_COMPLETED_CATCH_TRAINING = true,
|
||||
}
|
||||
|
||||
local yfBytes = GenSave.encode(fsave, yellowData, nil)
|
||||
check(flagBit(yfBytes, 1400) and not flagBit(yfBytes, 1406),
|
||||
"yellow export puts EVENT_GOT_DOME_FOSSIL on bit 1400, not red's 1406")
|
||||
check(flagBit(yfBytes, 1402),
|
||||
"yellow export carries EVENT_BEAT_MT_MOON_3_JESSIE_JAMES on bit 1402")
|
||||
check(flagBit(yfBytes, 45),
|
||||
"yellow export carries EVENT_COMPLETED_CATCH_TRAINING on bit 45")
|
||||
local yfDec = GenSave.decode(yfBytes, yellowData)
|
||||
check(yfDec.flags.EVENT_GOT_DOME_FOSSIL
|
||||
and yfDec.flags.EVENT_BEAT_MT_MOON_3_JESSIE_JAMES
|
||||
and yfDec.flags.EVENT_COMPLETED_CATCH_TRAINING,
|
||||
"yellow-numbered flags round-trip through decode")
|
||||
|
||||
-- the pre-fix failure mode, pinned so it can never quietly return: the
|
||||
-- red table lands the fossil on the wrong yellow bit and drops the
|
||||
-- yellow-only names entirely
|
||||
local rfBytes = GenSave.encode(fsave, redData, nil)
|
||||
check(flagBit(rfBytes, 1406) and not flagBit(rfBytes, 1400),
|
||||
"the red table writes the fossil on 1406, which yellow reads as another event")
|
||||
check(not flagBit(rfBytes, 1402) and not flagBit(rfBytes, 45),
|
||||
"the red table silently drops both yellow-only flags")
|
||||
|
||||
-- ------------------------------------------------------------------
|
||||
-- SaveConvert.ensureData substitutes the yellow flag table (and stamps
|
||||
-- gameVersion) when the caller names yellow; the versionless set still
|
||||
-- resolves red numbering, per-version cached separately (#420 pattern)
|
||||
-- ------------------------------------------------------------------
|
||||
|
||||
local yData, yErr = SaveConvert.loadData("yellow")
|
||||
check(yData ~= nil, "SaveConvert.loadData('yellow') resolves (" .. tostring(yErr) .. ")")
|
||||
check(yData and yData.gameVersion == "yellow",
|
||||
"loadData('yellow') stamps gameVersion for the codec's byte gate")
|
||||
check(yData and yData.eventFlags.byName.EVENT_GOT_DOME_FOSSIL == 1400
|
||||
and yData.eventFlags.byName.EVENT_BEAT_MT_MOON_3_JESSIE_JAMES == 1402,
|
||||
"loadData('yellow') serves the pokeyellow flag numbering")
|
||||
local dData = SaveConvert.loadData()
|
||||
check(dData and dData.eventFlags.byName.EVENT_GOT_DOME_FOSSIL == 1406
|
||||
and dData.gameVersion == nil,
|
||||
"versionless loadData still serves the red numbering, untagged")
|
||||
|
||||
print(string.format("save convert yellow #838: %d/%d checks passed",
|
||||
checks - failures, checks))
|
||||
if failures > 0 then os.exit(1) end
|
||||
Reference in New Issue
Block a user