Compare commits

...

30 Commits

Author SHA1 Message Date
bryanthaboi 4b2e772ab2 Merge pull request #1879 from bryanthaboi/dev
bazinga bazingo
2026-08-27 13:43:29 -04:00
bryanthaboi 8e9206b612 Merge pull request #1835 from campavao/rfc-0019-level-visible
engine: pokemon.level_visible, a level a mode can take off the screen (RFC 0019)
2026-08-27 13:38:59 -04:00
bryanthaboi dc99b1f8da Merge pull request #1852 from colsonrice/feat/gen2-save-export
Export Gen 2 cart saves
2026-08-27 13:37:43 -04:00
bryanthaboi 377f1f8a71 Merge pull request #1877 from 1Jamie/flatpak-lic-fix
update flatpak information that was left as template default
2026-08-27 13:36:27 -04:00
bryanthaboi 2441996d45 maybe better menu situation 2026-08-27 12:44:22 -04:00
1jamie 5e7dc556f5 update flatpak information that was left as template default
flatpak properly now marks as oss and points to site and gh
2026-08-27 10:30:44 -05:00
Colson Rice 71a5602b58 Export Gen 2 cart saves
Closes the round trip. Gold, Silver and Crystal progress writes back to a
cartridge save the real game boots.

Writes into the 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 cartridge behind it is refused rather than built from nothing.
That refusal is the known limitation: a game begun in this project has no
lineage to write back into yet.

The image lives BESIDE the slot as saves/<version>/<id>.cart, not in the
save table. 32 KB of binary in the serialized table is 40 KB of Lua source
reparsed on every save and load, for every imported slot, forever.

Only the primary copy is written. TryLoadSaveFile rewrites the backup from
the primary the moment VerifyChecksum passes, so a stale backup heals
itself on the first load and this does not need a second offset mapping.
Crystal arranges its backup differently from Gold and Silver, so that
matters.

What encode now reaches that it did not:

  * the bag, all four pockets, bucketed by each item's own pocket, plus
    wCurBox and the box names. It used to leave them at whatever the
    template carried, so a potion bought in a session never arrived.
  * the RTC footer. importToSlot truncates to 32768, so the image is kept
    at its full length and the footer is carried through. Dropping it
    resets the clock and costs the player daily events, the bug contest
    and a clock-adjustment penalty.
  * 0x1C-0x1E, pokerus and caught data. Left to the template they survive
    POSITIONALLY, so reordering the party gives slot 1 the previous
    occupant's caught level and location.

A bag that cannot be bucketed is refused rather than written short: without
the item table every item falls into ITEM, which holds 20, and a real bag
is bigger than that. Silently dropping the overflow would be worse than
the bug this fixes.

Two bugs in the text encoder that only real names caught: the cart's table
carries the ligature halves PO and KE, so a name containing "PO" became
0x70 where the cart had a plain P; and #glyph counts BYTES in Lua, so every
multi-byte glyph was dropped and came back as "?", which is NIDORAN and
every name with an accent.

Tests

The round trip CHANGES things first, in each place export has to reach, and
reads them back through a fresh decode. Exporting onto the buffer a save was
decoded from proves nothing, because every region encode does not write
matches by construction.

The fixture-gated audit exports a real cart save too, and pins that the
image keeps its size, RTC and all.

./scripts/test.sh passes end to end, and luacheck is clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 10:41:30 -04:00
Colson Rice ebd315b01e Import Gen 2 cart saves
Gold, Silver and Crystal battery saves import now. Export is still refused.

GenSave.lua is pokered's SRAM window and nothing else, which is why the
guard refusing Gen 2 was right to be there. This adds Gen2Save.lua beside
it, covering pokegold and pokecrystal.

Every offset is generated, not transcribed. tools/gen2_sram_offsets.py
reads pokegold.sym and pokecrystal.sym from a pret build and emits
Gen2Layout.lua, including the text table from constants/charmap.asm and
Crystal's backup-save layout. Gen 2 copies a contiguous WRAM block into
SRAM bank 1, so a field's file offset is sPlayerData + (wField -
wPlayerData); the generator asserts that relation against sPokemonData
rather than assuming it, and range-guards anything outside
sGameData..sGameDataEnd.

Gold and Crystal are separate tables because they disagree about nearly
every field. Reading a Crystal save with Gold's numbers gives a party
count of 133 and 13113 hours played, with a checksum that validates.

The cart stores numbers and the engine is keyed by name, so the codec
crosswalks species, moves and items through the generated tables the same
way GenSave.crosswalks does for Gen 1. Without that, an import looks
perfect and the engine cannot read a byte of it.

Shapes that have to match what the engine reads:
  * events is byte index -> packed byte, which Save.scrubEvents validates
    with tonumber. A set of booleans is silently emptied.
  * the bag is one flat save.inventory keyed by item id, which PackMenu
    buckets by each item's pocket. Nothing reads save.keyItems or
    save.balls, and the TM/HM pocket lands here too.
  * position carries the map id, or Save.summary falls through to
    save.spawn and the player resumes somewhere else at their old
    coordinates.
  * mon.status is an ItemEffects.STATUS_CLASS key with statusTurns beside
    it, nil when healthy. 0 is truthy in Lua.

A save the real cartridge would open is not refused: TryLoadSaveFile falls
back to VerifyBackupChecksum, so this does too. Crystal's backup is
contiguous and laid out like the primary; Gold and Silver split theirs
across three sections and have none to offer.

Three suites that pinned Gen 2 import being refused now pin what refuses
instead. Tests live in tests/engine so the ROM-free tier actually runs
them.

./scripts/test.sh passes end to end, and luacheck is clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 10:32:40 -04:00
bryanthaboi e9b815fcac Update RomImporter.lua 2026-08-27 08:39:06 -04:00
github-actions fbaf0441ee chore(ios): update app-repo.json [skip ci] 2026-08-27 07:57:06 -04:00
bryanthaboi d2a86cd258 Merge pull request #1873 from bryanthaboi/dev 2026-08-27 07:42:16 -04:00
bryanthaboi 8b56ec2d77 big egg energy 2026-08-27 07:40:02 -04:00
bryanthaboi f11724f08a Merge pull request #1841 from thibautbus/fix/gen2-pokedex-mod-text 2026-08-27 07:25:03 -04:00
bryanthaboi bd3e02986d test fix and iOS close hide 2026-08-27 07:11:43 -04:00
bryanthaboi eed246f645 CLOSES #1831, CLOSES #1834, CLOSES #1836, CLOSES #1860 2026-08-27 06:09:50 -04:00
bryanthaboi c9d06779d8 Merge pull request #1844 from colsonrice/fix/gen2-save-refusal-message
Refuse a Gen 2 cart save as a Gen 2 save, not as a broken Gen 1 one
2026-08-27 06:04:27 -04:00
bryanthaboi 48d8a4e922 CLOSES #1562, CLOSES #1804, CLOSES #1805, CLOSES #1810, CLOSES #1811, CLOSES #1814, CLOSES #1817, CLOSES #1818, CLOSES #1819, CLOSES #1821, CLOSES #1826, CLOSES #1827, CLOSES #1829, CLOSES #1833, CLOSES #1840, CLOSES #1842, CLOSES #1845, CLOSES #1846, CLOSES #1847, CLOSES #1848, CLOSES #1849, CLOSES #1853, CLOSES #1858, CLOSES #1862 2026-08-27 05:09:26 -04:00
github-actions 9a05de4474 chore(ios): update app-repo.json [skip ci] 2026-08-26 18:03:50 -04:00
bryanthaboi 5c833a3d73 Merge pull request #1864 from bryanthaboi/dev 2026-08-26 17:49:40 -04:00
bryanthaboi 5051cb902d Merge pull request #1863 from ShaneMcGovernIE/feat/handheld-osk-and-trigger-scroll 2026-08-26 17:47:04 -04:00
Shane McGovern 2720bfb5d8 feat(ui): scope on-screen VirtualKeyboard to Linux handhelds and restore L2/R2 trigger scrolling 2026-08-26 22:45:09 +01:00
github-actions 54ba0f04ff chore(ios): update app-repo.json [skip ci] 2026-08-26 17:34:24 -04:00
bryanthaboi a231657093 Merge pull request #1856 from bryanthaboi/dev
Update HostShell.lua
2026-08-26 17:17:14 -04:00
bryanthaboi 6f587520d4 Update HostShell.lua 2026-08-26 17:15:56 -04:00
Colson Rice 3a0a72102f Teach the bug420 SaveConvert double the method importToSlot now calls
CI caught this: tests/engine/save_import_retry_bug420.lua replaces
SaveConvert with a minimal double, and importToSlot now asks it
importSupported before it measures the bytes, so the double answered nil
and the call died.

The double stands in for the real module, so it grows with it. Answering
true keeps that case about the thing it is testing, which is that
importToSlot names the game whose cache to read.

./scripts/test.sh passes end to end locally now, every ROM-free tier.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 15:47:08 -04:00
Colson Rice fe31293566 Refuse a Gen 2 cart save as a Gen 2 save, not as a broken Gen 1 one
#1832 reports a Crystal .sav failing to import with "save data checksum
invalid (main data checksum mismatch)". The save is fine. It is being
measured with the wrong generation's ruler.

SaveFileIO.importToSlot sends anything that is not exactly SAVE_SIZE into
mainChecksumValid, which is pokered's main-data checksum. Gen 2 carts are
MBC3+TIMER, so a real Gold, Silver or Crystal battery save carries an RTC
footer: 32786 bytes, never 32768. It misses the size test, falls into the
checksum branch, and is told it is damaged by a rule written for a
different game. Every real Gen 2 cart save takes that path, every time.

The generation check already exists, it just lives too late, inside
importSav and behind the size gate that has already rejected the save.
This lifts it into SaveConvert.importSupported and exportSupported and
asks before the bytes are measured. Both existing guards route through
the same predicates now, so the early caller and the late one cannot
describe the same game two different ways.

Nothing changes for Gen 1, and Gen 2 is still refused, for the real
reason and in a sentence a player can do something with.

tests/gen2_save_import_message_test.lua covers it. Against the code as it
stands it is 20/26, and the failures are the report itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 14:52:44 -04:00
github-actions e41f2c528a chore(ios): update app-repo.json [skip ci] 2026-08-26 14:00:12 -04:00
thibautbus c8eeeaa1f6 Cover the #DEX registry projection fix with a targeted test 2026-08-26 19:44:00 +02:00
thibautbus 273350d11e Route the Gen2 #DEX screen's kind/text through the pokemon registry
src/ui/gen2/PokedexMenu.lua reads its KIND label and both description
pages from data.gen2Pokedex.entries, 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 =
... }) therefore validated but never reached the screen. Adds
src/core/gen2/PokedexText.lua to project a patched dexEntry onto the
#DEX table after the merge (Game2:load, alongside the other Gen 2
post-merge registries), and a text2 field to the dexEntry schema for
the entry's second description page, which the screen already reads
but the registry had no field for. Also routes the OPTION/SEARCH panel
titles (PokedexMenu.lua) through Strings(), the same literal-wrapping
pattern already used elsewhere in this screen and its siblings.
2026-08-26 19:43:53 +02:00
DESKTOP-8SRFDDM\cam95 49a5408c2d engine: pokemon.level_visible, a level a mode can take off the screen (RFC 0019)
A Pokémon's level is printed on four Gen 1 surfaces -- both battle
healthboxes, the party rows, and both status pages -- and every one of them
prints it unconditionally. There is no seam, so a mode that wants the number
gone has two options today and both are bad: paint over the engine's own
pixels from render.hud (four rectangles, a background shade to match, and
the palette flashes and healthbox slide to survive), or monkey-patch the
render modules from inside the sandbox, which works and is exactly what
CONTRIBUTING-mods.md tells mods not to do.

The motivating case is a battle royale that scales every party to a shared
rung rising with its fog: the number is the same for everyone, it changes on
a clock, and 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. A randomizer keeping an encounter unreadable, a challenge run that
forbids level-checking and a blind Nuzlocke want the same switch.

New hook `pokemon.level_visible`, taking the shape the presentation
predicates on the battle screen already use -- battle.status_hud_visible,
battle.bottom_ui_visible, battle.caught_marker_visible: consulted behind
Runtime.wantsHook, default visible, only an explicit false suppresses. It is
not named battle.* because a level is not a battle-only readout, and it
carries the surface that asked (battle.enemy / battle.player / party /
summary) so a mode can hide an opponent's level and keep its own.

src/ui/LevelDisplay.lua holds the one definition of "visible", so the four
call sites are a one-line guard each rather than four copies of the same
five lines that can drift apart.

No layout moves. Each site keeps its own hand-rolled PrintLevel rule
(home/pokemon.asm:335-345), it just asks first. Two details are deliberate:
a status condition still replaces the level on a healthbox exactly as in the
cart, so hiding a level never hides PSN or BRN (the guard is an elseif on
the existing status branch); and on status page 2 the <to> arrow is hidden
with the level it points at, because an arrow with nothing after it is half
a sentence.

Gen 1 only. The Gen 2 screens and the Gen 1 PC box list -- where the level
is part of a row label rather than a drawn field -- keep their own readouts
and do not consult the hook. Both are stated as follow-ups in the RFC and
beside the hook in docs/modding.md, so a mod author reads the limit before
depending on it.

Verification: tests/modkit/cases/pokemon_level_visible.lua covers the
contract through the public mod API; gate_hooks picks the hook up on its own
because it walks the live catalog; gate_meta_coverage is satisfied by the
change that introduces the seam, so it never enters the DEBT ledger.
tests/run_modkit.lua 33/33, tests/run_engine.lua 327/331 -- the same four
audio/hostshell suites fail unchanged on dev without this branch.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-26 10:53:09 -05:00
118 changed files with 7139 additions and 504 deletions
+19 -12
View File
@@ -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
View File
@@ -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
+31
View File
@@ -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
+1 -1
View File
@@ -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 |
+157
View File
@@ -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>
+28
View File
@@ -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",
+11 -9
View File
@@ -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
View File
@@ -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
+1
View File
@@ -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
View File
@@ -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
+5
View File
@@ -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
+3 -1
View File
@@ -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
View File
@@ -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)
+4 -2
View File
@@ -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.
+39
View File
@@ -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
+6 -4
View File
@@ -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
+2 -1
View File
@@ -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
+64 -5
View File
@@ -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
+51 -4
View File
@@ -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
+5 -2
View File
@@ -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
View File
@@ -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()
+3 -3
View File
@@ -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",
+5 -1
View File
@@ -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")),
+13
View File
@@ -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.
+87
View File
@@ -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]
+3 -6
View File
@@ -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
-- ------------------------------------------------------------------
+335
View File
@@ -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
+690
View File
@@ -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
+79 -15
View File
@@ -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
+6 -5
View File
@@ -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
+12 -6
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+33
View File
@@ -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
View File
@@ -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 = {}
+5 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+2 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
+47 -4
View File
@@ -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")
+9 -3
View File
@@ -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)
+11 -6
View File
@@ -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
View File
@@ -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,
+17
View File
@@ -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)
+51 -5
View File
@@ -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
+5 -9
View File
@@ -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)
+31 -24
View File
@@ -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
View File
@@ -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)
+101
View File
@@ -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
+131
View File
@@ -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
+5 -1
View File
@@ -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
+71
View File
@@ -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
+113
View File
@@ -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
+155
View File
@@ -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
+78
View File
@@ -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
+8
View File
@@ -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")
+386
View File
@@ -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()
+127
View File
@@ -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")
+27
View File
@@ -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()
+127
View File
@@ -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")
+13 -1
View File
@@ -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"
+100
View File
@@ -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)")
+113
View File
@@ -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()
+7 -5
View File
@@ -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)")
+10
View File
@@ -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")
+91
View File
@@ -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()
+14 -5
View File
@@ -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.
+12 -7
View File
@@ -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
+87
View File
@@ -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.
+5 -1
View File
@@ -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")
+6 -5
View File
@@ -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
View File
@@ -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")
+3 -1
View File
@@ -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