CLOSES #2564, CLOSES #2570

This commit is contained in:
bryanthaboi
2026-09-30 12:18:24 -04:00
parent af69971efe
commit 932a075496
29 changed files with 1686 additions and 70 deletions
+1
View File
@@ -36,6 +36,7 @@ body:
- Silver
- Crystal
- FireRed
- Emerald
- N/A
validations:
required: true
+5 -4
View File
@@ -1,6 +1,6 @@
# G1R Deluxe aka Gen1Recomp
A native LÖVE2D recreation of Poke Red, Blue, Yellow, Gold, Silver, Crystal, FireRed, and LeafGreen.
A native LÖVE2D recreation of Poke Red, Blue, Yellow, Gold, Silver, Crystal, FireRed, LeafGreen, and Emerald.
The engine and map behavior are hand-written Lua; game data and graphics are
decoded from a ROM supplied by the player.
@@ -61,7 +61,7 @@ And before you say, "that's not a recomp", you're wrong. Recomp is an acronym. *
## What this is
G1R Deluxe aka Gen1Recomp is a native LÖVE2D recreation of Pokemon Red, Blue, Yellow, Gold,
Silver, Crystal, FireRed, and LeafGreen. The engine and map behavior are
Silver, Crystal, FireRed, LeafGreen, and Emerald. The engine and map behavior are
hand-written Lua, ported from the [pret](https://github.com/pret)
disassemblies & C. Game data, graphics, and audio programs are decoded on first
launch from a ROM you supply.
@@ -70,7 +70,7 @@ The project does not include a ROM, emulate the Game Boy, transpile assembly,
or download a disassembly. Your ROM is verified, used during import, and
released from memory. It is never copied into the cache, and later launches
load the private generated cache without asking for it again. Music, sound
effects, and cries are synthesized while the game runs. All eight games can be
effects, and cries are synthesized while the game runs. All nine games can be
imported side by side. Gen 2 support is still under construction.
## Quick Start
@@ -84,7 +84,7 @@ imported side by side. Gen 2 support is still under construction.
4. Repeat for any other game you own. Each one gets its own tab in the launcher.
Only the canonical US English ROMs below are accepted. The importer checks the
SHA-1 before creating any game data. FireRed and LeafGreen support is in beta.
SHA-1 before creating any game data. FireRed, LeafGreen, and Emerald support is in beta.
| Game | Revision | ROM size | SHA-1 |
| --- | --- | --- | --- |
@@ -99,6 +99,7 @@ SHA-1 before creating any game data. FireRed and LeafGreen support is in beta.
| FireRed | 1.1 | 16 MiB | `dd5945db9b930750cb39d00c84da8571feebf417` |
| LeafGreen | 1.0 | 16 MiB | `574fa542ffebb14be69902d1d36f1ec0a4afd71e` |
| LeafGreen | 1.1 | 16 MiB | `7862c67bdecbe21d1d69ce082ce34327e1c6ed5e` |
| Emerald | 1.0 | 16 MiB | `f3ae088181bf583e55daf962a92bb46f4f1d07b7` |
**Platform notes:** [Linux](docs/platforms/linux.md),
[iOS](docs/platforms/ios.md), [Xbox Dev Mode](docs/platforms/xbox.md),
+4 -2
View File
@@ -1246,7 +1246,8 @@ local function clearFieldScreens()
FieldView.setCameraPanning(0, 0)
end
function Game3:returnToTitle()
function Game3:returnToTitle(opts)
opts = opts or {}
self.questPlayback=nil
Help.reset()
Audio.stopAll()
@@ -1285,7 +1286,8 @@ function Game3:returnToTitle()
end
Boot.setSaveStatus(self.boot, saveStatus)
Boot.setTextSpeed(self.boot, Options.block(self.options).textSpeed)
if not self.boot.custom then
if not self.boot.custom or opts.skipIntro then
if self.boot.custom then self.boot.custom.coldBoot = false end
self.boot.phase = Boot.PHASE.TITLE
self.boot.timer = 0
Boot.enterTitle(self.boot)
+14 -10
View File
@@ -421,6 +421,8 @@ function Audio.playSong(id, opts)
opts = opts or {}
id = Audio.resolveSong(id)
if id == nil or id == 0 or id == 0xFFFF then
Audio._fadeOut, Audio._fadeIn = nil, nil
Audio._fanfareRestore, Audio._fanfareDeferred = nil, nil
stop_bgm_source()
Audio._currentSong = nil
return true
@@ -428,7 +430,8 @@ function Audio.playSong(id, opts)
if opts.fanfare or (Audio.songInfo(id) and Audio.songInfo(id).kind == "fanfare") or (fanfare_entry(id) ~= nil) then
return Audio.playFanfare(id)
end
if not opts.restart and Audio._currentSong and Audio._currentSong.id == id then
if not opts.restart and not Audio._fadeOut and Audio._currentSong and Audio._currentSong.id == id then
if Audio._fanfareActive then Audio._fanfareDeferred = nil end
return true
end
local info = Audio.songInfo(id) or {}
@@ -488,8 +491,11 @@ function Audio.playSong(id, opts)
end
function Audio.currentMapMusic()
local pending = Audio._fadeOut and Audio._fadeOut.nextSong
return pending or (Audio._currentSong and Audio._currentSong.id) or 0
if Audio._fanfareActive and Audio._fanfareDeferred ~= nil then
return Audio._fanfareDeferred
end
if Audio._fadeOut then return Audio._fadeOut.nextSong or 0 end
return (Audio._currentSong and Audio._currentSong.id) or 0
end
local function play_policy_song(id, fadeOut, fadeIn)
@@ -630,9 +636,7 @@ function Audio.changeMusicTo(id)
if t then return Audio.fadeOutAndPlay(t.song, t.fadeOut) end
return true
end
local cur = Audio._currentSong and Audio._currentSong.id
local pending = Audio._fadeOut and Audio._fadeOut.nextSong
if (pending or cur) == id then return true end
if Audio.currentMapMusic() == id then return true end
return Audio.fadeOutAndPlay(id, 8)
end
@@ -671,9 +675,8 @@ function Audio.bikeMusic(on, forced)
end
Audio.setSavedSong(nil)
local id = Audio.specialMapSong()
local pending = Audio._fadeOut and Audio._fadeOut.nextSong
if id and id ~= (pending or (Audio._currentSong and Audio._currentSong.id)) then
Audio.playSong(id)
if id and id ~= Audio.currentMapMusic() then
play_policy_song(id)
end
end
@@ -693,7 +696,8 @@ end
function Audio.fadeDefaultBgm(speed)
if rse_policy() then return Audio.changeMusicToDefault() end
local id = Audio._mapSong
if id and Audio._currentSong and Audio._currentSong.id == id then return true end
if id and Audio.currentMapMusic() == id then return true end
if Audio._fanfareActive then return play_policy_song(id) end
Audio.fadeOutBgm(speed)
if id then return Audio.playSong(id) end
return true
+46
View File
@@ -1702,6 +1702,50 @@ function Field.flyDestination(section)
return baked[id]
end
-- pokefirered/src/overworld.c:289
local function resetCyclingRoadAfterTravel()
local session = Field._session
if not session then return end
local Profile = require("src.core.game3.profile")
local Flags = require("src.core.game3.scripting.flags")
local Runtime = package.loaded["src.core.game3.runtime"]
local Space = package.loaded["src.core.game3.scripting.space"]
local profile = Profile.forSession(session)
local defs = Flags.forVersion(profile.id)
local roadName = profile.family == "rse"
and "FLAG_SYS_CYCLING_ROAD" or "FLAG_SYS_ON_CYCLING_ROAD"
local road = assert(defs.IDS[roadName])
local scene = profile.family ~= "rse" and assert(defs.VAR_IDS.VAR_MAP_SCENE_ROUTE16)
local live = Runtime and Runtime.getSession and Runtime.getSession()
local seen = {}
for _, store in ipairs({ session, session.store or false, Space and Space.store or false,
live or false, live and live.store or false }) do
if store and not seen[store] then
seen[store] = true
Flags.setFlag(store, nil, road, false)
if store.flags then store.flags[roadName] = nil end
if scene then
Flags.setVar(store, nil, scene, 0)
if store.vars then
store.vars[tostring(scene)] = nil
store.vars[string.format("0x%X", scene)] = nil
store.vars.VAR_MAP_SCENE_ROUTE16 = nil
end
end
end
end
Player.biking, Player.bikeType = false, nil
Player.surfing, Player.surfHopping = false, false
for _, s in ipairs({ session, live or false }) do
if s then s.biking, s.bikeType = false, nil end
end
local save = Field._game and Field._game.save
if save then
save.biking, save.bikeType = false, nil
if save.position then save.position.biking = false end
end
end
-- pokefirered/src/field_effect.c:1065 ReturnToFieldFromFlyMapSelect
function Field.flyTo(section, mon, info)
local dest = (info and info.dest)
@@ -1716,6 +1760,7 @@ function Field.flyTo(section, mon, info)
local FieldEffects = require("src.core.game3.field_effects")
local function land()
local Map = require("src.core.game3.map")
resetCyclingRoadAfterTravel()
Map.load(Field._mod, Field._game, dest.map, {
x = dest.x, y = dest.y, facing = "down", depth1Connections = true,
})
@@ -1895,6 +1940,7 @@ end
function Field.respawnAtHeal(opts)
local session = Field._session
if not session then return end
resetCyclingRoadAfterTravel()
local HealLocations = require("src.core.game3.heal_locations")
HealLocations.normalizeSession(session)
local whiteOut = not (opts and opts.fieldMove)
+1 -1
View File
@@ -146,7 +146,7 @@ function Ghosts.capture(mapId)
local pool = { byId = {}, order = {}, bounds = snap.bounds }
for _, lid in ipairs(snap.order or {}) do
local eo = snap.byId[lid]
if eo then
if eo and eo.foreignMap == nil then
pool.byId[lid] = eo
pool.order[#pool.order + 1] = lid
eo.scriptBusy = false
+15 -3
View File
@@ -804,7 +804,7 @@ function Objects.forDraw()
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
and (eo.foreignMap ~= nil or not offMap(Objects._bounds, eo)) then
n = n + 1
list[n] = eo
end
@@ -1622,10 +1622,22 @@ local function idleTick(eo, game, ctx)
end
end
local trackIds, trackRefs, trackActors = {}, {}, {}
function Objects.update(game)
-- Advance script tracks then motion + idle.
local count = 0
for lid, tr in pairs(Objects._tracks) do
advanceTrack(lid, tr, game)
count = count + 1
trackIds[count], trackRefs[count], trackActors[count] = lid, tr, Objects.find(lid)
end
-- pokeemerald/src/event_object_movement.c:2167
for i = 1, count do
local tr, eo = trackRefs[i], trackActors[i]
local lid = (eo and eo.localId) or trackIds[i]
trackIds[i], trackRefs[i], trackActors[i] = nil, nil, nil
if Objects._tracks[lid] == tr and (not eo or Objects.find(lid) == eo) then
advanceTrack(lid, tr, game)
end
end
for _, lid in ipairs(Objects._order) do
local eo = Objects._byId[lid]
+1
View File
@@ -1547,6 +1547,7 @@ end
-- one visited, else Red. Kept as a function so the mods/find/skins panels
-- still answer "for which game" without a game tab being open.
local function gameTabs(imp)
SecretGames.update()
local launcher = type(imp) ~= "table" or imp.launcher and true or false
local c = imp and imp._gameTabs
if c and c.rev == SecretGames.rev and c.launcher == launcher then
+11
View File
@@ -1961,6 +1961,11 @@ function RomImporter:_versionForSha1(hash)
end
function RomImporter:_logoTap()
if SecretGames.update() then
self._secretPopup = nil
self._logoTaps = nil
return
end
if self._secretPopup or not SecretGames.anyLocked() then return end
self._logoTaps = (self._logoTaps or 0) + 1
if self._logoTaps >= SecretGames.TAPS then
@@ -3452,6 +3457,12 @@ function RomImporter:_pollPickedFiles(dt)
end
function RomImporter:update(dt)
local released, changed = SecretGames.update()
if released then
self._secretPopup = nil
self._logoTaps = nil
if changed then self._headerChrome = nil end
end
self.pulse = self.pulse + dt
if self._importerJob then self:_stepImporter() end
if not Transition.armed then
+10 -1
View File
@@ -4,6 +4,7 @@ local SecretGames = {}
SecretGames.GAMES = { emerald = "secretEmerald" }
SecretGames.TAPS = 20
SecretGames.EMERALD_RELEASE_AT = 1790856000
SecretGames.rev = 0
local unlocked
@@ -25,7 +26,7 @@ end
function SecretGames.visible(id)
if not SecretGames.GAMES[id] then return true end
if not unlocked then load() end
SecretGames.update()
return unlocked[id] == true
end
@@ -60,4 +61,12 @@ function SecretGames.unlock()
return saved
end
function SecretGames.update(now)
if not unlocked then load() end
local due = (now or os.time()) >= SecretGames.EMERALD_RELEASE_AT
local changed = due and unlocked.emerald ~= true
if changed then SecretGames.unlock() end
return due, changed
end
return SecretGames
+2 -5
View File
@@ -167,6 +167,7 @@ function Message.show(text, opts)
end
-- Prefer session options text speed when not overridden.
local instant = tonumber(opts.speed) == 0
local speed = opts.speed
if speed == nil then
local ok, Options = pcall(require, "src.core.game3.options")
@@ -182,10 +183,6 @@ function Message.show(text, opts)
Message._speedIdx = tonumber(speed) or 1
if Message._speedIdx < 0 then Message._speedIdx = 0 end
if Message._speedIdx > 2 then Message._speedIdx = 2 end
if tonumber(speed) == 0 then
-- Instant print (Oak repeat question)
Message._speedIdx = 0
end
local _, _, dlgW = Chrome.dialogueWindow()
local maxW = (opts.frame == "battle" or opts.battle) and 212 or dlgW * Display.TILE
@@ -204,7 +201,7 @@ function Message.show(text, opts)
Message._pages = split_pages(plain)
Message._page = 1
beginPage()
if tonumber(speed) == 0 then
if instant then
Message.skipReveal()
end
return Message
+1 -1
View File
@@ -253,7 +253,7 @@ function StartMenu.confirm()
or (Runtime and Runtime._game)
or StartMenu._game
if game and game.returnToTitle then
game:returnToTitle()
game:returnToTitle({ skipIntro = true })
end
else -- NO
StartMenu._confirmExit = false
+129 -23
View File
@@ -5,37 +5,143 @@ local d = X.new("em_briney_boat", "/tmp/em_briney_boat")
return function(game)
if not X.newGame(d, game, 0) then return d.finish() end
local Objects = require("src.core.game3.objects")
local Player = require("src.core.game3.player")
local Ghosts = require("src.core.game3.ghosts")
local Space = require("src.core.game3.scripting.space")
local Message = require("src.ui.game3.message")
local Fade = require("src.ui.game3.fade")
local state
local function monitor()
if not state then return end
local s = X.session()
if s and not state.maps[s.map] then
state.maps[s.map] = true
d.note(state.name .. " map " .. s.map .. " VM " .. X.vmWhere())
end
local boat, lid
for id, eo in pairs(Objects._byId) do
if eo.originMapId == state.origin and eo.originLocalId == state.localId then
boat, lid = eo, id
break
end
end
if not boat then return end
for _, pool in pairs(Ghosts._pools) do
for _, ghost in pairs(pool.byId) do
if ghost == boat then state.aliases = state.aliases + 1 end
end
end
local bt = Objects._tracks[lid]
local pt = Objects._tracks[Objects.PLAYER_LOCAL_ID]
if Player.visible == false and bt and pt and not bt.done and not pt.done then
local dx, dy = boat.px - Player.px, boat.py - Player.py
if state.dx == nil then state.dx, state.dy = dx, dy end
state.samples = state.samples + 1
if dx ~= state.dx or dy ~= state.dy then
state.drift = state.drift + 1
if state.drift == 1 then
d.note(string.format("%s drift at %s: expected %s,%s actual %s,%s",
state.name, s.map, state.dx, state.dy, dx, dy))
end
end
end
end
local function waitFor(pred, frames)
for i = 1, frames do
if pred() then return true end
if i % 6 == 0 then table.insert(game.input.pressQueue, "a") end
U.wait(1)
game.input.state.a = false
monitor()
end
return pred()
end
local function begin(name, origin, localId)
state = { name = name, origin = origin, localId = localId, maps = {},
samples = 0, drift = 0, aliases = 0 }
end
local function moment(label, file, pred, frames)
if not d.check(waitFor(pred, frames or 18000), label .. " (VM " .. X.vmWhere() .. ")") then
return false
end
d.check(d.still(game, file), label .. " screenshot")
return true
end
local function finishTrip(mapId, file)
moment(state.name .. " landing", file, function()
return not X.scriptRunning() and X.session().map == mapId and Player.visible ~= false
and not Fade.isActive() and not Fade.lockInput and (Fade.t or 0) <= 0
end)
d.check(state.samples > 0, state.name .. " samples sailing every simulation frame (" .. state.samples .. ")")
d.check(state.drift == 0, state.name .. " boat/player relative pixels remain constant (" .. state.drift .. ")")
d.check(state.aliases == 0, state.name .. " carried boat absent from every ghost pool (" .. state.aliases .. ")")
d.check(Player.visible ~= false, state.name .. " player visible after landing")
state = nil
end
local function start(label, localId, facing)
local key = Space.bundle.labels[label]
return d.check(key and Space.startScript(key, localId, facing), "cached " .. label .. " starts")
end
X.setFlag("FLAG_MR_BRINEY_SAILING_INTRO", true)
X.setFlag("FLAG_ENABLE_NORMAN_MATCH_CALL", true)
X.setFlag("FLAG_ENABLE_NORMAN_MATCH_CALL", false)
X.setFlag("FLAG_HIDE_BRINEYS_HOUSE_MR_BRINEY", false)
X.setFlag("FLAG_HIDE_BRINEYS_HOUSE_PEEKO", false)
X.setVar("VAR_BRINEY_HOUSE_STATE", 0)
d.check(X.goTo(d, game, "EM_ROUTE104_MR_BRINEYS_HOUSE", 5, 6, "up"), "Mr. Briney's house loads")
local briney = require("src.core.game3.objects").find(1)
local briney = Objects.find(1)
local bx, by = briney and briney.cellX or 5, briney and briney.cellY or 3
X.goTo(d, game, "EM_ROUTE104_MR_BRINEYS_HOUSE", bx, by + 1, "up")
local seen = {}
local finished = X.mash(game, function()
local s = X.session()
if s and s.map and not seen[s.map] then
seen[s.map] = true
d.note("map " .. s.map .. " VM " .. X.vmWhere())
end
return X.flag("FLAG_HIDE_ROUTE_104_MR_BRINEY_BOAT") and X.var("VAR_BOARD_BRINEY_BOAT_STATE") == 0
and not X.scriptRunning()
end, 6000, "a", 4)
d.check(seen.EM_ROUTE104, "SailToDewford warps to Route 104 and ON_FRAME boards the boat")
d.check(finished, "the sailing script runs every boat/player movement to the landing (VM " .. X.vmWhere() .. ")")
begin("Route104 Dad call", "EM_ROUTE104", 7)
table.insert(game.input.pressQueue, "a")
moment("Route104 boarded", "90002_route104_boarded.png", function()
return X.session().map == "EM_ROUTE104" and Player.visible == false
end)
moment("Route105 first seam", "90002_route105_first_seam.png", function()
return X.session().map == "EM_ROUTE105"
end)
moment("Dad call fully revealed", "90002_dad_call.png", function()
return Player.visible == false and Message.isOpen() and not Message.isTyping()
end)
moment("Route106 second seam", "90002_route106_second_seam.png", function()
return X.session().map == "EM_ROUTE106"
end)
finishTrip("EM_DEWFORD_TOWN", "90002_dewford_landed.png")
d.check(X.flag("FLAG_ENABLE_NORMAN_MATCH_CALL"), "first sailing trip enables Dad match call")
d.check(X.flag("FLAG_HIDE_MR_BRINEY_DEWFORD_TOWN") == false, "Dewford Briney shown after landing")
local s = X.session()
local Player = require("src.core.game3.player")
if s and s.map == "EM_DEWFORD_TOWN" then
d.check(true, "player ends in Dewford Town")
else
d.note(string.format("NOTE scripted movement crossed the Route 104 -> Dewford connection but the map did not switch " ..
"(still %s at %s,%s); connection transition during applymovement is a field/connections owner item",
tostring(s and s.map), tostring(Player.cellX), tostring(Player.cellY)))
for _, trip in ipairs({
{ name = "Dewford Petalburg", label = "DewfordTown_EventScript_SailToPetalburg",
seam = "EM_ROUTE106", landing = "EM_ROUTE104_MR_BRINEYS_HOUSE", prefix = "90002_petalburg" },
{ name = "Dewford Slateport", label = "DewfordTown_EventScript_SailToSlateport",
seam = "EM_ROUTE107", landing = "EM_ROUTE109", prefix = "90002_slateport" },
}) do
X.setFlag("FLAG_HIDE_MR_BRINEY_DEWFORD_TOWN", false)
X.setFlag("FLAG_HIDE_MR_BRINEY_BOAT_DEWFORD_TOWN", false)
d.check(X.goTo(d, game, "EM_DEWFORD_TOWN", 11, 9, "right"), trip.name .. " departure loads")
begin(trip.name, "EM_DEWFORD_TOWN", 4)
if start(trip.label, 2, 4) then
moment(trip.name .. " first seam", trip.prefix .. "_first_seam.png", function()
return X.session().map == trip.seam and Player.visible == false
end)
finishTrip(trip.landing, trip.prefix .. "_landed.png")
end
end
for _, approach in ipairs({
{ name = "south", x = 21, y = 23, facing = "down", dir = 1 },
{ name = "east", x = 20, y = 24, facing = "right", dir = 4 },
{ name = "west", x = 22, y = 24, facing = "left", dir = 3 },
}) do
X.setFlag("FLAG_HIDE_ROUTE_109_MR_BRINEY", false)
X.setFlag("FLAG_HIDE_ROUTE_109_MR_BRINEY_BOAT", false)
d.check(X.goTo(d, game, "EM_ROUTE109", approach.x, approach.y, approach.facing),
"Route109 " .. approach.name .. " departure loads")
begin("Route109 " .. approach.name, "EM_ROUTE109", 1)
if start("Route109_EventScript_StartDepartForDewford", 2, approach.dir) then
moment(state.name .. " first seam", "90002_return_" .. approach.name .. "_first_seam.png", function()
return X.session().map == "EM_ROUTE108" and Player.visible == false
end)
finishTrip("EM_DEWFORD_TOWN", "90002_return_" .. approach.name .. "_landed.png")
end
end
d.shot(game, "01_landed_in_dewford.png")
d.finish()
end
+113
View File
@@ -0,0 +1,113 @@
local U = require("tests.drivers.util")
local DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/em_start_menu_exit_90001"
return function(game)
local failures = 0
local overallDeadline = love.timer.getTime() + 25
local SaveData, originalSave
local function check(ok, label)
print((ok and "PASS " or "FAIL ") .. label)
if not ok then failures = failures + 1 end
return ok
end
local function waitUntil(pred, seconds)
local deadline = math.min(overallDeadline, love.timer.getTime() + seconds)
while love.timer.getTime() < deadline do
if pred() then return true end
U.wait(1)
end
return pred()
end
local ok, err = xpcall(function()
local identity = os.getenv("POKEPORT_IDENTITY") or ""
if not check(identity:match("^bsa0930%-") ~= nil, "isolated campaign identity") then return end
if not check(waitUntil(function() return game.boot ~= nil end, 5), "Emerald boot ready") then return end
local Boot = require("src.ui.game3.boot")
local StartMenu = require("src.ui.game3.start_menu")
local Runtime = require("src.core.game3.runtime")
SaveData = require("src.core.SaveData")
if not check(not SaveData.isPortable(), "save fixtures use isolated identity storage") then return end
originalSave = SaveData.save
local saves = 0
SaveData.save = function(...) saves = saves + 1; return originalSave(...) end
local Message = require("src.ui.game3.message")
local Field = require("src.core.game3.field")
local Fade = require("src.ui.game3.fade")
local Hud = require("src.ui.game3.hud")
local Space = require("src.core.game3.scripting.space")
local Warp = require("src.core.game3.warp")
local Forced = require("src.core.game3.forced_movement")
local Player = require("src.core.game3.player")
local function idle()
if Message.isOpen() then U.tap(game, "b") end
local running = Space.vm and Space.vm.isRunning and Space.vm:isRunning()
return game.phase == "field" and not Hud.busy() and not Fade.isActive() and not Fade.lockInput
and not Field.locked and not running and not Warp.isBusy() and not Forced.isForced()
and not Player.moving and Player.boulderPush == nil
end
for caseIndex, saved in ipairs({ false, true }) do
local prefix = saved and "saved" or "unsaved"
local slot = SaveData.createSlot("emerald")
if not check(slot and SaveData.setActiveSlot("emerald", slot) == slot, prefix .. " fresh save slot selected") then return end
game:_handleBootAction({ action = "new_game", name = "BRENDAN",
start = { map = "EM_LITTLEROOT_TOWN", x = 5, y = 9, facing = "down" } })
print("INFO " .. prefix .. " initial fade=" .. tostring(Fade.isActive()) .. " hudBusy=" .. tostring(Hud.busy()))
if not check(waitUntil(idle, 4), prefix .. " field ready after fade and script completion") then return end
if saved then
if not check(game:saveGame(), "saved fixture writes a real Emerald save") then return end
end
if not check(game:_hasContinueSave() == saved, prefix .. " expected continue availability before EXIT") then return end
local session, saveCount = game.session, saves
local rawBefore = SaveData.readSlotSource("emerald", slot)
U.tap(game, "start")
if not check(StartMenu.isOpen(), prefix .. " START opens Emerald menu") then return end
local exitIndex
for i, entry in ipairs(StartMenu.ENTRIES) do if entry.id == "exit" then exitIndex = i end end
if not check(exitIndex ~= nil, prefix .. " Emerald EXIT present") then return end
for _ = 1, #StartMenu.ENTRIES do
if StartMenu.cursor == exitIndex then break end
U.tap(game, "down")
end
U.tap(game, "a")
check(StartMenu._confirmExit and StartMenu._confirmCursor == 2, prefix .. " EXIT confirmation defaults NO")
check(U.still(game, DIR .. "/" .. string.format("%02d", (caseIndex - 1) * 3 + 1) .. "_" .. prefix .. "_exit_default_no.png"), prefix .. " default NO screenshot")
U.tap(game, "b")
check(StartMenu.isOpen() and game.session == session, prefix .. " EXIT B preserves field session")
U.tap(game, "a")
U.tap(game, "a")
check(StartMenu.isOpen() and game.session == session, prefix .. " EXIT NO preserves field session")
U.tap(game, "a")
U.tap(game, "up")
U.tap(game, "a")
local direct = game.boot and game.boot.phase == Boot.PHASE.TITLE and game.boot.custom.title
and not game.boot.custom.intro
if not check(direct, prefix .. " EXIT YES enters title directly") then return end
check(game.session == nil and not Runtime.isActive(), prefix .. " EXIT YES stops field runtime")
check(saves == saveCount and SaveData.readSlotSource("emerald", slot) == rawBefore, prefix .. " EXIT does not implicitly save")
check(game.boot.hasContinue == saved, prefix .. " EXIT preserves existing continue availability")
check(game.boot.custom.coldBoot == false, prefix .. " EXIT consumes cold boot")
if not check(waitUntil(function()
local title = game.boot.custom.title
if not title or title.phase ~= "phase3" then return false end
for _, sprite in pairs(title.m.ppu.sprites.sprites) do
if sprite.inUse and sprite.y == 108 and sprite.data[0] == 1 and not sprite.invisible then return true end
end
return false
end, 5), prefix .. " Emerald title settled") then return end
check(U.still(game, DIR .. "/" .. string.format("%02d", (caseIndex - 1) * 3 + 2) .. "_" .. prefix .. "_exit_title.png"), prefix .. " settled title screenshot")
U.tap(game, "start")
if not check(waitUntil(function()
local menu = game.boot.custom.menu
return game.boot.phase == Boot.PHASE.MENU and menu and menu.state == "input"
end, 4), prefix .. " title START reaches settled main menu") then return end
local menu = game.boot.custom.menu
check(menu.hasContinue == saved and menu.items[1] == (saved and "CONTINUE" or "NEW_GAME"), prefix .. " main menu offers correct first action")
if saved then check(menu.info and menu.info.name == "BRENDAN", "saved main menu retains trainer metadata") end
check(U.still(game, DIR .. "/" .. string.format("%02d", (caseIndex - 1) * 3 + 3) .. "_" .. prefix .. "_main_menu.png"), prefix .. " main menu screenshot")
end
end, debug.traceback)
if SaveData and originalSave then SaveData.save = originalSave end
if not ok then check(false, tostring(err)) end
print((failures == 0 and "PASS" or "FAIL") .. " em_start_menu_exit_90001 failures=" .. failures)
love.event.quit(failures == 0 and 0 or 1)
end
+2 -2
View File
@@ -90,7 +90,7 @@ return function(game)
"Naomi wanders up and down from the sealed band at (14,5)")
U.wait(20)
U.shot(game, DIR .. "/collision_npc_edge_01_naomi_on_the_band.png")
U.still(game, DIR .. "/collision_npc_edge_01_naomi_on_the_band.png")
local visited = {}
local shotSouth = false
@@ -99,7 +99,7 @@ return function(game)
visited[naomi.cellX .. "," .. naomi.cellY] = true
visited[naomi.targetX .. "," .. naomi.targetY] = true
if not shotSouth and naomi.cellY == 6 and not naomi.moving then
U.shot(game, DIR .. "/collision_npc_edge_02_naomi_stepped_south.png")
U.still(game, DIR .. "/collision_npc_edge_02_naomi_stepped_south.png")
shotSouth = true
end
end
+122
View File
@@ -0,0 +1,122 @@
local U = require("tests.drivers.util")
local DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/game3_cycling_fly_bug2564"
local failures = 0
local function check(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 ") .. "cycling_fly_bug2564")
love.event.quit(failures == 0 and 0 or 1)
end
return function(game)
for _ = 1, 900 do
if game.phase == "boot" and game.boot then break end
U.wait(1)
end
if not check(game.boot ~= nil, "cycling Fly boot reached") then return finish() end
game:_handleBootAction({ action = "new_game", name = "RED", gender = 0 })
U.wait(240)
local Runtime = require("src.core.game3.runtime")
local session = Runtime.getSession()
if not check(session ~= nil, "cycling Fly field reached") then return finish() end
local Map = require("src.core.game3.map")
local Player = require("src.core.game3.player")
local Field = require("src.core.game3.field")
local Flags = require("src.core.game3.scripting.flags")
local Space = require("src.core.game3.scripting.space")
local Message = require("src.ui.game3.message")
local Fade = require("src.ui.game3.fade")
local ItemUse = require("src.core.game3.item_use")
local Bag = require("src.core.game3.bag")
local Party = require("src.core.game3.party")
local C = require("src.core.game3.constants").of(session.version)
local rse = session.version == "emerald"
local defs = Flags.forVersion(session.version)
local road = assert(defs.IDS[rse and "FLAG_SYS_CYCLING_ROAD" or "FLAG_SYS_ON_CYCLING_ROAD"])
local scene = not rse and assert(defs.VAR_IDS.VAR_MAP_SCENE_ROUTE16)
local bike = C:require("items", rse and "ITEM_MACH_BIKE" or "ITEM_BICYCLE")
Bag.add(session.bag, bike, 1)
if not rse then Flags.setFlag(Space.store, nil, assert(defs.IDS.FLAG_GOT_BICYCLE), true) end
local function waitFor(pred, limit)
for _ = 1, limit do
if pred() then return true end
U.wait(1)
end
return pred()
end
local function settle()
return waitFor(function()
if Message.isOpen() then
if Message.isTyping() then Message.skipReveal() end
table.insert(game.input.pressQueue, "a")
end
return not Field.locked and not Fade.active and not Player.moving
and not Message.isOpen() and not (Space.vm and Space.vm:isRunning())
end, 600)
end
local function holdUntil(dir, pred, limit)
local reached = false
for _ = 1, limit do
if pred() then reached = true break end
table.insert(game.input.pressQueue, dir)
game.input.state[dir] = true
U.wait(1)
end
game.input.state[dir] = false
return reached or pred()
end
local gate = rse and "EM_ROUTE110_SEASIDE_CYCLING_ROAD_NORTH_ENTRANCE"
or "FR_ROUTE_16_NORTH_ENTRANCE_1F"
Map.load(nil, game, gate, { x = rse and 6 or 3, y = rse and 4 or 12,
facing = rse and "right" or "left" })
if not check(settle(), "cycling gate scripts settle") then return finish() end
check(game.data.maps[gate].bikingAllowed == 1, "cycling gate permits riding indoors")
check(ItemUse.useBike(session, bike) and Player.biking, "cycling gate bike mounts")
U.still(game, DIR .. "/cycling_gate_bike.png")
if rse then
if not check(holdUntil("right", function()
return Player.targetX == 12 and Player.targetY == 4
end, 240), "Emerald gate crosses BikeCheck and reaches east exit column") then return finish() end
end
if not check(holdUntil(rse and "down" or "left", function() return session.map ~= gate end, 600),
"cycling gate enters road through coordinate script and warp") then return finish() end
if not check(settle(), "cycling road arrival settles") then return finish() end
check(Flags.getFlag(Space.store, nil, road), "cycling road flag armed by real gate scripts")
if scene then check(Flags.getVar(Space.store, nil, scene) == 1, "Route 16 scene armed") end
check(Player.biking, "cycling road requires bike before Fly")
for _, eo in ipairs(require("src.core.game3.objects").forDraw()) do eo.sight = 0 end
session.party = {}
Party.giveMon(session, C:require("species", rse and "SPECIES_SWELLOW" or "SPECIES_CHARIZARD"), 40)
local section = rse and "MAPSEC_MAUVILLE_CITY" or "MAPSEC_CELADON_CITY"
local dest = assert(Field.flyDestination(section))
check(Field.flyTo(section, session.party[1]), "cycling road Fly starts")
if not check(waitFor(function()
return session.map == dest.map and not Field._flyLanding and not Field.locked and not Fade.active
end, 2400), "cycling Fly landing finishes") then return finish() end
check(not Flags.getFlag(Space.store, nil, road), "Fly clears cycling flag")
if scene then check(Flags.getVar(Space.store, nil, scene) == 0, "Fly clears Route 16 scene") end
check(not Player.biking and not session.biking and not game.save.biking, "Fly lands on foot in every state mirror")
U.still(game, DIR .. "/fly_city_on_foot.png")
local center = rse and "EM_MAUVILLE_CITY_POKEMON_CENTER_1F" or "FR_CELADON_CITY_POKEMON_CENTER_1F"
if not check(holdUntil("up", function() return session.map == center end, 300),
"walking north enters real Center door") then return finish() end
if not check(settle(), "Center warp finishes") then return finish() end
check(game.data.maps[center].bikingAllowed == 0 and not Player.biking and not session.biking,
"Center remains on foot after cycling road Fly")
U.still(game, DIR .. "/center_on_foot.png")
if not check(holdUntil("down", function() return session.map == dest.map end, 300),
"Center exit returns to city") then return finish() end
if not check(settle(), "Center exit settles") then return finish() end
check(ItemUse.useBike(session, bike) and Player.biking, "city bike mounts after cycling road Fly")
U.still(game, DIR .. "/city_bike_toggle.png")
check(ItemUse.useBike(session, bike) and not Player.biking, "city bike dismounts after cycling road Fly")
if rse then
local acro = C:require("items", "ITEM_ACRO_BIKE")
check(ItemUse.useBike(session, acro) and Player.biking and Player.bikeType == "acro", "city Acro Bike mounts")
check(ItemUse.useBike(session, acro) and not Player.biking, "city Acro Bike dismounts")
end
finish()
end
+109
View File
@@ -0,0 +1,109 @@
local U = require("tests.drivers.util")
local DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/game3_message_speed_2570"
local failures = 0
local function check(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 ") .. "message_speed_2570")
love.event.quit(failures == 0 and 0 or 1)
end
return function(game)
for _ = 1, 900 do
if game.phase == "boot" and game.boot then break end
U.wait(1)
end
if not check(game.boot ~= nil, "message speed boot reached") then return finish() end
game:_handleBootAction({ action = "new_game", name = "RED", gender = 0 })
U.wait(240)
local Runtime = require("src.core.game3.runtime")
local session = Runtime.getSession()
if not check(session ~= nil, "message speed field reached") then return finish() end
local Options = require("src.core.game3.options")
local Message = require("src.ui.game3.message")
local Objects = require("src.core.game3.objects")
local Player = require("src.core.game3.player")
local Field = require("src.core.game3.field")
local Space = require("src.core.game3.scripting.space")
local Map = require("src.core.game3.map")
local Flags = require("src.core.game3.scripting.flags")
local Fade = require("src.ui.game3.fade")
local rse = session.version == "emerald"
local defs = Flags.forVersion(session.version)
local mapId = rse and "EM_OLDALE_TOWN_POKEMON_CENTER_1F" or "FR_VIRIDIAN_CITY_POKEMON_CENTER_1F"
local function waitFor(pred, limit)
for _ = 1, limit do
if pred() then return true end
U.wait(1)
end
return pred()
end
local realShow = Message.show
local observed
Message.show = function(text, opts)
local result = realShow(text, opts)
observed = { first = Message._pages[1], count = #Message._pages, typing = Message.isTyping() }
return result
end
local cases = rse and {
{ id = 2, name = "oldale_gentleman" },
{ id = 3, name = "oldale_boy" },
{ id = 4, name = "oldale_girl_unavailable", dex = false },
{ id = 4, name = "oldale_girl_available", dex = true },
} or {
{ id = 2, name = "viridian_gentleman" },
{ id = 3, name = "viridian_boy" },
}
for _, case in ipairs(cases) do
for speed = 0, 2 do
Options.set(session, "textSpeed", speed)
if rse and case.dex ~= nil then
Flags.setFlag(Space.store, nil, assert(defs.IDS.FLAG_SYS_POKEDEX_GET), case.dex)
end
Map.load(nil, game, mapId, { x = 7, y = 7, facing = "up" })
if not check(waitFor(function()
return not Field.locked and not Fade.active and not (Space.vm and Space.vm:isRunning())
end, 600), case.name .. " map settles") then Message.show = realShow return finish() end
local eo = Objects.find(case.id)
if not check(eo ~= nil, case.name .. " NPC exists") then Message.show = realShow return finish() end
eo.frozen = true
Player.reset(eo.cellX, eo.cellY + 1, "up")
Player.syncToHost(game)
observed = nil
U.tap(game, "a")
if not check(waitFor(function() return observed ~= nil and Message.isOpen() end, 120),
case.name .. " opens from one A") then Message.show = realShow return finish() end
check(observed.count >= 2, case.name .. " cached ROM text contains multiple paragraphs")
check(observed.typing, case.name .. " option " .. speed .. " starts by typing")
check(Message._page == 1 and Message.currentPage() == observed.first,
case.name .. " option " .. speed .. " preserves first paragraph after opening A")
if not check(waitFor(Message.isWaiting, 1600), case.name .. " first paragraph prints") then
Message.show = realShow return finish()
end
if speed == 0 then U.still(game, DIR .. "/" .. case.name .. "_first.png") end
for page = 2, observed.count do
U.tap(game, "a")
check(Message._page == page, case.name .. " fresh A advances to paragraph " .. page)
if not check(waitFor(Message.isWaiting, 1600), case.name .. " paragraph " .. page .. " prints") then
Message.show = realShow return finish()
end
end
if speed == 0 then U.still(game, DIR .. "/" .. case.name .. "_last.png") end
for _ = 1, 120 do
if not Message.isOpen() and not (Space.vm and Space.vm:isRunning()) and not Field.locked then break end
U.tap(game, "a")
U.wait(2)
end
check(not Message.isOpen() and not (Space.vm and Space.vm:isRunning()) and not Field.locked,
case.name .. " script terminates and unlocks after final paragraph")
end
end
Message.show = realShow
Message.show("Instant", { speed = 0 })
check(Message.isWaiting(), "explicit speed zero remains instant in live field printer")
Message.close()
finish()
end
+207
View File
@@ -0,0 +1,207 @@
local U = require("tests.drivers.util")
local DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/game3_music_transition"
local function le16(n)
return string.char(n % 256, math.floor(n / 256) % 256)
end
local function le32(n)
return le16(n % 65536) .. le16(math.floor(n / 65536))
end
local function writeWav(path, sd)
local data, rate, channels = sd:getString(), sd:getSampleRate(), sd:getChannelCount()
local file = assert(io.open(path, "wb"))
file:write("RIFF", le32(36 + #data), "WAVEfmt ", le32(16), le16(1), le16(channels),
le32(rate), le32(rate * channels * 2), le16(channels * 2), le16(16), "data", le32(#data), data)
file:close()
end
local function peak(sd)
local value = 0
for i = 0, sd:getSampleCount() - 1 do
for ch = 1, sd:getChannelCount() do value = math.max(value, math.abs(sd:getSample(i, ch))) end
end
return value
end
return function(game)
local failures = 0
local deadline = love.timer.getTime() + 25
local Audio, realOut, realCmd, realSource
local function check(ok, label)
print((ok and "PASS " or "FAIL ") .. label)
if not ok then failures = failures + 1 end
return ok
end
local function waitUntil(pred, seconds)
local untilTime = math.min(deadline, love.timer.getTime() + seconds)
while love.timer.getTime() < untilTime do
if pred() then return true end
U.wait(1)
end
return pred()
end
local ok, err = xpcall(function()
if not check((os.getenv("POKEPORT_IDENTITY") or ""):match("^bsa0930%-") ~= nil,
"isolated campaign identity") then return end
game.driverSpeed = 1
game.speedOverride = 1
if not check(waitUntil(function() return game.boot ~= nil end, 4), "Gen3 boot ready") then return end
local Version = require("src.core.GameVersion")
local version = Version.get()
local emerald = version == "emerald"
local mapA, mapB = emerald and "EM_LITTLEROOT_TOWN" or "FR_PALLET_TOWN",
emerald and "EM_ROUTE101" or "FR_ROUTE_1"
local x, y = emerald and 5 or 10, emerald and 9 or 8
game:_handleBootAction({ action = "new_game", name = "RED",
start = { map = mapA, x = x, y = y, facing = "down" } })
Audio = require("src.core.game3.audio")
local Map = require("src.core.game3.map")
local Runtime = require("src.core.game3.runtime")
local Message = require("src.ui.game3.message")
local Field = require("src.core.game3.field")
local Hud = require("src.ui.game3.hud")
local Fade = require("src.ui.game3.fade")
local adapter = require("src.core.game3.scripting.adapters").host(nil, game, nil)
if not check(waitUntil(function()
if Message.isOpen() then U.tap(game, "b") end
return not Field.locked and not Message.isOpen() and not Hud.busy() and not Fade.active
end, 3), "Gen3 field settled") then return end
print(string.format("AUDIO ready=%s root=%s pack=%s worker=%s", tostring(Audio._ready),
tostring(Audio._root), tostring(Audio._pack ~= nil), tostring(Audio._worker ~= nil and Audio._worker ~= false)))
if not check(waitUntil(Audio.isReady, 3), "real audio pack ready") then return end
local defs = game.data and game.data.maps
local rawA, rawB = defs and defs[mapA] and defs[mapA].music, defs and defs[mapB] and defs[mapB].music
local a, b = Audio.resolveSong(rawA), Audio.resolveSong(rawB)
print(string.format("MAP SONGS A=%s raw=%s id=%s B=%s raw=%s id=%s", mapA, tostring(rawA),
tostring(a), mapB, tostring(rawB), tostring(b)))
if not check(type(a) == "number" and type(b) == "number" and a ~= b and Audio.songInfo(a)
and Audio.songInfo(b), "cached neighboring maps resolve distinct BGM") then return end
local ff = Audio.songs().MUS_LEVEL_UP
if not check(ff and waitUntil(function() return Audio._fanfareSd[ff] ~= nil end, 4),
"real fanfare PCM baked") then return end
local captured, commands = {}, {}
local messages = setmetatable({}, { __mode = "k" })
realOut, realCmd, realSource = Audio._outCh, Audio._cmdCh, Audio._bgmSource
if not check(realOut and realCmd and realSource, "real worker and queueable source ready") then return end
Audio._outCh = setmetatable({}, { __index = function(_, key)
if key == "pop" then return function()
local msg = realOut:pop()
if type(msg) == "table" and msg.data then messages[msg.data] = msg end
return msg
end end
return function(_, ...) return realOut[key](realOut, ...) end
end })
Audio._cmdCh = setmetatable({}, { __index = function(_, key)
if key == "push" then return function(_, msg)
commands[#commands + 1] = msg
return realCmd:push(msg)
end end
return function(_, ...) return realCmd[key](realCmd, ...) end
end })
Audio._bgmSource = setmetatable({}, { __index = function(_, key)
if key == "queue" then return function(_, sd)
local queued = realSource:queue(sd)
local msg = messages[sd]
if queued ~= false and msg then
captured[#captured + 1] = { gen = msg.gen, epoch = msg.epoch, at = msg.at, sd = sd, peak = peak(sd) }
end
return queued
end end
local value = realSource[key]
if type(value) ~= "function" then return value end
return function(_, ...) return value(realSource, ...) end
end })
local function go(map)
Map.load(nil, game, map, { x = x, y = y, facing = "down" })
return Runtime.getSession().map == map
end
local function pcm(label, from)
local packet
local found = waitUntil(function()
for i = (from or 0) + 1, #captured do
local c = captured[i]
if c.gen == a and c.epoch == Audio._bgmEpoch and c.peak > 0 then packet = c; return true end
end
return false
end, 3)
check(found and Audio._bgmGen == a and Audio._bgmSource:isPlaying(), label .. " queues audible latest A PCM")
if packet then
writeWav(DIR .. "/" .. label .. ".wav", packet.sd)
print(string.format("PCM %s gen=%s epoch=%s at=%s samples=%d peak=%.6f",
label, tostring(packet.gen), tostring(packet.epoch), tostring(packet.at), packet.sd:getSampleCount(), packet.peak))
end
return found
end
check(U.still(game, DIR .. "/01_initial_map.png"), "initial map screenshot")
for _, speed in ipairs({ 1, 200 }) do
game.speedOverride = speed
local prefix = version .. "_" .. speed .. "x"
check(game:logicSpeed() == speed, prefix .. " uses requested simulation speed")
check(go(mapA), prefix .. " starts on map A")
Audio.playSong(a, { restart = true })
if not pcm(prefix .. "_before", #captured) then return end
local epoch, count = Audio._bgmEpoch, #commands
Audio.playSong(a)
check(Audio._bgmEpoch == epoch and #commands == count, prefix .. " stable same-song avoids restart")
local from = #captured
Audio.fadeOutAndPlay(b, 1)
if not emerald then
Audio.fadeDefaultBgm(1)
check(Audio._fadeOut == nil and Audio._currentSong.id == a, prefix .. " default map replaces pending B fade")
end
check(go(mapA), prefix .. " reenters A during pending fade to B")
if not pcm(prefix .. "_fade_return", from) then return end
check(Audio._fadeOut == nil and Audio._currentSong.id == a, prefix .. " newer A owns interrupted fade")
from = #captured
Audio.playFanfare(ff)
check(Audio._fanfareActive and Audio._fanfareSource and Audio._fanfareSource:isPlaying(), prefix .. " real fanfare starts")
if not emerald then
Audio.changeMusicTo(b)
Audio.changeMusicTo(a)
check(Audio._fanfareDeferred == a, prefix .. " changeMusicTo replaces deferred B with A")
Audio.bikeMusic(true, true)
Audio.bikeMusic(false)
check(Audio._fanfareDeferred == a, prefix .. " bike dismount replaces deferred cycling with A")
Audio.changeMusicTo(b)
Audio.fadeDefaultBgm(1)
check(Audio._fanfareDeferred == a and Audio._fanfareActive and Audio._bgmPaused,
prefix .. " default map replaces deferred B while preserving fanfare")
end
check(go(mapB) and go(mapA), prefix .. " crosses B and returns A during fanfare")
check(Audio._fanfareDeferred == a, prefix .. " latest map A replaces deferred B")
Audio.fadeOutAndPlay(b, 1)
check(Audio.currentMapMusic() == b, prefix .. " script fixture defers older B during fanfare")
epoch, count = Audio._bgmEpoch, #commands
adapter.playBgm(a)
check(Audio.currentMapMusic() == a and Audio._fanfareDeferred == nil,
prefix .. " actual script playBgm(A) supersedes fanfare-deferred B")
check(Audio._fanfareActive and Audio._bgmPaused and Audio._bgmEpoch == epoch and #commands == count,
prefix .. " actual script same-A preserves fanfare and physical continuation")
if not check(waitUntil(function() return not Audio._fanfareActive end, 4), prefix .. " fanfare completes") then return end
check(Audio.currentMapMusic() == a, prefix .. " actual script fanfare finishes on latest A")
if not pcm(prefix .. "_fanfare_return", from) then return end
check(U.still(game, DIR .. "/" .. prefix .. "_returned_map.png"), prefix .. " returned map screenshot")
from = #captured
Audio.fadeOutBgm(1)
Audio.playSong(a)
if not pcm(prefix .. "_standalone_fade", from) then return end
check(Audio._fadeOut == nil and Audio._currentSong.id == a, prefix .. " same-song cancels standalone fade")
Audio.playFanfare(ff)
Audio.playSong(0)
count = #commands
if not check(waitUntil(function() return not Audio._fanfareActive end, 4), prefix .. " stopped-BGM fanfare completes") then return end
local revived = false
for i = count + 1, #commands do if commands[i].cmd == "play" then revived = true end end
check(Audio._bgmGen == nil and Audio._currentSong == nil and not Audio._bgmSource:isPlaying()
and not revived, prefix .. " explicit stop stays silent without revived play")
end
end, debug.traceback)
if Audio and realOut then Audio._outCh, Audio._cmdCh, Audio._bgmSource = realOut, realCmd, realSource end
if not ok then check(false, tostring(err)) end
print((failures == 0 and "PASS" or "FAIL") .. " game3_music_transition failures=" .. failures)
love.event.quit(failures == 0 and 0 or 1)
end
+1 -1
View File
@@ -61,7 +61,7 @@ return function(game)
and session.x == x and session.y == y, label)
end
local function shot(name)
return result(U.shot(game, DIR .. "/2394_" .. name .. ".png"), "shot_" .. name)
return result(U.still(game, DIR .. "/2394_" .. name .. ".png"), "shot_" .. name)
end
local function phases(name)
U.wait(90)
+12 -13
View File
@@ -84,12 +84,15 @@ return function(game)
end
local imp
local pending = nil
local drawSerial = 0
local Transition = require("src.ui.kit.Transition")
local realDraw = love.draw
love.draw = function()
if imp then imp:draw() end
if pending then
local path = pending
pending = nil
drawSerial = drawSerial + 1
if game.capturePath then
local path = game.capturePath
game.capturePath = nil
love.graphics.captureScreenshot(function(imagedata)
local f = io.open(path, "wb")
if f then f:write(imagedata:encode("png"):getString()) f:close() end
@@ -107,15 +110,10 @@ return function(game)
end
end
local function shot(name)
pending = dir .. "/" .. name
for _ = 1, 30 do
if not pending then break end
step(1)
end
step(2)
local f = io.open(dir .. "/" .. name, "rb")
U.log(f and "shot" or "FAIL shot", name)
if f then f:close() end
local before, deadline = drawSerial, love.timer.getTime() + 3
while love.timer.getTime() < deadline and (drawSerial == before or Transition.active()) do U.wait(1) end
expect(drawSerial > before and not Transition.active(), "shot draw settled " .. name)
expect(U.still(game, dir .. "/" .. name), "shot " .. name)
end
imp = newLauncher()
@@ -182,6 +180,7 @@ return function(game)
LauncherMods.checkDependencies = realDeps
Platform.canFetchRemote = realRemote
imp = nil
love.draw = realDraw
U.log("done")
love.event.quit(failed and 1 or 0)
end
@@ -0,0 +1,134 @@
return function(game)
local U = require("tests.drivers.util")
local identity = os.getenv("POKEPORT_IDENTITY")
if not identity or identity:lower():find("pokemon-love2d", 1, true) then
print("FAIL launcher_emerald_release_90005 requires an isolated identity")
love.event.quit(1)
return
end
local SaveData = require("src.core.SaveData")
local SecretGames = require("src.import.SecretGames")
local RomImporter = require("src.import.RomImporter")
local LauncherView = require("src.import.LauncherView")
local GameVersion = require("src.core.GameVersion")
local Kit = require("src.ui.kit.Kit")
local dir = assert(os.getenv("POKEPORT_SHOT_DIR"), "POKEPORT_SHOT_DIR is required")
local originalSecret = SaveData.loadOptions().secretEmerald == true
local realTime, now = os.time, 1790855999
local realSaveOptions = SaveData.saveOptions
local originalDraw, originalUpdate = rawget(game, "draw"), rawget(game, "update")
local failures, drawn, writes = 0, 0, 0
local imp
local function check(ok, label)
print((ok and "PASS " or "FAIL ") .. label)
if not ok then failures = failures + 1 end
return ok
end
local function finish()
os.time = realTime
SaveData.saveOptions = realSaveOptions
SaveData.saveOptions({ secretEmerald = originalSecret })
game.draw, game.update = originalDraw, originalUpdate
print((failures == 0 and "PASS " or "FAIL ") .. "launcher_emerald_release_90005")
love.event.quit(failures == 0 and 0 or 1)
end
os.time = function(t)
if t then return realTime(t) end
return now
end
SaveData.saveOptions({ secretEmerald = false })
SecretGames.reload()
imp = RomImporter.new(function() end, { launcher = true, reduceMotion = true })
if not check(imp.ready.emerald == true, "existing Emerald cache is ready") then
finish()
return
end
local flags = { resizable = true, highdpi = true, fullscreen = false }
if os.getenv("POKEPORT_BACKGROUND") == "1" then
flags.x, flags.y, flags.borderless = -30000, -30000, true
end
love.window.setMode(1280, 720, flags)
game.draw = function()
imp:draw()
drawn = drawn + 1
end
game.update = function(_, dt) imp:update(dt) end
local function drawFrames(n)
for _ = 1, n do
local seen = drawn
for _ = 1, 4000 do
U.wait(1)
if drawn > seen then break end
end
if not check(drawn > seen, "launcher frame reached the renderer") then return false end
end
return true
end
local function navHas(id)
for i = 1, Kit._navPrevN or 0 do
local slot = Kit._nav[i]
if slot and slot.id == id then return true end
end
return false
end
local function shot(name)
if not drawFrames(2) then return false end
return check(U.still(game, dir .. "/" .. name .. ".png"), "capture " .. name)
end
imp._gamePopup = true
local lockedTabs = LauncherView.gameTabs(imp)
check(#lockedTabs == 8 and not SecretGames.visible("emerald"), "before deadline Emerald remains hidden")
if not shot("90005_before_deadline_choose_game") then finish() return end
check(not navHas("gamepop-emerald"), "before deadline Choose game has no Emerald row")
SaveData.saveOptions = function(...)
writes = writes + 1
return realSaveOptions(...)
end
now = 1790856000
imp:update(1 / 60)
local releasedTabs = LauncherView.gameTabs(imp)
check(#releasedTabs == 9 and releasedTabs ~= lockedTabs, "deadline refreshes the same launcher's cached tabs")
check(SaveData.loadOptions().secretEmerald == true and writes == 1, "deadline persists once")
check(imp._secretPopup == nil and imp._logoTaps == nil, "deadline opens no BLITZ popup")
if not shot("90005_deadline_choose_game_emerald") then finish() return end
check(navHas("gamepop-emerald") and not navHas("secret-ok"), "deadline Choose game includes Emerald without confirmation")
for _ = 1, 20 do imp:update(1 / 60) imp:_logoTap() end
check(writes == 1 and imp._secretPopup == nil, "repeated released updates and taps remain silent")
check(imp:_versionForSha1(GameVersion.info("emerald").sha1) == "emerald", "deadline recognizes Emerald ROM")
SaveData.saveOptions = realSaveOptions
now = 1790855999
SaveData.saveOptions({ secretEmerald = false })
SecretGames.reload()
imp = RomImporter.new(function() end, { launcher = true, reduceMotion = true })
for _ = 1, SecretGames.TAPS do imp:_logoTap() end
check(imp._secretPopup == true, "pre-deadline manual BLITZ mechanism is preserved")
if not shot("90005_before_deadline_manual_blitz") then finish() return end
check(navHas("secret-ok"), "manual BLITZ confirmation is rendered before release")
now = 1790856000
LauncherView.gameTabs(imp)
imp:update(1 / 60)
check(imp._secretPopup == nil and imp._logoTaps == nil, "deadline clears an already-open BLITZ even after another reader unlocks")
if not shot("90005_deadline_manual_blitz_dismissed") then finish() return end
check(not navHas("secret-ok"), "scheduled release leaves no BLITZ confirmation control")
imp:_switchTab("emerald")
check(imp.tab == "emerald", "released launcher selects Emerald normally")
if not shot("90005_deadline_emerald_panel") then finish() return end
check(navHas("play-emerald"), "released Emerald panel has its Play control")
now = 1790855999
SecretGames.reload()
check(SecretGames.visible("emerald"), "persisted release survives clock rollback")
finish()
end
@@ -0,0 +1,204 @@
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
love = require("tests.love_stub")
local Version = require("src.core.GameVersion")
local Audio = require("src.core.game3.audio")
local Song = require("src.core.game3.song_ids")
local Player = require("src.core.game3.m4a_player")
local Adapters = require("src.core.game3.scripting.adapters")
local function channel()
local ch = { items = {} }
function ch:push(v) self.items[#self.items + 1] = v end
function ch:pop() return table.remove(self.items, 1) end
function ch:clear() self.items = {} end
return ch
end
local function source()
local src = { playing = true, volume = 1, queued = {}, stops = 0 }
function src:stop() self.playing = false; self.stops = self.stops + 1; self.queued = {} end
function src:play() self.playing = true end
function src:isPlaying() return self.playing end
function src:setVolume(v) self.volume = v end
function src:getFreeBufferCount() return Player.BUFFER_COUNT end
function src:queue(data) self.queued[#self.queued + 1] = data; return true end
return src
end
local function setup(version)
Version.set(version)
local songs = Song.forVersion(version)
local a = songs.MUS_LITTLEROOT or songs.MUS_PALLET
local b = songs.MUS_OLDALE or songs.MUS_VIRIDIAN_FOREST
assert(type(a) == "number" and type(b) == "number" and a ~= b)
Audio._ready, Audio._worker = true, true
Audio._pack = { index = { songs = {} } }
Audio._meta, Audio._fanfareCh, Audio._statusCh = nil, nil, nil
Audio._cmdCh, Audio._outCh = channel(), channel()
Audio._bgmSource, Audio._fanfareSource = source(), source()
Audio._currentSong, Audio._bgmGen = { id = a }, a
Audio._bgmEpoch, Audio._bgmQueuedAt, Audio._bgmBaseAt = 3, {}, 0
Audio._bgmLocal, Audio._pendingBgm, Audio._bgmPaused = nil, nil, false
Audio._fadeOut, Audio._fadeIn = nil, nil
Audio._fanfareActive, Audio._fanfareFrames = false, 0
Audio._fanfareRestore, Audio._fanfareDeferred, Audio._fanfarePending = nil, nil, nil
Audio._waitFanfareCb, Audio._suspended, Audio._mapSong = nil, false, a
return a, b
end
local function fanfare(a)
Audio.pauseBgm()
Audio._fanfareActive, Audio._fanfareFrames, Audio._fanfareRestore = true, 1, a
end
local function lastPlay()
for i = #Audio._cmdCh.items, 1, -1 do
local m = Audio._cmdCh.items[i]
if m.cmd == "play" then return m end
end
end
for _, version in ipairs({ "emerald", "firered", "leafgreen" }) do
local a, b = setup(version)
Audio.playSong(a)
eq(Audio._bgmEpoch, 3, version .. " stable same-song keeps epoch")
eq(#Audio._cmdCh.items, 0, version .. " stable same-song sends no restart")
eq(Audio._bgmSource.stops, 0, version .. " stable same-song preserves source")
a, b = setup(version)
fanfare(a)
local adapter = Adapters.host(nil, { data = { maps = {} } }, nil)
local epoch, count, callback = Audio._bgmEpoch, #Audio._cmdCh.items, 0
local sourceStops = Audio._bgmSource.stops
Audio._waitFanfareCb = function() callback = callback + 1 end
adapter.playBgm(a)
eq(Audio._bgmEpoch, epoch, version .. " stable script same-A during fanfare preserves epoch")
eq(#Audio._cmdCh.items, count, version .. " stable script same-A during fanfare sends no restart")
Audio.fadeOutAndPlay(b, 1)
eq(Audio.currentMapMusic(), b, version .. " actual script fixture has fanfare-deferred B")
adapter.playBgm(a)
eq(Audio.currentMapMusic(), a, version .. " actual script playBgm(A) supersedes fanfare-deferred B")
check(Audio._fanfareDeferred == nil, version .. " actual script A clears obsolete deferred B")
check(Audio._fanfareActive and Audio._bgmPaused and Audio._fanfareSource:isPlaying(),
version .. " actual script same-A preserves fanfare lifetime and BGM pause")
eq(Audio._bgmEpoch, epoch, version .. " actual script same-A preserves physical epoch")
eq(#Audio._cmdCh.items, count, version .. " actual script same-A sends no restart")
eq(Audio._bgmSource.stops, sourceStops, version .. " actual script same-A preserves physical source")
Audio.update(1)
eq(Audio.currentMapMusic(), a, version .. " actual script fanfare resumes latest A")
eq(lastPlay(), nil, version .. " actual script fanfare never starts obsolete B")
check(Audio._bgmSource:isPlaying() and not Audio._bgmPaused,
version .. " actual script fanfare resumes audible A")
eq(callback, 1, version .. " actual script same-A completes fanfare wait once")
a, b = setup(version)
Audio.fadeOutAndPlay(b, 1)
Audio.playSong(a)
Audio.update(1)
eq(Audio._currentSong and Audio._currentSong.id, a, version .. " newer A cancels pending B")
eq(lastPlay() and lastPlay().id, a, version .. " worker plays latest A")
check(Audio._fadeOut == nil, version .. " interrupted fade removed")
local stops = Audio._bgmSource.stops
Audio._outCh:push({ gen = a, epoch = Audio._bgmEpoch, data = "A PCM", at = 0, n = 16 })
Audio.update(1)
check(Audio._bgmSource:isPlaying() and Audio._bgmSource.volume > 0, version .. " replacement source remains audible")
eq(Audio._bgmSource.stops, stops, version .. " old fade cannot stop replacement")
a, b = setup(version)
Audio.fadeOutBgm(1)
eq(Audio.currentMapMusic(), 0, version .. " fade-to-stop reports logical silence")
Audio.playSong(a)
Audio.update(1)
eq(Audio._currentSong and Audio._currentSong.id, a, version .. " explicit same A cancels standalone fade")
Audio._outCh:push({ gen = a, epoch = Audio._bgmEpoch, data = "resumed PCM", at = 0, n = 16 })
Audio.update(1)
check(Audio._bgmSource:isPlaying() and Audio._bgmSource.volume > 0, version .. " standalone fade replacement is audible")
a, b = setup(version)
fanfare(a)
local callback = 0
Audio._waitFanfareCb = function() callback = callback + 1 end
Audio._fanfareDeferred = b
Audio.playSong(0)
check(Audio._fanfareActive and Audio._fanfareSource:isPlaying(), version .. " BGM stop preserves fanfare lifetime")
Audio.update(1)
eq(Audio._currentSong, nil, version .. " stop during fanfare stays silent")
eq(Audio._bgmGen, nil, version .. " stop during fanfare clears generation")
check(not Audio._bgmSource:isPlaying(), version .. " stop during fanfare stops source")
eq(lastPlay(), nil, version .. " fanfare completion sends no revived play")
eq(callback, 1, version .. " stopped BGM still completes fanfare wait")
a, b = setup(version)
Audio.fadeOutAndPlay(b, 1)
Audio.playSong(0)
Audio._outCh:push({ gen = a, epoch = Audio._bgmEpoch, data = "stale PCM", at = 0, n = 16 })
Audio.update(1)
eq(Audio._currentSong, nil, version .. " stop during fade stays silent")
check(not Audio._bgmSource:isPlaying() and #Audio._bgmSource.queued == 0, version .. " explicit stop rejects old queued PCM")
a, b = setup(version)
Audio._outCh:push({ gen = a, epoch = 2, data = "old epoch", at = 0, n = 16 })
Audio._outCh:push({ gen = b, epoch = 3, data = "old song", at = 0, n = 16 })
Audio._outCh:push({ gen = a, epoch = 3, data = "current PCM", at = 16, n = 16 })
Audio.pumpBgm()
eq(#Audio._bgmSource.queued, 1, version .. " pump rejects stale generation and epoch")
eq(Audio._bgmSource.queued[1], "current PCM", version .. " pump queues current PCM")
if version ~= "emerald" then
a, b = setup(version)
fanfare(a)
Audio.changeMusicTo(b)
Audio.changeMusicTo(a)
eq(Audio._fanfareDeferred, a, version .. " changeMusicTo replaces deferred B with A")
Audio.update(1)
eq(Audio._currentSong and Audio._currentSong.id, a, version .. " changeMusicTo fanfare resumes latest A")
a, b = setup(version)
fanfare(a)
Audio.bikeMusic(true, true)
Audio.bikeMusic(false)
eq(Audio._fanfareDeferred, a, version .. " bike dismount replaces deferred cycling with A")
Audio.update(1)
eq(Audio._currentSong and Audio._currentSong.id, a, version .. " dismount fanfare resumes map A")
a, b = setup(version)
Audio.fadeOutAndPlay(b, 1)
Audio.fadeDefaultBgm(1)
Audio.update(1)
eq(Audio._currentSong and Audio._currentSong.id, a, version .. " fadeDefaultBgm replaces pending B with map A")
a, b = setup(version)
fanfare(a)
Audio.changeMusicTo(b)
Audio.fadeDefaultBgm(1)
eq(Audio._fanfareDeferred, a, version .. " fadeDefaultBgm replaces fanfare-deferred B with map A")
check(Audio._fanfareActive and Audio._bgmPaused, version .. " default map request preserves active fanfare pause")
Audio.update(1)
eq(Audio._currentSong and Audio._currentSong.id, a, version .. " default map request fanfare resumes A")
end
end
do
local a, b = setup("emerald")
local session = { version = "emerald", map = "EM_LITTLEROOT_TOWN", x = 5, y = 5 }
package.loaded["src.core.game3.runtime"] = { getSession = function() return session end,
_game = { data = { maps = { EM_LITTLEROOT_TOWN = { music = a }, EM_OLDALE_TOWN = { music = b } } } } }
Audio.fadeOutAndPlay(b, 1)
Audio.mapLoadMusic({ mapId = session.map })
Audio.update(1)
eq(Audio._currentSong and Audio._currentSong.id, a, "Emerald actual map policy replaces pending fade B with A")
a, b = setup("emerald")
fanfare(a)
session.map = "EM_OLDALE_TOWN"
Audio.mapLoadMusic({ mapId = session.map })
eq(Audio.currentMapMusic(), b, "Emerald logical music reflects fanfare-deferred B")
session.map = "EM_LITTLEROOT_TOWN"
Audio.mapLoadMusic({ mapId = session.map })
eq(Audio._fanfareDeferred, a, "Emerald return map replaces deferred B with A")
Audio.update(1)
eq(Audio._currentSong and Audio._currentSong.id, a, "Emerald fanfare resumes latest map A")
check(Audio._bgmSource:isPlaying() and not Audio._bgmPaused, "Emerald fanfare restores audible A")
end
T.finish("game3_audio_transition_test")
@@ -4,6 +4,12 @@ local T = require("tests.harness")
local check = T.check
love = love or require("tests.love_stub")
local realTime, now = os.time, 1790855999
os.time = function(t)
if t then return realTime(t) end
return now
end
love.graphics.setLineJoin = love.graphics.setLineJoin or function() end
love.graphics.newShader = love.graphics.newShader or function() return {} end
@@ -121,4 +127,83 @@ check(ids(LauncherView.gameTabs(fresh)):find(" emerald ", 1, true) ~= nil,
"a new launcher lists emerald after unlock")
check(fresh._logoTaps == nil, "the tap counter starts over with each launcher")
T.eq(SecretGames.EMERALD_RELEASE_AT, 1790856000, "release is October 1 at 12 UTC")
SaveData.saveOptions({ secretEmerald = false, lastVersion = "blue" })
SecretGames.reload()
local timed = newLauncher()
local lockedTabs = LauncherView.gameTabs(timed)
local lockedRev = SecretGames.rev
check(not ids(lockedTabs):find(" emerald ", 1, true), "one second early stays locked")
for _ = 1, SecretGames.TAPS do timed:_logoTap() end
check(timed._secretPopup == true, "a pre-release BLITZ popup can be open at the deadline")
local realSaveOptions, writes = SaveData.saveOptions, 0
SaveData.saveOptions = function(...)
writes = writes + 1
return realSaveOptions(...)
end
now = 1790856000
local releasedTabs = LauncherView.gameTabs(timed)
check(releasedTabs ~= lockedTabs, "deadline invalidates the already-cached game list")
T.eq(#releasedTabs, 9, "deadline exposes all nine games")
check(ids(releasedTabs):find(" emerald ", 1, true) ~= nil, "deadline includes Emerald")
T.eq(SecretGames.rev, lockedRev + 1, "scheduled unlock advances the revision once")
T.eq(writes, 1, "scheduled unlock persists once")
local releasedOptions = SaveData.loadOptions()
check(releasedOptions.secretEmerald == true, "scheduled unlock persists secretEmerald")
T.eq(releasedOptions.lastVersion, "blue", "unlock preserves unrelated launcher options")
timed:update(1 / 60)
check(timed._secretPopup == nil and timed._logoTaps == nil,
"active launcher silently clears an already-open BLITZ popup and tap counter")
timed._gamePopup = true
rects = drawRects(timed)
check(rects["gamepop-emerald"] ~= nil and rects["secret-ok"] == nil,
"the active launcher draws Emerald without a BLITZ confirmation")
timed._gamePopup = nil
check(timed:_versionForSha1(emeraldSha) == "emerald", "release recognizes Emerald immediately")
for _ = 1, 10 do
timed:update(1 / 60)
timed:_logoTap()
T.eq(LauncherView.gameTabs(timed), releasedTabs, "released game list remains cached")
end
T.eq(writes, 1, "repeated frames and logo taps do not repeat persistence")
T.eq(SecretGames.rev, lockedRev + 1, "repeated frames do not repeat cache invalidation")
check(timed._secretPopup == nil and timed._logoTaps == nil, "released logo taps stay silent")
now = 1790856001
local releaseBoot = RomImporter.new(function() end, { launcher = true, initialTab = "emerald" })
T.eq(releaseBoot.tab, "emerald", "a launcher opened after release accepts its initial Emerald tab")
SecretGames.reload()
check(SecretGames.visible("emerald"), "scheduled unlock survives a settings reload")
T.eq(writes, 1, "reloading the persisted unlock does not rewrite options")
now = 1790855999
SecretGames.reload()
check(SecretGames.visible("emerald"), "a clock rollback cannot relock a persisted unlock")
T.eq(writes, 1, "clock rollback does not rewrite options")
check(ids(SecretGames.order({ launcher = false })):find(" emerald ", 1, true) ~= nil,
"scheduled availability preserves nonlauncher import recognition")
SaveData.saveOptions = realSaveOptions
SaveData.saveOptions({ secretEmerald = false })
SecretGames.reload()
local polling = newLauncher()
LauncherView.gameTabs(polling)
now = 1790856000
polling:update(1 / 60)
check(SaveData.loadOptions().secretEmerald == true,
"an already-open launcher update alone applies the scheduled unlock")
check(ids(LauncherView.gameTabs(polling)):find(" emerald ", 1, true) ~= nil,
"an already-open launcher needs no restart to expose Emerald")
now = 1790855999
SaveData.saveOptions({ secretEmerald = false })
SecretGames.reload()
now = 1790856000
local initial = RomImporter.new(function() end, { launcher = true, initialTab = "emerald" })
T.eq(initial.tab, "emerald", "a fresh deadline boot unlocks before initial tab validation")
check(SaveData.loadOptions().secretEmerald == true, "a fresh deadline boot persists the unlock")
os.time = realTime
T.finish("launcher secret emerald")
@@ -21,8 +21,8 @@ SaveData.loadOptions = function()
end
SaveData.saveOptions = function(opts)
local tc = type(opts) == "table" and type(opts.touchControls) == "table"
and opts.touchControls or {}
stored.enabled, stored.skin = tc.enabled, tc.skin
and opts.touchControls
if tc then stored.enabled, stored.skin = tc.enabled, tc.skin end
return true
end
+225
View File
@@ -0,0 +1,225 @@
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local Objects = require("src.core.game3.objects")
local Ghosts = require("src.core.game3.ghosts")
local Player = require("src.core.game3.player")
local Map = require("src.core.game3.map")
local Runtime = require("src.core.game3.runtime")
local Space = require("src.core.game3.scripting.space")
Space.bundle = { events = {} }
local game = { data = { maps = {} } }
Runtime.session = { version = "firered", map = "TEST_A", flags = {}, vars = {} }
Runtime._game = game
local layout = { width = 64, height = 64, collAt = function() return 0 end }
local first = { midLayout = layout, objects = {
{ localId = 7, x = 10, y = 10 },
{ localId = 8, x = 12, y = 10 },
{ localId = 9, x = 15, y = 10 },
} }
local empty = { midLayout = layout, objects = {} }
Objects.clear()
Ghosts.clear()
Objects.loadMap(game, "TEST_A", first)
Player.reset(10, 9, "down")
local boat, actor, npc = Objects.find(7), Objects.find(8), Objects.find(9)
local callbacks = { boat = 0, actor = 0, player = 0 }
local actions = {
{ kind = "step", dir = "down", fast = true },
{ kind = "step", dir = "down", fast = true },
{ kind = "step", dir = "down", fast = true },
}
Objects.startTrack(7, actions, function() callbacks.boat = callbacks.boat + 1 end)
Objects.startTrack(8, actions, function() callbacks.actor = callbacks.actor + 1 end)
Objects.startTrack(255, actions, function() callbacks.player = callbacks.player + 1 end)
npc.moving, npc.targetX, npc.targetY = true, npc.cellX, npc.cellY + 1
npc.progress, npc.animClock, npc.stepFrames = 0, 0, 16
npc.scriptBusy, npc.frozen = true, true
local function shiftPlayer(dx, dy)
Player.cellX, Player.cellY = Player.cellX + dx, Player.cellY + dy
Player.targetX, Player.targetY = Player.targetX + dx, Player.targetY + dy
Player.px, Player.py = Player.px + dx * 16, Player.py + dy * 16
end
local function transfer(from, to, dx, dy)
local carry = Objects.carryOut(dx, dy)
shiftPlayer(dx, dy)
Ghosts.capture(from)
local pool = Ghosts._pools[from]
for _, eo in pairs(pool.byId) do
T.check(eo ~= boat and eo ~= actor, "carried actors absent from departing ghost pool " .. from)
end
T.check(boat.scriptBusy and boat.frozen and actor.scriptBusy and actor.frozen,
"capture preserves held movement state " .. from)
Objects.loadMap(game, to, empty)
Objects.carryIn(carry)
Runtime.session.map = to
Map.world = { { id = "TEST_A", def = first, ox = 0, oy = 0 } }
if from ~= "TEST_A" then Map.world[#Map.world + 1] = { id = from, def = empty, ox = 0, oy = 0 } end
T.eq(Objects.find(Objects.foreignKey("TEST_A", 7)), boat, "foreign boat lookup survives " .. to)
T.eq(Objects.find(Objects.foreignKey("TEST_A", 8)), actor, "generic foreign lookup survives " .. to)
end
transfer("TEST_A", "TEST_B", 2, 0)
T.eq(Ghosts._pools.TEST_A.byId[9], npc, "ordinary departing NPC remains captured")
T.check(not npc.scriptBusy and not npc.frozen, "ordinary departing NPC is released")
local firstNpcY = npc.py
local function frame()
local before = boat.py
Objects.update(game)
local afterLive = boat.py
Ghosts.update(game)
T.eq(boat.py, afterLive, "ghost pass never moves live carried boat twice")
Player.tick(game)
T.eq(boat.py - Player.py, 16, "boat and player retain relative sailing pixels")
return boat.py - before
end
frame()
T.check(npc.py > firstNpcY, "ordinary ghost movement still advances")
transfer("TEST_B", "TEST_C", 3, 0)
for _ = 1, 30 do frame() end
T.eq(callbacks.boat, 1, "boat completion callback fires once")
T.eq(callbacks.actor, 1, "generic actor completion callback fires once")
T.eq(callbacks.player, 1, "player completion callback fires once")
T.check(Objects._tracks[Objects.foreignKey("TEST_A", 7)].done, "foreign boat track completes")
Ghosts.clear()
Objects.clear()
local Collision = require("src.core.game3.collision")
local realConnection, realPairs = Collision.scriptConnection, pairs
Runtime.session.version = "emerald"
Collision._grid, Collision._widthCells, Collision._heightCells = { 0 }, 4, 8
local seamLayout = { width = 4, height = 8, collAt = function() return 0 end }
local function contains(list, actor)
for _, eo in ipairs(list) do if eo == actor then return true end end
return false
end
for _, playerFirst in ipairs({ true, false }) do
Objects.clear()
Ghosts.clear()
local source = { midLayout = seamLayout, objects = {
{ localId = 7, x = 3, y = 2 }, { localId = 9, x = -1, y = 4 },
} }
local destination = { midLayout = seamLayout, objects = { { localId = 9, x = -1, y = 4 } } }
local from, seams = "SEAM_A", 0
Runtime.session.map = from
Objects.loadMap(game, from, source)
Player.reset(3, 1, "right")
local vessel, outside = Objects.find(7), Objects.find(9)
T.check(not contains(Objects.forDraw(), outside), "ordinary off-map EO remains filtered")
local movement = { { kind = "sleep", frames = 1 } }
for _ = 1, 9 do movement[#movement + 1] = { kind = "step", dir = "right", fast = true } end
local done = { boat = 0, player = 0 }
Objects.startTrack(7, movement, function() done.boat = done.boat + 1 end)
Objects.startTrack(255, movement, function() done.player = done.player + 1 end)
local boatTrack, playerTrack = Objects._tracks[7], Objects._tracks[255]
pairs = function(t)
if t ~= Objects._tracks then return realPairs(t) end
local order = playerFirst and { 255, vessel.localId } or { vessel.localId, 255 }
local index = 0
return function()
index = index + 1
local id = order[index]
if id then return id, t[id] end
end
end
Collision.scriptConnection = function(_, fx, fy, dir)
T.eq(dir, "right", "script seam uses current movement direction")
seams = seams + 1
local to = "SEAM_" .. seams
local carry = Objects.carryOut(-4, 0)
Ghosts.capture(from)
Objects.loadMap(game, to, destination)
Objects.carryIn(carry)
Runtime.session.map, from = to, to
Player.cellX, Player.cellY = fx - 4, fy
Player.px, Player.py = Player.cellX * 16, Player.cellY * 16
Player.targetX, Player.targetY = Player.cellX, Player.cellY
return 0, fy
end
for frame = 1, 80 do
Objects.update(game)
Player.tick(game)
T.eq(vessel.px - Player.px, 0, "mid-track seam keeps horizontal boat/player pixels")
T.eq(vessel.py - Player.py, 16, "mid-track seam keeps vertical boat/player pixels")
if frame == 1 then
T.check(vessel.cellX == -1 and contains(Objects.forDraw(), vessel),
"visible foreign EO draws outside host map bounds")
T.check(not contains(Objects.forDraw(), Objects.find(9)), "ordinary destination off-map EO remains filtered")
vessel.hidden = true
T.check(not contains(Objects.forDraw(), vessel), "hidden foreign EO remains filtered")
vessel.hidden, vessel.invisible = false, true
T.check(not contains(Objects.forDraw(), vessel), "invisible foreign EO remains filtered")
vessel.invisible = false
T.eq(boatTrack.i, playerTrack.i, "seam advances each original track once in either order")
end
end
T.eq(seams, 3, "script track crosses three repeated seams")
T.eq(done.boat, 1, "seam boat callback fires once")
T.eq(done.player, 1, "seam player callback fires once")
T.check(boatTrack.done and playerTrack.done, "seam keeps both held tracks to completion")
pairs = realPairs
end
for _, mode in ipairs({ "removed", "track_replaced", "actor_replaced" }) do
Objects.clear()
Ghosts.clear()
Objects.loadMap(game, "GUARD_A", { midLayout = seamLayout, objects = {
{ localId = 7, x = 3, y = 2 }, { localId = 8, x = 3, y = 4 },
} })
Runtime.session.map = "GUARD_A"
Player.reset(3, 1, "right")
local vessel, other = Objects.find(7), Objects.find(8)
local movement = { { kind = "sleep", frames = 1 }, { kind = "step", dir = "right", fast = true } }
local called = 0
Objects.startTrack(7, movement)
Objects.startTrack(8, movement, function() called = called + 1 end)
Objects.startTrack(255, movement)
local original = Objects._tracks[8]
local replacement
pairs = function(t)
if t ~= Objects._tracks then return realPairs(t) end
local order, index = { 255, vessel.localId, other.localId }, 0
return function()
index = index + 1
local id = order[index]
if id then return id, t[id] end
end
end
Collision.scriptConnection = function(_, fx, fy)
local carry = Objects.carryOut(-4, 0)
Ghosts.capture("GUARD_A")
Objects.loadMap(game, "GUARD_B", { midLayout = seamLayout, objects = {} })
Objects.carryIn(carry)
Runtime.session.map = "GUARD_B"
Player.cellX, Player.cellY = fx - 4, fy
Player.px, Player.py = Player.cellX * 16, Player.cellY * 16
Player.targetX, Player.targetY = Player.cellX, Player.cellY
if mode == "removed" then
Objects.removeObject(other.localId)
elseif mode == "track_replaced" then
Objects.startTrack(other.localId, { { kind = "sleep", frames = 3 } })
replacement = Objects._tracks[other.localId]
else
local copy = {}
for key, value in realPairs(other) do copy[key] = value end
Objects._byId[other.localId] = copy
end
return 0, fy
end
Objects.update(game)
Player.tick(game)
T.eq(original.i, 2, mode .. " snapshot never advances obsolete actor track")
T.eq(original.sleep, 1, mode .. " snapshot never consumes obsolete actor sleep")
T.eq(called, 0, mode .. " snapshot never fires obsolete actor callback")
if replacement then T.eq(replacement.sleep, 3, "replacement track waits until next frame") end
pairs = realPairs
end
Objects.clear()
Objects.startTrack(31, { { kind = "sleep", frames = 1 }, { kind = "sleep", frames = 1 } },
function() callbacks.actor = callbacks.actor + 1 end)
Objects.update(game)
Objects.update(game)
T.eq(callbacks.actor, 2, "initially missing optional EO track still completes")
pairs, Collision.scriptConnection = realPairs, realConnection
Ghosts.clear()
Objects.clear()
T.finish("game3_carried_actor_ghost")
+22 -1
View File
@@ -221,7 +221,28 @@ local dismountAction = Gen2Bike.tryBike({
})
check(dismountAction == "dismount", "Gen 2 Bike.tryBike allows dismount in INDOOR environment")
local Field = require("src.core.game3.field")
session = Runtime.getSession() or session
session.version = "firered"
session.flags = session.flags or {}
session.vars = session.vars or {}
session.store = session.store or { flags = {}, vars = {} }
Space.store = session.store
Field._game, Field._session = game, session
local defs = Flags.forVersion("firered")
local road, scene = defs.IDS.FLAG_SYS_ON_CYCLING_ROAD, defs.VAR_IDS.VAR_MAP_SCENE_ROUTE16
Flags.setFlag(session.store, nil, road, true)
Flags.setFlag(session, nil, road, true)
Flags.setVar(session.store, nil, scene, 1)
Player.biking, session.biking, game.save.biking = true, true, true
Field.flyTo(nil, nil, { dest = { map = "FR_SAFFRON_CITY", x = 18, y = 22 } })
check(not Flags.getFlag(session.store, nil, road) and not Flags.getFlag(session, nil, road),
"Cycling Road Fly clears every road flag store")
check(Flags.getVar(session.store, nil, scene) == 0, "Cycling Road Fly resets Route 16 scene")
Map.load(nil, game, "FR_SILPH_CO_1F", { x = 18, y = 21, facing = "up" })
check(not Player.biking and not session.biking and not game.save.biking,
"indoor entry after Cycling Road Fly keeps avatar session and save on foot")
print(string.format("=== RESULTS: %d passed, %d failed ===", passed, failed))
if failed > 0 then os.exit(1) end
+72
View File
@@ -0,0 +1,72 @@
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local GameVersion = require("src.core.GameVersion")
local Field = require("src.core.game3.field")
local Player = require("src.core.game3.player")
local Flags = require("src.core.game3.scripting.flags")
local Runtime = require("src.core.game3.runtime")
local Space = require("src.core.game3.scripting.space")
local Map = require("src.core.game3.map")
local realLoad = Map.load
local realHeal = package.loaded["src.core.game3.heal_locations"]
package.loaded["src.core.game3.heal_locations"] = {
normalizeSession = function() end,
model = function() return "heal_row" end,
}
Space.runImmediately = function() end
for _, version in ipairs({ "firered", "leafgreen", "emerald" }) do
GameVersion.set(version)
local defs = Flags.forVersion(version)
local roadName = version == "emerald" and "FLAG_SYS_CYCLING_ROAD" or "FLAG_SYS_ON_CYCLING_ROAD"
local road, scene = assert(defs.IDS[roadName]), defs.VAR_IDS.VAR_MAP_SCENE_ROUTE16
for _, mode in ipairs({ "fly", "field_move", "whiteout" }) do
local s = { version = version, flags = {}, vars = {}, party = {},
store = { flags = {}, vars = {} }, biking = true, bikeType = "mach" }
local live = { version = version, flags = {}, vars = {},
store = { flags = {}, vars = {} }, biking = true, bikeType = "acro" }
local script = { flags = {}, vars = {} }
local g = { session = s, save = { biking = true, bikeType = "mach", position = { biking = true } } }
Field._session, Field._game, Runtime.session, Runtime._game, Space.store = s, g, live, g, script
for _, store in ipairs({ s, s.store, script, live, live.store }) do
Flags.setFlag(store, nil, road, true)
store.flags[roadName] = true
if scene then
Flags.setVar(store, nil, scene, 1)
store.vars[tostring(scene)], store.vars.VAR_MAP_SCENE_ROUTE16 = 1, 1
end
end
Player.biking, Player.bikeType, Player.surfing = true, "mach", true
local loads = 0
Map.load = function(_, _, mapId)
loads = loads + 1
for _, store in ipairs({ s, s.store, script, live, live.store }) do
T.check(not Flags.getFlag(store, nil, road) and store.flags[roadName] == nil,
version .. " " .. mode .. " clears road flag before map load")
if scene then
T.eq(Flags.getVar(store, nil, scene), 0, version .. " " .. mode .. " resets scene before map load")
T.eq(store.vars[tostring(scene)], nil, "scene decimal alias cleared")
T.eq(store.vars.VAR_MAP_SCENE_ROUTE16, nil, "scene symbolic alias cleared")
end
end
T.check(not Player.biking and not s.biking and not live.biking and not g.save.biking
and not g.save.position.biking, version .. " " .. mode .. " on foot at map load")
T.check(Player.bikeType == nil and s.bikeType == nil and live.bikeType == nil
and g.save.bikeType == nil, version .. " " .. mode .. " clears bike type")
s.map, live.map = mapId, mapId
end
local dest = { map = version == "emerald" and "EM_MAUVILLE_CITY" or "FR_CELADON_CITY", x = 1, y = 1 }
if mode == "fly" then
Field.flyTo(nil, nil, { dest = dest })
else
Field.respawnAtHeal({ fieldMove = mode == "field_move", warp = dest })
end
T.eq(loads, 1, version .. " " .. mode .. " loads one destination")
T.check(not Player.biking and not live.biking and not g.save.biking,
version .. " " .. mode .. " remains on foot after landing")
end
end
Map.load = realLoad
package.loaded["src.core.game3.heal_locations"] = realHeal
T.finish("game3_fly_cycling_state")
+75
View File
@@ -0,0 +1,75 @@
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local GameVersion = require("src.core.GameVersion")
local Runtime = require("src.core.game3.runtime")
local Options = require("src.core.game3.options")
local Message = require("src.ui.game3.message")
local Hud = require("src.ui.game3.hud")
local TextIR = require("src.core.game3.scripting.text_ir")
local input = {
wasPressed = function(_, key) return key == "a" end,
isDown = function() return false end,
}
local game = { input = input }
local ir = TextIR.fromAscii("First paragraph.\\pSecond paragraph.")
local function exercise(version, speed, text)
GameVersion.set(version)
local session = { version = version }
Runtime.session = session
Options.set(session, "textSpeed", speed)
Message.showStay(text)
T.check(Message.isTyping() and Message._page == 1 and Message._revealed == 0,
version .. " implicit option " .. speed .. " starts typing first paragraph")
local first = Message.currentPage()
Message.tick()
Hud.update(game, 1 / 60)
T.eq(Message._page, 1, version .. " opening A preserves first paragraph at option " .. speed)
T.eq(Message.currentPage(), first, "opening A keeps first paragraph text")
T.check(Message.isWaiting(), "opening A finishes first paragraph reveal")
Message.advance()
T.eq(Message._page, 2, "fresh A advances exactly once")
T.check(Message.isTyping(), "second paragraph starts typing")
Message.close()
end
for _, version in ipairs({ "firered", "leafgreen", "emerald" }) do
for speed = 0, 2 do
exercise(version, speed, ir)
Message.showStay("ABC")
Message.tick()
T.eq(Message._revealed, 1, "first glyph prints on first tick")
local delay = ({ 8, 4, 1 })[speed + 1]
for _ = 1, delay - 1 do Message.tick() end
T.eq(Message._revealed, 1, version .. " no-input glyph interval " .. delay)
Message.tick()
T.eq(Message._revealed, 2, version .. " next glyph after " .. delay .. " ticks")
Message.showStay(ir, { speed = 0 })
T.check(Message.isWaiting() and Message._revealed == Message._total,
version .. " explicit speed zero stays instant at option " .. speed)
Message.close()
end
end
GameVersion.set("firered")
local bundle = require("tests.game3_cache").bundle("scripts/events.lua")
if bundle then
local event = assert(bundle.events.FR_VIRIDIAN_CITY_POKEMON_CENTER_1F)
for _, npc in ipairs({ { id = 2, name = "Viridian gentleman" }, { id = 3, name = "Viridian boy" } }) do
local scriptKey
for _, object in ipairs(event.objects) do
if object.localId == npc.id then scriptKey = object.scriptKey break end
end
local script = assert(bundle.scripts[assert(scriptKey, npc.name .. " event script")])
local command = assert(script[1])
assert(command.op == "loadword" and command.dest == 0, npc.name .. " cached message pointer")
local text = assert(bundle.text[command.value], npc.name .. " cached text IR")
T.eq(#TextIR.splitPages(TextIR.toTextBox(text, { maxWidth = 208 })), 2,
npc.name .. " cached literal text has two paragraphs")
exercise("firered", 0, text)
end
else
print("[skip] cached ROM message acceptance: " .. tostring(require("tests.game3_cache").reason))
end
T.finish("game3_message_speed")
+61 -1
View File
@@ -24,9 +24,11 @@ local Game3 = require("src.core.Game3")
-- Test 1: StartMenu EXIT flow and confirmation
do
local returnToTitleCalled = false
local returnToTitleOptions
local dummyGame = {
returnToTitle = function()
returnToTitle = function(_, opts)
returnToTitleCalled = true
returnToTitleOptions = opts
end,
}
local session = { name = "RED", game = dummyGame }
@@ -85,6 +87,7 @@ do
check(not StartMenu._confirmExit, "Exited confirm mode on YES")
check(not StartMenu.isOpen(), "StartMenu closed on YES")
check(returnToTitleCalled, "returnToTitle was called on YES")
check(returnToTitleOptions and returnToTitleOptions.skipIntro, "EXIT YES requests direct title")
end
-- Test 2: Game3:returnToTitle cleans up and sets boot to title phase
@@ -271,4 +274,61 @@ do
Runtime.stop(nil, game)
end
do
local Version = require("src.core.GameVersion")
local Runtime = require("src.core.game3.runtime")
local oldVersion = Version.get()
local titlePath = "src.ui.game3.rse.title_rse"
local introPath = "src.ui.game3.rse.intro_emerald"
local oldTitle, oldIntro = package.loaded[titlePath], package.loaded[introPath]
local titleCalls, introCalls, introColdBoot = 0, 0, nil
package.loaded[titlePath] = { new = function()
titleCalls = titleCalls + 1
return { update = function(self) return self.result end }
end }
package.loaded[introPath] = { new = function(_, opts)
introCalls, introColdBoot = introCalls + 1, opts.coldBoot
return { update = function() end }
end }
Version.set("emerald")
local game = Game3.new()
game.phase = "field"
game.session = { version = "emerald", map = "EM_LITTLEROOT_TOWN", x = 5, y = 5, game = game }
Runtime.start(nil, game, game.session, { alreadyOnMap = true })
StartMenu.show({ session = game.session, game = game })
for i, entry in ipairs(StartMenu.ENTRIES) do
if entry.id == "exit" then StartMenu.cursor = i break end
end
StartMenu.confirm()
eq(StartMenu._confirmCursor, 2, "Emerald EXIT defaults to NO")
StartMenu.cancel()
eq(game.phase, "field", "Emerald EXIT B preserves field")
StartMenu.confirm()
StartMenu.confirm()
eq(game.phase, "field", "Emerald EXIT NO preserves field")
StartMenu.confirm()
StartMenu.move(1)
StartMenu.confirm()
eq(game.phase, "boot", "Emerald EXIT tears down field")
eq(game.session, nil, "Emerald EXIT clears session")
check(not Runtime.isActive(), "Emerald EXIT stops runtime")
eq(Stack.depth(), 0, "Emerald EXIT clears screen stack")
eq(game.boot.phase, Boot.PHASE.TITLE, "Emerald EXIT enters TITLE directly")
eq(titleCalls, 1, "Emerald EXIT constructs title")
eq(introCalls, 0, "Emerald EXIT bypasses intro constructor")
eq(game.boot.custom.coldBoot, false, "Emerald EXIT consumes cold boot")
if game.boot.custom.title then
game.boot.custom.title.result = "copyright"
Boot.update(game.boot, {}, 1 / 60)
eq(introColdBoot, false, "Title idle copyright uses noncold intro")
end
game:returnToTitle()
eq(game.boot.phase, Boot.PHASE.INTRO, "Actual soft reset retains Emerald intro")
eq(game.boot.custom.coldBoot, true, "Actual soft reset retains cold boot")
Boot.update(game.boot, {}, 1 / 60)
eq(introColdBoot, true, "Actual soft reset constructs cold intro")
package.loaded[titlePath], package.loaded[introPath] = oldTitle, oldIntro
Version.set(oldVersion)
end
T.finish("game3_pause_and_main_menu_exit_test")