FireRed: fix party gaps after PC moves and TM ABLE!/NOT ABLE! labels

- PC storage: moving a party Pokemon out of the middle of the party left a
  nil hole, so later slots showed as empty in battle and could not be filled
  (#2494). Add Storage.compactParty (pret CompactPartySlots) and run it after
  every PC party move, when the PC opens, and on save load so already broken
  saves are repaired.
- Party menu: using a TM/HM from the bag now labels each slot ABLE!,
  NOT ABLE! or LEARNED, matching DisplayPartyPokemonDataToTeachMove (#2520).
- Add regression tests for both.
This commit is contained in:
shanemcgovernie
2026-09-28 07:54:53 +00:00
parent 5540fc1538
commit c0a6db4c8c
6 changed files with 214 additions and 16 deletions
+2 -1
View File
@@ -432,7 +432,8 @@ function Schema.fromSaveTable(save)
engine = save.engine or "game3",
version = save.version or Profile.active().id,
generation = tonumber(save.generation) or 3,
party = save.party or {},
-- A party saved with gaps (older PC builds) is closed up on load.
party = require("src.core.game3.storage").compactParty(save.party or {}),
bag = bag,
dex = save.dex or {},
money = save.money or 0,
+22 -15
View File
@@ -24,6 +24,23 @@ local function script_store(session)
return (Space and Space.store) or (session and session.store) or nil
end
--- Close gaps in the party in place so mons fill slots 1..n in order.
-- pokefirered/src/pokemon.c CompactPartySlots
function Storage.compactParty(party)
if type(party) ~= "table" then return party end
local keys = {}
for k, m in pairs(party) do
local n = tonumber(k)
if n and m ~= nil then keys[#keys + 1] = { n = n, k = k } end
end
table.sort(keys, function(a, b) return a.n < b.n end)
local mons = {}
for i, e in ipairs(keys) do mons[i] = party[e.k] end
for _, e in ipairs(keys) do party[e.k] = nil end
for i, m in ipairs(mons) do party[i] = m end
return party
end
--- Create a fresh Storage instance (14 boxes, 30 slots each, 50-item PC).
function Storage.new()
local storage = {
@@ -232,7 +249,9 @@ function Storage.moveMon(session, srcLoc, srcIdx, destLoc, destIdx, srcBox, dest
if not srcMon then return false, "src_empty" end
-- Cannot leave party empty if withdrawing/moving away
if srcLoc == "party" and destLoc == "box" and not destMon and #session.party <= 1 then
local partyCount = 0
for _, m in pairs(session.party) do if m ~= nil then partyCount = partyCount + 1 end end
if srcLoc == "party" and destLoc == "box" and not destMon and partyCount <= 1 then
return false, "last_pokemon"
end
@@ -251,20 +270,6 @@ function Storage.moveMon(session, srcLoc, srcIdx, destLoc, destIdx, srcBox, dest
-- Assign to src
if srcLoc == "party" then
session.party[srcIdx] = destMon
-- Clean up trailing nils in party array if moved without swap
if not destMon and srcIdx > #session.party then
local keys = {}
for k in pairs(session.party) do
if type(k) == "number" then keys[#keys + 1] = k end
end
table.sort(keys)
local newParty = {}
for _, k in ipairs(keys) do
local m = session.party[k]
if m then newParty[#newParty + 1] = m end
end
session.party = newParty
end
elseif srcLoc == "box" then
local box = storage.boxes[srcBox or storage.currentBox]
box.mons[srcIdx] = destMon
@@ -289,6 +294,8 @@ function Storage.moveMon(session, srcLoc, srcIdx, destLoc, destIdx, srcBox, dest
elseif srcLoc=="party" then
Q.event(session,"DepositedMonInPC",{D0=srcName,D1=dstBoxName})
else Q.event(session,"WithdrewMonFromPC",{D0=srcBoxName,D1=srcName}) end
-- A mon moved out of (or into) the middle of the party must not leave a gap.
Storage.compactParty(session.party)
return true
end
+4
View File
@@ -138,6 +138,8 @@ function BoxStorageUI.show(opts)
BoxStorageUI._actionSource = nil
BoxStorageUI._actionTarget = nil
local storage = Storage.ensure(BoxStorageUI._session)
-- Repair a party left with gaps by an older build before any slot is indexed.
if BoxStorageUI._session then Storage.compactParty(BoxStorageUI._session.party) end
-- pokefirered/src/pokemon_storage_system_tasks.c:426
BoxStorageUI._lastUsedBox = storage and ((tonumber(storage.currentBox) or 1) - 1) or nil
@@ -364,6 +366,8 @@ function BoxStorageUI.handleInput(input)
se(246)
end
end
-- pokefirered/src/pokemon_storage_system_tasks.c SetUpHidePartyMenu -> CompactPartySlots
Storage.compactParty(party)
elseif mon then
BoxStorageUI._actionSource = "party"
BoxStorageUI._actionTarget = { mon = mon, loc = "party", boxId = nil, slot = pIdx }
+10
View File
@@ -2429,6 +2429,16 @@ local function slot_description(slot, mon)
local item = PartyMenu._item
if not item or PartyMenu._battle then return nil end
if PartyMenu.mode ~= "use" and PartyMenu.mode ~= "message" then return nil end
-- pokefirered/src/party_menu.c:856 TM/HM -> DisplayPartyPokemonDataToTeachMove
if ItemsData.isTm(item) then
-- pokefirered/src/party_menu.c:4760 CanMonLearnTMTutor
if Pokemon.isEgg(mon) then return desc_text("NOT_ABLE_2") end
local moveId = Pokemon.moveFromTmItem(item)
local species = tonumber(mon.species or mon.speciesId)
if not moveId or not Pokemon.canLearnTmItem(species, item) then return desc_text("NOT_ABLE_2") end
if Pokemon.knowsMove(mon, moveId) then return desc_text("LEARNED") end
return desc_text("ABLE_2")
end
if not is_evolution_stone(item) then return nil end
local Evolution = require("src.core.game3.evolution")
if Evolution.itemCheck(mon, item) then return nil end
+96
View File
@@ -0,0 +1,96 @@
#!/usr/bin/env luajit
-- #2494: moving a party mon out of the middle of the party in the PC left a
-- hole, so later slots showed as empty in battle and could not be filled.
-- pokefirered/src/pokemon.c CompactPartySlots keeps the party packed.
package.path = "./?.lua;./?/init.lua;" .. package.path
require("tests.game3_cache").stubSpeciesNames()
require("tests.fixture_data.game3_items").install()
package.loaded["src.core.game3.rom_text"] = {
plain = function(key) return key end, box = function(key) return key end,
ascii = function(key) return key end, has = function() return true end,
key = function(n, i) return n .. "[" .. i .. "]" end,
at = function(n, i) return n .. "[" .. i .. "]" end,
count = function() return 0 end, list = function() return {} end,
lazy = function(map) return setmetatable({}, { __index = function(_, k) return map[k] end }) end,
}
local Storage = require("src.core.game3.storage")
local BoxStorageUI = require("src.ui.game3.box_storage_ui")
local failed = 0
local function check(cond, msg)
if cond then
print("[ok] " .. msg)
else
failed = failed + 1
print("[FAIL] " .. msg)
end
end
local function mon(species)
return { species = species, speciesId = species, level = 5, hp = 20, maxHp = 20, moves = { 33 }, pp = { 35 } }
end
local function packed(party, n)
for i = 1, n do
if party[i] == nil then return false end
end
for k in pairs(party) do
if type(k) ~= "number" or k > n then return false end
end
return true
end
print("[test] 1. moving slot 3 of 6 into a box closes the gap")
do
local session = { party = { mon(1), mon(4), mon(7), mon(10), mon(13), mon(16) } }
local party = session.party
Storage.ensure(session)
local ok = Storage.moveMon(session, "party", 3, "box", 1, nil, 1)
check(ok == true, "the move succeeds")
check(session.party == party, "the party table keeps its identity")
check(packed(session.party, 5), "the party is packed into slots 1..5")
check(session.party[3].species == 10 and session.party[5].species == 16,
"later mons shift up in order")
end
print("[test] 2. placing a box mon into an empty far party slot packs it")
do
local session = { party = { mon(1), mon(4) } }
Storage.ensure(session)
BoxStorageUI.show({ session = session })
local storage = Storage.ensure(session)
storage.boxes[1].mons[1] = mon(25)
BoxStorageUI.mode = "party_drawer"
BoxStorageUI.drawerOpen = true
BoxStorageUI.holdingMon = storage.boxes[1].mons[1]
BoxStorageUI.holdingSource = { loc = "box", boxId = 1, slot = 1 }
BoxStorageUI.partyCursor = 6
BoxStorageUI.handleInput({
wasPressed = function(_, k) return k == "a" end,
isDown = function(_, k) return k == "a" end,
})
check(packed(session.party, 3), "the party is packed into slots 1..3")
check(session.party[3] and session.party[3].species == 25, "the placed mon lands in slot 3")
check(storage.boxes[1].mons[1] == nil, "the box slot is emptied")
BoxStorageUI.close()
end
print("[test] 3. a party already saved with a gap is repaired")
do
local party = { mon(1), mon(4), nil, mon(10), mon(13) }
party[3] = nil
Storage.compactParty(party)
check(packed(party, 4), "numeric gaps close")
local stringy = { ["1"] = mon(1), ["2"] = mon(4), ["4"] = mon(10) }
Storage.compactParty(stringy)
check(packed(stringy, 3) and stringy[3].species == 10, "string-keyed slots close too")
end
if failed > 0 then
print(string.format("[FAIL] %d check(s) failed", failed))
os.exit(1)
end
print("[PASS] game3_pc_party_gap_2494")
+80
View File
@@ -0,0 +1,80 @@
#!/usr/bin/env luajit
-- #2520: choosing a TM/HM from the bag opens the party menu without the
-- ABLE! / NOT ABLE! / LEARNED labels on each slot.
-- pokefirered/src/party_menu.c:856 DisplayPartyPokemonDataForMoveTutorOrEvolutionItem
-- -> DisplayPartyPokemonDataToTeachMove.
package.path = "./?.lua;./?/init.lua;" .. package.path
require("tests.game3_cache").stubSpeciesNames()
require("tests.fixture_data.game3_items").install()
-- pokefirered/src/data/party_menu.h:634 sDescriptionStringTable
local DESCRIPTIONS = { [0] = "NO USE", "ABLE", "FIRST", "SECOND", "THIRD", "ABLE",
"NOT ABLE", "ABLE!", "NOT ABLE!", "LEARNED" }
package.loaded["src.core.game3.rom_text"] = {
plain = function(key) return key end, box = function(key) return key end,
ascii = function(key) return key end, has = function() return true end,
key = function(n, i) return n .. "[" .. tostring(i) .. "]" end,
at = function(n, i)
if n == "sDescriptionStringTable" then return DESCRIPTIONS[i] end
return n .. "[" .. tostring(i) .. "]"
end,
count = function() return 0 end, list = function() return {} end,
lazy = function(map) return setmetatable({}, { __index = function(_, k) return map[k] end }) end,
}
local ItemsData = require("src.core.game3.items_data")
local Pokemon = require("src.core.game3.pokemon")
local TM06, TOXIC = 294, 92
local BULBASAUR, CHARMANDER, GEODUDE = 1, 4, 74
ItemsData.isTm = function(id) return id == TM06 end
Pokemon.moveFromTmItem = function(id) return id == TM06 and TOXIC or nil end
Pokemon.canLearnTmItem = function(species) return species ~= CHARMANDER end
local PartyMenu = require("src.ui.game3.party_menu")
local failed = 0
local function check(cond, msg)
if cond then
print("[ok] " .. msg)
else
failed = failed + 1
print("[FAIL] " .. msg)
end
end
local function mon(species, moves)
return { species = species, speciesId = species, level = 10, hp = 20, maxHp = 20,
moves = moves or { 33 }, pp = { 35 } }
end
local party = {
mon(BULBASAUR),
mon(CHARMANDER),
mon(GEODUDE, { 33, TOXIC }),
{ species = BULBASAUR, speciesId = BULBASAUR, isEgg = true, hp = 0, maxHp = 0, moves = {} },
}
PartyMenu.show(party, nil, { mode = "use", item = TM06 })
PartyMenu._item = TM06
PartyMenu.mode = "use"
check(PartyMenu.slotDescription(1) == "ABLE!",
"a compatible mon reads ABLE!, got " .. tostring(PartyMenu.slotDescription(1)))
check(PartyMenu.slotDescription(2) == "NOT ABLE!",
"an incompatible mon reads NOT ABLE!, got " .. tostring(PartyMenu.slotDescription(2)))
check(PartyMenu.slotDescription(3) == "LEARNED",
"a mon that knows the move reads LEARNED, got " .. tostring(PartyMenu.slotDescription(3)))
check(PartyMenu.slotDescription(4) == "NOT ABLE!",
"an egg reads NOT ABLE!, got " .. tostring(PartyMenu.slotDescription(4)))
PartyMenu._item = 13 -- a Potion: no label
check(PartyMenu.slotDescription(1) == nil, "a non-TM item shows no label")
if failed > 0 then
print(string.format("[FAIL] %d check(s) failed", failed))
os.exit(1)
end
print("[PASS] game3_tm_able_desc_2520")