Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions docs/native-gen2-pc-mail.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Native GSC PC/mail core

The legacy Gen2 Mail module is absent and the Boxes alias has an incompatible
count contract. Native gift/delivery scripts are stubs; Gold/Silver mail
operands consume three bytes instead of two.

Add native slot-indexed party letters, ten-entry mailbox operations and a
14x20 PC facade over the existing save tables. Mail follows team removal
and reordering APIs, held letters block deposit/ordinary item replacement,
and last-healthy restrictions use the actual native HP/stats. Save
validation retains detached letters from quarantined carriers.

Raw ROM mail is decoded separately from dialogue and queued scripts. The
actual ScriptRunner gives letters and yields to the real party menu for all
five delivery outcomes. mail.read emits once per opening; mail.written
identifies compose/script. Item recognition uses imported semantic keys.
The primary pointer contracts are in pret/pokegold and pret/pokecrystal,
engine/overworld/scripting.asm (Script_givepokemail/Script_checkpokemail).

Validation: 208 PC/mail/codec checks and 69 binary ROM/actual runner/public
SDK checks pass. The current upstream baseline also has existing suite
failures; comparative engine/SDK results are recorded in the PR.

This standalone core contribution does not import the fork's separate UI,
capability facade, qualified runtime or packaged acceptance. Composition,
mailbox screens, SRAM conversion, stationery artwork and complete parity
are not claimed here. No private mod, ROM or generated artwork is included.
1 change: 1 addition & 0 deletions src/core/Data.lua
Original file line number Diff line number Diff line change
Expand Up @@ -1877,6 +1877,7 @@ function Data:seedDefaults()
end
seedMissingGen2Tilesets(self)
self:publishGen2Palettes()
require("src.pokemon.Gen2Mail").load(self)
ensureGen2WarpFallbacks(self)
ensureGen2HomeTextFallbacks(self)
end
Expand Down
4 changes: 3 additions & 1 deletion src/core/ModCompat.lua
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,9 @@ ModCompat.ALIASES = {
["src.world.gen2.Palettes"] = "src.render.PaletteFX",

-- save-side data
["src.core.gen2.Boxes"] = "src.pokemon.Boxes",
["src.core.gen2.Boxes"] = "src.pokemon.Gen2Boxes",
["src.core.gen2.Mail"] = "src.pokemon.Gen2Mail",
["src.ui.gen2.BoxMenu"] = "src.ui.BoxMenu",
["src.core.gen2.BugContest"] = "src.world.BugContest",
["src.core.gen2.ItemEffects"] = "src.inventory.ItemEffects",
["src.core.gen2.Save"] = "src.core.SaveData",
Expand Down
15 changes: 14 additions & 1 deletion src/core/SaveData.lua
Original file line number Diff line number Diff line change
Expand Up @@ -2030,6 +2030,13 @@ local function scrubMonList(list, where, save, data, report, slots)
if mon ~= nil
and (type(mon) ~= "table" or not known(data.pokemon, mon.species)) then
list[i] = nil
if where=="party" and GameVersion.isGen2() and type(mon)=="table"
and save.mail and save.mail.party and save.mail.party[i]
and require("src.pokemon.Gen2Mail").monHoldsMail(mon) then
-- Its letter moves to the mailbox/quarantine below; retaining a
-- second stationery on the quarantined carrier would duplicate it.
mon.item=nil
end
ensureOrphaned(save)
save.orphaned.mons[#save.orphaned.mons + 1] = mon
report.lostMons[#report.lostMons + 1] =
Expand Down Expand Up @@ -2160,6 +2167,10 @@ function SaveData.validate(save, data)
end
end
scrubMaps(save, data, report)
if GameVersion.isGen2() then
local Mail=require("src.pokemon.Gen2Mail")
Mail.load(data);Mail.validate(save,report)
end
local dex = save.pokedex
if type(dex) == "table" then
for _, key in ipairs({ "seen", "owned" }) do
Expand Down Expand Up @@ -2228,7 +2239,8 @@ function SaveData.validate(save, data)
-- an empty quarantine leaves no residue, so a vanilla save re-encodes
-- byte-identically
local orphaned = save.orphaned
if orphaned and #(orphaned.mons or {}) == 0 and #(orphaned.items or {}) == 0 then
if orphaned and #(orphaned.mons or {}) == 0 and #(orphaned.items or {}) == 0
and #(orphaned.mail or {})==0 then
save.orphaned = nil
end
return report
Expand All @@ -2239,6 +2251,7 @@ function SaveData.emptyReport(report)
-- restoreSave attaches one so a version bump alone still surfaces
local diff = report.modsDiff
return #report.lostMons == 0 and #report.lostItems == 0
and #(report.lostMail or {})==0 and #(report.restoredMail or {})==0
and #report.remappedMaps == 0 and #report.restoredMons == 0
and #report.restoredItems == 0 and not report.recovered
and (not diff or (#diff.added == 0 and #diff.removed == 0 and #diff.changed == 0))
Expand Down
8 changes: 3 additions & 5 deletions src/import/Gen2ScriptOps.lua
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ Gen2ScriptOps.COMMANDS = {
{ "takecoins", "w" }, { "checkcoins", "w" }, { "addcellnum", "b" },
{ "delcellnum", "b" }, { "checkcellnum", "b" }, { "checktime", "b" },
{ "checkpoke", "b" }, { "givepoke", "bbbbbD" }, { "giveegg", "bb" },
{ "givepokemail", "T" }, { "checkpokemail", "T" }, { "checkevent", "w" },
{ "givepokemail", "d" }, { "checkpokemail", "d" }, { "checkevent", "w" },
{ "clearevent", "w" }, { "setevent", "w" }, { "checkflag", "w" },
{ "clearflag", "w" }, { "setflag", "w" }, { "wildon", "" },
{ "wildoff", "" }, { "xycompare", "d" }, { "warpmod", "bbb" },
Expand Down Expand Up @@ -95,19 +95,17 @@ Gen2ScriptOps.COMMANDS = {

-- Crystal's ScriptCommandTable is NOT Gold's with entries appended. It
-- inserts `farjumptext` at $52, which shifts all 88 commands after it down by
-- one, and it changes the operand width of seven commands that kept their
-- one, and it changes the operand width of several commands that kept their
-- names. Disassembling a Crystal ROM with the Gold table desyncs on the
-- first shifted command and never recovers, so Crystal gets its own table.
--
-- Generated from pret/pokecrystal macros/scripts/events.asm: the `const
-- <name>_command` run gives the order, and each MACRO body gives the operand
-- widths. Where a command kept both its name and its total width, Gold's
-- spec letters are reused verbatim so the semantic distinctions (p vs t vs d)
-- carry over; the seven that changed width are listed below.
-- carry over; the width differences are listed below.
--
-- $10 memcallasm dba -> dw (D -> d)
-- $2F givepokemail dba -> dw (T -> d)
-- $30 checkpokemail dba -> dw (T -> d)
-- $92 reloadend 0 args -> 1 ("" -> b)
-- $98 phonecall dba -> dw (T -> w)
-- $A0 swarm +map_id (bb -> bbb)
Expand Down
35 changes: 34 additions & 1 deletion src/import/RomExtractorGen2.lua
Original file line number Diff line number Diff line change
Expand Up @@ -7705,6 +7705,35 @@ function RomExtractorGen2:gen2StoneTable(bank, address, pool)
return ok and rows and #rows > 0 and rows or nil
end

-- Mail operands are raw charmap records, not dialogue streams or scripts.
-- Both retail mail commands use a word in the current script bank.
function RomExtractorGen2:gen2MailOperand(bank,address,gift)
if not self:gen2InRom(bank,address) then return nil end
bank=self:gen2Home(bank,address)
self._gen2MailCharmap=self._gen2MailCharmap or self:readSourceTable("charmap")
local limit=bank==0 and 0x4000 or 0x8000
local ok,result=pcall(function()
local item
if gift then
item=string.format("ITEM_%03d",self.rom:byte(bank,address));address=address+1
end
local out={}
for offset=0,31 do
if address+offset>=limit then return nil end
local byte=self.rom:byte(bank,address+offset)
if byte==0x50 then break end
if byte==0x4E or byte==0x4F then out[#out+1]="\n"
else
local ch=self._gen2MailCharmap[tostring(byte)]
if type(ch)~="string" or ch:sub(1,1)=="<" then return nil end
out[#out+1]=ch
end
end
return {item=item,message=table.concat(out)}
end)
return ok and result or nil
end

function RomExtractorGen2:gen2DecodeScript(bank, address, label, pool)
-- Crystal inserts farjumptext at $52 and shifts 88 commands after it, so
-- the table has to be chosen per version -- decoding Crystal with Gold's
Expand Down Expand Up @@ -7754,7 +7783,11 @@ function RomExtractorGen2:gen2DecodeScript(bank, address, label, pool)
for i = 1, #spec do
local kind = spec:sub(i, i)
local value
if kind == "b" then
if name=="givepokemail" or name=="checkpokemail" then
local from,at=bank,self.rom:word(bank,pc)
if kind=="T" then from,at=self.rom:byte(bank,pc),self.rom:word(bank,pc+1) end
value=self:gen2MailOperand(from,at,name=="givepokemail") or false
elseif kind == "b" then
value = self.rom:byte(bank, pc)
elseif kind == "w" or kind == "d" then
value = self.rom:word(bank, pc)
Expand Down
2 changes: 1 addition & 1 deletion src/import/RomImporter.lua
Original file line number Diff line number Diff line change
Expand Up @@ -1306,7 +1306,7 @@ local function markerFor(version)
-- Only these three caches need rebuilding; other markers remain stable.
-- v8: field.gen2TradeAnim, GSC's own trade movie art (Gen2TradeArt).
if version == "gold" or version == "silver" or version == "crystal" then
revision = "gen2-field-encounters-v8:"
revision = "gen2-field-encounters-v8:gen2-mail-v1:"
end
-- field.tradeArt: the cable-trade movie's tiles out of the ROM
-- (Gen1TradeArt); without it the trade ran on drawn stand-ins.
Expand Down
6 changes: 6 additions & 0 deletions src/inventory/Bag.lua
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,9 @@ local function heldItemForm(mon, data)
end

function Bag.giveHeld(save, mon, id, data)
if require("src.core.GameVersion").isGen2() and require("src.pokemon.Gen2Mail").monHoldsMail(mon) then
return nil,"mail"
end
local previous = mon.item
mon.item = id
Bag.remove(save, id, 1)
Expand All @@ -283,6 +286,9 @@ end
-- TakeItem: nil when the mon holds nothing, or nil + "full" when the pack has
-- no room for it (the mon keeps holding it).
function Bag.takeHeld(save, mon, data)
if require("src.core.GameVersion").isGen2() and require("src.pokemon.Gen2Mail").monHoldsMail(mon) then
return nil,"mail"
end
local id = mon.item
if not id then return nil end
if not Bag.add(save, id, 1, data) then return nil, "full" end
Expand Down
1 change: 1 addition & 0 deletions src/mods/Loader.lua
Original file line number Diff line number Diff line change
Expand Up @@ -1316,6 +1316,7 @@ function Loader:load(data)
-- wraps and flags the previous load put on engine tables come off first.
ModSeams.unwindAll()
self.baseData = data
require("src.pokemon.Gen2Mail").load(data)
-- every registry folds against the pristine view of its Data target;
-- resolution is lazy so optional namespaces may appear later
for _, registry in pairs(self.content) do
Expand Down
106 changes: 106 additions & 0 deletions src/pokemon/Gen2Boxes.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md.
-- Party/storage operations over the native save. The legacy Gen2 count()
-- counts residents, unlike the shared storage module's box-count query.
local Storage=require("src.pokemon.Boxes")
local Mail=require("src.pokemon.Gen2Mail")
local Party=require("src.pokemon.Party")
local Strings=require("src.core.Strings")
local B={PARTY_SIZE=6}
local function valid(index,max) return type(index)=="number" and index>=1 and index<=max and index%1==0 end
function B.defaultName(n) return "BOX"..tostring(n) end
function B.name(save,n) return save and save.boxNames and save.boxNames[n] or B.defaultName(n) end
function B.rename(save,n,name)
if not save or not valid(n,Storage.count()) or type(name)~="string" then return false end
save.boxNames=save.boxNames or {};save.boxNames[n]=name;return true
end
function B.box(save,n)
if not save then return {} end
n=n or save.currentBox or 1
if not valid(n,Storage.count()) then return {} end
return Storage.ensure(save)[n]
end
function B.count(save,n) return Storage.used(B.box(save,n)) end
function B.isFull(save,n) return B.count(save,n)>=Storage.capacity() end
function B.setCurrent(save,n)
if not save or not valid(n,Storage.count()) then return false end
Storage.ensure(save);save.currentBox=n;return true
end
function B.healthyCount(party)
local n=0;for _,mon in ipairs(party or {}) do if (mon.hp or 0)>0 and not Party.isEgg(mon) then n=n+1 end end
return n
end
function B.canUsePc(save)
if not save or #(save.party or {})==0 then return false,Strings("You'll need a\nPOKéMON to call\fwith.") end
return true
end
function B.canDeposit(save,n,box)
local mon=save and save.party and save.party[n]
if not mon then return false,"There is no POKéMON there." end
box=box or save.currentBox or 1
if not valid(box,Storage.count()) then return false,"Invalid BOX." end
if B.isFull(save,box) then return false,"The BOX is full." end
if (mon.hp or 0)>0 and not Party.isEgg(mon) and B.healthyCount(save.party)<=1 then return false,Strings("You can't deposit\nthe last POKéMON!") end
if Mail.monHoldsMail(mon) then return false,"Remove MAIL." end
return true
end
function B.restorePP(mon,data)
require("src.pokemon.Pokemon").restorePP(mon,data and data.moves)
end
local function recover(mon,data)
if not mon.stats and data and data.pokemon then require("src.pokemon.Stats").ensure(data.pokemon[mon.species],mon) end
mon.status,mon.statusTurns=nil,nil
mon.hp=Party.isEgg(mon) and 0 or (mon.stats and mon.stats.hp or mon.maxHp or mon.hp)
end
function B.enterBox(mon,data)
if mon then B.restorePP(mon,data);recover(mon,data) end
return mon
end
local function remove(box,n)
local mon=box[n];box[n]=nil
-- Shift the occupied suffix without trusting Lua's length on sparse imports.
local nextSlot=n+1
while nextSlot<=Storage.capacity() do
if box[nextSlot]~=nil then box[n]=box[nextSlot];box[nextSlot]=nil;n=n+1 end
nextSlot=nextSlot+1
end
return mon
end
function B.deposit(save,n,box,data)
local ok,why=B.canDeposit(save,n,box);if not ok then return false,why end
local target=B.box(save,box);local to=Storage.firstFree(target)
local mon=table.remove(save.party,n);Mail.removeSlot(save,n)
target[to]=mon;B.enterBox(mon,data);return true,mon
end
function B.canWithdraw(save,box,n)
if not save or not valid(n,Storage.capacity()) or not B.box(save,box)[n] then return false,"There is no POKéMON there." end
if #(save.party or {})>=6 then return false,Strings("You can't take\nany more POKéMON.") end
return true
end
function B.withdraw(save,box,n,data)
local ok,why=B.canWithdraw(save,box,n);if not ok then return false,why end
local mon=remove(B.box(save,box),n);recover(mon,data)
save.party=save.party or {};save.party[#save.party+1]=mon;return true,mon
end
function B.release(save,box,n)
local target=B.box(save,box);if not valid(n,Storage.capacity()) or not target[n] then return false,"There is no POKéMON there." end
if Party.isEgg(target[n]) then return false,"You can't release an EGG!" end
return true,remove(target,n)
end
function B.releaseFromParty(save,n)
local mon=save and save.party and save.party[n]
if not mon then return false,"There is no POKéMON there." end
if Party.isEgg(mon) then return false,"You can't release an EGG!" end
local removed=table.remove(save.party,n);Mail.removeSlot(save,n);return true,removed
end
function B.move(save,from,n,to)
if from==to then return false,"It's already there." end
if not valid(to,Storage.count()) then return false,"Invalid BOX." end
local source=B.box(save,from);if not source[n] then return false,"There is no POKéMON there." end
if B.isFull(save,to) then return false,"The BOX is full." end
local target=B.box(save,to);local mon=remove(source,n)
target[Storage.firstFree(target)]=mon;return true,mon
end
return setmetatable(B,{__index=function(_,key)
if key=="NUM_BOXES" then return Storage.count() end
if key=="MONS_PER_BOX" then return Storage.capacity() end
end})
Loading