mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 16:31:05 +02:00
Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2765e3d145 | |||
| c62f1334bc | |||
| eea0dec4a6 | |||
| 25d241f12c | |||
| 0b55ab083a | |||
| 6b9d4f2470 | |||
| 8a8340ffc8 | |||
| 0af69e2df6 | |||
| 4b9a326f6b | |||
| a92761dc13 | |||
| 8a649e8d95 | |||
| 5c5c970e81 | |||
| 7b208043b9 | |||
| 26dfad0682 | |||
| 902f0d73d4 | |||
| f6ae967796 | |||
| 29afbdaec7 | |||
| 350402b179 | |||
| 0f581e2f69 | |||
| d3010dc060 | |||
| 53e1a535dd | |||
| e63fa70700 | |||
| 27fb6eedc6 |
@@ -39,3 +39,6 @@ mobile/dist/
|
||||
# Tiled map-editing workspace (tools/tiled_export.py). Derived from the imported
|
||||
# ROM cache exactly like data/generated/, so it is never committable.
|
||||
/build/tiled/
|
||||
|
||||
# per-machine iOS bundle-id pin (see scripts/build_ios.sh)
|
||||
mobile/ios/bundle_id.local
|
||||
|
||||
@@ -8,14 +8,45 @@ supplied by the player.
|
||||
|
||||
**SUPPORT / ANNOUNCEMENTS / MODS:** [Discord](https://bois.icu)
|
||||
|
||||
<p align="center"> <a href="https://www.polygon.com/pokemon-red-blue-3d-voxel-mod-battle-pixels-gameplay-footage-remake/"> <img src="https://img.shields.io/badge/AS%20SEEN%20ON-POLYGON-ea2e49?style=for-the-badge" alt="As seen on Polygon"> </a> </p>
|
||||
<p align="center"> <a href="https://kotaku.com/pokemon-red-blue-recompilation-project-voxel-3d-mod-2000720281"> <img src="https://img.shields.io/badge/AS%20SEEN%20ON-KOTAKU-ea2e49?style=for-the-badge" alt="As seen on KOTAKU"> </a> </p>
|
||||
<p align="center">
|
||||
|
||||
<a href="https://www.youtube.com/@bryanthaboi">
|
||||
<img src="https://img.shields.io/badge/YouTube-FF0000?style=for-the-badge&logo=youtube&logoColor=white" alt="YouTube">
|
||||
</a>
|
||||
<a href="https://www.tiktok.com/@bryanthaboi">
|
||||
<img src="https://img.shields.io/badge/TikTok-000000?style=for-the-badge&logo=tiktok&logoColor=white" alt="TikTok">
|
||||
</a>
|
||||
<a href="https://x.com/bryanthaboi">
|
||||
<img src="https://img.shields.io/badge/X-000000?style=for-the-badge&logo=x&logoColor=white" alt="X">
|
||||
</a>
|
||||
<a href="https://bsky.app/profile/bryanthaboi.live">
|
||||
<img src="https://img.shields.io/badge/Bluesky-0285FF?style=for-the-badge&logo=bluesky&logoColor=white" alt="Bluesky">
|
||||
</a>
|
||||
<a href="https://www.instagram.com/bryanthaboi">
|
||||
<img src="https://img.shields.io/badge/Instagram-E4405F?style=for-the-badge&logo=instagram&logoColor=white" alt="Instagram">
|
||||
</a>
|
||||
|
||||
</p>
|
||||
|
||||
|
||||
<p align="center"> <a href="https://www.polygon.com/pokemon-red-blue-3d-voxel-mod-battle-pixels-gameplay-footage-remake/"> <img src="https://img.shields.io/badge/AS%20SEEN%20ON-POLYGON-ea2e49?style=for-the-badge" alt="As seen on Polygon"> </a>
|
||||
<a href="https://kotaku.com/pokemon-red-blue-recompilation-project-voxel-3d-mod-2000720281"> <img src="https://img.shields.io/badge/AS%20SEEN%20ON-KOTAKU-ea2e49?style=for-the-badge" alt="As seen on KOTAKU"> </a>
|
||||
|
||||
<a href="https://www.digitalfoundry.net/news/2026/07/pokemon-yellow-voxel-mod-turns-the-original-gameboy-code-into-a-stunning-world">
|
||||
<img src="https://img.shields.io/badge/AS%20SEEN%20ON-DIGITAL%20FOUNDRY-ea2e49?style=for-the-badge" alt="As seen on Digital Foundry">
|
||||
</a>
|
||||
<a href="https://www.androidauthority.com/unofficial-android-port-pokemon-red-blue-yellow-3692724/">
|
||||
<img src="https://img.shields.io/badge/AS%20SEEN%20ON-ANDROID%20AUTHORITY-ea2e49?style=for-the-badge" alt="As seen on Android Authority">
|
||||
</a>
|
||||
|
||||
<a href="https://www.xda-developers.com/this-amazing-pokemon-red-and-blue-voxel-mod-adds-a-3d-perspective-without-an-emulator/">
|
||||
<img src="https://img.shields.io/badge/AS%20SEEN%20ON-XDA%20DEVELOPERS-ea2e49?style=for-the-badge" alt="As seen on XDA Developers">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
### Watch the latest update video
|
||||
|
||||
[](https://www.youtube.com/watch?v=TbHdJIrKJJU)
|
||||
[](https://www.youtube.com/watch?v=8IOgqbe4YvA)
|
||||
|
||||
|
||||
This project does not include a ROM, emulate the Game Boy, transpile assembly,
|
||||
|
||||
@@ -11,8 +11,70 @@
|
||||
-- equivalent, kept for save compatibility: rows 1-4 also self-heal
|
||||
-- older saves (flag set before the port hid the ball) by hiding the
|
||||
-- leftover ball on the next interaction.
|
||||
--
|
||||
-- The room's two readables (data/events/hidden_events.asm,
|
||||
-- hidden_events_for CELADON_MANSION_ROOF_HOUSE):
|
||||
-- hidden_text_predef 3, 0 / 4, 0 PrintBlackboardLinkCableText, LinkCableHelp
|
||||
-- hidden_text_predef 3, 4 PrintNotebookText, TMNotebook
|
||||
-- tools/extract/field.py only parses `hidden_event` rows, so no
|
||||
-- hidden_text_predef row reaches data/generated/field.lua and both tiles
|
||||
-- were dead A presses (#391). Same hook shape as the bedroom SNES in
|
||||
-- data/scripts/flavor/reds_house_2f.lua (#135). hidden_text_predef puts
|
||||
-- the predef id in the facing byte, so neither tile gates on facing.
|
||||
|
||||
local Menu = require("src.ui.Menu")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
|
||||
-- TMNotebookText (data/text/text_2.asm) has no leading underscore, so the
|
||||
-- extractor never collects it and the pamphlet's text is inlined.
|
||||
local TM_NOTEBOOK_TEXT = "It's a pamphlet\non TMs.\f...\f"
|
||||
.. "There are 50 TMs\nin all.\f"
|
||||
.. "There are also 5\nHMs that can be\vused repeatedly.\f"
|
||||
.. "SILPH CO."
|
||||
|
||||
-- LinkCableHelp (engine/events/hidden_events/school_blackboard.asm):
|
||||
-- HowToLinkText's four headings in the 15x10 box at the top left; picking
|
||||
-- a heading prints its LinkCableInfoText and returns to the menu, B or
|
||||
-- STOP READING closes.
|
||||
local LINK_HEADINGS = { "HOW TO LINK", "COLOSSEUM", "TRADE CENTER" }
|
||||
|
||||
local function linkCableHelp(game)
|
||||
local text = game.data.text or {}
|
||||
local items, showMenu, askHeading
|
||||
function showMenu()
|
||||
game.stack:push(Menu.new(game, items,
|
||||
{ tx = 0, ty = 0, tw = 15, th = 10, rowStep = 1 }))
|
||||
end
|
||||
function askHeading()
|
||||
game.stack:push(TextBox.new(game,
|
||||
text._LinkCableHelpText2 or "Which heading do\nyou want to read?",
|
||||
showMenu))
|
||||
end
|
||||
items = {}
|
||||
for i, label in ipairs(LINK_HEADINGS) do
|
||||
items[i] = { label = label, onSelect = function()
|
||||
game.stack:push(TextBox.new(game,
|
||||
text["_LinkCableInfoText" .. i] or label, askHeading))
|
||||
end }
|
||||
end
|
||||
items[#items + 1] = { label = "STOP READING" }
|
||||
game.stack:push(TextBox.new(game,
|
||||
text._LinkCableHelpText1 or "TRAINER TIPS\fUsing a Game Link\nCable",
|
||||
askHeading))
|
||||
end
|
||||
|
||||
return {
|
||||
onInteract = function(game, ow, fx, fy)
|
||||
if fy == 0 and (fx == 3 or fx == 4) then
|
||||
linkCableHelp(game)
|
||||
return true
|
||||
end
|
||||
if fx == 3 and fy == 4 then
|
||||
game.stack:push(TextBox.new(game, TM_NOTEBOOK_TEXT))
|
||||
return true
|
||||
end
|
||||
return false
|
||||
end,
|
||||
talk = {
|
||||
TEXT_CELADONMANSION_ROOF_HOUSE_EEVEE_POKEBALL = {
|
||||
{ "check_flag", "EVENT_GOT_EEVEE" }, -- 1
|
||||
@@ -22,11 +84,16 @@ return {
|
||||
{ "jump", 13 }, -- 4
|
||||
{ "give_pokemon", "EEVEE", 25 }, -- 5
|
||||
{ "jump_if_false", 12 }, -- 6 (party+box full)
|
||||
{ "play_sound", "Get_Item1" }, -- 7 (GotMonText jingle)
|
||||
{ "show_text", "_GotMonText", { RAM = "EEVEE" } }, -- 8
|
||||
{ "set_flag", "EVENT_GOT_EEVEE" }, -- 9
|
||||
-- flag + HideObject before the jingle and GotMonText: the nickname
|
||||
-- prompt inside give_pokemon and the text row both yield, and a
|
||||
-- script that dies there would leave the EEVEE taken with the ball
|
||||
-- still on the table and the gift claimable again (#426). The row
|
||||
-- count is unchanged, so the numeric jump targets still hold.
|
||||
{ "set_flag", "EVENT_GOT_EEVEE" }, -- 7
|
||||
{ "hide_object", "CELADON_MANSION_ROOF_HOUSE",
|
||||
"CELADONMANSION_ROOF_HOUSE_EEVEE_POKEBALL" }, -- 10
|
||||
"CELADONMANSION_ROOF_HOUSE_EEVEE_POKEBALL" }, -- 8
|
||||
{ "play_sound", "Get_Item1" }, -- 9 (GotMonText jingle)
|
||||
{ "show_text", "_GotMonText", { RAM = "EEVEE" } }, -- 10
|
||||
{ "jump", 13 }, -- 11
|
||||
{ "show_text", "_BoxIsFullText" }, -- 12
|
||||
},
|
||||
|
||||
+74
-16
@@ -666,6 +666,48 @@ M.WARDENS_HOUSE = {
|
||||
-- generic OPP_GIOVANNI#2 battle)
|
||||
-- -------------------------------------------------------------------
|
||||
|
||||
-- SilphCo11FTeamRocketLeavesScript (scripts/SilphCo11F.asm) hides every
|
||||
-- ROCKET and rocket-aligned SCIENTIST toggle on 2F-11F the moment Giovanni
|
||||
-- falls; the item balls, the 2F/10F rescued workers and the 7F rival keep
|
||||
-- theirs. Names and order are data/maps/toggleable_objects.asm's
|
||||
-- TOGGLE_SILPH_CO_<floor>_n entries. Saffron City's half of the same
|
||||
-- script lives in M.SAFFRON_CITY (story4.lua) (#392).
|
||||
local SILPH_ROCKET_OBJECTS = {
|
||||
{ "SILPH_CO_2F", { "SILPHCO2F_SCIENTIST1", "SILPHCO2F_SCIENTIST2",
|
||||
"SILPHCO2F_ROCKET1", "SILPHCO2F_ROCKET2" } },
|
||||
{ "SILPH_CO_3F", { "SILPHCO3F_ROCKET", "SILPHCO3F_SCIENTIST" } },
|
||||
{ "SILPH_CO_4F", { "SILPHCO4F_ROCKET1", "SILPHCO4F_SCIENTIST",
|
||||
"SILPHCO4F_ROCKET2" } },
|
||||
{ "SILPH_CO_5F", { "SILPHCO5F_ROCKET1", "SILPHCO5F_SCIENTIST",
|
||||
"SILPHCO5F_ROCKER", "SILPHCO5F_ROCKET2" } },
|
||||
{ "SILPH_CO_6F", { "SILPHCO6F_ROCKET1", "SILPHCO6F_SCIENTIST",
|
||||
"SILPHCO6F_ROCKET2" } },
|
||||
{ "SILPH_CO_7F", { "SILPHCO7F_ROCKET1", "SILPHCO7F_SCIENTIST",
|
||||
"SILPHCO7F_ROCKET2", "SILPHCO7F_ROCKET3" } },
|
||||
{ "SILPH_CO_8F", { "SILPHCO8F_ROCKET1", "SILPHCO8F_SCIENTIST",
|
||||
"SILPHCO8F_ROCKET2" } },
|
||||
{ "SILPH_CO_9F", { "SILPHCO9F_ROCKET1", "SILPHCO9F_SCIENTIST",
|
||||
"SILPHCO9F_ROCKET2" } },
|
||||
{ "SILPH_CO_10F", { "SILPHCO10F_ROCKET", "SILPHCO10F_SCIENTIST" } },
|
||||
{ "SILPH_CO_11F", { "SILPHCO11F_GIOVANNI", "SILPHCO11F_ROCKET1",
|
||||
"SILPHCO11F_ROCKET2" } },
|
||||
}
|
||||
|
||||
-- hide_object writes save.objectToggles, so the single pass at the win
|
||||
-- covers floors the player is not standing on; onlyMap is the per-floor
|
||||
-- repair path below.
|
||||
local function silphRocketsLeave(game, ow, onlyMap)
|
||||
local Commands = require("src.script.Commands")
|
||||
local ctx = { game = game, save = game.save, overworld = ow }
|
||||
for _, floor in ipairs(SILPH_ROCKET_OBJECTS) do
|
||||
if not onlyMap or onlyMap == floor[1] then
|
||||
for _, name in ipairs(floor[2]) do
|
||||
Commands.hide_object(ctx, floor[1], name)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
M.SILPH_CO_11F = {
|
||||
-- Giovanni's battle is a COORDINATE TRIGGER, not a talk.
|
||||
-- SilphCo11FDefaultScript (scripts/SilphCo11F.asm) checks
|
||||
@@ -692,13 +734,11 @@ M.SILPH_CO_11F = {
|
||||
ow:scriptMove(gio, "down", 3, function()
|
||||
gio:facePlayer(ow.player)
|
||||
ow:engageTrainer(gio, function()
|
||||
-- SilphCo11FTeamRocketLeavesScript: Giovanni leaves the floor
|
||||
-- SilphCo11FTeamRocketLeavesScript: every Silph rocket leaves
|
||||
-- after the loss (the street rockets are handled by
|
||||
-- M.SAFFRON_CITY.onEnter in story4.lua).
|
||||
if game.save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI then
|
||||
local Commands = require("src.script.Commands")
|
||||
local ctx = { game = game, save = game.save, overworld = ow }
|
||||
Commands.hide_object(ctx, "SILPH_CO_11F", "SILPHCO11F_GIOVANNI")
|
||||
silphRocketsLeave(game, ow)
|
||||
end
|
||||
end)
|
||||
end)
|
||||
@@ -706,28 +746,46 @@ M.SILPH_CO_11F = {
|
||||
end,
|
||||
onEnter = function(game, ow)
|
||||
if game.save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI then
|
||||
local Commands = require("src.script.Commands")
|
||||
local ctx = { game = game, save = game.save, overworld = ow }
|
||||
Commands.hide_object(ctx, "SILPH_CO_11F", "SILPHCO11F_GIOVANNI")
|
||||
silphRocketsLeave(game, ow)
|
||||
end
|
||||
end,
|
||||
talk = {
|
||||
-- SilphCo11FSilphPresidentText branches on EVENT_GOT_MASTER_BALL only:
|
||||
-- the teleport pads reach him without passing Giovanni's trigger, and
|
||||
-- afterwards he describes the ball forever. The old rows fell off the
|
||||
-- end of the script on both branches, so a second talk was silence
|
||||
-- (#392).
|
||||
TEXT_SILPHCO11F_SILPH_PRESIDENT = {
|
||||
{ "face_player" }, -- 1
|
||||
{ "check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI" }, -- 2
|
||||
{ "jump_if_false", 10 }, -- 3
|
||||
{ "check_flag", "EVENT_GOT_MASTER_BALL" }, -- 4
|
||||
{ "jump_if_true", 10 }, -- 5
|
||||
{ "show_text", "_SilphCo11FSilphPresidentText" }, -- 6
|
||||
{ "check_flag", "EVENT_GOT_MASTER_BALL" }, -- 2
|
||||
{ "jump_if_true", 9 }, -- 3
|
||||
{ "show_text", "_SilphCo11FSilphPresidentText" }, -- 4
|
||||
-- give-then-print like scripts/SilphCo11F.asm
|
||||
{ "give_item", "MASTER_BALL", 1, false }, -- 7
|
||||
{ "show_text", "_SilphCo11FSilphPresidentReceivedMasterBallText" }, -- 8
|
||||
{ "set_flag", "EVENT_GOT_MASTER_BALL" }, -- 9
|
||||
{ "jump", 11 }, -- 10
|
||||
{ "give_item", "MASTER_BALL", 1, false }, -- 5
|
||||
{ "show_text", "_SilphCo11FSilphPresidentReceivedMasterBallText" }, -- 6
|
||||
{ "set_flag", "EVENT_GOT_MASTER_BALL" }, -- 7
|
||||
{ "jump", "end" }, -- 8
|
||||
{ "show_text",
|
||||
"_SilphCo11FSilphPresidentMasterBallDescriptionText" }, -- 9
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
-- Floors 2F-10F carry no other onEnter, so this repairs saves that beat
|
||||
-- Giovanni before the hide list existed (same shape as
|
||||
-- M.POKEMON_TOWER_7F.onEnter); 11F hides its own inside the table above.
|
||||
for _, floor in ipairs(SILPH_ROCKET_OBJECTS) do
|
||||
local mapId = floor[1]
|
||||
if mapId ~= "SILPH_CO_11F" then
|
||||
M[mapId] = M[mapId] or {}
|
||||
M[mapId].onEnter = function(game, ow)
|
||||
if game.save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI then
|
||||
silphRocketsLeave(game, ow, mapId)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- -------------------------------------------------------------------
|
||||
-- Victory Road boulder switches (scripts/VictoryRoad1F/2F/3F.asm):
|
||||
-- a boulder resting on a switch removes a barrier block; the 3F hole
|
||||
|
||||
+108
-112
@@ -128,6 +128,11 @@ M.POKEMON_TOWER_6F = {
|
||||
t._PokemonTower6FBeGoneText or "Be gone...\nIntruders...", function()
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local battle = BattleState.newWild(game, "MAROWAK", 30)
|
||||
-- ItemUseBall .notOldManBattle (item_effects.asm:166-175): a ball
|
||||
-- thrown on POKEMON_TOWER_6F at the RESTLESS SOUL is dodged whether
|
||||
-- or not the scope revealed it, so the "can't be caught" state rides
|
||||
-- the battle instead of IsGhostBattle alone (#444)
|
||||
battle.noCatch = true
|
||||
if not game.save.inventory.SILPH_SCOPE then
|
||||
battle:makeGhost()
|
||||
end
|
||||
@@ -182,17 +187,16 @@ M.POKEMON_TOWER_6F = {
|
||||
-- preFrames: the shake's lead-in delays -- ShakeElevator's own Delay3s
|
||||
-- come to 9 frames; Silph/Rocket's ...ShakeScript prefixes another
|
||||
-- Delay3 (12) while CeladonMartElevatorShakeScript farjps straight in
|
||||
-- The panel is a bg_event, not a map-entry script: data/maps/objects/
|
||||
-- CeladonMartElevator.asm has `bg_event 3, 0, TEXT_CELADONMARTELEVATOR`
|
||||
-- and CeladonMartElevatorText is what runs DisplayElevatorFloorMenu, so
|
||||
-- the menu waits for the player to face the panel and press A (#395).
|
||||
-- Map entry only stores the car's exit warps (CeladonMartElevator
|
||||
-- StoreWarpEntriesScript), which is elevatorSeedExit below.
|
||||
-- After the ride the original does NOT jump-cut to the floor: choosing a
|
||||
-- floor in engine/events/elevator.asm DisplayElevatorFloorMenu rewrites
|
||||
-- the elevator car's own warp entries (wWarpEntries, via .UpdateWarp) to
|
||||
-- the chosen floor's exit warp, then the player walks out of the car onto
|
||||
-- that warp themselves (scripts/SilphCoElevator.asm etc.). Reproduce
|
||||
-- that here: rewrite the car's exit warps, then drive a short scripted
|
||||
-- walk-out onto an exit tile and take the (now rewritten) warp, reusing
|
||||
-- ow:scriptMove / ow:takeWarp (the Oak-escort primitives).
|
||||
local WALK_DIRVEC = { up = { 0, -1 }, down = { 0, 1 }, left = { -1, 0 }, right = { 1, 0 } }
|
||||
local WALK_OPP = { up = "down", down = "up", left = "right", right = "left" }
|
||||
local WALK_ORDER = { "up", "down", "left", "right" }
|
||||
-- floor rewrites the elevator car's own warp entries (wWarpEntries, via
|
||||
-- .UpdateWarp) to the chosen floor's exit warp and returns control, and
|
||||
-- the player walks out of the car onto that warp themselves.
|
||||
|
||||
-- .UpdateWarp: point EVERY car exit warp at the same floor's elevator
|
||||
-- door (warp id, map id). Shared generated map data, but the car's
|
||||
@@ -205,46 +209,6 @@ local function elevatorSetExit(ow, floor)
|
||||
end
|
||||
end
|
||||
|
||||
local function elevatorWalkOut(ow, floor)
|
||||
local m, p = ow.map, ow.player
|
||||
elevatorSetExit(ow, floor)
|
||||
-- leave by the exit tile under the player (they warped in onto one),
|
||||
-- else the nearest
|
||||
local door
|
||||
for _, w in ipairs(m.def.warps) do
|
||||
if w.x == p.cellX and w.y == p.cellY then door = w break end
|
||||
end
|
||||
if not door then
|
||||
local best
|
||||
for _, w in ipairs(m.def.warps) do
|
||||
local d = math.abs(w.x - p.cellX) + math.abs(w.y - p.cellY)
|
||||
if not best or d < best then best, door = d, w end
|
||||
end
|
||||
end
|
||||
-- the car interior sits on the door's walkable side; "out" is the
|
||||
-- doorway direction (the map edge for Silph/Celadon, the top doorway
|
||||
-- for the Rocket car). Fixed direction order keeps the step
|
||||
-- deterministic when a door tile has several walkable neighbours
|
||||
-- (Silph/Celadon step up into the car, the Rocket car steps down).
|
||||
local into
|
||||
for _, dir in ipairs(WALK_ORDER) do
|
||||
local v = WALK_DIRVEC[dir]
|
||||
local nx, ny = door.x + v[1], door.y + v[2]
|
||||
if m:inBounds(nx, ny) and m:isWalkableCell(nx, ny) then into = dir break end
|
||||
end
|
||||
into = into or "up"
|
||||
local out = WALK_OPP[into]
|
||||
local function leave()
|
||||
ow:takeWarp(door) -- door SFX + warp to the rewritten floor target
|
||||
end
|
||||
-- step one tile into the car, then walk back through the doorway onto
|
||||
-- the exit tile and take the warp: a visible walk-out on valid tiles
|
||||
-- for either door orientation, instead of a jump cut
|
||||
ow:scriptMove(p, into, 1, function()
|
||||
ow:scriptMove(p, out, 1, leave)
|
||||
end)
|
||||
end
|
||||
|
||||
local function elevatorFloors(elevatorMapId, game)
|
||||
local floors = {}
|
||||
for mapId, def in pairs(game.data.maps) do
|
||||
@@ -272,9 +236,9 @@ local function elevatorFloors(elevatorMapId, game)
|
||||
end
|
||||
|
||||
local function elevatorSeedExit(ow, floors, fromMapId)
|
||||
-- Seed a walk-out destination before the menu (or key-gate text):
|
||||
-- entry floor when known, else the first listed floor (1F). Choosing
|
||||
-- a floor still rewrites via elevatorWalkOut; B-cancel / no-key leave
|
||||
-- Seed a walk-out destination on entry (before the panel is ever
|
||||
-- read): entry floor when known, else the first listed floor (1F).
|
||||
-- Choosing a floor rewrites it again; B-cancel / no-key leave
|
||||
-- keeps this seed so walking out of the car cannot hit a missing ROM
|
||||
-- placeholder (#123) or the car's static default floor (#90: Rocket
|
||||
-- Hideout defaults to B1F even when entered from B2F/B4F).
|
||||
@@ -288,68 +252,68 @@ local function elevatorSeedExit(ow, floors, fromMapId)
|
||||
return exitFloor
|
||||
end
|
||||
|
||||
local function elevator(elevatorMapId, keyGate, preFrames)
|
||||
local function elevator(elevatorMapId, panelText, keyGate, preFrames)
|
||||
-- the panel bg_event: DisplayElevatorFloorMenu runs from this text
|
||||
-- script, never from map entry (#395)
|
||||
local function panel(game, ow, npc, done)
|
||||
done = done or function() end
|
||||
local floors = elevatorFloors(elevatorMapId, game)
|
||||
-- Rocket Hideout: without LIFT_KEY the panel only prints the need-
|
||||
-- a-key line and shows no floor menu
|
||||
-- (scripts/RocketHideoutElevator.asm).
|
||||
if keyGate and not game.save.inventory[keyGate.item] then
|
||||
local TextBox = require("src.render.TextBox")
|
||||
game.stack:push(TextBox.new(game,
|
||||
game.data.text[keyGate.text] or "It appears to\nneed a key.", done))
|
||||
return
|
||||
end
|
||||
local items = {}
|
||||
for _, f in ipairs(floors) do
|
||||
table.insert(items, { label = f.token, value = f })
|
||||
end
|
||||
local ListMenu = require("src.ui.ListMenu")
|
||||
game.stack:push(ListMenu.new(game, "WHICH FLOOR?", items, {
|
||||
onChoose = function(item, list)
|
||||
list:close()
|
||||
-- the whole ShakeElevator ride runs in place -- music stop, 100
|
||||
-- collision-thud scroll bounces, the PA chime -- and only then
|
||||
-- does .UpdateWarp's rewrite land, with the player still stood
|
||||
-- at the panel: they walk out to the car door themselves
|
||||
local ElevatorShake = require("src.world.ElevatorShake")
|
||||
game.stack:push(ElevatorShake.new(game, ow, {
|
||||
preFrames = preFrames,
|
||||
onDone = function()
|
||||
elevatorSetExit(ow, item.value)
|
||||
done()
|
||||
end,
|
||||
}))
|
||||
end,
|
||||
onCancel = function()
|
||||
-- DisplayElevatorFloorMenu: `ret c` on B -- no warp, nothing
|
||||
-- happens, the player just stays in the car (exit warps were
|
||||
-- already seeded on entry)
|
||||
done()
|
||||
end,
|
||||
}))
|
||||
end
|
||||
return {
|
||||
-- fromMapId: the floor the player just left (setMap passes it), so a
|
||||
-- B-cancel can still walk out onto a real map. Silph's ROM car warps
|
||||
-- default to UNUSED_MAP_ED, which is not in Data.maps -- Warp.resolve
|
||||
-- asserted and hard-crashed (#123).
|
||||
onEnter = function(game, ow, fromMapId)
|
||||
local floors = elevatorFloors(elevatorMapId, game)
|
||||
elevatorSeedExit(ow, floors, fromMapId)
|
||||
-- Rocket Hideout: without LIFT_KEY the panel only prints the need-
|
||||
-- a-key line (scripts/RocketHideoutElevator.asm). Exit warps are
|
||||
-- still seeded above so walking out returns to the entry floor
|
||||
-- instead of the car's ROM default (B1F) -- #90 / #105.
|
||||
if keyGate and not game.save.inventory[keyGate.item] then
|
||||
local TextBox = require("src.render.TextBox")
|
||||
game.stack:push(TextBox.new(game,
|
||||
game.data.text[keyGate.text] or "It appears to\nneed a key."))
|
||||
return
|
||||
end
|
||||
local items = {}
|
||||
for _, f in ipairs(floors) do
|
||||
table.insert(items, { label = f.token, value = f })
|
||||
end
|
||||
local ListMenu = require("src.ui.ListMenu")
|
||||
game.stack:push(ListMenu.new(game, "WHICH FLOOR?", items, {
|
||||
onChoose = function(item, list)
|
||||
list:close()
|
||||
-- the map-entry Transition (startWarpTo) calls onEnter from
|
||||
-- its OWN midpoint callback, so this menu was pushed on top
|
||||
-- of that still-active Transition; only the top state
|
||||
-- updates, so it froze mid-fade instead of finishing. Left
|
||||
-- alone it would resurface after the ride and play a stray
|
||||
-- fade at the wrong time -- pop it now, before it can happen.
|
||||
local Transition = require("src.render.Transition")
|
||||
if getmetatable(game.stack:top()) == Transition then
|
||||
game.stack:pop()
|
||||
end
|
||||
-- the whole ShakeElevator ride runs in place -- music stop,
|
||||
-- 100 collision-thud scroll bounces, the PA chime -- and only
|
||||
-- then does the player walk out of the car onto the chosen
|
||||
-- floor (elevatorWalkOut rewrites the car's exit warps first)
|
||||
local ElevatorShake = require("src.world.ElevatorShake")
|
||||
game.stack:push(ElevatorShake.new(game, ow, {
|
||||
preFrames = preFrames,
|
||||
onDone = function()
|
||||
elevatorWalkOut(ow, item.value)
|
||||
end,
|
||||
}))
|
||||
end,
|
||||
onCancel = function()
|
||||
-- DisplayElevatorFloorMenu: `ret c` on B -- no warp, nothing
|
||||
-- happens, the player just stays in the car (exit warps were
|
||||
-- already seeded to the entry floor above)
|
||||
end,
|
||||
}))
|
||||
elevatorSeedExit(ow, elevatorFloors(elevatorMapId, game), fromMapId)
|
||||
end,
|
||||
talk = { [panelText] = panel },
|
||||
}
|
||||
end
|
||||
|
||||
M.SILPH_CO_ELEVATOR = elevator("SILPH_CO_ELEVATOR")
|
||||
M.CELADON_MART_ELEVATOR = elevator("CELADON_MART_ELEVATOR", nil, 9)
|
||||
M.SILPH_CO_ELEVATOR = elevator("SILPH_CO_ELEVATOR",
|
||||
"TEXT_SILPHCOELEVATOR_ELEVATOR")
|
||||
M.CELADON_MART_ELEVATOR = elevator("CELADON_MART_ELEVATOR",
|
||||
"TEXT_CELADONMARTELEVATOR", nil, 9)
|
||||
M.ROCKET_HIDEOUT_ELEVATOR = elevator("ROCKET_HIDEOUT_ELEVATOR",
|
||||
"TEXT_ROCKETHIDEOUTELEVATOR",
|
||||
{ item = "LIFT_KEY", text = "_RocketHideoutElevatorAppearsToNeedKeyText" })
|
||||
|
||||
-- -------------------------------------------------------------------
|
||||
@@ -695,6 +659,16 @@ local DOCK_SHIP_BLOCKS = {
|
||||
{ bx = 7, by = 2, water = 13 }, { bx = 8, by = 2, water = 13 },
|
||||
}
|
||||
|
||||
-- her four hull columns bow-to-stern (upper-half / lower-half block ids)
|
||||
-- and the open-water ids of the rows she sits in
|
||||
local DOCK_SHIP_COLUMNS = {
|
||||
{ bx = 5, top = 4, bottom = 8 },
|
||||
{ bx = 6, top = 5, bottom = 9 },
|
||||
{ bx = 7, top = 6, bottom = 10 },
|
||||
{ bx = 8, top = 7, bottom = 11 },
|
||||
}
|
||||
local DOCK_WATER_TOP, DOCK_WATER_BOTTOM = 1, 13
|
||||
|
||||
M.VERMILION_DOCK = {
|
||||
onEnter = function(game, ow)
|
||||
local Flags = require("src.script.Flags")
|
||||
@@ -729,17 +703,39 @@ M.VERMILION_DOCK = {
|
||||
ow:startDustAnim(cx, 1, function() puff(n - 1, cx + 2) end)
|
||||
end
|
||||
puff(3, 15)
|
||||
-- VermilionDock_EraseSSAnne deliberately leaves the blocks under the
|
||||
-- player alone ("south of the player and won't be redrawn"), so skip
|
||||
-- his own block: he must not spend the walk-out standing on water
|
||||
local pbx = math.floor(ow.player.cellX / 2)
|
||||
local pby = math.floor(ow.player.cellY / 2)
|
||||
local rows = {}
|
||||
rows[#rows + 1] = { "wait", 100 }
|
||||
rows[#rows + 1] = { "play_sound", "SS_Anne_Horn" }
|
||||
for _, b in ipairs(DOCK_SHIP_BLOCKS) do
|
||||
rows[#rows + 1] = { "replace_block", b.bx, b.by, b.water }
|
||||
local function setBlock(bx, by, block)
|
||||
if bx < 1 or bx > 8 then return end
|
||||
if bx == pbx and by == pby then return end
|
||||
rows[#rows + 1] = { "replace_block", bx, by, block }
|
||||
end
|
||||
rows[#rows + 1] = { "wait", 30 }
|
||||
rows[#rows + 1] = { "wait", 120 }
|
||||
rows[#rows + 1] = { "play_sound", "SS_Anne_Horn" }
|
||||
-- .shift_columns_up slides her tile columns west behind a mid-frame
|
||||
-- rSCX split; with no split scroll here she sails one block per beat
|
||||
-- and the water closes in astern (#360)
|
||||
for step = 1, 8 do
|
||||
for _, col in ipairs(DOCK_SHIP_COLUMNS) do
|
||||
setBlock(col.bx - step, 1, col.top)
|
||||
setBlock(col.bx - step, 2, col.bottom)
|
||||
end
|
||||
setBlock(9 - step, 1, DOCK_WATER_TOP)
|
||||
setBlock(9 - step, 2, DOCK_WATER_BOTTOM)
|
||||
rows[#rows + 1] = { "wait", 20 }
|
||||
end
|
||||
-- the second horn as she clears the dock, then EraseSSAnne's 120
|
||||
-- frames before the walk out
|
||||
rows[#rows + 1] = { "play_sound", "SS_Anne_Horn" }
|
||||
rows[#rows + 1] = { "wait", 120 }
|
||||
rows[#rows + 1] = { "move_player", "up", 2 }
|
||||
-- keep Music_Surfing across the city warp (pokered stays on it
|
||||
-- through the exit-ship walk)
|
||||
rows[#rows + 1] = { "play_music", "Music_Surfing", { keep = true } }
|
||||
-- no keepMusic on this warp: Music_Surfing belongs to the dock's
|
||||
-- cutscene, and VERMILION_CITY's own theme has to take over as the
|
||||
-- player crosses in (EnterMap's PlayDefaultMusic)
|
||||
rows[#rows + 1] = { "warp", "VERMILION_CITY", 18, 31, "up" }
|
||||
rows[#rows + 1] = { "move_player", "up", 2 }
|
||||
ow:queueScript(rows)
|
||||
|
||||
+20
-3
@@ -135,6 +135,9 @@ M.SILPH_CO_2F = {
|
||||
talk = {
|
||||
TEXT_SILPHCO2F_SILPH_WORKER_F = gift({
|
||||
flag = "EVENT_GOT_TM36", item = "TM_SELFDESTRUCT",
|
||||
-- the label carries no leading underscore: pokered keeps this one in
|
||||
-- the script bank, not the far-text bank (#393)
|
||||
pre = "SilphCo2FSilphWorkerFPleaseTakeThisText",
|
||||
received = "_SilphCo2FSilphWorkerFReceivedTM36Text",
|
||||
explain = "_SilphCo2FSilphWorkerFTM36ExplanationText",
|
||||
noRoom = "_SilphCo2FSilphWorkerFTM36NoRoomText",
|
||||
@@ -836,6 +839,19 @@ M.SILPH_CO_7F = {
|
||||
end,
|
||||
}
|
||||
|
||||
-- SSAnne2FRivalAfterBattleScript's exit walk is keyed on the player's X,
|
||||
-- not on where the rival stopped: from (37,8) the rival stands below him on
|
||||
-- (36,8) and takes .RivalDownFourMovement straight down out of the room;
|
||||
-- from (36,8) he stands above him on (36,7) and takes
|
||||
-- .RivalWalkAroundPlayerMovement, which steps RIGHT and then falls through
|
||||
-- into the same four DOWNs, so five downs in all (#360).
|
||||
local function ssAnne2FRivalExitDirs(onLeft)
|
||||
if onLeft then
|
||||
return { "right", "down", "down", "down", "down", "down" }
|
||||
end
|
||||
return { "down", "down", "down", "down" }
|
||||
end
|
||||
|
||||
-- S.S. Anne 2F rival ambush (scripts/SSAnne2F.asm; coords 36/37,8)
|
||||
M.SS_ANNE_2F = {
|
||||
onStep = function(game, ow, x, y)
|
||||
@@ -848,11 +864,12 @@ M.SS_ANNE_2F = {
|
||||
{ "face_object", 2, onLeft and "down" or "right" }, -- 3
|
||||
{ "show_text", "_SSAnne2FRivalText" }, -- 4
|
||||
{ "rival_battle", "OPP_RIVAL2", 1 }, -- 5
|
||||
{ "jump_if_false", 10 }, -- 6
|
||||
{ "jump_if_false", 11 }, -- 6
|
||||
{ "set_flag", "EVENT_BEAT_SS_ANNE_RIVAL" }, -- 7
|
||||
{ "show_text", "_SSAnne2FRivalDefeatedText" }, -- 8
|
||||
{ "move_npc_to", 2, 36, 4 }, -- 9
|
||||
{ "hide_object", "SS_ANNE_2F", "SSANNE2F_RIVAL" }, -- 10
|
||||
{ "show_text", "_SSAnne2FRivalCutMasterText" }, -- 9
|
||||
{ "walk_npc", 2, ssAnne2FRivalExitDirs(onLeft) }, -- 10
|
||||
{ "hide_object", "SS_ANNE_2F", "SSANNE2F_RIVAL" }, -- 11
|
||||
}, onLeft and "up" or "left")
|
||||
end,
|
||||
}
|
||||
|
||||
@@ -23,10 +23,15 @@ M.CERULEAN_MELANIES_HOUSE = {
|
||||
rows[#rows + 1] = { "jump_if_false", "declined" }
|
||||
rows[#rows + 1] = { "give_pokemon", "BULBASAUR", 10 }
|
||||
rows[#rows + 1] = { "jump_if_false", "end" } -- party + box full
|
||||
rows[#rows + 1] = { "show_text", "MelanieText3" }
|
||||
-- Event and HideObject before MelanieText3: give_pokemon's nickname
|
||||
-- prompt and the received text both yield, and a script that dies
|
||||
-- in that window used to leave the BULBASAUR given with the event
|
||||
-- clear, so Melanie offered a second one (#426). The flag is only
|
||||
-- read at script entry, so the order is invisible in play.
|
||||
rows[#rows + 1] = { "set_flag", "EVENT_GOT_BULBASAUR_IN_CERULEAN" }
|
||||
rows[#rows + 1] = { "hide_object", "CERULEAN_MELANIES_HOUSE",
|
||||
"CERULEANMELANIESHOUSE_BULBASAUR" }
|
||||
rows[#rows + 1] = { "set_flag", "EVENT_GOT_BULBASAUR_IN_CERULEAN" }
|
||||
rows[#rows + 1] = { "show_text", "MelanieText3" }
|
||||
rows[#rows + 1] = { "jump", "end" }
|
||||
rows[#rows + 1] = { "label", "declined" }
|
||||
rows[#rows + 1] = { "show_text", "MelanieText5" }
|
||||
@@ -60,8 +65,13 @@ M.ROUTE_24 = {
|
||||
{ "jump_if_false", "declined" },
|
||||
{ "give_pokemon", "CHARMANDER", 10 },
|
||||
{ "jump_if_false", "end" },
|
||||
{ "show_text", "_Route24DamianText2" },
|
||||
-- SetEvent hoisted ahead of the received text (Route24.asm writes it
|
||||
-- after Route24Text_515e3, where nothing in between can fail): the
|
||||
-- nickname prompt and the text both yield, and a script death in that
|
||||
-- window left the CHARMANDER given with EVENT_54F clear, so Damian
|
||||
-- handed out a second one on the next talk (#426).
|
||||
{ "set_flag", "EVENT_54F" },
|
||||
{ "show_text", "_Route24DamianText2" },
|
||||
{ "jump", "end" },
|
||||
{ "label", "declined" },
|
||||
{ "show_text", "_Route24DamianText3" },
|
||||
@@ -87,9 +97,10 @@ M.VERMILION_CITY = {
|
||||
rows[#rows + 1] = { "jump_if_false", "declined" }
|
||||
rows[#rows + 1] = { "give_pokemon", "SQUIRTLE", 10 }
|
||||
rows[#rows + 1] = { "jump_if_false", "end" }
|
||||
rows[#rows + 1] = { "show_text", "_OfficerJennyText3" }
|
||||
-- event before the received text, as above (#426)
|
||||
rows[#rows + 1] = { "set_flag",
|
||||
"EVENT_GOT_SQUIRTLE_FROM_OFFICER_JENNY" }
|
||||
rows[#rows + 1] = { "show_text", "_OfficerJennyText3" }
|
||||
rows[#rows + 1] = { "jump", "end" }
|
||||
rows[#rows + 1] = { "label", "declined" }
|
||||
rows[#rows + 1] = { "show_text", "_OfficerJennyText4" }
|
||||
|
||||
@@ -48,7 +48,19 @@ M.MT_MOON_B2F = {
|
||||
{ "show_object", "MT_MOON_B2F", "MTMOONB2F_JESSIE" },
|
||||
{ "show_object", "MT_MOON_B2F", "MTMOONB2F_JAMES" },
|
||||
{ "show_text", "_MtMoonJessieJamesText1" },
|
||||
{ "face_player_dir", "right" },
|
||||
{ "face_player_dir", "up" },
|
||||
{ "emote", "player", "shock", 30 },
|
||||
-- MtMoonB2FScript_49e15 simulates PAD_UP for one player step, then
|
||||
-- Script6/Script9 walk Jessie (object 2, MovementData_f9e65: six
|
||||
-- $06) and James (object 6, f9e66: five $06); both objects' movement
|
||||
-- byte 2 is LEFT, so .determineDirection makes those LEFT steps --
|
||||
-- Jessie (9,3)->(3,3) above the player at (3,4), James (9,4)->(4,4)
|
||||
-- beside him. #423
|
||||
{ "walk_npc", "player", { "up" } },
|
||||
{ "walk_npc", 2, { "left", "left", "left", "left", "left", "left" } },
|
||||
{ "face_object", 2, "down" },
|
||||
{ "walk_npc", 6, { "left", "left", "left", "left", "left" } },
|
||||
{ "face_object", 6, "left" },
|
||||
{ "show_text", "_MtMoonJessieJamesText2" },
|
||||
{ "start_battle", "trainer", "OPP_ROCKET", 42 },
|
||||
{ "check_battle_result", "win" },
|
||||
@@ -57,8 +69,10 @@ M.MT_MOON_B2F = {
|
||||
{ "show_text", "_MtMoonJessieJamesText4" },
|
||||
{ "stop_music" },
|
||||
{ "play_music", "Music_MeetJessieJames" },
|
||||
{ "fade", "out" },
|
||||
{ "hide_object", "MT_MOON_B2F", "MTMOONB2F_JESSIE" },
|
||||
{ "hide_object", "MT_MOON_B2F", "MTMOONB2F_JAMES" },
|
||||
{ "fade", "in" },
|
||||
{ "play_default_music" },
|
||||
{ "set_flag", "EVENT_BEAT_MT_MOON_3_JESSIE_JAMES" },
|
||||
}, {})
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
# Build & install on your iPhone — step by step
|
||||
|
||||
This guide assumes **zero** programming experience. Follow it top to
|
||||
bottom and you'll have the game running on your own iPhone in roughly an
|
||||
hour (most of it is waiting for downloads).
|
||||
|
||||
## What you need
|
||||
|
||||
- A **Mac** (any Apple-silicon or recent Intel Mac on macOS 14 or newer)
|
||||
- An **iPhone** and its **charging cable**
|
||||
- A free **Apple ID** (the same account you use for the App Store)
|
||||
- Your **own, legally obtained** Pokémon Red or Blue ROM file (a 1 MB
|
||||
`.gb` file). This project ships no game data — the app rebuilds
|
||||
everything from *your* cartridge dump and verifies it before use.
|
||||
- About **15 GB free disk space** (Xcode is enormous; the game itself is
|
||||
tiny)
|
||||
|
||||
> **Free vs. paid Apple account:** a free Apple ID works. The only catch:
|
||||
> apps signed with a free account stop launching after **7 days** — just
|
||||
> re-run the install command (step 5) to re-sign; your save data is kept.
|
||||
> A paid Apple Developer account ($99/yr) extends that to a year.
|
||||
|
||||
---
|
||||
|
||||
## Step 1 — Install Xcode (once)
|
||||
|
||||
1. On the Mac, open the **App Store**, search **Xcode**, click **Get**.
|
||||
It's a ~10 GB download — go make tea.
|
||||
2. Open Xcode once. Accept the license. If it offers to install extra
|
||||
components or the **iOS platform**, say yes and let it finish.
|
||||
|
||||
## Step 2 — Sign Xcode into your Apple ID (once)
|
||||
|
||||
1. In Xcode's menu bar: **Xcode → Settings → Accounts**.
|
||||
2. Click the **+** in the bottom-left → **Apple Account** → sign in.
|
||||
3. Close the window. (This quietly creates the "signing certificate" the
|
||||
build uses — you never have to touch it again.)
|
||||
|
||||
## Step 3 — Get this project onto the Mac
|
||||
|
||||
If you received it as a folder, put it somewhere easy like your home
|
||||
folder. If it's on GitHub, click the green **Code** button → **Download
|
||||
ZIP**, then double-click the zip to unpack it.
|
||||
|
||||
## Step 4 — Build it (one command)
|
||||
|
||||
1. Open the **Terminal** app (press ⌘-space, type `terminal`, press
|
||||
return).
|
||||
2. Type `cd ` (c, d, space — don't press return yet), then **drag the
|
||||
project folder** from Finder onto the Terminal window — it fills in
|
||||
the path — and press **return**.
|
||||
3. Paste this and press return:
|
||||
|
||||
```sh
|
||||
scripts/build_ios.sh --fetch
|
||||
```
|
||||
|
||||
The first run downloads the LÖVE engine and compiles everything
|
||||
(5–15 minutes). Lines of build output scrolling by is normal. You're
|
||||
done when you see **`==> done`**.
|
||||
|
||||
## Step 5 — Put it on your iPhone
|
||||
|
||||
1. Plug the iPhone into the Mac with the cable. **Unlock it** and keep it
|
||||
unlocked. If it asks **"Trust This Computer?" → Trust**.
|
||||
2. Paste this and press return:
|
||||
|
||||
```sh
|
||||
scripts/build_ios.sh --device --install
|
||||
```
|
||||
|
||||
The script finds your signing identity and your phone by itself. If it
|
||||
complains, it tells you exactly what to fix (usually: the phone was
|
||||
locked — unlock and re-run).
|
||||
|
||||
3. First time only, the iPhone will want two approvals:
|
||||
- **Developer Mode**: Settings → **Privacy & Security** → scroll to
|
||||
**Developer Mode** → turn on → restart the phone → confirm.
|
||||
- **Trust the developer**: Settings → **General** → **VPN & Device
|
||||
Management** → tap the entry under *Developer App* → **Trust**.
|
||||
|
||||
Then re-run the command in step 5.2 if the install had failed.
|
||||
|
||||
## Step 6 — Give it your ROM and play
|
||||
|
||||
1. Get your `.gb` file onto the phone — AirDrop it to yourself, or save
|
||||
it in iCloud Drive / Files.
|
||||
2. Open the app. On the **RED** tab, tap **Import ROM** and pick your
|
||||
`.gb` file in the file browser that appears. (Alternative: in the
|
||||
**Files** app, drop the `.gb` into **On My iPhone → the game's
|
||||
folder** and just reopen the app — it imports automatically.)
|
||||
3. Import takes ~10 seconds, the button turns into **Play Red** — tap it.
|
||||
|
||||
## Optional goodies
|
||||
|
||||
- **Pokéwalker mode** (real steps → EXP): in-game **mod manager →
|
||||
POKEWALKER → SYNC STEPS on**, allow step access when iOS asks, and go
|
||||
for a walk.
|
||||
- **Mods**: launcher → **MODS** tab → **Import mod .zip**.
|
||||
- **Save import/export**: buttons on each game's tab, using the normal
|
||||
iOS file picker.
|
||||
|
||||
## When things go wrong
|
||||
|
||||
| Symptom | Fix |
|
||||
|---|---|
|
||||
| `xcodebuild not found` | Xcode isn't installed or wasn't opened once — do Step 1 |
|
||||
| `no Apple signing identity found` | Do Step 2, then re-run |
|
||||
| `no iPhone/iPad found` | Cable in? Phone unlocked? Tapped "Trust"? |
|
||||
| Install fails with "locked" | Unlock the phone, keep it unlocked, re-run |
|
||||
| App icon appears then won't open | Do the two approvals in Step 5.3 |
|
||||
| App stops launching after a week | Free-account 7-day limit — re-run Step 5.2 |
|
||||
| "That ROM could not be imported" | The file isn't a canonical 1 MB US Red/Blue dump — the importer checks its fingerprint |
|
||||
|
||||
Every build/install command is safe to re-run; your saves live on the
|
||||
phone and survive reinstalls.
|
||||
@@ -193,6 +193,11 @@ flushes the live options. Old saves that still embed an `options` table are
|
||||
migrated once into `options.lua` on load.
|
||||
|
||||
- Music / SFX volume
|
||||
- PIKACHU VOL (0-7, Yellow only): trims Pikachu's PCM voice clips under the
|
||||
SFX level, so the follower's constant chatter, the title-screen cry and
|
||||
every in-battle "Pika!" can be pulled down (or muted at 0) without
|
||||
quieting the rest of the sound effects. The row is hidden on Red/Blue,
|
||||
which have no voice clips.
|
||||
- Music Filter
|
||||
- OG GLITCHES on / off (Gen 1 quirks vs. modern-clean battle rules)
|
||||
- BATTLE LAYOUT (OG / WIDE); see "Widescreen battle layout" below
|
||||
|
||||
@@ -331,6 +331,30 @@ function love.gamepadaxis(joystick, axis, value)
|
||||
Game:gamepadaxis(joystick, axis, value)
|
||||
end
|
||||
|
||||
function love.joystickpressed(joystick, button)
|
||||
if editorMode then return end
|
||||
if Importer then return Importer:joystickpressed(joystick, button) end
|
||||
Game:joystickpressed(joystick, button)
|
||||
end
|
||||
|
||||
function love.joystickreleased(joystick, button)
|
||||
if editorMode then return end
|
||||
if Importer then return Importer:joystickreleased(joystick, button) end
|
||||
Game:joystickreleased(joystick, button)
|
||||
end
|
||||
|
||||
function love.joystickaxis(joystick, axis, value)
|
||||
if editorMode then return end
|
||||
if Importer then return Importer:joystickaxis(joystick, axis, value) end
|
||||
Game:joystickaxis(joystick, axis, value)
|
||||
end
|
||||
|
||||
function love.joystickhat(joystick, hat, direction)
|
||||
if editorMode then return end
|
||||
if Importer then return Importer:joystickhat(joystick, hat, direction) end
|
||||
Game:joystickhat(joystick, hat, direction)
|
||||
end
|
||||
|
||||
function love.joystickremoved(joystick)
|
||||
if editorMode then return end
|
||||
if Importer then return end
|
||||
@@ -358,7 +382,18 @@ end
|
||||
|
||||
function love.touchpressed(id, x, y, dx, dy, pressure)
|
||||
if editorMode then return end
|
||||
if Importer then return Importer:mousepressed(x, y, 1) end
|
||||
if Importer then
|
||||
-- iOS: LÖVE already synthesizes a mousepressed for the primary touch,
|
||||
-- and love.mousepressed below forwards that to the Importer, so
|
||||
-- forwarding here too fires every launcher button twice per tap. The
|
||||
-- resulting double-present was fatal for the document picker: the
|
||||
-- second sheet stole the first one's weakly-held delegate, so picking
|
||||
-- a file silently did nothing. Android keeps the forward for upstream
|
||||
-- parity (its SAF picker is a separate activity and tolerates the
|
||||
-- re-launch).
|
||||
if love.system.getOS() == "iOS" then return end
|
||||
return Importer:mousepressed(x, y, 1)
|
||||
end
|
||||
Game:touchpressed(id, x, y)
|
||||
end
|
||||
|
||||
@@ -424,6 +459,16 @@ function love.quit()
|
||||
pcall(function()
|
||||
require("src.core.DiscordPresence").shutdown()
|
||||
end)
|
||||
-- LOVE waits for every live love.thread before the process exits, and both
|
||||
-- background workers idle in a loop that only a "quit" command breaks, so
|
||||
-- without this the process outlived the window and the next launch re-entered
|
||||
-- the dead one instead of starting fresh (#339)
|
||||
if package.loaded["src.core.ChipAudio"] then
|
||||
pcall(package.loaded["src.core.ChipAudio"].shutdown)
|
||||
end
|
||||
if package.loaded["src.update.Check"] then
|
||||
pcall(package.loaded["src.update.Check"].shutdown)
|
||||
end
|
||||
end
|
||||
|
||||
function love.filedropped(file)
|
||||
@@ -461,6 +506,13 @@ function love.run()
|
||||
for name, a, b, c, d, e, f in love.event.poll() do
|
||||
if name == "quit" then
|
||||
if not love.quit or not love.quit() then
|
||||
-- Android keeps the process and its task alive after LOVE's own
|
||||
-- teardown, so the relaunched task re-enters an activity whose
|
||||
-- native main already returned; end the process outright once the
|
||||
-- love.quit hook has run (#339)
|
||||
if love.system and love.system.getOS() == "Android" then
|
||||
os.exit(a or 0)
|
||||
end
|
||||
return a or 0
|
||||
end
|
||||
end
|
||||
|
||||
@@ -78,6 +78,10 @@ public class GameActivity extends SDLActivity {
|
||||
private static final String PICKED_SAVE_FILENAME = "picked_save.sav";
|
||||
private static final String PENDING_EXPORT_FILENAME = "pending_export.sav";
|
||||
private static final String EXPORT_DONE_FILENAME = "export_done.flag";
|
||||
// Written when a SAF pick cannot be read at all, with the destination
|
||||
// basename as its body, so RomImporter:focus can say so in the launcher
|
||||
// instead of leaving the player on "No ROM imported" (issue #442).
|
||||
private static final String PICK_ERROR_FILENAME = "pick_error.flag";
|
||||
// Destination basename for the in-flight SAF pick (set by showFilePicker).
|
||||
private String pendingPickFilename = PICKED_ROM_FILENAME;
|
||||
// Suggested download name for the in-flight SAF create (set by showCreateDocument).
|
||||
@@ -452,6 +456,17 @@ public class GameActivity extends SDLActivity {
|
||||
return new File(new File(getExternalFilesDir(null), "save"), ROM_SAVE_IDENTITY);
|
||||
}
|
||||
|
||||
/** Drops a small flag file in the save identity for Lua to consume on focus. */
|
||||
private void writeSaveDirFlag(String name, String body) {
|
||||
try {
|
||||
FileOutputStream fos = new FileOutputStream(new File(saveIdentityDir(), name), false);
|
||||
fos.write(body.getBytes());
|
||||
fos.close();
|
||||
} catch (IOException e) {
|
||||
Log.d("GameActivity", "could not write " + name + ": " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private boolean copyFileToUri(File source, Uri destUri) {
|
||||
InputStream in = null;
|
||||
OutputStream out = null;
|
||||
@@ -491,14 +506,7 @@ public class GameActivity extends SDLActivity {
|
||||
Uri uri = data.getData();
|
||||
if (copyFileToUri(source, uri)) {
|
||||
// Signal Lua on next focus that the SAF export finished.
|
||||
File flag = new File(saveIdentityDir(), EXPORT_DONE_FILENAME);
|
||||
try {
|
||||
FileOutputStream fos = new FileOutputStream(flag, false);
|
||||
fos.write("ok".getBytes());
|
||||
fos.close();
|
||||
} catch (IOException e) {
|
||||
Log.d("GameActivity", "could not write export_done flag: " + e.getMessage());
|
||||
}
|
||||
writeSaveDirFlag(EXPORT_DONE_FILENAME, "ok");
|
||||
// Keep pending_export.sav so a retry still works; Lua may remove it.
|
||||
} else {
|
||||
Log.d("GameActivity", "could not write export to " + uri);
|
||||
@@ -521,19 +529,36 @@ public class GameActivity extends SDLActivity {
|
||||
? pendingPickFilename : PICKED_ROM_FILENAME;
|
||||
File destFile = new File(destDir, destName);
|
||||
|
||||
InputStream source;
|
||||
// ACTION_OPEN_DOCUMENT is meant to land in the system documents UI, but
|
||||
// some OEM shells (ColorOS) offer third-party file managers in a
|
||||
// chooser, and those hand back either a provider URI this app has no
|
||||
// grant for (SecurityException / FileNotFoundException) or a bare
|
||||
// file:// path (unreadable without storage permission on targetSdk 34).
|
||||
// Try the resolver, then the path, then tell Lua why nothing imported.
|
||||
InputStream source = null;
|
||||
try {
|
||||
source = getContentResolver().openInputStream(uri);
|
||||
} catch (FileNotFoundException e) {
|
||||
} catch (Exception e) {
|
||||
Log.d("GameActivity", "could not open picked file: " + e.getMessage());
|
||||
return;
|
||||
}
|
||||
if (source == null && "file".equals(uri.getScheme()) && uri.getPath() != null) {
|
||||
try {
|
||||
source = new FileInputStream(uri.getPath());
|
||||
} catch (FileNotFoundException e) {
|
||||
Log.d("GameActivity", "could not open picked path: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
if (source == null) {
|
||||
Log.d("GameActivity", "ContentResolver returned no stream for picked file");
|
||||
Log.d("GameActivity", "no readable stream for picked file " + uri);
|
||||
writeSaveDirFlag(PICK_ERROR_FILENAME, destName);
|
||||
return;
|
||||
}
|
||||
if (!copyAssetFile(source, destFile.getPath())) {
|
||||
Log.d("GameActivity", "could not copy picked file to " + destFile);
|
||||
// A truncated pick would only fail verification later, so drop it
|
||||
// and report instead.
|
||||
destFile.delete();
|
||||
writeSaveDirFlag(PICK_ERROR_FILENAME, destName);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,25 @@
|
||||
# iOS build (LÖVE 11.5)
|
||||
|
||||
> **Native ROM/mod/save import.** The iOS build ships a Swift
|
||||
> document-picker bridge (`native/GRPickerBridge.swift` + `GRBootstrap.m`)
|
||||
> that `patch_love_src.py` wires into the LÖVE tree on every build:
|
||||
>
|
||||
> - `love.system.pickFile("rom"|"mod"|"sav")` and `love.system.createFile`
|
||||
> are exposed to Lua on iOS (same contract as love-android's SAF picker:
|
||||
> picks land in the save dir as `picked_rom.gb` / `picked_mod.zip` /
|
||||
> `picked_save.sav`; exports signal via `export_done.flag`).
|
||||
> - The Info.plist overlay enables `UIFileSharingEnabled` +
|
||||
> `LSSupportsOpeningDocumentsInPlace`, and `GRBootstrap.m` sweeps
|
||||
> `.gb/.gbc/.zip/.sav` files dropped in Documents (Files app / Finder)
|
||||
> into the LÖVE save dir on every activation — drop a ROM, open the app,
|
||||
> and it imports with no taps.
|
||||
> - `src/import/RomImporter.lua` treats iOS as a mobile platform and polls
|
||||
> for picker results (iOS pickers are in-process modals, so Android's
|
||||
> refocus rescan never fires).
|
||||
>
|
||||
> The note below about a missing "UIDocumentPicker handoff" is
|
||||
> resolved by this bridge.
|
||||
|
||||
macOS + Xcode only. Pins the official **LÖVE 11.5** iOS Xcode tree
|
||||
(`love-11.5-ios-source.zip` from [love2d/love releases](https://github.com/love2d/love/releases/tag/11.5)),
|
||||
matching `conf.lua`'s `t.version = "11.5"`.
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
// gen1recomp iOS native bridge bootstrap.
|
||||
//
|
||||
// Runs the Files-app inbox sweep (GRPickerBridge.sweepInbox) every time the
|
||||
// app becomes active, so ROMs/mods/saves dropped into the app's Documents
|
||||
// folder land in the LÖVE save directory before the Lua importer rescans.
|
||||
// Registered from a constructor so no LÖVE/SDL source needs to know about it.
|
||||
|
||||
#import <UIKit/UIKit.h>
|
||||
|
||||
__attribute__((constructor))
|
||||
static void GRBootstrapInstall(void)
|
||||
{
|
||||
[[NSNotificationCenter defaultCenter]
|
||||
addObserverForName:UIApplicationDidBecomeActiveNotification
|
||||
object:nil
|
||||
queue:[NSOperationQueue mainQueue]
|
||||
usingBlock:^(NSNotification *note) {
|
||||
Class bridge = NSClassFromString(@"GRPickerBridge");
|
||||
if ([bridge respondsToSelector:@selector(sweepInbox)]) {
|
||||
[bridge performSelector:@selector(sweepInbox)];
|
||||
}
|
||||
}];
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
// gen1recomp iOS native bridge: Apple Health step sync (Pokéwalker mode).
|
||||
//
|
||||
// Reached from liblove's wrap_System.cpp through the Objective-C runtime as
|
||||
// love.system.syncHealthSteps() — see mobile/ios/patch_love_src.py. The Lua
|
||||
// side (mods/pokewalker) opts in, calls sync, and later consumes
|
||||
// steps_pending.json from the LÖVE save directory:
|
||||
//
|
||||
// { "steps": 4312, "from": "<ISO8601>", "to": "<ISO8601>" }
|
||||
//
|
||||
// Steps are counted from a persisted anchor (last successful sync; first
|
||||
// run starts at midnight today) so the same walk is never credited twice.
|
||||
// An unconsumed pending file is merged, not overwritten, so steps survive
|
||||
// the player quitting before entering the overworld.
|
||||
|
||||
import Foundation
|
||||
import HealthKit
|
||||
|
||||
@objc(GRHealthBridge)
|
||||
public final class GRHealthBridge: NSObject {
|
||||
|
||||
private static let store = HKHealthStore()
|
||||
private static let anchorKey = "GRHealthLastSyncDate"
|
||||
private static let pendingName = "steps_pending.json"
|
||||
// Sanity clamp per sync: guards against absurd backlogs (device clock
|
||||
// changes, months-old anchors) turning into instant level-100 parties.
|
||||
private static let maxStepsPerSync = 50_000
|
||||
|
||||
/// love.system.syncHealthSteps() -> bool ("sync started").
|
||||
/// Requests read authorization on first use (system sheet appears over
|
||||
/// the game), then asynchronously writes the pending-steps file.
|
||||
@objc(syncStepsWithCommand:saveDir:)
|
||||
public static func syncSteps(command: UnsafePointer<CChar>?,
|
||||
saveDir: UnsafePointer<CChar>?) -> Bool {
|
||||
guard HKHealthStore.isHealthDataAvailable(),
|
||||
let stepType = HKQuantityType.quantityType(forIdentifier: .stepCount)
|
||||
else { return false }
|
||||
let dir = saveDir.map { String(cString: $0) } ?? ""
|
||||
guard !dir.isEmpty else { return false }
|
||||
|
||||
store.requestAuthorization(toShare: nil, read: [stepType]) { _, error in
|
||||
if let error = error {
|
||||
NSLog("GRHealthBridge: authorization error: \(error)")
|
||||
return
|
||||
}
|
||||
// Read authorization is intentionally opaque (a denied query
|
||||
// just returns no samples), so always run the query.
|
||||
runQuery(stepType: stepType, dir: dir)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// MARK: - Query
|
||||
|
||||
private static func runQuery(stepType: HKQuantityType, dir: String) {
|
||||
let defaults = UserDefaults.standard
|
||||
let now = Date()
|
||||
let anchor = (defaults.object(forKey: anchorKey) as? Date)
|
||||
?? Calendar.current.startOfDay(for: now)
|
||||
guard anchor < now else { return }
|
||||
|
||||
let predicate = HKQuery.predicateForSamples(withStart: anchor, end: now,
|
||||
options: .strictStartDate)
|
||||
let query = HKStatisticsQuery(quantityType: stepType,
|
||||
quantitySamplePredicate: predicate,
|
||||
options: .cumulativeSum) { _, stats, error in
|
||||
if let error = error {
|
||||
// Typical on first run before the user answers the sheet,
|
||||
// or when access is denied; the anchor is left untouched so
|
||||
// the next sync retries the same window.
|
||||
NSLog("GRHealthBridge: step query error: \(error)")
|
||||
return
|
||||
}
|
||||
let steps = Int(stats?.sumQuantity()?.doubleValue(for: .count()) ?? 0)
|
||||
defaults.set(now, forKey: anchorKey)
|
||||
deliver(steps: min(steps, maxStepsPerSync),
|
||||
from: anchor, to: now, dir: dir)
|
||||
}
|
||||
store.execute(query)
|
||||
}
|
||||
|
||||
// MARK: - Pending file
|
||||
|
||||
private static func deliver(steps: Int, from: Date, to: Date, dir: String) {
|
||||
let url = URL(fileURLWithPath: dir).appendingPathComponent(pendingName)
|
||||
|
||||
// Merge with an unconsumed earlier delivery so steps are never lost.
|
||||
var total = steps
|
||||
var fromDate = from
|
||||
if let data = try? Data(contentsOf: url),
|
||||
let old = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] {
|
||||
total += (old["steps"] as? Int) ?? 0
|
||||
if let oldFrom = old["from"] as? String,
|
||||
let parsed = isoFormatter.date(from: oldFrom), parsed < fromDate {
|
||||
fromDate = parsed
|
||||
}
|
||||
}
|
||||
guard total > 0 else { return }
|
||||
|
||||
let payload: [String: Any] = [
|
||||
"steps": total,
|
||||
"from": isoFormatter.string(from: fromDate),
|
||||
"to": isoFormatter.string(from: to),
|
||||
]
|
||||
do {
|
||||
try FileManager.default.createDirectory(
|
||||
at: URL(fileURLWithPath: dir, isDirectory: true),
|
||||
withIntermediateDirectories: true)
|
||||
let data = try JSONSerialization.data(withJSONObject: payload)
|
||||
try data.write(to: url, options: .atomic)
|
||||
NSLog("GRHealthBridge: %d steps pending", total)
|
||||
} catch {
|
||||
NSLog("GRHealthBridge: could not write pending steps: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
private static let isoFormatter: ISO8601DateFormatter = {
|
||||
let f = ISO8601DateFormatter()
|
||||
f.formatOptions = [.withInternetDateTime]
|
||||
return f
|
||||
}()
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
// gen1recomp iOS native bridge: document picker + Files-app inbox sweep.
|
||||
//
|
||||
// liblove's wrap_System.cpp calls into this class through the Objective-C
|
||||
// runtime (objc_getClass / objc_msgSend), so nothing here may be renamed
|
||||
// without updating that patch (see mobile/ios/patch_love_src.py).
|
||||
//
|
||||
// Contract (mirrors love-android's GameActivity.showFilePicker):
|
||||
// love.system.pickFile("rom"|"mod"|"sav") -> copies the user's pick into
|
||||
// the LÖVE save directory as picked_rom.gb / picked_mod.zip /
|
||||
// picked_save.sav; RomImporter's pending-file scan consumes it.
|
||||
// love.system.createFile(name) -> exports save dir's pending_export.sav
|
||||
// through the system picker, then writes export_done.flag.
|
||||
|
||||
import UIKit
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
@objc(GRPickerBridge)
|
||||
public final class GRPickerBridge: NSObject {
|
||||
|
||||
// Every live picker keeps its own delegate here
|
||||
// (UIDocumentPickerViewController holds its delegate weakly). A single
|
||||
// "current delegate" slot would break under double activation: the
|
||||
// engine's touch handling can fire a button twice (touch + synthesized
|
||||
// mouse), the second present would replace the first picker's delegate,
|
||||
// and the sheet the user actually sees would then pick into nil —
|
||||
// silently doing nothing.
|
||||
private static var liveDelegates: [PickerDelegate] = []
|
||||
|
||||
// conf.lua t.identity — where LÖVE puts the fused save directory on iOS
|
||||
// (<sandbox>/Library/Application Support/<identity>).
|
||||
private static let loveIdentity = "pokemon-love2d"
|
||||
|
||||
// MARK: - Entry points called from liblove (C strings on purpose)
|
||||
|
||||
@objc(presentPickerWithKind:saveDir:)
|
||||
public static func presentPicker(kind: UnsafePointer<CChar>?,
|
||||
saveDir: UnsafePointer<CChar>?) -> Bool {
|
||||
let kindStr = kind.map { String(cString: $0) } ?? "rom"
|
||||
guard let dir = resolvedSaveDir(saveDir) else { return false }
|
||||
|
||||
let destName: String
|
||||
var types: [UTType] = []
|
||||
switch kindStr {
|
||||
case "mod":
|
||||
destName = "picked_mod.zip"
|
||||
types = [.zip]
|
||||
case "sav":
|
||||
destName = "picked_save.sav"
|
||||
default:
|
||||
destName = "picked_rom.gb"
|
||||
for ext in ["gb", "gbc"] {
|
||||
if let t = UTType(filenameExtension: ext) { types.append(t) }
|
||||
}
|
||||
}
|
||||
// .gb/.gbc/.sav resolve to dynamic UTTypes on most devices; offering
|
||||
// .data as well keeps every real file selectable. The importer
|
||||
// validates by size + SHA-1, so a wrong pick is rejected safely.
|
||||
types.append(.data)
|
||||
if !types.contains(.item) { types.append(.item) }
|
||||
|
||||
let picker = UIDocumentPickerViewController(forOpeningContentTypes: types,
|
||||
asCopy: true)
|
||||
picker.allowsMultipleSelection = false
|
||||
let delegate = PickerDelegate { urls in
|
||||
guard let src = urls.first else { return }
|
||||
copyItem(at: src, into: dir, named: destName)
|
||||
}
|
||||
return present(picker, with: delegate)
|
||||
}
|
||||
|
||||
@objc(presentExportWithName:saveDir:)
|
||||
public static func presentExport(name: UnsafePointer<CChar>?,
|
||||
saveDir: UnsafePointer<CChar>?) -> Bool {
|
||||
let suggested = name.map { String(cString: $0) } ?? "export.sav"
|
||||
guard let dir = resolvedSaveDir(saveDir) else { return false }
|
||||
let staged = dir.appendingPathComponent("pending_export.sav")
|
||||
guard FileManager.default.fileExists(atPath: staged.path) else { return false }
|
||||
|
||||
// Stage under the suggested name so the picker's filename field is
|
||||
// prefilled; forExporting moves/copies it to the user's destination.
|
||||
let tmp = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(suggested)
|
||||
try? FileManager.default.removeItem(at: tmp)
|
||||
do {
|
||||
try FileManager.default.copyItem(at: staged, to: tmp)
|
||||
} catch {
|
||||
NSLog("GRPickerBridge: staging export failed: \(error)")
|
||||
return false
|
||||
}
|
||||
|
||||
let picker = UIDocumentPickerViewController(forExporting: [tmp], asCopy: true)
|
||||
let delegate = PickerDelegate { urls in
|
||||
guard !urls.isEmpty else { return }
|
||||
// Same completion signal love-android's GameActivity writes;
|
||||
// RomImporter:focus consumes it and clears pending_export.sav.
|
||||
let flag = dir.appendingPathComponent("export_done.flag")
|
||||
try? "ok".data(using: .utf8)?.write(to: flag)
|
||||
}
|
||||
return present(picker, with: delegate)
|
||||
}
|
||||
|
||||
// Moves ROM/mod/save files the user dropped into the app's Documents
|
||||
// folder (Files app / Finder file sharing) into the LÖVE save directory,
|
||||
// where the importer's pending-file scan looks. Called on every
|
||||
// UIApplicationDidBecomeActive (see GRBootstrap.m).
|
||||
@objc public static func sweepInbox() {
|
||||
let fm = FileManager.default
|
||||
guard let docs = fm.urls(for: .documentDirectory, in: .userDomainMask).first,
|
||||
let appSupport = fm.urls(for: .applicationSupportDirectory,
|
||||
in: .userDomainMask).first else { return }
|
||||
let saveDir = appSupport.appendingPathComponent(loveIdentity, isDirectory: true)
|
||||
let wanted: Set<String> = ["gb", "gbc", "zip", "sav"]
|
||||
guard let items = try? fm.contentsOfDirectory(at: docs,
|
||||
includingPropertiesForKeys: nil) else { return }
|
||||
for url in items where wanted.contains(url.pathExtension.lowercased()) {
|
||||
ensureDirectory(saveDir)
|
||||
let dest = saveDir.appendingPathComponent(url.lastPathComponent)
|
||||
try? fm.removeItem(at: dest)
|
||||
do {
|
||||
try fm.moveItem(at: url, to: dest)
|
||||
NSLog("GRPickerBridge: swept %@ into save dir", url.lastPathComponent)
|
||||
} catch {
|
||||
NSLog("GRPickerBridge: sweep failed for \(url.lastPathComponent): \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private static func resolvedSaveDir(_ cstr: UnsafePointer<CChar>?) -> URL? {
|
||||
var dir = cstr.map { String(cString: $0) } ?? ""
|
||||
if dir.isEmpty {
|
||||
guard let appSupport = FileManager.default
|
||||
.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
|
||||
else { return nil }
|
||||
dir = appSupport.appendingPathComponent(loveIdentity).path
|
||||
}
|
||||
let url = URL(fileURLWithPath: dir, isDirectory: true)
|
||||
ensureDirectory(url)
|
||||
return url
|
||||
}
|
||||
|
||||
private static func ensureDirectory(_ url: URL) {
|
||||
try? FileManager.default.createDirectory(at: url,
|
||||
withIntermediateDirectories: true)
|
||||
}
|
||||
|
||||
private static func copyItem(at src: URL, into dir: URL, named name: String) {
|
||||
let scoped = src.startAccessingSecurityScopedResource()
|
||||
defer { if scoped { src.stopAccessingSecurityScopedResource() } }
|
||||
ensureDirectory(dir)
|
||||
let dest = dir.appendingPathComponent(name)
|
||||
try? FileManager.default.removeItem(at: dest)
|
||||
do {
|
||||
try FileManager.default.copyItem(at: src, to: dest)
|
||||
NSLog("GRPickerBridge: delivered %@", name)
|
||||
} catch {
|
||||
NSLog("GRPickerBridge: copy failed: \(error)")
|
||||
// Surface the failure in-game: the Lua pick poll turns this
|
||||
// into an on-screen notice instead of a silent no-op.
|
||||
let report = "Could not copy \(src.lastPathComponent): " +
|
||||
error.localizedDescription
|
||||
try? report.data(using: .utf8)?
|
||||
.write(to: dir.appendingPathComponent("pick_error.txt"))
|
||||
}
|
||||
}
|
||||
|
||||
private static func present(_ picker: UIDocumentPickerViewController,
|
||||
with delegate: PickerDelegate) -> Bool {
|
||||
let doPresent = { () -> Bool in
|
||||
// Double activation (touch + synthesized mouse) arrives within
|
||||
// one frame — before UIKit even exposes the first sheet via
|
||||
// presentedViewController — so gate on our own delegate list.
|
||||
// Without this the stacked present makes the picker auto-dismiss
|
||||
// with zero documents (observed as didPickDocumentsAt 0 urls)
|
||||
// and the user's pick silently does nothing.
|
||||
guard liveDelegates.isEmpty else {
|
||||
NSLog("GRPickerBridge: picker already active; ignoring re-present")
|
||||
return true
|
||||
}
|
||||
guard var top = UIApplication.shared.windows
|
||||
.first(where: { $0.isKeyWindow })?.rootViewController
|
||||
else { return false }
|
||||
while let presented = top.presentedViewController { top = presented }
|
||||
picker.delegate = delegate
|
||||
liveDelegates.append(delegate)
|
||||
delegate.onFinish = { [weak delegate] in
|
||||
liveDelegates.removeAll { $0 === delegate }
|
||||
}
|
||||
top.present(picker, animated: true)
|
||||
return true
|
||||
}
|
||||
if Thread.isMainThread { return doPresent() }
|
||||
var ok = false
|
||||
DispatchQueue.main.sync { ok = doPresent() }
|
||||
return ok
|
||||
}
|
||||
}
|
||||
|
||||
private final class PickerDelegate: NSObject, UIDocumentPickerDelegate {
|
||||
private let onPick: ([URL]) -> Void
|
||||
var onFinish: (() -> Void)?
|
||||
init(onPick: @escaping ([URL]) -> Void) { self.onPick = onPick }
|
||||
|
||||
func documentPicker(_ controller: UIDocumentPickerViewController,
|
||||
didPickDocumentsAt urls: [URL]) {
|
||||
NSLog("GRPickerBridge: didPickDocumentsAt %d url(s)", urls.count)
|
||||
onPick(urls)
|
||||
onFinish?()
|
||||
}
|
||||
|
||||
func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) {
|
||||
// No file was written; the Lua side's pending-file poll simply
|
||||
// never finds anything.
|
||||
NSLog("GRPickerBridge: picker cancelled")
|
||||
onFinish?()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<!-- gen1recomp iOS: read-only step counts for the Pokéwalker mod
|
||||
(GRHealthBridge.swift / mods/pokewalker). -->
|
||||
<key>com.apple.developer.healthkit</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -100,5 +100,16 @@
|
||||
</dict>
|
||||
</dict>
|
||||
</array>
|
||||
<!-- gen1recomp iOS: expose the app's Documents folder in the Files app /
|
||||
Finder so ROMs, mod .zips, and .sav files can be dropped in without
|
||||
the picker; GRBootstrap sweeps them into the LÖVE save dir. -->
|
||||
<key>UIFileSharingEnabled</key>
|
||||
<true/>
|
||||
<key>LSSupportsOpeningDocumentsInPlace</key>
|
||||
<true/>
|
||||
<!-- gen1recomp iOS: Pokéwalker mod reads step counts (opt-in, in the
|
||||
in-game mod manager) and converts them to Pokémon EXP. -->
|
||||
<key>NSHealthShareUsageDescription</key>
|
||||
<string>The Pokéwalker mod reads your step count to give your Pokémon party experience points for real-world walking.</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Applies gen1recomp's iOS native-bridge patches to the fetched LÖVE 11.5
|
||||
source tree (mobile/ios/love-src/). Idempotent AND re-appliable: the first
|
||||
run stashes a pristine `.orig` copy of every file it rewrites, and later
|
||||
runs always start over from that copy — so editing the patch content here
|
||||
just works on the next build, no manual restore needed.
|
||||
|
||||
What it does:
|
||||
1. Copies mobile/ios/native/ (GRPickerBridge.swift, GRHealthBridge.swift,
|
||||
GRBootstrap.m) and the HealthKit entitlements into the LÖVE tree.
|
||||
2. Patches liblove's wrap_System.cpp to expose love.system.pickFile,
|
||||
love.system.createFile, and love.system.syncHealthSteps on iOS (each
|
||||
calls a GR*Bridge Swift class through the Objective-C runtime, so
|
||||
liblove never links against Swift directly).
|
||||
3. Patches love.xcodeproj so the love-ios app target compiles the native
|
||||
files (Swift 5, iOS 14 deployment for UTType/forExporting APIs).
|
||||
"""
|
||||
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
IOS_DIR = Path(__file__).resolve().parent
|
||||
LOVE_SRC = IOS_DIR / "love-src"
|
||||
NATIVE_SRC = IOS_DIR / "native"
|
||||
NATIVE_DST = LOVE_SRC / "platform" / "xcode" / "ios" / "native"
|
||||
WRAP_SYSTEM = LOVE_SRC / "src" / "modules" / "system" / "wrap_System.cpp"
|
||||
PBXPROJ = LOVE_SRC / "platform" / "xcode" / "love.xcodeproj" / "project.pbxproj"
|
||||
ENTITLEMENTS_SRC = IOS_DIR / "overlays" / "love-ios.entitlements"
|
||||
|
||||
NATIVE_FILES = ("GRPickerBridge.swift", "GRHealthBridge.swift", "GRBootstrap.m")
|
||||
|
||||
MARKER = "gen1recomp iOS picker bridge"
|
||||
|
||||
# Headers must land outside `namespace love { namespace system {`.
|
||||
WRAP_INCLUDES = """
|
||||
// %s: headers for the native-bridge functions below.
|
||||
#ifdef LOVE_IOS
|
||||
#include <objc/runtime.h>
|
||||
#include <objc/message.h>
|
||||
#include "filesystem/Filesystem.h"
|
||||
#endif
|
||||
""" % MARKER
|
||||
|
||||
WRAP_FUNCS = """
|
||||
// --- %s -------------------------------------------------
|
||||
// love.system.pickFile / createFile / syncHealthSteps for iOS. pickFile and
|
||||
// createFile mirror the love-android extension this project's importer
|
||||
// already targets; syncHealthSteps feeds the Pokéwalker mod. Implemented in
|
||||
// Swift (GR*Bridge classes, love-ios app target); reached via the ObjC
|
||||
// runtime so liblove itself needs no Swift interop.
|
||||
#ifdef LOVE_IOS
|
||||
static const char *gr_saveDirectory()
|
||||
{
|
||||
auto fs = Module::getInstance<love::filesystem::Filesystem>(Module::M_FILESYSTEM);
|
||||
return fs != nullptr ? fs->getSaveDirectory() : "";
|
||||
}
|
||||
|
||||
static int gr_callBridge(lua_State *L, const char *className,
|
||||
const char *selector, const char *arg)
|
||||
{
|
||||
Class cls = objc_getClass(className);
|
||||
if (cls == nullptr)
|
||||
{
|
||||
lua_pushboolean(L, 0);
|
||||
return 1;
|
||||
}
|
||||
typedef signed char (*GRMsg)(Class, SEL, const char *, const char *);
|
||||
signed char ok = ((GRMsg)objc_msgSend)(cls, sel_registerName(selector),
|
||||
arg, gr_saveDirectory());
|
||||
lua_pushboolean(L, ok != 0);
|
||||
return 1;
|
||||
}
|
||||
|
||||
int w_pickFile(lua_State *L)
|
||||
{
|
||||
const char *kind = luaL_optstring(L, 1, "rom");
|
||||
return gr_callBridge(L, "GRPickerBridge", "presentPickerWithKind:saveDir:", kind);
|
||||
}
|
||||
|
||||
int w_createFile(lua_State *L)
|
||||
{
|
||||
const char *name = luaL_optstring(L, 1, "export.sav");
|
||||
return gr_callBridge(L, "GRPickerBridge", "presentExportWithName:saveDir:", name);
|
||||
}
|
||||
|
||||
int w_syncHealthSteps(lua_State *L)
|
||||
{
|
||||
return gr_callBridge(L, "GRHealthBridge", "syncStepsWithCommand:saveDir:", "sync");
|
||||
}
|
||||
#endif // LOVE_IOS
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
""" % MARKER
|
||||
|
||||
WRAP_REGISTRATION = """#ifdef LOVE_IOS
|
||||
{ "pickFile", w_pickFile },
|
||||
{ "createFile", w_createFile },
|
||||
{ "syncHealthSteps", w_syncHealthSteps },
|
||||
#endif
|
||||
"""
|
||||
|
||||
# Deterministic 24-hex-digit object IDs, chosen not to collide with the
|
||||
# upstream project (grep-verified against love-11.5's pbxproj).
|
||||
ID_FILE_PICKER = "6E1AC0DE0001000000000001"
|
||||
ID_FILE_OBJC = "6E1AC0DE0001000000000002"
|
||||
ID_FILE_HEALTH = "6E1AC0DE0001000000000003"
|
||||
ID_BUILD_PICKER = "6E1AC0DE0002000000000001"
|
||||
ID_BUILD_OBJC = "6E1AC0DE0002000000000002"
|
||||
ID_BUILD_HEALTH = "6E1AC0DE0002000000000003"
|
||||
SOURCES_PHASE_ID = "FA0B7F021A95AAF3000E1D17" # love-ios Sources phase
|
||||
IOS_APP_CONFIG_IDS = (
|
||||
"FA0B7F261A95AAF4000E1D17", # Debug
|
||||
"FA0B7F271A95AAF4000E1D17", # Release
|
||||
"FA0B7F281A95AAF4000E1D17", # Distribution
|
||||
)
|
||||
|
||||
PBX_SOURCES = (
|
||||
("GRPickerBridge.swift", ID_FILE_PICKER, ID_BUILD_PICKER, "sourcecode.swift"),
|
||||
("GRHealthBridge.swift", ID_FILE_HEALTH, ID_BUILD_HEALTH, "sourcecode.swift"),
|
||||
("GRBootstrap.m", ID_FILE_OBJC, ID_BUILD_OBJC, "sourcecode.c.objc"),
|
||||
)
|
||||
|
||||
|
||||
def fail(msg):
|
||||
print(f"patch_love_src: error: {msg}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def pristine(path: Path) -> str:
|
||||
"""Text of `path` before any of our patching: backed by a `.orig` stash.
|
||||
|
||||
The stash is only trusted if it is itself unpatched; that protects
|
||||
against a stash accidentally taken after an earlier patch run.
|
||||
"""
|
||||
orig = path.with_suffix(path.suffix + ".orig")
|
||||
if orig.is_file():
|
||||
text = orig.read_text()
|
||||
if MARKER not in text and ID_FILE_PICKER not in text:
|
||||
return text
|
||||
text = path.read_text()
|
||||
if MARKER in text or ID_FILE_PICKER in text:
|
||||
fail(f"{path} is already patched and no pristine .orig stash exists;\n"
|
||||
f" delete {LOVE_SRC} and re-run scripts/build_ios.sh --fetch")
|
||||
orig.write_text(text)
|
||||
return text
|
||||
|
||||
|
||||
def copy_native_files():
|
||||
NATIVE_DST.mkdir(parents=True, exist_ok=True)
|
||||
for name in NATIVE_FILES:
|
||||
src = NATIVE_SRC / name
|
||||
if not src.is_file():
|
||||
fail(f"missing {src}")
|
||||
shutil.copy2(src, NATIVE_DST / name)
|
||||
if not ENTITLEMENTS_SRC.is_file():
|
||||
fail(f"missing {ENTITLEMENTS_SRC}")
|
||||
shutil.copy2(ENTITLEMENTS_SRC, NATIVE_DST / "love-ios.entitlements")
|
||||
print(f"patch_love_src: native files -> {NATIVE_DST}")
|
||||
|
||||
|
||||
def patch_wrap_system():
|
||||
text = pristine(WRAP_SYSTEM)
|
||||
include_anchor = '#include "sdl/System.h"\n'
|
||||
if include_anchor not in text:
|
||||
fail(f"include anchor not found in {WRAP_SYSTEM}")
|
||||
text = text.replace(include_anchor, include_anchor + WRAP_INCLUDES, 1)
|
||||
anchor = "static const luaL_Reg functions[] ="
|
||||
if anchor not in text:
|
||||
fail(f"anchor not found in {WRAP_SYSTEM}")
|
||||
text = text.replace(anchor, WRAP_FUNCS + anchor, 1)
|
||||
reg_anchor = '\t{ "vibrate", w_vibrate },\n'
|
||||
if reg_anchor not in text:
|
||||
fail(f"registration anchor not found in {WRAP_SYSTEM}")
|
||||
text = text.replace(reg_anchor, reg_anchor + WRAP_REGISTRATION, 1)
|
||||
WRAP_SYSTEM.write_text(text)
|
||||
print("patch_love_src: wrap_System.cpp patched "
|
||||
"(pickFile/createFile/syncHealthSteps)")
|
||||
|
||||
|
||||
def patch_pbxproj():
|
||||
text = pristine(PBXPROJ)
|
||||
|
||||
build_files = "".join(
|
||||
f"\t\t{build_id} /* {name} in Sources */ = "
|
||||
f"{{isa = PBXBuildFile; fileRef = {file_id} /* {name} */; }};\n"
|
||||
for name, file_id, build_id, _ in PBX_SOURCES
|
||||
)
|
||||
anchor = "/* Begin PBXBuildFile section */\n"
|
||||
if anchor not in text:
|
||||
fail("PBXBuildFile section not found")
|
||||
text = text.replace(anchor, anchor + build_files, 1)
|
||||
|
||||
file_refs = "".join(
|
||||
f"\t\t{file_id} /* {name} */ = "
|
||||
f"{{isa = PBXFileReference; lastKnownFileType = {ftype}; "
|
||||
f"name = {name}; path = ios/native/{name}; "
|
||||
f"sourceTree = SOURCE_ROOT; }};\n"
|
||||
for name, file_id, _, ftype in PBX_SOURCES
|
||||
)
|
||||
anchor = "/* Begin PBXFileReference section */\n"
|
||||
if anchor not in text:
|
||||
fail("PBXFileReference section not found")
|
||||
text = text.replace(anchor, anchor + file_refs, 1)
|
||||
|
||||
# Add the files to the love-ios Sources phase.
|
||||
phase_re = re.compile(
|
||||
re.escape(SOURCES_PHASE_ID)
|
||||
+ r" /\* Sources \*/ = \{.*?files = \(\n", re.S)
|
||||
m = phase_re.search(text)
|
||||
if not m:
|
||||
fail("love-ios Sources phase not found")
|
||||
insertion = "".join(
|
||||
f"\t\t\t\t{build_id} /* {name} in Sources */,\n"
|
||||
for name, _, build_id, _ in PBX_SOURCES
|
||||
)
|
||||
text = text[: m.end()] + insertion + text[m.end():]
|
||||
|
||||
# Swift + modern deployment target + HealthKit entitlements on the
|
||||
# love-ios app target only (UTType and forExporting need iOS 14;
|
||||
# liblove stays as upstream).
|
||||
for config_id in IOS_APP_CONFIG_IDS:
|
||||
cfg_re = re.compile(
|
||||
re.escape(config_id) + r" /\* \w+ \*/ = \{.*?buildSettings = \{\n",
|
||||
re.S)
|
||||
m = cfg_re.search(text)
|
||||
if not m:
|
||||
fail(f"build configuration {config_id} not found")
|
||||
settings = (
|
||||
"\t\t\t\tSWIFT_VERSION = 5.0;\n"
|
||||
"\t\t\t\tIPHONEOS_DEPLOYMENT_TARGET = 14.0;\n"
|
||||
'\t\t\t\tCODE_SIGN_ENTITLEMENTS = "ios/native/love-ios.entitlements";\n'
|
||||
)
|
||||
text = text[: m.end()] + settings + text[m.end():]
|
||||
|
||||
PBXPROJ.write_text(text)
|
||||
print("patch_love_src: love.xcodeproj patched (native sources + Swift + entitlements)")
|
||||
|
||||
|
||||
def main():
|
||||
if not LOVE_SRC.is_dir():
|
||||
fail("love-src/ missing; run scripts/build_ios.sh --fetch first")
|
||||
copy_native_files()
|
||||
patch_wrap_system()
|
||||
patch_pbxproj()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,17 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this mod are documented here. Format follows
|
||||
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
|
||||
## [1.0.0] - 2026-07-30
|
||||
|
||||
### Added
|
||||
|
||||
- Opt-in SYNC STEPS option: Apple Health step counts (delivered by the iOS
|
||||
build's native bridge as `steps_pending.json`) convert to EXP.
|
||||
- STEPS PER EXP option (10 / 20 / 50, default 20).
|
||||
- GIVE EXP TO option: lead mon (default) or whole party split.
|
||||
- Level-ups applied with the engine's growth curves and rare-candy stat
|
||||
math; walk-report textbox at quiet moments.
|
||||
- Guardrails: steps anchored to the last sync (never credited twice),
|
||||
50,000-step clamp per sync, engine `levelCap` respected.
|
||||
@@ -0,0 +1,92 @@
|
||||
# Pokéwalker (Apple Health) — a Gen1Recomp mod
|
||||
|
||||
Your real-world steps become EXP for your Pokémon party — the HeartGold/
|
||||
SoulSilver Pokéwalker, except it's the iPhone already in your pocket.
|
||||
|
||||
A mod for [gen1recomp](https://github.com/bryanthaboi/gen1recomp)
|
||||
(the Gen 1 Recompilation Project). Opt-in, data-safe, and dormant on any
|
||||
platform that doesn't provide the native step source (see
|
||||
[Requirements](#requirements)).
|
||||
|
||||
## Install
|
||||
|
||||
Grab `pokewalker-<version>.modpkg` from
|
||||
[Releases](https://github.com/mresnick67/Gen1ReComp-Pokewalker/releases)
|
||||
(or use GitHub's *Code → Download ZIP* — the importer handles both), then:
|
||||
|
||||
- **In the launcher:** MODS tab → **Import mod .zip** → pick the file, or
|
||||
drag it onto the window on desktop.
|
||||
- **iOS:** you can also drop the zip into the app's folder in the Files
|
||||
app; it installs on next launch.
|
||||
|
||||
Then, in the **mod manager → POKEWALKER → options**, turn on **SYNC
|
||||
STEPS**. iOS asks for read-only access to your step count the first time.
|
||||
|
||||
## Options
|
||||
|
||||
| Option | Values | Default |
|
||||
|---|---|---|
|
||||
| SYNC STEPS | on / off | **off** |
|
||||
| STEPS PER EXP | 10 / 20 / 50 | 20 |
|
||||
| GIVE EXP TO | lead mon / whole party (split) | lead mon |
|
||||
|
||||
## Mechanics & guardrails
|
||||
|
||||
- EXP applies through the engine's own growth curves and rare-candy stat
|
||||
math, so levels, stats, and HP top-ups are exact.
|
||||
- Steps are anchored to the last sync — the same walk is never credited
|
||||
twice — and any single sync is clamped to 50,000 steps.
|
||||
- The engine `levelCap` constant is respected.
|
||||
- Credits land at quiet moments (save load, map change, battle end) with a
|
||||
walk-report textbox.
|
||||
|
||||
## Requirements
|
||||
|
||||
The Lua mod is platform-neutral, but it feeds on a **native step bridge**
|
||||
that currently ships in an iOS build of gen1recomp. Without the bridge the
|
||||
mod loads and stays dormant — safe to install anywhere.
|
||||
|
||||
### The bridge contract (for porters)
|
||||
|
||||
Any platform can light this mod up by providing:
|
||||
|
||||
- `love.system.syncHealthSteps()` → `boolean` — kick off an async step
|
||||
query (requesting OS permission on first use). On completion, write
|
||||
**`steps_pending.json`** to the LÖVE save directory:
|
||||
|
||||
```json
|
||||
{ "steps": 4312, "from": "2026-07-30T08:00:00Z", "to": "2026-07-30T17:00:00Z" }
|
||||
```
|
||||
|
||||
Count steps from a persisted anchor (last successful sync) so a walk is
|
||||
never delivered twice, and **merge** with an unconsumed pending file
|
||||
rather than overwriting it. The mod consumes and deletes the file.
|
||||
|
||||
The reference iOS implementation is a small Swift class (HealthKit
|
||||
`HKStatisticsQuery` over `stepCount`) exposed to Lua through a one-line
|
||||
`wrap_System.cpp` addition. Open an issue here if you're porting the
|
||||
bridge (Android: Health Connect / Google Fit would slot straight in).
|
||||
|
||||
## Known limitations (v1)
|
||||
|
||||
- Level-ups granted while walking don't prompt for new moves, and level
|
||||
evolutions wait for the next in-battle level — same behavior as
|
||||
over-leveling with rare candies.
|
||||
- Steps sync on launch/activation; no background delivery yet.
|
||||
|
||||
## Developing
|
||||
|
||||
From a gen1recomp checkout with this mod at `mods/pokewalker` and an
|
||||
imported data cache:
|
||||
|
||||
```sh
|
||||
luajit mods/pokewalker/tests/pokewalker_test.lua
|
||||
python3 tools/modkit.py validate mods/pokewalker --base imported
|
||||
python3 tools/modkit.py pack mods/pokewalker
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT — see [LICENSE](LICENSE). Not affiliated with Nintendo, Game Freak,
|
||||
or The Pokémon Company. This mod contains no ROM-derived content
|
||||
(`modkit lint` clean).
|
||||
@@ -0,0 +1,144 @@
|
||||
-- Pokéwalker: Apple Health steps become party EXP (iOS builds).
|
||||
--
|
||||
-- The Swift side (mobile/ios/native/GRHealthBridge.swift) owns HealthKit:
|
||||
-- love.system.syncHealthSteps() requests read access on first use, counts
|
||||
-- steps since the last sync anchor, and drops steps_pending.json in the
|
||||
-- save dir. This mod consumes that file at quiet moments (save loaded, map
|
||||
-- transitions, battle end), converts steps to EXP, and applies level-ups
|
||||
-- with the same stat math the engine uses.
|
||||
--
|
||||
-- Opt-in: everything is inert until SYNC STEPS is enabled in this mod's
|
||||
-- options (the HealthKit permission sheet appears on first enable). On
|
||||
-- non-iOS platforms love.system.syncHealthSteps does not exist and the mod
|
||||
-- stays dormant.
|
||||
--
|
||||
-- Known v1 limits (documented in README.md): level-ups applied here do not
|
||||
-- prompt for new moves (like over-leveling past a learnset entry with rare
|
||||
-- candies) and do not trigger level evolutions until the next battle candy
|
||||
-- or level gained in battle.
|
||||
|
||||
local PENDING = "steps_pending.json"
|
||||
|
||||
return function(mod)
|
||||
mod.options:define({
|
||||
{ key = "enabled", label = "SYNC STEPS", type = "toggle", default = false },
|
||||
{ key = "rate", label = "STEPS PER EXP", type = "choice", default = "20",
|
||||
choices = { { "10", "10" }, { "20", "20" }, { "50", "50" } } },
|
||||
{ key = "target", label = "GIVE EXP TO", type = "choice", default = "lead",
|
||||
choices = { { "LEAD MON", "lead" }, { "WHOLE PARTY", "party" } } },
|
||||
})
|
||||
|
||||
local Json = require("src.link.Json")
|
||||
local Growth = require("src.pokemon.Growth")
|
||||
local Stats = require("src.pokemon.Stats")
|
||||
local game
|
||||
|
||||
local function active()
|
||||
return love.system.syncHealthSteps ~= nil and mod.options:get("enabled")
|
||||
end
|
||||
|
||||
-- Ask the native side to refresh steps_pending.json. Async: results are
|
||||
-- picked up by a later consume() (next map change / battle end).
|
||||
local function requestSync()
|
||||
if active() then love.system.syncHealthSteps() end
|
||||
end
|
||||
|
||||
-- Add EXP to one mon, bumping levels with the engine's own stat math
|
||||
-- (mirrors the rare-candy path in src/inventory/ItemEffects.lua).
|
||||
-- Returns the EXP actually absorbed and any levels gained.
|
||||
local function applyToMon(mon, xp, data)
|
||||
local def = data.pokemon[mon.species]
|
||||
if not def or not mon.level then return 0, {} end
|
||||
local cap = (data.constants and data.constants.levelCap) or 100
|
||||
if mon.level >= cap then return 0, {} end
|
||||
local maxExp = Growth.expForLevel(def.growthRate, cap, data.growth_rates)
|
||||
local before = mon.exp or 0
|
||||
mon.exp = math.min(maxExp, before + xp)
|
||||
local absorbed = mon.exp - before
|
||||
if absorbed <= 0 then return 0, {} end
|
||||
local levels = {}
|
||||
local newLevel = Growth.levelForExp(def.growthRate, mon.exp, cap,
|
||||
data.growth_rates)
|
||||
while mon.level < newLevel do
|
||||
mon.level = mon.level + 1
|
||||
local old = mon.stats
|
||||
mon.stats = Stats.calc(def, mon.level, mon.dvs, mon.statExp)
|
||||
mon.hp = math.min(mon.stats.hp,
|
||||
(mon.hp or 0) + (mon.stats.hp - (old and old.hp or 0)))
|
||||
levels[#levels + 1] = mon.level
|
||||
end
|
||||
return absorbed, levels
|
||||
end
|
||||
|
||||
-- A short walk-report textbox, shown only when the overworld is idle;
|
||||
-- when a script is already running the report is silently skipped (the
|
||||
-- EXP is applied regardless, and the log has the numbers).
|
||||
local function report(steps, total, leveled)
|
||||
local msg = ("You walked %d steps!\nYour party gained %d EXP."):format(steps, total)
|
||||
if #leveled > 0 then
|
||||
msg = msg .. ("\n%s grew to L%d!"):format(leveled[1].name, leveled[1].level)
|
||||
end
|
||||
-- The report is decoration: it must never break the credit. mod.world
|
||||
-- materializes lazily (and can itself error in headless contexts), so
|
||||
-- the access lives inside the pcall too.
|
||||
pcall(function()
|
||||
local world = mod.world
|
||||
if world then world:queueScript({ { "show_text", msg } }) end
|
||||
end)
|
||||
end
|
||||
|
||||
local function consume()
|
||||
if not (game and active()) then return end
|
||||
local raw = love.filesystem.read(PENDING)
|
||||
if not raw then return end
|
||||
local decoded = Json.decode(raw)
|
||||
local steps = decoded and tonumber(decoded.steps) or 0
|
||||
love.filesystem.remove(PENDING)
|
||||
if steps <= 0 then return end
|
||||
|
||||
local party = game.save and game.save.party
|
||||
if not party or #party == 0 then return end
|
||||
local rate = tonumber(mod.options:get("rate")) or 20
|
||||
local xp = math.floor(steps / rate)
|
||||
if xp <= 0 then return end
|
||||
|
||||
local total, leveled = 0, {}
|
||||
local targets = {}
|
||||
if mod.options:get("target") == "party" then
|
||||
for _, mon in ipairs(party) do targets[#targets + 1] = mon end
|
||||
else
|
||||
targets[1] = party[1]
|
||||
end
|
||||
local share = math.max(1, math.floor(xp / #targets))
|
||||
for _, mon in ipairs(targets) do
|
||||
local absorbed, levels = applyToMon(mon, share, game.data)
|
||||
total = total + absorbed
|
||||
for _, level in ipairs(levels) do
|
||||
leveled[#leveled + 1] =
|
||||
{ name = mon.nickname or mon.species, level = level }
|
||||
end
|
||||
end
|
||||
if total <= 0 then return end
|
||||
mod.log:info("credited %d steps -> %d EXP (%d level-ups)",
|
||||
steps, total, #leveled)
|
||||
report(steps, total, leveled)
|
||||
end
|
||||
|
||||
-- game.ready is the sanctioned way to obtain the Game object; the party
|
||||
-- only exists once a save is loaded or created.
|
||||
mod.events:on("game.ready", function(payload)
|
||||
game = payload.game
|
||||
requestSync()
|
||||
end)
|
||||
mod.events:on("save.loaded", function()
|
||||
requestSync()
|
||||
consume()
|
||||
end)
|
||||
mod.events:on("save.created", function() requestSync() end)
|
||||
-- Quiet moments where a walk report can safely appear.
|
||||
mod.events:on("map.entered", function() consume() end)
|
||||
mod.events:on("battle.ended", function() consume() end)
|
||||
-- Flipping SYNC STEPS on triggers the HealthKit permission sheet
|
||||
-- immediately rather than on the next boot.
|
||||
mod.events:on("mod.options_changed", function() requestSync() end)
|
||||
end
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"id": "pokewalker",
|
||||
"name": "Pokewalker (Apple Health)",
|
||||
"version": "1.0.0",
|
||||
"api": 2,
|
||||
"entry": "main.lua",
|
||||
"profile": "content",
|
||||
"category": "MECHANIC",
|
||||
"game_version": ">=0.0.0-0 <2.0.0",
|
||||
"permissions": ["network", "engine_internals"],
|
||||
"priority": 100,
|
||||
"dependencies": [],
|
||||
"optional_dependencies": [],
|
||||
"conflicts": [],
|
||||
"description": "Your real-world steps (Apple Health) become EXP for your party. Opt-in: enable SYNC STEPS in this mod's options. Needs an iOS build with the Health bridge; dormant elsewhere."
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
-- Sharing metadata (25-community-and-ecosystem.md 3.2). Read by tooling
|
||||
-- and the manager detail pane; never by the loader's merge.
|
||||
return {
|
||||
summary = "Real-world steps from Apple Health become EXP for your party.",
|
||||
author = "mresnick67",
|
||||
contact = "https://github.com/mresnick67/Gen1ReComp-Pokewalker",
|
||||
tags = { "mechanic", "ios", "health", "opt-in", "field" },
|
||||
differences = {
|
||||
changed = {},
|
||||
added = {
|
||||
"an opt-in SYNC STEPS option: Apple Health step counts convert to "
|
||||
.. "EXP at a configurable rate (10/20/50 steps per EXP)",
|
||||
"EXP lands on the lead mon or splits across the party, applied with "
|
||||
.. "the engine's own growth curves and rare-candy stat math",
|
||||
"a walk-report textbox at quiet moments (save load, map change, "
|
||||
.. "battle end)",
|
||||
},
|
||||
known = {
|
||||
"needs an iOS build that ships the native Health bridge "
|
||||
.. "(love.system.syncHealthSteps); on every other platform the mod "
|
||||
.. "loads but stays dormant",
|
||||
"level-ups granted while walking do not prompt for new moves and do "
|
||||
.. "not trigger level evolutions until the next in-battle level "
|
||||
.. "(same as over-leveling with rare candies)",
|
||||
"steps sync on launch/activation; no background delivery yet",
|
||||
},
|
||||
},
|
||||
credits = {
|
||||
{ who = "Nintendo's Pokewalker (HGSS)", for_ = "the idea this recreates" },
|
||||
{ who = "bryanthaboi/gen1recomp", for_ = "the engine and mod platform" },
|
||||
},
|
||||
compat = { engine = ">=1.0.0 <2.0.0", modApi = 2 },
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
-- Standalone: luajit mods/pokewalker/tests/pokewalker_test.lua
|
||||
-- Exercises the stated effect: opt-in gating, the native-bridge seam, and
|
||||
-- steps converting to EXP through the engine's own growth math.
|
||||
--
|
||||
-- Shipped-mod suites are auto-run by the ROM-free T4 tier, so this runs
|
||||
-- against the committed fixture dataset (T.fixtures), never data/generated.
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.modkit")
|
||||
local Data = T.fixtures.fresh()
|
||||
|
||||
-- The native step bridge only exists inside the iOS/Android apps; stand it
|
||||
-- in so the mod sees the same surface it does on device.
|
||||
local syncCalls = 0
|
||||
love.system = love.system or {}
|
||||
love.system.syncHealthSteps = function()
|
||||
syncCalls = syncCalls + 1
|
||||
return true
|
||||
end
|
||||
|
||||
local run = T.sdk.loadMod("mods/pokewalker", { data = Data })
|
||||
T.eq(#run.errors, 0, "loads clean (" .. tostring(run.errors[1]) .. ")")
|
||||
|
||||
local events = run.loader.events
|
||||
local Growth = require("src.pokemon.Growth")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local mon = Pokemon.new(Data, "FIXMON_A", 5)
|
||||
local game = { data = Data, save = { party = { mon } } }
|
||||
|
||||
-- Dormant until opted in: seeded steps survive every event untouched and
|
||||
-- the native bridge is never poked (no permission prompt without consent).
|
||||
love.filesystem.write("steps_pending.json", '{"steps": 20000}')
|
||||
events:emit("game.ready", { game = game })
|
||||
events:emit("map.entered", {})
|
||||
T.check(love.filesystem.read("steps_pending.json") ~= nil,
|
||||
"opt-out leaves pending steps untouched")
|
||||
T.eq(syncCalls, 0, "opt-out never calls the native bridge")
|
||||
|
||||
-- Opted in: 20000 steps at the default 20 steps/EXP credit the lead mon.
|
||||
run.loader.modOptions.pokewalker = { enabled = true }
|
||||
local def = Data.pokemon[mon.species]
|
||||
local expBefore = mon.exp
|
||||
events:emit("save.loaded", {})
|
||||
T.eq(mon.exp, expBefore + 1000, "20000 steps at 20 steps/EXP = +1000 EXP")
|
||||
local expectedLevel = Growth.levelForExp(def.growthRate, mon.exp, 100,
|
||||
Data.growth_rates)
|
||||
T.check(mon.level > 5, "enough EXP to actually level (fixture curve)")
|
||||
T.eq(mon.level, expectedLevel, "level matches the engine growth curve")
|
||||
T.check(mon.stats.hp > 0 and mon.hp <= mon.stats.hp,
|
||||
"stat recalc keeps HP within the new maximum")
|
||||
T.check(love.filesystem.read("steps_pending.json") == nil,
|
||||
"pending file is consumed exactly once")
|
||||
T.check(syncCalls > 0, "opt-in requests a native sync")
|
||||
|
||||
-- A consumed file plus more events must not double-credit.
|
||||
local expAfter = mon.exp
|
||||
events:emit("map.entered", {})
|
||||
T.eq(mon.exp, expAfter, "no pending file, no phantom EXP")
|
||||
|
||||
run.release()
|
||||
T.finish("pokewalker")
|
||||
+108
-13
@@ -4,8 +4,11 @@
|
||||
#
|
||||
# Usage: scripts/build_ios.sh [--fetch] [--device] [--release] [--package-only]
|
||||
#
|
||||
# (default) Simulator Debug (CODE_SIGNING_ALLOWED=NO)
|
||||
# --device iphoneos SDK (needs signing / DEVELOPMENT_TEAM)
|
||||
# (default) Simulator Debug (ad-hoc signed)
|
||||
# --device iphoneos SDK; signing team auto-detected from the
|
||||
# keychain when DEVELOPMENT_TEAM is not set
|
||||
# --install after a --device build, install the app onto the
|
||||
# first connected iPhone/iPad (unlock it first)
|
||||
# --release Release configuration
|
||||
# --fetch Download love-11.5-ios-source.zip into mobile/ios/love-src/
|
||||
# --package-only Zip game.love + apply plist overlay; skip xcodebuild
|
||||
@@ -35,7 +38,19 @@ LIBS_DIR="$XCODE_DIR/ios/libraries"
|
||||
|
||||
APP_NAME="gen1recomp"
|
||||
DISPLAY_NAME="gen1recomp"
|
||||
BUNDLE_ID="com.theboisclub.pokemonred"
|
||||
# Bundle ID resolution, most specific wins:
|
||||
# 1. GEN1_BUNDLE_ID env var
|
||||
# 2. mobile/ios/bundle_id.local (one line, gitignored — pins YOUR install
|
||||
# so rebuilds keep updating the same app on your phone)
|
||||
# 3. device builds: com.gen1recomp.t<your team id> — explicit App IDs are
|
||||
# globally unique across ALL Apple accounts (and required once
|
||||
# capabilities like HealthKit are involved), so a per-team default
|
||||
# lets anyone build without colliding with someone else's app
|
||||
# 4. simulator: the project default (no App ID registration involved)
|
||||
BUNDLE_ID="${GEN1_BUNDLE_ID:-}"
|
||||
if [ -z "$BUNDLE_ID" ] && [ -f "$IOS_DIR/bundle_id.local" ]; then
|
||||
BUNDLE_ID="$(tr -d '[:space:]' < "$IOS_DIR/bundle_id.local")"
|
||||
fi
|
||||
LOVE_VERSION="$(tr -d '[:space:]' < "$IOS_DIR/LOVE_VERSION" 2>/dev/null || echo 11.5)"
|
||||
IOS_SOURCE_ZIP="love-${LOVE_VERSION}-ios-source.zip"
|
||||
APPLE_LIBS_ZIP="love-${LOVE_VERSION}-apple-libraries.zip"
|
||||
@@ -46,6 +61,7 @@ FETCH=false
|
||||
DEVICE=false
|
||||
RELEASE=false
|
||||
PACKAGE_ONLY=false
|
||||
INSTALL=false
|
||||
|
||||
say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
|
||||
warn() { printf '\033[1;33mwarn:\033[0m %s\n' "$*" >&2; }
|
||||
@@ -57,15 +73,44 @@ while [ $# -gt 0 ]; do
|
||||
--device) DEVICE=true ;;
|
||||
--release) RELEASE=true ;;
|
||||
--package-only) PACKAGE_ONLY=true ;;
|
||||
--install) INSTALL=true ;;
|
||||
-h|--help)
|
||||
sed -n '2,22p' "$0"
|
||||
exit 0
|
||||
;;
|
||||
*) fail "unknown argument: $1 (try --fetch, --device, --release, or --package-only)" ;;
|
||||
*) fail "unknown argument: $1 (try --fetch, --device, --release, --install, or --package-only)" ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
# ---------------------------------------------------------- signing identity
|
||||
# Auto-detect the Apple Development team when the caller didn't set one: the
|
||||
# OU field of the first Apple Development certificate in the keychain (Xcode
|
||||
# creates that certificate when you sign into Settings -> Accounts).
|
||||
detect_team() {
|
||||
security find-certificate -c "Apple Development" -p 2>/dev/null \
|
||||
| openssl x509 -noout -subject 2>/dev/null \
|
||||
| sed -n 's/.*OU *= *\([A-Z0-9]*\).*/\1/p' | head -1
|
||||
}
|
||||
if $DEVICE && [ -z "${DEVELOPMENT_TEAM:-}" ]; then
|
||||
DEVELOPMENT_TEAM="$(detect_team || true)"
|
||||
if [ -n "$DEVELOPMENT_TEAM" ]; then
|
||||
say "signing team auto-detected from keychain: $DEVELOPMENT_TEAM"
|
||||
else
|
||||
fail "no Apple signing identity found.
|
||||
Open Xcode -> Settings -> Accounts, press +, and sign in with your
|
||||
Apple ID (a free account works). That creates the certificate this
|
||||
script signs with. Then re-run this command."
|
||||
fi
|
||||
fi
|
||||
if [ -z "$BUNDLE_ID" ]; then
|
||||
if $DEVICE; then
|
||||
BUNDLE_ID="com.gen1recomp.t$(printf '%s' "$DEVELOPMENT_TEAM" | tr '[:upper:]' '[:lower:]')"
|
||||
else
|
||||
BUNDLE_ID="com.theboisclub.pokemonred"
|
||||
fi
|
||||
fi
|
||||
|
||||
# --------------------------------------------------------------- host checks
|
||||
if [ "$(uname -s)" != "Darwin" ]; then
|
||||
fail "iOS builds require macOS (Darwin). This host is $(uname -s).
|
||||
@@ -166,16 +211,23 @@ pack_game_love() {
|
||||
rm -f "$LOVE_FILE"
|
||||
# Same payload as scripts/build.sh / build_android.sh: game sources plus
|
||||
# tools/save-editor, which the launcher's Edit button opens in-process.
|
||||
# mods/pokewalker rides inside game.love on iOS only: physfs merges the
|
||||
# fused archive with the save dir, so the loader discovers it like any
|
||||
# installed mod, and its Apple Health sync is a no-op everywhere else.
|
||||
(cd "$ROOT" && zip -q -9 -r "$LOVE_FILE" \
|
||||
main.lua conf.lua src data assets tools/save-editor \
|
||||
tools/rom_manifest.json tools/rom_manifest_blue.json \
|
||||
mods/pokewalker \
|
||||
-x '*.DS_Store' -x '*/.git/*' -x '*/.DS_Store' \
|
||||
-x 'data/generated/*' -x 'assets/generated/*')
|
||||
# NOTE: grep -q here would race pipefail — it exits on first match, unzip
|
||||
# dies of SIGPIPE (141), and the pipeline "fails" nondeterministically.
|
||||
# >/dev/null keeps grep reading the whole stream instead.
|
||||
if unzip -Z1 "$LOVE_FILE" \
|
||||
| grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/'; then
|
||||
| grep -E '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/' >/dev/null; then
|
||||
fail "game.love unexpectedly contains generated ROM data"
|
||||
fi
|
||||
unzip -Z1 "$LOVE_FILE" | grep -qx 'tools/save-editor/App.lua' \
|
||||
unzip -Z1 "$LOVE_FILE" | grep -x 'tools/save-editor/App.lua' >/dev/null \
|
||||
|| fail "game.love is missing the save editor (Edit on a save row would crash)"
|
||||
say "game.love: $(du -h "$LOVE_FILE" | cut -f1) -> $LOVE_FILE"
|
||||
}
|
||||
@@ -300,18 +352,25 @@ run_xcodebuild() {
|
||||
SYMROOT="$BUILD_DIR/Build/Products"
|
||||
OBJROOT="$BUILD_DIR/Build/Intermediates"
|
||||
PRODUCT_BUNDLE_IDENTIFIER="$BUNDLE_ID"
|
||||
PRODUCT_NAME="$APP_NAME"
|
||||
MARKETING_VERSION="$LOVE_VERSION"
|
||||
ONLY_ACTIVE_ARCH=NO
|
||||
)
|
||||
|
||||
if ! $DEVICE; then
|
||||
# Simulator: no signing required
|
||||
args+=(CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO CODE_SIGN_IDENTITY=)
|
||||
# Simulator: ad-hoc signing (no certificate needed). A plain unsigned
|
||||
# build would drop the entitlements file, and HealthKit refuses to run
|
||||
# without the com.apple.developer.healthkit entitlement even in the
|
||||
# simulator.
|
||||
args+=(CODE_SIGNING_ALLOWED=YES CODE_SIGNING_REQUIRED=NO CODE_SIGN_IDENTITY=-)
|
||||
else
|
||||
warn "device build: configure signing in Xcode or set DEVELOPMENT_TEAM / CODE_SIGN_IDENTITY"
|
||||
if [ -n "${DEVELOPMENT_TEAM:-}" ]; then
|
||||
args+=(DEVELOPMENT_TEAM="$DEVELOPMENT_TEAM")
|
||||
# Automatic signing + provisioning updates lets xcodebuild register the
|
||||
# bundle ID / create a development profile from the CLI, so a device
|
||||
# build works without ever opening the project in Xcode.
|
||||
args+=(DEVELOPMENT_TEAM="$DEVELOPMENT_TEAM"
|
||||
CODE_SIGN_STYLE=Automatic
|
||||
-allowProvisioningUpdates)
|
||||
fi
|
||||
if [ -n "${CODE_SIGN_IDENTITY:-}" ]; then
|
||||
args+=(CODE_SIGN_IDENTITY="$CODE_SIGN_IDENTITY")
|
||||
@@ -364,20 +423,56 @@ run_xcodebuild() {
|
||||
local dist_dir="$DIST/${config}-${sdk}"
|
||||
rm -rf "$dist_dir"
|
||||
mkdir -p "$dist_dir"
|
||||
cp -R "$app" "$dist_dir/"
|
||||
say "copied to $dist_dir/$(basename "$app")"
|
||||
cp -R "$app" "$dist_dir/$APP_NAME.app"
|
||||
say "copied to $dist_dir/$APP_NAME.app"
|
||||
|
||||
say "iOS app: $app"
|
||||
say "bundle id: $BUNDLE_ID display: $DISPLAY_NAME"
|
||||
if $DEVICE; then
|
||||
warn "signing/provisioning is manual, see mobile/ios/README.md"
|
||||
if $INSTALL; then
|
||||
install_to_device "$app"
|
||||
else
|
||||
say "install with: scripts/build_ios.sh --device --install (iPhone plugged in + unlocked)"
|
||||
fi
|
||||
else
|
||||
say "simulator tip: xcrun simctl install booted \"$app\""
|
||||
fi
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------ device install
|
||||
# Installs the freshly built .app onto the first connected iPhone/iPad via
|
||||
# devicectl. The phone must be paired (plugged in at least once + "Trust
|
||||
# This Computer") and UNLOCKED during the install.
|
||||
install_to_device() {
|
||||
local app="$1"
|
||||
local line udid
|
||||
line="$(xcrun devicectl list devices 2>/dev/null \
|
||||
| grep -E 'iPhone|iPad' | grep -v 'Watch' | head -1 || true)"
|
||||
udid="$(printf '%s' "$line" \
|
||||
| grep -Eo '[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}' \
|
||||
| head -1 || true)"
|
||||
if [ -z "$udid" ]; then
|
||||
fail "no iPhone/iPad found.
|
||||
Plug the phone in with a cable, unlock it, tap 'Trust This Computer'
|
||||
if asked, then re-run: scripts/build_ios.sh --device --install"
|
||||
fi
|
||||
say "installing onto: $(printf '%s' "$line" | sed 's/ .*//') ($udid)"
|
||||
if xcrun devicectl device install app --device "$udid" "$app"; then
|
||||
say "installed. On the phone: tap the new app on your Home Screen."
|
||||
say "first launch may ask you to enable Developer Mode (Settings ->"
|
||||
say "Privacy & Security -> Developer Mode) and to trust the developer"
|
||||
say "(Settings -> General -> VPN & Device Management)."
|
||||
else
|
||||
fail "install failed. Most common cause: the phone was locked.
|
||||
Unlock it, keep it plugged in, and re-run:
|
||||
scripts/build_ios.sh --device --install"
|
||||
fi
|
||||
}
|
||||
|
||||
# --------------------------------------------------------------- main
|
||||
apply_ios_branding
|
||||
say "applying iOS native bridge patches (picker/Files support)"
|
||||
python3 "$IOS_DIR/patch_love_src.py" || fail "patch_love_src.py failed"
|
||||
pack_game_love
|
||||
ensure_game_love_in_xcode
|
||||
|
||||
|
||||
+186
-48
@@ -4,7 +4,7 @@
|
||||
-- Flow: intro -> menu (FIGHT/PKMN/ITEM/RUN) -> move select -> turn
|
||||
-- resolution (a queue of messages/actions/UI pushes) -> back to menu,
|
||||
-- until one side is out, then finish. Pops itself and calls
|
||||
-- onFinish("win"|"lose"|"run"|"caught"|"skipped").
|
||||
-- onFinish("win"|"lose"|"run"|"caught").
|
||||
--
|
||||
-- The Gen 1 move-effect pipeline (multi-hit, charge, trapping, thrash,
|
||||
-- bide, recharge, confusion, screens, substitute, transform, ...) is
|
||||
@@ -203,7 +203,16 @@ function BattleState.trainerPalette(data, trainer)
|
||||
return namedPalette(data, "MEWMON")
|
||||
end
|
||||
|
||||
function BattleState.trainerPicPath(data, trainer)
|
||||
-- Yellow only: ROCKET with wTrainerNo >= $2a is Jessie & James, who share
|
||||
-- the class and the name "ROCKET" but battle behind their own pic
|
||||
-- (home/trainers2.asm IsFightingJessieJames). picJessieJames exists only
|
||||
-- in a Yellow cache extracted after #439, so an older cache keeps the
|
||||
-- grunt pic until it is re-imported.
|
||||
function BattleState.trainerPicPath(data, trainer, oppClass, partyIndex)
|
||||
if oppClass == "OPP_ROCKET" and (partyIndex or 1) >= 42
|
||||
and trainer and trainer.picJessieJames then
|
||||
return trainer.picJessieJames
|
||||
end
|
||||
if trainer and trainer.pic then return trainer.pic end
|
||||
local base = trainer and trainer.basePic and data.trainers[trainer.basePic]
|
||||
return base and base.pic or nil
|
||||
@@ -629,7 +638,8 @@ function BattleState.newTrainer(game, oppClass, partyIndex)
|
||||
-- MonsterPalettes[0] = PAL_MEWMON -- InitBattleCommon zeroes
|
||||
-- wEnemyMonSpecies2 before the intro's SET_PAL_BATTLE
|
||||
-- (engine/battle/core.asm:6682, engine/gfx/palettes.asm SetPal_Battle)
|
||||
self.trainerPic = getImage(BattleState.trainerPicPath(game.data, self.trainer),
|
||||
self.trainerPic = getImage(
|
||||
BattleState.trainerPicPath(game.data, self.trainer, oppClass, partyIndex),
|
||||
BattleState.trainerPalette(game.data, self.trainer))
|
||||
self.introText = Strings("%s wants\nto fight!", self.trainer.name)
|
||||
return self
|
||||
@@ -762,10 +772,17 @@ function BattleState:buildScreen(id, ...)
|
||||
end
|
||||
|
||||
-- insert a wait for the HP bars to finish draining (UpdateHPBar):
|
||||
-- the queue holds until every battler's displayed HP catches up
|
||||
function BattleState:drainNext()
|
||||
-- the queue holds until every battler's displayed HP catches up.
|
||||
-- `stopAt` pins how far that battler's bar may drain on this row. A
|
||||
-- multi-hit move takes every strike off the model while the turn is still
|
||||
-- being queued, so an unpinned row would drain straight to the
|
||||
-- post-last-hit HP and the later strikes would animate nothing (#394);
|
||||
-- ApplyDamageToEnemyPokemon runs UpdateHPBar2 once per strike inside the
|
||||
-- wNumAttacksLeft loop (engine/battle/core.asm:4727).
|
||||
function BattleState:drainNext(battler, stopAt)
|
||||
self.nextInsert = (self.nextInsert or 0) + 1
|
||||
table.insert(self.queue, self.nextInsert, { drain = true })
|
||||
table.insert(self.queue, self.nextInsert,
|
||||
{ drain = true, battler = battler, stopAt = stopAt })
|
||||
end
|
||||
|
||||
-- One frame of the HP-bar drain (engine/gfx/hp_bar.asm UpdateHPBar):
|
||||
@@ -774,14 +791,22 @@ end
|
||||
function BattleState:stepHPDrain()
|
||||
local busy = false
|
||||
for _, b in ipairs({ self.player, self.enemy }) do
|
||||
if b and b.shownHP and b.shownHP ~= b.mon.hp then
|
||||
local step = math.max(1, b.mon.stats.hp) / 96
|
||||
if b.shownHP > b.mon.hp then
|
||||
b.shownHP = math.max(b.mon.hp, b.shownHP - step)
|
||||
else
|
||||
b.shownHP = math.min(b.mon.hp, b.shownHP + step)
|
||||
if b and b.shownHP then
|
||||
-- drainFloor is the stop the running row carries (see drainNext)
|
||||
local goal = b.mon.hp
|
||||
if b.drainFloor and b.drainFloor > goal
|
||||
and b.shownHP >= b.drainFloor then
|
||||
goal = b.drainFloor
|
||||
end
|
||||
if b.shownHP ~= goal then
|
||||
local step = math.max(1, b.mon.stats.hp) / 96
|
||||
if b.shownHP > goal then
|
||||
b.shownHP = math.max(goal, b.shownHP - step)
|
||||
else
|
||||
b.shownHP = math.min(goal, b.shownHP + step)
|
||||
end
|
||||
busy = busy or b.shownHP ~= goal
|
||||
end
|
||||
busy = busy or b.shownHP ~= b.mon.hp
|
||||
end
|
||||
end
|
||||
return busy
|
||||
@@ -859,6 +884,8 @@ function BattleState:updateQueue()
|
||||
if self.draining then
|
||||
if self:stepHPDrain() then return true end
|
||||
self.draining = nil
|
||||
if self.player then self.player.drainFloor = nil end
|
||||
if self.enemy then self.enemy.drainFloor = nil end
|
||||
end
|
||||
-- a move animation holds the queue until it finishes; its screen
|
||||
-- effects (SE_*) and per-row sounds route into the fx layer as they
|
||||
@@ -897,6 +924,7 @@ function BattleState:updateQueue()
|
||||
end
|
||||
if item.drain then
|
||||
self.draining = true
|
||||
if item.battler then item.battler.drainFloor = item.stopAt end
|
||||
return true
|
||||
end
|
||||
if item.wait then
|
||||
@@ -1119,10 +1147,34 @@ function BattleState:battleKind()
|
||||
end
|
||||
|
||||
function BattleState:enter()
|
||||
if self.dead then
|
||||
-- Out of useable POKéMON before the battle even starts. pokered does not
|
||||
-- skip the battle: .checkAnyPartyAlive (engine/battle/core.asm:158-162)
|
||||
-- runs right after the intro and jumps to HandlePlayerBlackOut, so the
|
||||
-- player blacks out and afterBattle's "lose" path revives the party at the
|
||||
-- last heal point. Handing the map back with a 0 HP party instead bricked
|
||||
-- the save: every later encounter aborted here too and sighted trainers
|
||||
-- re-engaged forever (#425). self.dead alone is not the test --
|
||||
-- makeOldManDemo and LinkBattle install a player battler after the
|
||||
-- constructor flagged the battle dead.
|
||||
if self.dead and not self.player then
|
||||
local name = self.game.save.player.name
|
||||
self.result = "lose"
|
||||
self.game.stack:pop()
|
||||
Runtime.emit("battle.ended", { battle = self, result = "skipped" })
|
||||
if self.onFinish then self.onFinish("skipped") end
|
||||
require("src.core.Music").restoreMap(self.data)
|
||||
Runtime.emit("battle.ended", { battle = self, result = "lose", skipped = true })
|
||||
local onFinish = self.onFinish
|
||||
local function blackedOut()
|
||||
if onFinish then onFinish("lose") end
|
||||
end
|
||||
-- the Oak's Lab starter rival returns above PlayerBlackedOutText2 and
|
||||
-- afterBattle keeps the player in the lab, so print nothing there
|
||||
if BattleState.isOaksLabStarterRival(self) then return blackedOut() end
|
||||
-- _PlayerBlackedOutText2 (data/text/text_2.asm:896): the two paragraphs
|
||||
-- playerMonFainted queues on the battle screen; there is no battle
|
||||
-- screen to queue them on here, so they print over the map.
|
||||
self.game.stack:push(require("src.render.TextBox").new(self.game,
|
||||
Strings("%s is out of\nuseable POKéMON!", name) .. "\f"
|
||||
.. Strings("%s blacked\nout!", name), blackedOut))
|
||||
return
|
||||
end
|
||||
local Music = require("src.core.Music")
|
||||
@@ -1402,6 +1454,7 @@ function BattleState:update(dt)
|
||||
for _, b in ipairs({ self.player, self.enemy }) do
|
||||
if b then
|
||||
if b.shownHP then b.shownHP = b.mon.hp end
|
||||
b.drainFloor = nil
|
||||
b.shownStatus = b.mon.status
|
||||
end
|
||||
end
|
||||
@@ -1959,6 +2012,15 @@ function BattleState:resolveSwitch(newMon)
|
||||
end
|
||||
|
||||
function BattleState:endOfTurn()
|
||||
-- the same ret: a decided battle never reaches HandlePoisonBurnLeechSeed
|
||||
-- or CheckNumAttacksLeft (core.asm:417-421, 456-460), so the residual
|
||||
-- sweep and the trapping-counter release are skipped on the turn a
|
||||
-- Teleport escape (or a win/loss/capture) settles it (#441). The
|
||||
-- turn_ended hook still fires: mods count turns, not residuals.
|
||||
if self.result then
|
||||
Runtime.emit("battle.turn_ended", { battle = self, turn = self.turnCount or 0 })
|
||||
return
|
||||
end
|
||||
-- sideToxic mirrors w*ToxicCounter: it advances only while the
|
||||
-- battler's badly-poisoned flag (toxicCounter) is set, an item/AI
|
||||
-- cure clears the flag but NOT the side counter, and a fresh Toxic
|
||||
@@ -2176,6 +2238,31 @@ local function startPicKind(pf, kind)
|
||||
pf.hidden = nil
|
||||
end
|
||||
|
||||
-- PredefShakeScreenHorizontally (engine/gfx/screen_effects.asm): the window
|
||||
-- jumps right by b for 5 frames then home for 4, b counting down to 1.
|
||||
-- b = 8 for SE_SHAKE_SCREEN and the heavy applying-attack shake, b = 2 for
|
||||
-- the light one.
|
||||
local function fastShakeProg(b)
|
||||
local prog = {}
|
||||
for i = b, 1, -1 do
|
||||
prog[#prog + 1] = { dx = i, frames = 5 }
|
||||
prog[#prog + 1] = { dx = 0, frames = 4 }
|
||||
end
|
||||
return prog
|
||||
end
|
||||
|
||||
-- AnimationShakeScreenHorizontallySlow (engine/battle/animations.asm:526):
|
||||
-- rWX creeps 1px right every 2 frames b times, then back down to 0, c times
|
||||
-- over. Silent -- this is the non-damaging move's feedback.
|
||||
local function slowShakeProg(b, c)
|
||||
local prog = {}
|
||||
for _ = 1, c do
|
||||
for i = 1, b do prog[#prog + 1] = { dx = i, frames = 2 } end
|
||||
for i = b - 1, 0, -1 do prog[#prog + 1] = { dx = i, frames = 2 } end
|
||||
end
|
||||
return prog
|
||||
end
|
||||
|
||||
-- Route one AnimPlayer event into the fx layer. Frame counts and
|
||||
-- amplitudes are the routines' own (engine/battle/animations.asm;
|
||||
-- shakes: engine/gfx/screen_effects.asm).
|
||||
@@ -2217,14 +2304,7 @@ function BattleState:applyAnimEffect(ev)
|
||||
|
||||
-- ---------------------------------------------- screen shakes
|
||||
elseif e == "SE_SHAKE_SCREEN" then
|
||||
-- PredefShakeScreenHorizontally b=8: the window jumps right by b
|
||||
-- for 5 frames then home for 4, b counting down 8..1
|
||||
local prog = {}
|
||||
for b = 8, 1, -1 do
|
||||
prog[#prog + 1] = { dx = b, frames = 5 }
|
||||
prog[#prog + 1] = { dx = 0, frames = 4 }
|
||||
end
|
||||
fx.shakeProg = prog
|
||||
fx.shakeProg = fastShakeProg(8)
|
||||
elseif e == "SE_ROCK_SLIDE_SHAKE" then
|
||||
-- DoRockSlideSpecialEffects: 1px horizontal then vertical rumble
|
||||
fx.shakeProg = { { dx = 1, frames = 5 }, { dx = 0, frames = 4 },
|
||||
@@ -2321,34 +2401,77 @@ function BattleState:applyAnimEffect(ev)
|
||||
-- battler.substituteHP is set (MoveEffects raises it with the move)
|
||||
end
|
||||
|
||||
-- The target's post-animation hit feedback (PlayApplyingAttackAnimation,
|
||||
-- engine/battle/animations.asm:475): the player's damaging moves blink
|
||||
-- the ENEMY pic; the enemy's damaging moves shake the screen vertically
|
||||
-- (ShakeScreenVertically -> PredefShakeScreenVertically b=8: the window
|
||||
-- drops by b for 3 frames then home for 3, b counting down) -- the
|
||||
-- player's pic never blinks. Damage sound with either. A hold keeps
|
||||
-- The post-animation applying-attack feedback (PlayApplyingAttackAnimation
|
||||
-- -> AnimationTypePointerTable, engine/battle/animations.asm:475-524).
|
||||
-- hit.animType is wAnimationType, 1..6:
|
||||
-- 1 enemy damaging, no added effect ShakeScreenVertically (b=8)
|
||||
-- 2 enemy damaging, added effect fast horizontal shake, b=8
|
||||
-- 3 enemy non-damaging slow horizontal shake, b=6, c=2
|
||||
-- 4 player damaging, no added effect BlinkEnemyMonSprite
|
||||
-- 5 player damaging, added effect fast horizontal shake, b=2
|
||||
-- 6 player non-damaging slow horizontal shake, b=3, c=2
|
||||
-- Types 3 and 6 are silent; the rest open with PlayApplyingAttackSound,
|
||||
-- which is the damage sound hit.sfx already carries. Only 1 and 4 were
|
||||
-- implemented, so every move with an added effect blinked (or shook
|
||||
-- vertically) instead of shaking sideways and every status move showed
|
||||
-- nothing at all -- Bubblebeam, Confusion, Hypnosis (#354). A hold keeps
|
||||
-- the queue still until the effect finishes.
|
||||
function BattleState:applyHitFx(hit)
|
||||
if hit.blink then
|
||||
self.fx = self.fx or {}
|
||||
if hit.blink.isPlayer then
|
||||
local prog = {}
|
||||
for b = 8, 1, -1 do
|
||||
prog[#prog + 1] = { dy = b, frames = 3 }
|
||||
prog[#prog + 1] = { dy = 0, frames = 3 }
|
||||
end
|
||||
self.fx.shakeProg = prog
|
||||
self.waitFrames = 48 -- the predef blocks until the shake settles
|
||||
else
|
||||
self.fx.blink = { target = hit.blink, frames = 20 }
|
||||
self.waitFrames = 20
|
||||
end
|
||||
end
|
||||
self.fx = self.fx or {}
|
||||
-- rows queued before animType existed carry only the blink target
|
||||
local t = hit.animType
|
||||
if not t and hit.blink then t = hit.blink.isPlayer and 1 or 4 end
|
||||
if hit.sfx then
|
||||
require("src.core.Sound").play(self.data, hit.sfx)
|
||||
end
|
||||
if not t or not self:animationsOn() then return end
|
||||
if t == 1 then
|
||||
-- PredefShakeScreenVertically b=8: the window drops by b for 3 frames
|
||||
-- then home for 3, b counting down
|
||||
local prog = {}
|
||||
for b = 8, 1, -1 do
|
||||
prog[#prog + 1] = { dy = b, frames = 3 }
|
||||
prog[#prog + 1] = { dy = 0, frames = 3 }
|
||||
end
|
||||
self.fx.shakeProg = prog
|
||||
self.waitFrames = 48 -- the predef blocks until the shake settles
|
||||
elseif t == 2 then
|
||||
self.fx.shakeProg = fastShakeProg(8)
|
||||
self.waitFrames = 72
|
||||
elseif t == 3 then
|
||||
self.fx.shakeProg = slowShakeProg(6, 2)
|
||||
self.waitFrames = 48
|
||||
elseif t == 4 then
|
||||
if hit.blink then
|
||||
self.fx.blink = { target = hit.blink, frames = 20 }
|
||||
self.waitFrames = 20
|
||||
end
|
||||
elseif t == 5 then
|
||||
self.fx.shakeProg = fastShakeProg(2)
|
||||
self.waitFrames = 18
|
||||
elseif t == 6 then
|
||||
self.fx.shakeProg = slowShakeProg(3, 2)
|
||||
self.waitFrames = 24
|
||||
end
|
||||
end
|
||||
|
||||
-- Primary status effects whose pokered handler ends in
|
||||
-- PlayCurrentMoveAnimation2 (engine/battle/effects.asm:1448), which sets
|
||||
-- wAnimationType 6 on the player's turn and 3 on the enemy's: sleep,
|
||||
-- poison, confuse, disable and the primary stat-down effects. Every other
|
||||
-- primary effect goes through PlayCurrentMoveAnimation and leaves the type
|
||||
-- at 0 (no applying animation): paralysis (FreezeBurnParalyzeEffect),
|
||||
-- leech seed, the stat-UP effects, Splash. Side-effect stat drops are
|
||||
-- skipped too -- UpdateLoweredStatDone bails out for them because the
|
||||
-- damaging move's own type 2/5 shake already played.
|
||||
local SLOW_SHAKE_EFFECTS = {
|
||||
SLEEP_EFFECT = true, POISON_EFFECT = true, CONFUSION_EFFECT = true,
|
||||
DISABLE_EFFECT = true,
|
||||
ATTACK_DOWN1_EFFECT = true, DEFENSE_DOWN1_EFFECT = true,
|
||||
DEFENSE_DOWN2_EFFECT = true, SPEED_DOWN1_EFFECT = true,
|
||||
ACCURACY_DOWN1_EFFECT = true,
|
||||
}
|
||||
|
||||
-- AnimateSendingOutMon (core.asm:6801-6838): the mon grows out of the
|
||||
-- ball -- a 3-frame ball beat, 4 frames of the pic at 3/7 scale (a 3x3
|
||||
-- block of its 7x7 tiles), 5 frames at 5/7 (5x5), then full size.
|
||||
@@ -2568,6 +2691,12 @@ function BattleState:syncShownStatus()
|
||||
end
|
||||
|
||||
function BattleState:executeAction(user, target, action)
|
||||
-- MainInBattleLoop reads wEscapedFromBattle right after Execute*Move and
|
||||
-- rets (core.asm:417-421, 456-460): a Teleport/Roar/Whirlwind escape ends
|
||||
-- the turn where it lands and the second mover never moves. self.result
|
||||
-- is only ever set once the battle is over (run/win/lose/caught), and the
|
||||
-- faint cases are already covered by the HP guard below (#441)
|
||||
if self.result then return end
|
||||
if user.mon.hp <= 0 or target.mon.hp <= 0 then return end
|
||||
if not action then return end
|
||||
|
||||
@@ -2932,6 +3061,8 @@ function BattleState:performMove(user, target, moveInst, isCalled)
|
||||
-- (AlreadyAsleep / NothingHappened / ButItFailed print with no anim)
|
||||
if primaryEffectFailed(msgs) then
|
||||
self:cancelMoveAnim()
|
||||
elseif SLOW_SHAKE_EFFECTS[move.effect] and self.moveAnimRow then
|
||||
self.moveAnimRow.hit = { animType = user.isPlayer and 6 or 3 }
|
||||
end
|
||||
for _, m in ipairs(msgs) do
|
||||
self:sayNext(m)
|
||||
@@ -2985,6 +3116,10 @@ function BattleState:continueBide(user, target)
|
||||
self:sayNext(Strings("But, it failed!"))
|
||||
return
|
||||
end
|
||||
-- .UnleashEnergy (core.asm:3501-3529) re-points wPlayerMoveNum at BIDE
|
||||
-- and rejoins HandleIfPlayerMoveMissed, so BIDE's own animation plays
|
||||
-- here, after UnleashedEnergyText and before the damage (#375)
|
||||
self:animNext("BIDE", user.isPlayer)
|
||||
self:applyDamage(target, dmg)
|
||||
if target.mon.hp <= 0 then self:onFaint(target) end
|
||||
end
|
||||
@@ -3009,7 +3144,7 @@ function BattleState:applyDamage(target, dmg)
|
||||
end
|
||||
local dealt = math.min(dmg, target.mon.hp)
|
||||
target.mon.hp = target.mon.hp - dealt
|
||||
if dealt > 0 then self:drainNext() end -- animate the bar down
|
||||
if dealt > 0 then self:drainNext(target, target.mon.hp) end -- animate the bar down
|
||||
if target.bideTurns then
|
||||
target.bideDamage = (target.bideDamage or 0) + dealt
|
||||
end
|
||||
@@ -3856,12 +3991,15 @@ function BattleState:throwBall(ball)
|
||||
self:act(function() self:endOfTurn() end)
|
||||
return
|
||||
end
|
||||
if self.ghost then
|
||||
if self.ghost or self.noCatch then
|
||||
-- ItemUseBall's can't-be-caught path (item_effects.asm:149-153):
|
||||
-- the ball is thrown (TossBallAnimation still picks the arc from
|
||||
-- wCurItem, so a Master/Ultra toss keeps its flicker), dodged
|
||||
-- ($10 anim data, no wobbles), and the turn is spent like any
|
||||
-- failed throw
|
||||
-- failed throw. battle.noCatch is the .notOldManBattle half of the
|
||||
-- same check (item_effects.asm:166-175): the POKEMON_TOWER_6F
|
||||
-- RESTLESS SOUL dodges balls even once the scope has revealed it,
|
||||
-- so it is not a ghost battle any more (#444)
|
||||
self:animNext(self:tossAnimFor(ball), true, nil, ball)
|
||||
self:sayNext(Strings("It dodged the\nthrown BALL!"))
|
||||
self:sayNext(Strings("This POKéMON\ncan't be caught!"))
|
||||
|
||||
@@ -186,7 +186,15 @@ function EffectRegistry.runDamaging(battle, ctx, record)
|
||||
-- hitRow carries the blink instead.
|
||||
local hitSfx = info.typeMult > 10 and "Super_Effective"
|
||||
or info.typeMult < 10 and "Not_Very_Effective" or "Damage"
|
||||
-- GetPlayerAnimationType / GetEnemyAnimationType (engine/battle/core.asm
|
||||
-- :3159 / :5555): wAnimationType is 4 (blink the enemy pic) or 1 (shake
|
||||
-- the screen vertically) for a damaging move with no added effect, and
|
||||
-- 5 / 2 (a horizontal shake) as soon as the move HAS one -- which is why
|
||||
-- Bubblebeam and Confusion shake instead of blinking (#354)
|
||||
local added = move.effect ~= nil and move.effect ~= "NO_ADDITIONAL_EFFECT"
|
||||
local hitFx = { sfx = hitSfx,
|
||||
animType = user.isPlayer and (added and 5 or 4)
|
||||
or (added and 2 or 1),
|
||||
blink = battle:animationsOn() and target or nil }
|
||||
|
||||
local totalDealt = 0
|
||||
|
||||
@@ -609,10 +609,16 @@ MoveEffects.full = {
|
||||
},
|
||||
|
||||
BIDE_EFFECT = {
|
||||
-- BideEffect (effects.asm:764-789) is a ResidualEffects2 entry: the
|
||||
-- storing turn plays XSTATITEM_ANIM (XSTATITEM_DUPLICATE_ANIM on the
|
||||
-- enemy side) and never BIDE's own animation, which belongs to the
|
||||
-- release turn in .UnleashEnergy (#375)
|
||||
perform = function(ctx)
|
||||
local user = ctx.user
|
||||
user.bideTurns = ctx.rng(2, 3)
|
||||
user.bideDamage = 0
|
||||
ctx.battle:cancelMoveAnim()
|
||||
ctx.anim(user.isPlayer and "XSTATITEM_ANIM" or "XSTATITEM_DUPLICATE_ANIM")
|
||||
ctx.say(Strings("%s\nis storing energy!", displayName(user)))
|
||||
end,
|
||||
},
|
||||
|
||||
+39
-5
@@ -32,17 +32,20 @@ local MUSIC_BUFFER_COUNT = ChipSynth.MUSIC_BUFFER_COUNT
|
||||
-- [1] pulse 1 [2] pulse 2 [3] wave [4] noise / drums
|
||||
-- Volume: 1 = authentic, 0 = mute, >1 boosts
|
||||
-- Pitch: 1 = authentic, 2 = +1 octave, 0.5 = -1 octave
|
||||
-- The shipped values stay at 1: 0.25 / 0.5 on the wave channel buried the Ch3
|
||||
-- countermelodies an octave low (#429), and ChipSynth already applies the
|
||||
-- wave channel's own hardware octave (frequency * 0.5).
|
||||
-- ---------------------------------------------------------------------------
|
||||
local CHANNEL_VOLUME = {
|
||||
[1] = 1, -- pulse 1
|
||||
[2] = 1, -- pulse 2
|
||||
[3] = 0.25, -- wave
|
||||
[3] = 1, -- wave
|
||||
[4] = 1, -- noise / drums
|
||||
}
|
||||
local CHANNEL_PITCH = {
|
||||
[1] = 1, -- pulse 1
|
||||
[2] = 1, -- pulse 2
|
||||
[3] = 0.5, -- wave
|
||||
[3] = 1, -- wave
|
||||
[4] = 1, -- noise / drums
|
||||
}
|
||||
ChipSynth.setChannelVolumes(CHANNEL_VOLUME)
|
||||
@@ -55,6 +58,14 @@ local currentMusic
|
||||
local pendingBuf -- a current-gen buffer popped from the worker but not yet
|
||||
-- queued because the Source was momentarily full
|
||||
|
||||
-- Music holds playback while a fanfare owns the music channels (#398).
|
||||
-- Pausing the Source is not enough on its own: this module is what starts a
|
||||
-- chip song (immediately on the sync path, on the first worker buffer on the
|
||||
-- threaded one), so a song that begins during a jingle would come up
|
||||
-- underneath it. Music.duckForFanfare sets the hold, Music releases it when
|
||||
-- the jingle ends.
|
||||
local musicHeld = false
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- worker management
|
||||
-- ---------------------------------------------------------------------------
|
||||
@@ -148,7 +159,7 @@ local function playMusicSync(data, header, allowLoops)
|
||||
currentMusic = { source = source, engine = engine, threaded = false,
|
||||
started = true, finished = false }
|
||||
fillSync(MUSIC_FILL_INITIAL)
|
||||
source:play()
|
||||
if not musicHeld then source:play() end
|
||||
return source
|
||||
end
|
||||
|
||||
@@ -222,7 +233,7 @@ local function updateThreaded()
|
||||
end
|
||||
end
|
||||
end
|
||||
if not m.started then
|
||||
if not m.started and not musicHeld then
|
||||
if (MUSIC_BUFFER_COUNT - m.source:getFreeBufferCount()) > 0 then
|
||||
pcall(function() m.source:play() end)
|
||||
m.started = true
|
||||
@@ -245,7 +256,7 @@ end
|
||||
-- pause/resume behavior.
|
||||
function ChipAudio.ensureMusicPlaying()
|
||||
local m = currentMusic
|
||||
if not m or m.finished then return end
|
||||
if not m or m.finished or musicHeld then return end
|
||||
if m.threaded then
|
||||
if not m.started then return end
|
||||
local ok, playing = pcall(function() return m.source:isPlaying() end)
|
||||
@@ -263,6 +274,18 @@ function ChipAudio.ensureMusicPlaying()
|
||||
end
|
||||
end
|
||||
|
||||
-- Silence the song for the length of a fanfare and start whatever was held
|
||||
-- back once it ends. Held state outlives a song change: Music.play may swap
|
||||
-- songs while the jingle is still sounding.
|
||||
function ChipAudio.holdMusic(held)
|
||||
held = not not held
|
||||
if held == musicHeld then return end
|
||||
musicHeld = held
|
||||
if held then return end
|
||||
ChipAudio.update()
|
||||
ChipAudio.ensureMusicPlaying()
|
||||
end
|
||||
|
||||
-- Threaded playMusic returns an empty QueueableSource and only calls
|
||||
-- Source:play once the first worker buffer lands (~1 frame later). Until
|
||||
-- then Source:isPlaying is false -- callers that treat that as "song over"
|
||||
@@ -303,6 +326,17 @@ function ChipAudio.invalidate()
|
||||
if workerReady and cmdCh then cmdCh:push({ cmd = "invalidate" }) end
|
||||
end
|
||||
|
||||
-- End the worker thread. LOVE waits for every live love.thread before the
|
||||
-- process exits and the worker's command loop only returns on "quit", so
|
||||
-- skipping this leaves the process running after the window is gone (#339).
|
||||
function ChipAudio.shutdown()
|
||||
ChipAudio.stopMusic()
|
||||
if workerReady and cmdCh then cmdCh:push({ cmd = "quit" }) end
|
||||
if worker then pcall(function() worker:wait() end) end
|
||||
worker, cmdCh, outCh = nil, nil, nil
|
||||
workerReady = false
|
||||
end
|
||||
|
||||
-- Runtime mix for one hardware channel (1..4). Takes effect on the next
|
||||
-- synthesized buffer (live music) and on any SFX/cry rendered after the call.
|
||||
function ChipAudio.setChannelVolume(hw, scale)
|
||||
|
||||
@@ -38,6 +38,22 @@ local CONSTANT_DEFAULTS = {
|
||||
},
|
||||
}
|
||||
|
||||
-- data/events/trades.asm in Pokemon Yellow has its own TradeMons table.
|
||||
-- Old Yellow imports were built from the Red manifest, so correct that
|
||||
-- table after loading as well as in the fixed import manifest below.
|
||||
local YELLOW_TRADES = {
|
||||
{ give = "LICKITUNG", get = "DUGTRIO", dialogset = 1, nickname = "GURIO" },
|
||||
{ give = "CLEFAIRY", get = "MR_MIME", dialogset = 1, nickname = "MILES" },
|
||||
{ give = "BUTTERFREE", get = "BEEDRILL", dialogset = 3, nickname = "STINGER" },
|
||||
{ give = "KANGASKHAN", get = "MUK", dialogset = 1, nickname = "STICKY" },
|
||||
{ give = "MEW", get = "MEW", dialogset = 3, nickname = "BART" },
|
||||
{ give = "TANGELA", get = "PARASECT", dialogset = 1, nickname = "SPIKE" },
|
||||
{ give = "PIDGEOT", get = "PIDGEOT", dialogset = 2, nickname = "MARTY" },
|
||||
{ give = "GOLDUCK", get = "RHYDON", dialogset = 2, nickname = "BUFFY" },
|
||||
{ give = "GROWLITHE", get = "DEWGONG", dialogset = 3, nickname = "CEZANNE" },
|
||||
{ give = "CUBONE", get = "MACHOKE", dialogset = 3, nickname = "RICKY" },
|
||||
}
|
||||
|
||||
-- field.boot is the total-conversion override point for the new game; the
|
||||
-- values match what SaveData.newGame and the Oak speech used to inline.
|
||||
local BOOT_DEFAULTS = {
|
||||
@@ -58,6 +74,12 @@ local function copy(value)
|
||||
return out
|
||||
end
|
||||
|
||||
function Data:applyVersionedFieldData()
|
||||
if require("src.core.GameVersion").isYellow() then
|
||||
self.field.trades = copy(YELLOW_TRADES)
|
||||
end
|
||||
end
|
||||
|
||||
-- Fills only what the cache is missing, so an importer that learns to
|
||||
-- stamp one of these keys silently takes over from the engine.
|
||||
function Data:seedDefaults()
|
||||
@@ -77,6 +99,7 @@ function Data:seedDefaults()
|
||||
if constants.dexDigits == nil then
|
||||
constants.dexDigits = math.max(3, #tostring(constants.dexSize))
|
||||
end
|
||||
self:applyVersionedFieldData()
|
||||
local boot = self.field.boot
|
||||
if boot == nil then
|
||||
boot = {}
|
||||
|
||||
@@ -491,6 +491,25 @@ function Game:gamepadaxis(joystick, axis, value)
|
||||
Input:gamepadaxis(joystick, axis, value)
|
||||
end
|
||||
|
||||
function Game:joystickpressed(joystick, button)
|
||||
TouchControls:noteGamepad()
|
||||
Input:joystickpressed(joystick, button)
|
||||
end
|
||||
|
||||
function Game:joystickreleased(joystick, button)
|
||||
Input:joystickreleased(joystick, button)
|
||||
end
|
||||
|
||||
function Game:joystickaxis(joystick, axis, value)
|
||||
if math.abs(value) > 0.5 then TouchControls:noteGamepad() end
|
||||
Input:joystickaxis(joystick, axis, value)
|
||||
end
|
||||
|
||||
function Game:joystickhat(joystick, hat, direction)
|
||||
if direction ~= "c" then TouchControls:noteGamepad() end
|
||||
Input:joystickhat(joystick, hat, direction)
|
||||
end
|
||||
|
||||
-- Window focus/visibility flips: a release due while unfocused/hidden can
|
||||
-- be swallowed by the OS. Reset on both edges -- gaining focus with a
|
||||
-- physically held key won't re-fire keypressed, so trusting leftover
|
||||
|
||||
@@ -39,6 +39,20 @@ local DEFAULT_GAMEPAD_BINDINGS = {
|
||||
local STICK_ON = 0.5
|
||||
local STICK_OFF = 0.3
|
||||
|
||||
-- Generic SDL joysticks expose the left stick as the first two numbered
|
||||
-- axes and the D-pad as a hat. This is common on Linux handhelds whose
|
||||
-- controller has no game-controller database entry.
|
||||
local RAW_BUTTON_BINDINGS = {
|
||||
[1] = "a", [2] = "b",
|
||||
[7] = "select", [8] = "start", [9] = "select", [10] = "start",
|
||||
}
|
||||
|
||||
local HAT_DIRECTIONS = {
|
||||
u = { "up" }, d = { "down" }, l = { "left" }, r = { "right" },
|
||||
lu = { "left", "up" }, ru = { "right", "up" },
|
||||
ld = { "left", "down" }, rd = { "right", "down" },
|
||||
}
|
||||
|
||||
function Input:init()
|
||||
self:applyBindings(nil)
|
||||
self:reset()
|
||||
@@ -79,6 +93,7 @@ function Input:reset()
|
||||
self.sources = {}
|
||||
self.stickAxis = { x = 0, y = 0 }
|
||||
self.stickDir = nil
|
||||
self.hatDirs = {}
|
||||
end
|
||||
|
||||
-- Multiple physical sources (W + Up, d-pad + stick, etc.) can claim the
|
||||
@@ -179,6 +194,16 @@ function Input:gamepadreleased(joystick, button)
|
||||
end
|
||||
end
|
||||
|
||||
function Input:joystickpressed(joystick, button)
|
||||
local btn = RAW_BUTTON_BINDINGS[button]
|
||||
if btn then press(self, btn, "joy:" .. button) end
|
||||
end
|
||||
|
||||
function Input:joystickreleased(joystick, button)
|
||||
local btn = RAW_BUTTON_BINDINGS[button]
|
||||
if btn then release(self, btn, "joy:" .. button) end
|
||||
end
|
||||
|
||||
-- left stick treated as a continuous held direction, same 4-way rule as
|
||||
-- the touch swipe d-pad: whichever axis has the larger magnitude wins.
|
||||
function Input:gamepadaxis(joystick, axis, value)
|
||||
@@ -214,6 +239,26 @@ function Input:gamepadaxis(joystick, axis, value)
|
||||
end
|
||||
end
|
||||
|
||||
function Input:joystickaxis(joystick, axis, value)
|
||||
if axis == 1 then
|
||||
self:gamepadaxis(joystick, "leftx", value)
|
||||
elseif axis == 2 then
|
||||
self:gamepadaxis(joystick, "lefty", value)
|
||||
end
|
||||
end
|
||||
|
||||
function Input:joystickhat(joystick, hat, direction)
|
||||
local source = "hat:" .. hat
|
||||
for _, btn in ipairs(self.hatDirs[hat] or {}) do
|
||||
release(self, btn, source)
|
||||
end
|
||||
local dirs = HAT_DIRECTIONS[direction] or {}
|
||||
for _, btn in ipairs(dirs) do
|
||||
press(self, btn, source)
|
||||
end
|
||||
self.hatDirs[hat] = dirs
|
||||
end
|
||||
|
||||
function Input:isDown(btn)
|
||||
return self.state[btn] or false
|
||||
end
|
||||
|
||||
@@ -85,6 +85,7 @@ local function fanfareActive()
|
||||
local ok, playing = pcall(src.isPlaying, src)
|
||||
if ok and playing then return true end
|
||||
state.fanfare = nil
|
||||
require("src.core.ChipAudio").holdMusic(false)
|
||||
return false
|
||||
end
|
||||
|
||||
@@ -94,6 +95,9 @@ end
|
||||
function Music.duckForFanfare(src)
|
||||
if not src then return end
|
||||
state.fanfare = src
|
||||
-- ChipAudio is what starts a chip song, so the pause below cannot hold one
|
||||
-- that has not started yet (nor one Music.play swaps in mid-jingle) (#398)
|
||||
require("src.core.ChipAudio").holdMusic(true)
|
||||
if state.source then
|
||||
local ok, playing = pcall(state.source.isPlaying, state.source)
|
||||
if ok and playing then
|
||||
|
||||
@@ -223,6 +223,9 @@ function SaveData.defaultOptions()
|
||||
-- 0-7 like the GB's NR50 master volume
|
||||
musicVol = 7,
|
||||
sfxVol = 7,
|
||||
-- Yellow only: 0-7 trim for Pikachu's PCM voice clips on top of sfxVol
|
||||
-- (the PIKACHU VOL row appears only on Yellow; see Sound.lua)
|
||||
pikaVol = 7,
|
||||
musicFilter = 0,
|
||||
-- logic fast-forward multiplier; audio is unaffected (GameSpeed.lua)
|
||||
speed = 1,
|
||||
|
||||
+51
-10
@@ -16,6 +16,30 @@ local cache = {}
|
||||
-- scaling the 0.8 base every source gets
|
||||
local BASE_VOLUME = 0.8
|
||||
local volumeScale = 1
|
||||
-- port addition (Yellow only): 0-7 trim from save.options.pikaVol on top of
|
||||
-- the SFX level, for Pikachu's voice clips alone. Yellow voices every
|
||||
-- Pikachu cry with PCM samples that are far louder and far more frequent
|
||||
-- than the chip cries around them (the follower alone talks on every
|
||||
-- interaction), so this is the one source players want to pull down
|
||||
-- without muting the rest of the SFX bus. 7 = untouched, 0 = silent.
|
||||
local pikaScale = 1
|
||||
|
||||
-- cache keys whose volume the Pikachu trim applies to: the PCM clips, plus
|
||||
-- the chip PIKACHU cry that a Yellow cache without extracted clips falls
|
||||
-- back to (playCry). Red/Blue never reach the second branch, so a shared
|
||||
-- options.lua carrying a low pikaVol cannot quiet their Pikachu.
|
||||
local function isPikaKey(key)
|
||||
if type(key) ~= "string" then return false end
|
||||
if key:sub(1, 8) == "pikacry:" then return true end
|
||||
return key == "cry:PIKACHU"
|
||||
and require("src.core.GameVersion").isYellow()
|
||||
end
|
||||
|
||||
local function volumeFor(key)
|
||||
local scale = volumeScale
|
||||
if isPikaKey(key) then scale = scale * pikaScale end
|
||||
return BASE_VOLUME * scale
|
||||
end
|
||||
|
||||
-- Fanfares occupy the music's tone channels on the Game Boy: their sfx
|
||||
-- headers claim channels 5-7 (= hardware channels 1-3), silencing the
|
||||
@@ -91,7 +115,7 @@ local function playPath(data, key, def, pitch, tempo)
|
||||
reportBadDef("sfx", key, owner(data, "sfx", key), err)
|
||||
return nil
|
||||
end
|
||||
s:setVolume(BASE_VOLUME * volumeScale)
|
||||
s:setVolume(volumeFor(key))
|
||||
cache[key] = s
|
||||
src = s
|
||||
end
|
||||
@@ -111,6 +135,9 @@ local function played(kind, name, species)
|
||||
Runtime.emit("sound.played", { kind = kind, name = name, species = species })
|
||||
end
|
||||
|
||||
-- returns the started source (nil headless, or when the def failed to load)
|
||||
-- so callers that block on a fanfare like the original's
|
||||
-- PlaySoundWaitForCurrent -> WaitForSoundToFinish can poll it
|
||||
function Sound.play(data, name)
|
||||
local sfx = data.audio and data.audio.sfx
|
||||
local def = sfx and sfx[name]
|
||||
@@ -120,6 +147,7 @@ function Sound.play(data, name)
|
||||
require("src.core.Music").duckForFanfare(src)
|
||||
end
|
||||
played("sfx", name)
|
||||
return src
|
||||
end
|
||||
|
||||
-- Play a move's sound with its MoveSoundTable pitch/tempo modifiers
|
||||
@@ -211,7 +239,7 @@ function Sound.playPikaCry(data, n)
|
||||
cache[key] = false
|
||||
return nil
|
||||
end
|
||||
s:setVolume(BASE_VOLUME * volumeScale)
|
||||
s:setVolume(volumeFor(key))
|
||||
cache[key] = s
|
||||
src = s
|
||||
end
|
||||
@@ -245,7 +273,7 @@ function Sound.playCry(data, species)
|
||||
owner(data, "cries", species), err)
|
||||
return nil
|
||||
end
|
||||
s:setVolume(BASE_VOLUME * volumeScale)
|
||||
s:setVolume(volumeFor(key))
|
||||
cache[key] = s
|
||||
src = s
|
||||
end
|
||||
@@ -321,7 +349,7 @@ function Sound.startLoop(data, name)
|
||||
return
|
||||
end
|
||||
s:setLooping(true)
|
||||
s:setVolume(BASE_VOLUME * volumeScale)
|
||||
s:setVolume(volumeFor(name))
|
||||
loopCache[name] = s
|
||||
src = s
|
||||
end
|
||||
@@ -345,14 +373,26 @@ end
|
||||
-- 0-7 SFX volume level (0 mutes); cached sources (menu beeps, cries,
|
||||
-- the low-health alarm loop) update immediately so the change is heard
|
||||
-- on the next play
|
||||
local function reapplyVolumes()
|
||||
for key, src in pairs(cache) do
|
||||
if src then pcall(src.setVolume, src, volumeFor(key)) end
|
||||
end
|
||||
for key, src in pairs(loopCache) do
|
||||
if src then pcall(src.setVolume, src, volumeFor(key)) end
|
||||
end
|
||||
end
|
||||
|
||||
function Sound.setVolumeLevel(level)
|
||||
volumeScale = math.max(0, math.min(7, level or 7)) / 7
|
||||
for _, src in pairs(cache) do
|
||||
if src then pcall(src.setVolume, src, BASE_VOLUME * volumeScale) end
|
||||
end
|
||||
for _, src in pairs(loopCache) do
|
||||
if src then pcall(src.setVolume, src, BASE_VOLUME * volumeScale) end
|
||||
end
|
||||
reapplyVolumes()
|
||||
end
|
||||
|
||||
-- 0-7 Pikachu-voice trim on top of the SFX level (7 = no trim, 0 mutes the
|
||||
-- clips while the rest of the SFX bus keeps its level). Yellow only: on
|
||||
-- Red/Blue no cached key answers isPikaKey, so this is inert there.
|
||||
function Sound.setPikaVolumeLevel(level)
|
||||
pikaScale = math.max(0, math.min(7, level or 7)) / 7
|
||||
reapplyVolumes()
|
||||
end
|
||||
|
||||
-- hot reload / jukebox A-B: drop one key's sources (its pitch-tempo
|
||||
@@ -386,6 +426,7 @@ Assets.register(Sound.invalidate)
|
||||
-- loading a save)
|
||||
function Sound.applyOptions(opts)
|
||||
Sound.setVolumeLevel(opts and opts.sfxVol or 7)
|
||||
Sound.setPikaVolumeLevel(opts and opts.pikaVol or 7)
|
||||
end
|
||||
|
||||
return Sound
|
||||
|
||||
+42
-3
@@ -179,6 +179,38 @@ local function resolveUnmount()
|
||||
return physfsUnmountFn
|
||||
end
|
||||
|
||||
-- PHYSFS_getMountPoint, resolved the same way: a non-NULL return means `dir`
|
||||
-- is already somewhere in the search path. withMounted needs it because
|
||||
-- PHYSFS_mount reports success for an already-mounted directory without
|
||||
-- adding a second entry, so its unmount would drop a mount it did not make
|
||||
-- (#413).
|
||||
local physfsMountPointFn = nil
|
||||
local function resolveMountPoint()
|
||||
if physfsMountPointFn ~= nil then return physfsMountPointFn end
|
||||
physfsMountPointFn = false
|
||||
local ok, ffi = pcall(require, "ffi")
|
||||
if not ok then return physfsMountPointFn end
|
||||
pcall(ffi.cdef, "const char *PHYSFS_getMountPoint(const char *dir);")
|
||||
local libs = {
|
||||
function() return ffi.C end,
|
||||
function() return ffi.load("love") end,
|
||||
}
|
||||
for _, getlib in ipairs(libs) do
|
||||
local okl, lib = pcall(getlib)
|
||||
if okl and lib then
|
||||
local oks, fn = pcall(function() return lib.PHYSFS_getMountPoint end)
|
||||
if oks and fn then
|
||||
physfsMountPointFn = function(d)
|
||||
local okr, ret = pcall(fn, d)
|
||||
return okr and ret ~= nil and ret ~= ffi.NULL
|
||||
end
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
return physfsMountPointFn
|
||||
end
|
||||
|
||||
-- The portable game folder when the cache should live there, else nil.
|
||||
-- Resolved (and, for a fused build, mounted) once and cached. Requires a
|
||||
-- desktop portable install (SaveData) and a working windowless mkdir.
|
||||
@@ -398,13 +430,20 @@ end
|
||||
-- resolves -- which is what makes it safe to point at a folder whose contents
|
||||
-- nobody has validated.
|
||||
--
|
||||
-- Returns nil when the mount is unavailable (no ffi, no PHYSFS symbol, or the
|
||||
-- mount was refused), which callers must treat as "could not look", not as
|
||||
-- "nothing there". An error inside fn still unmounts before it propagates.
|
||||
-- Returns nil when the mount is unavailable (no ffi, no PHYSFS symbol, the
|
||||
-- mount was refused, or `dir` is already on the read path), which callers must
|
||||
-- treat as "could not look", not as "nothing there". An error inside fn still
|
||||
-- unmounts before it propagates.
|
||||
function CacheFs.withMounted(dir, mountPoint, fn)
|
||||
if not dir or dir == "" then return nil end
|
||||
local mount, unmount = resolveMount(), resolveUnmount()
|
||||
if not (mount and unmount) then return nil end
|
||||
-- A directory already in the search path cannot be borrowed: PHYSFS_mount
|
||||
-- returns success without adding an entry, and the unmount below would then
|
||||
-- remove the mount somebody else is relying on -- for the portable game
|
||||
-- folder, the one that makes its cache and mods readable at all (#413)
|
||||
local mountedAt = resolveMountPoint()
|
||||
if mountedAt and mountedAt(dir) then return nil end
|
||||
if not mount(dir, mountPoint, true) then return nil end
|
||||
local ok, res = pcall(fn)
|
||||
unmount(dir)
|
||||
|
||||
@@ -1307,6 +1307,16 @@ function RomExtractor:extractTrainers()
|
||||
}
|
||||
self:tick("Trainers", index, #order)
|
||||
end
|
||||
-- Yellow's Jessie & James fight as OPP_ROCKET but carry their own pic
|
||||
-- (home/trainers2.asm IsFightingJessieJames); only Yellow's manifest has
|
||||
-- the symbol, so Red and Blue skip this (#439).
|
||||
if self.symbols.JessieJamesPic then
|
||||
local relative = "battle/trainers/jessie_james.png"
|
||||
self:writeCompressedPic("JessieJamesPic", relative)
|
||||
if out.OPP_ROCKET then
|
||||
out.OPP_ROCKET.picJessieJames = "assets/generated/" .. relative
|
||||
end
|
||||
end
|
||||
self:write("trainers", out)
|
||||
return out
|
||||
end
|
||||
|
||||
+271
-50
@@ -6,7 +6,7 @@ local RomImporter = {}
|
||||
RomImporter.__index = RomImporter
|
||||
|
||||
-- Cache generation tag; bump to force every imported version to re-extract.
|
||||
local CACHE_FORMAT = "rom-cache-v7:"
|
||||
local CACHE_FORMAT = "rom-cache-v8:"
|
||||
-- The completion marker is written under each version's cache prefix
|
||||
-- (rom-cache.complete for Red, blue/rom-cache.complete for Blue).
|
||||
local MARKER_PATH = "rom-cache.complete"
|
||||
@@ -35,6 +35,13 @@ local REQUIRED_FILES = {
|
||||
"assets/generated/audio/programs.bin",
|
||||
}
|
||||
|
||||
-- Files only one version's cache carries. A version that predates one of
|
||||
-- them re-imports on its own, without dragging the other versions through a
|
||||
-- CACHE_FORMAT bump.
|
||||
local VERSION_REQUIRED_FILES = {
|
||||
yellow = { "assets/generated/battle/trainers/jessie_james.png" }, -- #439
|
||||
}
|
||||
|
||||
-- "Split-screen ROM selector" first-run palette (matches FirstRun.dc.html from
|
||||
-- the Claude Design project): a dark neon arcade panel, one column per game.
|
||||
-- Red, Blue, and Yellow share the same importer flow once listed in
|
||||
@@ -99,6 +106,9 @@ local function allRequiredFilesExist(version)
|
||||
for _, path in ipairs(REQUIRED_FILES) do
|
||||
if not CacheFs.exists(path) then ok = false; break end
|
||||
end
|
||||
for _, path in ipairs(ok and VERSION_REQUIRED_FILES[version] or {}) do
|
||||
if not CacheFs.exists(path) then ok = false; break end
|
||||
end
|
||||
CacheFs.prefix = saved
|
||||
return ok
|
||||
end
|
||||
@@ -321,18 +331,46 @@ local function findPendingRom(ready)
|
||||
return nil
|
||||
end
|
||||
|
||||
-- GameActivity always writes the SAF pick to picked_rom.gb, so a leftover
|
||||
-- under that exact basename is the file the player just chose and
|
||||
-- findPendingRom silently refused: wrong size, or a hacked/overdumped cart
|
||||
-- whose SHA-1 matches no known version ([b]/[BF] dumps never will). Route it
|
||||
-- through startData so the launcher says which of the two it was instead of
|
||||
-- staying on "No ROM imported" with no message at all (issue #442), and drop
|
||||
-- the file so the next tap starts from a clean slate. A cart that is simply
|
||||
-- already imported is not an error -- #167 skips it on purpose -- so leave
|
||||
-- that one alone.
|
||||
local function consumePickedRomError(self)
|
||||
local preferred = "picked_rom.gb"
|
||||
if not love.filesystem.getInfo(preferred, "file") then return false end
|
||||
local data = love.filesystem.read(preferred)
|
||||
if type(data) == "string" and #data == 1024 * 1024 then
|
||||
local version = GameVersion.forSha1(sha1(data))
|
||||
if version and self.ready[version] then return false end
|
||||
end
|
||||
love.filesystem.remove(preferred)
|
||||
if type(data) ~= "string" then
|
||||
self:setError("The picked file could not be read. Reopen the picker and "
|
||||
.. "choose the ROM with the Files (Documents) app.")
|
||||
return true
|
||||
end
|
||||
self:startData(data, preferred)
|
||||
return true
|
||||
end
|
||||
|
||||
-- Android SAF writes mod picks to picked_mod.zip; USB copies may use any
|
||||
-- .zip basename at the save-dir root. preferAny=true also accepts those USB
|
||||
-- copies (Choose / Import); focus only consumes the SAF basename so a random
|
||||
-- leftover archive is never auto-installed on every refocus.
|
||||
local function findPendingMod(preferAny)
|
||||
local function findPendingMod(preferAny, skip)
|
||||
local preferred = "picked_mod.zip"
|
||||
if love.filesystem.getInfo(preferred, "file") then
|
||||
return preferred
|
||||
end
|
||||
if not preferAny then return nil end
|
||||
for _, name in ipairs(love.filesystem.getDirectoryItems("")) do
|
||||
if name:lower():match("%.zip$") and love.filesystem.getInfo(name, "file") then
|
||||
if name:lower():match("%.zip$") and not (skip and skip[name])
|
||||
and love.filesystem.getInfo(name, "file") then
|
||||
return name
|
||||
end
|
||||
end
|
||||
@@ -340,20 +378,36 @@ local function findPendingMod(preferAny)
|
||||
end
|
||||
|
||||
-- Same pattern as findPendingMod for battery saves (picked_save.sav / *.sav).
|
||||
local function findPendingSav(preferAny)
|
||||
local function findPendingSav(preferAny, skip)
|
||||
local preferred = "picked_save.sav"
|
||||
if love.filesystem.getInfo(preferred, "file") then
|
||||
return preferred
|
||||
end
|
||||
if not preferAny then return nil end
|
||||
for _, name in ipairs(love.filesystem.getDirectoryItems("")) do
|
||||
if name:lower():match("%.sav$") and love.filesystem.getInfo(name, "file") then
|
||||
if name:lower():match("%.sav$") and not (skip and skip[name])
|
||||
and love.filesystem.getInfo(name, "file") then
|
||||
return name
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Retire an Android pick once it has been through the installer / importer,
|
||||
-- whether or not it worked: a pick left on disk wins the scans above forever,
|
||||
-- so the next tap re-runs the same failing file and the picker never reopens
|
||||
-- (#420). The SAF basename is GameActivity's own copy of the pick and is
|
||||
-- always deleted; a USB copy is the player's file, so a failed one is only
|
||||
-- skipped for the rest of the session.
|
||||
local function consumePick(self, name, safName, ok)
|
||||
if ok or name == safName then
|
||||
love.filesystem.remove(name)
|
||||
return
|
||||
end
|
||||
self.pickSkip = self.pickSkip or {}
|
||||
self.pickSkip[name] = true
|
||||
end
|
||||
|
||||
local function chooseRom(promptName)
|
||||
promptName = promptName or "Pokemon"
|
||||
local prompt = "Choose your " .. promptName .. " ROM"
|
||||
@@ -482,7 +536,12 @@ end
|
||||
-- supplied the Edit label is not drawn at all).
|
||||
function RomImporter.new(onComplete, opts)
|
||||
opts = opts or {}
|
||||
local android = love.system.getOS() == "Android"
|
||||
-- iOS rides the same mobile import flows as Android: the save-dir
|
||||
-- pending-file scan plus love.system.pickFile / createFile, provided
|
||||
-- natively by the Swift GRPickerBridge (mobile/ios/native/). The flag
|
||||
-- keeps its historical name so every Android call site stays untouched.
|
||||
local mobileOS = love.system.getOS()
|
||||
local android = mobileOS == "Android" or mobileOS == "iOS"
|
||||
local CacheFs = require("src.import.CacheFs")
|
||||
local self = setmetatable({
|
||||
onComplete = onComplete,
|
||||
@@ -490,6 +549,11 @@ function RomImporter.new(onComplete, opts)
|
||||
forceImport = opts.forceImport or false,
|
||||
onEditSave = opts.onEditSave,
|
||||
android = android,
|
||||
ios = mobileOS == "iOS",
|
||||
-- One startup poll pass: files dropped through the Files app are swept
|
||||
-- into the save dir before Lua boots (GRBootstrap), but no love.focus
|
||||
-- event necessarily follows, so consume them via the first poll tick.
|
||||
pickPending = mobileOS == "iOS" or nil,
|
||||
-- Android drag: the launcher is handed no move events at all (main.lua
|
||||
-- forwards neither touchmoved nor mousemoved while it is up), and its mouse
|
||||
-- emulation is what "no reliable pointer polling" below refers to.
|
||||
@@ -540,6 +604,7 @@ function RomImporter.new(onComplete, opts)
|
||||
_padCursorActive = false,
|
||||
_padAxis = { leftx = 0, lefty = 0, righty = 0 },
|
||||
_padDir = {},
|
||||
_rawHatDirs = {},
|
||||
_padInited = false,
|
||||
}, RomImporter)
|
||||
|
||||
@@ -568,7 +633,13 @@ function RomImporter.new(onComplete, opts)
|
||||
end
|
||||
if android and needRom then
|
||||
local name, data = findPendingRom(self.ready)
|
||||
if name then self:startData(data, name) end
|
||||
if name then
|
||||
self:startData(data, name)
|
||||
else
|
||||
-- The picker runs as its own activity and Android may kill us while it
|
||||
-- is up, so a rejected pick can outlive the focus handler (#442).
|
||||
consumePickedRomError(self)
|
||||
end
|
||||
end
|
||||
|
||||
-- Mouse-wheel scroll for the save-slot / mods lists. main.lua (off limits)
|
||||
@@ -617,6 +688,11 @@ end
|
||||
-- up without the player needing to tap the button again. Mod and save SAF
|
||||
-- drops (picked_mod.zip / picked_save.sav) are consumed first so a leftover
|
||||
-- ROM pick cannot steal the focus path when both games are already ready.
|
||||
-- NOTE (iOS): do NOT clear pickPending here. The picker's dismissal focus
|
||||
-- event can arrive before the Swift delegate has finished copying the pick
|
||||
-- into the save dir; if this scan runs early and finds nothing, the poll in
|
||||
-- _pollPickedFiles must stay armed so it consumes the file when it lands
|
||||
-- moments later (it clears pickPending itself once something is found).
|
||||
function RomImporter:focus(f)
|
||||
if not (f and self.android and self.workState ~= "working") then return end
|
||||
-- SAF create-document finished: GameActivity wrote export_done.flag.
|
||||
@@ -629,28 +705,53 @@ function RomImporter:focus(f)
|
||||
if self.tab == "mods" then self.tab = version end
|
||||
return
|
||||
end
|
||||
local modName = findPendingMod(false)
|
||||
if modName then
|
||||
self:_installMod(modName)
|
||||
if self.modNotice and self.modNotice.ok then
|
||||
love.filesystem.remove(modName)
|
||||
-- The SAF pick failed inside GameActivity, which wrote pick_error.flag with
|
||||
-- the destination basename in it: some OEM shells (ColorOS) let a third-party
|
||||
-- archive manager win the ACTION_OPEN_DOCUMENT chooser and hand back a URI
|
||||
-- this app has no permission to read, and until #442 that returned to a
|
||||
-- launcher that said nothing at all.
|
||||
local pickError = love.filesystem.getInfo("pick_error.flag", "file")
|
||||
and love.filesystem.read("pick_error.flag")
|
||||
if pickError then
|
||||
love.filesystem.remove("pick_error.flag")
|
||||
local text = "Could not read the picked file. Reopen the picker and choose "
|
||||
.. "it with the Files (Documents) app, or copy it into: "
|
||||
.. love.filesystem.getSaveDirectory()
|
||||
if pickError:find("picked_mod", 1, true) then
|
||||
self.modNotice = { ok = false, text = text }
|
||||
elseif pickError:find("picked_save", 1, true) then
|
||||
local version = self.androidPendingVersion or self:_savedropTarget()
|
||||
self.androidPendingVersion = nil
|
||||
self.saveNotice[version] = { ok = false, text = text }
|
||||
else
|
||||
self:setError(text)
|
||||
end
|
||||
return
|
||||
end
|
||||
local savName = findPendingSav(false)
|
||||
local modName = findPendingMod(false, self.pickSkip)
|
||||
if modName then
|
||||
self:_installMod(modName)
|
||||
consumePick(self, modName, "picked_mod.zip",
|
||||
self.modNotice and self.modNotice.ok)
|
||||
return
|
||||
end
|
||||
local savName = findPendingSav(false, self.pickSkip)
|
||||
if savName then
|
||||
local version = self.androidPendingVersion or self:_savedropTarget()
|
||||
self.androidPendingVersion = nil
|
||||
self:_importSave(version, savName)
|
||||
if self.saveNotice[version] and self.saveNotice[version].ok then
|
||||
love.filesystem.remove(savName)
|
||||
end
|
||||
consumePick(self, savName, "picked_save.sav",
|
||||
self.saveNotice[version] and self.saveNotice[version].ok)
|
||||
return
|
||||
end
|
||||
for _, v in ipairs(GameVersion.ORDER) do
|
||||
if not self.ready[v] then
|
||||
local name, data = findPendingRom(self.ready)
|
||||
if name then self:startData(data, name) end
|
||||
if name then
|
||||
self:startData(data, name)
|
||||
else
|
||||
consumePickedRomError(self)
|
||||
end
|
||||
return
|
||||
end
|
||||
end
|
||||
@@ -698,8 +799,9 @@ function RomImporter:startData(data, displayName)
|
||||
local actualHash = sha1(data)
|
||||
local version = GameVersion.forSha1(actualHash)
|
||||
if not version then
|
||||
self:setError(("Unsupported ROM (SHA-1 %s). Use an unmodified US Pokemon "
|
||||
.. "Red, Blue, or Yellow ROM."):format(actualHash))
|
||||
self:setError(("Unsupported ROM (SHA-1 %s). This needs a clean US Pokemon "
|
||||
.. "Red, Blue, or Yellow dump; patched, trimmed or \"fixed\" dumps "
|
||||
.. "(tagged [b] or [BF]) never verify."):format(actualHash))
|
||||
return
|
||||
end
|
||||
local info = GameVersion.info(version)
|
||||
@@ -849,17 +951,19 @@ end
|
||||
function RomImporter:chooseMod()
|
||||
if self.workState == "working" then return end
|
||||
if self.android then
|
||||
local name = findPendingMod(true)
|
||||
local name = findPendingMod(true, self.pickSkip)
|
||||
if name then
|
||||
self:_installMod(name)
|
||||
if self.modNotice and self.modNotice.ok then
|
||||
love.filesystem.remove(name)
|
||||
end
|
||||
consumePick(self, name, "picked_mod.zip",
|
||||
self.modNotice and self.modNotice.ok)
|
||||
return
|
||||
end
|
||||
if not love.system.pickFile("mod") then
|
||||
self.modNotice = { ok = false,
|
||||
text = "Could not open the file picker. Copy a mod .zip via USB." }
|
||||
else
|
||||
self.pickPending = true
|
||||
self.pickTimer = 0
|
||||
end
|
||||
return
|
||||
end
|
||||
@@ -908,13 +1012,12 @@ end
|
||||
function RomImporter:chooseSaveImport(version)
|
||||
if self.workState == "working" then return end
|
||||
if self.android then
|
||||
local name = findPendingSav(true)
|
||||
local name = findPendingSav(true, self.pickSkip)
|
||||
if name then
|
||||
self.androidPendingVersion = version
|
||||
self:_importSave(version, name)
|
||||
if self.saveNotice[version] and self.saveNotice[version].ok then
|
||||
love.filesystem.remove(name)
|
||||
end
|
||||
consumePick(self, name, "picked_save.sav",
|
||||
self.saveNotice[version] and self.saveNotice[version].ok)
|
||||
return
|
||||
end
|
||||
self.androidPendingVersion = version
|
||||
@@ -922,6 +1025,9 @@ function RomImporter:chooseSaveImport(version)
|
||||
self.androidPendingVersion = nil
|
||||
self.saveNotice[version] = { ok = false,
|
||||
text = "Could not open the file picker. Copy a .sav via USB." }
|
||||
else
|
||||
self.pickPending = true
|
||||
self.pickTimer = 0
|
||||
end
|
||||
return
|
||||
end
|
||||
@@ -958,6 +1064,8 @@ function RomImporter:exportSave(version)
|
||||
end
|
||||
self.androidPendingExportVersion = version
|
||||
if love.system.createFile and love.system.createFile(suggested) then
|
||||
self.pickPending = true
|
||||
self.pickTimer = 0
|
||||
self.saveNotice[version] = { ok = true,
|
||||
text = "Pick where to save " .. suggested .. "..." }
|
||||
else
|
||||
@@ -999,6 +1107,8 @@ function RomImporter:choose(version)
|
||||
local name, data = findPendingRom(self.ready)
|
||||
if name then
|
||||
self:startData(data, name)
|
||||
elseif consumePickedRomError(self) then
|
||||
return -- a rejected pick explains itself instead of silently reopening
|
||||
elseif not love.system.pickFile() then
|
||||
-- Picker unavailable (API < 19, or no document-picker app installed):
|
||||
-- fall back to the USB folder-drop path as a friendly notice, not an
|
||||
@@ -1008,6 +1118,9 @@ function RomImporter:choose(version)
|
||||
status = "No picker available, copy your ROM into:",
|
||||
detail = love.filesystem.getSaveDirectory(),
|
||||
}
|
||||
else
|
||||
self.pickPending = true
|
||||
self.pickTimer = 0
|
||||
end
|
||||
return
|
||||
end
|
||||
@@ -1042,9 +1155,49 @@ function RomImporter:choose(version)
|
||||
end
|
||||
end
|
||||
|
||||
-- iOS: the document picker is an in-process modal sheet, so unlike Android's
|
||||
-- separate SAF activity there is no love.focus(true) when it dismisses.
|
||||
-- While a pick is outstanding, poll the save dir for the bridge's delivered
|
||||
-- file (picked_rom.gb / picked_mod.zip / picked_save.sav / export_done.flag)
|
||||
-- and run the same refocus import path Android uses.
|
||||
function RomImporter:_pollPickedFiles(dt)
|
||||
if not (self.ios and self.pickPending) then return end
|
||||
if self.workState == "working" then return end
|
||||
self.pickTimer = (self.pickTimer or 0) + dt
|
||||
if self.pickTimer < 0.5 then return end
|
||||
self.pickTimer = 0
|
||||
-- The Swift bridge reports a failed pick copy through pick_error.txt;
|
||||
-- surface it on whichever tab the player is looking at rather than
|
||||
-- letting the pick silently do nothing.
|
||||
local pickError = love.filesystem.read("pick_error.txt")
|
||||
if pickError then
|
||||
love.filesystem.remove("pick_error.txt")
|
||||
self.pickPending = nil
|
||||
self.modNotice = { ok = false, text = pickError }
|
||||
self.notice = { version = self.chooseVersion or "red",
|
||||
status = "File import failed:", detail = pickError }
|
||||
return
|
||||
end
|
||||
local found = love.filesystem.getInfo("export_done.flag", "file") ~= nil
|
||||
if not found then
|
||||
for _, name in ipairs(love.filesystem.getDirectoryItems("")) do
|
||||
local n = name:lower()
|
||||
if n:match("%.gbc?$") or n == "picked_mod.zip" or n == "picked_save.sav" then
|
||||
found = true
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
if found then
|
||||
self.pickPending = nil
|
||||
self:focus(true)
|
||||
end
|
||||
end
|
||||
|
||||
function RomImporter:update(dt)
|
||||
self.pulse = self.pulse + dt
|
||||
self:_updatePadCursor(dt)
|
||||
self:_pollPickedFiles(dt)
|
||||
if self.workState ~= "working" or not self.worker then return end
|
||||
local started = love.timer.getTime()
|
||||
repeat
|
||||
@@ -1184,6 +1337,36 @@ function RomImporter:gamepadaxis(_, axis, value)
|
||||
end
|
||||
end
|
||||
|
||||
function RomImporter:joystickpressed(joystick, button)
|
||||
if button == 1 then self:gamepadpressed(joystick, "a") end
|
||||
end
|
||||
|
||||
function RomImporter:joystickreleased(joystick, button)
|
||||
if button == 1 then self:gamepadreleased(joystick, "a") end
|
||||
end
|
||||
|
||||
function RomImporter:joystickaxis(joystick, axis, value)
|
||||
if axis == 1 then
|
||||
self:gamepadaxis(joystick, "leftx", value)
|
||||
elseif axis == 2 then
|
||||
self:gamepadaxis(joystick, "lefty", value)
|
||||
end
|
||||
end
|
||||
|
||||
function RomImporter:joystickhat(_, hat, direction)
|
||||
for _, dir in ipairs(self._rawHatDirs[hat] or {}) do
|
||||
self._padDir[dir] = nil
|
||||
end
|
||||
local dirs = ({
|
||||
u = { "dpup" }, d = { "dpdown" }, l = { "dpleft" }, r = { "dpright" },
|
||||
lu = { "dpleft", "dpup" }, ru = { "dpright", "dpup" },
|
||||
ld = { "dpleft", "dpdown" }, rd = { "dpright", "dpdown" },
|
||||
})[direction] or {}
|
||||
for _, dir in ipairs(dirs) do self._padDir[dir] = true end
|
||||
self._rawHatDirs[hat] = dirs
|
||||
if #dirs > 0 then self:_activatePadCursor() end
|
||||
end
|
||||
|
||||
-- Player pressed Play on a game whose ROM is imported: hand off to boot.
|
||||
function RomImporter:play(version)
|
||||
if self.workState == "working" then return end
|
||||
@@ -1415,6 +1598,32 @@ function RomImporter.pageScrollFor(naturalH, viewportH, scroll)
|
||||
return maxPage > 0, clamp(scroll or 0, 0, maxPage), maxPage
|
||||
end
|
||||
|
||||
-- Every hit rect mousepressed dispatches on, cleared before any panel draws:
|
||||
-- each rect is rebuilt only by the panel that draws its control, so whatever a
|
||||
-- frame does not draw must not stay clickable. Missing the Delete rects here
|
||||
-- let a click on the mods tab land on the game tab's save Delete label (#433).
|
||||
function RomImporter:_resetFrameRects()
|
||||
self.romButtonRect = nil
|
||||
self.playButtonRect = nil
|
||||
self.tabRects = {}
|
||||
-- Rebuilt only by the active version's SAVE SLOT panel, so the mods tab (or a
|
||||
-- version with no panel drawn this frame) cannot inherit last frame's rows.
|
||||
self.slotRects = nil
|
||||
self.slotEditRects = nil
|
||||
self.slotDeleteRects = nil
|
||||
self.newSlotRect = nil
|
||||
-- Rebuilt only by the mods panel; nil elsewhere so a game tab cannot inherit
|
||||
-- last frame's mod toggles / Delete labels / import button.
|
||||
self.modRects = nil
|
||||
self.modDeleteRects = nil
|
||||
self.modImportRect = nil
|
||||
-- Rebuilt only by the active game panel's SAVE FILES card; nil elsewhere so
|
||||
-- the mods tab cannot inherit last frame's save Import/Export/open-folder hits.
|
||||
self.saveImportRect = nil
|
||||
self.saveExportRect = nil
|
||||
self.saveFolderRect = nil
|
||||
end
|
||||
|
||||
function RomImporter:draw()
|
||||
local width, height = love.graphics.getDimensions()
|
||||
local s = clamp(height / 768, 0.7, 1.6)
|
||||
@@ -1433,23 +1642,7 @@ function RomImporter:draw()
|
||||
end
|
||||
self._hoverEnabled = self._padCursorActive or not self.android
|
||||
self._anyHover = false
|
||||
self.romButtonRect = nil
|
||||
self.playButtonRect = nil
|
||||
self.tabRects = {}
|
||||
-- Rebuilt only by the active version's SAVE SLOT panel, so the mods tab (or a
|
||||
-- version with no panel drawn this frame) cannot inherit last frame's rows.
|
||||
self.slotRects = nil
|
||||
self.slotEditRects = nil
|
||||
self.newSlotRect = nil
|
||||
-- Rebuilt only by the mods panel; nil elsewhere so a game tab cannot inherit
|
||||
-- last frame's mod toggles / import button.
|
||||
self.modRects = nil
|
||||
self.modImportRect = nil
|
||||
-- Rebuilt only by the active game panel's SAVE FILES card; nil elsewhere so
|
||||
-- the mods tab cannot inherit last frame's save Import/Export/open-folder hits.
|
||||
self.saveImportRect = nil
|
||||
self.saveExportRect = nil
|
||||
self.saveFolderRect = nil
|
||||
self:_resetFrameRects()
|
||||
|
||||
-- Fonts + size-dependent scenery, rebuilt only when the window size changes.
|
||||
local fontKey = ("%dx%d"):format(width, height)
|
||||
@@ -1936,6 +2129,16 @@ local function inside(r, x, y)
|
||||
return true
|
||||
end
|
||||
|
||||
-- A Delete label is armed by one click and commits on a second one on the same
|
||||
-- target; it disarms on any other press and after this many seconds, because
|
||||
-- nothing in the launcher can undo a delete (#433).
|
||||
local DELETE_CONFIRM_SECONDS = 4
|
||||
|
||||
local function armedDelete(a, kind, id, version)
|
||||
return a ~= nil and a.kind == kind and a.id == id and a.version == version
|
||||
and (love.timer.getTime() - a.t) <= DELETE_CONFIRM_SECONDS
|
||||
end
|
||||
|
||||
function RomImporter:mousepressed(x, y, button)
|
||||
if self._rename then return end -- the rename modal swallows all clicks
|
||||
-- Whether a press can be ARMED and resolved on release, which needs a
|
||||
@@ -1955,6 +2158,10 @@ function RomImporter:mousepressed(x, y, button)
|
||||
return
|
||||
end
|
||||
if button ~= 1 then return end
|
||||
-- Any press that is not the second click on an armed Delete disarms it, so
|
||||
-- take the arm off self up front and let the Delete loops below re-arm.
|
||||
local armed = self._confirmDelete
|
||||
self._confirmDelete = nil
|
||||
if inside(self.bcgButton, x, y) or inside(self.linkUrlRect, x, y) then
|
||||
love.system.openURL(COMMUNITY_URL)
|
||||
return
|
||||
@@ -2019,7 +2226,12 @@ function RomImporter:mousepressed(x, y, button)
|
||||
-- targets, no scroll conflict).
|
||||
for _, r in ipairs(self.slotDeleteRects or {}) do
|
||||
if inside(r, x, y) then
|
||||
self:_deleteSlot(self.panelVersion, r.id)
|
||||
if armedDelete(armed, "slot", r.id, self.panelVersion) then
|
||||
self:_deleteSlot(self.panelVersion, r.id)
|
||||
else
|
||||
self._confirmDelete = { kind = "slot", id = r.id,
|
||||
version = self.panelVersion, t = love.timer.getTime() }
|
||||
end
|
||||
return
|
||||
end
|
||||
end
|
||||
@@ -2053,7 +2265,11 @@ function RomImporter:mousepressed(x, y, button)
|
||||
end
|
||||
for _, r in ipairs(self.modDeleteRects or {}) do
|
||||
if inside(r, x, y) then
|
||||
self:_deleteMod(r.id)
|
||||
if armedDelete(armed, "mod", r.id, nil) then
|
||||
self:_deleteMod(r.id)
|
||||
else
|
||||
self._confirmDelete = { kind = "mod", id = r.id, t = love.timer.getTime() }
|
||||
end
|
||||
return
|
||||
end
|
||||
end
|
||||
@@ -2792,8 +3008,11 @@ function RomImporter:_drawSaveSlotPanel(version, x, y, w, h, paged)
|
||||
local drect = { x = delX - 6 * s, y = delY - 4 * s,
|
||||
width = delW + 12 * s, height = delH + 8 * s, id = slot.id }
|
||||
local dhot = self:_hover(drect)
|
||||
col(dhot and PAL.red or PAL.warning)
|
||||
love.graphics.print(delText, delX, delY)
|
||||
-- Armed by a first click, the label asks before a second one commits;
|
||||
-- the box stays sized to "Delete" so the row never reflows (#433).
|
||||
local darmed = armedDelete(self._confirmDelete, "slot", slot.id, version)
|
||||
col((darmed or dhot) and PAL.red or PAL.warning)
|
||||
love.graphics.printf(darmed and "Sure?" or delText, delX, delY, delW, "center")
|
||||
local rightReserve = delW + 18 * s
|
||||
|
||||
-- Edit label, immediately left of Delete: opens the bundled save
|
||||
@@ -3150,8 +3369,10 @@ function RomImporter:_drawModsPanel(x, y, w, h, paged)
|
||||
width = L.delW + 12 * s, height = delH + 4 * s, id = m.id }
|
||||
local dhot = self:_hover(drect)
|
||||
love.graphics.setFont(self.hintFont)
|
||||
col(dhot and PAL.red or PAL.warning)
|
||||
love.graphics.print("Delete", delX, delY)
|
||||
-- Same two-click arm as a save slot's Delete (#433).
|
||||
local darmed = armedDelete(self._confirmDelete, "mod", m.id, nil)
|
||||
col((darmed or dhot) and PAL.red or PAL.warning)
|
||||
love.graphics.printf(darmed and "Sure?" or "Delete", delX, delY, L.delW, "center")
|
||||
|
||||
-- hit rects clipped to the visible list band
|
||||
local vy = math.max(trect.y, top)
|
||||
|
||||
@@ -75,7 +75,10 @@ function SaveFileIO.importToSlot(source, version)
|
||||
return false, ("A save file must be %d bytes (32 KB); this one is %d.")
|
||||
:format(SAVE_SIZE, #bytes)
|
||||
end
|
||||
local save, convertErr = SaveConvert.importSav(bytes, version)
|
||||
-- 3rd arg: the crosswalk has to come from THIS game's ROM cache. The
|
||||
-- launcher imports before the cache is mounted on the un-prefixed paths, so
|
||||
-- SaveConvert cannot find the generated tables by itself here (#420).
|
||||
local save, convertErr = SaveConvert.importSav(bytes, version, version)
|
||||
if not save then return false, convertErr end
|
||||
-- Tag the game version and normalize the meta stamp: SaveConvert leaves
|
||||
-- meta.format = "gen1_import", but SaveData.load's migration pass compares
|
||||
@@ -103,7 +106,7 @@ function SaveFileIO.exportActiveSlot(version)
|
||||
version = version or GameVersion.get()
|
||||
local save = SaveData.load(version)
|
||||
if not save then return false, "this game has no save to export yet" end
|
||||
local bytes, exportErr = SaveConvert.exportSav(save)
|
||||
local bytes, exportErr = SaveConvert.exportSav(save, version)
|
||||
if not bytes then return false, exportErr end
|
||||
local slotId = SaveData.activeSlot(version) or "save"
|
||||
local fs = love and love.filesystem
|
||||
|
||||
@@ -168,6 +168,21 @@ function LauncherMods.pickStrays(found, installed)
|
||||
return out
|
||||
end
|
||||
|
||||
-- isReadableRoot(folder, source, cacheRoot) -> is folder already on the
|
||||
-- physfs read path, pure. source is love.filesystem.getSource(), cacheRoot
|
||||
-- the mounted portable game folder (CacheFs.root()); a fused build has both,
|
||||
-- and they are different paths (the archive inside the executable vs the
|
||||
-- folder beside it). Either one's mods/ is readable already, so nothing in
|
||||
-- it is a stray -- and the portable folder must additionally never be handed
|
||||
-- to CacheFs.withMounted: PHYSFS_mount reports success for a directory
|
||||
-- already in the search path WITHOUT adding a second entry, so the paired
|
||||
-- unmount tears down the one real mount and the panel loses every mod in the
|
||||
-- game folder (#413).
|
||||
function LauncherMods.isReadableRoot(folder, source, cacheRoot)
|
||||
if not folder or folder == "" then return false end
|
||||
return folder == source or folder == cacheRoot
|
||||
end
|
||||
|
||||
-- ------- discovery (love.filesystem)
|
||||
|
||||
local function decodeManifest(raw, path)
|
||||
@@ -349,17 +364,20 @@ end
|
||||
local STRAY_MOUNT = "stray_scan"
|
||||
|
||||
-- Run fn(mountedModsRoot) for each game folder that has a readable mods/
|
||||
-- directory, one mount at a time. Folders that are already the physfs source
|
||||
-- are skipped: their mods/ is discoverable by definition, so anything there is
|
||||
-- installed already and not a stray (this is every `love <gamedir>` dev run).
|
||||
-- directory, one mount at a time. Folders already on the read path are
|
||||
-- skipped (isReadableRoot): the physfs source, which is every `love <gamedir>`
|
||||
-- dev run, and the portable game folder CacheFs mounted, where re-mounting is
|
||||
-- what used to drop the mount (#413).
|
||||
local function eachStrayRoot(fn)
|
||||
local SaveData_ = require("src.core.SaveData")
|
||||
local fs = love and love.filesystem
|
||||
if not fs then return end
|
||||
local source = fs.getSource and fs.getSource()
|
||||
local cacheRoot = CacheFs.root()
|
||||
local seen = {}
|
||||
for _, folder in ipairs(SaveData_.gameFolders() or {}) do
|
||||
if not seen[folder] and folder ~= source then
|
||||
if not seen[folder]
|
||||
and not LauncherMods.isReadableRoot(folder, source, cacheRoot) then
|
||||
seen[folder] = true
|
||||
CacheFs.withMounted(folder, STRAY_MOUNT, function()
|
||||
local root = STRAY_MOUNT .. "/mods"
|
||||
|
||||
@@ -428,6 +428,7 @@ R.pokemon = {
|
||||
spriteFront = f.path, spriteBack = f.path, frontSize = f.int(1, 7),
|
||||
dexEntry = f.opt(f.rec{ kind = f.str, heightFt = f.int(0),
|
||||
heightIn = f.int(0, 11), weight = f.num,
|
||||
heightM = f.opt(f.num), weightKg = f.opt(f.num),
|
||||
text = f.str }),
|
||||
icon = f.opt(f.union{ f.str, f.rec{ image = f.path,
|
||||
frames = f.opt(f.int(1)) } }),
|
||||
|
||||
@@ -35,6 +35,48 @@ PaletteFX.MODE_LABELS = {
|
||||
}
|
||||
PaletteFX.mode = "gbc"
|
||||
|
||||
-- ------- dark-cave state (wMapPalOffset)
|
||||
--
|
||||
-- Unlike the per-frame shadeMap further down, this outlives a frame: ADVANCED
|
||||
-- resolves real colour per tile and BAKES it (tileset atlas, sprite sheets),
|
||||
-- so FadePal2's shift has to reach those bakes and their cache keys rather
|
||||
-- than a shader (#383). OverworldState owns it: armed before the map's atlas
|
||||
-- is built, cleared by FLASH.
|
||||
local darkWorld = false
|
||||
|
||||
-- returns true when the flag actually changed, so the caller can rebuild
|
||||
function PaletteFX.setDarkWorld(on)
|
||||
on = on and true or false
|
||||
if darkWorld == on then return false end
|
||||
darkWorld = on
|
||||
return true
|
||||
end
|
||||
|
||||
function PaletteFX.darkWorld() return darkWorld end
|
||||
|
||||
-- cache-key suffix for anything baked under the dark shift
|
||||
function PaletteFX.darkKey() return darkWorld and "#dark" or "" end
|
||||
|
||||
-- FadePal2 sets rOBP0 = `dc 3,3,3,2` as well as rBGP, so EVERY OBJ colour a
|
||||
-- sprite can carry lands on shade 3: the player, trainers and item balls are
|
||||
-- black silhouettes until FLASH (#383). Applied to whatever 4-colour OBP the
|
||||
-- active mode resolved, with a distinct cache group so the lit and dark bakes
|
||||
-- of one sheet never collide in SpriteRenderer's obpCache.
|
||||
function PaletteFX.darkObp(colors, group)
|
||||
if not (colors and darkWorld) then return colors, group end
|
||||
return PaletteFX.permute(colors, PaletteFX.DARK_BGP), tostring(group) .. "dark"
|
||||
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 out = {}
|
||||
for i = 1, #groups do
|
||||
out[i] = PaletteFX.permute(groups[i], PaletteFX.DARK_BGP)
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
-- Classic DMG pea-soup greens (#9BBC0F / #8BAC0F / #306230 / #0F380F)
|
||||
PaletteFX.CLASSIC = {
|
||||
{ 155, 188, 15 }, { 139, 172, 15 }, { 48, 98, 48 }, { 15, 56, 15 },
|
||||
@@ -93,9 +135,13 @@ end
|
||||
-- Red bake with a Blue one and one version would show the other's colors.
|
||||
-- Yellow: same Red OBJ green as above until Yellow-specific tables land.
|
||||
function PaletteFX.ogObj()
|
||||
if GameVersion.isBlue() then return PaletteFX.GBC_OBJ_BLUE, "gbcobj_blue" end
|
||||
if GameVersion.isYellow() then return PaletteFX.GBC_OBJ, "gbcobj" end
|
||||
return PaletteFX.GBC_OBJ, "gbcobj"
|
||||
if GameVersion.isBlue() then
|
||||
return PaletteFX.darkObp(PaletteFX.GBC_OBJ_BLUE, "gbcobj_blue")
|
||||
end
|
||||
if GameVersion.isYellow() then
|
||||
return PaletteFX.darkObp(PaletteFX.GBC_OBJ, "gbcobj")
|
||||
end
|
||||
return PaletteFX.darkObp(PaletteFX.GBC_OBJ, "gbcobj")
|
||||
end
|
||||
|
||||
-- The DMG object ramp every mode except OG RED bakes onto overworld sprites,
|
||||
@@ -114,7 +160,7 @@ PaletteFX.OBP0_SHADES = {
|
||||
}
|
||||
|
||||
function PaletteFX.dmgObj()
|
||||
return PaletteFX.OBP0_SHADES, "obp0"
|
||||
return PaletteFX.darkObp(PaletteFX.OBP0_SHADES, "obp0")
|
||||
end
|
||||
|
||||
local INV_MAP = { [0] = 3, [1] = 2, [2] = 1, [3] = 0 }
|
||||
@@ -503,7 +549,7 @@ function PaletteFX.worldGroupColors(data, tileset, mapId, playerCellY)
|
||||
local w = pack and pack.world
|
||||
local base = w and w.groupColors[tileset]
|
||||
if not base then return nil end
|
||||
if not w.roofGroup[tileset] then return base end
|
||||
if not w.roofGroup[tileset] then return darkGroups(base) end
|
||||
local roofMapId = mapId
|
||||
if mapId == ROUTE_6_SAFFRON.mapId and playerCellY
|
||||
and playerCellY < ROUTE_6_SAFFRON.cellYBelow then
|
||||
@@ -511,7 +557,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 base end
|
||||
if not roof then return darkGroups(base) end
|
||||
local out = {}
|
||||
for i = 1, 8 do out[i] = base[i] end
|
||||
-- LoadTownPalette only overwrites W2_BgPaletteData + $32, i.e. colors 1
|
||||
@@ -521,7 +567,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 out
|
||||
return darkGroups(out)
|
||||
end
|
||||
|
||||
-- an overworld sprite's resolved 4-color OBJ palette (ColorOverworldSprite),
|
||||
@@ -552,7 +598,7 @@ function PaletteFX.spriteObp(spriteDef, seed)
|
||||
for i = 1, #seed do h = (h * 31 + seed:byte(i)) % 4294967296 end
|
||||
group = h % 4
|
||||
end
|
||||
return w.spritePalettes[group], group
|
||||
return PaletteFX.darkObp(w.spritePalettes[group], group)
|
||||
end
|
||||
|
||||
-- GetHealthBarColor (home/palettes.asm) on the standard 48px bar
|
||||
|
||||
+18
-4
@@ -437,12 +437,26 @@ function Renderer:drawTiltedWorld(zoneList, sx, sy, wox, woy, target)
|
||||
return true
|
||||
end
|
||||
|
||||
-- clamp a scissor rect to the viewport box
|
||||
local function scissorClamped(x, y, w, h, ox, oy, vpw, vph)
|
||||
-- Clamp a scissor rect to the viewport box, then round it outward to whole
|
||||
-- framebuffer pixels. love.graphics.setScissor truncates x, y, w and h to
|
||||
-- pixels independently, so a rect with fractional unit edges (Android's
|
||||
-- non-integer DPI puts fitScale/dpi in Sx/Sy) loses up to a pixel per side
|
||||
-- and two adjacent SGB zones stop sharing an edge: the letterbox clear shows
|
||||
-- through as a horizontal seam at every zone boundary (#373). Rounding
|
||||
-- outward makes neighbours overlap by at most one row instead -- the overlap
|
||||
-- redraws the same canvas pixels one palette later, and past the canvas edge
|
||||
-- there is nothing to draw. The half pixel keeps LOVE's truncation on the
|
||||
-- snapped edge rather than one short of it.
|
||||
local function scissorClamped(x, y, w, h, ox, oy, vpw, vph, dpiX, dpiY)
|
||||
local x2, y2 = math.min(x + w, ox + vpw), math.min(y + h, oy + vph)
|
||||
x, y = math.max(x, ox), math.max(y, oy)
|
||||
if x2 <= x or y2 <= y then return false end
|
||||
love.graphics.setScissor(x, y, x2 - x, y2 - y)
|
||||
dpiX, dpiY = dpiX or 1, dpiY or 1
|
||||
local px1, py1 = math.floor(x * dpiX), math.floor(y * dpiY)
|
||||
local px2, py2 = math.ceil(x2 * dpiX), math.ceil(y2 * dpiY)
|
||||
love.graphics.setScissor((px1 + 0.5) / dpiX, (py1 + 0.5) / dpiY,
|
||||
(px2 - px1 + 0.5) / dpiX,
|
||||
(py2 - py1 + 0.5) / dpiY)
|
||||
return true
|
||||
end
|
||||
|
||||
@@ -572,7 +586,7 @@ function Renderer:endFrame(zones, worldZones)
|
||||
if not plain then PaletteFX.sendColors(shader, z.colors) end
|
||||
if scissorClamped(bx + z.x * zoneSx, by + z.y * zoneSy,
|
||||
z.w * zoneSx, z.h * zoneSy,
|
||||
boxX, boxY, boxW, boxH) then
|
||||
boxX, boxY, boxW, boxH, dpiX, dpiY) then
|
||||
love.graphics.draw(canvas, bx, by, 0, sx, sy)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -135,7 +135,10 @@ local function blitFrame(image, quad, x, y, flip, redraw)
|
||||
end
|
||||
end
|
||||
|
||||
function SpriteRenderer:draw(px, py, camX, camY, facing, walkPhase, stepFlip)
|
||||
-- topHalf blits only the upper 8 rows of the frame: FishingAnim overwrites the
|
||||
-- bottom tile row of the standing frames with the fishing pose art, which the
|
||||
-- caller then draws itself through :drawTile (Player:draw, #384)
|
||||
function SpriteRenderer:draw(px, py, camX, camY, facing, walkPhase, stepFlip, topHalf)
|
||||
local x = math.floor(px - camX)
|
||||
local y = math.floor(py - camY) - 4
|
||||
local image = self.image
|
||||
@@ -190,7 +193,39 @@ function SpriteRenderer:draw(px, py, camX, camY, facing, walkPhase, stepFlip)
|
||||
flip = true
|
||||
end
|
||||
local quad = self.frames[frame] or self.frames[0]
|
||||
if topHalf then
|
||||
self.halfFrames = self.halfFrames or {}
|
||||
if not self.halfFrames[frame] then
|
||||
local iw, ih = self.image:getDimensions()
|
||||
self.halfFrames[frame] = love.graphics.newQuad(0, frame * 16, 16, 8, iw, ih)
|
||||
end
|
||||
quad = self.halfFrames[frame]
|
||||
end
|
||||
blitFrame(image, quad, x, y, flip, redraw)
|
||||
end
|
||||
|
||||
-- Blit a loose 16-wide fx tile at screen (x, y) wearing THIS sprite's OBJ
|
||||
-- palette, mirroring the mode branches in :draw above. The fishing pose row
|
||||
-- overwrites the sheet's own tiles in VRAM in the original, so it has to be
|
||||
-- recolored and OG-RED-redrawn exactly like the sheet rather than blitted as
|
||||
-- raw DMG shades (#384).
|
||||
function SpriteRenderer:drawTile(path, x, y, flip)
|
||||
local image, redraw = getImage(path), false
|
||||
if self.def.trueColor then
|
||||
PaletteFX.markTrueColor(x, y, 16, 8)
|
||||
elseif PaletteFX.usesGbcPack() then
|
||||
local colors, group = PaletteFX.spriteObp(self.def, self.seed)
|
||||
if colors then image = getObpImage(path, colors, group) end
|
||||
elseif PaletteFX.usesSpriteObp() and PaletteFX.spriteRedrawPassActive() then
|
||||
image, redraw = getObpImage(path, PaletteFX.ogObj()), true
|
||||
else
|
||||
image = getObpImage(path, PaletteFX.dmgObj())
|
||||
end
|
||||
local iw, ih = image:getDimensions()
|
||||
self.tileQuads = self.tileQuads or {}
|
||||
self.tileQuads[path] = self.tileQuads[path]
|
||||
or love.graphics.newQuad(0, 0, iw, ih, iw, ih)
|
||||
blitFrame(image, self.tileQuads[path], x, y, flip, redraw)
|
||||
end
|
||||
|
||||
return SpriteRenderer
|
||||
|
||||
@@ -402,8 +402,15 @@ end
|
||||
-- the route's own default roof (Vermilion's) throughout.
|
||||
local gbcAtlasCache = {}
|
||||
|
||||
-- Cache suffix for a map's RED++ bake. A dark cave folds FadePal2 into the
|
||||
-- palette worldGroupColors hands the bake (#383), so the lit and dark bakes of
|
||||
-- one map are different images and must not share a key.
|
||||
local function gbcKeyFor(mapId)
|
||||
return "#gbc:" .. mapId .. PaletteFX.darkKey()
|
||||
end
|
||||
|
||||
local function getGbcAtlas(imagePath, tilesetId, mapId, perRow, data)
|
||||
local key = imagePath .. "#gbc:" .. mapId
|
||||
local key = imagePath .. gbcKeyFor(mapId)
|
||||
if gbcAtlasCache[key] ~= nil then return gbcAtlasCache[key] or nil end
|
||||
local img = false
|
||||
if love.image and love.image.newImageData then
|
||||
@@ -473,7 +480,7 @@ function TileRenderer.new(map, data)
|
||||
self.gbcAtlas = true
|
||||
-- also recolors the animated water/flower entries below, so they
|
||||
-- match the atlas's static tiles instead of showing raw grayscale
|
||||
gbcCtx = { tilesetId = map.tileset.id, mapId = map.id, key = "#gbc:" .. map.id,
|
||||
gbcCtx = { tilesetId = map.tileset.id, mapId = map.id, key = gbcKeyFor(map.id),
|
||||
groupColors = PaletteFX.worldGroupColors(data, map.tileset.id, map.id, nil) }
|
||||
-- ...and feeds the color-0-keyed single tiles the feet overdraw needs
|
||||
-- (see getKeyedTile): same source image and palette groups, so keep the
|
||||
@@ -481,6 +488,7 @@ function TileRenderer.new(map, data)
|
||||
gbcCtx.imagePath = map.tileset.image
|
||||
gbcCtx.perRow = map.tileset.tilesPerRow
|
||||
self.gbcCtx = gbcCtx
|
||||
self.gbcAtlasKey = map.tileset.image .. gbcCtx.key
|
||||
self.gbcKeyed = {}
|
||||
end
|
||||
end
|
||||
@@ -582,7 +590,7 @@ local function ensureWaterBorderFill(self)
|
||||
local groupColors = PaletteFX.worldGroupColors(
|
||||
self.data, map.tileset.id, map.id, nil)
|
||||
colors = group and groupColors and groupColors[group + 1] or nil
|
||||
gbcKey = "#gbc:" .. map.id
|
||||
gbcKey = gbcKeyFor(map.id)
|
||||
end
|
||||
local textures = getShiftVariants(map.tileset.image, perRow, WATER_TILE,
|
||||
colors, gbcKey)
|
||||
@@ -913,8 +921,9 @@ function TileRenderer:release()
|
||||
self.gbcCtx = nil
|
||||
end
|
||||
if self.gbcAtlas and self.image then
|
||||
local key = self.map.tileset.image .. "#gbc:" .. self.map.id
|
||||
local key = self.gbcAtlasKey or (self.map.tileset.image .. gbcKeyFor(self.map.id))
|
||||
if gbcAtlasCache[key] == self.image then gbcAtlasCache[key] = nil end
|
||||
self.gbcAtlasKey = nil
|
||||
safeRelease(self.image)
|
||||
self.image = nil
|
||||
self.gbcAtlas = nil
|
||||
|
||||
@@ -84,6 +84,15 @@ O.coins = O.mainData + 685 -- 2B BCD
|
||||
-- (wWalkBikeSurfState) + 10 = 359, so 685 + 359 = 1044 as well.
|
||||
O.townVisited = O.mainData + 1044 -- 2B (flag_array NUM_CITY_MAPS)
|
||||
O.eventFlags = O.mainData + 1104 -- 320B (flag_array NUM_EVENTS = 2560 bits)
|
||||
-- Progress bits vanilla keeps OUTSIDE wEventFlags that this port still spells
|
||||
-- as save.flags entries (#396). Offsets walk forward from wTownVisitedFlag
|
||||
-- over the same ram/wram.asm declaration run the 60-byte gap above sums:
|
||||
-- +29 wStatusFlags1, +35 wStatusFlags4, +41 wElite4Flags,
|
||||
-- +44 wCompletedInGameTradeFlags.
|
||||
O.statusFlags1 = O.townVisited + 29 -- 1B
|
||||
O.statusFlags4 = O.townVisited + 35 -- 1B
|
||||
O.elite4Flags = O.townVisited + 41 -- 1B
|
||||
O.tradeFlags = O.townVisited + 44 -- 2B (flag_array NUM_NPC_TRADES)
|
||||
-- Play time (wPlayTimeHours/Maxed/Minutes/Seconds/Frames) lives INSIDE the
|
||||
-- sMainData window (wMainDataStart..wMainDataEnd is copied verbatim into
|
||||
-- SRAM), 1866 bytes past wMainDataStart -- reached from the checksum-verified
|
||||
@@ -314,6 +323,34 @@ local BADGE_BY_BIT = {
|
||||
local BADGE_BY_BIT_SET = {}
|
||||
for _, name in pairs(BADGE_BY_BIT) do BADGE_BY_BIT_SET[name] = true end
|
||||
|
||||
-- save.flags names whose vanilla home is NOT wEventFlags (#396: exporting a
|
||||
-- save and importing it back made the Saffron gate guards thirsty again,
|
||||
-- because BIT_GAVE_SAFFRON_GUARDS_DRINK is a wStatusFlags1 bit and nothing
|
||||
-- carried it). Bit numbers are constants/ram_constants.asm; the trade bits
|
||||
-- are wWhichTrade, which engine/events/in_game_trades.asm uses to index
|
||||
-- wCompletedInGameTradeFlags, i.e. the data/events/trades.asm row order the
|
||||
-- port's `trade` command takes 1-based.
|
||||
local EXTRA_FLAG_BITS = {
|
||||
EVENT_GOT_OLD_ROD = { O.statusFlags1, 3 },
|
||||
EVENT_GOT_GOOD_ROD = { O.statusFlags1, 4 },
|
||||
EVENT_GOT_SUPER_ROD = { O.statusFlags1, 5 },
|
||||
EVENT_GAVE_GUARDS_DRINK = { O.statusFlags1, 6 },
|
||||
EVENT_GOT_LAPRAS = { O.statusFlags4, 0 },
|
||||
EVENT_STARTED_ELITE_4 = { O.elite4Flags, 1 },
|
||||
EVENT_TRADED_NIDORINO_FOR_NIDORINA = { O.tradeFlags, 0 },
|
||||
EVENT_TRADED_ABRA_FOR_MR_MIME = { O.tradeFlags, 1 },
|
||||
EVENT_TRADED_PONYTA_FOR_SEEL = { O.tradeFlags, 3 },
|
||||
EVENT_TRADED_SPEAROW_FOR_FARFETCHD = { O.tradeFlags, 4 },
|
||||
EVENT_TRADED_SLOWBRO_FOR_LICKITUNG = { O.tradeFlags, 5 },
|
||||
EVENT_TRADED_POLIWHIRL_FOR_JYNX = { O.tradeFlags, 6 },
|
||||
EVENT_TRADED_RAICHU_FOR_ELECTRODE = { O.tradeFlags, 7 },
|
||||
EVENT_TRADED_VENONAT_FOR_TANGELA = { O.tradeFlags, 8 },
|
||||
EVENT_TRADED_NIDORAN_M_FOR_NIDORAN_F = { O.tradeFlags, 9 },
|
||||
}
|
||||
|
||||
-- port-local name -> the wEventFlags name it means (#396)
|
||||
local FLAG_ALIAS = { EVENT_RECEIVED_BIKE_VOUCHER = "EVENT_GOT_BIKE_VOUCHER" }
|
||||
|
||||
-- STATUS_* bits (constants/battle_constants.asm): 0-2 sleep-turns-left,
|
||||
-- 3 PSN, 4 BRN, 5 FRZ, 6 PAR
|
||||
local STATUS_BIT = { PSN = 3, BRN = 4, FRZ = 5, PAR = 6 }
|
||||
@@ -671,6 +708,14 @@ function GenSave.decode(bytes, data, opts)
|
||||
end
|
||||
end
|
||||
|
||||
-- the same progress under names that are not wEventFlags bits (#396)
|
||||
for name, spec in pairs(EXTRA_FLAG_BITS) do
|
||||
if bitGet(bytes, spec[1], spec[2]) then save.flags[name] = true end
|
||||
end
|
||||
for portName, vanillaName in pairs(FLAG_ALIAS) do
|
||||
if save.flags[vanillaName] then save.flags[portName] = true end
|
||||
end
|
||||
|
||||
-- FLY destinations. wTownVisitedFlag's bit index IS the town's map index:
|
||||
-- engine/items/town_map.asm BuildFlyLocationsList loads the 16-bit value
|
||||
-- into de and rotates it right one bit per iteration with b counting up
|
||||
@@ -794,6 +839,19 @@ function GenSave.encode(save, data, template)
|
||||
local bitIdx = events.byName[name]
|
||||
if bitIdx then bitSet(buf, O.eventFlags, bitIdx, true) end
|
||||
end
|
||||
for portName, vanillaName in pairs(FLAG_ALIAS) do
|
||||
local bitIdx = events.byName[vanillaName]
|
||||
if bitIdx and save.flags[portName] then bitSet(buf, O.eventFlags, bitIdx, true) end
|
||||
end
|
||||
end
|
||||
|
||||
-- Non-wEventFlags progress, written both ways: this port's save is the only
|
||||
-- authority for these names, so a flag it does not hold must clear the
|
||||
-- template's bit rather than survive in the export (#396).
|
||||
if save.flags then
|
||||
for name, spec in pairs(EXTRA_FLAG_BITS) do
|
||||
bitSet(buf, spec[1], spec[2], save.flags[name] and true or false)
|
||||
end
|
||||
end
|
||||
|
||||
-- FLY destinations back into wTownVisitedFlag (see the decode note), so a
|
||||
|
||||
@@ -11,9 +11,14 @@
|
||||
-- tables load through `require`, exactly how src/core/Data.lua pulls the
|
||||
-- generated modules -- which resolves under both plain luajit (package.path
|
||||
-- "./?.lua") for the headless CLI/tests and love.filesystem for a fused
|
||||
-- build, with an OS-path fallback for odd working directories. The only
|
||||
-- place `love` is referenced is inside a guarded fallback, so running under
|
||||
-- stock Lua never touches it.
|
||||
-- build, with an OS-path fallback for odd working directories. `love` is only
|
||||
-- ever referenced inside guarded fallbacks, so running under stock Lua never
|
||||
-- touches it.
|
||||
--
|
||||
-- When a caller names the game a save belongs to, the generated tables come
|
||||
-- out of that version's ROM cache through CacheFs instead: the launcher does
|
||||
-- its importing before the cache is mounted onto the un-prefixed paths, so
|
||||
-- require alone cannot see them there (#420).
|
||||
|
||||
local GenSave = require("src.save_convert.GenSave")
|
||||
|
||||
@@ -63,20 +68,59 @@ local function loadTable(requirePath, filePath)
|
||||
:format(requirePath, requirePath, filePath)
|
||||
end
|
||||
|
||||
local crosswalk -- { pokemon=, moves=, items=, maps=, eventFlags= }
|
||||
-- The four generated tables live in one game's ROM cache, and the launcher
|
||||
-- reaches this code before that cache is on the un-prefixed read path:
|
||||
-- CacheFs.mountVersion only runs from main.lua's bootGame (after Play), and
|
||||
-- Blue/Yellow keep their cache under GameVersion.cachePrefix. So a bare
|
||||
-- require sees Red's copy at best, and nothing at all in a fused portable
|
||||
-- build (the game folder is only readable through CacheFs's PhysFS mount) --
|
||||
-- read the tables out of the cache whenever the caller names the game the
|
||||
-- save belongs to, and let the require path above cover everything else
|
||||
-- (#420).
|
||||
local function loadCacheTable(gameVersion, filePath)
|
||||
if not (gameVersion and love and love.filesystem) then return nil end
|
||||
if not filePath:match("^data/generated/") then return nil end
|
||||
local okc, CacheFs = pcall(require, "src.import.CacheFs")
|
||||
local okg, GameVersion = pcall(require, "src.core.GameVersion")
|
||||
if not (okc and okg and type(CacheFs) == "table") then return nil end
|
||||
local info = GameVersion.VERSIONS[gameVersion]
|
||||
if not info then return nil end
|
||||
-- CacheFs.prefix is launcher-owned global state (it points at whatever
|
||||
-- import last ran), so borrow it for this read and put it back.
|
||||
local saved = CacheFs.prefix
|
||||
CacheFs.prefix = info.cachePrefix
|
||||
local okr, bytes = pcall(CacheFs.read, filePath)
|
||||
CacheFs.prefix = saved
|
||||
if not (okr and type(bytes) == "string") then return nil end
|
||||
local chunk = loadstring(bytes, "@" .. info.cachePrefix .. filePath)
|
||||
if not chunk then return nil end
|
||||
local okx, mod = pcall(chunk)
|
||||
if okx and type(mod) == "table" then return mod end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Crosswalk sets keyed by the game whose cache they came from ("*" for the
|
||||
-- require-resolved set): Yellow's tables are not Red's, so one import must
|
||||
-- never be handed the previous import's data (#420).
|
||||
local crosswalks = {} -- [key] = { pokemon=, moves=, items=, maps=, eventFlags= }
|
||||
local charmapReady
|
||||
|
||||
local function ensureData()
|
||||
if not crosswalk then
|
||||
local function ensureData(gameVersion)
|
||||
local key = gameVersion or "*"
|
||||
if not crosswalks[key] then
|
||||
local data = {}
|
||||
for key, spec in pairs(DATA_MODULES) do
|
||||
if key ~= "charmap" then
|
||||
local mod, e = loadTable(spec[1], spec[2])
|
||||
if not mod then return nil, e end
|
||||
data[key] = mod
|
||||
for name, spec in pairs(DATA_MODULES) do
|
||||
if name ~= "charmap" then
|
||||
local mod = loadCacheTable(gameVersion, spec[2])
|
||||
if not mod then
|
||||
local e
|
||||
mod, e = loadTable(spec[1], spec[2])
|
||||
if not mod then return nil, e end
|
||||
end
|
||||
data[name] = mod
|
||||
end
|
||||
end
|
||||
crosswalk = data
|
||||
crosswalks[key] = data
|
||||
end
|
||||
if not charmapReady then
|
||||
local cm, err = loadTable(DATA_MODULES.charmap[1], DATA_MODULES.charmap[2])
|
||||
@@ -84,13 +128,14 @@ local function ensureData()
|
||||
GenSave.setCharmap(cm)
|
||||
charmapReady = true
|
||||
end
|
||||
return crosswalk
|
||||
return crosswalks[key]
|
||||
end
|
||||
|
||||
-- Exposed for the CLI/tests so they can share the exact data set the codec
|
||||
-- uses (and so a caller can pre-warm the cache). Returns data, err.
|
||||
function SaveConvert.loadData()
|
||||
return ensureData()
|
||||
-- uses (and so a caller can pre-warm the cache). gameVersion picks whose ROM
|
||||
-- cache the generated tables come from. Returns data, err.
|
||||
function SaveConvert.loadData(gameVersion)
|
||||
return ensureData(gameVersion)
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------------------
|
||||
@@ -108,7 +153,8 @@ local function defaultsSave()
|
||||
options = {
|
||||
textSpeed = 3, animations = true, battleStyle = "shift",
|
||||
battleLayout = "og",
|
||||
ruleset = "gen1_faithful", musicVol = 7, sfxVol = 7, musicFilter = 0,
|
||||
ruleset = "gen1_faithful", musicVol = 7, sfxVol = 7, pikaVol = 7,
|
||||
musicFilter = 0,
|
||||
speed = 1, colors = "gbc", tilt = 0, gbcfx = 0,
|
||||
videoMode = "windowed", mods = {},
|
||||
},
|
||||
@@ -138,20 +184,22 @@ SaveConvert.mergeDefaults = mergeDefaults
|
||||
-- Public API
|
||||
-- ------------------------------------------------------------------
|
||||
|
||||
-- importSav(bytes, version) -> saveTable, err
|
||||
-- importSav(bytes, version, gameVersion) -> saveTable, err
|
||||
-- bytes: the raw 32768-byte SRAM string. Validates size and the main-data
|
||||
-- checksum, decodes through GenSave, and returns a save table fully merged
|
||||
-- over the new-game defaults and tagged with `version`, ready to hand to
|
||||
-- SaveSerializer.encode for a slot file. On any failure returns nil + a
|
||||
-- message (never raises).
|
||||
function SaveConvert.importSav(bytes, version)
|
||||
-- SaveSerializer.encode for a slot file. gameVersion ("red"/"blue"/"yellow")
|
||||
-- names the game the save is being imported for, which is what selects the
|
||||
-- crosswalk tables; omit it to take whatever `require` resolves. On any
|
||||
-- failure returns nil + a message (never raises).
|
||||
function SaveConvert.importSav(bytes, version, gameVersion)
|
||||
if type(bytes) ~= "string" then
|
||||
return nil, "expected raw save bytes as a string"
|
||||
end
|
||||
if #bytes ~= GenSave.SAVE_SIZE then
|
||||
return nil, ("save must be %d bytes, got %d"):format(GenSave.SAVE_SIZE, #bytes)
|
||||
end
|
||||
local data, derr = ensureData()
|
||||
local data, derr = ensureData(gameVersion)
|
||||
if not data then return nil, derr end
|
||||
|
||||
local ok, decoded = pcall(GenSave.decode, bytes, data)
|
||||
@@ -170,16 +218,17 @@ function SaveConvert.importSav(bytes, version)
|
||||
return mergeDefaults(decoded, version)
|
||||
end
|
||||
|
||||
-- exportSav(saveTable) -> bytes, err
|
||||
-- exportSav(saveTable, gameVersion) -> bytes, err
|
||||
-- Encodes a save table back to a raw 32768-byte SRAM image. Template-aware:
|
||||
-- if the table still carries the stashed import template (saveTable.rawImport)
|
||||
-- GenSave reproduces every unmodeled region from it; otherwise those regions
|
||||
-- are zero-filled. On failure returns nil + a message (never raises).
|
||||
function SaveConvert.exportSav(saveTable)
|
||||
-- are zero-filled. gameVersion selects the crosswalk tables exactly as in
|
||||
-- importSav. On failure returns nil + a message (never raises).
|
||||
function SaveConvert.exportSav(saveTable, gameVersion)
|
||||
if type(saveTable) ~= "table" then
|
||||
return nil, "expected a save table"
|
||||
end
|
||||
local data, derr = ensureData()
|
||||
local data, derr = ensureData(gameVersion)
|
||||
if not data then return nil, derr end
|
||||
local ok, bytes = pcall(GenSave.encode, saveTable, data, nil)
|
||||
if not ok then return nil, "encode failed: " .. tostring(bytes) end
|
||||
|
||||
+14
-2
@@ -225,11 +225,23 @@ function Commands.give_item(ctx, itemId, count, gotText)
|
||||
ctx.game.stringBuffer = def and def.name or itemId
|
||||
-- the jingle rides the box -- Sound.play routes fanfares through
|
||||
-- Music.duckForFanfare, like PlaySoundWaitForCurrent
|
||||
require("src.core.Sound").play(ctx.game.data,
|
||||
(def and def.keyItem) and "Get_Key_Item" or "Get_Item1")
|
||||
local Sound = require("src.core.Sound")
|
||||
local jingle = (def and def.keyItem) and "Get_Key_Item" or "Get_Item1"
|
||||
if gotText ~= false then
|
||||
-- the gift texts carry the jingle as a trailing text command
|
||||
-- (sound_get_item_1 / sound_get_key_item -> home/text.asm
|
||||
-- TextCommand_SOUND), so it only fires once the last page has typed
|
||||
-- out, blocks on WaitForSoundToFinish, and AfterDisplayingTextID's
|
||||
-- button wait runs after it (#374)
|
||||
ctx.textOpts = ctx.textOpts or {}
|
||||
ctx.textOpts.auto = {
|
||||
sound = function() return Sound.play(ctx.game.data, jingle) end,
|
||||
wait = true,
|
||||
}
|
||||
Commands.show_text(ctx, gotText
|
||||
or Strings("{PLAYER} got\n%s!", ctx.game.stringBuffer))
|
||||
else
|
||||
Sound.play(ctx.game.data, jingle)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
+5
-3
@@ -305,9 +305,11 @@ local function useOn(game, battle, id, target, list, moveIndex, picker)
|
||||
-- HP medicine: fill the bar in the still-open picker first, then print
|
||||
-- and close, the order item_effects.asm .doneHealing runs in
|
||||
-- (SFX_HEAL_HP -> UpdateHPBar2 -> RedrawPartyMenu prints the message).
|
||||
-- picker is nil for every other item and for in-battle use, which keeps
|
||||
-- the pop-then-print path below. #252
|
||||
if picker and extra and extra.healedFrom and target then
|
||||
-- Only a keepOpen picker is still on the stack to animate: every other
|
||||
-- item, and every in-battle use, popped it in PartyMenu before onSwitch,
|
||||
-- and takes the pop-then-print path below -- which is the path that
|
||||
-- spends the battle turn. #252, #379
|
||||
if picker and picker.keepOpen and extra and extra.healedFrom and target then
|
||||
picker:animateTo(target, extra.healedFrom, function()
|
||||
showMessages(game, payload, closePicker)
|
||||
end)
|
||||
|
||||
@@ -97,8 +97,13 @@ function DexEntryMenu.render(game, def, sprite, forceOwned, trueColor)
|
||||
-- feet/inches use the dex screen's ′/″ glyphs ("HT ?′??″" in
|
||||
-- pokedex.asm; the tiles come from gfx/pokedex/pokedex.png via
|
||||
-- engine/gfx/load_pokedex_tiles.asm)
|
||||
Font.draw(Strings("HT %d′%02d″", e.heightFt, e.heightIn or 0), 72, 44)
|
||||
Font.draw(Strings("WT %.1flb", (e.weight or 0) / 10), 72, 54)
|
||||
if e.heightM then
|
||||
Font.draw((("GR. %.1fm"):format(e.heightM):gsub("(%d)%.(%d)", "%1,%2")), 64, 44)
|
||||
Font.draw((("GEW. %.1fkg"):format(e.weightKg or 0):gsub("(%d)%.(%d)", "%1,%2")), 64, 54)
|
||||
else
|
||||
Font.draw(Strings("HT %d′%02d″", e.heightFt, e.heightIn or 0), 72, 44)
|
||||
Font.draw(Strings("WT %.1flb", (e.weight or 0) / 10), 72, 54)
|
||||
end
|
||||
end
|
||||
local text = owned and e.text and game.data.text[e.text] or nil
|
||||
local y = 72
|
||||
|
||||
+1
-1
@@ -191,7 +191,7 @@ function ListMenu:draw()
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw(self.title, 8, 4)
|
||||
Font.draw(Strings(self.title), 8, 4)
|
||||
if #self.items == 0 then
|
||||
Font.draw(Strings("Nothing here."), 16, 64)
|
||||
end
|
||||
|
||||
@@ -19,6 +19,20 @@ function Menu.new(game, items, opts)
|
||||
self.tx = opts.tx or 10
|
||||
self.ty = opts.ty or 0
|
||||
self.tw = opts.tw or 10
|
||||
-- grow the box to the widest label so longer (e.g. localized) labels don't
|
||||
-- overflow the frame; nudge tx left to keep the box on-screen (20 tiles).
|
||||
do
|
||||
local widest = 0
|
||||
for _, it in ipairs(items) do
|
||||
if it.label then
|
||||
local n = #Font.split(it.label)
|
||||
if n > widest then widest = n end
|
||||
end
|
||||
end
|
||||
local needed = widest + 3
|
||||
if needed > self.tw then self.tw = needed end
|
||||
if self.tx + self.tw > 20 then self.tx = math.max(0, 20 - self.tw) end
|
||||
end
|
||||
self.rowStep = opts.rowStep or 2
|
||||
-- maxVisible: cap the box to this many rows and scroll the rest instead
|
||||
-- of growing past it (e.g. the start menu, whose row count varies with
|
||||
|
||||
@@ -183,7 +183,7 @@ function NamingScreen:draw()
|
||||
end
|
||||
for r, row in ipairs(self:grid()) do
|
||||
for c, cell in ipairs(row) do
|
||||
Font.draw(cell, c * 16, 32 + r * 16)
|
||||
Font.draw(Strings(cell), c * 16, 32 + r * 16)
|
||||
end
|
||||
end
|
||||
Font.drawCode(Theme.cursor, self.col * 16 - 8, 32 + self.row * 16)
|
||||
|
||||
@@ -486,7 +486,10 @@ end
|
||||
|
||||
function OakSpeech:advance()
|
||||
self.step = self.step + 1
|
||||
self.picFlip = false
|
||||
-- picFlip belongs to the pic, not to the step: OakSpeechText2 prints 2A
|
||||
-- and 2B over one flipped NIDORINO with no redraw between them
|
||||
-- (oak_speech.asm:80-83), so a pic-less step must not un-mirror what is
|
||||
-- still on screen; only applyPic and the demo step may change it (#397)
|
||||
local steps = self.steps
|
||||
if not steps then
|
||||
-- enter() builds steps; keep a path for callers that advance early
|
||||
|
||||
+31
-6
@@ -2,7 +2,8 @@
|
||||
-- (engine/menus/main_menu.asm DisplayOptionMenu), the battle ruleset
|
||||
-- (cycles the merged rulesets registry; gen1_faithful keeps the original
|
||||
-- quirks), plus the port's audio rows and display rows: music/SFX
|
||||
-- volume (0-7), music low-pass filter (OFF/1X/2X/3X), COLORS / TILT /
|
||||
-- volume (0-7), PIKACHU VOL (0-7, Yellow only: trims the PCM voice clips
|
||||
-- under SFX VOL), music low-pass filter (OFF/1X/2X/3X), COLORS / TILT /
|
||||
-- GBC FX / ZOOM / VOID FILL / VIDEO MODE, and the MODS row that opens
|
||||
-- the mod manager.
|
||||
-- Rows are descriptors fed through the ui.options.rows hook, so mods can
|
||||
@@ -16,6 +17,7 @@ local GBCFX = require("src.render.GBCFX")
|
||||
local Zoom = require("src.render.Zoom")
|
||||
local TileRenderer = require("src.render.TileRenderer")
|
||||
local GameSpeed = require("src.core.GameSpeed")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local VideoMode = require("src.core.VideoMode")
|
||||
local FrameCap = require("src.core.FrameCap")
|
||||
local Logger = require("src.core.Logger")
|
||||
@@ -82,7 +84,7 @@ local function rulesetName(game)
|
||||
local ids = rulesetIds(game)
|
||||
local id = ids[rulesetIndex(game, ids)] or game.save.options.ruleset
|
||||
local record = id and rulesets[id]
|
||||
return record and record.name or id or "----"
|
||||
return Strings(record and record.name or id or "----")
|
||||
end
|
||||
|
||||
-- 0-7 volume level display (0 = OFF)
|
||||
@@ -118,7 +120,7 @@ local function sameRows(_, rows) return rows end
|
||||
local function buildRows(game)
|
||||
local rows = {
|
||||
{ id = "textSpeed", label = Strings("TEXT SPEED"),
|
||||
value = function(g) return SPEEDS[speedIndex(g)][2] end,
|
||||
value = function(g) return Strings(SPEEDS[speedIndex(g)][2]) end,
|
||||
step = function(g)
|
||||
local i = speedIndex(g) % #SPEEDS + 1
|
||||
g.save.options.textSpeed = SPEEDS[i][1]
|
||||
@@ -126,7 +128,7 @@ local function buildRows(game)
|
||||
end },
|
||||
{ id = "animations", label = Strings("BATTLE ANIMATION"),
|
||||
value = function(g)
|
||||
return g.save.options.animations == false and "OFF" or "ON"
|
||||
return g.save.options.animations == false and Strings("OFF") or Strings("ON")
|
||||
end,
|
||||
step = function(g)
|
||||
local o = g.save.options
|
||||
@@ -135,7 +137,7 @@ local function buildRows(game)
|
||||
end },
|
||||
{ id = "battleStyle", label = Strings("BATTLE STYLE"),
|
||||
value = function(g)
|
||||
return g.save.options.battleStyle == "set" and "SET" or "SHIFT"
|
||||
return g.save.options.battleStyle == "set" and Strings("SET") or Strings("SHIFT")
|
||||
end,
|
||||
step = function(g)
|
||||
local o = g.save.options
|
||||
@@ -146,7 +148,7 @@ local function buildRows(game)
|
||||
-- widescreen composition (src/battle/WideBattle.lua)
|
||||
{ id = "battleLayout", label = Strings("BATTLE LAYOUT"),
|
||||
value = function(g)
|
||||
return g.save.options.battleLayout == "wide" and "WIDE" or "OG"
|
||||
return g.save.options.battleLayout == "wide" and Strings("WIDE") or Strings("OG")
|
||||
end,
|
||||
step = function(g)
|
||||
local o = g.save.options
|
||||
@@ -178,6 +180,18 @@ local function buildRows(game)
|
||||
require("src.core.Sound").setVolumeLevel(o.sfxVol)
|
||||
return true
|
||||
end },
|
||||
-- Yellow only (filtered out below on Red/Blue): trims Pikachu's voice
|
||||
-- clips under the SFX level, since Yellow plays them constantly (every
|
||||
-- follower interaction, the title screen, the dex) and they are mixed
|
||||
-- much hotter than the chip cries.
|
||||
{ id = "pikaVol", label = Strings("PIKACHU VOL"),
|
||||
value = function(g) return volLabel(g.save.options.pikaVol) end,
|
||||
step = function(g, dir)
|
||||
local o = g.save.options
|
||||
o.pikaVol = stepVolume(o.pikaVol, dir)
|
||||
require("src.core.Sound").setPikaVolumeLevel(o.pikaVol)
|
||||
return true
|
||||
end },
|
||||
{ id = "musicFilter", label = Strings("MUSIC FILTER"),
|
||||
value = function(g)
|
||||
return FILTERS[(g.save.options.musicFilter or 0) + 1]
|
||||
@@ -317,6 +331,17 @@ local function buildRows(game)
|
||||
end
|
||||
rows = filtered
|
||||
end
|
||||
-- PIKACHU VOL only means something where the voice clips exist: Yellow
|
||||
-- (data.audio.pikaCries is the clip count the importer wrote). Red/Blue
|
||||
-- keep the row list they always had.
|
||||
local audio = game and game.data and game.data.audio
|
||||
if not (GameVersion.isYellow() and audio and audio.pikaCries) then
|
||||
local filtered = {}
|
||||
for _, row in ipairs(rows) do
|
||||
if row.id ~= "pikaVol" then filtered[#filtered + 1] = row end
|
||||
end
|
||||
rows = filtered
|
||||
end
|
||||
-- A mod's render pipelines are display modes like TILT, so their rows sit
|
||||
-- with it rather than at the end of the list where a mod's own
|
||||
-- ui.options.rows additions land. Nothing registered means nothing
|
||||
|
||||
+24
-15
@@ -351,18 +351,23 @@ function PartyMenu:update(dt)
|
||||
end })
|
||||
return
|
||||
elseif action == "flash" then -- FLASH lights dark tunnels
|
||||
-- start_sub_menus.asm .flash: PrintText _FlashLightsAreaText, then
|
||||
-- GBPalWhiteOutWithDelay3 + jp .goBackToMap
|
||||
-- start_sub_menus.asm .flash: PrintText _FlashLightsAreaText runs
|
||||
-- with the party menu still on screen, and only then
|
||||
-- GBPalWhiteOutWithDelay3 + jp .goBackToMap. So the message reads
|
||||
-- over the menu, and the cave is lit when the blink hands the
|
||||
-- screen back, never under the text (#385).
|
||||
local ow = self.game.overworld
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local Transition = require("src.render.Transition")
|
||||
self.game.stack:pop()
|
||||
ow.dark = false
|
||||
self.game.save.flashLit = true
|
||||
self.game.stack:push(TextBox.new(self.game,
|
||||
self.game.data.text._FlashLightsAreaText
|
||||
or Strings("A blinding FLASH\nlights the area!"), function()
|
||||
self.game.stack:push(Transition.whiteFlash(self.game))
|
||||
self:close()
|
||||
-- setDark, not a bare field write: ADVANCED carries the darkness
|
||||
-- in a baked atlas, so lighting the cave rebuilds it (#383)
|
||||
self.game.stack:push(Transition.whiteFlash(self.game, nil,
|
||||
function() ow:setDark(false) end))
|
||||
end))
|
||||
return
|
||||
elseif action == "surf" then
|
||||
@@ -377,9 +382,11 @@ function PartyMenu:update(dt)
|
||||
local reason = ow:useSurfFieldMove()
|
||||
local Transition = require("src.render.Transition")
|
||||
if reason == "ok" then
|
||||
self.game.stack:pop() -- close the party menu (jp .goBackToMap)
|
||||
-- UseItem prints _SurfingGotOnText with the party menu still up;
|
||||
-- GBPalWhiteOutWithDelay3 + jp .goBackToMap only follow it, so
|
||||
-- trySurf closes this menu when its text does (#385)
|
||||
local fx, fy = ow.player:facingCell()
|
||||
ow:trySurf(fx, fy)
|
||||
ow:trySurf(fx, fy, function() self:close() end)
|
||||
return
|
||||
end
|
||||
if reason == "dismount" then
|
||||
@@ -410,9 +417,10 @@ function PartyMenu:update(dt)
|
||||
-- .cannotStopSurfing prints _SurfingNoPlaceToGetOffText but
|
||||
-- never zeroes wActionResultOrTookBattleTurn, so unlike the
|
||||
-- other refusals the menu still closes afterwards
|
||||
-- (GBPalWhiteOutWithDelay3 + .goBackToMap)
|
||||
self.game.stack:pop()
|
||||
-- (GBPalWhiteOutWithDelay3 + .goBackToMap), and the text prints
|
||||
-- over the still-open menu like every other .loop refusal (#385)
|
||||
self.game.stack:push(TextBox.new(self.game, txt, function()
|
||||
self:close()
|
||||
self.game.stack:push(Transition.whiteFlash(self.game))
|
||||
end))
|
||||
return
|
||||
@@ -454,18 +462,19 @@ function PartyMenu:update(dt)
|
||||
local Transition = require("src.render.Transition")
|
||||
local def = self.game.data.pokemon[mon.species]
|
||||
local name = mon.nickname or def.name
|
||||
self.game.stack:pop() -- close the party menu (jp .goBackToMap)
|
||||
ow.strengthActive = true
|
||||
local t1 = (self.game.data.text._UsedStrengthText
|
||||
or Strings("{RAM:wNameBuffer} used\nSTRENGTH.")):gsub("{RAM:wNameBuffer}", name)
|
||||
local t2 = (self.game.data.text._CanMoveBouldersText
|
||||
or Strings("{RAM:wNameBuffer} can\nmove boulders.")):gsub("{RAM:wNameBuffer}", name)
|
||||
-- like surf (#320): the blink belongs UNDER the texts, not as a
|
||||
-- flashbang on the empty map after them; the stack only updates
|
||||
-- the top state, so the flash holds until the texts close
|
||||
self.game.stack:push(Transition.whiteFlash(self.game))
|
||||
-- like surf (#320, #385): both texts print with the party menu
|
||||
-- still on screen, and the blink IS the menu closing afterwards,
|
||||
-- not a flashbang on the empty map
|
||||
self.game.stack:push(TextBox.new(self.game, t1, function()
|
||||
self.game.stack:push(TextBox.new(self.game, t2))
|
||||
self.game.stack:push(TextBox.new(self.game, t2, function()
|
||||
self:close()
|
||||
self.game.stack:push(Transition.whiteFlash(self.game))
|
||||
end))
|
||||
end, { auto = { sound = function()
|
||||
return require("src.core.Sound").playCry(self.game.data, mon.species)
|
||||
end } }))
|
||||
|
||||
+11
-4
@@ -43,12 +43,13 @@ function SummaryMenu.new(game, mon)
|
||||
Stats.ensure(game.data.pokemon[mon.species], mon)
|
||||
local self = setmetatable({ game = game, mon = mon, page = 1 }, SummaryMenu)
|
||||
local Sprites = require("src.pokemon.Sprites")
|
||||
local path = Sprites.path(game.data, mon.species, "front",
|
||||
local path, trueColor = Sprites.path(game.data, mon.species, "front",
|
||||
{ mon = mon, kind = "summary" })
|
||||
if path then
|
||||
local ok, img = pcall(love.graphics.newImage, path)
|
||||
self.sprite = ok and img or nil
|
||||
end
|
||||
self.spriteTrueColor = self.sprite and trueColor or false
|
||||
require("src.core.Sound").playCry(game.data, mon.species)
|
||||
return self
|
||||
end
|
||||
@@ -111,8 +112,14 @@ function SummaryMenu:draw()
|
||||
-- the same routine the intro's NIDORINO show-off uses (OakSpeech picFlip:
|
||||
-- negative x scale anchored at the pic's right edge). #280
|
||||
if self.sprite then
|
||||
love.graphics.draw(self.sprite, 8 + self.sprite:getWidth(),
|
||||
math.max(0, 56 - self.sprite:getHeight()), 0, -1, 1)
|
||||
local pw, ph = self.sprite:getDimensions()
|
||||
local py = math.max(0, 56 - ph)
|
||||
love.graphics.draw(self.sprite, 8 + pw, py, 0, -1, 1)
|
||||
-- a full-color pic has to sit out the SGB monPal recolor, so mark the
|
||||
-- rect the mirrored draw covers for the unshaded pass (#430)
|
||||
if self.spriteTrueColor then
|
||||
require("src.render.PaletteFX").markTrueColor(8, py, pw, ph)
|
||||
end
|
||||
end
|
||||
local HudTiles = require("src.render.HudTiles")
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
@@ -146,7 +153,7 @@ function SummaryMenu:draw()
|
||||
}
|
||||
for i, s in ipairs(stats) do
|
||||
local y = 72 + (i - 1) * 16
|
||||
Font.draw(s[1], 8, y)
|
||||
Font.draw(Strings(s[1]), 8, y)
|
||||
Font.draw(("%3d"):format(s[2]), 48, y + 8)
|
||||
end
|
||||
|
||||
|
||||
+14
-9
@@ -22,13 +22,17 @@ TitleState.isOpaque = true
|
||||
-- LOGO2 blue / LOGO1 red (issue #133). A trailing trueColor zone leaves the
|
||||
-- overlay's DMG black unshaded while the logo and title mon keep title pals.
|
||||
--
|
||||
-- ROM SuperPal whites are often {255,239,255}. Under RED++, LOGO2/MEWMON
|
||||
-- come from the GBC pack (pure white) while Blue's LOGO1 stays on the ROM
|
||||
-- pack (#128), so the version-ribbon row reads as a pink band. Force that
|
||||
-- slot to pure white; ink colors (Blue/Red "Version" text) stay intact.
|
||||
local function withPureWhite(pal)
|
||||
-- Every title SuperPal shares color 0 on hardware (sgb_palettes.asm: LOGO1,
|
||||
-- LOGO2 and MEWMON all start RGB 31,29,31), so the ribbon band's white has to
|
||||
-- be the neighbouring zones' white. Under RED++, LOGO2/MEWMON come from the
|
||||
-- GBC pack (pure white) while Blue's LOGO1 stays on the ROM pack (#128) and
|
||||
-- the row reads as a pink band; taking LOGO2's white fixes that without
|
||||
-- forcing pure white in SGB, where a brighter band drew two faint lines
|
||||
-- across the title (#373). Ink colors (Blue/Red "Version" text) stay intact.
|
||||
local function withWhiteOf(pal, ref)
|
||||
if not pal then return nil end
|
||||
return { { 255, 255, 255 }, pal[2], pal[3], pal[4] }
|
||||
if not (ref and ref[1]) then return pal end
|
||||
return { ref[1], pal[2], pal[3], pal[4] }
|
||||
end
|
||||
|
||||
function TitleState:sgbPalettes(game)
|
||||
@@ -46,9 +50,10 @@ function TitleState:sgbPalettes(game)
|
||||
P.zone(logoPal, 9, 8, 10, 8),
|
||||
}
|
||||
else
|
||||
local logoPal = P.pal(game.data, "LOGO2")
|
||||
z = {
|
||||
P.zone(P.pal(game.data, "LOGO2"), 0, 0, 19, 7),
|
||||
P.zone(withPureWhite(P.pal(game.data, "LOGO1")), 0, 8, 19, 9),
|
||||
P.zone(logoPal, 0, 0, 19, 7),
|
||||
P.zone(withWhiteOf(P.pal(game.data, "LOGO1"), logoPal), 0, 8, 19, 9),
|
||||
P.zone(P.pal(game.data, "MEWMON"), 0, 10, 19, 17),
|
||||
}
|
||||
end
|
||||
@@ -427,7 +432,7 @@ end
|
||||
-- the version ribbon at (7,8), Red's title art as OAM at px (82,80),
|
||||
-- the title mon in the 7x7 box at tile (5,10), copyright on row 17.
|
||||
-- Yellow (title_yellow.asm): logo (2,1), speech bubble (6,4), Pikachu
|
||||
-- (4,8) 12x9 — no version ribbon, no cycling mon, no Red OAM.
|
||||
-- (4,8) 12x9 -- no version ribbon, no cycling mon, no Red OAM.
|
||||
function TitleState:draw()
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
||||
|
||||
@@ -574,10 +574,14 @@ function YellowIntro:beginScenes()
|
||||
self:startScene(0)
|
||||
end
|
||||
|
||||
-- The movie's exit never stops the song (intro_yellow.asm PlayIntroScene
|
||||
-- .go_to_title_screen, where the A/B/START skip lands too): Music_YellowIntro
|
||||
-- outlasts the scenes and plays on into the title setup until title.asm's
|
||||
-- StopAllMusic before MUSIC_TITLE_SCREEN, which TitleState:startMusic stands
|
||||
-- in for (#436)
|
||||
function YellowIntro:finish()
|
||||
if self.finished then return end
|
||||
self.finished = true
|
||||
pcall(Music.stop)
|
||||
self.game.stack:pop()
|
||||
if self.onDone then self.onDone() end
|
||||
end
|
||||
|
||||
+3
-1
@@ -50,7 +50,9 @@ local CALLBACK_NAMES = {
|
||||
"keypressed", "keyreleased", "textinput",
|
||||
"mousepressed", "mousereleased", "mousemoved", "wheelmoved",
|
||||
"touchpressed", "touchmoved", "touchreleased",
|
||||
"gamepadpressed", "gamepadreleased", "gamepadaxis", "joystickremoved",
|
||||
"gamepadpressed", "gamepadreleased", "gamepadaxis",
|
||||
"joystickpressed", "joystickreleased", "joystickaxis", "joystickhat",
|
||||
"joystickremoved",
|
||||
"focus", "visible", "resize", "filedropped", "directorydropped",
|
||||
"errorhandler", "threaderror", "lowmemory",
|
||||
}
|
||||
|
||||
@@ -174,4 +174,14 @@ function Check.download()
|
||||
cmdCh:push({ cmd = "download" })
|
||||
end
|
||||
|
||||
-- End the worker thread. Its command loop sits in Channel:demand(), which
|
||||
-- never returns on its own, and LOVE waits for every live love.thread before
|
||||
-- the process exits (#339).
|
||||
function Check.shutdown()
|
||||
if cmdCh then cmdCh:push({ cmd = "quit" }) end
|
||||
if worker then pcall(function() worker:wait() end) end
|
||||
worker, cmdCh, stateCh = nil, nil, nil
|
||||
workerReady = false
|
||||
end
|
||||
|
||||
return Check
|
||||
|
||||
@@ -128,6 +128,23 @@ FieldDefaults.FIELD = {
|
||||
-- the one-shot flag the gate's pass text is gated on; a gate a mod adds
|
||||
-- gets "PASSED_<mapId>" instead of this pre-v2 spelling
|
||||
badgeGates = { ROUTE_22_GATE = { passedFlag = "PASSED_ROUTE22_GATE" } },
|
||||
-- the Rocket Hideout lift gates the floor callbacks stamp shut
|
||||
-- (scripts/RocketHideoutB1F.asm / RocketHideoutB4F.asm
|
||||
-- ...DoorCallbackScript); seeds caches imported before the manifest
|
||||
-- carried them, which left both gates standing open (#372)
|
||||
cardKeyDoors = {
|
||||
closedDoors = {
|
||||
ROCKET_HIDEOUT_B1F = {
|
||||
{ block = 0x54, bx = 12, by = 8, open = 0x0e,
|
||||
event = "EVENT_BEAT_ROCKET_HIDEOUT_1_TRAINER_4" },
|
||||
},
|
||||
ROCKET_HIDEOUT_B4F = {
|
||||
{ block = 0x2d, bx = 12, by = 5, open = 0x0e,
|
||||
events = { "EVENT_BEAT_ROCKET_HIDEOUT_4_TRAINER_0",
|
||||
"EVENT_BEAT_ROCKET_HIDEOUT_4_TRAINER_1" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
-- VermilionGymSetDoorTile opens the motorized door once both locks are hit
|
||||
hiddenExtras = {
|
||||
-- PrintTrashText bins (#188); seeds stale caches missing the key
|
||||
|
||||
+189
-151
@@ -65,6 +65,16 @@ local ROD_OAM = {
|
||||
right = { dx = 16, dy = 4, tile = 1, flip = true }, -- dbsprite 11, 10, 0, 0, $fe, XFLIP
|
||||
}
|
||||
|
||||
-- field.darkMaps (home/overworld.asm's dark-map check): the floors that run
|
||||
-- with wMapPalOffset = 6 until FLASH
|
||||
local function isDarkMap(mapId)
|
||||
local darkDef = Game.data.field.darkMaps
|
||||
for _, m in ipairs(darkDef and darkDef.maps or {}) do
|
||||
if m == mapId then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- object_event spawn filter (toggleable_objects, items taken, beaten
|
||||
-- static encounters), shared by the current map's real NPCs and the
|
||||
-- visual-only ghosts on connected neighbor maps
|
||||
@@ -188,8 +198,50 @@ function OverworldState:enter(mapId, x, y, facing)
|
||||
-- survives save/load: a loaded game may start inside a building whose
|
||||
-- exit mat is a LAST_MAP warp
|
||||
self.lastOutdoor = Game.save.lastOutdoor
|
||||
self.justWarped = false
|
||||
self:setMap(mapId, x, y, facing, { via = "boot" })
|
||||
-- boot/load: derive the flag from the tile the save left us standing on,
|
||||
-- like MapEntryAfterBattle's IsPlayerStandingOnWarp, so a game saved on a
|
||||
-- door mat can still walk straight back out (issue #378)
|
||||
self:refreshStandingOnWarp()
|
||||
end
|
||||
|
||||
-- Silph Co card key doors + Rocket Hideout elevator gates: the .blk
|
||||
-- layouts ship with the doorways open; each floor's map script stamps
|
||||
-- the closed door block on load until its unlock event is set
|
||||
-- (scripts/SilphCo2F.asm SilphCo2FGateCallbackScript et al., closed
|
||||
-- blocks $54/$5f/$20; scripts/RocketHideoutB1F.asm +
|
||||
-- RocketHideoutB4F.asm ...DoorCallbackScript, closed blocks $54/$2d over
|
||||
-- the lift doorway). A door opens on its single `event`, or on `events`
|
||||
-- when every listed flag must be set (Rocket Hideout B4F's lift gate
|
||||
-- needs both guard trainers beaten -- CheckBothEventsSet). The callbacks
|
||||
-- run whenever BIT_CUR_MAP_LOADED_1 is set, which is map load AND the end
|
||||
-- of a battle on that map (home/trainers.asm EndTrainerBattle), so the
|
||||
-- gate opens with SFX_GO_INSIDE the moment the last guard falls (#372).
|
||||
function OverworldState:stampClosedDoors()
|
||||
local closedDoors = FieldDefaults.fieldValue(Game.data, "cardKeyDoors",
|
||||
"closedDoors")
|
||||
local floorDoors = self.map and closedDoors and closedDoors[self.map.id]
|
||||
if not floorDoors then return end
|
||||
local stamped, unlocked = false, false
|
||||
for _, door in ipairs(floorDoors) do
|
||||
local open
|
||||
if door.events then
|
||||
open = true
|
||||
for _, ev in ipairs(door.events) do
|
||||
if not Game.save.flags[ev] then open = false break end
|
||||
end
|
||||
else
|
||||
open = Game.save.flags[door.event]
|
||||
end
|
||||
local want = open and door.open or door.block
|
||||
if self.map:blockAt(door.bx, door.by) ~= want then
|
||||
self.map:setBlock(door.bx, door.by, want)
|
||||
stamped = true
|
||||
if open then unlocked = true end
|
||||
end
|
||||
end
|
||||
if stamped then self.map.renderer:rebuild() end
|
||||
if unlocked then require("src.core.Sound").play(Game.data, "Go_Inside") end
|
||||
end
|
||||
|
||||
function OverworldState:setMap(mapId, x, y, facing, opts)
|
||||
@@ -222,6 +274,13 @@ function OverworldState:setMap(mapId, x, y, facing, opts)
|
||||
self.tileAnimOverride.tileset.animation = self.tileAnimOverride.animation
|
||||
self.tileAnimOverride = nil
|
||||
end
|
||||
-- ADVANCED bakes colour into the tileset atlas, so the dark-cave shift has
|
||||
-- to be armed before that atlas is built for this map (#383); self.dark is
|
||||
-- settled below, once the map record is in hand.
|
||||
if PaletteFX.setDarkWorld(isDarkMap(mapId) and not Game.save.flashLit)
|
||||
and PaletteFX.usesGbcPack() then
|
||||
MapLoader.invalidateAll()
|
||||
end
|
||||
self.map = MapLoader.load(Game.data, mapId)
|
||||
-- STRENGTH deactivates on every real map load (home/overworld.asm
|
||||
-- EnterMap -> ResetUsingStrengthOutOfBattleBit clears BIT_STRENGTH_ACTIVE
|
||||
@@ -241,38 +300,7 @@ function OverworldState:setMap(mapId, x, y, facing, opts)
|
||||
self.map.renderer:rebuild()
|
||||
self.cutBlocks[mapId] = nil
|
||||
end
|
||||
-- Silph Co card key doors + Rocket Hideout elevator gates: the .blk
|
||||
-- layouts ship with the doorways open; each floor's map script stamps
|
||||
-- the closed door block on load until its unlock event is set
|
||||
-- (scripts/SilphCo2F.asm SilphCo2FGateCallbackScript et al., closed
|
||||
-- blocks $54/$5f/$20; scripts/RocketHideoutB1F.asm +
|
||||
-- RocketHideoutB4F.asm ...DoorCallbackScript, closed blocks $54/$2d over
|
||||
-- the lift doorway). A door opens on its single `event`, or on `events`
|
||||
-- when every listed flag must be set (Rocket Hideout B4F's lift gate
|
||||
-- needs both guard trainers beaten -- CheckBothEventsSet).
|
||||
local closedDoors = FieldDefaults.fieldValue(Game.data, "cardKeyDoors",
|
||||
"closedDoors")
|
||||
local floorDoors = closedDoors and closedDoors[mapId]
|
||||
if floorDoors then
|
||||
local stamped = false
|
||||
for _, door in ipairs(floorDoors) do
|
||||
local open
|
||||
if door.events then
|
||||
open = true
|
||||
for _, ev in ipairs(door.events) do
|
||||
if not Game.save.flags[ev] then open = false break end
|
||||
end
|
||||
else
|
||||
open = Game.save.flags[door.event]
|
||||
end
|
||||
local want = open and door.open or door.block
|
||||
if self.map:blockAt(door.bx, door.by) ~= want then
|
||||
self.map:setBlock(door.bx, door.by, want)
|
||||
stamped = true
|
||||
end
|
||||
end
|
||||
if stamped then self.map.renderer:rebuild() end
|
||||
end
|
||||
self:stampClosedDoors()
|
||||
-- forced dismount only where riding is disallowed (IsBikeRidingAllowed,
|
||||
-- home/overworld.asm: bike_riding_tilesets.asm tilesets plus the
|
||||
-- ROUTE_23/INDIGO_PLATEAU map exceptions)
|
||||
@@ -294,18 +322,11 @@ function OverworldState:setMap(mapId, x, y, facing, opts)
|
||||
-- Rock Tunnel darkness (wMapPalOffset, home/overworld.asm): dark
|
||||
-- until FLASH is used; the light persists between the tunnel floors
|
||||
-- and resets once outside
|
||||
local darkDef = Game.data.field.darkMaps
|
||||
self.dark = false
|
||||
if darkDef then
|
||||
local isDark = false
|
||||
for _, m in ipairs(darkDef.maps) do
|
||||
if m == mapId then isDark = true break end
|
||||
end
|
||||
if isDark then
|
||||
self.dark = not Game.save.flashLit
|
||||
else
|
||||
Game.save.flashLit = nil
|
||||
end
|
||||
if isDarkMap(mapId) then
|
||||
self:setDark(not Game.save.flashLit)
|
||||
else
|
||||
Game.save.flashLit = nil
|
||||
self:setDark(false)
|
||||
end
|
||||
if Game.data.field.flyWarps[mapId] then
|
||||
Game.save.visited = Game.save.visited or {}
|
||||
@@ -345,7 +366,7 @@ function OverworldState:setMap(mapId, x, y, facing, opts)
|
||||
for _, n in ipairs(self.npcs) do table.insert(self.entities, n) end
|
||||
-- Yellow's companion Pikachu trails the player (never in
|
||||
-- self.entities: it does not block movement, pikachu_follow.asm)
|
||||
require("src.world.PikachuFollower").onMapEntered(Game, self)
|
||||
require("src.world.PikachuFollower").onMapEntered(Game, self, opts)
|
||||
|
||||
-- opts.keepMusic: the Oak-escort warp keeps MUSIC_MEET_PROF_OAK
|
||||
-- playing into the lab (BIT_NO_MAP_MUSIC in wStatusFlags7);
|
||||
@@ -576,17 +597,19 @@ function OverworldState:sgbWorldZones()
|
||||
return zones
|
||||
end
|
||||
|
||||
-- Whether the dark-map shade shift (PaletteFX.DARK_BGP, armed in drawWorld)
|
||||
-- can actually reach this frame's world pass. It cannot in RED++: that mode
|
||||
-- bakes real per-tile colour into the tileset atlas and sgbWorldZones returns
|
||||
-- an EMPTY zone list above, so the world blits with no shade-remap shader at
|
||||
-- all and there is no palette left to permute. Only then does fxDark
|
||||
-- composite the darkness by hand (#322).
|
||||
function OverworldState:darkNeedsOverlay()
|
||||
if not self.dark then return false end
|
||||
local renderer = self.map and self.map.renderer
|
||||
return PaletteFX.usesGbcPack() and renderer ~= nil
|
||||
and renderer.gbcAtlas ~= nil
|
||||
-- wMapPalOffset, the one piece of state both halves of the darkness read:
|
||||
-- drawWorld arms PaletteFX.DARK_BGP off self.dark for the shade-remapped
|
||||
-- modes, and PaletteFX.setDarkWorld feeds the bakes ADVANCED does instead of
|
||||
-- shading (tileset atlas, sprite sheets) plus their cache keys. A bake cannot
|
||||
-- be re-shaded in place, so a change there rebuilds every resident map --
|
||||
-- every dark floor, not just this one, since FLASH lights them all (#383).
|
||||
function OverworldState:setDark(on)
|
||||
on = on and true or false
|
||||
self.dark = on
|
||||
if PaletteFX.setDarkWorld(on) and PaletteFX.usesGbcPack() and self.map then
|
||||
MapLoader.invalidateAll()
|
||||
self:reloadMap(self.map.id, "dark")
|
||||
end
|
||||
end
|
||||
|
||||
function OverworldState:npcByIndex(index)
|
||||
@@ -795,6 +818,15 @@ function OverworldState:update(dt)
|
||||
if ca.onDone then ca.onDone() end
|
||||
end
|
||||
end
|
||||
-- fishing pose tail: the rod is already gone, the pose holds for the
|
||||
-- frames the original spends unwinding the item menu (#384)
|
||||
if self.fishPose then
|
||||
self.fishPose = self.fishPose - 1
|
||||
if self.fishPose <= 0 then
|
||||
self.fishPose = nil
|
||||
self.player.fishing = nil
|
||||
end
|
||||
end
|
||||
-- Yellow's companion hopping up onto the Poke Center counter owns the
|
||||
-- world for its arc, the same way the heal machine below does (#417)
|
||||
if self.pikaHop then
|
||||
@@ -867,7 +899,12 @@ function OverworldState:update(dt)
|
||||
-- the emotion-bubble pause holds the world for a beat
|
||||
if self.emote then
|
||||
self.emote.frames = self.emote.frames - 1
|
||||
if self.emote.frames <= 0 then
|
||||
-- PikaPicAnimTimerAndJoypad (engine/pikachu/pikachu_pic_animation.asm)
|
||||
-- cuts a pikapic beat short on A or B; the "!" bubble hold has no such
|
||||
-- check, so only the pikapic marks itself skippable (#424)
|
||||
local cut = self.emote.skippable
|
||||
and (Game.input:wasPressed("a") or Game.input:wasPressed("b"))
|
||||
if cut or self.emote.frames <= 0 then
|
||||
local done = self.emote.onDone
|
||||
self.emote = nil
|
||||
if done then done() end
|
||||
@@ -961,19 +998,33 @@ function OverworldState:dirHeld()
|
||||
or input:isDown("left") or input:isDown("right")
|
||||
end
|
||||
|
||||
-- The warp cell the player WARPED IN on is inert until they physically step
|
||||
-- off it: standing on it, or bonking a wall/edge from it, must not re-fire a
|
||||
-- warp (CheckWarpsNoCollision's arrival-disable; see setMap where
|
||||
-- warpEntryCell/justWarped are set and onStepComplete where they clear).
|
||||
-- Both stand-still warp triggers -- the map-edge exit (checkEdgeExit) and the
|
||||
-- blocked-step collision warp (handleInput) -- must consult this, or a corner
|
||||
-- staircase whose warp tile sits on the map edge (Red's-house (7,1)) bounces
|
||||
-- floors every input frame (issue #230).
|
||||
function OverworldState:onWarpArrivalCell()
|
||||
if self.justWarped then return true end
|
||||
local entry = self.warpEntryCell
|
||||
return entry ~= nil and self.player.cellX == entry.x
|
||||
and self.player.cellY == entry.y
|
||||
-- BIT_STANDING_ON_WARP (wMovementFlags): the warp under the player's feet may
|
||||
-- only fire from a collision -- the blocked-step warp (handleInput) and the
|
||||
-- map-edge exit (checkEdgeExit) -- while this flag is set. pokered clears it
|
||||
-- on every completed step, sets it again when that step lands on a warp
|
||||
-- square, then clears it once more when the square is a warp-activating tile
|
||||
-- that is not also a door tile (CheckWarpsNoCollisionLoop ->
|
||||
-- IsPlayerStandingOnDoorTileOrWarpTile, engine/overworld/player_state.asm);
|
||||
-- onStepComplete maintains it. ClearVariablesOnEnterMap does not clear
|
||||
-- wMovementFlags, so the flag rides through the warp itself: a house door
|
||||
-- tile ($1B) leaves it set, so you land on the interior mat still able to
|
||||
-- walk back out on that same tile (issue #378), while a staircase tile
|
||||
-- ($1A/$1C) clears it and cannot bounce you between floors (issue #230).
|
||||
function OverworldState:canCollisionWarp()
|
||||
return self.standingOnWarp == true
|
||||
end
|
||||
|
||||
-- Re-derive the flag from the tile under the player, the way a completed step
|
||||
-- does (and the way MapEntryAfterBattle's IsPlayerStandingOnWarp does after a
|
||||
-- battle): a door tile keeps it, a stair/ladder warp tile clears it.
|
||||
function OverworldState:refreshStandingOnWarp()
|
||||
local p = self.player
|
||||
self.standingOnWarp = false
|
||||
if self.map:warpAtCell(p.cellX, p.cellY)
|
||||
and not (self.map:isWarpTileCell(p.cellX, p.cellY)
|
||||
and not self.map:isDoorTileCell(p.cellX, p.cellY)) then
|
||||
self.standingOnWarp = true
|
||||
end
|
||||
end
|
||||
|
||||
function OverworldState:handleInput()
|
||||
@@ -1017,9 +1068,9 @@ function OverworldState:handleInput()
|
||||
local result, why = self.player:tryMove(dir, self.map, self.entities)
|
||||
-- a collision while standing on a warp square fires the warp when the
|
||||
-- extra check passes (CheckWarpsCollision: route-gate doorways, dock
|
||||
-- entrances, ...) -- but never on the inert cell we just warped in on
|
||||
-- (issue #230), which the completed-step path guards the same way.
|
||||
if result == "blocked" and not self:onWarpArrivalCell() then
|
||||
-- entrances, ...), and only while BIT_STANDING_ON_WARP is set (issue
|
||||
-- #230), which the map-edge path guards the same way.
|
||||
if result == "blocked" and self:canCollisionWarp() then
|
||||
local w = Warp.onCollision(self.map, Game.data.field.warpCarpets,
|
||||
self.player.cellX, self.player.cellY, dir)
|
||||
if w then
|
||||
@@ -1175,11 +1226,11 @@ function OverworldState:checkEdgeExit(dir)
|
||||
|
||||
local w = Warp.onEdge(self.map, p.cellX, p.cellY, dir)
|
||||
if w then
|
||||
-- ...but not while still standing on the warp cell we just arrived on
|
||||
-- (issue #230): fall through so pushing into the edge bonks (SFX +
|
||||
-- walk-in-place) instead of instantly re-warping. A real step onto an
|
||||
-- exit-carpet edge cleared warpEntryCell first, so those still fire.
|
||||
if self:onWarpArrivalCell() then return false end
|
||||
-- ...but only with BIT_STANDING_ON_WARP set: a staircase tile clears it,
|
||||
-- so pushing into the edge beside one bonks (SFX + walk-in-place) instead
|
||||
-- of bouncing floors (issue #230), while the door mat you warped in on
|
||||
-- keeps it and exits on that same tile (issue #378).
|
||||
if not self:canCollisionWarp() then return false end
|
||||
self:takeWarp(w.def)
|
||||
return true
|
||||
end
|
||||
@@ -1245,7 +1296,14 @@ function OverworldState:crossConnection(dir, conn)
|
||||
-- still protects the visible step when neighbor rebuild or the sync
|
||||
-- fallback stalls, and avoids the rare one-frame volume spike from a
|
||||
-- song swap mid-step.
|
||||
self:setMap(conn.map, x, y, p.facing, { seamless = true, keepMusic = true })
|
||||
-- Yellow's follower crosses the seam as one continuous walk, so hand the
|
||||
-- live instance through setMap (which rebuilds self.npcs) instead of
|
||||
-- letting it respawn behind the player (#427)
|
||||
local PikachuFollower = require("src.world.PikachuFollower")
|
||||
local pika = PikachuFollower.current(self)
|
||||
local fromX, fromY = p.cellX, p.cellY
|
||||
self:setMap(conn.map, x, y, p.facing,
|
||||
{ seamless = true, keepMusic = true, keepPikachu = pika })
|
||||
self.pendingSeamMusic = conn.map
|
||||
-- place the player one cell before the seam (their old world spot,
|
||||
-- which the neighbor strip renders identically) and start the step
|
||||
@@ -1255,6 +1313,8 @@ function OverworldState:crossConnection(dir, conn)
|
||||
local d = DIRVEC[dir]
|
||||
p.cellX, p.cellY = x - d[1], y - d[2]
|
||||
p.px, p.py = p.cellX * 16, p.cellY * 16
|
||||
-- same translation for the follower and the cell it is chasing
|
||||
PikachuFollower.rebase(self, p.cellX - fromX, p.cellY - fromY)
|
||||
self.camera:follow(p.px, p.py)
|
||||
p.facing = dir
|
||||
p.targetX, p.targetY = x, y
|
||||
@@ -1405,6 +1465,7 @@ function OverworldState:goFishing(rod)
|
||||
-- the bobber waits a beat before the verdict (the original's
|
||||
-- FishingInit dot animation); the rod pose draws in the meantime
|
||||
self.fishing = { facing = self.player.facing }
|
||||
self.player.fishing = true
|
||||
Game.stack:push(TextBox.new(Game, ". . .", function()
|
||||
-- FishingAnim (engine/overworld/player_animations.asm) holds
|
||||
-- BIT_LEDGE_OR_FISHING -- the rod OAM and the fishing pose -- through
|
||||
@@ -1412,12 +1473,20 @@ function OverworldState:goFishing(rod)
|
||||
-- must NOT vanish with the dots box (#321).
|
||||
if not enc then
|
||||
Game.stack:push(TextBox.new(Game, Strings("Not even a nibble!"), function()
|
||||
-- the rod OAM goes out with the verdict box (res BIT_LEDGE_OR_FISHING
|
||||
-- straight after PrintText) but the player keeps the patched tiles
|
||||
-- until the overworld reloads them a few frames later
|
||||
-- (RestoreScreenTilesAndReloadTilePatterns, home/palettes.asm ->
|
||||
-- ReloadMapSpriteTilePatterns, home/reload_sprites.asm) -- #384
|
||||
self.fishing = nil
|
||||
self.fishPose = 10
|
||||
end))
|
||||
return
|
||||
end
|
||||
Game.stack:push(TextBox.new(Game, Strings("Oh!\nIt's a bite!"), function()
|
||||
-- the bite goes straight into battle, which reloads the sprite tiles
|
||||
self.fishing = nil
|
||||
self.player.fishing = nil
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local battle = BattleState.newWild(Game, enc.species, enc.level, { hooked = true })
|
||||
if Game.save.safari and Map.inRegion(self.map.def, "SAFARI", "SAFARI_ZONE") then
|
||||
@@ -2098,25 +2167,26 @@ end
|
||||
-- Gen 1 has no confirmation prompt: using SURF gets straight on
|
||||
-- (_SurfingGotOnText, item_effects.asm .surf). Called from the party
|
||||
-- menu's SURF action (via useSurfFieldMove) once the facing tile has been
|
||||
-- confirmed to be water -- there is no overworld A-press hook.
|
||||
function OverworldState:trySurf(fx, fy)
|
||||
-- confirmed to be water -- there is no overworld A-press hook. onClose is
|
||||
-- that menu's own close, called when the got-on text ends (see below).
|
||||
function OverworldState:trySurf(fx, fy, onClose)
|
||||
local mon = self:partyKnows("SURF")
|
||||
if not mon then return end
|
||||
local name = mon.nickname or Game.data.pokemon[mon.species].name
|
||||
local p = self.player
|
||||
local text = (Game.data.text._SurfingGotOnText or Strings("{PLAYER} got on\n{RAM:wNameBuffer}!"))
|
||||
:gsub("{RAM:wNameBuffer}", name)
|
||||
-- GBPalWhiteOutWithDelay3 runs while the got-on text is still up
|
||||
-- (start_sub_menus.asm .surf), so the blink reads as a text flash
|
||||
-- instead of a flashbang on the empty map (#320). The flash sits
|
||||
-- under the textbox on the stack: only the top state updates, so it
|
||||
-- holds its frames until the text closes. surfing (and the sprite
|
||||
-- swap) only applies when the step happens -- no paddling on land.
|
||||
Game.stack:push(require("src.render.Transition").whiteFlash(Game))
|
||||
-- UseItem prints the got-on text with the party menu still on screen and
|
||||
-- GBPalWhiteOutWithDelay3 + .goBackToMap only run after it
|
||||
-- (start_sub_menus.asm .surf), so the text reads over the menu and the
|
||||
-- blink is the menu closing, not a flashbang on the empty map (#320,
|
||||
-- #385). The mount rides the blink, so nothing paddles on land.
|
||||
Game.stack:push(TextBox.new(Game, text, function()
|
||||
if onClose then onClose() end
|
||||
p.surfing = true
|
||||
require("src.core.Music").setSurfing(Game.data, true)
|
||||
self:stepForwardOrCrossEdge(p.facing)
|
||||
Game.stack:push(require("src.render.Transition").whiteFlash(Game, nil,
|
||||
function() self:stepForwardOrCrossEdge(p.facing) end))
|
||||
end))
|
||||
end
|
||||
|
||||
@@ -3050,18 +3120,16 @@ function OverworldState:onStepComplete()
|
||||
entry = nil
|
||||
end
|
||||
-- The arrival disable is POSITIONAL: warpEntryCell above is the whole
|
||||
-- test. justWarped only records that an arrival happened (it still
|
||||
-- backs onWarpArrivalCell's bonk guard for issue #230), so consuming a
|
||||
-- completed step with it swallowed the warp under the player's feet,
|
||||
-- which is why a second ladder one cell from the first did nothing
|
||||
-- (Seafoam B3F has warp tiles on (25,3) and (25,4)) -- issue #265.
|
||||
-- pokered has no such counter: every completed step runs
|
||||
-- CheckWarpsNoCollision (home/overworld.asm), and BIT_STANDING_ON_WARP,
|
||||
-- the flag the bonk path needs, is only set by
|
||||
-- CheckWarpsNoCollisionLoop itself or by IsPlayerStandingOnWarp from
|
||||
-- MapEntryAfterBattle, never on a plain warp arrival -- which is
|
||||
-- exactly what warpEntryCell reproduces.
|
||||
self.justWarped = false
|
||||
-- test. Consuming a completed step with a one-shot "just warped" counter
|
||||
-- instead swallowed the warp under the player's feet, which is why a
|
||||
-- second ladder one cell from the first did nothing (Seafoam B3F has warp
|
||||
-- tiles on (25,3) and (25,4)) -- issue #265. pokered has no such counter:
|
||||
-- every completed step runs CheckWarpsNoCollision (home/overworld.asm).
|
||||
-- That same step is where BIT_STANDING_ON_WARP is maintained: cleared
|
||||
-- before the check (home/overworld.asm:324), set again while standing on a
|
||||
-- warp square, then cleared once more when the square is a warp-activating
|
||||
-- tile that is not a door tile (IsPlayerStandingOnDoorTileOrWarpTile).
|
||||
self:refreshStandingOnWarp()
|
||||
if entry then
|
||||
-- still standing on the warp we arrived through; do not re-trigger it
|
||||
else
|
||||
@@ -3499,6 +3567,10 @@ function OverworldState:afterBattle(result, battle)
|
||||
{ save = Game.save, healTarget = self:healPoint() })
|
||||
self:warpToHealPoint(evolutions)
|
||||
else
|
||||
-- EndTrainerBattle sets BIT_CUR_MAP_LOADED_1 (home/trainers.asm), which
|
||||
-- re-runs the floor's door callback: beating the last Rocket Hideout guard
|
||||
-- opens the lift gate without leaving the map (#372)
|
||||
self:stampClosedDoors()
|
||||
-- throwing the last SAFARI BALL ends the game
|
||||
if Game.save.safari and Game.save.safari.balls <= 0 then
|
||||
self:safariGameOver(Strings("PA: You're out of\nSAFARI BALLs!"))
|
||||
@@ -3632,12 +3704,12 @@ function OverworldState:startWarpTo(mapId, x, y, facing, onDone, opts)
|
||||
self.arriveWarp = nil
|
||||
Game.stack:push(Transition.new(Game, function()
|
||||
self:setMap(mapId, x, y, facing or "down", opts)
|
||||
self.justWarped = true
|
||||
-- The warp we land ON stays inert until we physically step off it, so a
|
||||
-- warp whose destination cell is itself a warp cannot bounce us straight
|
||||
-- back (elevator cars, stacked stair/door mats). This generalizes the
|
||||
-- one-step justWarped guard, which only skipped the very next frame's
|
||||
-- check and so let a mon walked back onto the pad re-trigger it.
|
||||
-- The warp we land ON stays inert for the completed-step check until we
|
||||
-- physically step off it, so a warp whose destination cell is itself a
|
||||
-- warp cannot bounce us straight back (elevator cars, stacked stair/door
|
||||
-- mats). BIT_STANDING_ON_WARP is deliberately NOT touched here:
|
||||
-- ClearVariablesOnEnterMap leaves wMovementFlags alone, so the flag the
|
||||
-- departing tile set rides through the warp (issue #378).
|
||||
self.warpEntryCell = { x = x, y = y }
|
||||
-- Fly/Teleport/Dig/Escape-Rope landings poof the player back in
|
||||
-- (player_animations.asm EnterMapAnim). Blackouts and ordinary
|
||||
@@ -3663,8 +3735,8 @@ function OverworldState:startWarpTo(mapId, x, y, facing, onDone, opts)
|
||||
-- PlayerStepOutFromDoor (engine/overworld/auto_movement.asm): any
|
||||
-- warp that lands on a door tile auto-steps south once, indoor or
|
||||
-- outdoor. Auto-walk leaves the mat, so the arrival disable
|
||||
-- (warpEntryCell / justWarped) is unnecessary -- and would let you
|
||||
-- stand on the door without re-entering if you hold back into it.
|
||||
-- (warpEntryCell) is unnecessary -- and would let you stand on the
|
||||
-- door without re-entering if you hold back into it.
|
||||
-- The walk-out is a simulated d-pad press (wSimulatedJoypadStates),
|
||||
-- not a forced move, so it obeys collision: on a landing with a
|
||||
-- solid cell south of the door (the mansion stair landings back
|
||||
@@ -3673,7 +3745,6 @@ function OverworldState:startWarpTo(mapId, x, y, facing, onDone, opts)
|
||||
if self.map:isDoorTileCell(self.player.cellX, self.player.cellY) then
|
||||
if Collision.canMove(self.map, self.entities, self.player, "down") then
|
||||
self.warpEntryCell = nil
|
||||
self.justWarped = false
|
||||
self:scriptMove(self.player, "down", 1)
|
||||
else
|
||||
self.player.facing = "down"
|
||||
@@ -4116,23 +4187,6 @@ function OverworldState:drawWorld()
|
||||
end
|
||||
end
|
||||
|
||||
-- Rock Tunnel darkness. The original never cuts a window of light around
|
||||
-- the player: it shifts the BG palette for the WHOLE screen (wMapPalOffset
|
||||
-- = 6 -> home/fade.asm LoadGBPal -> FadePal2 `dc 3,3,3,2`) and FLASH shifts
|
||||
-- it back (#322). PaletteFX.DARK_BGP does that for every shade-remapped
|
||||
-- mode, armed at the top of drawWorld. RED++ is the one mode with no
|
||||
-- palette left to shift -- TileRenderer bakes true colour into the tileset
|
||||
-- atlas and sgbWorldZones hands the blit an EMPTY zone list, so no shader
|
||||
-- runs over the world at all -- so there the darkness is composited instead:
|
||||
-- a flat veil over the whole world view (surveying still cannot peek past
|
||||
-- it) at the 85/255 brightness FadePal2 leaves DMG white on.
|
||||
local function fxDark()
|
||||
if not self:darkNeedsOverlay() then return end
|
||||
love.graphics.setColor(0, 0, 0, 1 - 85 / 255)
|
||||
love.graphics.rectangle("fill", 0, 0, vw, vh)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
-- the FLY bird sweeping off with the player
|
||||
local function fxBird()
|
||||
if not self.flyAnim then return end
|
||||
@@ -4210,7 +4264,7 @@ function OverworldState:drawWorld()
|
||||
return PaletteFX.pal(Game.data, self:paletteNameFor(map or self.map))
|
||||
end,
|
||||
fx = { heal = fxHeal, dust = fxDust, cutTree = fxCutTree,
|
||||
emote = fxEmote, dark = fxDark, bird = fxBird, rod = fxRod },
|
||||
emote = fxEmote, bird = fxBird, rod = fxRod },
|
||||
}
|
||||
-- Draw every active field FX into the finished scene. `project(wx, wy)`
|
||||
-- maps a world point to canvas pixels (nil when it is behind the
|
||||
@@ -4261,17 +4315,6 @@ function OverworldState:drawWorld()
|
||||
if self.fishing then
|
||||
at(fxRod, self.player.px + 8, self.player.py + 16)
|
||||
end
|
||||
-- Rock Tunnel darkness is a screen-space veil, not a ground object:
|
||||
-- draw it flat over the finished scene like the tilt path. It fills
|
||||
-- the view in world-pixel units, so it only needs the scale, and it is
|
||||
-- only needed at all in the mode whose palette cannot carry the
|
||||
-- darkening itself (see fxDark).
|
||||
if self:darkNeedsOverlay() then
|
||||
love.graphics.push()
|
||||
love.graphics.scale(scale, scale)
|
||||
fxDark()
|
||||
love.graphics.pop()
|
||||
end
|
||||
end
|
||||
override = Pipelines.drawWorld(pipelineId, ctx)
|
||||
-- world post-processes (a miniature-diorama blur, a colour grade) fold
|
||||
@@ -4334,7 +4377,6 @@ function OverworldState:drawWorld()
|
||||
fxDust()
|
||||
fxCutTree()
|
||||
fxEmote()
|
||||
fxDark()
|
||||
fxBird()
|
||||
fxRod()
|
||||
else
|
||||
@@ -4423,12 +4465,6 @@ function OverworldState:drawWorld()
|
||||
self:billboard(fx, fy, vw, vh, zoneColorsAt(zones, fx, fy), false, fxRod)
|
||||
end
|
||||
|
||||
-- Rock Tunnel darkness is a screen-space veil, not a ground object --
|
||||
-- draw it flat into the upright canvas so it darkens the final
|
||||
-- composited scene uniformly. It no-ops unless this mode needs the
|
||||
-- composited fallback (see fxDark).
|
||||
fxDark()
|
||||
|
||||
Game.renderer:endUprightPass()
|
||||
end
|
||||
|
||||
@@ -4439,9 +4475,10 @@ 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
|
||||
-- per-emotion frame gfx (gfx/pikachu/unknown_*) are not extracted, so
|
||||
-- the front pic holds for the whole beat while the cry and any emote
|
||||
-- bubble play over the world below (#407).
|
||||
-- per-emotion frame gfx (gfx/pikachu/unknown_*) are not extracted, so the
|
||||
-- front pic stands in for every frame of the script; PikachuFollower
|
||||
-- .picLift lifts it on the runs that draw the alternate pose, and the
|
||||
-- script's own duration times the beat (#407, #424).
|
||||
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
|
||||
@@ -4455,8 +4492,9 @@ function OverworldState:drawUI()
|
||||
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))
|
||||
math.floor(48 + (40 - h) / 2) - lift)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -181,9 +181,29 @@ local function spawnCell(ow)
|
||||
return p.cellX, p.cellY
|
||||
end
|
||||
|
||||
function PikachuFollower.onMapEntered(game, ow)
|
||||
-- the live follower, for a caller that has to carry it across a setMap
|
||||
-- that rebuilds ow.npcs (OverworldState:crossConnection, #427)
|
||||
function PikachuFollower.current(ow)
|
||||
local npc = findFollower(ow)
|
||||
return npc
|
||||
end
|
||||
|
||||
function PikachuFollower.onMapEntered(game, ow, opts)
|
||||
remove(ow)
|
||||
if not shouldSpawn(game, ow) then return end
|
||||
-- opts.keepPikachu is the follower a connection crossing kept alive:
|
||||
-- LoadMapHeader's connection path sets wPikachuSpawnState = 2 and bit 4
|
||||
-- of wPikachuOverworldStateFlags, so SchedulePikachuSpawnForAfterText
|
||||
-- takes .normal_spawn_state -- map coords rebased, sprite data and
|
||||
-- follow command buffer left alone. Re-list the same instance and let
|
||||
-- rebase() shift its cell; a warp arrives without it and respawns
|
||||
-- behind the player, the full spawn path of that same routine.
|
||||
local keep = opts and opts.keepPikachu
|
||||
if keep then
|
||||
table.insert(ow.npcs, keep)
|
||||
table.insert(ow.entities, keep)
|
||||
return
|
||||
end
|
||||
local x, y = spawnCell(ow)
|
||||
local npc = makeFollower(game, ow, x, y, ow.player.facing)
|
||||
table.insert(ow.npcs, npc)
|
||||
@@ -271,6 +291,14 @@ end
|
||||
local function idleTick(ow, npc)
|
||||
-- Func_fc82e: a step in progress ends the idle state outright
|
||||
if ow.player.moving then idleReset(npc) return end
|
||||
-- Every counter below burns one unit per UpdateSprites call, and a
|
||||
-- standing OverworldLoop spends two DelayFrames on each pass (home/
|
||||
-- overworld.asm: OverworldLoop delays, falls into OverworldLoopLessDelay
|
||||
-- which delays again, then .noDirectionButtonsPressed loops back), so the
|
||||
-- whole Func_fc803 family runs at half this port's 60Hz fixed step: the
|
||||
-- first glance is $20 CALLS, 64 frames, not 32 (#424).
|
||||
npc.idleClock = ((npc.idleClock or 0) + 1) % 2
|
||||
if npc.idleClock ~= 0 then return end
|
||||
local idle = npc.idle
|
||||
if not idle then
|
||||
idle = { kind = "wait", frames = IDLE_LOOK }
|
||||
@@ -339,6 +367,28 @@ local function ledgeStep(game, ow, cx, cy, dir)
|
||||
return false
|
||||
end
|
||||
|
||||
-- Slide the follower into the connected map's coordinate frame by the
|
||||
-- delta crossConnection applied to the player: the two maps are one
|
||||
-- continuous world, so a seam is a pure translation. MapX/MapY in
|
||||
-- wSpritePikachuStateData2 are all .normal_spawn_state rewrites there,
|
||||
-- never the pixel coords, which is why the original walks through a seam
|
||||
-- instead of popping (#427).
|
||||
function PikachuFollower.rebase(ow, dx, dy)
|
||||
local npc = findFollower(ow)
|
||||
if npc then
|
||||
npc.cellX, npc.cellY = npc.cellX + dx, npc.cellY + dy
|
||||
npc.px, npc.py = npc.px + dx * 16, npc.py + dy * 16
|
||||
if npc.targetX then npc.targetX = npc.targetX + dx end
|
||||
if npc.targetY then npc.targetY = npc.targetY + dy end
|
||||
if npc.goalX then npc.goalX = npc.goalX + dx end
|
||||
if npc.goalY then npc.goalY = npc.goalY + dy end
|
||||
-- Func_fc82e: the player is taking a step, so any idle pose is over
|
||||
if npc.idle then idleReset(npc) end
|
||||
end
|
||||
local trail = ow.pikachuTrail
|
||||
if trail then trail.x, trail.y = trail.x + dx, trail.y + dy end
|
||||
end
|
||||
|
||||
-- one follow step per frame: chase the cell the player last vacated
|
||||
-- (pikachu_follow.asm keeps it one walk step behind)
|
||||
function PikachuFollower.update(game, ow)
|
||||
@@ -371,8 +421,27 @@ function PikachuFollower.update(game, ow)
|
||||
local destX = p.targetX or p.cellX
|
||||
local destY = p.targetY or p.cellY
|
||||
if destX ~= trail.x or destY ~= trail.y then
|
||||
npc.goalX, npc.goalY = trail.x, trail.y
|
||||
trail.x, trail.y = destX, destY
|
||||
local stepDir = destY > trail.y and "down" or destY < trail.y and "up"
|
||||
or destX > trail.x and "right" or "left"
|
||||
-- A ledge hop commits TWO steps (checkLedgeHop -> scriptMove(p, dir, 2),
|
||||
-- the two simulated presses of HandleLedges) but only ONE follow
|
||||
-- command: Func_fcc08 sees BIT_LEDGE_OR_FISHING and defers to
|
||||
-- Func_fcc64, which appends the $5-$8 hop on the takeoff step and
|
||||
-- appends nothing on the landing step (bit 6 of
|
||||
-- wPikachuOverworldStateFlags toggles between the two). With no command
|
||||
-- behind it the hop cannot leave the buffer -- Func_fcc92 only pops once
|
||||
-- a second command is queued -- so Pikachu walks up to the cell the
|
||||
-- player took off from, waits there two cells behind (the Func_fc842
|
||||
-- idle rolls), and hops one player step later (#424, after #409).
|
||||
if trail.ledgeHop == stepDir then
|
||||
trail.ledgeHop = nil
|
||||
trail.x, trail.y = destX, destY
|
||||
else
|
||||
trail.ledgeHop = ledgeStep(game, ow, trail.x, trail.y, stepDir)
|
||||
and stepDir or nil
|
||||
npc.goalX, npc.goalY = trail.x, trail.y
|
||||
trail.x, trail.y = destX, destY
|
||||
end
|
||||
end
|
||||
-- standing still with nothing to chase is the idle state (Func_fc803);
|
||||
-- once a step is under way NPC:update owns px/py, so only the idle
|
||||
@@ -404,7 +473,9 @@ function PikachuFollower.update(game, ow)
|
||||
npc.targetX = npc.cellX + (dir == "right" and 1 or dir == "left" and -1 or 0)
|
||||
npc.targetY = npc.cellY + (dir == "down" and 1 or dir == "up" and -1 or 0)
|
||||
-- the cell ahead is the ledge the player hopped: clear both cells in one
|
||||
-- step instead of stopping on the ledge (#409). pikachu_follow.asm
|
||||
-- step instead of stopping on the ledge (#409). The trail above holds
|
||||
-- this back until the player commits a further step, so it fires from the
|
||||
-- cell on top of the ledge, a step late (#424). pikachu_follow.asm
|
||||
-- Func_fcc08 appends the $5-$8 hop commands while BIT_LEDGE_OR_FISHING
|
||||
-- is set, and Func_fca0a runs them as two AddPikachuStepVector cells over
|
||||
-- one normal step's frames -- no arc and no shadow, the hop command only
|
||||
@@ -422,7 +493,12 @@ function PikachuFollower.update(game, ow)
|
||||
-- steps are queued (AreThereAtLeastTwoStepsInPikachuFollowCommandBuffer:
|
||||
-- walk counter $4 instead of NormalPikachuFollow's $8).
|
||||
local stepLen = p.stepFramesCur or p.stepFrames or 16
|
||||
if far > 1 then stepLen = math.max(1, math.floor(stepLen / 2)) end
|
||||
-- the hop is never a Fast step: Func_fc7aa jumps to Func_fca0a on the $4
|
||||
-- movement status BEFORE it asks AreThereAtLeastTwoSteps..., so its two
|
||||
-- cells ride one normal step's frames even though the goal is two away.
|
||||
if far > 1 and not npc.hopStep then
|
||||
stepLen = math.max(1, math.floor(stepLen / 2))
|
||||
end
|
||||
npc.stepFrames = stepLen
|
||||
npc.moving = true
|
||||
npc.progress = 0
|
||||
@@ -500,6 +576,60 @@ local MOOD_MATRIX = {
|
||||
-- .Emotions): scripted one-shots -- 21 is the fishing-rod reaction
|
||||
local MODIFIER_EMOTIONS = { 18, 21, 23, 24, 25 }
|
||||
|
||||
-- ExecutePikaPicAnimScript spends a Delay3 on every pass of its loop
|
||||
-- (pikachu_pic_animation.asm PikaPicAnimTimerAndJoypad), so one script tick
|
||||
-- is three 60Hz frames: the flat 50 frame hold this port used was under a
|
||||
-- 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
|
||||
|
||||
-- pikaemotion_pikapic's script id per emotion: emotion N takes
|
||||
-- PikaPicAnimScript N, except the four listed here (data/pikachu/
|
||||
-- pikachu_emotions.asm).
|
||||
local PIKAPIC_SCRIPT = { [29] = 10, [30] = 20, [31] = 23, [32] = 23 }
|
||||
|
||||
-- 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:
|
||||
-- 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.
|
||||
local PIKAPIC = {
|
||||
[1] = { dur = 40 },
|
||||
[2] = { dur = 44 },
|
||||
[3] = { dur = 80 },
|
||||
[4] = { dur = 70, seq = { 8, 8, 20, 8 } },
|
||||
[5] = { dur = 32 },
|
||||
[6] = { dur = 50 },
|
||||
[7] = { dur = 58, seq = { 0, 8, 2, 8, 2, 8 } },
|
||||
[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 } },
|
||||
[26] = { dur = 100, seq = { 20, 8, 20, 116 } },
|
||||
[27] = { dur = 30, seq = { 4, 100 } },
|
||||
[28] = { dur = 64, seq = { 12, 12, 12, 100 } },
|
||||
}
|
||||
|
||||
local function moodEmotion(save)
|
||||
local mood = save.pikachuMood or 128
|
||||
local column = 5
|
||||
@@ -579,17 +709,38 @@ function PikachuFollower.talk(game, ow, npc, done)
|
||||
-- ends with one, and its box is the only thing most of them put on
|
||||
-- screen (emotion 5, the fresh-save cell, has no bubble at all). The
|
||||
-- 40x40 front pic is the size of PikaAnimTilemap_1's 5x5 base frame;
|
||||
-- Sprites.path keeps a mod's replacement skin in play. The scripts'
|
||||
-- 32-58 frame durations bracket the hold below, so it stays at 50.
|
||||
-- Sprites.path keeps a mod's replacement skin in play.
|
||||
local Sprites = require("src.pokemon.Sprites")
|
||||
local pic = Sprites.path(game.data, "PIKACHU", "front",
|
||||
{ kind = "overworld" })
|
||||
local anim = PIKAPIC[PIKAPIC_SCRIPT[emotion] or emotion] or PIKAPIC[1]
|
||||
local hold = anim.dur * PIKAPIC_TICK
|
||||
ow.emote = {
|
||||
npc = npc, frames = 50, bubble = bi or false, pikaPic = pic,
|
||||
onDone = done,
|
||||
npc = npc, frames = hold, bubble = bi or false, pikaPic = pic,
|
||||
pikaSeq = anim.seq, pikaTotal = hold, skippable = true, onDone = done,
|
||||
}
|
||||
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 seq = emote and emote.pikaSeq
|
||||
if not seq then return 0 end
|
||||
local loop = 0
|
||||
for _, run in ipairs(seq) do loop = loop + run end
|
||||
if loop <= 0 then return 0 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
|
||||
tick = tick - run
|
||||
end
|
||||
return 0
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- PikachuWalksToNurseJoy (engine/pikachu/pikachu_emotions.asm, run by
|
||||
-- engine/events/pokecenter.asm once the heal is accepted): the companion
|
||||
|
||||
+29
-1
@@ -42,6 +42,20 @@ function Player.new(data, cx, cy, facing)
|
||||
local ok, img = pcall(love.graphics.newImage, fx.shadow.path)
|
||||
self.shadowImg = ok and img or nil
|
||||
end
|
||||
-- FishingAnim (engine/overworld/player_animations.asm) patches tiles
|
||||
-- $02/$06/$0a -- the bottom tile row of each standing frame -- with
|
||||
-- RedFishingTiles before it parks the rod OAM, so the rod stroke meets a
|
||||
-- pair of hands instead of ending in mid air (#384)
|
||||
if fx then
|
||||
local function posePath(name)
|
||||
local def = fx[name]
|
||||
return def and def.path or nil
|
||||
end
|
||||
local pose = { down = posePath("redFishFront"), up = posePath("redFishBack") }
|
||||
pose.left = posePath("redFishSide")
|
||||
pose.right = pose.left -- the side pose mirrors like the sprite (OAM_XFLIP)
|
||||
if pose.down or pose.up or pose.left then self.fishTiles = pose end
|
||||
end
|
||||
self.cellX, self.cellY = cx, cy
|
||||
self.px, self.py = cx * 16, cy * 16
|
||||
self.facing = facing or "down"
|
||||
@@ -231,7 +245,10 @@ function Player:pose()
|
||||
py = py - math.floor((total - self.spinFrames) * 24 / total)
|
||||
end
|
||||
end
|
||||
local sprite = (self.surfing and self.surfSprite)
|
||||
-- RodResponse (engine/items/item_effects.asm) zeroes wWalkBikeSurfState
|
||||
-- across FishingAnim, so casting from the water shows the on-foot sheet
|
||||
local sprite = (self.fishing and self.sprite)
|
||||
or (self.surfing and self.surfSprite)
|
||||
or (self.onBike and self.bikeSprite) or self.sprite
|
||||
return sprite, self.px, py, facing, phase, flip, hopping
|
||||
end
|
||||
@@ -263,6 +280,17 @@ function Player:draw(camX, camY)
|
||||
love.graphics.draw(self.shadowImg, sx + 16, sy + 16, 0, -1, -1)
|
||||
end
|
||||
end
|
||||
-- Fishing pose: the standing frame with its bottom tile row swapped for
|
||||
-- RedFishingTiles, which is where the hands and the near half of the rod
|
||||
-- live; the far half is the rod OAM OverworldState draws (FishingRodOAM,
|
||||
-- engine/overworld/player_animations.asm) -- #384
|
||||
local fishTile = self.fishing and self.fishTiles and self.fishTiles[facing]
|
||||
if fishTile then
|
||||
sprite:draw(px, py, camX, camY, facing, 0, false, true)
|
||||
sprite:drawTile(fishTile, math.floor(px - camX),
|
||||
math.floor(py - camY) - 4 + 8, facing == "right")
|
||||
return
|
||||
end
|
||||
sprite:draw(px, py, camX, camY, facing, phase, flip)
|
||||
end
|
||||
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
-- Driver: a POTION used in battle prints, moves the HUD bar and spends the
|
||||
-- turn (#379). pokered engine/items/item_effects.asm:1-3 leaves
|
||||
-- wActionResultOrTookBattleTurn at its success value, and ItemUseMedicine only
|
||||
-- clears it on .healingItemNoEffect (:1241), so a medicine that lands costs
|
||||
-- the turn; the party-menu bar fill is the field case (#252). Data half:
|
||||
-- tests/parity_battle_item_turn.lua. Never under POKEPORT_SPEED: fast-forward
|
||||
-- desynchronizes SFX_HEAL_HP from the bar.
|
||||
-- POKEPORT_DRIVER=tests/drivers/battle_item_turn_bug379_test.lua POKEPORT_IDENTITY=bug379 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local Bag = require("src.inventory.Bag")
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local ItemEffects = require("src.inventory.ItemEffects")
|
||||
local PartyMenu = require("src.ui.PartyMenu")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
|
||||
local pass, fail = 0, 0
|
||||
local function check(label, ok)
|
||||
if ok then pass = pass + 1 else fail = fail + 1 end
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
local function top() return game.stack:top() end
|
||||
local function isPicker(s)
|
||||
return s ~= nil and (s.screenId == "PartyMenu" or getmetatable(s) == PartyMenu)
|
||||
end
|
||||
local function isBox(s) return getmetatable(s) == TextBox end
|
||||
local function isBag(s) return s ~= nil and s.screenId == "BagMenu" end
|
||||
local function inStack(pred)
|
||||
for _, s in ipairs(game.stack.states or {}) do
|
||||
if pred(s) then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
local function boxText(s)
|
||||
local out = {}
|
||||
for _, page in ipairs(s.pages or {}) do
|
||||
for _, line in ipairs(page) do out[#out + 1] = line end
|
||||
end
|
||||
return table.concat(out, " ")
|
||||
end
|
||||
|
||||
-- ---- preconditions -----------------------------------------------------
|
||||
U.log("#379 in-battle medicine: machine checks")
|
||||
check("POTION takes the HP-medicine path", ItemEffects.healsHP("POTION") == true)
|
||||
check("ANTIDOTE does not (status cure, always spent the turn)",
|
||||
ItemEffects.healsHP("ANTIDOTE") ~= true)
|
||||
for _, id in ipairs({ "POTION", "SUPER_POTION", "ANTIDOTE" }) do
|
||||
check(id .. " resolves in the item table", game.data.items[id] ~= nil)
|
||||
end
|
||||
check("SFX_HEAL_HP is in the generated audio",
|
||||
game.data.audio and game.data.audio.sfx
|
||||
and game.data.audio.sfx.Heal_HP ~= nil)
|
||||
check("BattleState:itemUsed exists (queues the foe's turn)",
|
||||
type(BattleState.itemUsed) == "function")
|
||||
|
||||
local vol = game.save.options and game.save.options.sfxVol
|
||||
if vol == 0 then
|
||||
U.log("sfxVol is 0: the heal chime will be SILENT, raise it in OPTION first")
|
||||
else
|
||||
U.log("sfxVol", tostring(vol), "-- expect the heal chime under the bar drain")
|
||||
end
|
||||
|
||||
-- use() must hand back the pre-heal HP; without it neither bar has a start
|
||||
do
|
||||
local scratch = Pokemon.new(game.data, "BULBASAUR", 20)
|
||||
scratch.hp = 4
|
||||
local scratchSave = { party = { scratch }, inventory = {}, flags = {},
|
||||
pokedex = { seen = {}, owned = {} } }
|
||||
local result, msgs, extra = ItemEffects.use(game.data, scratchSave,
|
||||
"POTION", scratch)
|
||||
check("POTION on a hurt mon reports consumed", result == "consumed")
|
||||
check("...and hands back the pre-heal HP",
|
||||
type(extra) == "table" and extra.healedFrom == 4)
|
||||
check("...with the restored-HP message",
|
||||
type(msgs) == "table" and type(msgs[1]) == "string"
|
||||
and msgs[1]:find("restored", 1, true) ~= nil)
|
||||
end
|
||||
|
||||
-- ---- fixture -----------------------------------------------------------
|
||||
local lead = Pokemon.new(game.data, "CHARIZARD", 50)
|
||||
local second = Pokemon.new(game.data, "PIKACHU", 30)
|
||||
lead.hp = 12
|
||||
game.save.party = { lead, second }
|
||||
game.save.player.name = "RED"
|
||||
for _, id in ipairs({ "POTION", "SUPER_POTION", "ANTIDOTE" }) do
|
||||
Bag.add(game.save, id, 9)
|
||||
end
|
||||
|
||||
-- ROUTE_1 is open field with tall grass either side of the path
|
||||
-- (data/generated/maps.lua ROUTE_1); the cell comes off the loaded map so a
|
||||
-- map edit degrades to somewhere else in the grass instead of a wall.
|
||||
U.teleport(game, "ROUTE_1", 5, 5, "down")
|
||||
U.wait(10)
|
||||
local ow = game.overworld
|
||||
local map = ow.map
|
||||
local gx, gy
|
||||
if not map:isWalkableCell(5, 5) then
|
||||
for cy = 0, map.heightCells - 1 do
|
||||
for cx = 0, map.widthCells - 1 do
|
||||
if map:isGrassCell(cx, cy) and map:isWalkableCell(cx, cy) then
|
||||
gx, gy = cx, cy
|
||||
break
|
||||
end
|
||||
end
|
||||
if gx then break end
|
||||
end
|
||||
if gx then
|
||||
U.teleport(game, "ROUTE_1", gx, gy, "down")
|
||||
U.wait(10)
|
||||
ow = game.overworld
|
||||
end
|
||||
end
|
||||
check("player is standing on a walkable ROUTE_1 cell",
|
||||
ow.map:isWalkableCell(ow.player.cellX, ow.player.cellY))
|
||||
|
||||
-- ---- navigation --------------------------------------------------------
|
||||
local function mashUntil(cond, max)
|
||||
for _ = 1, max or 120 do
|
||||
if cond() then return true end
|
||||
U.tap(game, "a")
|
||||
U.wait(4)
|
||||
end
|
||||
return cond()
|
||||
end
|
||||
|
||||
-- FIGHT PKMN / ITEM RUN (BattleState.menuIndex): down from FIGHT is ITEM
|
||||
local function openBagFrom(battle)
|
||||
for _ = 1, 30 do
|
||||
if battle.menuIndex == 3 then break end
|
||||
U.tap(game, battle.menuIndex > 2 and "up" or "down")
|
||||
U.wait(4)
|
||||
end
|
||||
if battle.menuIndex ~= 3 then return nil, "ITEM cell unreachable" end
|
||||
U.tap(game, "a")
|
||||
U.wait(20)
|
||||
local bag = top()
|
||||
if not isBag(bag) then return nil, "bag never opened" end
|
||||
return bag
|
||||
end
|
||||
|
||||
local function cursorTo(menu, want)
|
||||
for _ = 1, 40 do
|
||||
if not menu or menu.index == want then return menu and menu.index == want end
|
||||
U.tap(game, menu.index < want and "down" or "up")
|
||||
U.wait(3)
|
||||
end
|
||||
return menu.index == want
|
||||
end
|
||||
|
||||
local function chooseItem(bag, id)
|
||||
local row
|
||||
for i, r in ipairs(bag.items or {}) do
|
||||
if r.value == id then row = i break end
|
||||
end
|
||||
if not row or not cursorTo(bag, row) then return false end
|
||||
U.tap(game, "a") -- in battle A uses it straight away, no USE / TOSS
|
||||
U.wait(16)
|
||||
return true
|
||||
end
|
||||
|
||||
local function newFight()
|
||||
local battle = BattleState.newWild(game, "PIDGEY", 8)
|
||||
battle.onFinish = function() end
|
||||
game.overworld:pushBattle(battle)
|
||||
mashUntil(function() return battle.phase == "menu" end)
|
||||
return battle
|
||||
end
|
||||
|
||||
-- ======== scripted run: POTION on the hurt lead ==========================
|
||||
U.log("#379 scripted run: POTION on a 12 HP CHARIZARD, mid-battle")
|
||||
local battle = newFight()
|
||||
check("the wild battle reached its FIGHT menu", battle.phase == "menu")
|
||||
|
||||
local turns, ends = 0, 0
|
||||
local realAction, realEnd = battle.executeAction, battle.endOfTurn
|
||||
battle.executeAction = function(self, ...)
|
||||
turns = turns + 1
|
||||
return realAction(self, ...)
|
||||
end
|
||||
battle.endOfTurn = function(self, ...)
|
||||
ends = ends + 1
|
||||
return realEnd(self, ...)
|
||||
end
|
||||
|
||||
local bag, why = openBagFrom(battle)
|
||||
check("ITEM opened the bag" .. (why and (" (" .. why .. ")") or ""), bag ~= nil)
|
||||
if bag then
|
||||
U.shot(game, DIR .. "/bug379_bag_in_battle.png")
|
||||
check("POTION chosen from the bag", chooseItem(bag, "POTION"))
|
||||
local picker = top()
|
||||
check("the party picker opened to pick a target", isPicker(picker))
|
||||
if isPicker(picker) then
|
||||
check("keepOpen is off in battle (the fill is the field case, #252)",
|
||||
picker.keepOpen ~= true)
|
||||
cursorTo(picker, 1)
|
||||
U.tap(game, "a")
|
||||
U.wait(10)
|
||||
|
||||
check("the mon was healed", lead.hp > 12)
|
||||
check("no party-menu bar fill was started in battle", picker.heal == nil)
|
||||
check("the picker popped itself", not inStack(isPicker))
|
||||
check("the bag list closed underneath it", not inStack(isBag))
|
||||
|
||||
-- THE DEFECT: the animate branch swallowed this message, and with it the
|
||||
-- itemUsed tail that spends the turn
|
||||
for _ = 1, 60 do
|
||||
if isBox(top()) then break end
|
||||
U.wait(1)
|
||||
end
|
||||
local box = top()
|
||||
check("the restored-HP message printed (#379)", isBox(box))
|
||||
if isBox(box) then
|
||||
U.wait(50)
|
||||
local said = boxText(box)
|
||||
U.log("box reads:", said)
|
||||
check("...and it is the restored-HP line",
|
||||
said:find("restored", 1, true) ~= nil)
|
||||
U.shot(game, DIR .. "/bug379_message_in_battle.png")
|
||||
end
|
||||
|
||||
-- the turn: drain, the foe's move, end-of-turn, back to the menu
|
||||
for _ = 1, 400 do
|
||||
if battle.phase == "menu" and #battle.queue == 0 then break end
|
||||
U.tap(game, "a")
|
||||
U.wait(4)
|
||||
end
|
||||
check("the foe took its turn after the item (#379)", turns >= 1)
|
||||
check("end-of-turn effects ran (#379)", ends >= 1)
|
||||
check("and the FIGHT menu came back", battle.phase == "menu")
|
||||
U.shot(game, DIR .. "/bug379_back_on_menu.png")
|
||||
end
|
||||
end
|
||||
|
||||
-- ======== control: a refused POTION is free =============================
|
||||
U.log("#379 control: a POTION on a full-HP mon is refused, turn NOT spent")
|
||||
lead.hp = lead.stats.hp
|
||||
local freeTurns = 0
|
||||
battle.executeAction = function(self, ...)
|
||||
freeTurns = freeTurns + 1
|
||||
return realAction(self, ...)
|
||||
end
|
||||
local held = game.save.inventory.POTION or 0
|
||||
local bag2 = openBagFrom(battle)
|
||||
if check("the bag reopened for the refusal", bag2 ~= nil) then
|
||||
chooseItem(bag2, "POTION")
|
||||
local picker = top()
|
||||
if isPicker(picker) then
|
||||
cursorTo(picker, 1)
|
||||
U.tap(game, "a")
|
||||
U.wait(12)
|
||||
end
|
||||
for _ = 1, 60 do
|
||||
if isBox(top()) then break end
|
||||
U.wait(1)
|
||||
end
|
||||
local box = top()
|
||||
if check("the refusal printed", isBox(box)) then
|
||||
U.wait(40)
|
||||
check("...as \"It won't have any effect.\"",
|
||||
boxText(box):find("effect", 1, true) ~= nil)
|
||||
U.shot(game, DIR .. "/bug379_refused.png")
|
||||
end
|
||||
check("a refused item costs no turn (item_effects.asm:1241)", freeTurns == 0)
|
||||
check("and the POTION was not consumed",
|
||||
(game.save.inventory.POTION or 0) == held)
|
||||
end
|
||||
|
||||
U.log(("machine checks: %d passed, %d failed"):format(pass, fail))
|
||||
|
||||
-- ---- re-arm and hand the pad over --------------------------------------
|
||||
for _ = 1, 200 do
|
||||
if battle.phase == "menu" and #battle.queue == 0 then break end
|
||||
U.tap(game, "a")
|
||||
U.wait(4)
|
||||
end
|
||||
lead.hp = 12
|
||||
local handoff = openBagFrom(battle)
|
||||
if handoff then
|
||||
for i, r in ipairs(handoff.items or {}) do
|
||||
if r.value == "POTION" then cursorTo(handoff, i) break end
|
||||
end
|
||||
end
|
||||
|
||||
U.log("The ITEMS list is open mid-battle with the cursor on POTION. Press A,")
|
||||
U.log("pick CHARIZARD: the picker and the list both close, \"CHARIZARD's HP was")
|
||||
U.log("restored!\" prints with the heal chime, the HUD bar climbs, then PIDGEY")
|
||||
U.log("attacks before FIGHT comes back. #379 was no message and a free heal.")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,324 @@
|
||||
-- Driver: the applying-attack shake pokered plays after a move's own
|
||||
-- animation (#354). PlayApplyingAttackAnimation dispatches wAnimationType
|
||||
-- 1..6 through AnimationTypePointerTable (engine/battle/animations.asm:475);
|
||||
-- only 1 (vertical shake) and 4 (blink the enemy pic) were wired, so an
|
||||
-- added-effect move like BUBBLEBEAM blinked instead of shaking sideways and a
|
||||
-- status move showed nothing. Wiring half: tests/parity_applying_attack_anim.lua.
|
||||
-- Never under POKEPORT_SPEED: fast-forward desynchronizes the damage sound.
|
||||
-- POKEPORT_DRIVER=tests/drivers/battle_shake_bug354_test.lua POKEPORT_IDENTITY=bug354 POKEPORT_TOUCH=0 SHOT_DIR=/tmp/shots love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
|
||||
local pass, fail = 0, 0
|
||||
local function check(label, ok)
|
||||
if ok then pass = pass + 1 else fail = fail + 1 end
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
-- pokered core.asm:3159 / effects.asm:1448: a player damaging move with an
|
||||
-- added effect is type 5, without one type 4, and a primary status effect
|
||||
-- that ends in PlayCurrentMoveAnimation2 is type 6. `frames` counts only
|
||||
-- the frames the screen sits off-centre: b=2 out for 5 frames twice, and
|
||||
-- the slow creep's five off-centre steps of 2 frames, twice over.
|
||||
local MOVES = {
|
||||
{ slot = 1, id = "BUBBLEBEAM", want = 5, peak = 2, frames = 10 },
|
||||
{ slot = 4, id = "TACKLE", want = 4 },
|
||||
{ slot = 3, id = "GROWL", want = 6, peak = 3, frames = 20 },
|
||||
{ slot = 2, id = "HYPNOSIS", want = 6, peak = 3, frames = 20 },
|
||||
}
|
||||
|
||||
U.log("#354 applying-attack shake: machine checks")
|
||||
for _, m in ipairs(MOVES) do
|
||||
check(m.id .. " is in the move table", game.data.moves[m.id] ~= nil)
|
||||
end
|
||||
local anims = game.data.battle_anims and game.data.battle_anims.moveAnims
|
||||
check("battle_anims carries BUBBLEBEAM's own animation",
|
||||
anims ~= nil and anims.BUBBLEBEAM ~= nil)
|
||||
check("...and HYPNOSIS's", anims ~= nil and anims.HYPNOSIS ~= nil)
|
||||
check("BattleState:applyHitFx exists", type(BattleState.applyHitFx) == "function")
|
||||
check("animations are on in OPTIONS (a shake is gated on them)",
|
||||
game.save.options.animations ~= false)
|
||||
|
||||
local vol = game.save.options and game.save.options.sfxVol
|
||||
if vol == 0 then
|
||||
U.log("sfxVol is 0: the damage thud under the shake will be SILENT,")
|
||||
U.log("raise it in OPTION before judging the sound")
|
||||
else
|
||||
U.log("sfxVol", tostring(vol), "-- the damage thud opens types 1, 2, 4 and 5;")
|
||||
U.log("types 3 and 6 (the slow creeps) are silent on purpose")
|
||||
end
|
||||
|
||||
-- ---- fixture -----------------------------------------------------------
|
||||
local squirtle = Pokemon.new(game.data, "SQUIRTLE", 30)
|
||||
squirtle.moves = {}
|
||||
for _, m in ipairs(MOVES) do
|
||||
squirtle.moves[m.slot] = { id = m.id, pp = game.data.moves[m.id].pp }
|
||||
end
|
||||
game.save.party = { squirtle }
|
||||
game.save.player.name = "RED"
|
||||
|
||||
-- data/generated/maps.lua ROUTE_1: (5, 5) is open walkable ground, and the
|
||||
-- battle is pushed straight in rather than encountered
|
||||
U.teleport(game, "ROUTE_1", 5, 5, "down")
|
||||
U.wait(10)
|
||||
local ow = game.overworld
|
||||
if not ow.map:isWalkableCell(5, 5) then
|
||||
-- a map edit moved the path: take the nearest walkable cell instead
|
||||
local sx, sy
|
||||
for r = 1, 6 do
|
||||
for dy = -r, r do
|
||||
for dx = -r, r do
|
||||
if not sx and ow.map:isWalkableCell(5 + dx, 5 + dy) then
|
||||
sx, sy = 5 + dx, 5 + dy
|
||||
end
|
||||
end
|
||||
end
|
||||
if sx then break end
|
||||
end
|
||||
if sx then
|
||||
U.log("(5, 5) is blocked, standing on", sx, sy)
|
||||
U.teleport(game, "ROUTE_1", sx, sy, "down")
|
||||
U.wait(10)
|
||||
ow = game.overworld
|
||||
end
|
||||
end
|
||||
check("the overworld is up on ROUTE_1", ow ~= nil)
|
||||
|
||||
local battle = BattleState.newWild(game, "PIDGEY", 20)
|
||||
battle.onFinish = function() end
|
||||
ow:pushBattle(battle)
|
||||
for _ = 1, 400 do
|
||||
if game.stack:top() == battle and (battle.introSlide or 0) == 0 then break end
|
||||
U.wait(1)
|
||||
end
|
||||
check("the battle reached the screen", game.stack:top() == battle)
|
||||
|
||||
-- a foe that survives four turns: every type needs its own turn to show
|
||||
battle.enemy.mon.stats.hp = 400
|
||||
battle.enemy.mon.hp = 400
|
||||
battle.enemy.shownHP = 400
|
||||
|
||||
-- A PIDGEY that level knows WHIRLWIND, and in a wild battle that blows the
|
||||
-- player out of the fight (result "run") the moment the foe picks it, which
|
||||
-- takes the untested types down with it. Drop it and the foe keeps the
|
||||
-- GUST / SAND-ATTACK / QUICK ATTACK the hand-off notes below describe.
|
||||
-- curMoves aliases mon.moves, so one pass covers both.
|
||||
for i = #battle.enemy.mon.moves, 1, -1 do
|
||||
local id = battle.enemy.mon.moves[i].id
|
||||
if id == "WHIRLWIND" or id == "ROAR" or id == "TELEPORT" then
|
||||
table.remove(battle.enemy.mon.moves, i)
|
||||
end
|
||||
end
|
||||
|
||||
-- ---- watch the fx layer ------------------------------------------------
|
||||
-- one entry per applying-attack row, labelled with the move that queued it
|
||||
local rows, cur, using = {}, nil, nil
|
||||
local realPerform, realFx = battle.performMove, battle.applyHitFx
|
||||
battle.performMove = function(self, user, target, moveInst, ...)
|
||||
using = { id = moveInst and moveInst.id, isPlayer = user.isPlayer }
|
||||
return realPerform(self, user, target, moveInst, ...)
|
||||
end
|
||||
battle.applyHitFx = function(self, hit)
|
||||
cur = { move = using and using.id, isPlayer = using and using.isPlayer,
|
||||
animType = hit.animType, frames = 0, peakX = 0, peakY = 0 }
|
||||
rows[#rows + 1] = cur
|
||||
realFx(self, hit)
|
||||
-- a spent blink table stays on fx with frames = 0, so freshness matters
|
||||
local bl = self.fx and self.fx.blink
|
||||
cur.blinked = bl ~= nil and bl.frames > 0
|
||||
end
|
||||
|
||||
local function sample()
|
||||
local fx = battle.fx
|
||||
if not (cur and fx) then return end
|
||||
local dx, dy = math.abs(fx.shakeX or 0), math.abs(fx.shakeY or 0)
|
||||
if dx > 0 or dy > 0 then
|
||||
cur.frames = cur.frames + 1
|
||||
cur.peakX = math.max(cur.peakX, dx)
|
||||
cur.peakY = math.max(cur.peakY, dy)
|
||||
end
|
||||
end
|
||||
|
||||
-- step n frames, sampling every one, pressing A every `mash` frames -- but
|
||||
-- only while a box is up. An A that lands on the FIGHT menu opens the move
|
||||
-- list and fires the highlighted move, so mashing past the end of a turn
|
||||
-- starts extra turns behind the driver's back and eventually ends the battle.
|
||||
local function pump(n, mash, stop)
|
||||
for i = 1, n do
|
||||
if mash and i % mash == 0 and battle.phase == "messages" then
|
||||
table.insert(game.input.pressQueue, "a")
|
||||
end
|
||||
U.wait(1)
|
||||
game.input.state.a = false
|
||||
sample()
|
||||
if stop and stop() then return end
|
||||
end
|
||||
end
|
||||
|
||||
-- a full turn is the player's announcement, animation and damage drain plus
|
||||
-- the foe's, so budget frames for the slowest of them rather than the typical
|
||||
-- one: a short budget hands the next move a battle still in `messages`
|
||||
local function toMenu()
|
||||
pump(1200, 6, function() return battle.phase == "menu" and #battle.queue == 0 end)
|
||||
return battle.phase == "menu"
|
||||
end
|
||||
|
||||
-- The move list is a flat column in the classic layout, but the wide one
|
||||
-- lays the four slots out 2x2 (WideBattle.navigate): up/down swap rows and
|
||||
-- left/right swap columns, so walking down the list there never reaches
|
||||
-- slots 2 and 4. Return the press that closes the gap in either layout.
|
||||
local function towardSlot(slot)
|
||||
if battle.moveIndex == slot then return nil end
|
||||
if battle:wideLayout() then
|
||||
local row, col = math.floor((battle.moveIndex - 1) / 2), (battle.moveIndex - 1) % 2
|
||||
local wantRow, wantCol = math.floor((slot - 1) / 2), (slot - 1) % 2
|
||||
if row ~= wantRow then return row < wantRow and "down" or "up" end
|
||||
return col < wantCol and "right" or "left"
|
||||
end
|
||||
return battle.moveIndex < slot and "down" or "up"
|
||||
end
|
||||
|
||||
-- FIGHT is menuIndex 1 of the 2x2 grid; A opens moveSelect, where the presses
|
||||
-- above walk the slots. A press that lands on a frame the battle is not
|
||||
-- reading input is simply lost, so every step retries instead of assuming.
|
||||
local function useMove(slot)
|
||||
for _ = 1, 80 do
|
||||
if battle.phase == "moveSelect" then break end
|
||||
if battle.phase == "menu" then
|
||||
if battle.menuIndex ~= 1 then
|
||||
U.tap(game, battle.menuIndex > 2 and "up" or "left")
|
||||
else
|
||||
U.tap(game, "a")
|
||||
end
|
||||
else
|
||||
-- the last turn's boxes are still up: keep turning pages
|
||||
U.tap(game, "a")
|
||||
end
|
||||
U.wait(4)
|
||||
end
|
||||
if battle.phase ~= "moveSelect" then return false end
|
||||
for _ = 1, 30 do
|
||||
local dir = towardSlot(slot)
|
||||
if not dir then break end
|
||||
U.tap(game, dir)
|
||||
U.wait(3)
|
||||
end
|
||||
if battle.moveIndex ~= slot then return false end
|
||||
for _ = 1, 20 do
|
||||
if battle.phase ~= "moveSelect" then return true end
|
||||
U.tap(game, "a")
|
||||
U.wait(3)
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function lastRowFor(id)
|
||||
for i = #rows, 1, -1 do
|
||||
if rows[i].move == id and rows[i].isPlayer then return rows[i] end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local SHOTS = { BUBBLEBEAM = "bug354_bubblebeam_shake.png",
|
||||
HYPNOSIS = "bug354_hypnosis_creep.png",
|
||||
TACKLE = "bug354_tackle_blink.png" }
|
||||
|
||||
check("the battle reached its FIGHT menu", toMenu())
|
||||
|
||||
for _, m in ipairs(MOVES) do
|
||||
-- catch the shake mid-flight for the screenshot: the offset is live for
|
||||
-- only a couple of dozen frames
|
||||
local shotAt
|
||||
-- HYPNOSIS lands 6 times in 10 and TACKLE 19 in 20, and a miss plays no
|
||||
-- applying-attack animation at all, so give each move a few turns to
|
||||
-- connect instead of reading a whiffed one as a regression
|
||||
local sent, r
|
||||
for _ = 1, 8 do
|
||||
-- the foe leans on SAND-ATTACK, and stacked accuracy drops make even
|
||||
-- TACKLE whiff turn after turn, so hand the accuracy back each try
|
||||
battle.player.stages.accuracy = 0
|
||||
sent = useMove(m.slot)
|
||||
if not sent then break end
|
||||
-- A every 8 frames: the text box between the announcement and the
|
||||
-- animation waits on the button like any other
|
||||
pump(1200, 8, function()
|
||||
local live = battle.fx and (m.peak and (battle.fx.shakeX or 0) ~= 0
|
||||
or (not m.peak and battle.fx.blink
|
||||
and battle.fx.blink.frames > 0))
|
||||
if not shotAt and live and SHOTS[m.id] then
|
||||
shotAt = true
|
||||
U.shot(game, DIR .. "/" .. SHOTS[m.id])
|
||||
end
|
||||
local row = lastRowFor(m.id)
|
||||
-- back at the menu with the row in hand: the turn is over either way
|
||||
if row and battle.phase == "menu" and #battle.queue == 0 then return true end
|
||||
return row ~= nil and battle.fx.shakeProg == nil
|
||||
and (row.frames > 0 or row.blinked)
|
||||
end)
|
||||
toMenu()
|
||||
r = lastRowFor(m.id)
|
||||
if r then break end
|
||||
U.log(" " .. m.id .. " did not connect that turn; using it again")
|
||||
end
|
||||
check("chose " .. m.id .. " from the move menu", sent)
|
||||
if sent then
|
||||
check(m.id .. " queued an applying-attack row", r ~= nil)
|
||||
if r then
|
||||
check(("%s is animation type %d (got %s)")
|
||||
:format(m.id, m.want, tostring(r.animType)),
|
||||
r.animType == m.want)
|
||||
U.log((" %s: %d frames off-centre, peak %dpx across / %dpx down, blink %s")
|
||||
:format(m.id, r.frames, r.peakX, r.peakY, tostring(r.blinked)))
|
||||
if m.peak then
|
||||
-- the exact frame count is pinned in the parity suite; sampling from
|
||||
-- a driver misses a frame whenever the logic step outruns the render
|
||||
check(("%s moved the screen %dpx sideways"):format(m.id, m.peak),
|
||||
r.peakX == m.peak)
|
||||
check(("%s held it off-centre (%d of the routine's %d frames)")
|
||||
:format(m.id, r.frames, m.frames), r.frames > 0)
|
||||
check(m.id .. " did not blink the enemy pic instead (#354)",
|
||||
r.blinked == false)
|
||||
else
|
||||
check(m.id .. " still blinks the enemy pic and holds still",
|
||||
r.blinked == true and r.frames == 0)
|
||||
end
|
||||
end
|
||||
if SHOTS[m.id] and not shotAt and m.peak then
|
||||
U.log(" no off-centre frame to capture for " .. m.id)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- the foe's own turns: a plain damaging move is type 1, one with an added
|
||||
-- effect type 2, SAND-ATTACK type 3
|
||||
local foe = {}
|
||||
for _, r in ipairs(rows) do
|
||||
if not r.isPlayer then
|
||||
foe[#foe + 1] = ("%s type %s (%d frames, %dpx across, %dpx down)")
|
||||
:format(tostring(r.move), tostring(r.animType),
|
||||
r.frames, r.peakX, r.peakY)
|
||||
end
|
||||
end
|
||||
U.log("the foe's rows so far: " .. (#foe > 0 and table.concat(foe, "; ")
|
||||
or "none yet"))
|
||||
U.log(("machine checks: %d passed, %d failed"):format(pass, fail))
|
||||
|
||||
-- ---- hand off ----------------------------------------------------------
|
||||
U.log("The pad is yours at the FIGHT menu. Slot 1 BUBBLEBEAM: after the")
|
||||
U.log("bubbles and their white flashes the whole screen -- field, HUD, text")
|
||||
U.log("box -- snaps 2px right and home four times, with the damage thud.")
|
||||
U.log("Slot 2 HYPNOSIS and slot 3 GROWL creep it 1px at a time out to 3px")
|
||||
U.log("and back, twice, in silence. Slot 4 TACKLE is the control: the foe's")
|
||||
U.log("pic blinks and nothing moves. Let the PIDGEY hit back too -- a plain")
|
||||
U.log("move drops the screen 8px vertically, SAND-ATTACK creeps it 6px across.")
|
||||
U.log("Reference: https://youtu.be/4aBT7rjZoIE at 1:19.")
|
||||
U.log("Screenshots: " .. DIR .. "/bug354_*.png")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,205 @@
|
||||
-- Eye/ear check on which animation BIDE plays when (#375). BideEffect ends on
|
||||
-- `add XSTATITEM_ANIM / jp PlayBattleAnimation2` (engine/battle/effects.asm:
|
||||
-- 764-789), so the storing turn is the silent X-item spiral; BIDE's own row
|
||||
-- (sound Battle_18) belongs to .UnleashEnergy (engine/battle/core.asm:3501-3529).
|
||||
-- POKEPORT_DRIVER=tests/drivers/bide_anim_bug375_test.lua POKEPORT_IDENTITY=bug375 POKEPORT_TOUCH=0 love .
|
||||
-- No POKEPORT_SPEED: fast-forward scales the logic clock only and desyncs audio.
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local ChipAudio = require("src.core.ChipAudio")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
|
||||
-- data/generated/maps.lua ROUTE_1: the open path south of the first grass
|
||||
-- patch, and pokered data/maps/objects/Route1.asm parks its youngsters at
|
||||
-- (5, 24) and (15, 13), so nobody is standing here or watching.
|
||||
local MAP = "ROUTE_1"
|
||||
local STAND = { x = 5, y = 5, facing = "down" }
|
||||
|
||||
local function check(label, ok)
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
-- ---- what the ear and eye cannot check ---------------------------------
|
||||
local opts = game.save.options or {}
|
||||
local sfxVol = opts.sfxVol or 7
|
||||
if sfxVol == 0 then
|
||||
U.log("FAIL sfx volume is 0: the release hit plays no sound at all, so")
|
||||
U.log(" the half of this that is audible cannot be judged. Set SFX")
|
||||
U.log(" to 7 in OPTION first.")
|
||||
end
|
||||
check(("sfx volume %d"):format(sfxVol), sfxVol > 0)
|
||||
if opts.animations == false then
|
||||
U.log("FAIL OPTION has animations off, which skips every queued anim row")
|
||||
U.log(" (BattleState:animationsOn). Turn ANIMATION on first.")
|
||||
end
|
||||
check("battle animations are on", opts.animations ~= false)
|
||||
|
||||
local moveDef = game.data.moves.BIDE
|
||||
check("BIDE is in the move table", moveDef ~= nil)
|
||||
local anims = (game.data.battle_anims or {}).moveAnims or {}
|
||||
check("BIDE has an animation", anims.BIDE ~= nil)
|
||||
check("XSTATITEM_ANIM has an animation", anims.XSTATITEM_ANIM ~= nil)
|
||||
check("XSTATITEM_DUPLICATE_ANIM has an animation",
|
||||
anims.XSTATITEM_DUPLICATE_ANIM ~= nil)
|
||||
-- the row's sound byte is a move id; playAnimSound resolves it through
|
||||
-- moves.lua, which is where the actual sfx program name lives
|
||||
local sfxName = moveDef and moveDef.anim and moveDef.anim.sound
|
||||
check("BIDE's animation names a sound (" .. tostring(sfxName) .. ")",
|
||||
sfxName ~= nil)
|
||||
if sfxName then
|
||||
local src = ((game.data.audio or {}).sfx or {})[sfxName]
|
||||
and ChipAudio.newSfx(game.data, sfxName) or nil
|
||||
local secs = src and src:getDuration() or 0
|
||||
check(("%s synthesizes (%.2fs)"):format(sfxName, secs), secs > 0)
|
||||
end
|
||||
|
||||
-- ---- a BIDE user, parked on Route 1 ------------------------------------
|
||||
local lead = Pokemon.new(game.data, "CHARMANDER", 20)
|
||||
lead.moves = { { id = "BIDE", pp = 10 } }
|
||||
game.save.party = { lead }
|
||||
|
||||
U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing)
|
||||
U.wait(15)
|
||||
local ow = game.overworld
|
||||
if not ow.map:isWalkableCell(STAND.x, STAND.y) then
|
||||
-- a map edit or a mod blocked the cell: take any free neighbour
|
||||
for _, d in ipairs({ { 0, 1 }, { 0, -1 }, { 1, 0 }, { -1, 0 } }) do
|
||||
local cx, cy = STAND.x + d[1], STAND.y + d[2]
|
||||
if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then
|
||||
U.log(("(%d, %d) is blocked, standing on"):format(STAND.x, STAND.y), cx, cy)
|
||||
U.teleport(game, MAP, cx, cy, STAND.facing)
|
||||
U.wait(15)
|
||||
ow = game.overworld
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
check("standing on " .. MAP, ow.map.id == MAP)
|
||||
|
||||
-- Every anim row this battle queues, in order. Polling the queue catches
|
||||
-- the announcement-time row performMove inserts directly as well as the
|
||||
-- ones animNext adds, which is the whole difference the fix makes.
|
||||
local function watchAnims(battle)
|
||||
local seen, mark = {}, {}
|
||||
return seen, function()
|
||||
for _, row in ipairs(battle.queue) do
|
||||
if row.anim and not mark[row] then
|
||||
mark[row] = true
|
||||
seen[#seen + 1] = row.anim
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function listOf(seen)
|
||||
return #seen > 0 and table.concat(seen, ", ") or "(none)"
|
||||
end
|
||||
|
||||
local function has(seen, name)
|
||||
for _, n in ipairs(seen) do
|
||||
if n == name then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- Mash A while polling, so a row that comes and goes inside the wait is
|
||||
-- still recorded.
|
||||
local function mash(battle, poll, taps, gap)
|
||||
for _ = 1, taps do
|
||||
U.tap(battle.game, "a")
|
||||
for _ = 1, gap do poll() U.wait(1) end
|
||||
end
|
||||
end
|
||||
|
||||
-- the wipe and the send-out chain hold the battle in "intro" for a while,
|
||||
-- and the send-out text needs the A presses to advance
|
||||
local function waitPhase(battle, phase, tries, poll)
|
||||
for _ = 1, tries do
|
||||
if battle.phase == phase then return true end
|
||||
mash(battle, poll, 1, 6)
|
||||
end
|
||||
return battle.phase == phase
|
||||
end
|
||||
|
||||
-- FIGHT then the only move in the list
|
||||
local function pickBide(battle, poll)
|
||||
U.tap(battle.game, "a")
|
||||
if not waitPhase(battle, "moveSelect", 10, poll) then return false end
|
||||
U.tap(battle.game, "a")
|
||||
return true
|
||||
end
|
||||
|
||||
-- ---- player side -------------------------------------------------------
|
||||
local battle = BattleState.newWild(game, "PIDGEY", 8)
|
||||
battle.onFinish = function() end
|
||||
ow:pushBattle(battle)
|
||||
local seen, poll = watchAnims(battle)
|
||||
check("the battle reached the menu", waitPhase(battle, "menu", 60, poll))
|
||||
check("BIDE is the move on the list", pickBide(battle, poll))
|
||||
for _ = 1, 20 do poll() U.wait(1) end
|
||||
U.shot(game, DIR .. "/bug375_store_spiral.png")
|
||||
for _ = 1, 30 do poll() U.wait(1) end
|
||||
U.shot(game, DIR .. "/bug375_store_text.png")
|
||||
|
||||
check("the storing turn queued XSTATITEM_ANIM", has(seen, "XSTATITEM_ANIM"))
|
||||
check("and not BIDE's hit animation: " .. listOf(seen), not has(seen, "BIDE"))
|
||||
check("BIDE locked in for " .. tostring(battle.player.bideTurns) .. " turns",
|
||||
battle.player.bideTurns ~= nil)
|
||||
|
||||
-- ride out the locked turns: the storing text needs A presses and the menu
|
||||
-- never comes back until BIDE unleashes
|
||||
local released = false
|
||||
for _ = 1, 120 do
|
||||
U.tap(game, "a")
|
||||
for _ = 1, 6 do poll() U.wait(1) end
|
||||
if has(seen, "BIDE") then
|
||||
released = true
|
||||
break
|
||||
end
|
||||
if game.stack:top() ~= battle or battle.player.mon.hp <= 0 then break end
|
||||
end
|
||||
U.shot(game, DIR .. "/bug375_release.png")
|
||||
check("the release queued BIDE's own animation: " .. listOf(seen), released)
|
||||
for _ = 1, 60 do poll() U.wait(1) end
|
||||
|
||||
-- ---- enemy side --------------------------------------------------------
|
||||
-- PlayBattleAnimation2 adds hWhoseTurn, so the foe's storing turn is
|
||||
-- XSTATITEM_DUPLICATE_ANIM (AttackAnimationPointers[174]), never the
|
||||
-- player row.
|
||||
for _ = 1, 8 do
|
||||
if game.stack:top() == ow then break end
|
||||
game.stack:pop()
|
||||
end
|
||||
lead.hp = lead.stats.hp
|
||||
local foeBattle = BattleState.newWild(game, "RATTATA", 8)
|
||||
foeBattle.onFinish = function() end
|
||||
foeBattle.enemy.mon.moves = { { id = "BIDE", pp = 10 } }
|
||||
foeBattle.enemy.curMoves = foeBattle.enemy.mon.moves
|
||||
ow:pushBattle(foeBattle)
|
||||
local foeSeen, foePoll = watchAnims(foeBattle)
|
||||
check("the foe's battle reached the menu",
|
||||
waitPhase(foeBattle, "menu", 60, foePoll))
|
||||
pickBide(foeBattle, foePoll) -- ours does not matter here, the foe's does
|
||||
for _ = 1, 60 do
|
||||
if has(foeSeen, "XSTATITEM_DUPLICATE_ANIM") then break end
|
||||
U.tap(game, "a")
|
||||
for _ = 1, 6 do foePoll() U.wait(1) end
|
||||
end
|
||||
U.shot(game, DIR .. "/bug375_enemy_store.png")
|
||||
check("the foe's storing turn queued XSTATITEM_DUPLICATE_ANIM: "
|
||||
.. listOf(foeSeen), has(foeSeen, "XSTATITEM_DUPLICATE_ANIM"))
|
||||
|
||||
U.log("The pad is yours in a battle where both sides know only BIDE, so")
|
||||
U.log("pick FIGHT then BIDE and watch turn one: the screen palette flashes")
|
||||
U.log("white and balls spiral inward, silently, and then it says storing")
|
||||
U.log("energy. No flash on the RATTATA and no BIDE thud until the turn it")
|
||||
U.log("unleashes, where the thud and its animation come after the text and")
|
||||
U.log("before the enemy HP bar slides down. The foe's spiral looks the same.")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,182 @@
|
||||
-- Walking into grass with no useable POKeMON must black the player out, not
|
||||
-- cancel the battle and hand the map back (#425). pokered core.asm:158-162
|
||||
-- runs .checkAnyPartyAlive after the intro and jumps to HandlePlayerBlackOut
|
||||
-- (core.asm:1145-1166, PlayerBlackedOutText2). Machine half also in
|
||||
-- tests/parity_blackout_no_party.lua. Never under POKEPORT_SPEED: the wipe,
|
||||
-- the theme change and the two text boxes come apart from each other.
|
||||
-- POKEPORT_DRIVER=tests/drivers/blackout_no_party_bug425_test.lua POKEPORT_IDENTITY=bug425 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Music = require("src.core.Music")
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
|
||||
local MAP = "ROUTE_1"
|
||||
local pass, fail = 0, 0
|
||||
local function check(label, ok)
|
||||
if ok then pass = pass + 1 else fail = fail + 1 end
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
local function waitFor(fn, limit)
|
||||
for _ = 1, limit do
|
||||
if fn() then return true end
|
||||
U.wait(1)
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- every song request in order, so "the battle theme was never restored"
|
||||
-- and "the map theme was never playing" read differently in the log
|
||||
local songs = {}
|
||||
local realPlay = Music.play
|
||||
Music.play = function(data, song, loop, ctx)
|
||||
songs[#songs + 1] = { song = song, frame = U.frame() }
|
||||
U.log(("frame %d play %s"):format(U.frame(), tostring(song)))
|
||||
return realPlay(data, song, loop, ctx)
|
||||
end
|
||||
local function playedAfter(song, frame)
|
||||
for _, s in ipairs(songs) do
|
||||
if s.song == song and s.frame >= frame then return s end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local opts = game.save.options
|
||||
if (opts and opts.musicVol or 0) == 0 then
|
||||
U.log("MUSIC VOLUME IS 0: the theme swap this bug is about is silent.")
|
||||
U.log("Raise it in OPTION, quit and rerun; the log half still holds.")
|
||||
end
|
||||
if (opts and opts.sfxVol or 0) == 0 then
|
||||
U.log("WARNING sfx volume is 0: the wipe and the text beeps will be")
|
||||
U.log("silent too.")
|
||||
end
|
||||
|
||||
-- MAGIKARP with SPLASH only would still be a battle; 0 HP is the state
|
||||
-- under test, and money is the blackout's other visible half
|
||||
game.save.party = { Pokemon.new(game.data, "MAGIKARP", 10) }
|
||||
local mon = game.save.party[1]
|
||||
local full = mon.stats.hp
|
||||
mon.hp = 0
|
||||
game.save.money = 3000
|
||||
|
||||
-- Route 1's first grass row. data/generated/maps.lua holds the tiles
|
||||
-- (pokered data/maps/objects/Route1.asm has no grass in it), so the cell is
|
||||
-- checked against the loaded map and rescanned if a map edit moved it.
|
||||
local GRASS = { x = 10, y = 6 }
|
||||
U.teleport(game, MAP, GRASS.x, GRASS.y, "down")
|
||||
local ow = game.overworld
|
||||
local function grassAt(cx, cy)
|
||||
return ow.map:isGrassCell(cx, cy) and ow.map:isWalkableCell(cx, cy)
|
||||
end
|
||||
if not grassAt(GRASS.x, GRASS.y) then
|
||||
local found
|
||||
for y = 0, ow.map.heightCells - 1 do
|
||||
for x = 0, ow.map.widthCells - 1 do
|
||||
-- a neighbouring grass cell too, so the hand-off half below has
|
||||
-- somewhere to step without leaving the patch
|
||||
if grassAt(x, y) and grassAt(x + 1, y) then found = { x = x, y = y } break end
|
||||
end
|
||||
if found then break end
|
||||
end
|
||||
if found then
|
||||
U.log(("(%d, %d) is not grass any more, standing on")
|
||||
:format(GRASS.x, GRASS.y), found.x, found.y)
|
||||
GRASS = found
|
||||
U.teleport(game, MAP, found.x, found.y, "down")
|
||||
ow = game.overworld
|
||||
end
|
||||
end
|
||||
check("the player is standing in " .. MAP .. " grass",
|
||||
grassAt(GRASS.x, GRASS.y))
|
||||
|
||||
local heal = ow:healPoint()
|
||||
U.log("heal point:", heal.map, heal.x, heal.y)
|
||||
local MAP_SONG = game.data.audio.mapSongs[MAP]
|
||||
local HEAL_SONG = game.data.audio.mapSongs[heal.map]
|
||||
local WILD_SONG = game.data.audio.battle.wild
|
||||
U.log("songs:", tostring(MAP_SONG), tostring(WILD_SONG), tostring(HEAL_SONG))
|
||||
|
||||
-- the encounter OverworldState:checkEncounter would build for this cell
|
||||
local encDef = game.data.encounters[MAP]
|
||||
local slots = encDef and encDef.grass and encDef.grass.slots
|
||||
check(MAP .. " has a grass encounter table", slots ~= nil and #slots > 0)
|
||||
if not slots then
|
||||
U.log("Nothing can be encountered here; rerun after a ROM re-import.")
|
||||
while true do coroutine.yield() end
|
||||
end
|
||||
|
||||
local start = U.frame()
|
||||
local battle = BattleState.newWild(game, slots[1].species, slots[1].level)
|
||||
check("the encounter starts out flagged dead (no healthy party)",
|
||||
battle.dead == true)
|
||||
local got
|
||||
battle.onFinish = function(result)
|
||||
got = result
|
||||
ow:afterBattle(result, battle)
|
||||
end
|
||||
ow:pushBattle(battle)
|
||||
check("the wipe started the battle theme",
|
||||
waitFor(function() return playedAfter(WILD_SONG, start) ~= nil end, 120))
|
||||
|
||||
local function box()
|
||||
local top = game.stack:top()
|
||||
return getmetatable(top) == TextBox and top or nil
|
||||
end
|
||||
local shown = waitFor(function() return box() ~= nil end, 600)
|
||||
check("the blackout text came up over the map", shown)
|
||||
if shown then
|
||||
local lines = {}
|
||||
for _, page in ipairs(box().pages or {}) do
|
||||
for _, line in ipairs(page) do lines[#lines + 1] = line end
|
||||
end
|
||||
local text = table.concat(lines, " / ")
|
||||
U.log("box reads:", text)
|
||||
check("it is PlayerBlackedOutText2, both paragraphs",
|
||||
text:find("out of", 1, true) ~= nil
|
||||
and text:find("blacked", 1, true) ~= nil)
|
||||
U.wait(60)
|
||||
U.shot(game, SHOT_DIR .. "/bug425_blackout.png")
|
||||
U.log("captured", SHOT_DIR .. "/bug425_blackout.png")
|
||||
end
|
||||
|
||||
-- page both paragraphs, then let the warp run
|
||||
for _ = 1, 12 do
|
||||
if not box() then break end
|
||||
U.tap(game, "a")
|
||||
U.wait(20)
|
||||
end
|
||||
local warped = waitFor(function()
|
||||
return game.overworld and game.overworld.map
|
||||
and game.overworld.map.id == heal.map
|
||||
and not game.overworld.transitioning
|
||||
end, 900)
|
||||
check("the player was warped to the heal point map " .. heal.map, warped)
|
||||
check("onFinish reported a loss, not a cancelled battle", got == "lose")
|
||||
check("the party was revived", mon.hp == full)
|
||||
check("half the money is gone (3000 -> 1500)", game.save.money == 1500)
|
||||
if HEAL_SONG then
|
||||
check("the heal point's own theme replaced the battle theme",
|
||||
playedAfter(HEAL_SONG, start) ~= nil)
|
||||
end
|
||||
local stillBattle = songs[#songs] and songs[#songs].song == WILD_SONG
|
||||
check("the battle theme is not what is still playing", not stillBattle)
|
||||
U.log(("%d passed, %d failed"):format(pass, fail))
|
||||
|
||||
-- re-arm for the hand-off: same 0 HP party, back in the grass
|
||||
mon.hp = 0
|
||||
game.save.money = 3000
|
||||
U.teleport(game, MAP, GRASS.x, GRASS.y, "down")
|
||||
U.log("MAGIKARP is at 0 HP again and you are back in the Route 1 grass.")
|
||||
U.log("Step around until an encounter rolls: the wipe and the battle theme,")
|
||||
U.log("then the two boxes over the map with the route theme back under them,")
|
||||
U.log("then the warp to Pallet Town. Landing back in the grass with the")
|
||||
U.log("battle music looping, over and over, is the bug.")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,169 @@
|
||||
-- Manual check on the Celadon Mansion roof house blackboard and pamphlet (#391).
|
||||
-- pokered data/events/hidden_events.asm CELADON_MANSION_ROOF_HOUSE declares both
|
||||
-- with hidden_text_predef (LinkCableHelp, TMNotebook), which the extractor never
|
||||
-- parsed, so both A presses were dead. The data half is in
|
||||
-- tests/parity_celadon_roof_readables.lua.
|
||||
-- POKEPORT_DRIVER=tests/drivers/celadon_roof_readables_bug391_test.lua POKEPORT_IDENTITY=bug391 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love . (no POKEPORT_SPEED: fast-forward desyncs audio)
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local Menu = require("src.ui.Menu")
|
||||
-- data/scripts/init.lua, not src/script/MapScripts.lua: OverworldController
|
||||
-- requires it lazily on the first map load, so the registrations it makes
|
||||
-- are not in place yet when the driver starts
|
||||
local MapScripts = require("data.scripts.init")
|
||||
|
||||
local MAP = "CELADON_MANSION_ROOF_HOUSE"
|
||||
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
|
||||
-- hidden_events.asm: hidden_text_predef 3, 4 (pamphlet on the table) and
|
||||
-- 3, 0 / 4, 0 (blackboard in the north wall). Both target cells are solid,
|
||||
-- so the stand cell is the walkable one below, facing up.
|
||||
local PAMPHLET = { x = 3, y = 4 }
|
||||
local BLACKBOARD = { x = 3, y = 0 }
|
||||
local BLACKBOARD_ALT = { x = 4, y = 0 }
|
||||
local BALL = { x = 4, y = 3 }
|
||||
|
||||
local function check(label, ok)
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
local hooks = MapScripts.get(MAP)
|
||||
check(MAP .. " registers an onInteract hook",
|
||||
hooks ~= nil and type(hooks.onInteract) == "function")
|
||||
check("the Eevee ball talk entry is still registered",
|
||||
hooks ~= nil and hooks.talk ~= nil
|
||||
and hooks.talk.TEXT_CELADONMANSION_ROOF_HOUSE_EEVEE_POKEBALL ~= nil)
|
||||
for _, key in ipairs({ "_LinkCableHelpText1", "_LinkCableHelpText2",
|
||||
"_LinkCableInfoText1", "_LinkCableInfoText2", "_LinkCableInfoText3" }) do
|
||||
local s = game.data.text[key]
|
||||
check(key .. " resolves to a string", type(s) == "string" and s ~= "")
|
||||
end
|
||||
|
||||
local opts = game.save.options or {}
|
||||
U.log("audio device present:", love.audio ~= nil,
|
||||
" SFX VOL (0-7):", tostring(opts.sfxVol))
|
||||
if not love.audio or opts.sfxVol == 0 then
|
||||
U.log("WARNING: sfx output is off, so the menu beeps and the wall bonk will",
|
||||
"not be audible; raise SFX VOL in OPTION first")
|
||||
end
|
||||
|
||||
-- reach the pamphlet: warps land at (2,7)/(3,7), so start below the table
|
||||
U.teleport(game, MAP, PAMPHLET.x, PAMPHLET.y + 2, "up")
|
||||
U.wait(10)
|
||||
local ow = game.overworld
|
||||
check("map " .. MAP .. " loaded", ow ~= nil and ow.map ~= nil)
|
||||
check("the pamphlet cell is solid, as the table it sits on",
|
||||
not ow.map:isWalkableCell(PAMPHLET.x, PAMPHLET.y))
|
||||
check("the blackboard cell is solid, as the wall it hangs on",
|
||||
not ow.map:isWalkableCell(BLACKBOARD.x, BLACKBOARD.y))
|
||||
U.hold(game, "up", 20)
|
||||
U.wait(10)
|
||||
|
||||
-- a map edit or a mod could move the table: any walkable neighbour will do,
|
||||
-- {dx, dy, facing} is the offset from the readable plus the look back at it
|
||||
local SIDES = {
|
||||
{ 0, 1, "up" }, { 0, -1, "down" }, { 1, 0, "left" }, { -1, 0, "right" },
|
||||
}
|
||||
-- alt is the blackboard's second cell: hidden_text_predef declares 3,0 and
|
||||
-- 4,0 with the same handler, so either one facing up is the right spot
|
||||
local function faceCell(cell, alt)
|
||||
local p = game.overworld.player
|
||||
local fx, fy = p:facingCell()
|
||||
if fx == cell.x and fy == cell.y then return true end
|
||||
if alt and fx == alt.x and fy == alt.y then return true end
|
||||
for _, s in ipairs(SIDES) do
|
||||
local cx, cy = cell.x + s[1], cell.y + s[2]
|
||||
if game.overworld.map:isWalkableCell(cx, cy)
|
||||
and not game.overworld:npcAtCell(cx, cy) then
|
||||
U.log(("(%d,%d) was not faced from where the walk ended, standing on")
|
||||
:format(cell.x, cell.y), cx, cy, "facing", s[3])
|
||||
U.teleport(game, MAP, cx, cy, s[3])
|
||||
U.wait(10)
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function boxText(box)
|
||||
local out = {}
|
||||
for _, page in ipairs(box.pages or {}) do
|
||||
for _, line in ipairs(page) do out[#out + 1] = line end
|
||||
end
|
||||
return table.concat(out, " / ")
|
||||
end
|
||||
|
||||
check("standing where the pamphlet is faced", faceCell(PAMPHLET))
|
||||
U.tap(game, "a")
|
||||
U.wait(30)
|
||||
local top = game.stack:top()
|
||||
local isBox = getmetatable(top) == TextBox
|
||||
check("A on the pamphlet opened a text box", isBox)
|
||||
if isBox then
|
||||
U.log("box reads:", boxText(top))
|
||||
check("it is the TM pamphlet, not the generic bookshelf line",
|
||||
boxText(top):find("pamphlet", 1, true) ~= nil)
|
||||
U.shot(game, SHOT_DIR .. "/bug391_pamphlet.png")
|
||||
end
|
||||
-- clear the box: five pages of TMNotebookText, then back to the overworld
|
||||
for _ = 1, 40 do
|
||||
if game.stack:top() == game.overworld then break end
|
||||
U.tap(game, "a")
|
||||
U.wait(8)
|
||||
end
|
||||
check("the pamphlet closed and gave the overworld back",
|
||||
game.stack:top() == game.overworld)
|
||||
|
||||
-- reach the blackboard: the table blocks column 3 between the two, so restart
|
||||
-- from the row below the wall rather than walking the long way around it
|
||||
U.teleport(game, MAP, BLACKBOARD.x, BLACKBOARD.y + 2, "up")
|
||||
U.hold(game, "up", 20) -- one step onto row 1, then a bonk on the north wall
|
||||
U.wait(10)
|
||||
U.log("walked to", game.overworld.player.cellX, game.overworld.player.cellY,
|
||||
"facing", game.overworld.player.facing)
|
||||
check("standing where the blackboard is faced",
|
||||
faceCell(BLACKBOARD, BLACKBOARD_ALT))
|
||||
U.tap(game, "a")
|
||||
U.wait(30)
|
||||
top = game.stack:top()
|
||||
isBox = getmetatable(top) == TextBox
|
||||
check("A on the blackboard opened a text box", isBox)
|
||||
if isBox then
|
||||
U.log("box reads:", boxText(top))
|
||||
check("it is _LinkCableHelpText1",
|
||||
boxText(top):find("TRAINER TIPS", 1, true) ~= nil)
|
||||
end
|
||||
-- through the intro and the prompt into the heading menu
|
||||
local menu
|
||||
for _ = 1, 40 do
|
||||
local t = game.stack:top()
|
||||
if getmetatable(t) == Menu then menu = t break end
|
||||
U.tap(game, "a")
|
||||
U.wait(8)
|
||||
end
|
||||
check("the prompt opened the heading menu", menu ~= nil)
|
||||
if menu then
|
||||
local labels = {}
|
||||
for i, item in ipairs(menu.items or {}) do labels[i] = item.label end
|
||||
U.log("menu rows:", table.concat(labels, " / "))
|
||||
check("four rows in HowToLinkText order",
|
||||
table.concat(labels, "/")
|
||||
== "HOW TO LINK/COLOSSEUM/TRADE CENTER/STOP READING")
|
||||
U.shot(game, SHOT_DIR .. "/bug391_blackboard_menu.png")
|
||||
U.log("captured", SHOT_DIR .. "/bug391_blackboard_menu.png")
|
||||
end
|
||||
|
||||
U.log("The heading menu on screen is the moment to eyeball: a 15x10 box in")
|
||||
U.log("the top-left corner, four rows, nothing clipped. Pick a heading and")
|
||||
U.log("its blurb prints, then the prompt and this menu should come back.")
|
||||
U.log("B or STOP READING drops you back outside and you can walk again.")
|
||||
U.log(("Then stand at (%d,%d) facing down and press A: the ball on the table")
|
||||
:format(BALL.x, BALL.y - 1))
|
||||
U.log("still hands over EEVEE, since the same script file changed.")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,139 @@
|
||||
-- Manual check that the elevator floor menu waits for the panel (#395).
|
||||
-- pokered data/maps/objects/CeladonMartElevator.asm has it as `bg_event 3, 0,
|
||||
-- TEXT_CELADONMARTELEVATOR`, and only that text reaches
|
||||
-- DisplayElevatorFloorMenu (engine/events/elevator.asm), which rewrites the
|
||||
-- car's warps and returns without moving the player. No POKEPORT_SPEED here:
|
||||
-- fast-forward desynchronizes the ride's audio.
|
||||
-- SHOT_DIR=/tmp/shots POKEPORT_DRIVER=tests/drivers/elevator_panel_bug395_test.lua POKEPORT_IDENTITY=bug395 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
local ListMenu = require("src.ui.ListMenu")
|
||||
local mapScripts = require("data.scripts.init")
|
||||
|
||||
-- data/generated/maps.lua CELADON_MART_ELEVATOR: sign (3,0) is the panel,
|
||||
-- warp (1,3) is the arrival tile, row 0 is wall, so the panel is read from
|
||||
-- (3,1) facing up. 2F is the floor picked below.
|
||||
local MAP = "CELADON_MART_ELEVATOR"
|
||||
local TEXT = "TEXT_CELADONMARTELEVATOR"
|
||||
local ARRIVE = { x = 1, y = 3 }
|
||||
local PANEL = { x = 3, y = 0 }
|
||||
local STAND = { x = 3, y = 1, facing = "up" }
|
||||
local PICK = "CELADON_MART_2F"
|
||||
|
||||
local function check(label, ok)
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
local def = game.data.maps[MAP]
|
||||
local sign
|
||||
for _, s in ipairs(def.signs or {}) do
|
||||
if s.text == TEXT then sign = s end
|
||||
end
|
||||
check(TEXT .. " is a sign in the map data", sign ~= nil)
|
||||
if sign then
|
||||
check(("the panel sits at (%d,%d)"):format(PANEL.x, PANEL.y),
|
||||
sign.x == PANEL.x and sign.y == PANEL.y)
|
||||
end
|
||||
check("the panel text runs a script", type(mapScripts.talkScript(MAP, TEXT)) == "function")
|
||||
|
||||
local vol = game.save.options and game.save.options.sfxVol
|
||||
if (vol or 0) == 0 then
|
||||
U.log("sfxVol is 0: the ride will be silent, raise it in OPTION first")
|
||||
else
|
||||
U.log("sfxVol", tostring(vol), "-- 100 collision thuds then the PA chime")
|
||||
end
|
||||
|
||||
U.teleport(game, MAP, ARRIVE.x, ARRIVE.y, "up")
|
||||
U.wait(10)
|
||||
local ow = game.overworld
|
||||
check("no menu opened on arrival", getmetatable(game.stack:top()) ~= ListMenu)
|
||||
U.shot(game, DIR .. "/elev395_0_entered.png")
|
||||
|
||||
local function facingThePanel()
|
||||
local fx, fy = ow.player:facingCell()
|
||||
return ow.map:signAtCell(fx, fy) ~= nil
|
||||
end
|
||||
|
||||
-- walk over to the panel, one cell per hold
|
||||
U.hold(game, "right", 24)
|
||||
U.hold(game, "right", 24)
|
||||
U.hold(game, "up", 24)
|
||||
U.hold(game, "up", 24)
|
||||
U.wait(10)
|
||||
U.log("standing at", ow.player.cellX, ow.player.cellY, ow.player.facing)
|
||||
|
||||
if not facingThePanel() and sign then
|
||||
-- a map edit moved the panel: stand on any walkable neighbour of it.
|
||||
-- {dx, dy, facing} is the offset from the panel plus the way back at it.
|
||||
local sides = {
|
||||
{ 0, 1, "up" }, { 0, -1, "down" }, { 1, 0, "left" }, { -1, 0, "right" },
|
||||
}
|
||||
for _, s in ipairs(sides) do
|
||||
local cx, cy = sign.x + s[1], sign.y + s[2]
|
||||
if ow.map:isWalkableCell(cx, cy) then
|
||||
U.log("panel moved; standing on", cx, cy, "facing", s[3])
|
||||
U.teleport(game, MAP, cx, cy, s[3])
|
||||
U.wait(10)
|
||||
ow = game.overworld
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
check("the player is facing the panel", facingThePanel())
|
||||
|
||||
U.tap(game, "a")
|
||||
U.wait(20)
|
||||
local menu = getmetatable(game.stack:top()) == ListMenu
|
||||
check("A on the panel opens the floor list", menu)
|
||||
if menu then
|
||||
local labels = {}
|
||||
for _, item in ipairs(game.stack:top().items or {}) do
|
||||
labels[#labels + 1] = item.label
|
||||
end
|
||||
U.log("floors listed:", table.concat(labels, " "))
|
||||
end
|
||||
U.shot(game, DIR .. "/elev395_1_menu.png")
|
||||
|
||||
U.tap(game, "down") -- 1F -> 2F
|
||||
U.wait(2)
|
||||
U.tap(game, "a")
|
||||
-- 9 zero frames (Celadon farjps into ShakeElevator with no extra Delay3),
|
||||
-- then -1,-1,+1,+1 in 2-frame steps
|
||||
local trace = {}
|
||||
for _ = 1, 24 do
|
||||
trace[#trace + 1] = tostring(ow.bgShakeY or 0)
|
||||
U.wait(1)
|
||||
end
|
||||
U.log("bgShakeY after the pick:", table.concat(trace, ","))
|
||||
U.shot(game, DIR .. "/elev395_2_shake_a.png")
|
||||
U.shot(game, DIR .. "/elev395_3_shake_b.png") -- the other phase of the bounce
|
||||
|
||||
local ElevatorShake = require("src.world.ElevatorShake")
|
||||
for _ = 1, 1200 do
|
||||
U.wait(1)
|
||||
if getmetatable(game.stack:top()) ~= ElevatorShake then break end
|
||||
end
|
||||
U.wait(20)
|
||||
check("the ride left the player in the car", ow.map.id == MAP)
|
||||
check("and standing where they read the panel",
|
||||
ow.player.cellX == STAND.x and ow.player.cellY == STAND.y)
|
||||
local dests = {}
|
||||
for _, w in ipairs(ow.map.def.warps) do dests[#dests + 1] = tostring(w.destMap) end
|
||||
U.log("car exit warps now point at:", table.concat(dests, " "))
|
||||
check("the exits were rewritten to " .. PICK, dests[1] == PICK)
|
||||
U.shot(game, DIR .. "/elev395_4_rode.png")
|
||||
|
||||
U.log("The ride is over and the pad is yours: walk down and left onto the")
|
||||
U.log("door tile at (2,3) and you should come out on CELADON MART 2F. The")
|
||||
U.log("panel is behind you -- A on it should reopen WHICH FLOOR?, and B out")
|
||||
U.log("of that list should leave you in the car with 2F still the exit.")
|
||||
U.log("Worth checking by hand: Silph Co (same panel cell, 11 floors) and the")
|
||||
U.log("Rocket car, whose panel is at (1,1) so you face LEFT from (2,1) and")
|
||||
U.log("with no LIFT KEY get \"It appears to need a key.\" and no list.")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -1,47 +0,0 @@
|
||||
-- Driver: elevator ride (ShakeElevator, engine/overworld/elevator.asm).
|
||||
-- Teleports onto the Celadon Mart elevator's exit tile (1,3) -- the real
|
||||
-- arrival cell, where the floor menu opens on entry -- picks 2F, traces
|
||||
-- the bgShakeY scroll offset through the 9-frame lead-in and first shake
|
||||
-- cycles, screenshots both phases of the oscillation, then confirms the
|
||||
-- post-ride walk-out onto the arrival floor (the car's exit warps are
|
||||
-- rewritten and the player walks out, per scripts/CeladonMartElevator.asm,
|
||||
-- instead of a jump-cut warp).
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
local ListMenu = require("src.ui.ListMenu")
|
||||
|
||||
U.teleport(game, "CELADON_MART_ELEVATOR", 1, 3, "up")
|
||||
U.wait(5)
|
||||
local ow = game.overworld
|
||||
U.log("map:", ow.map.id,
|
||||
"menu open:", tostring(getmetatable(game.stack:top()) == ListMenu))
|
||||
U.shot(game, DIR .. "/elev_0_menu.png")
|
||||
|
||||
U.tap(game, "down") -- cursor 1F -> 2F
|
||||
U.wait(2)
|
||||
U.tap(game, "a") -- choose 2F: the ElevatorShake state pushes
|
||||
-- expect 9 zero frames (Celadon farjps into ShakeElevator, no extra
|
||||
-- Delay3), then -1,-1,+1,+1,... in 2-frame steps
|
||||
local trace = {}
|
||||
for _ = 1, 24 do
|
||||
trace[#trace + 1] = tostring(ow.bgShakeY or 0)
|
||||
U.wait(1)
|
||||
end
|
||||
U.log("bgShakeY after A:", table.concat(trace, ","))
|
||||
U.shot(game, DIR .. "/elev_1_shake_a.png")
|
||||
U.shot(game, DIR .. "/elev_2_shake_b.png") -- 3 frames later: other phase
|
||||
-- ride out: rest of the 200 shake frames, the PA chime, then the
|
||||
-- scripted walk-out; wait until the walk-out has warped onto the floor
|
||||
for _ = 1, 1200 do
|
||||
U.wait(1)
|
||||
if ow.map.id ~= "CELADON_MART_ELEVATOR" and not ow.transitioning
|
||||
and #ow.scriptMoves == 0 then
|
||||
break
|
||||
end
|
||||
end
|
||||
U.wait(10)
|
||||
U.log("final map:", ow.map.id, "pos:", ow.player.cellX, ow.player.cellY,
|
||||
"bgShakeY:", tostring(ow.bgShakeY or 0))
|
||||
U.shot(game, DIR .. "/elev_3_arrived.png")
|
||||
end
|
||||
@@ -0,0 +1,123 @@
|
||||
-- Manual check that the exit mat you warp in on answers the first press of
|
||||
-- DOWN (#378). The house door outside is a door tile, so it leaves
|
||||
-- BIT_STANDING_ON_WARP set and ClearVariablesOnEnterMap never clears it
|
||||
-- (home/overworld.asm:247, engine/overworld/player_state.asm:190).
|
||||
-- POKEPORT_DRIVER=tests/drivers/exit_mat_bug378_test.lua POKEPORT_IDENTITY=bug378 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
|
||||
-- Do not set POKEPORT_SPEED: fast-forward desynchronizes the audio clock.
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Warp = require("src.world.Warp")
|
||||
|
||||
local HOUSE = "REDS_HOUSE_1F"
|
||||
local TOWN = "PALLET_TOWN"
|
||||
|
||||
local fails = 0
|
||||
local function expect(cond, ...)
|
||||
if not cond then fails = fails + 1 end
|
||||
U.log(cond and "PASS" or "FAIL", ...)
|
||||
end
|
||||
|
||||
local ow
|
||||
local function settle(mapId)
|
||||
for _ = 1, 300 do
|
||||
ow = game.overworld
|
||||
if ow and ow.map.id == mapId and not ow.transitioning
|
||||
and #ow.scriptMoves == 0 and not ow.player.moving then
|
||||
break
|
||||
end
|
||||
U.wait(1)
|
||||
end
|
||||
U.wait(4)
|
||||
ow = game.overworld
|
||||
end
|
||||
|
||||
-- data/generated/maps.lua warps, extracted from pokered
|
||||
-- data/maps/objects/PalletTown.asm and RedsHouse1F.asm: the town warp whose
|
||||
-- destination is the house is the door tile, and the house's LAST_MAP warps
|
||||
-- are the two mat cells on its bottom row.
|
||||
local function warpTo(mapId, destMap)
|
||||
for _, w in ipairs(game.data.maps[mapId].warps or {}) do
|
||||
if w.destMap == destMap then return w end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local door = warpTo(TOWN, HOUSE)
|
||||
local mat = warpTo(HOUSE, "LAST_MAP")
|
||||
expect(door ~= nil, "Pallet Town has a warp into " .. HOUSE)
|
||||
expect(mat ~= nil, HOUSE .. " has a LAST_MAP exit warp")
|
||||
if not (door and mat) then error("map data has no door/mat pair to test") end
|
||||
U.log(("door at (%d, %d), mat at (%d, %d)"):format(door.x, door.y,
|
||||
mat.x, mat.y))
|
||||
|
||||
-- Approach the door from the south, which is the only side the walk-in works
|
||||
-- from; if a map edit blocked that cell, take any walkable neighbour.
|
||||
U.teleport(game, TOWN, door.x, door.y + 1, "up")
|
||||
settle(TOWN)
|
||||
local stand = { x = door.x, y = door.y + 1, facing = "up" }
|
||||
if not ow.map:isWalkableCell(stand.x, stand.y) then
|
||||
local sides = {
|
||||
{ 0, 1, "up" }, { 0, -1, "down" }, { 1, 0, "left" }, { -1, 0, "right" },
|
||||
}
|
||||
for _, s in ipairs(sides) do
|
||||
local cx, cy = door.x + s[1], door.y + s[2]
|
||||
if ow.map:isWalkableCell(cx, cy) then
|
||||
U.log(("cell (%d, %d) is blocked, approaching from"):format(
|
||||
stand.x, stand.y), cx, cy, "facing", s[3])
|
||||
stand = { x = cx, y = cy, facing = s[3] }
|
||||
U.teleport(game, TOWN, cx, cy, s[3])
|
||||
settle(TOWN)
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Walk in for real: teleporting straight onto the mat would never set the
|
||||
-- flag the door sets, so the bug could not show up. The direction is
|
||||
-- released the instant the warp lands, because a still-held d-pad walks the
|
||||
-- player off the mat and stepping back onto it is ExtraWarpCheck's own
|
||||
-- trigger, which would exit the house before the human sees anything.
|
||||
for _ = 1, 120 do
|
||||
if game.overworld and game.overworld.map.id == HOUSE then break end
|
||||
table.insert(game.input.pressQueue, stand.facing)
|
||||
game.input.state[stand.facing] = true
|
||||
coroutine.yield()
|
||||
end
|
||||
game.input.state[stand.facing] = false
|
||||
settle(HOUSE)
|
||||
expect(ow.map.id == HOUSE, "walked in through the door, map:", ow.map.id)
|
||||
local p = ow.player
|
||||
expect(p.cellX == mat.x and p.cellY == mat.y,
|
||||
("landed on the mat (%d, %d), got:"):format(mat.x, mat.y),
|
||||
p.cellX, p.cellY)
|
||||
expect(ow.map:warpAtCell(p.cellX, p.cellY) ~= nil,
|
||||
"the cell under the player carries a warp entry")
|
||||
expect(not ow.map:isWarpTileCell(p.cellX, p.cellY),
|
||||
("the mat tile ($%02X) is no warp-activating tile, so nothing cleared "
|
||||
.. "the flag"):format(ow.map:cellTile(p.cellX, p.cellY)))
|
||||
expect(ow:canCollisionWarp(),
|
||||
"BIT_STANDING_ON_WARP survived the warp, so DOWN can exit (#378)")
|
||||
expect(Warp.onEdge(ow.map, p.cellX, p.cellY, "down") ~= nil,
|
||||
"and DOWN off the south edge resolves to the exit warp")
|
||||
expect(ow.warpEntryCell ~= nil,
|
||||
"the completed-step arrival record is set, as #265 wants it")
|
||||
|
||||
local vol = game.save.options and game.save.options.sfxVol
|
||||
if vol == 0 then
|
||||
U.log("sfxVol is 0: a wrong bonk would be SILENT, raise it in OPTION first")
|
||||
else
|
||||
U.log("sfxVol", tostring(vol), "-- there should be no collision thud at all")
|
||||
end
|
||||
|
||||
U.log("You are on the mat inside the house, already facing down. Press DOWN.")
|
||||
U.log("Right: it fades out on the first press and you are back in Pallet")
|
||||
U.log("Town on the door, which auto-steps you one cell south. Wrong: a")
|
||||
U.log("collision thud and Red walks in place until you step off the mat.")
|
||||
U.log("The stairs at the top right must still bonk, not bounce floors (#230).")
|
||||
|
||||
if fails > 0 then U.log(fails .. " check(s) failed before the handoff") end
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,158 @@
|
||||
-- Ear check: a song cued while a jingle sounds must wait for it (#398).
|
||||
-- A fanfare's sfx header claims the music channels and .playMusic rewrites
|
||||
-- only the music-channel state, so a mid-jingle song stays muted (pokered
|
||||
-- audio/engine_1.asm:39-56, :1343-1357).
|
||||
-- POKEPORT_DRIVER=tests/drivers/fanfare_hold_bug398_test.lua POKEPORT_IDENTITY=bug398 POKEPORT_TOUCH=0 love .
|
||||
-- No POKEPORT_SPEED: fast-forward scales the logic clock only and desyncs audio.
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local ChipAudio = require("src.core.ChipAudio")
|
||||
local Music = require("src.core.Music")
|
||||
local Sound = require("src.core.Sound")
|
||||
|
||||
-- data/generated/maps.lua ROUTE_1: the northern grass patch spans cells
|
||||
-- (10-17, 6-8), and pokered data/maps/objects/Route1.asm keeps its
|
||||
-- youngsters at (5, 24) and (15, 13), well south of it.
|
||||
local MAP = "ROUTE_1"
|
||||
local STAND = { x = 12, y = 7, facing = "down" }
|
||||
local FANFARES = {
|
||||
"Level_Up", "Caught_Mon", "Get_Item1", "Get_Item2",
|
||||
"Get_Key_Item", "Pokedex_Rating", "Dex_Page_Added", "Pokeflute",
|
||||
}
|
||||
|
||||
local function check(label, ok)
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
-- ---- what the ear cannot check -----------------------------------------
|
||||
local opts = game.save.options or {}
|
||||
local sfxVol = opts.sfxVol or 7
|
||||
local musicVol = opts.musicVol or 7
|
||||
if sfxVol == 0 then
|
||||
U.log("FAIL sfx volume is 0: no jingle will sound at all, so nothing")
|
||||
U.log(" below can be judged. Set SFX to 7 in OPTION first.")
|
||||
end
|
||||
if musicVol == 0 then
|
||||
U.log("FAIL music volume is 0: the song that must stay held is muted")
|
||||
U.log(" anyway. Set MUSIC to 7 in OPTION first.")
|
||||
end
|
||||
check(("sfx volume %d, music volume %d"):format(sfxVol, musicVol),
|
||||
sfxVol > 0 and musicVol > 0)
|
||||
|
||||
for _, name in ipairs(FANFARES) do
|
||||
local def = (game.data.audio.sfx or {})[name]
|
||||
local src = def and ChipAudio.newSfx(game.data, name) or nil
|
||||
local secs = src and src:getDuration() or 0
|
||||
check(("%s synthesizes (%.2fs)"):format(name, secs), secs > 0.3)
|
||||
end
|
||||
local fanfares = game.data.audio.fanfares
|
||||
check("Caught_Mon counts as a fanfare",
|
||||
fanfares == nil or fanfares.Caught_Mon == true)
|
||||
-- Music.lua:100 reaches into ChipAudio for the hold: a chip song is started
|
||||
-- by ChipAudio, not by Music, so pausing Music's own source cannot cover it
|
||||
check("ChipAudio.holdMusic exists for Music to call",
|
||||
type(ChipAudio.holdMusic) == "function")
|
||||
local routeSong = (game.data.audio.mapSongs or {})[MAP]
|
||||
check(MAP .. " has a map theme to hold (" .. tostring(routeSong) .. ")",
|
||||
routeSong ~= nil)
|
||||
|
||||
-- ---- park in the grass -------------------------------------------------
|
||||
game.save.party = {
|
||||
Pokemon.new(game.data, "PIDGEY", 4),
|
||||
Pokemon.new(game.data, "CHARIZARD", 50),
|
||||
}
|
||||
game.save.inventory = { POKE_BALL = 20, POTION = 5 }
|
||||
game.save.bagOrder = { "POKE_BALL", "POTION" }
|
||||
|
||||
U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing)
|
||||
U.wait(20)
|
||||
local ow = game.overworld
|
||||
if not ow.map:isWalkableCell(STAND.x, STAND.y) then
|
||||
-- a map edit or a mod blocked the cell: take any free neighbour
|
||||
for _, d in ipairs({ { 0, 1 }, { 0, -1 }, { 1, 0 }, { -1, 0 } }) do
|
||||
local cx, cy = STAND.x + d[1], STAND.y + d[2]
|
||||
if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then
|
||||
U.log(("(%d, %d) is blocked, standing on"):format(STAND.x, STAND.y), cx, cy)
|
||||
U.teleport(game, MAP, cx, cy, STAND.facing)
|
||||
U.wait(20)
|
||||
ow = game.overworld
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
check("standing on " .. MAP, ow.map.id == MAP)
|
||||
|
||||
-- ---- the hold, measured ------------------------------------------------
|
||||
-- Music hands its chip sources to ChipAudio, so the source the restore
|
||||
-- built is only reachable through playMusic's return value.
|
||||
local song
|
||||
local realPlayMusic = ChipAudio.playMusic
|
||||
ChipAudio.playMusic = function(...)
|
||||
local src, err = realPlayMusic(...)
|
||||
song = src or song
|
||||
return src, err
|
||||
end
|
||||
|
||||
local jingle = Sound.play(game.data, "Caught_Mon")
|
||||
song = nil
|
||||
-- BattleState:finish restores the map theme this way, and restoreMap clears
|
||||
-- the current label so even the same theme is rebuilt and restarted
|
||||
Music.restoreMap(game.data)
|
||||
U.wait(45) -- past the threaded first-buffer window, still inside the jingle
|
||||
local stillSounding = jingle ~= nil and jingle:isPlaying()
|
||||
local songUp = song ~= nil and song:isPlaying()
|
||||
if not stillSounding then
|
||||
U.log("FAIL the jingle ended before the restore could be judged; rerun")
|
||||
else
|
||||
check("the map theme stays silent under the jingle", not songUp)
|
||||
end
|
||||
|
||||
for _ = 1, 400 do
|
||||
if not (jingle and jingle:isPlaying()) then break end
|
||||
U.wait(1)
|
||||
end
|
||||
local back = false
|
||||
for _ = 1, 120 do
|
||||
if song and song:isPlaying() then back = true break end
|
||||
U.wait(1)
|
||||
end
|
||||
check("the held theme comes back when the jingle ends", back)
|
||||
ChipAudio.playMusic = realPlayMusic
|
||||
|
||||
-- ---- the real path, so the same beat can be heard in a battle ----------
|
||||
local battle = BattleState.newWild(game, "PIDGEY", 3)
|
||||
battle.rng = function(a, b) return a end -- clean capture, no wobbles
|
||||
ow:pushBattle(battle)
|
||||
for _ = 1, 14 do U.tap(game, "a") U.wait(6) end
|
||||
battle.phase = "messages"
|
||||
battle.afterQueue = "menu"
|
||||
battle:throwBall("POKE_BALL")
|
||||
local ChoiceBox = require("src.ui.ChoiceBox")
|
||||
for _ = 1, 120 do
|
||||
if game.stack:top() == ow then break end
|
||||
if getmetatable(game.stack:top()) == ChoiceBox then
|
||||
U.tap(game, "down") -- decline the nickname, or naming eats the mash
|
||||
U.wait(2)
|
||||
end
|
||||
U.tap(game, "a")
|
||||
U.wait(4)
|
||||
end
|
||||
if not check("the catch ran through to the overworld", game.stack:top() == ow) then
|
||||
-- hand the pad over in the grass whatever the mash got stuck on
|
||||
U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing)
|
||||
end
|
||||
U.wait(60)
|
||||
|
||||
U.log("That was a capture mashed straight out of the battle, which is the")
|
||||
U.log("tightest case: the caught jingle should sound alone start to finish")
|
||||
U.log("and the route theme should only come in after its last note.")
|
||||
U.log("You have 20 balls and a level 4 PIDGEY, so walk the grass and catch")
|
||||
U.log("something, or let it level up, and listen for the same thing.")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,187 @@
|
||||
-- Manual check that STRENGTH / SURF / FLASH print their message over the
|
||||
-- party menu and only then blink it away (#385). pokered
|
||||
-- engine/menus/start_sub_menus.asm .flash/.strength/.surf all run PrintText
|
||||
-- -> GBPalWhiteOutWithDelay3 -> jp .goBackToMap, so the menu is the backdrop
|
||||
-- of the text and the blink is the menu closing. No POKEPORT_SPEED here:
|
||||
-- POKEPORT_DRIVER=tests/drivers/field_move_layer_bug385_test.lua POKEPORT_IDENTITY=bug385 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local PartyMenu = require("src.ui.PartyMenu")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
|
||||
local fails = 0
|
||||
local function check(ok, msg)
|
||||
U.log(ok and "PASS" or "FAIL", msg)
|
||||
if not ok then fails = fails + 1 end
|
||||
return ok
|
||||
end
|
||||
|
||||
U.newGame(game)
|
||||
game.save.options = game.save.options or {}
|
||||
game.save.options.textSpeed = 5 -- SLOW: the shot lands while it types
|
||||
local vol = game.save.options.sfxVol
|
||||
if vol == 0 then
|
||||
U.log("sfxVol is 0: the STRENGTH cry will be silent, turn SFX up in OPTION")
|
||||
else
|
||||
U.log("sfxVol =", tostring(vol), "-- STRENGTH plays the mon's cry over the text")
|
||||
end
|
||||
|
||||
-- every badge the three field moves are gated on at list time
|
||||
game.save.inventory.BOULDERBADGE = true
|
||||
game.save.inventory.SOULBADGE = true
|
||||
game.save.inventory.RAINBOWBADGE = true
|
||||
|
||||
for _, key in ipairs({ "_UsedStrengthText", "_CanMoveBouldersText",
|
||||
"_SurfingGotOnText", "_FlashLightsAreaText" }) do
|
||||
local t = game.data.text[key]
|
||||
check(type(t) == "string" and t ~= "", key .. " extracted from the ROM")
|
||||
end
|
||||
|
||||
local function giveMon(species, move)
|
||||
local mon = Pokemon.new(game.data, species, 30)
|
||||
mon.moves = { { id = move, pp = 15 } }
|
||||
game.save.party = { mon }
|
||||
return mon
|
||||
end
|
||||
|
||||
-- park on `cell` if it is free, else on any walkable neighbour of the
|
||||
-- target that looks back at it (a map edit or a mod moves objects)
|
||||
local function stand(mapId, cell, target)
|
||||
U.teleport(game, mapId, cell.x, cell.y, cell.facing)
|
||||
local ow = game.overworld
|
||||
if ow.map:isWalkableCell(cell.x, cell.y) and not ow:npcAtCell(cell.x, cell.y) then
|
||||
return ow
|
||||
end
|
||||
local sides = {
|
||||
{ 0, 1, "up" }, { 0, -1, "down" }, { 1, 0, "left" }, { -1, 0, "right" },
|
||||
}
|
||||
for _, s in ipairs(sides) do
|
||||
local cx, cy = target.x + s[1], target.y + s[2]
|
||||
if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then
|
||||
U.log(("(%d, %d) is blocked, standing on"):format(cell.x, cell.y),
|
||||
cx, cy, "facing", s[3])
|
||||
U.teleport(game, mapId, cx, cy, s[3])
|
||||
return game.overworld
|
||||
end
|
||||
end
|
||||
return ow
|
||||
end
|
||||
|
||||
-- START -> POKéMON -> the mon -> the field-move row, by looking the rows
|
||||
-- up rather than counting presses (a mod can insert either list)
|
||||
local function openFieldMove(action)
|
||||
U.tap(game, "start")
|
||||
U.wait(12)
|
||||
local menu = game.stack:top()
|
||||
local row
|
||||
for i, it in ipairs(menu and menu.items or {}) do
|
||||
if tostring(it.label):upper():find("MON", 1, true) then row = i break end
|
||||
end
|
||||
if not check(row ~= nil, "START menu lists POKéMON") then return nil end
|
||||
for _ = 2, row do U.tap(game, "down"); U.wait(2) end
|
||||
U.tap(game, "a")
|
||||
U.wait(12)
|
||||
local pm = game.stack:top()
|
||||
if not check(getmetatable(pm) == PartyMenu, "POKéMON opens the party menu") then
|
||||
return nil
|
||||
end
|
||||
U.tap(game, "a") -- A on the mon builds the field-move submenu
|
||||
U.wait(8)
|
||||
local sub
|
||||
for i, it in ipairs(pm.subItems or {}) do
|
||||
if it.action == action then sub = i break end
|
||||
end
|
||||
if not check(sub ~= nil, action:upper() .. " is listed in the submenu") then
|
||||
return nil
|
||||
end
|
||||
for _ = 2, sub do U.tap(game, "down"); U.wait(2) end
|
||||
U.tap(game, "a")
|
||||
return pm
|
||||
end
|
||||
|
||||
-- the state the compositor starts from (StateStack:draw walks up from the
|
||||
-- highest opaque state): what is actually behind the message
|
||||
local function backdrop()
|
||||
return game.stack.states[game.stack:visibleBase()]
|
||||
end
|
||||
local function textIsUp()
|
||||
local top = game.stack:top()
|
||||
return top ~= nil and top.pages ~= nil
|
||||
end
|
||||
local function drainText()
|
||||
for _ = 1, 400 do
|
||||
if not textIsUp() then break end
|
||||
U.tap(game, "a")
|
||||
U.wait(3)
|
||||
end
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------- STRENGTH
|
||||
-- pokered data/maps/objects/SeafoamIslands1F.asm: BOULDER1 at (18, 10),
|
||||
-- so the floor to its west is the approach cell
|
||||
giveMon("MACHOP", "STRENGTH")
|
||||
local ow = stand("SEAFOAM_ISLANDS_1F", { x = 17, y = 10, facing = "right" },
|
||||
{ x = 18, y = 10 })
|
||||
check(ow:npcAtCell(18, 10) ~= nil, "the Seafoam boulder is loaded at (18,10)")
|
||||
local pmStr = openFieldMove("strength")
|
||||
U.wait(6)
|
||||
check(textIsUp(), "STRENGTH puts a message up")
|
||||
check(backdrop() == pmStr, "the party menu is the backdrop of the STRENGTH text")
|
||||
U.shot(game, DIR .. "/bug385_strength.png")
|
||||
drainText()
|
||||
U.wait(60)
|
||||
|
||||
-- ---------------------------------------------------------------- SURF
|
||||
-- pokered data/maps/objects/PalletTown.asm has no object on the south
|
||||
-- shore; (4, 13) is the land cell above the water at (4, 14)
|
||||
giveMon("SQUIRTLE", "SURF")
|
||||
ow = stand("PALLET_TOWN", { x = 4, y = 13, facing = "down" }, { x = 4, y = 13 })
|
||||
ow.player.surfing = false
|
||||
check(ow:useSurfFieldMove() == "ok", "(4,13) faces surfable water")
|
||||
local pmSurf = openFieldMove("surf")
|
||||
U.wait(6)
|
||||
check(textIsUp(), "SURF puts the got-on message up")
|
||||
check(backdrop() == pmSurf, "the party menu is the backdrop of the got-on text")
|
||||
U.shot(game, DIR .. "/bug385_surf.png")
|
||||
drainText()
|
||||
U.wait(60)
|
||||
U.shot(game, DIR .. "/bug385_surf_after.png")
|
||||
ow.player.surfing = false
|
||||
|
||||
-- ---------------------------------------------------------------- FLASH
|
||||
-- pokered data/maps/objects/RockTunnel1F.asm: warp 1 is (15, 3), so
|
||||
-- (15, 4) is a real walkable cell just inside the entrance
|
||||
game.save.flashLit = false
|
||||
giveMon("PIKACHU", "FLASH")
|
||||
ow = stand("ROCK_TUNNEL_1F", { x = 15, y = 4, facing = "down" }, { x = 15, y = 3 })
|
||||
check(ow.dark == true, "ROCK_TUNNEL_1F is dark before FLASH")
|
||||
local pmFlash = openFieldMove("flash")
|
||||
U.wait(6)
|
||||
check(textIsUp(), "FLASH puts a message up")
|
||||
check(backdrop() == pmFlash, "the party menu is the backdrop of the FLASH text")
|
||||
check(ow.dark == true, "the tunnel is still dark while the FLASH text is up")
|
||||
U.shot(game, DIR .. "/bug385_flash_text.png")
|
||||
drainText()
|
||||
U.wait(40)
|
||||
check(ow.dark == false, "the tunnel is lit once the blink hands the map back")
|
||||
U.shot(game, DIR .. "/bug385_flash_after.png")
|
||||
|
||||
U.log(fails == 0 and "all machine checks passed" or (fails .. " machine checks failed"))
|
||||
U.log("shots in", DIR)
|
||||
|
||||
-- hand the pad back on a dark tunnel with FLASH still unused
|
||||
game.save.flashLit = false
|
||||
giveMon("PIKACHU", "FLASH")
|
||||
stand("ROCK_TUNNEL_1F", { x = 15, y = 4, facing = "down" }, { x = 15, y = 3 })
|
||||
|
||||
U.log("You are in the dark tunnel with an unused FLASH: START, POKéMON, A, FLASH.")
|
||||
U.log("The message should read over the six-row party list, not a white screen,")
|
||||
U.log("and the tunnel should only be lit after the blink closes the menu.")
|
||||
U.log("Same for SURF and STRENGTH in the shots: party list behind the text,")
|
||||
U.log("one short blink, then the map.")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,219 @@
|
||||
-- Driver: the fishing POSE -- the standing frame's bottom tile row swapped for
|
||||
-- RedFishingTiles so the rod ends in hands -- and its two-stage teardown, rod
|
||||
-- OAM out at once, pose ~10 frames later (#384). FishingAnim / RedFishingTiles
|
||||
-- / res BIT_LEDGE_OR_FISHING: player_animations.asm:378, :485, :449.
|
||||
-- No POKEPORT_SPEED: fast-forward outruns the rendered frames and the audio
|
||||
-- clock, so the ten-frame tail can neither be sampled nor watched.
|
||||
-- POKEPORT_DRIVER=tests/drivers/fishing_pose_bug384_test.lua POKEPORT_IDENTITY=bug384 SHOT_DIR=/tmp/shots love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local Screens = require("src.ui.Screens")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local SpriteRenderer = require("src.render.SpriteRenderer")
|
||||
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
|
||||
local function check(label, ok)
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
-- Same pond as tests/drivers/fishing_rod_bug321_test.lua: the Viridian City
|
||||
-- water block (x=8..13, y=24..27, data/generated/maps.lua VIRIDIAN_CITY) is
|
||||
-- the one place all four shores sit a few steps apart. shoreFor() re-derives
|
||||
-- each stand cell from the map so a layout edit cannot park us at a wall.
|
||||
local MAP = "VIRIDIAN_CITY"
|
||||
local SPOTS = {
|
||||
{ x = 10, y = 23, facing = "down", note = "north shore" },
|
||||
{ x = 8, y = 28, facing = "up", note = "south shore" },
|
||||
{ x = 14, y = 24, facing = "left", note = "east shore" },
|
||||
{ x = 7, y = 24, facing = "right", note = "west shore" },
|
||||
}
|
||||
local DELTA = { up = { 0, -1 }, down = { 0, 1 }, left = { -1, 0 }, right = { 1, 0 } }
|
||||
local ROD = "OLD_ROD"
|
||||
|
||||
-- ---- the halves of the drawing, before anything is on screen ------------
|
||||
-- A missing sheet, a sheet of the wrong size and a Player that never picks
|
||||
-- up fishTiles all look the same: a rod ending in mid air, which is the
|
||||
-- shipped bug.
|
||||
|
||||
local fx = game.data.field.overworldFx
|
||||
local POSE = {
|
||||
{ key = "redFishFront", facing = "down", asm = "RedFishingTilesFront, tile $02" },
|
||||
{ key = "redFishBack", facing = "up", asm = "RedFishingTilesBack, tile $06" },
|
||||
{ key = "redFishSide", facing = "left", asm = "RedFishingTilesSide, tile $0a" },
|
||||
}
|
||||
for _, p in ipairs(POSE) do
|
||||
local def = fx and fx[p.key]
|
||||
if check(("field.overworldFx.%s resolves (%s)"):format(p.key, p.asm),
|
||||
def ~= nil and type(def.path) == "string") then
|
||||
local ok, img = pcall(love.graphics.newImage, def.path)
|
||||
if check(("%s loads: %s"):format(p.key, def.path), ok and img ~= nil) then
|
||||
local w, h = img:getDimensions()
|
||||
check(("%s is 16x8, one 16-wide tile row"):format(p.key), w == 16 and h == 8)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
check("SpriteRenderer:drawTile exists (the pose row wears the sprite's OBP)",
|
||||
type(SpriteRenderer.drawTile) == "function")
|
||||
|
||||
game.save.party = { Pokemon.new(game.data, "CHARIZARD", 50) }
|
||||
game.save.inventory = { [ROD] = 1 }
|
||||
game.save.bagOrder = { ROD }
|
||||
check("OLD ROD is a real item and the only thing in the bag",
|
||||
game.data.items[ROD] ~= nil and (game.save.inventory[ROD] or 0) > 0)
|
||||
local ItemEffects = require("src.inventory.ItemEffects")
|
||||
check("using it routes to the fishing branch",
|
||||
ItemEffects.use(game.data, game.save, ROD, nil, nil, nil, game.overworld) == "fish")
|
||||
|
||||
local function useRod()
|
||||
Screens.push(game, "BagMenu")
|
||||
U.wait(20)
|
||||
U.tap(game, "a") -- OLD ROD -> USE/TOSS
|
||||
U.wait(20)
|
||||
U.tap(game, "a") -- USE
|
||||
U.wait(30)
|
||||
end
|
||||
|
||||
local function topBox()
|
||||
local top = game.stack:top()
|
||||
if getmetatable(top) ~= TextBox then return nil end
|
||||
local shown = {}
|
||||
for _, page in ipairs(top.pages or {}) do
|
||||
for _, line in ipairs(page) do shown[#shown + 1] = line end
|
||||
end
|
||||
return top, table.concat(shown, " / ")
|
||||
end
|
||||
|
||||
local function shoreFor(map, spot)
|
||||
local d = DELTA[spot.facing]
|
||||
if map:inBounds(spot.x + d[1], spot.y + d[2])
|
||||
and map:isWaterCell(spot.x + d[1], spot.y + d[2])
|
||||
and map:isWalkableCell(spot.x, spot.y) then
|
||||
return spot.x, spot.y
|
||||
end
|
||||
for y = 0, map.def.height * 2 - 1 do
|
||||
for x = 0, map.def.width * 2 - 1 do
|
||||
if map:isWalkableCell(x, y) and not map:isWaterCell(x, y)
|
||||
and map:inBounds(x + d[1], y + d[2])
|
||||
and map:isWaterCell(x + d[1], y + d[2]) then
|
||||
U.log(("(%d,%d) no longer faces water; using (%d,%d) for %s")
|
||||
:format(spot.x, spot.y, x, y, spot.facing))
|
||||
return x, y
|
||||
end
|
||||
end
|
||||
end
|
||||
return spot.x, spot.y
|
||||
end
|
||||
|
||||
-- park on a shore, cast, and stop on the dots box
|
||||
local function castFrom(spot)
|
||||
U.teleport(game, MAP, spot.x, spot.y, spot.facing)
|
||||
U.wait(15)
|
||||
local sx, sy = shoreFor(game.overworld.map, spot)
|
||||
if sx ~= spot.x or sy ~= spot.y then
|
||||
U.teleport(game, MAP, sx, sy, spot.facing)
|
||||
U.wait(15)
|
||||
end
|
||||
local ow = game.overworld
|
||||
local fcx, fcy = ow.player:facingCell()
|
||||
check(("facing %s from (%d,%d), the %s: water in front")
|
||||
:format(spot.facing, sx, sy, spot.note),
|
||||
ow.map:inBounds(fcx, fcy) and ow.map:isWaterCell(fcx, fcy))
|
||||
useRod()
|
||||
return ow
|
||||
end
|
||||
|
||||
-- ---- pose up, one facing at a time -------------------------------------
|
||||
-- Every facing is left on its verdict box; the next teleport pops it, so no
|
||||
-- bite ever reaches a battle here.
|
||||
|
||||
for _, spot in ipairs(SPOTS) do
|
||||
local ow = castFrom(spot)
|
||||
local dots = topBox()
|
||||
check(("%s: USE opened the fishing box"):format(spot.facing), dots ~= nil)
|
||||
check(("%s: the rod OAM is up (overworld.fishing)"):format(spot.facing),
|
||||
ow.fishing ~= nil and ow.fishing.facing == spot.facing)
|
||||
check(("%s: the pose is up (player.fishing)"):format(spot.facing),
|
||||
ow.player.fishing == true)
|
||||
check(("%s: a pose row exists for this facing"):format(spot.facing),
|
||||
ow.player.fishTiles ~= nil and ow.player.fishTiles[spot.facing] ~= nil)
|
||||
if spot.facing == "right" then
|
||||
check("right reuses the left pose row, x-flipped like the sprite",
|
||||
ow.player.fishTiles.right == ow.player.fishTiles.left)
|
||||
end
|
||||
-- RodResponse (engine/items/item_effects.asm:1878) zeroes wWalkBikeSurfState
|
||||
-- across FishingAnim, so the sheet under the pose is always the walking one
|
||||
local sheet = ow.player:pose()
|
||||
check(("%s: the walking sheet is what draws (not surf/bike)"):format(spot.facing),
|
||||
sheet == ow.player.sprite)
|
||||
if not U.shot(game, ("%s/bug384_pose_%s.png"):format(SHOT_DIR, spot.facing)) then
|
||||
check(("%s: screenshot reached disk"):format(spot.facing), false)
|
||||
end
|
||||
end
|
||||
|
||||
-- ---- the retract, sampled frame by frame --------------------------------
|
||||
-- The rod OAM dies with the verdict box (res BIT_LEDGE_OR_FISHING right
|
||||
-- after PrintText, player_animations.asm:449) but the patched player tiles
|
||||
-- live until ReloadMapSpriteTilePatterns (home/reload_sprites.asm) a few
|
||||
-- frames later. Only the no-bite verdict shows that gap: a bite goes
|
||||
-- straight into a battle, which reloads the tiles itself, so drop OLD ROD's
|
||||
-- `always` hook for the rest of this run.
|
||||
local rodFishing = (game.data.field.fishing or {})[ROD]
|
||||
check("field.fishing.OLD_ROD exists", rodFishing ~= nil)
|
||||
if rodFishing then rodFishing.always = nil end
|
||||
|
||||
local ow = castFrom(SPOTS[4])
|
||||
local dots = topBox()
|
||||
if dots then
|
||||
for _ = 1, 12 do
|
||||
U.tap(game, "a")
|
||||
U.wait(12)
|
||||
if game.stack:top() ~= dots then break end
|
||||
end
|
||||
end
|
||||
local verdict, verdictText = topBox()
|
||||
check("the no-bite verdict box opened", verdict ~= nil)
|
||||
if verdictText then U.log("box reads:", verdictText) end
|
||||
|
||||
local rodGone, poseGone
|
||||
for i = 1, 180 do
|
||||
if game.stack:top() == verdict then U.tap(game, "a") else U.wait(1) end
|
||||
if not rodGone and ow.fishing == nil then rodGone = i end
|
||||
if not poseGone and ow.player.fishing == nil then poseGone = i end
|
||||
if poseGone then break end
|
||||
end
|
||||
check("the rod OAM came down", rodGone ~= nil)
|
||||
check("the pose came down too", poseGone ~= nil)
|
||||
if rodGone and poseGone then
|
||||
U.log(("rod out on sample frame %d, pose out on %d"):format(rodGone, poseGone))
|
||||
check("the pose OUTLIVES the rod (two stages, not one)", poseGone > rodGone)
|
||||
check("the tail is around ten frames", poseGone - rodGone >= 5)
|
||||
end
|
||||
|
||||
-- ---- hand off ----------------------------------------------------------
|
||||
local live = castFrom(SPOTS[4])
|
||||
if live.fishing then
|
||||
local box = topBox()
|
||||
if box then
|
||||
for _ = 1, 12 do
|
||||
U.tap(game, "a")
|
||||
U.wait(12)
|
||||
if game.stack:top() ~= box then break end
|
||||
end
|
||||
end
|
||||
end
|
||||
U.log("West shore of the Viridian pond, mid-cast, verdict box open. OLD ROD")
|
||||
U.log("was set to never bite for this cast, so dismissing it stays on the map.")
|
||||
U.log("The rod should read as one unbroken stroke: hands and near end in the")
|
||||
U.log("player's lower body, far end in the loose tile, touching on the same")
|
||||
U.log("scanline, and the lower row the same shade as the torso above it.")
|
||||
U.log("Dismiss the box: the loose tile goes at once, the hands hold about ten")
|
||||
U.log("frames, then the sprite snaps back. Both going together is the bug.")
|
||||
U.log("Shots: " .. SHOT_DIR .. "/bug384_pose_<facing>.png")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -181,7 +181,8 @@ return function(game)
|
||||
U.log("Correct: one 8x8 rod stroke touching the player's hands, a mirror")
|
||||
U.log("image left vs right, still up for the whole verdict box (#321).")
|
||||
U.log("Shots: " .. SHOT_DIR .. "/bug321_rod_<facing>_{dots,verdict}.png")
|
||||
U.log("Still unported: the fishing pose, the sprite shake, the ! bubble.")
|
||||
U.log("The pose swaps the sprite's bottom tile row (#384); the sprite shake")
|
||||
U.log("and the ! bubble on a bite are still unported.")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
-- Driver: the Rocket Hideout B4F lift gate stays barred until both guards fall,
|
||||
-- then opens on the spot (#372). scripts/RocketHideoutB4F.asm ...DoorCallback-
|
||||
-- Script stamps $2d at lb bc, 5, 12 until CheckBothEventsSet trainer_0/_1, then
|
||||
-- SFX_GO_INSIDE and $e, and re-runs after a battle (home/trainers.asm
|
||||
-- EndTrainerBattle). Data half: tests/parity_hideout_gate.lua. Never under
|
||||
-- POKEPORT_SPEED: fast-forward desynchronizes the door sound from the stamp.
|
||||
-- POKEPORT_DRIVER=tests/drivers/hideout_gate_bug372_test.lua POKEPORT_IDENTITY=bug372 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local FieldDefaults = require("src.world.FieldDefaults")
|
||||
|
||||
local MAP = "ROCKET_HIDEOUT_B4F"
|
||||
local MAP_LABEL = "RocketHideoutB4F"
|
||||
local GUARD_0 = "EVENT_BEAT_ROCKET_HIDEOUT_4_TRAINER_0"
|
||||
local GUARD_1 = "EVENT_BEAT_ROCKET_HIDEOUT_4_TRAINER_1"
|
||||
-- data/generated/maps.lua ROCKET_HIDEOUT_B4F: the gate is block (12,5),
|
||||
-- i.e. cells (24-25, 10-11), the only gap in that wall row; the guards
|
||||
-- (data/maps/objects/RocketHideoutB4F.asm) sit at (23,12) and (26,12) and
|
||||
-- Giovanni at (25,3) with the SILPH SCOPE ball at (25,2) behind the gate.
|
||||
local DOOR = { bx = 12, by = 5 }
|
||||
local GATE_CELLS = { { 24, 11 }, { 25, 11 } }
|
||||
local STAND = { x = 24, y = 13, facing = "up" }
|
||||
|
||||
local pass, fail = 0, 0
|
||||
local function check(label, ok)
|
||||
if ok then pass = pass + 1 else fail = fail + 1 end
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
game.save.party = {
|
||||
Pokemon.new(game.data, "CHARIZARD", 40),
|
||||
Pokemon.new(game.data, "NIDOKING", 38),
|
||||
}
|
||||
game.save.player.name = "RED"
|
||||
-- a save that has been here before would already carry the guard events
|
||||
game.save.flags[GUARD_0] = nil
|
||||
game.save.flags[GUARD_1] = nil
|
||||
game.save.defeatedTrainers = {}
|
||||
|
||||
local doors = FieldDefaults.fieldValue(game.data, "cardKeyDoors", "closedDoors")
|
||||
local row = doors and doors[MAP] and doors[MAP][1]
|
||||
check("the B4F gate has a closedDoors row (stale caches had none)", row ~= nil)
|
||||
if row then
|
||||
check("it stamps $2d over block (12,5)",
|
||||
row.block == 0x2d and row.bx == DOOR.bx and row.by == DOOR.by)
|
||||
check("it opens to $e on both guard events",
|
||||
row.open == 0x0e and row.events ~= nil and #row.events == 2)
|
||||
end
|
||||
check("B1F carries its own row ($54 at (12,8))",
|
||||
doors and doors.ROCKET_HIDEOUT_B1F ~= nil
|
||||
and doors.ROCKET_HIDEOUT_B1F[1].block == 0x54)
|
||||
check("the guards set the events the gate watches",
|
||||
game.data:trainerHeader(MAP_LABEL, 2).event == GUARD_0
|
||||
and game.data:trainerHeader(MAP_LABEL, 3).event == GUARD_1)
|
||||
check("Go_Inside is in the generated audio",
|
||||
game.data.audio and game.data.audio.sfx
|
||||
and game.data.audio.sfx.Go_Inside ~= nil)
|
||||
|
||||
local vol = game.save.options and game.save.options.sfxVol
|
||||
if vol == 0 then
|
||||
U.log("sfxVol is 0: the door sound will be SILENT, raise it in OPTION first")
|
||||
else
|
||||
U.log("sfxVol", tostring(vol), "-- expect one door sound as the gate opens")
|
||||
end
|
||||
|
||||
U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing)
|
||||
U.wait(10)
|
||||
local ow = game.overworld
|
||||
|
||||
-- a map edit or a mod moved the doorway: stand on any walkable cell
|
||||
-- touching the gate instead of the cell below it
|
||||
if not ow.map:isWalkableCell(STAND.x, STAND.y) then
|
||||
for _, cell in ipairs(GATE_CELLS) do
|
||||
for _, off in ipairs({ { 0, 1, "up" }, { 0, -1, "down" },
|
||||
{ 1, 0, "left" }, { -1, 0, "right" } }) do
|
||||
local cx, cy = cell[1] + off[1], cell[2] + off[2]
|
||||
if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then
|
||||
U.log(("(%d,%d) is not walkable, standing on"):format(STAND.x, STAND.y),
|
||||
cx, cy, "facing", off[3])
|
||||
U.teleport(game, MAP, cx, cy, off[3])
|
||||
U.wait(10)
|
||||
ow = game.overworld
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
check("the doorway is stamped shut on arrival",
|
||||
ow.map:blockAt(DOOR.bx, DOOR.by) == 0x2d)
|
||||
local blocked = true
|
||||
for _, cell in ipairs(GATE_CELLS) do
|
||||
if ow.map:isWalkableCell(cell[1], cell[2]) then blocked = false end
|
||||
end
|
||||
check("neither doorway cell can be stepped into", blocked)
|
||||
|
||||
local guards = 0
|
||||
for _, n in ipairs(ow.npcs or {}) do
|
||||
local name = n.def and n.def.name
|
||||
if name == "ROCKETHIDEOUTB4F_ROCKET1" or name == "ROCKETHIDEOUTB4F_ROCKET2" then
|
||||
guards = guards + 1
|
||||
end
|
||||
end
|
||||
check("both guards are on the floor", guards == 2)
|
||||
U.log(("machine checks: %d pass, %d fail"):format(pass, fail))
|
||||
|
||||
U.shot(game, (os.getenv("SHOT_DIR") or "/tmp/shots") .. "/bug372_gate_shut.png")
|
||||
|
||||
U.log("You are one cell south of the lift gate: barred, and walking up bumps.")
|
||||
U.log("Beat one grunt and it is still barred; beat the second and the bars turn")
|
||||
U.log("to floor as the fade ends, one door sound, then north to Giovanni.")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,171 @@
|
||||
-- Eye check on the Mt Moon B2F Jessie & James ambush (#423): the duo walk up
|
||||
-- to the player and leave behind a fade. pokeyellow scripts/MtMoonB2F.asm
|
||||
-- MtMoonB2FScript_49e15 simulates PAD_UP, Script6/Script9 walk Jessie six and
|
||||
-- James five LEFT steps, Script8/Script11 face them; Script14 hides them between
|
||||
-- GBFadeOutToBlack / GBFadeInFromBlack. No POKEPORT_SPEED (the fade and the
|
||||
-- theme sting ride the audio clock), and no POKEPORT_IDENTITY (it re-imports):
|
||||
-- POKEPORT_VERSION=yellow SHOT_DIR=/tmp/shots POKEPORT_TOUCH=0 POKEPORT_DRIVER=tests/drivers/jessie_james_mtmoon_bug423_test.lua love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
pcall(function() io.stdout:setvbuf("no") end) -- LOVE block-buffers stdout
|
||||
|
||||
-- pokeyellow data/maps/objects/MtMoonB2F.asm: object 2 JESSIE at (9,3),
|
||||
-- object 6 JAMES at (9,4). MtMoonB2FScript_49e15's trigger is (3,5), so the
|
||||
-- PAD_UP step puts the player on (3,4) and the walks land (3,3) and (4,4).
|
||||
local MAP = "MT_MOON_B2F"
|
||||
local TRIGGER = { x = 3, y = 5 }
|
||||
local JESSIE, JAMES = "MTMOONB2F_JESSIE", "MTMOONB2F_JAMES"
|
||||
local BEAT = "EVENT_BEAT_MT_MOON_3_JESSIE_JAMES"
|
||||
local WANT = {
|
||||
player = { x = TRIGGER.x, y = TRIGGER.y - 1 },
|
||||
jessie = { x = TRIGGER.x, y = TRIGGER.y - 2, facing = "down" },
|
||||
james = { x = TRIGGER.x + 1, y = TRIGGER.y - 1, facing = "left" },
|
||||
}
|
||||
|
||||
local function check(label, ok)
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
local function npcNamed(ow, name)
|
||||
for _, n in ipairs(ow.npcs or {}) do
|
||||
if n.def and n.def.name == name then return n end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- ---- what the eye cannot check -----------------------------------------
|
||||
local opts = game.save.options or {}
|
||||
local sfxVol = opts.sfxVol or 7
|
||||
if sfxVol == 0 then
|
||||
U.log("FAIL sfx volume is 0: the theme sting that opens the ambush and the")
|
||||
U.log(" bubble blip over the player are both silent, so the audio half")
|
||||
U.log(" cannot be judged. Set SFX to 7 in OPTION first.")
|
||||
end
|
||||
check(("sfx volume %d"):format(sfxVol), sfxVol > 0)
|
||||
check("running Yellow", GameVersion.isYellow())
|
||||
|
||||
-- one strong mon with one damaging move: a stat move stalls the A-mash
|
||||
local mon = Pokemon.new(game.data, "MEWTWO", 100)
|
||||
mon.moves = { { id = "TACKLE", pp = 35 } }
|
||||
game.save.party = { mon }
|
||||
game.save.flags[BEAT] = nil
|
||||
game.save.flags.EVENT_GOT_HELIX_FOSSIL = true
|
||||
|
||||
-- ---- reach the trigger ---------------------------------------------------
|
||||
U.teleport(game, MAP, TRIGGER.x, TRIGGER.y + 1, "up")
|
||||
local ow = game.overworld
|
||||
U.hold(game, "up", 20)
|
||||
U.wait(10)
|
||||
if not ow.runner:isRunning() then
|
||||
-- a map or mod edit blocked the approach from below: step in from whatever
|
||||
-- free walkable neighbour is left
|
||||
local sides = { { 1, 0, "left" }, { -1, 0, "right" }, { 0, -1, "down" } }
|
||||
for _, s in ipairs(sides) do
|
||||
local cx, cy = TRIGGER.x + s[1], TRIGGER.y + s[2]
|
||||
if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then
|
||||
U.log("approach from below failed, stepping in from", cx, cy)
|
||||
U.teleport(game, MAP, cx, cy, s[3])
|
||||
ow = game.overworld
|
||||
U.hold(game, s[3], 20)
|
||||
U.wait(10)
|
||||
if ow.runner:isRunning() then break end
|
||||
end
|
||||
end
|
||||
end
|
||||
check("the ambush script is running", ow.runner:isRunning())
|
||||
U.shot(game, DIR .. "/jj423_0_trigger.png")
|
||||
|
||||
-- ---- the duo close in ----------------------------------------------------
|
||||
-- Sample every mash: the closed-in positions only hold between the motto and
|
||||
-- the challenge line, and the battle push ends the window.
|
||||
local closed = nil
|
||||
local battle = nil
|
||||
for _ = 1, 1500 do
|
||||
local top = game.stack:top()
|
||||
if getmetatable(top) == BattleState then
|
||||
battle = top
|
||||
break
|
||||
end
|
||||
local jessie, james = npcNamed(ow, JESSIE), npcNamed(ow, JAMES)
|
||||
if not closed and jessie and james and not jessie.moving and not james.moving
|
||||
and jessie.cellX == WANT.jessie.x and james.cellX == WANT.james.x then
|
||||
closed = {
|
||||
px = ow.player.cellX, py = ow.player.cellY,
|
||||
jx = jessie.cellX, jy = jessie.cellY, jf = jessie.facing,
|
||||
mx = james.cellX, my = james.cellY, mf = james.facing,
|
||||
}
|
||||
U.shot(game, DIR .. "/jj423_1_scene.png")
|
||||
end
|
||||
U.tap(game, "a")
|
||||
U.wait(3)
|
||||
end
|
||||
|
||||
check("the duo walked in instead of staying at (9,3) and (9,4)", closed ~= nil)
|
||||
closed = closed or {}
|
||||
U.log("player", tostring(closed.px), tostring(closed.py),
|
||||
"jessie", tostring(closed.jx), tostring(closed.jy), tostring(closed.jf),
|
||||
"james", tostring(closed.mx), tostring(closed.my), tostring(closed.mf))
|
||||
check("the player took the simulated PAD_UP step onto (3,4)",
|
||||
closed.px == WANT.player.x and closed.py == WANT.player.y)
|
||||
check("Jessie is standing directly above the player",
|
||||
closed.jx == WANT.jessie.x and closed.jy == WANT.jessie.y)
|
||||
check("Jessie is facing down at him", closed.jf == WANT.jessie.facing)
|
||||
check("James is standing beside the player",
|
||||
closed.mx == WANT.james.x and closed.my == WANT.james.y)
|
||||
check("James is facing left at him", closed.mf == WANT.james.facing)
|
||||
check("the challenge line opened the battle", battle ~= nil)
|
||||
if battle then
|
||||
for _ = 1, 240 do
|
||||
if battle.showEnemyTrainer and (battle.introSlide or 0) <= 0 then break end
|
||||
U.wait(1)
|
||||
end
|
||||
U.shot(game, DIR .. "/jj423_1b_battle.png")
|
||||
end
|
||||
|
||||
-- ---- and leave behind a fade --------------------------------------------
|
||||
local peakFade, fadeShot = 0, false
|
||||
local settled = false
|
||||
for i = 1, 6000 do
|
||||
local overlay = ow.fadeOverlay
|
||||
local alpha = overlay and overlay.alpha or 0
|
||||
if alpha > peakFade then peakFade = alpha end
|
||||
if game.stack:top() == ow and not ow.runner:isRunning()
|
||||
and #ow.scriptMoves == 0 and game.save.flags[BEAT] then
|
||||
settled = true
|
||||
break
|
||||
end
|
||||
if alpha > 0.35 and not fadeShot then
|
||||
fadeShot = true
|
||||
U.shot(game, DIR .. "/jj423_2_fade.png")
|
||||
end
|
||||
U.tap(game, "a")
|
||||
-- step frame by frame while the overlay lives: a 12-frame ramp sampled
|
||||
-- every third frame reads as no fade at all
|
||||
U.wait((alpha > 0 or i > 1200) and 1 or 3)
|
||||
end
|
||||
U.shot(game, DIR .. "/jj423_3_done.png")
|
||||
|
||||
check("the scene ran to its end", settled)
|
||||
check("the beat flag is set", game.save.flags[BEAT] == true)
|
||||
check("Jessie is gone", npcNamed(ow, JESSIE) == nil)
|
||||
check("James is gone", npcNamed(ow, JAMES) == nil)
|
||||
U.log(("peak fade alpha %.2f"):format(peakFade))
|
||||
check("the exit dipped the screen toward black", peakFade > 0.3)
|
||||
|
||||
U.log("jj423_1_scene.png must show Jessie one cell ABOVE the player looking")
|
||||
U.log("down at him and James on his right looking left, not the pair parked")
|
||||
U.log("across the corridor; the exclamation bubble pops over the player just")
|
||||
U.log("before they start walking, so watch the window live for that beat.")
|
||||
U.log("jj423_2_fade.png is the room dimming with the duo still standing in")
|
||||
U.log("it, and jj423_3_done.png is the same room lit and empty; the second")
|
||||
U.log("Music_MeetJessieJames sting covers the fade, then the cave theme.")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,125 @@
|
||||
-- Eye check on the enemy pic in the Mt Moon Jessie & James fight (#439).
|
||||
-- pokeyellow home/trainers2.asm:36-50 IsFightingJessieJames points
|
||||
-- wTrainerPicPointer at JessieJamesPic when ROCKET fights with wTrainerNo
|
||||
-- >= $2a, leaving GetTrainerName alone. Needs a Yellow cache re-imported
|
||||
-- after #439. No POKEPORT_SPEED: the theme sting rides the audio clock.
|
||||
-- POKEPORT_VERSION=yellow POKEPORT_DRIVER=tests/drivers/jj_battle_pic_bug439_test.lua POKEPORT_IDENTITY=bug439 POKEPORT_TOUCH=0 love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local CacheFs = require("src.import.CacheFs")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
|
||||
-- pokeyellow data/maps/objects/MtMoonB2F.asm parks JESSIE at (9, 3) and
|
||||
-- JAMES at (9, 4); MtMoonB2FScript's coordinate trigger is the (3, 5) tile
|
||||
-- west of them, reachable from (3, 6) once a fossil is in the bag.
|
||||
local MAP = "MT_MOON_B2F"
|
||||
local TRIGGER = { x = 3, y = 5 }
|
||||
local BEAT = "EVENT_BEAT_MT_MOON_3_JESSIE_JAMES"
|
||||
|
||||
local function check(label, ok)
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
-- ---- what the eye cannot check -----------------------------------------
|
||||
local opts = game.save.options or {}
|
||||
local sfxVol = opts.sfxVol or 7
|
||||
if sfxVol == 0 then
|
||||
U.log("FAIL sfx volume is 0: the theme sting that opens the ambush is")
|
||||
U.log(" silent, so the scene's audio half cannot be judged. Set SFX")
|
||||
U.log(" to 7 in OPTION first.")
|
||||
end
|
||||
check(("sfx volume %d"):format(sfxVol), sfxVol > 0)
|
||||
|
||||
check("running Yellow", GameVersion.isYellow())
|
||||
local trainer = game.data.trainers.OPP_ROCKET
|
||||
check("OPP_ROCKET is in the trainer table", trainer ~= nil)
|
||||
trainer = trainer or {}
|
||||
check("the name is still ROCKET, as GetTrainerName leaves it",
|
||||
trainer.name == "ROCKET")
|
||||
-- a Yellow cache built before #439 has no duo pic at all, and every check
|
||||
-- below it would then be measuring the old grunt-only behaviour
|
||||
if not trainer.picJessieJames then
|
||||
U.log("FAIL the Yellow cache predates #439: no duo pic was extracted.")
|
||||
U.log(" Re-import the Yellow ROM from the launcher, then run again.")
|
||||
end
|
||||
check("the cache carries a duo pic", trainer.picJessieJames ~= nil)
|
||||
if trainer.picJessieJames then
|
||||
check("the duo pic file is on the read path",
|
||||
CacheFs.exists(trainer.picJessieJames)
|
||||
or love.filesystem.getInfo(trainer.picJessieJames) ~= nil)
|
||||
end
|
||||
local picked = BattleState.trainerPicPath(game.data, trainer, "OPP_ROCKET", 42)
|
||||
U.log("pic:", tostring(picked))
|
||||
check("party 42 selects the duo pic", picked == trainer.picJessieJames)
|
||||
check("a lone grunt party still selects the class pic",
|
||||
BattleState.trainerPicPath(game.data, trainer, "OPP_ROCKET", 3) == trainer.pic)
|
||||
|
||||
-- ---- reach the ambush ---------------------------------------------------
|
||||
-- one strong mon so the fight is survivable if the reader plays it out
|
||||
game.save.party = { Pokemon.new(game.data, "MEWTWO", 100) }
|
||||
game.save.flags[BEAT] = nil
|
||||
game.save.flags.EVENT_GOT_HELIX_FOSSIL = true
|
||||
|
||||
U.teleport(game, MAP, TRIGGER.x, TRIGGER.y + 1, "up")
|
||||
local ow = game.overworld
|
||||
U.hold(game, "up", 20)
|
||||
U.wait(10)
|
||||
if not ow.runner:isRunning() then
|
||||
-- a map or mod edit blocked the approach from below: come down onto the
|
||||
-- trigger from whichever free walkable neighbour is left
|
||||
local sides = {
|
||||
{ 0, -1, "down" }, { 1, 0, "left" }, { -1, 0, "right" },
|
||||
}
|
||||
for _, s in ipairs(sides) do
|
||||
local cx, cy = TRIGGER.x + s[1], TRIGGER.y + s[2]
|
||||
if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then
|
||||
U.log("approach from below failed, stepping in from", cx, cy)
|
||||
U.teleport(game, MAP, cx, cy, s[3])
|
||||
ow = game.overworld
|
||||
U.hold(game, s[3], 20)
|
||||
U.wait(10)
|
||||
if ow.runner:isRunning() then break end
|
||||
end
|
||||
end
|
||||
end
|
||||
check("the ambush script is running", ow.runner:isRunning())
|
||||
U.shot(game, DIR .. "/jj439_0_trigger.png")
|
||||
|
||||
-- A-mash the motto and the challenge line until the battle is pushed, then
|
||||
-- stop: the trainer pic stays up until the intro text is dismissed
|
||||
local battle = nil
|
||||
for _ = 1, 1200 do
|
||||
local top = game.stack:top()
|
||||
if getmetatable(top) == BattleState then
|
||||
battle = top
|
||||
break
|
||||
end
|
||||
U.tap(game, "a")
|
||||
U.wait(3)
|
||||
end
|
||||
check("the ambush opened a battle", battle ~= nil)
|
||||
if battle then
|
||||
for _ = 1, 240 do
|
||||
if battle.showEnemyTrainer and (battle.introSlide or 0) <= 0 then break end
|
||||
U.wait(1)
|
||||
end
|
||||
check("the enemy trainer pic is on screen", battle.showEnemyTrainer == true)
|
||||
U.shot(game, DIR .. "/jj439_1_intro.png")
|
||||
U.wait(40)
|
||||
U.shot(game, DIR .. "/jj439_2_intro_late.png")
|
||||
U.log("intro text:", tostring(battle.introText))
|
||||
end
|
||||
|
||||
U.log("Both intro shots must show the two-character duo pic: a woman on the")
|
||||
U.log("left and a man on the right, one 7x7 pic, no Meowth -- not the single")
|
||||
U.log("crouching ROCKET grunt. The name in the intro line stays ROCKET.")
|
||||
U.log("The theme under the trigger shot is Music_MeetJessieJames.")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,90 @@
|
||||
-- Manual check of the launcher's Delete labels (#433): a stale hit rect let a
|
||||
-- click on the Mods tab delete a save slot, and Delete never asked first.
|
||||
-- No pokered counterpart: src/import/RomImporter.lua is this port's own desktop
|
||||
-- launcher, so hit-rect coords here come from the panel that draws them.
|
||||
-- POKEPORT_DRIVER=tests/drivers/launcher_delete_bug433_test.lua POKEPORT_IDENTITY=bug433 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
|
||||
-- Leave POKEPORT_SPEED unset: this one is clicked by hand, at real time.
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local RomImporter = require("src.import.RomImporter")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
|
||||
local version = GameVersion.get()
|
||||
|
||||
local function check(label, ok)
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
-- A driver run boots straight past the launcher (main.lua: POKEPORT_DRIVER is
|
||||
-- a scripted run), so bring a real interactive launcher up ourselves and take
|
||||
-- over the frame + click handlers the launcher normally owns.
|
||||
local imp = RomImporter.new(function() end, { launcher = true })
|
||||
imp.play = function() U.log("Play is inert here: the game is already booted.") end
|
||||
|
||||
local prevDraw, prevPressed = love.draw, love.mousepressed
|
||||
local prevKey, prevText = love.keypressed, love.textinput
|
||||
love.draw = function()
|
||||
imp:update(love.timer.getDelta())
|
||||
imp:draw()
|
||||
end
|
||||
love.mousepressed = function(x, y, button) imp:mousepressed(x, y, button or 1) end
|
||||
love.keypressed = function(key) imp:keypressed(key) end
|
||||
love.textinput = function(t) imp:textinput(t) end
|
||||
local function restore()
|
||||
love.draw, love.mousepressed = prevDraw, prevPressed
|
||||
love.keypressed, love.textinput = prevKey, prevText
|
||||
end
|
||||
|
||||
local function slotCount()
|
||||
return #(SaveData.listSlots(version) or {})
|
||||
end
|
||||
|
||||
-- Two throwaway rows to click on, in this identity's save dir only.
|
||||
while slotCount() < 2 do imp:_newSlot(version) end
|
||||
imp.tab = version
|
||||
imp.pageScroll = 0
|
||||
U.wait(4)
|
||||
|
||||
-- The Delete rects exist only after the panel that owns them has drawn.
|
||||
local del = (imp.slotDeleteRects or {})[1]
|
||||
if not check("the save panel drew a Delete rect for a slot row", del ~= nil) then
|
||||
U.log("Nothing to click: the", version, "tab drew no slot rows.")
|
||||
restore()
|
||||
while true do coroutine.yield() end
|
||||
end
|
||||
local spot = { x = del.x + del.width / 2, y = del.y + del.height / 2, id = del.id }
|
||||
U.log(("Delete for %s sits at (%d, %d)."):format(spot.id, spot.x, spot.y))
|
||||
|
||||
imp.tab = "mods"
|
||||
U.wait(4)
|
||||
check("a Mods frame leaves no save Delete rect live",
|
||||
imp.slotDeleteRects == nil)
|
||||
local before = slotCount()
|
||||
imp:mousepressed(spot.x, spot.y, 1)
|
||||
U.wait(4)
|
||||
check("the reporter's click on that spot from Mods deletes nothing",
|
||||
slotCount() == before and imp._confirmDelete == nil)
|
||||
|
||||
imp.tab = version
|
||||
U.wait(4)
|
||||
imp:mousepressed(spot.x, spot.y, 1)
|
||||
U.wait(2)
|
||||
check("one click on Delete arms instead of deleting",
|
||||
slotCount() == before and imp._confirmDelete ~= nil
|
||||
and imp._confirmDelete.id == spot.id)
|
||||
imp:mousepressed(4, 4, 1) -- a press anywhere else
|
||||
U.wait(2)
|
||||
check("a press elsewhere takes the arm back off", imp._confirmDelete == nil)
|
||||
check("the slot survived the whole sequence", slotCount() == before)
|
||||
|
||||
U.log("The launcher on screen is live; the", version, "tab is up, disarmed.")
|
||||
U.log("Clicking Delete once should turn that label red and read \"Sure?\"")
|
||||
U.log("without the row moving, and go back to \"Delete\" after ~4 seconds or")
|
||||
U.log("on any other click. A second click on \"Sure?\" removes the slot.")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,184 @@
|
||||
-- Driver: a multi-hit move must step the enemy HP bar down once per strike
|
||||
-- (#394), not empty it on hit 1 and freeze for the rest. pokered
|
||||
-- ApplyDamageToEnemyPokemon (engine/battle/core.asm:4684-4727) subtracts
|
||||
-- wDamage then runs UpdateHPBar2 inside the wNumAttacksLeft loop. Machine
|
||||
-- half: tests/engine/multihit_hp_drain.lua. Never under POKEPORT_SPEED:
|
||||
-- fast-forward desynchronizes the per-strike damage sound from the bar.
|
||||
-- POKEPORT_DRIVER=tests/drivers/multihit_bug394_test.lua POKEPORT_IDENTITY=bug394 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
|
||||
local MOVE = "DOUBLESLAP"
|
||||
local pass, fail = 0, 0
|
||||
local function check(label, ok)
|
||||
if ok then pass = pass + 1 else fail = fail + 1 end
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
local moveDef = game.data.moves[MOVE]
|
||||
check(MOVE .. " resolves in the move table", moveDef ~= nil)
|
||||
check("...as a 2-to-5 strike move",
|
||||
moveDef ~= nil and moveDef.effect == "TWO_TO_FIVE_ATTACKS_EFFECT")
|
||||
check("SNORLAX resolves (a big HP pool, so each strike is visible)",
|
||||
game.data.pokemon.SNORLAX ~= nil)
|
||||
check("the damage sound is in the generated audio",
|
||||
game.data.audio and game.data.audio.sfx
|
||||
and game.data.audio.sfx.Damage ~= nil)
|
||||
|
||||
local vol = game.save.options and game.save.options.sfxVol
|
||||
if vol == 0 then
|
||||
U.log("sfxVol is 0: the per-strike damage hits will be SILENT,",
|
||||
"raise it in OPTION first")
|
||||
else
|
||||
U.log("sfxVol", tostring(vol), "-- expect one damage hit per strike")
|
||||
end
|
||||
|
||||
-- :L50 so a SNORLAX counterattack cannot end the run before the handoff
|
||||
local lead = Pokemon.new(game.data, "BULBASAUR", 50)
|
||||
lead.moves = { { id = MOVE, pp = 10, maxPP = 10 } } -- one slot, no mis-pick
|
||||
game.save.party = { lead }
|
||||
|
||||
-- offscreen scratch battle: the queue a multi-hit turn builds, with no
|
||||
-- animation timing in the way. Each drain row has to name its target and
|
||||
-- carry the HP left after its own strike; unpinned rows were the bug.
|
||||
do
|
||||
local seqIndex = 0
|
||||
local scratch = BattleState.newWild(game, "SNORLAX", 30)
|
||||
scratch.onFinish = function() end
|
||||
scratch.rng = function(_, hi) -- 5 hits, hit, no crit, max damage roll
|
||||
seqIndex = seqIndex + 1
|
||||
local scripted = ({ 7, 0, 255, 255 })[seqIndex]
|
||||
return scripted ~= nil and scripted or hi
|
||||
end
|
||||
local startHP = scratch.enemy.mon.hp
|
||||
scratch:performMove(scratch.player, scratch.enemy, { id = MOVE, pp = 10 })
|
||||
local stops, anims = {}, 0
|
||||
for _, row in ipairs(scratch.queue) do
|
||||
if row.drain then stops[#stops + 1] = row
|
||||
elseif row.anim == MOVE then anims = anims + 1 end
|
||||
end
|
||||
check(("%d strikes queued %d animations"):format(#stops, anims),
|
||||
#stops > 1 and anims == #stops)
|
||||
local stepped, named = true, true
|
||||
local prev = startHP
|
||||
local shownStops = {}
|
||||
for _, row in ipairs(stops) do
|
||||
named = named and row.battler == scratch.enemy
|
||||
stepped = stepped and type(row.stopAt) == "number" and row.stopAt < prev
|
||||
prev = row.stopAt or prev
|
||||
shownStops[#shownStops + 1] = tostring(row.stopAt)
|
||||
end
|
||||
check("every drain row names the enemy", named)
|
||||
check("and stops on its own strike's HP, one step at a time", stepped)
|
||||
check("the last stop is the post-turn HP", prev == scratch.enemy.mon.hp)
|
||||
U.log(("enemy HP %d -> stops: %s"):format(startHP,
|
||||
table.concat(shownStops, ", ")))
|
||||
end
|
||||
|
||||
-- ROUTE_1 is open field (data/generated/maps.lua ROUTE_1); the cell is read
|
||||
-- off the loaded map so a map edit degrades to another walkable cell
|
||||
-- instead of a wall.
|
||||
U.teleport(game, "ROUTE_1", 5, 5, "down")
|
||||
U.wait(10)
|
||||
local map = game.overworld.map
|
||||
if not map:isWalkableCell(5, 5) then
|
||||
local fx, fy
|
||||
for cy = 0, map.heightCells - 1 do
|
||||
for cx = 0, map.widthCells - 1 do
|
||||
if map:isWalkableCell(cx, cy) then fx, fy = cx, cy break end
|
||||
end
|
||||
if fx then break end
|
||||
end
|
||||
if fx then
|
||||
U.teleport(game, "ROUTE_1", fx, fy, "down")
|
||||
U.wait(10)
|
||||
end
|
||||
end
|
||||
local ow = game.overworld
|
||||
check("player stands on a walkable ROUTE_1 cell",
|
||||
ow.map:isWalkableCell(ow.player.cellX, ow.player.cellY))
|
||||
|
||||
local function mashUntil(cond, max)
|
||||
for _ = 1, max or 120 do
|
||||
if cond() then return true end
|
||||
U.tap(game, "a")
|
||||
U.wait(4)
|
||||
end
|
||||
return cond()
|
||||
end
|
||||
|
||||
local function newFight()
|
||||
local battle = BattleState.newWild(game, "SNORLAX", 30)
|
||||
battle.onFinish = function() end
|
||||
game.overworld:pushBattle(battle)
|
||||
U.wait(220) -- the send-out intro plays before the menu is reachable
|
||||
mashUntil(function() return battle.phase == "menu" end)
|
||||
return battle
|
||||
end
|
||||
|
||||
-- ---- scripted run: watch the bar across the strikes ---------------------
|
||||
local battle = newFight()
|
||||
check("the wild battle reached its FIGHT menu", battle.phase == "menu")
|
||||
U.shot(game, DIR .. "/bug394_menu.png")
|
||||
|
||||
U.tap(game, "a") -- FIGHT
|
||||
U.wait(16)
|
||||
U.tap(game, "a") -- the only move slot
|
||||
U.wait(8)
|
||||
|
||||
-- sample on the falling edge of battle.draining, a frame at a time: a
|
||||
-- coarser poll can miss a strike's drain entirely and read the next one's
|
||||
-- rest twice. Rows that leave the enemy bar where it was (the foe's own
|
||||
-- turn drains the player bar) and the full-bar reset a finished battle
|
||||
-- does are skipped. A is tapped only every eighth frame, to walk the
|
||||
-- queue's text rows without eating a whole drain.
|
||||
local seen, wasDraining = {}, false
|
||||
local lastShown = battle.enemy.shownHP or battle.enemy.mon.hp
|
||||
for frame = 1, 2400 do
|
||||
local draining = battle.draining ~= nil
|
||||
if wasDraining and not draining then
|
||||
local shown = battle.enemy.shownHP or battle.enemy.mon.hp
|
||||
if shown < lastShown - 0.5 then
|
||||
lastShown = shown
|
||||
seen[#seen + 1] = shown
|
||||
U.shot(game, DIR .. ("/bug394_hit%d.png"):format(#seen))
|
||||
U.log(("after strike %d the enemy bar rests on %.0f HP, model HP %d")
|
||||
:format(#seen, shown, battle.enemy.mon.hp))
|
||||
end
|
||||
end
|
||||
wasDraining = draining
|
||||
if not draining and battle.phase == "menu" and #battle.queue == 0 then break end
|
||||
if not draining and frame % 8 == 0 then U.tap(game, "a") end
|
||||
U.wait(1)
|
||||
end
|
||||
local rests = {}
|
||||
for i, hp in ipairs(seen) do rests[i] = ("%.0f"):format(hp) end
|
||||
U.log(("enemy bar rests: %s"):format(table.concat(rests, ", ")))
|
||||
check(("more than one strike moved the enemy bar (%d)"):format(#seen), #seen > 1)
|
||||
check("the first strike did not drain the whole turn's damage (#394)",
|
||||
#seen > 1 and seen[1] > seen[#seen])
|
||||
|
||||
U.log(("machine checks: %d passed, %d failed"):format(pass, fail))
|
||||
|
||||
-- ---- re-arm and hand the pad over --------------------------------------
|
||||
lead.hp = lead.stats.hp
|
||||
lead.status = nil
|
||||
lead.moves[1].pp = lead.moves[1].maxPP
|
||||
local handoff = newFight()
|
||||
U.log("A fresh SNORLAX is waiting on FIGHT with DOUBLESLAP in the only slot.")
|
||||
U.log("Press A twice and watch the enemy bar: it should tick down a bit on")
|
||||
U.log("every strike, in step with the replayed slap and its damage hit, with")
|
||||
U.log("the HP number dropping each time. Before #394 the whole bar emptied on")
|
||||
U.log("slap one and then sat frozen for the rest.")
|
||||
if handoff.phase ~= "menu" then
|
||||
U.log("(the menu did not come back on its own: mash A to reach FIGHT)")
|
||||
end
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,161 @@
|
||||
-- Manual check that Oak's NIDORINO keeps its mirrored front sprite across the
|
||||
-- _OakSpeechText2A -> _OakSpeechText2B page break (#397). OakSpeechText2 is one
|
||||
-- PrintText over one flipped pic (pokered engine/movie/oak_speech/oak_speech.asm
|
||||
-- :80-85, :172-177); advance() used to clear picFlip on the pic-less next step.
|
||||
-- SHOT_DIR=/tmp/bug397 POKEPORT_DRIVER=tests/drivers/oak_nidorino_flip_bug397_test.lua POKEPORT_IDENTITY=bug397 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
|
||||
-- Do not set POKEPORT_SPEED: fast-forward desynchronizes the cry from the wipe.
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
|
||||
local function check(label, ok)
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
local function top() return game.stack:top() end
|
||||
local function isBox(s) return getmetatable(s) == TextBox end
|
||||
|
||||
local function speechState()
|
||||
for _, s in ipairs(game.stack.states or {}) do
|
||||
if s.demoPic ~= nil or (s.steps and s.answers) then return s end
|
||||
end
|
||||
end
|
||||
|
||||
local function boxText(s)
|
||||
if not isBox(s) then return "" end
|
||||
local out = {}
|
||||
for _, page in ipairs(s.pages or {}) do
|
||||
for _, line in ipairs(page) do out[#out + 1] = line end
|
||||
end
|
||||
return table.concat(out, " / ")
|
||||
end
|
||||
|
||||
local function stepId(speech)
|
||||
local cur = speech.steps and speech.steps[speech.step]
|
||||
return cur and (cur.id or cur.kind) or "?"
|
||||
end
|
||||
|
||||
-- boot flow: attract movie -> title -> menu. With a save on disk NEW GAME is
|
||||
-- the second row, so land on it the same way silly_oak_intro_test does.
|
||||
U.wait(5)
|
||||
U.tap(game, "start")
|
||||
U.wait(15)
|
||||
U.tap(game, "a")
|
||||
U.wait(8)
|
||||
local ok, saved = pcall(function()
|
||||
return require("src.core.SaveData").load() ~= nil
|
||||
end)
|
||||
if ok and saved then
|
||||
U.tap(game, "down")
|
||||
U.wait(5)
|
||||
end
|
||||
U.tap(game, "a")
|
||||
U.wait(20)
|
||||
|
||||
local speech
|
||||
for _ = 1, 90 do
|
||||
speech = speechState()
|
||||
if speech then break end
|
||||
U.wait(1)
|
||||
end
|
||||
if not check("NEW GAME entered Oak's speech", speech ~= nil) then
|
||||
U.log("top is", tostring(top()), "-- CONTINUE was picked instead")
|
||||
while true do coroutine.yield() end
|
||||
end
|
||||
|
||||
local demoIdx
|
||||
for i, s in ipairs(speech.steps or {}) do
|
||||
if (s.kind or "say") == "demo" then demoIdx = i break end
|
||||
end
|
||||
check("the step list still has a demo beat", demoIdx ~= nil)
|
||||
check("the show-off front sprite loaded (" ..
|
||||
tostring(speech.demoSpecies) .. ")", speech.demoPic ~= nil)
|
||||
|
||||
local vol = game.save and game.save.options and game.save.options.sfxVol
|
||||
if vol == 0 then
|
||||
U.log("sfxVol is 0: the NIDORINO cry will be silent, raise it in OPTION")
|
||||
else
|
||||
U.log("sfxVol", tostring(vol), "-- the cry plays as the sprite wipes in")
|
||||
end
|
||||
|
||||
if not demoIdx then
|
||||
while true do coroutine.yield() end
|
||||
end
|
||||
|
||||
-- mash only up to the demo beat, then let the wipe finish on its own
|
||||
for _ = 1, 300 do
|
||||
if speech.step >= demoIdx then break end
|
||||
U.tap(game, "a")
|
||||
U.wait(3)
|
||||
end
|
||||
for _ = 1, 180 do
|
||||
if speech.step == demoIdx and not speech.picReveal and isBox(top()) then
|
||||
break
|
||||
end
|
||||
U.wait(1)
|
||||
end
|
||||
|
||||
check("_OakSpeechText2A is up on the demo beat",
|
||||
speech.step == demoIdx and isBox(top()))
|
||||
local textA = boxText(top())
|
||||
U.log("page A reads:", textA)
|
||||
check("the mirrored pic is on screen (picFlip set, demo pic)",
|
||||
speech.picFlip == true and speech.pic == speech.demoPic)
|
||||
U.shot(game, DIR .. "/oak_nido_2a.png")
|
||||
|
||||
-- one A per page of 2A; picFlip must survive every one of them. The page
|
||||
-- count is not fixed (an A during the typewriter only finishes the line), so
|
||||
-- keep turning until the step moves on rather than budgeting presses.
|
||||
local held = true
|
||||
for _ = 1, 60 do
|
||||
U.tap(game, "a")
|
||||
U.wait(12)
|
||||
if speech.picFlip ~= true or speech.pic ~= speech.demoPic then
|
||||
held = false
|
||||
break
|
||||
end
|
||||
if speech.step > demoIdx then break end
|
||||
end
|
||||
check("picFlip and pic unchanged after the page break", held)
|
||||
check("the speech moved on to the pic-less step (" .. stepId(speech) .. ")",
|
||||
speech.step > demoIdx)
|
||||
local textB = boxText(top())
|
||||
U.log("page B reads:", textB)
|
||||
check("a second page is up and it is not page A",
|
||||
isBox(top()) and textB ~= "" and textB ~= textA)
|
||||
U.shot(game, DIR .. "/oak_nido_2b.png")
|
||||
U.log("shots in", DIR)
|
||||
|
||||
-- put the beat back so the page break can be watched live: pop the box and
|
||||
-- rewind the step counter. The wipe and the cry are both skipped on the way
|
||||
-- back in -- the sprite is already standing there and it already called once,
|
||||
-- so replaying them reads as NIDORINO entering twice -- leaving just page A.
|
||||
for _ = 1, 8 do
|
||||
if top() == speech then break end
|
||||
game.stack:pop()
|
||||
end
|
||||
if top() == speech then
|
||||
local Sound = require("src.core.Sound")
|
||||
local realReveal, realCry = speech.revealPic, Sound.playCry
|
||||
speech.revealPic = function(self, _, next)
|
||||
self.picReveal = nil
|
||||
if next then next() end
|
||||
end
|
||||
Sound.playCry = function() end
|
||||
speech.step = demoIdx
|
||||
speech:runStep(speech.steps[demoIdx])
|
||||
speech.revealPic, Sound.playCry = realReveal, realCry
|
||||
U.wait(30)
|
||||
end
|
||||
|
||||
U.log("NIDORINO is standing where the wipe left it, facing LEFT with its horn")
|
||||
U.log("toward screen-left, and page A is back up. Press A to turn the page:")
|
||||
U.log("the sprite must not move or mirror, it stays facing left until the")
|
||||
U.log("screen fades to white for the naming beat.")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,102 @@
|
||||
-- Ear check on the Viridian Mart parcel jingle (#374): the key-item fanfare
|
||||
-- used to fire when the box opened, two pages early. pokered carries it as a
|
||||
-- text command inside the gift text (scripts/ViridianMart.asm
|
||||
-- sound_get_key_item, home/text.asm TextCommand_SOUND), so it lands after the
|
||||
-- last character. Do not set POKEPORT_SPEED: fast-forward scales the logic
|
||||
-- clock only, so audio and text desynchronize and the check means nothing.
|
||||
-- POKEPORT_DRIVER=tests/drivers/parcel_jingle_bug374_test.lua POKEPORT_IDENTITY=bug374 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Flags = require("src.script.Flags")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
|
||||
local MAP = "VIRIDIAN_MART"
|
||||
local QUEST = "_ViridianMartClerkParcelQuestText"
|
||||
-- pokered data/maps/objects/ViridianMart.asm: warp_event 3, 7 is the door;
|
||||
-- ViridianMartDefaultScript's .PlayerMovement walks left 1 / up 2 from it to
|
||||
-- the counter, so the driver only has to stand on the mat.
|
||||
local STAND = { x = 3, y = 7, facing = "up" }
|
||||
|
||||
local function check(label, ok)
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
Flags.set(game.save, "EVENT_GOT_STARTER")
|
||||
game.save.flags.EVENT_GOT_OAKS_PARCEL = nil
|
||||
game.save.flags.EVENT_OAK_GOT_PARCEL = nil
|
||||
check("starter flag set, both parcel flags clear",
|
||||
Flags.get(game.save, "EVENT_GOT_STARTER")
|
||||
and not Flags.get(game.save, "EVENT_GOT_OAKS_PARCEL")
|
||||
and not Flags.get(game.save, "EVENT_OAK_GOT_PARCEL"))
|
||||
|
||||
-- the whole point of the bug: the gift text is the three-page quest, so the
|
||||
-- jingle has two pages of typing to be early over
|
||||
local text = game.data.text[QUEST]
|
||||
check(QUEST .. " resolves", type(text) == "string" and text ~= "")
|
||||
local pages = 1
|
||||
for _ in tostring(text):gmatch("\f") do pages = pages + 1 end
|
||||
check("the quest text is three pages", pages == 3)
|
||||
local last = tostring(text):match("([^\f]*)$") or ""
|
||||
check("its last page is the got-parcel line",
|
||||
last:find("PARCEL", 1, true) ~= nil)
|
||||
|
||||
local sfx = game.data.audio and game.data.audio.sfx
|
||||
check("Get_Key_Item is in the sfx table", sfx ~= nil and sfx.Get_Key_Item ~= nil)
|
||||
local parcel = game.data.items.OAKS_PARCEL
|
||||
check("OAKS_PARCEL is a key item (picks Get_Key_Item over Get_Item1)",
|
||||
parcel ~= nil and parcel.keyItem == true)
|
||||
|
||||
local story = require("data.scripts.story")
|
||||
local gift
|
||||
for _, row in ipairs(story.VIRIDIAN_MART.talk.TEXT_VIRIDIANMART_CLERK) do
|
||||
if row[1] == "give_item" and row[2] == "OAKS_PARCEL" then gift = row end
|
||||
end
|
||||
check("the clerk's give_item row passes the quest text",
|
||||
gift ~= nil and gift[4] == QUEST)
|
||||
|
||||
local vol = game.save.options and game.save.options.sfxVol
|
||||
if vol == 0 then
|
||||
U.log("SFX VOLUME IS 0. Nothing will be audible. Raise it in OPTION and rerun.")
|
||||
else
|
||||
check("sfx volume is audible (" .. tostring(vol) .. ")", vol ~= 0)
|
||||
end
|
||||
|
||||
U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing)
|
||||
U.wait(10)
|
||||
local ow = game.overworld
|
||||
if ow and not ow.map:isWalkableCell(STAND.x, STAND.y) then
|
||||
-- a map edit or a mod moved the mat: any free walkable neighbour of the
|
||||
-- door still lands inside the shop, and the clerk's script drives the walk
|
||||
local sides = { { 1, 0 }, { -1, 0 }, { 0, 1 }, { 0, -1 } }
|
||||
for _, s in ipairs(sides) do
|
||||
local cx, cy = STAND.x + s[1], STAND.y + s[2]
|
||||
if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then
|
||||
U.log(("door cell (%d, %d) is blocked, standing on"):format(STAND.x, STAND.y),
|
||||
cx, cy)
|
||||
U.teleport(game, MAP, cx, cy, STAND.facing)
|
||||
ow = game.overworld
|
||||
U.wait(10)
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
check("standing in " .. MAP, ow ~= nil and ow.map.id == MAP)
|
||||
U.wait(50)
|
||||
|
||||
U.tap(game, "a") U.wait(40) -- the clerk's "you came from PALLET" page
|
||||
U.tap(game, "a") U.wait(90) -- simulated walk to the counter, quest page 1
|
||||
U.tap(game, "a") U.wait(60) -- page 2
|
||||
U.tap(game, "a") U.wait(180) -- last page types out, the jingle belongs here
|
||||
U.shot(game, DIR .. "/374_parcel.png")
|
||||
U.log("captured", DIR .. "/374_parcel.png")
|
||||
|
||||
U.log("The fanfare should start only after \"got / OAK's PARCEL!\" has")
|
||||
U.log("finished typing, the music ducks under it, and A is ignored until it")
|
||||
U.log("ends; then the arrow blinks and A closes the box. The pad is yours:")
|
||||
U.log("clear both parcel flags and walk back in the door to hear it again.")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,316 @@
|
||||
-- Yellow follower cadence (#424): the ledge hop deferred a player step, the
|
||||
-- standing idle counters at OverworldLoop's two DelayFrames per pass, and the
|
||||
-- pikapic beat timed by its own pikapic_setduration (pokeyellow engine/pikachu/
|
||||
-- pikachu_follow.asm Func_fc803/Func_fcc64/Func_fcc92, home/overworld.asm:43,
|
||||
-- pikachu_pic_animation.asm ExecutePikaPicAnimScript). Never POKEPORT_SPEED:
|
||||
-- it scales the logic clock alone and every number judged here is a frame count.
|
||||
-- POKEPORT_DRIVER=tests/drivers/pikachu_cadence_bug424_test.lua POKEPORT_IDENTITY=bug424 POKEPORT_TOUCH=0 POKEPORT_VERSION=yellow love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local PikachuFollower = require("src.world.PikachuFollower")
|
||||
|
||||
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
|
||||
local results = {}
|
||||
local function check(label, ok)
|
||||
results[#results + 1] = { label = label, ok = ok and true or false }
|
||||
return ok
|
||||
end
|
||||
local function report()
|
||||
for _, r in ipairs(results) do U.log(r.ok and "PASS" or "FAIL", r.label) end
|
||||
end
|
||||
local function idleForever()
|
||||
while true do coroutine.yield() end
|
||||
end
|
||||
|
||||
if not GameVersion.isYellow() then
|
||||
check("running the Yellow cache (POKEPORT_VERSION=yellow)", false)
|
||||
report()
|
||||
U.log("Red and Blue have no follower, no idle rolls and no pikapic box.")
|
||||
idleForever()
|
||||
end
|
||||
|
||||
-- ShouldPikachuSpawn's preconditions (pikachu_follow.asm): the lab gift
|
||||
-- happened and a healthy Pikachu leads the party. Without both there is no
|
||||
-- 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.party = { Pokemon.new(game.data, "PIKACHU", 20) }
|
||||
game.save.onBike = false
|
||||
|
||||
local frames = 0
|
||||
local function step(n)
|
||||
for _ = 1, n do
|
||||
coroutine.yield()
|
||||
frames = frames + 1
|
||||
end
|
||||
end
|
||||
|
||||
-- ROUTE_1 (pokeyellow data/maps/objects/Route1.asm has no object near the
|
||||
-- ledge run) carries the first plain south-facing ledge; (5, 4) is the cell
|
||||
-- to hop from, re-derived below out of the cached blocks against
|
||||
-- data.field.ledges so a map edit moves the test instead of breaking it.
|
||||
local MAP = "ROUTE_1"
|
||||
local WANT = { x = 5, y = 4 }
|
||||
|
||||
local function southLedge(map, cx, cy)
|
||||
if not (map:inBounds(cx, cy) and map:inBounds(cx, cy + 3)) then return false end
|
||||
-- the landing plus one more open cell below it: the queued hop only drains
|
||||
-- on the player's next step, so the ledge needs room for that step
|
||||
if not (map:isWalkableCell(cx, cy) and map:isWalkableCell(cx, cy - 1)
|
||||
and map:isWalkableCell(cx, cy + 2)
|
||||
and map:isWalkableCell(cx, cy + 3)) then
|
||||
return false
|
||||
end
|
||||
local standing, front = map:cellTile(cx, cy), map:cellTile(cx, cy + 1)
|
||||
for _, l in ipairs(game.data.field.ledges or {}) do
|
||||
if (l.tileset or "OVERWORLD") == map.def.tileset
|
||||
and l.facing == "down" and l.input == "down"
|
||||
and l.standingTile == standing and l.ledgeTile == front then
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
U.teleport(game, MAP, WANT.x, WANT.y - 1, "down")
|
||||
U.wait(20)
|
||||
|
||||
local ow = game.overworld
|
||||
check("overworld is up on " .. MAP, ow ~= nil and ow.map.id == MAP)
|
||||
if not ow then
|
||||
report()
|
||||
idleForever()
|
||||
end
|
||||
|
||||
local hx, hy = WANT.x, WANT.y
|
||||
if not southLedge(ow.map, hx, hy) then
|
||||
for cy = 1, ow.map.heightCells - 4 do
|
||||
for cx = 0, ow.map.widthCells - 1 do
|
||||
if southLedge(ow.map, cx, cy) then hx, hy = cx, cy break end
|
||||
end
|
||||
if hx ~= WANT.x or hy ~= WANT.y then break end
|
||||
end
|
||||
U.log(("(%d, %d) is no longer a south ledge; using"):format(WANT.x, WANT.y),
|
||||
hx, hy)
|
||||
U.teleport(game, MAP, hx, hy - 1, "down")
|
||||
U.wait(20)
|
||||
ow = game.overworld
|
||||
end
|
||||
check(("a south ledge to hop at (%d, %d)"):format(hx, hy),
|
||||
southLedge(ow.map, hx, hy))
|
||||
|
||||
local function follower()
|
||||
for _, n in ipairs(ow.npcs or {}) do
|
||||
if n.pikachuFollower then return n end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local npc = follower()
|
||||
if not check("the follower spawned", npc ~= nil) then
|
||||
report()
|
||||
idleForever()
|
||||
end
|
||||
|
||||
-- ---- the standing idle clock ----
|
||||
-- Func_fc803 reloads $20 and decrements it once per UpdateSprites call, and
|
||||
-- a standing OverworldLoop burns two DelayFrames per pass, so one counter
|
||||
-- unit is two of this port's fixed steps: the glance lands on frame 64.
|
||||
local function glanceLeft()
|
||||
local idle = npc.idle
|
||||
if idle and idle.kind == "wait" then return idle.frames end
|
||||
return nil
|
||||
end
|
||||
|
||||
for _ = 1, 90 do
|
||||
if glanceLeft() then break end
|
||||
step(1)
|
||||
end
|
||||
if not check("the follower is in the standing glance state", glanceLeft() ~= nil) then
|
||||
report()
|
||||
idleForever()
|
||||
end
|
||||
|
||||
local last, lastAt = glanceLeft(), frames
|
||||
-- the first change lands a partial unit after the sampling started, so it
|
||||
-- only sets the baseline: every gap measured after it is a whole unit
|
||||
local minGap, maxGap, seen, resetAt, period = 99, 0, 0, nil, nil
|
||||
for _ = 1, 300 do
|
||||
step(1)
|
||||
local now = glanceLeft()
|
||||
if now == nil then break end
|
||||
if now ~= last then
|
||||
seen = seen + 1
|
||||
local gap = frames - lastAt
|
||||
if seen > 1 then
|
||||
if gap < minGap then minGap = gap end
|
||||
if gap > maxGap then maxGap = gap end
|
||||
end
|
||||
if now > last then
|
||||
if resetAt then period = period or frames - resetAt end
|
||||
resetAt = frames
|
||||
end
|
||||
last, lastAt = now, frames
|
||||
end
|
||||
end
|
||||
check("the idle counter loses one unit every two frames, not every frame",
|
||||
minGap == 2 and maxGap == 2)
|
||||
check("a full glance takes 64 frames ($20 units), not 32", period == 64)
|
||||
U.log("idle counter gap", minGap, "to", maxGap, "frames; glance period",
|
||||
tostring(period))
|
||||
|
||||
-- ---- the ledge hop, one player step late ----
|
||||
-- Func_fcc08 hands a ledge step to Func_fcc64, which appends the $5-$8 hop
|
||||
-- on the takeoff step and nothing on the landing step; Func_fcc92 cannot pop
|
||||
-- a command with nothing queued behind it, so the hop waits for the player's
|
||||
-- next step and Pikachu sits two cells back until then.
|
||||
local sawHop, hopLen = false, nil
|
||||
local function sample()
|
||||
local f = follower()
|
||||
if f and f.hopStep then
|
||||
sawHop = true
|
||||
hopLen = hopLen or f.stepFrames
|
||||
end
|
||||
end
|
||||
|
||||
for _ = 1, 200 do
|
||||
table.insert(game.input.pressQueue, "down")
|
||||
game.input.state["down"] = true
|
||||
step(1)
|
||||
sample()
|
||||
if (ow.player.hopFrames or 0) > 0 then break end
|
||||
end
|
||||
game.input.state["down"] = false
|
||||
check("the player's ledge hop fired", (ow.player.hopFrames or 0) > 0)
|
||||
|
||||
for _ = 1, 150 do
|
||||
step(1)
|
||||
sample()
|
||||
end
|
||||
npc = follower()
|
||||
check("the follower waits on the cell the player took off from",
|
||||
npc ~= nil and npc.cellX == hx and npc.cellY == hy)
|
||||
check("it does not hop while the player stands still", not sawHop)
|
||||
if npc then
|
||||
U.log("player at", ow.player.cellX, ow.player.cellY,
|
||||
"| follower waiting at", npc.cellX, npc.cellY)
|
||||
end
|
||||
|
||||
-- one more player step drains the buffered hop
|
||||
for _ = 1, 120 do
|
||||
table.insert(game.input.pressQueue, "down")
|
||||
game.input.state["down"] = true
|
||||
step(1)
|
||||
sample()
|
||||
if ow.player.cellY > hy + 2 then break end
|
||||
end
|
||||
game.input.state["down"] = false
|
||||
for _ = 1, 120 do
|
||||
step(1)
|
||||
sample()
|
||||
end
|
||||
|
||||
check("the deferred hop then ran", sawHop)
|
||||
-- Func_fc7aa jumps to Func_fca0a on the $4 movement status before it asks
|
||||
-- AreThereAtLeastTwoStepsInPikachuFollowCommandBuffer, so a hop is never a
|
||||
-- Fast step even though its goal is two cells off
|
||||
local walkLen = ow.player.stepFramesCur or ow.player.stepFrames or 16
|
||||
check("the hop takes a full step's frames, not a halved Fast step",
|
||||
hopLen == walkLen)
|
||||
U.log("hop step frames", tostring(hopLen), "against a walk's", walkLen)
|
||||
npc = follower()
|
||||
check("the follower cleared the ledge row",
|
||||
npc ~= nil and npc.cellY > hy + 1
|
||||
and ow.map:isWalkableCell(npc.cellX, npc.cellY))
|
||||
|
||||
-- ---- the pikapic beat ----
|
||||
-- happiness 255 with mood 200 is the matrix cell for emotion 20 (a heart
|
||||
-- bubble and PikaPicAnimScript20: 50 ticks, so 150 frames, with the second
|
||||
-- full-body pose up for 24 of every 40 ticks).
|
||||
game.save.pikachuHappiness = 255
|
||||
game.save.pikachuMood = 200
|
||||
|
||||
local DIRS = { { "down", 0, 1 }, { "up", 0, -1 },
|
||||
{ "left", -1, 0 }, { "right", 1, 0 } }
|
||||
local function facingFollower()
|
||||
local fx, fy = ow.player:facingCell()
|
||||
return follower() ~= nil and ow:npcAtCell(fx, fy) == follower()
|
||||
end
|
||||
if not facingFollower() then
|
||||
for _, d in ipairs(DIRS) do
|
||||
if npc and npc.cellX == ow.player.cellX + d[2]
|
||||
and npc.cellY == ow.player.cellY + d[3] then
|
||||
U.tap(game, d[1])
|
||||
U.wait(6)
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
check("player is facing the follower", facingFollower())
|
||||
|
||||
-- a muted run sounds like a beat that never started, so say so up front
|
||||
local sfxVol = game.save.options and game.save.options.sfxVol
|
||||
U.log("save.options.sfxVol:", tostring(sfxVol))
|
||||
if (sfxVol or 0) == 0 then
|
||||
U.log("WARNING sfx volume is 0: the squeak cannot be heard whether or not")
|
||||
U.log("WARNING one plays. Raise it in OPTIONS before judging the sound.")
|
||||
end
|
||||
|
||||
U.tap(game, "a")
|
||||
U.wait(1)
|
||||
local emote = ow.emote
|
||||
if not check("the A press raised the framed pikapic", emote ~= nil) then
|
||||
report()
|
||||
idleForever()
|
||||
end
|
||||
check("the beat runs the script's 50 ticks at three frames each",
|
||||
emote.pikaTotal == 150)
|
||||
check("its length is not the old flat 50 frame hold", emote.pikaTotal ~= 50)
|
||||
check("the pikapic marks itself skippable", emote.skippable == true)
|
||||
check("emotion 20 carries an overlay frameset", type(emote.pikaSeq) == "table")
|
||||
|
||||
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
|
||||
lifted = lifted + 1
|
||||
if not liftShot then
|
||||
liftShot = SHOT_DIR .. "/bug424_pikapic_lift.png"
|
||||
if U.shot(game, liftShot) then U.log("captured", liftShot)
|
||||
else liftShot = nil end
|
||||
end
|
||||
else
|
||||
grounded = grounded + 1
|
||||
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",
|
||||
grounded)
|
||||
|
||||
local leftWhenCut = ow.emote and ow.emote.frames or 0
|
||||
U.tap(game, "a")
|
||||
U.wait(2)
|
||||
check("A cuts the beat short (PikaPicAnimTimerAndJoypad)",
|
||||
leftWhenCut > 0 and ow.emote == nil)
|
||||
U.log("cut with", leftWhenCut, "frames still on the clock")
|
||||
|
||||
report()
|
||||
|
||||
-- hand the pad back one cell above the same ledge so Down replays the hop
|
||||
U.teleport(game, MAP, hx, hy - 1, "down")
|
||||
U.wait(20)
|
||||
|
||||
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("beat, and A or B cuts the beat off early.")
|
||||
|
||||
idleForever()
|
||||
end
|
||||
@@ -71,11 +71,14 @@ return function(game)
|
||||
-- a cell the player can hop south from, with two walkable cells above it
|
||||
-- to walk in from and a walkable landing two cells below
|
||||
local function hopCellOk(map, cx, cy)
|
||||
if not (map:inBounds(cx, cy) and map:inBounds(cx, cy + 2)) then
|
||||
if not (map:inBounds(cx, cy) and map:inBounds(cx, cy + 3)) then
|
||||
return false
|
||||
end
|
||||
if not map:isWalkableCell(cx, cy) then return false end
|
||||
if not map:isWalkableCell(cx, cy + 2) then return false end
|
||||
-- one more open cell below the landing: the queued hop only drains on
|
||||
-- the player's NEXT step, so the ledge needs room to take it (#424)
|
||||
if not map:isWalkableCell(cx, cy + 3) then return false end
|
||||
if not (map:isWalkableCell(cx, cy - 1)
|
||||
and map:isWalkableCell(cx, cy - 2)) then
|
||||
return false
|
||||
@@ -209,6 +212,26 @@ return function(game)
|
||||
U.log("the arc was already over before the capture; no mid-air frame")
|
||||
end
|
||||
|
||||
-- The hop command is buffered a step behind the player: Func_fcc64 appends
|
||||
-- nothing for the landing half of the jump, so Func_fcc92 cannot pop the
|
||||
-- hop until another command queues behind it. Pikachu therefore walks to
|
||||
-- the cell the player took off from and waits there (#424).
|
||||
step(120)
|
||||
local waiting = follower()
|
||||
check("the follower waits on the cell the player took off from",
|
||||
waiting ~= nil and waiting.cellX == hx and waiting.cellY == hy)
|
||||
check("it has not hopped while the player stood still", not sawHopStep)
|
||||
|
||||
-- one more player step drains the queued hop
|
||||
for _ = 1, 90 do
|
||||
table.insert(game.input.pressQueue, "down")
|
||||
game.input.state["down"] = true
|
||||
coroutine.yield()
|
||||
sample()
|
||||
if ow.player.cellY > hy + 2 then break end
|
||||
end
|
||||
game.input.state["down"] = false
|
||||
|
||||
-- let the follower finish crossing (its hop is one normal step's frames
|
||||
-- with a doubled step vector, pikachu_follow.asm Func_fca0a)
|
||||
step(120)
|
||||
@@ -242,9 +265,11 @@ return function(game)
|
||||
|
||||
U.log("Hold Down to hop the ledge again from here.")
|
||||
U.log("During the arc there should be one flat ellipse on the ground under")
|
||||
U.log("the player, not a taller blob with a seam across its middle, and")
|
||||
U.log("Pikachu should clear both cells in one motion. The near miss to")
|
||||
U.log("watch for is Pikachu pausing a beat on the ledge tile itself.")
|
||||
U.log("the player, not a taller blob with a seam across its middle.")
|
||||
U.log("Pikachu should walk up to the cell you jumped from and STAY there")
|
||||
U.log("until you take another step, then clear both cells in one motion.")
|
||||
U.log("The near misses to watch for are Pikachu hopping alongside you and")
|
||||
U.log("Pikachu pausing a beat on the ledge tile itself.")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
-- A connection crossing has to carry Yellow's follower, not respawn it (#427).
|
||||
-- pokeyellow home/overworld.asm:648-655 sets bit 4 of
|
||||
-- wPikachuOverworldStateFlags before LoadMapHeader, so pikachu_follow.asm's
|
||||
-- SchedulePikachuSpawnForAfterText takes .normal_spawn_state: coords rebased,
|
||||
-- sprite data left alone. Never POKEPORT_SPEED here, the offsets are pixels.
|
||||
-- POKEPORT_DRIVER=tests/drivers/pikachu_seam_bug427_test.lua POKEPORT_TOUCH=0 POKEPORT_VERSION=yellow love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local PikachuFollower = require("src.world.PikachuFollower")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local Map = require("src.world.Map")
|
||||
|
||||
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
|
||||
local function check(label, ok)
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
if not GameVersion.isYellow() then
|
||||
check("only Yellow has a follower; re-run with POKEPORT_VERSION=yellow", false)
|
||||
while true do coroutine.yield() end
|
||||
end
|
||||
|
||||
-- pokeyellow data/maps/objects/PalletTown.asm puts no object on the town's
|
||||
-- north exit and PALLET_TOWN's north connection to ROUTE_1 carries offset 0,
|
||||
-- so the landing column is the column walked off the edge.
|
||||
local MAP = "PALLET_TOWN"
|
||||
local NORTH = "ROUTE_1"
|
||||
local COL = 10
|
||||
local START_Y = 3
|
||||
|
||||
game.save.party = { Pokemon.new(game.data, "PIKACHU", 100) }
|
||||
game.save.flags = game.save.flags or {}
|
||||
game.save.flags.EVENT_GOT_STARTER = true
|
||||
game.save.onBike = false
|
||||
game.save.repelSteps = 9999
|
||||
game.save.player.name = "bryan"
|
||||
|
||||
U.teleport(game, MAP, COL, START_Y, "up")
|
||||
U.wait(10)
|
||||
local ow = game.overworld
|
||||
local map = ow.map
|
||||
|
||||
-- a usable exit column: four clear cells up to the edge, nothing standing
|
||||
-- in them, and a landing the engine's own edge read accepts (Map.defPassable
|
||||
-- is what crossConnection consults before it commits to the crossing)
|
||||
local function exitOK(cx)
|
||||
for cy = 0, START_Y do
|
||||
if not map:isWalkableCell(cx, cy) then return false end
|
||||
-- ow.npcs carries the follower too, and it is standing in this column
|
||||
local at = ow:npcAtCell(cx, cy)
|
||||
if at and not at.pikachuFollower then return false end
|
||||
end
|
||||
local p = ow.player
|
||||
local keepX = p.cellX
|
||||
p.cellX = cx
|
||||
local dest, ts, lx, ly = ow:connectionLanding("up")
|
||||
p.cellX = keepX
|
||||
return dest ~= nil and dest.id == NORTH
|
||||
and Map.defPassable(dest, ts, lx, ly, false)
|
||||
end
|
||||
|
||||
if not exitOK(COL) then
|
||||
local found
|
||||
for cx = 0, map.widthCells - 1 do
|
||||
if exitOK(cx) then found = cx break end
|
||||
end
|
||||
U.log(("column %d no longer walks off the north edge; using column %s")
|
||||
:format(COL, tostring(found)))
|
||||
COL = found or COL
|
||||
U.teleport(game, MAP, COL, START_Y, "up")
|
||||
U.wait(10)
|
||||
ow = game.overworld
|
||||
map = ow.map
|
||||
end
|
||||
check(("column %d walks off %s's north edge onto %s"):format(COL, MAP, NORTH),
|
||||
exitOK(COL))
|
||||
|
||||
local pika = PikachuFollower.current(ow)
|
||||
check("the follower spawned on " .. MAP, pika ~= nil)
|
||||
|
||||
-- exactly one step north, then stop: the follower has to be trailing and
|
||||
-- settled, since a respawn on the cell it already occupies is invisible
|
||||
local p = ow.player
|
||||
local stopY = p.cellY - 1
|
||||
for _ = 1, 60 do
|
||||
if p.cellY <= stopY and not p.moving then break end
|
||||
table.insert(game.input.pressQueue, "up")
|
||||
game.input.state.up = true
|
||||
coroutine.yield()
|
||||
end
|
||||
game.input.state.up = false
|
||||
U.wait(20)
|
||||
U.log(("before the seam: player (%d, %d), follower (%s, %s)")
|
||||
:format(p.cellX, p.cellY, tostring(pika and pika.cellX),
|
||||
tostring(pika and pika.cellY)))
|
||||
check("the follower trails one cell behind before the seam",
|
||||
pika ~= nil and pika.cellX == p.cellX and pika.cellY == p.cellY + 1)
|
||||
if U.shot(game, SHOT_DIR .. "/bug427_before_seam.png") then
|
||||
U.log("captured", SHOT_DIR .. "/bug427_before_seam.png")
|
||||
end
|
||||
|
||||
-- Hold up through the crossing, sampling once per logic step (main.lua
|
||||
-- resumes the driver exactly once per Game:update). The pixel offset
|
||||
-- between the two sprites is the measurement: a respawn loses the in-flight
|
||||
-- step, so the offset snaps even when the new instance lands on the right
|
||||
-- cell. The mid-seam capture is armed inline instead of through U.shot so
|
||||
-- the walk keeps running while it lands.
|
||||
local samples, missing, maxJump = 0, 0, 0
|
||||
local prevX, prevY
|
||||
local crossedAt, pikaAfter
|
||||
local frames = 0
|
||||
while frames < 240 do
|
||||
table.insert(game.input.pressQueue, "up")
|
||||
game.input.state.up = true
|
||||
frames = frames + 1
|
||||
coroutine.yield()
|
||||
local npc = PikachuFollower.current(ow)
|
||||
if not npc then
|
||||
missing = missing + 1
|
||||
else
|
||||
local pl = ow.player
|
||||
local ox, oy = npc.px - pl.px, npc.py - pl.py
|
||||
if prevX then
|
||||
local jump = math.max(math.abs(ox - prevX), math.abs(oy - prevY))
|
||||
if jump > maxJump then maxJump = jump end
|
||||
end
|
||||
prevX, prevY = ox, oy
|
||||
samples = samples + 1
|
||||
end
|
||||
if not crossedAt and ow.map.def.id == NORTH then
|
||||
crossedAt = frames
|
||||
pikaAfter = npc
|
||||
game.capturePath = SHOT_DIR .. "/bug427_mid_seam.png"
|
||||
end
|
||||
if crossedAt and frames >= crossedAt + 24 then break end
|
||||
end
|
||||
game.input.state.up = false
|
||||
U.wait(30)
|
||||
|
||||
check("the player crossed the seam onto " .. NORTH,
|
||||
crossedAt ~= nil and ow.map.def.id == NORTH)
|
||||
check("the follower across the seam is the same instance",
|
||||
pikaAfter ~= nil and pikaAfter == pika)
|
||||
check("it is still the same instance once the walk settles",
|
||||
PikachuFollower.current(ow) == pika)
|
||||
check(("it never dropped out of ow.npcs (%d of %d frames)")
|
||||
:format(missing, samples + missing), missing == 0 and samples > 0)
|
||||
check(("its pixel offset to the player never jumped (max %dpx)")
|
||||
:format(maxJump), maxJump <= 4)
|
||||
|
||||
local settled = PikachuFollower.current(ow)
|
||||
p = ow.player
|
||||
if settled then
|
||||
U.log(("player (%d, %d) facing %s, follower (%d, %d)")
|
||||
:format(p.cellX, p.cellY, tostring(p.facing),
|
||||
settled.cellX, settled.cellY))
|
||||
end
|
||||
check("the follower came to rest one cell behind",
|
||||
settled ~= nil and settled.cellX == p.cellX
|
||||
and settled.cellY == p.cellY + 1)
|
||||
local trail = ow.pikachuTrail
|
||||
check("the chased trail cell was rebased into the new map",
|
||||
trail ~= nil and ow.map:inBounds(trail.x, trail.y))
|
||||
|
||||
local mid = io.open(SHOT_DIR .. "/bug427_mid_seam.png", "rb")
|
||||
if mid then
|
||||
mid:close()
|
||||
U.log("captured", SHOT_DIR .. "/bug427_mid_seam.png")
|
||||
else
|
||||
U.log("FAIL screenshot did not reach disk:", SHOT_DIR .. "/bug427_mid_seam.png")
|
||||
end
|
||||
if U.shot(game, SHOT_DIR .. "/bug427_after_seam.png") then
|
||||
U.log("captured", SHOT_DIR .. "/bug427_after_seam.png")
|
||||
end
|
||||
|
||||
U.log("You just walked north out of Pallet onto Route 1 with Pikachu behind")
|
||||
U.log("you. In bug427_mid_seam.png it has to be below you and mid-stride, on")
|
||||
U.log("the pixels the walk left it on; the bug put it squarely on a cell in")
|
||||
U.log("front of you instead, once per seam, which is the pop in the report.")
|
||||
U.log("Warps still respawn it behind you (walk through any door), and a")
|
||||
U.log("fainted or deposited Pikachu still leaves no follower at a seam.")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -196,6 +196,11 @@ return function(game)
|
||||
type(emote.pikaPic) == "string"
|
||||
and love.filesystem.getInfo(emote.pikaPic) ~= nil)
|
||||
check("a cry source was created", reportCries(before))
|
||||
-- the beat now runs the pikapic script's own pikapic_setduration, three
|
||||
-- 60Hz frames per tick, so even the shortest script (32 ticks) outlasts
|
||||
-- the 50 frame hold this port used to serve every emotion (#424)
|
||||
check("the hold runs the script's own duration, not a flat 50 frames",
|
||||
(emote.frames or 0) >= 80)
|
||||
bubbleReport(emote)
|
||||
U.log("happiness", tostring(game.save.pikachuHappiness or 90),
|
||||
"mood", tostring(game.save.pikachuMood or 128))
|
||||
@@ -209,7 +214,14 @@ return function(game)
|
||||
-- wPikachuEmotionModifier 5 is MapSpecificPikachuExpression's fifth
|
||||
-- entry, emotion 25: BOLT_BUBBLE plus PCM clip 35. Forcing it takes the
|
||||
-- mood roll out of the picture, so a missing bubble here is a real fault.
|
||||
U.wait(70) -- the 50 frame hold, plus slack, before input is looked at again
|
||||
-- wait the pikapic beat out instead of counting frames: its length is the
|
||||
-- script's now, and an A press during it would only cut it short
|
||||
-- (PikaPicAnimTimerAndJoypad, #424)
|
||||
for _ = 1, 400 do
|
||||
if not ow.emote then break end
|
||||
U.wait(1)
|
||||
end
|
||||
U.wait(6)
|
||||
game.save.pikachuEmotionModifier = 5
|
||||
check("still facing the follower for the second press", facingFollower())
|
||||
before = #cries
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
-- Manual check that EXIT GAME actually ends the process (#339): the two
|
||||
-- background workers are now told to quit from love.quit and Android exits
|
||||
-- outright, so the app reopens without swiping it out of Recents.
|
||||
-- Port lifecycle only, no pokered analogue (the GB has no process).
|
||||
-- POKEPORT_DRIVER=tests/drivers/quit_process_bug339_test.lua POKEPORT_IDENTITY=bug339 POKEPORT_TOUCH=0 love .
|
||||
-- Do not set POKEPORT_SPEED: fast-forward desynchronizes the title music.
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
|
||||
local function check(label, ok)
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
-- No map setup: the quit path is the title's EXIT GAME row
|
||||
-- (src/ui/TitleState.lua:368), reachable straight from boot. The START
|
||||
-- menu QUIT is not a process quit, it power-cycles to the title
|
||||
-- (src/core/Game.lua:165), so it cannot verify this.
|
||||
U.wait(5)
|
||||
U.tap(game, "start") -- past the copyright splash / attract movie
|
||||
U.wait(30)
|
||||
|
||||
local title = game.stack:top()
|
||||
check("the title screen is on top",
|
||||
title ~= nil and title.screenId == "TitleState")
|
||||
|
||||
-- love.quit reaches both workers through package.loaded, so what matters is
|
||||
-- that the module this session loaded is the one carrying shutdown
|
||||
local chip = package.loaded["src.core.ChipAudio"]
|
||||
check("ChipAudio is loaded in this session", chip ~= nil)
|
||||
check("ChipAudio.shutdown exists",
|
||||
chip ~= nil and type(chip.shutdown) == "function")
|
||||
check("love.thread is available, so the chip worker is the live one",
|
||||
love.thread ~= nil and love.thread.newThread ~= nil)
|
||||
|
||||
-- src/update/Check's worker is started by the launcher
|
||||
-- (src/import/RomImporter.lua:594), not by a driver run
|
||||
local upd = package.loaded["src.update.Check"]
|
||||
if upd then
|
||||
check("Check.shutdown exists", type(upd.shutdown) == "function")
|
||||
else
|
||||
local ok, mod = pcall(require, "src.update.Check")
|
||||
check("Check.shutdown exists (module not started this run)",
|
||||
ok and type(mod.shutdown) == "function")
|
||||
end
|
||||
|
||||
check("love.event.quit is reachable",
|
||||
love.event ~= nil and love.event.quit ~= nil)
|
||||
|
||||
local opts = game.save.options
|
||||
if (opts and opts.sfxVol or 0) == 0 then
|
||||
U.log("WARNING sfx volume is 0: the menu presses will make no sound.")
|
||||
end
|
||||
if (opts and opts.musicVol or 0) == 0 then
|
||||
U.log("WARNING music volume is 0: with the title theme silent the chip")
|
||||
U.log("worker may never have started, which is the thread this fix joins.")
|
||||
end
|
||||
|
||||
-- park the cursor on EXIT GAME and stop there; pressing it is the human's
|
||||
-- job, since the press ends the process
|
||||
U.tap(game, "a")
|
||||
U.wait(20)
|
||||
local menu = game.stack:top()
|
||||
local wanted, labels = nil, {}
|
||||
for i, item in ipairs(menu and menu.items or {}) do
|
||||
labels[#labels + 1] = tostring(item.label)
|
||||
if tostring(item.label):find("EXIT", 1, true) then wanted = i end
|
||||
end
|
||||
check("the title menu is open", menu ~= nil and menu.items ~= nil)
|
||||
check("it has an EXIT GAME row", wanted ~= nil)
|
||||
U.log("menu rows:", table.concat(labels, ", "))
|
||||
|
||||
if wanted then
|
||||
for _ = 1, #menu.items do
|
||||
if menu.index == wanted then break end
|
||||
U.tap(game, "down")
|
||||
U.wait(6)
|
||||
end
|
||||
check("the cursor is parked on EXIT GAME", menu.index == wanted)
|
||||
end
|
||||
|
||||
U.log("The cursor sits on EXIT GAME; press A and the window should close.")
|
||||
U.log("A few seconds later `pgrep -fl love` must print nothing at all: before")
|
||||
U.log("this fix a headless love process stayed resident burning a core.")
|
||||
U.log("On Android, reopening from the launcher without swiping the app out of")
|
||||
U.log("Recents should cold boot normally instead of flashing black.")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -45,8 +45,9 @@ return function(game)
|
||||
ow = game.overworld
|
||||
end
|
||||
|
||||
-- 1) Arrive on the 2F stairs via a REAL warp so warpEntryCell/justWarped
|
||||
-- are set. Teleporting straight onto (7,1) bypasses takeWarp and would
|
||||
-- 1) Arrive on the 2F stairs via a REAL warp so the arrival state is real
|
||||
-- (warpEntryCell set, BIT_STANDING_ON_WARP cleared by the stair tile).
|
||||
-- Teleporting straight onto (7,1) bypasses takeWarp and would
|
||||
-- never set the arrival-inert state, so the bug could not reproduce --
|
||||
-- we must walk up onto the 1F stairs and let the warp carry us.
|
||||
U.teleport(game, "REDS_HOUSE_1F", 7, 3, "up")
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
-- Driver: in a dark cave FadePal2 writes rOBP0 as well as rBGP, so the player
|
||||
-- is a black silhouette, and ADVANCED bakes the shift into its atlas instead
|
||||
-- of veiling the world (#383). home/fade.asm:3-19,66 LoadGBPal indexes
|
||||
-- FadePal4 - wMapPalOffset; home/overworld.asm:500,535,790 sets the offset.
|
||||
-- POKEPORT_DRIVER=tests/drivers/rock_tunnel_dark_bake_bug383_test.lua \
|
||||
-- POKEPORT_IDENTITY=bug383 POKEPORT_TOUCH=0 SHOT_DIR=/tmp/shots383 love .
|
||||
-- No POKEPORT_SPEED: fast-forward desynchronizes audio.
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Probe = dofile("tests/drivers/shot_probe.lua")
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local Zoom = require("src.render.Zoom")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots383"
|
||||
|
||||
local fails = 0
|
||||
local function check(ok, msg)
|
||||
U.log(ok and "PASS" or "FAIL", msg)
|
||||
if not ok then fails = fails + 1 end
|
||||
return ok
|
||||
end
|
||||
local function rgb(c)
|
||||
return c and ("(%d,%d,%d)"):format(c[1], c[2], c[3]) or "nil"
|
||||
end
|
||||
local function ramp(p)
|
||||
if not p then return "nil" end
|
||||
local s = {}
|
||||
for i = 1, 4 do s[i] = rgb(p[i]) end
|
||||
return table.concat(s, " ")
|
||||
end
|
||||
local function sameCol(a, b)
|
||||
return a and b and a[1] == b[1] and a[2] == b[2] and a[3] == b[3]
|
||||
end
|
||||
local function samePal(a, b)
|
||||
if not a or not b then return false end
|
||||
for i = 1, 4 do
|
||||
if not sameCol(a[i], b[i]) then return false end
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
game.save.flags.EVENT_GOT_STARTER = true
|
||||
game.save.inventory.BOULDERBADGE = true
|
||||
local mon = Pokemon.new(game.data, "PIKACHU", 30)
|
||||
mon.moves = { { id = "FLASH", pp = 15 } }
|
||||
game.save.party = { mon }
|
||||
game.save.options = game.save.options or {}
|
||||
game.save.options.textSpeed = 1
|
||||
local vol = game.save.options.sfxVol
|
||||
if vol == 0 then
|
||||
U.log("sfxVol is 0: the FLASH blink and menu beeps are silent, turn SFX up",
|
||||
"in OPTION before judging anything by ear")
|
||||
else
|
||||
U.log("sfxVol =", tostring(vol))
|
||||
end
|
||||
|
||||
-- ---- the arithmetic, before anything is on screen ----------------------
|
||||
local darkDef = game.data.field.darkMaps
|
||||
local listed = false
|
||||
for _, m in ipairs(darkDef and darkDef.maps or {}) do
|
||||
if m == "ROCK_TUNNEL_1F" then listed = true end
|
||||
end
|
||||
check(listed, "ROCK_TUNNEL_1F is in field.darkMaps.maps")
|
||||
local BGP = PaletteFX.DARK_BGP
|
||||
check(BGP[0] == 2 and BGP[1] == 3 and BGP[2] == 3 and BGP[3] == 3,
|
||||
"DARK_BGP is FadePal2's `dc 3,3,3,2`")
|
||||
|
||||
local litObp, litGroup = PaletteFX.dmgObj()
|
||||
check(litObp == PaletteFX.OBP0_SHADES,
|
||||
"with nothing armed dmgObj still hands back the lit OBP0 ramp by identity")
|
||||
check(PaletteFX.setDarkWorld(true) == true,
|
||||
"setDarkWorld reports the change, so the caller knows to rebake")
|
||||
check(PaletteFX.setDarkWorld(true) == false,
|
||||
"and reports nothing on a repeat, so walking a dark floor never rebuilds")
|
||||
check(PaletteFX.darkKey() == "#dark",
|
||||
"the bake cache key carries the flag")
|
||||
local darkObp, darkGroup = PaletteFX.dmgObj()
|
||||
U.log("OBP0 lit:", ramp(litObp))
|
||||
U.log("OBP0 dark:", ramp(darkObp))
|
||||
check(samePal(darkObp, { litObp[3], litObp[4], litObp[4], litObp[4] }),
|
||||
"rOBP0 `dc 3,3,3,2` collapses every OBJ colour a sprite draws to shade 3")
|
||||
check(darkGroup ~= litGroup,
|
||||
"and its bake sits in its own cache group, not over the lit one")
|
||||
local ogDark = PaletteFX.ogObj()
|
||||
check(samePal(ogDark, { PaletteFX.GBC_OBJ[3], PaletteFX.GBC_OBJ[4],
|
||||
PaletteFX.GBC_OBJ[4], PaletteFX.GBC_OBJ[4] }),
|
||||
"OG RED's boot-ROM greens go with it (colours 1/2/3 all black)")
|
||||
PaletteFX.setDarkWorld(false)
|
||||
check(PaletteFX.dmgObj() == PaletteFX.OBP0_SHADES and PaletteFX.darkKey() == "",
|
||||
"clearing the flag hands the lit ramp and the lit cache key back")
|
||||
check(game.overworld == nil or game.overworld.darkNeedsOverlay == nil,
|
||||
"the screen-space veil is gone: no darkNeedsOverlay left to ask")
|
||||
|
||||
-- ---- reach the moment ---------------------------------------------------
|
||||
-- pokered data/maps/objects/RockTunnel1F.asm: the Route 10 entrance is
|
||||
-- warp_event 15, 3, so two cells south of it is floor that cannot
|
||||
-- re-trigger the warp, with the ladder and the sign both on screen.
|
||||
local MAP, STAND = "ROCK_TUNNEL_1F", { x = 15, y = 5, facing = "down" }
|
||||
|
||||
local function enter(flashLit)
|
||||
game.save.flashLit = flashLit or nil
|
||||
U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing)
|
||||
U.wait(12)
|
||||
local ow = game.overworld
|
||||
if ow and ow.map and not ow.map:isWalkableCell(STAND.x, STAND.y) then
|
||||
local found
|
||||
for r = 1, 8 do
|
||||
for dy = -r, r do
|
||||
for dx = -r, r do
|
||||
local cx, cy = STAND.x + dx, STAND.y + dy
|
||||
if not found and ow.map:isWalkableCell(cx, cy)
|
||||
and not ow.map:warpAtCell(cx, cy) then
|
||||
found = { x = cx, y = cy }
|
||||
end
|
||||
end
|
||||
end
|
||||
if found then break end
|
||||
end
|
||||
if found then
|
||||
U.log(("(%d,%d) is not floor any more -- standing on"):format(
|
||||
STAND.x, STAND.y), found.x, found.y)
|
||||
STAND.x, STAND.y = found.x, found.y
|
||||
game.save.flashLit = flashLit or nil
|
||||
U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing)
|
||||
U.wait(12)
|
||||
ow = game.overworld
|
||||
end
|
||||
end
|
||||
return ow
|
||||
end
|
||||
|
||||
game.save.options.colors = "gbc"
|
||||
PaletteFX.setMode("gbc")
|
||||
local ow = enter(nil)
|
||||
check(ow ~= nil and ow.map and ow.map.id == MAP, "player is inside " .. MAP)
|
||||
check(ow ~= nil and ow.dark == true and PaletteFX.darkWorld() == true,
|
||||
"the floor is dark with no FLASH used, and the bake flag is armed with it")
|
||||
|
||||
-- The cavern floor tiles carry no shade-0 pixels, so `dc 3,3,3,2` leaves them
|
||||
-- solid shade 3 -- "the floor is completely black" -- while wall, sign and
|
||||
-- ladder tiles keep shade-0 pixels that land on shade 2 and stay legible.
|
||||
-- That is the reporter's reference image, and no uniform veil can produce it.
|
||||
local FLOOR_TILES = { 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x2f, 0x34,
|
||||
0x3d, 0x3e, 0x3f }
|
||||
local ts = ow and ow.map and ow.map.tileset
|
||||
if ts and love.image and love.image.newImageData then
|
||||
local okImg, art = pcall(love.image.newImageData, ts.image)
|
||||
if okImg and art then
|
||||
local perRow = ts.tilesPerRow or 16
|
||||
local lightest, worst = 0, nil
|
||||
for _, t in ipairs(FLOOR_TILES) do
|
||||
local bx, by = (t % perRow) * 8, math.floor(t / perRow) * 8
|
||||
for y = by, by + 7 do
|
||||
for x = bx, bx + 7 do
|
||||
local r = math.floor(select(1, art:getPixel(x, y)) * 255 + 0.5)
|
||||
if r > lightest then lightest, worst = r, t end
|
||||
end
|
||||
end
|
||||
end
|
||||
U.log(("cavern floor tiles: lightest pixel is %d (tile 0x%02x)")
|
||||
:format(lightest, worst or 0))
|
||||
check(lightest < 255,
|
||||
"no cavern floor tile has a shade-0 pixel, so the shift blacks it out")
|
||||
else
|
||||
U.log("WARN could not read", tostring(ts.image), "-- floor art unchecked")
|
||||
end
|
||||
end
|
||||
|
||||
-- Camera:follow parks the sprite at (vw/2 - 16, vh/2 - 12) in world-canvas
|
||||
-- pixels and endFrame blits that canvas centred at Zoom.scale(fitScale), so
|
||||
-- the player's torso is a fixed box in the captured frame.
|
||||
local function playerRect(shot)
|
||||
local wc = game.renderer and game.renderer.worldCanvas
|
||||
if not (shot and wc) then return nil end
|
||||
local W, H = shot:getDimensions()
|
||||
local vw, vh = wc:getWidth(), wc:getHeight()
|
||||
local sp = Zoom.scale(game.renderer:fitScale())
|
||||
local ox = math.floor((W - vw * sp) / 2)
|
||||
local oy = math.floor((H - vh * sp) / 2)
|
||||
local x0, y0 = vw / 2 - 16, vh / 2 - 12
|
||||
return { (ox + (x0 + 4) * sp) / (W - 1), (oy + (y0 + 6) * sp) / (H - 1),
|
||||
(ox + (x0 + 12) * sp) / (W - 1), (oy + (y0 + 14) * sp) / (H - 1) }
|
||||
end
|
||||
|
||||
-- Count the colours the player would still be wearing if only rBGP had been
|
||||
-- ported, inside his own 16x16 -- his cell is floor, so none of them can
|
||||
-- come from the ground under him.
|
||||
local function spriteProbe(label, shot, telltale)
|
||||
local rect = playerRect(shot)
|
||||
if not (shot and rect) then
|
||||
U.log("WARN no pixel probe for", label, "-- judge the player by eye")
|
||||
return
|
||||
end
|
||||
local counts, total = Probe.count(shot, telltale, 1, rect)
|
||||
local parts, leaked = {}, 0
|
||||
for name, n in pairs(counts) do
|
||||
parts[#parts + 1] = ("%s=%d"):format(name, n)
|
||||
leaked = leaked + n
|
||||
end
|
||||
table.sort(parts)
|
||||
U.log(("probe[%s player %d px] %s"):format(label, total,
|
||||
table.concat(parts, " ")))
|
||||
U.log(" tones on him:", Probe.fmt(Probe.top(shot, 3, 1, rect)))
|
||||
check(leaked == 0, label .. ": the player is black, not lit or half-lit")
|
||||
end
|
||||
|
||||
local CAVE = PaletteFX.pal(game.data, "CAVE")
|
||||
U.log("CAVE palette:", ramp(CAVE))
|
||||
|
||||
-- ---- ADVANCED / RED++, the mode in the report --------------------------
|
||||
local pack = PaletteFX.gbcPack()
|
||||
if not pack then
|
||||
U.log("WARN data/palettes_gbc is absent; the ADVANCED half cannot be shown")
|
||||
else
|
||||
game.save.options.colors = "redpp"
|
||||
PaletteFX.setMode("redpp")
|
||||
ow = enter(nil)
|
||||
U.wait(30)
|
||||
U.shot(game, DIR .. "/bug383_1_dark_redpp.png")
|
||||
local tsId = ow.map.tileset.id
|
||||
local lit = pack.world.groupColors[tsId]
|
||||
local shifted = PaletteFX.worldGroupColors(game.data, tsId, ow.map.id, nil)
|
||||
local allShifted = lit ~= nil and shifted ~= nil
|
||||
for i = 1, 8 do
|
||||
if not (lit and shifted and samePal(shifted[i],
|
||||
{ lit[i][3], lit[i][4], lit[i][4], lit[i][4] })) then
|
||||
allShifted = false
|
||||
end
|
||||
end
|
||||
check(allShifted,
|
||||
"every ADVANCED tile group bakes FadePal2-shifted, not veiled")
|
||||
local key = ow.map.renderer and ow.map.renderer.gbcAtlasKey
|
||||
U.log("atlas key:", tostring(key))
|
||||
check(type(key) == "string" and key:find("#dark", 1, true) ~= nil,
|
||||
"and the atlas it baked is keyed apart from the lit one")
|
||||
|
||||
local shot = Probe.grab()
|
||||
if shot then
|
||||
local top, total = Probe.top(shot, 4)
|
||||
U.log(("probe[ADVANCED dark] %d px: %s"):format(total, Probe.fmt(top)))
|
||||
local floorIsShifted = false
|
||||
for i = 1, 8 do
|
||||
for j = 1, 4 do
|
||||
if shifted and top[1] and sameCol(top[1], shifted[i][j]) then
|
||||
floorIsShifted = true
|
||||
end
|
||||
end
|
||||
end
|
||||
check(floorIsShifted and top[1].share > 0.3,
|
||||
"the screen is mostly one exact shifted colour -- flat black floor, "
|
||||
.. "no veil arithmetic and no speckle")
|
||||
local litSeen = 0
|
||||
for i = 1, 8 do
|
||||
local c = Probe.count(shot, { a = lit[i][1], b = lit[i][2] }, 3)
|
||||
litSeen = litSeen + c.a + c.b
|
||||
end
|
||||
check(litSeen == 0, "and none of the lit atlas's own two top colours "
|
||||
.. "survives anywhere on it")
|
||||
PaletteFX.setDarkWorld(false)
|
||||
local litSprite = PaletteFX.spriteObp(ow.player.sprite.def,
|
||||
ow.player.sprite.seed)
|
||||
PaletteFX.setDarkWorld(true)
|
||||
U.log("player OBP lit:", ramp(litSprite))
|
||||
spriteProbe("ADVANCED", shot,
|
||||
{ obj1 = litSprite[2], obj2 = litSprite[3] })
|
||||
end
|
||||
end
|
||||
|
||||
-- ---- OG RED: the reporter's reference capture --------------------------
|
||||
game.save.options.colors = "ogred"
|
||||
PaletteFX.setMode("ogred")
|
||||
ow = enter(nil)
|
||||
U.wait(30)
|
||||
U.shot(game, DIR .. "/bug383_2_dark_ogred.png")
|
||||
local shot = Probe.grab()
|
||||
if shot then
|
||||
local top, total = Probe.top(shot, 5)
|
||||
U.log(("probe[OG RED dark] %d px: %s"):format(total, Probe.fmt(top)))
|
||||
spriteProbe("OG RED", shot,
|
||||
{ green = PaletteFX.GBC_OBJ[2], darkGreen = PaletteFX.GBC_OBJ[3] })
|
||||
end
|
||||
|
||||
-- ---- the shade-remapped modes, each in its own ramp --------------------
|
||||
-- Their sprites bake OBP0 and are coloured by the zone shader, so the tone
|
||||
-- to look for is the one DARK_BGP sends DMG white to: palette entry 3, which
|
||||
-- the floor under him cannot supply.
|
||||
for _, m in ipairs({ { "gbc", "SGB", CAVE }, { "og", "plain DMG",
|
||||
{ { 255, 255, 255 }, { 170, 170, 170 }, { 85, 85, 85 },
|
||||
{ 0, 0, 0 } } },
|
||||
{ "classic", "CLASSIC", PaletteFX.CLASSIC } }) do
|
||||
game.save.options.colors = m[1]
|
||||
PaletteFX.setMode(m[1])
|
||||
ow = enter(nil)
|
||||
U.wait(30)
|
||||
U.shot(game, DIR .. "/bug383_3_dark_" .. m[1] .. ".png")
|
||||
local s = Probe.grab()
|
||||
if s then
|
||||
U.log(("probe[%s dark] %s"):format(m[2], Probe.fmt(Probe.top(s, 4))))
|
||||
spriteProbe(m[2], s, { shade2 = m[3][3] })
|
||||
end
|
||||
end
|
||||
|
||||
-- ---- FLASH lights it, sprites and atlas together -----------------------
|
||||
game.save.options.colors = "gbc"
|
||||
PaletteFX.setMode("gbc")
|
||||
ow = enter(true)
|
||||
check(ow.dark == false and PaletteFX.darkWorld() == false,
|
||||
"after FLASH neither half of the darkness is armed")
|
||||
U.wait(30)
|
||||
U.shot(game, DIR .. "/bug383_4_flash_lit_sgb.png")
|
||||
local litShot = Probe.grab()
|
||||
if litShot then
|
||||
local rect = playerRect(litShot)
|
||||
if rect then
|
||||
-- the same box that had to be featureless in the dark: CAVE[1] is the
|
||||
-- sprite's own shade-0 white and the floor around him has none, so this
|
||||
-- is also the proof the box really covers the player
|
||||
local c = Probe.count(litShot, { paper = CAVE[1] }, 1, rect)
|
||||
U.log("lit player box tones:", Probe.fmt(Probe.top(litShot, 3, 1, rect)))
|
||||
check(c.paper > 0,
|
||||
"lit again, the player fills that same box with CAVE's white")
|
||||
end
|
||||
end
|
||||
|
||||
if pack then
|
||||
game.save.options.colors = "redpp"
|
||||
PaletteFX.setMode("redpp")
|
||||
ow = enter(true)
|
||||
U.wait(30)
|
||||
U.shot(game, DIR .. "/bug383_5_flash_lit_redpp.png")
|
||||
local key = ow.map.renderer and ow.map.renderer.gbcAtlasKey
|
||||
U.log("atlas key after FLASH:", tostring(key))
|
||||
check(type(key) == "string" and key:find("#dark", 1, true) == nil,
|
||||
"ADVANCED rebaked its atlas lit rather than keeping the dark one")
|
||||
end
|
||||
|
||||
U.log(fails == 0 and "all #383 machine checks passed"
|
||||
or (fails .. " #383 machine check(s) FAILED -- read up"))
|
||||
U.log("shots in", DIR)
|
||||
|
||||
-- ---- hand the pad over, dark, in the reporter's mode -------------------
|
||||
if pack then
|
||||
game.save.options.colors = "redpp"
|
||||
PaletteFX.setMode("redpp")
|
||||
end
|
||||
enter(nil)
|
||||
|
||||
U.log("You are in ROCK TUNNEL 1F in ADVANCED colours, no FLASH used yet.")
|
||||
U.log("The floor should be flat black with no speckle, the rock outlines,")
|
||||
U.log("ladder and sign dim but legible, and the player gone: black on black.")
|
||||
U.log("START, POKeMON, A, FLASH lights it in one frame with no reload hitch,")
|
||||
U.log("him back in colour; the ladder to 2F is lit too, Route 10 and back is dark.")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -145,8 +145,8 @@ return function(game)
|
||||
"the map reports itself dark with no FLASH used")
|
||||
check(ow ~= nil and ow:paletteNameFor(ow.map) == "CAVE",
|
||||
"and resolves the CAVE palette (tileset CAVERN)")
|
||||
check(ow ~= nil and ow:darkNeedsOverlay() == false,
|
||||
"SGB needs NO composited veil -- its palette carries the darkening")
|
||||
check(ow ~= nil and ow.darkNeedsOverlay == nil,
|
||||
"the composited veil path is gone: SGB darkens via its palette")
|
||||
|
||||
local function probe(label, wanted, rect)
|
||||
local shot = Probe.grab()
|
||||
@@ -222,16 +222,13 @@ return function(game)
|
||||
local corner = Probe.count(shot, { red = { 148, 58, 58 } }, 3, FAR_CORNER)
|
||||
check(corner.red > 0, "and the far corner still carries that red, not a void")
|
||||
|
||||
-- Not a FAIL: OG RED replays its OBP-baked sprites on top of the finished
|
||||
-- zone pass (PaletteFX.markSpriteRedraw) and that replay is unshaded, so
|
||||
-- the player keeps his green where FadePal2's OBP0 would darken him too.
|
||||
local obj = PaletteFX.ogObj()
|
||||
local sprite, spriteTotal = Probe.count(shot,
|
||||
{ green = obj[2], darkGreen = obj[3] })
|
||||
local share = (sprite.green + sprite.darkGreen) * 100
|
||||
/ math.max(1, spriteTotal)
|
||||
U.log(("known gap: OG RED's player keeps his green in the dark "
|
||||
.. "(%.2f%% of the frame); hardware darkens OBP0 too"):format(share))
|
||||
-- FadePal2 writes rOBP0 as well as rBGP (`dc 3,3,3,2`), so every OBJ
|
||||
-- colour lands on shade 3 and the player is a black silhouette: none of
|
||||
-- the boot-ROM green may survive (#383).
|
||||
local sprite = Probe.count(shot,
|
||||
{ green = PaletteFX.GBC_OBJ[2], darkGreen = PaletteFX.GBC_OBJ[3] })
|
||||
check(sprite.green == 0 and sprite.darkGreen == 0,
|
||||
"OG RED's player is black in the dark, not green")
|
||||
end
|
||||
|
||||
-- ---- the modes that darken in their own ramps --------------------------
|
||||
@@ -259,13 +256,21 @@ return function(game)
|
||||
m[2] .. ": the far corner still has two tones (no light window)")
|
||||
end
|
||||
if m[1] == "redpp" then
|
||||
-- RED++ has no palette left to shift: TileRenderer bakes true colour
|
||||
-- into the atlas, so the darkness has to be composited by hand.
|
||||
-- RED++ has no palette left for the frame shader to shift, so the shift
|
||||
-- goes into the palette its atlas bakes from instead of a veil (#383).
|
||||
local o = game.overworld
|
||||
U.log("RED++ gbcAtlas present:",
|
||||
tostring(o and o.map and o.map.renderer and o.map.renderer.gbcAtlas ~= nil))
|
||||
check(o ~= nil and o:darkNeedsOverlay() == true,
|
||||
"RED++ is the ONE mode that still composites a flat veil")
|
||||
check(PaletteFX.darkWorld() == true,
|
||||
"RED++ arms the dark-world flag, so its atlas bakes dark")
|
||||
local pack = PaletteFX.gbcPack()
|
||||
local tsId = o and o.map and o.map.tileset and o.map.tileset.id
|
||||
local lit = pack and tsId and pack.world.groupColors[tsId]
|
||||
local shifted = tsId and PaletteFX.worldGroupColors(game.data, tsId,
|
||||
o.map.id, nil)
|
||||
check(lit ~= nil and shifted ~= nil and samePal(shifted[1],
|
||||
{ lit[1][3], lit[1][4], lit[1][4], lit[1][4] }),
|
||||
"and every baked group is FadePal2-shifted, not veiled")
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
+33
-23
@@ -6325,18 +6325,35 @@ function MANUAL.evolveNidorino() return true end
|
||||
-- SILPH_CO_ELEVATOR at 140) gate real geography: the mart's upper floors
|
||||
-- and most of Silph are only reachable through them.
|
||||
--
|
||||
-- The floor menu opens from the map's own onEnter (data/scripts/story3.lua
|
||||
-- `elevator`), so it is already up by the time we get here -- there is
|
||||
-- nothing to interact with. Rows are the short floor tokens pokered prints
|
||||
-- ("5F", "B2F"), which is exactly the tail of the destination map id, so
|
||||
-- the next segment names the button to press.
|
||||
-- The floor menu belongs to the car's panel bg_event, not to map entry
|
||||
-- (#395): walk to the panel and press A. Rows are the short floor tokens
|
||||
-- pokered prints ("5F", "B2F"), which is exactly the tail of the
|
||||
-- destination map id, so the next segment names the button to press.
|
||||
-- Ride the elevator to `wantMap` (defaults to the next segment's floor).
|
||||
--
|
||||
-- The floor menu opens from the elevator's own onEnter, so it is already up
|
||||
-- when we arrive. If it is NOT up -- we bounced in on a stray warp and the
|
||||
-- menu was dismissed, or we are on the default 1F-exit oscillation -- step
|
||||
-- back onto the car's warp to re-open it before giving up.
|
||||
local function rideElevator(where, wantMap)
|
||||
-- Stand beside the car's panel bg_event and press A to open the floor
|
||||
-- menu (data/maps/objects/CeladonMartElevator.asm `bg_event 3, 0`).
|
||||
local function pressPanel()
|
||||
local m = ow().map
|
||||
local sign = (m.def.signs or {})[1]
|
||||
if not sign then return false end
|
||||
-- offset from the panel cell, then the direction that faces it back
|
||||
local SIDES = { { 0, 1, "up" }, { 0, -1, "down" },
|
||||
{ 1, 0, "left" }, { -1, 0, "right" } }
|
||||
for _, s in ipairs(SIDES) do
|
||||
local sx, sy = sign.x + s[1], sign.y + s[2]
|
||||
if m:inBounds(sx, sy) and m:isWalkableCell(sx, sy) then
|
||||
ops.goto_({ x = sx, y = sy })
|
||||
local p = ow().player
|
||||
if ow().map.id == m.id and p.cellX == sx and p.cellY == sy then
|
||||
faceDir(s[3])
|
||||
press("a")
|
||||
if waitFor(isList, 60) then return true end
|
||||
end
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
local want = tostring(wantMap or nextMapWanted or "")
|
||||
local token = want:match("_([^_]+)$")
|
||||
if not token then
|
||||
@@ -6345,14 +6362,7 @@ local function rideElevator(where, wantMap)
|
||||
return false
|
||||
end
|
||||
local from = ow().map.id
|
||||
if not waitFor(isList, 20) then
|
||||
-- Re-open the floor menu: the car's exit warp re-enters the elevator,
|
||||
-- firing onEnter again. This is what breaks the un-ridden bounce.
|
||||
local car = findWarpTo("ELEVATOR") or findWarpTo("")
|
||||
if car then walkOntoWarp(car.x, car.y) end
|
||||
from = ow().map.id
|
||||
end
|
||||
if not waitFor(isList, 60) then
|
||||
if not isList() and not pressPanel() then
|
||||
note("elevator: no floor menu", where)
|
||||
say(("elevator on %s: the WHICH FLOOR? menu never opened"):format(from))
|
||||
return false
|
||||
@@ -6388,11 +6398,11 @@ local function rideElevator(where, wantMap)
|
||||
if not cursorTo("index", idx) then backOut() return false end
|
||||
press("a")
|
||||
-- ShakeElevator runs the whole ride in place -- music stop, 100 scroll
|
||||
-- bounces, the PA chime -- and only then walks us out onto the floor.
|
||||
for _ = 1, 600 do
|
||||
if ow().map.id ~= from then break end
|
||||
if idle() then U.wait(4) else mashUntilIdle() end
|
||||
end
|
||||
-- bounces, the PA chime -- and then hands control back inside the car:
|
||||
-- the rewritten exit warp is what we walk out onto (#395).
|
||||
mashUntilIdle()
|
||||
local car = findWarpTo(want) or ow().map.def.warps[1]
|
||||
if car then walkOntoWarp(car.x, car.y) end
|
||||
local landed = ow().map.id
|
||||
if landed == want then
|
||||
say(("rode the elevator to %s"):format(token))
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
-- Ear check: Route 8's wave-channel countermelody, at full volume and in the
|
||||
-- right octave (#429). ROUTE_8 is MUSIC_ROUTES3 (pokered data/maps/songs.asm:22)
|
||||
-- and Music_Routes3_Ch3 is a dense 2-frame-per-note line; ChipAudio shipped
|
||||
-- ch3 at 0.25 volume and 0.5 pitch, a quarter as loud and an octave below the
|
||||
-- hardware's own octave (audio/engine_1.asm:904-944 writes CHAN3's frequency
|
||||
-- register unmodified). SELECT toggles the 0.1.38 mix back on to compare.
|
||||
-- POKEPORT_DRIVER=tests/drivers/route8_music_bug429_test.lua POKEPORT_IDENTITY=bug429 POKEPORT_TOUCH=0 love .
|
||||
-- No POKEPORT_SPEED: fast-forward scales the logic clock only and desyncs audio.
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local ChipAudio = require("src.core.ChipAudio")
|
||||
local Music = require("src.core.Music")
|
||||
|
||||
-- pokered data/maps/objects/Route8.asm keeps its trainers at (8,5), (13,9),
|
||||
-- (26,3-6) and (42,6), and warps at x 1, 8 and 13, so the road east of the
|
||||
-- gate is clear; (30, 8) is only a listening spot, walk anywhere from it.
|
||||
local MAP = "ROUTE_8"
|
||||
local SONG = "Music_Routes3"
|
||||
local STAND = { x = 30, y = 8, facing = "down" }
|
||||
|
||||
local function check(label, ok)
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
-- ---- what the ear cannot check -----------------------------------------
|
||||
local opts = game.save.options or {}
|
||||
local musicVol = opts.musicVol or 7
|
||||
local sfxVol = opts.sfxVol or 7
|
||||
if musicVol == 0 then
|
||||
U.log("FAIL music volume is 0: nothing below can be heard at all.")
|
||||
U.log(" Set MUSIC to 7 in OPTION first.")
|
||||
end
|
||||
if sfxVol == 0 then
|
||||
U.log("FAIL sfx volume is 0, so the step and menu sounds are gone too;")
|
||||
U.log(" set SFX to 7 in OPTION if the run should sound normal.")
|
||||
end
|
||||
check(("music volume %d, sfx volume %d"):format(musicVol, sfxVol),
|
||||
musicVol > 0)
|
||||
|
||||
check(MAP .. " plays " .. SONG,
|
||||
(game.data.audio.mapSongs or {})[MAP] == SONG)
|
||||
local vols = ChipAudio.getChannelVolumes()
|
||||
local pitches = ChipAudio.getChannelPitches()
|
||||
check(("the shipped wave mix is unity (volume %s, pitch %s)")
|
||||
:format(tostring(vols[3]), tostring(pitches[3])),
|
||||
vols[3] == 1 and pitches[3] == 1)
|
||||
|
||||
-- render the ch3 layer alone: a silent or near-silent wave track would make
|
||||
-- every claim below unhearable no matter what the mix says
|
||||
local song = (game.data.audio.songs or {})[SONG]
|
||||
local function waveLayer()
|
||||
return ChipAudio._renderMusicChannelForTest(game.data, song, 1.5, 3)
|
||||
end
|
||||
local function measure(sd)
|
||||
local peak, crossings, prev = 0, 0, sd:getSample(0)
|
||||
for index = 1, sd:getSampleCount() - 1 do
|
||||
local sample = sd:getSample(index)
|
||||
if math.abs(sample) > peak then peak = math.abs(sample) end
|
||||
if prev * sample < 0 then crossings = crossings + 1 end
|
||||
prev = sample
|
||||
end
|
||||
return peak, crossings
|
||||
end
|
||||
local peak, crossings = measure(waveLayer())
|
||||
check(("the ch3 countermelody renders (peak %.3f, %d zero crossings)")
|
||||
:format(peak, crossings), peak > 0.02 and crossings > 200)
|
||||
|
||||
ChipAudio.setChannelVolumes({ 1, 1, 0.25, 1 })
|
||||
ChipAudio.setChannelPitches({ 1, 1, 0.5, 1 })
|
||||
local brokenPeak, brokenCrossings = measure(waveLayer())
|
||||
ChipAudio.setChannelVolumes({ 1, 1, 1, 1 })
|
||||
ChipAudio.setChannelPitches({ 1, 1, 1, 1 })
|
||||
check(("the 0.1.38 mix would render it at peak %.3f and %d crossings")
|
||||
:format(brokenPeak, brokenCrossings),
|
||||
brokenPeak < peak * 0.4 and brokenCrossings < crossings * 0.7)
|
||||
-- the invariant tests/engine/wave_channel_mix_bug429.lua asserts on blobs;
|
||||
-- this is the same statement measured on the ROM's own Music_Routes3
|
||||
|
||||
-- ---- stand on the road with the theme running --------------------------
|
||||
U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing)
|
||||
U.wait(20)
|
||||
local ow = game.overworld
|
||||
if not ow.map:isWalkableCell(STAND.x, STAND.y) then
|
||||
-- a map edit or a mod blocked the cell: take any free neighbour, the
|
||||
-- audio is what is being judged and any cell on Route 8 will do
|
||||
for _, d in ipairs({ { 0, 1 }, { 0, -1 }, { 1, 0 }, { -1, 0 } }) do
|
||||
local cx, cy = STAND.x + d[1], STAND.y + d[2]
|
||||
if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then
|
||||
U.log(("(%d, %d) is blocked, standing on"):format(STAND.x, STAND.y),
|
||||
cx, cy)
|
||||
U.teleport(game, MAP, cx, cy, STAND.facing)
|
||||
U.wait(20)
|
||||
ow = game.overworld
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
check("standing on " .. MAP, ow.map.id == MAP)
|
||||
|
||||
local function restart()
|
||||
-- the playback queue holds ~6s of already-synthesized audio, so a mix
|
||||
-- change is inaudible until the song is rebuilt from the new values
|
||||
Music.stop()
|
||||
Music.playMap(game.data, MAP)
|
||||
end
|
||||
restart()
|
||||
U.wait(60)
|
||||
|
||||
U.log("Route 8's theme is running and the pad is yours; walking works.")
|
||||
U.log("Under the lead there is a fast wave line, about two notes per beat,")
|
||||
U.log("as loud as the melody and no lower than the octave it is written in.")
|
||||
U.log("SELECT swaps in the 0.1.38 mix (quarter volume, a further octave")
|
||||
U.log("down: a dull rumble that all but vanishes), SELECT again restores it.")
|
||||
|
||||
local broken = false
|
||||
local held = false
|
||||
while true do
|
||||
local down = game.input:isDown("select")
|
||||
if down and not held then
|
||||
broken = not broken
|
||||
if broken then
|
||||
ChipAudio.setChannelVolumes({ 1, 1, 0.25, 1 })
|
||||
ChipAudio.setChannelPitches({ 1, 1, 0.5, 1 })
|
||||
U.log("0.1.38 mix: wave volume 0.25, one octave down")
|
||||
else
|
||||
ChipAudio.setChannelVolumes({ 1, 1, 1, 1 })
|
||||
ChipAudio.setChannelPitches({ 1, 1, 1, 1 })
|
||||
U.log("authentic mix: wave volume 1, pitch 1")
|
||||
end
|
||||
restart()
|
||||
end
|
||||
held = down
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,232 @@
|
||||
-- Manual check that Team Rocket leaves Silph Co and the president keeps talking
|
||||
-- (#392). pokered scripts/SilphCo11F.asm: the Giovanni win runs
|
||||
-- SilphCo11FTeamRocketLeavesScript (HideObject over TOGGLE_SILPH_CO_2F_2..11F_3),
|
||||
-- and SilphCo11FSilphPresidentText prints .MasterBallDescriptionText on every
|
||||
-- later talk. No POKEPORT_SPEED: fast-forward desynchronizes the jingle.
|
||||
-- SHOT_DIR=/tmp/shots POKEPORT_DRIVER=tests/drivers/silph_rockets_bug392_test.lua POKEPORT_IDENTITY=bug392 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local ScriptRunner = require("src.script.ScriptRunner")
|
||||
local mapScripts = require("data.scripts.init")
|
||||
|
||||
-- pokered data/maps/objects/SilphCo11F.asm: president (7,5) STAY DOWN, so
|
||||
-- he is read from (6,5) facing right; GIOVANNI (6,9), ROCKET1 (3,16),
|
||||
-- ROCKET2 (15,9). SilphCo11FDefaultScript.PlayerCoordsArray is (6,13) and
|
||||
-- (7,12), each stepped onto from the cell below it (teleport ignores
|
||||
-- collision, so the stand cell only has to be south of the trigger).
|
||||
local PRESIDENT = { x = 7, y = 5 }
|
||||
local READ = { x = 6, y = 5, facing = "right" }
|
||||
local TRIGGERS = { { stand = { 7, 13 }, cell = { 7, 12 } },
|
||||
{ stand = { 6, 14 }, cell = { 6, 13 } } }
|
||||
local ELEVENTH = { "SILPHCO11F_GIOVANNI", "SILPHCO11F_ROCKET1",
|
||||
"SILPHCO11F_ROCKET2" }
|
||||
-- one floor per shape: 3F is rocket + scientist + an item ball, 5F adds the
|
||||
-- rocket-aligned ROCKER, 7F keeps the rival out of the hide list
|
||||
local FLOORS = {
|
||||
{ "SILPH_CO_3F", { 22, 7 },
|
||||
gone = { "SILPHCO3F_ROCKET", "SILPHCO3F_SCIENTIST" },
|
||||
stays = { "SILPHCO3F_HYPER_POTION", "SILPHCO3F_SILPH_WORKER_M" } },
|
||||
{ "SILPH_CO_5F", { 13, 10 },
|
||||
gone = { "SILPHCO5F_ROCKET1", "SILPHCO5F_SCIENTIST",
|
||||
"SILPHCO5F_ROCKER", "SILPHCO5F_ROCKET2" },
|
||||
stays = { "SILPHCO5F_CARD_KEY", "SILPHCO5F_SILPH_WORKER_M" } },
|
||||
{ "SILPH_CO_7F", { 10, 9 },
|
||||
gone = { "SILPHCO7F_ROCKET1", "SILPHCO7F_SCIENTIST",
|
||||
"SILPHCO7F_ROCKET2", "SILPHCO7F_ROCKET3" },
|
||||
stays = { "SILPHCO7F_TM_SWORDS_DANCE", "SILPHCO7F_SILPH_WORKER_M3" } },
|
||||
}
|
||||
|
||||
local function check(label, ok)
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
local function spawned(name)
|
||||
for _, n in ipairs(game.overworld and game.overworld.npcs or {}) do
|
||||
if n.def and n.def.name == name then return n end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local rows = mapScripts.get("SILPH_CO_11F").talk
|
||||
.TEXT_SILPHCO11F_SILPH_PRESIDENT
|
||||
check("the president has a hand-ported talk script", type(rows) == "table")
|
||||
if type(rows) == "table" then
|
||||
local problems = ScriptRunner.validate(rows)
|
||||
check("his script validates: " .. (problems[1] or "no problems"),
|
||||
#problems == 0)
|
||||
end
|
||||
for _, key in ipairs({ "_SilphCo11FSilphPresidentText",
|
||||
"_SilphCo11FSilphPresidentReceivedMasterBallText",
|
||||
"_SilphCo11FSilphPresidentMasterBallDescriptionText" }) do
|
||||
local body = game.data.text[key]
|
||||
check(key .. " is extracted", type(body) == "string" and body ~= "")
|
||||
end
|
||||
|
||||
local vol = game.save.options and game.save.options.sfxVol
|
||||
if (vol or 0) == 0 then
|
||||
U.log("sfxVol is 0: the key-item jingle will be silent, raise it in OPTION")
|
||||
else
|
||||
U.log("sfxVol", tostring(vol), "-- the ball arrives on the key-item jingle")
|
||||
end
|
||||
|
||||
game.save.party = {
|
||||
Pokemon.new(game.data, "CHARIZARD", 70),
|
||||
Pokemon.new(game.data, "SNORLAX", 70),
|
||||
Pokemon.new(game.data, "LAPRAS", 70),
|
||||
}
|
||||
game.save.player.name = "bryan"
|
||||
game.save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI = nil
|
||||
game.save.flags.EVENT_GOT_MASTER_BALL = nil
|
||||
game.save.objectToggles = {}
|
||||
|
||||
U.teleport(game, "SILPH_CO_11F", READ.x, READ.y, READ.facing)
|
||||
U.wait(10)
|
||||
local ow = game.overworld
|
||||
for _, name in ipairs(ELEVENTH) do
|
||||
check(name .. " is on the floor before the fight", spawned(name) ~= nil)
|
||||
end
|
||||
U.shot(game, DIR .. "/silph392_0_before.png")
|
||||
|
||||
local function facingThePresident()
|
||||
local o = game.overworld
|
||||
local fx, fy = o.player:facingCell()
|
||||
return o:npcAtCell(fx, fy) == spawned("SILPHCO11F_SILPH_PRESIDENT")
|
||||
end
|
||||
|
||||
if not facingThePresident() then
|
||||
-- a map edit moved him: stand on any free walkable neighbour.
|
||||
-- {dx, dy, facing} is the offset from the president plus the way back.
|
||||
local sides = {
|
||||
{ 0, 1, "up" }, { 0, -1, "down" }, { 1, 0, "left" }, { -1, 0, "right" },
|
||||
}
|
||||
for _, s in ipairs(sides) do
|
||||
local cx, cy = PRESIDENT.x + s[1], PRESIDENT.y + s[2]
|
||||
if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then
|
||||
U.log("president moved; standing on", cx, cy, "facing", s[3])
|
||||
U.teleport(game, "SILPH_CO_11F", cx, cy, s[3])
|
||||
U.wait(10)
|
||||
ow = game.overworld
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
check("the player is facing the president", facingThePresident())
|
||||
|
||||
local function boxText(top)
|
||||
local lines = {}
|
||||
for _, page in ipairs(top.pages or {}) do
|
||||
for _, line in ipairs(page) do lines[#lines + 1] = line end
|
||||
end
|
||||
return table.concat(lines, " ")
|
||||
end
|
||||
|
||||
-- one whole talk: A to open, then A through every box until the world is back
|
||||
local function talk()
|
||||
local seen = {}
|
||||
U.tap(game, "a")
|
||||
U.wait(20)
|
||||
for _ = 1, 120 do
|
||||
local top = game.stack:top()
|
||||
if getmetatable(top) == TextBox then
|
||||
local txt = boxText(top)
|
||||
if txt ~= "" and seen[#seen] ~= txt then seen[#seen + 1] = txt end
|
||||
elseif top == game.overworld then
|
||||
break
|
||||
end
|
||||
U.tap(game, "a")
|
||||
U.wait(10)
|
||||
end
|
||||
return table.concat(seen, " / ")
|
||||
end
|
||||
|
||||
local first = talk()
|
||||
U.log("first talk reads:", first)
|
||||
check("the first talk prints his thank-you", first ~= "")
|
||||
check("and hands over the MASTER BALL",
|
||||
(game.save.inventory.MASTER_BALL or 0) > 0)
|
||||
check("EVENT_GOT_MASTER_BALL is set", game.save.flags.EVENT_GOT_MASTER_BALL == true)
|
||||
|
||||
U.wait(20)
|
||||
local second = talk()
|
||||
U.log("second talk reads:", second)
|
||||
check("the second talk is not silence", second ~= "")
|
||||
check("it is the MASTER BALL description",
|
||||
second:find("prototype", 1, true) ~= nil)
|
||||
check("and no second ball is handed out",
|
||||
(game.save.inventory.MASTER_BALL or 0) == 1)
|
||||
U.shot(game, DIR .. "/silph392_1_president.png")
|
||||
|
||||
-- the hide pass, floor by floor: this is what a save that beat Giovanni sees
|
||||
-- on its next visit, and what the win itself does in one go
|
||||
game.save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI = true
|
||||
for i, floor in ipairs(FLOORS) do
|
||||
local mapId, at = floor[1], floor[2]
|
||||
U.teleport(game, mapId, at[1], at[2], "down")
|
||||
U.wait(10)
|
||||
for _, name in ipairs(floor.gone) do
|
||||
check(name .. " left " .. mapId, spawned(name) == nil)
|
||||
end
|
||||
for _, name in ipairs(floor.stays) do
|
||||
check(name .. " is still there", spawned(name) ~= nil)
|
||||
end
|
||||
local toggles = game.save.objectToggles[mapId] or {}
|
||||
check(mapId .. " toggles were written to the save",
|
||||
toggles[floor.gone[1]] == false)
|
||||
U.shot(game, ("%s/silph392_2_%s.png"):format(DIR, mapId:lower()))
|
||||
if i == #FLOORS then
|
||||
check("the 7F rival keeps his toggle",
|
||||
toggles.SILPHCO7F_RIVAL ~= false)
|
||||
end
|
||||
end
|
||||
|
||||
-- put the fight back on the table: clear the event, the toggles and the
|
||||
-- battle records so the coordinate trigger re-arms
|
||||
game.save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI = nil
|
||||
game.save.objectToggles = {}
|
||||
game.save.defeatedTrainers = {}
|
||||
game.save.flags.EVENT_BEAT_SILPH_CO_11F_TRAINER_0 = nil
|
||||
game.save.flags.EVENT_BEAT_SILPH_CO_11F_TRAINER_1 = nil
|
||||
|
||||
local fired, cell
|
||||
for i, t in ipairs(TRIGGERS) do
|
||||
U.teleport(game, "SILPH_CO_11F", t.stand[1], t.stand[2], "up")
|
||||
U.wait(10)
|
||||
ow = game.overworld
|
||||
if i == 1 then
|
||||
for _, name in ipairs(ELEVENTH) do
|
||||
check(name .. " is back on the floor", spawned(name) ~= nil)
|
||||
end
|
||||
U.shot(game, DIR .. "/silph392_3_rearmed.png")
|
||||
end
|
||||
U.hold(game, "up", 24)
|
||||
for _ = 1, 300 do
|
||||
U.wait(1)
|
||||
if getmetatable(game.stack:top()) == BattleState then break end
|
||||
end
|
||||
if getmetatable(game.stack:top()) == BattleState
|
||||
or (ow.player.cellX == t.cell[1] and ow.player.cellY == t.cell[2]) then
|
||||
fired, cell = true, t.cell
|
||||
break
|
||||
end
|
||||
U.log(("the step up from (%d,%d) missed the trigger; trying the other pad")
|
||||
:format(t.stand[1], t.stand[2]))
|
||||
end
|
||||
cell = cell or TRIGGERS[1].cell
|
||||
check(("stepping onto (%d,%d) started GIOVANNI"):format(cell[1], cell[2]),
|
||||
fired == true)
|
||||
U.shot(game, DIR .. "/silph392_4_giovanni.png")
|
||||
|
||||
U.log("Win the fight: after the fade GIOVANNI and both ROCKETs, (3,16) and")
|
||||
U.log("(15,9), should be gone, and every grunt and lab-coat trainer on")
|
||||
U.log("3F/5F/7F with them. The president at (7,5) has the ball already, so")
|
||||
U.log("A on him should describe it again, never an empty beat.")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,167 @@
|
||||
-- Manual check that the Silph Co 2F worker begs before handing over TM36 (#393).
|
||||
-- pokered scripts/SilphCo2F.asm prints .PleaseTakeThisText (text/SilphCo2F.asm:1,
|
||||
-- "Eeek!" / "No! Stop! Help!" ... "please take this!") before GiveItem; the port
|
||||
-- had no such string in the cache, so the talk opened on "got TM36!". Re-import
|
||||
-- first (CACHE_FORMAT bump). No POKEPORT_SPEED here: fast-forward
|
||||
-- desynchronizes the item jingle from the box it belongs to.
|
||||
-- SHOT_DIR=/tmp/shots POKEPORT_DRIVER=tests/drivers/silph_tm36_bug393_test.lua POKEPORT_IDENTITY=bug393 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local Bag = require("src.inventory.Bag")
|
||||
local mapScripts = require("data.scripts.init")
|
||||
|
||||
-- pokered data/maps/objects/SilphCo2F.asm: SILPHCO2F_SILPH_WORKER_F stands at
|
||||
-- (10, 1) facing UP with row 0 walled off, so she is talked to from below.
|
||||
local MAP = "SILPH_CO_2F"
|
||||
local TEXT = "TEXT_SILPHCO2F_SILPH_WORKER_F"
|
||||
local WORKER = "SILPHCO2F_SILPH_WORKER_F"
|
||||
local PRE = "SilphCo2FSilphWorkerFPleaseTakeThisText"
|
||||
local STAND = { x = 10, y = 2, facing = "up" }
|
||||
|
||||
local function check(label, ok)
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
-- the whole bug was a missing cache string, so say which half is wrong when
|
||||
-- the boxes come out empty
|
||||
local pre = game.data.text[PRE]
|
||||
check(PRE .. " is in the text cache", type(pre) == "string" and pre ~= "")
|
||||
if type(pre) == "string" then
|
||||
check("it is the scared line", pre:find("Eeek!", 1, true) ~= nil
|
||||
and pre:find("please take this!", 1, true) ~= nil)
|
||||
U.log("pre text reads:", (pre:gsub("[\n\011\012]", " / ")))
|
||||
end
|
||||
check(MAP .. "/" .. TEXT .. " runs a hand-ported script",
|
||||
type(mapScripts.talkScript(MAP, TEXT)) == "function")
|
||||
check("TM_SELFDESTRUCT is a known item",
|
||||
game.data.items.TM_SELFDESTRUCT ~= nil)
|
||||
|
||||
-- a save that already has the TM takes the explanation-only branch
|
||||
if game.save.flags.EVENT_GOT_TM36 then
|
||||
game.save.flags.EVENT_GOT_TM36 = nil
|
||||
Bag.remove(game.save, "TM_SELFDESTRUCT", 1)
|
||||
U.log("EVENT_GOT_TM36 was already set; cleared it to replay the gift")
|
||||
end
|
||||
|
||||
local vol = game.save.options and game.save.options.sfxVol
|
||||
if (vol or 0) == 0 then
|
||||
U.log("sfxVol is 0: the item jingle will be silent, raise it in OPTION first")
|
||||
else
|
||||
U.log("sfxVol", tostring(vol), "-- one Get_Item1 jingle, after the pleading")
|
||||
end
|
||||
|
||||
U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing)
|
||||
U.wait(10)
|
||||
|
||||
local function workerIn(ow)
|
||||
for _, n in ipairs(ow.npcs or {}) do
|
||||
if n.def and n.def.name == WORKER then return n end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- re-reads game.overworld: the fallback below teleports again, which rebuilds
|
||||
-- the state and its npc list
|
||||
local function facingTheWorker()
|
||||
local ow = game.overworld
|
||||
local her = ow and workerIn(ow)
|
||||
if not her then return false end
|
||||
local fx, fy = ow.player:facingCell()
|
||||
return ow:npcAtCell(fx, fy) == her
|
||||
end
|
||||
|
||||
local ow = game.overworld
|
||||
local her = workerIn(ow)
|
||||
check("the worker is loaded on " .. MAP, her ~= nil)
|
||||
|
||||
if her and not facingTheWorker() then
|
||||
-- a map edit or a mod moved her: stand on any free walkable neighbour.
|
||||
-- {dx, dy, facing} is the offset from her cell plus the way back at her.
|
||||
local sides = {
|
||||
{ 0, 1, "up" }, { 0, -1, "down" }, { 1, 0, "left" }, { -1, 0, "right" },
|
||||
}
|
||||
for _, s in ipairs(sides) do
|
||||
local cx, cy = her.cellX + s[1], her.cellY + s[2]
|
||||
if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then
|
||||
U.log(("(%d, %d) is blocked, standing on"):format(STAND.x, STAND.y),
|
||||
cx, cy, "facing", s[3])
|
||||
U.teleport(game, MAP, cx, cy, s[3])
|
||||
U.wait(10)
|
||||
ow = game.overworld
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
check("the player is facing her", facingTheWorker())
|
||||
|
||||
local function boxText()
|
||||
local top = game.stack:top()
|
||||
if getmetatable(top) ~= TextBox then return nil end
|
||||
local out = {}
|
||||
for _, page in ipairs(top.pages or {}) do
|
||||
for _, line in ipairs(page) do out[#out + 1] = line end
|
||||
end
|
||||
return table.concat(out, " / ")
|
||||
end
|
||||
|
||||
local function hasTm()
|
||||
return (game.save.inventory or {}).TM_SELFDESTRUCT ~= nil
|
||||
end
|
||||
|
||||
U.tap(game, "a")
|
||||
U.wait(30)
|
||||
local first = boxText()
|
||||
check("A on her opens a text box", first ~= nil)
|
||||
if first then U.log("box 1 reads:", first) end
|
||||
check("the first box is the scared line, not the TM",
|
||||
first ~= nil and first:find("Eeek!", 1, true) ~= nil)
|
||||
check("and the TM is still hers while it is up", not hasTm())
|
||||
U.shot(game, DIR .. "/bug393_1_scared.png")
|
||||
|
||||
-- read the rest of the conversation the way a player does. The pleading runs
|
||||
-- seven lines before GiveItem, and the gift box and the SELFDESTRUCT warning
|
||||
-- follow it, so turn pages until the talk actually ends instead of counting
|
||||
-- them out -- the bound is only there so a stuck box cannot hang the driver.
|
||||
-- boxText reads the whole box, so several A presses walk one string: log a
|
||||
-- box the first time it is seen, not once per page turn
|
||||
local i, last = 1, first
|
||||
for _ = 1, 40 do
|
||||
U.tap(game, "a")
|
||||
U.wait(40)
|
||||
local t = boxText()
|
||||
if not t then break end
|
||||
if t ~= last then
|
||||
i, last = i + 1, t
|
||||
U.log(("box %d reads:"):format(i), t)
|
||||
if t:find("TM36", 1, true) then
|
||||
U.shot(game, DIR .. "/bug393_2_tm36.png")
|
||||
end
|
||||
end
|
||||
end
|
||||
check("TM36 ended up in the bag", hasTm())
|
||||
check("EVENT_GOT_TM36 is set", game.save.flags.EVENT_GOT_TM36 ~= nil)
|
||||
U.log("bag holds", tostring(Bag.slots(game.save)), "item kinds now")
|
||||
|
||||
-- hand it back unclaimed so the pleading can be watched and heard live
|
||||
game.save.flags.EVENT_GOT_TM36 = nil
|
||||
Bag.remove(game.save, "TM_SELFDESTRUCT", 1)
|
||||
while getmetatable(game.stack:top()) == TextBox do
|
||||
U.tap(game, "a")
|
||||
U.wait(20)
|
||||
end
|
||||
U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing)
|
||||
U.wait(10)
|
||||
U.log("the gift was rolled back; the pad is yours, press A on her")
|
||||
|
||||
U.log("She should panic first: \"Eeek!/No! Stop! Help!\", then work out you")
|
||||
U.log("are not a Rocket and offer the TM, and only then the jingle plays and")
|
||||
U.log("the box says <PLAYER> got TM36!, followed by the SELFDESTRUCT warning.")
|
||||
U.log("Press A on her once more after that: straight to the warning, no panic.")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,180 @@
|
||||
-- Eye/ear check on the S.S. Anne leaving Vermilion Dock and the rival's
|
||||
-- goodbye on 2F (#360): she used to blink to water in one frame, the surf
|
||||
-- music rode into the city, and the rival walked back to his spawn without
|
||||
-- the CUT-master line. scripts/VermilionDock.asm and scripts/SSAnne2F.asm.
|
||||
-- Do not set POKEPORT_SPEED: fast-forward scales the logic clock only, so
|
||||
-- the horns and the music switch desynchronize from what you see.
|
||||
-- POKEPORT_DRIVER=tests/drivers/ss_anne_departure_bug360_test.lua POKEPORT_IDENTITY=bug360 POKEPORT_DEV=1 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local Music = require("src.core.Music")
|
||||
local story3 = require("data.scripts.story3")
|
||||
local story5 = require("data.scripts.story5")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
|
||||
-- pokered data/maps/objects/VermilionDock.asm: warp_event 14, 2 is the
|
||||
-- gangway off the ship, which is block (7,1); SSAnne2F.asm's
|
||||
-- .PlayerCoordinatesArray is 36,8 / 37,8 with the rival spawned on (36,4).
|
||||
local DOCK, DOCK_CELL = "VERMILION_DOCK", { x = 14, y = 2 }
|
||||
local ANNE2F, TRIGGER = "SS_ANNE_2F", { x = 37, y = 8 }
|
||||
|
||||
local function check(label, ok)
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
local function rowsOfKind(rows, kind)
|
||||
local out = {}
|
||||
for _, r in ipairs(rows or {}) do
|
||||
if r[1] == kind then out[#out + 1] = r end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
-- capture what a scene queues without letting it touch the live save or
|
||||
-- start the encounter sting
|
||||
local function capture(run)
|
||||
local realPlay = Music.play
|
||||
Music.play = function() end
|
||||
local rows
|
||||
local ow = {
|
||||
runner = {
|
||||
isRunning = function() return false end,
|
||||
run = function(_, r) rows = r end,
|
||||
},
|
||||
player = { facing = "down", cellX = DOCK_CELL.x, cellY = DOCK_CELL.y },
|
||||
startDustAnim = function(_, _, _, done) if done then done() end end,
|
||||
queueScript = function(_, r) rows = r end,
|
||||
}
|
||||
run(ow)
|
||||
Music.play = realPlay
|
||||
return rows
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------- rival exit
|
||||
local CUT = "_SSAnne2FRivalCutMasterText"
|
||||
local cut = game.data.text[CUT]
|
||||
check(CUT .. " resolves", type(cut) == "string" and cut ~= "")
|
||||
if type(cut) == "string" then
|
||||
check("it is the CUT-master line", cut:find("CUT", 1, true) ~= nil)
|
||||
U.log("rival goodbye reads:", (cut:gsub("\n", " / ")))
|
||||
end
|
||||
|
||||
for _, side in ipairs({ { 37, 4 }, { 36, 6 } }) do
|
||||
local x, steps = side[1], side[2]
|
||||
local rows = capture(function(ow)
|
||||
story5[ANNE2F].onStep({ save = { flags = {} }, data = game.data }, ow, x, 8)
|
||||
end)
|
||||
local said = rowsOfKind(rows, "show_text")
|
||||
check(("x=%d the goodbye is the last thing he says"):format(x),
|
||||
said[#said] ~= nil and said[#said][2] == CUT)
|
||||
local walk = rowsOfKind(rows, "walk_npc")[1]
|
||||
check(("x=%d exit walk is %d steps, ending downward"):format(x, steps),
|
||||
walk ~= nil and #walk[3] == steps and walk[3][#walk[3]] == "down")
|
||||
check(("x=%d he does not retreat to the spawn (36,4)"):format(x), (function()
|
||||
for _, r in ipairs(rowsOfKind(rows, "move_npc_to")) do
|
||||
if r[3] == 36 and r[4] == 4 then return false end
|
||||
end
|
||||
return true
|
||||
end)())
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------------ the ship
|
||||
local dockRows = capture(function(ow)
|
||||
story3[DOCK].onEnter({ save = { flags = { EVENT_GOT_HM01 = true } },
|
||||
data = game.data }, ow)
|
||||
end)
|
||||
local horns = 0
|
||||
for _, r in ipairs(rowsOfKind(dockRows, "play_sound")) do
|
||||
if r[2] == "SS_Anne_Horn" then horns = horns + 1 end
|
||||
end
|
||||
check("the departure blows the horn twice", horns == 2)
|
||||
local slide = 0
|
||||
for _, w in ipairs(rowsOfKind(dockRows, "wait")) do
|
||||
if w[2] == 20 then slide = slide + 1 end
|
||||
end
|
||||
check("she sails in eight beats, not one frame", slide == 8)
|
||||
check("no keepMusic override into the city", (function()
|
||||
for _, r in ipairs(rowsOfKind(dockRows, "play_music")) do
|
||||
if r[3] and r[3].keep then return false end
|
||||
end
|
||||
return true
|
||||
end)())
|
||||
local sfx = game.data.audio and game.data.audio.sfx
|
||||
check("SS_Anne_Horn is in the sfx table", sfx ~= nil and sfx.SS_Anne_Horn ~= nil)
|
||||
local mapSongs = game.data.audio and game.data.audio.mapSongs
|
||||
check("VERMILION_CITY still owns Music_Vermilion",
|
||||
mapSongs ~= nil and mapSongs.VERMILION_CITY == "Music_Vermilion")
|
||||
|
||||
local opts = game.save.options or {}
|
||||
if opts.sfxVol == 0 or opts.musicVol == 0 then
|
||||
U.log("SFX OR MUSIC VOLUME IS 0. The horns and the music switch are the")
|
||||
U.log("whole check. Raise them in OPTION and rerun.")
|
||||
else
|
||||
check("sfx and music are audible (" .. tostring(opts.sfxVol) .. "/"
|
||||
.. tostring(opts.musicVol) .. ")", true)
|
||||
end
|
||||
|
||||
game.save.party = {
|
||||
Pokemon.new(game.data, "CHARIZARD", 50),
|
||||
Pokemon.new(game.data, "PIKACHU", 30),
|
||||
Pokemon.new(game.data, "SNORLAX", 77),
|
||||
}
|
||||
game.save.flags.EVENT_GOT_POKEDEX = true
|
||||
game.save.flags.EVENT_GOT_HM01 = true
|
||||
game.save.flags.EVENT_SS_ANNE_LEFT = nil
|
||||
game.save.flags.EVENT_BEAT_SS_ANNE_RIVAL = nil
|
||||
check("HM01 in hand, ship still docked, rival unbeaten",
|
||||
game.save.flags.EVENT_GOT_HM01 == true
|
||||
and game.save.flags.EVENT_SS_ANNE_LEFT == nil
|
||||
and game.save.flags.EVENT_BEAT_SS_ANNE_RIVAL == nil)
|
||||
|
||||
U.log("Watch for: she idles a couple of seconds with smoke off the funnel,")
|
||||
U.log("one horn, then travels WEST a block at a time with the water closing")
|
||||
U.log("in behind her, a second horn once she is gone, and the surf loop")
|
||||
U.log("gives way to the Vermilion theme the moment you cross into town.")
|
||||
|
||||
U.teleport(game, DOCK, DOCK_CELL.x, DOCK_CELL.y, "up")
|
||||
local ow = game.overworld
|
||||
if ow and ow.map.id ~= DOCK then
|
||||
check("standing on " .. DOCK, false)
|
||||
elseif ow and not ow.map:isWalkableCell(DOCK_CELL.x, DOCK_CELL.y) then
|
||||
-- a map edit moved the gangway: any walkable neighbour of it still sits
|
||||
-- on the deck row the departure is keyed on
|
||||
for _, s in ipairs({ { 0, 1 }, { -1, 0 }, { 0, -1 }, { 1, 0 } }) do
|
||||
local cx, cy = DOCK_CELL.x + s[1], DOCK_CELL.y + s[2]
|
||||
if ow.map:isWalkableCell(cx, cy) then
|
||||
U.log(("gangway cell (%d,%d) is blocked, standing on")
|
||||
:format(DOCK_CELL.x, DOCK_CELL.y), cx, cy)
|
||||
U.teleport(game, DOCK, cx, cy, "up")
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- filmstrip across the slide: 120 frames of idling, then a shot every 40
|
||||
U.wait(110)
|
||||
U.shot(game, DIR .. "/360_dock_0.png")
|
||||
for i = 1, 5 do
|
||||
U.wait(40)
|
||||
U.shot(game, DIR .. "/360_dock_" .. i .. ".png")
|
||||
end
|
||||
U.log("captured", DIR .. "/360_dock_0.png", "through 5")
|
||||
U.wait(240)
|
||||
U.shot(game, DIR .. "/360_dock_gone.png")
|
||||
|
||||
-- park him one step short of the 2F trigger so the pad picks it up
|
||||
U.wait(120)
|
||||
U.teleport(game, ANNE2F, TRIGGER.x, TRIGGER.y + 1, "up")
|
||||
|
||||
U.log("The pad is yours. One tap UP walks onto (37,8) and the rival comes")
|
||||
U.log("down from the captain's door: beat him, and after the goodbye he must")
|
||||
U.log("walk four cells DOWN out of the room, never back up to (36,4). For")
|
||||
U.log("the other branch, console: game.save.flags.EVENT_BEAT_SS_ANNE_RIVAL")
|
||||
U.log("= nil, then step onto (36,8) -- there he goes RIGHT around you first.")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,117 @@
|
||||
-- Manual check for the two faint horizontal lines across the title (#373):
|
||||
-- black gaps between the SGB zone clips plus a too-bright ribbon band.
|
||||
-- Zones are pokered data/sgb/sgb_packets.asm BlkPacket_Titlescreen (rows
|
||||
-- 0-7 / 8-9 / 10-17), whose whites all match (data/sgb/sgb_palettes.asm).
|
||||
-- POKEPORT_DRIVER=tests/drivers/title_seam_bug373_test.lua POKEPORT_IDENTITY=bug373 POKEPORT_TOUCH=0 SHOT_DIR=/tmp/shots love .
|
||||
-- Do not set POKEPORT_SPEED: fast-forward desynchronizes the title music.
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
local Renderer = require("src.render.Renderer")
|
||||
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
|
||||
local function check(label, ok)
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
-- past the copyright splash / attract movie (engine/movie/splash.asm)
|
||||
U.wait(5)
|
||||
U.tap(game, "start")
|
||||
U.wait(30)
|
||||
|
||||
local title = game.stack:top()
|
||||
check("the title screen is on top",
|
||||
title ~= nil and title.screenId == "TitleState")
|
||||
if not (title and title.sgbPalettes) then
|
||||
U.log("No title state to look at; nothing below can run.")
|
||||
while true do coroutine.yield() end
|
||||
end
|
||||
|
||||
local opts = game.save.options
|
||||
local mode = opts and opts.colors or PaletteFX.mode
|
||||
-- the seam only exists under the SGB zone clips, so put the renderer there
|
||||
-- rather than trusting whatever COLORS the save was last left on (setMode
|
||||
-- is display-only; the option itself is restored below)
|
||||
if PaletteFX.mode ~= "gbc" then
|
||||
U.log("COLORS is", tostring(mode) .. "; switching the view to SGB for the check")
|
||||
PaletteFX.setMode("gbc")
|
||||
U.wait(20)
|
||||
end
|
||||
check("the view is in SGB (the mode this bug shows in)",
|
||||
PaletteFX.mode == "gbc")
|
||||
if (opts and opts.musicVol or 0) == 0 then
|
||||
U.log("WARNING music volume is 0: the title theme will be silent.")
|
||||
end
|
||||
if (opts and opts.sfxVol or 0) == 0 then
|
||||
U.log("WARNING sfx volume is 0: the START press will make no sound.")
|
||||
end
|
||||
|
||||
-- the three palette names the zones are built from
|
||||
for _, name in ipairs({ "LOGO1", "LOGO2", "MEWMON" }) do
|
||||
local pal = PaletteFX.pal(game.data, name)
|
||||
check(name .. " resolves to a four-color palette",
|
||||
type(pal) == "table" and #pal == 4)
|
||||
end
|
||||
|
||||
local function report(z)
|
||||
if not z then return end
|
||||
for i = 1, #z do
|
||||
local c = z[i].colors
|
||||
U.log(("zone %d rows %d-%d, white %s"):format(
|
||||
i, z[i].y, z[i].y + z[i].h - 1,
|
||||
c and c[1] and table.concat(c[1], ",") or "none"))
|
||||
end
|
||||
end
|
||||
|
||||
local z = title:sgbPalettes(game)
|
||||
check("the title builds three zones", z ~= nil and #z == 3)
|
||||
if z and #z == 3 then
|
||||
check("the ribbon band starts at tile row 8 (y=64)", z[2].y == 64)
|
||||
check("the player/mon zone starts at tile row 10 (y=80)", z[3].y == 80)
|
||||
check("logo and ribbon share a boundary", z[1].y + z[1].h == z[2].y)
|
||||
check("ribbon and mon zone share a boundary", z[2].y + z[2].h == z[3].y)
|
||||
local w1, w2, w3 = z[1].colors[1], z[2].colors[1], z[3].colors[1]
|
||||
local function sameColor(a, b)
|
||||
return a and b and a[1] == b[1] and a[2] == b[2] and a[3] == b[3]
|
||||
end
|
||||
check("all three zones share color 0", sameColor(w1, w2) and sameColor(w1, w3))
|
||||
report(z)
|
||||
end
|
||||
|
||||
-- The black-gap half only exists where the framebuffer is not an integer
|
||||
-- multiple of the window, i.e. Android's truncated density (#208).
|
||||
local ww, wh = love.graphics.getDimensions()
|
||||
local pw, ph = ww, wh
|
||||
if love.graphics.getPixelDimensions then
|
||||
pw, ph = love.graphics.getPixelDimensions()
|
||||
end
|
||||
local dpiX, dpiY = pw / ww, ph / wh
|
||||
U.log(("surface %dx%d units, %dx%d pixels, dpi %.4f/%.4f, fit scale %d")
|
||||
:format(ww, wh, pw, ph, dpiX, dpiY, Renderer:fitScale()))
|
||||
if dpiX % 1 == 0 and dpiY % 1 == 0 then
|
||||
U.log("Integer DPI here, so the black-gap half cannot show on this")
|
||||
U.log("screen at all; only the Android build reproduces it.")
|
||||
end
|
||||
|
||||
U.shot(game, SHOT_DIR .. "/bug373_title_gbc.png")
|
||||
U.log("captured", SHOT_DIR .. "/bug373_title_gbc.png")
|
||||
|
||||
PaletteFX.setMode("redpp")
|
||||
U.wait(20)
|
||||
U.shot(game, SHOT_DIR .. "/bug373_title_redpp.png")
|
||||
U.log("captured", SHOT_DIR .. "/bug373_title_redpp.png")
|
||||
PaletteFX.setMode(mode)
|
||||
U.wait(10)
|
||||
|
||||
U.log("The title is on screen and the pad is yours; START replays it.")
|
||||
U.log("In SGB the whole background is one off-white: sample left-edge")
|
||||
U.log("background above, inside and below the \"Version\" band and all three")
|
||||
U.log("read the same, no brighter strip and no dark hairline at rows 64/80.")
|
||||
U.log("The redpp shot is pure white throughout with the ink still red on")
|
||||
U.log("Red and blue on Blue; run POKEPORT_VERSION=blue to confirm that half.")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,170 @@
|
||||
-- Ear check on Music_YellowIntro across the cut to the title (#436): finish()
|
||||
-- used to stop the song there, on both the scene-done exit and the A/B/START
|
||||
-- skip. pokeyellow engine/movie/intro_yellow.asm PlayIntroScene
|
||||
-- .go_to_title_screen (both exits land there) touches no audio at all; the
|
||||
-- song only dies at engine/movie/title.asm:149 StopAllMusic, one instruction
|
||||
-- before MUSIC_TITLE_SCREEN, which TitleState:startMusic stands in for.
|
||||
-- POKEPORT_VERSION=yellow POKEPORT_DRIVER=tests/drivers/yellow_intro_music_bug436_test.lua POKEPORT_TOUCH=0 love .
|
||||
-- SKIP=1 on the same command taps START 300 frames into the movie. Add
|
||||
-- POKEPORT_IDENTITY only if that identity already carries a Yellow import;
|
||||
-- an identity with no cache lands in the launcher and nothing runs.
|
||||
-- Do not set POKEPORT_SPEED: it scales the logic clock only, so the movie and
|
||||
-- the audio clock drift apart and the comparison means nothing.
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Music = require("src.core.Music")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
local SKIP = os.getenv("SKIP") == "1"
|
||||
|
||||
local function check(label, ok)
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
-- every music call in order, so "the song was cut" and "the song was never
|
||||
-- started" read differently in the log; nothing under src/ changes
|
||||
local events = {}
|
||||
local realPlay, realStop = Music.play, Music.stop
|
||||
Music.play = function(data, song, loop, ctx)
|
||||
events[#events + 1] = { kind = "play", song = song, frame = U.frame() }
|
||||
U.log(("frame %d play %s"):format(U.frame(), tostring(song)))
|
||||
return realPlay(data, song, loop, ctx)
|
||||
end
|
||||
Music.stop = function(...)
|
||||
events[#events + 1] = { kind = "stop", frame = U.frame() }
|
||||
U.log(("frame %d stop"):format(U.frame()))
|
||||
return realStop(...)
|
||||
end
|
||||
|
||||
local function firstPlay(song)
|
||||
for i, e in ipairs(events) do
|
||||
if e.kind == "play" and e.song == song then return i, e end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function stopBetween(a, b)
|
||||
for i = a, b do
|
||||
if events[i] and events[i].kind == "stop" then return events[i] end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function waitFor(fn, limit)
|
||||
for _ = 1, limit do
|
||||
if fn() then return true end
|
||||
U.wait(1)
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
check("this build booted as Yellow", GameVersion.isYellow())
|
||||
if not GameVersion.isYellow() then
|
||||
U.log("Only Yellow plays this movie; rerun with POKEPORT_VERSION=yellow.")
|
||||
while true do coroutine.yield() end
|
||||
end
|
||||
|
||||
local opts = game.save.options
|
||||
if (opts and opts.musicVol or 0) == 0 then
|
||||
U.log("MUSIC VOLUME IS 0. There is nothing to hear. Raise it in OPTION,")
|
||||
U.log("quit and rerun; the log half below still holds.")
|
||||
end
|
||||
if (opts and opts.sfxVol or 0) == 0 then
|
||||
U.log("WARNING sfx volume is 0: the logo crash and the whoosh over the")
|
||||
U.log("song will be silent, and Pikachu's cry with them.")
|
||||
end
|
||||
|
||||
local songs = game.data.audio and game.data.audio.songs
|
||||
local INTRO = songs and songs.Music_YellowIntro and "Music_YellowIntro"
|
||||
or "Music_IntroBattle"
|
||||
check("the movie's song is in the audio table", songs ~= nil
|
||||
and songs[INTRO] ~= nil)
|
||||
|
||||
-- the copyright card and the GAME FREAK splash run before the scenes
|
||||
-- (IntroMovie phases 1-2), so wait on the song rather than a frame count
|
||||
local started = waitFor(function() return firstPlay(INTRO) ~= nil end, 900)
|
||||
check("the movie started " .. INTRO, started)
|
||||
if not started then
|
||||
U.log("The movie never reached its own music; nothing below can run.")
|
||||
while true do coroutine.yield() end
|
||||
end
|
||||
local introIdx = firstPlay(INTRO)
|
||||
|
||||
if SKIP then
|
||||
-- PlayIntroScene:16-19, hJoyPressed & (PAD_A | PAD_B | PAD_START)
|
||||
U.wait(300)
|
||||
U.log("tapping START mid-movie (the skip exit)")
|
||||
U.tap(game, "start")
|
||||
end
|
||||
|
||||
local function titleState()
|
||||
local top = game.stack:top()
|
||||
if top and top.screenId == "TitleState" then return top end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- the scene timers add up to about 1040 frames; the skip path lands sooner
|
||||
local reached = waitFor(function() return titleState() ~= nil end, 1500)
|
||||
check("the movie handed off to the title screen", reached)
|
||||
if not reached then
|
||||
U.log("The title never came up; nothing below can run.")
|
||||
while true do coroutine.yield() end
|
||||
end
|
||||
local title = titleState()
|
||||
local cut = U.frame()
|
||||
U.log(("cut to the title at frame %d, %d frames after the song started")
|
||||
:format(cut, cut - events[introIdx].frame))
|
||||
|
||||
-- the bug: a stop right here, at the cut
|
||||
local cutStop = stopBetween(introIdx + 1, #events)
|
||||
check("no music was stopped at the cut", cutStop == nil)
|
||||
if cutStop then
|
||||
U.log(("a stop landed on frame %d; before #436 that is finish() killing")
|
||||
:format(cutStop.frame))
|
||||
U.log("the song, and the title screen comes up silent until the cry.")
|
||||
end
|
||||
check("the title runs the Yellow logo-drop sequence, not the plain title",
|
||||
title.yellowLayout == true and title.phase == "drop")
|
||||
|
||||
U.shot(game, SHOT_DIR .. "/bug436_logo_drop.png")
|
||||
U.log("captured", SHOT_DIR .. "/bug436_logo_drop.png")
|
||||
|
||||
-- drop 32 frames, settle 36, bubble 3, then the cry (title.asm's
|
||||
-- WaitForSoundToFinish) before MUSIC_TITLE_SCREEN
|
||||
local seen = title.phase
|
||||
for _ = 1, 600 do
|
||||
if title.phase ~= seen then
|
||||
seen = title.phase
|
||||
U.log(("frame %d title phase %s"):format(U.frame(), seen))
|
||||
if seen == "loop" then break end
|
||||
end
|
||||
U.wait(1)
|
||||
end
|
||||
check("the title sequence reached its interactive loop", title.phase == "loop")
|
||||
|
||||
-- read the song off the log rather than naming it: field.boot lets a mod
|
||||
-- own the title theme (TitleState:startMusic, self.title.music)
|
||||
local titleIdx, titlePlay
|
||||
for i = introIdx + 1, #events do
|
||||
if events[i].kind == "play" then titleIdx, titlePlay = i, events[i] break end
|
||||
end
|
||||
check("the title theme started", titlePlay ~= nil)
|
||||
if titlePlay then
|
||||
check("it started after the cut, not at it", titlePlay.frame > cut + 32)
|
||||
U.log(("%s started %d frames after the cut"):format(
|
||||
tostring(titlePlay.song), titlePlay.frame - cut))
|
||||
check("nothing stopped the music between the two songs",
|
||||
stopBetween(introIdx + 1, titleIdx - 1) == nil)
|
||||
end
|
||||
|
||||
U.log("The title is up and the pad is yours. The one thing to hear: the")
|
||||
U.log("intro song runs unbroken through the cut and on over the logo drop,")
|
||||
U.log("with the crash and the whoosh layered on top, until the title theme")
|
||||
U.log("takes over after Pikachu's cry. A break at the cut is the bug.")
|
||||
U.log("Rerun with SKIP=1 for the START skip; it must sound the same.")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user