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")