From b3ea11d27eea033276afeb7972f548ce020cfd3c Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sun, 25 Oct 2026 22:00:38 +0100 Subject: [PATCH 1/2] Implement native GSC mail and PC core over existing saves --- docs/native-gen2-pc-mail.md | 27 ++++ src/core/Data.lua | 1 + src/core/ModCompat.lua | 3 +- src/core/SaveData.lua | 15 +- src/import/Gen2ScriptOps.lua | 8 +- src/import/RomExtractorGen2.lua | 35 ++++- src/import/RomImporter.lua | 2 +- src/inventory/Bag.lua | 6 + src/mods/Loader.lua | 1 + src/pokemon/Gen2Boxes.lua | 106 +++++++++++++ src/pokemon/Gen2Mail.lua | 224 ++++++++++++++++++++++++++++ src/script/Gen2Commands.lua | 40 ++++- src/script/Gen2ScriptVM.lua | 7 +- tests/engine/gen2_mail_bytecode.lua | 108 ++++++++++++++ tests/engine/gen2_pc_mail.lua | 119 +++++++++++++++ 15 files changed, 684 insertions(+), 18 deletions(-) create mode 100644 docs/native-gen2-pc-mail.md create mode 100644 src/pokemon/Gen2Boxes.lua create mode 100644 src/pokemon/Gen2Mail.lua create mode 100644 tests/engine/gen2_mail_bytecode.lua create mode 100644 tests/engine/gen2_pc_mail.lua diff --git a/docs/native-gen2-pc-mail.md b/docs/native-gen2-pc-mail.md new file mode 100644 index 000000000..217832c72 --- /dev/null +++ b/docs/native-gen2-pc-mail.md @@ -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: 207 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. diff --git a/src/core/Data.lua b/src/core/Data.lua index 26a842ce5..34a867895 100644 --- a/src/core/Data.lua +++ b/src/core/Data.lua @@ -1877,6 +1877,7 @@ function Data:seedDefaults() end seedMissingGen2Tilesets(self) self:publishGen2Palettes() + require("src.pokemon.Gen2Mail").load(self) ensureGen2WarpFallbacks(self) ensureGen2HomeTextFallbacks(self) end diff --git a/src/core/ModCompat.lua b/src/core/ModCompat.lua index ee5e198fd..cf627ef11 100644 --- a/src/core/ModCompat.lua +++ b/src/core/ModCompat.lua @@ -66,7 +66,8 @@ 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.core.gen2.BugContest"] = "src.world.BugContest", ["src.core.gen2.ItemEffects"] = "src.inventory.ItemEffects", ["src.core.gen2.Save"] = "src.core.SaveData", diff --git a/src/core/SaveData.lua b/src/core/SaveData.lua index 2bfe53530..066771db4 100644 --- a/src/core/SaveData.lua +++ b/src/core/SaveData.lua @@ -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] = @@ -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 @@ -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 @@ -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)) diff --git a/src/import/Gen2ScriptOps.lua b/src/import/Gen2ScriptOps.lua index 7e7d90ce4..ec0ff1339 100644 --- a/src/import/Gen2ScriptOps.lua +++ b/src/import/Gen2ScriptOps.lua @@ -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" }, @@ -95,7 +95,7 @@ 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. -- @@ -103,11 +103,9 @@ Gen2ScriptOps.COMMANDS = { -- _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) diff --git a/src/import/RomExtractorGen2.lua b/src/import/RomExtractorGen2.lua index 90b74029e..38b7581f4 100644 --- a/src/import/RomExtractorGen2.lua +++ b/src/import/RomExtractorGen2.lua @@ -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 @@ -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) diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index 858974555..05bf4929d 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -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. diff --git a/src/inventory/Bag.lua b/src/inventory/Bag.lua index b09bf37e8..13d60a7e2 100644 --- a/src/inventory/Bag.lua +++ b/src/inventory/Bag.lua @@ -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) @@ -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 diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index 4094a38c4..847feeeff 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -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 diff --git a/src/pokemon/Gen2Boxes.lua b/src/pokemon/Gen2Boxes.lua new file mode 100644 index 000000000..a7272c589 --- /dev/null +++ b/src/pokemon/Gen2Boxes.lua @@ -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}) diff --git a/src/pokemon/Gen2Mail.lua b/src/pokemon/Gen2Mail.lua new file mode 100644 index 000000000..e85eed17a --- /dev/null +++ b/src/pokemon/Gen2Mail.lua @@ -0,0 +1,224 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- GSC letters belong to party slots; the PC mailbox holds detached letters. +-- Native item IDs are resolved by the imported semantic key, never a label. +local Runtime = require("src.mods.Runtime") +local Mail = { + MAIL_MSG_LENGTH=32, MAIL_LINE_LENGTH=16, MAILBOX_CAPACITY=10, + AUTHOR_LENGTH=10, PARTY_LENGTH=6, + POKEMAIL_WRONG_MAIL=0, POKEMAIL_CORRECT=1, POKEMAIL_REFUSED=2, + POKEMAIL_NO_MAIL=3, POKEMAIL_LAST_MON=4, + ITEMS={"FLOWER_MAIL","SURF_MAIL","LITEBLUEMAIL","PORTRAITMAIL","LOVELY_MAIL", + "EON_MAIL","MORPH_MAIL","BLUESKY_MAIL","MUSIC_MAIL","MIRAGE_MAIL"}, + INDEX={}, +} +local named, native, lastRead = {}, {}, nil +for i,id in ipairs(Mail.ITEMS) do named[id]=true;Mail.INDEX[id]=i-1 end +local function slot(n, max) + return type(n)=="number" and n==math.floor(n) and n>=1 and n<=max +end +function Mail.load(data) + for id in pairs(native) do Mail.INDEX[id]=nil end + native={};lastRead=nil + for i,id in ipairs(Mail.ITEMS) do Mail.INDEX[id]=i-1 end + for id,def in pairs(data and data.items or {}) do + if type(def)=="table" and named[def.key] then + native[id]=def.key;Mail.INDEX[id]=Mail.INDEX[def.key] + end + end +end +function Mail.isMail(id) return named[id]==true or native[id]~=nil end +function Mail.monHoldsMail(mon) return type(mon)=="table" and Mail.isMail(mon.item) end +function Mail.anyMonHoldingMail(save) + for _,mon in ipairs(save and save.party or {}) do if Mail.monHoldsMail(mon) then return true end end + return false +end +function Mail.state(save) + if type(save)~="table" then return {party={},box={}} end + if type(save.mail)~="table" then save.mail={party={},box={}} end + if type(save.mail.party)~="table" then save.mail.party={} end + if type(save.mail.box)~="table" then save.mail.box={} end + return save.mail +end +function Mail.characters(text) + local out={} + for ch in tostring(text or ""):gmatch("[%z\1-\127\194-\244][\128-\191]*") do out[#out+1]=ch end + return out +end +local function limit(text,n) + local chars=Mail.characters(text);return table.concat(chars,"",1,math.min(n,#chars)) +end +function Mail.trim(text) return limit(text,Mail.MAIL_MSG_LENGTH) end +function Mail.entry(item,message,author,authorId,species) + return {type=item,message=message or "",author=author or "",authorId=authorId or 0,species=species} +end +function Mail.armRead() lastRead=nil end +function Mail.get(save,n) Mail.armRead();return Mail.state(save).party[n] end +function Mail.set(save,n,entry) + if not slot(n,Mail.PARTY_LENGTH) then return false end + Mail.state(save).party[n]=entry;return true +end +function Mail.clear(save,n) if slot(n,Mail.PARTY_LENGTH) then Mail.state(save).party[n]=nil end end +function Mail.removeSlot(save,n) + if not slot(n,Mail.PARTY_LENGTH) or not (save and save.mail) then return end + local letters=Mail.state(save).party + for i=n,Mail.PARTY_LENGTH-1 do letters[i]=letters[i+1] end + letters[Mail.PARTY_LENGTH]=nil +end +function Mail.swapSlots(save,a,b) + if not slot(a,6) or not slot(b,6) or not (save and save.mail) then return end + local letters=Mail.state(save).party;letters[a],letters[b]=letters[b],letters[a] +end +local function written(save,n,mon,item,text,author,id,source) + if not save or not slot(n,6) or not Mail.isMail(item) then return false end + local entry=Mail.entry(item,Mail.trim(text),limit(author,10),id,mon and mon.species) + Mail.set(save,n,entry) + if Runtime.wants("mail.written") then + Runtime.emit("mail.written",{entry=entry,slot=n,mon=mon,message=entry.message,author=entry.author,source=source}) + end + return true +end +function Mail.compose(save,n,text,mon,item) + local player=save and save.player or {} + return written(save,n,mon,item,text,player.name or "",player.id or 0,"compose") +end +function Mail.give(save,item,text) + local party=save and save.party or {};local n=#party;local mon=party[n] + if not mon or not slot(n,6) or not Mail.isMail(item) then return false end + mon.item=item + return written(save,n,mon,item,text,mon.otName or mon.ot or "",mon.otId or 0,"script") +end +function Mail.lines(entry) + local text=type(entry)=="table" and tostring(entry.message or "") or "" + local top,bottom=text:match("^(.-)\n(.*)$") + if not top then + local chars=Mail.characters(text);top=table.concat(chars,"",1,math.min(16,#chars)) + bottom=#chars>16 and table.concat(chars,"",17) or "" + end + if type(entry)=="table" and Runtime.wants("mail.read") and lastRead~=entry then + lastRead=entry;Runtime.emit("mail.read",{entry=entry,message=entry.message,author=entry.author,top=top,bottom=bottom}) + end + return top,bottom +end +function Mail.mailbox(save) Mail.armRead();return Mail.state(save).box end +function Mail.mailboxCount(save) return #Mail.state(save).box end +function Mail.mailboxFull(save) return Mail.mailboxCount(save)>=10 end +function Mail.sendToPc(save,n) + local mon=save and save.party and save.party[n] + if not Mail.monHoldsMail(mon) or Mail.mailboxFull(save) then return false end + local state=Mail.state(save) + state.box[#state.box+1]=state.party[n] or Mail.entry(mon.item,"","",0,mon.species) + state.party[n]=nil;mon.item=nil;return true +end +function Mail.deleteFromPc(save,n) + local box=Mail.state(save).box + if not slot(n,#box) then return nil end + return table.remove(box,n) +end +function Mail.moveFromPcToParty(save,n,to) + local mon=save and save.party and save.party[to];local box=Mail.state(save).box + local letter=slot(n,#box) and box[n] + if not letter or not slot(to,6) or not mon or mon.item or require("src.pokemon.Party").isEgg(mon) then return false end + if not Mail.isMail(letter.type) then return false end + Mail.set(save,to,letter);mon.item=letter.type;table.remove(box,n);return true +end +function Mail.detachToBag(save,n,data) + local mon=save and save.party and save.party[n] + if not Mail.monHoldsMail(mon) then return false,"no_mail" end + if not require("src.inventory.Bag").add(save,mon.item,1,data) then return false,"full" end + mon.item=nil;Mail.clear(save,n);return true +end +function Mail.attachFromBag(save,n,item,text,data) + local mon=save and save.party and save.party[n] + if not mon or not slot(n,6) or require("src.pokemon.Party").isEgg(mon) then return false,"target" end + if Mail.monHoldsMail(mon) then return false,"mail" end + if not Mail.isMail(item) or not save.inventory or (save.inventory[item] or 0)<1 then return false,"item" end + local Bag=require("src.inventory.Bag") + -- Bag rules are pure store operations. Check the complete exchange before + -- changing either held state or the live acquisition-ordered inventory. + local shadow={inventory={}} + for id,count in pairs(save.inventory)do shadow.inventory[id]=count end + if save.bagOrder then shadow.bagOrder={};for i,id in ipairs(save.bagOrder)do shadow.bagOrder[i]=id end end + Bag.remove(shadow,item,1) + if mon.item and not Bag.add(shadow,mon.item,1,data) then return false,"full" end + for id in pairs(save.inventory)do save.inventory[id]=nil end + for id,count in pairs(shadow.inventory)do save.inventory[id]=count end + if save.bagOrder then + for i=#save.bagOrder,1,-1 do save.bagOrder[i]=nil end + for i,id in ipairs(shadow.bagOrder or {})do save.bagOrder[i]=id end + else save.bagOrder=shadow.bagOrder end + mon.item=item;Mail.compose(save,n,text,mon,item);return true +end +-- A carrier absent from the loaded dataset cannot keep a live party slot. +-- Retain its detached letter in the existing quarantine until mailbox room +-- is available, rather than attaching it to a later occupant of that slot. +function Mail.reclaimQuarantine(save,report) + local pending=save and save.orphaned and save.orphaned.mail + if type(pending)~="table" or #pending==0 then return end + local box=Mail.state(save).box;local i=1 + while i<=#pending and #box<10 do + local letter=pending[i] + if type(letter)=="table" and Mail.isMail(letter.type) then + box[#box+1]=table.remove(pending,i) + if report then report.restoredMail=report.restoredMail or {};report.restoredMail[#report.restoredMail+1]={author=letter.author} end + else i=i+1 end + end +end +function Mail.checkPokeMail(save,n,expected) + if n==nil then return Mail.POKEMAIL_REFUSED end + local mon=save and save.party and save.party[n] + if not Mail.monHoldsMail(mon) then return Mail.POKEMAIL_NO_MAIL end + local entry=Mail.get(save,n);local text=entry and entry.message or "" + if type(expected)~="string" or expected=="" or text:sub(1,#expected)~=expected then return Mail.POKEMAIL_WRONG_MAIL end + local other=false + for i,member in ipairs(save.party) do + if i~=n and (member.hp or 0)>0 and not require("src.pokemon.Party").isEgg(member) then other=true end + end + if (mon.hp or 0)>0 and not other then return Mail.POKEMAIL_LAST_MON end + table.remove(save.party,n);Mail.removeSlot(save,n);return Mail.POKEMAIL_CORRECT +end +function Mail.validate(save,report) + local lost={};if report then report.lostMail=lost end + if not save then return lost end + if save.mail==nil then Mail.reclaimQuarantine(save,report);return lost end + local raw=save.mail + local function reject(where,n,why,entry) + lost[#lost+1]={where=where,slot=n,why=why,entry=entry} + end + if type(raw)~="table" then reject("mail",nil,"not a table",raw);save.mail=nil;return lost end + local function clean(entry,where,n) + if type(entry)~="table" or not Mail.isMail(entry.type) then reject(where,n,"invalid letter",entry);return end + local out={};for k,v in pairs(entry) do out[k]=v end + out.message=Mail.trim(entry.message);out.author=limit(entry.author,10) + local id=tonumber(entry.authorId) or 0 + out.authorId=slot(id+1,65536) and id or 0 + if out.message~=entry.message or out.author~=entry.author or out.authorId~=entry.authorId then reject(where,n,"letter repaired",entry) end + return out + end + local party,box={},{} + if type(raw.party)=="table" then + for key,entry in pairs(raw.party) do + local n=tonumber(key) + if not slot(n,6) or not (save.party and save.party[n]) then + local letter=clean(entry,"party",key) + reject("party",key,"carrier unavailable",entry) + if letter then + save.orphaned=save.orphaned or {mons={},items={}} + save.orphaned.mail=save.orphaned.mail or {} + save.orphaned.mail[#save.orphaned.mail+1]=letter + end + else party[n]=clean(entry,"party",n) end + end + elseif raw.party~=nil then reject("party",nil,"not a table",raw.party) end + if type(raw.box)=="table" then + local keys={};for n in pairs(raw.box) do + if type(n)=="number" and n>=1 and n%1==0 then keys[#keys+1]=n else reject("box",n,"invalid slot",raw.box[n]) end + end + table.sort(keys) + for _,n in ipairs(keys) do + if #box>=10 then reject("box",n,"MAILBOX full",raw.box[n]) + else local entry=clean(raw.box[n],"box",n);if entry then box[#box+1]=entry end end + end + elseif raw.box~=nil then reject("box",nil,"not a table",raw.box) end + raw.party,raw.box=party,box;Mail.reclaimQuarantine(save,report);return lost +end +return Mail diff --git a/src/script/Gen2Commands.lua b/src/script/Gen2Commands.lua index a62fa0e17..3a34e80ab 100644 --- a/src/script/Gen2Commands.lua +++ b/src/script/Gen2Commands.lua @@ -23,6 +23,30 @@ local Strings = require("src.core.Strings") local Gen2Commands = {} +function Commands.g2_give_poke_mail(ctx,letter) + if type(letter)~="table" or type(letter.message)~="string" then + Logger.warn("givepokemail: unresolved native letter; import this cartridge again") + return + end + require("src.pokemon.Gen2Mail").give(ctx.save,letter.item,letter.message) +end + +function Commands.g2_check_poke_mail(ctx,letter) + local Mail=require("src.pokemon.Gen2Mail") + local picked + require("src.ui.Screens").push(ctx.game,"PartyMenu",{ + pickOnly=true, + onSwitch=function(mon) picked=mon;ctx.runner:resume() end, + onCancel=function() ctx.runner:resume() end, + }) + ctx.runner:yield() + local slot + for i,mon in ipairs(ctx.save.party or {}) do if mon==picked then slot=i;break end end + local expected=type(letter)=="table" and letter.message or nil + local result=Mail.checkPokeMail(ctx.save,slot,expected) + ctx.g2Var,ctx.lastCheck=result,result~=0 +end + local function scriptVar(ctx) return ctx.g2Var or 0 end @@ -3138,7 +3162,9 @@ function Commands.g2_return_nobus_aggron(ctx) end if not othersHealthy then return result(3) end for i, mon in ipairs(ctx.save.party) do - if mon == picked then table.remove(ctx.save.party, i) break end + if mon == picked then + table.remove(ctx.save.party,i);require("src.pokemon.Gen2Mail").removeSlot(ctx.save,i);break + end end result(2) end @@ -3335,6 +3361,7 @@ function Commands.g2_delete_party_mon(ctx) local _, index = orphanChoice(ctx) if not index then return end table.remove(ctx.save.party, index) + require("src.pokemon.Gen2Mail").removeSlot(ctx.save,index) ctx.g2PartyMon = nil end @@ -3724,6 +3751,7 @@ function Commands.g2_trade(ctx, index) -- reading at all -- so they all came over empty-handed. if trade.item then newMon.item = trade.item end table.remove(party, slot) + require("src.pokemon.Gen2Mail").removeSlot(ctx.save,slot) table.insert(party, newMon) local dex = ctx.save.pokedex if dex then @@ -3783,7 +3811,9 @@ function Commands.g2_return_shuckie(ctx) if not othersHealthy then return result(4) end if (picked.happiness or 0) >= 150 then return result(3) end for i, mon in ipairs(ctx.save.party) do - if mon == picked then table.remove(ctx.save.party, i) break end + if mon == picked then + table.remove(ctx.save.party,i);require("src.pokemon.Gen2Mail").removeSlot(ctx.save,i);break + end end result(2) end @@ -4290,7 +4320,7 @@ function Gen2Commands.dayCareDeposit(ctx, which, introText, leftText) return Commands.show_text(ctx, t._CantAcceptEggText or Strings("Sorry, but I can't\naccept an EGG.")) end - if picked.mail then + if picked.mail or require("src.pokemon.Gen2Mail").monHoldsMail(picked) then return Commands.show_text(ctx, t._RemoveMailText or Strings("Remove the MAIL\nfrom that #MON.")) end @@ -4305,7 +4335,9 @@ function Gen2Commands.dayCareDeposit(ctx, which, introText, leftText) end for i, mon in ipairs(save.party) do - if mon == picked then table.remove(save.party, i) break end + if mon == picked then + table.remove(save.party,i);require("src.pokemon.Gen2Mail").removeSlot(save,i);break + end end DayCare.deposit(save, which, picked) game.stringBuffer = Gen2Commands.dayCareName(game, picked) diff --git a/src/script/Gen2ScriptVM.lua b/src/script/Gen2ScriptVM.lua index 0d5c51cfe..427e3b7ef 100644 --- a/src/script/Gen2ScriptVM.lua +++ b/src/script/Gen2ScriptVM.lua @@ -261,11 +261,8 @@ L.phonecall = function(ir, s) emit(s, { "show_text", ir[2] }) end -- box closes itself. L.hangup = function() end --- Script_checkpokemail (25:$7608): does the party hold this mail? The port --- has no mail model, so answer "no" rather than leaving the branch to fall --- through on a stale flag. -L.checkpokemail = function(_, s) emit(s, { "g2_false" }) end -L.givepokemail = function() end +L.checkpokemail = function(ir,s) emit(s,{"g2_check_poke_mail",ir[2]}) end +L.givepokemail = function(ir,s) emit(s,{"g2_give_poke_mail",ir[2]}) end -- ------------------------------------------------------------------ no-ops -- Each of these was verified to touch only state the port does not model. diff --git a/tests/engine/gen2_mail_bytecode.lua b/tests/engine/gen2_mail_bytecode.lua new file mode 100644 index 000000000..c876925a8 --- /dev/null +++ b/tests/engine/gen2_mail_bytecode.lua @@ -0,0 +1,108 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local V=require('src.core.GameVersion') +local original=V.get() +V.set('crystal') +local data=T.fixtures.fresh() +require('src.render.Font').load(data) +data.items.ITEM_158={id='ITEM_158',key='FLOWER_MAIL',name='Letter',pocket='ITEM'} +local Mail=require('src.pokemon.Gen2Mail');Mail.load(data) +local Pokemon=require('src.pokemon.Pokemon') +local function mon() + return Pokemon.new(data,T.fixtures.ids.species[1],5,function(lo)return lo end) +end +local game={data=data,save={},stack={states={}}} +function game.stack:push(screen)self.states[#self.states+1]=screen;if screen.enter then screen:enter()end end +function game.stack:pop()return table.remove(self.states)end +function game.stack:top()return self.states[#self.states]end +local function press(screen,key) + game.input={wasPressed=function(_,k)return k==key end,isDown=function()return false end} + screen:update(1/60) +end +local function reset() + game.save={party={mon(),mon()},player={name='AUTHOR',id=17},inventory={ITEM_158=2},bagOrder={'ITEM_158'}} + game.stack.states={{parent=true}} +end +-- Exercise real party-menu callbacks and coroutine continuation, not a +-- substitute handler or a fabricated result variable. +local Runner=require('src.script.ScriptRunner') +local VM=require('src.script.Gen2ScriptVM') +for _,case in ipairs({ + {name='cancel',result=2}, {name='missing',result=3,pick=true}, + {name='wrong',result=0,pick=true,text='OTHER'}, + {name='last',result=4,pick=true,text='MATCH',last=true}, + {name='correct',result=1,pick=true,text='MATCH'}, +})do + reset();local carrier=game.save.party[1] + if case.last then game.save.party[2].hp=0 end + if case.text then carrier.item='ITEM_158';Mail.set(game.save,1,Mail.entry('ITEM_158',case.text,'R',2,carrier.species))end + local finish=0 + local scriptData={map_scripts={scripts={probe={{'checkpokemail',{message='MATCH'}},{'end'}}}}} + local rows=assert(VM.compile(scriptData,'probe')) + local runner=Runner.new(game,{}) + runner:run(rows,{onDone=function()finish=finish+1 end}) + T.check(runner:isRunning(),'mail check suspends for real party input: '..case.name) + T.eq(finish,0,'script does not continue before selection: '..case.name) + local picker=game.stack:top() + press(picker,case.pick and 'a' or 'b') + T.eq(runner.ctx.g2Var,case.result,'native bytecode result: '..case.name) + T.eq(finish,1,'script resumes once after real picker: '..case.name) + T.eq(runner:isRunning(),false,'mail script terminates: '..case.name) + T.eq(#game.save.party,case.result==1 and 1 or 2,'delivery changes party only for CORRECT: '..case.name) +end +reset() +local runner=Runner.new(game,{}) +local rows=assert(VM.compile({map_scripts={scripts={gift={{'givepokemail',{item='ITEM_158',message='GIFT'}},{'end'}}}}},'gift')) +runner:run(rows,{g2Var=91}) +T.eq(game.save.mail.party[2].message,'GIFT','compiled native gift targets the last party member') +T.eq(runner.ctx.g2Var,91,'gift does not overwrite the script result variable') + +-- Binary fixtures cover the pointer width which previously desynchronised +-- Gold/Silver and ensure mail data never enters the dialogue/script queues. +local Extractor=require('src.import.RomExtractorGen2') +local Rom=require('src.import.Rom');local Ops=require('src.import.Gen2ScriptOps') +local Writer=require('src.import.LuaWriter') +for _,version in ipairs({'gold','silver','crystal'})do + local codes={};for i,row in ipairs(Ops.commandsFor(version))do codes[row[1]]=i-1 end + local bytes={};for i=1,0x8000 do bytes[i]=0 end + local function put(at,values)for i,v in ipairs(values)do bytes[at+i]=v end end + put(0x4000,{codes.givepokemail,0x00,0x41,codes.checkpokemail,0x01,0x41,codes.setval,77,codes['end']}) + put(0x4100,{158,0x80,0x4e,0x81,0x50}) + local chunks={};for i=1,#bytes,256 do chunks[#chunks+1]=string.char(unpack(bytes,i,math.min(i+255,#bytes)))end + local extractor=setmetatable({version=version,rom=Rom.new(table.concat(chunks)),_gen2MailCharmap={['128']='A',['129']='B'}},Extractor) + local pool={scripts={},queue={},instructions=0,desyncs=0} + extractor:gen2DecodeScript(1,0x4000,'probe',pool) + local decoded=pool.scripts.probe + T.eq(#decoded,4,'word-sized mail operands retain following commands: '..version) + T.eq(decoded[1][2].item,'ITEM_158','gift stationery resolves to native ID: '..version) + T.eq(decoded[1][2].message,'A\nB','gift reads raw line break and terminator: '..version) + T.eq(decoded[2][2].message,'A\nB','expected message skips no first character: '..version) + T.eq(decoded[3][1],'setval','decoder remains aligned after both operands: '..version) + T.eq(decoded[3][2],77,'following command retains its actual byte: '..version) + T.eq(#pool.queue,0,'raw letters are not queued as scripts: '..version) + T.eq(pool.desyncs,0,'native mail bytecode has no desync: '..version) + local round=assert(loadstring(Writer.encodeSplit(pool)))() + T.eq(round.scripts.probe[2][2].message,'A\nB','actual LuaWriter retains semantic letter data: '..version) + T.eq(extractor:gen2MailOperand(1,0x8000,true),nil,'out-of-bank pointer is rejected: '..version) + T.eq(extractor:gen2MailOperand(1,0x7fff,true),nil,'truncated gift cannot read across bank boundary: '..version) +end +local run=T.sdk.loadMods({'mods/mail_probe'},{data=data,fs=T.sdk.memfs({ + ['mods/mail_probe/manifest.json']='{"id":"mail_probe","name":"Mail probe","version":"1.0.0","api":2,"entry":"main.lua"}', + ['mods/mail_probe/main.lua']=[[ + local mod=... + mod.exports.reads=0;mod.exports.writes=0 + mod.events:on("mail.read",function(e)mod.exports.reads=mod.exports.reads+1;mod.exports.message=e.message end) + mod.events:on("mail.written",function(e)mod.exports.writes=mod.exports.writes+1 end) + ]], +})}) +T.eq(#run.errors,0,'actual SDK mail event subscriptions load') +reset();Mail.load(data);Mail.compose(game.save,1,'PUBLIC',game.save.party[1],'ITEM_158') +Mail.lines(Mail.get(game.save,1));Mail.lines(game.save.mail.party[1]) +local exports=run.loader.exports.mail_probe +T.eq(exports.writes,1,'public SDK receives the native write once') +T.eq(exports.reads,1,'public SDK receives the native read once, not every redraw') +T.eq(exports.message,'PUBLIC','public SDK receives actual letter contents') +run.release() +V.set(original);Mail.load(nil) +T.finish('native GSC mail ROM bytecode, actual delivery runner and public SDK events') diff --git a/tests/engine/gen2_pc_mail.lua b/tests/engine/gen2_pc_mail.lua new file mode 100644 index 000000000..31a9c83ef --- /dev/null +++ b/tests/engine/gen2_pc_mail.lua @@ -0,0 +1,119 @@ +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local V=require('src.core.GameVersion') +local old=V.get() +require('src.core.ModCompat').install() +local Mail=require('src.core.gen2.Mail') +local Boxes=require('src.core.gen2.Boxes') +local Storage=require('src.pokemon.Boxes') +local Pokemon=require('src.pokemon.Pokemon') +local Save=require('src.core.SaveData') +local data=T.fixtures.fresh() +data.items.ITEM_158={id='ITEM_158',key='FLOWER_MAIL',name='Translated stationery'} +data.items.ITEM_181={id='ITEM_181',key='SURF_MAIL',name='Another translation'} +local function mon(hp) + local m=Pokemon.new(data,T.fixtures.ids.species[1],5,function(lo)return lo end) + if hp~=nil then m.hp=hp end + return m +end +for _,version in ipairs({'gold','silver','crystal'}) do + V.set(version);Storage.load(nil);Mail.load(data) + T.eq(Boxes.NUM_BOXES,14,'native GSC has fourteen boxes') + T.eq(Boxes.MONS_PER_BOX,20,'native GSC has twenty residents per box') + T.eq(Boxes.PARTY_SIZE,6,'native party limit') + T.eq(Boxes,require('src.pokemon.Gen2Boxes'),'legacy namespace reaches the real native operations') + T.check(Boxes~=Storage,'incompatible count contracts do not alias the same table') + T.check(Mail.isMail('ITEM_158'),'native opaque item ID is identified from its semantic key') + T.check(Mail.isMail('FLOWER_MAIL'),'symbolic stationery remains accepted') + T.eq(Mail.isMail('ITEM_001'),false,'ordinary native item is not inferred from display text') + T.eq(Mail.INDEX.ITEM_158,0,'native stationery has the same graphics index') + local a,b,c=mon(),mon(),mon() + local s={party={a,b,c},player={name='PLAYER',id=123}} + a.ot,a.otId='GIVER',456 + a.item='ITEM_158';Mail.set(s,1,Mail.entry(a.item,'FIRST','GIVER',456,a.species)) + b.item='ITEM_181';Mail.set(s,2,Mail.entry(b.item,'SECOND','OTHER',789,b.species)) + T.eq(Boxes.canDeposit(s,1),false,'mail-bearing party member cannot enter a box') + T.eq(#s.party,3,'refused deposit leaves native party unchanged') + local ok,removed=Boxes.deposit(s,3,1,data) + T.check(ok and removed==c,'native deposit moves the actual member') + T.eq(Boxes.count(s,1),1,'legacy count reports stored mons, not box count') + T.eq(Storage.count(),14,'shared box-count query remains unchanged') + T.eq(Boxes.box(s,1)[1],c,'legacy and native storage are the same save table') + T.eq(s.mail.party[1].message,'FIRST','unrelated deposit retains first letter') + T.eq(s.mail.party[2].message,'SECOND','unrelated deposit retains second letter') + T.eq(Boxes.deposit(s,1,1,data),false,'mail gate remains enforced with room in the box') + T.check(Mail.sendToPc(s,1),'detaching to mailbox succeeds') + T.eq(a.item,nil,'mailbox move clears held stationery') + T.eq(s.mail.party[1],nil,'mailbox move clears only the source letter') + T.eq(s.mail.box[1].message,'FIRST','mailbox retains the original message') + T.check(Boxes.deposit(s,1,1,data),'now ordinary native deposit succeeds') + T.eq(s.party[1],b,'party compacts after native deposit') + T.eq(s.mail.party[1].message,'SECOND','letter shifts with its original carrier') + T.eq(s.mail.party[2],nil,'old source slot is cleared') + T.eq(Boxes.canDeposit(s,1),false,'last healthy mail carrier cannot be deposited') + c.hp=0;c.status='PSN';c.statusTurns=4;c.moves[1].pp=0 + local ok,back=Boxes.withdraw(s,1,1,data) + T.check(ok and back==c,'withdraw returns the actual boxed mon') + T.eq(c.hp,c.stats.hp,'withdraw restores native max HP rather than legacy maxHp') + T.eq(c.status,nil,'withdraw clears status') + T.eq(c.statusTurns,nil,'withdraw clears status duration') + T.check(Mail.moveFromPcToParty(s,1,2),'mailbox letter can attach to a vacant party item') + T.eq(c.item,'ITEM_158','native item ID survives mailbox round trip') + T.eq(s.mail.party[2].message,'FIRST','mailbox message follows its selected carrier') + T.eq(#s.mail.box,0,'mailbox compacts after successful attachment') + local encoded=Save.encode(s);local round=assert(Save.decode(encoded)) + T.eq(round.mail.party[1].message,'SECOND','real save codec retains slot one mail') + T.eq(round.mail.party[2].authorId,456,'real save codec retains author identity') + T.eq(round.party[2].item,'ITEM_158','real save codec retains native stationery') + s.party[1],s.party[2]=s.party[2],s.party[1];Mail.swapSlots(s,1,2) + T.eq(s.mail.party[1].message,'FIRST','party reorder follows the mon') + T.eq(s.mail.party[2].message,'SECOND','party reorder does not exchange letter ownership') + T.eq(Mail.checkPokeMail(s,nil,'FIRST'),2,'cancel is the actual POKEMAIL_REFUSED value') + T.eq(Mail.checkPokeMail(s,1,'WRONG'),0,'wrong letter cannot deliver a mon') + T.eq(#s.party,2,'wrong letter leaves party unchanged') + T.eq(Mail.checkPokeMail(s,1,'FIRST'),1,'correct delivery consumes the chosen mon') + T.eq(s.party[1],b,'delivery preserves the other party member') + T.eq(s.mail.party[1].message,'SECOND','delivery shifts following mail') + T.eq(Mail.checkPokeMail(s,1,'SECOND'),4,'last healthy recipient cannot be given away') + T.eq(Mail.checkPokeMail(s,1,nil),0,'unresolved old-cache expected message never grants a delivery') + s.party[1].item=nil + T.eq(Mail.checkPokeMail(s,1,'SECOND'),3,'no mail has a distinct result') + T.check(Mail.give(s,'ITEM_181','GIFT'),'script gift writes real native mail') + T.eq(s.party[1].item,'ITEM_181','script gift writes native held item') + T.eq(s.mail.party[1].message,'GIFT','script gift writes the letter') + T.check(Mail.compose(s,1,string.rep('é',33),s.party[1],'ITEM_181'),'composition accepts native stationery') + T.eq(#Mail.characters(s.mail.party[1].message),32,'native character limit is UTF-8 aware') + T.eq(s.mail.party[1].author,'PLAYER','composition records the actual save player') + T.eq(s.mail.party[1].authorId,123,'composition records actual trainer ID') + local top,bottom=Mail.lines(s.mail.party[1]) + T.eq(#Mail.characters(top),16,'first mail line has sixteen characters') + T.eq(#Mail.characters(bottom),16,'second mail line has sixteen characters') + for i=1,10 do s.mail.box[i]=Mail.entry('ITEM_158','LETTER '..i) end + local letter=s.mail.party[1] + T.eq(Mail.sendToPc(s,1),false,'full mailbox refuses atomically') + T.eq(s.mail.party[1],letter,'failed send retains the same letter') + T.eq(s.party[1].item,'ITEM_181','failed send retains stationery') + T.eq(Mail.moveFromPcToParty(s,1,1),false,'occupied item refuses attachment') + T.eq(#s.mail.box,10,'failed attachment retains every mailbox letter') + local singleton={party={mon(),mon(0)}} + T.eq(Boxes.canDeposit(singleton,1),false,'fainted teammate does not satisfy last-healthy rule') + T.eq(Boxes.canDeposit(singleton,2),true,'fainted mon may be deposited while a healthy one remains') + singleton.party[2].moves[1].pp=0;singleton.party[2].status='PAR' + local target=singleton.party[2];Boxes.deposit(singleton,2,1,data) + T.eq(target.moves[1].pp,data.moves[target.moves[1].id].pp,'deposit restores native move PP') + T.eq(target.status,nil,'deposit clears boxed status') +end +V.set('crystal');Mail.load(data) +local Events=require('src.mods.Events');local Runtime=require('src.mods.Runtime') +local bus=Events.new();local previous=Runtime.events;Runtime.events=bus +local writes,reads=0,0 +bus:on('mail.written',function(e)writes=writes+1;T.eq(e.source,'compose','native write event identifies producer')end) +bus:on('mail.read',function(e)reads=reads+1;T.eq(e.author,'P','native read event carries author')end) +local s={party={mon()},player={name='P',id=7}} +Mail.compose(s,1,'ABC',s.party[1],'ITEM_158') +T.eq(writes,1,'actual native composition emits mail.written') +local entry=Mail.get(s,1);Mail.lines(entry);Mail.lines(entry) +T.eq(reads,1,'redrawing a letter does not emit mail.read again') +Mail.get(s,1);Mail.lines(entry);T.eq(reads,2,'opening the same letter again emits a new read') +Runtime.events=previous;V.set(old);Storage.load(nil);Mail.load(nil) +T.finish('native GSC PC transfers, slot-indexed mail, save codec and native events') From a70e34bc5d3fc1d661588ae4f4601c43e2b6ba02 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Sun, 25 Oct 2026 22:31:23 +0100 Subject: [PATCH 2/2] Expose the native Gen2 PC menu under its legacy module name --- docs/native-gen2-pc-mail.md | 2 +- src/core/ModCompat.lua | 1 + tests/engine/gen2_pc_mail.lua | 1 + 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/native-gen2-pc-mail.md b/docs/native-gen2-pc-mail.md index 217832c72..e47827cda 100644 --- a/docs/native-gen2-pc-mail.md +++ b/docs/native-gen2-pc-mail.md @@ -17,7 +17,7 @@ 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: 207 PC/mail/codec checks and 69 binary ROM/actual runner/public +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. diff --git a/src/core/ModCompat.lua b/src/core/ModCompat.lua index cf627ef11..d7d3e1115 100644 --- a/src/core/ModCompat.lua +++ b/src/core/ModCompat.lua @@ -68,6 +68,7 @@ ModCompat.ALIASES = { -- save-side data ["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", diff --git a/tests/engine/gen2_pc_mail.lua b/tests/engine/gen2_pc_mail.lua index 31a9c83ef..b5bdc9d6c 100644 --- a/tests/engine/gen2_pc_mail.lua +++ b/tests/engine/gen2_pc_mail.lua @@ -6,6 +6,7 @@ require('src.core.ModCompat').install() local Mail=require('src.core.gen2.Mail') local Boxes=require('src.core.gen2.Boxes') local Storage=require('src.pokemon.Boxes') +T.eq(require("src.ui.gen2.BoxMenu"),require("src.ui.BoxMenu"),"legacy Gen2 PC namespace resolves the real native factory") local Pokemon=require('src.pokemon.Pokemon') local Save=require('src.core.SaveData') local data=T.fixtures.fresh()