-- Hidden items: engine/events/checkforhiddenitems.asm, the BGEVENT_ITEM arm of -- the bg event dispatch (engine/overworld/events.asm `.itemifset`) and -- HiddenItemScript (engine/events/hidden_item.asm), plus the extractor half -- that stopped walking a `hiddenitem` struct as bytecode. -- -- ROM-free: `luajit tests/gen2_hidden_items_test.lua`. The cache section at -- the bottom SKIPs when no Gold cache is present. package.path = "./?.lua;./?/init.lua;" .. package.path local S = require("tests.harness").suite("gen2 hidden items") local check, eq = S.check, S.eq local HiddenItems = require("src.world.gen2.HiddenItems") local Events = require("src.world.gen2.Events") local Vm = require("src.script.gen2.Vm") -- maps/Route42.asm: -- bg_event 16, 11, BGEVENT_ITEM, Route42HiddenMaxPotion -- Route42HiddenMaxPotion: hiddenitem MAX_POTION, EVENT_ROUTE_42_HIDDEN_MAX_POTION -- MAX_POTION is item 15 (constants/item_constants.asm) and the flag is 173 -- (constants/event_flags.asm). local MAX_POTION, ROUTE_42_FLAG = 15, 173 local function itemRow(x, y, item, event) return { x = x, y = y, kind = HiddenItems.BGEVENT_ITEM, hiddenItem = { item = item, event = event } } end -- ---- the bg event row ----------------------------------------------------- do local row = itemRow(16, 11, MAX_POTION, ROUTE_42_FLAG) eq(HiddenItems.dataOf(row).item, MAX_POTION, "kind 7 carries hiddenitem data") eq(HiddenItems.dataOf(row).event, ROUTE_42_FLAG, "and its event flag") check(HiddenItems.dataOf({ x = 1, y = 1, kind = 0, scriptKey = "1:4000" }) == nil, "BGEVENT_READ is a sign, not an item") check(HiddenItems.dataOf({ x = 1, y = 1, kind = 7 }) == nil, "a kind 7 row from a cache that predates the decode answers nil") check(HiddenItems.dataOf(nil) == nil, "and nil is not a row") end -- ---- CheckForHiddenItems' box --------------------------------------------- -- The corner is player + SCREEN_WIDTH / 4, SCREEN_HEIGHT / 4 = +5, +4, and the -- differences that survive are 0..9 on x and 0..8 on y. So the box is ten -- cells wide with the player LEFT of centre and nine tall with the player -- centred, and it is not a radius. do local px, py = 20, 20 check(HiddenItems.onScreen(px, py, px, py), "the player's own cell is on screen") check(HiddenItems.onScreen(px, py, px + 5, py), "five cells right is the last x") check(not HiddenItems.onScreen(px, py, px + 6, py), "six is past the corner") check(HiddenItems.onScreen(px, py, px - 4, py), "four cells left is the first x") check(not HiddenItems.onScreen(px, py, px - 5, py), "five left is a screen away") check(HiddenItems.onScreen(px, py, px, py + 4), "four cells down is the last y") check(not HiddenItems.onScreen(px, py, px, py + 5), "five down is past the corner") check(HiddenItems.onScreen(px, py, px, py - 4), "four cells up is the first y") check(not HiddenItems.onScreen(px, py, px, py - 5), "five up is a screen away") check(not HiddenItems.onScreen(px, py, px + 6, py + 4), "either axis out of range is out") end -- ---- the sweep ------------------------------------------------------------ do local def = { bgEvents = { { x = 3, y = 3, kind = 0, scriptKey = "1:4000" }, itemRow(16, 11, MAX_POTION, ROUTE_42_FLAG), itemRow(40, 40, 32, 182), } } local events = Events.new() eq(#HiddenItems.unfound(def, events), 2, "two hidden items, one sign") local found = HiddenItems.nearby(def, 14, 10, events) check(found ~= nil, "the ITEMFINDER answers for the one in range") eq(found.item, MAX_POTION, "and hands back the item") eq(found.event, ROUTE_42_FLAG, "and its flag") check(HiddenItems.nearby(def, 30, 30, events) == nil, "and nothing when both are off screen") events:set(ROUTE_42_FLAG, true) check(HiddenItems.nearby(def, 14, 10, events) == nil, "an item already found never sets the ITEMFINDER off again") eq(#HiddenItems.unfound(def, events), 1, "and drops out of the unfound list") check(HiddenItems.nearby(def, 14, 10, nil) ~= nil, "with no flag store at all nothing has been found yet") end -- ---- the facing cell ------------------------------------------------------ do local def = { bgEvents = { { x = 3, y = 3, kind = 0, scriptKey = "1:4000" }, itemRow(16, 11, MAX_POTION, ROUTE_42_FLAG), } } local events = Events.new() eq(HiddenItems.at(def, 16, 11, events).item, MAX_POTION, "the facing cell finds the hidden item") check(HiddenItems.at(def, 3, 3, events) == nil, "a sign is not one") check(HiddenItems.at(def, 9, 9, events) == nil, "and an empty cell is not one") events:set(ROUTE_42_FLAG, true) check(HiddenItems.at(def, 16, 11, events) == nil, "`.itemifset` jumps to .dontread once the flag is set") end -- ---- HiddenItemScript ----------------------------------------------------- do local script = HiddenItems.pickupScript(MAX_POTION, ROUTE_42_FLAG) local ops = {} for _, cmd in ipairs(script) do ops[#ops + 1] = cmd.op end eq(table.concat(ops, ","), "opentext,getitemname,rawtext,giveitem,iffalse,setevent,specialsound," .. "itemnotify,closetext,end", "the order is HiddenItemScript's, with readmem folded into the item") eq(script[4].item, MAX_POTION, "giveitem ITEM_FROM_MEM gives the found item") eq(script[4].quantity, 1, "one of it") eq(script[6].event, ROUTE_42_FLAG, "callasm SetMemEvent sets the flag by NUMBER") local fullOps = {} for _, cmd in ipairs(script[5].script) do fullOps[#fullOps + 1] = cmd.op end eq(table.concat(fullOps, ","), "promptbutton,rawtext,waitbutton,closetext,end", ".bag_full prints and stops; it sets no flag") end -- The same list through the real VM, which is where the ordering matters: the -- flag must be set only on the arm that really took the item. local function runPickup(bagFull) local events = Events.new() local log, given = {}, nil local vm = Vm.new({}, {}, events, { showText = function(body, onDone) log[#log + 1] = body if onDone then onDone() end end, getItemName = function(index) eq(index, MAX_POTION, "getitemname is asked for the found item") return "MAX POTION" end, giveItem = function(index, qty) if bagFull then return false end given = { index = index, qty = qty } return true end, specialSound = function() end, waitSfx = function() return true end, }) vm:start(HiddenItems.pickupScript(MAX_POTION, ROUTE_42_FLAG)) for _ = 1, 8 do vm:update() end return events, log, given end do local events, log, given = runPickup(false) eq(log[1], "{PLAYER} found\nMAX POTION.", "_PlayerFoundItemText names the item; {PLAYER} is TextBox's to fill") eq(given.index, MAX_POTION, "and the item lands in the pack") eq(given.qty, 1, "one of it") check(events:get(ROUTE_42_FLAG), "SetMemEvent ticks the flag off") eq(log[2], "{PLAYER} put the\nMAX POTION in\nthe ITEM POCKET.", "itemnotify is the second line") local fullEvents, fullLog = runPickup(true) check(not fullEvents:get(ROUTE_42_FLAG), "a full pack leaves the flag CLEAR, so the item is still there") eq(fullLog[2], "But {PLAYER} has\nno space left…", "_ButNoSpaceText instead") end -- ---- FindItemInBallScript, the OBJECTTYPE_ITEMBALL press ------------------- -- engine/events/misc_scripts.asm:9. The object's pointer is two raw bytes, so -- nothing exists for the VM to start: World:interact feeds it this built list. -- Two load-bearing orderings. `callasm .TryReceiveItem` names the item AND -- takes it before any box is drawn, so the full-pocket branch is chosen with -- nothing on screen and its arm prints two boxes of its own; and `disappear` -- (which is what sets the ball's own event flag through World:disappearObject) -- sits on the success side of the iffalse only, or a full pack would still eat -- the ball. This is NOT the hidden item's script with a disappear swapped in, -- which is what it used to assert. local function runBallPickup(bagFull) local events = Events.new() local log, given, gone, sfx = {}, nil, nil, nil local vm = Vm.new({}, {}, events, { showText = function(body, onDone) log[#log + 1] = body if onDone then onDone() end end, getItemName = function() return "HM07" end, giveItem = function(index, qty) if bagFull then return false end given = { index = index, qty = qty } return true end, disappear = function(objectId) gone = objectId end, playSound = function(id) sfx = id end, specialSound = function() end, waitSfx = function() return true end, }) vm:start(HiddenItems.ballPickupScript(MAX_POTION, 1, 2)) -- The cart's `pause 60` now rides the found line's own row as `hold` (see -- the order assertion below), and a `hold` is counted by the TEXT BOX, not -- by the VM -- this stub showText answers straight away, so nothing parks -- here. The loop is left long so a future park would still be outlasted -- rather than read as a hang. for _ = 1, 160 do vm:update() end return log, given, gone, sfx, vm end do local script = HiddenItems.ballPickupScript(MAX_POTION, 3, 5) local ops = {} for _, cmd in ipairs(script) do ops[#ops + 1] = cmd.op end -- FindItemInBallScript is `writetext .FoundItemText / playsound SFX_ITEM / -- pause 60 / itemnotify` (engine/events/misc_scripts.asm:13-17), all of it -- inside ONE MapTextbox: nothing between the found line and the itemnotify -- line takes a box down. The port's box does take itself down -- it waits -- for its own button and pops on it -- so written in that order the pause -- ran with an EMPTY state stack: 120 frames of bare overworld inside one -- cart textbox, with Game2's play clock counting for all of them -- (tests/drivers/gold_item_pickup_box.lua measures exactly that). -- -- So the pause rides the found line's own row as `hold`, which World:showText -- counts on the standing box, and the sound leads the text instead of -- trailing it -- it has to be rung before the row that does not come back -- until the hold has drained. eq(table.concat(ops, ","), "getitemname,giveitem,iffalse,disappear,opentext,playsound,rawtext," .. "itemnotify,closetext,end", "FindItemInBallScript's order: the callasm pair first, then the freeze") eq(script[2].quantity, 3, "the itemball row's own quantity is given") eq(script[4].object, 5, "disappear names the object const the caller built") eq(script[6].id, 1, "playsound SFX_ITEM, not specialsound's TM-aware pick") eq(script[7].stay, true, "the found line holds the ONE MapTextbox open") eq(script[7].hold, 60, "and `pause 60` holds the world under that box") local log, given, gone, sfx, vm = runBallPickup(false) eq(log[1], "{PLAYER} found\nHM07!", "_FoundItemText ends on '!' -- the '.' one is the hidden item's") eq(given.index, MAX_POTION, "and the item lands in the pack") eq(gone, 2, "the ball object disappears -- its event flag rides on that") eq(sfx, 1, "SFX_ITEM rang") check(not vm:running(), "and the script ran off the end of the pause") local fullLog, fullGiven, fullGone = runBallPickup(true) check(fullGiven == nil, "a full pack takes nothing") check(fullGone == nil, "and the ball stays where it is") eq(fullLog[1], "{PLAYER} found\nHM07!", "the .no_room arm shows the find too") eq(fullLog[2], "But {PLAYER} can't\ncarry any more\vitems!", "_CantCarryItemText is the second box, not _ButNoSpaceText") end -- ---- ItemFinder's two queued scripts -------------------------------------- do local asked = {} local function sfxId(want, fallback) asked[#asked + 1] = want return fallback + 100 end local found = HiddenItems.itemfinderScript( { x = 1, y = 1, item = MAX_POTION, event = ROUTE_42_FLAG }, sfxId) local ops = {} for _, cmd in ipairs(found) do ops[#ops + 1] = cmd.op end eq(#asked, 8, ".ItemfinderSound is four rounds of two sounds") eq(asked[1], "Sfx_SecondPartOfItemfinder", "the beep comes first") eq(asked[2], "Sfx_Transaction", "then the answering blip") eq(ops[1], "waitsfx", "WaitPlaySFX waits BEFORE it plays") eq(ops[2], "playsound", "and the play follows the wait") eq(found[2].id, 118, "the id is resolved through this cache's sfx table") eq(#found, 21, "sixteen sound rows and the five the text box needs") eq(table.concat({ ops[16], ops[17], ops[18], ops[19], ops[20], ops[21] }, ","), "playsound,opentext,rawtext,waitbutton,closetext,end", "and the text lands after the last sound, unwaited") local nothing = HiddenItems.itemfinderScript(nil, sfxId) local nothingOps = {} for _, cmd in ipairs(nothing) do nothingOps[#nothingOps + 1] = cmd.op end eq(table.concat(nothingOps, ","), "opentext,rawtext,waitbutton,closetext,end", ".Script_FoundNothing makes no sound at all") eq(#asked, 8, "and asks the sfx table for nothing") end -- ---- the world wiring ----------------------------------------------------- local World = require("src.world.gen2.World") local COLL_FLOOR = 0x00 local function fakeWorld(bgEvents, px, py, facing) local game = { data = { items = { MAX_POTION = { id = "MAX_POTION", name = "MAX POTION", pocket = "ITEM", index = MAX_POTION }, ITEMFINDER = { id = "ITEMFINDER", name = "ITEMFINDER", pocket = "KEY_ITEM", index = 0x3c }, } }, save = { player = { name = "GOLD" }, party = {}, inventory = { ITEMFINDER = 1 } }, } local world = World.new(game) game.world = world world.map = { id = "TEST_MAP", def = { bgEvents = bgEvents, objects = {}, width = 10, height = 10 }, cellCollision = function() return COLL_FLOOR end, warpAt = function() return nil end, } world.maps = { TEST_MAP = world.map.def } world.player = { cellX = px, cellY = py, px = px * 16, py = py * 16, facing = facing or "down", moving = false, update = function() return false end, } world.pollTimeOfDay = function() end world.playSfx = function(self, id) self.sfxLog = self.sfxLog or {} self.sfxLog[#self.sfxLog + 1] = id end local started = {} world.startedScripts = started world.vm = { running = function() return false end, update = function() end, start = function(_, script) started[#started + 1] = script return true end, } return world, game end do -- Facing the hidden item's cell: the A press runs HiddenItemScript. local world = fakeWorld({ itemRow(5, 6, MAX_POTION, ROUTE_42_FLAG) }, 5, 5, "down") check(world:interact(), "an A press into a hidden item is taken") eq(#world.startedScripts, 1, "and starts one script") eq(world.startedScripts[1][4].item, MAX_POTION, "the script the world built is the pickup for that item") eq(world.sfxLog[1], 8, "PlayTalkObject's SFX_READ_TEXT_2 opens it") -- Already found: `.dontread`, so the press is NOT taken and falls through to -- the tile events (none here, so interact answers false). world.events:set(ROUTE_42_FLAG, true) check(not world:interact(), "a hidden item already taken does not eat the press") eq(#world.startedScripts, 1, "and starts nothing") end do -- OBJECTTYPE_ITEMBALL: World:interact has to route the A press to the built -- ball pickup, because the object's pointer is raw (item, quantity) bytes -- and there is no scriptKey. Without this arm no floor item in the game -- could be taken -- HM07 WATERFALL among them. local world = fakeWorld({}, 5, 5, "up") world.map.def.objects = { { index = 0, x = 5, y = 4, type = 1, -- OBJECTTYPE_ITEMBALL, faced cell itemball = { item = MAX_POTION, quantity = 1 } }, } world.npcs = { { cellX = 5, cellY = 4, def = world.map.def.objects[1] }, } check(world:interact(), "an A press into an item ball is taken") eq(#world.startedScripts, 1, "and starts the pickup script") eq(world.startedScripts[1][2].item, MAX_POTION, "for the ball's item") -- And hLastTalked is set to the object, so the script's disappear names the -- ball and not whatever an earlier conversation left behind. eq(world.vm.lastTalked, 1, "hLastTalked points at the ball object (index+1)") end do -- ItemFinder from the PACK: QueueScript, so nothing runs until the menus are -- gone and the world owns the frame again. local world = fakeWorld({ itemRow(5, 6, MAX_POTION, ROUTE_42_FLAG) }, 5, 5, "down") eq(world:useFieldItem("ITEMFINDER"), "itemfinder", "the ITEMFINDER always quits the PACK; there is no refusal arm") check(world.queuedScript ~= nil, "the script is QUEUED, not run") eq(#world.startedScripts, 0, "so nothing has started yet") eq(world.queuedScript[1].op, "waitsfx", "and it is the found-something one") world:step() eq(#world.startedScripts, 1, "the drain starts it the next frame") check(world.queuedScript == nil, "and the queue is empty again") local empty = fakeWorld({}, 5, 5, "down") eq(empty:useFieldItem("ITEMFINDER"), "itemfinder", "a bare map still quits") eq(empty.queuedScript[1].op, "opentext", "and queues .Script_FoundNothing, which is silent") check(empty:useFieldItem("MAX_POTION") == nil, "any other item still falls through to the PACK's own onChoose") end -- ---- against the cache ---------------------------------------------------- -- Every row here is transcribed from pokegold: `bg_event x, y, BGEVENT_ITEM` -- out of maps/.asm and the `hiddenitem item, flag` it points at, with the -- item id from constants/item_constants.asm and the flag from -- constants/event_flags.asm. local PINNED = { { "AZALEA_TOWN", 31, 6, 38, 177 }, -- FULL_HEAL { "ROUTE_42", 16, 11, 15, 173 }, -- MAX_POTION { "TIN_TOWER_4F", 11, 6, 15, 125 }, -- MAX_POTION, the lowest flag { "DRAGONS_DEN_B1F", 31, 15, 21, 162 }, -- MAX_ELIXER { "CERULEAN_CITY", 2, 12, 152, 250 }, -- BERSERK_GENE { "MOUNT_MOON_SQUARE", 7, 7, 8, 236 }, -- MOON_STONE { "ROUTE_17", 8, 77, 21, 247 }, -- MAX_ELIXER, the highest flag { "GOLDENROD_UNDERGROUND_SWITCH_ROOM_ENTRANCES", 1, 8, 39, 143 }, -- REVIVE } -- NATIONAL_PARK and NATIONAL_PARK_BUG_CONTEST are the same map twice and share -- one flag: finding the FULL_HEAL during the contest finds it outside too. local SHARED_FLAG = { "NATIONAL_PARK", "NATIONAL_PARK_BUG_CONTEST", 6, 47, 38, 132 } local HIDDEN_ITEM_COUNT = 87 local cacheDir = os.getenv("GOLD_CACHE") if not cacheDir then cacheDir = (os.getenv("HOME") or "") .. "/Library/Application Support/LOVE/gold-dev/gold" end local mapsFile = loadfile(cacheDir .. "/data/generated/maps.lua") if not mapsFile then check(true, "no Gold cache (SKIP)") S.finish() return end local maps = mapsFile() local rows, decoded, withKey = {}, 0, 0 for mapId, def in pairs(maps) do if type(def) == "table" then for _, ev in ipairs(def.bgEvents or {}) do if ev.kind == HiddenItems.BGEVENT_ITEM then rows[#rows + 1] = true if ev.hiddenItem then decoded = decoded + 1 end if ev.scriptKey then withKey = withKey + 1 end local key = ("%s:%d:%d"):format(mapId, ev.x, ev.y) rows[key] = ev end end end end if decoded == 0 then eq(#rows, HIDDEN_ITEM_COUNT, "the cache has every BGEVENT_ITEM row") check(true, "cache predates the hiddenitem decode; re-import (SKIP)") S.finish() return end eq(#rows, HIDDEN_ITEM_COUNT, "eighty-seven hidden items in Gold") eq(decoded, HIDDEN_ITEM_COUNT, "every one of them decoded its `hiddenitem`") eq(withKey, 0, "and none of them is queued as a script: a scriptKey here is the pointer " .. "walk disassembling the flag word as opcodes") for _, want in ipairs(PINNED) do local mapId, x, y, item, event = want[1], want[2], want[3], want[4], want[5] local ev = rows[("%s:%d:%d"):format(mapId, x, y)] check(ev ~= nil, mapId .. " has a hidden item at " .. x .. "," .. y) if ev then eq(ev.hiddenItem.item, item, mapId .. " hidden item id") eq(ev.hiddenItem.event, event, mapId .. " hidden item flag") end end do local a = rows[("%s:%d:%d"):format(SHARED_FLAG[1], SHARED_FLAG[3], SHARED_FLAG[4])] local b = rows[("%s:%d:%d"):format(SHARED_FLAG[2], SHARED_FLAG[3], SHARED_FLAG[4])] check(a ~= nil and b ~= nil, "NATIONAL PARK carries its FULL_HEAL on both maps") if a and b then eq(a.hiddenItem.event, SHARED_FLAG[6], "at the same flag") eq(b.hiddenItem.event, SHARED_FLAG[6], "on the contest copy too") eq(a.hiddenItem.item, SHARED_FLAG[5], "and the same item") end end -- Every flag has to land inside wEventFlags and every item inside ItemNames: -- a mis-read `dwb` would put the item byte in the low half of the flag word. do local items = assert(loadfile(cacheDir .. "/data/generated/items.lua"))() local byIndex = {} for id, def in pairs(items) do if type(def) == "table" and def.index then byIndex[def.index] = id end end local bad = 0 for key, ev in pairs(rows) do if type(key) == "string" then if not byIndex[ev.hiddenItem.item] then bad = bad + 1 end if ev.hiddenItem.event >= 2048 then bad = bad + 1 end end end eq(bad, 0, "every decoded item is a real item and every flag is in range") end -- The point of the extractor half: those 87 pointers were being disassembled, -- and the noise is where the port's unknown-opcode rows came from. do local scripts = assert(loadfile(cacheDir .. "/data/generated/scripts.lua"))() local unknown = 0 for key, list in pairs(scripts) do if key ~= "movements" and type(list) == "table" then for _, cmd in ipairs(list) do if cmd.op == "unknown" then unknown = unknown + 1 end end end end check(unknown <= 4, ("unknown opcode rows are down to %d (64 of the old 68 were hidden items)") :format(unknown)) end S.finish()