Compare commits

...

25 Commits

Author SHA1 Message Date
bryanthaboi 4c79db3d5d Merge pull request #472 from bryanthaboi/dev
stupid ass android
2026-07-30 12:28:57 -04:00
bryanthaboi c7e46b6563 stupid ass android 2026-07-30 12:26:27 -04:00
bryanthaboi 2765e3d145 Merge pull request #463 from bryanthaboi/dev
big bug squash party
2026-07-30 11:40:02 -04:00
bryanthaboi c62f1334bc merge main into dev 2026-07-30 11:35:57 -04:00
bryanthaboi eea0dec4a6 Merge pull request #459 from johnjohto/fix-generic-joystick-input
Handle generic joystick input
2026-07-30 11:27:41 -04:00
bryanthaboi 25d241f12c Merge pull request #456 from johnjohto/fix-yellow-npc-trades
Fix Yellow NPC trades
2026-07-30 11:26:56 -04:00
bryanthaboi 0b55ab083a Merge pull request #452 from mresnick67/ios-native-bridge
iOS: native document-picker + Apple Health bridges, working device builds
2026-07-30 11:26:16 -04:00
bryanthaboi 6b9d4f2470 Merge pull request #450 from RetroRulez/feat/i18n-enabling
i18n: make more UI reachable for translation mods (Strings wraps, menu box auto-size, optional metric dex)
2026-07-30 11:24:59 -04:00
bryanthaboi 8a8340ffc8 Merge pull request #461 from bryanthaboi/bug-squashing-jul-30
Bug squashing jul 30
2026-07-30 11:23:08 -04:00
bryanthaboi 0af69e2df6 pikachu volume 2026-07-30 11:20:21 -04:00
bryanthaboi 4b9a326f6b CLOSES #339, CLOSES #354, CLOSES #360, CLOSES #372, CLOSES #373, CLOSES #374, CLOSES #375, CLOSES #378, CLOSES #379, CLOSES #383, CLOSES #384, CLOSES #385, CLOSES #391, CLOSES #392, CLOSES #393, CLOSES #394, CLOSES #395, CLOSES #396, CLOSES #397, CLOSES #398, CLOSES #413, CLOSES #420, CLOSES #423, CLOSES #424, CLOSES #425, CLOSES #426, CLOSES #427, CLOSES #429, CLOSES #430, CLOSES #431, CLOSES #433, CLOSES #435, CLOSES #436, CLOSES #438, CLOSES #439, CLOSES #441, CLOSES #442, CLOSES #444 2026-07-30 11:14:51 -04:00
Myles Resnick a92761dc13 Make the Pokewalker suite CI-runnable (fixture dataset) + pay save.loaded debt
T4 auto-runs shipped-mod suites ROM-free: the test now uses
T.fixtures.fresh() (FIXMON_A) with the level assertion derived from the
growth curve, and the now-covered event:save.loaded DEBT entry is
removed as gate_meta_coverage instructs. All four CI jobs reproduced
green on a ROM-free checkout.
2026-07-30 10:46:34 -04:00
johnjohto 8a649e8d95 Handle generic joystick input
Unmapped handheld controllers emit joystick events, leaving the launcher
cursor and in-game input inactive.
2026-07-30 10:38:24 -04:00
Cyberboy 5c5c970e81 i18n: draw naming-grid cells through Strings()
The name-entry keyboard drew each cell literally, so the case-switch labels
("lower case" / "UPPER CASE") and the "ED" confirm cell could not be
localized. Wrapping the draw in Strings() makes them translatable; the grid
data keeps the original strings, so the case-switch detection is unaffected.
Behavior-neutral for the base game (Strings identity without a catalog).
2026-07-30 16:35:03 +02:00
johnjohto 7b208043b9 Fix Yellow NPC trades 2026-07-30 10:17:30 -04:00
johnjohto 26dfad0682 Merge remote-tracking branch 'upstream/main' 2026-07-30 10:13:03 -04:00
Myles Resnick 902f0d73d4 iOS: native document-picker + Apple Health bridges, working device builds
Makes the iOS build a first-class citizen: ROM/mod/save import through
the system document picker (the README's missing "UIDocumentPicker
handoff"), Files-app drop-in support, and an opt-in Apple Health
step-sync seam consumed by a new gallery mod (Pokewalker).

Native layer (mobile/ios/native/, wired by mobile/ios/patch_love_src.py
on every build, so the fetched love-src tree stays pristine + re-patchable):
- GRPickerBridge.swift: love.system.pickFile("rom"|"mod"|"sav") and
  love.system.createFile on iOS with the same contract as love-android's
  SAF picker (picked_rom.gb / picked_mod.zip / picked_save.sav /
  export_done.flag in the save dir). Reached from wrap_System.cpp via the
  ObjC runtime, so liblove needs no Swift interop.
- GRBootstrap.m: sweeps .gb/.gbc/.zip/.sav dropped in Documents (Files
  app / Finder sharing) into the save dir on every activation;
  UIFileSharingEnabled + LSSupportsOpeningDocumentsInPlace in the plist
  overlay. Drop a ROM, open the app, it imports with zero taps.
- GRHealthBridge.swift: love.system.syncHealthSteps() -> read-only
  HealthKit step query anchored to the last sync, delivered as
  steps_pending.json (merge-not-overwrite). HealthKit entitlement +
  usage description included.

Lua:
- RomImporter: iOS rides the Android mobile flows; a 0.5s poll consumes
  picker deliveries (iOS pickers are in-process modals, so the Android
  refocus rescan never fires); failed pick copies surface as an
  on-screen notice via pick_error.txt.
- main.lua: on iOS, stop forwarding touchpressed to the Importer - LOVE
  already synthesizes a mousepressed for the primary touch, and the
  resulting same-frame double-present made the document picker
  auto-dismiss with zero documents (silent import failure).
- mods/pokewalker: opt-in Pokewalker mod (manifest v2, MECHANIC,
  permissions declared, mod.card, CHANGELOG, headless test suite 9/9,
  modkit validate --base imported + lint clean). Fused into iOS
  game.love only; loads dormant anywhere without the bridge.

Build (scripts/build_ios.sh):
- Fix Xcode 26: the global PRODUCT_NAME override also renamed liblove.a
  and broke the app link; the app bundle is renamed after the build
  instead.
- Fix nondeterministic pack failures: grep -q + pipefail races SIGPIPE
  on the game.love content checks.
- Simulator builds sign ad-hoc so entitlements embed (HealthKit works in
  the simulator).
- Device builds: signing team auto-detected from the keychain,
  CODE_SIGN_STYLE=Automatic + -allowProvisioningUpdates for CLI-only
  provisioning, per-team derived bundle ID (explicit App IDs are
  globally unique, so third parties can't sign the project default),
  gitignored mobile/ios/bundle_id.local pin, and --install to push to a
  connected iPhone.
- docs/ios-install.md: a zero-knowledge walkthrough from bare Mac to
  playing on an iPhone.

Backward compatibility: no behavior change on desktop or Android. The
new love.system functions exist only under LOVE_IOS; RomImporter's
mobile flag simply includes iOS alongside Android; the main.lua change
is iOS-gated; the Pokewalker mod is packed only by the iOS build script
and its option defaults off.

Verified on an iPhone 17 Pro simulator and an iPhone 16 Pro device:
scripted ROM import to title screen, Files-drop zero-tap import,
picker-driven mod install and save import/export, HealthKit permission
sheet + step credit (4000 steps -> +200 EXP at the default rate through
the engine growth curve).
2026-07-30 09:46:48 -04:00
Cyberboy f6ae967796 i18n: route SummaryMenu stat labels through Strings()
The ATTACK/DEFENSE/SPEED/SPECIAL labels on the status screen were drawn
literally; wrap them like the other UI so a language mod can translate them.
Behavior-neutral for the base game (Strings identity without a catalog).
2026-07-30 15:37:10 +02:00
bryanthaboi 29afbdaec7 Update README with additional media badges
Added new badges for Digital Foundry, Android Authority, and XDA Developers to the README.
2026-07-30 09:08:58 -04:00
Cyberboy 350402b179 i18n: route hardcoded UI strings through Strings(), auto-size menu boxes, optional metric dex fields
Makes several UI spots reachable for translation mods without forking the
engine. All behavior-neutral for the base game: Strings(x) is the identity
function when no catalog is loaded, and the metric dex fields are opt-in.

- ListMenu: draw the title via Strings() (covers bag/PC/box/fly/move lists)
- Menu / (see follow-up ChoiceBox): grow the box to the widest label so longer
  localized labels don't overflow the frame
- OptionsMenu: wrap toggle values (ON/OFF, SET/SHIFT, WIDE/OG, text speed) and
  the ruleset display name in Strings()
- Schemas: add optional dexEntry.heightM / weightKg
- DexEntryMenu: render metric height/weight when those fields are present,
  otherwise unchanged (ft/in + lb)
2026-07-30 15:03:35 +02:00
bryanthaboi 0f581e2f69 so many bugs i cannot even breathe 2026-07-30 07:41:41 -04:00
johnjohto d3010dc060 Merge pull request #1 from johnjohto/fuchsia-swamp-trainer-support
Support custom trainer palettes and base portraits
2026-07-29 23:52:41 -04:00
johnjohto 53e1a535dd Support custom trainer palettes and base portraits 2026-07-29 23:51:10 -04:00
bryanthaboi e63fa70700 Update README.md 2026-07-29 21:22:25 -04:00
bryanthaboi 27fb6eedc6 Change update video link and thumbnail
Updated the YouTube video link and thumbnail in the README.
2026-07-29 16:48:54 -04:00
147 changed files with 13630 additions and 730 deletions
+3
View File
@@ -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
+34 -3
View File
@@ -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
[![Watch the latest update video](https://img.youtube.com/vi/TbHdJIrKJJU/maxresdefault.jpg)](https://www.youtube.com/watch?v=TbHdJIrKJJU)
[![Watch the latest update video](https://img.youtube.com/vi/8IOgqbe4YvA/maxresdefault.jpg)](https://www.youtube.com/watch?v=8IOgqbe4YvA)
This project does not include a ROM, emulate the Game Boy, transpile assembly,
+8
View File
@@ -78,6 +78,14 @@ function love.conf(t)
-- player to copy their ROM there instead of needing a native file
-- picker (LOVE 11.5 on Android has none -- see src/import/RomImporter.lua).
t.externalstorage = osName == "Android"
-- LOVE 11.x exposes the phone's accelerometer as a 3-axis joystick by
-- default. Gravity keeps one axis pinned near +/-1.0 whenever the phone
-- is held upright, so it streams past-deadzone joystickaxis events that
-- both hid the touch overlay every instant (it reads as "a controller is
-- in use") and, via the generic-joystick axis mapping (#459), walked the
-- player around by tilt. Nothing in the game reads the accelerometer,
-- so drop the device entirely (#468).
t.accelerometerjoystick = false
else
t.window.resizable = true
end
+71 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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,
}
+15 -4
View File
@@ -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" }
+15 -1
View File
@@ -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" },
}, {})
+116
View File
@@ -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
(515 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.
+5
View File
@@ -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
+53 -1
View File
@@ -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);
}
}
+20
View File
@@ -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"`.
+23
View File
@@ -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)];
}
}];
}
+121
View File
@@ -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
}()
}
+218
View File
@@ -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?()
}
}
+10
View File
@@ -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>
+11
View File
@@ -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>
+250
View File
@@ -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()
+17
View File
@@ -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.
+92
View File
@@ -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).
+144
View File
@@ -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
+16
View File
@@ -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."
}
+33
View File
@@ -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 },
}
+61
View File
@@ -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
View File
@@ -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
View File
@@ -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!"))
+8
View File
@@ -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
+6
View File
@@ -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
View File
@@ -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)
+23
View File
@@ -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 = {}
+34
View File
@@ -491,6 +491,40 @@ function Game:gamepadaxis(joystick, axis, value)
Input:gamepadaxis(joystick, axis, value)
end
-- conf.lua turns the mobile accelerometer-joystick off (#468), but guard the
-- generic joystick path anyway: any sensor-style device that still reaches us
-- has gravity pinning an axis past the deadzone, which would hide the touch
-- overlay every instant and steer the player by tilt through the axis-1/2
-- mapping (#459). Real controllers arrive as SDL gamepads or named sticks,
-- never as "* Accelerometer".
local function isAccelerometer(joystick)
local name = joystick and joystick.getName and joystick:getName()
return name ~= nil and name:lower():find("accelerometer", 1, true) ~= nil
end
function Game:joystickpressed(joystick, button)
if isAccelerometer(joystick) then return end
TouchControls:noteGamepad()
Input:joystickpressed(joystick, button)
end
function Game:joystickreleased(joystick, button)
if isAccelerometer(joystick) then return end
Input:joystickreleased(joystick, button)
end
function Game:joystickaxis(joystick, axis, value)
if isAccelerometer(joystick) then return end
if math.abs(value) > 0.5 then TouchControls:noteGamepad() end
Input:joystickaxis(joystick, axis, value)
end
function Game:joystickhat(joystick, hat, direction)
if isAccelerometer(joystick) then return end
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
+45
View File
@@ -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
+4
View File
@@ -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
+3
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+10
View File
@@ -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
View File
@@ -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)
+5 -2
View File
@@ -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
+22 -4
View File
@@ -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"
+1
View File
@@ -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)) } }),
+54 -8
View File
@@ -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
View File
@@ -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
+36 -1
View File
@@ -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
+13 -4
View File
@@ -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
+58
View File
@@ -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
+75 -26
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+7 -2
View File
@@ -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
View File
@@ -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
+14
View File
@@ -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
+1 -1
View File
@@ -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)
+4 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+5 -1
View File
@@ -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
View File
@@ -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",
}
+10
View File
@@ -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
+17
View File
@@ -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
View File
@@ -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
+160 -9
View File
@@ -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
View File
@@ -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
+324
View File
@@ -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
+205
View File
@@ -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
-47
View File
@@ -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
+123
View File
@@ -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
+158
View File
@@ -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
+219
View File
@@ -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
+2 -1
View File
@@ -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()
+118
View File
@@ -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
+125
View File
@@ -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
+184
View File
@@ -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
+102
View File
@@ -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()
+189
View File
@@ -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
+13 -1
View File
@@ -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
+21 -16
View File
@@ -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
View File
@@ -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))
+136
View File
@@ -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
+232
View File
@@ -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
+167
View File
@@ -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
+117
View File
@@ -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

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