Gen 3 online: all versions on the relay, event catalog, link room presence

This commit is contained in:
bryanthaboi
2026-10-06 15:48:54 -04:00
parent 602f7acdff
commit 697e064fd2
40 changed files with 4141 additions and 427 deletions
+7 -1
View File
@@ -16,6 +16,10 @@ Features intentionally added to the games themselves, beyond what the original c
* **Pokédex diploma and printer image exports**
* **Fast-forward locks to 1X in link play**, every link or online battle, link session and the FireRed/LeafGreen Union Room on every game
* **40-player Union Room** on FireRed and LeafGreen online, a larger square room where every trainer keeps a fixed spot and anyone can be talked to
* **Name badges over trainers** in Gen 3 link rooms (Union Room, Trade Center, Colosseum, Record Corner, Berry Blender), your own included
* **Chat, trade and battle icons** on those badges, so a busy trainer is visible at a glance
* **Idle Union Room trainers wander**, a few steps around their spot and back
* **Translation mods stay online** on Gen 3: mods that only change text, fonts, names or visuals can still link, trade and battle
* **Button remapping** on every game: OPTION -> CONTROLS (keys, pads, L/R on FireRed/LeafGreen/Emerald, fast-forward buttons)
## Gen 2 Specifics
@@ -24,4 +28,6 @@ Features intentionally added to the games themselves, beyond what the original c
* **Battle screen options** on Gold, Silver and Crystal: BATTLE SIZE (fixed or window-filling) and BATTLE BG (white, black or the dimmed map as the surround)
* **Widescreen battle layout** on Gold, Silver and Crystal: BATTLE LAYOUT -> WIDE spreads the HUDs and bottom windows across a 304-wide screen
* **Extended battle HUD** on Gold, Silver and Crystal: BATTLE HUD -> EXTENDED docks the WIDE layout's foe panel to the top of the window and the text area and player panel to the bottom
* **Event tickets** on Emerald: OPTION -> EXTRAS -> EVENT TICKETS lets the Lilycove Mystery Gift man hand out the Eon, Aurora and Mystic tickets and the Old Sea Map
* **Event tickets** on Emerald: OPTION -> EXTRAS -> EVENT TICKETS lets the Lilycove Mystery Gift man hand out whichever of the Eon, Aurora and Mystic tickets and the Old Sea Map the relay is currently distributing
* **Relay Mystery Events** on Ruby/Sapphire: MYSTERY EVENTS loads signed e-Reader events (Eon Ticket, gift ribbon) from the relay instead of a link partner
* **Wonder News on Ruby/Sapphire**: relay Wonder News is listed and saved under MYSTERY EVENTS, so every Gen 3 game reads the same news
+42 -5
View File
@@ -14,6 +14,44 @@ Events.ROWS = {
{ id="oldSeaMap",name="Old Sea Map",item="ITEM_OLD_SEA_MAP",versions={emerald={[1]=true}},
card="rse_old_sea_map",route="Faraway Island",method="Wonder Card" },
}
Events.FAMILY={firered="frlg",leafgreen="frlg",emerald="rse",ruby="rs",sapphire="rs"}
Events._feeds,Events._jobs={},{}
function Events.setFeed(version,list) Events._feeds[version]=list end
function Events.poll(version,opts)
local family=Events.FAMILY[version]
if not family then return "error","no_family" end
if Events._feeds[version] then return "ok",Events._feeds[version] end
local Gift=require("src.core.game3.mystery_gift")
local job=Events._jobs[version]
if not job then
opts=opts or {}
job=Gift.fetchOnline({family=family,version=version,transport=opts.transport,client=opts.client})
Events._jobs[version]=job
end
local status,result=Gift.pollOnline(job)
if status=="pending" then return status end
Events._jobs[version]=nil
if status=="ok" then Events._feeds[version]=result end
return status,result
end
local function relayOffer(save,row)
local status,list=Events.poll(save.version)
if status=="pending" then return nil,"Checking the relay for tickets. Try again in a moment." end
if status~="ok" then return nil,"The relay could not be reached, so no tickets can be received." end
local Gift=require("src.core.game3.mystery_gift")
local item=require("src.core.game3.constants").of(save.version):require("items",row.item)
if Events.FAMILY[save.version]=="rs" then
for _,ev in ipairs(list.events or {}) do
if Gift.eventRecordMixingItem(ev.bytes)==item then return ev end
end
else
for _,entry in ipairs(list.cards or {}) do
if entry.card.gift and entry.card.gift.kind=="item" and entry.card.gift.item==item then return entry end
end
end
return nil,"The relay is not offering this ticket right now."
end
Events.relayOffer=relayOffer
function Events.allowed(id,version,language)
for _,row in ipairs(Events.ROWS) do
if row.id==id then return row.versions[version] and row.versions[version][language] and row or nil end
@@ -22,6 +60,7 @@ end
function Events.list(version,language)
local out={}
for _,row in ipairs(Events.ROWS) do if Events.allowed(row.id,version,language) then out[#out+1]=row end end
if #out>0 and Events.autoFetch~=false then pcall(Events.poll,version) end
return out
end
local function session(save)
@@ -67,6 +106,8 @@ function Events.receive(save,id,language)
if not status then return nil,why end
if status~="Available" then return nil,"This event has already been received." end
return Context.withItems(save.version,function()
local offer,offerWhy=relayOffer(save,row)
if not offer then return nil,offerWhy end
local out=Store.copy(save)
local s=session(out)
local C=require("src.core.game3.constants").of(save.version)
@@ -85,11 +126,7 @@ function Events.receive(save,id,language)
local flag=Gift.receivedGiftFlag(existing.flagId,s)
if flag and not Gift.getFlag(s,flag) then return nil,"Collect your existing Wonder Card gift before receiving another card." end
end
local family=save.version=="emerald" and "rse" or "frlg"
local key=family=="rse" and (row.rseCard or row.card) or row.card
local card
for _,candidate in ipairs(Gift.builtins(family)) do if candidate.key==key then card=candidate.card end end
if not card or not Gift.receiveCard(s,card) then return nil,"The game's ticket delivery card could not be prepared." end
if not Gift.receiveCard(s,offer.card) then return nil,"The game's ticket delivery card could not be prepared." end
end
apply(s,out);return out
end)
+8
View File
@@ -1812,6 +1812,14 @@ function FieldView.draw(game, canvasW, canvasH, opts)
end
end
if underActors or overActors then
local LinkTags = package.loaded["src.ui.game3.link_tags"]
if LinkTags and LinkTags.draw then
LinkTags.draw(underActors, overActors, camX, camY, canvasW, canvasH,
FieldView._billboard and pushBillboard or nil)
end
end
-- pokefirered/src/field_effect.c:1024
if not opts.actorsOnly then
local Heal = modHeal()
+83 -50
View File
@@ -29,6 +29,9 @@ LB.LINKTYPE = {
SINGLE_BATTLE = 0x2233,
DOUBLE_BATTLE = 0x2244,
MULTI_BATTLE = 0x2255,
-- pokeemerald/include/link.h:95
BATTLE_TOWER_50 = 0x2266,
BATTLE_TOWER_OPEN = 0x2277,
RECORD_MIX_BEFORE = 0x3311,
BERRY_BLENDER_SETUP = 0x4411,
}
@@ -1619,9 +1622,59 @@ function LB.playerCount()
return 2
end
-- pokefirered/src/cable_club.c:222 CreateLinkupTask
local function relayPeers(live)
local out = {}
for seat, hello in pairs(type(live.peerHellos) == "table" and live.peerHellos or {}) do
local g3 = type(hello) == "table" and type(hello.game3) == "table" and hello.game3 or {}
out[#out + 1] = { seat = seat, linkType = tonumber(g3.linkType), version = tonumber(g3.gameVersion),
progressFlags = tonumber(g3.progressFlags) or 0 }
end
table.sort(out, function(a, b) return a.seat < b.seat end)
return out
end
-- pokeemerald/src/link.c:817 GetLinkPlayerDataExchangeStatusTimed
function LB.linkupStatus(ctx, spec, peers, relay)
local L = link()
local Family = require("src.core.game3.link.family")
local CableEntry = require("src.core.game3.link.cable_entry")
local players = LB.playerCount()
-- pokeemerald/src/link.c:830
if players < spec.min or players > spec.max then return L.LINKUP.WRONG_NUM_PLAYERS end
local want = spec.linkType
if relay then want = CableEntry.classOf(spec.linkType) end
for _, peer in ipairs(peers) do
local got = tonumber(peer.linkType)
if relay then got = CableEntry.classOf(peer.linkType) end
if (relay and want ~= nil and got ~= nil and got ~= want) or (not relay and got ~= want) then
-- pokeemerald/src/link.c:876
local towers = { battle_tower = true, battle_tower_open = true }
if towers[CableEntry.classOf(spec.linkType)] and towers[CableEntry.classOf(peer.linkType)] then
L.setVar(ctx, LB.VAR_0x8005, 3)
end
return L.LINKUP.DIFF_SELECTIONS
end
end
local peer = peers[1]
if spec.linkType == require("src.link.Game3Link").LINKTYPE.TRADE_SETUP and peer and peer.version ~= nil then
local mine = Family.localLinkPlayer(L.session())
-- pokeemerald/src/link.c:853
local code = Family.gameProgressForLinkTrade(mine.family, mine, peer)
if code == Family.TRADE.PLAYER_NOT_READY then return L.LINKUP.PLAYER_NOT_READY end
if code == Family.TRADE.PARTNER_NOT_READY then return L.LINKUP.PARTNER_NOT_READY end
end
return L.LINKUP.SUCCESS
end
-- pokefirered/src/cable_club.c:76
function LB.createLinkupTask(ctx, adapters, spec)
local L = link()
local lk = L.link
if not (lk and lk.isOpen and lk:isOpen()) then
return require("src.core.game3.link.cable_entry").run(ctx, adapters, spec, function(c, a)
return LB.createLinkupTask(c, a, spec)
end)
end
local function report(code)
LB.linkup = code
L.setResult(ctx, code)
@@ -1630,22 +1683,13 @@ function LB.createLinkupTask(ctx, adapters, spec)
report(L.LINKUP.ONGOING)
LB.state = "linkup"
LB.seed = nil
local announced = false
local lk = L.link
local Family = require("src.core.game3.link.family")
local mine = Family.localLinkPlayer(L.session())
local function linkupMsg()
return { type = LB.MSG.LINKUP, linkType = spec.linkType, players = spec.min,
version = mine.version, progressFlags = mine.progressFlags }
end
if lk then
lk.linkType = spec.linkType
-- pokefirered/src/cable_club.c:318 Task_LinkupExchangeDataWithLeader
lk:send(linkupMsg())
announced = true
else
-- pokefirered/src/cable_club.c:222 CreateLinkupTask waits for the other machine
L.beginConnect({ linkType = spec.linkType })
local relay = LB.onRelay()
lk.linkType = spec.linkType
if not relay then
local mine = require("src.core.game3.link.family").localLinkPlayer(L.session())
-- pokefirered/src/cable_club.c:318
lk:send({ type = LB.MSG.LINKUP, linkType = spec.linkType, players = spec.min,
version = mine.version, progressFlags = mine.progressFlags })
end
local ticks = 0
local Natives = natives()
@@ -1658,41 +1702,20 @@ function LB.createLinkupTask(ctx, adapters, spec)
ctx.nativePoll = function()
ticks = ticks + 1
local live = L.link
if live and not announced then
live.linkType = spec.linkType
live:send(linkupMsg())
announced = true
end
if not announced then
-- pokefirered/src/cable_club.c:482 TryLinkTimeout
if ticks > LB.LINKUP_TICKS then
report(L.LINKUP.CONNECTION_ERROR)
LB.state = "off"
return true
end
return false
end
local peer = live and live:isReady() and live:take(LB.MSG.LINKUP) or nil
if peer then
local players = LB.playerCount()
if tonumber(peer.linkType) ~= spec.linkType then
-- pokefirered/src/cable_club.c:122 EXCHANGE_DIFF_SELECTIONS
report(L.LINKUP.DIFF_SELECTIONS)
LB.state = "off"
elseif players < spec.min or players > spec.max then
-- pokefirered/src/cable_club.c:127 EXCHANGE_WRONG_NUM_PLAYERS
report(L.LINKUP.WRONG_NUM_PLAYERS)
LB.state = "off"
elseif spec.linkType == require("src.link.Game3Link").LINKTYPE.TRADE_SETUP and peer.version ~= nil
and Family.gameProgressForLinkTrade(mine.family, mine, peer) ~= Family.TRADE.BOTH_PLAYERS_READY then
-- pokeemerald/src/link.c:853
local code = Family.gameProgressForLinkTrade(mine.family, mine, peer)
report(code == Family.TRADE.PLAYER_NOT_READY and L.LINKUP.PLAYER_NOT_READY or L.LINKUP.PARTNER_NOT_READY)
LB.state = "off"
local peers
if live and live:isOpen() and live:isReady() then
if relay then
peers = relayPeers(live)
else
report(L.LINKUP.SUCCESS)
LB.state = "seat"
local peer = live:take(LB.MSG.LINKUP)
if peer then
peers = { { linkType = tonumber(peer.linkType), version = peer.version, progressFlags = peer.progressFlags } }
end
end
end
if peers then
local code = report(LB.linkupStatus(ctx, spec, peers, relay))
LB.state = code == L.LINKUP.SUCCESS and "seat" or "off"
return true
end
if not (live and live:isOpen()) then
@@ -1718,9 +1741,19 @@ function LB.tryBattleLinkup(ctx, adapters)
local mode = L.getVar(ctx, L.VAR_0x8004)
LB.mode = mode
LB.unionRoom = false
if mode == L.USING.BATTLE_TOWER then return LB.createLinkupTask(ctx, adapters, LB.towerSpec()) end
return LB.createLinkupTask(ctx, adapters, LB.PLAYERS[mode] or LB.PLAYERS[1])
end
-- pokeemerald/src/cable_club.c:591
function LB.towerSpec()
local okU, Util = pcall(require, "src.core.game3.rse.frontier.util")
local okD, D = pcall(require, "src.core.game3.rse.frontier.trainers")
local f = okU and Util.frontier and Util.frontier(session()) or nil
local open = okD and type(f) == "table" and D.LVL and f.lvlMode == D.LVL.OPEN
return { min = 2, max = 2, linkType = open and LB.LINKTYPE.BATTLE_TOWER_OPEN or LB.LINKTYPE.BATTLE_TOWER_50 }
end
-- pokefirered/src/script_pokemon_util.c:90 HasEnoughMonsForDoubleBattle
function LB.hasEnoughMonsForDoubleBattle(ctx)
local Party = require("src.core.game3.party")
+330
View File
@@ -0,0 +1,330 @@
local Link = require("src.core.game3.link.init")
local Strings = require("src.core.Strings")
local CableEntry = {}
-- pokeemerald/include/link.h:86
CableEntry.WIRE = {
[0x1133] = "trade",
[0x2233] = "battle_single",
[0x2244] = "battle_double",
[0x2255] = "battle_multi",
[0x2266] = "battle_tower",
[0x2277] = "battle_tower_open",
[0x3311] = "record_corner",
[0x4411] = "berry_blender",
[0x5501] = "mystery_event",
}
-- pokeemerald/include/link.h:105
CableEntry.CONTEST = { [0x6601] = true, [0x6602] = true }
-- pokeemerald/include/constants/union_room.h:73
CableEntry.CONTEST_WIRES = { [0] = "contest_cool", "contest_beauty", "contest_cute", "contest_smart", "contest_tough" }
CableEntry.DIRECT = {
battle_single = true, battle_double = true, battle_multi = true, trade = true,
record_corner = true, berry_blender = true, contest_cool = true, contest_beauty = true,
contest_cute = true, contest_smart = true, contest_tough = true,
}
CableEntry.CONNECT_TICKS = 1200
-- pokefirered/src/cable_club.c:483
CableEntry.READY_TICKS = 600
CableEntry.LOST_TICKS = 120
CableEntry.AWAY_TICKS = 600
local function contestCategory(ctx)
local ok, id = pcall(function()
return require("src.core.game3.constants").active(Link.session()):var("VAR_CONTEST_CATEGORY")
end)
return Link.getVar(ctx, ok and id or 0x8011)
end
function CableEntry.classOf(linkType)
linkType = tonumber(linkType)
if CableEntry.CONTEST[linkType] then return "contest" end
return CableEntry.WIRE[linkType]
end
function CableEntry.wireFor(spec, ctx)
if type(spec) ~= "table" then return nil end
if spec.wire then return spec.wire end
local linkType = tonumber(spec.linkType)
if CableEntry.CONTEST[linkType] then
return CableEntry.CONTEST_WIRES[contestCategory(ctx)]
end
return CableEntry.WIRE[linkType]
end
function CableEntry.matched(room, stale)
return type(room) == "table" and room.stage == "battling" and room.match ~= nil
and (stale == nil or room.match ~= stale)
end
local function protocol()
return require("src.online.Protocol2")
end
-- pokefirered/src/cable_club.c:76
function CableEntry.run(ctx, adapters, spec, handshake)
assert(type(spec) == "table", "cable service missing")
local live = Link.link
if live and live.isOpen and live:isOpen() then return handshake(ctx, adapters) end
local wire = CableEntry.wireFor(spec, ctx)
if not wire then
Link.setResult(ctx, Link.LINKUP.FAILED)
return false, Link.LINKUP.FAILED
end
local N = require("src.core.game3.scripting.natives")
local UI = require("src.ui.game3.rs.cable_lobby")
local P = protocol()
local watchDirect = CableEntry.DIRECT[wire] == true
local ruleset = P.ACTIVITY_RULESET[wire]
local min, max = tonumber(spec.min) or 2, tonumber(spec.max) or 2
local autoLead = min == max
local phase, ticks, done, role, pending, answered = "connect", 0, false, nil, nil, {}
local seenGroup, goneTicks, awayTicks, joining = false, 0, 0, nil
local avatar = spec.avatar or Link.avatar()
local profile = spec.profile
local room0 = Link.clientCall("room")
local stale = type(room0) == "table" and room0.match or nil
local function unwatch()
Link.clientCall("groupList", nil)
if watchDirect then Link.clientCall("directList", nil) end
end
local function finish(code)
if joining then Link.clientCall("leaveRoom") end
Link.clientCall("leaveGroup")
unwatch()
Link.closeLink("cable_entry_failed")
UI.close()
Link.setStatus("busy")
Link.setResult(ctx, code)
done = true
end
local function offline(member)
if type(member) ~= "table" then return false end
if member.online == false then return true end
for _, entry in ipairs(Link.clientCall("lobby") or {}) do
if type(entry) == "table" and entry.id ~= nil and entry.id == member.id then return entry.online == false end
end
return false
end
local function liveMembers(group)
local members = type(group) == "table" and type(group.members) == "table" and group.members or {}
local out = {}
for _, member in ipairs(members) do
if not offline(member) then out[#out + 1] = member end
end
return out, #out == #members
end
local function joinRows()
local out = {}
for _, g in ipairs(Link.clientCall("groups", wire) or {}) do
if type(g) == "table" and g.leader ~= nil then
local av = g.avatar or {}
out[#out + 1] = { kind = "group", id = g.leader, label = av.name or g.name or "",
disabled = (tonumber(g.joined) or 0) >= (tonumber(g.max) or max) }
end
end
if watchDirect then
for _, e in ipairs(Link.clientCall("directEntries", wire) or {}) do
if type(e) == "table" and e.kind == "room" and e.room ~= nil then
local av = type(e.avatar) == "table" and e.avatar or {}
out[#out + 1] = { kind = "room", id = e.room, label = av.name or e.name or "",
disabled = e.locked == true }
end
end
end
return out
end
local function rows()
if phase == "mode" then
return { { id = "leader", label = Strings("CREATE GROUP") }, { id = "join", label = Strings("JOIN GROUP") } }
end
if phase == "ask" then return { { id = "yes", label = Strings("YES") }, { id = "no", label = Strings("NO") } } end
if role == "leader" then
local out = {}
local members, all = liveMembers(Link.clientCall("group"))
for _, member in ipairs(members) do
local av = member.avatar or {}
out[#out + 1] = { label = av.name or member.name or "", disabled = true }
end
out[#out + 1] = { id = "start", label = Strings("START"), disabled = not all or #members < min or #members > max }
return out
end
if phase == "list" then return joinRows() end
return {}
end
local function select(row)
if phase == "mode" then
role, phase = row.id, "list"
if role == "leader" then
Link.clientCall("openGroup", wire, profile, avatar)
else
Link.clientCall("groupList", wire, profile)
if watchDirect then Link.clientCall("directList", wire, profile, avatar) end
end
elseif phase == "ask" then
answered[pending.id] = true
Link.clientCall("acceptGroup", pending.id, row.id == "yes")
pending, phase = nil, "list"
elseif role == "leader" and row.id == "start" then
Link.clientCall("startGroup")
phase, ticks = "starting", 0
elseif role == "join" and row.kind == "room" then
joining = Link.clientCall("joinRoom", row.id, "player", profile, nil)
phase, ticks = "joining", 0
elseif role == "join" then
Link.clientCall("joinGroup", row.id, profile, avatar)
phase, ticks, seenGroup, goneTicks = "waiting", 0, false, 0
end
end
local function enter()
profile = profile or Link.liveProfile(ruleset)
Link.setStatus("idle")
phase, ticks = "mode", 0
UI.cursor = 1
end
local function leaderTick(g)
local requests = type(g) == "table" and type(g.pending) == "table" and g.pending or {}
if phase == "ask" then
local still = false
for _, request in ipairs(requests) do
if pending and request.id == pending.id and not offline(request) then still = true end
end
if not still then
if pending then answered[pending.id] = true end
pending, phase = nil, "list"
UI.cursor = 1
end
elseif phase == "list" then
local members, all = liveMembers(g)
for _, request in ipairs(requests) do
if not answered[request.id] and not offline(request) then
if autoLead then
-- pokeemerald/src/cable_club.c:225
answered[request.id] = true
Link.clientCall("acceptGroup", request.id, #members < max)
else
pending, phase = request, "ask"
end
break
end
end
if phase == "list" and autoLead and all and #members >= max then
Link.clientCall("startGroup")
phase, ticks = "starting", 0
end
elseif phase == "starting" and type(g) == "table" then
local members, all = liveMembers(g)
if not all or #members < min then phase, ticks = "list", 0 end
end
end
local function joinTick(g)
if phase == "joining" then
local p = joining
if type(p) ~= "table" or (p.done and p.reason ~= nil) then
joining, phase = nil, "list"
UI.cursor = 1
end
return
end
if phase ~= "waiting" then return end
if type(g) == "table" then
seenGroup, goneTicks = true, 0
local leader
for _, member in ipairs(type(g.members) == "table" and g.members or {}) do
if member.id == g.leader then leader = member end
end
awayTicks = offline(leader) and awayTicks + 1 or 0
if awayTicks > CableEntry.AWAY_TICKS then return finish(Link.LINKUP.CONNECTION_ERROR) end
ticks = 0
elseif seenGroup then
goneTicks = goneTicks + 1
if goneTicks > CableEntry.LOST_TICKS then
phase, seenGroup, goneTicks = "list", false, 0
UI.cursor = 1
end
end
end
if not Link.online() then
local ok = (spec.connect or Link.connect)()
if not ok then
finish(Link.LINKUP.CONNECTION_ERROR)
return false, Link.LINKUP.CONNECTION_ERROR
end
else
enter()
end
N.yieldHost(ctx, adapters, function() end)
Link.setResult(ctx, Link.LINKUP.ONGOING)
local poll
poll = function()
if done then return true end
ticks = ticks + 1
if phase == "connect" then
if Link.online() then
enter()
elseif ticks > CableEntry.CONNECT_TICKS or Link.connectState() == "error" then
finish(Link.LINKUP.CONNECTION_ERROR)
end
return done
end
if not Link.online() then finish(Link.LINKUP.CONNECTION_ERROR); return true end
if phase == "ready" then
local lk = Link.link
if not (lk and lk:isOpen()) then finish(Link.LINKUP.CONNECTION_ERROR); return true end
if lk:isReady() then
UI.close()
Link.setStatus("busy")
local yielded, value = handshake(ctx, adapters)
if not yielded then
Link.setResult(ctx, value or Link.getVar(ctx, Link.VAR_RESULT))
return true
end
return false
elseif ticks > CableEntry.READY_TICKS then
finish(Link.LINKUP.CONNECTION_ERROR)
return true
end
return false
end
if CableEntry.matched(Link.clientCall("room"), stale) then
UI.close()
unwatch()
joining = nil
if not Link.openRelay({ linkType = spec.linkType, game = spec.game, hello = spec.hello }) then
finish(Link.LINKUP.CONNECTION_ERROR)
return true
end
phase, ticks = "ready", 0
return false
end
local g = Link.clientCall("group")
if role == "leader" then leaderTick(g) elseif role == "join" then joinTick(g) end
if not done and phase == "starting" and ticks > CableEntry.CONNECT_TICKS then
finish(Link.LINKUP.CONNECTION_ERROR)
end
return done
end
ctx.nativePoll = poll
UI.show({
rows = rows,
select = select,
tick = function() if ctx.nativePoll == poll then poll() end end,
title = function()
if phase == "ask" then return Strings("ADD %s?", (pending.avatar or {}).name or pending.name or "") end
return Strings((wire:upper():gsub("_", " ")))
end,
cancel = function()
if phase == "waiting" or phase == "joining" then
if phase == "joining" then Link.clientCall("leaveRoom") else Link.clientCall("leaveGroup") end
joining, phase = nil, "list"
UI.cursor = 1
return
end
finish(Link.LINKUP.FAILED)
end,
})
return true
end
return CableEntry
+18
View File
@@ -38,6 +38,18 @@ local function linkSeat(link)
return tonumber(link.seat) or 0
end
-- pokeemerald/src/contest_link.c:88
function ContestLink.flagsFor(link, wireless)
local flags = ContestLink.FLAG.IS_LINK + (wireless and ContestLink.FLAG.IS_WIRELESS or 0)
local ok, players = false, nil
if type(link) == "table" and type(link.players) == "function" then ok, players = pcall(link.players, link) end
local Rs = require("src.core.game3.link.rs")
for _, p in ipairs(ok and type(players) == "table" and players or {}) do
if type(p) == "table" and Rs.is(p.version) then return flags + ContestLink.FLAG.HAS_RS_PLAYER end
end
return flags
end
local function linkCount(link)
if type(link.players) == "function" then
local ok, list = pcall(link.players, link)
@@ -153,8 +165,14 @@ function Session:fromLeader(key, data)
return self:waiting(key)
end
function Session:hasRsPlayer()
return math.floor(self.flags / ContestLink.FLAG.HAS_RS_PLAYER) % 2 == 1
end
-- pokeemerald/src/contest_util.c:2718
function Session:standby()
-- pokeemerald/src/contest_link.c:100
if self:hasRsPlayer() then return true end
if not self.standbyKey then
self.standbyN = self.standbyN + 1
self.standbyKey = "standby:" .. self.standbyN
+16
View File
@@ -225,9 +225,24 @@ local function spawnAll(lk, own)
snap(r, M._base.x + seat, M._base.y)
M._remotes[seat] = r
end
local r = M._remotes[seat]
if r then r.name = type(player.name) == "string" and player.name or r.name end
end
end
local BUSY_KIND = { tradeCenter = "trade", colosseum2P = "battle", colosseum4P = "battle", recordCorner = "busy" }
function M.statusKind(seat)
local r = M._remotes[seat]
if not (r and r.busy) then return nil end
return BUSY_KIND[M._mapId] or "busy"
end
function M.nameOf(seat)
local r = M._remotes[seat]
return r and r.name or nil
end
function M.update()
local lk = liveLink()
if not lk then
@@ -254,6 +269,7 @@ function M.update()
end
if warping then return true end
M._mapId = mapId
if not package.loaded["src.ui.game3.link_tags"] then pcall(require, "src.ui.game3.link_tags") end
spawnAll(lk, own)
sendLocal(lk, mapId)
for seat, r in pairs(M._remotes) do
+171
View File
@@ -0,0 +1,171 @@
local Wander = {}
Wander.CELL = 16
Wander.STEP_FRAMES = 32
Wander.FIRST_REST = 360
Wander.REST_MIN, Wander.REST_MAX = 240, 600
Wander.PAUSE_MIN, Wander.PAUSE_MAX = 40, 120
Wander.MAX_STEPS = 3
Wander.RADIUS = 1
Wander.GIVE_UP = 240
local ORDER = { "down", "up", "left", "right" }
local DELTA = { down = { 0, 1 }, up = { 0, -1 }, left = { -1, 0 }, right = { 1, 0 } }
local OPPOSITE = { down = "up", up = "down", left = "right", right = "left" }
Wander.DELTA = DELTA
Wander.OPPOSITE = OPPOSITE
local function nextRand(w)
w.seed = (w.seed * 16807) % 2147483647
return w.seed
end
local function randRange(w, lo, hi)
return lo + nextRand(w) % (hi - lo + 1)
end
function Wander.new(seed, homeX, homeY)
local s = math.floor(math.abs(tonumber(seed) or 1)) % 2147483647
if s == 0 then s = 1 end
local w = {
seed = s, homeX = homeX, homeY = homeY, x = homeX, y = homeY,
prevX = homeX, prevY = homeY, path = {}, mode = "rest", want = 0,
moving = false, progress = 0, animClock = 0, stepFlip = false, blocked = 0,
facing = false, px = false, py = false, timer = 0,
}
w.timer = Wander.FIRST_REST + randRange(w, 0, Wander.REST_MAX - Wander.REST_MIN)
return w
end
function Wander.atHome(w)
return w ~= nil and not w.moving and w.x == w.homeX and w.y == w.homeY
end
function Wander.away(w)
return w ~= nil and not Wander.atHome(w)
end
local function inRange(w, x, y)
return math.abs(x - w.homeX) <= Wander.RADIUS and math.abs(y - w.homeY) <= Wander.RADIUS
end
local function beginStep(w, dir)
local d = DELTA[dir]
w.prevX, w.prevY = w.x, w.y
w.x, w.y = w.x + d[1], w.y + d[2]
w.facing = dir
w.moving, w.progress, w.animClock = true, 0, 0
w.px, w.py = w.prevX * Wander.CELL, w.prevY * Wander.CELL
end
local function advance(w)
w.progress = w.progress + 1
w.animClock = w.animClock + 1
local off = math.floor(Wander.CELL * math.min(w.progress, Wander.STEP_FRAMES) / Wander.STEP_FRAMES)
w.px = w.prevX * Wander.CELL + (w.x - w.prevX) * off
w.py = w.prevY * Wander.CELL + (w.y - w.prevY) * off
if w.progress >= Wander.STEP_FRAMES then
w.moving, w.progress, w.px, w.py = false, 0, false, false
w.prevX, w.prevY = w.x, w.y
w.stepFlip = not w.stepFlip
end
end
local function clearPath(w)
for i = #w.path, 1, -1 do w.path[i] = nil end
end
local function rest(w)
clearPath(w)
w.mode, w.want, w.blocked = "rest", 0, 0
w.facing = false
w.timer = randRange(w, Wander.REST_MIN, Wander.REST_MAX)
end
local function pickStep(w, free)
local last = w.path[#w.path]
local start = randRange(w, 1, #ORDER)
for i = 0, #ORDER - 1 do
local dir = ORDER[(start + i - 1) % #ORDER + 1]
if dir ~= (last and OPPOSITE[last]) then
local d = DELTA[dir]
local tx, ty = w.x + d[1], w.y + d[2]
if inRange(w, tx, ty) and free(tx, ty, w.x, w.y, dir) then return dir end
end
end
return nil
end
function Wander.tick(w, env)
if w.moving then
advance(w)
return true
end
env = env or {}
if env.frozen then return false end
local free = env.free or function() return false end
if not env.canWander and w.mode ~= "rest" then w.mode = "back" end
if w.mode == "rest" then
if not Wander.atHome(w) then
w.mode = "back"
return false
end
if not env.canWander then return false end
w.timer = w.timer - 1
if w.timer > 0 then return false end
clearPath(w)
w.mode, w.want = "out", randRange(w, 1, Wander.MAX_STEPS)
end
if w.mode == "out" then
local dir = #w.path < w.want and pickStep(w, free) or nil
if not dir and #w.path == 0 then
rest(w)
return false
end
if not dir then
w.mode = "pause"
w.timer = randRange(w, Wander.PAUSE_MIN, Wander.PAUSE_MAX)
local turn = ORDER[randRange(w, 1, #ORDER)]
if turn ~= w.facing then
w.facing = turn
return true
end
return false
end
w.path[#w.path + 1] = dir
beginStep(w, dir)
return true
end
if w.mode == "pause" then
w.timer = w.timer - 1
if w.timer <= 0 then w.mode = "back" end
return false
end
local last = w.path[#w.path]
if not last then
if w.x ~= w.homeX or w.y ~= w.homeY then
if not free(w.homeX, w.homeY) then return false end
w.x, w.y, w.prevX, w.prevY = w.homeX, w.homeY, w.homeX, w.homeY
end
rest(w)
return true
end
local dir = OPPOSITE[last]
local d = DELTA[dir]
if free(w.x + d[1], w.y + d[2], w.x, w.y, dir) then
w.path[#w.path] = nil
w.blocked = 0
beginStep(w, dir)
return true
end
w.blocked = w.blocked + 1
if w.blocked >= Wander.GIVE_UP and free(w.homeX, w.homeY) then
w.x, w.y, w.prevX, w.prevY = w.homeX, w.homeY, w.homeX, w.homeY
rest(w)
return true
end
return false
end
return Wander
+26
View File
@@ -58,8 +58,28 @@ local function deep(v)
return out
end
RecordMix.BYTES_KEY = "$b"
RecordMix.BYTES_MIN, RecordMix.BYTES_MAX = 16, 256
local function byteRun(v)
local n = #v
if n < RecordMix.BYTES_MIN or n > RecordMix.BYTES_MAX then return nil end
local count = 0
for _ in pairs(v) do count = count + 1 end
if count ~= n then return nil end
local hex = {}
for i = 1, n do
local b = v[i]
if type(b) ~= "number" or b < 0 or b > 255 or b % 1 ~= 0 then return nil end
hex[i] = string.format("%02x", b)
end
return table.concat(hex)
end
function RecordMix.toWire(v)
if type(v) ~= "table" then return v end
local hex = byteRun(v)
if hex then return { [RecordMix.BYTES_KEY] = hex } end
local out = {}
for k, x in pairs(v) do
if type(k) == "number" then out["#" .. tostring(k)] = RecordMix.toWire(x)
@@ -70,6 +90,12 @@ end
function RecordMix.fromWire(v)
if type(v) ~= "table" then return v end
local hex = v[RecordMix.BYTES_KEY]
if type(hex) == "string" and next(v, next(v)) == nil and #hex % 2 == 0 and not hex:find("[^%x]") then
local out = {}
for i = 1, #hex / 2 do out[i] = tonumber(hex:sub(i * 2 - 1, i * 2), 16) end
return out
end
local out = {}
for k, x in pairs(v) do
local n = type(k) == "string" and tonumber(k:match("^#(%-?%d+)$")) or nil
+157 -13
View File
@@ -30,8 +30,6 @@ Union.LINK_GROUP = {
POKEMON_JUMP = 4,
BERRY_CRUSH = 5,
BERRY_PICKING = 6,
WONDER_CARD = 7,
WONDER_NEWS = 8,
UNION_ROOM_RESUME = 9,
UNION_ROOM_INIT = 10,
-- pokeemerald/include/constants/union_room.h:70
@@ -77,15 +75,20 @@ Union.INVITE_ITEMS = {
{ key = "EXIT", activity = Union.ACTIVITY.NONE, union = true },
}
-- pokefirered/src/data/union_room.h:1 sLinkGroupActivityNameTexts
Union.ACTIVITY_NAMES = RomText.lazy({
[1] = "sLinkGroupActivityNameTexts[1]",
[2] = "sLinkGroupActivityNameTexts[2]",
[3] = "sLinkGroupActivityNameTexts[3]",
[4] = "sLinkGroupActivityNameTexts[4]",
[5] = "sLinkGroupActivityNameTexts[5]",
[8] = "sLinkGroupActivityNameTexts[8]",
[12] = "sLinkGroupActivityNameTexts[12]",
Union.ACTIVITY_NAME_IDS = {
-- pokefirered/src/data/union_room.h:1
frlg = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 21, 22 },
-- pokeemerald/src/data/union_room.h:592
rse = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 14, 15, 16, 21, 22, 23, 24, 25, 26, 27, 28 },
}
Union.ACTIVITY_NAMES = setmetatable({}, {
__index = function(_, raw)
raw = tonumber(raw)
for _, id in ipairs(Union.ACTIVITY_NAME_IDS[Family.of()] or {}) do
if id == raw then return RomText.plain(RomText.key("sLinkGroupActivityNameTexts", id)) end
end
return nil
end,
})
Union.AVATARS_FILE = "data/generated/gba/union_room/avatars.lua"
@@ -395,10 +398,113 @@ function Union.retryWaitingAvatars()
Union._avatarWaiting = waiting
end
Union.WANDER = true
Union.WANDER_NEAR_PLAYER = 2
local function wanderMod()
return require("src.core.game3.link.plaza_wander")
end
local STATUS_KIND = { chatting = "chat", trading = "trade", battling = "battle" }
function Union.memberStatusKind(p)
if type(p) ~= "table" then return nil end
local kind = STATUS_KIND[p.status]
if kind or p.status ~= nil then return kind end
local raw = math.floor(tonumber(p.activity) or 0) % Union.IN_UNION_ROOM
local A = Union.ACTIVITY
if raw == A.CHAT or raw == A.CARD then return "chat" end
if raw == A.TRADE or raw == A.SPIN_TRADE then return "trade" end
if raw == A.BATTLE_SINGLE or raw == A.BATTLE_DOUBLE or raw == A.BATTLE_MULTI then return "battle" end
return nil
end
function Union.memberIdle(p)
if type(p) ~= "table" or p.gone then return false end
if p.status ~= nil and p.status ~= "idle" then return false end
return idleActivity(p.activity)
end
local ENTER_OPTS = { surfing = false, elevation = 3 }
function Union.wanderWalkable(x, y, fromX, fromY, dir)
local Coll = package.loaded["src.core.game3.collision"]
if not (Coll and Coll._mapId == plazaMap().MAP_ID and Coll.canEnter) then return false end
if Coll.inBounds and not Coll.inBounds(x, y) then return false end
if Coll.isWater and Coll.isWater(x, y) then return false end
ENTER_OPTS.fromX, ENTER_OPTS.fromY, ENTER_OPTS.dir = fromX, fromY, dir
local ok = Coll.canEnter(link().game(), x, y, ENTER_OPTS)
return ok == true
end
local function wanderCellFree(x, y, fromX, fromY, dir)
if playerOn(x, y) then return false end
local Obj = objects()
if Obj and Obj.blocks and Obj.blocks(x, y) then return false end
return Union.wanderWalkable(x, y, fromX or x, fromY or y, dir)
end
local function playerNear(x, y)
local P = playerMod()
if not P then return false end
local near = Union.WANDER_NEAR_PLAYER
local px, py = tonumber(P.cellX), tonumber(P.cellY)
if px and py and math.abs(px - x) <= near and math.abs(py - y) <= near then return true end
if not P.moving then return false end
px, py = tonumber(P.targetX), tonumber(P.targetY)
return px ~= nil and py ~= nil and math.abs(px - x) <= near and math.abs(py - y) <= near
end
local WANDER_ENV = { free = wanderCellFree }
function Union.tickWander()
local dirty = false
local onPlaza = Union.WANDER and link().currentMap() == plazaMap().MAP_ID
for slot = 1, Union.capacity() do
local v = Union.vobj(slot)
local p = Union.players[slot]
if v and v.visible then
local w = v.wander
if not w or w.homeX ~= v.x or w.homeY ~= v.y then
w = wanderMod().new(slot * 7919 + (tonumber(p and p.trainerId) or 0) + 1, v.x, v.y)
v.wander = w
end
if v.anim == nil then
WANDER_ENV.frozen = Union._talkSlot == slot or Union.partnerId == slot
WANDER_ENV.canWander = onPlaza and Union.memberIdle(p) and not playerNear(v.x, v.y)
if wanderMod().tick(w, WANDER_ENV) then dirty = true end
end
elseif v then
v.wander = nil
end
end
return dirty
end
function Union.avatarCell(slot)
local v = Union.vobj(slot)
if not v then return nil end
local w = v.wander
if w then return w.x, w.y, w.moving end
return v.x, v.y, false
end
function Union.slotAtCell(x, y)
for slot = 1, Union.capacity() do
local v = Union.vobj(slot)
if v and v.visible then
local cx, cy, moving = Union.avatarCell(slot)
if cx == x and cy == y and not moving then return slot end
end
end
return nil
end
-- pokefirered/src/event_object_movement.c:9354
function Union.animateVobjs()
local dirty = Union._vobjDirty
local vobjs = Union._vobjs
if Union.tickWander() then dirty = true end
for _, v in pairs(vobjs) do
if v.anim == "in" then
v.y2 = math.min(0, v.y2 + Union.FLY_STEP)
@@ -431,9 +537,22 @@ function Union.animateVobjs()
if v.visible then
local rec = VirtualObjects.get(id) or VirtualObjects.spawn(id, v.gfx, v.x, v.y, 3, v.dir)
if rec then
local w = v.wander
rec.graphicsId = v.gfx
rec.x, rec.y = v.x, v.y
rec.direction = v.dir
rec.prevX, rec.prevY, rec.px, rec.py, rec.moving = nil, nil, nil, nil, nil
if w then
rec.x, rec.y = w.x, w.y
if w.facing and Union._talkSlot ~= v.slot then rec.direction = Union.FACE_DIR[w.facing] or v.dir end
if w.moving then
rec.prevX, rec.prevY, rec.px, rec.py, rec.moving = w.prevX, w.prevY, w.px, w.py, true
rec.targetX, rec.targetY = w.x, w.y
else
rec.targetX, rec.targetY = nil, nil
end
rec.animClock, rec.stepFrames, rec.stepFlip = w.animClock, wanderMod().STEP_FRAMES, w.stepFlip
end
rec.y2 = v.y2
rec.solid = v.anim ~= "out"
end
@@ -469,7 +588,7 @@ function Union.tryInteractWithMember()
local d = P and FACING_DELTA[P.facing or "down"]
if not d then return nil end
local fx, fy = (tonumber(P.cellX) or 0) + d[1], (tonumber(P.cellY) or 0) + d[2]
local slot = plazaMap().slotAt(fx, fy)
local slot = Union.slotAtCell(fx, fy)
if not slot then return nil end
local p = Union.players[slot]
local v = Union.vobj(slot)
@@ -1603,6 +1722,7 @@ end
function Union.relayTick(_dt)
local L = link()
if not package.loaded["src.ui.game3.link_tags"] then pcall(require, "src.ui.game3.link_tags") end
if not L.online() then
Union.plazaOffline()
Union.animateAll()
@@ -2583,6 +2703,8 @@ function Union.roomPartnerAvatar(room)
return nil
end
Union.GROUP_JOIN_TICKS = 600
function Union.directRows(wire)
local out = {}
for _, e in ipairs(link().clientCall("directEntries", wire) or {}) do
@@ -2596,6 +2718,11 @@ function Union.directRows(wire)
out[#out + 1] = { key = "p:" .. tostring(e.id), kind = "player", id = e.id,
name = av.name or e.name, trainerId = av.trainerId, gender = av.gender, version = av.version,
canLinkNationally = av.canLinkNationally == true }
elseif e.kind == "group" and e.leader ~= nil
and (tonumber(e.joined) or 0) < (tonumber(e.max) or 2) then
out[#out + 1] = { key = "g:" .. tostring(e.leader), kind = "group", id = e.leader, leader = e.leader,
name = av.name or e.name, trainerId = av.trainerId, gender = av.gender,
version = av.version or e.version, canLinkNationally = av.canLinkNationally == true }
end
end
end
@@ -2698,6 +2825,11 @@ function Union.chooseDirect(ctx, adapters, group, _direct)
if D then D.setFrozen(true) end
st.handle = L.clientCall("invite", row.id, wire, { ruleset = ruleset }, profile)
if M then M.show(Union.askedToJoinText(wire, row.name), { stay = true }) end
elseif row.kind == "group" then
st.phase, st.row, st.sawGroup, st.waited = "grouping", row, false, 0
if D then D.setFrozen(true) end
L.clientCall("joinGroup", row.leader, profile, L.avatar())
if M then M.show(Union.askedToJoinText(wire, row.name), { stay = true }) end
elseif row.locked then
askPin(row)
else
@@ -2723,6 +2855,15 @@ function Union.chooseDirect(ctx, adapters, group, _direct)
local row = st.row or {}
notice(h.why == "declined" and Union.rejectText(Union.GROUP_ACTIVITY[group].activity, row.gender) or busy)
end
elseif st.phase == "grouping" then
local g = L.clientCall("group")
st.waited = st.waited + 1
if type(g) == "table" and g.leader == st.row.leader then
st.sawGroup = true
elseif st.sawGroup or st.waited > Union.GROUP_JOIN_TICKS then
L.clientCall("leaveGroup")
notice(st.sawGroup and Union.rejectText(Union.GROUP_ACTIVITY[group].activity, st.row.gender) or busy)
end
elseif st.phase == "joining" then
local p = st.pending
if type(p) ~= "table" then
@@ -2806,7 +2947,10 @@ function Union.directFlow(ctx, adapters, group, role)
L.setStatus("idle")
local _, _, linkType = Union.directDest(group)
return Union.waitForMatch(ctx, adapters, {
cancel = function() L.clientCall("leaveDirect") end,
cancel = function()
L.clientCall("leaveDirect")
if type(L.clientCall("group")) == "table" then L.clientCall("leaveGroup") end
end,
refuse = function(room)
if wire ~= "trade" then return nil end
local ready = Union.tradeReadyWith(Union.roomPartnerAvatar(room))
+206 -43
View File
@@ -238,6 +238,27 @@ local function copyFlagList(list, version, bad)
return out
end
MysteryGift.VERSIONS = { "firered", "leafgreen", "ruby", "sapphire", "emerald" }
MysteryGift.FAMILY_VERSIONS = { frlg = { "firered", "leafgreen" }, rse = { "emerald" }, rs = { "ruby", "sapphire" } }
local function copyVersions(list)
if type(list) ~= "table" then return nil end
local known, out = {}, {}
for _, v in ipairs(MysteryGift.VERSIONS) do known[v] = true end
for _, v in ipairs(list) do
if known[v] then out[#out + 1] = v end
end
return out
end
function MysteryGift.offeredIn(list, version)
if type(list) ~= "table" or version == nil then return true end
for _, v in ipairs(list) do
if v == version then return true end
end
return false
end
local function resolved(version, kind, value, bad)
if value == nil then return nil end
local n = MysteryGift.resolveId(version, kind, value)
@@ -289,8 +310,22 @@ function MysteryGift.normalizeCard(card, version)
requireStat = tonumber(gift.requireStat),
requireValue = tonumber(gift.requireValue),
script = type(gift.script) == "string" and gift.script:match("^[%w_]+$") and gift.script or nil,
metLocation = tonumber(gift.metLocation),
metLevel = tonumber(gift.metLevel),
metGame = tonumber(gift.metGame),
ball = tonumber(gift.ball),
language = tonumber(gift.language),
otNames = type(gift.otNames) == "table" and copyList(gift.otNames, math.min(#gift.otNames, 6), 7) or nil,
otIdMax = tonumber(gift.otIdMax),
secretIdRandom = gift.secretIdRandom == true or nil,
otGender = tonumber(gift.otGender),
shiny = (gift.shiny == "never" or gift.shiny == "always" or gift.shiny == "random") and gift.shiny or nil,
nationalRibbon = gift.nationalRibbon == true or nil,
movesOnly = gift.movesOnly == true or nil,
},
versions = copyVersions(card.versions),
}
if gift.fateful == false then out.gift.fateful = false end
if bad.any then return nil, "unresolved" end
return out
end
@@ -331,12 +366,19 @@ local function cardBytes(card)
tostring(gift.requireStat or 0), tostring(gift.requireValue or 0),
}
if gift.script then parts[#parts + 1] = "R" .. tostring(gift.script) end
for _, k in ipairs({ "metLocation", "metLevel", "metGame", "ball", "language", "otIdMax", "secretIdRandom",
"otGender", "shiny", "nationalRibbon", "movesOnly" }) do
if gift[k] ~= nil then parts[#parts + 1] = k .. "=" .. tostring(gift[k]) end
end
if gift.fateful == false then parts[#parts + 1] = "fateful=false" end
for _, n in ipairs(type(gift.otNames) == "table" and gift.otNames or {}) do parts[#parts + 1] = "O" .. n end
for _, slot in ipairs(moveKeys) do
parts[#parts + 1] = tostring(slot) .. "=" .. tostring(gift.moves[slot])
end
for _, id in ipairs(gift.setFlags or {}) do parts[#parts + 1] = "S" .. tostring(id) end
for _, id in ipairs(gift.haveFlags or {}) do parts[#parts + 1] = "H" .. tostring(id) end
if gift.heldItem then parts[#parts + 1] = "I" .. tostring(gift.heldItem) end
for _, v in ipairs(type(card.versions) == "table" and card.versions or {}) do parts[#parts + 1] = "V" .. tostring(v) end
return table.concat(parts, "\2")
end
@@ -651,20 +693,6 @@ function MysteryGift.isNewsSameAsSaved(session, news)
return newsBytes(MysteryGift.ensure(session).news) == newsBytes(other)
end
-- pokefirered/src/mystery_gift.c:120 IsSendingSavedWonderNewsAllowed
function MysteryGift.isSendingNewsAllowed(session)
local news = MysteryGift.ensure(session).news
if not news then return false end
return num(news.sendType) ~= MysteryGift.SEND_TYPE_DISALLOWED
end
-- pokefirered/src/mystery_gift.c:208 IsSendingSavedWonderCardAllowed
function MysteryGift.isSendingCardAllowed(session)
local card = MysteryGift.ensure(session).card
if not card then return false end
return num(card.sendType) ~= MysteryGift.SEND_TYPE_DISALLOWED
end
-- pokefirered/src/mystery_gift.c:235 DisableWonderCardSending
function MysteryGift.disableCardSending(card)
if type(card) == "table" and num(card.sendType) == MysteryGift.SEND_TYPE_ALLOWED then
@@ -694,8 +722,30 @@ function MysteryGift.receivedGiftFlag(flagId, session)
return first + (flagId - MysteryGift.WONDER_CARD_FLAG_OFFSET)
end
local function tracksById(card)
local gift = type(card) == "table" and type(card.gift) == "table" and card.gift or {}
return (gift.kind == "mon" or gift.kind == "egg") and not gift.doneFlag
end
local function deliveredIds(session)
local rec = MysteryGift.ensure(session)
rec.deliveredIds = type(rec.deliveredIds) == "table" and rec.deliveredIds or {}
return rec.deliveredIds
end
function MysteryGift.wasDelivered(session, card)
local id = num(type(card) == "table" and card.idNumber)
for _, v in ipairs(deliveredIds(session)) do
if num(v) == id then return true end
end
return false
end
-- pokefirered/src/mystery_gift.c:248 IsSavedWonderCardGiftNotReceived
function MysteryGift.isGiftNotReceived(session)
if MysteryGift.validateSavedCard(session) and tracksById(MysteryGift.ensure(session).card) then
return not MysteryGift.wasDelivered(session, MysteryGift.ensure(session).card)
end
local flagId = MysteryGift.getCardFlagId(session)
local giftFlag = MysteryGift.receivedGiftFlag(flagId, session)
if not giftFlag then return false end
@@ -954,13 +1004,22 @@ function MysteryGift.getNewsRewardInfo(session)
return rewardType, item
end
-- pokefirered/src/mystery_gift_client.c:210 CLI_SAVE_NEWS
function MysteryGift.saveNewsIfNew(session, news)
if MysteryGift.isNewsSameAsSaved(session, news) then return false, "had" end
if not MysteryGift.saveNews(session, news) then return false, "invalid" end
return true
end
-- pokefirered/src/mystery_gift_client.c:213
function MysteryGift.receiveNews(session, news, newsType)
if not MysteryGift.saveNews(session, news) then return false end
local ok, why = MysteryGift.saveNewsIfNew(session, news)
if not ok then return false, why end
MysteryGift.setNewsReward(session, newsType or MysteryGift.WONDER_NEWS_RECV_WIRELESS)
return true
end
-- pokefirered/data/mystery_event_msg.s:69 SurfPichu_GiveEgg
local function createEventMon(session, gift)
local Party = require("src.core.game3.party")
@@ -980,8 +1039,43 @@ local function createEventMon(session, gift)
ok, code, mon = Party.giveMon(session, species, level, gift.nickname)
end
if not (ok and mon) then return nil, code end
if gift.personality then
mon.personality = num(gift.personality)
local Rng = require("src.core.game3.rng")
local otName = gift.otName
if type(gift.otNames) == "table" and #gift.otNames > 0 then
otName = gift.otNames[(Rng.Random() % #gift.otNames) + 1]
end
if otName then
mon.ot = otName
mon.otName = otName
end
if gift.otIdMax then
mon.otId = (Rng.Random() % num(gift.otIdMax)) + 1
mon.otSecretId = 0
elseif gift.otId then
mon.otId = num(gift.otId) % 65536
mon.otSecretId = math.floor(num(gift.otId) / 65536) % 65536
end
if gift.secretIdRandom then mon.otSecretId = Rng.Random() end
if gift.otGender == 2 then mon.otGender = Rng.Random() % 2
elseif gift.otGender then mon.otGender = gift.otGender end
local personality = gift.personality and num(gift.personality) or mon.personality
-- pokefirered/src/pokemon.c:6062 IsShinyOtIdPersonality
local function shiny(p)
local bit = require("bit")
local tid, sid = num(mon.otId) % 65536, num(mon.otSecretId) % 65536
return bit.bxor(bit.bxor(tid, sid), bit.bxor(math.floor(p / 65536), p % 65536)) < 8
end
if not gift.personality and gift.shiny == "never" then
while shiny(personality) do personality = Rng.Random32() % 0x100000000 end
elseif not gift.personality and gift.shiny == "always" and not shiny(personality) then
local bit = require("bit")
local hi = math.floor(personality / 65536)
local lo = bit.band(bit.bxor(bit.bxor(num(mon.otId) % 65536, num(mon.otSecretId) % 65536), hi), 0xFFF8)
+ personality % 8
personality = hi * 65536 + lo
end
if personality ~= mon.personality then
mon.personality = personality
if Pokemon.natureId then mon.nature = Pokemon.natureId(mon.personality) end
if Pokemon.abilityId then
mon.ability = Pokemon.abilityId(species, mon.personality)
@@ -990,25 +1084,24 @@ local function createEventMon(session, gift)
if Pokemon.gender then mon.gender = Pokemon.gender(species, mon.personality) end
if Pokemon.applyStats then Pokemon.applyStats(mon) end
end
if gift.otName then
mon.ot = gift.otName
mon.otName = gift.otName
end
if gift.otId then
-- pokefirered/src/pokemon.c:6062 IsShinyOtIdPersonality
mon.otId = num(gift.otId) % 65536
mon.otSecretId = math.floor(num(gift.otId) / 65536) % 65536
end
if gift.heldItem and num(gift.heldItem) > 0 then
mon.item, mon.heldItem = num(gift.heldItem), num(gift.heldItem)
end
-- pokefirered/src/scrcmd.c:2244 ScrCmd_setmonmodernfatefulencounter
mon.modernFatefulEncounter = true
mon.modernFatefulEncounter = gift.fateful ~= false
if gift.nationalRibbon then
require("src.core.game3.rse.ribbons").set(mon, "national", 1)
end
-- pokefirered/data/mystery_event_msg.s:72 setmonmetlocation METLOC_FATEFUL_ENCOUNTER
mon.metLocation = METLOC_FATEFUL_ENCOUNTER
mon.metLocation = gift.metLocation or METLOC_FATEFUL_ENCOUNTER
if gift.metLevel then mon.metLevel = gift.metLevel end
if gift.metGame then mon.metGame = gift.metGame end
if gift.ball then mon.pokeball = gift.ball end
if gift.language then mon.language = gift.language end
mon.moves = mon.moves or {}
mon.pp = mon.pp or {}
mon.maxPp = mon.maxPp or {}
if gift.movesOnly then mon.moves, mon.pp, mon.maxPp = {}, {}, {} end
for slot, move in pairs(gift.moves or {}) do
mon.moves[slot] = move
local pp = Pokemon.movePp and Pokemon.movePp(move) or nil
@@ -1034,12 +1127,14 @@ function MysteryGift.deliverGift(session, card)
card = card or MysteryGift.getSavedCard(session)
if type(card) ~= "table" then return MysteryGift.DELIVER_NOTHING end
local gift = type(card.gift) == "table" and card.gift or {}
local receivedFlag = MysteryGift.receivedGiftFlag(card.flagId, session)
local byId = tracksById(card)
local receivedFlag = not byId and MysteryGift.receivedGiftFlag(card.flagId, session) or nil
-- pokefirered/src/mystery_gift.c:248 IsSavedWonderCardGiftNotReceived
if receivedFlag and getFlag(session, receivedFlag) then
return MysteryGift.DELIVER_ALREADY
end
if byId and MysteryGift.wasDelivered(session, card) then return MysteryGift.DELIVER_ALREADY end
if gift.doneFlag and getFlag(session, gift.doneFlag) then
return MysteryGift.DELIVER_ALREADY
end
@@ -1085,6 +1180,7 @@ function MysteryGift.deliverGift(session, card)
end
if gift.doneFlag then setFlag(session, gift.doneFlag, true) end
if receivedFlag then setFlag(session, receivedFlag, true) end
if byId then table.insert(deliveredIds(session), num(card.idNumber)) end
return MysteryGift.DELIVER_GIVEN
end
@@ -1229,6 +1325,7 @@ function MysteryGift.builtins(family)
Strings("It is for use at VERMILION CITY port.")),
footerLine1Text = Strings("Speak to the deliveryman"),
footerLine2Text = Strings("at a POKéMON CENTER."),
versions = { "firered", "leafgreen" },
gift = {
kind = "item",
item = MysteryGift.ITEM_MYSTIC_TICKET,
@@ -1261,6 +1358,7 @@ function MysteryGift.builtins(family)
Strings("It is for use at VERMILION CITY port.")),
footerLine1Text = Strings("Speak to the deliveryman"),
footerLine2Text = Strings("at a POKéMON CENTER."),
versions = { "firered", "leafgreen" },
gift = {
kind = "item",
item = MysteryGift.ITEM_AURORA_TICKET,
@@ -1294,6 +1392,7 @@ function MysteryGift.builtins(family)
Strings("kindness.")),
footerLine1Text = Strings("Speak to the deliveryman"),
footerLine2Text = Strings("at a POKéMON CENTER."),
versions = { "firered", "leafgreen" },
gift = {
kind = "egg",
species = 172,
@@ -1326,6 +1425,7 @@ function MysteryGift.builtins(family)
Strings("your STAMP CARD.")),
footerLine1Text = Strings("Speak to the deliveryman"),
footerLine2Text = Strings("at a POKéMON CENTER."),
versions = { "firered", "leafgreen" },
gift = { kind = "none" },
},
},
@@ -1350,6 +1450,7 @@ function MysteryGift.builtins(family)
Strings("Look for them and battle!")),
footerLine1Text = Strings("Speak to the deliveryman"),
footerLine2Text = Strings("at a POKéMON CENTER."),
versions = { "firered", "leafgreen" },
-- pokefirered/data/mystery_event_msg.s:173 giveitem ITEM_POTION
gift = {
kind = "item",
@@ -1382,6 +1483,7 @@ function MysteryGift.builtins(family)
Strings("on SIX ISLAND.")),
footerLine1Text = Strings("Speak to the deliveryman"),
footerLine2Text = Strings("at a POKéMON CENTER."),
versions = { "firered", "leafgreen" },
-- pokefirered/data/mystery_event_msg.s:325
gift = {
kind = "var",
@@ -1403,6 +1505,7 @@ local function rseCard(key, label, t)
t.subtitleText = t.subtitleText or Strings("MYSTERY GIFT")
t.footerLine1Text = t.footerLine1Text or Strings("Speak to the deliveryman")
t.footerLine2Text = t.footerLine2Text or Strings("at a POKéMON CENTER.")
t.versions = t.versions or { "emerald" }
return { key = key, family = "rse", label = label, card = t }
end
@@ -1579,10 +1682,60 @@ end
local function familyVersion(family)
if family == MysteryGift.familyOf(nil) then return versionOf(nil) end
return family == "rse" and "emerald" or "firered"
return MysteryGift.FAMILY_VERSIONS[family] and MysteryGift.FAMILY_VERSIONS[family][1] or "firered"
end
function MysteryGift.parseFeed(payload, family)
-- pokeruby/src/mystery_event_script.c:46 CheckCompatibility
function MysteryGift.eventHeaderOk(bytes, version)
local Event = require("src.core.game3.rs.mystery_event")
if type(bytes) ~= "string" or #bytes < 17 or #bytes > Event.MAX_BYTES or bytes:byte(1) ~= 1 then return false end
local bit = require("bit")
local function u16(at) return bytes:byte(at) + bytes:byte(at + 1) * 256 end
local function u32(at) return u16(at) + u16(at + 2) * 65536 end
local mask = ({ ruby = 0x80, sapphire = 0x100 })[version] or 0x180
return bit.band(u16(6), 2) ~= 0 and bit.band(u32(8), 2) ~= 0 and bit.band(u16(12), 4) ~= 0
and bit.band(u32(14), mask) ~= 0
end
local EVENT_OP_SIZE = { [0] = 1, 17, 1, 6, 2, 5, 12, 5, 3, 1, 2, 5, 5, 5, 1, 13, 13 }
-- pokeruby/src/mystery_event_script.c:356 MEScrCmd_setrecordmixinggift
function MysteryGift.eventRecordMixingItem(bytes)
if type(bytes) ~= "string" then return nil end
local pos = 1
while pos <= #bytes do
local op = bytes:byte(pos)
local size = EVENT_OP_SIZE[op]
if not size or op == 2 then return nil end
if op == 11 and pos + 4 <= #bytes then
local unk, qty = bytes:byte(pos + 1), bytes:byte(pos + 2)
local item = bytes:byte(pos + 3) + bytes:byte(pos + 4) * 256
if unk ~= 0 and qty ~= 0 and item ~= 0 then return item end
end
pos = pos + size
end
return nil
end
local function parseEvents(data, version)
local out = {}
if type(data.events) ~= "table" then return out end
local Base64 = require("src.core.Base64")
for _, raw in ipairs(data.events) do
local ok, bytes = pcall(Base64.decode, type(raw) == "table" and raw.payload or nil)
if ok and rowFamily(raw) == "rs" and MysteryGift.offeredIn(copyVersions(raw.versions), version)
and MysteryGift.eventHeaderOk(bytes, version) then
out[#out + 1] = {
key = type(raw.key) == "string" and raw.key or tostring(#out + 1),
label = clampText(raw.title, WONDER_CARD_TEXT_LENGTH),
bytes = bytes,
}
end
end
return out
end
function MysteryGift.parseFeed(payload, family, gameVersion)
family = family or MysteryGift.familyOf(nil)
local version = familyVersion(family)
local Json = require("src.link.Json")
@@ -1590,9 +1743,11 @@ function MysteryGift.parseFeed(payload, family)
if type(data) ~= "table" or data.v ~= MysteryGift.FEED_VERSION then return nil, "bad_feed" end
if type(data.cards) ~= "table" or type(data.news) ~= "table" then return nil, "bad_feed" end
local out = { issued = num(data.issued), cards = {}, news = {} }
if family == "rs" then out.events = parseEvents(data, gameVersion) end
for _, raw in ipairs(data.cards) do
local card = rowFamily(raw) == family and MysteryGift.normalizeCard(raw, version) or nil
if card and validateCard(card) and isFlagIdInValidRange(card.flagId) and card.idNumber ~= 0 then
local card = rowFamily(raw) == family and MysteryGift.normalizeCard(raw, gameVersion or version) or nil
if card and validateCard(card) and isFlagIdInValidRange(card.flagId) and card.idNumber ~= 0
and MysteryGift.offeredIn(card.versions, gameVersion) then
out.cards[#out.cards + 1] = {
key = type(raw.key) == "string" and raw.key or tostring(card.idNumber),
label = card.titleText,
@@ -1601,7 +1756,7 @@ function MysteryGift.parseFeed(payload, family)
end
end
for _, raw in ipairs(data.news) do
local news = rowFamily(raw) == family and MysteryGift.normalizeNews(raw) or nil
local news = MysteryGift.normalizeNews(raw)
if news and validateNews(news) then
out.news[#out.news + 1] = {
key = type(raw.key) == "string" and raw.key or tostring(news.id),
@@ -1613,7 +1768,7 @@ function MysteryGift.parseFeed(payload, family)
return out
end
function MysteryGift.verifyFeed(feed, family)
function MysteryGift.verifyFeed(feed, family, gameVersion)
if type(feed) ~= "table" or type(feed.payload) ~= "string" or type(feed.sig) ~= "string" then
return nil, "bad_feed"
end
@@ -1621,17 +1776,25 @@ function MysteryGift.verifyFeed(feed, family)
if not Ed25519.verify(MysteryGift.GIFT_PUBKEY, feed.payload, feed.sig) then
return nil, "bad_signature"
end
return MysteryGift.parseFeed(feed.payload, family)
return MysteryGift.parseFeed(feed.payload, family, gameVersion)
end
function MysteryGift.feedPath(family)
if family == "frlg" then return MysteryGift.FEED_PATH end
return MysteryGift.FEED_PATH .. "?family=" .. tostring(family)
function MysteryGift.feedPath(family, gameVersion)
local q = {}
if family ~= "frlg" then q[#q + 1] = "family=" .. tostring(family) end
if gameVersion then q[#q + 1] = "version=" .. tostring(gameVersion) end
if #q == 0 then return MysteryGift.FEED_PATH end
return MysteryGift.FEED_PATH .. "?" .. table.concat(q, "&")
end
function MysteryGift.fetchOnline(opts)
opts = opts or {}
local family = opts.family or MysteryGift.familyOf(opts.session)
local gameVersion = opts.version
if gameVersion == nil then
gameVersion = versionOf(opts.session)
if not MysteryGift.offeredIn(MysteryGift.FAMILY_VERSIONS[family], gameVersion) then gameVersion = nil end
end
local client = opts.client
if not client then
local okS, SyncClient = pcall(require, "src.sync.SyncClient")
@@ -1640,12 +1803,12 @@ function MysteryGift.fetchOnline(opts)
if not okN then return { status = "error", reason = "offline" } end
client = made
end
local okR, handle = pcall(client.send, client, "GET", MysteryGift.feedPath(family), nil,
local okR, handle = pcall(client.send, client, "GET", MysteryGift.feedPath(family, gameVersion), nil,
{ noAuth = true, maxSeconds = MysteryGift.FETCH_SECONDS })
if not okR or handle == nil then
return { status = "error", reason = "offline", client = client, family = family }
return { status = "error", reason = "offline", client = client, family = family, version = gameVersion }
end
return { status = "pending", client = client, handle = handle, family = family }
return { status = "pending", client = client, handle = handle, family = family, version = gameVersion }
end
function MysteryGift.pollOnline(job)
@@ -1662,7 +1825,7 @@ function MysteryGift.pollOnline(job)
job.reason = (num(res.code) >= 400) and "server" or "offline"
return "error", job.reason
end
local list, why = MysteryGift.verifyFeed(res.data, job.family)
local list, why = MysteryGift.verifyFeed(res.data, job.family, job.version)
if not list then
job.status, job.reason = "error", why
return "error", why
+5 -146
View File
@@ -1,7 +1,6 @@
local Link = require("src.core.game3.link.init")
local Strings = require("src.core.Strings")
local CableEntry = require("src.core.game3.link.cable_entry")
local M = {}
-- pokeruby/cable_club.c:558
-- pokeruby/src/cable_club.c:558
M.SERVICES = {
[1] = {wire = "battle_single", linkType = 0x2233, min = 2, max = 2},
[2] = {wire = "battle_double", linkType = 0x2244, min = 2, max = 2},
@@ -10,149 +9,9 @@ M.SERVICES = {
records = {wire = "record_corner", linkType = 0x3311, min = 2, max = 4},
blender = {wire = "berry_blender", linkType = 0x4411, min = 2, max = 4},
}
-- pokeruby/src/cable_club.c:651
function M.contest(category)
local names = {"cool", "beauty", "cute", "smart", "tough"}
return {wire = "contest_" .. assert(names[(tonumber(category) or 0) + 1]), linkType = 0x6601, min = 4, max = 4}
end
function M.run(ctx, adapters, spec, handshake)
assert(spec, "RS cable service missing")
local live = Link.link
if live and live.isOpen and live:isOpen() then return handshake(ctx, adapters) end
local N = require("src.core.game3.scripting.natives")
local UI = require("src.ui.game3.rs.cable_lobby")
local phase, ticks, done, role, pending, answered = "connect", 0, false, nil, nil, {}
local seenGroup, goneTicks, awayTicks = false, 0, 0
local profile = assert(spec.profile or Link.liveProfile(), "RS link profile missing")
local avatar = spec.avatar or Link.avatar()
local function finish(code)
Link.clientCall("leaveGroup"); Link.closeLink("rs_cable_failed")
UI.close(); Link.clientCall("groupList", nil); Link.setStatus("busy")
Link.setResult(ctx, code); done = true
end
local function offline(member)
if type(member) ~= "table" then return false end
if member.online == false then return true end
for _, entry in ipairs(Link.clientCall("lobby") or {}) do
if type(entry) == "table" and entry.id ~= nil and entry.id == member.id then return entry.online == false end
end
return false
end
local function liveMembers(group)
local members = type(group) == "table" and type(group.members) == "table" and group.members or {}
local live = {}
for _, member in ipairs(members) do
if not offline(member) then live[#live + 1] = member end
end
return live, #live == #members
end
local function rows()
if phase == "mode" then return {{id = "leader", label = Strings("CREATE GROUP")}, {id = "join", label = Strings("JOIN GROUP")}} end
if phase == "ask" then return {{id = "yes", label = Strings("YES")}, {id = "no", label = Strings("NO")}} end
local out, group = {}, Link.clientCall("group")
if role == "leader" then
local members, all = liveMembers(group)
for _, member in ipairs(members) do
local av = member.avatar or {}
out[#out + 1] = {label = av.name or member.name or "", disabled = true}
end
out[#out + 1] = {id = "start", label = Strings("START"), disabled = not all or #members < spec.min or #members > spec.max}
elseif phase == "list" then
for _, groupRow in ipairs(Link.clientCall("groups", spec.wire) or {}) do
local av = groupRow.avatar or {}
out[#out + 1] = {id = groupRow.leader, label = av.name or groupRow.name or "",
disabled = (tonumber(groupRow.joined) or 0) >= (tonumber(groupRow.max) or spec.max)}
end
end
return out
end
local poll
local function select(row)
if phase == "mode" then
role, phase = row.id, "list"
if role == "leader" then Link.clientCall("openGroup", spec.wire, profile, avatar)
else Link.clientCall("groupList", spec.wire, profile) end
elseif phase == "ask" then
answered[pending.id] = true
Link.clientCall("acceptGroup", pending.id, row.id == "yes"); pending, phase = nil, "list"
elseif role == "leader" and row.id == "start" then
Link.clientCall("startGroup"); phase, ticks = "starting", 0
elseif role == "join" then Link.clientCall("joinGroup", row.id, profile, avatar); phase, ticks = "waiting", 0 end
end
N.yieldHost(ctx, adapters, function() end)
Link.setResult(ctx, 0)
if not Link.online() then
local ok = (spec.connect or Link.connect)()
if not ok then finish(Link.LINKUP.CONNECTION_ERROR); return false, Link.LINKUP.CONNECTION_ERROR end
else phase = "mode" end
poll = function()
if done then return true end
ticks = ticks + 1
if phase == "connect" then
if Link.online() then phase, ticks = "mode", 0
elseif ticks > 1200 or Link.connectState() == "error" then finish(Link.LINKUP.CONNECTION_ERROR) end
return done
end
if not Link.online() then finish(Link.LINKUP.CONNECTION_ERROR); return true end
if phase == "ready" then
local lk = Link.link
if not lk or not lk:isOpen() then finish(Link.LINKUP.CONNECTION_ERROR); return true end
if lk:isReady() then
UI.close(); Link.clientCall("groupList", nil); Link.setStatus("busy")
local yielded, value = handshake(ctx, adapters)
if not yielded then Link.setResult(ctx, value or Link.getVar(ctx, Link.VAR_RESULT)); return true end
return false
elseif ticks > 600 then finish(Link.LINKUP.CONNECTION_ERROR); return true end
return false
end
local room = Link.clientCall("room")
if type(room) == "table" and room.stage == "battling" and room.match ~= nil then
UI.close()
if not Link.openRelay({linkType = spec.linkType, game = spec.game, hello = spec.hello}) then finish(Link.LINKUP.CONNECTION_ERROR); return true end
phase, ticks = "ready", 0
return false
end
local g = Link.clientCall("group")
if role == "leader" and phase == "ask" then
local still = false
for _, request in ipairs(type(g) == "table" and type(g.pending) == "table" and g.pending or {}) do
if pending and request.id == pending.id and not offline(request) then still = true end
end
if not still then
if pending then answered[pending.id] = true end
pending, phase = nil, "list"
UI.cursor = 1
end
elseif role == "leader" and phase == "list" then
for _, request in ipairs(type(g) == "table" and type(g.pending) == "table" and g.pending or {}) do
if not answered[request.id] and not offline(request) then pending, phase = request, "ask"; break end
end
elseif role == "leader" and phase == "starting" and type(g) == "table" then
local members, all = liveMembers(g)
if not all or #members < spec.min then phase, ticks = "list", 0 end
elseif role == "join" and phase == "waiting" then
if type(g) == "table" then
seenGroup, goneTicks = true, 0
local leader
for _, member in ipairs(type(g.members) == "table" and g.members or {}) do
if member.id == g.leader then leader = member end
end
awayTicks = offline(leader) and awayTicks + 1 or 0
if awayTicks > 600 then finish(Link.LINKUP.CONNECTION_ERROR); return true end
ticks = 0
elseif seenGroup then
goneTicks = goneTicks + 1
if goneTicks > 120 then finish(Link.LINKUP.CONNECTION_ERROR); return true end
end
end
if not done and (phase == "starting" or phase == "waiting") and ticks > 1200 then finish(Link.LINKUP.CONNECTION_ERROR); return true end
return false
end
ctx.nativePoll = poll
UI.show({rows = rows, select = select, tick = function()
if ctx.nativePoll == poll then poll() end
end,
title = function() return phase == "ask" and Strings("ADD %s?", (pending.avatar or {}).name or pending.name or "") or Strings(spec.wire:upper():gsub("_", " ")) end,
cancel = function() finish(Link.LINKUP.FAILED) end})
return true
return {wire = assert(CableEntry.CONTEST_WIRES[tonumber(category) or -1]), linkType = 0x6601, min = 4, max = 4}
end
M.run = CableEntry.run
return M
+58 -6
View File
@@ -26,6 +26,11 @@ EventIslands.GIFT_FLAGS = {
local cache = {}
function EventIslands.available()
local ok, man = pcall(EventIslands.manifest)
return ok and type(man) == "table" and type(man.gifts) == "table"
end
function EventIslands.manifest()
local GameVersion = require("src.core.GameVersion")
local key = tostring(GameVersion.get())
@@ -70,28 +75,75 @@ local function hasItem(sess, name)
return Bag.has(sess.bag, C(sess):require("items", name), 1)
end
local TICKET_IDS = { eon = true, aurora = true, mystic = true, oldSeaMap = true }
EventIslands.REFRESH_SECONDS = 600
function EventIslands.relayTickets(sess)
local MysteryGift = require("src.core.game3.mystery_gift")
local rec = MysteryGift.ensure(sess)
local out = {}
for _, id in ipairs(type(rec.relayTickets) == "table" and rec.relayTickets or {}) do out[id] = true end
return out
end
function EventIslands.applyFeed(sess, list)
local MysteryGift = require("src.core.game3.mystery_gift")
local ids, seen = {}, {}
for _, entry in ipairs(type(list) == "table" and list.cards or {}) do
local id = MysteryGift.ramScriptId(sess, entry.card)
if TICKET_IDS[id] and not seen[id] then
seen[id] = true
ids[#ids + 1] = id
end
end
MysteryGift.ensure(sess).relayTickets = ids
return ids
end
function EventIslands.refresh(sess, opts)
local MysteryGift = require("src.core.game3.mystery_gift")
opts = opts or {}
if not EventIslands.job then
local now = os.time()
if EventIslands.fetchedAt and not opts.force and now - EventIslands.fetchedAt < EventIslands.REFRESH_SECONDS then
return "cached"
end
EventIslands.fetchedAt = now
EventIslands.job = MysteryGift.fetchOnline({ family = "rse", session = sess, transport = opts.transport,
client = opts.client })
end
local status, result = MysteryGift.pollOnline(EventIslands.job)
if status == "pending" then return "pending" end
EventIslands.job = nil
if status == "ok" then EventIslands.applyFeed(sess, result) end
return status, result
end
function EventIslands.eonPending(sess)
return not hasItem(sess, "ITEM_EON_TICKET") and not Rse.flag("FLAG_ENABLE_SHIP_SOUTHERN_ISLAND", sess)
return EventIslands.relayTickets(sess).eon == true
and not hasItem(sess, "ITEM_EON_TICKET") and not Rse.flag("FLAG_ENABLE_SHIP_SOUTHERN_ISLAND", sess)
end
-- pokeemerald/src/mystery_gift.c:156
function EventIslands.pendingGift(sess)
sess = sess or Rse.session()
if not EventIslands.enabled(sess) then return nil end
if not (sess and EventIslands.enabled(sess)) then return nil end
local relay = EventIslands.relayTickets(sess)
for _, g in ipairs(EventIslands.manifest().gifts) do
local flag = EventIslands.GIFT_FLAGS[g.id]
if flag and not Rse.flag(flag, sess) then return g end
if flag and relay[g.id] and not Rse.flag(flag, sess) then return g end
end
return nil
end
-- pokeemerald/src/field_specials.c:3630
function EventIslands.sync(sess)
function EventIslands.sync(sess, opts)
sess = sess or Rse.session()
if not (sess and Rse.isRse(sess) and Rse.store()) then return false end
if not EventIslands.enabled(sess) then return false end
if not (opts and opts.offline) then pcall(EventIslands.refresh, sess, opts) end
local pending = EventIslands.eonPending(sess) or EventIslands.pendingGift(sess) ~= nil
Rse.setVar("VAR_DISTRIBUTE_EON_TICKET", pending and 1 or 0, sess)
if pending then Rse.setVar("VAR_DISTRIBUTE_EON_TICKET", 1, sess) end
return pending
end
@@ -106,7 +158,7 @@ function EventIslands.optionRow()
step = function(c)
local o = require("src.core.game3.options").block(c.options)
o[EventIslands.OPTION_KEY] = (tonumber(o[EventIslands.OPTION_KEY]) or 0) ~= 0 and 0 or 1
pcall(EventIslands.sync, c.session)
pcall(EventIslands.sync, c.session, { force = true })
return true
end,
}
+7 -4
View File
@@ -258,8 +258,7 @@ function NativesContest.contestLinkTransfer(vm)
if not job then
if live.isReady and not live:isReady() then return false end
-- pokeemerald/src/contest_link.c:74
local flags = CL.FLAG.IS_LINK + (CL.wireless and CL.FLAG.IS_WIRELESS or 0)
local s = CL.newSession(live, { flags = flags })
local s = CL.newSession(live, { flags = CL.flagsFor(live, CL.wireless) })
local contestant = Util().contestantFromMon(mon, sess)
if contestant.nickname == nil or contestant.nickname == "" then
local Pokemon = require("src.core.game3.pokemon")
@@ -317,7 +316,8 @@ end
-- pokeemerald/src/contest_util.c:2718
function NativesContest.linkContestWaitForConnection(ctx, adapters)
local s = linkSession()
if not (s and isWireless()) then return false, 0 end
-- pokeemerald/src/contest_link.c:100
if not (s and isWireless()) or s:hasRsPlayer() then return false, 0 end
local Natives = Natives()
if not Natives.yieldHost(ctx, adapters, function() end) then return false, 1 end
ctx.nativePoll = function()
@@ -517,7 +517,10 @@ NativesContest.BY_NAME = {
-- pokeemerald/src/contest_util.c:2754
LinkContestTryHideWirelessIndicator = function() return false end,
-- pokeemerald/src/contest_util.c:2763
IsContestWithRSPlayer = function() return false, 0 end,
IsContestWithRSPlayer = function()
local s = linkSession()
return false, s and s:hasRsPlayer() and 1 or 0
end,
-- pokeemerald/src/contest_util.c:2771
ClearLinkContestFlags = function()
NativesContest.linkFlags = 0
+4
View File
@@ -92,6 +92,10 @@ end
local function helloFor(game, linkType, player)
local hello = Handshake.hello(handshakeView(game), nil)
local okA, ArenaData = pcall(require, "src.online.ArenaData")
if okA and type(ArenaData) == "table" and ArenaData.stripCosmeticMods then
ArenaData.stripCosmeticMods(hello, game)
end
hello.generation = Game3Link.GENERATION
hello.type = Game3Link.HELLO
hello.ruleset = Handshake.DEFAULT_RULESET
+81 -3
View File
@@ -326,17 +326,95 @@ local function liveVersion(game)
return GameVersion.get()
end
ArenaData.COSMETIC_REGISTRIES = {
text = true, rom_text = true, strings = true, font = true,
sprites = true, palettes = true, icons = true, battle_anims = true,
render_pipelines = true, battle_sprite_scales = true, transitions = true,
music = true, sfx = true, cries = true, audio = true, map_songs = true,
}
ArenaData.NAME_FIELDS = {
pokemon = { id = true, name = true, description = true, dexEntry = true, category = true },
moves = { id = true, name = true, description = true },
items = { id = true, name = true, description = true },
}
local function cosmeticOp(registry, entry)
if ArenaData.COSMETIC_REGISTRIES[registry] then return true end
local fields = ArenaData.NAME_FIELDS[registry]
if not fields or entry.op ~= "patch" or type(entry.value) ~= "table" then return false end
for key in pairs(entry.value) do
if not fields[key] then return false end
end
return true
end
local function owns(list, id)
for _, entry in ipairs(list or {}) do
if entry.owner == id then return true end
end
return false
end
function ArenaData.modTouchesGameplay(loader, id)
if type(loader) ~= "table" then return false end
local record = loader.mods and loader.mods[id]
local manifest = record and record.manifest
if manifest and #(manifest.permissions or {}) > 0 then return true end
for name, registry in pairs(loader.content or {}) do
for _, list in pairs(registry.ops or {}) do
for _, entry in ipairs(list) do
if entry.owner == id and not cosmeticOp(name, entry) then return true end
end
end
end
for _, chain in pairs((loader.hooks and loader.hooks.chains) or {}) do
if owns(chain, id) then return true end
end
for _, list in pairs((loader.events and loader.events.listeners) or {}) do
if owns(list, id) then return true end
end
local migrations = loader.migrations and loader.migrations[id]
if type(migrations) == "table" and next(migrations) ~= nil then return true end
local input = loader.modInput and loader.modInput[id]
if type(input) == "table" and (tonumber(input.seq) or 0) > 0 then return true end
if loader.stepsQueues and loader.stepsQueues[id] ~= nil then return true end
return false
end
function ArenaData.onlineBlockers3(game)
local out = {}
local loader = type(game) == "table" and game.mods or nil
if not loader then return out end
for _, entry in ipairs(Handshake.mods(game)) do
if entry.affectsLink or ArenaData.modTouchesGameplay(loader, entry.id) then out[#out + 1] = entry end
end
return out
end
local NO_MODS = {}
function ArenaData.stripCosmeticMods(hello, game)
if type(hello) ~= "table" or type(game) ~= "table" or not game.mods then return hello end
local blockers = ArenaData.onlineBlockers3(game)
if #blockers == #(hello.mods or NO_MODS) then return hello end
hello.mods = blockers
if #blockers == 0 then hello.linkModified = false end
local ok, fingerprint = pcall(Fingerprint.compute, game.data, blockers, 3)
if ok then hello.fingerprint = fingerprint end
return hello
end
function ArenaData.liveProfile3(game, rulesetId)
if type(game) ~= "table" then return nil, "no game" end
if Handshake.linkModified(game) then return nil, "mods" end
if #ArenaData.onlineBlockers3(game) > 0 then return nil, "mods" end
local version = liveVersion(game)
if not GameVersion.VERSIONS[version] or GameVersion.generation(version) ~= 3 then
return nil, "unknown game"
end
rulesetId = rulesetId or G3_DEFAULT_RULESET
if not isG3Ruleset(rulesetId) then return nil, "unknown ruleset" end
local ok, fingerprint = pcall(Fingerprint.compute, game.data,
Handshake.mods(game), 3)
local ok, fingerprint = pcall(Fingerprint.compute, game.data, NO_MODS, 3)
if not ok then return nil, tostring(fingerprint) end
return copyProfile({
engine = 3,
+336
View File
@@ -0,0 +1,336 @@
local LinkTags = {}
LinkTags.PLATE_H = 11
LinkTags.PAD = 2
LinkTags.ICON_GAP = 1
LinkTags.MAX_TEXT_W = 44
LinkTags.TEXT_DY = -2
LinkTags.HEAD_OVERLAP = 2
LinkTags.EDGE = 1
LinkTags.BLENDER_EM_BASE = 0x7F00
LinkTags.BLENDER_RS_BASE = 0xF0
LinkTags.MAX_SEATS = 5
LinkTags.PLATE = { 0.06, 0.07, 0.12, 0.62 }
LinkTags.PLATE_SELF = { 0.08, 0.2, 0.46, 0.7 }
LinkTags.COLORS = {
["#"] = { 0.12, 0.12, 0.16, 1 },
w = { 0.97, 0.97, 0.97, 1 },
b = { 0.3, 0.6, 1, 1 },
o = { 1, 0.58, 0.18, 1 },
r = { 0.92, 0.22, 0.2, 1 },
y = { 1, 0.86, 0.3, 1 },
}
LinkTags.ICONS = {
chat = {
".#######.",
"#wwwwwww#",
"#w#w#w#w#",
"#wwwwwww#",
".#ww####.",
".#w#.....",
".##......",
},
trade = {
"......#...",
"#######b#.",
"#bbbbbbbb#",
"#######b#.",
"...#..#...",
".#o#######",
"#oooooooo#",
".#o#######",
"...#......",
},
battle = {
"##.....##",
"#w#...#w#",
".#w#.#w#.",
"..#w#w#..",
"...#w#...",
"..##.##..",
".rr#.#rr.",
"#r#...#r#",
"##.....##",
},
busy = {
"#######",
"#yyyyy#",
".#yyy#.",
"..#y#..",
".#y.y#.",
"#yyyyy#",
"#######",
},
}
LinkTags._images = nil
LinkTags._widths = {}
LinkTags._widthCount = 0
LinkTags._src = { byVobj = {}, player = nil }
LinkTags._rows = {}
LinkTags._self = {}
function LinkTags.iconSize(kind)
local rows = LinkTags.ICONS[kind]
if not rows then return 0, 0 end
return #rows[1], #rows
end
local function union()
return package.loaded["src.core.game3.link.union_room"]
end
local function linkPlayers()
return package.loaded["src.core.game3.link.link_players"]
end
local function link()
return package.loaded["src.core.game3.link"]
end
local function tagRow(i)
local row = LinkTags._rows[i]
if not row then
row = {}
LinkTags._rows[i] = row
end
return row
end
local function localKind()
local LT = package.loaded["src.core.game3.link.trade"]
if LT and LT.isActive and LT.isActive() then return "trade" end
local LB = package.loaded["src.core.game3.link.battle"]
if LB and LB.isActive and LB.isActive() then return "battle" end
local Chat = package.loaded["src.core.game3.link.chat"]
if Chat and Chat.isActive and Chat.isActive() then return "chat" end
local U = union()
if U and U.isActive and U.isActive() and U.state == "in_activity" then
return U.memberStatusKind({ activity = U.activity })
end
return nil
end
local function blenderPartners(src, n)
local L = link()
local lk = L and L.link
if not (lk and lk.isOpen and lk:isOpen() and lk.players) then return n, false end
local V = package.loaded["src.core.game3.virtual_objects"]
if not V then return n, false end
local ok, players = pcall(lk.players, lk)
if not ok or type(players) ~= "table" then return n, false end
local any = false
for i, player in ipairs(players) do
local seat = tonumber(player.seat) or (i - 1)
if seat >= 0 and seat < LinkTags.MAX_SEATS and type(player.name) == "string" then
for _, id in ipairs({ LinkTags.BLENDER_EM_BASE + seat, LinkTags.BLENDER_RS_BASE - seat }) do
if V.get(id) then
n = n + 1
local row = tagRow(n)
row.name, row.kind = player.name, nil
src.byVobj[id] = row
any = true
end
end
end
end
return n, any
end
function LinkTags.sources()
local src = LinkTags._src
for k in pairs(src.byVobj) do src.byVobj[k] = nil end
src.player = nil
local active, n = false, 0
local U = union()
if U and U.isActive and U.isActive() and U.onUnionRoomMap and U.onUnionRoomMap() then
active = true
for slot = 1, U.capacity() do
local p = U.players[slot]
if p and not p.gone and type(p.name) == "string" then
n = n + 1
local row = tagRow(n)
row.name, row.kind = p.name, U.memberStatusKind(p)
src.byVobj[U.vobjId(slot)] = row
end
end
end
local LP = linkPlayers()
if LP and LP._mapId then
active = true
for seat in pairs(LP.remotes()) do
local name = LP.nameOf(seat)
if name then
n = n + 1
local row = tagRow(n)
row.name, row.kind = name, LP.statusKind(seat)
src.byVobj[LP.VOBJ_BASE + seat] = row
end
end
end
local blender
n, blender = blenderPartners(src, n)
active = active or blender
if not active then return nil end
local L = link()
local s = L and L.session and L.session()
local me = LinkTags._self
me.name = type(s) == "table" and tostring(s.name or s.playerName or ""):sub(1, 7) or nil
me.kind = localKind()
if me.name and me.name ~= "" then src.player = me end
return src
end
function LinkTags.tagFor(src, a)
if not (src and a) then return nil end
if a.kind == "player" then return src.player end
local eo = a.eventObject
local id = eo and eo.virtualId
return id and src.byVobj[id] or nil
end
function LinkTags.textWidth(name, measure)
local w = LinkTags._widths[name]
if w then return w end
if measure then
w = measure(name)
else
local FrlgFont = require("src.ui.game3.frlg_font")
local ok, got = pcall(FrlgFont.measure, name, { small = true })
w = ok and tonumber(got) or (#name * 6)
end
w = math.min(LinkTags.MAX_TEXT_W, math.max(0, w))
if LinkTags._widthCount > 256 then
LinkTags._widths, LinkTags._widthCount = {}, 0
end
LinkTags._widths[name] = w
LinkTags._widthCount = LinkTags._widthCount + 1
return w
end
function LinkTags.layout(tag, headX, headY, viewW, measure, out)
local textW = LinkTags.textWidth(tag.name, measure)
local iconW = tag.kind and LinkTags.iconSize(tag.kind) or 0
local lead = iconW > 0 and (iconW + LinkTags.ICON_GAP) or 0
local w = LinkTags.PAD * 2 + textW + lead
local x = math.floor(headX - w / 2 + 0.5)
if viewW then
x = math.max(LinkTags.EDGE, math.min(viewW - w - LinkTags.EDGE, x))
end
out = out or {}
out.x, out.y, out.w, out.h = x, math.floor(headY - LinkTags.PLATE_H + LinkTags.HEAD_OVERLAP), w, LinkTags.PLATE_H
out.textW, out.iconW, out.textX = textW, iconW, x + LinkTags.PAD + lead
return out
end
local LAYOUT = {}
local TEXT_OPTS = { small = true, colors = nil, maxWidth = 0 }
local function iconImages()
if LinkTags._images then return LinkTags._images end
local out = {}
for kind, rows in pairs(LinkTags.ICONS) do
local w, h = #rows[1], #rows
local data = love.image.newImageData(w, h)
for y = 1, h do
for x = 1, w do
local c = LinkTags.COLORS[rows[y]:sub(x, x)]
if c then data:setPixel(x - 1, y - 1, c[1], c[2], c[3], c[4]) end
end
end
local img = love.graphics.newImage(data)
img:setFilter("nearest", "nearest")
out[kind] = img
end
LinkTags._images = out
return out
end
local function plate(x, y, w, h, color)
love.graphics.setColor(color)
love.graphics.rectangle("fill", x + 1, y, w - 2, h)
love.graphics.rectangle("fill", x, y + 1, 1, h - 2)
love.graphics.rectangle("fill", x + w - 1, y + 1, 1, h - 2)
end
local opaqueTops = setmetatable({}, { __mode = "k" })
local function opaqueTop(spr)
local top = opaqueTops[spr]
if top then return top end
top = 0
local data = spr.imageData
local w, h = tonumber(spr.width), tonumber(spr.height)
if data and data.getPixel and w and h then
pcall(function()
for y = 0, h - 1 do
for x = 0, w - 1 do
local _, _, _, alpha = data:getPixel(x, y)
if alpha > 0 then
top = y
return
end
end
end
end)
end
opaqueTops[spr] = top
return top
end
local function headTop(a, camY)
local h, offY, top = 32, 0, 0
local Ow = package.loaded["src.core.game3.ow_sprites"]
if Ow and Ow.getDraw and a.graphicsId ~= nil then
local ok, spr = pcall(Ow.getDraw, a.graphicsId)
if ok and type(spr) == "table" then
h = tonumber(spr.height) or h
offY = tonumber(spr.drawOffY) or 0
top = opaqueTop(spr)
end
end
return a.y - camY + 16 - h + offY + top
end
local function drawTag(a, tag, camX, camY, viewW, isSelf)
local L = LinkTags.layout(tag, a.x - camX + 8, headTop(a, camY), viewW, nil, LAYOUT)
plate(L.x, L.y, L.w, L.h, isSelf and LinkTags.PLATE_SELF or LinkTags.PLATE)
if tag.kind then
local img = iconImages()[tag.kind]
if img then
local _, ih = LinkTags.iconSize(tag.kind)
love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(img, L.x + LinkTags.PAD, L.y + math.floor((L.h - ih) / 2))
end
end
local FrlgFont = require("src.ui.game3.frlg_font")
TEXT_OPTS.colors, TEXT_OPTS.maxWidth = FrlgFont.COLOR.WHITE, L.textW
pcall(FrlgFont.draw, tag.name, L.textX, L.y + LinkTags.TEXT_DY, TEXT_OPTS)
end
local function drawList(src, list, camX, camY, viewW, billboard)
if not list then return end
for _, a in ipairs(list) do
local tag = LinkTags.tagFor(src, a)
if tag and tag.name and tag.name ~= "" then
local pushed = billboard and billboard(a.x, a.y, camX, camY)
drawTag(a, tag, camX, camY, viewW, a.kind == "player")
if pushed then love.graphics.pop() end
end
end
end
function LinkTags.draw(under, over, camX, camY, viewW, _viewH, billboard)
if not (love and love.graphics) then return false end
local src = LinkTags.sources()
if not src then return false end
drawList(src, under, camX, camY, viewW, billboard)
drawList(src, over, camX, camY, viewW, billboard)
love.graphics.setColor(1, 1, 1, 1)
return true
end
return LinkTags
+159 -10
View File
@@ -29,6 +29,9 @@ Ui.STATE = {
SAVE_DONE = "save_done",
GIFT_INPUT = "gift_input",
NEWS_VIEW = "news_view",
SAVED_VIEW = "saved_view",
GIFT_MENU = "gift_menu",
ASK_TOSS = "ask_toss",
EXIT = "exit",
}
@@ -40,6 +43,13 @@ local THREE_WIN = Window.template(8, 5, 14, 5)
local OFFER_WIN = Window.template(1, 3, 17, 10)
-- pokefirered/src/mystery_gift_menu.c:157 sWindowTemplate_YesNoBox
local YESNO_WIN = Window.template(23, 15, 6, 4)
-- pokefirered/src/mystery_gift_menu.c:137 sWindowTemplate_GiftSelect
local GIFT_SELECT_WIN = Window.template(1, 15, 19, 4)
-- pokefirered/src/mystery_gift_menu.c:177 sWindowTemplate_GiftSelect_2Options
local GIFT_SELECT_OPTIONS = {
[3] = Window.template(22, 14, 7, 5),
[2] = Window.template(22, 15, 7, 4),
}
-- pokefirered/src/mystery_gift_show_card.c:67 sWindowTemplates
local CARD_HEADER = Window.template(1, 1, 25, 4)
local CARD_BODY = Window.template(1, 6, 28, 8)
@@ -264,7 +274,8 @@ function Ui.news(st)
end
-- pokefirered/src/mystery_gift_menu.c:855 SaveOnMysteryGiftMenu
local function beginSave(st)
local function beginSave(st, afterSave)
st.afterSave = afterSave
st.state = Ui.STATE.SAVE
-- pokefirered/src/strings.c:1321 gText_DataWillBeSaved
say(st, RomText.plain("gText_DataWillBeSaved"), nil, true)
@@ -302,11 +313,12 @@ local function toMainMenuOffline(st)
end
-- pokefirered/src/mystery_gift_menu.c:1352
local function clientResult(st, textKey, success)
local function clientResult(st, textKey, success, onShown)
st.state = Ui.STATE.RESULT_MSG
if success then
fanfare(Song.MUS_OBTAIN_ITEM)
say(st, RomText.plain(textKey), function(s)
if onShown then onShown(s) end
beginSave(s)
end, true, Ui.SUCCESS_FRAMES)
else
@@ -315,10 +327,29 @@ local function clientResult(st, textKey, success)
end
-- pokefirered/src/mystery_gift_menu.c:1344
local function commCompleted(st, textKey, success)
local function commCompleted(st, textKey, success, onShown)
iconOff()
closeOffers(st)
clientResult(st, textKey, success)
clientResult(st, textKey, success, onShown)
end
-- pokefirered/src/mystery_gift_client.c:210 CLI_SAVE_NEWS
local function receiveNewsFrom(st, entry)
local sess = session(st)
st.viewNews = nil
local ok, why = MysteryGift.saveNewsIfNew(sess, entry.news)
if not ok then
-- pokefirered/src/mystery_gift_menu.c:921
commCompleted(st, why == "had" and "gText_AlreadyHadNews" or "gText_CommunicationError", false)
return false
end
MysteryGift.claimNews(sess, entry.news)
-- pokefirered/src/mystery_gift_menu.c:905
commCompleted(st, "gText_WonderNewsReceived", true, function(s)
-- pokefirered/src/mystery_gift_menu.c:1369
MysteryGift.setNewsReward(session(s), MysteryGift.WONDER_NEWS_RECV_WIRELESS)
end)
return true
end
-- pokefirered/src/mystery_gift_client.c:208 CLI_SAVE_CARD
@@ -368,7 +399,9 @@ function Ui.offerRows(st)
local ListMenu = require("src.ui.game3.list_menu")
local items = {}
for i, entry in ipairs(st.offers or {}) do
local claimed = not st.isNews and MysteryGift.hasClaimedCard(sess, entry.card)
local claimed
if st.isNews then claimed = MysteryGift.hasClaimedNews(sess, entry.news)
else claimed = MysteryGift.hasClaimedCard(sess, entry.card) end
items[i] = {
label = entry.label,
id = i,
@@ -450,6 +483,76 @@ local function pickOffer(st, entry)
deliverOffer(st, entry)
end
-- pokefirered/src/mystery_gift_menu.c:765 ValidateCardOrNews
local function hasSaved(st)
if st.isNews then return MysteryGift.validateSavedNews(session(st)) end
return MysteryGift.validateSavedCard(session(st))
end
-- pokefirered/src/mystery_gift_menu.c:1390 MG_STATE_LOAD_GIFT
local function openSaved(st)
st.viewCard, st.viewNews, st.viewOffer = nil, nil, nil
st.newsScroll = 0
st.prompt = nil
st.state = Ui.STATE.SAVED_VIEW
end
-- pokefirered/src/mystery_gift_menu.c:1147 MG_STATE_DONT_HAVE_ANY
local function openBranch(st)
if hasSaved(st) then return openSaved(st) end
st.state = Ui.STATE.RESULT_MSG
say(st, RomText.plain(st.isNews and "gText_DontHaveNewsNewOneInput" or "gText_DontHaveCardNewOneInput"),
startSearch)
end
Ui.openBranch = openBranch
-- pokefirered/src/mystery_gift_menu.c:1417 MG_STATE_HANDLE_GIFT_SELECT
function Ui.giftMenuRows(st)
local list, actions
if st.isNews then
list, actions = "sListMenuItems_Receive", { "receive", "cancel" }
else
list, actions = "sListMenuItems_ReceiveToss", { "receive", "toss", "cancel" }
end
st.giftActions = actions
return RomText.list(list)
end
local function openGiftMenu(st)
st.state = Ui.STATE.GIFT_MENU
-- pokefirered/src/mystery_gift_menu.c:716
st.prompt = RomText.plain(st.isNews and "gText_WhatToDoWithNews" or "gText_WhatToDoWithCards")
setRows(st, Ui.giftMenuRows(st), 1)
end
-- pokefirered/src/mystery_gift_menu.c:1484 MG_STATE_TOSS
local function tossSaved(st)
st.prompt = nil
if st.isNews then MysteryGift.clearNewsAndRelated(session(st))
else MysteryGift.clearCardAndRelated(session(st)) end
beginSave(st, function(s)
s.state = Ui.STATE.RESULT_MSG
-- pokefirered/src/mystery_gift_menu.c:847 PrintThrownAway
say(s, RomText.plain(s.isNews and "gText_WonderNewsThrownAway" or "gText_WonderCardThrownAway"), toMainMenu)
end)
end
-- pokefirered/src/mystery_gift_menu.c:1454 MG_STATE_ASK_TOSS
local function askToss(st)
st.state = Ui.STATE.ASK_TOSS
st.prompt = nil
-- pokefirered/src/mystery_gift_menu.c:839 AskDiscardGift
ask(st, RomText.plain(st.isNews and "gText_OkayToDiscardNews" or "gText_IfThrowAwayCardEventWontHappen"),
function(s)
if not s.isNews and MysteryGift.isGiftNotReceived(session(s)) then
-- pokefirered/src/mystery_gift_menu.c:1470 MG_STATE_ASK_TOSS_UNRECEIVED
ask(s, RomText.plain("gText_HaventReceivedGiftOkayToDiscard"), tossSaved, openGiftMenu)
return
end
tossSaved(s)
end, openGiftMenu)
end
function Ui.close(st)
if type(st) == "table" and st.job then
MysteryGift.cancelOnline(st.job)
@@ -562,10 +665,10 @@ function Ui.update(st, pressed, dt)
local action = pick == -1 and "exit" or (st.mainActions or {})[pick]
if action == "news" then
st.isNews = true
startSearch(st)
openBranch(st)
elseif action == "cards" then
st.isNews = false
startSearch(st)
openBranch(st)
elseif action == "exit" then
st.state = S.EXIT
Ui.close(st)
@@ -615,6 +718,7 @@ function Ui.update(st, pressed, dt)
local item = list:selected()
if item and item.entry and st.isNews then
st.viewNews = item.entry.news
st.viewNewsEntry = item.entry
st.newsScroll = 0
st.prompt = nil
st.state = S.NEWS_VIEW
@@ -634,22 +738,59 @@ function Ui.update(st, pressed, dt)
if st.state == S.NEWS_VIEW then
tickNewsScroll(st, pressed)
if pressed("a") or pressed("b") then
if pressed("a") and st.viewNewsEntry then
se(Song.SE_SELECT)
local entry = st.viewNewsEntry
st.viewNewsEntry = nil
receiveNewsFrom(st, entry)
elseif pressed("a") or pressed("b") then
se(Song.SE_SELECT)
st.viewNews = nil
st.viewNewsEntry = nil
st.state = S.OFFER_LIST
st.prompt = Ui.listPrompt(st)
end
return nil
end
-- pokefirered/src/mystery_gift_menu.c:1394 MG_STATE_HANDLE_GIFT_INPUT
if st.state == S.SAVED_VIEW then
if st.isNews then tickNewsScroll(st, pressed) end
if pressed("a") then
se(Song.SE_SELECT)
openGiftMenu(st)
elseif pressed("b") then
se(Song.SE_SELECT)
toMainMenu(st)
end
return nil
end
if st.state == S.GIFT_MENU then
local pick = tickList(st, pressed)
if pick == nil then return nil end
local action = pick == -1 and "cancel" or (st.giftActions or {})[pick]
if action == "receive" then
-- pokefirered/src/mystery_gift_menu.c:1506 MG_STATE_RECEIVE
st.prompt = nil
startSearch(st)
elseif action == "toss" then
askToss(st)
else
openSaved(st)
end
return nil
end
if st.state == S.SAVE then
local ok = true
if st.onSave then ok = st.onSave(session(st)) ~= false end
local after = st.afterSave
st.afterSave = nil
st.state = S.SAVE_DONE
-- pokefirered/src/strings.c:1322 gText_SaveCompletedPressA
say(st, ok and RomText.plain("gText_SaveCompletedPressA")
or Strings("Save failed."), toMainMenu)
or Strings("Save failed."), ok and after or toMainMenu)
return nil
end
@@ -824,9 +965,11 @@ function Ui.drawNews(st)
end
end
local CARD_STATES = { gift_input = true, news_view = true, saved_view = true, gift_menu = true, ask_toss = true }
function Ui.draw(st)
local S = Ui.STATE
if st.state == S.GIFT_INPUT or st.state == S.NEWS_VIEW then
if CARD_STATES[st.state] then
if st.isNews then Ui.drawNews(st) else Ui.drawCard(st) end
else
drawMenuBg()
@@ -843,6 +986,12 @@ function Ui.draw(st)
drawYesNo(st)
elseif st.msg then
drawMessage(st.msg.text, math.min(st.msg.revealed, st.msg.total))
elseif st.state == S.GIFT_MENU then
-- pokefirered/src/mystery_gift_menu.c:719
Window.fixedStdFrame(GIFT_SELECT_WIN)
Window.printPx(st.prompt or "", GIFT_SELECT_WIN.left * T, GIFT_SELECT_WIN.top * T + 2,
{ colors = TEXT, maxWidth = GIFT_SELECT_WIN.width * T })
drawList(st, GIFT_SELECT_OPTIONS[#(st.rows or {})] or GIFT_SELECT_OPTIONS[3])
elseif st.prompt then
drawMessage(st.prompt, nil)
end
+2 -3
View File
@@ -359,9 +359,8 @@ function Rows.build(ctx, skip)
return true
end,
})
if require("src.core.game3.profile").family(ctx and ctx.session) == "rse" then
add(require("src.core.game3.rse.event_islands").optionRow())
end
local EventIslands = require("src.core.game3.rse.event_islands")
if EventIslands.available() then add(EventIslands.optionRow()) end
add({
id = "touchControls", label = Strings("TOUCH PAD"),
+7 -2
View File
@@ -4,6 +4,8 @@ local Font = require("src.ui.game3.frlg_font")
local Text = require("src.core.game3.rom_text")
local Strings = require("src.core.Strings")
local M = {ID = "rs_cable_lobby", open = false}
-- pokeemerald/src/strings.c:1242
M.HEADERS = {"gOtherText_LinkStandby", "gText_CommunicationStandby"}
function M.isOpen() return M.open end
function M.show(opts)
M.opts, M.cursor, M.open = opts, 1, true
@@ -38,8 +40,11 @@ end
function M.draw()
if not M.open then return end
Window.stdFrame(Window.template(1, 1, 28, 18))
local rows, opts = M.rows(), {font = "native_3"}
Font.draw(Text.plain("gOtherText_LinkStandby"), 16, 16, opts)
local Family = require("src.core.game3.link.family")
local rows, opts = M.rows(), Family.isRubySapphire(Family.activeVersion()) and {font = "native_3"} or {}
for _, key in ipairs(M.HEADERS) do
if Text.has(key) then Font.draw(Text.plain(key), 16, 16, opts); break end
end
local title = M.opts.title and M.opts.title() or ""
Font.draw(title, 16, 40, opts)
for i, row in ipairs(rows) do
+122 -83
View File
@@ -1,27 +1,18 @@
local Kit = require("src.ui.game3.rse.scene_kit")
local Pal = require("src.core.game3.pal_fade")
local Chrome = require("src.ui.game3.chrome")
local Link = require("src.core.game3.link.init")
local Event = require("src.core.game3.rs.mystery_event")
local M = {MESSAGE = "rs_mystery_event", REQUEST = "rs_mystery_event_request"}
local MysteryGift = require("src.core.game3.mystery_gift")
local Strings = require("src.core.Strings")
local M = {VISIBLE = 4, NEWS_LINES = 8}
M.__index = M
function M.send(link, bytes)
if type(bytes) == "string" then bytes = {bytes:byte(1, #bytes)} end
if type(bytes) ~= "table" or #bytes == 0 or #bytes > Event.MAX_BYTES then return false end
local copy = {}
for i, b in ipairs(bytes) do
if type(b) ~= "number" or b < 0 or b > 255 or b % 1 ~= 0 then return false end
copy[i] = b
end
return link:send({type = M.MESSAGE, bytes = copy})
end
function M.new(opts)
opts = opts or {}
local saved = opts.save or Kit.loadRawSave()
local saved = opts.save or (not opts.session and Kit.loadRawSave())
local session = opts.session or (saved and require("src.core.game3.save_schema_firered").fromSaveTable(saved))
assert(session, "RS Mystery Events require an existing save")
local self = setmetatable({game = opts.game, session = session, opts = opts, state = "fade_in",
pal = Pal.new(), ticks = 0, step = Kit.stepper()}, M)
pal = Pal.new(), ticks = 0, step = Kit.stepper(), cursor = 1, scroll = 0}, M)
self.pal:beginFade(Pal.ALL, 0, 16, 0, Pal.BLACK)
return self
end
@@ -29,8 +20,7 @@ function M:message(source, vars)
self.printer = Kit.printer(source, {speed = 2, canSpeedUp = false, ctx = {stringVars = vars or {}}})
end
function M:close()
require("src.ui.game3.rs.cable_lobby").close()
Link.clientCall("leaveGroup"); Link.closeLink("rs_mystery_event_exit")
if self.job then MysteryGift.cancelOnline(self.job); self.job = nil end
end
function M:exit()
self:close(); self.state = "exit"
@@ -40,83 +30,108 @@ function M:failed(why)
self.error = why; self.loading = false
self:close(); self:message("gSystemText_LoadingError"); self.state = "result_print"
end
function M:startEntry()
local profile = self.opts.profile
if not profile then profile = require("src.online.ArenaData").liveProfile3(self.game, "g3_link") end
function M:startFetch()
local s = self.session
local avatar = {name = s.name or s.playerName or "", gender = s.gender or 0, version = s.version,
trainerId = require("src.core.game3.link.rs").trainerId(s)}
local spec = {wire = "mystery_event", linkType = 0x5501, min = 2, max = 2, profile = profile,
avatar = avatar, game = self.game,
hello = require("src.link.Game3Link").hello(self.game, 0x5501, {session = s, version = s.version,
name = avatar.name, gender = avatar.gender, trainerId = avatar.trainerId}),
connect = function()
return require("src.online.Connect").start({source = "game", version = s.version,
trainerName = avatar.name, profiles = {assert(profile, "Mystery Event live profile missing")},
presence = {where = "game", status = "busy", version = s.version}})
end}
self.ctx = require("src.core.game3.scripting.ctx").new()
local function ready()
Kit.playSe("SE_PIN")
self.state = "ready_print"; self:message("gSystemText_LoadEventPressA")
return false, 1
self.job = MysteryGift.fetchOnline({family = "rs", version = s.version, session = s,
transport = self.opts.transport, client = self.opts.client})
self.state = "fetching"
end
function M:selected()
return self.rows and self.rows[self.cursor]
end
local function plainIr(text)
return require("src.core.game3.scripting.text_ir").fromAscii(text)
end
function M:writeSave()
local save = require("src.core.game3.save_schema_firered").toSaveTable(self.session)
local writer = self.opts.writeSave or require("src.core.SaveData").save
local ok, written = pcall(writer, save)
if not ok or written == false then return false end
self.saved = save; if self.game then self.game.save = save end
return true
end
function M:buildRows(result)
local rows = {}
if MysteryGift.validateSavedNews(self.session) then
rows[#rows + 1] = {kind = "news", saved = true, label = MysteryGift.getSavedNews(self.session).titleText,
news = MysteryGift.getSavedNews(self.session)}
end
local yielded, result = require("src.core.game3.rs.link_entry").run(self.ctx, {}, spec, ready)
if self.state == "ready_print" then return end
if yielded then self.state = "lobby" else self:failed(result) end
for _, ev in ipairs(result.events or {}) do rows[#rows + 1] = {kind = "event", label = ev.label, bytes = ev.bytes} end
for _, n in ipairs(result.news or {}) do
if not MysteryGift.hasClaimedNews(self.session, n.news) then
rows[#rows + 1] = {kind = "news", label = n.label, news = n.news}
end
end
return rows
end
function M:receiveNews(row)
local ok, why = MysteryGift.saveNewsIfNew(self.session, row.news)
if not ok then
self:message(plainIr(why == "had" and Strings("You already have this WONDER NEWS.") or Strings("This WONDER NEWS can't be read.")))
self.state = "result_print"
return
end
MysteryGift.claimNews(self.session, row.news)
if not self:writeSave() then return self:failed("save_failed") end
self:message(plainIr(Strings("The WONDER NEWS was saved.")))
self.state = "result_print"
end
function M:frame(inp)
self.ticks = self.ticks + 1
local lk = Link.link
if lk then lk:update(1 / 60) end
if self.opts.payload and lk and lk:isReady() and lk:take(M.REQUEST) then M.send(lk, self.opts.payload) end
if self.state == "fade_in" then
-- pokeruby/src/mystery_event_menu.c:98
if not self.pal:fadeActive() then self:message("gSystemText_LinkStandby"); self.state = "standby_print" end
elseif self.state == "standby_print" then
self.printer:run(inp)
if not self.printer:isActive() then self:startEntry() end
elseif self.state == "lobby" then
require("src.ui.game3.rs.cable_lobby").update(inp)
if self.ctx.nativePoll and self.ctx.nativePoll() and self.state == "lobby" then
local result = self.ctx.specialVars[0x800D]
if result == 5 then self:exit() else self:failed(result) end
if not self.printer:isActive() then self:startFetch() end
elseif self.state == "fetching" then
if inp.new.b then Kit.playSe("SE_SELECT"); self:exit(); return end
local status, result = MysteryGift.pollOnline(self.job)
if status == "pending" then return end
self.job = nil
if status ~= "ok" then return self:failed(result) end
self.rows = self:buildRows(result)
if #self.rows == 0 then return self:failed("no_events") end
self.cursor, self.scroll, self.state = 1, 0, "list"
elseif self.state == "list" then
if inp.new.b then Kit.playSe("SE_SELECT"); self:exit()
elseif inp.new.up and self.cursor > 1 then
Kit.playSe("SE_SELECT"); self.cursor = self.cursor - 1
if self.cursor <= self.scroll then self.scroll = self.cursor - 1 end
elseif inp.new.down and self.cursor < #self.rows then
Kit.playSe("SE_SELECT"); self.cursor = self.cursor + 1
if self.cursor > self.scroll + M.VISIBLE then self.scroll = self.cursor - M.VISIBLE end
elseif inp.new.a and self:selected().kind == "news" then
Kit.playSe("SE_SELECT"); self.newsScroll, self.state = 0, "news_view"
elseif inp.new.a then
-- pokeruby/src/mystery_event_menu.c:112
Kit.playSe("SE_PIN"); self:message("gSystemText_LoadEventPressA"); self.state = "ready_print"
end
elseif self.state == "news_view" then
local row = self:selected()
local last = 0
for i, line in ipairs(row.news.bodyText or {}) do if line ~= "" then last = i end end
if inp.new.up and self.newsScroll > 0 then self.newsScroll = self.newsScroll - 1
elseif inp.new.down and self.newsScroll < math.max(0, last - M.NEWS_LINES) then self.newsScroll = self.newsScroll + 1
elseif inp.new.a and not row.saved then Kit.playSe("SE_SELECT"); self:receiveNews(row)
elseif inp.new.a or inp.new.b then Kit.playSe("SE_SELECT"); self.state = "list" end
elseif self.state == "ready_print" then
self.printer:run(inp); if not self.printer:isActive() then self.state = "ready" end
elseif self.state == "ready" then
if inp.new.b then Kit.playSe("SE_SELECT"); self:exit()
elseif not lk or not lk:isOpen() then self:failed("peer_closed")
if inp.new.b then Kit.playSe("SE_SELECT"); self.printer = nil; self.state = "list"
elseif inp.new.a then
Kit.playSe("SE_SELECT")
local players = lk:players()
if #players ~= 2 or players[1].language ~= players[2].language then self:failed("peer_language_or_count")
else self:message("gSystemText_DontCutLink"); self.state = "receive_print"; self.loading = true end
-- pokeruby/src/mystery_event_menu.c:168
Kit.playSe("SE_SELECT"); self:message("gSystemText_DontCutLink"); self.state = "receive_print"; self.loading = true
end
elseif self.state == "receive_print" then
if not lk or not lk:isOpen() then self:failed("peer_closed")
else self.printer:run(inp) end
if self.state == "receive_print" and not self.printer:isActive() then
lk:send({type = M.REQUEST}); self.state, self.ticks = "receive", 0
end
elseif self.state == "receive" then
if not lk or not lk:isOpen() then self:failed("peer_closed")
else
local block = lk:take(M.MESSAGE)
if block then self.block, self.state = block.bytes, "received"
elseif self.ticks > 1200 then self:failed("receive_timeout") end
end
elseif self.state == "received" then
self:close(); self.state = "execute"
self.printer:run(inp)
if not self.printer:isActive() then self.state = "execute" end
elseif self.state == "execute" then
self.result = Event.run(self.block, self.session, {adapters = self.opts.adapters,
-- pokeruby/src/mystery_event_menu.c:291 RunMysteryEventScript
local picked = self:selected()
self.result = Event.run(picked.bytes, self.session, {adapters = self.opts.adapters,
saveBlock1Address = self.opts.saveBlock1Address})
self.block = nil
if self.result.save then
local save = require("src.core.game3.save_schema_firered").toSaveTable(self.session)
local ok, written = pcall(require("src.core.SaveData").save, save)
if not ok or written == false then return self:failed("save_failed") end
self.saved = save; if self.game then self.game.save = save end
end
if self.result.save and not self:writeSave() then return self:failed("save_failed") end
self.loading = false
self:message(self.result.message, self.result.stringVars); self.state = "result_print"
elseif self.state == "result_print" then
@@ -130,17 +145,41 @@ function M:update(input, dt)
self.step:collect(input)
return self.step:run(dt, function(inp) return self:frame(inp) end)
end
function M:drawList()
local FrlgFont = require("src.ui.game3.frlg_font")
local RomText = require("src.core.game3.rom_text")
local colors = Kit.messageColors()
local rows = math.min(M.VISIBLE, #self.rows)
Chrome.stdFrame(1, 1, 28, rows * 2)
for i = 1, rows do
local ev = self.rows[i + self.scroll]
FrlgFont.draw(ev.label or "", 16, 8 + (i - 1) * 16, {colors = colors})
end
FrlgFont.draw(RomText.plain("gText_SelectorArrow3"), 8, 8 + (self.cursor - self.scroll - 1) * 16, {colors = colors})
end
function M:drawNews()
local FrlgFont = require("src.ui.game3.frlg_font")
local colors = Kit.messageColors()
local news = self:selected().news
Chrome.stdFrame(1, 1, 28, 2)
FrlgFont.draw(news.titleText or "", 8, 8, {colors = colors})
Chrome.stdFrame(1, 5, 28, M.NEWS_LINES * 2)
for i = 1, M.NEWS_LINES do
FrlgFont.draw((news.bodyText or {})[i + self.newsScroll] or "", 8, 40 + (i - 1) * 16, {colors = colors})
end
end
function M:draw()
love.graphics.clear(0, 0, 0, 1)
if self.state == "lobby" then require("src.ui.game3.rs.cable_lobby").draw()
else
if self.state == "news_view" then self:drawNews()
elseif (self.state == "list" or self.state == "ready_print" or self.state == "ready") and self.rows then self:drawList() end
if self.printer and self.state ~= "list" then
Chrome.stdFrame(1, 15, 28, 4)
if self.printer then self.printer:draw(8, 120, {colors = Kit.messageColors()}) end
if self.loading then
Chrome.stdFrame(7, 6, 16, 2)
require("src.ui.game3.frlg_font").draw(require("src.core.game3.rom_text").plain("gSystemText_LoadingEvent"),
56, 48, {colors = Kit.messageColors()})
end
self.printer:draw(8, 120, {colors = Kit.messageColors()})
end
if self.loading then
Chrome.stdFrame(7, 6, 16, 2)
require("src.ui.game3.frlg_font").draw(require("src.core.game3.rom_text").plain("gSystemText_LoadingEvent"),
56, 48, {colors = Kit.messageColors()})
end
Kit.drawFade(self.pal, 0)
end
+7
View File
@@ -70,6 +70,13 @@ return function(game)
local row = EI.optionRow()
row.step({ options = game.options, session = session })
check(EI.enabled(session), "the EVENT TICKETS option row turns the distribution on")
check(EI.pendingGift(session) == nil, "no ticket is handed out until the relay publishes one")
local feedCards = {}
for _, b in ipairs(require("src.core.game3.mystery_gift").builtins("rse")) do
feedCards[#feedCards + 1] = { key = b.key, card = b.card }
end
EI.applyFeed(session, { cards = feedCards })
EI.sync(session, { offline = true })
check(S.var("VAR_DISTRIBUTE_EON_TICKET") == 1, "VAR_DISTRIBUTE_EON_TICKET raised for the Mystery Gift man")
if not check(teleport("EM_LILYCOVE_CITY_POKEMON_CENTER_2F", 1, 6, "up"), "Lilycove Pokemon Center 2F loads") then
+246
View File
@@ -0,0 +1,246 @@
local U = require("tests.drivers.util")
local DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/g3link_presence"
local VERSION = os.getenv("POKEPORT_VERSION") or "firered"
local PER = {
firered = { center = "FR_VIRIDIAN_CITY_POKEMON_CENTER_2F", trade = "FR_TRADE_CENTER" },
leafgreen = { center = "FR_VIRIDIAN_CITY_POKEMON_CENTER_2F", trade = "FR_TRADE_CENTER" },
emerald = { center = "EM_OLDALE_TOWN_POKEMON_CENTER_2F", trade = "EM_TRADE_CENTER" },
}
local CENTER_2F = PER[VERSION].center
local Plaza = require("src.core.game3.link.union_plaza_map")
local failures = 0
local function result(ok, label)
print((ok and "PASS " or "FAIL ") .. label)
if not ok then failures = failures + 1 end
return ok
end
local function finish()
local Link = package.loaded["src.core.game3.link"]
if Link then pcall(Link.reset) end
local Client = package.loaded["src.online.Client"]
if Client then pcall(Client.disconnect) end
if failures == 0 then
print("PASS g3link_room_presence")
love.event.quit(0)
else
print("FAIL g3link_room_presence failures=" .. failures)
love.event.quit(1)
end
end
local function now() return love.timer.getTime() end
return function(game)
for _ = 1, 900 do
if game.phase == "boot" and game.boot then break end
U.wait(1)
end
game:_handleBootAction({ action = "new_game", name = "RED" })
U.wait(240)
local Runtime = require("src.core.game3.runtime")
local Map = require("src.core.game3.map")
local Space = require("src.core.game3.scripting.space")
local Flags = require("src.core.game3.scripting.flags")
local Player = require("src.core.game3.player")
local Message = require("src.ui.game3.message")
local Choice = require("src.ui.game3.choice")
local SaveMenu = require("src.ui.game3.save_menu")
local Party = require("src.core.game3.party")
local Link = require("src.core.game3.link")
local Union = require("src.core.game3.link.union_room")
local Client = require("src.online.Client")
local Relay = require("tests.support.fake_relay")
local VirtualObjects = require("src.core.game3.virtual_objects")
local Family = require("src.core.game3.link.family")
local UNION_ROOM = Plaza.MAP_ID
local session = Runtime.getSession()
if not result(session ~= nil, "new game reached the game3 field") then return finish() end
Party.giveMon(session, 25, 12)
Party.giveMon(session, 1, 10)
local relay = Relay.new({ clock = function() return now() end })
local me = relay:seat("a0000001", "RED")
Client.configure({ relayAddress = "fake:1", connect = function() return me.transport end })
local function ctx() return Space.vm and Space.vm.ctx end
local function place(x, y, facing)
Player.cellX, Player.cellY = x, y
Player.px, Player.py = x * 16, y * 16
Player.targetX, Player.targetY = x, y
Player.facing = facing
game.session.x, game.session.y, game.session.facing = x, y, facing
end
local function wait(n)
for _ = 1, n do
relay:pump()
U.wait(1)
end
end
local function waitFor(cond, seconds, frames)
local t0, n = now(), 0
while not cond() do
relay:pump()
U.wait(1)
n = n + 1
if now() - t0 > (seconds or 5) and n > (frames or 60) then return false end
end
return true
end
local function drive(cond, seconds)
local t0 = now()
while not cond() and now() - t0 < (seconds or 10) do
if Choice.active or SaveMenu.isOpen() or Message.isOpen() then U.tap(game, "a") end
wait(6)
end
return cond()
end
local live = Link.liveProfile()
if not result(live ~= nil, "the live g3 profile computes") then return finish() end
local function peer(id, name, trainerId, gender, version, status)
local s = relay:seat(id, name)
relay:handle(s, { type = "lobby_hello", protocol = 3, name = name, profiles = { live },
presence = { where = "launcher", status = "idle", version = version } })
s.avatar = { name = name, trainerId = trainerId, gender = gender, version = version }
s.status = status
relay:handle(s, { type = "plaza_join", kind = "union", cap = 40, profile = live, avatar = s.avatar })
return s
end
Link.connect()
result(waitFor(function() return Client.state() == "online" end, 5, 120), "online on the relay")
result(Link.adapterConnected(), "the adapter reads connected")
Map.load(nil, game, CENTER_2F, { x = 6, y = 4, facing = "up" })
wait(60)
Flags.setFlag(Space.store, ctx(), Family.flag(VERSION, "FLAG_SYS_POKEDEX_GET"), true)
if VERSION == "emerald" then
-- pokeemerald/data/scripts/cable_club.inc:104
Flags.setVar(Space.store, ctx(), Family.var(VERSION, "VAR_CABLE_CLUB_TUTORIAL_STATE"), 2)
end
place(6, 4, "up")
wait(12)
U.tap(game, "a")
local entered = drive(function() return Space.mapId == UNION_ROOM and Union.state == "main" end, 25)
if not result(entered, "the attendant walks the player into the Union Room") then
print("[driver] map=" .. tostring(Space.mapId) .. " union=" .. tostring(Union.state))
U.shot(game, DIR .. "/" .. VERSION .. "_presence_enter_failed.png")
return finish()
end
local statuses = { "chatting", "trading", "battling", "idle", "idle", "idle", "idle", "idle", "idle", "idle", "idle", "idle" }
local names = { "BLUE", "GREEN", "GOLD", "SILVER", "MAY", "LEAF", "WALLY", "BRENDAN", "KRIS", "ETHAN", "LYRA", "DAWN" }
local versions = { "firered", "leafgreen", "emerald", "ruby", "sapphire" }
local crowd = {}
for i, name in ipairs(names) do
crowd[i] = peer(string.format("c%07x", i), name, 0x100 + i * 13, i % 2, versions[(i - 1) % #versions + 1], statuses[i])
end
result(waitFor(function() return Union.playerCount() == #names end, 10, 120), "the peers appear in the plaza")
result(waitFor(function() return VirtualObjects.count() == #names end, 5, 60), "as avatars")
wait(40)
local LinkTags = package.loaded["src.ui.game3.link_tags"]
result(LinkTags ~= nil, "the badge layer is loaded in the Union Room")
local src = LinkTags and LinkTags.sources()
local kinds, named = {}, 0
for slot = 1, Plaza.CAP do
local p = Union.players[slot]
local tag = p and src and src.byVobj[Union.vobjId(slot)]
if tag then
if tag.name == p.name then named = named + 1 end
if tag.kind then kinds[tag.kind] = true end
end
end
result(named == #names, "every avatar carries its relay name (" .. named .. ")")
result(kinds.chat and kinds.trade and kinds.battle, "chatting, trading and battling each get an icon")
result(src and src.player and src.player.name == "RED", "the player's own badge reads RED")
place(12, 15, "up")
wait(10)
U.still(game, DIR .. "/" .. VERSION .. "_presence_01_badges_and_icons.png")
local homes = {}
for slot = 1, Plaza.CAP do
if Union.players[slot] then homes[slot] = { Union.cellFor(slot) } end
end
place(1, 22, "up")
local moved, stepping, busyMoved = {}, false, false
for _ = 1, 1500 do
wait(1)
for slot, h in pairs(homes) do
local rec = VirtualObjects.get(Union.vobjId(slot))
if rec and (rec.x ~= h[1] or rec.y ~= h[2]) then
moved[slot] = true
if Union.memberStatusKind(Union.players[slot]) then busyMoved = true end
if rec.moving and not stepping then
stepping = true
place(12, 15, "up")
wait(8)
U.still(game, DIR .. "/" .. VERSION .. "_presence_02_wander_mid_step.png")
place(1, 22, "up")
end
end
end
end
local n = 0
for _ in pairs(moved) do n = n + 1 end
result(n >= 3, "idle avatars wander off their cells (" .. n .. ")")
result(not busyMoved, "chatting, trading and battling avatars stay put")
place(12, 15, "up")
wait(30)
U.still(game, DIR .. "/" .. VERSION .. "_presence_03_after_wander.png")
place(12, 22, "down")
wait(10)
for _ = 1, 20 do
if Map.current == CENTER_2F then break end
U.hold(game, "down", 8)
relay:pump()
end
result(waitFor(function() return Map.current == CENTER_2F end, 8, 300), "the exit warps back to the 2F")
wait(20)
result(LinkTags.sources() == nil, "no badges outside the link rooms")
local LinkPlayers = require("src.core.game3.link.link_players")
local inbox = {}
local fake = {
role = "host",
update = function() end,
isOpen = function() return true end,
isReady = function() return true end,
getSeat = function() return 0 end,
send = function() return true end,
take = function(_, kind)
for i, m in ipairs(inbox) do
if m.type == kind then return table.remove(inbox, i) end
end
return nil
end,
players = function()
return { { seat = 0, name = "RED", gender = 0, version = "firered" },
{ seat = 1, name = "MAY", gender = 1, version = "emerald" } }
end,
}
Client.disconnect()
Map.load(nil, game, PER[VERSION].trade, { x = 4, y = 5, facing = "right" })
place(4, 5, "right")
wait(30)
Link.link = fake
Link.startPump()
inbox[#inbox + 1] = { type = LinkPlayers.MSG, seat = 1, map = "tradeCenter", x = 7, y = 5, facing = "left", busy = true }
wait(30)
local tc = LinkTags.sources()
local mayTag = tc and tc.byVobj[LinkPlayers.VOBJ_BASE + 1]
result(mayTag and mayTag.name == "MAY", "the Trade Center partner wears her name")
result(mayTag and mayTag.kind == "trade", "and a trade icon while she is busy at the table")
result(tc and tc.player and tc.player.name == "RED", "and the player keeps his badge in the link room")
U.still(game, DIR .. "/" .. VERSION .. "_presence_04_trade_center.png")
Link.link = nil
wait(4)
finish()
end
+10 -1
View File
@@ -373,8 +373,17 @@ eq(Rse.var("VAR_DEOXYS_ROCK_LEVEL", sess), 0, "level back to 0")
sess.options = {}
check(Islands.pendingGift(sess) == nil, "no gifts while the option is off")
Islands.setEnabled(sess, true)
check(Islands.pendingGift(sess) == nil, "the option alone hands out nothing; the relay controls tickets")
eq(Rse.var("VAR_DISTRIBUTE_EON_TICKET", sess), 0, "no Eon Ticket without the relay")
local feedCards = {}
for _, b in ipairs(require("src.core.game3.mystery_gift").builtins("rse")) do feedCards[#feedCards + 1] = { key = b.key, card = b.card } end
Islands.applyFeed(sess, { cards = { feedCards[2] } })
eq(Islands.pendingGift(sess).id, "aurora", "a relay AURORA TICKET card is offered")
check(not Islands.eonPending(sess), "the relay feed without the Eon card leaves the Eon Ticket off")
Islands.applyFeed(sess, { cards = feedCards })
Islands.sync(sess, { offline = true })
eq(Islands.pendingGift(sess).id, "aurora", "the Aurora Ticket is the first wonder card")
eq(Rse.var("VAR_DISTRIBUTE_EON_TICKET", sess), 1, "the Eon Ticket distribution var is raised")
eq(Rse.var("VAR_DISTRIBUTE_EON_TICKET", sess), 1, "the relay Eon Ticket raises the distribution var")
Rse.setFlag("FLAG_RECEIVED_AURORA_TICKET", true, sess)
eq(Islands.pendingGift(sess).id, "mystic", "then the Mystic Ticket")
Rse.setFlag("FLAG_RECEIVED_MYSTIC_TICKET", true, sess)
+25
View File
@@ -21,6 +21,23 @@ for _, version in ipairs({ "ruby", "sapphire", "emerald", "firered", "leafgreen"
tables[prefix.."data/generated/gba/items/pack.lua"] = Serializer.encode({items=items})
end
CacheFs.readAt=function(path) return tables[path] end;Catalog.reset()
Events.autoFetch=false
local Gift=require("src.core.game3.mystery_gift")
local EON_EVENT=require("src.core.Base64").decode("AQAAAAICAAIAAAAEAIABAAAQfhYAAB4AAAKaAgACBggBAWEAAAL7AQACCwEFEwEF+wEAAgLB4wDn2dkA7ePp5gDa1ejc2eYA1egA6NzZAMHTxwDd4v7Kv867xrzPzMGt/7hhAAACRxMBAQAhDYABALsBvwAAAkoTAQEAIQ2AAQC7Ab8AAAIrzgC7Ab8AAAJqWr3JAAACZm1GEwEBACENgAAAuwHAAAACGgCAEwEaAYABAAkAKVMIvQYBAAJmbWwNvYYBAAJmbWwCvru+8AD9AasAwePj2ADo4wDn2dkA7ePpq/7O3Nnm2bTnANUA4Nno6NnmANzZ5tkA2uPmAO3j6bgA/QGt/767vvAAw+gA1eTk2dXm5wDo4wDW2QDVANrZ5ubtAM7DvcW/zrj+1unoAMO06tkA4tnq2eYA59nZ4gDj4tkA4N3f2QDd6ADW2drj5tmt+9Pj6QDn3OPp4NgA6t3n3egAxsPG073J0L8A1eLYANXn3/7V1uPp6ADd6ADo3Nnm2a3/vru+8AD9AbgA6NzZAMW/0wDDzr/HzQDKyb3Fv84A3eL+7ePp5gC8u8EA3ecA2ung4K37x+Pq2QDn4+HZAN/Z7QDd6Nnh5wDa4+YA59Xa2d/Z2eTd4tv+3eIA7ePp5gDKvbgA6NzZ4gDX4+HZAOfZ2QDh2a3/uPsBAAJHEwEBACENgAEAuwFBAgACShMBAQAhDYABALsBQQIAAivOALsBQQIAAkYTAQEAIQ2AAAC7AUkCAAK+NQAAAg4CAr5RAgACDgMCvnUCAAIOAwLO3N3nAL/Qv8jOAOHV7QDW2QDk4NXt2dgA4+Lg7QDj4tfZrf/T4+nmALy7wbTnAMW/0wDDzr/HzQDKyb3Fv84A3ecA2ung4K3/")
T.eq(Gift.eventRecordMixingItem(EON_EVENT),275,"the relay eon event hands out ITEM_EON_TICKET by record mixing")
for _,version in ipairs({"firered","leafgreen","emerald","ruby","sapphire"}) do
T.check(not Events.receive({version=version,flags={},vars={},modData={},bag=require("src.core.game3.bag").new()},
version=="emerald" and "aurora" or (Events.FAMILY[version]=="rs" and "eon" or "aurora"),2),
version.." hands out no ticket before the relay feed arrives")
end
local function feedFor(version)
local fam=Events.FAMILY[version]
if fam=="rs" then return {cards={},news={},events={{key="rs_eon_ticket",label="EON TICKET",bytes=EON_EVENT}}} end
local cards={}
for _,b in ipairs(Gift.builtins(fam)) do cards[#cards+1]={key=b.key,card=b.card} end
return {cards=cards,news={}}
end
for _,version in ipairs({"firered","leafgreen","emerald","ruby","sapphire"}) do Events.setFeed(version,feedFor(version)) end
T.check(not Events.allowed("oldSeaMap","emerald",2),"English Emerald cannot receive Old Sea Map")
T.check(Events.allowed("oldSeaMap","emerald",1)~=nil,"Japanese Emerald has historical Old Sea Map distribution")
T.check(not Events.allowed("aurora","emerald",1),"Japanese Emerald has no historical AuroraTicket distribution")
@@ -104,5 +121,13 @@ for _,version in ipairs({"emerald","firered","leafgreen"}) do
T.eq(Events.status(save,row),"Event already completed",version.." completed encounter is visible before receiving card")
T.check(not Events.receive(save,"mystic",2),"completed encounter cannot create undeliverable pending card")
end
Events.setFeed("firered",{cards={},news={}})
do
local save,why=Events.receive({version="firered",flags={},vars={},modData={},bag=require("src.core.game3.bag").new()},"aurora",2)
T.check(save==nil and tostring(why):find("not offering"),"a ticket the relay does not publish cannot be received: "..tostring(why))
end
Events.setFeed("ruby",{cards={},news={},events={}})
T.check(not Events.receive({version="ruby",flags={},vars={},modData={},bag=require("src.core.game3.bag").new()},"eon",2),
"Ruby gets no Eon Ticket without the relay e-Reader event")
CacheFs.readAt=oldRead;Catalog.reset()
T.finish("Box events")
+45
View File
@@ -0,0 +1,45 @@
package.path = "./?.lua;./?/init.lua;" .. package.path
love = love or require("tests.love_stub")
local version, seat, outFile, inFile = arg[1], tonumber(arg[2]), arg[3], arg[4]
local GameVersion = require("src.core.GameVersion")
require("src.core.game3.profile").reset()
GameVersion.set(version)
require("src.import.gba.versions").select(version)
require("src.core.game3.pokemon").install(nil)
local Json = require("src.link.Json")
local Wire = require("src.link.Wire")
local Schema = require("src.core.game3.save_schema_firered")
local Flags = require("src.core.game3.scripting.flags")
local Space = require("src.core.game3.scripting.space")
local RecordMix = require("src.core.game3.link.record_mix")
local other = version == "emerald" and "ruby" or "emerald"
local s = Schema.newGame({ version = version, name = version == "emerald" and "MAY" or "BRENDAN", rngSeed = 7 + seat })
s.trainerId, s.secretId, s.gender = 0x1000 + seat, 0, seat % 2
s.party = { { species = 1, level = 5, nickname = "", heldItem = 0 } }
Space.store = Flags.newStore()
local partners = {}
partners[seat + 1] = { seat = seat, version = version }
partners[2 - seat] = { seat = 1 - seat, version = other }
local mine = RecordMix.packet(s, seat, partners)
local text = Json.encode({ type = RecordMix.MSG.PACKET, spot = seat, packet = RecordMix.toWire(mine) })
local f = assert(io.open(outFile, "wb"))
f:write(text)
f:close()
print("bytes " .. #text)
print("layout " .. tostring(mine.recordLayout))
if not inFile then return end
local g = assert(io.open(inFile, "rb"))
local theirs = Wire.sanitize(Json.decode(g:read("*a")))
g:close()
local list = {}
list[seat + 1] = mine
list[2 - seat] = RecordMix.fromWire(theirs.packet)
local applied = RecordMix.receive(s, list, seat + 1)
print("unsupported " .. tostring(applied and applied.unsupportedReason))
for _, key in ipairs({ "secretBases", "tvShows", "pokeNews", "oldMan", "dewfordTrends", "daycareMail", "battleTower" }) do
print(key .. " " .. tostring(applied and applied[key]))
end
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -121,7 +121,7 @@ local job = MysteryGift.fetchOnline({ transport = okT })
eq(job.status, "pending", "the fetch starts")
local req = okT.begun[1]
check(req ~= nil and req.method == "GET", "it is a GET")
check(req ~= nil and req.url:sub(-#"/gifts/gen3") == "/gifts/gen3", "to /gifts/gen3: " .. tostring(req and req.url))
check(req ~= nil and req.url:find("/gifts/gen3", 1, true) ~= nil, "to /gifts/gen3: " .. tostring(req and req.url))
check(req ~= nil and req.headers["x-sync-token"] == nil, "without sync credentials")
eq(req and req.maxSeconds, 10, "with a ten second budget")
eq((MysteryGift.pollOnline(job)), "pending", "pending while the transport is")
+132 -14
View File
@@ -21,7 +21,7 @@ love = love or require("tests.love_stub")
local romBundle = require("tests.game3_cache").bundle()
if not romBundle then
local LIST_ROWS = { sListMenuItems_CardsOrNews = 3, sListMenuItems_ReceiveSendToss = 4, sListMenuItems_ReceiveToss = 3,
sListMenuItems_WirelessOrFriend = 3 }
sListMenuItems_WirelessOrFriend = 3, sListMenuItems_ReceiveSend = 3, sListMenuItems_Receive = 2 }
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,
@@ -145,12 +145,12 @@ local function giftSession(save)
end
local function openWireless(st, d, isNews)
if isNews then
d.step("down")
d.step("a")
return
end
if isNews then d.step("down") end
d.step("a")
if st.state == Ui.STATE.SAVED_VIEW then
d.step("a")
d.step("a")
end
d.waitFor(function() return st.state == Ui.STATE.SEARCHING end)
end
@@ -259,7 +259,11 @@ do
teq(rows[3], "EXIT", "EXIT is the third row")
step("a")
eq(st.state, Ui.STATE.SEARCHING, "WONDER CARDS fetches the card list at once")
-- pokefirered/src/mystery_gift_menu.c:1147 MG_STATE_DONT_HAVE_ANY
eq(st.state, Ui.STATE.RESULT_MSG, "with no card held the don't-have text comes first")
eq(feedTransport.calls, 0, "nothing is fetched yet")
d.waitFor(function() return st.state == Ui.STATE.SEARCHING end)
eq(st.state, Ui.STATE.SEARCHING, "then WONDER CARDS fetches the card list")
eq(feedTransport.calls, 1, "one request goes out")
eq(WirelessIcon.anim(), "searching", "the wireless icon plays its searching anim")
tcheck(type(st.prompt) == "string" and st.prompt:find("Searching for a WIRELESS"),
@@ -306,7 +310,22 @@ print("[test] 5. The list marks the held card and opens it")
do
local d = driver(st)
d.step("a")
eq(st.state, Ui.STATE.SEARCHING, "WONDER CARDS fetches again")
-- pokefirered/src/mystery_gift_menu.c:1390 MG_STATE_LOAD_GIFT
eq(st.state, Ui.STATE.SAVED_VIEW, "WONDER CARDS shows the held card first")
eq(Ui.card(st).titleText, "MYSTIC TICKET", "the saved card is on screen")
local okSaved, errSaved = pcall(Ui.draw, st)
tcheck(okSaved, "the saved card draws: " .. tostring(errSaved))
d.step("a")
eq(st.state, Ui.STATE.GIFT_MENU, "A opens the card's menu")
eq(#st.rows, 3, "a keep-only card offers RECEIVE, TOSS and CANCEL")
eq(st.giftActions[2], "toss", "the second row tosses")
local okMenu, errMenu = pcall(Ui.draw, st)
tcheck(okMenu, "the card menu draws: " .. tostring(errMenu))
d.step("b")
eq(st.state, Ui.STATE.SAVED_VIEW, "B goes back to the card")
d.step("a")
d.step("a")
eq(st.state, Ui.STATE.SEARCHING, "RECEIVE fetches again")
d.waitFor(function() return st.state ~= Ui.STATE.SEARCHING end)
eq(st.state, Ui.STATE.OFFER_LIST, "the list opens")
local lm = ListStub.last
@@ -378,7 +397,7 @@ print("[test] 7. Wonder News over WIRELESS COMMUNICATION")
do
local st2, d, sess2 = freshScreen(okFeed())
openWireless(st2, d, true)
eq(st2.state, Ui.STATE.SEARCHING, "WONDER NEWS fetches the list at once")
eq(st2.state, Ui.STATE.SEARCHING, "WONDER NEWS fetches the list")
searchResult(st2, d)
eq(st2.state, Ui.STATE.OFFER_LIST, "the news list opens")
eq(#ListStub.last.items, 1, "only news rows are listed")
@@ -404,9 +423,10 @@ do
d.step("a")
d.waitFor(function() return st2.state == Ui.STATE.MAIN_MENU and not st2.msg end)
eq(MysteryGift.getSavedCard(sess2).idNumber, 7, "the MEW card is installed")
local giftFlag = MysteryGift.receivedGiftFlag(MysteryGift.getSavedCard(sess2).flagId)
check(giftFlag ~= nil, "the MEW card has a received-gift flag")
MysteryGift.setFlag(sess2, giftFlag, true)
check(MysteryGift.isGiftNotReceived(sess2), "the MEW is not collected yet")
local rec = MysteryGift.ensure(sess2)
rec.deliveredIds = { 7 }
check(MysteryGift.wasDelivered(sess2, MysteryGift.getSavedCard(sess2)), "a Pokemon card is tracked by its ID number")
MysteryGift.clearCardAndRelated(sess2)
check(not MysteryGift.validateSavedCard(sess2), "then tossed")
@@ -439,7 +459,7 @@ print("[test] 9. Replacing a held card asks first")
do
local st2, d, sess2 = freshScreen(okFeed())
MysteryGift.receiveCard(sess2, MysteryGift.builtins()[2].card)
d.step("a")
openWireless(st2, d, false)
eq(st2.state, Ui.STATE.SEARCHING, "WONDER CARDS fetches the list")
searchResult(st2, d)
d.step("a")
@@ -582,13 +602,111 @@ do
teq(locked.rows[2], "EXIT", "row 2 is EXIT")
Ui.update(locked, function(k) return k == "a" end, 1 / 60)
check(locked.isNews, "the first row opens the news branch")
eq(locked.state, Ui.STATE.SEARCHING, "which fetches the news list")
eq(locked.state, Ui.STATE.RESULT_MSG, "which says no news is held before fetching")
local open = Ui.new({ session = giftSession({}), onSave = function() return true end })
eq(#open.rows, 3, "the passphrase adds WONDER CARDS back")
teq(open.rows[1], "WONDER CARDS", "on the first row")
end
print("[test] 14. Receiving Wonder News from the relay saves it and sets the reward")
do
local save = {}
local st2, d, sess2 = freshScreen(okFeed(), save)
local before = #fanfares
openWireless(st2, d, true)
searchResult(st2, d)
d.step("a")
eq(st2.state, Ui.STATE.NEWS_VIEW, "the news opens")
d.step("a")
eq(st2.state, Ui.STATE.RESULT_MSG, "A receives it")
check(MysteryGift.validateSavedNews(sess2), "the news is saved")
check(MysteryGift.hasClaimedNews(sess2, 1), "and claimed")
eq(#fanfares, before + 1, "the received fanfare plays")
tcheck(st2.msg ~= nil and tostring(st2.msg.text):find("NEWS"), "with the news-received text")
eq(MysteryGift.getSavedNewsMetadata(sess2).newsType, MysteryGift.WONDER_NEWS_NONE,
"the reward is not set before the message ends")
d.waitFor(function() return st2.state == Ui.STATE.SAVE end)
-- pokefirered/src/mystery_gift_menu.c:1369 WonderNews_SetReward(WONDER_NEWS_RECV_WIRELESS)
eq(MysteryGift.getSavedNewsMetadata(sess2).newsType, MysteryGift.WONDER_NEWS_RECV_WIRELESS,
"the wireless reward is set once the message is done")
d.waitFor(function() return st2.state == Ui.STATE.MAIN_MENU and not st2.msg end)
check(type(save.modData) == "table" and save.modData.mysteryGift.news ~= nil, "the news reached the save")
openWireless(st2, d, true)
eq(st2.state, Ui.STATE.SEARCHING, "the saved news offers RECEIVE")
searchResult(st2, d)
eq(ListStub.last.items[1].colors, ListStub.COLOR_WHITE, "the held news prints greyed")
MysteryGift.getSavedNewsMetadata(sess2).newsType = MysteryGift.WONDER_NEWS_NONE
d.step("a")
d.step("a")
eq(st2.state, Ui.STATE.RESULT_MSG, "the same news again ends the link")
tcheck(st2.msg ~= nil and tostring(st2.msg.text):find("already"), "with the already-had text")
d.waitFor(function() return st2.state == Ui.STATE.MAIN_MENU end)
eq(MysteryGift.getSavedNewsMetadata(sess2).newsType, MysteryGift.WONDER_NEWS_NONE,
"already-had news earns no reward")
end
print("[test] 15. The saved card menu tosses a card")
do
local save = {}
local saves = 0
local sess2 = giftSession(save)
local st2 = Ui.new({ session = sess2, fetch = { transport = okFeed() },
onSave = function(s) saves = saves + 1 return MysteryGift.applyToSave(s, save) end })
local d = driver(st2)
check(MysteryGift.receiveCard(sess2, MysteryGift.builtins()[2].card), "an uncollected AURORA card is held")
d.step("a")
eq(st2.state, Ui.STATE.SAVED_VIEW, "the held card shows")
d.step("a")
d.step("down")
d.step("a")
eq(st2.state, Ui.STATE.ASK_TOSS, "TOSS asks first")
check(st2.yesno ~= nil, "with a yes/no")
d.step("a")
check(st2.yesno ~= nil and st2.state == Ui.STATE.ASK_TOSS, "an uncollected gift asks a second time")
d.step("b")
eq(st2.state, Ui.STATE.GIFT_MENU, "NO goes back to the menu")
check(MysteryGift.validateSavedCard(sess2), "and keeps the card")
d.step("down")
d.step("a")
d.step("a")
d.step("a")
check(not MysteryGift.validateSavedCard(sess2), "YES twice throws the card away")
d.waitFor(function() return st2.state == Ui.STATE.MAIN_MENU and not st2.msg end)
eq(saves, 1, "the toss is saved once")
check(save.modData.mysteryGift.card == nil, "the save has no card")
end
print("[test] 16. A shareable card has no SEND row")
do
local sess2 = giftSession({})
local st2 = Ui.new({ session = sess2, onSave = function() return true end })
local d = driver(st2)
check(MysteryGift.receiveCard(sess2, MysteryGift.builtins()[4].card), "a shareable STAMP CARD is held")
d.step("a")
d.step("a")
eq(#st2.rows, 3, "the menu is RECEIVE, TOSS and CANCEL")
eq(table.concat(st2.giftActions, ","), "receive,toss,cancel", "with no send action")
check(MysteryGift.sendToFriend == nil, "there is no friend-send hook")
d.step("b")
d.step("b")
eq(st2.state, Ui.STATE.MAIN_MENU, "B twice goes back to the menu")
end
print("[test] 17. Held news offers RECEIVE and CANCEL only")
do
local sess2 = giftSession({})
local st2 = Ui.new({ session = sess2, onSave = function() return true end })
local d = driver(st2)
check(MysteryGift.receiveNews(sess2, { id = 3, sendType = 1, titleText = "NEWS", bodyText = {} }), "shareable news is held")
d.step("down")
d.step("a")
eq(st2.state, Ui.STATE.SAVED_VIEW, "the held news shows")
d.step("a")
eq(table.concat(st2.giftActions, ","), "receive,cancel", "news has no send or toss")
end
if failed == 0 then
print("PASS game3_gift_menu")
else
+10 -5
View File
@@ -218,10 +218,10 @@ do
eq(rse and #rse.cards, 1, "rse sees one card")
eq(rse and rse.cards[1].key, "em", "the rse row")
eq(rse and rse.cards[1].card.gift.setFlags[1], F("FLAG_ENABLE_SHIP_BIRTH_ISLAND"), "names resolve with Emerald ids")
eq(rse and #rse.news, 1, "rse sees one news")
eq(rse and #rse.news, 2, "rse sees every news, news has no family limit")
local frlg = MysteryGift.parseFeed(payload, "frlg")
eq(frlg and #frlg.cards, 2, "frlg sees its row and the unlabeled one")
eq(frlg and #frlg.news, 1, "frlg sees the unlabeled news")
eq(frlg and #frlg.news, 2, "frlg sees every news too")
local begun = {}
local transport = {
@@ -231,10 +231,15 @@ do
}
local job = MysteryGift.fetchOnline({ transport = transport, session = newSession("emerald") })
eq(job.family, "rse", "the Emerald job carries its family")
check(begun[1] and begun[1].url:sub(-#"/gifts/gen3?family=rse") == "/gifts/gen3?family=rse",
"Emerald fetches ?family=rse: " .. tostring(begun[1] and begun[1].url))
local emPath = "/gifts/gen3?family=rse&version=emerald"
check(begun[1] and begun[1].url:sub(-#emPath) == emPath,
"Emerald fetches ?family=rse and names its game: " .. tostring(begun[1] and begun[1].url))
MysteryGift.fetchOnline({ transport = transport, session = newSession("firered") })
check(begun[2] and begun[2].url:sub(-#"/gifts/gen3") == "/gifts/gen3", "FireRed keeps the bare path")
check(begun[2] and begun[2].url:sub(-#"/gifts/gen3?version=firered") == "/gifts/gen3?version=firered",
"FireRed keeps the frlg path and names its game")
MysteryGift.fetchOnline({ transport = transport, session = newSession("leafgreen") })
check(begun[3] and begun[3].url:sub(-#"/gifts/gen3?version=leafgreen") == "/gifts/gen3?version=leafgreen",
"LeafGreen names its own game")
end
print("[test] 11. LINK TOGETHER WITH ALL")
+91
View File
@@ -0,0 +1,91 @@
#!/usr/bin/env luajit
package.path = "./?.lua;./?/init.lua;" .. package.path
require("tests.fixture_data.game3_items").install()
love = love or require("tests.love_stub")
local T = require("tests.harness")
local check, eq = T.check, T.eq
local FIXTURE_PUB = "b084317448d99b4451ec0b3f5a4ec746a0de25800d8e5bdce4bedbcae6b7ce1d"
local FEED_FRLG = [==[{"v":1,"payload":"{\"cards\":[{\"bgType\":3,\"bodyText\":[\"Thank you for using the MYSTERY\",\"GIFT System.\",\"There is a ticket here for you.\",\"It is for use at VERMILION CITY port.\"],\"family\":\"frlg\",\"flagId\":1000,\"footerLine1Text\":\"Speak to the deliveryman\",\"footerLine2Text\":\"at a POKéMON CENTER.\",\"gift\":{\"haveFlags\":[679,740],\"item\":371,\"kind\":\"item\",\"quantity\":1,\"setFlags\":[2123,679]},\"iconSpecies\":0,\"idNumber\":2,\"key\":\"aurora_ticket\",\"maxStamps\":0,\"sendType\":0,\"subtitleText\":\"MYSTERY GIFT\",\"titleText\":\"AURORA TICKET\",\"type\":0,\"versions\":[\"firered\",\"leafgreen\"]},{\"bgType\":7,\"bodyText\":[\"A mythical POKéMON has been\",\"sent to you from the\",\"POKéMON CENTER.\",\"Please take good care of it.\"],\"family\":\"frlg\",\"flagId\":1007,\"footerLine1Text\":\"Speak to the deliveryman\",\"footerLine2Text\":\"at a POKéMON CENTER.\",\"gift\":{\"kind\":\"mon\",\"level\":10,\"moves\":{\"1\":1},\"otId\":20078,\"otName\":\"AURA\",\"species\":151},\"iconSpecies\":151,\"idNumber\":7,\"key\":\"lg_mew\",\"maxStamps\":0,\"sendType\":0,\"subtitleText\":\"MYSTERY GIFT\",\"titleText\":\"MEW\",\"type\":0,\"versions\":[\"leafgreen\"]}],\"family\":\"frlg\",\"issued\":5,\"news\":[{\"bgType\":0,\"bodyText\":[\"Every game reads this.\",\"\",\"\",\"\",\"\",\"\",\"\",\"\",\"\",\"\"],\"family\":\"frlg\",\"id\":9,\"key\":\"kanto_news\",\"sendType\":0,\"titleText\":\"RELAY NEWS\"}],\"v\":1}","sig":"f21a2cff1f284e7f31f507d27a2190b765f4839ff72077fe31321dce8bb655d460522748b79ef455500699012940a3509286b56820b8a2a657676226d5e9840c","key":"b084317448d99b4451ec0b3f5a4ec746a0de25800d8e5bdce4bedbcae6b7ce1d"}]==]
local FEED_RSE = [==[{"v":1,"payload":"{\"cards\":[{\"bgType\":6,\"bodyText\":[\"Thank you for using the MYSTERY\",\"GIFT System.\",\"We received this OLD SEA MAP\",\"addressed to you.\"],\"family\":\"rse\",\"flagId\":1002,\"footerLine1Text\":\"Speak to the deliveryman\",\"footerLine2Text\":\"at a POKéMON CENTER.\",\"gift\":{\"haveFlags\":[316,458],\"item\":376,\"kind\":\"item\",\"quantity\":1,\"setFlags\":[2262,316]},\"iconSpecies\":0,\"idNumber\":104,\"key\":\"rse_old_sea_map\",\"maxStamps\":0,\"sendType\":0,\"subtitleText\":\"MYSTERY GIFT\",\"titleText\":\"OLD SEA MAP\",\"type\":0,\"versions\":[\"emerald\"]}],\"family\":\"rse\",\"issued\":5,\"news\":[{\"bgType\":0,\"bodyText\":[\"Every game reads this.\",\"\",\"\",\"\",\"\",\"\",\"\",\"\",\"\",\"\"],\"family\":\"frlg\",\"id\":9,\"key\":\"kanto_news\",\"sendType\":0,\"titleText\":\"RELAY NEWS\"}],\"v\":1}","sig":"e10196799b4ff259bbf3b20f9ed133d5041dabf4f78bba4a3612d9464ecd2a693f95035e324697f0c31ae00efbcf33c43496882b7af03aeee2a5c99fe0a28f03","key":"b084317448d99b4451ec0b3f5a4ec746a0de25800d8e5bdce4bedbcae6b7ce1d"}]==]
local FEED_RS = [==[{"v":1,"payload":"{\"cards\":[],\"events\":[{\"family\":\"rs\",\"key\":\"rs_eon_ticket\",\"payload\":\"AQAAAAICAAIAAAAEAIABAAAQfhYAAB4AAAKaAgACBggBAWEAAAL7AQACCwEFEwEF+wEAAgLB4wDn2dkA7ePp5gDa1ejc2eYA1egA6NzZAMHTxwDd4v7Kv867xrzPzMGt/7hhAAACRxMBAQAhDYABALsBvwAAAkoTAQEAIQ2AAQC7Ab8AAAIrzgC7Ab8AAAJqWr3JAAACZm1GEwEBACENgAAAuwHAAAACGgCAEwEaAYABAAkAKVMIvQYBAAJmbWwNvYYBAAJmbWwCvru+8AD9AasAwePj2ADo4wDn2dkA7ePpq/7O3Nnm2bTnANUA4Nno6NnmANzZ5tkA2uPmAO3j6bgA/QGt/767vvAAw+gA1eTk2dXm5wDo4wDW2QDVANrZ5ubtAM7DvcW/zrj+1unoAMO06tkA4tnq2eYA59nZ4gDj4tkA4N3f2QDd6ADW2drj5tmt+9Pj6QDn3OPp4NgA6t3n3egAxsPG073J0L8A1eLYANXn3/7V1uPp6ADd6ADo3Nnm2a3/vru+8AD9AbgA6NzZAMW/0wDDzr/HzQDKyb3Fv84A3eL+7ePp5gC8u8EA3ecA2ung4K37x+Pq2QDn4+HZAN/Z7QDd6Nnh5wDa4+YA59Xa2d/Z2eTd4tv+3eIA7ePp5gDKvbgA6NzZ4gDX4+HZAOfZ2QDh2a3/uPsBAAJHEwEBACENgAEAuwFBAgACShMBAQAhDYABALsBQQIAAivOALsBQQIAAkYTAQEAIQ2AAAC7AUkCAAK+NQAAAg4CAr5RAgACDgMCvnUCAAIOAwLO3N3nAL/Qv8jOAOHV7QDW2QDk4NXt2dgA4+Lg7QDj4tfZrf/T4+nmALy7wbTnAMW/0wDDzr/HzQDKyb3Fv84A3ecA2ung4K3/\",\"title\":\"EON TICKET\",\"versions\":[\"ruby\",\"sapphire\"]},{\"family\":\"rs\",\"key\":\"rs_gift_ribbon\",\"payload\":\"AZsIAAICAAIAAAAEAIABAAAQTGwAALkIAAK9CAACCAABAg==\",\"title\":\"GIFT RIBBON\",\"versions\":[\"ruby\",\"sapphire\"]}],\"family\":\"rs\",\"issued\":5,\"news\":[{\"bgType\":0,\"bodyText\":[\"Every game reads this.\",\"\",\"\",\"\",\"\",\"\",\"\",\"\",\"\",\"\"],\"family\":\"frlg\",\"id\":9,\"key\":\"kanto_news\",\"sendType\":0,\"titleText\":\"RELAY NEWS\"}],\"v\":1}","sig":"ff759a880d1e32cd6e8962c86e3cb2f56b729b973545f4b748eaa7d571961c6690fae7034d36ef819f7127791d9bf972621097eab735f782558fa8cd1e9a680d","key":"b084317448d99b4451ec0b3f5a4ec746a0de25800d8e5bdce4bedbcae6b7ce1d"}]==]
local Json = require("src.link.Json")
local MysteryGift = require("src.core.game3.mystery_gift")
local Bag = require("src.core.game3.bag")
local Schema = require("src.core.game3.save_schema_firered")
MysteryGift.GIFT_PUBKEY = FIXTURE_PUB
local function keys(list, field)
local out = {}
for _, e in ipairs(list or {}) do out[#out + 1] = e[field or "key"] end
return table.concat(out, ",")
end
local function verify(body, family, version)
return MysteryGift.verifyFeed(Json.decode(body), family, version)
end
print("[test] 1. Cards only reach the games that had them")
local fr = verify(FEED_FRLG, "frlg", "firered")
local lg = verify(FEED_FRLG, "frlg", "leafgreen")
eq(fr and keys(fr.cards), "aurora_ticket", "FireRed does not see the LeafGreen-only card")
eq(lg and keys(lg.cards), "aurora_ticket,lg_mew", "LeafGreen sees its own card")
eq(lg and lg.cards[1].card.versions and table.concat(lg.cards[1].card.versions, ","), "firered,leafgreen",
"the card keeps its game list")
local em = verify(FEED_RSE, "rse", "emerald")
eq(em and keys(em.cards), "rse_old_sea_map", "Emerald sees the OLD SEA MAP")
local any = verify(FEED_FRLG, "frlg", nil)
eq(any and #any.cards, 2, "with no game named nothing is dropped")
print("[test] 2. Wonder News reaches every game")
for _, row in ipairs({ { "firered", FEED_FRLG, "frlg" }, { "leafgreen", FEED_FRLG, "frlg" },
{ "emerald", FEED_RSE, "rse" }, { "ruby", FEED_RS, "rs" }, { "sapphire", FEED_RS, "rs" } }) do
local list = verify(row[2], row[3], row[1])
eq(list and keys(list.news), "kanto_news", "news reaches " .. row[1])
end
print("[test] 3. LeafGreen receives and collects its Wonder Card")
do
local session = Schema.newGame({ rngSeed = 0x77 })
session.version = "leafgreen"
session.store = { flags = {}, vars = {} }
session.bag = Bag.new()
eq(MysteryGift.versionOf(session), "leafgreen", "the session is LeafGreen")
local entry = lg.cards[1]
check(MysteryGift.receiveCard(session, entry.card), "the relay AURORA TICKET card is saved on LeafGreen")
eq(MysteryGift.deliverGift(session, MysteryGift.getSavedCard(session)), MysteryGift.DELIVER_GIVEN,
"the deliveryman hands it over")
check(Bag.has(session.bag, MysteryGift.ITEM_AURORA_TICKET, 1), "the AURORA TICKET is in the bag")
-- pokefirered/data/mystery_event_msg.s:222
check(MysteryGift.getFlag(session, 0x84B) and MysteryGift.getFlag(session, 0x2A7),
"FLAG_ENABLE_SHIP_BIRTH_ISLAND and FLAG_RECEIVED_AURORA_TICKET are set")
end
print("[test] 4. The rs feed carries signed Mystery Events")
local ruby = verify(FEED_RS, "rs", "ruby")
local sapphire = verify(FEED_RS, "rs", "sapphire")
eq(ruby and keys(ruby.events), "rs_eon_ticket,rs_gift_ribbon", "Ruby lists both e-Reader events")
eq(sapphire and keys(sapphire.events), "rs_eon_ticket,rs_gift_ribbon", "and so does Sapphire")
eq(ruby and #ruby.cards, 0, "Ruby gets no Wonder Cards")
eq(ruby and ruby.events[1].label, "EON TICKET", "the event keeps its title")
check(ruby and #ruby.events[1].bytes > 17 and #ruby.events[1].bytes <= 0x7D4, "the bytes fit an e-Reader block")
-- pokeruby/data/debug_mystery_event_scripts.s:37 me_setrecordmixinggift 0x1, 0x5, ITEM_EON_TICKET
eq(MysteryGift.eventRecordMixingItem(ruby.events[1].bytes), 275, "the Eon event spreads ITEM_EON_TICKET")
eq(MysteryGift.eventRecordMixingItem(ruby.events[2].bytes), nil, "the ribbon event has no record mixing gift")
local forged = FEED_RS:gsub("EON TICKET", "EON TICKEU")
local bad, why = verify(forged, "rs", "ruby")
check(bad == nil and why == "bad_signature", "a tampered rs feed is refused")
local data = Json.decode(Json.decode(FEED_RS).payload)
local raw = require("src.core.Base64").decode(data.events[1].payload)
local rubyOnly = raw:sub(1, 13) .. string.char(0x80, 0, 0, 0) .. raw:sub(18)
data.events[1].payload = require("src.core.Base64").encode(rubyOnly)
local payload = Json.encode(data)
eq(#MysteryGift.parseFeed(payload, "rs", "ruby").events, 2, "a Ruby-only block still runs on Ruby")
eq(#MysteryGift.parseFeed(payload, "rs", "sapphire").events, 1, "but is dropped on Sapphire by its header")
data.events[2].versions = { "ruby" }
eq(#MysteryGift.parseFeed(Json.encode(data), "rs", "sapphire").events, 0, "and the feed game list drops the other")
T.finish("game3_gift_versions")
+53 -37
View File
@@ -221,19 +221,19 @@ session.party = { charizard() }
Link.reset()
freshCtx()
setVar(Link.VAR_0x8004, Link.USING.SINGLE_BATTLE)
local connects = 0
local Connect = { start = function() connects = connects + 1; return false, "offline" end,
state = function() return "offline" end }
package.loaded["src.online.Connect"] = Connect
package.loaded["src.online.ArenaData"] = {
liveProfile3 = function(_, rulesetId) return { engine = 3, version = "firered", rulesetId = rulesetId } end,
}
Link._live = nil
local waited = Natives.special(ctx, NativesLink.SPECIAL.TryBattleLinkup, adapters)
-- pokefirered/src/cable_club.c:208-222
check(waited, "with no cable yet the counter parks the script instead of answering")
eq(getVar(Link.VAR_RESULT), Link.LINKUP.ONGOING, "and reports LINKUP_ONGOING while it waits")
local spun = 0
for _ = 1, LB.LINKUP_TICKS + 1 do
if ctx.nativePoll() then break end
spun = spun + 1
end
-- pokefirered/src/cable_club.c:482 TryLinkTimeout
eq(spun, LB.LINKUP_TICKS, "it waits the full TryLinkTimeout window before giving up")
check(not waited, "with no cable and no relay the counter answers at once")
eq(connects, 1, "after trying the relay once")
eq(getVar(Link.VAR_RESULT), Link.LINKUP.CONNECTION_ERROR,
"and a partner that never arrives is LINKUP_CONNECTION_ERROR")
"and a relay that cannot be reached is LINKUP_CONNECTION_ERROR")
local host, guest = FakeRelay.pair({ game = game })
pumpLink(host, guest)
@@ -243,51 +243,67 @@ setVar(Link.VAR_0x8004, Link.USING.SINGLE_BATTLE)
local yielded = Natives.special(ctx, NativesLink.SPECIAL.TryBattleLinkup, adapters)
check(yielded, "the script yields while the linkup runs")
eq(getVar(Link.VAR_RESULT), Link.LINKUP.ONGOING, "VAR_RESULT is LINKUP_ONGOING while it waits")
check(ctx.nativePoll() == false, "nothing is decided before the peer says what it picked")
guest:update(0)
local peerPick = guest:take(LB.MSG.LINKUP)
eq(peerPick and peerPick.linkType, LB.LINKTYPE.SINGLE_BATTLE,
"the peer was told this machine picked LINKTYPE_SINGLE_BATTLE")
guest:send({ type = LB.MSG.LINKUP, linkType = LB.LINKTYPE.SINGLE_BATTLE, players = 2 })
host:update(0)
check(ctx.nativePoll() == true, "the poll settles once both picks are in")
eq(getVar(Link.VAR_RESULT), Link.LINKUP.SUCCESS, "matching picks report LINKUP_SUCCESS")
eq(guest:take(LB.MSG.LINKUP), nil, "a relay link sends no linkup message: the hellos already carry the picks")
check(ctx.nativePoll() == true, "the poll settles from the hellos")
eq(getVar(Link.VAR_RESULT), Link.LINKUP.SUCCESS, "a peer on the generic wireless link type is LINKUP_SUCCESS")
eq(LB.state, "seat", "and the session is waiting for a colosseum seat")
Link.closeLink("test")
local Game3Link = require("src.link.Game3Link")
local function relayPair(hostType, guestType, seats)
local room = FakeRelay.room({ seats = seats or 2 })
local a = Game3Link.attach(FakeRelay.transport(room, 0), { game = game, linkType = hostType, seat = 0, seats = room.seats })
local b = Game3Link.attach(FakeRelay.transport(room, 1), { game = game, linkType = guestType, seat = 1, seats = room.seats })
pumpLink(a, b)
return a, b
end
host, guest = relayPair(LB.LINKTYPE.SINGLE_BATTLE, LB.LINKTYPE.DOUBLE_BATTLE)
Link.attach(host)
freshCtx()
setVar(Link.VAR_0x8004, Link.USING.SINGLE_BATTLE)
Natives.special(ctx, NativesLink.SPECIAL.TryBattleLinkup, adapters)
guest:update(0)
guest:take(LB.MSG.LINKUP)
guest:send({ type = LB.MSG.LINKUP, linkType = LB.LINKTYPE.MULTI_BATTLE, players = 4 })
host:update(0)
ctx.nativePoll()
eq(getVar(Link.VAR_RESULT), Link.LINKUP.DIFF_SELECTIONS,
"a peer that picked another mode reports LINKUP_DIFF_SELECTIONS")
"a cable peer that picked another mode reports LINKUP_DIFF_SELECTIONS")
Link.closeLink("test")
host, guest = relayPair(LB.LINKTYPE.MULTI_BATTLE, LB.LINKTYPE.MULTI_BATTLE)
Link.attach(host)
freshCtx()
setVar(Link.VAR_0x8004, Link.USING.MULTI_BATTLE)
Natives.special(ctx, NativesLink.SPECIAL.TryBattleLinkup, adapters)
guest:update(0)
local multiPick = guest:take(LB.MSG.LINKUP)
eq(multiPick and multiPick.linkType, LB.LINKTYPE.MULTI_BATTLE, "MULTI BATTLE picks LINKTYPE_MULTI_BATTLE")
guest:send({ type = LB.MSG.LINKUP, linkType = LB.LINKTYPE.MULTI_BATTLE, players = 4 })
host:update(0)
ctx.nativePoll()
eq(getVar(Link.VAR_RESULT), Link.LINKUP.WRONG_NUM_PLAYERS,
"a four player mode on a two machine cable reports LINKUP_WRONG_NUM_PLAYERS")
"a four player mode on a two machine link reports LINKUP_WRONG_NUM_PLAYERS")
Link.closeLink("test")
local lhost, lguest = Game3Link.loopback({ game = game })
pumpLink(lhost, lguest)
Link.attach(lhost)
freshCtx()
setVar(Link.VAR_0x8004, Link.USING.SINGLE_BATTLE)
Natives.special(ctx, NativesLink.SPECIAL.TryBattleLinkup, adapters)
check(ctx.nativePoll() == false, "a direct cable waits for the peer's pick")
lguest:update(0)
local peerPick = lguest:take(LB.MSG.LINKUP)
eq(peerPick and peerPick.linkType, LB.LINKTYPE.SINGLE_BATTLE,
"and tells the peer this machine picked LINKTYPE_SINGLE_BATTLE")
lguest:send({ type = LB.MSG.LINKUP, linkType = LB.LINKTYPE.MULTI_BATTLE, players = 4 })
lhost:update(0)
ctx.nativePoll()
eq(getVar(Link.VAR_RESULT), Link.LINKUP.DIFF_SELECTIONS, "a different pick over the cable is LINKUP_DIFF_SELECTIONS")
Link.closeLink("test")
freshCtx()
setVar(Link.VAR_0x8004, Link.USING.SINGLE_BATTLE)
connects = 0
Natives.special(ctx, NativesLink.SPECIAL.TryBattleLinkup, adapters)
eq(getVar(Link.VAR_RESULT), Link.LINKUP.ONGOING,
"a torn down link puts the counter back to waiting for a machine")
for _ = 1, LB.LINKUP_TICKS + 1 do
if ctx.nativePoll() then break end
end
eq(getVar(Link.VAR_RESULT), Link.LINKUP.CONNECTION_ERROR,
"and times out to LINKUP_CONNECTION_ERROR")
eq(connects, 1, "a torn down link sends the counter back to the relay")
eq(getVar(Link.VAR_RESULT), Link.LINKUP.CONNECTION_ERROR, "which answers LINKUP_CONNECTION_ERROR when unreachable")
package.loaded["src.online.Connect"] = nil
package.loaded["src.online.ArenaData"] = nil
Link._live = nil
Link.reset()
print("[test] 4. the shared seed drives one RNG stream on both machines")
+184
View File
@@ -0,0 +1,184 @@
#!/usr/bin/env luajit
package.path = "./?.lua;./?/init.lua;" .. package.path
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 eq(a, b, msg)
check(a == b, string.format("%s (%s == %s)", msg, tostring(a), tostring(b)))
end
local session = { name = "RED", gender = 0, trainerId = 0x1234, version = "firered" }
package.loaded["src.core.game3.runtime"] = {
getSession = function() return session end,
_game = { session = session, data = { maps = {} } },
}
local Player = { cellX = 1, cellY = 22, facing = "up" }
package.loaded["src.core.game3.player"] = Player
local Plaza = require("src.core.game3.link.union_plaza_map")
package.loaded["src.core.game3.scripting.space"] = { mapId = Plaza.MAP_ID }
package.loaded["src.core.game3.collision"] = {
_mapId = Plaza.MAP_ID,
inBounds = function(x, y) return x >= 0 and y >= 0 and x < Plaza.WIDTH and y < Plaza.HEIGHT end,
isWater = function() return false end,
canEnter = function(_, x, y) return Plaza.roleAt(x, y) == "." end,
}
local VirtualObjects = require("src.core.game3.virtual_objects")
package.loaded["src.core.game3.objects"] = {
blocks = function(x, y)
for _, vo in ipairs(VirtualObjects.list()) do
if vo.solid and ((vo.x == x and vo.y == y) or (vo.prevX == x and vo.prevY == y)) then return true end
end
return false
end,
}
local Wander = require("src.core.game3.link.plaza_wander")
do
local function trace(seed)
local w = Wander.new(seed, 5, 5)
local env = { canWander = true, free = function() return true end }
local out = {}
for i = 1, 3000 do
Wander.tick(w, env)
out[i] = w.x * 100 + w.y
end
return out, w
end
local a = trace(42)
local b = trace(42)
local same, left, far = true, false, 0
for i = 1, #a do
if a[i] ~= b[i] then same = false end
local x, y = math.floor(a[i] / 100), a[i] % 100
if x ~= 5 or y ~= 5 then left = true end
far = math.max(far, math.abs(x - 5), math.abs(y - 5))
end
check(same, "the same seed walks the same path")
check(left, "an idle avatar leaves its cell now and then")
check(far <= Wander.RADIUS, "and never strays past its radius (" .. far .. ")")
local w = Wander.new(7, 5, 5)
local env = { canWander = true, free = function() return true end }
local guard = 0
while Wander.atHome(w) and guard < 5000 do Wander.tick(w, env); guard = guard + 1 end
check(not Wander.atHome(w), "the avatar starts a walk")
env.canWander = false
for _ = 1, 400 do Wander.tick(w, env) end
check(Wander.atHome(w), "and walks back home when it may not wander")
local still = Wander.new(9, 5, 5)
for _ = 1, 3000 do Wander.tick(still, { canWander = false, free = function() return true end }) end
check(Wander.atHome(still), "a busy avatar never moves")
end
local Union = require("src.core.game3.link.union_room")
Union.graphicsIdFor = function() return 1 end
Union._avatars = { opposite_facing = { 0, 2, 1, 4, 3 }, member_facing = { 1, 3, 1, 4, 2 } }
Union.state = "main"
Union._vobjs = {}
Union.players = {
[1] = { id = "b", name = "BLUE", gender = 0, trainerId = 2, status = "idle", activity = Union.ACTIVITY.NONE + Union.IN_UNION_ROOM },
[2] = { id = "g", name = "GREEN", gender = 1, trainerId = 3, status = "chatting", activity = Union.ACTIVITY.CHAT + Union.IN_UNION_ROOM },
[3] = { id = "y", name = "YELLOW", gender = 1, trainerId = 4, status = "trading", activity = Union.ACTIVITY.TRADE + Union.IN_UNION_ROOM },
[4] = { id = "p", name = "PINK", gender = 1, trainerId = 5, status = "battling", activity = Union.ACTIVITY.BATTLE_SINGLE + Union.IN_UNION_ROOM },
}
Union.refreshPlaza()
eq(Union.memberStatusKind(Union.players[1]), nil, "an idle member shows no status")
eq(Union.memberStatusKind(Union.players[2]), "chat", "chatting shows the chat icon")
eq(Union.memberStatusKind(Union.players[3]), "trade", "trading shows the trade icon")
eq(Union.memberStatusKind(Union.players[4]), "battle", "battling shows the battle icon")
eq(Union.memberStatusKind({ activity = Union.ACTIVITY.CARD + Union.IN_UNION_ROOM }), "chat",
"with no relay status the activity decides")
local hx, hy = Union.cellFor(1)
local bx, by = Union.cellFor(2)
local leftHome, moved2, maxOff = false, false, 0
local awayAt
for _ = 1, 4000 do
Union.animateAll()
local rec = VirtualObjects.get(Union.vobjId(1))
if rec and (rec.x ~= hx or rec.y ~= hy) then
leftHome = true
maxOff = math.max(maxOff, math.abs(rec.x - hx), math.abs(rec.y - hy))
if not rec.moving and not awayAt then awayAt = { rec.x, rec.y } end
end
local rec2 = VirtualObjects.get(Union.vobjId(2))
if rec2 and (rec2.x ~= bx or rec2.y ~= by) then moved2 = true end
if awayAt then break end
end
check(leftHome, "an idle plaza avatar wanders off its cell")
check(maxOff <= 1, "inside its own 3x3 box")
check(not moved2, "a chatting avatar stays put")
if awayAt then
Player.cellX, Player.cellY, Player.facing, Player.moving = awayAt[1], awayAt[2] + 1, "up", false
if Plaza.roleAt(Player.cellX, Player.cellY) ~= "." then
Player.cellY, Player.facing = awayAt[2] - 1, "down"
end
eq(Union.tryInteractWithMember(), 1, "talking reaches the avatar where it stands now")
for _ = 1, 200 do Union.animateAll() end
local rec = VirtualObjects.get(Union.vobjId(1))
check(rec and rec.x == awayAt[1] and rec.y == awayAt[2], "and it holds still while talked to")
Union.updateMemberFacing(1)
Player.cellX, Player.cellY = 1, 22
local home = false
for _ = 1, 4000 do
Union.animateAll()
local r = VirtualObjects.get(Union.vobjId(1))
if r and r.x == hx and r.y == hy and not r.moving then home = true break end
end
check(home, "after the talk it walks back to its own cell")
else
check(false, "the idle avatar never stopped away from home")
end
local LinkTags = require("src.ui.game3.link_tags")
local src = LinkTags.sources()
check(src ~= nil, "the Union Room has tags")
local t1 = src and src.byVobj[Union.vobjId(1)]
eq(t1 and t1.name, "BLUE", "the badge carries the relay avatar name")
eq(t1 and t1.kind, nil, "idle has no icon")
eq(src and src.byVobj[Union.vobjId(2)] and src.byVobj[Union.vobjId(2)].kind, "chat", "chatting badge")
eq(src and src.player and src.player.name, "RED", "the local player gets a badge too")
eq(LinkTags.tagFor(src, { kind = "player" }), src and src.player, "the player actor maps to its badge")
eq(LinkTags.tagFor(src, { kind = "npc", eventObject = { virtualId = Union.vobjId(3) } }), src and src.byVobj[Union.vobjId(3)],
"a plaza avatar maps to its badge")
local measure = function(name) return #name * 6 end
local left = LinkTags.layout({ name = "LONGNAME", kind = "trade" }, 0, 40, 240, measure)
check(left.x >= LinkTags.EDGE, "a badge at the left edge stays on screen")
local right = LinkTags.layout({ name = "LONGNAME", kind = "battle" }, 240, 40, 240, measure)
check(right.x + right.w <= 240 - LinkTags.EDGE, "a badge at the right edge stays on screen")
local plain = LinkTags.layout({ name = "WALLACEMORE" }, 120, 40, 240, function() return 999 end)
eq(plain.textW, LinkTags.MAX_TEXT_W, "long names are clipped to the badge width")
for kind in pairs(LinkTags.ICONS) do
local w, h = LinkTags.iconSize(kind)
check(h <= LinkTags.PLATE_H and w > 0, kind .. " icon fits the badge")
end
local LP = require("src.core.game3.link.link_players")
LP._mapId = "tradeCenter"
LP._remotes[1] = { busy = true, name = "GREEN", x = 4, y = 5 }
LP._remotes[2] = { busy = false, name = "GOLD", x = 7, y = 5 }
eq(LP.statusKind(1), "trade", "a busy player in the Trade Center is trading")
eq(LP.statusKind(2), nil, "an idle one has no icon")
LP._mapId = "colosseum2P"
eq(LP.statusKind(1), "battle", "a busy player in the Colosseum is battling")
src = LinkTags.sources()
eq(src and src.byVobj[LP.VOBJ_BASE + 1] and src.byVobj[LP.VOBJ_BASE + 1].name, "GREEN", "link room avatars get badges")
LP._remotes, LP._mapId = {}, nil
if failed > 0 then
print(("FAIL game3_link_room_presence_test (%d)"):format(failed))
os.exit(1)
end
print("PASS game3_link_room_presence_test")
+262
View File
@@ -0,0 +1,262 @@
#!/usr/bin/env luajit
package.path = "./?.lua;./?/init.lua;" .. package.path
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 eq(a, b, msg)
check(a == b, string.format("%s (%s == %s)", msg, tostring(a), tostring(b)))
end
local FakeRelay = require("tests.g3link_fake_relay")
local store = { flags = {}, vars = {} }
local session = {
version = "ruby", store = store, map = "RS_TEST_POKEMON_CENTER_2F", x = 5, y = 3,
name = "BRENDAN", gender = 0, trainerId = 0x2222,
party = { { species = 280, level = 12 }, { species = 283, level = 9 } },
bag = { pockets = { items = {} } },
}
local game = { data = { maps = {} }, session = session, input = { wasPressed = function() return false end },
save = { player = { name = "BRENDAN" } } }
package.loaded["src.core.game3.runtime"] = {
getSession = function() return session end,
isActive = function() return true end,
_game = game,
}
package.loaded["src.core.game3.player"] = { cellX = 5, cellY = 3, facing = "up" }
package.loaded["src.core.game3.map"] = { load = function() end }
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,
ir = function(key) return { { t = "text", s = key } } end,
translate = function(ir) return ir end,
key = function(n, i, j) return j and (n .. "[" .. i .. "][" .. j .. "]") or (n .. "[" .. i .. "]") end,
at = function(n, i, j) return j and (n .. "[" .. i .. "][" .. j .. "]") or (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 ctx = { specialVars = {}, stringVars = {} }
local adapters = { log = function() end, playSe = function() end }
package.loaded["src.core.game3.scripting.space"] = {
store = store, mapId = session.map, vm = { ctx = ctx, adapters = adapters },
ensureBundle = function() return nil end,
}
package.loaded["src.online.ArenaData"] = {
liveProfile3 = function(_, rulesetId)
return { engine = 3, engineVersion = "1.0.0", fingerprint = "f00dcafe", kind = "vanilla",
version = session.version, rulesetId = rulesetId }
end,
}
local Client = FakeRelay.client({ state = "online", id = "0000beef" })
package.loaded["src.online.Client"] = Client
local Link = require("src.core.game3.link")
local LB = require("src.core.game3.link.battle")
local Flags = require("src.core.game3.scripting.flags")
local Lobby = require("src.ui.game3.rs.cable_lobby")
local Game3Link = require("src.link.Game3Link")
local RsNatives = require("src.core.game3.scripting.natives_link_rs").BY_NAME
local RseNatives = require("src.core.game3.scripting.natives_link_rse").BY_NAME
local ContestNatives = require("src.core.game3.scripting.natives_contest").BY_NAME
local CL = require("src.core.game3.link.contest_link")
local function setVar(id, v) Flags.setVar(store, ctx, id, v) end
local function getVar(id) return tonumber(Flags.getVar(store, ctx, id)) or 0 end
local function pick(id)
for _, row in ipairs(Lobby.rows()) do
if row.id == id and not row.disabled then return Lobby.opts.select(row) end
end
error("no enabled row " .. tostring(id))
end
local function pump(peers)
for _ = 1, 4 do
for _, p in ipairs(peers) do p:update(0) end
Link.update(0)
end
end
local function linkup(version, start, peerVersions, peerLinkType, max)
session.version = version
ctx.specialVars, ctx.nativePoll = {}, nil
Client.clear()
Client._group, Client._room, Client._seat = nil, nil, nil
Lobby.reset()
Link.reset()
Link._live = nil
local yielded = start()
if not yielded then return nil, "did not yield" end
pick("leader")
local members = { { id = "0000beef", name = session.name } }
for i = 1, #peerVersions do members[#members + 1] = { id = string.format("00e0000%d", i), name = "P" .. i } end
Client._group = { leader = "0000beef", min = 2, max = max, members = members, pending = {} }
ctx.nativePoll()
if Client.count("startGroup") == 0 then pick("start") end
local room = FakeRelay.room({ seats = #peerVersions + 1 })
Client.bindRoom(room, 0)
local peers = {}
for i, v in ipairs(peerVersions) do
local hello = Game3Link.hello(game, peerLinkType, { version = v, name = v:upper() .. i, trainerId = 0x3000 + i })
peers[i] = Game3Link.attach(FakeRelay.transport(room, i), {
game = game, linkType = peerLinkType, seat = i, seats = room.seats, hello = hello,
})
end
ctx.nativePoll()
pump(peers)
for _ = 1, LB.LINKUP_TICKS + 5 do
if ctx.nativePoll() then break end
end
return getVar(Link.VAR_RESULT), peers
end
local LT = Game3Link.LINKTYPE
print("[test] 1. trade: Ruby cable vs Emerald, both directions")
local code = linkup("ruby", function() return RsNatives.sub_80834E4(ctx, adapters) end,
{ "emerald" }, LT.TRADE_SETUP, 2)
eq(code, Link.LINKUP.SUCCESS, "Ruby's Trade Center links with an Emerald trader")
code = linkup("emerald", function() return RseNatives.TryTradeLinkup(ctx, adapters) end,
{ "sapphire" }, LT.TRADE_SETUP, 2)
eq(code, Link.LINKUP.SUCCESS, "Emerald's cable Trade Center links with a Sapphire trader")
print("[test] 2. battles: Ruby cable vs Emerald and FireRed wireless players")
code = linkup("ruby", function() setVar(0x8004, 1); return RsNatives.sub_808347C(ctx, adapters) end, { "emerald" }, LT.BATTLE, 2)
eq(code, Link.LINKUP.SUCCESS, "single battle with an Emerald Direct Corner player")
code = linkup("sapphire", function() setVar(0x8004, 2); return RsNatives.sub_808347C(ctx, adapters) end, { "firered" }, LT.BATTLE, 2)
eq(code, Link.LINKUP.SUCCESS, "double battle with a FireRed Direct Corner player")
code = linkup("ruby", function() setVar(0x8004, 5); return RsNatives.sub_808347C(ctx, adapters) end,
{ "emerald", "leafgreen", "sapphire" }, LT.BATTLE, 4)
eq(code, Link.LINKUP.SUCCESS, "multi battle with Emerald, LeafGreen and Sapphire")
code = linkup("emerald", function() setVar(0x8004, 1); return RseNatives.TryBattleLinkup(ctx, adapters) end, { "ruby" }, LT.SINGLE_BATTLE, 2)
eq(code, Link.LINKUP.SUCCESS, "Emerald's cable colosseum with a Ruby cable player")
code = linkup("emerald", function() setVar(0x8004, 1); return RseNatives.TryBattleLinkup(ctx, adapters) end, { "ruby" }, LT.DOUBLE_BATTLE, 2)
eq(code, Link.LINKUP.DIFF_SELECTIONS, "a single vs double cable pick is still LINKUP_DIFF_SELECTIONS")
print("[test] 3. record corner: Ruby with Emerald, three players")
code = linkup("ruby", function() return RsNatives.sub_808350C(ctx, adapters) end,
{ "emerald", "sapphire" }, LT.RECORD_MIX_BEFORE, 4)
eq(code, Link.LINKUP.SUCCESS, "Ruby's Record Corner links three players")
local players = Link.link and Link.link:players() or {}
eq(#players, 3, "all three seats are linked")
eq(players[2] and players[2].version, "emerald", "seat 1 is the Emerald player")
check(require("src.core.game3.link.rs_record_cross").anyRS(players), "Emerald sees an RS partner and uses the RS layout")
print("[test] 4. berry blender: Ruby and Emerald blend in step")
local blendPeers
code, blendPeers = linkup("ruby", function() return RsNatives.sub_8083614(ctx, adapters) end,
{ "emerald" }, LT.BERRY_BLENDER_SETUP, 4)
eq(code, Link.LINKUP.SUCCESS, "Ruby's blender links with an Emerald blender")
eq(Link.link and Link.link.linkType, LT.BERRY_BLENDER_SETUP, "the link runs as LINKTYPE_BERRY_BLENDER_SETUP")
local Blend = require("src.core.game3.rse.berry_blender_link")
local mine = Blend.new(Link.link, Link.link:players())
local theirs = Blend.new(blendPeers[1], blendPeers[1]:players())
eq(mine.rngState, theirs.rngState, "both machines seed the same blender RNG")
local a, b
for _ = 1, 8 do
a = a or mine:submitBerry(133)
b = b or theirs:submitBerry(140)
pump(blendPeers)
end
eq(a and a[1], 140, "Ruby sees the Emerald berry")
eq(b and b[0], 133, "Emerald sees the Ruby berry")
local fa, fb
for _ = 1, 8 do
fa = fa or mine:exchangeFrame(1, 2)
fb = fb or theirs:exchangeFrame(1, 3)
pump(blendPeers)
end
eq(fa and fa[1], 3, "the per-frame scores cross over")
eq(fb and fb[0], 2, "both ways")
print("[test] 5. contests: Ruby with three Emerald players")
code = linkup("ruby", function() setVar(0x8011, 0); return RsNatives.sub_808363C(ctx, adapters) end,
{ "emerald", "emerald", "emerald" }, 0x6602, 4)
eq(code, Link.LINKUP.SUCCESS, "Ruby's COOL contest links with three Emerald wireless contestants")
local flags = CL.flagsFor(Link.link, false)
eq(flags, CL.FLAG.IS_LINK + CL.FLAG.HAS_RS_PLAYER, "the contest is flagged HAS_RS_PLAYER")
code = linkup("emerald", function() setVar(0x8011, 0); return ContestNatives.TryContestEModeLinkup(ctx, adapters) end,
{ "ruby" }, 0x6601, 4)
eq(code, Link.LINKUP.SUCCESS, "Emerald's cable contest links with a Ruby contestant")
eq(CL.flagsFor(Link.link, true), CL.FLAG.IS_LINK + CL.FLAG.IS_WIRELESS + CL.FLAG.HAS_RS_PLAYER,
"and a wireless Emerald contest with a Ruby player skips the standby too")
session.version = "ruby"
ctx.specialVars, ctx.nativePoll = {}, nil
Client.clear()
Client._group, Client._room, Client._seat = nil, nil, nil
Lobby.reset()
Link.reset()
setVar(0x8011, 0)
RsNatives.sub_808363C(ctx, adapters)
pick("join")
Client._groups = { { leader = "00e00009", name = "MAY", avatar = { name = "MAY" }, joined = 1, max = 4 } }
pick("00e00009")
eq(Client.last("joinGroup") and Client.last("joinGroup")[1], "00e00009", "Ruby joins an Emerald wireless contest group")
local room2 = FakeRelay.room({ seats = 2 })
Client.bindRoom(room2, 1)
local lead = Game3Link.attach(FakeRelay.transport(room2, 0), { game = game, linkType = 0x6602, seat = 0, seats = 2,
hello = Game3Link.hello(game, 0x6602, { version = "emerald", name = "MAY", trainerId = 0x4444 }) })
ctx.nativePoll()
pump({ lead })
for _ = 1, LB.LINKUP_TICKS + 5 do
if ctx.nativePoll() then break end
end
eq(getVar(Link.VAR_RESULT), Link.LINKUP.WRONG_NUM_PLAYERS,
"a two player contest started by Emerald is LINKUP_WRONG_NUM_PLAYERS on Ruby")
Link.reset()
Lobby.reset()
print("[test] 6. record mixing data crosses Ruby and Emerald")
local function cacheRoot(version)
local env = os.getenv("POKEPORT_" .. version:upper() .. "_CACHE")
if env and env ~= "" then return env end
local identity, home = os.getenv("POKEPORT_" .. version:upper() .. "_IDENTITY") or os.getenv("POKEPORT_IDENTITY"), os.getenv("HOME")
if not (identity and identity ~= "" and home) then return nil end
for _, base in ipairs({ home .. "/Library/Application Support/LOVE/", home .. "/.local/share/love/" }) do
local root = base .. identity .. "/" .. version .. "/data/generated/gba"
local f = io.open(root .. "/meta.json", "rb")
if f then f:close(); return root end
end
return nil
end
local rubyRoot, emeraldRoot = cacheRoot("ruby"), cacheRoot("emerald")
if not (rubyRoot and emeraldRoot) then
print("[skip] 6. needs a Ruby and an Emerald cache (POKEPORT_RUBY_CACHE / POKEPORT_EMERALD_CACHE)")
else
local tmp = os.tmpname()
local function side(version, root, seat, out, inp)
local cmd = string.format("POKEPORT_GBA_CACHE=%q luajit tests/g3link_rs_mix_side.lua %s %d %q %s 2>&1",
root, version, seat, out, inp and string.format("%q", inp) or "")
local p = io.popen(cmd)
local text = p:read("*a")
p:close()
local out2 = {}
for k, v in text:gmatch("\n?(%w+) ([^\n]*)") do out2[k] = v end
return out2, text
end
local rubyOut, emOut = tmp .. ".ruby", tmp .. ".em"
local r1 = side("ruby", rubyRoot, 1, rubyOut)
local e = side("emerald", emeraldRoot, 0, emOut, rubyOut)
local r = side("ruby", rubyRoot, 1, rubyOut, emOut)
eq(e.layout, "rs", "Emerald sends the RS record layout to a Ruby partner")
check((tonumber(e.bytes) or 1e9) < 65536, "and the packet fits the relay's rse_record_mix cap (" .. tostring(e.bytes) .. ")")
check((tonumber(r1.bytes) or 1e9) < 65536, "Ruby's packet fits too (" .. tostring(r1.bytes) .. ")")
eq(e.unsupported, "nil", "Emerald takes Ruby's records")
eq(e.secretBases, "true", "Emerald mixes the secret bases")
eq(e.tvShows, "true", "and the TV shows")
eq(r.unsupported, "nil", "Ruby takes Emerald's records")
eq(r.oldMan, "true", "Ruby swaps the old man")
eq(r.daycareMail, "true", "and the daycare mail")
os.remove(tmp)
os.remove(rubyOut)
os.remove(emOut)
end
print(string.format("%s game3_link_rs_cross_test", failed == 0 and "PASS" or ("FAIL " .. failed)))
os.exit(failed == 0 and 0 or 1)
+304
View File
@@ -0,0 +1,304 @@
#!/usr/bin/env luajit
package.path = "./?.lua;./?/init.lua;" .. package.path
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 eq(a, b, msg)
check(a == b, string.format("%s (%s == %s)", msg, tostring(a), tostring(b)))
end
local FakeRelay = require("tests.g3link_fake_relay")
local store = { flags = {}, vars = {} }
local session = {
version = "ruby", store = store, map = "RS_OLDALE_TOWN_POKEMON_CENTER_2F", x = 5, y = 3,
name = "BRENDAN", gender = 0, trainerId = 0x2222,
party = { { species = 280, level = 12 }, { species = 283, level = 9 } },
bag = { pockets = { items = {} } },
}
local input = { pressed = {} }
function input:wasPressed(k) return self.pressed[k] == true end
function input:isDown(k) return self.pressed[k] == true end
local game = { data = { maps = {} }, session = session, input = input, save = { player = { name = "BRENDAN" } } }
package.loaded["src.core.game3.runtime"] = {
getSession = function() return session end,
isActive = function() return true end,
_game = game,
}
package.loaded["src.core.game3.player"] = { cellX = 5, cellY = 3, facing = "up" }
package.loaded["src.core.game3.map"] = { load = function() end }
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,
ir = function(key) return { { t = "text", s = key } } end,
translate = function(ir) return ir end,
key = function(n, i, j) return j and (n .. "[" .. i .. "][" .. j .. "]") or (n .. "[" .. i .. "]") end,
at = function(n, i, j) return j and (n .. "[" .. i .. "][" .. j .. "]") or (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 ctx = { specialVars = {}, stringVars = {} }
local adapters = { log = function() end, playSe = function() end }
package.loaded["src.core.game3.scripting.space"] = {
store = store, mapId = session.map, vm = { ctx = ctx, adapters = adapters },
ensureBundle = function() return nil end,
}
local LIVE = { engine = 3, engineVersion = "1.0.0", fingerprint = "f00dcafe", kind = "vanilla" }
package.loaded["src.online.ArenaData"] = {
liveProfile3 = function(_, rulesetId)
local p = { version = session.version }
for k, v in pairs(LIVE) do p[k] = v end
p.rulesetId = rulesetId
return p
end,
}
local Client = FakeRelay.client({ state = "online", id = "0000beef" })
package.loaded["src.online.Client"] = Client
local Link = require("src.core.game3.link")
local LB = require("src.core.game3.link.battle")
local Union = require("src.core.game3.link.union_room")
local Flags = require("src.core.game3.scripting.flags")
local Lobby = require("src.ui.game3.rs.cable_lobby")
local Game3Link = require("src.link.Game3Link")
local NativesLinkRs = require("src.core.game3.scripting.natives_link_rs")
local function getVar(id) return tonumber(Flags.getVar(store, ctx, id)) or 0 end
local function setVar(id, v) Flags.setVar(store, ctx, id, v) end
local function fresh()
ctx.specialVars = {}
ctx.nativePoll, ctx.mode, ctx.status = nil, nil, nil
Client.clear()
Client._group, Client._groups, Client._directEntries = nil, {}, {}
Client._room, Client._seat = nil, nil
Lobby.reset()
Link.reset()
Link._live = nil
end
local function rowIds()
local out = {}
for i, row in ipairs(Lobby.rows()) do out[i] = row.id end
return out
end
local function pick(id)
for _, row in ipairs(Lobby.rows()) do
if row.id == id then return Lobby.opts.select(row) end
end
error("no row " .. tostring(id))
end
local function poll(n)
local r
for _ = 1, n or 1 do
r = ctx.nativePoll()
if r then break end
end
return r
end
local function me(id) return { id = id or "0000beef", name = "BRENDAN", avatar = { name = "BRENDAN" } } end
local function peerOn(room, seat, version, linkType)
local hello = Game3Link.hello(game, linkType, { version = version, name = version:upper(), trainerId = 0x1000 + seat })
return Game3Link.attach(FakeRelay.transport(room, seat), {
game = game, linkType = linkType, seat = seat, seats = room.seats, hello = hello,
})
end
local function pump(peers)
for _ = 1, 4 do
for _, p in ipairs(peers) do p:update(0) end
Link.update(0)
end
end
print("[test] 1. a Ruby cable leader auto-accepts and starts a two-seat group")
fresh()
setVar(0x8004, 1)
local yielded = NativesLinkRs.BY_NAME.sub_808347C(ctx, adapters)
check(yielded, "sub_808347C parks the script")
check(Lobby.isOpen(), "the cable lobby is up")
eq(table.concat(rowIds(), ","), "leader,join", "CREATE GROUP / JOIN GROUP")
pick("leader")
local og = Client.last("openGroup")
eq(og and og[1], "battle_single", "the leader opens a battle_single group")
eq(og and og[2] and og[2].rulesetId, "g3_single", "with the battle ruleset profile")
Client._group = { leader = "0000beef", min = 2, max = 2, members = { me() },
pending = { { id = "00e00001", name = "MAY", avatar = { name = "MAY" } } } }
poll()
local acc = Client.last("acceptGroup")
eq(acc and acc[1], "00e00001", "the first group_request is accepted without a prompt")
eq(acc and acc[2], true, "with ok = true")
Client._group = { leader = "0000beef", min = 2, max = 2, members = { me(), { id = "00e00001", name = "MAY" } }, pending = {} }
poll()
eq(Client.count("startGroup"), 1, "a full two-seat group starts by itself")
print("[test] 2. an Emerald wireless peer seated into the group links up with no linkup message")
local room = FakeRelay.room({ seats = 2 })
Client.bindRoom(room, 0)
local peer = peerOn(room, 1, "emerald", Game3Link.LINKTYPE.BATTLE)
check(poll() == false, "the match opens the relay link")
check(Link.link ~= nil, "Link.openRelay ran")
pump({ peer })
check(Link.link and Link.link:isReady(), "the hellos agree")
local done = poll(LB.LINKUP_TICKS + 5)
check(done == true, "the linkup finishes")
eq(getVar(Link.VAR_RESULT), Link.LINKUP.SUCCESS, "Ruby vs an Emerald wireless player is LINKUP_SUCCESS")
eq(LB.state, "seat", "and the colosseum seat wait begins")
eq(Link.link and Link.link.linkType, 0x2233, "the link runs as LINKTYPE_SINGLE_BATTLE")
check(not Lobby.isOpen(), "the lobby closed")
print("[test] 3. a Ruby joiner sees Direct Corner hosts next to cable groups")
fresh()
setVar(0x8004, 2)
NativesLinkRs.BY_NAME.sub_808347C(ctx, adapters)
pick("join")
eq(Client.last("groupList") and Client.last("groupList")[1], "battle_double", "it watches battle_double groups")
eq(Client.last("directList") and Client.last("directList")[1], "battle_double", "and the Direct Corner list")
Client._groups = { { leader = "00c00001", name = "RED", avatar = { name = "RED" }, joined = 1, max = 2 } }
Client._directEntries.battle_double = {
{ kind = "room", room = "r00000000000000aa", host = "00d00001", name = "LEAF", avatar = { name = "LEAF" } },
{ kind = "room", room = "r00000000000000bb", host = "00d00002", name = "PIN", locked = true },
{ kind = "player", id = "00d00003", name = "AUTO" },
}
local rows = Lobby.rows()
eq(#rows, 3, "one group and two hosted rooms (queued players are not joinable)")
eq(rows[2].kind, "room", "the Direct Corner host is listed")
eq(rows[3].disabled, true, "a PIN room is not joinable from the cable")
pick("r00000000000000aa")
local jr = Client.last("joinRoom")
eq(jr and jr[1], "r00000000000000aa", "room_join to the Direct Corner host")
eq(jr and jr[2], "player", "as a player")
Lobby.opts.cancel()
eq(Client.count("leaveRoom"), 1, "B while joining leaves the room")
check(Lobby.isOpen(), "and goes back to the list")
print("[test] 4. FireRed's cable counter falls back to the relay instead of timing out")
fresh()
session.version = "firered"
local NativesLink = require("src.core.game3.scripting.natives_link")
setVar(0x8004, Link.USING.TRADE_CENTER)
yielded = NativesLink.BY_NAME.TryTradeLinkup(ctx, adapters)
check(yielded, "TryTradeLinkup parks the script")
check(Lobby.isOpen(), "with the cable lobby, not a dead wait")
for _ = 1, LB.LINKUP_TICKS + 5 do ctx.nativePoll() end
eq(getVar(Link.VAR_RESULT), Link.LINKUP.ONGOING, "and it does not time out to CONNECTION_ERROR")
pick("leader")
eq(Client.last("openGroup") and Client.last("openGroup")[1], "trade", "the trade group")
Lobby.opts.cancel()
eq(getVar(Link.VAR_RESULT), Link.LINKUP.FAILED, "B is LINKUP_FAILED")
eq(Client.count("leaveGroup"), 1, "and leaves the group")
print("[test] 5. Emerald's cable counters pick the matching relay activity")
session.version = "emerald"
local cases = {
{ "TryBattleLinkup", Link.USING.SINGLE_BATTLE, "battle_single" },
{ "TryBattleLinkup", Link.USING.MULTI_BATTLE, "battle_multi" },
{ "TryBattleLinkup", Link.USING.BATTLE_TOWER, "battle_tower" },
{ "TryRecordMixLinkup", 0, "record_corner" },
}
local NativesLinkRse = require("src.core.game3.scripting.natives_link_rse")
for _, c in ipairs(cases) do
fresh()
setVar(0x8004, c[2])
NativesLinkRse.BY_NAME[c[1]](ctx, adapters)
pick("leader")
eq(Client.last("openGroup") and Client.last("openGroup")[1], c[3], c[1] .. " " .. c[2] .. " opens " .. c[3])
end
fresh()
NativesLinkRse.BY_NAME.TryBerryBlenderLinkup(ctx, adapters)
pick("leader")
eq(Client.last("openGroup") and Client.last("openGroup")[1], "berry_blender", "TryBerryBlenderLinkup opens berry_blender")
fresh()
setVar(0x8011, 2)
local NativesContest = require("src.core.game3.scripting.natives_contest")
NativesContest.BY_NAME.TryContestEModeLinkup(ctx, adapters)
pick("leader")
eq(Client.last("openGroup") and Client.last("openGroup")[1], "contest_cute", "a CUTE link contest opens contest_cute")
eq(LB.towerSpec().linkType, 0x2266, "a level 50 tower challenge links as LINKTYPE_BATTLE_TOWER_50")
print("[test] 6. a 2..4 group still asks the leader")
fresh()
setVar(0x8004, 0)
NativesLinkRse.BY_NAME.TryRecordMixLinkup(ctx, adapters)
pick("leader")
Client._group = { leader = "0000beef", min = 2, max = 4, members = { me() },
pending = { { id = "00e00002", name = "WALLY", avatar = { name = "WALLY" } } } }
poll()
eq(table.concat(rowIds(), ","), "yes,no", "ADD WALLY? YES / NO")
eq(Client.count("acceptGroup"), 0, "nothing accepted yet")
pick("yes")
eq(Client.last("acceptGroup") and Client.last("acceptGroup")[2], true, "YES accepts")
print("[test] 7. the activity names follow each game's sLinkGroupActivityNameTexts")
session.version = "emerald"
eq(Union.ACTIVITY_NAMES[15], "sLinkGroupActivityNameTexts[15]", "Emerald RECORD CORNER")
eq(Union.ACTIVITY_NAMES[23], "sLinkGroupActivityNameTexts[23]", "Emerald COOL CONTEST")
eq(Union.ACTIVITY_NAMES[28], "sLinkGroupActivityNameTexts[28]", "Emerald BATTLE TOWER LV. 50")
eq(Union.ACTIVITY_NAMES[13], nil, "Emerald SPIN TRADE is an empty string")
eq(Union.ACTIVITY_NAMES[3], "sLinkGroupActivityNameTexts[3]", "MULTI BATTLE")
session.version = "firered"
eq(Union.ACTIVITY_NAMES[13], "sLinkGroupActivityNameTexts[13]", "FireRed SPIN TRADE")
eq(Union.ACTIVITY_NAMES[15], nil, "FireRed has no RECORD CORNER name")
eq(Union.ACTIVITY_NAMES[10], "sLinkGroupActivityNameTexts[10]", "BERRY CRUSH")
print("[test] 8. the Direct Corner CHOOSE list shows open cable groups")
Client._directEntries.trade = {
{ kind = "group", leader = "00f00001", name = "BRENDAN", avatar = { name = "BRENDAN", version = "ruby" }, joined = 1, max = 2 },
{ kind = "group", leader = "00f00002", name = "FULL", avatar = { name = "FULL" }, joined = 2, max = 2 },
}
local drows = Union.directRows("trade")
eq(#drows, 1, "the open group is listed, the full one is not")
eq(drows[1] and drows[1].kind, "group", "as a group row")
eq(drows[1] and drows[1].leader, "00f00001", "with its leader id")
eq(drows[1] and drows[1].version, "ruby", "and the Ruby version for the trade check")
print("[test] 9. record mixing packs byte runs so a full Emerald packet fits the relay cap")
local RecordMix = require("src.core.game3.link.record_mix")
local Json = require("src.link.Json")
local Wire = require("src.link.Wire")
local raw = {}
for i = 1, 160 do raw[i] = (i * 37) % 256 end
local packet = { secretBases = { { trainerName = "MAY", _recordMixNativeBytes = raw } }, tvShows = { [0] = { kind = 1 } } }
local msg = Wire.sanitize(Json.decode(Json.encode({ type = RecordMix.MSG.PACKET, spot = 1, packet = RecordMix.toWire(packet) })))
local back = RecordMix.fromWire(msg.packet)
local same = true
for i = 1, 160 do if back.secretBases[1]._recordMixNativeBytes[i] ~= raw[i] then same = false end end
check(same, "160 native bytes survive the wire")
eq(back.tvShows[0].kind, 1, "zero-indexed rows still round trip")
check(#Json.encode(RecordMix.toWire(packet)) < 700, "and the byte run travels as one hex string")
print("[test] 10. a contest with a Ruby player is flagged and skips the wireless standby")
local CL = require("src.core.game3.link.contest_link")
local fakeLink = {
isOpen = function() return true end,
players = function() return { { seat = 0, version = "emerald" }, { seat = 1, version = "ruby" } } end,
send = function(self, m) self.sent = (self.sent or 0) + 1 end,
take = function() return nil end,
getSeat = function() return 0 end,
}
local flags = CL.flagsFor(fakeLink, true)
eq(flags, CL.FLAG.IS_LINK + CL.FLAG.IS_WIRELESS + CL.FLAG.HAS_RS_PLAYER, "IS_LINK | IS_WIRELESS | HAS_RS_PLAYER")
local s = CL.newSession(fakeLink, { flags = flags })
eq(s:standby(), true, "LinkContest_TryLinkStandby skips the standby with an RS player")
eq(fakeLink.sent, nil, "without sending a standby block")
CL.active = s
NativesContest.linkFlags = flags
eq(select(2, NativesContest.BY_NAME.IsContestWithRSPlayer()), 1, "IsContestWithRSPlayer answers TRUE")
eq(select(1, NativesContest.linkContestWaitForConnection(ctx, adapters)), false,
"LinkContestWaitForConnection does not park the script for an RS contest")
eq(CL.flagsFor({ players = function() return { { seat = 0, version = "emerald" } } end }, false), CL.FLAG.IS_LINK,
"an all-Emerald cable contest is just IS_LINK")
CL.reset()
NativesContest.linkFlags = 0
Link.reset()
Lobby.reset()
print(string.format("%s game3_link_rs_entry_test", failed == 0 and "PASS" or ("FAIL " .. failed)))
os.exit(failed == 0 and 0 or 1)
+121
View File
@@ -0,0 +1,121 @@
#!/usr/bin/env luajit
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
if not _G.love then _G.love = require("tests.love_stub") end
local Cache = require("tests.game3_cache")
if not Cache.mount("meta.json") then
print("[skip] game3_link_translation_mods_test: " .. tostring(Cache.reason))
os.exit(0)
end
local files = {}
local memfs = {
getInfo = function(name) return files[name] and { type = "file" } or nil end,
read = function(name) return files[name] end,
write = function(name, body) files[name] = body return true end,
remove = function(name) files[name] = nil return true end,
createDirectory = function() return true end,
getDirectoryItems = function() return {} end,
}
local SaveData = require("src.core.SaveData")
SaveData.portableFs = function() return memfs end
require("src.core.GameVersion").set("firered")
local ArenaData = require("src.online.ArenaData")
local Handshake = require("src.link.Handshake")
local Fingerprint = require("src.link.Fingerprint")
local Protocol = require("src.link.Protocol")
local Pokemon = require("src.core.game3.pokemon")
Pokemon.install(nil)
local vanilla = ArenaData.liveProfile3({ data = { generation = 3 }, version = "firered" })
T.check(vanilla ~= nil, "a vanilla FireRed has a live profile")
local function ops(list)
local out = {}
for _, row in ipairs(list) do
out[row.registry] = out[row.registry] or { ops = {} }
local reg = out[row.registry].ops
reg[row.id] = reg[row.id] or {}
local l = reg[row.id]
l[#l + 1] = { op = row.op, value = row.value, owner = row.owner }
end
return out
end
local function loader(id, content, extra)
extra = extra or {}
return {
mods = { [id] = { manifest = { id = id, permissions = extra.permissions or {} } } },
content = content,
hooks = { chains = extra.chains or {} },
events = { listeners = {} },
migrations = {}, modInput = {}, stepsQueues = {},
status = function()
return { loaded = { { id = id, version = "1.0", affects_link = extra.affectsLink == true } } }
end,
}
end
local function game(mods)
return { data = { generation = 3 }, version = "firered", mods = mods }
end
local translation = loader("espanol", ops({
{ registry = "rom_text", id = "gText_UR_EggTrade", op = "override", value = "HUEVO", owner = "espanol" },
{ registry = "strings", id = "Connect to the Wireless Club?", op = "override", value = "Conectar?", owner = "espanol" },
{ registry = "pokemon", id = "BULBASAUR", op = "patch", value = { name = "BULBIZARRE" }, owner = "espanol" },
{ registry = "moves", id = "TACKLE", op = "patch", value = { name = "PLACAJE" }, owner = "espanol" },
{ registry = "items", id = "POTION", op = "patch", value = { name = "POCION", description = "Cura 20 PS." }, owner = "espanol" },
}))
T.eq(ArenaData.modTouchesGameplay(translation, "espanol"), false, "names and text are cosmetic")
local live, why = ArenaData.liveProfile3(game(translation))
T.check(live ~= nil, "a translation mod keeps the game online: " .. tostring(why))
T.eq(live and live.fingerprint, vanilla and vanilla.fingerprint, "and leaves the dataset fingerprint alone")
local rebalance = loader("rebalance", ops({
{ registry = "pokemon", id = "BULBASAUR", op = "patch", value = { name = "BULBA", baseStats = { hp = 99 } }, owner = "rebalance" },
}))
T.eq(ArenaData.modTouchesGameplay(rebalance, "rebalance"), true, "a stat change is gameplay")
local none, noneWhy = ArenaData.liveProfile3(game(rebalance))
T.eq(none, nil, "a gameplay mod still blocks online")
T.eq(noneWhy, "mods", "with the mods reason")
local encounters = loader("wild", ops({
{ registry = "encounters", id = "ROUTE1", op = "override", value = {}, owner = "wild" },
}))
T.eq(select(2, ArenaData.liveProfile3(game(encounters))), "mods", "an encounter table is gameplay too")
local hooked = loader("hooky", ops({
{ registry = "strings", id = "Hi", op = "override", value = "Hola", owner = "hooky" },
}), { chains = { ["battle.damage"] = { { owner = "hooky" } } } })
T.eq(select(2, ArenaData.liveProfile3(game(hooked))), "mods", "a text mod that also hooks code blocks")
local permitted = loader("net", ops({}), { permissions = { "network" } })
T.eq(select(2, ArenaData.liveProfile3(game(permitted))), "mods", "a mod asking for permissions blocks")
local claimed = loader("overhaul", ops({}), { affectsLink = true })
T.eq(select(2, ArenaData.liveProfile3(game(claimed))), "mods", "a mod that says it affects link blocks")
local data = { generation = 3 }
local hello = Handshake.hello({ data = data, mods = translation }, nil)
ArenaData.stripCosmeticMods(hello, { data = data, mods = translation })
T.eq(hello.linkModified, false, "the room hello does not flag a translation as link-modified")
T.eq(#hello.mods, 0, "and lists no link mods")
T.eq(hello.fingerprint, Fingerprint.compute(data, {}, 3), "and hashes like vanilla")
local plainHello = Handshake.hello({ data = data }, nil)
T.eq(Handshake.checkCompat(hello, plainHello), "full", "so it pairs fully with a vanilla game")
local gameplayHello = Handshake.hello({ data = data, mods = rebalance }, nil)
ArenaData.stripCosmeticMods(gameplayHello, { data = data, mods = rebalance })
T.eq(gameplayHello.linkModified, true, "a gameplay mod still flags the hello")
local mon = { species = 1, nickname = "", level = 5, personality = 1, otId = 1, otName = "RED" }
local packed = Protocol.packMon3(mon)
T.eq(packed.species, 1, "a traded mon travels as its species id")
T.eq(packed.nickname, "", "an unnamed mon sends no species name text")
T.finish()
File diff suppressed because one or more lines are too long
+256
View File
@@ -0,0 +1,256 @@
#!/usr/bin/env python3
import json
import os
import re
import struct
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
DEV = os.path.abspath(os.path.join(HERE, "..", ".."))
PKHEX = os.environ.get("PKHEX", os.path.join(DEV, "pkhex-master", "PKHeX.Core"))
SERVER = os.environ.get("POKESERVER", os.path.join(DEV, "pokeserver"))
WC3 = "Legality/Encounters/Data/Gen3/EncountersWC3.cs"
RSE = "Legality/Encounters/Data/Gen3/Encounters3RSE.cs"
NY = "Legality/Encounters/Templates/Gen3/Gifts/Distribution3NY.cs"
NY_PKL = "Resources/legality/wild/Gen3/encounter_pcny.pkl"
JPN = "Legality/Encounters/Templates/Gen3/Gifts/Distribution3JPN.cs"
LANG = {"Japanese": 1, "English": 2, "French": 3, "Italian": 4, "German": 5, "Spanish": 7}
LANG_FILE = {1: "ja", 3: "fr", 4: "it", 5: "de", 7: "es"}
GAME_ID = {"S": 1, "R": 2, "E": 3, "FR": 4, "LG": 5}
ALL = ["firered", "leafgreen", "ruby", "sapphire", "emerald"]
GAMES = {"R": ALL, "S": ALL, "RS": ALL, "Gen3": ALL, "FRLG": ["firered", "leafgreen"],
"EFL": ["firered", "leafgreen", "emerald"]}
GENDER = {"Only0": 0, "RandD3_0": 0, "Only1": 1, "RandD3_1": 1, "Recipient": None}
CONSTS = {"PCJPEggTrainerName": "オヤNAME", "M2WishEggOT": ""}
OWNER = {("Encounters3RSE", 251): {"otId": 31121, "secretIdZero": True,
"note": "owner: SID 0; PKHeX leaves the SID unspecified"}}
def read(rel):
with open(os.path.join(PKHEX, rel), encoding="utf-8-sig") as f:
return f.read().split("\n")
CATALOG = json.load(open(os.path.join(SERVER, "gifts_catalog.json"), encoding="utf-8"))
INTERNAL = {s["dex"]: s["id"] for s in CATALOG["species"]}
NAME = {s["dex"]: s["name"] for s in CATALOG["species"]}
CHARSET = set(CATALOG["charset"])
SPECIES_TEXT = {}
def species_name(dex, lang):
if lang not in LANG_FILE:
return None
code = LANG_FILE[lang]
if code not in SPECIES_TEXT:
path = os.path.join(PKHEX, "Resources/text/other/%s/text_Species_%s.txt" % (code, code))
SPECIES_TEXT[code] = open(path, encoding="utf-8-sig").read().split("\n")
name = SPECIES_TEXT[code][dex].strip()
if lang != 1:
name = name.upper()
if not all(ch in CHARSET for ch in name):
return None
return name[:10]
def split_top(s):
out, depth, cur = [], 0, ""
for ch in s:
if ch in "([":
depth += 1
elif ch in ")]":
depth -= 1
if ch == "," and depth == 0:
out.append(cur.strip())
cur = ""
else:
cur += ch
if cur.strip():
out.append(cur.strip())
return out
def value(v):
v = v.strip()
if v.startswith('"'):
return v.strip('"')
if v in CONSTS:
return CONSTS[v]
if v.startswith("(int)"):
return LANG[v[5:]]
if re.match(r"^\d+$", v):
return int(v, 10)
return v
def entry(rel, line_no, line):
m = re.match(r"\s*new\((.*?)\)\s*\{(.*)\},?\s*(//\s*(.*))?$", line)
if not m:
return None
args = split_top(m.group(1))
props = {}
for part in split_top(m.group(2)):
if "=" in part:
k, v = part.split("=", 1)
props[k.strip()] = v.strip()
dex, level, version = int(args[0]), int(args[1]), args[2]
egg = len(args) > 3 and args[3] == "true"
met = None
for a in args[3:]:
if a.startswith("met:"):
met = int(a[4:])
elif re.match(r"^\d+$", a):
met = int(a)
moves = []
if "Moves" in props:
moves = [int(x) for x in re.findall(r"\d+", props["Moves"])]
ev = {
"source": "%s:%d" % (rel, line_no),
"comment": (m.group(4) or "").strip(),
"dex": dex, "species": INTERNAL[dex], "level": level, "egg": egg, "version": version,
"moves": [x for x in moves if x],
"ot": value(props.get("OriginalTrainerName", '""')),
"language": value(props["Language"]) if "Language" in props else None,
"shiny": {"Never": "never", "Always": "always"}.get(props.get("Shiny", "Random"), "random"),
"fateful": props.get("FatefulEncounter") == "true",
"nationalRibbon": props.get("RibbonNational") == "true",
"metLevel": met if met is not None else (0 if egg else level),
}
gender = props.get("OriginalTrainerGender")
ev["otGender"] = GENDER[gender] if gender in GENDER else (None if gender is None else 2)
if "ID32" in props:
id32 = int(props["ID32"], 10)
ev["otId"], ev["secretIdRandom"] = id32, False
elif "TID16" in props:
ev["otId"], ev["secretIdRandom"] = int(props["TID16"], 10), True
return ev
def wc3():
out, section = [], None
lines = read(WC3)
for i, line in enumerate(lines, 1):
s = re.match(r"\s*private static readonly EncounterGift3\[\] (\w+)", line)
if s:
section = s.group(1)
ev = entry(WC3, i, line)
if ev:
ev["group"] = section
if ev["language"] is None and not ev["egg"]:
ev["language"] = 2
if ev["language"] is None and any(ord(ch) > 0x7F for ch in ev["ot"]):
ev["language"] = 1
out.append(ev)
return out
def colo():
out = []
lines = read(RSE)
names = {}
for line in lines:
m = re.match(r'\s*private static readonly string\[\] (\w+) = \[(.*)\];', line)
if m:
names[m.group(1)] = [x.strip().strip('"') for x in m.group(2).split(",")]
for i, line in enumerate(lines, 1):
m = re.match(r"\s*new\((\d+), (\d+), (\w+), (\w+)\)\s*\{(.*)\},?\s*//\s*(.*)$", line)
if not m or "Bonus" not in "".join(lines[i - 4:i]) or "without" in "".join(lines[i - 4:i]):
continue
dex = int(m.group(1))
props = dict((k.strip(), v.strip()) for k, v in (p.split("=", 1) for p in split_top(m.group(5))))
ev = {"source": "%s:%d" % (RSE, i), "comment": m.group(6).strip(), "group": "ColoBonus",
"dex": dex, "species": INTERNAL[dex], "level": int(m.group(2)), "egg": False, "version": m.group(4),
"moves": [], "ot": names[m.group(3)][1], "language": 1, "shiny": "never", "fateful": False,
"nationalRibbon": False, "metLevel": int(m.group(2)), "otGender": int(props["OriginalTrainerGender"]),
"otId": int(props["TID16"]), "secretIdRandom": True}
fix = OWNER.get(("Encounters3RSE", dex))
if fix:
ev["otId"], ev["secretIdRandom"], ev["note"] = fix["otId"], False, fix["note"]
out.append(ev)
return out
def pcny():
lines = read(NY)
order = []
enum_at = next(i for i, l in enumerate(lines) if "public enum Distribution3NY" in l)
for line in lines[enum_at + 2:]:
m = re.match(r"\s*(\w+),\s*$", line)
if not m:
break
order.append(m.group(1))
names = {}
for i, line in enumerate(lines, 1):
m = re.match(r"\s*(\w+) => (pivot \? (\w) : (\w)|(\w)),", line)
if m and m.group(1) in order and m.group(1) not in names:
picks = [m.group(4), m.group(3)] if m.group(3) else [m.group(5)]
names[m.group(1)] = (["PCNY" + p.lower() for p in picks], i)
data = open(os.path.join(PKHEX, NY_PKL), "rb").read()
out = []
for k in range(len(data) // 12):
dex, level, dist = struct.unpack_from("<HBB", data, k * 12)
moves = [x for x in struct.unpack_from("<4H", data, k * 12 + 4) if x]
dname = order[dist]
ots, line = names[dname]
out.append({"source": "%s record %d, %s:%d" % (NY_PKL, k, NY, line), "comment": "PCNY " + dname,
"group": "PCNY", "dex": dex, "species": INTERNAL[dex], "level": level, "egg": False,
"version": "RS", "moves": moves, "otNames": ots, "language": 2, "shiny": "never",
"fateful": False, "nationalRibbon": False, "metLevel": level, "otGender": None,
"otIdMax": 2999})
return out
def pcjp():
lines = read(JPN)
text = "\n".join(lines)
cities = dict(re.findall(r'private const string (\w+) = "(.*?)";', text))
tids = dict(re.findall(r"(\w+)\s+=> (\d{5}),", text))
out = []
for i, line in enumerate(lines, 1):
m = re.match(r"\s*private static ReadOnlySpan<ushort> Distro(\d) => \[(.*)\];", line)
if not m:
continue
dist = ["First", "Second", "Third", "Fourth", "Fifth", "Sixth"][int(m.group(1)) - 1]
ots = [cities[c] for c in ["Tokyo", "Yokohama", "Nagoya", "Osaka", "Fukuoka", "Sapporo"]]
if dist == "Sixth":
ots = ots[:5]
for dex in [int(x) for x in m.group(2).split(",")]:
out.append({"source": "%s:%d" % (JPN, i), "comment": "PCJP " + dist, "group": "PCJP", "dex": dex,
"species": INTERNAL[dex], "level": 10, "egg": False, "version": "R", "moves": [],
"otNames": ots, "language": 1, "shiny": "never", "fateful": False,
"nationalRibbon": False, "metLevel": 10, "otGender": None, "otId": int(tids[dist]),
"secretIdRandom": False})
return out
def slug(text):
return re.sub(r"[^a-z0-9]+", "_", text.lower()).strip("_")
def main():
events = wc3() + colo() + pcny() + pcjp()
seen = {}
for n, ev in enumerate(events):
base = "ev%03d_%s" % (n, slug(NAME[ev["dex"]]))
ev["key"] = base[:28]
assert ev["key"] not in seen
seen[ev["key"]] = True
ev["idNumber"] = 1000 + n
ev["games"] = GAMES[ev["version"]]
if ev["version"] in GAME_ID:
ev["metGame"] = GAME_ID[ev["version"]]
lang = ev.get("language")
nick = species_name(ev["dex"], lang) if lang and lang != 2 and not ev["egg"] else None
if nick:
ev["nickname"] = nick
title = NAME[ev["dex"]] if not ev["egg"] else "POKéMON EGG"
ev["title"] = title
label = ev["comment"] or title
ev["label"] = ("%s (%s)" % (label, ev.get("ot") or "/".join(ev.get("otNames", [])) or "recipient"))
json.dump(events, sys.stdout, ensure_ascii=False, indent=1)
sys.stdout.write("\n")
if __name__ == "__main__":
main()
+107
View File
@@ -0,0 +1,107 @@
package.path = "./?.lua;./?/init.lua;" .. package.path
love = love or require("tests.love_stub")
print = function() end
local root, templatesPath = arg[1], arg[2]
assert(root and templatesPath,
"usage: luajit tools/rs_mystery_event_mons.lua <ruby cache data/generated/gba root> <gen3_event_catalog.py output>")
local GameVersion = require("src.core.GameVersion")
GameVersion.set("ruby")
local Dataset = require("src.core.game3.dataset")
Dataset.cacheRootOverride = root
Dataset.mountExtractRoots()
local Pokemon = require("src.core.game3.pokemon")
Pokemon.install(nil)
local Rng = require("src.core.game3.rng")
local Base64 = require("src.core.Base64")
local Json = require("src.link.Json")
local MysteryGift = require("src.core.game3.mystery_gift")
local bit = require("bit")
local codec = require("src.save_convert.Gen3Save").forVersion("ruby")
local BASE = 0x02000000
local FATEFUL = 0xFF
local POKE_BALL = 4
local function u8(n) return string.char(n % 256) end
local function u16(n) return u8(n) .. u8(math.floor(n / 256)) end
local function u32(n) return u16(n % 65536) .. u16(math.floor(n / 65536)) end
local function crc16(s)
local crc = 0x1121
for i = 1, #s do
crc = bit.bxor(crc, s:byte(i))
for _ = 1, 8 do
if bit.band(crc, 1) ~= 0 then crc = bit.bxor(bit.rshift(crc, 1), 0x8408) else crc = bit.rshift(crc, 1) end
end
end
return bit.band(bit.bnot(crc), 0xFFFF)
end
local function giftOf(t)
local g = { kind = t.egg and "egg" or "mon", species = t.species, ball = POKE_BALL }
if not t.egg then
g.level, g.metLocation, g.metLevel = t.level, FATEFUL, t.metLevel
end
if #t.moves > 0 then
g.moves, g.movesOnly = {}, true
for i, m in ipairs(t.moves) do g.moves[tostring(i)] = m end
end
if t.ot and t.ot ~= "" then g.otName = t.ot end
if t.otNames then g.otNames = t.otNames end
if t.otIdMax then g.otIdMax = t.otIdMax elseif t.otId then g.otId = t.otId end
if t.secretIdRandom then g.secretIdRandom = true end
if t.otGender then g.otGender = t.otGender end
if t.language then g.language = t.language end
if t.metGame then g.metGame = t.metGame end
if t.nickname then g.nickname = t.nickname end
g.shiny = t.shiny
if not t.fateful then g.fateful = false end
if t.nationalRibbon then g.nationalRibbon = true end
return g
end
local function partyMon(t, gift, seed)
Rng.SeedRng(seed)
local session = { version = "ruby", party = {}, name = "PLAYER", trainerId = 0, secretId = 0, gender = 0 }
local card = MysteryGift.normalizeCard({ flagId = 1019, idNumber = 1, type = 0, gift = gift }, "ruby")
assert(card, "gift does not normalize: " .. t.key)
local mon = assert(MysteryGift.createEventMon(session, card.gift), "could not build " .. t.key)
local cart = codec.fromPortMon(mon, { trainerId = mon.otId, secretId = mon.otSecretId, playerName = mon.otName,
speciesName = Pokemon.name }, true)
local bytes = codec.encodePartyMon(cart)
assert(#bytes == 100, "party mon is not 100 bytes")
local back = codec.decodePartyMon(bytes)
assert(back.species == t.species and back.otId == mon.otId, "party mon does not round trip: " .. t.key)
if t.language == 1 and mon.otName ~= "" then
assert(codec.decodeString(back.otNameRaw, 0, 7, 1, true) == mon.otName, "Japanese OT lost: " .. t.key)
end
return bytes
end
-- pokeruby/include/macros/mystery_event_script.inc:5 me_checkcompat
local function payload(monBytes)
local mail = string.rep("\0", 36)
local monAt = 17 + 13 + 5 + 1
local total = monAt + #monBytes + #mail
local header = u8(1) .. u32(BASE) .. u16(2) .. u32(2) .. u16(4) .. u32(0x180)
local body = u8(12) .. u32(BASE + monAt) .. u8(2) .. monBytes .. mail
local crcOp = u8(16) .. u32(crc16(body)) .. u32(BASE + 30) .. u32(BASE + total)
local out = header .. crcOp .. body
assert(#out == total and #out <= 0x7D4)
return out
end
local templates = Json.decode(io.open(templatesPath, "rb"):read("*a"))
local out = {}
for i, t in ipairs(templates) do
local gift = giftOf(t)
local row = { key = t.key, label = t.label, title = t.title, games = t.games, source = t.source,
idNumber = t.idNumber, egg = t.egg, gift = gift, note = t.note }
local rs = false
for _, g in ipairs(t.games) do if g == "ruby" or g == "sapphire" then rs = true end end
if rs then row.payload = Base64.encode(payload(partyMon(t, gift, 0x47334500 + i))) end
out[#out + 1] = row
end
io.write(Json.encode(out), "\n")
+97
View File
@@ -0,0 +1,97 @@
#!/usr/bin/env python3
import base64
import json
import os
import shutil
import struct
import subprocess
import sys
import tempfile
HERE = os.path.dirname(os.path.abspath(__file__))
RUBY = os.path.abspath(os.environ.get("POKERUBY", os.path.join(HERE, "..", "..", "pokeruby")))
SOURCE = "data/debug_mystery_event_scripts.s"
BASE = 0x02000000
BOTH_VERSIONS = 0x80 | 0x100
EVENTS = [
("rs_eon_ticket", "EON TICKET", "gUnknown_Debug_845DAE1", "gUnknown_Debug_845DAE1End"),
("rs_gift_ribbon", "GIFT RIBBON", "gUnknown_Debug_845E3E0", "gUnknown_Debug_845E3E0End"),
]
def tool(name):
for prefix in ("arm-none-eabi-",):
path = shutil.which(prefix + name)
if path:
return path
sys.exit("missing arm-none-eabi-" + name)
def crc16(data):
crc = 0x1121
for b in data:
crc ^= b
for _ in range(8):
crc = (crc >> 1) ^ 0x8408 if crc & 1 else crc >> 1
return ~crc & 0xFFFF
def assemble(work):
preproc = os.path.join(RUBY, "tools", "preproc", "preproc")
charmap = os.path.join(RUBY, "charmap.txt")
src = os.path.join(RUBY, SOURCE)
obj = os.path.join(work, "events.o")
flags = ["-mcpu=arm7tdmi", "-I", "include", "--defsym", "RUBY=1", "--defsym", "REVISION=0",
"--defsym", "DEBUG_FIX=0", "--defsym", "ENGLISH=1", "--defsym", "DEBUG=1",
"--defsym", "MODERN=0"]
a = subprocess.run([preproc, src, charmap], cwd=RUBY, check=True, capture_output=True).stdout
b = subprocess.run([tool("cpp"), "-I", "include"], cwd=RUBY, input=a, check=True,
capture_output=True).stdout
c = subprocess.run([preproc, "-ie", src, charmap], cwd=RUBY, input=b, check=True,
capture_output=True).stdout
subprocess.run([tool("as")] + flags + ["-o", obj], cwd=RUBY, input=c, check=True)
elf = os.path.join(work, "events.elf")
subprocess.run([tool("ld"), "--section-start", ".rodata=0x%08X" % BASE, "-e", "0", "-o", elf, obj],
check=True)
binary = os.path.join(work, "events.bin")
subprocess.run([tool("objcopy"), "-O", "binary", "-j", ".rodata", elf, binary], check=True)
syms = {}
out = subprocess.run([tool("nm"), elf], check=True, capture_output=True, text=True).stdout
for line in out.splitlines():
parts = line.split()
if len(parts) == 3:
syms[parts[2]] = int(parts[0], 16)
with open(binary, "rb") as f:
return f.read(), syms
def fix_crc(block, start):
if block[0] != 1 or block[0x11] != 16 or struct.unpack_from("<I", block, 0x12)[0] != 0:
return
first = struct.unpack_from("<I", block, 0x16)[0] - start
last = struct.unpack_from("<I", block, 0x1A)[0] - start
struct.pack_into("<I", block, 0x12, crc16(block[first:last]))
def main():
work = tempfile.mkdtemp()
try:
image, syms = assemble(work)
finally:
shutil.rmtree(work, ignore_errors=True)
out = []
for key, title, first, last in EVENTS:
start, end = syms[first], syms[last]
block = bytearray(image[start - BASE:end - BASE])
assert struct.unpack_from("<I", block, 1)[0] == start
struct.pack_into("<I", block, 13, struct.unpack_from("<I", block, 13)[0] | BOTH_VERSIONS)
fix_crc(block, start)
assert 0 < len(block) <= 0x7D4
out.append({"key": key, "title": title, "payload": base64.b64encode(bytes(block)).decode()})
json.dump(out, sys.stdout, indent=2)
sys.stdout.write("\n")
if __name__ == "__main__":
main()