CLOSES #604, CLOSES #666, CLOSES #716, CLOSES #727, CLOSES #763, CLOSES #781, CLOSES #784, CLOSES #799, CLOSES #801, CLOSES #810, CLOSES #828, CLOSES #834, CLOSES #838, CLOSES #849, CLOSES #852, CLOSES #857, CLOSES #863, CLOSES #864, CLOSES #867, CLOSES #869, CLOSES #870, CLOSES #872, CLOSES #839

This commit is contained in:
bryanthaboi
2026-08-05 16:36:37 -04:00
parent f56970350a
commit e2820e02c5
36 changed files with 4046 additions and 64 deletions
+29 -18
View File
@@ -800,9 +800,13 @@ M.SILPH_CO_11F = {
-- line) would touch, and the whole Silph ending -- the flag, the Master -- line) would touch, and the whole Silph ending -- the flag, the Master
-- Ball, the Saffron streets clearing -- silently never happened. -- Ball, the Saffron streets clearing -- silently never happened.
-- --
-- engageTrainer shows TEXT_SILPHCO11F_GIOVANNI as the battle text and, -- SilphCo11FDefaultScript orders it DisplayTextID TEXT_SILPHCO11F_GIOVANNI
-- via victories.lua OPP_GIOVANNI#2, sets the event on a win; a loss -- FIRST, then MoveSprite .GiovanniMovement: he speaks from behind the desk
-- sets nothing, so the trigger re-arms exactly as vanilla does. -- 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) onStep = function(game, ow, x, y)
if game.save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI then return false end 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 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 if npc.def and npc.def.name == "SILPHCO11F_GIOVANNI" then gio = npc break end
end end
if not gio or ow:trainerDefeated(gio) then return false end if not gio or ow:trainerDefeated(gio) then return false end
ow:scriptMove(gio, "down", 3, function() local TextBox = require("src.render.TextBox")
gio:facePlayer(ow.player) game.stack:push(TextBox.new(game,
ow:engageTrainer(gio, function() game.data.text._SilphCo11FGiovanniText
-- SilphCo11FGiovanniAfterBattleScript: the "Blast it all!" speech, or "Ah {PLAYER}!\nSo we meet again!",
-- then SilphCo11FTeamRocketLeavesScript behind a fade so every Silph function()
-- rocket leaves off-screen (the street rockets are handled by ow:scriptMove(gio, "down", 3, function()
-- M.SAFFRON_CITY.onEnter in story4.lua). Queued, not run here: the gio:facePlayer(ow.player)
-- battle's own callbacks are still unwinding, so queueScript starts ow:engageTrainer(gio, function()
-- it on the first idle overworld frame -- after the end-battle -- SilphCo11FGiovanniAfterBattleScript: the "Blast it all!"
-- "Arrgh!!" box victories.lua OPP_GIOVANNI#2 pushes (#722). -- speech, then SilphCo11FTeamRocketLeavesScript behind a fade so
if game.save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI then -- every Silph rocket leaves off-screen (the street rockets are
ow:queueScript(silphAftermathRows()) -- handled by M.SAFFRON_CITY.onEnter in story4.lua). Queued, not
end -- run here: the battle's own callbacks are still unwinding, so
end) -- queueScript starts it on the first idle overworld frame --
end) -- 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 return true
end, end,
onEnter = function(game, ow) onEnter = function(game, ow)
+21 -3
View File
@@ -151,9 +151,27 @@ M.POKEMON_TOWER_6F = {
-- trick, and the speedrun route this bot follows depends on it. -- trick, and the speedrun route this bot follows depends on it.
if result == "win" or battle.pokeDollEscape then if result == "win" or battle.pokeDollEscape then
game.save.flags.EVENT_BEAT_GHOST_MAROWAK = true game.save.flags.EVENT_BEAT_GHOST_MAROWAK = true
game.stack:push(TextBox.new(game, -- PokemonTower6FMarowakDepartedText (scripts/PokemonTower6F.asm)
t._PokemonTower6FSoulWasCalmedText -- is two texts, not one: the CUBONE's-mother line first, then
or "The mother's soul\nwas calmed.\012It departed to\nthe afterlife!")) -- 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 elseif result ~= "lose" then
-- .did_not_defeat: one simulated step right, off the trigger, -- .did_not_defeat: one simulated step right, off the trigger,
-- so fleeing does not leave you standing on a cell that -- so fleeing does not leave you standing on a cell that
+34 -1
View File
@@ -667,7 +667,33 @@ function love.wheelmoved(x, y)
Game:wheelmoved(x, y) Game:wheelmoved(x, y)
end 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) function love.mousepressed(x, y, button, istouch)
if not istouch then eventMouseX, eventMouseY = x, y end
if TouchEditor then if TouchEditor then
-- Android primary touch already arrived via love.touchpressed; a second -- Android primary touch already arrived via love.touchpressed; a second
-- mouse path would double-fire Done / begin a second drag. -- mouse path would double-fire Done / begin a second drag.
@@ -720,6 +746,7 @@ function love.mousereleased(x, y, button, istouch)
end end
function love.mousemoved(x, y, dx, dy, istouch) function love.mousemoved(x, y, dx, dy, istouch)
if not istouch then eventMouseX, eventMouseY = x, y end
if TouchEditor then if TouchEditor then
if love.system.getOS() == "Android" then return end if love.system.getOS() == "Android" then return end
return TouchEditor.mousemoved(x, y) return TouchEditor.mousemoved(x, y)
@@ -748,7 +775,13 @@ local quitToLauncher = false
function love.quit() function love.quit()
if editorMode and EditorApp.quit then 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 end
-- Closing the window of a running game returns to the launcher instead of -- 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 -- exiting the app, so testing a mod does not need a relaunch every time
@@ -37,6 +37,13 @@
#include "filesystem/physfs/PhysfsIo.h" #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 love
{ {
namespace android namespace android
@@ -183,6 +190,19 @@ void vibrate(double seconds)
env->DeleteLocalRef(activity); 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) bool showFilePicker(const char *destFilename)
{ {
if (destFilename == nullptr || destFilename[0] == '\0') if (destFilename == nullptr || destFilename[0] == '\0')
@@ -192,9 +212,11 @@ bool showFilePicker(const char *destFilename)
jclass activity = env->FindClass("org/love2d/android/GameActivity"); jclass activity = env->FindClass("org/love2d/android/GameActivity");
jmethodID method = env->GetStaticMethodID(activity, "showFilePicker", jmethodID method = env->GetStaticMethodID(activity, "showFilePicker",
"(Ljava/lang/String;)Z"); "(Ljava/lang/String;Ljava/lang/String;)Z");
jstring jname = env->NewStringUTF(destFilename); 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(jname);
env->DeleteLocalRef(activity); env->DeleteLocalRef(activity);
@@ -210,9 +232,11 @@ bool showCreateDocument(const char *suggestedName)
jclass activity = env->FindClass("org/love2d/android/GameActivity"); jclass activity = env->FindClass("org/love2d/android/GameActivity");
jmethodID method = env->GetStaticMethodID(activity, "showCreateDocument", jmethodID method = env->GetStaticMethodID(activity, "showCreateDocument",
"(Ljava/lang/String;)Z"); "(Ljava/lang/String;Ljava/lang/String;)Z");
jstring jname = env->NewStringUTF(suggestedName); 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(jname);
env->DeleteLocalRef(activity); env->DeleteLocalRef(activity);
@@ -116,6 +116,18 @@ public class GameActivity extends SDLActivity {
// bad ROM instead of installing it (#553). // bad ROM instead of installing it (#553).
private String pendingPickFilename = PICKED_ROM_FILENAME; private String pendingPickFilename = PICKED_ROM_FILENAME;
private static final String STATE_PENDING_PICK = "pendingPickFilename"; 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"; private static final String STATE_PENDING_CREATE = "pendingCreateSuggestedName";
// Suggested download name for the in-flight SAF create (set by showCreateDocument). // Suggested download name for the in-flight SAF create (set by showCreateDocument).
private String pendingCreateSuggestedName = "export.sav"; 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. // a recreated activity still lands under the basename it asked for.
String pick = savedInstanceState.getString(STATE_PENDING_PICK); String pick = savedInstanceState.getString(STATE_PENDING_PICK);
if (pick != null) pendingPickFilename = 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); String create = savedInstanceState.getString(STATE_PENDING_CREATE);
if (create != null) pendingCreateSuggestedName = 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. * @param destFilename basename under the app save identity (e.g.
* picked_rom.gb, picked_mod.zip, picked_save.sav) * picked_rom.gb, picked_mod.zip, picked_save.sav)
*/ */
/** Legacy single-argument entry; resolves the save dir itself. */
@Keep @Keep
public static boolean showFilePicker(String destFilename) { public static boolean showFilePicker(String destFilename) {
return showFilePicker(destFilename, null);
}
@Keep
public static boolean showFilePicker(String destFilename, String saveDir) {
GameActivity self = (GameActivity) mSingleton; GameActivity self = (GameActivity) mSingleton;
if (self == null) return false; if (self == null) return false;
if (destFilename == null || destFilename.length() == 0) { if (destFilename == null || destFilename.length() == 0) {
destFilename = PICKED_ROM_FILENAME; 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 // Reject path separators so a hostile JNI caller cannot escape the
// save identity directory. // save identity directory.
if (destFilename.indexOf('/') >= 0 || destFilename.indexOf('\\') >= 0) { 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 * return degrades on the Lua side (RomImporter export) to "Exported
* inside the app folder", which is the correct pre-KitKat behavior. * inside the app folder", which is the correct pre-KitKat behavior.
*/ */
/** Legacy single-argument entry; resolves the save dir itself. */
@Keep @Keep
public static boolean showCreateDocument(String suggestedName) { 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; if (android.os.Build.VERSION.SDK_INT < 19) return false;
// (see showFilePicker for why the import side got a pre-19 path) // (see showFilePicker for why the import side got a pre-19 path)
GameActivity self = (GameActivity) mSingleton; GameActivity self = (GameActivity) mSingleton;
@@ -657,9 +687,12 @@ public class GameActivity extends SDLActivity {
Log.d("GameActivity", "refusing unsafe create name: " + suggestedName); Log.d("GameActivity", "refusing unsafe create name: " + suggestedName);
return false; return false;
} }
File source = new File( // Route through the mounted save dir (#604, #839): Lua staged
new File(self.getExternalFilesDir(null), "save"), // pending_export.sav where physfs writes, which is not necessarily
ROM_SAVE_IDENTITY + "/" + PENDING_EXPORT_FILENAME); // 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()) { if (!source.isFile()) {
Log.d("GameActivity", "no pending export at " + source); Log.d("GameActivity", "no pending export at " + source);
return false; return false;
@@ -680,7 +713,21 @@ public class GameActivity extends SDLActivity {
} }
private File saveIdentityDir() { 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. */ /** 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) { protected void onSaveInstanceState(Bundle outState) {
super.onSaveInstanceState(outState); super.onSaveInstanceState(outState);
outState.putString(STATE_PENDING_PICK, pendingPickFilename); outState.putString(STATE_PENDING_PICK, pendingPickFilename);
outState.putString(STATE_PENDING_PICK_DIR, pendingPickSaveDir);
outState.putString(STATE_PENDING_CREATE, pendingCreateSuggestedName); outState.putString(STATE_PENDING_CREATE, pendingCreateSuggestedName);
} }
+18
View File
@@ -28,6 +28,24 @@ function SafeArea.rect()
return 0, 0, ww, wh return 0, 0, ww, wh
end 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 -- Clamp to the drawable window so a bad / mid-rotation backend cannot
-- push layout outside the surface. -- push layout outside the surface.
x = math.max(0, math.min(x, ww)) x = math.max(0, math.min(x, ww))
+12
View File
@@ -417,6 +417,18 @@ function SaveData.saveOptions(opts, fs)
#encoded, type(wrote) == "string" and tostring(#wrote) or "nothing") #encoded, type(wrote) == "string" and tostring(#wrote) or "nothing")
return nil return nil
end 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 -- the staged witness has served its purpose; the main file is verified
remove(fs, OPTIONS_TMP_FILENAME) remove(fs, OPTIONS_TMP_FILENAME)
return opts return opts
+9 -1
View File
@@ -2200,7 +2200,15 @@ end
-- pinned Play block used to walk up over the cards on a short window, which -- 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. -- is unusable, and the footer simply lives below the fold until scrolled to.
local function minPanelHeight(m) 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 end
function LauncherView.draw(imp) function LauncherView.draw(imp)
+11 -3
View File
@@ -2409,10 +2409,18 @@ function RomImporter:runActions(queue)
end end
-- Clicks are polled inside FlexLove (mouse + love.touch); host-forwarded -- Clicks are polled inside FlexLove (mouse + love.touch); host-forwarded
-- mousepressed stays inert so Android's synthesized mouse path cannot -- mousepressed mints no click, so Android's synthesized mouse path cannot
-- double-fire a tap (#553). Touch move/press/release must still reach -- 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. -- 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) function RomImporter:touchpressed(id, x, y, dx, dy, pressure)
if not self._flex then return end if not self._flex then return end
+47 -12
View File
@@ -474,6 +474,33 @@ local function removeTree(path)
fs.remove(path) fs.remove(path)
end 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 -- ------- strays: mods dropped beside the game that it cannot see
-- love.filesystem looks in two places for "mods/": the save directory, and -- -- love.filesystem looks in two places for "mods/": the save directory, and --
@@ -666,16 +693,21 @@ function LauncherMods._installZipInner(source, opts)
end end
local dest = "mods/" .. manifest.id local dest = "mods/" .. manifest.id
if fs.getInfo(dest) then local existing, installedSomewhere = sameIdTrees(fs, manifest.id)
if not opts.replace then if installedSomewhere and not opts.replace then
cleanup() cleanup()
return nil, "a mod named '" .. manifest.id .. "' is already installed" return nil, "a mod named '" .. manifest.id .. "' is already installed"
end end
-- drop the old tree before copy; enable-flag is preserved (uninstall if #existing > 0 then
-- would clear it, which would surprise an update) -- 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 local savedPrefix = CacheFs.prefix
CacheFs.prefix = "" CacheFs.prefix = ""
removeTree(dest) for _, path in ipairs(existing) do removeTree(path) end
CacheFs.prefix = savedPrefix CacheFs.prefix = savedPrefix
end end
@@ -789,14 +821,17 @@ function LauncherMods.uninstall(id)
return nil, "mod uninstall needs LOVE" return nil, "mod uninstall needs LOVE"
end end
local fs = love.filesystem local fs = love.filesystem
local dest = "mods/" .. id local trees = sameIdTrees(fs, id)
if not fs.getInfo(dest) then if #trees == 0 then
return nil, "mod '" .. id .. "' is not installed" return nil, "mod '" .. id .. "' is not installed"
end 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 local savedPrefix = CacheFs.prefix
CacheFs.prefix = "" CacheFs.prefix = ""
removeTree(dest) for _, path in ipairs(trees) do removeTree(path) end
CacheFs.prefix = savedPrefix CacheFs.prefix = savedPrefix
-- Drop the enable flag so a reinstall of the same id starts from the -- Drop the enable flag so a reinstall of the same id starts from the
-- loader's default (enabled) rather than a stale false. -- loader's default (enabled) rather than a stale false.
+17 -2
View File
@@ -820,8 +820,13 @@ function Renderer:endFrame(zones, worldZones)
-- non-battle state that opts in) uses the paper shade. "world" never -- 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 -- reaches here -- it makes the battle non-opaque, so the world pass is
-- active and this whole branch is skipped. -- 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 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) clearR, clearG, clearB = PaletteFX.paperShade(Game and Game.data)
end end
end end
@@ -1029,7 +1034,17 @@ function Renderer:endFrame(zones, worldZones)
local veil = self.screenVeil local veil = self.screenVeil
if veil and veil[2] > 0 then if veil and veil[2] > 0 then
love.graphics.setColor(veil[1], veil[1], veil[1], veil[2]) 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) love.graphics.setColor(1, 1, 1, 1)
end end
+88
View File
@@ -93,6 +93,12 @@ O.statusFlags1 = O.townVisited + 29 -- 1B
O.statusFlags4 = O.townVisited + 35 -- 1B O.statusFlags4 = O.townVisited + 35 -- 1B
O.elite4Flags = O.townVisited + 41 -- 1B O.elite4Flags = O.townVisited + 41 -- 1B
O.tradeFlags = O.townVisited + 44 -- 2B (flag_array NUM_NPC_TRADES) 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 -- Play time (wPlayTimeHours/Maxed/Minutes/Seconds/Frames) lives INSIDE the
-- sMainData window (wMainDataStart..wMainDataEnd is copied verbatim into -- sMainData window (wMainDataStart..wMainDataEnd is copied verbatim into
-- SRAM), 1866 bytes past wMainDataStart -- reached from the checksum-verified -- 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.playTimeMinutes = O.mainData + 1868 -- 1B (0-59)
O.playTimeSeconds = O.mainData + 1869 -- 1B (0-59) O.playTimeSeconds = O.mainData + 1869 -- 1B (0-59)
O.playTimeFrames = O.mainData + 1870 -- 1B (0-59, 1/60s ticks) 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.mainDataSize = 1929 -- wMainDataEnd - wMainDataStart
O.spriteData = O.mainData + O.mainDataSize 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 if save.flags[vanillaName] then save.flags[portName] = true end
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: -- FLY destinations. wTownVisitedFlag's bit index IS the town's map index:
-- engine/items/town_map.asm BuildFlyLocationsList loads the 16-bit value -- 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 -- 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.playTimeSeconds)
+ u8(bytes, O.playTimeFrames) / 60 + 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.warnings = warnings
save.rawImport = bytes -- template for a later encode(); see file header save.rawImport = bytes -- template for a later encode(); see file header
return save return save
@@ -868,6 +911,41 @@ function GenSave.encode(save, data, template)
end end
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 -- FLY destinations back into wTownVisitedFlag (see the decode note), so a
-- save exported from this port is flyable on hardware (#263). A save -- 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 -- 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) setByte(buf, O.playTimeFrames, rem - secs * 60)
end 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) local out = table.concat(buf)
-- checksums, computed last over the now-final bytes -- checksums, computed last over the now-final bytes
local outBuf = {} local outBuf = {}
+20
View File
@@ -44,6 +44,19 @@ local DATA_MODULES = {
maps = { "data.generated.maps", "data/generated/maps.lua" }, maps = { "data.generated.maps", "data/generated/maps.lua" },
charmap = { "src.save_convert.data.charmap", "src/save_convert/data/charmap.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" }, 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) local function loadTable(requirePath, filePath)
@@ -112,6 +125,9 @@ local function ensureData(gameVersion)
local data = {} local data = {}
for name, spec in pairs(DATA_MODULES) do for name, spec in pairs(DATA_MODULES) do
if name ~= "charmap" then 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]) local mod = loadCacheTable(gameVersion, spec[2])
if not mod then if not mod then
local e local e
@@ -121,6 +137,10 @@ local function ensureData(gameVersion)
data[name] = mod data[name] = mod
end end
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 crosswalks[key] = data
end end
if not charmapReady then if not charmapReady then
File diff suppressed because it is too large Load Diff
+243
View File
@@ -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 },
},
}
+8 -1
View File
@@ -68,7 +68,14 @@ function TitleState:sgbPalettes(game)
local top = game.stack and game.stack:top() local top = game.stack and game.stack:top()
local box = top and top.titleUiBox local box = top and top.titleUiBox
if box then 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 end
return z[3] and z or nil return z[3] and z or nil
end end
+13
View File
@@ -234,6 +234,11 @@ local function launchDownload(url, partAbs, doneAbs)
local batRel = "updates/dl.bat" local batRel = "updates/dl.bat"
love.filesystem.write(batRel, love.filesystem.write(batRel,
"@echo off\r\n" "@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 \"" .. "curl -fsSL --connect-timeout 15 --max-time 900 -o \""
.. partAbs .. "\" \"" .. url .. "\"\r\n" .. partAbs .. "\" \"" .. url .. "\"\r\n"
.. "type nul > \"" .. doneAbs .. "\"\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 -- stalled or run-away transfer breaks out and lets verification fail cleanly
local waited, lastSize, lastChange = 0, -1, 0 local waited, lastSize, lastChange = 0, -1, 0
while true do 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 if love.filesystem.getInfo(doneRel) then break end
local pinfo = love.filesystem.getInfo(partRel) local pinfo = love.filesystem.getInfo(partRel)
local cur = (pinfo and pinfo.size) or 0 local cur = (pinfo and pinfo.size) or 0
+43 -8
View File
@@ -440,8 +440,10 @@ function OverworldState:setMap(mapId, x, y, facing, opts)
self.entities = { self.player } self.entities = { self.player }
for _, n in ipairs(self.npcs) do table.insert(self.entities, n) end for _, n in ipairs(self.npcs) do table.insert(self.entities, n) end
-- Yellow's companion Pikachu trails the player (never in -- Yellow's companion Pikachu trails the player (never in
-- self.entities: it does not block movement, pikachu_follow.asm) -- self.entities: it does not block movement, pikachu_follow.asm).
require("src.world.PikachuFollower").onMapEntered(Game, self, opts) -- 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 -- opts.keepMusic: the Oak-escort warp keeps MUSIC_MEET_PROF_OAK
-- playing into the lab (BIT_NO_MAP_MUSIC in wStatusFlags7); -- playing into the lab (BIT_NO_MAP_MUSIC in wStatusFlags7);
@@ -1925,7 +1927,14 @@ function OverworldState:tryHiddenObject(fx, fy)
save.hiddenTaken = save.hiddenTaken or {} save.hiddenTaken = save.hiddenTaken or {}
if save.hiddenTaken[key] then return false end if save.hiddenTaken[key] then return false end
if not require("src.inventory.Bag").add(save, h.item, 1, Game.data) then 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 return true
end end
save.hiddenTaken[key] = true 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. -- 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 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 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 return
end end
Game.save.itemsTaken = Game.save.itemsTaken or {} Game.save.itemsTaken = Game.save.itemsTaken or {}
@@ -2984,7 +3003,12 @@ local function meetTrainerTheme(cls)
end end
-- Run the pre-battle text -> battle -> won text -> flags sequence. -- 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 local d = npc.def
Runtime.emit("world.trainer_engaged", { npc = npc, trainerClass = d.trainerClass, Runtime.emit("world.trainer_engaged", { npc = npc, trainerClass = d.trainerClass,
partyIndex = d.trainerParty }) partyIndex = d.trainerParty })
@@ -3005,7 +3029,7 @@ function OverworldState:engageTrainer(npc, onDone, endBattleText)
or (header and header.won and Game.data.text[header.won]) or (header and header.won and Game.data.text[header.won])
local BattleState = require("src.battle.BattleState") 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 -- TalkToTrainer (home/trainers.asm:88) prints the before-battle text
-- FIRST and only then runs `call EngageMapTrainer` / `jp -- FIRST and only then runs `call EngageMapTrainer` / `jp
-- StartTrainerBattle`, so a trainer challenged on foot gets the sting -- StartTrainerBattle`, so a trainer challenged on foot gets the sting
@@ -3048,7 +3072,12 @@ function OverworldState:engageTrainer(npc, onDone, endBattleText)
end end
end end
self:pushBattle(battle) self:pushBattle(battle)
end)) end
if skipBattleText then
startBattle()
else
Game.stack:push(TextBox.new(Game, battleText, startBattle))
end
end end
-- Shared GiveItem step for the victory rewards (pokered home/give.asm): -- 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 -- ghost NPCs on neighbor maps, y-sorted among themselves
table.sort(self.ghosts, table.sort(self.ghosts,
function(a, b) return a.npc.py + a.oy < b.npc.py + b.oy end) 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 ========================================== -- === shared FX draw bodies ==========================================
-- Each draws at flat world-canvas offsets; the tilt path wraps the -- Each draws at flat world-canvas offsets; the tilt path wraps the
+9 -2
View File
@@ -188,7 +188,7 @@ function PikachuFollower.current(ow)
return npc return npc
end 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 -- Bill's House owns a short scripted scene that deliberately keeps
-- Pikachu off the normal trailing loop. A new map instance ends it. -- Pikachu off the normal trailing loop. A new map instance ends it.
ow.pikachuBillsScene = nil ow.pikachuBillsScene = nil
@@ -200,7 +200,8 @@ function PikachuFollower.onMapEntered(game, ow, opts)
-- takes .normal_spawn_state -- map coords rebased, sprite data and -- takes .normal_spawn_state -- map coords rebased, sprite data and
-- follow command buffer left alone. Re-list the same instance and let -- follow command buffer left alone. Re-list the same instance and let
-- rebase() shift its cell; a warp arrives without it and respawns -- 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 local keep = opts and opts.keepPikachu
if keep then if keep then
table.insert(ow.npcs, keep) table.insert(ow.npcs, keep)
@@ -208,6 +209,12 @@ function PikachuFollower.onMapEntered(game, ow, opts)
return return
end end
local x, y = spawnCell(ow) 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) local npc = makeFollower(game, ow, x, y, ow.player.facing)
table.insert(ow.npcs, npc) table.insert(ow.npcs, npc)
-- entities is the draw list; passable keeps it out of collision -- 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 = ", check(live.files[OPTIONS] ~= "return { battleLayout = ",
"the main options file is healed from the copy that parsed") "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") local gone = memfs("honest")
SaveData.saveOptions({ battleLayout = "wide" }, gone) SaveData.saveOptions({ battleLayout = "wide" }, gone)
gone.files[OPTIONS] = nil gone.files[OPTIONS] = nil
+51
View File
@@ -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")
+37
View File
@@ -188,6 +188,43 @@ local leftover = 0
for _ in pairs(stagedTemps) do leftover = leftover + 1 end for _ in pairs(stagedTemps) do leftover = leftover + 1 end
eq(leftover, 0, "fallback cleans staged temp after install") 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 -- Restore
love.filesystem = savedFs love.filesystem = savedFs
SaveData.portableBaseDir = savedSaveDataPortable 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
View File
@@ -700,8 +700,14 @@ do
end end
-- issue #133: title menu / continue overlays must not inherit LOGO2/LOGO1 -- 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 do
local PaletteFX = require("src.render.PaletteFX")
local logo2 = { local logo2 = {
{ 255, 255, 255 }, { 230, 197, 0 }, { 148, 156, 148 }, { 41, 99, 181 }, { 255, 255, 255 }, { 230, 197, 0 }, { 148, 156, 148 }, { 41, 99, 181 },
} }
@@ -730,8 +736,8 @@ do
menu.titleUiBox = { 0, 0, 12, 3 } menu.titleUiBox = { 0, 0, 12, 3 }
game.stack:push(menu) game.stack:push(menu)
local withMenu = TitleState.sgbPalettes(title, game) local withMenu = TitleState.sgbPalettes(title, game)
check(withMenu and #withMenu == 4 and withMenu[4].colors == false, check(withMenu and #withMenu == 4 and withMenu[4].colors == PaletteFX.GRAYS,
"title menu adds a trueColor overlay zone") "title menu adds a DMG-grays overlay zone (#870)")
check(withMenu[4].x == 0 and withMenu[4].y == 0 check(withMenu[4].x == 0 and withMenu[4].y == 0
and withMenu[4].w == 13 * 8 and withMenu[4].h == 4 * 8, and withMenu[4].w == 13 * 8 and withMenu[4].h == 4 * 8,
"menu overlay covers the CONTINUE/NEW GAME box") "menu overlay covers the CONTINUE/NEW GAME box")
@@ -739,8 +745,8 @@ do
game.stack:pop() game.stack:pop()
game.stack:push({ titleUiBox = { 4, 7, 19, 16 } }) game.stack:push({ titleUiBox = { 4, 7, 19, 16 } })
local withCont = TitleState.sgbPalettes(title, game) local withCont = TitleState.sgbPalettes(title, game)
check(withCont and #withCont == 4 and withCont[4].colors == false, check(withCont and #withCont == 4 and withCont[4].colors == PaletteFX.GRAYS,
"continue-info overlay adds a trueColor zone") "continue-info overlay adds a DMG-grays zone (#870)")
check(withCont[4].x == 4 * 8 and withCont[4].y == 7 * 8 check(withCont[4].x == 4 * 8 and withCont[4].y == 7 * 8
and withCont[4].w == 16 * 8 and withCont[4].h == 10 * 8, and withCont[4].w == 16 * 8 and withCont[4].h == 10 * 8,
"continue overlay matches DisplayContinueGameInfo's box") "continue overlay matches DisplayContinueGameInfo's box")
+71
View File
@@ -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()
+8
View File
@@ -45,4 +45,12 @@ ri:play("red")
eq(booted, "red", "unsupported system cursors still allow boot") eq(booted, "red", "unsupported system cursors still allow boot")
eq(currentCursor, "hand", "unsupported system cursors leave the existing cursor alone") 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() S.finish()
+208
View File
@@ -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