diff --git a/assets/launcher/splash.ogv b/assets/launcher/splash.ogv new file mode 100644 index 00000000..20c4e37a Binary files /dev/null and b/assets/launcher/splash.ogv differ diff --git a/conf.lua b/conf.lua index 71cfc6c5..74c9f021 100644 --- a/conf.lua +++ b/conf.lua @@ -132,6 +132,7 @@ function love.conf(t) t.accelerometerjoystick = false else t.window.resizable = true + pcall(function() require("src.core.PadHints").apply(osName) end) if os.getenv("POKEPORT_BACKGROUND") == "1" and osName == "OS X" then pcall(function() local ffi = require("ffi") diff --git a/docs/modding.md b/docs/modding.md index 88d107c1..a90aacaf 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -1212,9 +1212,9 @@ identifiers: `pc_box_withdraw`, `pc_box_deposit`, `pc_box_release`, control the battle text/menu layer and the HP/status panels. Both receive `(next, state)` and default to `true`, so vanilla rendering is unchanged. Both hooks apply to Gen 1 and Gen 2 battles. -Text boxes and YES/NO prompts pushed above a battle inherit a `false` result -for that battle, so hiding the bottom layer cannot leave their white backing -behind under another overlay. Text boxes also pass through the hook as their +Text boxes and YES/NO prompts pushed above a battle of either generation +inherit a `false` result for that battle, so hiding the bottom layer cannot +leave their white backing behind under another overlay. Text boxes also pass through the hook as their own state, preserving selective control outside a battle; a wrapper that only owns battle presentation should return `false` only for its active battle or text-box state. @@ -1251,17 +1251,22 @@ level is part of a row label rather than a drawn field. Both are noted in RFC 0019 as follow-ups. `core.logic_speed` receives `(next, game)` once per `Game:logicSpeed()` call -(once per frame). Vanilla behavior resolves the per-category GAME SPEED -option (`GameSpeed.CATEGORIES`: overworld/battle/menu) for whichever -category `Game.speedCategoryInStack` says is active right now. A mod may -call `next(game)` and return its result to pass that resolution through, or -return a different number outright to override it for that frame (a bot mod -forcing 1X for one route segment, say, regardless of the category or saved -option). The result is clamped to the nearest valid `GameSpeed.LEVELS` entry -regardless of what a subscriber returns, so a bad value (0, negative, `nil`) -cannot destabilize the fixed-step accumulator. This hook runs *after* link -play's 1X lock and the `--speed`/equivalent run-argument override, both of -which stay unconditional and are never visible to a subscriber. +(once per frame), but only on frames where the speed is not locked. Every +battle (wild, trainer, link, online, tournament, spectate), link play and a +fixed-speed minigame lock the logic clock to 1X, and `Game:speedLocked()` +is checked before anything else: the hook is never called on those frames, +so it cannot fast-forward a battle. On every other frame vanilla behavior +resolves the per-category GAME SPEED option (`speedOverworld` or +`speedMenu`) for whichever category `Game.speedCategoryInStack` says is +active. A mod may call `next(game)` and return its result to pass that +resolution through, or return a different number outright to override it +for that frame (a bot mod forcing 1X for one route segment, say). The +result is clamped to the nearest valid `GameSpeed.LEVELS` entry regardless +of what a subscriber returns, so a bad value (0, negative, `nil`) cannot +destabilize the fixed-step accumulator. The `--speed`/`POKEPORT_SPEED` +run-argument override also wins over the hook, but not over the lock. +Gen 2 and Gen 3 have no hook; their `logicSpeed()` facades report the +shell's own value, lock included. Developer mode also arms the mod loader's dev tripwire, which flags mods that reach outside their permission set. diff --git a/docs/new-features.md b/docs/new-features.md index 4bdab07f..24e913fa 100644 --- a/docs/new-features.md +++ b/docs/new-features.md @@ -27,6 +27,8 @@ Features intentionally added beyond the original Pokémon Red, Blue, and Yellow * **Filter Find mods by game**, a generation or single-game filter of its own, with every listing showing the games and tags it declares * **Update all** in one press from the MODS tab, installing every outdated mod and every installed cart the index lists ahead of it, with a summary of what failed * **Rebindable GAME SPEED shortcuts**, SPEED - / SPEED + rows in CONTROLS that move the shoulder hotkeys to any pad button or switch them off +* **Fast-forward locks to 1X in battles and link rooms**, every 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 * **Key bar on the touch pad**, a corner toggle that slides out SAVE, LOAD, SPEED, COLOR, TILT and ZOOM for phones with no keyboard * **Save editor item verbs**, sorting the bag and PC by item number or name, filling one stack or every stack to x99, and a coin editor on every game * **Save editor bag / PC move**, a PC / BAG button on every item row that moves the stack across, clamped to the stack and slot caps diff --git a/main.lua b/main.lua index 88c657d9..ca9aa79b 100644 --- a/main.lua +++ b/main.lua @@ -16,6 +16,7 @@ end local editorMode = os.getenv("POKEPORT_EDITOR") == "1" or POKEPORT_EDITOR_MODE == true local SwitchDiagnostics = require("src.debug.SwitchDiagnostics") +local PadHints = require("src.core.PadHints") local LaunchOptions = require("src.core.LaunchOptions") local NxDisplay = require("src.core.NxDisplay") local PlatformHooks = require("src.core.PlatformHooks") @@ -132,6 +133,7 @@ do end local Game, EditorApp, Importer, TouchEditor, Studio, Prelaunch +local launcherSplash -- #887: quit-to-launcher state, shared by love.load and love.quit (both need -- it, so it is declared here rather than next to love.quit). @@ -591,7 +593,7 @@ end local deferredLaunchRequest local function launcherBusy() - return Importer ~= nil and (Importer._updateAll ~= nil + return launcherSplash ~= nil or Importer ~= nil and (Importer._updateAll ~= nil or Importer._modInstall ~= nil or Importer._cartInstall ~= nil or Importer._autoUpdateAll ~= nil) end @@ -840,6 +842,7 @@ function love.load(args) -- Edit on a save row opens the bundled editor on that slot (openEditor). Importer = makeLauncher() if not relaunched then + launcherSplash = require("src.import.LauncherSplash").new() autoUpdateMods(resolvedLaunch, not resolvedLaunch.game and "mods" or nil) end @@ -851,6 +854,14 @@ function love.update(dt) SwitchDiagnostics.maybeFlush(false) -- NX only (no-op elsewhere): follow dock/undock without waiting for SDL. NxDisplay.sync() + if launcherSplash then + if launcherSplash:update(dt) then + launcherSplash:release() + launcherSplash = nil + if Importer and Importer.resumeAfterOverlay then Importer:resumeAfterOverlay() end + end + return + end if editorMode then return EditorApp.update(dt) end if TouchEditor then return TouchEditor.update(dt) end if Studio then return Studio.update(dt) end @@ -946,6 +957,7 @@ function love.draw() GameViewport.reset() HostDisplay.beginFrame("launcher", Importer) local result = Importer:draw() + if launcherSplash then launcherSplash:draw() end HostDisplay.endFrame("launcher", Importer) return result end @@ -973,6 +985,7 @@ function love.draw() end function love.keypressed(key, scancode, isrepeat) + if launcherSplash then return end if editorMode then return EditorApp.keypressed(key) end if TouchEditor then return TouchEditor.keypressed(key) end if Studio then return Studio.keypressed(key) end @@ -983,6 +996,7 @@ function love.keypressed(key, scancode, isrepeat) end function love.keyreleased(key) + if launcherSplash then return end if editorMode or TouchEditor or Studio then return end if Importer then return end if not Game then return end @@ -990,7 +1004,9 @@ function love.keyreleased(key) end function love.gamepadpressed(joystick, button) + if launcherSplash then return end SwitchDiagnostics.onJoystickEvent("gamepadpressed", joystick, button) + if PadHints.windowMinimized() then return end if editorMode then if EditorApp and EditorApp.gamepadpressed then return EditorApp.gamepadpressed(joystick, button) @@ -1011,6 +1027,7 @@ function love.gamepadpressed(joystick, button) end function love.gamepadreleased(joystick, button) + if launcherSplash then return end SwitchDiagnostics.onJoystickEvent("gamepadreleased", joystick, button) if editorMode then if EditorApp and EditorApp.gamepadreleased then @@ -1031,7 +1048,9 @@ function love.gamepadreleased(joystick, button) end function love.gamepadaxis(joystick, axis, value) + if launcherSplash then return end SwitchDiagnostics.onJoystickEvent("gamepadaxis", joystick, axis, { value = value }) + if PadHints.windowMinimized() then value = 0 end if editorMode then if EditorApp and EditorApp.gamepadaxis then return EditorApp.gamepadaxis(joystick, axis, value) @@ -1051,7 +1070,9 @@ function love.gamepadaxis(joystick, axis, value) end function love.joystickpressed(joystick, button) + if launcherSplash then return end SwitchDiagnostics.onJoystickEvent("joystickpressed", joystick, button) + if PadHints.windowMinimized() then return end if editorMode then if EditorApp and EditorApp.joystickpressed then return EditorApp.joystickpressed(joystick, button) @@ -1071,6 +1092,7 @@ function love.joystickpressed(joystick, button) end function love.joystickreleased(joystick, button) + if launcherSplash then return end SwitchDiagnostics.onJoystickEvent("joystickreleased", joystick, button) if editorMode then if EditorApp and EditorApp.joystickreleased then @@ -1091,7 +1113,9 @@ function love.joystickreleased(joystick, button) end function love.joystickaxis(joystick, axis, value) + if launcherSplash then return end SwitchDiagnostics.onJoystickEvent("joystickaxis", joystick, axis, { value = value }) + if PadHints.windowMinimized() then value = 0 end if editorMode then if EditorApp and EditorApp.joystickaxis then return EditorApp.joystickaxis(joystick, axis, value) @@ -1111,7 +1135,9 @@ function love.joystickaxis(joystick, axis, value) end function love.joystickhat(joystick, hat, direction) + if launcherSplash then return end SwitchDiagnostics.onJoystickEvent("joystickhat", joystick, hat, { direction = direction }) + if PadHints.windowMinimized() then direction = "c" end if editorMode then if EditorApp and EditorApp.joystickhat then return EditorApp.joystickhat(joystick, hat, direction) @@ -1150,6 +1176,7 @@ end -- direction's key-up can be delivered to the OS instead of the game while -- unfocused, so reset input on either transition rather than trust it. function love.focus(f) + SwitchDiagnostics.onFocus(f) if editorMode or TouchEditor then return end if Studio then if Studio.focus then Studio.focus(f) end @@ -1227,6 +1254,7 @@ function love.handlers.intent_uri(uri) end function love.touchpressed(id, x, y, dx, dy, pressure) + if launcherSplash then return end if editorMode then -- iOS synthesizes mousepressed for the primary touch; forwarding here -- would double-fire. Android / NX need the explicit touch → click path @@ -1256,6 +1284,7 @@ function love.touchpressed(id, x, y, dx, dy, pressure) end function love.touchmoved(id, x, y, dx, dy, pressure) + if launcherSplash then return end if editorMode then return end if TouchEditor then if love.system.getOS() == "iOS" then return end @@ -1270,6 +1299,7 @@ function love.touchmoved(id, x, y, dx, dy, pressure) end function love.touchreleased(id, x, y, dx, dy, pressure) + if launcherSplash then return end if editorMode then return end if TouchEditor then if love.system.getOS() == "iOS" then return end @@ -1284,6 +1314,7 @@ function love.touchreleased(id, x, y, dx, dy, pressure) end function love.wheelmoved(x, y) + if launcherSplash then return end if editorMode then if EditorApp.wheelmoved then return EditorApp.wheelmoved(x, y) end return @@ -1321,6 +1352,7 @@ if love.system and love.system.getOS() == "Linux" end function love.mousepressed(x, y, button, istouch) + if launcherSplash then return end if not istouch then eventMouseX, eventMouseY = x, y end if TouchEditor then -- Android primary touch already arrived via love.touchpressed; a second @@ -1368,6 +1400,7 @@ function love.mousepressed(x, y, button, istouch) end function love.mousereleased(x, y, button, istouch) + if launcherSplash then return end if TouchEditor then if love.system.getOS() == "Android" then return end return TouchEditor.mousereleased(x, y, button) @@ -1388,6 +1421,7 @@ function love.mousereleased(x, y, button, istouch) end function love.mousemoved(x, y, dx, dy, istouch) + if launcherSplash then return end if not istouch then eventMouseX, eventMouseY = x, y end if TouchEditor then if love.system.getOS() == "Android" then return end @@ -1406,6 +1440,7 @@ function love.mousemoved(x, y, dx, dy, istouch) end function love.textinput(text) + if launcherSplash then return end if TouchEditor then return end if Studio then return Studio.textinput(text) end if Importer then return Importer:textinput(text) end @@ -1421,6 +1456,7 @@ end local quitToLauncher = false function love.quit() + if launcherSplash then launcherSplash:release(); launcherSplash = nil end if Importer then require("src.import.LauncherWindow").observe(0) require("src.import.LauncherWindow").flush() @@ -1484,6 +1520,7 @@ function love.quit() end function love.filedropped(file) + if launcherSplash then return end local filename = file and file.getFilename and file:getFilename() if LaunchOptions.isLaunchURI(filename) then local request = LaunchOptions.parseURI(filename) diff --git a/mod_option_schemas.json b/mod_option_schemas.json index 592ffeba..4002f364 100644 --- a/mod_option_schemas.json +++ b/mod_option_schemas.json @@ -1 +1 @@ -{"mods":[],"schema_version":1} \ No newline at end of file +{"schema_version":1,"mods":[]} \ No newline at end of file diff --git a/src/core/FixedStep.lua b/src/core/FixedStep.lua index b501c1ef..45c9ae24 100644 --- a/src/core/FixedStep.lua +++ b/src/core/FixedStep.lua @@ -6,11 +6,9 @@ local FixedStep = {} FixedStep.GB_HZ = 4194304 / 70224 FixedStep.STEP = 1 / 60 --- Hard ceiling on catch-up debt (seconds of logic time drained in one --- rendered frame). 0.25s = 15 GB steps. Game:update may lower this for the --- current speed target but must never raise it: a hitch at high multipliers --- cannot dump unbounded steps and starve input / spiral the frame. FixedStep.MAX_ACCUM = 0.25 +FixedStep.WORK_FRACTION = 0.75 +FixedStep.clock = nil local MAX_ACCUM = FixedStep.MAX_ACCUM local SMOOTH_FRAMES = 4 local SMOOTH_MAX = 1 / 60 * 2.5 @@ -69,35 +67,49 @@ function FixedStep:init(callback) self.suppressCatchup = false self.dtHistory, self.dtSum = nil, 0 self.phasedFor = nil + self.frameBreak = false end --- The anti-spiral clamp is the steps-per-frame ceiling. Game:update sets --- maxAccum to the current speed's one-frame budget, hard-capped at MAX_ACCUM --- so high fast-forward cannot turn a hitch into input starvation. FixedStep.maxAccum = MAX_ACCUM --- Compute the live catch-up ceiling for a logic-speed multiplier. --- Always ≤ MAX_ACCUM; at least two steps so ordinary vsync wobble can recover. -function FixedStep.catchupLimit(speed) +function FixedStep.catchupLimit(speed, dt) speed = tonumber(speed) or 1 if speed < 1 then speed = 1 end - local target = speed * FixedStep.STEP * 1.5 - local floor = FixedStep.STEP * 2 + local step = FixedStep.STEP + local frame = tonumber(dt) or step + if frame ~= frame or frame < step or frame > SMOOTH_MAX then frame = step end + local target = speed * frame * 1.5 + local floor = step * 2 if target < floor then target = floor end - if target > MAX_ACCUM then target = MAX_ACCUM end return target end +local function workBudget(self, dt) + local base = self.refreshPeriod or self.STEP + local frame = dt + if frame < base then frame = base end + if frame > self.STEP * 2 then frame = math.max(base, self.STEP * 2) end + return frame * FixedStep.WORK_FRACTION +end + +local function workClock() + if FixedStep.clock then return FixedStep.clock end + local timer = love and love.timer + return timer and timer.getTime or nil +end + function FixedStep:update(dt, speed) -- A hitch's oversized dt lands on the frame AFTER discardCatchup was -- called (the hitch itself already ran inside the current step); absorb -- that one frame as a single step instead of the normal accumulator so -- the burst it would otherwise release doesn't play out as a slide. + self.frameBreak = false if self.suppressCatchup then self.suppressCatchup = false self.dtHistory, self.dtSum = nil, 0 self.accum = self.STEP * RESEED_PHASE self.callback(self.STEP) + self.frameBreak = false return end -- Snap/smooth against the wall-clock frame dt first. Game speed is a @@ -109,6 +121,7 @@ function FixedStep:update(dt, speed) -- Pathological wall-clock stalls: do not let dt alone exceed the catch-up -- ceiling before speed is applied (speed amplify would just hit the clamp). if dt > MAX_ACCUM then dt = MAX_ACCUM end + local frameDt = dt local period = self.refreshPeriod local snapped = false if period and dt > 0 then @@ -140,12 +153,27 @@ function FixedStep:update(dt, speed) self.accum = self.accum - self.accum % self.STEP + target end self.accum = math.min(self.accum + dt * speed, self.maxAccum or MAX_ACCUM) + local clock = speed > 1 and workClock() or nil + local deadline = clock and (clock() + workBudget(self, frameDt)) or nil while self.accum >= self.STEP - STEP_EPS do self.accum = self.accum - self.STEP self.callback(self.STEP) + if self.frameBreak then + self.frameBreak = false + self.accum = self.accum % self.STEP + break + end + if deadline and self.accum >= self.STEP - STEP_EPS and clock() >= deadline then + self.accum = self.accum % self.STEP + break + end end end +function FixedStep:endFrame() + self.frameBreak = true +end + -- Drop any pending catch-up steps and arm the one-frame clamp above. A -- hitch inside one logic step (map seam setMap / song start) makes the -- next real-time dt huge; without this the while-loop above would advance diff --git a/src/core/Game.lua b/src/core/Game.lua index b4409285..a4230709 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -101,7 +101,10 @@ function Game:load(opts) -- apply the persisted audio + display options before anything plays self:applyOptions(self.save.options) - FixedStep:init(function(step) self:step(step) end) + FixedStep:init(function(step) + self:step(step) + self:_speedLockEdge() + end) self.fixedStep = FixedStep local OverworldState = require("src.world.OverworldController") @@ -369,12 +372,7 @@ function Game:_resolveLogicSpeed() return GameSpeed.clamp(opts and opts[key] or GameSpeed.DEFAULT) end --- The logic multiplier for this frame. Read live rather than cached so the --- Options rows take effect immediately; speedOverride is the --speed / --- POKEPORT_SPEED run argument, which wins over the saved option so a bot --- or screenshot run does not depend on whatever the player last chose. -function Game:logicSpeed() - local GameSpeed = require("src.core.GameSpeed") +function Game:speedLocked() -- Link play is always 1X on both machines, and this wins over every other -- source including POKEPORT_SPEED and every per-category option. -- Fast-forward multiplies the logic clock, so a peer at 10X burned a @@ -384,11 +382,30 @@ function Game:logicSpeed() -- either player set this to -- checked here, before the core.logic_speed -- hook ever runs, so a mod cannot defeat it either. if self.linkSession or (self.linkNet and not self.linkNet.closed) then - return 1 + return true, "link" end if Game.isFixedSpeedInStack and Game.isFixedSpeedInStack(self.stack) then - return 1 + return true, "minigame" end + if Game.speedCategoryInStack(self.stack) == "battle" then + return true, "battle" + end + return false +end + +function Game:_speedLockEdge() + if (self._frameSpeed or 1) > 1 and self:speedLocked() then + FixedStep:endFrame() + end +end + +-- The logic multiplier for this frame. Read live rather than cached so the +-- Options rows take effect immediately; speedOverride is the --speed / +-- POKEPORT_SPEED run argument, which wins over the saved option so a bot +-- or screenshot run does not depend on whatever the player last chose. +function Game:logicSpeed() + if self:speedLocked() then return 1 end + local GameSpeed = require("src.core.GameSpeed") if self.speedOverride then return GameSpeed.clamp(self.speedOverride) end -- Clamp here too, not just in _resolveLogicSpeed's vanilla path: a mod's -- core.logic_speed hook can return anything (0, negative, nil, NaN) and @@ -404,7 +421,8 @@ function Game:update(dt) -- Give the accumulator room for one full frame at the current speed, -- or the anti-spiral clamp quietly caps every level above ~15X. local speed = self:logicSpeed() - FixedStep.maxAccum = FixedStep.catchupLimit(speed) + self._frameSpeed = speed + FixedStep.maxAccum = FixedStep.catchupLimit(speed, dt) FixedStep:update(dt, speed) -- Audio runs off real time at a fixed 60Hz regardless of game speed or -- display refresh, so fades and chip synthesis keep their intended tempo @@ -783,6 +801,7 @@ end function Game:_cycleSpeed(dir) if not (self.save and self.save.options) then return end + if self:speedLocked() then return end local busy local ow = self.overworld if ow then @@ -794,12 +813,6 @@ function Game:_cycleSpeed(dir) or ow.engaging or ow.emote)) end if busy then return end - -- Cycles whichever category Game.speedCategoryInStack says is active - -- right now (RFC 0007) -- pressing the hotkey during a battle speeds up - -- just the battle, on the overworld just the walk, in a menu just the - -- menu. A single physical control that means "speed up whatever I'm - -- looking at right now" needs no new UI and matches what a player - -- pressing it mid-battle almost certainly wants. local GameSpeed = require("src.core.GameSpeed") local key = GameSpeed.optionKey(Game.speedCategoryInStack(self.stack)) self.save.options[key] = GameSpeed.cycle(self.save.options[key], dir) diff --git a/src/core/Game2.lua b/src/core/Game2.lua index bd24dd25..c084e56a 100644 --- a/src/core/Game2.lua +++ b/src/core/Game2.lua @@ -1208,7 +1208,12 @@ function Game2:load(opts) self:showCopyright() end + local stepBody FixedStep:init(function(dt) + stepBody(dt) + self:_speedLockEdge() + end) + stepBody = function(dt) -- Tool mods (autoplay, accessibility drivers, input visualizers) act on the -- same fixed-step boundary a physical controller does. Raised HERE, ahead -- of both the AUTO_INPUT arm and Input:step, for the reason Gen 1 raises it @@ -1270,7 +1275,7 @@ function Game2:load(opts) self.world:interact() end self.world:step() - end) + end end function Game2:inFillBoot() @@ -1279,7 +1284,29 @@ function Game2:inFillBoot() return self.phase == "boot" and self.stack:top() ~= nil end +function Game2:speedLocked() + if self.linkSession or (self.linkNet and not self.linkNet.closed) then + return true, "link" + end + local states = self.stack and self.stack.states + for i = #(states or {}), 1, -1 do + local state = states[i] + if state and state.isBattle then return true, "battle" end + if state and (state.isFixedSpeed or state.isMinigame) then + return true, "minigame" + end + end + return false +end + +function Game2:_speedLockEdge() + if (self._frameSpeed or 1) > 1 and self:speedLocked() then + FixedStep:endFrame() + end +end + function Game2:logicSpeed() + if self:speedLocked() then return 1 end return math.max(1, tonumber(self.speedOverride) or tonumber(self.options and self.options.speed) or 1) @@ -1310,13 +1337,14 @@ function Game2:update(dt) -- driver/CLI hook and wins over the saved option. -- pokegold engine/menus/intro_menu.asm:848 IntroSequence: boot cinema runs on the same clock as the overworld local speed = self:logicSpeed() + self._frameSpeed = speed if self.phase == "boot" then - FixedStep.maxAccum = FixedStep.catchupLimit(speed) + FixedStep.maxAccum = FixedStep.catchupLimit(speed, dt) FixedStep:update(dt, speed) return end if not self.world or not self.world.map then return end - FixedStep.maxAccum = FixedStep.catchupLimit(speed) + FixedStep.maxAccum = FixedStep.catchupLimit(speed, dt) FixedStep:update(dt, speed) end @@ -2057,6 +2085,7 @@ function Game2:hotkey(key) if loaded then self:continueGame(loaded) end return true elseif hk == "1" then + if self:speedLocked() then return true end local GameSpeed = require("src.core.GameSpeed") options.speed = GameSpeed.cycle(options.speed, 1) persist() @@ -2391,6 +2420,7 @@ function Game2:applyOptions() end function Game2:_cycleSpeed(dir) + if self:speedLocked() then return end local GameSpeed = require("src.core.GameSpeed") self.options.speed = GameSpeed.cycle(self.options.speed, dir) if self.save then self.save.options = self.options end diff --git a/src/core/Game3.lua b/src/core/Game3.lua index 3176dd0b..7fae71b0 100644 --- a/src/core/Game3.lua +++ b/src/core/Game3.lua @@ -185,6 +185,7 @@ function Game3:load(opts) if opts.arena then FixedStep:init(function(dt) self:fixedUpdate(dt) + self:_speedLockEdge() end) pcall(function() require("src.core.PresentSync").applyFixedStepPeriod() @@ -208,6 +209,7 @@ function Game3:load(opts) FixedStep:init(function(dt) self:fixedUpdate(dt) + self:_speedLockEdge() end) pcall(function() require("src.core.PresentSync").applyFixedStepPeriod() @@ -414,7 +416,7 @@ function Game3:_handleRegisteredItem() if not item then return end -- src/item_menu.c:2025 local Map = package.loaded["src.core.game3.map"] - if Map and Map.current == "FR_UNION_ROOM" then return end + if Map and require("src.core.game3.link.union_room").isUnionMap(Map.current) then return end -- src/overworld.c:2813 local Link = package.loaded["src.core.game3.link"] if type(Link) == "table" and Link.link ~= nil and Link.inLinkRoom() == true then return end @@ -585,8 +587,53 @@ function Game3:isFixedSpeed() return type(MG) == "table" and MG.isActive ~= nil and MG.isActive() == true end +local function unionRoomMap(id) + return require("src.core.game3.link.union_room").isUnionMap(id) +end + +function Game3:speedLocked() + if self:isFixedSpeed() then return true, "link" end + local Battle = package.loaded["src.core.game3.battle"] + if type(Battle) == "table" and Battle.isActive and Battle.isActive() then + return true, "battle" + end + local Transition = package.loaded["src.core.game3.battle_transition"] + if type(Transition) == "table" and Transition.isActive and Transition.isActive() then + return true, "battle" + end + local Union = package.loaded["src.core.game3.link.union_room"] + if type(Union) == "table" and Union.isActive and Union.isActive() then + return true, "link" + end + local LinkMenu = package.loaded["src.ui.game3.link_menu"] + if type(LinkMenu) == "table" then + if LinkMenu.isOpen and LinkMenu.isOpen() then return true, "link" end + local Direct = LinkMenu.Direct + if type(Direct) == "table" and Direct.isOpen and Direct.isOpen() then + return true, "link" + end + end + if self.phase ~= "field" then return false end + local Map = package.loaded["src.core.game3.map"] + if type(Map) == "table" and unionRoomMap(Map.current) then + return true, "link" + end + local Link = package.loaded["src.core.game3.link"] + if type(Link) == "table" and Link.inLinkRoom then + local ok, inRoom = pcall(Link.inLinkRoom) + if ok and inRoom == true then return true, "link" end + end + return false +end + +function Game3:_speedLockEdge() + if (self._frameSpeed or 1) > 1 and self:speedLocked() then + FixedStep:endFrame() + end +end + function Game3:logicSpeed() - if self:isFixedSpeed() then return 1 end + if self:speedLocked() then return 1 end local override = tonumber(self.speedOverride) if override then return math.max(1, override) end local b = self.phase == "boot" and self.boot @@ -603,6 +650,7 @@ end function Game3:_cycleSpeed(dir) if type(self.options) ~= "table" then return end + if self:speedLocked() then return end local GameSpeed = require("src.core.GameSpeed") local key = GameSpeed.optionKey(self:speedCategory()) self.options[key] = GameSpeed.cycle(self.options[key], dir) @@ -633,7 +681,8 @@ end function Game3:update(dt) local speed = self:logicSpeed() - FixedStep.maxAccum = FixedStep.catchupLimit(speed) + self._frameSpeed = speed + FixedStep.maxAccum = FixedStep.catchupLimit(speed, dt) FixedStep:update(dt, speed) self._audioAccum = (self._audioAccum or 0) + dt local STEP = 1 / 60 diff --git a/src/core/Input.lua b/src/core/Input.lua index 13b36d25..859ca8ce 100644 --- a/src/core/Input.lua +++ b/src/core/Input.lua @@ -2,6 +2,7 @@ -- `down` = held this frame; `pressed` = edge, consumed per fixed step. local GamepadMap = require("src.core.GamepadMap") +local PadHints = require("src.core.PadHints") local Input = {} @@ -361,16 +362,20 @@ function Input:pollPads() self.padSuppress = suppress end local backDown = nil + local minimized = nil for i = 1, #list do local e = list[i] local down = anyPadDown(pads, e.button) local sources = self.sources[e.btn] local held = sources ~= nil and sources[e.source] == true if not down then suppress[e.button] = nil end + if down and not held and minimized == nil then + minimized = PadHints.windowMinimized() + end if held and not down then release(self, e.btn, e.source) notePadRepair("release", e.button) - elseif down and not held and not mute and not suppress[e.button] + elseif down and not held and not mute and not minimized and not suppress[e.button] and not self.padActions[e.button] then if e.chord and backDown == nil then backDown = self.state.select == true or anyPadDown(pads, "back") diff --git a/src/core/PadHints.lua b/src/core/PadHints.lua new file mode 100644 index 00000000..9a6eb8ca --- /dev/null +++ b/src/core/PadHints.lua @@ -0,0 +1,73 @@ +local PadHints = {} + +PadHints.BACKGROUND_HINT = "SDL_JOYSTICK_ALLOW_BACKGROUND_EVENTS" + +function PadHints.apply(osName, ffiLike, getenv) + if osName ~= "Windows" then return false end + getenv = getenv or os.getenv + local okEnv, flag = pcall(getenv, "POKEPORT_PAD_BACKGROUND") + if okEnv and flag == "0" then return false end + local ok, set = pcall(function() + local ffi = ffiLike or require("ffi") + pcall(ffi.cdef, "int SDL_SetHint(const char *name, const char *value);") + local sdl = ffi.load("SDL2") + return sdl.SDL_SetHint(PadHints.BACKGROUND_HINT, "1") + end) + return ok and set ~= nil and set ~= 0 +end + +function PadHints.windowMinimized() + local win = love and love.window + if not win then return false end + if win.isMinimized then + local ok, v = pcall(win.isMinimized) + if ok then return v == true end + end + if win.isVisible then + local ok, v = pcall(win.isVisible) + if ok then return v == false end + end + return false +end + +function PadHints.hasFocus() + local win = love and love.window + if not (win and win.hasFocus) then return nil end + local ok, v = pcall(win.hasFocus) + if ok then return v end + return nil +end + +local fgReady = nil + +function PadHints.foreground(osName, ffiLike) + if osName ~= "Windows" then return nil end + if fgReady == false then return nil end + local ok, owner = pcall(function() + local ffi = ffiLike or require("ffi") + if not fgReady then + pcall(ffi.cdef, [[ + void *GetForegroundWindow(void); + unsigned long GetWindowThreadProcessId(void *hwnd, unsigned long *pid); + unsigned long GetCurrentProcessId(void); + ]]) + fgReady = true + end + local hwnd = ffi.C.GetForegroundWindow() + if hwnd == nil then return "none" end + local pid = ffi.new("unsigned long[1]") + ffi.C.GetWindowThreadProcessId(hwnd, pid) + return pid[0] == ffi.C.GetCurrentProcessId() and "ours" or "other" + end) + if not ok then + fgReady = false + return nil + end + return owner +end + +function PadHints._resetForTests() + fgReady = nil +end + +return PadHints diff --git a/src/core/PresentSync.lua b/src/core/PresentSync.lua index 8091392d..7ff9ba1f 100644 --- a/src/core/PresentSync.lua +++ b/src/core/PresentSync.lua @@ -8,8 +8,6 @@ -- unknown rates fail closed. -- 3. Fallback cascade — ungated / failed / abandoned waits force FrameCap -- immediately; logic never snaps to panel Hz unless sync is confirmed. --- 4. (FixedStep) catch-up debt is hard-capped so speed multipliers cannot --- spiral into input-starving multi-frame dumps. local PresentSync = {} diff --git a/src/core/SaveData.lua b/src/core/SaveData.lua index ee7522b4..09e8451a 100644 --- a/src/core/SaveData.lua +++ b/src/core/SaveData.lua @@ -1163,11 +1163,8 @@ function SaveData.slotSummary(save) local gen3 = save.generation == 3 or (vinfo and vinfo.generation == 3) or (save.engine == "game3") or false local dexCount = 0 if gen3 then - local dex = save.dex or save.pokedex or {} - local owned = dex.owned or dex.caught or {} - for _, has in pairs(owned) do - if has then dexCount = dexCount + 1 end - end + local okD, Dex = pcall(require, "src.core.game3.dex") + dexCount = okD and Dex and Dex.summaryCount and Dex.summaryCount(save) or 0 elseif gen2 then for _, has in pairs((save.pokedex and save.pokedex.caught) or {}) do if has then dexCount = dexCount + 1 end @@ -1422,7 +1419,18 @@ local function writeSlotIn(key, slotId, saveTable) end if type(saveTable) ~= "table" then return false, "missing save table" end local main, bak, tmp = slotNames(key, slotId) - local encoded = SaveSerializer.encode(saveTable) + local vinfo = type(saveTable.version) == "string" and GameVersion.info(saveTable.version) + or type(key) == "string" and GameVersion.info(key) + local gen3 = saveTable.engine == "game3" or saveTable.generation == 3 + or (vinfo and vinfo.generation == 3) or false + local body = saveTable + if not gen3 and saveTable.options ~= nil then + body = {} + for k, v in pairs(saveTable) do + if k ~= "options" then body[k] = v end + end + end + local encoded = SaveSerializer.encode(body) local fs = persistFs(nil) ensureParentDir(fs, main) if fs.getInfo(main) then @@ -2003,6 +2011,7 @@ function SaveData.buildMeta(mods, previous, sessionStart) cartHash = type(previous) == "table" and previous.cartHash or nil, sealBroken = (type(previous) == "table" and previous.sealBroken == true) or nil, mods = list, + modCount = #list, } end diff --git a/src/core/game3/battle/engine.lua b/src/core/game3/battle/engine.lua index 72351bf2..a92faa35 100644 --- a/src/core/game3/battle/engine.lua +++ b/src/core/game3/battle/engine.lua @@ -1949,7 +1949,12 @@ function Engine.performSwitch(st, adapter, side, slot, opts) end local foe = (side == "player") and st.enemy or st.player if side == "player" then - State.trackParticipant(st, st.enemy, slot) + if opts.isShift or opts.reason == "shift" then + -- pokefirered/src/battle_script_commands.c:5945 + State.resetSentPokes(st) + else + State.trackParticipant(st, st.enemy, slot) + end elseif st.player then -- pokefirered/src/battle_util.c:254 State.opponentSwitchInResetSentPokes(st, nb) diff --git a/src/core/game3/battle/init.lua b/src/core/game3/battle/init.lua index fcfc6274..fcbaa3d2 100644 --- a/src/core/game3/battle/init.lua +++ b/src/core/game3/battle/init.lua @@ -1219,8 +1219,7 @@ local function refuse_link_item(input) if not (input and st and st.link) then return false end if Ui._mode ~= "menu" or not input:wasPressed("a") then return false end if Commands.MENU[Ui._menuIndex or 1] ~= "BAG" then return false end - -- pokefirered/src/battle_message.c:251 - Ui.push(BattleText.get("STRINGID_ITEMSCANTBEUSEDNOW")) + Ui.refuseItems() return true end diff --git a/src/core/game3/battle/switch_seq.lua b/src/core/game3/battle/switch_seq.lua index 6f72fc9e..89f66a7c 100644 --- a/src/core/game3/battle/switch_seq.lua +++ b/src/core/game3/battle/switch_seq.lua @@ -159,8 +159,9 @@ local function switch_in_text(st, battler) return BattleText.get(BattleText.SWITCHINMON, SwitchSeq.switchInFill(st, battler)) end -local function apply_entry_triggers(st, side, pushMsg) +local function apply_entry_triggers(st, side, pushMsg, opts) if not st then return end + opts = opts or {} local b = st[side] if not b or not b.mon or (tonumber(b.mon.hp) or 0) <= 0 then return end @@ -168,7 +169,7 @@ local function apply_entry_triggers(st, side, pushMsg) local sideState = (side == "player") and st.playerSide or st.enemySide local hazards = sideState and sideState.hazards or {} local spikesLayers = tonumber(hazards.spikes or hazards.SPIKES) or 0 - if spikesLayers > 0 and b and b.mon then + if not opts.noSpikes and spikesLayers > 0 and b and b.mon then local isFlying = (b.type1 == 2 or b.type2 == 2 or b.type1 == "FLYING" or b.type2 == "FLYING") local hasLevitate = (b.ability == "LEVITATE" or b.ability == 26) if not isFlying and not hasLevitate then @@ -186,7 +187,7 @@ local function apply_entry_triggers(st, side, pushMsg) end -- Ability: Intimidate (ability 22 or "INTIMIDATE") - if (b.ability == 22 or b.ability == "INTIMIDATE") and (tonumber(b.mon.hp) or 0) > 0 then + if not opts.noIntimidate and (b.ability == 22 or b.ability == "INTIMIDATE") and (tonumber(b.mon.hp) or 0) > 0 then local oppSide = (side == "player") and "enemy" or "player" local opp = st[oppSide] if opp and opp.mon and (tonumber(opp.mon.hp) or 0) > 0 then @@ -232,24 +233,45 @@ local function switch_out_effects(st, battler) capture_events(function(ad) Engine.switchOutEffects(st, ad, battler) end) end +local function fallback_entry(st, sides, pushMsg, opts) + opts = opts or {} + local deferred = opts.deferred or {} + if not opts.deferIntimidate and #deferred == 0 then + for _, side in ipairs(sides) do apply_entry_triggers(st, side, pushMsg) end + return + end + for _, side in ipairs(sides) do apply_entry_triggers(st, side, pushMsg, { noIntimidate = true }) end + if opts.deferIntimidate then return end + for _, side in ipairs(deferred) do apply_entry_triggers(st, side, pushMsg, { noSpikes = true }) end + for _, side in ipairs(sides) do apply_entry_triggers(st, side, pushMsg, { noSpikes = true }) end +end + -- pokefirered/src/battle_script_commands.c:4960 -local function engine_entry_events(st, sides) +local function engine_entry_events(st, sides, opts) + opts = opts or {} local Engine = package.loaded["src.core.game3.battle.engine"] if not (Engine and Engine.switchInEffects and battle_adapter()) then return nil end return capture_events(function(ad) for _, side in ipairs(sides) do local b = st and ((type(side) == "number") and State.battler(st, side) or st[side]) if b and b.mon and (tonumber(b.mon.hp) or 0) > 0 then - Engine.switchInEffects(st, ad, b, { spikes = true }) + Engine.switchInEffects(st, ad, b, { spikes = true, deferIntimidate = opts.deferIntimidate }) + end + end + if opts.deferred and not opts.deferIntimidate then + local Abilities = require("src.core.game3.battle.abilities") + -- pokefirered/src/battle_util.c:1209 + for _ = 1, 4 do + if not (Abilities.runIntimidate(ad) or Abilities.runTrace(ad)) then break end end end end) end -local function headless_entry(st, sides) - local evs = engine_entry_events(st, sides) +local function headless_entry(st, sides, opts) + local evs = engine_entry_events(st, sides, opts) if evs == nil then - for _, side in ipairs(sides) do apply_entry_triggers(st, side, SwitchSeq._pushMsg) end + fallback_entry(st, sides, SwitchSeq._pushMsg, opts) return end for _, e in ipairs(evs) do @@ -442,7 +464,7 @@ function SwitchSeq.beginEventSwitchIn(st, side, opts) return true end ---- Retail Shift sequence: Player recalls active mon -> Enemy sends out replacement FIRST -> Player sends out replacement SECOND. +-- pokefirered/data/battle_scripts_1.s:2856 function SwitchSeq.beginShiftSwitch(st, playerSlot, enemySlot, opts) opts = opts or {} SwitchSeq.reset() @@ -459,38 +481,39 @@ function SwitchSeq.beginShiftSwitch(st, playerSlot, enemySlot, opts) switch_out_effects(st, oldBattler) State.syncBattlerToParty(st.player, st.playerParty) State.wipeVolatilesAndStages(st.player) - st.enemy = State.makeBattler(st.foeParty[enemySlot], "enemy", { partyIndex = enemySlot, st = st }) - if SwitchSeq._pushMsg then SwitchSeq._pushMsg(switch_in_text(st, st.enemy)) end st.player = State.makeBattler(st.playerParty[playerSlot], "player", { partyIndex = playerSlot, st = st, state = st }) - State.opponentSwitchInResetSentPokes(st, st.enemy) - State.trackParticipant(st, st.enemy, playerSlot) - Anim.syncDisplayFromState(st) if SwitchSeq._pushMsg then SwitchSeq._pushMsg(switch_in_text(st, st.player)) end - headless_entry(st, { "enemy", "player" }) + headless_entry(st, { "player" }, { deferIntimidate = true }) + -- pokefirered/src/battle_script_commands.c:5945 + State.resetSentPokes(st) + st.enemy = State.makeBattler(st.foeParty[enemySlot], "enemy", { partyIndex = enemySlot, st = st }) + -- pokefirered/src/battle_util.c:254 + State.opponentSwitchInResetSentPokes(st, st.enemy) + Anim.syncDisplayFromState(st) + if SwitchSeq._pushMsg then SwitchSeq._pushMsg(switch_in_text(st, st.enemy)) end + headless_entry(st, { "enemy" }, { deferred = { "player" } }) finish() return false end local steps = { - -- 1. Player recall { kind = "msg", data = { text = withdrawMsg } }, { kind = "withdraw", data = { side = "player" } }, - -- 2. Enemy sendout first (retail FRLG order) - { kind = "swap_data", data = { side = "enemy", newSlot = enemySlot, isShift = true, reason = "shift" } }, - { kind = "msg_sendout", data = { side = "enemy" } }, - { kind = "sendout_enemy", data = { slot = enemySlot } }, - { kind = "shiny_check", data = { side = "enemy" } }, - { kind = "cry", data = { side = "enemy" } }, - { kind = "healthbox", data = { side = "enemy" } }, - -- 3. Player sendout second { kind = "swap_data", data = { side = "player", newSlot = playerSlot, isShift = true, reason = "shift" } }, { kind = "msg_sendout", data = { side = "player" } }, { kind = "sendout_player", data = { slot = playerSlot } }, { kind = "shiny_check", data = { side = "player" } }, { kind = "cry", data = { side = "player" } }, { kind = "healthbox", data = { side = "player" } }, - -- 4. Entry abilities and hazards in speed order - { kind = "entry_triggers", data = { sides = { "enemy", "player" } } }, + { kind = "entry_triggers", data = { side = "player", deferIntimidate = true } }, + -- pokefirered/data/battle_scripts_1.s:2874 + { kind = "swap_data", data = { side = "enemy", newSlot = enemySlot, reason = "shift" } }, + { kind = "msg_sendout", data = { side = "enemy" } }, + { kind = "sendout_enemy", data = { slot = enemySlot } }, + { kind = "shiny_check", data = { side = "enemy" } }, + { kind = "cry", data = { side = "enemy" } }, + { kind = "healthbox", data = { side = "enemy" } }, + { kind = "entry_triggers", data = { side = "enemy", deferred = { "player" } } }, } SwitchSeq._steps = steps @@ -591,16 +614,22 @@ local function run_step(step) return end switch_out_effects(st, st and st[side]) + local isShift = d.isShift or d.reason == "shift" if side == "player" then if st and st.player then - if not d.isShift and d.reason ~= "shift" then + if not isShift then State.trackParticipant(st, st.enemy, st.player.partyIndex or 1) end State.syncBattlerToParty(st.player, st.playerParty) State.wipeVolatilesAndStages(st.player, { batonPass = d.batonPass }) end st.player = State.makeBattler(st.playerParty[newSlot], "player", { partyIndex = newSlot, st = st }) - State.trackParticipant(st, st.enemy, newSlot) + if isShift then + -- pokefirered/src/battle_script_commands.c:5945 + State.resetSentPokes(st) + else + State.trackParticipant(st, st.enemy, newSlot) + end else if st and st.enemy then State.syncBattlerToParty(st.enemy, st.foeParty) @@ -608,9 +637,6 @@ local function run_step(step) end st.enemy = State.makeBattler(st.foeParty[newSlot], "enemy", { partyIndex = newSlot, st = st, state = st }) State.opponentSwitchInResetSentPokes(st, st.enemy) - if d.isShift or d.reason == "shift" then - st.enemy.participants = {} - end end Anim.syncDisplayFromState(st) advance() @@ -753,11 +779,10 @@ local function run_step(step) end) sides = sorted end - local evs = engine_entry_events(st, sides) + local entryOpts = { deferIntimidate = d.deferIntimidate, deferred = d.deferred } + local evs = engine_entry_events(st, sides, entryOpts) if evs == nil then - for _, sSide in ipairs(sides) do - apply_entry_triggers(st, sSide, SwitchSeq._pushMsg) - end + fallback_entry(st, sides, SwitchSeq._pushMsg, entryOpts) elseif #evs > 0 then local AnimSeq = require("src.core.game3.battle.anim_seq") local Ui = require("src.core.game3.battle.ui") diff --git a/src/core/game3/battle/ui.lua b/src/core/game3/battle/ui.lua index 45baa7f2..a1605e94 100644 --- a/src/core/game3/battle/ui.lua +++ b/src/core/game3/battle/ui.lua @@ -435,7 +435,16 @@ local function restore_action_menu() if Message and Message.open then Message.reset() end end +-- pokefirered/src/battle_main.c:3182 BattleScript_ActionSelectionItemsCantBeUsed +function Ui.refuseItems() + Ui._selCmd = nil + Ui._selReturn = "menu" + Ui._mode = "selmsg" + Ui.push(BattleText.get("STRINGID_ITEMSCANTBEUSEDNOW")) +end + local function open_battle_bag() + if Ui._st and Ui._st.link then return Ui.refuseItems() end local BagMenu = require("src.ui.game3.bag_menu") local Runtime = package.loaded["src.core.game3.runtime"] local session = Ui._session diff --git a/src/core/game3/collision.lua b/src/core/game3/collision.lua index 07dbef53..75869365 100644 --- a/src/core/game3/collision.lua +++ b/src/core/game3/collision.lua @@ -2,6 +2,7 @@ -- Built from mapDef.blocks + tileset.collision (Gen2 COLL_* quads baked from -- FRLG metatile attrs at extract). Does not call World:step / Player:tryMove. +local Connections = require("src.core.game3.connections") local Collision = {} local DELTA = { @@ -582,8 +583,7 @@ function Collision.connectionLanding(destDef, conn, dir, fromCx, fromCy) else return nil end - x = math.max(0, math.min(destW - 1, x)) - y = math.max(0, math.min(destH - 1, y)) + if x < 0 or y < 0 or x >= destW or y >= destH then return nil end return x, y end @@ -614,17 +614,17 @@ function Collision.tryConnection(game, fromX, fromY, dir, run) local mapDef = Collision._mapDef if not mapDef or type(mapDef.connections) ~= "table" then return false end - local conn = mapDef.connections[dir] - or mapDef.connections[DIR_CONN[dir] or ""] - if not conn then return false end - local destMap = conn.map or conn.mapId - if type(destMap) ~= "string" then return false end - local data = game and game.data and game.data.maps - local destDef = data and data[destMap] - if not destDef then return false end + if not data then return false end local Map = require("src.core.game3.map") - if Map.ensureMidLayout then Map.ensureMidLayout(game, destMap, destDef) end + -- pokefirered/src/fieldmap.c:673 + local conn, destDef = Connections.incoming(mapDef, DIR_CONN[dir], fromX, fromY, function(id) + local def = data[id] + if def and Map.ensureMidLayout then Map.ensureMidLayout(game, id, def) end + return def + end) + if not conn then return false end + local destMap = conn.map local lx, ly = Collision.connectionLanding(destDef, conn, dir, fromX, fromY) if not lx then return false end diff --git a/src/core/game3/connections.lua b/src/core/game3/connections.lua new file mode 100644 index 00000000..37d5f15f --- /dev/null +++ b/src/core/game3/connections.lua @@ -0,0 +1,117 @@ +local Connections = {} + +local CARDINAL = { + north = "north", south = "south", west = "west", east = "east", + up = "north", down = "south", left = "west", right = "east", +} + +function Connections.cardinal(dir) + return CARDINAL[dir] +end + +local function entry(dir, c) + local d = CARDINAL[dir] + local map = type(c) == "table" and (c.map or c.mapId) or c + if not d or type(map) ~= "string" then return nil end + return { dir = d, map = map, offset = type(c) == "table" and tonumber(c.offset) or 0 } +end + +function Connections.each(def) + local out = {} + local conns = def and def.connections + if type(conns) ~= "table" then return out end + local n = #conns + for i = 1, n do + local c = conns[i] + local e = type(c) == "table" and entry(c.dir or c.direction, c) + if e then out[#out + 1] = e end + end + local keyed = {} + for k, c in pairs(conns) do + if type(k) == "string" then + local e = entry(k, c) + if e then keyed[#keyed + 1] = { k = k, e = e } end + end + end + table.sort(keyed, function(a, b) return a.k < b.k end) + for _, kv in ipairs(keyed) do out[#out + 1] = kv.e end + return out +end + +function Connections.sizeOf(def) + local L = def and def.midLayout + if L and L.width and L.height then return L.width, L.height end + return (tonumber(def and def.width) or 0) * 2, (tonumber(def and def.height) or 0) * 2 +end + +local function vertical(dir) + return dir == "north" or dir == "south" +end + +-- pokefirered/src/fieldmap.c:724 +local function coordInIncoming(coord, srcMax, destMax, offset) + local lo = math.max(offset, 0) + if destMax + offset < srcMax then srcMax = destMax + offset end + return lo <= coord and coord <= srcMax +end + +-- pokefirered/src/fieldmap.c:686 +function Connections.incoming(def, dir, x, y, destDefOf) + dir = CARDINAL[dir] + if not dir then return nil end + local srcW, srcH = Connections.sizeOf(def) + for _, c in ipairs(Connections.each(def)) do + if c.dir == dir then + local destDef = destDefOf(c.map) + if destDef then + local w, h = Connections.sizeOf(destDef) + local hit + if vertical(dir) then + hit = coordInIncoming(x, srcW, w, c.offset) + else + hit = coordInIncoming(y, srcH, h, c.offset) + end + if hit then return c, destDef end + end + end + end + return nil +end + +-- pokefirered/src/fieldmap.c:745 +function Connections.localPos(n, x, y, srcW, srcH) + local w, h = Connections.sizeOf(n.def) + local off = tonumber(n.offset) or 0 + local lx, ly + if n.dir == "north" then + lx, ly = x - off, h + y + elseif n.dir == "south" then + lx, ly = x - off, y - srcH + elseif n.dir == "west" then + lx, ly = w + x, y - off + elseif n.dir == "east" then + lx, ly = x - srcW, y - off + else + return nil + end + local along = vertical(n.dir) and lx or ly + local span = vertical(n.dir) and w or h + if along < 0 or along >= span then return nil end + return lx, ly +end + +-- pokefirered/src/fieldmap.c:761 +function Connections.atPos(list, x, y, srcW, srcH) + for _, n in ipairs(list or {}) do + local dir = n.dir + local skip = (dir == "north" and y >= 0) or (dir == "south" and y < srcH) + or (dir == "west" and x >= 0) or (dir == "east" and x < srcW) + if not skip and n.def then + local lx, ly = Connections.localPos(n, x, y, srcW, srcH) + if lx then return n, lx, ly end + end + end + return nil +end + +return Connections diff --git a/src/core/game3/dataset.lua b/src/core/game3/dataset.lua index cecc8f73..60db21d3 100644 --- a/src/core/game3/dataset.lua +++ b/src/core/game3/dataset.lua @@ -339,6 +339,7 @@ function Dataset.hydrate(game) Space.ensureBundle(nil) nEvents = Space.attachEventsToMaps(game.data.maps, Space.bundle) or 0 end + require("src.core.game3.link.union_plaza_map").ensure(game) local NativeTileset = require("src.core.game3.tileset_native") if NativeTileset.install then diff --git a/src/core/game3/dex.lua b/src/core/game3/dex.lua index fbebf608..27c1ebcc 100644 --- a/src/core/game3/dex.lua +++ b/src/core/game3/dex.lua @@ -242,6 +242,65 @@ function Dex.countOwned(dex, mode) return Dex.countCaught(dex, mode) end +local function keyed(t, id) + if type(t) ~= "table" then return nil end + local v = t[id] + if v == nil then v = t[tostring(id)] end + if v == nil then v = t[string.format("0x%X", id)] end + return v +end + +-- pokefirered/src/event_data.c:107 +function Dex.nationalEnabled(save) + if type(save) ~= "table" then return false end + local dex = type(save.dex) == "table" and save.dex or {} + if dex.national == true or dex.nationalUnlocked == true or dex.isNationalUnlocked == true + or save.national_dex_unlocked == true then + return true + end + local flag = keyed(save.flags, 0x840) + if flag == true or (type(save.flags) == "table" and save.flags.FLAG_SYS_NATIONAL_DEX == true) then + return true + end + local var = keyed(save.vars, 0x404E) + if var == nil and type(save.vars) == "table" then var = save.vars.VAR_NATIONAL_DEX end + return tonumber(var) == 0x6258 +end + +-- pokefirered/src/main_menu.c:643 +function Dex.summaryCount(save) + if type(save) ~= "table" then return 0 end + local dex = type(save.dex) == "table" and save.dex + or type(save.pokedex) == "table" and save.pokedex or {} + local national = Dex.nationalEnabled(save) + local counted, n = {}, 0 + for _, key in ipairs({ "caught", "owned" }) do + for sp, on in pairs(type(dex[key]) == "table" and dex[key] or {}) do + local id = tonumber(sp) + if not id and type(sp) == "string" then + local ok, res = pcall(resolve_species_id, sp) + id = ok and tonumber(res) or nil + end + if id and on and on ~= 0 and not counted[id] + and id >= 1 and (national or id <= Dex.KANTO_MAX) then + counted[id] = true + n = n + 1 + end + end + end + local ci = type(save.modData) == "table" and type(save.modData.cartImport) == "table" + and save.modData.cartImport + local maxNat = national and Dex.NATIONAL_MAX or Dex.KANTO_MAX + for _, nat in ipairs(ci and type(ci.dexOwned) == "table" and ci.dexOwned or {}) do + nat = tonumber(nat) + if nat and nat >= 1 and nat <= maxNat and not (nat <= Dex.HOST_MAX and counted[nat]) then + if nat <= Dex.HOST_MAX then counted[nat] = true end + n = n + 1 + end + end + return n +end + --- Split for returnToHost: hostUpdates (1–251) + national sidecar (252+). function Dex.splitForHost(dex) local hostSeen, hostCaught = {}, {} diff --git a/src/core/game3/doors.lua b/src/core/game3/doors.lua index 8621eeb2..8ec4401e 100644 --- a/src/core/game3/doors.lua +++ b/src/core/game3/doors.lua @@ -133,8 +133,8 @@ local function resolveLayout(mapId) end end - if Map and Map.neighbors then - for _, n in pairs(Map.neighbors) do + if Map and Map.neighborList then + for _, n in ipairs(Map.neighborList) do if n.def and n.def.midLayout then if norm(n.map or n.mapId) == key then return n.def.midLayout, n.def.pair diff --git a/src/core/game3/field.lua b/src/core/game3/field.lua index 28987fe7..fc1a96c3 100644 --- a/src/core/game3/field.lua +++ b/src/core/game3/field.lua @@ -474,6 +474,7 @@ function Field.useItemfinder(session, showOWMessage) width = layout and layout.width, height = layout and layout.height, neighbors = Map.neighbors, + neighborList = Map.neighborList, eventsFor = function(id) return get_map_bg_events(game, id) end, }) @@ -995,14 +996,21 @@ function Field.executeFieldMove(payload) local Map = require("src.core.game3.map") local def = Map.currentDef() local layout = def and def.midLayout - if layout and layout.midAt and layout.setMidAt then - local Collision = require("src.core.game3.collision") + if layout then local FieldMoves = require("src.core.game3.field_moves") + local w, h = layout.width or 0, layout.height or 0 + local Collision = require("src.core.game3.collision") + -- pokefirered/src/fldeff_rocksmash.c:31 + local elev = P.elevation or 0 FieldMoves.mowGrass3x3(P.cellX, P.cellY, function(x, y) return layout:midAt(x, y) end, - function(x, y, mid) layout:setMidAt(x, y, mid) end, - function(x, y) return Collision.isGrass(x, y) end) - local okFv, FieldView = pcall(require, "src.core.game3.field_view") - if okFv and FieldView then FieldView._nativeDirty = true end + function(x, y, mid) Field.setMetatile(x, y, mid, false) end, + function(x, y) + -- pokefirered/src/fldeff_cut.c:219 + return x >= 0 and y >= 0 and x < w and y < h and layout:elevAt(x, y) == elev + and Collision.isGrass(x, y) + end) + -- pokefirered/src/field_effect_helpers.c:313 + if not Collision.isGrass(P.cellX, P.cellY) then FieldEffects.clearTallGrass() end end FieldEffects.startCutGrass(P.cellX, P.cellY, function() Field.locked = false diff --git a/src/core/game3/field_moves.lua b/src/core/game3/field_moves.lua index dc6abf33..be04f8c6 100644 --- a/src/core/game3/field_moves.lua +++ b/src/core/game3/field_moves.lua @@ -106,6 +106,8 @@ FieldMoves.CUT_GRASS_METATILES = { [0x300] = 0x310, -- FuchsiaCity: SafariZoneTreeTopLeft_Grass -> SafariZoneTreeTopLeft_Mowed [0x301] = 0x311, -- FuchsiaCity: SafariZoneTreeTopMiddle_Grass -> SafariZoneTreeTopMiddle_Mowed [0x302] = 0x312, -- FuchsiaCity: SafariZoneTreeTopRight_Grass -> SafariZoneTreeTopRight_Mowed + -- pokefirered/include/constants/metatile_labels.h:295 + [0x284] = 0x281, } -- Metatile terrain / collision behaviors @@ -757,7 +759,8 @@ end -- `getMetatileFn(x, y)`: returns numeric metatileId -- `setMetatileFn(x, y, newMetatileId)`: applies new metatileId -- Returns count of cut tiles -function FieldMoves.mowGrass3x3(cx, cy, getMetatileFn, setMetatileFn, isGrassFn) +-- pokefirered/src/fldeff_cut.c:200 +function FieldMoves.mowGrass3x3(cx, cy, getMetatileFn, setMetatileFn, sameElevationFn) if not getMetatileFn or not setMetatileFn then return 0 end local count = 0 @@ -765,13 +768,10 @@ function FieldMoves.mowGrass3x3(cx, cy, getMetatileFn, setMetatileFn, isGrassFn) for dx = -1, 1 do local x = cx + dx local y = cy + dy - local mid = getMetatileFn(x, y) - if mid then - local newMid = FieldMoves.CUT_GRASS_METATILES[mid] - if not newMid and isGrassFn and isGrassFn(x, y) then - -- Default flat-ground replacement in general tileset - newMid = 0x001 - end + if not sameElevationFn or sameElevationFn(x, y) then + local mid = getMetatileFn(x, y) + -- pokefirered/src/fldeff_cut.c:237 + local newMid = mid and FieldMoves.CUT_GRASS_METATILES[mid] if newMid then setMetatileFn(x, y, newMid) count = count + 1 diff --git a/src/core/game3/field_view.lua b/src/core/game3/field_view.lua index babc307e..e4cd954e 100644 --- a/src/core/game3/field_view.lua +++ b/src/core/game3/field_view.lua @@ -433,10 +433,19 @@ local function actorPriority(a) end end +local function sortActors(a, b) + local ay = a.subpriority or a.sortY or a.y + local by = b.subpriority or b.sortY or b.y + if ay == by then return (a.i or 0) < (b.i or 0) end + return ay < by +end + -- event_object_movement.c:8379-8387, scrcmd.c:1130 -local function applyDrawOrder(actors) - local underActors = {} - local overActors = {} +local function applyDrawOrder(actors, underActors, overActors) + underActors = underActors or {} + overActors = overActors or {} + for i = #underActors, 1, -1 do underActors[i] = nil end + for i = #overActors, 1, -1 do overActors[i] = nil end for _, a in ipairs(actors) do local obj = a.eventObject if obj and obj.fixedPriority then @@ -454,18 +463,14 @@ local function applyDrawOrder(actors) underActors[#underActors + 1] = a end end - local function sortActors(a, b) - local ay = a.subpriority or a.sortY or a.y - local by = b.subpriority or b.sortY or b.y - if ay == by then return (a.i or 0) < (b.i or 0) end - return ay < by - end table.sort(underActors, sortActors) table.sort(overActors, sortActors) return underActors, overActors end FieldView.applyDrawOrder = applyDrawOrder +local owOpts = {} + local function drawSingleActor(game, mapDef, a, camX, camY) local daytime = daytimeFor(game, mapDef) local okOw, OwSprites = pcall(require, "src.core.game3.ow_sprites") @@ -478,15 +483,14 @@ local function drawSingleActor(game, mapDef, a, camX, camY) drew = true end if not drew and useOw and a.graphicsId ~= nil then - local opts = { - bow = a.bow, - fieldMove = a.fieldMove, - fieldMoveFrame = a.fieldMoveFrame, - fishing = a.fishing, - fishFrame = a.fishFrame, - frame = a.frame, - running = a.running, - } + local opts = owOpts + opts.bow = a.bow + opts.fieldMove = a.fieldMove + opts.fieldMoveFrame = a.fieldMoveFrame + opts.fishing = a.fishing + opts.fishFrame = a.fishFrame + opts.frame = a.frame + opts.running = a.running drew = OwSprites.draw( a.graphicsId, a.x, a.y, camX, camY, a.facing, a.walkPhase, a.stepFlip, opts) end @@ -509,11 +513,16 @@ local function drawSingleActor(game, mapDef, a, camX, camY) if billboarded then love.graphics.pop() end end +local frameActors, frameUnder, frameOver = {}, {}, {} +local npcActors = setmetatable({}, { __mode = "k" }) +local playerActor = {} + local function collectGame3Actors(game, mapDef, camX, camY, px, py, facing, walkPhase, stepFlip, playerYOff, playerXOff) local okO, Objects = pcall(require, "src.core.game3.objects") local okOw, OwSprites = pcall(require, "src.core.game3.ow_sprites") local useOw = okOw and OwSprites and OwSprites.ready and OwSprites.ready() - local actors = {} + local actors = frameActors + for i = #actors, 1, -1 do actors[i] = nil end local hasObjects = okO and Objects and Objects.hasMap and Objects.hasMap() if hasObjects then @@ -522,23 +531,27 @@ local function collectGame3Actors(game, mapDef, camX, camY, px, py, facing, walk if eo.moving and eo.targetY and eo.targetY > (eo.cellY or 0) then sortY = math.max(sortY, eo.targetY * CELL) end - actors[#actors + 1] = { - kind = "npc", - i = eo.localId, - obj = eo.def, - eventObject = eo, - elevation = eo.elevation or (eo.def and eo.def.elevation) or 0, - x = (eo.px or (eo.cellX * CELL)) + (eo.raiseX or 0), - y = (eo.py or (eo.cellY * CELL)) + (eo.raiseY or 0), - sortY = sortY, - facing = eo.facing or "down", - walkPhase = Objects.walkPhase(eo), - stepFlip = eo.stepFlip and true or false, - bow = (eo.bowFrames and eo.bowFrames > 0) or eo.raiseHand == true, - frame = eo.customFrame, - sprite = eo.sprite or spriteNameForObj(eo.def or {}), - graphicsId = eo.graphicsId or (eo.def and (eo.def.graphicsId or eo.def.graphics)), - } + local a = npcActors[eo] + if not a then + a = { kind = "npc", eventObject = eo } + npcActors[eo] = a + end + a.i = eo.localId + a.obj = eo.def + a.elevation = eo.elevation or (eo.def and eo.def.elevation) or 0 + a.x = (eo.px or (eo.cellX * CELL)) + (eo.raiseX or 0) + a.y = (eo.py or (eo.cellY * CELL)) + (eo.raiseY or 0) + a.sortY = sortY + a.facing = eo.facing or "down" + a.walkPhase = Objects.walkPhase(eo) + a.stepFlip = eo.stepFlip and true or false + a.bow = (eo.bowFrames and eo.bowFrames > 0) or eo.raiseHand == true + a.frame = eo.customFrame + a.sprite = eo.sprite or spriteNameForObj(eo.def or {}) + a.graphicsId = eo.graphicsId or (eo.def and (eo.def.graphicsId or eo.def.graphics)) + a.priority = nil + a.subpriority = nil + actors[#actors + 1] = a end collectNeighborActors(actors, 10000, currentMapId(game), resolveMapDef(game, currentMapId(game))) @@ -606,28 +619,30 @@ local function collectGame3Actors(game, mapDef, camX, camY, px, py, facing, walk if not fieldMove and FieldMod and FieldMod.fishingPose then fishFrame, fishX2, fishY2 = FieldMod.fishingPose() end - actors[#actors + 1] = { - kind = "player", - elevation = PlayerMod and PlayerMod.elevation or 3, - x = px + (playerXOff or 0) + (fishX2 or 0), - y = py + (playerYOff or 0) + (fishY2 or 0), - sortY = playerSortY, - facing = facing or "down", - walkPhase = (walkPhase == 1 or walkPhase == true) and 1 or 0, - stepFlip = stepFlip and true or false, - fieldMove = fieldMove, - fieldMoveFrame = fieldMoveFrame, - fishing = fishFrame ~= nil, - fishFrame = fishFrame, - running = PlayerMod and PlayerMod.runPose and PlayerMod.runPose() or nil, - sprite = playerSpriteName(game), - graphicsId = useOw and OwSprites.playerGraphicsId(game) or nil, - } + local a = playerActor + a.kind = "player" + a.elevation = PlayerMod and PlayerMod.elevation or 3 + a.x = px + (playerXOff or 0) + (fishX2 or 0) + a.y = py + (playerYOff or 0) + (fishY2 or 0) + a.sortY = playerSortY + a.facing = facing or "down" + a.walkPhase = (walkPhase == 1 or walkPhase == true) and 1 or 0 + a.stepFlip = stepFlip and true or false + a.fieldMove = fieldMove + a.fieldMoveFrame = fieldMoveFrame + a.fishing = fishFrame ~= nil + a.fishFrame = fishFrame + a.running = PlayerMod and PlayerMod.runPose and PlayerMod.runPose() or nil + a.sprite = playerSpriteName(game) + a.graphicsId = useOw and OwSprites.playerGraphicsId(game) or nil + a.priority = nil + a.subpriority = nil + actors[#actors + 1] = a end local follower = require("src.world.game3.Follower").actor() if follower then actors[#actors + 1] = follower end - return applyDrawOrder(actors) + return applyDrawOrder(actors, frameUnder, frameOver) end --- Collect visible tile draws grouped by palette slot for batched GbcPalette.with. diff --git a/src/core/game3/itemfinder.lua b/src/core/game3/itemfinder.lua index bdfe10df..53852380 100644 --- a/src/core/game3/itemfinder.lua +++ b/src/core/game3/itemfinder.lua @@ -1,3 +1,4 @@ +local Connections = require("src.core.game3.connections") local Itemfinder = {} local SE_ITEMFINDER = 65 @@ -36,54 +37,22 @@ local function hidden_item_at_pos(events, x, y, flagSet) return false end -local function neighbor(neighbors, a, b) - return neighbors and (neighbors[a] or neighbors[b]) -end - -local function layout_size(def) - local L = def and def.midLayout - if L then return L.width or 0, L.height or 0 end - return (tonumber(def and def.width) or 0) * 2, (tonumber(def and def.height) or 0) * 2 +local function neighbor_list(opts) + if opts.neighborList then return opts.neighborList end + local list = {} + for k, n in pairs(opts.neighbors or {}) do + local dir = Connections.cardinal(n.dir or k) + if dir then + list[#list + 1] = { dir = dir, map = n.map, mapId = n.mapId, def = n.def, offset = n.offset } + end + end + return list end -- pokefirered/src/fieldmap.c:761, src/itemfinder.c:312 local function connected_hidden_item(opts, lx, ly) - local curW, curH = opts.width, opts.height - local n = opts.neighbors - local conn, cx, cy - if ly < 0 then - conn = neighbor(n, "north", "up") - if conn then - local w, h = layout_size(conn.def) - local off = tonumber(conn.offset) or 0 - if lx - off >= 0 and lx - off < w then cx, cy = lx - off, h + ly end - end - end - if not cx and ly >= curH then - conn = neighbor(n, "south", "down") - if conn then - local w = layout_size(conn.def) - local off = tonumber(conn.offset) or 0 - if lx - off >= 0 and lx - off < w then cx, cy = lx - off, ly - curH end - end - end - if not cx and lx < 0 then - conn = neighbor(n, "west", "left") - if conn then - local w, h = layout_size(conn.def) - local off = tonumber(conn.offset) or 0 - if ly - off >= 0 and ly - off < h then cx, cy = w + lx, ly - off end - end - end - if not cx and lx >= curW then - conn = neighbor(n, "east", "right") - if conn then - local _, h = layout_size(conn.def) - local off = tonumber(conn.offset) or 0 - if ly - off >= 0 and ly - off < h then cx, cy = lx - curW, ly - off end - end - end - if not cx then return false end + local conn, cx, cy = Connections.atPos(neighbor_list(opts), lx, ly, opts.width, opts.height) + if not conn then return false end return hidden_item_at_pos(opts.eventsFor(conn.map or conn.mapId), cx, cy, opts.flagSet) end diff --git a/src/core/game3/link/union_plaza_map.lua b/src/core/game3/link/union_plaza_map.lua new file mode 100644 index 00000000..90321305 --- /dev/null +++ b/src/core/game3/link/union_plaza_map.lua @@ -0,0 +1,213 @@ +local Plaza = {} + +Plaza.MAP_ID = "FR_UNION_ROOM_PLAZA" +Plaza.SOURCE_ID = "FR_UNION_ROOM" +Plaza.CAP = 40 +Plaza.WIDTH = 25 +Plaza.HEIGHT = 25 +Plaza.EXIT_X, Plaza.EXIT_Y = 12, 24 +Plaza.ATTENDANT_LOCAL_ID = 1 + +Plaza.ROLES = { + "ABCDTTTTTTTTTTTTTTTTTTTTE", + "abcdtttttttttttttttttttte", + "FGHHHHHHHHHHHHHHHHHHHHHIJ", + "fg.....................ij", + "L.......................R", + "L.......................R", + "L.......................R", + "L.......................R", + "L.......................R", + "L.......................R", + "L..........123..........R", + "L..........456..........R", + "L..........789..........R", + "L.......................R", + "L.......................R", + "L.......................R", + "L.......................R", + "L.......................R", + "L.......................R", + "L.......................R", + "L.......................R", + "L.......................R", + "L.......................R", + "Kk_________mno_________pq", + "###########xXz###########", +} + +Plaza.SOURCE = { + A = { 0, 0 }, B = { 1, 0 }, C = { 2, 0 }, D = { 3, 0 }, T = { 4, 0 }, E = { 14, 0 }, + a = { 0, 1 }, b = { 1, 1 }, c = { 2, 1 }, d = { 3, 1 }, t = { 4, 1 }, e = { 14, 1 }, + F = { 0, 2 }, G = { 1, 2 }, H = { 2, 2 }, I = { 13, 2 }, J = { 14, 2 }, + f = { 0, 3 }, g = { 1, 3 }, ["."] = { 2, 3 }, i = { 13, 3 }, j = { 14, 3 }, + L = { 0, 4 }, R = { 14, 4 }, + ["1"] = { 6, 5 }, ["2"] = { 7, 5 }, ["3"] = { 8, 5 }, + ["4"] = { 6, 6 }, ["5"] = { 7, 6 }, ["6"] = { 8, 6 }, + ["7"] = { 6, 7 }, ["8"] = { 7, 7 }, ["9"] = { 8, 7 }, + K = { 0, 10 }, k = { 1, 10 }, _ = { 2, 10 }, m = { 6, 10 }, n = { 7, 10 }, o = { 8, 10 }, + p = { 13, 10 }, q = { 14, 10 }, + ["#"] = { 0, 11 }, x = { 6, 11 }, X = { 7, 11 }, z = { 8, 11 }, +} + +Plaza.BLOCKED = { + A = true, B = true, C = true, D = true, T = true, E = true, + a = true, b = true, c = true, d = true, t = true, e = true, + ["#"] = true, +} + +local CELL_XS = { 3, 6, 9, 12, 15, 18, 21 } +local CELL_YS = { 5, 8, 11, 14, 17, 20 } +local DOOR_X, DOOR_Y = 12, 23 + +local cells, bySlot, byKey = {}, {}, {} +for _, y in ipairs(CELL_YS) do + for _, x in ipairs(CELL_XS) do + if not (x == DOOR_X and y == 20) then + cells[#cells + 1] = { x = x, y = y } + end + end +end +table.sort(cells, function(p, q) + local dp = math.abs(p.x - DOOR_X) + math.abs(p.y - DOOR_Y) + local dq = math.abs(q.x - DOOR_X) + math.abs(q.y - DOOR_Y) + if dp ~= dq then return dp < dq end + local ap, aq = math.abs(p.x - DOOR_X), math.abs(q.x - DOOR_X) + if ap ~= aq then return ap < aq end + if p.x ~= q.x then return p.x < q.x end + return p.y > q.y +end) +for i, c in ipairs(cells) do + if i <= Plaza.CAP then + bySlot[i] = c + byKey[c.y * 64 + c.x] = i + end +end +Plaza.CELLS = cells + +function Plaza.cellFor(slot) + local c = bySlot[tonumber(slot) or -1] + if not c then return nil end + return c.x, c.y, "down" +end + +function Plaza.slotAt(x, y) + x, y = tonumber(x), tonumber(y) + if not (x and y) then return nil end + return byKey[y * 64 + x] +end + +function Plaza.entry() + return Plaza.EXIT_X, Plaza.EXIT_Y, "up" +end + +function Plaza.roleAt(x, y) + local row = Plaza.ROLES[y + 1] + if not row or x < 0 or x >= #row then return nil end + return row:sub(x + 1, x + 1) +end + +local function copyRow(row) + local out = {} + for k, v in pairs(row) do out[k] = v end + return out +end + +function Plaza.buildDecoded(src) + local cellsOut = {} + for y = 0, Plaza.HEIGHT - 1 do + for x = 0, Plaza.WIDTH - 1 do + local s = Plaza.SOURCE[Plaza.roleAt(x, y)] + if s[1] >= (src.trueWidth or src.width or 0) or s[2] >= (src.trueHeight or src.height or 0) then + error(("union plaza: %s layout has no cell %d,%d; re-import the ROM"):format(Plaza.SOURCE_ID, s[1], s[2])) + end + local c = src:cellAt(s[1], s[2]) + cellsOut[y * Plaza.WIDTH + x + 1] = { mid = c.mid, coll = c.coll, elev = c.elev } + end + end + local border = {} + for i, m in ipairs(src.borderMids or { 0 }) do border[i] = m end + return { + width = Plaza.WIDTH, + height = Plaza.HEIGHT, + trueWidth = Plaza.WIDTH, + trueHeight = Plaza.HEIGHT, + borderWidth = src.borderWidth or 1, + borderHeight = src.borderHeight or 1, + borderMids = border, + cells = cellsOut, + } +end + +local function buildEvents(ev) + if type(ev) ~= "table" then return nil end + local out = {} + for k, v in pairs(ev) do out[k] = v end + local objs = {} + for _, row in ipairs(ev.objects or ev.objectEvents or {}) do + if tonumber(row.localId) == Plaza.ATTENDANT_LOCAL_ID then + objs[#objs + 1] = copyRow(row) + end + end + out.objects = objs + out.objectEvents = nil + out.bgEvents = {} + out.coordEvents = {} + return out +end + +function Plaza.ensure(game) + local maps = game and game.data and game.data.maps + if type(maps) ~= "table" then error("union plaza: no map table to build " .. Plaza.MAP_ID .. " into", 2) end + local srcDef = maps[Plaza.SOURCE_ID] + if not srcDef then + error("union plaza: " .. Plaza.SOURCE_ID .. " is missing from the cache; re-import the ROM", 2) + end + if not srcDef.midLayout then + local okM, Map = pcall(require, "src.core.game3.map") + if okM and Map and Map.ensureMidLayout then Map.ensureMidLayout(game, Plaza.SOURCE_ID, srcDef) end + end + local src = srcDef.midLayout + if not src then + error("union plaza: " .. Plaza.SOURCE_ID .. " has no layout in the cache; re-import the ROM", 2) + end + + local def = maps[Plaza.MAP_ID] + if not (def and def._plazaSource == src) then + local LayoutNative = require("src.core.game3.layout_native") + local pair = src.pair or srcDef.pair + def = {} + for k, v in pairs(srcDef) do def[k] = v end + def.id = Plaza.MAP_ID + def.name = Plaza.MAP_ID + def.width = Plaza.WIDTH + def.height = Plaza.HEIGHT + def.connections = {} + def.midLayout = LayoutNative.fromDecoded(Plaza.buildDecoded(src), Plaza.MAP_ID, pair) + def.pair = pair + local warp = copyRow((srcDef.warps and srcDef.warps[1]) or {}) + warp.x, warp.y = Plaza.EXIT_X, Plaza.EXIT_Y + def.warps = { warp } + def.objects = nil + def.objectEvents = nil + def.bgEvents = {} + def.coordEvents = {} + def._plazaSource = src + maps[Plaza.MAP_ID] = def + end + + local Space = package.loaded["src.core.game3.scripting.space"] + local bundle = Space and Space.bundle + local events = bundle and bundle.events + if type(events) == "table" and events[Plaza.SOURCE_ID] + and events[Plaza.MAP_ID] == nil then + events[Plaza.MAP_ID] = buildEvents(events[Plaza.SOURCE_ID]) + end + local ev = events and events[Plaza.MAP_ID] + if ev and def.objects == nil and Space.attachEventsToMaps then + Space.attachEventsToMaps({ [Plaza.MAP_ID] = def }, bundle) + end + return def +end + +return Plaza diff --git a/src/core/game3/link/union_room.lua b/src/core/game3/link/union_room.lua index edcc5863..8b4ce1ec 100644 --- a/src/core/game3/link/union_room.lua +++ b/src/core/game3/link/union_room.lua @@ -29,9 +29,6 @@ Union.ACTIVITY = { -- pokefirered/include/constants/union_room.h:49 Union.IN_UNION_ROOM = 0x40 --- pokefirered/include/constants/union_room.h:8 -Union.MAX_LEADERS = 8 - -- pokefirered/include/constants/union_room.h:51 Union.LINK_GROUP = { SINGLE_BATTLE = 0, @@ -79,8 +76,6 @@ Union.ACTIVITY_NAMES = RomText.lazy({ [12] = "sLinkGroupActivityNameTexts[12]", }) --- pokefirered/src/union_room_player_avatar.c:97 -Union.LOCAL_IDS = { 9, 8, 7, 2, 6, 5, 4, 3 } Union.AVATARS_FILE = "data/generated/gba/union_room/avatars.lua" -- pokefirered/include/constants/global.h:110 Union.DIR = { NONE = 0, SOUTH = 1, NORTH = 2, WEST = 3, EAST = 4 } @@ -106,26 +101,16 @@ function Union.avatarData() return Union._avatars end --- pokefirered/include/constants/flags.h:115 -Union.FLAG_HIDE_PLAYER_1 = 0x63 --- pokefirered/include/constants/vars.h:28 -Union.VAR_OBJ_GFX_ID_0 = 0x4010 -- pokefirered/include/constants/union_room.h:82 Union.INTERACT_ATTENDANT = 9 Union.INTERACT_START_MENU = 10 Union.MAP = "FR_UNION_ROOM" -Union.RESPONSE_SECONDS = 3 Union.MSG = { HELLO = "game3_union_hello", - BYE = "game3_union_bye", - REQUEST = "game3_union_request", - RESPONSE = "game3_union_response", } --- pokefirered/src/union_room_player_avatar.c:566 -Union.REFRESH_FRAMES = 300 Union.AWAIT_ROOM_SECONDS = 20 Union.CARD_WAIT_SECONDS = 3 @@ -148,7 +133,6 @@ Union.activity = nil Union.lastResult = nil Union._name = nil Union._pump = nil -Union._spawned = {} Union._trade = nil Union.relay = false Union.memberIds = {} @@ -156,10 +140,8 @@ Union.invite = nil Union.incoming = nil Union.direct = {} Union._refresh = 0 -Union._refreshTimer = 0 Union._answered = {} Union._await = nil -Union._objs = {} Union._vobjs = {} Union._vobjDirty = false Union.flow = nil @@ -194,10 +176,6 @@ local function objects() return package.loaded["src.core.game3.objects"] end -local function flags() - return require("src.core.game3.scripting.flags") -end - local function ctxOf(extra) return Union.textCtx(extra) end @@ -210,8 +188,23 @@ function Union.isActive() return Union.state ~= "off" end +local function plazaMap() + return require("src.core.game3.link.union_plaza_map") +end + +Union.plazaMap = plazaMap + +function Union.isUnionMap(id) + if type(id) ~= "string" then return false end + return id == Union.MAP or id == plazaMap().MAP_ID +end + function Union.onUnionRoomMap() - return link().currentMap() == Union.MAP + return Union.isUnionMap(link().currentMap()) +end + +function Union.capacity() + return plazaMap().CAP end -- pokefirered/src/union_room.c:114 URTRADE_STATE_* @@ -256,14 +249,6 @@ function Union.graphicsIdFor(gender, trainerId) return row[((tonumber(trainerId) or 0) % 8) + 1] end --- pokefirered/src/union_room_player_avatar.c:134 -function Union.memberCoords(slot, member) - local data = Union.avatarData() - local c = data.leader_coords[slot] - local o = data.group_offsets[(member or 0) + 1] - return c[1] + o[1], c[2] + o[2] -end - -- pokefirered/src/union_room_player_avatar.c:448 function Union.memberFacing(member, activity) if (member or 0) ~= 0 then return Union.avatarData().member_facing[member + 1] end @@ -272,62 +257,7 @@ function Union.memberFacing(member, activity) return Union.DIR.EAST end --- pokefirered/src/union_room_player_avatar.c:169 CreateUnionRoomPlayerObjectEvent -function Union.spawnLeader(slot, player) - if not (slot and slot >= 1 and slot <= Union.MAX_LEADERS) then return false end - local L = link() - local store = L.store() - local F = flags() - local gfx = Union.graphicsIdFor(player and player.gender, player and player.trainerId) - F.setVar(store, nil, Union.VAR_OBJ_GFX_ID_0 + (slot - 1), gfx) - -- pokefirered/src/union_room_player_avatar.c:159 ShowUnionRoomPlayer - F.setFlag(store, nil, Union.FLAG_HIDE_PLAYER_1 + (slot - 1), false) - local Objects = objects() - if Objects then - if Objects.addObject then Objects.addObject(Union.LOCAL_IDS[slot]) end - if Objects.refreshGraphics then Objects.refreshGraphics() end - end - Union._spawned[slot] = true - return true -end - --- pokefirered/src/union_room_player_avatar.c:174 RemoveUnionRoomPlayerObjectEvent -function Union.despawnLeader(slot) - if not (slot and slot >= 1 and slot <= Union.MAX_LEADERS) then return false end - local L = link() - local F = flags() - -- pokefirered/src/union_room_player_avatar.c:154 HideUnionRoomPlayer - F.setFlag(L.store(), nil, Union.FLAG_HIDE_PLAYER_1 + (slot - 1), true) - local Objects = objects() - if Objects and Objects.removeObject then Objects.removeObject(Union.LOCAL_IDS[slot]) end - Union._spawned[slot] = nil - return true -end - -local function avatarLive(slot) - local Objects = objects() - local eo = Objects and Objects.find and Objects.find(Union.LOCAL_IDS[slot]) - return eo ~= nil and eo.visible == true -end - --- pokefirered/src/union_room_player_avatar.c:367 Task_AnimateUnionRoomPlayers -function Union.refreshAvatars() - if not Union.onUnionRoomMap() then return 0 end - local n = 0 - for slot = 1, Union.MAX_LEADERS do - if Union.players[slot] and not avatarLive(slot) then - Union.spawnLeader(slot, Union.players[slot]) - n = n + 1 - end - end - return n -end - function Union.despawnAll() - for slot = 1, Union.MAX_LEADERS do - if Union._spawned[slot] then Union.despawnLeader(slot) end - end - Union._objs = {} if next(Union._vobjs or {}) then Union._vobjs = {} local VirtualObjects = package.loaded["src.core.game3.virtual_objects"] @@ -351,152 +281,106 @@ local function playerOn(x, y) return P.moving and tonumber(P.targetX) == x and tonumber(P.targetY) == y or false end -local function leaderObj(slot) - Union._objs = Union._objs or {} - local o = Union._objs[slot] - if not o then - o = { state = 0, anim = 0, sched = nil, y2 = 0 } - Union._objs[slot] = o - end - return o +local function plazaVobjId(slot) + return Union.VOBJ_BASE + slot end -Union.leaderObj = leaderObj +Union.vobjId = plazaVobjId -local function setRaise(slot, y2) - local Objects = objects() - local eo = Objects and Objects.find and Objects.find(Union.LOCAL_IDS[slot]) - if eo then eo.raiseY = y2 end +function Union.vobj(slot) + return Union._vobjs and Union._vobjs[plazaVobjId(slot)] or nil end --- pokefirered/src/union_room_player_avatar.c:265 -local function animateSpawn(slot, o) - if o.anim == 0 then - if not playerStandingStill() then return false end - local x, y = Union.memberCoords(slot, 0) - if playerOn(x, y) then return false end - Union.spawnLeader(slot, o.player) - o.y2 = -Union.FLY_HEIGHT - setRaise(slot, o.y2) - o.anim = 1 - return false - end - o.y2 = math.min(0, o.y2 + Union.FLY_STEP) - setRaise(slot, o.y2) - if o.y2 >= 0 then - o.anim = 0 - return true - end - return false -end - --- pokefirered/src/union_room_player_avatar.c:236 -local function animateDespawn(slot, o) - if o.anim == 0 then - o.y2 = 0 - o.anim = 1 - flags().setFlag(link().store(), nil, Union.FLAG_HIDE_PLAYER_1 + (slot - 1), true) - end - o.y2 = o.y2 - Union.FLY_STEP - setRaise(slot, o.y2) - if o.y2 <= -Union.FLY_HEIGHT then - Union.despawnLeader(slot) - o.anim = 0 - return true - end - return false -end - --- pokefirered/src/union_room_player_avatar.c:325 -function Union.animatePlayer(slot) - local o = leaderObj(slot) - if o.state == 0 then - if o.sched ~= "in" then - o.sched = nil - return - end - o.state, o.anim = 2, 0 - end - if o.state == 2 then - if o.sched == "out" then - o.state, o.anim = 0, 0 - if Union._spawned[slot] then Union.despawnLeader(slot) end - elseif animateSpawn(slot, o) then - o.state = 1 - end - elseif o.state == 1 then - if o.sched == "out" then - o.state, o.anim = 3, 0 - if animateDespawn(slot, o) then o.state = 0 end - end - elseif o.state == 3 then - if animateDespawn(slot, o) then o.state = 0 end - end - o.sched = nil -end - --- pokefirered/src/union_room_player_avatar.c:300 -function Union.scheduleLeaderIn(slot, player) - local o = leaderObj(slot) - o.sched = "in" - o.player = player -end - --- pokefirered/src/union_room_player_avatar.c:313 -function Union.scheduleLeaderOut(slot) - leaderObj(slot).sched = "out" -end - -local function vobjId(slot, member) - return Union.VOBJ_BASE + (slot - 1) * Union.GROUP_SIZE + member -end - -Union.vobjId = vobjId - -function Union.vobj(slot, member) - Union._vobjs = Union._vobjs or {} - return Union._vobjs[vobjId(slot, member)] -end - -function Union.vobjVisible(slot, member) - local v = Union.vobj(slot, member) +function Union.vobjVisible(slot) + local v = Union.vobj(slot) return v ~= nil and v.visible == true end +local function facingDir(facing) + if type(facing) == "number" then return facing end + return Union.FACE_DIR[facing] or Union.DIR.SOUTH +end + +function Union.cellFor(slot) + local x, y, facing = plazaMap().cellFor(slot) + return x, y, facingDir(facing) +end + +local function idleActivity(activity) + local raw = math.floor(tonumber(activity) or 0) % Union.IN_UNION_ROOM + return raw == Union.ACTIVITY.NONE or raw == Union.ACTIVITY.PLYRTALK or raw == Union.ACTIVITY.SEARCH +end + +Union.idleActivity = idleActivity + +function Union.cellFacing(slot, p) + if p and not idleActivity(p.activity) then return Union.memberFacing(0, p.activity) end + local _, _, dir = Union.cellFor(slot) + return dir +end + -- pokefirered/src/union_room_player_avatar.c:463 -function Union.spawnMember(slot, member, gfx, activity) - Union._vobjs = Union._vobjs or {} - local id = vobjId(slot, member) - local v = Union._vobjs[id] +function Union.showAvatar(slot, p) + local vobjs = Union._vobjs + local id = plazaVobjId(slot) + local v = vobjs[id] if not v then - v = { slot = slot, member = member, visible = false, y2 = 0 } - Union._vobjs[id] = v + v = { slot = slot, member = 0, visible = false, y2 = 0 } + vobjs[id] = v end + local x, y = Union.cellFor(slot) if not v.visible or v.anim == "out" then + if playerOn(x, y) then + v.waiting = true + Union._avatarWaiting = true + return false + end v.visible = true v.anim = "in" v.y2 = -Union.FLY_HEIGHT end - v.gfx = gfx - v.dir = Union.memberFacing(member, activity) - v.x, v.y = Union.memberCoords(slot, member) + v.waiting = nil + v.gfx = Union.graphicsIdFor(p.gender, p.trainerId) + v.x, v.y = x, y + if Union._talkSlot ~= slot then v.dir = Union.cellFacing(slot, p) end Union._vobjDirty = true + return true end -- pokefirered/src/union_room_player_avatar.c:478 -function Union.despawnMember(slot, member) - local v = Union.vobj(slot, member) - if v and v.visible and v.anim ~= "out" then +function Union.hideAvatar(slot) + local v = Union.vobj(slot) + if not v then return end + v.waiting = nil + if v.visible and v.anim ~= "out" then v.anim = "out" v.y2 = v.y2 or 0 Union._vobjDirty = true end end +function Union.retryWaitingAvatars() + if not playerStandingStill() then return end + local waiting = false + for slot = 1, Union.capacity() do + local v = Union.vobj(slot) + if v and v.waiting then + local p = Union.players[slot] + if p and not p.gone then + if not Union.showAvatar(slot, p) then waiting = true end + else + v.waiting = nil + end + end + end + Union._avatarWaiting = waiting +end + -- pokefirered/src/event_object_movement.c:9354 function Union.animateVobjs() local dirty = Union._vobjDirty - for _, v in pairs(Union._vobjs or {}) do + local vobjs = Union._vobjs + for _, v in pairs(vobjs) do if v.anim == "in" then v.y2 = math.min(0, v.y2 + Union.FLY_STEP) if v.y2 >= 0 then v.anim = nil end @@ -507,128 +391,83 @@ function Union.animateVobjs() v.visible = false v.anim = nil v.y2 = 0 + Union._vobjRebuild = true end dirty = true end end if not dirty then return end Union._vobjDirty = false - local okV, VirtualObjects = pcall(require, "src.core.game3.virtual_objects") - if not okV then return end - VirtualObjects.clear() - local ids = {} - for id, v in pairs(Union._vobjs or {}) do - if v.visible then ids[#ids + 1] = id end + local VirtualObjects = package.loaded["src.core.game3.virtual_objects"] + if not VirtualObjects then + local okV, mod = pcall(require, "src.core.game3.virtual_objects") + if not okV then return end + VirtualObjects = mod end - table.sort(ids) - for _, id in ipairs(ids) do - local v = Union._vobjs[id] - local rec = VirtualObjects.spawn(id, v.gfx, v.x, v.y, 3, v.dir) - if rec then - rec.y2 = v.y2 - rec.solid = v.anim ~= "out" - end + if Union._vobjRebuild then + Union._vobjRebuild = false + VirtualObjects.clear() end -end - --- pokefirered/src/union_room_player_avatar.c:486 -function Union.assembleGroup(slot, p) - if not Union.vobjVisible(slot, 0) then - local x, y = Union.memberCoords(slot, 0) - if playerOn(x, y) then return end - Union.spawnMember(slot, 0, Union.graphicsIdFor(p.gender, p.trainerId), p.activity) - end - for i = 1, Union.GROUP_SIZE - 1 do - local partner = p.partners and p.partners[i] - if not partner then - Union.despawnMember(slot, i) - else - local x, y = Union.memberCoords(slot, i) - if not playerOn(x, y) then - Union.spawnMember(slot, i, Union.graphicsIdFor(partner.gender, partner.trainerId), p.activity) + for id, v in pairs(vobjs) do + 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 + rec.graphicsId = v.gfx + rec.x, rec.y = v.x, v.y + rec.direction = v.dir + rec.y2 = v.y2 + rec.solid = v.anim ~= "out" end end end end --- pokefirered/src/union_room_player_avatar.c:510 -function Union.spawnGroup(slot, p) - local raw = math.floor(tonumber(p.activity) or 0) % Union.IN_UNION_ROOM - if raw == Union.ACTIVITY.NONE or raw == Union.ACTIVITY.PLYRTALK or raw == Union.ACTIVITY.SEARCH then - Union.scheduleLeaderIn(slot, p) - for i = 0, Union.GROUP_SIZE - 1 do Union.despawnMember(slot, i) end - else - Union.scheduleLeaderOut(slot) - Union.assembleGroup(slot, p) - end -end - --- pokefirered/src/union_room_player_avatar.c:544 -function Union.despawnGroup(slot) - Union.scheduleLeaderOut(slot) - for i = 0, Union.GROUP_SIZE - 1 do Union.despawnMember(slot, i) end -end - --- pokefirered/src/union_room_player_avatar.c:552 -function Union.updatePlayerSprites() - Union._refreshTimer = 0 - for slot = 1, Union.MAX_LEADERS do +function Union.refreshPlaza() + for slot = 1, Union.capacity() do local p = Union.players[slot] if p and p.gone then - Union.despawnGroup(slot) + Union.hideAvatar(slot) Union.players[slot] = nil Union.memberIds[slot] = nil if Union.partnerId == slot then Union.partnerId = nil end elseif p then - Union.spawnGroup(slot, p) + Union.showAvatar(slot, p) end end end --- pokefirered/src/union_room_player_avatar.c:566 -function Union.scheduleRefresh() - Union._refreshTimer = Union.REFRESH_FRAMES -end - --- pokefirered/src/union_room_player_avatar.c:571 -function Union.handleRefresh() - Union._refreshTimer = (Union._refreshTimer or 0) + 1 - if Union._refreshTimer > Union.REFRESH_FRAMES then Union.updatePlayerSprites() end -end - function Union.animateAll() - for slot = 1, Union.MAX_LEADERS do Union.animatePlayer(slot) end + if Union._avatarWaiting then Union.retryWaitingAvatars() end Union.animateVobjs() end +local FACING_DELTA = { down = { 0, 1 }, up = { 0, -1 }, left = { -1, 0 }, right = { 1, 0 } } + -- pokefirered/src/union_room_player_avatar.c:577 function Union.tryInteractWithMember() if not playerStandingStill() then return nil end local P = playerMod() - local d = P and ({ down = { 0, 1 }, up = { 0, -1 }, left = { -1, 0 }, right = { 1, 0 } })[P.facing or "down"] + 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] - for slot = 1, Union.MAX_LEADERS do - local p = Union.players[slot] - for member = 0, Union.GROUP_SIZE - 1 do - local v = Union.vobj(slot, member) - if v and v.visible and v.anim == nil and p and not p.gone and v.x == fx and v.y == fy then - local opp = Union.avatarData().opposite_facing[(Union.FACE_DIR[P.facing] or 1) + 1] - v.dir = opp - Union._vobjDirty = true - return slot, member - end - end - end - return nil + local slot = plazaMap().slotAt(fx, fy) + if not slot then return nil end + local p = Union.players[slot] + local v = Union.vobj(slot) + if not (v and v.visible and v.anim == nil and p and not p.gone) then return nil end + v.dir = Union.avatarData().opposite_facing[(Union.FACE_DIR[P.facing] or 1) + 1] + Union._talkSlot = slot + Union._vobjDirty = true + return slot, 0 end -- pokefirered/src/union_room_player_avatar.c:621 -function Union.updateMemberFacing(slot, member) - local v = slot and Union.vobj(slot, member or 0) +function Union.updateMemberFacing(slot) + if slot == nil or Union._talkSlot == slot then Union._talkSlot = nil end + local v = slot and Union.vobj(slot) local p = slot and Union.players[slot] if not (v and p) then return end - v.dir = Union.memberFacing(member or 0, p.activity) + v.dir = Union.cellFacing(slot, p) Union._vobjDirty = true end @@ -638,7 +477,7 @@ end function Union.playerCount() local n = 0 - for slot = 1, Union.MAX_LEADERS do + for slot = 1, Union.capacity() do if Union.players[slot] then n = n + 1 end end return n @@ -646,7 +485,7 @@ end function Union.list() local out = {} - for slot = 1, Union.MAX_LEADERS do + for slot = 1, Union.capacity() do local p = Union.players[slot] if p then out[#out + 1] = { slot = slot, name = p.name, activity = p.activity } end end @@ -699,39 +538,6 @@ function Union.bufferPlayerName(ctx, adapters) return false, 1 end -local function addPlayer(hello) - for slot = 1, Union.MAX_LEADERS do - local p = Union.players[slot] - if p and p.trainerId == hello.trainerId and p.name == hello.name then - p.activity = hello.activity or p.activity - return slot, false - end - end - for slot = 1, Union.MAX_LEADERS do - if not Union.players[slot] then - Union.players[slot] = { - name = hello.name, - gender = tonumber(hello.gender) or 0, - trainerId = tonumber(hello.trainerId) or 0, - activity = tonumber(hello.activity) or Union.ACTIVITY.SEARCH, - } - return slot, true - end - end - return nil, false -end - -Union.addPlayer = addPlayer - -function Union.removePlayer(slot) - if not Union.players[slot] then return false end - Union.players[slot] = nil - Union.memberIds[slot] = nil - Union.despawnLeader(slot) - if Union.partnerId == slot then Union.partnerId = nil end - return true -end - function Union.announce() local L = link() local lk = L.link @@ -746,37 +552,28 @@ end -- pokefirered/src/union_room.c:3515 InitUnionRoom function Union.init(ctx) Union._name = nil - if Union.state ~= "off" then return false end - Union.players = {} - Union._spawned = {} - local lk = link().link - if lk and lk.isOpen and lk:isOpen() then - -- pokefirered/src/union_room.c:3536 Task_InitUnionRoom - Union.state = "search" - Union.startPump() - end return false end -- pokefirered/src/union_room.c:2579 RunUnionRoom function Union.run(ctx, adapters) link().setResult(ctx, 0) - if Union.state ~= "off" and Union.state ~= "search" and Union.onUnionRoomMap() then - if Union.relay then - Union._objs = {} - Union._spawned = {} - Union._vobjDirty = true - Union.scheduleRefresh() - end + if Union.state ~= "off" and Union.onUnionRoomMap() then + Union._vobjDirty = true + Union._vobjRebuild = true Union.startPump() return false end Union.players = {} - Union._spawned = {} - Union._objs = {} Union._vobjs = {} + Union._vobjDirty = false + Union._vobjRebuild = false + Union._avatarWaiting = false + Union._plazaSynced = false + Union._plazaInstance, Union._plazaRev = nil, nil + Union._offlineSince, Union._offlineWiped = nil, nil + Union._upgradeShown = nil Union.flow = nil - Union._refreshTimer = 0 Union.memberIds = {} Union.partnerId = nil Union.activity = nil @@ -787,13 +584,11 @@ function Union.run(ctx, adapters) Union._await = nil Union.state = "init" local L = link() - Union.relay = L.adapterConnected() - if Union.relay then - L.clientCall("joinPlaza", "union", L.liveProfile(), L.avatar()) - L.setStatus("idle") - Union._refresh = 0 - Union._synced = false - end + Union.relay = true + L.clientCall("joinPlaza", "union", L.liveProfile(), L.avatar(), plazaMap().CAP) + L.setStatus("idle") + Union._refresh = 0 + Union._synced = false L.setResult(ctx, 0) Union.startPump() return false @@ -802,20 +597,13 @@ end function Union.stop(reason) if Union.state == "off" then return false end local L = link() - if Union.relay then - if Union.incoming then L.clientCall("replyInvite", Union.incoming.id, false) end - L.clientCall("leavePlaza", "union") - L.setStatus("busy") - Union.relay = false - Union.invite = nil - Union.incoming = nil - Union._await = nil - else - local lk = L.link - if lk and lk.isOpen and lk:isOpen() then - lk:send({ type = Union.MSG.BYE, reason = tostring(reason or "left") }) - end - end + if Union.incoming then L.clientCall("replyInvite", Union.incoming.id, false) end + L.clientCall("leavePlaza", "union") + L.setStatus("busy") + Union.relay = false + Union.invite = nil + Union.incoming = nil + Union._await = nil local s = screen() if s and s.isOpen and s.isOpen() then s.close() end Union.flow = nil @@ -832,21 +620,6 @@ function Union.stop(reason) return true end -local function chooserOpen() - local s = screen() - return s and s.isOpen and s.isOpen() and s.mode == "activity" -end - -local function sendRequest(activity) - if Union.relay then return Union.sendInvite(activity) end - local lk = link().link - if lk and lk.isOpen and lk:isOpen() then - lk:send({ type = Union.MSG.REQUEST, activity = activity, name = localPlayer().name }) - return true - end - return false -end - -- pokefirered/src/union_room.c:2896 UR_STATE_HANDLE_DO_SOMETHING_PROMPT_INPUT function Union.chooseActivity(index) local item = Union.INVITE_ITEMS[tonumber(index) or 0] @@ -854,7 +627,7 @@ function Union.chooseActivity(index) local M = message() if item.activity == Union.ACTIVITY.NONE then Union.activity = nil - if Union.relay and M and M.show then + if M and M.show then -- pokefirered/src/union_room.c:2916 local p = Union.partnerRow() or {} local text = RomText.ascii(RomText.key("gTexts_UR_IfYouWantToDoSomething", tonumber(p.gender) == 1 and 1 or 0)) @@ -868,9 +641,9 @@ function Union.chooseActivity(index) local activity = item.union and (item.activity + Union.IN_UNION_ROOM) or item.activity Union.activity = activity Union._role = "child" - local sent = sendRequest(activity) + local sent = Union.sendInvite(activity) Union.state = sent and "send_activity_request" or "print_and_exit" - if sent and Union.relay and M and M.show then + if sent and M and M.show then -- pokefirered/src/union_room.c:2941 local p = Union.partnerRow() or {} local g = tonumber(p.gender) == 1 and 1 or 0 @@ -887,31 +660,6 @@ function Union.cancelActivity() return true end -local DELTA = { down = { 0, 1 }, up = { 0, -1 }, left = { -1, 0 }, right = { 1, 0 } } - -function Union.slotForLocalId(localId) - for slot = 1, Union.MAX_LEADERS do - if Union.LOCAL_IDS[slot] == localId then return slot end - end - return nil -end - --- pokefirered/src/union_room.c:2757 TryInteractWithUnionRoomMember -function Union.facingMemberSlot() - local Player = package.loaded["src.core.game3.player"] - local Objects = objects() - if not (Player and Objects and Objects.at) then return nil end - local d = DELTA[Player.facing or "down"] - if not d then return nil end - local eo = Objects.at((tonumber(Player.cellX) or 0) + d[1], (tonumber(Player.cellY) or 0) + d[2]) - if not eo then return nil end - -- pokefirered/include/constants/event_objects.h:72 - if tonumber(eo.graphicsId) == 66 then return Union.INTERACT_ATTENDANT end - local slot = Union.slotForLocalId(tonumber(eo.localId)) - if slot and Union._spawned[slot] then return slot end - return nil -end - local function pressedA() local game = link().game() local input = game and game.input @@ -919,204 +667,14 @@ local function pressedA() return input:wasPressed("a") and true or false end --- pokefirered/src/union_room.c:2734 UR_STATE_MAIN -local function pollInteraction(ctx) - local L = link() - local result = L.getVar(ctx, L.VAR_RESULT) - if result == 0 and pressedA() then - local slot = Union.facingMemberSlot() - if slot then - local okA, Audio = pcall(require, "src.core.game3.audio") - local okS, SE = pcall(require, "src.core.game3.se_ids") - if okA and okS and Audio.playSe then pcall(Audio.playSe, SE.SE_SELECT) end - result = slot - end - end - if result == 0 then return false end - L.setVar(ctx, L.VAR_RESULT, 0) - if result == Union.INTERACT_ATTENDANT then - Union.state = "interact_with_attendant" - return true - end - if result == Union.INTERACT_START_MENU then return false end - local slot = result - if not Union.players[slot] then - Union.state = "print_and_exit" - return true - end - Union.partnerId = slot - Union.state = "do_something_prompt" - return true -end - -- pokefirered/src/union_room.c:2647 Task_RunUnionRoom function Union.update(dt) if Union.state == "off" then return false end - local L = link() - local ctx = select(1, L.vmCtx()) - local lk = L.link - - if Union.state ~= "search" and not Union.onUnionRoomMap() then + if not Union.onUnionRoomMap() then Union.stop("left_union_room") return false end - - if Union.relay then return Union.relayUpdate(dt, ctx) end - if lk and lk.isOpen and lk:isOpen() then - local hello = lk:take(Union.MSG.HELLO) - while hello do - local slot, isNew = addPlayer(hello) - if slot and isNew then - if Union.state ~= "search" then Union.spawnLeader(slot, Union.players[slot]) end - Union.noteUnionRoomPlayer(Union.players[slot].name) - end - hello = lk:take(Union.MSG.HELLO) - end - if lk:take(Union.MSG.BYE) then - for slot = Union.MAX_LEADERS, 1, -1 do - if Union.players[slot] then Union.removePlayer(slot) end - end - end - local request = lk:take(Union.MSG.REQUEST) - if request and Union.state == "main" then - Union.activity = tonumber(request.activity) - Union._requestName = request.name - Union.state = "player_contacted_you" - end - local response = lk:take(Union.MSG.RESPONSE) - if response then - Union._waited = nil - Union.lastResult = response.accept and "accepted" or "declined" - Union.state = response.accept and "start_activity" or "print_and_exit" - end - elseif Union.state ~= "init" and Union.state ~= "search" then - for slot = Union.MAX_LEADERS, 1, -1 do - if Union.players[slot] then Union.removePlayer(slot) end - end - end - - -- pokefirered/src/union_room.c:2795 HandleUnionRoomPlayerRefresh - if Union.state ~= "search" then Union.refreshAvatars() end - - if Union.state == "search" then - if not (lk and lk.isOpen and lk:isOpen()) then - Union.state = "off" - return false - end - elseif Union.state == "init" then - -- pokefirered/src/union_room.c:2674 UR_STATE_INIT_LINK - Union.announce() - Union.state = "main" - elseif Union.state == "main" then - pollInteraction(ctx) - elseif Union.state == "player_contacted_you" then - Union.askActivityRequest() - elseif Union.state == "handle_activity_request" then - local M = message() - local okC, Choice = pcall(require, "src.ui.game3.choice") - Union.offerYesNo() - local waiting = (M and M.isOpen and M.isOpen()) - or (okC and Choice and Choice.isOpen and Choice.isOpen()) - if not waiting then Union.answerRequest(false) end - elseif Union.state == "do_something_prompt" then - if not chooserOpen() then - local s = screen() - if s then - s.showActivities(Union.INVITE_ITEMS, { - partner = Union.players[Union.partnerId or 0], - onChoose = function(index) Union.chooseActivity(index) end, - onCancel = function() Union.cancelActivity() end, - }) - Union.state = "handle_do_something_prompt_input" - else - Union.state = "main" - end - end - elseif Union.state == "handle_do_something_prompt_input" then - if not chooserOpen() then Union.state = "main" end - elseif Union.state == "interact_with_attendant" then - -- pokefirered/src/union_room.c:3373 UR_STATE_CHECK_TRADING_BOARD - local s = screen() - if s then - s.showPlayers(Union.list(), { - mode = "board", - onPoll = function() return Union.list() end, - -- pokefirered/src/union_room.c:3485 UR_STATE_TRADE_SELECT_MON - onConfirm = function(slot) - if not Union.chooseMonForTradingBoard(slot) then Union.state = "main" end - end, - onCancel = function() Union.state = "main" end, - }) - Union.state = "check_trading_board" - else - Union.state = "main" - end - elseif Union.state == "check_trading_board" then - local s = screen() - if not (s and s.isOpen and s.isOpen()) then Union.state = "main" end - elseif Union.state == "send_activity_request" and Union.relay then - Union.pollInvite() - elseif Union.state == "await_room" then - Union.pollAwaitRoom(dt) - elseif Union.state == "await_link" then - Union.pollAwaitLink(dt) - elseif Union.state == "send_activity_request" then - -- pokefirered/src/union_room.c:2937 UR_STATE_TRAINER_APPEARS_BUSY - Union._waited = (Union._waited or 0) + (tonumber(dt) or 0) - if Union._waited >= Union.RESPONSE_SECONDS then - Union._waited = nil - Union.lastResult = "busy" - local M = message() - if M and M.show then - M.show(RomText.ascii("gText_UR_TrainerAppearsBusy")) - end - Union.state = "print_and_exit" - end - elseif Union.state == "start_activity" then - Union.startActivity() - elseif Union.state == "in_activity" then - local raw = math.floor(tonumber(Union.activity) or 0) - if raw % Union.IN_UNION_ROOM == Union.ACTIVITY.CHAT then - -- pokefirered/src/union_room.c:1949 EnterUnionRoomChat - if not chat().isActive() then - Union.activity = nil - Union.partnerId = nil - Union.state = "main" - end - elseif raw % Union.IN_UNION_ROOM == Union.ACTIVITY.CARD then - -- pokefirered/src/union_room.c:1954 CB2_ShowCard - local okC, TrainerCard = pcall(require, "src.ui.game3.trainer_card") - local open = okC and TrainerCard and TrainerCard.isOpen and TrainerCard.isOpen() - if not open then - Union.activity = nil - Union.partnerId = nil - Union.state = "main" - end - elseif raw % Union.IN_UNION_ROOM == Union.ACTIVITY.TRADE then - -- pokefirered/src/union_room.c:1713 Task_StartUnionRoomTrade - local LT = linkTrade() - if not LT.isActive() then - Union.activity = nil - Union.partnerId = nil - Union.state = "main" - end - else - local LB = battle() - if LB.state == "setup" then - LB.pumpUnionSetup() - elseif LB.state ~= "battle" then - Union.activity = nil - Union.partnerId = nil - Union.state = "main" - end - end - elseif Union.state == "print_and_exit" then - Union.activity = nil - Union.partnerId = nil - Union.state = "main" - end - - return true + return Union.relayUpdate(dt, select(1, link().vmCtx())) end -- pokefirered/src/union_room.c:1832 WarpForCableClubActivity @@ -1168,7 +726,7 @@ function Union.startActivity() local act = raw % Union.IN_UNION_ROOM local inRoom = raw >= Union.IN_UNION_ROOM local L = link() - local warpOpts = Union.relay and { onDone = function() end } or nil + local warpOpts = { onDone = function() end } if act == Union.ACTIVITY.BATTLE_SINGLE and inRoom then -- pokefirered/src/union_room.c:1811 StartUnionRoomBattle if battle().startUnionRoomBattle() then @@ -1189,7 +747,7 @@ function Union.startActivity() return true elseif act == Union.ACTIVITY.TRADE and inRoom then -- pokefirered/src/union_room.c:1936 Task_StartUnionRoomTrade - if Union.relay then Union.prepareBoardTrade() end + Union.prepareBoardTrade() if linkTrade().startUnionRoomTrade(function() Union.state = "main" end) then Union.state = "in_activity" return true @@ -1199,20 +757,10 @@ function Union.startActivity() Union.stop() Union.warpForCableClubActivity(Union.TRADE_CENTER, L.USING.TRADE_CENTER, warpOpts) return true - elseif act == Union.ACTIVITY.CHAT and Union.relay then + elseif act == Union.ACTIVITY.CHAT then -- pokefirered/src/union_room.c:1949 local rs = Union._chatSession or L.clientCall("roomSession") if rs then return Union.enterChat(rs) end - elseif act == Union.ACTIVITY.CHAT then - -- pokefirered/src/union_room.c:1949 EnterUnionRoomChat - if chat().start({ onDone = function() - Union.activity = nil - Union.partnerId = nil - Union.state = "main" - end }) then - Union.state = "in_activity" - return true - end elseif act == Union.ACTIVITY.CARD then -- pokefirered/src/union_room.c:1954 CB2_ShowCard if Union.showPartnerCard() then @@ -1447,7 +995,7 @@ end -- pokefirered/src/union_room.c:4400 function Union.boardOffers() local out = {} - for slot = 1, Union.MAX_LEADERS do + for slot = 1, Union.capacity() do local p = Union.players[slot] local b = p and not p.gone and type(p.board) == "table" and p.board or nil if b and (tonumber(b.species) or 0) ~= 0 then @@ -1509,7 +1057,7 @@ function Union.openTradingBoard() own = own, offers = offers, onChoose = function(_, item) - if not item or item.id == 8 then return Union.toMain() end + if not item or item.id == s.BOARD_EXIT then return Union.toMain() end local entry = item.entry if not entry then return Union.toMain() end Union.boardPick(entry) @@ -1672,37 +1220,28 @@ end -- pokefirered/src/union_room.c:3148 function Union.answerRequest(accept) - if Union.relay then - local inv = Union.incoming - Union.incoming = nil - local raw = math.floor(tonumber(Union.activity) or 0) % Union.IN_UNION_ROOM - local M = message() - local shown = inv and M and M.show and type(love) == "table" and love.graphics - if inv then link().clientCall("replyInvite", inv.id, accept and true or false) end - Union.lastResult = accept and "accepted" or "declined" - if accept and inv then - Union._role = "parent" - Union.flow = nil - Union.beginAwaitRoom(nil) - else - Union.activity = nil - if shown then - -- pokefirered/src/union_room.c:887 - local key = (raw == Union.ACTIVITY.CHAT or raw == Union.ACTIVITY.CARD) - and "gText_UR_OfferDeclined2" or "gText_UR_OfferDeclined1" - return Union.printAndExit(RomText.ascii(key)) - end - Union.flow = nil - Union.state = "main" - end - return true - end - local lk = link().link - if lk and lk.isOpen and lk:isOpen() then - lk:send({ type = Union.MSG.RESPONSE, accept = accept and true or false }) - end - Union.state = accept and "start_activity" or "main" + local inv = Union.incoming + Union.incoming = nil + local raw = math.floor(tonumber(Union.activity) or 0) % Union.IN_UNION_ROOM + local M = message() + local shown = inv and M and M.show and type(love) == "table" and love.graphics + if inv then link().clientCall("replyInvite", inv.id, accept and true or false) end Union.lastResult = accept and "accepted" or "declined" + if accept and inv then + Union._role = "parent" + Union.flow = nil + Union.beginAwaitRoom(nil) + else + Union.activity = nil + if shown then + -- pokefirered/src/union_room.c:887 + local key = (raw == Union.ACTIVITY.CHAT or raw == Union.ACTIVITY.CARD) + and "gText_UR_OfferDeclined2" or "gText_UR_OfferDeclined1" + return Union.printAndExit(RomText.ascii(key)) + end + Union.flow = nil + Union.state = "main" + end return true end @@ -1873,7 +1412,7 @@ end function Union.slotForId(id) if id == nil then return nil end - for slot = 1, Union.MAX_LEADERS do + for slot = 1, Union.capacity() do if Union.memberIds[slot] == id then return slot end end return nil @@ -1917,101 +1456,91 @@ local function avatarRow(m) } end -local function partnerSig(list) - local out = {} - for i, p in ipairs(list or {}) do out[i] = tostring(p.id) end - return table.concat(out, ",") -end +local NO_MEMBERS = {} -- pokefirered/src/union_room.c:2781 +Union.GONE_GRACE_SECONDS = 1.5 + function Union.syncPlaza() local plaza = link().clientCall("plaza") if type(plaza) ~= "table" then return false end + if Union._plazaSynced and plaza.instance == Union._plazaInstance and plaza.rev == Union._plazaRev then + return false + end + Union._plazaSynced = true + Union._plazaInstance, Union._plazaRev = plaza.instance, plaza.rev + Union.plazaBuilds = (Union.plazaBuilds or 0) + 1 local me = myId() local mySlot = tonumber(plaza.you) - local members, present = {}, {} - for _, m in ipairs(type(plaza.members) == "table" and plaza.members or {}) do - if type(m) == "table" and m.id ~= nil and m.id ~= me - and not (mySlot and tonumber(m.slot) == mySlot) then - members[#members + 1] = m - present[m.id] = m - end - end - table.sort(members, function(a, b) return (tonumber(a.slot) or 99) < (tonumber(b.slot) or 99) end) - local partnerOf = {} - for _, m in ipairs(members) do - local g = type(m.group) == "table" and m.group or nil - if g and g.leader ~= nil and g.leader ~= m.id and present[g.leader] then partnerOf[m.id] = g.leader end - end - local leaders, partners = {}, {} - for _, m in ipairs(members) do - if not partnerOf[m.id] then - leaders[m.id] = m - local list = {} - local g = type(m.group) == "table" and m.group or nil - for _, id in ipairs(g and type(g.members) == "table" and g.members or {}) do - if partnerOf[id] == m.id and present[id] and #list < Union.GROUP_SIZE - 1 then - list[#list + 1] = avatarRow(present[id]) - end + local cap = Union.capacity() + local gen = (Union._plazaGen or 0) + 1 + Union._plazaGen = gen + local fresh = false + local members = type(plaza.members) == "table" and plaza.members or NO_MEMBERS + for i = 1, #members do + local m = members[i] + local slot = type(m) == "table" and tonumber(m.slot) or nil + if slot and slot >= 1 and slot <= cap and m.id ~= nil and m.id ~= me and slot ~= mySlot then + local p = Union.players[slot] + if p and p.id ~= m.id then + Union.hideAvatar(slot) + p = nil end - partners[m.id] = list + if not p then + p = avatarRow(m) + Union.players[slot] = p + Union.memberIds[slot] = m.id + Union.noteUnionRoomPlayer(p.name) + fresh = true + end + p.gen = gen + p.gone = nil + p.online = m.online + p.status = m.status + p.group = m.group + p.board = m.board + p.activity = Union.memberActivity(m) end end - local changed, fresh = false, false - for slot = 1, Union.MAX_LEADERS do + for slot = 1, cap do local p = Union.players[slot] - if p and not p.gone and not leaders[p.id] then p.gone = true end - end - for _, m in ipairs(members) do - if leaders[m.id] then - local slot = Union.slotForId(m.id) - local p = slot and Union.players[slot] or nil - local activity = Union.memberActivity(m) - if p then - if p.gone or p.activity ~= activity or partnerSig(p.partners) ~= partnerSig(partners[m.id]) then - changed = true - end - p.gone = nil - p.board = m.board - p.recruiting = m.recruiting - p.status = m.status - p.group = m.group - p.activity = activity - p.partners = partners[m.id] - elseif not slot then - for s = 1, Union.MAX_LEADERS do - if not Union.players[s] then - slot = s - break - end - end - if slot then - local row = avatarRow(m) - row.activity = activity - row.board = m.board - row.recruiting = m.recruiting - row.status = m.status - row.group = m.group - row.partners = partners[m.id] - Union.memberIds[slot] = m.id - Union.players[slot] = row - Union.noteUnionRoomPlayer(row.name) - fresh, changed = true, true - end - end - end + if p and p.gen ~= gen then p.gone = true end end -- pokefirered/src/union_room.c:2783 if fresh and Union._synced then playSe("SE_NOTE_C") end - if changed then Union.scheduleRefresh() end Union._synced = true + Union.refreshPlaza() return true end +function Union.plazaOffline() + local now = Union.clock() + Union._plazaSynced = false + Union._offlineSince = Union._offlineSince or now + if Union._offlineWiped or now - Union._offlineSince < Union.GONE_GRACE_SECONDS then return end + Union._offlineWiped = true + for slot = 1, Union.capacity() do + local p = Union.players[slot] + if p then p.gone = true end + end + Union.refreshPlaza() +end + +function Union.checkUpgrade() + if Union._upgradeShown or Union.state ~= "main" or Union.flow then return end + local up = link().clientCall("upgradeRequired") + if not up then return end + Union._upgradeShown = true + local M = message() + if M and M.show then + Union.printAndExit(require("src.online.Protocol2").upgradeText(up)) + end +end + -- pokefirered/src/union_room.c:3106 function Union.pollIncoming() local L = link() - if Union.state == "init" or Union.state == "search" then return end + if Union.state == "init" then return end for _, inv in ipairs(L.clientCall("invites") or {}) do local id = type(inv) == "table" and inv.id or nil if id ~= nil and not Union._answered[id] then @@ -2036,16 +1565,13 @@ end function Union.relayTick(_dt) local L = link() if not L.online() then - for slot = Union.MAX_LEADERS, 1, -1 do - local p = Union.players[slot] - if p and not p.gone then p.gone = true end - end - Union.handleRefresh() + Union.plazaOffline() Union.animateAll() + Union.checkUpgrade() return end + Union._offlineSince, Union._offlineWiped = nil, nil Union.syncPlaza() - Union.handleRefresh() Union.animateAll() Union.pollIncoming() if Union.state == "main" and L.link then @@ -2163,15 +1689,16 @@ function Union.toMain() Union.flow = nil Union.activity = nil Union.state = "main" + if Union._talkSlot then Union.updateMemberFacing(Union._talkSlot) end Union.releaseScript() end -- pokefirered/src/union_room.c:3455 -function Union.printAndExit(text, slot, member) +function Union.printAndExit(text, slot) return Union.runFlow(stepsOf( Union.sayStep(text), Union.doStep(function() - Union.updateMemberFacing(slot, member) + Union.updateMemberFacing(slot) Union.toMain() end) ), "print_and_exit") @@ -2296,32 +1823,35 @@ function Union.doSomethingPrompt(again) ), "do_something_prompt") end +function Union.chatHasRoom(p) + local g = type(p.group) == "table" and p.group or nil + local seats = g and type(g.members) == "table" and #g.members or 0 + return seats < Union.GROUP_SIZE +end + -- pokefirered/src/union_room.c:2805 -function Union.talkTo(slot, member) - member = member or 0 +function Union.talkTo(slot) local p = Union.players[slot] if not p or p.gone then - return Union.printAndExit(RomText.ascii("gText_UR_TrainerAppearsBusy"), slot, member) + return Union.printAndExit(RomText.ascii("gText_UR_TrainerAppearsBusy"), slot) end Union.partnerId = slot Union._partner = p local raw = math.floor(tonumber(p.activity) or 0) % Union.IN_UNION_ROOM - local idle = raw == Union.ACTIVITY.NONE or raw == Union.ACTIVITY.PLYRTALK or raw == Union.ACTIVITY.SEARCH - if member == 0 and idle then return Union.doSomethingPrompt(false) end - local who = member == 0 and p or (p.partners and p.partners[member]) or p - local g = genderIndex(who) - local ctx = textCtx({ stringVars = { who.name or "" } }) - if member == 0 and raw == Union.ACTIVITY.CHAT and #(p.partners or {}) < Union.GROUP_SIZE - 1 then + if idleActivity(p.activity) then return Union.doSomethingPrompt(false) end + local g = genderIndex(p) + local ctx = textCtx({ stringVars = { p.name or "" } }) + if raw == Union.ACTIVITY.CHAT and Union.chatHasRoom(p) then local met = Union.metBefore(p) and 1 or 0 return Union.runFlow(stepsOf( Union.stayStep(RomText.ascii(Union.JOIN_CHAT_TEXTS[met][g], ctx)), Union.yesNoStep(function(yes) if yes then return Union.joinChat(slot) end - Union.printAndExit(RomText.ascii(RomText.key("gTexts_UR_DeclineChat", g), ctx), slot, member) + Union.printAndExit(RomText.ascii(RomText.key("gTexts_UR_DeclineChat", g), ctx), slot) end) ), "recv_join_chat_request") end - return Union.printAndExit(Union.reactionText(p.activity, g, who.name), slot, member) + return Union.printAndExit(Union.reactionText(p.activity, g, p.name), slot) end -- pokefirered/src/union_room.c:3027 @@ -2376,7 +1906,6 @@ function Union.pollMain(ctx) if result ~= 0 then L.setVar(ctx, L.VAR_RESULT, 0) if result == Union.INTERACT_ATTENDANT then return Union.attendant() end - if result >= 1 and result <= Union.MAX_LEADERS then return Union.talkTo(result, 0) end Union.releaseScript() return end @@ -2384,10 +1913,10 @@ function Union.pollMain(ctx) local Hud = package.loaded["src.ui.game3.hud"] if Hud and Hud.busy and Hud.busy() then return end if not pressedA() then return end - local slot, member = Union.tryInteractWithMember() + local slot = Union.tryInteractWithMember() if slot then playSe("SE_SELECT") - return Union.talkTo(slot, member) + return Union.talkTo(slot) end if Union.facingTradingBoard() then return Union.checkTradingBoard() end end @@ -2395,10 +1924,11 @@ end function Union.facingTradingBoard() local P = playerMod() if not P or P.moving then return false end - local d = ({ down = { 0, 1 }, up = { 0, -1 }, left = { -1, 0 }, right = { 1, 0 } })[P.facing or "down"] + local d = FACING_DELTA[P.facing or "down"] if not d then return false end - return (tonumber(P.cellX) or 0) + d[1] == Union.TRADING_BOARD.x - and (tonumber(P.cellY) or 0) + d[2] == Union.TRADING_BOARD.y + local board = plazaMap().TRADING_BOARD or Union.TRADING_BOARD + return (tonumber(P.cellX) or 0) + d[1] == board.x + and (tonumber(P.cellY) or 0) + d[2] == board.y end function Union.namedRequest(name) @@ -2484,7 +2014,7 @@ function Union.relayUpdate(dt, ctx) if Union.flow then return true end end local st = Union.state - if st == "init" or st == "search" then + if st == "init" then Union.state = "main" elseif st == "main" then Union.pollMain(ctx) @@ -2589,8 +2119,7 @@ function Union.pollInvite() text = RomText.ascii("gText_UR_TrainerAppearsBusy") end if M and M.isOpen and M.isOpen() then M.close() end - if Union.relay and M and M.show then return Union.printAndExit(text) end - if M and M.show then M.show(text) end + if M and M.show then return Union.printAndExit(text) end Union.state = "print_and_exit" end @@ -2657,7 +2186,7 @@ function Union.pollAwaitLink(dt) if raw == Union.ACTIVITY.CARD and not L.peerCard and a.t < Union.CARD_WAIT_SECONDS then return end Union._await = nil local M = message() - if Union.relay and M and M.show and type(love) == "table" and love.graphics then + if M and M.show and type(love) == "table" and love.graphics then if raw == Union.ACTIVITY.CARD then return Union.cardFlow() end return Union.printStartActivity() end @@ -3447,7 +2976,6 @@ function Union.reset() Union.stop("reset") Union.state = "off" Union.players = {} - Union._spawned = {} Union.memberIds = {} Union.partnerId = nil Union.activity = nil @@ -3455,7 +2983,6 @@ function Union.reset() Union._name = nil Union._pump = nil Union._trade = nil - Union._waited = nil Union._requestName = nil Union.relay = false Union.invite = nil @@ -3467,10 +2994,14 @@ function Union.reset() Union._synced = false Union._groupMembers = nil Union._choose = nil - Union._objs = {} Union._vobjs = {} Union._vobjDirty = false - Union._refreshTimer = 0 + Union._vobjRebuild = false + Union._avatarWaiting = false + Union._plazaSynced = false + Union._plazaInstance, Union._plazaRev = nil, nil + Union._offlineSince, Union._offlineWiped = nil, nil + Union._upgradeShown = nil Union.flow = nil Union._partner = nil Union._role = nil diff --git a/src/core/game3/map.lua b/src/core/game3/map.lua index 6998d392..61c8cacb 100644 --- a/src/core/game3/map.lua +++ b/src/core/game3/map.lua @@ -4,11 +4,13 @@ local MapIds = require("src.core.game3.map_ids") local ModRuntime = require("src.mods.Runtime") +local Connections = require("src.core.game3.connections") local Map = {} Map.current = nil Map._announced = nil Map.neighbors = {} +Map.neighborList = {} Map._loadedLayouts = {} Map._def = nil Map._currentDef = nil @@ -32,33 +34,34 @@ end function Map.loadNeighborsDepth1(game, primaryDef) Map.neighbors = {} + Map.neighborList = {} if not primaryDef or type(primaryDef.connections) ~= "table" then return Map.neighbors end local data = game and game.data and game.data.maps if not data then return Map.neighbors end - for dir, conn in pairs(primaryDef.connections) do - local mid = type(conn) == "table" and conn.map or conn - if type(mid) == "string" and data[mid] then + -- pokefirered/src/fieldmap.c:129 + for _, conn in ipairs(Connections.each(primaryDef)) do + local mid = conn.map + if data[mid] then local def = data[mid] Map.ensureMidLayout(game, mid, def) - Map.neighbors[dir] = { + local n = { + dir = conn.dir, map = mid, mapId = mid, def = def, - offset = type(conn) == "table" and (conn.offset or 0) or 0, + offset = conn.offset, } + Map.neighborList[#Map.neighborList + 1] = n + Map.neighbors[conn.dir] = Map.neighbors[conn.dir] or n Map._loadedLayouts[mid] = true end end return Map.neighbors end -local function cell_size(def) - local L = def and def.midLayout - if L and L.width and L.height then return L.width or 0, L.height or 0 end - return (tonumber(def and def.width) or 0) * 2, (tonumber(def and def.height) or 0) * 2 -end +local cell_size = Connections.sizeOf Map.world = {} Map._worldRoot = nil @@ -86,21 +89,22 @@ function Map.computeWorld(maps, rootId, hops, reachW, reachH, ensure) local cur = queue[qi] qi = qi + 1 local curW, curH = cell_size(cur.def) - for dir, conn in pairs(cur.def.connections or {}) do - local destId = type(conn) == "table" and conn.map or conn - local destDef = type(destId) == "string" and maps[destId] or nil + for _, conn in ipairs(Connections.each(cur.def)) do + local dir = conn.dir + local destId = conn.map + local destDef = maps[destId] if destDef and destDef ~= rootDef and not placed[destId] then - local offset = (type(conn) == "table" and tonumber(conn.offset) or 0) or 0 + local offset = conn.offset ensure(destId, destDef) local destW, destH = cell_size(destDef) local ox, oy - if dir == "north" or dir == "up" then + if dir == "north" then ox, oy = offset, -destH - elseif dir == "south" or dir == "down" then + elseif dir == "south" then ox, oy = offset, curH - elseif dir == "west" or dir == "left" then + elseif dir == "west" then ox, oy = -destW, offset - elseif dir == "east" or dir == "right" then + elseif dir == "east" then ox, oy = curW, offset end if ox then @@ -153,28 +157,12 @@ end function Map.overscanSlices() local slices = {} - for dir, n in pairs(Map.neighbors) do - slices[#slices + 1] = { dir = dir, mapId = n.map or n.mapId, offset = n.offset } + for _, n in ipairs(Map.neighborList or {}) do + slices[#slices + 1] = { dir = n.dir, mapId = n.map or n.mapId, offset = n.offset } end return slices end -local function neighborFor(cardinal) - local n = Map.neighbors - if not n then return nil end - -- Dataset keys are north/south/west/east; some callers use up/down/left/right. - if cardinal == "north" or cardinal == "up" then - return n.north or n.up - elseif cardinal == "south" or cardinal == "down" then - return n.south or n.down - elseif cardinal == "west" or cardinal == "left" then - return n.west or n.left - elseif cardinal == "east" or cardinal == "right" then - return n.east or n.right - end - return n[cardinal] -end - --- Resolve a cell in current-map space, sampling connected neighbors when OOB -- (pret VMap connection fill). Returns mid, sourcePair. OOB with no neighbor -- falls through to primary border tiling. @@ -199,40 +187,44 @@ function Map.worldMidAt(cx, cy, primaryDef) return layout:midAt(cx, cy), primaryPair end - if cy < 0 then - local n = neighborFor("north") - if n then - local offset = tonumber(n.offset) or 0 - local L = n.def and n.def.midLayout - local nh = L and L.height or 0 - local mid, pair = fromNeighbor(n, cx - offset, nh + cy) - if mid ~= nil then return mid, pair end - end - elseif cy >= h then - local n = neighborFor("south") - if n then - local offset = tonumber(n.offset) or 0 - local mid, pair = fromNeighbor(n, cx - offset, cy - h) - if mid ~= nil then return mid, pair end + local list = Map.neighborList or {} + local function fromDir(dir) + for i = #list, 1, -1 do + local n = list[i] + if n.dir == dir and n.def and n.def.midLayout then + local L = n.def.midLayout + local offset = tonumber(n.offset) or 0 + local nx, ny + if dir == "north" then + nx, ny = cx - offset, (L.height or 0) + cy + elseif dir == "south" then + nx, ny = cx - offset, cy - h + elseif dir == "west" then + nx, ny = (L.width or 0) + cx, cy - offset + else + nx, ny = cx - w, cy - offset + end + local mid, pair = fromNeighbor(n, nx, ny) + if mid ~= nil then return mid, pair end + end end end + -- pokefirered/src/fieldmap.c:129 + if cy < 0 then + local mid, pair = fromDir("north") + if mid ~= nil then return mid, pair end + elseif cy >= h then + local mid, pair = fromDir("south") + if mid ~= nil then return mid, pair end + end + if cx < 0 then - local n = neighborFor("west") - if n then - local offset = tonumber(n.offset) or 0 - local L = n.def and n.def.midLayout - local nw = L and L.width or 0 - local mid, pair = fromNeighbor(n, nw + cx, cy - offset) - if mid ~= nil then return mid, pair end - end + local mid, pair = fromDir("west") + if mid ~= nil then return mid, pair end elseif cx >= w then - local n = neighborFor("east") - if n then - local offset = tonumber(n.offset) or 0 - local mid, pair = fromNeighbor(n, cx - w, cy - offset) - if mid ~= nil then return mid, pair end - end + local mid, pair = fromDir("east") + if mid ~= nil then return mid, pair end end for _, entry in ipairs(Map.world) do @@ -335,6 +327,7 @@ function Map.load(mod, game, mapId, opts) Map.loadNeighborsDepth1(game, def) else Map.neighbors = {} + Map.neighborList = {} end local world = host_world(game) diff --git a/src/core/game3/objects.lua b/src/core/game3/objects.lua index c542ce09..f07b2197 100644 --- a/src/core/game3/objects.lua +++ b/src/core/game3/objects.lua @@ -420,6 +420,48 @@ local function resolveContextualMapObjects(mapId) end end +-- src/event_object_movement.c:1312 +local function spawnFromTemplate(def, mapId) + local eo = newEventObject(def) + if eo.localId > 0 then + applyPerm(eo, mapId) + local tmt = Objects._templateMt[eo.localId] + if tmt then + Objects.setTrainerMovementType(eo, tmt) + -- src/event_object_movement.c:1569 + eo.invisible = tmt == MOVEMENT_TYPE_INVISIBLE + local face = ({ [7] = "up", [8] = "down", [9] = "left", [10] = "right" })[tmt] + if face then eo.facing = face end + end + end + return eo +end + +-- src/event_object_movement.c:1651 +local function respawnFromTemplate(lid) + local tpl + for _, def in ipairs(Objects._defs or {}) do + if tonumber(def.localId or def.index) == lid then tpl = def end + end + if not tpl then return nil end + local eo = spawnFromTemplate(tpl, Objects._mapId) + eo.hidden, eo.visible = false, true + if eo.def then eo.def.hidden = false end + local present = Objects._byId[lid] ~= nil + if not present then + for _, id in ipairs(Objects._order) do + if id == lid then present = true break end + end + end + Objects._byId[lid] = eo + Objects._tracks[lid] = nil + if not present then Objects._order[#Objects._order + 1] = lid end + if ModRuntime.wants("world.npc_spawned") then + ModRuntime.emit("world.npc_spawned", { mapId = Objects._mapId, npcId = lid, runtime = eo }) + end + return eo +end + --- Spawn EventObjects from mapDef.objects (extract / content). function Objects.loadMap(game, mapId, mapDef) local sameMap = Objects._mapId == mapId @@ -456,17 +498,8 @@ function Objects.loadMap(game, mapId, mapDef) resolveContextualMapObjects(mapId) local announce = ModRuntime.wants("world.npc_spawned") for _, def in ipairs(Objects._defs) do - local eo = newEventObject(def) + local eo = spawnFromTemplate(def, mapId) if eo.localId > 0 then - applyPerm(eo, mapId) - local tmt = Objects._templateMt[eo.localId] - if tmt then - Objects.setTrainerMovementType(eo, tmt) - -- src/event_object_movement.c:1569 - eo.invisible = tmt == MOVEMENT_TYPE_INVISIBLE - local face = ({ [7] = "up", [8] = "down", [9] = "left", [10] = "right" })[tmt] - if face then eo.facing = face end - end Objects._byId[eo.localId] = eo Objects._order[#Objects._order + 1] = eo.localId if announce then @@ -537,35 +570,45 @@ end local VIRT_DIR_FACE = { [1] = "down", [2] = "up", [3] = "left", [4] = "right" } +local drawList = {} +local vrecs = {} + function Objects.forDraw() - local list = {} + local list = drawList + local n = 0 for _, lid in ipairs(Objects._order) do local eo = Objects._byId[lid] -- src/event_object_movement.c:8014 if eo and eo.visible and not eo.hidden and not eo.invisible and not offMap(Objects._bounds, eo) then - list[#list + 1] = eo + n = n + 1 + list[n] = eo end end -- src/event_object_movement.c:1719 - if VirtualObjects.count() > 0 then - for _, vo in ipairs(VirtualObjects.list()) do + for i = 1, VirtualObjects.slots() do + local vo = VirtualObjects.nth(i) + if vo then + local vrec = vrecs[vo.id] + if not vrec then + vrec = { virtualId = vo.id, visible = true, hidden = false } + vrecs[vo.id] = vrec + end local gid = tonumber(vo.graphicsId) or 0 - local vrec = { - virtualId = vo.id, - cellX = tonumber(vo.x) or 0, - cellY = tonumber(vo.y) or 0, - elevation = tonumber(vo.elevation) or 3, - facing = VIRT_DIR_FACE[tonumber(vo.direction)] or "down", - sprite = GfxIds.spriteFor(gid), - graphicsId = gid, - raiseY = tonumber(vo.y2) or 0, - visible = true, - hidden = false, - } - if not offMap(Objects._bounds, vrec) then list[#list + 1] = vrec end + vrec.cellX = tonumber(vo.x) or 0 + vrec.cellY = tonumber(vo.y) or 0 + vrec.elevation = tonumber(vo.elevation) or 3 + vrec.facing = VIRT_DIR_FACE[tonumber(vo.direction)] or "down" + vrec.sprite = GfxIds.spriteFor(gid) + vrec.graphicsId = gid + vrec.raiseY = tonumber(vo.y2) or 0 + if not offMap(Objects._bounds, vrec) then + n = n + 1 + list[n] = vrec + end end end + for i = #list, n + 1, -1 do list[i] = nil end return list end @@ -609,10 +652,9 @@ function Objects.blocks(tx, ty, exceptLocalId, elevation) end end -- pokefirered/src/union_room_player_avatar.c:475 - if VirtualObjects.count() > 0 then - for _, vo in ipairs(VirtualObjects.list()) do - if vo.solid == true and tonumber(vo.x) == tx and tonumber(vo.y) == ty then return true end - end + for i = 1, VirtualObjects.slots() do + local vo = VirtualObjects.nth(i) + if vo and vo.solid == true and tonumber(vo.x) == tx and tonumber(vo.y) == ty then return true end end return false end @@ -662,7 +704,7 @@ local function beginStep(eo, tx, ty) eo.animClock = 0 end -local function finishStep(eo, game) +local function finishStep(eo, game, ctx) eo.cellX = eo.targetX eo.cellY = eo.targetY eo.px = eo.cellX * CELL @@ -673,11 +715,13 @@ local function finishStep(eo, game) if eo.def then eo.def.x, eo.def.y = eo.cellX, eo.cellY end + if ctx then return end local Coll = Collision() local curElev = Coll and Coll.elevationAt and Coll.elevationAt(eo.cellX, eo.cellY) if curElev and curElev ~= 0 and curElev ~= 15 then eo.elevation = curElev end + -- src/trainer_see.c:94 if eo.sight and eo.sight > 0 and not eo.scriptBusy and not eo.frozen then local okTs, TrainerSight = pcall(require, "src.core.game3.trainer_sight") if okTs and TrainerSight and TrainerSight.check then @@ -716,7 +760,7 @@ local function stepOffset(frames, progress, cells) return math.floor(cells * CELL * math.min(progress, frames) / frames) end -local function tickMotion(eo, game) +local function tickMotion(eo, game, ctx) Objects.updateElevation(eo) if not eo.moving then return false end eo.progress = eo.progress + 1 @@ -728,7 +772,7 @@ local function tickMotion(eo, game) eo.px = eo.cellX * CELL + (dx > 0 and off or dx < 0 and -off or 0) eo.py = eo.cellY * CELL + (dy > 0 and off or dy < 0 and -off or 0) if eo.progress >= frames then - finishStep(eo, game) + finishStep(eo, game, ctx) return true end return false @@ -1339,7 +1383,7 @@ function Objects.tickPool(pool, game, ctx) eo.bowFrames = eo.bowFrames - 1 if eo.bowFrames <= 0 then eo.bowFrames = nil end end - tickMotion(eo, game) + tickMotion(eo, game, ctx or pool) idleTick(eo, game, ctx) end end @@ -1394,30 +1438,8 @@ end function Objects.addObject(localId) localId = tonumber(localId) or 0 local eo = Objects._byId[localId] - if eo then - if eo.hidden then - -- src/event_object_movement.c:1569 - eo.invisible = Objects.templateMovementType(localId) == MOVEMENT_TYPE_INVISIBLE - end - applyPerm(eo, Objects._mapId) - eo.hidden = false - eo.visible = true - if eo.def then eo.def.hidden = false end - return true - end - -- Respawn from template defs. - for _, def in ipairs(Objects._defs or {}) do - if tonumber(def.localId or def.index) == localId then - eo = newEventObject(def) - applyPerm(eo, Objects._mapId) - eo.hidden = false - eo.visible = true - Objects._byId[localId] = eo - Objects._order[#Objects._order + 1] = localId - return true - end - end - return false + if eo and not eo.hidden then return true end + return respawnFromTemplate(localId) ~= nil end --- Re-evaluate hide flags after sidecar load / setflag mid-map. @@ -1449,7 +1471,6 @@ end Objects.inCameraView = inCameraView --- pret FlagClear/FlagSet on an object template hide flag. --- clearflag after removeobject must bring the NPC back at perm coords. -- src/scrcmd.c:558 function Objects.syncFlagVisibility(flagId, hidden, force) flagId = tonumber(flagId) or 0 @@ -1465,15 +1486,9 @@ function Objects.syncFlagVisibility(flagId, hidden, force) eo.visible = false if eo.def then eo.def.hidden = true end end - else - if eo.hidden then - -- src/event_object_movement.c:1569 - eo.invisible = Objects.templateMovementType(lid) == MOVEMENT_TYPE_INVISIBLE - end - applyPerm(eo, Objects._mapId) - eo.hidden = false - eo.visible = true - if eo.def then eo.def.hidden = false end + elseif eo.hidden then + -- src/event_object_movement.c:1792 + respawnFromTemplate(lid) end end end diff --git a/src/core/game3/save_schema_firered.lua b/src/core/game3/save_schema_firered.lua index 356b3b78..27c88327 100644 --- a/src/core/game3/save_schema_firered.lua +++ b/src/core/game3/save_schema_firered.lua @@ -76,6 +76,19 @@ local CONTINUE_GAME_WARP = 0x01 -- pokefirered/data/maps/PokemonLeague_HallOfFame/scripts.inc:40 local HALL_OF_FAME_MAP = "FR_POKEMON_LEAGUE_HALL_OF_FAME" +local UNION_ROOMS = { FR_UNION_ROOM = true, FR_UNION_ROOM_PLAZA = true } +-- pokefirered/data/maps/ViridianCity_PokemonCenter_2F/map.json:84 +local UNION_DOOR_X, UNION_DOOR_Y = 5, 1 +local FIRST_CENTER_2F = "FR_VIRIDIAN_CITY_POKEMON_CENTER_2F" + +local function union_room_door_map(session) + local heal = type(session.healMap) == "string" and session.healMap or "" + local city = heal:match("^(.+_POKEMON_CENTER)_1F$") + if city then return city .. "_2F" end + if heal:match("_POKECENTER$") then return heal .. "_2F" end + return FIRST_CENTER_2F +end + -- pokefirered/src/overworld.c:1706 CB2_ContinueSavedGame local function use_continue_game_warp(session, mounted) local Bit = require("bit") @@ -87,6 +100,11 @@ local function use_continue_game_warp(session, mounted) return end session._continueWarpDeferred = nil + if UNION_ROOMS[session.map] then + session.map, session.x, session.y, session.facing = + union_room_door_map(session), UNION_DOOR_X, UNION_DOOR_Y, "down" + return + end if session.map == HALL_OF_FAME_MAP then local Field = require("src.core.game3.field") if not mounted and not Field.flyDestinationsMounted() then @@ -111,6 +129,7 @@ local LINK_ROOMS = { FR_RECORD_CORNER = true, FR_BATTLE_COLOSSEUM_4P = true, FR_UNION_ROOM = true, + FR_UNION_ROOM_PLAZA = true, } -- pokefirered/src/load_save.c:149 SetContinueGameWarpStatusToDynamicWarp diff --git a/src/core/game3/scripting/adapters.lua b/src/core/game3/scripting/adapters.lua index 4f9dfd99..af38c110 100644 --- a/src/core/game3/scripting/adapters.lua +++ b/src/core/game3/scripting/adapters.lua @@ -1228,6 +1228,12 @@ function Adapters.host(mod, game, world) return v end local cx, cy = as_coord(x), as_coord(y) + if mapId == "FR_UNION_ROOM" then + local Plaza = require("src.core.game3.link.union_plaza_map") + Plaza.ensure(resolveGame()) + mapId = Plaza.MAP_ID + cx, cy = Plaza.entry() + end local wid = tonumber(warpId) if (not cx or not cy) and wid and wid ~= 0xFF and wid >= 0 then local def = w and w.data and w.data.maps and w.data.maps[mapId] diff --git a/src/core/game3/trainer_sight.lua b/src/core/game3/trainer_sight.lua index c41d694c..48ffae4a 100644 --- a/src/core/game3/trainer_sight.lua +++ b/src/core/game3/trainer_sight.lua @@ -379,6 +379,8 @@ function TrainerSight.check(game, specificTrainer) if specificTrainer then local eo = specificTrainer + -- src/trainer_see.c:94 + if Objs.find(eo.localId) ~= eo then return false end if eo.visible and not eo.hidden and not eo.scriptBusy and not eo.frozen then local sight = tonumber(eo.sight or (eo.def and (eo.def.sight or eo.def.trainerRange))) or 0 if sight > 0 and TrainerSight.isTrainerType(eo) diff --git a/src/core/game3/virtual_objects.lua b/src/core/game3/virtual_objects.lua index ac2e379a..735fdde9 100644 --- a/src/core/game3/virtual_objects.lua +++ b/src/core/game3/virtual_objects.lua @@ -64,6 +64,15 @@ function VirtualObjects.list() return out end +function VirtualObjects.slots() + return #order +end + +function VirtualObjects.nth(i) + local id = order[i] + return id and byId[id] or nil +end + function VirtualObjects.count() local n = 0 for _ in pairs(byId) do n = n + 1 end diff --git a/src/debug/SwitchDiagnostics.lua b/src/debug/SwitchDiagnostics.lua index 55de37d9..0e33244c 100644 --- a/src/debug/SwitchDiagnostics.lua +++ b/src/debug/SwitchDiagnostics.lua @@ -198,6 +198,14 @@ function SwitchDiagnostics.onPadReconcile() SwitchDiagnostics.maybeFlush(true) end +local function osName() + local sys = love and love.system + if not (sys and sys.getOS) then return nil end + local ok, name = pcall(sys.getOS) + if ok then return name end + return nil +end + function SwitchDiagnostics.onJoystickEvent(kind, joystick, button, extra) if not SwitchDiagnostics.isEnabled() then return end local payload = { button = button } @@ -209,9 +217,27 @@ function SwitchDiagnostics.onJoystickEvent(kind, joystick, button, extra) if extra then for k, v in pairs(extra) do payload[k] = v end end + local PadHints = require("src.core.PadHints") + payload.hf = tostring(PadHints.hasFocus()) + local fg = PadHints.foreground(osName()) + if fg then payload.fg = fg end SwitchDiagnostics.onEvent(kind, payload) end +function SwitchDiagnostics.onFocus(f) + if not SwitchDiagnostics.isEnabled() then return end + local PadHints = require("src.core.PadHints") + local payload = { + f = tostring(f), + hasFocus = tostring(PadHints.hasFocus()), + visible = tostring(not PadHints.windowMinimized()), + } + local fg = PadHints.foreground(osName()) + if fg then payload.fg = fg end + SwitchDiagnostics.onEvent("focus", payload) + SwitchDiagnostics.maybeFlush(true) +end + function SwitchDiagnostics.logLuaError(msg) local filesystem = fs() if not filesystem then return nil end diff --git a/src/import/CacheContract.lua b/src/import/CacheContract.lua index dbedaacd..7d08be04 100644 --- a/src/import/CacheContract.lua +++ b/src/import/CacheContract.lua @@ -19,10 +19,11 @@ CacheContract.VERSION_FORMAT = { silver = "rom-cache-v12:", crystal = "rom-cache-v12-crystal4:", -- engine/overworld/map_sprites.asm:181, engine/battle/animations.asm:2600 - yellow = "rom-cache-v12-yellow1:", + -- data/pikachu/pikachu_pic_animation.asm:340 + yellow = "rom-cache-v12-yellow2:", -- v8: M4A tracks retain reachable patterns and explicit entry offsets. - firered = "rom-cache-v16-firered:", - leafgreen = "rom-cache-v1-leafgreen:", + firered = "rom-cache-v17-firered:", + leafgreen = "rom-cache-v2-leafgreen:", } CacheContract.MARKER_PATH = "rom-cache.complete" @@ -49,6 +50,63 @@ CacheContract.VERSION_REQUIRED_FILES = { "assets/generated/battle/trainers/jessie_james.png", "assets/generated/battle/profoakb.png", "assets/generated/pikachu/pikapic_1.png", + "assets/generated/pikachu/pikapic_2.png", + "assets/generated/pikachu/pikapic_3.png", + "assets/generated/pikachu/pikapic_4.png", + "assets/generated/pikachu/pikapic_5.png", + "assets/generated/pikachu/pikapic_6.png", + "assets/generated/pikachu/pikapic_7.png", + "assets/generated/pikachu/pikapic_8.png", + "assets/generated/pikachu/pikapic_9.png", + "assets/generated/pikachu/pikapic_10.png", + "assets/generated/pikachu/pikapic_11.png", + "assets/generated/pikachu/pikapic_12.png", + "assets/generated/pikachu/pikapic_13.png", + "assets/generated/pikachu/pikapic_14.png", + "assets/generated/pikachu/pikapic_15.png", + "assets/generated/pikachu/pikapic_16.png", + "assets/generated/pikachu/pikapic_17.png", + "assets/generated/pikachu/pikapic_18.png", + "assets/generated/pikachu/pikapic_19.png", + "assets/generated/pikachu/pikapic_20.png", + "assets/generated/pikachu/pikapic_21.png", + "assets/generated/pikachu/pikapic_22.png", + "assets/generated/pikachu/pikapic_23.png", + "assets/generated/pikachu/pikapic_24.png", + "assets/generated/pikachu/pikapic_25.png", + "assets/generated/pikachu/pikapic_26.png", + "assets/generated/pikachu/pikapic_27.png", + "assets/generated/pikachu/pikapic_28.png", + "assets/generated/pikachu/gfx_e4841.png", + "assets/generated/pikachu/gfx_e4ce0.png", + "assets/generated/pikachu/gfx_e4e70.png", + "assets/generated/pikachu/gfx_e50af.png", + "assets/generated/pikachu/gfx_e52fe.png", + "assets/generated/pikachu/gfx_e5541.png", + "assets/generated/pikachu/gfx_e5794.png", + "assets/generated/pikachu/gfx_e59ed.png", + "assets/generated/pikachu/gfx_e5c4d.png", + "assets/generated/pikachu/gfx_e5e90.png", + "assets/generated/pikachu/gfx_e6020.png", + "assets/generated/pikachu/gfx_e61b0.png", + "assets/generated/pikachu/gfx_e63f7.png", + "assets/generated/pikachu/gfx_e6646.png", + "assets/generated/pikachu/gfx_e682f.png", + "assets/generated/pikachu/gfx_e69bf.png", + "assets/generated/pikachu/gfx_e6b4f.png", + "assets/generated/pikachu/gfx_e6cdf.png", + "assets/generated/pikachu/gfx_e6e6f.png", + "assets/generated/pikachu/gfx_e6fff.png", + "assets/generated/pikachu/gfx_e718f.png", + "assets/generated/pikachu/gfx_e731f.png", + "assets/generated/pikachu/gfx_e74af.png", + "assets/generated/pikachu/gfx_e763f.png", + "assets/generated/pikachu/gfx_e7863.png", + "assets/generated/pikachu/gfx_e79f3.png", + "assets/generated/pikachu/gfx_e7b83.png", + "assets/generated/pikachu/gfx_e7d13.png", + "assets/generated/pikachu/gfx_f0b64.png", + "assets/generated/pikachu/gfx_f0d82.png", "assets/generated/minigame/surf_1a.png", "assets/generated/minigame/surf_1b.png", "assets/generated/minigame/surf_1c.png", diff --git a/src/import/LauncherSettings.lua b/src/import/LauncherSettings.lua index 8c0fec24..ec38eef7 100644 --- a/src/import/LauncherSettings.lua +++ b/src/import/LauncherSettings.lua @@ -344,20 +344,12 @@ local function coreRows(opts, hooks) local okSpd, GameSpeed = pcall(require, "src.core.GameSpeed") if okSpd then - -- Per-category (RFC 0007): overworld/battle/menu each cycle their own - -- multiplier, mirroring OptionsMenu.lua's three rows. add(Strings("OVERWORLD SPEED"), function() return GameSpeed.levelLabel(opts.speedOverworld) end, function(dir) opts.speedOverworld = GameSpeed.cycle(opts.speedOverworld, dir) return true end) - add(Strings("BATTLE SPEED"), - function() return GameSpeed.levelLabel(opts.speedBattle) end, - function(dir) - opts.speedBattle = GameSpeed.cycle(opts.speedBattle, dir) - return true - end) add(Strings("MENU SPEED"), function() return GameSpeed.levelLabel(opts.speedMenu) end, function(dir) diff --git a/src/import/LauncherSplash.lua b/src/import/LauncherSplash.lua new file mode 100644 index 00000000..6eac316c --- /dev/null +++ b/src/import/LauncherSplash.lua @@ -0,0 +1,97 @@ +local LauncherSplash = {} +LauncherSplash.__index = LauncherSplash + +local DURATION = 127 / 30 +local FADE_SECONDS = 0.75 +local REVEAL_SECONDS = 0.65 + +local function smoothstep(value) + local t = math.max(0, math.min(1, value)) + return t * t * (3 - 2 * t) +end + +function LauncherSplash.new() + local self = setmetatable({ elapsed = 0, age = 0, opacity = 1 }, LauncherSplash) + local ok, err = pcall(function() + self.video = love.graphics.newVideo("assets/launcher/splash.ogv", { + audio = love.audio ~= nil, + }) + self.video:setFilter("linear", "linear") + local width, height = self.video:getDimensions() + self.canvas = love.graphics.newCanvas(width, height) + self.canvas:setFilter("linear", "linear") + self.shader = love.graphics.newShader([[ + extern number opacity; + vec4 effect(vec4 color, Image texture, vec2 uv, vec2 screen) { + vec4 pixel = Texel(texture, uv); + float brightness = max(pixel.r, max(pixel.g, pixel.b)); + float coverage = smoothstep(0.015, 0.10, brightness); + return vec4(pixel.rgb, pixel.a * coverage * opacity) * color; + } + ]]) + local options = require("src.core.SaveData").loadOptions() + self.volume = math.max(0, math.min(7, tonumber(options.sfxVol) or 7)) / 7 + local source = self.video:getSource() + if source then source:setVolume(self.volume) end + self.video:play() + end) + if not ok then + self:release() + print("[launcher splash] unavailable: " .. tostring(err)) + return nil + end + return self +end + +function LauncherSplash:update(dt) + self.age = self.age + dt + self.elapsed = self.video:tell() + self.opacity = 1 - smoothstep((self.elapsed - (DURATION - FADE_SECONDS)) / FADE_SECONDS) + local source = self.video:getSource() + if source then source:setVolume(self.volume * self.opacity) end + -- A decoder that stalls must not prevent access to the launcher. + return self.elapsed >= DURATION - 0.035 or not self.video:isPlaying() + or self.age > DURATION + 2 +end + +function LauncherSplash:draw() + local g = love.graphics + local width, height = g.getDimensions() + local vw, vh = self.video:getDimensions() + local scale = width / vw + local backdrop = 1 - smoothstep((self.elapsed - DURATION / 2) / REVEAL_SECONDS) + + g.push("all") + g.origin() + g.setScissor() + g.setShader() + g.setBlendMode("alpha", "alphamultiply") + g.setColor(0, 0, 0, backdrop) + g.rectangle("fill", 0, 0, width, height) + + -- Video supplies YUV planes, so convert through a canvas before keying RGB. + local target = g.getCanvas() + g.setCanvas(self.canvas) + g.clear(0, 0, 0, 0) + g.setColor(1, 1, 1, 1) + g.draw(self.video, 0, 0) + g.setCanvas(target) + self.shader:send("opacity", self.opacity) + g.setShader(self.shader) + g.draw(self.canvas, 0, (height - vh * scale) / 2, 0, scale, scale) + g.pop() +end + +function LauncherSplash:release() + if self.video then + self.video:pause() + local source = self.video:getSource() + if source then source:stop() end + end + for _, key in ipairs({ "video", "canvas", "shader" }) do + local resource = self[key] + if resource then resource:release(); self[key] = nil end + end +end + +return LauncherSplash diff --git a/src/import/RomExtractor.lua b/src/import/RomExtractor.lua index 484d8b3e..8fe06d6e 100644 --- a/src/import/RomExtractor.lua +++ b/src/import/RomExtractor.lua @@ -2137,10 +2137,7 @@ function RomExtractor:extractField() -- (PikaAnimTilemap_1, column order) paints. Scripts 18/22/23/24 have no -- compressed base and take PikaPicAnimBGFrames_5 -> PikaAnimTilemap_9, -- which is ROW order over a raw 25-tile sheet, hence no columns flag. - -- Script 26 shares script 11's base. The pikaframe overlays each script - -- draws ON TOP of the base are a second full pose out of the same blob and - -- stay unripped: PikachuFollower.picLift stands in for their motion - -- (#561, still on #407's stand-in). + -- Script 26 shares script 11's base. local PIKAPIC_BASE = { "Pic_e4000", "Pic_e411c", "Pic_e4272", "Pic_e4383", "Pic_e458b", "Pic_e467b", "Pic_e476e", "Pic_e49d1", "Pic_e4b39", "Pic_e4c3e", @@ -2158,6 +2155,17 @@ function RomExtractor:extractField() self:writeCompressedPic(label, path) end end + -- data/pikachu/pikachu_pic_animation.asm:340 + for _, label in ipairs({ + "GFX_e4841", "GFX_e4ce0", "GFX_e4e70", "GFX_e50af", "GFX_e52fe", + "GFX_e5541", "GFX_e5794", "GFX_e59ed", "GFX_e5c4d", "GFX_e5e90", + "GFX_e6020", "GFX_e61b0", "GFX_e63f7", "GFX_e6646", "GFX_e682f", + "GFX_e69bf", "GFX_e6b4f", "GFX_e6cdf", "GFX_e6e6f", "GFX_e6fff", + "GFX_e718f", "GFX_e731f", "GFX_e74af", "GFX_e763f", "GFX_e7863", + "GFX_e79f3", "GFX_e7b83", "GFX_e7d13", "GFX_f0b64", "GFX_f0d82", + }) do + self:raw2bpp(label, 40, 40, "pikachu/" .. label:lower() .. ".png") + end end self:raw1bpp("LedgeHoppingShadow", 8, 8, diff --git a/src/import/gba/extract_island1.lua b/src/import/gba/extract_island1.lua index 3c3b3eb9..5916d385 100644 --- a/src/import/gba/extract_island1.lua +++ b/src/import/gba/extract_island1.lua @@ -443,7 +443,7 @@ function Extract.run(imports, cache, progressCb) for _, mapId in ipairs(mapOrder) do local conns = romConns[mapId] or {} local fixed = {} - for dir, c in pairs(conns) do + for _, c in ipairs(conns) do local dest = c.map if (not dest or dest:match("^g%d+_m%d+$")) and c.mapGroup ~= nil then dest = MapCatalog.mapIdFor(c.mapGroup, c.mapNum) or dest @@ -451,7 +451,7 @@ function Extract.run(imports, cache, progressCb) dest = MapCatalog.resolve(dest) or dest end if dest then - fixed[dir] = { map = dest, offset = tonumber(c.offset) or 0 } + fixed[#fixed + 1] = { dir = c.dir, map = dest, offset = tonumber(c.offset) or 0 } end end connections[mapId] = fixed @@ -533,13 +533,9 @@ function Extract.run(imports, cache, progressCb) for _, mapId in ipairs(mapOrder) do local conns = connections[mapId] or {} cl[#cl + 1] = (" %s = {\n"):format(mapId) - local dirs = {} - for dir in pairs(conns) do dirs[#dirs + 1] = dir end - table.sort(dirs) - for _, dir in ipairs(dirs) do - local c = conns[dir] - cl[#cl + 1] = (" %s = { map = %q, offset = %d },\n"):format( - dir, c.map, tonumber(c.offset) or 0) + for _, c in ipairs(conns) do + cl[#cl + 1] = (" { dir = %q, map = %q, offset = %d },\n"):format( + c.dir, c.map, tonumber(c.offset) or 0) end cl[#cl + 1] = " },\n" end @@ -1844,7 +1840,7 @@ local function _dormant_quantize_run(imports, cache, progressCb) for _, mapId in ipairs(mapOrder) do local conns = romConns[mapId] or {} local fixed = {} - for dir, c in pairs(conns) do + for _, c in ipairs(conns) do local dest = c.map if (not dest or dest:match("^g%d+_m%d+$")) and c.mapGroup ~= nil then dest = MapCatalog.mapIdFor(c.mapGroup, c.mapNum) or dest @@ -1852,7 +1848,7 @@ local function _dormant_quantize_run(imports, cache, progressCb) dest = MapCatalog.resolve(dest) or dest end if dest then - fixed[dir] = { map = dest, offset = tonumber(c.offset) or 0 } + fixed[#fixed + 1] = { dir = c.dir, map = dest, offset = tonumber(c.offset) or 0 } end end connections[mapId] = fixed @@ -1885,13 +1881,9 @@ local function _dormant_quantize_run(imports, cache, progressCb) for _, mapId in ipairs(mapOrder) do local conns = connections[mapId] or {} cl[#cl + 1] = (" %s = {\n"):format(mapId) - local dirs = {} - for dir in pairs(conns) do dirs[#dirs + 1] = dir end - table.sort(dirs) - for _, dir in ipairs(dirs) do - local c = conns[dir] - cl[#cl + 1] = (" %s = { map = %q, offset = %d },\n"):format( - dir, c.map, tonumber(c.offset) or 0) + for _, c in ipairs(conns) do + cl[#cl + 1] = (" { dir = %q, map = %q, offset = %d },\n"):format( + c.dir, c.map, tonumber(c.offset) or 0) end cl[#cl + 1] = " },\n" end diff --git a/src/import/gba/extract_map_events.lua b/src/import/gba/extract_map_events.lua index 3926aed9..03f01cbd 100644 --- a/src/import/gba/extract_map_events.lua +++ b/src/import/gba/extract_map_events.lua @@ -351,7 +351,7 @@ function ExtractMapEvents.parseConnections(rom, connectionsPtr) local dirName = CONN_DIR[direction] local destMap = Versions.frMapFor(mapGroup, mapNum) if dirName and destMap then - out[dirName] = { map = destMap, offset = offset } + out[#out + 1] = { dir = dirName, map = destMap, offset = offset } end end return out diff --git a/src/import/gba/map_tree.lua b/src/import/gba/map_tree.lua index 5421fa87..ae70336a 100644 --- a/src/import/gba/map_tree.lua +++ b/src/import/gba/map_tree.lua @@ -149,7 +149,8 @@ function MapTree.parseConnections(rom, connectionsPtr, groupsData) local maps = groupsData.groups[mapGroup].maps pretName = maps and maps[mapNum + 1] end - out[dirName] = { + out[#out + 1] = { + dir = dirName, mapGroup = mapGroup, mapNum = mapNum, offset = offset, diff --git a/src/import/gba/versions.lua b/src/import/gba/versions.lua index 770c7843..52f3a311 100644 --- a/src/import/gba/versions.lua +++ b/src/import/gba/versions.lua @@ -43,7 +43,7 @@ Versions.ROM_SIZE = 16777216 -- v115: LeafGreen profiles, edition-specific title assets and Deoxys stats. -- v121: chrome/fonts/japanese_{normal,small}_* and japanese_widths.lua, the -- cart's Japanese fonts, for text a Japanese translation mod prints. -Versions.CACHE_VERSION = 125 +Versions.CACHE_VERSION = 126 Versions.NATIVE_VERSION = 6 Versions.OW_VERSION = 3 Versions.ANIM_VERSION = 1 diff --git a/src/link/Wire.lua b/src/link/Wire.lua index 0c9d9427..a6d59493 100644 --- a/src/link/Wire.lua +++ b/src/link/Wire.lua @@ -327,7 +327,7 @@ local MAX_TEAM = 6 local MAX_DEADLINES = 8 local MAX_U32 = 4294967295 local MAX_SEATS = 5 -local MAX_PLAZA = 9 +local MAX_PLAZA = 40 local MAX_DIRECT = 64 local MAX_GROUPS = 64 local MAX_AVATAR_NAME = 16 @@ -979,6 +979,7 @@ end SCHEMAS.plaza_join = function(m) return { kind = Wire.str(m.kind, nil, MAX_NAME), + cap = Wire.num(m.cap, nil, 1, MAX_PLAZA), profile = profile(m.profile), avatar = Wire.avatar(m.avatar), } @@ -992,6 +993,8 @@ SCHEMAS.plaza_state = function(m) return { kind = Wire.str(m.kind, nil, MAX_NAME), instance = Wire.num(m.instance, nil, 0, MAX_INT), + cap = Wire.num(m.cap, nil, 1, MAX_PLAZA), + rev = Wire.num(m.rev, nil, 0, MAX_INT), you = Wire.num(m.you, nil, 1, MAX_PLAZA), members = type(m.members) == "table" and Wire.list(m.members, MAX_PLAZA, Wire.member) or nil, @@ -1002,6 +1005,7 @@ SCHEMAS.plaza_delta = function(m) return { kind = Wire.str(m.kind, nil, MAX_NAME), instance = Wire.num(m.instance, nil, 0, MAX_INT), + rev = Wire.num(m.rev, nil, 0, MAX_INT), joined = Wire.list(m.joined, MAX_PLAZA, Wire.member), left = idList(m.left, MAX_PLAZA), changed = Wire.list(m.changed, MAX_PLAZA, Wire.member), diff --git a/src/mods/Gen3Compat.lua b/src/mods/Gen3Compat.lua index 377f8c10..ead6c18a 100644 --- a/src/mods/Gen3Compat.lua +++ b/src/mods/Gen3Compat.lua @@ -903,6 +903,7 @@ COVERAGE["src.core.Game"] = { -- ------- src.world.Map local MapView = {} +local connViews = setmetatable({}, { __mode = "k" }) local function collision() return package.loaded["src.core.game3.collision"] @@ -919,7 +920,21 @@ function MapView.__index(self, key) if key == "widthCells" then return def and def.width end if key == "heightCells" then return def and def.height end if key == "warps" then return def and def.warps end - if key == "connections" then return def and def.connections end + if key == "connections" then + if not def then return nil end + local hit = connViews[def] + if hit and hit.src == def.connections then return hit.view end + local list = require("src.core.game3.connections").each(def) + local view = setmetatable(list, { __index = function(t, dir) + if type(dir) ~= "string" then return nil end + for i = 1, #t do + if t[i].dir == dir then return t[i] end + end + return nil + end }) + connViews[def] = { src = def.connections, view = view } + return view + end return nil end diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index b1c3a5a3..c473b2e5 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -258,11 +258,14 @@ function Loader:_installDevShim() -- doing it is the hole this closes, and any future path that runs mod -- code without a sandbox env still lands here. local owner = Runtime.currentMod or Runtime.modRequire - if owner or callerIsMod(3) then + if owner then local id = type(owner) == "string" and owner or nil local denial = Sandbox.moduleDenial(name, devShim.permissions[id]) or (id and crossGenerationDenial(name, devShim.generation)) if denial then error(("[%s] %s"):format(id or "mod", denial), 0) end + else + local denial = Sandbox.moduleDenial(name, nil) + if denial and callerIsMod(3) then error(("[mod] %s"):format(denial), 0) end end if devShim.dev or devShim.generation ~= 1 then scanRequire(name) end -- The Gen 1 name a mod asked for, answered by this generation's compat arm. diff --git a/src/online/Client.lua b/src/online/Client.lua index 35bcdd11..5f7ed943 100644 --- a/src/online/Client.lua +++ b/src/online/Client.lua @@ -143,6 +143,11 @@ function Client.upgradeRequired() return S.upgrade end function Client.invites() return S.invites end function Client.outgoing() return S.outgoing end function Client.plaza() return S.plaza end +function Client.plazaRev() + local p = S.plaza + if not p then return nil end + return p.instance, p.rev +end function Client.plazaCounts() return S.plazaCounts end function Client.group() return S.group end function Client.direct() return S.direct end @@ -747,6 +752,7 @@ local function applyPlazaDelta(msg) and msg.instance ~= plaza.instance then return false end + if type(msg.rev) == "number" then plaza.rev = msg.rev end local gone = {} for _, id in ipairs(msg.left or {}) do gone[id] = true end local members = {} @@ -831,7 +837,7 @@ local function resendState() if S.presence then sendRaw(Protocol2.presence(S.presence)) end for _, kind in ipairs({ "union", "wireless" }) do local j = S.plazaJoins[kind] - if j then sendRaw(Protocol2.plazaJoin(kind, j.profile, j.avatar)) end + if j then sendRaw(Protocol2.plazaJoin(kind, j.profile, j.avatar, j.cap)) end end local board = S.presence and S.presence.board if S.plazaJoins.union and type(board) == "table" then @@ -1051,7 +1057,7 @@ local function handle(msg) emit("invite_closed", msg) elseif kind == "plaza_state" then S.plaza = { kind = msg.kind, instance = msg.instance, you = msg.you, - members = sortMembers(msg.members) } + cap = msg.cap, rev = msg.rev, members = sortMembers(msg.members) } emit("plaza", S.plaza) elseif kind == "plaza_delta" then if applyPlazaDelta(msg) then emit("plaza", S.plaza) end @@ -1398,10 +1404,11 @@ function Client.replyInvite(id, accept) return sendRaw(Protocol2.inviteReply(id, accept == true)) end -function Client.joinPlaza(kind, profile, avatar) +function Client.joinPlaza(kind, profile, avatar, cap) profile = profile or defaultProfile(3) - S.plazaJoins[kind] = { profile = profile, avatar = avatar } - return sendRaw(Protocol2.plazaJoin(kind, profile, avatar)) + if cap == nil and kind == "union" then cap = Protocol2.PLAZA_CAP end + S.plazaJoins[kind] = { profile = profile, avatar = avatar, cap = cap } + return sendRaw(Protocol2.plazaJoin(kind, profile, avatar, cap)) end function Client.leavePlaza(kind) diff --git a/src/online/Protocol2.lua b/src/online/Protocol2.lua index 8f455801..47af9e49 100644 --- a/src/online/Protocol2.lua +++ b/src/online/Protocol2.lua @@ -98,9 +98,11 @@ function Protocol2.inviteReply(id, accept) return build({ type = "invite_reply", id = id, accept = accept == true }) end -function Protocol2.plazaJoin(kind, profile, avatar) +Protocol2.PLAZA_CAP = 40 + +function Protocol2.plazaJoin(kind, profile, avatar, cap) return build({ type = "plaza_join", kind = kind, profile = profile, - avatar = avatar }) + avatar = avatar, cap = cap }) end function Protocol2.plazaLeave(kind) @@ -399,11 +401,14 @@ end VALIDATORS.plaza_state = function(m) if type(m.members) ~= "table" then return nil, "plaza_state without members" end if type(m.kind) ~= "string" then return nil, "plaza_state without a kind" end + if m.rev ~= nil and type(m.rev) ~= "number" then return nil, "plaza_state rev is not a number" end + if m.cap ~= nil and type(m.cap) ~= "number" then return nil, "plaza_state cap is not a number" end return m end VALIDATORS.plaza_delta = function(m) if type(m.kind) ~= "string" then return nil, "plaza_delta without a kind" end + if m.rev ~= nil and type(m.rev) ~= "number" then return nil, "plaza_delta rev is not a number" end return m end diff --git a/src/render/BattleTransition.lua b/src/render/BattleTransition.lua index aa214344..012a146d 100644 --- a/src/render/BattleTransition.lua +++ b/src/render/BattleTransition.lua @@ -14,6 +14,7 @@ local Runtime = require("src.mods.Runtime") local BattleTransition = {} BattleTransition.__index = BattleTransition BattleTransition.isOpaque = false -- draws over the frozen overworld +BattleTransition.isFixedSpeed = true -- BattleTransition_FlashScreenPalettes: fade to black and back, then to -- white and back; each palette held 2 frames, whole sequence played 3 diff --git a/src/render/PaletteFX.lua b/src/render/PaletteFX.lua index 44c5523d..7a8b6a8f 100644 --- a/src/render/PaletteFX.lua +++ b/src/render/PaletteFX.lua @@ -73,8 +73,8 @@ function PaletteFX.darkObp(colors, group) end -- the same shift folded into a baked 8-group world palette array (ADVANCED) -local function darkGroups(groups) - if not (groups and darkWorld) then return groups end +local function darkGroups(groups, lit) + if lit or not (groups and darkWorld) then return groups end local out = {} for i = 1, #groups do out[i] = PaletteFX.permute(groups[i], PaletteFX.DARK_BGP) @@ -716,11 +716,11 @@ end -- slot swapped to the current town/route (Route 6's north end uses -- Saffron's roof colors while the player stands in its top 2 cell rows, -- like pokered's wYCoord check -- data is Game.data, for the map lookup) -function PaletteFX.worldGroupColors(data, tileset, mapId, playerCellY) +function PaletteFX.worldGroupColors(data, tileset, mapId, playerCellY, lit) local w = PaletteFX.worldPack() local base = w and w.groupColors[tileset] if not base then return nil end - if not w.roofGroup[tileset] then return darkGroups(base) end + if not w.roofGroup[tileset] then return darkGroups(base, lit) end local roofMapId = mapId if mapId == ROUTE_6_SAFFRON.mapId and playerCellY and playerCellY < ROUTE_6_SAFFRON.cellYBelow then @@ -728,7 +728,7 @@ function PaletteFX.worldGroupColors(data, tileset, mapId, playerCellY) end local roofMap = data and data.maps and data.maps[roofMapId] local roof = roofMap and w.roofByMapIndex[roofMap.index] - if not roof then return darkGroups(base) end + if not roof then return darkGroups(base, lit) end local out = {} for i = 1, 8 do out[i] = base[i] end -- LoadTownPalette only overwrites W2_BgPaletteData + $32, i.e. colors 1 @@ -738,7 +738,7 @@ function PaletteFX.worldGroupColors(data, tileset, mapId, playerCellY) -- material's 2 middle shades are town-specific local base4 = base[ROOF_GROUP + 1] out[ROOF_GROUP + 1] = { base4[1], roof[1], roof[2], base4[4] } - return darkGroups(out) + return darkGroups(out, lit) end -- an overworld sprite's resolved 4-color OBJ palette (ColorOverworldSprite), diff --git a/src/render/TileRenderer.lua b/src/render/TileRenderer.lua index 9ba00136..fe41cf26 100644 --- a/src/render/TileRenderer.lua +++ b/src/render/TileRenderer.lua @@ -185,11 +185,11 @@ TileRenderer.recolorSample = recolorSample -- the 8 shifted variants of one tile (built once per sheet + tile id [+ -- gbcKey, when `colors` recolors it for RED++ -- see buildAnim]) local shiftVariants = {} -local function getShiftVariants(tilesetImagePath, perRow, tile, colors, gbcKey) +local function getShiftVariants(tilesetImagePath, perRow, tile, colors, gbcKey, own) local key = tilesetImagePath .. "#" .. tile .. (gbcKey or "") - if shiftVariants[key] ~= nil then return shiftVariants[key] end + if not own and shiftVariants[key] ~= nil then return shiftVariants[key] end if not (love.image and love.image.newImageData) then - shiftVariants[key] = false + if not own then shiftVariants[key] = false end return false end local id = Assets.imageData(tilesetImagePath) @@ -207,14 +207,14 @@ local function getShiftVariants(tilesetImagePath, perRow, tile, colors, gbcKey) end out[o + 1] = love.graphics.newImage(v) end - shiftVariants[key] = out + if not own then shiftVariants[key] = out end return out end local frameImages = {} -local function getFrameImages(paths, colors, gbcKey) +local function getFrameImages(paths, colors, gbcKey, own) local key = table.concat(paths, "|") .. (gbcKey or "") - if frameImages[key] ~= nil then return frameImages[key] end + if not own and frameImages[key] ~= nil then return frameImages[key] end local out = {} for i, path in ipairs(paths) do local ok, img = pcall(function() @@ -234,12 +234,12 @@ local function getFrameImages(paths, colors, gbcKey) return love.graphics.newImage(out2) end) if not ok then - frameImages[key] = false + if not own then frameImages[key] = false end return false end out[i] = img end - frameImages[key] = out + if not own then frameImages[key] = out end return out end @@ -359,14 +359,14 @@ local function buildAnim(spec, tilesetImagePath, perRow, quads, gbc) local sequence = {} for i, offset in ipairs(offsets) do sequence[i] = offset + 1 end return { tiles = tiles, textures = textures, sequence = sequence, - period = period } + period = period, spec = gbc and spec or nil } elseif spec.kind == "frames" then local sequence = spec.sequence if not (spec.images and sequence and #sequence > 0) then return nil end local textures = getFrameImages(spec.images, colors, gbc and gbc.key) if not textures then return nil end return { tiles = tiles, textures = textures, sequence = sequence, - period = period } + period = period, spec = gbc and spec or nil } elseif spec.kind == "toggle" then if gbc then return nil end local image = getToggleImage(spec, tilesetImagePath, perRow) @@ -407,12 +407,24 @@ local function gbcKeyFor(mapId) return "#gbc:" .. mapId .. PaletteFX.darkKey() end -local function getGbcAtlas(imagePath, tilesetId, mapId, perRow, data) +local function bgpGroupColors(data, tilesetId, mapId, bgp) + local groupColors = PaletteFX.worldGroupColors(data, tilesetId, mapId, nil, bgp ~= nil) + if groupColors and bgp then + local shade = {} + for i = 0, 3 do shade[i] = math.floor(bgp / (4 ^ i)) % 4 end + local shaded = {} + for i = 1, #groupColors do shaded[i] = PaletteFX.permute(groupColors[i], shade) end + groupColors = shaded + end + return groupColors +end + +local function getGbcAtlas(imagePath, tilesetId, mapId, perRow, data, bgp) local key = imagePath .. gbcKeyFor(mapId) - if gbcAtlasCache[key] ~= nil then return gbcAtlasCache[key] or nil end + if not bgp and gbcAtlasCache[key] ~= nil then return gbcAtlasCache[key] or nil end local img = false if love.image and love.image.newImageData then - local groupColors = PaletteFX.worldGroupColors(data, tilesetId, mapId, nil) + local groupColors = bgpGroupColors(data, tilesetId, mapId, bgp) if groupColors then local src = Assets.imageData(imagePath) local iw, ih = src:getDimensions() @@ -458,6 +470,7 @@ local function getGbcAtlas(imagePath, tilesetId, mapId, perRow, data) img = love.graphics.newImage(out) end end + if bgp then return img or nil end gbcAtlasCache[key] = img return img or nil end @@ -881,6 +894,90 @@ end local function safeRelease(o) if o and o.release then pcall(o.release, o) end end +local EMPTY = {} + +-- engine/pikachu/pikachu_pic_animation.asm:845 +function TileRenderer:bgpImage(byte) + if not (byte and self.gbcAtlas and self.data) then return nil end + local base = self.baseImage or self.image + if byte == 0xE4 and not PaletteFX.darkWorld() then return base end + self.bgpImages = self.bgpImages or {} + local img = self.bgpImages[byte] + if img == nil then + local ts = self.map.tileset + local ok, made = pcall(getGbcAtlas, ts.image, ts.id, self.map.id, + ts.tilesPerRow, self.data, byte) + img = ok and made or false + self.bgpImages[byte] = img + if img then pcall(self.bakeBgpAnims, self, byte) end + end + return img or nil +end + +function TileRenderer:bakeBgpAnims(byte) + local ctx = self.gbcCtx + if not (ctx and self.anims) then return end + local groupColors = bgpGroupColors(self.data, ctx.tilesetId, ctx.mapId, byte) + if not groupColors then return end + for _, anim in ipairs(self.anims) do + local spec = anim.spec + local group = spec and PaletteFX.worldGroupAt(ctx.tilesetId, ctx.mapId, anim.tiles[1]) + local colors = group and groupColors[group + 1] + if colors then + local textures + if spec.kind == "hshift" then + textures = getShiftVariants(ctx.imagePath, ctx.perRow, anim.tiles[1], + colors, nil, true) + else + textures = getFrameImages(spec.images, colors, nil, true) + end + if textures then + anim.bgpTextures = anim.bgpTextures or {} + anim.bgpTextures[byte] = textures + end + end + end +end + +function TileRenderer:setBgp(byte) + if byte == self.curBgp and byte ~= nil then return true end + local before = self.image + if self.curBgp then + self.bgpBorders[self.curBgp] = self.borderFill + self.image = self.baseImage + self.borderFill, self.borderFillMode = self.baseBorder, self.baseBorderMode + self.baseImage, self.baseBorder, self.baseBorderMode = nil, nil, nil + self.curBgp = nil + for _, anim in ipairs(self.anims or EMPTY) do + if anim.baseTextures then + anim.textures, anim.baseTextures = anim.baseTextures, nil + end + end + end + local shown = false + local img = self:bgpImage(byte) + if img and img == self.image then + shown = true + elseif img then + self.bgpBorders = self.bgpBorders or {} + self.baseImage = self.image + self.baseBorder, self.baseBorderMode = self.borderFill, self.borderFillMode + self.image = img + self.borderFill = self.bgpBorders[byte] + self.borderFillMode = self.borderFill and self.baseBorderMode or nil + self.bgpBorders[byte] = nil + self.curBgp = byte + for _, anim in ipairs(self.anims or EMPTY) do + local textures = anim.bgpTextures and anim.bgpTextures[byte] + if textures then + anim.baseTextures, anim.textures = anim.textures, textures + end + end + shown = true + end + if self.winBatch and self.image ~= before then self.winBatch:setTexture(self.image) end + return shown +end -- Release only the GPU objects this instance built and uniquely owns: the -- two SpriteBatches, the border-fill image and its quad, the per-tile @@ -890,6 +987,9 @@ end -- them. Used by :rebuild before it swaps in fresh batches, and by -- :release on eviction. function TileRenderer:releaseBatches() + if self.curBgp then self:setBgp(nil) end + for _, img in pairs(self.bgpBorders or {}) do safeRelease(img) end + self.bgpBorders = nil safeRelease(self.winBatch); self.winBatch = nil safeRelease(self.borderFill); self.borderFill = nil safeRelease(self.borderQuad); self.borderQuad = nil @@ -928,6 +1028,14 @@ function TileRenderer:release() self.image = nil self.gbcAtlas = nil end + for _, img in pairs(self.bgpImages or {}) do safeRelease(img) end + self.bgpImages = nil + for _, anim in ipairs(self.anims or {}) do + for _, textures in pairs(anim.bgpTextures or {}) do + for _, img in ipairs(textures) do safeRelease(img) end + end + anim.bgpTextures = nil + end self.anims = nil end diff --git a/src/save_convert/GenSave.lua b/src/save_convert/GenSave.lua index 518a6a6f..a8d89686 100644 --- a/src/save_convert/GenSave.lua +++ b/src/save_convert/GenSave.lua @@ -1123,10 +1123,12 @@ function GenSave.encode(save, data, template) for b = 7, 12 do encodeBoxRegion(O.box7 + (b - 7) * BOX_REGION_SIZE, boxes[b] or {}) end -- map + position + local px = math.floor(tonumber(save.player and save.player.x) or 0) + local py = math.floor(tonumber(save.player and save.player.y) or 0) if save.player and save.player.map then setByte(buf, O.curMap, cw.mapsIndex[save.player.map] or 0) - setByte(buf, O.yCoord, save.player.y or 0) - setByte(buf, O.xCoord, save.player.x or 0) + setByte(buf, O.yCoord, py) + setByte(buf, O.xCoord, px) end if save.lastOutdoor and save.lastOutdoor.id then setByte(buf, O.lastMap, cw.mapsIndex[save.lastOutdoor.id] or 0) @@ -1137,10 +1139,10 @@ function GenSave.encode(save, data, template) -- zero-filled one boots into a garbled map on a silent hang (#889). -- -- Rebuilt when there is no template at all (a save that began as a New Game - -- in this port), and when the template was saved on a DIFFERENT map than the - -- one the player is standing on now -- an imported save that has since been - -- played carries the old map's header, which is just as unbootable. A - -- template still on its own map keeps its bytes untouched: they are the + -- in this port), and when the template was saved on a DIFFERENT map or cell + -- than the one the player is standing on now -- an imported save that has + -- since been played carries a stale map window, which is just as unbootable. + -- A template still on its own cell keeps its bytes untouched: they are the -- game's own, including live NPC positions, and preserving them is what -- makes import -> export byte-identical. local mapId = save.player and save.player.map @@ -1151,10 +1153,11 @@ function GenSave.encode(save, data, template) -- map has no index at all rather than trusting a stale template local index = cw.mapsIndex[mapId] rebuild = index == nil or u8(src, O.curMap) ~= bit.band(index, 0xFF) + or u8(src, O.yCoord) ~= bit.band(py, 0xFF) + or u8(src, O.xCoord) ~= bit.band(px, 0xFF) end if rebuild then - local ctx, why = MapContext.build(data, mapId, - (save.player and save.player.x) or 0, (save.player and save.player.y) or 0) + local ctx, why = MapContext.build(data, mapId, px, py) -- home/overworld.asm:2016 (#1691) if not ctx then error(("this save cannot be exported: %s"):format(tostring(why)), 0) diff --git a/src/save_convert/MapContext.lua b/src/save_convert/MapContext.lua index 7289dbe2..ee133ea4 100644 --- a/src/save_convert/MapContext.lua +++ b/src/save_convert/MapContext.lua @@ -57,6 +57,10 @@ local O = { currentMapHeight2 = 557, currentMapWidth2 = 558, tilesetHeader = 564, -- wTilesetBank .. wGrassTile (11) + grassRate = 1424, -- ram/wram.asm:2408 wGrassRate + grassMons = 1425, + waterRate = 1453, + waterMons = 1454, } MapContext.OFFSETS = O @@ -66,6 +70,7 @@ local MAX_OBJECT_EVENTS = 16 local CONNECTION_STRUCT = 11 -- map_connection_struct (macros/ram.asm) local SPRITE_STRUCT = 16 -- one wSpriteStateData1/2 entry local NUM_SPRITE_STRUCTS = 16 +local NUM_WILDMONS = 10 -- wOverworldMap, the tile-block map the view pointer indexes. Recovered from -- the event_displacement formula below applied to a real save's coordinates, @@ -227,6 +232,36 @@ function MapContext.build(data, mapId, x, y) writes[O.mapMusicSoundID] = { id } writes[O.mapMusicROMBank] = { song.bank % 256 } + -- engine/overworld/wild_mons.asm:1 + if type(data.encounters) ~= "table" then + return nil, "no wild encounter data (re-import the ROM)" + end + local enc = data.encounters[mapId] + local pokemon = data.pokemon or {} + local function wildRows(t) + local out = {} + for i = 1, NUM_WILDMONS do + local slot = t.slots and t.slots[i] + local def = slot and pokemon[slot.species] + if not (slot and def and def.index) then return nil end + out[#out + 1] = (tonumber(slot.level) or 0) % 256 + out[#out + 1] = def.index % 256 + end + return out + end + for _, kind in ipairs({ "grass", "water" }) do + local t = type(enc) == "table" and enc[kind] or nil + local rate = t and tonumber(t.rate) or 0 + writes[O[kind .. "Rate"]] = { rate % 256 } + if rate ~= 0 then + local rows = wildRows(t) + if not rows then + return nil, ("bad %s wild data for %s (re-import the ROM)"):format(kind, tostring(mapId)) + end + writes[O[kind .. "Mons"]] = rows + end + end + -- Player position within its block, and the upper-left corner of the view. -- The pointer is the same expression the warp_to tables are assembled with -- (macros/coords.asm event_displacement). diff --git a/src/save_convert/SaveConvert.lua b/src/save_convert/SaveConvert.lua index 26493ed4..ad5277b6 100644 --- a/src/save_convert/SaveConvert.lua +++ b/src/save_convert/SaveConvert.lua @@ -94,6 +94,7 @@ local DATA_MODULES = { -- rebuild the current map's engine state on export (#889) tilesets = { "data.generated.tilesets", "data/generated/tilesets.lua" }, audio = { "data.generated.audio", "data/generated/audio.lua" }, + encounters = { "data.generated.encounters", "data/generated/encounters.lua" }, charmap = { "src.save_convert.data.charmap", "src/save_convert/data/charmap.lua" }, eventFlags = { "src.save_convert.data.event_flags", "src/save_convert/data/event_flags.lua" }, toggleObjects = { "src.save_convert.data.toggle_objects", "src/save_convert/data/toggle_objects.lua" }, @@ -253,14 +254,6 @@ local function defaultsSave() defeatedTrainers = {}, repelSteps = 0, modData = {}, - options = { - textSpeed = 3, animations = true, battleStyle = "shift", - battleLayout = "og", - ruleset = "gen1_faithful", musicVol = 7, sfxVol = 7, pikaVol = 7, - musicFilter = 0, - speed = 1, colors = "gbc", tilt = 0, - videoMode = "windowed", mods = {}, - }, } end diff --git a/src/sync/SyncEngine.lua b/src/sync/SyncEngine.lua index a1679f9f..0ef0fe6b 100644 --- a/src/sync/SyncEngine.lua +++ b/src/sync/SyncEngine.lua @@ -207,6 +207,9 @@ function SyncEngine.defaultSaves() playthroughId = id, format = meta.format, engine = meta.engine, + -- Derive from the saved set, including older saves that + -- predate modCount, rather than this device's current mods. + modCount = #(type(meta.mods) == "table" and meta.mods or {}), playTime = tonumber(save.playTime), summary = { name = name, diff --git a/src/ui/OptionsMenu.lua b/src/ui/OptionsMenu.lua index 517b2d2f..cdc9f444 100644 --- a/src/ui/OptionsMenu.lua +++ b/src/ui/OptionsMenu.lua @@ -529,10 +529,7 @@ local function buildRows(game) return true end }, -- fast-forward the logic clock only; music and sfx keep their tempo - -- (src/core/GameSpeed.lua), so this is safe to leave on. Per-category - -- (RFC 0007): overworld walking, battle turns and menu navigation each - -- cycle their own multiplier -- GameSpeed.CATEGORIES is the single - -- source of truth for which three rows exist. + -- (src/core/GameSpeed.lua), so this is safe to leave on. { id = "speedOverworld", label = Strings("OVERWORLD SPEED"), value = function(g) return gameSpeedLabel(g.save.options.speedOverworld) @@ -542,15 +539,6 @@ local function buildRows(game) o.speedOverworld = GameSpeed.cycle(o.speedOverworld, dir) return true end }, - { id = "speedBattle", label = Strings("BATTLE SPEED"), - value = function(g) - return gameSpeedLabel(g.save.options.speedBattle) - end, - step = function(g, dir) - local o = g.save.options - o.speedBattle = GameSpeed.cycle(o.speedBattle, dir) - return true - end }, { id = "speedMenu", label = Strings("MENU SPEED"), value = function(g) return gameSpeedLabel(g.save.options.speedMenu) @@ -726,7 +714,7 @@ local GROUPS = { members = { "uiLayout", "videoMode", "orientation", "faithfulRes", "screenPos", "fpsCap", "vsync", "logicClock" } }, { id = "group.speed", label = "SPEED", - members = { "textSpeed", "speedOverworld", "speedBattle", "speedMenu" } }, + members = { "textSpeed", "speedOverworld", "speedMenu" } }, { id = "group.graphics", label = "GRAPHICS", members = { "colors", "uiLetterbox", "shaderfx", "shaderfx2" } }, -- A mod's Pipelines row splices in after TILT and is in no group, so it diff --git a/src/ui/ShopMenu.lua b/src/ui/ShopMenu.lua index c966e366..4a8c4a48 100644 --- a/src/ui/ShopMenu.lua +++ b/src/ui/ShopMenu.lua @@ -28,12 +28,31 @@ local function anythingElse(game) Strings("Is there anything\nelse I can do?")) end +-- engine/events/pokemart.asm:131-133 +local function say(game, menu, text, after) + local box + box = TextBox.new(game, text, nil, { stay = { onShown = function() + if game.stack:top() == box then game.stack:pop() end + menu.footer = text + if after then after() end + end } }) + game.stack:push(box) +end + +-- engine/events/pokemart.asm:199-206 +local function backToMenu(game, menu) + say(game, menu, anythingElse(game), function() + -- engine/events/pokemart.asm:10 + menu.index, menu.hollowIndex = 1, nil + end) +end + -- .returnToMainPokemartMenu -- pokered engine/events/pokemart.asm:199-206 local function refuse(game, menu, list, text, under) game.stack:push(TextBox.new(game, text, function() if under and game.stack:top() == under then game.stack:pop() end if list then list:close() end - menu.footer = anythingElse(game) + backToMenu(game, menu) end)) end @@ -62,12 +81,12 @@ local function buy(game, stock, menu) itemBox = true, money = function() return game.save.money end, footer = greet, - onCancel = function() menu.footer = anythingElse(game) end, + onCancel = function() backToMenu(game, menu) end, onChoose = function(item) -- home/list_menu.asm:105-110, 523-528 if item.cancel then list:close() - menu.footer = anythingElse(game) + backToMenu(game, menu) return end -- home/list_menu.asm:89-91 @@ -126,7 +145,8 @@ local function buy(game, stock, menu) game.stack:push(qtyBox) end, }) - game.stack:push(list) + -- engine/events/pokemart.asm:131-150 + say(game, menu, greet, function() game.stack:push(list) end) end -- home/list_menu.asm:472-477 @@ -168,7 +188,7 @@ local function sell(game, menu) itemBox = true, money = function() return game.save.money end, footer = greet, - onCancel = function() menu.footer = anythingElse(game) end, + onCancel = function() backToMenu(game, menu) end, onSelectKey = function(item, l) -- swap_items.asm:19-22 if not item or item.cancel then return end @@ -186,7 +206,7 @@ local function sell(game, menu) -- home/list_menu.asm:105-110, 523-528 if item.cancel then list:close() - menu.footer = anythingElse(game) + backToMenu(game, menu) return end -- home/list_menu.asm:89-91 @@ -245,7 +265,8 @@ local function sell(game, menu) game.stack:push(qtyBox) end, }) - game.stack:push(list) + -- engine/events/pokemart.asm:50-52 + say(game, menu, greet, function() game.stack:push(list) end) end -- MONEY_BOX 11,0 (data/text_boxes.asm:35) over the greeting PrintText left @@ -281,21 +302,39 @@ function ShopMenu.new(game, stock, onQuit) -- keepOpen: the mart menu stays underneath its list so closing the -- list lands back here; only QUIT (or B) leaves and fires onQuit local menu + -- engine/menus/text_box.asm:176 + local function chosen(fn) + return function() + menu.hollowIndex = menu.index + fn() + end + end -- engine/events/pokemart.asm:220 .done, reached by QUIT and by B alike local function farewell() game.stack:push(TextBox.new(game, - txt(game, "_PokemartThankYouText", "Thank you!"), onQuit)) + txt(game, "_PokemartThankYouText", "Thank you!"), function() + if game.stack:top() == menu then game.stack:pop() end + if onQuit then onQuit() end + end)) end menu = Menu.new(game, { - { label = Strings("BUY"), keepOpen = true, onSelect = function() buy(game, stock, menu) end }, - { label = Strings("SELL"), keepOpen = true, onSelect = function() sell(game, menu) end }, - { label = Strings("QUIT"), onSelect = farewell }, + { label = Strings("BUY"), keepOpen = true, + onSelect = chosen(function() buy(game, stock, menu) end) }, + { label = Strings("SELL"), keepOpen = true, + onSelect = chosen(function() sell(game, menu) end) }, + { label = Strings("QUIT"), keepOpen = true, onSelect = chosen(farewell) }, -- data/text_boxes.asm:34 - }, { tx = 0, ty = 0, tw = 11, th = 7 }) - menu.onCancel = farewell - menu.footer = txt(game, "_PokemartGreetingText", - Strings("Hi there!\nMay I help you?")) + }, { tx = 0, ty = 0, tw = 11, th = 7, keepOnCancel = true }) + menu.onCancel = chosen(farewell) + -- home/text_script.asm:141-150 + function menu:enter() + self.hidden = true + say(game, self, txt(game, "_PokemartGreetingText", + Strings("Hi there!\nMay I help you?")), + function() self.hidden = false end) + end menu.draw = function(self) + if self.hidden then return end drawClerk(self) Menu.draw(self) end diff --git a/src/ui/game3/bag_menu.lua b/src/ui/game3/bag_menu.lua index 967f3e28..5d2eb95b 100644 --- a/src/ui/game3/bag_menu.lua +++ b/src/ui/game3/bag_menu.lua @@ -61,7 +61,7 @@ end -- src/union_room.c:4558 InUnionRoom local function link_menus_active() local Map = package.loaded["src.core.game3.map"] - if Map and Map.current == "FR_UNION_ROOM" then return true end + if Map and require("src.core.game3.link.union_room").isUnionMap(Map.current) then return true end local Link = package.loaded["src.core.game3.link"] return type(Link) == "table" and Link.link ~= nil and Link.inLinkRoom() == true end diff --git a/src/ui/game3/boot.lua b/src/ui/game3/boot.lua index 3750ed1f..d080fcf5 100644 --- a/src/ui/game3/boot.lua +++ b/src/ui/game3/boot.lua @@ -159,16 +159,7 @@ function Boot.continueInfoFromSave(save) local store = { flags = type(save.flags) == "table" and save.flags or {} } local pt = type(save.playTime) == "table" and save.playTime or type(save.playtime) == "table" and save.playtime or {} - local dex = type(save.dex) == "table" and save.dex or {} - local caught = dex.caught or dex.owned or {} - local counted, n = {}, 0 - for sp, on in pairs(caught) do - local id = tonumber(sp) - if id and on and on ~= 0 and not counted[id] and (dex.national or id <= 151) then - counted[id] = true - n = n + 1 - end - end + local n = require("src.core.game3.dex").summaryCount(save) local name = tostring(save.name or save.playerName or "") return { name = FrlgFont.truncate(name, 7), diff --git a/src/ui/game3/option_rows.lua b/src/ui/game3/option_rows.lua index baac784c..cd2d0e56 100644 --- a/src/ui/game3/option_rows.lua +++ b/src/ui/game3/option_rows.lua @@ -70,7 +70,6 @@ function Rows.build(ctx) step = function(c, dir) return cartCycle(c, "textSpeed", 3, dir) end, }) add(speedRow("speedOverworld", "OVERWORLD SPEED", "speedOverworld")) - add(speedRow("speedBattle", "BATTLE SPEED", "speedBattle")) add(speedRow("speedMenu", "MENU SPEED", "speedMenu")) add({ @@ -397,7 +396,7 @@ end Rows.GROUPS = { { id = "group.speed", label = "SPEED", - members = { "textSpeed", "speedOverworld", "speedBattle", "speedMenu" } }, + members = { "textSpeed", "speedOverworld", "speedMenu" } }, { id = "group.video", label = "VIDEO", members = { "uiLayout", "videoMode", "orientation", "faithfulRes", "screenPos", "fpsCap", "vsync", "logicClock" } }, diff --git a/src/ui/game3/party_menu.lua b/src/ui/game3/party_menu.lua index 73b6dc58..18ac8702 100644 --- a/src/ui/game3/party_menu.lua +++ b/src/ui/game3/party_menu.lua @@ -1657,7 +1657,19 @@ function PartyMenu.handleInput(input) local fx, fy = P.cellX + d[1], P.cellY + d[2] local facingObj = Objects.at(fx, fy) local isWater = Collision.isWater and Collision.isWater(fx, fy) - local isGrass = Collision.isGrass and Collision.isGrass(fx, fy) + -- pokefirered/src/fldeff_cut.c:140 + local function hasCuttableGrass3x3(px, py) + if not Collision.isGrass then return false end + -- pokefirered/src/fldeff_rocksmash.c:31 + local elev = P.elevation + for y = py - 1, py + 1 do + for x = px - 1, px + 1 do + local e = Collision.elevationAt and Collision.elevationAt(x, y) + if (elev == nil or e == elev) and Collision.isGrass(x, y) then return true end + end + end + return false + end local mapDef = Map.currentDef() -- pokefirered/src/party_menu.c:4118 local facingBeh = Collision.behavior and Collision.behavior(fx, fy) @@ -1671,7 +1683,7 @@ function PartyMenu.handleInput(input) isFacingWaterfall = FieldMoves.isWaterfallBehavior(facingBeh), facing = P.facing, isSurfing = P.surfing == true, - hasCuttableGrass = isGrass or (Collision.isGrass and Collision.isGrass(P.cellX, P.cellY)), + hasCuttableGrass = hasCuttableGrass3x3(P.cellX, P.cellY), mapType = mapDef and mapDef.mapType, isCave = mapHeaderFlag(mapDef, "cave"), canEscapeRope = mapHeaderFlag(mapDef, "allowEscaping"), diff --git a/src/ui/game3/start_menu.lua b/src/ui/game3/start_menu.lua index fae3fe36..456d101f 100644 --- a/src/ui/game3/start_menu.lua +++ b/src/ui/game3/start_menu.lua @@ -40,7 +40,7 @@ end local function in_union_room(session) local Map = package.loaded["src.core.game3.map"] local cur = (Map and type(Map.current) == "string" and Map.current) or (session and session.map) - return cur == "FR_UNION_ROOM" + return require("src.core.game3.link.union_room").isUnionMap(cur) end local function safari_active(session) diff --git a/src/ui/game3/union_room.lua b/src/ui/game3/union_room.lua index 9d978bdb..6c3c40a0 100644 --- a/src/ui/game3/union_room.lua +++ b/src/ui/game3/union_room.lua @@ -21,6 +21,9 @@ UnionRoomScreen.TYPES_TEMPLATE = Window.template(20, 2, 9, 11) UnionRoomScreen.BOARD_HEADER_TEMPLATE = Window.template(1, 1, 28, 2) -- pokefirered/src/data/union_room.h:302 UnionRoomScreen.BOARD_TEMPLATE = Window.template(1, 5, 28, 10) +-- pokefirered/src/union_room.c:4400 +UnionRoomScreen.BOARD_ROWS = 8 +UnionRoomScreen.BOARD_EXIT = "exit" -- pokefirered/src/data/union_room.h:197 UnionRoomScreen.ROW_HEIGHT = 14 @@ -202,14 +205,15 @@ function UnionRoomScreen.showBoard(opts) if item.entry then UnionRoomScreen.printBoardRow(item.entry, px, py, UnionRoomScreen.BOARD_SELF) end end } local offers = opts.offers or {} - for i = 0, 7 do + for i = 0, math.max(UnionRoomScreen.BOARD_ROWS, #offers) - 1 do local entry = offers[i + 1] rows[#rows + 1] = { label = "", id = i, disabled = entry == nil, entry = entry, print = function(item, px, py) if item.entry then UnionRoomScreen.printBoardRow(item.entry, px, py, UnionRoomScreen.BOARD_OTHER) end end } end - rows[#rows + 1] = { label = RomText.plain("gText_UR_Exit2"), id = 8, colors = UnionRoomScreen.BOARD_OTHER } + rows[#rows + 1] = { label = RomText.plain("gText_UR_Exit2"), id = UnionRoomScreen.BOARD_EXIT, + colors = UnionRoomScreen.BOARD_OTHER } local shown = UnionRoomScreen.showList("board", { template = UnionRoomScreen.BOARD_TEMPLATE, items = rows, diff --git a/src/ui/game3/wireless_icon.lua b/src/ui/game3/wireless_icon.lua index e954a6fe..ed051592 100644 --- a/src/ui/game3/wireless_icon.lua +++ b/src/ui/game3/wireless_icon.lua @@ -150,6 +150,7 @@ end function WirelessIcon.onLinkMap(mapId) if type(mapId) ~= "string" then return false end if WirelessIcon.LINK_MAPS[mapId] then return true end + if require("src.core.game3.link.union_room").isUnionMap(mapId) then return true end return mapId:match("_POKEMON_CENTER_2F$") ~= nil end diff --git a/src/ui/gen2/BattleState.lua b/src/ui/gen2/BattleState.lua index 38556edb..47aecf5a 100644 --- a/src/ui/gen2/BattleState.lua +++ b/src/ui/gen2/BattleState.lua @@ -54,6 +54,7 @@ local Unown = require("src.core.gen2.Unown") local BattleState = {} BattleState.__index = BattleState BattleState.isOpaque = true +BattleState.isBattle = true function BattleState:moveGridNavigation() if not Runtime.wantsHook("battle.move_grid_navigation") then return false end diff --git a/src/ui/gen2/BattleTransition.lua b/src/ui/gen2/BattleTransition.lua index 1a778fb3..9f9411f3 100644 --- a/src/ui/gen2/BattleTransition.lua +++ b/src/ui/gen2/BattleTransition.lua @@ -35,6 +35,7 @@ local Tilt = require("src.render.Tilt") local BattleTransition = {} BattleTransition.__index = BattleTransition BattleTransition.isOpaque = false +BattleTransition.isFixedSpeed = true local COLS, ROWS = 20, 18 -- SCREEN_WIDTH x SCREEN_HEIGHT, in tiles diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index 99dfd3c0..f5928395 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -5868,6 +5868,30 @@ function OverworldState:drawWorld() PaletteFX.setShadeMap((self.dark and not battleOverWorld) and PaletteFX.DARK_BGP or self:poisonShadeMap()) end + local tiles = self.map and self.map.renderer + self.boltBaked = nil + if tiles and tiles.setBgp then + local e = self.emote + -- engine/pikachu/pikachu_pic_animation.asm:790 + local prebake = e and e.boltAt and not e.boltDone and (e.boltT or 0) > e.boltAt + local bolt = e and not battleOverWorld and e.bgp or nil + local neighbors = self.neighbors + for i = 1, neighbors and #neighbors or 0 do + local r = neighbors[i].map.renderer + if r and r.setBgp then + if prebake then + r:bgpImage(0xC0) + r:bgpImage(0xE4) + end + r:setBgp(bolt) + end + end + if prebake then + tiles:bgpImage(0xC0) + tiles:bgpImage(0xE4) + end + self.boltBaked = tiles:setBgp(bolt) and bolt or nil + end -- advance the water/flower tile animation (runs under dialogs too). -- TileRenderer.tick uses wall-clock 60Hz steps so display refresh rate -- does not speed or slow the cycle (issue #4). @@ -6439,28 +6463,27 @@ function OverworldState:drawUI() -- TalkToPikachu's picture box (engine/pikachu/pikachu_pic_animation.asm -- PlacePikapicTextBoxBorder: TextBoxBorder at (6,5) with b,c = 5,5, so a -- 7x7 box holding the 5x5 pic at (7,6) -- PikaAnimTilemap_1). The - -- script's base frame is ripped as pikachu/pikapic_N.png (#561) but the - -- pikaframe overlays on top of it are not, so PikachuFollower - -- .picLift lifts the base on the runs that draw the alternate pose, and the -- script's own duration times the beat (#407, #424). Palette zone -- PAL_PIKACHU_PORTRAIT covers (7,6)-(11,10) via sgbPalettes above. if self.emote and self.emote.pikaPic then require("src.render.Font").drawBox(6, 5, 7, 7) - -- one image per path, cached: this draws every frame of the hold, and - -- a mod skin can move the path between talks - if self.pikaPicPath ~= self.emote.pikaPic then - local ok, loaded = pcall(love.graphics.newImage, self.emote.pikaPic) - self.pikaPicImg = ok and loaded or nil - self.pikaPicPath = self.emote.pikaPic + local path, lift = require("src.world.PikachuFollower").picFrame(self.emote) + self.pikaPicImgs = self.pikaPicImgs or {} + local img = path and self.pikaPicImgs[path] + if path and img == nil then + local ok, loaded = pcall(love.graphics.newImage, path) + img = ok and loaded or false + self.pikaPicImgs[path] = img end - local img = self.pikaPicImg + self.pikaPicDrawn = img and path or nil if img then love.graphics.setColor(1, 1, 1, 1) local w, h = img:getDimensions() - local lift = require("src.world.PikachuFollower").picLift(self.emote) love.graphics.draw(img, math.floor(56 + (40 - w) / 2), - math.floor(48 + (40 - h) / 2) - lift) + math.floor(48 + (40 - h) / 2) - (lift or 0)) end + else + self.pikaPicDrawn = nil end -- engine/gfx/screen_effects.asm:1-12 @@ -6483,7 +6506,7 @@ function OverworldState:drawUI() -- engine/pikachu/pikachu_pic_animation.asm:847 local bolt = self.emote and self.emote.bgp - if bolt and bolt ~= 0xE4 and Game and Game.renderer + if bolt and bolt ~= 0xE4 and Game and Game.renderer and self.boltBaked ~= bolt and (self:bakedWorldColors() or not PaletteFX.shader()) then Game.renderer.screenVeil = BOLT_WHITE_VEIL end diff --git a/src/world/PikachuFollower.lua b/src/world/PikachuFollower.lua index 7168ebd3..8101e7e3 100644 --- a/src/world/PikachuFollower.lua +++ b/src/world/PikachuFollower.lua @@ -735,11 +735,7 @@ end -- and voiced PCM clip, and raise the framed Pikachu picture the original -- puts over the map (pikaemotion_pikapic -> pikachu_pic_animation.asm -- PlacePikapicTextBoxBorder), drawn by OverworldController:drawUI. Each --- script's BASE 5x5 frame is ripped as pikachu/pikapic_N.png (#561); the --- pikaframe overlays it alternates with are a second full-body pose out of --- the same blob and are still unported, so picLift below stands in for --- their motion and the battle front pic covers caches built before the --- rip (#407). +-- script's BASE 5x5 frame is ripped as pikachu/pikapic_N.png (#561). -- --------------------------------------------------------------------- -- PikachuEmotionTable, reduced to each entry's bubble + pikaemotion_pcm @@ -804,6 +800,7 @@ local MODIFIER_EMOTIONS = { 18, 21, 23, 24, 25 } -- third of even the shortest script (#424). local PIKAPIC_TICK = 3 local PIKAPIC_LIFT = 4 -- px the stand-in pic rises on an overlay run +local PIKAPIC_DIR = "assets/generated/pikachu/" -- pikaemotion_pikapic's script id per emotion: emotion N takes -- PikaPicAnimScript N, except the four listed here (data/pikachu/ @@ -821,11 +818,9 @@ PikachuFollower.THUNDERBOLT_ROW = THUNDERBOLT_ROW -- Per script: pikapic_setduration's tick count, and for the scripts whose -- overlay is a whole second pose, that frameset's run lengths in ticks -- (data/pikachu/pikachu_pic_objects.asm PikaPicAnimBGFrames_*, which script --- N reaches as frameset N+5, or N+6 from script 10 up). The list alternates --- pikaframedelay (the base pic alone) and pikaframe (the overlay) starting --- with a delay, so a frameset that opens on a pikaframe opens with a zero --- here; the frameset restarts until pikapic_looptofinish runs the duration --- out. Scripts 1, 2, 3, 5, 6, 8 and 9 are left without a list on purpose: +-- N reaches as frameset N+5, or N+6 from script 10 up). The frameset +-- restarts until pikapic_looptofinish runs the duration out. +-- Scripts 1, 2, 3, 5, 6, 8 and 9 are left without a list on purpose: -- their overlays (PikaAnimTilemap_14 to _22) only paint a few tiles over a -- pic that otherwise stands still, so with no tiles to paint the port has -- nothing to show for them and must not bob the whole picture instead. @@ -836,29 +831,50 @@ local PIKAPIC = { [4] = { dur = 70, seq = { 8, 8, 20, 8 } }, [5] = { dur = 32 }, [6] = { dur = 50 }, - [7] = { dur = 58, seq = { 0, 8, 2, 8, 2, 8 } }, + [7] = { dur = 58, seq = { 8, 2, 8, 2, 8 }, + poses = { "e4841", false, "e4841", false, "e4841" } }, [8] = { dur = 44 }, [9] = { dur = 56 }, - [10] = { dur = 56, seq = { 8, 11, 5 } }, - [11] = { dur = 100, seq = { 20, 8, 20, 8 } }, - [12] = { dur = 50, seq = { 13, 12, 100, 8 } }, - [13] = { dur = 50, seq = { 5, 5, 5, 5, 100 } }, - [14] = { dur = 40, seq = { 2, 2, 2, 2 } }, - [15] = { dur = 50, seq = { 5, 5, 5, 5 } }, - [16] = { dur = 32, seq = { 0, 8, 100 } }, - [17] = { dur = 100, seq = { 10, 3, 3, 3, 100 } }, - [18] = { dur = 32, seq = { 3, 100, 8, 8 } }, - [19] = { dur = 44, seq = { 0, 6, 6, 6, 6 } }, - [20] = { dur = 50, seq = { 8, 12, 8, 12 } }, - [21] = { dur = 40, seq = { 8, 104 } }, - [22] = { dur = 40, seq = { 8, 100 } }, - [23] = { dur = 70, seq = { 16, 16, 16, 16 } }, - [24] = { dur = 60, seq = { 6, 6, 6, 6, 100 } }, - [25] = { dur = 50, seq = { 6, 106 }, bolt = 13 }, - [26] = { dur = 100, seq = { 20, 8, 20, 116 } }, - [27] = { dur = 30, seq = { 4, 100 } }, - [28] = { dur = 64, seq = { 12, 12, 12, 100 } }, + [10] = { dur = 56, seq = { 8, 3, 5, 3, 5 }, + poses = { false, "e4ce0", "e4e70", "e4ce0", false } }, + [11] = { dur = 100, seq = { 20, 8, 20, 8 }, + poses = { false, "e50af", false, "e50af" } }, + [12] = { dur = 50, seq = { 13, 12, 100, 8 }, + poses = { false, "e52fe", false, "e52fe" } }, + [13] = { dur = 50, seq = { 5, 5, 5, 5, 100 }, + poses = { false, "e5541", false, "e5541", false } }, + [14] = { dur = 40, seq = { 2, 2, 2, 2 }, + poses = { false, "e5794", false, "e5794" } }, + [15] = { dur = 50, seq = { 5, 5, 5, 5 }, + poses = { false, "e59ed", false, "e59ed" } }, + [16] = { dur = 32, seq = { 8, 100 }, + poses = { "e5c4d", false } }, + [17] = { dur = 100, seq = { 10, 3, 3, 3, 100 }, + poses = { false, "e5e90", false, "e5e90", false } }, + [18] = { dur = 32, seq = { 3, 100, 8, 8 }, + poses = { false, "e61b0", false, "e61b0" } }, + [19] = { dur = 44, seq = { 6, 6, 6, 6 }, + poses = { "e63f7", false, "e63f7", false } }, + [20] = { dur = 50, seq = { 8, 12, 8, 12 }, + poses = { false, "e6646", false, "e6646" } }, + [21] = { dur = 40, seq = { 8, 2, 1, 1, 100 }, + poses = { false, "e682f", "e69bf", "e6b4f", "e6cdf" } }, + [22] = { dur = 40, seq = { 8, 100 }, + poses = { false, "e6fff" } }, + [23] = { dur = 70, seq = { 16, 16, 16, 16 }, + poses = { false, "e731f", false, "e731f" } }, + [24] = { dur = 60, seq = { 6, 6, 6, 6, 100 }, + poses = { false, "e763f", false, "e763f", false } }, + [25] = { dur = 50, seq = { 6, 6, 100 }, + poses = { false, "e7863", "e79f3" }, bolt = 13 }, + [26] = { dur = 100, seq = { 20, 8, 20, 8, 8, 100 }, + poses = { false, "e50af", false, "e50af", "e7b83", "e7d13" } }, + [27] = { dur = 30, seq = { 4, 100 }, + poses = { false, "f0b64" } }, + [28] = { dur = 64, seq = { 12, 12, 12, 100 }, + poses = { false, "f0d82", false, "f0d82" } }, } +PikachuFollower.PIKAPIC = PIKAPIC local function moodEmotion(save) local mood = save.pikachuMood or 128 @@ -953,19 +969,22 @@ function playEmotion(game, ow, npc, emotion, opts) -- data/pikachu/pikachu_emotions.asm local function pikapic() - local Sprites = require("src.pokemon.Sprites") local script = PIKAPIC_SCRIPT[emotion] or emotion - local pic = "assets/generated/pikachu/pikapic_" .. script .. ".png" - if not require("src.render.Assets").exists(pic) then - pic = Sprites.path(game.data, "PIKACHU", "front", - { kind = "overworld" }) - end + local pic = PIKAPIC_DIR .. "pikapic_" .. script .. ".png" local anim = PIKAPIC[script] or PIKAPIC[1] local hold = anim.dur * PIKAPIC_TICK + local poses + if anim.poses then + poses = {} + for i = 1, #anim.seq do + local id = anim.poses[i] + poses[i] = id and (PIKAPIC_DIR .. "gfx_" .. id .. ".png") or false + end + end ow.emote = { npc = npc, frames = hold, bubble = false, pikaPic = pic, - pikaSeq = anim.seq, pikaTotal = hold, skippable = opts.skippable, - onDone = done, + pikaSeq = anim.seq, pikaPoses = poses, pikaTotal = hold, + skippable = opts.skippable, onDone = done, } if anim.bolt then -- engine/pikachu/pikachu_pic_animation.asm:520 @@ -1016,24 +1035,37 @@ function playEmotion(game, ow, npc, emotion, opts) return cry() end --- Where the framed pic sits this frame. The overlay a pikaframe run draws --- is a second full-body pose (PikaAnimTilemap_23 and up replace all 5x5 --- tiles) out of gfx/pikachu/unknown_*, which the cache does not carry, so --- the port lifts the one pic it has for the length of those runs -- the jump --- the happy emotions make inside the box (#424, still on #407's stand-in). -function PikachuFollower.picLift(emote) +local function frameRun(emote) local seq = emote and emote.pikaSeq - if not seq then return 0 end + if not seq then return nil end local loop = 0 for _, run in ipairs(seq) do loop = loop + run end - if loop <= 0 then return 0 end + if loop <= 0 then return nil end local elapsed = math.max(0, (emote.pikaTotal or 0) - (emote.frames or 0)) local tick = math.floor(elapsed / PIKAPIC_TICK) % loop for i, run in ipairs(seq) do - if tick < run then return i % 2 == 0 and PIKAPIC_LIFT or 0 end + if tick < run then return i end tick = tick - run end - return 0 + return nil +end + +function PikachuFollower.picLift(emote) + if emote and emote.pikaPoses then return 0 end + local i = frameRun(emote) + return (i and i % 2 == 0) and PIKAPIC_LIFT or 0 +end + +-- engine/pikachu/pikachu_pic_animation.asm:359 +function PikachuFollower.picFrame(emote) + if not emote then return nil, 0 end + if emote.pikaBlankAt and (emote.frames or 0) <= emote.pikaBlankAt then + return nil, 0 + end + local poses = emote.pikaPoses + if not poses then return emote.pikaPic, PikachuFollower.picLift(emote) end + local i = frameRun(emote) + return (i and poses[i]) or emote.pikaPic, 0 end -- engine/pikachu/pikachu_pic_animation.asm:790 @@ -1066,7 +1098,9 @@ function PikachuFollower.tickBolt(game, ow, emote) -- engine/pikachu/pikachu_pic_animation.asm:802 if Sound.moveSfxBusy() and k < strobe + THUNDERBOLT_SOUND_CAP then return end emote.boltDone = true - emote.frames = PIKAPIC_TICK + 1 + -- engine/pikachu/pikachu_pic_animation.asm:136-147 + emote.pikaBlankAt = PIKAPIC_TICK + emote.frames = 2 * PIKAPIC_TICK + 1 end -- Bill's House has three map-scripted Yellow companion beats diff --git a/tests/drivers/battle_cry_speed_bug2087_test.lua b/tests/drivers/battle_cry_speed_bug2087_test.lua index 598e6492..8cfbb8f2 100644 --- a/tests/drivers/battle_cry_speed_bug2087_test.lua +++ b/tests/drivers/battle_cry_speed_bug2087_test.lua @@ -25,6 +25,19 @@ return function(game) ow:pushBattle(battle) U.log("logic speed", game:logicSpeed(), "sfx rate", Sound.rate()) + local function inBattle() + for _, st in ipairs(game.stack.states) do + if st == battle then return true end + end + return false + end + local function assertLocked(where) + if inBattle() and game:logicSpeed() ~= 1 then + error(("speed lock: %s ran at %sX with speedOverride 4") + :format(where, tostring(game:logicSpeed()))) + end + end + assertLocked("battle start") if Sound.rate() ~= 1 then error(("bug2087: Game:update pitched SFX off battle speed (rate %s at 4X)") :format(tostring(Sound.rate()))) @@ -58,8 +71,11 @@ return function(game) end end + local lockedFrames = 0 local shot = false for _ = 1, 2400 do + assertLocked("entrance") + if inBattle() then lockedFrames = lockedFrames + 1 end poll("entrance cry") if battle.phase == "menu" then break end if battle.waitingSound and not shot then @@ -87,6 +103,7 @@ return function(game) for _ = 1, 2400 do poll("post-move sfx") if game.stack:top() ~= battle then break end + assertLocked("post-move") if #gates > before and cur and not shotLevel then shotLevel = U.shot(game, DIR .. "/bug2087_levelup.png") end @@ -105,11 +122,12 @@ return function(game) fail = ("bug2087: %s was pitched with battle speed (%s)"):format( g.label, tostring(g.pitch)) elseif g.dur and g.held < g.dur * 0.9 then - fail = ("bug2087: %s cut short at 4X (%.3fs of %.3fs)"):format( + fail = ("bug2087: %s cut short (%.3fs of %.3fs)"):format( g.label, g.held, g.dur) end end if fail then error(fail) end - U.log("PASS every battle sfx gate held for its real length at 4X") - U.log("input is yours now") + if lockedFrames == 0 then error("speed lock: the battle never reached the stack") end + U.log("PASS every battle sfx gate held for its real length, battle locked to 1X under a 4X override") + love.event.quit(0) end diff --git a/tests/drivers/battle_fanfare_speed_bug1952_test.lua b/tests/drivers/battle_fanfare_speed_bug1952_test.lua index 578abc83..e62b0ed3 100644 --- a/tests/drivers/battle_fanfare_speed_bug1952_test.lua +++ b/tests/drivers/battle_fanfare_speed_bug1952_test.lua @@ -1,5 +1,4 @@ --- Wait gates hold for the sound's real length at high GAME SPEED --- (#1952/#2087) without pitching one-shot SFX (#1990/#1991/#1997). +-- home/text.asm:506 (#1952/#2087, #1990/#1991/#1997) return function(game) local U = dofile("tests/drivers/util.lua") local DIR = os.getenv("SHOT_DIR") or os.getenv("POKEPORT_SHOT_DIR") or "/tmp/shots" @@ -26,12 +25,26 @@ return function(game) ow:pushBattle(battle) U.log("logic speed", game:logicSpeed(), "sfx rate", Sound.rate()) + local function inBattle() + for _, st in ipairs(game.stack.states) do + if st == battle then return true end + end + return false + end + local function assertLocked(where) + if inBattle() and game:logicSpeed() ~= 1 then + error(("speed lock: %s ran at %sX with speedOverride 4") + :format(where, tostring(game:logicSpeed()))) + end + end + assertLocked("battle start") if Sound.rate() ~= 1 then error(("bug1952: Game:update pitched SFX off GAME SPEED (rate %s at 4X)") :format(tostring(Sound.rate()))) end for _ = 1, 240 do + assertLocked("battle intro") if battle.phase == "menu" then break end U.tap(game, "a") U.wait(3) @@ -50,6 +63,7 @@ return function(game) local shot = false for _ = 1, 1200 do U.wait(1) + assertLocked("level-up") if battle.waitingSound and not t0 then local src = battle.waitingSound t0 = love.timer.getTime() @@ -80,13 +94,25 @@ return function(game) if not dur then error("bug1952: no duration for the level-up fanfare") end if held < dur * 0.9 then - error(("bug2087: the 4X battle cut the fanfare short (%.3fs of %.3fs)") + error(("bug2087: the battle cut the fanfare short (%.3fs of %.3fs)") :format(held, dur)) end if held > dur + 1 then error(("bug1952: the fanfare dragged past its length (%.3fs of %.3fs)") :format(held, dur)) end - U.log("PASS the fanfare kept natural pitch and played to completion at 4X") - love.event.quit() + for _ = 1, 2400 do + if not inBattle() then break end + assertLocked("battle end") + U.tap(game, "a") + U.wait(1) + end + if inBattle() then error("speed lock: the battle never ended") end + U.wait(2) + if game:logicSpeed() ~= 4 then + error(("speed lock: after the battle logic speed is %s, want 4") + :format(tostring(game:logicSpeed()))) + end + U.log("PASS the fanfare kept natural pitch at 1X in a 4X-override battle, 4X back after") + love.event.quit(0) end diff --git a/tests/drivers/blues_house_bug11_test.lua b/tests/drivers/blues_house_bug11_test.lua index b28341dc..cc43b859 100644 --- a/tests/drivers/blues_house_bug11_test.lua +++ b/tests/drivers/blues_house_bug11_test.lua @@ -69,13 +69,27 @@ return function(game) check(game.save.inventory["0"] == 1, "seeded unknown item '0' in bag") -- open the clerk's BUY/SELL/QUIT menu exactly as the mart interaction does + local Menu = require("src.ui.Menu") + local ListMenu = require("src.ui.ListMenu") + local TextBox = require("src.render.TextBox") + local function waitTop(pred) + for _ = 1, 600 do + local top = game.stack:top() + if top and pred(top) then return top end + U.wait(1) + end + end Screens.push(game, "ShopMenu", {}) - U.wait(4) + local shop = waitTop(function(t) + return getmetatable(t) == Menu and not t.hidden + end) + check(shop ~= nil, "clerk menu up after the greeting") + U.wait(2) U.tap(game, "down") -- BUY -> SELL U.wait(4) U.tap(game, "a") -- open the SELL list - U.wait(6) - local sellList = game.stack:top() + local sellList = waitTop(function(t) return getmetatable(t) == ListMenu end) + U.wait(2) check(sellList and sellList.items ~= nil, "SELL list opened") U.shot(game, DIR .. "/bh_2_sell_list.png") @@ -88,7 +102,6 @@ return function(game) check(item ~= nil, "bogus '0' row present in sell list") if item then sellList.index = idx - local footerBefore = sellList.footer -- Invoke the real onChoose the ListMenu would call on A. Before the -- fix this throws at math.floor(def.price/2) with def=nil; we catch -- it so the run reports the crash instead of hanging on love's error @@ -101,9 +114,15 @@ return function(game) check(true, "selling '0' did not crash") -- nothing may be sold: the guard returns before any QuantityBox check(game.save.inventory["0"] == 1, "unknown item still in bag (not sold)") - check(sellList.footer ~= footerBefore - and tostring(sellList.footer):find("price") ~= nil, - "sell footer shows the unsellable message") + local box = game.stack:top() + local said = {} + if getmetatable(box) == TextBox then + for _, page in ipairs(box.pages) do + for _, line in ipairs(page) do said[#said + 1] = line end + end + end + check(table.concat(said, " "):find("price") ~= nil, + "clerk says the unsellable line") end U.wait(4) U.shot(game, DIR .. "/bh_3_sell_choose.png") @@ -112,4 +131,5 @@ return function(game) U.log("RESULT", ("pass=%d fail=%d"):format(pass, fail)) if fail == 0 then U.log("RESULT", "ALL PASS") else U.log("RESULT", "HAS FAILURES") end + love.event.quit(fail == 0 and 0 or 1) end diff --git a/tests/drivers/g3link_union_plaza.lua b/tests/drivers/g3link_union_plaza.lua index 278bf073..499aeaff 100644 --- a/tests/drivers/g3link_union_plaza.lua +++ b/tests/drivers/g3link_union_plaza.lua @@ -2,7 +2,9 @@ local U = require("tests.drivers.util") local DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/g3link" local CENTER_2F = "FR_VIRIDIAN_CITY_POKEMON_CENTER_2F" -local UNION_ROOM = "FR_UNION_ROOM" +local Plaza = require("src.core.game3.link.union_plaza_map") +local UNION_ROOM = Plaza.MAP_ID +local OTHERS = Plaza.CAP - 1 -- pokefirered/include/constants/flags.h:1375 local FLAG_SYS_POKEDEX_GET = 0x829 -- pokefirered/include/constants/metatile_behaviors.h:104 @@ -135,13 +137,82 @@ return function(game) me.presence.where = "union" local blue = peer("b0000002", "BLUE", 0x2222, 0, "leafgreen") - local leaf = peer("c0000003", "LEAF", 0x3131, 1, "firered") - relay:handle(blue, { type = "plaza_join", kind = "union", profile = live, avatar = blue.avatar }) - relay:handle(leaf, { type = "plaza_join", kind = "union", profile = live, avatar = leaf.avatar }) - result(waitFor(function() return Union.playerCount() == 2 end, 10, Union.REFRESH_FRAMES + 30), - "both plaza members appear as union room avatars") + relay:handle(blue, { type = "plaza_join", kind = "union", cap = 40, profile = live, avatar = blue.avatar }) + local crowd = { blue } + for i = 2, OTHERS do + local s = peer(string.format("c%07x", i), "T" .. i, 0x100 + i * 13, i % 2, i % 3 == 0 and "leafgreen" or "firered") + relay:handle(s, { type = "plaza_join", kind = "union", cap = 40, profile = live, avatar = s.avatar }) + crowd[#crowd + 1] = s + end + result(waitFor(function() return Union.playerCount() == OTHERS end, 10, 120), + "all 39 other plaza members appear as avatars") + result(waitFor(function() + local VirtualObjects = require("src.core.game3.virtual_objects") + return VirtualObjects.count() == OTHERS + end, 5, 60), "39 virtual objects, no cart map objects") + local mySlot = Client.plaza() and Client.plaza().you + result(mySlot ~= nil and Union.players[mySlot] == nil, "our own slot is not drawn") + local cellsOk = true + for slot = 1, Plaza.CAP do + if slot ~= mySlot then + local v = Union.vobj(slot) + local x, y = Plaza.cellFor(slot) + if not (v and v.visible and v.x == x and v.y == y) then cellsOk = false end + end + end + result(cellsOk, "every avatar stands on its slot's cell") wait(60) - U.shot(game, DIR .. "/g3link_union_plaza_avatars.png") + U.still(game, DIR .. "/g3link_union_plaza_entrance.png") + place(12, 12, "down") + wait(20) + U.still(game, DIR .. "/g3link_union_plaza_full_room.png") + place(22, 3, "left") + wait(20) + U.still(game, DIR .. "/g3link_union_plaza_far_corner.png") + + local far = crowd[#crowd] + local farSlot = Union.slotForId(far.id) + if farSlot ~= Plaza.CAP then + for _, s in ipairs(crowd) do + if Union.slotForId(s.id) == Plaza.CAP then far, farSlot = s, Plaza.CAP end + end + end + local fx, fy = Plaza.cellFor(Plaza.CAP) + result(farSlot == Plaza.CAP, "someone holds slot 40 at " .. tostring(fx) .. "," .. tostring(fy)) + place(12, 22, "up") + wait(10) + local function walk(dir, axis, target) + for _ = 1, 600 do + local v = axis == "x" and Player.cellX or Player.cellY + if v == target then + waitFor(function() return not Player.moving end, 2, 60) + return (axis == "x" and Player.cellX or Player.cellY) == target + end + if Player.moving then wait(1) else U.hold(game, dir, 1) end + end + return false + end + local walked = walk("up", "y", 18) and walk("left", "x", fx + 1) and walk("up", "y", fy) + if walked and Player.facing ~= "left" then + U.tap(game, "left") + wait(6) + end + print("[driver] walked to " .. tostring(Player.cellX) .. "," .. tostring(Player.cellY) .. " " .. tostring(Player.facing)) + result(walked and Player.cellX == fx + 1 and Player.cellY == fy, "walked through the crowd to slot 40's cell") + U.tap(game, "a") + local prompt = waitFor(function() + if Message.isOpen() and Message.isWaiting() and (Message._page or 1) < #(Message._pages or {}) then + U.tap(game, "a") + end + local Screen = require("src.ui.game3.union_room") + return Screen.isOpen() and Screen.mode == "activity" + end, 8, 600) + result(prompt, "talking to slot 40 opens the do-something prompt") + result(Union.vobj(Plaza.CAP).dir == Union.DIR.EAST, "slot 40 turned to face the player") + wait(10) + U.still(game, DIR .. "/g3link_union_plaza_slot40_prompt.png") + U.tap(game, "b") + drive(function() return Union.state == "main" and not Message.isOpen() end, 8) relay:handle(blue, { type = "invite", to = "a0000001", activity = "chat", detail = {}, profile = live }) result(waitFor(function() return Union.state == "player_contacted_you" or Union.state == "handle_activity_request" end, @@ -170,8 +241,16 @@ return function(game) "leaving the chat ends it") result(Client.room() == nil, "and leaves the private room") - Map.load(nil, game, CENTER_2F, { x = 7, y = 5, facing = "down" }) - wait(90) + 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 pad warps back to the PC 2F") + wait(60) + U.still(game, DIR .. "/g3link_union_plaza_exit_2f.png") result(Union.state == "off", "leaving the Union Room stops it") result(Client.plaza() == nil, "and leaves the plaza") @@ -194,9 +273,9 @@ return function(game) local rows = LinkMenu.rows print("[driver] rows trade=" .. tostring(rows[1] and rows[1].count) .. " battle=" .. tostring(rows[2] and rows[2].count) .. " union=" .. tostring(rows[3] and rows[3].count) .. " total=" .. tostring(rows[4] and rows[4].count)) - result(rows[Status.GROUPTYPE.UNION] and rows[Status.GROUPTYPE.UNION].count == 2, - "UNION ROOM counts the two plaza members") - result(rows[Status.GROUPTYPE.TOTAL] and rows[Status.GROUPTYPE.TOTAL].count == 2, "TOTAL = trade + battle + union") + result(rows[Status.GROUPTYPE.UNION] and rows[Status.GROUPTYPE.UNION].count == OTHERS, + "UNION ROOM counts the 39 plaza members") + result(rows[Status.GROUPTYPE.TOTAL] and rows[Status.GROUPTYPE.TOTAL].count == OTHERS, "TOTAL = trade + battle + union") result(relay.wireless["a0000001"] == true, "the monitor subscribed to wireless counts") U.shot(game, DIR .. "/g3link_wireless_monitor_counts.png") U.tap(game, "a") diff --git a/tests/drivers/g3link_union_plaza_live.lua b/tests/drivers/g3link_union_plaza_live.lua new file mode 100644 index 00000000..2ae400a1 --- /dev/null +++ b/tests/drivers/g3link_union_plaza_live.lua @@ -0,0 +1,228 @@ +local U = require("tests.drivers.util") +local DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/g3link" + +local CENTER_2F = "FR_VIRIDIAN_CITY_POKEMON_CENTER_2F" +local Plaza = require("src.core.game3.link.union_plaza_map") +-- pokefirered/include/constants/flags.h:1375 +local FLAG_SYS_POKEDEX_GET = 0x829 +local ROLE = os.getenv("B4_ROLE") or "A" +local NAMES = { A = "ALICE", B = "BOB", C = "CAROL" } +local NAME = NAMES[ROLE] +-- pokefirered/include/constants/species.h:10 +local CHARIZARD, CATERPIE, BULBASAUR = 6, 10, 1 + +local failures = 0 +local function result(ok, label) + print((ok and "PASS " or "FAIL ") .. "[" .. ROLE .. "] " .. 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 + print((failures == 0 and "PASS" or "FAIL") .. " g3link_union_plaza_live role=" .. ROLE .. " failures=" .. failures) + love.event.quit(failures == 0 and 0 or 1) + coroutine.yield() +end + +local function now() return love.timer.getTime() end + +return function(game) + print("PASS driver_started") + for _ = 1, 900 do + if game.phase == "boot" and game.boot then break end + U.wait(1) + end + game:_handleBootAction({ action = "new_game", name = NAME }) + 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 Party = require("src.core.game3.party") + local Link = require("src.core.game3.link") + local Union = require("src.core.game3.link.union_room") + local LB = require("src.core.game3.link.battle") + local Chat = require("src.core.game3.link.chat") + local Screen = require("src.ui.game3.union_room") + local Client = require("src.online.Client") + local Natives = require("src.core.game3.scripting.natives") + local MapCatalog = require("src.import.gba.map_catalog") + + local session = Runtime.getSession() + session.name = NAME + if ROLE == "A" then Party.giveMon(session, CHARIZARD, 70) + elseif ROLE == "B" then Party.giveMon(session, CATERPIE, 3) + else Party.giveMon(session, BULBASAUR, 5) 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 waitFor(cond, seconds, onFrame) + local t0 = now() + while not cond() do + if onFrame then onFrame() end + U.wait(1) + if now() - t0 > (seconds or 5) then return false end + end + return true + end + local function pageThrough() + if Message.isOpen() and Message.isWaiting() and (Message._page or 1) < #(Message._pages or {}) then + U.tap(game, "a") + end + end + local function findByName(name) + for slot = 1, Plaza.CAP do + local p = Union.players[slot] + if p and not p.gone and p.name == name then return slot, p end + end + return nil + end + local function talkTo(slot) + local x, y = Plaza.cellFor(slot) + place(x, y + 1, "up") + U.wait(4) + U.tap(game, "a") + end + local function mashBattle(seconds) + local sawBattle = waitFor(function() return LB.state == "battle" end, 30, pageThrough) + if not sawBattle then return false, false end + local t0, k = now(), 0 + while LB.state == "battle" or LB.state == "setup" do + k = k + 1 + if k % 8 == 0 then U.tap(game, "a") else U.wait(1) end + if now() - t0 > seconds then return true, false end + end + return true, waitFor(function() return Union.state == "main" end, 30, function() + if Message.isOpen() then U.tap(game, "a") end + end) + end + + local live = Link.liveProfile() + if not result(live ~= nil, "live g3 profile (vanilla)") then return finish() end + Link.connect() + if not result(waitFor(function() return Client.state() == "online" end, 20), "online on the local relay") then + print("[driver] client state=" .. tostring(Client.state()) .. " err=" .. tostring(Client.error())) + return finish() + end + + Map.load(nil, game, CENTER_2F, { x = 5, y = 1, facing = "up" }) + U.wait(30) + Flags.setFlag(Space.store, ctx(), FLAG_SYS_POKEDEX_GET, true) + place(5, 1, "up") + U.wait(5) + -- pokefirered/data/specials.inc:12 + Natives.special(ctx(), 0x01, Space.vm.adapters) + local slotKey = MapCatalog.slotKeyFor("FR_UNION_ROOM") or "" + local group, num = slotKey:match("(%d+)%D+(%d+)") + -- pokefirered/data/scripts/cable_club.inc:797 + Space.vm.adapters.warp(tonumber(group), tonumber(num), 0xFF, 7, 11, function() end, "warpspinenter") + if not result(waitFor(function() return Space.mapId == Plaza.MAP_ID and Union.state == "main" end, 30), + "entered the plaza over the real relay") then + return finish() + end + result(waitFor(function() return Client.plaza() ~= nil end, 10), "plaza_state from the relay") + local mySlot = Client.plaza() and Client.plaza().you + print("[driver] " .. ROLE .. " slot=" .. tostring(mySlot) .. " instance=" .. tostring(Client.plaza() and Client.plaza().instance) + .. " cap=" .. tostring(Client.plaza() and Client.plaza().cap) .. " rev=" .. tostring(Client.plaza() and Client.plaza().rev)) + result(Client.plaza() and Client.plaza().cap == 40, "the relay reports a 40-cap plaza") + + if ROLE == "A" then + local slotB + result(waitFor(function() slotB = findByName("BOB") return slotB ~= nil end, 60), "BOB appears in the plaza") + if not slotB then return finish() end + U.wait(60) + local bx, by = Plaza.cellFor(slotB) + print("[driver] A sees BOB on slot " .. slotB .. " cell " .. bx .. "," .. by) + U.still(game, DIR .. "/live_a_sees_bob.png") + talkTo(slotB) + result(waitFor(function() return Screen.isOpen() and Screen.mode == "activity" end, 10, pageThrough), + "talking to BOB opens the do-something prompt") + Screen.cursor = 2 + U.tap(game, "a") + local started, back = mashBattle(180) + result(started, "the battle with BOB started") + result(back, "the battle ended and ALICE is back in the Union Room") + result(waitFor(function() return Space.mapId == Plaza.MAP_ID end, 10), "still on the plaza map") + U.wait(90) + local slotB2 = findByName("BOB") + result(Client.plaza() and Client.plaza().you == mySlot, "ALICE kept slot " .. tostring(mySlot)) + result(slotB2 == slotB, "BOB is back on slot " .. tostring(slotB)) + local v = Union.vobj(slotB) + result(v and v.visible and v.x == bx and v.y == by, "BOB's avatar stands on the same cell") + U.still(game, DIR .. "/live_a_after_battle.png") + U.wait(120) + talkTo(slotB) + result(waitFor(function() return Screen.isOpen() and Screen.mode == "activity" end, 10, pageThrough), + "BOB's menu again") + Screen.cursor = 3 + U.tap(game, "a") + result(waitFor(function() return Chat.isActive() end, 40, pageThrough), "ALICE and BOB are chatting") + local three = waitFor(function() + local room = Client.room() + return room and room.players and #room.players >= 3 + end, 120) + result(three, "CAROL joined the chat") + U.wait(120) + U.still(game, DIR .. "/live_a_chat_three.png") + Chat.stop("left") + waitFor(function() return Union.state == "main" end, 10) + elseif ROLE == "B" then + result(waitFor(function() return Choice.active end, 120, pageThrough), "ALICE's battle request rings BOB") + U.wait(10) + U.tap(game, "a") + local started, back = mashBattle(180) + result(started, "the battle with ALICE started") + result(back, "BOB is back in the Union Room") + U.wait(90) + result(Client.plaza() and Client.plaza().you == mySlot, "BOB kept slot " .. tostring(mySlot)) + local slotA = findByName("ALICE") + local ax, ay = Plaza.cellFor(slotA or 0) + local v = slotA and Union.vobj(slotA) + result(v and v.visible and v.x == ax and v.y == ay, "ALICE's avatar stands on her cell") + U.still(game, DIR .. "/live_b_after_battle.png") + result(waitFor(function() return Choice.active end, 120, pageThrough), "ALICE's chat request rings BOB") + U.wait(10) + U.tap(game, "a") + result(waitFor(function() return Chat.isActive() end, 40, pageThrough), "BOB is chatting") + waitFor(function() + local room = Client.room() + return room and room.players and #room.players >= 3 + end, 120) + waitFor(function() return not Chat.isActive() end, 60) + Chat.stop("left") + waitFor(function() return Union.state == "main" end, 10) + else + local slotB + result(waitFor(function() + slotB = findByName("BOB") + local p = slotB and Union.players[slotB] + return p and (p.activity % Union.IN_UNION_ROOM) == Union.ACTIVITY.CHAT + end, 300), "CAROL sees BOB chatting") + if not slotB then return finish() end + U.wait(30) + talkTo(slotB) + result(waitFor(function() return Choice.active end, 15, pageThrough), "the join-chat prompt") + U.still(game, DIR .. "/live_c_join_prompt.png") + U.tap(game, "a") + result(waitFor(function() return Chat.isActive() end, 40, pageThrough), "CAROL joined the chat") + U.wait(120) + U.still(game, DIR .. "/live_c_chat.png") + Chat.stop("left") + waitFor(function() return Union.state == "main" end, 10) + end + finish() +end diff --git a/tests/drivers/g3link_union_plaza_perf.lua b/tests/drivers/g3link_union_plaza_perf.lua new file mode 100644 index 00000000..c4ea43e9 --- /dev/null +++ b/tests/drivers/g3link_union_plaza_perf.lua @@ -0,0 +1,196 @@ +local U = require("tests.drivers.util") + +local CENTER_2F = "FR_VIRIDIAN_CITY_POKEMON_CENTER_2F" +local Plaza = require("src.core.game3.link.union_plaza_map") +-- pokefirered/include/constants/flags.h:1375 +local FLAG_SYS_POKEDEX_GET = 0x829 +local SECONDS = tonumber(os.getenv("PERF_SECONDS")) or 20 +local LABEL = os.getenv("POKEPORT_TOUCH") == "1" and "touch" or "desktop" + +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_union_plaza_perf") + love.event.quit(0) + else + print("FAIL g3link_union_plaza_perf failures=" .. failures) + love.event.quit(1) + end +end + +local function now() return love.timer.getTime() end + +return function(game) + print("PASS driver_started") + 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 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 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 Natives = require("src.core.game3.scripting.natives") + local MapCatalog = require("src.import.gba.map_catalog") + + 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 wait(n) + for _ = 1, n do + relay:pump() + U.wait(1) + end + end + local function waitFor(cond, seconds) + local t0 = now() + while not cond() do + relay:pump() + U.wait(1) + if now() - t0 > (seconds or 5) 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 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 (vanilla game)") then return finish() end + Link.connect() + result(waitFor(function() return Client.state() == "online" end, 5), "online on the relay") + Map.load(nil, game, CENTER_2F, { x = 5, y = 1, facing = "up" }) + wait(30) + Flags.setFlag(Space.store, ctx(), FLAG_SYS_POKEDEX_GET, true) + Player.cellX, Player.cellY, Player.targetX, Player.targetY = 5, 1, 5, 1 + Player.px, Player.py, Player.facing = 5 * 16, 1 * 16, "up" + wait(5) + -- pokefirered/data/specials.inc:12 + Natives.special(ctx(), 0x01, Space.vm.adapters) + local slot = MapCatalog.slotKeyFor("FR_UNION_ROOM") or "" + local group, num = slot:match("(%d+)%D+(%d+)") + -- pokefirered/data/scripts/cable_club.inc:797 + Space.vm.adapters.warp(tonumber(group), tonumber(num), 0xFF, 7, 11, function() end, "warpspinenter") + if not result(drive(function() return Space.mapId == Plaza.MAP_ID and Union.state == "main" end, 60), + "entered the plaza") then + return finish() + end + + local peers = {} + local function addPeers(n) + while #peers < n do + local i = #peers + 1 + local id = string.format("d%07x", i) + local s = relay:seat(id, "P" .. i) + relay:handle(s, { type = "lobby_hello", protocol = 3, name = "P" .. i, profiles = { live }, + presence = { where = "launcher", status = "idle", version = "firered" } }) + s.avatar = { name = "P" .. i, trainerId = i * 37, gender = i % 2, version = "firered" } + relay:handle(s, { type = "plaza_join", kind = "union", cap = 40, profile = live, avatar = s.avatar }) + peers[i] = s + end + end + + local ownUpdate, ownDraw = rawget(game, "update"), rawget(game, "draw") + local rawUpdate, rawDraw = game.update, game.draw + local upd, drw = { sum = 0, max = 0, n = 0 }, { sum = 0, max = 0, n = 0 } + local function timed(fn, acc) + return function(self, ...) + local t = love.timer.getTime() + local a, b = fn(self, ...) + local d = (love.timer.getTime() - t) * 1000 + acc.sum, acc.n = acc.sum + d, acc.n + 1 + if d > acc.max then acc.max = d end + return a, b + end + end + game.update = timed(rawUpdate, upd) + game.draw = timed(rawDraw, drw) + + local STATUSES = { "chatting", "battling", "idle" } + local function measure(n) + addPeers(n) + result(waitFor(function() return Union.playerCount() == n end, 10), n .. " avatars in the room") + Player.cellX, Player.cellY, Player.targetX, Player.targetY = 12, 12, 12, 12 + Player.px, Player.py = 12 * 16, 12 * 16 + wait(90) + upd.sum, upd.max, upd.n = 0, 0, 0 + drw.sum, drw.max, drw.n = 0, 0, 0 + collectgarbage("collect") + local kb0 = collectgarbage("count") + local last = kb0 + local allocated = 0 + local frame = { sum = 0, max = 0, n = 0 } + local builds0 = Union.plazaBuilds or 0 + local t0, tPrev, nextChange, k = now(), now(), now() + 1, 0 + while now() - t0 < SECONDS do + relay:pump() + for i = 1, #peers do + local box = peers[i].transport.inbox + for j = #box, 1, -1 do box[j] = nil end + end + if now() >= nextChange then + k = k + 1 + local s = peers[(k - 1) % n + 1] + s.status = STATUSES[(k - 1) % #STATUSES + 1] + relay:plazaChanged(s) + nextChange = nextChange + 1 + end + U.wait(1) + local t = now() + local d = (t - tPrev) * 1000 + tPrev = t + frame.sum, frame.n = frame.sum + d, frame.n + 1 + if d > frame.max then frame.max = d end + local kb = collectgarbage("count") + if kb > last then allocated = allocated + (kb - last) end + last = kb + end + local elapsed = now() - t0 + collectgarbage("collect") + local growth = collectgarbage("count") - kb0 + local line = string.format( + "PERF %s members=%d seconds=%.1f frames=%d frame_ms mean=%.2f max=%.2f update_ms mean=%.3f max=%.3f" + .. " draw_ms mean=%.3f max=%.3f alloc_kb_per_s=%.1f gc_growth_kb=%.1f status_changes=%d rebuilds=%d", + LABEL, n, elapsed, frame.n, frame.sum / math.max(1, frame.n), frame.max, + upd.sum / math.max(1, upd.n), upd.max, drw.sum / math.max(1, drw.n), drw.max, + allocated / elapsed, growth, k, (Union.plazaBuilds or 0) - builds0) + print(line) + local f = io.open(os.getenv("PERF_OUT") or "/tmp/g3link_union_plaza_perf.log", "a") + if f then + f:write(line, "\n") + f:close() + end + result((Union.plazaBuilds or 0) - builds0 <= k + 1, "one rebuild per status change at " .. n) + end + + measure(8) + measure(Plaza.CAP - 1) + game.update, game.draw = ownUpdate, ownDraw + finish() +end diff --git a/tests/drivers/g3link_union_room.lua b/tests/drivers/g3link_union_room.lua index 08aabfb0..86901a31 100644 --- a/tests/drivers/g3link_union_room.lua +++ b/tests/drivers/g3link_union_room.lua @@ -2,7 +2,8 @@ local U = require("tests.drivers.util") local DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/g3link" local CENTER_2F = "FR_VIRIDIAN_CITY_POKEMON_CENTER_2F" -local UNION_ROOM = "FR_UNION_ROOM" +local Plaza = require("src.core.game3.link.union_plaza_map") +local UNION_ROOM = Plaza.MAP_ID -- pokefirered/include/constants/flags.h:1375 local FLAG_SYS_POKEDEX_GET = 0x829 -- pokefirered/include/constants/species.h:5 @@ -69,16 +70,6 @@ return function(game) Party.giveMon(session, PIDGEY, 9) local relay = Relay.new({ clock = function() return now() end }) - local plazaState = relay.plazaStateMsg - relay.plazaStateMsg = function(self, s) - local msg = plazaState(self, s) - for _, m in ipairs(msg.members or {}) do - local ss = self.sessions[m.id] - if ss and ss.group then m.group = ss.group end - if ss and ss.status then m.status = ss.status end - end - return msg - end local me = relay:seat("a0000001", "RED") Client.configure({ relayAddress = "fake:1", connect = function() return me.transport end }) @@ -125,9 +116,6 @@ return function(game) return cond() end end - local function pushPlaza() - relay:to(me, relay:plazaStateMsg(me)) - end local live = Link.liveProfile() if not result(live ~= nil, "the live g3 profile computes (vanilla game)") then return finish() end @@ -163,32 +151,34 @@ return function(game) local yellow = peer("d0000004", "YELLOW", 6, 0, "firered") for _, s in ipairs({ green, pink, yellow }) do s.presence.where = "union" - relay:handle(s, { type = "plaza_join", kind = "union", profile = live, avatar = s.avatar }) + relay:handle(s, { type = "plaza_join", kind = "union", cap = 40, profile = live, avatar = s.avatar }) end local group = { leader = pink.id, members = { pink.id, yellow.id }, activity = "chat" } pink.group, pink.status = group, "chatting" yellow.group, yellow.status = group, "chatting" - pushPlaza() - result(waitFor(function() return Union.playerCount() == 2 end, 10, 60), - "GREEN takes a leader slot and PINK's chat group another") + relay:plazaChanged(pink) + relay:plazaChanged(yellow) + result(waitFor(function() return Union.playerCount() == 3 end, 10, 60), + "GREEN, PINK and YELLOW each take their own plaza slot") local slotG = Union.slotForId(green.id) local slotP = Union.slotForId(pink.id) - result(slotG ~= nil and slotP ~= nil, "both have slots") - result(waitFor(function() return slotG and Union.leaderObj(slotG).state == 1 end, 5, 60), - "GREEN flew down onto her spot") - result(slotP and Union.vobjVisible(slotP, 0) and Union.vobjVisible(slotP, 1), - "PINK and YELLOW stand at the group offsets") - result(slotP and Union.vobj(slotP, 0).dir == Union.DIR.SOUTH, "the chat leader faces south") - place(7, 9, "up") + local slotY = Union.slotForId(yellow.id) + result(slotG ~= nil and slotP ~= nil and slotY ~= nil, "all three have slots") + result(waitFor(function() return slotG and Union.vobjVisible(slotG) and Union.vobj(slotG).anim == nil end, 5, 60), + "GREEN flew down onto her cell") + local gx, gy = Plaza.cellFor(slotG) + result(Union.vobj(slotG).x == gx and Union.vobj(slotG).y == gy, "on her slot's cell") + result(slotP and Union.vobjVisible(slotP) and Union.vobjVisible(slotY), "PINK and YELLOW stand on their own cells") + result(slotP and Union.vobj(slotP).dir == Union.DIR.SOUTH, "a chatter faces south") + place(12, 22, "up") wait(40) U.still(game, DIR .. "/g3link_union_room_avatars.png") print("[driver] 2. talking to a trainer opens the invite menu") - local Objects = require("src.core.game3.objects") local opened = false for _ = 1, 20 do - local eo = Objects.find(Union.LOCAL_IDS[slotG]) - if eo and not eo.moving then + local eo = { cellX = gx, cellY = gy } + do place(eo.cellX, eo.cellY + 1, "up") wait(2) U.tap(game, "a") @@ -217,7 +207,7 @@ return function(game) drive(function() return Union.state == "main" and not Message.isOpen() end, 8) print("[driver] 3. an incoming invite rings") - place(7, 9, "up") + place(12, 22, "up") wait(6) relay:handle(green, { type = "invite", to = me.id, activity = "chat", detail = {}, profile = live }) result(waitFor(function() return Union.state == "player_contacted_you" or Union.state == "handle_activity_request" end, @@ -319,8 +309,8 @@ return function(game) end local asked = false for _ = 1, 20 do - local eo = Objects.find(Union.LOCAL_IDS[slotG]) - if eo and not eo.moving then + local eo = { cellX = gx, cellY = gy } + do place(eo.cellX, eo.cellY + 1, "up") wait(2) U.tap(game, "a") @@ -351,7 +341,7 @@ return function(game) U.still(game, DIR .. "/g3link_union_request_declined.png") drive(function() return Union.state == "main" and not Message.isOpen() end, 8) - place(7, 9, "up") + place(12, 22, "up") wait(6) relay:handle(green, { type = "invite", to = me.id, activity = "chat", detail = {}, profile = live }) result(waitFor(pageThrough(function() return Choice.active end), 8, 600), "GREEN asks to chat again") @@ -368,7 +358,6 @@ return function(game) print("[driver] 5. the trading board") relay:handle(green, { type = "presence", board = { species = BULBASAUR, level = 14, wantType = 10 } }) relay:handle(yellow, { type = "presence", board = { species = PIDGEY, level = 5, wantType = 12 } }) - pushPlaza() result(waitFor(function() return #Union.boardOffers() >= 1 end, 5, 120), "GREEN's offer is on the board") Union.trade().playerSpecies, Union.trade().playerLevel, Union.trade().type = CHARMANDER, 10, 11 place(2, 2, "up") @@ -395,7 +384,7 @@ return function(game) drive(function() return Union.state == "main" and not Message.isOpen() end, 8) print("[driver] 6. a minigame group recruiting") - place(7, 9, "up") + place(12, 22, "up") wait(6) Flags.setVar(Space.store, ctx(), Link.VAR_0x8004, Union.LINK_GROUP.BERRY_CRUSH) local c = ctx() diff --git a/tests/drivers/game3_cut_grass_2470.lua b/tests/drivers/game3_cut_grass_2470.lua new file mode 100644 index 00000000..fc3e7f60 --- /dev/null +++ b/tests/drivers/game3_cut_grass_2470.lua @@ -0,0 +1,127 @@ +local U = require("tests.drivers.util") +local DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/game3_cut_grass_2470" +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() + result(failures == 0, "game3_cut_grass_2470") + love.event.quit(failures == 0 and 0 or 1) +end + +return function(game) + for _ = 1, 600 do + if game.phase == "boot" and game.boot then break end + U.wait(1) + end + if not result(game.phase == "boot" and game.boot ~= nil, "boot_ready") then return finish() end + game:_handleBootAction({ action = "new_game", name = "RED" }) + U.wait(180) + local Runtime = require("src.core.game3.runtime") + local Map = require("src.core.game3.map") + local Player = require("src.core.game3.player") + local Collision = require("src.core.game3.collision") + local Field = require("src.core.game3.field") + local FieldMoves = require("src.core.game3.field_moves") + local Party = require("src.core.game3.party") + local Objects = require("src.core.game3.objects") + require("src.core.game3.encounters").onStep = function() return nil end + require("src.core.game3.trainer_sight").check = function() return false end + local session = Runtime.getSession() + if not result(session ~= nil, "new_game_reached_field") then return finish() end + session.party = {} + Party.giveMon(session, 29, 20) + local mon = session.party[1] + + local function place(map, x, y) + Map.load(Runtime._mod, game, map, { x = x, y = y, facing = "down" }) + Player.surfing, Player.biking = false, false + U.wait(60) + return Map.current == map and Player.cellX == x and Player.cellY == y + end + local function layout() return Map.currentDef().midLayout end + local function findPatch(map) + if Map.current ~= map then place(map, 0, 0) end + local L = game.data.maps[map].midLayout + for y = 1, L.height - 2 do + for x = 1, L.width - 2 do + local ok = true + for dy = -1, 1 do + for dx = -1, 1 do + local cx, cy = x + dx, y + dy + if L:midAt(cx, cy) ~= 0x00D or L:elevAt(cx, cy) ~= 3 or Objects.at(cx, cy) then ok = false end + end + end + if ok then return x, y end + end + end + end + local function cut() + local store = { flags = {}, vars = {} } + require("src.core.game3.scripting.flags").setFlag(store, nil, FieldMoves.BADGE_FLAGS.CUT, true) + local res = FieldMoves.cutFromMenu({ mon = mon, party = session.party, store = store, + hasCuttableGrass = Collision.isGrass(Player.cellX, Player.cellY) }) + if not result(res and res.ok and res.action == "cut_grass", "party_menu_picks_cut_grass") then return false end + Field.executeFieldMove(res) + for _ = 1, 600 do + if not Field.locked then break end + U.wait(1) + end + U.wait(20) + return not Field.locked + end + local function all3x3(cx, cy, fn) + for dy = -1, 1 do + for dx = -1, 1 do + if not fn(cx + dx, cy + dy) then return false end + end + end + return true + end + + local MAP = "FR_ROUTE_1" + local x, y = findPatch(MAP) + if not result(x ~= nil, "route1_grass_patch_found") then return finish() end + print(("[driver] %s grass patch centre (%d,%d)"):format(MAP, x, y)) + if not result(place(MAP, x, y), "placed_in_grass") then return finish() end + result(all3x3(x, y, function(cx, cy) return Collision.isGrass(cx, cy) end), "3x3_is_grass_before") + result(U.still(game, DIR .. "/2470_route1_grass_before_cut.png"), "shot_before") + result(cut(), "cut_finished") + local L = layout() + result(all3x3(x, y, function(cx, cy) return L:midAt(cx, cy) == 0x001 end), "3x3_mowed_to_plain_mowed") + result(all3x3(x, y, function(cx, cy) return not Collision.isGrass(cx, cy) end), "3x3_no_longer_grass") + result(require("src.core.game3.field_effects")._fx == nil, "tall_grass_overlay_gone_from_player") + result(U.still(game, DIR .. "/2470_route1_grass_mowed.png"), "shot_mowed") + + place(MAP, x, y) + L = layout() + result(all3x3(x, y, function(cx, cy) return L:midAt(cx, cy) == 0x00D and Collision.isGrass(cx, cy) end), + "grass_grows_back_on_reload") + + local VF = "FR_VIRIDIAN_FOREST" + local vf = game.data.maps[VF] and game.data.maps[VF].midLayout + local tx, ty + if vf then + for yy = 1, vf.height - 2 do + for xx = 1, vf.width - 2 do + if not tx and vf:midAt(xx, yy) == 0x284 and vf:elevAt(xx, yy) == 3 + and vf:midAt(xx, yy - 1) == 0x00D and vf:elevAt(xx, yy - 1) == 3 then + tx, ty = xx, yy + end + end + end + end + if tx then + print(("[driver] %s 0x284 at (%d,%d)"):format(VF, tx, ty)) + if result(place(VF, tx, ty - 1), "placed_above_huge_tree_grass") then + result(cut(), "forest_cut_finished") + result(layout():midAt(tx, ty) == 0x281, "viridian_forest_0x284_mowed_to_0x281") + result(U.still(game, DIR .. "/2470_viridian_forest_huge_tree_mowed.png"), "shot_forest") + end + else + result(false, "viridian_forest_0x284_cell_found") + end + finish() +end diff --git a/tests/drivers/game3_ghost_trainer_sight_2460.lua b/tests/drivers/game3_ghost_trainer_sight_2460.lua new file mode 100644 index 00000000..0a02bf90 --- /dev/null +++ b/tests/drivers/game3_ghost_trainer_sight_2460.lua @@ -0,0 +1,129 @@ +local U = require("tests.drivers.util") +local DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/bug2460" + +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() + if failures == 0 then + print("PASS game3_ghost_trainer_sight_2460") + love.event.quit(0) + else + print("FAIL game3_ghost_trainer_sight_2460 failures=" .. failures) + love.event.quit(1) + end +end + +return function(game) + for _ = 1, 600 do + if game.phase == "boot" and game.boot then break end + U.wait(1) + end + game:_handleBootAction({ action = "new_game", name = "RED" }) + U.wait(180) + + local Runtime = require("src.core.game3.runtime") + local Map = require("src.core.game3.map") + local Party = require("src.core.game3.party") + local Objects = require("src.core.game3.objects") + local Player = require("src.core.game3.player") + local Field = require("src.core.game3.field") + local Ghosts = require("src.core.game3.ghosts") + local TrainerSight = require("src.core.game3.trainer_sight") + local Battle = package.loaded["src.core.game3.battle"] or require("src.core.game3.battle") + + local session = Runtime.getSession() + if not result(session ~= nil, "new game reached the field") then return finish() end + session.party = {} + Party.giveMon(session, 7, 40) + + local engaged = {} + local allowReal = false + local realEngage = TrainerSight.engage + TrainerSight.engage = function(g, eo, dist) + engaged[#engaged + 1] = { eo = eo, dist = dist, live = Objects.find(eo and eo.localId) == eo, + script = eo and eo.scriptKey } + print(string.format("[2460] engage lid=%s dist=%s live=%s script=%s", tostring(eo and eo.localId), + tostring(dist), tostring(engaged[#engaged].live), tostring(eo and eo.scriptKey))) + if allowReal then return realEngage(g, eo, dist) end + end + + local function place(mapId, x, y, facing) + Map.load(nil, game, mapId, { x = x, y = y, facing = facing }) + game.session.x, game.session.y, game.session.facing = x, y, facing + U.wait(20) + end + + local function ghostTrainers() + local list = {} + for mapId, pool in pairs(Ghosts._pools or {}) do + for _, lid in ipairs(pool.order or {}) do + local eo = pool.byId[lid] + if eo and (tonumber(eo.sight) or 0) > 0 and TrainerSight.isTrainerType(eo) then + list[#list + 1] = { mapId = mapId, eo = eo } + end + end + end + return list + end + + local function dwell(label, mapId, spots, frames, needCover) + engaged = {} + local cover, pools = 0, 0 + for _, s in ipairs(spots) do + place(mapId, s[1], s[2], s[3]) + local list = ghostTrainers() + pools = math.max(pools, #list) + for _ = 1, frames do + U.wait(1) + if Player.cellX ~= s[1] or Player.cellY ~= s[2] then Player.reset(s[1], s[2], s[3]) end + for _, t in ipairs(ghostTrainers()) do + if not t.eo.hidden and TrainerSight.checkLineOfSight(t.eo, Player, game) then + cover = cover + 1 + end + end + end + end + print(string.format("[2460] %s: %d neighbor-map trainers ticking, %d frames a ghost cone covered the player", + label, pools, cover)) + result(pools > 0, label .. ": neighbor-map trainers are loaded and ticking") + if needCover then + result(cover > 0, label .. ": a neighbor-map trainer's local cone lined up with the player") + end + result(#engaged == 0, label .. ": no neighbor-map trainer engaged (engages=" .. #engaged .. ")") + result(not Field.locked and not (Battle.isActive and Battle.isActive()), label .. ": field unlocked, no battle") + end + + place("FR_VERMILION_CITY", 28, 12, "up") + U.still(game, DIR .. "/2460_01_vermilion_under_dave_ghost.png") + dwell("Vermilion vs Route 11 Dave", "FR_VERMILION_CITY", + { { 28, 12, "up" }, { 29, 14, "up" }, { 28, 14, "up" } }, 400, true) + U.still(game, DIR .. "/2460_02_vermilion_mart_no_engage.png") + + dwell("Fuchsia vs Route 17/18 bikers", "FR_FUCHSIA_CITY", + { { 11, 17, "up" }, { 11, 19, "up" }, { 4, 20, "up" } }, 400, true) + U.still(game, DIR .. "/2460_03_fuchsia_no_engage.png") + + engaged = {} + allowReal = true + place("FR_ROUTE_11", 28, 12, "up") + local dave = Objects.find(4) + result(dave ~= nil and dave.sight == 1 and TrainerSight.isTrainerType(dave), + "control: Route 11 Dave (localId 4) loaded as a current-map trainer") + local spotted = false + for _ = 1, 1200 do + if #engaged > 0 then spotted = true break end + U.wait(1) + end + result(spotted and engaged[1].live and engaged[1].eo == dave and engaged[1].dist == 1, + "control: Dave on Route 11 still spots the player after a pacing step") + if spotted then + U.wait(10) + U.still(game, DIR .. "/2460_04_route11_dave_spots_player.png") + end + return finish() +end diff --git a/tests/drivers/game3_import_dex_count_2461.lua b/tests/drivers/game3_import_dex_count_2461.lua new file mode 100644 index 00000000..e57169cc --- /dev/null +++ b/tests/drivers/game3_import_dex_count_2461.lua @@ -0,0 +1,102 @@ +local U = require("tests.drivers.util") +local DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/game3_import_dex_count_2461" + +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() + print((failures == 0 and "PASS" or "FAIL") .. " import_dex_count_2461 failures=" .. failures) + love.event.quit(failures == 0 and 0 or 1) +end + +local function unrle(s) + local out = {} + for tok in s:gmatch("%S+") do + local k, n = tok:match("^([ZF])(%x+)$") + if k then + out[#out + 1] = string.rep(k == "Z" and "\0" or "\255", tonumber(n, 16)) + else + out[#out + 1] = (tok:gsub("%x%x", function(h) return string.char(tonumber(h, 16)) end)) + end + end + return table.concat(out) +end + +return function(game) + for _ = 1, 900 do + if game.phase == "boot" and game.boot then break end + U.wait(1) + end + + local GameVersion = require("src.core.GameVersion") + local SaveData = require("src.core.SaveData") + local SaveSerializer = require("src.core.SaveSerializer") + local SaveFileIO = require("src.import.SaveFileIO") + local Boot = require("src.ui.game3.boot") + local Dex = require("src.core.game3.dex") + local version = GameVersion.get() + local cart = unrle(require("tests.fixture_data.gen3_saves").images.fr_rich_game) + love.filesystem.write("picked_cart_2461.sav", cart) + local ok, slotId = SaveFileIO.importToSlot("picked_cart_2461.sav", version) + love.filesystem.remove("picked_cart_2461.sav") + if not result(ok == true, "the FireRed cart imports into " .. version .. " (" .. tostring(slotId) .. ")") then + return finish() + end + + local meta + for _, row in ipairs(SaveData.listSlots(version)) do + if row.id == slotId then meta = row.meta end + end + print("launcher meta dexCount=" .. tostring(meta and meta.dexCount) .. " badges=" .. tostring(meta and meta.badges) + .. " time=" .. tostring(meta and meta.timeText)) + result(meta and meta.dexCount == 5, "launcher card shows 5 caught for the fresh import") + result(meta and meta.badges == 2, "launcher card shows 2 badges") + + local raw = SaveSerializer.decode(SaveData.readSlotSource(version, slotId) or "") + local info = raw and Boot.continueInfoFromSave(raw) + result(info and info.dexCount == 5, "CONTINUE window counts 5 caught before the first load") + if info then + Boot.setHasContinue(game.boot, true) + Boot.setContinueInfo(game.boot, info) + end + + local function phase() return game.boot and game.boot.phase end + local n = 0 + while phase() ~= "title" and n < 1200 do + U.tap(game, "start") + U.wait(30) + n = n + 30 + end + for _ = 1, 20 do + if phase() ~= "title" then break end + U.wait(60) + U.tap(game, "start") + U.wait(2) + end + for _ = 1, 600 do + if phase() == "menu" and (game.boot.fadeT or 0) == 0 then break end + U.wait(1) + end + U.wait(4) + print("boot phase=" .. tostring(phase()) .. " game.phase=" .. tostring(game.phase)) + if result(phase() == "menu", "main menu with CONTINUE is up") then + U.still(game, DIR .. "/2461_continue_dex_5.png") + end + + game:_handleBootAction({ action = "continue" }) + local Runtime = require("src.core.game3.runtime") + local s + for _ = 1, 900 do + s = Runtime.getSession() + if s and game.phase ~= "quest_log" and game.phase ~= "boot" then break end + if game.phase == "quest_log" then U.tap(game, "b") end + U.wait(4) + end + s = Runtime.getSession() + if not result(s ~= nil, "CONTINUE reached the field") then return finish() end + result(Dex.countCaught(s.dex, "kanto") == 5, "in-game Kanto dex count is 5 (matches the launcher)") + finish() +end diff --git a/tests/drivers/game3_importer_round2.lua b/tests/drivers/game3_importer_round2.lua index 7d52e92c..b5a82612 100644 --- a/tests/drivers/game3_importer_round2.lua +++ b/tests/drivers/game3_importer_round2.lua @@ -31,14 +31,15 @@ local function find_edge_item(game) table.sort(names) for _, id in ipairs(names) do local def = maps[id] - for dir, conn in pairs(def.connections or {}) do - local nid = type(conn) == "table" and conn.map or conn + for _, conn in ipairs(require("src.core.game3.connections").each(def)) do + local dir = conn.dir + local nid = conn.map local ndef = maps[nid] if ndef and (dir == "north" or dir == "up") then for _, ev in ipairs(ndef.bgEvents or {}) do if is_hidden(ev) and not ev.underfoot then local L = Map.ensureMidLayout(game, nid, ndef) - local off = tonumber(type(conn) == "table" and conn.offset) or 0 + local off = tonumber(conn.offset) or 0 local fromBottom = L and (L.height - 1 - ev.y) if fromBottom and fromBottom <= 2 then return id, nid, ev, ev.x + off, fromBottom diff --git a/tests/drivers/game3_link_union_room.lua b/tests/drivers/game3_link_union_room.lua index df6ef713..ca524a2d 100644 --- a/tests/drivers/game3_link_union_room.lua +++ b/tests/drivers/game3_link_union_room.lua @@ -1,7 +1,8 @@ local U = require("tests.drivers.util") local DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/game3_link_union_room" -local UNION_ROOM = "FR_UNION_ROOM" +local Plaza = require("src.core.game3.link.union_plaza_map") +local UNION_ROOM = Plaza.MAP_ID local CENTER_2F = "FR_VIRIDIAN_CITY_POKEMON_CENTER_2F" -- pokefirered/data/specials.inc:5 SetCableClubWarp local SET_CABLE_CLUB_WARP = 0x01 @@ -81,8 +82,10 @@ return function(game) result(session.dynamicWarp ~= nil and session.dynamicWarp.map == CENTER_2F, "SetCableClubWarp recorded the way back to the cable club counter") setVar(VAR_CABLE_CLUB_STATE, 6) - Map.load(nil, game, UNION_ROOM, { x = 7, y = 11, facing = "up" }) - place(7, 11, "up") + local slotKey = require("src.import.gba.map_catalog").slotKeyFor("FR_UNION_ROOM") or "" + local group, num = slotKey:match("(%d+)%D+(%d+)") + -- pokefirered/data/scripts/cable_club.inc:797 + Space.vm.adapters.warp(tonumber(group), tonumber(num), 0xFF, 7, 11, function() end, "warpspinenter") U.wait(120) result(Space.mapId == UNION_ROOM, "the player is in the Union Room") @@ -90,92 +93,9 @@ return function(game) print("[driver] union state after entry: " .. tostring(Union.state)) U.shot(game, DIR .. "/union_room_01_empty.png") - local host, peer = Game3Link.loopback({ game = game }) - host:update(0) - peer:update(0) - Link.attach(host) - result(host:isReady(), "the loopback session handshook") - - peer:send({ - type = Union.MSG.HELLO, - name = "BLUE", - trainerId = 0x2222, - gender = 0, - activity = Union.ACTIVITY.SEARCH + Union.IN_UNION_ROOM, - }) + result(Union.relay and Union.capacity() == Plaza.CAP, "offline, the Union Room is still the 40-player plaza") + result(Union.playerCount() == 0, "with nobody in it") local waited = 0 - while waited < 300 and Union.playerCount() == 0 do - U.wait(10) - waited = waited + 10 - end - if not result(Union.playerCount() == 1, "the peer appeared in the Union Room player list") then - U.shot(game, DIR .. "/union_room_99_no_peer.png") - return finish() - end - local avatar = Objects.find(Union.LOCAL_IDS[1]) - result(avatar ~= nil and avatar.visible == true, - "the slot 1 union room avatar is on the map") - U.wait(30) - U.shot(game, DIR .. "/union_room_02_player_spawned.png") - - -- pokefirered/src/union_room.c:3264 UR_STATE_INTERACT_WITH_ATTENDANT - place(3, 3, "up") - U.wait(15) - waited = 0 - while waited < 200 and not Screen.isOpen() do - U.tap(game, "a") - U.wait(20) - waited = waited + 20 - end - if result(Screen.isOpen(), "the Union Room attendant opened the player list") then - result(Screen.mode == "board", "the list is the trading board view") - result(#Screen.players == 1, "it lists the one other player in the room") - U.wait(20) - U.shot(game, DIR .. "/union_room_03_player_list.png") - U.tap(game, "b") - U.wait(30) - end - - -- pokefirered/data/maps/UnionRoom/scripts.inc:28 UnionRoom_EventScript_Player1 - waited = 0 - while waited < 240 and not Screen.isOpen() do - local eo = Objects.find(Union.LOCAL_IDS[1]) - if eo and not eo.moving then - place(tonumber(eo.cellX), tonumber(eo.cellY) + 1, "up") - U.wait(10) - U.tap(game, "a") - end - U.wait(20) - waited = waited + 30 - end - if not result(Screen.isOpen(), "talking to the player opened the activity chooser") then - print("[driver] VAR_RESULT=" .. tostring(getVar(VAR_RESULT)) .. - " state=" .. tostring(Union.state)) - U.shot(game, DIR .. "/union_room_98_no_chooser.png") - return finish() - end - result(Screen.mode == "activity", "the chooser is the invite-to-activity list") - result(#Screen.items == 4, "GREETINGS / BATTLE / CHAT / EXIT") - U.wait(20) - U.shot(game, DIR .. "/union_room_04_activity_chooser.png") - - U.tap(game, "a") - U.wait(30) - print("[driver] after greeting: activity=" .. tostring(Union.activity) - .. " state=" .. tostring(Union.state)) - result(not Screen.isOpen(), "GREETINGS closed the chooser") - result(Union.activity == Union.ACTIVITY.CARD, "and sent the trainer card activity") - U.shot(game, DIR .. "/union_room_05_after_greeting.png") - - -- pokefirered/src/union_room.c:2937 UR_STATE_TRAINER_APPEARS_BUSY - waited = 0 - while waited < 600 and Union.state ~= "main" do - U.tap(game, "a") - U.wait(20) - waited = waited + 20 - end - result(Union.state == "main", "an unanswered request frees the room again") - -- pokefirered/src/field_fadetransition.c:578 DoUnionRoomWarp waited = 0 while waited < 200 and Screen.isOpen() do @@ -184,7 +104,7 @@ return function(game) waited = waited + 20 end U.wait(30) - place(7, 10, "down") + place(12, 22, "down") U.wait(30) local Message = require("src.ui.game3.message") waited = 0 @@ -194,7 +114,12 @@ return function(game) waited = waited + 20 end U.wait(20) - U.hold(game, "down", 30) + for _ = 1, 20 do + if Space.mapId ~= UNION_ROOM then break end + U.hold(game, "down", 8) + end + print("[driver] exit walk: p=" .. tostring(Player.cellX) .. "," .. tostring(Player.cellY) + .. " msg=" .. tostring(Message.isOpen()) .. " union=" .. tostring(Union.state)) waited = 0 while waited < 600 and Space.mapId == UNION_ROOM do U.wait(15) diff --git a/tests/drivers/game3_pad_battle_text_bug2452.lua b/tests/drivers/game3_pad_battle_text_bug2452.lua new file mode 100644 index 00000000..7c0cca04 --- /dev/null +++ b/tests/drivers/game3_pad_battle_text_bug2452.lua @@ -0,0 +1,109 @@ +local U = require("tests.drivers.util") +local DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/game3_pad_battle_text_bug2452" + +return function(game) + local fails = 0 + local function result(ok, label) + print((ok and "PASS " or "FAIL ") .. label) + if not ok then fails = fails + 1 end + end + + for _ = 1, 600 do + if game.phase == "boot" and game.boot then break end + U.wait(1) + end + game:_handleBootAction({ action = "new_game", name = "RED" }) + U.wait(180) + + local Runtime = require("src.core.game3.runtime") + local Party = require("src.core.game3.party") + local BattleBridge = require("src.core.game3.battle_bridge") + local Battle = require("src.core.game3.battle") + local Ui = require("src.core.game3.battle.ui") + local session = Runtime.getSession() + session.party = {} + Party.giveMon(session, 6, 50) + + local ok, err = BattleBridge.startWild(Runtime._mod, game, { species = 16, level = 45 }, { fade = false }) + result(ok == true, "2452_wild_battle_started " .. tostring(err or "")) + if not ok then love.event.quit(1) return end + + local physical = {} + local pad = { + isGamepad = function() return true end, + isGamepadDown = function(_, button) return physical[button] == true end, + getGamepadAxis = function() return 0 end, + getName = function() return "Xbox Controller" end, + getGUID = function() return "030000005e040000" end, + getID = function() return 1 end, + isConnected = function() return true end, + } + + local input = game.input + local edges = 0 + local origStep = input.step + input.step = function(self, ...) + local r = origStep(self, ...) + if self.pressed and self.pressed.a then edges = edges + 1 end + return r + end + + local win = love.window + local origMin, origFocus = win.isMinimized, win.hasFocus + local minimized, focused = false, true + win.isMinimized = function() return minimized end + win.hasFocus = function() return focused end + + local function padA() + local before = edges + physical.a = true + love.gamepadpressed(pad, "a") + U.wait(2) + physical.a = false + love.gamepadreleased(pad, "a") + U.wait(12) + return edges - before + end + + local function atCommand() + return Battle.isActive() and Battle._phase == "command" and Ui._mode == "menu" + end + + local presses, doubles = 0, 0 + for _ = 1, 300 do + if atCommand() then break end + if Ui.dialogPending and Ui.dialogPending() then + focused = (presses % 2 == 1) + local n = padA() + presses = presses + 1 + if n ~= 1 then doubles = doubles + 1 end + else + U.wait(1) + end + end + focused = true + result(atCommand(), "2452_battle_text_advanced_on_pad_a") + result(presses > 0, "2452_pad_presses_used " .. presses) + result(doubles == 0, "2452_one_edge_per_pad_press") + U.still(game, DIR .. "/2452_command_menu_after_pad_a.png") + + local sawText = false + for i = 1, 600 do + if Ui.dialogPending and Ui.dialogPending() then sawText = true break end + if i % 20 == 1 then U.tap(game, "a") else U.wait(1) end + end + if sawText then + minimized = true + local dropped = padA() + result(dropped == 0, "2452_minimized_pad_press_dropped") + minimized = false + local landed = padA() + result(landed == 1, "2452_restored_pad_press_lands") + else + result(false, "2452_no_text_after_fight") + end + + input.step = origStep + win.isMinimized, win.hasFocus = origMin, origFocus + love.event.quit(fails == 0 and 0 or 1) +end diff --git a/tests/drivers/game3_pewter_guide_bug2472.lua b/tests/drivers/game3_pewter_guide_bug2472.lua new file mode 100644 index 00000000..20696f6f --- /dev/null +++ b/tests/drivers/game3_pewter_guide_bug2472.lua @@ -0,0 +1,164 @@ +local U = require("tests.drivers.util") +local DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/bug2472" + +local PEWTER = "FR_PEWTER_CITY" +local GYM_GUIDE, GYM_X, GYM_Y = 5, 42, 20 +local MUSEUM_GUIDE, MUSEUM_X, MUSEUM_Y = 2, 33, 17 + +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() + if failures == 0 then + print("PASS game3_pewter_guide_bug2472") + love.event.quit(0) + else + print("FAIL game3_pewter_guide_bug2472 failures=" .. failures) + love.event.quit(1) + end +end + +return function(game) + for _ = 1, 600 do + if game.phase == "boot" and game.boot then break end + U.wait(1) + end + game:_handleBootAction({ action = "new_game", name = "RED" }) + U.wait(180) + + local Runtime = require("src.core.game3.runtime") + local Map = require("src.core.game3.map") + local Space = require("src.core.game3.scripting.space") + local Party = require("src.core.game3.party") + local Objects = require("src.core.game3.objects") + local Player = require("src.core.game3.player") + local Field = require("src.core.game3.field") + local Message = package.loaded["src.ui.game3.message"] or require("src.ui.game3.message") + local Choice = require("src.ui.game3.choice") + + local session = Runtime.getSession() + if not result(session ~= nil, "new game reached the field") then return finish() end + session.party = {} + Party.giveMon(session, 4, 12) + + local function vmBusy() + return Space.vm and Space.vm.isRunning and Space.vm:isRunning() + end + local function msgOpen() + return Message.isOpen and Message.isOpen() + end + + Map.load(nil, game, PEWTER, { x = GYM_X - 1, y = GYM_Y, facing = "right" }) + game.session.x, game.session.y, game.session.facing = GYM_X - 1, GYM_Y, "right" + U.wait(30) + + local bounds = Objects._bounds + if not result(bounds and bounds.w > 0 and bounds.h > 0, "Pewter layout bounds known") then return finish() end + local function inBounds(x, y) + return x >= 0 and y >= 0 and x < bounds.w and y < bounds.h + end + + local function escort(lid, label, answerNo) + local worst, lastPage + local started = false + for _ = 1, 3000 do + local eo = Objects.find(lid) + if eo and not eo.hidden and not inBounds(eo.cellX, eo.cellY) then + worst = worst or string.format("guide (%d,%d)", eo.cellX, eo.cellY) + end + if not inBounds(Player.cellX, Player.cellY) then + worst = worst or string.format("player (%d,%d)", Player.cellX, Player.cellY) + end + if vmBusy() or Field.locked then started = true end + if started and not vmBusy() and not msgOpen() and not Field.locked then break end + if Choice.isOpen and Choice.isOpen() then + print("[2472] yes/no open, answering " .. (answerNo and "NO" or "YES")) + if answerNo then Choice.cursor = 2 end + U.tap(game, "a") + elseif msgOpen() then + local page = Message.currentPage and Message.currentPage() + if page and page ~= lastPage then + print("[2472] " .. label .. " text: " .. tostring(page):gsub("\n", " ")) + lastPage = page + end + if answerNo and tostring(page):find("MUSEUM?", 1, true) then + for _ = 1, 120 do + if Choice.isOpen() then break end + U.wait(1) + end + end + if not Choice.isOpen() then U.tap(game, "a") end + end + U.wait(2) + end + result(started, label .. ": script ran") + result(not vmBusy() and not Field.locked, label .. ": script finished and field unlocked") + result(worst == nil, label .. ": guide and player stayed inside the map" .. (worst and (" (left at " .. worst .. ")") or "")) + return Player.cellX, Player.cellY + end + + local function atTemplate(lid, x, y, label, before, face) + local eo = Objects.find(lid) + result(eo ~= nil and eo.visible and not eo.hidden, label .. ": guide visible again") + result(eo ~= nil and eo.cellX == x and eo.cellY == y, + string.format("%s: guide back on template (%d,%d), got (%s,%s)", label, x, y, + tostring(eo and eo.cellX), tostring(eo and eo.cellY))) + if face then + result(eo ~= nil and eo.facing == face, label .. ": template facing " .. face .. ", got " .. tostring(eo and eo.facing)) + end + result(eo ~= before, label .. ": guide is a respawned object") + return eo + end + + local guide = Objects.find(GYM_GUIDE) + if not result(guide ~= nil and guide.cellX == GYM_X and guide.cellY == GYM_Y and not guide.hidden, + "gym guide spawned at (42,20)") then return finish() end + U.still(game, DIR .. "/2472_01_gym_guide_at_spawn.png") + + U.tap(game, "a") + local px1, py1 = escort(GYM_GUIDE, "gym escort 1", false) + print(string.format("[2472] escort 1 left the player at (%d,%d)", px1, py1)) + result(px1 < 20, "gym escort 1 brought the player to the gym side of town") + local after = Objects.find(GYM_GUIDE) + result(not (after and not after.hidden and after.cellX == 7 and after.cellY == 19), + "no stale gym guide left standing at the exit cell (7,19)") + U.still(game, DIR .. "/2472_02_gym_escort_done_guide_gone.png") + guide = atTemplate(GYM_GUIDE, GYM_X, GYM_Y, "gym guide", guide, "down") + + Player.reset(GYM_X - 1, GYM_Y, "right") + U.wait(20) + U.still(game, DIR .. "/2472_03_gym_guide_back_at_spawn.png") + U.tap(game, "a") + local px2, py2 = escort(GYM_GUIDE, "gym escort 2", false) + result(px2 == px1 and py2 == py1, + string.format("gym escort 2 ends where escort 1 did (%d,%d vs %d,%d)", px2, py2, px1, py1)) + atTemplate(GYM_GUIDE, GYM_X, GYM_Y, "gym guide after replay", guide, "down") + + Player.reset(MUSEUM_X, MUSEUM_Y + 1, "up") + U.wait(20) + local museum = Objects.find(MUSEUM_GUIDE) + if not result(museum ~= nil and museum.cellX == MUSEUM_X and museum.cellY == MUSEUM_Y and not museum.hidden, + "museum guide spawned at (33,17)") then return finish() end + U.tap(game, "a") + local mx1, my1 = escort(MUSEUM_GUIDE, "museum escort 1", true) + print(string.format("[2472] museum escort 1 left the player at (%d,%d)", mx1, my1)) + result(mx1 ~= MUSEUM_X or my1 ~= MUSEUM_Y + 1, "museum escort 1 moved the player") + museum = atTemplate(MUSEUM_GUIDE, MUSEUM_X, MUSEUM_Y, "museum guide", museum, nil) + U.still(game, DIR .. "/2472_04_museum_escort_done.png") + + Player.reset(MUSEUM_X, MUSEUM_Y + 1, "up") + U.wait(20) + local m = Objects.find(MUSEUM_GUIDE) + if m then m.facing = "down" end + U.tap(game, "a") + local mx2, my2 = escort(MUSEUM_GUIDE, "museum escort 2", true) + result(mx2 == mx1 and my2 == my1, + string.format("museum escort 2 ends where escort 1 did (%d,%d vs %d,%d)", mx2, my2, mx1, my1)) + atTemplate(MUSEUM_GUIDE, MUSEUM_X, MUSEUM_Y, "museum guide after replay", museum, nil) + + return finish() +end diff --git a/tests/drivers/game3_shift_exp_2473.lua b/tests/drivers/game3_shift_exp_2473.lua new file mode 100644 index 00000000..7aa5f0f0 --- /dev/null +++ b/tests/drivers/game3_shift_exp_2473.lua @@ -0,0 +1,154 @@ +local U = require("tests.drivers.util") +local DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/game3_shift_exp_2473" + +return function(game) + local fails = 0 + local function result(ok, label) + print((ok and "PASS " or "FAIL ") .. label) + if not ok then fails = fails + 1 end + end + + for _ = 1, 600 do + if game.phase == "boot" and game.boot then break end + U.wait(1) + end + game:_handleBootAction({ action = "new_game", name = "RED" }) + U.wait(180) + + local Runtime = require("src.core.game3.runtime") + local Party = require("src.core.game3.party") + local Options = require("src.core.game3.options") + local BattleBridge = require("src.core.game3.battle_bridge") + local Battle = require("src.core.game3.battle") + local Anim = require("src.core.game3.battle.anim") + local SwitchSeq = require("src.core.game3.battle.switch_seq") + local Ui = require("src.core.game3.battle.ui") + local session = Runtime.getSession() + session.party = {} + Party.giveMon(session, 4, 30) + Party.giveMon(session, 7, 20) + Options.ensure(session).battleStyle = 0 + result(Options.battleStyle(session) == "shift", "battle style is shift") + + local ok, err = BattleBridge.start(Runtime._mod, game, + { trainerId = 326, party = { { species = 16, level = 5 }, { species = 19, level = 5 } } }, + { wild = false, trainerId = 326, fade = false }) + result(ok == true, "trainer battle started " .. tostring(err or "")) + if not ok then love.event.quit(1) return end + + local f, lastTap = 0, 0 + local function at_command() + return Battle.isActive() and Battle._phase == "command" and Ui._mode == "menu" + end + + local shiftOrder, goShot, participantsAfter = nil, false, nil + local function watch_shift() + if not SwitchSeq.busy() or shiftOrder then return end + local steps = SwitchSeq._steps or {} + local order = {} + for _, s in ipairs(steps) do + if s.kind == "swap_data" then order[#order + 1] = s.data and s.data.side end + end + if #order == 2 then shiftOrder = order end + end + + local function pump(stop, limit) + for _ = 1, limit or 6000 do + f = f + 1 + watch_shift() + if stop() then return true end + local cur = SwitchSeq.busy() and SwitchSeq._steps and SwitchSeq._steps[SwitchSeq._i] + local bst = Battle.getState() + if not goShot and cur and cur.kind == "sendout_player" and bst + and bst.enemy and (tonumber(bst.enemy.mon.hp) or 0) <= 0 then + U.wait(10) + U.still(game, DIR .. "/2473_mon2_out_before_foe.png") + goShot = true + end + local PM = package.loaded["src.ui.game3.party_menu"] + if PM and PM.isOpen and PM.isOpen() then + U.wait(20) + PM.cursor = 2 + U.tap(game, "a") + U.wait(12) + U.tap(game, "a") + U.wait(12) + elseif Ui.choiceActive and Ui.choiceActive() then + U.wait(8) + U.tap(game, "a") + U.wait(8) + elseif Ui.dialogPending and Ui.dialogPending() and f - lastTap >= 14 then + lastTap = f + U.tap(game, "a") + else + U.wait(1) + end + end + return stop() + end + + result(pump(at_command), "reached command menu vs foe 1") + local st = Battle.getState() + if not st then love.event.quit(1) return end + for _, m in ipairs(st.foeParty or {}) do + m.moves = { 150, 0, 0, 0 } + m.pp = { 40, 0, 0, 0 } + m.hp = 1 + end + st.enemy.mon.hp = 1 + local ep = Anim.present("enemy") + if ep then ep.displayHp = 1 end + + local function attack() + local mon = st.player.mon + mon.moves[1] = 129 + mon.pp = mon.pp or {} + mon.pp[1] = 20 + Ui._pendingCommand = { kind = "move", move = 129, slot = 1, user = "player" } + Ui._mode = "none" + end + + attack() + result(pump(function() return Battle._phase == "shift_prompt" end), "shift prompt offered after foe 1 fainted") + result(pump(function() return at_command() and st.player.partyIndex == 2 end), "shift switched to party slot 2") + result(shiftOrder ~= nil and shiftOrder[1] == "player" and shiftOrder[2] == "enemy", + "player swap runs before enemy send-out (" .. table.concat(shiftOrder or {}, ",") .. ")") + result(goShot, "shot 2473_mon2_out_before_foe.png") + participantsAfter = {} + for pi in pairs(st.enemy.participants or {}) do participantsAfter[#participantsAfter + 1] = pi end + table.sort(participantsAfter) + result(#participantsAfter == 1 and participantsAfter[1] == 2, + "foe 2 sent mons = {" .. table.concat(participantsAfter, ",") .. "}") + + local expA = tonumber(st.playerParty[1].exp) or 0 + local expB = tonumber(st.playerParty[2].exp) or 0 + st.enemy.mon.hp = 1 + ep = Anim.present("enemy") + if ep then ep.displayHp = 1 end + attack() + local expShot = false + result(pump(function() + if not expShot then + for _, t in ipairs(Ui.log()) do + if tostring(t):find("gained", 1, true) and (tonumber(st.playerParty[2].exp) or 0) > expB then + U.wait(90) + U.still(game, DIR .. "/2473_only_mon2_gains_exp.png") + expShot = true + break + end + end + end + return not Battle.isActive() + end, 9000), "battle ended after foe 2 fainted") + local party = session.party + local aNow = tonumber(party[1] and party[1].exp) or 0 + local bNow = tonumber(party[2] and party[2].exp) or 0 + print(string.format(" exp A %d -> %d, B %d -> %d", expA, aNow, expB, bNow)) + result(aNow == expA, "switched-out mon 1 gained no EXP for foe 2") + result(bNow > expB, "mon 2 gained EXP for foe 2") + result(expShot, "shot 2473_only_mon2_gains_exp.png") + for _, t in ipairs(Ui.log()) do print(" log: " .. tostring(t):gsub("\n", " ")) end + + print(string.format("shift exp 2473 driver: %d failure(s)", fails)) + love.event.quit(fails == 0 and 0 or 1) +end diff --git a/tests/drivers/game3_six_island_water_path.lua b/tests/drivers/game3_six_island_water_path.lua new file mode 100644 index 00000000..884bb054 --- /dev/null +++ b/tests/drivers/game3_six_island_water_path.lua @@ -0,0 +1,88 @@ +local U = require("tests.drivers.util") +local DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/game3_six_island_water_path" +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() + result(failures == 0, "game3_six_island_water_path") + love.event.quit(failures == 0 and 0 or 1) +end + +return function(game) + for _ = 1, 600 do + if game.phase == "boot" and game.boot then break end + U.wait(1) + end + if not result(game.phase == "boot" and game.boot ~= nil, "boot_ready") then return finish() end + game:_handleBootAction({ action = "new_game", name = "RED" }) + U.wait(180) + local Runtime = require("src.core.game3.runtime") + local Map = require("src.core.game3.map") + local Player = require("src.core.game3.player") + require("src.core.game3.encounters").onStep = function() return nil end + require("src.core.game3.trainer_sight").check = function() return false end + if not result(Runtime.getSession() ~= nil, "new_game_reached_field") then return finish() end + local WP = "FR_SIX_ISLAND_WATER_PATH" + + local function setup(map, x, y, facing) + Map.load(Runtime._mod, game, map, { x = x, y = y, facing = facing }) + Player.surfing, Player.elevation = false, 3 + U.wait(60) + return result(Map.current == map and Player.cellX == x and Player.cellY == y, "setup_" .. map .. "_" .. y) + end + local function step(dir) + local oldMap, x, y = Map.current, Player.cellX, Player.cellY + for _ = 1, 24 do + U.hold(game, dir, 1) + if Player.moving or Map.current ~= oldMap then break end + end + for _ = 1, 48 do + if not Player.moving then break end + U.wait(1) + end + return not Player.moving and (Map.current ~= oldMap or Player.cellX ~= x or Player.cellY ~= y) + end + local function at(map, x, y, label) + local session = Runtime.getSession() + return result(Map.current == map and Player.cellX == x and Player.cellY == y + and session.map == map and session.x == x and session.y == y, label) + end + local function drawsWest(row, sourceMap, sx, sy, label) + local mid, _, void = Map.worldMidAt(-1, row, Map.currentDef()) + local src = game.data.maps[sourceMap].midLayout + return result(mid ~= nil and not void and mid == src:midAt(sx, sy), label) + end + + if not setup("FR_SIX_ISLAND", 23, 12, "right") then return finish() end + if not result(step("right"), "six_island_east_step") then return finish() end + if not at(WP, 0, 52, "six_island_east_lands_on_water_path") then return finish() end + result(#Map.neighborList == 3, "water_path_has_three_west_neighbors") + drawsWest(52, "FR_SIX_ISLAND", 23, 12, "water_path_row52_draws_six_island") + U.wait(30) + result(U.still(game, DIR .. "/2456_water_path_six_island_west.png"), "shot_six_island_west") + if not result(step("left"), "water_path_west_step_row52") then return finish() end + at("FR_SIX_ISLAND", 23, 12, "water_path_row52_back_to_six_island") + + if setup(WP, 0, 10, "left") then + drawsWest(10, "FR_SIX_ISLAND_GREEN_PATH", 71, 10, "water_path_row10_draws_green_path") + U.wait(30) + result(U.still(game, DIR .. "/2456_water_path_green_path_west.png"), "shot_green_path_west") + result(step("left"), "water_path_west_step_row10") + at("FR_SIX_ISLAND_GREEN_PATH", 71, 10, "water_path_row10_to_green_path") + end + + if setup(WP, 0, 90, "left") then + drawsWest(90, "FR_SIX_ISLAND_RUIN_VALLEY", 47, 10, "water_path_row90_draws_ruin_valley") + result(step("left"), "water_path_west_step_row90") + at("FR_SIX_ISLAND_RUIN_VALLEY", 47, 10, "water_path_row90_to_ruin_valley") + end + + if setup(WP, 0, 48, "left") then + result(step("left"), "water_path_west_step_row48") + at("FR_SIX_ISLAND", 23, 8, "water_path_row48_to_six_island") + end + finish() +end diff --git a/tests/drivers/game3_union_plaza_map.lua b/tests/drivers/game3_union_plaza_map.lua new file mode 100644 index 00000000..539cf480 --- /dev/null +++ b/tests/drivers/game3_union_plaza_map.lua @@ -0,0 +1,107 @@ +local U = require("tests.drivers.util") +local DIR = os.getenv("SHOT_DIR") or os.getenv("POKEPORT_SHOT_DIR") or ".bazinga/union40/shots/map" + +local CENTER_2F = "FR_VIRIDIAN_CITY_POKEMON_CENTER_2F" +-- pokefirered/data/specials.inc:12 +local SET_CABLE_CLUB_WARP = 0x01 +-- pokefirered/include/constants/flags.h:1375 +local FLAG_SYS_POKEDEX_GET = 0x829 + +return function(game) + local function fail(msg) + print("FAIL game3_union_plaza_map: " .. msg) + love.event.quit(1) + coroutine.yield() + end + 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) + if game.phase ~= "field" then return fail("never reached the field") end + + 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 Natives = require("src.core.game3.scripting.natives") + local MapCatalog = require("src.import.gba.map_catalog") + local Plaza = require("src.core.game3.link.union_plaza_map") + + 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 + if game.session then + game.session.x, game.session.y, game.session.facing = x, y, facing + end + end + + local def = game.data and game.data.maps and game.data.maps[Plaza.MAP_ID] + if not def then return fail("hydrate did not register " .. Plaza.MAP_ID) end + + -- pokefirered/data/scripts/cable_club.inc:743 + Flags.setFlag(Space.store, ctx(), FLAG_SYS_POKEDEX_GET, true) + Map.load(nil, game, CENTER_2F, { x = 5, y = 2, facing = "up" }) + place(5, 2, "up") + U.wait(30) + place(5, 1, "up") + U.wait(10) + Natives.special(ctx(), SET_CABLE_CLUB_WARP, Space.vm and Space.vm.adapters) + local dw = game.session and game.session.dynamicWarp + if not (dw and dw.map == CENTER_2F) then return fail("SetCableClubWarp did not record the way back") end + + local slot = MapCatalog.slotKeyFor("FR_UNION_ROOM") or "" + local group, num = slot:match("(%d+)%D+(%d+)") + group, num = tonumber(group), tonumber(num) + if not (group and num) then return fail("no group/num for FR_UNION_ROOM (" .. slot .. ")") end + + local arrived = false + -- pokefirered/data/scripts/cable_club.inc:797 + Space.vm.adapters.warp(group, num, 0xFF, 7, 11, function() arrived = true end, "warpspinenter") + for _ = 1, 600 do + if arrived and Map.current == Plaza.MAP_ID then break end + U.wait(1) + end + if Map.current ~= Plaza.MAP_ID then + return fail("warp to the Union Room landed on " .. tostring(Map.current)) + end + local ex, ey = Plaza.entry() + if Player.cellX ~= ex or Player.cellY ~= ey then + return fail(("spawned at %s,%s want %d,%d"):format(tostring(Player.cellX), tostring(Player.cellY), ex, ey)) + end + U.log("entered", Map.current, Player.cellX, Player.cellY) + U.wait(120) + U.still(game, DIR .. "/plaza_entrance.png") + + place(12, 18, "up") + U.wait(30) + U.still(game, DIR .. "/plaza_center.png") + + place(22, 3, "up") + U.wait(30) + U.still(game, DIR .. "/plaza_far_corner.png") + + place(3, 3, "up") + U.wait(30) + U.still(game, DIR .. "/plaza_counter.png") + + place(12, 22, "down") + U.wait(30) + U.hold(game, "down", 40) + for _ = 1, 300 do + if Map.current == CENTER_2F then break end + U.wait(1) + end + if Map.current ~= CENTER_2F then + return fail("exit landed on " .. tostring(Map.current) .. " want " .. CENTER_2F) + end + U.wait(60) + U.still(game, DIR .. "/plaza_exit_center_2f.png") + U.log("exit landed", Map.current, Player.cellX, Player.cellY) + print("PASS game3_union_plaza_map") + love.event.quit(0) +end diff --git a/tests/drivers/gen1_mart_sell_glyph_test.lua b/tests/drivers/gen1_mart_sell_glyph_test.lua index 4a59d101..f752deaf 100644 --- a/tests/drivers/gen1_mart_sell_glyph_test.lua +++ b/tests/drivers/gen1_mart_sell_glyph_test.lua @@ -77,7 +77,7 @@ return function(game) U.tap(game, "a") U.wait(20) end - list = waitForList(60) + list = waitForList(300) if list then break end closeAll() end diff --git a/tests/drivers/gold_fanfare_speed_bug1952_test.lua b/tests/drivers/gold_fanfare_speed_bug1952_test.lua index 962ae358..610580af 100644 --- a/tests/drivers/gold_fanfare_speed_bug1952_test.lua +++ b/tests/drivers/gold_fanfare_speed_bug1952_test.lua @@ -22,6 +22,10 @@ return function(game) game.speedOverride = 4 U.wait(2) + if game:logicSpeed() ~= 4 then + error(("bug1952: the overworld did not take the 4X override (%s)") + :format(tostring(game:logicSpeed()))) + end U.log("gen2 speed override", 4, "sfx rate", Sound.rate()) if Sound.rate() ~= 1 then error(("bug1952: Game2:update pitched SFX off GAME SPEED (rate %s at 4X)") @@ -71,6 +75,12 @@ return function(game) :format(held, dur)) end + game.linkNet = { closed = false } + if game:logicSpeed() ~= 1 then + error(("speed lock: an open gen 2 link ran at %sX under a 4X override") + :format(tostring(game:logicSpeed()))) + end + game.linkNet = nil game.speedOverride = nil U.wait(2) if Sound.rate() ~= 1 then @@ -80,5 +90,5 @@ return function(game) U.shot(game, DIR .. "/bug1952_gold_after.png") U.log("PASS the gen 2 jingle kept natural pitch under GAME SPEED") - love.event.quit() + love.event.quit(0) end diff --git a/tests/drivers/mart_clerk_typing_bug2384.lua b/tests/drivers/mart_clerk_typing_bug2384.lua new file mode 100644 index 00000000..3d047e18 --- /dev/null +++ b/tests/drivers/mart_clerk_typing_bug2384.lua @@ -0,0 +1,126 @@ +-- home/text_script.asm:141-150 +-- engine/events/pokemart.asm:7-19 +-- engine/events/pokemart.asm:131-133 +-- engine/events/pokemart.asm:199-206 +-- engine/events/pokemart.asm:220-222 +-- engine/menus/text_box.asm:168-176 +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("POKEPORT_SHOT_DIR") or os.getenv("SHOT_DIR") or "/tmp/shots" + local Menu = require("src.ui.Menu") + local ListMenu = require("src.ui.ListMenu") + local TextBox = require("src.render.TextBox") + + local failures = 0 + local function check(label, ok) + if not ok then failures = failures + 1 end + U.log(ok and "PASS" or "FAIL", label) + return ok + end + local function top() return game.stack:top() end + local function states() return game.stack.states or {} end + local function waitFor(pred, frames) + for _ = 1, frames or 600 do + if pred() then return true end + U.wait(1) + end + return pred() + end + local function typing(first) + local t = top() + if getmetatable(t) ~= TextBox or not t.stay or t.done then return false end + local page = t.pages and t.pages[1] or {} + if page[1] ~= first then return false end + local line = t.shown and t.shown[1] + return line ~= nil and #line >= 3 and #line < #page[1] + end + local function onStack(cls) + for _, s in ipairs(states()) do + if getmetatable(s) == cls then return s end + end + return nil + end + local function finish() + U.log(failures == 0 and "PASS mart_clerk_typing_bug2384" + or ("FAIL mart_clerk_typing_bug2384 (" .. failures .. " failures)")) + love.event.quit(failures == 0 and 0 or 1) + U.wait(600) + end + + local ok, err = pcall(function() + game.save.money = 3000 + game.save.options = game.save.options or {} + game.save.options.textSpeed = 3 + U.teleport(game, "PEWTER_MART", 2, 5, "left") + U.wait(20) + local ow = top() + + U.tap(game, "a") + if not check("the greeting types in an empty box", + waitFor(function() return typing("Hi there!") end, 120)) then return end + local menu = onStack(Menu) + check("BUY/SELL/QUIT is on the stack but hidden", menu ~= nil and menu.hidden == true) + U.still(game, DIR .. "/2384_greeting_typing.png") + + if not check("BUY/SELL/QUIT comes up after the greeting", + waitFor(function() return top() == menu end, 300)) then return end + check("the menu is drawn now", menu.hidden == false) + check("filled cursor on BUY", menu.index == 1 and menu.hollowIndex == nil) + U.wait(4) + U.still(game, DIR .. "/2384_menu_filled_cursor.png") + + U.tap(game, "a") + if not check("Take your time. types before the list", + waitFor(function() return typing("Take your time.") end, 60)) then return end + check("BUY goes hollow", menu.hollowIndex == 1) + check("no list while the lead-in types", onStack(ListMenu) == nil) + U.still(game, DIR .. "/2384_take_your_time_typing.png") + + if not check("the buy list opens after the lead-in", + waitFor(function() return getmetatable(top()) == ListMenu end, 300)) then return end + U.wait(6) + check("BUY stays hollow while the list is up", menu.hollowIndex == 1) + U.still(game, DIR .. "/2384_buy_list_hollow_buy.png") + + U.tap(game, "b") + if not check("anything-else types after the list closes", + waitFor(function() return typing("Is there anything") end, 60)) then return end + check("BUY still hollow while it types", menu.hollowIndex == 1) + U.still(game, DIR .. "/2384_anything_else_typing.png") + + if not check("back on BUY/SELL/QUIT", + waitFor(function() return top() == menu end, 300)) then return end + check("cursor reset to BUY and filled", menu.index == 1 and menu.hollowIndex == nil) + U.wait(4) + U.still(game, DIR .. "/2384_back_on_buy.png") + + U.tap(game, "down") + U.wait(4) + U.tap(game, "down") + U.wait(4) + check("cursor on QUIT", menu.index == 3) + U.tap(game, "a") + local bye + if not check("Thank you! goes up", waitFor(function() + local t = top() + if getmetatable(t) == TextBox and not t.stay then bye = t return true end + return false + end, 30)) then return end + local s = states() + check("the mart menu stays under Thank you!", s[#s - 1] == menu) + check("QUIT is hollow under Thank you!", menu.hollowIndex == 3) + waitFor(function() return bye.done end, 300) + U.wait(4) + U.still(game, DIR .. "/2384_thank_you.png") + + U.tap(game, "a") + check("the mart closes back to the overworld", + waitFor(function() return top() == ow end, 60)) + check("no mart screen left", onStack(Menu) == nil and onStack(ListMenu) == nil) + end) + if not ok then + failures = failures + 1 + U.log("FAIL driver error:", tostring(err)) + end + finish() +end diff --git a/tests/drivers/mart_price_text_bug2291.lua b/tests/drivers/mart_price_text_bug2291.lua index bd4e299b..334930e2 100644 --- a/tests/drivers/mart_price_text_bug2291.lua +++ b/tests/drivers/mart_price_text_bug2291.lua @@ -58,7 +58,7 @@ return function(game) local menu = top() U.tap(game, "a") - if not check("BUY opens the list", waitFor(function() return topIs(ListMenu) end, 60)) then return end + if not check("BUY opens the list", waitFor(function() return topIs(ListMenu) end, 300)) then return end local list = top() local itemId = list.items[list.index].value local def = game.data.items[itemId] @@ -109,12 +109,12 @@ return function(game) check("NO buys nothing", game.save.money == 3000) U.tap(game, "b") - if not check("B returns to BUY/SELL/QUIT", waitFor(function() return top() == menu end, 30)) then return end + if not check("B returns to BUY/SELL/QUIT", waitFor(function() return top() == menu end, 300)) then return end U.wait(4) U.tap(game, "down") U.wait(4) U.tap(game, "a") - if not check("SELL opens the sell list", waitFor(function() return topIs(ListMenu) end, 60)) then return end + if not check("SELL opens the sell list", waitFor(function() return topIs(ListMenu) end, 300)) then return end local sellList = top() for _ = 1, 20 do local row = sellList.items[sellList.index] diff --git a/tests/drivers/overworld_fast_forward_bug2471.lua b/tests/drivers/overworld_fast_forward_bug2471.lua new file mode 100644 index 00000000..2b3a586e --- /dev/null +++ b/tests/drivers/overworld_fast_forward_bug2471.lua @@ -0,0 +1,111 @@ +local U = require("tests.drivers.util") +local FixedStep = require("src.core.FixedStep") + +local SHOT_DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/pokeport-shots" + +return function(game) + local failed = false + local function expect(cond, label) + print((cond and "PASS " or "FAIL ") .. label) + if not cond then failed = true end + end + + local pos + local gen = "gen" .. tostring(require("src.core.GameVersion").generation()) + + if gen == "gen1" then + U.newGame(game) + U.teleport(game, "PALLET_TOWN", 8, 6, "down") + pos = function() + local p = game.overworld and game.overworld.player + return p and p.cellX, p and p.cellY + end + elseif gen == "gen2" then + U.wait(60) + if game.world and game.world.map then + game.world:warpToMapId("NEW_BARK_TOWN", 5, 6, "down") + U.wait(120) + end + pos = function() + local p = game.world and game.world.player + return p and p.cellX, p and p.cellY + end + else + 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) + pos = function() + local s = game.session + return s and s.x, s and s.y + end + end + local x0, y0 = pos() + expect(x0 ~= nil, gen .. "_2471_overworld_reached") + if x0 == nil then love.event.quit(1) return end + + local counted = 0 + local orig = FixedStep.callback + FixedStep.callback = function(dt) + counted = counted + 1 + return orig(dt) + end + + local function stepsPerUpdate(speed, updates) + game.speedOverride = speed + counted = 0 + for _ = 1, updates do game:update(1 / 60) end + game.speedOverride = 1 + return counted / updates + end + + local function walk(speed, dir, updates) + local ax, ay = pos() + game.speedOverride = speed + for _ = 1, updates do + table.insert(game.input.pressQueue, dir) + game.input.state[dir] = true + game:update(1 / 60) + end + game.input.state[dir] = false + game.speedOverride = 1 + for _ = 1, 60 do game:update(1 / 60) end + local bx, by = pos() + return math.abs((bx or ax) - ax) + math.abs((by or ay) - ay) + end + + local s10 = stepsPerUpdate(10, 30) + local s200 = stepsPerUpdate(200, 30) + print(("[2471] %s logic steps per rendered frame: 10X=%.1f 200X=%.1f"):format(gen, s10, s200)) + expect(math.abs(s10 - 10) < 0.5, gen .. "_2471_10x_runs_10_steps_a_frame") + expect(s200 > 60, gen .. "_2471_200x_not_capped_at_15") + + local best, bestDir = -1, nil + for _, dir in ipairs({ "left", "right", "down", "up" }) do + local sx, sy = pos() + local cells10 = walk(10, dir, 1) + local back = ({ left = "right", right = "left", up = "down", down = "up" })[dir] + for _ = 1, 120 do + local cx, cy = pos() + if cx == sx and cy == sy then break end + walk(1, back, 1) + end + local cells200 = walk(200, dir, 1) + print(("[2471] %s %s: 10X moved %d cells, 200X moved %d cells in one frame") + :format(gen, dir, cells10, cells200)) + if cells200 - cells10 > best then best, bestDir = cells200 - cells10, dir end + if cells200 >= 3 and cells200 > cells10 * 2 then break end + for _ = 1, 200 do + local cx, cy = pos() + if cx == sx and cy == sy then break end + walk(4, back, 1) + end + end + expect(best >= 2, gen .. "_2471_200x_moves_more_cells_than_10x_" .. tostring(bestDir)) + FixedStep.callback = orig + + U.still(game, SHOT_DIR .. "/2471_" .. gen .. "_after_200x_frame.png") + love.event.quit(failed and 1 or 0) +end diff --git a/tests/drivers/pikachu_cadence_bug424_test.lua b/tests/drivers/pikachu_cadence_bug424_test.lua index 0cb9263e..49a9eba0 100644 --- a/tests/drivers/pikachu_cadence_bug424_test.lua +++ b/tests/drivers/pikachu_cadence_bug424_test.lua @@ -22,6 +22,9 @@ return function(game) for _, r in ipairs(results) do U.log(r.ok and "PASS" or "FAIL", r.label) end end local function idleForever() + local failed = false + for _, r in ipairs(results) do if not r.ok then failed = true end end + love.event.quit(failed and 1 or 0) while true do coroutine.yield() end end @@ -37,7 +40,10 @@ return function(game) -- follower and every count below would read as the bug. game.save.flags = game.save.flags or {} game.save.flags.EVENT_GOT_STARTER = true + game.save.flags.EVENT_BATTLED_RIVAL_IN_OAKS_LAB = true + game.save.pikachuInBall = false game.save.party = { Pokemon.new(game.data, "PIKACHU", 20) } + require("src.battle.BattleState").stampOT(game.save, game.save.party[1]) game.save.onBike = false local frames = 0 @@ -261,8 +267,12 @@ return function(game) U.tap(game, "a") U.wait(1) + for _ = 1, 600 do + if ow.emote and ow.emote.pikaPic then break end + U.wait(1) + end local emote = ow.emote - if not check("the A press raised the framed pikapic", emote ~= nil) then + if not check("the A press raised the framed pikapic", emote ~= nil and emote.pikaPic ~= nil) then report() idleForever() end @@ -275,12 +285,12 @@ return function(game) local lifted, grounded, liftShot = 0, 0, nil for _ = 1, 60 do if not ow.emote then break end - local lift = PikachuFollower.picLift(ow.emote) - if lift > 0 then + local path = PikachuFollower.picFrame(ow.emote) + if path and path:find("gfx_e6646", 1, true) then lifted = lifted + 1 if not liftShot then - liftShot = SHOT_DIR .. "/bug424_pikapic_lift.png" - if U.shot(game, liftShot) then U.log("captured", liftShot) + liftShot = SHOT_DIR .. "/bug424_pikapic_pose_e6646.png" + if U.still(game, liftShot) then U.log("captured", liftShot) else liftShot = nil end end else @@ -288,9 +298,9 @@ return function(game) end step(1) end - check("the pic sits on the box floor for part of the beat", grounded > 0) - check("and rises off it for another part", lifted > 0) - U.log("of the first 60 frames the pic was up for", lifted, "and down for", + check("the base pic shows for part of the beat", grounded > 0) + check("and PikaAnimTilemap_34's GFX_e6646 pose for another part", lifted > 0) + U.log("of the first 60 frames the pose was up for", lifted, "and the base for", grounded) local leftWhenCut = ow.emote and ow.emote.frames or 0 @@ -309,7 +319,7 @@ return function(game) U.log("Hold Down: Pikachu should walk up to the ledge lip and WAIT there,") U.log("then clear both cells in one motion on your next step, at walking") U.log("speed. Let go and count the first glance: about a second, not half.") - U.log("Face it, press A: the framed pic hops inside the box for the whole") + U.log("Face it, press A: the framed pic swaps to the hearts pose and back for the whole") U.log("beat, and A or B cuts the beat off early.") idleForever() diff --git a/tests/drivers/pikachu_thunder_flash_2347.lua b/tests/drivers/pikachu_thunder_flash_2347.lua index 8196e585..4bb7ddfb 100644 --- a/tests/drivers/pikachu_thunder_flash_2347.lua +++ b/tests/drivers/pikachu_thunder_flash_2347.lua @@ -163,15 +163,36 @@ return function(game) check("2347_talk1_pikapic_25", pic:find("pikapic_25", 1, true) ~= nil) local emote = ow.emote check("2347_talk1_bolt_spec", emote and emote.boltAt == 45) - local bgpSeq, shadeSeq, frames = {}, {}, 0 + local bgpSeq, poseSeq, doneSeq, frames = {}, {}, {}, 0 local prebolt = 0 - while ow.emote == emote and frames < 900 do + local function poseName(e) + local path, lift = PikachuFollower.picFrame(e) + if (lift or 0) ~= 0 then return "lifted" end + return path and path:match("([^/]+)%.png$") or false + end + local PROBE = (os.getenv("TMPDIR") or "/tmp/") .. "2347_probe.png" + local function drawnShade() + U.still(game, PROBE) + os.remove(PROBE) + return PaletteFX.shadeMap() + end + local darkDuringPre, whiteDrawn, litTail + while ow.emote == emote and frames < 1500 do U.wait(1) frames = frames + 1 if ow.emote ~= emote then break end bgpSeq[#bgpSeq + 1] = emote.bgp or false - shadeSeq[#bgpSeq] = PaletteFX.shadeMap() or false + poseSeq[#bgpSeq] = poseName(emote) + doneSeq[#bgpSeq] = emote.boltDone and true or false if not emote.bgp then prebolt = prebolt + 1 end + local k = (emote.boltT or 0) - emote.boltAt - 2 + if darkDuringPre == nil and not emote.bgp and frames > 10 then + darkDuringPre = isDarkMap(drawnShade()) + elseif whiteDrawn == nil and emote.bgp == 0xC0 then + whiteDrawn = isBoltMap(drawnShade()) + elseif litTail == nil and k >= 81 and not emote.boltDone then + litTail = drawnShade() == nil + end end local strobeOk, seen = true, 0 local first @@ -190,28 +211,35 @@ return function(game) end U.log("prebolt frames", prebolt, "strobe start", tostring(first), "total", frames) check("2347_talk1_strobe_alternates_4f", first ~= nil and strobeOk and seen == 80) - local whiteDrawn, darkDuringPre = false, false - for i = 1, #bgpSeq do - if isBoltMap(shadeSeq[i]) then whiteDrawn = true end - if first and i < first and isDarkMap(shadeSeq[i]) then darkDuringPre = true end - end - check("2347_talk1_prebolt_dark", darkDuringPre) - check("2347_talk1_strobe_white_drawn", whiteDrawn) - local litTail = first and #bgpSeq > first + 82 - if litTail then - for i = first + 82, #bgpSeq do - if shadeSeq[i] then litTail = false end + check("2347_talk1_prebolt_dark", darkDuringPre == true) + check("2347_talk1_strobe_white_drawn", whiteDrawn == true) + check("2347_talk1_lit_tail_while_box_up", litTail == true) + local boltPose, sawE7863, blank = first ~= nil, false, 0 + for i = 1, #poseSeq do + if poseSeq[i] == "gfx_e7863" then sawE7863 = true end + if first and i >= first then + if poseSeq[i] == false then blank = blank + 1 + elseif blank > 0 or poseSeq[i] ~= "gfx_e79f3" then boltPose = false end end end - check("2347_talk1_lit_tail_while_box_up", litTail and true or false) + U.log("pose at strobe start", tostring(first and poseSeq[first]), "blank close frames", blank) + check("2347_talk1_e7863_before_bolt", sawE7863) + check("2347_talk1_bolt_pose", boltPose) + local closeSeq = {} + for i = 1, #poseSeq do + if doneSeq[i] then closeSeq[#closeSeq + 1] = tostring(poseSeq[i]) end + end + U.log("close frames", table.concat(closeSeq, ",")) + check("2347_talk1_empty_box_at_close", + table.concat(closeSeq, ",") == "gfx_e79f3,gfx_e79f3,gfx_e79f3,false,false,false") check("2347_talk1_thunderbolt_sound", moveSounds[1] == "Battle_2F") check("2347_talk1_music_muted", ducks == 1) U.wait(6) - check("2347_talk1_dark_after_close", isDarkMap(PaletteFX.shadeMap())) + check("2347_talk1_dark_after_close", ow.emote == nil and isDarkMap(drawnShade())) check("2347_modifier_survives_talk", game.save.pikachuEmotionModifier == 5) if not check("2347_talk2_emote", startTalk("2347_talk2")) then quit() end - U.shot(game, SHOT_DIR .. "/2347_02_bolt_bubble.png") + U.still(game, SHOT_DIR .. "/2347_02_bolt_bubble.png") for _ = 1, 200 do if ow.emote and ow.emote.pikaPic then break end U.wait(1) @@ -219,28 +247,36 @@ return function(game) emote = ow.emote check("2347_talk2_replays_bolt", emote and emote.boltAt ~= nil) U.wait(20) - U.shot(game, SHOT_DIR .. "/2347_03_pikapic_prebolt.png") + U.still(game, SHOT_DIR .. "/2347_03_pikapic_prebolt.png") for _ = 1, 200 do if emote.bgp == 0xC0 then break end U.wait(1) end - U.shot(game, SHOT_DIR .. "/2347_04_strobe_white.png") + local function drawn() + return tostring(ow.pikaPicDrawn):match("([^/]+)%.png$") + end + U.still(game, SHOT_DIR .. "/2347_04_strobe_white.png") + check("2347_talk2_c0_draws_e79f3", emote.bgp == 0xC0 and drawn() == "gfx_e79f3") for _ = 1, 200 do if emote.bgp == 0xE4 then break end U.wait(1) end - U.shot(game, SHOT_DIR .. "/2347_05_strobe_lit.png") + U.still(game, SHOT_DIR .. "/2347_05_strobe_lit.png") + check("2347_talk2_e4_draws_e79f3", emote.bgp == 0xE4 and drawn() == "gfx_e79f3") for _ = 1, 400 do - if emote.boltDone or ow.emote ~= emote then break end + if (emote.boltT or 0) - emote.boltAt > 2 + 80 or ow.emote ~= emote then break end U.wait(1) end - if ow.emote == emote then U.shot(game, SHOT_DIR .. "/2347_06_lit_tail.png") end - for _ = 1, 200 do + if ow.emote == emote and not emote.boltDone then + U.still(game, SHOT_DIR .. "/2347_06_lit_tail.png") + check("2347_talk2_tail_draws_e79f3", drawn() == "gfx_e79f3") + end + for _ = 1, 1500 do if ow.emote ~= emote then break end U.wait(1) end U.wait(10) - U.shot(game, SHOT_DIR .. "/2347_07_after_close_dark.png") + U.still(game, SHOT_DIR .. "/2347_07_after_close_dark.png") for _ = 1, 3 do stepAndBack() @@ -256,7 +292,7 @@ return function(game) U.wait(1) end U.wait(20) - U.shot(game, SHOT_DIR .. "/2347_08_after_steps_mood_talk.png") + U.still(game, SHOT_DIR .. "/2347_08_after_steps_mood_talk.png") check("2347_talk3_no_bolt", ow.emote and ow.emote.pikaPic and not ow.emote.boltAt and true or false) for _ = 1, 600 do @@ -278,22 +314,126 @@ return function(game) U.wait(1) end emote = ow.emote - local veiled = false - for _ = 1, 400 do + local tiles = ow.map.renderer + local darkAtlas = tiles.image + local function atlasFor(byte) + local imgs = tiles.bgpImages or {} + return imgs[byte] and tiles.image == imgs[byte] and tiles.curBgp == byte + and tiles.baseImage == darkAtlas + end + local shotC0, shotE4 = false, false + local litOnE4, c0Atlas, veilOnC0 = true, true, false + for _ = 1, 1500 do if ow.emote ~= emote then break end U.wait(1) - local v = game.renderer and game.renderer.screenVeil - if emote.bgp == 0xC0 and v and v[1] == 1 then - if not veiled then U.shot(game, SHOT_DIR .. "/2347_09_baked_strobe_white.png") end - veiled = true + if ow.emote ~= emote then break end + if emote.bgp == 0xC0 and not shotC0 then + shotC0 = true + U.still(game, SHOT_DIR .. "/2347_09_baked_strobe_white.png") + local v = game.renderer and game.renderer.screenVeil + if v then veilOnC0 = true end + if not atlasFor(0xC0) then c0Atlas = false end + check("2347_baked_c0_draws_e79f3", drawn() == "gfx_e79f3") + elseif emote.bgp == 0xE4 and shotC0 and not shotE4 then + shotE4 = true + U.still(game, SHOT_DIR .. "/2347_10_baked_strobe_lit.png") + if not atlasFor(0xE4) then litOnE4 = false end + check("2347_baked_e4_draws_e79f3", drawn() == "gfx_e79f3") end end - check("2347_baked_white_veil", veiled) + check("2347_baked_strobe_seen", shotC0 and shotE4) + check("2347_baked_lit_atlas", shotE4 and litOnE4) + check("2347_baked_c0_atlas", shotC0 and c0Atlas) + check("2347_baked_no_white_veil", shotC0 and not veilOnC0) + U.wait(10) + U.still(game, SHOT_DIR .. "/2347_11_baked_after_close_dark.png") + check("2347_baked_dark_after_close", ow.emote == nil and tiles.image == darkAtlas + and tiles.curBgp == nil and tiles.baseImage == nil and ow.dark == true) end else U.log("SKIP 2347_baked (no gbc atlas in this cache)") end + if PaletteFX.mode == "redpp" then + U.teleport(game, "PALLET_TOWN", 11, 11, "down") + U.wait(20) + ow = game.overworld + npc = follower() + local tiles = ow.map.renderer + local renderers = { tiles } + for _, nb in ipairs(ow.neighbors or {}) do + if nb.map.renderer and nb.map.renderer.gbcAtlas then + renderers[#renderers + 1] = nb.map.renderer + end + end + check("2347_outdoor_lit", ow.dark ~= true and ow:bakedWorldColors()) + check("2347_outdoor_neighbors_baked", #renderers >= 2) + local animated = 0 + for _, a in ipairs(tiles.anims or {}) do + if a.spec then animated = animated + 1 end + end + check("2347_outdoor_water_flower_anims", animated >= 2) + local function allOn(byte) + for _, r in ipairs(renderers) do + local img = r.bgpImages and r.bgpImages[byte] + if not (img and r.image == img and r.curBgp == byte) then return false end + for _, a in ipairs(r.anims or {}) do + if a.spec and not (a.bgpTextures and a.textures == a.bgpTextures[byte]) then + return false + end + end + end + return true + end + local function allBase() + for _, r in ipairs(renderers) do + if r.curBgp ~= nil or r.baseImage ~= nil then return false end + for _, a in ipairs(r.anims or {}) do + if a.baseTextures then return false end + for _, t in pairs(a.bgpTextures or {}) do + if a.textures == t then return false end + end + end + end + return true + end + PikachuFollower.onMoveLearned(game.save, pika, "THUNDERBOLT") + if npc then PikachuFollower.talk(game, ow, npc, function() end) end + for _ = 1, 200 do + if ow.emote and ow.emote.pikaPic then break end + U.wait(1) + end + emote = ow.emote + if check("2347_outdoor_emote", emote ~= nil) then + local onC0, onE4, sawC0, sawE4 = nil, nil, 0, 0 + local last + for _ = 1, 2000 do + if ow.emote ~= emote then break end + U.wait(1) + if ow.emote ~= emote then break end + local bgp = emote.bgp + if bgp ~= last and (bgp == 0xC0 and sawC0 < 3 or bgp == 0xE4 and sawE4 < 3) then + if bgp == 0xC0 then + sawC0 = sawC0 + 1 + U.still(game, SHOT_DIR .. "/2347_12_outdoor_strobe_white_" .. sawC0 .. ".png") + onC0 = (onC0 ~= false) and allOn(0xC0) + else + sawE4 = sawE4 + 1 + U.still(game, SHOT_DIR .. "/2347_13_outdoor_strobe_lit_" .. sawE4 .. ".png") + onE4 = (onE4 ~= false) and allBase() + end + end + last = bgp + end + check("2347_outdoor_strobe_seen", sawC0 == 3 and sawE4 == 3) + check("2347_outdoor_c0_every_renderer_and_anim", onC0 == true) + check("2347_outdoor_e4_lit_base", onE4 == true) + U.wait(6) + U.still(game, SHOT_DIR .. "/2347_14_outdoor_after_close.png") + check("2347_outdoor_base_after_close", ow.emote == nil and allBase()) + end + end + Sound.playMove, Music.duckForFanfare = realPlayMove, realDuck quit() end diff --git a/tests/drivers/route.lua b/tests/drivers/route.lua index 08dd35a1..3668acd3 100644 --- a/tests/drivers/route.lua +++ b/tests/drivers/route.lua @@ -2403,13 +2403,14 @@ local function sellItem(id) press("a") U.wait(10) end - pressUntil(isList, "a", 20) - sold = ((G.save.inventory or {})[id] or 0) == 0 + pressUntil(function() return isList() or isMenu() end, "a", 20) + sold =((G.save.inventory or {})[id] or 0) == 0 end -- back out of the sell list to the BUY/SELL menu - for _ = 1, 10 do + for _ = 1, 60 do if isMenu() then break end - press("b") + local t = top() + if not (t and t.stay) then press("b") end U.wait(6) end return sold @@ -2526,10 +2527,16 @@ end -- shopping spree that empties the wallet and then sits on the "not enough -- money" footer forever. B is the only safe key once a shop screen is up. local function closeShop() - for _ = 1, 40 do - if not (isList() or isQty() or isChoice() or isMenu()) then return true end - press("b") - U.wait(6) + for _ = 1, 80 do + local t = top() + if t and t.stay then + U.wait(6) + elseif not (isList() or isQty() or isChoice() or isMenu()) then + return true + else + press("b") + U.wait(6) + end end return false end diff --git a/tests/drivers/route_shop_buy_sell_bug2291.lua b/tests/drivers/route_shop_buy_sell_bug2291.lua index cfaab989..744bd9b9 100644 --- a/tests/drivers/route_shop_buy_sell_bug2291.lua +++ b/tests/drivers/route_shop_buy_sell_bug2291.lua @@ -78,9 +78,9 @@ return function(game) check("route buyItem paid 600", game.save.money == money0 - 600) check("route buyItem leaves the buy list on top", R.isList()) - for _ = 1, 10 do + for _ = 1, 60 do if R.isMenu() then break end - U.tap(game, "b") + if R.isList() then U.tap(game, "b") end U.wait(6) end if not check("back on the BUY/SELL menu", R.isMenu()) then return end diff --git a/tests/drivers/shop_test.lua b/tests/drivers/shop_test.lua index a5853b46..a9fd87f1 100644 --- a/tests/drivers/shop_test.lua +++ b/tests/drivers/shop_test.lua @@ -39,7 +39,11 @@ return function(game) U.wait(20) check(tag .. " menu", mash("a", function() return topIs(Menu) end)) U.tap(game, "a") -- BUY - U.wait(8) + for _ = 1, 300 do + if topIs(ListMenu) then break end + U.wait(1) + end + U.wait(4) check(tag .. " list", topIs(ListMenu)) U.shot(game, ("%s/%s_0_list.png"):format(DIR, tag)) U.tap(game, "a") -- first item diff --git a/tests/drivers/speed_lock_firered.lua b/tests/drivers/speed_lock_firered.lua new file mode 100644 index 00000000..b7f408ce --- /dev/null +++ b/tests/drivers/speed_lock_firered.lua @@ -0,0 +1,149 @@ +local U = require("tests.drivers.util") +local DIR = os.getenv("SHOT_DIR") or os.getenv("POKEPORT_SHOT_DIR") or "/tmp/shots" + +local UNION_ROOM = "FR_UNION_ROOM" +local CENTER_2F = "FR_VIRIDIAN_CITY_POKEMON_CENTER_2F" +-- pokefirered/data/specials.inc:12 +local SET_CABLE_CLUB_WARP = 0x01 +-- pokefirered/include/constants/vars.h:163 +local VAR_CABLE_CLUB_STATE = 0x406F +-- pokefirered/include/constants/flags.h:1375 +local FLAG_SYS_POKEDEX_GET = 0x829 + +local function key(k) + love.keypressed(k, k, false) + love.keyreleased(k, k) +end + +return function(game) + local function fail(msg) + error("FAIL speed lock: " .. msg, 0) + end + 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) + if game.phase ~= "field" then fail("never reached the field") end + + local Runtime = require("src.core.game3.runtime") + local Party = require("src.core.game3.party") + local BattleBridge = require("src.core.game3.battle_bridge") + local Battle = require("src.core.game3.battle") + 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 Union = require("src.core.game3.link.union_room") + local o = game.options + + game.speedOverride = nil + o.speedOverworld, o.speedBattle, o.speedMenu = 10, 200, 1 + U.wait(2) + if game:logicSpeed() ~= 10 then + fail(("field at OVERWORLD SPEED 10 reads %s"):format(tostring(game:logicSpeed()))) + end + + local session = Runtime.getSession() + session.party = {} + Party.giveMon(session, 6, 50) + local ok, err = BattleBridge.startWild(Runtime._mod, game, { species = 16, level = 2 }, { fade = false }) + if not ok then fail("wild battle did not start " .. tostring(err)) end + + local checked, shot = 0, false + local Ui = require("src.core.game3.battle.ui") + for f = 1, 20000 do + if not Battle.isActive() then break end + if game:logicSpeed() ~= 1 then + fail(("battle ran at %sX with speedBattle 200"):format(tostring(game:logicSpeed()))) + end + checked = checked + 1 + if checked == 30 then + key("1") + game:gamepadaxis(nil, "triggerright", 1) + game:gamepadaxis(nil, "triggerright", 0) + if o.speedOverworld ~= 10 or o.speedBattle ~= 200 or o.speedMenu ~= 1 then + fail(("speed presses in battle changed options (%s/%s/%s)"):format( + tostring(o.speedOverworld), tostring(o.speedBattle), tostring(o.speedMenu))) + end + game.speedOverride = 200 + if game:logicSpeed() ~= 1 then fail("speedOverride 200 defeated the battle lock") end + game.speedOverride = nil + if game.touchSkinHotkey then game:touchSkinHotkey("fast_forward_hold", true) end + if game:logicSpeed() ~= 1 then fail("skin FF hold defeated the battle lock") end + if game.touchSkinHotkey then game:touchSkinHotkey("fast_forward_hold", false) end + end + if checked == 200 and not shot then + shot = U.shot(game, DIR .. "/firered_battle_locked.png") + end + local st = Battle.getState and Battle.getState() + if st and Battle._phase == "command" and Ui._mode == "menu" and not Ui._pendingCommand then + local mon = st.player.mon + mon.moves[1] = 33 + mon.pp = mon.pp or {} + mon.pp[1] = 10 + st.enemy.mon.hp = 1 + Ui._pendingCommand = { kind = "move", move = 33, slot = 1, user = "player" } + Ui._mode = "none" + U.wait(1) + elseif f % 14 == 0 then + U.tap(game, "a") + else + U.wait(1) + end + end + if Battle.isActive() then + fail(("battle never ended (phase %s, ui %s)"):format(tostring(Battle._phase), tostring(Ui._mode))) + end + U.log("battle frames checked at 1X", checked) + U.wait(120) + if game:logicSpeed() ~= 10 then + fail(("after the battle logic speed is %s, want 10"):format(tostring(game:logicSpeed()))) + 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 + if game.session then + game.session.x, game.session.y, game.session.facing = x, y, facing + end + end + -- pokefirered/data/scripts/cable_club.inc:743 + Flags.setFlag(Space.store, ctx(), FLAG_SYS_POKEDEX_GET, true) + Map.load(nil, game, CENTER_2F, { x = 5, y = 2, facing = "up" }) + place(5, 2, "up") + U.wait(60) + if game:logicSpeed() ~= 10 then + fail(("the cable club counter room reads %s, want 10"):format(tostring(game:logicSpeed()))) + end + place(5, 1, "up") + U.wait(10) + local Natives = require("src.core.game3.scripting.natives") + Natives.special(ctx(), SET_CABLE_CLUB_WARP, Space.vm and Space.vm.adapters) + Flags.setVar(Space.store, ctx(), VAR_CABLE_CLUB_STATE, 6) + Map.load(nil, game, UNION_ROOM, { x = 7, y = 11, facing = "up" }) + place(7, 11, "up") + local seen = 0 + for _ = 1, 120 do + U.wait(1) + if game:logicSpeed() ~= 1 then + fail(("the Union Room ran at %sX with OVERWORLD SPEED 10"):format(tostring(game:logicSpeed()))) + end + seen = seen + 1 + end + if not Union.isActive() then U.log("note: union session not active, locked by map") end + key("1") + game:gamepadaxis(nil, "triggerright", 1) + game:gamepadaxis(nil, "triggerright", 0) + if o.speedOverworld ~= 10 then + fail(("speed presses in the Union Room moved OVERWORLD SPEED to %s"):format(tostring(o.speedOverworld))) + end + U.shot(game, DIR .. "/firered_union_locked.png") + U.log("union room frames checked at 1X", seen, "union state", tostring(Union.state)) + U.log("PASS firered battle and Union Room locked to 1X, OVERWORLD SPEED 10 back after the battle") + love.event.quit(0) +end diff --git a/tests/drivers/speed_lock_gold.lua b/tests/drivers/speed_lock_gold.lua new file mode 100644 index 00000000..3636e3a7 --- /dev/null +++ b/tests/drivers/speed_lock_gold.lua @@ -0,0 +1,102 @@ +local U = require("tests.drivers.util") + +local Mon = require("src.battle.gen2.Mon") + +return function(game) + local DIR = os.getenv("SHOT_DIR") or os.getenv("POKEPORT_SHOT_DIR") or "/tmp/shots" + local function fail(msg) + error("FAIL speed lock: " .. msg, 0) + end + local function tap(button, frames) + game.input.pressQueue[#game.input.pressQueue + 1] = button + game.input.state[button] = true + U.wait(2) + game.input.state[button] = false + U.wait(frames or 6) + end + + U.wait(45) + local world = game.world + if not (world and world.map) then return fail("gold world did not boot") end + + game.speedOverride = nil + game.options.speed = 10 + U.wait(2) + if game:logicSpeed() ~= 10 then + return fail(("overworld at GAME SPEED 10 reads %s"):format(tostring(game:logicSpeed()))) + end + U.log("overworld logic speed", game:logicSpeed()) + + local player = Mon.new(game.data, "CYNDAQUIL", 10) + game.save.party = { player } + local wild = Mon.new(game.data, "PIDGEY", 2) + if not world:startBattle({ wild = wild }) then return fail("startBattle failed") end + + local battle + for _ = 1, 600 do + local top = game.stack:top() + if top and top.battle then battle = top break end + U.wait(1) + end + if not battle then return fail("battle screen never came up") end + + local function inBattle() + for _, st in ipairs(game.stack.states) do + if st == battle then return true end + end + return false + end + + local checked, shot = 0, false + for _ = 1, 2000 do + if not inBattle() then break end + if game:logicSpeed() ~= 1 then + return fail(("battle ran at %sX with GAME SPEED 10"):format(tostring(game:logicSpeed()))) + end + checked = checked + 1 + if checked == 30 then + game:hotkey("1") + game:_cycleSpeed(1) + game:_cycleSpeed(-1) + if game.options.speed ~= 10 then + return fail(("speed presses in battle moved GAME SPEED to %s"):format(tostring(game.options.speed))) + end + game.speedOverride = 200 + if game:logicSpeed() ~= 1 then + return fail("speedOverride 200 defeated the battle lock") + end + game.speedOverride = nil + end + if battle.phase == "menu" and not shot then + shot = U.shot(game, DIR .. "/gold_battle_locked.png") + end + if battle.battle and battle.battle.over then + tap("a", 3) + elseif battle.phase == "menu" then + tap("a", 4) + tap("a", 4) + else + tap("a", 3) + end + end + if inBattle() then + local top = game.stack:top() + return fail(("battle never ended (phase %s, over %s, top %s)"):format( + tostring(battle.phase), tostring(battle.battle and battle.battle.over), + tostring(top and (top.screenId or top.name) or top))) + end + U.log("battle frames checked at 1X", checked) + U.wait(30) + if game:logicSpeed() ~= 10 then + return fail(("after the battle logic speed is %s, want 10"):format(tostring(game:logicSpeed()))) + end + game.linkNet = { closed = false } + if game:logicSpeed() ~= 1 then return fail("an open linkNet did not lock 1X") end + game.linkNet = nil + game.linkSession = true + if game:logicSpeed() ~= 1 then return fail("a linkSession did not lock 1X") end + game.linkSession = nil + U.shot(game, DIR .. "/gold_after_battle.png") + U.log("PASS gold battle and link locked to 1X, GAME SPEED 10 back after") + love.event.quit(0) +end diff --git a/tests/drivers/speed_lock_red.lua b/tests/drivers/speed_lock_red.lua new file mode 100644 index 00000000..29fe7e28 --- /dev/null +++ b/tests/drivers/speed_lock_red.lua @@ -0,0 +1,87 @@ +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or os.getenv("POKEPORT_SHOT_DIR") or "/tmp/shots" + local Pokemon = require("src.pokemon.Pokemon") + local BattleState = require("src.battle.BattleState") + local function fail(msg) + error("FAIL speed lock: " .. msg, 0) + end + local function key(k) + love.keypressed(k, k, false) + love.keyreleased(k, k) + end + + local squirtle = Pokemon.new(game.data, "SQUIRTLE", 30, function(_, b) return b end) + game.save.party = { squirtle } + U.teleport(game, "ROUTE_1", 5, 5, "down") + local ow = game.overworld + + local o = game.save.options + game.speedOverride = nil + o.speedOverworld, o.speedBattle, o.speedMenu = 10, 200, 1 + U.wait(2) + if game:logicSpeed() ~= 10 then + fail(("overworld at OVERWORLD SPEED 10 reads %s"):format(tostring(game:logicSpeed()))) + end + U.log("overworld logic speed", game:logicSpeed()) + + local battle = BattleState.newWild(game, "RATTATA", 2) + battle.onFinish = function() end + battle.enemy.mon.hp = 1 + ow:pushBattle(battle) + + local function inBattle() + for _, st in ipairs(game.stack.states) do + if st == battle then return true end + end + return false + end + + for _ = 1, 600 do + if inBattle() then break end + U.wait(1) + end + if not inBattle() then fail("the battle never reached the stack") end + + local checked, shot = 0, false + for _ = 1, 2400 do + if not inBattle() then break end + if game:logicSpeed() ~= 1 then + fail(("battle ran at %sX with speedBattle 200"):format(tostring(game:logicSpeed()))) + end + checked = checked + 1 + if checked == 20 then + key("1") + game:gamepadaxis(nil, "triggerright", 1) + game:gamepadaxis(nil, "triggerright", 0) + game:touchSkinHotkey("fast_forward_toggle", true) + if o.speedOverworld ~= 10 or o.speedBattle ~= 200 or o.speedMenu ~= 1 then + fail(("speed presses in battle changed options (%s/%s/%s)"):format( + tostring(o.speedOverworld), tostring(o.speedBattle), tostring(o.speedMenu))) + end + game:touchSkinHotkey("fast_forward_hold", true) + if game:logicSpeed() ~= 1 then fail("skin FF hold defeated the battle lock") end + game:touchSkinHotkey("fast_forward_hold", false) + game.speedOverride = 200 + if game:logicSpeed() ~= 1 then fail("speedOverride 200 defeated the battle lock") end + game.speedOverride = nil + end + if battle.phase == "menu" and not shot then + shot = U.shot(game, DIR .. "/red_battle_locked.png") + end + U.tap(game, "a") + U.wait(2) + end + if inBattle() then fail("battle never ended") end + U.log("battle frames checked at 1X", checked) + U.wait(10) + if game:logicSpeed() ~= 10 then + fail(("after the battle logic speed is %s, want 10"):format(tostring(game:logicSpeed()))) + end + game.linkSession = true + if game:logicSpeed() ~= 1 then fail("a link session did not lock 1X") end + game.linkSession = nil + U.shot(game, DIR .. "/red_after_battle.png") + U.log("PASS red battle and link locked to 1X, OVERWORLD SPEED 10 back after") + love.event.quit(0) +end diff --git a/tests/engine/crystal_map_name_sign_speed_bug1994.lua b/tests/engine/crystal_map_name_sign_speed_bug1994.lua index b601d6ad..7e34fbe2 100644 --- a/tests/engine/crystal_map_name_sign_speed_bug1994.lua +++ b/tests/engine/crystal_map_name_sign_speed_bug1994.lua @@ -85,24 +85,16 @@ if check(one.mapSign ~= nil, "the sign comes up at 1X") then end -- ../pokecrystal/engine/overworld/events.asm:177-191 +FixedStep.clock = function() return 0 end for _, speed in ipairs({ 1, 4, 10, 20, 200 }) do local w = raise(gameAt(speed)) local frame, steps, maxPerFrame = renderedFramesToClear(w, speed, 2000) eq(frame, 122, ("%dX: the sign clears on rendered frame 122"):format(speed)) - if speed <= 10 then - eq(steps, 122 * speed, - ("%dX: under the catch-up cap the logic ran %d steps"):format(speed, speed * 122)) - else - check(maxPerFrame <= 15 and steps < 122 * speed, - ("%dX: FixedStep pinned at %d steps a frame, %d in all (nominal would be %d)") - :format(speed, maxPerFrame, steps, 122 * speed)) - end + eq(steps, 122 * speed, + ("%dX: the logic ran %d steps"):format(speed, speed * 122)) + eq(maxPerFrame, speed, ("%dX: %d steps a rendered frame"):format(speed, speed)) end - -local capped = raise(gameAt(200)) -local _, _, perFrame = renderedFramesToClear(capped, 200, 2000) -check(perFrame >= 14 and perFrame <= 15, - ("200X: the capped regime really ran ~15 steps a frame (%d)"):format(perFrame)) +FixedStep.clock = nil local hidden = raise(gameAt(1)) for _ = 1, 3 do Sign.frame(hidden) end diff --git a/tests/engine/fast_forward_rate_bug2471.lua b/tests/engine/fast_forward_rate_bug2471.lua new file mode 100644 index 00000000..5a8f3255 --- /dev/null +++ b/tests/engine/fast_forward_rate_bug2471.lua @@ -0,0 +1,91 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq +love = love or require("tests.love_stub") + +local FixedStep = require("src.core.FixedStep") +local GameSpeed = require("src.core.GameSpeed") +local STEP = FixedStep.STEP + +local function run(speed, fps, frames, clock, period) + local now = 0 + local steps, maxPerFrame, maxAccum = 0, 0, 0 + FixedStep.refreshPeriod = period + FixedStep:init(function() + steps = steps + 1 + if clock == "ms" then now = now + 0.001 end + end) + FixedStep.clock = function() return now end + local dt = 1 / fps + for _ = 1, frames do + local before = steps + FixedStep.maxAccum = FixedStep.catchupLimit(speed, dt) + FixedStep:update(dt, speed) + if clock == "ms" then now = now + 0.004 end + if steps - before > maxPerFrame then maxPerFrame = steps - before end + if FixedStep.accum > maxAccum then maxAccum = FixedStep.accum end + end + FixedStep.clock = nil + FixedStep.refreshPeriod = nil + return steps, maxPerFrame, maxAccum +end + +for _, speed in ipairs(GameSpeed.LEVELS) do + local steps = run(speed, 60, 60, "frozen") + check(math.abs(steps - 60 * speed) <= 1, + ("%dX at 60fps: %d logic steps a second (want %d)"):format(speed, steps, 60 * speed)) + local slow = run(speed, 30, 30, "frozen") + check(math.abs(slow - 60 * speed) <= 1, + ("%dX at 30fps: %d logic steps a second (want %d)"):format(speed, slow, 60 * speed)) + local mid = run(speed, 45, 45, "frozen") + check(math.abs(mid - 60 * speed) <= 1, + ("%dX at 45fps: %d logic steps a second (want %d)"):format(speed, mid, 60 * speed)) +end + +local fast = run(200, 60, 60, "frozen") +local ten = run(10, 60, 60, "frozen") +check(fast >= ten * 15, + ("200X runs far more logic than 10X on a fast frame (%d vs %d)"):format(fast, ten)) + +local steps, perFrame, carried = run(200, 60, 60, "ms", 1 / 60) +check(perFrame >= 11 and perFrame <= 14, + ("200X on a slow device stops at the frame budget (%d steps a frame)"):format(perFrame)) +check(carried < STEP, + ("the unpaid debt is dropped, not carried (max accum %.4f)"):format(carried)) +check(steps >= 60 * 11, + ("and still runs as fast as the device allows (%d steps)"):format(steps)) + +local oneX = run(1, 60, 60, "ms", 1 / 60) +eq(oneX, 60, "1X ignores the work budget") + +local function hitchBurst(speed, hitch) + local n = 0 + FixedStep.refreshPeriod = nil + FixedStep.clock = function() return 0 end + FixedStep:init(function() n = n + 1 end) + for _ = 1, 30 do + FixedStep.maxAccum = FixedStep.catchupLimit(speed, 1 / 60) + FixedStep:update(1 / 60, speed) + end + n = 0 + FixedStep.maxAccum = FixedStep.catchupLimit(speed, hitch) + FixedStep:update(hitch, speed) + FixedStep.clock = nil + return n +end + +for _, speed in ipairs({ 1, 2, 4, 10 }) do + local cap = math.max(2, math.floor(speed * 1.5 + 1e-9)) + for _, hitch in ipairs({ 0.05, 0.1, 0.25, 1.0 }) do + local n = hitchBurst(speed, hitch) + check(n <= cap, + ("%dX: a %.0fms hitch is not repaid as a burst (%d steps, max %d)"):format(speed, hitch * 1000, n, cap)) + end +end +eq(hitchBurst(1, 0.25), 2, "1X after a 250ms hitch runs 2 steps, like before the budget") + +FixedStep:init(function() end) +FixedStep.maxAccum = FixedStep.MAX_ACCUM + +T.finish("fast forward rate bug 2471") diff --git a/tests/engine/game3_speed_hotkey_test.lua b/tests/engine/game3_speed_hotkey_test.lua index da01a1eb..a8969f77 100644 --- a/tests/engine/game3_speed_hotkey_test.lua +++ b/tests/engine/game3_speed_hotkey_test.lua @@ -38,6 +38,14 @@ do eq(g:logicSpeed(), 2, "walk speed sticks after the menu closes") battleActive = true eq(g:speedCategory(), "battle", "battle stays battle") + g.options.speedBattle = 10 + eq(g:logicSpeed(), 1, "a gen3 battle runs 1X whatever BATTLE SPEED says") + g.speedOverride = 20 + eq(g:logicSpeed(), 1, "and whatever speedOverride says") + g.speedOverride = nil + g:keypressed("1") + eq(g.options.speedBattle, 10, "1 in battle is ignored") + eq(g.options.speedOverworld, 2, "and leaves OVERWORLD SPEED alone") battleActive = false eq(newGame3("boot"):speedCategory(), "menu", "boot phase stays menu") eq(newGame3("quest_log"):speedCategory(), "menu", "quest log stays menu") @@ -158,4 +166,44 @@ do package.loaded["src.core.game3.minigames.common"] = nil end +do + local g = newGame3("field") + g.options.speedOverworld = 10 + eq(g:logicSpeed(), 10, "plain field walk honors OVERWORLD SPEED") + local unionOn = true + package.loaded["src.core.game3.link.union_room"] = { + isActive = function() return unionOn end, + } + eq(g:logicSpeed(), 1, "an active Union Room is locked to 1X") + g:keypressed("1") + eq(g.options.speedOverworld, 10, "1 in the Union Room is ignored") + unionOn = false + eq(g:logicSpeed(), 10, "leaving the Union Room restores the walk speed") + package.loaded["src.core.game3.link.union_room"] = nil + package.loaded["src.core.game3.map"] = { current = "FR_UNION_ROOM" } + eq(g:logicSpeed(), 1, "standing on the Union Room map is locked to 1X") + package.loaded["src.core.game3.map"] = { current = "FR_PALLET_TOWN" } + eq(g:logicSpeed(), 10, "another map is not") + package.loaded["src.core.game3.map"] = nil + local room = 1 + package.loaded["src.core.game3.link"] = { + inLinkRoom = function() return room ~= 0 end, + } + eq(g:logicSpeed(), 1, "a cable club link room is locked to 1X") + room = 0 + eq(g:logicSpeed(), 10, "and outside it the walk speed is back") + package.loaded["src.core.game3.link"] = nil + local menuOpen, directOpen = true, false + package.loaded["src.ui.game3.link_menu"] = { + isOpen = function() return menuOpen end, + Direct = { isOpen = function() return directOpen end }, + } + eq(g:logicSpeed(), 1, "the wireless link menu is locked to 1X") + menuOpen, directOpen = false, true + eq(g:logicSpeed(), 1, "the Direct Corner chooser is locked to 1X") + directOpen = false + eq(g:logicSpeed(), 10, "closed link menus release the lock") + package.loaded["src.ui.game3.link_menu"] = nil +end + T.finish() diff --git a/tests/engine/game3_trainer_sight_test.lua b/tests/engine/game3_trainer_sight_test.lua index 48caefec..97112b2a 100644 --- a/tests/engine/game3_trainer_sight_test.lua +++ b/tests/engine/game3_trainer_sight_test.lua @@ -454,6 +454,80 @@ check(pacer.moving == false and pacer.cellX == 40 and pacer.cellY == 13, "Traine resetField() +print("[test] 11. Neighbor-map pool trainers never engage (Route 11 Dave seen from Vermilion)") +Objects.clear() +local engagedBy = {} +local realEngage = TrainerSight.engage +TrainerSight.engage = function(_, e, dist) engagedBy[#engagedBy + 1] = { eo = e, dist = dist } end +local ghostPool = Objects.spawnFromDefs({ + { localId = 4, x = 29, y = 11, movementType = 0x08, trainerType = 1, sight = 1, + scriptKey = "trainer_battle_01", elevation = 3 }, +}, { midLayout = { width = 36, height = 10 } }, "FR_ROUTE_11") +local dave = ghostPool.byId[4] +check(dave ~= nil and dave.sight == 1 and TrainerSight.isTrainerType(dave), "ghost Dave spawned as a sight-1 trainer") +Player.reset(29, 13, "up") +Player.elevation = 3 +dave.moving, dave.progress, dave.targetX, dave.targetY, dave.facing = true, 0, 29, 12, "down" +local ghostCtx = { + ox = 48, oy = 10, + canEnter = function() return false end, + blocks = function() return true end, +} +for _ = 1, 20 do Objects.tickPool(ghostPool, dummyGame, ghostCtx) end +check(dave.cellX == 29 and dave.cellY == 12 and not dave.moving, "ghost Dave finished his step onto local (29,12)") +check(TrainerSight.checkLineOfSight(dave, Player, dummyGame) == true, + "ghost-local cone lines up with the current-map player cell") +check(#engagedBy == 0 and not Field.locked, "ghost step completion never engages") +check(TrainerSight.check(dummyGame, dave) == false and #engagedBy == 0, + "TrainerSight.check refuses an object outside the current map") + +resetField() +dave.cellY, dave.py, dave.homeY = 11, 11 * 16, 11 +dave.moving, dave.progress, dave.targetX, dave.targetY, dave.facing = true, 0, 29, 12, "down" +Objects._byId[4] = dave +Objects._order = { 4 } +Player.reset(29, 13, "up") +Player.elevation = 3 +for _ = 1, 20 do + Objects.update(dummyGame) + if #engagedBy > 0 then break end +end +check(#engagedBy == 1 and engagedBy[1].eo == dave and engagedBy[1].dist == 1, + "control: the same trainer on the current map engages at dist 1 after its step") +TrainerSight.engage = realEngage +Objects.clear() +resetField() + +print("[test] 12. Pool step completion skips sight and current-map elevation even without the identity guard") +local realCheck, realElevAt = TrainerSight.check, Collision.elevationAt +local checkCalls = 0 +TrainerSight.check = function() checkCalls = checkCalls + 1 return false end +Collision.elevationAt = function() return 7 end +local pool2 = Objects.spawnFromDefs({ + { localId = 4, x = 29, y = 11, movementType = 0x08, trainerType = 1, sight = 1, + scriptKey = "trainer_battle_01", elevation = 3 }, +}, { midLayout = { width = 36, height = 10 } }, "FR_ROUTE_11") +local ghost = pool2.byId[4] +Player.reset(29, 13, "up") +Player.elevation = 3 +for _, ctxArg in ipairs({ ghostCtx, false }) do + ghost.cellX, ghost.cellY, ghost.px, ghost.py, ghost.homeY = 29, 11, 29 * 16, 11 * 16, 11 + ghost.elevation = 3 + ghost.moving, ghost.progress, ghost.targetX, ghost.targetY, ghost.facing = true, 0, 29, 12, "down" + local stepped = false + for _ = 1, 20 do + Objects.tickPool(pool2, dummyGame, ctxArg or nil) + if not ghost.moving then stepped = true break end + end + local label = ctxArg and "ctx" or "nil ctx" + check(stepped and ghost.cellY == 12, label .. ": ghost finished its step") + check(ghost.elevation == 3, label .. ": ghost elevation not read from the current map, got " .. tostring(ghost.elevation)) +end +check(checkCalls == 0, "pool step never calls TrainerSight.check, got " .. checkCalls) +TrainerSight.check, Collision.elevationAt = realCheck, realElevAt +Objects.clear() +resetField() + if failed > 0 then print(string.format("\n%d FAILURE(S)", failed)) os.exit(1) diff --git a/tests/engine/game3_virtual_objects_drawhook_test.lua b/tests/engine/game3_virtual_objects_drawhook_test.lua index 8c99828b..47dfee56 100644 --- a/tests/engine/game3_virtual_objects_drawhook_test.lua +++ b/tests/engine/game3_virtual_objects_drawhook_test.lua @@ -31,6 +31,22 @@ eq(found and found.sprite, GfxIds.spriteFor(40), "sprite name reaches the non-OW eq(Objects._byId[1], nil, "the registry is not in the object store (no collision)") eq(Objects.at(5, 6), nil, "Objects.at cannot see it") +local again +for _, eo in ipairs(Objects.forDraw()) do + if eo.virtualId == 1 then again = eo end +end +check(again == found, "forDraw reuses the draw record for an unchanged virtual object") +if jit then jit.off() end +for _ = 1, 20 do Objects.forDraw() end +collectgarbage("collect") +collectgarbage("stop") +local kb0 = collectgarbage("count") +for _ = 1, 100 do Objects.forDraw() end +local grown = collectgarbage("count") - kb0 +collectgarbage("restart") +if jit then jit.on() end +check(grown < 1, "forDraw allocates nothing in steady state (" .. grown .. " KB)") + VirtualObjects.turn(1, 2) local turned for _, eo in ipairs(Objects.forDraw()) do diff --git a/tests/engine/game_speed_categories_test.lua b/tests/engine/game_speed_categories_test.lua index 6303fd42..200124c2 100644 --- a/tests/engine/game_speed_categories_test.lua +++ b/tests/engine/game_speed_categories_test.lua @@ -1,5 +1,5 @@ -- Per-category GAME SPEED (RFC 0007): Game.speedCategoryInStack's stack --- walk, Game:logicSpeed()'s precedence (link lock / run-argument override / +-- walk, Game:logicSpeed()'s precedence (battle/link lock / run-argument override / -- the core.logic_speed hook), Game:_cycleSpeed's per-category cycling, and -- the core.logic_speed hook itself exercised through the public mod API -- (Hooks.new() + bus:wrap, the same idiom other hooks' tests use -- not a @@ -66,6 +66,7 @@ do local g = gameWith({ battle }, { speedOverworld = 4, speedBattle = 10, speedMenu = 2 }) eq(g:_resolveLogicSpeed(), 10, "battle reads speedBattle") + eq(g:logicSpeed(), 1, "but a battle always runs 1X whatever speedBattle says") end do local g = gameWith({ overlay }, @@ -97,10 +98,17 @@ do eq(g:logicSpeed(), 1, "an open linkNet forces 1X the same way") end do - local g = gameWith({ battle }, { speedBattle = 50 }) + local g = gameWith({ overworld }, { speedOverworld = 50 }) g.linkNet = { closed = true } eq(g:logicSpeed(), 50, "a CLOSED linkNet does not force 1X") end +do + local g = gameWith({ overworld, battle }, { speedOverworld = 50, speedBattle = 50 }) + g.speedOverride = 20 + eq(g:logicSpeed(), 1, "a battle forces 1X over speedOverride too") + local locked, why = g:speedLocked() + check(locked and why == "battle", "speedLocked reports the battle") +end do local g = gameWith({ overworld }, { speedOverworld = 4 }) g.speedOverride = 20 @@ -172,10 +180,20 @@ do { speedOverworld = 1, speedBattle = 1, speedMenu = 1 }) function g:writeOptions() writeOptions.calls = writeOptions.calls + 1 end g:_cycleSpeed(1) - eq(g.save.options.speedBattle, 2, "cycling during battle bumps speedBattle") + eq(g.save.options.speedBattle, 1, "cycling during battle is ignored") eq(g.save.options.speedOverworld, 1, "...and leaves speedOverworld alone") eq(g.save.options.speedMenu, 1, "...and leaves speedMenu alone") - eq(writeOptions.calls, 1, "a successful cycle persists the option") + eq(writeOptions.calls, 0, "and persists nothing") +end +do + local calls = 0 + local g = gameWith({ overworld }, + { speedOverworld = 1, speedBattle = 1, speedMenu = 1 }) + function g:writeOptions() calls = calls + 1 end + g.linkSession = true + g:_cycleSpeed(1) + eq(g.save.options.speedOverworld, 1, "cycling during link play is ignored") + eq(calls, 0, "...and persists nothing") end do local g = gameWith({ overworld }, diff --git a/tests/engine/gen2_logic_speed_bug1994.lua b/tests/engine/gen2_logic_speed_bug1994.lua index d2f0305e..b434d876 100644 --- a/tests/engine/gen2_logic_speed_bug1994.lua +++ b/tests/engine/gen2_logic_speed_bug1994.lua @@ -25,6 +25,38 @@ eq(game({ options = { speed = -5 } }):logicSpeed(), 1, eq(game({ options = { speed = "fast" } }):logicSpeed(), 1, "an unparseable option falls back rather than erroring") +local function stack(...) return { states = { ... } } end +local battle = { isBattle = true } +local world = {} + +eq(game({ options = { speed = 10 }, stack = stack(world) }):logicSpeed(), 10, + "the overworld under a plain stack honors GAME SPEED") +eq(game({ options = { speed = 10 }, stack = stack(world, battle) }):logicSpeed(), 1, + "a battle on the stack is locked to 1X") +eq(game({ options = { speed = 10 }, speedOverride = 20, + stack = stack(world, battle, {}) }):logicSpeed(), 1, + "even with speedOverride and a menu over the battle") +eq(game({ options = { speed = 10 }, linkNet = { closed = false } }):logicSpeed(), 1, + "an open linkNet (online arena, LinkBattle2) is locked to 1X") +eq(game({ options = { speed = 10 }, linkNet = { closed = true } }):logicSpeed(), 10, + "a closed linkNet is not") +eq(game({ options = { speed = 10 }, linkSession = true }):logicSpeed(), 1, + "a link session (tournament, spectate) is locked to 1X") +eq(require("src.ui.gen2.BattleState").isBattle, true, + "the Gen 2 battle screen marks itself as a battle") + +do + local persisted = 0 + local g = game({ options = { speed = 1 }, stack = stack(world, battle), + persistOptions = function() persisted = persisted + 1 end }) + g:_cycleSpeed(1) + eq(g.options.speed, 1, "SPEED + in battle is ignored") + eq(persisted, 0, "and persists nothing") + g.stack = stack(world) + g:_cycleSpeed(1) + eq(g.options.speed, 2, "SPEED + on the overworld still cycles") +end + local Gen2Compat = require("src.mods.Gen2Compat") local live = game({ options = { speed = 4 } }) Gen2Compat.bind(function() return live end) diff --git a/tests/engine/gen3_dex_summary_2461.lua b/tests/engine/gen3_dex_summary_2461.lua new file mode 100644 index 00000000..36d3d1e4 --- /dev/null +++ b/tests/engine/gen3_dex_summary_2461.lua @@ -0,0 +1,75 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local eq = T.eq +love = love or require("tests.love_stub") + +local SaveConvert = require("src.save_convert.SaveConvert") +local SaveData = require("src.core.SaveData") +local Boot = require("src.ui.game3.boot") +local Dex = require("src.core.game3.dex") +local F = require("tests.fixture_data.gen3_saves") + +local function unrle(s) + local out = {} + for tok in s:gmatch("%S+") do + local k, n = tok:match("^([ZF])(%x+)$") + if k then + out[#out + 1] = string.rep(k == "Z" and "\0" or "\255", tonumber(n, 16)) + else + out[#out + 1] = (tok:gsub("%x%x", function(h) return string.char(tonumber(h, 16)) end)) + end + end + return table.concat(out) +end + +local function launcherCount(save) + local _, meta = SaveData.slotSummary(save) + return meta and meta.dexCount +end + +for _, name in ipairs({ "fr_rich_game", "lg_rich_game" }) do + local ver = name:sub(1, 2) == "fr" and "firered" or "leafgreen" + local save = assert(SaveConvert.importSav(unrle(F.images[name]), ver, ver)) + eq(#save.modData.cartImport.dexOwned, 8, name .. " cart owns 8 species") + eq(Dex.nationalEnabled(save), false, name .. " national dex off") + eq(launcherCount(save), 5, name .. " launcher counts the imported Kanto dex") + eq(Boot.continueInfoFromSave(save).dexCount, 5, name .. " CONTINUE counts the imported Kanto dex") + save.dex.national = true + eq(launcherCount(save), 8, name .. " launcher counts the imported national dex") + eq(Boot.continueInfoFromSave(save).dexCount, 8, name .. " CONTINUE counts the imported national dex") +end + +do + local save = { + engine = "game3", version = "firered", name = "RED", + dex = { seen = {}, owned = { [1] = true, [4] = true, [277] = true }, caught = { [1] = true, [4] = true, [277] = true }, national = false }, + flags = {}, vars = {}, + } + eq(launcherCount(save), 2, "native save before national dex counts Kanto only") + eq(Boot.continueInfoFromSave(save).dexCount, 2, "CONTINUE native save counts Kanto only") + save.flags[0x840] = true + save.vars[0x404E] = 0x6258 + eq(launcherCount(save), 3, "native save with FLAG_SYS_NATIONAL_DEX counts national") + eq(Boot.continueInfoFromSave(save).dexCount, 3, "CONTINUE native save with national flag counts national") + save.flags, save.vars = {}, {} + save.dex.nationalUnlocked = true + eq(launcherCount(save), 3, "dex.nationalUnlocked counts national") + save.dex.nationalUnlocked = nil + save.flags = { ["2112"] = true } + eq(Dex.nationalEnabled(save), true, "string-keyed national flag") + save.flags = {} + save.dex.owned, save.dex.caught = { ["1"] = true, ["150"] = true }, {} + eq(launcherCount(save), 2, "string-keyed dex entries count") +end + +do + local save = { + engine = "game3", version = "firered", + dex = { seen = {}, owned = { [25] = true }, caught = { [25] = true } }, + modData = { cartImport = { dexOwned = { 25, 26 } } }, + } + eq(Dex.summaryCount(save), 2, "cart list and merged dex do not double count") +end + +T.finish("gen3_dex_summary_2461") diff --git a/tests/engine/gen3_dex_summary_keys_2461.lua b/tests/engine/gen3_dex_summary_keys_2461.lua new file mode 100644 index 00000000..fdd146e4 --- /dev/null +++ b/tests/engine/gen3_dex_summary_keys_2461.lua @@ -0,0 +1,23 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local eq = T.eq +love = love or require("tests.love_stub") + +local Dex = require("src.core.game3.dex") + +do + local save = { dex = { caught = { [1] = true, [4] = true, NOT_A_SPECIES_2461 = true, [200] = true } } } + eq(Dex.nationalEnabled(save), false, "national dex off") + eq(Dex.summaryCount(save), 2, "unresolvable string key is not counted on the Kanto gate") + save.dex.national = true + eq(Dex.summaryCount(save), 3, "unresolvable string key is not counted on the national gate") +end + +do + local save = { dex = { owned = { ["7"] = true, ["152"] = true } } } + eq(Dex.summaryCount(save), 1, "numeric string keys still honour the Kanto gate") +end + +T.finish("gen3_dex_summary_keys_2461") +if T.failures > 0 then os.exit(1) end diff --git a/tests/engine/mart_bag_full_bug2162.lua b/tests/engine/mart_bag_full_bug2162.lua index 92c900f3..1abd9e93 100644 --- a/tests/engine/mart_bag_full_bug2162.lua +++ b/tests/engine/mart_bag_full_bug2162.lua @@ -73,6 +73,10 @@ local function newGame(money, ballCount) constants = {}, }, save = { money = money, inventory = {}, bagOrder = {} }, + input = { + wasPressed = function() return false end, + isDown = function() return false end, + }, } if ballCount then game.save.inventory.POKE_BALL = ballCount @@ -93,9 +97,18 @@ local function newGame(money, ballCount) return game, events, stack end +local function drain(game) + for _ = 1, 600 do + local top = game.stack:top() + if not (top and top.stay and not top.stayShown) then return end + top:update(1 / 60) + end +end + local function buyOne(game, stack) local menu = ShopMenu.new(game, { "POKE_BALL" }, function() end) menu.items[1].onSelect() + drain(game) local list = stack[#stack] list.onChoose({ value = "POKE_BALL" }) local qty = stack[#stack] @@ -137,6 +150,7 @@ for _, case in ipairs({ game.stack:pop() box.onDone() + drain(game) eq(stack[#stack], nil, case.name .. ": A closes the list") eq(menu.footer, ANYTHING, case.name .. ": the mart menu asks again") eq(game.save.money, case.money, case.name .. ": money is still untouched") @@ -151,6 +165,7 @@ do game.save.bagOrder = { "BICYCLE" } local menu = ShopMenu.new(game, { "POKE_BALL" }, function() end) menu.items[2].onSelect() + drain(game) local list = stack[#stack] list.onChoose({ value = "BICYCLE" }) local box = stack[#stack] @@ -158,6 +173,7 @@ do eq(stack[#stack - 1], list, "unsellable: the sell list is still under it") game.stack:pop() box.onDone() + drain(game) eq(stack[#stack], nil, "unsellable: A closes the sell list") eq(menu.footer, ANYTHING, "unsellable: the mart menu asks again") end @@ -172,6 +188,7 @@ do check(getmetatable(box) == TextBox, "empty bag: only a text box is pushed") game.stack:pop() box.onDone() + drain(game) eq(menu.footer, ANYTHING, "empty bag: the mart menu asks again") end diff --git a/tests/engine/mart_clerk_typing_bug2384.lua b/tests/engine/mart_clerk_typing_bug2384.lua new file mode 100644 index 00000000..370f4141 --- /dev/null +++ b/tests/engine/mart_clerk_typing_bug2384.lua @@ -0,0 +1,252 @@ +-- home/text_script.asm:141-150 +-- engine/events/pokemart.asm:7-19 +-- engine/events/pokemart.asm:131-133 +-- engine/events/pokemart.asm:199-206 +-- engine/events/pokemart.asm:220-222 +-- engine/menus/text_box.asm:168-176 + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq +love = love or require("tests.love_stub") + +local function chars(text) + local out = {} + for s in tostring(text):gmatch("[%z\1-\127\194-\244][\128-\191]*") do + out[#out + 1] = s + end + return out +end + +local drawn = {} +local realFont = package.loaded["src.render.Font"] +package.loaded["src.render.Font"] = { + BORDER = { tl = 1, tr = 2, bl = 3, br = 4, h = 5, v = 6 }, + draw = function(text) drawn[#drawn + 1] = { "draw", tostring(text) } end, + drawCode = function(code) drawn[#drawn + 1] = { "code", code } end, + drawBox = function(tx, ty, tw, th) drawn[#drawn + 1] = { "box", tx, ty, tw, th } end, + width = function(text) return #chars(text) * 8 end, + split = chars, + spansFitting = function(spans, pixels) + return math.min(#spans, math.floor(pixels / 8)) + end, + encode = chars, + advanceOf = function() return 8 end, +} + +local RELOAD = { + "src.ui.QuantityBox", "src.ui.ShopMenu", "src.ui.ListMenu", + "src.ui.ChoiceBox", "src.ui.Menu", "src.ui.Theme", "src.render.TextBox", +} +for _, m in ipairs(RELOAD) do package.loaded[m] = nil end + +local ShopMenu = require("src.ui.ShopMenu") +local TextBox = require("src.render.TextBox") +local ListMenu = require("src.ui.ListMenu") +local Theme = require("src.ui.Theme") + +local HI = "Hi there!\nMay I help you?" +local TAKE = "Take your time." +local SELL = "What would you\nlike to sell?" +local ELSE = "Is there anything\nelse I can do?" +local THANKS = "Thank you!" + +local function newGame() + local states, pressed = {}, {} + local game = { + data = { + items = { POKE_BALL = { name = "POKe BALL", price = 200 } }, + text = { + _PokemartGreetingText = HI, + _PokemartBuyingGreetingText = TAKE, + _PokemonSellingGreetingText = SELL, + _PokemartAnythingElseText = ELSE, + _PokemartThankYouText = THANKS, + }, + constants = {}, + }, + save = { money = 3000, inventory = { POKE_BALL = 3 }, bagOrder = { "POKE_BALL" }, + options = { textSpeed = 3 } }, + input = { + wasPressed = function(_, b) return pressed[b] == true end, + isDown = function() return false end, + }, + } + game.stack = { + states = states, + push = function(_, s) + states[#states + 1] = s + if type(s.enter) == "function" then s:enter() end + end, + pop = function() return table.remove(states) end, + top = function() return states[#states] end, + } + local function frame(btn) + for k in pairs(pressed) do pressed[k] = nil end + if btn then pressed[btn] = true end + local top = states[#states] + if top then top:update(1 / 60) end + for k in pairs(pressed) do pressed[k] = nil end + end + return game, states, frame +end + +local function until_(frame, pred) + for _ = 1, 600 do + if pred() then return true end + frame() + end + return pred() +end + +local function drewText(fn, text) + drawn = {} + fn() + for _, c in ipairs(drawn) do + if c[1] == "draw" and c[2] == text then return true end + end + return false +end + +local function drewBox(fn, tx, ty, tw, th) + drawn = {} + fn() + for _, c in ipairs(drawn) do + if c[1] == "box" and c[2] == tx and c[3] == ty and c[4] == tw and c[5] == th then + return true + end + end + return false +end + +local function cursorGlyph(menu) + drawn = {} + menu:draw() + local last + for _, c in ipairs(drawn) do + if c[1] == "code" then last = c[2] end + end + return last +end + +local function isSay(s, text) + return getmetatable(s) == TextBox and s.stay ~= nil + and table.concat(chars(s.pages and s.pages[1] and s.pages[1][1] or "")) + == (tostring(text):match("^[^\n]*")) +end + +do + local game, states, frame = newGame() + local menu = ShopMenu.new(game, { "POKE_BALL" }, function() end) + game.stack:push(menu) + local greet = states[#states] + check(isSay(greet, HI), "greeting: a typing text box goes up first") + eq(greet.done, false, "greeting: it is not instant") + eq(menu.hidden, true, "greeting: BUY/SELL/QUIT is not up yet") + check(not drewText(function() menu:draw() end, "BUY"), "greeting: no BUY row drawn") + check(not drewBox(function() menu:draw() end, 11, 0, 9, 3), "greeting: no MONEY box drawn") + for _ = 1, 6 do frame() end + local typed = greet.shown[1] and #greet.shown[1] or 0 + check(typed > 0 and typed < #chars("Hi there!"), "greeting: typing mid-line") + check(until_(frame, function() return states[#states] == menu end), + "greeting: the menu takes over once the text is done") + eq(menu.hidden, false, "greeting: BUY/SELL/QUIT is up") + eq(menu.footer, HI, "greeting: the line stays in the box under the menu") + check(drewText(function() menu:draw() end, "BUY"), "greeting: BUY drawn") + eq(cursorGlyph(menu), Theme.cursor, "greeting: filled cursor on BUY") +end + +do + local game, states, frame = newGame() + local menu = ShopMenu.new(game, { "POKE_BALL" }, function() end) + game.stack:push(menu) + until_(frame, function() return states[#states] == menu end) + frame("a") + eq(menu.hollowIndex, 1, "BUY: the chosen row goes hollow") + eq(cursorGlyph(menu), Theme.cursorHollow, "BUY: hollow glyph drawn") + local say = states[#states] + check(isSay(say, TAKE), "BUY: Take your time. types before the list") + eq(say.done, false, "BUY: the lead-in is typing") + eq(states[#states - 1], menu, "BUY: BUY/SELL/QUIT stays under the lead-in") + check(until_(frame, function() return getmetatable(states[#states]) == ListMenu end), + "BUY: the list opens after the lead-in") + local list = states[#states] + eq(list.footer, TAKE, "BUY: the list keeps the lead-in in its box") + eq(menu.hollowIndex, 1, "BUY: BUY stays hollow under the list") + + frame("b") + local back = states[#states] + check(isSay(back, ELSE), "B on the list: anything-else types") + eq(back.done, false, "B on the list: it is typing") + eq(menu.hollowIndex, 1, "B on the list: still hollow while it types") + check(until_(frame, function() return states[#states] == menu end), + "B on the list: back to BUY/SELL/QUIT") + eq(menu.index, 1, "back: cursor on BUY") + eq(menu.hollowIndex, nil, "back: cursor filled again") + eq(menu.footer, ELSE, "back: anything-else stays in the box") +end + +do + local game, states, frame = newGame() + local menu = ShopMenu.new(game, { "POKE_BALL" }, function() end) + game.stack:push(menu) + until_(frame, function() return states[#states] == menu end) + frame("down") + frame("a") + eq(menu.hollowIndex, 2, "SELL: the chosen row goes hollow") + check(isSay(states[#states], SELL), "SELL: the sell lead-in types first") + check(until_(frame, function() return getmetatable(states[#states]) == ListMenu end), + "SELL: the list opens after the lead-in") + local list = states[#states] + list.index = #list.items + frame("a") + check(isSay(states[#states], ELSE), "CANCEL: anything-else types") + check(until_(frame, function() return states[#states] == menu end), + "CANCEL: back to BUY/SELL/QUIT") + eq(menu.index, 1, "CANCEL: the cursor resets to BUY, not SELL") + eq(menu.hollowIndex, nil, "CANCEL: filled again") +end + +for _, case in ipairs({ { name = "QUIT", keys = { "down", "down", "a" }, row = 3 }, + { name = "B", keys = { "b" }, row = 1 } }) do + local game, states, frame = newGame() + local quits = 0 + local menu = ShopMenu.new(game, { "POKE_BALL" }, function() quits = quits + 1 end) + game.stack:push(menu) + until_(frame, function() return states[#states] == menu end) + for _, k in ipairs(case.keys) do frame(k) end + local bye = states[#states] + check(getmetatable(bye) == TextBox, case.name .. ": Thank you! is up") + eq(states[#states - 1], menu, case.name .. ": the menu stays under Thank you!") + eq(menu.hollowIndex, case.row, case.name .. ": the chosen row is hollow") + eq(quits, 0, case.name .. ": onQuit waits for the text") + until_(frame, function() return bye.done end) + frame("a") + eq(#states, 0, case.name .. ": the box and the menu close together") + eq(quits, 1, case.name .. ": onQuit fires once") +end + +do + local game, states, frame = newGame() + game.save.inventory, game.save.bagOrder = {}, {} + local menu = ShopMenu.new(game, { "POKE_BALL" }, function() end) + game.stack:push(menu) + until_(frame, function() return states[#states] == menu end) + frame("down") + frame("a") + local empty = states[#states] + check(getmetatable(empty) == TextBox and not empty.stay, "empty bag: the refusal prompt is up") + until_(frame, function() return empty.done end) + frame("a") + check(isSay(states[#states], ELSE), "empty bag: anything-else types after the refusal") + check(until_(frame, function() return states[#states] == menu end), + "empty bag: back to BUY/SELL/QUIT") + eq(menu.index, 1, "empty bag: cursor on BUY") +end + +package.loaded["src.render.Font"] = realFont +for _, m in ipairs(RELOAD) do package.loaded[m] = nil end +require("src.ui.Screens").invalidate() + +T.finish() diff --git a/tests/engine/mart_list_box_bug1887.lua b/tests/engine/mart_list_box_bug1887.lua index 8ec4fe54..b92f8d33 100644 --- a/tests/engine/mart_list_box_bug1887.lua +++ b/tests/engine/mart_list_box_bug1887.lua @@ -65,6 +65,14 @@ game = { }, } +local function drain() + for _ = 1, 600 do + local top = game.stack:top() + if not (top and top.stay and not top.stayShown) then return end + top:update(1 / 60) + end +end + local function openList(which) game.stack.states = {} local menu = ShopMenu.new(game, { "POKE_BALL", "GREAT_BALL" }, function() end) @@ -73,6 +81,7 @@ local function openList(which) pressed = "a" menu:update(1 / 60) pressed = nil + drain() return menu, game.stack:top() end @@ -129,6 +138,7 @@ do pressed = "a" list:update(1 / 60) pressed = nil + drain() eq(game.stack:top(), menu, "A on CANCEL returns to the mart menu") end diff --git a/tests/engine/mart_price_text_bug2291.lua b/tests/engine/mart_price_text_bug2291.lua index fd19d972..b4ce4434 100644 --- a/tests/engine/mart_price_text_bug2291.lua +++ b/tests/engine/mart_price_text_bug2291.lua @@ -93,6 +93,14 @@ local function newGame(money) return game, stack, frame end +local function drain(game) + for _ = 1, 600 do + local top = game.stack:top() + if not (top and top.stay and not top.stayShown) then return end + top:update(1 / 60) + end +end + local function flat(box) local out = {} for _, page in ipairs(box.pages or {}) do @@ -105,6 +113,7 @@ do local game, stack, frame = newGame(3000) local menu = ShopMenu.new(game, { "POKE_BALL" }, function() end) menu.items[1].onSelect() + drain(game) local list = stack[#stack] list.index = 1 list.onChoose({ value = "POKE_BALL" }) @@ -161,6 +170,7 @@ do local game, stack = newGame(3000) local menu = ShopMenu.new(game, { "POKE_BALL" }, function() end) menu.items[1].onSelect() + drain(game) local list = stack[#stack] list.onChoose({ value = "POKE_BALL" }) local qty = stack[#stack] @@ -185,6 +195,7 @@ do game.save.bagOrder = { "POKE_BALL" } local menu = ShopMenu.new(game, { "POKE_BALL" }, function() end) menu.items[2].onSelect() + drain(game) local list = stack[#stack] list.index = 1 list.onChoose(list.items[1]) diff --git a/tests/engine/mart_quantity_cap_bug1981.lua b/tests/engine/mart_quantity_cap_bug1981.lua index 72ab711f..08a2f5d7 100644 --- a/tests/engine/mart_quantity_cap_bug1981.lua +++ b/tests/engine/mart_quantity_cap_bug1981.lua @@ -133,15 +133,17 @@ do }, save = { money = 100, inventory = {}, bagOrder = {} }, stack = { push = function(_, s) pushed[#pushed + 1] = s end, - pop = function() end }, + pop = function() end, + top = function() return pushed[#pushed] end }, } local menu = ShopMenu.new(game, { "POKE_BALL" }, function() end) menu.items[1].onSelect() - local list = pushed[1] + pushed[1].stay.onShown() + local list = pushed[2] if check(list ~= nil and list.onChoose ~= nil, "BUY pushes the item list") then local before = list.footer list.onChoose({ value = "POKE_BALL" }) - local qty = pushed[2] + local qty = pushed[3] if check(qty ~= nil, "choosing an item opens the quantity menu") then eq(qty.max, 99, "wMaxItemQuantity is 99, not floor(money / price)") eq(qty.unitPrice, 200, "the unit price rides along for the total") diff --git a/tests/engine/options_submenu_marquee_test.lua b/tests/engine/options_submenu_marquee_test.lua index ce4b580e..4b119db2 100644 --- a/tests/engine/options_submenu_marquee_test.lua +++ b/tests/engine/options_submenu_marquee_test.lua @@ -45,7 +45,7 @@ local menu = OptionsMenu.new(game) T.check(find(menu.rows, "battleBg"), "self.rows keeps the flat list for mods") T.check(find(menu.rows, "musicVol"), "self.rows keeps the audio rows too") -for _, id in ipairs({ "battleBg", "speedBattle", "uiLayout", "tilt", "zoom", +for _, id in ipairs({ "battleBg", "speedMenu", "uiLayout", "tilt", "zoom", "voidFill", "colors", "shaderfx", "shaderfx2", "musicVol", "videoMode" }) do T.check(not find(menu.view, id), id .. " moved onto a page") @@ -53,7 +53,7 @@ end for _, spec in ipairs({ { "group.battle", 6 }, { "group.audio", 3 }, { "group.video", 5 }, - { "group.speed", 4 }, { "group.graphics", 4 }, { "group.extras", 3 }, + { "group.speed", 3 }, { "group.graphics", 4 }, { "group.extras", 3 }, }) do local row = find(menu.view, spec[1]) T.check(row, spec[1] .. " has an opener row") @@ -100,7 +100,7 @@ game.stack:pop() local speed = find(menu.view, "group.speed") speed.activate(game) T.eq(ids(game.stack:top().view), - "textSpeed,speedOverworld,speedBattle,speedMenu", + "textSpeed,speedOverworld,speedMenu", "TEXT SPEED heads the SPEED page") game.stack:pop() diff --git a/tests/engine/pad_hints_bug2452_test.lua b/tests/engine/pad_hints_bug2452_test.lua new file mode 100644 index 00000000..60811846 --- /dev/null +++ b/tests/engine/pad_hints_bug2452_test.lua @@ -0,0 +1,172 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end + +local T = require("tests.harness") +local check, eq = T.check, T.eq + +local PadHints = require("src.core.PadHints") + +local function fakeFfi(opts) + opts = opts or {} + local calls = {} + local ffi = { + cdef = function() end, + load = function(name) + calls[#calls + 1] = { "load", name } + if opts.loadThrows then error("no SDL2 here") end + return { + SDL_SetHint = function(name2, value) + calls[#calls + 1] = { "hint", name2, value } + return 1 + end, + } + end, + } + return ffi, calls +end + +local function env(values) + return function(k) return values[k] end +end + +local ffi, calls = fakeFfi() +check(PadHints.apply("Windows", ffi, env({})), "Windows sets the hint") +eq(calls[1] and calls[1][2], "SDL2", "the hint goes through SDL2.dll") +eq(calls[2] and calls[2][2], "SDL_JOYSTICK_ALLOW_BACKGROUND_EVENTS", "hint name") +eq(calls[2] and calls[2][3], "1", "hint value") + +for _, name in ipairs({ "OS X", "Linux", "Android", "iOS", "NX" }) do + local f, c = fakeFfi() + check(not PadHints.apply(name, f, env({})), name .. " leaves the hint alone") + eq(#c, 0, name .. " never touches SDL") +end + +local f0, c0 = fakeFfi() +check(not PadHints.apply("Windows", f0, env({ POKEPORT_PAD_BACKGROUND = "0" })), + "POKEPORT_PAD_BACKGROUND=0 opts out") +eq(#c0, 0, "and never loads SDL2") + +local f1, c1 = fakeFfi() +check(PadHints.apply("Windows", f1, env({ POKEPORT_PAD_BACKGROUND = "1" })), + "POKEPORT_PAD_BACKGROUND=1 still sets it") +eq(#c1, 2, "one load and one hint") + +local fThrow = fakeFfi({ loadThrows = true }) +local okThrow, result = pcall(PadHints.apply, "Windows", fThrow, env({})) +check(okThrow, "a throwing ffi.load is swallowed") +check(result == false, "and reports the hint as unset") + +local savedWindow = love.window +local minimized, focused = false, true +love.window = { + isMinimized = function() return minimized end, + isVisible = function() return not minimized end, + hasFocus = function() return focused end, +} +check(not PadHints.windowMinimized(), "a shown window is not minimized") +minimized = true +check(PadHints.windowMinimized(), "a minimized window is") +minimized = false +focused = false +check(not PadHints.windowMinimized(), "losing focus alone does not count as minimized") +love.window = { isVisible = function() return false end } +check(PadHints.windowMinimized(), "isVisible false is the fallback") +love.window = { isMinimized = function() error("boom") end } +check(not PadHints.windowMinimized(), "a throwing window query reads as shown") +love.window = nil +check(not PadHints.windowMinimized(), "no window module reads as shown") + +local Input = require("src.core.Input") +local physical = {} +local pad = { + isGamepad = function() return true end, + isGamepadDown = function(_, button) return physical[button] == true end, + getGamepadAxis = function() return 0 end, + getName = function() return "Xbox Controller" end, + getGUID = function() return "030000005e040000" end, + isConnected = function() return true end, +} +local savedJoystick = love.joystick +love.joystick = { + getJoystickCount = function() return 1 end, + getJoysticks = function() return { pad } end, +} +minimized, focused = false, false +love.window = { + isMinimized = function() return minimized end, + isVisible = function() return not minimized end, + hasFocus = function() return focused end, +} + +Input:init() +Input:applyBindings(nil) +Input:reset() +Input:step() + +physical.a = true +Input:step() +check(Input:wasPressed("a"), "unfocused but shown: the pad A press still lands") +physical.a = false +Input:step() +check(not Input:isDown("a"), "and releases") + +minimized = true +physical.a = true +Input:step() +check(not Input:wasPressed("a"), "minimized: a pad press is dropped") +check(not Input:isDown("a"), "and never held") +physical.a = false +Input:step() +minimized = false +Input:step() +physical.a = true +Input:step() +check(Input:wasPressed("a"), "restored: the next press lands") +physical.a = false +Input:step() + +local SwitchDiagnostics = require("src.debug.SwitchDiagnostics") +local savedSystem = love.system +love.system = { getOS = function() return "OS X" end } +SwitchDiagnostics._resetForTests() +love.filesystem.write("switch-debug.txt", "") +focused = false +SwitchDiagnostics.onFocus(false) +SwitchDiagnostics.onJoystickEvent("gamepadpressed", pad, "a") +focused = true +SwitchDiagnostics.onFocus(true) +SwitchDiagnostics.maybeFlush(true, 100) +local log = love.filesystem.read("switch.log") or "" +check(log:find("focus f=false hasFocus=false visible=true", 1, true) ~= nil, + "focus loss lands in the ring with hasFocus and visible") +check(log:find("focus f=true hasFocus=true visible=true", 1, true) ~= nil, + "focus gain lands in the ring") +check(log:find("gamepadpressed button=a guid=030000005e040000 hf=false", 1, true) ~= nil, + "joystick lines carry hf=") +check(not log:find("fg=", 1, true), "fg= is Windows only") + +eq(PadHints.foreground("OS X"), nil, "foreground probe is Windows only") +PadHints._resetForTests() +local fgFfi = { + cdef = function() end, + C = { + GetForegroundWindow = function() return {} end, + GetWindowThreadProcessId = function(_, pid) pid[0] = 42 end, + GetCurrentProcessId = function() return 42 end, + }, + new = function() return {} end, +} +eq(PadHints.foreground("Windows", fgFfi), "ours", "our foreground window reads ours") +fgFfi.C.GetCurrentProcessId = function() return 7 end +eq(PadHints.foreground("Windows", fgFfi), "other", "another process reads other") +PadHints._resetForTests() +eq(PadHints.foreground("Windows", { cdef = function() end, C = {} }), nil, + "a missing user32 export is swallowed") + +love.filesystem.remove("switch-debug.txt") +love.filesystem.remove("switch.log") +SwitchDiagnostics._resetForTests() +PadHints._resetForTests() +love.window, love.joystick, love.system = savedWindow, savedJoystick, savedSystem + +T.finish("pad hints bug 2452") diff --git a/tests/engine/pikachu_thunderbolt_flash_2347.lua b/tests/engine/pikachu_thunderbolt_flash_2347.lua index 20106d60..128bc75c 100644 --- a/tests/engine/pikachu_thunderbolt_flash_2347.lua +++ b/tests/engine/pikachu_thunderbolt_flash_2347.lua @@ -28,10 +28,7 @@ Sound.moveSfxBusy = function() end Music.duckForFanfare = function(src) ducks[#ducks + 1] = src end local realExists = Assets.exists -Assets.exists = function(path) - if path:find("pikapic_", 1, true) then return true end - return realExists(path) -end +Assets.exists = function() return false end local tbAnim = { sound = "Battle_2F", pitch = 32, tempo = 128 } local game = { @@ -60,7 +57,8 @@ local function newOw() end local function runPress(ow, pressAt) - local log = { frames = 0, bgp = {}, skippableAt = {} } + local log = { frames = 0, bgp = {}, skippableAt = {}, pics = {}, lifts = {}, + close = {} } local guard = 0 while ow.emote and guard < 2000 do guard = guard + 1 @@ -71,8 +69,14 @@ local function runPress(ow, pressAt) log.frames = log.frames + 1 if e.pikaPic then log.pic = log.pic or e + local path, lift = PikachuFollower.picFrame(e) + log.pics[#log.bgp + 1] = path and path:match("([^/]+)%.png$") or false + log.lifts[#log.bgp + 1] = lift log.bgp[#log.bgp + 1] = e.bgp or false log.skippableAt[#log.skippableAt + 1] = e.skippable and true or false + if e.boltDone and e.frames > 0 then + log.close[#log.close + 1] = tostring(log.pics[#log.pics]) + end end if (e.skippable and pressed) or e.frames <= 0 then local done = e.onDone @@ -122,6 +126,33 @@ for i = pre + 81, #log.bgp do if log.bgp[i] ~= 0xE4 then tailLit = false end end check(tailLit, "the lit %11100100 BGP holds until the box closes") + +eq(log.pic.pikaPic, "assets/generated/pikachu/pikapic_25.png", + "the base pic is the cache rip even when Assets.exists says no (no fallback)") +local function runOf(from, to, want) + for i = from, to do + if log.pics[i] ~= want or log.lifts[i] ~= 0 then return false end + end + return true +end +check(runOf(1, 17, "pikapic_25"), "ticks 0-5: the base frown Pic_e77cf alone (pikaframedelay 6)") +check(runOf(18, 35, "gfx_e7863"), "ticks 6-11: PikaAnimTilemap_9 draws GFX_e7863") +check(runOf(36, pre, "gfx_e79f3"), "tick 12 on: PikaAnimTilemap_10 draws GFX_e79f3") +local strobePose = true +for i = pre + 1, pre + 80 do + if log.pics[i] ~= "gfx_e79f3" or log.lifts[i] ~= 0 then strobePose = false end +end +check(strobePose, "the Thunderbolt pose GFX_e79f3 holds through every strobe frame, unlifted") +local tailPose, blank = true, 0 +for i = pre + 81, #log.pics do + if log.pics[i] == false then blank = blank + 1 + elseif blank > 0 or log.pics[i] ~= "gfx_e79f3" then tailPose = false end +end +check(tailPose, "and through the lit tail") +check(blank >= 1, "the box is emptied before it closes (.RunPikapic PlacePikapicTextBoxBorder)") +eq(table.concat(log.close, ",", 1, math.min(#log.close, 6)) .. "|" .. #log.close, + "gfx_e79f3,gfx_e79f3,gfx_e79f3,false,false,false|6", + "PlacePikapicTextBoxBorder: the pose stays up for the first Delay3, the empty box for the second") eq(log.skippableAt[45], true, "A/B can still cut the pic before the bolt") eq(log.skippableAt[46], false, "the bolt itself is not skippable") check(not ducks[1].isPlaying(), "the mute releases once the box closes") @@ -134,7 +165,7 @@ eq(ow.emote and ow.emote.bubble, 4, "a second talk inside the window replays Pik busyFrames = 30 log = runPress(ow) eq(#moveCalls, 1, "the second talk bolts again") -eq(#log.bgp - (46 + 80), 30 + 4, "WaitForSoundToFinish holds the box until the sound ends") +eq(#log.bgp - (46 + 80), 30 + 7, "WaitForSoundToFinish holds the box until the sound ends") moveCalls, ducks = {}, {} ow = newOw() @@ -153,6 +184,72 @@ log = runPress(ow) eq(#moveCalls, 0, "a plain mood talk has no bolt") check(log.pic == nil or log.pic.boltAt == nil, "and no bolt spec") +local function poseAt(e, tick) + e.frames = e.pikaTotal - tick * 3 + local path, lift = PikachuFollower.picFrame(e) + return path and path:match("([^/]+)%.png$") or false, lift +end +game.save.pikachuHappiness, game.save.pikachuMood = 255, 255 +ow = newOw() +PikachuFollower.talk(game, ow, newNpc(), function() end) +local e20 = ow.emote +check(e20 and e20.pikaPoses ~= nil, "emotion 20 plays PikaPicAnimBGFrames_26 from ripped poses") +if e20 and e20.pikaPoses then + eq(poseAt(e20, 0), "pikapic_20", "script 20 opens on its base pic (pikaframedelay 8)") + eq(poseAt(e20, 8), "gfx_e6646", "tick 8: PikaAnimTilemap_34 draws GFX_e6646") + eq(poseAt(e20, 20), "pikapic_20", "tick 20: back to the base") + eq(poseAt(e20, 28), "gfx_e6646", "tick 28: the pose again") + eq(poseAt(e20, 40), "pikapic_20", "tick 40: the frameset restarts at pikaframeend") + local _, lift = poseAt(e20, 8) + eq(lift, 0, "a ripped pose is drawn in place, not lifted") + eq(PikachuFollower.picLift(e20), 0, "picLift stays 0 for a pose script") +end + +local P = PikachuFollower.PIKAPIC +local function fakeEmote(script) + local a = P[script] + local poses + if a.poses then + poses = {} + for i = 1, #a.seq do + poses[i] = a.poses[i] and ("assets/generated/pikachu/gfx_" .. a.poses[i] .. ".png") or false + end + end + return { pikaPic = "assets/generated/pikachu/pikapic_" .. script .. ".png", + pikaSeq = a.seq, pikaPoses = poses, pikaTotal = a.dur * 3, frames = a.dur * 3 } +end +local e21 = fakeEmote(21) +eq(poseAt(e21, 8), "gfx_e682f", "script 21 tick 8: PikaAnimTilemap_9 -> GFX_e682f") +eq(poseAt(e21, 10), "gfx_e69bf", "script 21 tick 10: PikaAnimTilemap_10 -> GFX_e69bf") +eq(poseAt(e21, 11), "gfx_e6b4f", "script 21 tick 11: PikaAnimTilemap_11 -> GFX_e6b4f") +eq(poseAt(e21, 12), "gfx_e6cdf", "script 21 tick 12: PikaAnimTilemap_12 -> GFX_e6cdf") +local e26 = fakeEmote(26) +eq(poseAt(e26, 56), "gfx_e7b83", "script 26 tick 56: GFX_e7b83") +eq(poseAt(e26, 64), "gfx_e7d13", "script 26 tick 64: GFX_e7d13 holds") +local e7 = fakeEmote(7) +eq(poseAt(e7, 0), "gfx_e4841", "script 7 opens on PikaAnimTilemap_20 (GFX_e4841)") +eq(poseAt(e7, 8), "pikapic_7", "script 7 tick 8: base") +local e4 = fakeEmote(4) +local p4, l4 = poseAt(e4, 8) +check(p4 == "pikapic_4" and l4 > 0, "script 4 (20-tile GFX_e444b overlay) keeps the picLift stand-in") + +local CacheContract = require("src.import.CacheContract") +local required = {} +for _, path in ipairs(CacheContract.VERSION_REQUIRED_FILES.yellow or {}) do required[path] = true end +local listed, missing = 0, {} +for script, a in pairs(P) do + for _, id in ipairs(a.poses or {}) do + if id then + local path = "assets/generated/pikachu/gfx_" .. id .. ".png" + if required[path] then listed = listed + 1 else missing[#missing + 1] = script .. ":" .. id end + end + end + if a.poses then eq(#a.poses, #a.seq, "script " .. script .. " has one pose slot per run") end +end +check(listed > 0, "the frameset table names ripped poses") +check(#missing == 0, "every pose the frameset table draws is a required yellow cache file " + .. table.concat(missing, ",")) + Sound.playMove, Sound.playPikaCry, Sound.playCry = realPlayMove, realPikaCry, realCry Sound.moveSfxBusy, Music.duckForFanfare = realBusy, realDuck Assets.exists = realExists diff --git a/tests/engine/present_sync_logic.lua b/tests/engine/present_sync_logic.lua index 79a89754..b5809c3c 100644 --- a/tests/engine/present_sync_logic.lua +++ b/tests/engine/present_sync_logic.lua @@ -125,13 +125,18 @@ FixedStep.maxAccum = FixedStep.catchupLimit(2) FixedStep:update(1 / 60, 2) T.eq(steps, 2, "and 2X runs two") --- Catch-up debt hard ceiling: high speed cannot raise maxAccum past MAX_ACCUM. T.eq(FixedStep.catchupLimit(1), FixedStep.STEP * 2, "1X catch-up floor is two steps") T.eq(FixedStep.catchupLimit(4), 4 * FixedStep.STEP * 1.5, "4X catch-up matches one frame of target work") -T.eq(FixedStep.catchupLimit(200), FixedStep.MAX_ACCUM, - "200X catch-up is hard-capped at MAX_ACCUM (no input-starving spiral)") +T.eq(FixedStep.catchupLimit(200), 200 * FixedStep.STEP * 1.5, + "200X catch-up scales with speed") +T.check(FixedStep.catchupLimit(200) > FixedStep.MAX_ACCUM, + "200X catch-up is not pinned at MAX_ACCUM") +T.eq(FixedStep.catchupLimit(4, 1 / 30), 4 * (1 / 30) * 1.5, + "a 30fps frame buys two frames of target work") +T.eq(FixedStep.catchupLimit(4, 1 / 240), 4 * FixedStep.STEP * 1.5, + "a short frame keeps the one-step floor") FixedStep.refreshPeriod = nil love.window.getVSync, love.window.setVSync = nil, nil diff --git a/tests/engine/save_editor_options_2388.lua b/tests/engine/save_editor_options_2388.lua new file mode 100644 index 00000000..aebd96f8 --- /dev/null +++ b/tests/engine/save_editor_options_2388.lua @@ -0,0 +1,121 @@ +package.path = package.path .. ";./?.lua;./?/init.lua;./tools/save-editor/?.lua" + .. ";./tools/save-editor/panels/?.lua" + +love = require("tests.love_stub") + +local passed, failed = 0, 0 +local function check(cond, msg) + if cond then + passed = passed + 1 + else + failed = failed + 1 + print("FAIL: " .. msg) + end +end +local function eq(a, b, msg) + check(a == b, msg .. string.format(" (got %s, want %s)", tostring(a), tostring(b))) +end + +local SaveData = require("src.core.SaveData") +local SaveSerializer = require("src.core.SaveSerializer") +local SessionLifecycle = require("src.core.SessionLifecycle") +local FsIo = require("tests.fs_io") +local Ops = require("Ops") +local App = require("App") + +local fs = love.filesystem + +local ffi = require("ffi") +local setVar +if ffi.os == "Windows" then + ffi.cdef([[ int _putenv(const char *envstring); ]]) + setVar = function(name, value) ffi.C._putenv(name .. "=" .. value) end +else + ffi.cdef([[ + int setenv(const char *name, const char *value, int overwrite); + int unsetenv(const char *name); + ]]) + setVar = function(name, value) + if value == "" then ffi.C.unsetenv(name) else ffi.C.setenv(name, value, 1) end + end +end +local originalDataDir = os.getenv("POKEPORT_DATA_DIR") +setVar("POKEPORT_DATA_DIR", "tests/fixture_data") + +do + local opts = SaveData.loadOptions() + opts.battleLayout, opts.battleFit, opts.battleHud, opts.battleBg = "wide", "fixed", "extended", "world" + opts.colors, opts.tilt = "og", 2 + SaveData.saveOptions(opts) + local slotId = SaveData.createSlot("red") + check(slotId ~= nil, "a red slot is created") + SaveData.setActiveSlot("red", slotId) + local before = fs.read("options.lua") + check(type(before) == "string" and before:find("wide", 1, true) ~= nil, "options.lua holds WIDE before the edit") + local save = SaveData.newGame() + save.player.name = "ASH" + save.options = { battleLayout = "og", battleBg = "white", colors = "gbc", tilt = 0, videoMode = "windowed" } + check(SaveData.writeSlot("red", slotId, save) == true, "writeSlot writes the slot") + eq(save.options.battleLayout, "og", "writeSlot leaves the caller's table alone") + local slotBody = SaveData.readSlotSource("red", slotId) + check(slotBody ~= nil, "the slot reads back") + local slotTable = slotBody and SaveSerializer.decode(slotBody) + check(slotTable ~= nil and slotTable.options == nil, "writeSlot strips options from a gen1 slot") + eq(fs.read("options.lua"), before, "writeSlot does not touch options.lua") + + local tmpBase = os.tmpname() + local tmpPath = tmpBase .. "-options-2388.lua" + local f = io.open(tmpPath, "wb") + f:write(slotBody) + f:close() + App.load(tmpPath, { version = "red", slotId = slotId, embedded = true, onClose = function() end }) + local s = App.getState() + check(s ~= nil and s.loadError == false, "the editor opens the slot") + Ops.addMoney(s, 10) + s.save.pokedex = s.save.pokedex or {} + s.save.pokedex.owned = s.save.pokedex.owned or {} + s.save.pokedex.owned.PIKACHU = true + s.dirty = true + App.save() + eq(fs.read("options.lua"), before, "App.save leaves options.lua byte-identical") + pcall(SessionLifecycle.endEditorSession, { version = "red", app = App }) + eq(fs.read("options.lua"), before, "closing the editor leaves options.lua byte-identical") + + local rf = io.open(tmpPath, "rb") + local edited = rf and rf:read("*a") or "" + if rf then rf:close() end + local editedTable = SaveSerializer.decode(edited) + check(editedTable ~= nil, "the edited slot decodes") + check(editedTable and editedTable.options == nil, "the edited slot has no options key") + check(editedTable and editedTable.pokedex and editedTable.pokedex.owned + and editedTable.pokedex.owned.PIKACHU == true, "the dex edit landed") + + check(SaveData.writeSlot("red", slotId, editedTable) == true, "the edited slot writes back") + local loaded = SaveData.load("red") + check(loaded ~= nil, "the edited slot loads") + eq(loaded and loaded.options and loaded.options.battleLayout, "wide", "battle layout stays WIDE after an edit") + eq(loaded and loaded.options and loaded.options.battleBg, "world", "battle bg stays WORLD after an edit") + eq(loaded and loaded.options and loaded.options.battleHud, "extended", "battle hud stays EXTENDED after an edit") + eq(loaded and loaded.options and loaded.options.colors, "og", "colors stay OG after an edit") + eq(fs.read("options.lua") and SaveSerializer.decode(fs.read("options.lua")).battleLayout, "wide", + "options.lua still says WIDE after the load") + + os.remove(tmpPath) + os.remove(tmpBase) + for _, bak in ipairs(FsIo.globPrefix(tmpPath .. ".bak-")) do os.remove(bak) end +end + +do + local slotId = SaveData.createSlot("firered") + if slotId then + local save = { engine = "game3", version = "firered", name = "RED", options = { textSpeed = 1, frameType = 3 } } + check(SaveData.writeSlot("firered", slotId, save) == true, "writeSlot writes a firered slot") + local back = SaveSerializer.decode(SaveData.readSlotSource("firered", slotId) or "") + eq(back and back.options and back.options.frameType, 3, "a gen3 slot keeps its in-save options block") + end +end + +setVar("POKEPORT_DATA_DIR", originalDataDir or "") + +print(string.format("save editor options 2388: %d passed, %d failed", passed, failed)) +if failed > 0 then os.exit(1) end diff --git a/tests/engine/save_export_same_map_moved_2468.lua b/tests/engine/save_export_same_map_moved_2468.lua new file mode 100644 index 00000000..1de997d7 --- /dev/null +++ b/tests/engine/save_export_same_map_moved_2468.lua @@ -0,0 +1,130 @@ +-- home/overworld.asm:1809 +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local MapContext = require("src.save_convert.MapContext") +local GenSave = require("src.save_convert.GenSave") + +local O = MapContext.OFFSETS +local SAVE = GenSave.OFFSETS +local stampMapWindow = loadfile("tests/fixture_data/map_window.lua")() + +GenSave.setCharmap(loadfile("src/save_convert/data/charmap.lua")()) + +local GRASS = {} +for i = 1, 10 do + GRASS[i] = { level = 2 + i, species = (i % 2 == 1) and "FIXMON_A" or "FIXMON_C" } +end +local WATER = {} +for i = 1, 10 do WATER[i] = { level = 20 + i, species = "FIXMON_B" } end + +local function fixtureData() + local maps = {} + for id, map in pairs(dofile("tests/fixture_data/maps.lua")) do + local copy = {} + for k, v in pairs(map) do copy[k] = v end + maps[id] = copy + end + local data = { + maps = maps, + pokemon = dofile("tests/fixture_data/pokemon.lua"), + moves = dofile("tests/fixture_data/moves.lua"), + items = dofile("tests/fixture_data/items.lua"), + encounters = { + FIX_TOWN = { grass = { rate = 25, slots = GRASS }, water = { rate = 5, slots = WATER } }, + }, + } + data = stampMapWindow(data, "FIX_TOWN") + data.maps.FIX_TOWN.sram.objects = { + 0x0E, 0, 0, + 1, 0x01, 0x14, 0x15, 0xFF, 0xD0, 0x05, + } + return data +end + +local data = fixtureData() + +local function newSave(x, y) + return { + player = { name = "RED", rival = "BLUE", map = "FIX_TOWN", x = x, y = y }, + money = 3000, inventory = {}, pokedex = { seen = {}, owned = {} }, + flags = {}, party = {}, boxes = {}, + } +end + +local function mainByte(bytes, off) return bytes:byte(SAVE.mainData + off + 1) end + +do + local ctx = assert(MapContext.build(data, "FIX_TOWN", 7, 4)) + T.eq(ctx.writes[O.grassRate][1], 25, "wGrassRate is the map's grass rate") + T.eq(#ctx.writes[O.grassMons], 20, "wGrassMons is 10 level/species rows") + T.eq(ctx.writes[O.grassMons][1], 3, "grass slot 1 level") + T.eq(ctx.writes[O.grassMons][2], 1, "grass slot 1 species index") + T.eq(ctx.writes[O.grassMons][4], 3, "grass slot 2 species index") + T.eq(ctx.writes[O.waterRate][1], 5, "wWaterRate is the map's water rate") + T.eq(ctx.writes[O.waterMons][1], 21, "water slot 1 level") + T.eq(ctx.writes[O.waterMons][2], 2, "water slot 1 species index") + T.eq(O.grassRate, 0xD886 - 0xD2F6, "wGrassRate offset matches the Yellow sym") + T.eq(O.waterMons, 0xD8A4 - 0xD2F6, "wWaterMons offset matches the Yellow sym") + T.eq(O.grassRate, 0xD887 - 0xD2F7, "wGrassRate offset matches the Red sym") + T.eq(O.waterMons, 0xD8A5 - 0xD2F7, "wWaterMons offset matches the Red sym") +end + +do + local d = fixtureData() + d.encounters = {} + local ctx = assert(MapContext.build(d, "FIX_TOWN", 7, 4)) + T.eq(ctx.writes[O.grassRate][1], 0, "a map with no wild data writes grass rate 0") + T.eq(ctx.writes[O.waterRate][1], 0, "a map with no wild data writes water rate 0") + T.eq(ctx.writes[O.grassMons], nil, "no grass rows when the rate is 0") + d.encounters = nil + local none, why = MapContext.build(d, "FIX_TOWN", 7, 4) + T.eq(none, nil, "a data set with no encounter table refuses the build") + T.eq(type(why) == "string" and why:find("re%-import") ~= nil, true, "and says to re-import the ROM") +end + +do + local fresh = GenSave.encode(newSave(7, 4), data, nil) + T.eq(mainByte(fresh, O.grassRate), 25, "a templateless export carries the grass rate") + T.eq(mainByte(fresh, O.grassMons + 1), 1, "a templateless export carries the grass mons") + T.eq(mainByte(fresh, O.waterRate), 5, "a templateless export carries the water rate") + T.eq(GenSave.mainChecksumValid(fresh), true, "wild data sits inside a valid checksum") + + local tpl = {} + for i = 1, #fresh do tpl[i] = fresh:sub(i, i) end + tpl[SAVE.spriteData + 17] = string.char(0x77) + tpl[SAVE.mainData + O.grassRate + 1] = string.char(0x44) + local template = table.concat(tpl) + + local same = GenSave.encode(newSave(7, 4), data, template) + T.eq(same:byte(SAVE.spriteData + 17), 0x77, "an unmoved template keeps its live sprite window") + T.eq(mainByte(same, O.grassRate), 0x44, "an unmoved template keeps its own wild data") + + local moved = GenSave.encode(newSave(9, 6), data, template) + local ctx = assert(MapContext.build(data, "FIX_TOWN", 9, 6)) + local view = ctx.writes[O.viewPointer] + T.eq(mainByte(moved, O.yCoord), 6, "moved export writes the new y") + T.eq(mainByte(moved, O.xCoord), 9, "moved export writes the new x") + T.eq(mainByte(moved, O.viewPointer), view[1], "moved export rebuilds the view pointer (lo)") + T.eq(mainByte(moved, O.viewPointer + 1), view[2], "moved export rebuilds the view pointer (hi)") + T.eq(mainByte(moved, O.yBlockCoord), 0, "moved export rebuilds wYBlockCoord") + T.eq(mainByte(moved, O.xBlockCoord), 1, "moved export rebuilds wXBlockCoord") + for i = 1, 512 do + if moved:byte(SAVE.spriteData + i) ~= ctx.spriteData[i] then + T.eq(moved:byte(SAVE.spriteData + i), ctx.spriteData[i], ("moved export sprite byte %d"):format(i)) + break + end + end + T.eq(moved:byte(SAVE.spriteData + 17), ctx.spriteData[17], "moved export drops the stale sprite window") + T.eq(mainByte(moved, O.grassRate), 25, "moved export reloads the grass rate") + T.eq(GenSave.mainChecksumValid(moved), true, "moved export checksum is valid") + + local frac = GenSave.encode(newSave(7.4, 4.9), data, template) + T.eq(frac:byte(SAVE.spriteData + 17), 0x77, "fractional coords floor onto the template's own spot") + T.eq(mainByte(frac, O.yCoord), 4, "fractional y floors") + T.eq(mainByte(frac, O.xCoord), 7, "fractional x floors") + local movedX = GenSave.encode(newSave(8, 4), data, template) + T.eq(mainByte(movedX, O.xBlockCoord), 0, "an x-only move rebuilds the window too") +end + +T.finish("save_export_same_map_moved_2468") diff --git a/tests/engine/save_import_retry_bug420.lua b/tests/engine/save_import_retry_bug420.lua index f638e91e..60abe0f8 100644 --- a/tests/engine/save_import_retry_bug420.lua +++ b/tests/engine/save_import_retry_bug420.lua @@ -136,7 +136,7 @@ local SaveConvert = require("src.save_convert.SaveConvert") -- The audio entry is single-quoted on purpose: gate_meta_coverage.lua treats a -- double-quoted registry name anywhere in the test corpus as that registry's -- unit test, and this suite is not the mod audio registry's. -local GENERATED = { "pokemon", "moves", "items", "maps", "tilesets", 'audio' } +local GENERATED = { "pokemon", "moves", "items", "maps", "tilesets", 'audio', 'encounters' } local function prefixes() local seen = {} diff --git a/tests/engine/save_map_context_bug889.lua b/tests/engine/save_map_context_bug889.lua index f0b9f6f3..455fcd45 100644 --- a/tests/engine/save_map_context_bug889.lua +++ b/tests/engine/save_map_context_bug889.lua @@ -66,6 +66,7 @@ local function fixtureData() moves = dofile("tests/fixture_data/moves.lua"), items = dofile("tests/fixture_data/items.lua"), tilesets = { [maps.FIX_TOWN.tileset] = { header = TILESET_HEADER } }, + encounters = {}, audio = { -- Song ids are computed from the header address -- (constants/music_constants.asm): (address - $4000) / 3. diff --git a/tests/engine/speed_lock_test.lua b/tests/engine/speed_lock_test.lua new file mode 100644 index 00000000..7903b8b2 --- /dev/null +++ b/tests/engine/speed_lock_test.lua @@ -0,0 +1,188 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end + +local T = require("tests.harness") +local check, eq = T.check, T.eq + +local FixedStep = require("src.core.FixedStep") +local GameSpeed = require("src.core.GameSpeed") +local Hooks = require("src.mods.Hooks") +local Runtime = require("src.mods.Runtime") +local Game = require("src.core.Game") +local Game2 = require("src.core.Game2") +local Game3 = require("src.core.Game3") + +local function stack(...) return { states = { ... } } end +local unpack = table.unpack or unpack + +local battle = { isBattle = true } +local overworld = { isOverworld = true } + +local function gen1(states, opts) + return setmetatable({ + save = { options = opts or { speedOverworld = 200, speedBattle = 200, speedMenu = 200 } }, + stack = stack(unpack(states)), + writeOptions = function() end, + }, { __index = Game }) +end + +do + local g = gen1({ overworld }) + eq(g:logicSpeed(), 200, "gen1 overworld runs the saved 200X") + check(not g:speedLocked(), "gen1 overworld is not locked") + g = gen1({ overworld, battle }) + eq(g:logicSpeed(), 1, "gen1 battle is 1X at speedBattle 200") + g.speedOverride = 200 + eq(g:logicSpeed(), 1, "gen1 battle ignores speedOverride (--speed, skin FF hold)") + local bus = Hooks.new() + local saved = Runtime.hooks + Runtime.hooks = bus + local ran = false + local unsub = bus:wrap("core.logic_speed", function() ran = true; return 200 end) + g.speedOverride = nil + eq(g:logicSpeed(), 1, "gen1 battle ignores a core.logic_speed hook returning 200") + check(not ran, "the hook is never called while locked") + local ow = gen1({ overworld }) + eq(ow:logicSpeed(), 200, "outside battle the hook still runs") + check(ran, "...and was called") + unsub() + Runtime.hooks = saved +end + +do + GameSpeed.setAllowed({ 2, 4 }) + local g = gen1({ overworld, battle }) + eq(g:logicSpeed(), 1, "a cart ladder without 1 still locks to a literal 1") + eq(gen1({ overworld }, { speedOverworld = 4 }):logicSpeed(), 4, + "and outside battle the ladder applies") + GameSpeed.setAllowed(nil) +end + +do + local g = gen1({ overworld, battle }, + { speedOverworld = 1, speedBattle = 1, speedMenu = 1 }) + g:_cycleSpeed(1) + g:_cycleSpeed(-1) + g:_cycleSpeed(1) + eq(g.save.options.speedBattle, 1, "gen1 speed presses in battle leave speedBattle") + eq(g.save.options.speedOverworld, 1, "and speedOverworld") + g:touchSkinHotkey("fast_forward_toggle", true) + eq(g.save.options.speedBattle, 1, "gen1 skin FF toggle in battle is ignored") + g:touchSkinHotkey("fast_forward_hold", true) + eq(g:logicSpeed(), 1, "gen1 skin FF hold in battle stays 1X") + g:touchSkinHotkey("fast_forward_hold", false) + eq(g.speedOverride, nil, "and releasing restores the pre-hold override") +end + +do + local g = gen1({ overworld }) + g.linkSession = true + eq(g:logicSpeed(), 1, "gen1 link session is 1X") + g.linkSession = nil + g.linkNet = { closed = false } + eq(g:logicSpeed(), 1, "gen1 open linkNet (arena, tournament, spectate) is 1X") +end + +local function gen2(fields) + fields.persistOptions = fields.persistOptions or function() end + return setmetatable(fields, { __index = Game2 }) +end + +do + eq(gen2({ options = { speed = 200 }, stack = stack({}) }):logicSpeed(), 200, + "gen2 overworld runs GAME SPEED") + eq(gen2({ options = { speed = 200 }, stack = stack({}, battle) }):logicSpeed(), 1, + "gen2 battle is 1X") + eq(gen2({ options = { speed = 200 }, speedOverride = 200, + stack = stack({}, battle) }):logicSpeed(), 1, + "gen2 battle ignores speedOverride") + eq(gen2({ options = { speed = 200 }, linkNet = { closed = false } }):logicSpeed(), 1, + "gen2 online arena / LinkBattle2 linkNet is 1X") + eq(gen2({ options = { speed = 200 }, linkSession = true }):logicSpeed(), 1, + "gen2 tournament / spectate linkSession is 1X") + local g = gen2({ options = { speed = 1 }, stack = stack({}, battle) }) + g:_cycleSpeed(1) + eq(g.options.speed, 1, "gen2 pad speed press in battle is ignored") + g.stack = stack({}) + g.linkNet = { closed = false } + g:_cycleSpeed(1) + eq(g.options.speed, 1, "gen2 pad speed press over a link is ignored") +end + +local battleActive = false +package.loaded["src.core.game3.battle"] = { + isActive = function() return battleActive end, +} + +local function gen3() + return setmetatable({ + phase = "field", + options = { speedOverworld = 200, speedBattle = 200, speedMenu = 200 }, + writeOptions = function() end, + }, { __index = Game3 }) +end + +do + local g = gen3() + eq(g:logicSpeed(), 200, "gen3 field runs OVERWORLD SPEED") + battleActive = true + eq(g:logicSpeed(), 1, "gen3 battle is 1X") + g.speedOverride = 200 + eq(g:logicSpeed(), 1, "gen3 battle ignores speedOverride") + g.speedOverride = nil + g:_cycleSpeed(1) + eq(g.options.speedBattle, 200, "gen3 speed press in battle is ignored") + battleActive = false + package.loaded["src.core.game3.link.union_room"] = { isActive = function() return true end } + eq(g:logicSpeed(), 1, "gen3 Union Room is 1X") + package.loaded["src.core.game3.link.union_room"] = nil + package.loaded["src.core.game3.map"] = { current = "FR_UNION_ROOM" } + eq(g:logicSpeed(), 1, "gen3 Union Room map is 1X") + package.loaded["src.core.game3.map"] = nil + eq(g:logicSpeed(), 200, "and the field is back to 200X") +end + +local function runFrame(g, lockAt) + local steps = 0 + local savedInit = { accum = FixedStep.accum, callback = FixedStep.callback } + FixedStep.clock = function() return 0 end + FixedStep:init(function() + steps = steps + 1 + if steps == lockAt then battleActive = true; g.stack = stack(overworld, battle) end + g:_speedLockEdge() + end) + local speed = g:logicSpeed() + g._frameSpeed = speed + FixedStep.maxAccum = FixedStep.catchupLimit(speed, FixedStep.STEP) + FixedStep:update(FixedStep.STEP, speed) + FixedStep.clock = nil + FixedStep.accum, FixedStep.callback = savedInit.accum, savedInit.callback + return steps, speed +end + +do + battleActive = false + local g = gen3() + local steps, speed = runFrame(g, 3) + eq(speed, 200, "the frame started unlocked at 200X") + eq(steps, 3, "gen3: a battle starting mid-frame ends the frame on that step") + battleActive = false + + local g1 = gen1({ overworld }) + steps = runFrame(g1, 5) + eq(steps, 5, "gen1: a battle push mid-frame ends the frame on that step") + eq(g1:logicSpeed(), 1, "and the next frame samples 1X") + + local g2 = gen2({ options = { speed = 200 }, stack = stack({}) }) + steps = runFrame(g2, 2) + eq(steps, 2, "gen2: a battle push mid-frame ends the frame on that step") + battleActive = false + + local plain = gen1({ overworld }) + steps = runFrame(plain, -1) + check(steps > 100, "without a lock flip the 200X frame runs its steps") +end + +package.loaded["src.core.game3.battle"] = nil + +T.finish("speed lock") diff --git a/tests/engine/sync_session_meta_test.lua b/tests/engine/sync_session_meta_test.lua index 36bf7a65..39c257c2 100644 --- a/tests/engine/sync_session_meta_test.lua +++ b/tests/engine/sync_session_meta_test.lua @@ -32,6 +32,13 @@ end do local plain = SaveData.buildMeta({}) + T.eq(plain.modCount, 0, "vanilla saves explicitly record zero mods") + local modded = SaveData.buildMeta({ { id = "one" }, { id = "two" } }) + T.eq(modded.modCount, 2, "the count reflects the mods that wrote the save") + T.eq(SaveData.buildMeta(nil, modded).modCount, 2, + "a headless rewrite preserves the saved mod count") + T.eq(SaveData.buildMeta({}, modded).modCount, 0, + "disabling all mods clears the previous count") T.check(type(plain.savedAt) == "number", "a save still records when it ended") T.eq(plain.sessionStart, nil, "and records no session start when nobody supplied one") @@ -106,9 +113,22 @@ do T.eq(entries[1].meta.summary.name, "ASH", "with the launcher summary the conflict prompt shows") T.check(entries[1].meta.sessionStart ~= nil, "and the session start") + T.eq(entries[1].meta.modCount, 0, "sync metadata explicitly reports vanilla") + T.eq(SaveData.decode(entries[1].blob).meta.modCount, 0, + "the saved blob also carries the count") T.check(entries[1].blob:find("ASH", 1, true) ~= nil, "the blob is the encoded save itself") + save.meta.mods = { { id = "one" }, { id = "two" } } + save.meta.modCount = nil + SaveData.writeSlot("red", slotId, save) + T.eq(provider.list()[1].meta.modCount, 2, + "older saves derive their sync count from their own mod list") + save.meta.modCount = 99 + SaveData.writeSlot("red", slotId, save) + T.eq(provider.list()[1].meta.modCount, 2, + "a stale count cannot override the saved mod list") + local other = SaveData.newGame() other.player.name = "BLUE" other.meta = SaveData.buildMeta({}, { playthroughId = "xyz" }, os.time() - 30) diff --git a/tests/fixture_data/map_window.lua b/tests/fixture_data/map_window.lua index 59d21281..964c4f44 100644 --- a/tests/fixture_data/map_window.lua +++ b/tests/fixture_data/map_window.lua @@ -26,5 +26,6 @@ return function(data, mapId) data.audio.mapSongs[mapId] = data.audio.mapSongs[mapId] or "Music_Fixture" data.audio.songs.Music_Fixture = data.audio.songs.Music_Fixture or { address = 0x4000 + 3 * 0xBD, bank = 2 } + data.encounters = data.encounters or {} return data end diff --git a/tests/g3link_fake_relay.lua b/tests/g3link_fake_relay.lua index 5be08448..dec6bd13 100644 --- a/tests/g3link_fake_relay.lua +++ b/tests/g3link_fake_relay.lua @@ -305,7 +305,7 @@ function FakeRelay.client(opts) rec("setStatus", status) C.status = status end - function C.joinPlaza(kind, profile, avatar) rec("joinPlaza", kind, profile, avatar) end + function C.joinPlaza(kind, profile, avatar, cap) rec("joinPlaza", kind, profile, avatar, cap) end function C.leavePlaza(kind) rec("leavePlaza", kind) end function C.plaza() return C._plaza end function C.plazaCounts() return C._counts end diff --git a/tests/game3_battle_exp_participants_test.lua b/tests/game3_battle_exp_participants_test.lua index f07e095f..5cd33eef 100644 --- a/tests/game3_battle_exp_participants_test.lua +++ b/tests/game3_battle_exp_participants_test.lua @@ -101,6 +101,121 @@ do check(awards2[1].amount > awards1[1].amount, "p3 receives full undivided EXP for e2") end +print("\n=== 2b. Live shift step list: player swaps before enemy, only new mon earns EXP ===") +do + local Adapter = require("src.core.game3.battle.adapter") + local p1 = Damage.ensureStats({ species = 1, level = 10, hp = 30, maxHp = 30, exp = 1000 }) + local p2 = Damage.ensureStats({ species = 4, level = 10, hp = 30, maxHp = 30, exp = 1000 }) + local e1 = Damage.ensureStats({ species = 16, level = 10, hp = 0, maxHp = 30, exp = 1000 }) + local e2 = Damage.ensureStats({ species = 19, level = 10, hp = 30, maxHp = 30, exp = 1000 }) + + local st = State.new({ + playerParty = { p1, p2 }, + foeParty = { e1, e2 }, + foeMon = e1, + wild = false, + }) + local ad = Adapter.new(st, function() end) + + eq(SwitchSeq.beginShiftSwitch(st, 2, 2, { headless = false }), true, "live shift builds a step list") + local swaps = {} + for _, step in ipairs(SwitchSeq._steps or {}) do + if step.kind == "swap_data" then swaps[#swaps + 1] = step.data end + end + eq(#swaps, 2, "shift step list has two swap_data rows") + eq(swaps[1].side, "player", "player swap_data runs before the enemy send-out") + eq(swaps[2].side, "enemy", "enemy swap_data runs second") + SwitchSeq.reset() + + for _, d in ipairs(swaps) do + Engine.performSwitch(st, ad, d.side, d.newSlot, + { reason = d.reason or "switch", isShift = d.isShift }) + end + eq(st.player.partyIndex, 2, "p2 is active after live shift") + eq(st.enemy.partyIndex, 2, "e2 is active after live shift") + eq(st.enemy.participants[1], nil, "withdrawn p1 is NOT participant on e2 (live path)") + eq(st.enemy.participants[2], true, "p2 is participant on e2 (live path)") + + local awards = Experience.awardFoe(st, st.enemy, { trainer = true }) + eq(#awards, 1, "only p2 receives EXP for e2 (live path)") + eq(awards[1].partyIndex, 2, "live-path award goes to p2") +end + +print("\n=== 2d. Shift switch-in effects: player Spikes before the enemy send-out, Intimidate hits the new foe ===") +do + local Adapter = require("src.core.game3.battle.adapter") + local Hazards = require("src.core.game3.battle.effects.hazards") + local p1 = Damage.ensureStats({ species = 1, level = 10, hp = 30, maxHp = 30, exp = 1000 }) + local p2 = Damage.ensureStats({ species = 58, level = 10, hp = 32, maxHp = 32, exp = 1000, ability = 22 }) + local e1 = Damage.ensureStats({ species = 16, level = 10, hp = 0, maxHp = 30, exp = 1000 }) + local e2 = Damage.ensureStats({ species = 19, level = 10, hp = 30, maxHp = 30, exp = 1000, ability = 50 }) + + local st = State.new({ + playerParty = { p1, p2 }, + foeParty = { e1, e2 }, + foeMon = e1, + wild = false, + }) + st.trainerClassName, st.trainerName = "YOUNGSTER", "BEN" + local savedAd = Battle._adapter + Battle._adapter = Adapter.new(st, function() end) + Hazards.set(st.playerSide, 1) + local log = {} + SwitchSeq.beginShiftSwitch(st, 2, 2, { headless = true, pushMsg = function(text) + log[#log + 1] = tostring(text):lower() + end }) + Battle._adapter = savedAd + + local spikesAt, sentAt, intimAt + for i, t in ipairs(log) do + if t:find("spikes") then spikesAt = spikesAt or i end + if t:find("sent") then sentAt = sentAt or i end + if t:find("cuts") then intimAt = intimAt or i end + end + check(spikesAt ~= nil and sentAt ~= nil and intimAt ~= nil, "spikes, send-out and Intimidate messages pushed") + check(spikesAt < sentAt, "player Spikes resolve before the enemy sends out") + eq(st.player.mon.hp, 28, "player took 1/8 Spikes damage") + check(intimAt > sentAt, "Intimidate resolves after the new foe is out") + eq(st.enemy.stages.attack, -1, "new foe's attack cut by the shifted-in Intimidate") + + eq(SwitchSeq.beginShiftSwitch(st, 1, 2, { headless = false }), true, "live shift builds a step list") + local entry, enemySwap = {}, nil + for i, step in ipairs(SwitchSeq._steps or {}) do + if step.kind == "entry_triggers" then entry[#entry + 1] = { i = i, d = step.data } end + if step.kind == "swap_data" and step.data.side == "enemy" then enemySwap = { i = i, d = step.data } end + end + SwitchSeq.reset() + eq(#entry, 2, "two entry_triggers steps") + eq(entry[1].d.side, "player", "first entry step is the player's") + check(entry[1].d.deferIntimidate, "player entry step defers Intimidate") + check(entry[1].i < enemySwap.i, "player entry step runs before the enemy swap_data") + eq(entry[2].d.side, "enemy", "second entry step is the enemy's") + check(entry[2].i > enemySwap.i, "enemy entry step runs after the enemy swap_data") + eq(enemySwap.d.reason, "shift", "enemy swap_data keeps reason shift") +end + +print("\n=== 2c. Shift performSwitch resets sent mons even when the enemy swaps first ===") +do + local Adapter = require("src.core.game3.battle.adapter") + local p1 = Damage.ensureStats({ species = 1, level = 10, hp = 30, maxHp = 30, exp = 1000 }) + local p2 = Damage.ensureStats({ species = 4, level = 10, hp = 30, maxHp = 30, exp = 1000 }) + local e1 = Damage.ensureStats({ species = 16, level = 10, hp = 0, maxHp = 30, exp = 1000 }) + local e2 = Damage.ensureStats({ species = 19, level = 10, hp = 30, maxHp = 30, exp = 1000 }) + + local st = State.new({ + playerParty = { p1, p2 }, + foeParty = { e1, e2 }, + foeMon = e1, + wild = false, + }) + local ad = Adapter.new(st, function() end) + Engine.performSwitch(st, ad, "enemy", 2, { reason = "switch" }) + eq(st.enemy.participants[1], true, "p1 tracked on e2 while still active") + Engine.performSwitch(st, ad, "player", 2, { reason = "shift", isShift = true }) + eq(st.enemy.participants[1], nil, "shift switch drops p1 from e2's sent mons") + eq(st.enemy.participants[2], true, "shift switch leaves only p2 on e2") +end + print("\n=== 3. Enemy AI switch resets participant tracking ===") do local p1 = Damage.ensureStats({ species = 1, level = 10, hp = 30, maxHp = 30, exp = 1000 }) diff --git a/tests/game3_battle_switch_and_faint_test.lua b/tests/game3_battle_switch_and_faint_test.lua index e77e452f..1640fcb4 100644 --- a/tests/game3_battle_switch_and_faint_test.lua +++ b/tests/game3_battle_switch_and_faint_test.lua @@ -399,8 +399,8 @@ do eq(st.player.partyIndex, 2, "player active slot is 2") eq(st.enemy.partyIndex, 2, "enemy active slot is 2") check(logged[1]:find("that's enough!"), "step 1: player recall message") - check(logged[2]:find("sent\nout"), "step 2: enemy sendout message (BEFORE player sendout)") - check(logged[3]:find("Go!"), "step 3: player sendout message (AFTER enemy sendout)") + check(logged[2]:find("Go!"), "step 2: player sendout message (BEFORE enemy sendout)") + check(logged[3]:find("sent\nout"), "step 3: enemy sendout message (AFTER player sendout)") -- Non-headless step ordering test SwitchSeq.beginShiftSwitch(st, 2, 2, { headless = false, pushMsg = pushMsg }) @@ -419,16 +419,16 @@ do local iWithdraw = find_step("withdraw") local iEnemySend = find_step("sendout_enemy") local iPlayerSend = find_step("sendout_player") - local iEntryTriggers = find_step("entry_triggers") + local iEntryTriggers = find_step("entry_triggers", iEnemySend) check(iWithdraw ~= nil, "has withdraw step") check(iEnemySend ~= nil, "has sendout_enemy step") check(iPlayerSend ~= nil, "has sendout_player step") check(iEntryTriggers ~= nil, "has entry_triggers step") - check(iWithdraw < iEnemySend, "player withdraws BEFORE enemy sends out") - check(iEnemySend < iPlayerSend, "enemy sends out BEFORE player sends out (retail FRLG)") - check(iPlayerSend < iEntryTriggers, "entry triggers fire AFTER both mons placed on field") + check(iWithdraw < iPlayerSend, "player withdraws BEFORE player sends out") + check(iPlayerSend < iEnemySend, "player sends out BEFORE enemy sends out (retail FRLG)") + check(iEnemySend < iEntryTriggers, "entry triggers fire AFTER the enemy send-out") end print("\n--- Testing SwitchSeq.beginTrainerSlideIn ---") diff --git a/tests/game3_compat_connections_view_test.lua b/tests/game3_compat_connections_view_test.lua new file mode 100644 index 00000000..ec2f5eda --- /dev/null +++ b/tests/game3_compat_connections_view_test.lua @@ -0,0 +1,45 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +require("src.core.GameVersion").set("firered") + +local failures = 0 +local function check(ok, label) + print((ok and "PASS " or "FAIL ") .. label) + if not ok then failures = failures + 1 end +end + +local Gen3Compat = require("src.mods.Gen3Compat") + +local def = { + id = "FR_SIX_ISLAND_WATER_PATH", width = 20, height = 70, + connections = { + { dir = "west", map = "FR_SIX_ISLAND_GREEN_PATH", offset = 0 }, + { dir = "west", map = "FR_SIX_ISLAND", offset = 40 }, + { dir = "west", map = "FR_SIX_ISLAND_RUIN_VALLEY", offset = 80 }, + { dir = "north", map = "FR_SIX_ISLAND_POKEMON_CENTER", offset = 0 }, + }, +} +local game = { data = { maps = { FR_SIX_ISLAND_WATER_PATH = def } } } +Gen3Compat.bind(function() return game end) + +local view = Gen3Compat.mapView("FR_SIX_ISLAND_WATER_PATH") +local conns = view.connections +check(type(conns) == "table" and #conns == 4, "four ordered connections") + +local seen = 0 +for _ in pairs(conns) do seen = seen + 1 end +check(seen == 4, "pairs() yields each connection once (got " .. seen .. ")") + +check(conns.west and conns.west.map == "FR_SIX_ISLAND_GREEN_PATH", "direction name resolves to the first entry") +check(conns.north and conns.north.map == "FR_SIX_ISLAND_POKEMON_CENTER", "single-entry direction resolves") +check(conns.south == nil, "missing direction is nil") +check(conns[1].map == "FR_SIX_ISLAND_GREEN_PATH" and conns[3].map == "FR_SIX_ISLAND_RUIN_VALLEY", "ROM order kept") + +check(rawequal(view.connections, conns), "view is cached per def") +check(rawequal(Gen3Compat.mapView("FR_SIX_ISLAND_WATER_PATH").connections, conns), "cache is shared across views") + +def.connections = { { dir = "east", map = "FR_SIX_ISLAND", offset = 0 } } +local fresh = view.connections +check(not rawequal(fresh, conns) and #fresh == 1 and fresh.east ~= nil, "replaced connections rebuild the view") + +os.exit(failures == 0 and 0 or 1) diff --git a/tests/game3_connection_list_test.lua b/tests/game3_connection_list_test.lua new file mode 100644 index 00000000..2e91c5c0 --- /dev/null +++ b/tests/game3_connection_list_test.lua @@ -0,0 +1,114 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +require("src.core.GameVersion").set("firered") + +local ExtractMapEvents = require("src.import.gba.extract_map_events") +local MapTree = require("src.import.gba.map_tree") +local Versions = require("src.import.gba.versions") + +local failures = 0 +local function check(ok, label) + print((ok and "PASS " or "FAIL ") .. label) + if not ok then failures = failures + 1 end +end + +local function fakeRom(bytes) + local rom = { bytes = bytes } + function rom:get(off) return self.bytes[off + 1] or 0 end + function rom:u32(off) + return self:get(off) + self:get(off + 1) * 256 + + self:get(off + 2) * 65536 + self:get(off + 3) * 16777216 + end + function rom:ptrOffset(p) + if p < 0x08000000 or p >= 0x0A000000 then return nil end + return p - 0x08000000 + end + return rom +end + +local function putU32(bytes, off, v) + if v < 0 then v = v + 0x100000000 end + for i = 0, 3 do + bytes[off + i + 1] = math.floor(v / 256 ^ i) % 256 + end +end + +local ENTRIES = { + { dirByte = 3, offset = 0, group = 3, num = 0 }, + { dirByte = 3, offset = 40, group = 3, num = 1 }, + { dirByte = 3, offset = 80, group = 3, num = 2 }, + { dirByte = 4, offset = -12, group = 3, num = 19 }, +} + +local bytes = {} +for i = 1, 0x100 do bytes[i] = 0 end +local HEADER, LIST = 0x10, 0x40 +putU32(bytes, HEADER, #ENTRIES) +putU32(bytes, HEADER + 4, 0x08000000 + LIST) +for i, e in ipairs(ENTRIES) do + local base = LIST + (i - 1) * 12 + bytes[base + 1] = e.dirByte + putU32(bytes, base + 4, e.offset) + bytes[base + 9] = e.group + bytes[base + 10] = e.num +end +local rom = fakeRom(bytes) + +print("[test] 1. ExtractMapEvents.parseConnections keeps every entry in ROM order") +local list = ExtractMapEvents.parseConnections(rom, 0x08000000 + HEADER) +check(#list == 4, "four connections survive (got " .. #list .. ")") +local wantDir = { "west", "west", "west", "east" } +for i, e in ipairs(ENTRIES) do + local c = list[i] or {} + check(c.dir == wantDir[i], ("entry %d dir %s"):format(i, tostring(c.dir))) + check(c.offset == e.offset, ("entry %d offset %s"):format(i, tostring(c.offset))) + check(c.map == Versions.frMapFor(e.group, e.num), + ("entry %d map %s"):format(i, tostring(c.map))) +end +check(list.west == nil and list.east == nil, "no direction-keyed fields") + +print("[test] 2. MapTree.parseConnections keeps every entry in ROM order") +local tree = MapTree.parseConnections(rom, 0x08000000 + HEADER, nil) +check(#tree == 4, "four census connections survive (got " .. #tree .. ")") +for i, e in ipairs(ENTRIES) do + local c = tree[i] or {} + check(c.dir == wantDir[i] and c.offset == e.offset + and c.mapGroup == e.group and c.mapNum == e.num, + ("census entry %d is %s %s %s:%s"):format(i, tostring(c.dir), + tostring(c.offset), tostring(c.mapGroup), tostring(c.mapNum))) +end + +print("[test] 3. an imported cache carries Water Path's three west entries") +local Cache = require("tests.game3_cache") +local root = Cache.root("connections.lua") +if not root then + print("[skip] cache section: " .. tostring(Cache.reason)) +else + local ok, conns = pcall(dofile, root .. "/connections.lua") + check(ok and type(conns) == "table", "connections.lua loads") + local wp = ok and conns.FR_SIX_ISLAND_WATER_PATH or {} + local want = { + { "west", "FR_SIX_ISLAND_GREEN_PATH", 0 }, + { "west", "FR_SIX_ISLAND", 40 }, + { "west", "FR_SIX_ISLAND_RUIN_VALLEY", 80 }, + } + check(#wp == 3, "Water Path has three connections (got " .. #wp .. ")") + for i, w in ipairs(want) do + local c = wp[i] or {} + check(c.dir == w[1] and c.map == w[2] and c.offset == w[3], + ("Water Path entry %d is %s %s %s"):format(i, tostring(c.dir), + tostring(c.map), tostring(c.offset))) + end + local six = ok and conns.FR_SIX_ISLAND or {} + check(#six == 1 and six[1].dir == "east" + and six[1].map == "FR_SIX_ISLAND_WATER_PATH" and six[1].offset == -40, + "Six Island keeps its one east connection") + local total = 0 + for _, entries in pairs(ok and conns or {}) do total = total + #entries end + check(total == 116, "every ROM connection is in the cache (got " .. total .. ")") +end + +if failures > 0 then + print(("game3_connection_list_test: %d failure(s)"):format(failures)) + os.exit(1) +end +print("game3_connection_list_test: all passed") diff --git a/tests/game3_cut_grass_mow_test.lua b/tests/game3_cut_grass_mow_test.lua new file mode 100644 index 00000000..37e4095e --- /dev/null +++ b/tests/game3_cut_grass_mow_test.lua @@ -0,0 +1,87 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +require("src.core.GameVersion").set("firered") + +local failures = 0 +local function check(ok, label) + print((ok and "PASS " or "FAIL ") .. label) + if not ok then failures = failures + 1 end +end + +package.loaded["src.core.game3.field_move_show_mon"] = { start = function(_, _, fn) fn() end } +package.loaded["src.core.game3.quest_log_recorder"] = { event = function() end, location = function() end } + +local FieldMoves = require("src.core.game3.field_moves") +local LayoutNative = require("src.core.game3.layout_native") +local FieldEffects = require("src.core.game3.field_effects") +local Audio = require("src.core.game3.audio") +local Field = require("src.core.game3.field") +local Map = require("src.core.game3.map") +local Player = require("src.core.game3.player") + +check(FieldMoves.CUT_GRASS_METATILES[0x284] == 0x281, "Viridian Forest HugeTreeTopMiddle_Grass mows to 0x281") +check(LayoutNative.setMidAt == nil, "LayoutNative has no setMidAt, writes go through applyOverride") + +local function grid(w, h, fill, coll) + local cells = {} + for y = 0, h - 1 do + for x = 0, w - 1 do + cells[y * w + x + 1] = { mid = fill(x, y), coll = coll or 0, elev = 3 } + end + end + return LayoutNative.fromDecoded({ width = w, height = h, cells = cells }, "FR_CUT_TEST", "cut_test") +end + +local L = grid(5, 5, function() return 0x00D end) +L.cells[1 * 5 + 1 + 1] = { mid = 0x00D, coll = 0, elev = 4 } +L.cells[3 * 5 + 3 + 1] = { mid = 0x284, coll = 0, elev = 3 } +L.cells[1 * 5 + 3 + 1] = { mid = 0x3FF, coll = 0, elev = 3 } +local n = FieldMoves.mowGrass3x3(2, 2, + function(x, y) return L:midAt(x, y) end, + function(x, y, mid) L:applyOverride(x, y, mid, 0, L:elevAt(x, y)) end, + function(x, y) return L:elevAt(x, y) == 3 end) +check(n == 7, "seven cells mowed (got " .. n .. ")") +check(L:midAt(2, 2) == 0x001 and L:midAt(1, 3) == 0x001, "plain grass becomes Plain_Mowed") +check(L:midAt(3, 3) == 0x281, "0x284 becomes 0x281") +check(L:midAt(1, 1) == 0x00D, "grass at another elevation is left alone") +check(L:midAt(3, 1) == 0x3FF, "unmapped metatile is left alone") +check(L:midAt(0, 0) == 0x00D, "cells outside the 3x3 are untouched") + +local def = { id = "FR_CUT_TEST", pair = "cut_test", width = 3, height = 3, connections = {}, objects = {}, warps = {} } +def.midLayout = grid(6, 6, function(x) return x == 5 and 0x001 or 0x00D end, 0x18) +def.midLayout.cells[1 * 6 + 1 + 1] = { mid = 0x284, coll = 0, elev = 3 } +def.midLayout.cells[2 * 6 + 0 + 1] = { mid = 0x3FF, coll = 0x18, elev = 0 } +local game = { data = { maps = { FR_CUT_TEST = def } }, save = {} } +local session = { map = "FR_CUT_TEST", flags = {}, vars = {} } +Field._game, Field._session = game, session +Map._def, Map.current = def, "FR_CUT_TEST" +Player.cellX, Player.cellY, Player.elevation = 0, 2, 3 +Audio.playSe = function() end +require("src.core.game3.collision").bindMap(game, "FR_CUT_TEST", def) +local grassCb = 0 +FieldEffects.startCutGrass = function(_, _, cb) grassCb = grassCb + 1; if cb then cb() end end +local events = {} +local ModRuntime = require("src.mods.Runtime") +local wants, emit = ModRuntime.wants, ModRuntime.emit +ModRuntime.wants = function(name) return name == "world.block_replaced" or wants(name) end +ModRuntime.emit = function(name, ev) if name == "world.block_replaced" then events[#events + 1] = ev else return emit(name, ev) end end + +Field.executeFieldMove({ action = "cut_grass", mon = {} }) +ModRuntime.wants, ModRuntime.emit = wants, emit + +local count = 0 +for _ in pairs(def.midLayout.overrides) do count = count + 1 end +check(count == 4, "edge cut writes the four in-bounds grass cells at the player's elevation (got " .. count .. ")") +check(def.midLayout:midAt(1, 1) == 0x284, "table id on a non-grass cell is left alone") +check(def.midLayout:midAt(0, 2) == 0x3FF, "player's elevation-0 cell does not drive the cut elevation") +check(def.midLayout:midAt(0, 1) == 0x001 and def.midLayout:midAt(1, 3) == 0x001, "field branch mows through Field.setMetatile") +check(def.midLayout.overrides[2 * 1024 - 1] == nil and def.midLayout.overrides[1 * 1024 - 1] == nil, + "no write outside the map") +check(#events == 4, "every mowed cell raises world.block_replaced") +check(Field.metatileOverrideAt("FR_CUT_TEST", 1, 2) ~= nil, "override is recorded for the map") +check(grassCb == 1 and Field.locked == false, "leaf animation runs and unlocks the field") + +Field.clearMetatiles(def.midLayout) +check(def.midLayout:midAt(1, 2) == 0x00D, "map reload grows the grass back") + +os.exit(failures == 0 and 0 or 1) diff --git a/tests/game3_link_battle_test.lua b/tests/game3_link_battle_test.lua index 33f0cac5..6277667a 100644 --- a/tests/game3_link_battle_test.lua +++ b/tests/game3_link_battle_test.lua @@ -847,6 +847,8 @@ check(Battle._refuseLinkItem(fakeInput), "A on BAG in a link battle is refused a local after = Ui.log() or {} check(#after > before and tostring(after[#after]):find("can't be used", 1, true) ~= nil, "and the refusal prints sText_ItemsCantBeUsedNow") +eq(Ui._mode, "selmsg", "shown as a selection message so it stays on screen") +eq(Ui._selReturn, "menu", "then returns to the action menu") Ui._mode = "menu" Ui._menuIndex = 1 eq(Battle._refuseLinkItem(fakeInput), false, "FIGHT is not touched by the rule") diff --git a/tests/game3_link_continue_warp_test.lua b/tests/game3_link_continue_warp_test.lua index fdfe93b6..7df3c56a 100644 --- a/tests/game3_link_continue_warp_test.lua +++ b/tests/game3_link_continue_warp_test.lua @@ -51,6 +51,26 @@ for _, room in ipairs({ "FR_BATTLE_COLOSSEUM_2P", "FR_BATTLE_COLOSSEUM_4P", "FR_ check(c.map == CENTER_2F and c.x == 9 and c.y == 1, room .. " save continues at the dynamic warp") end +for _, room in ipairs({ "FR_UNION_ROOM_PLAZA", "FR_UNION_ROOM" }) do + for _, case in ipairs({ + { heal = "FR_PEWTER_CITY_POKEMON_CENTER_1F", want = "FR_PEWTER_CITY_POKEMON_CENTER_2F" }, + { heal = "SEVII_ONE_ISLAND_POKECENTER", want = "SEVII_ONE_ISLAND_POKECENTER_2F" }, + { heal = "FR_PLAYERS_HOUSE_1F", want = CENTER_2F }, + }) do + local s = linkSession(room) + s.dynamicWarp = nil + s.healMap = case.heal + local saved = Schema.toSaveTable(s) + check((tonumber(saved.specialSaveWarpFlags) or 0) == 0, room .. " save with no dynamic warp sets no continue warp") + local c = Schema.fromSaveTable(saved) + check(c.map == case.want and c.x == 5 and c.y == 1 and c.facing == "down", + room .. " save with no dynamic warp (heal " .. case.heal .. ") continues at the Union Room door (got " + .. tostring(c.map) .. " " .. tostring(c.x) .. "," .. tostring(c.y) .. ")") + end + local c = Schema.fromSaveTable(Schema.toSaveTable(linkSession(room))) + check(c.map == CENTER_2F and c.x == 9 and c.y == 1, room .. " save with a dynamic warp still uses it") +end + local outside = linkSession(CENTER_2F) outside.x, outside.y = 7, 4 local o = Schema.fromSaveTable(Schema.toSaveTable(outside)) diff --git a/tests/game3_link_relay_union_test.lua b/tests/game3_link_relay_union_test.lua index 1bc846d4..bb7009f3 100644 --- a/tests/game3_link_relay_union_test.lua +++ b/tests/game3_link_relay_union_test.lua @@ -18,17 +18,18 @@ end local FakeRelay = require("tests.g3link_fake_relay") -local UNION_MAP = "FR_UNION_ROOM" +local Plaza = require("src.core.game3.link.union_plaza_map") +local UNION_MAP = Plaza.MAP_ID local CENTER_MAP = "FR_TEST_POKEMON_CENTER_2F" local MAPS = { - [UNION_MAP] = { warps = { { x = 7, y = 11, mapGroup = 0x7F, mapNum = 0x7F, destWarp = 128 } } }, + [UNION_MAP] = { warps = { { x = 12, y = 24, mapGroup = 0x7F, mapNum = 0x7F, destWarp = 128 } } }, [CENTER_MAP] = { warps = { { x = 9, y = 1, destMap = "FR_TRADE_CENTER", destWarp = 1 } } }, FR_TRADE_CENTER = { warps = { { x = 5, y = 8, mapGroup = 0x7F, mapNum = 0x7F, destWarp = 128 } } }, } local store = { flags = {}, vars = {} } local session = { - store = store, map = UNION_MAP, x = 7, y = 11, name = "RED", gender = 0, trainerId = 0x1234, + store = store, map = UNION_MAP, x = 12, y = 24, name = "RED", gender = 0, trainerId = 0x1234, party = { { species = 1, level = 12 }, { species = 4, level = 9 } }, bag = { pockets = { items = {} } }, } @@ -42,7 +43,7 @@ package.loaded["src.core.game3.runtime"] = { isActive = function() return true end, _game = game, } -local Player = { cellX = 7, cellY = 11, facing = "down" } +local Player = { cellX = 12, cellY = 23, facing = "down" } package.loaded["src.core.game3.player"] = Player local mapLoads = {} package.loaded["src.core.game3.map"] = { @@ -181,50 +182,160 @@ local join = Client.last("joinPlaza") eq(join and join[1], "union", "joinPlaza union") eq(join and join[2] and join[2].rulesetId, "g3_link", "with the live g3_link profile") eq(join and join[3] and join[3].name, "RED", "and the avatar") +eq(join and join[4], 40, "announcing the 40-player plaza cap") +eq(Union.capacity(), Plaza.CAP, "the relay room holds 40 players") eq(Client.status, "idle", "presence status goes idle in the Union Room") +check(Union.isUnionMap(UNION_MAP) and Union.isUnionMap("FR_UNION_ROOM"), "both rooms are Union Room maps") +check(not Union.isUnionMap(CENTER_MAP), "the Pokemon Center is not") -print("[test] 2. plaza members fill the leader slots in slot order, self excluded") -Client._plaza = { kind = "union", instance = 1, you = 2, members = { +print("[test] 2. every other trainer stands on the cell of their relay slot, self excluded") +Client._plaza = { kind = "union", instance = 1, cap = 40, rev = 1, you = 2, members = { + member("aaaa0002", 1, "GREEN", 3, 1), member("0000beef", 2, "RED", 0x1234, 0), member("aaaa0001", 5, "BLUE", 0x2222, 0), - member("aaaa0002", 1, "GREEN", 3, 1), } } run(1) eq(Union.playerCount(), 2, "two other trainers") -eq(Union.players[1] and Union.players[1].name, "GREEN", "the lowest relay slot takes cart slot 1") -eq(Union.players[2] and Union.players[2].name, "BLUE", "the next takes cart slot 2") +eq(Union.players[1] and Union.players[1].name, "GREEN", "relay slot 1 is GREEN") +eq(Union.players[5] and Union.players[5].name, "BLUE", "relay slot 5 keeps its number (slot = cell)") +eq(Union.players[2], nil, "our own slot is never drawn") +eq(Union.vobj(2), nil, "and has no avatar") eq(Union.players[1] and Union.players[1].id, "aaaa0002", "the slot remembers the relay id") -eq(objectCalls.added[1], Union.LOCAL_IDS[1], "slot 1's avatar spawned on the scheduled refresh") -eq(getVar(Union.VAR_OBJ_GFX_ID_0), Union.graphicsIdFor(1, 3), "with the class graphics id") -local green = liveObjects[Union.LOCAL_IDS[1]] -eq(green and green.raiseY, -Union.FLY_HEIGHT, "UNION_ROOM_SPAWN_IN starts 160 px up (FLY_DOWN)") -run(10) -eq(green.raiseY, -Union.FLY_HEIGHT + 10 * Union.FLY_STEP, "and drops 8 px a frame") -run(10) -eq(green.raiseY, 0, "landing after 20 frames") -eq(Union.leaderObj(1).state, 1, "the leader is shown") -eq(Union.avatarData().leader_coords[1][1], 4, "leader slot 1 sits at sUnionRoomPlayerCoords[0]") +eq(#objectCalls.added, 0, "no cart map object is used") +local g1 = Union.vobj(1) +local cx1, cy1, cf1 = Plaza.cellFor(1) +check(g1 and g1.visible, "GREEN is a virtual object") +eq(g1 and g1.x, cx1, "on slot 1's cell x") +eq(g1 and g1.y, cy1, "on slot 1's cell y") +eq(g1 and g1.dir, Union.FACE_DIR[cf1], "facing the cell's default") +eq(Union.vobjId(5), Union.VOBJ_BASE + 5, "virtual object ids are VOBJ_BASE + slot") +local b5 = Union.vobj(5) +local cx5, cy5 = Plaza.cellFor(5) +eq(b5 and b5.x, cx5, "BLUE on slot 5's cell x") +eq(b5 and b5.y, cy5, "BLUE on slot 5's cell y") +local rec1 = VirtualObjects.get(Union.vobjId(1)) +eq(rec1 and rec1.graphicsId, Union.graphicsIdFor(1, 3), "with the class graphics id") +check(rec1 and rec1.y2 < 0, "flying in") +run(20) +eq(VirtualObjects.get(Union.vobjId(1)).y2, 0, "and landed") +eq(VirtualObjects.count(), 2, "two avatars in the virtual object pool") -print("[test] 3. refreshes are throttled like the cart, departures fly out") -table.remove(Client._plaza.members, 2) +print("[test] 3. the plaza rebuilds only on a new (instance, rev)") +local builds = Union.plazaBuilds +run(30) +eq(Union.plazaBuilds, builds, "no rebuild while the rev stands still") +Client._plaza.members[3].status = "battling" run(1) -eq(Union.playerCount(), 2, "not refreshed on the next frame") -check(Union.players[2] and Union.players[2].gone, "BLUE is marked gone") -local frames = 0 -while Union.playerCount() == 2 and frames < Union.REFRESH_FRAMES + 5 do - run(1) - frames = frames + 1 -end -eq(Union.playerCount(), 1, "BLUE left on the periodic refresh") -check(frames > 200, "which waits for the 300-frame refresh (" .. frames .. ")") -eq(Union.players[1] and Union.players[1].name, "GREEN", "GREEN keeps slot 1 (sticky)") -local blue = liveObjects[Union.LOCAL_IDS[2]] -check(blue ~= nil and blue.raiseY < 0, "UNION_ROOM_SPAWN_OUT flies the avatar up") +eq(Union.players[5].activity, Union.ACTIVITY.NONE + Union.IN_UNION_ROOM, "an unannounced change is not applied") +Client._plaza.rev = 2 +run(1) +eq(Union.plazaBuilds, builds + 1, "a new rev rebuilds once") +eq(Union.players[5].activity, Union.ACTIVITY.BATTLE_SINGLE + Union.IN_UNION_ROOM, "BLUE is battling") +eq(b5.x, cx5, "a busy player stays on its cell") +eq(b5.dir, Union.DIR.EAST, "facing its activity") +Client._plaza.members[3].status = "idle" +Client._plaza.instance, Client._plaza.rev = 2, 2 +run(1) +eq(Union.plazaBuilds, builds + 2, "a new instance with the same rev rebuilds too") +eq(b5.dir, Union.FACE_DIR[select(3, Plaza.cellFor(5))], "BLUE is idle again") +local blueRow = table.remove(Client._plaza.members, 3) +Client._plaza.rev = 3 +run(1) +eq(Union.players[5], nil, "a leaver is gone on the delta that drops it") +eq(b5.anim, "out", "and flies out") run(Union.FLY_HEIGHT / Union.FLY_STEP) -eq(objectCalls.removed[#objectCalls.removed], Union.LOCAL_IDS[2], "then removes the object") -eq(liveObjects[Union.LOCAL_IDS[2]], nil, "slot 2's avatar is gone") +eq(VirtualObjects.get(Union.vobjId(5)), nil, "then leaves the pool") +eq(VirtualObjects.count(), 1, "GREEN stays") +table.insert(Client._plaza.members, blueRow) +Client._plaza.rev = 4 +run(1) +check(Union.vobjVisible(5), "a rejoin on the same slot comes back to the same cell") +run(20) -print("[test] 3b. a busy pair is one group: leader and member at the cart offsets, facing each other") +print("[test] 3a. a short relay blip keeps the room, a long drop empties it") +local realClock = Union.clock +local fakeNow = 1000 +Union.clock = function() return fakeNow end +Client._state = "reconnecting" +run(1) +fakeNow = fakeNow + Union.GONE_GRACE_SECONDS / 2 +run(1) +eq(Union.playerCount(), 2, "a blip keeps everyone") +Client._state = "online" +run(1) +eq(Union.playerCount(), 2, "and the resumed plaza (same rev) is applied again") +Client._state = "reconnecting" +run(1) +fakeNow = fakeNow + Union.GONE_GRACE_SECONDS + 1 +run(1) +eq(Union.playerCount(), 0, "a drop past the grace empties the room") +Client._state = "online" +run(1) +eq(Union.playerCount(), 2, "the same rev after the drop still rebuilds") +Union.clock = realClock +run(20) + +print("[test] 3b. a full 40-player plaza: 39 avatars, no per-frame allocation") +local full = {} +for slot = 1, Plaza.CAP do + if slot == 2 then + full[#full + 1] = member("0000beef", 2, "RED", 0x1234, 0) + else + full[#full + 1] = member(string.format("bbbb%04x", slot), slot, "P" .. slot, slot * 7, slot % 2) + end +end +full[1] = member("aaaa0002", 1, "GREEN", 3, 1) +Client._plaza = { kind = "union", instance = 3, cap = 40, rev = 10, you = 2, members = full } +run(1) +eq(Union.playerCount(), Plaza.CAP - 1, "39 other trainers") +run(25) +eq(VirtualObjects.count(), Plaza.CAP - 1, "39 avatars in the pool") +local cellsOk, seen = true, {} +for slot = 1, Plaza.CAP do + if slot ~= 2 then + local v = Union.vobj(slot) + local x, y = Plaza.cellFor(slot) + if not (v and v.visible and v.x == x and v.y == y) or seen[y * 64 + x] then cellsOk = false end + seen[y * 64 + x] = true + end +end +check(cellsOk, "every avatar on its own slot cell") +builds = Union.plazaBuilds +local okJit, jit = pcall(require, "jit") +if okJit and jit and jit.off then jit.off() jit.flush() end +for _ = 1, 10 do Union.relayTick(1 / 60) end +collectgarbage("collect") +collectgarbage("stop") +local kb = collectgarbage("count") +for _ = 1, 600 do Union.relayTick(1 / 60) end +local grown = (collectgarbage("count") - kb) * 1024 +collectgarbage("restart") +if okJit and jit and jit.on then jit.on() end +eq(Union.plazaBuilds, builds, "600 frames on one rev never rebuild") +check(grown == 0, string.format("and allocate nothing (%d bytes)", grown)) +Client._plaza.members[40].status = "chatting" +Client._plaza.rev = 11 +run(1) +eq(Union.plazaBuilds, builds + 1, "one status change is one rebuild") +eq(Union.vobj(40).dir, Union.DIR.SOUTH, "a chatter faces south on its cell") +local x40, y40 = Plaza.cellFor(40) +Player.cellX, Player.cellY, Player.facing = x40 - 1, y40, "right" +input.pressed = { a = true } +run(1) +input.pressed = {} +eq(Union.state, "recv_join_chat_request", "slot 40's chatter is talkable from the west") +check(settle(), "the join prompt asks YES/NO") +answer(false) +drain() +eq(Union.state, "main", "back to the main loop") +Client._plaza = { kind = "union", instance = 1, cap = 40, rev = 20, you = 2, members = { + member("aaaa0002", 1, "GREEN", 3, 1), + member("0000beef", 2, "RED", 0x1234, 0), +} } +run(25) +eq(Union.playerCount(), 1, "back to GREEN alone") + +print("[test] 3c. anyone chatting can be joined, from any side; idle trainers from any side") local pink = member("aaaa0003", 3, "PINK", 5, 1) pink.status = "chatting" pink.group = { leader = "aaaa0003", members = { "aaaa0003", "aaaa0004" }, activity = "chat" } @@ -233,41 +344,30 @@ yellow.status = "chatting" yellow.group = pink.group table.insert(Client._plaza.members, pink) table.insert(Client._plaza.members, yellow) -run(1) -eq(Union.playerCount(), 2, "the member rides with its leader instead of taking a slot") -eq(Union.players[2] and Union.players[2].name, "PINK", "the leader takes the free slot") -eq(Union.players[2] and #Union.players[2].partners, 1, "with YELLOW as its group member") -eq(Union.players[2].activity, Union.ACTIVITY.CHAT + Union.IN_UNION_ROOM, "the group is chatting") -local vl = Union.vobj(2, 0) -local vm = Union.vobj(2, 1) -check(vl and vl.visible, "the leader is a virtual object") -check(vm and vm.visible, "and so is the member") -eq(vl and vl.x, 13, "leader at sUnionRoomPlayerCoords[1].x") -eq(vl and vl.y, 8, "leader at sUnionRoomPlayerCoords[1].y") -eq(vm and vm.x, 14, "member at offset (1,0)") -eq(vl and vl.dir, Union.DIR.SOUTH, "a chat leader faces south") -eq(vm and vm.dir, Union.DIR.WEST, "the member faces the leader") -eq(VirtualObjects.count(), 2, "both are in the virtual object pool") -check((VirtualObjects.get(Union.vobjId(2, 0)) or {}).y2 < 0, "flying in") -run(20) -eq((VirtualObjects.get(Union.vobjId(2, 0)) or {}).y2, 0, "and landed") -eq(liveObjects[Union.LOCAL_IDS[2]], nil, "no object event for a busy leader") - -print("[test] 3c. talking to a chatting group offers to join; a member gives a reaction") +Client._plaza.rev = 21 +run(21) +eq(Union.playerCount(), 3, "no grouping: the chat member has its own slot") +eq(Union.players[4] and Union.players[4].name, "YELLOW", "YELLOW on slot 4") +eq(Union.players[3].partners, nil, "no partner lists") +local vy = Union.vobj(4) +local x4, y4 = Plaza.cellFor(4) +eq(vy and vy.x, x4, "YELLOW stands on slot 4's cell") +eq(vy and vy.dir, Union.DIR.SOUTH, "facing its chat") Union.toMain() Message.reset() -Player.cellX, Player.cellY, Player.facing = 13, 9, "up" +Player.cellX, Player.cellY, Player.facing = x4, y4 + 1, "up" input.pressed = { a = true } run(1) input.pressed = {} -eq(Union.state, "recv_join_chat_request", "A in front of the chat leader") +eq(Union.state, "recv_join_chat_request", "A in front of a chat member who is not the host") +eq(vy.dir, Union.DIR.SOUTH, "YELLOW turns to face the player") check(settle(), "the join prompt asks YES/NO") if romBundle then - check(shows(RomText.ascii("sText_JoinChatFemale")), "gTexts_UR_JoinChat for a female leader") + check(shows(RomText.ascii("sText_JoinChatMale")), "gTexts_UR_JoinChat for a male chatter") end answer(true) local jinv = Client.last("invite") -eq(jinv and jinv[1], "aaaa0003", "joining asks the leader") +eq(jinv and jinv[1], "aaaa0004", "joining asks YELLOW directly") eq(jinv and jinv[2], "chat", "for the chat") eq(jinv and jinv[3] and jinv[3].join, true, "as a join") eq(Union.state, "send_activity_request", "waiting for the relay") @@ -276,32 +376,76 @@ jh.state, jh.why = "closed", "busy" run(1) eq(Union.state, "print_and_exit", "a refused join") if romBundle then - check(shows(RomText.ascii(RomText.key("gTexts_UR_ChatDeclined", 1))), "reads gTexts_UR_ChatDeclined") + check(shows(RomText.ascii(RomText.key("gTexts_UR_ChatDeclined", 0))), "reads gTexts_UR_ChatDeclined") end drain() eq(Union.state, "main", "back to the main loop") -Player.cellX, Player.cellY, Player.facing = 14, 9, "up" +eq(vy.dir, Union.DIR.SOUTH, "YELLOW faces its chat again") +local x3, y3 = Plaza.cellFor(3) +pink.group.members = { "aaaa0003", "aaaa0004", "c1", "c2", "c3" } +Client._plaza.rev = 22 +run(1) +Player.cellX, Player.cellY, Player.facing = x3 + 1, y3, "left" input.pressed = { a = true } run(1) input.pressed = {} -eq(Union.state, "print_and_exit", "A in front of a group member") -eq(vm.dir, Union.DIR.SOUTH, "the member turns to face the player") -run(2) -check(Message.isOpen(), "a reaction line") +eq(Union.state, "print_and_exit", "a full chat (5 seats) only gets a reaction") +eq(Union.vobj(3).dir, Union.DIR.EAST, "PINK turns east to the player") drain() -eq(vm.dir, Union.DIR.WEST, "and turns back to its leader") -Player.cellX, Player.cellY, Player.facing = 7, 11, "down" +local xg, yg = Plaza.cellFor(1) +for _, side in ipairs({ { 0, 1, "up" }, { 0, -1, "down" }, { -1, 0, "right" }, { 1, 0, "left" } }) do + Union.toMain() + Message.reset() + Player.cellX, Player.cellY, Player.facing = xg + side[1], yg + side[2], side[3] + input.pressed = { a = true } + run(1) + input.pressed = {} + eq(Union.state, "do_something_prompt", "idle GREEN is talkable facing " .. side[3]) + Union.toMain() + Union.flow = nil + Message.reset() +end +eq(Union.vobj(1).dir, Union.FACE_DIR[cf1], "and turns back to its cell facing") +Player.cellX, Player.cellY, Player.facing = 12, 23, "down" + +print("[test] 3d. a relay that refuses this build shows the update prompt once") +Union.toMain() +Message.reset() +Client._state = "error" +Client.upgradeRequired = function() return { type = "upgrade_required", text = "This build is too old for online play. Please update." } end +run(1) +eq(Union.state, "print_and_exit", "upgrade_required prints") +tickMsg() +check(Message.isOpen() and tostring(Message.currentPage()):find("too old", 1, true) ~= nil, "the relay's update text") +drain() +eq(Union.state, "main", "back to the main loop") +run(5) +eq(Union.state, "main", "and it is shown only once") +Client.upgradeRequired = nil +Client._state = "online" +run(25) +eq(Union.playerCount(), 3, "the room comes back when the relay does") +Union.toMain() +Message.reset() print("[test] 4. talking to a trainer: HiDoSomething, the invite menu, the cart's reject") run(1) local held = ctx.stateWait -eq(held, Union.scriptWaitTask, "the Union Room holds the next waitstate (UnionRoom_EventScript_Player#)") +eq(held, Union.scriptWaitTask, "the Union Room holds the next waitstate") ctx.stateWait = nil Flags.setVar(store, ctx, 0x800D, 1) eq(held(), false, "setvar VAR_RESULT, 1 then waitstate: the script waits") Flags.setVar(store, ctx, 0x800D, 0) run(1) +eq(Union.state, "main", "a VAR_RESULT that is not the attendant talks to no slot") +eq(Union.flow, nil, "and opens no flow") +eq(held(), true, "the waitstate is released") +Player.cellX, Player.cellY, Player.facing = xg, yg + 1, "up" +input.pressed = { a = true } +run(1) +input.pressed = {} eq(Union.state, "do_something_prompt", "talking to GREEN") +Player.cellX, Player.cellY, Player.facing = 12, 23, "down" check(settle(), "the activity menu opens") eq(Screen.mode, "activity", "GREETINGS / BATTLE / CHAT / EXIT") if romBundle then @@ -328,7 +472,6 @@ check(shows(RomText.ascii(RomText.key("gTexts_UR_BattleDeclined", 1))), "the partner's gendered battle refusal (GetURoomActivityRejectMsg)") drain() eq(Union.state, "main", "back to the main loop") -eq(held(), true, "and the talk script is released") Union.partnerId = 1 Union.chooseActivity(3) @@ -485,9 +628,10 @@ eq(board and board.wantType, 10, "wanting a FIRE type") drain() eq(Union.state, "main", "registration complete") -local greenMember = Client._plaza.members[2] +local greenMember = Client._plaza.members[1] eq(greenMember.name, "GREEN", "GREEN's plaza row") greenMember.board = { species = 4, level = 9, wantType = 12 } +Client._plaza.rev = Client._plaza.rev + 1 run(1) eq(Union.players[1].board and Union.players[1].board.species, 4, "GREEN's board offer is on the member row") local offers = Union.boardOffers() @@ -528,6 +672,39 @@ end eq(Union.state, "main", "back from the board") Player.cellX, Player.cellY, Player.facing = 7, 11, "down" +print("[test] 7b. the board lists every offer in the room and scrolls; EXIT never collides") +for slot = 5, 16 do + local m = member(string.format("cccc%04x", slot), slot, "T" .. slot, slot, 0) + m.board = { species = slot, level = slot, wantType = 12 } + table.insert(Client._plaza.members, m) +end +Client._plaza.rev = Client._plaza.rev + 1 +run(1) +eq(#Union.boardOffers(), 13, "13 offers from 40 possible slots") +Union.openTradingBoard() +eq(Screen.mode, "board", "the trading board") +local items = Screen.list.items +eq(#items, 15, "header, 13 offers, EXIT") +eq(items[#items].id, Screen.BOARD_EXIT, "EXIT has its own id") +eq(items[10].id, 8, "the ninth offer keeps id 8") +check(items[10].entry ~= nil and not items[10].disabled, "and is a real, pickable offer") +eq(Screen.list.maxShowed, 5, "five rows at a time") +Screen.select(10) +check(Screen.list.scroll > 0, "the list scrolls to reach it") +Screen.confirm() +check(Union.state ~= "main", "picking offer id 8 opens that offer, not EXIT (" .. tostring(Union.state) .. ")") +Union.toMain() +Message.reset() +Union.openTradingBoard() +Screen.select(#Screen.list.items) +Screen.confirm() +eq(Union.state, "main", "EXIT closes the board") +for i = #Client._plaza.members, 1, -1 do + if tostring(Client._plaza.members[i].id):sub(1, 4) == "cccc" then table.remove(Client._plaza.members, i) end +end +Client._plaza.rev = Client._plaza.rev + 1 +run(25) + Union.trade().playerSpecies, Union.trade().playerLevel = 1, 12 Flags.setVar(store, ctx, 0x800D, Union.INTERACT_ATTENDANT) run(1) diff --git a/tests/game3_link_union_test.lua b/tests/game3_link_union_test.lua index 93e7476c..a9fdec5c 100644 --- a/tests/game3_link_union_test.lua +++ b/tests/game3_link_union_test.lua @@ -111,16 +111,6 @@ local function getVar(id) return tonumber(Flags.getVar(store, ctx, id)) or 0 end -local function peerHello(name, trainerId, gender) - return { - type = Union.MSG.HELLO, - name = name, - trainerId = trainerId, - gender = gender or 0, - activity = Union.ACTIVITY.SEARCH + Union.IN_UNION_ROOM, - } -end - print("[test] 1. the union room specials answer to their pret index") local EXPECTED = { TryBecomeLinkLeader = 0x16B, @@ -153,7 +143,7 @@ end eq(#logs, 0, "nothing reached the unknown-special log") Link.reset() -print("[test] 3. RunUnionRoom starts the session and announces to the peer") +print("[test] 3. RunUnionRoom always opens the relay plaza, never a cable room") local host, guest = FakeRelay.pair({ game = game }) host:update(0) guest:update(0) @@ -164,74 +154,17 @@ Flags.setVar(store, ctx, Link.VAR_RESULT, 5) Natives.special(ctx, NativesLink.SPECIAL.RunUnionRoom, adapters) eq(Union.state, "init", "RunUnionRoom armed the union room task") eq(getVar(Link.VAR_RESULT), 0, "VAR_RESULT is cleared on entry") +eq(Union.relay, true, "with a cable link up and no relay the room is still the plaza") +eq(Union.capacity(), 40, "holding 40 players") Union.update(0) eq(Union.state, "main", "the session reached the main loop") guest:update(0) -local heard = guest:take(Union.MSG.HELLO) -check(heard ~= nil, "the peer heard this player enter the union room") -eq(heard and heard.name, "RED", "with the player's name") - -print("[test] 4. the player list fills from the transport and spawns an avatar") -guest:send(peerHello("BLUE", 0x2222, 0)) +eq(guest:take("game3_union_hello"), nil, "nothing is announced over the cable") +guest:send({ type = "game3_union_hello", name = "BLUE", trainerId = 0x2222, gender = 0, activity = 0x4C }) host:update(0) Union.update(0) -eq(Union.playerCount(), 1, "one other player is in the room") -local listed = Union.list()[1] -eq(listed and listed.name, "BLUE", "the list names the peer") -eq(listed and listed.slot, 1, "in the first leader slot") -eq(objectCalls.added[1], Union.LOCAL_IDS[1], "the slot 1 union room object was spawned") -eq(Flags.getFlag(store, ctx, Union.FLAG_HIDE_PLAYER_1), false, - "FLAG_HIDE_UNION_ROOM_PLAYER_1 was cleared") -eq(getVar(Union.VAR_OBJ_GFX_ID_0), Union.graphicsIdFor(0, 0x2222), - "VAR_OBJ_GFX_ID_0 holds the trainer-class graphics id") - -print("[test] 5. BufferUnionRoomPlayerName hands the nurse the name once") -local _, first = NativesLink.HANDLERS[NativesLink.SPECIAL.BufferUnionRoomPlayerName](ctx, adapters) -eq(first, 1, "the first call reports TRUE") -eq(ctx.stringVars[1], "BLUE", "and buffers the name into STR_VAR_1") -local _, second = NativesLink.HANDLERS[NativesLink.SPECIAL.BufferUnionRoomPlayerName](ctx, adapters) -eq(second, 0, "the second call reports FALSE") - -print("[test] 6. talking to a player opens the activity chooser") -Flags.setVar(store, ctx, Link.VAR_RESULT, 1) -Union.update(0) -eq(Union.partnerId, 1, "the interaction picked the player in slot 1") -Union.update(0) -check(Screen.isOpen(), "the union room screen opened") -eq(Screen.mode, "activity", "in activity-chooser mode") -eq(#Screen.items, 4, "with the four pret entries") -teq(Screen.labelFor(Screen.items[1]), "GREETINGS", "GREETINGS first") -teq(Screen.labelFor(Screen.items[4]), "EXIT", "EXIT last") - -print("[test] 7. BATTLE has no level limit") -session.party = { { species = 1, level = 42 }, { species = 4, level = 60 } } -Screen.cursor = 2 -Screen.confirm() -eq(Union.activity, Union.ACTIVITY.BATTLE_SINGLE + Union.IN_UNION_ROOM, - "the battle request carries ACTIVITY_BATTLE_SINGLE | IN_UNION_ROOM") -eq(Union.state, "send_activity_request", "and the request went out") -guest:update(0) -local request = guest:take(Union.MSG.REQUEST) -check(request ~= nil, "the peer received the activity request") -eq(request and request.activity, Union.ACTIVITY.BATTLE_SINGLE + Union.IN_UNION_ROOM, - "with the same activity id") - -print("[test] 8. a partner that never answers frees the room again") -eq(Union.state, "send_activity_request", "the request is still outstanding") -Union.update(Union.RESPONSE_SECONDS + 0.1) -eq(Union.lastResult, "busy", "the partner is reported busy") -Union.update(0) -eq(Union.state, "main", "and the union room goes back to its main loop") -eq(Union.activity, nil, "with no pending activity") - -print("[test] 9. EXIT leaves the chooser without contacting anybody") -Union.state = "do_something_prompt" -Union.partnerId = 1 -Union.update(0) -Screen.cursor = 4 -Screen.confirm() -eq(Union.activity, nil, "EXIT clears the pending activity") -eq(Union.state, "main", "and returns to the union room main loop") +eq(Union.playerCount(), 0, "and a cable hello adds nobody") +eq(#objectCalls.added, 0, "no cart player object is spawned") print("[test] 10. stepping on the union room pad returns the dynamic warp, not a header map") local pad = MAPS[UNION_MAP].warps[1] @@ -243,29 +176,15 @@ eq(destX, 5, "at the counter door x") eq(destY, 3, "at the counter door y") print("[test] 11. leaving the union room map ends the session") -objectCalls.removed = {} Space.mapId = CENTER_MAP session.map = CENTER_MAP local alive = Union.update(0) eq(alive, false, "the union room task stopped") eq(Union.state, "off", "the session is closed") -eq(Union.playerCount(), 0, "the player list is empty") -eq(objectCalls.removed[1], Union.LOCAL_IDS[1], "the spawned avatar was removed") -guest:update(0) -check(guest:take(Union.MSG.BYE) ~= nil, "the peer was told this player left") -print("[test] 12. InitUnionRoom scans from the Pokemon Center without spawning avatars") -objectCalls.added = {} +print("[test] 12. InitUnionRoom has no cable to scan") Natives.special(ctx, NativesLink.SPECIAL.InitUnionRoom, adapters) -eq(Union.state, "search", "the Pokemon Center ON_RESUME scan is running") -guest:send(peerHello("GREEN", 0x3333, 1)) -host:update(0) -Union.update(0) -eq(Union.playerCount(), 1, "the scan found a union room player") -eq(#objectCalls.added, 0, "and spawned nothing on the Pokemon Center map") -local _, greeted = NativesLink.HANDLERS[NativesLink.SPECIAL.BufferUnionRoomPlayerName](ctx, adapters) -eq(greeted, 1, "the nurse can greet the player it found") -eq(ctx.stringVars[1], "GREEN", "by name") +eq(Union.state, "off", "the Pokemon Center ON_RESUME call starts nothing") print("[test] 13. the link group flows answer LINKUP through VAR_RESULT") Link.reset() @@ -275,11 +194,6 @@ local host2, guest2 = FakeRelay.pair({ game = game }) host2:update(0) guest2:update(0) Link.attach(host2) -Natives.special(ctx, NativesLink.SPECIAL.RunUnionRoom, adapters) -Union.update(0) -guest2:send(peerHello("BLUE", 0x2222, 0)) -host2:update(0) -Union.update(0) Flags.setVar(store, ctx, Link.VAR_0x8004, Union.LINK_GROUP.SINGLE_BATTLE) local yielded = Natives.special(ctx, NativesLink.SPECIAL.TryBecomeLinkLeader, adapters) @@ -331,90 +245,6 @@ else end end -print("[test] an incoming activity request is answered, never parked") -Link.reset() -Union.reset() -local reqHost, reqGuest = FakeRelay.pair({ game = game }) -reqHost:update(0) -reqGuest:update(0) -Link.attach(reqHost) -Space.mapId = UNION_MAP -session.map = UNION_MAP -Natives.special(ctx, NativesLink.SPECIAL.RunUnionRoom, adapters) -Union.update(0) -reqGuest:update(0) -while reqGuest:take(Union.MSG.HELLO) do end -reqGuest:send({ type = Union.MSG.REQUEST, name = "BLUE", - activity = Union.ACTIVITY.CHAT + Union.IN_UNION_ROOM }) -reqHost:update(0) -Union.update(1 / 60) -eq(Union.activity, Union.ACTIVITY.CHAT + Union.IN_UNION_ROOM, - "the request lands in UR_STATE_RECV_ACTIVITY_REQUEST with the activity asked for") -eq(Union._requestName, "BLUE", "and the name of the trainer who asked") --- pokefirered/src/union_room.c:4486 -local prompt = Union.requestPrompt() -if romBundle then - eq(prompt, require("src.core.game3.rom_text").ascii("gText_UR_ChatInvitation"), - "the prompt is the cart's chat invitation") -else - print("[skip] ROM text: the contact prompt reads gText_UR_ChatInvitation") -end -Union.update(1 / 60) -reqHost:update(0) -reqGuest:update(0) -local answer = reqGuest:take(Union.MSG.RESPONSE) -check(answer ~= nil, "the peer gets a RESPONSE instead of timing out on a parked room") -check(Union.state ~= "player_contacted_you", "and the room leaves player_contacted_you") - -print("[test] the chooser's CHAT and GREETINGS start real activities") -local Chat = require("src.core.game3.link.chat") -Union.state = "main" -Union.activity = nil -Union.chooseActivity(3) -eq(Union.state, "send_activity_request", "CHAT asks the partner first") -reqGuest:update(0) -reqGuest:take(Union.MSG.REQUEST) -reqGuest:send({ type = Union.MSG.RESPONSE, accept = true }) -reqHost:update(0) -Union.update(1 / 60) -eq(Union.state, "in_activity", "an accepted CHAT enters the chat activity") -check(Chat.isActive(), "and the chat session is live") -Chat.say("HI") -reqGuest:update(0) -local said = reqGuest:take(Chat.MSG.LINE) -eq(said and said.text, "HI", "a line the player writes goes out on the wire") -reqGuest:send({ type = Chat.MSG.LINE, name = "BLUE", text = "HELLO" }) -reqHost:update(0) -Chat.update(1 / 60) -eq(#Chat.lines, 2, "and the peer's line lands in the log") -check(Chat.lines[2] and Chat.lines[2].text:find("BLUE", 1, true) == 1 - and Chat.lines[2].text:sub(-6) == ":HELLO", "with the peer's name and words") -Chat.stop("left") -Union.update(1 / 60) -eq(Union.state, "main", "leaving the chat puts the room back in its main loop") - -Union.state = "main" -Union.activity = nil -Union.chooseActivity(1) -eq(Union.state, "send_activity_request", "GREETINGS asks the partner first") -reqGuest:update(0) -reqGuest:take(Union.MSG.REQUEST) -reqGuest:send({ type = Union.MSG.RESPONSE, accept = true }) -reqHost:update(0) -Union.update(1 / 60) --- pokefirered/src/union_room.c:1954 CB2_ShowCard -eq(Union.state, "in_activity", "an accepted GREETINGS shows a trainer card") -eq(Union.lastResult, "card_shown", "built from CreateTrainerCardInBuffer") -local cardOut = nil -reqGuest:update(0) -local seen = reqGuest:take(Link.MSG.CARD) -while seen do - cardOut = seen - seen = reqGuest:take(Link.MSG.CARD) -end -check(cardOut ~= nil, "and this machine's own card went out to the peer") -eq(cardOut and cardOut.card and cardOut.card.name, session.name, "carrying the player's name") - print("[test] the link group at the counter lists the machine on the cable") Link.reset() Union.reset() diff --git a/tests/game3_multi_connection_test.lua b/tests/game3_multi_connection_test.lua new file mode 100644 index 00000000..9066d28c --- /dev/null +++ b/tests/game3_multi_connection_test.lua @@ -0,0 +1,171 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +local Connections = require("src.core.game3.connections") +local Map = require("src.core.game3.map") +local Collision = require("src.core.game3.collision") +local Itemfinder = require("src.core.game3.itemfinder") + +local failures = 0 +local function check(ok, label) + print((ok and "PASS " or "FAIL ") .. label) + if not ok then failures = failures + 1 end +end + +local function layout(tag, w, h) + local L = { width = w, height = h, pair = tag } + function L:midAt(x, y) return tag .. ":" .. x .. "," .. y end + function L:collAt() return 0 end + return L +end + +local function def(tag, w, h, conns) + return { id = tag, midLayout = layout(tag, w, h), pair = tag, connections = conns or {} } +end + +local maps = { + SRC = def("SRC", 24, 100, { + { dir = "west", map = "GREEN", offset = 0 }, + { dir = "west", map = "SIX", offset = 40 }, + { dir = "west", map = "RUIN", offset = 80 }, + { dir = "north", map = "TOP", offset = 0 }, + }), + GREEN = def("GREEN", 72, 20), + SIX = def("SIX", 24, 30, { { dir = "east", map = "SRC", offset = -40 } }), + RUIN = def("RUIN", 48, 40), + TOP = def("TOP", 24, 10), + KEYED = def("KEYED", 10, 10, { east = { map = "SIX", offset = 2 }, left = { map = "GREEN", offset = 0 } }), +} +local game = { data = { maps = maps } } + +local each = Connections.each(maps.SRC) +check(#each == 4 and each[1].map == "GREEN" and each[2].map == "SIX" and each[3].map == "RUIN" + and each[4].dir == "north", "list form keeps ROM order") +local keyed = Connections.each(maps.KEYED) +check(#keyed == 2 and keyed[1].dir == "east" and keyed[1].offset == 2 + and keyed[2].dir == "west" and keyed[2].map == "GREEN", "keyed form still resolves") + +local function destOf(id) return maps[id] end +for _, row in ipairs({ { 10, "GREEN" }, { 50, "SIX" }, { 90, "RUIN" } }) do + local c = Connections.incoming(maps.SRC, "left", 0, row[1], destOf) + check(c and c.map == row[2], "incoming west row " .. row[1] .. " -> " .. row[2]) +end +check(Connections.incoming(maps.SRC, "west", 0, 30, destOf) == nil, "incoming gap row 30 has no connection") +check(Connections.incoming(maps.SRC, "east", 23, 10, destOf) == nil, "incoming east with no east connection") +local c20 = Connections.incoming(maps.SRC, "west", 0, 20, destOf) +check(c20 and c20.map == "GREEN", "incoming bound is inclusive like fieldmap.c:724") + +Map.loadNeighborsDepth1(game, maps.SRC) +check(#Map.neighborList == 4, "neighborList has every connection") +check(Map.neighbors.west and Map.neighbors.west.map == "GREEN", "neighbors[dir] keeps the first entry") +check(Map.worldMidAt(-1, 10, maps.SRC) == "GREEN:71,10", "west row 10 draws Green Path") +check(Map.worldMidAt(-1, 50, maps.SRC) == "SIX:23,10", "west row 50 draws Six Island") +check(Map.worldMidAt(-3, 95, maps.SRC) == "RUIN:45,15", "west row 95 draws Ruin Valley") +local _, _, void = Map.worldMidAt(-1, 30, maps.SRC) +check(void == true, "west gap row draws border") + +local world = Map.computeWorld(maps, "SRC", 1, nil, nil) +local placed = {} +for _, e in ipairs(world) do placed[e.id] = e end +check(placed.GREEN and placed.GREEN.ox == -72 and placed.GREEN.oy == 0, "computeWorld places Green Path") +check(placed.SIX and placed.SIX.ox == -24 and placed.SIX.oy == 40, "computeWorld places Six Island") +check(placed.RUIN and placed.RUIN.ox == -48 and placed.RUIN.oy == 80, "computeWorld places Ruin Valley") + +local conn = Connections.incoming(maps.SRC, "west", 0, 50, destOf) +local lx, ly = Collision.connectionLanding(maps.SIX, conn, "left", 0, 50) +check(lx == 23 and ly == 10, "landing on Six Island at (23,10)") +check(Collision.connectionLanding(maps.SIX, conn, "left", 0, 70) == nil, "landing outside the span is refused, not clamped") +local back = Connections.incoming(maps.SIX, "right", 23, 10, destOf) +local bx, by = Collision.connectionLanding(maps.SRC, back, "right", 23, 10) +check(back and back.map == "SRC" and bx == 0 and by == 50, "Six Island east lands on the source at row 50") + +local n, nx, ny = Connections.atPos(Map.neighborList, -2, 45, 24, 100) +check(n and n.map == "SIX" and nx == 22 and ny == 5, "atPos resolves the covering west connection") +check(Connections.atPos(Map.neighborList, -2, 75, 24, 100) == nil, "atPos gap row has none") + +local events = { + SIX = { { type = "hidden_item", x = 22, y = 5 } }, + GREEN = {}, + RUIN = { { type = "hidden_item", x = 47, y = 1 } }, +} +local scan = Itemfinder.scan({ + px = 1, py = 46, events = {}, flagSet = function() return false end, + width = 24, height = 100, neighborList = Map.neighborList, + eventsFor = function(id) return events[id] or {} end, +}) +check(scan ~= nil, "itemfinder sees a hidden item on the middle west connection") +local scanRuin = Itemfinder.scan({ + px = 1, py = 84, events = {}, flagSet = function() return false end, + width = 24, height = 100, neighborList = Map.neighborList, + eventsFor = function(id) return events[id] or {} end, +}) +check(scanRuin ~= nil, "itemfinder sees a hidden item on the last west connection") + +check(Map.worldMidAt(2, -2, maps.SRC) == "TOP:2,8", "north row draws the north connection") + +require("src.core.GameVersion").set("firered") +local Cache = require("tests.game3_cache") +if not Cache.mount("connections.lua", { native = true }) then + print("[skip] game3_multi_connection_test cache section: " .. tostring(Cache.reason)) + os.exit(failures == 0 and 0 or 1) +end + +local Dataset = require("src.core.game3.dataset") +local Player = require("src.core.game3.player") +local Runtime = require("src.core.game3.runtime") +local Field = require("src.core.game3.field") +local Ghosts = require("src.core.game3.ghosts") +local Space = require("src.core.game3.scripting.space") +local rgame = { data = {}, save = {}, session = { flags = {}, vars = {} } } +Dataset.hydrate(rgame) +Runtime.session = rgame.session +Field._game, Field._session, Field.running = rgame, rgame.session, true +Space.runEnterScripts = function() end +Space.vm = nil + +local function setup(map, x, y, dir) + Ghosts.clear() + Map.load(nil, rgame, map, { x = x, y = y, facing = dir }) + rgame.session = Runtime.getSession() + rgame.session.x, rgame.session.y = Player.cellX, Player.cellY + Field.locked = false + Player.surfing, Player.elevation = false, 3 +end +local function finishStep() + Player._onStepDone = function() end + for _ = 1, 32 do + if not Player.moving then break end + Player.tick(rgame) + end +end + +local WP = "FR_SIX_ISLAND_WATER_PATH" +local wpDef = rgame.data.maps[WP] +local west = {} +for _, c in ipairs(Connections.each(wpDef)) do + if c.dir == "west" then west[#west + 1] = c.map .. "@" .. c.offset end +end +check(table.concat(west, " ") == "FR_SIX_ISLAND_GREEN_PATH@0 FR_SIX_ISLAND@40 FR_SIX_ISLAND_RUIN_VALLEY@80", + "cache Water Path west connections in ROM order") + +setup("FR_SIX_ISLAND", 23, 12, "right") +check(Player.tryMove("right", rgame, false) == "connection", "Six Island east crosses") +finishStep() +check(Map.current == WP and Player.cellX == 0 and Player.cellY == 52, "Six Island east lands on Water Path (0,52)") +check(#Map.neighborList == 3, "Water Path loads all three west neighbors") +local mid, _, void = Map.worldMidAt(-1, 52, wpDef) +check(mid ~= nil and not void and mid == rgame.data.maps.FR_SIX_ISLAND.midLayout:midAt(23, 12), + "Water Path west of row 52 draws Six Island") + +for _, r in ipairs({ + { 52, "FR_SIX_ISLAND", 23, 12 }, + { 10, "FR_SIX_ISLAND_GREEN_PATH", 71, 10 }, + { 90, "FR_SIX_ISLAND_RUIN_VALLEY", 47, 10 }, +}) do + setup(WP, 0, r[1], "left") + check(Player.tryMove("left", rgame, false) == "connection", "Water Path row " .. r[1] .. " crosses west") + finishStep() + check(Map.current == r[2] and Player.cellX == r[3] and Player.cellY == r[4], + "Water Path row " .. r[1] .. " lands on " .. r[2]) +end + +os.exit(failures == 0 and 0 or 1) diff --git a/tests/game3_objects_flag_sync_test.lua b/tests/game3_objects_flag_sync_test.lua index 9f755b2a..87e1d994 100644 --- a/tests/game3_objects_flag_sync_test.lua +++ b/tests/game3_objects_flag_sync_test.lua @@ -179,6 +179,95 @@ check(Objects.find(AIDE).visible, "mid-map setflag leaves the in-view aide drawn Space.bundle, Space.vm, Space.store = saved.bundle, saved.vm, saved.store Space.mapId, Space.active = saved.mapId, saved.active +local PEWTER = "FR_PEWTER_CITY" +local GYM_GUIDE, MUSEUM_GUIDE = 5, 2 +local FLAG_HIDE_GYM_GUIDE, FLAG_HIDE_MUSEUM_GUIDE = 0x2E, 0x2F +local function pewterDef() + return { + midLayout = { width = 50, height = 40 }, + objects = { + { localId = MUSEUM_GUIDE, graphicsId = 30, x = 33, y = 17, movementType = 8, flag = FLAG_HIDE_MUSEUM_GUIDE }, + { localId = GYM_GUIDE, graphicsId = 30, x = 42, y = 20, movementType = 8, flag = FLAG_HIDE_GYM_GUIDE }, + }, + } +end +local function freshPewter() + Objects.reset() + Objects.loadMap(nil, PEWTER, pewterDef()) +end +local function strand(lid, x, y, facing) + local eo = Objects.find(lid) + eo.cellX, eo.cellY, eo.targetX, eo.targetY = x, y, x, y + eo.px, eo.py = x * 16, y * 16 + eo.facing = facing + eo.frozen, eo.scriptBusy = true, true + if eo.def then eo.def.x, eo.def.y = x, y end + Objects._tracks[lid] = { done = false } + return eo +end +local function atTemplate(lid, x, y, label) + local eo = Objects.find(lid) + check(eo and eo.visible and not eo.hidden, label .. ": visible") + check(eo and eo.cellX == x and eo.cellY == y, label .. string.format(": at template (%d,%d), got (%s,%s)", + x, y, tostring(eo and eo.cellX), tostring(eo and eo.cellY))) + check(eo and eo.homeX == x and eo.homeY == y and eo.px == x * 16 and eo.py == y * 16, label .. ": home/px reset") + check(eo and eo.facing == "down", label .. ": template facing down, got " .. tostring(eo and eo.facing)) + check(eo and not eo.moving and not eo.frozen and not eo.scriptBusy, label .. ": idle") + check(Objects._tracks[lid] == nil, label .. ": no stale track") +end + +print("[test] 8. removeobject + clearflag respawns the Pewter gym guide at his template cell") +freshPewter() +placePlayer(15, 18) +local stale = strand(GYM_GUIDE, 7, 19, "left") +Objects.removeObject(GYM_GUIDE) +Objects.syncFlagVisibility(FLAG_HIDE_GYM_GUIDE, false) +atTemplate(GYM_GUIDE, 42, 20, "gym guide") +check(Objects.find(GYM_GUIDE) ~= stale, "gym guide is a fresh object") + +print("[test] 9. museum guide sibling") +freshPewter() +placePlayer(20, 17) +strand(MUSEUM_GUIDE, 24, 15, "up") +Objects.removeObject(MUSEUM_GUIDE) +Objects.syncFlagVisibility(FLAG_HIDE_MUSEUM_GUIDE, false) +atTemplate(MUSEUM_GUIDE, 33, 17, "museum guide") + +print("[test] 10. removeobject + addobject respawns from the template too") +freshPewter() +placePlayer(15, 18) +strand(GYM_GUIDE, 7, 19, "left") +Objects.removeObject(GYM_GUIDE) +check(Objects.addObject(GYM_GUIDE), "addobject ok") +atTemplate(GYM_GUIDE, 42, 20, "addobject gym guide") +local n = 0 +for _, id in ipairs(Objects._order) do if id == GYM_GUIDE then n = n + 1 end end +check(n == 1, "gym guide listed once in draw order") + +print("[test] 11. a perm row still places the respawn (Oak's lab shape)") +freshPewter() +placePlayer(15, 18) +strand(GYM_GUIDE, 7, 19, "left") +Objects.removeObject(GYM_GUIDE) +Objects.setObjectXY(GYM_GUIDE, 6, 3) +Objects.setMovementType(GYM_GUIDE, 8) +Objects.syncFlagVisibility(FLAG_HIDE_GYM_GUIDE, false) +local permEo = Objects.find(GYM_GUIDE) +check(permEo.visible and permEo.cellX == 6 and permEo.cellY == 3 and permEo.facing == "down", + "perm row wins over the template") + +print("[test] 12. clearflag leaves an active object where it is") +freshPewter() +placePlayer(15, 18) +local live = strand(GYM_GUIDE, 16, 18, "left") +live.frozen, live.scriptBusy = false, false +Objects._tracks[GYM_GUIDE] = nil +Objects.rememberPerm(PEWTER, GYM_GUIDE, { x = 6, y = 3, movementType = 7 }) +Objects.syncFlagVisibility(FLAG_HIDE_GYM_GUIDE, false) +check(Objects.find(GYM_GUIDE) == live and live.cellX == 16 and live.cellY == 18 and live.facing == "left", + "active gym guide untouched by clearflag") +check(Objects.addObject(GYM_GUIDE) and Objects.find(GYM_GUIDE) == live, "addobject on an active object is a no-op") + if failed > 0 then print("[test] FAILED " .. failed) os.exit(1) diff --git a/tests/game3_union_plaza_map_test.lua b/tests/game3_union_plaza_map_test.lua new file mode 100644 index 00000000..f086dc5a --- /dev/null +++ b/tests/game3_union_plaza_map_test.lua @@ -0,0 +1,199 @@ +#!/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 Plaza = require("src.core.game3.link.union_plaza_map") + +print("[test] contract") +eq(Plaza.MAP_ID, "FR_UNION_ROOM_PLAZA", "map id") +eq(Plaza.CAP, 40, "cap") +eq(#Plaza.CELLS, 41, "41 cells on the grid") +eq(#Plaza.ROLES, 25, "role grid is 25 rows") +local badRow = nil +for y, row in ipairs(Plaza.ROLES) do + if #row ~= 25 then badRow = y end + for i = 1, #row do + if not Plaza.SOURCE[row:sub(i, i)] then badRow = y end + end +end +check(badRow == nil, "every role row is 25 wide with a known role: " .. tostring(badRow)) + +print("[test] slot table") +local seen, unique = {}, 0 +for slot = 1, Plaza.CAP do + local x, y, facing = Plaza.cellFor(slot) + check(x ~= nil and y ~= nil and facing ~= nil, "slot " .. slot .. " has a cell") + local k = tostring(x) .. "," .. tostring(y) + if not seen[k] then unique = unique + 1 end + seen[k] = true + eq(Plaza.slotAt(x, y), slot, "slotAt inverts slot " .. slot) +end +eq(unique, 40, "40 unique cells") +check(Plaza.cellFor(0) == nil and Plaza.cellFor(41) == nil, "slots outside 1..40 have no cell") +check(Plaza.slotAt(12, 24) == nil and Plaza.slotAt(0, 0) == nil, "slotAt is nil off the cell table") + +local ex, ey = Plaza.entry() +local function dist(slot) + local x, y = Plaza.cellFor(slot) + return math.abs(x - ex) + math.abs(y - (ey - 1)) +end +local sorted = true +for slot = 2, Plaza.CAP do + if dist(slot) < dist(slot - 1) then sorted = false end +end +check(sorted, "fill order moves outward from the entrance") +local x1, y1 = Plaza.cellFor(1) +eq(x1, 12, "slot 1 sits on the entrance column") +eq(y1, 17, "slot 1 is the first cell up from the door") +local sx, sy = Plaza.cellFor(40) +check(sy == 5, "slot 40 is on the far row") + +print("[test] geometry") +eq(ex, 12, "entry x") +eq(ey, 24, "entry y") +eq(Plaza.roleAt(ex, ey), "X", "entry is the exit mat") +local attX, attY = 3, 2 +local blocked = {} +local function key(x, y) return y * 64 + x end +for _, c in ipairs(Plaza.CELLS) do blocked[key(c.x, c.y)] = true end +blocked[key(attX, attY)] = true +local function walkable(x, y) + local r = Plaza.roleAt(x, y) + return r ~= nil and not Plaza.BLOCKED[r] and not blocked[key(x, y)] +end +local reach = { [key(ex, ey)] = true } +local queue = { { ex, ey } } +local head = 1 +while queue[head] do + local p = queue[head] + head = head + 1 + for _, d in ipairs({ { 1, 0 }, { -1, 0 }, { 0, 1 }, { 0, -1 } }) do + local nx, ny = p[1] + d[1], p[2] + d[2] + if walkable(nx, ny) and not reach[key(nx, ny)] then + reach[key(nx, ny)] = true + queue[#queue + 1] = { nx, ny } + end + end +end +local allSides, onBad = true, {} +for _, c in ipairs(Plaza.CELLS) do + for _, d in ipairs({ { 1, 0 }, { -1, 0 }, { 0, 1 }, { 0, -1 } }) do + if not reach[key(c.x + d[1], c.y + d[2])] then + allSides = false + print((" cell %d,%d side %d,%d unreachable"):format(c.x, c.y, d[1], d[2])) + end + end + local r = Plaza.roleAt(c.x, c.y) + if Plaza.BLOCKED[r] or c.y <= 3 or c.y >= 23 or (c.x == ex and c.y >= 19) then + onBad[#onBad + 1] = c.x .. "," .. c.y + end +end +check(allSides, "every cell is reachable from all 4 sides with the room full") +eq(#onBad, 0, "no cell on a wall, the counter rows or the exit lane") +check(reach[key(2, 2)] == true, "the trading board spot below the counter is reachable") +check(reach[key(3, 3)] == true, "the attendant is reachable from the front") + +print("[test] a cache that cannot build the plaza is a hard error") +eq(Plaza.shouldUse, nil, "no switch back to the cart room") +local function fails(game, want, msg) + local ok, err = pcall(Plaza.ensure, game) + check(not ok and tostring(err):find(want, 1, true) ~= nil, msg .. ": " .. tostring(err)) +end +local realMap = package.loaded["src.core.game3.map"] +package.loaded["src.core.game3.map"] = { ensureMidLayout = function() end } +fails({ data = {} }, "no map table", "no map table") +fails({ data = { maps = {} } }, "FR_UNION_ROOM is missing from the cache", "no FR_UNION_ROOM") +fails({ data = { maps = { FR_UNION_ROOM = { warps = {} } } } }, "FR_UNION_ROOM has no layout", + "FR_UNION_ROOM without a layout") +local tiny = { width = 4, height = 4, cellAt = function() return { mid = 1, coll = 0, elev = 0 } end } +fails({ data = { maps = { FR_UNION_ROOM = { warps = {}, midLayout = tiny } } } }, "layout has no cell", + "a truncated FR_UNION_ROOM layout") +local Adapters = require("src.core.game3.scripting.adapters") +local broken = Adapters.host(nil, { data = { maps = { FR_UNION_ROOM = { warps = {} } } } }, nil) +local realVersions = package.loaded["src.import.gba.versions"] +package.loaded["src.import.gba.versions"] = { + frMapFor = function() return "FR_UNION_ROOM" end, + seviiMapFor = function() return nil end, +} +local okWarp, warpErr = pcall(broken.warp, 1, 1, 0xFF, 7, 11, function() end, "warpspinenter") +package.loaded["src.import.gba.versions"] = realVersions +package.loaded["src.core.game3.map"] = realMap +check(not okWarp and tostring(warpErr):find("FR_UNION_ROOM has no layout", 1, true) ~= nil, + "the Union Room warp errors instead of landing in the cart room: " .. tostring(warpErr)) + +print("[test] built from the imported cache") +local Cache = require("tests.game3_cache") +local root = Cache.mount("meta.json", { native = true }) +if not root then + print("[skip] plaza cache build: " .. tostring(Cache.reason)) +else + local Dataset = require("src.core.game3.dataset") + local maps = Dataset.buildMaps() + local src = maps.FR_UNION_ROOM + check(src ~= nil, "FR_UNION_ROOM is in the cache") + Dataset.attachMidLayouts({ FR_UNION_ROOM = src }, Cache.cache()) + local game = { data = { maps = maps } } + local def = Plaza.ensure(game) + check(def ~= nil and maps[Plaza.MAP_ID] == def, "ensure registers the plaza def") + eq(Plaza.ensure(game), def, "ensure is idempotent") + if def and src and src.midLayout then + local L = def.midLayout + eq(L.width, 25, "layout width") + eq(L.height, 25, "layout height") + eq(def.width, 25, "def width") + eq(def.height, 25, "def height") + eq(def.pair, src.pair, "same tileset pair as the cart room") + local srcMids = {} + local SL = src.midLayout + for y = 0, (SL.trueHeight or SL.height) - 1 do + for x = 0, (SL.trueWidth or SL.width) - 1 do + srcMids[SL:midAt(x, y)] = true + end + end + local foreign, collMismatch = 0, 0 + for y = 0, 24 do + for x = 0, 24 do + if not srcMids[L:midAt(x, y)] then foreign = foreign + 1 end + local c = L:collAt(x, y) + local r = Plaza.roleAt(x, y) + if (c ~= 0) ~= (Plaza.BLOCKED[r] == true or r == "X") then + collMismatch = collMismatch + 1 + end + end + end + eq(foreign, 0, "every metatile comes from the Union Room layout") + eq(collMismatch, 0, "blocked roles match the cart collision") + for slot = 1, Plaza.CAP do + local x, y = Plaza.cellFor(slot) + if L:collAt(x, y) ~= 0 then check(false, "slot " .. slot .. " cell is floor") end + end + eq(#def.warps, 1, "one warp") + local w, sw = def.warps[1], src.warps[1] + eq(w.x, 12, "warp x") + eq(w.y, 24, "warp y") + eq(w.mapGroup, sw.mapGroup, "warp group matches FR_UNION_ROOM") + eq(w.mapNum, sw.mapNum, "warp num matches FR_UNION_ROOM") + eq(w.destWarp, sw.destWarp, "warp dest matches FR_UNION_ROOM") + eq(w.destMap, sw.destMap, "warp destMap matches FR_UNION_ROOM") + eq(L:collAt(12, 24), SL:collAt(7, 11), "exit tile keeps the cart warp collision") + eq(def.music, src.music, "music copied") + end +end + +if failed > 0 then + print(("[FAIL] %d check(s) failed"):format(failed)) + os.exit(1) +end +print("[PASS] union plaza map") diff --git a/tests/mod_ui_tests.lua b/tests/mod_ui_tests.lua index 0d4d7f5a..37e84a8a 100644 --- a/tests/mod_ui_tests.lua +++ b/tests/mod_ui_tests.lua @@ -288,7 +288,7 @@ local WANT_IDS = { "textSpeed", "animations", "battleStyle", "battleLayout", "performance", "colors", "tilt", "uiLetterbox", "shaderfx", "shaderfx2", "zoom", "voidFill", "videoMode", "faithfulRes", "screenPos", "fpsCap", "vsync", "logicClock", - "speedOverworld", "speedBattle", "speedMenu", + "speedOverworld", "speedMenu", "mods", "controls", "dateFormat", "timeFormat" } local function orow(menu, id) for _, row in ipairs(menu.rows) do diff --git a/tests/online_client_gen3.lua b/tests/online_client_gen3.lua index c73faf87..d1d8df90 100644 --- a/tests/online_client_gen3.lua +++ b/tests/online_client_gen3.lua @@ -367,7 +367,7 @@ end do local w = newWorld() - local A = w:add(0x30, "ANN") + local A, sA = w:add(0x30, "ANN") local B = w:add(0x31, "BEN") local Mon = w:add(0x32, "MON") w:pump() @@ -379,8 +379,17 @@ do check(p ~= nil and p.kind == "union", "plaza_state lands") eq(p and p.you, 1, "the first member takes slot 1") eq(p and #p.members, 1, "and is alone") + eq(p and p.cap, 40, "plaza_state carries the 40 cap") + local sentJoin = w.relay:sent(sA, "plaza_join")[1] + eq(sentJoin and sentJoin.cap, 40, "a union plaza_join announces cap 40") + local inst0, rev0 = A.plazaRev() + check(inst0 ~= nil and type(rev0) == "number", "plazaRev reports (instance, rev)") B.joinPlaza("union", PROFILE3, avatar("BEN", 201, 0)) w:pump() + local inst1, rev1 = A.plazaRev() + eq(inst1, inst0, "the joiner lands in the same instance") + check(rev1 > rev0, "a plaza_delta moves the rev forward") + eq(select(2, B.plazaRev()), rev1, "the joiner's plaza_state rev matches the delta rev") eq(#A.plaza().members, 2, "plaza_delta adds the joiner") eq(A.plaza().members[2].slot, 2, "members sort by slot") eq(A.plaza().members[2].avatar.trainerId, 201, "and carry their avatar") @@ -681,6 +690,8 @@ do "group_open", "group_list" } local seq = table.concat(order, ",") eq(seq, table.concat(want, ","), "a fresh welcome re-sends the session state in order") + local rejoin = w.relay:sent(sa, "plaza_join") + eq(rejoin[#rejoin] and rejoin[#rejoin].cap, 40, "the re-sent plaza_join keeps cap 40") local presence = w.relay:sent(sa, "presence") eq(presence[#presence].status, "busy", "the re-sent presence is the latest") local sp = w.relay:sent(sa, "set_profiles") diff --git a/tests/parity_yellow_pikapic_pose_2347.lua b/tests/parity_yellow_pikapic_pose_2347.lua new file mode 100644 index 00000000..f5712758 --- /dev/null +++ b/tests/parity_yellow_pikapic_pose_2347.lua @@ -0,0 +1,181 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +local CacheContract = require("src.import.CacheContract") + +local LABELS = { + "e4841", "e4ce0", "e4e70", "e50af", "e52fe", "e5541", "e5794", "e59ed", + "e5c4d", "e5e90", "e6020", "e61b0", "e63f7", "e6646", "e682f", "e69bf", + "e6b4f", "e6cdf", "e6e6f", "e6fff", "e718f", "e731f", "e74af", "e763f", + "e7863", "e79f3", "e7b83", "e7d13", "f0b64", "f0d82", +} + +local function skip(why) + print("[skip] parity_yellow_pikapic_pose_2347: " .. why) + os.exit(0) +end + +local function readAll(path) + local f = io.open(path, "rb") + if not f then return nil end + local data = f:read("*a") + f:close() + return data +end + +local required = {} +for _, path in ipairs(CacheContract.VERSION_REQUIRED_FILES.yellow) do + required[path] = true +end +local missingContract = {} +for _, label in ipairs(LABELS) do + local path = "assets/generated/pikachu/gfx_" .. label .. ".png" + if not required[path] then missingContract[#missingContract + 1] = path end +end +if #missingContract > 0 then + error("VERSION_REQUIRED_FILES.yellow is missing " .. table.concat(missingContract, " ")) +end + +local function cacheRoot() + local env = os.getenv("YELLOW_CACHE") + if env and env ~= "" then return env end + local home = os.getenv("HOME") or "" + local identity = os.getenv("POKEPORT_IDENTITY") + local names = {} + if identity and identity ~= "" then names[#names + 1] = identity end + names[#names + 1] = "pokemon-love2d" + for _, name in ipairs(names) do + for _, base in ipairs({ home .. "/Library/Application Support/LOVE", home .. "/.local/share/love" }) do + local root = base .. "/" .. name .. "/yellow" + local marker = readAll(root .. "/rom-cache.complete") + if marker and marker:sub(1, #CacheContract.VERSION_FORMAT.yellow) + == CacheContract.VERSION_FORMAT.yellow then + return root + end + end + end +end + +local root = cacheRoot() +if not root then skip("no current Yellow cache") end +local PRET = "../pokeyellow/gfx/pikachu" +if not readAll(PRET .. "/unknown_e79f3.png") then skip("no ../pokeyellow checkout") end + +local okFfi, ffi = pcall(require, "ffi") +if not okFfi then skip("no ffi") end +pcall(ffi.cdef, [[ +int uncompress(uint8_t *dest, unsigned long *destLen, const uint8_t *source, unsigned long sourceLen); +]]) +local zlib +for _, name in ipairs({ "z", "libz.so.1", "libz.1.dylib" }) do + local ok, lib = pcall(ffi.load, name) + if ok then zlib = lib break end +end +if not zlib then skip("no zlib") end + +local function be32(s, i) + local a, b, c, d = s:byte(i, i + 3) + return ((a * 256 + b) * 256 + c) * 256 + d +end + +local function paeth(a, b, c) + local p = a + b - c + local pa, pb, pc = math.abs(p - a), math.abs(p - b), math.abs(p - c) + if pa <= pb and pa <= pc then return a end + if pb <= pc then return b end + return c +end + +local function decodeShades(path) + local data = assert(readAll(path), path) + assert(data:sub(1, 8) == "\137PNG\r\n\26\n", path .. " is not a png") + local pos, idat = 9, {} + local width, height, depth, colorType, interlace + while pos <= #data do + local len = be32(data, pos) + local kind = data:sub(pos + 4, pos + 7) + local body = data:sub(pos + 8, pos + 7 + len) + if kind == "IHDR" then + width, height = be32(body, 1), be32(body, 5) + depth, colorType, interlace = body:byte(9), body:byte(10), body:byte(13) + elseif kind == "IDAT" then + idat[#idat + 1] = body + end + pos = pos + 12 + len + end + assert(interlace == 0, path .. " is interlaced") + local channels = ({ [0] = 1, [2] = 3, [4] = 2, [6] = 4 })[colorType] + assert(channels and (colorType == 0 or depth == 8), + ("%s: unsupported png type %d depth %d"):format(path, colorType, depth)) + local bitsPerPixel = channels * depth + local stride = math.ceil(width * bitsPerPixel / 8) + local bpp = math.max(1, bitsPerPixel / 8) + local src = table.concat(idat) + local outLen = height * (stride + 1) + local out = ffi.new("uint8_t[?]", outLen) + local destLen = ffi.new("unsigned long[1]", outLen) + assert(zlib.uncompress(out, destLen, src, #src) == 0, path .. ": inflate failed") + local rows, prev = {}, {} + for x = 1, stride do prev[x] = 0 end + for y = 0, height - 1 do + local base = y * (stride + 1) + local filter = out[base] + local row = {} + for x = 1, stride do + local raw = out[base + x] + local left = x > bpp and row[x - bpp] or 0 + local up = prev[x] + local upLeft = x > bpp and prev[x - bpp] or 0 + local v + if filter == 0 then v = raw + elseif filter == 1 then v = raw + left + elseif filter == 2 then v = raw + up + elseif filter == 3 then v = raw + math.floor((left + up) / 2) + elseif filter == 4 then v = raw + paeth(left, up, upLeft) + else error(path .. ": bad filter " .. filter) end + row[x] = v % 256 + end + rows[y + 1] = row + prev = row + end + local shades = {} + local maxGray = 2 ^ depth - 1 + for y = 1, height do + local row = rows[y] + for x = 0, width - 1 do + local gray, alpha + if colorType == 0 then + local bit = x * depth + local byte = row[math.floor(bit / 8) + 1] + local shift = 8 - depth - bit % 8 + gray = math.floor(byte / 2 ^ shift) % (maxGray + 1) / maxGray + alpha = 1 + else + local i = x * channels + 1 + gray = row[i] / 255 + alpha = (colorType == 4 and row[i + 1] or colorType == 6 and row[i + 3] or 255) / 255 + end + shades[#shades + 1] = { 3 - math.floor(gray * 3 + 0.5), alpha } + end + end + return shades, width, height +end + +local failures = 0 +for _, label in ipairs(LABELS) do + local mine, w, h = decodeShades(root .. "/assets/generated/pikachu/gfx_" .. label .. ".png") + local pret, pw, ph = decodeShades(PRET .. "/unknown_" .. label .. ".png") + local diff, translucent = 0, 0 + for i = 1, #pret do + if mine[i][1] ~= pret[i][1] then diff = diff + 1 end + if mine[i][2] < 1 then translucent = translucent + 1 end + end + local ok = w == 40 and h == 40 and pw == 40 and ph == 40 and diff == 0 and translucent == 0 + print(("%s gfx_%s: %d shade diffs, %d non-opaque pixels"):format( + ok and "PASS" or "FAIL", label, diff, translucent)) + if not ok then failures = failures + 1 end +end + +if failures > 0 then + error(("parity_yellow_pikapic_pose_2347: %d pose(s) differ from pret"):format(failures)) +end +print("parity_yellow_pikapic_pose_2347: all 30 poses match pret") diff --git a/tests/run_tests.lua b/tests/run_tests.lua index 918c4657..a182bc9e 100644 --- a/tests/run_tests.lua +++ b/tests/run_tests.lua @@ -2448,10 +2448,25 @@ do StateStack:update(1 / 60) Input.pressed = {} end + local function waitTop(pred) + for _ = 1, 600 do + if pred(StateStack:top()) then return true end + StateStack:update(1 / 60) + end + return pred(StateStack:top()) + end + check(StateStack:top() ~= shop and StateStack:top().isTextBox, + "the greeting types before BUY/SELL/QUIT (text_script.asm:141-150)") + check(waitTop(function(t) return t == shop end), "the greeting hands over to the mart menu") press("a") -- BUY check(StateStack:top() ~= shop, "BUY opens the buy list") + eq(shop.hollowIndex, 1, "BUY leaves a hollow cursor (text_box.asm:176)") + check(waitTop(function(t) return t ~= nil and t.onChoose ~= nil end), + "the buy list follows Take your time.") press("b") -- close the list - eq(StateStack:top(), shop, "closing the list returns to the mart menu") + check(waitTop(function(t) return t == shop end), "closing the list returns to the mart menu") + eq(shop.index, 1, "the mart menu cursor is back on BUY (pokemart.asm:10)") + eq(shop.hollowIndex, nil, "and filled again") press("down") press("down") press("a") -- QUIT @@ -2459,6 +2474,8 @@ do local goodbye = StateStack:top() check(goodbye ~= shop and goodbye.isTextBox, "QUIT prints _PokemartThankYouText") + eq(StateStack.states[#StateStack.states - 1], shop, + "the mart menu stays under the goodbye (pokemart.asm:220)") for _ = 1, 600 do if quitCalled then break end press("a") @@ -2487,8 +2504,16 @@ end Game.save.inventory = { GREAT_BALL = 5, HYPER_POTION = 99 } local sellShop = require("src.ui.ShopMenu").new(Game, { "POTION" }, function() end) StateStack:push(sellShop) + for _ = 1, 600 do + if StateStack:top() == sellShop then break end + StateStack:update(1 / 60) + end Input.pressed = { down = true }; StateStack:update(1 / 60); Input.pressed = {} Input.pressed = { a = true }; StateStack:update(1 / 60); Input.pressed = {} + for _ = 1, 600 do + if StateStack:top() ~= sellShop and StateStack:top().items then break end + StateStack:update(1 / 60) + end local sellList = StateStack:top() local foundHyper for _, it in ipairs(sellList.items or {}) do @@ -2947,8 +2972,6 @@ do check(seek("vsync"), "cursor reaches VSYNC") press("a") eq(og.save.options.vsync, "off", "A cycles VSYNC to OFF") - -- RFC 0007: the single GAME SPEED row is now three independent rows, - -- one per GameSpeed.CATEGORIES entry. check(seek("speedOverworld"), "cursor reaches OVERWORLD SPEED") press("a") eq(og.save.options.speedOverworld, 2, "A cycles OVERWORLD SPEED to 2X") @@ -2957,11 +2980,7 @@ do -- bug when the cycling is fine and the row is simply one longer. for _ = 1, #GameSpeed.LEVELS - 1 do press("a") end eq(og.save.options.speedOverworld, 1, "OVERWORLD SPEED wraps back to NORMAL") - check(seek("speedBattle"), "cursor reaches BATTLE SPEED") - press("a") - eq(og.save.options.speedBattle, 2, "A cycles BATTLE SPEED to 2X") - for _ = 1, #GameSpeed.LEVELS - 1 do press("a") end - eq(og.save.options.speedBattle, 1, "BATTLE SPEED wraps back to NORMAL") + check(not seek("speedBattle"), "no BATTLE SPEED row: battles always run 1X") check(seek("speedMenu"), "cursor reaches MENU SPEED") press("a") eq(og.save.options.speedMenu, 2, "A cycles MENU SPEED to 2X") diff --git a/tests/save_convert_tests.lua b/tests/save_convert_tests.lua index eb7dfc1e..b4ddeaf0 100644 --- a/tests/save_convert_tests.lua +++ b/tests/save_convert_tests.lua @@ -295,8 +295,8 @@ check(scSave and scSave.player and scSave.player.id == 12345, check(scSave and scSave.inventory and scSave.inventory.POKE_BALL == 5, "SaveConvert.importSav: decoded bag items survive the merge") -- merge over new-game defaults -check(scSave and type(scSave.options) == "table" and scSave.options.ruleset == "gen1_faithful", - "SaveConvert.importSav: new-game default options merged in") +check(scSave and scSave.options == nil, + "SaveConvert.importSav: no options table rides the imported save") check(scSave and type(scSave.defeatedTrainers) == "table" and type(scSave.modData) == "table" and scSave.repelSteps == 0, "SaveConvert.importSav: default defeatedTrainers/modData/repelSteps merged in") diff --git a/tests/save_convert_yellow_bug838_test.lua b/tests/save_convert_yellow_bug838_test.lua index f591bf00..5c8a1187 100644 --- a/tests/save_convert_yellow_bug838_test.lua +++ b/tests/save_convert_yellow_bug838_test.lua @@ -53,6 +53,7 @@ local yellowData = { local stampMapWindow = loadfile("tests/fixture_data/map_window.lua")() for mapId in pairs(shared.maps) do stampMapWindow(redData, mapId) end yellowData.tilesets, yellowData.audio = redData.tilesets, redData.audio +yellowData.encounters = redData.encounters local OFF = GenSave.OFFSETS diff --git a/tests/support/fake_relay.lua b/tests/support/fake_relay.lua index 21c9d139..218c929e 100644 --- a/tests/support/fake_relay.lua +++ b/tests/support/fake_relay.lua @@ -38,6 +38,7 @@ function FakeRelay.new(opts) invites = {}, tokens = {}, plazas = { union = {} }, + plazaRev = 0, wireless = {}, groups = {}, groupWatch = {}, @@ -369,9 +370,9 @@ end local function memberOf(s, slot) return { id = s.id, name = s.name, verified = true, slot = slot, - online = s.online, status = s.recruiting and "recruiting" or "idle", + online = s.online, status = s.status or (s.recruiting and "recruiting" or "idle"), avatar = copy(s.avatar), recruiting = copy(s.recruiting), - board = copy(s.presence.board) } + board = copy(s.presence.board), group = copy(s.group) } end function FakeRelay:plazaInstance(s) @@ -381,8 +382,12 @@ function FakeRelay:plazaInstance(s) return nil end +FakeRelay.PLAZA_CAP = 40 + function FakeRelay:plazaDelta(inst, index, fields, except) - local msg = { type = "plaza_delta", kind = "union", instance = index, + self.plazaRev = self.plazaRev + 1 + inst.rev = self.plazaRev + local msg = { type = "plaza_delta", kind = "union", instance = index, rev = inst.rev, joined = fields.joined or {}, left = fields.left or {}, changed = fields.changed or {} } for id in pairs(inst.slots) do @@ -397,8 +402,8 @@ function FakeRelay:plazaStateMsg(s) members[#members + 1] = memberOf(self.sessions[id], slot) end table.sort(members, function(a, b) return a.slot < b.slot end) - return { type = "plaza_state", kind = "union", instance = index, - you = inst.slots[s.id], members = members } + return { type = "plaza_state", kind = "union", instance = index, cap = FakeRelay.PLAZA_CAP, + rev = inst.rev, you = inst.slots[s.id], members = members } end function FakeRelay:plazaJoin(s, msg) @@ -409,6 +414,12 @@ function FakeRelay:plazaJoin(s, msg) if type(msg.profile) ~= "table" or msg.profile.engine ~= 3 then return self:joinError(s, "bad_profile") end + if not (math.floor(tonumber(msg.cap) or 0) >= FakeRelay.PLAZA_CAP) then + self:to(s, { type = "upgrade_required", protocol = self.minProtocol, minBuild = nil, + text = "This build is too old for online play. Please update." }) + s.transport.closed = true + return + end s.avatar = copy(msg.avatar) s.where = "union" if self:plazaInstance(s) then return self:to(s, self:plazaStateMsg(s)) end @@ -416,13 +427,13 @@ function FakeRelay:plazaJoin(s, msg) for i, inst in ipairs(self.plazas.union) do local n = 0 for _ in pairs(inst.slots) do n = n + 1 end - if n < 9 and (not best or n > best.n) then best, bestIndex = { inst = inst, n = n }, i end + if n < FakeRelay.PLAZA_CAP and (not best or n > best.n) then best, bestIndex = { inst = inst, n = n }, i end end local inst, index if best then inst, index = best.inst, bestIndex else - inst = { slots = {} } + inst = { slots = {}, rev = 0 } self.plazas.union[#self.plazas.union + 1] = inst index = #self.plazas.union end @@ -431,8 +442,8 @@ function FakeRelay:plazaJoin(s, msg) local slot = 1 while used[slot] do slot = slot + 1 end inst.slots[s.id] = slot - self:to(s, self:plazaStateMsg(s)) self:plazaDelta(inst, index, { joined = { memberOf(s, slot) } }, s.id) + self:to(s, self:plazaStateMsg(s)) end function FakeRelay:plazaLeave(s, kind) diff --git a/tools/make_yellow_manifest.py b/tools/make_yellow_manifest.py index 10988433..66f58ed0 100755 --- a/tools/make_yellow_manifest.py +++ b/tools/make_yellow_manifest.py @@ -119,6 +119,13 @@ YELLOW_EXTRA_SYMBOLS = ( "Pic_e5b7d", "Pic_e5ddd", "GFX_e6020", "Pic_e6340", "Pic_e6587", "Pic_e67d6", "GFX_e6e6f", "GFX_e718f", "GFX_e74af", "Pic_e77cf", "Pic_f0abf", "Pic_f0cf4", + # data/pikachu/pikachu_pic_animation.asm:340 + "GFX_e4841", "GFX_e4ce0", "GFX_e4e70", "GFX_e50af", "GFX_e52fe", + "GFX_e5541", "GFX_e5794", "GFX_e59ed", "GFX_e5c4d", "GFX_e5e90", + "GFX_e61b0", "GFX_e63f7", "GFX_e6646", "GFX_e682f", "GFX_e69bf", + "GFX_e6b4f", "GFX_e6cdf", "GFX_e6fff", "GFX_e731f", "GFX_e763f", + "GFX_e7863", "GFX_e79f3", "GFX_e7b83", "GFX_e7d13", "GFX_f0b64", + "GFX_f0d82", # Yellow-only overworld player surf sprite, loaded outside # SpriteSheetPointerTable (LoadSurfingPlayerSpriteGraphics2) -- # needs its own extract like RedBikeSprite. (RFC 0001) diff --git a/tools/rom_manifest_yellow.json b/tools/rom_manifest_yellow.json index 5db89265..6b1615ff 100644 --- a/tools/rom_manifest_yellow.json +++ b/tools/rom_manifest_yellow.json @@ -21781,22 +21781,126 @@ 29, 18588 ], + "GFX_e4841": [ + 57, + 18497 + ], + "GFX_e4ce0": [ + 57, + 19680 + ], + "GFX_e4e70": [ + 57, + 20080 + ], + "GFX_e50af": [ + 57, + 20655 + ], + "GFX_e52fe": [ + 57, + 21246 + ], + "GFX_e5541": [ + 57, + 21825 + ], + "GFX_e5794": [ + 57, + 22420 + ], + "GFX_e59ed": [ + 57, + 23021 + ], + "GFX_e5c4d": [ + 57, + 23629 + ], + "GFX_e5e90": [ + 57, + 24208 + ], "GFX_e6020": [ 57, 24608 ], + "GFX_e61b0": [ + 57, + 25008 + ], + "GFX_e63f7": [ + 57, + 25591 + ], + "GFX_e6646": [ + 57, + 26182 + ], + "GFX_e682f": [ + 57, + 26671 + ], + "GFX_e69bf": [ + 57, + 27071 + ], + "GFX_e6b4f": [ + 57, + 27471 + ], + "GFX_e6cdf": [ + 57, + 27871 + ], "GFX_e6e6f": [ 57, 28271 ], + "GFX_e6fff": [ + 57, + 28671 + ], "GFX_e718f": [ 57, 29071 ], + "GFX_e731f": [ + 57, + 29471 + ], "GFX_e74af": [ 57, 29871 ], + "GFX_e763f": [ + 57, + 30271 + ], + "GFX_e7863": [ + 57, + 30819 + ], + "GFX_e79f3": [ + 57, + 31219 + ], + "GFX_e7b83": [ + 57, + 31619 + ], + "GFX_e7d13": [ + 57, + 32019 + ], + "GFX_f0b64": [ + 60, + 19300 + ], + "GFX_f0d82": [ + 60, + 19842 + ], "GamblerPic": [ 19, 21537 diff --git a/tools/run_driver.sh b/tools/run_driver.sh index 086b21c2..6d5ab8f3 100755 --- a/tools/run_driver.sh +++ b/tools/run_driver.sh @@ -3,6 +3,7 @@ set -u ROOT="$(cd "$(dirname "$0")/.." && pwd)" SLOW_DRIVERS="game3_daycare_egg game3_daycare_menu game3_daycare_deposit game3_import2_fresh_cache game3_import2_town_map game3_trade_scene game3_u8c_yesno_frame game3_u9c_catch_ball_open" +REALTIME_DRIVERS="battle_fanfare_speed_bug1952_test" help_text() { cat <