mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-28 09:54:43 +02:00
Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4b2e772ab2 | |||
| 8e9206b612 | |||
| dc99b1f8da | |||
| 377f1f8a71 | |||
| 2441996d45 | |||
| 5e7dc556f5 | |||
| 71a5602b58 | |||
| ebd315b01e | |||
| e9b815fcac | |||
| fbaf0441ee | |||
| d2a86cd258 | |||
| 8b56ec2d77 | |||
| f11724f08a | |||
| bd3e02986d | |||
| eed246f645 | |||
| c9d06779d8 | |||
| 48d8a4e922 | |||
| 9a05de4474 | |||
| 5c833a3d73 | |||
| 5051cb902d | |||
| 2720bfb5d8 | |||
| 54ba0f04ff | |||
| a231657093 | |||
| 6f587520d4 | |||
| 3a0a72102f | |||
| fe31293566 | |||
| e41f2c528a | |||
| c8eeeaa1f6 | |||
| 273350d11e | |||
| 49a5408c2d |
+19
-12
@@ -1251,19 +1251,26 @@ local function pokemonTower2FRivalScript(playerX)
|
||||
and TOWER_RIVAL_EXIT_DOWN_THEN_RIGHT
|
||||
or TOWER_RIVAL_EXIT_RIGHT_THEN_DOWN
|
||||
return {
|
||||
{ "face_player" }, -- 1
|
||||
{ "check_flag", "EVENT_BEAT_POKEMON_TOWER_RIVAL" }, -- 2
|
||||
{ "jump_if_true", 12 }, -- 3
|
||||
{ "show_text", "_PokemonTower2FRivalWhatBringsYouHereText" }, -- 4
|
||||
{ "rival_battle", "OPP_RIVAL2", 4 }, -- 5
|
||||
{ "jump_if_false", "end" }, -- 6 loss: stay
|
||||
{ "set_flag", "EVENT_BEAT_POKEMON_TOWER_RIVAL" }, -- 7
|
||||
{ "show_text", "_PokemonTower2FRivalDefeatedText" }, -- 8
|
||||
{ "face_player" },
|
||||
{ "check_flag", "EVENT_BEAT_POKEMON_TOWER_RIVAL" },
|
||||
{ "jump_if_true", "beaten" },
|
||||
{ "show_text", "_PokemonTower2FRivalWhatBringsYouHereText" },
|
||||
-- .DefeatedText rides BIT_PRINT_END_BATTLE_TEXT, so it prints on the
|
||||
-- battle screen -- scripts/PokemonTower2F.asm:145-150
|
||||
{ "save_end_battle_text", "_PokemonTower2FRivalDefeatedText" },
|
||||
{ "rival_battle", "OPP_RIVAL2", 4 },
|
||||
{ "jump_if_false", "end" }, -- loss: stay
|
||||
{ "set_flag", "EVENT_BEAT_POKEMON_TOWER_RIVAL" },
|
||||
-- the win re-runs DisplayTextID on the beaten branch
|
||||
-- scripts/PokemonTower2F.asm:72-75, :137-140
|
||||
{ "show_text", "_PokemonTower2FRivalHowsYourDexText" },
|
||||
{ "play_music", "Music_MeetRival", { start = "rival" } },
|
||||
{ "walk_npc", 1, exitDirs }, -- 9
|
||||
{ "hide_object", "POKEMON_TOWER_2F", "POKEMONTOWER2F_RIVAL" }, -- 10
|
||||
{ "jump", "end" }, -- 11
|
||||
{ "show_text", "_PokemonTower2FRivalHowsYourDexText" }, -- 12
|
||||
{ "walk_npc", 1, exitDirs },
|
||||
{ "hide_object", "POKEMON_TOWER_2F", "POKEMONTOWER2F_RIVAL" },
|
||||
{ "play_default_music" }, -- scripts/PokemonTower2F.asm:124
|
||||
{ "jump", "end" },
|
||||
{ "label", "beaten" },
|
||||
{ "show_text", "_PokemonTower2FRivalHowsYourDexText" },
|
||||
}
|
||||
end
|
||||
|
||||
|
||||
+20
-21
@@ -167,13 +167,11 @@ M.POKEMON_TOWER_6F = {
|
||||
-- PlayCry RESTLESS_SOUL (EQU MAROWAK, constants/pokemon_constants
|
||||
-- .asm:209) + WaitForSoundToFinish + DelayFrames 30 before the
|
||||
-- calmed line; the port dropped the first text and the cry
|
||||
-- (#867). play_cry arms the next show_text, so the cry rides
|
||||
-- the calmed box's open with the button prompt kept, and the
|
||||
-- wait row stands in for the asm's 30-frame gap.
|
||||
-- (#867). text/PokemonTower6F.asm:1-5 (#1849)
|
||||
local rows = {
|
||||
{ "play_cry", "MAROWAK" },
|
||||
{ "show_text", t._PokemonTower6FGhostWasCubonesMotherText
|
||||
or "The GHOST was the\nrestless soul of\vCUBONE's mother!" },
|
||||
{ "play_cry", "MAROWAK", true },
|
||||
{ "wait", 30 },
|
||||
{ "show_text", t._PokemonTower6FSoulWasCalmedText
|
||||
or "The mother's soul\nwas calmed.\012It departed to\nthe afterlife!" },
|
||||
@@ -191,7 +189,8 @@ M.POKEMON_TOWER_6F = {
|
||||
end
|
||||
ow:afterBattle(result, battle)
|
||||
end
|
||||
game.stack:push(battle)
|
||||
-- InitWildBattle runs the wipe before the disguise (core.asm:6695-6702)
|
||||
ow:pushBattle(battle)
|
||||
end))
|
||||
return true
|
||||
end,
|
||||
@@ -443,6 +442,9 @@ M.ROCKET_HIDEOUT_B4F = {
|
||||
or "I hope we meet\nagain..."
|
||||
game.stack:push(TextBox.new(game, impressed, function()
|
||||
local battle = BattleState.newTrainer(game, "OPP_GIOVANNI", 1)
|
||||
-- SaveEndBattleTextPointers (scripts/RocketHideoutB4F.asm:99-120):
|
||||
-- PrintEndBattleText prints it on the battle screen (#1817)
|
||||
battle.endBattleText = TextBox.substitute(game, cannotBe)
|
||||
battle.onFinish = function(result)
|
||||
if result ~= "win" then
|
||||
ow:afterBattle(result, battle)
|
||||
@@ -451,22 +453,19 @@ M.ROCKET_HIDEOUT_B4F = {
|
||||
end
|
||||
game.save.defeatedTrainers[npc.id] = true
|
||||
game.save.flags.EVENT_BEAT_ROCKET_HIDEOUT_GIOVANNI = true
|
||||
-- End-battle "WHAT!" then BeatGiovanniScript's hope text,
|
||||
-- fade, HideObject Giovanni, ShowObject Silph Scope.
|
||||
game.stack:push(TextBox.new(game, cannotBe, function()
|
||||
game.stack:push(TextBox.new(game, hope, function()
|
||||
local Transition = require("src.render.Transition")
|
||||
game.stack:push(Transition.new(game, function()
|
||||
local Commands = require("src.script.Commands")
|
||||
local ctx = { game = game, save = game.save, overworld = ow }
|
||||
Commands.hide_object(ctx, "ROCKET_HIDEOUT_B4F",
|
||||
"ROCKETHIDEOUTB4F_GIOVANNI")
|
||||
Commands.show_object(ctx, "ROCKET_HIDEOUT_B4F",
|
||||
"ROCKETHIDEOUTB4F_SILPH_SCOPE")
|
||||
end, function()
|
||||
ow:afterBattle(result, battle)
|
||||
done()
|
||||
end))
|
||||
-- BeatGiovanniScript (scripts/RocketHideoutB4F.asm)
|
||||
game.stack:push(TextBox.new(game, hope, function()
|
||||
local Transition = require("src.render.Transition")
|
||||
game.stack:push(Transition.new(game, function()
|
||||
local Commands = require("src.script.Commands")
|
||||
local ctx = { game = game, save = game.save, overworld = ow }
|
||||
Commands.hide_object(ctx, "ROCKET_HIDEOUT_B4F",
|
||||
"ROCKETHIDEOUTB4F_GIOVANNI")
|
||||
Commands.show_object(ctx, "ROCKET_HIDEOUT_B4F",
|
||||
"ROCKETHIDEOUTB4F_SILPH_SCOPE")
|
||||
end, function()
|
||||
ow:afterBattle(result, battle)
|
||||
done()
|
||||
end))
|
||||
end))
|
||||
end
|
||||
|
||||
@@ -1111,6 +1111,37 @@ own state, preserving selective control outside a battle; a wrapper that only
|
||||
owns battle presentation should return `false` only for its active battle or
|
||||
text-box state.
|
||||
|
||||
`pokemon.level_visible` (RFC 0019) takes a Pokémon's level off the screens
|
||||
that print it, without moving anything else on them:
|
||||
|
||||
```lua
|
||||
mod.hooks:wrap("pokemon.level_visible", function(next, mon, ctx)
|
||||
-- ctx = { where = "battle.enemy" | "battle.player" | "party" | "summary",
|
||||
-- game = <Game> }
|
||||
if myMode.active and ctx.where ~= "party" then return false end
|
||||
return next(mon, ctx)
|
||||
end)
|
||||
```
|
||||
|
||||
It receives `(next, mon, ctx)` and defaults to `true`, so vanilla rendering
|
||||
is unchanged. Only an explicit `false` suppresses; `nil` and anything else
|
||||
print, so a wrapper that forgets a branch cannot blank a screen by accident.
|
||||
The `<LV>` glyph goes with the digits, and on status page 2 so does the
|
||||
`<to>` arrow that points at the next level, because an arrow with nothing
|
||||
after it is half a sentence. A status condition still replaces the level on
|
||||
a battle healthbox exactly as it does in the cart, so hiding a level never
|
||||
hides `PSN` or `BRN`.
|
||||
|
||||
`ctx.where` names the surface rather than the widget, because the number
|
||||
means different things on different screens: on a battle HUD an opponent's
|
||||
level is information about them, on the party and status screens your own
|
||||
level is information about you. A mode can hide one and keep the other.
|
||||
|
||||
**Gen 1 only for now.** The Gen 2 screens keep their own level readouts and
|
||||
do not consult this hook, and neither does the Gen 1 PC box list, where the
|
||||
level is part of a row label rather than a drawn field. Both are noted in
|
||||
RFC 0019 as follow-ups.
|
||||
|
||||
`core.logic_speed` receives `(next, game)` once per `Game:logicSpeed()` call
|
||||
(once per frame). Vanilla behavior resolves the per-category GAME SPEED
|
||||
option (`GameSpeed.CATEGORIES`: overworld/battle/menu) for whichever
|
||||
|
||||
@@ -821,7 +821,7 @@ mod.content.phone_contacts:patch("PHONE_YOUNGSTER_JOEY", { map = "ROUTE_31" })
|
||||
| `catchRate` | integer 0..255 | yes |
|
||||
| `cry` | cries id | no |
|
||||
| `dex` | integer >= 1 | yes |
|
||||
| `dexEntry` | {heightFt, heightIn, heightM?, kind, text, weight, weightKg?} | no |
|
||||
| `dexEntry` | {heightFt, heightIn, heightM?, kind, text, text2?, weight, weightKg?} | no |
|
||||
| `evolutions` | list of {item?, level?, method, species} | yes |
|
||||
| `frontSize` | integer 1..7 | yes |
|
||||
| `growthRate` | growth_rates id | yes |
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
# RFC 0019: `pokemon.level_visible` — a level a mode can take off the screen
|
||||
|
||||
## Status
|
||||
|
||||
Proposed.
|
||||
|
||||
## Motivation
|
||||
|
||||
A Pokémon's level is printed on four Gen 1 surfaces — both battle
|
||||
healthboxes, the party rows, and page 1 and page 2 of the status screen —
|
||||
and every one of them prints it unconditionally. There is no seam. A mode
|
||||
that wants the number gone has exactly two options today, and both are bad.
|
||||
|
||||
It can paint over the text from `render.hud`, which means knowing four pixel
|
||||
rectangles, matching the background shade, and surviving the palette flashes
|
||||
and the healthbox slide-in. Or it can monkey-patch the drawing modules from
|
||||
inside its sandbox, which works — `require` hands a mod the engine's own
|
||||
table — and is precisely what `CONTRIBUTING-mods.md` tells mods not to do.
|
||||
A mod that took the second road already deleted its patching layer once, for
|
||||
the reasons that document gives.
|
||||
|
||||
The motivating case is a battle royale where every party is scaled to a
|
||||
shared rung that rises with the fog. The number on the healthbox is
|
||||
therefore never news — it is the same for everyone, it changes on a clock,
|
||||
and a player reading `:L37` on an opponent learns nothing except that they
|
||||
are playing the same match. Worse, it reads as a threat it is not: a Lv37
|
||||
opponent looks dangerous to a player who has not worked out that their own
|
||||
team is Lv37 too. The mode wants the level off the HUD and out of the party
|
||||
list, and the announcement it replaces it with is one line about everyone
|
||||
getting stronger at once.
|
||||
|
||||
Nothing about that is specific to a battle royale. A randomizer that hides
|
||||
levels to keep an encounter unreadable, a challenge run that forbids
|
||||
level-checking, a hard mode that withholds an opponent's level, and a
|
||||
"blind" Nuzlocke all want the same switch, and none of them should have to
|
||||
learn where the `<LV>` glyph lives.
|
||||
|
||||
## The decision it extends
|
||||
|
||||
This extends the **additive, guarded seam convention** Route B in
|
||||
`CONTRIBUTING-mods.md` documents, and is gated by the parity guarantee
|
||||
`tests/engine/gate_meta_coverage.lua` enforces.
|
||||
|
||||
It sits with the presentation predicates already on the battle screen —
|
||||
`battle.status_hud_visible`, `battle.bottom_ui_visible` and
|
||||
`battle.caught_marker_visible` — and takes the same shape: a hook consulted
|
||||
behind `Runtime.wantsHook`, defaulting to visible, where only an explicit
|
||||
`false` suppresses. The difference is that a level is not a battle-only
|
||||
readout, so this one is not named `battle.*` and carries the surface that
|
||||
asked.
|
||||
|
||||
There is no in-repo D-number registry to amend.
|
||||
|
||||
## Exact API delta
|
||||
|
||||
### New hook: `pokemon.level_visible`
|
||||
|
||||
```lua
|
||||
mod.hooks:wrap("pokemon.level_visible", function(next, mon, ctx)
|
||||
-- ctx = { where = "battle.enemy" | "battle.player" | "party" | "summary",
|
||||
-- game = <Game> }
|
||||
if myMode.active and ctx.where ~= "party" then
|
||||
return false -- the level is not printed on that surface
|
||||
end
|
||||
return next(mon, ctx) -- true: printed, as today
|
||||
end)
|
||||
```
|
||||
|
||||
Default `true`. Only an explicit `false` suppresses; `nil` and every other
|
||||
value print, so a wrapper that forgets a branch cannot blank a screen by
|
||||
accident.
|
||||
|
||||
`ctx.where` names the surface rather than the widget, because the number
|
||||
means different things on different screens: on a battle HUD an opponent's
|
||||
level is information about *them*, on the party and status screens your own
|
||||
level is information about *you*. A mode that wants to hide the first and
|
||||
keep the second can, and the motivating mode does exactly that.
|
||||
|
||||
### New module: `src/ui/LevelDisplay.lua`
|
||||
|
||||
One function, `LevelDisplay.visible(mon, where, game)`, wrapping the
|
||||
`wantsHook`/`call` pair. It exists so the four call sites are a one-line
|
||||
guard each instead of four copies of the same five lines, and so the hook
|
||||
has one definition of "visible" rather than four that can drift.
|
||||
|
||||
### Call sites
|
||||
|
||||
| Surface | File | `where` |
|
||||
| --- | --- | --- |
|
||||
| Enemy healthbox | `src/battle/BattleState.lua` | `battle.enemy` |
|
||||
| Player healthbox | `src/battle/BattleState.lua` | `battle.player` |
|
||||
| Party rows | `src/ui/PartyMenu.lua` | `party` |
|
||||
| Status page 1 and 2 | `src/ui/SummaryMenu.lua` | `summary` |
|
||||
|
||||
No layout moves. Each site keeps its own hand-rolled PrintLevel rule
|
||||
(`home/pokemon.asm:335-345` — the `<LV>` tile, then the digits, with a level
|
||||
of 100 writing its third digit back over the tile); it just asks first.
|
||||
|
||||
Two details are deliberate:
|
||||
|
||||
- **A status condition still replaces the level on a healthbox**, exactly as
|
||||
it does in the cart, so hiding the level never hides `PSN` or `BRN`. The
|
||||
guard is an `elseif` on the existing status branch, not a wrapper around
|
||||
it.
|
||||
- **On status page 2 the `<to>` arrow is hidden with the level it points
|
||||
at.** The arrow introduces the next level; on its own it is half a
|
||||
sentence. The EXP-to-next-level figure beside it is a different field and
|
||||
still prints.
|
||||
|
||||
### No other surface changes
|
||||
|
||||
No event, no registry, no save field, no manifest key.
|
||||
|
||||
## Migration
|
||||
|
||||
None. The hook is additive and defaults to current behaviour.
|
||||
|
||||
## Verification
|
||||
|
||||
- `tests/modkit/cases/pokemon_level_visible.lua` — the contract through the
|
||||
public mod API: default true with no mod, `false` suppresses, the surface
|
||||
and the mon reach the hook, falling through prints, and a `nil` mon
|
||||
answers rather than throwing.
|
||||
- `tests/engine/gate_hooks.lua` — the structural parity gate picks the hook
|
||||
up automatically, because it walks the live catalog rather than a list.
|
||||
- `tests/engine/gate_meta_coverage.lua` — the coverage ratchet; the seam is
|
||||
covered by name from the change that introduces it, so it never enters the
|
||||
DEBT ledger.
|
||||
|
||||
## Backward compatibility
|
||||
|
||||
A build with no mod wrapping the hook never reaches `Runtime.call`:
|
||||
`wantsHook` is checked first, which matters because two of the four sites
|
||||
are on a per-frame draw path. Pixels are unchanged, and the parity gates
|
||||
assert it.
|
||||
|
||||
## Scope, and what is deliberately not in this change
|
||||
|
||||
**Gen 1 only.** The Gen 2 screens keep their own level readouts
|
||||
(`src/ui/gen2/PartyMenu.lua`, `SummaryMenu.lua`, `BoxMenu.lua`,
|
||||
`HallOfFame.lua`) and do not consult the hook. So does the Gen 1 PC box list
|
||||
(`src/ui/BoxMenu.lua`), where the level is baked into a row label string
|
||||
rather than drawn as a field, and the box-to-PNG print path beside it.
|
||||
|
||||
Those are mechanical follow-ups, held back so this change stays reviewable
|
||||
against screens that can actually be exercised here. The limitation is
|
||||
stated in `docs/modding.md` beside the hook, so a mod author reads it before
|
||||
depending on it rather than after.
|
||||
|
||||
## Compatibility seam for older engines
|
||||
|
||||
There is none, and none is possible without patching: the call sites are
|
||||
mid-draw, so a mod on a stock engine cannot reach them. That is the argument
|
||||
for the seam rather than a gap around it — the alternatives available to a
|
||||
mod today are painting over the engine's own pixels or reaching into its
|
||||
render modules, and the second is the thing the mod contract exists to
|
||||
prevent.
|
||||
@@ -4,7 +4,7 @@
|
||||
<name>gen1recomp</name>
|
||||
<summary>Pokémon Gen 1/2 recompilation</summary>
|
||||
<metadata_license>CC0-1.0</metadata_license>
|
||||
<project_license>LicenseRef-proprietary</project_license>
|
||||
<project_license>MIT</project_license>
|
||||
<developer_name>The Bois Club</developer_name>
|
||||
<description>
|
||||
<p>
|
||||
@@ -14,7 +14,8 @@
|
||||
</p>
|
||||
</description>
|
||||
<launchable type="desktop-id">com.theboisclub.gen1recomp.desktop</launchable>
|
||||
<url type="homepage">https://github.com/bryanthaboi/gen1recomp</url>
|
||||
<url type="homepage">https://gen1re.com/</url>
|
||||
<url type="vcs-browser">https://github.com/bryanthaboi/gen1recomp</url>
|
||||
<screenshots>
|
||||
<screenshot type="default">
|
||||
<caption>Launcher</caption>
|
||||
|
||||
@@ -12,6 +12,34 @@
|
||||
"tintColor": "3b5ca8",
|
||||
"category": "games",
|
||||
"versions": [
|
||||
{
|
||||
"version": "0.2.30",
|
||||
"date": "2026-08-27",
|
||||
"size": 21375253,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.30/gen1recomp++-0.2.30-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1562 Link battles show no withdraw message on switch\n- #1804 closed poke ball is not shown sometimes after catching\n- #1805 Missing jingle + closing menu when using ability enhancers\n- #1810 Wrong amount of hidden coins in the game corner\n- #1811 Slot machine issues\n- #1814 Rocket has no vision\n- #1817 Giovanni dialogue in the hideout\n- #1818 Sound effect missing when teaching a move\n- #1819 Option box in Pokemon menu not connected to bottom of the screen\n- #1821 Cursor position still on SAVE after loading\n- #1826 EXP calculation after opposing trainer switches out its PKMN\n- #1827 Behavior of sprite replacement mods on Crystal\n- #1829 Pokedex could use further clean up\n- #1831 Spinning too fast\n- #1833 Cancel option missing when selecting Pokemon in the overworld\n- #1834 Possible bug with Unown in Gold, Silver, Crystal\n- #1836 Crystal Kanto Flight Bugged\n- #1840 Two small issues with flying\n- #1842 Pokemon Tower rival bugs\n- #1845 Pokemon Trainer Item PC wrong Layout\n- #1846 No jingle when leveling up with rare candy\n- #1847 Someones / Bill PC Wrong Layout\n- #1848 Nightshade animation too slow\n- #1849 Pokemon Tower Marowak bugs\n- #1853 Try Catching Pokemon have wierd White nohing\n- #1858 (GSC) Broken colors on stats screen since 0.2.27\n- #1860 (GSC) Jingles are cut off if you advance the dialogue while they play\n- #1862 (GSC) Whirlpool disappearing before the SFX ends\n\n## Contributors\n\n- @bryanthaboi\n- @colsonrice\n- @thibautbus"
|
||||
},
|
||||
{
|
||||
"version": "0.2.29",
|
||||
"date": "2026-08-26",
|
||||
"size": 21366635,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.29/gen1recomp++-0.2.29-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @bryanthaboi\n- @ShaneMcGovernIE"
|
||||
},
|
||||
{
|
||||
"version": "0.2.28",
|
||||
"date": "2026-08-26",
|
||||
"size": 21366087,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.28/gen1recomp++-0.2.28-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @bryanthaboi"
|
||||
},
|
||||
{
|
||||
"version": "0.2.27",
|
||||
"date": "2026-08-26",
|
||||
"size": 21365966,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.27/gen1recomp++-0.2.27-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @1Jamie\n- @bryanthaboi\n- @ShaneMcGovernIE"
|
||||
},
|
||||
{
|
||||
"version": "0.2.26",
|
||||
"date": "2026-08-25",
|
||||
|
||||
@@ -104,7 +104,9 @@ local SE_FRAMES = {
|
||||
SE_FLASH_MON_PIC = 4, SE_FLASH_ENEMY_MON_PIC = 4,
|
||||
SE_TRANSFORM_MON = 4,
|
||||
SE_SUBSTITUTE_MON = 3,
|
||||
SE_WAVY_SCREEN = 255, -- AnimationWavyScreen: ld c, $ff frames
|
||||
-- AnimationWavyScreen: `ld c, $ff` counts outer passes, and the inner
|
||||
-- loop exits twice per displayed frame (animations.asm:1884-1903)
|
||||
SE_WAVY_SCREEN = 128,
|
||||
}
|
||||
|
||||
-- data/battle_anims/special_effects.asm AnimationIdSpecialEffects:
|
||||
@@ -502,15 +504,10 @@ function AnimPlayer:start(moveId, attackerIsPlayer, opts)
|
||||
local transform = resolveTransform(sub.type, attackerIsPlayer)
|
||||
local first, last, dir = 1, #sub.blocks, 1
|
||||
if transform == "REVERSE" then first, last, dir = last, first, -1 end
|
||||
-- DoBallShakeSpecialEffects: each ball shake opens with SFX_TINK
|
||||
-- and a 40-frame pause, then rewinds the same subanimation; the
|
||||
-- mode-4 frame blocks persist, so the resting ball stays visible
|
||||
-- through the pauses between wobbles
|
||||
-- DoBallShakeSpecialEffects: animations.asm:739-747, :623-627
|
||||
local pendingTink = false
|
||||
for _ = 1, (opts and opts.shakes) or 1 do
|
||||
if opts and opts.shakes then
|
||||
events[#events + 1] = { effect = "SFX_TINK", frame = frame }
|
||||
emit(40)
|
||||
end
|
||||
pendingTink = opts and opts.shakes ~= nil
|
||||
local dest = 1 -- PlaySubanimation resets the OAM cursor per row
|
||||
local nblocks = math.abs(last - first) + 1
|
||||
local played = 0
|
||||
@@ -568,6 +565,11 @@ function AnimPlayer:start(moveId, attackerIsPlayer, opts)
|
||||
-- DoSpecialEffectByAnimationId runs after every frame
|
||||
-- block with wSubAnimCounter = blocks remaining
|
||||
played = played + 1
|
||||
if pendingTink then
|
||||
pendingTink = false
|
||||
events[#events + 1] = { effect = "SFX_TINK", frame = frame }
|
||||
emit(40)
|
||||
end
|
||||
if ballFlicker then obp0Flip = not obp0Flip end
|
||||
if idFx then
|
||||
local counter = nblocks - played + 1
|
||||
|
||||
+46
-12
@@ -16,6 +16,7 @@ local Damage = require("src.battle.Damage")
|
||||
local EffectRegistry = require("src.battle.EffectRegistry")
|
||||
local Experience = require("src.battle.Experience")
|
||||
local Font = require("src.render.Font")
|
||||
local LevelDisplay = require("src.ui.LevelDisplay")
|
||||
local Logger = require("src.core.Logger")
|
||||
local MoveEffects = require("src.battle.MoveEffects")
|
||||
local Party = require("src.pokemon.Party")
|
||||
@@ -340,6 +341,22 @@ local function monPalette(data, species)
|
||||
return { name = name, colors = colors }
|
||||
end
|
||||
|
||||
-- MarowakAnim OBJ pics under OAM_PAL1:
|
||||
-- engine/battle/ghost_marowak_anim.asm:3-5,77
|
||||
local function objPicPalette()
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
if not PaletteFX.usesSpriteObp() then return nil end
|
||||
local colors, group = PaletteFX.ogObj()
|
||||
if not colors then return nil end
|
||||
return { name = "obp1:" .. tostring(group), colors = colors }
|
||||
end
|
||||
|
||||
local function objPic(path, trueColor)
|
||||
local pal = objPicPalette()
|
||||
if not pal then return nil end
|
||||
return getImage(path, pal, trueColor)
|
||||
end
|
||||
|
||||
-- a named palette from the active COLORS pack as a getImage pal
|
||||
local function namedPalette(data, name)
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
@@ -897,7 +914,8 @@ local function disguiseAsGhost(self)
|
||||
self.enemy.name = "GHOST"
|
||||
self.enemy.sprite = getImage("assets/generated/battle/front/ghost.png",
|
||||
monPalette(self.data, self.enemy.mon.species))
|
||||
self.introText = Strings("The GHOST\nappeared!")
|
||||
-- _EnemyAppearedText (data/text/text_2.asm:1251-1255) has no article
|
||||
self.introText = self:romText("_EnemyAppearedText", "%s\nappeared!", self.enemy.name)
|
||||
end
|
||||
|
||||
-- Pokémon Tower ghosts (engine/battle/core.asm): without the Silph Scope
|
||||
@@ -941,7 +959,11 @@ function BattleState:queueScopeReveal()
|
||||
local unveiled = self.data.text and self.data.text._UnveiledGhostText
|
||||
self:say(unveiled
|
||||
or Strings("SILPH SCOPE\nunveiled the\vGHOST's identity!"))
|
||||
self:act(function() self.ghostReveal = { t = 0 } end)
|
||||
self:act(function()
|
||||
self.ghostReveal = { t = 0 }
|
||||
local ghostObj = objPic("assets/generated/battle/front/ghost.png")
|
||||
if ghostObj then self.enemy.sprite = ghostObj end
|
||||
end)
|
||||
table.insert(self.queue, { wait = BattleState.GHOST_REVEAL_FRAMES })
|
||||
self:say(self:romText("_WildMonAppearedText", "Wild %s\nappeared!",
|
||||
self.ghostReal and self.ghostReal.name or self.enemy.name))
|
||||
@@ -3264,9 +3286,9 @@ function BattleState:applyAnimEffect(ev)
|
||||
end
|
||||
fx.hudShakeProg = prog
|
||||
elseif e == "SE_WAVY_SCREEN" then
|
||||
-- AnimationWavyScreen: 255 frames of per-scanline SCX offsets
|
||||
-- walking WavyScreenLineOffsets
|
||||
fx.wavy = { left = 255, phase = 0 }
|
||||
-- AnimationWavyScreen: 255 outer passes, two per displayed frame
|
||||
-- (animations.asm:1884-1903), walking WavyScreenLineOffsets
|
||||
fx.wavy = { left = 128, phase = 0 }
|
||||
|
||||
-- ---------------------------------------------- mon pic effects
|
||||
elseif e == "SE_SLIDE_MON_OFF" then
|
||||
@@ -3650,7 +3672,7 @@ function BattleState:updateFx()
|
||||
end
|
||||
if fx.wavy then
|
||||
fx.wavy.left = fx.wavy.left - 1
|
||||
fx.wavy.phase = fx.wavy.phase + 1
|
||||
fx.wavy.phase = fx.wavy.phase + 2
|
||||
if fx.wavy.left <= 0 then fx.wavy = nil end
|
||||
end
|
||||
end
|
||||
@@ -3704,12 +3726,22 @@ function BattleState:updateFx()
|
||||
local real = self.ghostReal
|
||||
if real then
|
||||
self.enemy.name = real.name or self.enemy.name
|
||||
self.enemy.sprite = real.sprite or self.enemy.sprite
|
||||
gr.bgSprite = real.sprite or self.enemy.sprite
|
||||
local objReal
|
||||
if objPicPalette() then
|
||||
local Sprites = require("src.pokemon.Sprites")
|
||||
local path, tc = Sprites.path(self.data, self.enemy.mon.species,
|
||||
"front", { mon = self.enemy.mon, kind = "battle" })
|
||||
objReal = path and objPic(path, tc)
|
||||
end
|
||||
self.enemy.sprite = objReal or gr.bgSprite
|
||||
end
|
||||
end
|
||||
pf.fade = math.min(1, math.ceil((gr.t - outEnd) / 10) / 4)
|
||||
end
|
||||
if gr.t >= BattleState.GHOST_REVEAL_FRAMES then
|
||||
-- home/clear_sprites.asm:1
|
||||
if gr.bgSprite then self.enemy.sprite = gr.bgSprite end
|
||||
self.ghostReveal, self.scopeReveal, pf.fade = nil, nil, nil
|
||||
end
|
||||
end
|
||||
@@ -3825,6 +3857,9 @@ function BattleState:executeAction(user, target, action)
|
||||
-- EnemySendOutFirstMon (core.asm:1314-1315): clears player's trap
|
||||
clearTrapping(self.player)
|
||||
self:syncSides()
|
||||
-- EnemySendOut (core.asm:1276-1289): only the mon on the field stays flagged
|
||||
self.participants = {}
|
||||
self:markParticipant()
|
||||
Runtime.emit("battle.battler_switched", {
|
||||
battle = self, side = self.sides[2], battler = self.enemy,
|
||||
previous = previous,
|
||||
@@ -5950,8 +5985,7 @@ local WAVY_OFFSETS = { 0, 0, 0, 0, 0, 1, 1, 1, 2, 2, 2, 2, 2, 1, 1, 1,
|
||||
0, 0, 0, 0, 0, -1, -1, -1, -2, -2, -2, -2, -2,
|
||||
-1, -1, -1 }
|
||||
|
||||
-- wave the BG canvas one scanline at a time; the offset table walks
|
||||
-- one entry per frame like the asm's advancing pointer
|
||||
-- WavyScreen_SetSCX: animations.asm:1916-1927
|
||||
function BattleState:applyWavy(src)
|
||||
local wavy = self.fx and self.fx.wavy
|
||||
if not wavy then return src end
|
||||
@@ -5964,7 +5998,7 @@ function BattleState:applyWavy(src)
|
||||
for line = 0, 143 do
|
||||
self.waveQuad:setViewport(0, line, 160, 1)
|
||||
g.draw(src, self.waveQuad,
|
||||
WAVY_OFFSETS[(line + wavy.phase) % 32 + 1], line)
|
||||
WAVY_OFFSETS[(line * 2 + wavy.phase) % 32 + 1], line)
|
||||
end
|
||||
g.setCanvas(prev)
|
||||
return self.waveCanvas
|
||||
@@ -6304,7 +6338,7 @@ function BattleState:drawHUDs(slide)
|
||||
end
|
||||
if self.enemy.shownStatus then
|
||||
Font.draw(self:statusLabel({ status = self.enemy.shownStatus }), 40, 8)
|
||||
else
|
||||
elseif LevelDisplay.visible(self.enemy.mon, "battle.enemy", self.game) then
|
||||
hudTile(0x6E, 32, 8) -- <LV>
|
||||
Font.draw(tostring(self.enemy.mon.level), 40, 8)
|
||||
end
|
||||
@@ -6388,7 +6422,7 @@ function BattleState:drawHUDs(slide)
|
||||
Font.draw(self.player.name, nameX(10, self.player.name), 56)
|
||||
if self.player.shownStatus then
|
||||
Font.draw(self:statusLabel({ status = self.player.shownStatus }), 120, 64)
|
||||
else
|
||||
elseif LevelDisplay.visible(self.player.mon, "battle.player", self.game) then
|
||||
hudTile(0x6E, 112, 64) -- <LV>
|
||||
Font.draw(tostring(self.player.mon.level), 120, 64)
|
||||
end
|
||||
|
||||
@@ -88,6 +88,7 @@ function AnimRunner.new(opts)
|
||||
}
|
||||
self.objects = AnimObjects.new(self.data, self.constants, self.env)
|
||||
self.bg = BgEffects.new(self.constants, self.env)
|
||||
self.animId = opts.animId -- wFXAnimID
|
||||
self.gfxOrder = self.constants.battleAnimGfxOrder or {}
|
||||
self.sfxOrder = opts.sfxOrder or {}
|
||||
|
||||
|
||||
+8
-2
@@ -186,7 +186,8 @@ function Game:makeTitleState()
|
||||
onContinue = function()
|
||||
local loaded, recovered = SaveData.load()
|
||||
if loaded then
|
||||
self:restoreSave(loaded, recovered, { freshBoot = true })
|
||||
self:restoreSave(loaded, recovered,
|
||||
{ freshBoot = true, continued = true })
|
||||
end
|
||||
end,
|
||||
onExit = self.onExit,
|
||||
@@ -1322,6 +1323,9 @@ function Game:restoreSave(loaded, recovered, opts)
|
||||
report.recovered = recovered
|
||||
report.modsDiff = modsDiff
|
||||
self.save = loaded
|
||||
-- the START cursor lives outside sGameData (ram/sram.asm:17-21)
|
||||
loaded.startMenuIndex = nil
|
||||
self.startMenuIndex = nil
|
||||
self:adoptSave(loaded)
|
||||
-- SaveData.load already attached the standalone options.lua table
|
||||
self:applyOptions(loaded.options)
|
||||
@@ -1336,8 +1340,10 @@ function Game:restoreSave(loaded, recovered, opts)
|
||||
-- freshBoot threads through from the caller (onContinue and F2 both set
|
||||
-- it); a future caller that doesn't ask for it keeps the ordinary
|
||||
-- crossfade by default.
|
||||
-- engine/menus/main_menu.asm:110 (CONTINUE forces PLAYER_DIR_DOWN)
|
||||
local facing = (opts and opts.continued) and "down" or loaded.player.facing
|
||||
self.stack:push(self.overworld, loaded.player.map,
|
||||
loaded.player.x, loaded.player.y, loaded.player.facing,
|
||||
loaded.player.x, loaded.player.y, facing,
|
||||
{ via = "boot", freshBoot = opts and opts.freshBoot })
|
||||
self.saveReport = report
|
||||
if not SaveData.emptyReport(report) then
|
||||
|
||||
@@ -1024,6 +1024,11 @@ function Game2:load()
|
||||
require("src.core.gen2.Phone").useRegistry(self.data)
|
||||
require("src.core.gen2.Decorations").useRegistry(self.data)
|
||||
require("src.core.gen2.Apricorns").useRegistry(self.data)
|
||||
-- data.gen2Pokedex is a separate table from the `pokemon` registry's own
|
||||
-- merge target (data.pokemon): a translation mod's
|
||||
-- mod.content.pokemon:patch(id, { dexEntry = ... }) would otherwise never
|
||||
-- reach the #DEX screen. See src/core/gen2/PokedexText.lua.
|
||||
require("src.core.gen2.PokedexText").apply(self.data)
|
||||
|
||||
-- Rendering pipelines: the engine half of the render_pipelines registry
|
||||
-- (src/render/Pipelines.lua). install() points it at GOLD's merged dataset
|
||||
|
||||
@@ -40,8 +40,10 @@ GamepadMap.NX_RAW_BUTTON_BINDINGS = {
|
||||
|
||||
-- Raw index -> gamepad button *name* for RomImporter (then NX face swap applies).
|
||||
GamepadMap.RAW_TO_GAMEPAD_BUTTON = {
|
||||
[1] = "a", [2] = "b",
|
||||
[1] = "a", [2] = "b", [3] = "x", [4] = "y",
|
||||
[5] = "leftshoulder", [6] = "rightshoulder",
|
||||
[7] = "back", [8] = "start", [9] = "back", [10] = "start",
|
||||
[11] = "triggerleft", [12] = "triggerright",
|
||||
}
|
||||
|
||||
GamepadMap.NX_RAW_TO_GAMEPAD_BUTTON = {
|
||||
|
||||
+14
-1
@@ -197,8 +197,9 @@ function HostShell.popen(command, mode, opts)
|
||||
prefix = opts.envPrefix
|
||||
end
|
||||
local pipe
|
||||
local line = HostShell.shellCommand(prefix .. command)
|
||||
withPopenLock(function()
|
||||
local ok, p = pcall(io.popen, prefix .. command, mode or "r")
|
||||
local ok, p = pcall(io.popen, line, mode or "r")
|
||||
pipe = (ok and p) or nil
|
||||
end)
|
||||
return pipe
|
||||
@@ -363,6 +364,18 @@ local function fetchError(url, status, noise)
|
||||
return ("fetch failed for %s: %s"):format(url, why)
|
||||
end
|
||||
|
||||
function HostShell.isWindows()
|
||||
return (love and love.system and love.system.getOS
|
||||
and love.system.getOS() == "Windows") or false
|
||||
end
|
||||
|
||||
function HostShell.shellCommand(command)
|
||||
command = tostring(command)
|
||||
if not HostShell.isWindows() then return command end
|
||||
if command:sub(1, 1) ~= '"' then return command end
|
||||
return '"' .. command .. '"'
|
||||
end
|
||||
|
||||
-- Shell quoting for one curl argument; cmd.exe has no single-quote form.
|
||||
function HostShell.quote(s)
|
||||
s = tostring(s)
|
||||
|
||||
@@ -1101,8 +1101,10 @@ function Breeding.hatch(data, save, index, nickname, where)
|
||||
hatched.experience = egg.experience
|
||||
-- `ld a, [de] / ld [hli], a` twice: HP := MaxHP.
|
||||
hatched.hp = hatched.maxHp
|
||||
hatched.ot = egg.ot or (save.player and save.player.name)
|
||||
hatched.otId = egg.otId or (save.player and save.player.id)
|
||||
-- HatchEggs overwrites OT unconditionally, so the ODD_EGG's "ODD" and its
|
||||
-- table id do not survive -- engine/pokemon/breeding.asm:299-309
|
||||
hatched.ot = (save.player and save.player.name) or egg.ot
|
||||
hatched.otId = (save.player and save.player.id) or egg.otId
|
||||
hatched.caughtLevel = egg.level or Breeding.EGG_LEVEL
|
||||
-- SetEggMonCaughtData swaps wCurPartyLevel for CAUGHT_EGG_LEVEL around the
|
||||
-- shared setter -- engine/pokemon/caught_data.asm:235-246.
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
-- Projects a mod's `pokemon` registry dexEntry onto the #DEX screen's own
|
||||
-- data table.
|
||||
--
|
||||
-- data.gen2Pokedex.entries[species] (data/generated/pokedex.lua,
|
||||
-- RomExtractorGen2:extractPokedex) is what src/ui/gen2/PokedexMenu.lua
|
||||
-- actually reads for the KIND label and the two description pages
|
||||
-- (entry.kind, entry.text, entry.text2) -- a table loaded straight from
|
||||
-- disk in Game2:load, BEFORE mods:load runs, and never routed through a
|
||||
-- registry. The `pokemon` registry's own merge target is the separate
|
||||
-- data.pokemon table (src/mods/Schemas.lua, R.pokemon: `target = "pokemon"`),
|
||||
-- which mod.content.pokemon:patch(id, { dexEntry = { kind = ..., text = ...,
|
||||
-- text2 = ... } }) already reaches -- so a translation mod's #DEX text was
|
||||
-- silently invisible in-game despite validating against the registry.
|
||||
--
|
||||
-- This is the missing link: called once after the merge (src/core/Game2.lua),
|
||||
-- the same way ItemEffects.applyHeldItems projects held_items onto
|
||||
-- data.gen2HeldItems. height/weight/dex stay untouched -- game state, not
|
||||
-- text a translation carries.
|
||||
local PokedexText = {}
|
||||
|
||||
function PokedexText.apply(data)
|
||||
local dex = data and data.gen2Pokedex
|
||||
local pokemon = data and data.pokemon
|
||||
if not (dex and dex.entries and pokemon) then return 0 end
|
||||
local count = 0
|
||||
for species, entry in pairs(dex.entries) do
|
||||
local def = pokemon[species]
|
||||
local override = def and def.dexEntry
|
||||
if override and (override.kind or override.text or override.text2) then
|
||||
if override.kind then entry.kind = override.kind end
|
||||
if override.text then entry.text = override.text end
|
||||
if override.text2 then entry.text2 = override.text2 end
|
||||
count = count + 1
|
||||
end
|
||||
end
|
||||
return count
|
||||
end
|
||||
|
||||
return PokedexText
|
||||
@@ -1394,7 +1394,9 @@ local function buildHeader(imp, m)
|
||||
-- under the gear and the quit X -- "the settings is covering the logo".
|
||||
-- Reserving the space on both sides costs a little width and cannot
|
||||
-- overlap at any window size.
|
||||
local clusterW = 2 * gear + math.floor(6 * m.s) + m.pad
|
||||
-- iOS has no quit button (the OS owns app exit), so the cluster is the
|
||||
-- gear alone and the wordmark gets that width back
|
||||
local clusterW = (imp.ios and gear or 2 * gear + math.floor(6 * m.s)) + m.pad
|
||||
local boxX = m.x + clusterW
|
||||
local boxW = math.max(0, m.w - 2 * clusterW)
|
||||
if imp.logo and boxW > 0 then
|
||||
@@ -1428,8 +1430,8 @@ local function buildHeader(imp, m)
|
||||
-- inboard of it -- but the two are REGISTERED gear first, because the first
|
||||
-- focusable of the first frame adopts the keyboard ring and that must not be
|
||||
-- the button that exits the app.
|
||||
local quitX = rx - gear
|
||||
rx = quitX - math.floor(6 * m.s)
|
||||
local quitX = not imp.ios and rx - gear or nil
|
||||
if quitX then rx = quitX - math.floor(6 * m.s) end
|
||||
|
||||
-- Settings gear. It now also owns the CONTROL settings (touch overlay
|
||||
-- editor, reset rebinds), which used to be buttons stacked in the game
|
||||
@@ -1441,7 +1443,7 @@ local function buildHeader(imp, m)
|
||||
chrome.gear.image = imp._gearIcon
|
||||
btn(imp, rx, by, gear, gear, "gear", "", chrome.gear)
|
||||
|
||||
btn(imp, quitX, by, gear, gear, "quit", "", chrome.quit)
|
||||
if quitX then btn(imp, quitX, by, gear, gear, "quit", "", chrome.quit) end
|
||||
|
||||
-- The self-update control lives in the FOOTER next to the BCG mark (small,
|
||||
-- out of the wordmark's way -- it used to overlap the logo on a phone). It
|
||||
|
||||
@@ -2753,7 +2753,8 @@ function RomExtractorGen2:extractAudio(maps)
|
||||
local speciesOrder = self.manifest.constants.speciesOrder or {}
|
||||
local cries = {}
|
||||
for index, species in ipairs(speciesOrder) do
|
||||
if species ~= "UNOWN" and not tostring(species):match("^UNUSED") then
|
||||
-- data/pokemon/cries.asm:209 gives UNOWN a real cry row
|
||||
if not tostring(species):match("^UNUSED") then
|
||||
local row = self.rom:bytes(
|
||||
pokeCryPtr.bank, pokeCryPtr.address + (index - 1) * 6, 6)
|
||||
local cryIndex = row[1] + row[2] * 256
|
||||
|
||||
@@ -2296,8 +2296,8 @@ function RomImporter:_importRequiredSource(modId, importId, source, confirmed)
|
||||
}
|
||||
return nil
|
||||
end
|
||||
if type(size) == "number" and size > RequiredImports.LARGE_WARN_BYTES
|
||||
and spec.format ~= "n64" then
|
||||
if spec.format ~= "n64" and (size == nil
|
||||
or size > RequiredImports.LARGE_WARN_BYTES) then
|
||||
local ok, result = streamRequiredImport(manifest, importId, source)
|
||||
if ok then
|
||||
self.requiredImportNotice = nil
|
||||
@@ -3193,8 +3193,13 @@ function RomImporter:gamepadpressed(_, button)
|
||||
return
|
||||
end
|
||||
|
||||
-- Select button: toggle Virtual Keyboard
|
||||
-- Select button: toggle Virtual Keyboard on handhelds
|
||||
if button == "back" or button == "select" or action == "select" then
|
||||
local isHandheld = os.getenv("HANDHELD") == "1" or os.getenv("PORTMASTER") == "1"
|
||||
or os.getenv("POKEPORT_HANDHELD") == "1" or os.getenv("TRIMUI") == "1"
|
||||
or os.getenv("MUOS") == "1" or os.getenv("KNULLI") == "1"
|
||||
or os.getenv("POKEPORT_SBC") == "1" or os.getenv("ANBERNIC") == "1"
|
||||
if not isHandheld then return end
|
||||
if okKit and Kit.VirtualKeyboard then
|
||||
if Kit.VirtualKeyboard.active then
|
||||
Kit.VirtualKeyboard.close(false)
|
||||
@@ -3277,6 +3282,17 @@ function RomImporter:gamepadpressed(_, button)
|
||||
return
|
||||
end
|
||||
|
||||
-- Triggers (L2 / R2): fast scroll content / lists up and down
|
||||
if button == "triggerleft" or button == "lefttrigger" or button == "l2"
|
||||
or action == "triggerleft" or action == "l2" then
|
||||
require("src.import.LauncherView").wheelmoved(self, 0, 4)
|
||||
return
|
||||
elseif button == "triggerright" or button == "righttrigger" or button == "r2"
|
||||
or action == "triggerright" or action == "r2" then
|
||||
require("src.import.LauncherView").wheelmoved(self, 0, -4)
|
||||
return
|
||||
end
|
||||
|
||||
-- Start button: Play / Choose ROM
|
||||
if button == "start" then
|
||||
if self.workState == "working" then return end
|
||||
@@ -3353,6 +3369,24 @@ function RomImporter:gamepadaxis(_, axis, value)
|
||||
elseif axis == "lefty" and math.abs(value) <= PAD_DEAD then
|
||||
self._padStickCentered = true
|
||||
end
|
||||
elseif axis == "triggerleft" or axis == "lefttrigger" then
|
||||
if value > 0.4 then
|
||||
if not self._l2TriggerActive then
|
||||
require("src.import.LauncherView").wheelmoved(self, 0, 4)
|
||||
self._l2TriggerActive = true
|
||||
end
|
||||
elseif value < 0.2 then
|
||||
self._l2TriggerActive = false
|
||||
end
|
||||
elseif axis == "triggerright" or axis == "righttrigger" then
|
||||
if value > 0.4 then
|
||||
if not self._r2TriggerActive then
|
||||
require("src.import.LauncherView").wheelmoved(self, 0, -4)
|
||||
self._r2TriggerActive = true
|
||||
end
|
||||
elseif value < 0.2 then
|
||||
self._r2TriggerActive = false
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -3380,6 +3414,24 @@ function RomImporter:joystickaxis(joystick, axis, value)
|
||||
self:gamepadaxis(joystick, "leftx", value)
|
||||
elseif axis == 2 then
|
||||
self:gamepadaxis(joystick, "lefty", value)
|
||||
elseif axis == 3 or axis == 5 then
|
||||
if value > 0.4 then
|
||||
if not self._l2RawActive then
|
||||
require("src.import.LauncherView").wheelmoved(self, 0, 4)
|
||||
self._l2RawActive = true
|
||||
end
|
||||
elseif value < 0.2 then
|
||||
self._l2RawActive = false
|
||||
end
|
||||
elseif axis == 4 or axis == 6 then
|
||||
if value > 0.4 then
|
||||
if not self._r2RawActive then
|
||||
require("src.import.LauncherView").wheelmoved(self, 0, -4)
|
||||
self._r2RawActive = true
|
||||
end
|
||||
elseif value < 0.2 then
|
||||
self._r2RawActive = false
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -4214,6 +4266,7 @@ function RomImporter:_toggleSafeMode()
|
||||
self.mods = nil
|
||||
self.findInstalled = nil
|
||||
self._modSortCache = nil
|
||||
self._cartCaptureCache = nil
|
||||
self._modInfoFetch = nil
|
||||
self.modNotice = nil
|
||||
end
|
||||
@@ -4540,7 +4593,7 @@ function RomImporter:_selectCart(version, id)
|
||||
self.activeCart[version] = id
|
||||
self._cartPlan = nil
|
||||
-- the MODS panel answers for the cart now, so its cached rows are stale
|
||||
self.mods, self._modSortCache = nil, nil
|
||||
self.mods, self._modSortCache, self._cartCaptureCache = nil, nil, nil
|
||||
local scope = self:slotScope(version)
|
||||
self.slots[scope] = nil
|
||||
self.slotScroll[scope] = nil
|
||||
@@ -4839,7 +4892,11 @@ end
|
||||
-- pins the off ones too, so they are part of the cart it would write.
|
||||
function RomImporter:_cartCaptureCount(version)
|
||||
if not GameVersion.VERSIONS[version] then return 0 end
|
||||
return #cartModRows(self, version)
|
||||
local cache = self._cartCaptureCache
|
||||
if cache and cache.version == version then return cache.n end
|
||||
local n = #cartModRows(self, version)
|
||||
self._cartCaptureCache = { version = version, n = n }
|
||||
return n
|
||||
end
|
||||
|
||||
function RomImporter:_cartAuthor()
|
||||
@@ -5198,6 +5255,7 @@ function RomImporter:_refreshMods()
|
||||
local SaveData = require("src.core.SaveData")
|
||||
self._cartPlan = nil
|
||||
self._profileCache = nil
|
||||
self._cartCaptureCache = nil
|
||||
self.findInstalled = nil
|
||||
self.safeMode = SaveData.isSafeMode(SaveData.loadOptions())
|
||||
-- Once per session, ahead of the first listing: pull in any mod the player
|
||||
@@ -5232,6 +5290,7 @@ function RomImporter:_refreshMods()
|
||||
if m.targetsHere ~= false then kept[#kept + 1] = m end
|
||||
end
|
||||
self.mods = kept
|
||||
self._cartCaptureCache = { version = self.modScope, n = #kept }
|
||||
end
|
||||
-- a pin is judged against the whole listing: the cart named it, so it is
|
||||
-- listed even where the game filter above would have dropped it
|
||||
|
||||
@@ -20,6 +20,37 @@ local GameVersion = require("src.core.GameVersion")
|
||||
|
||||
local SaveFileIO = {}
|
||||
|
||||
-- The cartridge image an imported Gen 2 slot came from, kept BESIDE the slot
|
||||
-- rather than inside it: export needs the regions the codec does not model,
|
||||
-- and 32 KB of binary in the serialized table is 40 KB of Lua source reparsed
|
||||
-- on every save and load.
|
||||
local function cartPath(version, slotId)
|
||||
return ("saves/%s/%s.cart"):format(version, tostring(slotId))
|
||||
end
|
||||
|
||||
local function cartFs()
|
||||
local portable = SaveData.portableFs and SaveData.portableFs()
|
||||
return portable or (love and love.filesystem)
|
||||
end
|
||||
|
||||
local function writeCart(version, slotId, bytes)
|
||||
local fs = cartFs()
|
||||
if not (fs and fs.write and bytes) then return end
|
||||
if fs.createDirectory then
|
||||
fs.createDirectory("saves")
|
||||
fs.createDirectory("saves/" .. version)
|
||||
end
|
||||
fs.write(cartPath(version, slotId), bytes)
|
||||
end
|
||||
|
||||
local function readCart(version, slotId)
|
||||
local fs = cartFs()
|
||||
if not (fs and fs.read) then return nil end
|
||||
local ok, bytes = pcall(fs.read, cartPath(version, slotId))
|
||||
if ok and type(bytes) == "string" then return bytes end
|
||||
return nil
|
||||
end
|
||||
|
||||
local SAVE_SIZE = SaveConvert.SAVE_SIZE
|
||||
|
||||
-- Resolve raw save bytes from whatever the launcher hands us:
|
||||
@@ -78,8 +109,17 @@ function SaveFileIO.importToSlot(source, version, force)
|
||||
version = version or GameVersion.get()
|
||||
local bytes, readErr = readSource(source)
|
||||
if not bytes then return false, readErr end
|
||||
-- The GAME decides before the BYTES do. Everything below this line used to
|
||||
-- judge a save by Gen 1's rules whatever game it was for, and a Gen 2 cart
|
||||
-- is MBC3+TIMER: a real Gold/Silver/Crystal .sav carries an RTC footer, so
|
||||
-- it is 32786 bytes, misses the size test, and was then measured against
|
||||
-- pokered's checksum -- which is why a perfectly good Crystal save reported
|
||||
-- as corrupt (#1832). mainChecksumValid now takes the game and asks that
|
||||
-- generation's rule.
|
||||
local supported, unsupportedWhy = SaveConvert.importSupported(version)
|
||||
if not supported then return false, unsupportedWhy end
|
||||
if #bytes ~= SAVE_SIZE then
|
||||
local check = SaveConvert.mainChecksumValid(bytes)
|
||||
local check = SaveConvert.mainChecksumValid(bytes, version)
|
||||
if check == nil then
|
||||
return false, ("A save file must be %d bytes (32 KB); this one is %d.")
|
||||
:format(SAVE_SIZE, #bytes)
|
||||
@@ -87,7 +127,12 @@ function SaveFileIO.importToSlot(source, version, force)
|
||||
if check == false then
|
||||
return false, "save data checksum invalid (main data checksum mismatch)"
|
||||
end
|
||||
if #bytes > SAVE_SIZE and not force then
|
||||
-- The confirm exists because a Gen 1 save bigger than 32768 is a surprise
|
||||
-- worth asking about. On a Gen 2 cart it is the normal shape -- every
|
||||
-- real one has the footer -- so asking would be a prompt with one sensible
|
||||
-- answer, on every import, forever.
|
||||
if #bytes > SAVE_SIZE and not force
|
||||
and not SaveConvert.isGen2Cart(version) then
|
||||
return false, nil, { needsConfirm = true, size = #bytes }
|
||||
end
|
||||
bytes = #bytes > SAVE_SIZE and bytes:sub(1, SAVE_SIZE)
|
||||
@@ -111,6 +156,7 @@ function SaveFileIO.importToSlot(source, version, force)
|
||||
return false, "could not write the imported save: " .. tostring(writeErr)
|
||||
end
|
||||
SaveData.setActiveSlot(version, slotId)
|
||||
if SaveConvert.isGen2Cart(version) then writeCart(version, slotId, bytes) end
|
||||
return true, slotId
|
||||
end
|
||||
|
||||
@@ -125,9 +171,10 @@ function SaveFileIO.exportActiveSlot(version)
|
||||
version = version or GameVersion.get()
|
||||
local save = SaveData.load(version)
|
||||
if not save then return false, "this game has no save to export yet" end
|
||||
local bytes, exportErr = SaveConvert.exportSav(save, version)
|
||||
if not bytes then return false, exportErr end
|
||||
local slotId = SaveData.activeSlot(version) or "save"
|
||||
local bytes, exportErr = SaveConvert.exportSav(save, version,
|
||||
readCart(version, slotId))
|
||||
if not bytes then return false, exportErr end
|
||||
-- Portable mode is the same seam SaveData's own persistFs uses: when
|
||||
-- portable.txt marks the install every persistent write leaves the OS save
|
||||
-- directory for the game folder, and an export is no exception. Writing
|
||||
|
||||
@@ -94,9 +94,11 @@ function ItemEffects.healsHP(id)
|
||||
end
|
||||
|
||||
-- .useRareCandy prints over the still-drawn party menu
|
||||
-- (engine/items/item_effects.asm:1392-1418)
|
||||
-- (engine/items/item_effects.asm:1392-1418); .useVitamin ends at
|
||||
-- RemoveUsedItem the same way (engine/items/item_effects.asm:1315-1322)
|
||||
function ItemEffects.keepsPartyMenuOpen(id)
|
||||
return ItemEffects.healsHP(id) or id == "RARE_CANDY"
|
||||
or VITAMINS[id] ~= nil
|
||||
end
|
||||
|
||||
function ItemEffects.isBattleMedicine(id)
|
||||
@@ -526,8 +528,9 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
||||
-- _VitaminStatRoseText's slot order is localization-dependent (the
|
||||
-- Spanish ROM puts the stat before the name), so the extracted line
|
||||
-- cannot be filled positionally; the engine wording stands
|
||||
-- engine/items/item_effects.asm:1313
|
||||
return "consumed", { Strings("%s's %s\nrose!", monName(data, target),
|
||||
Strings(STAT_LABEL[vitaminStat])) }
|
||||
Strings(STAT_LABEL[vitaminStat])) }, { useJingle = true }
|
||||
end
|
||||
|
||||
-- PP UP boosts the move the player picked (ItemUsePPUp's move menu)
|
||||
|
||||
+26
-4
@@ -23,6 +23,7 @@ local Protocol = require("src.link.Protocol")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local TurnOrder = require("src.battle.TurnOrder")
|
||||
local Strings = require("src.core.Strings")
|
||||
local Timing = require("src.core.Timing")
|
||||
|
||||
local LinkBattle = {}
|
||||
|
||||
@@ -473,11 +474,22 @@ function LinkBattle.new(game, net, opts)
|
||||
-- switches happen before attacks (both may switch)
|
||||
if myMsg.kind == "switch" then
|
||||
local idx = myMsg.index
|
||||
s:act(function() sendOutPlayer(s, myParty[idx]) end)
|
||||
-- SwitchPlayerMon (engine/battle/core.asm:2419-2423); a post-faint
|
||||
-- replacement shares sendOutPlayer and prints neither line
|
||||
s:act(function()
|
||||
s:sayNextAuto(s:withdrawText(s.player.name), Timing.SWITCH_PLAYER_MON)
|
||||
s:queueRetreatAnim()
|
||||
s:actNext(function() sendOutPlayer(s, myParty[idx]) end)
|
||||
end)
|
||||
myAction = nil
|
||||
end
|
||||
if theirSwitch then
|
||||
s:act(function() sendOutEnemy(s, theirParty[theirSwitch]) end)
|
||||
-- SwitchEnemyMon (engine/battle/trainer_ai.asm:596-599)
|
||||
s:act(function()
|
||||
s:sayNext(s:romText("_AIBattleWithdrawText", "%s with-\ndrew %s!",
|
||||
theirName, s.enemy.name))
|
||||
s:actNext(function() sendOutEnemy(s, theirParty[theirSwitch]) end)
|
||||
end)
|
||||
end
|
||||
|
||||
s:act(function()
|
||||
@@ -885,11 +897,21 @@ function LinkBattle.newSpectator(game, net, opts)
|
||||
|
||||
if hostMsg.kind == "switch" then
|
||||
local idx = hostMsg.index
|
||||
s:act(function() sendOutHost(s, hostParty[idx]) end)
|
||||
-- SwitchPlayerMon (engine/battle/core.asm:2419-2423)
|
||||
s:act(function()
|
||||
s:sayNextAuto(s:withdrawText(s.player.name), Timing.SWITCH_PLAYER_MON)
|
||||
s:queueRetreatAnim()
|
||||
s:actNext(function() sendOutHost(s, hostParty[idx]) end)
|
||||
end)
|
||||
end
|
||||
if guestMsg.kind == "switch" then
|
||||
local idx = guestMsg.index
|
||||
s:act(function() sendOutGuest(s, guestParty[idx]) end)
|
||||
-- SwitchEnemyMon (engine/battle/trainer_ai.asm:596-599)
|
||||
s:act(function()
|
||||
s:sayNext(s:romText("_AIBattleWithdrawText", "%s with-\ndrew %s!",
|
||||
guestName, s.enemy.name))
|
||||
s:actNext(function() sendOutGuest(s, guestParty[idx]) end)
|
||||
end)
|
||||
end
|
||||
|
||||
s:act(function()
|
||||
|
||||
@@ -1711,8 +1711,8 @@ local function buildStartMenu()
|
||||
function overrides.new(game)
|
||||
local g = live() or game
|
||||
local save = g and g.save
|
||||
if save and save.startMenuIndex then
|
||||
Start2.lastIndex = save.startMenuIndex
|
||||
if g and g.startMenuIndex then
|
||||
Start2.lastIndex = g.startMenuIndex
|
||||
end
|
||||
return newOrig(g, {
|
||||
save = save,
|
||||
@@ -1741,7 +1741,7 @@ COVERAGE["src.ui.StartMenu"] = {
|
||||
new = "synthesises onClose (stack:pop) and onChoose "
|
||||
.. "(Game2:openStartMenuItem); without them the menu cannot be left",
|
||||
index = "the cursor is menu.list.index on Gold (a Chrome.List)",
|
||||
["save.startMenuIndex"] = "copied INTO StartMenu.lastIndex on construct "
|
||||
["game.startMenuIndex"] = "copied INTO StartMenu.lastIndex on construct "
|
||||
.. "and never copied back: Gold's cursor is a class field, not save data",
|
||||
tx = "the box is fixed at Chrome.box(10, 0, 10, h); tx/ty/tw/th/anchor/"
|
||||
.. "maxVisible/startCloses/noSound do not exist and writes are inert",
|
||||
|
||||
@@ -826,10 +826,14 @@ R.pokemon = {
|
||||
item = f.opt(f.id("items")),
|
||||
species = f.id("pokemon") }),
|
||||
spriteFront = f.path, spriteBack = f.path, frontSize = f.int(1, 7),
|
||||
-- text2 is the #DEX entry's second description page (Pokedex_asm's bare
|
||||
-- `page` macro, engine/pokedex/pokedex.asm): PokedexMenu:drawEntryBody
|
||||
-- shows `entry.text` on page 1 and `entry.text2` on page 2, so a
|
||||
-- translation needs both to cover the whole entry.
|
||||
dexEntry = f.opt(f.rec{ kind = f.str, heightFt = f.int(0),
|
||||
heightIn = f.int(0, 11), weight = f.num,
|
||||
heightM = f.opt(f.num), weightKg = f.opt(f.num),
|
||||
text = f.str }),
|
||||
text = f.str, text2 = f.opt(f.str) }),
|
||||
icon = f.opt(f.union{ f.str, f.rec{ image = f.path,
|
||||
frames = f.opt(f.int(1)) } }),
|
||||
cry = f.opt(f.id("cries")), palette = f.opt(f.id("palettes")),
|
||||
|
||||
@@ -332,6 +332,7 @@ end
|
||||
|
||||
-- transparent: the world pass shows through (UI pass draws overlays only)
|
||||
function Renderer:beginFrame(transparent)
|
||||
self.uiOpaque = not transparent
|
||||
self.worldActive = false
|
||||
self.uprightActive = false
|
||||
self.worldOverride = nil
|
||||
@@ -699,6 +700,18 @@ function Renderer:blitCanvas(canvas, sx, sy, zoneList, zoneSx, zoneSy,
|
||||
return
|
||||
end
|
||||
love.graphics.setShader(shader)
|
||||
-- data/sgb/sgb_packets.asm:123-127: zone 1 is the whole-screen ATTR_BLK,
|
||||
-- so the underpaint is only drawn when it leaves part of the box bare (#1866)
|
||||
local first = zoneList[1]
|
||||
if canvas == self.canvas and self.uiOpaque and first.colors
|
||||
and not (bx + first.x * zoneSx <= boxX
|
||||
and by + first.y * zoneSy <= boxY
|
||||
and bx + (first.x + first.w) * zoneSx >= boxX + boxW
|
||||
and by + (first.y + first.h) * zoneSy >= boxY + boxH) then
|
||||
PaletteFX.sendColors(shader, first.colors)
|
||||
love.graphics.setScissor(boxX, boxY, boxW, boxH)
|
||||
love.graphics.draw(canvas, bx, by, 0, sx, sy)
|
||||
end
|
||||
-- a colors == false zone is the trueColor opt-out: its rect draws with
|
||||
-- no shader at all. Nothing sets one without a mod, so a vanilla zone
|
||||
-- list never toggles and issues exactly the calls it always did.
|
||||
|
||||
@@ -28,6 +28,52 @@ local MAX_COLS = 18
|
||||
-- pokegold constants/ram_constants.asm: TEXT_DELAY_FAST/MED/SLOW = 1/3/5
|
||||
local NAME_DELAYS = { FAST = 1, MID = 3, SLOW = 5 }
|
||||
|
||||
-- TextCommand_PAUSE (home/text.asm:492-504): a mid-string wait callers
|
||||
-- embed as TextBox.PAUSE, stripped here and re-anchored after pagination.
|
||||
TextBox.PAUSE = "\1"
|
||||
local PAUSE_FRAMES = 30
|
||||
|
||||
local function glyphs(str)
|
||||
return #Font.split((str:gsub("[\n\v\f]", "")))
|
||||
end
|
||||
|
||||
local function stripPauses(text)
|
||||
if not text:find(TextBox.PAUSE, 1, true) then return text, nil end
|
||||
local out, marks, count, pos = {}, {}, 0, 1
|
||||
while true do
|
||||
local i = text:find(TextBox.PAUSE, pos, true)
|
||||
if not i then
|
||||
out[#out + 1] = text:sub(pos)
|
||||
break
|
||||
end
|
||||
local chunk = text:sub(pos, i - 1)
|
||||
out[#out + 1] = chunk
|
||||
count = count + glyphs(chunk)
|
||||
marks[#marks + 1] = count
|
||||
pos = i + 1
|
||||
end
|
||||
return table.concat(out), marks
|
||||
end
|
||||
|
||||
-- glyph offsets into the whole text -> [page][line][char]
|
||||
local function mapPauses(pages, marks)
|
||||
local at, acc, mi = {}, 0, 1
|
||||
for pi, page in ipairs(pages) do
|
||||
for li, line in ipairs(page) do
|
||||
local n = #Font.split(line)
|
||||
while mi <= #marks and marks[mi] <= acc + n do
|
||||
local ci = marks[mi] - acc
|
||||
at[pi] = at[pi] or {}
|
||||
at[pi][li] = at[pi][li] or {}
|
||||
at[pi][li][ci] = mi
|
||||
mi = mi + 1
|
||||
end
|
||||
acc = acc + n
|
||||
end
|
||||
end
|
||||
return at
|
||||
end
|
||||
|
||||
-- opts.choice: when the last page has typed out, a YES/NO ChoiceBox pops
|
||||
-- up over the still-visible text (YesNoChoicePokeCenter and friends);
|
||||
-- the box then closes and choice(yes) runs instead of onDone.
|
||||
@@ -66,6 +112,8 @@ function TextBox.new(game, text, onDone, opts)
|
||||
self.stay = opts and opts.stay
|
||||
-- engine/events/hidden_events/cinnabar_gym_quiz.asm:119
|
||||
self.preSound = opts and opts.preSound
|
||||
-- pokegold engine/overworld/scripting.asm:485 WaitSFX
|
||||
self.sfxWait = opts and opts.sfxWait
|
||||
-- opts.instant: put the LAST page up already typed, with no typewriter and
|
||||
-- no page waits. A `yesorno` follows a `writetext` that has already been
|
||||
-- read, so re-typing the line under the YES/NO box would be wrong -- the
|
||||
@@ -81,7 +129,13 @@ function TextBox.new(game, text, onDone, opts)
|
||||
self.line1Y = (self.boxTy + 2) * 8
|
||||
self.line2Y = (self.boxTy + 4) * 8
|
||||
text = TextBox.substitute(game, text)
|
||||
local marks
|
||||
text, marks = stripPauses(text)
|
||||
self.pages = TextBox.paginate(text, self.maxCols)
|
||||
-- opts.pauseSounds[i] is the sfx the i-th marker fires once its wait is
|
||||
-- over (text_asm SFX_SWAP, engine/pokemon/learn_move.asm:210-213)
|
||||
self.pauseSounds = opts and opts.pauseSounds
|
||||
self.pauseAt = marks and mapPauses(self.pages, marks) or nil
|
||||
self.pageIndex = 1
|
||||
self.lineIndex = 1
|
||||
self.charIndex = 0
|
||||
@@ -276,6 +330,14 @@ function TextBox:visibleText()
|
||||
return #out > 0 and out or nil
|
||||
end
|
||||
|
||||
-- pokegold engine/overworld/scripting.asm:484-485 PlaySFX / WaitSFX
|
||||
function TextBox:sfxHeld()
|
||||
if not self.sfxWait then return false end
|
||||
if require("src.core.Sound").sfxBusy() then return true end
|
||||
self.sfxWait = nil
|
||||
return false
|
||||
end
|
||||
|
||||
function TextBox:update(dt)
|
||||
local input = self.game.input
|
||||
self.blink = (self.blink + 1) % 60
|
||||
@@ -297,6 +359,20 @@ function TextBox:update(dt)
|
||||
self.holdFrames = self.holdFrames - 1
|
||||
return
|
||||
end
|
||||
-- home/text.asm:492
|
||||
if self.pauseFrames then
|
||||
if self.pauseFrames > 0 then
|
||||
self.pauseFrames = self.pauseFrames - 1
|
||||
return
|
||||
end
|
||||
self.pauseFrames = nil
|
||||
local snd = self.pauseSounds and self.pauseSounds[self.pauseMark]
|
||||
if type(snd) == "function" then
|
||||
snd()
|
||||
elseif snd then
|
||||
require("src.core.Sound").play(self.game.data, snd)
|
||||
end
|
||||
end
|
||||
if self.done then
|
||||
-- opts.stay: the box is finished but stays up under whatever the caller
|
||||
-- pushed over it; StateStack updates the top state only, so this runs
|
||||
@@ -378,6 +454,7 @@ function TextBox:update(dt)
|
||||
end
|
||||
return
|
||||
end
|
||||
if self:sfxHeld() then return end
|
||||
if input:wasPressed("a") or input:wasPressed("b") then
|
||||
require("src.core.Sound").play(self.game.data, "Press_AB")
|
||||
self.game.stack:pop()
|
||||
@@ -393,6 +470,7 @@ function TextBox:update(dt)
|
||||
self.preWait = self.preWait - 1
|
||||
return
|
||||
end
|
||||
if self:sfxHeld() then return end
|
||||
if input:wasPressed("a") or input:wasPressed("b") then
|
||||
require("src.core.Sound").play(self.game.data, "Press_AB")
|
||||
self.waiting = false
|
||||
@@ -430,6 +508,15 @@ function TextBox:update(dt)
|
||||
self.charIndex = self.charIndex + 1
|
||||
local line = self.shown[#self.shown]
|
||||
line[#line + 1] = self.codes[self.charIndex]
|
||||
local marks = self.pauseAt and self.pauseAt[self.pageIndex]
|
||||
marks = marks and marks[self.lineIndex]
|
||||
if marks and marks[self.charIndex] then
|
||||
self.pauseMark = marks[self.charIndex]
|
||||
-- TextCommand_PAUSE reads hJoyHeld, so a held A/B skips the wait
|
||||
self.pauseFrames = (input:isDown("a") or input:isDown("b"))
|
||||
and 0 or PAUSE_FRAMES
|
||||
break
|
||||
end
|
||||
else
|
||||
-- line finished
|
||||
local page = self.pages[self.pageIndex]
|
||||
|
||||
@@ -150,13 +150,10 @@ end
|
||||
|
||||
-- true while the spinner arrow tiles should show the 'blur' graphic; false
|
||||
-- means draw nothing extra (the static window tile shows through,
|
||||
-- matching the asm's restore-to-original behavior). The 8-tick
|
||||
-- half-period approximates one GB movement step (2px/frame); this is a
|
||||
-- deliberate approximation of wSimulatedJoypadStatesIndex bit-0 parity, not
|
||||
-- a cycle-accurate replication -- the port's tweened scriptMove has no
|
||||
-- direct equivalent discrete step counter.
|
||||
-- matching the asm's restore-to-original behavior).
|
||||
-- spinners.asm:17-22, home/overworld.asm:1844-1846, :49-52
|
||||
function TileRenderer.spinBlurActive()
|
||||
return spinning and (math.floor(animFrame / 8) % 2 == 0)
|
||||
return spinning and (math.floor(animFrame / 16) % 2 == 0)
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
-- GENERATED by tools/gen2_sram_offsets.py. Do not edit by hand.
|
||||
-- Regenerate from a pret/pokegold + pret/pokecrystal build; see that
|
||||
-- script's header for the derivation and the assertion behind it.
|
||||
local Gen2Layout = {}
|
||||
|
||||
Gen2Layout.goldSilver = {
|
||||
sCheckValue1 = 0x2008,
|
||||
sCheckValue2 = 0x2D6B,
|
||||
sChecksum = 0x2D69,
|
||||
sGameData = 0x2009,
|
||||
sGameDataEnd = 0x2D69,
|
||||
wPlayerName = 0x200B,
|
||||
wPlayerID = 0x2009,
|
||||
wMoney = 0x23DB,
|
||||
wCoins = 0x23E2,
|
||||
wBadges = 0x23E4,
|
||||
wKantoBadges = 0x23E5,
|
||||
wRivalName = 0x2021,
|
||||
wMomsName = 0x2016,
|
||||
wPartyCount = 0x288A,
|
||||
wPartySpecies = 0x288B,
|
||||
wPartyMons = 0x2892,
|
||||
wPartyMonNicknames = 0x29F4,
|
||||
wPartyMonOTs = 0x29B2,
|
||||
wNumItems = 0x241F,
|
||||
wItems = 0x2420,
|
||||
wNumKeyItems = 0x2449,
|
||||
wKeyItems = 0x244A,
|
||||
wNumBalls = 0x2464,
|
||||
wBalls = 0x2465,
|
||||
wTMsHMs = 0x23E6,
|
||||
wPokedexCaught = 0x2A4C,
|
||||
wPokedexSeen = 0x2A6C,
|
||||
wCurBox = 0x2724,
|
||||
wBoxNames = 0x2727,
|
||||
wMapGroup = 0x2868,
|
||||
wMapNumber = 0x2869,
|
||||
wXCoord = 0x286B,
|
||||
wYCoord = 0x286A,
|
||||
wEventFlags = 0x261F,
|
||||
wPlayerState = 0x24EA,
|
||||
wGameTimeHours = 0x2053,
|
||||
wGameTimeMinutes = 0x2055,
|
||||
-- The 14 archived boxes, listed rather than strided (see BOX_COUNT).
|
||||
boxes = { 0x4000, 0x4450, 0x48A0, 0x4CF0, 0x5140, 0x5590, 0x59E0, 0x6000, 0x6450, 0x68A0, 0x6CF0, 0x7140, 0x7590, 0x79E0 },
|
||||
}
|
||||
|
||||
Gen2Layout.crystal = {
|
||||
sCheckValue1 = 0x2008,
|
||||
sCheckValue2 = 0x2D0F,
|
||||
sChecksum = 0x2D0D,
|
||||
sGameData = 0x2009,
|
||||
sGameDataEnd = 0x2B83,
|
||||
wPlayerName = 0x200B,
|
||||
wPlayerID = 0x2009,
|
||||
wMoney = 0x23DC,
|
||||
wCoins = 0x23E3,
|
||||
wBadges = 0x23E5,
|
||||
wKantoBadges = 0x23E6,
|
||||
wRivalName = 0x2021,
|
||||
wMomsName = 0x2016,
|
||||
wPartyCount = 0x2865,
|
||||
wPartySpecies = 0x2866,
|
||||
wPartyMons = 0x286D,
|
||||
wPartyMonNicknames = 0x29CF,
|
||||
wPartyMonOTs = 0x298D,
|
||||
wNumItems = 0x2420,
|
||||
wItems = 0x2421,
|
||||
wNumKeyItems = 0x244A,
|
||||
wKeyItems = 0x244B,
|
||||
wNumBalls = 0x2465,
|
||||
wBalls = 0x2466,
|
||||
wTMsHMs = 0x23E7,
|
||||
wPokedexCaught = 0x2A27,
|
||||
wPokedexSeen = 0x2A47,
|
||||
wCurBox = 0x2700,
|
||||
wBoxNames = 0x2703,
|
||||
wMapGroup = 0x2843,
|
||||
wMapNumber = 0x2844,
|
||||
wXCoord = 0x2846,
|
||||
wYCoord = 0x2845,
|
||||
wEventFlags = 0x2600,
|
||||
wPlayerState = 0x24EB,
|
||||
wGameTimeHours = 0x2052,
|
||||
wGameTimeMinutes = 0x2054,
|
||||
-- The 14 archived boxes, listed rather than strided (see BOX_COUNT).
|
||||
boxes = { 0x4000, 0x4450, 0x48A0, 0x4CF0, 0x5140, 0x5590, 0x59E0, 0x6000, 0x6450, 0x68A0, 0x6CF0, 0x7140, 0x7590, 0x79E0 },
|
||||
-- The backup copy the game falls back to when the primary
|
||||
-- checksum fails. Same shape, shifted.
|
||||
backup = {
|
||||
sCheckValue1 = 0x1208,
|
||||
sCheckValue2 = 0x1F0F,
|
||||
sChecksum = 0x1F0D,
|
||||
sGameData = 0x1209,
|
||||
sGameDataEnd = 0x1D83,
|
||||
wPlayerName = 0x120B,
|
||||
wPlayerID = 0x1209,
|
||||
wMoney = 0x15DC,
|
||||
wCoins = 0x15E3,
|
||||
wBadges = 0x15E5,
|
||||
wKantoBadges = 0x15E6,
|
||||
wRivalName = 0x1221,
|
||||
wMomsName = 0x1216,
|
||||
wPartyCount = 0x1A65,
|
||||
wPartySpecies = 0x1A66,
|
||||
wPartyMons = 0x1A6D,
|
||||
wPartyMonNicknames = 0x1BCF,
|
||||
wPartyMonOTs = 0x1B8D,
|
||||
wNumItems = 0x1620,
|
||||
wItems = 0x1621,
|
||||
wNumKeyItems = 0x164A,
|
||||
wKeyItems = 0x164B,
|
||||
wNumBalls = 0x1665,
|
||||
wBalls = 0x1666,
|
||||
wTMsHMs = 0x15E7,
|
||||
wPokedexCaught = 0x1C27,
|
||||
wPokedexSeen = 0x1C47,
|
||||
wCurBox = 0x1900,
|
||||
wBoxNames = 0x1903,
|
||||
wMapGroup = 0x1A43,
|
||||
wMapNumber = 0x1A44,
|
||||
wXCoord = 0x1A46,
|
||||
wYCoord = 0x1A45,
|
||||
wEventFlags = 0x1800,
|
||||
wPlayerState = 0x16EB,
|
||||
wGameTimeHours = 0x1252,
|
||||
wGameTimeMinutes = 0x1254,
|
||||
boxes = { 0x4000, 0x4450, 0x48A0, 0x4CF0, 0x5140, 0x5590, 0x59E0, 0x6000, 0x6450, 0x68A0, 0x6CF0, 0x7140, 0x7590, 0x79E0 },
|
||||
},
|
||||
}
|
||||
|
||||
Gen2Layout.charmap = {
|
||||
[0x05] = "ガ",
|
||||
[0x06] = "ギ",
|
||||
[0x07] = "グ",
|
||||
[0x08] = "ゲ",
|
||||
[0x09] = "ゴ",
|
||||
[0x0A] = "ザ",
|
||||
[0x0B] = "ジ",
|
||||
[0x0C] = "ズ",
|
||||
[0x0D] = "ゼ",
|
||||
[0x0E] = "ゾ",
|
||||
[0x0F] = "ダ",
|
||||
[0x10] = "ヂ",
|
||||
[0x11] = "ヅ",
|
||||
[0x12] = "デ",
|
||||
[0x13] = "ド",
|
||||
[0x19] = "バ",
|
||||
[0x1A] = "ビ",
|
||||
[0x1B] = "ブ",
|
||||
[0x1C] = "ボ",
|
||||
[0x26] = "が",
|
||||
[0x27] = "ぎ",
|
||||
[0x28] = "ぐ",
|
||||
[0x29] = "げ",
|
||||
[0x2A] = "ご",
|
||||
[0x2B] = "ざ",
|
||||
[0x2C] = "じ",
|
||||
[0x2D] = "ず",
|
||||
[0x2E] = "ぜ",
|
||||
[0x2F] = "ぞ",
|
||||
[0x30] = "だ",
|
||||
[0x31] = "ぢ",
|
||||
[0x32] = "づ",
|
||||
[0x33] = "で",
|
||||
[0x34] = "ど",
|
||||
[0x3A] = "ば",
|
||||
[0x3B] = "び",
|
||||
[0x3C] = "ぶ",
|
||||
[0x3D] = "べ",
|
||||
[0x3E] = "ぼ",
|
||||
[0x3F] = "⁂",
|
||||
[0x40] = "パ",
|
||||
[0x41] = "ピ",
|
||||
[0x42] = "プ",
|
||||
[0x43] = "ポ",
|
||||
[0x44] = "ぱ",
|
||||
[0x45] = "ぴ",
|
||||
[0x46] = "ぷ",
|
||||
[0x47] = "ぺ",
|
||||
[0x48] = "ぽ",
|
||||
[0x50] = "@",
|
||||
[0x54] = "#",
|
||||
[0x60] = "■",
|
||||
[0x61] = "▲",
|
||||
[0x62] = "☎",
|
||||
[0x6E] = "ぃ",
|
||||
[0x6F] = "ぅ",
|
||||
[0x70] = "PO",
|
||||
[0x71] = "KE",
|
||||
[0x72] = "“",
|
||||
[0x73] = "”",
|
||||
[0x74] = "·",
|
||||
[0x75] = "…",
|
||||
[0x76] = "ぁ",
|
||||
[0x77] = "ぇ",
|
||||
[0x78] = "ぉ",
|
||||
[0x79] = "┌",
|
||||
[0x7A] = "─",
|
||||
[0x7B] = "┐",
|
||||
[0x7C] = "│",
|
||||
[0x7D] = "└",
|
||||
[0x7E] = "┘",
|
||||
[0x7F] = " ",
|
||||
[0x80] = "A",
|
||||
[0x81] = "B",
|
||||
[0x82] = "C",
|
||||
[0x83] = "D",
|
||||
[0x84] = "E",
|
||||
[0x85] = "F",
|
||||
[0x86] = "G",
|
||||
[0x87] = "H",
|
||||
[0x88] = "I",
|
||||
[0x89] = "J",
|
||||
[0x8A] = "K",
|
||||
[0x8B] = "L",
|
||||
[0x8C] = "M",
|
||||
[0x8D] = "N",
|
||||
[0x8E] = "O",
|
||||
[0x8F] = "P",
|
||||
[0x90] = "Q",
|
||||
[0x91] = "R",
|
||||
[0x92] = "S",
|
||||
[0x93] = "T",
|
||||
[0x94] = "U",
|
||||
[0x95] = "V",
|
||||
[0x96] = "W",
|
||||
[0x97] = "X",
|
||||
[0x98] = "Y",
|
||||
[0x99] = "Z",
|
||||
[0x9A] = "(",
|
||||
[0x9B] = ")",
|
||||
[0x9C] = ":",
|
||||
[0x9D] = ";",
|
||||
[0x9E] = "[",
|
||||
[0x9F] = "]",
|
||||
[0xA0] = "a",
|
||||
[0xA1] = "b",
|
||||
[0xA2] = "c",
|
||||
[0xA3] = "d",
|
||||
[0xA4] = "e",
|
||||
[0xA5] = "f",
|
||||
[0xA6] = "g",
|
||||
[0xA7] = "h",
|
||||
[0xA8] = "i",
|
||||
[0xA9] = "j",
|
||||
[0xAA] = "k",
|
||||
[0xAB] = "l",
|
||||
[0xAC] = "m",
|
||||
[0xAD] = "n",
|
||||
[0xAE] = "o",
|
||||
[0xAF] = "p",
|
||||
[0xB0] = "q",
|
||||
[0xB1] = "r",
|
||||
[0xB2] = "s",
|
||||
[0xB3] = "t",
|
||||
[0xB4] = "u",
|
||||
[0xB5] = "v",
|
||||
[0xB6] = "w",
|
||||
[0xB7] = "x",
|
||||
[0xB8] = "y",
|
||||
[0xB9] = "z",
|
||||
[0xBA] = "こ",
|
||||
[0xBB] = "さ",
|
||||
[0xBC] = "し",
|
||||
[0xBD] = "す",
|
||||
[0xBE] = "せ",
|
||||
[0xBF] = "そ",
|
||||
[0xC0] = "Ä",
|
||||
[0xC1] = "Ö",
|
||||
[0xC2] = "Ü",
|
||||
[0xC3] = "ä",
|
||||
[0xC4] = "ö",
|
||||
[0xC5] = "ü",
|
||||
[0xC6] = "に",
|
||||
[0xC7] = "ぬ",
|
||||
[0xC8] = "ね",
|
||||
[0xC9] = "の",
|
||||
[0xCA] = "は",
|
||||
[0xCB] = "ひ",
|
||||
[0xCC] = "ふ",
|
||||
[0xCD] = "へ",
|
||||
[0xCE] = "ほ",
|
||||
[0xCF] = "ま",
|
||||
[0xD0] = "'d",
|
||||
[0xD1] = "'l",
|
||||
[0xD2] = "'m",
|
||||
[0xD3] = "'r",
|
||||
[0xD4] = "'s",
|
||||
[0xD5] = "'t",
|
||||
[0xD6] = "'v",
|
||||
[0xD7] = "ら",
|
||||
[0xD8] = "り",
|
||||
[0xD9] = "る",
|
||||
[0xDA] = "れ",
|
||||
[0xDB] = "ろ",
|
||||
[0xDC] = "わ",
|
||||
[0xDD] = "を",
|
||||
[0xDE] = "ん",
|
||||
[0xDF] = "←",
|
||||
[0xE0] = "'",
|
||||
[0xE1] = "PK",
|
||||
[0xE2] = "MN",
|
||||
[0xE3] = "-",
|
||||
[0xE4] = "゚",
|
||||
[0xE5] = "゙",
|
||||
[0xE6] = "?",
|
||||
[0xE7] = "!",
|
||||
[0xE8] = ".",
|
||||
[0xE9] = "&",
|
||||
[0xEA] = "é",
|
||||
[0xEB] = "→",
|
||||
[0xEC] = "▷",
|
||||
[0xED] = "▶",
|
||||
[0xEE] = "▼",
|
||||
[0xEF] = "♂",
|
||||
[0xF0] = "¥",
|
||||
[0xF1] = "×",
|
||||
[0xF2] = ".",
|
||||
[0xF3] = "/",
|
||||
[0xF4] = ",",
|
||||
[0xF5] = "♀",
|
||||
[0xF6] = "0",
|
||||
[0xF7] = "1",
|
||||
[0xF8] = "2",
|
||||
[0xF9] = "3",
|
||||
[0xFA] = "4",
|
||||
[0xFB] = "5",
|
||||
[0xFC] = "6",
|
||||
[0xFD] = "7",
|
||||
[0xFE] = "8",
|
||||
[0xFF] = "9",
|
||||
}
|
||||
|
||||
return Gen2Layout
|
||||
@@ -0,0 +1,690 @@
|
||||
-- Vanilla Gen 2 (Gold/Silver/Crystal) raw SRAM <-> src/core/gen2/Save.lua.
|
||||
-- Companion to GenSave.lua, which is Gen 1 only.
|
||||
--
|
||||
-- Offsets come from Gen2Layout.lua, generated by tools/gen2_sram_offsets.py.
|
||||
-- Gold and Silver share a layout; Crystal does not.
|
||||
--
|
||||
-- Pure Lua, no love.* at require time, same as GenSave.
|
||||
|
||||
local Gen2Layout = require("src.save_convert.Gen2Layout")
|
||||
|
||||
local Gen2Save = {}
|
||||
|
||||
Gen2Save.SAVE_SIZE = 32768
|
||||
Gen2Save.PARTY_STRUCT = 48
|
||||
Gen2Save.NAME_LENGTH = 11
|
||||
Gen2Save.PARTY_LENGTH = 6
|
||||
|
||||
function Gen2Save.layoutFor(gameVersion)
|
||||
if gameVersion == "crystal" then return Gen2Layout.crystal end
|
||||
if gameVersion == "gold" or gameVersion == "silver" then return Gen2Layout.goldSilver end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function u8(b, o) return b:byte(o + 1) end
|
||||
local function be(b, o, n)
|
||||
local v = 0
|
||||
for i = 0, n - 1 do v = v * 256 + b:byte(o + i + 1) end
|
||||
return v
|
||||
end
|
||||
|
||||
-- The cart's text encoding, from Gen2Layout.charmap. 0x50 terminates.
|
||||
local function text(b, o, n)
|
||||
local out = {}
|
||||
for i = 0, n - 1 do
|
||||
local c = b:byte(o + i + 1)
|
||||
if not c or c == 0x50 then break end
|
||||
out[#out + 1] = Gen2Layout.charmap[c] or "?"
|
||||
end
|
||||
return table.concat(out)
|
||||
end
|
||||
|
||||
-- Both check values AND the 16-bit sum between them. A blank SRAM sums to a
|
||||
-- valid 0 == 0, so the check values are what reject it.
|
||||
function Gen2Save.checksumValid(bytes, L)
|
||||
if #bytes < Gen2Save.SAVE_SIZE then return nil end
|
||||
if u8(bytes, L.sCheckValue1) ~= 0x63 then return false end
|
||||
if u8(bytes, L.sCheckValue2) ~= 0x7F then return false end
|
||||
local sum = 0
|
||||
for i = L.sGameData, L.sGameDataEnd - 1 do sum = (sum + u8(bytes, i)) % 65536 end
|
||||
local stored = u8(bytes, L.sChecksum) + u8(bytes, L.sChecksum + 1) * 256
|
||||
return sum == stored
|
||||
end
|
||||
|
||||
-- The cart stores numbers, the engine is keyed by name. Same shape
|
||||
-- GenSave.crosswalks builds for Gen 1. Without `data` the raw numbers survive.
|
||||
local function byIndex(defs)
|
||||
local out = {}
|
||||
for id, def in pairs(defs or {}) do
|
||||
if type(def) == "table" and def.index ~= nil then out[def.index] = id end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
local function toIndex(defs)
|
||||
local out = {}
|
||||
for id, def in pairs(defs or {}) do
|
||||
if type(def) == "table" and def.index ~= nil then out[id] = def.index end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
function Gen2Save.crosswalks(data)
|
||||
data = data or {}
|
||||
local items = byIndex(data.items)
|
||||
-- Maps are found by (group, number) rather than by a flat index.
|
||||
local maps = {}
|
||||
for id, def in pairs(data.maps or {}) do
|
||||
if type(def) == "table" and def.group and def.map then
|
||||
maps[def.group * 256 + def.map] = id
|
||||
end
|
||||
end
|
||||
local mapIds = {}
|
||||
for id, def in pairs(data.maps or {}) do
|
||||
if type(def) == "table" and def.group and def.map then
|
||||
mapIds[id] = { def.group, def.map }
|
||||
end
|
||||
end
|
||||
return {
|
||||
pokemon = byIndex(data.pokemon),
|
||||
moves = byIndex(data.moves),
|
||||
items = items,
|
||||
maps = maps,
|
||||
pokemonIndex = toIndex(data.pokemon),
|
||||
moveIndex = toIndex(data.moves),
|
||||
itemIndex = toIndex(data.items),
|
||||
mapIds = mapIds,
|
||||
itemDefs = data.items or {},
|
||||
}
|
||||
end
|
||||
|
||||
-- Name if the crosswalk knows it, the raw number if not. Never silently drops
|
||||
-- a value: an id this build cannot name is still the player's.
|
||||
local function named(map, index)
|
||||
if index == nil or index == 0 then return nil end
|
||||
return map[index] or index
|
||||
end
|
||||
|
||||
-- constants/battle_constants.asm: SLP_MASK is bits 0-2, PSN=3 BRN=4 FRZ=5
|
||||
-- PAR=6. Engine wants an ItemEffects.STATUS_CLASS key, nil when healthy.
|
||||
local STATUS_BITS = { { 3, "psn" }, { 4, "brn" }, { 5, "frz" }, { 6, "par" } }
|
||||
|
||||
local function encodeStatus(name, turns)
|
||||
if name == "slp" then return math.min(math.max(turns or 1, 1), 7) end
|
||||
for _, row in ipairs(STATUS_BITS) do
|
||||
if row[2] == name then return 2 ^ row[1] end
|
||||
end
|
||||
return 0
|
||||
end
|
||||
|
||||
local function decodeStatus(byte)
|
||||
local turns = byte % 8
|
||||
if turns > 0 then return "slp", turns end
|
||||
for _, row in ipairs(STATUS_BITS) do
|
||||
if math.floor(byte / 2 ^ row[1]) % 2 == 1 then return row[2], nil end
|
||||
end
|
||||
return nil, nil
|
||||
end
|
||||
|
||||
-- Four DVs as nibbles in two bytes; the HP DV is rebuilt from the low bit of
|
||||
-- each (pokecrystal engine/pokemon/health.asm).
|
||||
local function decodeDVs(bytes, o)
|
||||
local hi, lo = u8(bytes, o), u8(bytes, o + 1)
|
||||
local atk = math.floor(hi / 16)
|
||||
local def = hi % 16
|
||||
local spd = math.floor(lo / 16)
|
||||
local spc = lo % 16
|
||||
local hp = (atk % 2) * 8 + (def % 2) * 4 + (spd % 2) * 2 + (spc % 2)
|
||||
return { hp = hp, attack = atk, defense = def, speed = spd, special = spc }
|
||||
end
|
||||
|
||||
-- Five 16-bit stat experience words, in the cart's order.
|
||||
local function decodeStatExp(bytes, o)
|
||||
return {
|
||||
hp = be(bytes, o, 2), attack = be(bytes, o + 2, 2),
|
||||
defense = be(bytes, o + 4, 2), speed = be(bytes, o + 6, 2),
|
||||
special = be(bytes, o + 8, 2),
|
||||
}
|
||||
end
|
||||
|
||||
-- The first 32 bytes, which a box mon and a party mon share.
|
||||
local function decodeSharedMon(bytes, o, x)
|
||||
local moves, pp = {}, {}
|
||||
for i = 0, 3 do
|
||||
local id = named(x.moves, u8(bytes, o + 2 + i))
|
||||
if id then moves[#moves + 1] = id end
|
||||
end
|
||||
for i = 0, 3 do pp[i + 1] = u8(bytes, o + 0x17 + i) % 64 end
|
||||
return {
|
||||
species = named(x.pokemon, u8(bytes, o)),
|
||||
item = named(x.items, u8(bytes, o + 1)),
|
||||
moves = moves, pp = pp,
|
||||
otId = be(bytes, o + 6, 2),
|
||||
experience = be(bytes, o + 8, 3),
|
||||
statExp = decodeStatExp(bytes, o + 0x0B),
|
||||
dvs = decodeDVs(bytes, o + 0x15),
|
||||
happiness = u8(bytes, o + 0x1B),
|
||||
pokerus = u8(bytes, o + 0x1C),
|
||||
caughtData = be(bytes, o + 0x1D, 2),
|
||||
level = u8(bytes, o + 0x1F),
|
||||
}
|
||||
end
|
||||
|
||||
local function decodeMon(bytes, o, x)
|
||||
local mon = decodeSharedMon(bytes, o, x)
|
||||
mon.status, mon.statusTurns = decodeStatus(u8(bytes, o + 0x20))
|
||||
mon.hp = be(bytes, o + 0x22, 2)
|
||||
mon.maxHp = be(bytes, o + 0x24, 2)
|
||||
mon.stats = {
|
||||
hp = mon.maxHp,
|
||||
attack = be(bytes, o + 0x26, 2), defense = be(bytes, o + 0x28, 2),
|
||||
speed = be(bytes, o + 0x2A, 2), specialAttack = be(bytes, o + 0x2C, 2),
|
||||
specialDefense = be(bytes, o + 0x2E, 2),
|
||||
}
|
||||
return mon
|
||||
end
|
||||
|
||||
-- box_struct: OTs come BEFORE nicknames, and a box mon is 32 bytes with none
|
||||
-- of the party's computed stats.
|
||||
Gen2Save.BOX_CAPACITY = 20
|
||||
Gen2Save.BOX_MON_STRUCT = 32
|
||||
local BOX_SPECIES, BOX_MONS, BOX_OTS, BOX_NICKS = 0x01, 0x16, 0x296, 0x372
|
||||
|
||||
local decodeBoxMon = decodeSharedMon
|
||||
|
||||
-- A count past BOX_CAPACITY means the wrong layout, not an odd save.
|
||||
function Gen2Save.decodeBoxes(bytes, L, x)
|
||||
local boxes = {}
|
||||
for index, base in ipairs(L.boxes) do
|
||||
local count = u8(bytes, base)
|
||||
if count > Gen2Save.BOX_CAPACITY then
|
||||
return nil, ("box %d reports %d Pokemon, which is impossible; this is the "
|
||||
.. "wrong layout for this save"):format(index, count)
|
||||
end
|
||||
local mons = {}
|
||||
for i = 0, count - 1 do
|
||||
local mon = decodeBoxMon(bytes, base + BOX_MONS + i * Gen2Save.BOX_MON_STRUCT, x)
|
||||
-- The species list beside the mons must agree, which is a free check
|
||||
-- on the layout.
|
||||
local listed = named(x.pokemon, u8(bytes, base + BOX_SPECIES + i))
|
||||
if listed ~= mon.species then
|
||||
return nil, ("box %d slot %d: the species list says %s and the stored "
|
||||
.. "Pokemon says %s; wrong layout for this save")
|
||||
:format(index, i + 1, tostring(listed), tostring(mon.species))
|
||||
end
|
||||
mon.ot = text(bytes, base + BOX_OTS + i * Gen2Save.NAME_LENGTH, Gen2Save.NAME_LENGTH)
|
||||
mon.nickname = text(bytes, base + BOX_NICKS + i * Gen2Save.NAME_LENGTH, Gen2Save.NAME_LENGTH)
|
||||
mons[#mons + 1] = mon
|
||||
end
|
||||
boxes[index] = mons
|
||||
end
|
||||
return boxes
|
||||
end
|
||||
|
||||
-- The bag. Gen 2 splits it into four pockets, and they are not all shaped the
|
||||
-- same: ITEM and BALL are (id, quantity) pairs, KEY_ITEM is bare ids because a
|
||||
-- key item is unique, and TM_HM is a flat run of counts indexed by TM number.
|
||||
-- All of the list pockets are terminated by 0xFF as well as counted, and the
|
||||
-- count is trusted only as far as the terminator.
|
||||
-- One bag keyed by item id; PackMenu buckets it by each item's `pocket`.
|
||||
-- ITEM and BALL are (id, quantity) pairs, KEY_ITEM is bare ids.
|
||||
local function addPairs(out, bytes, countAt, listAt, cap, x)
|
||||
local n = u8(bytes, countAt)
|
||||
if n > cap then n = cap end
|
||||
for i = 0, n - 1 do
|
||||
local raw = u8(bytes, listAt + i * 2)
|
||||
if raw == 0xFF or raw == 0 then break end
|
||||
local id = named(x.items, raw)
|
||||
out[id] = (out[id] or 0) + u8(bytes, listAt + i * 2 + 1)
|
||||
end
|
||||
end
|
||||
|
||||
local function addIds(out, bytes, countAt, listAt, cap, x)
|
||||
local n = u8(bytes, countAt)
|
||||
if n > cap then n = cap end
|
||||
for i = 0, n - 1 do
|
||||
local raw = u8(bytes, listAt + i)
|
||||
if raw == 0xFF or raw == 0 then break end
|
||||
local id = named(x.items, raw)
|
||||
out[id] = (out[id] or 0) + 1
|
||||
end
|
||||
end
|
||||
|
||||
-- TM_HM is a flat run of counts indexed by TM number.
|
||||
local function addMachines(out, bytes, at, x, items)
|
||||
local byNumber = {}
|
||||
for id, def in pairs(items or {}) do
|
||||
if type(def) == "table" and def.tmNumber then byNumber[def.tmNumber] = id end
|
||||
end
|
||||
for number, id in pairs(byNumber) do
|
||||
local count = u8(bytes, at + number - 1)
|
||||
if count > 0 then out[id] = (out[id] or 0) + count end
|
||||
end
|
||||
end
|
||||
|
||||
-- Byte index -> byte, which is what Save.scrubEvents validates.
|
||||
local function decodeFlagBytes(bytes, at, count)
|
||||
local out = {}
|
||||
for i = 0, count - 1 do out[i] = u8(bytes, at + i) end
|
||||
return out
|
||||
end
|
||||
|
||||
-- A set keyed by species id: save.pokedex.caught[species] = true.
|
||||
local function decodeDex(bytes, at, x)
|
||||
local out = {}
|
||||
for i = 0, Gen2Save.NUM_SPECIES - 1 do
|
||||
local byte = u8(bytes, at + math.floor(i / 8))
|
||||
if math.floor(byte / (2 ^ (i % 8))) % 2 == 1 then
|
||||
out[named(x.pokemon, i + 1) or (i + 1)] = true
|
||||
end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
-- Badges by NAME, which is how FieldMoves.hasBadge and Battle:hasBadge read
|
||||
-- them. Bit position is accepted as a fallback key there, but the name is the
|
||||
-- primary and is what a save written by this project carries.
|
||||
Gen2Save.JOHTO_BADGES = {
|
||||
"ZEPHYR", "HIVE", "PLAIN", "FOG", "MINERAL", "STORM", "GLACIER", "RISING",
|
||||
}
|
||||
Gen2Save.KANTO_BADGES = {
|
||||
"BOULDER", "CASCADE", "THUNDER", "RAINBOW",
|
||||
"SOUL", "MARSH", "VOLCANO", "EARTH",
|
||||
}
|
||||
local function decodeBadges(byte, order)
|
||||
local out = {}
|
||||
for bit, name in ipairs(order) do
|
||||
if math.floor(byte / (2 ^ (bit - 1))) % 2 == 1 then out[name] = true end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
Gen2Save.NUM_SPECIES = 251
|
||||
Gen2Save.EVENT_BYTES = 256
|
||||
|
||||
-- decode(bytes, gameVersion, data) -> partial save table, err
|
||||
function Gen2Save.decode(bytes, gameVersion, data)
|
||||
local L = Gen2Save.layoutFor(gameVersion)
|
||||
if not L then return nil, "no Gen 2 layout for " .. tostring(gameVersion) end
|
||||
if type(bytes) ~= "string" or #bytes < Gen2Save.SAVE_SIZE then
|
||||
return nil, ("save must be at least %d bytes"):format(Gen2Save.SAVE_SIZE)
|
||||
end
|
||||
-- TryLoadSaveFile falls back to VerifyBackupChecksum and loads the backup
|
||||
-- copy, so a save the real cartridge would open must not be refused here.
|
||||
-- Crystal's backup is contiguous and laid out like the primary; Gold and
|
||||
-- Silver split theirs across three sections and have none to offer.
|
||||
if Gen2Save.checksumValid(bytes, L) ~= true then
|
||||
if L.backup and Gen2Save.checksumValid(bytes, L.backup) == true then
|
||||
L = L.backup
|
||||
else
|
||||
return nil, "save data checksum invalid (Gen 2 check values or sum mismatch)"
|
||||
end
|
||||
end
|
||||
|
||||
local x = Gen2Save.crosswalks(data)
|
||||
local count = u8(bytes, L.wPartyCount)
|
||||
if count > Gen2Save.PARTY_LENGTH then
|
||||
return nil, ("party count %d is impossible; this is probably the wrong layout")
|
||||
:format(count)
|
||||
end
|
||||
local party = {}
|
||||
for i = 0, count - 1 do
|
||||
local mon = decodeMon(bytes, L.wPartyMons + i * Gen2Save.PARTY_STRUCT, x)
|
||||
mon.nickname = text(bytes, L.wPartyMonNicknames + i * Gen2Save.NAME_LENGTH,
|
||||
Gen2Save.NAME_LENGTH)
|
||||
mon.ot = text(bytes, L.wPartyMonOTs + i * Gen2Save.NAME_LENGTH,
|
||||
Gen2Save.NAME_LENGTH)
|
||||
party[#party + 1] = mon
|
||||
end
|
||||
|
||||
local boxes, boxErr = Gen2Save.decodeBoxes(bytes, L, x)
|
||||
if not boxes then return nil, boxErr end
|
||||
|
||||
local inventory = {}
|
||||
addPairs(inventory, bytes, L.wNumItems, L.wItems, 20, x)
|
||||
addIds(inventory, bytes, L.wNumKeyItems, L.wKeyItems, 25, x)
|
||||
addPairs(inventory, bytes, L.wNumBalls, L.wBalls, 12, x)
|
||||
if L.wTMsHMs then addMachines(inventory, bytes, L.wTMsHMs, x, (data or {}).items) end
|
||||
|
||||
return {
|
||||
generation = 2,
|
||||
version = gameVersion,
|
||||
player = {
|
||||
name = text(bytes, L.wPlayerName, Gen2Save.NAME_LENGTH),
|
||||
id = be(bytes, L.wPlayerID, 2),
|
||||
money = be(bytes, L.wMoney, 3),
|
||||
coins = be(bytes, L.wCoins, 2),
|
||||
badges = decodeBadges(u8(bytes, L.wBadges), Gen2Save.JOHTO_BADGES),
|
||||
kantoBadges = decodeBadges(u8(bytes, L.wKantoBadges), Gen2Save.KANTO_BADGES),
|
||||
},
|
||||
rival = { name = text(bytes, L.wRivalName, Gen2Save.NAME_LENGTH) },
|
||||
mom = { name = text(bytes, L.wMomsName, Gen2Save.NAME_LENGTH) },
|
||||
party = party,
|
||||
boxes = boxes,
|
||||
currentBox = u8(bytes, L.wCurBox) % 16 + 1,
|
||||
inventory = inventory,
|
||||
-- Species ids, 1-based, so the set keys match save.pokedex.caught[species].
|
||||
pokedex = {
|
||||
caught = decodeDex(bytes, L.wPokedexCaught, x),
|
||||
seen = decodeDex(bytes, L.wPokedexSeen, x),
|
||||
},
|
||||
events = decodeFlagBytes(bytes, L.wEventFlags, Gen2Save.EVENT_BYTES),
|
||||
-- Save.summary does `save.position.map or save.spawn`.
|
||||
position = {
|
||||
map = x.maps[u8(bytes, L.wMapGroup) * 256 + u8(bytes, L.wMapNumber)],
|
||||
mapGroup = u8(bytes, L.wMapGroup), mapNumber = u8(bytes, L.wMapNumber),
|
||||
x = u8(bytes, L.wXCoord), y = u8(bytes, L.wYCoord),
|
||||
},
|
||||
playTime = {
|
||||
hours = be(bytes, L.wGameTimeHours, 2),
|
||||
minutes = u8(bytes, L.wGameTimeMinutes),
|
||||
seconds = 0, frames = 0,
|
||||
},
|
||||
}
|
||||
end
|
||||
|
||||
-- Everything the cart does not carry comes from a fresh game, exactly as
|
||||
-- Gen 1's mergeDefaults does: the decode above models what the SRAM holds,
|
||||
-- and mail, phone contacts, the unown dex, the hall of fame and the RTC are
|
||||
-- left to the engine's own defaults rather than invented here.
|
||||
--
|
||||
-- Loaded lazily. src/core/gen2/Save.lua pulls in love.filesystem at require
|
||||
-- time, and this module has to stay require-clean for the headless CLI and
|
||||
-- the tests, same rule GenSave follows.
|
||||
function Gen2Save.mergeDefaults(decoded, gameVersion)
|
||||
local ok, Save = pcall(require, "src.core.gen2.Save")
|
||||
if not ok then return decoded end
|
||||
local base = Save.newGame({ playerName = decoded.player and decoded.player.name })
|
||||
for k, v in pairs(decoded) do base[k] = v end
|
||||
base.version = gameVersion
|
||||
return base
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------------------
|
||||
-- Export
|
||||
-- ------------------------------------------------------------------
|
||||
|
||||
local function putU8(t, at, v) t[at] = v % 256 end
|
||||
local function putBE(t, at, v, n)
|
||||
for i = n - 1, 0, -1 do t[at + i] = v % 256; v = math.floor(v / 256) end
|
||||
end
|
||||
|
||||
-- Engine id back to the cart's number. A raw number passes through, which is
|
||||
-- how a save imported without a crosswalk round trips.
|
||||
local function indexOf(map, id)
|
||||
if id == nil then return 0 end
|
||||
if type(id) == "number" then return id end
|
||||
return map[id] or 0
|
||||
end
|
||||
|
||||
local function charBytes(str, i)
|
||||
local b = str:byte(i)
|
||||
if not b then return 0 end
|
||||
if b < 0x80 then return 1 end
|
||||
if b >= 0xF0 then return 4 end
|
||||
if b >= 0xE0 then return 3 end
|
||||
if b >= 0xC0 then return 2 end
|
||||
return 1
|
||||
end
|
||||
|
||||
local function glyphChars(glyph)
|
||||
local n, i = 0, 1
|
||||
while i <= #glyph do i = i + charBytes(glyph, i); n = n + 1 end
|
||||
return n
|
||||
end
|
||||
|
||||
-- One-CHARACTER glyphs plus PK and MN. The cart's table also carries the
|
||||
-- ligature halves PO and KE, and accepting those turns a name containing "PO"
|
||||
-- into 0x70 where the cart had a plain P. #glyph counts BYTES, so a byte test
|
||||
-- would also drop every multi-byte glyph and turn NIDORAN into NIDORAN?.
|
||||
local REVERSE = nil
|
||||
local function reverseCharmap()
|
||||
if REVERSE then return REVERSE end
|
||||
REVERSE = {}
|
||||
for code, glyph in pairs(Gen2Layout.charmap) do
|
||||
if glyphChars(glyph) == 1 then REVERSE[glyph] = code end
|
||||
end
|
||||
for code, glyph in pairs(Gen2Layout.charmap) do
|
||||
if glyph == "PK" or glyph == "MN" then REVERSE[glyph] = code end
|
||||
end
|
||||
return REVERSE
|
||||
end
|
||||
|
||||
local function putText(t, at, str, n)
|
||||
local codes = reverseCharmap()
|
||||
local i, written = 1, 0
|
||||
while i <= #str and written < n - 1 do
|
||||
local two = str:sub(i, i + 1)
|
||||
if (two == "PK" or two == "MN") and codes[two] then
|
||||
t[at + written] = codes[two]; i = i + 2
|
||||
else
|
||||
local w = charBytes(str, i)
|
||||
t[at + written] = codes[str:sub(i, i + w - 1)] or 0xE6
|
||||
i = i + w
|
||||
end
|
||||
written = written + 1
|
||||
end
|
||||
t[at + written] = 0x50
|
||||
end
|
||||
|
||||
local function putBadges(t, at, owned, order)
|
||||
local byte = 0
|
||||
for bit, name in ipairs(order) do
|
||||
if owned and owned[name] then byte = byte + 2 ^ (bit - 1) end
|
||||
end
|
||||
t[at] = byte
|
||||
end
|
||||
|
||||
local function putSharedMon(t, o, mon, x)
|
||||
putU8(t, o, indexOf(x.pokemonIndex, mon.species))
|
||||
putU8(t, o + 1, indexOf(x.itemIndex, mon.item))
|
||||
for i = 0, 3 do
|
||||
putU8(t, o + 2 + i, indexOf(x.moveIndex, (mon.moves or {})[i + 1]))
|
||||
end
|
||||
putBE(t, o + 6, mon.otId or 0, 2)
|
||||
putBE(t, o + 8, mon.experience or 0, 3)
|
||||
local se = mon.statExp or {}
|
||||
putBE(t, o + 0x0B, se.hp or 0, 2); putBE(t, o + 0x0D, se.attack or 0, 2)
|
||||
putBE(t, o + 0x0F, se.defense or 0, 2); putBE(t, o + 0x11, se.speed or 0, 2)
|
||||
putBE(t, o + 0x13, se.special or 0, 2)
|
||||
local d = mon.dvs or {}
|
||||
putU8(t, o + 0x15, (d.attack or 0) * 16 + (d.defense or 0))
|
||||
putU8(t, o + 0x16, (d.speed or 0) * 16 + (d.special or 0))
|
||||
for i = 0, 3 do
|
||||
putU8(t, o + 0x17 + i, (mon.ppRaw or {})[i + 1] or (mon.pp or {})[i + 1] or 0)
|
||||
end
|
||||
putU8(t, o + 0x1B, mon.happiness or 0)
|
||||
-- 0x1C-0x1E belong to the mon, not to the slot: leaving them to the template
|
||||
-- means reordering the party gives slot 1 the previous occupant's pokerus
|
||||
-- and caught data.
|
||||
putU8(t, o + 0x1C, mon.pokerus or 0)
|
||||
putBE(t, o + 0x1D, mon.caughtData or 0, 2)
|
||||
putU8(t, o + 0x1F, mon.level or 0)
|
||||
end
|
||||
|
||||
-- The bag back into its four pockets, by each item's own `pocket`.
|
||||
local function putBag(t, L, inventory, x)
|
||||
local buckets = { ITEM = {}, KEY_ITEM = {}, BALL = {}, TM_HM = {} }
|
||||
for id, count in pairs(inventory or {}) do
|
||||
local def = x.itemDefs[id]
|
||||
local pocket = (type(def) == "table" and def.pocket) or "ITEM"
|
||||
if buckets[pocket] == nil then pocket = "ITEM" end
|
||||
buckets[pocket][#buckets[pocket] + 1] = { id = id, count = count, def = def }
|
||||
end
|
||||
-- By cart index, so the order is deterministic. A flat inventory has no
|
||||
-- order of its own, so the pocket order a round trip produces is stable
|
||||
-- rather than original.
|
||||
for _, list in pairs(buckets) do
|
||||
table.sort(list, function(a, b)
|
||||
return indexOf(x.itemIndex, a.id) < indexOf(x.itemIndex, b.id)
|
||||
end)
|
||||
end
|
||||
|
||||
local overflow = nil
|
||||
local function writePairs(countAt, listAt, cap, list, pocket)
|
||||
if #list > cap then overflow = overflow or { pocket, #list, cap } end
|
||||
local n = math.min(#list, cap)
|
||||
putU8(t, countAt, n)
|
||||
for i = 1, n do
|
||||
putU8(t, listAt + (i - 1) * 2, indexOf(x.itemIndex, list[i].id))
|
||||
putU8(t, listAt + (i - 1) * 2 + 1, math.min(list[i].count, 99))
|
||||
end
|
||||
putU8(t, listAt + n * 2, 0xFF)
|
||||
end
|
||||
local function writeIds(countAt, listAt, cap, list, pocket)
|
||||
if #list > cap then overflow = overflow or { pocket, #list, cap } end
|
||||
local n = math.min(#list, cap)
|
||||
putU8(t, countAt, n)
|
||||
for i = 1, n do putU8(t, listAt + i - 1, indexOf(x.itemIndex, list[i].id)) end
|
||||
putU8(t, listAt + n, 0xFF)
|
||||
end
|
||||
|
||||
writePairs(L.wNumItems, L.wItems, 20, buckets.ITEM, "ITEM")
|
||||
writeIds(L.wNumKeyItems, L.wKeyItems, 25, buckets.KEY_ITEM, "KEY_ITEM")
|
||||
writePairs(L.wNumBalls, L.wBalls, 12, buckets.BALL, "BALL")
|
||||
if L.wTMsHMs then
|
||||
for _, row in ipairs(buckets.TM_HM) do
|
||||
local number = type(row.def) == "table" and row.def.tmNumber
|
||||
if number then putU8(t, L.wTMsHMs + number - 1, math.min(row.count, 99)) end
|
||||
end
|
||||
end
|
||||
return overflow
|
||||
end
|
||||
|
||||
local function putFlagSet(t, at, set, count, indexFor)
|
||||
for i = 0, count - 1 do
|
||||
local byteAt = at + math.floor(i / 8)
|
||||
local bit = 2 ^ (i % 8)
|
||||
local cur = t[byteAt] or 0
|
||||
local on = math.floor(cur / bit) % 2 == 1
|
||||
local want = set and set[indexFor(i)] == true
|
||||
if on ~= want then t[byteAt] = want and (cur + bit) or (cur - bit) end
|
||||
end
|
||||
end
|
||||
|
||||
-- encode(save, gameVersion, template, data) -> bytes, err
|
||||
--
|
||||
-- Writes into the cartridge image the save came from: Gen 2 SRAM holds a great
|
||||
-- deal this codec does not model and the real game trusts it on CONTINUE, so a
|
||||
-- save with no image behind it is refused rather than built from nothing.
|
||||
--
|
||||
-- Only the primary copy is written. TryLoadSaveFile rewrites the backup from
|
||||
-- the primary on every successful load.
|
||||
function Gen2Save.encode(save, gameVersion, template, data)
|
||||
local L = Gen2Save.layoutFor(gameVersion)
|
||||
if not L then return nil, "no Gen 2 layout for " .. tostring(gameVersion) end
|
||||
if type(save) ~= "table" then return nil, "expected a save table" end
|
||||
if type(template) ~= "string" or #template < Gen2Save.SAVE_SIZE then
|
||||
return nil, "this save has no cartridge image to write back into, and a "
|
||||
.. "Gen 2 save built from nothing does not boot on real hardware"
|
||||
end
|
||||
|
||||
local x = Gen2Save.crosswalks(data)
|
||||
local t = {}
|
||||
for i = 0, Gen2Save.SAVE_SIZE - 1 do t[i] = template:byte(i + 1) end
|
||||
|
||||
local p = save.player or {}
|
||||
putText(t, L.wPlayerName, p.name or "", Gen2Save.NAME_LENGTH)
|
||||
putBE(t, L.wPlayerID, p.id or 0, 2)
|
||||
putBE(t, L.wMoney, p.money or 0, 3)
|
||||
putBE(t, L.wCoins, p.coins or 0, 2)
|
||||
putBadges(t, L.wBadges, p.badges, Gen2Save.JOHTO_BADGES)
|
||||
putBadges(t, L.wKantoBadges, p.kantoBadges, Gen2Save.KANTO_BADGES)
|
||||
putText(t, L.wRivalName, (save.rival or {}).name or "", Gen2Save.NAME_LENGTH)
|
||||
putText(t, L.wMomsName, (save.mom or {}).name or "", Gen2Save.NAME_LENGTH)
|
||||
|
||||
local party = save.party or {}
|
||||
if #party > Gen2Save.PARTY_LENGTH then
|
||||
return nil, ("a party of %d cannot be written to a cartridge"):format(#party)
|
||||
end
|
||||
putU8(t, L.wPartyCount, #party)
|
||||
for i, mon in ipairs(party) do
|
||||
putU8(t, L.wPartySpecies + i - 1, indexOf(x.pokemonIndex, mon.species))
|
||||
local o = L.wPartyMons + (i - 1) * Gen2Save.PARTY_STRUCT
|
||||
putSharedMon(t, o, mon, x)
|
||||
putU8(t, o + 0x20, encodeStatus(mon.status, mon.statusTurns))
|
||||
putBE(t, o + 0x22, mon.hp or 0, 2)
|
||||
local st = mon.stats or {}
|
||||
putBE(t, o + 0x24, mon.maxHp or st.hp or 0, 2)
|
||||
putBE(t, o + 0x26, st.attack or 0, 2); putBE(t, o + 0x28, st.defense or 0, 2)
|
||||
putBE(t, o + 0x2A, st.speed or 0, 2); putBE(t, o + 0x2C, st.specialAttack or 0, 2)
|
||||
putBE(t, o + 0x2E, st.specialDefense or 0, 2)
|
||||
putText(t, L.wPartyMonNicknames + (i - 1) * Gen2Save.NAME_LENGTH,
|
||||
mon.nickname or "", Gen2Save.NAME_LENGTH)
|
||||
putText(t, L.wPartyMonOTs + (i - 1) * Gen2Save.NAME_LENGTH,
|
||||
mon.ot or "", Gen2Save.NAME_LENGTH)
|
||||
end
|
||||
putU8(t, L.wPartySpecies + #party, 0xFF)
|
||||
|
||||
for index, base in ipairs(L.boxes) do
|
||||
local box = (save.boxes or {})[index] or {}
|
||||
if #box > Gen2Save.BOX_CAPACITY then
|
||||
return nil, ("box %d holds %d, which a cartridge cannot"):format(index, #box)
|
||||
end
|
||||
putU8(t, base, #box)
|
||||
for i, mon in ipairs(box) do
|
||||
putU8(t, base + BOX_SPECIES + i - 1, indexOf(x.pokemonIndex, mon.species))
|
||||
putSharedMon(t, base + BOX_MONS + (i - 1) * Gen2Save.BOX_MON_STRUCT, mon, x)
|
||||
putText(t, base + BOX_OTS + (i - 1) * Gen2Save.NAME_LENGTH,
|
||||
mon.ot or "", Gen2Save.NAME_LENGTH)
|
||||
putText(t, base + BOX_NICKS + (i - 1) * Gen2Save.NAME_LENGTH,
|
||||
mon.nickname or "", Gen2Save.NAME_LENGTH)
|
||||
end
|
||||
putU8(t, base + BOX_SPECIES + #box, 0xFF)
|
||||
end
|
||||
|
||||
-- Refuse rather than drop. Without item defs every item buckets into ITEM,
|
||||
-- which holds 20, and a real bag is bigger than that.
|
||||
local overflow = putBag(t, L, save.inventory, x)
|
||||
if overflow then
|
||||
return nil, ("the %s pocket would need %d slots and a cartridge has %d; "
|
||||
.. "the item table for this game is needed to sort the bag")
|
||||
:format(overflow[1], overflow[2], overflow[3])
|
||||
end
|
||||
if save.currentBox then putU8(t, L.wCurBox, (save.currentBox - 1) % 16) end
|
||||
if save.boxNames and L.wBoxNames then
|
||||
for i = 1, 14 do
|
||||
putText(t, L.wBoxNames + (i - 1) * 9, save.boxNames[i] or "", 9)
|
||||
end
|
||||
end
|
||||
|
||||
if save.pokedex then
|
||||
local function species(i) return x.pokemon[i + 1] or (i + 1) end
|
||||
putFlagSet(t, L.wPokedexCaught, save.pokedex.caught, Gen2Save.NUM_SPECIES, species)
|
||||
putFlagSet(t, L.wPokedexSeen, save.pokedex.seen, Gen2Save.NUM_SPECIES, species)
|
||||
end
|
||||
for i = 0, Gen2Save.EVENT_BYTES - 1 do
|
||||
local byte = (save.events or {})[i]
|
||||
if type(byte) == "number" then putU8(t, L.wEventFlags + i, byte) end
|
||||
end
|
||||
|
||||
local pos = save.position
|
||||
if pos then
|
||||
local ids = pos.map and x.mapIds[pos.map]
|
||||
putU8(t, L.wMapGroup, (ids and ids[1]) or pos.mapGroup or 0)
|
||||
putU8(t, L.wMapNumber, (ids and ids[2]) or pos.mapNumber or 0)
|
||||
putU8(t, L.wXCoord, pos.x or 0)
|
||||
putU8(t, L.wYCoord, pos.y or 0)
|
||||
end
|
||||
local pt = save.playTime
|
||||
if pt then
|
||||
putBE(t, L.wGameTimeHours, pt.hours or 0, 2)
|
||||
putU8(t, L.wGameTimeMinutes, pt.minutes or 0)
|
||||
end
|
||||
|
||||
putU8(t, L.sCheckValue1, 0x63)
|
||||
putU8(t, L.sCheckValue2, 0x7F)
|
||||
local sum = 0
|
||||
for i = L.sGameData, L.sGameDataEnd - 1 do sum = (sum + t[i]) % 65536 end
|
||||
putU8(t, L.sChecksum, sum % 256)
|
||||
putU8(t, L.sChecksum + 1, math.floor(sum / 256) % 256)
|
||||
|
||||
local out = {}
|
||||
for i = 0, Gen2Save.SAVE_SIZE - 1 do out[i + 1] = string.char(t[i]) end
|
||||
-- Whatever followed the 32 KiB of SRAM is the cart's RTC footer. Dropping it
|
||||
-- resets the clock, which costs the player daily events and the bug contest
|
||||
-- and earns them the clock-adjustment penalty.
|
||||
return table.concat(out) .. template:sub(Gen2Save.SAVE_SIZE + 1)
|
||||
end
|
||||
|
||||
return Gen2Save
|
||||
@@ -21,12 +21,23 @@
|
||||
-- require alone cannot see them there (#420).
|
||||
|
||||
local GenSave = require("src.save_convert.GenSave")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local Gen2Save = require("src.save_convert.Gen2Save")
|
||||
|
||||
local SaveConvert = {}
|
||||
|
||||
SaveConvert.SAVE_SIZE = GenSave.SAVE_SIZE
|
||||
SaveConvert.mainChecksumValid = GenSave.mainChecksumValid
|
||||
-- Is this a real save for THIS GAME? Dispatches on the generation, because
|
||||
-- the two do not share a rule: Gen 1 stores a complement checksum of its main
|
||||
-- data block, Gen 2 stores two check values plus a 16-bit sum. Run one over
|
||||
-- the other's bytes and the answer is always no.
|
||||
--
|
||||
-- gameVersion is optional and defaults to Gen 1's rule, which is what every
|
||||
-- caller meant before Gen 2 had a codec.
|
||||
function SaveConvert.mainChecksumValid(bytes, gameVersion)
|
||||
local L = gameVersion and Gen2Save.layoutFor(gameVersion)
|
||||
if L then return Gen2Save.checksumValid(bytes, L) end
|
||||
return GenSave.mainChecksumValid(bytes)
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------------------
|
||||
-- Crosswalk data loading (cached). Mirrors src/core/Data.lua: prefer
|
||||
@@ -125,6 +136,24 @@ local function loadCacheTable(gameVersion, filePath)
|
||||
return nil
|
||||
end
|
||||
|
||||
-- The generated tables Gen2Save needs to turn cart numbers into the ids the
|
||||
-- engine is keyed by. Deliberately not ensureData: that one also demands Gen
|
||||
-- 1's charmap, event flags and hidden items, none of which a Gen 2 cache has
|
||||
-- or a Gen 2 save uses.
|
||||
local gen2Data = {}
|
||||
local function ensureGen2Data(gameVersion)
|
||||
local key = gameVersion or "*"
|
||||
if gen2Data[key] == nil then
|
||||
local out = {}
|
||||
for _, name in ipairs({ "pokemon", "moves", "items", "maps" }) do
|
||||
out[name] = loadCacheTable(gameVersion, "data/generated/" .. name .. ".lua")
|
||||
or (loadTable("data.generated." .. name, "data/generated/" .. name .. ".lua"))
|
||||
end
|
||||
gen2Data[key] = out
|
||||
end
|
||||
return gen2Data[key]
|
||||
end
|
||||
|
||||
-- Crosswalk sets keyed by the game whose cache they came from ("*" for the
|
||||
-- require-resolved set): Yellow's tables are not Red's, so one import must
|
||||
-- never be handed the previous import's data (#420).
|
||||
@@ -228,11 +257,36 @@ SaveConvert.mergeDefaults = mergeDefaults
|
||||
-- pokered's single sPlayerName..sMainDataCheckSum window), and no Gen 2
|
||||
-- codec exists yet. Both directions answer with a plain message the
|
||||
-- launcher's save card renders as-is, instead of pushing a Gen 2 save
|
||||
-- table through Gen 1 offsets and surfacing a codec traceback.
|
||||
local function gen2CartName(gameVersion)
|
||||
if not GameVersion.VERSIONS[gameVersion] then return nil end
|
||||
if GameVersion.generation(gameVersion) ~= 2 then return nil end
|
||||
return GameVersion.info(gameVersion).displayName
|
||||
-- Can a cart save for this game cross in or out at all? Public because the
|
||||
-- launcher has to ask about the GAME before it measures the BYTES.
|
||||
--
|
||||
-- SaveFileIO.importToSlot judges anything that is not exactly SAVE_SIZE with
|
||||
-- mainChecksumValid, which is pokered's main-data checksum. A Gen 2 cart is
|
||||
-- MBC3+TIMER, so a real Gold/Silver/Crystal .sav carries an RTC footer and is
|
||||
-- 32786 bytes: it misses the size test and is then measured against a rule
|
||||
-- that was never going to match it. The player is told "save data checksum
|
||||
-- invalid" about a save that is perfectly good (#1832).
|
||||
--
|
||||
-- Returns true, or false plus the same sentence importSav/exportSav would have
|
||||
-- answered with, so a caller that asks early and a caller that does not cannot
|
||||
-- describe the same game two different ways.
|
||||
-- Does this game use a Gen 2 cart save? The RTC footer that follows one is
|
||||
-- expected rather than surprising, which the import path needs to know.
|
||||
function SaveConvert.isGen2Cart(gameVersion)
|
||||
return Gen2Save.layoutFor(gameVersion) ~= nil
|
||||
end
|
||||
|
||||
function SaveConvert.importSupported(gameVersion)
|
||||
-- Gen 2 imports through Gen2Save now. Kept as a predicate rather than
|
||||
-- deleted: SaveFileIO asks it before it measures the bytes, and export
|
||||
-- still answers no.
|
||||
return true
|
||||
end
|
||||
|
||||
-- Gen 2 exports through Gen2Save now, but only for a save with a cartridge
|
||||
-- image behind it; encode says so itself.
|
||||
function SaveConvert.exportSupported(gameVersion)
|
||||
return true
|
||||
end
|
||||
|
||||
-- importSav(bytes, version, gameVersion) -> saveTable, err
|
||||
@@ -247,9 +301,16 @@ function SaveConvert.importSav(bytes, version, gameVersion)
|
||||
if type(bytes) ~= "string" then
|
||||
return nil, "expected raw save bytes as a string"
|
||||
end
|
||||
local gen2Name = gen2CartName(gameVersion)
|
||||
if gen2Name then
|
||||
return nil, gen2Name .. " uses a Gen 2 cart save; importing one is not supported yet."
|
||||
local supported, unsupportedWhy = SaveConvert.importSupported(gameVersion)
|
||||
if not supported then return nil, unsupportedWhy end
|
||||
-- Gen 2 is a different SRAM entirely: different bank map, different party
|
||||
-- struct, its own check values. Gen2Save owns it, and it needs no crosswalk
|
||||
-- tables because it decodes ids the engine already speaks.
|
||||
if Gen2Save.layoutFor(gameVersion) then
|
||||
local decoded, gen2Err = Gen2Save.decode(bytes, gameVersion,
|
||||
ensureGen2Data(gameVersion))
|
||||
if not decoded then return nil, gen2Err end
|
||||
return Gen2Save.mergeDefaults(decoded, gameVersion)
|
||||
end
|
||||
if #bytes ~= GenSave.SAVE_SIZE then
|
||||
return nil, ("save must be %d bytes, got %d"):format(GenSave.SAVE_SIZE, #bytes)
|
||||
@@ -273,19 +334,22 @@ function SaveConvert.importSav(bytes, version, gameVersion)
|
||||
return mergeDefaults(decoded, version)
|
||||
end
|
||||
|
||||
-- exportSav(saveTable, gameVersion) -> bytes, err
|
||||
-- exportSav(saveTable, gameVersion, cartImage) -> bytes, err
|
||||
-- Encodes a save table back to a raw 32768-byte SRAM image. Template-aware:
|
||||
-- if the table still carries the stashed import template (saveTable.rawImport)
|
||||
-- GenSave reproduces every unmodeled region from it; otherwise those regions
|
||||
-- are zero-filled. gameVersion selects the crosswalk tables exactly as in
|
||||
-- importSav. On failure returns nil + a message (never raises).
|
||||
function SaveConvert.exportSav(saveTable, gameVersion)
|
||||
function SaveConvert.exportSav(saveTable, gameVersion, cartImage)
|
||||
if type(saveTable) ~= "table" then
|
||||
return nil, "expected a save table"
|
||||
end
|
||||
local gen2Name = gen2CartName(gameVersion)
|
||||
if gen2Name then
|
||||
return nil, gen2Name .. " uses a Gen 2 cart save; exporting one is not supported yet."
|
||||
local supported, unsupportedWhy = SaveConvert.exportSupported(gameVersion)
|
||||
if not supported then return nil, unsupportedWhy end
|
||||
-- Gen 2 has its own SRAM and its own codec, and needs no Gen 1 crosswalks.
|
||||
if Gen2Save.layoutFor(gameVersion) then
|
||||
return Gen2Save.encode(saveTable, gameVersion, cartImage,
|
||||
ensureGen2Data(gameVersion))
|
||||
end
|
||||
local data, derr = ensureData(gameVersion)
|
||||
if not data then return nil, derr end
|
||||
|
||||
@@ -451,12 +451,13 @@ function H.CutDownTreeOrGrass(ctx)
|
||||
end
|
||||
|
||||
-- DisappearWhirlpool is CutDownTreeOrGrass with PlayWhirlpoolSound in place of
|
||||
-- OWCutAnimation: the same block write, the same redraw, then the surf wash the
|
||||
-- port owns as World:playWhirlpoolSound. #1717
|
||||
-- OWCutAnimation, and the block only reaches the screen after the sound
|
||||
-- -- engine/events/overworld.asm:1157-1164 (#1717, #1862)
|
||||
function H.DisappearWhirlpool(ctx)
|
||||
local ret = H.CutDownTreeOrGrass(ctx)
|
||||
call(ctx, "playWhirlpoolSound")
|
||||
return ret
|
||||
local index = ctx and ctx.cutWhirlpoolBlockIndex
|
||||
local blockId = ctx and ctx.cutWhirlpoolReplacement
|
||||
call(ctx, "playWhirlpoolSound", index, blockId)
|
||||
return nil
|
||||
end
|
||||
|
||||
-- BlindingFlash sets STATUSFLAGS_FLASH_F and reloads the palettes. Setting
|
||||
|
||||
@@ -152,6 +152,12 @@ local function answer(vm, value)
|
||||
vm.scriptVar = value or 0
|
||||
end
|
||||
|
||||
-- WaitSFX (pokegold home/audio.asm); a test stub that calls a handler off
|
||||
-- the coroutine has no sfx to drain.
|
||||
local function drainSfx()
|
||||
if coroutine.running() then coroutine.yield({ kind = "waitsfx" }) end
|
||||
end
|
||||
|
||||
-- Every routine that ends `call GetPokemonName / jp
|
||||
-- CopyPokemonName_Buffer1_Buffer3` puts a name where the next writetext's
|
||||
-- {STRBUF} will find it.
|
||||
@@ -452,8 +458,10 @@ H.BugContestJudging = function(vm)
|
||||
local what = (h.monName and h.monName(entry.species)) or entry.species
|
||||
or ""
|
||||
vm:showRaw(Strings(row.page, who, what))
|
||||
-- pokegold engine/events/bug_contest/judging.asm:29-32
|
||||
drainSfx()
|
||||
if h.playSfxNamed then h.playSfxNamed(row.sfx) end
|
||||
vm:showRaw(Strings(row.score, entry.score or 0))
|
||||
vm:showRaw(Strings(row.score, entry.score or 0), nil, nil, true)
|
||||
end
|
||||
end
|
||||
answer(vm, place)
|
||||
@@ -2033,12 +2041,10 @@ H.ProfOaksPCBoot = function(vm)
|
||||
vm:showRaw(Strings(OAK_PC_TEXT.counts, seen, caught))
|
||||
local rating = findOakRating(caught)
|
||||
local h = hooks(vm)
|
||||
-- pokegold engine/events/prof_oaks_pc.asm:18-20 PlaySFX / JoyWaitAorB / WaitSFX
|
||||
drainSfx()
|
||||
if h.playSfxNamed then h.playSfxNamed(rating.sfx) end
|
||||
vm:showRaw(Strings(rating.text))
|
||||
-- `call PlaySFX / call JoyWaitAorB / call WaitSFX`: the fanfare is left
|
||||
-- playing under the rating text, and the caller (OaksLab's own script)
|
||||
-- waits it out before closing the box.
|
||||
coroutine.yield({ kind = "waitsfx" })
|
||||
vm:showRaw(Strings(rating.text), nil, nil, true)
|
||||
end
|
||||
|
||||
-- The whose-PC menu's PROF.OAK's PC row (src/ui/gen2/CenterPcMenu.lua) runs
|
||||
|
||||
+10
-6
@@ -700,8 +700,10 @@ local function runCmd(self, cmd, op)
|
||||
-- GetPocketName fills from ItemPocketNames: KEY ITEMs, BALLs and TMs
|
||||
-- name their own pocket, not the ITEM one (data/text/common_2.asm
|
||||
-- :1351, data/items/pocket_names.asm:10-13).
|
||||
-- Script_specialsound's WaitSFX (scripting.asm:485): the box holds
|
||||
-- its press until the jingle ends.
|
||||
self:showRaw(Strings("{PLAYER} put the\n%s in\nthe %s.",
|
||||
name, self:pocketName(item)))
|
||||
name, self:pocketName(item)), nil, nil, true)
|
||||
else
|
||||
self:showRaw(Strings("The %s\nis full…", self:pocketName(item)))
|
||||
end
|
||||
@@ -1510,8 +1512,8 @@ local function runCmd(self, cmd, op)
|
||||
self.playSoundFn(SFX_ITEM)
|
||||
end
|
||||
-- FruitTreeScript's tail is `specialsound / itemnotify` with NOTHING
|
||||
-- between them (engine/events/fruit_trees.asm:23-24), and Script_specialsound
|
||||
-- is a bare PlaySFX -- it does not wait either (scripting.asm:476-483).
|
||||
-- between them (engine/events/fruit_trees.asm:23-24);
|
||||
-- Script_specialsound ends PlaySFX / WaitSFX (scripting.asm:484-485)
|
||||
-- The port used to park here on a `waitsfx`, which is the same seam
|
||||
-- GiveItemScript's did: this port's box takes its own button and pops on
|
||||
-- it, so the park ran with an EMPTY state stack and the bare overworld
|
||||
@@ -1523,7 +1525,7 @@ local function runCmd(self, cmd, op)
|
||||
-- noun still comes from ItemPocketNames rather than from a third copy of
|
||||
-- the literal (data/items/pocket_names.asm:10-13).
|
||||
self:showRaw(Strings("{PLAYER} put the\n%s in\nthe %s.",
|
||||
name, self:pocketName(item)))
|
||||
name, self:pocketName(item)), nil, nil, true)
|
||||
return "end"
|
||||
elseif op == "describedecoration" then
|
||||
-- `describedecoration byte` picks one of five DECODESC_* arms
|
||||
@@ -2427,7 +2429,7 @@ end
|
||||
-- the port's world does not tick while a box is on the stack (Game2:update
|
||||
-- stops at the top state), so the only clock that can count it is the box's;
|
||||
-- World:showText is where it lands.
|
||||
function Vm:showRaw(body, stay, hold)
|
||||
function Vm:showRaw(body, stay, hold, sfxWait)
|
||||
if not body or body == "" then body = "..." end
|
||||
if self.stringBuffer and self.stringBuffer ~= "" then
|
||||
body = body:gsub("{STRBUF}", self.stringBuffer)
|
||||
@@ -2438,6 +2440,8 @@ function Vm:showRaw(body, stay, hold)
|
||||
text = body,
|
||||
stay = (stay or self:textStays()) and true or false,
|
||||
hold = hold,
|
||||
-- pokegold engine/overworld/scripting.asm:485 WaitSFX
|
||||
sfxWait = sfxWait and true or nil,
|
||||
})
|
||||
end
|
||||
end
|
||||
@@ -2646,7 +2650,7 @@ function Vm:resume(resumeValue)
|
||||
if req and req.kind == "text" and self.showTextFn then
|
||||
self.showTextFn(req.text, function()
|
||||
self:resume()
|
||||
end, req.stay, req.hold)
|
||||
end, req.stay, req.hold, req.sfxWait)
|
||||
elseif req and req.kind == "wait" then
|
||||
self.waitLeft = req.frames or 0
|
||||
elseif req and req.kind == "waitbutton" then
|
||||
|
||||
+3
-1
@@ -360,6 +360,8 @@ local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker)
|
||||
-- .useItem_partyMenu jumps back to StartMenu_Item once UseItem
|
||||
-- returns, cursor still on the candy (start_sub_menus.asm) -- so
|
||||
-- mashing A burns through a stack of them (#796)
|
||||
-- RareCandyText carries sound_get_item_1
|
||||
-- (engine/menus/party_menu.asm:289-293)
|
||||
showMessages(game, payload, function()
|
||||
local StatBox = require("src.battle.BattleState").StatBox
|
||||
game.stack:push(StatBox.new(game, target, function()
|
||||
@@ -400,7 +402,7 @@ local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker)
|
||||
end
|
||||
nextStep()
|
||||
end))
|
||||
end)
|
||||
end, TextBox.soundOpts(game, "Get_Item1"))
|
||||
return
|
||||
end
|
||||
-- HP medicine: fill the bar in the still-open picker first, then print
|
||||
|
||||
+85
-41
@@ -13,9 +13,21 @@ local Strings = require("src.core.Strings")
|
||||
|
||||
local BoxMenu = {}
|
||||
|
||||
local function monLabel(game, mon)
|
||||
-- PrintListMenuEntries prints the nickname at hlcoord 6,4 and PrintLevel
|
||||
-- one row down, 8 columns right (home/list_menu.asm:364-365, 459-461)
|
||||
local function monRow(game, mon, value)
|
||||
local def = game.data.pokemon[mon.species]
|
||||
return Strings("%s :L%d", mon.nickname or def.name, mon.level)
|
||||
return {
|
||||
label = mon.nickname or def.name,
|
||||
sub = Strings(":L%d", mon.level),
|
||||
value = value,
|
||||
}
|
||||
end
|
||||
|
||||
-- the $ff terminator's row (home/list_menu.asm:371-372, 523-528)
|
||||
local function withCancel(items)
|
||||
items[#items + 1] = { cancel = true, label = Strings("CANCEL") }
|
||||
return items
|
||||
end
|
||||
|
||||
local function monName(game, mon)
|
||||
@@ -61,14 +73,15 @@ local function withdraw(game)
|
||||
return
|
||||
end
|
||||
local items = {}
|
||||
for i, mon in ipairs(box) do
|
||||
table.insert(items, { label = monLabel(game, mon), value = i })
|
||||
end
|
||||
game.stack:push(ListMenu.new(game,
|
||||
Strings("BOX %d (WITHDRAW)", game.save.currentBox), items, {
|
||||
for i, mon in ipairs(box) do table.insert(items, monRow(game, mon, i)) end
|
||||
game.stack:push(ListMenu.new(game, nil, withCancel(items), {
|
||||
noSound = true, -- PCMainMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
|
||||
kind = "pc_box_withdraw",
|
||||
-- DisplayMonListMenu draws LIST_MENU_BOX over the PC menu, which stays
|
||||
-- visible around it (home/list_menu.asm:29-31)
|
||||
itemBox = true,
|
||||
onChoose = function(item, list)
|
||||
if item.cancel then list:close() return end
|
||||
local mon = box[item.value]
|
||||
if not mon then return end
|
||||
monSubmenu(game, "WITHDRAW", mon, function()
|
||||
@@ -111,12 +124,14 @@ local function deposit(game)
|
||||
end
|
||||
local items = {}
|
||||
for i, mon in ipairs(game.save.party) do
|
||||
table.insert(items, { label = monLabel(game, mon), value = i })
|
||||
table.insert(items, monRow(game, mon, i))
|
||||
end
|
||||
game.stack:push(ListMenu.new(game, "PARTY (DEPOSIT)", items, {
|
||||
game.stack:push(ListMenu.new(game, nil, withCancel(items), {
|
||||
noSound = true, -- PCMainMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
|
||||
kind = "pc_box_deposit",
|
||||
itemBox = true,
|
||||
onChoose = function(item, list)
|
||||
if item.cancel then list:close() return end
|
||||
local mon = game.save.party[item.value]
|
||||
if not mon then return end
|
||||
local Follower = require("src.world.PikachuFollower")
|
||||
@@ -163,14 +178,13 @@ local function release(game)
|
||||
return
|
||||
end
|
||||
local items = {}
|
||||
for i, mon in ipairs(box) do
|
||||
table.insert(items, { label = monLabel(game, mon), value = i })
|
||||
end
|
||||
game.stack:push(ListMenu.new(game,
|
||||
Strings("BOX %d (RELEASE)", game.save.currentBox), items, {
|
||||
for i, mon in ipairs(box) do table.insert(items, monRow(game, mon, i)) end
|
||||
game.stack:push(ListMenu.new(game, nil, withCancel(items), {
|
||||
noSound = true, -- PCMainMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
|
||||
kind = "pc_box_release",
|
||||
onChoose = function(_, list)
|
||||
itemBox = true,
|
||||
onChoose = function(item, list)
|
||||
if item.cancel then list:close() return end
|
||||
local mon = box[list.index]
|
||||
if not mon then return end
|
||||
local name = monName(game, mon)
|
||||
@@ -202,37 +216,67 @@ local function release(game)
|
||||
}))
|
||||
end
|
||||
|
||||
local function changeBox(game)
|
||||
-- DisplayChangeBoxMenu: engine/menus/save.asm:437-506
|
||||
local function changeBoxMenu(game)
|
||||
local boxes = Boxes.ensure(game.save)
|
||||
local items = {}
|
||||
for i = 1, Boxes.COUNT do
|
||||
local mark = i == game.save.currentBox and "*" or " "
|
||||
table.insert(items, {
|
||||
label = Strings("%sBOX %2d", mark, i),
|
||||
right = ("%d/%d"):format(#boxes[i], Boxes.CAPACITY),
|
||||
value = i,
|
||||
})
|
||||
items[i] = {
|
||||
label = Strings("BOX%2d", i),
|
||||
onSelect = function()
|
||||
game.save.currentBox = i
|
||||
if game.writeSave then game:writeSave() end
|
||||
-- ChangeBox (engine/menus/save.asm) rings SFX_SAVE after SaveGameData (#1044)
|
||||
require("src.core.Sound").play(game.data, "Save")
|
||||
end,
|
||||
}
|
||||
end
|
||||
game.stack:push(ListMenu.new(game, "CHANGE BOX", items, {
|
||||
noSound = true, -- PCMainMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
|
||||
kind = "pc_box_change",
|
||||
onChoose = function(item, list)
|
||||
-- the original asks BEFORE switching ("When you change a #MON
|
||||
-- BOX, data will be saved. OK?"); declining aborts the change
|
||||
game.stack:push(TextBox.new(game,
|
||||
Strings("When you change a\nPOKéMON BOX, data\nwill be saved. OK?"), nil, {
|
||||
noSound = true,
|
||||
choice = function(yes)
|
||||
if not yes then return end
|
||||
game.save.currentBox = item.value
|
||||
if game.writeSave then game:writeSave() end
|
||||
-- ChangeBox (engine/menus/save.asm) rings SFX_SAVE after SaveGameData (#1044)
|
||||
require("src.core.Sound").play(game.data, "Save")
|
||||
list:close()
|
||||
end,
|
||||
}))
|
||||
local menu = Menu.new(game, items, {
|
||||
tx = 11, ty = 0, tw = 9, th = 14, rowStep = 1, itemY = 1, noSound = true,
|
||||
})
|
||||
menu.kind = "pc_box_change" -- screen.render_visible identity
|
||||
menu.index = math.max(1, math.min(Boxes.COUNT, game.save.currentBox or 1))
|
||||
local t = game.data.text
|
||||
local baseDraw = menu.draw
|
||||
function menu:draw()
|
||||
-- ChooseABoxText goes to the standard box, over BillsPCMenu's "What?"
|
||||
Font.drawBox(0, 12, 20, 6)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
local y = 112
|
||||
for line in ((t._ChooseABoxText or Strings("Choose a\nPOKéMON BOX."))
|
||||
.. "\n"):gmatch("([^\n]*)\n") do
|
||||
Font.draw(line, 8, y)
|
||||
y = y + 16
|
||||
end
|
||||
Font.drawBox(0, 0, 11, 4)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw(Strings("BOX No."), 8, 16)
|
||||
local n = game.save.currentBox or 1
|
||||
Font.draw(tostring(n), n >= 10 and 64 or 72, 16)
|
||||
baseDraw(self)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
for i = 1, Boxes.COUNT do
|
||||
if #boxes[i] > 0 then ListMenu.drawBall(148, i * 8 + 4) end
|
||||
end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
game.stack:push(menu)
|
||||
end
|
||||
|
||||
-- ChangeBox (engine/menus/save.asm:358-368) asks before showing the menu
|
||||
-- and returns to the PC menu on No.
|
||||
local function changeBox(game)
|
||||
local t = game.data.text
|
||||
local ask = TextBox.new(game, t._WhenYouChangeBoxText
|
||||
or Strings("When you change a\nPOKéMON BOX, data\vwill be saved.\fIs that okay?"),
|
||||
nil, {
|
||||
noSound = true,
|
||||
choice = function(yes)
|
||||
if yes then changeBoxMenu(game) end
|
||||
end,
|
||||
}))
|
||||
})
|
||||
ask.kind = "pc_box_change"
|
||||
game.stack:push(ask)
|
||||
end
|
||||
|
||||
-- bills_pc.asm BillsPCMenu chrome: What? text box + BOX No. overlay
|
||||
|
||||
+37
-5
@@ -87,6 +87,15 @@ local function frameSheet(game)
|
||||
return frameCache[path]
|
||||
end
|
||||
|
||||
-- one tile of the sheet, for the CONTENTS screen's divider column
|
||||
-- (engine/menus/pokedex.asm:350, DrawPokedexVerticalLine)
|
||||
function DexEntryMenu.tile(game, code, tx, ty)
|
||||
local frame = frameSheet(game)
|
||||
if not (frame and code) then return end
|
||||
local quad = frame.quads[code - 0x60]
|
||||
if quad then love.graphics.draw(frame.img, quad, tx * 8, ty * 8) end
|
||||
end
|
||||
|
||||
-- engine/menus/pokedex.asm:601
|
||||
local DIVIDER = {
|
||||
0x68, 0x69, 0x6B, 0x69, 0x6B, 0x69, 0x6B, 0x69, 0x6B, 0x6B,
|
||||
@@ -106,14 +115,28 @@ function DexEntryMenu.new(game, speciesOrOpts, onDone)
|
||||
self.sprite = ok and img or nil
|
||||
self.spriteTrueColor = self.sprite and trueColor or false
|
||||
self.page = 1
|
||||
self.blink = 0
|
||||
local pages = descPages(game, self.def, forceOwned)
|
||||
self.pageCount = pages and #pages or 1
|
||||
require("src.core.Sound").playCry(game.data, species)
|
||||
-- engine/menus/pokedex.asm:500-506, home/pokemon.asm:145-148
|
||||
self.crySrc = require("src.core.Sound").playCry(game.data, species)
|
||||
return self
|
||||
end
|
||||
|
||||
-- home/pokemon.asm:148 (jp WaitForSoundToFinish)
|
||||
function DexEntryMenu:crying()
|
||||
local src = self.crySrc
|
||||
if not src then return false end
|
||||
local ok, playing = pcall(src.isPlaying, src)
|
||||
if ok and playing then return true end
|
||||
self.crySrc = nil
|
||||
return false
|
||||
end
|
||||
|
||||
function DexEntryMenu:update(dt)
|
||||
local input = self.game.input
|
||||
self.blink = ((self.blink or 0) + 1) % 60
|
||||
if self:crying() then return end
|
||||
if input:wasPressed("a") or input:wasPressed("b") then
|
||||
-- home/text.asm:245
|
||||
if self.page < (self.pageCount or 1) then
|
||||
@@ -127,15 +150,18 @@ end
|
||||
|
||||
function DexEntryMenu:draw()
|
||||
DexEntryMenu.render(self.game, self.def, self.sprite, self.forceOwned,
|
||||
self.spriteTrueColor, self.page)
|
||||
self.spriteTrueColor, self.page,
|
||||
{ crying = self:crying(),
|
||||
arrow = (self.blink or 0) < 30 })
|
||||
end
|
||||
|
||||
-- Static entry-page renderer, shared with the printer stand-in
|
||||
-- (src/core/Printer.lua renders the same page into a PNG the way
|
||||
-- PrintPokedexEntry rendered it to the Game Boy Printer).
|
||||
-- engine/menus/pokedex.asm:399
|
||||
function DexEntryMenu.render(game, def, sprite, forceOwned, trueColor, page)
|
||||
function DexEntryMenu.render(game, def, sprite, forceOwned, trueColor, page, state)
|
||||
page = page or 1
|
||||
state = state or {}
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
||||
local frame = frameSheet(game)
|
||||
@@ -185,6 +211,12 @@ function DexEntryMenu.render(game, def, sprite, forceOwned, trueColor, page)
|
||||
Font.draw(Strings("No.") .. ("%0" .. digits .. "d"):format(def.dex or 0),
|
||||
16, 64)
|
||||
local owned = ownedFor(game, def, forceOwned)
|
||||
-- engine/menus/pokedex.asm:516: everything below the divider waits on the
|
||||
-- cry the line above it started
|
||||
if state.crying then
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
return
|
||||
end
|
||||
-- engine/menus/pokedex.asm:449, numbers only once owned
|
||||
if owned and e.heightFt then
|
||||
if e.heightM then
|
||||
@@ -202,8 +234,8 @@ function DexEntryMenu.render(game, def, sprite, forceOwned, trueColor, page)
|
||||
for i, line in ipairs(lines) do
|
||||
Font.draw(line, 8, 72 + i * 16)
|
||||
end
|
||||
-- home/text.asm:245
|
||||
if page < #pages then
|
||||
-- home/text.asm:245 places the '▼' and home/joypad2.asm:55-83 blinks it
|
||||
if page < #pages and state.arrow ~= false then
|
||||
Font.drawCode(Theme.moreArrow, 144, 128)
|
||||
end
|
||||
else
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
-- Whether a Pokémon's level is printed on a screen that would normally
|
||||
-- print it (RFC 0019).
|
||||
--
|
||||
-- Every Gen 1 screen that shows a level does it with the same pokered rule
|
||||
-- -- home/pokemon.asm:335-345 PrintLevel: the <LV> tile, then the digits
|
||||
-- left-aligned after it, with a level of 100 writing its third digit back
|
||||
-- over the tile -- and each screen hand-rolls that rule against its own
|
||||
-- coordinates. This module does not touch any of that. It answers one
|
||||
-- question, in one place, so that a mode which wants the number off does
|
||||
-- not have to know four call sites and a glyph code.
|
||||
--
|
||||
-- Default is true, and `Runtime.wantsHook` is checked first, so a build
|
||||
-- with no mod wrapping the hook prints exactly what it always did and pays
|
||||
-- nothing for the seam on a per-frame draw path.
|
||||
--
|
||||
-- `where` names the surface rather than the widget, because the number
|
||||
-- means different things on different screens: on the battle HUD an
|
||||
-- opponent's level is information about them, on the party and status
|
||||
-- screens your own level is information about you. A mode can hide one and
|
||||
-- keep the other.
|
||||
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
|
||||
local LevelDisplay = {}
|
||||
|
||||
-- where: "battle.enemy" | "battle.player" | "party" | "summary"
|
||||
function LevelDisplay.visible(mon, where, game)
|
||||
if not Runtime.wantsHook("pokemon.level_visible") then return true end
|
||||
return Runtime.call("pokemon.level_visible", function() return true end,
|
||||
mon, { where = where, game = game }) ~= false
|
||||
end
|
||||
|
||||
return LevelDisplay
|
||||
+41
-26
@@ -120,8 +120,9 @@ function ListMenu.new(game, title, items, opts)
|
||||
-- so their lists opt out of the A/B beep the same way Menu's noSound does
|
||||
self.noSound = opts.noSound or false
|
||||
-- the bag's item list: a partial box the map stays visible around, not a
|
||||
-- screen of its own (home/list_menu.asm:29-31)
|
||||
self.itemBox = opts.itemBox or false
|
||||
-- screen of its own (home/list_menu.asm:29-31). Every DisplayListMenuID
|
||||
-- caller gets the same box, the PC item lists included (#1845).
|
||||
self.itemBox = opts.itemBox or opts.messageBox or false
|
||||
if self.itemBox then
|
||||
self.isOpaque = false
|
||||
-- keep RunDefaultPaletteCommand's last palette: ItemMenuLoop never sets
|
||||
@@ -256,6 +257,34 @@ function ListMenu:close()
|
||||
if top == self then self.game.stack:pop() end
|
||||
end
|
||||
|
||||
-- standard bottom text box (PrintText); long prompts wrap and keep
|
||||
-- their last two lines, like the GB's scrolled box (#115/#174)
|
||||
local function drawMessageBox(self)
|
||||
Font.drawBox(0, 12, 20, 6)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
if not self.footer then return end
|
||||
local flat = {}
|
||||
for _, page in ipairs(require("src.render.TextBox").paginate(self.footer)) do
|
||||
for _, line in ipairs(page) do flat[#flat + 1] = line end
|
||||
end
|
||||
local y = 112
|
||||
for i = math.max(1, #flat - 1), #flat do
|
||||
Font.draw(flat[i], 8, y)
|
||||
y = y + 16
|
||||
end
|
||||
end
|
||||
|
||||
-- the Pokédex owned-ball marker tile, also the CHANGE BOX screen's
|
||||
-- PokeballTileGraphics marker (engine/menus/save.asm:495-499)
|
||||
function ListMenu.drawBall(x, y)
|
||||
local r, g, b, a = love.graphics.getColor()
|
||||
love.graphics.circle("fill", x, y, 3.5)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.rectangle("fill", x - 3.5, y - 0.5, 7, 1)
|
||||
love.graphics.circle("fill", x, y, 1.2)
|
||||
love.graphics.setColor(r, g, b, a)
|
||||
end
|
||||
|
||||
-- PrintListMenuEntries, minus the price column StartMenu_Item never asks for
|
||||
-- (wPrintItemPrices = 0, engine/menus/start_sub_menus.asm)
|
||||
function ListMenu:drawItemBox()
|
||||
@@ -274,7 +303,10 @@ function ListMenu:drawItemBox()
|
||||
if item.cancel then sawCancel = true end
|
||||
local y = ITEM_TOP_Y + (row - 1) * 16
|
||||
Font.draw(item.label, ITEM_NAME_X, y)
|
||||
if item.right then
|
||||
if item.sub then
|
||||
-- PrintLevel, one row down and 8 columns right (home/list_menu.asm:459-461)
|
||||
Font.draw(item.sub, ITEM_QTY_X, y + 8)
|
||||
elseif item.right then
|
||||
-- '×' at column 14, PrintNumber's two right-aligned digits after it
|
||||
-- (home/list_menu.asm:479-490)
|
||||
local count = item.right:sub(2)
|
||||
@@ -294,6 +326,9 @@ function ListMenu:drawItemBox()
|
||||
if shown == self.rows and not sawCancel then
|
||||
Font.drawCode(Theme.moreArrow, ITEM_MORE_X, ITEM_MORE_Y)
|
||||
end
|
||||
-- players_pc.asm:97/151/205 PrintText the prompt before DisplayListMenuID,
|
||||
-- so the bottom box sits under the list from the first frame
|
||||
if self.messageBox or self.footer then drawMessageBox(self) end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
@@ -326,13 +361,7 @@ function ListMenu:draw()
|
||||
-- one blank glyph after the name, measured in glyph advances rather
|
||||
-- than bytes: NIDORAN♂/♀ carry a multi-byte charmap entry, so
|
||||
-- `#item.label` overcounted by 2 and pushed their ball 16px right (#285)
|
||||
local bx = 16 + Font.width(label) + 8 + 3
|
||||
local by = y + 3
|
||||
love.graphics.circle("fill", bx, by, 3.5)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.rectangle("fill", bx - 3.5, by - 0.5, 7, 1)
|
||||
love.graphics.circle("fill", bx, by, 1.2)
|
||||
love.graphics.setColor(unpack(textColor))
|
||||
ListMenu.drawBall(16 + Font.width(label) + 8 + 3, y + 3)
|
||||
end
|
||||
if item.right then
|
||||
Font.draw(item.right, 160 - 8 - Font.width(item.right), y)
|
||||
@@ -360,22 +389,8 @@ function ListMenu:draw()
|
||||
local money = ("¥%d"):format(self.money and self.money() or 0)
|
||||
Font.draw(money, 152 - Font.width(money), 8)
|
||||
end
|
||||
if self.dialogue or (self.messageBox and self.footer) then
|
||||
-- standard bottom text box (PrintText); long prompts wrap and keep
|
||||
-- their last two lines, like the GB's scrolled box (#115/#174)
|
||||
Font.drawBox(0, 12, 20, 6)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
if self.footer then
|
||||
local flat = {}
|
||||
for _, page in ipairs(require("src.render.TextBox").paginate(self.footer)) do
|
||||
for _, line in ipairs(page) do flat[#flat + 1] = line end
|
||||
end
|
||||
local y = 112
|
||||
for i = math.max(1, #flat - 1), #flat do
|
||||
Font.draw(flat[i], 8, y)
|
||||
y = y + 16
|
||||
end
|
||||
end
|
||||
if self.dialogue then
|
||||
drawMessageBox(self)
|
||||
elseif self.footer then
|
||||
-- bare footer (bag money line, etc.)
|
||||
local flat = {}
|
||||
|
||||
@@ -120,14 +120,17 @@ function MoveLearnMenu:finish(learned)
|
||||
local opts
|
||||
if learned then
|
||||
-- pokered pages this as four texts in a row; _ForgotAndText carries
|
||||
-- the "And..." tail
|
||||
-- the "And..." tail. Each text_pause holds the box, and the first one
|
||||
-- is followed by SFX_SWAP (engine/pokemon/learn_move.asm:208-222).
|
||||
msg = romText(game.data, "_OneTwoAndText", "1, 2 and...")
|
||||
.. TextBox.PAUSE
|
||||
.. romText(game.data, "_PoofText", " Poof!")
|
||||
.. TextBox.PAUSE
|
||||
.. romText(game.data, "_ForgotAndText",
|
||||
"\f%s forgot\n%s!\fAnd...", name, self.forgot)
|
||||
.. "\f" .. romText(game.data, "_LearnedMove1Text",
|
||||
"%s learned\n%s!", name, mdef.name)
|
||||
opts = { auto = { sound = function()
|
||||
opts = { pauseSounds = { "Swap" }, auto = { sound = function()
|
||||
return require("src.core.Sound").play(game.data, self.learnedSound)
|
||||
end, wait = true } }
|
||||
else
|
||||
|
||||
+27
-6
@@ -11,6 +11,7 @@
|
||||
|
||||
local Assets = require("src.render.Assets")
|
||||
local Font = require("src.render.Font")
|
||||
local LevelDisplay = require("src.ui.LevelDisplay")
|
||||
local Logger = require("src.core.Logger")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local Screens = require("src.ui.Screens")
|
||||
@@ -71,6 +72,12 @@ function PartyMenu:sgbPalettes(game)
|
||||
return zones
|
||||
end
|
||||
|
||||
-- data/moves/field_moves.asm: leftmost tile per field move name
|
||||
local FIELD_MOVE_X = {
|
||||
cut = 12, fly = 12, surf = 12, flash = 12, DIG = 12,
|
||||
strength = 10, TELEPORT = 10, softboiled = 8,
|
||||
}
|
||||
|
||||
local function sameItems(_, items) return items end
|
||||
|
||||
local function followerUnavailable(game, mon)
|
||||
@@ -706,6 +713,9 @@ function PartyMenu:update(dt)
|
||||
-- (text_box.asm .donePrintingNames). #768
|
||||
items[#items + 1] = { label = Strings("STATS"), action = "stats" }
|
||||
items[#items + 1] = { label = Strings("SWITCH"), action = "switch" }
|
||||
-- CANCEL closes the whole party menu (start_sub_menus.asm:71-75
|
||||
-- .exitMenu), where B returns to the party list. #1833
|
||||
items[#items + 1] = { label = Strings("CANCEL"), action = "cancel" }
|
||||
end
|
||||
local ctx = { battle = self.battle, overworld = ow }
|
||||
local hooked = Runtime.call("ui.party.submenu", sameItems,
|
||||
@@ -785,7 +795,11 @@ function PartyMenu:draw()
|
||||
-- level at column 13 (<LV> tile + digits, PrintLevel) AND the
|
||||
-- status/FNT text at column 17 (PrintStatusCondition), like the
|
||||
-- original rows -- statused mons keep their level display
|
||||
if mon.level < 100 then
|
||||
if not LevelDisplay.visible(mon, "party", self.game) then -- RFC 0019
|
||||
-- the level column is simply empty; the status/FNT column at 17 is a
|
||||
-- separate field and still prints, exactly as it does for a mon whose
|
||||
-- level is on screen
|
||||
elseif mon.level < 100 then
|
||||
HudTiles.tile(0x6E, 104, y) -- <LV>
|
||||
Font.draw(tostring(mon.level), 112, y)
|
||||
else
|
||||
@@ -873,12 +887,19 @@ function PartyMenu:draw()
|
||||
end
|
||||
if self.submenu then
|
||||
local n = #self.subItems
|
||||
Font.drawBox(9, 17 - n * 2 - 1, 11, n * 2 + 1)
|
||||
local y0 = (17 - n * 2) * 8
|
||||
for si, entry in ipairs(self.subItems) do
|
||||
Font.draw(entry.label, 88, y0 + (si - 1) * 16)
|
||||
-- engine/menus/text_box.asm:397-440, data/text_boxes.asm:33 #1819
|
||||
local lx = 12
|
||||
for _, entry in ipairs(self.subItems) do
|
||||
local mx = FIELD_MOVE_X[entry.move or entry.action]
|
||||
if mx and mx < lx then lx = mx end
|
||||
end
|
||||
Font.drawCode(Theme.cursor, 80, y0 + (self.subIndex - 1) * 16)
|
||||
local top = n > 3 and math.max(0, 16 - n * 2) or 11
|
||||
Font.drawBox(lx - 1, top, 21 - lx, 18 - top)
|
||||
local y0 = (18 - n * 2) * 8
|
||||
for si, entry in ipairs(self.subItems) do
|
||||
Font.draw(entry.label, (lx + 1) * 8, y0 + (si - 1) * 16)
|
||||
end
|
||||
Font.drawCode(Theme.cursor, lx * 8, y0 + (self.subIndex - 1) * 16)
|
||||
end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
+46
-3
@@ -11,6 +11,8 @@ local ListMenu = require("src.ui.ListMenu")
|
||||
local Menu = require("src.ui.Menu")
|
||||
local Sound = require("src.core.Sound")
|
||||
local Strings = require("src.core.Strings")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local romText = require("src.core.RomText")
|
||||
|
||||
local PlayerPC = {}
|
||||
|
||||
@@ -36,9 +38,20 @@ local function buildItems(game, store, order)
|
||||
})
|
||||
end
|
||||
end
|
||||
-- the $ff terminator's row (home/list_menu.asm:371-372, 523-528)
|
||||
items[#items + 1] = { cancel = true, label = Strings("CANCEL") }
|
||||
return items
|
||||
end
|
||||
|
||||
-- A on CANCEL leaves the list exactly like B (home/list_menu.asm:105-110)
|
||||
local function leftOnCancel(item, list)
|
||||
if item and item.cancel then
|
||||
list:close()
|
||||
return true
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- Ask "How many?" (DepositHowManyText/WithdrawHowManyText →
|
||||
-- DisplayChooseQuantityMenu) capped at the stack count. Key items and
|
||||
-- HMs always move one, with no prompt (IsKeyItem in players_pc.asm).
|
||||
@@ -76,11 +89,21 @@ end
|
||||
|
||||
local function withdraw(game)
|
||||
local pc = game.save.pcItems
|
||||
game.stack:push(ListMenu.new(game, "WITHDRAW ITEM", buildItems(game, pc), {
|
||||
-- players_pc.asm:144-149: an empty list is never opened
|
||||
if next(pc) == nil then
|
||||
game.stack:push(TextBox.new(game, romText(game.data, "_NothingStoredText",
|
||||
"There is nothing\nstored."), nil, { noSound = true }))
|
||||
return
|
||||
end
|
||||
game.stack:push(ListMenu.new(game, nil, buildItems(game, pc), {
|
||||
kind = "pc_item_withdraw",
|
||||
messageBox = true,
|
||||
-- players_pc.asm:151-152 WhatToWithdrawText, printed before the list
|
||||
footer = romText(game.data, "_WhatToWithdrawText",
|
||||
"What do you want\nto withdraw?"),
|
||||
noSound = true, -- PlayerPCMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
|
||||
onChoose = function(item, list)
|
||||
if leftOnCancel(item, list) then return end
|
||||
askQuantity(game, list, pc[item.value] or 1, item.value, function(qty)
|
||||
local Bag = require("src.inventory.Bag")
|
||||
if not Bag.add(game.save, item.value, qty, game.data) then
|
||||
@@ -112,11 +135,21 @@ local function deposit(game)
|
||||
local Bag = require("src.inventory.Bag")
|
||||
-- engine/menus/players_pc.asm:99 wListPointer = wNumBagItems, so deposit order == bag order
|
||||
local order = Bag.order(game.save, game.data)
|
||||
game.stack:push(ListMenu.new(game, "DEPOSIT ITEM", buildItems(game, inv, order), {
|
||||
-- players_pc.asm:90-95: an empty bag never reaches the list
|
||||
if #order == 0 then
|
||||
game.stack:push(TextBox.new(game, romText(game.data, "_NothingToDepositText",
|
||||
"You have nothing\nto deposit."), nil, { noSound = true }))
|
||||
return
|
||||
end
|
||||
game.stack:push(ListMenu.new(game, nil, buildItems(game, inv, order), {
|
||||
kind = "pc_item_deposit",
|
||||
messageBox = true,
|
||||
-- players_pc.asm:97-98 WhatToDepositText, printed before the list
|
||||
footer = romText(game.data, "_WhatToDepositText",
|
||||
"What do you want\nto deposit?"),
|
||||
noSound = true, -- PlayerPCMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
|
||||
onChoose = function(item, list)
|
||||
if leftOnCancel(item, list) then return end
|
||||
askQuantity(game, list, inv[item.value] or 1, item.value, function(qty)
|
||||
if pcFull(game, pc, item.value) then
|
||||
list.footer = Strings("No room left to\nstore items.")
|
||||
@@ -134,11 +167,21 @@ end
|
||||
|
||||
local function toss(game)
|
||||
local pc = game.save.pcItems
|
||||
game.stack:push(ListMenu.new(game, "TOSS ITEM", buildItems(game, pc), {
|
||||
-- players_pc.asm:196-201: an empty list is never opened
|
||||
if next(pc) == nil then
|
||||
game.stack:push(TextBox.new(game, romText(game.data, "_NothingStoredText",
|
||||
"There is nothing\nstored."), nil, { noSound = true }))
|
||||
return
|
||||
end
|
||||
game.stack:push(ListMenu.new(game, nil, buildItems(game, pc), {
|
||||
kind = "pc_item_toss",
|
||||
messageBox = true,
|
||||
-- players_pc.asm:205-206 WhatToTossText, printed before the list
|
||||
footer = romText(game.data, "_WhatToTossText",
|
||||
"What do you want\nto toss away?"),
|
||||
noSound = true, -- PlayerPCMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
|
||||
onChoose = function(item, list)
|
||||
if leftOnCancel(item, list) then return end
|
||||
local def = game.data.items[item.value]
|
||||
if (def and def.keyItem) or item.value:find("^HM_") then
|
||||
list.footer = Strings("That's too impor-\ntant to toss!")
|
||||
|
||||
+273
-82
@@ -1,9 +1,38 @@
|
||||
-- Minimal Pokédex: dex-ordered list with seen/owned markers.
|
||||
-- Pokédex CONTENTS screen: engine/menus/pokedex.asm:157-348
|
||||
-- (Yellow: pokeyellow engine/menus/pokedex.asm:263-336).
|
||||
|
||||
local ListMenu = require("src.ui.ListMenu")
|
||||
local Font = require("src.render.Font")
|
||||
local Strings = require("src.core.Strings")
|
||||
local Theme = require("src.ui.Theme")
|
||||
|
||||
local PokedexMenu = {}
|
||||
PokedexMenu.__index = PokedexMenu
|
||||
PokedexMenu.isOpaque = true
|
||||
|
||||
-- engine/menus/pokedex.asm:226 (`ld d, 7`), :21 (top menu item Y 3, X 0)
|
||||
local ROWS = 7
|
||||
local FIRST_ROW_Y = 24
|
||||
local NUM_X, BALL_X, NAME_X, CURSOR_X = 8, 24, 32, 0
|
||||
|
||||
-- engine/menus/pokedex.asm:163-199
|
||||
-- (Yellow: pokeyellow engine/menus/pokedex.asm:266-306)
|
||||
local RED_ROWS = { seen = 2, own = 5, rule = 8, items = 10 }
|
||||
local YELLOW_ROWS = { seen = 1, own = 4, rule = 6, items = 8 }
|
||||
|
||||
-- engine/menus/pokedex.asm:350 (DrawPokedexVerticalLine): column 14, $71
|
||||
-- toggling to $70 down each nine-tile run, both runs restarting on $71
|
||||
local function dividerCodes()
|
||||
local codes = { [0] = 0x71 }
|
||||
for _, top in ipairs({ 1, 9 }) do
|
||||
local code = 0x71
|
||||
for i = 0, 8 do
|
||||
codes[top + i] = code
|
||||
code = code == 0x71 and 0x70 or 0x71
|
||||
end
|
||||
end
|
||||
return codes
|
||||
end
|
||||
local DIVIDER = dividerCodes()
|
||||
|
||||
-- SGB: PalPacket_Pokedex, whole screen
|
||||
function PokedexMenu:sgbPalettes(game)
|
||||
@@ -12,108 +41,270 @@ end
|
||||
|
||||
function PokedexMenu.new(game, opts)
|
||||
opts = opts or {}
|
||||
local self = setmetatable({}, PokedexMenu)
|
||||
self.game = game
|
||||
self.onCancel = opts.onCancel -- B returns to the start menu when opened from it
|
||||
self.index = 1
|
||||
self.scroll = 0
|
||||
self.rowsAt = require("src.core.GameVersion").isYellow()
|
||||
and YELLOW_ROWS or RED_ROWS
|
||||
local dex = game.save.pokedex or { seen = {}, owned = {} }
|
||||
local byDex = {}
|
||||
for species, def in pairs(game.data.pokemon) do
|
||||
if def.dex then byDex[def.dex] = def end
|
||||
end
|
||||
local items = {}
|
||||
local seen, owned = 0, 0
|
||||
-- dex bound and number width come from constants; the fallbacks keep a
|
||||
-- cache imported before those keys existed on the Kanto numbering
|
||||
local constants = game.data.constants or {}
|
||||
local numFmt = ("%%0%dd"):format(constants.dexDigits or 3)
|
||||
for n = 1, constants.dexSize or 151 do
|
||||
local dexSize = constants.dexSize or 151
|
||||
-- engine/menus/pokedex.asm:200-216: the list stops at wDexMaxSeenMon, the
|
||||
-- highest number the player has seen, not at the end of the roster
|
||||
local maxSeen = 0
|
||||
local seen, owned = 0, 0
|
||||
for n = 1, dexSize do
|
||||
local def = byDex[n]
|
||||
if def then
|
||||
local label
|
||||
if dex.owned[def.id] then
|
||||
label = (numFmt .. " %s"):format(n, def.name)
|
||||
owned = owned + 1
|
||||
seen = seen + 1
|
||||
maxSeen = n
|
||||
elseif dex.seen[def.id] then
|
||||
label = (numFmt .. " %s"):format(n, def.name)
|
||||
seen = seen + 1
|
||||
else
|
||||
label = (numFmt .. " -----"):format(n)
|
||||
maxSeen = n
|
||||
end
|
||||
table.insert(items, {
|
||||
label = label,
|
||||
end
|
||||
end
|
||||
local items = {}
|
||||
for n = 1, math.min(dexSize, maxSeen) do
|
||||
local def = byDex[n]
|
||||
if def then
|
||||
local known = dex.owned[def.id] or dex.seen[def.id]
|
||||
-- engine/menus/pokedex.asm:265 (.dashedLine) for anything unseen
|
||||
local name = known and def.name or "----------"
|
||||
items[#items + 1] = {
|
||||
num = numFmt:format(n),
|
||||
name = name,
|
||||
label = (numFmt .. " %s"):format(n, name),
|
||||
-- owned entries carry the pokéball marker like the original
|
||||
-- list; seen-only entries are just the name
|
||||
ball = dex.owned[def.id] or nil,
|
||||
value = (dex.owned[def.id] or dex.seen[def.id]) and def.id or nil,
|
||||
})
|
||||
value = known and def.id or nil,
|
||||
}
|
||||
end
|
||||
end
|
||||
local list = ListMenu.new(game, "POKéDEX", items, {
|
||||
-- SEEN / OWN in the original's fixed three-digit field: engine/menus/
|
||||
-- pokedex.asm HandlePokedexListMenu prints both counts with
|
||||
-- `lb bc, 1, 3` (hlcoord 16,3 and 16,6), labelled by PokedexSeenText
|
||||
-- and PokedexOwnText ("OWN", not "OWNED"). The width is load bearing
|
||||
-- here: a bare ListMenu footer goes through the 18-column text wrap,
|
||||
-- so the old 19-glyph "SEEN 100 OWNED 100" split in two and its first
|
||||
-- half landed on the list's last row at y=120 (#639).
|
||||
footer = Strings("SEEN %3d OWN %3d", seen, owned),
|
||||
pageJump = true, -- Left/Right page jumps like the original
|
||||
onCancel = opts.onCancel, -- B returns to the start menu when opened from it
|
||||
onChoose = function(item, dexList)
|
||||
if not item.value then return end
|
||||
-- the DATA / CRY / AREA / QUIT choice (engine/menus/pokedex.asm
|
||||
-- PokedexMenuItemsText); CRY keeps the side menu open like the
|
||||
-- original. B is what returns to the list: HandlePokedexSideMenu
|
||||
-- hands back b=2 for B and b=1 for QUIT, and ShowPokedexMenu sends
|
||||
-- b=1 to .exitPokedex, so QUIT closes the whole Pokédex (#571)
|
||||
local Menu = require("src.ui.Menu")
|
||||
local Screens = require("src.ui.Screens")
|
||||
local entries = {
|
||||
{ label = Strings("DATA"), onSelect = function()
|
||||
Screens.push(game, "DexEntryMenu", item.value)
|
||||
end },
|
||||
{ label = Strings("CRY"), keepOpen = true, onSelect = function()
|
||||
require("src.core.Sound").playCry(game.data, item.value)
|
||||
end },
|
||||
{ label = Strings("AREA"), onSelect = function()
|
||||
Screens.push(game, "TownMap", { nestSpecies = item.value })
|
||||
end },
|
||||
}
|
||||
-- Yellow's PRNT item (engine/menus/pokedex.asm PokedexMenuItemsText
|
||||
-- _YELLOW branch -> PrintPokedexEntry): the Game Boy Printer job is
|
||||
-- stood in for by a PNG of the entry page saved under prints/.
|
||||
if require("src.core.GameVersion").isYellow() then
|
||||
entries[#entries + 1] = { label = Strings("PRNT"), onSelect = function()
|
||||
local DexEntryMenu = require("src.ui.DexEntryMenu")
|
||||
local Printer = require("src.core.Printer")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local def = game.data.pokemon[item.value]
|
||||
local path = require("src.pokemon.Sprites").path(
|
||||
game.data, item.value, "front", { kind = "dex" })
|
||||
local ok, sprite = false, nil
|
||||
if path then ok, sprite = pcall(love.graphics.newImage, path) end
|
||||
local saved, err = Printer.save("dex_" .. item.value, 160, 144,
|
||||
function()
|
||||
DexEntryMenu.render(game, def, ok and sprite or nil, false)
|
||||
end)
|
||||
game.stack:push(TextBox.new(game, saved
|
||||
and Strings("Printed %s's\ndata!\fSaved as\n%s\vin the save\nfolder.",
|
||||
def.name, saved)
|
||||
or Strings("Printer error!\n%s", tostring(err))))
|
||||
end }
|
||||
end
|
||||
entries[#entries + 1] = { label = Strings("QUIT"), onSelect = function()
|
||||
-- .exitPokedex: drop the dex list too and hand back to whoever
|
||||
-- opened it (the start menu, whose saved cursor is still on
|
||||
-- POKéDEX -- wBattleAndStartSavedMenuItem)
|
||||
dexList:close()
|
||||
if opts.onCancel then opts.onCancel() end
|
||||
end }
|
||||
game.stack:push(Menu.new(game, entries,
|
||||
{ tx = 12, ty = 8, tw = 8,
|
||||
th = #entries * 2 + 2 }))
|
||||
end,
|
||||
})
|
||||
list.sgbPalettes = PokedexMenu.sgbPalettes
|
||||
return list
|
||||
self.items = items
|
||||
-- SEEN / OWN, `lb bc, 1, 3` at hlcoord 16,3 and 16,6: engine/menus/
|
||||
-- pokedex.asm HandlePokedexListMenu (#639)
|
||||
self.seenCount, self.ownedCount = seen, owned
|
||||
self.footer = Strings("SEEN %3d OWN %3d", seen, owned)
|
||||
return self
|
||||
end
|
||||
|
||||
-- HandleMenuInput_ replays SFX_PRESS_AB for A and B (home/window.asm)
|
||||
local function beep(self)
|
||||
if not (self.game and self.game.data) then return end
|
||||
require("src.core.Sound").play(self.game.data, "Press_AB")
|
||||
end
|
||||
|
||||
function PokedexMenu:rows()
|
||||
return math.min(ROWS, #self.items)
|
||||
end
|
||||
|
||||
-- engine/menus/pokedex.asm:290-313
|
||||
function PokedexMenu:syncScroll()
|
||||
local rows = self:rows()
|
||||
if rows == 0 then
|
||||
self.index, self.scroll = 1, 0
|
||||
return
|
||||
end
|
||||
self.index = math.max(1, math.min(#self.items, self.index))
|
||||
if self.index - self.scroll > rows then self.scroll = self.index - rows end
|
||||
if self.index - self.scroll < 1 then self.scroll = self.index - 1 end
|
||||
end
|
||||
|
||||
-- engine/menus/pokedex.asm:314-342: Left and Right move wListScrollOffset
|
||||
-- by seven and never touch wCurrentMenuItem, so the cursor keeps its row
|
||||
function PokedexMenu:pageScroll(dir)
|
||||
local n = #self.items
|
||||
if n < ROWS then return end
|
||||
local row = self.index - self.scroll
|
||||
local scroll = self.scroll + dir * ROWS
|
||||
scroll = math.max(0, math.min(n - ROWS, scroll))
|
||||
self.scroll = scroll
|
||||
self.index = math.min(n, scroll + row)
|
||||
end
|
||||
|
||||
function PokedexMenu:close()
|
||||
local top = self.game.stack:top()
|
||||
if top == self then self.game.stack:pop() end
|
||||
end
|
||||
|
||||
function PokedexMenu:update(dt)
|
||||
-- back on top of the stack, so the side menu's hollow '▷' is gone
|
||||
-- (engine/menus/pokedex.asm:61, PlaceUnfilledArrowMenuCursor)
|
||||
self.hollowIndex = nil
|
||||
local input = self.game.input
|
||||
if #self.items == 0 then
|
||||
if input:wasPressed("a") or input:wasPressed("b") then
|
||||
beep(self)
|
||||
self.game.stack:pop()
|
||||
if self.onCancel then self.onCancel() end
|
||||
end
|
||||
return
|
||||
end
|
||||
if input:wasPressed("up") then
|
||||
self.index = self.index - 1
|
||||
elseif input:wasPressed("down") then
|
||||
self.index = self.index + 1
|
||||
elseif input:wasPressed("left") then
|
||||
self:pageScroll(-1)
|
||||
elseif input:wasPressed("right") then
|
||||
self:pageScroll(1)
|
||||
elseif input:wasPressed("b") then
|
||||
beep(self)
|
||||
self.game.stack:pop()
|
||||
if self.onCancel then self.onCancel() end
|
||||
return
|
||||
elseif input:wasPressed("a") then
|
||||
beep(self)
|
||||
self.onChoose(self.items[self.index], self)
|
||||
return
|
||||
end
|
||||
self:syncScroll()
|
||||
end
|
||||
|
||||
-- engine/menus/pokedex.asm:371-375, :81-85
|
||||
function PokedexMenu:sideItems()
|
||||
local labels = { Strings("DATA"), Strings("CRY"), Strings("AREA") }
|
||||
if require("src.core.GameVersion").isYellow() then
|
||||
labels[#labels + 1] = Strings("PRNT")
|
||||
end
|
||||
labels[#labels + 1] = Strings("QUIT")
|
||||
return labels
|
||||
end
|
||||
|
||||
local function sideRowY(menu, row)
|
||||
return (menu.ty + menu.th - 2 - (#menu.items - row) * 2) * 8
|
||||
end
|
||||
|
||||
local function drawSideMenu(self)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
for row = 1, #self.items do
|
||||
Font.draw(self.items[row].label, (self.tx + 2) * 8, sideRowY(self, row))
|
||||
end
|
||||
Font.drawCode(Theme.cursor, (self.tx + 1) * 8, sideRowY(self, self.index))
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
-- engine/menus/pokedex.asm HandlePokedexSideMenu, ShowPokedexMenu
|
||||
-- .exitPokedex (#571)
|
||||
local function chooseEntry(item, dexList)
|
||||
local game = dexList.game
|
||||
-- engine/menus/pokedex.asm:77-80: an unseen row hands back b=2 at once
|
||||
if not item.value then return end
|
||||
local Menu = require("src.ui.Menu")
|
||||
local Screens = require("src.ui.Screens")
|
||||
local entries = {
|
||||
{ label = Strings("DATA"), onSelect = function()
|
||||
Screens.push(game, "DexEntryMenu", item.value)
|
||||
end },
|
||||
{ label = Strings("CRY"), keepOpen = true, onSelect = function()
|
||||
require("src.core.Sound").playCry(game.data, item.value)
|
||||
end },
|
||||
{ label = Strings("AREA"), onSelect = function()
|
||||
Screens.push(game, "TownMap", { nestSpecies = item.value })
|
||||
end },
|
||||
}
|
||||
-- pokeyellow engine/menus/pokedex.asm PokedexMenuItemsText,
|
||||
-- PrintPokedexEntry
|
||||
if require("src.core.GameVersion").isYellow() then
|
||||
entries[#entries + 1] = { label = Strings("PRNT"), onSelect = function()
|
||||
local DexEntryMenu = require("src.ui.DexEntryMenu")
|
||||
local Printer = require("src.core.Printer")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local def = game.data.pokemon[item.value]
|
||||
local path = require("src.pokemon.Sprites").path(
|
||||
game.data, item.value, "front", { kind = "dex" })
|
||||
local ok, sprite = false, nil
|
||||
if path then ok, sprite = pcall(love.graphics.newImage, path) end
|
||||
local saved, err = Printer.save("dex_" .. item.value, 160, 144,
|
||||
function()
|
||||
DexEntryMenu.render(game, def, ok and sprite or nil, false)
|
||||
end)
|
||||
game.stack:push(TextBox.new(game, saved
|
||||
and Strings("Printed %s's\ndata!\fSaved as\n%s\vin the save\nfolder.",
|
||||
def.name, saved)
|
||||
or Strings("Printer error!\n%s", tostring(err))))
|
||||
end }
|
||||
end
|
||||
entries[#entries + 1] = { label = Strings("QUIT"), onSelect = function()
|
||||
-- engine/menus/pokedex.asm .exitPokedex
|
||||
dexList:close()
|
||||
if dexList.onCancel then dexList.onCancel() end
|
||||
end }
|
||||
local ty = dexList.rowsAt.items - 2
|
||||
local side = Menu.new(game, entries, { tx = 14, ty = ty, tw = 6 })
|
||||
-- the block is already on screen, so the side menu is the cursor alone:
|
||||
-- text at column 16, cursor at 15 (engine/menus/pokedex.asm:82-86)
|
||||
side.tx, side.tw, side.ty, side.th = 14, 6, ty, #entries * 2 + 2
|
||||
side.draw = drawSideMenu
|
||||
dexList.hollowIndex = dexList.index
|
||||
game.stack:push(side)
|
||||
end
|
||||
PokedexMenu.onChoose = chooseEntry
|
||||
|
||||
-- engine/menus/pokedex.asm:256 writes tile $72 in the column left of the
|
||||
-- name; the port paints the same marker rather than carry a one-tile sheet
|
||||
local function drawBall(x, y)
|
||||
love.graphics.circle("fill", x + 4, y + 4, 3.5)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.rectangle("fill", x + 0.5, y + 3.5, 7, 1)
|
||||
love.graphics.circle("fill", x + 4, y + 4, 1.2)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
end
|
||||
|
||||
function PokedexMenu:draw()
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
||||
local at = self.rowsAt
|
||||
local DexEntryMenu = require("src.ui.DexEntryMenu")
|
||||
-- engine/menus/pokedex.asm:159-176: the divider column and the rule that
|
||||
-- fences SEEN / OWN off from the side-menu block
|
||||
for ty = 0, 17 do
|
||||
DexEntryMenu.tile(self.game, DIVIDER[ty], 14, ty)
|
||||
end
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw(("─"):rep(5), 120, at.rule * 8)
|
||||
Font.draw(Strings("CONTENTS"), 8, 8)
|
||||
Font.draw(Strings("SEEN"), 128, at.seen * 8)
|
||||
Font.draw(Strings("OWN"), 128, at.own * 8)
|
||||
-- PrintNumber's fixed three-digit field ends on column 19
|
||||
local function count(n, ty)
|
||||
local text = tostring(n)
|
||||
Font.draw(text, 152 - Font.width(text), ty * 8)
|
||||
end
|
||||
count(self.seenCount, at.seen + 1)
|
||||
count(self.ownedCount, at.own + 1)
|
||||
local labels = self:sideItems()
|
||||
for i, label in ipairs(labels) do
|
||||
Font.draw(label, 128, (at.items + (i - 1) * 2) * 8)
|
||||
end
|
||||
for row = 1, self:rows() do
|
||||
local i = self.scroll + row
|
||||
local item = self.items[i]
|
||||
if not item then break end
|
||||
local y = FIRST_ROW_Y + (row - 1) * 16
|
||||
-- the number sits one row above its name (engine/menus/pokedex.asm:242-246)
|
||||
Font.draw(item.num, NUM_X, y - 8)
|
||||
if item.ball then drawBall(BALL_X, y) end
|
||||
Font.draw(item.name, NAME_X, y)
|
||||
if i == self.index then
|
||||
Font.drawCode(self.hollowIndex == i
|
||||
and Theme.cursorHollow or Theme.cursor, CURSOR_X, y)
|
||||
end
|
||||
end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
return PokedexMenu
|
||||
|
||||
+14
-23
@@ -192,9 +192,9 @@ function SlotMachine.new(game, lucky)
|
||||
local self = setmetatable({}, SlotMachine)
|
||||
self.game = game
|
||||
self.wheels = game.data.field.slotWheels
|
||||
-- intro | bet | spinup | spin | reroll | flash | message | payout | onemore
|
||||
-- PromptUserToPlaySlots asks "Want to play?" before the session starts.
|
||||
self.stage = "intro"
|
||||
-- bet | spinup | spin | reroll | flash | message | payout | onemore
|
||||
-- PromptUserToPlaySlots: engine/slots/slot_machine.asm:9-23
|
||||
self.stage = "bet"
|
||||
self.yesno = 1 -- YES/NO cursor (1 = YES); wCurrentMenuItem
|
||||
-- CoinMultiplierSlotMachineText lists ×3/×2/×1 with the cursor defaulting to
|
||||
-- the top (wCurrentMenuItem 0), i.e. bet = 3 - menuItem.
|
||||
@@ -417,7 +417,7 @@ function SlotMachine:startPayout()
|
||||
self.flash = false
|
||||
end
|
||||
|
||||
-- YES/NO prompt shared by the intro ("Want to play?") and "One more go?".
|
||||
-- the "One more go?" YES/NO prompt.
|
||||
function SlotMachine:updateYesNo(onYes)
|
||||
local input = self.game.input
|
||||
if input:wasPressed("up") or input:wasPressed("down") then
|
||||
@@ -435,12 +435,6 @@ function SlotMachine:update(dt)
|
||||
local input = self.game.input
|
||||
local save = self.game.save
|
||||
|
||||
if self.stage == "intro" then
|
||||
-- PromptUserToPlaySlots: "A slot machine! Want to play?"
|
||||
self:updateYesNo(function() self:enterBet() end)
|
||||
return
|
||||
end
|
||||
|
||||
if self.stage == "message" then
|
||||
if self.exitTimer then
|
||||
-- OutOfCoinsSlotMachineText: DelayFrames 60, then leave
|
||||
@@ -638,9 +632,7 @@ end
|
||||
-- menu on the right (like MainSlotMachineLoop's TextBoxBorder + menus).
|
||||
function SlotMachine:drawBottom()
|
||||
local lines
|
||||
if self.stage == "intro" then
|
||||
lines = { "A slot machine!", "Want to play?" }
|
||||
elseif self.stage == "bet" then
|
||||
if self.stage == "bet" then
|
||||
lines = { "Bet how many", "coins?" }
|
||||
elseif self.stage == "onemore" then
|
||||
lines = { "One more", "go?" }
|
||||
@@ -661,22 +653,21 @@ function SlotMachine:drawBottom()
|
||||
Font.draw(lines[1] or "", 8, 14 * 8)
|
||||
Font.draw(lines[2] or "", 8, 16 * 8)
|
||||
if self.stage == "bet" then
|
||||
Font.drawBox(14, 11, 6, 5)
|
||||
-- hlcoord 14,11 with b=5 c=4 -- engine/slots/slot_machine.asm:83-86
|
||||
Font.drawBox(14, 11, 6, 7)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw("×3", 16 * 8, 12 * 8)
|
||||
Font.draw("×2", 16 * 8, 13 * 8)
|
||||
Font.draw("×1", 16 * 8, 14 * 8)
|
||||
Font.drawCode(0xED, 15 * 8, (12 + self.betIndex) * 8)
|
||||
elseif self.stage == "intro" or self.stage == "onemore" then
|
||||
-- "One more go?" sits at the right of the box (hlcoord 14,12); the longer
|
||||
-- "A slot machine!" prompt would clip against it, so the intro's YES/NO
|
||||
-- floats above the reels instead.
|
||||
local by = self.stage == "intro" and 6 or 11
|
||||
Font.drawBox(13, by, 6, 5)
|
||||
elseif self.stage == "onemore" then
|
||||
-- hlcoord 14,12 with the YES_NO_MENU 4x3 body
|
||||
-- engine/slots/slot_machine.asm:136-138, data/yes_no_menu_strings.asm:10
|
||||
Font.drawBox(14, 12, 6, 5)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw(Strings("YES"), 15 * 8, (by + 1) * 8)
|
||||
Font.draw(Strings("NO"), 15 * 8, (by + 2) * 8)
|
||||
Font.drawCode(0xED, 14 * 8, (by + 1 + (self.yesno == 1 and 0 or 1)) * 8)
|
||||
Font.draw(Strings("YES"), 16 * 8, 13 * 8)
|
||||
Font.draw(Strings("NO"), 16 * 8, 14 * 8)
|
||||
Font.drawCode(0xED, 15 * 8, (13 + (self.yesno == 1 and 0 or 1)) * 8)
|
||||
end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
@@ -200,12 +200,12 @@ function StartMenu.new(game)
|
||||
anchor = "topright" })
|
||||
-- the cursor position survives closing the menu
|
||||
-- (wBattleAndStartSavedMenuItem, home/start_menu.asm)
|
||||
menu.index = math.min(game.save.startMenuIndex or 1, #items)
|
||||
menu.index = math.min(game.startMenuIndex or 1, #items)
|
||||
menu:clampScroll()
|
||||
local baseUpdate = menu.update
|
||||
menu.update = function(self, dt)
|
||||
baseUpdate(self, dt)
|
||||
game.save.startMenuIndex = self.index
|
||||
game.startMenuIndex = self.index
|
||||
end
|
||||
|
||||
-- inside the Safari Zone the start menu also shows remaining steps and
|
||||
|
||||
+10
-3
@@ -12,6 +12,7 @@ local Font = require("src.render.Font")
|
||||
-- TypeChart.displayName maps it back to "PSYCHIC", like HallOfFame and the
|
||||
-- battle move-type box already do (#214).
|
||||
local TypeChart = require("src.battle.TypeChart")
|
||||
local LevelDisplay = require("src.ui.LevelDisplay")
|
||||
local Strings = require("src.core.Strings")
|
||||
local Stats = require("src.pokemon.Stats")
|
||||
local Status = require("src.battle.Status")
|
||||
@@ -137,7 +138,9 @@ function SummaryMenu:draw()
|
||||
if self.page == 1 then
|
||||
-- level is page 1 only: StatusScreen2 opens with ClearScreenArea over
|
||||
-- (9,2) 5x10 (status_screen.asm:303-305). #280
|
||||
printLevel(14, 2, mon.level)
|
||||
if LevelDisplay.visible(mon, "summary", self.game) then -- RFC 0019
|
||||
printLevel(14, 2, mon.level)
|
||||
end
|
||||
drawLineBox(19, 1, 6, 10)
|
||||
-- engine/pokemon/status_screen.asm:120-125
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
@@ -196,8 +199,12 @@ function SummaryMenu:draw()
|
||||
local nextExp = mon.level < 100
|
||||
and (Growth.expForLevel(def.growthRate, mon.level + 1) - mon.exp) or 0
|
||||
Font.draw(("%7d"):format(math.max(0, nextExp)), 56, 48)
|
||||
HudTiles.statusTile(0x70, 112, 48) -- '<to>' at (14,6), was missing (#280)
|
||||
printLevel(16, 6, math.min(100, mon.level + 1))
|
||||
if LevelDisplay.visible(mon, "summary", self.game) then -- RFC 0019
|
||||
-- the '<to>' arrow is half a sentence without the level it points at,
|
||||
-- so the pair is hidden together
|
||||
HudTiles.statusTile(0x70, 112, 48) -- '<to>' at (14,6), was missing (#280)
|
||||
printLevel(16, 6, math.min(100, mon.level + 1))
|
||||
end
|
||||
Font.drawBox(0, 8, 20, 10)
|
||||
for i = 1, 4 do
|
||||
local mv = mon.moves[i]
|
||||
|
||||
+43
-27
@@ -130,6 +130,25 @@ local function markerXY(loc)
|
||||
return loc.x * 8 + 16, loc.y * 8 + 8
|
||||
end
|
||||
|
||||
-- the marker wears its own sheet's OBJ palette and shade-0 keying
|
||||
-- engine/items/town_map.asm:342
|
||||
local function markerSheet(def, seed)
|
||||
local colors, group
|
||||
if PaletteFX.usesGbcPack() then
|
||||
colors, group = PaletteFX.spriteObp(def, seed)
|
||||
end
|
||||
if not colors then
|
||||
if PaletteFX.usesSpriteObp() then
|
||||
colors, group = PaletteFX.ogObj()
|
||||
else
|
||||
colors, group = PaletteFX.dmgObj()
|
||||
end
|
||||
end
|
||||
local ok, img = pcall(SpriteRenderer.obpImage, def and def.image, colors, group)
|
||||
if not (ok and img) then return nil, nil end
|
||||
return img, love.graphics.newQuad(0, 0, 16, 16, img:getDimensions())
|
||||
end
|
||||
|
||||
-- the row-0 name banner; fly mode prefixes "To " like LoadTownMap_Fly
|
||||
-- (engine/menus/town_map.asm prints the destination as "To <NAME>")
|
||||
function TownMap:bannerText(loc)
|
||||
@@ -224,32 +243,18 @@ function TownMap.new(game, opts)
|
||||
local mapId = game.overworld and game.overworld.map and game.overworld.map.id
|
||||
self.playerLoc = mapId and self.byMap[mapId] or nil
|
||||
-- engine/items/town_map.asm:347
|
||||
do
|
||||
local playerSprites = (game.data.field and game.data.field.playerSprites)
|
||||
or {}
|
||||
local sprites = game.data.sprites or {}
|
||||
local red = sprites[playerSprites.walk or "SPRITE_RED"]
|
||||
or sprites.SPRITE_RED
|
||||
-- the marker is the overworld walking sheet, so it wears that sheet's OBJ
|
||||
-- palette and shade-0 keying -- engine/items/town_map.asm:342
|
||||
local colors, group
|
||||
if PaletteFX.usesGbcPack() then
|
||||
colors, group = PaletteFX.spriteObp(red, "player")
|
||||
end
|
||||
if not colors then
|
||||
if PaletteFX.usesSpriteObp() then
|
||||
colors, group = PaletteFX.ogObj()
|
||||
else
|
||||
colors, group = PaletteFX.dmgObj()
|
||||
end
|
||||
end
|
||||
local ok, img = pcall(SpriteRenderer.obpImage,
|
||||
red and red.image, colors, group)
|
||||
if ok and img then
|
||||
self.playerSheet = img
|
||||
self.playerQuad = love.graphics.newQuad(0, 0, 16, 16,
|
||||
img:getDimensions())
|
||||
end
|
||||
local playerSprites = (game.data.field and game.data.field.playerSprites)
|
||||
or {}
|
||||
local sprites = game.data.sprites or {}
|
||||
self.playerSheet, self.playerQuad =
|
||||
markerSheet(sprites[playerSprites.walk or "SPRITE_RED"] or sprites.SPRITE_RED,
|
||||
"player")
|
||||
-- LoadTownMap_Fly overwrites the cursor tiles with BirdSprite and marks
|
||||
-- the destination with it -- engine/items/town_map.asm:146-149, 177-179
|
||||
if self.fly then
|
||||
self.birdSheet, self.birdQuad =
|
||||
markerSheet(sprites[playerSprites.fly or "SPRITE_BIRD"]
|
||||
or sprites.SPRITE_BIRD, "bird")
|
||||
end
|
||||
self.sel = 1
|
||||
-- LoadTownMap_Fly always opens with hl on wFlyLocationsList[0], the FIRST
|
||||
@@ -402,8 +407,19 @@ function TownMap:draw()
|
||||
end
|
||||
end
|
||||
-- WriteTownMapSpriteOAM carry quirk: -4 X, -3 Y for cursor and player alike -- engine/items/town_map.asm:454
|
||||
-- LoadTownMap_Fly's .inputLoop has no blinking animation
|
||||
-- engine/items/town_map.asm:190-198
|
||||
local showCursor = true
|
||||
if GameVersion.generation() == 1 then
|
||||
if self.fly and self.birdSheet then
|
||||
if selected then
|
||||
local x, y = markerXY(selected)
|
||||
love.graphics.draw(self.birdSheet, self.birdQuad, x - 4, y - 3)
|
||||
if PaletteFX.usesSpriteObp() or PaletteFX.usesGbcPack() then
|
||||
PaletteFX.markUiSpriteRedraw(self.birdSheet, self.birdQuad, x - 4, y - 3)
|
||||
end
|
||||
end
|
||||
showCursor = false
|
||||
elseif GameVersion.generation() == 1 then
|
||||
showCursor = self.blink < 25
|
||||
else
|
||||
showCursor = self.blink % 16 < 10
|
||||
|
||||
@@ -605,23 +605,66 @@ function BattleState:animData(mon)
|
||||
local def = self.pokemon and mon and self.pokemon[mon.species]
|
||||
if not def then return nil end
|
||||
if mon.species == Unown.SPECIES and def.letters then
|
||||
local entry = def.letters[Unown.monLetter(mon)]
|
||||
local entry = def.letters[Unown.name(Unown.monLetter(mon))]
|
||||
if entry and entry.anim then return entry.anim end
|
||||
end
|
||||
return def.anim
|
||||
end
|
||||
|
||||
-- ../pokecrystal/engine/gfx/load_pics.asm:105-131
|
||||
function BattleState:frontPicReplaced(mon)
|
||||
local def = self.pokemon and mon and self.pokemon[mon.species]
|
||||
local vanilla = def and def.spriteFront
|
||||
if mon and mon.species == Unown.SPECIES then
|
||||
vanilla = Unown.formSprite(self.pokemon, Unown.monLetter(mon), false)
|
||||
or vanilla
|
||||
end
|
||||
if type(vanilla) ~= "string" then return false end
|
||||
local _, _, path = self:pic(mon, false)
|
||||
if type(path) == "string" and path ~= vanilla then return true end
|
||||
return Assets.resolve(vanilla) ~= vanilla
|
||||
end
|
||||
|
||||
-- one cart asset, two files on the pokemon.sprite seam (#1827)
|
||||
-- ../pokecrystal/engine/gfx/load_pics.asm:132-158
|
||||
function BattleState:animSheetPath(mon, data)
|
||||
local path = data and data.sheet
|
||||
if type(path) ~= "string" then return nil, false end
|
||||
if not Runtime.wantsHook("pokemon.sprite") then
|
||||
return path, Assets.resolve(path) ~= path
|
||||
end
|
||||
local letter
|
||||
if mon and mon.species == Unown.SPECIES then letter = Unown.monLetter(mon) end
|
||||
local hooked = Runtime.call("pokemon.sprite",
|
||||
function(value) return value end, path, {
|
||||
species = mon and mon.species,
|
||||
side = "front",
|
||||
kind = "battle_anim",
|
||||
mon = mon,
|
||||
data = (self.game and self.game.data) or nil,
|
||||
letter = letter,
|
||||
shiny = mon and mon.shiny and true or false,
|
||||
})
|
||||
if type(hooked) == "string" and hooked ~= "" and hooked ~= path then
|
||||
return hooked, true
|
||||
end
|
||||
return path, Assets.resolve(path) ~= path
|
||||
end
|
||||
|
||||
-- ANIM_MON_NORMAL, the scene BattleStartMessage runs on the enemy's frontpic.
|
||||
-- ../pokecrystal/engine/battle/core.asm:9112-9113
|
||||
function BattleState:startFrontAnim(mon)
|
||||
self.frontAnim = nil
|
||||
local data = self:animData(mon)
|
||||
if not data then return end
|
||||
local cached = self.picCache[data.sheet]
|
||||
local sheet, sheetReplaced = self:animSheetPath(mon, data)
|
||||
if not sheet then return end
|
||||
if not sheetReplaced and self:frontPicReplaced(mon) then return end
|
||||
local cached = self.picCache[sheet]
|
||||
if cached == nil then
|
||||
local ok, image = pcall(Assets.image, data.sheet)
|
||||
local ok, image = pcall(Assets.image, sheet)
|
||||
cached = ok and image or false
|
||||
self.picCache[data.sheet] = cached
|
||||
self.picCache[sheet] = cached
|
||||
end
|
||||
if not cached then return end
|
||||
local runner = MonAnim.new(data, "battle")
|
||||
|
||||
@@ -37,6 +37,13 @@ local TileSheet = require("src.ui.gen2.TileSheet")
|
||||
local Nests = require("src.core.gen2.Nests")
|
||||
local Sound = require("src.core.Sound")
|
||||
local Unown = require("src.core.gen2.Unown")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
-- `db $3b, " OPTION ", $3c` / `db $3b, " SEARCH ", $3c"`: the panel titles
|
||||
-- drawn by drawOption/drawSearch below, declared here (rather than inline)
|
||||
-- so Strings.source puts them in the catalog harvest.
|
||||
local OPTION_LABEL = Strings.source(" OPTION ")
|
||||
local SEARCH_LABEL = Strings.source(" SEARCH ")
|
||||
|
||||
local PokedexMenu = {}
|
||||
PokedexMenu.__index = PokedexMenu
|
||||
@@ -776,7 +783,6 @@ function PokedexMenu:printEntry()
|
||||
local row = self:current()
|
||||
if not row then return end
|
||||
local Printer = require("src.core.Printer")
|
||||
local Strings = require("src.core.Strings")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local name = (self.pokemon and self.pokemon[row.species]
|
||||
and self.pokemon[row.species].name) or tostring(row.species)
|
||||
@@ -1385,7 +1391,7 @@ function PokedexMenu:drawOption()
|
||||
-- `db $3b, " OPTION ", $3c`: the two end-cap tiles are the dex sheet's, not
|
||||
-- font glyphs.
|
||||
self:tile(0x3b, 0, 1)
|
||||
self:text(" OPTION ", 1, 1)
|
||||
self:text(Strings(OPTION_LABEL), 1, 1)
|
||||
self:tile(0x3c, 9, 1)
|
||||
local rows = self:optionRows()
|
||||
for i, row in ipairs(rows) do
|
||||
@@ -1405,7 +1411,7 @@ function PokedexMenu:drawSearch()
|
||||
self:fill(TILE_BG, 0, 0, Chrome.SCREEN_W, Chrome.SCREEN_H)
|
||||
self:border(0, 2, 14, 18)
|
||||
self:tile(0x3b, 0, 1)
|
||||
self:text(" SEARCH ", 1, 1)
|
||||
self:text(Strings(SEARCH_LABEL), 1, 1)
|
||||
self:tile(0x3c, 9, 1)
|
||||
self:text("TYPE1", 3, 4)
|
||||
self:text("TYPE2", 3, 6)
|
||||
|
||||
@@ -336,7 +336,7 @@ function SummaryMenu:startPicAnim()
|
||||
if not def then return end
|
||||
local data = def.anim
|
||||
if mon.species == Unown.SPECIES and def.letters then
|
||||
local entry = def.letters[Unown.monLetter(mon)]
|
||||
local entry = def.letters[Unown.name(Unown.monLetter(mon))]
|
||||
if entry and entry.anim then data = entry.anim end
|
||||
end
|
||||
if not data then return end
|
||||
@@ -1105,9 +1105,14 @@ function SummaryMenu:drawEggIconFallback(colors)
|
||||
G.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
function SummaryMenu:drawPlacements(list)
|
||||
-- engine/gfx/color.asm:342-359
|
||||
function SummaryMenu:drawPlacements(list, palette)
|
||||
for _, entry in ipairs(list) do
|
||||
Chrome.print(entry.text, entry.x, entry.y)
|
||||
if palette then
|
||||
Chrome.printThrough(entry.text, entry.x, entry.y, palette)
|
||||
else
|
||||
Chrome.print(entry.text, entry.x, entry.y)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1168,7 +1173,7 @@ function SummaryMenu:drawPinkPage()
|
||||
HpBar.drawWithLabel(self.palettes, mon.hp, maxHp, 0, 9, Font)
|
||||
end
|
||||
self:drawVerticalDivider(9)
|
||||
self:drawPlacements(self:pinkPlacements())
|
||||
self:drawPlacements(self:pinkPlacements(), self:lowerColors())
|
||||
|
||||
-- FillInExpBar is handed (11,16), adds 7 to reach the rightmost cell and
|
||||
-- fills eight of them walking left, with the $40/$41 caps outside at (10,16)
|
||||
@@ -1188,12 +1193,12 @@ function SummaryMenu:drawPinkPage()
|
||||
end
|
||||
|
||||
function SummaryMenu:drawGreenPage()
|
||||
self:drawPlacements(self:greenPlacements())
|
||||
self:drawPlacements(self:greenPlacements(), self:lowerColors())
|
||||
end
|
||||
|
||||
function SummaryMenu:drawBluePage()
|
||||
self:drawVerticalDivider(10)
|
||||
self:drawPlacements(self:bluePlacements())
|
||||
self:drawPlacements(self:bluePlacements(), self:lowerColors())
|
||||
end
|
||||
|
||||
function SummaryMenu:drawMoveDetail()
|
||||
|
||||
+2
-1
@@ -934,9 +934,10 @@ function Kit.textfield(id, x, y, w, h, value, placeholder)
|
||||
local isHandheld = os.getenv("HANDHELD") == "1" or os.getenv("PORTMASTER") == "1"
|
||||
or os.getenv("POKEPORT_HANDHELD") == "1" or os.getenv("TRIMUI") == "1"
|
||||
or os.getenv("MUOS") == "1" or os.getenv("KNULLI") == "1"
|
||||
or os.getenv("POKEPORT_SBC") == "1" or os.getenv("ANBERNIC") == "1"
|
||||
|
||||
local clickedOrActivated = (Kit._activateId == id) or (isHandheld and Kit.press(x, y, w, h))
|
||||
if clickedOrActivated and not mobile() then
|
||||
if isHandheld and clickedOrActivated and not mobile() then
|
||||
VirtualKeyboard.open({
|
||||
text = value,
|
||||
targetId = id,
|
||||
|
||||
@@ -48,7 +48,23 @@ local ROWS_SYM = {
|
||||
{ { id = "url_https", label = "https://", w = 3 }, { id = "url_http", label = "http://", w = 2 }, { id = "url_json", label = ".json", w = 2 }, { id = "url_zip", label = ".zip", w = 2 }, { id = "clear", label = "Clear", w = 3 } },
|
||||
}
|
||||
|
||||
local function isLinuxHandheld()
|
||||
return os.getenv("HANDHELD") == "1"
|
||||
or os.getenv("PORTMASTER") == "1"
|
||||
or os.getenv("POKEPORT_HANDHELD") == "1"
|
||||
or os.getenv("TRIMUI") == "1"
|
||||
or os.getenv("MUOS") == "1"
|
||||
or os.getenv("KNULLI") == "1"
|
||||
or os.getenv("POKEPORT_SBC") == "1"
|
||||
or os.getenv("ANBERNIC") == "1"
|
||||
end
|
||||
|
||||
function VirtualKeyboard.isSupported()
|
||||
return isLinuxHandheld()
|
||||
end
|
||||
|
||||
function VirtualKeyboard.open(opts)
|
||||
if not isLinuxHandheld() then return false end
|
||||
opts = opts or {}
|
||||
VirtualKeyboard.active = true
|
||||
VirtualKeyboard.text = tostring(opts.text or "")
|
||||
@@ -58,6 +74,7 @@ function VirtualKeyboard.open(opts)
|
||||
VirtualKeyboard.row = 1
|
||||
VirtualKeyboard.col = 1
|
||||
VirtualKeyboard.mode = 1
|
||||
return true
|
||||
end
|
||||
|
||||
function VirtualKeyboard.close(confirmed)
|
||||
|
||||
@@ -48,6 +48,10 @@ local WILD_ENCOUNTER_GRACE_STEPS = 3
|
||||
-- of the GB screen center: FLY_ANCHOR is the pair where the original has
|
||||
-- the player's sprite, so path1 starts exactly on the player.
|
||||
local FLY_ANCHOR = { 0x3C, 0x48 }
|
||||
-- StopMusic's fade-out control byte and the 7 volume steps it waits out
|
||||
-- engine/overworld/player_animations.asm:123, home/fade_audio.asm:36
|
||||
local FLY_FADE_CONTROL = 4
|
||||
local FLY_FADE_FRAMES = FLY_FADE_CONTROL * 7
|
||||
local FLY_PATH1 = { -- FlyAnimationScreenCoords1: up and off to the right
|
||||
{ 0x3C, 0x48 }, { 0x3C, 0x50 }, { 0x3B, 0x58 }, { 0x3A, 0x60 },
|
||||
{ 0x39, 0x68 }, { 0x37, 0x70 }, { 0x37, 0x78 }, { 0x33, 0x80 },
|
||||
@@ -1189,6 +1193,17 @@ function OverworldState:update(dt)
|
||||
end
|
||||
return
|
||||
end
|
||||
-- StopMusic busy-waits on the fade before LoadBirdSpriteGraphics, so the
|
||||
-- world holds and the bird is not on screen yet -- home/overworld.asm:772
|
||||
if self.flyFade then
|
||||
self.flyFade = self.flyFade - 1
|
||||
if self.flyFade <= 0 then
|
||||
self.flyFade = nil
|
||||
self.flyAnim = { phase = "flap", t = 0 }
|
||||
end
|
||||
self.player:update()
|
||||
return
|
||||
end
|
||||
if self.flyAnim then
|
||||
-- DoFlyAnimation runs one coord pair every Delay3 (3 frames); the
|
||||
-- in-place flap is 8 pairs, then the two paths with a 40-frame beat
|
||||
@@ -2070,7 +2085,9 @@ function OverworldState:flyTo(mapId)
|
||||
-- then SFX_FLY and the up-right path, a 40-frame beat off screen, and
|
||||
-- the exit over the top-left -- the warp fades only once the bird is
|
||||
-- gone (#702). fxBird draws it; the player hides for the whole flight.
|
||||
self.flyAnim = { phase = "flap", t = 0 }
|
||||
-- engine/overworld/player_animations.asm:123, home/overworld.asm:772
|
||||
require("src.core.Music").fadeOut(FLY_FADE_CONTROL)
|
||||
self.flyFade = FLY_FADE_FRAMES
|
||||
self.player.inputLocked = true
|
||||
self.flyDest = { map = mapId, x = spot.x, y = spot.y }
|
||||
end
|
||||
@@ -2093,6 +2110,9 @@ function OverworldState:beginTeleportOut(onDone)
|
||||
if onDone then onDone() end
|
||||
return
|
||||
end
|
||||
-- StopMusic sits above both _LeaveMapAnim branches
|
||||
-- engine/overworld/player_animations.asm:123
|
||||
require("src.core.Music").fadeOut(FLY_FADE_CONTROL)
|
||||
require("src.core.Sound").play(Game.data, "Teleport_Exit1")
|
||||
self.player.surfing = false
|
||||
self:syncSurfingPikachu()
|
||||
@@ -2315,6 +2335,15 @@ function OverworldState.benchGuyText(data, save, label)
|
||||
return data.text["_" .. label] or data.text[BENCH_GUY_TEXT[label] or ""]
|
||||
end
|
||||
|
||||
-- HiddenCoins pays a BCD constant chosen by the argument, and the 40 case
|
||||
-- falls into .bcd20 -- engine/events/hidden_items.asm:79
|
||||
function OverworldState.hiddenCoinPayout(amount)
|
||||
amount = tonumber(amount) or 0
|
||||
if amount == 10 then return 10 end
|
||||
if amount == 20 or amount == 40 then return 20 end
|
||||
return 100
|
||||
end
|
||||
|
||||
-- Hidden events at the faced cell (data/events/hidden_events.asm):
|
||||
-- HiddenItems give their item once, HiddenCoins fill the COIN CASE,
|
||||
-- StartSlotMachine seats open the minigame. Taken spots persist in
|
||||
@@ -2359,9 +2388,10 @@ function OverworldState:tryHiddenObject(fx, fy)
|
||||
if save.hiddenTaken[key] then return false end
|
||||
if not save.inventory.COIN_CASE then return false end
|
||||
save.hiddenTaken[key] = true
|
||||
save.coins = math.min(9999, (save.coins or 0) + h.coins)
|
||||
local paid = OverworldState.hiddenCoinPayout(h.coins)
|
||||
save.coins = math.min(9999, (save.coins or 0) + paid)
|
||||
Game.stack:push(TextBox.new(Game,
|
||||
Strings("%s found\n%d coins!", save.player.name, h.coins),
|
||||
Strings("%s found\n%d coins!", save.player.name, paid),
|
||||
nil, TextBox.soundOpts(Game, "Get_Item2")))
|
||||
return true
|
||||
end
|
||||
@@ -2391,7 +2421,19 @@ function OverworldState:tryHiddenObject(fx, fy)
|
||||
else
|
||||
-- one machine per visit is secretly lucky
|
||||
-- (wLuckySlotHiddenEventIndex, engine/slots/game_corner_slots.asm)
|
||||
Screens.push(Game, "SlotMachine", seatIndex == self.luckySlot)
|
||||
local lucky = seatIndex == self.luckySlot
|
||||
-- PromptUserToPlaySlots: engine/slots/slot_machine.asm:9-23
|
||||
Game.stack:push(TextBox.new(Game, txt._PlaySlotMachineText
|
||||
or romText(Game.data, "_PlaySlotMachineText",
|
||||
"A slot machine!\nWant to play?"), nil, {
|
||||
choice = function(yes)
|
||||
if not yes then return end
|
||||
self.emote = {
|
||||
npc = self.player, frames = 60, bubble = 3,
|
||||
onDone = function() Screens.push(Game, "SlotMachine", lucky) end,
|
||||
}
|
||||
end,
|
||||
}))
|
||||
end
|
||||
return true
|
||||
end
|
||||
@@ -3829,6 +3871,10 @@ function OverworldState:checkTrainerSight()
|
||||
if self.player.moving or self.engaging then return end
|
||||
if Game.stack:top() ~= self then return end
|
||||
local p = self.player
|
||||
-- a map contribution opts a trainer out of CheckFightingMapTrainers with
|
||||
-- `noSight = { TEXT_... = true }` -- home/trainers.asm:129
|
||||
local view = mapScripts.get and mapScripts.get(self.map.id)
|
||||
local noSight = view and view.noSight
|
||||
local cancelled = self.cancelledTrainerSight
|
||||
if cancelled and (cancelled.playerX ~= p.cellX
|
||||
or cancelled.playerY ~= p.cellY) then
|
||||
@@ -3842,7 +3888,7 @@ function OverworldState:checkTrainerSight()
|
||||
if d.trainerClass and not npc.moving
|
||||
and not (cancelled and cancelled.npcId == npc.id)
|
||||
and not self:trainerDefeated(npc)
|
||||
and not mapScripts.talkScript(self.map.id, d.text)
|
||||
and not (noSight and d.text and noSight[d.text])
|
||||
and trainerSpriteOnScreen(npc, p) then
|
||||
local header = Game.data:trainerHeader(self.map.def.label, d.index)
|
||||
local range = header and header.range or 0
|
||||
|
||||
@@ -182,6 +182,9 @@ function Player:update()
|
||||
if self.turnTimer > 0 then
|
||||
self.turnTimer = self.turnTimer - 1
|
||||
end
|
||||
if self.spinning then
|
||||
self.spinTimer = (self.spinTimer or 0) + 1
|
||||
end
|
||||
if self.spinFrames then
|
||||
self.spinFrames = self.spinFrames - 1
|
||||
if self.spinFrames <= 0 then
|
||||
@@ -261,11 +264,6 @@ local SPIN_ORDER = { "down", "left", "up", "right" }
|
||||
--
|
||||
-- The last return says the player is mid-ledge-hop, which is what the 2D
|
||||
-- path draws the ground shadow from and a 3D path turns into vertical lift.
|
||||
--
|
||||
-- This ADVANCES the surf-bob and spinner timers, so exactly one of pose()
|
||||
-- and draw() may run per frame -- and draw() is written in terms of pose()
|
||||
-- to keep that true by construction. (hopFrames counts down in
|
||||
-- Player:update, on the fixed step, so it is safe to read here.)
|
||||
function Player:pose()
|
||||
local py = self.py
|
||||
local hopping = false
|
||||
@@ -288,10 +286,8 @@ function Player:pose()
|
||||
-- the leg cadence
|
||||
local flip = math.floor((self.animClock or 0) / 16) % 2 == 1
|
||||
if self.spinning then
|
||||
-- spinner tiles whirl the sprite on its standing pose, one facing
|
||||
-- per frame (LoadSpinnerArrowTiles runs every OverworldLoop frame)
|
||||
self.spinTimer = (self.spinTimer or 0) + 1
|
||||
facing = SPIN_ORDER[self.spinTimer % 4 + 1]
|
||||
-- spinners.asm:1-11, home/overworld.asm:41-44, :268-272
|
||||
facing = SPIN_ORDER[math.floor((self.spinTimer or 0) / 2) % 4 + 1]
|
||||
phase, flip = 0, false
|
||||
-- teleport arrivals spin the sprite down into place
|
||||
-- (EnterMapAnim PlayerSpinWhileMovingDown)
|
||||
|
||||
@@ -187,6 +187,11 @@ function FieldMoves.bindEngineFlags(order)
|
||||
-- row, so every id from BUG_CONTEST_TIMER up shifts one.
|
||||
FieldMoves.BUG_CONTEST_FLAG = byName["ENGINE_BUG_CONTEST_TIMER"] or 16
|
||||
FieldMoves.BIKE_SHOP_CALL_FLAG = byName["ENGINE_BIKE_SHOP_CALL_ENABLED"] or 19
|
||||
-- pokecrystal constants/engine_flags.asm:66-92 vs pokegold :65-91
|
||||
for _, row in ipairs(FieldMoves.FLYPOINTS or {}) do
|
||||
row.goldFlag = row.goldFlag or row.flag
|
||||
row.flag = byName[row.name] or row.goldFlag
|
||||
end
|
||||
return flags
|
||||
end
|
||||
|
||||
@@ -475,33 +480,35 @@ end
|
||||
-- const_def count (0-based, ENGINE_RADIO_CARD is 0): the byte a town's own
|
||||
-- MAPCALLBACK_NEWMAP callback sets with `setflag` the first time you walk in,
|
||||
-- and what FieldMoves.hasVisitedSpawn below actually reads.
|
||||
-- Ids are pokegold's; bindEngineFlags rebinds by name (pokecrystal
|
||||
-- constants/engine_flags.asm:25 ENGINE_MOBILE_SYSTEM shifts them +1).
|
||||
FieldMoves.FLYPOINTS = {
|
||||
-- Johto
|
||||
{ landmark = "LANDMARK_NEW_BARK_TOWN", spawn = "SPAWN_NEW_BARK", flag = 64 },
|
||||
{ landmark = "LANDMARK_CHERRYGROVE_CITY", spawn = "SPAWN_CHERRYGROVE", flag = 65 },
|
||||
{ landmark = "LANDMARK_VIOLET_CITY", spawn = "SPAWN_VIOLET", flag = 66 },
|
||||
{ landmark = "LANDMARK_AZALEA_TOWN", spawn = "SPAWN_AZALEA", flag = 67 },
|
||||
{ landmark = "LANDMARK_GOLDENROD_CITY", spawn = "SPAWN_GOLDENROD", flag = 69 },
|
||||
{ landmark = "LANDMARK_ECRUTEAK_CITY", spawn = "SPAWN_ECRUTEAK", flag = 71 },
|
||||
{ landmark = "LANDMARK_OLIVINE_CITY", spawn = "SPAWN_OLIVINE", flag = 70 },
|
||||
{ landmark = "LANDMARK_CIANWOOD_CITY", spawn = "SPAWN_CIANWOOD", flag = 68 },
|
||||
{ landmark = "LANDMARK_MAHOGANY_TOWN", spawn = "SPAWN_MAHOGANY", flag = 72 },
|
||||
{ landmark = "LANDMARK_LAKE_OF_RAGE", spawn = "SPAWN_LAKE_OF_RAGE", flag = 73 },
|
||||
{ landmark = "LANDMARK_BLACKTHORN_CITY", spawn = "SPAWN_BLACKTHORN", flag = 74 },
|
||||
{ landmark = "LANDMARK_SILVER_CAVE", spawn = "SPAWN_MT_SILVER", flag = 75 },
|
||||
{ landmark = "LANDMARK_NEW_BARK_TOWN", spawn = "SPAWN_NEW_BARK", flag = 64, name = "ENGINE_FLYPOINT_NEW_BARK" },
|
||||
{ landmark = "LANDMARK_CHERRYGROVE_CITY", spawn = "SPAWN_CHERRYGROVE", flag = 65, name = "ENGINE_FLYPOINT_CHERRYGROVE" },
|
||||
{ landmark = "LANDMARK_VIOLET_CITY", spawn = "SPAWN_VIOLET", flag = 66, name = "ENGINE_FLYPOINT_VIOLET" },
|
||||
{ landmark = "LANDMARK_AZALEA_TOWN", spawn = "SPAWN_AZALEA", flag = 67, name = "ENGINE_FLYPOINT_AZALEA" },
|
||||
{ landmark = "LANDMARK_GOLDENROD_CITY", spawn = "SPAWN_GOLDENROD", flag = 69, name = "ENGINE_FLYPOINT_GOLDENROD" },
|
||||
{ landmark = "LANDMARK_ECRUTEAK_CITY", spawn = "SPAWN_ECRUTEAK", flag = 71, name = "ENGINE_FLYPOINT_ECRUTEAK" },
|
||||
{ landmark = "LANDMARK_OLIVINE_CITY", spawn = "SPAWN_OLIVINE", flag = 70, name = "ENGINE_FLYPOINT_OLIVINE" },
|
||||
{ landmark = "LANDMARK_CIANWOOD_CITY", spawn = "SPAWN_CIANWOOD", flag = 68, name = "ENGINE_FLYPOINT_CIANWOOD" },
|
||||
{ landmark = "LANDMARK_MAHOGANY_TOWN", spawn = "SPAWN_MAHOGANY", flag = 72, name = "ENGINE_FLYPOINT_MAHOGANY" },
|
||||
{ landmark = "LANDMARK_LAKE_OF_RAGE", spawn = "SPAWN_LAKE_OF_RAGE", flag = 73, name = "ENGINE_FLYPOINT_LAKE_OF_RAGE" },
|
||||
{ landmark = "LANDMARK_BLACKTHORN_CITY", spawn = "SPAWN_BLACKTHORN", flag = 74, name = "ENGINE_FLYPOINT_BLACKTHORN" },
|
||||
{ landmark = "LANDMARK_SILVER_CAVE", spawn = "SPAWN_MT_SILVER", flag = 75, name = "ENGINE_FLYPOINT_SILVER_CAVE" },
|
||||
-- Kanto
|
||||
{ landmark = "LANDMARK_PALLET_TOWN", spawn = "SPAWN_PALLET", flag = 52 },
|
||||
{ landmark = "LANDMARK_VIRIDIAN_CITY", spawn = "SPAWN_VIRIDIAN", flag = 53 },
|
||||
{ landmark = "LANDMARK_PEWTER_CITY", spawn = "SPAWN_PEWTER", flag = 54 },
|
||||
{ landmark = "LANDMARK_CERULEAN_CITY", spawn = "SPAWN_CERULEAN", flag = 55 },
|
||||
{ landmark = "LANDMARK_VERMILION_CITY", spawn = "SPAWN_VERMILION", flag = 57 },
|
||||
{ landmark = "LANDMARK_ROCK_TUNNEL", spawn = "SPAWN_ROCK_TUNNEL", flag = 56 },
|
||||
{ landmark = "LANDMARK_LAVENDER_TOWN", spawn = "SPAWN_LAVENDER", flag = 58 },
|
||||
{ landmark = "LANDMARK_CELADON_CITY", spawn = "SPAWN_CELADON", flag = 60 },
|
||||
{ landmark = "LANDMARK_SAFFRON_CITY", spawn = "SPAWN_SAFFRON", flag = 59 },
|
||||
{ landmark = "LANDMARK_FUCHSIA_CITY", spawn = "SPAWN_FUCHSIA", flag = 61 },
|
||||
{ landmark = "LANDMARK_CINNABAR_ISLAND", spawn = "SPAWN_CINNABAR", flag = 62 },
|
||||
{ landmark = "LANDMARK_INDIGO_PLATEAU", spawn = "SPAWN_INDIGO", flag = 63 },
|
||||
{ landmark = "LANDMARK_PALLET_TOWN", spawn = "SPAWN_PALLET", flag = 52, name = "ENGINE_FLYPOINT_PALLET" },
|
||||
{ landmark = "LANDMARK_VIRIDIAN_CITY", spawn = "SPAWN_VIRIDIAN", flag = 53, name = "ENGINE_FLYPOINT_VIRIDIAN" },
|
||||
{ landmark = "LANDMARK_PEWTER_CITY", spawn = "SPAWN_PEWTER", flag = 54, name = "ENGINE_FLYPOINT_PEWTER" },
|
||||
{ landmark = "LANDMARK_CERULEAN_CITY", spawn = "SPAWN_CERULEAN", flag = 55, name = "ENGINE_FLYPOINT_CERULEAN" },
|
||||
{ landmark = "LANDMARK_VERMILION_CITY", spawn = "SPAWN_VERMILION", flag = 57, name = "ENGINE_FLYPOINT_VERMILION" },
|
||||
{ landmark = "LANDMARK_ROCK_TUNNEL", spawn = "SPAWN_ROCK_TUNNEL", flag = 56, name = "ENGINE_FLYPOINT_ROCK_TUNNEL" },
|
||||
{ landmark = "LANDMARK_LAVENDER_TOWN", spawn = "SPAWN_LAVENDER", flag = 58, name = "ENGINE_FLYPOINT_LAVENDER" },
|
||||
{ landmark = "LANDMARK_CELADON_CITY", spawn = "SPAWN_CELADON", flag = 60, name = "ENGINE_FLYPOINT_CELADON" },
|
||||
{ landmark = "LANDMARK_SAFFRON_CITY", spawn = "SPAWN_SAFFRON", flag = 59, name = "ENGINE_FLYPOINT_SAFFRON" },
|
||||
{ landmark = "LANDMARK_FUCHSIA_CITY", spawn = "SPAWN_FUCHSIA", flag = 61, name = "ENGINE_FLYPOINT_FUCHSIA" },
|
||||
{ landmark = "LANDMARK_CINNABAR_ISLAND", spawn = "SPAWN_CINNABAR", flag = 62, name = "ENGINE_FLYPOINT_CINNABAR" },
|
||||
{ landmark = "LANDMARK_INDIGO_PLATEAU", spawn = "SPAWN_INDIGO", flag = 63, name = "ENGINE_FLYPOINT_INDIGO_PLATEAU" },
|
||||
}
|
||||
|
||||
-- spawn -> row, built once, so hasVisitedSpawn below does not walk the whole
|
||||
|
||||
+27
-11
@@ -929,8 +929,8 @@ function World:load()
|
||||
-- `hold` is the same story one argument along: the cart `pause` a held box
|
||||
-- stands through (FindItemInBallScript's `pause 60`). Dropping it made the
|
||||
-- box hand back the instant it finished typing.
|
||||
showText = function(body, onDone, stay, hold)
|
||||
self:showText(body, onDone, stay, hold)
|
||||
showText = function(body, onDone, stay, hold, sfxWait)
|
||||
self:showText(body, onDone, stay, hold, sfxWait)
|
||||
end,
|
||||
facePlayer = function()
|
||||
if self.talkNpc and self.player then
|
||||
@@ -4224,7 +4224,7 @@ function World:tryWildEncounter()
|
||||
-- That is what keeps the Ruins chambers empty until a wall has been solved.
|
||||
local monOpts = nil
|
||||
if roll.species == Unown.SPECIES then
|
||||
local flags = self:engineFlags()
|
||||
local flags = self:unownUnlockFlags()
|
||||
if not Unown.anyUnlocked(flags) then return false end
|
||||
-- LoadEnemyMon's .GenerateDVs loop rerolls until CheckUnownLetter clears
|
||||
-- the form, so a chamber only ever produces letters its own puzzle
|
||||
@@ -4846,6 +4846,19 @@ function World:engineFlagId(name, goldId)
|
||||
return ids[name] or goldId
|
||||
end
|
||||
|
||||
-- Unown.UNLOCK_SETS keys the flags by pokegold's ids; Crystal's are one
|
||||
-- higher (../pokecrystal/constants/engine_flags.asm:57-60 vs ../pokegold:56-59).
|
||||
function World:unownUnlockFlags()
|
||||
local engine = self:engineFlags()
|
||||
local out = {}
|
||||
for _, set in ipairs(Unown.UNLOCK_SETS) do
|
||||
if engine[self:engineFlagId(set.name, set.flag)] then
|
||||
out[set.flag] = true
|
||||
end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
-- wBikeFlags' three bits are ENGINE_* ids like any other flag, so the map
|
||||
-- callbacks that set them (Route16AlwaysOnBikeCallback,
|
||||
-- Route17AlwaysOnBikeCallback) already land on save.engineFlags.
|
||||
@@ -5338,7 +5351,7 @@ function World:sweetScentEncounter()
|
||||
-- stays empty for SWEET SCENT too.
|
||||
local monOpts = nil
|
||||
if roll.species == Unown.SPECIES then
|
||||
local flags = self:engineFlags()
|
||||
local flags = self:unownUnlockFlags()
|
||||
if not Unown.anyUnlocked(flags) then return false end
|
||||
monOpts = { dvs = Unown.wildDVs(flags, Mon.randomDVs) }
|
||||
end
|
||||
@@ -5618,15 +5631,16 @@ end
|
||||
function World:runWhirlpool(result)
|
||||
self:setNickname(result.mon)
|
||||
self:showText(Strings(result.text), function()
|
||||
self:replaceBlock(result.blockIndex, result.replacement)
|
||||
self:playWhirlpoolSound()
|
||||
self:playWhirlpoolSound(result.blockIndex, result.replacement)
|
||||
end)
|
||||
end
|
||||
|
||||
-- PlayWhirlpoolSound is WaitSFX, SFX_SURF, WaitSFX, never a bare PlaySFX
|
||||
-- -- engine/events/field_moves.asm:5-10 (#1717)
|
||||
function World:playWhirlpoolSound()
|
||||
self.fieldMove = { phase = "whirlpoolsfx", waiting = true, left = 180 }
|
||||
-- -- engine/events/field_moves.asm:5-10 (#1717). The block swap lands after
|
||||
-- it -- engine/events/overworld.asm:1157-1164
|
||||
function World:playWhirlpoolSound(blockIndex, replacement)
|
||||
self.fieldMove = { phase = "whirlpoolsfx", waiting = true, left = 180,
|
||||
blockIndex = blockIndex, replacement = replacement }
|
||||
end
|
||||
|
||||
-- PlayerMovementPointers' .force_turn arm and the Script_ForcedMovement it
|
||||
@@ -6193,6 +6207,8 @@ function World:updateFieldMove()
|
||||
end
|
||||
if Sound.sfxBusy() and st.left > 0 then return end
|
||||
self.fieldMove = nil
|
||||
-- engine/events/overworld.asm:1163-1164
|
||||
if st.blockIndex then self:replaceBlock(st.blockIndex, st.replacement) end
|
||||
return
|
||||
end
|
||||
if st.phase == "strength" then
|
||||
@@ -7277,7 +7293,7 @@ end
|
||||
-- stack the overworld and the VM under it do not tick at all -- so the wait has
|
||||
-- to be counted by the box itself. Frames, already doubled by Vm:pauseFrames
|
||||
-- the way Script_pause's `ld c, 2 / call DelayFrames` doubles the operand.
|
||||
function World:showText(body, onDone, stay, hold)
|
||||
function World:showText(body, onDone, stay, hold, sfxWait)
|
||||
local game = self.game
|
||||
-- The box a PREVIOUS `stay` left standing (TextBox's contract is "whoever
|
||||
-- pushed it owns the pop", src/render/TextBox.lua:40). `yesorno` consumes it
|
||||
@@ -7327,7 +7343,7 @@ function World:showText(body, onDone, stay, hold)
|
||||
game.stack:push(TextBox.new(game, body, function()
|
||||
self.textbox = nil
|
||||
if onDone then onDone() end
|
||||
end))
|
||||
end, sfxWait and { sfxWait = true } or nil))
|
||||
end
|
||||
|
||||
function World:pooledNpc(mapId, obj)
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
-- Manual check of the FLY picker's bird marker and the departure fade (#1840).
|
||||
-- LoadTownMap_Fly overwrites the cursor tiles with BirdSprite and marks the
|
||||
-- destination with it, with no blink (engine/items/town_map.asm:146-149,
|
||||
-- 177-179, 190-198); _LeaveMapAnim stops the music with fade control $4 before
|
||||
-- the bird appears (engine/overworld/player_animations.asm:123, home/overworld.asm:772).
|
||||
-- POKEPORT_DRIVER=tests/drivers/fly_bird_bug1840_test.lua POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Screens = require("src.ui.Screens")
|
||||
local Music = require("src.core.Music")
|
||||
local TownMap = require("src.ui.TownMap")
|
||||
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
|
||||
local function check(label, ok)
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
local vol = game.save.options and game.save.options.musicVol
|
||||
if vol == 0 then
|
||||
U.log("music volume is 0 in options; raise it or the fade is inaudible")
|
||||
end
|
||||
if (game.save.options and game.save.options.sfxVol) == 0 then
|
||||
U.log("sfx volume is 0 in options; the bird's SFX_FLY will be silent too")
|
||||
end
|
||||
|
||||
-- BuildFlyLocationsList only offers visited towns
|
||||
game.save.visited = game.save.visited or {}
|
||||
for _, town in ipairs({ "PALLET_TOWN", "VIRIDIAN_CITY", "PEWTER_CITY",
|
||||
"CERULEAN_CITY", "CELADON_CITY" }) do
|
||||
game.save.visited[town] = true
|
||||
end
|
||||
|
||||
U.teleport(game, "PALLET_TOWN", 5, 6, "down")
|
||||
U.wait(30)
|
||||
|
||||
local flown = nil
|
||||
Screens.push(game, "TownMap", { fly = true,
|
||||
onFly = function(mapId) flown = mapId end })
|
||||
U.wait(20)
|
||||
local map = game.stack:top()
|
||||
check("the FLY picker opened", getmetatable(map) == TownMap)
|
||||
check("it is in fly mode", map.fly == true)
|
||||
check("the destination marker is the bird sheet", map.birdSheet ~= nil)
|
||||
check("the blinking cursor art is still loaded for the plain viewer",
|
||||
map.bg == nil or map.bg.cursor ~= nil)
|
||||
U.log("fly destinations:", #(map.locs or {}))
|
||||
U.shot(game, SHOT_DIR .. "/bug1840_flypicker.png")
|
||||
|
||||
-- the marker must not blink: sample the same screen a few frames apart
|
||||
map.blink = 0
|
||||
U.wait(2)
|
||||
U.shot(game, SHOT_DIR .. "/bug1840_flypicker_a.png")
|
||||
map.blink = 30
|
||||
U.wait(2)
|
||||
U.shot(game, SHOT_DIR .. "/bug1840_flypicker_b.png")
|
||||
U.log("compare", SHOT_DIR .. "/bug1840_flypicker_a.png",
|
||||
"with _b.png: they must be identical")
|
||||
|
||||
U.tap(game, "up")
|
||||
U.wait(20)
|
||||
U.tap(game, "a")
|
||||
U.wait(20)
|
||||
check("A picks a destination", flown ~= nil)
|
||||
U.log("flying to", tostring(flown))
|
||||
|
||||
local faded = false
|
||||
local realFade = Music.fadeOut
|
||||
Music.fadeOut = function(control, pending)
|
||||
faded = control
|
||||
return realFade(control, pending)
|
||||
end
|
||||
local ow = game.overworld
|
||||
if flown then ow:flyTo(flown) end
|
||||
U.wait(1)
|
||||
check("departure asks the music to fade", faded ~= false)
|
||||
check("with StopMusic's control byte $4", faded == 4)
|
||||
check("the world holds during the fade", ow.flyFade ~= nil)
|
||||
check("and the bird is not on screen yet", ow.flyAnim == nil)
|
||||
U.shot(game, SHOT_DIR .. "/bug1840_fading.png")
|
||||
|
||||
local flapping = false
|
||||
for _ = 1, 120 do
|
||||
if ow.flyAnim then flapping = true break end
|
||||
U.wait(1)
|
||||
end
|
||||
check("the bird appears once the fade is done", flapping)
|
||||
Music.fadeOut = realFade
|
||||
U.wait(6)
|
||||
U.shot(game, SHOT_DIR .. "/bug1840_bird.png")
|
||||
U.log("captured", SHOT_DIR .. "/bug1840_{flypicker,fading,bird}.png")
|
||||
|
||||
U.log("Right: the picker marks the town with a small bird that holds still,")
|
||||
U.log("and pressing A drops the map theme to silence before the bird flaps.")
|
||||
U.log("The near-miss is a square bracket cursor blinking on the town, or the")
|
||||
U.log("theme still playing under the flap and cutting off at the warp.")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,129 @@
|
||||
-- Eye/ear check on the Pokemon Tower 6F ghost scene (#1849): entry wipe,
|
||||
-- the "GHOST appeared!" wording, the SILPH SCOPE unveil colours, and the
|
||||
-- cry landing on the CUBONE's-mother line.
|
||||
-- pokered engine/battle/core.asm:6695-6702, data/text/text_2.asm:1251-1255,
|
||||
-- engine/battle/ghost_marowak_anim.asm:3-5,77, scripts/PokemonTower6F.asm:137-148.
|
||||
-- POKEPORT_DRIVER=tests/drivers/ghost_marowak_bug1849_test.lua POKEPORT_IDENTITY=bug1849 POKEPORT_TOUCH=0 love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
|
||||
-- PokemonTower6FMarowakCoords (scripts/PokemonTower6F.asm:43-45) is
|
||||
-- (10, 16); the 7F stairs at (9, 16) sit right beside it, so the cell
|
||||
-- north of the trigger is open floor.
|
||||
local MAP = "POKEMON_TOWER_6F"
|
||||
local TRIGGER = { x = 10, y = 16 }
|
||||
local STAND = { x = 10, y = 15, facing = "down" }
|
||||
|
||||
local function check(label, ok)
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
local opts = game.save.options or {}
|
||||
local sfxVol = opts.sfxVol or 7
|
||||
if sfxVol == 0 then
|
||||
U.log("FAIL sfx volume is 0: the MAROWAK cry is the audible half of this")
|
||||
U.log(" check and will not be heard. Set SFX to 7 in OPTION first.")
|
||||
end
|
||||
check(("sfx volume %d"):format(sfxVol), sfxVol > 0)
|
||||
|
||||
game.save.party = { Pokemon.new(game.data, "CHARMANDER", 40) }
|
||||
game.save.inventory = game.save.inventory or {}
|
||||
game.save.inventory.SILPH_SCOPE = 1
|
||||
game.save.flags = game.save.flags or {}
|
||||
game.save.flags.EVENT_BEAT_GHOST_MAROWAK = nil
|
||||
check("the SILPH SCOPE is in the bag (this is the unveil path)",
|
||||
(game.save.inventory.SILPH_SCOPE or 0) > 0)
|
||||
|
||||
U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing)
|
||||
U.wait(20)
|
||||
local ow = game.overworld
|
||||
check("standing on " .. MAP, ow.map.id == MAP)
|
||||
if not ow.map:isWalkableCell(STAND.x, STAND.y) then
|
||||
-- a map edit moved the floor: take any free neighbour of the trigger
|
||||
for _, d in ipairs({ { 0, -1 }, { -1, 0 }, { 1, 0 }, { 0, 1 } }) do
|
||||
local cx, cy = TRIGGER.x + d[1], TRIGGER.y + d[2]
|
||||
if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then
|
||||
local facing = (d[2] == -1 and "down") or (d[2] == 1 and "up")
|
||||
or (d[1] == -1 and "right") or "left"
|
||||
U.log(("(%d, %d) is blocked, standing on"):format(STAND.x, STAND.y),
|
||||
cx, cy, "facing", facing)
|
||||
U.teleport(game, MAP, cx, cy, facing)
|
||||
U.wait(20)
|
||||
ow = game.overworld
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- walk onto the trigger cell ourselves; the Be gone... box opens on arrival
|
||||
local BattleTransition = require("src.render.BattleTransition")
|
||||
local sawWipe = false
|
||||
local battle
|
||||
for _ = 1, 240 do
|
||||
U.hold(game, "down", 2)
|
||||
U.tap(game, "a")
|
||||
U.wait(4)
|
||||
local top = game.stack:top()
|
||||
if getmetatable(top) == BattleTransition then
|
||||
sawWipe = true
|
||||
U.shot(game, DIR .. "/bug1849_entry_wipe.png")
|
||||
end
|
||||
if getmetatable(top) == BattleState then
|
||||
battle = top
|
||||
break
|
||||
end
|
||||
end
|
||||
check("the ghost battle started", battle ~= nil)
|
||||
check("an entry wipe ran before it (pushBattle, not a bare stack push)",
|
||||
sawWipe)
|
||||
|
||||
if battle then
|
||||
check("the foe is disguised as the GHOST", battle.enemy.name == "GHOST")
|
||||
U.log("intro line reads:", (tostring(battle.introText):gsub("\n", " / ")))
|
||||
check("the intro line is \"GHOST appeared!\", with no article",
|
||||
battle.introText == "GHOST\nappeared!")
|
||||
check("the scope unveil is queued", battle.scopeReveal == true)
|
||||
|
||||
-- ride the unveil: mash A through the boxes and shoot the anim
|
||||
local shotFlash, shotFade, shotDone = false, false, false
|
||||
for _ = 1, 900 do
|
||||
U.tap(game, "a")
|
||||
U.wait(2)
|
||||
local gr = battle.ghostReveal
|
||||
if gr then
|
||||
if not shotFlash and gr.t >= 20 then
|
||||
shotFlash = true
|
||||
U.shot(game, DIR .. "/bug1849_unveil_flash.png")
|
||||
end
|
||||
if not shotFade and gr.t >= 100 then
|
||||
shotFade = true
|
||||
U.shot(game, DIR .. "/bug1849_unveil_fadein.png")
|
||||
end
|
||||
elseif shotFade and not shotDone then
|
||||
shotDone = true
|
||||
U.shot(game, DIR .. "/bug1849_unveiled.png")
|
||||
break
|
||||
end
|
||||
end
|
||||
check("the unveil animation played", shotFlash and shotFade)
|
||||
check("the real name came back: " .. tostring(battle.enemy.name),
|
||||
battle.enemy.name ~= "GHOST")
|
||||
end
|
||||
|
||||
U.log("Shots are in " .. DIR .. ": bug1849_entry_wipe, _unveil_flash,")
|
||||
U.log("_unveil_fadein, _unveiled.png. In OG RED mode MarowakAnim draws the")
|
||||
U.log("pic as a sprite under OBJ palette 1, so the flashing ghost and the")
|
||||
U.log("fading-in MAROWAK are green (pink in Blue) and only settle back to")
|
||||
U.log("the red background palette once the animation ends. The near-miss is")
|
||||
U.log("a pic that stays red the whole way through.")
|
||||
U.log("Beat the MAROWAK and listen: the cry belongs on the CUBONE's-mother")
|
||||
U.log("box, not on the one after it.")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,131 @@
|
||||
-- #1860: jingles cut or dropped by the text blip. GiveItemScript
|
||||
-- (pokegold engine/overworld/scripting.asm:441-449), Oak's rating
|
||||
-- (engine/events/prof_oaks_pc.asm:14-21), bug contest judging
|
||||
-- (engine/events/bug_contest/judging.asm:29-32).
|
||||
-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold POKEPORT_TOUCH=0 \
|
||||
-- POKEPORT_DRIVER=tests/drivers/gold_jingle_bug1860_test.lua love .
|
||||
local U = require("tests.drivers.util")
|
||||
local Sound = require("src.core.Sound")
|
||||
|
||||
return function(game)
|
||||
U.wait(45)
|
||||
local world = game.world
|
||||
assert(world and world.map and world.vm, "gold world did not boot")
|
||||
|
||||
local opts = game.save.options or {}
|
||||
if (opts.sfxVol or 7) == 0 then
|
||||
U.log("WARNING: options.sfxVol is 0 -- every jingle below will be")
|
||||
U.log("WARNING: silent; raise SFX volume before judging this by ear")
|
||||
end
|
||||
|
||||
local results = {}
|
||||
local function check(label, ok)
|
||||
results[#results + 1] = (ok and "PASS " or "FAIL ") .. label
|
||||
end
|
||||
|
||||
local function specialId(name)
|
||||
for index, entry in ipairs(world.vm.specialOrder or {}) do
|
||||
if entry == name then return index - 1 end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- World:specialSound resolves the numeric index (itemIdByIndex), and only
|
||||
-- a TM_HM-pocket item rings Sfx_GetTm.
|
||||
local tm
|
||||
for _, def in pairs(game.data.items or {}) do
|
||||
if type(def) == "table" and def.pocket == "TM_HM" and def.index then
|
||||
tm = def.index
|
||||
break
|
||||
end
|
||||
end
|
||||
check("cache names a TM_HM-pocket item", tm ~= nil)
|
||||
|
||||
-- Runs one scripted moment while mashing A every 4th frame the whole way.
|
||||
-- everBusy: a gated sfx started at all (the drop half of the bug).
|
||||
-- maxRun: longest unbroken stretch it kept sounding (the cut half).
|
||||
-- popEarly: a box with the sfx hold popped while the jingle still rang.
|
||||
local function runMoment(label, script, minRun)
|
||||
U.wait(30)
|
||||
world.vm:start(script)
|
||||
U.wait(2)
|
||||
local everBusy, maxRun, run = false, 0, 0
|
||||
local sawHeld, popEarly = false, false
|
||||
local pressIn = 4
|
||||
for _ = 1, 1500 do
|
||||
local busy = Sound.sfxBusy()
|
||||
if busy then
|
||||
everBusy = true
|
||||
run = run + 1
|
||||
if run > maxRun then maxRun = run end
|
||||
else
|
||||
run = 0
|
||||
end
|
||||
local top = game.stack:top()
|
||||
if top and top.sfxWait and busy then sawHeld = true end
|
||||
pressIn = pressIn - 1
|
||||
if pressIn <= 0 then
|
||||
pressIn = 4
|
||||
game.input.pressQueue[#game.input.pressQueue + 1] = "a"
|
||||
game.input.state.a = true
|
||||
U.wait(1)
|
||||
game.input.state.a = false
|
||||
if top and top.sfxWait and busy and game.stack:top() ~= top then
|
||||
popEarly = true
|
||||
end
|
||||
else
|
||||
U.wait(1)
|
||||
end
|
||||
if not world:busy() and not game.stack:top() then break end
|
||||
end
|
||||
check(label .. ": a jingle started (not dropped by the blip)", everBusy)
|
||||
check(("%s: it survived mashed A for %d frames (want >= %d)")
|
||||
:format(label, maxRun, minRun), maxRun >= minRun)
|
||||
check(label .. ": the held box refused A while it rang",
|
||||
sawHeld and not popEarly)
|
||||
end
|
||||
|
||||
if tm then
|
||||
runMoment("verbosegiveitem TM", {
|
||||
{ op = "opentext" },
|
||||
{ op = "verbosegiveitem", args = { tm, 1 } },
|
||||
{ op = "closetext" },
|
||||
{ op = "end" },
|
||||
}, 60)
|
||||
end
|
||||
|
||||
local oak = specialId("ProfOaksPCBoot")
|
||||
check("specialOrder names ProfOaksPCBoot", oak ~= nil)
|
||||
if oak then
|
||||
game.save.pokedex = game.save.pokedex or { seen = {}, caught = {} }
|
||||
game.save.pokedex.caught = {}
|
||||
for i = 1, 150 do game.save.pokedex.caught["DEX_SEED_" .. i] = true end
|
||||
runMoment("Oak's dex rating fanfare", {
|
||||
{ op = "opentext" },
|
||||
{ op = "special", id = oak },
|
||||
{ op = "closetext" },
|
||||
{ op = "end" },
|
||||
}, 40)
|
||||
end
|
||||
|
||||
local judging = specialId("BugContestJudging")
|
||||
check("specialOrder names BugContestJudging", judging ~= nil)
|
||||
if judging then
|
||||
runMoment("bug contest place fanfares", {
|
||||
{ op = "opentext" },
|
||||
{ op = "special", id = judging },
|
||||
{ op = "closetext" },
|
||||
{ op = "end" },
|
||||
}, 30)
|
||||
end
|
||||
|
||||
for _, line in ipairs(results) do U.log(line) end
|
||||
|
||||
U.log("Right sounds like: the TM jingle, the dex-rating fanfare and each")
|
||||
U.log("place fanfare play out in full even while A is mashed; the box under")
|
||||
U.log("each one only closes once its jingle has finished ringing.")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,156 @@
|
||||
-- #1858: stats-screen strings stamped white paper over the tinted lower half.
|
||||
-- Rows 8-17 stay on BG palette 0, whose colour 0 LoadStatsScreenPals sets to
|
||||
-- the page tint, so a glyph's blank pixels show that tint -- engine/gfx/
|
||||
-- color.asm:342-359, engine/gfx/cgb_layouts.asm:167-192. Never POKEPORT_SPEED
|
||||
-- here: the page redraw and its cry are being judged in order.
|
||||
-- POKEPORT_IDENTITY=c10-gold POKEPORT_GAME=gold POKEPORT_TOUCH=0 POKEPORT_DRIVER=tests/drivers/gold_stats_text_paper_bug1858_test.lua POKEPORT_SHOT_DIR=/tmp/gold-bug1858 love .
|
||||
local U = require("tests.drivers.util")
|
||||
|
||||
local GbcPalette = require("src.render.GbcPalette")
|
||||
local Mon = require("src.battle.gen2.Mon")
|
||||
local SummaryMenu = require("src.ui.gen2.SummaryMenu")
|
||||
|
||||
-- The rows the lower half prints on: EXP POINTS / LEVEL UP / TO on the pink
|
||||
-- page, ITEM and the move list on green, <ID>№. and OT/ on blue.
|
||||
local TEXT_ROWS = {
|
||||
[SummaryMenu.PINK_PAGE] = {
|
||||
{ "the EXP POINTS row", 80, 72, 159, 79 },
|
||||
{ "the STATUS/ row", 0, 96, 55, 103 },
|
||||
{ "the LEVEL UP row", 80, 96, 159, 103 },
|
||||
},
|
||||
[SummaryMenu.GREEN_PAGE] = {
|
||||
{ "the ITEM row", 0, 64, 159, 71 },
|
||||
{ "the first move name", 64, 80, 159, 87 },
|
||||
{ "the first PP row", 96, 88, 159, 95 },
|
||||
},
|
||||
[SummaryMenu.BLUE_PAGE] = {
|
||||
{ "the ID No. row", 0, 72, 79, 79 },
|
||||
{ "the OT/ row", 0, 96, 79, 103 },
|
||||
{ "the ATTACK row", 88, 64, 159, 71 },
|
||||
},
|
||||
}
|
||||
|
||||
return function(game)
|
||||
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-bug1858"
|
||||
local fails, lines = 0, {}
|
||||
local function claim(ok, text)
|
||||
if not ok then fails = fails + 1 end
|
||||
lines[#lines + 1] = (ok and "PASS " or "FAIL ") .. text
|
||||
return ok
|
||||
end
|
||||
local function tap(button)
|
||||
U.tap(game, button)
|
||||
U.wait(4)
|
||||
end
|
||||
local function top() return game.stack:top() end
|
||||
|
||||
U.wait(45)
|
||||
if not (game.world and game.world.map) then
|
||||
U.log("FAIL the gold world never booted, nothing to open a summary over")
|
||||
while true do coroutine.yield() end
|
||||
end
|
||||
|
||||
local save = game.save
|
||||
save.player.name = "GOLD"
|
||||
save.party = { Mon.new(game.data, "CYNDAQUIL", 22),
|
||||
Mon.new(game.data, "TOTODILE", 18) }
|
||||
local lead = save.party[1]
|
||||
lead.item = "BERRY"
|
||||
lead.hp = math.max(1, math.floor((lead.maxHp or 1) * 0.45))
|
||||
claim(GbcPalette.available(), "the GBC shade-remap shader compiled")
|
||||
game.options = game.options or {}
|
||||
game.options.color = "gbc"
|
||||
save.options = game.options
|
||||
GbcPalette.applyOptions(game.options)
|
||||
claim(GbcPalette.mode == "gbc", "COLOR is GBC for the run")
|
||||
|
||||
-- The summary draws at GB coordinates, so a 160x144 canvas at scale 1 makes
|
||||
-- a GB pixel a pixel and the paper countable.
|
||||
local function frameOf(screen)
|
||||
local G = love.graphics
|
||||
local canvas = G.newCanvas(160, 144)
|
||||
G.setCanvas(canvas)
|
||||
G.clear(0, 0, 0, 1)
|
||||
G.setColor(1, 1, 1, 1)
|
||||
screen:draw()
|
||||
G.setCanvas()
|
||||
G.setColor(1, 1, 1, 1)
|
||||
return canvas:newImageData()
|
||||
end
|
||||
|
||||
local function whitePct(img, rect)
|
||||
local white, total = 0, 0
|
||||
for y = rect[3], rect[5] do
|
||||
for x = rect[2], rect[4] do
|
||||
local r, g, b = img:getPixel(x, y)
|
||||
total = total + 1
|
||||
if r * 255 > 250 and g * 255 > 250 and b * 255 > 250 then
|
||||
white = white + 1
|
||||
end
|
||||
end
|
||||
end
|
||||
return white / total * 100
|
||||
end
|
||||
|
||||
tap("start")
|
||||
local menu = top()
|
||||
if not claim(menu and menu.screenId == "Gen2StartMenu",
|
||||
"START opened the menu") then
|
||||
for _, line in ipairs(lines) do U.log(line) end
|
||||
while true do coroutine.yield() end
|
||||
end
|
||||
for _ = 1, 10 do
|
||||
if menu.list:current().value == "pokemon" then break end
|
||||
tap("down")
|
||||
end
|
||||
tap("a")
|
||||
local party = top()
|
||||
if not claim(party and party.screenId == "Gen2PartyMenu",
|
||||
"that opened the party list") then
|
||||
for _, line in ipairs(lines) do U.log(line) end
|
||||
while true do coroutine.yield() end
|
||||
end
|
||||
tap("a")
|
||||
tap("a")
|
||||
local summary = top()
|
||||
if not claim(summary and summary.screenId == "Gen2SummaryMenu",
|
||||
"STATS opened the summary") then
|
||||
for _, line in ipairs(lines) do U.log(line) end
|
||||
while true do coroutine.yield() end
|
||||
end
|
||||
|
||||
local NAMES = { "pink", "green", "blue" }
|
||||
local function inspect(shot)
|
||||
local img = frameOf(summary)
|
||||
local page = summary.page
|
||||
for _, rect in ipairs(TEXT_ROWS[page] or {}) do
|
||||
local pct = whitePct(img, rect)
|
||||
U.log((" %-22s %5.1f%% white"):format(rect[1], pct))
|
||||
claim(pct <= 1, ("%s (%s page) has no white paper behind it"):format(
|
||||
rect[1], NAMES[page] or "?"))
|
||||
end
|
||||
U.shot(game, ("%s/%s.png"):format(out, shot))
|
||||
end
|
||||
|
||||
inspect("01-pink-page")
|
||||
tap("right")
|
||||
claim(summary.page == SummaryMenu.GREEN_PAGE, "right reached the green page")
|
||||
inspect("02-green-page")
|
||||
tap("right")
|
||||
claim(summary.page == SummaryMenu.BLUE_PAGE, "right reached the blue page")
|
||||
inspect("03-blue-page")
|
||||
tap("right")
|
||||
|
||||
for _, line in ipairs(lines) do U.log(line) end
|
||||
U.log(("%d checks, %d failed"):format(#lines, fails))
|
||||
if fails > 0 then
|
||||
U.log("a FAIL above means the run is not showing what it claims to")
|
||||
end
|
||||
U.log("what right looks like: everything under the rule is one flat wash of")
|
||||
U.log("the page colour, with the letters and numbers sitting straight on it.")
|
||||
U.log("a white box the width of each string is the bug; a white box behind")
|
||||
U.log("only the numbers, or only the upper half tinted, is a half fix.")
|
||||
U.log("left and right turn the page; the pad is yours.")
|
||||
|
||||
while true do coroutine.yield() end
|
||||
end
|
||||
@@ -0,0 +1,141 @@
|
||||
-- #1862: the whirlpool must stay on screen for the whole surf wash.
|
||||
-- DisappearWhirlpool blanks hBGMapMode before the redraw, plays the sound and
|
||||
-- only then buffers the screen -- engine/events/overworld.asm:1150-1165,
|
||||
-- engine/events/field_moves.asm:5-10. No POKEPORT_SPEED: audio runs on its
|
||||
-- own real-time accumulator and the ordering is the whole point.
|
||||
-- POKEPORT_IDENTITY=c10-gold POKEPORT_GAME=gold POKEPORT_TOUCH=0 POKEPORT_DRIVER=tests/drivers/gold_whirlpool_block_bug1862_test.lua POKEPORT_SHOT_DIR=/tmp/gold-bug1862 love .
|
||||
local U = require("tests.drivers.util")
|
||||
|
||||
local FieldMoves = require("src.world.gen2.FieldMoves")
|
||||
local Mon = require("src.battle.gen2.Mon")
|
||||
local Permissions = require("src.world.gen2.Permissions")
|
||||
|
||||
-- data/generated/maps.lua, ROUTE_41: whirlpools at (22,12), (42,24), (6,30)
|
||||
-- and (28,48). Stand one cell north of the first and face it.
|
||||
local MAP = "ROUTE_41"
|
||||
local WHIRL = { x = 22, y = 12 }
|
||||
|
||||
return function(game)
|
||||
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-bug1862"
|
||||
local fails, lines = 0, {}
|
||||
local function claim(ok, text)
|
||||
if not ok then fails = fails + 1 end
|
||||
lines[#lines + 1] = (ok and "PASS " or "FAIL ") .. text
|
||||
return ok
|
||||
end
|
||||
local function report()
|
||||
for _, line in ipairs(lines) do U.log(line) end
|
||||
U.log(("%d checks, %d failed"):format(#lines, fails))
|
||||
end
|
||||
|
||||
U.wait(45)
|
||||
local world = game.world
|
||||
if not (world and world.map) then
|
||||
U.log("FAIL the gold world never booted, nothing to drain")
|
||||
while true do coroutine.yield() end
|
||||
end
|
||||
|
||||
local vol = game.save.options and game.save.options.sfxVol
|
||||
claim(vol ~= 0, ("SFX VOL is %s"):format(tostring(vol)))
|
||||
if vol == 0 then
|
||||
U.log("SFX VOL is ZERO, so the wash this is timed against is inaudible;")
|
||||
U.log("turn it up in OPTION before judging anything by ear.")
|
||||
end
|
||||
|
||||
local badges = game.save.player.badges or {}
|
||||
game.save.player.badges = badges
|
||||
for _, badge in pairs(FieldMoves.BADGE) do badges[badge] = true end
|
||||
local swimmer = Mon.new(game.data, "LAPRAS", 30,
|
||||
{ moves = { { id = "SURF" }, { id = "WHIRLPOOL" } } })
|
||||
claim(swimmer ~= nil, "a LAPRAS that knows SURF and WHIRLPOOL")
|
||||
game.save.party = { swimmer }
|
||||
|
||||
world:applyPlayerState(FieldMoves.PLAYER_SURF)
|
||||
world:setMap(MAP, WHIRL.x, WHIRL.y - 1, "down")
|
||||
U.wait(15)
|
||||
world.noWildEncounters = true
|
||||
|
||||
local ctx = world:fieldContext()
|
||||
local facing = Permissions.isWhirlpool(ctx.facingColl)
|
||||
if not facing then
|
||||
-- A re-import moved it: take any whirlpool with open water above it.
|
||||
local def = world.maps[MAP]
|
||||
for y = 1, (def.height or 0) * 2 - 1 do
|
||||
for x = 0, (def.width or 0) * 2 - 1 do
|
||||
if not facing and Permissions.isWhirlpool(world.map:cellCollision(x, y))
|
||||
and Permissions.isWater(world.map:cellCollision(x, y - 1)) then
|
||||
WHIRL.x, WHIRL.y = x, y
|
||||
world:setMap(MAP, x, y - 1, "down")
|
||||
world:applyPlayerState(FieldMoves.PLAYER_SURF)
|
||||
U.wait(10)
|
||||
ctx = world:fieldContext()
|
||||
facing = Permissions.isWhirlpool(ctx.facingColl)
|
||||
U.log(("note: using the whirlpool at (%d,%d)"):format(x, y))
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
claim(facing, ("facing the whirlpool at (%d,%d) on %s"):format(
|
||||
WHIRL.x, WHIRL.y, MAP))
|
||||
local blocks = world.maps[MAP] and world.maps[MAP].blocks
|
||||
local index = ctx.facingBlockIndex
|
||||
local before = blocks and index and blocks[index]
|
||||
claim(before ~= nil, "and the block behind it is readable")
|
||||
if not (facing and before) then
|
||||
report()
|
||||
U.log("nothing to drive; stopping rather than faking the moment")
|
||||
while true do coroutine.yield() end
|
||||
end
|
||||
U.shot(game, out .. "/01-facing.png")
|
||||
|
||||
local function tap(button, gap)
|
||||
table.insert(game.input.pressQueue, button)
|
||||
game.input.state[button] = true
|
||||
coroutine.yield()
|
||||
game.input.state[button] = false
|
||||
for _ = 1, (gap or 8) do coroutine.yield() end
|
||||
end
|
||||
|
||||
-- A into the whirlpool, then A through the ask box (YES is the default) and
|
||||
-- the USED WHIRLPOOL line.
|
||||
for _ = 1, 24 do
|
||||
if world.fieldMove and world.fieldMove.phase == "whirlpoolsfx" then break end
|
||||
tap("a")
|
||||
end
|
||||
claim(world.fieldMove and world.fieldMove.phase == "whirlpoolsfx",
|
||||
"the drain reached PlayWhirlpoolSound")
|
||||
claim(blocks[index] == before,
|
||||
"the whirlpool is still in the block buffer as the wash starts")
|
||||
U.shot(game, out .. "/02-wash-starts.png")
|
||||
|
||||
local swapped, phaseFrames = nil, 0
|
||||
for _ = 1, 300 do
|
||||
if world.fieldMove and world.fieldMove.phase == "whirlpoolsfx" then
|
||||
phaseFrames = phaseFrames + 1
|
||||
if blocks[index] ~= before and not swapped then swapped = phaseFrames end
|
||||
if phaseFrames == 6 then U.shot(game, out .. "/03-mid-wash.png") end
|
||||
end
|
||||
if not world:busy() then break end
|
||||
coroutine.yield()
|
||||
end
|
||||
claim(swapped == nil,
|
||||
"and it stayed there for every frame of the wash")
|
||||
claim(blocks[index] ~= before,
|
||||
"the block was swapped once the wash ended")
|
||||
claim(phaseFrames > 0 and phaseFrames < 180,
|
||||
("the wash ended on its own after %d frames"):format(phaseFrames))
|
||||
U.wait(4)
|
||||
U.shot(game, out .. "/04-drained.png")
|
||||
|
||||
report()
|
||||
if fails > 0 then
|
||||
U.log("a FAIL above means the run is not showing what it claims to")
|
||||
end
|
||||
U.log("what right looks like: the whirlpool keeps spinning through the whole")
|
||||
U.log("surf wash and only pops out of the water when the sound has finished.")
|
||||
U.log("water where the whirlpool was while the wash is still playing is the")
|
||||
U.log("bug; a whirlpool still there after control comes back is the overshoot.")
|
||||
U.log("the pad is yours -- the other whirlpools are further south.")
|
||||
|
||||
while true do coroutine.yield() end
|
||||
end
|
||||
@@ -0,0 +1,122 @@
|
||||
-- Eye check on NIGHT_SHADE's wavy screen (#1848). AnimationWavyScreen's
|
||||
-- `ld c, $ff` counts outer passes and the inner loop exits twice a frame
|
||||
-- (pokered engine/battle/animations.asm:1884-1903, 1916-1927), so the
|
||||
-- wobble runs ~128 frames with a 16-line wave, not 255 with a 32-line one.
|
||||
-- POKEPORT_DRIVER=tests/drivers/nightshade_anim_bug1848_test.lua POKEPORT_IDENTITY=bug1848 POKEPORT_TOUCH=0 love .
|
||||
-- No POKEPORT_SPEED: fast-forward scales the logic clock only.
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local AnimPlayer = require("src.battle.AnimPlayer")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
|
||||
-- data/generated/maps.lua ROUTE_1: open path south of the first grass
|
||||
-- patch; pokered data/maps/objects/Route1.asm parks its youngsters at
|
||||
-- (5, 24) and (15, 13), so nobody is standing here or watching.
|
||||
local MAP = "ROUTE_1"
|
||||
local STAND = { x = 5, y = 5, facing = "down" }
|
||||
|
||||
local function check(label, ok)
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
local opts = game.save.options or {}
|
||||
local sfxVol = opts.sfxVol or 7
|
||||
if sfxVol == 0 then
|
||||
U.log("FAIL sfx volume is 0, so the flash that opens the animation is")
|
||||
U.log(" silent. Set SFX to 7 in OPTION first.")
|
||||
end
|
||||
check(("sfx volume %d"):format(sfxVol), sfxVol > 0)
|
||||
if opts.animations == false then
|
||||
U.log("FAIL OPTION has animations off, which skips every queued anim row.")
|
||||
end
|
||||
check("battle animations are on", opts.animations ~= false)
|
||||
|
||||
local anims = (game.data.battle_anims or {}).moveAnims or {}
|
||||
check("NIGHT_SHADE has an animation", anims.NIGHT_SHADE ~= nil)
|
||||
|
||||
-- what the animation player budgets for the effect, ahead of the battle
|
||||
local probe = AnimPlayer.new(game.data.battle_anims)
|
||||
probe:start("NIGHT_SHADE", true)
|
||||
local budget
|
||||
for _, e in ipairs(probe.events) do
|
||||
if e.effect == "SE_WAVY_SCREEN" then budget = e.dur end
|
||||
end
|
||||
check(("SE_WAVY_SCREEN is budgeted %s frames (want 128)"):format(tostring(budget)),
|
||||
budget == 128)
|
||||
|
||||
local lead = Pokemon.new(game.data, "GASTLY", 25)
|
||||
lead.moves = { { id = "NIGHT_SHADE", pp = 15 } }
|
||||
game.save.party = { lead }
|
||||
|
||||
U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing)
|
||||
U.wait(15)
|
||||
local ow = game.overworld
|
||||
if not ow.map:isWalkableCell(STAND.x, STAND.y) then
|
||||
for _, d in ipairs({ { 0, 1 }, { 0, -1 }, { 1, 0 }, { -1, 0 } }) do
|
||||
local cx, cy = STAND.x + d[1], STAND.y + d[2]
|
||||
if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then
|
||||
U.log(("(%d, %d) is blocked, standing on"):format(STAND.x, STAND.y), cx, cy)
|
||||
U.teleport(game, MAP, cx, cy, STAND.facing)
|
||||
U.wait(15)
|
||||
ow = game.overworld
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
check("standing on " .. MAP, ow.map.id == MAP)
|
||||
|
||||
local battle = BattleState.newWild(game, "PIDGEY", 6)
|
||||
battle.onFinish = function() end
|
||||
ow:pushBattle(battle)
|
||||
|
||||
local function waitPhase(phase, tries)
|
||||
for _ = 1, tries do
|
||||
if battle.phase == phase then return true end
|
||||
U.tap(game, "a")
|
||||
U.wait(6)
|
||||
end
|
||||
return battle.phase == phase
|
||||
end
|
||||
check("the battle reached the menu", waitPhase("menu", 60))
|
||||
U.tap(game, "a")
|
||||
check("NIGHT_SHADE is the move on the list", waitPhase("moveSelect", 20))
|
||||
U.tap(game, "a")
|
||||
|
||||
-- ride the animation and time the wave from the fx layer itself
|
||||
local started, frames, phases = false, 0, {}
|
||||
local shots = { [4] = "start", [64] = "middle", [124] = "end" }
|
||||
for _ = 1, 400 do
|
||||
local wavy = battle.fx and battle.fx.wavy
|
||||
if wavy then
|
||||
started = true
|
||||
frames = frames + 1
|
||||
phases[#phases + 1] = wavy.phase
|
||||
local tag = shots[frames]
|
||||
if tag then
|
||||
U.shot(game, ("%s/bug1848_wavy_%s.png"):format(DIR, tag))
|
||||
end
|
||||
elseif started then
|
||||
break
|
||||
end
|
||||
U.wait(1)
|
||||
end
|
||||
|
||||
check("the wave actually ran", started)
|
||||
check(("the wave lasted %d frames (want 128)"):format(frames),
|
||||
frames >= 120 and frames <= 136)
|
||||
local step = (#phases >= 2) and (phases[2] - phases[1]) or 0
|
||||
check(("the offset pointer advances %d entries a frame (want 2)"):format(step),
|
||||
step == 2)
|
||||
|
||||
U.log("Three shots are in " .. DIR .. ": bug1848_wavy_start/middle/end.png.")
|
||||
U.log("The screen should ripple with about nine full waves top to bottom,")
|
||||
U.log("scrolling upward, and settle after roughly two seconds. The near-miss")
|
||||
U.log("to watch for is half that many waves crawling for twice as long.")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,118 @@
|
||||
-- Party submenu box geometry (#1819) with PokemonMenuEntries in full (#1833).
|
||||
-- pokered anchors this box to the bottom row of the screen and grows it
|
||||
-- upward two rows per field move, indenting it by the widest field move name
|
||||
-- (engine/menus/text_box.asm:397-440, data/moves/field_moves.asm,
|
||||
-- data/text_boxes.asm:33).
|
||||
-- POKEPORT_DRIVER=tests/drivers/party_submenu_box_bug1819_test.lua POKEPORT_TOUCH=0 love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local Screens = require("src.ui.Screens")
|
||||
local Font = require("src.render.Font")
|
||||
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
|
||||
local function check(label, ok)
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
local mon = Pokemon.new(game.data, "CHANSEY", 40)
|
||||
game.save.party = { mon }
|
||||
game.save.player.name = "bryan"
|
||||
U.teleport(game, "PALLET_TOWN", 10, 8, "down")
|
||||
U.wait(10)
|
||||
|
||||
-- record the boxes one real draw pass puts on screen; the submenu is the
|
||||
-- only one that does not start at column 0
|
||||
local function boxThisFrame()
|
||||
local seen = {}
|
||||
local real = Font.drawBox
|
||||
Font.drawBox = function(tx, ty, tw, th, ...)
|
||||
seen[#seen + 1] = { tx, ty, tw, th }
|
||||
return real(tx, ty, tw, th, ...)
|
||||
end
|
||||
U.wait(2)
|
||||
Font.drawBox = real
|
||||
for i = #seen, 1, -1 do
|
||||
if seen[i][1] > 0 then return seen[i] end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function openSubmenu(moves)
|
||||
mon.moves = moves
|
||||
Screens.push(game, "PartyMenu", {})
|
||||
U.wait(12)
|
||||
U.tap(game, "a")
|
||||
U.wait(12)
|
||||
return game.stack:top()
|
||||
end
|
||||
|
||||
local function closeSubmenu()
|
||||
U.tap(game, "b")
|
||||
U.wait(8)
|
||||
U.tap(game, "b")
|
||||
U.wait(8)
|
||||
end
|
||||
|
||||
local function move(id)
|
||||
local def = game.data.moves[id]
|
||||
return { id = id, pp = def and def.pp or 10 }
|
||||
end
|
||||
|
||||
-- tx, ty, tw, th the port should hand Font.drawBox, per case
|
||||
local cases = {
|
||||
{ name = "no field moves", shot = "bug1819_submenu_plain.png",
|
||||
moves = { move("POUND") }, rows = 3, box = { 11, 11, 9, 7 } },
|
||||
{ name = "one field move (STRENGTH indents to tile 10)",
|
||||
shot = "bug1819_submenu_strength.png",
|
||||
moves = { move("STRENGTH") }, rows = 4, box = { 9, 8, 11, 10 } },
|
||||
{ name = "SOFTBOILED, the widest name (tile 8)",
|
||||
shot = "bug1819_submenu_softboiled.png",
|
||||
moves = { move("SOFTBOILED") }, rows = 4, box = { 7, 8, 13, 10 } },
|
||||
}
|
||||
|
||||
for _, c in ipairs(cases) do
|
||||
local pm = openSubmenu(c.moves)
|
||||
local ok = pm and pm.submenu
|
||||
check(c.name .. ": the submenu opened", ok == true)
|
||||
if ok then
|
||||
local labels = {}
|
||||
for i, e in ipairs(pm.subItems) do labels[i] = e.label end
|
||||
U.log(c.name .. " lists:", table.concat(labels, " / "))
|
||||
check(c.name .. ": " .. c.rows .. " rows ending in CANCEL",
|
||||
#pm.subItems == c.rows
|
||||
and pm.subItems[#pm.subItems].action == "cancel")
|
||||
local b = boxThisFrame()
|
||||
if not b then
|
||||
check(c.name .. ": a submenu box was drawn", false)
|
||||
else
|
||||
U.log(c.name .. " box tx,ty,tw,th:", b[1], b[2], b[3], b[4])
|
||||
check(c.name .. ": box is " .. table.concat(c.box, ","),
|
||||
b[1] == c.box[1] and b[2] == c.box[2]
|
||||
and b[3] == c.box[3] and b[4] == c.box[4])
|
||||
check(c.name .. ": its bottom border is on tile row 17",
|
||||
b[2] + b[4] - 1 == 17)
|
||||
check(c.name .. ": its right edge is on tile column 19",
|
||||
b[1] + b[3] - 1 == 19)
|
||||
end
|
||||
U.shot(game, SHOT_DIR .. "/" .. c.shot)
|
||||
U.log("captured", SHOT_DIR .. "/" .. c.shot)
|
||||
end
|
||||
closeSubmenu()
|
||||
end
|
||||
|
||||
-- leave the last one up for the eye
|
||||
openSubmenu({ move("SOFTBOILED") })
|
||||
|
||||
U.log("The submenu on screen should sit flush against the bottom of the")
|
||||
U.log("screen, its lower border sharing the screen's last pixel row, with")
|
||||
U.log("SOFTBOILED / STATS / SWITCH / CANCEL inside it and one blank row")
|
||||
U.log("above SOFTBOILED. The near-miss to watch for is an 8px gap under")
|
||||
U.log("the box that shows the message box's own bottom line, so the two")
|
||||
U.log("borders read as a doubled or detached line.")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -72,7 +72,11 @@ return function(game)
|
||||
check(howY == 112, "How many? sits on text-box first line (y=112)")
|
||||
check(textBox ~= nil, "bottom PrintText box drawn")
|
||||
check(qtyBox ~= nil, "quantity box at pokered row 9")
|
||||
check(maxItemY and maxItemY <= 72, "last visible item row above qty/text (y<=72)")
|
||||
-- LIST_MENU_BOX 4,2 - 19,12: the fourth row's quantity sits at y=88
|
||||
-- (data/text_boxes.asm:13, engine/menus/players_pc.asm)
|
||||
check(maxItemY == 88, "last visible item row at y=88")
|
||||
check(textBox and maxItemY and maxItemY + 8 <= textBox.ty * 8,
|
||||
"last visible item row clears the bottom text box (#174)")
|
||||
if maxItemY and howY then
|
||||
check(maxItemY + 8 <= howY, "item glyphs do not overlap How many?")
|
||||
end
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
-- The four PC list screens: the player's PC item lists (#1845) and Bill's
|
||||
-- PC mon lists plus CHANGE BOX (#1847). Both are LIST_MENU_BOX screens in
|
||||
-- the original (home/list_menu.asm:29-31, engine/menus/save.asm:437-506),
|
||||
-- not full-screen lists of their own.
|
||||
-- SHOT_DIR=/tmp/pc_lists POKEPORT_DRIVER=tests/drivers/pc_lists_bug1845_bug1847_test.lua POKEPORT_TOUCH=0 love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
local Boxes = require("src.pokemon.Boxes")
|
||||
local BoxMenu = require("src.ui.BoxMenu")
|
||||
local Font = require("src.render.Font")
|
||||
local ListMenu = require("src.ui.ListMenu")
|
||||
local Menu = require("src.ui.Menu")
|
||||
local PlayerPC = require("src.ui.PlayerPC")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
|
||||
local pass = true
|
||||
local function check(label, ok)
|
||||
if ok then U.log("PASS", label) else pass = false; U.log("FAIL", label) end
|
||||
return ok
|
||||
end
|
||||
|
||||
-- record every Font.drawBox/Font.draw of one frame so the geometry can be
|
||||
-- asserted instead of eyeballed
|
||||
local function capture()
|
||||
local out = {}
|
||||
local savedDraw, savedBox = Font.draw, Font.drawBox
|
||||
Font.draw = function(text, x, y)
|
||||
out[#out + 1] = { kind = "text", text = tostring(text), x = x, y = y }
|
||||
return savedDraw(text, x, y)
|
||||
end
|
||||
Font.drawBox = function(tx, ty, tw, th)
|
||||
out[#out + 1] = { kind = "box", tx = tx, ty = ty, tw = tw, th = th }
|
||||
return savedBox(tx, ty, tw, th)
|
||||
end
|
||||
game.stack:draw()
|
||||
Font.draw, Font.drawBox = savedDraw, savedBox
|
||||
return out
|
||||
end
|
||||
|
||||
local function hasBox(drawn, tx, ty, tw, th)
|
||||
for _, d in ipairs(drawn) do
|
||||
if d.kind == "box" and d.tx == tx and d.ty == ty
|
||||
and d.tw == tw and d.th == th then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function textOnRow(drawn, y)
|
||||
for _, d in ipairs(drawn) do
|
||||
if d.kind == "text" and d.y == y and d.text ~= "" then return d end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function hasText(drawn, want)
|
||||
for _, d in ipairs(drawn) do
|
||||
if d.kind == "text" and d.text == want then return d end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- pokered data/maps/objects/ViridianPokecenter.asm keeps the floor east of
|
||||
-- the counter free; the PC itself is a hidden object, so the screens are
|
||||
-- pushed directly rather than bumped into
|
||||
local MAP, STAND = "VIRIDIAN_POKECENTER", { x = 13, y = 4 }
|
||||
U.teleport(game, MAP, STAND.x, STAND.y, "up")
|
||||
U.wait(6)
|
||||
local function freeNeighbour(ow)
|
||||
for dy = -2, 2 do
|
||||
for dx = -2, 2 do
|
||||
local cx, cy = STAND.x + dx, STAND.y + dy
|
||||
if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then
|
||||
return cx, cy
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
local ow = game.overworld
|
||||
if ow and ow.map and not ow.map:isWalkableCell(STAND.x, STAND.y) then
|
||||
local cx, cy = freeNeighbour(ow)
|
||||
if cx then
|
||||
U.log("stand cell blocked, standing on", cx, cy)
|
||||
U.teleport(game, MAP, cx, cy, "up")
|
||||
U.wait(6)
|
||||
end
|
||||
end
|
||||
|
||||
-- #1845: the player's PC item lists
|
||||
game.save.pcItems = { POTION = 3, ANTIDOTE = 1, REPEL = 5, ESCAPE_ROPE = 2 }
|
||||
game.stack:push(PlayerPC.new(game))
|
||||
U.wait(4)
|
||||
U.tap(game, "a") -- WITHDRAW ITEM
|
||||
U.wait(6)
|
||||
local list = game.stack:top()
|
||||
check("WITHDRAW ITEM opened a list", getmetatable(list) == ListMenu)
|
||||
if getmetatable(list) == ListMenu then
|
||||
check("the list is drawn as LIST_MENU_BOX, not a screen", list.itemBox == true)
|
||||
check("the PC menu underneath stays visible", list.isOpaque == false)
|
||||
check("PrintListMenuEntries' four rows", list.rows == 4)
|
||||
check("the list ends on the CANCEL terminator",
|
||||
list.items[#list.items].cancel == true)
|
||||
check("the withdraw prompt is up before anything is chosen",
|
||||
type(list.footer) == "string" and list.footer:find("withdraw", 1, true) ~= nil)
|
||||
local drawn = capture()
|
||||
check("LIST_MENU_BOX at tile 4,2 (16x11)", hasBox(drawn, 4, 2, 16, 11))
|
||||
check("the prompt's text box at tile 0,12 (20x6)", hasBox(drawn, 0, 12, 20, 6))
|
||||
check("nothing on the invented title row (y=4)", textOnRow(drawn, 4) == nil)
|
||||
local name = hasText(drawn, list.items[1].label)
|
||||
check("first name at hlcoord 6,4 (48,32)",
|
||||
name ~= nil and name.x == 48 and name.y == 32)
|
||||
end
|
||||
U.shot(game, DIR .. "/pc_1845_withdraw_item.png")
|
||||
U.tap(game, "b") -- back to the PC menu
|
||||
U.wait(4)
|
||||
U.tap(game, "down"); U.tap(game, "down"); U.tap(game, "down")
|
||||
U.wait(2)
|
||||
U.tap(game, "a") -- LOG OFF
|
||||
U.wait(6)
|
||||
|
||||
-- #1847: Bill's PC mon lists and CHANGE BOX
|
||||
local boxes = Boxes.ensure(game.save)
|
||||
boxes[1] = {
|
||||
Pokemon.new(game.data, "PIDGEY", 12),
|
||||
Pokemon.new(game.data, "RATTATA", 7),
|
||||
Pokemon.new(game.data, "NIDORAN_M", 15),
|
||||
Pokemon.new(game.data, "ZUBAT", 9),
|
||||
Pokemon.new(game.data, "ONIX", 22),
|
||||
}
|
||||
boxes[3] = { Pokemon.new(game.data, "MAGIKARP", 5) }
|
||||
game.save.currentBox = 1
|
||||
game.save.party = { Pokemon.new(game.data, "CHARMANDER", 14) }
|
||||
game.stack:push(BoxMenu.new(game))
|
||||
U.wait(4)
|
||||
U.tap(game, "a") -- WITHDRAW POKéMON
|
||||
U.wait(6)
|
||||
local mons = game.stack:top()
|
||||
check("WITHDRAW POKéMON opened a list", getmetatable(mons) == ListMenu)
|
||||
if getmetatable(mons) == ListMenu then
|
||||
check("the mon list is drawn as LIST_MENU_BOX", mons.itemBox == true)
|
||||
check("the mon list ends on CANCEL",
|
||||
mons.items[#mons.items].cancel == true)
|
||||
check("the level is a row of its own, not part of the name",
|
||||
mons.items[1].sub == ":L12"
|
||||
and mons.items[1].label:find(":L") == nil)
|
||||
local drawn = capture()
|
||||
check("LIST_MENU_BOX at tile 4,2 (16x11)", hasBox(drawn, 4, 2, 16, 11))
|
||||
check("no invented BOX 1 (WITHDRAW) title",
|
||||
hasText(drawn, "BOX 1 (WITHDRAW)") == nil)
|
||||
local lvl = hasText(drawn, ":L12")
|
||||
check("PrintLevel one row down, 8 columns right (112,40)",
|
||||
lvl ~= nil and lvl.x == 112 and lvl.y == 40)
|
||||
end
|
||||
U.shot(game, DIR .. "/pc_1847_withdraw_mon.png")
|
||||
U.tap(game, "b")
|
||||
U.wait(4)
|
||||
|
||||
U.tap(game, "down"); U.tap(game, "down"); U.tap(game, "down")
|
||||
U.wait(2)
|
||||
U.tap(game, "a") -- CHANGE BOX
|
||||
U.wait(30)
|
||||
local ask = game.stack:top()
|
||||
check("CHANGE BOX asks before it shows the box list",
|
||||
getmetatable(ask) == TextBox)
|
||||
U.shot(game, DIR .. "/pc_1847_change_box_prompt.png")
|
||||
-- page through "When you change a POKéMON BOX..." and take the YES
|
||||
local function isPicker(s)
|
||||
return getmetatable(s) == Menu and s.kind == "pc_box_change"
|
||||
end
|
||||
for _ = 1, 12 do
|
||||
if isPicker(game.stack:top()) then break end
|
||||
U.tap(game, "a")
|
||||
U.wait(12)
|
||||
end
|
||||
local picker = game.stack:top()
|
||||
check("YES opens the box picker", isPicker(picker))
|
||||
if isPicker(picker) then
|
||||
check("twelve boxes", #picker.items == 12)
|
||||
check("BoxNames spelling", picker.items[1].label == "BOX 1"
|
||||
and picker.items[12].label == "BOX12")
|
||||
check("the cursor starts on the current box",
|
||||
picker.index == game.save.currentBox)
|
||||
local drawn = capture()
|
||||
check("box list at tile 11,0 (9x14)", hasBox(drawn, 11, 0, 9, 14))
|
||||
check("BOX No. panel at tile 0,0 (11x4)", hasBox(drawn, 0, 0, 11, 4))
|
||||
local first = hasText(drawn, "BOX 1")
|
||||
check("BoxNames at hlcoord 13,1 (104,8)",
|
||||
first ~= nil and first.x == 104 and first.y == 8)
|
||||
end
|
||||
U.shot(game, DIR .. "/pc_1847_change_box.png")
|
||||
|
||||
U.log(pass and "RESULT: ALL PASS" or "RESULT: SEE FAILURES ABOVE")
|
||||
U.log("On screen now: the CHANGE BOX picker. BOX 1..BOX12 sit in a tall")
|
||||
U.log("box on the right, BOX No. 1 in a small panel top-left, and a")
|
||||
U.log("pokeball marks BOX 1 and BOX 3 only, the two that hold POKéMON.")
|
||||
U.log("The near-miss to watch for is a marker on all twelve boxes, or on")
|
||||
U.log("none, and BOX10-12 spelled with the space of BOX 1 to BOX 9.")
|
||||
U.log("Shots are in " .. DIR .. "; the two list shots must show the")
|
||||
U.log("PC menu and the What? box around a bordered list, never a white")
|
||||
U.log("page with a title row.")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,119 @@
|
||||
-- Eye/ear check for the Pokédex CONTENTS screen and entry page (#1829):
|
||||
-- screen furniture, the seven rows, the side-menu cursor, the cry the entry
|
||||
-- page waits on and the blinking page arrow.
|
||||
-- engine/menus/pokedex.asm:161-199, :242-273, :500-506; home/joypad2.asm:55-83.
|
||||
-- No POKEPORT_SPEED: the cry runs on the real-time audio clock.
|
||||
-- POKEPORT_DRIVER=tests/drivers/pokedex_contents_bug1829_test.lua POKEPORT_IDENTITY=bug1829 POKEPORT_TOUCH=0 love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Screens = require("src.ui.Screens")
|
||||
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
|
||||
local function check(label, ok)
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
local opts = game.save.options or {}
|
||||
if (opts.sfxVol or 1) == 0 then
|
||||
U.log("WARNING sfxVol is 0, so the entry page's cry will be silent and")
|
||||
U.log("WARNING the pause before the height/weight lines will look like a hang")
|
||||
end
|
||||
|
||||
-- dex numbers -> species, so the driver never hardcodes an id
|
||||
local byDex = {}
|
||||
for _, def in pairs(game.data.pokemon) do
|
||||
if def.dex then byDex[def.dex] = def end
|
||||
end
|
||||
check("the cache carries a Kanto numbering", byDex[1] ~= nil and byDex[20] ~= nil)
|
||||
|
||||
game.save.pokedex = { seen = {}, owned = {} }
|
||||
for n = 1, 20 do
|
||||
local def = byDex[n]
|
||||
if def and n ~= 5 then game.save.pokedex.seen[def.id] = true end
|
||||
end
|
||||
for _, n in ipairs({ 1, 2, 3, 7, 8 }) do
|
||||
local def = byDex[n]
|
||||
if def then
|
||||
game.save.pokedex.seen[def.id] = true
|
||||
game.save.pokedex.owned[def.id] = true
|
||||
end
|
||||
end
|
||||
game.save.player.name = "bryan"
|
||||
|
||||
U.teleport(game, "PALLET_TOWN", 10, 8, "down")
|
||||
U.wait(20)
|
||||
|
||||
local dex = Screens.push(game, "PokedexMenu")
|
||||
U.wait(20)
|
||||
check("the dex list is on the stack", game.stack:top() == dex)
|
||||
check("the list stops at the highest seen number (20)", #dex.items == 20)
|
||||
check("dex 005 was left unseen, so its row is a dashed line",
|
||||
dex.items[5] and dex.items[5].name == "----------")
|
||||
check("dex 001 is owned, so its row carries the ball marker",
|
||||
dex.items[1] and dex.items[1].ball == true)
|
||||
U.shot(game, SHOT_DIR .. "/bug1829_contents.png")
|
||||
|
||||
-- Right pages the list without moving the cursor off its screen row
|
||||
local rowBefore = dex.index - dex.scroll
|
||||
U.tap(game, "right")
|
||||
U.wait(10)
|
||||
check("Right scrolled the list", dex.scroll == 7)
|
||||
check("the cursor kept its screen row", dex.index - dex.scroll == rowBefore)
|
||||
U.shot(game, SHOT_DIR .. "/bug1829_contents_paged.png")
|
||||
U.tap(game, "left")
|
||||
U.wait(10)
|
||||
check("Left paged back to the top", dex.scroll == 0)
|
||||
|
||||
-- A on an owned row opens the side menu: the cursor moves onto the
|
||||
-- DATA/CRY/AREA/QUIT block that was already on screen
|
||||
U.tap(game, "a")
|
||||
U.wait(20)
|
||||
local side = game.stack:top()
|
||||
check("A opened the side menu", side ~= dex)
|
||||
check("the list row kept its hollow cursor", dex.hollowIndex == dex.index)
|
||||
U.shot(game, SHOT_DIR .. "/bug1829_side_menu.png")
|
||||
|
||||
-- DATA is the first row, so A again opens the entry page
|
||||
U.tap(game, "a")
|
||||
U.wait(2)
|
||||
local page = game.stack:top()
|
||||
check("DATA opened the entry page", page ~= side and page.def ~= nil)
|
||||
U.shot(game, SHOT_DIR .. "/bug1829_entry_cry.png")
|
||||
|
||||
local waited = 0
|
||||
while page.crying and page:crying() and waited < 300 do
|
||||
waited = waited + 1
|
||||
coroutine.yield()
|
||||
end
|
||||
U.log("the cry held the page for", waited, "frames")
|
||||
U.wait(10)
|
||||
U.shot(game, SHOT_DIR .. "/bug1829_entry_full.png")
|
||||
check("the entry has more than one page, so the arrow is drawn",
|
||||
(page.pageCount or 1) > 1)
|
||||
|
||||
local function shotAtBlink(target, path)
|
||||
for _ = 1, 120 do
|
||||
if (page.blink or 0) == target then break end
|
||||
coroutine.yield()
|
||||
end
|
||||
U.shot(game, path)
|
||||
end
|
||||
shotAtBlink(8, SHOT_DIR .. "/bug1829_arrow_on.png")
|
||||
shotAtBlink(38, SHOT_DIR .. "/bug1829_arrow_off.png")
|
||||
|
||||
U.log("Shots are in " .. SHOT_DIR .. ". The CONTENTS screen should read")
|
||||
U.log("CONTENTS top left, a vertical rule down column 14 with SEEN and OWN")
|
||||
U.log("counts beside it and DATA/CRY/AREA/QUIT under them, and seven rows")
|
||||
U.log("of number-above-name with the ball left of the owned names.")
|
||||
U.log("bug1829_entry_cry should show the frame, name, kind, No. and pic")
|
||||
U.log("with nothing below the divider while the cry sounds; the height,")
|
||||
U.log("weight and description arrive in bug1829_entry_full once it ends.")
|
||||
U.log("The near-miss to watch for: all of it painted at once with the cry")
|
||||
U.log("playing over the top, and a page arrow that never blinks off")
|
||||
U.log("(bug1829_arrow_on has it, bug1829_arrow_off should not).")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,71 @@
|
||||
-- Rare Candy's "grew to level N!" line carries a jingle (#1846): pokered's
|
||||
-- RareCandyText is text_far _RareCandyText / sound_get_item_1 /
|
||||
-- text_promptbutton (engine/menus/party_menu.asm:289-293), and outside
|
||||
-- battle that command plays SFX_GET_ITEM_1 (home/text.asm:546).
|
||||
-- POKEPORT_DRIVER=tests/drivers/rare_candy_bug1846_test.lua POKEPORT_TOUCH=0 love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local Screens = require("src.ui.Screens")
|
||||
local Bag = require("src.inventory.Bag")
|
||||
local Sound = require("src.core.Sound")
|
||||
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
|
||||
local function check(label, ok)
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
if (game.save.options and game.save.options.sfxVol or 1) == 0 then
|
||||
U.log("WARNING: sfxVol is 0, so nothing here can be heard.")
|
||||
U.log("WARNING: set SFX volume in OPTION before judging this driver.")
|
||||
end
|
||||
|
||||
local mon = Pokemon.new(game.data, "NIDORINO", 20)
|
||||
game.save.party = { mon }
|
||||
game.save.player.name = "bryan"
|
||||
game.save.inventory = {}
|
||||
Bag.add(game.save, "RARE_CANDY", 3, game.data)
|
||||
U.teleport(game, "PALLET_TOWN", 10, 8, "down")
|
||||
U.wait(10)
|
||||
|
||||
local heard = {}
|
||||
local realPlay = Sound.play
|
||||
Sound.play = function(data, name, ...)
|
||||
heard[#heard + 1] = name
|
||||
return realPlay(data, name, ...)
|
||||
end
|
||||
|
||||
-- bag -> RARE CANDY -> USE -> the only party member
|
||||
Screens.push(game, "BagMenu", {})
|
||||
U.wait(12)
|
||||
U.tap(game, "a")
|
||||
U.wait(12)
|
||||
U.tap(game, "a")
|
||||
U.wait(12)
|
||||
local level = mon.level
|
||||
U.tap(game, "a")
|
||||
U.wait(40)
|
||||
|
||||
Sound.play = realPlay
|
||||
check("the candy raised the level", mon.level == level + 1)
|
||||
U.log("sounds heard:", table.concat(heard, " "))
|
||||
local jingle = false
|
||||
for _, name in ipairs(heard) do
|
||||
if name == "Get_Item1" then jingle = true end
|
||||
end
|
||||
check("the level line carried SFX_GET_ITEM_1", jingle)
|
||||
|
||||
U.shot(game, SHOT_DIR .. "/bug1846_rare_candy.png")
|
||||
U.log("captured", SHOT_DIR .. "/bug1846_rare_candy.png")
|
||||
|
||||
U.log("NIDORINO grew to level 21 just now: the short item fanfare should")
|
||||
U.log("sound as that line finishes typing, and the box waits for A after")
|
||||
U.log("it. Silence there is the bug. The other near-miss is the longer")
|
||||
U.log("level-up fanfare, which only belongs to a candy used in battle.")
|
||||
U.log("Two candies are left in the bag to hear it again.")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,100 @@
|
||||
-- Manual check that Hideout B4F's Rocket3 engages on sight (#1814).
|
||||
-- RocketHideout4TrainerHeader2 is `trainer EVENT_..., 1, ...`
|
||||
-- (scripts/RocketHideoutB4F.asm:95-96), so CheckSpriteCanSeePlayer engages him
|
||||
-- from the tile below. The LIFT KEY half (his after-battle talk still reveals
|
||||
-- the ball) is asserted in tests/parity_rocket3_sight_bug1814.lua.
|
||||
-- POKEPORT_DRIVER=tests/drivers/rocket3_sight_bug1814_test.lua POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
|
||||
local function check(label, ok)
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
-- pokered data/maps/objects/RocketHideoutB4F.asm: ROCKET3 stands at (11,2)
|
||||
-- facing DOWN, so the sight tile is the one below him
|
||||
local MAP = "ROCKET_HIDEOUT_B4F"
|
||||
local TEXT = "TEXT_ROCKETHIDEOUTB4F_ROCKET3"
|
||||
|
||||
local function rocketIn(ow)
|
||||
for _, n in ipairs(ow.npcs or {}) do
|
||||
if n.def and n.def.text == TEXT then return n end
|
||||
end
|
||||
end
|
||||
|
||||
-- a party that can take a hit, so the battle the sight starts is playable
|
||||
if #(game.save.party or {}) == 0 then
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
game.save.party = { Pokemon.new(game.data, "NIDOKING", 45) }
|
||||
end
|
||||
|
||||
U.teleport(game, MAP, 13, 3, "left")
|
||||
U.wait(10)
|
||||
local ow = game.overworld
|
||||
local rocket = rocketIn(ow)
|
||||
check("Rocket3 is on the map", rocket ~= nil)
|
||||
if rocket then
|
||||
U.log(("Rocket3 at (%d, %d) facing %s")
|
||||
:format(rocket.cellX, rocket.cellY, tostring(rocket.facing)))
|
||||
end
|
||||
|
||||
local header = rocket
|
||||
and game.data:trainerHeader(ow.map.def.label, rocket.def.index)
|
||||
check("his extracted view range is 1 tile", header and header.range == 1)
|
||||
|
||||
-- walk north into his line of sight; a map edit that moved him is a
|
||||
-- different failure than a sight check that never fires, so aim at the
|
||||
-- cell below wherever he actually stands
|
||||
local target = rocket and { rocket.cellX, rocket.cellY + 1 } or { 11, 3 }
|
||||
local steps = 0
|
||||
for _ = 1, 8 do
|
||||
local p = game.overworld.player
|
||||
if (p.cellX == target[1] and p.cellY == target[2])
|
||||
or game.overworld.engaging then break end
|
||||
if p.cellX > target[1] then U.hold(game, "left", 16)
|
||||
elseif p.cellX < target[1] then U.hold(game, "right", 16)
|
||||
elseif p.cellY > target[2] then U.hold(game, "up", 16)
|
||||
else U.hold(game, "down", 16) end
|
||||
steps = steps + 1
|
||||
U.wait(4)
|
||||
end
|
||||
U.log("walked", steps, "steps toward", target[1], target[2])
|
||||
|
||||
local sighted = false
|
||||
for _ = 1, 90 do
|
||||
ow = game.overworld
|
||||
if ow.engaging or ow.emote then sighted = true break end
|
||||
U.wait(1)
|
||||
end
|
||||
check("stepping into his line of sight engages him", sighted)
|
||||
if game.overworld.emote then
|
||||
U.shot(game, SHOT_DIR .. "/bug1814_sighted.png")
|
||||
U.log("captured", SHOT_DIR .. "/bug1814_sighted.png")
|
||||
end
|
||||
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
-- his line prints first and holds on a button wait (home/text_script.asm:96)
|
||||
local fought = false
|
||||
for _ = 1, 60 do
|
||||
if getmetatable(game.stack:top()) == BattleState then fought = true break end
|
||||
U.tap(game, "a")
|
||||
U.wait(8)
|
||||
end
|
||||
check("the walk-up runs into the battle", fought)
|
||||
if fought then
|
||||
U.wait(60)
|
||||
U.shot(game, SHOT_DIR .. "/bug1814_battle.png")
|
||||
U.log("captured", SHOT_DIR .. "/bug1814_battle.png")
|
||||
end
|
||||
|
||||
U.log("Right: the grunt notices the player one tile below him, the bubble")
|
||||
U.log("pops, he marches down and the fight starts on its own. The near-miss")
|
||||
U.log("is nothing happening until the player presses A at him, which is the")
|
||||
U.log("bug. After winning, talk to him again: he drops the LIFT KEY ball.")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,113 @@
|
||||
-- Manual check of the Game Corner slot prompt, emote and menus (#1811).
|
||||
-- PromptUserToPlaySlots prints "Want to play?" and floats SMILE_BUBBLE over
|
||||
-- the player on the map, and only then loads the slot screen, whose bet menu
|
||||
-- is hlcoord 14,11 b=5 c=4 (engine/slots/slot_machine.asm:9-23, :83-86).
|
||||
-- Box geometry is asserted in tests/engine/slot_machine_boxes_bug1811.lua.
|
||||
-- POKEPORT_DRIVER=tests/drivers/slots_prompt_bug1811_test.lua POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local SlotMachine = require("src.ui.SlotMachine")
|
||||
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
|
||||
local function check(label, ok)
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
local vol = game.save.options and game.save.options.sfxVol
|
||||
if vol == 0 then
|
||||
U.log("sfx volume is 0 in options; raise it or the menu beeps are silent")
|
||||
end
|
||||
|
||||
-- AbleToPlaySlotsCheck wants a COIN CASE with coins in it
|
||||
game.save.inventory = game.save.inventory or {}
|
||||
game.save.inventory.COIN_CASE = 1
|
||||
game.save.coins = math.max(game.save.coins or 0, 50)
|
||||
|
||||
-- the seats are hidden events on the machine tiles (data/events/
|
||||
-- hidden_events.asm StartSlotMachine); stand on any walkable neighbour
|
||||
local seats = (game.data.field.slotMachines or {}).GAME_CORNER or {}
|
||||
local placed = nil
|
||||
U.teleport(game, "GAME_CORNER", 9, 15, "down")
|
||||
local ow = game.overworld
|
||||
local sides = {
|
||||
{ 0, 1, "up" }, { 0, -1, "down" }, { 1, 0, "left" }, { -1, 0, "right" },
|
||||
}
|
||||
for _, seat in ipairs(seats) do
|
||||
if seat.state == "ok" then
|
||||
for _, s in ipairs(sides) do
|
||||
local cx, cy = seat.x + s[1], seat.y + s[2]
|
||||
if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then
|
||||
U.teleport(game, "GAME_CORNER", cx, cy, s[3])
|
||||
U.wait(10)
|
||||
ow = game.overworld
|
||||
local fx, fy = ow.player:facingCell()
|
||||
if fx == seat.x and fy == seat.y then
|
||||
placed = { seat = seat, x = cx, y = cy, facing = s[3] }
|
||||
end
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
if placed then break end
|
||||
end
|
||||
check("standing at a working slot machine", placed ~= nil)
|
||||
if placed then
|
||||
U.log(("machine at (%d, %d), player at (%d, %d) facing %s")
|
||||
:format(placed.seat.x, placed.seat.y, placed.x, placed.y,
|
||||
placed.facing))
|
||||
end
|
||||
|
||||
U.tap(game, "a")
|
||||
U.wait(30)
|
||||
local top = game.stack:top()
|
||||
local isBox = getmetatable(top) == TextBox
|
||||
check("A opens a text box, not the slot screen", isBox)
|
||||
check("the slot screen is not on the stack yet",
|
||||
getmetatable(top) ~= SlotMachine)
|
||||
if isBox then
|
||||
local shown = {}
|
||||
for _, page in ipairs(top.pages or {}) do
|
||||
for _, line in ipairs(page) do shown[#shown + 1] = line end
|
||||
end
|
||||
U.log("box reads:", table.concat(shown, " / "))
|
||||
end
|
||||
U.shot(game, SHOT_DIR .. "/bug1811_prompt.png")
|
||||
|
||||
-- through the text, then YES
|
||||
for _ = 1, 8 do
|
||||
if game.overworld.emote then break end
|
||||
U.tap(game, "a")
|
||||
U.wait(12)
|
||||
end
|
||||
U.wait(4)
|
||||
local emote = game.overworld.emote
|
||||
check("YES floats an emotion bubble over the player", emote ~= nil)
|
||||
check("it is SMILE_BUBBLE, the third crop", emote and emote.bubble == 3)
|
||||
check("and it is over the player, not an NPC",
|
||||
emote and emote.npc == game.overworld.player)
|
||||
U.shot(game, SHOT_DIR .. "/bug1811_smile.png")
|
||||
|
||||
for _ = 1, 120 do
|
||||
if getmetatable(game.stack:top()) == SlotMachine then break end
|
||||
U.wait(2)
|
||||
end
|
||||
local slots = game.stack:top()
|
||||
check("the slot screen opens after the bubble",
|
||||
getmetatable(slots) == SlotMachine)
|
||||
check("and it opens on the bet menu", slots.stage == "bet")
|
||||
U.wait(10)
|
||||
U.shot(game, SHOT_DIR .. "/bug1811_bet.png")
|
||||
U.log("captured", SHOT_DIR .. "/bug1811_{prompt,smile,bet}.png")
|
||||
|
||||
U.log("Right: the prompt box sits over the still-drawn Game Corner floor,")
|
||||
U.log("then a smiley pops over the player, then the machine appears with a")
|
||||
U.log("bet menu whose bottom edge is the last row of the screen. The")
|
||||
U.log("near-miss is the machine already drawn behind the prompt, or a bet")
|
||||
U.log("box two rows short of the bottom.")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,99 @@
|
||||
-- #1831: spinner tiles whirled the sprite per DISPLAY frame and flickered
|
||||
-- the arrows at twice the cart's rate (engine/overworld/spinners.asm:1-22,
|
||||
-- home/overworld.asm:41-44,268-272).
|
||||
-- POKEPORT_DRIVER=tests/drivers/spinner_rate_bug1831_test.lua \
|
||||
-- POKEPORT_IDENTITY=bug1831 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local TileRenderer = require("src.render.TileRenderer")
|
||||
|
||||
local results = {}
|
||||
local function check(label, ok)
|
||||
results[#results + 1] = (ok and "PASS " or "FAIL ") .. label
|
||||
end
|
||||
|
||||
-- data/generated/field.lua ROCKET_HIDEOUT_B2F: the arrow at (12, 9)
|
||||
-- slides 10 cells left, the longest straight ride on the floor.
|
||||
U.teleport(game, "ROCKET_HIDEOUT_B2F", 13, 9, "left")
|
||||
U.wait(10)
|
||||
local ow = game.overworld
|
||||
check("overworld is up on ROCKET_HIDEOUT_B2F", ow ~= nil and ow.player ~= nil)
|
||||
|
||||
-- one step left onto the arrow starts the forced slide
|
||||
U.hold(game, "left", 20)
|
||||
local spinning = false
|
||||
for _ = 1, 30 do
|
||||
if ow.player.spinning then spinning = true break end
|
||||
U.wait(1)
|
||||
end
|
||||
check("stepping on the arrow starts the spin", spinning)
|
||||
|
||||
-- sample per fixed step while the slide runs
|
||||
local facings, timers, blurs = {}, {}, {}
|
||||
for _ = 1, 200 do
|
||||
if not ow.player.spinning then break end
|
||||
local _, _, _, facing = ow.player:pose()
|
||||
facings[#facings + 1] = facing
|
||||
timers[#timers + 1] = ow.player.spinTimer or 0
|
||||
blurs[#blurs + 1] = TileRenderer.spinBlurActive()
|
||||
U.wait(1)
|
||||
end
|
||||
check(("the slide gave %d samples (want >= 32)"):format(#facings),
|
||||
#facings >= 32)
|
||||
|
||||
-- spinTimer ticks once per fixed step, not per rendered frame
|
||||
local ticks = true
|
||||
for i = 2, #timers do
|
||||
if timers[i] - timers[i - 1] ~= 1 then ticks = false end
|
||||
end
|
||||
check("spinTimer advances exactly once per fixed step", ticks)
|
||||
|
||||
-- each facing holds for 2 fixed steps: one quarter-turn per OverworldLoop
|
||||
-- iteration, two frames each (home/overworld.asm:41-44)
|
||||
local runs, run = {}, 1
|
||||
for i = 2, #facings do
|
||||
if facings[i] == facings[i - 1] then
|
||||
run = run + 1
|
||||
else
|
||||
runs[#runs + 1] = run
|
||||
run = 1
|
||||
end
|
||||
end
|
||||
local twos, others = 0, 0
|
||||
for i = 2, #runs do -- the first run starts mid-phase, skip it
|
||||
if runs[i] == 2 then twos = twos + 1 else others = others + 1 end
|
||||
end
|
||||
check(("facing holds 2 steps (%d runs of 2, %d other)"):format(twos, others),
|
||||
twos >= 8 and others == 0)
|
||||
|
||||
-- arrow blur half-period is 16 frames: one whole 16-frame walked tile
|
||||
-- per wSimulatedJoypadStatesIndex parity (spinners.asm:18-22). The clock
|
||||
-- advances on the draw path, so allow one frame of sampling skew; the old
|
||||
-- bug read 8 here.
|
||||
local span, spans = 1, {}
|
||||
for i = 2, #blurs do
|
||||
if blurs[i] == blurs[i - 1] then
|
||||
span = span + 1
|
||||
else
|
||||
spans[#spans + 1] = span
|
||||
span = 1
|
||||
end
|
||||
end
|
||||
local good, bad = 0, 0
|
||||
for i = 2, #spans do -- first span starts mid-phase, skip it
|
||||
if spans[i] >= 15 and spans[i] <= 17 then good = good + 1
|
||||
else bad = bad + 1 end
|
||||
end
|
||||
check(("blur toggles every ~16 frames (%d good, %d off)"):format(good, bad),
|
||||
good >= 1 and bad == 0)
|
||||
|
||||
for _, line in ipairs(results) do U.log(line) end
|
||||
|
||||
U.log("Right looks like: while the player is swept along the arrow the")
|
||||
U.log("sprite makes roughly one full turn per 8 frames, a lazy whirl, and")
|
||||
U.log("the arrow tiles swap between blur and static about twice a second.")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,155 @@
|
||||
-- Manual check for #1866: black hairlines at the title's SGB zone boundaries
|
||||
-- (canvas rows 64 and 80, BlkPacket_Titlescreen, data/sgb/sgb_packets.asm:
|
||||
-- 123-127) at window heights that are not a whole multiple of 144.
|
||||
-- POKEPORT_DRIVER=tests/drivers/title_seam_bug1866_test.lua POKEPORT_TOUCH=0 SHOT_DIR=/tmp/shots love .
|
||||
-- Do not set POKEPORT_SPEED: fast-forward desynchronizes the title music.
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
local Renderer = require("src.render.Renderer")
|
||||
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
|
||||
local function check(label, ok)
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
local title
|
||||
for _ = 1, 120 do
|
||||
local top = game.stack:top()
|
||||
if top and top.screenId == "TitleState" and top.sgbPalettes then
|
||||
title = top
|
||||
break
|
||||
end
|
||||
U.tap(game, "start")
|
||||
U.wait(9)
|
||||
end
|
||||
check("the title screen is on top", title ~= nil)
|
||||
if not (title and title.sgbPalettes) then
|
||||
U.log("No title state to look at; nothing below can run.")
|
||||
while true do coroutine.yield() end
|
||||
end
|
||||
|
||||
local opts = game.save.options
|
||||
local mode = opts and opts.colors or PaletteFX.mode
|
||||
if PaletteFX.mode ~= "gbc" then
|
||||
U.log("COLORS is", tostring(mode) .. "; switching the view to SGB for the check")
|
||||
PaletteFX.setMode("gbc")
|
||||
U.wait(20)
|
||||
end
|
||||
local zones = PaletteFX.ensureZones(title:sgbPalettes(game))
|
||||
check("the title builds three SGB zones", zones ~= nil and #zones == 3)
|
||||
|
||||
-- Every height below is odd and none is a multiple of 144, so the picture
|
||||
-- never lands on a whole number of screen pixels per GB pixel. "fill"
|
||||
-- additionally drops the integer fit scale, which is the shape the report's
|
||||
-- screenshot was taken in (about 5.98 screen pixels per GB pixel).
|
||||
local HEIGHTS = { 721, 823, 907, 1013 }
|
||||
local WIDTH = 1157
|
||||
local worstRows, worstTag = 0, "none"
|
||||
|
||||
-- the control: a zone list with a real hole in it, drawn the way the
|
||||
-- per-zone pass alone would draw it. It proves the sampler below can see
|
||||
-- a seam at all, and that the underpaint is what closes this one.
|
||||
local gapped = {}
|
||||
for i, z in ipairs(zones) do
|
||||
gapped[i] = { x = z.x, y = z.y, w = z.w, h = z.h, colors = z.colors }
|
||||
end
|
||||
if gapped[2] then
|
||||
gapped[2].y = gapped[2].y + 1
|
||||
gapped[2].h = gapped[2].h - 2
|
||||
end
|
||||
|
||||
local function drawZonesOnly(zoneList, Ux, Uy, uox, uoy)
|
||||
local shader = PaletteFX.shader()
|
||||
love.graphics.setShader(shader)
|
||||
for _, z in ipairs(zoneList) do
|
||||
PaletteFX.sendColors(shader, z.colors)
|
||||
love.graphics.setScissor(uox + z.x * Ux, uoy + z.y * Uy,
|
||||
z.w * Ux, z.h * Uy)
|
||||
love.graphics.draw(Renderer.canvas, uox, uoy, 0, Ux, Uy)
|
||||
end
|
||||
love.graphics.setScissor()
|
||||
love.graphics.setShader()
|
||||
end
|
||||
|
||||
local function probeAt(ph, fill, zoneList, zonesOnly)
|
||||
local dpi = 1
|
||||
local probe = love.graphics.newCanvas(WIDTH, ph, { dpiscale = dpi })
|
||||
local Up
|
||||
if fill then
|
||||
Up = math.min(ph / 144, WIDTH / 160)
|
||||
else
|
||||
Up = math.max(1, math.floor(math.min(WIDTH / 160, ph / 144)))
|
||||
end
|
||||
local Ux, Uy = Up / dpi, Up / dpi
|
||||
local uox = math.floor((WIDTH - 160 * Up) / 2) / dpi
|
||||
local uoy = math.floor((ph - 144 * Up) / 2) / dpi
|
||||
local uvpw, uvph = 160 * Ux, 144 * Uy
|
||||
love.graphics.setCanvas(probe)
|
||||
love.graphics.clear(0, 0, 0, 1)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
if zonesOnly then
|
||||
drawZonesOnly(zoneList, Ux, Uy, uox, uoy)
|
||||
else
|
||||
Renderer:blitCanvas(Renderer.canvas, Ux, Uy, zoneList, Ux, Uy,
|
||||
uox, uoy, uox, uoy, uvpw, uvph, dpi, dpi)
|
||||
end
|
||||
love.graphics.setCanvas()
|
||||
local data = probe:newImageData()
|
||||
local top = math.floor(uoy * dpi) + 1
|
||||
local bottom = math.min(math.floor((uoy + uvph) * dpi) - 1, data:getHeight() - 1)
|
||||
local col = math.floor(uox * dpi) + 2
|
||||
local rows = {}
|
||||
for py = top, bottom do
|
||||
local r, g, b = data:getPixel(col, py)
|
||||
if r < 0.02 and g < 0.02 and b < 0.02 then rows[#rows + 1] = py end
|
||||
end
|
||||
return rows, ("%dx%d %s"):format(WIDTH, ph, fill and "fill" or "fit")
|
||||
end
|
||||
|
||||
for _, ph in ipairs(HEIGHTS) do
|
||||
for _, fill in ipairs({ false, true }) do
|
||||
local rows, tag = probeAt(ph, fill, zones, false)
|
||||
if #rows > worstRows then worstRows, worstTag = #rows, tag end
|
||||
check(tag .. ": no letterbox row shows through a zone boundary",
|
||||
#rows == 0)
|
||||
if #rows > 0 then
|
||||
U.log(" black rows:", table.concat(rows, ","))
|
||||
end
|
||||
end
|
||||
end
|
||||
U.log("worst case:", worstRows, "black rows at", worstTag)
|
||||
|
||||
local holeRows = probeAt(HEIGHTS[3], true, gapped, true)
|
||||
check("a zone list with a hole in it does show the letterbox clear",
|
||||
#holeRows > 0)
|
||||
local filledRows = probeAt(HEIGHTS[3], true, gapped, false)
|
||||
check("and the same hole drawn through the renderer shows none",
|
||||
#filledRows == 0)
|
||||
|
||||
-- and the live window at one of those heights, for the eye
|
||||
local ok = pcall(love.window.setMode, WIDTH, HEIGHTS[3],
|
||||
{ resizable = true, vsync = 0 })
|
||||
check("the window resized to " .. WIDTH .. "x" .. HEIGHTS[3], ok)
|
||||
U.wait(20)
|
||||
check("wrote " .. SHOT_DIR .. "/bug1866_title_907.png",
|
||||
U.shot(game, SHOT_DIR .. "/bug1866_title_907.png"))
|
||||
pcall(love.window.setMode, WIDTH, HEIGHTS[4], { resizable = true, vsync = 0 })
|
||||
U.wait(20)
|
||||
check("wrote " .. SHOT_DIR .. "/bug1866_title_1013.png",
|
||||
U.shot(game, SHOT_DIR .. "/bug1866_title_1013.png"))
|
||||
|
||||
PaletteFX.setMode(mode)
|
||||
U.wait(10)
|
||||
|
||||
U.log("Look along the title picture where the POKeMON logo meets the red")
|
||||
U.log("VERSION ribbon, and where the ribbon meets RED and the copyright: the")
|
||||
U.log("off-white background must run through both boundaries unbroken.")
|
||||
U.log("The near-miss is a hairline one or two pixels tall that is dark grey")
|
||||
U.log("or slightly off-white rather than black; that is still the seam.")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,78 @@
|
||||
-- Vitamins (#1805): pokered's .useVitamin plays SFX_HEAL_AILMENT, then
|
||||
-- prints VitaminStatRoseText over the still-drawn party menu and ends at
|
||||
-- RemoveUsedItem, which only strips the item from the bag
|
||||
-- (engine/items/item_effects.asm:1313-1322, :2274).
|
||||
-- POKEPORT_DRIVER=tests/drivers/vitamin_bug1805_test.lua POKEPORT_TOUCH=0 love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local Screens = require("src.ui.Screens")
|
||||
local Bag = require("src.inventory.Bag")
|
||||
local Sound = require("src.core.Sound")
|
||||
local PartyMenu = require("src.ui.PartyMenu")
|
||||
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
|
||||
local function check(label, ok)
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
if (game.save.options and game.save.options.sfxVol or 1) == 0 then
|
||||
U.log("WARNING: sfxVol is 0, so nothing here can be heard.")
|
||||
U.log("WARNING: set SFX volume in OPTION before judging this driver.")
|
||||
end
|
||||
|
||||
local mon = Pokemon.new(game.data, "NIDORINO", 20)
|
||||
game.save.party = { mon }
|
||||
game.save.player.name = "bryan"
|
||||
game.save.inventory = {}
|
||||
Bag.add(game.save, "HP_UP", 3, game.data)
|
||||
U.teleport(game, "PALLET_TOWN", 10, 8, "down")
|
||||
U.wait(10)
|
||||
|
||||
local heard = {}
|
||||
local realPlay = Sound.play
|
||||
Sound.play = function(data, name, ...)
|
||||
heard[#heard + 1] = name
|
||||
return realPlay(data, name, ...)
|
||||
end
|
||||
|
||||
-- bag -> HP UP -> USE -> the only party member
|
||||
Screens.push(game, "BagMenu", {})
|
||||
U.wait(12)
|
||||
U.tap(game, "a")
|
||||
U.wait(12)
|
||||
U.tap(game, "a")
|
||||
U.wait(12)
|
||||
U.tap(game, "a")
|
||||
U.wait(30)
|
||||
|
||||
Sound.play = realPlay
|
||||
check("the vitamin was consumed", (game.save.inventory.HP_UP or 0) == 2)
|
||||
check("HP stat exp went up", (mon.statExp and mon.statExp.hp or 0) == 2560)
|
||||
U.log("sounds heard:", table.concat(heard, " "))
|
||||
local jingle = false
|
||||
for _, name in ipairs(heard) do
|
||||
if name == "Heal_Ailment" then jingle = true end
|
||||
end
|
||||
check("the stat line carried SFX_HEAL_AILMENT", jingle)
|
||||
|
||||
local partyUp = false
|
||||
for _, s in ipairs(game.stack.states or {}) do
|
||||
if getmetatable(s) == PartyMenu then partyUp = true end
|
||||
end
|
||||
check("the party menu is still drawn under the message", partyUp)
|
||||
|
||||
U.shot(game, SHOT_DIR .. "/bug1805_vitamin.png")
|
||||
U.log("captured", SHOT_DIR .. "/bug1805_vitamin.png")
|
||||
|
||||
U.log("NIDORINO's HEALTH rose just now: the short cure jingle should sound")
|
||||
U.log("as that line types, with the party list still on screen behind the")
|
||||
U.log("box, and only then does the menu come down. The bug was silence")
|
||||
U.log("plus the list vanishing to the map the instant A was pressed.")
|
||||
U.log("Two HP UPs are left in the bag to try it again.")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -21,5 +21,13 @@ local xml = meta:read("*a")
|
||||
meta:close()
|
||||
check(xml:find("<releases>", 1, true), "AppStream metainfo includes releases")
|
||||
check(xml:find("<release ", 1, true), "AppStream metainfo includes a release entry")
|
||||
check(xml:find("<project_license>MIT</project_license>", 1, true),
|
||||
"AppStream metainfo declares MIT project license")
|
||||
check(not xml:find("LicenseRef-proprietary", 1, true),
|
||||
"AppStream metainfo must not claim proprietary")
|
||||
check(xml:find('url type="homepage">https://gen1re.com/</url>', 1, true),
|
||||
"AppStream metainfo homepage is gen1re.com")
|
||||
check(xml:find('url type="vcs-browser">https://github.com/bryanthaboi/gen1recomp</url>', 1, true),
|
||||
"AppStream metainfo vcs-browser points at the public repo")
|
||||
|
||||
print("ok")
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
-- data/moves/animations.asm:401-403
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
love = require("tests.love_stub")
|
||||
|
||||
local T = require("tests.harness")
|
||||
local UI = require("src.ui.gen2.BattleState")
|
||||
local AnimRunner = require("src.battle.gen2.AnimRunner")
|
||||
|
||||
local runner = AnimRunner.new({ animId = "ANIM_THROW_POKE_BALL", battleTurn = 0 })
|
||||
T.eq(runner.animId, "ANIM_THROW_POKE_BALL",
|
||||
"the runner exposes the anim id BattleState latches on")
|
||||
T.eq(runner.env.animId, "ANIM_THROW_POKE_BALL",
|
||||
"the object/bg env still sees the same id")
|
||||
|
||||
-- A real runner skipped with B must still hide the caught mon.
|
||||
do
|
||||
local s = setmetatable({
|
||||
anim = runner,
|
||||
ballThrow = { caught = true },
|
||||
picHidden = { player = false, enemy = false },
|
||||
}, { __index = UI })
|
||||
local input = { wasPressed = function(_, key) return key == "b" end }
|
||||
s:stepAnim(input)
|
||||
T.eq(s.picHidden.enemy, true, "a B-skipped catch latches with a real runner")
|
||||
end
|
||||
|
||||
do
|
||||
local free = AnimRunner.new({ animId = "ANIM_THROW_POKE_BALL", battleTurn = 0 })
|
||||
local s = setmetatable({
|
||||
anim = free,
|
||||
ballThrow = { caught = false },
|
||||
picHidden = { player = false, enemy = false },
|
||||
}, { __index = UI })
|
||||
local input = { wasPressed = function(_, key) return key == "b" end }
|
||||
s:stepAnim(input)
|
||||
T.eq(s.picHidden.enemy, false, "a B-skipped break-free still does not latch")
|
||||
end
|
||||
|
||||
T.finish("gen2 ball anim id bug 1804")
|
||||
@@ -0,0 +1,67 @@
|
||||
-- pokecrystal constants/engine_flags.asm:66-92 vs pokegold :65-91
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.harness")
|
||||
local FieldMoves = require("src.world.gen2.FieldMoves")
|
||||
|
||||
-- pokegold constants/engine_flags.asm:65-91, in const_def order from
|
||||
-- ENGINE_RADIO_CARD = 0: PALLET 52 .. INDIGO_PLATEAU 63, NEW_BARK 64 ..
|
||||
-- SILVER_CAVE 75.
|
||||
local GOLD_IDS = {
|
||||
SPAWN_PALLET = 52, SPAWN_VIRIDIAN = 53, SPAWN_PEWTER = 54,
|
||||
SPAWN_CERULEAN = 55, SPAWN_ROCK_TUNNEL = 56, SPAWN_VERMILION = 57,
|
||||
SPAWN_LAVENDER = 58, SPAWN_SAFFRON = 59, SPAWN_CELADON = 60,
|
||||
SPAWN_FUCHSIA = 61, SPAWN_CINNABAR = 62, SPAWN_INDIGO = 63,
|
||||
SPAWN_NEW_BARK = 64, SPAWN_CHERRYGROVE = 65, SPAWN_VIOLET = 66,
|
||||
SPAWN_AZALEA = 67, SPAWN_CIANWOOD = 68, SPAWN_GOLDENROD = 69,
|
||||
SPAWN_OLIVINE = 70, SPAWN_ECRUTEAK = 71, SPAWN_MAHOGANY = 72,
|
||||
SPAWN_LAKE_OF_RAGE = 73, SPAWN_BLACKTHORN = 74, SPAWN_MT_SILVER = 75,
|
||||
}
|
||||
|
||||
local function byFlag()
|
||||
local out = {}
|
||||
for _, row in ipairs(FieldMoves.FLYPOINTS) do out[row.spawn] = row.flag end
|
||||
return out
|
||||
end
|
||||
|
||||
-- A Gold cache carries no engineFlagOrder; the table keeps its literal ids.
|
||||
FieldMoves.bindEngineFlags(nil)
|
||||
local gold = byFlag()
|
||||
for spawn, id in pairs(GOLD_IDS) do
|
||||
T.eq(gold[spawn], id, spawn .. " keeps the pokegold id with no order")
|
||||
end
|
||||
|
||||
-- pokecrystal constants/engine_flags.asm:25 ENGINE_MOBILE_SYSTEM at index 16
|
||||
-- shifts every later id up exactly one.
|
||||
local crystalOrder = {}
|
||||
for _, row in ipairs(FieldMoves.FLYPOINTS) do
|
||||
-- order[i] names id i-1, so the Crystal id (gold + 1) sits at gold + 2
|
||||
crystalOrder[GOLD_IDS[row.spawn] + 2] = row.name
|
||||
end
|
||||
FieldMoves.bindEngineFlags(crystalOrder)
|
||||
local crystal = byFlag()
|
||||
for spawn, id in pairs(GOLD_IDS) do
|
||||
T.eq(crystal[spawn], id + 1, spawn .. " rebinds one higher under Crystal")
|
||||
end
|
||||
|
||||
-- The Kanto gate reads SPAWN_INDIGO through the rebound id: Crystal's 64,
|
||||
-- which the old table misread as Gold's SPAWN_NEW_BARK.
|
||||
local save = { engineFlags = { [64] = true } }
|
||||
T.check(FieldMoves.hasVisitedSpawn(save, "SPAWN_INDIGO"),
|
||||
"Crystal flag 64 is the INDIGO_PLATEAU flypoint")
|
||||
local kanto = FieldMoves.flyPoints(save, nil, "kanto")
|
||||
T.eq(#kanto, 1, "the Kanto half opens on it")
|
||||
T.eq(kanto[1].spawn, "SPAWN_INDIGO", "with the plateau row itself")
|
||||
|
||||
-- And a rebind back to a Gold cache restores the literal ids.
|
||||
FieldMoves.bindEngineFlags(nil)
|
||||
local again = byFlag()
|
||||
for spawn, id in pairs(GOLD_IDS) do
|
||||
T.eq(again[spawn], id, spawn .. " returns to the pokegold id")
|
||||
end
|
||||
local goldSave = { engineFlags = { [63] = true } }
|
||||
T.check(FieldMoves.hasVisitedSpawn(goldSave, "SPAWN_INDIGO"),
|
||||
"Gold flag 63 is INDIGO_PLATEAU again")
|
||||
|
||||
T.finish("gen2 flypoint rebind bug 1836")
|
||||
@@ -0,0 +1,79 @@
|
||||
-- ../pokecrystal/engine/gfx/load_pics.asm:105-158
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
love = require("tests.love_stub")
|
||||
|
||||
local T = require("tests.harness")
|
||||
local Assets = require("src.render.Assets")
|
||||
local UI = require("src.ui.gen2.BattleState")
|
||||
|
||||
local FRONT = "assets/generated/battle/front/cyndaquil.png"
|
||||
local SHEET = "assets/generated/battle/anim/cyndaquil.png"
|
||||
local MOD_FRONT = "mods/skin/overrides/battle/front/cyndaquil.png"
|
||||
local MOD_SHEET = "mods/skin/overrides/battle/anim/cyndaquil.png"
|
||||
|
||||
local image = { getDimensions = function() return 40, 200 end }
|
||||
Assets.image = function() return image end
|
||||
|
||||
local overrides = {}
|
||||
Assets.resolve = function(path) return overrides[path] or path end
|
||||
|
||||
local mon = { species = "CYNDAQUIL" }
|
||||
local anim = { sheet = SHEET, tiles = 5, play = { { 1 } } }
|
||||
|
||||
local function newSelf(picPath)
|
||||
local self = setmetatable({
|
||||
pokemon = { CYNDAQUIL = { spriteFront = FRONT, anim = anim } },
|
||||
picCache = {},
|
||||
}, { __index = UI })
|
||||
if picPath then
|
||||
self.pic = function() return image, false, picPath end
|
||||
end
|
||||
return self
|
||||
end
|
||||
|
||||
-- Stock cache, no mod: the sheet still animates.
|
||||
do
|
||||
overrides = {}
|
||||
local s = newSelf()
|
||||
s:startFrontAnim(mon)
|
||||
T.check(s.frontAnim ~= nil, "an unmodded Crystal front pic still animates")
|
||||
T.eq(s.frontAnim and s.frontAnim.sheet, image, "off the extracted sheet")
|
||||
end
|
||||
|
||||
-- Gold and Silver have no anim row at all and never reach any of this.
|
||||
do
|
||||
overrides = {}
|
||||
local s = newSelf()
|
||||
s.pokemon.CYNDAQUIL.anim = nil
|
||||
s:startFrontAnim(mon)
|
||||
T.eq(s.frontAnim, nil, "a cache with no anim row is untouched")
|
||||
s.pokemon.CYNDAQUIL.anim = anim
|
||||
end
|
||||
|
||||
-- The bug: the static pic replaced, the sheet left stock. Holding the static
|
||||
-- pic for the whole scene is the only way not to flicker stock art over it.
|
||||
do
|
||||
overrides = { [FRONT] = MOD_FRONT }
|
||||
local s = newSelf()
|
||||
s:startFrontAnim(mon)
|
||||
T.eq(s.frontAnim, nil, "an overridden front pic suppresses the stock frames")
|
||||
end
|
||||
|
||||
do
|
||||
overrides = {}
|
||||
local s = newSelf(MOD_FRONT)
|
||||
s:startFrontAnim(mon)
|
||||
T.eq(s.frontAnim, nil, "a pokemon.sprite path also suppresses them")
|
||||
end
|
||||
|
||||
-- A mod that ships both halves keeps its animation.
|
||||
do
|
||||
overrides = { [FRONT] = MOD_FRONT, [SHEET] = MOD_SHEET }
|
||||
local s = newSelf()
|
||||
s:startFrontAnim(mon)
|
||||
T.check(s.frontAnim ~= nil, "a mod that ships a sheet as well still animates")
|
||||
end
|
||||
|
||||
T.finish("gen2 front anim mod sheet bug 1827")
|
||||
@@ -0,0 +1,386 @@
|
||||
-- Importing a Gen 2 cart save (Gold, Silver, Crystal).
|
||||
-- luajit tests/gen2_save_import_test.lua
|
||||
-- Also dofile'd by tests/run_tests.lua.
|
||||
--
|
||||
-- The save this builds is synthesized rather than checked in, the same rule
|
||||
-- tests/save_convert_tests.lua follows for Gen 1: a real .sav is personal
|
||||
-- data. Point POKEPORT_GEN2_SAV_FIXTURE at one to run the audit at the
|
||||
-- bottom against your own Gold/Silver/Crystal save.
|
||||
--
|
||||
-- Every offset under test comes from src/save_convert/Gen2Layout.lua, which
|
||||
-- tools/gen2_sram_offsets.py generates from a pret build. The reason that
|
||||
-- matters is here in miniature: Gold and Crystal disagree on almost every
|
||||
-- field, so reading one with the other's table is not a near miss, it is a
|
||||
-- party count of 133.
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
love = love or require("tests.love_stub")
|
||||
|
||||
local T = require("tests.harness")
|
||||
local check, eq = T.check, T.eq
|
||||
|
||||
local Gen2Save = require("src.save_convert.Gen2Save")
|
||||
local Gen2Layout = require("src.save_convert.Gen2Layout")
|
||||
local SaveConvert = require("src.save_convert.SaveConvert")
|
||||
|
||||
local SIZE = Gen2Save.SAVE_SIZE
|
||||
|
||||
-- A byte-addressable save under construction.
|
||||
local function blank()
|
||||
local b = {}
|
||||
for i = 0, SIZE - 1 do b[i] = 0 end
|
||||
return b
|
||||
end
|
||||
local function put(b, at, ...)
|
||||
local vals = { ... }
|
||||
for i, v in ipairs(vals) do b[at + i - 1] = v % 256 end
|
||||
end
|
||||
local function putName(b, at, name)
|
||||
for i = 1, #name do b[at + i - 1] = 0x80 + (name:byte(i) - 65) end
|
||||
b[at + #name] = 0x50
|
||||
end
|
||||
local function sealOne(b, L)
|
||||
b[L.sCheckValue1] = 0x63
|
||||
b[L.sCheckValue2] = 0x7F
|
||||
local sum = 0
|
||||
for i = L.sGameData, L.sGameDataEnd - 1 do sum = (sum + b[i]) % 65536 end
|
||||
b[L.sChecksum] = sum % 256
|
||||
b[L.sChecksum + 1] = math.floor(sum / 256) % 256
|
||||
end
|
||||
|
||||
-- A real cart seals both copies, and the game rewrites the backup from the
|
||||
-- primary on every successful load.
|
||||
local function seal(b, L)
|
||||
if L.backup then
|
||||
for i = 0, (L.sGameDataEnd - L.sGameData) - 1 do
|
||||
b[L.backup.sGameData + i] = b[L.sGameData + i]
|
||||
end
|
||||
sealOne(b, L.backup)
|
||||
end
|
||||
sealOne(b, L)
|
||||
end
|
||||
local function pack(b)
|
||||
local out = {}
|
||||
for i = 0, SIZE - 1 do out[i + 1] = string.char(b[i]) end
|
||||
return table.concat(out)
|
||||
end
|
||||
|
||||
-- A save with one known Pokemon in the party and one in box 3.
|
||||
local function build(version)
|
||||
local L = Gen2Save.layoutFor(version)
|
||||
local b = blank()
|
||||
putName(b, L.wPlayerName, "ASH")
|
||||
putName(b, L.wRivalName, "GARY")
|
||||
putName(b, L.wMomsName, "MOM")
|
||||
put(b, L.wPlayerID, 0x12, 0x34) -- big-endian 0x1234
|
||||
put(b, L.wMoney, 0x01, 0xE2, 0x40) -- 123456
|
||||
put(b, L.wBadges, 0x05) -- ZEPHYR + PLAIN
|
||||
put(b, L.wPartyCount, 1)
|
||||
put(b, L.wPartySpecies, 155)
|
||||
local mon = L.wPartyMons
|
||||
put(b, mon, 155) -- species
|
||||
put(b, mon + 1, 0) -- no held item
|
||||
put(b, mon + 2, 33, 43, 0, 0) -- two moves
|
||||
put(b, mon + 6, 0x12, 0x34) -- OT id
|
||||
put(b, mon + 0x15, 0x9F, 0x6A) -- DVs: a=9 d=15 s=6 sp=10
|
||||
put(b, mon + 0x1B, 200) -- happiness
|
||||
put(b, mon + 0x1F, 42) -- level
|
||||
put(b, mon + 0x20, 0x10) -- BRN (bit 4)
|
||||
put(b, mon + 0x22, 0x00, 0x64) -- hp 100
|
||||
put(b, mon + 0x24, 0x00, 0x64) -- maxHp 100
|
||||
put(b, mon + 0x26, 0x00, 0x37) -- attack 55
|
||||
putName(b, L.wPartyMonNicknames, "FLAME")
|
||||
putName(b, L.wPartyMonOTs, "ASH")
|
||||
put(b, L.wMapGroup, 21); put(b, L.wMapNumber, 14)
|
||||
put(b, L.wNumItems, 1); put(b, L.wItems, 20, 3); put(b, L.wItems + 2, 0xFF)
|
||||
put(b, L.wNumKeyItems, 1); put(b, L.wKeyItems, 7); put(b, L.wKeyItems + 1, 0xFF)
|
||||
put(b, L.wNumBalls, 1); put(b, L.wBalls, 5, 9); put(b, L.wBalls + 2, 0xFF)
|
||||
-- box 3, one Pokemon
|
||||
local box = L.boxes[3]
|
||||
put(b, box, 1)
|
||||
put(b, box + 0x01, 7)
|
||||
put(b, box + 0x16, 7)
|
||||
put(b, box + 0x16 + 0x1F, 15)
|
||||
putName(b, box + 0x296, "ASH")
|
||||
putName(b, box + 0x372, "SQUIRT")
|
||||
seal(b, L)
|
||||
return pack(b)
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------------------
|
||||
-- What the cart holds comes back out
|
||||
-- ------------------------------------------------------------------
|
||||
|
||||
for _, version in ipairs({ "gold", "silver", "crystal" }) do
|
||||
local save, err = Gen2Save.decode(build(version), version)
|
||||
check(save ~= nil, version .. ": a valid save decodes -- " .. tostring(err))
|
||||
if save then
|
||||
eq(save.player.name, "ASH", version .. ": player name")
|
||||
eq(save.rival.name, "GARY", version .. ": rival name")
|
||||
eq(save.player.id, 0x1234, version .. ": trainer id is big-endian")
|
||||
eq(save.player.money, 123456, version .. ": money is a 3-byte big-endian")
|
||||
eq(save.player.badges.ZEPHYR, true, version .. ": badges are keyed by name")
|
||||
eq(save.player.badges.PLAIN, true, version .. ": and the second bit too")
|
||||
eq(save.player.badges.HIVE, nil, version .. ": an unearned badge is absent")
|
||||
eq(#save.party, 1, version .. ": party size")
|
||||
local m = save.party[1]
|
||||
eq(m.species, 155, version .. ": species, uncrosswalked")
|
||||
eq(m.level, 42, version .. ": level")
|
||||
eq(m.maxHp, 100, version .. ": max hp")
|
||||
eq(m.stats.attack, 55, version .. ": computed stats come off the party tail")
|
||||
eq(#m.moves, 2, version .. ": empty move slots are dropped")
|
||||
eq(m.nickname, "FLAME", version .. ": nickname")
|
||||
eq(m.ot, "ASH", version .. ": OT name")
|
||||
eq(m.happiness, 200, version .. ": happiness")
|
||||
-- DVs are nibbles, and the HP DV is rebuilt from the other four's low bits
|
||||
eq(m.dvs.attack, 9, version .. ": attack DV is the high nibble")
|
||||
eq(m.dvs.defense, 15, version .. ": defense DV is the low nibble")
|
||||
eq(m.dvs.speed, 6, version .. ": speed DV")
|
||||
eq(m.dvs.special, 10, version .. ": special DV")
|
||||
eq(m.dvs.hp, 12, version .. ": HP DV is rebuilt, not stored")
|
||||
eq(#save.boxes, 14, version .. ": every box is present")
|
||||
eq(#save.boxes[3], 1, version .. ": box 3 holds one Pokemon")
|
||||
eq(save.boxes[3][1].species, 7, version .. ": the stored species, uncrosswalked")
|
||||
eq(save.boxes[3][1].nickname, "SQUIRT", version .. ": box nicknames follow the OTs")
|
||||
eq(save.boxes[3][1].ot, "ASH", version .. ": box OT")
|
||||
eq(save.boxes[3][1].hp, nil, version .. ": a box mon carries no computed stats")
|
||||
end
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------------------
|
||||
-- The engine is keyed by name, so the codec has to translate
|
||||
-- ------------------------------------------------------------------
|
||||
--
|
||||
-- The cart stores numbers. save.inventory holds POTION, mon.species is
|
||||
-- "TYPHLOSION", data.pokemon is indexed by that name. Without this the import
|
||||
-- looks perfect and the engine cannot read a byte of it.
|
||||
|
||||
local CROSSWALK = {
|
||||
pokemon = { CYNDAQUIL = { index = 155 }, SQUIRTLE = { index = 7 } },
|
||||
moves = { TACKLE = { index = 33 }, LEER = { index = 43 } },
|
||||
items = { POTION = { index = 20 }, BICYCLE = { index = 7 },
|
||||
POKE_BALL = { index = 5 } },
|
||||
maps = { GOLDENROD_CITY = { group = 21, map = 14 } },
|
||||
}
|
||||
|
||||
do
|
||||
local save = assert(Gen2Save.decode(build("gold"), "gold", CROSSWALK))
|
||||
local m = save.party[1]
|
||||
eq(m.species, "CYNDAQUIL", "species is the engine's id, not the cart's number")
|
||||
eq(m.moves[1], "TACKLE", "and so are moves")
|
||||
eq(m.moves[2], "LEER", "both of them")
|
||||
eq(save.boxes[3][1].species, "SQUIRTLE", "boxes translate too")
|
||||
|
||||
-- One flat bag. Nothing in src reads save.keyItems or save.balls; PackMenu
|
||||
-- buckets save.inventory by each item's own pocket.
|
||||
eq(save.keyItems, nil, "there is no separate key item table")
|
||||
eq(save.balls, nil, "nor a separate ball table")
|
||||
eq(save.inventory.POTION, 3, "the ITEM pocket lands in the bag")
|
||||
eq(save.inventory.BICYCLE, 1, "so does KEY_ITEM, which is why you can cycle")
|
||||
eq(save.inventory.POKE_BALL, 9, "and BALL, which is why you can throw one")
|
||||
|
||||
-- Save.summary does `save.position.map or save.spawn`, so a save with no
|
||||
-- map key resumes at the spawn point with the old coordinates.
|
||||
eq(save.position.map, "GOLDENROD_CITY", "position names the map it is on")
|
||||
|
||||
-- save.pokedex.caught[species] = true, keyed the same way.
|
||||
local dexKey = next(save.pokedex.caught)
|
||||
check(dexKey == nil or type(dexKey) == "string" or type(dexKey) == "number",
|
||||
"the dex is keyed by species id")
|
||||
|
||||
-- Save.scrubEvents runs tonumber over the VALUE against Save.EVENT_BYTES,
|
||||
-- and tonumber(true) is nil, so a set of booleans is silently emptied.
|
||||
eq(type(save.events[0]), "number", "events are packed bytes, not booleans")
|
||||
local evCount = 0
|
||||
for _ in pairs(save.events) do evCount = evCount + 1 end
|
||||
eq(evCount, Gen2Save.EVENT_BYTES, "one entry per event byte")
|
||||
|
||||
-- 0 is truthy in Lua, so a raw status byte makes healthy mons look ill.
|
||||
eq(m.status, "brn", "status is the engine's class string")
|
||||
eq(save.boxes[3][1].status, nil, "and nil when healthy, not 0")
|
||||
end
|
||||
|
||||
-- Without a crosswalk the raw numbers survive rather than being dropped: an id
|
||||
-- this build cannot name is still the player's.
|
||||
do
|
||||
local save = assert(Gen2Save.decode(build("gold"), "gold"))
|
||||
eq(save.party[1].species, 155, "an unknown species keeps its cart number")
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------------------
|
||||
-- Export: what goes in comes back out, including what changed
|
||||
-- ------------------------------------------------------------------
|
||||
--
|
||||
-- Exporting a save onto the buffer it was decoded from proves nothing: every
|
||||
-- region encode does not write matches because it was copied. So this CHANGES
|
||||
-- things first, in each of the places export has to reach, and reads them back
|
||||
-- through a fresh decode.
|
||||
|
||||
do
|
||||
local ITEMS = {
|
||||
POTION = { index = 20, pocket = "ITEM" },
|
||||
BICYCLE = { index = 7, pocket = "KEY_ITEM" },
|
||||
POKE_BALL = { index = 5, pocket = "BALL" },
|
||||
TM_HEADBUTT = { index = 191, pocket = "TM_HM", tmNumber = 2 },
|
||||
}
|
||||
local data = {
|
||||
pokemon = CROSSWALK.pokemon, moves = CROSSWALK.moves,
|
||||
items = ITEMS, maps = CROSSWALK.maps,
|
||||
}
|
||||
local cart = build("gold")
|
||||
local save = assert(Gen2Save.decode(cart, "gold", data))
|
||||
|
||||
save.inventory = { POTION = 7, BICYCLE = 1, POKE_BALL = 12, TM_HEADBUTT = 1 }
|
||||
save.currentBox = 5
|
||||
save.party[1].status, save.party[1].statusTurns = "slp", 3
|
||||
save.party[1].pokerus = 0x34
|
||||
save.party[1].caughtData = 0x1234
|
||||
save.events[9] = 0xA5
|
||||
|
||||
local out = assert(Gen2Save.encode(save, "gold", cart, data))
|
||||
eq(#out, #cart, "the image keeps its size")
|
||||
local back = assert(Gen2Save.decode(out, "gold", data))
|
||||
|
||||
-- The bag: encode used to leave it at whatever the template carried, so a
|
||||
-- potion bought in a session never reached the cartridge.
|
||||
eq(back.inventory.POTION, 7, "the ITEM pocket is written")
|
||||
eq(back.inventory.BICYCLE, 1, "and KEY_ITEM")
|
||||
eq(back.inventory.POKE_BALL, 12, "and BALL")
|
||||
eq(back.inventory.TM_HEADBUTT, 1, "and TM_HM, by its tmNumber")
|
||||
eq(back.currentBox, 5, "the open box is written")
|
||||
|
||||
-- 0x1C-0x1E are the mon's, not the slot's: left to the template, reordering
|
||||
-- the party gives slot 1 the previous occupant's pokerus and caught data.
|
||||
eq(back.party[1].pokerus, 0x34, "pokerus rides the mon")
|
||||
eq(back.party[1].caughtData, 0x1234, "so does caught data")
|
||||
|
||||
eq(back.party[1].status, "slp", "status survives as a class")
|
||||
eq(back.party[1].statusTurns, 3, "with its turn count")
|
||||
eq(back.events[9], 0xA5, "event bytes are written")
|
||||
end
|
||||
|
||||
-- A save with no cartridge image behind it is refused, not invented.
|
||||
do
|
||||
local out, err = Gen2Save.encode({ player = { name = "A" } }, "gold", nil, {})
|
||||
eq(out, nil, "encode refuses a save with no lineage")
|
||||
check(type(err) == "string" and err:find("no cartridge image", 1, true) ~= nil,
|
||||
"and says why -- " .. tostring(err))
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------------------
|
||||
-- A save the real cartridge would open must not be refused
|
||||
-- ------------------------------------------------------------------
|
||||
--
|
||||
-- TryLoadSaveFile checks the primary, and on failure VerifyBackupChecksum and
|
||||
-- LoadBackupPlayerData. Refusing on the primary alone reports a save the game
|
||||
-- itself would load as corrupt, which is what #1832 was about.
|
||||
|
||||
do
|
||||
local L = Gen2Save.layoutFor("crystal")
|
||||
check(L.backup ~= nil, "Crystal carries a backup layout")
|
||||
eq(Gen2Save.layoutFor("gold").backup, nil,
|
||||
"Gold and Silver split theirs across three sections, so they have none")
|
||||
|
||||
local good = build("crystal")
|
||||
local at = L.sChecksum + 1
|
||||
local broken = good:sub(1, at - 1)
|
||||
.. string.char((good:byte(at) + 1) % 256) .. good:sub(at + 1)
|
||||
|
||||
eq(Gen2Save.checksumValid(broken, L), false, "the primary is now corrupt")
|
||||
eq(Gen2Save.checksumValid(broken, L.backup), true, "the backup is not")
|
||||
local save, err = Gen2Save.decode(broken, "crystal")
|
||||
check(save ~= nil, "so the save still opens -- " .. tostring(err))
|
||||
if save then
|
||||
eq(save.player.name, "ASH", "and reads the same player out of the backup")
|
||||
end
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------------------
|
||||
-- Gold's table is not Crystal's
|
||||
-- ------------------------------------------------------------------
|
||||
|
||||
do
|
||||
local goldBytes = build("gold")
|
||||
local wrong, err = Gen2Save.decode(goldBytes, "crystal")
|
||||
check(wrong == nil, "a Gold save read with Crystal's table is refused")
|
||||
check(type(err) == "string" and err:find("checksum", 1, true) ~= nil,
|
||||
"and refused by the guard, not by luck -- got: " .. tostring(err))
|
||||
check(Gen2Layout.goldSilver.wPartyMons ~= Gen2Layout.crystal.wPartyMons,
|
||||
"the two layouts really do disagree about where the party is")
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------------------
|
||||
-- Through the launcher's own entry point
|
||||
-- ------------------------------------------------------------------
|
||||
|
||||
do
|
||||
local save, err = SaveConvert.importSav(build("gold"), "gold", "gold")
|
||||
check(save ~= nil, "importSav accepts a Gen 2 save now -- " .. tostring(err))
|
||||
if save then
|
||||
eq(save.player.name, "ASH", "and returns the decoded player")
|
||||
eq(save.generation, 2, "tagged as Gen 2")
|
||||
-- Everything the cart does not carry still has to be there.
|
||||
check(type(save.mail) == "table", "mail falls back to the new-game default")
|
||||
check(type(save.hallOfFame) == "table", "so does the hall of fame")
|
||||
check(type(save.phoneContacts) == "table", "and the phone book")
|
||||
end
|
||||
-- Export needs the cartridge image the save came from. Without one it is
|
||||
-- refused rather than built from nothing.
|
||||
local _, expErr = SaveConvert.exportSav({ meta = {} }, "gold")
|
||||
check(type(expErr) == "string" and expErr:find("no cartridge image", 1, true) ~= nil,
|
||||
"a save with no cartridge behind it is refused -- got: " .. tostring(expErr))
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------------------
|
||||
-- Real-save audit (fixture-gated)
|
||||
-- ------------------------------------------------------------------
|
||||
|
||||
local fixture = os.getenv("POKEPORT_GEN2_SAV_FIXTURE")
|
||||
local fixtureVersion = os.getenv("POKEPORT_GEN2_SAV_VERSION") or "crystal"
|
||||
if not fixture then
|
||||
print("real-save audit skipped (set POKEPORT_GEN2_SAV_FIXTURE to a Gen 2 .sav, "
|
||||
.. "and POKEPORT_GEN2_SAV_VERSION to gold/silver/crystal)")
|
||||
else
|
||||
local f = io.open(fixture, "rb")
|
||||
local bytes = f and f:read("*a")
|
||||
if f then f:close() end
|
||||
check(bytes ~= nil, "the fixture is readable")
|
||||
if bytes then
|
||||
local save, err = Gen2Save.decode(bytes, fixtureVersion)
|
||||
check(save ~= nil, "a real cart save decodes -- " .. tostring(err))
|
||||
if save then
|
||||
check(#save.party >= 1 and #save.party <= 6, "party size is possible")
|
||||
check(#save.player.name > 0, "the player has a name")
|
||||
for i, mon in ipairs(save.party) do
|
||||
check(mon.species >= 1 and mon.species <= Gen2Save.NUM_SPECIES,
|
||||
("party %d species is a real species (%d)"):format(i, mon.species))
|
||||
check(mon.level >= 1 and mon.level <= 100,
|
||||
("party %d level is possible (%d)"):format(i, mon.level))
|
||||
check(mon.dvs.attack <= 15 and mon.dvs.special <= 15,
|
||||
("party %d DVs are nibbles"):format(i))
|
||||
end
|
||||
for b, box in ipairs(save.boxes) do
|
||||
check(#box <= Gen2Save.BOX_CAPACITY, ("box %d holds at most 20"):format(b))
|
||||
end
|
||||
|
||||
-- And back out. Without the item table the bag cannot be bucketed, so
|
||||
-- this asserts the refusal rather than a lossy write.
|
||||
local out, err = Gen2Save.encode(save, fixtureVersion, bytes, {})
|
||||
if out then
|
||||
eq(#out, #bytes, "the exported image keeps the cart's size, RTC and all")
|
||||
local back = assert(Gen2Save.decode(out, fixtureVersion))
|
||||
eq(back.player.name, save.player.name, "the player survives the round trip")
|
||||
eq(#back.party, #save.party, "and the party")
|
||||
local a, b2 = 0, 0
|
||||
for _, box in ipairs(save.boxes) do a = a + #box end
|
||||
for _, box in ipairs(back.boxes) do b2 = b2 + #box end
|
||||
eq(b2, a, "and every stored Pokemon")
|
||||
else
|
||||
check(err:find("pocket", 1, true) ~= nil,
|
||||
"or it refuses because the bag cannot be sorted -- " .. tostring(err))
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
T.finish()
|
||||
@@ -0,0 +1,127 @@
|
||||
-- A real Gen 2 cart save is 32786 bytes, and that must not be held against it
|
||||
-- (#1832).
|
||||
-- luajit tests/gen2_save_import_message_test.lua
|
||||
-- Also dofile'd by tests/run_tests.lua.
|
||||
--
|
||||
-- Gen 2 carts are MBC3+TIMER, so a real Gold/Silver/Crystal battery save
|
||||
-- carries an RTC footer past the 32768 bytes of SRAM. SaveFileIO.importToSlot
|
||||
-- judges anything that is not exactly SAVE_SIZE, and it used to judge it with
|
||||
-- pokered's main-data checksum whatever game it was for, so every real Gen 2
|
||||
-- save came back "save data checksum invalid" -- a perfectly good save
|
||||
-- reported as corrupt.
|
||||
--
|
||||
-- This is the size gate specifically. tests/gen2_save_import_test.lua covers
|
||||
-- the codec.
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
love = love or require("tests.love_stub")
|
||||
|
||||
-- SaveData is stubbed so this stays about the gate: the real one wants a
|
||||
-- filesystem and a registry, and neither is the subject here.
|
||||
local written = {}
|
||||
package.loaded["src.core.SaveData"] = {
|
||||
load = function() return { meta = {} } end,
|
||||
activeSlot = function() return "slot1" end,
|
||||
buildMeta = function(_, m) return m or {} end,
|
||||
createSlot = function() return "slot1" end,
|
||||
writeSlot = function(_, _, save) written[#written + 1] = save return true end,
|
||||
setActiveSlot = function() return true end,
|
||||
}
|
||||
|
||||
local T = require("tests.harness")
|
||||
local check, eq = T.check, T.eq
|
||||
|
||||
local Gen2Save = require("src.save_convert.Gen2Save")
|
||||
local SaveConvert = require("src.save_convert.SaveConvert")
|
||||
local SaveFileIO = require("src.import.SaveFileIO")
|
||||
|
||||
-- The size a real Gen 2 cart save actually is: 32768 of SRAM plus an 18-byte
|
||||
-- RTC footer. Verified against a real Gold cartridge in an emulator core,
|
||||
-- which reports the cart's backup size as exactly this.
|
||||
local GEN2_CART_SAVE_SIZE = 32786
|
||||
|
||||
local function validSave(version, trailing)
|
||||
local L = Gen2Save.layoutFor(version)
|
||||
local b = {}
|
||||
for i = 0, Gen2Save.SAVE_SIZE - 1 do b[i] = 0 end
|
||||
b[L.wPlayerName] = 0x80 -- "A", so the save is not entirely blank
|
||||
b[L.wPlayerName + 1] = 0x50
|
||||
b[L.sCheckValue1] = 0x63
|
||||
b[L.sCheckValue2] = 0x7F
|
||||
local sum = 0
|
||||
for i = L.sGameData, L.sGameDataEnd - 1 do sum = (sum + b[i]) % 65536 end
|
||||
b[L.sChecksum] = sum % 256
|
||||
b[L.sChecksum + 1] = math.floor(sum / 256) % 256
|
||||
local out = {}
|
||||
for i = 0, Gen2Save.SAVE_SIZE - 1 do out[i + 1] = string.char(b[i]) end
|
||||
return table.concat(out) .. string.rep("\0", trailing or 0)
|
||||
end
|
||||
|
||||
local function savFile(bytes)
|
||||
local path = os.tmpname()
|
||||
local f = assert(io.open(path, "wb"))
|
||||
f:write(bytes)
|
||||
f:close()
|
||||
return path
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------------------
|
||||
-- The report: a real-sized Gen 2 save imports
|
||||
-- ------------------------------------------------------------------
|
||||
|
||||
for _, version in ipairs({ "gold", "silver", "crystal" }) do
|
||||
local trailing = GEN2_CART_SAVE_SIZE - Gen2Save.SAVE_SIZE
|
||||
local path = savFile(validSave(version, trailing))
|
||||
written = {}
|
||||
-- force is deliberately NOT passed: the footer is the normal shape of a Gen
|
||||
-- 2 cart save, so it must not raise the oversize confirmation either.
|
||||
local ok, err = SaveFileIO.importToSlot(path, version)
|
||||
check(ok == true,
|
||||
version .. ": a 32786-byte cart save imports -- " .. tostring(err))
|
||||
check(type(err) ~= "string" or err:find("checksum", 1, true) == nil,
|
||||
version .. ": and is never blamed on a checksum -- " .. tostring(err))
|
||||
eq(#written, 1, version .. ": the slot is written")
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------------------
|
||||
-- The checksum question is asked of the right generation
|
||||
-- ------------------------------------------------------------------
|
||||
|
||||
do
|
||||
local gen2 = validSave("gold")
|
||||
eq(SaveConvert.mainChecksumValid(gen2, "gold"), true,
|
||||
"a Gen 2 save is valid under Gen 2's rule")
|
||||
eq(SaveConvert.mainChecksumValid(gen2), false,
|
||||
"and would read as invalid under Gen 1's, which is the whole bug")
|
||||
check(SaveConvert.isGen2Cart("crystal"), "Crystal is a Gen 2 cart")
|
||||
check(not SaveConvert.isGen2Cart("red"), "Red is not")
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------------------
|
||||
-- Gen 1 keeps its own diagnosis
|
||||
-- ------------------------------------------------------------------
|
||||
|
||||
do
|
||||
local path = savFile(string.rep("\0", GEN2_CART_SAVE_SIZE))
|
||||
local ok, err = SaveFileIO.importToSlot(path, "red", true)
|
||||
check(ok == false, "red: a corrupt oversize save is still refused")
|
||||
check(type(err) == "string" and err:find("checksum", 1, true) ~= nil,
|
||||
"red: still diagnosed by pokered's checksum -- got: " .. tostring(err))
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------------------
|
||||
-- Export needs the cartridge image behind the save
|
||||
-- ------------------------------------------------------------------
|
||||
|
||||
-- Export goes through the codec now, but only for a save that came from a
|
||||
-- cartridge: the regions it does not model are the ones the real game trusts
|
||||
-- on CONTINUE.
|
||||
for _, version in ipairs({ "gold", "silver", "crystal" }) do
|
||||
eq(SaveConvert.exportSupported(version), true, version .. ": export is supported")
|
||||
local out, why = SaveConvert.exportSav({ meta = {}, player = { name = "A" } }, version)
|
||||
eq(out, nil, version .. ": a save with no cartridge behind it is refused")
|
||||
check(type(why) == "string" and why:find("no cartridge image", 1, true) ~= nil,
|
||||
version .. ": and the reason is the missing image -- " .. tostring(why))
|
||||
end
|
||||
|
||||
T.finish()
|
||||
@@ -0,0 +1,72 @@
|
||||
-- engine/gfx/color.asm:342-359 / engine/gfx/cgb_layouts.asm:167-192
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.harness")
|
||||
|
||||
love = require("tests.love_stub")
|
||||
|
||||
local Chrome = require("src.ui.gen2.Chrome")
|
||||
local SummaryMenu = require("src.ui.gen2.SummaryMenu")
|
||||
|
||||
local printed, through
|
||||
local realPrint, realThrough = Chrome.print, Chrome.printThrough
|
||||
Chrome.print = function(text, tx, ty)
|
||||
printed[#printed + 1] = { text = text, x = tx, y = ty }
|
||||
return 0
|
||||
end
|
||||
Chrome.printThrough = function(text, tx, ty, palette)
|
||||
through[#through + 1] = { text = text, x = tx, y = ty, palette = palette }
|
||||
return 0
|
||||
end
|
||||
|
||||
local function fake(page, placements)
|
||||
local self = setmetatable({ page = page, mon = {}, party = {} },
|
||||
{ __index = SummaryMenu })
|
||||
self.greenPlacements = function() return placements end
|
||||
self.bluePlacements = function() return placements end
|
||||
self.pinkPlacements = function() return placements end
|
||||
self.drawVerticalDivider = function() end
|
||||
return self
|
||||
end
|
||||
|
||||
local rows = {
|
||||
{ text = "ITEM", x = 0, y = 8 },
|
||||
{ text = "MOVE", x = 0, y = 10 },
|
||||
}
|
||||
|
||||
for _, page in ipairs({ SummaryMenu.PINK_PAGE, SummaryMenu.GREEN_PAGE,
|
||||
SummaryMenu.BLUE_PAGE }) do
|
||||
local self = fake(page, rows)
|
||||
printed, through = {}, {}
|
||||
if page == SummaryMenu.GREEN_PAGE then
|
||||
self:drawGreenPage()
|
||||
elseif page == SummaryMenu.BLUE_PAGE then
|
||||
self:drawBluePage()
|
||||
else
|
||||
self:drawPlacements(self:pinkPlacements(), self:lowerColors())
|
||||
end
|
||||
T.eq(#printed, 0, "no lower-half string prints on the white default paper")
|
||||
T.eq(#through, #rows, "every lower-half string prints through a palette")
|
||||
local tint = SummaryMenu.PAGE_TINTS[page]
|
||||
for _, call in ipairs(through) do
|
||||
T.eq(call.palette and call.palette[1][1], tint[1], "paper red is the page tint")
|
||||
T.eq(call.palette and call.palette[1][2], tint[2], "paper green is the page tint")
|
||||
T.eq(call.palette and call.palette[1][3], tint[3], "paper blue is the page tint")
|
||||
T.eq(call.palette and call.palette[4][1], 0, "ink stays black")
|
||||
end
|
||||
end
|
||||
|
||||
-- Rows 0-7 are filled with palette $1, whose colour 0 is white, so the upper
|
||||
-- half keeps the default paper.
|
||||
do
|
||||
local self = fake(SummaryMenu.PINK_PAGE, rows)
|
||||
printed, through = {}, {}
|
||||
self:drawPlacements(rows)
|
||||
T.eq(#printed, #rows, "upper-half strings still take the default paper")
|
||||
T.eq(#through, 0, "upper-half strings do not force a page tint")
|
||||
end
|
||||
|
||||
Chrome.print, Chrome.printThrough = realPrint, realThrough
|
||||
|
||||
T.finish("gen2 stats text tint bug 1858")
|
||||
@@ -0,0 +1,56 @@
|
||||
-- engine/events/overworld.asm:1150-1165, engine/events/field_moves.asm:5-10
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
love = require("tests.love_stub")
|
||||
|
||||
local T = require("tests.harness")
|
||||
local Sound = require("src.core.Sound")
|
||||
local World = require("src.world.gen2.World")
|
||||
|
||||
local busy = false
|
||||
Sound.sfxBusy = function() return busy end
|
||||
|
||||
local log = {}
|
||||
local world = setmetatable({}, { __index = World })
|
||||
world.setNickname = function() end
|
||||
world.showText = function(_, _, done) if done then done() end end
|
||||
world.playSfxNamed = function() log[#log + 1] = "sfx" end
|
||||
world.replaceBlock = function(_, index, blockId)
|
||||
log[#log + 1] = "block:" .. tostring(index) .. ":" .. tostring(blockId)
|
||||
return true
|
||||
end
|
||||
|
||||
world:runWhirlpool({ text = "USED WHIRLPOOL", blockIndex = 7, replacement = 3 })
|
||||
T.eq(#log, 0, "the box closing does not swap the block yet")
|
||||
T.eq(world.fieldMove and world.fieldMove.phase, "whirlpoolsfx",
|
||||
"closing the box parks the WaitSFX phase")
|
||||
|
||||
busy = false
|
||||
world:updateFieldMove()
|
||||
T.same(log, { "sfx" }, "the first WaitSFX passes and SFX_SURF starts")
|
||||
|
||||
busy = true
|
||||
world:updateFieldMove()
|
||||
world:updateFieldMove()
|
||||
T.same(log, { "sfx" }, "the whirlpool is still on screen while the sfx plays")
|
||||
|
||||
busy = false
|
||||
world:updateFieldMove()
|
||||
T.same(log, { "sfx", "block:7:3" },
|
||||
"the block is swapped once the second WaitSFX clears")
|
||||
T.eq(world.fieldMove, nil, "and the phase is done")
|
||||
|
||||
-- The callback path (DisappearWhirlpool via callasm) carries no block, and
|
||||
-- must not invent one.
|
||||
do
|
||||
log = {}
|
||||
world.fieldMove = nil
|
||||
world:playWhirlpoolSound()
|
||||
busy = false
|
||||
world:updateFieldMove()
|
||||
world:updateFieldMove()
|
||||
T.same(log, { "sfx" }, "a bare sound call swaps nothing")
|
||||
end
|
||||
|
||||
T.finish("gen2 whirlpool block after sfx bug 1862")
|
||||
@@ -85,4 +85,31 @@ do
|
||||
"MOD INDEX panel does not force the full MODS list")
|
||||
end
|
||||
|
||||
do
|
||||
local calls = 0
|
||||
local old = LauncherMods.list
|
||||
LauncherMods.list = function()
|
||||
calls = calls + 1
|
||||
return {
|
||||
{ id = "scoped", name = "Scoped", targetsHere = true },
|
||||
{ id = "other", name = "Other", targetsHere = false },
|
||||
}
|
||||
end
|
||||
local imp = setmetatable({
|
||||
modScope = "red",
|
||||
modStraysChecked = true,
|
||||
activeCart = {},
|
||||
}, RomImporter)
|
||||
imp:_refreshMods()
|
||||
local listed = calls
|
||||
check(listed >= 1, "refresh lists installed mods")
|
||||
eq(imp:_cartCaptureCount("red"), 1,
|
||||
"capture count is the targeting subset")
|
||||
eq(calls, listed,
|
||||
"Save as cart does not re-list after the panel already has the rows")
|
||||
eq(imp:_cartCaptureCount("red"), 1, "a second count is the same answer")
|
||||
eq(calls, listed, "and still does not re-list")
|
||||
LauncherMods.list = old
|
||||
end
|
||||
|
||||
T.finish("launcher_navigation_perf")
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
-- A link switch runs SwitchPlayerMon on the switcher and SwitchEnemyMon on
|
||||
-- the peer (core.asm:2418-2422, trainer_ai.asm:596-599) (#1562).
|
||||
-- luajit tests/run_engine.lua
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.modkit")
|
||||
local Data = T.fixtures.fresh()
|
||||
local Link = require("tests.modkit.link")
|
||||
local Net = require("src.link.Net")
|
||||
local Protocol = require("src.link.Protocol")
|
||||
local LinkBattle = require("src.link.LinkBattle")
|
||||
|
||||
math.randomseed(4242)
|
||||
local Input = Link.prepare(Data)
|
||||
|
||||
local gameA = Link.fakeGame(Data, { "FIXMON_A", "FIXMON_B" }, { name = "RED" })
|
||||
local gameB = Link.fakeGame(Data, { "FIXMON_A", "FIXMON_B" }, { name = "BLUE" })
|
||||
|
||||
local netA, netB = Net.loopbackPair()
|
||||
local packedA = Protocol.packParty(gameA.save.party)
|
||||
local packedB = Protocol.packParty(gameB.save.party)
|
||||
local battleA = LinkBattle.newHost(gameA, netA, {
|
||||
myParty = packedA, theirParty = packedB,
|
||||
theirName = "BLUE", seed = 987654321,
|
||||
})
|
||||
local battleB = LinkBattle.newGuest(gameB, netB, {
|
||||
myParty = packedB, theirParty = packedA,
|
||||
theirName = "RED", seed = 987654321,
|
||||
})
|
||||
|
||||
local function watch(battle)
|
||||
local seen = {}
|
||||
local sayNext, sayNextAuto = battle.sayNext, battle.sayNextAuto
|
||||
battle.sayNext = function(s, text) seen[#seen + 1] = text; return sayNext(s, text) end
|
||||
battle.sayNextAuto = function(s, text, d)
|
||||
seen[#seen + 1] = text
|
||||
return sayNextAuto(s, text, d)
|
||||
end
|
||||
return seen
|
||||
end
|
||||
local seenA, seenB = watch(battleA), watch(battleB)
|
||||
|
||||
local function count(list, needle)
|
||||
local n = 0
|
||||
for _, text in ipairs(list) do
|
||||
if text and text:find(needle, 1, true) then n = n + 1 end
|
||||
end
|
||||
return n
|
||||
end
|
||||
local function firstIndex(list, needle)
|
||||
for i, text in ipairs(list) do
|
||||
if text and text:find(needle, 1, true) then return i end
|
||||
end
|
||||
end
|
||||
|
||||
local resA, resB
|
||||
battleA.onFinish = function(r) resA = r end
|
||||
battleB.onFinish = function(r) resB = r end
|
||||
gameA.stack:push(battleA)
|
||||
gameB.stack:push(battleB)
|
||||
|
||||
local switched, retreatSeen = false, false
|
||||
for _ = 1, 60000 do
|
||||
if resA and resB then break end
|
||||
Input.pressed = { a = true }
|
||||
if not switched and battleA.phase == "menu" and battleA.playerParty[2] then
|
||||
battleA:resolveSwitch(battleA.playerParty[2])
|
||||
switched = true
|
||||
end
|
||||
for _, g in ipairs({ gameA, gameB }) do
|
||||
local top = g.stack:top()
|
||||
if top and top.forceSwitch and top.party then
|
||||
for i, mon in ipairs(top.party) do
|
||||
if mon.hp > 0 then top.index = i break end
|
||||
end
|
||||
end
|
||||
g.stack:update(1 / 60)
|
||||
end
|
||||
if battleA.shrinkOut then retreatSeen = true end
|
||||
end
|
||||
|
||||
T.check(switched, "the host got a menu phase to switch from")
|
||||
T.eq(count(seenA, "Come back!"), 1,
|
||||
"RetreatMon prints once, for the voluntary switch only")
|
||||
T.check(retreatSeen, "AnimateRetreatingPlayerMon runs on the switcher")
|
||||
T.eq(count(seenB, "drew"), 1, "the peer prints AIBattleWithdrawText once")
|
||||
|
||||
local withdrawA = firstIndex(seenA, "Come back!")
|
||||
local sendA = firstIndex(seenA, "Go! ") or firstIndex(seenA, "Do it! ")
|
||||
or firstIndex(seenA, "Get'm! ")
|
||||
T.check(sendA ~= nil and withdrawA < sendA, "the recall line comes before the send-out")
|
||||
local withdrawB = firstIndex(seenB, "drew")
|
||||
local sendB = firstIndex(seenB, "sent\nout")
|
||||
T.check(sendB ~= nil and withdrawB < sendB, "and so does the peer's withdraw line")
|
||||
|
||||
T.check(battleA.localHashes ~= nil and battleB.localHashes ~= nil,
|
||||
"both sides still record per-turn hashes")
|
||||
local mismatch
|
||||
for turn, hash in pairs(battleA.localHashes) do
|
||||
local other = battleB.localHashes[turn]
|
||||
if other and other ~= hash then mismatch = mismatch or turn end
|
||||
end
|
||||
T.check(mismatch == nil, "the added lines leave the lockstep state hash alone")
|
||||
|
||||
T.finish("link switch prints the recall lines (#1562)")
|
||||
@@ -5,7 +5,7 @@
|
||||
-- GetMonFieldMoves (engine/menus/text_box.asm, called from
|
||||
-- start_sub_menus.asm) walks wPartyMon1Moves in slot order -- so a mon
|
||||
-- with STRENGTH in slot 3 and SURF in slot 4 shows STRENGTH then SURF on
|
||||
-- top, with STATS/SWITCH closing the list. The port used to build the
|
||||
-- top, with STATS/SWITCH/CANCEL closing the list. The port used to build the
|
||||
-- submenu as STATS/SWITCH first and tack the field moves on the bottom.
|
||||
-- ROM-free: drives the real PartyMenu over stub game state.
|
||||
-- luajit tests/engine/party_fieldmove_order_bug792.lua
|
||||
@@ -74,7 +74,8 @@ local pm = PartyMenu.new(game, {})
|
||||
game.stack:push(pm)
|
||||
openSubmenu(pm)
|
||||
check(pm.submenu, "A on a party mon opens the submenu")
|
||||
same(actions(pm.subItems), { "strength", "surf", "stats", "switch" },
|
||||
same(actions(pm.subItems),
|
||||
{ "strength", "surf", "stats", "switch", "cancel" },
|
||||
"field moves sit above STATS/SWITCH in move-list order (#792)")
|
||||
eq(pm.subItems[1].label, "STRENGTH", "slot 3's STRENGTH leads the submenu")
|
||||
eq(pm.subItems[2].label, "SURF", "slot 4's SURF follows it")
|
||||
@@ -84,8 +85,8 @@ local plain = newGame({ { id = "TACKLE", pp = 35 } })
|
||||
local pm2 = PartyMenu.new(plain, {})
|
||||
plain.stack:push(pm2)
|
||||
openSubmenu(pm2)
|
||||
same(actions(pm2.subItems), { "stats", "switch" },
|
||||
"no field moves: the submenu is just STATS/SWITCH")
|
||||
same(actions(pm2.subItems), { "stats", "switch", "cancel" },
|
||||
"no field moves: the submenu is just PokemonMenuEntries")
|
||||
|
||||
-- GetMonFieldMoves is badge-blind: the same Lapras without the badges
|
||||
-- still lists both HMs, and .outOfBattleMovePointers refuses on
|
||||
@@ -95,7 +96,8 @@ local noBadges = newGame(
|
||||
local pm3 = PartyMenu.new(noBadges, {})
|
||||
noBadges.stack:push(pm3)
|
||||
openSubmenu(pm3)
|
||||
same(actions(pm3.subItems), { "strength", "surf", "stats", "switch" },
|
||||
same(actions(pm3.subItems),
|
||||
{ "strength", "surf", "stats", "switch", "cancel" },
|
||||
"the HM moves are listed without the badges (#1022)")
|
||||
|
||||
T.finish("party_fieldmove_order_bug792")
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
-- Out of battle the per-mon submenu ends STATS/SWITCH/CANCEL: pokered
|
||||
-- prints one string, PokemonMenuEntries (engine/menus/text_box.asm:504-507),
|
||||
-- under whatever field moves the mon has, and start_sub_menus.asm:71-75
|
||||
-- treats the last row as a real selection that leaves the party menu
|
||||
-- (.exitMenu, start_sub_menus.asm:26-30) where B only returns to the list.
|
||||
-- The port stopped at SWITCH (#1833). ROM-free: drives the real PartyMenu
|
||||
-- over stub game state.
|
||||
-- luajit tests/engine/party_submenu_cancel_bug1833.lua
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.harness")
|
||||
local check, eq, same = T.check, T.eq, T.same
|
||||
love = love or require("tests.love_stub")
|
||||
|
||||
local PartyMenu = require("src.ui.PartyMenu")
|
||||
|
||||
local function newGame(moves)
|
||||
local game = {
|
||||
data = { pokemon = { LAPRAS = { name = "LAPRAS" } } },
|
||||
save = {
|
||||
party = { { species = "LAPRAS", hp = 50, stats = { hp = 50 },
|
||||
level = 30, moves = moves } },
|
||||
inventory = {}, options = {}, flags = {},
|
||||
},
|
||||
overworld = { map = { def = { tileset = "OVERWORLD" },
|
||||
id = "PALLET_TOWN" },
|
||||
dark = false },
|
||||
}
|
||||
game.stack = {
|
||||
states = {},
|
||||
push = function(self, s) table.insert(self.states, s) end,
|
||||
pop = function(self) return table.remove(self.states) end,
|
||||
top = function(self) return self.states[#self.states] end,
|
||||
}
|
||||
game.input = {
|
||||
queue = {},
|
||||
wasPressed = function(self, btn) return self.queue[btn] or false end,
|
||||
isDown = function() return false end,
|
||||
}
|
||||
return game
|
||||
end
|
||||
|
||||
local function press(pm, btn)
|
||||
pm.game.input.queue = { [btn] = true }
|
||||
pm:update(1 / 60)
|
||||
pm.game.input.queue = {}
|
||||
end
|
||||
|
||||
local function actions(items)
|
||||
local out = {}
|
||||
for i, item in ipairs(items or {}) do out[i] = item.action end
|
||||
return out
|
||||
end
|
||||
|
||||
local game = newGame({ { id = "TACKLE", pp = 35 } })
|
||||
local cancelled = false
|
||||
local pm = PartyMenu.new(game, { onCancel = function() cancelled = true end })
|
||||
game.stack:push(pm)
|
||||
press(pm, "a")
|
||||
check(pm.submenu, "A on a party mon opens the submenu")
|
||||
same(actions(pm.subItems), { "stats", "switch", "cancel" },
|
||||
"the plain list is PokemonMenuEntries in full (#1833)")
|
||||
eq(pm.subItems[3].label, "CANCEL", "CANCEL closes the list")
|
||||
|
||||
-- CANCEL sits at the bottom, so one wrap upward reaches it
|
||||
press(pm, "up")
|
||||
eq(pm.subIndex, 3, "up from STATS wraps onto CANCEL")
|
||||
press(pm, "a")
|
||||
eq(#game.stack.states, 0, "choosing CANCEL leaves the party menu (.exitMenu)")
|
||||
check(cancelled, "and reports the cancel to the caller")
|
||||
|
||||
-- B out of the submenu is the other path: back to the party list, menu up
|
||||
local game2 = newGame({ { id = "TACKLE", pp = 35 } })
|
||||
local pm2 = PartyMenu.new(game2, {})
|
||||
game2.stack:push(pm2)
|
||||
press(pm2, "a")
|
||||
press(pm2, "b")
|
||||
check(not pm2.submenu, "B closes the submenu")
|
||||
eq(#game2.stack.states, 1, "B keeps the party menu up (start_sub_menus.asm:68-69)")
|
||||
|
||||
-- field moves still lead, with the three fixed rows under them
|
||||
local game3 = newGame({ { id = "STRENGTH", pp = 15 }, { id = "SURF", pp = 15 } })
|
||||
local pm3 = PartyMenu.new(game3, {})
|
||||
game3.stack:push(pm3)
|
||||
press(pm3, "a")
|
||||
same(actions(pm3.subItems), { "strength", "surf", "stats", "switch", "cancel" },
|
||||
"field moves sit above the whole of PokemonMenuEntries")
|
||||
|
||||
-- the in-battle list is its own three-row template and is unchanged
|
||||
local game4 = newGame({ { id = "SURF", pp = 15 } })
|
||||
local pm4 = PartyMenu.new(game4, { battle = {}, onSwitch = function() end })
|
||||
game4.stack:push(pm4)
|
||||
press(pm4, "a")
|
||||
same(actions(pm4.subItems), { "battle_switch", "stats", "cancel" },
|
||||
"battle keeps SwitchStatsCancelText's order (data/text_boxes.asm:33)")
|
||||
|
||||
T.finish()
|
||||
@@ -0,0 +1,127 @@
|
||||
-- The Pokédex list was a generic ListMenu with a title and a footer instead
|
||||
-- of the cart's CONTENTS screen, and Left/Right dragged the cursor with the
|
||||
-- page (#1829).
|
||||
-- engine/menus/pokedex.asm:161-199 (furniture), :242-273 (rows), :314-342
|
||||
-- (Left/Right move wListScrollOffset only), :208-222 (wDexMaxSeenMon).
|
||||
-- luajit tests/engine/pokedex_contents_bug1829.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")
|
||||
|
||||
-- stub Font: the screen draws through Font.draw/Font.drawCode only, and the
|
||||
-- real Font needs loaded page images this suite has no reason to touch.
|
||||
local calls = {}
|
||||
package.loaded["src.render.Font"] = {
|
||||
draw = function(text, x, y) calls[#calls + 1] = { text = text, x = x, y = y } end,
|
||||
drawCode = function(code, x, y) calls[#calls + 1] = { code = code, x = x, y = y } end,
|
||||
width = function(text) return #text * 8 end,
|
||||
split = function(text)
|
||||
local out = {}
|
||||
for i = 1, #text do out[i] = { from = i, to = i } end
|
||||
return out
|
||||
end,
|
||||
}
|
||||
|
||||
local PokedexMenu = require("src.ui.PokedexMenu")
|
||||
local Theme = require("src.ui.Theme")
|
||||
|
||||
local function hasText(text, x, y)
|
||||
for _, c in ipairs(calls) do
|
||||
if c.text == text and c.x == x and c.y == y then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
local function hasCode(code, x, y)
|
||||
for _, c in ipairs(calls) do
|
||||
if c.code == code and c.x == x and c.y == y then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local DEX_SIZE = 151
|
||||
local data = {
|
||||
pokemon = {},
|
||||
constants = { dexSize = DEX_SIZE, dexDigits = 3 },
|
||||
}
|
||||
for n = 1, DEX_SIZE do
|
||||
local id = ("DEXMON_%03d"):format(n)
|
||||
data.pokemon[id] = { id = id, name = ("MON%03d"):format(n), dex = n }
|
||||
end
|
||||
|
||||
-- seen 1..20, owned 1..3, so the list stops at 20 and row 2 carries a ball
|
||||
local function newGame()
|
||||
local save = { pokedex = { seen = {}, owned = {} } }
|
||||
for n = 1, 20 do save.pokedex.seen[("DEXMON_%03d"):format(n)] = true end
|
||||
for n = 1, 3 do save.pokedex.owned[("DEXMON_%03d"):format(n)] = true end
|
||||
return { data = data, save = save,
|
||||
stack = { push = function() end, pop = function() end,
|
||||
top = function() end } }
|
||||
end
|
||||
|
||||
local dex = PokedexMenu.new(newGame(), {})
|
||||
|
||||
-- ------------------------------------------------ wDexMaxSeenMon caps it
|
||||
eq(#dex.items, 20, "the list stops at the highest seen number, not at 151")
|
||||
eq(dex.items[20].name, "MON020", "the last row is the highest seen species")
|
||||
|
||||
-- ------------------------------------------------ Left / Right scroll only
|
||||
dex.index, dex.scroll = 3, 0
|
||||
dex:pageScroll(1)
|
||||
eq(dex.scroll, 7, "Right pages the scroll offset down seven rows")
|
||||
eq(dex.index - dex.scroll, 3, "the cursor keeps the screen row it was on")
|
||||
dex:pageScroll(1)
|
||||
eq(dex.scroll, 13, "the second page clamps to wDexMaxSeenMon - 7")
|
||||
eq(dex.index - dex.scroll, 3, "the clamped page still keeps the cursor row")
|
||||
dex:pageScroll(-1)
|
||||
eq(dex.scroll, 6, "Left pages back seven rows")
|
||||
eq(dex.index - dex.scroll, 3, "Left keeps the cursor row too")
|
||||
dex:pageScroll(-1)
|
||||
eq(dex.scroll, 0, "Left stops at the top of the list")
|
||||
eq(dex.index, 3, "the cursor is back where it started")
|
||||
|
||||
-- the witness for the bug: a whole-page jump used to move the cursor to the
|
||||
-- bottom row, which is what a page jump on any other list does
|
||||
check(dex.index - dex.scroll ~= 7,
|
||||
"the cursor did not slide to the last screen row (the ListMenu bug)")
|
||||
|
||||
-- ------------------------------------------------------------- the screen
|
||||
dex.index, dex.scroll = 1, 0
|
||||
calls = {}
|
||||
dex:draw()
|
||||
check(hasText("CONTENTS", 8, 8), "CONTENTS at hlcoord 1,1, not a POKéDEX title")
|
||||
check(hasText("SEEN", 128, 16), "SEEN label at hlcoord 16,2")
|
||||
check(hasText("20", 136, 24), "the seen count right-aligned in its 3-tile field")
|
||||
check(hasText("OWN", 128, 40), "OWN label at hlcoord 16,5")
|
||||
check(hasText("3", 144, 48), "the owned count right-aligned under it")
|
||||
check(not hasText("SEEN 20 OWN 3", 8, 136),
|
||||
"the counts are not a bottom footer line any more")
|
||||
check(hasText("DATA", 128, 80) and hasText("CRY", 128, 96)
|
||||
and hasText("AREA", 128, 112) and hasText("QUIT", 128, 128),
|
||||
"DATA/CRY/AREA/QUIT are permanent furniture at hlcoord 16,10")
|
||||
|
||||
check(hasText("001", 8, 16), "row 1's number sits one row above its name")
|
||||
check(hasText("MON001", 32, 24), "row 1's name is at column 4")
|
||||
check(hasCode(Theme.cursor, 0, 24), "the cursor is at column 0 on row 3")
|
||||
check(hasText("007", 8, 112) and hasText("MON007", 32, 120),
|
||||
"seven rows, two tiles apart, end on row 15")
|
||||
check(not hasText("MON008", 32, 136), "there is no eighth row")
|
||||
|
||||
-- the hollow ▷ pokered leaves under the side menu (PlaceUnfilledArrowMenuCursor)
|
||||
dex.hollowIndex = 1
|
||||
calls = {}
|
||||
dex:draw()
|
||||
check(hasCode(Theme.cursorHollow, 0, 24),
|
||||
"the chosen row keeps a hollow cursor while the side menu is open")
|
||||
|
||||
-- ------------------------------------------------------ unseen dashed rows
|
||||
local partial = newGame()
|
||||
partial.save.pokedex.seen.DEXMON_005 = nil
|
||||
local gapped = PokedexMenu.new(partial, {})
|
||||
eq(gapped.items[5].name, "----------",
|
||||
"an unseen number inside the range prints the dashed line")
|
||||
check(gapped.items[5].value == nil, "and cannot be chosen")
|
||||
|
||||
T.finish("pokedex contents bug 1829")
|
||||
@@ -23,7 +23,7 @@
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.harness")
|
||||
local check, eq = T.check, T.eq
|
||||
local check, eq, same = T.check, T.eq, T.same
|
||||
love = love or require("tests.love_stub")
|
||||
|
||||
-- Lazily-required inside the use branches, so seeding package.loaded before
|
||||
@@ -34,8 +34,15 @@ package.loaded["src.core.Sound"] = {
|
||||
}
|
||||
-- Real TextBoxes want a Font atlas. This flow only cares that a message
|
||||
-- opened, what it says, and what its onDone does.
|
||||
local jingles = {}
|
||||
package.loaded["src.render.TextBox"] = {
|
||||
new = function(_, text, done) return { textBox = true, text = text, done = done } end,
|
||||
soundOpts = function(_, sound, opts)
|
||||
jingles[#jingles + 1] = sound
|
||||
opts = opts or {}
|
||||
opts.auto = { sound = sound, wait = true }
|
||||
return opts
|
||||
end,
|
||||
}
|
||||
-- BagMenu and PartyMenu bind TextBox at require time, so they load against
|
||||
-- the stub; Screens caches its factory per id and must be told to forget.
|
||||
@@ -133,6 +140,7 @@ end
|
||||
-- The bug: three candies in the bag, use one in the field.
|
||||
do
|
||||
local game, mon = freshGame(3)
|
||||
jingles = {}
|
||||
local list, why = useFromBag(game, nil, "RARE_CANDY")
|
||||
if check(list ~= nil, "the bag opened and reached the picker: " .. tostring(why)) then
|
||||
eq(mon.level, 6, "the candy leveled the mon 5 -> 6")
|
||||
@@ -156,6 +164,10 @@ do
|
||||
check(box.text:find("level 6", 1, true) ~= nil,
|
||||
"and it names the new level: " .. tostring(box.text))
|
||||
end
|
||||
-- RareCandyText: text_far, sound_get_item_1, text_promptbutton
|
||||
-- (engine/menus/party_menu.asm:289-293)
|
||||
same(jingles, { "Get_Item1" },
|
||||
"the level-up line carries sound_get_item_1 and nothing else")
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -221,6 +221,13 @@ do
|
||||
local seen = {}
|
||||
package.loaded["src.save_convert.SaveConvert"] = {
|
||||
SAVE_SIZE = 32768,
|
||||
-- importToSlot asks this before it measures the bytes, so a save for a
|
||||
-- game with no codec is refused as that rather than as a bad checksum.
|
||||
-- The double has to answer it; "yes" is what keeps this case about the
|
||||
-- cache-name contract below and nothing else.
|
||||
importSupported = function() return true end,
|
||||
-- Red is not a Gen 2 cart, which is what this case uses.
|
||||
isGen2Cart = function() return false end,
|
||||
importSav = function(_, version, gameVersion)
|
||||
seen.import = { version = version, gameVersion = gameVersion }
|
||||
return nil, "stub"
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
-- The slot machine's menus sit where the original draws them (#1811).
|
||||
--
|
||||
-- The bet menu is `hlcoord 14,11 / b=5 / c=4 / TextBoxBorder`
|
||||
-- (engine/slots/slot_machine.asm:83-86), i.e. a 6x7 box whose bottom edge is
|
||||
-- the last row of the screen, with the multipliers at hlcoord 16,12 and the
|
||||
-- cursor at wTopMenuItemX 15 (asm:77-78, :87). "One more go?" is a
|
||||
-- TWO_OPTION_MENU at hlcoord 14,12 (asm:136-138) over the YES_NO_MENU body
|
||||
-- (width 4, height 3 -> a 6x5 box; data/yes_no_menu_strings.asm:10).
|
||||
-- The "Want to play?" prompt is not this screen's at all: PromptUserToPlaySlots
|
||||
-- asks it before LoadSlotMachineTiles (asm:9-23), so the screen opens on the
|
||||
-- bet menu.
|
||||
-- luajit tests/engine/slot_machine_boxes_bug1811.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")
|
||||
|
||||
-- Font wants a real atlas and this suite is about which box lands on which
|
||||
-- row, so it records the calls instead (tests/engine/bag_item_box_bug1521.lua's
|
||||
-- shape)
|
||||
local calls = {}
|
||||
package.loaded["src.render.Font"] = {
|
||||
BORDER = { tl = 1, tr = 2, bl = 3, br = 4, h = 5, v = 6 },
|
||||
draw = function(text, x, y) calls[#calls + 1] = { "draw", text, x, y } end,
|
||||
drawCode = function(code, x, y) calls[#calls + 1] = { "code", code, x, y } end,
|
||||
drawBox = function(tx, ty, tw, th) calls[#calls + 1] = { "box", tx, ty, tw, th } end,
|
||||
width = function(text) return #tostring(text) * 8 end,
|
||||
}
|
||||
package.loaded["src.core.Sound"] = { play = function() end }
|
||||
package.loaded["src.ui.SlotMachine"] = nil
|
||||
local SlotMachine = require("src.ui.SlotMachine")
|
||||
|
||||
local game = {
|
||||
data = { field = { slotWheels = {} }, text = {} },
|
||||
save = { coins = 100 },
|
||||
}
|
||||
|
||||
local function record(fn)
|
||||
calls = {}
|
||||
fn()
|
||||
return calls
|
||||
end
|
||||
|
||||
local function box(list, tx, ty)
|
||||
for _, c in ipairs(list) do
|
||||
if c[1] == "box" and c[2] == tx and c[3] == ty then return c end
|
||||
end
|
||||
end
|
||||
|
||||
local function drawn(list, text)
|
||||
for _, c in ipairs(list) do
|
||||
if c[1] == "draw" and c[2] == text then return c end
|
||||
end
|
||||
end
|
||||
|
||||
local function cursor(list)
|
||||
for _, c in ipairs(list) do
|
||||
if c[1] == "code" and c[2] == 0xED then return c end
|
||||
end
|
||||
end
|
||||
|
||||
local slots = SlotMachine.new(game, false)
|
||||
eq(slots.stage, "bet", "the screen opens on the bet menu, not a prompt")
|
||||
|
||||
local bet = record(function() slots:drawBottom() end)
|
||||
local betBox = box(bet, 14, 11)
|
||||
check(betBox ~= nil, "the bet menu box starts at (14,11)")
|
||||
eq(betBox and betBox[4], 6, "it is 6 tiles wide (c = 4)")
|
||||
eq(betBox and betBox[5], 7, "and 7 tall (b = 5), down to the last row")
|
||||
eq(betBox and (betBox[3] + betBox[5]), 18, "so its bottom edge is the screen bottom")
|
||||
local x3 = drawn(bet, "×3")
|
||||
eq(x3 and x3[3], 16 * 8, "the multipliers print at column 16")
|
||||
eq(x3 and x3[4], 12 * 8, "on row 12")
|
||||
local betCursor = cursor(bet)
|
||||
eq(betCursor and betCursor[3], 15 * 8, "the cursor sits at column 15")
|
||||
eq(betCursor and betCursor[4], 12 * 8, "on the ×3 row by default")
|
||||
|
||||
slots.stage = "onemore"
|
||||
slots.yesno = 1
|
||||
local more = record(function() slots:drawBottom() end)
|
||||
local moreBox = box(more, 14, 12)
|
||||
check(moreBox ~= nil, "the One more go? menu box starts at (14,12)")
|
||||
eq(moreBox and moreBox[4], 6, "it is 6 tiles wide")
|
||||
eq(moreBox and moreBox[5], 5, "and 5 tall (YES_NO_MENU 4x3)")
|
||||
check(box(more, 13, 12) == nil, "and not one column left of that")
|
||||
local yes, no = drawn(more, "YES"), drawn(more, "NO")
|
||||
eq(yes and yes[3], 16 * 8, "YES prints at column 16")
|
||||
eq(yes and yes[4], 13 * 8, "on row 13")
|
||||
eq(no and no[4], 14 * 8, "NO one row below it")
|
||||
local moreCursor = cursor(more)
|
||||
eq(moreCursor and moreCursor[3], 15 * 8, "its cursor sits at column 15")
|
||||
eq(moreCursor and moreCursor[4], 13 * 8, "starting on YES")
|
||||
|
||||
slots.yesno = 2
|
||||
local onNo = record(function() slots:drawBottom() end)
|
||||
eq(cursor(onNo) and cursor(onNo)[4], 14 * 8, "and moves to the NO row")
|
||||
|
||||
T.finish()
|
||||
@@ -0,0 +1,52 @@
|
||||
-- The START cursor is unsaved WRAM (ram/wram.asm:238-242) and lies outside
|
||||
-- sGameData (ram/sram.asm:17-21), so it must not ride the save file (#1821).
|
||||
-- luajit tests/run_engine.lua
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.modkit")
|
||||
local Data = T.fixtures.fresh()
|
||||
require("src.render.Font").load(Data)
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local StartMenu = require("src.ui.StartMenu")
|
||||
|
||||
local function stubGame(save)
|
||||
local stack = { states = {} }
|
||||
function stack:push(s) self.states[#self.states + 1] = s end
|
||||
function stack:pop() return table.remove(self.states) end
|
||||
function stack:top() return self.states[#self.states] end
|
||||
return {
|
||||
data = Data, save = save, stack = stack,
|
||||
input = { wasPressed = function() return false end,
|
||||
isDown = function() return false end },
|
||||
}
|
||||
end
|
||||
|
||||
do
|
||||
local save = SaveData.newGame()
|
||||
local game = stubGame(save)
|
||||
local menu = StartMenu.new(game)
|
||||
menu.index = math.min(3, #menu.items)
|
||||
menu:update(1 / 60)
|
||||
T.eq(game.startMenuIndex, menu.index, "the cursor slot lands on the game")
|
||||
T.check(save.startMenuIndex == nil, "and never on the save table")
|
||||
|
||||
local reopened = StartMenu.new(stubGame(save))
|
||||
T.eq(reopened.index, 1, "a fresh session opens on the top row")
|
||||
|
||||
local same = StartMenu.new(game)
|
||||
T.eq(same.index, menu.index, "the same session reopens where it was")
|
||||
end
|
||||
|
||||
do
|
||||
local save = SaveData.newGame()
|
||||
save.startMenuIndex = 5
|
||||
local encoded = SaveData.encodeForTest and SaveData.encodeForTest(save) or nil
|
||||
T.check(encoded == nil or encoded:find("startMenuIndex") == nil,
|
||||
"no serializer writes the cursor back out")
|
||||
local game = stubGame(save)
|
||||
local menu = StartMenu.new(game)
|
||||
T.eq(menu.index, 1,
|
||||
"a legacy save file's stored slot no longer reopens the menu on it")
|
||||
end
|
||||
|
||||
T.finish("START menu cursor is session state (#1821)")
|
||||
@@ -0,0 +1,113 @@
|
||||
-- The forget-a-move text is not one uninterrupted string: pokered's
|
||||
-- OneTwoAndText holds the box with text_pause, plays SFX_SWAP, prints
|
||||
-- " Poof!" and holds it again before the paragraph break
|
||||
-- (engine/pokemon/learn_move.asm:208-222, home/text.asm:492-504). The port
|
||||
-- typed all of it straight through in silence (#1818). TextBox.PAUSE now
|
||||
-- carries those waits inside the string, with opts.pauseSounds naming the
|
||||
-- sfx each one fires. ROM-free.
|
||||
-- luajit tests/engine/textbox_pause_bug1818.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 TextBox = require("src.render.TextBox")
|
||||
local MoveLearnMenu = require("src.ui.MoveLearnMenu")
|
||||
local Sound = require("src.core.Sound")
|
||||
|
||||
local played = {}
|
||||
local realPlay = Sound.play
|
||||
Sound.play = function(data, name)
|
||||
played[#played + 1] = name
|
||||
return nil
|
||||
end
|
||||
|
||||
local function newGame()
|
||||
local game = {
|
||||
save = { player = {}, options = { textSpeed = "FAST" } },
|
||||
data = { text = {}, moves = { MEGA_PUNCH = { name = "MEGA PUNCH" } },
|
||||
pokemon = { NIDORINO = { name = "NIDORINO" } } },
|
||||
}
|
||||
game.stack = {
|
||||
states = {},
|
||||
push = function(self, s) table.insert(self.states, s) end,
|
||||
pop = function(self) return table.remove(self.states) end,
|
||||
top = function(self) return self.states[#self.states] end,
|
||||
}
|
||||
game.input = {
|
||||
queue = {},
|
||||
wasPressed = function(self, btn) return self.queue[btn] or false end,
|
||||
isDown = function(self, btn) return self.queue[btn] or false end,
|
||||
}
|
||||
return game
|
||||
end
|
||||
|
||||
local game = newGame()
|
||||
local box = TextBox.new(game, "1, 2 and..." .. TextBox.PAUSE .. " Poof!",
|
||||
nil, { pauseSounds = { "Swap" } })
|
||||
|
||||
eq(box.pages[1][1], "1, 2 and... Poof!",
|
||||
"the marker is stripped out of the line the font ever sees")
|
||||
check(box.pauseAt and box.pauseAt[1] and box.pauseAt[1][1],
|
||||
"the pause is re-anchored onto page 1, line 1")
|
||||
eq(box.pauseAt[1][1][11], 1, "it sits on the 11th glyph, the last of \"1, 2 and...\"")
|
||||
|
||||
-- type up to the marker
|
||||
local frames = 0
|
||||
while not box.pauseFrames and frames < 600 do
|
||||
box:update(1 / 60)
|
||||
frames = frames + 1
|
||||
end
|
||||
check(box.pauseFrames, "typing stops at the marker")
|
||||
eq(box.charIndex, 11, "it stops with \"1, 2 and...\" printed and nothing after it")
|
||||
eq(#played, 0, "SFX_SWAP has not sounded yet -- the wait comes first")
|
||||
|
||||
local frozen = box.charIndex
|
||||
for _ = 1, 31 do
|
||||
box:update(1 / 60)
|
||||
if #played > 0 then break end
|
||||
eq(box.charIndex, frozen, "nothing types while the pause runs")
|
||||
end
|
||||
eq(played[1], "Swap", "the wait ends with SFX_SWAP (learn_move.asm:210-213)")
|
||||
|
||||
for _ = 1, 600 do
|
||||
if box.done then break end
|
||||
box:update(1 / 60)
|
||||
end
|
||||
check(box.done, "\" Poof!\" types out after the sound")
|
||||
eq(box.charIndex, 17, "the whole line printed, marker included in nothing")
|
||||
|
||||
-- a held A/B skips the wait: TextCommand_PAUSE reads hJoyHeld
|
||||
local held = newGame()
|
||||
held.input.queue = { a = true }
|
||||
local box2 = TextBox.new(held, "1, 2 and..." .. TextBox.PAUSE .. " Poof!",
|
||||
nil, { pauseSounds = { "Swap" } })
|
||||
local before = #played
|
||||
for _ = 1, 40 do
|
||||
if box2.done then break end
|
||||
box2:update(1 / 60)
|
||||
end
|
||||
check(box2.done, "with A held the box runs straight through the pause")
|
||||
check(#played > before, "the sound still plays on the skipped wait")
|
||||
|
||||
-- and the real caller wires both waits with the swap on the first
|
||||
local mlm = MoveLearnMenu.new(game, { species = "NIDORINO", level = 20,
|
||||
moves = {} }, "MEGA_PUNCH")
|
||||
mlm.forgot = "HORN ATTACK"
|
||||
game.stack:push(mlm)
|
||||
mlm:finish(true)
|
||||
local learned = game.stack:top()
|
||||
check(learned.pauseAt, "the learned-move box carries text_pause waits")
|
||||
eq(learned.pauseSounds[1], "Swap", "the first wait carries SFX_SWAP")
|
||||
local count = 0
|
||||
for _, lines in pairs(learned.pauseAt) do
|
||||
for _, chars in pairs(lines) do
|
||||
for _ in pairs(chars) do count = count + 1 end
|
||||
end
|
||||
end
|
||||
eq(count, 2, "both of learn_move.asm's text_pause commands survive")
|
||||
|
||||
Sound.play = realPlay
|
||||
T.finish()
|
||||
@@ -133,16 +133,18 @@ check(ow.player.spinDrop ~= true and ow.player.spinning == false,
|
||||
check(not playerHidden(ow), "player drawable again after the dig landing")
|
||||
|
||||
-- ------------------------------------------------------------------ fly
|
||||
-- flap (24) + path1 (36) + hold (40) + path2 (33) = 133 frames of flyAnim,
|
||||
-- then the fade, then the bird swoops in (flyArrive). Same invariant.
|
||||
-- the StopMusic fade hold (28) then flap (24) + path1 (36) + hold (40) +
|
||||
-- path2 (33) = 161 frames of flyAnim, then the fade, then the bird swoops
|
||||
-- in (flyArrive). Same invariant.
|
||||
Data.field.flyWarps.FIX_ROUTE = { x = 4, y = 6 }
|
||||
ow = newOW()
|
||||
ow:flyTo("FIX_ROUTE")
|
||||
st = drive(ow, 260)
|
||||
check(st.warpFrame ~= nil, "fly departure ends and the warp fade begins")
|
||||
-- flap (8*3) + path1 (12*3) + hold (40) + path2 (11*3) = 133 frames; the
|
||||
-- warp fires on frame 133's update, so the fade is on top from loop frame 134
|
||||
eq(st.warpFrame, 134, "fly fade begins right after the bird''s exit path")
|
||||
-- fade hold (4*7) + flap (8*3) + path1 (12*3) + hold (40) + path2 (11*3)
|
||||
-- = 161 frames; the warp fires on frame 161's update, so the fade is on top
|
||||
-- from loop frame 162 (player_animations.asm:123 StopMusic) #1840
|
||||
eq(st.warpFrame, 162, "fly fade begins right after the bird''s exit path")
|
||||
check(st.fadeFrames > 0, "fly warp fade ran (" .. st.fadeFrames .. " frames)")
|
||||
eq(st.gapFrames, 0,
|
||||
"no fly fade frame leaves the player standing bare (#916)")
|
||||
|
||||
@@ -645,6 +645,16 @@ save = newSave({ eggWith(0) })
|
||||
hatched = Breeding.hatch(DATA, save, 1, "SPROUT")
|
||||
eq(hatched.nickname, "SPROUT", "a nickname taken from the naming screen sticks")
|
||||
|
||||
-- HatchEggs writes wPlayerID / wPlayerName over whatever the egg carried, so
|
||||
-- the ODD_EGG's "ODD" and its table id do not survive the hatch.
|
||||
-- engine/pokemon/breeding.asm:299-309
|
||||
save = newSave({ eggWith(0) })
|
||||
save.party[1].ot = "ODD"
|
||||
save.party[1].otId = 2048
|
||||
hatched = Breeding.hatch(DATA, save, 1)
|
||||
eq(hatched.ot, "GOLD", "the hatchling's OT becomes the player")
|
||||
eq(hatched.otId, 1234, "and MON_OT_ID becomes wPlayerID")
|
||||
|
||||
eq(#Breeding.readyToHatch(newSave({ eggWith(0), eggWith(2), eggWith(0) })), 2,
|
||||
"readyToHatch names every spent counter")
|
||||
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
-- The #DEX screen (src/ui/gen2/PokedexMenu.lua) reads its KIND label and
|
||||
-- description pages from data.gen2Pokedex.entries, a table loaded straight
|
||||
-- from disk before mods:load runs -- a separate table from data.pokemon,
|
||||
-- the `pokemon` registry's own merge target
|
||||
-- (mod.content.pokemon:patch(id, { dexEntry = ... })). Without a projection
|
||||
-- step, a translation mod's #DEX text validates against the registry and
|
||||
-- never reaches the screen. src/core/gen2/PokedexText.lua is that
|
||||
-- projection, called once after the merge (src/core/Game2.lua). ROM-free:
|
||||
-- luajit tests/gen2_pokedex_text_registry_test.lua
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local S = require("tests.harness").suite("gen2 pokedex text registry")
|
||||
local check, eq = S.check, S.eq
|
||||
|
||||
local PokedexText = require("src.core.gen2.PokedexText")
|
||||
|
||||
-- A patched species (CYNDAQUIL, kind/text/text2 all overridden), one left
|
||||
-- alone (TOTODILE, no dexEntry at all in data.pokemon -- a mod that never
|
||||
-- touched it), and one gen2Pokedex has no matching data.pokemon row for
|
||||
-- (UNOWN, e.g. a form gen2Pokedex tracks that the pokemon registry does not).
|
||||
do
|
||||
local data = {
|
||||
pokemon = {
|
||||
CYNDAQUIL = { name = "CYNDAQUIL", dexEntry = {
|
||||
kind = "SOURIS DE FEU", text = "Page un traduite.",
|
||||
text2 = "Page deux traduite.",
|
||||
} },
|
||||
TOTODILE = { name = "TOTODILE" },
|
||||
},
|
||||
gen2Pokedex = { entries = {
|
||||
CYNDAQUIL = { id = "CYNDAQUIL", dex = 155, kind = "FIRE MOUSE",
|
||||
height = 108, weight = 170,
|
||||
text = "It is timid, and always curls itself up in a ball.",
|
||||
text2 = "If attacked, it flares up its back for protection." },
|
||||
TOTODILE = { id = "TOTODILE", dex = 158, kind = "BIG JAW",
|
||||
height = 200, weight = 200, text = "English page one.",
|
||||
text2 = "English page two." },
|
||||
UNOWN = { id = "UNOWN", dex = 201, kind = "SYMBOL",
|
||||
height = 50, weight = 50, text = "English only." },
|
||||
} },
|
||||
}
|
||||
|
||||
local touched = PokedexText.apply(data)
|
||||
eq(touched, 1, "only the species a mod actually patched are touched")
|
||||
|
||||
local cyndaquil = data.gen2Pokedex.entries.CYNDAQUIL
|
||||
eq(cyndaquil.kind, "SOURIS DE FEU", "kind reaches the #DEX entry")
|
||||
eq(cyndaquil.text, "Page un traduite.", "page one reaches the #DEX entry")
|
||||
eq(cyndaquil.text2, "Page deux traduite.", "page two reaches the #DEX entry")
|
||||
eq(cyndaquil.dex, 155, "dex number is untouched -- it is not translatable text")
|
||||
eq(cyndaquil.height, 108, "height is untouched -- game state, not prose")
|
||||
eq(cyndaquil.weight, 170, "weight is untouched -- game state, not prose")
|
||||
|
||||
local totodile = data.gen2Pokedex.entries.TOTODILE
|
||||
eq(totodile.kind, "BIG JAW", "a species with no dexEntry patch stays in English")
|
||||
eq(totodile.text, "English page one.", "unpatched page one is untouched")
|
||||
|
||||
local unown = data.gen2Pokedex.entries.UNOWN
|
||||
eq(unown.kind, "SYMBOL",
|
||||
"a #DEX entry with no data.pokemon counterpart at all is left alone")
|
||||
end
|
||||
|
||||
-- A patch that only sets `kind` (species_kinds without species_dex_text
|
||||
-- shipped, or vice versa) must not blank out the fields it did not touch.
|
||||
do
|
||||
local data = {
|
||||
pokemon = { ABRA = { name = "ABRA", dexEntry = { kind = "PSY" } } },
|
||||
gen2Pokedex = { entries = {
|
||||
ABRA = { id = "ABRA", dex = 63, kind = "PSI",
|
||||
text = "English text.", text2 = "English text two." },
|
||||
} },
|
||||
}
|
||||
PokedexText.apply(data)
|
||||
local abra = data.gen2Pokedex.entries.ABRA
|
||||
eq(abra.kind, "PSY", "kind alone is applied")
|
||||
eq(abra.text, "English text.", "text is left alone when the patch omits it")
|
||||
eq(abra.text2, "English text two.", "text2 is left alone when the patch omits it")
|
||||
end
|
||||
|
||||
-- Missing tables at every level answer 0 rather than raising -- a Gen 1 boot,
|
||||
-- or a Gold boot with no mods loaded, never calls this with a shaped table.
|
||||
do
|
||||
eq(PokedexText.apply(nil), 0, "nil data does not raise")
|
||||
eq(PokedexText.apply({}), 0, "empty data does not raise")
|
||||
eq(PokedexText.apply({ gen2Pokedex = { entries = {} } }), 0,
|
||||
"no data.pokemon at all does not raise")
|
||||
end
|
||||
|
||||
check(true, "PokedexText.apply covers the projection this fix adds")
|
||||
|
||||
S.finish()
|
||||
@@ -62,20 +62,29 @@ write(goldPath, SaveSerializer.encode({
|
||||
|
||||
local out = run(("luajit tools/save_convert/convert.lua export %q %q")
|
||||
:format(goldPath, outPath))
|
||||
check(out:find("Gen 2 cart save", 1, true) ~= nil,
|
||||
"exporting a Gold save.lua is refused by name: " .. (out:gsub("%s+$", "")))
|
||||
-- Gen 2 exports through Gen2Save now, but only for a save that carries the
|
||||
-- cartridge image it came from. A slot built in the launcher has none.
|
||||
check(out:find("no cartridge image", 1, true) ~= nil,
|
||||
"exporting a Gold slot with no cartridge behind it is refused, and says why: "
|
||||
.. (out:gsub("%s+$", "")))
|
||||
check(not exists(outPath),
|
||||
"and no 32768-byte file that looks like a Red battery is written")
|
||||
|
||||
-- The same gate on the way in, when the caller names the game.
|
||||
-- The way IN is no longer a gate: Gen 2 imports through
|
||||
-- src/save_convert/Gen2Save.lua now. What this pins is that the bytes reach
|
||||
-- that codec and are judged by ITS rules -- an all-zero image has neither of
|
||||
-- Gen 2's check values, so it is refused for being blank rather than for
|
||||
-- being Gold.
|
||||
local savPath = tmp("in.sav")
|
||||
local outPath2 = tmp("in.lua")
|
||||
os.remove(outPath2)
|
||||
write(savPath, string.rep("\0", 32768))
|
||||
out = run(("luajit tools/save_convert/convert.lua import %q %q gold")
|
||||
:format(savPath, outPath2))
|
||||
check(out:find("Gen 2 cart save", 1, true) ~= nil,
|
||||
"importing for a Gen 2 game is refused too")
|
||||
check(out:find("not supported yet", 1, true) == nil,
|
||||
"importing for a Gen 2 game is no longer refused by version: " .. (out:gsub("%s+$", "")))
|
||||
check(out:find("checksum", 1, true) ~= nil,
|
||||
"a blank image is refused on Gen 2's own check values: " .. (out:gsub("%s+$", "")))
|
||||
check(not exists(outPath2), "and writes nothing")
|
||||
|
||||
-- Gen 1 keeps working: a Red-shaped save is never caught by the Gen 2 gate.
|
||||
|
||||
@@ -66,8 +66,8 @@ check(files["saves/gold/" .. tostring(slotId) .. ".lua"] ~= nil,
|
||||
|
||||
local ok, res = SaveFileIO.exportActiveSlot("gold")
|
||||
eq(ok, false, "Export on a Gold slot is refused, not crashed")
|
||||
check(type(res) == "string" and res:find("not supported yet", 1, true),
|
||||
"the refusal is the plain launcher message: " .. tostring(res))
|
||||
check(type(res) == "string" and res:find("no cartridge image", 1, true),
|
||||
"the refusal names the missing cartridge image: " .. tostring(res))
|
||||
check(not tostring(res):find("GenSave", 1, true)
|
||||
and not tostring(res):find("attempt to index", 1, true),
|
||||
"no codec traceback leaks into the notice line")
|
||||
@@ -77,12 +77,17 @@ for path in pairs(files) do
|
||||
end
|
||||
eq(exported, false, "no export file is written for a Gold slot")
|
||||
|
||||
-- The import direction through the same seam: a 32 KB image aimed at Gold
|
||||
-- must be refused by version, before any Gen 1 decoding is attempted.
|
||||
-- The import direction no longer matches the export one. Gold imports through
|
||||
-- Gen2Save now, so a 32 KB image aimed at Gold is decoded rather than turned
|
||||
-- away by version. An all-zero image still fails, because Gen 2's guards are
|
||||
-- two check values and a sum and a blank image has none of them: refused for
|
||||
-- what it is, not for which game it is for.
|
||||
local iok, ierr = SaveConvert.importSav(string.rep("\0", 32768), "gold", "gold")
|
||||
eq(iok, nil, "importing a cart .sav for Gold is refused")
|
||||
check(type(ierr) == "string" and ierr:find("not supported yet", 1, true),
|
||||
"the import refusal is the plain launcher message: " .. tostring(ierr))
|
||||
eq(iok, nil, "a blank cart .sav for Gold is still refused")
|
||||
check(type(ierr) == "string" and ierr:find("not supported yet", 1, true) == nil,
|
||||
"and no longer refused by version: " .. tostring(ierr))
|
||||
check(type(ierr) == "string" and ierr:find("checksum", 1, true) ~= nil,
|
||||
"it is Gen 2's own guards that turn it away: " .. tostring(ierr))
|
||||
|
||||
-- Gen 1 versions still pass the gate: red reaches the codec proper and
|
||||
-- fails on its own terms (an all-zero image is not a table), never on the
|
||||
|
||||
@@ -526,6 +526,93 @@ for _, id in ipairs(Screens.GEN2_IDS) do
|
||||
end
|
||||
check("Gen2UnownPuzzle is a screen id", registered, true)
|
||||
|
||||
-- ================================================= Crystal's flag skew (#1834)
|
||||
--
|
||||
-- pokecrystal constants/engine_flags.asm:25 ENGINE_MOBILE_SYSTEM shifts the
|
||||
-- unlock flags to 43-46 (:57-60); pokegold keeps 42-45 (:56-59).
|
||||
do
|
||||
local World = require("src.world.gen2.World")
|
||||
local crystalOrder = {}
|
||||
for _, set in ipairs(Unown.UNLOCK_SETS) do
|
||||
crystalOrder[set.flag + 2] = set.name
|
||||
end
|
||||
local function stubWorld(engineFlags, order)
|
||||
return setmetatable({
|
||||
constants = { engineFlagOrder = order },
|
||||
game = { save = { engineFlags = engineFlags } },
|
||||
}, { __index = World })
|
||||
end
|
||||
|
||||
-- what a Crystal save's raw flags used to feed Unown directly: L-Z only
|
||||
local rawCrystal = { [43] = true, [44] = true, [45] = true, [46] = true }
|
||||
local leaked = Unown.unlockedLetters(rawCrystal)
|
||||
check("raw Crystal flags leak only 15 letters", #leaked, 15)
|
||||
check("and the first survivor is L", leaked[1], 12)
|
||||
|
||||
local view = stubWorld(rawCrystal, crystalOrder):unownUnlockFlags()
|
||||
local resolved = Unown.unlockedLetters(view)
|
||||
check("the resolved view unlocks all 26", #resolved, 26)
|
||||
check("A included", resolved[1], 1)
|
||||
|
||||
-- the same distribution through wildDVs itself
|
||||
math.randomseed(1)
|
||||
local function roll()
|
||||
return { attack = math.random(0, 15), defense = math.random(0, 15),
|
||||
speed = math.random(0, 15), special = math.random(0, 15) }
|
||||
end
|
||||
local seenOld, seenNew = {}, {}
|
||||
for _ = 1, 4000 do
|
||||
seenOld[Unown.letterFromDVs(Unown.wildDVs(rawCrystal, roll))] = true
|
||||
seenNew[Unown.letterFromDVs(Unown.wildDVs(view, roll))] = true
|
||||
end
|
||||
local countOld, countNew = 0, 0
|
||||
for _ in pairs(seenOld) do countOld = countOld + 1 end
|
||||
for _ in pairs(seenNew) do countNew = countNew + 1 end
|
||||
check("raw flags roll only 15 letters", countOld, 15)
|
||||
check("raw flags never roll A", seenOld[1], nil)
|
||||
check("the resolved view rolls all 26", countNew, 26)
|
||||
|
||||
-- one chamber: Crystal's setflag 43 is Kabuto's A-K, not L-R
|
||||
local kabuto = Unown.unlockedLetters(
|
||||
stubWorld({ [43] = true }, crystalOrder):unownUnlockFlags())
|
||||
check("Crystal flag 43 is A-K", #kabuto, 11)
|
||||
check("starting at A", kabuto[1], 1)
|
||||
|
||||
-- Crystal's 42 is ENGINE_EARTHBADGE and must not unlock a chamber
|
||||
check("Crystal flag 42 unlocks nothing", Unown.anyUnlocked(
|
||||
stubWorld({ [42] = true }, crystalOrder):unownUnlockFlags()), false)
|
||||
|
||||
-- a Gold cache has no engineFlagOrder and keeps its own ids
|
||||
local gold = Unown.unlockedLetters(
|
||||
stubWorld({ [42] = true }, nil):unownUnlockFlags())
|
||||
check("Gold flag 42 stays A-K", #gold, 11)
|
||||
end
|
||||
|
||||
-- ============================================= the letters table's keys (#1834)
|
||||
--
|
||||
-- RomExtractorGen2 keys `letters` by "A".."Z"; monLetter answers a number, so
|
||||
-- the anim lookup must convert or every letter animates as A.
|
||||
do
|
||||
local BattleState = require("src.ui.gen2.BattleState")
|
||||
local SummaryMenu = require("src.ui.gen2.SummaryMenu")
|
||||
local animA, animI = { sheet = "sheet-a" }, { sheet = "sheet-i" }
|
||||
data.pokemon.UNOWN.anim = animA
|
||||
data.pokemon.UNOWN.letters.I.anim = animI
|
||||
local screen = { pokemon = data.pokemon }
|
||||
check("animData picks the mon's own letter",
|
||||
BattleState.animData(screen, unown), animI)
|
||||
local plain = Mon.new(data, "UNOWN", 5, { dvs = Unown.dvsForLetter(2) })
|
||||
check("a letter with no anim row falls back to the species'",
|
||||
BattleState.animData(screen, plain), animA)
|
||||
-- StatsScreen_PlaceFrontpic reads the same letter row
|
||||
-- (../pokecrystal/engine/pokemon/stats_screen.asm:889-901)
|
||||
local asked
|
||||
local summary = { mon = unown, pokemon = data.pokemon,
|
||||
picImage = function(_, sheet) asked = sheet return nil end }
|
||||
SummaryMenu.startPicAnim(summary)
|
||||
check("the summary menu reads the same letter row", asked, "sheet-i")
|
||||
end
|
||||
|
||||
print(("gen2 unown: %d checks, %d failures"):format(checks, failures))
|
||||
-- Raise rather than os.exit: tests/run_tests.lua dofiles this file, so an exit
|
||||
-- here would take the whole tier down and silently skip every suite after it.
|
||||
|
||||
@@ -922,8 +922,12 @@ check(poolWorld:useFieldMove("WHIRLPOOL", SPINNER[1]).ok, "whirlpool cleared")
|
||||
runFrames(poolWorld, 1)
|
||||
eq(poolWorld.log[1], T.USE_WHIRLPOOL, "UseWhirlpoolText")
|
||||
advanceText(poolWorld)
|
||||
-- engine/events/overworld.asm:1142-1164
|
||||
eq(poolWorld.map.def.blocks[TREE_BLOCK_INDEX], 0x07,
|
||||
"the whirlpool block is still on screen while the sfx plays (#1862)")
|
||||
runFrames(poolWorld, 4)
|
||||
eq(poolWorld.map.def.blocks[TREE_BLOCK_INDEX], 0x36,
|
||||
"DisappearWhirlpool swaps block $07 for $36")
|
||||
"DisappearWhirlpool swaps block $07 for $36 once the sfx ends")
|
||||
local wrongBlock = fieldWorld({ [4 * 100 + 5] = COLL_WHIRLPOOL }, SPINNER)
|
||||
check(not wrongBlock:useFieldMove("WHIRLPOOL", SPINNER[1]).ok,
|
||||
"a whirlpool collision over the wrong block is refused")
|
||||
|
||||
@@ -558,8 +558,9 @@ local pm = PartyMenu.new(pgame)
|
||||
pm.game = pgame
|
||||
pgame.stack:push(pm)
|
||||
press(pm, "a")
|
||||
check(pm.submenu and #pm.subItems == 2
|
||||
and pm.subItems[1].label == "STATS" and pm.subItems[2].label == "SWITCH",
|
||||
check(pm.submenu and #pm.subItems == 3
|
||||
and pm.subItems[1].label == "STATS" and pm.subItems[2].label == "SWITCH"
|
||||
and pm.subItems[3].label == "CANCEL",
|
||||
"vanilla party submenu unchanged with no hooks")
|
||||
pm.submenu = nil
|
||||
|
||||
@@ -570,9 +571,9 @@ hooks:wrap("ui.party.submenu", function(nextFn, game, items, mon, ctx)
|
||||
return nextFn(game, items, mon, ctx)
|
||||
end, 0, "fixture")
|
||||
press(pm, "a")
|
||||
check(#pm.subItems == 3 and pm.subItems[3].label == "QUESTS",
|
||||
check(#pm.subItems == 4 and pm.subItems[4].label == "QUESTS",
|
||||
"hook appends a party submenu entry")
|
||||
pm.subIndex = 3
|
||||
pm.subIndex = 4
|
||||
press(pm, "a")
|
||||
check(ranWith == pgame.save.party[1],
|
||||
"an injected entry's onSelect runs with the focused mon")
|
||||
@@ -581,7 +582,7 @@ hooks:removeOwner("fixture")
|
||||
|
||||
hooks:wrap("ui.party.submenu", function() return nil end, 0, "bad")
|
||||
press(pm, "a")
|
||||
check(#pm.subItems == 2, "a non-table submenu result keeps the vanilla list")
|
||||
check(#pm.subItems == 3, "a non-table submenu result keeps the vanilla list")
|
||||
hooks:removeOwner("bad")
|
||||
pm.submenu = nil
|
||||
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
-- A sandboxed mod can take a Pokémon's level off the screens that print it
|
||||
-- (pokemon.level_visible): the readout goes, the layout does not, and a
|
||||
-- build with no mod wrapping the hook prints exactly what it always did.
|
||||
--
|
||||
-- The predicate is tested rather than the pixels: every Gen 1 level readout
|
||||
-- goes through LevelDisplay.visible, and the four call sites are the same
|
||||
-- one-line guard. What matters here is the contract -- default true, false
|
||||
-- suppresses, the surface is named, and no-mod costs nothing.
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
love = love or require("tests.love_stub")
|
||||
|
||||
local T = require("tests.modkit")
|
||||
local LevelDisplay = require("src.ui.LevelDisplay")
|
||||
|
||||
local FIXTURE = {
|
||||
["mods/level_probe/manifest.json"] = [[{
|
||||
"id": "level_probe",
|
||||
"name": "Level Probe",
|
||||
"version": "1.0.0",
|
||||
"entry": "main.lua",
|
||||
"api": 2
|
||||
}]],
|
||||
["mods/level_probe/main.lua"] = [[
|
||||
local mod = ...
|
||||
mod.exports.answer = nil
|
||||
mod.exports.seen = nil
|
||||
mod.hooks:wrap("pokemon.level_visible", function(next, mon, ctx)
|
||||
mod.exports.seen = { mon = mon, where = ctx and ctx.where,
|
||||
game = ctx and ctx.game }
|
||||
if mod.exports.answer == nil then return next(mon, ctx) end
|
||||
return mod.exports.answer
|
||||
end)
|
||||
]],
|
||||
}
|
||||
|
||||
local MON = { species = "RATTATA", level = 42, moves = {} }
|
||||
local GAME = { save = {} }
|
||||
|
||||
-- ------- no mod: the level is always printed
|
||||
|
||||
local vanilla = T.sdk.loadNone({})
|
||||
T.eq(LevelDisplay.visible(MON, "battle.enemy", GAME), true,
|
||||
"no mod: the enemy healthbox prints a level")
|
||||
T.eq(LevelDisplay.visible(MON, "party", GAME), true,
|
||||
"no mod: the party rows print a level")
|
||||
T.eq(LevelDisplay.visible(nil, "summary", nil), true,
|
||||
"no mod: even a nil mon answers true rather than throwing")
|
||||
vanilla.release()
|
||||
|
||||
-- ------- a mod hides it
|
||||
|
||||
local run = T.sdk.loadMods({ "mods/level_probe" }, { fs = T.sdk.memfs(FIXTURE) })
|
||||
T.eq(#run.errors, 0, "the level probe loads clean (" .. tostring(run.errors[1]) .. ")")
|
||||
local probe = run.loader.exports.level_probe
|
||||
|
||||
probe.answer = false
|
||||
T.eq(LevelDisplay.visible(MON, "battle.enemy", GAME), false,
|
||||
"hidden: the enemy healthbox prints no level")
|
||||
T.eq(probe.seen and probe.seen.where, "battle.enemy",
|
||||
"the hook is told which surface asked")
|
||||
T.check(probe.seen and probe.seen.mon == MON, "and which Pokémon")
|
||||
T.check(probe.seen and probe.seen.game == GAME, "and the game")
|
||||
|
||||
-- the surface is what lets a mode hide an opponent's level and keep its own
|
||||
probe.answer = nil
|
||||
T.eq(LevelDisplay.visible(MON, "party", GAME), true,
|
||||
"falling through prints, as today")
|
||||
T.eq(probe.seen and probe.seen.where, "party", "and still names the surface")
|
||||
|
||||
-- only an explicit false suppresses: a mod returning nothing must not blank
|
||||
-- a screen by accident
|
||||
probe.answer = true
|
||||
T.eq(LevelDisplay.visible(MON, "summary", GAME), true,
|
||||
"an explicit true prints")
|
||||
|
||||
run.release()
|
||||
T.finish("pokemon level visible")
|
||||
+5
-2
@@ -44,11 +44,14 @@ end
|
||||
TileRenderer.setSpinning(false)
|
||||
check(not TileRenderer.spinBlurActive(), "no arrow blur frame outside a spin")
|
||||
|
||||
-- one toggle per 16-frame walked tile: wSimulatedJoypadStatesIndex drops
|
||||
-- once per JoypadOverworld call (home/overworld.asm:1844-1846) and
|
||||
-- spinners.asm:18-22 reads its bit 0 (#1831)
|
||||
TileRenderer.setSpinning(true)
|
||||
local a = TileRenderer.spinBlurActive()
|
||||
for i = 1, 8 do TileRenderer.tick() end
|
||||
for i = 1, 16 do TileRenderer.tick() end
|
||||
local b = TileRenderer.spinBlurActive()
|
||||
check(a ~= b, "arrow blur frame toggles every ~8 ticks while spinning")
|
||||
check(a ~= b, "arrow blur frame toggles every ~16 ticks while spinning")
|
||||
|
||||
TileRenderer.setSpinning(false)
|
||||
check(not TileRenderer.spinBlurActive(), "blur frame turns off once the spin ends")
|
||||
|
||||
@@ -303,7 +303,9 @@ Game.save.forcedBike = true
|
||||
ow = pushOW("ROUTE_17", 4, 10, "down")
|
||||
ow:flyTo("PALLET_TOWN")
|
||||
eq(Game.save.forcedBike, nil, "Fly clears BIT_ALWAYS_ON_BIKE")
|
||||
ow.flyAnim, ow.flyDest, ow.player.inputLocked = nil, nil, false -- undo flyTo
|
||||
-- undo flyTo, flyFade included: it re-arms flyAnim when it drains
|
||||
ow.flyAnim, ow.flyDest, ow.flyFade = nil, nil, nil
|
||||
ow.player.inputLocked = false
|
||||
Game.save.forcedBike = true
|
||||
ow:warpToHealPoint()
|
||||
eq(Game.save.forcedBike, nil, "blackout/escape warps clear BIT_ALWAYS_ON_BIKE")
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user