Merge pull request #2476 from bryanthaboi/dev

Bunga
This commit is contained in:
bryanthaboi
2026-09-25 11:15:03 -04:00
committed by GitHub
147 changed files with 7302 additions and 1822 deletions
Binary file not shown.
+1
View File
@@ -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")
+19 -14
View File
@@ -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.
+2
View File
@@ -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
+38 -1
View File
@@ -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)
+1 -1
View File
@@ -1 +1 @@
{"mods":[],"schema_version":1}
{"schema_version":1,"mods":[]}
+41 -13
View File
@@ -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
+29 -16
View File
@@ -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)
+33 -3
View File
@@ -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
+52 -3
View File
@@ -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
+6 -1
View File
@@ -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")
+73
View File
@@ -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
-2
View File
@@ -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 = {}
+15 -6
View File
@@ -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
+6 -1
View File
@@ -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)
+1 -2
View File
@@ -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
+60 -35
View File
@@ -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")
+9
View File
@@ -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
+11 -11
View File
@@ -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
+117
View File
@@ -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
+1
View File
@@ -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
+59
View File
@@ -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 = {}, {}
+2 -2
View File
@@ -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
+14 -6
View File
@@ -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
+8 -8
View File
@@ -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
+69 -54
View File
@@ -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.
+13 -44
View File
@@ -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
+213
View File
@@ -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
File diff suppressed because it is too large Load Diff
+58 -65
View File
@@ -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)
+84 -69
View File
@@ -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
+19
View File
@@ -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
+6
View File
@@ -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]
+2
View File
@@ -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)
+9
View File
@@ -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
+26
View File
@@ -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
+61 -3
View File
@@ -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",
-8
View File
@@ -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)
+97
View File
@@ -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
+12 -4
View File
@@ -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,
+10 -18
View File
@@ -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
+1 -1
View File
@@ -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
+2 -1
View File
@@ -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,
+1 -1
View File
@@ -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
+5 -1
View File
@@ -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),
+16 -1
View File
@@ -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
+4 -1
View File
@@ -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.
+12 -5
View File
@@ -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)
+7 -2
View File
@@ -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
+1
View File
@@ -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
+6 -6
View File
@@ -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),
+121 -13
View File
@@ -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
+11 -8
View File
@@ -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)
+35
View File
@@ -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).
+1 -8
View File
@@ -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
+3
View File
@@ -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,
+2 -14
View File
@@ -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
+54 -15
View File
@@ -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
+1 -1
View File
@@ -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
+1 -10
View File
@@ -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),
+1 -2
View File
@@ -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" } },
+14 -2
View File
@@ -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"),
+1 -1
View File
@@ -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)
+6 -2
View File
@@ -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,
+1
View File
@@ -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
+1
View File
@@ -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
+1
View File
@@ -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
+36 -13
View File
@@ -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
+83 -49
View File
@@ -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
@@ -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
@@ -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
+27 -7
View File
@@ -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
+91 -12
View File
@@ -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")
+228
View File
@@ -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
+196
View File
@@ -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
+23 -34
View File
@@ -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()
+127
View File
@@ -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
@@ -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
@@ -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
+4 -3
View File
@@ -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
+15 -90
View File
@@ -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)
@@ -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
@@ -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
+154
View File
@@ -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
@@ -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
+107
View File
@@ -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
+1 -1
View File
@@ -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
@@ -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
+126
View File
@@ -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
+3 -3
View File
@@ -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]
@@ -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
+19 -9
View File
@@ -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()
+172 -32
View File
@@ -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
+15 -8
View File
@@ -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
@@ -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
+5 -1
View File
@@ -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
+149
View File
@@ -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
+102
View File
@@ -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
+87
View File
@@ -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
@@ -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

Some files were not shown because too many files have changed in this diff Show More