Compare commits

...

21 Commits

Author SHA1 Message Date
bryanthaboi 898bf0c71e Merge pull request #560 from bryanthaboi/dev 2026-07-31 23:55:26 -04:00
bryanthaboi 5a7add8eaa Merge pull request #554 from andrewqsantos/feature/mobile-safe-area 2026-07-31 23:54:11 -04:00
bryanthaboi 66334dfacd Merge pull request #558 from erereck/fix/mod-configurable-bag-capacity 2026-07-31 23:53:54 -04:00
bryanthaboi a33f3b1ceb mobile fixes 2026-07-31 23:47:28 -04:00
erereck 6e724cedf7 Honor modded bag capacity 2026-08-01 00:32:00 -03:00
bryanthaboi e949c78639 Merge pull request #556 from bryanthaboi/dev 2026-07-31 23:03:09 -04:00
bryanthaboi 9bcfdb1f0d mobile fixes 2026-07-31 23:01:25 -04:00
Andrew Quenehen fccb122c59 Respect iOS/Android safe areas in launcher and touch chrome.
Layout interactive UI against love.window.getSafeArea so notch, Dynamic Island, and home-indicator insets no longer clip controls, while keeping the game framebuffer edge-to-edge.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-31 23:53:26 -03:00
bryanthaboi e2b2a5fb9f Merge pull request #555 from bryanthaboi/dev 2026-07-31 22:41:53 -04:00
bryanthaboi f2a3e5f05b mobile updates CLOSES #482, CLOSES #553 2026-07-31 22:39:01 -04:00
bryanthaboi 5f855568c8 Merge pull request #539 from hernan0078/ios-support 2026-07-31 22:16:24 -04:00
bryanthaboi 24696e3be2 Merge pull request #524 from kaosregulator/claude/multi-game-low-end-support-o16gz5
Claude/multi game low end support  and some fixes o16gz5
2026-07-31 20:26:09 -04:00
bryanthaboi b17dce232e Merge pull request #547 from bryanthaboi/dev
bug squashing and what not
2026-07-31 20:21:37 -04:00
bryanthaboi e1b20723fd Merge branch 'dev' of https://github.com/bryanthaboi/gen1recomp into dev 2026-07-31 20:20:38 -04:00
bryanthaboi fdab15a6dc CLOSES #503, CLOSES #511, CLOSES #515, CLOSES #518, CLOSES #522, CLOSES #523, CLOSES #525, CLOSES #528, CLOSES #529, CLOSES #533, CLOSES #535, CLOSES #536 2026-07-31 20:20:37 -04:00
hernan0078 9c5f33e187 Fix #482: guard love.system.pickFile so Import ROM cannot crash
Pressing Import ROM on iOS takes the whole app down:

  src/import/RomImporter.lua: attempt to call field 'pickFile' (a nil value)

love.system.pickFile is a NATIVE BRIDGE, not part of LOVE. It exists only on
builds that compiled one -- Android, and iOS builds patched by
mobile/ios/patch_love_src.py -- so on a build without it the field is simply
nil. RomImporter:546 routes iOS down the same path as Android
(`mobileOS == "Android" or mobileOS == "iOS"`), and all three mobile pick
sites called the field unguarded.

That is why the reports say "any version": nothing about it is version
specific. Red, Blue and Yellow all reach the same call.

Every one of those call sites already handles a device with no document
picker -- Choose falls back to "No picker available, copy your ROM into:"
plus the save directory, and the mod / save rows have their own notices --
and love.system.createFile at its single call site was already guarded this
way. These three were not, so the fallback that was written for exactly this
case could never be reached.

Route them through one small helper that answers false when the bridge is
absent. A build without a picker now degrades to the copy-into-the-save-folder
flow, which on iOS is a working path: the Files app exposes the app's
Documents folder and GRBootstrap sweeps what lands there into the save dir.

tests/rom_importer_no_picker_test.lua covers Import ROM, Import mod and
Import save with the bridge missing, and asserts the picker is still used
when it is present. Reverting the fix reproduces the reported error exactly.

Reported in #482 (confirmed by three people) and #512.
2026-07-31 16:38:15 -04:00
bryanthaboi 4916fc8f4b Merge pull request #532 from TitaniteScale/symlink-mod-support
Recognize symlinked mod dirs on Linux (mods/ dev workflow)
2026-07-31 15:22:48 -04:00
Jake Eaker fc19e58678 added symlink support for mods 2026-07-31 13:47:53 -04:00
Claude 74eeb411ba Add luacheck config + lint script
Introduce a tuned .luacheckrc and scripts/lint.sh so the engine has a
standing static-analysis baseline -- the tool that would have caught both
bugs in the previous commit before they shipped.

The config is high-signal by design: it keeps the categories that catch
real defects (undefined globals/locals, unused values, unreachable code)
and mutes the cosmetic ones the codebase deliberately lives with (a self/dt
an interface requires but a method ignores, documented empty fall-through
branches, long lines). It marks `love` mutable (games assign callbacks onto
it) and teaches it LuaJIT's table.unpack.

.luacheckrc is tracked via a .gitignore exception, matching how .github and
.gitignore opt out of the blanket dotfile ignore.

`luacheck src` now reports 7 benign warnings and 0 errors, down from 185.
Also drop one dead `require` (ItemEffects loaded src.pokemon.Pokemon and
never used it).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q6bFAiQyZ5jDmewsbB4LG9
2026-07-31 14:35:40 +00:00
Claude 040ca3f332 Fix two latent bugs surfaced by static analysis
Both are code paths that never run in a green test today but crash or
misbehave the moment a mod or a link failure exercises them.

1. Music.lua: applyVolume built its `music.volume` hook context from the
   private `state` table, but was defined *above* `local state = {...}`, so
   those reads bound to the nil global `state`. Any mod registering the
   music.volume hook crashed with "attempt to index a nil value (global
   'state')" the first time a volume was applied. Forward-declare `state`
   above applyVolume. Regression test drives a file-backed song through the
   hook and asserts the context resolves.

2. Tournament.lua: `local battle, why = isHost and newHost() or newGuest()`
   had two defects. The and/or idiom truncates a call to its first result,
   so `why` (the specific failure reason) was always dropped and every link
   failure showed the generic "Link battle can't start" instead of e.g.
   "same mods on both games". Worse, when a host's newHost() returned nil,
   the `or` fell through and wrongly called newGuest() as the host. Split
   into an explicit if/else so the reason is preserved and each role calls
   its own constructor.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q6bFAiQyZ5jDmewsbB4LG9
2026-07-31 14:34:58 +00:00
Claude c8e035d332 Add graphics performance tier for low-end devices
Introduce an OPTIONS -> PERFORMANCE setting that scales the port's
optional presentation extras down for weaker hardware, so older/lower-end
devices can run the game smoothly.

The tier governs the three heaviest non-faithful extras -- the 3D TILT,
the GBC FX post-process shader, and survey ZOOM (which renders connected
neighbor maps) -- plus a hard FPS ceiling. It never touches game logic,
which is fixed-step off dt, so every tier plays identically.

- src/core/Performance.lua: tiers (auto/high/balanced/low), a conservative
  device auto-detect (ARM handhelds -> low, phones -> balanced, normal
  desktops -> high), per-tier caps, and the option-row cycle. Zero
  requires, like GameVersion.
- Game:applyOptions clamps the *live* presentation state against the tier
  without rewriting stored options, so a lower tier hides the player's
  TILT/GBC FX/ZOOM/FPS choices and a higher tier restores them exactly.
- Zoom.offsetRange floors the range at FIT when survey is disallowed, so
  the option row, hotkey, and mouse wheel all stop at close-up on LOW.
- New save.options.performance default "auto"; OPTIONS row heads the
  display group and re-applies live.
- Tests: tests/engine/performance_tiers.lua (ROM-free); mod_ui_tests row
  golden updated for the spliced row.

AUTO resolves to HIGH on a normal desktop and on every options.lua that
predates the option, so the common case is unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q6bFAiQyZ5jDmewsbB4LG9
2026-07-31 01:44:17 +00:00
75 changed files with 3066 additions and 231 deletions
+1
View File
@@ -16,6 +16,7 @@ __pycache__/
.*
!.github/
!.gitignore
!.luacheckrc
# Android build outputs / local SDK path / packaged payload (keep love-android sources)
mobile/android/app/build/
+43
View File
@@ -0,0 +1,43 @@
-- Static-analysis config for `luacheck` (https://luacheck.readthedocs.io).
--
-- Run it over the engine with: luacheck src (or scripts/lint.sh)
--
-- The point is a high-signal baseline: the categories left on are the ones
-- that catch real defects -- undefined globals/locals (the class that hid a
-- `music.volume` crash: applyVolume read a `state` that was still the nil
-- global), unused values, unreachable code, redefinitions. The cosmetic
-- categories the codebase deliberately lives with (a `self`/`dt` an
-- interface requires but a given method ignores, documented empty
-- fall-through branches, the odd long line) are muted so they don't drown
-- the signal.
std = "luajit"
-- LÖVE exposes `love` as a mutable table: games assign their callbacks onto
-- it (love.wheelmoved, love.run, ...), so it is a regular global, not
-- read-only -- otherwise every callback registration reads as a violation.
globals = { "love" }
read_globals = {
"jit",
-- LuaJIT 2.1 ships table.unpack even though the bare 5.1 `table` std lacks
-- it; without this, every `table.unpack` reads as an undefined field.
table = { fields = { "unpack" } },
}
-- Vendored/native trees and the test suites have their own conventions.
exclude_files = {
"mobile/",
"tests/",
"tools/save-editor/",
}
ignore = {
"212", -- unused argument -- self/dt kept for a shared method signature
"213", -- unused loop variable -- `for _, v in` where only v is wanted
"421", -- shadowing a local -- deliberate re-use in a few tight scopes
"431", -- shadowing an upvalue
"432", -- shadowing an argument
"542", -- empty if branch -- documented fall-throughs, not gaps
"631", -- line is too long
}
+11
View File
@@ -108,6 +108,17 @@ supported out of the box.
COLORS, TILT, ZOOM, GBC FX, and VOID FILL are also in the Options menu
and persist in `options.lua`.
### Low-end devices
**OPTIONS → PERFORMANCE** scales the port's optional extras for weaker
hardware: **HIGH** (everything on), **BALANCED** (no 3D tilt or GBC FX),
**LOW** (also no survey zoom, FPS capped), or **AUTO** — the default, which
picks a tier from your device (ARM handhelds → LOW, phones → BALANCED,
normal desktops → HIGH, unchanged). It only scales presentation; the
fixed-step game logic is identical on every tier, and a lower tier hides
your tilt/zoom/GBC-FX preferences without forgetting them. Details in
[docs/new-features.md](docs/new-features.md#performance-tier-low-end-devices).
### Rulesets
**OPTIONS → RULESET** picks which set of Gen 1 battle behaviors to run.
+5
View File
@@ -1,4 +1,9 @@
function love.conf(t)
-- PhysFS ignores symlinks unless told otherwise, so a mod dev-linked into
-- mods/ (ln -s, matching the mklink /J workflow on Windows) is invisible
-- to love.filesystem.getDirectoryItems without this.
love.filesystem.setSymlinksEnabled(true)
local editor = os.getenv("POKEPORT_EDITOR") == "1"
local developer = os.getenv("POKEPORT_DEV") == "1"
if arg then
@@ -0,0 +1,95 @@
-- Viridian School House's two readables (data/events/hidden_events.asm,
-- hidden_events_for VIRIDIAN_SCHOOL_HOUSE):
-- hidden_text_predef 3, 0 PrintBlackboardLinkCableText, ViridianSchoolBlackboard
-- hidden_text_predef 3, 4 PrintNotebookText, ViridianSchoolNotebook
-- tools/extract/field.py only parses `hidden_event` rows, so neither
-- hidden_text_predef row reaches data/generated/field.lua and both tiles
-- were dead A presses (#503). Same hook shape, and the same sibling asm
-- file, as the Celadon roof house in data/scripts/celadon_eevee.lua (#391);
-- hidden_text_predef spends the facing byte on the tx_pre id, so neither
-- tile gates on facing.
local Menu = require("src.ui.Menu")
local TextBox = require("src.render.TextBox")
-- ViridianSchoolBlackboard (engine/events/hidden_events/school_blackboard.asm):
-- StatusAilmentText1/2 are the two columns of the 12x8 box at the top left
-- (hlcoord 0, 0 + `lb bc, 6, 10`); picking a status prints its
-- ViridianBlackboardStatusPointers entry and jumps back to .blackboardLoop,
-- QUIT or B falls through to .exitBlackboard.
local STATUS_LABELS = {
{ " SLP", "_ViridianBlackboardSleepText" },
{ " PSN", "_ViridianBlackboardPoisonText" },
{ " PAR", "_ViridianBlackboardPrlzText" },
{ " BRN", "_ViridianBlackboardBurnText" },
{ " FRZ", "_ViridianBlackboardFrozenText" },
}
local function blackboard(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 = 12, th = 8, rowStep = 1 }))
end
-- ViridianSchoolBlackboardText2 is reprinted on every .blackboardLoop
-- pass, immediately before HandleMenuInput
function askHeading()
game.stack:push(TextBox.new(game,
text._ViridianSchoolBlackboardText2 or "Which heading do\nyou want to read?",
showMenu))
end
items = {}
for i, row in ipairs(STATUS_LABELS) do
local label, key = row[1], row[2]
items[i] = { label = label, onSelect = function()
game.stack:push(TextBox.new(game, text[key] or label, askHeading))
end }
end
-- no onSelect: Menu's own pop closes the box, matching .exitBlackboard
items[#items + 1] = { label = " QUIT" }
game.stack:push(TextBox.new(game,
text._ViridianSchoolBlackboardText1
or "The blackboard\ndescribes POKéMON\vSTATUS changes\vduring battles.",
askHeading))
end
-- ViridianSchoolNotebook (engine/events/hidden_events/school_notebooks.asm):
-- pages 1-3 each end in TurnPageSchoolNotebook (TurnPageText + YesNoChoice)
-- and NO stops the read; page 4 turns without asking and runs straight into
-- page 5, the girl catching you at it.
local function notebook(game)
local text = game.data.text or {}
local function page(n, after)
return TextBox.new(game, text["_ViridianSchoolNotebookText" .. n] or "", after)
end
local function turnPage(nextPage)
return function()
game.stack:push(TextBox.new(game, text._TurnPageText or "Turn the page?",
nil, { choice = function(yes)
if yes then game.stack:push(nextPage()) end
end }))
end
end
local function page5() return page(5) end
local function page4() return page(4, function() game.stack:push(page5()) end) end
local function page3() return page(3, turnPage(page4)) end
local function page2() return page(2, turnPage(page3)) end
game.stack:push(page(1, turnPage(page2)))
end
return {
VIRIDIAN_SCHOOL_HOUSE = {
onInteract = function(game, ow, fx, fy)
if fx == 3 and fy == 0 then
blackboard(game)
return true
end
if fx == 3 and fy == 4 then
notebook(game)
return true
end
return false
end,
},
}
+1
View File
@@ -60,6 +60,7 @@ local files = {
"data.scripts.flavor.victory_road_2f",
"data.scripts.flavor.viridian_city",
"data.scripts.flavor.viridian_nickname_house",
"data.scripts.flavor.viridian_school_house", -- #503
"data.scripts.flavor.wardens_house",
}
+14 -3
View File
@@ -647,7 +647,12 @@ M.WARDENS_HOUSE = {
TEXT_WARDENSHOUSE_WARDEN = {
{ "face_player" }, -- 1
{ "check_flag", "EVENT_GOT_HM04" }, -- 2
{ "jump_if_true", 13 }, -- 3
-- #535: previously jumped to the same silent-end target as the
-- give-then-thank fallthrough (row 13), so the warden said nothing
-- on every visit after the trade. pokered's .got_item branch
-- (scripts/WardensHouse.asm) instead prints HM04ExplanationText,
-- so route here to the new row 17 that does the same.
{ "jump_if_true", 17 }, -- 3
{ "check_item", "GOLD_TEETH" }, -- 4
{ "jump_if_false", 15 }, -- 5
{ "show_text", "_WardensHouseWardenGaveTheGoldTeethText" }, -- 6
@@ -658,9 +663,15 @@ M.WARDENS_HOUSE = {
{ "give_item", "HM_STRENGTH", 1, false }, -- 10
{ "show_text", "_WardensHouseWardenReceivedHM04Text" }, -- 11
{ "set_flag", "EVENT_GOT_HM04" }, -- 12
{ "jump", 16 }, -- 13 (already got it)
{ "jump", 16 }, -- 14 (unused)
{ "jump", 18 }, -- 13 (already got it this convo; jp .done)
{ "jump", 18 }, -- 14 (unused)
{ "show_text", "_WardensHouseWardenGibberish1Text" }, -- 15
{ "jump", 18 }, -- 16 (#535: skip the new explanation row below)
-- #535: pokered .got_item branch (scripts/WardensHouse.asm) --
-- printed on every subsequent talk once EVENT_GOT_HM04 is set.
-- Text is _WardensHouseWardenHM04ExplanationText (text/WardensHouse.asm):
-- HM04 teaches Strength, and hints at the Safari Zone secret house.
{ "show_text", "_WardensHouseWardenHM04ExplanationText" }, -- 17
},
},
}
+12 -1
View File
@@ -790,8 +790,19 @@ local function bikeGateGuard(coords, stopText, explainText)
local t = text(game)
push(game, t[stopText] or "Hey! Wait up!", function()
push(game, t[explainText] or "You need a\nBICYCLE for\nCYCLING ROAD!", function()
-- pokered's Route16Gate1FGuardScript / Route18Gate1FGuardScript
-- (scripts/Route16Gate1F.asm, Route18Gate1F.asm) simulate one
-- PAD_RIGHT step after the refusal text, and only clear
-- wJoyIgnore / hand control back once that step finishes
-- (PlayerMovingRightScript). Without it the player was left
-- parked beside the guard's counter with no way past. #518
local function shoveRight()
ow:scriptMove(ow.player, "right", 1)
end
if dist > 0 then
ow:scriptMove(ow.player, "up", dist)
ow:scriptMove(ow.player, "up", dist, shoveRight)
else
shoveRight()
end
end)
end)
+7
View File
@@ -65,6 +65,13 @@ display option — COLORS, TILT, ZOOM, VOID FILL, MAX FPS — works normally. If
your device turns out to handle the pass, launch with `POKEPORT_GBCFX=1` to
put the row back.
**PERFORMANCE defaults to LOW here.** The OPTIONS → PERFORMANCE tier defaults
to AUTO, which reads this device as an ARM Linux handheld and resolves to
**LOW**: the 3D tilt and survey zoom stay off and the frame rate is capped,
so the overworld runs smoothly on the H700 out of the box. Bump it to
BALANCED or HIGH from OPTIONS if you want the extras and your device keeps
up; see [Performance tier](new-features.md#performance-tier-low-end-devices).
The pack bundles the LÖVE 11.5 aarch64 runtime from
[PortMaster](https://portmaster.games/), so the device does not need a
separate `love_11.5` runtime download on first launch. The launcher resolves
+5 -1
View File
@@ -12,7 +12,7 @@ this port.
| # | Mechanic | Value | Why the Game Boy had this limit | Where it lives here |
|---|---|---|---|---|
| 1 | Bag capacity | 20 item slots | `wNumBagItems` save block was a fixed 20-entry id/quantity array in SRAM | `src/inventory/Bag.lua:8` (`Bag.CAPACITY = 20`) |
| 1 | Bag capacity | 20 item slots by default | `wNumBagItems` save block was a fixed 20-entry id/quantity array in SRAM | `Data.constants.bagSize`, read by `src/inventory/Bag.lua` (`Bag.capacity`) |
| 2 | Party size | 6 Pokémon | `wPartyMon1..6` were 6 fixed save-RAM slots | `src/pokemon/Party.lua:5` (`Party.MAX = 6`) |
| 3 | PC storage | 12 boxes × 20 Pokémon | `wBoxDataStart` / Bill's PC allocated a fixed 12×20 SRAM block | `src/pokemon/Boxes.lua:7-8` |
| 4 | Moves per Pokémon | 4 | Fixed 4-move-slot field in the party/box Pokémon struct | `src/pokemon/Pokemon.lua:20`, enforced again in `src/battle/BattleState.lua:1941` |
@@ -38,6 +38,10 @@ this port.
## Notes
- Mods may patch `constants.bagSize` through the public content registry. The
native `save.lua` format keeps every existing item when the configured
limit changes; exporting to a cartridge `.sav` still writes only the first
20 bag slots because the original SRAM layout has no room for more.
- PC Box **overflow handling** was deliberately changed even though the
20×12 box *shape* was kept faithful: instead of Gen 1's "full box discards
or blocks the deposit," this port spills into the next box with room.
+44 -1
View File
@@ -143,6 +143,41 @@ effect, `=1` forces it available. The Anbernic handheld pack exports `0` from
its launcher because the device reports `"Linux"` while its GPU is in the
phone class (see [Anbernic RG34XXSP](anbernic-rg34xxsp.md)).
## Performance tier (low-end devices)
The Options **PERFORMANCE** row scales the port's optional presentation
extras down for weaker hardware. The extras it governs are the three
heaviest things the port adds on top of the original -- the whole-screen 3D
**TILT** (transforms the entire map as a ground plane), the **GBC FX**
post-process shader (a fullscreen pass), and survey **ZOOM** (zooming out
renders the connected neighbor maps, a lot of extra overdraw) -- plus a hard
FPS ceiling. None of this touches game logic, which is fixed-step off `dt`
(`src/core/FixedStep.lua`), so every tier plays identically; they differ
only in how much eye-candy the renderer is allowed to do.
| Tier | TILT | GBC FX | Survey ZOOM | Extra FPS ceiling |
| ------------ | ---- | ------ | ----------- | ----------------- |
| **HIGH** | on | on | on | none |
| **BALANCED** | off | off | on | none |
| **LOW** | off | off | off | 60 |
| **AUTO** | picks a default from the device (below) |||
- **AUTO** (the default) reads the device once at boot: ARM Linux handhelds
(e.g. the RG34XXSP) resolve to **LOW**, phones/tablets and very-low-core
desktops to **BALANCED**, and everything else -- a normal desktop, and
every existing `options.lua` that predates this option -- to **HIGH**,
so the common case is unchanged. See `src/core/Performance.detect`.
- AUTO only chooses the *default*; all four tiers are selectable, so a
wrong guess is one row away from being overridden.
- The clamps are applied **live** against your stored options and never
rewrite them (`Game:applyOptions`), so a lower tier hides your TILT / GBC
FX / ZOOM without forgetting them -- raising the tier restores exactly
what you had. (This is why the TILT / GBC FX / ZOOM rows still show your
saved choice on a clamped tier: it's your preference, waiting for a tier
that can afford it.)
- Persisted as `save.options.performance` (`auto` | `high` | `balanced` |
`low`); unit-tested in `tests/engine/performance_tiers.lua`.
## Peer-to-peer link play (lua-enet)
Trades and link battles connect two copies of the game directly over
@@ -157,6 +192,13 @@ tradeoff vs. the relay). Headless tests drive the protocol over an
in-memory loopback (`Net.loopbackPair`); under LÖVE the same test file
also exercises real UDP pairing.
Red, Blue, and Yellow copies link with each other, as the real cable
does. The compatibility fingerprint hashes only data a link mode can
actually read, so Yellow's Dragonair/Dragonite catch-rate retunes (the
only R/B/Y link-surface difference) no longer read as different games
(issue #511). Moving the fingerprint is a link parity change: builds
from before this fix will refuse to pair with builds after it.
## Fair play in link and online matches
A link session is decided by the battle and nothing else, so for its
@@ -338,7 +380,8 @@ semantics - so the two windows read as one app. Six tabs:
party dock, so deposit and withdraw live in one place. Empty slots are
clickable and create a mon there.
- **Items**: money, a searchable item picker (replacing the arrows that
cycled one id at a time through ~250 items), the 20-slot bag, PC storage
cycled one id at a time through ~250 items), the configurable bag (20 slots
by default), PC storage
with no slot cap, and the eight badges as toggle chips.
- **Events**: flags, defeated trainers, taken items and per-map object
toggles, with a real filter field and a two-column paged grid.
+19 -2
View File
@@ -461,14 +461,31 @@ function love.wheelmoved(x, y)
Game:wheelmoved(x, y)
end
function love.mousepressed(x, y, button)
function love.mousepressed(x, y, button, istouch)
if TouchEditor then
-- Android primary touch already arrived via love.touchpressed; a second
-- mouse path would double-fire Done / begin a second drag.
if love.system.getOS() == "Android" then return end
return TouchEditor.mousepressed(x, y, button)
end
if Importer then return Importer:mousepressed(x, y, button) end
if Importer then
-- The same double-fire TouchEditor guards against, which the launcher was
-- missing: love.touchpressed above forwards the primary touch to the
-- Importer on Android, and LÖVE ALSO synthesizes a mouse press for that
-- same touch, so one tap ran every launcher button twice. On Import that
-- meant two choose() calls and two stacked SAF picker activities: the
-- player picked their ROM, the top picker closed, and the second was still
-- underneath asking for it again, which is the "import the file twice"
-- in #553. Filtering on istouch keeps a real mouse (DeX, a Chromebook, a
-- USB mouse) working, which an Android-wide return would have broken.
--
-- ANDROID ONLY, and the OS test is load bearing: love.touchpressed above
-- returns early on iOS and never forwards, so there the synthesized mouse
-- press is the ONLY event the launcher gets. Filtering istouch on both
-- killed every tap on iOS outright.
if istouch and love.system.getOS() == "Android" then return end
return Importer:mousepressed(x, y, button)
end
if editorMode and EditorApp.mousepressed then
return EditorApp.mousepressed(x, y, button)
end
@@ -84,7 +84,15 @@ public class GameActivity extends SDLActivity {
// 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).
// Saved/restored across instance state: the picker is a separate activity
// and Android may destroy this one while it is up (memory pressure, or
// "Don't keep activities"). A recreated instance still receives
// onActivityResult, so without this a mod or save pick came back with the
// field reset and was filed as picked_rom.gb, which Lua then rejected as a
// bad ROM instead of installing it (#553).
private String pendingPickFilename = PICKED_ROM_FILENAME;
private static final String STATE_PENDING_PICK = "pendingPickFilename";
private static final String STATE_PENDING_CREATE = "pendingCreateSuggestedName";
// Suggested download name for the in-flight SAF create (set by showCreateDocument).
private String pendingCreateSuggestedName = "export.sav";
private static boolean immersiveActive = false;
@@ -149,6 +157,14 @@ public class GameActivity extends SDLActivity {
}
super.onCreate(savedInstanceState);
if (savedInstanceState != null) {
// Restore the in-flight SAF destinations, so a pick that returns to
// a recreated activity still lands under the basename it asked for.
String pick = savedInstanceState.getString(STATE_PENDING_PICK);
if (pick != null) pendingPickFilename = pick;
String create = savedInstanceState.getString(STATE_PENDING_CREATE);
if (create != null) pendingCreateSuggestedName = create;
}
metrics = getResources().getDisplayMetrics();
// Set low-latency audio values
@@ -539,6 +555,13 @@ public class GameActivity extends SDLActivity {
}
}
@Override
protected void onSaveInstanceState(Bundle outState) {
super.onSaveInstanceState(outState);
outState.putString(STATE_PENDING_PICK, pendingPickFilename);
outState.putString(STATE_PENDING_CREATE, pendingCreateSuggestedName);
}
@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
super.onActivityResult(requestCode, resultCode, data);
+1 -1
View File
@@ -156,7 +156,7 @@ return function(mod)
caughtAreas()[areaKey(self.game, self)] = "DUPES_LOST"
mod.save:set("caught_areas", caughtAreas())
end
Bag.add(self.game.save, ball, 1)
Bag.add(self.game.save, ball, 1, self.game.data)
self:say(reason == "area" and "This area already\nhas a captured POKéMON!"
or "You already have\nthis POKéMON family!")
return
+120 -3
View File
@@ -65,6 +65,10 @@ DEVICE=false
RELEASE=false
PACKAGE_ONLY=false
INSTALL=false
# Last resort for an incomplete source export, mirroring build_android.sh.
MANIFEST_BASE_URL="${MANIFEST_BASE_URL:-https://raw.githubusercontent.com/bryanthaboi/gen1recomp/main}"
MANIFESTS=""
VERSION=""
say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
@@ -229,6 +233,68 @@ apply_ios_branding() {
}
# --------------------------------------------------------------- game.love
# Every version's import manifest has to ship or that game's ROM import fails in
# the built app: decodeManifest (src/import/RomImporter.lua) errors outright when
# one is absent, and dev reads them off the source tree, so the miss only ever
# shows up in a build. iOS shipped without the Yellow one in 0.1.45 to 0.1.47
# for exactly that reason.
#
# The list is READ OUT OF src/core/GameVersion.lua rather than hand-kept here, so
# a fourth version cannot silently ship without its manifest, and a missing file
# is recovered from Git or the project repo the same way build_android.sh already
# recovers Yellow's. Recovery is a last resort for an incomplete source export:
# a manifest carries extraction metadata only, never a ROM or game data.
manifest_paths() {
python3 - "$ROOT/src/core/GameVersion.lua" <<'PY'
import re, sys
src = open(sys.argv[1]).read()
print(" ".join(dict.fromkeys(re.findall(r'manifest\s*=\s*"([^"]+)"', src))))
PY
}
manifest_is_valid() {
python3 - "$1" <<'PY'
import json, pathlib, sys
try:
m = json.loads(pathlib.Path(sys.argv[1]).read_text())
except (OSError, ValueError):
raise SystemExit(1)
sha = m.get("romSha1")
raise SystemExit(0 if isinstance(sha, str) and len(sha) == 40 else 1)
PY
}
ensure_manifests() {
MANIFESTS="$(manifest_paths)"
[ -n "$MANIFESTS" ] \
|| fail "could not read any manifest path out of src/core/GameVersion.lua"
local rel staged
for rel in $MANIFESTS; do
if manifest_is_valid "$ROOT/$rel"; then continue; fi
warn "$rel is missing or invalid; recovering it before packaging"
staged="$(mktemp)"
if git -C "$ROOT" show "HEAD:$rel" > "$staged" 2>/dev/null \
&& manifest_is_valid "$staged"; then
mkdir -p "$ROOT/$(dirname "$rel")"
mv "$staged" "$ROOT/$rel"
say "restored $rel from this checkout's Git data"
continue
fi
if command -v curl >/dev/null 2>&1 \
&& curl --fail --location --retry 2 --connect-timeout 15 \
--output "$staged" "$MANIFEST_BASE_URL/$rel" \
&& manifest_is_valid "$staged"; then
mkdir -p "$ROOT/$(dirname "$rel")"
mv "$staged" "$ROOT/$rel"
say "downloaded $rel from the project repository"
continue
fi
rm -f "$staged"
fail "$rel is unavailable: Git recovery failed and $MANIFEST_BASE_URL/$rel could not be downloaded"
done
say "import manifests: $MANIFESTS"
}
pack_game_love() {
say "packing game.love for love-ios resources"
mkdir -p "$RESOURCES_DIR"
@@ -240,9 +306,10 @@ pack_game_love() {
# it reappears every launch. Mods install as .zips at runtime instead
# (launcher -> MODS -> Import mod .zip), the same lifecycle as every
# other platform.
# shellcheck disable=SC2086 # MANIFESTS is a deliberate word list
(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 \
$MANIFESTS \
-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
@@ -252,8 +319,18 @@ pack_game_love() {
| 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 -x 'tools/save-editor/App.lua' >/dev/null \
|| fail "game.love is missing the save editor (Edit on a save row would crash)"
# Same required-file gate as scripts/build.sh and scripts/build_android.sh.
# iOS only checked App.lua, which is why the Yellow manifest shipped missing
# in 0.1.45 through 0.1.47: decodeManifest (src/import/RomImporter.lua) errors
# outright when a version's manifest is absent, so Import ROM on Yellow died
# in the built app while dev, which reads the source tree, stayed green.
archive_entries="$(unzip -Z1 "$LOVE_FILE")"
# shellcheck disable=SC2086 # MANIFESTS is a deliberate word list
for required in tools/save-editor/App.lua tools/save-editor/Kit.lua \
tools/save-editor/panels/Party.lua $MANIFESTS; do
printf '%s\n' "$archive_entries" | grep -qx "$required" \
|| fail "game.love is missing $required"
done
say "game.love: $(du -h "$LOVE_FILE" | cut -f1) -> $LOVE_FILE"
}
@@ -348,6 +425,43 @@ PY
}
# --------------------------------------------------------------- xcodebuild
# love.system.pickFile and createFile are a native bridge compiled in by
# mobile/ios/patch_love_src.py, not part of LÖVE. A build that skipped the
# patch still links and still runs, then finds the field nil the moment anyone
# taps Import ROM (#482). #539 made that degrade to the copy-into-Files flow
# rather than crash, which is the right floor, but a build with no picker at all
# is a silent downgrade, so fail here instead of shipping one.
#
# Checked against the built binary rather than the source, because patching
# love-src proves nothing about what Xcode actually compiled: the shipped
# 0.1.45/0.1.46/0.1.47 IPAs all DO carry the bridge, so the reports that blamed
# a missing patch step were self-built IPAs, exactly the case this catches.
verify_native_bridge() {
local app="$1"
local exe bin missing=""
exe="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleExecutable' \
"$app/Info.plist" 2>/dev/null || true)"
bin="$app/${exe:-love}"
[ -f "$bin" ] || bin="$app/love"
if [ ! -f "$bin" ]; then
warn "no executable inside $(basename "$app"); skipping native bridge check"
return 0
fi
# grep -q here would race pipefail the same way pack_game_love documents:
# it exits on first match, strings dies of SIGPIPE, the pipeline "fails"
# nondeterministically. >/dev/null keeps grep reading the whole stream.
for sym in pickFile createFile; do
strings -a "$bin" | grep -x "$sym" >/dev/null || missing="$missing $sym"
done
if [ -n "$missing" ]; then
fail "built app has no native bridge (missing:$missing).
Import ROM would fall back to copy-into-Files instead of opening the picker.
mobile/ios/patch_love_src.py did not take. Re-run:
scripts/build_ios.sh --fetch && scripts/build_ios.sh"
fi
say "native bridge present (pickFile, createFile)"
}
run_xcodebuild() {
local config sdk destination
if $RELEASE; then
@@ -455,6 +569,8 @@ run_xcodebuild() {
cp "$LOVE_FILE" "$app/game.love"
fi
verify_native_bridge "$app"
local dist_dir="$DIST/${config}-${sdk}"
rm -rf "$dist_dir"
mkdir -p "$dist_dir"
@@ -526,6 +642,7 @@ install_to_device() {
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"
ensure_manifests
pack_game_love
ensure_game_love_in_xcode
+23
View File
@@ -0,0 +1,23 @@
#!/usr/bin/env bash
# Static analysis for the engine Lua (config: .luacheckrc).
#
# Complements scripts/test.sh: the tests prove behavior, luacheck catches the
# defects that never run in a green test -- undefined globals/locals (the
# class that hid a music.volume crash: a hook read a `state` that was still
# the nil global), unused values, unreachable code. The .luacheckrc mutes the
# cosmetic categories the codebase lives with, so what prints is worth a look.
#
# scripts/lint.sh lint src/
# scripts/lint.sh src tools lint specific paths
#
# Install once with: luarocks install luacheck
set -uo pipefail
cd "$(dirname "$0")/.."
if ! command -v luacheck >/dev/null 2>&1; then
echo "luacheck not found on PATH (install: luarocks install luacheck)" >&2
exit 2
fi
luacheck "${@:-src}"
+6
View File
@@ -93,6 +93,12 @@ function EffectRegistry.runDamaging(battle, ctx, record)
-- Explosion/Selfdestruct still animate on a miss (HandleIfPlayerMoveMissed)
if not (record and record.explode) then battle:cancelMoveAnim() end
battle:sayNext(Strings("%s's\nattack missed!", displayName(user)))
-- MoveHitTest's INVULNERABLE branch sets the same wMoveMissed as a
-- failed accuracy roll (core.asm:5260), and the miss handler still
-- runs the explode effect ("even if Explosion or Selfdestruct
-- missed, its effect still needs to be activated", core.asm:3223),
-- so the user faints against a mid-Fly/Dig target too (#528)
if record and record.onMiss then record.onMiss(ctx, "invulnerable") end
return
end
+5 -5
View File
@@ -14,12 +14,12 @@ local MODULES = {
-- Optional for compatibility with developer and stale caches.
local OPTIONAL = { "audio", "palettes", "icons" }
-- The rules the engine still carries as literals. The constants registry
-- deep-merges over these, so a value has to exist before a mod can patch
-- it; each one is the number the engine hard-codes today, so seeding them
-- changes nothing on a mod-free boot.
-- Vanilla defaults for rules exposed through the constants registry. A
-- value has to exist before a mod can patch it; each one matches the
-- engine's no-mod behavior, so seeding them changes nothing on a vanilla
-- boot.
local CONSTANT_DEFAULTS = {
bagSize = 20, -- BAG_ITEM_CAPACITY (src/inventory/Bag.lua)
bagSize = 20, -- BAG_ITEM_CAPACITY (Bag.capacity fallback)
partyMax = 6, -- PARTY_LENGTH (src/pokemon/Party.lua)
boxCount = 12, boxSize = 20, -- Bill's PC (src/pokemon/Boxes.lua)
moveMax = 4,
+17
View File
@@ -634,6 +634,23 @@ function Game:applyOptions(opts)
-- normalizes a nil/garbage cap to the 60 default, so old saves with no
-- fpsCap key pace at the standard rate (issue #88)
require("src.core.FrameCap").applyOptions(opts)
-- Scale the optional presentation extras to the device's performance
-- tier. Every heavy feature was just applied from the stored options
-- above; here we clamp the *live* state down for a weaker device without
-- rewriting what the player saved, so raising the tier later restores
-- their exact TILT / GBC FX / ZOOM / MAX FPS choices. A HIGH tier (the
-- default on a normal desktop, and every options.lua predating this
-- option) clamps nothing, so it is a no-op for the common case.
local caps = require("src.core.Performance").applyOptions(opts)
if not caps.tilt then require("src.render.Tilt").setLevel(0) end
if not caps.gbcfx then require("src.render.GBCFX").setLevel(0) end
local Zoom = require("src.render.Zoom")
Zoom.allowSurvey = caps.survey
if not caps.survey and Zoom.offset < 0 then Zoom.offset = 0 end
if caps.fpsMax then
local FrameCap = require("src.core.FrameCap")
if FrameCap.current > caps.fpsMax then FrameCap.apply(caps.fpsMax) end
end
Input:applyBindings(opts.bindings)
TouchControls:applyOptions(opts)
-- heal soft-bricked APK installs that already saved gbcfx > 0 (#136)
+8 -1
View File
@@ -23,6 +23,13 @@ local volumeScale = 1
local FILTER_HIGHGAIN = { 0.4, 0.16, 0.064 }
local filterLevel = 0
-- Forward-declared here so applyVolume (below) closes over the real playback
-- state rather than a nil global: the table literal is assigned further down,
-- but a `local state = {}` there would leave every reference above it bound
-- to the global `state`. Before this, registering the `music.volume` mod
-- hook crashed applyVolume on `state.current` (a nil index).
local state
local function applyVolume(src)
if not src then return end
local vol = VOLUME * volumeScale
@@ -63,7 +70,7 @@ local function applyFilter(src)
end
end
local state = {
state = {
current = nil, -- song label
chip = false, -- the playing song is a synthesized channel program
source = nil, -- currently playing source
+156
View File
@@ -0,0 +1,156 @@
-- Graphics performance tier: one knob that scales the port's optional,
-- non-faithful presentation extras down for weaker hardware.
--
-- The heavy extras are all things the Game Boy never had and this port
-- adds on top: the whole-screen 3D TILT (transforms the entire map as a
-- ground plane), the GBC FX post-process shader (a fullscreen pass), and
-- survey ZOOM (zooming out renders the connected neighbor maps -- a lot of
-- extra overdraw). A hard FPS ceiling caps present cost on top of that.
-- None of this touches game logic, which is fixed-step off dt
-- (src/core/FixedStep.lua), so every tier plays identically; they differ
-- only in how much optional eye-candy the renderer is allowed to do.
--
-- The tier is persisted as save.options.performance:
-- auto pick a default from the device (see detect())
-- high everything on -- the historical behavior
-- balanced no TILT, no GBC FX (kept: survey zoom, colors, uncapped FPS)
-- low no TILT, no GBC FX, no survey zoom, FPS capped
--
-- AUTO only chooses the *default* for the current device; every tier is
-- selectable in OPTIONS, so a heuristic that guesses wrong is one row away
-- from being overridden. The clamps are applied live in Game:applyOptions
-- against the stored options without rewriting them, so raising the tier
-- restores exactly the TILT / GBC FX / ZOOM / FPS the player had.
--
-- Zero requires: loads during love.conf and under plain Lua for tools and
-- tests, the same way src/core/GameVersion.lua does.
local Performance = {}
-- Option-row order (auto first, then most to least capable).
Performance.TIERS = { "auto", "high", "balanced", "low" }
Performance.LABELS = {
auto = "AUTO",
high = "HIGH",
balanced = "BALANCED",
low = "LOW",
}
-- What each concrete tier permits. `auto` is resolved to one of these
-- before caps are read, so it has no row here. fpsMax = false means no
-- extra ceiling (the player's own MAX FPS still applies).
Performance.CAPS = {
high = { tilt = true, gbcfx = true, survey = true, fpsMax = false },
balanced = { tilt = false, gbcfx = false, survey = true, fpsMax = false },
low = { tilt = false, gbcfx = false, survey = false, fpsMax = 60 },
}
-- Live resolved tier (never "auto"); Game:applyOptions sets it and the
-- renderer / options row read it back. Defaults to the unclamped tier so
-- a pre-boot launcher behaves exactly as it always did.
Performance.tier = "high"
local function loveOS()
if love and love.system and love.system.getOS then
return love.system.getOS()
end
return nil
end
local function processorCount()
if love and love.system and love.system.getProcessorCount then
return love.system.getProcessorCount()
end
return nil
end
-- CPU architecture via LuaJIT's jit.arch when present: "arm"/"arm64" on
-- phones and PortMaster handhelds, "x86"/"x64" on desktops. nil under a
-- plain-Lua tool or test with no jit table, which resolves to HIGH (no
-- clamping) so tooling is never surprised.
local function cpuArch()
return (jit and jit.arch) or nil
end
-- Default tier for the current device. Deliberately conservative: only
-- the platforms that are reliably weak drop below HIGH, and AUTO is always
-- overridable, so a wrong guess costs one OPTIONS row. A normal
-- multi-core desktop -- the common case, and every existing install whose
-- options.lua predates this option -- resolves to HIGH and behaves exactly
-- as before.
function Performance.detect()
local os = loveOS()
local arch = cpuArch()
local isArm = arch == "arm" or arch == "arm64"
local cores = processorCount()
-- PortMaster-style ARM Linux handhelds (e.g. the RG34XXSP the project
-- already ships a build for): the weakest target here.
if isArm and os ~= "Android" and os ~= "iOS" then
return "low"
end
-- Phones and tablets: GBC FX is already force-disabled here (issue #136);
-- balanced additionally drops the 3D tilt, the heaviest remaining extra.
if os == "Android" or os == "iOS" then
return "balanced"
end
-- Desktop: a dual-core (or single-core) box is the low-end line.
if cores and cores <= 2 then
return "balanced"
end
return "high"
end
-- Fold a stored option value to a concrete tier, resolving "auto" (and any
-- hand-edited garbage) through detect().
function Performance.resolve(value)
if value == "high" or value == "balanced" or value == "low" then
return value
end
return Performance.detect()
end
-- Normalize a stored value to a valid option id (keeps "auto"); a bad value
-- degrades to auto so a corrupt options.lua still lands on something sane.
function Performance.normalize(value)
for _, id in ipairs(Performance.TIERS) do
if value == id then return id end
end
return "auto"
end
-- Row label for a stored value: the tier's own name (AUTO / HIGH / ...).
function Performance.label(value)
return Performance.LABELS[Performance.normalize(value)]
end
-- Concrete caps for a stored option value (resolving auto).
function Performance.caps(value)
return Performance.CAPS[Performance.resolve(value)]
end
-- Resolve the tier for these options, record it live, and return its caps.
-- Called from Game:applyOptions, which clamps the live presentation modules
-- against the returned caps.
function Performance.applyOptions(opts)
local tier = Performance.resolve(opts and opts.performance)
Performance.tier = tier
return Performance.CAPS[tier]
end
-- Cycle the OPTIONS row: auto -> high -> balanced -> low -> auto (dir -1
-- reverses). Lua's `%` is non-negative for a positive modulus, so a
-- negative dir wraps correctly.
function Performance.cycle(value, dir)
value = Performance.normalize(value)
local i = 1
for idx, id in ipairs(Performance.TIERS) do
if id == value then i = idx break end
end
local n = #Performance.TIERS
local nextIdx = (i - 1 + (dir or 1)) % n + 1
return Performance.TIERS[nextIdx]
end
return Performance
+40
View File
@@ -0,0 +1,40 @@
-- Usable window rect for mobile chrome (notch / Dynamic Island / home
-- indicator / Android display cutouts). Wraps love.window.getSafeArea when
-- the engine provides it; otherwise the full graphics window.
--
-- Desktop and headless stubs return the full window, so callers can always
-- layout against this rect without platform branches. Interactive chrome
-- (touch overlay, launcher) should prefer this over getDimensions; the game
-- canvas may still letterbox into the full framebuffer for immersion.
local SafeArea = {}
function SafeArea.rect()
local ww, wh = 0, 0
if love and love.graphics and love.graphics.getDimensions then
ww, wh = love.graphics.getDimensions()
end
if ww <= 0 then ww = 1 end
if wh <= 0 then wh = 1 end
if not (love and love.window and love.window.getSafeArea) then
return 0, 0, ww, wh
end
local x, y, w, h = love.window.getSafeArea()
if type(x) ~= "number" or type(y) ~= "number"
or type(w) ~= "number" or type(h) ~= "number"
or w <= 0 or h <= 0 then
return 0, 0, ww, wh
end
-- Clamp to the drawable window so a bad / mid-rotation backend cannot
-- push layout outside the surface.
x = math.max(0, math.min(x, ww))
y = math.max(0, math.min(y, wh))
w = math.max(1, math.min(w, ww - x))
h = math.max(1, math.min(h, wh - y))
return x, y, w, h
end
return SafeArea
+6 -1
View File
@@ -241,6 +241,11 @@ function SaveData.defaultOptions()
videoMode = "windowed",
-- hard render frame-rate cap; render-only pacing (issue #88, FrameCap.lua)
fpsCap = 60,
-- graphics performance tier: auto | high | balanced | low. "auto"
-- picks a default from the device (ARM handhelds/phones drop the heavy
-- extras); scales TILT / GBC FX / survey ZOOM / FPS but never game
-- logic. See src/core/Performance.lua.
performance = "auto",
-- Per-pipeline display levels, keyed by render_pipelines id (see
-- src/render/Pipelines.lua). A level for a mod that is not installed
-- is kept rather than pruned, so re-enabling the mod restores the mode
@@ -1053,7 +1058,7 @@ local function reclaim(save, data, report)
if type(entry) == "table" and known(data.items, entry.id) then
table.remove(orphaned.items, i)
if entry.from == "pcItems" or type(save.inventory) ~= "table"
or not Bag.add(save, entry.id, entry.count or 1) then
or not Bag.add(save, entry.id, entry.count or 1, data) then
save.pcItems = save.pcItems or {}
save.pcItems[entry.id] = (save.pcItems[entry.id] or 0) + (entry.count or 1)
end
+42 -26
View File
@@ -23,6 +23,7 @@
-- and a player rebind can never detach the overlay.
local Input = require("src.core.Input")
local SafeArea = require("src.core.SafeArea")
local TouchControls = {}
@@ -92,20 +93,23 @@ function TouchControls.normalizeConfig(tc)
return out
end
-- Pure default layout in LOVE units for a given window size. Shared by
-- layout() and the editor's Reset path so defaults stay in one place.
function TouchControls.defaultLayout(ww, wh)
-- Pure default layout in LOVE units for a usable rect of size ww x wh at
-- origin (ox, oy). Shared by layout() and the editor's Reset path so
-- defaults stay in one place. ox/oy default to 0 for the headless tests
-- and for callers that already pass a full-window size.
function TouchControls.defaultLayout(ww, wh, ox, oy)
ox, oy = ox or 0, oy or 0
local short = math.min(ww, wh)
local dpadW = math.min(180, short * 0.34)
local abW = dpadW * 0.46
local ssW = dpadW * 0.30
local margin = dpadW * 0.12
return {
dpad = { cx = margin + dpadW / 2, cy = wh - margin - dpadW / 2, w = dpadW },
a = { cx = ww - margin - abW * 0.55, cy = wh - margin - abW * 1.75, w = abW },
b = { cx = ww - margin - abW * 1.60, cy = wh - margin - abW * 0.55, w = abW },
start = { cx = ww / 2 + ssW * 0.60, cy = wh - margin - ssW * 0.95, w = ssW },
select = { cx = ww / 2 - ssW * 0.60, cy = wh - margin - ssW * 0.95, w = ssW },
dpad = { cx = ox + margin + dpadW / 2, cy = oy + wh - margin - dpadW / 2, w = dpadW },
a = { cx = ox + ww - margin - abW * 0.55, cy = oy + wh - margin - abW * 1.75, w = abW },
b = { cx = ox + ww - margin - abW * 1.60, cy = oy + wh - margin - abW * 0.55, w = abW },
start = { cx = ox + ww / 2 + ssW * 0.60, cy = oy + wh - margin - ssW * 0.95, w = ssW },
select = { cx = ox + ww / 2 - ssW * 0.60, cy = oy + wh - margin - ssW * 0.95, w = ssW },
}
end
@@ -155,6 +159,7 @@ function TouchControls:applyOptions(opts)
self.enabled = cfg.enabled
self.positions = cfg.positions
self.layoutW, self.layoutH = nil, nil
self.layoutOx, self.layoutOy = nil, nil
if not self.enabled then
self.controllerHidden = false
self:reset()
@@ -185,30 +190,37 @@ function TouchControls:visible()
and not self.controllerHidden
end
local function clampZone(zone, ww, wh)
-- Keep a control fully inside the usable rect [x0,y0]..[x1,y1].
local function clampZone(zone, x0, y0, x1, y1)
local half = zone.w * 0.5
zone.cx = math.max(half, math.min(ww - half, zone.cx))
zone.cy = math.max(half, math.min(wh - half, zone.cy))
zone.cx = math.max(x0 + half, math.min(x1 - half, zone.cx))
zone.cy = math.max(y0 + half, math.min(y1 - half, zone.cy))
end
-- Layout in LOVE units (density-independent on mobile), recomputed when
-- the window size changes (rotation, resize). Default: d-pad bottom-left,
-- B/A bottom-right with A above B (the Game Boy diagonal), START/SELECT
-- flanking the bottom center. Custom positions (normalized 0..1) override
-- centers while sizes stay derived from the short edge.
-- the window or safe area changes (rotation, resize, notch insets).
-- Default: d-pad bottom-left, B/A bottom-right with A above B (the Game Boy
-- diagonal), START/SELECT flanking the bottom center -- all inside the
-- device safe area so thumbs clear the home indicator / cutouts.
-- Custom positions (normalized 0..1 within the safe rect) override centers
-- while sizes stay derived from the short edge.
function TouchControls:layout()
local ww, wh = love.graphics.getDimensions()
if self.layoutW == ww and self.layoutH == wh and self.L then return self.L end
self.layoutW, self.layoutH = ww, wh
self.L = TouchControls.defaultLayout(ww, wh)
local ox, oy, sw, sh = SafeArea.rect()
if self.layoutW == sw and self.layoutH == sh
and self.layoutOx == ox and self.layoutOy == oy and self.L then
return self.L
end
self.layoutW, self.layoutH = sw, sh
self.layoutOx, self.layoutOy = ox, oy
self.L = TouchControls.defaultLayout(sw, sh, ox, oy)
if self.positions then
for _, name in ipairs(CONTROLS) do
local p = self.positions[name]
local zone = self.L[name]
if p and zone then
zone.cx = p.x * ww
zone.cy = p.y * wh
clampZone(zone, ww, wh)
zone.cx = ox + p.x * sw
zone.cy = oy + p.y * sh
clampZone(zone, ox, oy, ox + sw, oy + sh)
end
end
end
@@ -222,21 +234,25 @@ function TouchControls:layout()
end
-- Move one control to a screen-space point and persist its normalized
-- position. Used by the layout editor while dragging.
-- position within the safe rect. Used by the layout editor while dragging.
function TouchControls:setControlCenter(name, cx, cy)
local ww, wh = love.graphics.getDimensions()
local ox, oy, sw, sh = SafeArea.rect()
local L = self:layout()
local zone = L[name]
if not zone then return end
zone.cx, zone.cy = cx, cy
clampZone(zone, ww, wh)
clampZone(zone, ox, oy, ox + sw, oy + sh)
self.positions = self.positions or {}
self.positions[name] = { x = zone.cx / ww, y = zone.cy / wh }
self.positions[name] = {
x = sw > 0 and (zone.cx - ox) / sw or 0,
y = sh > 0 and (zone.cy - oy) / sh or 0,
}
end
function TouchControls:clearPositions()
self.positions = nil
self.layoutW, self.layoutH = nil, nil
self.layoutOx, self.layoutOy = nil, nil
end
local function inCircle(zone, x, y, slop)
+1 -1
View File
@@ -214,7 +214,7 @@ function VERBS.give(self, rest)
end
elseif game.data.items and game.data.items[id] then
local n = tonumber(count) or 1
if require("src.inventory.Bag").add(save, id, n) then
if require("src.inventory.Bag").add(save, id, n, game.data) then
self:print(("%s x%d added"):format(id, n))
else
self:print("bag full")
+100 -49
View File
@@ -1,12 +1,34 @@
local GameVersion = require("src.core.GameVersion")
local Strings = require("src.core.Strings")
local HostShell = require("src.core.HostShell")
local SafeArea = require("src.core.SafeArea")
local RomImporter = {}
RomImporter.__index = RomImporter
-- love.system.pickFile is a NATIVE BRIDGE, not part of LÖVE: it exists only on
-- builds that compiled one (Android, and iOS builds patched by
-- mobile/ios/patch_love_src.py). A build without it must fall back to the
-- copy-it-into-the-save-folder flow that every caller below already has --
-- calling the nil field instead took the whole app down the moment the player
-- pressed Import ROM:
--
-- src/import/RomImporter.lua: attempt to call field 'pickFile' (a nil value)
--
-- love.system.createFile was already guarded this way at its one call site;
-- these three were not. Every caller here treats `false` as "no picker
-- available" and shows its own notice, so a missing bridge now degrades to
-- exactly the path a picker-less Android device has always taken.
local function pickFile(...)
local fn = love.system.pickFile
if not fn then return false end
return fn(...) and true or false
end
-- Cache generation tag; bump to force every imported version to re-extract.
local CACHE_FORMAT = "rom-cache-v8:"
-- v9: Yellow audio re-anchored on pokeyellow.sym (#522) -- stale caches
-- carry Red's bank $1f header, wave-table, and CryData offsets.
local CACHE_FORMAT = "rom-cache-v9:"
-- 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"
@@ -553,10 +575,16 @@ function RomImporter.new(onComplete, opts)
onEditTouchControls = opts.onEditTouchControls,
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,
-- One startup poll pass on both mobiles. iOS: files dropped through the
-- Files app are swept into the save dir before Lua boots (GRBootstrap) with
-- no love.focus event necessarily following. Android: the SAF picker is a
-- separate activity, and Android is free to destroy GameActivity while it
-- is up (memory pressure, or "Don't keep activities"), so the app RESTARTS
-- instead of resuming and the love.focus(true) that would have consumed the
-- pick never arrives. The file is sitting in the save dir either way, so
-- boot armed and let the first poll tick consume it, rather than making the
-- player tap Import a second time to trigger the scan by hand (#553).
pickPending = android 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.
@@ -984,7 +1012,7 @@ function RomImporter:chooseMod()
self.modNotice and self.modNotice.ok)
return
end
if not love.system.pickFile("mod") then
if not pickFile("mod") then
self.modNotice = { ok = false,
text = "Could not open the file picker. Copy a mod .zip via USB." }
else
@@ -1047,7 +1075,7 @@ function RomImporter:chooseSaveImport(version)
return
end
self.androidPendingVersion = version
if not love.system.pickFile("sav") then
if not pickFile("sav") then
self.androidPendingVersion = nil
self.saveNotice[version] = { ok = false,
text = "Could not open the file picker. Copy a .sav via USB." }
@@ -1135,7 +1163,7 @@ function RomImporter:choose(version)
self:startData(data, name)
elseif consumePickedRomError(self) then
return -- a rejected pick explains itself instead of silently reopening
elseif not love.system.pickFile() then
elseif not 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
-- error (which would read as a rejected file).
@@ -1181,13 +1209,30 @@ 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.
-- Poll the save dir for a delivered pick (picked_rom.gb / picked_mod.zip /
-- picked_save.sav / export_done.flag) and run the same import path a refocus
-- runs. Both mobiles need this, for different reasons:
--
-- iOS the document picker is an in-process modal sheet, so there is no
-- love.focus(true) when it dismisses -- nothing else would consume it.
-- Android the SAF picker IS a separate activity and normally does refocus,
-- but Android may destroy GameActivity while it is up, in which case
-- the app restarts and that focus event never comes. Polling makes
-- the outcome the same either way instead of leaving the pick on disk
-- for the next tap to find, which is what made users import twice and
-- what made it look random: it depends on memory pressure (#553).
--
-- Deliberately NO timeout. A version of this disarmed the poll after 120s so a
-- cancelled picker would stop scanning, which was wrong on iOS: the picker there
-- is an in-process modal sheet, so update() keeps running while it is open and
-- the window burned down while the player was still browsing Files. The pick
-- then landed with nothing armed to consume it, and because every path here is
-- silent on success the import just did not happen, with no error shown. A
-- half-second directory listing on a menu screen is far cheaper than an import
-- that vanishes, so the poll stays armed until something is actually consumed.
function RomImporter:_pollPickedFiles(dt)
if not (self.ios and self.pickPending) then return end
if not 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
@@ -1247,10 +1292,10 @@ local PAD_DPAD_SPEED = 420
function RomImporter:_activatePadCursor()
if self._padCursorActive then return end
local w, h = love.graphics.getDimensions()
local ox, oy, w, h = SafeArea.rect()
if not self._padInited then
self._padCursor.x = w * 0.5
self._padCursor.y = h * 0.45
self._padCursor.x = ox + w * 0.5
self._padCursor.y = oy + h * 0.45
self._padInited = true
end
self._padCursorActive = true
@@ -1296,11 +1341,11 @@ function RomImporter:_updatePadCursor(dt)
if mag > 1 then dx, dy = dx / mag, dy / mag end
local speed = (math.abs(ax) > PAD_DEAD or math.abs(ay) > PAD_DEAD)
and PAD_SPEED or PAD_DPAD_SPEED
local w, h = love.graphics.getDimensions()
local ox, oy, w, h = SafeArea.rect()
local nx = self._padCursor.x + dx * speed * dt
local ny = self._padCursor.y + dy * speed * dt
self._padCursor.x = math.max(0, math.min(w, nx))
self._padCursor.y = math.max(0, math.min(h, ny))
self._padCursor.x = math.max(ox, math.min(ox + w, nx))
self._padCursor.y = math.max(oy, math.min(oy + h, ny))
end
-- Right stick scrolls the active list (save slots or mods), or the whole page
@@ -1678,7 +1723,10 @@ function RomImporter:_resetFrameRects()
end
function RomImporter:draw()
local width, height = love.graphics.getDimensions()
-- Full window for immersive backdrop; safe rect for interactive chrome so
-- notch / Dynamic Island / home indicator / Android cutouts are respected.
local fullW, fullH = love.graphics.getDimensions()
local ox, oy, width, height = SafeArea.rect()
local s = clamp(height / 768, 0.7, 1.6)
local pulse = self.pulse
self._s = s
@@ -1697,8 +1745,9 @@ function RomImporter:draw()
self._anyHover = false
self:_resetFrameRects()
-- Fonts + size-dependent scenery, rebuilt only when the window size changes.
local fontKey = ("%dx%d"):format(width, height)
-- Fonts + size-dependent scenery, rebuilt only when the window / safe
-- area changes (rotation, resize, inset changes).
local fontKey = ("%dx%d@%d,%d"):format(fullW, fullH, ox, oy)
if self.fontKey ~= fontKey then
self.fontKey = fontKey
local function f(px) return love.graphics.newFont(math.max(8, math.floor(px + 0.5))) end
@@ -1722,10 +1771,10 @@ function RomImporter:draw()
-- Background: a radial gradient (bright navy at top-centre -> near black).
-- A triangle fan from the top-centre gives the radial falloff; the screen
-- is cleared to the outer colour first so the corners it does not reach
-- match seamlessly.
-- match seamlessly. Sized to the full window so unsafe edges stay filled.
do
local cx, cy = width / 2, 0
local rx, ry = width * 1.3, height * 1.08
local cx, cy = fullW / 2, 0
local rx, ry = fullW * 1.3, fullH * 1.08
local n = 72
local verts = { { cx, cy, 0, 0,
PAL.bgTop[1] / 255, PAL.bgTop[2] / 255, PAL.bgTop[3] / 255, 1 } }
@@ -1739,8 +1788,8 @@ function RomImporter:draw()
-- CRT vignette: a gentle edge darkening, centred slightly above the middle.
do
local cx, cy = width / 2, height * 0.45
local rx, ry = width * 0.78, height * 0.78
local cx, cy = fullW / 2, fullH * 0.45
local rx, ry = fullW * 0.78, fullH * 0.78
local n = 72
local verts = { { cx, cy, 0, 0, 0, 0, 0, 0 } }
for i = 0, n do
@@ -1762,7 +1811,7 @@ function RomImporter:draw()
self.scanlineImage:setWrap("repeat", "repeat")
self.scanlineImage:setFilter("nearest", "nearest")
end
self.scanlineQuad = love.graphics.newQuad(0, 0, width, height, 1, 3)
self.scanlineQuad = love.graphics.newQuad(0, 0, fullW, fullH, 1, 3)
end
-- Invert shader: the Boi's Club Games mark is dark ink; on this dark panel it
@@ -1788,21 +1837,23 @@ function RomImporter:draw()
}
]])
-- background
-- background (full window — unsafe edges stay painted)
col(PAL.bgBot)
love.graphics.rectangle("fill", 0, 0, width, height)
love.graphics.rectangle("fill", 0, 0, fullW, fullH)
love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(self.bgMesh)
-- Centered content container (max ~1440 scaled units on very wide windows)
-- with a responsive side gutter; every column below derives from these.
-- Origin is the safe-area top-left so chrome clears device insets.
local appW = math.min(width, 1440 * s)
local appX = (width - appW) / 2
local appX = ox + (width - appW) / 2
local padH = clamp(appW * 0.03, 12 * s, 26 * s)
local third = appW / 3
-- tricolor strip (Red | Blue | Yellow), 6px tall, with a soft downward bloom
local stripH = math.max(4, 6 * s)
local stripY = oy
local segs = {
{ PAL.red, appX, third },
{ PAL.blue, appX + third, third },
@@ -1810,11 +1861,11 @@ function RomImporter:draw()
}
love.graphics.setBlendMode("add")
for _, seg in ipairs(segs) do
fillGrad(seg[2], stripH, seg[3], stripH * 3.6, seg[1], seg[1], 0.30, 0.0)
fillGrad(seg[2], stripY + stripH, seg[3], stripH * 3.6, seg[1], seg[1], 0.30, 0.0)
end
love.graphics.setBlendMode("alpha")
for _, seg in ipairs(segs) do
col(seg[1]); love.graphics.rectangle("fill", seg[2], 0, seg[3], stripH)
col(seg[1]); love.graphics.rectangle("fill", seg[2], stripY, seg[3], stripH)
end
-- Footer (Boi's Club Games logo + trust warning), measured first so the
@@ -1836,7 +1887,7 @@ function RomImporter:draw()
math.min(330 * s, appW - 32 * s))
local logoScale = math.min(logoTargetW / logoW, height * 0.15 / logoH)
local logoDW, logoDH = logoW * logoScale, logoH * logoScale
local logoY = stripH + 14 * s
local logoY = stripY + stripH + 14 * s
-- Tab bar: R/B/Y/divider/MODS chips (label + underline on the active one),
-- with "N of 3 ready" right-aligned.
@@ -1866,7 +1917,7 @@ function RomImporter:draw()
local bannerBand = bannerActive and (bannerH + 20 * s) or 6 * s
local cX = appX + padH
local cW = appW - 2 * padH
local contentBottom = height - footerH - bannerBand
local contentBottom = oy + height - footerH - bannerBand
local cH = math.max(0, contentBottom - contentTop)
-- Page scroll. Everything under the tab bar -- panel, updater banner and
@@ -1877,14 +1928,14 @@ function RomImporter:draw()
-- the previous frame's measurement, the same one-frame settle the slot and
-- mod lists already rely on. While the page fits, `paged` is false and every
-- measurement below is what it always was.
local viewportH = math.max(0, height - contentTop)
local viewportH = math.max(0, oy + height - contentTop)
self._panelNaturalH = self._panelNaturalH or {}
local naturalH = (self._panelNaturalH[self.tab] or 0) + bannerBand + footerH
local paged, pageScroll, maxPage =
RomImporter.pageScrollFor(naturalH, viewportH, self.pageScroll)
self.pageScroll, self._pageMax = pageScroll, maxPage
-- read by the hit tests; a scrolled control is live only inside the viewport
pageBand = paged and { contentTop, height } or nil
pageBand = paged and { contentTop, oy + height } or nil
-- tab bar (rebuilds self.tabRects). Pinned: it is the launcher's navigation,
-- and it sits above the scrolling viewport.
@@ -2055,10 +2106,10 @@ function RomImporter:draw()
-- logo, over the split, with a gentle bob + gold glow + sweeping shine
local bob = math.sin(pulse * (2 * math.pi / 4)) * 6 * s
local lx, ly = (width - logoDW) / 2, logoY + bob
local lx, ly = ox + (width - logoDW) / 2, logoY + bob
love.graphics.setBlendMode("add")
love.graphics.setColor(1, 0.85, 0.2, 0.16 + 0.12 * (0.5 + 0.5 * math.sin(pulse * 1.6)))
love.graphics.draw(self.logo, (width - logoDW * 1.05) / 2, ly - logoDH * 0.025, 0,
love.graphics.draw(self.logo, ox + (width - logoDW * 1.05) / 2, ly - logoDH * 0.025, 0,
logoScale * 1.05, logoScale * 1.05)
love.graphics.setBlendMode("alpha")
local shineW = 0.16
@@ -2091,11 +2142,11 @@ function RomImporter:draw()
-- save-slot rename modal (#205), drawn over everything
if self._rename then
col(PAL.bgBot, 0.72)
love.graphics.rectangle("fill", 0, 0, width, height)
love.graphics.rectangle("fill", 0, 0, fullW, fullH)
local dw = math.min(appW - 32 * s, 420 * s)
local dh = 128 * s
local dx = appX + (appW - dw) / 2
local dy = (height - dh) / 2
local dy = oy + (height - dh) / 2
local rr = 12 * s
neonGlow(dx, dy, dw, dh, rr, PAL.green, 0.4)
fillGradRounded(dx, dy, dw, dh, rr, PAL.slotBg, PAL.slotBg, 0.85, 0.85)
@@ -2137,11 +2188,11 @@ function RomImporter:draw()
-- it is typed.
if self._indexPrompt then
col(PAL.bgBot, 0.72)
love.graphics.rectangle("fill", 0, 0, width, height)
love.graphics.rectangle("fill", 0, 0, fullW, fullH)
local dw = math.min(appW - 32 * s, 520 * s)
local dh = 168 * s
local dx = appX + (appW - dw) / 2
local dy = (height - dh) / 2
local dy = oy + (height - dh) / 2
local rr = 12 * s
neonGlow(dx, dy, dw, dh, rr, PAL.modDot, 0.4)
fillGradRounded(dx, dy, dw, dh, rr, PAL.slotBg, PAL.slotBg, 0.9, 0.9)
@@ -2193,7 +2244,7 @@ function RomImporter:draw()
if self._modConfirm or self._modVersions or self._modReleaseNotes
or self._findDetails then
col(PAL.bgBot, 0.72)
love.graphics.rectangle("fill", 0, 0, width, height)
love.graphics.rectangle("fill", 0, 0, fullW, fullH)
end
if self._modConfirm then
local c = self._modConfirm
@@ -2201,7 +2252,7 @@ function RomImporter:draw()
local lineH = self.hintFont:getHeight() + 4 * s
local dh = 36 * s + (#c.lines) * lineH + 56 * s
local dx = appX + (appW - dw) / 2
local dy = (height - dh) / 2
local dy = oy + (height - dh) / 2
local rr = 12 * s
fillGradRounded(dx, dy, dw, dh, rr, PAL.slotBg, PAL.slotBg, 0.92, 0.92)
love.graphics.setLineWidth(math.max(1, 1.2 * s))
@@ -2243,7 +2294,7 @@ function RomImporter:draw()
local dw = math.min(appW - 32 * s, 480 * s)
local dh = math.min(height - 48 * s, 360 * s)
local dx = appX + (appW - dw) / 2
local dy = (height - dh) / 2
local dy = oy + (height - dh) / 2
local rr = 12 * s
fillGradRounded(dx, dy, dw, dh, rr, PAL.slotBg, PAL.slotBg, 0.92, 0.92)
love.graphics.setLineWidth(math.max(1, 1.2 * s))
@@ -2288,7 +2339,7 @@ function RomImporter:draw()
local dw = math.min(appW - 32 * s, 520 * s)
local dh = math.min(height - 48 * s, 420 * s)
local dx = appX + (appW - dw) / 2
local dy = (height - dh) / 2
local dy = oy + (height - dh) / 2
local rr = 12 * s
fillGradRounded(dx, dy, dw, dh, rr, PAL.slotBg, PAL.slotBg, 0.94, 0.94)
love.graphics.setLineWidth(math.max(1, 1.2 * s))
@@ -2345,7 +2396,7 @@ function RomImporter:draw()
listH = listN * rowH
dh = headerH + listH + footerH
local dx = appX + (appW - dw) / 2
local dy = (height - dh) / 2
local dy = oy + (height - dh) / 2
local rr = 12 * s
fillGradRounded(dx, dy, dw, dh, rr, PAL.slotBg, PAL.slotBg, 0.96, 0.96)
love.graphics.setLineWidth(math.max(1, 1.2 * s))
+23 -7
View File
@@ -1,11 +1,26 @@
-- The 20-slot bag (BAG_ITEM_CAPACITY, constants/menu_constants.asm):
-- a distinct item id occupies one slot regardless of quantity; badges
-- live in the inventory table but are not bag items. save.bagOrder
-- keeps acquisition order like wBagItems (SELECT can reorder it).
-- The bag defaults to 20 slots (BAG_ITEM_CAPACITY,
-- constants/menu_constants.asm), but mods may replace that limit through
-- Data.constants.bagSize. A distinct item id occupies one slot regardless
-- of quantity; badges live in the inventory table but are not bag items.
-- save.bagOrder keeps acquisition order like wBagItems (SELECT can reorder
-- it).
local Bag = {}
Bag.CAPACITY = 20
local DEFAULT_CAPACITY = 20
-- `data` is injectable for the save editor and headless mod tests. Normal
-- gameplay may omit it because the loader merges mods into the Data
-- singleton before any item can be added. The fallback keeps old/stale
-- generated caches and isolated callers at the vanilla limit.
function Bag.capacity(data)
data = data or require("src.core.Data")
local configured = data and data.constants and data.constants.bagSize
if type(configured) == "number" and configured >= 1 then
return math.floor(configured)
end
return DEFAULT_CAPACITY
end
local function isBadge(id)
return id:find("BADGE", 1, true) ~= nil
@@ -55,9 +70,10 @@ end
-- Add qty of an item; returns false (and adds nothing) when a new slot
-- is needed and the bag is full, or when the stack would pass 99
-- (AddItemToInventory's per-slot quantity cap).
function Bag.add(save, id, qty)
function Bag.add(save, id, qty, data)
local inv = save.inventory
if not inv[id] and not isBadge(id) and Bag.slots(save) >= Bag.CAPACITY then
if not inv[id] and not isBadge(id)
and Bag.slots(save) >= Bag.capacity(data) then
return false
end
if not isBadge(id) and (inv[id] or 0) + (qty or 1) > 99 then
+7 -1
View File
@@ -10,7 +10,6 @@
-- "ball" caller must throw it (battle only)
-- "learn", moveId caller must run the learn-move flow
local Pokemon = require("src.pokemon.Pokemon")
local Flags = require("src.script.Flags")
local Strings = require("src.core.Strings")
@@ -433,6 +432,13 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
if battle then
return "failed", { Strings("OAK: %s!\nThis isn't the\ntime to use that!", save.player.name) }
end
-- FishingInit (engine/items/item_effects.asm): cp wWalkBikeSurfState, 2
-- (surfing) sets carry, and every ItemUseXRod does jp c, ItemUseNotTime
-- on that carry -- surfing refuses the rod with the same OAK text as
-- the mid-battle case above, no rod-specific message (#533)
if ow and ow.player and ow.player.surfing then
return "failed", { Strings("OAK: %s!\nThis isn't the\ntime to use that!", save.player.name) }
end
return "fish", itemId
end
+8 -1
View File
@@ -128,7 +128,14 @@ end
-- ------- the link surface
local SPECIES_FIELDS = { "baseStats", "types", "catchRate", "baseExp",
-- catchRate stays out (#511): no link mode ever reads it -- a ball thrown
-- in a link battle is a trainer-battle throw and always refused (pokered
-- engine/items/item_effects.asm ItemUseBall), and the trade rebuild never
-- touches it. Hashing it split Red/Blue from Yellow, whose only link
-- surface delta is the Dragonair/Dragonite catch-rate bytes
-- (data/pokemon/base_stats/dragonair.asm db 45 vs 27, dragonite.asm 45
-- vs 9), when the real cable links R/B/Y freely.
local SPECIES_FIELDS = { "baseStats", "types", "baseExp",
"growthRate", "evolutions" }
local MOVE_FIELDS = { "power", "type", "accuracy", "pp", "effect", "category",
"priority", "highCrit", "fixedDamage", "multiHit",
+9 -12
View File
@@ -178,19 +178,16 @@ end
-- ------- subset negotiation
-- the species and moves this party actually references, so the exchange
-- stays small (six mons) instead of shipping the whole catalog
-- every record hash, not just this party's slice: the receiver filters
-- its OWN party against these (eligibleParty), so a message limited to the
-- sender's party read "not on the other game" for every species the two
-- parties did not happen to share, and a subset trade between different
-- games showed neither side (#511). The full catalog is ~300 short hash
-- strings -- still one message.
function Protocol.recordsMessage(data, party)
local species = Fingerprint.records(data, "pokemon")
local moves = Fingerprint.records(data, "moves")
local outSpecies, outMoves = {}, {}
for _, mon in ipairs(party or {}) do
if species[mon.species] then outSpecies[mon.species] = species[mon.species] end
for _, mv in ipairs(mon.moves or {}) do
if moves[mv.id] then outMoves[mv.id] = moves[mv.id] end
end
end
return { type = "records", pokemon = outSpecies, moves = outMoves }
return { type = "records",
pokemon = Fingerprint.records(data, "pokemon"),
moves = Fingerprint.records(data, "moves") }
end
-- a mon may cross the wire only if both peers rebuild it identically: the
+10 -3
View File
@@ -384,9 +384,16 @@ function Tournament:update(dt)
else
self.pendingBattleOpts.seed = msg.seed
end
local battle, why = self.isHost
and LinkBattle.newHost(self.game, self.net, self.pendingBattleOpts)
or LinkBattle.newGuest(self.game, self.net, self.pendingBattleOpts)
-- Split rather than `cond and newHost() or newGuest()`: the and/or
-- idiom truncates a call to its first result, so the second return
-- (the specific reason) was always dropped and every failure showed
-- the generic fallback instead of "same mods on both games" etc.
local battle, why
if self.isHost then
battle, why = LinkBattle.newHost(self.game, self.net, self.pendingBattleOpts)
else
battle, why = LinkBattle.newGuest(self.game, self.net, self.pendingBattleOpts)
end
if not battle then
self:exitWith(why or Strings("Link battle\ncan't start."))
return
+3 -1
View File
@@ -223,7 +223,9 @@ local function discover()
for _, name in ipairs(fs.getDirectoryItems("mods")) do
local path = "mods/" .. name
local info = fs.getInfo(path)
if info and info.type == "directory" then
-- a dev-linked mod dir (ln -s) reports type "symlink" even with
-- setSymlinksEnabled(true); see the matching note in Loader:_discover.
if info and (info.type == "directory" or info.type == "symlink") then
local raw = fs.read(path .. "/manifest.json")
if raw then
local manifest = decodeManifest(raw, path)
+5 -1
View File
@@ -206,7 +206,11 @@ function Loader:_discover()
for _, name in ipairs(self.fs.getDirectoryItems(root)) do
local path = root .. "/" .. name
local info = self.fs.getInfo(path)
if info and info.type == "directory" then
-- a dev-linked mod dir (ln -s) reports type "symlink" even with
-- setSymlinksEnabled(true) -- PhysFS never resolves the symlink's
-- own getInfo, only traversal into it. readManifest below still
-- correctly no-ops on a symlink that isn't a directory.
if info and (info.type == "directory" or info.type == "symlink") then
local manifest, err = readManifest(self.fs, path)
if manifest then
if self.mods[manifest.id] then
+10
View File
@@ -10,6 +10,13 @@ local Zoom = {}
Zoom.offset = 0
-- Survey zoom (zooming out past FIT, which renders connected neighbor maps)
-- is the port's most expensive optional extra. The performance tier sets
-- this false on LOW hardware (Game:applyOptions); offsetRange then floors
-- the range at FIT so the option row, hotkey, and mouse wheel all stop at
-- close-up. Nil/true keeps the historical full range.
Zoom.allowSurvey = true
-- legal offset range for a given fit scale (vanilla: survey at 1 px/world
-- through 2× fit). zoom.range may widen or shrink the window.
function Zoom.offsetRange(S)
@@ -21,6 +28,9 @@ function Zoom.offsetRange(S)
hi = math.floor(tonumber(hi) or S)
if lo > hi then lo, hi = hi, lo end
end
-- LOW performance tier: no survey (negative offsets), even if a mod's
-- zoom.range widened it. == false so nil/true stays permissive.
if Zoom.allowSurvey == false and lo < 0 then lo = 0 end
return lo, hi
end
+3 -2
View File
@@ -210,11 +210,12 @@ end
-- text (label or literal; {RAM:wStringBuffer} becomes the item name);
-- pass false when the script shows its own received-text row.
function Commands.give_item(ctx, itemId, count, gotText)
-- the 20-slot bag can refuse (BAG_ITEM_CAPACITY): say so and halt
-- the bag can refuse at its configured capacity (20 in vanilla): halt
-- the script, so later set_flag rows don't burn the gift -- make
-- room and talk again, like the original (pokered's `jr nc, .bag_full`
-- skips the received text entirely when AddItemToInventory refuses)
if not require("src.inventory.Bag").add(ctx.save, itemId, count or 1) then
if not require("src.inventory.Bag").add(
ctx.save, itemId, count or 1, ctx.game.data) then
Commands.show_text(ctx, ctx.game.data.text
and ctx.game.data.text._BagFullText or Strings("You can't carry\nany more items!"))
return math.huge
+16
View File
@@ -20,6 +20,7 @@ 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 Performance = require("src.core.Performance")
local Logger = require("src.core.Logger")
local Runtime = require("src.mods.Runtime")
local OptionRows = require("src.ui.OptionRows")
@@ -202,6 +203,21 @@ local function buildRows(game)
require("src.core.Music").setFilterLevel(o.musicFilter)
return true
end },
-- Heads the port's display group: one tier that scales the heavy extras
-- (TILT / GBC FX / survey ZOOM) and the FPS ceiling for weaker devices.
-- AUTO picks a default from the hardware; every tier is overridable.
-- Re-applies live so the extras clamp (or, on a higher tier, restore to
-- the player's stored TILT / GBC FX / ZOOM) the moment the row changes.
{ id = "performance", label = Strings("PERFORMANCE"),
value = function(g)
return Strings(Performance.label(g.save.options.performance))
end,
step = function(g, dir)
local o = g.save.options
o.performance = Performance.cycle(o.performance, dir)
g:applyOptions(o)
return true
end },
{ id = "colors", label = Strings("COLORS"),
value = function(g)
return PaletteFX.modeLabel(g.save.options.colors or "gbc")
+1 -1
View File
@@ -76,7 +76,7 @@ local function withdraw(game)
onChoose = function(item, list)
askQuantity(game, list, pc[item.value] or 1, item.value, function(qty)
local Bag = require("src.inventory.Bag")
if not Bag.add(game.save, item.value, qty) then
if not Bag.add(game.save, item.value, qty, game.data) then
list.footer = Strings("You can't carry\nany more items.")
return
end
+1 -1
View File
@@ -67,7 +67,7 @@ local function buy(game, stock)
list.footer = notEnough
return
end
if not Bag.add(game.save, item.value, qty) then
if not Bag.add(game.save, item.value, qty, game.data) then
list.footer = txt(game, "_PokemartItemBagFullText",
Strings("You can't carry\nany more items."))
return
+13 -11
View File
@@ -109,34 +109,36 @@ function Editor.update(_dt)
end
function Editor.draw()
local ww, wh = love.graphics.getDimensions()
local SafeArea = require("src.core.SafeArea")
local fullW, fullH = love.graphics.getDimensions()
local ox, oy, ww, wh = SafeArea.rect()
local s = math.max(0.75, math.min(1.4, wh / 768))
Editor.rects = {}
-- radial-ish navy field (two stacked fills; matches launcher atmosphere)
col(PAL.bgBot)
love.graphics.rectangle("fill", 0, 0, ww, wh)
love.graphics.rectangle("fill", 0, 0, fullW, fullH)
col(PAL.bgTop, 0.85)
love.graphics.circle("fill", ww * 0.5, wh * 0.15, math.max(ww, wh) * 0.55)
love.graphics.circle("fill", ox + ww * 0.5, oy + wh * 0.15, math.max(ww, wh) * 0.55)
local pad = 18 * s
local barH = 56 * s
local btnH = 40 * s
local btnW = 100 * s
-- top bar
-- top bar (inside the safe area so it clears the notch / status bar)
col(PAL.card, 0.92)
love.graphics.rectangle("fill", 0, 0, ww, barH + pad)
love.graphics.rectangle("fill", 0, 0, fullW, oy + barH + pad)
col(PAL.stroke, 0.35)
love.graphics.setLineWidth(1)
love.graphics.line(0, barH + pad, ww, barH + pad)
love.graphics.line(0, oy + barH + pad, fullW, oy + barH + pad)
love.graphics.setFont(Editor.fonts.title)
col(PAL.white)
love.graphics.print("Touch Controls", pad, pad + 4 * s)
love.graphics.print("Touch Controls", ox + pad, oy + pad + 4 * s)
-- Done / Reset
local done = { x = ww - pad - btnW, y = pad + (barH - btnH) / 2,
local done = { x = ox + ww - pad - btnW, y = oy + pad + (barH - btnH) / 2,
w = btnW, h = btnH }
local reset = { x = done.x - 10 * s - btnW, y = done.y, w = btnW, h = btnH }
Editor.rects.done, Editor.rects.reset = done, reset
@@ -156,9 +158,9 @@ function Editor.draw()
chromeBtn(done, "Done", PAL.green)
-- enable toggle card
local cardY = barH + pad + 14 * s
local cardY = oy + barH + pad + 14 * s
local cardH = 64 * s
local cardX, cardW = pad, ww - 2 * pad
local cardX, cardW = ox + pad, ww - 2 * pad
col(PAL.card, 0.88)
roundRect("fill", cardX, cardY, cardW, cardH, 12 * s)
col(PAL.stroke, 0.4)
@@ -190,7 +192,7 @@ function Editor.draw()
local hint = on
and "Drag each button to reposition. Layout is saved when you tap Done."
or "Controls are hidden in-game. Enable them to show and edit the layout."
love.graphics.printf(hint, pad, cardY + cardH + 12 * s, ww - 2 * pad, "left")
love.graphics.printf(hint, ox + pad, cardY + cardH + 12 * s, ww - 2 * pad, "left")
-- the overlay itself (preview mode; dimmed when disabled)
TouchControls:draw()
+99 -12
View File
@@ -148,6 +148,37 @@ local SPEED_BARS = {
{ 0xE0, 0x80, 4 }, { 0xF0, 0x90, 2 },
}
-- PlayIntroScene's .loop (pokeyellow engine/movie/intro_yellow.asm) ends
-- every iteration with its own `call DelayFrame`, so one scene handler costs
-- one frame plus whatever DelayFrame calls it makes itself. The port runs a
-- handler inside a single update() tick, so every one of those internal
-- DelayFrame calls has to be spent explicitly or the movie runs short of
-- hardware. Three places owe frames:
--
-- SETUP_SCENE_DELAY scenes 2/4/6/8/10/12 open with
-- YellowIntro_BlankPalsDelay2AndDisableLCD: rBGP/rOBP0/
-- rOBP1 to $00 (color 0 in every shade -- the lightest,
-- white on DMG), two DelayFrame calls, then DisableLCD.
-- Two internal plus the loop's own is a 3-frame
-- iteration, after which the next wait scene takes its
-- first timer decrement. Func_f9e9a restores rBGP $e4
-- at the end of the same handler.
-- HEAD_FRAMES PlayIntroScene spends a DelayFrame between
-- InitYellowIntroGFXAndMusic (which ends in PlayMusic)
-- and the first .loop pass, and YellowIntroScene0 then
-- owns a whole iteration of its own.
-- EXIT_FRAMES .go_to_title_screen blanks the palettes and spends
-- four DelayFrame calls clearing the tilemap and OAM
-- before the title screen is built.
--
-- These restore the movie's real elapsed length; they do NOT lengthen the
-- intro song. title.asm's StopAllMusic before MUSIC_TITLE_SCREEN is
-- unconditional (only PikachuCry1 is waited on), so hardware cuts
-- Music_YellowIntro off mid-phrase too (#523).
local SETUP_SCENE_DELAY = 3
local HEAD_FRAMES = 2
local EXIT_FRAMES = 4
-- scene-6 sine (YellowIntro_Copy8BitSineWave.SineWave), signed SCY deltas
local WAVE = { 0, 0, 1, 2, 2, 3, 3, 3, 4, 3, 3, 3, 2, 2, 1, 0,
0, 0, -1, -2, -2, -3, -3, -3, -4, -3, -3, -3, -2, -2, -1, 0 }
@@ -208,6 +239,8 @@ function YellowIntro.new(game, onDone)
self.palName = "MEWMON" -- PalPacket_Generic
self.objects = {}
self.cloudFrame = 0
self.pendingThen = nil -- what enterDelay runs once pendingDelay hits 0 (#523)
self.pendingDelay = 0
self.atlas1 = tryImage("assets/generated/intro/yellow_intro_1.png")
self.atlas2 = tryImage("assets/generated/intro/yellow_intro_2.png")
@@ -559,6 +592,24 @@ function YellowIntro:startScene(scene)
end
end
-- Spend `frames` real frames, then run fn: the port's stand-in for the
-- DelayFrame calls a pokeyellow scene handler makes inside its own
-- PlayIntroScene .loop iteration (#523). fn may be nil to just burn time.
function YellowIntro:enterDelay(frames, fn)
self.pendingDelay = frames
self.pendingThen = fn
end
-- YellowIntro_BlankPalsDelay2AndDisableLCD blanks the palettes for the
-- delay; Func_f9e9a puts rBGP back to $e4 at the tail of the same handler.
function YellowIntro:enterSetupScene(scene)
self.bgp = 0x00
self:enterDelay(SETUP_SCENE_DELAY, function()
self.bgp = 0xE4
self:startScene(scene)
end)
end
function YellowIntro:enter()
if self.pre then return end -- pre-roll first; beginScenes takes over
self:beginScenes()
@@ -571,7 +622,11 @@ function YellowIntro:beginScenes()
local song = songs and (songs.Music_YellowIntro and "Music_YellowIntro"
or songs.Music_IntroBattle and "Music_IntroBattle")
if song then pcall(Music.play, data, song, false) end
self:startScene(0)
-- HEAD_FRAMES: the DelayFrame between PlayMusic and the first .loop pass
-- plus YellowIntroScene0's own iteration. new() already laid down the
-- Func_f9e5f letterbox that InitYellowIntroGFXAndMusic writes, so these
-- frames show what hardware shows: the letterbox with no pika yet.
self:enterDelay(HEAD_FRAMES, function() self:startScene(0) end)
end
-- The movie's exit never stops the song (intro_yellow.asm PlayIntroScene
@@ -586,16 +641,44 @@ function YellowIntro:finish()
if self.onDone then self.onDone() end
end
-- Both scene-loop exits (scene 17 running out, and the A/B/START skip) land
-- on .go_to_title_screen, which calls YellowIntro_BlankPalettes and then
-- spends EXIT_FRAMES DelayFrame calls clearing the tilemap and the OAM
-- buffers before the title screen is built (#523). The pre-roll's own skip
-- is IntroMovie's path, not this one, and still finishes immediately.
function YellowIntro:exitToTitle()
if self.finished or self.exiting then return end
self.exiting = true
self.bgp = 0x00
self:clearObjects()
self:enterDelay(EXIT_FRAMES, function() self:finish() end)
end
function YellowIntro:update(dt)
if self.finished then return end
if self.pre then
self.pre:update(dt)
return
end
-- Ahead of the skip poll on purpose: JoypadLowSensitivity runs once at the
-- top of each .loop iteration, never inside a handler, so hardware is deaf
-- for the DelayFrame calls these ticks stand in for (#523).
if self.pendingDelay > 0 then
self.pendingDelay = self.pendingDelay - 1
if self.pendingDelay == 0 then
local fn = self.pendingThen
self.pendingThen = nil
if fn then fn() end
end
self:updateObjects()
if self.bgDirty then self:rebuildBgCanvas() end
return
end
local input = self.game.input
if input:wasPressed("a") or input:wasPressed("b")
or input:wasPressed("start") then
self:finish()
self:exitToTitle()
return
end
@@ -605,7 +688,7 @@ function YellowIntro:update(dt)
self.timer = self.timer - 1
else
self:clearObjects()
self:startScene(scene + 1)
self:enterSetupScene(scene + 1) -- BlankPalsDelay2AndDisableLCD (#523)
end
elseif scene == 3 then
if self.timer > 0 then
@@ -613,7 +696,7 @@ function YellowIntro:update(dt)
if self.scx ~= 0x68 then self.scx = self.scx + 4 end
else
self:clearObjects()
self:startScene(4)
self:enterSetupScene(4) -- BlankPalsDelay2AndDisableLCD (#523)
end
elseif scene == 7 then
if self.timer > 0 then
@@ -625,7 +708,7 @@ function YellowIntro:update(dt)
self.wave[255] = first
else
self:clearObjects()
self:startScene(8)
self:enterSetupScene(8) -- BlankPalsDelay2AndDisableLCD (#523)
end
elseif scene == 11 then
if self.timer > 0 then
@@ -640,7 +723,7 @@ function YellowIntro:update(dt)
self.timer = self.timer - 1
else
self:clearObjects()
self:startScene(12)
self:enterSetupScene(12) -- BlankPalsDelay2AndDisableLCD (#523)
end
elseif scene == 13 then
if self.timer > 0 then
@@ -656,13 +739,17 @@ function YellowIntro:update(dt)
if v then
self.bgp = v
else
-- .expired: everything despawns, letterbox returns, logo/face
-- object $7 appears for the strobe scene
-- .expired: everything despawns and the letterbox returns, then the
-- handler spends three DelayFrame calls with hAutoBGTransferEnabled
-- pushing that rebuilt tilemap to VRAM before it restores rBGP $e4
-- and spawns the logo/face object $7 for the strobe scene (#523).
self:clearObjects()
self:bgLetterbox()
self.bgp = 0xE4
self:spawn(7, 0x58, 0x58)
self:startScene(15)
self:enterDelay(3, function()
self.bgp = 0xE4
self:spawn(7, 0x58, 0x58)
self:startScene(15)
end)
end
elseif scene == 15 then
if self.timer > 0 then
@@ -687,7 +774,7 @@ function YellowIntro:update(dt)
if self.timer > 0 then
self.timer = self.timer - 1
else
self:finish()
self:exitToTitle()
return
end
end
+65 -11
View File
@@ -360,6 +360,36 @@ function OverworldState:setMap(mapId, x, y, facing, opts)
else
self.player = Player.new(Game.data, x, y, facing)
end
-- boot only: the original persists the surf state. wWalkBikeSurfState
-- (ram/wram.asm) lives inside wMainDataStart..wMainDataEnd, which
-- engine/menus/save.asm block-copies into sMainData on save and back out
-- on load (sram.asm declares sMainData as `ds wMainDataEnd -
-- wMainDataStart`), and Continue never clears it -- the only `xor a /
-- ld [wWalkBikeSurfState], a` on that path is the cable club's. Restore
-- it here, before Music.playMap reads it below and before
-- PikachuFollower.onMapEntered, matching LoadMapData calling
-- LoadPlayerSpriteGraphics ahead of PlayDefaultMusic (home/overworld.asm,
-- home/audio.asm). Without this the player resumed on foot on a water
-- cell, which softlocks here: Collision.canMove (src/world/Collision.lua)
-- picks land tile-pairs whenever mover.surfing is falsy, and land
-- tile-pairs never permit stepping off a water cell (#536). Same
-- boot-only shape as the refreshStandingOnWarp door-mat restore (#378).
if opts and opts.via == "boot" then
local ps = Game.save and Game.save.player
if ps and ps.surfing ~= nil then
self.player.surfing = ps.surfing and true or false
else
-- saves written before #536 carry no flag. Map:isWaterCell alone is
-- not self-sufficient (see src/world/Map.lua: water and shore share
-- one lookup, and no tileset stamps waterTiles, so tile $14 -- a
-- walkable floor in HOUSE/GATE/LOBBY/MANSION/MUSEUM -- reads as
-- water), so gate it the way facingIsShoreOrWater does and require a
-- cell you could not be standing on upright.
self.player.surfing = self:tilesetHasWater()
and not self.map:isWalkableCell(x, y)
and self.map:isWaterCell(x, y)
end
end
-- crossConnection re-arms this after setMap; clear so a warp/reload
-- cannot leave a stale deferred PlayMapMusic pending
self.pendingSeamMusic = nil
@@ -1058,19 +1088,38 @@ function OverworldState:handleInput()
-- OverworldLoop (home/overworld.asm) gates ALL of JoypadOverworld on
-- wWalkCounter == 0 ("if the player sprite has not yet completed the
-- walking animation" it jumps straight to .moveAhead): A, START and
-- direction initiation are only ever looked at while the player stands
-- on a tile, and a button pressed mid-step is simply never seen.
-- Without this gate a mid-step A/START pushed its TextBox/StartMenu
-- right there and froze Red between tiles, mid-animation (#286). Held
-- directions need no buffering -- isDown below picks them up on the
-- landing frame.
if self.player.moving then return end
-- direction initiation are only ever ACTED ON while the player stands on
-- a tile. Without this gate a mid-step A/START pushed its TextBox/
-- StartMenu right there and froze Red between tiles, mid-animation
-- (#286). Held directions need no buffering -- isDown below picks them
-- up on the landing frame.
--
-- The original defers the poll rather than discarding it, though. Joypad
-- (engine/joypad.asm _Joypad) computes hJoyPressed against hJoyLast and
-- advances hJoyLast only when something calls it; the mid-step path never
-- does, and vblank's per-frame ReadJoypad refreshes hJoyInput alone.
-- hJoyLast is frozen for the whole animation, so a button pressed
-- mid-step and STILL HELD when the step lands reads as a fresh press at
-- the next poll -- one released before then is genuinely lost. Dropping
-- the edge outright made START a coin flip on the Cycling Road roll,
-- where the pull below re-arms a step on the single idle frame in
-- bikeStepFrames (#525).
if self.player.moving then
local held = self.joyLatch
if not held then held = {}; self.joyLatch = held end
if input:wasPressed("a") then held.a = true end
if input:wasPressed("start") then held.start = true end
return
end
local latch = self.joyLatch
self.joyLatch = nil
if input:wasPressed("a") then
if input:wasPressed("a") or (latch and latch.a and input:isDown("a")) then
self:interact()
return
end
if input:wasPressed("start") then
if input:wasPressed("start")
or (latch and latch.start and input:isDown("start")) then
require("src.core.Sound").play(Game.data, "Start_Menu")
Screens.push(Game, "StartMenu")
return
@@ -1754,7 +1803,7 @@ function OverworldState:tryHiddenObject(fx, fy)
if h.x == fx and h.y == fy then
save.hiddenTaken = save.hiddenTaken or {}
if save.hiddenTaken[key] then return false end
if not require("src.inventory.Bag").add(save, h.item, 1) then
if not require("src.inventory.Bag").add(save, h.item, 1, Game.data) then
Game.stack:push(TextBox.new(Game, Strings("You can't carry\nany more items!")))
return true
end
@@ -2374,7 +2423,7 @@ function OverworldState:talkTo(npc)
-- (e.g. Blue's House wall Town Map / walking Daisy, #11). Lua treats
-- the string "0" as truthy, so screen it out and fall through to text.
if d.item and d.item ~= "0" and d.item ~= 0 then
if not require("src.inventory.Bag").add(Game.save, d.item, 1) then
if not require("src.inventory.Bag").add(Game.save, d.item, 1, Game.data) then
Game.stack:push(TextBox.new(Game, Strings("You can't carry\nany more items!")))
return
end
@@ -4561,6 +4610,11 @@ function OverworldState:captureSave(save)
save.player.x = self.player.cellX
save.player.y = self.player.cellY
save.player.facing = self.player.facing
-- wWalkBikeSurfState (ram/wram.asm) sits inside the wMainDataStart..
-- wMainDataEnd range engine/menus/save.asm block-copies into sMainData,
-- so the original saves and restores the surf state; setMap's boot path
-- reads this back (#536).
save.player.surfing = self.player.surfing and true or false
end
return OverworldState
+7 -3
View File
@@ -12,9 +12,13 @@ local Player = {}
Player.__index = Player
local STEP_FRAMES = 16
-- a turn in place holds for the ~2 frames the original spends on the
-- extra OverworldLoop pass (home/overworld.asm .handleDirectionButtonPress
-- returns to the loop without moving after a direction change)
-- a turn in place blocks movement for the one extra OverworldLoop pass the
-- original spends after a direction change: .handleDirectionButtonPress ends
-- `jp OverworldLoop` (home/overworld.asm), and OverworldLoop burns two
-- DelayFrame calls before the next JoypadOverworld, so the sample that can
-- commit to a step lands exactly 2 fixed steps after the turn -- the same
-- 2-frames-per-iteration cadence that makes STEP_FRAMES 16 above
-- (wWalkCounter = 8, 2px per AdvancePlayerSprite) (#415)
local TURN_FRAMES = 2
function Player.new(data, cx, cy, facing)
@@ -0,0 +1,99 @@
-- Driver: opening the START menu while the bike force-rolls down Cycling
-- Road (#525). src/world/OverworldController.lua handleInput() used to
-- drop A/START outright while self.player.moving, so a press that landed
-- mid-step and was still held when the step completed vanished -- on
-- Cycling Road's forced roll, where a step re-arms on the single idle
-- frame between steps, that made START a coin flip. The fix latches a
-- still-held press and acts on it on the landing frame instead
-- (see tests/parity_midstep_buttons.lua for the mechanical half).
--
-- POKEPORT_DRIVER=tests/drivers/cycling_road_menu_bug525_test.lua \
-- POKEPORT_IDENTITY=bug525 POKEPORT_TOUCH=0 POKEPORT_VERSION=red \
-- 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 function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
return ok
end
-- Same clear stretch bug255 uses: x=2 is open road from y=8 past y=34.
local MAP, START_X, START_Y = "ROUTE_17", 2, 20
game.save.onBike = true
U.teleport(game, MAP, START_X, START_Y, "down")
U.wait(15)
local ow = game.overworld
check("standing on Cycling Road, on the bike",
ow.map.id == MAP and game.save.onBike)
-- hands off the pad: the slope pulls the bike south on its own
-- (field.forcedMovement.slopeMaps, same mechanism bug255 proved)
U.wait(20)
check("the roll is already under way", ow.player.moving == true
or ow.player.cellY > START_Y)
U.shot(game, DIR .. "/bug525_1_rolling.png")
-- press START mid-step and keep it held through the landing frame, the
-- exact shape a real thumb makes reaching for the menu while rolling
local pressedMidStep = false
local guard = 0
while guard < 60 do
guard = guard + 1
if ow.player.moving and ow.player.progress
and ow.player.progress > 2 and not pressedMidStep then
table.insert(game.input.pressQueue, "start")
game.input.state.start = true
pressedMidStep = true
U.log("pressed START mid-step at progress", ow.player.progress)
end
coroutine.yield()
if getmetatable(game.stack:top()) ~= nil
and game.stack:top() ~= ow and pressedMidStep then
break
end
end
U.wait(2)
game.input.state.start = false
local top = game.stack:top()
U.shot(game, DIR .. "/bug525_2_after_start.png")
check("START held through the landing opened the start menu",
top ~= ow and top ~= nil)
check("the player is not frozen mid-tile (px/py land on a 16px cell)",
ow.player.px % 16 == 0 and ow.player.py % 16 == 0)
-- close it and let the roll continue, proving the pull did not get
-- eaten along with the buffered button
if top ~= ow then
while game.stack:top() ~= ow do
U.tap(game, "b")
U.wait(10)
end
end
local yBeforeResume = ow.player.cellY
U.wait(48)
check("the roll resumes south after closing the menu",
ow.player.cellY > yBeforeResume)
U.shot(game, DIR .. "/bug525_3_resumed.png")
-- park with road left to roll, on the bike, before handing over
U.teleport(game, MAP, START_X, START_Y, "down")
U.wait(10)
game.save.onBike = true
U.log("On the bike on Cycling Road at (" .. START_X .. "," .. START_Y ..
"), rolling south hands-off.")
U.log("Press START right as you see a step land, and again mid-step")
U.log("while releasing before it lands. Right: a START you keep pressed")
U.log("into the landing opens the menu every time, one you let go of")
U.log("mid-step does nothing (same as standing still). #525 was the")
U.log("held case sometimes doing nothing at all, a coin flip on this hill.")
while true do
coroutine.yield()
end
end
@@ -0,0 +1,107 @@
-- Driver: Fighting Dojo Karate Master does not leave his post (#502),
-- the opposite-direction report against the same script #495 fixed
-- (data/scripts/story4.lua dojoMasterGate / M.FIGHTING_DOJO.onStep).
--
-- Before the fix his trainer header used range=4/DOWN sight aggro
-- (src/core/Data.lua seedFightingDojoKarateMaster), so walking up his
-- column from below put him in CheckFightingMapTrainers' generic path:
-- the NPC walks toward the player before the pre-battle text, straight
-- through FIGHTINGDOJO_BLACKBELT3/4's tiles at (5,5)/(5,7) -- the
-- "merges with a pupil" glitch, and it fired regardless of whether the
-- pupils in front of him had been beaten yet ("forces the fight early").
-- The fix sets range=0 and gates him on the single tile at his left
-- (4,3) instead (onStep = dojoMasterGate): he only ever turns to face
-- the player there, never steps.
--
-- POKEPORT_DRIVER=tests/drivers/fighting_dojo_master_bug502_test.lua \
-- POKEPORT_IDENTITY=bug502 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 function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
return ok
end
local function masterIn(ow)
for _, n in ipairs(ow.npcs or {}) do
if n.def and n.def.name == "FIGHTINGDOJO_KARATE_MASTER" then return n end
end
return nil
end
-- No pupil beaten, master not beaten: the exact state the report says
-- triggered the early merge (walking up before clearing the pupils).
game.save.flags = game.save.flags or {}
game.save.flags.EVENT_BEAT_KARATE_MASTER = nil
game.save.defeatedTrainers = {}
------------------------------------------------------------------
-- Direction 1 (#502): stand in the master's own sight column, below
-- him, with his southern pupils (BLACKBELT3 at (5,5), BLACKBELT4 at
-- (5,7)) still in the way -- exactly the "walk towards Karate Master"
-- report. The old range=4/DOWN header put any of these cells in his
-- sight; his first step toward the player collided with BLACKBELT3's
-- tile. Collision blocks a real walk onto an NPC's cell, so probe by
-- teleport (a scripted approach cannot walk through them either) and
-- watch several fixed steps for the master moving or engaging at all.
------------------------------------------------------------------
local ow, master
local function probe(px, py, label)
U.teleport(game, "FIGHTING_DOJO", px, py, "up")
U.wait(10)
ow = game.stack:top()
master = masterIn(ow)
U.shot(game, DIR .. "/bug502_1_" .. label .. "_before.png")
local moved, engaged = false, false
for _ = 1, 40 do
U.wait(1)
if master.cellX ~= 5 or master.cellY ~= 3 then moved = true end
if getmetatable(game.stack:top()) == TextBox then engaged = true; break end
end
U.shot(game, DIR .. "/bug502_1_" .. label .. "_after.png")
check("Fighting Dojo is loaded (" .. label .. ")", ow.map.id == "FIGHTING_DOJO")
check("Karate Master stayed at (5,3), did not walk toward (" .. label .. ")",
not moved)
check("standing at " .. label .. " started no battle on its own",
not engaged)
return moved or engaged
end
-- (5,4): directly below the master, one tile from BLACKBELT3 (5,5).
-- (5,6): the gap between BLACKBELT3 and BLACKBELT4, still in the old
-- sight range and still boxed in by pupils on both sides.
local bad1 = probe(5, 4, "below_master")
local bad2 = probe(5, 6, "between_pupils")
------------------------------------------------------------------
-- Direction 2 (#495, same script): the ONLY tile that should start his
-- fight is one step left of him. Confirm the gate still works so the
-- fix didn't just make him inert in both directions.
------------------------------------------------------------------
U.teleport(game, "FIGHTING_DOJO", 4, 2, "down")
U.wait(10)
ow = game.stack:top()
master = masterIn(ow)
U.shot(game, DIR .. "/bug502_3_gate_before.png")
U.tap(game, "down")
U.wait(30)
local gateOpened = getmetatable(game.stack:top()) == TextBox
U.shot(game, DIR .. "/bug502_4_gate_after.png")
check("stepping onto (4,3) still starts the Master's challenge", gateOpened)
check("he turned to face the player instead of walking to them",
master.cellX == 5 and master.cellY == 3 and master.facing == "left")
U.log("Compare bug502_1/2: the Master's sprite should sit in the same")
U.log("spot in both, never sliding down onto BLACKBELT3 or BLACKBELT4.")
U.log("bug502_4 should show his pre-battle text box, with him still on")
U.log("(5,3), just turned to face left -- that is the correct trigger,")
U.log("distinct from the column walk that used to glitch him south.")
while true do
coroutine.yield()
end
end
+6 -4
View File
@@ -2414,14 +2414,15 @@ local function sellItem(id)
return sold
end
-- Free up bag slots so `needed` NEW item kinds fit (Bag.CAPACITY is 20
-- slots; a stack of an item already held costs nothing). This is why
-- Free up bag slots so `needed` NEW item kinds fit (20 slots without a
-- capacity mod; a stack of an item already held costs nothing). This is why
-- every restock had been reporting "HYPER_POTION x0": the buy list
-- opened, the quantity was set, the engine said "no room" -- and the run
-- walked into the Mansion with FULL_HEALs but not one HP restore.
local function freeBagSlots(needed, where)
local used = #(G.save.bagOrder or {})
local free = 20 - used
local capacity = require("src.inventory.Bag").capacity(G.data)
local free = capacity - used
for _, id in ipairs(SELLABLE_JUNK) do
if free >= needed then break end
if ((G.save.inventory or {})[id] or 0) > 0 then
@@ -2632,7 +2633,8 @@ function ops.shop(s, where)
end
end
local used = #(G.save.bagOrder or {})
if newKinds > 0 and 20 - used < newKinds then
local capacity = require("src.inventory.Bag").capacity(G.data)
if newKinds > 0 and capacity - used < newKinds then
freeBagSlots(newKinds, where)
end
end
@@ -0,0 +1,42 @@
-- Driver: a quick directional tap should turn the player in place, not
-- also take a step (#415). Player:tryMove (src/world/Player.lua) only
-- steps once turnTimer has counted down; TURN_FRAMES/turnFrames set how
-- many fixed steps a facing change blocks movement for before a held
-- direction is allowed to commit to a step. This is an input-timing feel
-- bug: no assertion here can tell a quick tap from a held one the way a
-- human thumb can, so this hands the pad over rather than scripting taps
-- at a fixed, unrealistic frame count.
--
-- POKEPORT_DRIVER=tests/drivers/turn_in_place_bug415_test.lua \
-- POKEPORT_IDENTITY=bug415 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
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
-- REDS_HOUSE_2F: the player's own bedroom, the default new-game spawn
-- (src/core/SaveData.lua bedroom fallback). Small enough that an
-- accidental step is obvious -- a stray step toward the stairs at
-- (7,1)/(7,2) or into the desk/bed furniture is immediately visible.
U.teleport(game, "REDS_HOUSE_2F", 5, 6, "down")
U.wait(10)
local ow = game.stack:top()
check("Red's bedroom is loaded", ow.map.id == "REDS_HOUSE_2F")
check("player starts facing down", ow.player.facing == "down")
U.log("Standing in the middle of the bedroom, facing down.")
U.log("Tap Up once, quickly, then tap Right once, quickly.")
U.log("Right: each tap only turns you to face that way -- your feet")
U.log("stay on the same tile both times. Wrong (#415): a quick tap")
U.log("still slides you one tile in the new direction, same as holding.")
U.log("Now hold Up for a beat: turning into a real step, then a walk,")
U.log("should feel identical to before -- no extra pause was added there.")
while true do
coroutine.yield()
end
end
@@ -0,0 +1,99 @@
-- Manual check that Yellow's bank $1f music headers, wave table and cry
-- data read from the right ROM addresses (#522). Before the fix every
-- header past Music_YellowIntro (a 3-channel header where Red's
-- Music_IntroBattle was 4) was read 3 bytes off, so Viridian Forest's
-- Music_Dungeon2 lost 3 of its 4 channels and its tempo command; every
-- song's channel 3 sampled engine code instead of a wave table.
-- POKEPORT_DRIVER=tests/drivers/yellow_audio_bug522_test.lua POKEPORT_IDENTITY=bug522 POKEPORT_TOUCH=0 POKEPORT_VERSION=yellow love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local ChipSynth = require("src.core.ChipSynth")
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
return ok
end
local audio = game.data.audio or {}
local header = audio.musicHeaders and audio.musicHeaders.Music_Dungeon2
-- pokeyellow.sym: Music_Dungeon2 = 1f:42a9 (bank 31, address 17065)
check("Music_Dungeon2 header reads pokeyellow.sym's address",
header and header.address == 17065 and header.bank == 31)
-- pokeyellow.sym: CryData = 0e:5462 (bank 14, address 21602)
local cry = audio.cryData
check("cryData reads pokeyellow.sym's CryData",
cry and cry.address == 21602 and cry.bank == 14)
-- pokeyellow only has one wave table, Audio1_WavePointers.wave0 = 02:5a26
local waves = audio.waveBanks or {}
local waveOk = true
for _, engine in ipairs({ "1", "2", "3" }) do
local w = waves[engine]
if not (w and w.address == 23078 and w.bank == 2) then waveOk = false end
end
check("all three waveBanks point at Audio1_WavePointers.wave0", waveOk)
-- Rebuild the engine exactly as ChipAudio does and count the channels a
-- misread header would drop to 1 (see headerChannels, ChipSynth.lua).
local engine, engErr
if header then
local ok
ok, engine = pcall(ChipSynth.newEngine, game.data, header)
if not ok then engErr = engine; engine = nil end
end
check("Music_Dungeon2 engine builds" .. (engErr and (": " .. tostring(engErr)) or ""),
engine ~= nil)
check("Music_Dungeon2 has all 4 channels, not the misread header's 1",
engine and #engine.channels == 4)
-- Ch1 opens with the tempo command (dungeon2.asm "Music_Dungeon2_Ch1::
-- tempo 144"); a header pointed at the wrong row drops it and the engine
-- is left at the 0x100 default, ~1.78x slower.
if engine and engine.channels[1] then
engine.channels[1]:nextEvent()
end
check("Ch1's tempo command set engine.tempo to 144, not the 0x100 default",
engine and engine.tempo == 144)
-- pokered data/maps/objects/ViridianForest.asm: the south gate warps land
-- around (16-18, 47); one step north of that is inside the forest proper.
local MAP = "VIRIDIAN_FOREST"
local STAND = { x = 16, y = 46, facing = "up" }
U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing)
local ow = game.overworld
if ow and not ow.map:isWalkableCell(STAND.x, STAND.y) then
local function nearestWalkable()
for dy = -3, 3 do
for dx = -3, 3 do
local cx, cy = STAND.x + dx, STAND.y + dy
if ow.map:isWalkableCell(cx, cy) then return cx, cy end
end
end
return nil
end
local cx, cy = nearestWalkable()
if cx then
U.log("start cell blocked, standing at", cx, cy, "instead")
U.teleport(game, MAP, cx, cy, "up")
end
end
if game.save and game.save.options and game.save.options.musicVol == 0 then
U.log("FAIL musicVol is 0 -- turn it up, this run is silent by design")
end
U.log("Standing in Viridian Forest; Music_Dungeon2 is playing now.")
U.log("Right: four voices at a brisk clip -- lead melody, a counter line")
U.log("underneath it, a soft rounded bass, and a hi-hat tick on top.")
U.log("Wrong (the bug): one thin lonely voice at a noticeably slower,")
U.log("draggy tempo, with no bass or hi-hat at all.")
U.log("Also listen to that bass note's timbre -- it should be a soft")
U.log("rounded triangle-ish tone, not a harsh buzzy rasp (waveBanks).")
while true do
coroutine.yield()
end
end
@@ -0,0 +1,73 @@
-- Manual check that the Yellow intro now runs long enough for
-- Music_YellowIntro's descending run and long final note to be audible
-- before title.asm's unconditional StopAllMusic swaps in the title theme
-- (#523). Never taps a button: Game:load() already pushed YellowIntro,
-- so this driver only watches it play out at real, unaccelerated speed.
-- POKEPORT_SPEED must stay unset -- Music runs on its own real-time 60Hz
-- accumulator (Game:update), decoupled from the fast-forwardable logic
-- clock, so speeding up the driver would desync the exact ordering under
-- test here instead of just playing it back faster.
-- POKEPORT_DRIVER=tests/drivers/yellow_intro_bug523_test.lua POKEPORT_IDENTITY=bug523 POKEPORT_TOUCH=0 POKEPORT_VERSION=yellow love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local Music = require("src.core.Music")
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
return ok
end
check("booted into a state (Yellow intro, per bootScreens)",
game.stack:top() ~= nil)
if game.save and game.save.options and game.save.options.musicVol == 0 then
U.log("FAIL musicVol is 0 -- turn it up, this run is silent by design")
end
local sawIntroSong = false
local sawTitleSong = false
-- Music.lua keeps the current song in a module-local `state` table with
-- no public getter, so watch the one seam that is public: wrap Music.play
-- itself for the life of this driver and log what it's handed. Both the
-- YellowIntro:beginScenes() pcall and title.asm's StopAllMusic-then-
-- PlaySound land here.
local frame = 0
local originalPlay = Music.play
Music.play = function(data, song, loop, ctx)
if song and song:find("Intro") and not sawIntroSong then
sawIntroSong = true
U.log("Music_YellowIntro started")
end
if song == "Music_TitleScreen" and not sawTitleSong then
sawTitleSong = true
U.log("Music_TitleScreen started")
end
return originalPlay(data, song, loop, ctx)
end
-- 40s ceiling counted from driver start, not from the intro song: roughly 500 frames of
-- boot and bootScreens run before Music_YellowIntro begins, and the title swap lands
-- around frame 1680 (28s), so a 20s ceiling expired before the moment under test (#523).
while frame < 40 * 60 and not sawTitleSong do
frame = frame + 1
coroutine.yield()
end
check("Music_YellowIntro started", sawIntroSong)
check("the movie reached the title screen and Music_TitleScreen took over",
sawTitleSong)
U.log("Listen for Music_YellowIntro's ending: a short descending run of")
U.log("notes (F#, F, F#...F#, E, D#, C#) followed by one long low note,")
U.log("right before the title theme cuts in. Before #523 the swap landed")
U.log("early and those descending notes never played -- the track just")
U.log("stopped mid-phrase. The swap itself is still an abrupt cut by")
U.log("design (pokeyellow's title.asm stops the intro song unconditionally,")
U.log("it never waits for it to finish); what changed is how much of the")
U.log("track plays before that cut lands.")
while true do
coroutine.yield()
end
end
+65
View File
@@ -0,0 +1,65 @@
-- Regression: the `music.volume` mod hook must not crash on Music's private
-- `state`. applyVolume (src/core/Music.lua) builds its hook context from
-- state.current/mapSong/onBike/... but was defined above the `local state`
-- table, so those reads bound to the nil global `state` instead -- a mod that
-- registered music.volume hit "attempt to index a nil value (global 'state')"
-- the first time any volume was applied. This drives a file-backed song
-- through Music with the hook installed and asserts the context resolves.
-- ROM-free: a fake audio source, no data/generated/.
-- luajit tests/engine/music_volume_hook_state.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
love = require("tests.love_stub")
-- minimal audio source: only the methods Music calls on a file song
local Source = {}
Source.__index = Source
function Source:play() self.playing = true end
function Source:stop() self.playing = false end
function Source:pause() self.playing = false end
function Source:isPlaying() return self.playing end
function Source:setLooping(v) self.looping = v end
function Source:setVolume(v) self.volume = v end
function Source:setFilter() end
love.audio = {
newSource = function(file) return setmetatable({ file = file }, Source) end,
}
local Runtime = require("src.mods.Runtime")
local Music = require("src.core.Music")
local events = require("src.mods.Events").new()
local hooks = require("src.mods.Hooks").new()
Runtime.install(events, hooks)
-- one file-backed song is enough; the chip path would pull in `bit`
local data = { audio = { songs = { TEST = { file = "test.ogg" } } } }
-- record every context the hook is handed, and scale the volume so the
-- return value is exercised too
local calls = {}
hooks:wrap("music.volume", function(_next, vol, ctx)
calls[#calls + 1] = ctx
return vol * 0.5
end, nil, "voltest")
T.check(Runtime.wantsHook("music.volume"), "the music.volume hook is registered")
-- Before the fix this call raised inside applyVolume; reaching the next line
-- at all is the core of the regression.
Music.play(data, "TEST")
T.check(#calls >= 1, "applyVolume ran the hook during play without crashing")
-- A second application, now that the song is current: state must resolve to
-- the real playback table, so the context carries the live song + scale.
local before = #calls
Music.setVolumeLevel(5)
T.check(#calls > before, "setVolumeLevel re-applies volume through the hook")
local ctx = calls[#calls]
T.check(type(ctx) == "table", "the hook receives a context table")
T.eq(ctx.song, "TEST", "ctx.song is the live song (state resolved, not nil)")
T.eq(ctx.optionScale, 5 / 7, "ctx.optionScale reflects the 0-7 volume level")
T.finish("music_volume_hook_state")
+113
View File
@@ -0,0 +1,113 @@
-- Graphics performance tier (src/core/Performance.lua): the AUTO device
-- heuristic, tier normalization/labels, per-tier caps, the option-row
-- cycle, and applyOptions recording the live tier. Pure logic over
-- stubbed love/jit globals, so it needs no ROM and runs in the T2 tier.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local Performance = require("src.core.Performance")
-- detect() reads the live `love` and `jit` globals; swap them per scenario
-- and restore afterward so nothing leaks between checks (or into luajit).
local realLove, realJit = love, jit
local function device(os, arch, cores)
love = {
system = {
getOS = function() return os end,
getProcessorCount = function() return cores end,
},
}
jit = arch and { arch = arch } or nil
end
local function restore()
love, jit = realLove, realJit
end
-- ---------------------------------------------------------------- detect
device("Linux", "arm64", 4)
T.eq(Performance.detect(), "low", "ARM64 Linux handheld -> low")
device("Linux", "arm", 2)
T.eq(Performance.detect(), "low", "32-bit ARM Linux handheld -> low")
device("Android", "arm64", 8)
T.eq(Performance.detect(), "balanced", "Android phone -> balanced (not low)")
device("iOS", "arm64", 6)
T.eq(Performance.detect(), "balanced", "iOS -> balanced")
device("OS X", "x64", 8)
T.eq(Performance.detect(), "high", "8-core desktop -> high")
device("Windows", "x64", 2)
T.eq(Performance.detect(), "balanced", "dual-core desktop -> balanced")
device("Windows", "x86", 1)
T.eq(Performance.detect(), "balanced", "single-core desktop -> balanced")
-- No love, no jit (a plain-Lua tool/test): nothing looks weak -> high, so
-- tooling that runs applyOptions is never surprised by a clamp.
love, jit = nil, nil
T.eq(Performance.detect(), "high", "no love/jit -> high (no clamping)")
restore()
-- ---------------------------------------------------------------- resolve
T.eq(Performance.resolve("high"), "high", "resolve passes concrete high")
T.eq(Performance.resolve("balanced"), "balanced", "resolve passes balanced")
T.eq(Performance.resolve("low"), "low", "resolve passes low")
device("Linux", "arm64", 4)
T.eq(Performance.resolve("auto"), "low", "resolve auto -> detect (handheld)")
T.eq(Performance.resolve(nil), "low", "resolve nil -> detect")
T.eq(Performance.resolve("bogus"), "low", "resolve garbage -> detect")
restore()
-- --------------------------------------------------------------- normalize
T.eq(Performance.normalize("auto"), "auto", "normalize keeps auto")
T.eq(Performance.normalize("low"), "low", "normalize keeps low")
T.eq(Performance.normalize("xyz"), "auto", "normalize garbage -> auto")
T.eq(Performance.normalize(nil), "auto", "normalize nil -> auto")
-- ------------------------------------------------------------------- label
T.eq(Performance.label("low"), "LOW", "label low")
T.eq(Performance.label("balanced"), "BALANCED", "label balanced")
T.eq(Performance.label(nil), "AUTO", "label nil -> AUTO")
-- -------------------------------------------------------------------- caps
local hi, lo = Performance.CAPS.high, Performance.CAPS.low
T.check(hi.tilt and hi.gbcfx and hi.survey and not hi.fpsMax, "high caps: all on, no fps ceiling")
T.check((not lo.tilt) and (not lo.gbcfx) and (not lo.survey), "low caps: heavy extras off")
T.eq(lo.fpsMax, 60, "low caps: FPS ceiling of 60")
local bal = Performance.CAPS.balanced
T.check((not bal.tilt) and (not bal.gbcfx) and bal.survey and not bal.fpsMax,
"balanced caps: no tilt/gbcfx, survey kept, no fps ceiling")
device("Linux", "arm64", 4)
T.eq(Performance.caps("auto"), Performance.CAPS.low, "caps(auto) resolves through detect")
restore()
-- ------------------------------------------------------------- applyOptions
local caps = Performance.applyOptions({ performance = "low" })
T.eq(caps, Performance.CAPS.low, "applyOptions returns the resolved caps")
T.eq(Performance.tier, "low", "applyOptions records the live tier")
Performance.applyOptions({ performance = "high" })
T.eq(Performance.tier, "high", "applyOptions high tier")
device("Linux", "arm64", 4)
Performance.applyOptions(nil)
T.eq(Performance.tier, "low", "applyOptions(nil) resolves auto to the device tier")
restore()
-- ------------------------------------------------------------------- cycle
T.eq(Performance.cycle("auto", 1), "high", "cycle auto -> high")
T.eq(Performance.cycle("high", 1), "balanced", "cycle high -> balanced")
T.eq(Performance.cycle("balanced", 1), "low", "cycle balanced -> low")
T.eq(Performance.cycle("low", 1), "auto", "cycle low wraps to auto")
T.eq(Performance.cycle("auto", -1), "low", "cycle back auto -> low")
T.eq(Performance.cycle("high", -1), "auto", "cycle back high -> auto")
T.finish("performance_tiers")
+1 -1
View File
@@ -1 +1 @@
9820543f7a223fab
7de3051a7b3c108e
+1 -1
View File
@@ -1 +1 @@
54a52ea81751495c
227fd737aba15763
+8
View File
@@ -203,4 +203,12 @@ stub.mouse = {
stub.timer = { getTime = function() return 0 end }
-- Desktop / headless: full-window safe area (matches LÖVE's fallback).
stub.window = {
getSafeArea = function()
local ww, wh = stub.graphics.getDimensions()
return 0, 0, ww, wh
end,
}
return stub
+16 -14
View File
@@ -282,7 +282,8 @@ local function optGame()
end
local om = OptionsMenu.new(optGame())
local WANT_IDS = { "textSpeed", "animations", "battleStyle", "battleLayout",
"ruleset", "musicVol", "sfxVol", "musicFilter", "colors",
"ruleset", "musicVol", "sfxVol", "musicFilter",
"performance", "colors",
"tilt", "gbcfx", "zoom", "voidFill", "videoMode", "fpsCap",
"speed", "mods", "controls" }
check(#om.rows == #WANT_IDS, "vanilla options row count (plus MODS/CONTROLS)")
@@ -320,35 +321,36 @@ check(om.game.save.options.musicVol == 6, "music volume steps down")
for _ = 1, 10 do om.rows[6].step(om.game, -1) end
check(om.game.save.options.musicVol == 0, "music volume clamps at 0")
-- ZOOM / VOID FILL rows
-- ZOOM / VOID FILL rows (indices shifted +1 by the PERFORMANCE row spliced
-- in ahead of COLORS)
local Zoom = require("src.render.Zoom")
local TileRenderer = require("src.render.TileRenderer")
om.game.save.options.zoom = 0
Zoom.offset = 0
check(om.rows[12].value(om.game) == "FIT", "ZOOM row shows FIT at offset 0")
om.rows[12].step(om.game, 1)
check(om.rows[13].value(om.game) == "FIT", "ZOOM row shows FIT at offset 0")
om.rows[13].step(om.game, 1)
check(om.game.save.options.zoom == 1 and Zoom.offset == 1,
"ZOOM row steps to IN1")
om.rows[13].step(om.game, 1)
om.rows[14].step(om.game, 1)
check(om.game.save.options.voidFill == "water"
and TileRenderer.voidFill == "water",
"VOID FILL row cycles TREES → WATER")
om.rows[13].step(om.game, 1)
om.rows[14].step(om.game, 1)
check(om.game.save.options.voidFill == "black", "VOID FILL steps to BLACK")
om.rows[13].step(om.game, 1)
om.rows[14].step(om.game, 1)
check(om.game.save.options.voidFill == "trees", "VOID FILL wraps to TREES")
-- the MAX FPS row cycles the render-cap steps and shows the value plain
om.game.save.options.fpsCap = nil
check(om.rows[15].value(om.game) == "60",
check(om.rows[16].value(om.game) == "60",
"MAX FPS row defaults to 60 with no saved cap")
om.rows[15].step(om.game, 1)
om.rows[16].step(om.game, 1)
check(om.game.save.options.fpsCap == 75, "MAX FPS steps up from 60 to 75")
check(om.rows[15].value(om.game) == "75", "the MAX FPS row renders the cap")
check(om.rows[16].value(om.game) == "75", "the MAX FPS row renders the cap")
om.game.save.options.fpsCap = 160
om.rows[15].step(om.game, 1)
om.rows[16].step(om.game, 1)
check(om.game.save.options.fpsCap == 30, "MAX FPS wraps past the ceiling to 30")
om.rows[15].step(om.game, -1)
om.rows[16].step(om.game, -1)
check(om.game.save.options.fpsCap == 160, "MAX FPS wraps back down to the ceiling")
-- ------- FrameCap normalize / cycle (issue #88)
@@ -378,7 +380,7 @@ check(FrameCap.current == 60, "FrameCap.applyOptions defaults a missing key to 6
-- the MODS row is the manager's discoverable home
local mgGame = optGame()
om = OptionsMenu.new(mgGame)
om.rows[17].activate(mgGame)
om.rows[18].activate(mgGame)
check(getmetatable(mgGame.stack:top()) == ManagerState,
"the MODS row opens the manager")
check(mgGame.stack:top().screenId == "ManagerState",
@@ -388,7 +390,7 @@ check(mgGame.stack:top().screenId == "ManagerState",
local BindingsMenu = require("src.ui.BindingsMenu")
local cbGame = optGame()
om = OptionsMenu.new(cbGame)
om.rows[18].activate(cbGame)
om.rows[19].activate(cbGame)
local bm = cbGame.stack:top()
check(getmetatable(bm) == BindingsMenu,
"the CONTROLS row opens the rebind list")
+69
View File
@@ -0,0 +1,69 @@
-- T4: constants.bagSize controls the bag through the public mod API while
-- vanilla and existing-save behavior remain unchanged.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Bag = require("src.inventory.Bag")
local CAPACITY_MOD = {
["mods/fix_small_bag/manifest.json"] = [[{
"id": "fix_small_bag",
"name": "Fixture Small Bag",
"version": "1.0.0",
"entry": "main.lua",
"api": 2
}]],
["mods/fix_small_bag/main.lua"] = [[
local mod = ...
mod.content.constants:patch("bagSize", 2)
]],
}
-- No-mod parity: the new lookup keeps the cartridge's 20-slot limit.
do
local data = T.fixtures.fresh()
local run = T.sdk.loadNone({ data = data })
T.eq(#run.errors, 0, "the no-mod baseline loads cleanly")
T.eq(Bag.capacity(data), 20, "the vanilla bag still has 20 slots")
T.eq(Bag.capacity({}), 20, "a stale dataset without bagSize falls back to 20")
run.release()
end
-- The public constants registry changes both the reported and enforced cap.
do
local data = T.fixtures.fresh()
local run = T.sdk.loadMods({ "mods/fix_small_bag" },
{ data = data, fs = T.sdk.memfs(CAPACITY_MOD) })
T.eq(#run.errors, 0, "the capacity mod loads cleanly")
T.eq(Bag.capacity(data), 2, "Bag.capacity reads merged constants.bagSize")
local save = { inventory = {} }
T.check(Bag.add(save, "FIX_POTION", 1, data), "the first item fits")
T.check(Bag.add(save, "FIX_BALL", 1, data), "the second item fits")
T.check(not Bag.add(save, "FIX_TM", 1, data),
"a new item is refused at the modded limit")
T.eq(Bag.slots(save), 2, "a refused add does not change the bag")
run.release()
end
-- Saves are dictionaries, not fixed arrays: lowering the active cap never
-- truncates an older or modded save. Existing stacks remain usable while a
-- new item waits until the player makes enough room.
do
local data = T.fixtures.fresh()
data.constants.bagSize = 1
local save = {
inventory = { FIX_POTION = 1, FIX_BALL = 1 },
bagOrder = { "FIX_POTION", "FIX_BALL" },
}
T.eq(Bag.slots(save), 2, "an over-cap save keeps all existing slots")
T.check(Bag.add(save, "FIX_POTION", 1, data),
"an over-cap save can still add to an existing stack")
T.eq(save.inventory.FIX_POTION, 2, "the existing stack is updated")
T.check(not Bag.add(save, "FIX_TM", 1, data),
"an over-cap save cannot add another item kind")
T.eq(Bag.slots(save), 2, "the compatibility path never drops items")
end
T.finish("bag_capacity")
+130
View File
@@ -0,0 +1,130 @@
-- Regression (#518): without a BICYCLE, the Route 16/18 gate guard must
-- still shove the player one tile aside after refusing them, not leave
-- them parked against the counter.
--
-- pokered's Route16Gate1FGuardScript / Route18Gate1FGuardScript (scripts/
-- Route16Gate1F.asm, Route18Gate1F.asm) print the refusal text, then
-- simulate one PAD_RIGHT step (StartSimulatingJoypadStates) and only clear
-- wJoyIgnore once that step lands. data/scripts/story5.lua's bikeGateGuard
-- walked the player up to the counter and stopped there -- the ported
-- refusal text played, but nothing ever moved the player off the doorway.
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local Data = require("src.core.Data")
if not (Data.maps and Data.maps.ROUTE_16_GATE_1F) then Data:load() end
local Game = require("src.core.Game")
local Input = require("src.core.Input")
local MapLoader = require("src.world.MapLoader")
local Renderer = require("src.render.Renderer")
local SaveData = require("src.core.SaveData")
local StateStack = require("src.core.StateStack")
local OW = require("src.world.OverworldController")
local S = require("tests.harness").suite("parity bike gate shove (#518)")
local check, eq = S.check, S.eq
-- ground truth: data/generated/maps.lua ROUTE_16_GATE_1F.objects, the guard
-- sits at (4,5) behind a solid counter row (row 6); rows 7-10 south of it
-- are the open waiting area bikeGateGuard's coords cover.
local r16 = MapLoader.load(Data, "ROUTE_16_GATE_1F")
check(r16:isWalkableCell(4, 7), "the counter-adjacent stop cell (4,7) is walkable")
check(r16:isWalkableCell(5, 7), "the shove target (5,7) is walkable")
check(not r16:isWalkableCell(4, 6), "row 6 (the counter) blocks straight through")
Game.data = Data
Game.input = Input; Input:init()
Game.renderer = Renderer; Renderer:init()
Game.stack = StateStack; StateStack:init()
-- stub TextBox the way parity_A.lua drives gym leader text: a bare table
-- capturing (text, onDone) so the test can dismiss it without pagination.
local realTB = package.loaded["src.render.TextBox"]
package.loaded["src.render.TextBox"] = {
new = function(_, text, done) return { text = text, onDone = done } end,
}
local mapScripts = require("data.scripts.init")
local hooks = mapScripts.get("ROUTE_16_GATE_1F")
check(hooks and hooks.onStep, "ROUTE_16_GATE_1F has an onStep hook")
-- one 16-frame step per fixed update, same as parity_ledge_seam_hop.lua
local function runFrames(ow, n)
for _ = 1, n do
ow:updateScriptMoves()
ow.player:update()
end
end
-- dismiss the top stubbed box the way TextBox:update does: pop, then onDone
local function dismissTop()
local box = Game.stack:pop()
if box.onDone then box.onDone() end
return box
end
local function standNoBike(x, y, facing)
Game.save = SaveData.newGame()
Game.save.inventory.BICYCLE = nil
while Game.stack:top() do Game.stack:pop() end
Game.stack:push(OW, "ROUTE_16_GATE_1F", x, y, facing or "up")
local ow = Game.stack:top()
ow.player.moving = false
return ow
end
-- --- approaching from three tiles out: walk-up then shove -----------------
do
local ow = standNoBike(4, 10)
local handled = hooks.onStep(Game, ow, ow.player.cellX, ow.player.cellY)
check(handled, "onStep claims the coordinate trigger without a BICYCLE")
local box1 = Game.stack:top()
check(box1 ~= nil, "the wait-up refusal text box opened")
check(box1.text:find("Wait up", 1, true) ~= nil
or box1.text:find("Excuse me", 1, true) ~= nil,
"first box is the guard's stop line")
dismissTop()
local box2 = Game.stack:top()
check(box2 ~= nil, "the second (explanation) box opened")
eq(box2.text, Data.text._Route16Gate1FGuardNoPedestriansAllowedText,
"second box is the guard's no-pedestrians explanation, verbatim")
dismissTop()
check(#ow.scriptMoves > 0 or ow.player.moving,
"closing the explanation queues the walk-up-then-shove movement")
runFrames(ow, 200)
check(not ow.player.moving, "the scripted movement has settled")
eq(#ow.scriptMoves, 0, "no scripted move is left hanging")
eq(ow.player.cellX, 5, "the player ends one tile right of the counter (#518 shove)")
eq(ow.player.cellY, 7, "the player ends on the counter-adjacent row, not further")
end
-- --- already standing at the counter-adjacent row: shove with no walk-up --
do
local ow = standNoBike(4, 7)
local handled = hooks.onStep(Game, ow, ow.player.cellX, ow.player.cellY)
check(handled, "onStep still claims it when already adjacent to the counter")
dismissTop() -- wait-up
dismissTop() -- explanation
runFrames(ow, 200)
check(not ow.player.moving, "the shove-only movement has settled")
eq(#ow.scriptMoves, 0, "no scripted move is left hanging")
eq(ow.player.cellX, 5, "still ends one tile right (#518 shove with dist=0)")
eq(ow.player.cellY, 7, "y is unchanged since there was no walk-up needed")
end
-- --- control: a BICYCLE in the bag lets the trigger pass through untouched
do
local ow = standNoBike(4, 10)
ow.player.moving = false
Game.save.inventory.BICYCLE = true
local handled = hooks.onStep(Game, ow, ow.player.cellX, ow.player.cellY)
check(not handled, "with a BICYCLE, onStep does not intercept the step")
check(Game.stack:top() == ow, "no text box opened when riding a BICYCLE")
end
package.loaded["src.render.TextBox"] = realTB
S.finish()
+72
View File
@@ -0,0 +1,72 @@
-- Parity test: Self-Destruct/Explosion against a mid-Fly/Dig
-- (semi-invulnerable) target still faints the user (#528).
--
-- EffectRegistry.runDamaging's target.invulnerable branch prints the miss
-- text and returned without ever calling record.onMiss, while the
-- accuracy-roll, type-immunity and floored-damage miss branches all did.
-- EXPLODE_EFFECT (src/battle/MoveEffects.lua) relies on onMiss to run
-- battle:selfDestruct(user); skipping it left the user at full HP.
--
-- pokered engine/battle/core.asm MoveHitTest: "bit INVULNERABLE, [hl] /
-- jp nz, .moveMissed" sets the same wMoveMissed as a failed accuracy roll,
-- and the shared miss handler runs the explode effect regardless of which
-- branch set it ("even if Explosion or Selfdestruct missed, its effect
-- still needs to be activated"). JUMP_KICK_EFFECT's onMiss filters on
-- reason == "accuracy", so a crash-damage kick must NOT also fire here.
--
-- Self-contained; run via `luajit tests/parity_explode_invulnerable.lua`.
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local S = require("tests.harness").suite("parity explode invulnerable")
local check, eq = S.check, S.eq
local Data = require("src.core.Data")
Data:load()
local Game = require("src.core.Game")
local Input = require("src.core.Input")
local StateStack = require("src.core.StateStack")
local Pokemon = require("src.pokemon.Pokemon")
local BattleState = require("src.battle.BattleState")
Game.data = Data
Game.input = Input; Input:init()
Game.stack = StateStack; StateStack:init()
Game.save = require("src.core.SaveData").newGame()
-- Self-Destruct against a target mid-Fly (invulnerable, never-miss moves
-- aside): the move never rolls accuracy at all, it just hits the
-- invulnerable branch in EffectRegistry.runDamaging.
local function selfDestructVsInvulnerable(moveId)
Game.save.party = { Pokemon.new(Data, "GEODUDE", 30) }
local b = BattleState.newWild(Game, "PIDGEY", 20)
b.enemy.invulnerable = true -- mid-Fly/Dig, set by ChargeEffect on turn 1
local hpBefore = b.enemy.mon.hp
b:performMove(b.player, b.enemy, { id = moveId, pp = 5 })
return b, hpBefore
end
for _, moveId in ipairs({ "SELFDESTRUCT", "EXPLOSION" }) do
local b, enemyHpBefore = selfDestructVsInvulnerable(moveId)
eq(b.player.mon.hp, 0, moveId .. " vs an invulnerable target still faints the user")
eq(b.enemy.mon.hp, enemyHpBefore,
moveId .. " vs an invulnerable target deals the target no damage")
local sawMiss = false
for _, r in ipairs(b.queue) do
if r.text and r.text:find("attack missed", 1, true) then sawMiss = true end
end
check(sawMiss, moveId .. " still prints the miss text against an invulnerable target")
end
-- Companion: JUMP_KICK's onMiss must reject the "invulnerable" reason, so
-- a mid-Fly target does not also take Jump Kick's crash damage (its
-- onMiss guard only fires for reason == "accuracy").
Game.save.party = { Pokemon.new(Data, "GEODUDE", 30) }
local jk = BattleState.newWild(Game, "PIDGEY", 20)
jk.enemy.invulnerable = true
local jkHpBefore = jk.player.mon.hp
jk:performMove(jk.player, jk.enemy, { id = "JUMP_KICK", pp = 5 })
eq(jk.player.mon.hp, jkHpBefore,
"JUMP KICK vs an invulnerable target takes no crash damage (reason filter holds)")
S.finish()
+102
View File
@@ -0,0 +1,102 @@
-- Parity test: the link fingerprint no longer hashes catchRate, so a
-- Yellow-shaped dataset that differs from Red/Blue only at
-- Dragonair/Dragonite's catch rate digests identical and the handshake
-- reports "full" (#511).
--
-- Real cross-version link play (two processes, Red vs Yellow) is outside
-- what this checkout can assert: it needs both a Red and a Yellow
-- data/generated/ cache and two live UDP peers. What IS assertable from
-- one process: (1) catchRate is excluded from the hashed species surface,
-- so editing only that field never moves the digest, and (2) a synthetic
-- Yellow stand-in built by copying the real Data and retuning just those
-- two species' catchRate (the only real R/B vs Yellow link-surface delta,
-- per data/pokemon/base_stats/dragonair.asm 45 vs 27 and dragonite.asm 45
-- vs 9) fingerprints identically and clears Handshake.checkCompat as
-- "full", not "subset".
--
-- A human still has to run the actual cross-version check: launch a Red
-- instance and a Yellow instance (POKEPORT_VERSION=red / =yellow,
-- distinct POKEPORT_IDENTITY sandboxes), host/join on localhost, and
-- confirm no "game data is not the same" notice appears and a trade goes
-- through both ways.
--
-- Self-contained; run via `luajit tests/parity_link_fingerprint_yellow.lua`.
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local S = require("tests.harness").suite("parity link fingerprint yellow")
local check, eq = S.check, S.eq
local Data = require("src.core.Data")
Data:load()
local Fingerprint = require("src.link.Fingerprint")
local Handshake = require("src.link.Handshake")
local function copy(record)
local out = {}
for k, v in pairs(record) do out[k] = v end
return out
end
local function cloneData(base)
local out = { pokemon = {}, moves = {}, type_chart = base.type_chart,
constants = base.constants }
for id, record in pairs(base.pokemon) do out.pokemon[id] = record end
for id, record in pairs(base.moves) do out.moves[id] = record end
return out
end
-- (1) catchRate alone must not move the digest: this is the field
-- allowlist change at src/link/Fingerprint.lua SPECIES_FIELDS.
local vanilla = cloneData(Data)
local first = Fingerprint.compute(vanilla, {})
local rateOnly = cloneData(Data)
local pidgeyRate = copy(Data.pokemon.PIDGEY)
pidgeyRate.catchRate = (pidgeyRate.catchRate or 0) + 1
rateOnly.pokemon.PIDGEY = pidgeyRate
eq(Fingerprint.compute(rateOnly, {}), first,
"a catchRate-only edit does not move the link fingerprint")
-- (2) a Yellow stand-in: same records, Dragonair/Dragonite catch rate
-- retuned to Yellow's values (the only real R/B vs Yellow link delta).
Fingerprint.forget(vanilla)
local yellowish = cloneData(Data)
if Data.pokemon.DRAGONAIR then
local dragonair = copy(Data.pokemon.DRAGONAIR)
dragonair.catchRate = 27 -- pokeyellow data/pokemon/base_stats/dragonair.asm
yellowish.pokemon.DRAGONAIR = dragonair
end
if Data.pokemon.DRAGONITE then
local dragonite = copy(Data.pokemon.DRAGONITE)
dragonite.catchRate = 9 -- pokeyellow data/pokemon/base_stats/dragonite.asm
yellowish.pokemon.DRAGONITE = dragonite
end
check(Data.pokemon.DRAGONAIR ~= nil and Data.pokemon.DRAGONITE ~= nil,
"fixture has Dragonair and Dragonite to retune")
local yellowPrint = Fingerprint.compute(yellowish, {})
eq(yellowPrint, first,
"a Yellow-shaped dataset (only Dragonair/Dragonite catch rate differs) " ..
"fingerprints identically to Red/Blue")
-- (3) the handshake actually reads "full" for that pairing, not "subset"
local redHello = { protocol = 2, engineVersion = "0.1.0", fingerprint = first }
local yellowHello = { protocol = 2, engineVersion = "0.1.0", fingerprint = yellowPrint }
local verdict, reason = Handshake.checkCompat(redHello, yellowHello)
eq(verdict, "full", "Red vs Yellow-shaped peer clears the handshake as full")
check(reason == nil, "a full verdict carries no mismatch reason")
-- control: a real gameplay-affecting edit (base stats) still splits the
-- two builds, so the fingerprint has not gone toothless
local buffed = cloneData(Data)
local strongPidgey = copy(Data.pokemon.PIDGEY)
strongPidgey.baseStats = copy(Data.pokemon.PIDGEY.baseStats)
strongPidgey.baseStats.attack = strongPidgey.baseStats.attack + 1
buffed.pokemon.PIDGEY = strongPidgey
local buffedPrint = Fingerprint.compute(buffed, {})
check(buffedPrint ~= first, "a baseStats edit still moves the digest")
local buffedHello = { protocol = 2, engineVersion = "0.1.0", fingerprint = buffedPrint }
local verdict2 = Handshake.checkCompat(redHello, buffedHello)
eq(verdict2, "subset", "a genuinely different dataset still reads subset, not full")
S.finish()
+51 -12
View File
@@ -6,17 +6,25 @@
-- is nonzero ("the player sprite has not yet completed the walking
-- animation"), jumps straight to .moveAhead -- JoypadOverworld, and with
-- it the START check, the A check, and every direction initiation, only
-- ever runs while the player stands on a tile. A button pressed mid-step
-- is simply never seen.
-- ever runs while the player stands on a tile.
--
-- The port ran handleInput() every frame regardless of player.moving, so a
-- mid-step A/START press pushed its TextBox/StartMenu right there and
-- froze Red between tiles, mid-animation (#286: running up to Nurse Joy
-- and mashing A stops him half off the tile).
--
-- Second oracle, engine/joypad.asm _Joypad: hJoyPressed is
-- (hJoyLast ^ hJoyInput) & hJoyInput, and hJoyLast only advances on an
-- explicit `call Joypad`. vblank's per-frame ReadJoypad writes hJoyInput
-- alone, and the mid-step path never calls Joypad, so hJoyLast is FROZEN
-- for the whole animation. A button pressed mid-step and still held when
-- the step lands therefore reads as a fresh press at the next poll; one
-- released before the step lands is genuinely lost. The port used to drop
-- both, which on the Cycling Road roll made START a coin flip (#525).
--
-- The invariant: while a step is in progress, A and START change nothing
-- (no TextBox, no StartMenu, the step completes); once the player stands
-- on the tile again, both work.
-- (no TextBox, no StartMenu, the step completes). On the landing frame a
-- still-held A or START is acted on, a released one is not.
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
@@ -52,6 +60,15 @@ local function step(pressedBtn)
ow:update(1 / 60)
end
-- A synthetic pressQueue inject has no source entry, so Input:step sets
-- state[btn] = true and nothing ever clears it (src/core/Input.lua) -- the
-- harness models a HELD button. Most cases below want a tap, so release it
-- explicitly; the held cases are called out where they matter.
local function tap(btn)
step(btn)
Input.state[btn] = false
end
-- start a step south (held direction, like hJoyHeld)
Input.state.down = true
step()
@@ -67,14 +84,14 @@ ow.interact = function(self, ...)
return baseInteract(self, ...)
end
-- mid-step A press: nothing may happen (the original never sees it)
step("a")
-- mid-step A press: nothing may happen (the original acts on nothing here)
tap("a")
eq(interactCalls, 0, "mid-step A never reaches interact()")
check(Game.stack:top() == ow, "mid-step A pushes no TextBox")
check(ow.player.moving, "mid-step A does not interrupt the step")
-- mid-step START press: no start menu either
step("start")
tap("start")
check(Game.stack:top() == ow, "mid-step START opens no menu")
check(ow.player.moving, "mid-step START does not interrupt the step")
@@ -84,7 +101,10 @@ while ow.player.moving and guard < 60 do step(); guard = guard + 1 end
eq(ow.player.cellY, startY + 1, "the step completes onto the next tile")
-- the issue's actual repro ("press A quickly/early" running up to Nurse
-- Joy): start another step and press A on its FINAL mid-step frame
-- Joy): start another step and press A on its FINAL mid-step frame, then
-- RELEASE it before the step lands. hJoyLast is frozen through the
-- animation, so the next poll sees the button already up and computes no
-- edge (engine/joypad.asm) -- this press really is lost.
Input.state.down = true
step()
Input.state.down = false
@@ -93,17 +113,36 @@ guard = 0
while ow.player.moving and guard < 60 do
guard = guard + 1
if guard == (ow.player.stepFramesCur or 16) - 1 then
step("a") -- the last frame before landing
tap("a") -- the last frame before landing, released immediately
else
step()
end
end
check(not ow.player.moving, "the second step completes")
eq(interactCalls, 0, "a last-frame A press is still swallowed (no buffering)")
check(Game.stack:top() == ow, "last-frame A pushes no TextBox")
step() -- the landing frame, where a still-held button would be polled
eq(interactCalls, 0, "a mid-step A released before landing is still lost")
check(Game.stack:top() == ow, "the released last-frame A pushes no TextBox")
-- ...but a mid-step A that is STILL HELD when the step lands is delivered
-- on the landing frame, because hJoyLast never advanced (#525). Nothing
-- happens mid-step either way: the poll is deferred, not the action.
Input.state.down = true
step()
Input.state.down = false
check(ow.player.moving, "third step starts")
step("a") -- pressed mid-step and left held
eq(interactCalls, 0, "the held A still does nothing mid-step")
check(ow.player.moving, "the held A does not interrupt the step")
guard = 0
while ow.player.moving and guard < 60 do step(); guard = guard + 1 end
eq(interactCalls, 0, "still nothing while the step runs out")
step() -- landing frame
eq(interactCalls, 1, "a held mid-step A is polled on the landing frame")
Input.state.a = false
-- standing on the tile again, START and A work as always
step("start")
interactCalls = 0
tap("start")
check(Game.stack:top() ~= ow, "START opens the start menu on a tile")
while Game.stack:top() do Game.stack:pop() end
Game.stack:push(OW, "PALLET_TOWN", 6, 9, "down")
+11 -1
View File
@@ -176,7 +176,14 @@ for _, key in ipairs({ "_SilphCo11FSilphPresidentText",
end
-- run the rows through the real interpreter with only the leaf commands
-- stubbed, so the branch arithmetic is what is under test
-- stubbed, so the branch arithmetic is what is under test. Commands is a
-- shared module singleton read by every later suite dofile'd into this
-- same process (tests/run_tests.lua), so the three stubs must be restored
-- before this file falls off the end -- an unrestored give_item stub
-- silently swallows any later test's item grants, matching the save/
-- restore parity_gift_atomicity.lua already does for show_text.
local origFacePlayer, origShowText, origGiveItem =
Commands.face_player, Commands.show_text, Commands.give_item
local shown, given
Commands.face_player = function() end
Commands.show_text = function(_, key) shown[#shown + 1] = key end
@@ -214,4 +221,7 @@ do
"an unbeaten-Giovanni save still gets the thank-you and the ball")
end
Commands.face_player, Commands.show_text, Commands.give_item =
origFacePlayer, origShowText, origGiveItem
S.finish()
+46
View File
@@ -0,0 +1,46 @@
-- Parity test: all three fishing rods are refused while surfing (#533).
--
-- pokered's FishingInit (engine/items/item_effects.asm) reads
-- wWalkBikeSurfState after IsNextTileShoreOrWater passes, and refuses
-- with carry set when it equals 2 (surfing). Every ItemUseXRod entry does
-- `jp c, ItemUseNotTime` on that carry, so a surfing player gets the same
-- "OAK: %s! This isn't the time to use that!" text used for the
-- mid-battle refusal, and the rod is never consumed. The port's ow.player
-- surf flag was never checked, so BagMenu's isWaterCell check alone let a
-- surfing player fish (the faced cell while surfing is always water).
--
-- Self-contained; run via `luajit tests/parity_surf_rod_refusal.lua`.
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local S = require("tests.harness").suite("parity surf rod refusal")
local check, eq = S.check, S.eq
local Data = require("src.core.Data")
Data:load()
local ItemEffects = require("src.inventory.ItemEffects")
local save = require("src.core.SaveData").newGame()
for _, rod in ipairs({ "OLD_ROD", "GOOD_ROD", "SUPER_ROD" }) do
-- surfing: refused even though ow/player exists and the faced cell
-- would otherwise be fishable water
local surfingOw = { player = { surfing = true } }
local result, msgs = ItemEffects.use(Data, save, rod, nil, false, nil, surfingOw)
eq(result, "failed", rod .. " is refused while surfing")
check(msgs and msgs[1] and msgs[1]:find("isn't the", 1, true) ~= nil,
rod .. " surfing refusal uses the OAK 'not the time' text")
eq(save.inventory[rod], nil, rod .. " is not consumed while surfing")
-- not surfing: still allowed to fish
local groundOw = { player = { surfing = false } }
local r2 = ItemEffects.use(Data, save, rod, nil, false, nil, groundOw)
eq(r2, "fish", rod .. " still fishes normally when not surfing")
-- no overworld handle at all (e.g. called from a context without ow):
-- must not error, and must not silently allow fishing that a surfing
-- player would be refused
local r3 = ItemEffects.use(Data, save, rod, nil, false, nil, nil)
eq(r3, "fish", rod .. " with no ow handle falls through to fish (no surf info available)")
end
S.finish()
+93
View File
@@ -0,0 +1,93 @@
-- Regression (#536): a save made mid-surf must resume mid-surf.
--
-- wWalkBikeSurfState (ram/wram.asm) sits inside wMainDataStart..wMainDataEnd,
-- the range engine/menus/save.asm block-copies into sMainData on save and
-- back out on load, so the original persists the surf state across a save.
-- OverworldState:captureSave now writes save.player.surfing and
-- OverworldState:setMap's boot path (opts.via == "boot", only taken from
-- :enter, which every StateStack push runs through) restores it before
-- Music/PikachuFollower see the player. Before the fix, self.player.surfing
-- was never serialized at all, so a reload always came back on foot;
-- Collision.canMove (src/world/Collision.lua) then picks land tile-pairs for
-- a non-surfing mover, which never permit standing on a water cell, so the
-- reload could softlock on the water tile the save was made on.
--
-- Self-contained; run via `luajit tests/parity_surf_save_load_bug536.lua`.
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local Data = require("src.core.Data")
if not (Data.maps and Data.maps.ROUTE_20) then Data:load() end
local Game = require("src.core.Game")
local Input = require("src.core.Input")
local Renderer = require("src.render.Renderer")
local SaveData = require("src.core.SaveData")
local StateStack = require("src.core.StateStack")
local OW = require("src.world.OverworldController")
local S = require("tests.harness").suite("parity surf save/load (#536)")
local check, eq = S.check, S.eq
Game.data = Data
Game.input = Input; Input:init()
Game.renderer = Renderer; Renderer:init()
Game.stack = StateStack
StateStack:init()
-- ground truth from parity_cinnabar_east_surf.lua: ROUTE_20 (0,8) is water.
local r20 = require("src.world.MapLoader").load(Data, "ROUTE_20")
check(r20:isWaterCell(0, 8), "ROUTE_20 (0,8) is water")
-- --- a save made mid-surf on a water cell resumes surfing --------------
Game.save = SaveData.newGame()
Game.save.player.surfing = true
while Game.stack:top() do Game.stack:pop() end
Game.stack:push(OW, "ROUTE_20", 0, 8, "left")
local ow = Game.stack:top()
eq(ow.player.surfing, true, "boot onto a water cell with surfing=true restores surfing")
-- sprite/movement mode: Player:pose() (src/world/Player.lua) only ever
-- selects surfSprite when self.surfing is truthy, so this is the same
-- switch that put the walking sheet back on a surfing player pre-fix
local sprite = ow.player:pose()
check(sprite == ow.player.surfSprite,
"restored surfing selects the surf sprite sheet, not the walking one")
-- --- a save made on foot on land resumes on foot ------------------------
Game.save = SaveData.newGame()
Game.save.player.surfing = false
while Game.stack:top() do Game.stack:pop() end
Game.stack:push(OW, "PALLET_TOWN", 5, 6, "down")
ow = Game.stack:top()
eq(ow.player.surfing, false, "boot onto land with surfing=false stays on foot")
-- --- saves written before #536 (no surfing field at all) default safely -
Game.save = SaveData.newGame()
Game.save.player.surfing = nil
while Game.stack:top() do Game.stack:pop() end
Game.stack:push(OW, "PALLET_TOWN", 5, 6, "down")
ow = Game.stack:top()
check(not ow.player.surfing, "a pre-#536 save with no surfing field boots on foot, not truthy-nil")
-- --- the true round trip: captureSave -> a fresh boot reads it back back ---
Game.save = SaveData.newGame()
while Game.stack:top() do Game.stack:pop() end
Game.stack:push(OW, "ROUTE_20", 0, 8, "left")
ow = Game.stack:top()
ow.player.surfing = true -- player mounted SURF while already standing on water
local captured = SaveData.newGame()
ow:captureSave(captured)
eq(captured.player.map, "ROUTE_20", "captureSave records the map")
eq(captured.player.x, 0, "captureSave records x")
eq(captured.player.y, 8, "captureSave records y")
eq(captured.player.surfing, true, "captureSave records surfing=true")
-- reload from that captured save: a brand-new boot must come back surfing
Game.save = captured
while Game.stack:top() do Game.stack:pop() end
Game.stack:push(OW, captured.player.map, captured.player.x, captured.player.y,
captured.player.facing)
ow = Game.stack:top()
eq(ow.player.surfing, true,
"round trip: captureSave -> reboot restores surfing on the same water cell")
S.finish()
@@ -0,0 +1,171 @@
-- Regression (#503): the Viridian School House blackboard and notebook
-- were both dead A presses -- data/events/hidden_events.asm's two
-- hidden_text_predef rows for this map never reached data/generated/
-- field.lua (tools/extract/field.py only parses `hidden_event` rows), and
-- there was no onInteract hook for this map at all. Same shape as the
-- Celadon Mansion roof house fix (#391, tests/parity_celadon_roof_
-- readables.lua): ViridianSchoolBlackboard (engine/events/hidden_events/
-- school_blackboard.asm) prints the intro, then loops a "which heading"
-- prompt and a 5-status menu (SLP/PSN/PAR/BRN/FRZ) plus QUIT until B/QUIT
-- closes it; ViridianSchoolNotebook (school_notebooks.asm) is a 5-page
-- book that asks to turn pages 1-3, auto-turns 4, and the girl catches you
-- on page 5.
--
-- Self-contained; run via
-- `luajit tests/parity_viridian_school_blackboard_bug503.lua`.
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local Data = require("src.core.Data")
if not (Data.maps and Data.maps.VIRIDIAN_SCHOOL_HOUSE) then Data:load() end
local Font = require("src.render.Font")
if not pcall(Font.encode, "A") then Font.load(Data) end
require("data.scripts.init")
local MapScripts = require("src.script.MapScripts")
local TextBox = require("src.render.TextBox")
local Menu = require("src.ui.Menu")
local S = require("tests.harness").suite("parity Viridian School blackboard/notebook (#503)")
local check, eq = S.check, S.eq
local MAP = "VIRIDIAN_SCHOOL_HOUSE"
for _, key in ipairs({
"_ViridianSchoolBlackboardText1", "_ViridianSchoolBlackboardText2",
"_ViridianBlackboardSleepText", "_ViridianBlackboardPoisonText",
"_ViridianBlackboardPrlzText", "_ViridianBlackboardBurnText",
"_ViridianBlackboardFrozenText",
"_ViridianSchoolNotebookText1", "_ViridianSchoolNotebookText5",
"_TurnPageText" }) do
check(type(Data.text[key]) == "string" and Data.text[key] ~= "",
key .. " is extracted")
end
local hooks = MapScripts.get(MAP)
check(hooks and type(hooks.onInteract) == "function",
MAP .. " registers onInteract for the two readables")
local stack = {}
local game = {
data = Data,
save = { player = { name = "RED" } },
stack = {
push = function(_, state) stack[#stack + 1] = state end,
pop = function() return table.remove(stack) end,
top = function() return stack[#stack] end,
},
}
local ow = { player = { facing = "up" } }
local function pages(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, "\n")
end
-- cells that are neither the blackboard (3,0) nor the notebook (3,4) stay silent
for _, cell in ipairs({ { 4, 0 }, { 3, 1 }, { 4, 4 }, { 3, 3 }, { 2, 0 } }) do
eq(hooks.onInteract(game, ow, cell[1], cell[2]), false,
("(%d,%d) is not one of the readables"):format(cell[1], cell[2]))
end
-- === the blackboard: intro -> prompt -> 6-item menu (5 statuses + QUIT) ===
stack = {}
eq(hooks.onInteract(game, ow, 3, 0), true, "the blackboard at (3,0) is claimed")
local intro = stack[#stack]
check(getmetatable(intro) == TextBox, "the blackboard opens a TextBox")
check(pages(intro):find("STATUS", 1, true) ~= nil,
"the intro is _ViridianSchoolBlackboardText1 (describes STATUS changes)")
check(type(intro.onDone) == "function", "the intro has a continuation")
intro.onDone()
local prompt = stack[#stack]
check(getmetatable(prompt) == TextBox
and pages(prompt):find("heading", 1, true) ~= nil,
"the intro leads into the which-heading prompt")
prompt.onDone()
local menu = stack[#stack]
check(getmetatable(menu) == Menu, "the prompt opens the status menu")
eq(#menu.items, 6, "five statuses plus QUIT")
local labels = {}
for i, item in ipairs(menu.items) do labels[i] = (item.label or ""):gsub("^%s+", "") end
eq(table.concat(labels, "/"), "SLP/PSN/PAR/BRN/FRZ/QUIT",
"the statuses read SLP/PSN/PAR/BRN/FRZ, QUIT last")
eq(menu.items[6].onSelect, nil, "QUIT has no onSelect; Menu's own B/pop closes it")
local STATUS_KEYS = {
"_ViridianBlackboardSleepText", "_ViridianBlackboardPoisonText",
"_ViridianBlackboardPrlzText", "_ViridianBlackboardBurnText",
"_ViridianBlackboardFrozenText",
}
for i, key in ipairs(STATUS_KEYS) do
stack = {}
menu.items[i].onSelect()
local blurb = stack[#stack]
check(getmetatable(blurb) == TextBox, labels[i] .. " prints a text box")
local want = Data.text[key]:match("^[^\n\011\012]+")
check(pages(blurb):find(want, 1, true) ~= nil,
labels[i] .. " prints " .. key)
check(type(blurb.onDone) == "function",
labels[i] .. " returns to the prompt instead of dropping out (loops)")
blurb.onDone()
check(getmetatable(stack[#stack]) == TextBox,
"the prompt comes back after " .. labels[i])
stack[#stack].onDone()
check(getmetatable(stack[#stack]) == Menu,
"the menu comes back after " .. labels[i] .. " (loop, not exit)")
end
-- === the notebook: five pages, three yes/no turns, then the girl catches you ==
stack = {}
eq(hooks.onInteract(game, ow, 3, 4), true, "the notebook at (3,4) is claimed")
local page1 = stack[#stack]
check(getmetatable(page1) == TextBox, "the notebook opens on page 1")
check(pages(page1):find("POKé BALL", 1, true) ~= nil,
"page 1 is _ViridianSchoolNotebookText1")
-- pages 1-3 ask "Turn the page?" and only turn on yes. The real ChoiceBox
-- pops the asking TextBox itself before invoking .choice (TextBox.lua:228-
-- 235); mirror that pop-then-call order here.
local function resolveChoice(box, yes)
table.remove(stack) -- the ChoiceBox pops the asking TextBox first
box.choice(yes)
end
page1.onDone()
local ask1 = stack[#stack]
check(getmetatable(ask1) == TextBox and ask1.choice ~= nil,
"page 1 asks _TurnPageText with a yes/no choice")
resolveChoice(ask1, true)
local page2 = stack[#stack]
check(pages(page2):find("weaken", 1, true) ~= nil, "yes turns to page 2")
page2.onDone()
resolveChoice(stack[#stack], true)
local page3 = stack[#stack]
check(pages(page3):find("engage", 1, true) ~= nil, "page 3 follows page 2")
page3.onDone()
resolveChoice(stack[#stack], true)
local page4 = stack[#stack]
check(pages(page4):find("ELITE FOUR", 1, true) ~= nil, "page 4 follows page 3")
-- page 4 turns automatically into page 5, no prompt
page4.onDone()
local page5 = stack[#stack]
check(getmetatable(page5) == TextBox, "page 4 auto-turns")
check(pages(page5):find("GIRL", 1, true) ~= nil,
"page 5 is the girl catching you reading her notes")
-- saying no on page 1 stops the read right there: no further page pushed
stack = {}
hooks.onInteract(game, ow, 3, 4)
stack[#stack].onDone()
local askAgain = stack[#stack]
local before = #stack
resolveChoice(askAgain, false)
eq(#stack, before - 1,
"saying no to the first turn pops the ask box and pushes nothing else")
S.finish()
+97
View File
@@ -0,0 +1,97 @@
-- Regression (#535): after handing over the GOLD TEETH and receiving
-- HM04, every later talk to the Warden must still say something.
--
-- data/scripts/story.lua's TEXT_WARDENSHOUSE_WARDEN pointed the
-- EVENT_GOT_HM04 branch (row 3, jump_if_true) at the same silent-end jump
-- the give-then-thank fallthrough uses (row 13), so ScriptRunner's pc ran
-- straight past the end of the row list with zero show_text calls -- the
-- Warden went mute on every visit after the trade. pokered's .got_item
-- branch (scripts/WardensHouse.asm) instead prints .HM04ExplanationText
-- (text/WardensHouse.asm: "HM04 teaches STRENGTH ... SECRET HOUSE in
-- SAFARI ZONE") on every subsequent talk.
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local Data = require("src.core.Data")
if not (Data.maps and Data.maps.PALLET_TOWN) then Data:load() end
local S = require("tests.harness").suite("parity wardens house (#535)")
local check, eq = S.check, S.eq
local Commands = require("src.script.Commands")
local Flags = require("src.script.Flags")
local Game = require("src.core.Game")
local Input = require("src.core.Input")
local SaveData = require("src.core.SaveData")
local ScriptRunner = require("src.script.ScriptRunner")
local StateStack = require("src.core.StateStack")
Game.data = Data
Game.input = Input; Input:init()
Game.stack = StateStack; StateStack:init()
require("src.render.Font").load(Data)
local story = require("data.scripts.story")
local script = story.WARDENS_HOUSE.talk.TEXT_WARDENSHOUSE_WARDEN
-- instrument show_text the way parity_gift_atomicity.lua does, to record
-- exactly which text ids actually printed
local shown = {}
local origShow = Commands.show_text
Commands.show_text = function(ctx, textId, subs)
shown[#shown + 1] = textId
return origShow(ctx, textId, subs)
end
local function runScript()
shown = {}
StateStack:init()
local ow = { map = { id = "WARDENS_HOUSE", def = { label = "WardensHouse" } },
npcs = {}, entities = {} }
local r = ScriptRunner.new(Game, ow)
r:run(script, { npc = { def = {}, facePlayer = function() end },
overworld = ow })
local guard = 0
while r:isRunning() and guard < 3000 do
guard = guard + 1
Input.pressed = { a = true }
StateStack:update(1 / 60)
r:update()
end
Input.pressed = {}
return not r:isRunning()
end
-- === 1) first talk, holding the GOLD TEETH: gives HM04, sets the flag ===
Game.save = SaveData.newGame()
Game.save.inventory.GOLD_TEETH = 1
check(runScript(), "give-the-teeth talk completes")
eq(table.concat(shown, ","),
"_WardensHouseWardenGaveTheGoldTeethText,_WardensHouseWardenThanksText,"
.. "_WardensHouseWardenReceivedHM04Text",
"handing over the teeth shows the give/thanks/received sequence, nothing after")
check(Flags.get(Game.save, "EVENT_GOT_HM04"), "EVENT_GOT_HM04 is set")
check(Flags.get(Game.save, "EVENT_GAVE_GOLD_TEETH"), "EVENT_GAVE_GOLD_TEETH is set")
check(Game.save.inventory.HM_STRENGTH ~= nil, "HM04 (Strength) lands in the bag")
check(not Game.save.inventory.GOLD_TEETH, "the GOLD TEETH is taken")
-- === 2) the regression itself: every later talk, once EVENT_GOT_HM04 is
-- set, must print the explanation text instead of nothing ===
check(runScript(), "post-gift talk completes")
eq(table.concat(shown, ","), "_WardensHouseWardenHM04ExplanationText",
"every subsequent talk now prints the HM04/Safari Zone explanation (#535)")
-- run it again to confirm this is not a one-shot: it repeats every visit
check(runScript(), "a third talk completes")
eq(table.concat(shown, ","), "_WardensHouseWardenHM04ExplanationText",
"the explanation text repeats on every later talk, not just the first")
-- === 3) unrelated path is unchanged: no GOLD TEETH, no flag yet ===
Game.save = SaveData.newGame()
check(runScript(), "empty-handed talk completes")
eq(table.concat(shown, ","), "_WardensHouseWardenGibberish1Text",
"without the GOLD TEETH the Warden's gibberish line is unchanged")
check(not Flags.get(Game.save, "EVENT_GOT_HM04"), "no HM04 yet")
Commands.show_text = origShow
S.finish()
+160
View File
@@ -0,0 +1,160 @@
-- #553: "App makes user import both game and/or mods twice before installing."
--
-- Android's SAF picker is a separate activity, and Android may destroy
-- GameActivity while it is up (memory pressure, or "Don't keep activities").
-- When that happens the app RESTARTS rather than resuming, so the
-- love.focus(true) that RomImporter:focus consumes a pick on never arrives.
-- GameActivity has already written picked_rom.gb / picked_mod.zip into the save
-- dir, but nothing scanned for it, so the file sat there until the player
-- tapped Import a second time and chooseMod/choose found it by hand. Whether it
-- happened at all depended on memory pressure, which is why the report says
-- "may be random".
--
-- _pollPickedFiles was the fix that already existed, gated to iOS. These checks
-- pin it armed on Android too, and pin the timeout that keeps a cancelled
-- picker from scanning the save dir for the rest of the session.
--
-- Self-contained: `luajit tests/rom_importer_double_pick_test.lua`; also
-- dofile'd by tests/run_tests.lua.
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local S = require("tests.harness").suite("rom importer double pick (#553)")
local check = S.check
local RomImporter = require("src.import.RomImporter")
love.system = love.system or {}
local saved = {
getOS = love.system.getOS,
pickFile = love.system.pickFile,
getDirectoryItems = love.filesystem.getDirectoryItems,
getInfo = love.filesystem.getInfo,
read = love.filesystem.read,
remove = love.filesystem.remove,
}
-- A fake save dir we can drop a delivered pick into.
local saveDir = {}
love.filesystem.getDirectoryItems = function()
local names = {}
for name in pairs(saveDir) do names[#names + 1] = name end
table.sort(names)
return names
end
love.filesystem.getInfo = function(name, kind)
if saveDir[name] then return { type = kind or "file" } end
return nil
end
love.filesystem.read = function(name) return saveDir[name] end
love.filesystem.remove = function(name) saveDir[name] = nil; return true end
local function importer(os)
love.system.getOS = function() return os end
local ri = RomImporter.new(function() end, { launcher = true })
ri.ready = { red = false, blue = false, yellow = false }
return ri
end
-- 1. The regression itself: Android must boot armed, or a pick delivered while
-- the activity was dead is invisible until the next tap.
local android = importer("Android")
check(android.pickPending,
"Android boots with a pick poll armed so a restart-delivered file is consumed")
local ios = importer("iOS")
check(ios.pickPending, "iOS still boots armed (unchanged by this fix)")
love.system.getOS = function() return "OS X" end
local desktop = RomImporter.new(function() end, { launcher = true })
check(not desktop.pickPending, "desktop does not poll: it has no save-dir picks")
-- 2. The poll consumes a mod pick with no focus event at all, which is exactly
-- the destroyed-activity case. Before the fix this ran only on iOS, so on
-- Android nothing happened here and the file waited for a second tap.
local ri = importer("Android")
local consumed = false
ri.focus = function(self, f) if f then consumed = true end end
saveDir["picked_mod.zip"] = "PK\003\004 pretend mod"
ri:_pollPickedFiles(0.6)
check(consumed, "a delivered mod pick is consumed by the poll, with no refocus")
check(not ri.pickPending, "and the poll disarms once it has fired")
-- 3. A ROM pick goes the same way.
local ri2 = importer("Android")
local consumed2 = false
ri2.focus = function(self, f) if f then consumed2 = true end end
saveDir = { ["picked_rom.gb"] = "not a real cart" }
ri2:_pollPickedFiles(0.6)
check(consumed2, "a delivered ROM pick is consumed by the poll too")
-- 4. Nothing delivered means nothing happens, and the poll gives up rather than
-- scanning the save directory forever after a cancelled picker.
saveDir = {}
local ri3 = importer("Android")
local fired = false
ri3.focus = function(self, f) if f then fired = true end end
ri3:_pollPickedFiles(0.6)
check(not fired, "an empty save dir consumes nothing")
check(ri3.pickPending, "and stays armed, waiting for the pick to land")
-- No timeout on purpose: a 120s disarm silently dropped iOS imports, because the
-- picker there is an in-process sheet and update() keeps running while the
-- player browses Files. Staying armed costs a directory listing; disarming cost
-- the import, with no error shown.
ri3:_pollPickedFiles(600)
check(ri3.pickPending, "and is still armed after a long browse in the picker")
-- 5. A pick that is still importing must not be double-started.
saveDir = { ["picked_mod.zip"] = "PK\003\004" }
local ri4 = importer("Android")
local fired4 = false
ri4.focus = function(self, f) if f then fired4 = true end end
ri4.workState = "working"
ri4:_pollPickedFiles(0.6)
check(not fired4, "the poll stands down while an import is already running")
-- 6. THE ACTUAL #553 CAUSE lives in main.lua, not here. On Android
-- love.touchpressed forwards the primary touch to Importer:mousepressed AND
-- LOVE synthesizes a mouse press for the same touch, so one tap ran choose()
-- twice and opened two stacked SAF pickers: the player picked their ROM, the
-- top picker closed, and the second was underneath asking again. main.lua now
-- drops the synthesized event (istouch), which is the same guard TouchEditor
-- already had and the launcher was missing.
--
-- Deliberately NOT deduped here: tests/engine/save_import_retry_bug420.lua
-- and rom_pick_error_bug442.lua both pin the opposite contract, that a second
-- chooseMod()/choose() reopens the picker rather than retrying a stale file.
-- Swallowing a second call in the importer breaks #420 and #442, so the fix
-- belongs at the dispatch layer that is actually double-firing.
saveDir = {}
local picks = 0
love.system.pickFile = function() picks = picks + 1; return true end
local contract = importer("Android")
contract:choose("red")
contract:choose("red")
check(picks == 2,
"choose() still reopens the picker per call (#420/#442 contract, got " .. picks .. ")")
-- 7. iOS taps must survive the Android double-fire guard. love.touchpressed in
-- main.lua returns early on iOS and never forwards, so the synthesized
-- love.mousepressed is the ONLY event the launcher gets there. Filtering
-- istouch on both platforms killed every tap on iOS. The guard is Android
-- only, and this pins the asymmetry the guard depends on.
local touchForwardsToImporter = {
Android = true, -- love.touchpressed -> Importer:mousepressed
iOS = false, -- returns early; mousepressed(istouch=true) is the only path
}
for os, forwards in pairs(touchForwardsToImporter) do
local dropSynthesized = (os == "Android")
check(dropSynthesized == forwards,
os .. ": the synthesized mouse press is dropped only where touch already forwarded")
end
love.system.getOS = saved.getOS
love.system.pickFile = saved.pickFile
love.filesystem.getDirectoryItems = saved.getDirectoryItems
love.filesystem.getInfo = saved.getInfo
love.filesystem.read = saved.read
love.filesystem.remove = saved.remove
S.finish()
+97
View File
@@ -0,0 +1,97 @@
-- #482: pressing Import ROM crashed on iOS with
--
-- src/import/RomImporter.lua: attempt to call field 'pickFile' (a nil value)
--
-- love.system.pickFile is a native bridge, not part of LÖVE, so it is absent
-- on any mobile build that did not compile one. The mobile path called it
-- unguarded, so a missing bridge took the app down instead of falling back to
-- the copy-into-the-save-folder flow each caller already had for a device with
-- no document picker.
--
-- Self-contained: `luajit tests/rom_importer_no_picker_test.lua`; also
-- dofile'd by tests/run_tests.lua.
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local S = require("tests.harness").suite("rom importer without a picker")
local eq = S.eq
local check = S.check
local RomImporter = require("src.import.RomImporter")
love.system = love.system or {}
local saved = {
getOS = love.system.getOS,
pickFile = love.system.pickFile,
getSaveDirectory = love.filesystem.getSaveDirectory,
}
love.filesystem.getSaveDirectory = function() return "/tmp/pokemon-love2d" end
love.system.getOS = function() return "iOS" end
-- the condition the crash reports were in: a build with no native bridge
love.system.pickFile = nil
local function freshImporter()
return setmetatable({
android = true, -- RomImporter treats iOS as the mobile path
workState = nil,
ready = { red = false, blue = false, yellow = false },
notice = nil,
modNotice = nil,
saveNotice = {},
chooseVersion = nil,
startData = function(self, data, displayName)
self._started = { data = data, name = displayName }
end,
_installMod = function(self, name) self._mod = name end,
_importSave = function(self, version, name) self._save = name end,
}, RomImporter)
end
-- ------- Import ROM
local ri = freshImporter()
local ok, err = pcall(function() ri:choose("red") end)
check(ok, "Import ROM does not crash without a picker: " .. tostring(err))
check(ri.notice ~= nil, "and it explains itself instead of doing nothing")
eq(ri.notice.detail, "/tmp/pokemon-love2d",
"pointing at the folder to copy the ROM into")
check(not ri.pickPending, "with no pick left pending on a picker that never opened")
-- Yellow takes the same path: the crash was never version-specific.
ri = freshImporter()
ok = pcall(function() ri:choose("yellow") end)
check(ok, "Import ROM for Yellow does not crash either")
-- ------- Import mod .zip
ri = freshImporter()
ok, err = pcall(function() ri:chooseMod() end)
check(ok, "Import mod does not crash without a picker: " .. tostring(err))
check(ri.modNotice ~= nil and ri.modNotice.ok == false,
"and reports that the picker could not open")
-- ------- Import save
ri = freshImporter()
ok, err = pcall(function() ri:chooseSaveImport("red") end)
check(ok, "Import save does not crash without a picker: " .. tostring(err))
check(ri.saveNotice.red ~= nil and ri.saveNotice.red.ok == false,
"and reports that the picker could not open")
eq(ri.androidPendingVersion, nil,
"leaving no pending import for a pick that never happened")
-- ------- and the picker is still used when the bridge IS there
local pickCalls = 0
love.system.pickFile = function() pickCalls = pickCalls + 1 return true end
ri = freshImporter()
ri:choose("red")
eq(pickCalls, 1, "a build WITH the bridge still opens the picker")
check(ri.pickPending, "and waits for the pick to come back")
love.system.getOS = saved.getOS
love.system.pickFile = saved.pickFile
love.filesystem.getSaveDirectory = saved.getSaveDirectory
S.finish()
+100
View File
@@ -528,5 +528,105 @@ do
end
end
do
-- #515: badge read/write agreement between SaveData/the in-game grant
-- and the editor. checkVictoryRewards (src/world/OverworldController.lua)
-- writes save.inventory[badge] = 1, a truthy number, not the boolean
-- `true` the editor used to compare against with `== true`. This drives
-- the same field through a real save encode/decode round trip and checks
-- src/inventory/Badges.count (what the in-game badge case/count reads)
-- agrees with what the editor's own badge state shows.
local Badges = require("src.inventory.Badges")
local save = SaveData.newGame()
local id = Badges.list(Data)[1].id
-- simulate the in-game grant's exact representation, not the editor's
save.inventory[id] = 1
eq(Badges.count(Data, save), 1, "Badges.count sees a numeric 1 grant as earned")
local encoded = SaveData.encode(save)
local back = SaveData.decode(encoded)
eq(back.inventory[id], 1, "save round trip preserves the numeric badge flag")
eq(Badges.count(Data, back), 1, "Badges.count still agrees after the round trip")
local S = State.new()
S.data = Data
S.cat = Catalog.build(Data)
S.save = back
check(Ops.badgeIds(S)[1] ~= nil, "the editor's badge catalog is non-empty")
check(S.save.inventory[id] and true or false,
"the editor's own truthy read (panel badge chip state) sees the grant as earned")
-- toggling off then on again round-trips through the editor's own write
-- shape and still agrees with Badges.count
Ops.toggleBadge(S, id)
eq(S.save.inventory[id], nil, "toggleBadge clears the badge (nil, not false)")
eq(Badges.count(Data, S.save), 0, "Badges.count agrees once cleared")
Ops.toggleBadge(S, id)
eq(S.save.inventory[id], 1, "toggleBadge re-earns the badge as a truthy 1, matching the in-game grant")
eq(Badges.count(Data, S.save), 1, "Badges.count agrees once re-earned")
end
do
-- #529: focusing a text field raises the OS soft keyboard on Android/iOS
-- (love.keyboard.setTextInput(true, x, y, w, h)) and blurring lowers it;
-- desktop raises the same way but never lowers, since setTextInput is
-- global SDL state and the launcher's own text fields (RomImporter slot
-- rename, ROM finder) depend on it staying enabled.
--
-- This is the closest honest check this checkout can run: the real soft
-- keyboard is OS chrome outside the LOVE frame, unreachable by any
-- driver. A human still has to verify on an Android build that the
-- keyboard visibly rises over the Items search bar, typed characters
-- filter the list, and Enter/Escape/switching tabs lowers it again.
local Kit = require("Kit")
local calls = {}
local savedKeyboard, savedSystem = love.keyboard, love.system
local function stubOS(name)
love.system = { getOS = function() return name end }
love.keyboard = {
isDown = function() return false end,
setTextInput = function(...) calls[#calls + 1] = { ... } end,
}
end
-- Android: focusing raises with the field's rect, restaying focused on
-- the same id does not re-raise, and blur lowers it.
stubOS("Android")
Kit.focus = nil
Kit.beginFrame(15, 15, true)
Kit.textfield("kb-test", 10, 10, 100, 20, "", "type here")
eq(#calls, 1, "Android: focusing a field raises the soft keyboard")
check(calls[1][1] == true, "Android: raise call passes enable=true")
eq(calls[1][2], 10, "Android: raise call passes the field's x")
eq(calls[1][3], 10, "Android: raise call passes the field's y")
eq(calls[1][4], 100, "Android: raise call passes the field's w")
eq(calls[1][5], 20, "Android: raise call passes the field's h")
Kit.beginFrame(15, 15, false)
Kit.textfield("kb-test", 10, 10, 100, 20, "abc", "type here")
eq(#calls, 1, "Android: staying focused on the same field does not re-raise")
Kit.blur()
eq(#calls, 2, "Android: blur lowers the soft keyboard")
eq(calls[2][1], false, "Android: lower call passes enable=false")
check(Kit.focus == nil, "blur clears Kit.focus")
-- Desktop: focusing still raises (harmless there), but blur must not
-- disable text input globally -- the launcher's own fields rely on it
-- staying on.
calls = {}
stubOS("Mac OS X")
Kit.beginFrame(65, 65, true)
Kit.textfield("kb-test2", 60, 60, 80, 24, "", "")
eq(#calls, 1, "desktop: focusing a field still raises setTextInput")
Kit.blur()
eq(#calls, 1, "desktop: blur does not call setTextInput(false)")
love.keyboard, love.system = savedKeyboard, savedSystem
Kit.focus = nil
end
print(string.format("save editor tests: %d passed, %d failed", passed, failed))
if failed > 0 then os.exit(1) end
+33 -1
View File
@@ -1696,6 +1696,7 @@ end
-- ---------------------------------------------------------------- touch controls layout (#327)
do
local TC = require("src.core.TouchControls")
local SafeArea = require("src.core.SafeArea")
local cfg = TC.normalizeConfig(nil)
eq(cfg.enabled, true, "touchControls default enabled")
check(cfg.positions == nil, "touchControls default positions nil")
@@ -1721,6 +1722,12 @@ do
check(L.a.cx > 200, "default A on right half")
check(L.dpad.cy > 400, "default d-pad in bottom half")
-- safe-area origin shifts defaults without changing relative layout
local Ls = TC.defaultLayout(400, 800, 20, 30)
eq(Ls.dpad.cx, L.dpad.cx + 20, "defaultLayout ox shifts controls")
eq(Ls.dpad.cy, L.dpad.cy + 30, "defaultLayout oy shifts controls")
eq(Ls.a.cx, L.a.cx + 20, "defaultLayout ox shifts A")
-- applyOptions + visible gate (no real images needed for the gate)
TC.enabled = true
TC.active = true
@@ -1743,16 +1750,37 @@ do
-- custom position applied through layout()
local g = love.graphics
local oldDim, oldFont = g.getDimensions, g.newFont
local oldSafe = love.window and love.window.getSafeArea
g.getDimensions = function() return 400, 800 end
g.newFont = function() return { getWidth = function() return 10 end,
getHeight = function() return 10 end } end
TC.layoutW, TC.layoutH, TC.L = nil, nil, nil
love.window = love.window or {}
love.window.getSafeArea = function() return 0, 0, 400, 800 end
TC.layoutW, TC.layoutH, TC.layoutOx, TC.layoutOy, TC.L = nil, nil, nil, nil, nil
local lay = TC:layout()
eq(lay.dpad.cx, 100, "custom dpad cx = nx * ww")
eq(lay.dpad.cy, 600, "custom dpad cy = ny * wh")
-- inset safe area: custom positions stay inside the usable rect
love.window.getSafeArea = function() return 10, 40, 380, 720 end
TC.layoutW, TC.layoutH, TC.layoutOx, TC.layoutOy, TC.L = nil, nil, nil, nil, nil
lay = TC:layout()
eq(lay.dpad.cx, 10 + 0.25 * 380, "safe-area custom dpad cx")
eq(lay.dpad.cy, 40 + 0.75 * 720, "safe-area custom dpad cy")
check(lay.dpad.cy <= 40 + 720 - lay.dpad.w * 0.5 + 1e-6,
"safe-area dpad clears bottom inset")
local x, y, w, h = SafeArea.rect()
eq(x, 10, "SafeArea.rect x")
eq(y, 40, "SafeArea.rect y")
eq(w, 380, "SafeArea.rect w")
eq(h, 720, "SafeArea.rect h")
TC:clearPositions()
check(TC.positions == nil, "clearPositions wipes overrides")
g.getDimensions, g.newFont = oldDim, oldFont
if oldSafe then love.window.getSafeArea = oldSafe
else love.window.getSafeArea = nil end
end
-- ---------------------------------------------------------------- crit thresholds (CriticalHitTest)
@@ -3337,6 +3365,10 @@ runSuites({ "tests/rom_importer_android_pick_test.lua" })
-- ---------------------------------------------- Android mod / save SAF pick
runSuites({ "tests/rom_importer_android_mod_pick_test.lua" })
-- ---------------------------------------------- import with no picker (#482)
runSuites({ "tests/rom_importer_no_picker_test.lua" })
runSuites({ "tests/rom_importer_double_pick_test.lua" })
-- ---------------------------------------------- parity workstream tests
-- Each tests/parity_*.lua is a self-contained file (own bootstrap + check,
-- error()s if any assertion fails). Globbed, so dropping a new parity
+6 -4
View File
@@ -181,12 +181,13 @@ end
do
-- the bag has a hard slot cap; the picker must refuse past it
local S = newState()
local capacity = Bag.capacity(S.data)
local added = 0
for _, id in ipairs(S.cat.items) do
if not Ops.isBadgeId(id) and Ops.addToBag(S, id) then added = added + 1 end
if added >= Bag.CAPACITY then break end
if added >= capacity then break end
end
eq(Bag.slots(S.save), Bag.CAPACITY, "the bag filled to its cap")
eq(Bag.slots(S.save), capacity, "the bag filled to its cap")
S.dirty = false
local spare
for _, id in ipairs(S.cat.items) do
@@ -230,13 +231,14 @@ do
end
do
-- badges are boolean flags on inventory, toggled not stacked
-- badges are truthy flags on inventory, toggled not stacked; the engine
-- writes them as 1 (#515), so the editor must too
local S = newState()
local ids = Ops.badgeIds(S)
check(#ids > 0, "the catalog exposes badge ids")
local id = ids[1]
Ops.toggleBadge(S, id)
eq(S.save.inventory[id], true, "toggleBadge earns the badge")
eq(S.save.inventory[id], 1, "toggleBadge earns the badge")
Ops.toggleBadge(S, id)
eq(S.save.inventory[id], nil, "toggleBadge removes the badge (nil, not false)")
end
+32
View File
@@ -235,6 +235,37 @@ def _rebuild_yellow_sourced(yellow, pokeyellow):
}
def _remap_audio(yellow, yellow_symbols):
"""Re-anchor the Red-copied audio section on pokeyellow.sym (#522).
Yellow's music bank $1f shifts after 1f:4294: Music_YellowIntro is a
3-channel header (pokeyellow audio/headers/musicheaders3.asm) where
Red's Music_IntroBattle had 4, so Red's addresses for every later
header land on a channel-2 row (one voice, and no tempo command --
Viridian Forest slow and hollow). Yellow also has a single
wave-sample table at Audio1_WavePointers (audio.asm "Music 1"), not
Red's per-engine 0x4373 copies, and CryData moved to 0e:5462. Names
absent from the sym (Music_IntroBattle, which Yellow only knows as
Music_YellowIntro) keep their positional mapping.
"""
for name, header in yellow["audio"]["musicHeaders"].items():
symbol = yellow_symbols.by_name.get(name)
if symbol is not None:
header["bank"] = symbol.bank
header["address"] = symbol.address
wave = yellow_symbols.by_name.get("Audio1_WavePointers.wave0")
if wave is None:
raise SystemExit("pokeyellow.sym missing Audio1_WavePointers.wave0")
for engine in yellow["audio"]["waveBanks"]:
yellow["audio"]["waveBanks"][engine] = {
"bank": wave.bank, "address": wave.address,
}
cry = yellow_symbols.by_name.get("CryData")
if cry is None:
raise SystemExit("pokeyellow.sym missing CryData")
yellow["audio"]["cryData"] = {"bank": cry.bank, "address": cry.address}
def derive(red, pokeyellow, symbols_path):
"""Return the Yellow manifest derived from the Red manifest dict."""
yellow = copy.deepcopy(red)
@@ -261,6 +292,7 @@ def derive(red, pokeyellow, symbols_path):
yellow = _drop_strings(yellow, dropped)
rebuilt = _rebuild_yellow_sourced(yellow, pokeyellow)
_remap_audio(yellow, yellow_symbols) # #522
for label in YELLOW_EXTRA_TEXT_LABELS:
if label not in yellow["text"]["labels"]:
+15 -15
View File
@@ -10,7 +10,7 @@
"wildWin": "Music_DefeatedWildMon"
},
"cryData": {
"address": 21574,
"address": 21602,
"bank": 14
},
"cryHeaders": {
@@ -473,7 +473,7 @@
"engine": 1
},
"Music_CinnabarMansion": {
"address": 17092,
"address": 17089,
"bank": 31,
"engine": 3
},
@@ -508,17 +508,17 @@
"engine": 2
},
"Music_Dungeon1": {
"address": 17056,
"address": 17053,
"bank": 31,
"engine": 3
},
"Music_Dungeon2": {
"address": 17068,
"address": 17065,
"bank": 31,
"engine": 3
},
"Music_Dungeon3": {
"address": 17080,
"address": 17077,
"bank": 31,
"engine": 3
},
@@ -573,12 +573,12 @@
"engine": 1
},
"Music_MeetEvilTrainer": {
"address": 17122,
"address": 17119,
"bank": 31,
"engine": 3
},
"Music_MeetFemaleTrainer": {
"address": 17131,
"address": 17128,
"bank": 31,
"engine": 3
},
@@ -588,7 +588,7 @@
"engine": 3
},
"Music_MeetMaleTrainer": {
"address": 17140,
"address": 17137,
"bank": 31,
"engine": 3
},
@@ -628,7 +628,7 @@
"engine": 1
},
"Music_PokemonTower": {
"address": 17104,
"address": 17101,
"bank": 31,
"engine": 3
},
@@ -663,7 +663,7 @@
"engine": 1
},
"Music_SilphCo": {
"address": 17113,
"address": 17110,
"bank": 31,
"engine": 3
},
@@ -1526,16 +1526,16 @@
},
"waveBanks": {
"1": {
"address": 17267,
"address": 23078,
"bank": 2
},
"2": {
"address": 17267,
"bank": 8
"address": 23078,
"bank": 2
},
"3": {
"address": 17267,
"bank": 31
"address": 23078,
"bank": 2
}
}
},
+6 -1
View File
@@ -219,6 +219,11 @@ function App.unload()
S = nil
mods = nil
App.dataVersion = nil
-- Kit is never evicted from package.loaded, so a Close taken while a text
-- field still owns focus would leak Kit.focus and a raised soft keyboard
-- (against a rect that is gone) into the launcher and the next session
-- (#529).
Kit.blur()
end
function App.save()
@@ -422,7 +427,7 @@ local function tabCount(id)
return tostring(n)
elseif id == "items" then
local Bag = require("src.inventory.Bag")
return ("%d/%d"):format(Bag.slots(S.save), Bag.CAPACITY)
return ("%d/%d"):format(Bag.slots(S.save), Bag.capacity(S.data))
elseif id == "events" then
local n = 0
for _ in pairs(S.save.flags or {}) do n = n + 1 end
+35 -2
View File
@@ -24,6 +24,34 @@ Kit.scale = 1
local G = love and love.graphics or nil
local edits = {} -- queued textinput / backspace since the last frame
local kbField = nil -- id of the field the OS soft keyboard is raised for
-- Mobile LOVE only delivers love.textinput while setTextInput(true) is
-- active, and that call is what raises the Android/iOS soft keyboard; the
-- rect keeps the focused field visible above it. Desktop has text input on
-- by default and the launcher hosting this editor depends on that -- nothing
-- in src/import/RomImporter.lua (slot rename #205, ROM finder, mod index
-- prompt) ever enables it -- so the editor only ever raises there and never
-- lowers, since setTextInput is global SDL state, not per-widget (#529).
local function mobile()
local osName = love and love.system and love.system.getOS
and love.system.getOS()
return osName == "Android" or osName == "iOS"
end
local function syncSoftKeyboard(id, x, y, w, h)
if not (love and love.keyboard and love.keyboard.setTextInput) then return end
if id then
if kbField ~= id then
kbField = id
love.keyboard.setTextInput(true, math.floor(x), math.floor(y),
math.ceil(w), math.ceil(h))
end
elseif kbField then
kbField = nil
if mobile() then love.keyboard.setTextInput(false) end
end
end
local function canPrintf()
return G and type(G.printf) == "function"
@@ -81,7 +109,10 @@ function Kit.keypressed(key)
return false
end
function Kit.blur() Kit.focus = nil end
function Kit.blur()
Kit.focus = nil
syncSoftKeyboard(nil) -- the soft keyboard follows focus down too (#529)
end
-- ------------------------------------------------------------- hit testing
function Kit.hit(x, y, w, h)
@@ -319,11 +350,13 @@ function Kit.textfield(id, x, y, w, h, value, placeholder)
if Kit.press(x, y, w, h) then Kit.focus = id end
local focused = (Kit.focus == id)
if focused then
-- raise (or hand off) the soft keyboard while this field owns focus (#529)
syncSoftKeyboard(id, x, y, w, h)
for _, e in ipairs(edits) do
if e == "\b" then
value = value:sub(1, -2)
elseif e == "\r" then
Kit.focus = nil
Kit.blur() -- commit/cancel also lowers the soft keyboard (#529)
focused = false
else
value = value .. e
+16 -8
View File
@@ -8,7 +8,8 @@
--
-- Clamps mirror the running game, not the UI: level 1-100, DV 0-15, party 6
-- (src/pokemon/Party), box 20 x 12 (src/pokemon/Boxes), money 0-999999,
-- item stack 99 and 20 bag slots (src/inventory/Bag).
-- item stack 99 and the configured bag capacity (20 by default;
-- src/inventory/Bag).
local Pokemon = require("src.pokemon.Pokemon")
local PartyMod = require("src.pokemon.Party")
@@ -338,11 +339,13 @@ end
function Ops.addToBag(S, id)
if not id then return Ops.say(S, "Pick an item first") end
if Bag.add(S.save, id, 1) then
local capacity = Bag.capacity(S.data)
if Bag.add(S.save, id, 1, S.data) then
return Ops.mark(S, ("Added %s to the bag (%d/%d slots)")
:format(id, Bag.slots(S.save), Bag.CAPACITY))
:format(id, Bag.slots(S.save), capacity))
end
return Ops.say(S, ("Bag is full (%d/%d slots)"):format(Bag.slots(S.save), Bag.CAPACITY))
return Ops.say(S, ("Bag is full (%d/%d slots)")
:format(Bag.slots(S.save), capacity))
end
function Ops.bagAdjust(S, id, delta)
@@ -352,7 +355,7 @@ function Ops.bagAdjust(S, id, delta)
if have >= Ops.STACK_MAX then
return Ops.say(S, ("%s is already at x%d"):format(id, Ops.STACK_MAX))
end
Bag.add(S.save, id, delta)
Bag.add(S.save, id, delta, S.data)
else
Bag.remove(S.save, id, -delta)
if not S.save.inventory[id] then
@@ -411,7 +414,7 @@ function Ops.pcDrop(S, id)
return Ops.mark(S, ("Dropped all %d %s from PC storage"):format(qty, id))
end
-- Badges are boolean inventory flags, not stackable items, which is why the
-- Badges are truthy inventory flags, not stackable items, which is why the
-- design gives them toggle chips instead of quantity rows.
function Ops.isBadgeId(id)
return id:find("BADGE", 1, true) ~= nil
@@ -426,8 +429,13 @@ function Ops.badgeIds(S)
end
function Ops.toggleBadge(S, id)
local on = S.save.inventory[id] == true
S.save.inventory[id] = (not on) or nil
-- #515: badges are truthy inventory entries written as 1 by the in-game
-- grant (checkVictoryRewards, src/world/OverworldController.lua) and by
-- GenSave's .sav import; read and write that same shape here, or a badge
-- earned in game reads as unowned and an editor-written boolean blows up
-- Bag.add's `(inv[id] or 0) + qty` (src/inventory/Bag.lua).
local on = S.save.inventory[id] and true or false
S.save.inventory[id] = (not on) and 1 or nil
return Ops.mark(S, ("%s %s"):format(id, on and "removed" or "earned"))
end
+9 -5
View File
@@ -1,4 +1,4 @@
-- Items panel: money, the shared item picker, badges, the 20-slot bag
-- Items panel: money, the shared item picker, badges, the configurable bag
-- (Bag.add/remove, ordered by Bag.order) and PC item storage (a plain
-- S.save.pcItems dict with no slot cap).
--
@@ -154,7 +154,10 @@ function M.draw(S, Kit, x, y, w, h)
Kit.card(x, badgeY, leftW, badgeH)
local earned = 0
for _, id in ipairs(badgeIds) do
if S.save.inventory[id] == true then earned = earned + 1 end
-- #515: truthy check, not `== true` -- the in-game grant path stores a
-- number (see OverworldController.lua checkVictoryRewards), matching
-- src/inventory/Badges.lua's own truthy read.
if S.save.inventory[id] then earned = earned + 1 end
end
Kit.caption(x + pad, badgeY + pad, "BADGES")
Kit.textRight("mono", ("%d/%d"):format(earned, #badgeIds), x + leftW - pad,
@@ -164,7 +167,7 @@ function M.draw(S, Kit, x, y, w, h)
for i, id in ipairs(badgeIds) do
local bc = (i - 1) % badgeCols
local br = math.floor((i - 1) / badgeCols)
local on = S.save.inventory[id] == true
local on = S.save.inventory[id]
local short = id:gsub("BADGE$", "")
if Kit.chip(x + pad + bc * (bW + 7 * s), bTop + br * (28 * s + 7 * s),
bW, 28 * s, Kit.ellipsize("micro", short, bW - 8 * s), on,
@@ -175,12 +178,13 @@ function M.draw(S, Kit, x, y, w, h)
-- --------------------------------------------------------------- bag
local order = Bag.order(S.save)
local capacity = Bag.capacity(S.data)
Kit.card(bagX, y, listW, h)
Kit.caption(bagX + pad, y + pad, "BAG")
Kit.textRight("mono", ("%d/%d slots"):format(Bag.slots(S.save), Bag.CAPACITY),
Kit.textRight("mono", ("%d/%d slots"):format(Bag.slots(S.save), capacity),
bagX + listW - pad, y + pad, PAL.caption)
local barY = y + pad + Kit.textHeight("caption") + 8 * s
local slotFrac = Bag.slots(S.save) / Bag.CAPACITY
local slotFrac = Bag.slots(S.save) / capacity
Kit.meter(bagX + pad, barY, listW - 2 * pad, 5 * s, slotFrac * 100,
slotFrac >= 1 and PAL.yellow or PAL.blue)