Merge pull request #2755 from Nexhas28/debug/save-editor-volcanic-ash-2752-corrected

This commit is contained in:
bryanthaboi
2026-10-07 22:44:52 -04:00
committed by GitHub
7 changed files with 262 additions and 1 deletions
@@ -0,0 +1,200 @@
-- #2752: RSE Volcanic Ash is VAR_ASH_GATHER_COUNT, not a separate wallet field.
package.path = package.path .. ";./?.lua;./?/init.lua;./tools/save-editor/?.lua;./tools/save-editor/panels/?.lua"
love = require("tests.love_stub")
local T = require("tests.harness")
local Version = require("src.core.GameVersion")
local Serializer = require("src.core.SaveSerializer")
local Codec = require("src.save_convert.Gen3Save")
local Compat = require("src.save_convert.Compat")
local Gen, Ops = require("Gen"), require("Ops")
local State, History = require("State"), require("History")
local Kit, Touch = require("Kit"), require("TouchEditor")
local Trainer, Items = require("Trainer"), require("Items")
local ASH = 0x4048 -- pokeemerald/include/constants/vars.h:92; pokeruby VAR_ASH_GATHER_COUNT
local SUPPORTED = { ruby = true, sapphire = true, emerald = true }
local PREFIX = { ruby = "RU_", sapphire = "SA_", emerald = "EM_" }
local function state(save, version)
local s = State.new()
s.save, s.version = save, version
s.save.pcItems = s.save.pcItems or {}
Gen.ensureBoxes(s.save)
return s
end
local function snapshot(s)
return { bytes = Serializer.encode(s.save), dirty = s.dirty, revision = s.revision,
token = s.historyToken, undo = #(s.undoStack or {}), redo = #(s.redoStack or {}) }
end
local function unchanged(s, before, msg) T.same(snapshot(s), before, msg) end
local function amount(save) return Gen.getVar(save, ASH) end
local function seed(v)
return { version = v, generation = 3, map = PREFIX[v] .. "LITTLEROOT_TOWN", x = 5, y = 8,
name = "BRENDAN", trainerId = 1234, secretId = 5678, gender = 0,
money = 123456, coins = 4321, vars = { [ASH] = 500, [ASH + 1] = 987 },
party = {}, pcItems = {}, boxes = {} }
end
local function export(v, save)
local bytes, err = Codec.forVersion(v).exportPort(save, {
version = v, metGame = Version.gameCode(v), itemId = tonumber,
toNational = function() return nil end,
mapLayoutId = function() return 1 end,
healWarp = function() return { group = 0, num = 9, warpId = -1, x = 5, y = 8 } end,
})
return assert(bytes, err)
end
for _, v in ipairs({ "ruby", "sapphire", "emerald" }) do
Version.set(v)
local C = Codec.forVersion(v)
local raw = export(v, seed(v))
for _, value in ipairs({ 0, 1234, 9999 }) do
local s = state(assert(C.importPort(raw, v)), v)
T.eq(amount(s.save), 500, v .. " reads imported ash variable")
local baseline = export(v, Serializer.decode(Serializer.encode(s.save)))
T.eq(Ops.setTrainerProperty(s, "volcanicAsh", value), true, v .. " accepts ash " .. value)
T.eq(amount(s.save), value, v .. " writes canonical ash variable")
T.eq(s.save.volcanicAsh, nil, v .. " does not invent a duplicate currency field")
T.eq(s.dirty, true, v .. " edit marks dirty")
T.eq(#(s.undoStack or {}), 1, v .. " edit records one undo")
T.eq(s.status, "Volcanic Ash updated", v .. " edit status")
local persisted = Serializer.decode(Serializer.encode(s.save))
T.eq(amount(persisted), value, v .. " Lua persistence keeps ash")
local out = export(v, persisted)
local cart = assert(C.decode(out))
T.eq(cart.vars[ASH] or 0, value, v .. " cart variable stores ash")
T.eq(cart.vars[ASH + 1], 987, v .. " neighboring variable unchanged")
T.eq(cart.money, 123456, v .. " money unchanged")
T.eq(cart.coins, 4321, v .. " coins unchanged")
local changed = 0
for i = 1, #out do if out:byte(i) ~= baseline:byte(i) then changed = changed + 1 end end
T.check(changed <= 4, v .. " only ash u16 and sector checksum change (" .. changed .. ")")
local family = v == "emerald" and "emerald" or "rs"
local blocks = assert(Compat.gen3Blocks(out, family))
local original = assert(Compat.gen3Blocks(baseline, family))
-- Independent raw SaveBlock1 offset check (not the editor/codec var getter).
-- pokeemerald vars start at 0x139C; pokeruby vars start at 0x1340.
local ashOff = (v == "emerald" and 0x139C or 0x1340) + (ASH - 0x4000) * 2
T.eq(blocks.sb1:byte(ashOff + 1) + blocks.sb1:byte(ashOff + 2) * 256, value,
v .. " raw cartridge ash word")
local normalized = blocks.sb1:sub(1, ashOff) .. original.sb1:sub(ashOff + 1, ashOff + 2)
.. blocks.sb1:sub(ashOff + 3)
T.eq(normalized, original.sb1, v .. " all other SaveBlock1 bytes preserved")
T.eq(blocks.sb2, original.sb2, v .. " all SaveBlock2 bytes preserved")
T.eq(blocks.storage, original.storage, v .. " all storage bytes preserved")
T.eq(amount(assert(C.importPort(out, v))), value, v .. " cart reimport keeps ash")
T.eq(History.undo(s), true, v .. " undo succeeds")
T.eq(amount(s.save), 500, v .. " undo restores imported ash")
T.eq(s.dirty, false, v .. " undo to initial save is clean")
T.eq(History.redo(s), true, v .. " redo succeeds")
T.eq(amount(s.save), value, v .. " redo restores ash")
local before = snapshot(s)
T.eq(Ops.setTrainerProperty(s, "volcanicAsh", value), true, v .. " same value accepted")
unchanged(s, before, v .. " same value adds no history or mutations")
end
local s = state(seed(v), v)
T.eq(Ops.setTrainerProperty(s, "volcanicAsh", "777"), true, v .. " numeric string accepted")
T.eq(amount(s.save), 777, v .. " numeric string stored as number")
for _, draft in ipairs({ { -1 }, { 10000 }, { 1.5 }, { 0/0 }, { math.huge }, { -math.huge },
{ "" }, { "ash" }, { "3.5" }, { false }, {} }) do
local before = snapshot(s)
T.eq(Ops.setTrainerProperty(s, "volcanicAsh", draft[1]), false, v .. " invalid ash rejected")
unchanged(s, before, v .. " invalid ash leaves save/history unchanged")
if Gen.setVolcanicAsh then
T.eq(Gen.setVolcanicAsh(s.save, draft[1], v), false, v .. " direct setter rejects invalid ash")
unchanged(s, before, v .. " invalid direct setter leaves save/history unchanged")
end
T.eq(s.status, "Volcanic Ash must be a whole number from 0 to 9999", v .. " validation feedback")
end
end
-- Capture the real touch-value controls and test their apply callbacks too.
local rows, realValue = {}, Touch.value
Touch.value = function(s, kit, id, title, value, limits, x, y, w, apply, issue)
rows[id] = { title = title, value = value, limits = limits, apply = apply }
return realValue(s, kit, id, title, value, limits, x, y, w, apply, issue)
end
local function panel(s, which, width, height)
rows = {}
Kit.layout(width, height)
Kit.blockClicks = false
Kit.beginFrame(-1, -1, false, 0)
if which == "wallet" then s.itemView = "wallet"; Items.draw(s, Kit, 0, 0, width, height)
else Trainer.draw(s, Kit, 0, 0, width, height) end
Kit.endFrame()
return rows[(which == "wallet" and "wallet-" or "trainer-") .. "volcanicAsh"]
end
for _, v in ipairs(Version.ORDER) do
local info = assert(Version.info(v))
Version.set(v)
local eligible = SUPPORTED[v] == true
local s = state({ version = v, generation = info.generation, player = { name = "ASH" },
vars = { [ASH] = 7 } }, v)
local before = snapshot(s)
if Gen.hasVolcanicAsh then T.eq(Gen.hasVolcanicAsh(s.save, s.version), eligible, v .. " adapter edition gate") end
if not eligible and Gen.setVolcanicAsh then
T.eq(Gen.setVolcanicAsh(s.save, 15, s.version), false, v .. " direct unsupported setter rejected")
unchanged(s, before, v .. " direct unsupported setter does not mutate")
T.eq(Gen.volcanicAsh(s.save, s.version), 0, v .. " unsupported currency reads zero")
end
T.eq(Ops.setTrainerProperty(s, "volcanicAsh", 15), eligible, v .. " operation edition gate")
if not eligible then unchanged(s, before, v .. " unsupported save not mutated") end
for _, size in ipairs({ { 720, 1400 }, { 1280, 720 } }) do
for _, which in ipairs({ "trainer", "wallet" }) do
before = snapshot(s)
local row = panel(s, which, size[1], size[2])
T.eq(row ~= nil, eligible, v .. " " .. which .. " row eligibility at " .. size[1])
unchanged(s, before, v .. " drawing does not mutate save/history")
if row then
T.eq(row.title, "Volcanic Ash", which .. " currency label")
T.eq(row.value, amount(s.save), which .. " reads canonical ash")
T.eq(row.limits.lo, 0, which .. " minimum")
T.eq(row.limits.hi, 9999, which .. " original-game maximum")
T.check(row.limits.help:find("Soot Sack", 1, true), which .. " explains currency")
T.eq(row.apply(2345), true, which .. " control applies edits")
T.eq(amount(s.save), 2345, which .. " callback writes ash")
end
end
end
end
Touch.value = realValue
-- Public adapter contract: edition routing must not depend on active cart;
-- older port saves can lack vars, and serialized numeric keys may be strings.
T.check(type(Gen.hasVolcanicAsh) == "function", "Gen.hasVolcanicAsh exists")
T.check(type(Gen.volcanicAsh) == "function", "Gen.volcanicAsh exists")
T.check(type(Gen.setVolcanicAsh) == "function", "Gen.setVolcanicAsh exists")
if Gen.hasVolcanicAsh and Gen.volcanicAsh and Gen.setVolcanicAsh then
for _, v in ipairs({ "ruby", "sapphire", "emerald" }) do
Version.set("firered")
local save = { version = v, generation = 3, vars = { [tostring(ASH)] = 21, [ASH + 1] = 100 } }
T.eq(Gen.volcanicAsh(save), 21, v .. " reads string-key ash with another cart active")
T.eq(Gen.setVolcanicAsh(save, 42), true, v .. " routes by save edition")
T.eq(save.vars[ASH], 42, v .. " canonical numeric key")
T.eq(save.vars[tostring(ASH)], nil, v .. " stale string alias removed")
T.eq(save.vars[ASH + 1], 100, v .. " unrelated var preserved")
save = { generation = 3 }
T.eq(Gen.volcanicAsh(save, v), 0, v .. " missing vars default zero")
T.eq(Gen.setVolcanicAsh(save, 10, v), true, v .. " initializes missing vars with edition argument")
T.eq(save.vars[ASH], 10, v .. " missing vars now canonical")
local prior = Serializer.encode(save)
T.eq(Gen.setVolcanicAsh(save, 10000, v), false, v .. " direct setter enforces cap")
T.eq(Serializer.encode(save), prior, v .. " invalid direct setter leaves save alone")
local s = state({ generation = 3 }, v)
local before = snapshot(s)
T.eq(Ops.setTrainerProperty(s, "volcanicAsh", 0), true, v .. " absent ash zero edit is a no-op")
unchanged(s, before, v .. " zero no-op does not create vars or history")
T.eq(Ops.setTrainerProperty(s, "volcanicAsh", 9999), true, v .. " Ops initializes missing vars")
T.eq(s.save.vars[ASH], 9999, v .. " Ops initializes canonical ash")
T.eq(#s.undoStack, 1, v .. " missing-vars edit records history")
T.eq(History.undo(s), true, v .. " missing-vars edit undoes")
T.eq(s.save.vars, nil, v .. " undo restores absent vars")
s = state({ version = v, generation = 3, vars = "malformed" }, v)
before = snapshot(s)
T.eq(Ops.setTrainerProperty(s, "volcanicAsh", 0), false, v .. " malformed vars rejected even for zero")
unchanged(s, before, v .. " malformed vars remain untouched")
end
end
local f = assert(io.open("tests/quick.list", "r"))
local quick = f:read("*a"); f:close()
T.check(quick:find("tests/engine/save_editor_volcanic_ash_2752.lua", 1, true), "registered in quick suite")
Version.set("red")
T.finish("save_editor_volcanic_ash_2752")
+1
View File
@@ -624,6 +624,7 @@ tests/engine/saffron_cries_bug2248_2249.lua
tests/engine/save_confirm_layout_bug1522.lua
tests/engine/save_convert_extra_flags.lua
tests/engine/save_convert_toggle_objects.lua
tests/engine/save_editor_volcanic_ash_2752.lua
tests/engine/save_editor_berry_powder_2698.lua
tests/engine/save_editor_buena_points_2640.lua
tests/engine/save_editor_custom_maps.lua
+31
View File
@@ -478,6 +478,37 @@ function Gen.setBerryPowder(save, amount, version)
return true
end
Gen.VOLCANIC_ASH_MAX = 9999 -- pokeemerald/src/field_tasks.c:773
function Gen.hasVolcanicAsh(save, version)
if type(save) ~= "table" or Gen.of(save, version) ~= 3 then return false end
local v = versionOf(save, version)
return v == "ruby" or v == "sapphire" or v == "emerald"
end
local function volcanicAshVar(save, version)
return require("src.core.game3.scripting.flags").forVersion(versionOf(save, version))
.VAR_IDS.VAR_ASH_GATHER_COUNT
end
function Gen.volcanicAsh(save, version)
if not Gen.hasVolcanicAsh(save, version) or type(save.vars) ~= "table" then return 0 end
-- Read by the save's edition, not the currently active cart. Flags.getVar
-- accepts both canonical numeric keys and legacy serialized string keys.
return tonumber(require("src.core.game3.scripting.flags").getVar(save, nil,
volcanicAshVar(save, version))) or 0
end
function Gen.setVolcanicAsh(save, amount, version)
if not Gen.hasVolcanicAsh(save, version) or type(amount) ~= "number" or amount ~= amount
or amount < 0 or amount > Gen.VOLCANIC_ASH_MAX or amount ~= math.floor(amount) then return false end
if save.vars ~= nil and type(save.vars) ~= "table" then return false end
if Gen.volcanicAsh(save, version) == amount then return true end
save.vars = save.vars or {}
require("src.core.game3.scripting.flags").setVar(save, nil, volcanicAshVar(save, version), amount)
return true
end
function Gen.playerGender(save)
if type(save) ~= "table" then return "male" end
if save.gender == 1 or (save.player and (save.player.gender == 1 or save.player.gender == "female")) then
+16
View File
@@ -814,6 +814,22 @@ function Ops.setTrainerProperty(S, key, value)
if Gen.buenaPoints(S.save, S.version) == n then return true end
if not Gen.setBuenaPoints(S.save, n, S.version) then return Ops.say(S, "Invalid Crystal Buena state") end
return Ops.mark(S, "Buena points updated")
elseif key == "volcanicAsh" then
if not Gen.hasVolcanicAsh(S.save, S.version) then
return Ops.say(S, "Volcanic Ash is only in Ruby, Sapphire and Emerald")
end
local n = tonumber(value)
if not n or n ~= n or n < 0 or n > Gen.VOLCANIC_ASH_MAX or n ~= math.floor(n) then
return Ops.say(S, "Volcanic Ash must be a whole number from 0 to 9999")
end
if S.save.vars ~= nil and type(S.save.vars) ~= "table" then
return Ops.say(S, "Invalid Volcanic Ash variable state")
end
if Gen.volcanicAsh(S.save, S.version) == n then return true end
if not Gen.setVolcanicAsh(S.save, n, S.version) then
return Ops.say(S, "Invalid Volcanic Ash variable state")
end
return Ops.mark(S, "Volcanic Ash updated")
elseif key == "berryPowder" then
if not Gen.hasBerryPowder(S.save, S.version) then
return Ops.say(S, "Berry Powder is only in FireRed, LeafGreen and Emerald")
+5
View File
@@ -89,6 +89,11 @@ FireRed, LeafGreen and Emerald also show Berry Powder in Trainer and Items /
Wallet. It accepts whole numbers from 0 to 99999 and supports Undo/Redo; the
.sav export re-encrypts it with the save's key. Ruby and Sapphire have no Berry
Powder, so the row is hidden there.
Ruby, Sapphire and Emerald also show Volcanic Ash in Trainer and Items / Wallet.
It accepts whole numbers from 0 to 9999 and supports Undo/Redo. Edits update
`VAR_ASH_GATHER_COUNT`, the Soot Sack currency spent at the Glass Workshop;
Lua saves and cartridge `.sav` export preserve that same variable. Other
editions do not show the row.
**Undo/Redo** and Ctrl/Cmd+Z (Shift+Z to redo) retain up to eight session snapshots.
Snapshots preserve unknown metadata. Saving establishes the clean history point;
+4
View File
@@ -90,6 +90,10 @@ local function drawView(S, Kit, x, y, w, h)
fields[#fields + 1] = { "berryPowder", "Berry Powder", Gen.berryPowder(S.save, S.version),
Gen.BERRY_POWDER_MAX, "Powder from Berry Crush, spent at the Berry Powder shop. Choose 0 to 99999." }
end
if Gen.hasVolcanicAsh(S.save, S.version) then
fields[#fields + 1] = { "volcanicAsh", "Volcanic Ash", Gen.volcanicAsh(S.save, S.version),
Gen.VOLCANIC_ASH_MAX, "Ash collected in the Soot Sack, spent at the Glass Workshop. Choose 0 to 9999." }
end
for _, f in ipairs(fields) do
cy = cy
+ Touch.value(
+5 -1
View File
@@ -27,6 +27,9 @@ function M.draw(S, Kit, x, y, w, h)
if Gen.hasBerryPowder(S.save, S.version) then
fields[#fields + 1] = { "berryPowder", "Berry Powder", Gen.berryPowder(S.save, S.version) }
end
if Gen.hasVolcanicAsh(S.save, S.version) then
fields[#fields + 1] = { "volcanicAsh", "Volcanic Ash", Gen.volcanicAsh(S.save, S.version) }
end
local contentH = S._trainerHeight or 0
S.trainerScroll = Kit.scrollPixels(x, y, w, h, S.trainerScroll or 0, contentH)
Kit.pushClip(x, y, w, h)
@@ -35,7 +38,7 @@ function M.draw(S, Kit, x, y, w, h)
for _, f in ipairs(fields) do
if f[1] ~= "name" then
local hi = ({ id = 65535, secretId = 65535, money = 999999, coins = 9999, buenaPoints = 30,
berryPowder = Gen.BERRY_POWDER_MAX })[f[1]]
berryPowder = Gen.BERRY_POWDER_MAX, volcanicAsh = Gen.VOLCANIC_ASH_MAX })[f[1]]
cy = cy
+ Touch.value(
S,
@@ -50,6 +53,7 @@ function M.draw(S, Kit, x, y, w, h)
or f[1] == "coins" and "Game Corner coins. Max fills the coin case."
or f[1] == "buenaPoints" and "Blue Card points for Buena's prizes. Choose 0 to 30."
or f[1] == "berryPowder" and "Powder from Berry Crush, spent at the Berry Powder shop. Choose 0 to 99999."
or f[1] == "volcanicAsh" and "Ash collected in the Soot Sack, spent at the Glass Workshop. Choose 0 to 9999."
or "Part of your trainer identity. Changing it can affect who owns a Pokémon.",
},
cx,