Files
gen1recomp/tests/emerald_berry_blender_test.lua
T
bryanthaboi ff9cc6d172 YELLOWCARD
2026-09-28 06:45:52 -04:00

398 lines
19 KiB
Lua

package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
local ROM_PATH = os.getenv("POKEPORT_EMERALD_ROM") or "../pokeemerald/pokeemerald.gba"
local PRET = os.getenv("POKEPORT_PRET_EMERALD") or "../pokeemerald"
local f = io.open(ROM_PATH, "rb")
if not f then
print("emerald_berry_blender_test: skipped (no ROM at " .. ROM_PATH .. ")")
os.exit(0)
end
local data = f:read("*a")
f:close()
if data:sub(0xAD, 0xB0) ~= "BPEE" then
print("emerald_berry_blender_test: skipped (" .. ROM_PATH .. " is not Emerald)")
os.exit(0)
end
local GameVersion = require("src.core.GameVersion")
local prevVersion = GameVersion.get()
GameVersion.set("emerald")
local imports = {
info = function(_, id) return { id = id, size = #data, md5 = "emerald", file = "memory" } end,
read = function(_, _, off, len) return data:sub(off + 1, off + len) end,
}
local rom = assert(require("src.import.gba.rom").open(imports, "emerald"))
local store = {}
local cache = {
write = function(_, rel, bytes) store[rel] = bytes; return true end,
read = function(_, rel) return store[rel] end,
exists = function(_, rel) return store[rel] ~= nil end,
}
local ROOT = "data/generated/gba"
local BX = require("src.import.gba.rse.berry_blender_extract")
check(not BX.ready(cache, ROOT), "blender cache not ready before run")
local ok, man = BX.run(rom, cache, { cacheRoot = ROOT })
eq(ok, true, "blender extractor ran")
check(BX.ready(cache, ROOT), "blender cache ready after run")
for _, rel in ipairs(BX.REQUIRED) do check(store[ROOT .. "/" .. rel] ~= nil, rel .. " written") end
require("src.import.gba.berries_extract").run(rom, cache, { cacheRoot = ROOT })
local pack = assert(loadstring(store[ROOT .. "/berries/berries.lua"]))()
local plan = require("src.import.gba.plans.rse")
local planned = false
for _, m in ipairs(require("src.import.gba.plans.registry").modules(plan)) do
if m == "src.import.gba.rse.berry_blender_extract" then planned = true end
end
check(planned, "rse import plan runs the blender extractor")
local B = require("src.core.game3.rse.berry_blender")
local Tb = man.tables
local ids = B.itemIds("emerald")
-- pokeemerald/src/berry_blender.c:452
local pretC
do
local h = io.open(PRET .. "/src/berry_blender.c", "rb")
if h then pretC = h:read("*a"); h:close() end
end
local function pretNumbers(name)
if not pretC then return nil end
local body = pretC:match(name .. "[%[%]%w_ %-]*=%s*(%b{})")
local out = {}
for n in (body or ""):gmatch("%d+") do out[#out + 1] = tonumber(n) end
return out
end
if pretC then
eq(table.concat(Tb.arrowHitRangeStart, ","), table.concat(pretNumbers("sArrowHitRangeStart"), ","),
"sArrowHitRangeStart equals pret source")
eq(table.concat(Tb.numPlayersToSpeedDivisor, ","), table.concat(pretNumbers("sNumPlayersToSpeedDivisor"), ","),
"sNumPlayersToSpeedDivisor equals pret source")
eq(table.concat(Tb.arrowStartPosIds, ","), table.concat(pretNumbers("sArrowStartPosIds"), ","),
"sArrowStartPosIds equals pret source")
local pos = pretNumbers("sPlayerArrowPos")
local got = {}
for _, r in ipairs(Tb.playerArrowPos) do got[#got + 1] = r[1]; got[#got + 1] = r[2] end
eq(table.concat(got, ","), table.concat(pos, ","), "sPlayerArrowPos equals pret source")
else
print("emerald_berry_blender_test: pret source not found, source comparisons skipped")
end
eq(table.concat(Tb.arrowStartPos, ","), "0,49152,16384,32768", "sArrowStartPos")
eq(#Tb.opponentBerrySets, 10, "ten NPC berry sets")
eq(#Tb.blackPokeblockFlavorFlags, 10, "ten black flavor sets")
eq(table.concat(man.opponentNames, ","), "MISTER,LADDIE,LASSIE,MASTER,DUDE,MISS", "opponent names")
eq(man.texts.theLevelIs, "The level is ", "sText_TheLevelIs")
check(man.texts.berryBlenderStart:find("Starting up the BERRY BLENDER.", 1, true) ~= nil, "sText_BerryBlenderStart")
eq(man.windows[5].left .. "," .. man.windows[5].top .. "," .. man.windows[5].width, "2,15,27", "message window template")
eq(man.sprites.arrow.frames, 4, "player arrow has 4 frames")
eq(#man.sprites.arrow.anims, 12, "player arrow has 12 anims")
eq(man.berry.frames, 43, "43 berry pics")
eq(#man.palettes.center, 128, "center palette 128 colors")
eq(#man.outerMap, 1024, "outer tilemap 32x32")
local function newGame(opp, item, random, master)
local g = B.new({ tables = Tb, berries = pack.berries, opponents = opp, playerItem = item, itemIds = ids,
opponentNames = man.opponentNames, playerName = "NICK", random = random, blendMaster = master })
g:setPlayerIdMaps()
return g
end
-- pokeemerald/src/berry_blender.c:1542
local g1 = newGame(1, ids.first)
eq(g1.chosenItemId[1], ids.aspear, "Cheri vs MISTER: MISTER blends an Aspear")
eq(g1.playerNames[1], "MISTER", "one NPC without the blend master is MISTER")
local g3 = newGame(3, ids.first)
eq(g3.chosenItemId[1] .. "," .. g3.chosenItemId[2] .. "," .. g3.chosenItemId[3],
(ids.first + 4) .. "," .. (ids.first + 3) .. "," .. (ids.first + 2), "Cheri vs 3 NPCs: Aspear, Rawst, Pecha")
eq(g3.playerNames[1] .. "," .. g3.playerNames[2] .. "," .. g3.playerNames[3], "MISS,LADDIE,LASSIE", "3 NPC names")
local gm = newGame(1, ids.first, nil, true)
eq(gm.playerNames[1], "MASTER", "blend master present names MASTER")
eq(gm.chosenItemId[1], ids.first + Tb.berryMasterBerries[1], "blend master counters Cheri with a Spelon")
local gms = newGame(1, ids.spelon, nil, true)
eq(gms.chosenItemId[1], ids.first + Tb.berryMasterBerries[1] - 5, "blend master drops a set for a master berry")
local gE = newGame(1, ids.enigma)
check(gE.chosenItemId[1] >= ids.first and gE.chosenItemId[1] <= ids.aspear, "Enigma picks an NPC berry set")
eq(g3.arrowIdToPlayerId[0] .. "," .. g3.arrowIdToPlayerId[3], "0,3", "4-player arrow map")
eq(g1.playerIdToArrowId[0] .. "," .. g1.playerIdToArrowId[1], "1,2", "2-player: player on arrow 1, NPC on arrow 2")
-- pokeemerald/src/berry_blender.c:1525
g1.arrowPos = (224 + 20 - 24) * 256
eq(g1:arrowProximity(g1.arrowPos, 0), B.PROXIMITY.BEST, "best window start")
g1.arrowPos = (224 + 28 - 24) * 256
eq(g1:arrowProximity(g1.arrowPos, 0), B.PROXIMITY.GOOD, "good after the best window")
g1.arrowPos = (224 - 1 - 24) * 256
eq(g1:arrowProximity(g1.arrowPos, 0), B.PROXIMITY.MISS, "miss before the window")
eq(B.arrowSpeedToRPM(128), 703, "MIN_ARROW_SPEED = 7.03 RPM")
-- pokeemerald/src/berry_blender.c:2387
local function berryOf(item) return B.blenderBerry(item, pack.berries, ids) end
local function calc(items, rpm)
local list = {}
for i, it in ipairs(items) do list[i - 1] = berryOf(it) end
return (B.calculatePokeblock(list, #items, rpm, function() return 0 end, ids, Tb.blackPokeblockFlavorFlags))
end
local red = calc({ ids.first, ids.aspear }, 9887)
eq(red.color .. "," .. red.spicy .. "," .. red.feel, "1,12,23", "Cheri + Aspear at 98.87 RPM = RED lv12 feel 23 (pygba)")
local black = calc({ ids.first, ids.first }, 5000)
eq(black.color, B.COLOR.BLACK, "same berry twice makes a BLACK block")
eq(black.sour .. "," .. black.bitter .. "," .. black.sweet, "2,2,2", "black flavors from sBlackPokeblockFlavorFlags[0]")
local zero = calc({ ids.first }, 0)
eq(zero.color, B.COLOR.RED, "single Cheri is RED")
local C = require("src.core.game3.constants").of("emerald")
local function synth(item, fl)
return { itemId = item, name = tostring(item), flavors = { [0] = fl[1], fl[2], fl[3], fl[4], fl[5], fl[6] } }
end
local gold = B.calculatePokeblock({ [0] = synth(1, { 60, 0, 0, 0, 0, 20 }), synth(2, { 0, 0, 0, 0, 0, 20 }) }, 2, 0,
function() return 0 end, ids, Tb.blackPokeblockFlavorFlags)
eq(gold.color .. "," .. gold.spicy .. "," .. gold.feel, "14,59,18", "a flavor above 50 makes GOLD")
local purple = calc({ C:require("items", "ITEM_SPELON_BERRY"), ids.aspear }, 15000)
eq(purple.color .. "," .. purple.spicy .. "," .. purple.dry, "6,41,12", "Spelon + Aspear at 150 RPM = PURPLE")
-- pokeemerald/src/berry_blender.c:3592
eq(B.madeText(red, man.texts, pack.pokeblockNames), "RED POKéBLOCK was made!\nThe level is 12, and the feel is 23.",
"made text")
eq(B.maxSpeedText(9887, man.texts), "{UNK_SPACER}98.87 RPM", "max speed text")
eq(B.timeText(1664, man.texts), "00 min. 27 sec.", "time text")
local function unhex(s, w, i)
return tonumber(s:sub((i - 1) * w + 1, i * w), 16)
end
local function signed16(v) if v >= 0x8000 then return v - 0x10000 end return v end
local golden = dofile("tests/data/emerald_blender/golden.lua")
local Rng = require("src.core.game3.rng")
for name, G in pairs(golden) do
local saved = Rng._value
Rng._value = G.rng0
local g = newGame(G.opponents, G.berries[1], Rng.Random)
for i = 2, #G.berries do eq(g.chosenItemId[i - 1], G.berries[i], name .. " NPC berry " .. (i - 1)) end
g.arrowPos = g:arrowStartPos()
g:startPlay()
local press = {}
for _, p in ipairs(G.press) do press[p] = true end
local bad, first = 0, nil
local ended = false
for i = 0, G.frames - 1 do
Rng.Random()
ended = g:playFrame(press[i] or false)
local okF = g.speed == signed16(unhex(G.speed, 4, i + 1)) and g.progressBarValue == unhex(G.progress, 3, i + 1)
if i % 8 == 7 then
local j = (i - 7) / 8 + 1
okF = okF and g.arrowPos == unhex(G.arrowPos8, 4, j) and Rng._value == unhex(G.rng8, 8, j)
end
if not okF then
bad = bad + 1
first = first or i
end
end
eq(bad, 0, name .. ": every frame matches the pygba trace" .. (first and (" (first bad " .. first .. ")") or ""))
check(ended, name .. ": the game ends on the traced frame")
local F = G.final
eq(g.maxRPM, F.maxRPM, name .. ": max RPM")
eq(g.gameFrameTime, F.frameTime, name .. ": game time")
eq(Rng._value, F.rng, name .. ": rng state after play")
local sc = {}
for p = 0, 3 do for k = 0, 2 do sc[#sc + 1] = g.scores[p][k] end end
eq(table.concat(sc, ","), table.concat(F.scores, ","), name .. ": scores")
local b = g:calculate()
local P = G.pokeblock
eq(table.concat({ b.color, b.spicy, b.dry, b.sweet, b.bitter, b.sour, b.feel }, ","),
table.concat({ P.color, P.spicy, P.dry, P.sweet, P.bitter, P.sour, P.feel }, ","), name .. ": pokeblock equals the cart")
local sess = { berryBlenderRecords = { 11052, 0, 0 } }
g:tryUpdateRecord(sess)
eq(table.concat(sess.berryBlenderRecords, ","), table.concat(G.records, ","), name .. ": speed records")
local e = 0
while not g:endFrame() do e = e + 1 end
check(e > 0 and g.speed == 0, name .. ": blender spins down")
Rng._value = saved
end
-- pokeemerald/src/berry_blender.c:1047
local UI = require("src.ui.game3.rse.berry_blender")
require("src.import.gba.items_extract").run(rom, cache, { cacheRoot = ROOT })
require("src.core.game3.items_data").installPack(assert(loadstring(store[ROOT .. "/items/pack.lua"]))())
for name, G in pairs(golden) do
local saved = Rng._value
local Bag = require("src.core.game3.bag")
local bag = Bag.new()
Bag.add(bag, G.berries[1], 3)
local sess = { bag = bag, berryBlenderRecords = { 11052, 0, 0 }, gameStats = {} }
require("src.core.game3.rse.pokeblock").clearAll(sess)
local doneCalled = false
local ui = UI.new({ session = sess, manifest = man, berries = pack, opponents = G.opponents, playerName = "NICK", cache = cache,
headless = true, vblankRandom = true, random = Rng.Random, itemIds = ids, version = "emerald",
chooseBerry = function(done) done(G.berries[1]) end, onDone = function() doneCalled = true end })
local playFrame, seeded = nil, false
local ynFrames
local press = {}
for _, p in ipairs(G.press) do press[p] = true end
local log = {}
local frames = 0
while not ui.done and frames < 20000 do
frames = frames + 1
local inp = { new = {}, held = {} }
if ui.cb == "play" then
if not seeded then
Rng._value = G.rng0
seeded = true
playFrame = 0
end
if press[playFrame] then inp.new.a = true end
playFrame = playFrame + 1
elseif ui.cb == "end" and ui.game.gameEndState == 10 then
ynFrames = (ynFrames or 0) + 1
if ynFrames == 2 then inp.new.down = true elseif ynFrames == 6 then inp.new.a = true end
elseif frames % 6 == 0 then
inp.new.a = true
end
local wasCb = ui.cb
ui:frame(inp)
if wasCb == "startLocal" and ui.cb == "play" then seeded = false end
if wasCb ~= ui.cb then log[#log + 1] = ui.cb end
end
check(ui.done and doneCalled, name .. ": headless blender screen returns to the field (" .. table.concat(log, ">") .. ")")
eq(playFrame, G.frames, name .. ": play lasts the traced frame count")
eq(ui.game.maxRPM, G.final.maxRPM, name .. ": screen max RPM equals the cart")
local b = sess.pokeblocks[1]
local P = G.pokeblock
eq(table.concat({ b.color, b.spicy, b.dry, b.sweet, b.bitter, b.sour, b.feel }, ","),
table.concat({ P.color, P.spicy, P.dry, P.sweet, P.bitter, P.sour, P.feel }, ","), name .. ": screen adds the cart pokeblock")
eq(Bag.get(bag, G.berries[1]), 2, name .. ": one berry used")
eq(sess.gameStats[B.GAME_STAT_POKEBLOCKS], 1, name .. ": GAME_STAT_POKEBLOCKS")
eq(table.concat(sess.berryBlenderRecords, ","), table.concat(G.records, ","), name .. ": records after the screen")
local logS = table.concat(ui.sound.log, ",")
check(logS:find("SE_FALL", 1, true) and logS:find("MUS_LEVEL_UP", 1, true) and logS:find("SE_BERRY_BLENDER", 1, true),
name .. ": fall SE, blender SE and level-up fanfare played")
Rng._value = saved
end
local SaveSections = require("src.core.game3.save_sections")
local probe = SaveSections.fields(B.SAVE_FIELDS)
local exported, restored = {}, {}
probe.export({ berryBlenderRecords = { 1, 2, 3 } }, exported)
probe.restore(exported, restored)
eq(table.concat(restored.berryBlenderRecords, ","), "1,2,3", "blender records survive save/restore")
local fresh = {}
B.records(fresh)
eq(table.concat(fresh.berryBlenderRecords, ","), "0,0,0", "records default to zero")
local okReg, listed = pcall(function()
for _, sec in ipairs(SaveSections.of("emerald")) do
if sec.name == "berryBlender" then return true end
end
return false
end)
check(okReg and listed, "emerald save sections resolve with the blender listed")
check(require("src.core.game3.rse.init").system("berryBlender") == B, "Rse system berryBlender registered")
local BlenderLink = require("src.core.game3.rse.berry_blender_link")
local bus = { queues = { [0] = {}, [1] = {} } }
local function peer(seat)
local link = { seat = seat, bus = bus, open = true }
function link:getSeat() return self.seat end
function link:isOpen() return self.open end
function link:send(message)
local target = 1 - self.seat
local copy = {}
for key, value in pairs(message) do copy[key] = value end
copy.seat = self.seat
self.bus.queues[target][#self.bus.queues[target] + 1] = copy
end
function link:take(kind, predicate)
local q = self.bus.queues[self.seat]
for i, message in ipairs(q) do
if message.type == kind and (not predicate or predicate(message)) then
table.remove(q, i)
return message
end
end
end
return link
end
local players = {
{ seat = 0, trainerId = 111, name = "HOST" },
{ seat = 1, trainerId = 222, name = "GUEST" },
}
local hostBlenderLink = BlenderLink.new(peer(0), players)
local guestBlenderLink = BlenderLink.new(peer(1), players)
eq(hostBlenderLink:submitBerry(133), nil, "linked berry waits for the partner choice")
local guestBerries = guestBlenderLink:submitBerry(134)
eq(guestBerries[0], 133, "guest receives host berry by link seat")
local hostBerries = hostBlenderLink:submitBerry(133)
eq(hostBerries[1], 134, "host receives guest berry by link seat")
eq(hostBlenderLink:exchangeFrame(0, B.CMD.BEST), nil, "host frame waits for the guest hit")
local guestFrame = guestBlenderLink:exchangeFrame(0, B.CMD.GOOD)
eq(guestFrame[0], B.CMD.BEST, "linked timing frame carries the host hit")
local hostFrame = hostBlenderLink:exchangeFrame(0, B.CMD.BEST)
eq(hostFrame[1], B.CMD.GOOD, "linked timing frame carries the guest hit")
eq(hostBlenderLink:exchangeContinue(B.PLAY_AGAIN.YES), nil, "continue decision waits for the guest")
eq(guestBlenderLink:exchangeContinue(B.PLAY_AGAIN.CANT_PLAY_NO_BERRIES), nil, "guest waits for leader decision")
local decision = hostBlenderLink:exchangeContinue(B.PLAY_AGAIN.YES)
check(decision and not decision.continue and decision.reason == B.PLAY_AGAIN.CANT_PLAY_NO_BERRIES
and decision.seat == 1, "leader resolves the partner's no-berries response")
local peerDecision = guestBlenderLink:exchangeContinue(B.PLAY_AGAIN.CANT_PLAY_NO_BERRIES)
check(peerDecision and not peerDecision.continue and peerDecision.seat == 1, "leader decision is broadcast to guest")
hostBlenderLink:abort("test_cancel")
local _, abortReason = guestBlenderLink:exchangeFrame(1, 0)
eq(abortReason, "abort", "peer cancellation releases a waiting timing frame")
bus = { queues = { [0] = {}, [1] = {} } }
local linkHost, linkGuest = peer(0), peer(1)
local playerRows = {
{ seat = 0, trainerId = 111, name = "HOST" },
{ seat = 1, trainerId = 222, name = "GUEST" },
}
local hostSession, guestSession = {}, {}
for _, sess in ipairs({ hostSession, guestSession }) do
local Bag = require("src.core.game3.bag")
sess.bag = Bag.new()
Bag.add(sess.bag, ids.first, 4)
sess.name = sess == hostSession and "HOST" or "GUEST"
sess.gameStats = {}
require("src.core.game3.rse.pokeblock").clearAll(sess)
end
local hostScreen = UI.new({ session = hostSession, manifest = man, berries = pack, opponents = 0,
playerName = "HOST", playerNames = { "HOST", "GUEST" }, numPlayers = 2, itemIds = ids, version = "emerald",
cache = cache, headless = true, linkSession = BlenderLink.new(linkHost, playerRows),
chooseBerry = function(done) done(ids.first) end })
local guestScreen = UI.new({ session = guestSession, manifest = man, berries = pack, opponents = 0,
playerName = "GUEST", playerNames = { "HOST", "GUEST" }, numPlayers = 2, itemIds = ids, version = "emerald",
cache = cache, headless = true, linkSession = BlenderLink.new(linkGuest, playerRows),
chooseBerry = function(done) done(ids.first) end })
local linkedScreens = { hostScreen, guestScreen }
local yesNoTicks = { 0, 0 }
local linkedFrames = 0
while not hostScreen.done or not guestScreen.done do
linkedFrames = linkedFrames + 1
if linkedFrames > 30000 then break end
for index, ui in ipairs(linkedScreens) do
if not ui.done then
local inp = { new = {}, held = {} }
if ui.cb == "play" then
inp.new.a = ui.game.gameFrameTime % 2 == 0
elseif ui.cb == "end" and ui.game.gameEndState == 10 then
yesNoTicks[index] = yesNoTicks[index] + 1
if yesNoTicks[index] == 2 then inp.new.down = true end
if yesNoTicks[index] == 6 then inp.new.a = true end
elseif linkedFrames % 6 == 0 then
inp.new.a = true
end
ui:frame(inp)
end
end
end
check(hostScreen.done and guestScreen.done, "linked Blender screens finish together")
check(hostScreen.game and guestScreen.game and hostScreen.game.gameFrameTime == guestScreen.game.gameFrameTime,
"linked Blender clients advance the same number of frames")
check(hostSession.pokeblocks and guestSession.pokeblocks and hostSession.pokeblocks[1]
and guestSession.pokeblocks[1], "both linked players receive the same blended Pokeblock")
eq(require("src.core.game3.bag").get(hostSession.bag, ids.first), 3, "host consumes only its selected berry")
eq(require("src.core.game3.bag").get(guestSession.bag, ids.first), 3, "guest consumes only its selected berry")
GameVersion.set(prevVersion)
T.finish("emerald_berry_blender_test")