mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-13 17:00:58 +02:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e2b2a5fb9f | |||
| f2a3e5f05b | |||
| 5f855568c8 | |||
| 24696e3be2 | |||
| b17dce232e | |||
| e1b20723fd | |||
| fdab15a6dc | |||
| 9c5f33e187 | |||
| 4916fc8f4b | |||
| fc19e58678 | |||
| 74eeb411ba | |||
| 040ca3f332 | |||
| c8e035d332 |
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
+53
-2
@@ -243,6 +243,7 @@ pack_game_love() {
|
||||
(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 \
|
||||
tools/rom_manifest_yellow.json \
|
||||
-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 +253,19 @@ 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")"
|
||||
for required in tools/save-editor/App.lua tools/save-editor/Kit.lua \
|
||||
tools/save-editor/panels/Party.lua \
|
||||
tools/rom_manifest.json tools/rom_manifest_blue.json \
|
||||
tools/rom_manifest_yellow.json; 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 +360,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 +504,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"
|
||||
|
||||
Executable
+23
@@ -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}"
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
+64
-16
@@ -5,8 +5,29 @@ local HostShell = require("src.core.HostShell")
|
||||
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 +574,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,12 +1011,13 @@ 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
|
||||
self.pickPending = true
|
||||
self.pickTimer = 0
|
||||
self.pickElapsed = 0
|
||||
end
|
||||
return
|
||||
end
|
||||
@@ -1047,13 +1075,14 @@ 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." }
|
||||
else
|
||||
self.pickPending = true
|
||||
self.pickTimer = 0
|
||||
self.pickElapsed = 0
|
||||
end
|
||||
return
|
||||
end
|
||||
@@ -1092,6 +1121,7 @@ function RomImporter:exportSave(version)
|
||||
if love.system.createFile and love.system.createFile(suggested) then
|
||||
self.pickPending = true
|
||||
self.pickTimer = 0
|
||||
self.pickElapsed = 0
|
||||
self.saveNotice[version] = { ok = true,
|
||||
text = "Pick where to save " .. suggested .. "..." }
|
||||
else
|
||||
@@ -1135,7 +1165,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).
|
||||
@@ -1147,6 +1177,7 @@ function RomImporter:choose(version)
|
||||
else
|
||||
self.pickPending = true
|
||||
self.pickTimer = 0
|
||||
self.pickElapsed = 0
|
||||
end
|
||||
return
|
||||
end
|
||||
@@ -1181,14 +1212,31 @@ 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).
|
||||
--
|
||||
-- Disarms after PICK_TIMEOUT so a cancelled picker (which delivers nothing,
|
||||
-- ever) does not leave this scanning the save directory for the whole session.
|
||||
local PICK_TIMEOUT = 120
|
||||
|
||||
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.pickElapsed = (self.pickElapsed or 0) + dt
|
||||
if self.pickElapsed > PICK_TIMEOUT then
|
||||
self.pickPending, self.pickElapsed = nil, nil
|
||||
return
|
||||
end
|
||||
self.pickTimer = (self.pickTimer or 0) + dt
|
||||
if self.pickTimer < 0.5 then return end
|
||||
self.pickTimer = 0
|
||||
@@ -1198,7 +1246,7 @@ function RomImporter:_pollPickedFiles(dt)
|
||||
local pickError = love.filesystem.read("pick_error.txt")
|
||||
if pickError then
|
||||
love.filesystem.remove("pick_error.txt")
|
||||
self.pickPending = nil
|
||||
self.pickPending, self.pickElapsed = nil, nil
|
||||
self.modNotice = { ok = false, text = pickError }
|
||||
self.notice = { version = self.chooseVersion or "red",
|
||||
status = "File import failed:", detail = pickError }
|
||||
@@ -1215,7 +1263,7 @@ function RomImporter:_pollPickedFiles(dt)
|
||||
end
|
||||
end
|
||||
if found then
|
||||
self.pickPending = nil
|
||||
self.pickPending, self.pickElapsed = nil, nil
|
||||
self:focus(true)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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,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
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
+99
-12
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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 @@
|
||||
9820543f7a223fab
|
||||
7de3051a7b3c108e
|
||||
|
||||
@@ -1 +1 @@
|
||||
54a52ea81751495c
|
||||
227fd737aba15763
|
||||
|
||||
+16
-14
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -0,0 +1,119 @@
|
||||
-- #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 while it is still within the timeout")
|
||||
ri3:_pollPickedFiles(200)
|
||||
check(not ri3.pickPending, "a cancelled pick disarms instead of polling forever")
|
||||
|
||||
-- 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")
|
||||
|
||||
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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -3337,6 +3337,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
|
||||
|
||||
@@ -230,13 +230,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
|
||||
|
||||
@@ -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"]:
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -411,7 +411,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 +426,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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user