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.
This commit is contained in:
bryanthaboi
2026-07-22 09:45:23 -04:00
parent 15029d811f
commit 5041125751
13 changed files with 412 additions and 315 deletions
+21
View File
@@ -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 data build, test suites, and cache management are covered in
[Developer Setup](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki/Guide-Developer-Setup). [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 ## Modding
The game ships a native mod platform: content registries, events and hooks, The game ships a native mod platform: content registries, events and hooks,
+16 -7
View File
@@ -1532,10 +1532,16 @@ function BattleState:endOfTurn()
-- that sets the flag also zeroes the counter, so a stale value is -- that sets the flag also zeroes the counter, so a stale value is
-- unobservable (a switch or cure downgrades Toxic to plain poison). -- unobservable (a switch or cure downgrades Toxic to plain poison).
self.sideToxic = self.sideToxic or {} self.sideToxic = self.sideToxic or {}
for _, pair in ipairs({ { self.player, self.enemy, "player" }, -- a battler whose opponent was already knocked out by a move this turn
{ self.enemy, self.player, "enemy" } }) do -- skips its own residual (HandlePoisonBurnLeechSeed is bypassed when the
local b, opp, side = pair[1], pair[2], pair[3] -- move faints the target); snapshot before residual so one side's
if b.mon.hp > 0 then -- 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) local msgs = Status.residual(b, opp, self)
for _, m in ipairs(msgs) do self:sayNext(prefixEnemy(m, b)) end for _, m in ipairs(msgs) do self:sayNext(prefixEnemy(m, b)) end
if #msgs > 0 then self:drainNext() end -- poison/burn/seed HP moved if #msgs > 0 then self:drainNext() end -- poison/burn/seed HP moved
@@ -2442,6 +2448,9 @@ end
function BattleState:onFaint(battler) function BattleState:onFaint(battler)
if battler.faintQueued then return end if battler.faintQueued then return end
battler.faintQueued = true battler.faintQueued = true
if battler.isPlayer and self.participants then
self.participants[battler.mon] = nil
end
Runtime.emit("battle.fainted", { battle = self, battler = battler }) Runtime.emit("battle.fainted", { battle = self, battler = battler })
-- the faint slide + cry ride the queue (after the move animation and -- the faint slide + cry ride the queue (after the move animation and
-- the HP-bar drain, pokered's order); the slide finishes before the -- 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 -- exp is split among the mons that fought this enemy
-- (engine/battle/experience.asm); traded mons earn x1.5; each -- (engine/battle/experience.asm); traded mons earn x1.5; each
-- participant gets the full stat exp -- participant gets the full stat exp
-- the divisor counts EVERY participant, fainted ones included -- a mon that fainted mid-fight has had its gain-exp flag cleared
-- (DivideExpDataByNumMonsGainingExp keeps their flag bits); only the -- (RemoveFaintedPlayerMon), so it drops out of the divisor and only
-- living ones are actually paid -- the surviving participants are counted and paid
local participants, alive = 0, {} local participants, alive = 0, {}
for _, mon in ipairs(self.game.save.party) do for _, mon in ipairs(self.game.save.party) do
if self.participants and self.participants[mon] then if self.participants and self.participants[mon] then
+16 -4
View File
@@ -283,10 +283,22 @@ function Game:keypressed(key)
self:zoomStep(1) self:zoomStep(1)
return return
elseif key == "2" then elseif key == "2" then
-- cycle COLORS (GBC / OG / OG INV / GBC INV / CLASSIC); always on -- cycle COLORS (GBC / OG / OG INV / GBC INV / CLASSIC); the pack change
local PaletteFX = require("src.render.PaletteFX") -- forces Game.overworld:reloadMap, which rebuilds the live NPC array, so
self.save.options.colors = PaletteFX.cycleMode() -- hold it while a warp/transition or an on-screen scripted cutscene is
self:writeOptions() -- 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 return
elseif key == "3" then elseif key == "3" then
-- cycle TILT OFF → 15 → 35 → 50 → OFF (mnemonic: 3D), free-roam only -- cycle TILT OFF → 15 → 35 → 50 → OFF (mnemonic: 3D), free-roam only
+106 -5
View File
@@ -27,6 +27,107 @@ local OPTIONS_FILENAME = "options.lua"
local BACKUP_FILENAME = FILENAME .. ".bak" local BACKUP_FILENAME = FILENAME .. ".bak"
local TMP_FILENAME = FILENAME .. ".tmp" 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 -- Port + original Options menu defaults. Missing keys on load are filled
-- from this table so old options.lua files stay compatible. -- from this table so old options.lua files stay compatible.
function SaveData.defaultOptions() function SaveData.defaultOptions()
@@ -94,7 +195,7 @@ end
-- love.filesystem, so the mod loader's injected filesystem can carry the -- love.filesystem, so the mod loader's injected filesystem can carry the
-- options round-trip headless (no love global). -- options round-trip headless (no love global).
function SaveData.saveOptions(opts, fs) function SaveData.saveOptions(opts, fs)
fs = fs or love.filesystem fs = persistFs(fs)
opts = SaveData.mergeOptions(opts) opts = SaveData.mergeOptions(opts)
-- modOptions is per-mod nested state: fold the on-disk sub-tree -- modOptions is per-mod nested state: fold the on-disk sub-tree
-- underneath (newest value winning per key) so one caller's partial -- underneath (newest value winning per key) so one caller's partial
@@ -123,7 +224,7 @@ function SaveData.saveOptions(opts, fs)
end end
function SaveData.loadOptions(fs) function SaveData.loadOptions(fs)
fs = fs or love.filesystem fs = persistFs(fs)
local data, err = readTable(fs, OPTIONS_FILENAME) local data, err = readTable(fs, OPTIONS_FILENAME)
if not data then if not data then
if fs.getInfo(OPTIONS_FILENAME) then if fs.getInfo(OPTIONS_FILENAME) then
@@ -311,7 +412,7 @@ end)
-- afterwards either way -- afterwards either way
SaveData.addCoreMigration(1, function(save) SaveData.addCoreMigration(1, function(save)
if type(save.options) == "table" 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) SaveData.saveOptions(save.options)
end end
end) end)
@@ -342,7 +443,7 @@ function SaveData.save(data, mods)
if k ~= "options" then gameOnly[k] = v end if k ~= "options" then gameOnly[k] = v end
end end
local encoded = SaveSerializer.encode(gameOnly) local encoded = SaveSerializer.encode(gameOnly)
local fs = love.filesystem local fs = persistFs(nil)
if fs.getInfo(FILENAME) then if fs.getInfo(FILENAME) then
local prev = fs.read(FILENAME) local prev = fs.read(FILENAME)
if prev then fs.write(BACKUP_FILENAME, prev) end 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 -- or corrupt and a staged/backup copy was promoted; Game surfaces the
-- recovery on the load report -- recovery on the load report
function SaveData.load() function SaveData.load()
local fs = love.filesystem local fs = persistFs(nil)
local data, err = readTable(fs, FILENAME) local data, err = readTable(fs, FILENAME)
local recovered local recovered
if not data then if not data then
+122 -1
View File
@@ -2,7 +2,7 @@ local RomImporter = {}
RomImporter.__index = RomImporter RomImporter.__index = RomImporter
local ROM_SHA1 = "ea9bcae617fdf159b045185467ae58b2e4a48b9a" 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 MARKER_PATH = "rom-cache.complete"
local COMMUNITY_URL = "https://bois.icu" local COMMUNITY_URL = "https://bois.icu"
local TRUST_WARNING = "if you did not get this from bryanthaboi's github " .. 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() return real == love.filesystem.getSource()
end 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() function RomImporter.isReady()
if sourceTreeHasData() then return true end 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 return love.filesystem.read(MARKER_PATH) == CACHE_MARKER
and allRequiredFilesExist() and allRequiredFilesExist()
end end
@@ -211,6 +327,11 @@ function RomImporter:startData(data, displayName)
collectgarbage("collect") collectgarbage("collect")
local ok, writeError = love.filesystem.write(MARKER_PATH, CACHE_MARKER) 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 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.state = "complete"
self.status = "Ready" self.status = "Ready"
self.detail = "Starting Pokemon Red..." self.detail = "Starting Pokemon Red..."
+6 -1
View File
@@ -397,6 +397,7 @@ local function toggleObject(ctx, mapId, objName, visible)
-- positions mid-cutscene) -- positions mid-cutscene)
local ow = ctx.overworld local ow = ctx.overworld
if not ow or ow.map.id ~= mapId then return end if not ow or ow.map.id ~= mapId then return end
ow.npcPool = ow.npcPool or {}
if visible then if visible then
for _, n in ipairs(ow.npcs) do for _, n in ipairs(ow.npcs) do
if n.def.name == objName then return end 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) local npc = NPC.new(ctx.game.data, mapId, obj)
table.insert(ow.npcs, npc) table.insert(ow.npcs, npc)
table.insert(ow.entities, npc) table.insert(ow.entities, npc)
ow.npcPool[npc.id] = npc
return return
end end
end end
else else
for i = #ow.npcs, 1, -1 do 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 end
for i = #ow.entities, 1, -1 do for i = #ow.entities, 1, -1 do
local e = ow.entities[i] local e = ow.entities[i]
+43 -28
View File
@@ -296,37 +296,52 @@ local function useOn(game, battle, id, target, list, moveIndex)
showMessages(game, payload) -- failed showMessages(game, payload) -- failed
end 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 function useItem(game, battle, id, list)
local def = game.data.items[id] local def = game.data.items[id]
if ItemEffects.needsTarget(id, def) and not ItemEffects.isBall(id) then if ItemEffects.needsTarget(id, def) and not ItemEffects.isBall(id) then
-- pick a target from the party -- TMs/HMs boot up and announce their move before the target picker
-- the ETHERs and PP UP open the move menu after picking a mon -- (ItemUseTMHM: BootedUpTMText / BootedUpHMText + TeachMachineMoveText)
-- (ItemUsePPRestore / ItemUsePPUp); the ELIXERs hit every move if def and def.machine then
local wantsMove = id == "ETHER" or id == "MAX_ETHER" or id == "PP_UP" local moveDef = game.data.moves[def.machine.move]
require("src.ui.Screens").push(game, "PartyMenu", { local moveName = moveDef and moveDef.name or def.machine.move
pickOnly = true, local booted = def.machine.kind == "HM"
onSwitch = function(mon) and "Booted up an HM!" or "Booted up a TM!"
if not wantsMove then showMessages(game, { booted, ("It contained\n%s!"):format(moveName) },
useOn(game, battle, id, mon, list) function() pickTargetAndUse(game, battle, id, list) end)
return return
end end
local rows = {} pickTargetAndUse(game, battle, id, list)
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,
})
else else
useOn(game, battle, id, nil, list) useOn(game, battle, id, nil, list)
end end
+17 -7
View File
@@ -1708,8 +1708,10 @@ function OverworldState:tryCut(fx, fy)
-- chain-cut "ornamental bushes" around Saffron and Celadon. -- chain-cut "ornamental bushes" around Saffron and Celadon.
local ts = self.map.def.tileset local ts = self.map.def.tileset
local tile = self.map:cellTile(fx, fy) local tile = self.map:cellTile(fx, fy)
local isGrass = (ts == "OVERWORLD" and tile == 0x52)
if not ((ts == "OVERWORLD" and tile == 0x3d) 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 return false
end end
local bx, by = math.floor(fx / 2), math.floor(fy / 2) 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 for _, sw in ipairs(Game.data.field.cutTreeSwaps) do
if sw.before == block then swap = sw break end if sw.before == block then swap = sw break end
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") local mon = self:partyKnows("CUT")
if not mon then return false end if not mon then return false end
-- gen 1 confirms nothing (engine/overworld/cut.asm UsedCut): the -- gen 1 confirms nothing (engine/overworld/cut.asm UsedCut): the
@@ -1736,7 +1738,11 @@ function OverworldState:tryCut(fx, fy)
local finish = function() local finish = function()
require("src.core.Sound").play(Game.data, "Cut") require("src.core.Sound").play(Game.data, "Cut")
end 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 tree splits in half and slides apart (AnimCut .cutTreeLoop);
-- the GYM plant keeps the shared dust/leaf puff -- the GYM plant keeps the shared dust/leaf puff
self:startCutTreeAnim(fx, fy, finish) self:startCutTreeAnim(fx, fy, finish)
@@ -1822,8 +1828,10 @@ function OverworldState:useCutFieldMove()
-- "nothing to cut" in vanilla -- "nothing to cut" in vanilla
local ts = self.map.def.tileset local ts = self.map.def.tileset
local tile = self.map:cellTile(fx, fy) local tile = self.map:cellTile(fx, fy)
local isGrass = (ts == "OVERWORLD" and tile == 0x52)
if not ((ts == "OVERWORLD" and tile == 0x3d) 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" return "nothing"
end end
local bx, by = math.floor(fx / 2), math.floor(fy / 2) 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 for _, sw in ipairs(Game.data.field.cutTreeSwaps) do
if sw.before == block then swap = sw break end if sw.before == block then swap = sw break end
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" return "ok"
end end
@@ -2004,9 +2012,11 @@ function OverworldState:openPC(onDone)
end end
table.insert(items, { label = "LOG OFF", onSelect = logOff }) table.insert(items, { label = "LOG OFF", onSelect = logOff })
-- pokered sets BIT_NO_MENU_BUTTON_SOUND for the whole PC session -- 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, 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 })) noSound = true }))
end end
-67
View File
@@ -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()
-190
View File
@@ -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()
+55 -3
View File
@@ -931,6 +931,61 @@ do
Game.save.inventory.EXP_ALL = nil Game.save.inventory.EXP_ALL = nil
end 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 Game.save.party = savedParty
end end
@@ -2076,10 +2131,7 @@ do
press("down") press("down")
eq(om.index, 8, "cursor reaches COLORS") eq(om.index, 8, "cursor reaches COLORS")
press("a") 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 for _ = 1, 4 do press("a") end
eq(og.save.options.colors, "gbc", "COLORS wraps back to GBC")
press("down") press("down")
eq(om.index, 9, "cursor reaches TILT") eq(om.index, 9, "cursor reaches TILT")
press("a") press("a")
+3 -2
View File
@@ -116,7 +116,8 @@ def parse_hidden_events(pokered):
Itemfinder detects), HiddenCoins (Game Corner floor coins) and Itemfinder detects), HiddenCoins (Game Corner floor coins) and
StartSlotMachine (slot machine seats; arg SLOTS_* marks broken ones). StartSlotMachine (slot machine seats; arg SLOTS_* marks broken ones).
Also collects the engine text hooks that the port implements natively: 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 Gym GymTrashScript cans (arg = [wGymTrashCanIndex]). For those the
fourth macro argument is the facing direction required to trigger the fourth macro argument is the facing direction required to trigger the
event, except GymTrashScript where it is the can index. event, except GymTrashScript where it is the can index.
@@ -153,7 +154,7 @@ def parse_hidden_events(pokered):
elif arg == "SLOTS_SOMEONESKEYS": elif arg == "SLOTS_SOMEONESKEYS":
state = "keys" state = "keys"
slots.setdefault(current, []).append({"x": x, "y": y, "state": state}) slots.setdefault(current, []).append({"x": x, "y": y, "state": state})
elif func == "OpenPokemonCenterPC": elif func == "OpenPokemonCenterPC" or func == "OpenRedsPC":
extras["pcTiles"].setdefault(current, []).append( extras["pcTiles"].setdefault(current, []).append(
{"x": x, "y": y, "facing": DIRS.get(arg, arg)}) {"x": x, "y": y, "facing": DIRS.get(arg, arg)})
elif func == "PrintBenchGuyText": elif func == "PrintBenchGuyText":
+7
View File
@@ -5698,6 +5698,13 @@
"y": 3 "y": 3
} }
], ],
"REDS_HOUSE_2F": [
{
"facing": "up",
"x": 0,
"y": 1
}
],
"ROCK_TUNNEL_POKECENTER": [ "ROCK_TUNNEL_POKECENTER": [
{ {
"facing": "up", "facing": "up",