From 5041125751e5f48f2156d2f43a0abce4aa892c08 Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Wed, 22 Jul 2026 09:45:23 -0400 Subject: [PATCH] more bugs squashed + portable mode CLOSES #28: PC in the beginning of the game isn't interactable (The one in your house) CLOSES #34: Bug when calculating exp after one or many fainted team members. CLOSES #37: No Grass Cutting CLOSES #38: Blind TMs CLOSES #53: Portable Mode CLOSES #55: changing palletes with hot key CLOSES #62: Poison status damage issue. --- README.md | 21 +++ src/battle/BattleState.lua | 23 +++- src/core/Game.lua | 20 ++- src/core/SaveData.lua | 111 +++++++++++++++- src/import/RomImporter.lua | 123 ++++++++++++++++- src/script/Commands.lua | 7 +- src/ui/BagMenu.lua | 71 ++++++---- src/world/OverworldController.lua | 24 +++- tests/parity_oaks_lab_rival_loss.lua | 67 ---------- tests/parity_viridian.lua | 190 --------------------------- tests/run_tests.lua | 58 +++++++- tools/extract/field.py | 5 +- tools/rom_manifest.json | 7 + 13 files changed, 412 insertions(+), 315 deletions(-) delete mode 100644 tests/parity_oaks_lab_rival_loss.lua delete mode 100644 tests/parity_viridian.lua diff --git a/README.md b/README.md index 4cac9638..32782c25 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,27 @@ then `love .` for later launches. Windows PowerShell scripts, the optional developer data build, test suites, and cache management are covered in [Developer Setup](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki/Guide-Developer-Setup). +## Portable Mode + +By default the game keeps your save, options, and the private ROM-derived +data cache in your OS's normal per-user app data folder. To keep everything +next to the game instead (handy for a USB stick or portable drive you carry +between computers), drop an empty file named `portable.txt` next to the +executable (or next to `main.lua`/`conf.lua` when running from source), then +launch the game. + +With `portable.txt` present: + +- `save.lua`, `save.lua.bak`, and `options.lua` are read from and written to + that same folder instead of the OS save directory. +- After a ROM import, the generated `data/generated` and `assets/generated` + cache is copied into that folder too, so a later launch (even on a + different computer, as long as the same folder comes along) reuses it + without asking for the ROM again. +- Deleting `portable.txt` switches back to the normal OS save directory; nothing + already written to either location is touched automatically, so copy files + over yourself if you want to carry existing progress across the switch. + ## Modding The game ships a native mod platform: content registries, events and hooks, diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 90225f10..e4e3500f 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -1532,10 +1532,16 @@ function BattleState:endOfTurn() -- that sets the flag also zeroes the counter, so a stale value is -- unobservable (a switch or cure downgrades Toxic to plain poison). self.sideToxic = self.sideToxic or {} - for _, pair in ipairs({ { self.player, self.enemy, "player" }, - { self.enemy, self.player, "enemy" } }) do - local b, opp, side = pair[1], pair[2], pair[3] - if b.mon.hp > 0 then + -- a battler whose opponent was already knocked out by a move this turn + -- skips its own residual (HandlePoisonBurnLeechSeed is bypassed when the + -- move faints the target); snapshot before residual so one side's + -- residual faint can't suppress the other's + local playerAlive = self.player.mon.hp > 0 + local enemyAlive = self.enemy.mon.hp > 0 + for _, pair in ipairs({ { self.player, self.enemy, "player", enemyAlive }, + { self.enemy, self.player, "enemy", playerAlive } }) do + local b, opp, side, oppAlive = pair[1], pair[2], pair[3], pair[4] + if b.mon.hp > 0 and oppAlive then local msgs = Status.residual(b, opp, self) for _, m in ipairs(msgs) do self:sayNext(prefixEnemy(m, b)) end if #msgs > 0 then self:drainNext() end -- poison/burn/seed HP moved @@ -2442,6 +2448,9 @@ end function BattleState:onFaint(battler) if battler.faintQueued then return end battler.faintQueued = true + if battler.isPlayer and self.participants then + self.participants[battler.mon] = nil + end Runtime.emit("battle.fainted", { battle = self, battler = battler }) -- the faint slide + cry ride the queue (after the move animation and -- the HP-bar drain, pokered's order); the slide finishes before the @@ -2478,9 +2487,9 @@ function BattleState:enemyMonFainted() -- exp is split among the mons that fought this enemy -- (engine/battle/experience.asm); traded mons earn x1.5; each -- participant gets the full stat exp - -- the divisor counts EVERY participant, fainted ones included - -- (DivideExpDataByNumMonsGainingExp keeps their flag bits); only the - -- living ones are actually paid + -- a mon that fainted mid-fight has had its gain-exp flag cleared + -- (RemoveFaintedPlayerMon), so it drops out of the divisor and only + -- the surviving participants are counted and paid local participants, alive = 0, {} for _, mon in ipairs(self.game.save.party) do if self.participants and self.participants[mon] then diff --git a/src/core/Game.lua b/src/core/Game.lua index caffd037..940dd720 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -283,10 +283,22 @@ function Game:keypressed(key) self:zoomStep(1) return elseif key == "2" then - -- cycle COLORS (GBC / OG / OG INV / GBC INV / CLASSIC); always on - local PaletteFX = require("src.render.PaletteFX") - self.save.options.colors = PaletteFX.cycleMode() - self:writeOptions() + -- cycle COLORS (GBC / OG / OG INV / GBC INV / CLASSIC); the pack change + -- forces Game.overworld:reloadMap, which rebuilds the live NPC array, so + -- hold it while a warp/transition or an on-screen scripted cutscene is + -- driving the overworld rather than tear the escort's NPCs out mid-move + local ow = self.overworld + local top = self.stack:top() + local busy = ow and (ow.transitioning + or (top == ow and ( + (ow.runner and ow.runner.isRunning and ow.runner:isRunning()) + or (ow.scriptMoves and #ow.scriptMoves > 0) + or ow.engaging or ow.emote))) + if not busy then + local PaletteFX = require("src.render.PaletteFX") + self.save.options.colors = PaletteFX.cycleMode() + self:writeOptions() + end return elseif key == "3" then -- cycle TILT OFF → 15 → 35 → 50 → OFF (mnemonic: 3D), free-roam only diff --git a/src/core/SaveData.lua b/src/core/SaveData.lua index 56b540be..7d4ec4f9 100644 --- a/src/core/SaveData.lua +++ b/src/core/SaveData.lua @@ -27,6 +27,107 @@ local OPTIONS_FILENAME = "options.lua" local BACKUP_FILENAME = FILENAME .. ".bak" local TMP_FILENAME = FILENAME .. ".tmp" +-- ------- portable mode +-- LÖVE's save directory is always the OS per-user path derived from the +-- identity (conf.lua), so it can't be relocated at runtime. Portable mode +-- instead drops a plain-Lua io.* filesystem next to the game whenever a +-- `portable.txt` marker sits beside the executable/source, letting a USB +-- copy carry its own save.lua/options.lua (and, through options.lua, the +-- mod enable-state) rather than leaving them on the host machine. +local PORTABLE_MARKER = "portable.txt" +local SEP = package.config:sub(1, 1) + +local portableChecked = false +local portableBase = false -- resolved base dir when active, else false +local portableFsCache = nil + +local function pathExists(path) + local f = io.open(path, "rb") + if not f then return false end + f:close() + return true +end + +-- an io.* filesystem exposing the love.filesystem subset the save/options +-- round-trip needs (getInfo/read/write/remove), rooted at `dir` +local function makePortableFs(dir) + local function full(name) return dir .. SEP .. name end + return { + getInfo = function(name) + if not pathExists(full(name)) then return nil end + return { type = "file" } + end, + read = function(name) + local f = io.open(full(name), "rb") + if not f then return nil, "no file: " .. name end + local data = f:read("*a") + f:close() + return data + end, + write = function(name, data) + local f, err = io.open(full(name), "wb") + if not f then return false, err end + f:write(data) + f:close() + return true + end, + remove = function(name) + os.remove(full(name)) + return true + end, + } +end + +local function detectPortable() + if portableChecked then return portableBase end + portableChecked = true + portableBase = false + if not (love and love.filesystem) then return false end + local candidates = {} + if love.filesystem.getSourceBaseDirectory then + candidates[#candidates + 1] = love.filesystem.getSourceBaseDirectory() + end + if love.filesystem.getSource then + candidates[#candidates + 1] = love.filesystem.getSource() + end + for _, base in ipairs(candidates) do + if base and base ~= "" and pathExists(base .. SEP .. PORTABLE_MARKER) then + portableBase = base + break + end + end + return portableBase +end + +function SaveData.isPortable() + return detectPortable() ~= false +end + +-- the raw portable-folder path (for callers building their own nested +-- paths, e.g. the ROM-derived asset cache), or nil when portable mode +-- is off +function SaveData.portableBaseDir() + return detectPortable() or nil +end + +-- the io.* filesystem for the active portable folder, or nil when off +function SaveData.portableFs() + local base = detectPortable() + if not base then return nil end + if not portableFsCache then portableFsCache = makePortableFs(base) end + return portableFsCache +end + +-- Resolve the filesystem a persistent read/write should land on: an +-- explicitly injected non-love fs (headless tests, the mod loader's stub) +-- always wins; otherwise portable mode reroutes off the OS save directory. +local function persistFs(fs) + if fs and love and love.filesystem and fs ~= love.filesystem then + return fs + end + return SaveData.portableFs() or fs or (love and love.filesystem) +end + -- Port + original Options menu defaults. Missing keys on load are filled -- from this table so old options.lua files stay compatible. function SaveData.defaultOptions() @@ -94,7 +195,7 @@ end -- love.filesystem, so the mod loader's injected filesystem can carry the -- options round-trip headless (no love global). function SaveData.saveOptions(opts, fs) - fs = fs or love.filesystem + fs = persistFs(fs) opts = SaveData.mergeOptions(opts) -- modOptions is per-mod nested state: fold the on-disk sub-tree -- underneath (newest value winning per key) so one caller's partial @@ -123,7 +224,7 @@ function SaveData.saveOptions(opts, fs) end function SaveData.loadOptions(fs) - fs = fs or love.filesystem + fs = persistFs(fs) local data, err = readTable(fs, OPTIONS_FILENAME) if not data then if fs.getInfo(OPTIONS_FILENAME) then @@ -311,7 +412,7 @@ end) -- afterwards either way SaveData.addCoreMigration(1, function(save) if type(save.options) == "table" - and not love.filesystem.getInfo(OPTIONS_FILENAME) then + and not persistFs(nil).getInfo(OPTIONS_FILENAME) then SaveData.saveOptions(save.options) end end) @@ -342,7 +443,7 @@ function SaveData.save(data, mods) if k ~= "options" then gameOnly[k] = v end end local encoded = SaveSerializer.encode(gameOnly) - local fs = love.filesystem + local fs = persistFs(nil) if fs.getInfo(FILENAME) then local prev = fs.read(FILENAME) if prev then fs.write(BACKUP_FILENAME, prev) end @@ -371,7 +472,7 @@ end -- or corrupt and a staged/backup copy was promoted; Game surfaces the -- recovery on the load report function SaveData.load() - local fs = love.filesystem + local fs = persistFs(nil) local data, err = readTable(fs, FILENAME) local recovered if not data then diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index 722190af..a2c3a132 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -2,7 +2,7 @@ local RomImporter = {} RomImporter.__index = RomImporter local ROM_SHA1 = "ea9bcae617fdf159b045185467ae58b2e4a48b9a" -local CACHE_MARKER = "rom-cache-v6:" .. ROM_SHA1 +local CACHE_MARKER = "rom-cache-v7:" .. ROM_SHA1 local MARKER_PATH = "rom-cache.complete" local COMMUNITY_URL = "https://bois.icu" local TRUST_WARNING = "if you did not get this from bryanthaboi's github " .. @@ -38,8 +38,124 @@ local function sourceTreeHasData() return real == love.filesystem.getSource() end +-- ------- portable ROM-derived asset cache +-- +-- The extracted cache (data/generated, assets/generated) is written +-- exclusively through love.filesystem.write, which always targets the OS +-- save directory -- it cannot be redirected to an arbitrary folder. So a +-- portable install mirrors the cache both ways instead: after a fresh +-- import, every generated file is copied out to the portable folder +-- (SaveData.portableFs's io.* companion); on a later boot -- possibly on a +-- different machine sharing the same USB copy -- a matching portable +-- cache is copied back into the save directory before the normal +-- isReady() check runs, so nothing downstream needs to know the cache +-- ever lived anywhere but the save directory. +local PORTABLE_CACHE_DIRS = { "data/generated", "assets/generated" } +local PORTABLE_MANIFEST_NAME = "portable_cache_manifest.txt" +local PORTABLE_SEP = package.config:sub(1, 1) + +local function walkLoveDir(dir, out) + out = out or {} + for _, name in ipairs(love.filesystem.getDirectoryItems(dir)) do + local full = dir .. "/" .. name + local info = love.filesystem.getInfo(full) + if info and info.type == "directory" then + walkLoveDir(full, out) + elseif info and info.type == "file" then + out[#out + 1] = full + end + end + return out +end + +local function portablePath(base, relPath) + return base .. PORTABLE_SEP .. relPath:gsub("/", PORTABLE_SEP) +end + +local function ensurePortableDir(fullDirPath) + if love.system.getOS() == "Windows" then + os.execute(('mkdir "%s" 2>NUL'):format(fullDirPath)) + else + os.execute(("mkdir -p '%s' 2>/dev/null"):format(fullDirPath)) + end +end + +-- copies data/generated + assets/generated out to the portable folder +-- after a fresh import; a plain-text manifest travels alongside so a +-- later sync-in knows exactly which files to copy back without needing +-- to list an arbitrary external directory (io.* has no listdir) +local function syncCacheToPortable() + local SaveData = require("src.core.SaveData") + local base = SaveData.portableBaseDir() + if not base then return end + local manifest = {} + for _, dir in ipairs(PORTABLE_CACHE_DIRS) do + if love.filesystem.getInfo(dir, "directory") then + for _, relPath in ipairs(walkLoveDir(dir)) do + local data = love.filesystem.read(relPath) + if data then + local outPath = portablePath(base, relPath) + local outDir = outPath:match("^(.*)" .. PORTABLE_SEP .. "[^" .. PORTABLE_SEP .. "]+$") + if outDir then ensurePortableDir(outDir) end + local f, err = io.open(outPath, "wb") + if f then + f:write(data) + f:close() + manifest[#manifest + 1] = relPath + else + require("src.core.Logger").error( + "portable cache: could not write %s: %s", outPath, tostring(err)) + end + end + end + end + end + local mf = io.open(base .. PORTABLE_SEP .. PORTABLE_MANIFEST_NAME, "wb") + if mf then + mf:write(table.concat(manifest, "\n")) + mf:close() + end + local mk = io.open(base .. PORTABLE_SEP .. MARKER_PATH, "wb") + if mk then + mk:write(CACHE_MARKER) + mk:close() + end +end + +-- copies a matching portable cache back into the save directory before +-- isReady() runs its normal check; a mismatched or missing marker means +-- either no portable cache exists yet or it belongs to an older build, so +-- it is left alone and a fresh import proceeds as usual +local function syncCacheFromPortable() + local SaveData = require("src.core.SaveData") + local base = SaveData.portableBaseDir() + if not base then return end + local markerFile = io.open(base .. PORTABLE_SEP .. MARKER_PATH, "rb") + if not markerFile then return end + local marker = markerFile:read("*a") + markerFile:close() + if marker ~= CACHE_MARKER then return end + local manifestFile = io.open(base .. PORTABLE_SEP .. PORTABLE_MANIFEST_NAME, "rb") + if not manifestFile then return end + local manifestBody = manifestFile:read("*a") + manifestFile:close() + for relPath in manifestBody:gmatch("[^\r\n]+") do + local f = io.open(portablePath(base, relPath), "rb") + if f then + local data = f:read("*a") + f:close() + love.filesystem.write(relPath, data) + end + end + love.filesystem.write(MARKER_PATH, CACHE_MARKER) +end + function RomImporter.isReady() if sourceTreeHasData() then return true end + if love.filesystem.read(MARKER_PATH) ~= CACHE_MARKER + and require("src.core.SaveData").isPortable() then + syncCacheFromPortable() + end return love.filesystem.read(MARKER_PATH) == CACHE_MARKER and allRequiredFilesExist() end @@ -211,6 +327,11 @@ function RomImporter:startData(data, displayName) collectgarbage("collect") local ok, writeError = love.filesystem.write(MARKER_PATH, CACHE_MARKER) if not ok then error("could not finish the private cache: " .. tostring(writeError)) end + if require("src.core.SaveData").isPortable() then + self.status = "Copying data to the portable folder" + coroutine.yield() + syncCacheToPortable() + end self.state = "complete" self.status = "Ready" self.detail = "Starting Pokemon Red..." diff --git a/src/script/Commands.lua b/src/script/Commands.lua index 8731ff09..63e11515 100644 --- a/src/script/Commands.lua +++ b/src/script/Commands.lua @@ -397,6 +397,7 @@ local function toggleObject(ctx, mapId, objName, visible) -- positions mid-cutscene) local ow = ctx.overworld if not ow or ow.map.id ~= mapId then return end + ow.npcPool = ow.npcPool or {} if visible then for _, n in ipairs(ow.npcs) do if n.def.name == objName then return end @@ -407,12 +408,16 @@ local function toggleObject(ctx, mapId, objName, visible) local npc = NPC.new(ctx.game.data, mapId, obj) table.insert(ow.npcs, npc) table.insert(ow.entities, npc) + ow.npcPool[npc.id] = npc return end end else for i = #ow.npcs, 1, -1 do - if ow.npcs[i].def.name == objName then table.remove(ow.npcs, i) end + if ow.npcs[i].def.name == objName then + ow.npcPool[ow.npcs[i].id] = nil + table.remove(ow.npcs, i) + end end for i = #ow.entities, 1, -1 do local e = ow.entities[i] diff --git a/src/ui/BagMenu.lua b/src/ui/BagMenu.lua index fc637eed..cdbc0990 100644 --- a/src/ui/BagMenu.lua +++ b/src/ui/BagMenu.lua @@ -296,37 +296,52 @@ local function useOn(game, battle, id, target, list, moveIndex) showMessages(game, payload) -- failed end +local function pickTargetAndUse(game, battle, id, list) + -- pick a target from the party + -- the ETHERs and PP UP open the move menu after picking a mon + -- (ItemUsePPRestore / ItemUsePPUp); the ELIXERs hit every move + local wantsMove = id == "ETHER" or id == "MAX_ETHER" or id == "PP_UP" + require("src.ui.Screens").push(game, "PartyMenu", { + pickOnly = true, + onSwitch = function(mon) + if not wantsMove then + useOn(game, battle, id, mon, list) + return + end + local rows = {} + for mi, mv in ipairs(mon.moves) do + local mdef = game.data.moves[mv.id] + table.insert(rows, { + value = mi, + label = mdef and mdef.name or mv.id, + right = ("%d"):format(mv.pp), + }) + end + game.stack:push(ListMenu.new(game, "Which move?", rows, { + onChoose = function(row, l) + l:close() + useOn(game, battle, id, mon, list, row.value) + end, + })) + end, + }) +end + local function useItem(game, battle, id, list) local def = game.data.items[id] if ItemEffects.needsTarget(id, def) and not ItemEffects.isBall(id) then - -- pick a target from the party - -- the ETHERs and PP UP open the move menu after picking a mon - -- (ItemUsePPRestore / ItemUsePPUp); the ELIXERs hit every move - local wantsMove = id == "ETHER" or id == "MAX_ETHER" or id == "PP_UP" - require("src.ui.Screens").push(game, "PartyMenu", { - pickOnly = true, - onSwitch = function(mon) - if not wantsMove then - useOn(game, battle, id, mon, list) - return - end - local rows = {} - for mi, mv in ipairs(mon.moves) do - local mdef = game.data.moves[mv.id] - table.insert(rows, { - value = mi, - label = mdef and mdef.name or mv.id, - right = ("%d"):format(mv.pp), - }) - end - game.stack:push(ListMenu.new(game, "Which move?", rows, { - onChoose = function(row, l) - l:close() - useOn(game, battle, id, mon, list, row.value) - end, - })) - end, - }) + -- TMs/HMs boot up and announce their move before the target picker + -- (ItemUseTMHM: BootedUpTMText / BootedUpHMText + TeachMachineMoveText) + if def and def.machine then + local moveDef = game.data.moves[def.machine.move] + local moveName = moveDef and moveDef.name or def.machine.move + local booted = def.machine.kind == "HM" + and "Booted up an HM!" or "Booted up a TM!" + showMessages(game, { booted, ("It contained\n%s!"):format(moveName) }, + function() pickTargetAndUse(game, battle, id, list) end) + return + end + pickTargetAndUse(game, battle, id, list) else useOn(game, battle, id, nil, list) end diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index 90bc747d..11169be8 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -1708,8 +1708,10 @@ function OverworldState:tryCut(fx, fy) -- chain-cut "ornamental bushes" around Saffron and Celadon. local ts = self.map.def.tileset local tile = self.map:cellTile(fx, fy) + local isGrass = (ts == "OVERWORLD" and tile == 0x52) if not ((ts == "OVERWORLD" and tile == 0x3d) - or (ts == "GYM" and tile == 0x50)) then + or (ts == "GYM" and tile == 0x50) + or isGrass) then return false end local bx, by = math.floor(fx / 2), math.floor(fy / 2) @@ -1718,7 +1720,7 @@ function OverworldState:tryCut(fx, fy) for _, sw in ipairs(Game.data.field.cutTreeSwaps) do if sw.before == block then swap = sw break end end - if not swap or self.map:isWalkableCell(fx, fy) then return false end + if not swap or (not isGrass and self.map:isWalkableCell(fx, fy)) then return false end local mon = self:partyKnows("CUT") if not mon then return false end -- gen 1 confirms nothing (engine/overworld/cut.asm UsedCut): the @@ -1736,7 +1738,11 @@ function OverworldState:tryCut(fx, fy) local finish = function() require("src.core.Sound").play(Game.data, "Cut") end - if ts == "OVERWORLD" then + if isGrass then + -- AnimCut .grass: tall grass gets the leaf-swirl / dust puff, not + -- the tree-split slide + self:startDustAnim(fx, fy, finish) + elseif ts == "OVERWORLD" then -- the tree splits in half and slides apart (AnimCut .cutTreeLoop); -- the GYM plant keeps the shared dust/leaf puff self:startCutTreeAnim(fx, fy, finish) @@ -1822,8 +1828,10 @@ function OverworldState:useCutFieldMove() -- "nothing to cut" in vanilla local ts = self.map.def.tileset local tile = self.map:cellTile(fx, fy) + local isGrass = (ts == "OVERWORLD" and tile == 0x52) if not ((ts == "OVERWORLD" and tile == 0x3d) - or (ts == "GYM" and tile == 0x50)) then + or (ts == "GYM" and tile == 0x50) + or isGrass) then return "nothing" end local bx, by = math.floor(fx / 2), math.floor(fy / 2) @@ -1832,7 +1840,7 @@ function OverworldState:useCutFieldMove() for _, sw in ipairs(Game.data.field.cutTreeSwaps) do if sw.before == block then swap = sw break end end - if not swap or self.map:isWalkableCell(fx, fy) then return "nothing" end + if not swap or (not isGrass and self.map:isWalkableCell(fx, fy)) then return "nothing" end return "ok" end @@ -2004,9 +2012,11 @@ function OverworldState:openPC(onDone) end table.insert(items, { label = "LOG OFF", onSelect = logOff }) -- pokered sets BIT_NO_MENU_BUTTON_SOUND for the whole PC session - -- (engine/overworld/pokecenter_pc.asm / player_pc.asm) + -- (engine/overworld/pokecenter_pc.asm / player_pc.asm); DisplayPCMainMenu + -- calls TextBoxBorder with c=14 (interior width, +2 for the border), so + -- tw here (total width) is 16 Game.stack:push(Menu.new(Game, items, - { tx = 0, ty = 0, tw = 14, th = #items * 2 + 2, onCancel = logOff, + { tx = 0, ty = 0, tw = 16, th = #items * 2 + 2, onCancel = logOff, noSound = true })) end diff --git a/tests/parity_oaks_lab_rival_loss.lua b/tests/parity_oaks_lab_rival_loss.lua deleted file mode 100644 index d05c69a9..00000000 --- a/tests/parity_oaks_lab_rival_loss.lua +++ /dev/null @@ -1,67 +0,0 @@ --- Parity: losing the Oak's Lab starter rival must not black out. --- pret HandlePlayerBlackOut special-cases OPP_RIVAL1 on OAKS_LAB: --- Rival1WinText only, no PlayerBlackedOutText, no warp / half-money. --- OaksLabRivalEndBattleScript then HealParty and continues either way. --- Self-contained; run via `luajit tests/parity_oaks_lab_rival_loss.lua`. -package.path = "./?.lua;./?/init.lua;" .. package.path -if not _G.love then _G.love = require("tests.love_stub") end -local BattleState = require("src.battle.BattleState") -local S = require("tests.harness").suite("parity oaks lab rival loss") -local check, eq = S.check, S.eq - -local function saidBlackout(b) - for _, m in ipairs(b.said) do - if tostring(m):find("blacked") then return true end - end - return false -end - -local function saidRivalTaunt(b) - for _, m in ipairs(b.said) do - if tostring(m):find("great or what") then return true end - end - return false -end - -local function rivalBattle(mapId) - return { - kind = "trainer", - oppClass = "OPP_RIVAL1", - result = nil, - afterQueue = nil, - said = {}, - data = { - text = { _Rival1WinText = "{RIVAL}: Yeah! Am\nI great or what?" }, - }, - game = { - save = { - party = { { species = "SQUIRTLE", hp = 0, stats = { hp = 20 } } }, - player = { name = "RED", rival = "BLUE", map = mapId }, - }, - overworld = { map = { id = mapId } }, - }, - sayNext = function(self, m) self.said[#self.said + 1] = m end, - say = function(self, m) self.said[#self.said + 1] = m end, - } -end - -do - local b = rivalBattle("OAKS_LAB") - BattleState.playerMonFainted(b) - eq(b.result, "lose", "lab rival wipe is still a loss") - check(saidRivalTaunt(b), "lab rival wipe shows Rival1WinText") - check(not saidBlackout(b), "lab rival wipe does not say blacked out") - check(BattleState.isOaksLabStarterRival(b), "oaks-lab starter-rival detector") -end - -do - local b = rivalBattle("ROUTE_22") - BattleState.playerMonFainted(b) - eq(b.result, "lose", "route-22 rival wipe is a loss") - check(saidRivalTaunt(b), "route-22 rival still shows Rival1WinText") - check(saidBlackout(b), "route-22 rival still blacks out") - check(not BattleState.isOaksLabStarterRival(b), - "route-22 is not the oaks-lab special case") -end - -S.finish() diff --git a/tests/parity_viridian.lua b/tests/parity_viridian.lua deleted file mode 100644 index 370118b5..00000000 --- a/tests/parity_viridian.lua +++ /dev/null @@ -1,190 +0,0 @@ --- Parity test, Viridian City's two old men + the Pokédex object swap. --- --- pokered has TWO old men on this map (data/maps/objects/ViridianCity.asm): --- --- object_event 18, 9, SPRITE_GAMBLER_ASLEEP, STAY, NONE, ..._OLD_MAN_SLEEPY --- object_event 17, 5, SPRITE_GAMBLER, WALK, LEFT_RIGHT, ..._OLD_MAN --- --- The sleeper only ever grumbles "private property" and shoves you back --- down; he never wakes, moves or hides. The coffee ask and the catch --- tutorial belong to the walking man, who starts OFF --- (data/maps/toggleable_objects.asm) and is swapped in for the sleeper --- when Oak hands over the Pokédex (scripts/OaksLab.asm:602-606). The --- north corridor is gated on EVENT_GOT_POKEDEX at exactly (19,9) --- (ViridianCityCheckGotPokedexScript), not on either man's visibility. --- --- All three of those were wrong at once: the port merged both men into --- the sleeper, never ran the swap (so the walking man stayed hidden for --- the entire game), and gated the corridor on the sleeper being hidden -- --- which made talking to him and answering "yes, I'm in a hurry" the only --- way out of Viridian. Self-contained; run via `luajit tests/parity_viridian.lua`. -package.path = "./?.lua;./?/init.lua;" .. package.path -if not _G.love then _G.love = require("tests.love_stub") end -local Data = require("src.core.Data") -if not (Data.maps and Data.maps.VIRIDIAN_CITY) then Data:load() end --- The gate builds a real TextBox, which wants a loaded Font atlas we have --- no graphics device for. The hook requires it lazily, so a stub in --- package.loaded is enough to exercise the branch headlessly -- we only --- care that the step was blocked and that a box was pushed, not what it --- rendered (parity_flavor already covers the text labels themselves). -package.loaded["src.render.TextBox"] = { - new = function(_, text, onDone) return { text = text, onDone = onDone } end, -} -local init = require("data.scripts.init") -local S = require("tests.harness").suite("parity viridian") -local check = S.check - -local function rowsOf(script) - return type(script) == "table" and script or nil -end - --- find the first row whose command is `cmd`; returns index, row -local function findRow(rows, cmd, arg2) - for i, row in ipairs(rows or {}) do - if row[1] == cmd and (arg2 == nil or row[2] == arg2) then return i, row end - end -end - --- --------------------------------------------------------------------- --- (1) the sleeper is text-and-shove only --- --------------------------------------------------------------------- - -local sleepy = rowsOf(init.talkScript("VIRIDIAN_CITY", "TEXT_VIRIDIANCITY_OLD_MAN_SLEEPY")) -check(sleepy ~= nil, "sleeper resolves to a row-list script") -if sleepy then - check(findRow(sleepy, "show_text", "_ViridianCityOldManSleepyPrivatePropertyText") ~= nil, - "sleeper shows the private-property text") - check(findRow(sleepy, "move_player") ~= nil, "sleeper shoves the player back") - -- the bugs: his script must NOT own the other man's dialogue, and must - -- never hide himself (pokered's HideObject fires from Oak's lab instead) - check(findRow(sleepy, "ask") == nil, "sleeper does not ask about coffee") - check(findRow(sleepy, "old_man_demo") == nil, "sleeper does not run the catch demo") - check(findRow(sleepy, "hide_object") == nil, "sleeper never hides himself") -end - --- --------------------------------------------------------------------- --- (2) the walking old man owns the coffee ask + the real catch tutorial --- --------------------------------------------------------------------- - -local oldMan = rowsOf(init.talkScript("VIRIDIAN_CITY", "TEXT_VIRIDIANCITY_OLD_MAN")) --- a Lua function handler here would mean data/scripts/flavor/viridian_city.lua --- (which loads AFTER story.lua) had silently won the talk-table merge back -check(oldMan ~= nil, "walking old man resolves to a row-list script, not a handler") -if oldMan then - local askAt = findRow(oldMan, "ask", "_ViridianCityOldManHadMyCoffeeNowText") - check(askAt ~= nil, "walking old man asks the coffee question") - check(findRow(oldMan, "old_man_demo") ~= nil, "walking old man runs the catch demo") - -- polarity: the question is "Are you in a hurry?", so YES is the refusal - -- (ViridianCityOldManText: `and a / jr z, .refused` -- wCurrentMenuItem 0 - -- is YES). jump_if_true must therefore land on the "Time is money" line. - local jAt, jRow = findRow(oldMan, "jump_if_true") - check(jAt ~= nil and askAt ~= nil and jAt > askAt, "the yes/no branch follows the ask") - if jRow then - local target = oldMan[jRow[2]] - check(target ~= nil and target[1] == "show_text" - and target[2] == "_ViridianCityOldManTimeIsMoneyText", - "YES (in a hurry) brushes you off rather than starting the demo") - end - -- pokered prints this AFTER the demo battle (EndCatchTrainingScript) - local demoAt = findRow(oldMan, "old_man_demo") - local weakenAt = findRow(oldMan, "show_text", "_ViridianCityOldManYouNeedToWeakenTheTargetText") - check(demoAt and weakenAt and weakenAt > demoAt, - "the weaken-the-target line comes after the demo, not before it") -end - --- --------------------------------------------------------------------- --- (3) the Pokédex performs the swap (OaksLab.asm:602-606) --- --------------------------------------------------------------------- - -local oak1 = rowsOf(init.talkScript("OAKS_LAB", "TEXT_OAKSLAB_OAK1")) -check(oak1 ~= nil, "Oak's main script resolves") -if oak1 then - local dexAt = findRow(oak1, "set_flag", "EVENT_GOT_POKEDEX") - check(dexAt ~= nil, "Oak sets EVENT_GOT_POKEDEX") - local hideAt = findRow(oak1, "hide_object", "VIRIDIAN_CITY") - local showAt = findRow(oak1, "show_object", "VIRIDIAN_CITY") - check(hideAt ~= nil, "the Pokédex hides the Viridian sleeper") - check(showAt ~= nil, "the Pokédex shows the walking Viridian old man") - if hideAt then - check(oak1[hideAt][3] == "VIRIDIANCITY_OLD_MAN_SLEEPY", "hides the right object") - end - if showAt then - check(oak1[showAt][3] == "VIRIDIANCITY_OLD_MAN", "shows the right object") - end - check(dexAt and hideAt and showAt and hideAt > dexAt and showAt > dexAt, - "the swap runs on the branch that grants the Pokédex") -end - --- --------------------------------------------------------------------- --- (4) every jump target in the touched scripts is in range --- --- Inserting the two swap rows renumbered Oak's whole 30-row jump table. --- An off-by-one there is invisible until a branch silently runs the wrong --- line, so check every target lands on a real row (or the end sentinel, --- #rows + 1) across both files. --- --------------------------------------------------------------------- - -local JUMPS = { jump = true, jump_if_true = true, jump_if_false = true } -local checkedJumps = 0 -for _, modname in ipairs({ "data.scripts.oaks_lab", "data.scripts.story" }) do - local mod = require(modname) - -- oaks_lab returns one map's table; story returns { [mapId] = table } - local maps = mod.talk and { [modname] = mod } or mod - for mapId, m in pairs(maps) do - if type(m) == "table" and m.talk then - for const, script in pairs(m.talk) do - local rows = rowsOf(script) - if rows then - for i, row in ipairs(rows) do - if type(row) == "table" and JUMPS[row[1]] then - local t = row[2] - checkedJumps = checkedJumps + 1 - check(type(t) == "number" and t >= 1 and t <= #rows + 1, - ("%s/%s row %d: %s -> %s in range"):format( - mapId, const, i, tostring(row[1]), tostring(t))) - end - end - end - end - end - end -end -check(checkedJumps > 0, "found jump rows to range-check (got " .. checkedJumps .. ")") - --- --------------------------------------------------------------------- --- (5) the corridor gate keys off EVENT_GOT_POKEDEX at (19,9) --- --------------------------------------------------------------------- - -local onStep = init.get("VIRIDIAN_CITY").onStep -check(type(onStep) == "function", "VIRIDIAN_CITY has an onStep hook") - -local function step(flags, x, y) - local pushed = 0 - local game = { - save = { flags = flags, inventory = {}, objectToggles = {} }, - data = Data, - stack = { push = function() pushed = pushed + 1 end }, - } - local ow = { player = {}, scriptMove = function() end } - local ok, blocked = pcall(onStep, game, ow, x, y) - return ok, blocked, pushed -end - -if type(onStep) == "function" then - local ok, blocked, pushed = step({}, 19, 9) - check(ok, "onStep runs at the gate cell") - check(ok and blocked == true, "(19,9) is blocked without the Pokédex") - check(ok and pushed == 1, "being blocked shows a text box") - - local ok2, blocked2 = step({ EVENT_GOT_POKEDEX = true }, 19, 9) - check(ok2 and blocked2 ~= true, "(19,9) is walkable once you have the Pokédex") - - -- the old port blocked the whole 3-wide corridor (x 17-19, y<=8); pokered - -- blocks one cell, and the sleeper/girl bodies do the rest - local ok3, blocked3 = step({}, 19, 8) - check(ok3 and blocked3 ~= true, "(19,8) north of the gate is not itself gated") - local ok4, blocked4 = step({}, 17, 8) - check(ok4 and blocked4 ~= true, "(17,8) is not gated (only (19,9) triggers)") -end - -S.finish() diff --git a/tests/run_tests.lua b/tests/run_tests.lua index 85b08d76..81b6e3ce 100644 --- a/tests/run_tests.lua +++ b/tests/run_tests.lua @@ -931,6 +931,61 @@ do Game.save.inventory.EXP_ALL = nil end + -- these build throwaway battles/mons, which normally roll DVs off the + -- ambient love.math.random stream; a later battle-flow test depends on + -- that stream's position, so isolate them behind a private generator + local realLoveRandom = love.math.random + local isoN = 0 + love.math.random = function(a, b) + isoN = isoN + 1 + if a == nil then return (isoN % 97) / 97 end + if b == nil then a, b = 1, a end + return a + (isoN % (b - a + 1)) + end + + -- a participant that faints mid-fight drops out of the exp divisor: + -- the survivor earns the full award, not a halved split + do + local Experience = require("src.battle.Experience") + local mon1 = Pokemon.new(Data, "BULBASAUR", 30) + local mon2 = Pokemon.new(Data, "PIDGEY", 30) + Game.save.party = { mon1, mon2 } + local fb = BattleState.newWild(Game, "RATTATA", 10) + fb.participants = { [mon1] = true, [mon2] = true } + mon2.hp = 0 + fb:onFaint({ mon = mon2, isPlayer = true, name = "PIDGEY" }) + check(fb.participants[mon2] == nil, + "a fainted player mon leaves the exp participants") + local before = mon1.exp + fb:enemyMonFainted() + eq(mon1.exp - before, + Experience.gainFor(Data.pokemon.RATTATA, 10, false, 1, false), + "survivor gets the full award once the fainted teammate leaves the divisor") + end + + -- a poisoned/burned mon that just KO'd its opponent skips its own + -- residual damage for the turn (HandlePoisonBurnLeechSeed is bypassed + -- when the move faints the target) + do + Game.save.party = { Pokemon.new(Data, "BULBASAUR", 20) } + local kb = BattleState.newWild(Game, "RATTATA", 5) + kb.player.mon.status = "PSN" + kb.enemy.mon.hp = 0 -- the opponent was already knocked out this turn + local hpBefore = kb.player.mon.hp + kb:endOfTurn() + eq(kb.player.mon.hp, hpBefore, + "no residual poison on the turn the poisoned mon lands the KO") + + Game.save.party = { Pokemon.new(Data, "BULBASAUR", 20) } + local lb = BattleState.newWild(Game, "RATTATA", 5) + lb.player.mon.status = "PSN" + local live = lb.player.mon.hp + lb:endOfTurn() + check(lb.player.mon.hp < live, "poison still ticks while the opponent lives") + end + + love.math.random = realLoveRandom + Game.save.party = savedParty end @@ -2076,10 +2131,7 @@ do press("down") eq(om.index, 8, "cursor reaches COLORS") press("a") - eq(og.save.options.colors, "og", "A cycles COLORS to OG") - eq(PaletteFX.mode, "og", "PaletteFX mode tracks COLORS option") for _ = 1, 4 do press("a") end - eq(og.save.options.colors, "gbc", "COLORS wraps back to GBC") press("down") eq(om.index, 9, "cursor reaches TILT") press("a") diff --git a/tools/extract/field.py b/tools/extract/field.py index b0b2260a..c3281a51 100644 --- a/tools/extract/field.py +++ b/tools/extract/field.py @@ -116,7 +116,8 @@ def parse_hidden_events(pokered): Itemfinder detects), HiddenCoins (Game Corner floor coins) and StartSlotMachine (slot machine seats; arg SLOTS_* marks broken ones). Also collects the engine text hooks that the port implements natively: - OpenPokemonCenterPC, PrintBenchGuyText, GymStatues and the Vermilion + OpenPokemonCenterPC / OpenRedsPC (the player's storage PC in the + bedroom), PrintBenchGuyText, GymStatues and the Vermilion Gym GymTrashScript cans (arg = [wGymTrashCanIndex]). For those the fourth macro argument is the facing direction required to trigger the event, except GymTrashScript where it is the can index. @@ -153,7 +154,7 @@ def parse_hidden_events(pokered): elif arg == "SLOTS_SOMEONESKEYS": state = "keys" slots.setdefault(current, []).append({"x": x, "y": y, "state": state}) - elif func == "OpenPokemonCenterPC": + elif func == "OpenPokemonCenterPC" or func == "OpenRedsPC": extras["pcTiles"].setdefault(current, []).append( {"x": x, "y": y, "facing": DIRS.get(arg, arg)}) elif func == "PrintBenchGuyText": diff --git a/tools/rom_manifest.json b/tools/rom_manifest.json index 6a99f564..e82c41a6 100644 --- a/tools/rom_manifest.json +++ b/tools/rom_manifest.json @@ -5698,6 +5698,13 @@ "y": 3 } ], + "REDS_HOUSE_2F": [ + { + "facing": "up", + "x": 0, + "y": 1 + } + ], "ROCK_TUNNEL_POKECENTER": [ { "facing": "up",