mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-23 22:17:19 +02:00
Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 116a6ba450 | |||
| 478e3bf8eb | |||
| f009fc856c | |||
| 3abc6ceb15 | |||
| b93d070abc | |||
| 921f565f1d | |||
| 6e624724ca | |||
| 51bc4c7437 | |||
| e13271fc85 | |||
| c23f82efdd | |||
| 15fc04e067 | |||
| 61f42cea2b | |||
| 2c1e411e3c | |||
| c0f4315cd8 | |||
| 8c65e76145 | |||
| ea2cd63395 | |||
| 70d7b6383e | |||
| 5ec9ce5f88 | |||
| 087a275189 | |||
| 63b31d234c | |||
| 44f4680b24 | |||
| 06e06e305b | |||
| 464fb47756 |
@@ -468,3 +468,24 @@ jobs:
|
||||
if [ "$found" = "0" ]; then
|
||||
echo "no committed mods to lint"
|
||||
fi
|
||||
|
||||
luacheck:
|
||||
name: engine lint (luacheck)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: install luacheck
|
||||
run: |
|
||||
set -e
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y lua5.4 liblua5.4-dev luarocks
|
||||
sudo luarocks install luacheck || sudo apt-get install -y lua-check
|
||||
luacheck --version
|
||||
|
||||
- name: luacheck gate (undefined globals, unreachable code)
|
||||
run: ./scripts/lint.sh --gate
|
||||
|
||||
- name: luacheck full report (advisory)
|
||||
continue-on-error: true
|
||||
run: ./scripts/lint.sh
|
||||
|
||||
@@ -23,6 +23,10 @@ read_globals = {
|
||||
-- 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" } },
|
||||
"POKEPORT_DISPLAY_COMPANION",
|
||||
"POKEPORT_EDITOR_MODE",
|
||||
"rawlen",
|
||||
package = { fields = { "searchers" } },
|
||||
}
|
||||
|
||||
-- Vendored/native trees and the test suites have their own conventions.
|
||||
@@ -30,6 +34,7 @@ exclude_files = {
|
||||
"mobile/",
|
||||
"tests/",
|
||||
"tools/save-editor/",
|
||||
"tools/save_convert/vendor/",
|
||||
}
|
||||
|
||||
ignore = {
|
||||
|
||||
@@ -111,7 +111,7 @@ local function joinPrompt(game, ow, done)
|
||||
local t = game.data.text
|
||||
local back = function(text)
|
||||
game.stack:push(TextBox.new(game, text, function()
|
||||
ow:scriptMove(ow.player, "down", 1, done)
|
||||
ow:scriptMove(ow.player, "down", 1, done, { collide = true })
|
||||
end))
|
||||
end
|
||||
game.stack:push(TextBox.new(game,
|
||||
|
||||
@@ -127,7 +127,7 @@ M.VIRIDIAN_CITY = {
|
||||
game.stack:push(TextBox.new(game,
|
||||
game.data.text._ViridianCityOldManSleepyPrivatePropertyText
|
||||
or "You can't go\nthrough here!\fThis is private\nproperty!",
|
||||
function() ow:scriptMove(ow.player, "down", 1) end))
|
||||
function() ow:scriptMove(ow.player, "down", 1, nil, { collide = true }) end))
|
||||
return true
|
||||
end,
|
||||
}
|
||||
@@ -356,7 +356,7 @@ M.VERMILION_CITY = {
|
||||
if shipLeft then
|
||||
game.stack:push(TextBox.new(game,
|
||||
t._VermilionCitySailor1ShipSetSailText or "The ship set sail.",
|
||||
function() ow:scriptMove(ow.player, "up", 1) end))
|
||||
function() ow:scriptMove(ow.player, "up", 1, nil, { collide = true }) end))
|
||||
return true
|
||||
end
|
||||
-- Walk-past is never facing-right / inFrontOfOrBehindGuardCoords, so
|
||||
@@ -377,7 +377,7 @@ M.VERMILION_CITY = {
|
||||
ask .. "\f"
|
||||
.. (t._VermilionCitySailor1YouNeedATicketText
|
||||
or "You need a ticket\nto get aboard."),
|
||||
function() ow:scriptMove(ow.player, "up", 1) end))
|
||||
function() ow:scriptMove(ow.player, "up", 1, nil, { collide = true }) end))
|
||||
return true
|
||||
end,
|
||||
talk = {
|
||||
|
||||
@@ -414,7 +414,7 @@ local function saffronGate(guardText, triggers, horizontal)
|
||||
game.stack:push(TextBox.new(game,
|
||||
t._SaffronGateGuardGeeImThirstyText or "Gee, I'm thirsty\nthough!\nThe road's closed.",
|
||||
function()
|
||||
ow:scriptMove(ow.player, back, 1)
|
||||
ow:scriptMove(ow.player, back, 1, nil, { collide = true })
|
||||
end))
|
||||
return true
|
||||
end,
|
||||
@@ -769,7 +769,7 @@ M.MUSEUM_1F = {
|
||||
if y == 4 and (x == 9 or x == 10)
|
||||
and not game.save.flags.EVENT_BOUGHT_MUSEUM_TICKET then
|
||||
museumClerk(game, ow, nil, function()
|
||||
ow:scriptMove(ow.player, "down", 1)
|
||||
ow:scriptMove(ow.player, "down", 1, nil, { collide = true })
|
||||
end)
|
||||
return true
|
||||
end
|
||||
|
||||
@@ -176,7 +176,7 @@ M.POKEMON_TOWER_6F = {
|
||||
-- .did_not_defeat: one simulated step right, off the trigger,
|
||||
-- so fleeing does not leave you standing on a cell that
|
||||
-- immediately re-fires.
|
||||
ow:scriptMove(ow.player, "right", 1)
|
||||
ow:scriptMove(ow.player, "right", 1, nil, { collide = true })
|
||||
end
|
||||
ow:afterBattle(result, battle)
|
||||
end
|
||||
|
||||
@@ -734,7 +734,7 @@ local function e4ExitSeal(flag, closedBlock, openBlock, dontRunText, autoFlag)
|
||||
local TextBox = require("src.render.TextBox")
|
||||
game.stack:push(TextBox.new(game,
|
||||
game.data.text[dontRunText] or "Don't run away!", function()
|
||||
ow:scriptMove(ow.player, "up", 1)
|
||||
ow:scriptMove(ow.player, "up", 1, nil, { collide = true })
|
||||
end))
|
||||
return true
|
||||
end,
|
||||
|
||||
@@ -240,7 +240,7 @@ local function stepGate(opts)
|
||||
push(game, text(game)[opts.text] or opts.fallback, function()
|
||||
ow.player.facing = opts.push
|
||||
if not ow:checkLedgeHop(opts.push) then
|
||||
ow:scriptMove(ow.player, opts.push, 1)
|
||||
ow:scriptMove(ow.player, opts.push, 1, nil, { collide = true })
|
||||
end
|
||||
end)
|
||||
return true
|
||||
@@ -820,7 +820,8 @@ M.PEWTER_POKECENTER = {
|
||||
-- on the west-side cells and walks you back
|
||||
local function bikeGateGuard(coords, stopText, explainText)
|
||||
return function(game, ow, x, y)
|
||||
if game.save.inventory.BICYCLE then return false end
|
||||
local bike = game.save.inventory.BICYCLE
|
||||
if bike and bike ~= 0 then return false end
|
||||
if not inCoords(coords, x, y) then return false end
|
||||
-- walk the player up to the tile beside the counter, no further:
|
||||
-- (matchedY - closestY) tiles, 0 when already next to it
|
||||
@@ -842,10 +843,10 @@ local function bikeGateGuard(coords, stopText, explainText)
|
||||
-- (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)
|
||||
ow:scriptMove(ow.player, "right", 1, nil, { collide = true })
|
||||
end
|
||||
if dist > 0 then
|
||||
ow:scriptMove(ow.player, "up", dist, shoveRight)
|
||||
ow:scriptMove(ow.player, "up", dist, shoveRight, { collide = true })
|
||||
else
|
||||
shoveRight()
|
||||
end
|
||||
|
||||
@@ -550,6 +550,17 @@ gains a field instead of the name gaining a prefix.
|
||||
screen has no `.data` field, so the Gen 2 site **adds** `ctx.data` beside the
|
||||
Gen 1 keys. A mod that calls `nextFn` is unaffected; one that reaches through
|
||||
`ctx.battle.data` instead gets nil on Gold.
|
||||
`battle.exp_award`'s `ctx.applyShare(mon, split, announce)` reads its third
|
||||
argument on both generations: truthy prints the mon's GainedText, falsy pays
|
||||
it silently, so one mod source can print a single summary line for a
|
||||
party-wide award instead of a box per recipient. Gold honours it **only when
|
||||
it is passed**, by argument count -- `applyShare(mon, split)` was written
|
||||
against a seam that always announced on Gold and keeps announcing there,
|
||||
while `applyShare(mon, split, nil)` is silent on both. Pass the argument
|
||||
explicitly and the two generations agree; omit it and Gen 1 stays silent
|
||||
where Gold speaks. Only the line is affected: the exp, the stat exp,
|
||||
`battle.exp_gained`, the level-up line, learned moves and the forget prompt
|
||||
happen either way.
|
||||
- *The catch and the evolution:* `pokemon.caught`, `pokemon.evolved`; hook
|
||||
`evolution.check`. `src/ui/gen2/BattleState.lua:pushCaught` emits
|
||||
`pokemon.caught` once the mon is in the party or the box, and
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
# RFC 0012: `applyShare`'s announce argument on Gen 2
|
||||
|
||||
## Status
|
||||
|
||||
Proposed.
|
||||
|
||||
## Motivation
|
||||
|
||||
`battle.exp_award` hands a mod `ctx.applyShare(mon, split, announce)` on both
|
||||
generations. On Gen 1 the third argument decides whether the mon's GainedText
|
||||
box is printed (`src/battle/BattleState.lua`, `if announce then`), which is how
|
||||
a mod that pays the whole party prints **one** summary line instead of a box
|
||||
per recipient.
|
||||
|
||||
Gold accepts the argument and ignores it, as its own comment above the hook
|
||||
call says. So the same mod source, running the same code, prints one line on
|
||||
Red and six on Gold — one for the participant plus one for every bench mon it
|
||||
paid.
|
||||
|
||||
This is not hypothetical. The [Exp Share](https://github.com/ShaneMcGovernIE/exp_share)
|
||||
mod declares `"games": ["gen1", "gen2"]` and its description promises "a single
|
||||
shared-exp line instead of one message per Pokemon". It passes `true` for the
|
||||
fighters and `nil` for the bench, exactly as the Gen 1 seam asks. On Gold every
|
||||
one of those `nil` calls announces anyway, so a five-mon party turns every KO
|
||||
into six boxes to click through.
|
||||
|
||||
There is no mod-side fix. The announcement is emitted inside
|
||||
`Battle:giveExperiencePass`, behind no hook, and a mod cannot ask for silence
|
||||
because the argument that means "quietly" is discarded. The only workaround is
|
||||
to intercept the battle's event queue afterwards and delete the boxes, which is
|
||||
what a mod written for this had to do — a mod reaching into engine internals to
|
||||
undo something the public seam should never have done.
|
||||
|
||||
## Decision and plan extended
|
||||
|
||||
This does not add a seam. It finishes one: `battle.exp_award` is documented as
|
||||
"the same hook `BattleState:awardExp` calls on Gen 1 and with the same ctx",
|
||||
and `docs/mod-api-gen2-compat.md` lists it among the hooks shared with Gen 1.
|
||||
The third `applyShare` argument is the one part of that ctx whose meaning did
|
||||
not survive the crossing, so the promise the catalog already makes is what this
|
||||
change delivers.
|
||||
|
||||
The delta follows Route B's additive, guarded convention in
|
||||
`CONTRIBUTING-mods.md`: nothing is renamed, nothing is removed, and no mod that
|
||||
exists today changes behaviour.
|
||||
|
||||
## Exact API delta
|
||||
|
||||
`ctx.applyShare(mon, split, announce)` on Gen 2 now reads `announce`:
|
||||
|
||||
| Call | Gen 1 | Gen 2 before | Gen 2 after |
|
||||
|---|---|---|---|
|
||||
| `applyShare(mon, split)` | silent | announces | **announces** (unchanged) |
|
||||
| `applyShare(mon, split, nil)` | silent | announces | **silent** |
|
||||
| `applyShare(mon, split, false)` | silent | announces | **silent** |
|
||||
| `applyShare(mon, split, true)` | announces | announces | announces |
|
||||
| `applyShare(mon, split, "expAll")` | announces | announces | announces |
|
||||
|
||||
The argument is honoured **only when it is actually passed**, decided by
|
||||
argument count rather than by value:
|
||||
|
||||
```lua
|
||||
local function applyShare(mon, split, ...)
|
||||
local announce = ...
|
||||
local silent = select("#", ...) > 0 and not announce
|
||||
...
|
||||
end
|
||||
```
|
||||
|
||||
`select("#", ...)` counts an explicit `nil`, so `applyShare(mon, split)` and
|
||||
`applyShare(mon, split, nil)` are distinguishable — and they have to be, because
|
||||
the first is a Gen 2-era call written against a seam that always announced, and
|
||||
the second is a deliberate "pay this one quietly".
|
||||
|
||||
Only the `{ kind = "experience" }` event is affected. A silent award is still a
|
||||
whole award: the exp, the stat exp, the `battle.exp_gained` event, the
|
||||
`grew to level` line, learned moves and the interactive forget-a-move prompt all
|
||||
happen exactly as before, in the same order.
|
||||
|
||||
Internally `Battle:giveExperiencePass` takes a sixth parameter, `silent`. It
|
||||
defaults to announcing, so both of the cart's own passes are untouched.
|
||||
|
||||
## Migration and compatibility
|
||||
|
||||
**Existing mods change nothing.** A Gen 2 mod calling `applyShare(mon, split)`
|
||||
gets the behaviour it was written against. A Gen 1 mod is untouched: no Gen 1
|
||||
file is modified. The v1 surface — `content.X:register/override/get`,
|
||||
`events:on`, `hooks:wrap`, `mod.log`, `mod:read`, the manifest v1 fields and
|
||||
`pokemon.before_give` — is not involved; `mods/example_mew_starter` neither
|
||||
calls this seam nor loads differently.
|
||||
|
||||
A mod that wants parity passes the argument explicitly, which is what the Gen 1
|
||||
seam has always documented. Exp Share already does, and needs no edit to get
|
||||
its own README's behaviour on Gold.
|
||||
|
||||
One residual difference is deliberate and now documented rather than silent: an
|
||||
**omitted** third argument still means "silent" on Gen 1 and "announce" on
|
||||
Gold. Closing that would change what an existing Gen 2 mod prints, which Route
|
||||
B rejects. Passing the argument makes the two generations agree, so the rule an
|
||||
author needs is one sentence: *say what you mean and both games do the same
|
||||
thing.*
|
||||
|
||||
## Verification
|
||||
|
||||
- `tests/gen2_exp_share_test.lua` grows two sections and 14 checks, and the 23
|
||||
checks it already had are unchanged — which is itself the vanilla-parity
|
||||
evidence for this file.
|
||||
- **The no-mod test.** With nothing subscribed to `battle.exp_award`, a solo
|
||||
participant still prints one line and the EXP.SHARE double pass still
|
||||
prints both. The hot path is unchanged: `Runtime.wantsHook` still guards
|
||||
the ctx allocation, and `vanillaAward` never passes `silent`.
|
||||
- **The mod-API test.** The seam is driven through `hooks:wrap` on a real
|
||||
`Runtime.install`ed bus, not by calling internals: the omitted argument
|
||||
announces, an explicit `nil` and an explicit `false` are silent, a truthy
|
||||
value (including Gen 1's `"expAll"`) announces, the exp and stat exp paid
|
||||
are identical either way, and no `experience` event leaks into the queue on
|
||||
a silent pass.
|
||||
- `tests/engine/gate_gen2_mod_api.lua` (943 checks),
|
||||
`tests/engine/gate_hooks.lua` (493) and `tests/engine/gate_events.lua` (529)
|
||||
pass unchanged; `battle.exp_award` was already in the shared catalog, so no
|
||||
gate list moves.
|
||||
- `tests/gen2_battle_test.lua` (690), `gen2_battle_end_test.lua` (32),
|
||||
`gen2_battle_items_test.lua` (99), `gen2_badge_boosts_test.lua` (34) and
|
||||
`gen2_battle_loss_test.lua` (15) pass unchanged.
|
||||
|
||||
## Docs with the change
|
||||
|
||||
`docs/mod-api-gen2-compat.md` gains the `applyShare` reading beside the
|
||||
existing `battle.low_health_alarm` payload note, in the same section that lists
|
||||
`battle.exp_award` as shared — including the argument-count rule and the
|
||||
residual difference above.
|
||||
|
||||
No registry or schema field changes, so `src/mods/Schemas.lua` is untouched and
|
||||
`tools/gen_registry_docs.lua` has nothing new to emit.
|
||||
|
||||
## Deprecation etiquette
|
||||
|
||||
Nothing is removed, renamed, superseded or deprecated. The two-argument call is
|
||||
not deprecated either — it keeps its current Gen 2 meaning permanently, and the
|
||||
docs name the explicit form as the one that behaves the same on both games.
|
||||
+14
-1
@@ -209,9 +209,21 @@ New/Load workspace controls are deliberately not duplicated inside that editor.
|
||||
|
||||
The editor keeps its canvas unobstructed and puts the contextual actions in a
|
||||
compact lower tray: add/control binding, button and bezel artwork, pages,
|
||||
screen placement, freeform/10:9 screen shape and deletion. Touches select, drag and resize the
|
||||
screen placement, freeform/10:9 screen shape and deletion. **Screen** opens
|
||||
cutout, bezel-hole detect, **Detect this screen**, and the canvas presets.
|
||||
**Detect this screen** (also on the tray) sets the mock device to the live
|
||||
window size so a phone skin is authored at that phone's form factor rather
|
||||
than a generic 1080x1920 16:9. **Zoom −** shrinks the mock device inside the
|
||||
workspace so the screen hole can be dragged larger than the bezel while the
|
||||
handles stay grabable; **Fit** restores contain. The mouse wheel over the
|
||||
canvas, and `-` / `=` / `0` on a keyboard, do the same. Touches select, drag and resize the
|
||||
same controls that a mouse edits on desktop.
|
||||
|
||||
My Skins and the editor chrome sit inside the platform safe area (notch,
|
||||
status bar, home indicator), the same inset the launcher uses. The mock
|
||||
device still represents the full window, because a skin covers the whole
|
||||
screen at play time.
|
||||
|
||||
The launcher’s **Turn skins off** button clears the selected skin and disables
|
||||
skin use. With no skin enabled, mobile falls back to the built-in pad; that pad
|
||||
is not itself a skin card.
|
||||
@@ -222,6 +234,7 @@ phone proportions on a desktop monitor.
|
||||
| Preset | Size |
|
||||
| --- | --- |
|
||||
| Phone portrait / landscape | 1080x1920, 1920x1080 |
|
||||
| This screen | the live window, so a phone is authored at its own height |
|
||||
| Tablet portrait / landscape | 1536x2048, 2048x1536 |
|
||||
| Steam Deck | 1280x800 |
|
||||
| Desktop 1080p | 1920x1080 |
|
||||
|
||||
+6
-3
@@ -126,9 +126,12 @@ bundled game, in that case.
|
||||
persistent launcher control. Android downloads the release APK, verifies
|
||||
its SHA-256 entry from `sha256sums.txt`, then invokes Android's Package
|
||||
Installer. The installer asks the user for consent and enforces package,
|
||||
version-code, and signing-certificate compatibility. iOS links the
|
||||
sideload repository for a re-sideload; Xbox, desktop, and PortMaster builds
|
||||
link their correctly named full package. Switch keeps its native OTA flow.
|
||||
version-code, and signing-certificate compatibility. A legacy APK without
|
||||
the installer bridge links its full package for one manual bootstrap
|
||||
update, including when its downloaded payload already reports the latest
|
||||
engine version. iOS links the sideload repository for a re-sideload; Xbox,
|
||||
desktop, and PortMaster builds link their correctly named full package.
|
||||
Switch keeps its native OTA flow.
|
||||
|
||||
## Known limitations
|
||||
|
||||
|
||||
@@ -24,10 +24,10 @@ local GameViewport = require("src.render.GameViewport")
|
||||
|
||||
-- Lua errors: persist a redacted trace in the save dir and surface a hint.
|
||||
do
|
||||
local defaultErrorHandler = love.errorhandler
|
||||
local defaultErrorHandler = love.errorhandler or love.errhand
|
||||
function love.errorhandler(msg)
|
||||
local hint = SwitchDiagnostics.logLuaError(msg)
|
||||
if hint and type(msg) == "string" then
|
||||
local ok, hint = pcall(SwitchDiagnostics.logLuaError, msg)
|
||||
if ok and hint and type(msg) == "string" then
|
||||
msg = msg .. "\n\n" .. hint
|
||||
end
|
||||
if defaultErrorHandler then
|
||||
|
||||
@@ -12,6 +12,27 @@
|
||||
"tintColor": "3b5ca8",
|
||||
"category": "games",
|
||||
"versions": [
|
||||
{
|
||||
"version": "0.2.18",
|
||||
"date": "2026-08-21",
|
||||
"size": 13772120,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.18/gen1recomp++-0.2.18-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @bryanthaboi"
|
||||
},
|
||||
{
|
||||
"version": "0.2.17",
|
||||
"date": "2026-08-21",
|
||||
"size": 13771903,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.17/gen1recomp++-0.2.17-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @bryanthaboi"
|
||||
},
|
||||
{
|
||||
"version": "0.2.16",
|
||||
"date": "2026-08-21",
|
||||
"size": 13769300,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.16/gen1recomp++-0.2.16-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1367 Android auto scrolling down bug\n- #1570 Pokemon Gold isn't noticing my controller inputs on...ANY controller I own, despite all of them working in the other games, and in the main launcher\n- #1585 Power-saving mode or dark mode closes the game on Android.\n- #1611 bug: keyboard gets stuck IOS\n- #1612 Fold 7 infinite scrolling mouse.\n- #1636 Game freezes when trainer battle starts\n- #1638 Game screen orientation on smartphone\n- #1641 [Gold] [Android] PKMN Evolutions sequence freezes the Game \n\n## Contributors\n\n- @1Jamie\n- @bryanthaboi\n- @HighDrexler\n- MaxTomahawk"
|
||||
},
|
||||
{
|
||||
"version": "0.2.15",
|
||||
"date": "2026-08-21",
|
||||
|
||||
+21
-2
@@ -7,7 +7,8 @@
|
||||
# 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 full advisory report over every shipped tree
|
||||
# scripts/lint.sh --gate only the codes CI blocks on (0xx, 1xx, 511)
|
||||
# scripts/lint.sh src tools lint specific paths
|
||||
#
|
||||
# Install once with: luarocks install luacheck
|
||||
@@ -15,9 +16,27 @@
|
||||
set -uo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
DEFAULT_PATHS=(main.lua conf.lua src data/scripts mods tools)
|
||||
|
||||
GATE=0
|
||||
if [ "${1:-}" = "--gate" ]; then
|
||||
GATE=1
|
||||
shift
|
||||
fi
|
||||
|
||||
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}"
|
||||
if [ "$#" -gt 0 ]; then
|
||||
PATHS=("$@")
|
||||
else
|
||||
PATHS=("${DEFAULT_PATHS[@]}")
|
||||
fi
|
||||
|
||||
if [ "$GATE" = "1" ]; then
|
||||
exec luacheck "${PATHS[@]}" -q --codes --only 0 1 511
|
||||
fi
|
||||
|
||||
luacheck "${PATHS[@]}"
|
||||
|
||||
@@ -66,6 +66,15 @@ run_tier() {
|
||||
|
||||
# ------- ROM-free tiers: these are what CI runs
|
||||
|
||||
if command -v luacheck >/dev/null 2>&1; then
|
||||
run_tier "T0 luacheck gate (undefined globals, unreachable code)" \
|
||||
./scripts/lint.sh --gate
|
||||
else
|
||||
echo ""
|
||||
echo "-- T0 luacheck gate: skipped (no luacheck on PATH --"
|
||||
echo " luarocks install luacheck; CI installs and gates on it regardless)"
|
||||
fi
|
||||
|
||||
run_tier "T0 ROM builder version routing" python3 tests/build_rom_data_cli_test.py
|
||||
run_tier "T0 ROM manifest generator pin/overrides" python3 tests/rom_manifest_generator_test.py
|
||||
run_tier "T0 switch CI workflow content gate" "$LUA" tests/switch_ci_workflows_test.lua
|
||||
|
||||
@@ -538,6 +538,7 @@ end
|
||||
|
||||
local function makeBattler(data, mon, isPlayer, save)
|
||||
local def = data.pokemon[mon.species]
|
||||
require("src.pokemon.Stats").ensure(def, mon)
|
||||
local badgeBoosts = data.constants and data.constants.badgeBoosts
|
||||
local badges = nil
|
||||
if isPlayer and save then
|
||||
|
||||
@@ -645,7 +645,9 @@ MoveEffects.full = {
|
||||
user.bideTurns = ctx.rng(2, 3)
|
||||
user.bideDamage = 0
|
||||
ctx.battle:cancelMoveAnim()
|
||||
ctx.anim(user.isPlayer and "XSTATITEM_ANIM" or "XSTATITEM_DUPLICATE_ANIM")
|
||||
ctx.battle:animBeforeMove(
|
||||
user.isPlayer and "XSTATITEM_ANIM" or "XSTATITEM_DUPLICATE_ANIM",
|
||||
user.isPlayer)
|
||||
ctx.say(Strings("%s\nis storing energy!", displayName(user)))
|
||||
end,
|
||||
},
|
||||
|
||||
+217
-41
@@ -903,8 +903,16 @@ function Battle:movePriority(moveId)
|
||||
return (def and Battle.PRIORITY[def.effect]) or 0
|
||||
end
|
||||
|
||||
-- engine/battle/effect_commands.asm:192-197 (enemy twin :383-390)
|
||||
Battle.SLEEP_BYPASS_MOVES = { SNORE = true, SLEEP_TALK = true }
|
||||
|
||||
-- Can this mon act? Returns true, or false plus the message the cart prints.
|
||||
function Battle:canAct(mon)
|
||||
-- `moveId` is wCurPlayerMove / wCurEnemyMove (effect_commands.asm:193).
|
||||
local function clearBide(state)
|
||||
state.bideTurns, state.bideStored, state.bideMove = nil, nil, nil
|
||||
end
|
||||
|
||||
local function checkTurn(self, mon, moveId)
|
||||
local name = self:monName(mon)
|
||||
-- SUBSTATUS_RECHARGE, and it is checked BEFORE status: CheckPlayerTurn reads
|
||||
-- it first, clears it, prints MustRechargeText and jumps to EndTurn, so a mon
|
||||
@@ -925,7 +933,11 @@ function Battle:canAct(mon)
|
||||
local beforeMove = record and record.beforeMove
|
||||
if beforeMove
|
||||
and (record.beforeMovePriority or 0) > Battle.VOLATILE_PRIORITY then
|
||||
return beforeMove(self, mon, name) and true or false
|
||||
-- engine/battle/effect_commands.asm:188-200
|
||||
local bypass = mon.status == "sleep" and Battle.SLEEP_BYPASS_MOVES[moveId]
|
||||
local acted = beforeMove(self, mon, name) and true or false
|
||||
if acted or not bypass then return acted end
|
||||
beforeMove = nil
|
||||
end
|
||||
-- SUBSTATUS_FLINCHED, read and cleared right after the freeze check
|
||||
-- (CheckPlayerTurn / CheckEnemyTurn `.not_frozen`). Set this turn by the
|
||||
@@ -959,6 +971,14 @@ function Battle:canAct(mon)
|
||||
return true
|
||||
end
|
||||
|
||||
-- CantMove (engine/battle/effect_commands.asm:344-353) clears BIDE on every
|
||||
-- arm of CheckPlayerTurn / CheckEnemyTurn that spends the turn.
|
||||
function Battle:canAct(mon, moveId)
|
||||
local acted = checkTurn(self, mon, moveId)
|
||||
if not acted then clearBide(self:volatile(mon)) end
|
||||
return acted
|
||||
end
|
||||
|
||||
-- STRUGGLE, the move a mon with nothing left to spend falls back to
|
||||
-- (engine/battle/core.asm `.CheckPlayerHasUsableMoves` for the player and
|
||||
-- `.struggle` for the enemy). It lives in the move table like any other move
|
||||
@@ -1234,10 +1254,15 @@ function Battle:dealDamage(attacker, defender, damage, opts)
|
||||
if opts.critical then
|
||||
self:emit({ kind = "message", text = "A critical hit!" })
|
||||
end
|
||||
-- SuperEffectiveText / NotVeryEffectiveText (data/text/battle.asm:603,608).
|
||||
-- The cart breaks both across the box's two lines and hyphenates "super-"
|
||||
-- to do it, and the not-very line ends on the single ellipsis glyph Gold's
|
||||
-- charmap carries at $75, not three periods.
|
||||
if opts.effectiveness and opts.effectiveness > 10 then
|
||||
self:emit({ kind = "message", text = "It's super effective!" })
|
||||
self:emit({ kind = "message", text = Strings("It's super-\neffective!") })
|
||||
elseif opts.effectiveness and opts.effectiveness < 10 then
|
||||
self:emit({ kind = "message", text = "It's not very effective..." })
|
||||
self:emit({ kind = "message",
|
||||
text = Strings("It's not very\neffective…") })
|
||||
end
|
||||
if endured then
|
||||
self:emit({ kind = "message",
|
||||
@@ -1389,9 +1414,18 @@ function Battle:useMove(attacker, defender, moveId)
|
||||
-- free of PP and obedience in exactly the same way.
|
||||
local rolling = state.rolloutLock == moveId
|
||||
|
||||
if not (charging or rampaging or rolling) then
|
||||
-- engine/battle/effect_commands.asm:977-979, data/moves/effects.asm:795-800,
|
||||
-- engine/battle/move_effects/bide.asm:62-68
|
||||
local biding = def.effect == "EFFECT_BIDE" and state.bideTurns ~= nil
|
||||
|
||||
-- engine/battle/effect_commands.asm:6222-6234, :949-951
|
||||
local called = (self.copyDepth or 0) > 0
|
||||
|
||||
if not (charging or rampaging or rolling or biding or called) then
|
||||
if move and (move.pp or 0) <= 0 then
|
||||
self:emit({ kind = "message", text = "No PP left for this move!" })
|
||||
-- BattleText_TheresNoPPLeftForThisMove (data/text/battle.asm:315).
|
||||
self:emit({ kind = "message",
|
||||
text = Strings("There's no PP left\nfor this move!") })
|
||||
return
|
||||
end
|
||||
if move then move.pp = (move.pp or 1) - 1 end
|
||||
@@ -1471,9 +1505,38 @@ function Battle:useMove(attacker, defender, moveId)
|
||||
return
|
||||
end
|
||||
|
||||
-- engine/battle/move_effects/sleep_talk.asm:2, :16-19, :61
|
||||
if def.effect == "EFFECT_SLEEP_TALK" then
|
||||
local picked
|
||||
if attacker.status == "sleep" and (self.copyDepth or 0) == 0 then
|
||||
-- engine/battle/move_effects/sleep_talk.asm:40-44, :117-141
|
||||
local pool = {}
|
||||
for _, own in ipairs(attacker.moves or {}) do
|
||||
local ownDef = self:moveDef(own.id)
|
||||
local effect = ownDef and ownDef.effect
|
||||
if own.id ~= moveId and not self:moveDisabled(attacker, own.id)
|
||||
and not Effects.CHARGE[effect] and effect ~= "EFFECT_BIDE" then
|
||||
pool[#pool + 1] = own.id
|
||||
end
|
||||
end
|
||||
if #pool > 0 then picked = pool[rand(self.random, #pool) + 1] end
|
||||
end
|
||||
if not picked then
|
||||
self:markMissed()
|
||||
self:emit({ kind = "message", text = "But it failed!" })
|
||||
return
|
||||
end
|
||||
state.lastMove = nil
|
||||
self.copyDepth = (self.copyDepth or 0) + 1
|
||||
self:useMove(attacker, defender, picked)
|
||||
self.copyDepth = self.copyDepth - 1
|
||||
return
|
||||
end
|
||||
|
||||
-- Everything past here counts as "the user's last move" for Mirror Move,
|
||||
-- Encore and Disable.
|
||||
state.lastMove = moveId
|
||||
-- Encore and Disable. A called move skips the write
|
||||
-- (engine/battle/used_move_text.asm:30-36).
|
||||
if (self.copyDepth or 0) == 0 then state.lastMove = moveId end
|
||||
state.turnsTaken = (state.turnsTaken or 0) + 1
|
||||
state.usedMoves = state.usedMoves or {}
|
||||
local seen = false
|
||||
@@ -1516,6 +1579,13 @@ function Battle:useMove(attacker, defender, moveId)
|
||||
return
|
||||
end
|
||||
|
||||
-- BattleCommand_Snore (engine/battle/move_effects/snore.asm:1-9)
|
||||
if def.effect == "EFFECT_SNORE" and attacker.status ~= "sleep" then
|
||||
self:markMissed()
|
||||
self:emit({ kind = "message", text = "But it failed!" })
|
||||
return
|
||||
end
|
||||
|
||||
-- Counter and Mirror Coat answer what the user took this turn, at double,
|
||||
-- and fail outright when nothing of the right kind landed.
|
||||
local counterKind = Effects.COUNTER[def.effect]
|
||||
@@ -1747,8 +1817,12 @@ function Battle:useMove(attacker, defender, moveId)
|
||||
landed = landed + 1
|
||||
end
|
||||
if landed > 1 then
|
||||
self:emit({ kind = "message",
|
||||
text = ("Hit %d time(s)!"):format(landed) })
|
||||
-- PlayerHitTimesText / EnemyHitTimesText (data/text/battle.asm:749,755)
|
||||
-- are "Hit @ times!". Gen 2 has no singular form of this line, so the
|
||||
-- plural stands even at one hit rather than the "(s)" this printed.
|
||||
-- Gen 1 already says it this way (src/battle/EffectRegistry.lua,
|
||||
-- _HitXTimesText).
|
||||
self:emit({ kind = "message", text = Strings("Hit %d times!", landed) })
|
||||
end
|
||||
|
||||
-- move_effects/pay_day.asm:13
|
||||
@@ -1968,8 +2042,11 @@ Battle.MOVE_EFFECTS.EFFECT_PERISH_SONG = function(self)
|
||||
if mine.perish and theirs.perish then return fail(self) end
|
||||
if not mine.perish then mine.perish = Effects.PERISH_TURNS end
|
||||
if not theirs.perish then theirs.perish = Effects.PERISH_TURNS end
|
||||
-- StartPerishText (data/text/battle.asm:986). What shipped here was a
|
||||
-- sentence no cart prints; the Gen 2 line names both sides and counts in
|
||||
-- digits.
|
||||
self:emit({ kind = "message",
|
||||
text = "All POKéMON hearing the song will faint in three turns!" })
|
||||
text = Strings("Both POKéMON will\nfaint in 3 turns!") })
|
||||
end
|
||||
|
||||
-- BattleCommand_Encore: 3-6 turns locked into the move the target last used.
|
||||
@@ -2099,6 +2176,11 @@ Battle.MOVE_EFFECTS.EFFECT_SPIKES = function(self, attacker, defender)
|
||||
local side = self:sideOf(defender)
|
||||
if self.spikes[side] then return fail(self) end
|
||||
self.spikes[side] = true
|
||||
-- SpikesText (data/text/battle.asm:974) is three rows, the third scrolled
|
||||
-- (`cont`) and carrying <TARGET>. The battle message path has no `cont`:
|
||||
-- src/ui/gen2/BattleState.lua sets self.message straight from the event and
|
||||
-- printMessage cuts past two rows, so the cart's line cannot be told here
|
||||
-- yet without the name being dropped on screen. Left as it stands.
|
||||
self:emit({ kind = "message", text = "Spikes were scattered all around!" })
|
||||
end
|
||||
|
||||
@@ -2133,6 +2215,8 @@ Battle.MOVE_EFFECTS.EFFECT_BIDE = function(self, attacker, defender, def, moveId
|
||||
if not state.bideTurns then
|
||||
state.bideTurns = Effects.bideTurns(self.random)
|
||||
state.bideStored = 0
|
||||
-- engine/battle/core.asm:574-576
|
||||
state.bideMove = moveId
|
||||
self:emit({ kind = "message",
|
||||
text = self:monName(attacker) .. " is storing energy!" })
|
||||
return
|
||||
@@ -2144,7 +2228,7 @@ Battle.MOVE_EFFECTS.EFFECT_BIDE = function(self, attacker, defender, def, moveId
|
||||
return
|
||||
end
|
||||
local damage = Effects.bideDamage(state.bideStored)
|
||||
state.bideTurns, state.bideStored = nil, nil
|
||||
state.bideTurns, state.bideStored, state.bideMove = nil, nil, nil
|
||||
self:emit({ kind = "message",
|
||||
text = self:monName(attacker) .. " unleashed energy!" })
|
||||
if damage <= 0 then return fail(self) end
|
||||
@@ -2334,7 +2418,9 @@ Battle.MOVE_EFFECTS.EFFECT_BEAT_UP = function(self, attacker, defender, def)
|
||||
{ move = def, moveId = def and def.id })
|
||||
landed = landed + 1
|
||||
end
|
||||
self:emit({ kind = "message", text = ("Hit %d time(s)!"):format(landed) })
|
||||
-- BattleCommand_EndLoop prints the same line for Beat Up, and Beat Up can
|
||||
-- land exactly once, which is the case the cart still prints as "times".
|
||||
self:emit({ kind = "message", text = Strings("Hit %d times!", landed) })
|
||||
end
|
||||
|
||||
-- BattleCommand_Heal (effect_commands.asm:5986): Recover and Rest are both
|
||||
@@ -2417,6 +2503,8 @@ Battle.MOVE_EFFECTS.EFFECT_BATON_PASS = function(self, attacker)
|
||||
self.enemyIndex = target
|
||||
self.enemy = party[target]
|
||||
self.enemy.volatile = carried
|
||||
-- engine/battle/move_effects/baton_pass.asm:59
|
||||
self:resetParticipants()
|
||||
end
|
||||
local sent = side == "player" and self.player or self.enemy
|
||||
self:emit({ kind = "send", side = side, mon = sent,
|
||||
@@ -2679,6 +2767,8 @@ Battle.MOVE_EFFECTS.EFFECT_FORCE_SWITCH = function(self, attacker, defender,
|
||||
self.enemyIndex = pick
|
||||
self.enemy = incoming
|
||||
self.stages.enemy = Battle.newStages()
|
||||
-- ForceEnemySwitch (engine/battle/core.asm:2937)
|
||||
self:resetParticipants()
|
||||
end
|
||||
self:emit({ kind = "send", side = self:sideOf(incoming), mon = incoming,
|
||||
hp = incoming.hp or 0, status = incoming.status or false,
|
||||
@@ -3088,6 +3178,12 @@ end
|
||||
|
||||
-- Faint bookkeeping and experience. Returns true when the battle ended.
|
||||
function Battle:resolveFaints()
|
||||
-- engine/battle/core.asm:2551-2556, :7116-7130, :3033-3037
|
||||
if (self.player.hp or 0) <= 0 and self.participantsCleared ~= self.player then
|
||||
self.participantsCleared = self.player
|
||||
if self.playerIndex then self.participants[self.playerIndex] = nil end
|
||||
end
|
||||
|
||||
if (self.enemy.hp or 0) <= 0 then
|
||||
self:emit({ kind = "faint", side = "enemy",
|
||||
text = (self.wild and "Wild " or "") .. self:monName(self.enemy)
|
||||
@@ -3272,7 +3368,16 @@ end
|
||||
-- `count` is the pass's own divisor -- the participant count for the first
|
||||
-- pass, the holder count for the EXP.SHARE pass -- and `halved` is whether
|
||||
-- any Share holder taxed the whole pool.
|
||||
function Battle:giveExperiencePass(loser, def, recipients, count, halved)
|
||||
--
|
||||
-- `silent` suppresses only the GainedText line. It exists for the
|
||||
-- battle.exp_award seam below, where a mod paying the bench wants one summary
|
||||
-- line rather than a box per mon; the cart's own two passes never pass it, so
|
||||
-- vanilla prints exactly what it always did. Everything else about the pass
|
||||
-- -- the exp, the stat exp, battle.exp_gained, "grew to level", learned moves
|
||||
-- and the forget prompt -- is unaffected, because a silent award is still an
|
||||
-- award.
|
||||
function Battle:giveExperiencePass(loser, def, recipients, count, halved,
|
||||
silent)
|
||||
for _, index in ipairs(recipients) do
|
||||
local mon = self.party[index]
|
||||
if mon and (mon.hp or 0) > 0 and not mon.isEgg then
|
||||
@@ -3323,10 +3428,12 @@ function Battle:giveExperiencePass(loser, def, recipients, count, halved)
|
||||
index = index,
|
||||
})
|
||||
end
|
||||
self:emit({ kind = "experience", index = index, amount = amount,
|
||||
-- BoostedExpPointsText, keyed on the traded arm alone.
|
||||
text = self:monName(mon) .. " gained "
|
||||
.. (traded and "a boosted " or "") .. amount .. " EXP. Points!" })
|
||||
if not silent then
|
||||
self:emit({ kind = "experience", index = index, amount = amount,
|
||||
-- BoostedExpPointsText, keyed on the traded arm alone.
|
||||
text = self:monName(mon) .. " gained "
|
||||
.. (traded and "a boosted " or "") .. amount .. " EXP. Points!" })
|
||||
end
|
||||
if result.levels > 0 then
|
||||
-- "level up happiness mod", the cart's own comment, sitting right
|
||||
-- after the stat recalc and before the "grew to level" text. It fires
|
||||
@@ -3341,7 +3448,9 @@ function Battle:giveExperiencePass(loser, def, recipients, count, halved)
|
||||
local moveDef = self:moveDef(moveId)
|
||||
local moveName = (moveDef and moveDef.name) or moveId
|
||||
if ok then
|
||||
-- data/text/common_3.asm:119
|
||||
self:emit({ kind = "message",
|
||||
sfx = "Sfx_DexFanfare5079", waitSfx = true,
|
||||
text = self:monName(mon) .. " learned " .. moveName .. "!" })
|
||||
elseif reason == "full" then
|
||||
-- LearnMove's full-moveset arm calls ForgetMove, which asks with
|
||||
@@ -3420,22 +3529,38 @@ function Battle:awardExperience(loser)
|
||||
|
||||
-- battle.exp_award, the same hook BattleState:awardExp calls on Gen 1 and
|
||||
-- with the same ctx: the participant COUNT, the live participants, and an
|
||||
-- applyShare(mon, split) a mod can call to pay one mon its own share. The
|
||||
-- third applyShare argument is Gen 1's EXP.ALL announcement variant; Gen 2
|
||||
-- has no EXP.ALL (the EXP.SHARE pass below is its replacement), so it is
|
||||
-- accepted and ignored rather than changing what is printed. `recipients`,
|
||||
-- `holders` and `halved` are the Gen 2 additions.
|
||||
-- applyShare(mon, split, announce) a mod can call to pay one mon its own
|
||||
-- share. `recipients`, `holders` and `halved` are the Gen 2 additions.
|
||||
--
|
||||
-- `announce` is Gen 1's third argument (src/battle/BattleState.lua
|
||||
-- applyShare) and means the same thing here: truthy prints the mon's
|
||||
-- GainedText, falsy pays it silently. That is what lets one mod source
|
||||
-- print ONE summary line for a party-wide award on both generations instead
|
||||
-- of a box per mon -- which is what the Exp Share mod documents and could
|
||||
-- not do on Gold, because this argument used to be accepted and ignored.
|
||||
--
|
||||
-- It is honoured only when it is actually PASSED, by argument count rather
|
||||
-- than by value. A Gen 2-era mod calling applyShare(mon, split) was written
|
||||
-- against a seam that always announced and keeps announcing; a caller that
|
||||
-- passes the argument -- including an explicit nil, which is what a "pay
|
||||
-- this one quietly" call looks like -- gets Gen 1's reading. So no existing
|
||||
-- mod changes behaviour, and a mod that opts in gets parity.
|
||||
if Runtime.wantsHook("battle.exp_award") then
|
||||
local alive = {}
|
||||
for _, index in ipairs(participants) do
|
||||
local mon = self.party[index]
|
||||
if mon and (mon.hp or 0) > 0 then alive[#alive + 1] = mon end
|
||||
end
|
||||
local function applyShare(mon, split)
|
||||
local function applyShare(mon, split, ...)
|
||||
local announce = ...
|
||||
-- select("#") counts an explicit nil; `announce == nil` alone could not
|
||||
-- tell applyShare(mon, split) from applyShare(mon, split, nil), and
|
||||
-- those two have to mean different things here.
|
||||
local silent = select("#", ...) > 0 and not announce
|
||||
for index, candidate in ipairs(self.party) do
|
||||
if candidate == mon then
|
||||
return self:giveExperiencePass(loser, def, { index },
|
||||
math.max(1, split or 1), halved)
|
||||
math.max(1, split or 1), halved, silent)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -3450,8 +3575,7 @@ function Battle:awardExperience(loser)
|
||||
|
||||
-- GiveExperiencePoints .done falls through ResetBattleParticipants into
|
||||
-- AddBattleParticipant (engine/battle/core.asm:7116 and :3033).
|
||||
self.participants = {}
|
||||
if self.playerIndex then self.participants[self.playerIndex] = true end
|
||||
self:resetParticipants()
|
||||
end
|
||||
|
||||
-- The answer to a `choose-forget`: drop the move in `slot` and put the
|
||||
@@ -3472,7 +3596,9 @@ function Battle:resolveForget(index, slot, entry, moveName)
|
||||
end
|
||||
self:emit({ kind = "message",
|
||||
text = "1, 2 and… " .. self:monName(mon) .. " forgot " .. oldName .. "!" })
|
||||
-- engine/pokemon/learn.asm:115, data/text/common_3.asm:119
|
||||
self:emit({ kind = "message",
|
||||
sfx = "Sfx_DexFanfare5079", waitSfx = true,
|
||||
text = self:monName(mon) .. " learned "
|
||||
.. (moveName or (entry and entry.id) or "?") .. "!" })
|
||||
-- The forget path writes the slot itself rather than going through
|
||||
@@ -3511,6 +3637,13 @@ function Battle:switchLocked()
|
||||
return self:volatile(self.enemy).trapsTarget == true
|
||||
end
|
||||
|
||||
-- ResetBattleParticipants falls through into AddBattleParticipant
|
||||
-- (engine/battle/core.asm:3033 and :3037).
|
||||
function Battle:resetParticipants()
|
||||
self.participants = {}
|
||||
if self.playerIndex then self.participants[self.playerIndex] = true end
|
||||
end
|
||||
|
||||
-- EnemySwitch's shift arm zeroes both participant bitfields before PlayerSwitch
|
||||
-- (engine/battle/core.asm:2959-2961).
|
||||
function Battle:shiftSwitch(index)
|
||||
@@ -3533,6 +3666,7 @@ function Battle:switch(index)
|
||||
-- A mon that comes back (a REVIVE, or a second battle) has to be able to
|
||||
-- announce its own faint again; see resolveFaints.
|
||||
self.faintAnnounced = nil
|
||||
self.participantsCleared = nil
|
||||
-- ForcePlayerMonChoice has been answered, so the next faint may ask again.
|
||||
self.pendingSwitch = nil
|
||||
self.player = mon
|
||||
@@ -3770,19 +3904,41 @@ function Battle:lockedInMove(mon)
|
||||
return nil
|
||||
end
|
||||
|
||||
-- ParsePlayerAction's bide arm (engine/battle/core.asm:569-576), enemy twin
|
||||
-- at :5650
|
||||
function Battle:fightLockedMove(mon)
|
||||
local state = self:volatile(mon)
|
||||
if state.bideTurns then return state.bideMove end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- engine/battle/core.asm:627-629
|
||||
function Battle:cancelBide(mon)
|
||||
clearBide(self:volatile(mon))
|
||||
end
|
||||
|
||||
-- Encore forces the move; Disable forbids one. Both are read by the screen
|
||||
-- (to grey out the move list) and by the enemy's own choice below.
|
||||
-- engine/battle/core.asm:561-566
|
||||
local function encoredMove(state, mon)
|
||||
if not state.encore then return nil end
|
||||
for _, move in ipairs(mon.moves or {}) do
|
||||
if move.id == state.encore and (move.pp or 0) > 0 then
|
||||
return state.encore
|
||||
end
|
||||
end
|
||||
state.encore, state.encoreTurns = nil, nil
|
||||
return nil
|
||||
end
|
||||
|
||||
function Battle:forcedMove(mon)
|
||||
local locked = self:lockedInMove(mon)
|
||||
if locked then return locked end
|
||||
local state = self:volatile(mon)
|
||||
if not state.encore then return nil end
|
||||
for _, move in ipairs(mon.moves or {}) do
|
||||
if move.id == state.encore and (move.pp or 0) > 0 then return state.encore end
|
||||
end
|
||||
-- Encore ends early when the move runs out of PP.
|
||||
state.encore, state.encoreTurns = nil, nil
|
||||
return nil
|
||||
-- ParsePlayerAction reads SUBSTATUS_ENCORED ahead of the bide arm
|
||||
-- (engine/battle/core.asm:561-566).
|
||||
local encored = encoredMove(self:volatile(mon), mon)
|
||||
if encored then return encored end
|
||||
return self:fightLockedMove(mon)
|
||||
end
|
||||
|
||||
function Battle:moveDisabled(mon, moveId)
|
||||
@@ -3796,8 +3952,9 @@ function Battle:usableMoves(mon)
|
||||
-- .CheckPlayerHasUsableMoves (core.asm:533-556), so a Rollout or a rampage
|
||||
-- that spent its last PP on the opening turn keeps running: no later turn
|
||||
-- of the lock spends any. Encore is not in this exemption -- forcedMove
|
||||
-- ends it the moment the encored move runs dry.
|
||||
local locked = self:lockedInMove(mon)
|
||||
-- ends it the moment the encored move runs dry. Bide is exempt too:
|
||||
-- .CheckPlayerHasUsableMoves lives inside MoveSelectionScreen (core.asm:5058).
|
||||
local locked = self:lockedInMove(mon) or self:fightLockedMove(mon)
|
||||
local out = {}
|
||||
for _, move in ipairs(mon.moves or {}) do
|
||||
local ok = (move.pp or 0) > 0 and not self:moveDisabled(mon, move.id)
|
||||
@@ -3999,6 +4156,8 @@ function Battle:enemyTrySwitchOrItem()
|
||||
.. self:monName(outgoing) .. "!" })
|
||||
self.enemyIndex = target
|
||||
self.enemy = self.enemyParty[target]
|
||||
-- AI_Switch (engine/battle/ai/items.asm:697)
|
||||
self:resetParticipants()
|
||||
-- ResetEnemyBattleVars (engine/battle/core.asm:3016) zeroes wCurEnemyMove
|
||||
-- and wLastEnemyMove and NewEnemyMonStatus wipes the substatus bytes, so
|
||||
-- the mon coming IN starts from an empty area -- the same pair of clears
|
||||
@@ -4089,9 +4248,16 @@ function Battle:vanillaEnemyMove()
|
||||
-- move answers "TYPHLOSION's attack missed!", so Hitmonlee cannot be damaged
|
||||
-- by anything, at any level. Fifteen straight attempts at the Elite Four
|
||||
-- died there, and no amount of grinding could ever have got past it.
|
||||
local charged = self:volatile(self.enemy).chargeMove
|
||||
local enemyState = self:volatile(self.enemy)
|
||||
local charged = enemyState.chargeMove
|
||||
if charged then return charged end
|
||||
|
||||
-- engine/battle/core.asm:5524-5533: the encore arm runs ahead of
|
||||
-- CheckEnemyLockedIn (:5650).
|
||||
local encored = encoredMove(enemyState, self.enemy)
|
||||
if encored then return encored end
|
||||
if enemyState.bideTurns then return enemyState.bideMove end
|
||||
|
||||
-- Encore and Disable narrow the pool before the AI ever scores it.
|
||||
local moves = self:usableMoves(self.enemy)
|
||||
if #moves == 0 then
|
||||
@@ -4185,6 +4351,7 @@ local function runTurn(self, action)
|
||||
-- (engine/battle/core.asm:5035-5038), which reopens the 2x2 menu with the
|
||||
-- turn unspent -- so a refused RUN never bought the enemy a free attack.
|
||||
if self.runRefused then return self:takeEvents() end
|
||||
self:cancelBide(self.player)
|
||||
action = { kind = "skip" }
|
||||
end
|
||||
|
||||
@@ -4200,6 +4367,9 @@ local function runTurn(self, action)
|
||||
if action.kind == "item" and Battle.X_ITEMS[action.item] then
|
||||
Happiness.change(self.player, "USEDXITEM")
|
||||
end
|
||||
-- engine/battle/core.asm:572-573 into :627-629; a switch takes :570-571
|
||||
-- instead and keeps the store.
|
||||
if action.kind == "item" then self:cancelBide(self.player) end
|
||||
|
||||
-- AI_SwitchOrTryItem runs BEFORE the move is chosen: a trainer that decides
|
||||
-- to rotate or drink a potion spends its whole turn on it.
|
||||
@@ -4248,7 +4418,6 @@ local function runTurn(self, action)
|
||||
|
||||
local function playerAttack()
|
||||
if action.kind ~= "move" then return end
|
||||
if not self:canAct(self.player) then return end
|
||||
local move = action.move
|
||||
-- An encored mon has no choice, whatever the menu said.
|
||||
local forced = self:forcedMove(self.player)
|
||||
@@ -4268,13 +4437,20 @@ local function runTurn(self, action)
|
||||
-- player's own mon spends the rest of the battle underground.
|
||||
local stored = self:volatile(self.player).chargeMove
|
||||
if stored then move = stored end
|
||||
-- engine/battle/core.asm:558-598 settles wCurPlayerMove before
|
||||
-- engine/battle/effect_commands.asm:193 reads it.
|
||||
if not self:canAct(self.player, move) then return end
|
||||
-- CheckPlayerLockedIn quits before .CheckPlayerHasUsableMoves and before
|
||||
-- checkobedience, so a locked Rollout or Thrash is exempt from the
|
||||
-- Struggle substitution and the obedience roll the same way the second
|
||||
-- half of a charge move is.
|
||||
local charging = self:volatile(self.player).chargeMove == move
|
||||
or self:lockedInMove(self.player) == move
|
||||
if not charging and not self:hasUsableMoves(self.player) then
|
||||
-- engine/battle/core.asm:5058, and data/moves/effects.asm:796 keeps
|
||||
-- `checkobedience`.
|
||||
local bideLocked = self:fightLockedMove(self.player) == move
|
||||
if not charging and not bideLocked
|
||||
and not self:hasUsableMoves(self.player) then
|
||||
self:emit({ kind = "message",
|
||||
text = self:monName(self.player) .. " has no moves left!" })
|
||||
move = Battle.STRUGGLE
|
||||
@@ -4312,7 +4488,7 @@ local function runTurn(self, action)
|
||||
-- against a trainer, could not be escaped either.
|
||||
enemyMoveId = Battle.STRUGGLE
|
||||
end
|
||||
if not self:canAct(self.enemy) then return end
|
||||
if not self:canAct(self.enemy, enemyMoveId) then return end
|
||||
-- CheckEnemyTurn's disabled arm (engine/battle/effect_commands.asm:562-574):
|
||||
-- the AI chose before the player's Disable landed, so the turn is spent here.
|
||||
if self:moveDisabled(self.enemy, enemyMoveId) then
|
||||
|
||||
+14
-6
@@ -483,8 +483,10 @@ function Game2:learnMoveOn(mon, moveId, onDone)
|
||||
if onDone then onDone(learned) end
|
||||
end
|
||||
if ok then
|
||||
-- data/text/common_3.asm:119
|
||||
return self:say(("%s learned\n%s!"):format(name, moveName),
|
||||
function() finish(true) end)
|
||||
function() finish(true) end,
|
||||
TextBox.soundOpts(self, "Sfx_DexFanfare5079"))
|
||||
end
|
||||
if reason ~= "full" then return finish(false) end
|
||||
local askForget, pickMove, askStop
|
||||
@@ -539,9 +541,11 @@ function Game2:learnMoveOn(mon, moveId, onDone)
|
||||
-- The slot is written here rather than through Mon.learnMove, so
|
||||
-- pokemon.move_learned is raised here too.
|
||||
ModRuntime.emit("pokemon.move_learned", { mon = mon, moveId = moveId })
|
||||
-- engine/pokemon/learn.asm:115, data/text/common_3.asm:119
|
||||
self:say(("1, 2 and… Poof!\f%s forgot\n%s.\fAnd…\f%s learned\n%s!")
|
||||
:format(name, oldName, name, moveName),
|
||||
function() finish(true) end)
|
||||
function() finish(true) end,
|
||||
TextBox.soundOpts(self, "Sfx_DexFanfare5079"))
|
||||
end,
|
||||
})
|
||||
end
|
||||
@@ -706,7 +710,9 @@ function Game2:usePartyItem(itemId)
|
||||
})
|
||||
elseif action == "candy" then
|
||||
self:consumeItem(itemId)
|
||||
self:say(result.text, function() self:afterRareCandy(mon, result) end)
|
||||
-- data/text/common_1.asm:86
|
||||
self:say(result.text, function() self:afterRareCandy(mon, result) end,
|
||||
result.sfx and TextBox.soundOpts(self, result.sfx) or nil)
|
||||
else
|
||||
self:consumeItem(itemId)
|
||||
self:say(result.text)
|
||||
@@ -765,8 +771,10 @@ function Game2:useSelectItem()
|
||||
local name = (items[itemId] and items[itemId].name) or itemId
|
||||
self:say(Strings("{PLAYER} used the\n%s.", name))
|
||||
elseif outcome == "trophy_sent" then
|
||||
-- data/text/common_3.asm:372
|
||||
self:say(Strings(
|
||||
"There was a trophy\ninside!\fThe trophy was\nsent home."))
|
||||
"There was a trophy\ninside!\fThe trophy was\nsent home."),
|
||||
nil, TextBox.soundOpts(self, "Sfx_DexFanfare5079"))
|
||||
end
|
||||
-- Anything else (a fishing bite, the ITEMFINDER's queued script) already
|
||||
-- drives its own presentation off World:step -- nothing left to print here.
|
||||
@@ -778,9 +786,9 @@ end
|
||||
-- onDone that popped again ate the state UNDER the box: dismissing a message
|
||||
-- over the PACK closed the PACK with it, and over an empty overworld stack it
|
||||
-- was a silent extra pop.
|
||||
function Game2:say(text, onDone)
|
||||
function Game2:say(text, onDone, opts)
|
||||
local TextBox = require("src.render.TextBox")
|
||||
self.stack:push(TextBox.new(self, text, onDone))
|
||||
self.stack:push(TextBox.new(self, text, onDone, opts))
|
||||
end
|
||||
|
||||
-- The landmark the player is standing in, for the Pokegear map's marker.
|
||||
|
||||
+21
-1
@@ -1700,6 +1700,14 @@ local function clamp(n, lo, hi, fallback)
|
||||
return n
|
||||
end
|
||||
|
||||
-- PP and the PP Up count are unsigned bit fields of one byte
|
||||
-- (constants/pokemon_data_constants.asm:101-102)
|
||||
local function ppInt(v, fallback)
|
||||
local n = tonumber(v)
|
||||
if n == nil or n ~= n or n == math.huge or n == -math.huge then return fallback end
|
||||
return math.max(0, math.floor(n))
|
||||
end
|
||||
|
||||
local function ensureOrphaned(save)
|
||||
if not save.orphaned then
|
||||
save.orphaned = { mons = {}, items = {} }
|
||||
@@ -1763,7 +1771,7 @@ local function scrubKnownMon(mon, data)
|
||||
-- (status_screen.asm:66-76, add_mon.asm _MoveMon); deriving once here means
|
||||
-- every later reader (menus, battle, items, SGB bar zones, the link
|
||||
-- fingerprint) sees a party-shaped mon. Runs after the level clamp above
|
||||
-- so the derived stats use a sane level. A save that already has stats is
|
||||
-- so the derived stats use a sane level. A complete stat block is
|
||||
-- untouched.
|
||||
Stats.ensure(data.pokemon and data.pokemon[mon.species], mon)
|
||||
local moves = mon.moves
|
||||
@@ -1783,6 +1791,18 @@ local function scrubKnownMon(mon, data)
|
||||
moves[1] = { id = fallback, pp = def.pp }
|
||||
end
|
||||
end
|
||||
-- the replacement PP mirrors AddBonusPP (engine/items/item_effects.asm:2418);
|
||||
-- Mimic rewrites the move id and not the PP (engine/battle/effects.asm:1266)
|
||||
for j = 1, #moves do
|
||||
local slot = moves[j]
|
||||
if type(slot) == "table" then
|
||||
local mdef = data.moves and data.moves[slot.id]
|
||||
local base = ppInt(mdef and mdef.pp, 0)
|
||||
local ppUps = math.min(3, ppInt(slot.ppUps, 0))
|
||||
if slot.ppUps ~= nil then slot.ppUps = ppUps end
|
||||
slot.pp = ppInt(slot.pp, base + ppUps * math.floor(base / 5))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function scrubMonList(list, where, save, data, report)
|
||||
|
||||
+13
-6
@@ -294,7 +294,6 @@ function TouchSkin.parse(text)
|
||||
page.viewport = { x = num(vp[1], 0), y = num(vp[2], 0),
|
||||
w = num(vp[3], 1), h = num(vp[4], 1) }
|
||||
page.viewportFill = toBool(kv[p .. "_viewport_fill"])
|
||||
page.viewportExpand = toBool(kv[p .. "_viewport_expand"])
|
||||
end
|
||||
|
||||
page.pixelCoords = not page.normalized
|
||||
@@ -447,7 +446,6 @@ function TouchSkin.parseNative(text)
|
||||
page.viewport = { x = num(raw.viewport.x, 0), y = num(raw.viewport.y, 0),
|
||||
w = num(raw.viewport.w, 1), h = num(raw.viewport.h, 1) }
|
||||
page.viewportFill = raw.viewport.fill == true
|
||||
page.viewportExpand = raw.viewport.expand == true
|
||||
end
|
||||
for _, c in ipairs(raw.controls or {}) do
|
||||
local buttons, hotkeys, keys, decorative = parseBinds(c.bind or "nul")
|
||||
@@ -526,7 +524,6 @@ function TouchSkin.toNative(skin)
|
||||
x = page.viewport.x, y = page.viewport.y,
|
||||
w = page.viewport.w, h = page.viewport.h,
|
||||
fill = page.viewportFill or nil,
|
||||
expand = page.viewportExpand or nil,
|
||||
}
|
||||
end
|
||||
for _, ctl in ipairs(page.controls or {}) do
|
||||
@@ -922,7 +919,6 @@ function TouchSkin.toRetroArchConfig(skin)
|
||||
if page.viewport then
|
||||
out[#out + 1] = p .. "_viewport = " .. fmtRect(page.viewport)
|
||||
if page.viewportFill then out[#out + 1] = p .. "_viewport_fill = true" end
|
||||
if page.viewportExpand then out[#out + 1] = p .. "_viewport_expand = true" end
|
||||
end
|
||||
local controls = {}
|
||||
for _, ctl in ipairs(page.controls or {}) do
|
||||
@@ -1434,6 +1430,16 @@ local function remainderBox(ox, oy, w, h, bx, by, bw, bh)
|
||||
return best[1], best[2], best[3], best[4]
|
||||
end
|
||||
|
||||
-- The deck box pinned to the lower edge (see pageBox) leaves room above it
|
||||
-- that belongs to the game, so a screen rect flush with the top of the deck
|
||||
-- grows into it instead of showing a black band.
|
||||
local function deckHeadroom(y, vh, by, bh, oy, h)
|
||||
if by <= oy + 0.5 then return y, vh end
|
||||
if by + bh < oy + h - 0.5 then return y, vh end
|
||||
if y - by > math.max(2, bh * 0.01) then return y, vh end
|
||||
return oy, vh + (y - oy)
|
||||
end
|
||||
|
||||
function TouchSkin.pageViewport(page, w, h, ox, oy)
|
||||
if not page then return nil end
|
||||
ox, oy = ox or 0, oy or 0
|
||||
@@ -1443,12 +1449,13 @@ function TouchSkin.pageViewport(page, w, h, ox, oy)
|
||||
local x, y = bx + v.x * bw, by + v.y * bh
|
||||
local vw, vh = v.w * bw, v.h * bh
|
||||
if vw <= 0 or vh <= 0 then return nil end
|
||||
return x, y, vw, vh, page.viewportFill == true, page.viewportExpand == true
|
||||
y, vh = deckHeadroom(y, vh, by, bh, oy, h)
|
||||
return x, y, vw, vh, page.viewportFill == true
|
||||
end
|
||||
if page.screenFit == "remainder" then
|
||||
local x, y, vw, vh = remainderBox(ox, oy, w, h, bx, by, bw, bh)
|
||||
if not x then return nil end
|
||||
return x, y, vw, vh, false, false
|
||||
return x, y, vw, vh, false
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
@@ -240,6 +240,8 @@ local function rareCandy(mon, data)
|
||||
used = true,
|
||||
level = newLevel,
|
||||
learned = learned,
|
||||
-- data/text/common_1.asm:86
|
||||
sfx = "Sfx_DexFanfare5079",
|
||||
text = ("%s grew to\nlevel %d!"):format(monName(mon), newLevel),
|
||||
}
|
||||
end
|
||||
|
||||
@@ -1067,9 +1067,22 @@ local GAME_TABS = {
|
||||
local HEADER_TABS = {
|
||||
{ id = "mods", key = "tab-mods" },
|
||||
{ id = "find", key = "tab-find" },
|
||||
{ id = "skins", key = "tab-skins", glyph = true },
|
||||
{ id = "skins", key = "tab-skins", glyph = true, beta = true },
|
||||
{ id = "bug", key = "tab-bug" },
|
||||
}
|
||||
|
||||
local BETA_TAG_OPTS = { fill = true, bold = true, ink = PAL.inverse }
|
||||
|
||||
local function drawBetaTag(x, y, w, h)
|
||||
Kit.tag(x, y, w, h, "BETA", PAL.yellow, BETA_TAG_OPTS)
|
||||
end
|
||||
|
||||
local function overlayBeta(tx, ty, w, tabH, m)
|
||||
local bh = math.floor(11 * m.s)
|
||||
local bw = math.min(w, Kit.textWidth("micro", "BETA") + math.floor(10 * m.s))
|
||||
drawBetaTag(tx + (w - bw) / 2, ty + tabH - bh - math.floor(2 * m.s), bw, bh)
|
||||
end
|
||||
|
||||
for _, t in ipairs(HEADER_TABS) do
|
||||
t.opts = { face = "tab", font = "tab", color = t.color, letter = t.letter }
|
||||
if t.glyph then
|
||||
@@ -1273,6 +1286,7 @@ local function buildHeader(imp, m)
|
||||
o.image = t.icon
|
||||
o.action = chrome.tab[t.id]
|
||||
btn(imp, tx, ty, w, tabH, t.key, "", o)
|
||||
if t.beta then overlayBeta(tx, ty, w, tabH, m) end
|
||||
tx = tx + w + tabGap
|
||||
end
|
||||
-- The bug-report chip sits LAST, past the sync chip.
|
||||
@@ -1290,10 +1304,7 @@ local function buildHeader(imp, m)
|
||||
local o = chrome.sync
|
||||
o.active = imp._syncModal ~= nil
|
||||
btn(imp, tx, ty, w, tabH, "tab-sync", "", o)
|
||||
local bh = math.floor(11 * m.s)
|
||||
local bw = math.min(w, Kit.textWidth("micro", "BETA") + math.floor(10 * m.s))
|
||||
Kit.tag(tx + (w - bw) / 2, ty + tabH - bh - math.floor(2 * m.s), bw, bh,
|
||||
"BETA", o.active and PAL.inverse or PAL.yellow)
|
||||
overlayBeta(tx, ty, w, tabH, m)
|
||||
local eng = imp._sync
|
||||
if eng and eng.busy and eng:busy() then
|
||||
Kit.spinner(tx + w - math.floor(8 * m.s), ty + math.floor(8 * m.s),
|
||||
@@ -1309,7 +1320,7 @@ local function buildHeader(imp, m)
|
||||
return y + math.floor(10 * m.s)
|
||||
end
|
||||
|
||||
-- The state of the self-updater, as a top-right control.
|
||||
-- The state of the self-updater, shown in the launcher footer.
|
||||
-- Returns status, label, action, glow.
|
||||
function LauncherView._updateControl(imp)
|
||||
if not imp.Check then return nil end
|
||||
@@ -4490,8 +4501,8 @@ local function syncTitle(imp, m, px, py, pw, pad)
|
||||
Kit.text("button", label, px + pad, py, PAL.heading)
|
||||
local bh = math.floor(15 * m.s)
|
||||
local bw = Kit.textWidth("micro", "BETA") + math.floor(14 * m.s)
|
||||
Kit.tag(px + pad + Kit.textWidth("button", label) + math.floor(8 * m.s),
|
||||
py + (Kit.textHeight("button") - bh) / 2, bw, bh, "BETA", PAL.yellow)
|
||||
drawBetaTag(px + pad + Kit.textWidth("button", label) + math.floor(8 * m.s),
|
||||
py + (Kit.textHeight("button") - bh) / 2, bw, bh)
|
||||
return py + Kit.textHeight("button") + math.floor(12 * m.s)
|
||||
end
|
||||
|
||||
|
||||
@@ -5151,6 +5151,7 @@ function RomExtractorGen2:extractMenuGfx()
|
||||
}
|
||||
out.pack = pack
|
||||
out.pokedex = self:pokedexGfx()
|
||||
out.billsPc = self:billsPcGfx()
|
||||
out.pokegear = self:pokegearGfx()
|
||||
out.trainerCard = self:trainerCardGfx()
|
||||
out.unownPuzzle = self:unownPuzzleGfx()
|
||||
@@ -5697,6 +5698,25 @@ function RomExtractorGen2:pokedexGfx()
|
||||
return dex
|
||||
end
|
||||
|
||||
-- BillsPC_InitGFX's four tiles at vTiles2 $5c
|
||||
-- (engine/pokemon/bills_pc.asm:2170-2173)
|
||||
function RomExtractorGen2:billsPcGfx()
|
||||
if not self.symbols["PCMailGFX"] then return nil end
|
||||
local pc = {}
|
||||
local gfx = self:symbol("PCMailGFX")
|
||||
self:write2bpp(self.rom:bytes(gfx.bank, gfx.address, 4 * 16),
|
||||
32, 8, "pc/mail_item.png")
|
||||
pc.icons = "assets/generated/pc/mail_item.png"
|
||||
pc.firstTile = 0x5c
|
||||
pc.palette = self:predefPal(PREDEFPAL_POKEDEX)
|
||||
-- gfx/pc/orange.pal
|
||||
if self.symbols["BillsPCOrangePalette"] then
|
||||
local orange = self:symbol("BillsPCOrangePalette")
|
||||
pc.orangePalette = self:colors(orange.bank, orange.address, 4)
|
||||
end
|
||||
return pc
|
||||
end
|
||||
|
||||
-- Reads a `tile, count` RLE tilemap (Pokegear_LoadTilemapRLE). The routine's
|
||||
-- own comment says "repeat count, tile ID" and has it backwards: it loads b
|
||||
-- from the first byte, c from the second, and writes `b` c times. $ff ends
|
||||
|
||||
@@ -146,6 +146,8 @@ local VERSION_REQUIRED_FILES_OVERRIDE = {
|
||||
-- complete and SlotMachine crashed on its labelled-cell fallback.
|
||||
"assets/generated/slots/gold_slots_1.png",
|
||||
"assets/generated/card_flip/card_flip_1.png",
|
||||
-- PCMailGFX (engine/pokemon/bills_pc.asm:2170-2173)
|
||||
"assets/generated/pc/mail_item.png",
|
||||
},
|
||||
}
|
||||
-- Same Gen 2 extract, so a Silver cache is complete when the same files exist.
|
||||
@@ -1084,13 +1086,19 @@ end
|
||||
-- naive "first ROM wins" scan would re-import Red when the player tries to
|
||||
-- add Blue (issue #167). Yellow and Gold carts are typically .gbc (Gold is
|
||||
-- 2 MiB).
|
||||
local function findPendingRom(ready)
|
||||
-- `wanted` narrows the scan to one version. A Choose names the cart it is
|
||||
-- for, so without it several dumps sitting in the save directory answered in
|
||||
-- listing order and the selection was dropped: picking Red imported Blue
|
||||
-- (#1274). The callers that are not a Choose, the Android USB-drop scans,
|
||||
-- pass nothing and still take the first pending cart of any version.
|
||||
local function findPendingRom(ready, wanted)
|
||||
for _, name in ipairs(love.filesystem.getDirectoryItems("")) do
|
||||
if name:lower():match("%.gbc?$") and love.filesystem.getInfo(name, "file") then
|
||||
local data = love.filesystem.read(name)
|
||||
if type(data) == "string" and isAcceptedRomSize(#data) then
|
||||
local version = GameVersion.forSha1(sha1(data))
|
||||
if version and not ready[version] then
|
||||
if version and not ready[version]
|
||||
and (wanted == nil or version == wanted) then
|
||||
return name, data
|
||||
end
|
||||
end
|
||||
@@ -2573,8 +2581,9 @@ function RomImporter:choose(version)
|
||||
if self.android then
|
||||
-- Prefer a not-yet-imported .gb/.gbc already in the save dir (USB copy, or
|
||||
-- a fresh SAF pick). Never reuse an already-imported cart's file -- that
|
||||
-- was the #167 failure mode (second Choose just re-extracted Red).
|
||||
local name, data = findPendingRom(self.ready)
|
||||
-- was the #167 failure mode (second Choose just re-extracted Red). This
|
||||
-- is a Choose, so it takes the cart that was asked for and nothing else.
|
||||
local name, data = findPendingRom(self.ready, self.chooseVersion)
|
||||
if name then
|
||||
self:startData(data, name)
|
||||
elseif consumePickedRomError(self) then
|
||||
@@ -2602,8 +2611,10 @@ function RomImporter:choose(version)
|
||||
-- Handheld Linux (Anbernic stock OS / PortMaster) rarely has zenity or
|
||||
-- kdialog. Fall back to the same "drop a .gb/.gbc next to the game" scan
|
||||
-- used on Android, which works when the game is launched as an unpacked
|
||||
-- directory (see build-rg34xxsp.sh).
|
||||
local name, data = findPendingRom(self.ready)
|
||||
-- directory (see build-rg34xxsp.sh). Narrowed to the chosen version: with
|
||||
-- four dumps in the folder the unnarrowed scan answered in listing order,
|
||||
-- so Choose Red imported and decoded Blue (#1274).
|
||||
local name, data = findPendingRom(self.ready, self.chooseVersion)
|
||||
if name then
|
||||
self:startData(data, name)
|
||||
return
|
||||
|
||||
@@ -10,13 +10,14 @@
|
||||
local Bag = {}
|
||||
|
||||
-- MAX_ITEMS / MAX_BALLS / MAX_KEY_ITEMS, and the TM/HM pocket holds one of
|
||||
-- every TM plus the seven HMs (NUM_TMS + NUM_HMS). A `mods` bagSize override
|
||||
-- every TM plus the seven HMs (NUM_TMS + NUM_HMS -- ram/wram.asm:2421 is
|
||||
-- `wTMsHMs:: ds NUM_TMS + NUM_HMS`, 57 bytes). A `mods` bagSize override
|
||||
-- replaces the ITEM pocket only, the way the Gen 1 single-bag config did.
|
||||
local POCKET_CAPACITY = {
|
||||
ITEM = 20,
|
||||
BALL = 12,
|
||||
KEY_ITEM = 25,
|
||||
TM_HM = 64,
|
||||
TM_HM = 57,
|
||||
}
|
||||
local DEFAULT_CAPACITY = 20
|
||||
|
||||
|
||||
+46
-5
@@ -808,6 +808,33 @@ local function buildOverworld()
|
||||
local Movement = rawRequire("src.script.gen2.Movement")
|
||||
local HiddenItems = rawRequire("src.world.gen2.HiddenItems")
|
||||
local Bike = rawRequire("src.world.gen2.Bike")
|
||||
local FieldMoves = rawRequire("src.world.gen2.FieldMoves")
|
||||
|
||||
-- home/map.asm:1869
|
||||
local function stepAllowed(world, entity, dir, cx, cy)
|
||||
local d = Map2.DELTA[dir]
|
||||
if not (world.map and d and entity) then return true end
|
||||
cx, cy = cx or entity.cellX, cy or entity.cellY
|
||||
if not (cx and cy) then return true end
|
||||
if not Permissions.stepPermitted(
|
||||
function(px, py) return world:cellCollisionAcross(world.map, px, py) end,
|
||||
cx, cy, dir) then
|
||||
return false
|
||||
end
|
||||
local map = world.map
|
||||
if entity == world.player and FieldMoves.isSurfing(world.playerState) then
|
||||
map = world:surfMap(world.map)
|
||||
end
|
||||
local tx, ty = cx + d[1], cy + d[2]
|
||||
if not map:inBounds(tx, ty) or not map:isWalkable(tx, ty) then return false end
|
||||
for _, e in ipairs(world.entities or {}) do
|
||||
if e ~= entity and not e.passable then
|
||||
if e.cellX == tx and e.cellY == ty then return false end
|
||||
if e.moving and e.targetX == tx and e.targetY == ty then return false end
|
||||
end
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
local api = nil
|
||||
-- one WorldAPI instance, so queueScript reuses the five-verb allow list
|
||||
@@ -1011,20 +1038,33 @@ local function buildOverworld()
|
||||
|
||||
-- Gold has ONE movement slot; a second concurrent call is refused with a
|
||||
-- reason rather than dropped (src/world/gen2/WorldAPI.lua:171's recipe).
|
||||
function ow.scriptMove(entity, dir, tiles, onDone)
|
||||
function ow.scriptMove(entity, dir, tiles, onDone, opts)
|
||||
local world = w("scriptMove")
|
||||
if not world then return nil, "no overworld" end
|
||||
if world.moveState then return nil, "a movement is already running" end
|
||||
local step = Movement.stepByte(dir)
|
||||
if not step then return nil, "unknown direction: " .. tostring(dir) end
|
||||
local bytes = {}
|
||||
for _ = 1, math.max(0, tiles or 1) do bytes[#bytes + 1] = step end
|
||||
bytes[#bytes + 1] = Movement.STEP_END
|
||||
local objectId = objectIdOf(world, entity)
|
||||
if not objectId then
|
||||
return nil, "no Gen 2 objectId for that entity: only the player and a "
|
||||
.. "mapped object (def.index) can be moved"
|
||||
end
|
||||
local n = math.max(0, tiles or 1)
|
||||
if opts and opts.collide and n > 0 then
|
||||
local d = Map2.DELTA[dir]
|
||||
local cx, cy = entity.cellX, entity.cellY
|
||||
local allowed = 0
|
||||
for _ = 1, n do
|
||||
if not stepAllowed(world, entity, dir, cx, cy) then break end
|
||||
allowed = allowed + 1
|
||||
if d and cx and cy then cx, cy = cx + d[1], cy + d[2] end
|
||||
end
|
||||
if allowed < n then entity.facing = dir end
|
||||
n = allowed
|
||||
end
|
||||
local bytes = {}
|
||||
for _ = 1, n do bytes[#bytes + 1] = step end
|
||||
bytes[#bytes + 1] = Movement.STEP_END
|
||||
world:beginMovement(objectId, bytes, onDone)
|
||||
return true
|
||||
end
|
||||
@@ -1401,7 +1441,8 @@ COVERAGE[OW] = {
|
||||
.. "objectId 1 is wLastTalked, not object zero",
|
||||
scriptMove = "the player maps to objectId 0 and a mapped object to "
|
||||
.. "def.index + 1; an entity with neither (a mod's own guest) is "
|
||||
.. "REFUSED with a reason rather than moving the last-talked NPC",
|
||||
.. "REFUSED with a reason rather than moving the last-talked NPC; "
|
||||
.. "opts.collide truncates the walk at the first blocked step",
|
||||
connectionLanding = "Gen 1's five values (destDef, tilesetDef, x, y, "
|
||||
.. "conn); `conn` is Gold's connection record, keyed map/mapId + offset",
|
||||
timeOfDay = "recomputed from the clock every call and never cached, so a "
|
||||
|
||||
@@ -119,6 +119,17 @@ local GEN1_ONLY_MODULES = {
|
||||
["src.ui.OptionsMenu"] = true,
|
||||
}
|
||||
|
||||
local function crossGenerationDenial(name, generation)
|
||||
if type(name) ~= "string" or generation ~= 1 then return nil end
|
||||
if not (name:find("^src%.[%w_]+%.gen2%.") or name == "src.core.Game2") then
|
||||
return nil
|
||||
end
|
||||
return ("%s is a Gen 2 engine module and this is a Gen 1 game; the structs "
|
||||
.. "it reads and writes are not this game's, so anything it stores lands "
|
||||
.. "on the save in the wrong shape. Take the game from mod.game and the "
|
||||
.. "world from mod.world, which resolve per generation"):format(name)
|
||||
end
|
||||
|
||||
-- the src.* modules the mod surface points authors at: another mod's
|
||||
-- exports carry a version string that wants range-checking before use, and
|
||||
-- ChipAsm is the authoring path for chip music and sfx
|
||||
@@ -214,6 +225,7 @@ function Loader:_installDevShim()
|
||||
if owner or callerIsMod(3) then
|
||||
local id = type(owner) == "string" and owner or nil
|
||||
local denial = Sandbox.moduleDenial(name, devShim.permissions[id])
|
||||
or (id and crossGenerationDenial(name, devShim.generation))
|
||||
if denial then error(("[%s] %s"):format(id or "mod", denial), 0) end
|
||||
end
|
||||
if devShim.dev or devShim.generation ~= 1 then scanRequire(name) end
|
||||
|
||||
+13
-4
@@ -51,11 +51,20 @@ end
|
||||
-- (engine/pokemon/status_screen.asm:66-76, "mon is in a box or daycare" ->
|
||||
-- CalcStats) and when one is moved back into the party
|
||||
-- (engine/pokemon/add_mon.asm _MoveMon tail). The stored current HP is
|
||||
-- kept (box_struct does hold it) but clamped to the recalculated maximum so
|
||||
-- a tampered save cannot overfill the bar. A mon that already has stats is
|
||||
-- returned untouched, so a vanilla save round-trips. #233, #304
|
||||
-- kept (box_struct does hold it) but clamped to the recalculated maximum.
|
||||
-- A complete stat block is returned untouched; an incomplete one is rebuilt,
|
||||
-- because CalcStats writes all NUM_STATS stats or none
|
||||
-- (home/move_mon.asm:33-48). #233, #304, #1517
|
||||
local function statsComplete(stats)
|
||||
for _, key in ipairs(ORDER) do
|
||||
if type(stats[key]) ~= "number" then return false end
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
function Stats.ensure(speciesDef, mon)
|
||||
if type(mon) ~= "table" or type(mon.stats) == "table" then return mon end
|
||||
if type(mon) ~= "table" then return mon end
|
||||
if type(mon.stats) == "table" and statsComplete(mon.stats) then return mon end
|
||||
if type(speciesDef) ~= "table" or type(speciesDef.baseStats) ~= "table" then
|
||||
return mon
|
||||
end
|
||||
|
||||
@@ -3,8 +3,6 @@ local TouchSkin = require("src.core.TouchSkin")
|
||||
|
||||
local Playfield = {}
|
||||
|
||||
Playfield.WIDTH, Playfield.HEIGHT = 160, 144
|
||||
|
||||
Playfield.entered = false
|
||||
Playfield.box = nil
|
||||
|
||||
@@ -35,14 +33,9 @@ function Playfield.cutout(sw, sh)
|
||||
end
|
||||
|
||||
function Playfield.rect(sw, sh)
|
||||
local x, y, w, h, _, expand = Playfield.cutout(sw, sh)
|
||||
local x, y, w, h = Playfield.cutout(sw, sh)
|
||||
if not x then return 0, 0, sw or 0, sh or 0, false end
|
||||
if expand then return x, y, w, h, true end
|
||||
local s = math.max(1, math.floor(math.min(w / Playfield.WIDTH,
|
||||
h / Playfield.HEIGHT)))
|
||||
local pw = math.min(w, Playfield.WIDTH * s)
|
||||
local ph = math.min(h, Playfield.HEIGHT * s)
|
||||
return x + math.floor((w - pw) / 2), y + math.floor((h - ph) / 2), pw, ph, true
|
||||
return x, y, w, h, true
|
||||
end
|
||||
|
||||
function Playfield.enter(x, y, w, h)
|
||||
|
||||
@@ -87,12 +87,12 @@ local function displayMetrics()
|
||||
if dpiX < 1e-6 then dpiX = 1 end
|
||||
if dpiY < 1e-6 then dpiY = 1 end
|
||||
local vx, vy = 0, 0
|
||||
local cut, grow = false, false
|
||||
local sx, sy, sw, sh, _, expand = Playfield.cutout(pw, ph)
|
||||
local cut = false
|
||||
local sx, sy, sw, sh = Playfield.cutout(pw, ph)
|
||||
if sx then
|
||||
vx, vy, pw, ph, cut, grow = sx, sy, sw, sh, true, expand
|
||||
vx, vy, pw, ph, cut = sx, sy, sw, sh, true
|
||||
end
|
||||
return ww, wh, pw, ph, dpiX, dpiY, vx, vy, cut, grow
|
||||
return ww, wh, pw, ph, dpiX, dpiY, vx, vy, cut
|
||||
end
|
||||
|
||||
local function positionLift(ph, contentPx, dpiY, cut)
|
||||
@@ -275,7 +275,7 @@ end
|
||||
-- corners; flat mode returns exactly today's size (growth factor is 1 when
|
||||
-- tilt is inactive).
|
||||
function Renderer:worldViewSize()
|
||||
local _, _, pw, ph, _, dpiY, _, _, cut, grow = displayMetrics()
|
||||
local _, _, pw, ph, _, dpiY, _, _, cut = displayMetrics()
|
||||
-- FAITHFUL RATIO on mobile. The world pass deliberately expands to cover the
|
||||
-- WHOLE display, so letterbox voids become more map instead of black bars.
|
||||
-- That is why the lock appeared to do nothing in the overworld: it shrank
|
||||
@@ -287,7 +287,6 @@ function Renderer:worldViewSize()
|
||||
-- this is the same sum with the viewport standing in for the window, so
|
||||
-- both platforms show the same map area at the same zoom.
|
||||
local cap = FaithfulRes.scaleCap()
|
||||
if not cap and cut and not grow then cap = self:fitScale() end
|
||||
if cap then
|
||||
local uiw, uih = self:uiSize()
|
||||
pw = cut and math.min(pw, uiw * cap) or uiw * cap
|
||||
|
||||
@@ -85,7 +85,9 @@ function Transition.new(game, onMidpoint, onDone, warp)
|
||||
end
|
||||
|
||||
function Transition:finish()
|
||||
self.game.stack:pop()
|
||||
if self.done then return end
|
||||
self.done = true
|
||||
if not self.offStack then self.game.stack:pop() end
|
||||
if self.onDone then self.onDone() end
|
||||
end
|
||||
|
||||
@@ -96,10 +98,16 @@ function Transition:update(dt)
|
||||
self.t = 0
|
||||
if self.phase == "out" then
|
||||
self.phase = "in"
|
||||
if self.onMidpoint then self.onMidpoint() end
|
||||
-- LoadGBPal restores the palettes in one write, so with no fade in the
|
||||
-- map is simply there on the next frame
|
||||
if (self.framesIn or 0) <= 0 then self:finish() end
|
||||
if (self.framesIn or 0) <= 0 then
|
||||
self.offStack = true
|
||||
self.game.stack:pop()
|
||||
if self.onMidpoint then self.onMidpoint() end
|
||||
self:finish()
|
||||
elseif self.onMidpoint then
|
||||
self.onMidpoint()
|
||||
end
|
||||
else
|
||||
self:finish()
|
||||
end
|
||||
|
||||
+330
-56
@@ -5,12 +5,14 @@ local TouchSkin = require("src.core.TouchSkin")
|
||||
local TouchControls = require("src.core.TouchControls")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local FilePicker = require("src.core.FilePicker")
|
||||
local SafeArea = require("src.core.SafeArea")
|
||||
|
||||
local Studio = {}
|
||||
|
||||
Studio.CANVASES = {
|
||||
{ id = "phone_portrait", label = "Phone portrait", w = 1080, h = 1920 },
|
||||
{ id = "phone_landscape", label = "Phone landscape", w = 1920, h = 1080 },
|
||||
{ id = "this_device", label = "This screen", live = true },
|
||||
{ id = "tablet_portrait", label = "Tablet portrait", w = 1536, h = 2048 },
|
||||
{ id = "tablet_landscape", label = "Tablet landscape", w = 2048, h = 1536 },
|
||||
{ id = "steamdeck", label = "Steam Deck", w = 1280, h = 800 },
|
||||
@@ -20,6 +22,17 @@ Studio.CANVASES = {
|
||||
lockViewport = { x = 48 / 256, y = 40 / 224, w = 160 / 256, h = 144 / 224 } },
|
||||
}
|
||||
|
||||
-- Editor view zoom: 1 is contain-fit. Smaller values shrink the mock
|
||||
-- device inside the workspace so the screen hole can be dragged past the
|
||||
-- bezel and you can still grab the handles.
|
||||
Studio.ZOOM_LEVELS = { 1, 0.75, 0.5, 0.35 }
|
||||
Studio.viewZoom = 1
|
||||
|
||||
-- Hydrated live canvas; canvas() is called many times a frame so this is
|
||||
-- mutated in place instead of allocating a new table.
|
||||
local liveCanvas = { id = "this_device", label = "This screen", live = true,
|
||||
w = 1080, h = 1920 }
|
||||
|
||||
-- Per-page lock, and whether the canvas preset follows it. Match is on
|
||||
-- by default so a portrait/landscape overlay pair does not need two
|
||||
-- separate clicks to preview the right way up. #1503
|
||||
@@ -100,8 +113,92 @@ end
|
||||
|
||||
local function round(v) return math.floor(v + 0.5) end
|
||||
|
||||
function Studio.deviceSize()
|
||||
if love and love.graphics and love.graphics.getDimensions then
|
||||
local w, h = love.graphics.getDimensions()
|
||||
w, h = tonumber(w), tonumber(h)
|
||||
if w and h and w > 1 and h > 1 then return w, h end
|
||||
end
|
||||
return 1080, 1920
|
||||
end
|
||||
|
||||
function Studio.canvasIndexById(id)
|
||||
for i, c in ipairs(Studio.CANVASES) do
|
||||
if c.id == id then return i end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
function Studio.canvas()
|
||||
return Studio.CANVASES[Studio.canvasIndex] or Studio.CANVASES[1]
|
||||
local spec = Studio.CANVASES[Studio.canvasIndex] or Studio.CANVASES[1]
|
||||
if spec.live then
|
||||
local w, h = Studio.deviceSize()
|
||||
liveCanvas.id = spec.id
|
||||
liveCanvas.label = spec.label
|
||||
liveCanvas.w, liveCanvas.h = w, h
|
||||
liveCanvas.live = true
|
||||
liveCanvas.lockViewport = spec.lockViewport
|
||||
return liveCanvas
|
||||
end
|
||||
return spec
|
||||
end
|
||||
|
||||
function Studio.zoomIndex()
|
||||
local z = Studio.viewZoom or 1
|
||||
local best, dist = 1, math.huge
|
||||
for i, v in ipairs(Studio.ZOOM_LEVELS) do
|
||||
local d = math.abs(v - z)
|
||||
if d < dist then best, dist = i, d end
|
||||
end
|
||||
return best
|
||||
end
|
||||
|
||||
function Studio.zoomOut()
|
||||
local i = Studio.zoomIndex()
|
||||
if i < #Studio.ZOOM_LEVELS then
|
||||
Studio.viewZoom = Studio.ZOOM_LEVELS[i + 1]
|
||||
end
|
||||
return Studio.viewZoom
|
||||
end
|
||||
|
||||
function Studio.zoomIn()
|
||||
local i = Studio.zoomIndex()
|
||||
if i > 1 then
|
||||
Studio.viewZoom = Studio.ZOOM_LEVELS[i - 1]
|
||||
end
|
||||
return Studio.viewZoom
|
||||
end
|
||||
|
||||
function Studio.zoomFit()
|
||||
Studio.viewZoom = Studio.ZOOM_LEVELS[1]
|
||||
return Studio.viewZoom
|
||||
end
|
||||
|
||||
function Studio.detectDeviceCanvas()
|
||||
local idx = Studio.canvasIndexById("this_device")
|
||||
if not idx then return nil end
|
||||
Studio.setCanvas(idx)
|
||||
local canvas = Studio.canvas()
|
||||
Studio.setStatus(("This screen: %dx%d"):format(canvas.w, canvas.h))
|
||||
return canvas
|
||||
end
|
||||
|
||||
-- Usable chrome rect. Background still fills the window so the notch
|
||||
-- band is the same colour as the rest of the studio; every button and
|
||||
-- the mock device live inside the safe area, matching the launcher.
|
||||
function Studio.safeFrame()
|
||||
local W, H = 0, 0
|
||||
if love and love.graphics and love.graphics.getDimensions then
|
||||
W, H = love.graphics.getDimensions()
|
||||
end
|
||||
W, H = math.max(1, tonumber(W) or 1), math.max(1, tonumber(H) or 1)
|
||||
local ox, oy, sw, sh = SafeArea.rect()
|
||||
ox = math.max(0, tonumber(ox) or 0)
|
||||
oy = math.max(0, tonumber(oy) or 0)
|
||||
sw = math.max(1, tonumber(sw) or W)
|
||||
sh = math.max(1, tonumber(sh) or H)
|
||||
Studio._frame = { W = W, H = H, x = ox, y = oy, w = sw, h = sh }
|
||||
return W, H, ox, oy, sw, sh
|
||||
end
|
||||
|
||||
function Studio.page()
|
||||
@@ -256,11 +353,19 @@ end
|
||||
local function canvasOrientation(canvas)
|
||||
canvas = canvas or Studio.canvas()
|
||||
if not canvas then return nil end
|
||||
return canvas.w > canvas.h and "landscape" or "portrait"
|
||||
local w, h = canvas.w, canvas.h
|
||||
if canvas.live and (not w or not h) then
|
||||
w, h = Studio.deviceSize()
|
||||
end
|
||||
if not w or not h then return nil end
|
||||
return w > h and "landscape" or "portrait"
|
||||
end
|
||||
|
||||
local function pickCanvasIndex(want)
|
||||
local cur = Studio.canvas()
|
||||
-- A live "this screen" canvas already is the window; keep it so Match
|
||||
-- canvas cannot yank a phone author back onto a generic 16:9 preset.
|
||||
if cur and cur.live then return Studio.canvasIndex end
|
||||
if canvasOrientation(cur) == want then return Studio.canvasIndex end
|
||||
if cur and cur.id then
|
||||
local hint = cur.id:gsub("portrait", want):gsub("landscape", want)
|
||||
@@ -269,7 +374,10 @@ local function pickCanvasIndex(want)
|
||||
end
|
||||
end
|
||||
for i, c in ipairs(Studio.CANVASES) do
|
||||
if canvasOrientation(c) == want and not c.lockViewport then return i end
|
||||
if not c.live and not c.lockViewport
|
||||
and canvasOrientation(c) == want then
|
||||
return i
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
@@ -348,9 +456,10 @@ function Studio.setCanvas(index, fromSync)
|
||||
page.viewportFill = false
|
||||
markDirty()
|
||||
end
|
||||
-- A cfg-authored aspect_ratio is the overlay's design aspect; keep it so
|
||||
-- the preview letterboxes like RetroArch instead of stretching (#1503).
|
||||
if page and not page.aspectFromCfg then
|
||||
-- A cfg-authored aspect_ratio is the overlay's design aspect, and bezel art
|
||||
-- is its own design canvas (TouchSkin.applyImageAspect); keep either so the
|
||||
-- preview lays the page out exactly the way the game does (#1503).
|
||||
if page and not (page.aspectFromCfg or page.aspectFromImage) then
|
||||
page.aspect = canvas.w / canvas.h
|
||||
end
|
||||
end
|
||||
@@ -368,6 +477,14 @@ function Studio.load(opts)
|
||||
Studio.drag = nil
|
||||
Studio.pendingPlay = false
|
||||
Studio.canvasIndex = 1
|
||||
Studio.viewZoom = 1
|
||||
-- On a phone the mock device should be this screen's form factor, not a
|
||||
-- generic 1080x1920 16:9, so the hole and the pad land where they will
|
||||
-- at play time.
|
||||
if Studio.isMobile() then
|
||||
local live = Studio.canvasIndexById("this_device")
|
||||
if live then Studio.canvasIndex = live end
|
||||
end
|
||||
-- A free-form screen is the default. 10:9 is an optional convenience,
|
||||
-- never a restriction on imported or custom skins.
|
||||
Studio.aspectLock = false
|
||||
@@ -1065,6 +1182,11 @@ function Studio.openPageMenu()
|
||||
return true
|
||||
end
|
||||
|
||||
function Studio.openScreenMenu()
|
||||
Studio.openModal("screen")
|
||||
return true
|
||||
end
|
||||
|
||||
function Studio.renamePage(name)
|
||||
local page = Studio.page()
|
||||
if not page then return false end
|
||||
@@ -1222,8 +1344,13 @@ end
|
||||
function Studio.canvasRect(x, y, w, h)
|
||||
local canvas = Studio.canvas()
|
||||
local aspect = canvas.w / canvas.h
|
||||
if not aspect or aspect <= 0 then aspect = 1 end
|
||||
local cw, ch = w, w / aspect
|
||||
if ch > h then ch, cw = h, h * aspect end
|
||||
local z = Studio.viewZoom or 1
|
||||
if z < 0.999 then
|
||||
cw, ch = cw * z, ch * z
|
||||
end
|
||||
return x + (w - cw) * 0.5, y + (h - ch) * 0.5, cw, ch
|
||||
end
|
||||
|
||||
@@ -1430,10 +1557,12 @@ local function drawGameTest(r, page)
|
||||
end
|
||||
|
||||
local function drawCanvas(x, y, w, h)
|
||||
Studio.canvasWorkspace = { x = x, y = y, w = w, h = h }
|
||||
local page = Studio.page()
|
||||
local r = { }
|
||||
r.x, r.y, r.w, r.h = Studio.canvasRect(x, y, w, h)
|
||||
Studio.lastCanvas = r
|
||||
Theme.fill(x, y, w, h, { 10, 12, 18 }, 1)
|
||||
if not page then return r end
|
||||
|
||||
Theme.fill(r.x, r.y, r.w, r.h, { 0, 0, 0 }, 1)
|
||||
@@ -1745,12 +1874,18 @@ function Studio.commitField(id, text)
|
||||
end
|
||||
|
||||
local function modalFrame(W, H, title, wFrac, hFrac)
|
||||
Theme.fill(0, 0, W, H, PAL.bg, 0.85)
|
||||
local ox, oy, sw, sh, winW, winH = 0, 0, W, H, W, H
|
||||
local f = Studio._frame
|
||||
if f then
|
||||
ox, oy, sw, sh = f.x, f.y, f.w, f.h
|
||||
winW, winH = f.W, f.H
|
||||
end
|
||||
Theme.fill(0, 0, winW, winH, PAL.bg, 0.85)
|
||||
local pad = 14 * Kit.scale
|
||||
local mw = math.min(W - pad * 2, math.max(320 * Kit.scale, (wFrac or 0.6) * W))
|
||||
local mh = math.min(H - pad * 2, math.max(220 * Kit.scale, (hFrac or 0.72) * H))
|
||||
local mx = math.floor((W - mw) * 0.5)
|
||||
local my = math.floor((H - mh) * 0.5)
|
||||
local mw = math.min(sw - pad * 2, math.max(320 * Kit.scale, (wFrac or 0.6) * sw))
|
||||
local mh = math.min(sh - pad * 2, math.max(220 * Kit.scale, (hFrac or 0.72) * sh))
|
||||
local mx = math.floor(ox + (sw - mw) * 0.5)
|
||||
local my = math.floor(oy + (sh - mh) * 0.5)
|
||||
Kit.card(mx, my, mw, mh)
|
||||
Kit.textBold("title", title, mx + pad, my + pad * 0.7, PAL.heading)
|
||||
return mx, my, mw, mh, pad
|
||||
@@ -2022,6 +2157,79 @@ local function drawExportModal(W, H)
|
||||
mx + pad, cy, mw - pad * 2, PAL.faint, 2)
|
||||
end
|
||||
|
||||
local function drawScreenModal(W, H)
|
||||
local modal = Studio.modal
|
||||
local mx, my, mw, mh, pad = modalFrame(W, H, "Screen & canvas", 0.62, 0.82)
|
||||
local top = my + pad + Kit.textHeight("title") + pad * 0.5
|
||||
local by, bh = modalFooter(mx, my, mw, mh, pad)
|
||||
local gap = 6 * Kit.scale
|
||||
local rowH = math.max(Kit.tapMin(), 34 * Kit.scale)
|
||||
local innerW = mw - pad * 2
|
||||
local half = (innerW - gap) * 0.5
|
||||
local page = Studio.page()
|
||||
local locked = Studio.canvas().lockViewport
|
||||
|
||||
local vpOn = page and (page.viewport or page.screenFit == "remainder")
|
||||
if Kit.button(mx + pad, top, half, rowH,
|
||||
vpOn and "Cutout: ON" or "Cutout: OFF",
|
||||
{ id = "screen-cutout", active = vpOn == true,
|
||||
enabled = not locked }) then
|
||||
Studio.toggleViewport()
|
||||
end
|
||||
if Kit.button(mx + pad + half + gap, top, half, rowH,
|
||||
Studio.aspectLock and "Shape: 10:9" or "Shape: Free",
|
||||
{ id = "screen-shape", active = Studio.aspectLock }) then
|
||||
Studio.aspectLock = not Studio.aspectLock
|
||||
end
|
||||
top = top + rowH + gap
|
||||
if Kit.button(mx + pad, top, innerW, rowH, "Detect screen from bezel",
|
||||
{ id = "screen-bezel",
|
||||
enabled = page ~= nil and page.imagePath ~= nil and not locked }) then
|
||||
Studio.detectViewport()
|
||||
end
|
||||
top = top + rowH + gap
|
||||
local live = Studio.canvas()
|
||||
local detectLabel = ("Detect this screen (%dx%d)"):format(live.w, live.h)
|
||||
if live.id ~= "this_device" then
|
||||
local w, h = Studio.deviceSize()
|
||||
detectLabel = ("Detect this screen (%dx%d)"):format(w, h)
|
||||
end
|
||||
if Kit.button(mx + pad, top, innerW, rowH, detectLabel,
|
||||
{ id = "screen-device", kind = "accent" }) then
|
||||
Studio.detectDeviceCanvas()
|
||||
end
|
||||
top = top + rowH + gap * 1.5
|
||||
Kit.caption(mx + pad, top, "CANVAS")
|
||||
top = top + Kit.textHeight("small") + gap
|
||||
|
||||
local viewH = by - top - pad
|
||||
local canvases = Studio.CANVASES
|
||||
local contentH = #canvases * (rowH + gap)
|
||||
local at = modalScroll(modal, mx + pad, top, innerW, viewH, contentH)
|
||||
local maxScroll = Kit.scrollExtent(contentH, viewH)
|
||||
local baseY = Kit.scrollBegin(mx + pad, top, innerW, viewH, at, maxScroll)
|
||||
for i, spec in ipairs(canvases) do
|
||||
local py = baseY + (i - 1) * (rowH + gap)
|
||||
local selected = i == Studio.canvasIndex
|
||||
local clicked = Kit.row(mx + pad, py, innerW, rowH, selected,
|
||||
"canvas-" .. spec.id)
|
||||
local label = spec.label
|
||||
local detail
|
||||
if spec.live then
|
||||
local w, h = Studio.deviceSize()
|
||||
detail = ("%dx%d · this window"):format(w, h)
|
||||
else
|
||||
detail = ("%dx%d"):format(spec.w, spec.h)
|
||||
end
|
||||
Kit.text("mono", Kit.ellipsize("mono", label, innerW - pad),
|
||||
mx + pad * 2, py + 4 * Kit.scale, selected and PAL.green or PAL.heading)
|
||||
Kit.text("small", Kit.ellipsize("small", detail, innerW - pad),
|
||||
mx + pad * 2, py + 4 * Kit.scale + Kit.textHeight("mono"), PAL.muted)
|
||||
if clicked then Studio.setCanvas(i) end
|
||||
end
|
||||
Kit.scrollEnd(mx + pad, top, innerW, viewH, at, maxScroll)
|
||||
end
|
||||
|
||||
local function drawConfirm(W, H)
|
||||
local c = Studio.confirm
|
||||
local mx, my, mw, mh, pad = modalFrame(W, H, "Unsaved changes", 0.42, 0.3)
|
||||
@@ -2057,21 +2265,24 @@ function Studio.drawOverlay(W, H)
|
||||
elseif modal.kind == "open" then drawOpenModal(W, H)
|
||||
elseif modal.kind == "page" then drawPageModal(W, H)
|
||||
elseif modal.kind == "export" then drawExportModal(W, H)
|
||||
elseif modal.kind == "screen" then drawScreenModal(W, H)
|
||||
else Studio.closeModal() end
|
||||
return true
|
||||
end
|
||||
|
||||
local function drawLegacyStudio()
|
||||
local W, H = love.graphics.getDimensions()
|
||||
local winW, winH, ox, oy, W, H = Studio.safeFrame()
|
||||
Kit.layout(W, H)
|
||||
local mx, my = love.mouse.getPosition()
|
||||
if Studio.pointerX ~= nil then mx, my = Studio.pointerX, Studio.pointerY end
|
||||
Kit.beginFrame(mx, my, Studio.clicked, Studio.wheel)
|
||||
Kit.beginFrame(mx - ox, my - oy, Studio.clicked, Studio.wheel)
|
||||
Studio.clicked, Studio.wheel = false, 0
|
||||
|
||||
Theme.fill(0, 0, W, H, PAL.bg, 1)
|
||||
Theme.fill(0, 0, winW, winH, PAL.bg, 1)
|
||||
Studio.expireStatus()
|
||||
Kit.blockClicks = Studio.modalUp()
|
||||
love.graphics.push()
|
||||
love.graphics.translate(ox, oy)
|
||||
|
||||
local pad = 14 * Kit.scale
|
||||
local mobile = Studio.isMobile()
|
||||
@@ -2142,6 +2353,7 @@ local function drawLegacyStudio()
|
||||
end
|
||||
if closed then
|
||||
Kit.blockClicks = false
|
||||
love.graphics.pop()
|
||||
Kit.endFrame()
|
||||
return
|
||||
end
|
||||
@@ -2183,6 +2395,16 @@ local function drawLegacyStudio()
|
||||
Studio.statusErr and PAL.red or PAL.detail)
|
||||
|
||||
Kit.blockClicks = false
|
||||
love.graphics.pop()
|
||||
if r then r.x, r.y = r.x + ox, r.y + oy end
|
||||
if Studio.lastCanvas then
|
||||
Studio.lastCanvas.x = Studio.lastCanvas.x + ox
|
||||
Studio.lastCanvas.y = Studio.lastCanvas.y + oy
|
||||
end
|
||||
if Studio.canvasWorkspace then
|
||||
Studio.canvasWorkspace.x = Studio.canvasWorkspace.x + ox
|
||||
Studio.canvasWorkspace.y = Studio.canvasWorkspace.y + oy
|
||||
end
|
||||
Studio.drawOverlay(W, H)
|
||||
|
||||
Kit.endFrame()
|
||||
@@ -2225,34 +2447,37 @@ local function drawSkinArtwork(image, x, y, w, h)
|
||||
PAL.steel, 0.8, h * 0.12)
|
||||
end
|
||||
|
||||
local function drawLibrary(W, H, pad)
|
||||
local function drawLibrary()
|
||||
local f = Studio._frame
|
||||
local ox, oy, W, H = f.x, f.y, f.w, f.h
|
||||
local pad = 14 * Kit.scale
|
||||
local btnH = math.max(Kit.tapMin(), 32 * Kit.scale)
|
||||
local titleY = pad * 0.55
|
||||
Kit.textBold("title", "My Skins", pad, titleY, PAL.heading)
|
||||
local titleY = oy + pad * 0.55
|
||||
Kit.textBold("title", "My Skins", ox + pad, titleY, PAL.heading)
|
||||
local closeW = math.min(112 * Kit.scale, W * 0.24)
|
||||
if Kit.button(W - pad - closeW, pad * 0.5, closeW, btnH, "Close",
|
||||
if Kit.button(ox + W - pad - closeW, oy + pad * 0.5, closeW, btnH, "Close",
|
||||
{ id = "library-close" }) then
|
||||
if Studio.onClose then Studio.onClose() end
|
||||
return
|
||||
end
|
||||
|
||||
local y = pad * 0.5 + btnH + pad
|
||||
local y = oy + pad * 0.5 + btnH + pad
|
||||
local gap = 8 * Kit.scale
|
||||
local half = (W - pad * 2 - gap) * 0.5
|
||||
if Kit.button(pad, y, half, btnH * 1.12, "+ New skin",
|
||||
if Kit.button(ox + pad, y, half, btnH * 1.12, "+ New skin",
|
||||
{ id = "library-new", kind = "accent" }) then
|
||||
Studio.newSkin()
|
||||
Studio.enterEditor()
|
||||
return
|
||||
end
|
||||
if Kit.button(pad + half + gap, y, half, btnH * 1.12, "Import skin",
|
||||
if Kit.button(ox + pad + half + gap, y, half, btnH * 1.12, "Import skin",
|
||||
{ id = "library-import" }) then
|
||||
Studio.importSkinFile()
|
||||
return
|
||||
end
|
||||
y = y + btnH * 1.12 + pad
|
||||
|
||||
Kit.caption(pad, y, "YOUR SKINS")
|
||||
Kit.caption(ox + pad, y, "YOUR SKINS")
|
||||
y = y + Kit.textHeight("small") + gap
|
||||
|
||||
local entries = Studio.available or {}
|
||||
@@ -2264,7 +2489,7 @@ local function drawLibrary(W, H, pad)
|
||||
local tc = type(saved.touchControls) == "table" and saved.touchControls or {}
|
||||
local activeId = tc.enabled == false and nil or tc.skin
|
||||
local total = #entries
|
||||
local maxRows = math.max(1, math.floor((H - y - pad - btnH - gap)
|
||||
local maxRows = math.max(1, math.floor((oy + H - y - pad - btnH - gap)
|
||||
/ (cardH + cardGap)))
|
||||
local perPage = maxRows * cols
|
||||
local pages = math.max(1, math.ceil(total / perPage))
|
||||
@@ -2274,7 +2499,7 @@ local function drawLibrary(W, H, pad)
|
||||
|
||||
for slot = first, last do
|
||||
local index = slot - first
|
||||
local cx = pad + (index % cols) * (cardW + cardGap)
|
||||
local cx = ox + pad + (index % cols) * (cardW + cardGap)
|
||||
local cy = y + math.floor(index / cols) * (cardH + cardGap)
|
||||
do
|
||||
local entry = entries[slot]
|
||||
@@ -2319,67 +2544,71 @@ local function drawLibrary(W, H, pad)
|
||||
end
|
||||
|
||||
if pages > 1 then
|
||||
local pagerY = H - pad - btnH
|
||||
local pagerY = oy + H - pad - btnH
|
||||
local prevW, nextW = (W - pad * 2 - gap) * 0.5, (W - pad * 2 - gap) * 0.5
|
||||
if Kit.button(pad, pagerY, prevW, btnH, "Previous",
|
||||
if Kit.button(ox + pad, pagerY, prevW, btnH, "Previous",
|
||||
{ id = "library-prev", enabled = Studio.libraryPage > 1 }) then
|
||||
Studio.libraryPage = Studio.libraryPage - 1
|
||||
end
|
||||
if Kit.button(pad + prevW + gap, pagerY, nextW, btnH, "Next",
|
||||
if Kit.button(ox + pad + prevW + gap, pagerY, nextW, btnH, "Next",
|
||||
{ id = "library-next", enabled = Studio.libraryPage < pages }) then
|
||||
Studio.libraryPage = Studio.libraryPage + 1
|
||||
end
|
||||
end
|
||||
|
||||
if #entries == 0 then
|
||||
Kit.text("small", "Create a blank skin or import one to get started.", pad,
|
||||
y + cardH + gap, PAL.muted)
|
||||
Kit.text("small", "Create a blank skin or import one to get started.",
|
||||
ox + pad, y + cardH + gap, PAL.muted)
|
||||
end
|
||||
end
|
||||
|
||||
local function drawEditor(W, H, pad)
|
||||
local function drawEditor()
|
||||
local f = Studio._frame
|
||||
local ox, oy, W, H = f.x, f.y, f.w, f.h
|
||||
local pad = 14 * Kit.scale
|
||||
local btnH = math.max(Kit.tapMin(), 32 * Kit.scale)
|
||||
local gap = 7 * Kit.scale
|
||||
local backW = math.min(120 * Kit.scale, W * 0.23)
|
||||
if Kit.button(pad, pad * 0.5, backW, btnH, "My Skins",
|
||||
if Kit.button(ox + pad, oy + pad * 0.5, backW, btnH, "My Skins",
|
||||
{ id = "editor-back" }) then
|
||||
Studio.backToLibrary()
|
||||
end
|
||||
local closeW = math.min(90 * Kit.scale, W * 0.17)
|
||||
local saveW = math.min(90 * Kit.scale, W * 0.17)
|
||||
local testW = math.min(100 * Kit.scale, W * 0.18)
|
||||
local right = W - pad
|
||||
if Kit.button(right - closeW, pad * 0.5, closeW, btnH, "Close",
|
||||
local right = ox + W - pad
|
||||
if Kit.button(right - closeW, oy + pad * 0.5, closeW, btnH, "Close",
|
||||
{ id = "editor-close" }) then
|
||||
Studio.guard("Close the studio and lose the unsaved changes?", function()
|
||||
if Studio.onClose then Studio.onClose() end
|
||||
end)
|
||||
end
|
||||
right = right - closeW - gap
|
||||
if Kit.button(right - saveW, pad * 0.5, saveW, btnH, "Save",
|
||||
if Kit.button(right - saveW, oy + pad * 0.5, saveW, btnH, "Save",
|
||||
{ id = "editor-save", kind = "accent" }) then Studio.save() end
|
||||
right = right - saveW - gap
|
||||
if Kit.button(right - testW, pad * 0.5, testW, btnH,
|
||||
if Kit.button(right - testW, oy + pad * 0.5, testW, btnH,
|
||||
Studio.testing and "Test: ON" or "Test",
|
||||
{ id = "editor-test", active = Studio.testing }) then
|
||||
Studio.testing = not Studio.testing
|
||||
TouchControls:setPreview(not Studio.testing)
|
||||
TouchControls:reset()
|
||||
end
|
||||
local titleX = pad + backW + gap
|
||||
local titleX = ox + pad + backW + gap
|
||||
local titleW = math.max(0, right - testW - gap - titleX)
|
||||
Kit.textBold("button", Kit.ellipsize("button",
|
||||
(Studio.skin and Studio.skin.name) or "Untitled skin", titleW),
|
||||
titleX, pad * 0.5 + (btnH - Kit.textHeight("button")) * 0.5, PAL.heading)
|
||||
titleX, oy + pad * 0.5 + (btnH - Kit.textHeight("button")) * 0.5, PAL.heading)
|
||||
|
||||
local trayH = btnH * 2 + gap * 3 + 30 * Kit.scale
|
||||
local bodyY = pad * 0.5 + btnH + pad
|
||||
local trayY = H - pad - trayH
|
||||
local zH = math.max(Kit.tapMin(), 28 * Kit.scale)
|
||||
local trayH = btnH * 2 + zH + gap * 4 + Kit.textHeight("small") + gap
|
||||
local bodyY = oy + pad * 0.5 + btnH + pad
|
||||
local trayY = oy + H - pad - trayH
|
||||
local canvasH = math.max(120 * Kit.scale, trayY - bodyY - pad)
|
||||
drawCanvas(pad, bodyY, W - pad * 2, canvasH)
|
||||
drawCanvas(ox + pad, bodyY, W - pad * 2, canvasH)
|
||||
|
||||
Kit.card(pad, trayY, W - pad * 2, trayH)
|
||||
local innerX, innerW = pad + gap, W - pad * 2 - gap * 2
|
||||
Kit.card(ox + pad, trayY, W - pad * 2, trayH)
|
||||
local innerX, innerW = ox + pad + gap, W - pad * 2 - gap * 2
|
||||
local actionW = (innerW - gap * 3) / 4
|
||||
local ctl = Studio.selectedControl()
|
||||
if Kit.button(innerX, trayY + gap, actionW, btnH, "+ Control",
|
||||
@@ -2401,19 +2630,41 @@ local function drawEditor(W, H, pad)
|
||||
Studio.openImagePicker("bezel")
|
||||
end
|
||||
|
||||
if Kit.button(innerX, trayY + gap * 2 + btnH, actionW, btnH, "Pages",
|
||||
local row2 = trayY + gap * 2 + btnH
|
||||
if Kit.button(innerX, row2, actionW, btnH, "Pages",
|
||||
{ id = "tray-pages" }) then Studio.openPageMenu() end
|
||||
if Kit.button(innerX + (actionW + gap), trayY + gap * 2 + btnH, actionW, btnH,
|
||||
"Screen", { id = "tray-screen" }) then Studio.toggleViewport() end
|
||||
if Kit.button(innerX + (actionW + gap) * 2, trayY + gap * 2 + btnH, actionW, btnH,
|
||||
if Kit.button(innerX + (actionW + gap), row2, actionW, btnH,
|
||||
"Screen", { id = "tray-screen" }) then Studio.openScreenMenu() end
|
||||
if Kit.button(innerX + (actionW + gap) * 2, row2, actionW, btnH,
|
||||
Studio.aspectLock and "Shape: 10:9" or "Shape: Free",
|
||||
{ id = "tray-shape", active = Studio.aspectLock }) then
|
||||
Studio.aspectLock = not Studio.aspectLock
|
||||
end
|
||||
if Kit.button(innerX + (actionW + gap) * 3, trayY + gap * 2 + btnH, actionW, btnH,
|
||||
if Kit.button(innerX + (actionW + gap) * 3, row2, actionW, btnH,
|
||||
"Delete", { id = "tray-delete", kind = "danger", enabled = ctl ~= nil }) then
|
||||
Studio.deleteControl()
|
||||
end
|
||||
|
||||
local row3 = row2 + btnH + gap
|
||||
local zoomAt = Studio.zoomIndex()
|
||||
if Kit.button(innerX, row3, actionW, zH, "Detect screen",
|
||||
{ id = "tray-detect" }) then
|
||||
Studio.detectDeviceCanvas()
|
||||
end
|
||||
if Kit.button(innerX + (actionW + gap), row3, actionW, zH, "Zoom −",
|
||||
{ id = "zoom-out", enabled = zoomAt < #Studio.ZOOM_LEVELS }) then
|
||||
Studio.zoomOut()
|
||||
end
|
||||
local pct = math.floor((Studio.viewZoom or 1) * 100 + 0.5) .. "%"
|
||||
if Kit.button(innerX + (actionW + gap) * 2, row3, actionW, zH, "Fit " .. pct,
|
||||
{ id = "zoom-fit", active = zoomAt == 1 }) then
|
||||
Studio.zoomFit()
|
||||
end
|
||||
if Kit.button(innerX + (actionW + gap) * 3, row3, actionW, zH, "Zoom +",
|
||||
{ id = "zoom-in", enabled = zoomAt > 1 }) then
|
||||
Studio.zoomIn()
|
||||
end
|
||||
|
||||
local hint = ctl and ("Selected: " .. TouchSkin.describeBind(ctl.spec)
|
||||
.. " — drag to move; use blue handles to resize.")
|
||||
or "Tap a control to select it. Drag to move; use blue handles to resize."
|
||||
@@ -2423,8 +2674,9 @@ local function drawEditor(W, H, pad)
|
||||
end
|
||||
|
||||
function Studio.draw()
|
||||
local W, H = love.graphics.getDimensions()
|
||||
Kit.layout(W, H)
|
||||
local W, H = Studio.safeFrame()
|
||||
local f = Studio._frame
|
||||
Kit.layout(f.w, f.h)
|
||||
local mx, my = love.mouse.getPosition()
|
||||
if Studio.pointerX ~= nil then mx, my = Studio.pointerX, Studio.pointerY end
|
||||
Kit.beginFrame(mx, my, Studio.clicked, Studio.wheel)
|
||||
@@ -2433,12 +2685,11 @@ function Studio.draw()
|
||||
Studio.expireStatus()
|
||||
Kit.blockClicks = Studio.modalUp()
|
||||
|
||||
local pad = 14 * Kit.scale
|
||||
if Studio.mode == "library" then drawLibrary(W, H, pad)
|
||||
else drawEditor(W, H, pad) end
|
||||
if Studio.mode == "library" then drawLibrary()
|
||||
else drawEditor() end
|
||||
|
||||
Kit.blockClicks = false
|
||||
Studio.drawOverlay(W, H)
|
||||
Studio.drawOverlay(f.w, f.h)
|
||||
Kit.endFrame()
|
||||
end
|
||||
|
||||
@@ -2467,8 +2718,12 @@ function Studio.mousepressed(x, y, button)
|
||||
return
|
||||
end
|
||||
local slop = HANDLE * 2 * Kit.scale
|
||||
if x < r.x - slop or x > r.x + r.w + slop
|
||||
or y < r.y - slop or y > r.y + r.h + slop then
|
||||
local work = Studio.canvasWorkspace
|
||||
local insideDevice = x >= r.x - slop and x <= r.x + r.w + slop
|
||||
and y >= r.y - slop and y <= r.y + r.h + slop
|
||||
local insideWork = work and x >= work.x and x <= work.x + work.w
|
||||
and y >= work.y and y <= work.y + work.h
|
||||
if not insideDevice and not insideWork then
|
||||
return
|
||||
end
|
||||
Studio.beginCanvasDrag(x, y, r)
|
||||
@@ -2517,6 +2772,19 @@ function Studio.touchreleased(id, x, y)
|
||||
end
|
||||
|
||||
function Studio.wheelmoved(_, dy)
|
||||
if Studio.mode == "editor" and not Studio.modalUp() and dy and dy ~= 0 then
|
||||
local work = Studio.canvasWorkspace
|
||||
local mx, my = Studio.pointerX, Studio.pointerY
|
||||
if (not mx or not my) and love and love.mouse and love.mouse.getPosition then
|
||||
mx, my = love.mouse.getPosition()
|
||||
end
|
||||
if work and mx and my
|
||||
and mx >= work.x and mx <= work.x + work.w
|
||||
and my >= work.y and my <= work.y + work.h then
|
||||
if dy > 0 then Studio.zoomIn() else Studio.zoomOut() end
|
||||
return
|
||||
end
|
||||
end
|
||||
Studio.wheel = dy
|
||||
end
|
||||
|
||||
@@ -2594,6 +2862,12 @@ function Studio.keypressed(key)
|
||||
Studio.testing = not Studio.testing
|
||||
TouchControls:setPreview(not Studio.testing)
|
||||
TouchControls:reset()
|
||||
elseif key == "-" or key == "kp-" then
|
||||
Studio.zoomOut()
|
||||
elseif key == "=" or key == "+" or key == "kp+" then
|
||||
Studio.zoomIn()
|
||||
elseif key == "0" or key == "kp0" then
|
||||
Studio.zoomFit()
|
||||
elseif key == "s" then
|
||||
Studio.save()
|
||||
elseif key == "tab" then
|
||||
|
||||
@@ -2707,7 +2707,9 @@ function BattleState:pushCaught(enemy, itemId)
|
||||
-- so the contest branch is BELOW them (item_effects.asm:528-546), and
|
||||
-- CheckReceivedDex gates the pair (:532-533).
|
||||
if not knew and self:hasPokedex() then
|
||||
-- data/text/common_3.asm:285
|
||||
self:push({ kind = "message",
|
||||
sfx = "Sfx_SlotMachineStart", waitSfx = true,
|
||||
text = self:name(enemy) .. "'s data was newly added to the #DEX." })
|
||||
self:push({ kind = "dex-entry", species = enemy.species })
|
||||
end
|
||||
|
||||
+122
-12
@@ -47,6 +47,7 @@ local Font = require("src.render.Font")
|
||||
local GbcPalette = require("src.render.GbcPalette")
|
||||
local Mail = require("src.core.gen2.Mail")
|
||||
local Palettes = require("src.world.gen2.Palettes")
|
||||
local PartyMenu = require("src.ui.gen2.PartyMenu")
|
||||
local Screens = require("src.ui.Screens")
|
||||
local Sound = require("src.core.Sound")
|
||||
local Unown = require("src.core.gen2.Unown")
|
||||
@@ -65,6 +66,21 @@ local PIC_X, PIC_Y = 1, 4
|
||||
-- 7-size blank tiles per column (engine/gfx/load_pics.asm:342-386).
|
||||
local PIC_PAD = { [7] = { 0, 0 }, [6] = { 1, 1 }, [5] = { 1, 2 } }
|
||||
|
||||
-- gfx/pc/orange.pal
|
||||
local BILLS_PC_ORANGE = {
|
||||
{ 255, 123, 0 }, { 189, 99, 0 }, { 123, 58, 0 }, { 0, 0, 0 },
|
||||
}
|
||||
|
||||
-- PCMonInfo prints the held-item icon at hlcoord 7, 12
|
||||
-- (engine/pokemon/bills_pc.asm:1093).
|
||||
local ICON_X, ICON_Y = 7, 12
|
||||
|
||||
-- $5f at hlcoord 8, 1 and $5e at hlcoord 19, 1, off a PCMailGFX sheet that
|
||||
-- starts at $5c (engine/pokemon/bills_pc.asm:957-963).
|
||||
local ARROW_ROW = 1
|
||||
local ARROW_LEFT = { 3, 8 }
|
||||
local ARROW_RIGHT = { 2, 19 }
|
||||
|
||||
-- wBillsPC_LoadedBox: 0 is the PARTY, 1..NUM_BOXES are the boxes. Only the
|
||||
-- MOVE screen ever loads box 0; the withdraw and deposit lists are one list
|
||||
-- each (BillsPC_BoxName reads the same byte for all three).
|
||||
@@ -272,6 +288,8 @@ function BoxMenu:beginMove()
|
||||
-- to the submenu; here the message holds until a button clears it and the
|
||||
-- list comes back, which is the same place the player ends up.
|
||||
self.phase = nil
|
||||
-- engine/pokemon/bills_pc.asm:1607
|
||||
self:playSfx("Sfx_Wrong")
|
||||
self.message = reason
|
||||
return
|
||||
end
|
||||
@@ -299,9 +317,13 @@ end
|
||||
function BoxMenu:doWithdraw()
|
||||
local ok, result = Boxes.withdraw(self.save, self.boxIndex, self.index)
|
||||
if not ok then
|
||||
-- engine/pokemon/bills_pc.asm:1845
|
||||
self:playSfx("Sfx_Wrong")
|
||||
self.message = result
|
||||
return
|
||||
end
|
||||
-- engine/pokemon/bills_pc.asm:1817
|
||||
self:playMonCry(result)
|
||||
self.message = nil
|
||||
self.phase = nil
|
||||
self:clampIndex()
|
||||
@@ -311,9 +333,13 @@ end
|
||||
function BoxMenu:doDeposit()
|
||||
local ok, result = Boxes.deposit(self.save, self.index, self.boxIndex)
|
||||
if not ok then
|
||||
-- engine/pokemon/bills_pc.asm:1790
|
||||
self:playSfx("Sfx_Wrong")
|
||||
self.message = result
|
||||
return
|
||||
end
|
||||
-- engine/pokemon/bills_pc.asm:1762
|
||||
self:playMonCry(result)
|
||||
self.message = nil
|
||||
self.phase = nil
|
||||
self.index, self.scroll = 1, 0
|
||||
@@ -486,6 +512,8 @@ function BoxMenu:update(_dt)
|
||||
if not ok then
|
||||
-- .no_space: `dec [hl]` puts the jumptable back on .PrepInsertCursor,
|
||||
-- so the refusal leaves the cursor exactly where it was.
|
||||
-- engine/pokemon/bills_pc.asm:1567
|
||||
self:playSfx("Sfx_Wrong")
|
||||
self.message = reason
|
||||
else
|
||||
self:insertMon()
|
||||
@@ -528,6 +556,14 @@ function BoxMenu:playSfx(name)
|
||||
if sfx and sfx[Sound.resolve(data, name)] then Sound.play(data, name) end
|
||||
end
|
||||
|
||||
-- PlayMonCry: `call GetCryIndex / jr c, .done` (home/pokemon.asm:113-114)
|
||||
function BoxMenu:playMonCry(mon)
|
||||
local data = self.game and self.game.data
|
||||
if not (data and mon and mon.species) or mon.isEgg then return end
|
||||
local cries = data.audio and data.audio.cries
|
||||
if cries and cries[mon.species] then Sound.playCry(data, mon.species) end
|
||||
end
|
||||
|
||||
-- BillsPC's RELEASE, which the model has always supported and nothing on
|
||||
-- screen reached. The cart asks first and starts the prompt on NO, the way
|
||||
-- every irreversible choice in the game does.
|
||||
@@ -541,6 +577,8 @@ function BoxMenu:askRelease()
|
||||
local allowed, refusal = self:checkMailPreventBlackout()
|
||||
if not allowed then
|
||||
self.phase = nil
|
||||
-- engine/pokemon/bills_pc.asm:1607
|
||||
self:playSfx("Sfx_Wrong")
|
||||
self.message = refusal
|
||||
return
|
||||
end
|
||||
@@ -568,6 +606,8 @@ function BoxMenu:askRelease()
|
||||
self.message = err
|
||||
return
|
||||
end
|
||||
-- engine/pokemon/bills_pc.asm:1866
|
||||
self:playMonCry(mon)
|
||||
self.message = name .. " was released."
|
||||
self.phase = nil
|
||||
self:clampIndex()
|
||||
@@ -627,14 +667,31 @@ function BoxMenu:picFor(mon)
|
||||
return self:image(path)
|
||||
end
|
||||
|
||||
-- engine/gfx/cgb_layouts.asm:284-300, engine/pokemon/bills_pc.asm:356-369
|
||||
function BoxMenu:panelColors(speciesId, shiny)
|
||||
if self.phase == "submenu" or self.phase == "insert" then
|
||||
return self.palettes
|
||||
and Palettes.monColors(self.palettes, speciesId, shiny)
|
||||
end
|
||||
local gfx = (self.menuGfx or {}).billsPc
|
||||
return (gfx and gfx.orangePalette) or BILLS_PC_ORANGE
|
||||
end
|
||||
|
||||
-- ClearBox runs before `cp -1 / ret z` (engine/pokemon/bills_pc.asm:1009-1021)
|
||||
function BoxMenu:fillPicBlock(colors)
|
||||
local G = love.graphics
|
||||
local blank = colors and GbcPalette.color(colors, 1) or { 255, 255, 255 }
|
||||
G.setColor(blank[1] / 255, blank[2] / 255, blank[3] / 255, 1)
|
||||
G.rectangle("fill", PIC_X * 8, PIC_Y * 8, 7 * 8, 7 * 8)
|
||||
G.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
-- PCMonInfo lays the padded pic as one 7x7 block at hlcoord 1, 4
|
||||
-- (engine/pokemon/bills_pc.asm:1023-1042), the pad tiles at the palette's 0.
|
||||
function BoxMenu:drawPicBlock(image, colors)
|
||||
if not image then return end
|
||||
local G = love.graphics
|
||||
local blank = colors and GbcPalette.color(colors, 1) or { 255, 255, 255 }
|
||||
G.setColor(blank[1] / 255, blank[2] / 255, blank[3] / 255, 1)
|
||||
G.rectangle("fill", PIC_X * 8, PIC_Y * 8, 7 * 8, 7 * 8)
|
||||
self:fillPicBlock(colors)
|
||||
|
||||
local pad = PIC_PAD[math.floor(image:getWidth() / 8)] or PIC_PAD[7]
|
||||
G.setColor(1, 1, 1, 1)
|
||||
@@ -650,12 +707,12 @@ function BoxMenu:drawPicBlock(image, colors)
|
||||
end
|
||||
|
||||
function BoxMenu:drawPic(mon)
|
||||
local image = self:picFor(mon)
|
||||
if not image then return end
|
||||
-- _CGB_BillsPC hands wTempMonDVs to GetPlayerOrMonPalettePointer, so the box
|
||||
-- pic takes the shiny row (engine/gfx/cgb_layouts.asm:292-293).
|
||||
local colors = self.palettes
|
||||
and Palettes.monColors(self.palettes, mon.species, mon.shiny)
|
||||
local colors = self:panelColors(mon.species, mon.shiny)
|
||||
local image = self:picFor(mon)
|
||||
-- engine/pokemon/bills_pc.asm:1009-1011
|
||||
if not image then return self:fillPicBlock(colors) end
|
||||
self:drawPicBlock(image, colors)
|
||||
end
|
||||
|
||||
@@ -664,17 +721,14 @@ end
|
||||
-- with the party list's ICON_EGG standing in for a cache built before that.
|
||||
function BoxMenu:drawEggPic(mon)
|
||||
local G = love.graphics
|
||||
local colors = self.palettes
|
||||
and Palettes.monColors(self.palettes, "EGG", mon and mon.shiny)
|
||||
local colors = self:panelColors("EGG", mon and mon.shiny)
|
||||
local gfx = (self.menuGfx or {}).eggHatch
|
||||
local image = self:image(gfx and gfx.egg)
|
||||
if image then return self:drawPicBlock(image, colors) end
|
||||
self:fillPicBlock(colors)
|
||||
local entry = self.icons and self.icons.icons and self.icons.icons.ICON_EGG
|
||||
image = self:image(entry and entry.image)
|
||||
if not image then return end
|
||||
local blank = colors and GbcPalette.color(colors, 1) or { 255, 255, 255 }
|
||||
G.setColor(blank[1] / 255, blank[2] / 255, blank[3] / 255, 1)
|
||||
G.rectangle("fill", PIC_X * 8, PIC_Y * 8, 7 * 8, 7 * 8)
|
||||
-- The ICON_EGG sheet stacks its frames; the first is the egg at rest.
|
||||
local w = entry.width or 16
|
||||
local h = math.min(entry.height or 16, image:getHeight())
|
||||
@@ -694,6 +748,58 @@ function BoxMenu:drawEggPic(mon)
|
||||
G.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
-- ItemIsMail picks $5c over $5d at hlcoord 7, 12
|
||||
-- (engine/pokemon/bills_pc.asm:1079-1094)
|
||||
function BoxMenu:drawHeldIcon(mon)
|
||||
local row = PartyMenu.heldMarkerRow(mon)
|
||||
if not row then return end
|
||||
local gfx = (self.menuGfx or {}).billsPc
|
||||
local image = self:image(gfx and gfx.icons)
|
||||
if not image then return end
|
||||
local ok, quad = pcall(love.graphics.newQuad, row * 8, 0, 8, 8,
|
||||
image:getDimensions())
|
||||
if not ok then return end
|
||||
local G = love.graphics
|
||||
G.setColor(1, 1, 1, 1)
|
||||
local function body() G.draw(image, quad, ICON_X * 8, ICON_Y * 8) end
|
||||
local colors = gfx and gfx.palette
|
||||
if colors and GbcPalette.available() then
|
||||
GbcPalette.with(colors, body)
|
||||
else
|
||||
body()
|
||||
end
|
||||
G.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
-- _MovePKMNWithoutMail only (engine/pokemon/bills_pc.asm:545, :698)
|
||||
function BoxMenu:drawBoxArrows()
|
||||
if self.mode ~= "move" then return end
|
||||
local gfx = (self.menuGfx or {}).billsPc
|
||||
local image = self:image(gfx and gfx.icons)
|
||||
if not image then return end
|
||||
local G = love.graphics
|
||||
local quads = {}
|
||||
for _, arrow in ipairs({ ARROW_LEFT, ARROW_RIGHT }) do
|
||||
local ok, quad = pcall(love.graphics.newQuad, arrow[1] * 8, 0, 8, 8,
|
||||
image:getDimensions())
|
||||
if not ok then return end
|
||||
quads[#quads + 1] = { quad, arrow[2] }
|
||||
end
|
||||
G.setColor(1, 1, 1, 1)
|
||||
local function body()
|
||||
for _, entry in ipairs(quads) do
|
||||
G.draw(image, entry[1], entry[2] * 8, ARROW_ROW * 8)
|
||||
end
|
||||
end
|
||||
local colors = gfx and gfx.palette
|
||||
if colors and GbcPalette.available() then
|
||||
GbcPalette.with(colors, body)
|
||||
else
|
||||
body()
|
||||
end
|
||||
G.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
-- The PC does not mark the selected row with a ▶: BillsPC_UpdateSelectionCursor
|
||||
-- lays 20 OBJs as a frame *around* the row -- ten tiles wide by two tall, top
|
||||
-- left at pixel (71, 25), stepping 16 pixels per row. Those cursor tiles are
|
||||
@@ -740,6 +846,7 @@ function BoxMenu:drawPanel()
|
||||
-- Textbox at (8,0) with a 10x1 interior and the name at (10,1).
|
||||
Chrome.box(8, 0, 12, 3)
|
||||
Chrome.print(self:title(), 10, 1)
|
||||
self:drawBoxArrows()
|
||||
Chrome.box(8, 2, 12, 12)
|
||||
-- BillsPC_RefreshTextboxes overwrites its own top corners with '└'/'┘'
|
||||
-- (engine/pokemon/bills_pc.asm:1204-1211) so the list reads as hanging
|
||||
@@ -793,7 +900,10 @@ function BoxMenu:drawPanel()
|
||||
Chrome.print("\xe2\x99\x80", 5, 12)
|
||||
end
|
||||
Chrome.print(mon.name or mon.species or "?", PIC_X, 14)
|
||||
self:drawHeldIcon(mon)
|
||||
end
|
||||
else
|
||||
self:fillPicBlock(self:panelColors())
|
||||
end
|
||||
|
||||
-- BillsPC_PlaceString: Textbox at (0,15) with a one-row interior, string at
|
||||
|
||||
@@ -320,12 +320,15 @@ function ItemPcMenu:leaveDeposit()
|
||||
end
|
||||
|
||||
function ItemPcMenu:offerToDeposit(id, count)
|
||||
-- .TryDepositItem's `.no_toss` arm is a bare ret: a KEY ITEM or HM stays in
|
||||
-- the bag with no message at all.
|
||||
if self:cantToss(id) then return end
|
||||
if (count or 0) < 1 then return end
|
||||
local def = self:def(id)
|
||||
local name = (def and def.name) or id
|
||||
-- .DepositItem (engine/events/pokecenter_pc.asm:504): an item with no
|
||||
-- quantity is always x1 and never reaches .AskQuantity.
|
||||
if self:cantToss(id) then
|
||||
self:deposit(id, name, 1)
|
||||
return
|
||||
end
|
||||
self:askQuantity(count,
|
||||
{ "How many do you", "want to deposit?" },
|
||||
function(qty) self:deposit(id, name, qty) end)
|
||||
@@ -518,9 +521,10 @@ function ItemPcMenu:drawList()
|
||||
local entry = self.rows[i]
|
||||
if i == self.listIndex then Chrome.cursor(5, ty) end
|
||||
Chrome.print(entry.name, 6, ty)
|
||||
-- PlaceMenuItemQuantity (engine/menus/menu_2.asm:24): the xNN is the
|
||||
-- entry's second line, right-aligned in a blank-padded 2-digit field.
|
||||
Chrome.print(TIMES .. Chrome.number(entry.count, 2), 7, ty + 1)
|
||||
-- PlaceMenuItemQuantity (engine/menus/menu_2.asm:18, :24)
|
||||
if not self:cantToss(entry.id) then
|
||||
Chrome.print(TIMES .. Chrome.number(entry.count, 2), 7, ty + 1)
|
||||
end
|
||||
elseif i == self:listTotal() then
|
||||
if i == self.listIndex then Chrome.cursor(5, ty) end
|
||||
Chrome.print("CANCEL", 6, ty)
|
||||
|
||||
@@ -16,6 +16,9 @@ local PackGfx = require("src.ui.gen2.PackGfx")
|
||||
local Screens = require("src.ui.Screens")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
-- constants/sfx_constants.asm:3, :28
|
||||
local SFX_DEX_FANFARE_50_79, SFX_WRONG = 0, 25
|
||||
|
||||
local PackMenu = {}
|
||||
PackMenu.__index = PackMenu
|
||||
PackMenu.isOpaque = true
|
||||
@@ -192,6 +195,14 @@ function PackMenu:pocketOf(itemId)
|
||||
return (def and def.pocket) or "ITEM"
|
||||
end
|
||||
|
||||
-- engine/items/tmhm.asm:341
|
||||
function PackMenu:tmhmKey(itemId)
|
||||
local def = self.items and self.items[itemId]
|
||||
local n = def and tonumber(def.tmNumber)
|
||||
if n then return n end
|
||||
return 1000 + ((def and tonumber(def.index)) or 0)
|
||||
end
|
||||
|
||||
-- The name on the row. An inventory key with no ItemAttributes row behind it
|
||||
-- (an older cache, a mod's own item, a driver seeding an id that is not in
|
||||
-- items.lua) still has to draw something a person can read, so the id stands
|
||||
@@ -239,6 +250,14 @@ function PackMenu:rebuild()
|
||||
}
|
||||
end
|
||||
end
|
||||
-- engine/items/tmhm.asm:341
|
||||
if pocket == "TM_HM" then
|
||||
table.sort(rows, function(a, b)
|
||||
local ka, kb = self:tmhmKey(a.id), self:tmhmKey(b.id)
|
||||
if ka ~= kb then return ka < kb end
|
||||
return a.id < b.id
|
||||
end)
|
||||
end
|
||||
self.rows = rows
|
||||
self.index = math.min(self.index, #rows + 1)
|
||||
if self.index < 1 then self.index = 1 end
|
||||
@@ -359,6 +378,9 @@ function PackMenu:useSelected()
|
||||
-- with sound_dex_fanfare_50_79 between them; the PACK's box here holds
|
||||
-- all four rows at once, the way OAK_THIS_ISNT_THE_TIME's three fit.
|
||||
-- World has already set the decoration's flag and taken the box.
|
||||
if world.playSfxNamed then
|
||||
world:playSfxNamed("Sfx_DexFanfare5079", SFX_DEX_FANFARE_50_79)
|
||||
end
|
||||
self.message = { "There was a trophy", "inside!",
|
||||
"{PLAYER} sent the", "trophy home." }
|
||||
self:rebuild()
|
||||
@@ -641,6 +663,11 @@ function PackMenu:openTeachParty(row)
|
||||
if id == moveId then allowed = true end
|
||||
end
|
||||
if not allowed then
|
||||
-- engine/items/tmhm.asm:131
|
||||
local world = game.world
|
||||
if world and world.playSfxNamed then
|
||||
world:playSfxNamed("Sfx_Wrong", SFX_WRONG)
|
||||
end
|
||||
if game.say then
|
||||
game:say(("%s can't learn %s!"):format(
|
||||
require("src.battle.gen2.Mon").displayName(mon), moveName))
|
||||
@@ -740,7 +767,9 @@ function PackMenu:update(_dt)
|
||||
end
|
||||
|
||||
-- engine/items/pack.asm:1290 Pack_InterpretJoypad .select
|
||||
-- engine/items/tmhm.asm:207 -- the TM/HM pocket's joypad filter drops SELECT.
|
||||
function PackMenu:armSwitch()
|
||||
if self:pocket().id == "TM_HM" then return end
|
||||
if self:isCancel() then return end
|
||||
if not self.rows[self.index] then return end
|
||||
self.switching = self.index
|
||||
|
||||
+16
-5
@@ -749,12 +749,23 @@ function Kit.chip(x, y, w, h, label, on, color, id)
|
||||
end
|
||||
|
||||
-- A status label with no interaction: outlined text, the "INSTALLED"/"UPDATE"
|
||||
-- markers on mod rows.
|
||||
function Kit.tag(x, y, w, h, label, color)
|
||||
-- markers on mod rows. Pass opts.fill for a solid chip (BETA badges); ink
|
||||
-- then defaults to PAL.inverse so the label stays readable on the fill.
|
||||
function Kit.tag(x, y, w, h, label, color, opts)
|
||||
if not G then return end
|
||||
Theme.strokeRounded(x, y, w, h, color or PAL.line, 0.7, 1)
|
||||
Kit.textCenter("micro", label, x, y + (h - Kit.textHeight("micro")) / 2, w,
|
||||
color or PAL.muted)
|
||||
opts = opts or {}
|
||||
if opts.fill then
|
||||
Theme.fillRounded(x, y, w, h, color or PAL.yellow, 1, h / 2)
|
||||
else
|
||||
Theme.strokeRounded(x, y, w, h, color or PAL.line, 0.7, 1)
|
||||
end
|
||||
local ink = opts.ink or (opts.fill and PAL.inverse) or color or PAL.muted
|
||||
local ty = y + (h - Kit.textHeight("micro")) / 2
|
||||
if opts.bold then
|
||||
Kit.textCenterBold("micro", label, x, ty, w, ink)
|
||||
else
|
||||
Kit.textCenter("micro", label, x, ty, w, ink)
|
||||
end
|
||||
end
|
||||
|
||||
-- Checkbox row. Returns (newChecked, changed).
|
||||
|
||||
+15
-5
@@ -58,6 +58,13 @@ function Check.fullAssetName(version, osName, arch, port)
|
||||
return fullAssetName(version, osName, arch, port)
|
||||
end
|
||||
|
||||
-- A legacy Android APK can run a newer downloaded payload while still lacking
|
||||
-- the native bridge that installs future APK updates. It needs one manual
|
||||
-- package update even when that payload already reports the latest engine.
|
||||
function Check.androidNeedsInstallerBootstrap(osName, hasInstaller)
|
||||
return osName == "Android" and not hasInstaller
|
||||
end
|
||||
|
||||
local CMD = "update_check_cmd"
|
||||
local STATE = "update_check_state"
|
||||
|
||||
@@ -266,12 +273,15 @@ function Check.fullUpdateAction()
|
||||
local st = Check.state()
|
||||
if st.status ~= "needs_full" and st.status ~= "full_ready" then return nil end
|
||||
local osName = love and love.system and love.system.getOS and love.system.getOS() or ""
|
||||
if osName == "Android" and type(love.system.installApk) == "function"
|
||||
and type(st.full) == "table" and type(st.full.url) == "string" then
|
||||
if st.status == "full_ready" and type(st.full.path) == "string" then
|
||||
return { label = "Install Android update", kind = "install" }
|
||||
if osName == "Android" and type(st.full) == "table"
|
||||
and type(st.full.url) == "string" then
|
||||
if type(love.system.installApk) == "function" then
|
||||
if st.status == "full_ready" and type(st.full.path) == "string" then
|
||||
return { label = "Install Android update", kind = "install" }
|
||||
end
|
||||
return { label = "Download Android update", kind = "download" }
|
||||
end
|
||||
return { label = "Download Android update", kind = "download" }
|
||||
return { label = "Update app manually", url = st.full.url }
|
||||
end
|
||||
if osName == "iOS" then
|
||||
return { label = "Re-sideload app", url =
|
||||
|
||||
@@ -243,6 +243,12 @@ local function doCheck(target)
|
||||
return
|
||||
end
|
||||
|
||||
if Check.androidNeedsInstallerBootstrap(osName,
|
||||
type(love.system.installApk) == "function") then
|
||||
postFullRequirement(rel, "native_installer_missing")
|
||||
return
|
||||
end
|
||||
|
||||
if compareVersions(rel.version, currentEngine) <= 0 then
|
||||
-- We are now running a native shell at least as new as GitHub's latest
|
||||
-- release, so a former minShell/payloadHost prompt no longer applies.
|
||||
|
||||
@@ -150,6 +150,21 @@ local function pooledNPC(pool, data, mapId, obj)
|
||||
end
|
||||
OverworldState.pooledNPC = pooledNPC -- exposed for tests
|
||||
|
||||
local function nearestWalkableCell(map, x, y)
|
||||
for r = 1, 8 do
|
||||
for dy = -r, r do
|
||||
for dx = -r, r do
|
||||
if math.abs(dx) == r or math.abs(dy) == r then
|
||||
local nx, ny = x + dx, y + dy
|
||||
if map:inBounds(nx, ny) and map:isWalkableCell(nx, ny) then
|
||||
return nx, ny
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- connection hops rendered around the current map: two, so
|
||||
-- corner-adjacent maps (connections of connections) don't pop in and
|
||||
-- out of the survey zoom at the seams (constants.world.neighborHops)
|
||||
@@ -414,6 +429,15 @@ function OverworldState:setMap(mapId, x, y, facing, opts)
|
||||
table.insert(self.npcs, npc)
|
||||
end
|
||||
end
|
||||
if opts and opts.via == "boot" and self.map:inBounds(x, y)
|
||||
and not self.map:isWalkableCell(x, y) and not self.map:isWaterCell(x, y) then
|
||||
local rx, ry = nearestWalkableCell(self.map, x, y)
|
||||
if rx then
|
||||
Logger.warn("saved position %s (%d,%d) is not walkable; moved to (%d,%d)",
|
||||
mapId, x, y, rx, ry)
|
||||
x, y = rx, ry
|
||||
end
|
||||
end
|
||||
if self.player then
|
||||
self.player.cellX, self.player.cellY = x, y
|
||||
self.player.px, self.player.py = x * 16, y * 16
|
||||
@@ -4113,7 +4137,7 @@ function OverworldState:checkBadgeGate()
|
||||
Game.stack:push(TextBox.new(Game,
|
||||
(t["_" .. g.failText] or Strings("You don't have the\nBOULDERBADGE yet!"))
|
||||
.. (t._Route22GateGuardICantLetYouPassText or ""), function()
|
||||
self:scriptMove(p, "down", 1)
|
||||
self:scriptMove(p, "down", 1, nil, { collide = true })
|
||||
end))
|
||||
return true
|
||||
end
|
||||
@@ -4141,7 +4165,7 @@ function OverworldState:checkBadgeGate()
|
||||
local text = (t["_" .. g.failText] or
|
||||
Strings("You don't have the\n{RAM} yet!")):gsub("{RAM:wNameBuffer}", badgeName)
|
||||
Game.stack:push(TextBox.new(Game, text, function()
|
||||
self:scriptMove(p, "down", 1)
|
||||
self:scriptMove(p, "down", 1, nil, { collide = true })
|
||||
end))
|
||||
return true
|
||||
end
|
||||
@@ -4181,7 +4205,7 @@ function OverworldState:checkForcedMovement()
|
||||
function()
|
||||
local back = ({ up = "down", down = "up",
|
||||
left = "right", right = "left" })[p.facing]
|
||||
self:scriptMove(p, back, 1)
|
||||
self:scriptMove(p, back, 1, nil, { collide = true })
|
||||
end))
|
||||
return true
|
||||
end
|
||||
@@ -4222,6 +4246,7 @@ function OverworldState:checkSeafoamCurrent()
|
||||
-- push so the B3F stair warps underfoot cannot bounce you back.
|
||||
self.forcedWarp = false
|
||||
require("src.core.Sound").play(Game.data, "Collision")
|
||||
-- home/overworld.asm:1891
|
||||
self:scriptMove(p, "up", c.y == 17 and 2 or 1)
|
||||
return true
|
||||
end
|
||||
@@ -4780,9 +4805,10 @@ end
|
||||
-- scripted movement
|
||||
-- -------------------------------------------------------------------------
|
||||
|
||||
function OverworldState:scriptMove(entity, dir, tiles, onDone)
|
||||
function OverworldState:scriptMove(entity, dir, tiles, onDone, opts)
|
||||
table.insert(self.scriptMoves, {
|
||||
entity = entity, dir = dir, remaining = tiles, onDone = onDone,
|
||||
collide = opts and opts.collide or nil,
|
||||
})
|
||||
end
|
||||
|
||||
@@ -4822,14 +4848,20 @@ function OverworldState:updateScriptMoves()
|
||||
e.moving = true
|
||||
e.marching = true
|
||||
e.progress = 0
|
||||
mv.remaining = mv.remaining - 1
|
||||
elseif mv.collide
|
||||
and not Collision.canMove(self.map, self.entities, e, mv.dir) then
|
||||
-- home/overworld.asm:1224
|
||||
e.facing = mv.dir
|
||||
mv.remaining = 0
|
||||
else
|
||||
e.facing = mv.dir
|
||||
local tx, ty = Collision.target(e.cellX, e.cellY, mv.dir)
|
||||
e.targetX, e.targetY = tx, ty
|
||||
e.moving = true
|
||||
e.progress = 0
|
||||
mv.remaining = mv.remaining - 1
|
||||
end
|
||||
mv.remaining = mv.remaining - 1
|
||||
end
|
||||
end
|
||||
-- march_in_place toggles: re-arm the in-place cycle each time it ends.
|
||||
|
||||
@@ -83,6 +83,7 @@ local SFX = {
|
||||
WARP_TO = 19,
|
||||
EXIT_BUILDING = 35,
|
||||
JUMP_OVER_LEDGE = 0x16,
|
||||
BUMP = 0x24,
|
||||
}
|
||||
local EMOTE_SHOCK = 0
|
||||
|
||||
@@ -2183,6 +2184,13 @@ function World:playSfxNamed(want, fallbackId)
|
||||
self:playSfx(self:sfxIdNamed(want, fallbackId))
|
||||
end
|
||||
|
||||
-- .BumpSound (engine/overworld/player_movement.asm:771), CheckSFX at
|
||||
-- home/audio.asm:477
|
||||
function World:bumpSound()
|
||||
if Sound.sfxBusy() then return end
|
||||
self:playSfxNamed("Sfx_Bump", SFX.BUMP)
|
||||
end
|
||||
|
||||
-- Script_specialsound (engine/overworld/scripting.asm:476) is not a fixed cue:
|
||||
-- it farcalls CheckItemPocket (engine/items/items.asm:512), which writes
|
||||
-- wCurItem's pocket into wItemAttributeValue, and rings SFX.GET_TM for the
|
||||
@@ -9031,6 +9039,9 @@ function World:movePlayer(dir)
|
||||
elseif result == "blocked" or result == "edge" then
|
||||
self.turningDirection = nil
|
||||
end
|
||||
-- .NotMoving and .Ice's own arm (player_movement.asm:102 and :87), which
|
||||
-- .TryJump's carry (:376) returns above.
|
||||
if result == "blocked" then self:bumpSound() end
|
||||
-- NormalStep, in order (engine/overworld/movement.asm:657-674): InitStep has
|
||||
-- already moved OBJECT_TILE_COLLISION onto the destination.
|
||||
if result == "moved" then self:playerStepGrass() end
|
||||
@@ -9738,7 +9749,8 @@ function World:stepBody()
|
||||
|
||||
local result = self:movePlayer(dir)
|
||||
if result == "edge" then
|
||||
self:tryConnection(dir)
|
||||
-- A border block is a wall: engine/overworld/player_movement.asm:264
|
||||
if not self:tryConnection(dir) then self:bumpSound() end
|
||||
elseif result == "blocked" and p.facing == dir then
|
||||
-- .CheckNPC came back 2: something movable is in the way. The step is
|
||||
-- lost either way, and the boulder is what moves.
|
||||
|
||||
@@ -83,15 +83,36 @@ return function(game)
|
||||
-- the announcement-time row performMove inserts directly as well as the
|
||||
-- ones animNext adds, which is the whole difference the fix makes.
|
||||
local function watchAnims(battle)
|
||||
local seen, mark = {}, {}
|
||||
local seen, mark, types = {}, {}, {}
|
||||
return seen, function()
|
||||
for _, row in ipairs(battle.queue) do
|
||||
if row.anim and not mark[row] then
|
||||
mark[row] = true
|
||||
seen[#seen + 1] = row.anim
|
||||
types[row.anim] = row.hit and row.hit.animType or false
|
||||
end
|
||||
end
|
||||
end, types
|
||||
end
|
||||
|
||||
-- engine/battle/animations.asm:427-437
|
||||
local function rideAndSample(battle, poll, frames, shotPath)
|
||||
local peak, shot = 0, false
|
||||
for i = 1, frames do
|
||||
poll()
|
||||
if i % 6 == 0 then U.tap(battle.game, "a") end
|
||||
local fx = battle.fx
|
||||
if fx and fx.shakeX and not battle.animPlaying then
|
||||
local dx = math.abs(fx.shakeX)
|
||||
if dx > peak then peak = dx end
|
||||
if dx > 0 and not shot then
|
||||
shot = true
|
||||
U.shot(battle.game, shotPath)
|
||||
end
|
||||
end
|
||||
U.wait(1)
|
||||
end
|
||||
return peak
|
||||
end
|
||||
|
||||
local function listOf(seen)
|
||||
@@ -136,23 +157,28 @@ return function(game)
|
||||
local battle = BattleState.newWild(game, "PIDGEY", 8)
|
||||
battle.onFinish = function() end
|
||||
ow:pushBattle(battle)
|
||||
local seen, poll = watchAnims(battle)
|
||||
local seen, poll, types = watchAnims(battle)
|
||||
check("the battle reached the menu", waitPhase(battle, "menu", 60, poll))
|
||||
check("BIDE is the move on the list", pickBide(battle, poll))
|
||||
for _ = 1, 20 do poll() U.wait(1) end
|
||||
U.shot(game, DIR .. "/bug375_store_spiral.png")
|
||||
for _ = 1, 30 do poll() U.wait(1) end
|
||||
|
||||
local peak = rideAndSample(battle, poll, 180,
|
||||
DIR .. "/bug1564_store_shake.png")
|
||||
U.shot(game, DIR .. "/bug375_store_text.png")
|
||||
|
||||
check("the storing turn queued XSTATITEM_ANIM", has(seen, "XSTATITEM_ANIM"))
|
||||
check("and not BIDE's hit animation: " .. listOf(seen), not has(seen, "BIDE"))
|
||||
check("BIDE locked in for " .. tostring(battle.player.bideTurns) .. " turns",
|
||||
battle.player.bideTurns ~= nil)
|
||||
check("the storing spiral carries wAnimationType 6 (#1564)",
|
||||
types["XSTATITEM_ANIM"] == 6)
|
||||
check(("the screen shook, peak dx = %d px (want 3)"):format(peak), peak == 3)
|
||||
|
||||
-- ride out the locked turns: the storing text needs A presses and the menu
|
||||
-- never comes back until BIDE unleashes
|
||||
local released = false
|
||||
for _ = 1, 120 do
|
||||
for _ = 1, 220 do
|
||||
U.tap(game, "a")
|
||||
for _ = 1, 6 do poll() U.wait(1) end
|
||||
if has(seen, "BIDE") then
|
||||
@@ -179,7 +205,7 @@ return function(game)
|
||||
foeBattle.enemy.mon.moves = { { id = "BIDE", pp = 10 } }
|
||||
foeBattle.enemy.curMoves = foeBattle.enemy.mon.moves
|
||||
ow:pushBattle(foeBattle)
|
||||
local foeSeen, foePoll = watchAnims(foeBattle)
|
||||
local foeSeen, foePoll, foeTypes = watchAnims(foeBattle)
|
||||
check("the foe's battle reached the menu",
|
||||
waitPhase(foeBattle, "menu", 60, foePoll))
|
||||
pickBide(foeBattle, foePoll) -- ours does not matter here, the foe's does
|
||||
@@ -192,10 +218,20 @@ return function(game)
|
||||
check("the foe's storing turn queued XSTATITEM_DUPLICATE_ANIM: "
|
||||
.. listOf(foeSeen), has(foeSeen, "XSTATITEM_DUPLICATE_ANIM"))
|
||||
|
||||
local foePeak = rideAndSample(foeBattle, foePoll, 180,
|
||||
DIR .. "/bug1564_enemy_store_shake.png")
|
||||
check("the foe's spiral carries wAnimationType 3 (#1564)",
|
||||
foeTypes["XSTATITEM_DUPLICATE_ANIM"] == 3)
|
||||
check(("the foe's shake peaked at %d px (want 6)"):format(foePeak),
|
||||
foePeak == 6)
|
||||
|
||||
U.log("The pad is yours in a battle where both sides know only BIDE, so")
|
||||
U.log("pick FIGHT then BIDE and watch turn one: the screen palette flashes")
|
||||
U.log("white and balls spiral inward, silently, and then it says storing")
|
||||
U.log("energy. No flash on the RATTATA and no BIDE thud until the turn it")
|
||||
U.log("white and balls spiral inward, silently. Then, still in silence, the")
|
||||
U.log("whole battle screen creeps sideways 1 px at a time out to 3 px and")
|
||||
U.log("back, twice, and only then does it say storing energy. On the foe's")
|
||||
U.log("turn the same creep goes out to 6 px and takes twice as long.")
|
||||
U.log("No flash on the RATTATA and no BIDE thud until the turn it")
|
||||
U.log("unleashes, where the thud and its animation come after the text and")
|
||||
U.log("before the enemy HP bar slides down. The foe's spiral looks the same.")
|
||||
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
-- scripts/Route16Gate1F.asm:38 / home/overworld.asm:1224
|
||||
-- data/maps/force_bike_surf.asm:5
|
||||
-- POKEPORT_DRIVER=tests/drivers/bike_gate_wall_bug1548_test.lua \
|
||||
-- POKEPORT_IDENTITY=bug1548 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
|
||||
-- Watch the sprite after each teleport: it must never overlap the gate
|
||||
-- building's black outline. Compare .bazinga/august21p2/media/1548-1.jpg.
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local OverworldState = require("src.world.OverworldController")
|
||||
|
||||
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
local ok = true
|
||||
local function check(label, pass)
|
||||
U.log(pass and "PASS" or "FAIL", label)
|
||||
if not pass then ok = false end
|
||||
return pass
|
||||
end
|
||||
|
||||
local function settle()
|
||||
for _ = 1, 12 do
|
||||
if game.stack:top() == game.overworld then break end
|
||||
U.tap(game, "a")
|
||||
U.wait(25)
|
||||
end
|
||||
U.wait(60)
|
||||
end
|
||||
local function landed()
|
||||
local ow = game.overworld
|
||||
local p = ow.player
|
||||
return p.cellX, p.cellY, ow.map:isWalkableCell(p.cellX, p.cellY)
|
||||
end
|
||||
local function bikeless()
|
||||
game.save.inventory.BICYCLE = nil
|
||||
game.save.onBike, game.save.forcedBike = false, nil
|
||||
end
|
||||
|
||||
game.save.player.name = "PROBE"
|
||||
game.save.party = { Pokemon.new(game.data, "CHARIZARD", 50) }
|
||||
bikeless()
|
||||
|
||||
local fm = game.data.field.forcedMovement
|
||||
check("field.forcedMovement carries the Route 16 cells",
|
||||
fm and fm.tiles and fm.tiles.ROUTE_16 ~= nil)
|
||||
|
||||
for _, c in ipairs({ { "ROUTE_16", 17, 10 }, { "ROUTE_16", 17, 11 },
|
||||
{ "ROUTE_18", 33, 8 }, { "ROUTE_18", 33, 9 } }) do
|
||||
bikeless()
|
||||
U.teleport(game, c[1], c[2], c[3], "left")
|
||||
U.wait(20)
|
||||
settle()
|
||||
local x, y, walk = landed()
|
||||
U.log(("%s (%d,%d) facing left -> (%d,%d) walkable=%s")
|
||||
:format(c[1], c[2], c[3], x, y, tostring(walk)))
|
||||
check(("%s (%d,%d): the refusal leaves the player on a walkable cell")
|
||||
:format(c[1], c[2], c[3]), walk)
|
||||
end
|
||||
|
||||
bikeless()
|
||||
U.teleport(game, "ROUTE_16", 17, 10, "left")
|
||||
U.wait(20)
|
||||
settle()
|
||||
U.shot(game, SHOT_DIR .. "/bug1548_route16_after.png")
|
||||
|
||||
bikeless()
|
||||
U.teleport(game, "ROUTE_16", 16, 10, "right")
|
||||
U.wait(20)
|
||||
U.hold(game, "right", 24)
|
||||
settle()
|
||||
local bx, by = landed()
|
||||
U.log(("stepped onto ROUTE_16 (17,10) from the west -> (%d,%d)"):format(bx, by))
|
||||
check("a shove with open ground behind it still moves one cell",
|
||||
bx == 16 and by == 10)
|
||||
|
||||
bikeless()
|
||||
while game.stack:top() do game.stack:pop() end
|
||||
game.stack:push(OverworldState, "ROUTE_16", 18, 10, "left")
|
||||
game.overworld:setMap("ROUTE_16", 18, 10, "left", { via = "boot" })
|
||||
U.wait(20)
|
||||
local cx, cy, cwalk = landed()
|
||||
U.log(("boot inside the wall (18,10) -> (%d,%d) walkable=%s")
|
||||
:format(cx, cy, tostring(cwalk)))
|
||||
check("a save parked inside the gate wall is lifted out on load", cwalk)
|
||||
settle()
|
||||
local dx, dy, dwalk = landed()
|
||||
U.log(("after the refusal that follows -> (%d,%d) walkable=%s")
|
||||
:format(dx, dy, tostring(dwalk)))
|
||||
check("and the refusal it lands on cannot push it back in", dwalk)
|
||||
U.shot(game, SHOT_DIR .. "/bug1548_repaired.png")
|
||||
|
||||
game.save.inventory.BICYCLE = 1
|
||||
game.save.onBike, game.save.forcedBike = false, nil
|
||||
U.teleport(game, "ROUTE_16", 17, 10, "left")
|
||||
U.wait(30)
|
||||
local ex, ey, ewalk = landed()
|
||||
check("with a BICYCLE the forced tile mounts instead of shoving",
|
||||
game.save.onBike == true and ex == 17 and ey == 10 and ewalk)
|
||||
|
||||
U.log(ok and "all clear" or "a check failed")
|
||||
while true do coroutine.yield() end
|
||||
end
|
||||
@@ -0,0 +1,46 @@
|
||||
-- Driver: #1517's other half, the error handler that hid the crash.
|
||||
--
|
||||
-- boot.lua's deferErrhand picks `love.errorhandler or love.errhand`, so a
|
||||
-- love.errorhandler that returns nil ends love.run's `while func do` loop and
|
||||
-- the process leaves to the OS with no error screen and nothing on stdout.
|
||||
-- LOVE 11.5 pre-populates love.errhand only, so main.lua capturing
|
||||
-- love.errorhandler captured nil and every Lua error in every shipped build
|
||||
-- exited silently. Measured on the installed 11.5:
|
||||
-- love.errorhandler type: nil / love.errhand type: function
|
||||
--
|
||||
-- Nothing here can be a unit test: the deliverable is whether a human sees
|
||||
-- LOVE's blue error screen or an app that vanishes.
|
||||
--
|
||||
-- POKEPORT_TOUCH=0 \
|
||||
-- POKEPORT_DRIVER=tests/drivers/errorhandler_probe_bug1517_test.lua love .
|
||||
--
|
||||
-- Pre-fix: the window closes on the first drawn frame after the probe arms,
|
||||
-- exit 1, no output. Post-fix: the blue screen reads "bug1517: error-handler
|
||||
-- probe" plus the lua-error.log hint, and stays up until it is dismissed.
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
|
||||
U.log("[1517] love.errorhandler at boot was: "
|
||||
.. (rawget(love, "errorhandler") and "a function" or "nil"))
|
||||
U.log("[1517] love.errhand is: " .. type(rawget(love, "errhand")))
|
||||
|
||||
U.wait(30)
|
||||
|
||||
-- Raise from a draw callback, not from this coroutine: main.lua resumes the
|
||||
-- driver under coroutine.resume and prints its errors itself, which is the
|
||||
-- one path that does NOT reach love.errorhandler. StateStack:draw only
|
||||
-- calls the states it is actually holding, so the probe goes on the live
|
||||
-- top state rather than on game.overworld, which is not on the stack while
|
||||
-- the title screen or the load menu is up.
|
||||
local target = game.stack:top()
|
||||
U.log("[1517] arming the probe on " .. tostring(target and target.screenId))
|
||||
local realDraw = target.draw
|
||||
target.draw = function(self, ...)
|
||||
if realDraw then realDraw(self, ...) end
|
||||
error("bug1517: error-handler probe")
|
||||
end
|
||||
|
||||
U.log("[1517] armed; the next drawn frame raises from love.draw")
|
||||
U.log("[1517] you should now see LOVE's blue error screen, not a closed app")
|
||||
while true do coroutine.yield() end
|
||||
end
|
||||
@@ -0,0 +1,171 @@
|
||||
-- BIDE on the real Gold battle screen: the PP counter in the move list, and
|
||||
-- the lock that keeps a storing mon on the move it started (#1664, #1665).
|
||||
--
|
||||
-- POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/gold_bide_lock_bug1664_1665.lua \
|
||||
-- POKEPORT_SHOT_DIR=/tmp/gold-bide love .
|
||||
--
|
||||
-- data/moves/effects.asm:795-800 puts `storeenergy` ahead of `doturn`, and
|
||||
-- BattleCommand_DoTurn masks SUBSTATUS_BIDE out of the PP spend
|
||||
-- (engine/battle/effect_commands.asm:977-979), so the whole three-turn Bide
|
||||
-- costs the one PP the opening turn paid. The shots are the point: the
|
||||
-- number beside BIDE in the move list must read the same on 01, 02 and 03.
|
||||
--
|
||||
-- The lock is ParsePlayerAction's own arm (engine/battle/core.asm:569-576),
|
||||
-- which sits INSIDE the FIGHT branch and skips MoveSelectionScreen only -- so
|
||||
-- the 2x2 menu still opens, a switch is still legal, and using an item runs
|
||||
-- .reset_bide (:627-629) and CANCELS the store. Block 2 submits TACKLE in
|
||||
-- the middle of a Bide on purpose: the engine has to answer with the Bide.
|
||||
--
|
||||
-- NOT covered here, and deliberately: src/ui/gen2/BattleState.lua still draws
|
||||
-- the move list on a storing turn. The cart jumps past it. That half is a
|
||||
-- screen change and is called out in the fix report rather than faked.
|
||||
local U = require("tests.drivers.util")
|
||||
|
||||
local Mon = require("src.battle.gen2.Mon")
|
||||
|
||||
local function battleScreen(game)
|
||||
for _ = 1, 900 do
|
||||
local top = game.stack:top()
|
||||
if top and top.battle then return top end
|
||||
U.wait(1)
|
||||
end
|
||||
error("battle screen never came up")
|
||||
end
|
||||
|
||||
local function drain(game, screen, frames)
|
||||
for _ = 1, (frames or 400) do
|
||||
if screen.phase == "menu" and #screen.queue == 0 and not screen.anim then
|
||||
return true
|
||||
end
|
||||
U.tap(game, "a")
|
||||
U.wait(3)
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function giveMoves(mon, game, moves)
|
||||
mon.moves = {}
|
||||
for i, id in ipairs(moves) do
|
||||
local def = assert(game.data.moves[id], id .. " is not in moves.lua")
|
||||
mon.moves[i] = { id = id, pp = def.pp, maxPp = def.pp }
|
||||
end
|
||||
return mon
|
||||
end
|
||||
|
||||
local function newWild(game, species, level, moves)
|
||||
local mon = Mon.new(game.data, species, level)
|
||||
if moves then giveMoves(mon, game, moves) end
|
||||
return mon
|
||||
end
|
||||
|
||||
return function(game)
|
||||
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-bide"
|
||||
U.wait(45)
|
||||
local world = game.world
|
||||
assert(world and world.map, "gold world did not boot")
|
||||
local failures = {}
|
||||
local function check(ok, what)
|
||||
print((ok and "[ok] " or "[FAIL] ") .. what)
|
||||
if not ok then failures[#failures + 1] = what end
|
||||
end
|
||||
local function pp(mon, slot) return mon.moves[slot].pp end
|
||||
|
||||
-- ------------------------------------------------------------------ 1
|
||||
-- One Bide, start to finish, with the move list open between turns. The
|
||||
-- foe is a SPLASH-only RATTATA so nothing interrupts the store.
|
||||
local player = Mon.new(game.data, "SNORLAX", 40)
|
||||
giveMoves(player, game, { "BIDE", "TACKLE" })
|
||||
game.save.party = { player }
|
||||
game.save.inventory = { POTION = 5 }
|
||||
local foe = newWild(game, "RATTATA", 20, { "TACKLE" })
|
||||
assert(world:startBattle({ wild = foe }), "startBattle failed")
|
||||
local screen = battleScreen(game)
|
||||
drain(game, screen, 200)
|
||||
local opening = pp(player, 1)
|
||||
print(("[driver] BIDE opens at %d PP"):format(opening))
|
||||
|
||||
screen:submit({ kind = "move", move = "BIDE" })
|
||||
drain(game, screen, 400)
|
||||
U.shot(game, out .. "/01-selected.png")
|
||||
local afterSelect = pp(player, 1)
|
||||
print(("[driver] after the SELECTION turn: %d PP"):format(afterSelect))
|
||||
check(afterSelect == opening - 1,
|
||||
"the opening turn pays its one PP through doturn")
|
||||
check(screen.battle:forcedMove(player) == "BIDE",
|
||||
"and ParsePlayerAction's bide arm holds the FIGHT choice")
|
||||
|
||||
-- The middle of the store, with TACKLE deliberately submitted: the cart
|
||||
-- never re-reads the move list, so wCurPlayerMove is still the BIDE.
|
||||
local tackleBefore = pp(player, 2)
|
||||
screen:submit({ kind = "move", move = "TACKLE" })
|
||||
drain(game, screen, 400)
|
||||
U.shot(game, out .. "/02-storing.png")
|
||||
print(("[driver] after a STORING turn: BIDE %d PP, TACKLE %d PP")
|
||||
:format(pp(player, 1), pp(player, 2)))
|
||||
check(pp(player, 1) == afterSelect, "a storing turn spends no PP at all")
|
||||
check(pp(player, 2) == tackleBefore,
|
||||
"and the move the menu offered was never run")
|
||||
|
||||
-- The release. Whatever the roll, the store is two or three turns
|
||||
-- (UnleashEnergy's `BattleRandom / and 1 / inc a / inc a`,
|
||||
-- move_effects/bide.asm:88-92), so keep pressing FIGHT until it lets go.
|
||||
local foeBefore = foe.hp
|
||||
for _ = 1, 3 do
|
||||
if not screen.battle:forcedMove(player) then break end
|
||||
screen:submit({ kind = "move", move = "BIDE" })
|
||||
drain(game, screen, 400)
|
||||
end
|
||||
U.shot(game, out .. "/03-unleashed.png")
|
||||
print(("[driver] after the RELEASE: %d PP, foe %d -> %d HP")
|
||||
:format(pp(player, 1), foeBefore, foe.hp))
|
||||
check(pp(player, 1) == afterSelect,
|
||||
"the whole Bide cost exactly one PP, the way the cart charges it")
|
||||
check(screen.battle:forcedMove(player) == nil, "and the lock is released")
|
||||
for _ = 1, 400 do
|
||||
if not game.stack:top() or not game.stack:top().battle then break end
|
||||
U.tap(game, "a")
|
||||
U.wait(3)
|
||||
end
|
||||
U.wait(60)
|
||||
|
||||
-- ------------------------------------------------------------------ 2
|
||||
-- .reset_bide: a nonzero wBattlePlayerAction that is not
|
||||
-- BATTLEPLAYERACTION_SWITCH clears SUBSTATUS_BIDE (core.asm:572-573,
|
||||
-- :627-629), so opening the PACK mid-store throws the stored damage away.
|
||||
local bider = Mon.new(game.data, "SNORLAX", 40)
|
||||
giveMoves(bider, game, { "BIDE", "TACKLE" })
|
||||
bider.hp = math.max(1, bider.hp - 30)
|
||||
game.save.party = { bider }
|
||||
game.save.inventory = { POTION = 5 }
|
||||
local foe2 = newWild(game, "RATTATA", 20, { "TACKLE" })
|
||||
assert(world:startBattle({ wild = foe2 }), "startBattle failed")
|
||||
screen = battleScreen(game)
|
||||
drain(game, screen, 200)
|
||||
screen:submit({ kind = "move", move = "BIDE" })
|
||||
drain(game, screen, 400)
|
||||
check(screen.battle:forcedMove(bider) == "BIDE", "the second Bide is up")
|
||||
screen:useItem("POTION")
|
||||
drain(game, screen, 400)
|
||||
U.shot(game, out .. "/04-item-cancelled.png")
|
||||
print("[driver] after the PACK: forcedMove="
|
||||
.. tostring(screen.battle:forcedMove(bider)))
|
||||
check(screen.battle:forcedMove(bider) == nil,
|
||||
"using an item cancels the Bide, as .reset_bide does")
|
||||
check(screen.battle:volatile(bider).bideStored == nil,
|
||||
"and the damage it had banked goes with it")
|
||||
for _ = 1, 400 do
|
||||
if not game.stack:top() or not game.stack:top().battle then break end
|
||||
U.tap(game, "a")
|
||||
U.wait(3)
|
||||
end
|
||||
|
||||
print("[driver] NOTE: the move list is still drawn on a storing turn; the "
|
||||
.. "cart's bide arm skips MoveSelectionScreen and that half lives in "
|
||||
.. "src/ui/gen2/BattleState.lua")
|
||||
if #failures > 0 then
|
||||
for _, what in ipairs(failures) do print("[FAIL] " .. what) end
|
||||
error(#failures .. " bide checks failed")
|
||||
end
|
||||
print("[driver] all bide checks passed")
|
||||
print("[driver] shots in " .. out)
|
||||
end
|
||||
@@ -0,0 +1,113 @@
|
||||
-- #1672: Bill's PC MOVE screen -- the box-name arrows.
|
||||
--
|
||||
-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold POKEPORT_TOUCH=0 \
|
||||
-- POKEPORT_DRIVER=tests/drivers/gold_billspc_arrows_bug1672.lua \
|
||||
-- perl -e 'alarm 420; exec @ARGV' \
|
||||
-- python3 -c "import pty; pty.spawn(['love','.'])"
|
||||
-- POKEPORT_SHOT_DIR=/tmp/gold-bug1672-arrows (default)
|
||||
--
|
||||
-- BillsPC_MoveMonWOMail_BoxNameAndArrows writes $5f at hlcoord 8, 1 and $5e at
|
||||
-- hlcoord 19, 1 (engine/pokemon/bills_pc.asm:957-963), replacing the box-name
|
||||
-- Textbox's own side borders. Only _MovePKMNWithoutMail calls it: .Init (:545)
|
||||
-- and .PrepInsertCursor (:698). The withdraw (:299) and deposit (:56) inits
|
||||
-- call bare BillsPC_BoxName (:965) and are the negative controls here.
|
||||
--
|
||||
-- What to look for in each shot: a solid LEFT-pointing triangle in the left
|
||||
-- border of the name box and a solid RIGHT-pointing one in the right border,
|
||||
-- both level with the name, on every move-mode shot and on neither of the last
|
||||
-- two. Getting them the wrong way round is the failure this exists to catch.
|
||||
--
|
||||
-- The run ends with the MOVE screen open so a human takes the controls there.
|
||||
local U = require("tests.drivers.util")
|
||||
|
||||
local Boxes = require("src.core.gen2.Boxes")
|
||||
local Mon = require("src.battle.gen2.Mon")
|
||||
local Screens = require("src.ui.Screens")
|
||||
|
||||
return function(game)
|
||||
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-bug1672-arrows"
|
||||
|
||||
local function tap(button, frames)
|
||||
game.input.pressQueue[#game.input.pressQueue + 1] = button
|
||||
game.input.state[button] = true
|
||||
U.wait(2)
|
||||
game.input.state[button] = false
|
||||
U.wait(frames or 6)
|
||||
end
|
||||
|
||||
U.wait(45)
|
||||
assert(game.world and game.world.map, "gold world did not boot")
|
||||
local save, data = game.save, game.data
|
||||
|
||||
local function mon(species, level) return Mon.new(data, species, level) end
|
||||
|
||||
-- IsAnyMonHoldingMail refuses the whole screen (src/ui/gen2/PcMenu.lua:303),
|
||||
-- so the seeded party carries no mail.
|
||||
save.party = {}
|
||||
for _, species in ipairs({ "CYNDAQUIL", "PIDGEY", "SENTRET" }) do
|
||||
save.party[#save.party + 1] = mon(species, 12)
|
||||
end
|
||||
local stored = Boxes.box(save, 1)
|
||||
for i = #stored, 1, -1 do stored[i] = nil end
|
||||
for i, species in ipairs({ "GEODUDE", "ZUBAT", "RATTATA" }) do
|
||||
stored[i] = mon(species, 10 + i)
|
||||
end
|
||||
Boxes.rename(save, 1, "GRASS")
|
||||
save.currentBox = 1
|
||||
|
||||
local function openBox(mode)
|
||||
Screens.push(game, "Gen2BoxMenu", {
|
||||
save = save, mode = mode,
|
||||
onClose = function() game.stack:pop() end,
|
||||
})
|
||||
U.wait(8)
|
||||
return game.stack:top()
|
||||
end
|
||||
|
||||
local function close()
|
||||
while game.stack:top() and game.stack:top().submenuRows do
|
||||
game.stack:pop()
|
||||
end
|
||||
U.wait(4)
|
||||
end
|
||||
|
||||
-- ---- 1. the move list on a renamed box -----------------------------------
|
||||
local menu = openBox("move")
|
||||
U.log("01 move, box GRASS:", "want both arrows flanking GRASS on row 1")
|
||||
U.shot(game, out .. "/01-move-box.png")
|
||||
|
||||
-- ---- 2. LEFT onto the PARTY ----------------------------------------------
|
||||
-- BillsPC_BoxName's `.party` arm; only the move screen ever loads box 0.
|
||||
tap("left")
|
||||
U.log("02 move, PARTY:", "boxIndex " .. tostring(menu.boxIndex) ..
|
||||
", want 0 and both arrows still up")
|
||||
U.shot(game, out .. "/02-move-party.png")
|
||||
tap("right")
|
||||
|
||||
-- ---- 3. the MOVE / STATS / CANCEL submenu --------------------------------
|
||||
tap("a")
|
||||
U.log("03 submenu:", "phase " .. tostring(menu.phase) ..
|
||||
", want submenu and both arrows still up")
|
||||
U.shot(game, out .. "/03-move-submenu.png")
|
||||
|
||||
-- ---- 4. the insert cursor (.PrepInsertCursor rewrites them) --------------
|
||||
tap("a", 10)
|
||||
U.log("04 insert:", "phase " .. tostring(menu.phase) ..
|
||||
", want insert and both arrows still up")
|
||||
U.shot(game, out .. "/04-move-insert.png")
|
||||
close()
|
||||
|
||||
-- ---- 5/6. the negative controls ------------------------------------------
|
||||
openBox("withdraw")
|
||||
U.log("05 withdraw:", "want plain border tiles, NO arrows")
|
||||
U.shot(game, out .. "/05-withdraw-none.png")
|
||||
close()
|
||||
|
||||
openBox("deposit")
|
||||
U.log("06 deposit:", "want plain border tiles, NO arrows")
|
||||
U.shot(game, out .. "/06-deposit-none.png")
|
||||
close()
|
||||
|
||||
openBox("move")
|
||||
U.log("done -- the MOVE screen is open; the controls are yours")
|
||||
end
|
||||
@@ -70,15 +70,18 @@ return function(game)
|
||||
U.tap(game, "right")
|
||||
shot("03-tmhm") -- TMs carry ×NN, the two HMs carry none
|
||||
|
||||
-- SELECT arms the row instead of registering it (#1427): the hollow ▷ marks
|
||||
-- TM_HEADBUTT while "Where should this be moved to?" holds the box.
|
||||
-- SELECT armed the row here for #1427; since #1567 it is filtered out of the
|
||||
-- TM/HM pocket entirely (engine/items/tmhm.asm:207), so these taps must leave
|
||||
-- the list exactly as shot 03 has it: no hollow ▷, no "Where should this be
|
||||
-- moved to?", no reorder.
|
||||
U.tap(game, "down")
|
||||
U.tap(game, "select")
|
||||
shot("04-tmhm-armed")
|
||||
shot("04-tmhm-select-ignored")
|
||||
U.tap(game, "up")
|
||||
shot("05-tmhm-destination")
|
||||
shot("05-tmhm-still-numbered")
|
||||
U.tap(game, "a")
|
||||
shot("06-tmhm-moved") -- HEADBUTT is now the first TM row
|
||||
U.tap(game, "b")
|
||||
shot("06-tmhm-unchanged")
|
||||
|
||||
U.log("[driver] bag order:", table.concat(Bag.order(save), " "))
|
||||
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
-- Assertion driver: a KEY ITEM and an HM through the player's item PC.
|
||||
--
|
||||
-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold \
|
||||
-- POKEPORT_DRIVER=tests/drivers/gold_bug1486_test.lua \
|
||||
-- perl -e 'alarm 300; exec @ARGV' \
|
||||
-- python3 -c "import pty; pty.spawn(['love','.'])"
|
||||
--
|
||||
-- Issue #1486. What the unit tier cannot show: the two screens a player
|
||||
-- actually looks at. .DepositItem (engine/events/pokecenter_pc.asm:504) reads
|
||||
-- CANT_TOSS only to force x1 and skip .AskQuantity, and PlaceMenuItemQuantity
|
||||
-- (engine/menus/menu_2.asm:18) draws no ×NN under such a row.
|
||||
--
|
||||
-- Shots land in /tmp/gold-bug1486:
|
||||
--
|
||||
-- 01-key-items-pocket.png the DEPOSIT chooser on the KEY ITEMS pocket
|
||||
-- 02-deposited.png "Deposited 1 BICYCLE(S)." -- before the fix this
|
||||
-- is the unchanged pocket with no message at all
|
||||
-- 03-pc-list.png the PC list: POTION with its ×03, BICYCLE and
|
||||
-- HM01 with no ×NN at all
|
||||
local U = require("tests.drivers.util")
|
||||
|
||||
return function(game)
|
||||
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-bug1486"
|
||||
local fails = 0
|
||||
|
||||
local function ok(cond, msg)
|
||||
if cond then print("[1486] ok " .. msg)
|
||||
else fails = fails + 1 print("[1486] FAIL " .. msg) end
|
||||
return cond
|
||||
end
|
||||
|
||||
local function tap(button, frames)
|
||||
game.input.pressQueue[#game.input.pressQueue + 1] = button
|
||||
game.input.state[button] = true
|
||||
U.wait(2)
|
||||
game.input.state[button] = false
|
||||
U.wait(frames or 4)
|
||||
end
|
||||
|
||||
U.wait(45)
|
||||
local w = game.world
|
||||
assert(w and w.map, "gold world did not boot")
|
||||
local save = game.save
|
||||
|
||||
local Bag = require("src.inventory.Bag")
|
||||
local Mon = require("src.battle.gen2.Mon")
|
||||
save.party = { Mon.new(game.data, "CYNDAQUIL", 10) }
|
||||
save.inventory = {}
|
||||
save.pcItems = { POTION = 3 }
|
||||
assert(Bag.add(save, "BICYCLE", 1, game.data), "BICYCLE into the bag")
|
||||
assert(Bag.add(save, "HM_CUT", 1, game.data), "HM01 into the bag")
|
||||
|
||||
local function topId()
|
||||
local top = game.stack:top()
|
||||
return top and top.screenId or nil
|
||||
end
|
||||
|
||||
assert(w:setMap("CHERRYGROVE_POKECENTER_1F", 4, 4, "up"),
|
||||
"setMap CHERRYGROVE_POKECENTER_1F failed")
|
||||
U.wait(5)
|
||||
local pcX, pcY
|
||||
for cy = 0, w.map.heightCells - 1 do
|
||||
for cx = 0, w.map.widthCells - 1 do
|
||||
if w.map:cellCollision(cx, cy) == 0x93 then pcX, pcY = cx, cy end
|
||||
end
|
||||
end
|
||||
assert(pcX, "no COLL_PC tile in the Pokecenter")
|
||||
assert(w:setMap("CHERRYGROVE_POKECENTER_1F", pcX, pcY + 1, "up"),
|
||||
"setMap onto the PC tile failed")
|
||||
U.wait(5)
|
||||
|
||||
tap("a", 8) -- PCScript
|
||||
tap("a", 4) -- the turn-on line
|
||||
tap("down", 4)
|
||||
tap("a", 4) -- <PLAYER>'s PC
|
||||
tap("a", 4)
|
||||
tap("a", 6) -- both PokecenterPlayersPCText pages
|
||||
ok(topId() == "Gen2ItemPcMenu",
|
||||
"<PLAYER>'s PC opens the item PC (top: " .. tostring(topId()) .. ")")
|
||||
|
||||
-- DEPOSIT ITEM -> the PACK chooser -> the KEY ITEMS pocket.
|
||||
tap("down", 4)
|
||||
tap("a", 6)
|
||||
tap("right", 4) -- ITEM -> BALL
|
||||
tap("right", 4) -- BALL -> KEY ITEMS
|
||||
U.shot(game, out .. "/01-key-items-pocket.png")
|
||||
tap("a", 6) -- the BICYCLE row: x1, no prompt
|
||||
ok(save.pcItems.BICYCLE == 1,
|
||||
"the BICYCLE landed in the PC (" .. tostring(save.pcItems.BICYCLE) .. ")")
|
||||
ok(save.inventory.BICYCLE == nil,
|
||||
"and left the bag (" .. tostring(save.inventory.BICYCLE) .. ")")
|
||||
U.shot(game, out .. "/02-deposited.png")
|
||||
tap("a", 4) -- the Deposited line
|
||||
|
||||
-- The same, one pocket over: an HM.
|
||||
tap("right", 4) -- KEY ITEMS -> TM/HM
|
||||
tap("a", 6)
|
||||
ok(save.pcItems.HM_CUT == 1,
|
||||
"HM01 landed in the PC (" .. tostring(save.pcItems.HM_CUT) .. ")")
|
||||
ok(save.inventory.HM_CUT == nil,
|
||||
"and left the bag (" .. tostring(save.inventory.HM_CUT) .. ")")
|
||||
tap("a", 4)
|
||||
tap("b", 6) -- close the PACK
|
||||
|
||||
-- WITHDRAW ITEM: the list is where PlaceMenuItemQuantity shows.
|
||||
tap("up", 4)
|
||||
tap("a", 6)
|
||||
U.shot(game, out .. "/03-pc-list.png")
|
||||
tap("a", 6) -- the first row, x1 or the selector
|
||||
print(("[1486] %d failures"):format(fails))
|
||||
love.event.quit(fails == 0 and 0 or 1)
|
||||
end
|
||||
@@ -0,0 +1,168 @@
|
||||
-- #1556, the two battle jingles: _NewDexDataText's sound_slot_machine_start
|
||||
-- (data/text/common_3.asm:285) on a first catch, and _LearnedMoveText's
|
||||
-- sound_dex_fanfare_50_79 (data/text/common_3.asm:119) on a level-up move.
|
||||
--
|
||||
-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold POKEPORT_TOUCH=0 \
|
||||
-- POKEPORT_DRIVER=tests/drivers/gold_bug1556_battle.lua \
|
||||
-- perl -e 'alarm 420; exec @ARGV' \
|
||||
-- python3 -c "import pty; pty.spawn(['love','.'])"
|
||||
-- POKEPORT_SHOT_DIR=/tmp/gold-bug1556-battle (default)
|
||||
--
|
||||
-- Both ride the message queue's `sfx` + `waitSfx` keys, which the "Gotcha!"
|
||||
-- line and the "grew to level" line already used -- the two lines next door
|
||||
-- to them did not. So the ear is comparing neighbours: the catch jingle must
|
||||
-- be followed by a SECOND, different one over the #DEX line, and the level-up
|
||||
-- fanfare by a second over "learned".
|
||||
local U = require("tests.drivers.util")
|
||||
|
||||
local Bag = require("src.inventory.Bag")
|
||||
local PackMenu = require("src.ui.gen2.PackMenu")
|
||||
local Mon = require("src.battle.gen2.Mon")
|
||||
local Sound = require("src.core.Sound")
|
||||
|
||||
return function(game)
|
||||
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-bug1556-battle"
|
||||
|
||||
local heard = {}
|
||||
local realPlay = Sound.play
|
||||
Sound.play = function(data, name)
|
||||
heard[#heard + 1] = name
|
||||
return realPlay(data, name)
|
||||
end
|
||||
local function reset() heard = {} end
|
||||
-- A throw, its wobbles and a level-up all animate for a while; a report taken
|
||||
-- before the jingle lands reads as silence.
|
||||
local function awaitSfx(name, frames, want)
|
||||
want = want or 1
|
||||
for i = 1, (frames or 200) do
|
||||
local seen = 0
|
||||
for _, n in ipairs(heard) do if n == name then seen = seen + 1 end end
|
||||
if seen >= want then return true end
|
||||
if i % 5 == 0 then U.tap(game, "a") else U.wait(2) end
|
||||
end
|
||||
return false
|
||||
end
|
||||
local function report(label, want)
|
||||
U.log(label, #heard > 0 and table.concat(heard, ", ") or "(silence)")
|
||||
U.log(" want:", want)
|
||||
end
|
||||
|
||||
U.wait(45)
|
||||
local world = game.world
|
||||
assert(world and world.map, "gold world did not boot")
|
||||
local save, data = game.save, game.data
|
||||
|
||||
local function battleScreen()
|
||||
local top = game.stack:top()
|
||||
return (top and top.battle) and top or nil
|
||||
end
|
||||
|
||||
local function tapUntil(predicate, tries, btn)
|
||||
for _ = 1, tries or 400 do
|
||||
if predicate() then return true end
|
||||
U.tap(game, btn or "a")
|
||||
U.wait(2)
|
||||
end
|
||||
return predicate()
|
||||
end
|
||||
|
||||
-- ---- 1. a first catch: "Gotcha!" then the #DEX line ---------------------
|
||||
save.party = { Mon.new(data, "CYNDAQUIL", 20) }
|
||||
save.inventory, save.bagOrder = {}, {}
|
||||
Bag.add(save, "MASTER_BALL", 1, data)
|
||||
save.pokedexReceived = true
|
||||
save.pokedex = { seen = {}, caught = {} }
|
||||
|
||||
local wild = Mon.new(data, "PIDGEY", 3)
|
||||
assert(wild, "the cache carries no PIDGEY")
|
||||
assert(world:startBattle({ wild = wild }), "the wild battle refused to start")
|
||||
assert(tapUntil(function()
|
||||
local screen = battleScreen()
|
||||
return screen ~= nil and screen.phase == "menu"
|
||||
end), "the battle never reached BattleMenu")
|
||||
local screen = battleScreen()
|
||||
|
||||
-- BattleMenuHeader's 2x2 grid: 1 FIGHT / 2 PkMn, 3 PACK / 4 RUN.
|
||||
if screen.menuIndex % 2 == 0 then U.tap(game, "left") U.wait(3) end
|
||||
if screen.menuIndex <= 2 then U.tap(game, "down") U.wait(3) end
|
||||
U.tap(game, "a")
|
||||
U.wait(6)
|
||||
local pack = game.stack:top()
|
||||
assert(pack and pack.rows, "the battle PACK did not open")
|
||||
-- The PACK restores its own cursor pocket; the BALL pocket is where a
|
||||
-- MASTER BALL lives (engine/items/pack.asm's ItemAttributes pocket byte).
|
||||
for _ = 1, #PackMenu.POCKETS do
|
||||
if pack:pocket().id == "BALL" then break end
|
||||
U.tap(game, "right")
|
||||
U.wait(3)
|
||||
end
|
||||
assert(pack:pocket().id == "BALL", "could not reach the BALL pocket")
|
||||
local ballRow
|
||||
for index, row in ipairs(pack.rows) do
|
||||
if row.id == "MASTER_BALL" then ballRow = index end
|
||||
end
|
||||
assert(ballRow, "the battle PACK does not show the MASTER BALL")
|
||||
for _ = 2, ballRow do U.tap(game, "down") U.wait(2) end
|
||||
reset()
|
||||
U.tap(game, "a")
|
||||
awaitSfx("Sfx_CaughtMon", 400)
|
||||
awaitSfx("Sfx_SlotMachineStart", 300)
|
||||
U.wait(30)
|
||||
report("01 catch:",
|
||||
"Sfx_CaughtMon, then Sfx_SlotMachineStart over the #DEX line")
|
||||
U.shot(game, out .. "/01-caught.png")
|
||||
for _ = 1, 6 do U.tap(game, "a") U.wait(20) end
|
||||
U.shot(game, out .. "/02-dex-line.png")
|
||||
for _ = 1, 30 do
|
||||
if not battleScreen() then break end
|
||||
U.tap(game, "a")
|
||||
U.wait(10)
|
||||
end
|
||||
|
||||
-- ---- 2. a level-up that teaches a move ---------------------------------
|
||||
local SPECIES = "CYNDAQUIL"
|
||||
local def = data.pokemon and data.pokemon[SPECIES]
|
||||
local target
|
||||
for _, entry in ipairs((def and def.levelMoves) or {}) do
|
||||
if entry.level and entry.level > 6 and not target then target = entry.level end
|
||||
end
|
||||
if not target then
|
||||
U.log("SKIP 02 -- no level-up move row for " .. SPECIES)
|
||||
else
|
||||
local hero = Mon.new(data, SPECIES, target - 1)
|
||||
local growth = Mon.growthFor(data, def.growthRate)
|
||||
hero.experience = Mon.experienceForLevel(growth, target) - 1
|
||||
-- Only three moves, so LearnMove takes the free slot rather than ForgetMove
|
||||
-- (engine/pokemon/learn.asm:23-29).
|
||||
while hero.moves and #hero.moves > 3 do table.remove(hero.moves) end
|
||||
save.party = { hero }
|
||||
save.inventory = {}
|
||||
U.log(("levelling %s %d -> %d for its %s row")
|
||||
:format(SPECIES, hero.level, target, tostring(target)))
|
||||
|
||||
local prey = Mon.new(data, "PIDGEY", 2)
|
||||
prey.hp = 1
|
||||
assert(world:startBattle({ wild = prey }), "the second battle refused")
|
||||
assert(tapUntil(function()
|
||||
local s = battleScreen()
|
||||
return s ~= nil and s.phase == "menu"
|
||||
end), "the second battle never reached BattleMenu")
|
||||
screen = battleScreen()
|
||||
if screen.menuIndex % 2 == 0 then U.tap(game, "left") U.wait(3) end
|
||||
if screen.menuIndex > 2 then U.tap(game, "up") U.wait(3) end
|
||||
reset()
|
||||
U.tap(game, "a") -- FIGHT
|
||||
U.wait(6)
|
||||
U.tap(game, "a") -- the first move
|
||||
awaitSfx("Sfx_DexFanfare5079", 400, 2)
|
||||
U.wait(60)
|
||||
report("02 level-up move:",
|
||||
"Sfx_DexFanfare5079 twice: \"grew to level\" and \"learned\"")
|
||||
U.shot(game, out .. "/03-level-up.png")
|
||||
for _ = 1, 8 do U.tap(game, "a") U.wait(30) end
|
||||
U.shot(game, out .. "/04-learned.png")
|
||||
end
|
||||
|
||||
Sound.play = realPlay
|
||||
U.log("done -- the controls are yours")
|
||||
end
|
||||
@@ -0,0 +1,196 @@
|
||||
-- #1556: Bill's PC -- the deposit / withdraw / release cry, and the six
|
||||
-- SFX_WRONG refusals (engine/pokemon/bills_pc.asm).
|
||||
--
|
||||
-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold POKEPORT_TOUCH=0 \
|
||||
-- POKEPORT_DRIVER=tests/drivers/gold_bug1556_billspc.lua \
|
||||
-- perl -e 'alarm 420; exec @ARGV' \
|
||||
-- python3 -c "import pty; pty.spawn(['love','.'])"
|
||||
-- POKEPORT_SHOT_DIR=/tmp/gold-bug1556-billspc (default)
|
||||
--
|
||||
-- Eight moments, each seeded, driven through the real screen and screenshotted.
|
||||
-- The log names what sounded; the ear is the point. DepositPokemon and
|
||||
-- TryWithdrawPokemon both `call PlayMonCry` right after RemoveMonFromPartyOrBox
|
||||
-- (:1762 and :1817), and PlayMonCry itself bails on an EGG
|
||||
-- (home/pokemon.asm:113), so a boxed egg must move in silence.
|
||||
--
|
||||
-- The run ends with the PC screen open so a human takes the controls there.
|
||||
local U = require("tests.drivers.util")
|
||||
|
||||
local Boxes = require("src.core.gen2.Boxes")
|
||||
local Mon = require("src.battle.gen2.Mon")
|
||||
local Screens = require("src.ui.Screens")
|
||||
local Sound = require("src.core.Sound")
|
||||
|
||||
return function(game)
|
||||
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-bug1556-billspc"
|
||||
|
||||
local heard = {}
|
||||
local realPlay, realCry = Sound.play, Sound.playCry
|
||||
Sound.play = function(data, name)
|
||||
heard[#heard + 1] = name
|
||||
return realPlay(data, name)
|
||||
end
|
||||
Sound.playCry = function(data, species, clip)
|
||||
heard[#heard + 1] = "cry:" .. tostring(species)
|
||||
return realCry(data, species, clip)
|
||||
end
|
||||
|
||||
local function reset() heard = {} end
|
||||
local function report(label, want)
|
||||
U.log(label, #heard > 0 and table.concat(heard, ", ") or "(silence)")
|
||||
U.log(" want:", want)
|
||||
end
|
||||
|
||||
local function tap(button, frames)
|
||||
game.input.pressQueue[#game.input.pressQueue + 1] = button
|
||||
game.input.state[button] = true
|
||||
U.wait(2)
|
||||
game.input.state[button] = false
|
||||
U.wait(frames or 6)
|
||||
end
|
||||
|
||||
U.wait(45)
|
||||
local w = game.world
|
||||
assert(w and w.map, "gold world did not boot")
|
||||
local save = game.save
|
||||
local data = game.data
|
||||
|
||||
local function mon(species, level)
|
||||
return Mon.new(data, species, level)
|
||||
end
|
||||
|
||||
local function openBox(mode)
|
||||
Screens.push(game, "Gen2BoxMenu", {
|
||||
save = save, mode = mode,
|
||||
onClose = function() game.stack:pop() end,
|
||||
})
|
||||
U.wait(8)
|
||||
return game.stack:top()
|
||||
end
|
||||
|
||||
local function close()
|
||||
while game.stack:top() and game.stack:top().submenuRows do
|
||||
game.stack:pop()
|
||||
end
|
||||
U.wait(4)
|
||||
end
|
||||
|
||||
local function fillBox(index, n, species)
|
||||
local box = Boxes.box(save, index)
|
||||
for i = #box, 1, -1 do box[i] = nil end
|
||||
for _ = 1, n do box[#box + 1] = mon(species or "RATTATA", 5) end
|
||||
end
|
||||
|
||||
local function setParty(n, species)
|
||||
save.party = {}
|
||||
for _ = 1, n do save.party[#save.party + 1] = mon(species or "CYNDAQUIL", 10) end
|
||||
end
|
||||
|
||||
save.currentBox = 1
|
||||
|
||||
-- ---- 1. WITHDRAW a mon: its cry ------------------------------------------
|
||||
setParty(1)
|
||||
fillBox(1, 1, "PIDGEY")
|
||||
local menu = openBox("withdraw")
|
||||
reset()
|
||||
tap("a") -- the submenu
|
||||
tap("a", 12) -- WITHDRAW
|
||||
report("01 withdraw:", "cry:PIDGEY")
|
||||
U.shot(game, out .. "/01-withdraw-cry.png")
|
||||
tap("a") -- clear anything on screen
|
||||
close()
|
||||
|
||||
-- ---- 2. DEPOSIT it back: the cry again -----------------------------------
|
||||
setParty(3)
|
||||
save.party[#save.party + 1] = mon("PIDGEY", 5)
|
||||
fillBox(1, 0)
|
||||
menu = openBox("deposit")
|
||||
for _ = 1, 3 do tap("down") end
|
||||
reset()
|
||||
tap("a")
|
||||
tap("a", 12) -- DEPOSIT
|
||||
report("02 deposit:", "cry:PIDGEY")
|
||||
U.shot(game, out .. "/02-deposit-cry.png")
|
||||
tap("a")
|
||||
close()
|
||||
|
||||
-- ---- 3. WITHDRAW into a full party: SFX_WRONG (:1845) --------------------
|
||||
setParty(Boxes.PARTY_SIZE)
|
||||
fillBox(1, 1, "PIDGEY")
|
||||
menu = openBox("withdraw")
|
||||
reset()
|
||||
tap("a")
|
||||
tap("a", 12)
|
||||
report("03 withdraw, party full:", "Sfx_Wrong + \"You can't take any more\"")
|
||||
U.shot(game, out .. "/03-party-full.png")
|
||||
tap("a")
|
||||
close()
|
||||
|
||||
-- ---- 4. DEPOSIT into a full box: SFX_WRONG (:1790) ----------------------
|
||||
setParty(3)
|
||||
fillBox(1, Boxes.MONS_PER_BOX)
|
||||
menu = openBox("deposit")
|
||||
reset()
|
||||
tap("a")
|
||||
tap("a", 12)
|
||||
report("04 deposit, box full:", "Sfx_Wrong + \"The BOX is full.\"")
|
||||
U.shot(game, out .. "/04-box-full.png")
|
||||
tap("a")
|
||||
close()
|
||||
|
||||
-- ---- 5. DEPOSIT the last healthy mon: SFX_WRONG -------------------------
|
||||
setParty(2)
|
||||
save.party[2].hp = 0
|
||||
fillBox(1, 0)
|
||||
menu = openBox("deposit")
|
||||
reset()
|
||||
tap("a")
|
||||
tap("a", 12)
|
||||
report("05 deposit, last healthy:", "Sfx_Wrong + \"can't deposit the last\"")
|
||||
U.shot(game, out .. "/05-last-mon.png")
|
||||
tap("a")
|
||||
close()
|
||||
|
||||
-- ---- 6. RELEASE a boxed mon: its cry (:1866) ----------------------------
|
||||
setParty(3)
|
||||
fillBox(1, 1, "SENTRET")
|
||||
menu = openBox("withdraw")
|
||||
reset()
|
||||
tap("select", 8) -- RELEASE asks first
|
||||
tap("up") -- the YES/NO box defaults to NO
|
||||
tap("a", 14)
|
||||
report("06 release:", "cry:SENTRET before \"was released.\"")
|
||||
U.shot(game, out .. "/06-release-cry.png")
|
||||
tap("a")
|
||||
close()
|
||||
|
||||
-- ---- 7. RELEASE an EGG: SFX_WRONG, no cry (:1625) -----------------------
|
||||
setParty(3)
|
||||
fillBox(1, 0)
|
||||
local egg = mon("TOGEPI", 5)
|
||||
egg.isEgg = true
|
||||
Boxes.box(save, 1)[1] = egg
|
||||
menu = openBox("withdraw")
|
||||
reset()
|
||||
tap("select", 10)
|
||||
report("07 release an EGG:", "Sfx_Wrong, and NO cry:TOGEPI")
|
||||
U.shot(game, out .. "/07-release-egg.png")
|
||||
tap("a")
|
||||
close()
|
||||
|
||||
-- ---- 8. MOVE into a full box: SFX_WRONG (:1567) -------------------------
|
||||
setParty(3)
|
||||
fillBox(1, 1, "HOOTHOOT")
|
||||
fillBox(2, Boxes.MONS_PER_BOX)
|
||||
menu = openBox("move")
|
||||
reset()
|
||||
tap("a") -- the MOVE/STATS/CANCEL submenu
|
||||
tap("a", 8) -- MOVE -> the insert cursor
|
||||
tap("right", 8) -- box 2, which is full
|
||||
tap("a", 10)
|
||||
report("08 move into a full box:", "Sfx_Wrong + \"There's no room!\"")
|
||||
U.shot(game, out .. "/08-no-room.png")
|
||||
|
||||
Sound.play, Sound.playCry = realPlay, realCry
|
||||
U.log("done -- the PC is open; the controls are yours")
|
||||
end
|
||||
@@ -0,0 +1,226 @@
|
||||
-- #1556: the bump sound, which the Gen 2 port never wired.
|
||||
--
|
||||
-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold POKEPORT_TOUCH=0 \
|
||||
-- POKEPORT_DRIVER=tests/drivers/gold_bug1556_bump.lua \
|
||||
-- perl -e 'alarm 420; exec @ARGV' \
|
||||
-- python3 -c "import pty; pty.spawn(['love','.'])"
|
||||
-- POKEPORT_SHOT_DIR=/tmp/gold-bug1556-bump (default)
|
||||
--
|
||||
-- Nothing here can be asserted by a unit test: the deliverable is the sound.
|
||||
-- What the log gives a reader is the RATE, which is the half that is easy to
|
||||
-- get wrong -- .BumpSound is `call CheckSFX / ret c`
|
||||
-- (engine/overworld/player_movement.asm:771), a busy-channel test rather than
|
||||
-- a frame counter, so a held direction re-rings only once the previous Sfx_Bump
|
||||
-- has stopped. A per-frame count means Sound.sfxBusy() is not seeing it.
|
||||
--
|
||||
-- Five moments, in the cart's own terms:
|
||||
-- 1. a wall .CheckLandPerms carry -> .bump (:264-265)
|
||||
-- 2. an NPC .CheckNPC = 0 -> .bump (:267-269)
|
||||
-- 3. a ledge hop .TryJump's carry returns ABOVE .NotMoving (:80-81),
|
||||
-- so the hop is Sfx_JumpOverLedge and NO bump
|
||||
-- 4. a map connection the step is taken; silence
|
||||
-- 5. a map edge with no connection behind it: the border block is a wall
|
||||
--
|
||||
-- The run ends in the overworld so a human takes the controls where it stops.
|
||||
local U = require("tests.drivers.util")
|
||||
|
||||
local Permissions = require("src.world.gen2.Permissions")
|
||||
local Sound = require("src.core.Sound")
|
||||
|
||||
return function(game)
|
||||
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-bug1556-bump"
|
||||
|
||||
local heard = {}
|
||||
local realPlay = Sound.play
|
||||
Sound.play = function(data, name)
|
||||
heard[#heard + 1] = name
|
||||
return realPlay(data, name)
|
||||
end
|
||||
|
||||
local function reset() heard = {} end
|
||||
|
||||
-- Re-entering a map re-fires its entry script, and a held direction would
|
||||
-- feed that textbox instead of the walk. World:busy() is the same gate the
|
||||
-- overworld's own input uses.
|
||||
local function settle()
|
||||
local calm = 0
|
||||
for _ = 1, 400 do
|
||||
if game.world:busy() then
|
||||
calm = 0
|
||||
U.tap(game, "a")
|
||||
U.wait(2)
|
||||
else
|
||||
calm = calm + 1
|
||||
if calm >= 12 then break end
|
||||
U.wait(2)
|
||||
end
|
||||
end
|
||||
U.wait(6)
|
||||
end
|
||||
|
||||
local function report(label)
|
||||
local counts, order = {}, {}
|
||||
for _, name in ipairs(heard) do
|
||||
if not counts[name] then order[#order + 1] = name end
|
||||
counts[name] = (counts[name] or 0) + 1
|
||||
end
|
||||
local parts = {}
|
||||
for _, name in ipairs(order) do
|
||||
parts[#parts + 1] = ("%s x%d"):format(name, counts[name])
|
||||
end
|
||||
U.log(label, #parts > 0 and table.concat(parts, ", ") or "(silence)")
|
||||
return counts
|
||||
end
|
||||
|
||||
U.wait(45)
|
||||
local w = game.world
|
||||
assert(w and w.map, "gold world did not boot")
|
||||
|
||||
local function walkable(map, x, y)
|
||||
return map:inBounds(x, y) and map:isWalkable(x, y)
|
||||
end
|
||||
|
||||
local DELTA = { up = { 0, -1 }, down = { 0, 1 },
|
||||
left = { -1, 0 }, right = { 1, 0 } }
|
||||
|
||||
-- A standing cell whose neighbour in `dir` is a wall.
|
||||
local function findWall(map)
|
||||
for y = 0, map.heightCells - 1 do
|
||||
for x = 0, map.widthCells - 1 do
|
||||
if walkable(map, x, y) then
|
||||
for dir, d in pairs(DELTA) do
|
||||
local nx, ny = x + d[1], y + d[2]
|
||||
if map:inBounds(nx, ny) and not map:isWalkable(nx, ny) then
|
||||
return x, y, dir
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- ---- 1. a wall -----------------------------------------------------------
|
||||
assert(w:setMap("NEW_BARK_TOWN", 5, 5, "down"), "setMap NEW_BARK_TOWN")
|
||||
U.wait(5)
|
||||
local wx, wy, wdir = findWall(w.map)
|
||||
if wx then
|
||||
assert(w:setMap("NEW_BARK_TOWN", wx, wy, wdir))
|
||||
U.wait(8)
|
||||
settle()
|
||||
reset()
|
||||
U.hold(game, wdir, 90)
|
||||
U.wait(5)
|
||||
local counts = report("01 wall (" .. wdir .. ", 90 frames held):")
|
||||
U.log(" Sfx_Bump over 90 held frames:", counts.Sfx_Bump or 0,
|
||||
"-- a per-frame count here means the CheckSFX gate is not working")
|
||||
U.shot(game, out .. "/01-wall.png")
|
||||
else
|
||||
U.log("SKIP 01 wall -- no wall cell found in NEW_BARK_TOWN")
|
||||
end
|
||||
|
||||
-- ---- 2. an NPC -----------------------------------------------------------
|
||||
local npc, npcDir
|
||||
for _, e in ipairs(w.entities or {}) do
|
||||
if e ~= w.player and e.cellX and not e.bigObject then
|
||||
for dir, d in pairs(DELTA) do
|
||||
local sx, sy = e.cellX - d[1], e.cellY - d[2]
|
||||
if walkable(w.map, sx, sy) then npc, npcDir = { sx, sy }, dir break end
|
||||
end
|
||||
end
|
||||
if npc then break end
|
||||
end
|
||||
if npc then
|
||||
assert(w:setMap("NEW_BARK_TOWN", npc[1], npc[2], npcDir))
|
||||
U.wait(8)
|
||||
settle()
|
||||
reset()
|
||||
U.hold(game, npcDir, 45)
|
||||
U.wait(5)
|
||||
report("02 NPC (" .. npcDir .. "):")
|
||||
U.shot(game, out .. "/02-npc.png")
|
||||
else
|
||||
U.log("SKIP 02 NPC -- no reachable NPC in NEW_BARK_TOWN")
|
||||
end
|
||||
|
||||
-- ---- 3. a ledge ----------------------------------------------------------
|
||||
local ROUTES = { "ROUTE_29", "ROUTE_30", "ROUTE_31", "ROUTE_32",
|
||||
"ROUTE_33", "ROUTE_34", "ROUTE_35", "ROUTE_36" }
|
||||
local hopped = false
|
||||
for _, mapId in ipairs(ROUTES) do
|
||||
if w:setMap(mapId, 5, 5, "down") then
|
||||
U.wait(5)
|
||||
local map = w.map
|
||||
for y = 0, map.heightCells - 1 do
|
||||
for x = 0, map.widthCells - 1 do
|
||||
local facings = Permissions.ledgeFacings(map:cellCollision(x, y))
|
||||
if facings then
|
||||
for dir, on in pairs(facings) do
|
||||
local d = on and DELTA[dir]
|
||||
if d and walkable(map, x + d[1] * 2, y + d[2] * 2) then
|
||||
assert(w:setMap(mapId, x, y, dir))
|
||||
U.wait(8)
|
||||
settle()
|
||||
reset()
|
||||
U.hold(game, dir, 40)
|
||||
U.wait(20)
|
||||
local counts = report(
|
||||
("03 ledge hop (%s %s):"):format(mapId, dir))
|
||||
U.log(" want Sfx_JumpOverLedge and NO Sfx_Bump; bumps:",
|
||||
counts.Sfx_Bump or 0)
|
||||
U.shot(game, out .. "/03-ledge.png")
|
||||
hopped = true
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
if hopped then break end
|
||||
end
|
||||
if hopped then break end
|
||||
end
|
||||
end
|
||||
if hopped then break end
|
||||
end
|
||||
if not hopped then U.log("SKIP 03 ledge -- no hoppable ledge found") end
|
||||
|
||||
-- ---- 4 and 5. map edges --------------------------------------------------
|
||||
assert(w:setMap("NEW_BARK_TOWN", 5, 5, "down"), "setMap NEW_BARK_TOWN")
|
||||
U.wait(5)
|
||||
local DIR_CONN = { up = "north", down = "south", left = "west",
|
||||
right = "east" }
|
||||
local map = w.map
|
||||
local function edgeCell(dir)
|
||||
local d = DELTA[dir]
|
||||
for y = 0, map.heightCells - 1 do
|
||||
for x = 0, map.widthCells - 1 do
|
||||
if walkable(map, x, y)
|
||||
and not map:inBounds(x + d[1], y + d[2]) then
|
||||
return x, y
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
for _, dir in ipairs({ "up", "down", "left", "right" }) do
|
||||
local conn = map:connection(DIR_CONN[dir])
|
||||
local ex, ey = edgeCell(dir)
|
||||
if ex then
|
||||
assert(w:setMap("NEW_BARK_TOWN", ex, ey, dir))
|
||||
U.wait(8)
|
||||
settle()
|
||||
reset()
|
||||
U.hold(game, dir, 40)
|
||||
U.wait(20)
|
||||
local counts = report(("%s edge %s (connection: %s):")
|
||||
:format(conn and "04" or "05", dir,
|
||||
tostring(conn and conn.mapId or "none")))
|
||||
U.log(" want", conn and "silence" or "Sfx_Bump", "-- bumps:",
|
||||
counts.Sfx_Bump or 0)
|
||||
U.shot(game, out .. ("/0%s-edge-%s.png"):format(conn and "4" or "5", dir))
|
||||
assert(w:setMap("NEW_BARK_TOWN", ex, ey, dir))
|
||||
U.wait(5)
|
||||
end
|
||||
end
|
||||
|
||||
Sound.play = realPlay
|
||||
assert(w:setMap("NEW_BARK_TOWN", 5, 5, "down"))
|
||||
U.log("done -- the controls are yours; walk into anything")
|
||||
end
|
||||
@@ -0,0 +1,228 @@
|
||||
-- #1556: the field jingles that ride a TX_SOUND at the end of a string --
|
||||
-- RARE CANDY's level-up, "X learned MOVE!", the trophy -- plus the TM/HM
|
||||
-- refusals, which are `call PlaySFX` next to the message instead.
|
||||
--
|
||||
-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold POKEPORT_TOUCH=0 \
|
||||
-- POKEPORT_DRIVER=tests/drivers/gold_bug1556_fanfare.lua \
|
||||
-- perl -e 'alarm 420; exec @ARGV' \
|
||||
-- python3 -c "import pty; pty.spawn(['love','.'])"
|
||||
-- POKEPORT_SHOT_DIR=/tmp/gold-bug1556-fanfare (default)
|
||||
--
|
||||
-- The one deliberate SILENCE is moment 4: TeachTMHM's `.compatible` arm is
|
||||
-- `callfar KnowsMove / jr c, .nope` (engine/items/tmhm.asm:139-140) with no
|
||||
-- PlaySFX at all, so "X already knows MOVE!" must stay mute. Only the
|
||||
-- INCOMPATIBLE arm rings (:131).
|
||||
--
|
||||
-- The box is supposed to HOLD for the fanfare before it takes a button:
|
||||
-- TextCommand_SOUND is PlaySFX then WaitSFX (home/text.asm), which
|
||||
-- TextBox.soundOpts models with auto.wait.
|
||||
local U = require("tests.drivers.util")
|
||||
|
||||
local Mon = require("src.battle.gen2.Mon")
|
||||
local PackMenu = require("src.ui.gen2.PackMenu")
|
||||
local Sound = require("src.core.Sound")
|
||||
|
||||
return function(game)
|
||||
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-bug1556-fanfare"
|
||||
|
||||
local heard = {}
|
||||
local realPlay = Sound.play
|
||||
Sound.play = function(data, name)
|
||||
heard[#heard + 1] = name
|
||||
return realPlay(data, name)
|
||||
end
|
||||
local function reset() heard = {} end
|
||||
local function report(label, want)
|
||||
U.log(label, #heard > 0 and table.concat(heard, ", ") or "(silence)")
|
||||
U.log(" want:", want)
|
||||
end
|
||||
|
||||
local function tap(button, frames)
|
||||
game.input.pressQueue[#game.input.pressQueue + 1] = button
|
||||
game.input.state[button] = true
|
||||
U.wait(2)
|
||||
game.input.state[button] = false
|
||||
U.wait(frames or 6)
|
||||
end
|
||||
|
||||
-- TextCommand_SOUND rings when the box FINISHES printing (home/text.asm),
|
||||
-- so a report taken mid-print reads as silence.
|
||||
local function awaitSfx(name, frames)
|
||||
for _ = 1, (frames or 90) do
|
||||
for _, n in ipairs(heard) do if n == name then return true end end
|
||||
U.wait(2)
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
U.wait(45)
|
||||
local w = game.world
|
||||
assert(w and w.map, "gold world did not boot")
|
||||
local save, data = game.save, game.data
|
||||
|
||||
local SPECIES = "CYNDAQUIL"
|
||||
local def = data.pokemon and data.pokemon[SPECIES]
|
||||
assert(def, "the cache carries no " .. SPECIES)
|
||||
|
||||
-- Which TMs this species may and may not learn, straight off its BASE_TMHM.
|
||||
local canLearn, cannotLearn = nil, nil
|
||||
local allowed = {}
|
||||
for _, moveId in ipairs(def.tmhm or {}) do allowed[moveId] = true end
|
||||
local tmIds = {}
|
||||
for itemId, item in pairs(data.items or {}) do
|
||||
if type(item) == "table" and item.teaches
|
||||
and tostring(itemId):sub(1, 3) == "TM_" then
|
||||
tmIds[#tmIds + 1] = itemId
|
||||
end
|
||||
end
|
||||
table.sort(tmIds)
|
||||
for _, itemId in ipairs(tmIds) do
|
||||
if allowed[data.items[itemId].teaches] then
|
||||
canLearn = canLearn or itemId
|
||||
else
|
||||
cannotLearn = cannotLearn or itemId
|
||||
end
|
||||
end
|
||||
U.log("TM that fits:", tostring(canLearn),
|
||||
"-- TM that does not:", tostring(cannotLearn))
|
||||
|
||||
local function seed()
|
||||
local mon = Mon.new(data, SPECIES, 12)
|
||||
save.party = { mon }
|
||||
return mon
|
||||
end
|
||||
|
||||
local function openPack()
|
||||
local pack = PackMenu.new(game, {
|
||||
save = save, world = w, onClose = function() end })
|
||||
game.stack:push(pack)
|
||||
U.wait(6)
|
||||
return pack
|
||||
end
|
||||
|
||||
local field = game.overworld or game.stack:top()
|
||||
local function popToField()
|
||||
for _ = 1, 12 do
|
||||
if game.stack:top() == field or not game.stack:top() then break end
|
||||
game.stack:pop()
|
||||
U.wait(2)
|
||||
end
|
||||
end
|
||||
|
||||
-- ---- 1. RARE CANDY: "X grew to level N!" + the level-up fanfare ---------
|
||||
local mon = seed()
|
||||
save.inventory = { RARE_CANDY = 2 }
|
||||
reset()
|
||||
game:usePartyItem("RARE_CANDY")
|
||||
U.wait(8)
|
||||
tap("a", 30) -- the party pick
|
||||
awaitSfx("Sfx_DexFanfare5079")
|
||||
report("01 rare candy:", "Sfx_DexFanfare5079 under \"grew to level 13!\"")
|
||||
U.shot(game, out .. "/01-rare-candy.png")
|
||||
tap("a", 10)
|
||||
popToField()
|
||||
|
||||
-- ---- 2. a TM the mon CAN learn: "X learned MOVE!" + the fanfare ---------
|
||||
if canLearn then
|
||||
mon = seed()
|
||||
-- A full moveset diverts into MoveAskForgetText (engine/pokemon/learn.asm:
|
||||
-- 110-137); the direct-learn arm is what carries the fanfare.
|
||||
while #mon.moves > 2 do table.remove(mon.moves) end
|
||||
save.inventory = { [canLearn] = 1 }
|
||||
local pack = openPack()
|
||||
reset()
|
||||
pack:openTeachParty({ id = canLearn })
|
||||
U.wait(10)
|
||||
tap("a", 30) -- the party pick
|
||||
awaitSfx("Sfx_DexFanfare5079")
|
||||
report("02 TM " .. canLearn .. ":",
|
||||
"Sfx_DexFanfare5079 under \"learned ...!\"")
|
||||
U.shot(game, out .. "/02-tm-learned.png")
|
||||
tap("a", 10)
|
||||
popToField()
|
||||
else
|
||||
U.log("SKIP 02 -- no TM in this cache that " .. SPECIES .. " can learn")
|
||||
end
|
||||
|
||||
-- ---- 3. a TM the mon CANNOT learn: SFX_WRONG (tmhm.asm:131) ------------
|
||||
if cannotLearn then
|
||||
mon = seed()
|
||||
save.inventory = { [cannotLearn] = 1 }
|
||||
local pack = openPack()
|
||||
reset()
|
||||
pack:openTeachParty({ id = cannotLearn })
|
||||
U.wait(10)
|
||||
tap("a", 20)
|
||||
report("03 TM " .. cannotLearn .. ":",
|
||||
"Sfx_Wrong under \"can't learn ...!\"")
|
||||
U.shot(game, out .. "/03-tm-refused.png")
|
||||
tap("a", 10)
|
||||
popToField()
|
||||
else
|
||||
U.log("SKIP 03 -- every TM in this cache fits " .. SPECIES)
|
||||
end
|
||||
|
||||
-- ---- 4. a TM the mon ALREADY KNOWS: SILENCE ----------------------------
|
||||
if canLearn then
|
||||
mon = seed()
|
||||
local moveId = data.items[canLearn].teaches
|
||||
mon.moves = mon.moves or {}
|
||||
mon.moves[1] = { id = moveId, pp = 10, maxPp = 10 }
|
||||
save.inventory = { [canLearn] = 1 }
|
||||
local pack = openPack()
|
||||
reset()
|
||||
pack:openTeachParty({ id = canLearn })
|
||||
U.wait(10)
|
||||
tap("a", 20)
|
||||
report("04 TM already known:", "SILENCE -- KnowsMove has no PlaySFX")
|
||||
U.shot(game, out .. "/04-tm-already-known.png")
|
||||
tap("a", 10)
|
||||
popToField()
|
||||
end
|
||||
|
||||
-- ---- 5. a TM on an EGG: SFX_WRONG (tmhm.asm:104-111), a regression -----
|
||||
if canLearn then
|
||||
mon = seed()
|
||||
mon.isEgg = true
|
||||
save.inventory = { [canLearn] = 1 }
|
||||
local pack = openPack()
|
||||
reset()
|
||||
pack:openTeachParty({ id = canLearn })
|
||||
U.wait(10)
|
||||
tap("a", 20)
|
||||
report("05 TM on an EGG:", "Sfx_Wrong, and the list stays up")
|
||||
U.shot(game, out .. "/05-tm-egg.png")
|
||||
tap("b", 6)
|
||||
popToField()
|
||||
end
|
||||
|
||||
-- ---- 6. the trophy box: _SentTrophyHomeText's fanfare ------------------
|
||||
if data.items and data.items.NORMAL_BOX then
|
||||
seed()
|
||||
save.inventory = { NORMAL_BOX = 1 }
|
||||
save.decorations = save.decorations or {}
|
||||
local pack = openPack()
|
||||
pack:rebuild()
|
||||
local row
|
||||
for index, entry in ipairs(pack.rows) do
|
||||
if entry.id == "NORMAL_BOX" then row = index end
|
||||
end
|
||||
if row then
|
||||
pack.index = row
|
||||
reset()
|
||||
pack:useSelected()
|
||||
U.wait(20)
|
||||
report("06 trophy box:", "Sfx_DexFanfare5079 with the trophy line")
|
||||
U.shot(game, out .. "/06-trophy.png")
|
||||
tap("a", 6)
|
||||
else
|
||||
U.log("SKIP 06 -- the NORMAL BOX is not in the ITEM pocket here")
|
||||
end
|
||||
popToField()
|
||||
else
|
||||
U.log("SKIP 06 -- this cache has no NORMAL_BOX")
|
||||
end
|
||||
|
||||
Sound.play = realPlay
|
||||
U.log("done -- the controls are yours")
|
||||
end
|
||||
@@ -0,0 +1,105 @@
|
||||
-- #1567: the TM/HM pocket's row order.
|
||||
--
|
||||
-- POKEPORT_GAME=gold POKEPORT_TOUCH=0 \
|
||||
-- POKEPORT_DRIVER=tests/drivers/gold_bug1567_test.lua love .
|
||||
-- POKEPORT_SHOT_DIR=/tmp/gold-bug1567 (default)
|
||||
--
|
||||
-- Nothing here can be asserted: the bug IS the order rows land in.
|
||||
-- TMHM_DisplayPocketItems walks the fixed 57-byte wTMsHMs array from 1 to 57
|
||||
-- and prints every non-zero slot (engine/items/tmhm.asm:341), so the cart's
|
||||
-- pocket is always TM01..TM50 then HM01..HM07 no matter what the player picked
|
||||
-- up first. The port drew it from the acquisition-ordered bag list instead.
|
||||
--
|
||||
-- The seed is deliberately scrambled -- gold_bug1425_test.lua seeds its TMs
|
||||
-- already in numeric order, which is why its screenshots never showed this.
|
||||
--
|
||||
-- The run ends with the PACK still open on the TM pocket, so a human takes the
|
||||
-- controls exactly where the screenshots stop.
|
||||
local U = require("tests.drivers.util")
|
||||
|
||||
local Bag = require("src.inventory.Bag")
|
||||
local PackMenu = require("src.ui.gen2.PackMenu")
|
||||
|
||||
-- Picked up back to front: TM/HM numbers 57, 50, 51, 5, 1, 53. TM_ROAR is the
|
||||
-- one on the far side of the ITEM_C3 hole (id $c4, number 5), so it also pins
|
||||
-- that the number and not the item id is what sorts.
|
||||
local SEED = {
|
||||
{ "HM_WATERFALL", 1 },
|
||||
{ "TM_NIGHTMARE", 2 },
|
||||
{ "HM_CUT", 1 },
|
||||
{ "TM_ROAR", 3 },
|
||||
{ "TM_DYNAMICPUNCH", 12 },
|
||||
{ "HM_SURF", 1 },
|
||||
}
|
||||
|
||||
return function(game)
|
||||
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-bug1567"
|
||||
|
||||
local function shot(name)
|
||||
U.wait(3)
|
||||
U.shot(game, ("%s/%s.png"):format(out, name))
|
||||
end
|
||||
|
||||
U.wait(45)
|
||||
assert(game.world and game.world.map, "gold world did not boot")
|
||||
|
||||
local save = game.save
|
||||
save.inventory = {}
|
||||
save.bagOrder = {}
|
||||
for _, entry in ipairs(SEED) do
|
||||
local id, count = entry[1], entry[2]
|
||||
if not (game.data.items and game.data.items[id]) then
|
||||
U.log("[driver] SKIP", id, "-- not in this cache")
|
||||
else
|
||||
save.inventory[id] = count
|
||||
table.insert(Bag.order(save), id)
|
||||
end
|
||||
end
|
||||
-- A POTION and a SUPER POTION so the ITEM pocket can be compared against the
|
||||
-- TM pocket at the end: SELECT must still arm a row over there.
|
||||
for _, entry in ipairs({ { "POTION", 5 }, { "SUPER_POTION", 2 } }) do
|
||||
if game.data.items and game.data.items[entry[1]] then
|
||||
save.inventory[entry[1]] = entry[2]
|
||||
table.insert(Bag.order(save), entry[1])
|
||||
end
|
||||
end
|
||||
|
||||
U.log("[driver] pickup order:", table.concat(Bag.order(save), " "))
|
||||
|
||||
local pack = PackMenu.new(game, { save = save, world = game.world,
|
||||
onClose = function() end })
|
||||
game.stack:push(pack)
|
||||
|
||||
shot("00-items") -- POTION over SUPER POTION, pickup order
|
||||
U.tap(game, "right")
|
||||
U.tap(game, "right")
|
||||
U.tap(game, "right")
|
||||
-- TM01 DYNAMICPUNCH ×12, TM05 ROAR ×03, TM50 NIGHTMARE ×02, then HM01 CUT,
|
||||
-- HM03 SURF, HM07 WATERFALL with no ×NN at all (engine/items/tmhm.asm:390).
|
||||
-- Before the fix this read HM07, TM50, HM01, TM05, TM01, HM03.
|
||||
shot("01-tmhm-numbered")
|
||||
|
||||
local order = {}
|
||||
for i = 1, #pack.rows do order[i] = pack.rows[i].id end
|
||||
U.log("[driver] TM/HM rows:", table.concat(order, " "))
|
||||
|
||||
-- engine/items/tmhm.asm:207 filters SELECT out of this pocket: no hollow ▷,
|
||||
-- no "Where should this be moved to?".
|
||||
U.tap(game, "select")
|
||||
shot("02-tmhm-select-ignored")
|
||||
U.tap(game, "down")
|
||||
U.tap(game, "select")
|
||||
shot("03-tmhm-select-ignored-row2")
|
||||
|
||||
-- The ITEM pocket, where SELECT still arms (engine/items/pack.asm:1290).
|
||||
U.tap(game, "right")
|
||||
U.tap(game, "select")
|
||||
shot("04-items-select-arms")
|
||||
U.tap(game, "b")
|
||||
|
||||
-- Back to the TM pocket for the human.
|
||||
U.tap(game, "right")
|
||||
U.tap(game, "right")
|
||||
U.tap(game, "right")
|
||||
U.log("[driver] shots in " .. out .. " -- the PACK is yours")
|
||||
end
|
||||
@@ -0,0 +1,197 @@
|
||||
-- #1513: exp credit has to be per THIS appearance of the enemy's active mon.
|
||||
-- ResetBattleParticipants falls through into AddBattleParticipant
|
||||
-- (engine/battle/core.asm:3033 and :3037), and AI_Switch farcalls it right
|
||||
-- after EnemySwitch (engine/battle/ai/items.asm:697), so the moment the rival
|
||||
-- rotates, the only mon still credited is the one the player has on the field.
|
||||
--
|
||||
-- POKEPORT_GAME=gold POKEPORT_TOUCH=0 POKEPORT_IDENTITY=gold-dev \
|
||||
-- POKEPORT_DRIVER=tests/drivers/gold_exp_participants_bug1513.lua love .
|
||||
--
|
||||
-- What to look for, in order:
|
||||
-- 00 CYNDAQUIL is out against JOEY's GEODUDE.
|
||||
-- 01 TOTODILE has been switched in through the real party menu, so BOTH
|
||||
-- party mons have now "met" GEODUDE.
|
||||
-- 02 "JOEY withdrew GEODUDE!" -- the rotation the ticket is about.
|
||||
-- 03 the exp lines for the KO of the mon that came IN (PIDGEY). Only
|
||||
-- TOTODILE may be named here. A "CYNDAQUIL gained N EXP. Points!" line
|
||||
-- in this shot is the bug.
|
||||
-- 04 the party screen: CYNDAQUIL's level and EXP bar are pixel-identical to
|
||||
-- shot 01's, TOTODILE's have moved.
|
||||
-- 05 GEODUDE comes back out and is KO'd. CYNDAQUIL still gains nothing --
|
||||
-- that half is correct cart behavior (ram/wram.asm:775-776, "All bits
|
||||
-- cleared if the enemy faints") and must NOT be "fixed".
|
||||
local U = require("tests.drivers.util")
|
||||
|
||||
local Mon = require("src.battle.gen2.Mon")
|
||||
|
||||
local function battleScreen(game)
|
||||
for _ = 1, 900 do
|
||||
local top = game.stack:top()
|
||||
if top and top.battle then return top end
|
||||
U.wait(1)
|
||||
end
|
||||
error("battle screen never came up")
|
||||
end
|
||||
|
||||
local function runToPhase(game, screen, phase, frames)
|
||||
for _ = 1, (frames or 600) do
|
||||
if screen.phase == phase then return true end
|
||||
if screen.battle.over then return false end
|
||||
U.tap(game, "a")
|
||||
U.wait(3)
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- Page the queue out, shooting the first box that carries `needle`.
|
||||
local function pageUntilMenu(game, screen, out, needles)
|
||||
local shot = {}
|
||||
for _ = 1, 1200 do
|
||||
local message = screen.message
|
||||
if message then
|
||||
for needle, path in pairs(needles) do
|
||||
if not shot[needle] and message:find(needle, 1, true) then
|
||||
U.shot(game, out .. "/" .. path)
|
||||
shot[needle] = message
|
||||
end
|
||||
end
|
||||
end
|
||||
if screen.phase == "menu" or screen.phase == "done"
|
||||
or screen.battle.over then
|
||||
break
|
||||
end
|
||||
U.tap(game, "a")
|
||||
U.wait(3)
|
||||
end
|
||||
return shot
|
||||
end
|
||||
|
||||
return function(game)
|
||||
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-bug1513"
|
||||
U.wait(45)
|
||||
local world = game.world
|
||||
assert(world and world.map, "gold world did not boot")
|
||||
local failures = {}
|
||||
local function check(ok, what)
|
||||
print((ok and "[ok] " or "[FAIL] ") .. what)
|
||||
if not ok then failures[#failures + 1] = what end
|
||||
end
|
||||
|
||||
local lead = Mon.new(game.data, "CYNDAQUIL", 20)
|
||||
lead.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } }
|
||||
local bench = Mon.new(game.data, "TOTODILE", 20)
|
||||
bench.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } }
|
||||
game.save.party = { lead, bench }
|
||||
|
||||
local foe1 = Mon.new(game.data, "GEODUDE", 8)
|
||||
foe1.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } }
|
||||
local foe2 = Mon.new(game.data, "PIDGEY", 8)
|
||||
foe2.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } }
|
||||
-- attributes[6] is the low byte of TRNATTR_AI_ITEM_SWITCH: OFTEN
|
||||
-- (src/battle/gen2/Ai.lua:1307). The real Rival1 class is SWITCH_SOMETIMES
|
||||
-- (pokegold:data/trainers/attributes.asm), the same path at a lower roll.
|
||||
assert(world:startBattle({ trainer = { class = "YOUNGSTER", name = "JOEY",
|
||||
party = { foe1, foe2 }, attributes = { 0, 0, 0, 0, 0, 0x01, 0 } } }),
|
||||
"trainer startBattle failed")
|
||||
local screen = battleScreen(game)
|
||||
assert(runToPhase(game, screen, "menu", 400), "never reached the menu")
|
||||
U.shot(game, out .. "/00-lead-out.png")
|
||||
|
||||
------------------------------------------------- the player's own switch
|
||||
-- Through the real party menu, so the human sees the same path they used.
|
||||
screen:chooseMenu("party")
|
||||
for _ = 1, 240 do
|
||||
if screen.battle.player == bench then break end
|
||||
local top = game.stack:top()
|
||||
if top ~= screen then
|
||||
U.tap(game, "down")
|
||||
U.wait(3)
|
||||
U.tap(game, "a")
|
||||
U.wait(6)
|
||||
U.tap(game, "a")
|
||||
U.wait(6)
|
||||
else
|
||||
U.wait(2)
|
||||
end
|
||||
end
|
||||
check(screen.battle.player == bench, "TOTODILE was switched in")
|
||||
check(screen.battle.participants[1] and screen.battle.participants[2],
|
||||
"both party mons are credited against GEODUDE")
|
||||
pageUntilMenu(game, screen, out, {})
|
||||
U.shot(game, out .. "/01-switched.png")
|
||||
print(("[driver] before the rotation: cyndaquil %d totodile %d")
|
||||
:format(lead.experience, bench.experience))
|
||||
local leadExp = lead.experience
|
||||
local benchExp = bench.experience
|
||||
|
||||
------------------------------------------------------- the AI's rotation
|
||||
-- Perish Song at one turn left is CheckAbleToSwitch's maximum score, the
|
||||
-- cheapest way to make the AI commit to the rotation on demand.
|
||||
screen.battle:volatile(screen.battle.enemy).perish = 1
|
||||
screen:submit({ kind = "move", move = "TACKLE" })
|
||||
local seen = pageUntilMenu(game, screen, out, {
|
||||
["withdrew"] = "02-rival-withdrew.png",
|
||||
["gained"] = "03-exp-lines.png",
|
||||
})
|
||||
check(seen["withdrew"] ~= nil, "JOEY withdrew GEODUDE")
|
||||
check(screen.battle.enemy == foe2, "and sent PIDGEY out")
|
||||
check(screen.battle.participants[1] == nil,
|
||||
"CYNDAQUIL lost its credit when the rival rotated")
|
||||
check(screen.battle.participants[2] == true,
|
||||
"and TOTODILE, the mon on the field, kept it")
|
||||
|
||||
------------------------------------------------------------ the KO payout
|
||||
if not screen.battle.over and (foe2.hp or 0) > 0 then
|
||||
if screen.phase ~= "menu" then
|
||||
runToPhase(game, screen, "menu", 400)
|
||||
end
|
||||
foe2.hp = 1
|
||||
screen:submit({ kind = "move", move = "TACKLE" })
|
||||
local paid = pageUntilMenu(game, screen, out, {
|
||||
["gained"] = "03-exp-lines.png",
|
||||
})
|
||||
if paid["gained"] then print("[driver] exp box: " .. paid["gained"]) end
|
||||
end
|
||||
print(("[driver] after the PIDGEY KO: cyndaquil %d totodile %d")
|
||||
:format(lead.experience, bench.experience))
|
||||
check(lead.experience == leadExp,
|
||||
"CYNDAQUIL earned NOTHING from the mon it never faced")
|
||||
check(bench.experience > benchExp, "TOTODILE was paid for it")
|
||||
|
||||
if screen.phase == "menu" then
|
||||
screen:chooseMenu("party")
|
||||
U.wait(20)
|
||||
U.shot(game, out .. "/04-party-exp.png")
|
||||
U.tap(game, "b")
|
||||
U.wait(10)
|
||||
if game.stack:top() ~= screen then
|
||||
U.tap(game, "b")
|
||||
U.wait(10)
|
||||
end
|
||||
end
|
||||
|
||||
------------------------------------------- GEODUDE comes back and faints
|
||||
-- Correct cart behavior: no residual credit for a previous appearance.
|
||||
leadExp = lead.experience
|
||||
benchExp = bench.experience
|
||||
if not screen.battle.over then
|
||||
runToPhase(game, screen, "menu", 600)
|
||||
if screen.phase == "menu" and screen.battle.enemy == foe1 then
|
||||
foe1.hp = 1
|
||||
screen:submit({ kind = "move", move = "TACKLE" })
|
||||
pageUntilMenu(game, screen, out, { ["gained"] = "05-geodude-ko.png" })
|
||||
print(("[driver] after the GEODUDE KO: cyndaquil %d totodile %d")
|
||||
:format(lead.experience, bench.experience))
|
||||
check(lead.experience == leadExp,
|
||||
"CYNDAQUIL still earns nothing when GEODUDE comes back (correct)")
|
||||
check(bench.experience > benchExp, "and TOTODILE takes the whole share")
|
||||
else
|
||||
print("[driver] GEODUDE never came back out; second half skipped")
|
||||
end
|
||||
end
|
||||
|
||||
if #failures > 0 then
|
||||
error(#failures .. " exp participant checks failed")
|
||||
end
|
||||
print("[driver] #1513 fixed; shots in " .. out)
|
||||
end
|
||||
@@ -221,6 +221,13 @@ return function(game)
|
||||
stored[i] = mon(species, 10 + i)
|
||||
end
|
||||
Boxes.rename(save, 2, "GRASS")
|
||||
-- $5d for a held item, $5c for MAIL (engine/pokemon/bills_pc.asm:1079-1094).
|
||||
-- A boxed mon can never hold mail, so the letter goes on a party mon.
|
||||
stored[1].item = "BERRY"
|
||||
save.party[2].item = "FLOWER_MAIL"
|
||||
local Mail = require("src.core.gen2.Mail")
|
||||
Mail.set(save, 2, Mail.entry("FLOWER_MAIL", "HI THERE!",
|
||||
save.player.name or "GOLD", save.player.id or 0, save.party[2].species))
|
||||
|
||||
local pc = PcMenu.new(game, { save = save })
|
||||
show("20-pc-menu", pc)
|
||||
@@ -228,12 +235,27 @@ return function(game)
|
||||
pc.pickIndex = 2
|
||||
show("21-pc-changebox", pc)
|
||||
|
||||
show("22-pc-withdraw", BoxMenu.new(game, {
|
||||
save = save, mode = "withdraw",
|
||||
}))
|
||||
show("23-pc-deposit", BoxMenu.new(game, {
|
||||
save = save, mode = "deposit",
|
||||
}))
|
||||
-- Browsing paints the 7x7 block in BillsPCOrangePalette and only .PrepSubmenu
|
||||
-- swaps the mon's colours in (engine/pokemon/bills_pc.asm:305-309, :356-369,
|
||||
-- engine/gfx/cgb_layouts.asm:284-289); the icon is BG palette 0 either way.
|
||||
local wd = BoxMenu.new(game, { save = save, mode = "withdraw" })
|
||||
show("22-pc-withdraw", wd)
|
||||
wd.index = wd:total() -- CANCEL
|
||||
wd:ensureVisible()
|
||||
show("22b-pc-withdraw-cancel", wd)
|
||||
wd.index = 1
|
||||
wd:ensureVisible()
|
||||
wd.phase = "submenu"
|
||||
show("22c-pc-withdraw-submenu", wd)
|
||||
local dep = BoxMenu.new(game, { save = save, mode = "deposit" })
|
||||
show("23-pc-deposit", dep)
|
||||
dep.index = 2 -- the mon holding FLOWER MAIL
|
||||
show("23b-pc-deposit-mail", dep)
|
||||
|
||||
-- Only the move list gets the box-name arrows, $5f left and $5e right
|
||||
-- (engine/pokemon/bills_pc.asm:957-963); 22 and 23 above are the controls.
|
||||
local mv = BoxMenu.new(game, { save = save, mode = "move" })
|
||||
show("23c-pc-move-arrows", mv)
|
||||
|
||||
-- The two clock screens NEW GAME and Mom open (timeset.asm InitClock and
|
||||
-- SetDayOfWeek), each at its picker rather than at its opening page.
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
-- Screen-rect check for a touch skin, both generations.
|
||||
--
|
||||
-- POKEPORT_DRIVER=tests/drivers/skin_screen_shot.lua love .
|
||||
-- POKEPORT_GAME=gold POKEPORT_SKIN=my_skin POKEPORT_DRIVER=... love .
|
||||
--
|
||||
-- Gold's cache lives under the DEFAULT save identity, so a POKEPORT_IDENTITY
|
||||
-- sandbox has to be seeded with it first or the boot hangs in the importer:
|
||||
-- cp -R ~/Library/Application\ Support/LOVE/pokemon-love2d/{gold,skins} \
|
||||
-- ~/Library/Application\ Support/LOVE/<identity>/
|
||||
local U = require("tests.drivers.util")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
|
||||
local function env(name, fallback)
|
||||
local v = os.getenv(name)
|
||||
if v == nil or v == "" then return fallback end
|
||||
return v
|
||||
end
|
||||
|
||||
local function tap(game, button)
|
||||
game.input.pressQueue[#game.input.pressQueue + 1] = button
|
||||
game.input.state[button] = true
|
||||
U.wait(2)
|
||||
game.input.state[button] = false
|
||||
end
|
||||
|
||||
return function(game)
|
||||
local TouchControls = require("src.core.TouchControls")
|
||||
local out = env("POKEPORT_SHOT_DIR", ".")
|
||||
local gen2 = GameVersion.generation() == 2
|
||||
|
||||
love.window.setMode(tonumber(env("POKEPORT_SHOT_W", 460)),
|
||||
tonumber(env("POKEPORT_SHOT_H", 950)))
|
||||
local skin = env("POKEPORT_SKIN", "gb_anim")
|
||||
local ok, err = TouchControls:selectSkin(skin)
|
||||
assert(ok, "skin " .. skin .. ": " .. tostring(err))
|
||||
|
||||
U.wait(60)
|
||||
if gen2 then
|
||||
local A = require("tests.drivers.gold.adapter")
|
||||
assert(game.world and game.world.map, "gold world did not boot")
|
||||
A.teleport(game, env("POKEPORT_SHOT_MAP", "NEW_BARK_TOWN"), 6, 6)
|
||||
U.wait(120)
|
||||
else
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
game.save.party = { Pokemon.new(game.data, "CHARIZARD", 50) }
|
||||
U.teleport(game, env("POKEPORT_SHOT_MAP", "PALLET_TOWN"), 10, 8, "down")
|
||||
U.wait(40)
|
||||
end
|
||||
U.shot(game, out .. "/skin_world.png")
|
||||
|
||||
tap(game, "start")
|
||||
U.wait(60)
|
||||
U.shot(game, out .. "/skin_menu.png")
|
||||
|
||||
love.event.quit()
|
||||
while true do coroutine.yield() end
|
||||
end
|
||||
@@ -58,6 +58,19 @@ return function(game)
|
||||
U.log("selected:", ctl and ctl.spec, "x", ctl and ctl.x, "y", ctl and ctl.y)
|
||||
shot("studio_selected.png")
|
||||
|
||||
Studio.zoomOut()
|
||||
U.wait(2)
|
||||
shot("studio_zoom_out.png")
|
||||
Studio.zoomFit()
|
||||
|
||||
Studio.openScreenMenu()
|
||||
U.wait(2)
|
||||
shot("studio_screen.png")
|
||||
Studio.closeModal()
|
||||
Studio.detectDeviceCanvas()
|
||||
U.wait(2)
|
||||
shot("studio_this_screen.png")
|
||||
|
||||
-- drag the selected control and confirm the model moved
|
||||
local r = Studio.lastCanvas
|
||||
if r and ctl then
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
-- Driver: #1517, a Gen 2 stat block in a Gen 1 party.
|
||||
--
|
||||
-- Gen 1's party_struct carries one Special word (macros/ram.asm:28-37) and
|
||||
-- PrintStatsBox reads four fixed cells, wLoadedMonAttack/Defense/Speed/Special
|
||||
-- (engine/pokemon/status_screen.asm:238-287). Gen 2 splits that word into
|
||||
-- SpclAtk/SpclDef (pokegold macros/ram.asm:29-42), so a mod that wrote a Gen 2
|
||||
-- block over a Yellow party leaves the port with no `special` to print.
|
||||
--
|
||||
-- cp -R ~/Library/Application\ Support/LOVE/pokemon-love2d/yellow \
|
||||
-- ~/Library/Application\ Support/LOVE/bug1517/yellow
|
||||
-- POKEPORT_VERSION=yellow POKEPORT_GAME=yellow POKEPORT_IDENTITY=bug1517 \
|
||||
-- POKEPORT_TOUCH=0 POKEPORT_SPEED=8 SHOT_DIR=/tmp/bug1517 \
|
||||
-- POKEPORT_DRIVER=tests/drivers/summary_stats_bug1517_test.lua love .
|
||||
--
|
||||
-- The copy is not optional: a fresh POKEPORT_IDENTITY has no cache and the
|
||||
-- boot hangs with no output.
|
||||
--
|
||||
-- Pre-fix the LOVE window vanishes the moment STATS is chosen, with no error
|
||||
-- screen and nothing on stdout (that silence is the other half of #1517).
|
||||
-- Post-fix the status screen opens and SPECIAL reads the CalcStats value
|
||||
-- logged below. The run ends on the open screen, so a human takes the
|
||||
-- controls at exactly the frame the reporter's build died on.
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/bug1517"
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local Stats = require("src.pokemon.Stats")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local SummaryMenu = require("src.ui.SummaryMenu")
|
||||
|
||||
local function top() return game.stack:top() end
|
||||
|
||||
U.newGame(game)
|
||||
U.wait(20)
|
||||
|
||||
-- BUTTERFREE because the reporter's party led with one, and CONFUSION is a
|
||||
-- special move, so the same block would have crashed Damage.applyStage.
|
||||
local species = game.data.pokemon.BUTTERFREE and "BUTTERFREE" or "PIDGEY"
|
||||
local def = game.data.pokemon[species]
|
||||
local mon = Pokemon.new(game.data, species, 21)
|
||||
local want = Stats.calc(def, 21, mon.dvs, mon.statExp)
|
||||
|
||||
-- the shape src/battle/gen2/Mon.lua Mon.stats produces: no `special`
|
||||
mon.stats = {
|
||||
hp = want.hp, attack = want.attack, defense = want.defense,
|
||||
speed = want.speed, specialAttack = want.special, specialDefense = want.special,
|
||||
}
|
||||
mon.hp = mon.stats.hp
|
||||
game.save.party = { mon }
|
||||
U.log(("[1517] fixture: %s :L21, stats.special = %s, CalcStats says %d")
|
||||
:format(species, tostring(mon.stats.special), want.special))
|
||||
|
||||
SaveData.validate(game.save, game.data)
|
||||
local after = game.save.party[1].stats
|
||||
U.log("[1517] after SaveData.validate, special = " .. tostring(after.special)
|
||||
.. ", specialAttack = " .. tostring(after.specialAttack))
|
||||
U.log("[1517] the repair layer is SaveData.validate -> scrubKnownMon -> "
|
||||
.. "Stats.ensure; a nil above means it did not run")
|
||||
|
||||
U.teleport(game, "PALLET_TOWN", 10, 8, "down")
|
||||
U.wait(10)
|
||||
|
||||
local function cursorTo(menu, field, wanted)
|
||||
for _ = 1, 40 do
|
||||
if not menu or menu[field] == wanted then return menu and menu[field] == wanted end
|
||||
U.tap(game, menu[field] < wanted and "down" or "up")
|
||||
U.wait(3)
|
||||
end
|
||||
return menu[field] == wanted
|
||||
end
|
||||
|
||||
U.tap(game, "start")
|
||||
U.wait(10)
|
||||
local menu = top()
|
||||
if not (menu and menu.screenId == "StartMenu") then
|
||||
U.log("[1517] FAIL no start menu; top = " .. tostring(menu and menu.screenId))
|
||||
while true do coroutine.yield() end
|
||||
end
|
||||
local row
|
||||
for i, it in ipairs(menu.items or {}) do
|
||||
if it.label == "POKéMON" then row = i break end
|
||||
end
|
||||
if not (row and cursorTo(menu, "index", row)) then
|
||||
U.log("[1517] FAIL could not reach the POKéMON row")
|
||||
while true do coroutine.yield() end
|
||||
end
|
||||
U.tap(game, "a")
|
||||
U.wait(10)
|
||||
|
||||
local party = top()
|
||||
if not (party and party.screenId == "PartyMenu") then
|
||||
U.log("[1517] FAIL no party menu; top = " .. tostring(party and party.screenId))
|
||||
while true do coroutine.yield() end
|
||||
end
|
||||
U.shot(game, DIR .. "/01-party-list.png")
|
||||
U.tap(game, "a")
|
||||
U.wait(8)
|
||||
local subRow
|
||||
for i, it in ipairs(party.subItems or {}) do
|
||||
if it.action == "stats" then subRow = i break end
|
||||
end
|
||||
if not (subRow and cursorTo(party, "subIndex", subRow)) then
|
||||
U.log("[1517] FAIL could not reach the STATS row")
|
||||
while true do coroutine.yield() end
|
||||
end
|
||||
U.shot(game, DIR .. "/02-stats-selected.png")
|
||||
|
||||
U.log("[1517] opening STATS -- pre-fix the app dies here, silently")
|
||||
U.tap(game, "a")
|
||||
U.wait(30)
|
||||
|
||||
local screen = top()
|
||||
local opened = getmetatable(screen) == SummaryMenu or screen.screenId == "SummaryMenu"
|
||||
U.log("[1517] SURVIVED, top = " .. tostring(screen and screen.screenId))
|
||||
U.shot(game, DIR .. "/03-status-screen.png")
|
||||
U.log(("[1517] %s: SPECIAL on screen must read %d")
|
||||
:format(opened and "read it off the frame" or "WRONG SCREEN", want.special))
|
||||
U.log("[1517] shots in " .. DIR .. " -- the status screen is yours")
|
||||
while true do coroutine.yield() end
|
||||
end
|
||||
@@ -0,0 +1,80 @@
|
||||
-- data/maps/force_bike_surf.asm:5 / engine/overworld/player_state.asm:34-72,
|
||||
-- home/overworld.asm:690-703 (the map-change fade has no fade back in).
|
||||
-- POKEPORT_DRIVER=tests/drivers/warp_midpoint_box_bug1663_test.lua \
|
||||
-- POKEPORT_IDENTITY=bug1663 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
|
||||
-- Ride into the Route 16 gate, drop the BICYCLE inside, then walk back out
|
||||
-- the west door. The warp lands on a forced-bike tile, so the refusal box
|
||||
-- opens from inside the fade's midpoint: it must be on screen when the fade
|
||||
-- ends (#1663 ate it), and dismissing it must leave the player on Route 16's
|
||||
-- (17,10), not shoved into the gate wall at (18,10) (#1548).
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
|
||||
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
local ok = true
|
||||
local function check(label, pass)
|
||||
U.log(pass and "PASS" or "FAIL", label)
|
||||
if not pass then ok = false end
|
||||
return pass
|
||||
end
|
||||
|
||||
local function where()
|
||||
local ow = game.overworld
|
||||
return ow.map.id, ow.player.cellX, ow.player.cellY
|
||||
end
|
||||
local function bikeless()
|
||||
game.save.inventory.BICYCLE = nil
|
||||
game.save.onBike, game.save.forcedBike = false, nil
|
||||
end
|
||||
|
||||
game.save.player.name = "PROBE"
|
||||
game.save.party = { Pokemon.new(game.data, "CHARIZARD", 50) }
|
||||
game.save.inventory.BICYCLE = 1
|
||||
game.save.onBike, game.save.forcedBike = false, nil
|
||||
|
||||
-- (16,10) is the open cell west of the gate door; (17,10) is the forced
|
||||
-- tile the door sits on, so the step onto it mounts and then warps
|
||||
U.teleport(game, "ROUTE_16", 16, 10, "right")
|
||||
U.wait(20)
|
||||
U.hold(game, "right", 40)
|
||||
U.wait(40)
|
||||
local map, x, y = where()
|
||||
U.log(("rode east into the gate -> %s (%d,%d)"):format(map, x, y))
|
||||
check("the bike carried the player into the gate",
|
||||
map == "ROUTE_16_GATE_1F")
|
||||
|
||||
bikeless()
|
||||
U.hold(game, "left", 40)
|
||||
-- the map-change fade is 32 frames and hands back with no fade in
|
||||
U.wait(50)
|
||||
|
||||
map, x, y = where()
|
||||
local top = game.stack:top()
|
||||
U.log(("walked back out -> %s (%d,%d), top=%s"):format(map, x, y,
|
||||
tostring(getmetatable(top) == TextBox and "TextBox" or top)))
|
||||
check("the west door lands back on Route 16", map == "ROUTE_16")
|
||||
check("on the forced tile the gate exit warps to", x == 17 and y == 10)
|
||||
check("the refusal box the warp opened is on screen",
|
||||
getmetatable(top) == TextBox)
|
||||
U.shot(game, SHOT_DIR .. "/bug1663_refusal.png")
|
||||
|
||||
-- close it: the shove back east is refused by collision, (18,10) is wall
|
||||
for _ = 1, 12 do
|
||||
if game.stack:top() == game.overworld then break end
|
||||
U.tap(game, "a")
|
||||
U.wait(25)
|
||||
end
|
||||
U.wait(30)
|
||||
map, x, y = where()
|
||||
local walkable = game.overworld.map:isWalkableCell(x, y)
|
||||
U.log(("after the refusal -> %s (%d,%d) walkable=%s")
|
||||
:format(map, x, y, tostring(walkable)))
|
||||
check("dismissing it leaves the player on a walkable cell", walkable)
|
||||
check("and not inside the gate wall at (18,10)", not (x == 18 and y == 10))
|
||||
U.shot(game, SHOT_DIR .. "/bug1663_after.png")
|
||||
|
||||
U.log(ok and "all clear" or "a check failed")
|
||||
while true do coroutine.yield() end
|
||||
end
|
||||
@@ -0,0 +1,197 @@
|
||||
-- Five Gen 2 battle messages printed something the cart does not say. Each
|
||||
-- case below drives a real turn and compares the emitted line against the
|
||||
-- label in pokegold's data/text/battle.asm:
|
||||
--
|
||||
-- SuperEffectiveText :603
|
||||
-- NotVeryEffectiveText :608
|
||||
-- BattleText_TheresNoPPLeftForThisMove :315
|
||||
-- PlayerHitTimesText / EnemyHitTimesText:749, :755
|
||||
-- StartPerishText :986
|
||||
--
|
||||
-- The marker is the one src/import/RomExtractorGen2.lua decodes the cart's own
|
||||
-- $4e (`line`) into, so a line written here reads exactly as an extracted one
|
||||
-- would. The tail case pins the other half of that: printMessage draws at
|
||||
-- most TEXT_ROWS rows and cuts the rest, so a line has to fit two of them.
|
||||
--
|
||||
-- luajit tests/engine/gen2_battle_text_matches_cart.lua
|
||||
--
|
||||
-- ROM-free: the fixtures below are the extractor's shapes.
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
love = require("tests.love_stub")
|
||||
|
||||
local T = require("tests.harness")
|
||||
local Battle = require("src.battle.gen2.Battle")
|
||||
local Mon = require("src.battle.gen2.Mon")
|
||||
|
||||
local check = T.check
|
||||
|
||||
-- ---------------------------------------------------------------- fixtures
|
||||
|
||||
local TYPES = {
|
||||
NORMAL = { id = "NORMAL", index = 0, category = "physical" },
|
||||
GROUND = { id = "GROUND", index = 4, category = "physical" },
|
||||
ROCK = { id = "ROCK", index = 5, category = "physical" },
|
||||
FIRE = { id = "FIRE", index = 20, category = "special" },
|
||||
WATER = { id = "WATER", index = 21, category = "special" },
|
||||
}
|
||||
|
||||
local MATCHUPS = {
|
||||
{ attacker = "NORMAL", defender = "ROCK", multiplier = 5 },
|
||||
{ attacker = "WATER", defender = "FIRE", multiplier = 20 },
|
||||
}
|
||||
|
||||
local MOVES = {
|
||||
TACKLE = { id = "TACKLE", name = "TACKLE", power = 35, type = "NORMAL",
|
||||
accuracy = 100, pp = 35, effect = "EFFECT_NORMAL_HIT" },
|
||||
WATER_GUN = { id = "WATER_GUN", name = "WATER GUN", power = 40,
|
||||
type = "WATER", accuracy = 100, pp = 25, effect = "EFFECT_NORMAL_HIT" },
|
||||
DOUBLESLAP = { id = "DOUBLESLAP", name = "DOUBLESLAP", power = 15,
|
||||
type = "NORMAL", accuracy = 100, pp = 10, effect = "EFFECT_MULTI_HIT" },
|
||||
PERISH_SONG = { id = "PERISH_SONG", name = "PERISH SONG", power = 0,
|
||||
type = "NORMAL", accuracy = 100, pp = 5, effect = "EFFECT_PERISH_SONG" },
|
||||
}
|
||||
|
||||
local GROWTH = {
|
||||
GROWTH_MEDIUM_SLOW = { numerator = 6, denominator = 5, squared = -15,
|
||||
linear = 100, constant = 140 },
|
||||
}
|
||||
|
||||
local function species(id, index, types)
|
||||
return { id = id, index = index, name = id,
|
||||
baseStats = { hp = 50, attack = 60, defense = 50, speed = 50,
|
||||
specialAttack = 50, specialDefense = 50 },
|
||||
types = types, catchRate = 255, baseExp = 60,
|
||||
growthRate = "GROWTH_MEDIUM_SLOW", genderRatio = 31,
|
||||
levelMoves = { { level = 1, move = "TACKLE" } }, evolutions = {} }
|
||||
end
|
||||
|
||||
local POKEMON = {
|
||||
growthRates = GROWTH,
|
||||
CYNDAQUIL = species("CYNDAQUIL", 155, { "FIRE", "FIRE" }),
|
||||
GEODUDE = species("GEODUDE", 74, { "ROCK", "GROUND" }),
|
||||
}
|
||||
|
||||
local DATA = { pokemon = POKEMON, moves = MOVES,
|
||||
type_chart = { types = TYPES, matchups = MATCHUPS }, items = {} }
|
||||
|
||||
local perfect = { attack = 15, defense = 15, speed = 15, special = 15 }
|
||||
perfect.hp = Mon.hpDV(perfect)
|
||||
|
||||
-- The smallest roll that is neither a critical hit nor a miss.
|
||||
local function detRandom(n)
|
||||
if (n or 1) <= 1 then return 0 end
|
||||
return 1
|
||||
end
|
||||
|
||||
-- One turn of the player's move, returning every message line it printed.
|
||||
local function linesFrom(playerSpecies, playerMoves, wildSpecies, moveId)
|
||||
local player = Mon.new(DATA, playerSpecies, 20, { dvs = perfect })
|
||||
player.moves = playerMoves
|
||||
local wild = Mon.new(DATA, wildSpecies, 20, { dvs = perfect })
|
||||
wild.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } }
|
||||
local battle = Battle.new({ data = DATA, party = { player }, wild = wild,
|
||||
random = detRandom })
|
||||
local said = {}
|
||||
for _, event in ipairs(battle:takeTurn({ kind = "move", move = moveId })) do
|
||||
if event.kind == "message" and event.text then
|
||||
said[#said + 1] = event.text
|
||||
end
|
||||
end
|
||||
return said
|
||||
end
|
||||
|
||||
local function saw(said, text)
|
||||
for _, line in ipairs(said) do
|
||||
if line == text then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function shown(said)
|
||||
return "printed: " .. table.concat(said, " | ")
|
||||
end
|
||||
|
||||
-- ---- the two effectiveness lines ------------------------------------------
|
||||
-- Both break across the box's two lines on the cart, and "super-" is
|
||||
-- hyphenated to make the break. The not-very line ends on the single
|
||||
-- ellipsis glyph the Gold charmap carries at $75, not on three periods.
|
||||
do
|
||||
local said = linesFrom("CYNDAQUIL",
|
||||
{ { id = "TACKLE", pp = 35, maxPp = 35 } }, "GEODUDE", "TACKLE")
|
||||
check(saw(said, "It's not very\neffective…"),
|
||||
"NotVeryEffectiveText prints as the cart writes it. " .. shown(said))
|
||||
|
||||
said = linesFrom("GEODUDE",
|
||||
{ { id = "WATER_GUN", pp = 25, maxPp = 25 } }, "CYNDAQUIL", "WATER_GUN")
|
||||
check(saw(said, "It's super-\neffective!"),
|
||||
"SuperEffectiveText keeps its hyphen and its break. " .. shown(said))
|
||||
end
|
||||
|
||||
-- ---- a move at zero PP ----------------------------------------------------
|
||||
do
|
||||
-- a second move with PP left, so the turn refuses the empty one rather
|
||||
-- than falling through to Struggle
|
||||
local said = linesFrom("CYNDAQUIL",
|
||||
{ { id = "TACKLE", pp = 0, maxPp = 35 },
|
||||
{ id = "WATER_GUN", pp = 25, maxPp = 25 } }, "GEODUDE", "TACKLE")
|
||||
check(saw(said, "There's no PP left\nfor this move!"),
|
||||
"the empty-PP refusal is the cart's sentence. " .. shown(said))
|
||||
end
|
||||
|
||||
-- ---- the multi-hit tally --------------------------------------------------
|
||||
-- "Hit @ times!" has no singular form on the cart, which is why the "(s)"
|
||||
-- this used to print is not a hedge the game ever makes. Gen 1 says it the
|
||||
-- same way (src/battle/EffectRegistry.lua, _HitXTimesText).
|
||||
do
|
||||
local said = linesFrom("CYNDAQUIL",
|
||||
{ { id = "DOUBLESLAP", pp = 10, maxPp = 10 } }, "GEODUDE", "DOUBLESLAP")
|
||||
local tally
|
||||
for _, line in ipairs(said) do
|
||||
if line:match("^Hit %d+ times!$") then tally = line end
|
||||
end
|
||||
check(tally ~= nil,
|
||||
"the multi-hit tally reads \"Hit N times!\". " .. shown(said))
|
||||
check(not table.concat(said, " "):find("time(s)", 1, true),
|
||||
"and no line hedges the plural with a parenthetical. " .. shown(said))
|
||||
end
|
||||
|
||||
-- ---- Perish Song ----------------------------------------------------------
|
||||
-- What shipped here was a sentence no cart prints. StartPerishText names
|
||||
-- both sides and counts in digits.
|
||||
do
|
||||
local said = linesFrom("CYNDAQUIL",
|
||||
{ { id = "PERISH_SONG", pp = 5, maxPp = 5 } }, "GEODUDE", "PERISH_SONG")
|
||||
check(saw(said, "Both POKéMON will\nfaint in 3 turns!"),
|
||||
"Perish Song prints StartPerishText. " .. shown(said))
|
||||
end
|
||||
|
||||
-- ---- and every one of them fits the box -----------------------------------
|
||||
-- printMessage draws Chrome.wrap(self.message, TEXT_WIDTH) and stops at
|
||||
-- TEXT_ROWS (home/text.asm:143, :397), so a third row is cut rather than
|
||||
-- spilled. A cart line that needs one, such as SpikesText's `cont` row
|
||||
-- carrying <TARGET>, cannot be told on this path at all: engine messages set
|
||||
-- self.message directly (src/ui/gen2/BattleState.lua) instead of going
|
||||
-- through showPages, so nothing paginates them. Hence Spikes is left alone
|
||||
-- above, and every line that IS changed is checked to fit here.
|
||||
do
|
||||
local Chrome = require("src.ui.gen2.Chrome")
|
||||
-- TEXT_WIDTH and TEXT_ROWS are file-locals in BattleState, so their values
|
||||
-- are repeated rather than required.
|
||||
local BOX_WIDTH, BOX_ROWS = 18, 2
|
||||
for _, line in ipairs({
|
||||
"It's super-\neffective!",
|
||||
"It's not very\neffective…",
|
||||
"There's no PP left\nfor this move!",
|
||||
"Hit 3 times!",
|
||||
"Both POKéMON will\nfaint in 3 turns!",
|
||||
}) do
|
||||
local rows = #Chrome.wrap(line, BOX_WIDTH)
|
||||
check(rows <= BOX_ROWS,
|
||||
("\"%s\" wraps to %d rows, and the box draws %d")
|
||||
:format((line:gsub("\n", "\\n")), rows, BOX_ROWS))
|
||||
end
|
||||
end
|
||||
|
||||
T.finish("gen2 battle text matches the cart")
|
||||
@@ -316,6 +316,8 @@ check(view:find('"tab-sync"', 1, true) ~= nil,
|
||||
local header = view:match("local HEADER_TABS = %{(.-)%}\n")
|
||||
check(header and header:find('id = "skins"', 1, true) ~= nil,
|
||||
"and it sits beside the skins tab")
|
||||
check(header and header:find("beta = true", 1, true) ~= nil,
|
||||
"the skins tab carries a BETA badge too")
|
||||
check(view:find('"BETA"', 1, true) ~= nil,
|
||||
"the button and the modal are labelled BETA")
|
||||
check(view:find("buildSyncModal", 1, true) ~= nil,
|
||||
|
||||
@@ -23,6 +23,7 @@ local function session()
|
||||
Studio.pageIndex = 1
|
||||
Studio.selected = nil
|
||||
Studio.canvasIndex = 1
|
||||
Studio.viewZoom = 1
|
||||
Studio.aspectLock = true
|
||||
Studio.drag = nil
|
||||
Studio.dirty = false
|
||||
@@ -73,11 +74,16 @@ eq(Studio.selected, nil, "and clears the selection")
|
||||
|
||||
local ids = {}
|
||||
for _, c in ipairs(Studio.CANVASES) do
|
||||
check(c.w > 0 and c.h > 0, c.id .. " has real pixel dimensions")
|
||||
if c.live then
|
||||
check(c.id == "this_device", "the live preset is this screen")
|
||||
else
|
||||
check(c.w > 0 and c.h > 0, c.id .. " has real pixel dimensions")
|
||||
end
|
||||
check(not ids[c.id], c.id .. " appears once")
|
||||
ids[c.id] = true
|
||||
end
|
||||
check(ids.phone_portrait, "a phone portrait preset exists")
|
||||
check(ids.this_device, "a this-screen live preset exists")
|
||||
check(ids.desktop_1080, "a desktop preset exists")
|
||||
check(ids.ultrawide, "an ultrawide preset exists")
|
||||
check(ids.sgb_border, "a Super Game Boy border preset exists")
|
||||
@@ -105,9 +111,27 @@ check(Studio.page().viewport ~= nil, "the SGB cutout cannot be toggled off")
|
||||
Studio.setCanvas(#Studio.CANVASES + 1)
|
||||
eq(Studio.canvasIndex, 1, "canvas selection wraps")
|
||||
|
||||
-- ------------------------------------------------------------ canvas fit
|
||||
-- the preview must lay the page out the way the game will, so a preset never
|
||||
-- overwrites the aspect the bezel art (or a cfg) already fixed
|
||||
session()
|
||||
local art = Studio.page()
|
||||
art.aspect, art.aspectFromImage = 0.5625, true
|
||||
Studio.setCanvas(1)
|
||||
near(art.aspect, 0.5625, 1e-9, "bezel art keeps its own aspect on a preset switch")
|
||||
local deckW, deckH = 900, 1700
|
||||
local _, dby, _, dbh = TouchSkin.pageBox(art, deckW, deckH)
|
||||
check(dby > 0 and math.abs(dby + dbh - deckH) < 1e-6,
|
||||
"so a preset taller than the art pins the deck low, as gameplay does")
|
||||
|
||||
session()
|
||||
local plain = Studio.page()
|
||||
plain.aspect, plain.aspectFromImage, plain.aspectFromCfg = nil, nil, nil
|
||||
Studio.setCanvas(1)
|
||||
near(plain.aspect, Studio.canvas().w / Studio.canvas().h, 1e-9,
|
||||
"a page with no art of its own still takes the preset's aspect")
|
||||
|
||||
Studio.setCanvas(1)
|
||||
Studio.viewZoom = 1
|
||||
local cx, cy, cw, ch = Studio.canvasRect(0, 0, 800, 600)
|
||||
near(cw / ch, Studio.canvas().w / Studio.canvas().h, 1e-6,
|
||||
"the mock device keeps its aspect")
|
||||
@@ -115,6 +139,35 @@ check(cw <= 800 + 1e-6 and ch <= 600 + 1e-6, "and fits inside the workspace")
|
||||
near(cx + cw / 2, 400, 1e-6, "centred horizontally")
|
||||
near(cy + ch / 2, 300, 1e-6, "centred vertically")
|
||||
|
||||
eq(Studio.zoomOut(), 0.75, "zoom out steps to 75%")
|
||||
local zx, zy, zw, zh = Studio.canvasRect(0, 0, 800, 600)
|
||||
near(zw, cw * 0.75, 1e-6, "zoom out shrinks the mock device")
|
||||
near(zh, ch * 0.75, 1e-6, "on both axes")
|
||||
near(zx + zw / 2, 400, 1e-6, "and stays centred")
|
||||
near(zw / zh, cw / ch, 1e-6, "keeping the same aspect")
|
||||
check(zw < cw, "so there is margin around the device for a larger screen hole")
|
||||
eq(Studio.zoomIn(), 1, "zoom in restores fit")
|
||||
eq(Studio.zoomOut(), 0.75)
|
||||
eq(Studio.zoomOut(), 0.5)
|
||||
eq(Studio.zoomOut(), 0.35)
|
||||
eq(Studio.zoomOut(), 0.35, "zoom out stops at the smallest level")
|
||||
eq(Studio.zoomFit(), 1, "fit snaps back to contain")
|
||||
|
||||
local oldDims = love.graphics.getDimensions
|
||||
love.graphics.getDimensions = function() return 1170, 2532 end
|
||||
session()
|
||||
local live = Studio.detectDeviceCanvas()
|
||||
eq(live.id, "this_device", "detect this screen selects the live canvas")
|
||||
eq(live.w, 1170, "at the window width")
|
||||
eq(live.h, 2532, "and the window height")
|
||||
eq(Studio.canvas().w / Studio.canvas().h, 1170 / 2532,
|
||||
"so the mock device is this screen's form factor")
|
||||
Studio.matchOrient = true
|
||||
Studio.syncCanvasToPage()
|
||||
eq(Studio.canvas().id, "this_device",
|
||||
"Match canvas leaves the live screen selected")
|
||||
love.graphics.getDimensions = oldDims
|
||||
|
||||
-- ----------------------------------------------------------- touch bridge
|
||||
|
||||
session()
|
||||
|
||||
@@ -18,6 +18,7 @@ local function session()
|
||||
Studio.pageIndex = 1
|
||||
Studio.selected = nil
|
||||
Studio.canvasIndex = 1
|
||||
Studio.viewZoom = 1
|
||||
Studio.aspectLock = true
|
||||
Studio.drag = nil
|
||||
Studio.dirty = false
|
||||
@@ -325,11 +326,52 @@ love.graphics.getDimensions = love.graphics.getDimensions
|
||||
or function() return 1280, 720 end
|
||||
session()
|
||||
Studio.addControl()
|
||||
for _, kind in ipairs({ "bind", "image", "open", "page", "export" }) do
|
||||
for _, kind in ipairs({ "bind", "image", "open", "page", "export", "screen" }) do
|
||||
Studio.openModal(kind)
|
||||
check(pcall(Studio.draw), "the studio draws with the " .. kind .. " modal up")
|
||||
end
|
||||
Studio.closeModal()
|
||||
check(Studio.openScreenMenu(), "Screen opens the canvas/screen modal")
|
||||
eq(Studio.modal.kind, "screen", "as the screen modal")
|
||||
Studio.closeModal()
|
||||
|
||||
-- chrome stays inside the platform safe area (notch / home indicator)
|
||||
local Kit = require("src.ui.kit.Kit")
|
||||
local oldDims = love.graphics.getDimensions
|
||||
local oldSafe = love.window.getSafeArea
|
||||
love.graphics.getDimensions = function() return 400, 800 end
|
||||
love.window.getSafeArea = function() return 12, 48, 376, 704 end
|
||||
session()
|
||||
Studio.mode = "library"
|
||||
Studio.available = {}
|
||||
Kit.audit = {}
|
||||
check(pcall(Studio.draw), "My Skins draws inside an inset safe area")
|
||||
local function insideSafe(rects, ox, oy, sw, sh, where)
|
||||
for _, r in ipairs(rects or {}) do
|
||||
check(r.x >= ox - 0.5,
|
||||
where .. " control '" .. r.label .. "' is right of the left inset")
|
||||
check(r.y >= oy - 0.5,
|
||||
where .. " control '" .. r.label .. "' is below the notch")
|
||||
check(r.x + r.w <= ox + sw + 0.5,
|
||||
where .. " control '" .. r.label .. "' stays left of the right inset")
|
||||
check(r.y + r.h <= oy + sh + 0.5,
|
||||
where .. " control '" .. r.label .. "' stays above the home indicator")
|
||||
end
|
||||
end
|
||||
insideSafe(Kit.audit, 12, 48, 376, 704, "library")
|
||||
Studio.mode = "editor"
|
||||
Studio.addControl()
|
||||
Kit.audit = {}
|
||||
check(pcall(Studio.draw), "the editor draws inside an inset safe area")
|
||||
insideSafe(Kit.audit, 12, 48, 376, 704, "editor")
|
||||
check(Studio.lastCanvas ~= nil, "the editor still has a mock device")
|
||||
check(Studio.lastCanvas.y >= 48 - 0.5, "the mock device is below the notch")
|
||||
check(Studio.lastCanvas.y + Studio.lastCanvas.h <= 48 + 704 + 0.5,
|
||||
"and above the home indicator")
|
||||
Kit.audit = nil
|
||||
love.graphics.getDimensions = oldDims
|
||||
love.window.getSafeArea = oldSafe
|
||||
Studio.closeModal()
|
||||
Studio.ask("sure?", function() end)
|
||||
check(pcall(Studio.draw), "and with the confirm prompt up")
|
||||
Studio.confirmNo()
|
||||
|
||||
@@ -136,11 +136,16 @@ for off = lo, hi do
|
||||
end
|
||||
Zoom.offset = 0
|
||||
|
||||
local capped = select(1, Renderer:worldViewSize())
|
||||
useSkin("0.25,0.1,0.5,0.6", "overlay0_viewport_expand = true")
|
||||
local expanded = select(1, Renderer:worldViewSize())
|
||||
check(expanded > capped,
|
||||
"viewport_expand lets the survey world fill the cutout instead of the GB box")
|
||||
useSkin("0.25,0.1,0.3,0.8")
|
||||
local tallR = Renderer:frameRects()
|
||||
local tallVw, tallVh = Renderer:worldViewSize()
|
||||
local tallSp = Zoom.scale(tallR.Sp)
|
||||
check(tallVw * tallSp >= tallR.vuw and tallVh * tallSp >= tallR.vuh,
|
||||
"the world pass covers the whole cutout, not just the GB box inside it")
|
||||
check(tallVh * tallSp > tallR.uvph,
|
||||
"so a cutout taller than 160x144 shows map where the UI letterbox ends")
|
||||
check(tallR.uvph < tallR.vuh and tallR.uvpw <= tallR.vuw,
|
||||
"while the UI keeps its whole-pixel letterbox inside that cutout")
|
||||
useSkin("0.25,0.1,0.5,0.6")
|
||||
|
||||
setWindow(480, 800)
|
||||
@@ -159,22 +164,14 @@ setWindow(1280, 720)
|
||||
useSkin("0.25,0.1,0.5,0.6")
|
||||
local px, py, pw, ph, active = Playfield.rect(1280, 720)
|
||||
eq(active, true, "Gold sees the cutout too")
|
||||
eq(pw, 480, "the playfield is a whole multiple of 160")
|
||||
eq(ph, 432, "and of 144")
|
||||
check(inside(px, py, pw, ph, 320, 72, 640, 432),
|
||||
"centred inside the cutout")
|
||||
eq(Chrome.fitScale(1280, 720), 3, "Chrome fits the playfield")
|
||||
eq(px, 320, "the playfield is the cutout, at its origin")
|
||||
eq(py, 72, "on both axes")
|
||||
eq(pw, 640, "with its full width")
|
||||
eq(ph, 432, "and its full height")
|
||||
eq(Chrome.fitScale(1280, 720), 3, "Chrome fits whole GB pixels in it")
|
||||
local cox, coy = Chrome.fitOrigin(1280, 720)
|
||||
eq(cox, px, "and centres the panel on it")
|
||||
eq(coy, py, "on both axes")
|
||||
|
||||
useSkin("0.25,0.1,0.5,0.6", "overlay0_viewport_expand = true")
|
||||
local ex, ey, ew, eh = Playfield.rect(1280, 720)
|
||||
eq(ew, 640, "expand hands the picture the full cutout width")
|
||||
eq(eh, 432, "and its full height")
|
||||
eq(ex, 320, "at the cutout origin")
|
||||
eq(ey, 72, "on both axes")
|
||||
useSkin("0.25,0.1,0.5,0.6")
|
||||
eq(cox, 320 + 80, "and centres the panel on the cutout")
|
||||
eq(coy, 72, "on both axes")
|
||||
|
||||
local ew2, eh2, ex2, ey2, act2 = Playfield.push(1280, 720)
|
||||
eq(act2, true, "push reports the frame is contained")
|
||||
@@ -184,12 +181,29 @@ eq(ew2, pw, "and hands the scene the playfield size")
|
||||
eq(Playfield.cutout(ew2, eh2), nil, "inside the frame there is no cutout left")
|
||||
eq(select(3, Playfield.rect(ew2, eh2)), pw, "so the playfield is the surface")
|
||||
eq(Chrome.fitScale(ew2, eh2), 3, "and Chrome fits it without re-applying")
|
||||
eq(select(1, Chrome.fitOrigin(ew2, eh2)), 0, "at a local origin")
|
||||
eq(select(1, Chrome.fitOrigin(ew2, eh2)), 80, "centred on that surface alone")
|
||||
eq(select(1, Playfield.dimensions()), pw, "screens read the playfield as the display")
|
||||
Playfield.pop()
|
||||
eq(Playfield.entered, false, "pop leaves the frame")
|
||||
eq(select(1, Playfield.cutout(1280, 720)), 320, "and the cutout is visible again")
|
||||
|
||||
setWindow(960, 1901)
|
||||
useSkin("0,0,1,0.5", "overlay0_aspect_ratio = 0.5625")
|
||||
local deck = TouchSkin.page()
|
||||
local _, dy, _, dh = TouchSkin.pageBox(deck, 960, 1901)
|
||||
check(dy > 0 and math.abs(dy + dh - 1901) < 1,
|
||||
"a portrait deck taller than the display pins to the lower edge")
|
||||
local hx, hy, hw, hh = Playfield.cutout(960, 1901)
|
||||
eq(hy, 0, "and the screen rect it left flush takes the room above it")
|
||||
eq(hx, 0, "without moving sideways")
|
||||
eq(hw, 960, "or changing width")
|
||||
eq(hh, math.floor(dy + dh * 0.5), "growing by exactly the headroom")
|
||||
|
||||
useSkin("0,0.1,1,0.5", "overlay0_aspect_ratio = 0.5625")
|
||||
check(select(2, Playfield.cutout(960, 1901)) > dy,
|
||||
"a screen rect the author inset from the top keeps that bezel margin")
|
||||
|
||||
setWindow(1280, 720)
|
||||
useSkin("0.4,0.4,0.1,0.1")
|
||||
local sx, sy, sw, sh = Playfield.rect(1280, 720)
|
||||
check(inside(sx, sy, sw, sh, 512, 288, 128, 72),
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
-- A Gen 1 move slot stores current PP in six bits of one byte and the PP Up
|
||||
-- count in the other two (constants/pokemon_data_constants.asm:101-102), and
|
||||
-- the status screen reads it back with `and PP_MASK` before PrintNumber
|
||||
-- (engine/pokemon/status_screen.asm:357-365), so "not a number" is not a state
|
||||
-- the hardware record can hold and the load-time repair must normalize it.
|
||||
-- Max PP follows GetMaxPP/AddBonusPP (engine/items/item_effects.asm:2467,
|
||||
-- 2418). #1668
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.harness")
|
||||
local check, eq = T.check, T.eq
|
||||
love = love or require("tests.love_stub")
|
||||
|
||||
local SaveData = require("src.core.SaveData")
|
||||
|
||||
local data = {
|
||||
pokemon = { FIXMON_A = { id = "FIXMON_A", name = "FIXMON A", types = { "GRASS" },
|
||||
baseStats = { hp = 45, attack = 49, defense = 49, speed = 45, special = 65 } } },
|
||||
moves = {
|
||||
FIX_TACKLE = { id = "FIX_TACKLE", name = "TACKLE", pp = 35 },
|
||||
FIX_GROWL = { id = "FIX_GROWL", name = "GROWL", pp = 40 },
|
||||
},
|
||||
items = {}, maps = { FIXMAP = { id = "FIXMAP" } },
|
||||
constants = { fallbackMove = "FIX_TACKLE" },
|
||||
}
|
||||
|
||||
local function saveWith(moves)
|
||||
return {
|
||||
party = { { species = "FIXMON_A", level = 21, hp = 62, exp = 9000,
|
||||
dvs = { hp = 15, attack = 9, defense = 8, speed = 8, special = 8 },
|
||||
statExp = {}, moves = moves } },
|
||||
boxes = {}, inventory = {}, pcItems = {},
|
||||
player = { map = "FIXMAP", x = 1, y = 1, name = "RED", id = 1 },
|
||||
}
|
||||
end
|
||||
|
||||
local function scrub(moves)
|
||||
local save = saveWith(moves)
|
||||
SaveData.validate(save, data)
|
||||
return save.party[1].moves
|
||||
end
|
||||
|
||||
-- SummaryMenu page 2 and every battle reader index the save's own slot table
|
||||
local function readers(mv)
|
||||
local mdef = data.moves[mv.id]
|
||||
local maxPP = mdef.pp + (mv.ppUps or 0) * math.floor(mdef.pp / 5)
|
||||
local okFmt = pcall(string.format, "%2d/%2d", mv.pp, maxPP)
|
||||
local okCmp = pcall(function() return mv.pp > 0 end)
|
||||
return okFmt, okCmp, maxPP
|
||||
end
|
||||
|
||||
do -- every non-numeric pp shape the tree can be handed
|
||||
local moves = scrub({
|
||||
{ id = "FIX_TACKLE" },
|
||||
{ id = "FIX_TACKLE", pp = {} },
|
||||
{ id = "FIX_TACKLE", pp = "35" },
|
||||
{ id = "FIX_TACKLE", pp = 0 / 0 },
|
||||
})
|
||||
eq(#moves, 4, "all four slots survive")
|
||||
for i = 1, 4 do
|
||||
local mv = moves[i]
|
||||
check(type(mv.pp) == "number", "slot " .. i .. " carries a numeric pp")
|
||||
local okFmt, okCmp, maxPP = readers(mv)
|
||||
check(okFmt, "slot " .. i .. ": SummaryMenu's ('%2d/%2d'):format no longer raises")
|
||||
check(okCmp, "slot " .. i .. ": playerHasPP's `mv.pp > 0` no longer raises")
|
||||
check(type(mv.pp) == "number" and mv.pp >= 0 and mv.pp <= maxPP,
|
||||
"slot " .. i .. " sits inside 0..maxPP")
|
||||
end
|
||||
eq(moves[1].pp, 35, "a missing pp heals to full, not to Struggle")
|
||||
eq(moves[2].pp, 35, "a table pp heals to full")
|
||||
eq(moves[3].pp, 35, "a numeric string becomes the number it spells")
|
||||
eq(moves[4].pp, 35, "a nan pp heals to full")
|
||||
end
|
||||
|
||||
do -- numeric, but not a value the unsigned byte field can express
|
||||
local moves = scrub({
|
||||
{ id = "FIX_TACKLE", pp = -4 },
|
||||
{ id = "FIX_TACKLE", pp = 1.7 },
|
||||
{ id = "FIX_TACKLE", pp = math.huge },
|
||||
{ id = "FIX_TACKLE", pp = -math.huge },
|
||||
})
|
||||
eq(moves[1].pp, 0, "a negative pp clamps up to zero")
|
||||
eq(moves[2].pp, 1, "a fractional pp floors to an integer")
|
||||
eq(moves[3].pp, 35, "an infinite pp heals to full")
|
||||
eq(moves[4].pp, 35, "so does a negative infinity")
|
||||
end
|
||||
|
||||
-- MimicEffect writes the copied move id into the slot and never touches the
|
||||
-- PP byte (engine/battle/effects.asm:1261-1266), and this port's battler reads
|
||||
-- mon.moves by identity, so a mid-battle save legitimately carries a PP count
|
||||
-- above the slot's current move's max. Clamping it down corrupts a battle
|
||||
-- checkpoint, which is why the repair only replaces values that are not
|
||||
-- numbers at all.
|
||||
do
|
||||
local moves = scrub({
|
||||
{ id = "FIX_TACKLE", pp = 40, mimic = true },
|
||||
{ id = "FIX_TACKLE", pp = 999 },
|
||||
{ id = "FIX_GROWL", pp = 40, ppUps = 0 },
|
||||
})
|
||||
eq(moves[1].pp, 40, "a Mimic'd slot keeps the 40 PP the GROWL it replaced had")
|
||||
check(moves[1].mimic == true, "and the battler's own restore marker survives")
|
||||
eq(moves[2].pp, 999, "an over-max pp is left alone, not clamped")
|
||||
eq(moves[3].pp, 40, "and a full slot is unchanged")
|
||||
end
|
||||
|
||||
do -- the PP Up count is two bits, so 0..3
|
||||
local moves = scrub({
|
||||
{ id = "FIX_TACKLE", pp = 5, ppUps = "x" },
|
||||
{ id = "FIX_TACKLE", pp = 5, ppUps = 9 },
|
||||
{ id = "FIX_TACKLE", pp = 49, ppUps = 2 },
|
||||
{ id = "FIX_TACKLE", pp = 5 },
|
||||
})
|
||||
eq(moves[1].ppUps, 0, "a non-numeric ppUps becomes zero")
|
||||
eq(moves[2].ppUps, 3, "an over-max ppUps clamps to three")
|
||||
eq(moves[3].ppUps, 2, "a legal ppUps is kept")
|
||||
eq(moves[3].pp, 49, "and its PP-Upped count is untouched: 35 + 2 * 7")
|
||||
eq(moves[4].ppUps, nil, "a slot without a ppUps does not grow one")
|
||||
for i = 1, 4 do
|
||||
local okFmt = pcall(string.format, "%2d/%2d", moves[i].pp, 35)
|
||||
check(okFmt, "slot " .. i .. " formats after a ppUps repair")
|
||||
local mdef = data.moves[moves[i].id]
|
||||
local ok = pcall(function()
|
||||
return mdef.pp + (moves[i].ppUps or 0) * math.floor(mdef.pp / 5)
|
||||
end)
|
||||
check(ok, "slot " .. i .. ": SummaryMenu's maxPP arithmetic no longer raises")
|
||||
end
|
||||
end
|
||||
|
||||
-- A scalar move slot is a shape the id filter has always tolerated, and
|
||||
-- tests/modkit/cases/checkpoints.lua treats one as valid content that
|
||||
-- SaveData.validate must not rewrite. It stays out of the repair; the
|
||||
-- SummaryMenu.lua:206 crash it causes is a separate defect.
|
||||
do
|
||||
local moves = scrub({ "FIX_TACKLE", { id = "FIX_GROWL" } })
|
||||
eq(#moves, 2, "the scalar slot survives the id filter, as before")
|
||||
eq(moves[1], "FIX_TACKLE", "and is left exactly as the save stored it")
|
||||
eq(moves[2].pp, 40, "while its table-shaped neighbour is still repaired")
|
||||
end
|
||||
|
||||
do -- the moveless-mon fallback slot goes through the same normalization
|
||||
local moves = scrub({ { id = "NOT_A_MOVE", pp = "junk" } })
|
||||
eq(#moves, 1, "the unknown move is replaced by the fallback")
|
||||
eq(moves[1].id, "FIX_TACKLE", "which is data.constants.fallbackMove")
|
||||
check(type(moves[1].pp) == "number", "and carries a numeric pp")
|
||||
eq(moves[1].pp, 35, "at full")
|
||||
end
|
||||
|
||||
do -- a vanilla slot passes through byte-identical
|
||||
local moves = scrub({
|
||||
{ id = "FIX_TACKLE", pp = 20 },
|
||||
{ id = "FIX_GROWL", pp = 0 },
|
||||
{ id = "FIX_GROWL", pp = 64, ppUps = 3 },
|
||||
})
|
||||
eq(moves[1].pp, 20, "a mid-fight pp is left alone")
|
||||
eq(moves[2].pp, 0, "an exhausted move is left on zero, not healed")
|
||||
eq(moves[3].pp, 64, "and a PP-Upped slot agrees with SummaryMenu's own maxPP")
|
||||
eq(moves[3].ppUps, 3, "with its PP Up count intact")
|
||||
end
|
||||
|
||||
do -- box mons are reached by the same pass
|
||||
local save = saveWith({ { id = "FIX_TACKLE", pp = 20 } })
|
||||
save.boxes = { { { species = "FIXMON_A", level = 21, hp = 62,
|
||||
dvs = {}, statExp = {},
|
||||
moves = { { id = "FIX_TACKLE", pp = "nonsense" } } } } }
|
||||
SaveData.validate(save, data)
|
||||
local mv = save.boxes[1][1].moves[1]
|
||||
check(type(mv.pp) == "number", "a box mon's move slot is repaired too")
|
||||
eq(mv.pp, 35, "to full PP")
|
||||
end
|
||||
|
||||
T.finish()
|
||||
@@ -0,0 +1,100 @@
|
||||
-- A Gen 1 party mon carries one Special word (macros/ram.asm:28-37) and
|
||||
-- CalcStats writes all NUM_STATS stats or none (home/move_mon.asm:33-48,
|
||||
-- constants/battle_constants.asm:11-17), so a block missing a key is not a
|
||||
-- Gen 1 record and the load-time repair must rebuild it. Gen 2 splits it
|
||||
-- into SpclAtk/SpclDef (pokegold macros/ram.asm:29-42). #1517
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.harness")
|
||||
local check, eq = T.check, T.eq
|
||||
love = love or require("tests.love_stub")
|
||||
|
||||
local Stats = require("src.pokemon.Stats")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
|
||||
local data = {
|
||||
pokemon = { FIXMON_A = { id = "FIXMON_A", name = "FIXMON A", types = { "GRASS" },
|
||||
baseStats = { hp = 45, attack = 49, defense = 49, speed = 45, special = 65 } } },
|
||||
moves = { FIX_TACKLE = { id = "FIX_TACKLE", name = "TACKLE", pp = 35 } },
|
||||
items = {}, maps = { FIXMAP = { id = "FIXMAP" } },
|
||||
constants = { fallbackMove = "FIX_TACKLE" },
|
||||
}
|
||||
local DEF = data.pokemon.FIXMON_A
|
||||
local DVS = { hp = 15, attack = 9, defense = 8, speed = 8, special = 8 }
|
||||
|
||||
-- src/battle/gen2/Mon.lua Mon.stats' shape: no `special`
|
||||
local function gen2Shaped()
|
||||
return {
|
||||
species = "FIXMON_A", level = 21, hp = 62, exp = 9000,
|
||||
dvs = { hp = 15, attack = 9, defense = 8, speed = 8, special = 8 },
|
||||
statExp = {},
|
||||
stats = { hp = 62, attack = 27, defense = 29, speed = 37,
|
||||
specialAttack = 8, specialDefense = 8 },
|
||||
moves = { { id = "FIX_TACKLE", pp = 35 } },
|
||||
}
|
||||
end
|
||||
|
||||
do
|
||||
local mon = gen2Shaped()
|
||||
eq(mon.stats.special, nil, "premise: a Gen 2 block has no `special`")
|
||||
Stats.ensure(DEF, mon)
|
||||
local want = Stats.calc(DEF, 21, DVS, {})
|
||||
for _, key in ipairs(Stats.ORDER) do
|
||||
eq(mon.stats[key], want[key], "ensure rebuilds " .. key .. " from CalcStats")
|
||||
end
|
||||
eq(mon.stats.specialAttack, nil, "and drops the Gen 2 keys")
|
||||
eq(mon.stats.specialDefense, nil, "both of them")
|
||||
end
|
||||
|
||||
do
|
||||
local mon = gen2Shaped()
|
||||
local save = { party = { mon }, boxes = {}, inventory = {}, pcItems = {},
|
||||
player = { map = "FIXMAP", x = 1, y = 1, name = "RED", id = 1 } }
|
||||
SaveData.validate(save, data)
|
||||
check(type(save.party[1].stats.special) == "number",
|
||||
"validate repairs a party mon carrying a Gen 2 stat block")
|
||||
local ok = pcall(string.format, "%3d", save.party[1].stats.special)
|
||||
check(ok, "SummaryMenu's ('%3d'):format over stats.special no longer raises")
|
||||
ok = pcall(Stats.applyStage, save.party[1].stats.special, 0)
|
||||
check(ok, "and Damage's applyStage over curStats.special no longer raises")
|
||||
end
|
||||
|
||||
-- a box mon reached through the same pass
|
||||
do
|
||||
local save = { party = {}, boxes = { { gen2Shaped() } }, inventory = {},
|
||||
pcItems = {},
|
||||
player = { map = "FIXMAP", x = 1, y = 1, name = "RED", id = 1 } }
|
||||
SaveData.validate(save, data)
|
||||
check(type(save.boxes[1][1].stats.special) == "number",
|
||||
"the repair reaches box mons too")
|
||||
end
|
||||
|
||||
do -- a complete block is never rewritten
|
||||
local want = Stats.calc(DEF, 21, DVS, {})
|
||||
local mon = { species = "FIXMON_A", level = 21, hp = 5, dvs = DVS, statExp = {},
|
||||
stats = { hp = want.hp, attack = 999, defense = want.defense,
|
||||
speed = want.speed, special = want.special },
|
||||
moves = { { id = "FIX_TACKLE", pp = 35 } } }
|
||||
Stats.ensure(DEF, mon)
|
||||
eq(mon.stats.attack, 999, "a complete block is returned untouched")
|
||||
eq(mon.hp, 5, "and a stored current HP below the max is kept")
|
||||
end
|
||||
|
||||
do -- a mon with no block at all still gets one (#233, #304 unchanged)
|
||||
local mon = { species = "FIXMON_A", level = 21, dvs = DVS, statExp = {} }
|
||||
Stats.ensure(DEF, mon)
|
||||
local want = Stats.calc(DEF, 21, DVS, {})
|
||||
eq(mon.stats.special, want.special, "a box mon with no stats still gets them")
|
||||
eq(mon.hp, want.hp, "and a missing current HP fills to the maximum")
|
||||
end
|
||||
|
||||
do -- no species definition: nothing to rebuild from, so leave it alone
|
||||
local mon = { species = "MISSINGNO", level = 21,
|
||||
stats = { hp = 62, attack = 27 } }
|
||||
Stats.ensure(nil, mon)
|
||||
eq(mon.stats.attack, 27, "an unknown species leaves a partial block as-is")
|
||||
eq(mon.stats.special, nil, "rather than inventing one")
|
||||
end
|
||||
|
||||
T.finish()
|
||||
@@ -0,0 +1,133 @@
|
||||
-- home/overworld.asm:690-703 (PlayMapChangeSound tail-calls GBFadeOutToBlack)
|
||||
-- and home/fade.asm:43-46: the map-change fade has no matching fade in, so the
|
||||
-- warp shape ends in the same frame its midpoint runs. The midpoint is where
|
||||
-- setMap opens things (the Cycling Road refusal box, a map script's onEnter),
|
||||
-- and a fade that popped the top of the stack after that ate them and then
|
||||
-- finished a second time (#1663).
|
||||
-- luajit tests/engine/transition_identity_pop_bug1663.lua
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.harness")
|
||||
local check, eq = T.check, T.eq
|
||||
love = love or require("tests.love_stub")
|
||||
|
||||
local StateStack = require("src.core.StateStack")
|
||||
local Transition = require("src.render.Transition")
|
||||
local Timing = require("src.core.Timing")
|
||||
|
||||
local function overworld() return { name = "overworld", isOpaque = true } end
|
||||
|
||||
-- ------------------------------------------------ the warp shape (#1663)
|
||||
|
||||
do
|
||||
StateStack:init()
|
||||
local ow = overworld()
|
||||
StateStack:push(ow)
|
||||
|
||||
local box = { name = "refusal" }
|
||||
local entered, depthAtMidpoint, topAtMidpoint = 0, nil, nil
|
||||
function box:enter() entered = entered + 1 end
|
||||
local dones = 0
|
||||
local fade
|
||||
fade = Transition.new({ stack = StateStack }, function()
|
||||
depthAtMidpoint = #StateStack.states
|
||||
topAtMidpoint = StateStack:top()
|
||||
StateStack:push(box)
|
||||
end, function() dones = dones + 1 end, true)
|
||||
StateStack:push(fade)
|
||||
|
||||
local finishedOn
|
||||
for frame = 1, 120 do
|
||||
StateStack:update(1 / 60)
|
||||
if dones > 0 and not finishedOn then finishedOn = frame end
|
||||
end
|
||||
|
||||
eq(finishedOn, Timing.WARP_FADE_OUT, "the warp hands back at the end of the fade")
|
||||
eq(dones, 1, "and hands back exactly once")
|
||||
eq(depthAtMidpoint, 1, "the fade is off the stack before the midpoint runs")
|
||||
check(topAtMidpoint == ow, "so the map switch sees the overworld on top")
|
||||
eq(entered, 1, "the state the midpoint pushed entered once")
|
||||
eq(#StateStack.states, 2, "the fade is gone and the pushed state is not")
|
||||
check(StateStack.states[1] == ow, "the overworld is still the base")
|
||||
check(StateStack:top() == box, "the box the midpoint opened owns the screen")
|
||||
end
|
||||
|
||||
-- a midpoint that pushes nothing still leaves exactly the overworld behind
|
||||
do
|
||||
StateStack:init()
|
||||
local ow = overworld()
|
||||
StateStack:push(ow)
|
||||
local mids, dones = 0, 0
|
||||
local fade = Transition.new({ stack = StateStack },
|
||||
function() mids = mids + 1 end,
|
||||
function() dones = dones + 1 end, true)
|
||||
StateStack:push(fade)
|
||||
for _ = 1, 120 do StateStack:update(1 / 60) end
|
||||
eq(mids, 1, "the map switched once")
|
||||
eq(dones, 1, "the plain warp still hands back exactly once")
|
||||
eq(#StateStack.states, 1, "and pops nothing but itself")
|
||||
check(StateStack:top() == ow, "leaving the overworld on top")
|
||||
end
|
||||
|
||||
-- ------------------------------------------ the script fade is unchanged
|
||||
|
||||
-- ViridianGym.asm .afterBeat / RocketHideoutB4F BeatGiovanniScript bracket
|
||||
-- their HideObject with GBFadeOutToBlack -> GBFadeInFromBlack, so those keep
|
||||
-- a real fade in and wait under whatever the midpoint opened.
|
||||
do
|
||||
StateStack:init()
|
||||
local ow = overworld()
|
||||
StateStack:push(ow)
|
||||
|
||||
local box = { name = "script box" }
|
||||
local dones = 0
|
||||
local fade = Transition.new({ stack = StateStack },
|
||||
function() StateStack:push(box) end,
|
||||
function() dones = dones + 1 end, false)
|
||||
StateStack:push(fade)
|
||||
|
||||
for _ = 1, Timing.WARP_FADE_OUT + 8 do StateStack:update(1 / 60) end
|
||||
eq(dones, 0, "a fade with a fade-in waits under what its midpoint opened")
|
||||
check(StateStack:top() == box, "the pushed state is on top")
|
||||
check(StateStack.states[2] == fade, "with the fade still underneath it")
|
||||
|
||||
StateStack:pop()
|
||||
for _ = 1, Timing.FADE_IN_FROM_BLACK + 8 do StateStack:update(1 / 60) end
|
||||
eq(dones, 1, "and finishes once the box is gone")
|
||||
eq(#StateStack.states, 1, "leaving the overworld alone on the stack")
|
||||
check(StateStack:top() == ow, "and nothing else came off with it")
|
||||
end
|
||||
|
||||
-- ------------------------------------------------- finish is idempotent
|
||||
|
||||
do
|
||||
StateStack:init()
|
||||
local ow = overworld()
|
||||
StateStack:push(ow)
|
||||
local dones = 0
|
||||
local fade = Transition.new({ stack = StateStack }, nil,
|
||||
function() dones = dones + 1 end, true)
|
||||
StateStack:push(fade)
|
||||
fade:finish()
|
||||
fade:finish()
|
||||
eq(dones, 1, "a second finish does not hand back a second time")
|
||||
eq(#StateStack.states, 1, "and takes nothing else off the stack")
|
||||
check(StateStack:top() == ow, "the overworld survives the second call")
|
||||
end
|
||||
|
||||
-- a mod record may still ask a warp for a fade in; framesIn 0 is truthy in
|
||||
-- Lua, so the built-in warp keeps its 0 and the retimed one keeps its own
|
||||
do
|
||||
local retimed = { transitions = { warp_fade = { kind = "fade", frames = 32,
|
||||
framesIn = 16 } } }
|
||||
local fade = Transition.new({ data = retimed, stack = StateStack }, nil,
|
||||
nil, true)
|
||||
eq(fade.framesIn, 16, "a retimed warp record keeps its fade in")
|
||||
local vanilla = Transition.new({ stack = StateStack }, nil, nil, true)
|
||||
eq(vanilla.framesIn, Timing.WARP_FADE_IN, "the built-in warp has none")
|
||||
end
|
||||
|
||||
StateStack:clear()
|
||||
|
||||
T.finish("transition_identity_pop_bug1663")
|
||||
@@ -48,6 +48,15 @@ eq(Check.fullAssetName("1.4.2", "iOS", "arm64"),
|
||||
eq(Check.fullAssetName("not-a-version", "Android", "arm64"), nil,
|
||||
"invalid full-package version rejected")
|
||||
|
||||
-- A legacy Android shell needs one manual package update even when its
|
||||
-- downloaded payload already reports the latest engine version.
|
||||
eq(Check.androidNeedsInstallerBootstrap("Android", false), true,
|
||||
"legacy Android shell requires one bootstrap package")
|
||||
eq(Check.androidNeedsInstallerBootstrap("Android", true), false,
|
||||
"current Android shell keeps the selective updater")
|
||||
eq(Check.androidNeedsInstallerBootstrap("Windows", false), false,
|
||||
"non-Android update behavior remains unchanged")
|
||||
|
||||
local withNotes = Check.parseRelease(Json.encode({
|
||||
tag_name = "v1.4.2",
|
||||
body = "## Issues closed\n\n- #1 cart padding",
|
||||
|
||||
@@ -89,6 +89,15 @@ local MOVES = {
|
||||
accuracy = 100, pp = 10, effect = "EFFECT_ENDURE" },
|
||||
RAGE = { id = "RAGE", name = "RAGE", power = 20, type = "NORMAL",
|
||||
accuracy = 100, pp = 20, effect = "EFFECT_RAGE" },
|
||||
-- data/moves/moves.asm:133, :230, :189 and :92 rows, unedited.
|
||||
BIDE = { id = "BIDE", name = "BIDE", power = 0, type = "NORMAL",
|
||||
accuracy = 100, pp = 10, effect = "EFFECT_BIDE" },
|
||||
SLEEP_TALK = { id = "SLEEP_TALK", name = "SLEEP TALK", power = 0,
|
||||
type = "NORMAL", accuracy = 100, pp = 10, effect = "EFFECT_SLEEP_TALK" },
|
||||
SNORE = { id = "SNORE", name = "SNORE", power = 40, type = "NORMAL",
|
||||
accuracy = 100, pp = 15, effect = "EFFECT_SNORE", effectChance = 30 },
|
||||
SOLARBEAM = { id = "SOLARBEAM", name = "SOLARBEAM", power = 120,
|
||||
type = "GRASS", accuracy = 100, pp = 10, effect = "EFFECT_SOLARBEAM" },
|
||||
}
|
||||
|
||||
local GROWTH = {
|
||||
@@ -851,6 +860,83 @@ do
|
||||
check("the trainer's PP is untouched", refuseParty[1].moves[1].pp, 35)
|
||||
end
|
||||
|
||||
-- ResetBattleParticipants falls through into AddBattleParticipant
|
||||
-- (engine/battle/core.asm:3033 and :3037), so every ENEMY-initiated mon change
|
||||
-- wipes both bitfields and re-credits the mon the player has out. The player's
|
||||
-- own send-outs only ever call AddBattleParticipant (core.asm:2655, :2681,
|
||||
-- :3783, :4989, :5014).
|
||||
do
|
||||
local function creditBattle()
|
||||
local party = {
|
||||
Mon.new(DATA, "CYNDAQUIL", 20, { dvs = perfect }),
|
||||
Mon.new(DATA, "TOTODILE", 20, { dvs = perfect }),
|
||||
}
|
||||
local foes = {
|
||||
Mon.new(DATA, "GEODUDE", 8, { dvs = perfect }),
|
||||
Mon.new(DATA, "PIDGEY", 8, { dvs = perfect }),
|
||||
}
|
||||
for _, mon in ipairs(party) do
|
||||
mon.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } }
|
||||
end
|
||||
for _, mon in ipairs(foes) do
|
||||
mon.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } }
|
||||
end
|
||||
local battle = Battle.new({
|
||||
data = DATA, party = party,
|
||||
-- attributes[6] is the low byte of the switch flags: OFTEN.
|
||||
trainer = { class = "YOUNGSTER", name = "JOEY", party = foes,
|
||||
attributes = { 0, 0, 0, 0, 0, 0x01, 0 } },
|
||||
random = zeroRandom,
|
||||
})
|
||||
battle:switch(2)
|
||||
return battle, party, foes
|
||||
end
|
||||
|
||||
-- AI_Switch (engine/battle/ai/items.asm:697).
|
||||
local rotate, rotateParty, rotateFoes = creditBattle()
|
||||
check("both mons are credited before the rotation",
|
||||
rotate.participants[1] and rotate.participants[2], true)
|
||||
rotate:volatile(rotate.enemy).perish = 1
|
||||
check("the AI rotated", rotate:enemyTrySwitchOrItem(), true)
|
||||
check("the rotation installed the second foe", rotate.enemy, rotateFoes[2])
|
||||
check("the bench mon lost its credit", rotate.participants[1], nil)
|
||||
check("only the mon on the field keeps it", rotate.participants[2], true)
|
||||
local benchExp = rotateParty[1].experience
|
||||
local activeExp = rotateParty[2].experience
|
||||
rotate:awardExperience(rotate.enemy)
|
||||
check("the bench mon earns nothing from the new foe",
|
||||
rotateParty[1].experience, benchExp)
|
||||
check("and the mon that faced it is still paid",
|
||||
rotateParty[2].experience > activeExp, true)
|
||||
|
||||
-- ForceEnemySwitch (engine/battle/core.asm:2937), reached only from
|
||||
-- BattleCommand_ForceSwitch (effect_commands.asm:4999).
|
||||
local roar, _, roarFoes = creditBattle()
|
||||
roar.firstMover = "enemy"
|
||||
Battle.MOVE_EFFECTS.EFFECT_FORCE_SWITCH(roar, roar.player, roar.enemy,
|
||||
nil, "ROAR", true)
|
||||
check("Roar dragged the second foe out", roar.enemy, roarFoes[2])
|
||||
check("the bench mon lost its credit to Roar", roar.participants[1], nil)
|
||||
check("and the mon on the field keeps it", roar.participants[2], true)
|
||||
|
||||
-- engine/battle/move_effects/baton_pass.asm:59.
|
||||
local baton, _, batonFoes = creditBattle()
|
||||
Battle.MOVE_EFFECTS.EFFECT_BATON_PASS(baton, baton.enemy)
|
||||
check("the baton passed to the second foe", baton.enemy, batonFoes[2])
|
||||
check("the bench mon lost its credit to the baton",
|
||||
baton.participants[1], nil)
|
||||
check("and the mon on the field keeps it", baton.participants[2], true)
|
||||
|
||||
-- PassedBattleMonEntrance only adds (engine/battle/core.asm:5014): the
|
||||
-- player's own baton pass must NOT wipe the set.
|
||||
local playerBaton, playerBatonParty = creditBattle()
|
||||
Battle.MOVE_EFFECTS.EFFECT_BATON_PASS(playerBaton, playerBaton.player)
|
||||
check("the player's baton pass moved the lead back in",
|
||||
playerBaton.player, playerBatonParty[1])
|
||||
check("and credited both mons",
|
||||
playerBaton.participants[1] and playerBaton.participants[2], true)
|
||||
end
|
||||
|
||||
-- Switching costs the turn and adds the newcomer to the participant set.
|
||||
local switchParty = {
|
||||
Mon.new(DATA, "CYNDAQUIL", 10, { dvs = perfect }),
|
||||
@@ -2713,6 +2799,259 @@ end)()
|
||||
check("and its user reappears", b:volatile(player).vanished, nil)
|
||||
end)()
|
||||
|
||||
-- ------------------------------------------------------------------- Bide
|
||||
--
|
||||
-- data/moves/effects.asm:795-800 runs `storeenergy` ahead of `doturn`, and
|
||||
-- BattleCommand_DoTurn's mask drops SUBSTATUS_BIDE outright
|
||||
-- (engine/battle/effect_commands.asm:977-979), so the whole Bide costs the
|
||||
-- one PP its opening turn spent. The lock is ParsePlayerAction's own arm
|
||||
-- (engine/battle/core.asm:569-576) for the player and CheckEnemyLockedIn
|
||||
-- (:5650) for the foe.
|
||||
;(function()
|
||||
local function said(events, text)
|
||||
for _, e in ipairs(events) do
|
||||
if e.kind == "message" and e.text == text then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
local player = Mon.new(DATA, "CYNDAQUIL", 20, { dvs = perfect })
|
||||
player.moves = { { id = "BIDE", pp = 10, maxPp = 10 },
|
||||
{ id = "TACKLE", pp = 35, maxPp = 35 } }
|
||||
player.hp, player.maxHp = 999, 999
|
||||
local wild = Mon.new(DATA, "PIDGEY", 20, { dvs = perfect })
|
||||
wild.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } }
|
||||
wild.hp, wild.maxHp = 9999, 9999
|
||||
local b = Battle.new({ data = DATA, party = { player }, wild = wild,
|
||||
random = zeroRandom })
|
||||
b:takeEvents()
|
||||
|
||||
check("nothing forces the first BIDE", b:forcedMove(player), nil)
|
||||
b:useMove(player, wild, "BIDE")
|
||||
b:takeEvents()
|
||||
check("the opening turn spends one PP", player.moves[1].pp, 9)
|
||||
check("and the FIGHT menu is locked into it", b:forcedMove(player), "BIDE")
|
||||
check("with the move list narrowed to the one",
|
||||
#b:usableMoves(player), 1)
|
||||
|
||||
-- BattleCommand_StoreEnergy banks wPlayerDamageTaken while the bit is up.
|
||||
b:dealDamage(wild, player, 5, {})
|
||||
b:takeEvents()
|
||||
b:useMove(player, wild, "BIDE")
|
||||
check("a storing turn spends no PP", player.moves[1].pp, 9)
|
||||
check("and stays locked", b:forcedMove(player), "BIDE")
|
||||
check("`.still_storing` prints rather than attacking",
|
||||
said(b:takeEvents(), "CYNDAQUIL is storing energy!"), true)
|
||||
|
||||
-- A dry BIDE keeps running: the bide arm jumps past MoveSelectionScreen,
|
||||
-- where .CheckPlayerHasUsableMoves lives (engine/battle/core.asm:5058).
|
||||
player.moves[1].pp = 0
|
||||
check("a spent BIDE is still offered", #b:usableMoves(player), 1)
|
||||
check("...and it is the BIDE", b:usableMoves(player)[1].id, "BIDE")
|
||||
player.moves[1].pp = 9
|
||||
|
||||
local hpBefore = wild.hp
|
||||
b:useMove(player, wild, "BIDE")
|
||||
b:takeEvents()
|
||||
check("the release spends no PP either", player.moves[1].pp, 9)
|
||||
check("UnleashEnergy pays back double", wild.hp, hpBefore - 10)
|
||||
check("and the lock is gone", b:forcedMove(player), nil)
|
||||
|
||||
-- CheckEnemyLockedIn holds SUBSTATUS_BIDE, so the AI is never asked.
|
||||
local es = b:volatile(b.enemy)
|
||||
es.bideTurns, es.bideMove, es.bideStored = 2, "BIDE", 0
|
||||
check("a biding foe re-uses its Bide", b:enemyMove(), "BIDE")
|
||||
es.bideTurns, es.bideMove, es.bideStored = nil, nil, nil
|
||||
|
||||
-- .reset_bide (engine/battle/core.asm:572-573, :627-629): the PACK cancels
|
||||
-- a Bide, a switch does not.
|
||||
b:useMove(player, wild, "BIDE")
|
||||
b:takeEvents()
|
||||
check("locked again", b:forcedMove(player), "BIDE")
|
||||
b:takeTurn({ kind = "item", item = "POTION" })
|
||||
b:takeEvents()
|
||||
check("using an item cancels the Bide", b:forcedMove(player), nil)
|
||||
check("and drops the bank", b:volatile(player).bideStored, nil)
|
||||
|
||||
-- CantMove (engine/battle/effect_commands.asm:344-353) clears SUBSTATUS_BIDE
|
||||
-- on every arm that spends the turn, so a flinch ends the Bide.
|
||||
b:useMove(player, wild, "BIDE")
|
||||
b:takeEvents()
|
||||
check("locked once more", b:forcedMove(player), "BIDE")
|
||||
b:volatile(player).flinched = true
|
||||
check("a flinch spends the turn", b:canAct(player, "BIDE"), false)
|
||||
b:takeEvents()
|
||||
check("and CantMove ends the Bide", b:forcedMove(player), nil)
|
||||
check("bank dropped with it", b:volatile(player).bideStored, nil)
|
||||
|
||||
-- .not_linked reads SUBSTATUS_ENCORED before CheckEnemyLockedIn
|
||||
-- (engine/battle/core.asm:5524-5533), so an encored foe obeys the Encore.
|
||||
es.bideTurns, es.bideMove, es.bideStored = 2, "BIDE", 0
|
||||
es.encore, es.encoreTurns = "TACKLE", 3
|
||||
check("Encore outranks the foe's Bide lock", b:enemyMove(), "TACKLE")
|
||||
es.encore, es.encoreTurns = nil, nil
|
||||
check("without it the Bide lock holds", b:enemyMove(), "BIDE")
|
||||
es.bideTurns, es.bideMove, es.bideStored = nil, nil, nil
|
||||
end)()
|
||||
|
||||
-- --------------------------------------------------- Snore and Sleep Talk
|
||||
--
|
||||
-- `.fast_asleep` prints FastAsleepText and then falls into `.not_asleep` for
|
||||
-- those two moves instead of `call CantMove / jp EndTurn`
|
||||
-- (engine/battle/effect_commands.asm:188-200). BattleCommand_SleepTalk opens
|
||||
-- on ClearLastMove and ends in ResetTurn (move_effects/sleep_talk.asm:2, :61).
|
||||
;(function()
|
||||
local function said(events, text)
|
||||
for _, e in ipairs(events) do
|
||||
if e.kind == "message" and e.text == text then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
local function sleeper(moves)
|
||||
local player = Mon.new(DATA, "CYNDAQUIL", 20, { dvs = perfect })
|
||||
player.moves = moves
|
||||
player.hp, player.maxHp = 999, 999
|
||||
local wild = Mon.new(DATA, "PIDGEY", 20, { dvs = perfect })
|
||||
wild.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } }
|
||||
wild.hp, wild.maxHp = 9999, 9999
|
||||
local b = Battle.new({ data = DATA, party = { player }, wild = wild,
|
||||
random = zeroRandom })
|
||||
b:takeEvents()
|
||||
return b, player, wild
|
||||
end
|
||||
|
||||
local b, player, wild = sleeper({
|
||||
{ id = "SLEEP_TALK", pp = 10, maxPp = 10 },
|
||||
{ id = "TACKLE", pp = 35, maxPp = 35 } })
|
||||
|
||||
player.status, player.statusTurns = "sleep", 3
|
||||
check("an ordinary move still loses the turn to sleep",
|
||||
b:canAct(player, "TACKLE"), false)
|
||||
check("and the counter was spent", player.statusTurns, 2)
|
||||
check("FastAsleepText still goes up",
|
||||
said(b:takeEvents(), "CYNDAQUIL is fast asleep!"), true)
|
||||
|
||||
player.statusTurns = 3
|
||||
check("SLEEP TALK is let through", b:canAct(player, "SLEEP_TALK"), true)
|
||||
check("...and still spends its sleep turn", player.statusTurns, 2)
|
||||
check("...and still prints the line",
|
||||
said(b:takeEvents(), "CYNDAQUIL is fast asleep!"), true)
|
||||
player.statusTurns = 3
|
||||
check("SNORE is let through too", b:canAct(player, "SNORE"), true)
|
||||
b:takeEvents()
|
||||
|
||||
-- The wake-up arm is not a bypass: it answers for the whole turn.
|
||||
player.statusTurns = 1
|
||||
check("the last sleep turn wakes up", b:canAct(player, "SLEEP_TALK"), true)
|
||||
check("and clears the status", player.status, nil)
|
||||
|
||||
player.status, player.statusTurns = "sleep", 5
|
||||
local hpBefore = wild.hp
|
||||
b:useMove(player, wild, "SLEEP_TALK")
|
||||
b:takeEvents()
|
||||
check("SLEEP TALK pays its own PP through doturn", player.moves[1].pp, 9)
|
||||
check("but the move it calls pays none (ResetTurn)", player.moves[2].pp, 35)
|
||||
check("and that move really landed", wild.hp < hpBefore, true)
|
||||
check("ClearLastMove leaves no last move (used_move_text.asm:30-36)",
|
||||
b:volatile(player).lastMove, nil)
|
||||
|
||||
-- .check_two_turn_move (sleep_talk.asm:117-141) drops the five charge
|
||||
-- effects and EFFECT_BIDE, so a mon with nothing else fails.
|
||||
local b2, player2, wild2 = sleeper({
|
||||
{ id = "SLEEP_TALK", pp = 10, maxPp = 10 },
|
||||
{ id = "SOLARBEAM", pp = 10, maxPp = 10 } })
|
||||
player2.status, player2.statusTurns = "sleep", 5
|
||||
b2:useMove(player2, wild2, "SLEEP_TALK")
|
||||
check("a two-turn move is never sampled",
|
||||
said(b2:takeEvents(), "But it failed!"), true)
|
||||
check("and nothing was called", b2:volatile(player2).chargeMove, nil)
|
||||
|
||||
-- BattleCommand_SleepTalk's own `and SLP_MASK / jr z, .fail` (:16-19).
|
||||
local b3, player3, wild3 = sleeper({
|
||||
{ id = "SLEEP_TALK", pp = 10, maxPp = 10 },
|
||||
{ id = "TACKLE", pp = 35, maxPp = 35 } })
|
||||
b3:useMove(player3, wild3, "SLEEP_TALK")
|
||||
check("an awake SLEEP TALK fails",
|
||||
said(b3:takeEvents(), "But it failed!"), true)
|
||||
|
||||
-- BattleCommand_Snore (move_effects/snore.asm:1-9) is the same refusal.
|
||||
local b4, player4, wild4 = sleeper({ { id = "SNORE", pp = 15, maxPp = 15 } })
|
||||
local snoreBefore = wild4.hp
|
||||
b4:useMove(player4, wild4, "SNORE")
|
||||
check("an awake SNORE fails", said(b4:takeEvents(), "But it failed!"), true)
|
||||
check("and deals nothing", wild4.hp, snoreBefore)
|
||||
player4.status, player4.statusTurns = "sleep", 5
|
||||
b4:useMove(player4, wild4, "SNORE")
|
||||
b4:takeEvents()
|
||||
check("a sleeping SNORE hits", wild4.hp < snoreBefore, true)
|
||||
end)()
|
||||
|
||||
-- ------------------------------------------- fainted mons stop participating
|
||||
--
|
||||
-- UpdateFaintedPlayerMon RESET_FLAGs wBattleParticipantsNotFainted
|
||||
-- (engine/battle/core.asm:2551-2556) and .EvenlyDivideExpAmongParticipants
|
||||
-- divides by the count of set bits (:7118-7130), so the survivor of a lost
|
||||
-- lead collects a whole share, not half of one.
|
||||
;(function()
|
||||
local function twoMonBattle()
|
||||
local one = Mon.new(DATA, "CYNDAQUIL", 20, { dvs = perfect })
|
||||
one.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } }
|
||||
local two = Mon.new(DATA, "CYNDAQUIL", 20, { dvs = perfect })
|
||||
two.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } }
|
||||
local wild = Mon.new(DATA, "PIDGEY", 20, { dvs = perfect })
|
||||
wild.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } }
|
||||
local b = Battle.new({ data = DATA, party = { one, two }, wild = wild,
|
||||
random = zeroRandom })
|
||||
b:takeEvents()
|
||||
return b, one, two, wild
|
||||
end
|
||||
|
||||
local b, one, two, wild = twoMonBattle()
|
||||
check("the lead starts as a participant", b.participants[1], true)
|
||||
one.hp = 0
|
||||
b:resolveFaints()
|
||||
b:takeEvents()
|
||||
check("a fainted participant drops out", b.participants[1], nil)
|
||||
|
||||
-- The clear is one-shot: GiveExperiencePoints' `.done` falls through
|
||||
-- ResetBattleParticipants into AddBattleParticipant (:7116, :3033-3037) and
|
||||
-- puts the dead slot's bit back, and nothing takes it off again.
|
||||
local oneShot = twoMonBattle()
|
||||
oneShot.player.hp = 0
|
||||
oneShot:resolveFaints()
|
||||
oneShot:takeEvents()
|
||||
oneShot:resetParticipants()
|
||||
oneShot:resolveFaints()
|
||||
oneShot:takeEvents()
|
||||
check("and the cart's own re-add survives a second pass",
|
||||
oneShot.participants[1], true)
|
||||
|
||||
b:switch(2)
|
||||
b:takeEvents()
|
||||
check("the replacement is a participant", b.participants[2], true)
|
||||
check("...and the fainted lead is not", b.participants[1], nil)
|
||||
local before = two.experience
|
||||
wild.hp = 0
|
||||
b:resolveFaints()
|
||||
b:takeEvents()
|
||||
local shared = two.experience - before
|
||||
|
||||
-- The control: the same KO with the same mon as the only party member.
|
||||
local solo = Mon.new(DATA, "CYNDAQUIL", 20, { dvs = perfect })
|
||||
solo.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } }
|
||||
local soloWild = Mon.new(DATA, "PIDGEY", 20, { dvs = perfect })
|
||||
soloWild.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } }
|
||||
local soloBattle = Battle.new({ data = DATA, party = { solo },
|
||||
wild = soloWild, random = zeroRandom })
|
||||
soloBattle:takeEvents()
|
||||
local soloBefore = solo.experience
|
||||
soloWild.hp = 0
|
||||
soloBattle:resolveFaints()
|
||||
soloBattle:takeEvents()
|
||||
check("the survivor gets a whole share, not half",
|
||||
shared, solo.experience - soloBefore)
|
||||
check("and the share is a real number", shared > 0, true)
|
||||
end)()
|
||||
|
||||
print(("gen2 battle: %d checks, %d failures"):format(checks, failures))
|
||||
|
||||
-- Raise rather than os.exit: tests/run_tests.lua dofiles this file, so an
|
||||
|
||||
@@ -175,7 +175,7 @@ do
|
||||
dealt = event.amount
|
||||
end
|
||||
if event.kind == "message"
|
||||
and event.text == "It's not very effective..." then
|
||||
and event.text == "It's not very\neffective…" then
|
||||
sawNve = true
|
||||
end
|
||||
end
|
||||
|
||||
@@ -213,4 +213,115 @@ do
|
||||
"all four arms compose in the cart's order")
|
||||
end
|
||||
|
||||
-- ---- battle.exp_award: applyShare's third argument ------------------------
|
||||
--
|
||||
-- Gen 1's applyShare(mon, split, announce) pays the mon and prints its
|
||||
-- GainedText only when `announce` is truthy (src/battle/BattleState.lua).
|
||||
-- Gold accepted the argument and ignored it, so a mod that pays the whole
|
||||
-- party -- the Exp Share mod's own documented behaviour -- could not print one
|
||||
-- summary line here; it got a box per recipient.
|
||||
--
|
||||
-- Two tests, per Route B in CONTRIBUTING-mods.md: vanilla is unchanged with no
|
||||
-- subscriber, and the seam is driven through the public hook API.
|
||||
|
||||
local Events = require("src.mods.Events")
|
||||
local Hooks = require("src.mods.Hooks")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
|
||||
-- Counts the GainedText boxes an award produced.
|
||||
local function expLines(events)
|
||||
local out = {}
|
||||
for _, event in ipairs(events) do
|
||||
if event.kind == "experience" then out[#out + 1] = event.text end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
local function kindsOf(events)
|
||||
local out = {}
|
||||
for _, event in ipairs(events) do out[#out + 1] = event.kind end
|
||||
return table.concat(out, ",")
|
||||
end
|
||||
|
||||
-- ---- the no-mod test: nothing subscribed, nothing changes -----------------
|
||||
do
|
||||
local _, events = award({ { otId = PLAYER_ID, participant = true } })
|
||||
eq(#expLines(events), 1, "no subscriber: a solo participant still prints one line")
|
||||
|
||||
local _, twoPass = award({
|
||||
{ otId = PLAYER_ID, participant = true },
|
||||
{ otId = PLAYER_ID, item = "EXP_SHARE" },
|
||||
})
|
||||
eq(#expLines(twoPass), 2,
|
||||
"no subscriber: the EXP.SHARE double pass still prints both lines")
|
||||
end
|
||||
|
||||
-- ---- the mod-API test: driven through hooks:wrap, not internals -----------
|
||||
do
|
||||
local savedEvents, savedHooks = Runtime.events, Runtime.hooks
|
||||
local hooks = Hooks.new()
|
||||
Runtime.install(Events.new(), hooks)
|
||||
|
||||
-- Each case pays a two-mon party through ctx.applyShare and reports the
|
||||
-- GainedText boxes that survived. The party mon in slot 2 never fights, so
|
||||
-- it stands in for the bench a party-wide mod pays.
|
||||
local function awardVia(payer)
|
||||
local unsub = hooks:wrap("battle.exp_award", function(_nextFn, ctx)
|
||||
payer(ctx)
|
||||
end)
|
||||
local gained, events, party = award({
|
||||
{ otId = PLAYER_ID, participant = true },
|
||||
{ otId = PLAYER_ID },
|
||||
})
|
||||
unsub()
|
||||
return gained, events, party
|
||||
end
|
||||
|
||||
-- omitted: the Gen 2-era call, which has always announced
|
||||
local gained, events = awardVia(function(ctx)
|
||||
ctx.applyShare(ctx.battle.party[1], 1)
|
||||
ctx.applyShare(ctx.battle.party[2], 1)
|
||||
end)
|
||||
eq(#expLines(events), 2,
|
||||
"applyShare(mon, split) still announces -- no existing mod changes")
|
||||
check(gained[1] > 0 and gained[2] > 0, "and both mons were paid")
|
||||
|
||||
-- passed nil: "pay this one quietly", Gen 1's reading
|
||||
local quietGained, quietEvents = awardVia(function(ctx)
|
||||
ctx.applyShare(ctx.battle.party[1], 1, true)
|
||||
ctx.applyShare(ctx.battle.party[2], 1, nil)
|
||||
end)
|
||||
eq(#expLines(quietEvents), 1,
|
||||
"an explicit nil third argument pays the mon silently")
|
||||
eq(expLines(quietEvents)[1], "MACHOP gained 110 EXP. Points!",
|
||||
"and the announced participant keeps its own line")
|
||||
check(quietGained[2] > 0, "the silent mon is still paid the same exp")
|
||||
eq(quietGained[1], gained[1], "and the announced mon's exp is untouched")
|
||||
eq(quietGained[2], gained[2], "as is the silent one's")
|
||||
|
||||
-- false reads the same as nil; a truthy string is Gen 1's EXP.ALL variant
|
||||
local _, falseEvents = awardVia(function(ctx)
|
||||
ctx.applyShare(ctx.battle.party[1], 1, false)
|
||||
end)
|
||||
eq(#expLines(falseEvents), 0, "false is silent too")
|
||||
|
||||
local _, expAllEvents = awardVia(function(ctx)
|
||||
ctx.applyShare(ctx.battle.party[1], 1, "expAll")
|
||||
end)
|
||||
eq(#expLines(expAllEvents), 1,
|
||||
"any truthy value announces, so Gen 1's \"expAll\" carries over")
|
||||
|
||||
-- a silent award is still a whole award: only the line goes
|
||||
local _, levelEvents, levelParty = awardVia(function(ctx)
|
||||
ctx.applyShare(ctx.battle.party[2], 1, nil)
|
||||
end)
|
||||
eq(#expLines(levelEvents), 0, "the silent pass prints no GainedText")
|
||||
check(levelParty[2].statExp.attack > 0,
|
||||
"but stat exp is still awarded on a silent pass")
|
||||
check(not kindsOf(levelEvents):find("experience", 1, true),
|
||||
"and no experience event leaks into the queue")
|
||||
|
||||
Runtime.install(savedEvents, savedHooks)
|
||||
end
|
||||
|
||||
S.finish()
|
||||
|
||||
@@ -483,6 +483,99 @@ packInput:press("right")
|
||||
pack:update(0)
|
||||
check("pocket wraps around", pack:pocket().id, "ITEM")
|
||||
|
||||
-- engine/items/tmhm.asm:341 -- TMHM_DisplayPocketItems walks wTMsHMs 1..57, so
|
||||
-- the pocket is TM01..TM50 then HM01..HM07 whatever order the player picked
|
||||
-- them up in, and tmhm.asm:207 keeps SELECT out of it entirely.
|
||||
do
|
||||
local tmSave2 = Save.newGame()
|
||||
tmSave2.inventory = {
|
||||
HM_WATERFALL = 1, TM_NIGHTMARE = 2, HM_CUT = 1, TM_ROAR = 3,
|
||||
TM_DYNAMICPUNCH = 12, TM_LEGACY = 1,
|
||||
}
|
||||
tmSave2.bagOrder = {
|
||||
"HM_WATERFALL", "TM_NIGHTMARE", "HM_CUT", "TM_ROAR", "TM_DYNAMICPUNCH",
|
||||
"TM_LEGACY",
|
||||
}
|
||||
local tmGame2, tmInput2 = newGame(tmSave2)
|
||||
tmGame2.data.items = {
|
||||
TM_DYNAMICPUNCH = { id = "TM_DYNAMICPUNCH", name = "TM01",
|
||||
pocket = "TM_HM", index = 191, tmNumber = 1 },
|
||||
TM_ROAR = { id = "TM_ROAR", name = "TM05", pocket = "TM_HM",
|
||||
index = 195, tmNumber = 5 },
|
||||
TM_NIGHTMARE = { id = "TM_NIGHTMARE", name = "TM50", pocket = "TM_HM",
|
||||
index = 240, tmNumber = 50 },
|
||||
HM_CUT = { id = "HM_CUT", name = "HM01", pocket = "TM_HM",
|
||||
index = 241, tmNumber = 51 },
|
||||
HM_WATERFALL = { id = "HM_WATERFALL", name = "HM07", pocket = "TM_HM",
|
||||
index = 247, tmNumber = 57 },
|
||||
-- A cache (or a mod) with no tmNumber falls back to the ItemNames index.
|
||||
TM_LEGACY = { id = "TM_LEGACY", name = "TM??", pocket = "TM_HM",
|
||||
index = 300 },
|
||||
}
|
||||
local tmPack = PackMenu.new(tmGame2, { pocket = "TM_HM" })
|
||||
local function ids(rows)
|
||||
local out = {}
|
||||
for i = 1, #rows do out[i] = rows[i].id end
|
||||
return table.concat(out, ",")
|
||||
end
|
||||
check("TM/HM pocket is TM-number ordered, not pickup ordered",
|
||||
ids(tmPack.rows),
|
||||
"TM_DYNAMICPUNCH,TM_ROAR,TM_NIGHTMARE,HM_CUT,HM_WATERFALL,TM_LEGACY")
|
||||
check("the first row is TM01", tmPack.rows[1].id, "TM_DYNAMICPUNCH")
|
||||
check("and a tmNumber-less item lands after HM07",
|
||||
tmPack.rows[#tmPack.rows].id, "TM_LEGACY")
|
||||
check("the TM keeps its count", tmPack.rows[1].showCount, true)
|
||||
check("the HM shows none", tmPack.rows[4].showCount, false)
|
||||
|
||||
tmPack.index = 1
|
||||
tmInput2:press("select")
|
||||
tmPack:update(0)
|
||||
check("SELECT cannot arm a TM/HM row", tmPack.switching, nil)
|
||||
check("and prints no move prompt", tmPack.message, nil)
|
||||
|
||||
-- The other three pockets still reorder on SELECT (pack.asm:1290).
|
||||
tmSave2.inventory.POTION = 1
|
||||
tmSave2.inventory.SUPER_POTION = 1
|
||||
tmSave2.bagOrder = { "POTION", "SUPER_POTION" }
|
||||
tmGame2.data.items.POTION =
|
||||
{ id = "POTION", name = "POTION", pocket = "ITEM", index = 1 }
|
||||
tmGame2.data.items.SUPER_POTION =
|
||||
{ id = "SUPER_POTION", name = "SUPER POTION", pocket = "ITEM", index = 2 }
|
||||
tmPack.pocketIndex = 1
|
||||
tmPack:rebuild()
|
||||
tmPack.index = 1
|
||||
tmInput2:press("select")
|
||||
tmPack:update(0)
|
||||
check("but the ITEM pocket still arms", tmPack.switching, 1)
|
||||
end
|
||||
|
||||
-- item_data_constants.asm:47 MAX_ITEMS / MAX_BALLS / MAX_KEY_ITEMS, and the
|
||||
-- TM/HM pocket is wTMsHMs (ram/wram.asm:2421), NUM_TMS + NUM_HMS = 57 bytes:
|
||||
-- 50 add_tm rows and 7 add_hm rows in constants/item_constants.asm:220-293.
|
||||
do
|
||||
local Bag = require("src.inventory.Bag")
|
||||
check("ITEM pocket is MAX_ITEMS", Bag.capacity(packGame.data, "ITEM"), 20)
|
||||
check("BALL pocket is MAX_BALLS", Bag.capacity(packGame.data, "BALL"), 12)
|
||||
check("KEY_ITEM pocket is MAX_KEY_ITEMS",
|
||||
Bag.capacity(packGame.data, "KEY_ITEM"), 25)
|
||||
check("TM/HM pocket is NUM_TMS + NUM_HMS",
|
||||
Bag.capacity(packGame.data, "TM_HM"), 57)
|
||||
|
||||
-- Bag.add tests the cap before inserting, so all 57 cart TM/HMs fit.
|
||||
local tmData = { items = {}, constants = { bagSize = 2 } }
|
||||
for i = 1, 58 do
|
||||
tmData.items["TM_FIX_" .. i] = { id = "TM_FIX_" .. i, name = "TM" .. i,
|
||||
pocket = "TM_HM", index = 200 + i }
|
||||
end
|
||||
check("a mod's bagSize resizes the ITEM pocket only",
|
||||
Bag.capacity(tmData, "TM_HM"), 57)
|
||||
local tmSave = { inventory = {}, bagOrder = {} }
|
||||
for i = 1, 57 do Bag.add(tmSave, "TM_FIX_" .. i, 1, tmData) end
|
||||
check("all 57 of them fit", Bag.slots(tmSave, tmData, "TM_HM"), 57)
|
||||
check("and a 58th TM/HM id has no byte to live in",
|
||||
Bag.add(tmSave, "TM_FIX_58", 1, tmData), false)
|
||||
end
|
||||
|
||||
-- CANCEL sits one past the last row.
|
||||
check("cancel is past the end", pack:total(), #pack.rows + 1)
|
||||
pack.index = pack:total()
|
||||
|
||||
@@ -310,7 +310,8 @@ do
|
||||
eq(save.inventory.TM_HEADBUTT, nil, "and leaves the bag")
|
||||
end
|
||||
|
||||
-- .TryDepositItem's .no_toss: a KEY ITEM stays in the bag, silently.
|
||||
-- .DepositItem: CANT_TOSS only skips .AskQuantity. A KEY ITEM deposits x1,
|
||||
-- and .Submenu withdraws it back the same way (issue #1486).
|
||||
do
|
||||
local save = newSave(1)
|
||||
local game, input = newGame(save)
|
||||
@@ -320,9 +321,63 @@ do
|
||||
press(pc, input, "right", "right") -- ITEM -> BALL -> KEY_ITEM pocket
|
||||
press(pc, input, "a") -- choose the BICYCLE
|
||||
eq(pc.qtyState, nil, "no quantity selector for a KEY ITEM")
|
||||
eq(pc.message, nil, "no message either: .no_toss is a bare ret")
|
||||
eq(save.pcItems.BICYCLE, nil, "and the BICYCLE never leaves the bag")
|
||||
eq(save.inventory.BICYCLE, 1, "still there")
|
||||
eq(save.pcItems.BICYCLE, 1, "the BICYCLE lands in the PC")
|
||||
eq(save.inventory.BICYCLE, nil, "and leaves the bag")
|
||||
eq(pc.message.pages[1][1], "Deposited 1", "_PlayersPCDepositItemsText")
|
||||
press(pc, input, "a") -- clear it
|
||||
press(pc, input, "b") -- close the PACK
|
||||
press(pc, input, "up", "a") -- WITHDRAW ITEM
|
||||
eq(pc.phase, "withdraw", "the PC item list opens on the KEY ITEM")
|
||||
press(pc, input, "a") -- the BICYCLE row
|
||||
eq(pc.qtyState, nil, "no quantity selector on the way back either")
|
||||
eq(save.inventory.BICYCLE, 1, "the BICYCLE is back in the bag")
|
||||
eq(save.pcItems.BICYCLE, nil, "and out of the PC")
|
||||
end
|
||||
|
||||
-- The TM_HM pocket's HMs are CANT_TOSS too, and deposit prompt-free x1.
|
||||
do
|
||||
local save = newSave(1)
|
||||
local game, input = newGame(save)
|
||||
Bag.add(save, "HM_CUT", 1, game.data)
|
||||
local pc = ItemPcMenu.new(game, { save = save, items = ITEMS })
|
||||
press(pc, input, "down", "a") -- DEPOSIT ITEM
|
||||
press(pc, input, "right", "right", "right") -- ITEM -> BALL -> KEY_ITEM -> TM_HM
|
||||
press(pc, input, "a") -- choose HM01
|
||||
eq(pc.qtyState, nil, "no quantity selector for an HM")
|
||||
eq(save.pcItems.HM_CUT, 1, "HM01 lands in the PC")
|
||||
eq(save.inventory.HM_CUT, nil, "and leaves the bag")
|
||||
end
|
||||
|
||||
-- PlaceMenuItemQuantity: the PC list draws no xNN for a CANT_TOSS row.
|
||||
do
|
||||
local save = newSave(1)
|
||||
local game = newGame(save)
|
||||
save.pcItems = { POTION = 3, HM_CUT = 1 }
|
||||
local pc = ItemPcMenu.new(game, { save = save, items = ITEMS })
|
||||
local Chrome = require("src.ui.gen2.Chrome")
|
||||
local TIMES = "\xc3\x97"
|
||||
local saved = { print = Chrome.print, box = Chrome.box,
|
||||
cursor = Chrome.cursor }
|
||||
local printed = {}
|
||||
Chrome.print = function(text, x, y)
|
||||
printed[#printed + 1] = { text = text, x = x, y = y }
|
||||
end
|
||||
Chrome.box = function() end
|
||||
Chrome.cursor = function() end
|
||||
pc.phase = "withdraw"
|
||||
pc:rebuild()
|
||||
local drew, err = pcall(function() pc:drawList() end)
|
||||
Chrome.print, Chrome.box, Chrome.cursor = saved.print, saved.box, saved.cursor
|
||||
check(drew, "the PC list draws: " .. tostring(err))
|
||||
local rowY = {}
|
||||
for i, row in ipairs(pc.rows) do rowY[row.id] = i * 2 end
|
||||
local counts = {}
|
||||
for _, p in ipairs(printed) do
|
||||
if p.x == 7 then counts[p.y] = p.text end
|
||||
end
|
||||
eq(counts[rowY.POTION + 1], TIMES .. " 3", "the POTION stack keeps its xNN")
|
||||
eq(counts[rowY.HM_CUT + 1], nil,
|
||||
"and the HM row draws none (PlaceMenuItemQuantity .done)")
|
||||
end
|
||||
|
||||
-- An empty bag never opens the PACK (.CheckItemsInBag).
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
-- T4: a mod may not reach a Gen 2 engine module from a Gen 1 game.
|
||||
--
|
||||
-- src/battle/gen2/Mon.lua returns { hp, attack, defense, speed, specialAttack,
|
||||
-- specialDefense }; a Gen 1 party mon carries `special` instead. A Yellow mod
|
||||
-- that required that module and ran refreshStats over save.party wrote the Gen
|
||||
-- 2 block onto Gen 1 mons, and every reader of stats.special raised from then
|
||||
-- on (#1517). The require shim refuses it now, whatever the manifest declares:
|
||||
-- engine_internals is a disclosure, not a generation gate.
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.modkit")
|
||||
|
||||
local PROBE = [[
|
||||
local mod = ...
|
||||
local out = mod.exports
|
||||
local function attempt(name)
|
||||
local ok, result = pcall(require, name)
|
||||
if ok then return nil, type(result) end
|
||||
return tostring(result), nil
|
||||
end
|
||||
out.attempt = attempt
|
||||
out.monErr, out.monType = attempt("src.battle.gen2.Mon")
|
||||
out.saveErr = attempt("src.core.gen2.Save")
|
||||
out.worldErr = attempt("src.ui.gen2.Chrome")
|
||||
out.game2Err = attempt("src.core.Game2")
|
||||
out.semverErr, out.semverType = attempt("src.mods.Semver")
|
||||
out.statsErr, out.statsType = attempt("src.pokemon.Stats")
|
||||
out.compatErr, out.compatType = attempt("src.mods.Gen2Compat")
|
||||
out.loggerErr, out.loggerType = attempt("src.core.Logger")
|
||||
]]
|
||||
|
||||
local function manifest(id, permissions)
|
||||
return ('{"id":"%s","name":"%s","version":"1.0.0","entry":"main.lua",'
|
||||
.. '"api":2,"games":["all"]%s}'):format(id, id, permissions or "")
|
||||
end
|
||||
|
||||
-- ------- Gen 1 boot: refused, and the permission does not unlock it
|
||||
|
||||
local FILES = {
|
||||
["mods/gen2_declared/manifest.json"] =
|
||||
manifest("gen2_declared", ',"permissions":["engine_internals"]'),
|
||||
["mods/gen2_declared/main.lua"] = PROBE,
|
||||
["mods/gen2_undeclared/manifest.json"] = manifest("gen2_undeclared"),
|
||||
["mods/gen2_undeclared/main.lua"] = PROBE,
|
||||
}
|
||||
|
||||
T.eq(package.loaded["src.battle.gen2.Mon"], nil,
|
||||
"the Gen 2 mon module is not loaded before the Gen 1 boot")
|
||||
|
||||
local run = T.sdk.loadMods({ "mods/gen2_declared", "mods/gen2_undeclared" },
|
||||
{ fs = T.sdk.memfs(FILES), data = {}, generation = 1 })
|
||||
T.eq(#run.errors, 0,
|
||||
"a mod that only probes still loads clean (" .. tostring(run.errors[1]) .. ")")
|
||||
|
||||
local declared = run.loader.exports.gen2_declared or {}
|
||||
local undeclared = run.loader.exports.gen2_undeclared or {}
|
||||
|
||||
T.check(declared.monErr, "src.battle.gen2.Mon is refused on a Gen 1 game")
|
||||
T.check(declared.monErr and declared.monErr:find("src.battle.gen2.Mon", 1, true),
|
||||
"the refusal names the module: " .. tostring(declared.monErr))
|
||||
T.check(declared.monErr and declared.monErr:find("gen2_declared", 1, true),
|
||||
"and the mod that asked for it")
|
||||
T.check(declared.monErr and declared.monErr:find("mod.game", 1, true),
|
||||
"and the surface to use instead")
|
||||
T.eq(declared.monType, nil, "nothing came back for the mod to call")
|
||||
T.eq(package.loaded["src.battle.gen2.Mon"], nil,
|
||||
"and the module was never loaded at all")
|
||||
|
||||
T.check(declared.saveErr, "src.core.gen2.Save is refused the same way")
|
||||
T.check(declared.worldErr, "so is src.ui.gen2.Chrome")
|
||||
T.check(declared.game2Err and declared.game2Err:find("src.core.Game2", 1, true),
|
||||
"and src.core.Game2, the Gen 2 service owner: " .. tostring(declared.game2Err))
|
||||
|
||||
T.check(undeclared.monErr,
|
||||
"a mod with no permissions is refused for the same reason")
|
||||
T.eq(declared.monErr and declared.monErr:gsub("gen2_declared", "X"),
|
||||
undeclared.monErr and undeclared.monErr:gsub("gen2_undeclared", "X"),
|
||||
"engine_internals is a disclosure, not a generation gate: same refusal")
|
||||
|
||||
-- ------- what the gate does NOT touch
|
||||
|
||||
T.eq(declared.semverErr, nil, "the supported requires still resolve")
|
||||
T.eq(declared.semverType, "table", "and hand back the module")
|
||||
T.eq(declared.statsErr, nil, "src.pokemon.Stats is still published to mods")
|
||||
T.eq(declared.compatErr, nil,
|
||||
"src.mods.Gen2Compat is not a Gen 2 engine module: it is the adapter table")
|
||||
T.eq(declared.compatType, "table", "so it still answers")
|
||||
T.eq(declared.loggerErr, nil,
|
||||
"an undeclared engine require is still a warning, not a block")
|
||||
T.eq(declared.loggerType, "table", "and still resolves")
|
||||
T.eq(undeclared.loggerErr, nil,
|
||||
"including for a mod that declared no permissions at all")
|
||||
|
||||
-- a lazy require made long after the entry chunk ran is gated too: the mod's
|
||||
-- own require is what the shim identifies, not the load phase
|
||||
do
|
||||
local err = declared.attempt and declared.attempt("src.world.gen2.World")
|
||||
T.check(err and err:find("src.world.gen2.World", 1, true),
|
||||
"a require made after load is refused too: " .. tostring(err))
|
||||
end
|
||||
|
||||
-- the shim only judges a mod's own require; engine and harness callers keep
|
||||
-- the module they asked for
|
||||
do
|
||||
local ok, module = pcall(require, "src.world.gen2.WorldAPI")
|
||||
T.check(ok, "a non-mod caller still reaches a Gen 2 module: " .. tostring(module))
|
||||
T.eq(type(module), "table", "and gets the real one")
|
||||
end
|
||||
|
||||
run.release()
|
||||
|
||||
-- ------- an unguarded reach fails the whole mod, on the boot error feed
|
||||
|
||||
local HARD = {
|
||||
["mods/gen2_hard/manifest.json"] = manifest("gen2_hard",
|
||||
',"permissions":["engine_internals"]'),
|
||||
["mods/gen2_hard/main.lua"] = [[
|
||||
local mod = ...
|
||||
local Mon = require("src.battle.gen2.Mon")
|
||||
mod.exports.reached = Mon ~= nil
|
||||
]],
|
||||
}
|
||||
local hard = T.sdk.loadMods({ "mods/gen2_hard" },
|
||||
{ fs = T.sdk.memfs(HARD), data = {}, generation = 1 })
|
||||
T.eq(hard.loader.mods.gen2_hard.state, "failed",
|
||||
"a mod that reaches across the generation does not load")
|
||||
T.eq(hard.loader.exports.gen2_hard, nil, "and publishes nothing")
|
||||
T.check(hard.errors[1] and hard.errors[1]:find("src.battle.gen2.Mon", 1, true),
|
||||
"the boot error feed names the module: " .. tostring(hard.errors[1]))
|
||||
hard.release()
|
||||
|
||||
-- ------- Gen 2 boot: the same require is the mod's own generation
|
||||
|
||||
local gen2 = T.sdk.loadMods({ "mods/gen2_declared" },
|
||||
{ fs = T.sdk.memfs(FILES), data = {}, generation = 2 })
|
||||
local onGold = gen2.loader.exports.gen2_declared or {}
|
||||
T.eq(onGold.monErr, nil,
|
||||
"on a Gen 2 game the same require is answered: " .. tostring(onGold.monErr))
|
||||
T.eq(onGold.monType, "table", "with the real Gen 2 mon module")
|
||||
T.eq(onGold.game2Err, nil, "and src.core.Game2 is this game's service owner")
|
||||
gen2.release()
|
||||
|
||||
T.finish("cross_generation_require")
|
||||
@@ -311,6 +311,46 @@ ow.transitioning = false -- undo the queued warp transition
|
||||
Game.save.onBike = false
|
||||
Game.save.inventory.BICYCLE = nil
|
||||
|
||||
-- home/overworld.asm:1224
|
||||
local function settle(o)
|
||||
drainText()
|
||||
for _ = 1, 60 do o:updateScriptMoves(); o.player:update() end
|
||||
end
|
||||
for _, c in ipairs({ { "ROUTE_16", 17, 10 }, { "ROUTE_16", 17, 11 },
|
||||
{ "ROUTE_18", 33, 8 }, { "ROUTE_18", 33, 9 } }) do
|
||||
Game.save.inventory.BICYCLE = nil
|
||||
Game.save.onBike, Game.save.forcedBike = false, nil
|
||||
pushOW(c[1], c[2], c[3], "left")
|
||||
ow = OW
|
||||
check(not ow.map:isWalkableCell(c[2] + 1, c[3]),
|
||||
("%s (%d,%d): the cell behind a left-facing arrival is the gate wall")
|
||||
:format(c[1], c[2], c[3]))
|
||||
settle(ow)
|
||||
eq(#ow.scriptMoves, 0,
|
||||
("%s (%d,%d): the refusal shove settles"):format(c[1], c[2], c[3]))
|
||||
check(ow.map:isWalkableCell(ow.player.cellX, ow.player.cellY),
|
||||
("%s (%d,%d): the bikeless Cycling Road refusal never shoves into the "
|
||||
.. "gate wall"):format(c[1], c[2], c[3]))
|
||||
end
|
||||
|
||||
Game.save.onBike, Game.save.forcedBike = false, nil
|
||||
pushOW("ROUTE_16", 17, 10, "right")
|
||||
ow = OW
|
||||
settle(ow)
|
||||
eq(ow.player.cellX, 16, "an unobstructed refusal shove still moves one cell")
|
||||
eq(ow.player.cellY, 10, "and only along the arrival axis")
|
||||
|
||||
Game.save.onBike, Game.save.forcedBike = false, nil
|
||||
ow:setMap("ROUTE_16", 18, 10, "left", { via = "boot" })
|
||||
check(ow.map:isWalkableCell(ow.player.cellX, ow.player.cellY),
|
||||
"a save parked inside the Route 16 gate wall is lifted out on load")
|
||||
eq(ow.player.cellY, 10, "the repair stays on the row it was saved on")
|
||||
settle(ow)
|
||||
check(ow.map:isWalkableCell(ow.player.cellX, ow.player.cellY),
|
||||
"and the refusal it lands on cannot push it back in")
|
||||
popToOW()
|
||||
Game.save.onBike, Game.save.forcedBike = false, nil
|
||||
|
||||
-- Seafoam B4F: only the stairs square (dbmapcoord 7,11) refuses, and only
|
||||
-- until BOTH boulders are down (CheckBothEventsSet)
|
||||
Game.save.flags["EVENT_SEAFOAM4_BOULDER1_DOWN_HOLE"] = nil
|
||||
|
||||
@@ -42,7 +42,8 @@ local function trace(tb)
|
||||
if row.text then
|
||||
out[#out + 1] = { text = (row.text:gsub("\n", " ")) }
|
||||
elseif row.anim then
|
||||
out[#out + 1] = { anim = row.anim, isPlayer = row.attackerIsPlayer }
|
||||
out[#out + 1] = { anim = row.anim, isPlayer = row.attackerIsPlayer,
|
||||
hit = row.hit }
|
||||
elseif row.drain then
|
||||
out[#out + 1] = { drain = true }
|
||||
end
|
||||
@@ -95,6 +96,9 @@ do
|
||||
check(spiral ~= nil, "the storing turn plays XSTATITEM_ANIM")
|
||||
eq(rows[spiral] and rows[spiral].isPlayer, true,
|
||||
"on the player's side of the field")
|
||||
-- effects.asm:1461-1471
|
||||
eq(rows[spiral] and rows[spiral].hit and rows[spiral].hit.animType, 6,
|
||||
"PlayBattleAnimation2 stamps wAnimationType 6 on the player's turn (#1564)")
|
||||
local used = indexOfText(rows, "used BIDE!")
|
||||
local storing = indexOfText(rows, "storing energy!")
|
||||
check(used and spiral and used < spiral,
|
||||
@@ -119,6 +123,8 @@ do
|
||||
local dup = indexOfAnim(rows, "XSTATITEM_DUPLICATE_ANIM")
|
||||
check(dup ~= nil, "the foe's storing turn plays XSTATITEM_DUPLICATE_ANIM")
|
||||
check(dup and not rows[dup].isPlayer, "attributed to the enemy side")
|
||||
eq(rows[dup] and rows[dup].hit and rows[dup].hit.animType, 3,
|
||||
"and wAnimationType 3 on the enemy's turn (#1564)")
|
||||
end
|
||||
|
||||
-- locked turns: .BideCheck decrements with no text of its own in pokered and
|
||||
@@ -151,6 +157,10 @@ do
|
||||
check(unleashed ~= nil, "the release prints UnleashedEnergyText")
|
||||
check(hit ~= nil, "the release plays BIDE's own animation (#375)")
|
||||
eq(rows[hit] and rows[hit].isPlayer, true, "from the player's side")
|
||||
-- core.asm:3526-3528 -> effects.asm:1476-1477
|
||||
check(rows[hit] and rows[hit].hit == nil,
|
||||
".UnleashEnergy rejoins PlayCurrentMoveAnimation, which zeroes "
|
||||
.. "wAnimationType")
|
||||
check(unleashed and hit and unleashed < hit,
|
||||
"the animation follows the text")
|
||||
local drainAt
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
-- #1274: with several cartridge dumps sitting in the save directory, Choose
|
||||
-- ROM imported whichever version was not yet ready rather than the one the
|
||||
-- player picked. Select Red, get Blue.
|
||||
--
|
||||
-- The fallback exists for devices with no file picker (handheld Linux with
|
||||
-- no zenity or kdialog, and the Android USB-drop path), where chooseRom
|
||||
-- returns nil and the importer scans the save directory instead. That scan,
|
||||
-- findPendingRom, answered with the first dump whose SHA-1 mapped to any
|
||||
-- not-yet-ready version, so the selection was dropped on the floor.
|
||||
--
|
||||
-- Self-contained: `luajit tests/rom_importer_choose_version_test.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 honours the chosen version")
|
||||
local eq = S.eq
|
||||
local check = S.check
|
||||
|
||||
local RomImporter = require("src.import.RomImporter")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
|
||||
local ROM_BYTES = 1024 * 1024
|
||||
|
||||
-- One dump per version, each a blob of the right size whose first byte says
|
||||
-- which cart it is. love.data.hash is stubbed to answer with the real SHA-1
|
||||
-- from GameVersion, so GameVersion.forSha1 does its own lookup unchanged.
|
||||
local MARK = { R = "red", B = "blue", Y = "yellow" }
|
||||
local FILES = {
|
||||
["blue.gb"] = string.rep("B", ROM_BYTES),
|
||||
["red.gb"] = string.rep("R", ROM_BYTES),
|
||||
["yellow.gbc"] = string.rep("Y", ROM_BYTES),
|
||||
}
|
||||
-- Deliberately not alphabetical and not selection order: the bug returned
|
||||
-- whatever getDirectoryItems listed first.
|
||||
local LISTING = { "blue.gb", "red.gb", "yellow.gbc" }
|
||||
|
||||
love.system = love.system or {}
|
||||
love.data = love.data or {}
|
||||
|
||||
local saved = {
|
||||
getOS = love.system.getOS,
|
||||
hash = love.data.hash,
|
||||
encode = love.data.encode,
|
||||
getDirectoryItems = love.filesystem.getDirectoryItems,
|
||||
getInfo = love.filesystem.getInfo,
|
||||
read = love.filesystem.read,
|
||||
getSaveDirectory = love.filesystem.getSaveDirectory,
|
||||
}
|
||||
|
||||
-- Not OS X, Windows or Linux, so chooseRom returns nil without shelling out
|
||||
-- to a dialog that may or may not exist on the machine running the suite.
|
||||
-- The fallback under test is the same one every pickerless device takes.
|
||||
love.system.getOS = function() return "Unknown" end
|
||||
love.filesystem.getSaveDirectory = function() return "/tmp/pokemon-love2d" end
|
||||
love.filesystem.getDirectoryItems = function() return LISTING end
|
||||
love.filesystem.getInfo = function(name, filter)
|
||||
if FILES[name] then return { type = "file", size = #FILES[name] } end
|
||||
return nil
|
||||
end
|
||||
love.filesystem.read = function(name) return FILES[name] end
|
||||
-- RomImporter's sha1 helper is hash then encode-to-hex, so the pair is
|
||||
-- stubbed together: hash answers with the digest already in the form
|
||||
-- GameVersion stores, and encode hands it back untouched.
|
||||
love.data.hash = function(_, data)
|
||||
local version = MARK[data:sub(1, 1)]
|
||||
return version and GameVersion.info(version).sha1 or "0"
|
||||
end
|
||||
love.data.encode = function(_, _, digest) return digest end
|
||||
|
||||
local function freshImporter()
|
||||
return setmetatable({
|
||||
android = false,
|
||||
nativePicker = false,
|
||||
workState = nil,
|
||||
ready = { red = false, blue = false, yellow = false },
|
||||
notice = nil,
|
||||
modNotice = nil,
|
||||
saveNotice = {},
|
||||
baseRoms = {},
|
||||
chooseVersion = nil,
|
||||
startData = function(self, data, displayName)
|
||||
self._started = { data = data, name = displayName }
|
||||
end,
|
||||
startPath = function(self, path) self._startedPath = path end,
|
||||
}, RomImporter)
|
||||
end
|
||||
|
||||
-- ------- the report: pick Red, get Red
|
||||
|
||||
local ri = freshImporter()
|
||||
ri:choose("red")
|
||||
check(ri._started ~= nil, "a pickerless Choose still imports from the save dir")
|
||||
eq(ri._started and ri._started.name, "red.gb",
|
||||
"and it imports the cart that was chosen, not the first pending one")
|
||||
|
||||
-- ------- the same for a version listed after another pending dump
|
||||
|
||||
ri = freshImporter()
|
||||
ri:choose("yellow")
|
||||
eq(ri._started and ri._started.name, "yellow.gbc",
|
||||
"Yellow is imported for Yellow, with Blue and Red still pending")
|
||||
|
||||
-- ------- a chosen version with no dump present imports nothing
|
||||
|
||||
ri = freshImporter()
|
||||
ri.ready = { red = false, blue = false, yellow = false, gold = false }
|
||||
ri:choose("gold")
|
||||
check(ri._started == nil,
|
||||
"choosing a version whose dump is absent imports no other cart")
|
||||
|
||||
-- ------- an already-imported cart is still never re-extracted (#167)
|
||||
|
||||
ri = freshImporter()
|
||||
ri.ready = { red = true, blue = false, yellow = false }
|
||||
ri:choose("red")
|
||||
check(ri._started == nil,
|
||||
"and a version already imported is not extracted a second time")
|
||||
|
||||
for name, fn in pairs(saved) do
|
||||
if name == "getOS" then love.system.getOS = fn
|
||||
elseif name == "hash" then love.data.hash = fn
|
||||
elseif name == "encode" then love.data.encode = fn
|
||||
else love.filesystem[name] = fn end
|
||||
end
|
||||
|
||||
S.finish()
|
||||
@@ -3623,6 +3623,8 @@ 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" })
|
||||
-- the same pickerless scan, asked for one version in particular (#1274)
|
||||
runSuites({ "tests/rom_importer_choose_version_test.lua" })
|
||||
-- ---------------------------------------------- Switch platform capabilities
|
||||
-- platform_nx_* / rom_importer_nx_* live in tests/engine/ (ROM-free T2) so
|
||||
-- CI's headless lane runs them without data/generated/.
|
||||
|
||||
@@ -699,6 +699,9 @@ REQUIRED_SYMBOLS = {
|
||||
# marker without this symbol.
|
||||
"MonMenuIcons", "Icons", "IconPointers", "HeldItemIcons",
|
||||
"PokedexDataPointerTable",
|
||||
# engine/pokemon/bills_pc.asm:2170-2173 (the four vTiles2 $5c tiles
|
||||
# PCMonInfo prints at :1086/:1091) and engine/gfx/cgb_layouts.asm:287.
|
||||
"PCMailGFX", "BillsPCOrangePalette",
|
||||
# New-game / Pokecenter respawn table (data/maps/spawn_points.asm)
|
||||
"SpawnPoints",
|
||||
"NewPokedexOrder", "AlphabeticalPokedexOrder", "Landmarks",
|
||||
|
||||
@@ -12912,6 +12912,10 @@
|
||||
105,
|
||||
16837
|
||||
],
|
||||
"BillsPCOrangePalette": [
|
||||
2,
|
||||
21965
|
||||
],
|
||||
"BlastoiseBackpic": [
|
||||
25,
|
||||
30882
|
||||
@@ -12936,6 +12940,10 @@
|
||||
107,
|
||||
21761
|
||||
],
|
||||
"BoltEmote": [
|
||||
5,
|
||||
17737
|
||||
],
|
||||
"BugCatchingContestantEventFlagTable": [
|
||||
4,
|
||||
32186
|
||||
@@ -12968,6 +12976,30 @@
|
||||
26,
|
||||
26667
|
||||
],
|
||||
"CardFlipLZ01": [
|
||||
56,
|
||||
21795
|
||||
],
|
||||
"CardFlipLZ02": [
|
||||
56,
|
||||
22197
|
||||
],
|
||||
"CardFlipLZ03": [
|
||||
56,
|
||||
21736
|
||||
],
|
||||
"CardFlipOffButtonGFX": [
|
||||
56,
|
||||
21763
|
||||
],
|
||||
"CardFlipOnButtonGFX": [
|
||||
56,
|
||||
21779
|
||||
],
|
||||
"CardFlipTilemap": [
|
||||
56,
|
||||
22809
|
||||
],
|
||||
"CardStatusGFX": [
|
||||
9,
|
||||
22287
|
||||
@@ -13612,6 +13644,10 @@
|
||||
106,
|
||||
19800
|
||||
],
|
||||
"FishEmote": [
|
||||
5,
|
||||
17865
|
||||
],
|
||||
"FishGroups": [
|
||||
36,
|
||||
27127
|
||||
@@ -13956,6 +13992,10 @@
|
||||
4,
|
||||
26664
|
||||
],
|
||||
"HeartEmote": [
|
||||
5,
|
||||
17673
|
||||
],
|
||||
"HeldItemIcons": [
|
||||
35,
|
||||
26843
|
||||
@@ -15420,6 +15460,10 @@
|
||||
5,
|
||||
18398
|
||||
],
|
||||
"PCMailGFX": [
|
||||
56,
|
||||
31768
|
||||
],
|
||||
"PackGFX": [
|
||||
4,
|
||||
21553
|
||||
@@ -16096,22 +16140,6 @@
|
||||
5,
|
||||
17417
|
||||
],
|
||||
"HeartEmote": [
|
||||
5,
|
||||
17673
|
||||
],
|
||||
"BoltEmote": [
|
||||
5,
|
||||
17737
|
||||
],
|
||||
"SleepEmote": [
|
||||
5,
|
||||
17801
|
||||
],
|
||||
"FishEmote": [
|
||||
5,
|
||||
17865
|
||||
],
|
||||
"Shrink1Pic": [
|
||||
62,
|
||||
30142
|
||||
@@ -16156,6 +16184,26 @@
|
||||
106,
|
||||
22935
|
||||
],
|
||||
"SleepEmote": [
|
||||
5,
|
||||
17801
|
||||
],
|
||||
"Slots1LZ": [
|
||||
36,
|
||||
31138
|
||||
],
|
||||
"Slots2LZ": [
|
||||
36,
|
||||
31522
|
||||
],
|
||||
"Slots3LZ": [
|
||||
36,
|
||||
32130
|
||||
],
|
||||
"SlotsTilemap": [
|
||||
36,
|
||||
30898
|
||||
],
|
||||
"SlowbroBackpic": [
|
||||
26,
|
||||
32198
|
||||
@@ -16356,6 +16404,14 @@
|
||||
105,
|
||||
22509
|
||||
],
|
||||
"StatsScreenPagePals": [
|
||||
2,
|
||||
21715
|
||||
],
|
||||
"StatsScreenPageTilesGFX": [
|
||||
62,
|
||||
19106
|
||||
],
|
||||
"StatsScreen_PlaceFrontpic": [
|
||||
20,
|
||||
20675
|
||||
@@ -17667,46 +17723,6 @@
|
||||
"wBaseUnusedFrontpic": [
|
||||
1,
|
||||
53554
|
||||
],
|
||||
"Slots1LZ": [
|
||||
36,
|
||||
31138
|
||||
],
|
||||
"Slots2LZ": [
|
||||
36,
|
||||
31522
|
||||
],
|
||||
"Slots3LZ": [
|
||||
36,
|
||||
32130
|
||||
],
|
||||
"SlotsTilemap": [
|
||||
36,
|
||||
30898
|
||||
],
|
||||
"CardFlipLZ01": [
|
||||
56,
|
||||
21795
|
||||
],
|
||||
"CardFlipLZ02": [
|
||||
56,
|
||||
22197
|
||||
],
|
||||
"CardFlipLZ03": [
|
||||
56,
|
||||
21736
|
||||
],
|
||||
"CardFlipOnButtonGFX": [
|
||||
56,
|
||||
21779
|
||||
],
|
||||
"CardFlipOffButtonGFX": [
|
||||
56,
|
||||
21763
|
||||
],
|
||||
"CardFlipTilemap": [
|
||||
56,
|
||||
22809
|
||||
]
|
||||
},
|
||||
"tilesets": {
|
||||
|
||||
@@ -12912,6 +12912,10 @@
|
||||
105,
|
||||
16840
|
||||
],
|
||||
"BillsPCOrangePalette": [
|
||||
2,
|
||||
21965
|
||||
],
|
||||
"BlastoiseBackpic": [
|
||||
25,
|
||||
31647
|
||||
@@ -12972,6 +12976,30 @@
|
||||
26,
|
||||
27406
|
||||
],
|
||||
"CardFlipLZ01": [
|
||||
56,
|
||||
21795
|
||||
],
|
||||
"CardFlipLZ02": [
|
||||
56,
|
||||
22197
|
||||
],
|
||||
"CardFlipLZ03": [
|
||||
56,
|
||||
21736
|
||||
],
|
||||
"CardFlipOffButtonGFX": [
|
||||
56,
|
||||
21763
|
||||
],
|
||||
"CardFlipOnButtonGFX": [
|
||||
56,
|
||||
21779
|
||||
],
|
||||
"CardFlipTilemap": [
|
||||
56,
|
||||
22809
|
||||
],
|
||||
"CardStatusGFX": [
|
||||
9,
|
||||
22287
|
||||
@@ -15432,6 +15460,10 @@
|
||||
5,
|
||||
18398
|
||||
],
|
||||
"PCMailGFX": [
|
||||
56,
|
||||
31768
|
||||
],
|
||||
"PackGFX": [
|
||||
4,
|
||||
21553
|
||||
@@ -16156,6 +16188,22 @@
|
||||
5,
|
||||
17801
|
||||
],
|
||||
"Slots1LZ": [
|
||||
36,
|
||||
31138
|
||||
],
|
||||
"Slots2LZ": [
|
||||
36,
|
||||
31522
|
||||
],
|
||||
"Slots3LZ": [
|
||||
36,
|
||||
32130
|
||||
],
|
||||
"SlotsTilemap": [
|
||||
36,
|
||||
30898
|
||||
],
|
||||
"SlowbroBackpic": [
|
||||
26,
|
||||
31900
|
||||
@@ -16356,6 +16404,14 @@
|
||||
105,
|
||||
22501
|
||||
],
|
||||
"StatsScreenPagePals": [
|
||||
2,
|
||||
21715
|
||||
],
|
||||
"StatsScreenPageTilesGFX": [
|
||||
62,
|
||||
19106
|
||||
],
|
||||
"StatsScreen_PlaceFrontpic": [
|
||||
20,
|
||||
20675
|
||||
@@ -17667,46 +17723,6 @@
|
||||
"wBaseUnusedFrontpic": [
|
||||
1,
|
||||
53554
|
||||
],
|
||||
"Slots1LZ": [
|
||||
36,
|
||||
31138
|
||||
],
|
||||
"Slots2LZ": [
|
||||
36,
|
||||
31522
|
||||
],
|
||||
"Slots3LZ": [
|
||||
36,
|
||||
32130
|
||||
],
|
||||
"SlotsTilemap": [
|
||||
36,
|
||||
30898
|
||||
],
|
||||
"CardFlipLZ01": [
|
||||
56,
|
||||
21795
|
||||
],
|
||||
"CardFlipLZ02": [
|
||||
56,
|
||||
22197
|
||||
],
|
||||
"CardFlipLZ03": [
|
||||
56,
|
||||
21736
|
||||
],
|
||||
"CardFlipOnButtonGFX": [
|
||||
56,
|
||||
21779
|
||||
],
|
||||
"CardFlipOffButtonGFX": [
|
||||
56,
|
||||
21763
|
||||
],
|
||||
"CardFlipTilemap": [
|
||||
56,
|
||||
22809
|
||||
]
|
||||
},
|
||||
"tilesets": {
|
||||
|
||||
Reference in New Issue
Block a user