Compare commits

..

23 Commits

Author SHA1 Message Date
bryanthaboi 116a6ba450 Merge pull request #1679 from bryanthaboi/dev 2026-08-21 22:48:58 -04:00
bryanthaboi 478e3bf8eb Merge pull request #1660 from jramiresbrito/fix/gen2-exp-award-announce 2026-08-21 22:35:43 -04:00
bryanthaboi f009fc856c Merge pull request #1678 from colsonrice/fix/choose-rom-imports-the-chosen-version 2026-08-21 22:31:35 -04:00
bryanthaboi 3abc6ceb15 Merge pull request #1673 from colsonrice/fix/gen2-battle-text-matches-cart 2026-08-21 22:28:42 -04:00
bryanthaboi b93d070abc Merge pull request #1627 from AverageConsumer/codex/android-full-apk-updates 2026-08-21 22:24:44 -04:00
Colson Rice 921f565f1d Import the cart Choose ROM asked for
findPendingRom answered with the first dump in the save directory whose SHA-1
mapped to any not-yet-ready version.  On a device with no file picker that
scan IS the import, so with four dumps in the folder, choosing Red imported
and decoded Blue (#1274).

It now takes an optional version and narrows to it.  The two Choose paths
pass self.chooseVersion, since a Choose names the cart it is for.  The two
Android USB-drop scans pass nothing and still take the first pending cart of
any version, which is what they are for.

A chosen version with no dump present now imports nothing rather than the
wrong cart, and falls through to the notice that says where to put the file.
2026-08-21 22:21:41 -04:00
bryanthaboi 6e624724ca Fix the Gold SFX drivers' staging and wait budgets 2026-08-21 22:21:39 -04:00
bryanthaboi 51bc4c7437 Fix the Gold bump driver's staging so it actually measures the bump 2026-08-21 22:00:23 -04:00
bryanthaboi e13271fc85 a little more 2026-08-21 21:33:42 -04:00
bryanthaboi c23f82efdd Fix transition state pop, Gen 2 Bide/Sleep Talk/exp bugs, pp repair, TM pocket capacity and Bill's PC arrows
CLOSES #1663, CLOSES #1664, CLOSES #1665, CLOSES #1666, CLOSES #1667, CLOSES #1668, CLOSES #1670, CLOSES #1672
2026-08-21 21:21:59 -04:00
bryanthaboi 15fc04e067 Collision-check the remaining single-tile player refusal shoves
CLOSES #1548
2026-08-21 19:46:15 -04:00
Colson Rice 61f42cea2b Cover the Gen 2 battle lines against pokegold's own labels
Drives a real turn per case and compares the emitted line to the label in
data/text/battle.asm.  Six of the eleven checks fail against the text as it
stood.

The tail case wraps each changed line through Chrome.wrap at the box's own
width and holds it to the two rows printMessage draws.  That is the check
that caught SpikesText needing a third row, which is why Spikes is not in
this change.
2026-08-21 19:37:41 -04:00
Colson Rice 2c1e411e3c Print the Gen 2 battle lines the cart actually writes
Five messages in the Gen 2 battle code are written by hand rather than taken
from data/text/battle.asm, and each has drifted from what the game prints:

  SuperEffectiveText                     lost its hyphen and its line break
  NotVeryEffectiveText                   ended on three periods, not the
                                         ellipsis glyph the charmap carries
  BattleText_TheresNoPPLeftForThisMove   dropped "There's"
  PlayerHitTimesText/EnemyHitTimesText   printed "Hit 3 time(s)!", showing
                                         the parenthetical on screen; Gen 1
                                         already prints "Hit 3 times!" via
                                         _HitXTimesText
  StartPerishText                        printed a sentence no cart prints

The break is \n, which is what RomExtractorGen2 decodes the cart's own $4e
into, so these read as an extracted line would.  Each goes through Strings
now, which is what the rest of this file already does with its messages.

SpikesText is left alone and the reason is written down beside it: its third
row is a `cont`, and engine messages set self.message directly rather than
going through showPages, so printMessage would cut the row carrying <TARGET>.
2026-08-21 19:37:41 -04:00
bryanthaboi c0f4315cd8 Fix Gen 1/2 battle, menu, PC and audio parity gaps; wire luacheck into CI
CLOSES #1484, CLOSES #1486, CLOSES #1513, CLOSES #1517, CLOSES #1556, CLOSES #1564, CLOSES #1567
2026-08-21 19:22:34 -04:00
AverageConsumer 8c65e76145 fix(android): bootstrap legacy APK updater 2026-08-21 23:37:12 +02:00
github-actions ea2cd63395 chore(ios): update app-repo.json [skip ci] 2026-08-21 17:19:30 -04:00
bryanthaboi 70d7b6383e Merge pull request #1659 from bryanthaboi/dev
skins again skins again skins again skins again skins again skins again skins again skins again skins again skins again skins again skins again skins again skins again skins again skins again
2026-08-21 17:10:00 -04:00
jramiresbrito 5ec9ce5f88 feat(gen2): read applyShare's announce argument in battle.exp_award
battle.exp_award hands a mod ctx.applyShare(mon, split, announce) on both
generations, and on Gen 1 the third argument decides whether the mon's
GainedText box is printed -- which is how a mod paying the whole party
prints ONE summary line instead of a box per recipient.  Gold accepted
the argument and ignored it, so the same mod source printed one line on
Red and one per party member on Gold.

The Exp Share mod is the live case: it declares games gen1+gen2 and its
description promises "a single shared-exp line instead of one message per
Pokemon", passing true for the fighters and nil for the bench exactly as
the Gen 1 seam asks.  On Gold every nil call announced anyway, so a
five-mon party turned every KO into six boxes.  There was no mod-side
fix: the emit sits behind no hook, and the argument meaning "quietly" was
discarded.

Gold now reads it, and ONLY when it is actually passed -- by argument
count, not by value.  select("#", ...) counts an explicit nil, so
applyShare(mon, split) is distinguishable from applyShare(mon, split,
nil); the first is a Gen 2-era call written against a seam that always
announced and keeps announcing, the second is a deliberate "pay this one
quietly" and is now silent on both games.  No mod that exists today
changes behaviour, and a mod that passes the argument gets parity.

Only the { kind = "experience" } event is affected.  A silent award is
still a whole award: exp, stat exp, battle.exp_gained, "grew to level",
learned moves and the forget prompt are untouched, in the same order.
giveExperiencePass takes a sixth `silent` parameter that defaults to
announcing, so both of the cart's own passes are unchanged.

RFC: docs/rfcs/0012-gen2-exp-award-announce.md
Docs: docs/mod-api-gen2-compat.md gains the reading and the residual
      omitted-argument difference beside the existing payload note.
Tests: tests/gen2_exp_share_test.lua grows the two Route B tests -- the
       no-mod parity case and the seam driven through hooks:wrap -- and
       its 23 existing checks are unchanged.
2026-08-21 18:07:36 -03:00
bryanthaboi 087a275189 skins 2026-08-21 16:36:55 -04:00
github-actions 63b31d234c chore(ios): update app-repo.json [skip ci] 2026-08-21 15:11:23 -04:00
bryanthaboi 44f4680b24 Merge pull request #1652 from bryanthaboi/dev
skin studio fix
2026-08-21 15:01:51 -04:00
bryanthaboi 06e06e305b skin studio fix 2026-08-21 14:38:54 -04:00
github-actions 464fb47756 chore(ios): update app-repo.json [skip ci] 2026-08-21 14:21:21 -04:00
83 changed files with 5078 additions and 353 deletions
+21
View File
@@ -468,3 +468,24 @@ jobs:
if [ "$found" = "0" ]; then if [ "$found" = "0" ]; then
echo "no committed mods to lint" echo "no committed mods to lint"
fi 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
+5
View File
@@ -23,6 +23,10 @@ read_globals = {
-- LuaJIT 2.1 ships table.unpack even though the bare 5.1 `table` std lacks -- 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. -- it; without this, every `table.unpack` reads as an undefined field.
table = { fields = { "unpack" } }, table = { fields = { "unpack" } },
"POKEPORT_DISPLAY_COMPANION",
"POKEPORT_EDITOR_MODE",
"rawlen",
package = { fields = { "searchers" } },
} }
-- Vendored/native trees and the test suites have their own conventions. -- Vendored/native trees and the test suites have their own conventions.
@@ -30,6 +34,7 @@ exclude_files = {
"mobile/", "mobile/",
"tests/", "tests/",
"tools/save-editor/", "tools/save-editor/",
"tools/save_convert/vendor/",
} }
ignore = { ignore = {
+1 -1
View File
@@ -111,7 +111,7 @@ local function joinPrompt(game, ow, done)
local t = game.data.text local t = game.data.text
local back = function(text) local back = function(text)
game.stack:push(TextBox.new(game, text, function() 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))
end end
game.stack:push(TextBox.new(game, game.stack:push(TextBox.new(game,
+3 -3
View File
@@ -127,7 +127,7 @@ M.VIRIDIAN_CITY = {
game.stack:push(TextBox.new(game, game.stack:push(TextBox.new(game,
game.data.text._ViridianCityOldManSleepyPrivatePropertyText game.data.text._ViridianCityOldManSleepyPrivatePropertyText
or "You can't go\nthrough here!\fThis is private\nproperty!", 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 return true
end, end,
} }
@@ -356,7 +356,7 @@ M.VERMILION_CITY = {
if shipLeft then if shipLeft then
game.stack:push(TextBox.new(game, game.stack:push(TextBox.new(game,
t._VermilionCitySailor1ShipSetSailText or "The ship set sail.", 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 return true
end end
-- Walk-past is never facing-right / inFrontOfOrBehindGuardCoords, so -- Walk-past is never facing-right / inFrontOfOrBehindGuardCoords, so
@@ -377,7 +377,7 @@ M.VERMILION_CITY = {
ask .. "\f" ask .. "\f"
.. (t._VermilionCitySailor1YouNeedATicketText .. (t._VermilionCitySailor1YouNeedATicketText
or "You need a ticket\nto get aboard."), 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 return true
end, end,
talk = { talk = {
+2 -2
View File
@@ -414,7 +414,7 @@ local function saffronGate(guardText, triggers, horizontal)
game.stack:push(TextBox.new(game, game.stack:push(TextBox.new(game,
t._SaffronGateGuardGeeImThirstyText or "Gee, I'm thirsty\nthough!\nThe road's closed.", t._SaffronGateGuardGeeImThirstyText or "Gee, I'm thirsty\nthough!\nThe road's closed.",
function() function()
ow:scriptMove(ow.player, back, 1) ow:scriptMove(ow.player, back, 1, nil, { collide = true })
end)) end))
return true return true
end, end,
@@ -769,7 +769,7 @@ M.MUSEUM_1F = {
if y == 4 and (x == 9 or x == 10) if y == 4 and (x == 9 or x == 10)
and not game.save.flags.EVENT_BOUGHT_MUSEUM_TICKET then and not game.save.flags.EVENT_BOUGHT_MUSEUM_TICKET then
museumClerk(game, ow, nil, function() museumClerk(game, ow, nil, function()
ow:scriptMove(ow.player, "down", 1) ow:scriptMove(ow.player, "down", 1, nil, { collide = true })
end) end)
return true return true
end end
+1 -1
View File
@@ -176,7 +176,7 @@ M.POKEMON_TOWER_6F = {
-- .did_not_defeat: one simulated step right, off the trigger, -- .did_not_defeat: one simulated step right, off the trigger,
-- so fleeing does not leave you standing on a cell that -- so fleeing does not leave you standing on a cell that
-- immediately re-fires. -- immediately re-fires.
ow:scriptMove(ow.player, "right", 1) ow:scriptMove(ow.player, "right", 1, nil, { collide = true })
end end
ow:afterBattle(result, battle) ow:afterBattle(result, battle)
end end
+1 -1
View File
@@ -734,7 +734,7 @@ local function e4ExitSeal(flag, closedBlock, openBlock, dontRunText, autoFlag)
local TextBox = require("src.render.TextBox") local TextBox = require("src.render.TextBox")
game.stack:push(TextBox.new(game, game.stack:push(TextBox.new(game,
game.data.text[dontRunText] or "Don't run away!", function() 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)) end))
return true return true
end, end,
+5 -4
View File
@@ -240,7 +240,7 @@ local function stepGate(opts)
push(game, text(game)[opts.text] or opts.fallback, function() push(game, text(game)[opts.text] or opts.fallback, function()
ow.player.facing = opts.push ow.player.facing = opts.push
if not ow:checkLedgeHop(opts.push) then 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
end) end)
return true return true
@@ -820,7 +820,8 @@ M.PEWTER_POKECENTER = {
-- on the west-side cells and walks you back -- on the west-side cells and walks you back
local function bikeGateGuard(coords, stopText, explainText) local function bikeGateGuard(coords, stopText, explainText)
return function(game, ow, x, y) 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 if not inCoords(coords, x, y) then return false end
-- walk the player up to the tile beside the counter, no further: -- walk the player up to the tile beside the counter, no further:
-- (matchedY - closestY) tiles, 0 when already next to it -- (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 -- (PlayerMovingRightScript). Without it the player was left
-- parked beside the guard's counter with no way past. #518 -- parked beside the guard's counter with no way past. #518
local function shoveRight() local function shoveRight()
ow:scriptMove(ow.player, "right", 1) ow:scriptMove(ow.player, "right", 1, nil, { collide = true })
end end
if dist > 0 then if dist > 0 then
ow:scriptMove(ow.player, "up", dist, shoveRight) ow:scriptMove(ow.player, "up", dist, shoveRight, { collide = true })
else else
shoveRight() shoveRight()
end end
+11
View File
@@ -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 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 Gen 1 keys. A mod that calls `nextFn` is unaffected; one that reaches through
`ctx.battle.data` instead gets nil on Gold. `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 - *The catch and the evolution:* `pokemon.caught`, `pokemon.evolved`; hook
`evolution.check`. `src/ui/gen2/BattleState.lua:pushCaught` emits `evolution.check`. `src/ui/gen2/BattleState.lua:pushCaught` emits
`pokemon.caught` once the mon is in the party or the box, and `pokemon.caught` once the mon is in the party or the box, and
+140
View File
@@ -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
View File
@@ -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 The editor keeps its canvas unobstructed and puts the contextual actions in a
compact lower tray: add/control binding, button and bezel artwork, pages, 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. 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 launchers **Turn skins off** button clears the selected skin and disables The launchers **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 skin use. With no skin enabled, mobile falls back to the built-in pad; that pad
is not itself a skin card. is not itself a skin card.
@@ -222,6 +234,7 @@ phone proportions on a desktop monitor.
| Preset | Size | | Preset | Size |
| --- | --- | | --- | --- |
| Phone portrait / landscape | 1080x1920, 1920x1080 | | Phone portrait / landscape | 1080x1920, 1920x1080 |
| This screen | the live window, so a phone is authored at its own height |
| Tablet portrait / landscape | 1536x2048, 2048x1536 | | Tablet portrait / landscape | 1536x2048, 2048x1536 |
| Steam Deck | 1280x800 | | Steam Deck | 1280x800 |
| Desktop 1080p | 1920x1080 | | Desktop 1080p | 1920x1080 |
+6 -3
View File
@@ -126,9 +126,12 @@ bundled game, in that case.
persistent launcher control. Android downloads the release APK, verifies persistent launcher control. Android downloads the release APK, verifies
its SHA-256 entry from `sha256sums.txt`, then invokes Android's Package its SHA-256 entry from `sha256sums.txt`, then invokes Android's Package
Installer. The installer asks the user for consent and enforces package, Installer. The installer asks the user for consent and enforces package,
version-code, and signing-certificate compatibility. iOS links the version-code, and signing-certificate compatibility. A legacy APK without
sideload repository for a re-sideload; Xbox, desktop, and PortMaster builds the installer bridge links its full package for one manual bootstrap
link their correctly named full package. Switch keeps its native OTA flow. 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 ## Known limitations
+3 -3
View File
@@ -24,10 +24,10 @@ local GameViewport = require("src.render.GameViewport")
-- Lua errors: persist a redacted trace in the save dir and surface a hint. -- Lua errors: persist a redacted trace in the save dir and surface a hint.
do do
local defaultErrorHandler = love.errorhandler local defaultErrorHandler = love.errorhandler or love.errhand
function love.errorhandler(msg) function love.errorhandler(msg)
local hint = SwitchDiagnostics.logLuaError(msg) local ok, hint = pcall(SwitchDiagnostics.logLuaError, msg)
if hint and type(msg) == "string" then if ok and hint and type(msg) == "string" then
msg = msg .. "\n\n" .. hint msg = msg .. "\n\n" .. hint
end end
if defaultErrorHandler then if defaultErrorHandler then
+21
View File
@@ -12,6 +12,27 @@
"tintColor": "3b5ca8", "tintColor": "3b5ca8",
"category": "games", "category": "games",
"versions": [ "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", "version": "0.2.15",
"date": "2026-08-21", "date": "2026-08-21",
+21 -2
View File
@@ -7,7 +7,8 @@
# the nil global), unused values, unreachable code. The .luacheckrc mutes the # the nil global), unused values, unreachable code. The .luacheckrc mutes the
# cosmetic categories the codebase lives with, so what prints is worth a look. # 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 # scripts/lint.sh src tools lint specific paths
# #
# Install once with: luarocks install luacheck # Install once with: luarocks install luacheck
@@ -15,9 +16,27 @@
set -uo pipefail set -uo pipefail
cd "$(dirname "$0")/.." 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 if ! command -v luacheck >/dev/null 2>&1; then
echo "luacheck not found on PATH (install: luarocks install luacheck)" >&2 echo "luacheck not found on PATH (install: luarocks install luacheck)" >&2
exit 2 exit 2
fi 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[@]}"
+9
View File
@@ -66,6 +66,15 @@ run_tier() {
# ------- ROM-free tiers: these are what CI runs # ------- 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 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 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 run_tier "T0 switch CI workflow content gate" "$LUA" tests/switch_ci_workflows_test.lua
+1
View File
@@ -538,6 +538,7 @@ end
local function makeBattler(data, mon, isPlayer, save) local function makeBattler(data, mon, isPlayer, save)
local def = data.pokemon[mon.species] local def = data.pokemon[mon.species]
require("src.pokemon.Stats").ensure(def, mon)
local badgeBoosts = data.constants and data.constants.badgeBoosts local badgeBoosts = data.constants and data.constants.badgeBoosts
local badges = nil local badges = nil
if isPlayer and save then if isPlayer and save then
+3 -1
View File
@@ -645,7 +645,9 @@ MoveEffects.full = {
user.bideTurns = ctx.rng(2, 3) user.bideTurns = ctx.rng(2, 3)
user.bideDamage = 0 user.bideDamage = 0
ctx.battle:cancelMoveAnim() 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))) ctx.say(Strings("%s\nis storing energy!", displayName(user)))
end, end,
}, },
+213 -37
View File
@@ -903,8 +903,16 @@ function Battle:movePriority(moveId)
return (def and Battle.PRIORITY[def.effect]) or 0 return (def and Battle.PRIORITY[def.effect]) or 0
end 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. -- 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) local name = self:monName(mon)
-- SUBSTATUS_RECHARGE, and it is checked BEFORE status: CheckPlayerTurn reads -- SUBSTATUS_RECHARGE, and it is checked BEFORE status: CheckPlayerTurn reads
-- it first, clears it, prints MustRechargeText and jumps to EndTurn, so a mon -- 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 local beforeMove = record and record.beforeMove
if beforeMove if beforeMove
and (record.beforeMovePriority or 0) > Battle.VOLATILE_PRIORITY then 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 end
-- SUBSTATUS_FLINCHED, read and cleared right after the freeze check -- SUBSTATUS_FLINCHED, read and cleared right after the freeze check
-- (CheckPlayerTurn / CheckEnemyTurn `.not_frozen`). Set this turn by the -- (CheckPlayerTurn / CheckEnemyTurn `.not_frozen`). Set this turn by the
@@ -959,6 +971,14 @@ function Battle:canAct(mon)
return true return true
end 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 -- STRUGGLE, the move a mon with nothing left to spend falls back to
-- (engine/battle/core.asm `.CheckPlayerHasUsableMoves` for the player and -- (engine/battle/core.asm `.CheckPlayerHasUsableMoves` for the player and
-- `.struggle` for the enemy). It lives in the move table like any other move -- `.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 if opts.critical then
self:emit({ kind = "message", text = "A critical hit!" }) self:emit({ kind = "message", text = "A critical hit!" })
end 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 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 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 end
if endured then if endured then
self:emit({ kind = "message", self:emit({ kind = "message",
@@ -1389,9 +1414,18 @@ function Battle:useMove(attacker, defender, moveId)
-- free of PP and obedience in exactly the same way. -- free of PP and obedience in exactly the same way.
local rolling = state.rolloutLock == moveId 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 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 return
end end
if move then move.pp = (move.pp or 1) - 1 end if move then move.pp = (move.pp or 1) - 1 end
@@ -1471,9 +1505,38 @@ function Battle:useMove(attacker, defender, moveId)
return return
end 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, -- Everything past here counts as "the user's last move" for Mirror Move,
-- Encore and Disable. -- Encore and Disable. A called move skips the write
state.lastMove = moveId -- (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.turnsTaken = (state.turnsTaken or 0) + 1
state.usedMoves = state.usedMoves or {} state.usedMoves = state.usedMoves or {}
local seen = false local seen = false
@@ -1516,6 +1579,13 @@ function Battle:useMove(attacker, defender, moveId)
return return
end 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, -- Counter and Mirror Coat answer what the user took this turn, at double,
-- and fail outright when nothing of the right kind landed. -- and fail outright when nothing of the right kind landed.
local counterKind = Effects.COUNTER[def.effect] local counterKind = Effects.COUNTER[def.effect]
@@ -1747,8 +1817,12 @@ function Battle:useMove(attacker, defender, moveId)
landed = landed + 1 landed = landed + 1
end end
if landed > 1 then if landed > 1 then
self:emit({ kind = "message", -- PlayerHitTimesText / EnemyHitTimesText (data/text/battle.asm:749,755)
text = ("Hit %d time(s)!"):format(landed) }) -- 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 end
-- move_effects/pay_day.asm:13 -- 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 mine.perish and theirs.perish then return fail(self) end
if not mine.perish then mine.perish = Effects.PERISH_TURNS end if not mine.perish then mine.perish = Effects.PERISH_TURNS end
if not theirs.perish then theirs.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", 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 end
-- BattleCommand_Encore: 3-6 turns locked into the move the target last used. -- 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) local side = self:sideOf(defender)
if self.spikes[side] then return fail(self) end if self.spikes[side] then return fail(self) end
self.spikes[side] = true 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!" }) self:emit({ kind = "message", text = "Spikes were scattered all around!" })
end end
@@ -2133,6 +2215,8 @@ Battle.MOVE_EFFECTS.EFFECT_BIDE = function(self, attacker, defender, def, moveId
if not state.bideTurns then if not state.bideTurns then
state.bideTurns = Effects.bideTurns(self.random) state.bideTurns = Effects.bideTurns(self.random)
state.bideStored = 0 state.bideStored = 0
-- engine/battle/core.asm:574-576
state.bideMove = moveId
self:emit({ kind = "message", self:emit({ kind = "message",
text = self:monName(attacker) .. " is storing energy!" }) text = self:monName(attacker) .. " is storing energy!" })
return return
@@ -2144,7 +2228,7 @@ Battle.MOVE_EFFECTS.EFFECT_BIDE = function(self, attacker, defender, def, moveId
return return
end end
local damage = Effects.bideDamage(state.bideStored) 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", self:emit({ kind = "message",
text = self:monName(attacker) .. " unleashed energy!" }) text = self:monName(attacker) .. " unleashed energy!" })
if damage <= 0 then return fail(self) end 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 }) { move = def, moveId = def and def.id })
landed = landed + 1 landed = landed + 1
end 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 end
-- BattleCommand_Heal (effect_commands.asm:5986): Recover and Rest are both -- 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.enemyIndex = target
self.enemy = party[target] self.enemy = party[target]
self.enemy.volatile = carried self.enemy.volatile = carried
-- engine/battle/move_effects/baton_pass.asm:59
self:resetParticipants()
end end
local sent = side == "player" and self.player or self.enemy local sent = side == "player" and self.player or self.enemy
self:emit({ kind = "send", side = side, mon = sent, 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.enemyIndex = pick
self.enemy = incoming self.enemy = incoming
self.stages.enemy = Battle.newStages() self.stages.enemy = Battle.newStages()
-- ForceEnemySwitch (engine/battle/core.asm:2937)
self:resetParticipants()
end end
self:emit({ kind = "send", side = self:sideOf(incoming), mon = incoming, self:emit({ kind = "send", side = self:sideOf(incoming), mon = incoming,
hp = incoming.hp or 0, status = incoming.status or false, 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. -- Faint bookkeeping and experience. Returns true when the battle ended.
function Battle:resolveFaints() 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 if (self.enemy.hp or 0) <= 0 then
self:emit({ kind = "faint", side = "enemy", self:emit({ kind = "faint", side = "enemy",
text = (self.wild and "Wild " or "") .. self:monName(self.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 -- `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 -- pass, the holder count for the EXP.SHARE pass -- and `halved` is whether
-- any Share holder taxed the whole pool. -- 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 for _, index in ipairs(recipients) do
local mon = self.party[index] local mon = self.party[index]
if mon and (mon.hp or 0) > 0 and not mon.isEgg then 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, index = index,
}) })
end end
if not silent then
self:emit({ kind = "experience", index = index, amount = amount, self:emit({ kind = "experience", index = index, amount = amount,
-- BoostedExpPointsText, keyed on the traded arm alone. -- BoostedExpPointsText, keyed on the traded arm alone.
text = self:monName(mon) .. " gained " text = self:monName(mon) .. " gained "
.. (traded and "a boosted " or "") .. amount .. " EXP. Points!" }) .. (traded and "a boosted " or "") .. amount .. " EXP. Points!" })
end
if result.levels > 0 then if result.levels > 0 then
-- "level up happiness mod", the cart's own comment, sitting right -- "level up happiness mod", the cart's own comment, sitting right
-- after the stat recalc and before the "grew to level" text. It fires -- 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 moveDef = self:moveDef(moveId)
local moveName = (moveDef and moveDef.name) or moveId local moveName = (moveDef and moveDef.name) or moveId
if ok then if ok then
-- data/text/common_3.asm:119
self:emit({ kind = "message", self:emit({ kind = "message",
sfx = "Sfx_DexFanfare5079", waitSfx = true,
text = self:monName(mon) .. " learned " .. moveName .. "!" }) text = self:monName(mon) .. " learned " .. moveName .. "!" })
elseif reason == "full" then elseif reason == "full" then
-- LearnMove's full-moveset arm calls ForgetMove, which asks with -- 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 -- 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 -- 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 -- applyShare(mon, split, announce) a mod can call to pay one mon its own
-- third applyShare argument is Gen 1's EXP.ALL announcement variant; Gen 2 -- share. `recipients`, `holders` and `halved` are the Gen 2 additions.
-- 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`, -- `announce` is Gen 1's third argument (src/battle/BattleState.lua
-- `holders` and `halved` are the Gen 2 additions. -- 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 if Runtime.wantsHook("battle.exp_award") then
local alive = {} local alive = {}
for _, index in ipairs(participants) do for _, index in ipairs(participants) do
local mon = self.party[index] local mon = self.party[index]
if mon and (mon.hp or 0) > 0 then alive[#alive + 1] = mon end if mon and (mon.hp or 0) > 0 then alive[#alive + 1] = mon end
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 for index, candidate in ipairs(self.party) do
if candidate == mon then if candidate == mon then
return self:giveExperiencePass(loser, def, { index }, return self:giveExperiencePass(loser, def, { index },
math.max(1, split or 1), halved) math.max(1, split or 1), halved, silent)
end end
end end
end end
@@ -3450,8 +3575,7 @@ function Battle:awardExperience(loser)
-- GiveExperiencePoints .done falls through ResetBattleParticipants into -- GiveExperiencePoints .done falls through ResetBattleParticipants into
-- AddBattleParticipant (engine/battle/core.asm:7116 and :3033). -- AddBattleParticipant (engine/battle/core.asm:7116 and :3033).
self.participants = {} self:resetParticipants()
if self.playerIndex then self.participants[self.playerIndex] = true end
end end
-- The answer to a `choose-forget`: drop the move in `slot` and put the -- 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 end
self:emit({ kind = "message", self:emit({ kind = "message",
text = "1, 2 and… " .. self:monName(mon) .. " forgot " .. oldName .. "!" }) text = "1, 2 and… " .. self:monName(mon) .. " forgot " .. oldName .. "!" })
-- engine/pokemon/learn.asm:115, data/text/common_3.asm:119
self:emit({ kind = "message", self:emit({ kind = "message",
sfx = "Sfx_DexFanfare5079", waitSfx = true,
text = self:monName(mon) .. " learned " text = self:monName(mon) .. " learned "
.. (moveName or (entry and entry.id) or "?") .. "!" }) .. (moveName or (entry and entry.id) or "?") .. "!" })
-- The forget path writes the slot itself rather than going through -- 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 return self:volatile(self.enemy).trapsTarget == true
end 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 -- EnemySwitch's shift arm zeroes both participant bitfields before PlayerSwitch
-- (engine/battle/core.asm:2959-2961). -- (engine/battle/core.asm:2959-2961).
function Battle:shiftSwitch(index) 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 -- A mon that comes back (a REVIVE, or a second battle) has to be able to
-- announce its own faint again; see resolveFaints. -- announce its own faint again; see resolveFaints.
self.faintAnnounced = nil self.faintAnnounced = nil
self.participantsCleared = nil
-- ForcePlayerMonChoice has been answered, so the next faint may ask again. -- ForcePlayerMonChoice has been answered, so the next faint may ask again.
self.pendingSwitch = nil self.pendingSwitch = nil
self.player = mon self.player = mon
@@ -3770,19 +3904,41 @@ function Battle:lockedInMove(mon)
return nil return nil
end 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 -- 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. -- (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) function Battle:forcedMove(mon)
local locked = self:lockedInMove(mon) local locked = self:lockedInMove(mon)
if locked then return locked end if locked then return locked end
local state = self:volatile(mon) -- ParsePlayerAction reads SUBSTATUS_ENCORED ahead of the bide arm
if not state.encore then return nil end -- (engine/battle/core.asm:561-566).
for _, move in ipairs(mon.moves or {}) do local encored = encoredMove(self:volatile(mon), mon)
if move.id == state.encore and (move.pp or 0) > 0 then return state.encore end if encored then return encored end
end return self:fightLockedMove(mon)
-- Encore ends early when the move runs out of PP.
state.encore, state.encoreTurns = nil, nil
return nil
end end
function Battle:moveDisabled(mon, moveId) function Battle:moveDisabled(mon, moveId)
@@ -3796,8 +3952,9 @@ function Battle:usableMoves(mon)
-- .CheckPlayerHasUsableMoves (core.asm:533-556), so a Rollout or a rampage -- .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 -- 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 -- of the lock spends any. Encore is not in this exemption -- forcedMove
-- ends it the moment the encored move runs dry. -- ends it the moment the encored move runs dry. Bide is exempt too:
local locked = self:lockedInMove(mon) -- .CheckPlayerHasUsableMoves lives inside MoveSelectionScreen (core.asm:5058).
local locked = self:lockedInMove(mon) or self:fightLockedMove(mon)
local out = {} local out = {}
for _, move in ipairs(mon.moves or {}) do for _, move in ipairs(mon.moves or {}) do
local ok = (move.pp or 0) > 0 and not self:moveDisabled(mon, move.id) 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:monName(outgoing) .. "!" })
self.enemyIndex = target self.enemyIndex = target
self.enemy = self.enemyParty[target] self.enemy = self.enemyParty[target]
-- AI_Switch (engine/battle/ai/items.asm:697)
self:resetParticipants()
-- ResetEnemyBattleVars (engine/battle/core.asm:3016) zeroes wCurEnemyMove -- ResetEnemyBattleVars (engine/battle/core.asm:3016) zeroes wCurEnemyMove
-- and wLastEnemyMove and NewEnemyMonStatus wipes the substatus bytes, so -- and wLastEnemyMove and NewEnemyMonStatus wipes the substatus bytes, so
-- the mon coming IN starts from an empty area -- the same pair of clears -- 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 -- move answers "TYPHLOSION's attack missed!", so Hitmonlee cannot be damaged
-- by anything, at any level. Fifteen straight attempts at the Elite Four -- 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. -- 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 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. -- Encore and Disable narrow the pool before the AI ever scores it.
local moves = self:usableMoves(self.enemy) local moves = self:usableMoves(self.enemy)
if #moves == 0 then 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 -- (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. -- turn unspent -- so a refused RUN never bought the enemy a free attack.
if self.runRefused then return self:takeEvents() end if self.runRefused then return self:takeEvents() end
self:cancelBide(self.player)
action = { kind = "skip" } action = { kind = "skip" }
end end
@@ -4200,6 +4367,9 @@ local function runTurn(self, action)
if action.kind == "item" and Battle.X_ITEMS[action.item] then if action.kind == "item" and Battle.X_ITEMS[action.item] then
Happiness.change(self.player, "USEDXITEM") Happiness.change(self.player, "USEDXITEM")
end 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 -- AI_SwitchOrTryItem runs BEFORE the move is chosen: a trainer that decides
-- to rotate or drink a potion spends its whole turn on it. -- to rotate or drink a potion spends its whole turn on it.
@@ -4248,7 +4418,6 @@ local function runTurn(self, action)
local function playerAttack() local function playerAttack()
if action.kind ~= "move" then return end if action.kind ~= "move" then return end
if not self:canAct(self.player) then return end
local move = action.move local move = action.move
-- An encored mon has no choice, whatever the menu said. -- An encored mon has no choice, whatever the menu said.
local forced = self:forcedMove(self.player) 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. -- player's own mon spends the rest of the battle underground.
local stored = self:volatile(self.player).chargeMove local stored = self:volatile(self.player).chargeMove
if stored then move = stored end 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 -- CheckPlayerLockedIn quits before .CheckPlayerHasUsableMoves and before
-- checkobedience, so a locked Rollout or Thrash is exempt from the -- checkobedience, so a locked Rollout or Thrash is exempt from the
-- Struggle substitution and the obedience roll the same way the second -- Struggle substitution and the obedience roll the same way the second
-- half of a charge move is. -- half of a charge move is.
local charging = self:volatile(self.player).chargeMove == move local charging = self:volatile(self.player).chargeMove == move
or self:lockedInMove(self.player) == 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", self:emit({ kind = "message",
text = self:monName(self.player) .. " has no moves left!" }) text = self:monName(self.player) .. " has no moves left!" })
move = Battle.STRUGGLE move = Battle.STRUGGLE
@@ -4312,7 +4488,7 @@ local function runTurn(self, action)
-- against a trainer, could not be escaped either. -- against a trainer, could not be escaped either.
enemyMoveId = Battle.STRUGGLE enemyMoveId = Battle.STRUGGLE
end 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): -- 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. -- the AI chose before the player's Disable landed, so the turn is spent here.
if self:moveDisabled(self.enemy, enemyMoveId) then if self:moveDisabled(self.enemy, enemyMoveId) then
+14 -6
View File
@@ -483,8 +483,10 @@ function Game2:learnMoveOn(mon, moveId, onDone)
if onDone then onDone(learned) end if onDone then onDone(learned) end
end end
if ok then if ok then
-- data/text/common_3.asm:119
return self:say(("%s learned\n%s!"):format(name, moveName), return self:say(("%s learned\n%s!"):format(name, moveName),
function() finish(true) end) function() finish(true) end,
TextBox.soundOpts(self, "Sfx_DexFanfare5079"))
end end
if reason ~= "full" then return finish(false) end if reason ~= "full" then return finish(false) end
local askForget, pickMove, askStop 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 -- The slot is written here rather than through Mon.learnMove, so
-- pokemon.move_learned is raised here too. -- pokemon.move_learned is raised here too.
ModRuntime.emit("pokemon.move_learned", { mon = mon, moveId = moveId }) 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!") self:say(("1, 2 and… Poof!\f%s forgot\n%s.\fAnd…\f%s learned\n%s!")
:format(name, oldName, name, moveName), :format(name, oldName, name, moveName),
function() finish(true) end) function() finish(true) end,
TextBox.soundOpts(self, "Sfx_DexFanfare5079"))
end, end,
}) })
end end
@@ -706,7 +710,9 @@ function Game2:usePartyItem(itemId)
}) })
elseif action == "candy" then elseif action == "candy" then
self:consumeItem(itemId) 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 else
self:consumeItem(itemId) self:consumeItem(itemId)
self:say(result.text) self:say(result.text)
@@ -765,8 +771,10 @@ function Game2:useSelectItem()
local name = (items[itemId] and items[itemId].name) or itemId local name = (items[itemId] and items[itemId].name) or itemId
self:say(Strings("{PLAYER} used the\n%s.", name)) self:say(Strings("{PLAYER} used the\n%s.", name))
elseif outcome == "trophy_sent" then elseif outcome == "trophy_sent" then
-- data/text/common_3.asm:372
self:say(Strings( 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 end
-- Anything else (a fishing bite, the ITEMFINDER's queued script) already -- Anything else (a fishing bite, the ITEMFINDER's queued script) already
-- drives its own presentation off World:step -- nothing left to print here. -- 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 -- 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 -- over the PACK closed the PACK with it, and over an empty overworld stack it
-- was a silent extra pop. -- was a silent extra pop.
function Game2:say(text, onDone) function Game2:say(text, onDone, opts)
local TextBox = require("src.render.TextBox") local TextBox = require("src.render.TextBox")
self.stack:push(TextBox.new(self, text, onDone)) self.stack:push(TextBox.new(self, text, onDone, opts))
end end
-- The landmark the player is standing in, for the Pokegear map's marker. -- The landmark the player is standing in, for the Pokegear map's marker.
+21 -1
View File
@@ -1700,6 +1700,14 @@ local function clamp(n, lo, hi, fallback)
return n return n
end 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) local function ensureOrphaned(save)
if not save.orphaned then if not save.orphaned then
save.orphaned = { mons = {}, items = {} } 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 -- (status_screen.asm:66-76, add_mon.asm _MoveMon); deriving once here means
-- every later reader (menus, battle, items, SGB bar zones, the link -- every later reader (menus, battle, items, SGB bar zones, the link
-- fingerprint) sees a party-shaped mon. Runs after the level clamp above -- 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. -- untouched.
Stats.ensure(data.pokemon and data.pokemon[mon.species], mon) Stats.ensure(data.pokemon and data.pokemon[mon.species], mon)
local moves = mon.moves local moves = mon.moves
@@ -1783,6 +1791,18 @@ local function scrubKnownMon(mon, data)
moves[1] = { id = fallback, pp = def.pp } moves[1] = { id = fallback, pp = def.pp }
end end
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 end
local function scrubMonList(list, where, save, data, report) local function scrubMonList(list, where, save, data, report)
+13 -6
View File
@@ -294,7 +294,6 @@ function TouchSkin.parse(text)
page.viewport = { x = num(vp[1], 0), y = num(vp[2], 0), page.viewport = { x = num(vp[1], 0), y = num(vp[2], 0),
w = num(vp[3], 1), h = num(vp[4], 1) } w = num(vp[3], 1), h = num(vp[4], 1) }
page.viewportFill = toBool(kv[p .. "_viewport_fill"]) page.viewportFill = toBool(kv[p .. "_viewport_fill"])
page.viewportExpand = toBool(kv[p .. "_viewport_expand"])
end end
page.pixelCoords = not page.normalized 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), 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) } w = num(raw.viewport.w, 1), h = num(raw.viewport.h, 1) }
page.viewportFill = raw.viewport.fill == true page.viewportFill = raw.viewport.fill == true
page.viewportExpand = raw.viewport.expand == true
end end
for _, c in ipairs(raw.controls or {}) do for _, c in ipairs(raw.controls or {}) do
local buttons, hotkeys, keys, decorative = parseBinds(c.bind or "nul") 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, x = page.viewport.x, y = page.viewport.y,
w = page.viewport.w, h = page.viewport.h, w = page.viewport.w, h = page.viewport.h,
fill = page.viewportFill or nil, fill = page.viewportFill or nil,
expand = page.viewportExpand or nil,
} }
end end
for _, ctl in ipairs(page.controls or {}) do for _, ctl in ipairs(page.controls or {}) do
@@ -922,7 +919,6 @@ function TouchSkin.toRetroArchConfig(skin)
if page.viewport then if page.viewport then
out[#out + 1] = p .. "_viewport = " .. fmtRect(page.viewport) out[#out + 1] = p .. "_viewport = " .. fmtRect(page.viewport)
if page.viewportFill then out[#out + 1] = p .. "_viewport_fill = true" end 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 end
local controls = {} local controls = {}
for _, ctl in ipairs(page.controls or {}) do 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] return best[1], best[2], best[3], best[4]
end 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) function TouchSkin.pageViewport(page, w, h, ox, oy)
if not page then return nil end if not page then return nil end
ox, oy = ox or 0, oy or 0 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 x, y = bx + v.x * bw, by + v.y * bh
local vw, vh = v.w * bw, v.h * bh local vw, vh = v.w * bw, v.h * bh
if vw <= 0 or vh <= 0 then return nil end 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 end
if page.screenFit == "remainder" then if page.screenFit == "remainder" then
local x, y, vw, vh = remainderBox(ox, oy, w, h, bx, by, bw, bh) local x, y, vw, vh = remainderBox(ox, oy, w, h, bx, by, bw, bh)
if not x then return nil end if not x then return nil end
return x, y, vw, vh, false, false return x, y, vw, vh, false
end end
return nil return nil
end end
+2
View File
@@ -240,6 +240,8 @@ local function rareCandy(mon, data)
used = true, used = true,
level = newLevel, level = newLevel,
learned = learned, learned = learned,
-- data/text/common_1.asm:86
sfx = "Sfx_DexFanfare5079",
text = ("%s grew to\nlevel %d!"):format(monName(mon), newLevel), text = ("%s grew to\nlevel %d!"):format(monName(mon), newLevel),
} }
end end
+19 -8
View File
@@ -1067,9 +1067,22 @@ local GAME_TABS = {
local HEADER_TABS = { local HEADER_TABS = {
{ id = "mods", key = "tab-mods" }, { id = "mods", key = "tab-mods" },
{ id = "find", key = "tab-find" }, { 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" }, { 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 for _, t in ipairs(HEADER_TABS) do
t.opts = { face = "tab", font = "tab", color = t.color, letter = t.letter } t.opts = { face = "tab", font = "tab", color = t.color, letter = t.letter }
if t.glyph then if t.glyph then
@@ -1273,6 +1286,7 @@ local function buildHeader(imp, m)
o.image = t.icon o.image = t.icon
o.action = chrome.tab[t.id] o.action = chrome.tab[t.id]
btn(imp, tx, ty, w, tabH, t.key, "", o) btn(imp, tx, ty, w, tabH, t.key, "", o)
if t.beta then overlayBeta(tx, ty, w, tabH, m) end
tx = tx + w + tabGap tx = tx + w + tabGap
end end
-- The bug-report chip sits LAST, past the sync chip. -- The bug-report chip sits LAST, past the sync chip.
@@ -1290,10 +1304,7 @@ local function buildHeader(imp, m)
local o = chrome.sync local o = chrome.sync
o.active = imp._syncModal ~= nil o.active = imp._syncModal ~= nil
btn(imp, tx, ty, w, tabH, "tab-sync", "", o) btn(imp, tx, ty, w, tabH, "tab-sync", "", o)
local bh = math.floor(11 * m.s) overlayBeta(tx, ty, w, tabH, m)
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)
local eng = imp._sync local eng = imp._sync
if eng and eng.busy and eng:busy() then if eng and eng.busy and eng:busy() then
Kit.spinner(tx + w - math.floor(8 * m.s), ty + math.floor(8 * m.s), 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) return y + math.floor(10 * m.s)
end 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. -- Returns status, label, action, glow.
function LauncherView._updateControl(imp) function LauncherView._updateControl(imp)
if not imp.Check then return nil end 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) Kit.text("button", label, px + pad, py, PAL.heading)
local bh = math.floor(15 * m.s) local bh = math.floor(15 * m.s)
local bw = Kit.textWidth("micro", "BETA") + math.floor(14 * 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), drawBetaTag(px + pad + Kit.textWidth("button", label) + math.floor(8 * m.s),
py + (Kit.textHeight("button") - bh) / 2, bw, bh, "BETA", PAL.yellow) py + (Kit.textHeight("button") - bh) / 2, bw, bh)
return py + Kit.textHeight("button") + math.floor(12 * m.s) return py + Kit.textHeight("button") + math.floor(12 * m.s)
end end
+20
View File
@@ -5151,6 +5151,7 @@ function RomExtractorGen2:extractMenuGfx()
} }
out.pack = pack out.pack = pack
out.pokedex = self:pokedexGfx() out.pokedex = self:pokedexGfx()
out.billsPc = self:billsPcGfx()
out.pokegear = self:pokegearGfx() out.pokegear = self:pokegearGfx()
out.trainerCard = self:trainerCardGfx() out.trainerCard = self:trainerCardGfx()
out.unownPuzzle = self:unownPuzzleGfx() out.unownPuzzle = self:unownPuzzleGfx()
@@ -5697,6 +5698,25 @@ function RomExtractorGen2:pokedexGfx()
return dex return dex
end 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 -- 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 -- 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 -- from the first byte, c from the second, and writes `b` c times. $ff ends
+17 -6
View File
@@ -146,6 +146,8 @@ local VERSION_REQUIRED_FILES_OVERRIDE = {
-- complete and SlotMachine crashed on its labelled-cell fallback. -- complete and SlotMachine crashed on its labelled-cell fallback.
"assets/generated/slots/gold_slots_1.png", "assets/generated/slots/gold_slots_1.png",
"assets/generated/card_flip/card_flip_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. -- 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 -- 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 -- add Blue (issue #167). Yellow and Gold carts are typically .gbc (Gold is
-- 2 MiB). -- 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 for _, name in ipairs(love.filesystem.getDirectoryItems("")) do
if name:lower():match("%.gbc?$") and love.filesystem.getInfo(name, "file") then if name:lower():match("%.gbc?$") and love.filesystem.getInfo(name, "file") then
local data = love.filesystem.read(name) local data = love.filesystem.read(name)
if type(data) == "string" and isAcceptedRomSize(#data) then if type(data) == "string" and isAcceptedRomSize(#data) then
local version = GameVersion.forSha1(sha1(data)) 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 return name, data
end end
end end
@@ -2573,8 +2581,9 @@ function RomImporter:choose(version)
if self.android then if self.android then
-- Prefer a not-yet-imported .gb/.gbc already in the save dir (USB copy, or -- 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 -- a fresh SAF pick). Never reuse an already-imported cart's file -- that
-- was the #167 failure mode (second Choose just re-extracted Red). -- was the #167 failure mode (second Choose just re-extracted Red). This
local name, data = findPendingRom(self.ready) -- 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 if name then
self:startData(data, name) self:startData(data, name)
elseif consumePickedRomError(self) then elseif consumePickedRomError(self) then
@@ -2602,8 +2611,10 @@ function RomImporter:choose(version)
-- Handheld Linux (Anbernic stock OS / PortMaster) rarely has zenity or -- 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 -- 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 -- used on Android, which works when the game is launched as an unpacked
-- directory (see build-rg34xxsp.sh). -- directory (see build-rg34xxsp.sh). Narrowed to the chosen version: with
local name, data = findPendingRom(self.ready) -- 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 if name then
self:startData(data, name) self:startData(data, name)
return return
+3 -2
View File
@@ -10,13 +10,14 @@
local Bag = {} local Bag = {}
-- MAX_ITEMS / MAX_BALLS / MAX_KEY_ITEMS, and the TM/HM pocket holds one of -- 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. -- replaces the ITEM pocket only, the way the Gen 1 single-bag config did.
local POCKET_CAPACITY = { local POCKET_CAPACITY = {
ITEM = 20, ITEM = 20,
BALL = 12, BALL = 12,
KEY_ITEM = 25, KEY_ITEM = 25,
TM_HM = 64, TM_HM = 57,
} }
local DEFAULT_CAPACITY = 20 local DEFAULT_CAPACITY = 20
+46 -5
View File
@@ -808,6 +808,33 @@ local function buildOverworld()
local Movement = rawRequire("src.script.gen2.Movement") local Movement = rawRequire("src.script.gen2.Movement")
local HiddenItems = rawRequire("src.world.gen2.HiddenItems") local HiddenItems = rawRequire("src.world.gen2.HiddenItems")
local Bike = rawRequire("src.world.gen2.Bike") 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 local api = nil
-- one WorldAPI instance, so queueScript reuses the five-verb allow list -- 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 -- 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). -- 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") local world = w("scriptMove")
if not world then return nil, "no overworld" end if not world then return nil, "no overworld" end
if world.moveState then return nil, "a movement is already running" end if world.moveState then return nil, "a movement is already running" end
local step = Movement.stepByte(dir) local step = Movement.stepByte(dir)
if not step then return nil, "unknown direction: " .. tostring(dir) end 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) local objectId = objectIdOf(world, entity)
if not objectId then if not objectId then
return nil, "no Gen 2 objectId for that entity: only the player and a " return nil, "no Gen 2 objectId for that entity: only the player and a "
.. "mapped object (def.index) can be moved" .. "mapped object (def.index) can be moved"
end 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) world:beginMovement(objectId, bytes, onDone)
return true return true
end end
@@ -1401,7 +1441,8 @@ COVERAGE[OW] = {
.. "objectId 1 is wLastTalked, not object zero", .. "objectId 1 is wLastTalked, not object zero",
scriptMove = "the player maps to objectId 0 and a mapped object to " 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 " .. "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, " connectionLanding = "Gen 1's five values (destDef, tilesetDef, x, y, "
.. "conn); `conn` is Gold's connection record, keyed map/mapId + offset", .. "conn); `conn` is Gold's connection record, keyed map/mapId + offset",
timeOfDay = "recomputed from the clock every call and never cached, so a " timeOfDay = "recomputed from the clock every call and never cached, so a "
+12
View File
@@ -119,6 +119,17 @@ local GEN1_ONLY_MODULES = {
["src.ui.OptionsMenu"] = true, ["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 -- the src.* modules the mod surface points authors at: another mod's
-- exports carry a version string that wants range-checking before use, and -- exports carry a version string that wants range-checking before use, and
-- ChipAsm is the authoring path for chip music and sfx -- ChipAsm is the authoring path for chip music and sfx
@@ -214,6 +225,7 @@ function Loader:_installDevShim()
if owner or callerIsMod(3) then if owner or callerIsMod(3) then
local id = type(owner) == "string" and owner or nil local id = type(owner) == "string" and owner or nil
local denial = Sandbox.moduleDenial(name, devShim.permissions[id]) 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 if denial then error(("[%s] %s"):format(id or "mod", denial), 0) end
end end
if devShim.dev or devShim.generation ~= 1 then scanRequire(name) end if devShim.dev or devShim.generation ~= 1 then scanRequire(name) end
+13 -4
View File
@@ -51,11 +51,20 @@ end
-- (engine/pokemon/status_screen.asm:66-76, "mon is in a box or daycare" -> -- (engine/pokemon/status_screen.asm:66-76, "mon is in a box or daycare" ->
-- CalcStats) and when one is moved back into the party -- CalcStats) and when one is moved back into the party
-- (engine/pokemon/add_mon.asm _MoveMon tail). The stored current HP is -- (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 -- kept (box_struct does hold it) but clamped to the recalculated maximum.
-- a tampered save cannot overfill the bar. A mon that already has stats is -- A complete stat block is returned untouched; an incomplete one is rebuilt,
-- returned untouched, so a vanilla save round-trips. #233, #304 -- 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) 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 if type(speciesDef) ~= "table" or type(speciesDef.baseStats) ~= "table" then
return mon return mon
end end
+2 -9
View File
@@ -3,8 +3,6 @@ local TouchSkin = require("src.core.TouchSkin")
local Playfield = {} local Playfield = {}
Playfield.WIDTH, Playfield.HEIGHT = 160, 144
Playfield.entered = false Playfield.entered = false
Playfield.box = nil Playfield.box = nil
@@ -35,14 +33,9 @@ function Playfield.cutout(sw, sh)
end end
function Playfield.rect(sw, sh) 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 not x then return 0, 0, sw or 0, sh or 0, false end
if expand then return x, y, w, h, true end return x, y, w, h, true
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
end end
function Playfield.enter(x, y, w, h) function Playfield.enter(x, y, w, h)
+5 -6
View File
@@ -87,12 +87,12 @@ local function displayMetrics()
if dpiX < 1e-6 then dpiX = 1 end if dpiX < 1e-6 then dpiX = 1 end
if dpiY < 1e-6 then dpiY = 1 end if dpiY < 1e-6 then dpiY = 1 end
local vx, vy = 0, 0 local vx, vy = 0, 0
local cut, grow = false, false local cut = false
local sx, sy, sw, sh, _, expand = Playfield.cutout(pw, ph) local sx, sy, sw, sh = Playfield.cutout(pw, ph)
if sx then 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 end
return ww, wh, pw, ph, dpiX, dpiY, vx, vy, cut, grow return ww, wh, pw, ph, dpiX, dpiY, vx, vy, cut
end end
local function positionLift(ph, contentPx, dpiY, cut) 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 -- corners; flat mode returns exactly today's size (growth factor is 1 when
-- tilt is inactive). -- tilt is inactive).
function Renderer:worldViewSize() 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 -- FAITHFUL RATIO on mobile. The world pass deliberately expands to cover the
-- WHOLE display, so letterbox voids become more map instead of black bars. -- 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 -- 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 -- 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. -- both platforms show the same map area at the same zoom.
local cap = FaithfulRes.scaleCap() local cap = FaithfulRes.scaleCap()
if not cap and cut and not grow then cap = self:fitScale() end
if cap then if cap then
local uiw, uih = self:uiSize() local uiw, uih = self:uiSize()
pw = cut and math.min(pw, uiw * cap) or uiw * cap pw = cut and math.min(pw, uiw * cap) or uiw * cap
+11 -3
View File
@@ -85,7 +85,9 @@ function Transition.new(game, onMidpoint, onDone, warp)
end end
function Transition:finish() 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 if self.onDone then self.onDone() end
end end
@@ -96,10 +98,16 @@ function Transition:update(dt)
self.t = 0 self.t = 0
if self.phase == "out" then if self.phase == "out" then
self.phase = "in" self.phase = "in"
if self.onMidpoint then self.onMidpoint() end
-- LoadGBPal restores the palettes in one write, so with no fade in the -- LoadGBPal restores the palettes in one write, so with no fade in the
-- map is simply there on the next frame -- 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 else
self:finish() self:finish()
end end
+330 -56
View File
@@ -5,12 +5,14 @@ local TouchSkin = require("src.core.TouchSkin")
local TouchControls = require("src.core.TouchControls") local TouchControls = require("src.core.TouchControls")
local SaveData = require("src.core.SaveData") local SaveData = require("src.core.SaveData")
local FilePicker = require("src.core.FilePicker") local FilePicker = require("src.core.FilePicker")
local SafeArea = require("src.core.SafeArea")
local Studio = {} local Studio = {}
Studio.CANVASES = { Studio.CANVASES = {
{ id = "phone_portrait", label = "Phone portrait", w = 1080, h = 1920 }, { id = "phone_portrait", label = "Phone portrait", w = 1080, h = 1920 },
{ id = "phone_landscape", label = "Phone landscape", w = 1920, h = 1080 }, { 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_portrait", label = "Tablet portrait", w = 1536, h = 2048 },
{ id = "tablet_landscape", label = "Tablet landscape", w = 2048, h = 1536 }, { id = "tablet_landscape", label = "Tablet landscape", w = 2048, h = 1536 },
{ id = "steamdeck", label = "Steam Deck", w = 1280, h = 800 }, { 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 } }, 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 -- 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 -- by default so a portrait/landscape overlay pair does not need two
-- separate clicks to preview the right way up. #1503 -- 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 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() 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 end
function Studio.page() function Studio.page()
@@ -256,11 +353,19 @@ end
local function canvasOrientation(canvas) local function canvasOrientation(canvas)
canvas = canvas or Studio.canvas() canvas = canvas or Studio.canvas()
if not canvas then return nil end 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 end
local function pickCanvasIndex(want) local function pickCanvasIndex(want)
local cur = Studio.canvas() 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 canvasOrientation(cur) == want then return Studio.canvasIndex end
if cur and cur.id then if cur and cur.id then
local hint = cur.id:gsub("portrait", want):gsub("landscape", want) local hint = cur.id:gsub("portrait", want):gsub("landscape", want)
@@ -269,7 +374,10 @@ local function pickCanvasIndex(want)
end end
end end
for i, c in ipairs(Studio.CANVASES) do 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 end
return nil return nil
end end
@@ -348,9 +456,10 @@ function Studio.setCanvas(index, fromSync)
page.viewportFill = false page.viewportFill = false
markDirty() markDirty()
end end
-- A cfg-authored aspect_ratio is the overlay's design aspect; keep it so -- A cfg-authored aspect_ratio is the overlay's design aspect, and bezel art
-- the preview letterboxes like RetroArch instead of stretching (#1503). -- is its own design canvas (TouchSkin.applyImageAspect); keep either so the
if page and not page.aspectFromCfg then -- 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 page.aspect = canvas.w / canvas.h
end end
end end
@@ -368,6 +477,14 @@ function Studio.load(opts)
Studio.drag = nil Studio.drag = nil
Studio.pendingPlay = false Studio.pendingPlay = false
Studio.canvasIndex = 1 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, -- A free-form screen is the default. 10:9 is an optional convenience,
-- never a restriction on imported or custom skins. -- never a restriction on imported or custom skins.
Studio.aspectLock = false Studio.aspectLock = false
@@ -1065,6 +1182,11 @@ function Studio.openPageMenu()
return true return true
end end
function Studio.openScreenMenu()
Studio.openModal("screen")
return true
end
function Studio.renamePage(name) function Studio.renamePage(name)
local page = Studio.page() local page = Studio.page()
if not page then return false end if not page then return false end
@@ -1222,8 +1344,13 @@ end
function Studio.canvasRect(x, y, w, h) function Studio.canvasRect(x, y, w, h)
local canvas = Studio.canvas() local canvas = Studio.canvas()
local aspect = canvas.w / canvas.h local aspect = canvas.w / canvas.h
if not aspect or aspect <= 0 then aspect = 1 end
local cw, ch = w, w / aspect local cw, ch = w, w / aspect
if ch > h then ch, cw = h, h * aspect end 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 return x + (w - cw) * 0.5, y + (h - ch) * 0.5, cw, ch
end end
@@ -1430,10 +1557,12 @@ local function drawGameTest(r, page)
end end
local function drawCanvas(x, y, w, h) local function drawCanvas(x, y, w, h)
Studio.canvasWorkspace = { x = x, y = y, w = w, h = h }
local page = Studio.page() local page = Studio.page()
local r = { } local r = { }
r.x, r.y, r.w, r.h = Studio.canvasRect(x, y, w, h) r.x, r.y, r.w, r.h = Studio.canvasRect(x, y, w, h)
Studio.lastCanvas = r Studio.lastCanvas = r
Theme.fill(x, y, w, h, { 10, 12, 18 }, 1)
if not page then return r end if not page then return r end
Theme.fill(r.x, r.y, r.w, r.h, { 0, 0, 0 }, 1) Theme.fill(r.x, r.y, r.w, r.h, { 0, 0, 0 }, 1)
@@ -1745,12 +1874,18 @@ function Studio.commitField(id, text)
end end
local function modalFrame(W, H, title, wFrac, hFrac) 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 pad = 14 * Kit.scale
local mw = math.min(W - pad * 2, math.max(320 * Kit.scale, (wFrac or 0.6) * W)) local mw = math.min(sw - pad * 2, math.max(320 * Kit.scale, (wFrac or 0.6) * sw))
local mh = math.min(H - pad * 2, math.max(220 * Kit.scale, (hFrac or 0.72) * H)) local mh = math.min(sh - pad * 2, math.max(220 * Kit.scale, (hFrac or 0.72) * sh))
local mx = math.floor((W - mw) * 0.5) local mx = math.floor(ox + (sw - mw) * 0.5)
local my = math.floor((H - mh) * 0.5) local my = math.floor(oy + (sh - mh) * 0.5)
Kit.card(mx, my, mw, mh) Kit.card(mx, my, mw, mh)
Kit.textBold("title", title, mx + pad, my + pad * 0.7, PAL.heading) Kit.textBold("title", title, mx + pad, my + pad * 0.7, PAL.heading)
return mx, my, mw, mh, pad return mx, my, mw, mh, pad
@@ -2022,6 +2157,79 @@ local function drawExportModal(W, H)
mx + pad, cy, mw - pad * 2, PAL.faint, 2) mx + pad, cy, mw - pad * 2, PAL.faint, 2)
end 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 function drawConfirm(W, H)
local c = Studio.confirm local c = Studio.confirm
local mx, my, mw, mh, pad = modalFrame(W, H, "Unsaved changes", 0.42, 0.3) 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 == "open" then drawOpenModal(W, H)
elseif modal.kind == "page" then drawPageModal(W, H) elseif modal.kind == "page" then drawPageModal(W, H)
elseif modal.kind == "export" then drawExportModal(W, H) elseif modal.kind == "export" then drawExportModal(W, H)
elseif modal.kind == "screen" then drawScreenModal(W, H)
else Studio.closeModal() end else Studio.closeModal() end
return true return true
end end
local function drawLegacyStudio() local function drawLegacyStudio()
local W, H = love.graphics.getDimensions() local winW, winH, ox, oy, W, H = Studio.safeFrame()
Kit.layout(W, H) Kit.layout(W, H)
local mx, my = love.mouse.getPosition() local mx, my = love.mouse.getPosition()
if Studio.pointerX ~= nil then mx, my = Studio.pointerX, Studio.pointerY end 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 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() Studio.expireStatus()
Kit.blockClicks = Studio.modalUp() Kit.blockClicks = Studio.modalUp()
love.graphics.push()
love.graphics.translate(ox, oy)
local pad = 14 * Kit.scale local pad = 14 * Kit.scale
local mobile = Studio.isMobile() local mobile = Studio.isMobile()
@@ -2142,6 +2353,7 @@ local function drawLegacyStudio()
end end
if closed then if closed then
Kit.blockClicks = false Kit.blockClicks = false
love.graphics.pop()
Kit.endFrame() Kit.endFrame()
return return
end end
@@ -2183,6 +2395,16 @@ local function drawLegacyStudio()
Studio.statusErr and PAL.red or PAL.detail) Studio.statusErr and PAL.red or PAL.detail)
Kit.blockClicks = false 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) Studio.drawOverlay(W, H)
Kit.endFrame() Kit.endFrame()
@@ -2225,34 +2447,37 @@ local function drawSkinArtwork(image, x, y, w, h)
PAL.steel, 0.8, h * 0.12) PAL.steel, 0.8, h * 0.12)
end 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 btnH = math.max(Kit.tapMin(), 32 * Kit.scale)
local titleY = pad * 0.55 local titleY = oy + pad * 0.55
Kit.textBold("title", "My Skins", pad, titleY, PAL.heading) Kit.textBold("title", "My Skins", ox + pad, titleY, PAL.heading)
local closeW = math.min(112 * Kit.scale, W * 0.24) 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 { id = "library-close" }) then
if Studio.onClose then Studio.onClose() end if Studio.onClose then Studio.onClose() end
return return
end end
local y = pad * 0.5 + btnH + pad local y = oy + pad * 0.5 + btnH + pad
local gap = 8 * Kit.scale local gap = 8 * Kit.scale
local half = (W - pad * 2 - gap) * 0.5 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 { id = "library-new", kind = "accent" }) then
Studio.newSkin() Studio.newSkin()
Studio.enterEditor() Studio.enterEditor()
return return
end 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 { id = "library-import" }) then
Studio.importSkinFile() Studio.importSkinFile()
return return
end end
y = y + btnH * 1.12 + pad 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 y = y + Kit.textHeight("small") + gap
local entries = Studio.available or {} 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 tc = type(saved.touchControls) == "table" and saved.touchControls or {}
local activeId = tc.enabled == false and nil or tc.skin local activeId = tc.enabled == false and nil or tc.skin
local total = #entries 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))) / (cardH + cardGap)))
local perPage = maxRows * cols local perPage = maxRows * cols
local pages = math.max(1, math.ceil(total / perPage)) local pages = math.max(1, math.ceil(total / perPage))
@@ -2274,7 +2499,7 @@ local function drawLibrary(W, H, pad)
for slot = first, last do for slot = first, last do
local index = slot - first 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) local cy = y + math.floor(index / cols) * (cardH + cardGap)
do do
local entry = entries[slot] local entry = entries[slot]
@@ -2319,67 +2544,71 @@ local function drawLibrary(W, H, pad)
end end
if pages > 1 then 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 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 { id = "library-prev", enabled = Studio.libraryPage > 1 }) then
Studio.libraryPage = Studio.libraryPage - 1 Studio.libraryPage = Studio.libraryPage - 1
end 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 { id = "library-next", enabled = Studio.libraryPage < pages }) then
Studio.libraryPage = Studio.libraryPage + 1 Studio.libraryPage = Studio.libraryPage + 1
end end
end end
if #entries == 0 then if #entries == 0 then
Kit.text("small", "Create a blank skin or import one to get started.", pad, Kit.text("small", "Create a blank skin or import one to get started.",
y + cardH + gap, PAL.muted) ox + pad, y + cardH + gap, PAL.muted)
end end
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 btnH = math.max(Kit.tapMin(), 32 * Kit.scale)
local gap = 7 * Kit.scale local gap = 7 * Kit.scale
local backW = math.min(120 * Kit.scale, W * 0.23) 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 { id = "editor-back" }) then
Studio.backToLibrary() Studio.backToLibrary()
end end
local closeW = math.min(90 * Kit.scale, W * 0.17) local closeW = math.min(90 * Kit.scale, W * 0.17)
local saveW = 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 testW = math.min(100 * Kit.scale, W * 0.18)
local right = W - pad local right = ox + W - pad
if Kit.button(right - closeW, pad * 0.5, closeW, btnH, "Close", if Kit.button(right - closeW, oy + pad * 0.5, closeW, btnH, "Close",
{ id = "editor-close" }) then { id = "editor-close" }) then
Studio.guard("Close the studio and lose the unsaved changes?", function() Studio.guard("Close the studio and lose the unsaved changes?", function()
if Studio.onClose then Studio.onClose() end if Studio.onClose then Studio.onClose() end
end) end)
end end
right = right - closeW - gap 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 { id = "editor-save", kind = "accent" }) then Studio.save() end
right = right - saveW - gap 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", Studio.testing and "Test: ON" or "Test",
{ id = "editor-test", active = Studio.testing }) then { id = "editor-test", active = Studio.testing }) then
Studio.testing = not Studio.testing Studio.testing = not Studio.testing
TouchControls:setPreview(not Studio.testing) TouchControls:setPreview(not Studio.testing)
TouchControls:reset() TouchControls:reset()
end end
local titleX = pad + backW + gap local titleX = ox + pad + backW + gap
local titleW = math.max(0, right - testW - gap - titleX) local titleW = math.max(0, right - testW - gap - titleX)
Kit.textBold("button", Kit.ellipsize("button", Kit.textBold("button", Kit.ellipsize("button",
(Studio.skin and Studio.skin.name) or "Untitled skin", titleW), (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 zH = math.max(Kit.tapMin(), 28 * Kit.scale)
local bodyY = pad * 0.5 + btnH + pad local trayH = btnH * 2 + zH + gap * 4 + Kit.textHeight("small") + gap
local trayY = H - pad - trayH local bodyY = oy + pad * 0.5 + btnH + pad
local trayY = oy + H - pad - trayH
local canvasH = math.max(120 * Kit.scale, trayY - bodyY - pad) 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) Kit.card(ox + pad, trayY, W - pad * 2, trayH)
local innerX, innerW = pad + gap, W - pad * 2 - gap * 2 local innerX, innerW = ox + pad + gap, W - pad * 2 - gap * 2
local actionW = (innerW - gap * 3) / 4 local actionW = (innerW - gap * 3) / 4
local ctl = Studio.selectedControl() local ctl = Studio.selectedControl()
if Kit.button(innerX, trayY + gap, actionW, btnH, "+ Control", if Kit.button(innerX, trayY + gap, actionW, btnH, "+ Control",
@@ -2401,19 +2630,41 @@ local function drawEditor(W, H, pad)
Studio.openImagePicker("bezel") Studio.openImagePicker("bezel")
end 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 { id = "tray-pages" }) then Studio.openPageMenu() end
if Kit.button(innerX + (actionW + gap), trayY + gap * 2 + btnH, actionW, btnH, if Kit.button(innerX + (actionW + gap), row2, actionW, btnH,
"Screen", { id = "tray-screen" }) then Studio.toggleViewport() end "Screen", { id = "tray-screen" }) then Studio.openScreenMenu() end
if Kit.button(innerX + (actionW + gap) * 2, trayY + gap * 2 + btnH, actionW, btnH, if Kit.button(innerX + (actionW + gap) * 2, row2, actionW, btnH,
Studio.aspectLock and "Shape: 10:9" or "Shape: Free", Studio.aspectLock and "Shape: 10:9" or "Shape: Free",
{ id = "tray-shape", active = Studio.aspectLock }) then { id = "tray-shape", active = Studio.aspectLock }) then
Studio.aspectLock = not Studio.aspectLock Studio.aspectLock = not Studio.aspectLock
end 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 "Delete", { id = "tray-delete", kind = "danger", enabled = ctl ~= nil }) then
Studio.deleteControl() Studio.deleteControl()
end 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) local hint = ctl and ("Selected: " .. TouchSkin.describeBind(ctl.spec)
.. " — drag to move; use blue handles to resize.") .. " — drag to move; use blue handles to resize.")
or "Tap a control to select it. 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 end
function Studio.draw() function Studio.draw()
local W, H = love.graphics.getDimensions() local W, H = Studio.safeFrame()
Kit.layout(W, H) local f = Studio._frame
Kit.layout(f.w, f.h)
local mx, my = love.mouse.getPosition() local mx, my = love.mouse.getPosition()
if Studio.pointerX ~= nil then mx, my = Studio.pointerX, Studio.pointerY end if Studio.pointerX ~= nil then mx, my = Studio.pointerX, Studio.pointerY end
Kit.beginFrame(mx, my, Studio.clicked, Studio.wheel) Kit.beginFrame(mx, my, Studio.clicked, Studio.wheel)
@@ -2433,12 +2685,11 @@ function Studio.draw()
Studio.expireStatus() Studio.expireStatus()
Kit.blockClicks = Studio.modalUp() Kit.blockClicks = Studio.modalUp()
local pad = 14 * Kit.scale if Studio.mode == "library" then drawLibrary()
if Studio.mode == "library" then drawLibrary(W, H, pad) else drawEditor() end
else drawEditor(W, H, pad) end
Kit.blockClicks = false Kit.blockClicks = false
Studio.drawOverlay(W, H) Studio.drawOverlay(f.w, f.h)
Kit.endFrame() Kit.endFrame()
end end
@@ -2467,8 +2718,12 @@ function Studio.mousepressed(x, y, button)
return return
end end
local slop = HANDLE * 2 * Kit.scale local slop = HANDLE * 2 * Kit.scale
if x < r.x - slop or x > r.x + r.w + slop local work = Studio.canvasWorkspace
or y < r.y - slop or y > r.y + r.h + slop then 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 return
end end
Studio.beginCanvasDrag(x, y, r) Studio.beginCanvasDrag(x, y, r)
@@ -2517,6 +2772,19 @@ function Studio.touchreleased(id, x, y)
end end
function Studio.wheelmoved(_, dy) 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 Studio.wheel = dy
end end
@@ -2594,6 +2862,12 @@ function Studio.keypressed(key)
Studio.testing = not Studio.testing Studio.testing = not Studio.testing
TouchControls:setPreview(not Studio.testing) TouchControls:setPreview(not Studio.testing)
TouchControls:reset() 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 elseif key == "s" then
Studio.save() Studio.save()
elseif key == "tab" then elseif key == "tab" then
+2
View File
@@ -2707,7 +2707,9 @@ function BattleState:pushCaught(enemy, itemId)
-- so the contest branch is BELOW them (item_effects.asm:528-546), and -- so the contest branch is BELOW them (item_effects.asm:528-546), and
-- CheckReceivedDex gates the pair (:532-533). -- CheckReceivedDex gates the pair (:532-533).
if not knew and self:hasPokedex() then if not knew and self:hasPokedex() then
-- data/text/common_3.asm:285
self:push({ kind = "message", self:push({ kind = "message",
sfx = "Sfx_SlotMachineStart", waitSfx = true,
text = self:name(enemy) .. "'s data was newly added to the #DEX." }) text = self:name(enemy) .. "'s data was newly added to the #DEX." })
self:push({ kind = "dex-entry", species = enemy.species }) self:push({ kind = "dex-entry", species = enemy.species })
end end
+122 -12
View File
@@ -47,6 +47,7 @@ local Font = require("src.render.Font")
local GbcPalette = require("src.render.GbcPalette") local GbcPalette = require("src.render.GbcPalette")
local Mail = require("src.core.gen2.Mail") local Mail = require("src.core.gen2.Mail")
local Palettes = require("src.world.gen2.Palettes") local Palettes = require("src.world.gen2.Palettes")
local PartyMenu = require("src.ui.gen2.PartyMenu")
local Screens = require("src.ui.Screens") local Screens = require("src.ui.Screens")
local Sound = require("src.core.Sound") local Sound = require("src.core.Sound")
local Unown = require("src.core.gen2.Unown") 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). -- 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 } } 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 -- 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 -- MOVE screen ever loads box 0; the withdraw and deposit lists are one list
-- each (BillsPC_BoxName reads the same byte for all three). -- 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 -- 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. -- list comes back, which is the same place the player ends up.
self.phase = nil self.phase = nil
-- engine/pokemon/bills_pc.asm:1607
self:playSfx("Sfx_Wrong")
self.message = reason self.message = reason
return return
end end
@@ -299,9 +317,13 @@ end
function BoxMenu:doWithdraw() function BoxMenu:doWithdraw()
local ok, result = Boxes.withdraw(self.save, self.boxIndex, self.index) local ok, result = Boxes.withdraw(self.save, self.boxIndex, self.index)
if not ok then if not ok then
-- engine/pokemon/bills_pc.asm:1845
self:playSfx("Sfx_Wrong")
self.message = result self.message = result
return return
end end
-- engine/pokemon/bills_pc.asm:1817
self:playMonCry(result)
self.message = nil self.message = nil
self.phase = nil self.phase = nil
self:clampIndex() self:clampIndex()
@@ -311,9 +333,13 @@ end
function BoxMenu:doDeposit() function BoxMenu:doDeposit()
local ok, result = Boxes.deposit(self.save, self.index, self.boxIndex) local ok, result = Boxes.deposit(self.save, self.index, self.boxIndex)
if not ok then if not ok then
-- engine/pokemon/bills_pc.asm:1790
self:playSfx("Sfx_Wrong")
self.message = result self.message = result
return return
end end
-- engine/pokemon/bills_pc.asm:1762
self:playMonCry(result)
self.message = nil self.message = nil
self.phase = nil self.phase = nil
self.index, self.scroll = 1, 0 self.index, self.scroll = 1, 0
@@ -486,6 +512,8 @@ function BoxMenu:update(_dt)
if not ok then if not ok then
-- .no_space: `dec [hl]` puts the jumptable back on .PrepInsertCursor, -- .no_space: `dec [hl]` puts the jumptable back on .PrepInsertCursor,
-- so the refusal leaves the cursor exactly where it was. -- so the refusal leaves the cursor exactly where it was.
-- engine/pokemon/bills_pc.asm:1567
self:playSfx("Sfx_Wrong")
self.message = reason self.message = reason
else else
self:insertMon() self:insertMon()
@@ -528,6 +556,14 @@ function BoxMenu:playSfx(name)
if sfx and sfx[Sound.resolve(data, name)] then Sound.play(data, name) end if sfx and sfx[Sound.resolve(data, name)] then Sound.play(data, name) end
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 -- 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 -- screen reached. The cart asks first and starts the prompt on NO, the way
-- every irreversible choice in the game does. -- every irreversible choice in the game does.
@@ -541,6 +577,8 @@ function BoxMenu:askRelease()
local allowed, refusal = self:checkMailPreventBlackout() local allowed, refusal = self:checkMailPreventBlackout()
if not allowed then if not allowed then
self.phase = nil self.phase = nil
-- engine/pokemon/bills_pc.asm:1607
self:playSfx("Sfx_Wrong")
self.message = refusal self.message = refusal
return return
end end
@@ -568,6 +606,8 @@ function BoxMenu:askRelease()
self.message = err self.message = err
return return
end end
-- engine/pokemon/bills_pc.asm:1866
self:playMonCry(mon)
self.message = name .. " was released." self.message = name .. " was released."
self.phase = nil self.phase = nil
self:clampIndex() self:clampIndex()
@@ -627,14 +667,31 @@ function BoxMenu:picFor(mon)
return self:image(path) return self:image(path)
end 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 -- 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. -- (engine/pokemon/bills_pc.asm:1023-1042), the pad tiles at the palette's 0.
function BoxMenu:drawPicBlock(image, colors) function BoxMenu:drawPicBlock(image, colors)
if not image then return end if not image then return end
local G = love.graphics local G = love.graphics
local blank = colors and GbcPalette.color(colors, 1) or { 255, 255, 255 } self:fillPicBlock(colors)
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)
local pad = PIC_PAD[math.floor(image:getWidth() / 8)] or PIC_PAD[7] local pad = PIC_PAD[math.floor(image:getWidth() / 8)] or PIC_PAD[7]
G.setColor(1, 1, 1, 1) G.setColor(1, 1, 1, 1)
@@ -650,12 +707,12 @@ function BoxMenu:drawPicBlock(image, colors)
end end
function BoxMenu:drawPic(mon) function BoxMenu:drawPic(mon)
local image = self:picFor(mon)
if not image then return end
-- _CGB_BillsPC hands wTempMonDVs to GetPlayerOrMonPalettePointer, so the box -- _CGB_BillsPC hands wTempMonDVs to GetPlayerOrMonPalettePointer, so the box
-- pic takes the shiny row (engine/gfx/cgb_layouts.asm:292-293). -- pic takes the shiny row (engine/gfx/cgb_layouts.asm:292-293).
local colors = self.palettes local colors = self:panelColors(mon.species, mon.shiny)
and Palettes.monColors(self.palettes, 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) self:drawPicBlock(image, colors)
end end
@@ -664,17 +721,14 @@ end
-- with the party list's ICON_EGG standing in for a cache built before that. -- with the party list's ICON_EGG standing in for a cache built before that.
function BoxMenu:drawEggPic(mon) function BoxMenu:drawEggPic(mon)
local G = love.graphics local G = love.graphics
local colors = self.palettes local colors = self:panelColors("EGG", mon and mon.shiny)
and Palettes.monColors(self.palettes, "EGG", mon and mon.shiny)
local gfx = (self.menuGfx or {}).eggHatch local gfx = (self.menuGfx or {}).eggHatch
local image = self:image(gfx and gfx.egg) local image = self:image(gfx and gfx.egg)
if image then return self:drawPicBlock(image, colors) end 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 local entry = self.icons and self.icons.icons and self.icons.icons.ICON_EGG
image = self:image(entry and entry.image) image = self:image(entry and entry.image)
if not image then return end 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. -- The ICON_EGG sheet stacks its frames; the first is the egg at rest.
local w = entry.width or 16 local w = entry.width or 16
local h = math.min(entry.height or 16, image:getHeight()) local h = math.min(entry.height or 16, image:getHeight())
@@ -694,6 +748,58 @@ function BoxMenu:drawEggPic(mon)
G.setColor(1, 1, 1, 1) G.setColor(1, 1, 1, 1)
end 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 -- 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 -- 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 -- 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). -- Textbox at (8,0) with a 10x1 interior and the name at (10,1).
Chrome.box(8, 0, 12, 3) Chrome.box(8, 0, 12, 3)
Chrome.print(self:title(), 10, 1) Chrome.print(self:title(), 10, 1)
self:drawBoxArrows()
Chrome.box(8, 2, 12, 12) Chrome.box(8, 2, 12, 12)
-- BillsPC_RefreshTextboxes overwrites its own top corners with '└'/'┘' -- BillsPC_RefreshTextboxes overwrites its own top corners with '└'/'┘'
-- (engine/pokemon/bills_pc.asm:1204-1211) so the list reads as hanging -- (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) Chrome.print("\xe2\x99\x80", 5, 12)
end end
Chrome.print(mon.name or mon.species or "?", PIC_X, 14) Chrome.print(mon.name or mon.species or "?", PIC_X, 14)
self:drawHeldIcon(mon)
end end
else
self:fillPicBlock(self:panelColors())
end end
-- BillsPC_PlaceString: Textbox at (0,15) with a one-row interior, string at -- BillsPC_PlaceString: Textbox at (0,15) with a one-row interior, string at
+9 -5
View File
@@ -320,12 +320,15 @@ function ItemPcMenu:leaveDeposit()
end end
function ItemPcMenu:offerToDeposit(id, count) 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 if (count or 0) < 1 then return end
local def = self:def(id) local def = self:def(id)
local name = (def and def.name) or 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, self:askQuantity(count,
{ "How many do you", "want to deposit?" }, { "How many do you", "want to deposit?" },
function(qty) self:deposit(id, name, qty) end) function(qty) self:deposit(id, name, qty) end)
@@ -518,9 +521,10 @@ function ItemPcMenu:drawList()
local entry = self.rows[i] local entry = self.rows[i]
if i == self.listIndex then Chrome.cursor(5, ty) end if i == self.listIndex then Chrome.cursor(5, ty) end
Chrome.print(entry.name, 6, ty) Chrome.print(entry.name, 6, ty)
-- PlaceMenuItemQuantity (engine/menus/menu_2.asm:24): the xNN is the -- PlaceMenuItemQuantity (engine/menus/menu_2.asm:18, :24)
-- entry's second line, right-aligned in a blank-padded 2-digit field. if not self:cantToss(entry.id) then
Chrome.print(TIMES .. Chrome.number(entry.count, 2), 7, ty + 1) Chrome.print(TIMES .. Chrome.number(entry.count, 2), 7, ty + 1)
end
elseif i == self:listTotal() then elseif i == self:listTotal() then
if i == self.listIndex then Chrome.cursor(5, ty) end if i == self.listIndex then Chrome.cursor(5, ty) end
Chrome.print("CANCEL", 6, ty) Chrome.print("CANCEL", 6, ty)
+29
View File
@@ -16,6 +16,9 @@ local PackGfx = require("src.ui.gen2.PackGfx")
local Screens = require("src.ui.Screens") local Screens = require("src.ui.Screens")
local Strings = require("src.core.Strings") local Strings = require("src.core.Strings")
-- constants/sfx_constants.asm:3, :28
local SFX_DEX_FANFARE_50_79, SFX_WRONG = 0, 25
local PackMenu = {} local PackMenu = {}
PackMenu.__index = PackMenu PackMenu.__index = PackMenu
PackMenu.isOpaque = true PackMenu.isOpaque = true
@@ -192,6 +195,14 @@ function PackMenu:pocketOf(itemId)
return (def and def.pocket) or "ITEM" return (def and def.pocket) or "ITEM"
end 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 -- 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 -- (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 -- items.lua) still has to draw something a person can read, so the id stands
@@ -239,6 +250,14 @@ function PackMenu:rebuild()
} }
end end
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.rows = rows
self.index = math.min(self.index, #rows + 1) self.index = math.min(self.index, #rows + 1)
if self.index < 1 then self.index = 1 end 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 -- 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. -- 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. -- 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!", self.message = { "There was a trophy", "inside!",
"{PLAYER} sent the", "trophy home." } "{PLAYER} sent the", "trophy home." }
self:rebuild() self:rebuild()
@@ -641,6 +663,11 @@ function PackMenu:openTeachParty(row)
if id == moveId then allowed = true end if id == moveId then allowed = true end
end end
if not allowed then 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 if game.say then
game:say(("%s can't learn %s!"):format( game:say(("%s can't learn %s!"):format(
require("src.battle.gen2.Mon").displayName(mon), moveName)) require("src.battle.gen2.Mon").displayName(mon), moveName))
@@ -740,7 +767,9 @@ function PackMenu:update(_dt)
end end
-- engine/items/pack.asm:1290 Pack_InterpretJoypad .select -- 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() function PackMenu:armSwitch()
if self:pocket().id == "TM_HM" then return end
if self:isCancel() then return end if self:isCancel() then return end
if not self.rows[self.index] then return end if not self.rows[self.index] then return end
self.switching = self.index self.switching = self.index
+15 -4
View File
@@ -749,12 +749,23 @@ function Kit.chip(x, y, w, h, label, on, color, id)
end end
-- A status label with no interaction: outlined text, the "INSTALLED"/"UPDATE" -- A status label with no interaction: outlined text, the "INSTALLED"/"UPDATE"
-- markers on mod rows. -- markers on mod rows. Pass opts.fill for a solid chip (BETA badges); ink
function Kit.tag(x, y, w, h, label, color) -- 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 if not G then return end
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) 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, end
color or PAL.muted) 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 end
-- Checkbox row. Returns (newChecked, changed). -- Checkbox row. Returns (newChecked, changed).
+12 -2
View File
@@ -58,6 +58,13 @@ function Check.fullAssetName(version, osName, arch, port)
return fullAssetName(version, osName, arch, port) return fullAssetName(version, osName, arch, port)
end 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 CMD = "update_check_cmd"
local STATE = "update_check_state" local STATE = "update_check_state"
@@ -266,13 +273,16 @@ function Check.fullUpdateAction()
local st = Check.state() local st = Check.state()
if st.status ~= "needs_full" and st.status ~= "full_ready" then return nil end 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 "" local osName = love and love.system and love.system.getOS and love.system.getOS() or ""
if osName == "Android" and type(love.system.installApk) == "function" if osName == "Android" and type(st.full) == "table"
and type(st.full) == "table" and type(st.full.url) == "string" then 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 if st.status == "full_ready" and type(st.full.path) == "string" then
return { label = "Install Android update", kind = "install" } return { label = "Install Android update", kind = "install" }
end end
return { label = "Download Android update", kind = "download" } return { label = "Download Android update", kind = "download" }
end end
return { label = "Update app manually", url = st.full.url }
end
if osName == "iOS" then if osName == "iOS" then
return { label = "Re-sideload app", url = return { label = "Re-sideload app", url =
"https://github.com/bryanthaboi/gen1recomp/raw/refs/heads/main/mobile/ios/app-repo.json" } "https://github.com/bryanthaboi/gen1recomp/raw/refs/heads/main/mobile/ios/app-repo.json" }
+6
View File
@@ -243,6 +243,12 @@ local function doCheck(target)
return return
end 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 if compareVersions(rel.version, currentEngine) <= 0 then
-- We are now running a native shell at least as new as GitHub's latest -- 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. -- release, so a former minShell/payloadHost prompt no longer applies.
+37 -5
View File
@@ -150,6 +150,21 @@ local function pooledNPC(pool, data, mapId, obj)
end end
OverworldState.pooledNPC = pooledNPC -- exposed for tests 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 -- connection hops rendered around the current map: two, so
-- corner-adjacent maps (connections of connections) don't pop in and -- corner-adjacent maps (connections of connections) don't pop in and
-- out of the survey zoom at the seams (constants.world.neighborHops) -- 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) table.insert(self.npcs, npc)
end end
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 if self.player then
self.player.cellX, self.player.cellY = x, y self.player.cellX, self.player.cellY = x, y
self.player.px, self.player.py = x * 16, y * 16 self.player.px, self.player.py = x * 16, y * 16
@@ -4113,7 +4137,7 @@ function OverworldState:checkBadgeGate()
Game.stack:push(TextBox.new(Game, Game.stack:push(TextBox.new(Game,
(t["_" .. g.failText] or Strings("You don't have the\nBOULDERBADGE yet!")) (t["_" .. g.failText] or Strings("You don't have the\nBOULDERBADGE yet!"))
.. (t._Route22GateGuardICantLetYouPassText or ""), function() .. (t._Route22GateGuardICantLetYouPassText or ""), function()
self:scriptMove(p, "down", 1) self:scriptMove(p, "down", 1, nil, { collide = true })
end)) end))
return true return true
end end
@@ -4141,7 +4165,7 @@ function OverworldState:checkBadgeGate()
local text = (t["_" .. g.failText] or local text = (t["_" .. g.failText] or
Strings("You don't have the\n{RAM} yet!")):gsub("{RAM:wNameBuffer}", badgeName) Strings("You don't have the\n{RAM} yet!")):gsub("{RAM:wNameBuffer}", badgeName)
Game.stack:push(TextBox.new(Game, text, function() Game.stack:push(TextBox.new(Game, text, function()
self:scriptMove(p, "down", 1) self:scriptMove(p, "down", 1, nil, { collide = true })
end)) end))
return true return true
end end
@@ -4181,7 +4205,7 @@ function OverworldState:checkForcedMovement()
function() function()
local back = ({ up = "down", down = "up", local back = ({ up = "down", down = "up",
left = "right", right = "left" })[p.facing] left = "right", right = "left" })[p.facing]
self:scriptMove(p, back, 1) self:scriptMove(p, back, 1, nil, { collide = true })
end)) end))
return true return true
end end
@@ -4222,6 +4246,7 @@ function OverworldState:checkSeafoamCurrent()
-- push so the B3F stair warps underfoot cannot bounce you back. -- push so the B3F stair warps underfoot cannot bounce you back.
self.forcedWarp = false self.forcedWarp = false
require("src.core.Sound").play(Game.data, "Collision") require("src.core.Sound").play(Game.data, "Collision")
-- home/overworld.asm:1891
self:scriptMove(p, "up", c.y == 17 and 2 or 1) self:scriptMove(p, "up", c.y == 17 and 2 or 1)
return true return true
end end
@@ -4780,9 +4805,10 @@ end
-- scripted movement -- scripted movement
-- ------------------------------------------------------------------------- -- -------------------------------------------------------------------------
function OverworldState:scriptMove(entity, dir, tiles, onDone) function OverworldState:scriptMove(entity, dir, tiles, onDone, opts)
table.insert(self.scriptMoves, { table.insert(self.scriptMoves, {
entity = entity, dir = dir, remaining = tiles, onDone = onDone, entity = entity, dir = dir, remaining = tiles, onDone = onDone,
collide = opts and opts.collide or nil,
}) })
end end
@@ -4822,16 +4848,22 @@ function OverworldState:updateScriptMoves()
e.moving = true e.moving = true
e.marching = true e.marching = true
e.progress = 0 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 else
e.facing = mv.dir e.facing = mv.dir
local tx, ty = Collision.target(e.cellX, e.cellY, mv.dir) local tx, ty = Collision.target(e.cellX, e.cellY, mv.dir)
e.targetX, e.targetY = tx, ty e.targetX, e.targetY = tx, ty
e.moving = true e.moving = true
e.progress = 0 e.progress = 0
end
mv.remaining = mv.remaining - 1 mv.remaining = mv.remaining - 1
end end
end end
end
-- march_in_place toggles: re-arm the in-place cycle each time it ends. -- march_in_place toggles: re-arm the in-place cycle each time it ends.
-- Not a scriptMove, so an ambient marcher never trips the input lockout. -- Not a scriptMove, so an ambient marcher never trips the input lockout.
for entity in pairs(self.marchers or {}) do for entity in pairs(self.marchers or {}) do
+13 -1
View File
@@ -83,6 +83,7 @@ local SFX = {
WARP_TO = 19, WARP_TO = 19,
EXIT_BUILDING = 35, EXIT_BUILDING = 35,
JUMP_OVER_LEDGE = 0x16, JUMP_OVER_LEDGE = 0x16,
BUMP = 0x24,
} }
local EMOTE_SHOCK = 0 local EMOTE_SHOCK = 0
@@ -2183,6 +2184,13 @@ function World:playSfxNamed(want, fallbackId)
self:playSfx(self:sfxIdNamed(want, fallbackId)) self:playSfx(self:sfxIdNamed(want, fallbackId))
end 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: -- Script_specialsound (engine/overworld/scripting.asm:476) is not a fixed cue:
-- it farcalls CheckItemPocket (engine/items/items.asm:512), which writes -- it farcalls CheckItemPocket (engine/items/items.asm:512), which writes
-- wCurItem's pocket into wItemAttributeValue, and rings SFX.GET_TM for the -- 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 elseif result == "blocked" or result == "edge" then
self.turningDirection = nil self.turningDirection = nil
end 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 -- NormalStep, in order (engine/overworld/movement.asm:657-674): InitStep has
-- already moved OBJECT_TILE_COLLISION onto the destination. -- already moved OBJECT_TILE_COLLISION onto the destination.
if result == "moved" then self:playerStepGrass() end if result == "moved" then self:playerStepGrass() end
@@ -9738,7 +9749,8 @@ function World:stepBody()
local result = self:movePlayer(dir) local result = self:movePlayer(dir)
if result == "edge" then 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 elseif result == "blocked" and p.facing == dir then
-- .CheckNPC came back 2: something movable is in the way. The step is -- .CheckNPC came back 2: something movable is in the way. The step is
-- lost either way, and the boulder is what moves. -- lost either way, and the boulder is what moves.
+43 -7
View File
@@ -83,15 +83,36 @@ return function(game)
-- the announcement-time row performMove inserts directly as well as the -- the announcement-time row performMove inserts directly as well as the
-- ones animNext adds, which is the whole difference the fix makes. -- ones animNext adds, which is the whole difference the fix makes.
local function watchAnims(battle) local function watchAnims(battle)
local seen, mark = {}, {} local seen, mark, types = {}, {}, {}
return seen, function() return seen, function()
for _, row in ipairs(battle.queue) do for _, row in ipairs(battle.queue) do
if row.anim and not mark[row] then if row.anim and not mark[row] then
mark[row] = true mark[row] = true
seen[#seen + 1] = row.anim seen[#seen + 1] = row.anim
types[row.anim] = row.hit and row.hit.animType or false
end end
end end
end, types
end 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 end
local function listOf(seen) local function listOf(seen)
@@ -136,23 +157,28 @@ return function(game)
local battle = BattleState.newWild(game, "PIDGEY", 8) local battle = BattleState.newWild(game, "PIDGEY", 8)
battle.onFinish = function() end battle.onFinish = function() end
ow:pushBattle(battle) 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("the battle reached the menu", waitPhase(battle, "menu", 60, poll))
check("BIDE is the move on the list", pickBide(battle, poll)) check("BIDE is the move on the list", pickBide(battle, poll))
for _ = 1, 20 do poll() U.wait(1) end for _ = 1, 20 do poll() U.wait(1) end
U.shot(game, DIR .. "/bug375_store_spiral.png") 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") U.shot(game, DIR .. "/bug375_store_text.png")
check("the storing turn queued XSTATITEM_ANIM", has(seen, "XSTATITEM_ANIM")) 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("and not BIDE's hit animation: " .. listOf(seen), not has(seen, "BIDE"))
check("BIDE locked in for " .. tostring(battle.player.bideTurns) .. " turns", check("BIDE locked in for " .. tostring(battle.player.bideTurns) .. " turns",
battle.player.bideTurns ~= nil) 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 -- ride out the locked turns: the storing text needs A presses and the menu
-- never comes back until BIDE unleashes -- never comes back until BIDE unleashes
local released = false local released = false
for _ = 1, 120 do for _ = 1, 220 do
U.tap(game, "a") U.tap(game, "a")
for _ = 1, 6 do poll() U.wait(1) end for _ = 1, 6 do poll() U.wait(1) end
if has(seen, "BIDE") then if has(seen, "BIDE") then
@@ -179,7 +205,7 @@ return function(game)
foeBattle.enemy.mon.moves = { { id = "BIDE", pp = 10 } } foeBattle.enemy.mon.moves = { { id = "BIDE", pp = 10 } }
foeBattle.enemy.curMoves = foeBattle.enemy.mon.moves foeBattle.enemy.curMoves = foeBattle.enemy.mon.moves
ow:pushBattle(foeBattle) ow:pushBattle(foeBattle)
local foeSeen, foePoll = watchAnims(foeBattle) local foeSeen, foePoll, foeTypes = watchAnims(foeBattle)
check("the foe's battle reached the menu", check("the foe's battle reached the menu",
waitPhase(foeBattle, "menu", 60, foePoll)) waitPhase(foeBattle, "menu", 60, foePoll))
pickBide(foeBattle, foePoll) -- ours does not matter here, the foe's does 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: " check("the foe's storing turn queued XSTATITEM_DUPLICATE_ANIM: "
.. listOf(foeSeen), has(foeSeen, "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("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("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("white and balls spiral inward, silently. Then, still in silence, the")
U.log("energy. No flash on the RATTATA and no BIDE thud until the turn it") 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("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.") 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
+8 -5
View File
@@ -70,15 +70,18 @@ return function(game)
U.tap(game, "right") U.tap(game, "right")
shot("03-tmhm") -- TMs carry ×NN, the two HMs carry none shot("03-tmhm") -- TMs carry ×NN, the two HMs carry none
-- SELECT arms the row instead of registering it (#1427): the hollow ▷ marks -- SELECT armed the row here for #1427; since #1567 it is filtered out of the
-- TM_HEADBUTT while "Where should this be moved to?" holds the box. -- 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, "down")
U.tap(game, "select") U.tap(game, "select")
shot("04-tmhm-armed") shot("04-tmhm-select-ignored")
U.tap(game, "up") U.tap(game, "up")
shot("05-tmhm-destination") shot("05-tmhm-still-numbered")
U.tap(game, "a") 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), " ")) U.log("[driver] bag order:", table.concat(Bag.order(save), " "))
+112
View File
@@ -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
+168
View File
@@ -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
+196
View File
@@ -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
+226
View File
@@ -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
+228
View File
@@ -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
+105
View File
@@ -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
+28 -6
View File
@@ -221,6 +221,13 @@ return function(game)
stored[i] = mon(species, 10 + i) stored[i] = mon(species, 10 + i)
end end
Boxes.rename(save, 2, "GRASS") 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 }) local pc = PcMenu.new(game, { save = save })
show("20-pc-menu", pc) show("20-pc-menu", pc)
@@ -228,12 +235,27 @@ return function(game)
pc.pickIndex = 2 pc.pickIndex = 2
show("21-pc-changebox", pc) show("21-pc-changebox", pc)
show("22-pc-withdraw", BoxMenu.new(game, { -- Browsing paints the 7x7 block in BillsPCOrangePalette and only .PrepSubmenu
save = save, mode = "withdraw", -- 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.
show("23-pc-deposit", BoxMenu.new(game, { local wd = BoxMenu.new(game, { save = save, mode = "withdraw" })
save = save, mode = "deposit", 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 -- The two clock screens NEW GAME and Mom open (timeset.asm InitClock and
-- SetDayOfWeek), each at its picker rather than at its opening page. -- SetDayOfWeek), each at its picker rather than at its opening page.
+57
View File
@@ -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
+13
View File
@@ -58,6 +58,19 @@ return function(game)
U.log("selected:", ctl and ctl.spec, "x", ctl and ctl.x, "y", ctl and ctl.y) U.log("selected:", ctl and ctl.spec, "x", ctl and ctl.x, "y", ctl and ctl.y)
shot("studio_selected.png") 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 -- drag the selected control and confirm the model moved
local r = Studio.lastCanvas local r = Studio.lastCanvas
if r and ctl then 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")
+2
View File
@@ -316,6 +316,8 @@ check(view:find('"tab-sync"', 1, true) ~= nil,
local header = view:match("local HEADER_TABS = %{(.-)%}\n") local header = view:match("local HEADER_TABS = %{(.-)%}\n")
check(header and header:find('id = "skins"', 1, true) ~= nil, check(header and header:find('id = "skins"', 1, true) ~= nil,
"and it sits beside the skins tab") "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, check(view:find('"BETA"', 1, true) ~= nil,
"the button and the modal are labelled BETA") "the button and the modal are labelled BETA")
check(view:find("buildSyncModal", 1, true) ~= nil, check(view:find("buildSyncModal", 1, true) ~= nil,
+54 -1
View File
@@ -23,6 +23,7 @@ local function session()
Studio.pageIndex = 1 Studio.pageIndex = 1
Studio.selected = nil Studio.selected = nil
Studio.canvasIndex = 1 Studio.canvasIndex = 1
Studio.viewZoom = 1
Studio.aspectLock = true Studio.aspectLock = true
Studio.drag = nil Studio.drag = nil
Studio.dirty = false Studio.dirty = false
@@ -73,11 +74,16 @@ eq(Studio.selected, nil, "and clears the selection")
local ids = {} local ids = {}
for _, c in ipairs(Studio.CANVASES) do for _, c in ipairs(Studio.CANVASES) do
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") check(c.w > 0 and c.h > 0, c.id .. " has real pixel dimensions")
end
check(not ids[c.id], c.id .. " appears once") check(not ids[c.id], c.id .. " appears once")
ids[c.id] = true ids[c.id] = true
end end
check(ids.phone_portrait, "a phone portrait preset exists") 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.desktop_1080, "a desktop preset exists")
check(ids.ultrawide, "an ultrawide preset exists") check(ids.ultrawide, "an ultrawide preset exists")
check(ids.sgb_border, "a Super Game Boy border 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) Studio.setCanvas(#Studio.CANVASES + 1)
eq(Studio.canvasIndex, 1, "canvas selection wraps") 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.setCanvas(1)
Studio.viewZoom = 1
local cx, cy, cw, ch = Studio.canvasRect(0, 0, 800, 600) local cx, cy, cw, ch = Studio.canvasRect(0, 0, 800, 600)
near(cw / ch, Studio.canvas().w / Studio.canvas().h, 1e-6, near(cw / ch, Studio.canvas().w / Studio.canvas().h, 1e-6,
"the mock device keeps its aspect") "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(cx + cw / 2, 400, 1e-6, "centred horizontally")
near(cy + ch / 2, 300, 1e-6, "centred vertically") 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 -- ----------------------------------------------------------- touch bridge
session() session()
+43 -1
View File
@@ -18,6 +18,7 @@ local function session()
Studio.pageIndex = 1 Studio.pageIndex = 1
Studio.selected = nil Studio.selected = nil
Studio.canvasIndex = 1 Studio.canvasIndex = 1
Studio.viewZoom = 1
Studio.aspectLock = true Studio.aspectLock = true
Studio.drag = nil Studio.drag = nil
Studio.dirty = false Studio.dirty = false
@@ -325,11 +326,52 @@ love.graphics.getDimensions = love.graphics.getDimensions
or function() return 1280, 720 end or function() return 1280, 720 end
session() session()
Studio.addControl() 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) Studio.openModal(kind)
check(pcall(Studio.draw), "the studio draws with the " .. kind .. " modal up") check(pcall(Studio.draw), "the studio draws with the " .. kind .. " modal up")
end end
Studio.closeModal() 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) Studio.ask("sure?", function() end)
check(pcall(Studio.draw), "and with the confirm prompt up") check(pcall(Studio.draw), "and with the confirm prompt up")
Studio.confirmNo() Studio.confirmNo()
+35 -21
View File
@@ -136,11 +136,16 @@ for off = lo, hi do
end end
Zoom.offset = 0 Zoom.offset = 0
local capped = select(1, Renderer:worldViewSize()) useSkin("0.25,0.1,0.3,0.8")
useSkin("0.25,0.1,0.5,0.6", "overlay0_viewport_expand = true") local tallR = Renderer:frameRects()
local expanded = select(1, Renderer:worldViewSize()) local tallVw, tallVh = Renderer:worldViewSize()
check(expanded > capped, local tallSp = Zoom.scale(tallR.Sp)
"viewport_expand lets the survey world fill the cutout instead of the GB box") 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") useSkin("0.25,0.1,0.5,0.6")
setWindow(480, 800) setWindow(480, 800)
@@ -159,22 +164,14 @@ setWindow(1280, 720)
useSkin("0.25,0.1,0.5,0.6") useSkin("0.25,0.1,0.5,0.6")
local px, py, pw, ph, active = Playfield.rect(1280, 720) local px, py, pw, ph, active = Playfield.rect(1280, 720)
eq(active, true, "Gold sees the cutout too") eq(active, true, "Gold sees the cutout too")
eq(pw, 480, "the playfield is a whole multiple of 160") eq(px, 320, "the playfield is the cutout, at its origin")
eq(ph, 432, "and of 144") eq(py, 72, "on both axes")
check(inside(px, py, pw, ph, 320, 72, 640, 432), eq(pw, 640, "with its full width")
"centred inside the cutout") eq(ph, 432, "and its full height")
eq(Chrome.fitScale(1280, 720), 3, "Chrome fits the playfield") eq(Chrome.fitScale(1280, 720), 3, "Chrome fits whole GB pixels in it")
local cox, coy = Chrome.fitOrigin(1280, 720) local cox, coy = Chrome.fitOrigin(1280, 720)
eq(cox, px, "and centres the panel on it") eq(cox, 320 + 80, "and centres the panel on the cutout")
eq(coy, py, "on both axes") eq(coy, 72, "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")
local ew2, eh2, ex2, ey2, act2 = Playfield.push(1280, 720) local ew2, eh2, ex2, ey2, act2 = Playfield.push(1280, 720)
eq(act2, true, "push reports the frame is contained") 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(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(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(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") eq(select(1, Playfield.dimensions()), pw, "screens read the playfield as the display")
Playfield.pop() Playfield.pop()
eq(Playfield.entered, false, "pop leaves the frame") eq(Playfield.entered, false, "pop leaves the frame")
eq(select(1, Playfield.cutout(1280, 720)), 320, "and the cutout is visible again") 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") useSkin("0.4,0.4,0.1,0.1")
local sx, sy, sw, sh = Playfield.rect(1280, 720) local sx, sy, sw, sh = Playfield.rect(1280, 720)
check(inside(sx, sy, sw, sh, 512, 288, 128, 72), 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")
+9
View File
@@ -48,6 +48,15 @@ eq(Check.fullAssetName("1.4.2", "iOS", "arm64"),
eq(Check.fullAssetName("not-a-version", "Android", "arm64"), nil, eq(Check.fullAssetName("not-a-version", "Android", "arm64"), nil,
"invalid full-package version rejected") "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({ local withNotes = Check.parseRelease(Json.encode({
tag_name = "v1.4.2", tag_name = "v1.4.2",
body = "## Issues closed\n\n- #1 cart padding", body = "## Issues closed\n\n- #1 cart padding",
+339
View File
@@ -89,6 +89,15 @@ local MOVES = {
accuracy = 100, pp = 10, effect = "EFFECT_ENDURE" }, accuracy = 100, pp = 10, effect = "EFFECT_ENDURE" },
RAGE = { id = "RAGE", name = "RAGE", power = 20, type = "NORMAL", RAGE = { id = "RAGE", name = "RAGE", power = 20, type = "NORMAL",
accuracy = 100, pp = 20, effect = "EFFECT_RAGE" }, 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 = { local GROWTH = {
@@ -851,6 +860,83 @@ do
check("the trainer's PP is untouched", refuseParty[1].moves[1].pp, 35) check("the trainer's PP is untouched", refuseParty[1].moves[1].pp, 35)
end 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. -- Switching costs the turn and adds the newcomer to the participant set.
local switchParty = { local switchParty = {
Mon.new(DATA, "CYNDAQUIL", 10, { dvs = perfect }), Mon.new(DATA, "CYNDAQUIL", 10, { dvs = perfect }),
@@ -2713,6 +2799,259 @@ end)()
check("and its user reappears", b:volatile(player).vanished, nil) check("and its user reappears", b:volatile(player).vanished, nil)
end)() 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)) print(("gen2 battle: %d checks, %d failures"):format(checks, failures))
-- Raise rather than os.exit: tests/run_tests.lua dofiles this file, so an -- Raise rather than os.exit: tests/run_tests.lua dofiles this file, so an
+1 -1
View File
@@ -175,7 +175,7 @@ do
dealt = event.amount dealt = event.amount
end end
if event.kind == "message" if event.kind == "message"
and event.text == "It's not very effective..." then and event.text == "It's not very\neffective" then
sawNve = true sawNve = true
end end
end end
+111
View File
@@ -213,4 +213,115 @@ do
"all four arms compose in the cart's order") "all four arms compose in the cart's order")
end 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() S.finish()
+93
View File
@@ -483,6 +483,99 @@ packInput:press("right")
pack:update(0) pack:update(0)
check("pocket wraps around", pack:pocket().id, "ITEM") 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. -- CANCEL sits one past the last row.
check("cancel is past the end", pack:total(), #pack.rows + 1) check("cancel is past the end", pack:total(), #pack.rows + 1)
pack.index = pack:total() pack.index = pack:total()
+59 -4
View File
@@ -310,7 +310,8 @@ do
eq(save.inventory.TM_HEADBUTT, nil, "and leaves the bag") eq(save.inventory.TM_HEADBUTT, nil, "and leaves the bag")
end 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 do
local save = newSave(1) local save = newSave(1)
local game, input = newGame(save) local game, input = newGame(save)
@@ -320,9 +321,63 @@ do
press(pc, input, "right", "right") -- ITEM -> BALL -> KEY_ITEM pocket press(pc, input, "right", "right") -- ITEM -> BALL -> KEY_ITEM pocket
press(pc, input, "a") -- choose the BICYCLE press(pc, input, "a") -- choose the BICYCLE
eq(pc.qtyState, nil, "no quantity selector for a KEY ITEM") 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, 1, "the BICYCLE lands in the PC")
eq(save.pcItems.BICYCLE, nil, "and the BICYCLE never leaves the bag") eq(save.inventory.BICYCLE, nil, "and leaves the bag")
eq(save.inventory.BICYCLE, 1, "still there") 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 end
-- An empty bag never opens the PACK (.CheckItemsInBag). -- 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")
+40
View File
@@ -311,6 +311,46 @@ ow.transitioning = false -- undo the queued warp transition
Game.save.onBike = false Game.save.onBike = false
Game.save.inventory.BICYCLE = nil 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 -- Seafoam B4F: only the stairs square (dbmapcoord 7,11) refuses, and only
-- until BOTH boulders are down (CheckBothEventsSet) -- until BOTH boulders are down (CheckBothEventsSet)
Game.save.flags["EVENT_SEAFOAM4_BOULDER1_DOWN_HOLE"] = nil Game.save.flags["EVENT_SEAFOAM4_BOULDER1_DOWN_HOLE"] = nil
+11 -1
View File
@@ -42,7 +42,8 @@ local function trace(tb)
if row.text then if row.text then
out[#out + 1] = { text = (row.text:gsub("\n", " ")) } out[#out + 1] = { text = (row.text:gsub("\n", " ")) }
elseif row.anim then 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 elseif row.drain then
out[#out + 1] = { drain = true } out[#out + 1] = { drain = true }
end end
@@ -95,6 +96,9 @@ do
check(spiral ~= nil, "the storing turn plays XSTATITEM_ANIM") check(spiral ~= nil, "the storing turn plays XSTATITEM_ANIM")
eq(rows[spiral] and rows[spiral].isPlayer, true, eq(rows[spiral] and rows[spiral].isPlayer, true,
"on the player's side of the field") "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 used = indexOfText(rows, "used BIDE!")
local storing = indexOfText(rows, "storing energy!") local storing = indexOfText(rows, "storing energy!")
check(used and spiral and used < spiral, check(used and spiral and used < spiral,
@@ -119,6 +123,8 @@ do
local dup = indexOfAnim(rows, "XSTATITEM_DUPLICATE_ANIM") local dup = indexOfAnim(rows, "XSTATITEM_DUPLICATE_ANIM")
check(dup ~= nil, "the foe's storing turn plays 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") 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 end
-- locked turns: .BideCheck decrements with no text of its own in pokered and -- 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(unleashed ~= nil, "the release prints UnleashedEnergyText")
check(hit ~= nil, "the release plays BIDE's own animation (#375)") check(hit ~= nil, "the release plays BIDE's own animation (#375)")
eq(rows[hit] and rows[hit].isPlayer, true, "from the player's side") 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, check(unleashed and hit and unleashed < hit,
"the animation follows the text") "the animation follows the text")
local drainAt local drainAt
+126
View File
@@ -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()
+2
View File
@@ -3623,6 +3623,8 @@ runSuites({ "tests/rom_importer_android_mod_pick_test.lua" })
-- ---------------------------------------------- import with no picker (#482) -- ---------------------------------------------- import with no picker (#482)
runSuites({ "tests/rom_importer_no_picker_test.lua" }) runSuites({ "tests/rom_importer_no_picker_test.lua" })
runSuites({ "tests/rom_importer_double_pick_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 -- ---------------------------------------------- Switch platform capabilities
-- platform_nx_* / rom_importer_nx_* live in tests/engine/ (ROM-free T2) so -- platform_nx_* / rom_importer_nx_* live in tests/engine/ (ROM-free T2) so
-- CI's headless lane runs them without data/generated/. -- CI's headless lane runs them without data/generated/.
+3
View File
@@ -699,6 +699,9 @@ REQUIRED_SYMBOLS = {
# marker without this symbol. # marker without this symbol.
"MonMenuIcons", "Icons", "IconPointers", "HeldItemIcons", "MonMenuIcons", "Icons", "IconPointers", "HeldItemIcons",
"PokedexDataPointerTable", "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) # New-game / Pokecenter respawn table (data/maps/spawn_points.asm)
"SpawnPoints", "SpawnPoints",
"NewPokedexOrder", "AlphabeticalPokedexOrder", "Landmarks", "NewPokedexOrder", "AlphabeticalPokedexOrder", "Landmarks",
+72 -56
View File
@@ -12912,6 +12912,10 @@
105, 105,
16837 16837
], ],
"BillsPCOrangePalette": [
2,
21965
],
"BlastoiseBackpic": [ "BlastoiseBackpic": [
25, 25,
30882 30882
@@ -12936,6 +12940,10 @@
107, 107,
21761 21761
], ],
"BoltEmote": [
5,
17737
],
"BugCatchingContestantEventFlagTable": [ "BugCatchingContestantEventFlagTable": [
4, 4,
32186 32186
@@ -12968,6 +12976,30 @@
26, 26,
26667 26667
], ],
"CardFlipLZ01": [
56,
21795
],
"CardFlipLZ02": [
56,
22197
],
"CardFlipLZ03": [
56,
21736
],
"CardFlipOffButtonGFX": [
56,
21763
],
"CardFlipOnButtonGFX": [
56,
21779
],
"CardFlipTilemap": [
56,
22809
],
"CardStatusGFX": [ "CardStatusGFX": [
9, 9,
22287 22287
@@ -13612,6 +13644,10 @@
106, 106,
19800 19800
], ],
"FishEmote": [
5,
17865
],
"FishGroups": [ "FishGroups": [
36, 36,
27127 27127
@@ -13956,6 +13992,10 @@
4, 4,
26664 26664
], ],
"HeartEmote": [
5,
17673
],
"HeldItemIcons": [ "HeldItemIcons": [
35, 35,
26843 26843
@@ -15420,6 +15460,10 @@
5, 5,
18398 18398
], ],
"PCMailGFX": [
56,
31768
],
"PackGFX": [ "PackGFX": [
4, 4,
21553 21553
@@ -16096,22 +16140,6 @@
5, 5,
17417 17417
], ],
"HeartEmote": [
5,
17673
],
"BoltEmote": [
5,
17737
],
"SleepEmote": [
5,
17801
],
"FishEmote": [
5,
17865
],
"Shrink1Pic": [ "Shrink1Pic": [
62, 62,
30142 30142
@@ -16156,6 +16184,26 @@
106, 106,
22935 22935
], ],
"SleepEmote": [
5,
17801
],
"Slots1LZ": [
36,
31138
],
"Slots2LZ": [
36,
31522
],
"Slots3LZ": [
36,
32130
],
"SlotsTilemap": [
36,
30898
],
"SlowbroBackpic": [ "SlowbroBackpic": [
26, 26,
32198 32198
@@ -16356,6 +16404,14 @@
105, 105,
22509 22509
], ],
"StatsScreenPagePals": [
2,
21715
],
"StatsScreenPageTilesGFX": [
62,
19106
],
"StatsScreen_PlaceFrontpic": [ "StatsScreen_PlaceFrontpic": [
20, 20,
20675 20675
@@ -17667,46 +17723,6 @@
"wBaseUnusedFrontpic": [ "wBaseUnusedFrontpic": [
1, 1,
53554 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": { "tilesets": {
+56 -40
View File
@@ -12912,6 +12912,10 @@
105, 105,
16840 16840
], ],
"BillsPCOrangePalette": [
2,
21965
],
"BlastoiseBackpic": [ "BlastoiseBackpic": [
25, 25,
31647 31647
@@ -12972,6 +12976,30 @@
26, 26,
27406 27406
], ],
"CardFlipLZ01": [
56,
21795
],
"CardFlipLZ02": [
56,
22197
],
"CardFlipLZ03": [
56,
21736
],
"CardFlipOffButtonGFX": [
56,
21763
],
"CardFlipOnButtonGFX": [
56,
21779
],
"CardFlipTilemap": [
56,
22809
],
"CardStatusGFX": [ "CardStatusGFX": [
9, 9,
22287 22287
@@ -15432,6 +15460,10 @@
5, 5,
18398 18398
], ],
"PCMailGFX": [
56,
31768
],
"PackGFX": [ "PackGFX": [
4, 4,
21553 21553
@@ -16156,6 +16188,22 @@
5, 5,
17801 17801
], ],
"Slots1LZ": [
36,
31138
],
"Slots2LZ": [
36,
31522
],
"Slots3LZ": [
36,
32130
],
"SlotsTilemap": [
36,
30898
],
"SlowbroBackpic": [ "SlowbroBackpic": [
26, 26,
31900 31900
@@ -16356,6 +16404,14 @@
105, 105,
22501 22501
], ],
"StatsScreenPagePals": [
2,
21715
],
"StatsScreenPageTilesGFX": [
62,
19106
],
"StatsScreen_PlaceFrontpic": [ "StatsScreen_PlaceFrontpic": [
20, 20,
20675 20675
@@ -17667,46 +17723,6 @@
"wBaseUnusedFrontpic": [ "wBaseUnusedFrontpic": [
1, 1,
53554 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": { "tilesets": {