From 8c9af95598172a7455cf280ae3a9fc40355c4183 Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Mon, 17 Aug 2026 10:27:14 -0400 Subject: [PATCH] CLOSES #1396, CLOSES #1398, CLOSES #1400, CLOSES #1401, CLOSES #1406, CLOSES #1407, CLOSES #1411, CLOSES #1413, CLOSES #1415, CLOSES #1416, CLOSES #1417, CLOSES #1419, CLOSES #1421, CLOSES #1422, CLOSES #1423, CLOSES #1424, CLOSES #1425, CLOSES #1427, CLOSES #1428, CLOSES #1429, CLOSES #1431, CLOSES #1432, CLOSES #1433, CLOSES #1435, CLOSES #1437, CLOSES #1440, CLOSES #1441, CLOSES #1442, CLOSES #1443, CLOSES #1444, CLOSES #1447, CLOSES #1449, CLOSES #1456, CLOSES #1461, CLOSES #1464, CLOSES #1465, CLOSES #1466, CLOSES #1468, CLOSES #1469, CLOSES #1470 --- src/battle/BattleState.lua | 4 +- src/battle/gen2/Battle.lua | 31 +++++++++- src/battle/gen2/Mon.lua | 3 +- src/core/Game.lua | 1 + src/core/SaveData.lua | 20 ++++++ .../exp_traded_ot_survives_reload_bug1265.lua | 26 ++++++++ .../gen2_status_type_immunity_bug1444.lua | 62 +++++++++++++++++++ tests/run_save_editor_tests.lua | 27 ++++++++ tools/save-editor/Catalog.lua | 18 +++++- tools/save-editor/MonOps.lua | 2 +- tools/save-editor/Ops.lua | 2 +- 11 files changed, 186 insertions(+), 10 deletions(-) create mode 100644 tests/engine/gen2_status_type_immunity_bug1444.lua diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index e43d5348..58f80d59 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -4111,8 +4111,8 @@ function BattleState:awardExp() end local function applyShare(mon, split, announce) local playerId = self.game.save.player and self.game.save.player.id - local traded = mon.otId ~= nil and playerId ~= nil - and mon.otId ~= playerId or mon.traded == true and mon.otId == nil + local traded = mon.traded == true + or (mon.otId ~= nil and playerId ~= nil and mon.otId ~= playerId) local levels, gained = Experience.apply(self.data, mon, self.enemy.def, self.enemy.mon.level, self.kind == "trainer", split, traded) diff --git a/src/battle/gen2/Battle.lua b/src/battle/gen2/Battle.lua index 0bb2ad52..16ed674e 100644 --- a/src/battle/gen2/Battle.lua +++ b/src/battle/gen2/Battle.lua @@ -1868,7 +1868,11 @@ function Battle:useMove(attacker, defender, moveId) if status and (def.power or 0) == 0 then -- A refused primary status is a failed move (effect_commands.asm:3748, -- :6656); a refused secondary already animated and stays unmarked (:3752). - if Battle.AI_FAIL_STATUSES[status] + if self:statusRefusedByType(defender, def.type, status) then + self:markMissed() + self:emit({ kind = "message", + text = "It doesn't affect " .. self:monName(defender) .. "..." }) + elseif Battle.AI_FAIL_STATUSES[status] and self:aiRandomFail(attacker, defender) then self:markMissed() self:emit({ kind = "message", text = "But it failed!" }) @@ -1880,7 +1884,8 @@ function Battle:useMove(attacker, defender, moveId) and record.status or nil -- engine/battle/effect_commands.asm:6325 if secondary and (defender.hp or 0) > 0 - and not self:safeguarded(defender) then + and not self:safeguarded(defender) + and not self:statusRefusedByType(defender, def.type, secondary) then local chance = def.effectChance or 0 if chance > 0 and rand(self.random, 100) < chance then self:applyStatus(defender, secondary, attacker) @@ -2941,6 +2946,27 @@ end -- `source` is the battler that inflicted it, carried only so -- battle.status_inflicted can name it the way Gen 1's does. +-- BattleCommand_Paralyze and BattleCommand_Poison refuse on a zero matchup, +-- and the poison pair also refuses a POISON-type target: effect_commands.asm +-- :5788 (paralyze), :3671 (poison), :3646 / :4019 (the secondary arms). +-- Sleep, confusion and stat changes are deliberately not gated. +function Battle:statusRefusedByType(defender, moveType, status) + if not (status == "paralyze" or status == "poison" or status == "toxic") then + return false + end + local types = (self:speciesDef(defender) or {}).types or defender.types or {} + if moveType then + local matchups = self.data.type_chart and self.data.type_chart.matchups + if Damage.typeMultiplier(moveType, types, matchups) == 0 then return true end + end + if status == "poison" or status == "toxic" then + for _, t in ipairs(types) do + if t == "POISON" then return true end + end + end + return false +end + function Battle:applyStatus(mon, status, source) if (mon.hp or 0) <= 0 then return false end -- Confusion is SUBSTATUS_CONFUSED on the cart, not a status byte: it lives @@ -3202,6 +3228,7 @@ end -- player's own. function Battle:isOutsider(mon) local playerId = self.save and self.save.player and self.save.player.id + if mon.traded == true then return true end if mon.otId == nil or playerId == nil then return false end return mon.otId ~= playerId end diff --git a/src/battle/gen2/Mon.lua b/src/battle/gen2/Mon.lua index 4b79c460..5f515567 100644 --- a/src/battle/gen2/Mon.lua +++ b/src/battle/gen2/Mon.lua @@ -388,7 +388,8 @@ function Mon.stampOT(save, mon) mon.ot = mon.ot or player.name -- NpcTrade.lua:150: `ot` is what Breeding reads, `otName` what the summary prints. mon.otName = mon.otName or mon.ot - mon.otId = mon.otId or player.id + -- engine/battle/experience.asm:69: a traded mon keeps its own OT id + if not mon.traded then mon.otId = mon.otId or player.id end return mon end diff --git a/src/core/Game.lua b/src/core/Game.lua index 5eaded11..1b6019e1 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -1233,6 +1233,7 @@ function Game:restoreSave(loaded, recovered, opts) self:applyOptions(loaded.options) -- saves from before OT/ID stamping: backfill with the player's (after -- the scrub, so every mon the stamp loop sees is known) + SaveData.repairTradedOtIds(loaded) local stamp = require("src.battle.BattleState").stampOT for _, mon in ipairs(loaded.party or {}) do stamp(loaded, mon) end for _, box in ipairs(loaded.boxes or {}) do diff --git a/src/core/SaveData.lua b/src/core/SaveData.lua index 313af119..a63db2a4 100644 --- a/src/core/SaveData.lua +++ b/src/core/SaveData.lua @@ -1923,6 +1923,26 @@ function SaveData.applyPostGameHome(save, boot) return heal end +-- 0.1.82-0.1.9x loads stamped the player's own id onto traded mons and saved +-- it, which reads back as home-caught. A caught mon can never carry +-- traded=true, so clearing that pair is safe on every load. #1461 +function SaveData.repairTradedOtIds(save) + local playerId = save and save.player and save.player.id + if playerId == nil then return 0 end + local fixed = 0 + local function scrub(mon) + if mon and mon.traded == true and mon.otId == playerId then + mon.otId = nil + fixed = fixed + 1 + end + end + for _, mon in ipairs(save.party or {}) do scrub(mon) end + for _, box in ipairs(save.boxes or {}) do + for _, mon in ipairs(box) do scrub(mon) end + end + return fixed +end + -- Softlocked 0.1.11 saves: still standing in HALL_OF_FAME after credits, -- with lastOutdoor on Indigo. One-shot rescue on CONTINUE. function SaveData.needsPostGameRescue(save) diff --git a/tests/engine/exp_traded_ot_survives_reload_bug1265.lua b/tests/engine/exp_traded_ot_survives_reload_bug1265.lua index 26619855..8cdd3917 100644 --- a/tests/engine/exp_traded_ot_survives_reload_bug1265.lua +++ b/tests/engine/exp_traded_ot_survives_reload_bug1265.lua @@ -46,4 +46,30 @@ do T.eq(tradedWithId.otId, 777, "a recorded foreign OT id is never overwritten") end +-- #1461: saves that passed through 0.1.82-0.1.9x already have the player's +-- own id written onto traded mons, so stamping correctly from now on does +-- not help them. The repair runs on every load, not behind a format gate. +do + local SaveData = require("src.core.SaveData") + local save = newSave() + local poisoned = { traded = true, otId = 12345 } + local foreign = { traded = true, otId = 777 } + local caught = { otId = 12345 } + save.party = { poisoned, foreign, caught } + save.boxes = { { { traded = true, otId = 12345 } } } + + T.eq(SaveData.repairTradedOtIds(save), 2, "both poisoned mons are repaired") + T.eq(poisoned.otId, nil, "a traded mon carrying the player id is cleared") + T.eq(save.boxes[1][1].otId, nil, "boxed mons are repaired too") + T.eq(foreign.otId, 777, "a real foreign OT id is left alone") + T.eq(caught.otId, 12345, "a caught mon keeps the player id") + T.eq(caught.traded, nil, "and is never marked traded") + + T.eq(SaveData.repairTradedOtIds(save), 0, "the repair is idempotent") + + -- and after the repair the stamp loop must not re-adopt it + BattleState.stampOT(save, poisoned) + T.eq(poisoned.otId, nil, "the stamp loop does not undo the repair") +end + T.finish("exp traded ot survives reload bug 1265") diff --git a/tests/engine/gen2_status_type_immunity_bug1444.lua b/tests/engine/gen2_status_type_immunity_bug1444.lua new file mode 100644 index 00000000..6fff9649 --- /dev/null +++ b/tests/engine/gen2_status_type_immunity_bug1444.lua @@ -0,0 +1,62 @@ +-- engine/battle/effect_commands.asm:5788, :3671, :3646, :4019 + +package.path = "./?.lua;./?/init.lua;" .. package.path + +love = require("tests.love_stub") + +local T = require("tests.harness") +local Battle = require("src.battle.gen2.Battle") + +local MATCHUPS = { + { attacker = "ELECTRIC", defender = "GROUND", multiplier = 0 }, + { attacker = "POISON", defender = "STEEL", multiplier = 0 }, + { attacker = "POISON", defender = "POISON", multiplier = 5 }, + { attacker = "NORMAL", defender = "GHOST", multiplier = 0 }, + { attacker = "PSYCHIC_TYPE", defender = "GROUND", multiplier = 10 }, +} + +local POKEMON = { + GEODUDE = { types = { "ROCK", "GROUND" } }, + MAGNEMITE = { types = { "STEEL", "ELECTRIC" } }, + ZUBAT = { types = { "POISON", "FLYING" } }, + GASTLY = { types = { "GHOST", "POISON" } }, + PIDGEY = { types = { "NORMAL", "FLYING" } }, +} + +local battle = setmetatable({ + data = { type_chart = { matchups = MATCHUPS }, pokemon = POKEMON }, +}, { __index = Battle }) + +local function mon(species) return { species = species, hp = 20 } end +local function refused(species, moveType, status) + return battle:statusRefusedByType(mon(species), moveType, status) +end + +-- The two repros the reporter filed, neither of which the #1318 fix touched. +T.eq(refused("GEODUDE", "ELECTRIC", "paralyze"), true, + "Thunder Wave does not affect a GROUND type") +T.eq(refused("MAGNEMITE", "POISON", "poison"), true, + "Poison Gas does not affect a STEEL type") + +-- Poison vs POISON is 0.5x, not an immunity, so only CheckIfTargetIsPoisonType +-- refuses it: this is the Poison Sting on Zubat case. +T.eq(refused("ZUBAT", "POISON", "poison"), true, + "a POISON type cannot be poisoned even on a resisted, landed hit") +T.eq(refused("GASTLY", "POISON", "toxic"), true, + "Toxic is refused by the same check") +T.eq(refused("GASTLY", "NORMAL", "paralyze"), true, + "Glare does not affect a GHOST type") + +-- Everything the cart deliberately leaves ungated stays ungated. +T.eq(refused("GEODUDE", "PSYCHIC_TYPE", "sleep"), false, + "Hypnosis still lands on a GROUND type") +T.eq(refused("GEODUDE", "PSYCHIC_TYPE", "confuse"), false, + "confusion is never type-gated") +T.eq(refused("PIDGEY", "ELECTRIC", "paralyze"), false, + "Thunder Wave still paralyses a non-immune target") +T.eq(refused("PIDGEY", "POISON", "poison"), false, + "a non-POISON target is still poisonable") +T.eq(refused("GEODUDE", nil, "paralyze"), false, + "a status with no move type behind it is not refused") + +T.finish("gen2 status type immunity bug 1444") diff --git a/tests/run_save_editor_tests.lua b/tests/run_save_editor_tests.lua index f958d614..ef452f87 100644 --- a/tests/run_save_editor_tests.lua +++ b/tests/run_save_editor_tests.lua @@ -132,6 +132,33 @@ do check(cat.species[1] < cat.species[2], "species sorted") end +-- RomExtractorGen2 stamps generation/source beside the id-keyed records, and +-- they sort last because lowercase follows uppercase. #1466 +do + local gold = { + pokemon = { generation = 2, source = "ROM", CHIKORITA = { name = "CHIKORITA" } }, + items = { generation = 2, source = "ROM", POTION = { name = "POTION" } }, + moves = { + generation = 2, source = "ROM:Moves + MoveNames", + TACKLE = { pp = 35 }, ZAP_CANNON = { pp = 5 }, + }, + } + local cat = Catalog.build(gold) + eq(#cat.moves, 2, "gold move catalog holds only real moves") + eq(cat.moves[#cat.moves], "ZAP_CANNON", "and the last entry is a move, not a scalar") + for _, list in pairs(cat) do + for _, id in ipairs(list) do + check(id ~= "generation" and id ~= "source", + "no provenance scalar reached a catalog: " .. tostring(id)) + end + end + + local S = { data = gold, cat = cat } + local mon = { moves = { { id = "ZAP_CANNON", pp = 5 } } } + check(require("Ops").cycleMove(S, mon, 1), "cycling off the last move succeeds") + eq(mon.moves[1].id, "TACKLE", "and wraps to the first move instead of a scalar") +end + do local events = Catalog.scrapeEvents("data/scripts", "data/generated/trainer_headers.lua") check(#events > 50, "scraped events") diff --git a/tools/save-editor/Catalog.lua b/tools/save-editor/Catalog.lua index 12fe8e7f..6280ea25 100644 --- a/tools/save-editor/Catalog.lua +++ b/tools/save-editor/Catalog.lua @@ -9,11 +9,23 @@ local function sortedKeys(t) return keys end +-- Gen 2's generated tables carry provenance scalars (generation, source) +-- beside the id-keyed records, so a wheel built from every key would offer +-- them as pickable entries. #1466 +local function sortedRecordKeys(t) + local keys = {} + for k, v in pairs(t or {}) do + if type(v) == "table" then table.insert(keys, k) end + end + table.sort(keys) + return keys +end + function Catalog.build(data) return { - species = sortedKeys(data.pokemon), - items = sortedKeys(data.items), - moves = sortedKeys(data.moves), + species = sortedRecordKeys(data.pokemon), + items = sortedRecordKeys(data.items), + moves = sortedRecordKeys(data.moves), } end diff --git a/tools/save-editor/MonOps.lua b/tools/save-editor/MonOps.lua index 418c5f36..e74c207d 100644 --- a/tools/save-editor/MonOps.lua +++ b/tools/save-editor/MonOps.lua @@ -43,7 +43,7 @@ end function MonOps.setMove(data, mon, slot, moveId) assert(slot >= 1 and slot <= 4) local mdef = data.moves[moveId] - assert(mdef, "unknown move") + assert(type(mdef) == "table", "unknown move") mon.moves = mon.moves or {} mon.moves[slot] = { id = moveId, diff --git a/tools/save-editor/Ops.lua b/tools/save-editor/Ops.lua index 77ba080a..86276d76 100644 --- a/tools/save-editor/Ops.lua +++ b/tools/save-editor/Ops.lua @@ -441,7 +441,7 @@ function Ops.cycleMove(S, mon, slot) end for step = 1, #moves do local nextId = moves[((idx + step - 1) % #moves) + 1] - if S.data and S.data.moves and S.data.moves[nextId] then + if S.data and S.data.moves and type(S.data.moves[nextId]) == "table" then MonOps.setMove(S.data, mon, slot, nextId) return Ops.mark(S, ("Move %d set to %s"):format(slot, nextId)) end