mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-24 06:29:08 +02:00
Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7ae469603c | |||
| 77e189bc2e | |||
| e88f2ef060 | |||
| 16eefcde68 | |||
| d54f9a02e0 | |||
| e094b73536 | |||
| 6e7b9c0848 | |||
| 116a6ba450 | |||
| 478e3bf8eb | |||
| f009fc856c | |||
| 3abc6ceb15 | |||
| b93d070abc | |||
| 921f565f1d | |||
| 6e624724ca | |||
| 51bc4c7437 | |||
| e13271fc85 | |||
| c23f82efdd | |||
| 15fc04e067 | |||
| 61f42cea2b | |||
| 2c1e411e3c | |||
| c0f4315cd8 | |||
| 8c65e76145 | |||
| ea2cd63395 | |||
| 5ec9ce5f88 |
@@ -468,3 +468,24 @@ jobs:
|
||||
if [ "$found" = "0" ]; then
|
||||
echo "no committed mods to lint"
|
||||
fi
|
||||
|
||||
luacheck:
|
||||
name: engine lint (luacheck)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: install luacheck
|
||||
run: |
|
||||
set -e
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y lua5.4 liblua5.4-dev luarocks
|
||||
sudo luarocks install luacheck || sudo apt-get install -y lua-check
|
||||
luacheck --version
|
||||
|
||||
- name: luacheck gate (undefined globals, unreachable code)
|
||||
run: ./scripts/lint.sh --gate
|
||||
|
||||
- name: luacheck full report (advisory)
|
||||
continue-on-error: true
|
||||
run: ./scripts/lint.sh
|
||||
|
||||
@@ -23,6 +23,10 @@ read_globals = {
|
||||
-- LuaJIT 2.1 ships table.unpack even though the bare 5.1 `table` std lacks
|
||||
-- it; without this, every `table.unpack` reads as an undefined field.
|
||||
table = { fields = { "unpack" } },
|
||||
"POKEPORT_DISPLAY_COMPANION",
|
||||
"POKEPORT_EDITOR_MODE",
|
||||
"rawlen",
|
||||
package = { fields = { "searchers" } },
|
||||
}
|
||||
|
||||
-- Vendored/native trees and the test suites have their own conventions.
|
||||
@@ -30,6 +34,7 @@ exclude_files = {
|
||||
"mobile/",
|
||||
"tests/",
|
||||
"tools/save-editor/",
|
||||
"tools/save_convert/vendor/",
|
||||
}
|
||||
|
||||
ignore = {
|
||||
|
||||
@@ -111,7 +111,7 @@ local function joinPrompt(game, ow, done)
|
||||
local t = game.data.text
|
||||
local back = function(text)
|
||||
game.stack:push(TextBox.new(game, text, function()
|
||||
ow:scriptMove(ow.player, "down", 1, done)
|
||||
ow:scriptMove(ow.player, "down", 1, done, { collide = true })
|
||||
end))
|
||||
end
|
||||
game.stack:push(TextBox.new(game,
|
||||
|
||||
@@ -127,7 +127,7 @@ M.VIRIDIAN_CITY = {
|
||||
game.stack:push(TextBox.new(game,
|
||||
game.data.text._ViridianCityOldManSleepyPrivatePropertyText
|
||||
or "You can't go\nthrough here!\fThis is private\nproperty!",
|
||||
function() ow:scriptMove(ow.player, "down", 1) end))
|
||||
function() ow:scriptMove(ow.player, "down", 1, nil, { collide = true }) end))
|
||||
return true
|
||||
end,
|
||||
}
|
||||
@@ -356,7 +356,7 @@ M.VERMILION_CITY = {
|
||||
if shipLeft then
|
||||
game.stack:push(TextBox.new(game,
|
||||
t._VermilionCitySailor1ShipSetSailText or "The ship set sail.",
|
||||
function() ow:scriptMove(ow.player, "up", 1) end))
|
||||
function() ow:scriptMove(ow.player, "up", 1, nil, { collide = true }) end))
|
||||
return true
|
||||
end
|
||||
-- Walk-past is never facing-right / inFrontOfOrBehindGuardCoords, so
|
||||
@@ -377,7 +377,7 @@ M.VERMILION_CITY = {
|
||||
ask .. "\f"
|
||||
.. (t._VermilionCitySailor1YouNeedATicketText
|
||||
or "You need a ticket\nto get aboard."),
|
||||
function() ow:scriptMove(ow.player, "up", 1) end))
|
||||
function() ow:scriptMove(ow.player, "up", 1, nil, { collide = true }) end))
|
||||
return true
|
||||
end,
|
||||
talk = {
|
||||
|
||||
@@ -414,7 +414,7 @@ local function saffronGate(guardText, triggers, horizontal)
|
||||
game.stack:push(TextBox.new(game,
|
||||
t._SaffronGateGuardGeeImThirstyText or "Gee, I'm thirsty\nthough!\nThe road's closed.",
|
||||
function()
|
||||
ow:scriptMove(ow.player, back, 1)
|
||||
ow:scriptMove(ow.player, back, 1, nil, { collide = true })
|
||||
end))
|
||||
return true
|
||||
end,
|
||||
@@ -769,7 +769,7 @@ M.MUSEUM_1F = {
|
||||
if y == 4 and (x == 9 or x == 10)
|
||||
and not game.save.flags.EVENT_BOUGHT_MUSEUM_TICKET then
|
||||
museumClerk(game, ow, nil, function()
|
||||
ow:scriptMove(ow.player, "down", 1)
|
||||
ow:scriptMove(ow.player, "down", 1, nil, { collide = true })
|
||||
end)
|
||||
return true
|
||||
end
|
||||
|
||||
@@ -176,7 +176,7 @@ M.POKEMON_TOWER_6F = {
|
||||
-- .did_not_defeat: one simulated step right, off the trigger,
|
||||
-- so fleeing does not leave you standing on a cell that
|
||||
-- immediately re-fires.
|
||||
ow:scriptMove(ow.player, "right", 1)
|
||||
ow:scriptMove(ow.player, "right", 1, nil, { collide = true })
|
||||
end
|
||||
ow:afterBattle(result, battle)
|
||||
end
|
||||
|
||||
@@ -734,7 +734,7 @@ local function e4ExitSeal(flag, closedBlock, openBlock, dontRunText, autoFlag)
|
||||
local TextBox = require("src.render.TextBox")
|
||||
game.stack:push(TextBox.new(game,
|
||||
game.data.text[dontRunText] or "Don't run away!", function()
|
||||
ow:scriptMove(ow.player, "up", 1)
|
||||
ow:scriptMove(ow.player, "up", 1, nil, { collide = true })
|
||||
end))
|
||||
return true
|
||||
end,
|
||||
|
||||
@@ -240,7 +240,7 @@ local function stepGate(opts)
|
||||
push(game, text(game)[opts.text] or opts.fallback, function()
|
||||
ow.player.facing = opts.push
|
||||
if not ow:checkLedgeHop(opts.push) then
|
||||
ow:scriptMove(ow.player, opts.push, 1)
|
||||
ow:scriptMove(ow.player, opts.push, 1, nil, { collide = true })
|
||||
end
|
||||
end)
|
||||
return true
|
||||
@@ -820,7 +820,8 @@ M.PEWTER_POKECENTER = {
|
||||
-- on the west-side cells and walks you back
|
||||
local function bikeGateGuard(coords, stopText, explainText)
|
||||
return function(game, ow, x, y)
|
||||
if game.save.inventory.BICYCLE then return false end
|
||||
local bike = game.save.inventory.BICYCLE
|
||||
if bike and bike ~= 0 then return false end
|
||||
if not inCoords(coords, x, y) then return false end
|
||||
-- walk the player up to the tile beside the counter, no further:
|
||||
-- (matchedY - closestY) tiles, 0 when already next to it
|
||||
@@ -842,10 +843,10 @@ local function bikeGateGuard(coords, stopText, explainText)
|
||||
-- (PlayerMovingRightScript). Without it the player was left
|
||||
-- parked beside the guard's counter with no way past. #518
|
||||
local function shoveRight()
|
||||
ow:scriptMove(ow.player, "right", 1)
|
||||
ow:scriptMove(ow.player, "right", 1, nil, { collide = true })
|
||||
end
|
||||
if dist > 0 then
|
||||
ow:scriptMove(ow.player, "up", dist, shoveRight)
|
||||
ow:scriptMove(ow.player, "up", dist, shoveRight, { collide = true })
|
||||
else
|
||||
shoveRight()
|
||||
end
|
||||
|
||||
@@ -550,6 +550,17 @@ gains a field instead of the name gaining a prefix.
|
||||
screen has no `.data` field, so the Gen 2 site **adds** `ctx.data` beside the
|
||||
Gen 1 keys. A mod that calls `nextFn` is unaffected; one that reaches through
|
||||
`ctx.battle.data` instead gets nil on Gold.
|
||||
`battle.exp_award`'s `ctx.applyShare(mon, split, announce)` reads its third
|
||||
argument on both generations: truthy prints the mon's GainedText, falsy pays
|
||||
it silently, so one mod source can print a single summary line for a
|
||||
party-wide award instead of a box per recipient. Gold honours it **only when
|
||||
it is passed**, by argument count -- `applyShare(mon, split)` was written
|
||||
against a seam that always announced on Gold and keeps announcing there,
|
||||
while `applyShare(mon, split, nil)` is silent on both. Pass the argument
|
||||
explicitly and the two generations agree; omit it and Gen 1 stays silent
|
||||
where Gold speaks. Only the line is affected: the exp, the stat exp,
|
||||
`battle.exp_gained`, the level-up line, learned moves and the forget prompt
|
||||
happen either way.
|
||||
- *The catch and the evolution:* `pokemon.caught`, `pokemon.evolved`; hook
|
||||
`evolution.check`. `src/ui/gen2/BattleState.lua:pushCaught` emits
|
||||
`pokemon.caught` once the mon is in the party or the box, and
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
# RFC 0012: `applyShare`'s announce argument on Gen 2
|
||||
|
||||
## Status
|
||||
|
||||
Proposed.
|
||||
|
||||
## Motivation
|
||||
|
||||
`battle.exp_award` hands a mod `ctx.applyShare(mon, split, announce)` on both
|
||||
generations. On Gen 1 the third argument decides whether the mon's GainedText
|
||||
box is printed (`src/battle/BattleState.lua`, `if announce then`), which is how
|
||||
a mod that pays the whole party prints **one** summary line instead of a box
|
||||
per recipient.
|
||||
|
||||
Gold accepts the argument and ignores it, as its own comment above the hook
|
||||
call says. So the same mod source, running the same code, prints one line on
|
||||
Red and six on Gold — one for the participant plus one for every bench mon it
|
||||
paid.
|
||||
|
||||
This is not hypothetical. The [Exp Share](https://github.com/ShaneMcGovernIE/exp_share)
|
||||
mod declares `"games": ["gen1", "gen2"]` and its description promises "a single
|
||||
shared-exp line instead of one message per Pokemon". It passes `true` for the
|
||||
fighters and `nil` for the bench, exactly as the Gen 1 seam asks. On Gold every
|
||||
one of those `nil` calls announces anyway, so a five-mon party turns every KO
|
||||
into six boxes to click through.
|
||||
|
||||
There is no mod-side fix. The announcement is emitted inside
|
||||
`Battle:giveExperiencePass`, behind no hook, and a mod cannot ask for silence
|
||||
because the argument that means "quietly" is discarded. The only workaround is
|
||||
to intercept the battle's event queue afterwards and delete the boxes, which is
|
||||
what a mod written for this had to do — a mod reaching into engine internals to
|
||||
undo something the public seam should never have done.
|
||||
|
||||
## Decision and plan extended
|
||||
|
||||
This does not add a seam. It finishes one: `battle.exp_award` is documented as
|
||||
"the same hook `BattleState:awardExp` calls on Gen 1 and with the same ctx",
|
||||
and `docs/mod-api-gen2-compat.md` lists it among the hooks shared with Gen 1.
|
||||
The third `applyShare` argument is the one part of that ctx whose meaning did
|
||||
not survive the crossing, so the promise the catalog already makes is what this
|
||||
change delivers.
|
||||
|
||||
The delta follows Route B's additive, guarded convention in
|
||||
`CONTRIBUTING-mods.md`: nothing is renamed, nothing is removed, and no mod that
|
||||
exists today changes behaviour.
|
||||
|
||||
## Exact API delta
|
||||
|
||||
`ctx.applyShare(mon, split, announce)` on Gen 2 now reads `announce`:
|
||||
|
||||
| Call | Gen 1 | Gen 2 before | Gen 2 after |
|
||||
|---|---|---|---|
|
||||
| `applyShare(mon, split)` | silent | announces | **announces** (unchanged) |
|
||||
| `applyShare(mon, split, nil)` | silent | announces | **silent** |
|
||||
| `applyShare(mon, split, false)` | silent | announces | **silent** |
|
||||
| `applyShare(mon, split, true)` | announces | announces | announces |
|
||||
| `applyShare(mon, split, "expAll")` | announces | announces | announces |
|
||||
|
||||
The argument is honoured **only when it is actually passed**, decided by
|
||||
argument count rather than by value:
|
||||
|
||||
```lua
|
||||
local function applyShare(mon, split, ...)
|
||||
local announce = ...
|
||||
local silent = select("#", ...) > 0 and not announce
|
||||
...
|
||||
end
|
||||
```
|
||||
|
||||
`select("#", ...)` counts an explicit `nil`, so `applyShare(mon, split)` and
|
||||
`applyShare(mon, split, nil)` are distinguishable — and they have to be, because
|
||||
the first is a Gen 2-era call written against a seam that always announced, and
|
||||
the second is a deliberate "pay this one quietly".
|
||||
|
||||
Only the `{ kind = "experience" }` event is affected. A silent award is still a
|
||||
whole award: the exp, the stat exp, the `battle.exp_gained` event, the
|
||||
`grew to level` line, learned moves and the interactive forget-a-move prompt all
|
||||
happen exactly as before, in the same order.
|
||||
|
||||
Internally `Battle:giveExperiencePass` takes a sixth parameter, `silent`. It
|
||||
defaults to announcing, so both of the cart's own passes are untouched.
|
||||
|
||||
## Migration and compatibility
|
||||
|
||||
**Existing mods change nothing.** A Gen 2 mod calling `applyShare(mon, split)`
|
||||
gets the behaviour it was written against. A Gen 1 mod is untouched: no Gen 1
|
||||
file is modified. The v1 surface — `content.X:register/override/get`,
|
||||
`events:on`, `hooks:wrap`, `mod.log`, `mod:read`, the manifest v1 fields and
|
||||
`pokemon.before_give` — is not involved; `mods/example_mew_starter` neither
|
||||
calls this seam nor loads differently.
|
||||
|
||||
A mod that wants parity passes the argument explicitly, which is what the Gen 1
|
||||
seam has always documented. Exp Share already does, and needs no edit to get
|
||||
its own README's behaviour on Gold.
|
||||
|
||||
One residual difference is deliberate and now documented rather than silent: an
|
||||
**omitted** third argument still means "silent" on Gen 1 and "announce" on
|
||||
Gold. Closing that would change what an existing Gen 2 mod prints, which Route
|
||||
B rejects. Passing the argument makes the two generations agree, so the rule an
|
||||
author needs is one sentence: *say what you mean and both games do the same
|
||||
thing.*
|
||||
|
||||
## Verification
|
||||
|
||||
- `tests/gen2_exp_share_test.lua` grows two sections and 14 checks, and the 23
|
||||
checks it already had are unchanged — which is itself the vanilla-parity
|
||||
evidence for this file.
|
||||
- **The no-mod test.** With nothing subscribed to `battle.exp_award`, a solo
|
||||
participant still prints one line and the EXP.SHARE double pass still
|
||||
prints both. The hot path is unchanged: `Runtime.wantsHook` still guards
|
||||
the ctx allocation, and `vanillaAward` never passes `silent`.
|
||||
- **The mod-API test.** The seam is driven through `hooks:wrap` on a real
|
||||
`Runtime.install`ed bus, not by calling internals: the omitted argument
|
||||
announces, an explicit `nil` and an explicit `false` are silent, a truthy
|
||||
value (including Gen 1's `"expAll"`) announces, the exp and stat exp paid
|
||||
are identical either way, and no `experience` event leaks into the queue on
|
||||
a silent pass.
|
||||
- `tests/engine/gate_gen2_mod_api.lua` (943 checks),
|
||||
`tests/engine/gate_hooks.lua` (493) and `tests/engine/gate_events.lua` (529)
|
||||
pass unchanged; `battle.exp_award` was already in the shared catalog, so no
|
||||
gate list moves.
|
||||
- `tests/gen2_battle_test.lua` (690), `gen2_battle_end_test.lua` (32),
|
||||
`gen2_battle_items_test.lua` (99), `gen2_badge_boosts_test.lua` (34) and
|
||||
`gen2_battle_loss_test.lua` (15) pass unchanged.
|
||||
|
||||
## Docs with the change
|
||||
|
||||
`docs/mod-api-gen2-compat.md` gains the `applyShare` reading beside the
|
||||
existing `battle.low_health_alarm` payload note, in the same section that lists
|
||||
`battle.exp_award` as shared — including the argument-count rule and the
|
||||
residual difference above.
|
||||
|
||||
No registry or schema field changes, so `src/mods/Schemas.lua` is untouched and
|
||||
`tools/gen_registry_docs.lua` has nothing new to emit.
|
||||
|
||||
## Deprecation etiquette
|
||||
|
||||
Nothing is removed, renamed, superseded or deprecated. The two-argument call is
|
||||
not deprecated either — it keeps its current Gen 2 meaning permanently, and the
|
||||
docs name the explicit form as the one that behaves the same on both games.
|
||||
+6
-3
@@ -126,9 +126,12 @@ bundled game, in that case.
|
||||
persistent launcher control. Android downloads the release APK, verifies
|
||||
its SHA-256 entry from `sha256sums.txt`, then invokes Android's Package
|
||||
Installer. The installer asks the user for consent and enforces package,
|
||||
version-code, and signing-certificate compatibility. iOS links the
|
||||
sideload repository for a re-sideload; Xbox, desktop, and PortMaster builds
|
||||
link their correctly named full package. Switch keeps its native OTA flow.
|
||||
version-code, and signing-certificate compatibility. A legacy APK without
|
||||
the installer bridge links its full package for one manual bootstrap
|
||||
update, including when its downloaded payload already reports the latest
|
||||
engine version. iOS links the sideload repository for a re-sideload; Xbox,
|
||||
desktop, and PortMaster builds link their correctly named full package.
|
||||
Switch keeps its native OTA flow.
|
||||
|
||||
## Known limitations
|
||||
|
||||
|
||||
@@ -24,10 +24,10 @@ local GameViewport = require("src.render.GameViewport")
|
||||
|
||||
-- Lua errors: persist a redacted trace in the save dir and surface a hint.
|
||||
do
|
||||
local defaultErrorHandler = love.errorhandler
|
||||
local defaultErrorHandler = love.errorhandler or love.errhand
|
||||
function love.errorhandler(msg)
|
||||
local hint = SwitchDiagnostics.logLuaError(msg)
|
||||
if hint and type(msg) == "string" then
|
||||
local ok, hint = pcall(SwitchDiagnostics.logLuaError, msg)
|
||||
if ok and hint and type(msg) == "string" then
|
||||
msg = msg .. "\n\n" .. hint
|
||||
end
|
||||
if defaultErrorHandler then
|
||||
@@ -82,6 +82,43 @@ end
|
||||
local editorHost, editorVersion, editorWindow
|
||||
local closeEditor -- forward declaration: openEditor hands it to the editor
|
||||
|
||||
-- Drop CacheFs / Data / mod Runtime / Assets / LegacyCompat for one mounted
|
||||
-- version session (save editor or game). closeEditor and returnToLauncher
|
||||
-- both go through here so neither path can forget a singleton the other resets.
|
||||
local function teardownMountedSession(version)
|
||||
if version then
|
||||
require("src.import.CacheFs").unmountVersion(version)
|
||||
end
|
||||
require("src.core.Data"):unloadGenerated()
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
if Runtime.reset then Runtime.reset() end
|
||||
local Assets = require("src.render.Assets")
|
||||
if Assets.installLoader then Assets.installLoader(nil) end
|
||||
local okCompat, LegacyCompat = pcall(require, "src.mods.LegacyCompat")
|
||||
if okCompat and LegacyCompat.reset then LegacyCompat.reset() end
|
||||
end
|
||||
|
||||
-- Evict every save-editor module from package.loaded without a hardcoded
|
||||
-- panel whitelist. Flat require names (App, Party, …) resolve under
|
||||
-- tools/save-editor/; path-style keys may also appear. A key is flushed
|
||||
-- when it names a save-editor path or when tools/save-editor/{panels/}K.lua
|
||||
-- exists for a flat name K -- new panels are picked up automatically.
|
||||
local function flushEditorPackageLoaded()
|
||||
local fs = love and love.filesystem
|
||||
local function isEditorFlat(name)
|
||||
if not (fs and fs.getInfo) then return false end
|
||||
if name:find("[./]") then return false end
|
||||
return fs.getInfo("tools/save-editor/" .. name .. ".lua") ~= nil
|
||||
or fs.getInfo("tools/save-editor/panels/" .. name .. ".lua") ~= nil
|
||||
end
|
||||
for k in pairs(package.loaded) do
|
||||
if type(k) == "string"
|
||||
and (k:find("save%-editor", 1, false) or isEditorFlat(k)) then
|
||||
package.loaded[k] = nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- The editor's modules use flat names (require("Kit"), require("Party")), so
|
||||
-- their directories have to be on the require path. It must be
|
||||
-- love.filesystem's path, not package.path: in a packaged build these files
|
||||
@@ -180,9 +217,9 @@ local function openEditor(version, slotId)
|
||||
if EditorApp.unload then pcall(EditorApp.unload) end
|
||||
EditorApp = nil
|
||||
if version then
|
||||
require("src.import.CacheFs").unmountVersion(version)
|
||||
require("src.core.Data"):unloadGenerated()
|
||||
teardownMountedSession(version)
|
||||
end
|
||||
flushEditorPackageLoaded()
|
||||
restoreWindow()
|
||||
Importer = editorHost
|
||||
editorHost = nil
|
||||
@@ -197,21 +234,17 @@ end
|
||||
-- Back to the launcher. Everything the editor mounted or cached has to come
|
||||
-- back out: the version overlay (CacheFs) and the generated modules require
|
||||
-- cached behind it (Data), or pressing Play on the OTHER game would boot it
|
||||
-- with this one's data.
|
||||
-- with this one's data. Also reset Runtime / Assets / LegacyCompat so the
|
||||
-- next Edit or Play does not inherit the editor's dead mod loader.
|
||||
function closeEditor()
|
||||
local version = editorVersion
|
||||
editorMode = false
|
||||
if EditorApp and EditorApp.unload then EditorApp.unload() end
|
||||
EditorApp = nil
|
||||
if version then
|
||||
require("src.import.CacheFs").unmountVersion(version)
|
||||
require("src.core.Data"):unloadGenerated()
|
||||
end
|
||||
for k in pairs(package.loaded) do
|
||||
if type(k) == "string" and (k:find("save%-editor") or k == "App" or k == "Kit" or k == "State" or k == "Catalog" or k == "SaveIO" or k == "Ops" or k == "MonOps" or k == "ItemOps" or k == "PadInput" or k == "Gen" or k == "Theme") then
|
||||
package.loaded[k] = nil
|
||||
end
|
||||
teardownMountedSession(version)
|
||||
end
|
||||
flushEditorPackageLoaded()
|
||||
editorVersion = nil
|
||||
restoreWindow()
|
||||
Importer = editorHost
|
||||
@@ -333,16 +366,11 @@ local function returnToLauncher()
|
||||
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local currentVersion = GameVersion.get()
|
||||
if currentVersion then
|
||||
require("src.import.CacheFs").unmountVersion(currentVersion)
|
||||
end
|
||||
require("src.core.Data"):unloadGenerated()
|
||||
teardownMountedSession(currentVersion)
|
||||
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
if Runtime.reset then
|
||||
Runtime.reset()
|
||||
if Game.reset then
|
||||
pcall(function() Game:reset() end)
|
||||
end
|
||||
|
||||
Game = nil
|
||||
autopilot = nil
|
||||
driverCo = nil
|
||||
|
||||
@@ -12,6 +12,27 @@
|
||||
"tintColor": "3b5ca8",
|
||||
"category": "games",
|
||||
"versions": [
|
||||
{
|
||||
"version": "0.2.20",
|
||||
"date": "2026-08-22",
|
||||
"size": 13812226,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.20/gen1recomp++-0.2.20-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1536 Magnet Train Cutscene visual bugs\n- #1597 Link cable looks broken\n- #1643 Pikachu + Title uses an incorrect Colour Palette\n- #1662 The game glitches after exiting, returning to the menu, and choosing another one.\n\n## Contributors\n\n- @1Jamie\n- @bryanthaboi\n- @colsonrice"
|
||||
},
|
||||
{
|
||||
"version": "0.2.19",
|
||||
"date": "2026-08-22",
|
||||
"size": 13778435,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.19/gen1recomp++-0.2.19-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1274 Choose ROM imports a different version than the one selected when several dumps are pending (Linux, no file picker)\n- #1484 (Pokémon Gold) Bill's PC doesn't show equipped items + missing orange filter on pokémon sprite\n- #1486 (Pokémon Gold) Can't deposit key items\n- #1513 Experience share issue different case from #1464\n- #1517 Game Crash Opening Stats\n- #1548 Colisión\n- #1556 (Gold) Missing SFX for a lot of actions\n- #1564 Bide's storing turn is missing the screen shake\n- #1567 TM/HM case not in order\n- #1663 transition pops the top state, destroying anything pushed from a midpoint callback\n- #1664 gold bide spends pp every turn instead of only the turn it is selected\n- #1665 gold has no bide lock-in, so a mid-bide switch of move strands the counter\n- #1666 sleep talk is inert in gold: EFFECT_SLEEP_TALK has no move effect entry\n- #1667 gold: a fainted party mon keeps its exp participant credit and halves the survivor's exp\n- #1668 summary page 2 formats a move's pp with no numeric guard\n- #1670 gold tm/hm pocket capacity is an invented 64 where the cart has 57 slots\n- #1672 bill's pc move screen is missing the box name arrows\n\n## Contributors\n\n- @AverageConsumer\n- @bryanthaboi\n- @colsonrice\n- @jramiresbrito"
|
||||
},
|
||||
{
|
||||
"version": "0.2.18",
|
||||
"date": "2026-08-21",
|
||||
"size": 13772120,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.18/gen1recomp++-0.2.18-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @bryanthaboi"
|
||||
},
|
||||
{
|
||||
"version": "0.2.17",
|
||||
"date": "2026-08-21",
|
||||
|
||||
+21
-2
@@ -7,7 +7,8 @@
|
||||
# the nil global), unused values, unreachable code. The .luacheckrc mutes the
|
||||
# cosmetic categories the codebase lives with, so what prints is worth a look.
|
||||
#
|
||||
# scripts/lint.sh lint src/
|
||||
# scripts/lint.sh full advisory report over every shipped tree
|
||||
# scripts/lint.sh --gate only the codes CI blocks on (0xx, 1xx, 511)
|
||||
# scripts/lint.sh src tools lint specific paths
|
||||
#
|
||||
# Install once with: luarocks install luacheck
|
||||
@@ -15,9 +16,27 @@
|
||||
set -uo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
DEFAULT_PATHS=(main.lua conf.lua src data/scripts mods tools)
|
||||
|
||||
GATE=0
|
||||
if [ "${1:-}" = "--gate" ]; then
|
||||
GATE=1
|
||||
shift
|
||||
fi
|
||||
|
||||
if ! command -v luacheck >/dev/null 2>&1; then
|
||||
echo "luacheck not found on PATH (install: luarocks install luacheck)" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
luacheck "${@:-src}"
|
||||
if [ "$#" -gt 0 ]; then
|
||||
PATHS=("$@")
|
||||
else
|
||||
PATHS=("${DEFAULT_PATHS[@]}")
|
||||
fi
|
||||
|
||||
if [ "$GATE" = "1" ]; then
|
||||
exec luacheck "${PATHS[@]}" -q --codes --only 0 1 511
|
||||
fi
|
||||
|
||||
luacheck "${PATHS[@]}"
|
||||
|
||||
@@ -66,6 +66,15 @@ run_tier() {
|
||||
|
||||
# ------- ROM-free tiers: these are what CI runs
|
||||
|
||||
if command -v luacheck >/dev/null 2>&1; then
|
||||
run_tier "T0 luacheck gate (undefined globals, unreachable code)" \
|
||||
./scripts/lint.sh --gate
|
||||
else
|
||||
echo ""
|
||||
echo "-- T0 luacheck gate: skipped (no luacheck on PATH --"
|
||||
echo " luarocks install luacheck; CI installs and gates on it regardless)"
|
||||
fi
|
||||
|
||||
run_tier "T0 ROM builder version routing" python3 tests/build_rom_data_cli_test.py
|
||||
run_tier "T0 ROM manifest generator pin/overrides" python3 tests/rom_manifest_generator_test.py
|
||||
run_tier "T0 switch CI workflow content gate" "$LUA" tests/switch_ci_workflows_test.lua
|
||||
|
||||
@@ -538,6 +538,7 @@ end
|
||||
|
||||
local function makeBattler(data, mon, isPlayer, save)
|
||||
local def = data.pokemon[mon.species]
|
||||
require("src.pokemon.Stats").ensure(def, mon)
|
||||
local badgeBoosts = data.constants and data.constants.badgeBoosts
|
||||
local badges = nil
|
||||
if isPlayer and save then
|
||||
|
||||
@@ -645,7 +645,9 @@ MoveEffects.full = {
|
||||
user.bideTurns = ctx.rng(2, 3)
|
||||
user.bideDamage = 0
|
||||
ctx.battle:cancelMoveAnim()
|
||||
ctx.anim(user.isPlayer and "XSTATITEM_ANIM" or "XSTATITEM_DUPLICATE_ANIM")
|
||||
ctx.battle:animBeforeMove(
|
||||
user.isPlayer and "XSTATITEM_ANIM" or "XSTATITEM_DUPLICATE_ANIM",
|
||||
user.isPlayer)
|
||||
ctx.say(Strings("%s\nis storing energy!", displayName(user)))
|
||||
end,
|
||||
},
|
||||
|
||||
+217
-41
@@ -903,8 +903,16 @@ function Battle:movePriority(moveId)
|
||||
return (def and Battle.PRIORITY[def.effect]) or 0
|
||||
end
|
||||
|
||||
-- engine/battle/effect_commands.asm:192-197 (enemy twin :383-390)
|
||||
Battle.SLEEP_BYPASS_MOVES = { SNORE = true, SLEEP_TALK = true }
|
||||
|
||||
-- Can this mon act? Returns true, or false plus the message the cart prints.
|
||||
function Battle:canAct(mon)
|
||||
-- `moveId` is wCurPlayerMove / wCurEnemyMove (effect_commands.asm:193).
|
||||
local function clearBide(state)
|
||||
state.bideTurns, state.bideStored, state.bideMove = nil, nil, nil
|
||||
end
|
||||
|
||||
local function checkTurn(self, mon, moveId)
|
||||
local name = self:monName(mon)
|
||||
-- SUBSTATUS_RECHARGE, and it is checked BEFORE status: CheckPlayerTurn reads
|
||||
-- it first, clears it, prints MustRechargeText and jumps to EndTurn, so a mon
|
||||
@@ -925,7 +933,11 @@ function Battle:canAct(mon)
|
||||
local beforeMove = record and record.beforeMove
|
||||
if beforeMove
|
||||
and (record.beforeMovePriority or 0) > Battle.VOLATILE_PRIORITY then
|
||||
return beforeMove(self, mon, name) and true or false
|
||||
-- engine/battle/effect_commands.asm:188-200
|
||||
local bypass = mon.status == "sleep" and Battle.SLEEP_BYPASS_MOVES[moveId]
|
||||
local acted = beforeMove(self, mon, name) and true or false
|
||||
if acted or not bypass then return acted end
|
||||
beforeMove = nil
|
||||
end
|
||||
-- SUBSTATUS_FLINCHED, read and cleared right after the freeze check
|
||||
-- (CheckPlayerTurn / CheckEnemyTurn `.not_frozen`). Set this turn by the
|
||||
@@ -959,6 +971,14 @@ function Battle:canAct(mon)
|
||||
return true
|
||||
end
|
||||
|
||||
-- CantMove (engine/battle/effect_commands.asm:344-353) clears BIDE on every
|
||||
-- arm of CheckPlayerTurn / CheckEnemyTurn that spends the turn.
|
||||
function Battle:canAct(mon, moveId)
|
||||
local acted = checkTurn(self, mon, moveId)
|
||||
if not acted then clearBide(self:volatile(mon)) end
|
||||
return acted
|
||||
end
|
||||
|
||||
-- STRUGGLE, the move a mon with nothing left to spend falls back to
|
||||
-- (engine/battle/core.asm `.CheckPlayerHasUsableMoves` for the player and
|
||||
-- `.struggle` for the enemy). It lives in the move table like any other move
|
||||
@@ -1234,10 +1254,15 @@ function Battle:dealDamage(attacker, defender, damage, opts)
|
||||
if opts.critical then
|
||||
self:emit({ kind = "message", text = "A critical hit!" })
|
||||
end
|
||||
-- SuperEffectiveText / NotVeryEffectiveText (data/text/battle.asm:603,608).
|
||||
-- The cart breaks both across the box's two lines and hyphenates "super-"
|
||||
-- to do it, and the not-very line ends on the single ellipsis glyph Gold's
|
||||
-- charmap carries at $75, not three periods.
|
||||
if opts.effectiveness and opts.effectiveness > 10 then
|
||||
self:emit({ kind = "message", text = "It's super effective!" })
|
||||
self:emit({ kind = "message", text = Strings("It's super-\neffective!") })
|
||||
elseif opts.effectiveness and opts.effectiveness < 10 then
|
||||
self:emit({ kind = "message", text = "It's not very effective..." })
|
||||
self:emit({ kind = "message",
|
||||
text = Strings("It's not very\neffective…") })
|
||||
end
|
||||
if endured then
|
||||
self:emit({ kind = "message",
|
||||
@@ -1389,9 +1414,18 @@ function Battle:useMove(attacker, defender, moveId)
|
||||
-- free of PP and obedience in exactly the same way.
|
||||
local rolling = state.rolloutLock == moveId
|
||||
|
||||
if not (charging or rampaging or rolling) then
|
||||
-- engine/battle/effect_commands.asm:977-979, data/moves/effects.asm:795-800,
|
||||
-- engine/battle/move_effects/bide.asm:62-68
|
||||
local biding = def.effect == "EFFECT_BIDE" and state.bideTurns ~= nil
|
||||
|
||||
-- engine/battle/effect_commands.asm:6222-6234, :949-951
|
||||
local called = (self.copyDepth or 0) > 0
|
||||
|
||||
if not (charging or rampaging or rolling or biding or called) then
|
||||
if move and (move.pp or 0) <= 0 then
|
||||
self:emit({ kind = "message", text = "No PP left for this move!" })
|
||||
-- BattleText_TheresNoPPLeftForThisMove (data/text/battle.asm:315).
|
||||
self:emit({ kind = "message",
|
||||
text = Strings("There's no PP left\nfor this move!") })
|
||||
return
|
||||
end
|
||||
if move then move.pp = (move.pp or 1) - 1 end
|
||||
@@ -1471,9 +1505,38 @@ function Battle:useMove(attacker, defender, moveId)
|
||||
return
|
||||
end
|
||||
|
||||
-- engine/battle/move_effects/sleep_talk.asm:2, :16-19, :61
|
||||
if def.effect == "EFFECT_SLEEP_TALK" then
|
||||
local picked
|
||||
if attacker.status == "sleep" and (self.copyDepth or 0) == 0 then
|
||||
-- engine/battle/move_effects/sleep_talk.asm:40-44, :117-141
|
||||
local pool = {}
|
||||
for _, own in ipairs(attacker.moves or {}) do
|
||||
local ownDef = self:moveDef(own.id)
|
||||
local effect = ownDef and ownDef.effect
|
||||
if own.id ~= moveId and not self:moveDisabled(attacker, own.id)
|
||||
and not Effects.CHARGE[effect] and effect ~= "EFFECT_BIDE" then
|
||||
pool[#pool + 1] = own.id
|
||||
end
|
||||
end
|
||||
if #pool > 0 then picked = pool[rand(self.random, #pool) + 1] end
|
||||
end
|
||||
if not picked then
|
||||
self:markMissed()
|
||||
self:emit({ kind = "message", text = "But it failed!" })
|
||||
return
|
||||
end
|
||||
state.lastMove = nil
|
||||
self.copyDepth = (self.copyDepth or 0) + 1
|
||||
self:useMove(attacker, defender, picked)
|
||||
self.copyDepth = self.copyDepth - 1
|
||||
return
|
||||
end
|
||||
|
||||
-- Everything past here counts as "the user's last move" for Mirror Move,
|
||||
-- Encore and Disable.
|
||||
state.lastMove = moveId
|
||||
-- Encore and Disable. A called move skips the write
|
||||
-- (engine/battle/used_move_text.asm:30-36).
|
||||
if (self.copyDepth or 0) == 0 then state.lastMove = moveId end
|
||||
state.turnsTaken = (state.turnsTaken or 0) + 1
|
||||
state.usedMoves = state.usedMoves or {}
|
||||
local seen = false
|
||||
@@ -1516,6 +1579,13 @@ function Battle:useMove(attacker, defender, moveId)
|
||||
return
|
||||
end
|
||||
|
||||
-- BattleCommand_Snore (engine/battle/move_effects/snore.asm:1-9)
|
||||
if def.effect == "EFFECT_SNORE" and attacker.status ~= "sleep" then
|
||||
self:markMissed()
|
||||
self:emit({ kind = "message", text = "But it failed!" })
|
||||
return
|
||||
end
|
||||
|
||||
-- Counter and Mirror Coat answer what the user took this turn, at double,
|
||||
-- and fail outright when nothing of the right kind landed.
|
||||
local counterKind = Effects.COUNTER[def.effect]
|
||||
@@ -1747,8 +1817,12 @@ function Battle:useMove(attacker, defender, moveId)
|
||||
landed = landed + 1
|
||||
end
|
||||
if landed > 1 then
|
||||
self:emit({ kind = "message",
|
||||
text = ("Hit %d time(s)!"):format(landed) })
|
||||
-- PlayerHitTimesText / EnemyHitTimesText (data/text/battle.asm:749,755)
|
||||
-- are "Hit @ times!". Gen 2 has no singular form of this line, so the
|
||||
-- plural stands even at one hit rather than the "(s)" this printed.
|
||||
-- Gen 1 already says it this way (src/battle/EffectRegistry.lua,
|
||||
-- _HitXTimesText).
|
||||
self:emit({ kind = "message", text = Strings("Hit %d times!", landed) })
|
||||
end
|
||||
|
||||
-- move_effects/pay_day.asm:13
|
||||
@@ -1968,8 +2042,11 @@ Battle.MOVE_EFFECTS.EFFECT_PERISH_SONG = function(self)
|
||||
if mine.perish and theirs.perish then return fail(self) end
|
||||
if not mine.perish then mine.perish = Effects.PERISH_TURNS end
|
||||
if not theirs.perish then theirs.perish = Effects.PERISH_TURNS end
|
||||
-- StartPerishText (data/text/battle.asm:986). What shipped here was a
|
||||
-- sentence no cart prints; the Gen 2 line names both sides and counts in
|
||||
-- digits.
|
||||
self:emit({ kind = "message",
|
||||
text = "All POKéMON hearing the song will faint in three turns!" })
|
||||
text = Strings("Both POKéMON will\nfaint in 3 turns!") })
|
||||
end
|
||||
|
||||
-- BattleCommand_Encore: 3-6 turns locked into the move the target last used.
|
||||
@@ -2099,6 +2176,11 @@ Battle.MOVE_EFFECTS.EFFECT_SPIKES = function(self, attacker, defender)
|
||||
local side = self:sideOf(defender)
|
||||
if self.spikes[side] then return fail(self) end
|
||||
self.spikes[side] = true
|
||||
-- SpikesText (data/text/battle.asm:974) is three rows, the third scrolled
|
||||
-- (`cont`) and carrying <TARGET>. The battle message path has no `cont`:
|
||||
-- src/ui/gen2/BattleState.lua sets self.message straight from the event and
|
||||
-- printMessage cuts past two rows, so the cart's line cannot be told here
|
||||
-- yet without the name being dropped on screen. Left as it stands.
|
||||
self:emit({ kind = "message", text = "Spikes were scattered all around!" })
|
||||
end
|
||||
|
||||
@@ -2133,6 +2215,8 @@ Battle.MOVE_EFFECTS.EFFECT_BIDE = function(self, attacker, defender, def, moveId
|
||||
if not state.bideTurns then
|
||||
state.bideTurns = Effects.bideTurns(self.random)
|
||||
state.bideStored = 0
|
||||
-- engine/battle/core.asm:574-576
|
||||
state.bideMove = moveId
|
||||
self:emit({ kind = "message",
|
||||
text = self:monName(attacker) .. " is storing energy!" })
|
||||
return
|
||||
@@ -2144,7 +2228,7 @@ Battle.MOVE_EFFECTS.EFFECT_BIDE = function(self, attacker, defender, def, moveId
|
||||
return
|
||||
end
|
||||
local damage = Effects.bideDamage(state.bideStored)
|
||||
state.bideTurns, state.bideStored = nil, nil
|
||||
state.bideTurns, state.bideStored, state.bideMove = nil, nil, nil
|
||||
self:emit({ kind = "message",
|
||||
text = self:monName(attacker) .. " unleashed energy!" })
|
||||
if damage <= 0 then return fail(self) end
|
||||
@@ -2334,7 +2418,9 @@ Battle.MOVE_EFFECTS.EFFECT_BEAT_UP = function(self, attacker, defender, def)
|
||||
{ move = def, moveId = def and def.id })
|
||||
landed = landed + 1
|
||||
end
|
||||
self:emit({ kind = "message", text = ("Hit %d time(s)!"):format(landed) })
|
||||
-- BattleCommand_EndLoop prints the same line for Beat Up, and Beat Up can
|
||||
-- land exactly once, which is the case the cart still prints as "times".
|
||||
self:emit({ kind = "message", text = Strings("Hit %d times!", landed) })
|
||||
end
|
||||
|
||||
-- BattleCommand_Heal (effect_commands.asm:5986): Recover and Rest are both
|
||||
@@ -2417,6 +2503,8 @@ Battle.MOVE_EFFECTS.EFFECT_BATON_PASS = function(self, attacker)
|
||||
self.enemyIndex = target
|
||||
self.enemy = party[target]
|
||||
self.enemy.volatile = carried
|
||||
-- engine/battle/move_effects/baton_pass.asm:59
|
||||
self:resetParticipants()
|
||||
end
|
||||
local sent = side == "player" and self.player or self.enemy
|
||||
self:emit({ kind = "send", side = side, mon = sent,
|
||||
@@ -2679,6 +2767,8 @@ Battle.MOVE_EFFECTS.EFFECT_FORCE_SWITCH = function(self, attacker, defender,
|
||||
self.enemyIndex = pick
|
||||
self.enemy = incoming
|
||||
self.stages.enemy = Battle.newStages()
|
||||
-- ForceEnemySwitch (engine/battle/core.asm:2937)
|
||||
self:resetParticipants()
|
||||
end
|
||||
self:emit({ kind = "send", side = self:sideOf(incoming), mon = incoming,
|
||||
hp = incoming.hp or 0, status = incoming.status or false,
|
||||
@@ -3088,6 +3178,12 @@ end
|
||||
|
||||
-- Faint bookkeeping and experience. Returns true when the battle ended.
|
||||
function Battle:resolveFaints()
|
||||
-- engine/battle/core.asm:2551-2556, :7116-7130, :3033-3037
|
||||
if (self.player.hp or 0) <= 0 and self.participantsCleared ~= self.player then
|
||||
self.participantsCleared = self.player
|
||||
if self.playerIndex then self.participants[self.playerIndex] = nil end
|
||||
end
|
||||
|
||||
if (self.enemy.hp or 0) <= 0 then
|
||||
self:emit({ kind = "faint", side = "enemy",
|
||||
text = (self.wild and "Wild " or "") .. self:monName(self.enemy)
|
||||
@@ -3272,7 +3368,16 @@ end
|
||||
-- `count` is the pass's own divisor -- the participant count for the first
|
||||
-- pass, the holder count for the EXP.SHARE pass -- and `halved` is whether
|
||||
-- any Share holder taxed the whole pool.
|
||||
function Battle:giveExperiencePass(loser, def, recipients, count, halved)
|
||||
--
|
||||
-- `silent` suppresses only the GainedText line. It exists for the
|
||||
-- battle.exp_award seam below, where a mod paying the bench wants one summary
|
||||
-- line rather than a box per mon; the cart's own two passes never pass it, so
|
||||
-- vanilla prints exactly what it always did. Everything else about the pass
|
||||
-- -- the exp, the stat exp, battle.exp_gained, "grew to level", learned moves
|
||||
-- and the forget prompt -- is unaffected, because a silent award is still an
|
||||
-- award.
|
||||
function Battle:giveExperiencePass(loser, def, recipients, count, halved,
|
||||
silent)
|
||||
for _, index in ipairs(recipients) do
|
||||
local mon = self.party[index]
|
||||
if mon and (mon.hp or 0) > 0 and not mon.isEgg then
|
||||
@@ -3323,10 +3428,12 @@ function Battle:giveExperiencePass(loser, def, recipients, count, halved)
|
||||
index = index,
|
||||
})
|
||||
end
|
||||
self:emit({ kind = "experience", index = index, amount = amount,
|
||||
-- BoostedExpPointsText, keyed on the traded arm alone.
|
||||
text = self:monName(mon) .. " gained "
|
||||
.. (traded and "a boosted " or "") .. amount .. " EXP. Points!" })
|
||||
if not silent then
|
||||
self:emit({ kind = "experience", index = index, amount = amount,
|
||||
-- BoostedExpPointsText, keyed on the traded arm alone.
|
||||
text = self:monName(mon) .. " gained "
|
||||
.. (traded and "a boosted " or "") .. amount .. " EXP. Points!" })
|
||||
end
|
||||
if result.levels > 0 then
|
||||
-- "level up happiness mod", the cart's own comment, sitting right
|
||||
-- after the stat recalc and before the "grew to level" text. It fires
|
||||
@@ -3341,7 +3448,9 @@ function Battle:giveExperiencePass(loser, def, recipients, count, halved)
|
||||
local moveDef = self:moveDef(moveId)
|
||||
local moveName = (moveDef and moveDef.name) or moveId
|
||||
if ok then
|
||||
-- data/text/common_3.asm:119
|
||||
self:emit({ kind = "message",
|
||||
sfx = "Sfx_DexFanfare5079", waitSfx = true,
|
||||
text = self:monName(mon) .. " learned " .. moveName .. "!" })
|
||||
elseif reason == "full" then
|
||||
-- LearnMove's full-moveset arm calls ForgetMove, which asks with
|
||||
@@ -3420,22 +3529,38 @@ function Battle:awardExperience(loser)
|
||||
|
||||
-- battle.exp_award, the same hook BattleState:awardExp calls on Gen 1 and
|
||||
-- with the same ctx: the participant COUNT, the live participants, and an
|
||||
-- applyShare(mon, split) a mod can call to pay one mon its own share. The
|
||||
-- third applyShare argument is Gen 1's EXP.ALL announcement variant; Gen 2
|
||||
-- has no EXP.ALL (the EXP.SHARE pass below is its replacement), so it is
|
||||
-- accepted and ignored rather than changing what is printed. `recipients`,
|
||||
-- `holders` and `halved` are the Gen 2 additions.
|
||||
-- applyShare(mon, split, announce) a mod can call to pay one mon its own
|
||||
-- share. `recipients`, `holders` and `halved` are the Gen 2 additions.
|
||||
--
|
||||
-- `announce` is Gen 1's third argument (src/battle/BattleState.lua
|
||||
-- applyShare) and means the same thing here: truthy prints the mon's
|
||||
-- GainedText, falsy pays it silently. That is what lets one mod source
|
||||
-- print ONE summary line for a party-wide award on both generations instead
|
||||
-- of a box per mon -- which is what the Exp Share mod documents and could
|
||||
-- not do on Gold, because this argument used to be accepted and ignored.
|
||||
--
|
||||
-- It is honoured only when it is actually PASSED, by argument count rather
|
||||
-- than by value. A Gen 2-era mod calling applyShare(mon, split) was written
|
||||
-- against a seam that always announced and keeps announcing; a caller that
|
||||
-- passes the argument -- including an explicit nil, which is what a "pay
|
||||
-- this one quietly" call looks like -- gets Gen 1's reading. So no existing
|
||||
-- mod changes behaviour, and a mod that opts in gets parity.
|
||||
if Runtime.wantsHook("battle.exp_award") then
|
||||
local alive = {}
|
||||
for _, index in ipairs(participants) do
|
||||
local mon = self.party[index]
|
||||
if mon and (mon.hp or 0) > 0 then alive[#alive + 1] = mon end
|
||||
end
|
||||
local function applyShare(mon, split)
|
||||
local function applyShare(mon, split, ...)
|
||||
local announce = ...
|
||||
-- select("#") counts an explicit nil; `announce == nil` alone could not
|
||||
-- tell applyShare(mon, split) from applyShare(mon, split, nil), and
|
||||
-- those two have to mean different things here.
|
||||
local silent = select("#", ...) > 0 and not announce
|
||||
for index, candidate in ipairs(self.party) do
|
||||
if candidate == mon then
|
||||
return self:giveExperiencePass(loser, def, { index },
|
||||
math.max(1, split or 1), halved)
|
||||
math.max(1, split or 1), halved, silent)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -3450,8 +3575,7 @@ function Battle:awardExperience(loser)
|
||||
|
||||
-- GiveExperiencePoints .done falls through ResetBattleParticipants into
|
||||
-- AddBattleParticipant (engine/battle/core.asm:7116 and :3033).
|
||||
self.participants = {}
|
||||
if self.playerIndex then self.participants[self.playerIndex] = true end
|
||||
self:resetParticipants()
|
||||
end
|
||||
|
||||
-- The answer to a `choose-forget`: drop the move in `slot` and put the
|
||||
@@ -3472,7 +3596,9 @@ function Battle:resolveForget(index, slot, entry, moveName)
|
||||
end
|
||||
self:emit({ kind = "message",
|
||||
text = "1, 2 and… " .. self:monName(mon) .. " forgot " .. oldName .. "!" })
|
||||
-- engine/pokemon/learn.asm:115, data/text/common_3.asm:119
|
||||
self:emit({ kind = "message",
|
||||
sfx = "Sfx_DexFanfare5079", waitSfx = true,
|
||||
text = self:monName(mon) .. " learned "
|
||||
.. (moveName or (entry and entry.id) or "?") .. "!" })
|
||||
-- The forget path writes the slot itself rather than going through
|
||||
@@ -3511,6 +3637,13 @@ function Battle:switchLocked()
|
||||
return self:volatile(self.enemy).trapsTarget == true
|
||||
end
|
||||
|
||||
-- ResetBattleParticipants falls through into AddBattleParticipant
|
||||
-- (engine/battle/core.asm:3033 and :3037).
|
||||
function Battle:resetParticipants()
|
||||
self.participants = {}
|
||||
if self.playerIndex then self.participants[self.playerIndex] = true end
|
||||
end
|
||||
|
||||
-- EnemySwitch's shift arm zeroes both participant bitfields before PlayerSwitch
|
||||
-- (engine/battle/core.asm:2959-2961).
|
||||
function Battle:shiftSwitch(index)
|
||||
@@ -3533,6 +3666,7 @@ function Battle:switch(index)
|
||||
-- A mon that comes back (a REVIVE, or a second battle) has to be able to
|
||||
-- announce its own faint again; see resolveFaints.
|
||||
self.faintAnnounced = nil
|
||||
self.participantsCleared = nil
|
||||
-- ForcePlayerMonChoice has been answered, so the next faint may ask again.
|
||||
self.pendingSwitch = nil
|
||||
self.player = mon
|
||||
@@ -3770,19 +3904,41 @@ function Battle:lockedInMove(mon)
|
||||
return nil
|
||||
end
|
||||
|
||||
-- ParsePlayerAction's bide arm (engine/battle/core.asm:569-576), enemy twin
|
||||
-- at :5650
|
||||
function Battle:fightLockedMove(mon)
|
||||
local state = self:volatile(mon)
|
||||
if state.bideTurns then return state.bideMove end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- engine/battle/core.asm:627-629
|
||||
function Battle:cancelBide(mon)
|
||||
clearBide(self:volatile(mon))
|
||||
end
|
||||
|
||||
-- Encore forces the move; Disable forbids one. Both are read by the screen
|
||||
-- (to grey out the move list) and by the enemy's own choice below.
|
||||
-- engine/battle/core.asm:561-566
|
||||
local function encoredMove(state, mon)
|
||||
if not state.encore then return nil end
|
||||
for _, move in ipairs(mon.moves or {}) do
|
||||
if move.id == state.encore and (move.pp or 0) > 0 then
|
||||
return state.encore
|
||||
end
|
||||
end
|
||||
state.encore, state.encoreTurns = nil, nil
|
||||
return nil
|
||||
end
|
||||
|
||||
function Battle:forcedMove(mon)
|
||||
local locked = self:lockedInMove(mon)
|
||||
if locked then return locked end
|
||||
local state = self:volatile(mon)
|
||||
if not state.encore then return nil end
|
||||
for _, move in ipairs(mon.moves or {}) do
|
||||
if move.id == state.encore and (move.pp or 0) > 0 then return state.encore end
|
||||
end
|
||||
-- Encore ends early when the move runs out of PP.
|
||||
state.encore, state.encoreTurns = nil, nil
|
||||
return nil
|
||||
-- ParsePlayerAction reads SUBSTATUS_ENCORED ahead of the bide arm
|
||||
-- (engine/battle/core.asm:561-566).
|
||||
local encored = encoredMove(self:volatile(mon), mon)
|
||||
if encored then return encored end
|
||||
return self:fightLockedMove(mon)
|
||||
end
|
||||
|
||||
function Battle:moveDisabled(mon, moveId)
|
||||
@@ -3796,8 +3952,9 @@ function Battle:usableMoves(mon)
|
||||
-- .CheckPlayerHasUsableMoves (core.asm:533-556), so a Rollout or a rampage
|
||||
-- that spent its last PP on the opening turn keeps running: no later turn
|
||||
-- of the lock spends any. Encore is not in this exemption -- forcedMove
|
||||
-- ends it the moment the encored move runs dry.
|
||||
local locked = self:lockedInMove(mon)
|
||||
-- ends it the moment the encored move runs dry. Bide is exempt too:
|
||||
-- .CheckPlayerHasUsableMoves lives inside MoveSelectionScreen (core.asm:5058).
|
||||
local locked = self:lockedInMove(mon) or self:fightLockedMove(mon)
|
||||
local out = {}
|
||||
for _, move in ipairs(mon.moves or {}) do
|
||||
local ok = (move.pp or 0) > 0 and not self:moveDisabled(mon, move.id)
|
||||
@@ -3999,6 +4156,8 @@ function Battle:enemyTrySwitchOrItem()
|
||||
.. self:monName(outgoing) .. "!" })
|
||||
self.enemyIndex = target
|
||||
self.enemy = self.enemyParty[target]
|
||||
-- AI_Switch (engine/battle/ai/items.asm:697)
|
||||
self:resetParticipants()
|
||||
-- ResetEnemyBattleVars (engine/battle/core.asm:3016) zeroes wCurEnemyMove
|
||||
-- and wLastEnemyMove and NewEnemyMonStatus wipes the substatus bytes, so
|
||||
-- the mon coming IN starts from an empty area -- the same pair of clears
|
||||
@@ -4089,9 +4248,16 @@ function Battle:vanillaEnemyMove()
|
||||
-- move answers "TYPHLOSION's attack missed!", so Hitmonlee cannot be damaged
|
||||
-- by anything, at any level. Fifteen straight attempts at the Elite Four
|
||||
-- died there, and no amount of grinding could ever have got past it.
|
||||
local charged = self:volatile(self.enemy).chargeMove
|
||||
local enemyState = self:volatile(self.enemy)
|
||||
local charged = enemyState.chargeMove
|
||||
if charged then return charged end
|
||||
|
||||
-- engine/battle/core.asm:5524-5533: the encore arm runs ahead of
|
||||
-- CheckEnemyLockedIn (:5650).
|
||||
local encored = encoredMove(enemyState, self.enemy)
|
||||
if encored then return encored end
|
||||
if enemyState.bideTurns then return enemyState.bideMove end
|
||||
|
||||
-- Encore and Disable narrow the pool before the AI ever scores it.
|
||||
local moves = self:usableMoves(self.enemy)
|
||||
if #moves == 0 then
|
||||
@@ -4185,6 +4351,7 @@ local function runTurn(self, action)
|
||||
-- (engine/battle/core.asm:5035-5038), which reopens the 2x2 menu with the
|
||||
-- turn unspent -- so a refused RUN never bought the enemy a free attack.
|
||||
if self.runRefused then return self:takeEvents() end
|
||||
self:cancelBide(self.player)
|
||||
action = { kind = "skip" }
|
||||
end
|
||||
|
||||
@@ -4200,6 +4367,9 @@ local function runTurn(self, action)
|
||||
if action.kind == "item" and Battle.X_ITEMS[action.item] then
|
||||
Happiness.change(self.player, "USEDXITEM")
|
||||
end
|
||||
-- engine/battle/core.asm:572-573 into :627-629; a switch takes :570-571
|
||||
-- instead and keeps the store.
|
||||
if action.kind == "item" then self:cancelBide(self.player) end
|
||||
|
||||
-- AI_SwitchOrTryItem runs BEFORE the move is chosen: a trainer that decides
|
||||
-- to rotate or drink a potion spends its whole turn on it.
|
||||
@@ -4248,7 +4418,6 @@ local function runTurn(self, action)
|
||||
|
||||
local function playerAttack()
|
||||
if action.kind ~= "move" then return end
|
||||
if not self:canAct(self.player) then return end
|
||||
local move = action.move
|
||||
-- An encored mon has no choice, whatever the menu said.
|
||||
local forced = self:forcedMove(self.player)
|
||||
@@ -4268,13 +4437,20 @@ local function runTurn(self, action)
|
||||
-- player's own mon spends the rest of the battle underground.
|
||||
local stored = self:volatile(self.player).chargeMove
|
||||
if stored then move = stored end
|
||||
-- engine/battle/core.asm:558-598 settles wCurPlayerMove before
|
||||
-- engine/battle/effect_commands.asm:193 reads it.
|
||||
if not self:canAct(self.player, move) then return end
|
||||
-- CheckPlayerLockedIn quits before .CheckPlayerHasUsableMoves and before
|
||||
-- checkobedience, so a locked Rollout or Thrash is exempt from the
|
||||
-- Struggle substitution and the obedience roll the same way the second
|
||||
-- half of a charge move is.
|
||||
local charging = self:volatile(self.player).chargeMove == move
|
||||
or self:lockedInMove(self.player) == move
|
||||
if not charging and not self:hasUsableMoves(self.player) then
|
||||
-- engine/battle/core.asm:5058, and data/moves/effects.asm:796 keeps
|
||||
-- `checkobedience`.
|
||||
local bideLocked = self:fightLockedMove(self.player) == move
|
||||
if not charging and not bideLocked
|
||||
and not self:hasUsableMoves(self.player) then
|
||||
self:emit({ kind = "message",
|
||||
text = self:monName(self.player) .. " has no moves left!" })
|
||||
move = Battle.STRUGGLE
|
||||
@@ -4312,7 +4488,7 @@ local function runTurn(self, action)
|
||||
-- against a trainer, could not be escaped either.
|
||||
enemyMoveId = Battle.STRUGGLE
|
||||
end
|
||||
if not self:canAct(self.enemy) then return end
|
||||
if not self:canAct(self.enemy, enemyMoveId) then return end
|
||||
-- CheckEnemyTurn's disabled arm (engine/battle/effect_commands.asm:562-574):
|
||||
-- the AI chose before the player's Disable landed, so the turn is spent here.
|
||||
if self:moveDisabled(self.enemy, enemyMoveId) then
|
||||
|
||||
@@ -1381,4 +1381,26 @@ function Game:restoreCheckpointBattle(battle)
|
||||
if battle.resumeCheckpoint then battle:resumeCheckpoint() end
|
||||
end
|
||||
|
||||
-- Drop every session-owned field so the next Game:load() starts clean when
|
||||
-- the process returns to the launcher in-place (Android / intent_game).
|
||||
-- main.lua must not guess field names: new systems (Game.network, …) are
|
||||
-- cleared automatically because only functions (methods) are kept.
|
||||
function Game:reset()
|
||||
if self.stack and self.stack.clear then
|
||||
pcall(function() self.stack:clear() end)
|
||||
end
|
||||
if self.renderer and self.renderer.releaseCanvases then
|
||||
pcall(function() self.renderer:releaseCanvases() end)
|
||||
end
|
||||
local keys = {}
|
||||
for key, value in pairs(self) do
|
||||
if type(value) ~= "function" then
|
||||
keys[#keys + 1] = key
|
||||
end
|
||||
end
|
||||
for _, key in ipairs(keys) do
|
||||
self[key] = nil
|
||||
end
|
||||
end
|
||||
|
||||
return Game
|
||||
|
||||
+38
-6
@@ -483,8 +483,10 @@ function Game2:learnMoveOn(mon, moveId, onDone)
|
||||
if onDone then onDone(learned) end
|
||||
end
|
||||
if ok then
|
||||
-- data/text/common_3.asm:119
|
||||
return self:say(("%s learned\n%s!"):format(name, moveName),
|
||||
function() finish(true) end)
|
||||
function() finish(true) end,
|
||||
TextBox.soundOpts(self, "Sfx_DexFanfare5079"))
|
||||
end
|
||||
if reason ~= "full" then return finish(false) end
|
||||
local askForget, pickMove, askStop
|
||||
@@ -539,9 +541,11 @@ function Game2:learnMoveOn(mon, moveId, onDone)
|
||||
-- The slot is written here rather than through Mon.learnMove, so
|
||||
-- pokemon.move_learned is raised here too.
|
||||
ModRuntime.emit("pokemon.move_learned", { mon = mon, moveId = moveId })
|
||||
-- engine/pokemon/learn.asm:115, data/text/common_3.asm:119
|
||||
self:say(("1, 2 and… Poof!\f%s forgot\n%s.\fAnd…\f%s learned\n%s!")
|
||||
:format(name, oldName, name, moveName),
|
||||
function() finish(true) end)
|
||||
function() finish(true) end,
|
||||
TextBox.soundOpts(self, "Sfx_DexFanfare5079"))
|
||||
end,
|
||||
})
|
||||
end
|
||||
@@ -706,7 +710,9 @@ function Game2:usePartyItem(itemId)
|
||||
})
|
||||
elseif action == "candy" then
|
||||
self:consumeItem(itemId)
|
||||
self:say(result.text, function() self:afterRareCandy(mon, result) end)
|
||||
-- data/text/common_1.asm:86
|
||||
self:say(result.text, function() self:afterRareCandy(mon, result) end,
|
||||
result.sfx and TextBox.soundOpts(self, result.sfx) or nil)
|
||||
else
|
||||
self:consumeItem(itemId)
|
||||
self:say(result.text)
|
||||
@@ -765,8 +771,10 @@ function Game2:useSelectItem()
|
||||
local name = (items[itemId] and items[itemId].name) or itemId
|
||||
self:say(Strings("{PLAYER} used the\n%s.", name))
|
||||
elseif outcome == "trophy_sent" then
|
||||
-- data/text/common_3.asm:372
|
||||
self:say(Strings(
|
||||
"There was a trophy\ninside!\fThe trophy was\nsent home."))
|
||||
"There was a trophy\ninside!\fThe trophy was\nsent home."),
|
||||
nil, TextBox.soundOpts(self, "Sfx_DexFanfare5079"))
|
||||
end
|
||||
-- Anything else (a fishing bite, the ITEMFINDER's queued script) already
|
||||
-- drives its own presentation off World:step -- nothing left to print here.
|
||||
@@ -778,9 +786,9 @@ end
|
||||
-- onDone that popped again ate the state UNDER the box: dismissing a message
|
||||
-- over the PACK closed the PACK with it, and over an empty overworld stack it
|
||||
-- was a silent extra pop.
|
||||
function Game2:say(text, onDone)
|
||||
function Game2:say(text, onDone, opts)
|
||||
local TextBox = require("src.render.TextBox")
|
||||
self.stack:push(TextBox.new(self, text, onDone))
|
||||
self.stack:push(TextBox.new(self, text, onDone, opts))
|
||||
end
|
||||
|
||||
-- The landmark the player is standing in, for the Pokegear map's marker.
|
||||
@@ -935,6 +943,12 @@ function Game2:load()
|
||||
self.data.gen2Scripts = loadGenerated("data/generated/scripts.lua")
|
||||
self.data.gen2StdScripts = loadGenerated("data/generated/std_scripts.lua")
|
||||
self.data.gen2Text = loadGenerated("data/generated/text.lua")
|
||||
-- The engine's own strings, keyed by the disassembly's label. gen2Text
|
||||
-- above is the script text and is keyed by bank:address for the overworld
|
||||
-- VM, so the two are different tables and both are loaded. This one is
|
||||
-- what src/core/RomText.lua reads, which is why it lands on `text`: that
|
||||
-- helper is shared with Gen 1 and looks up data.text[label].
|
||||
self.data.text = loadGenerated("data/generated/rom_text.lua") or {}
|
||||
-- data/generated/events.lua: the side tables a script command NAMES rather
|
||||
-- than carries -- the phone book, the in-game trades, the elevator's floor
|
||||
-- labels, the decoration descriptions. Keyed for World's own `eventTables`
|
||||
@@ -2109,4 +2123,22 @@ function Game2:joystickremoved()
|
||||
TouchControls:joystickremoved()
|
||||
end
|
||||
|
||||
-- In-process return-to-launcher (Android / intent_game): drop session fields
|
||||
-- so a later Game2.new() + load is not sharing a live stack or mod loader.
|
||||
-- Methods live on the class table; pairs(self) only sees instance state.
|
||||
function Game2:reset()
|
||||
if self.stack and self.stack.clear then
|
||||
pcall(function() self.stack:clear() end)
|
||||
end
|
||||
local keys = {}
|
||||
for key, value in pairs(self) do
|
||||
if type(value) ~= "function" then
|
||||
keys[#keys + 1] = key
|
||||
end
|
||||
end
|
||||
for _, key in ipairs(keys) do
|
||||
self[key] = nil
|
||||
end
|
||||
end
|
||||
|
||||
return Game2
|
||||
|
||||
+21
-1
@@ -1700,6 +1700,14 @@ local function clamp(n, lo, hi, fallback)
|
||||
return n
|
||||
end
|
||||
|
||||
-- PP and the PP Up count are unsigned bit fields of one byte
|
||||
-- (constants/pokemon_data_constants.asm:101-102)
|
||||
local function ppInt(v, fallback)
|
||||
local n = tonumber(v)
|
||||
if n == nil or n ~= n or n == math.huge or n == -math.huge then return fallback end
|
||||
return math.max(0, math.floor(n))
|
||||
end
|
||||
|
||||
local function ensureOrphaned(save)
|
||||
if not save.orphaned then
|
||||
save.orphaned = { mons = {}, items = {} }
|
||||
@@ -1763,7 +1771,7 @@ local function scrubKnownMon(mon, data)
|
||||
-- (status_screen.asm:66-76, add_mon.asm _MoveMon); deriving once here means
|
||||
-- every later reader (menus, battle, items, SGB bar zones, the link
|
||||
-- fingerprint) sees a party-shaped mon. Runs after the level clamp above
|
||||
-- so the derived stats use a sane level. A save that already has stats is
|
||||
-- so the derived stats use a sane level. A complete stat block is
|
||||
-- untouched.
|
||||
Stats.ensure(data.pokemon and data.pokemon[mon.species], mon)
|
||||
local moves = mon.moves
|
||||
@@ -1783,6 +1791,18 @@ local function scrubKnownMon(mon, data)
|
||||
moves[1] = { id = fallback, pp = def.pp }
|
||||
end
|
||||
end
|
||||
-- the replacement PP mirrors AddBonusPP (engine/items/item_effects.asm:2418);
|
||||
-- Mimic rewrites the move id and not the PP (engine/battle/effects.asm:1266)
|
||||
for j = 1, #moves do
|
||||
local slot = moves[j]
|
||||
if type(slot) == "table" then
|
||||
local mdef = data.moves and data.moves[slot.id]
|
||||
local base = ppInt(mdef and mdef.pp, 0)
|
||||
local ppUps = math.min(3, ppInt(slot.ppUps, 0))
|
||||
if slot.ppUps ~= nil then slot.ppUps = ppUps end
|
||||
slot.pp = ppInt(slot.pp, base + ppUps * math.floor(base / 5))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function scrubMonList(list, where, save, data, report)
|
||||
|
||||
@@ -240,6 +240,8 @@ local function rareCandy(mon, data)
|
||||
used = true,
|
||||
level = newLevel,
|
||||
learned = learned,
|
||||
-- data/text/common_1.asm:86
|
||||
sfx = "Sfx_DexFanfare5079",
|
||||
text = ("%s grew to\nlevel %d!"):format(monName(mon), newLevel),
|
||||
}
|
||||
end
|
||||
|
||||
+22
-8
@@ -614,14 +614,18 @@ function CacheFs.mountVersion(version)
|
||||
return true
|
||||
end
|
||||
|
||||
-- Undo mountVersion. A process normally mounts exactly one version and then
|
||||
-- boots it, but the launcher can open the save editor on one game's save,
|
||||
-- close it, and press Play on another: with the first version's subtree
|
||||
-- still prepended, the other's require("data.generated.*") and generated
|
||||
-- art would silently resolve to the first game's files. Callers must also
|
||||
-- drop the generated modules from package.loaded
|
||||
-- (src.core.Data:unloadGenerated) -- unmounting alone only fixes the read
|
||||
-- path, not what require already cached.
|
||||
-- Undo mountVersion in LIFO order relative to mountVersion: generated-tree
|
||||
-- overlays first (assets, then data -- reverse of mountGeneratedTrees), then
|
||||
-- the version folder. PHYSFS resolves by stack order; peeling the wrong
|
||||
-- layer first can leave another version's generated files winning a name.
|
||||
--
|
||||
-- A process normally mounts exactly one version and then boots it, but the
|
||||
-- launcher can open the save editor on one game's save, close it, and press
|
||||
-- Play on another: with the first version's subtree still prepended, the
|
||||
-- other's require("data.generated.*") and generated art would silently
|
||||
-- resolve to the first game's files. Callers must also drop the generated
|
||||
-- modules from package.loaded (src.core.Data:unloadGenerated) -- unmounting
|
||||
-- alone only fixes the read path, not what require already cached.
|
||||
--
|
||||
-- Returns true when nothing was mounted or the unmount took.
|
||||
function CacheFs.unmountVersion(version)
|
||||
@@ -633,6 +637,16 @@ function CacheFs.unmountVersion(version)
|
||||
base = love.filesystem.getSaveDirectory()
|
||||
end
|
||||
local done = false
|
||||
-- LIFO vs mountGeneratedTrees: assets/generated, then data/generated.
|
||||
if love.filesystem and love.filesystem.unmount then
|
||||
local generated = {
|
||||
prefix .. "assets/generated",
|
||||
prefix .. "data/generated",
|
||||
}
|
||||
for _, src in ipairs(generated) do
|
||||
done = love.filesystem.unmount(src) or done
|
||||
end
|
||||
end
|
||||
local fn = resolveUnmount()
|
||||
if fn and base then
|
||||
done = fn(base .. SEP .. sub) or done
|
||||
|
||||
@@ -1320,7 +1320,7 @@ local function buildHeader(imp, m)
|
||||
return y + math.floor(10 * m.s)
|
||||
end
|
||||
|
||||
-- The state of the self-updater, as a top-right control.
|
||||
-- The state of the self-updater, shown in the launcher footer.
|
||||
-- Returns status, label, action, glow.
|
||||
function LauncherView._updateControl(imp)
|
||||
if not imp.Check then return nil end
|
||||
|
||||
@@ -27,7 +27,7 @@ RomExtractorGen2.__index = RomExtractorGen2
|
||||
-- scripts, pokemon, moves, items, marts, encounters, trainers, pokedex,
|
||||
-- landmarks, intro movie, menu gfx, title, credits, diploma, trade animation,
|
||||
-- audio, stubs
|
||||
local STAGE_COUNT = 26
|
||||
local STAGE_COUNT = 27
|
||||
local Opcodes = require("src.script.gen2.Opcodes")
|
||||
|
||||
-- BG palette slots inside one loaded 8-palette set (constants/tileset_constants.asm
|
||||
@@ -136,6 +136,18 @@ local TEXT_NO_GLYPH = {
|
||||
[0x13] = true, [0x15] = true,
|
||||
}
|
||||
|
||||
-- The three runtime name slots. PlaceMoveUsersName, PlaceMoveTargetsName and
|
||||
-- PlaceEnemysName (home/text.asm:302, :307, :327) swap these for a battler's
|
||||
-- own name as the line prints, so they are markers rather than glyphs. They
|
||||
-- decode to the same shape Gen 1 uses, which src/core/RomText.lua already
|
||||
-- fills in argument order. Dropped, SubTookDamageText read "took damage
|
||||
-- for" with nothing after it.
|
||||
local NAME_SLOT = {
|
||||
["<USER>"] = "{USER}",
|
||||
["<TARGET>"] = "{TARGET}",
|
||||
["<ENEMY>"] = "{ENEMY}",
|
||||
}
|
||||
|
||||
local ROOF_TILES = 9
|
||||
local SPRITEDATA_LENGTH = 6
|
||||
|
||||
@@ -2596,6 +2608,8 @@ function RomExtractorGen2:decodeGen2Text(bank, address, charmap, buffers)
|
||||
out[#out + 1] = ch
|
||||
elseif ch == "<……>" or b == 0x56 then
|
||||
out[#out + 1] = "……"
|
||||
elseif NAME_SLOT[ch] then
|
||||
out[#out + 1] = NAME_SLOT[ch]
|
||||
elseif not ch then
|
||||
out[#out + 1] = ("{BYTE:%02X}"):format(b)
|
||||
end
|
||||
@@ -3698,6 +3712,37 @@ function RomExtractorGen2:splashGfx()
|
||||
}
|
||||
end
|
||||
|
||||
-- The engine's own strings, keyed by the label the disassembly gives them.
|
||||
--
|
||||
-- This is what extractOakSpeech has always done for _OakText1-7: resolve the
|
||||
-- label, decode from the cart, key by name. What is new is that the list of
|
||||
-- labels comes from the manifest instead of being written out here, so all of
|
||||
-- data/text/ arrives rather than seven strings. Gen 1 has had the same table
|
||||
-- since RomExtractor:extractText; this is the Gen 2 side of it, and it is
|
||||
-- what lets src/core/RomText.lua work on Gold and Silver at all.
|
||||
--
|
||||
-- Written as `rom_text` rather than `text`: data/generated/text.lua is
|
||||
-- already the script text, keyed by bank:address for the overworld VM, and
|
||||
-- these are a different table with different keys.
|
||||
function RomExtractorGen2:extractText()
|
||||
self:beginStage("Dialogue")
|
||||
local charmap = self.manifest.charmap or {}
|
||||
local labels = (self.manifest.text or {}).labels or {}
|
||||
local texts = {}
|
||||
for index, label in ipairs(labels) do
|
||||
local location = self.symbols[label]
|
||||
-- A label the manifest names but the symbol table does not carry would
|
||||
-- be a generator bug, not a cart difference: make_gold_manifest.py
|
||||
-- resolves every one of these before it writes the list.
|
||||
if location then
|
||||
texts[label] = self:decodeGen2Text(location[1], location[2], charmap)
|
||||
end
|
||||
self:tick("Dialogue", index, #labels)
|
||||
end
|
||||
self:write("rom_text", texts)
|
||||
return texts
|
||||
end
|
||||
|
||||
-- OakSpeech (engine/menus/intro_menu.asm): named _OakText* strings plus the
|
||||
-- POKEMON_PROF / CAL trainer pics shown before NamePlayer. Also pulls
|
||||
-- Shrink1/2 pics and the GameFreak splash sheets for the boot cinema.
|
||||
@@ -5151,6 +5196,7 @@ function RomExtractorGen2:extractMenuGfx()
|
||||
}
|
||||
out.pack = pack
|
||||
out.pokedex = self:pokedexGfx()
|
||||
out.billsPc = self:billsPcGfx()
|
||||
out.pokegear = self:pokegearGfx()
|
||||
out.trainerCard = self:trainerCardGfx()
|
||||
out.unownPuzzle = self:unownPuzzleGfx()
|
||||
@@ -5697,6 +5743,25 @@ function RomExtractorGen2:pokedexGfx()
|
||||
return dex
|
||||
end
|
||||
|
||||
-- BillsPC_InitGFX's four tiles at vTiles2 $5c
|
||||
-- (engine/pokemon/bills_pc.asm:2170-2173)
|
||||
function RomExtractorGen2:billsPcGfx()
|
||||
if not self.symbols["PCMailGFX"] then return nil end
|
||||
local pc = {}
|
||||
local gfx = self:symbol("PCMailGFX")
|
||||
self:write2bpp(self.rom:bytes(gfx.bank, gfx.address, 4 * 16),
|
||||
32, 8, "pc/mail_item.png")
|
||||
pc.icons = "assets/generated/pc/mail_item.png"
|
||||
pc.firstTile = 0x5c
|
||||
pc.palette = self:predefPal(PREDEFPAL_POKEDEX)
|
||||
-- gfx/pc/orange.pal
|
||||
if self.symbols["BillsPCOrangePalette"] then
|
||||
local orange = self:symbol("BillsPCOrangePalette")
|
||||
pc.orangePalette = self:colors(orange.bank, orange.address, 4)
|
||||
end
|
||||
return pc
|
||||
end
|
||||
|
||||
-- Reads a `tile, count` RLE tilemap (Pokegear_LoadTilemapRLE). The routine's
|
||||
-- own comment says "repeat count, tile ID" and has it backwards: it loads b
|
||||
-- from the first byte, c from the second, and writes `b` c times. $ff ends
|
||||
@@ -6263,6 +6328,7 @@ function RomExtractorGen2:run()
|
||||
results.sprites = self:extractSprites()
|
||||
results.stdScripts = self:extractStdScripts()
|
||||
results.scripts = self:extractScriptsAndText(results.maps, results.stdScripts)
|
||||
results.text = self:extractText()
|
||||
results.pokemon = self:extractPokemon()
|
||||
results.moves = self:extractMoves()
|
||||
results.items = self:extractItems()
|
||||
|
||||
@@ -104,6 +104,12 @@ local VERSION_REQUIRED_FILES_OVERRIDE = {
|
||||
"data/generated/sprites.lua", -- OW sheets (Chris + NPCs)
|
||||
"data/generated/scripts.lua", -- disassembled map scripts
|
||||
"data/generated/text.lua", -- decoded Gen 2 dialogue strings
|
||||
-- The engine's own strings, keyed by label rather than by address. A
|
||||
-- cache built before RomExtractorGen2:extractText has none, and every
|
||||
-- line that reads through src/core/RomText.lua would silently keep
|
||||
-- printing its Lua fallback, so this re-imports those caches rather than
|
||||
-- bumping CACHE_FORMAT and dragging Red, Blue and Yellow through it too.
|
||||
"data/generated/rom_text.lua",
|
||||
"data/generated/pokemon.lua",
|
||||
"data/generated/tilesets.lua",
|
||||
"data/generated/audio.lua",
|
||||
@@ -146,6 +152,8 @@ local VERSION_REQUIRED_FILES_OVERRIDE = {
|
||||
-- complete and SlotMachine crashed on its labelled-cell fallback.
|
||||
"assets/generated/slots/gold_slots_1.png",
|
||||
"assets/generated/card_flip/card_flip_1.png",
|
||||
-- PCMailGFX (engine/pokemon/bills_pc.asm:2170-2173)
|
||||
"assets/generated/pc/mail_item.png",
|
||||
},
|
||||
}
|
||||
-- Same Gen 2 extract, so a Silver cache is complete when the same files exist.
|
||||
@@ -1084,13 +1092,19 @@ end
|
||||
-- naive "first ROM wins" scan would re-import Red when the player tries to
|
||||
-- add Blue (issue #167). Yellow and Gold carts are typically .gbc (Gold is
|
||||
-- 2 MiB).
|
||||
local function findPendingRom(ready)
|
||||
-- `wanted` narrows the scan to one version. A Choose names the cart it is
|
||||
-- for, so without it several dumps sitting in the save directory answered in
|
||||
-- listing order and the selection was dropped: picking Red imported Blue
|
||||
-- (#1274). The callers that are not a Choose, the Android USB-drop scans,
|
||||
-- pass nothing and still take the first pending cart of any version.
|
||||
local function findPendingRom(ready, wanted)
|
||||
for _, name in ipairs(love.filesystem.getDirectoryItems("")) do
|
||||
if name:lower():match("%.gbc?$") and love.filesystem.getInfo(name, "file") then
|
||||
local data = love.filesystem.read(name)
|
||||
if type(data) == "string" and isAcceptedRomSize(#data) then
|
||||
local version = GameVersion.forSha1(sha1(data))
|
||||
if version and not ready[version] then
|
||||
if version and not ready[version]
|
||||
and (wanted == nil or version == wanted) then
|
||||
return name, data
|
||||
end
|
||||
end
|
||||
@@ -2573,8 +2587,9 @@ function RomImporter:choose(version)
|
||||
if self.android then
|
||||
-- Prefer a not-yet-imported .gb/.gbc already in the save dir (USB copy, or
|
||||
-- a fresh SAF pick). Never reuse an already-imported cart's file -- that
|
||||
-- was the #167 failure mode (second Choose just re-extracted Red).
|
||||
local name, data = findPendingRom(self.ready)
|
||||
-- was the #167 failure mode (second Choose just re-extracted Red). This
|
||||
-- is a Choose, so it takes the cart that was asked for and nothing else.
|
||||
local name, data = findPendingRom(self.ready, self.chooseVersion)
|
||||
if name then
|
||||
self:startData(data, name)
|
||||
elseif consumePickedRomError(self) then
|
||||
@@ -2602,8 +2617,10 @@ function RomImporter:choose(version)
|
||||
-- Handheld Linux (Anbernic stock OS / PortMaster) rarely has zenity or
|
||||
-- kdialog. Fall back to the same "drop a .gb/.gbc next to the game" scan
|
||||
-- used on Android, which works when the game is launched as an unpacked
|
||||
-- directory (see build-rg34xxsp.sh).
|
||||
local name, data = findPendingRom(self.ready)
|
||||
-- directory (see build-rg34xxsp.sh). Narrowed to the chosen version: with
|
||||
-- four dumps in the folder the unnarrowed scan answered in listing order,
|
||||
-- so Choose Red imported and decoded Blue (#1274).
|
||||
local name, data = findPendingRom(self.ready, self.chooseVersion)
|
||||
if name then
|
||||
self:startData(data, name)
|
||||
return
|
||||
|
||||
@@ -10,13 +10,14 @@
|
||||
local Bag = {}
|
||||
|
||||
-- MAX_ITEMS / MAX_BALLS / MAX_KEY_ITEMS, and the TM/HM pocket holds one of
|
||||
-- every TM plus the seven HMs (NUM_TMS + NUM_HMS). A `mods` bagSize override
|
||||
-- every TM plus the seven HMs (NUM_TMS + NUM_HMS -- ram/wram.asm:2421 is
|
||||
-- `wTMsHMs:: ds NUM_TMS + NUM_HMS`, 57 bytes). A `mods` bagSize override
|
||||
-- replaces the ITEM pocket only, the way the Gen 1 single-bag config did.
|
||||
local POCKET_CAPACITY = {
|
||||
ITEM = 20,
|
||||
BALL = 12,
|
||||
KEY_ITEM = 25,
|
||||
TM_HM = 64,
|
||||
TM_HM = 57,
|
||||
}
|
||||
local DEFAULT_CAPACITY = 20
|
||||
|
||||
|
||||
+46
-5
@@ -808,6 +808,33 @@ local function buildOverworld()
|
||||
local Movement = rawRequire("src.script.gen2.Movement")
|
||||
local HiddenItems = rawRequire("src.world.gen2.HiddenItems")
|
||||
local Bike = rawRequire("src.world.gen2.Bike")
|
||||
local FieldMoves = rawRequire("src.world.gen2.FieldMoves")
|
||||
|
||||
-- home/map.asm:1869
|
||||
local function stepAllowed(world, entity, dir, cx, cy)
|
||||
local d = Map2.DELTA[dir]
|
||||
if not (world.map and d and entity) then return true end
|
||||
cx, cy = cx or entity.cellX, cy or entity.cellY
|
||||
if not (cx and cy) then return true end
|
||||
if not Permissions.stepPermitted(
|
||||
function(px, py) return world:cellCollisionAcross(world.map, px, py) end,
|
||||
cx, cy, dir) then
|
||||
return false
|
||||
end
|
||||
local map = world.map
|
||||
if entity == world.player and FieldMoves.isSurfing(world.playerState) then
|
||||
map = world:surfMap(world.map)
|
||||
end
|
||||
local tx, ty = cx + d[1], cy + d[2]
|
||||
if not map:inBounds(tx, ty) or not map:isWalkable(tx, ty) then return false end
|
||||
for _, e in ipairs(world.entities or {}) do
|
||||
if e ~= entity and not e.passable then
|
||||
if e.cellX == tx and e.cellY == ty then return false end
|
||||
if e.moving and e.targetX == tx and e.targetY == ty then return false end
|
||||
end
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
local api = nil
|
||||
-- one WorldAPI instance, so queueScript reuses the five-verb allow list
|
||||
@@ -1011,20 +1038,33 @@ local function buildOverworld()
|
||||
|
||||
-- Gold has ONE movement slot; a second concurrent call is refused with a
|
||||
-- reason rather than dropped (src/world/gen2/WorldAPI.lua:171's recipe).
|
||||
function ow.scriptMove(entity, dir, tiles, onDone)
|
||||
function ow.scriptMove(entity, dir, tiles, onDone, opts)
|
||||
local world = w("scriptMove")
|
||||
if not world then return nil, "no overworld" end
|
||||
if world.moveState then return nil, "a movement is already running" end
|
||||
local step = Movement.stepByte(dir)
|
||||
if not step then return nil, "unknown direction: " .. tostring(dir) end
|
||||
local bytes = {}
|
||||
for _ = 1, math.max(0, tiles or 1) do bytes[#bytes + 1] = step end
|
||||
bytes[#bytes + 1] = Movement.STEP_END
|
||||
local objectId = objectIdOf(world, entity)
|
||||
if not objectId then
|
||||
return nil, "no Gen 2 objectId for that entity: only the player and a "
|
||||
.. "mapped object (def.index) can be moved"
|
||||
end
|
||||
local n = math.max(0, tiles or 1)
|
||||
if opts and opts.collide and n > 0 then
|
||||
local d = Map2.DELTA[dir]
|
||||
local cx, cy = entity.cellX, entity.cellY
|
||||
local allowed = 0
|
||||
for _ = 1, n do
|
||||
if not stepAllowed(world, entity, dir, cx, cy) then break end
|
||||
allowed = allowed + 1
|
||||
if d and cx and cy then cx, cy = cx + d[1], cy + d[2] end
|
||||
end
|
||||
if allowed < n then entity.facing = dir end
|
||||
n = allowed
|
||||
end
|
||||
local bytes = {}
|
||||
for _ = 1, n do bytes[#bytes + 1] = step end
|
||||
bytes[#bytes + 1] = Movement.STEP_END
|
||||
world:beginMovement(objectId, bytes, onDone)
|
||||
return true
|
||||
end
|
||||
@@ -1401,7 +1441,8 @@ COVERAGE[OW] = {
|
||||
.. "objectId 1 is wLastTalked, not object zero",
|
||||
scriptMove = "the player maps to objectId 0 and a mapped object to "
|
||||
.. "def.index + 1; an entity with neither (a mod's own guest) is "
|
||||
.. "REFUSED with a reason rather than moving the last-talked NPC",
|
||||
.. "REFUSED with a reason rather than moving the last-talked NPC; "
|
||||
.. "opts.collide truncates the walk at the first blocked step",
|
||||
connectionLanding = "Gen 1's five values (destDef, tilesetDef, x, y, "
|
||||
.. "conn); `conn` is Gold's connection record, keyed map/mapId + offset",
|
||||
timeOfDay = "recomputed from the clock every call and never cached, so a "
|
||||
|
||||
@@ -119,6 +119,17 @@ local GEN1_ONLY_MODULES = {
|
||||
["src.ui.OptionsMenu"] = true,
|
||||
}
|
||||
|
||||
local function crossGenerationDenial(name, generation)
|
||||
if type(name) ~= "string" or generation ~= 1 then return nil end
|
||||
if not (name:find("^src%.[%w_]+%.gen2%.") or name == "src.core.Game2") then
|
||||
return nil
|
||||
end
|
||||
return ("%s is a Gen 2 engine module and this is a Gen 1 game; the structs "
|
||||
.. "it reads and writes are not this game's, so anything it stores lands "
|
||||
.. "on the save in the wrong shape. Take the game from mod.game and the "
|
||||
.. "world from mod.world, which resolve per generation"):format(name)
|
||||
end
|
||||
|
||||
-- the src.* modules the mod surface points authors at: another mod's
|
||||
-- exports carry a version string that wants range-checking before use, and
|
||||
-- ChipAsm is the authoring path for chip music and sfx
|
||||
@@ -214,6 +225,7 @@ function Loader:_installDevShim()
|
||||
if owner or callerIsMod(3) then
|
||||
local id = type(owner) == "string" and owner or nil
|
||||
local denial = Sandbox.moduleDenial(name, devShim.permissions[id])
|
||||
or (id and crossGenerationDenial(name, devShim.generation))
|
||||
if denial then error(("[%s] %s"):format(id or "mod", denial), 0) end
|
||||
end
|
||||
if devShim.dev or devShim.generation ~= 1 then scanRequire(name) end
|
||||
|
||||
+13
-4
@@ -51,11 +51,20 @@ end
|
||||
-- (engine/pokemon/status_screen.asm:66-76, "mon is in a box or daycare" ->
|
||||
-- CalcStats) and when one is moved back into the party
|
||||
-- (engine/pokemon/add_mon.asm _MoveMon tail). The stored current HP is
|
||||
-- kept (box_struct does hold it) but clamped to the recalculated maximum so
|
||||
-- a tampered save cannot overfill the bar. A mon that already has stats is
|
||||
-- returned untouched, so a vanilla save round-trips. #233, #304
|
||||
-- kept (box_struct does hold it) but clamped to the recalculated maximum.
|
||||
-- A complete stat block is returned untouched; an incomplete one is rebuilt,
|
||||
-- because CalcStats writes all NUM_STATS stats or none
|
||||
-- (home/move_mon.asm:33-48). #233, #304, #1517
|
||||
local function statsComplete(stats)
|
||||
for _, key in ipairs(ORDER) do
|
||||
if type(stats[key]) ~= "number" then return false end
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
function Stats.ensure(speciesDef, mon)
|
||||
if type(mon) ~= "table" or type(mon.stats) == "table" then return mon end
|
||||
if type(mon) ~= "table" then return mon end
|
||||
if type(mon.stats) == "table" and statsComplete(mon.stats) then return mon end
|
||||
if type(speciesDef) ~= "table" or type(speciesDef.baseStats) ~= "table" then
|
||||
return mon
|
||||
end
|
||||
|
||||
+16
-10
@@ -397,9 +397,12 @@ function PaletteFX.uiSpriteRedraws()
|
||||
end
|
||||
|
||||
-- Active named-palette table for COLORS: RED++ uses data/palettes_gbc.lua,
|
||||
-- everything else uses the ROM-imported data.palettes.
|
||||
-- Yellow uses data/palettes_yellow.lua, everything else uses the ROM-imported data.palettes.
|
||||
function PaletteFX.pack(data)
|
||||
if PaletteFX.usesGbcPack() then
|
||||
if GameVersion.isYellow() then
|
||||
local y = PaletteFX.yellowPack()
|
||||
if y then return y end
|
||||
elseif PaletteFX.usesGbcPack() then
|
||||
local g = PaletteFX.gbcPack()
|
||||
if g then return g end
|
||||
end
|
||||
@@ -449,24 +452,27 @@ function PaletteFX.pal(data, name)
|
||||
local fromRom = romNamedPal(data, name)
|
||||
if fromRom then return fromRom end
|
||||
end
|
||||
if PaletteFX.usesYellowCgb() then
|
||||
local fromCgb = yellowCgbNamedPal(data, name)
|
||||
if fromCgb then return fromCgb end
|
||||
if GameVersion.isYellow() then
|
||||
if PaletteFX.usesYellowCgb() then
|
||||
local fromCgb = yellowCgbNamedPal(data, name)
|
||||
if fromCgb then return fromCgb end
|
||||
end
|
||||
local y = PaletteFX.yellowPack()
|
||||
local yc = y and y.palettes and y.palettes[name]
|
||||
if yc then return yc end
|
||||
local fromRom = romNamedPal(data, name)
|
||||
if fromRom then return fromRom end
|
||||
end
|
||||
local p = PaletteFX.pack(data)
|
||||
local c = p and p.palettes and p.palettes[name]
|
||||
if c then return c end
|
||||
if GameVersion.isYellow() then
|
||||
local y = PaletteFX.yellowPack()
|
||||
local yc = y and y.palettes and y.palettes[name]
|
||||
if yc then return yc end
|
||||
end
|
||||
if PaletteFX.usesGbcPack() then
|
||||
return romNamedPal(data, name)
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
|
||||
-- the species' palette (data/pokemon/palettes.asm), MEWMON for unknowns.
|
||||
-- transformed forces PAL_GRAYMON (Ditto's palette) regardless of species
|
||||
-- (engine/gfx/palettes.asm DeterminePaletteID: bit TRANSFORMED, a; a
|
||||
|
||||
@@ -100,10 +100,30 @@ local function positionLift(ph, contentPx, dpiY, cut)
|
||||
return ScreenPosition.lift(ph, contentPx, ScreenPosition.safeTop() * dpiY)
|
||||
end
|
||||
|
||||
-- Free GPU canvases immediately. Overwriting the Lua reference alone leaves
|
||||
-- VRAM allocated until LOVE's GC runs, which is too slow when Android loops
|
||||
-- Play → launcher → Play in one process.
|
||||
local function releaseCanvas(canvas)
|
||||
if canvas and canvas.release then pcall(canvas.release, canvas) end
|
||||
end
|
||||
|
||||
function Renderer:releaseCanvases()
|
||||
releaseCanvas(self.canvas); self.canvas = nil
|
||||
releaseCanvas(self.battleHUDCanvas); self.battleHUDCanvas = nil
|
||||
releaseCanvas(self.worldCanvas); self.worldCanvas = nil
|
||||
releaseCanvas(self.uprightCanvas); self.uprightCanvas = nil
|
||||
self.worldActive = false
|
||||
self.uprightActive = false
|
||||
self.worldOverride = nil
|
||||
end
|
||||
|
||||
function Renderer:init()
|
||||
-- 160x144 real pixels, never DPI-scaled: see src/render/PixelCanvas.lua
|
||||
-- (#208). Every canvas below is sized in framebuffer pixels for the same
|
||||
-- reason -- worldViewSize() already works in drawable pixels.
|
||||
-- Release any prior session's surfaces before reallocating (in-process
|
||||
-- return-to-launcher reuses this Renderer singleton).
|
||||
self:releaseCanvases()
|
||||
self.uiWidth, self.uiHeight = self.WIDTH, self.HEIGHT
|
||||
self.canvas = PixelCanvas.new(self.uiWidth, self.uiHeight, "nearest")
|
||||
self.battleHUDCanvas = nil
|
||||
|
||||
@@ -951,6 +951,13 @@ function TileRenderer.invalidate()
|
||||
frameImages = {}
|
||||
toggleImages = {}
|
||||
stripData = {}
|
||||
-- RED++ per-map atlases are large Images; clear and release so an
|
||||
-- in-process Play → launcher → Play loop does not keep every prior
|
||||
-- session's bake in VRAM / Lua heap.
|
||||
for key, img in pairs(gbcAtlasCache) do
|
||||
safeRelease(img)
|
||||
gbcAtlasCache[key] = nil
|
||||
end
|
||||
end
|
||||
|
||||
Assets.register(TileRenderer.invalidate)
|
||||
|
||||
@@ -85,7 +85,9 @@ function Transition.new(game, onMidpoint, onDone, warp)
|
||||
end
|
||||
|
||||
function Transition:finish()
|
||||
self.game.stack:pop()
|
||||
if self.done then return end
|
||||
self.done = true
|
||||
if not self.offStack then self.game.stack:pop() end
|
||||
if self.onDone then self.onDone() end
|
||||
end
|
||||
|
||||
@@ -96,10 +98,16 @@ function Transition:update(dt)
|
||||
self.t = 0
|
||||
if self.phase == "out" then
|
||||
self.phase = "in"
|
||||
if self.onMidpoint then self.onMidpoint() end
|
||||
-- LoadGBPal restores the palettes in one write, so with no fade in the
|
||||
-- map is simply there on the next frame
|
||||
if (self.framesIn or 0) <= 0 then self:finish() end
|
||||
if (self.framesIn or 0) <= 0 then
|
||||
self.offStack = true
|
||||
self.game.stack:pop()
|
||||
if self.onMidpoint then self.onMidpoint() end
|
||||
self:finish()
|
||||
elseif self.onMidpoint then
|
||||
self.onMidpoint()
|
||||
end
|
||||
else
|
||||
self:finish()
|
||||
end
|
||||
|
||||
+536
-142
@@ -34,10 +34,12 @@ local DEFAULT_ART = {
|
||||
cableBall = "assets/generated/trade/cable_ball.png",
|
||||
cableBallAlt = "assets/generated/trade/cable_ball_alt.png",
|
||||
bubble = "assets/generated/trade/bubble.png",
|
||||
moveAnim0 = "assets/generated/battle/anims/move_anim_0.png",
|
||||
balls = "assets/generated/battle/balls.png",
|
||||
}
|
||||
|
||||
local function tryImage(path)
|
||||
if not path then return nil end
|
||||
if not (path and love and love.graphics and love.graphics.newImage) then return nil end
|
||||
local ok, img = pcall(love.graphics.newImage, path)
|
||||
return ok and img or nil
|
||||
end
|
||||
@@ -89,6 +91,92 @@ local SEQ = {
|
||||
"done",
|
||||
}
|
||||
|
||||
-- FrameBlocks 03..08 definition for move_anim_0.png (128x40, 16 tiles/row, 8x8 per tile)
|
||||
local FRAME_BLOCKS = {
|
||||
-- FrameBlock03: Pokeball normal (16x16)
|
||||
[3] = {
|
||||
{ tile = 2, dx = 0, dy = 0, xflip = false, yflip = false },
|
||||
{ tile = 2, dx = 8, dy = 0, xflip = true, yflip = false },
|
||||
{ tile = 18, dx = 0, dy = 8, xflip = false, yflip = false },
|
||||
{ tile = 18, dx = 8, dy = 8, xflip = true, yflip = false },
|
||||
},
|
||||
-- FrameBlock04: Pokeball tilted left (16x16)
|
||||
[4] = {
|
||||
{ tile = 6, dx = 0, dy = 0, xflip = false, yflip = false },
|
||||
{ tile = 7, dx = 8, dy = 0, xflip = false, yflip = false },
|
||||
{ tile = 22, dx = 0, dy = 8, xflip = false, yflip = false },
|
||||
{ tile = 23, dx = 8, dy = 8, xflip = false, yflip = false },
|
||||
},
|
||||
-- FrameBlock05: Pokeball tilted right (16x16)
|
||||
[5] = {
|
||||
{ tile = 7, dx = 0, dy = 0, xflip = true, yflip = false },
|
||||
{ tile = 6, dx = 8, dy = 0, xflip = true, yflip = false },
|
||||
{ tile = 23, dx = 0, dy = 8, xflip = true, yflip = false },
|
||||
{ tile = 22, dx = 8, dy = 8, xflip = true, yflip = false },
|
||||
},
|
||||
-- FrameBlock06: Small Poof smoke cloud (32x32)
|
||||
[6] = {
|
||||
{ tile = 0x23, dx = 8, dy = 0, xflip = false, yflip = false },
|
||||
{ tile = 0x32, dx = 0, dy = 8, xflip = false, yflip = false },
|
||||
{ tile = 0x33, dx = 8, dy = 8, xflip = false, yflip = false },
|
||||
{ tile = 0x23, dx = 16, dy = 0, xflip = true, yflip = false },
|
||||
{ tile = 0x33, dx = 16, dy = 8, xflip = true, yflip = false },
|
||||
{ tile = 0x32, dx = 24, dy = 8, xflip = true, yflip = false },
|
||||
{ tile = 0x32, dx = 0, dy = 16, xflip = false, yflip = true },
|
||||
{ tile = 0x33, dx = 8, dy = 16, xflip = false, yflip = true },
|
||||
{ tile = 0x23, dx = 8, dy = 24, xflip = false, yflip = true },
|
||||
{ tile = 0x33, dx = 16, dy = 16, xflip = true, yflip = true },
|
||||
{ tile = 0x32, dx = 24, dy = 16, xflip = true, yflip = true },
|
||||
{ tile = 0x23, dx = 16, dy = 24, xflip = true, yflip = true },
|
||||
},
|
||||
-- FrameBlock07: Medium Poof smoke cloud (32x32)
|
||||
[7] = {
|
||||
{ tile = 0x20, dx = 0, dy = 0, xflip = false, yflip = false },
|
||||
{ tile = 0x21, dx = 8, dy = 0, xflip = false, yflip = false },
|
||||
{ tile = 0x30, dx = 0, dy = 8, xflip = false, yflip = false },
|
||||
{ tile = 0x31, dx = 8, dy = 8, xflip = false, yflip = false },
|
||||
{ tile = 0x21, dx = 16, dy = 0, xflip = true, yflip = false },
|
||||
{ tile = 0x20, dx = 24, dy = 0, xflip = true, yflip = false },
|
||||
{ tile = 0x31, dx = 16, dy = 8, xflip = true, yflip = false },
|
||||
{ tile = 0x30, dx = 24, dy = 8, xflip = true, yflip = false },
|
||||
{ tile = 0x30, dx = 0, dy = 16, xflip = false, yflip = true },
|
||||
{ tile = 0x31, dx = 8, dy = 16, xflip = false, yflip = true },
|
||||
{ tile = 0x20, dx = 0, dy = 24, xflip = false, yflip = true },
|
||||
{ tile = 0x21, dx = 8, dy = 24, xflip = false, yflip = true },
|
||||
{ tile = 0x31, dx = 16, dy = 16, xflip = true, yflip = true },
|
||||
{ tile = 0x30, dx = 24, dy = 16, xflip = true, yflip = true },
|
||||
{ tile = 0x21, dx = 16, dy = 24, xflip = true, yflip = true },
|
||||
{ tile = 0x20, dx = 24, dy = 24, xflip = true, yflip = true },
|
||||
},
|
||||
-- FrameBlock08: Large Poof smoke cloud (32x32)
|
||||
[8] = {
|
||||
{ tile = 0x22, dx = 0, dy = 0, xflip = false, yflip = false },
|
||||
{ tile = 0x23, dx = 8, dy = 0, xflip = false, yflip = false },
|
||||
{ tile = 0x32, dx = 0, dy = 8, xflip = false, yflip = false },
|
||||
{ tile = 0x33, dx = 8, dy = 8, xflip = false, yflip = false },
|
||||
{ tile = 0x23, dx = 16, dy = 0, xflip = true, yflip = false },
|
||||
{ tile = 0x22, dx = 24, dy = 0, xflip = true, yflip = false },
|
||||
{ tile = 0x33, dx = 16, dy = 8, xflip = true, yflip = false },
|
||||
{ tile = 0x32, dx = 24, dy = 8, xflip = true, yflip = false },
|
||||
{ tile = 0x32, dx = 0, dy = 16, xflip = false, yflip = true },
|
||||
{ tile = 0x33, dx = 8, dy = 16, xflip = false, yflip = true },
|
||||
{ tile = 0x22, dx = 0, dy = 24, xflip = false, yflip = true },
|
||||
{ tile = 0x23, dx = 8, dy = 24, xflip = false, yflip = true },
|
||||
{ tile = 0x33, dx = 16, dy = 16, xflip = true, yflip = true },
|
||||
{ tile = 0x32, dx = 24, dy = 16, xflip = true, yflip = true },
|
||||
{ tile = 0x23, dx = 16, dy = 24, xflip = true, yflip = true },
|
||||
{ tile = 0x22, dx = 24, dy = 24, xflip = true, yflip = true },
|
||||
},
|
||||
}
|
||||
|
||||
-- BallMoveDistances1 (engine/battle/animations.asm:881)
|
||||
-- Pokéball jumps up into the open link cable nozzle: -12, -12, -8 (3 frames each)
|
||||
local BALL_SUCTION_DISTANCES = { -12, -12, -8 }
|
||||
|
||||
-- BallMoveDistances2 (engine/battle/animations.asm:922)
|
||||
-- Incoming Pokéball bounces out of nozzle onto the ground: 11, 12, -12, -7, 7, 12, -8, 8 (5 frames each)
|
||||
local BALL_BOUNCE_DISTANCES = { 11, 12, -12, -7, 7, 12, -8, 8 }
|
||||
|
||||
function TradeAnim.new(game, opts)
|
||||
opts = opts or {}
|
||||
local self = setmetatable({}, TradeAnim)
|
||||
@@ -120,6 +208,8 @@ function TradeAnim.new(game, opts)
|
||||
cableBall = tryImage(art.cableBall or DEFAULT_ART.cableBall),
|
||||
cableBallAlt = tryImage(art.cableBallAlt or DEFAULT_ART.cableBallAlt),
|
||||
bubble = tryImage(art.bubble or DEFAULT_ART.bubble),
|
||||
moveAnim0 = tryImage(DEFAULT_ART.moveAnim0),
|
||||
balls = tryImage(DEFAULT_ART.balls),
|
||||
}
|
||||
self.sentSprite, self.sentSpriteTrueColor = spriteOf(game, self.sent)
|
||||
self.recvSprite, self.recvSpriteTrueColor = spriteOf(game, self.received)
|
||||
@@ -136,6 +226,14 @@ function TradeAnim.new(game, opts)
|
||||
self.monVisible = true
|
||||
self.waitingText = false
|
||||
self.cableFlash = false
|
||||
self.activeBallBlock = nil
|
||||
self.activeBallX = 0
|
||||
self.activeBallY = 0
|
||||
self.activePoofBlock = nil
|
||||
self.activePoofX = 0
|
||||
self.activePoofY = 0
|
||||
self.wx = 0
|
||||
self.slidingBox = false
|
||||
return self
|
||||
end
|
||||
|
||||
@@ -151,52 +249,100 @@ function TradeAnim:advance()
|
||||
self.sub = nil
|
||||
self.flash = false
|
||||
self.cableFlash = false
|
||||
self.poof = nil
|
||||
self.activeBallBlock = nil
|
||||
self.activePoofBlock = nil
|
||||
self.slidingBox = false
|
||||
self.wx = 0
|
||||
|
||||
if self.phase == "done" then
|
||||
self.game.stack:pop()
|
||||
if self.onDone then self.onDone() end
|
||||
elseif self.phase == "show_enemy" then
|
||||
self.monVisible = false
|
||||
self.activeBallBlock = 4
|
||||
self.activeBallX, self.activeBallY = 72, 40
|
||||
self.activePoofBlock = nil
|
||||
self.cableSlideOut = 0
|
||||
elseif self.phase == "transfer_lr" then
|
||||
-- wBaseCoord $54, $1c OAM -> screen (76, 12)
|
||||
self.monX, self.monY = 76, 12
|
||||
-- BaseCoord $54, $1c -> Screen (92, 28) for 16x16 icon (center 100, 36)
|
||||
self.monX, self.monY = 92, 28
|
||||
self.scx = 0
|
||||
elseif self.phase == "transfer_rl" then
|
||||
-- wBaseCoord $64, $44 OAM -> screen (92, 52), right GB on screen
|
||||
self.monX, self.monY = 92, 52
|
||||
self.scx = 160
|
||||
-- BaseCoord $64, $44 -> Screen (108, 68) for 16x16 icon (center 116, 76)
|
||||
self.monX, self.monY = 108, 68
|
||||
self.scx = 256
|
||||
elseif self.phase == "ball_enter" then
|
||||
-- lb bc, $20, $60: b = Y, c = X (Trade_AnimateBallEnteringLinkCable)
|
||||
-- Pokéball sits at BaseCoord $48 (Screen 72, 64) before shake and suction
|
||||
self.activeBallBlock = 3
|
||||
self.activeBallX, self.activeBallY = 72, 64
|
||||
self.ballX, self.ballY = 0x60, 0x20
|
||||
elseif self.phase == "open_cable" or self.phase == "open_cable2" then
|
||||
-- SCX $a0 -> $f0: cable slides in from the right, open end rests at x64
|
||||
self.scx = 0x50
|
||||
-- SCX $a0 (160) -> $f0 (240): cable slides in from right, open end rests at x64
|
||||
self.scx = 80
|
||||
if self.phase == "open_cable" then
|
||||
self.activeBallBlock = 3
|
||||
self.activeBallX, self.activeBallY = 72, 64
|
||||
end
|
||||
Sound.play(self.game.data, "Heal_HP")
|
||||
end
|
||||
end
|
||||
|
||||
function TradeAnim:say(text, delay, thenFn)
|
||||
self.waitingText = true
|
||||
self.game.stack:push(TextBox.new(self.game, text, function()
|
||||
self.waitingText = false
|
||||
if thenFn then thenFn() else self:advance() end
|
||||
end, { auto = { delay = delay or 80 } }))
|
||||
function TradeAnim:skipHeld()
|
||||
return self.game.input:wasPressed("a") or self.game.input:wasPressed("b")
|
||||
end
|
||||
|
||||
function TradeAnim:skipHeld()
|
||||
return self.game.input:wasPressed("a") or self.game.input:isDown("a")
|
||||
function TradeAnim:drawFrameBlock(blockId, x, y)
|
||||
local block = FRAME_BLOCKS[blockId]
|
||||
if not block then return end
|
||||
if self.img.moveAnim0 then
|
||||
local iw, ih = self.img.moveAnim0:getDimensions()
|
||||
for _, tile in ipairs(block) do
|
||||
local tx = (tile.tile % 16) * 8
|
||||
local ty = math.floor(tile.tile / 16) * 8
|
||||
local quad = love.graphics.newQuad(tx, ty, 8, 8, iw, ih)
|
||||
local sx = tile.xflip and -1 or 1
|
||||
local sy = tile.yflip and -1 or 1
|
||||
local ox = tile.xflip and 8 or 0
|
||||
local oy = tile.yflip and 8 or 0
|
||||
love.graphics.draw(self.img.moveAnim0, quad, x + tile.dx + ox, y + tile.dy + oy, 0, sx, sy)
|
||||
end
|
||||
else
|
||||
if blockId == 3 or blockId == 4 or blockId == 5 then
|
||||
love.graphics.setColor(0.9, 0.2, 0.2, 1)
|
||||
love.graphics.arc("fill", x + 8, y + 8, 7, math.pi, 0)
|
||||
love.graphics.setColor(0.9, 0.9, 0.9, 1)
|
||||
love.graphics.arc("fill", x + 8, y + 8, 7, 0, math.pi)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
love.graphics.circle("line", x + 8, y + 8, 7)
|
||||
love.graphics.line(x + 1, y + 8, x + 15, y + 8)
|
||||
love.graphics.circle("fill", x + 8, y + 8, 2)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
elseif blockId == 6 or blockId == 7 or blockId == 8 then
|
||||
local r = (blockId - 5) * 5
|
||||
love.graphics.setColor(0.8, 0.8, 0.8, 0.8)
|
||||
love.graphics.circle("fill", x + 16, y + 16, r)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function TradeAnim:update(dt)
|
||||
if self.waitingText or self.phase == "done" then return end
|
||||
local skip = self.game.input:wasPressed("a")
|
||||
local skip = self:skipHeld()
|
||||
self.t = self.t + 1
|
||||
local p = self.phase
|
||||
|
||||
if p == "show_player" then
|
||||
-- slide from SCX $86 -> 0, hold 80, poof, cry (Trade_ShowPlayerMon)
|
||||
-- Trade_ShowPlayerMon:
|
||||
-- 1. Slide from SCX $7e (126) -> 0 at 2px/frame (63 frames)
|
||||
-- 2. Hold 80 frames
|
||||
-- 3. TRADE_BALL_POOF_ANIM at BaseCoord $72 (Screen 64, 48): 3 frames x 6 ticks = 18 ticks
|
||||
-- 4. TRADE_BALL_DROP_ANIM: mon vanishes, ball drops $41 (72, 56) -> $48 (72, 64) over 36 ticks
|
||||
-- 5. PlayCry while ball sits on ground at (72, 64)
|
||||
if not self.sub then self.sub = "slide" end
|
||||
|
||||
if self.sub == "slide" then
|
||||
self.scx = math.max(0, 0x86 - self.t * 2)
|
||||
self.scx = math.max(0, 126 - self.t * 2)
|
||||
if skip or self.scx <= 0 then
|
||||
self.scx = 0
|
||||
self.sub = "hold"
|
||||
@@ -206,52 +352,131 @@ function TradeAnim:update(dt)
|
||||
if skip or self.t >= 80 then
|
||||
self.sub = "poof"
|
||||
self.t = 0
|
||||
self.monVisible = false
|
||||
Sound.play(self.game.data, "Ball_Poof")
|
||||
end
|
||||
elseif self.sub == "poof" then
|
||||
self.poof = math.max(0, 16 - self.t)
|
||||
if skip or self.t >= 16 then
|
||||
local step = math.floor(self.t / 6)
|
||||
if step >= 3 or skip then
|
||||
self.sub = "ball_drop"
|
||||
self.t = 0
|
||||
self.activePoofBlock = nil
|
||||
self.monVisible = false
|
||||
else
|
||||
self.activePoofBlock = 6 + step
|
||||
self.activePoofX, self.activePoofY = 64, 48
|
||||
end
|
||||
elseif self.sub == "ball_drop" then
|
||||
if skip or self.t >= 36 then
|
||||
self.sub = "cry"
|
||||
self.t = 0
|
||||
self.activeBallBlock = 3
|
||||
self.activeBallX, self.activeBallY = 72, 64
|
||||
Sound.playCry(self.game.data, self.sent.species)
|
||||
else
|
||||
local step = math.floor(self.t / 6)
|
||||
if step == 0 then
|
||||
self.activeBallBlock = 3
|
||||
self.activeBallX, self.activeBallY = 72, 56
|
||||
else
|
||||
local blocks = { 3, 4, 3, 5, 3 }
|
||||
self.activeBallBlock = blocks[step] or 3
|
||||
self.activeBallX, self.activeBallY = 72, 64
|
||||
end
|
||||
end
|
||||
elseif self.sub == "cry" then
|
||||
if skip or self.t >= 40 then
|
||||
self.sub = nil
|
||||
self:advance()
|
||||
end
|
||||
end
|
||||
|
||||
elseif p == "open_cable" or p == "open_cable2" then
|
||||
-- 20 steps of 4px, matching the SCX loop
|
||||
self.scx = math.max(0, 0x50 - self.t * 4)
|
||||
-- Trade_DrawOpenEndOfLinkCable: SCX $a0 (160) -> $f0 (240) in 20 steps of 4px
|
||||
self.scx = math.max(0, 80 - self.t * 4)
|
||||
if skip or self.scx <= 0 then
|
||||
self.scx = 0
|
||||
self:advance()
|
||||
end
|
||||
|
||||
elseif p == "ball_enter" then
|
||||
-- TRADE_BALL_SHAKE then ball rides the cable; X from $60 toward $a0
|
||||
if self.t < 20 then
|
||||
if skip then self.t = 20 end
|
||||
return
|
||||
-- Trade_AnimateBallEnteringLinkCable:
|
||||
-- 1. TRADE_BALL_SHAKE_ANIM (16 ticks) + TradeShakePokeball suction jump (-12, -12, -8 over 9 ticks):
|
||||
-- - Shakes at (72, 64) for 16 ticks (FrameBlock 04, 03, 05, 03)
|
||||
-- - Jumps up: Y=52 (3 ticks), Y=40 (3 ticks), Y=32 (3 ticks)
|
||||
-- - Ball cleared, SFX_TRADE_MACHINE plays
|
||||
-- 2. DelayFrames 10 (chills for 10 frames)
|
||||
-- 3. Ball inside horizontal cable: 16 steps of +4px every 3 frames (48 frames) with Tink sfx & bulge toggle
|
||||
-- 4. Delay3
|
||||
if not self.sub then self.sub = "shake" end
|
||||
|
||||
if self.sub == "shake" then
|
||||
if skip or self.t >= 16 then
|
||||
self.sub = "jump_up"
|
||||
self.t = 0
|
||||
else
|
||||
local shakeBlocks = { 4, 3, 5, 3 }
|
||||
local step = math.floor(self.t / 4) + 1
|
||||
self.activeBallBlock = shakeBlocks[step] or 3
|
||||
self.activeBallX, self.activeBallY = 72, 64
|
||||
end
|
||||
elseif self.sub == "jump_up" then
|
||||
local step = math.floor(self.t / 3) + 1
|
||||
if step > #BALL_SUCTION_DISTANCES or skip then
|
||||
self.sub = "chill"
|
||||
self.t = 0
|
||||
self.activeBallBlock = nil
|
||||
Sound.play(self.game.data, "Trade_Machine")
|
||||
else
|
||||
local yPos = 64
|
||||
for i = 1, step do
|
||||
yPos = yPos + BALL_SUCTION_DISTANCES[i]
|
||||
end
|
||||
self.activeBallBlock = 3
|
||||
self.activeBallX, self.activeBallY = 72, yPos
|
||||
end
|
||||
elseif self.sub == "chill" then
|
||||
if skip or self.t >= 10 then
|
||||
self.sub = "suction"
|
||||
self.t = 0
|
||||
self.ballX = 0x60
|
||||
end
|
||||
elseif self.sub == "suction" then
|
||||
local step = math.floor(self.t / 3)
|
||||
if step >= 16 or skip then
|
||||
self.sub = "exit_pause"
|
||||
self.t = 0
|
||||
self.ballX = 0xA0
|
||||
else
|
||||
self.ballX = 0x60 + step * 4
|
||||
self.flash = (step % 2 == 1)
|
||||
if self.t % 3 == 0 and step < 16 then
|
||||
Sound.play(self.game.data, "Tink")
|
||||
end
|
||||
end
|
||||
elseif self.sub == "exit_pause" then
|
||||
if skip or self.t >= 3 then
|
||||
self.sub = nil
|
||||
self:advance()
|
||||
end
|
||||
end
|
||||
local step = self.t - 20
|
||||
if step % 3 == 0 then
|
||||
self.ballX = 0x60 + math.floor(step / 3) * 4
|
||||
self.flash = not self.flash
|
||||
if self.ballX < 0xA0 then Sound.play(self.game.data, "Tink") end
|
||||
end
|
||||
if skip then self.ballX = 0xA0 end
|
||||
if self.ballX >= 0xA0 then self:advance() end
|
||||
|
||||
elseif p == "transfer_lr" then
|
||||
-- scroll left GB off while the mon rides the cable, then the sprite
|
||||
-- itself moves right and down into the right GB (Trade_AnimMonMoveVertical)
|
||||
if self.t <= 80 then
|
||||
self.scx = math.min(160, self.t * 2)
|
||||
self.monX, self.monY = 76, 12
|
||||
elseif self.t <= 80 + 32 then
|
||||
self.monX = 76 + math.floor((self.t - 80) / 8) * 4
|
||||
elseif self.t <= 80 + 64 then
|
||||
self.monX = 92
|
||||
self.monY = 12 + math.floor((self.t - 112) / 8) * 10
|
||||
-- 16 units of 16px (256px total scroll) at 2px/frame = 128 frames (SCX 0 -> 256)
|
||||
if self.t <= 128 then
|
||||
self.scx = math.min(256, self.t * 2)
|
||||
self.monX, self.monY = 92, 28
|
||||
elseif self.t <= 128 + 32 then
|
||||
local step = math.floor((self.t - 128) / 8) + 1
|
||||
step = math.min(4, step)
|
||||
self.scx = 256
|
||||
self.monX = 92 + step * 4
|
||||
self.monY = 28
|
||||
elseif self.t <= 128 + 64 then
|
||||
local step = math.floor((self.t - 160) / 8) + 1
|
||||
step = math.min(4, step)
|
||||
self.scx = 256
|
||||
self.monX = 108
|
||||
self.monY = 28 + step * 10
|
||||
else
|
||||
self:advance()
|
||||
return
|
||||
@@ -260,17 +485,22 @@ function TradeAnim:update(dt)
|
||||
if skip then self:advance() end
|
||||
|
||||
elseif p == "transfer_rl" then
|
||||
-- mirror: sprite climbs out of the right GB, then the screen scrolls
|
||||
-- back to the left GB
|
||||
-- 64 frames vertical ascent + 128 frames horizontal scroll (SCX 256 -> 0)
|
||||
if self.t <= 32 then
|
||||
self.scx = 160
|
||||
self.monX, self.monY = 92, 52 - math.floor(self.t / 8) * 10
|
||||
local step = math.floor(self.t / 8) + 1
|
||||
step = math.min(4, step)
|
||||
self.scx = 256
|
||||
self.monX = 108
|
||||
self.monY = 68 - step * 10
|
||||
elseif self.t <= 64 then
|
||||
self.monX = 92 - math.floor((self.t - 32) / 8) * 4
|
||||
self.monY = 12
|
||||
elseif self.t <= 64 + 80 then
|
||||
self.scx = math.max(0, 160 - (self.t - 64) * 2)
|
||||
self.monX, self.monY = 76, 12
|
||||
local step = math.floor((self.t - 32) / 8) + 1
|
||||
step = math.min(4, step)
|
||||
self.scx = 256
|
||||
self.monX = 108 - step * 4
|
||||
self.monY = 28
|
||||
elseif self.t <= 64 + 128 then
|
||||
self.scx = math.max(0, 256 - (self.t - 64) * 2)
|
||||
self.monX, self.monY = 92, 28
|
||||
else
|
||||
self:advance()
|
||||
return
|
||||
@@ -282,54 +512,185 @@ function TradeAnim:update(dt)
|
||||
if skip or self.t >= 100 then self:advance() end
|
||||
|
||||
elseif p == "went_to" then
|
||||
local text = expand(self.game, "_TradeWentToText", {
|
||||
["RAM:wStringBuffer"] = speciesName(self.game, self.sent),
|
||||
["RAM:wLinkEnemyTrainerName"] = self.enemyName,
|
||||
})
|
||||
self:say(text, 200)
|
||||
if not self.sub then
|
||||
self.sub = "text"
|
||||
self.t = 0
|
||||
local text = expand(self.game, "_TradeWentToText", {
|
||||
["RAM:wStringBuffer"] = speciesName(self.game, self.sent),
|
||||
["RAM:wLinkEnemyTrainerName"] = self.enemyName,
|
||||
})
|
||||
self.dialogText = text
|
||||
end
|
||||
|
||||
if self.sub == "text" then
|
||||
if skip or self.t >= 200 then
|
||||
self.sub = "slide_hold"
|
||||
self.t = 0
|
||||
end
|
||||
elseif self.sub == "slide_hold" then
|
||||
if skip or self.t >= 50 then
|
||||
self.sub = "slide_off"
|
||||
self.t = 0
|
||||
self.slidingBox = true
|
||||
end
|
||||
elseif self.sub == "slide_off" then
|
||||
self.wx = math.min(160, self.t * 2)
|
||||
if skip or self.wx >= 160 then
|
||||
self.wx = 160
|
||||
self.sub = "slide_end_pause"
|
||||
self.t = 0
|
||||
end
|
||||
elseif self.sub == "slide_end_pause" then
|
||||
if skip or self.t >= 10 then
|
||||
self.slidingBox = false
|
||||
self.dialogText = nil
|
||||
self.sub = nil
|
||||
self:advance()
|
||||
end
|
||||
end
|
||||
|
||||
elseif p == "for_sends" then
|
||||
local a = expand(self.game, "_TradeForText", {
|
||||
["RAM:wStringBuffer"] = speciesName(self.game, self.sent),
|
||||
})
|
||||
local b = expand(self.game, "_TradeSendsText", {
|
||||
["RAM:wLinkEnemyTrainerName"] = self.enemyName,
|
||||
["RAM:wNameBuffer"] = nameOf(self.game, self.received),
|
||||
})
|
||||
self.waitingText = true
|
||||
self.game.stack:push(TextBox.new(self.game, a, function()
|
||||
self.game.stack:push(TextBox.new(self.game, b, function()
|
||||
self.waitingText = false
|
||||
if not self.sub then
|
||||
self.sub = "for_text"
|
||||
self.t = 0
|
||||
self.dialogText = expand(self.game, "_TradeForText", {
|
||||
["RAM:wStringBuffer"] = speciesName(self.game, self.sent),
|
||||
})
|
||||
end
|
||||
|
||||
if self.sub == "for_text" then
|
||||
if skip or self.t >= 80 then
|
||||
self.sub = "sends_text"
|
||||
self.t = 0
|
||||
self.dialogText = expand(self.game, "_TradeSendsText", {
|
||||
["RAM:wLinkEnemyTrainerName"] = self.enemyName,
|
||||
["RAM:wNameBuffer"] = nameOf(self.game, self.received),
|
||||
})
|
||||
end
|
||||
elseif self.sub == "sends_text" then
|
||||
if skip or self.t >= 80 then
|
||||
self.dialogText = nil
|
||||
self.sub = nil
|
||||
self:advance()
|
||||
end, { auto = { delay = 80 } }))
|
||||
end, { auto = { delay = 80 } }))
|
||||
end
|
||||
end
|
||||
|
||||
elseif p == "farewell" then
|
||||
local a = expand(self.game, "_TradeWavesFarewellText", {
|
||||
["RAM:wLinkEnemyTrainerName"] = self.enemyName,
|
||||
})
|
||||
local b = expand(self.game, "_TradeTransferredText", {
|
||||
["RAM:wNameBuffer"] = nameOf(self.game, self.received),
|
||||
})
|
||||
self.waitingText = true
|
||||
self.game.stack:push(TextBox.new(self.game, a, function()
|
||||
self.game.stack:push(TextBox.new(self.game, b, function()
|
||||
self.waitingText = false
|
||||
if not self.sub then
|
||||
self.sub = "farewell_text"
|
||||
self.t = 0
|
||||
self.dialogText = expand(self.game, "_TradeWavesFarewellText", {
|
||||
["RAM:wLinkEnemyTrainerName"] = self.enemyName,
|
||||
})
|
||||
end
|
||||
|
||||
if self.sub == "farewell_text" then
|
||||
if skip or self.t >= 80 then
|
||||
self.sub = "transferred_text"
|
||||
self.t = 0
|
||||
self.dialogText = expand(self.game, "_TradeTransferredText", {
|
||||
["RAM:wNameBuffer"] = nameOf(self.game, self.received),
|
||||
})
|
||||
end
|
||||
elseif self.sub == "transferred_text" then
|
||||
if skip or self.t >= 80 then
|
||||
self.sub = "slide_hold"
|
||||
self.t = 0
|
||||
end
|
||||
elseif self.sub == "slide_hold" then
|
||||
if skip or self.t >= 50 then
|
||||
self.sub = "slide_off"
|
||||
self.t = 0
|
||||
self.slidingBox = true
|
||||
end
|
||||
elseif self.sub == "slide_off" then
|
||||
self.wx = math.min(160, self.t * 2)
|
||||
if skip or self.wx >= 160 then
|
||||
self.wx = 160
|
||||
self.sub = "slide_end_pause"
|
||||
self.t = 0
|
||||
end
|
||||
elseif self.sub == "slide_end_pause" then
|
||||
if skip or self.t >= 10 then
|
||||
self.slidingBox = false
|
||||
self.dialogText = nil
|
||||
self.sub = nil
|
||||
self:advance()
|
||||
end, { auto = { delay = 80 } }))
|
||||
end, { auto = { delay = 80 } }))
|
||||
end
|
||||
end
|
||||
|
||||
elseif p == "show_enemy" then
|
||||
if self.t == 1 then
|
||||
Sound.play(self.game.data, "Ball_Poof")
|
||||
self.monVisible = true
|
||||
elseif self.t == 20 then
|
||||
Sound.playCry(self.game.data, self.received.species)
|
||||
elseif self.t >= 120 or skip then
|
||||
local text = expand(self.game, "_TradeTakeCareText", {
|
||||
["RAM:wNameBuffer"] = nameOf(self.game, self.received),
|
||||
})
|
||||
self:say(text, 80)
|
||||
-- Trade_ShowEnemyMon (engine/movie/trade.asm:354):
|
||||
-- 1. TRADE_BALL_TILT_ANIM with TradeJumpPokeball (8 bounce steps, 5 ticks each = 40 ticks):
|
||||
-- - Starts at BaseCoord $84 (Screen X=72, Y=40)
|
||||
-- - Moves Y by BallMoveDistances2 (+11, +12, -12, -7, +7, +12, -8, +8)
|
||||
-- - On each step: Cable slides off-screen right by 8px
|
||||
-- - Plays SFX_SWAP on impacts
|
||||
-- 2. ClearScreen, Info box at (4, 10), Mon front sprite at (7, 2)
|
||||
-- 3. TRADE_BALL_POOF_ANIM: FrameBlock 06, 07, 08 (18 ticks), SFX_BALL_POOF, revealing mon
|
||||
-- 4. PlayCry
|
||||
-- 5. Trade_Delay100
|
||||
-- 6. Info box cleared, PrintTradeTakeCareText (80 ticks)
|
||||
-- 7. Trade_Delay100 -> finish
|
||||
if not self.sub then
|
||||
self.sub = "ball_bounce"
|
||||
self.t = 0
|
||||
self.cableSlideOut = 0
|
||||
self.activeBallBlock = 4
|
||||
self.activeBallX, self.activeBallY = 72, 40
|
||||
end
|
||||
|
||||
if self.sub == "ball_bounce" then
|
||||
local step = math.floor(self.t / 5) + 1
|
||||
if step > #BALL_BOUNCE_DISTANCES or skip then
|
||||
self.sub = "poof"
|
||||
self.t = 0
|
||||
self.activeBallBlock = nil
|
||||
self.monVisible = true
|
||||
self.cableSlideOut = nil
|
||||
Sound.play(self.game.data, "Ball_Poof")
|
||||
else
|
||||
local yPos = 40
|
||||
for i = 1, step do
|
||||
yPos = yPos + BALL_BOUNCE_DISTANCES[i]
|
||||
end
|
||||
self.activeBallBlock = 4
|
||||
self.activeBallX, self.activeBallY = 72, yPos
|
||||
self.cableSlideOut = (step - 1) * 8
|
||||
if self.t % 5 == 0 and (BALL_BOUNCE_DISTANCES[step] == 12 or step == #BALL_BOUNCE_DISTANCES) then
|
||||
Sound.play(self.game.data, "Swap")
|
||||
end
|
||||
end
|
||||
elseif self.sub == "poof" then
|
||||
local step = math.floor(self.t / 6)
|
||||
if step >= 3 or skip then
|
||||
self.sub = "cry"
|
||||
self.t = 0
|
||||
self.activePoofBlock = nil
|
||||
Sound.playCry(self.game.data, self.received.species)
|
||||
else
|
||||
self.activePoofBlock = 6 + step
|
||||
self.activePoofX, self.activePoofY = 64, 48
|
||||
end
|
||||
elseif self.sub == "cry" then
|
||||
if skip or self.t >= 100 then
|
||||
self.sub = "take_care"
|
||||
self.t = 0
|
||||
self.dialogText = expand(self.game, "_TradeTakeCareText", {
|
||||
["RAM:wNameBuffer"] = nameOf(self.game, self.received),
|
||||
})
|
||||
end
|
||||
elseif self.sub == "take_care" then
|
||||
if skip or self.t >= 80 then
|
||||
self.sub = "delay_end"
|
||||
self.t = 0
|
||||
self.dialogText = nil
|
||||
end
|
||||
elseif self.sub == "delay_end" then
|
||||
if skip or self.t >= 100 then
|
||||
self.sub = nil
|
||||
self:advance()
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -337,10 +698,18 @@ end
|
||||
local function drawCableHoriz(self, y, x0, x1)
|
||||
local w = math.max(0, x1 - x0)
|
||||
if w <= 0 then return end
|
||||
if self.cableFlash then
|
||||
love.graphics.setColor(0.65, 0.65, 0.65, 1)
|
||||
else
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
if self.img.cableHoriz then
|
||||
local iw, ih = self.img.cableHoriz:getDimensions()
|
||||
local quad = love.graphics.newQuad(0, 0, math.min(w, iw), ih, iw, ih)
|
||||
love.graphics.draw(self.img.cableHoriz, quad, x0, y)
|
||||
for x = x0, x1 - 1, iw do
|
||||
local drawW = math.min(iw, x1 - x)
|
||||
local quad = love.graphics.newQuad(0, 0, drawW, ih, iw, ih)
|
||||
love.graphics.draw(self.img.cableHoriz, quad, x, y)
|
||||
end
|
||||
elseif self.img.cableSeg then
|
||||
for x = x0, x1 - 8, 8 do
|
||||
love.graphics.draw(self.img.cableSeg, x, y)
|
||||
@@ -348,13 +717,11 @@ local function drawCableHoriz(self, y, x0, x1)
|
||||
else
|
||||
love.graphics.setColor(0.2, 0.2, 0.2, 1)
|
||||
love.graphics.rectangle("fill", x0, y + 1, w, 6)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
function TradeAnim:drawMonInfo(mon, ot, otId, boxTy)
|
||||
-- Trade_PrintPlayerMonInfoText: rows +0/+2/+4/+6 from the box top; the
|
||||
-- No. line replaces part of the top border (hlcoord 5, 0 in trade2.asm)
|
||||
Font.drawBox(4, boxTy, 12, 8)
|
||||
local y0 = boxTy * 8
|
||||
local no = ("No.%03d"):format(dexOf(self.game, mon))
|
||||
@@ -368,16 +735,6 @@ function TradeAnim:drawMonInfo(mon, ot, otId, boxTy)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
-- Trade_WriteCircledMonOAM: the mon crosses the cable as its party-menu
|
||||
-- sprite (wMonPartySpriteSpecies -> WriteMonPartySpriteOAMBySpecies), not as
|
||||
-- its battle pic, and Trade_AnimCircledMon flips both it and the ring to
|
||||
-- their second frame every step. The ring is four OAM blocks --
|
||||
-- Trade_CircleOAMBlocks .OAMBlock0-3 at (8,8) (24,8) (8,24) (24,24) with the
|
||||
-- X/Y flips -- so the 16x32 bubble sheet holds one quadrant per frame and the
|
||||
-- circle it makes is 32x32 around the 16x16 icon. The icon rides OAM
|
||||
-- block 0 and the circle blocks 1-4 (Trade_WriteCircleOAMBlock counts a up
|
||||
-- from 1), and the lower OAM index wins overlap on DMG, so the icon draws
|
||||
-- on top of the circle's filled interior. #750
|
||||
function TradeAnim:drawIconInBubble(mon, x, y)
|
||||
if self.img.bubble then
|
||||
if not self.bubbleQuad then
|
||||
@@ -415,11 +772,14 @@ function TradeAnim:drawGameBoy(x, y)
|
||||
end
|
||||
|
||||
function TradeAnim:drawLeftGB()
|
||||
-- cable from GB to the right edge
|
||||
if self.cableFlash then
|
||||
love.graphics.setColor(0.65, 0.65, 0.65, 1)
|
||||
end
|
||||
if self.img.cableConn then
|
||||
love.graphics.draw(self.img.cableConn, 88, 32)
|
||||
end
|
||||
drawCableHoriz(self, 32, 96, 160)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
drawCableHoriz(self, 32, 96, 256)
|
||||
self:drawGameBoy(40, 24)
|
||||
Font.drawBox(4, 12, 9, 4)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
@@ -429,6 +789,9 @@ end
|
||||
|
||||
function TradeAnim:drawRightGB()
|
||||
drawCableHoriz(self, 32, 0, 112)
|
||||
if self.cableFlash then
|
||||
love.graphics.setColor(0.65, 0.65, 0.65, 1)
|
||||
end
|
||||
if self.img.cableCorner then love.graphics.draw(self.img.cableCorner, 112, 32) end
|
||||
if self.img.cableVert then
|
||||
for i = 1, 4 do
|
||||
@@ -437,6 +800,7 @@ function TradeAnim:drawRightGB()
|
||||
end
|
||||
if self.img.cableEnd then love.graphics.draw(self.img.cableEnd, 112, 72) end
|
||||
if self.img.cableConn then love.graphics.draw(self.img.cableConn, 104, 72) end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
self:drawGameBoy(56, 64)
|
||||
Font.drawBox(6, 0, 9, 4)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
@@ -444,6 +808,24 @@ function TradeAnim:drawRightGB()
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
function TradeAnim:drawDialog()
|
||||
if not self.dialogText then return end
|
||||
love.graphics.push()
|
||||
if self.slidingBox then
|
||||
love.graphics.translate(self.wx, 0)
|
||||
end
|
||||
Font.drawBox(0, 12, 20, 6)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
local lines = {}
|
||||
for line in self.dialogText:gmatch("[^\n]+") do
|
||||
lines[#lines + 1] = line
|
||||
end
|
||||
if lines[1] then Font.draw(lines[1], 8, 112) end
|
||||
if lines[2] then Font.draw(lines[2], 8, 128) end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.pop()
|
||||
end
|
||||
|
||||
function TradeAnim:draw()
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
||||
@@ -452,10 +834,7 @@ function TradeAnim:draw()
|
||||
if p == "show_player" then
|
||||
love.graphics.push()
|
||||
love.graphics.translate(-self.scx, 0)
|
||||
-- mon pic on the BG at hlcoord 7, 2; info box on the window at
|
||||
-- hWY $50, so it sits in the bottom half of the screen
|
||||
if self.monVisible and self.sentSprite then
|
||||
-- engine/movie/trade.asm:751
|
||||
love.graphics.draw(self.sentSprite, 56 + self.sentSprite:getWidth(), 16, 0, -1, 1)
|
||||
if self.sentSpriteTrueColor then
|
||||
require("src.render.PaletteFX").markTrueColor(
|
||||
@@ -464,64 +843,79 @@ function TradeAnim:draw()
|
||||
end
|
||||
self:drawMonInfo(self.sent, self.playerOt, self.playerOtId, 10)
|
||||
love.graphics.pop()
|
||||
if self.poof and self.poof > 0 then
|
||||
love.graphics.setColor(0, 0, 0, self.poof / 16)
|
||||
love.graphics.circle("fill", 80, 40, 20 - (self.poof or 0))
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
|
||||
if self.activePoofBlock then
|
||||
self:drawFrameBlock(self.activePoofBlock, self.activePoofX, self.activePoofY)
|
||||
end
|
||||
if self.activeBallBlock then
|
||||
self:drawFrameBlock(self.activeBallBlock, self.activeBallX, self.activeBallY)
|
||||
end
|
||||
|
||||
elseif p == "open_cable" or p == "open_cable2" then
|
||||
love.graphics.push()
|
||||
love.graphics.translate(self.scx, 0)
|
||||
if self.img.openCable then
|
||||
love.graphics.draw(self.img.openCable, 64, 16) -- open end at SCX $f0
|
||||
love.graphics.draw(self.img.openCable, 64, 16)
|
||||
end
|
||||
love.graphics.pop()
|
||||
if p == "open_cable" and self.activeBallBlock then
|
||||
self:drawFrameBlock(self.activeBallBlock, self.activeBallX, self.activeBallY)
|
||||
end
|
||||
|
||||
elseif p == "ball_enter" then
|
||||
if self.img.openCable then
|
||||
love.graphics.draw(self.img.openCable, 64, 16)
|
||||
end
|
||||
-- OAM coords carry a (+8, +16) hardware offset
|
||||
local ball = self.flash and (self.img.cableBallAlt or self.img.cableBall)
|
||||
or self.img.cableBall
|
||||
if ball then
|
||||
love.graphics.draw(ball, self.ballX - 8, self.ballY - 16)
|
||||
else
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
love.graphics.circle("fill", self.ballX, self.ballY - 8, 6)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
if self.activeBallBlock then
|
||||
self:drawFrameBlock(self.activeBallBlock, self.activeBallX, self.activeBallY)
|
||||
elseif self.sub == "suction" or self.sub == "exit_pause" then
|
||||
local ball = self.flash and (self.img.cableBallAlt or self.img.cableBall)
|
||||
or self.img.cableBall
|
||||
if ball then
|
||||
love.graphics.draw(ball, self.ballX - 8, self.ballY - 16)
|
||||
else
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
love.graphics.circle("fill", self.ballX, self.ballY - 8, 6)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
end
|
||||
|
||||
elseif p == "transfer_lr" or p == "transfer_rl" then
|
||||
-- two-screen world: left GB scene at x0, right GB scene at x160;
|
||||
-- the mon icon stays in screen space like the OAM sprite it ports
|
||||
love.graphics.push()
|
||||
love.graphics.translate(-self.scx, 0)
|
||||
self:drawLeftGB()
|
||||
love.graphics.translate(160, 0)
|
||||
love.graphics.translate(256, 0)
|
||||
self:drawRightGB()
|
||||
love.graphics.pop()
|
||||
local mon = p == "transfer_lr" and self.sent or self.received
|
||||
self:drawIconInBubble(mon, self.monX, self.monY)
|
||||
if self.cableFlash then
|
||||
love.graphics.setColor(1, 1, 1, 0.15)
|
||||
love.graphics.rectangle("fill", 0, 32, 160, 8)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
elseif p == "show_enemy" then
|
||||
if self.cableSlideOut and self.img.openCable then
|
||||
love.graphics.push()
|
||||
love.graphics.translate(self.cableSlideOut, 0)
|
||||
love.graphics.draw(self.img.openCable, 64, 16)
|
||||
love.graphics.pop()
|
||||
end
|
||||
if self.monVisible and self.recvSprite then
|
||||
-- engine/movie/trade.asm:751
|
||||
love.graphics.draw(self.recvSprite, 56 + self.recvSprite:getWidth(), 16, 0, -1, 1)
|
||||
if self.recvSpriteTrueColor then
|
||||
require("src.render.PaletteFX").markTrueColor(
|
||||
56, 16, self.recvSprite:getDimensions())
|
||||
end
|
||||
end
|
||||
self:drawMonInfo(self.received, self.enemyName, self.enemyOtId, 10)
|
||||
if self.monVisible and self.sub ~= "take_care" and self.sub ~= "delay_end" then
|
||||
self:drawMonInfo(self.received, self.enemyName, self.enemyOtId, 10)
|
||||
end
|
||||
if self.activeBallBlock then
|
||||
self:drawFrameBlock(self.activeBallBlock, self.activeBallX, self.activeBallY)
|
||||
end
|
||||
if self.activePoofBlock then
|
||||
self:drawFrameBlock(self.activePoofBlock, self.activePoofX, self.activePoofY)
|
||||
end
|
||||
end
|
||||
-- went_to / for_sends / farewell / delay: cleared window; TextBox draws
|
||||
|
||||
self:drawDialog()
|
||||
end
|
||||
|
||||
return TradeAnim
|
||||
|
||||
@@ -2707,7 +2707,9 @@ function BattleState:pushCaught(enemy, itemId)
|
||||
-- so the contest branch is BELOW them (item_effects.asm:528-546), and
|
||||
-- CheckReceivedDex gates the pair (:532-533).
|
||||
if not knew and self:hasPokedex() then
|
||||
-- data/text/common_3.asm:285
|
||||
self:push({ kind = "message",
|
||||
sfx = "Sfx_SlotMachineStart", waitSfx = true,
|
||||
text = self:name(enemy) .. "'s data was newly added to the #DEX." })
|
||||
self:push({ kind = "dex-entry", species = enemy.species })
|
||||
end
|
||||
|
||||
+122
-12
@@ -47,6 +47,7 @@ local Font = require("src.render.Font")
|
||||
local GbcPalette = require("src.render.GbcPalette")
|
||||
local Mail = require("src.core.gen2.Mail")
|
||||
local Palettes = require("src.world.gen2.Palettes")
|
||||
local PartyMenu = require("src.ui.gen2.PartyMenu")
|
||||
local Screens = require("src.ui.Screens")
|
||||
local Sound = require("src.core.Sound")
|
||||
local Unown = require("src.core.gen2.Unown")
|
||||
@@ -65,6 +66,21 @@ local PIC_X, PIC_Y = 1, 4
|
||||
-- 7-size blank tiles per column (engine/gfx/load_pics.asm:342-386).
|
||||
local PIC_PAD = { [7] = { 0, 0 }, [6] = { 1, 1 }, [5] = { 1, 2 } }
|
||||
|
||||
-- gfx/pc/orange.pal
|
||||
local BILLS_PC_ORANGE = {
|
||||
{ 255, 123, 0 }, { 189, 99, 0 }, { 123, 58, 0 }, { 0, 0, 0 },
|
||||
}
|
||||
|
||||
-- PCMonInfo prints the held-item icon at hlcoord 7, 12
|
||||
-- (engine/pokemon/bills_pc.asm:1093).
|
||||
local ICON_X, ICON_Y = 7, 12
|
||||
|
||||
-- $5f at hlcoord 8, 1 and $5e at hlcoord 19, 1, off a PCMailGFX sheet that
|
||||
-- starts at $5c (engine/pokemon/bills_pc.asm:957-963).
|
||||
local ARROW_ROW = 1
|
||||
local ARROW_LEFT = { 3, 8 }
|
||||
local ARROW_RIGHT = { 2, 19 }
|
||||
|
||||
-- wBillsPC_LoadedBox: 0 is the PARTY, 1..NUM_BOXES are the boxes. Only the
|
||||
-- MOVE screen ever loads box 0; the withdraw and deposit lists are one list
|
||||
-- each (BillsPC_BoxName reads the same byte for all three).
|
||||
@@ -272,6 +288,8 @@ function BoxMenu:beginMove()
|
||||
-- to the submenu; here the message holds until a button clears it and the
|
||||
-- list comes back, which is the same place the player ends up.
|
||||
self.phase = nil
|
||||
-- engine/pokemon/bills_pc.asm:1607
|
||||
self:playSfx("Sfx_Wrong")
|
||||
self.message = reason
|
||||
return
|
||||
end
|
||||
@@ -299,9 +317,13 @@ end
|
||||
function BoxMenu:doWithdraw()
|
||||
local ok, result = Boxes.withdraw(self.save, self.boxIndex, self.index)
|
||||
if not ok then
|
||||
-- engine/pokemon/bills_pc.asm:1845
|
||||
self:playSfx("Sfx_Wrong")
|
||||
self.message = result
|
||||
return
|
||||
end
|
||||
-- engine/pokemon/bills_pc.asm:1817
|
||||
self:playMonCry(result)
|
||||
self.message = nil
|
||||
self.phase = nil
|
||||
self:clampIndex()
|
||||
@@ -311,9 +333,13 @@ end
|
||||
function BoxMenu:doDeposit()
|
||||
local ok, result = Boxes.deposit(self.save, self.index, self.boxIndex)
|
||||
if not ok then
|
||||
-- engine/pokemon/bills_pc.asm:1790
|
||||
self:playSfx("Sfx_Wrong")
|
||||
self.message = result
|
||||
return
|
||||
end
|
||||
-- engine/pokemon/bills_pc.asm:1762
|
||||
self:playMonCry(result)
|
||||
self.message = nil
|
||||
self.phase = nil
|
||||
self.index, self.scroll = 1, 0
|
||||
@@ -486,6 +512,8 @@ function BoxMenu:update(_dt)
|
||||
if not ok then
|
||||
-- .no_space: `dec [hl]` puts the jumptable back on .PrepInsertCursor,
|
||||
-- so the refusal leaves the cursor exactly where it was.
|
||||
-- engine/pokemon/bills_pc.asm:1567
|
||||
self:playSfx("Sfx_Wrong")
|
||||
self.message = reason
|
||||
else
|
||||
self:insertMon()
|
||||
@@ -528,6 +556,14 @@ function BoxMenu:playSfx(name)
|
||||
if sfx and sfx[Sound.resolve(data, name)] then Sound.play(data, name) end
|
||||
end
|
||||
|
||||
-- PlayMonCry: `call GetCryIndex / jr c, .done` (home/pokemon.asm:113-114)
|
||||
function BoxMenu:playMonCry(mon)
|
||||
local data = self.game and self.game.data
|
||||
if not (data and mon and mon.species) or mon.isEgg then return end
|
||||
local cries = data.audio and data.audio.cries
|
||||
if cries and cries[mon.species] then Sound.playCry(data, mon.species) end
|
||||
end
|
||||
|
||||
-- BillsPC's RELEASE, which the model has always supported and nothing on
|
||||
-- screen reached. The cart asks first and starts the prompt on NO, the way
|
||||
-- every irreversible choice in the game does.
|
||||
@@ -541,6 +577,8 @@ function BoxMenu:askRelease()
|
||||
local allowed, refusal = self:checkMailPreventBlackout()
|
||||
if not allowed then
|
||||
self.phase = nil
|
||||
-- engine/pokemon/bills_pc.asm:1607
|
||||
self:playSfx("Sfx_Wrong")
|
||||
self.message = refusal
|
||||
return
|
||||
end
|
||||
@@ -568,6 +606,8 @@ function BoxMenu:askRelease()
|
||||
self.message = err
|
||||
return
|
||||
end
|
||||
-- engine/pokemon/bills_pc.asm:1866
|
||||
self:playMonCry(mon)
|
||||
self.message = name .. " was released."
|
||||
self.phase = nil
|
||||
self:clampIndex()
|
||||
@@ -627,14 +667,31 @@ function BoxMenu:picFor(mon)
|
||||
return self:image(path)
|
||||
end
|
||||
|
||||
-- engine/gfx/cgb_layouts.asm:284-300, engine/pokemon/bills_pc.asm:356-369
|
||||
function BoxMenu:panelColors(speciesId, shiny)
|
||||
if self.phase == "submenu" or self.phase == "insert" then
|
||||
return self.palettes
|
||||
and Palettes.monColors(self.palettes, speciesId, shiny)
|
||||
end
|
||||
local gfx = (self.menuGfx or {}).billsPc
|
||||
return (gfx and gfx.orangePalette) or BILLS_PC_ORANGE
|
||||
end
|
||||
|
||||
-- ClearBox runs before `cp -1 / ret z` (engine/pokemon/bills_pc.asm:1009-1021)
|
||||
function BoxMenu:fillPicBlock(colors)
|
||||
local G = love.graphics
|
||||
local blank = colors and GbcPalette.color(colors, 1) or { 255, 255, 255 }
|
||||
G.setColor(blank[1] / 255, blank[2] / 255, blank[3] / 255, 1)
|
||||
G.rectangle("fill", PIC_X * 8, PIC_Y * 8, 7 * 8, 7 * 8)
|
||||
G.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
-- PCMonInfo lays the padded pic as one 7x7 block at hlcoord 1, 4
|
||||
-- (engine/pokemon/bills_pc.asm:1023-1042), the pad tiles at the palette's 0.
|
||||
function BoxMenu:drawPicBlock(image, colors)
|
||||
if not image then return end
|
||||
local G = love.graphics
|
||||
local blank = colors and GbcPalette.color(colors, 1) or { 255, 255, 255 }
|
||||
G.setColor(blank[1] / 255, blank[2] / 255, blank[3] / 255, 1)
|
||||
G.rectangle("fill", PIC_X * 8, PIC_Y * 8, 7 * 8, 7 * 8)
|
||||
self:fillPicBlock(colors)
|
||||
|
||||
local pad = PIC_PAD[math.floor(image:getWidth() / 8)] or PIC_PAD[7]
|
||||
G.setColor(1, 1, 1, 1)
|
||||
@@ -650,12 +707,12 @@ function BoxMenu:drawPicBlock(image, colors)
|
||||
end
|
||||
|
||||
function BoxMenu:drawPic(mon)
|
||||
local image = self:picFor(mon)
|
||||
if not image then return end
|
||||
-- _CGB_BillsPC hands wTempMonDVs to GetPlayerOrMonPalettePointer, so the box
|
||||
-- pic takes the shiny row (engine/gfx/cgb_layouts.asm:292-293).
|
||||
local colors = self.palettes
|
||||
and Palettes.monColors(self.palettes, mon.species, mon.shiny)
|
||||
local colors = self:panelColors(mon.species, mon.shiny)
|
||||
local image = self:picFor(mon)
|
||||
-- engine/pokemon/bills_pc.asm:1009-1011
|
||||
if not image then return self:fillPicBlock(colors) end
|
||||
self:drawPicBlock(image, colors)
|
||||
end
|
||||
|
||||
@@ -664,17 +721,14 @@ end
|
||||
-- with the party list's ICON_EGG standing in for a cache built before that.
|
||||
function BoxMenu:drawEggPic(mon)
|
||||
local G = love.graphics
|
||||
local colors = self.palettes
|
||||
and Palettes.monColors(self.palettes, "EGG", mon and mon.shiny)
|
||||
local colors = self:panelColors("EGG", mon and mon.shiny)
|
||||
local gfx = (self.menuGfx or {}).eggHatch
|
||||
local image = self:image(gfx and gfx.egg)
|
||||
if image then return self:drawPicBlock(image, colors) end
|
||||
self:fillPicBlock(colors)
|
||||
local entry = self.icons and self.icons.icons and self.icons.icons.ICON_EGG
|
||||
image = self:image(entry and entry.image)
|
||||
if not image then return end
|
||||
local blank = colors and GbcPalette.color(colors, 1) or { 255, 255, 255 }
|
||||
G.setColor(blank[1] / 255, blank[2] / 255, blank[3] / 255, 1)
|
||||
G.rectangle("fill", PIC_X * 8, PIC_Y * 8, 7 * 8, 7 * 8)
|
||||
-- The ICON_EGG sheet stacks its frames; the first is the egg at rest.
|
||||
local w = entry.width or 16
|
||||
local h = math.min(entry.height or 16, image:getHeight())
|
||||
@@ -694,6 +748,58 @@ function BoxMenu:drawEggPic(mon)
|
||||
G.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
-- ItemIsMail picks $5c over $5d at hlcoord 7, 12
|
||||
-- (engine/pokemon/bills_pc.asm:1079-1094)
|
||||
function BoxMenu:drawHeldIcon(mon)
|
||||
local row = PartyMenu.heldMarkerRow(mon)
|
||||
if not row then return end
|
||||
local gfx = (self.menuGfx or {}).billsPc
|
||||
local image = self:image(gfx and gfx.icons)
|
||||
if not image then return end
|
||||
local ok, quad = pcall(love.graphics.newQuad, row * 8, 0, 8, 8,
|
||||
image:getDimensions())
|
||||
if not ok then return end
|
||||
local G = love.graphics
|
||||
G.setColor(1, 1, 1, 1)
|
||||
local function body() G.draw(image, quad, ICON_X * 8, ICON_Y * 8) end
|
||||
local colors = gfx and gfx.palette
|
||||
if colors and GbcPalette.available() then
|
||||
GbcPalette.with(colors, body)
|
||||
else
|
||||
body()
|
||||
end
|
||||
G.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
-- _MovePKMNWithoutMail only (engine/pokemon/bills_pc.asm:545, :698)
|
||||
function BoxMenu:drawBoxArrows()
|
||||
if self.mode ~= "move" then return end
|
||||
local gfx = (self.menuGfx or {}).billsPc
|
||||
local image = self:image(gfx and gfx.icons)
|
||||
if not image then return end
|
||||
local G = love.graphics
|
||||
local quads = {}
|
||||
for _, arrow in ipairs({ ARROW_LEFT, ARROW_RIGHT }) do
|
||||
local ok, quad = pcall(love.graphics.newQuad, arrow[1] * 8, 0, 8, 8,
|
||||
image:getDimensions())
|
||||
if not ok then return end
|
||||
quads[#quads + 1] = { quad, arrow[2] }
|
||||
end
|
||||
G.setColor(1, 1, 1, 1)
|
||||
local function body()
|
||||
for _, entry in ipairs(quads) do
|
||||
G.draw(image, entry[1], entry[2] * 8, ARROW_ROW * 8)
|
||||
end
|
||||
end
|
||||
local colors = gfx and gfx.palette
|
||||
if colors and GbcPalette.available() then
|
||||
GbcPalette.with(colors, body)
|
||||
else
|
||||
body()
|
||||
end
|
||||
G.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
-- The PC does not mark the selected row with a ▶: BillsPC_UpdateSelectionCursor
|
||||
-- lays 20 OBJs as a frame *around* the row -- ten tiles wide by two tall, top
|
||||
-- left at pixel (71, 25), stepping 16 pixels per row. Those cursor tiles are
|
||||
@@ -740,6 +846,7 @@ function BoxMenu:drawPanel()
|
||||
-- Textbox at (8,0) with a 10x1 interior and the name at (10,1).
|
||||
Chrome.box(8, 0, 12, 3)
|
||||
Chrome.print(self:title(), 10, 1)
|
||||
self:drawBoxArrows()
|
||||
Chrome.box(8, 2, 12, 12)
|
||||
-- BillsPC_RefreshTextboxes overwrites its own top corners with '└'/'┘'
|
||||
-- (engine/pokemon/bills_pc.asm:1204-1211) so the list reads as hanging
|
||||
@@ -793,7 +900,10 @@ function BoxMenu:drawPanel()
|
||||
Chrome.print("\xe2\x99\x80", 5, 12)
|
||||
end
|
||||
Chrome.print(mon.name or mon.species or "?", PIC_X, 14)
|
||||
self:drawHeldIcon(mon)
|
||||
end
|
||||
else
|
||||
self:fillPicBlock(self:panelColors())
|
||||
end
|
||||
|
||||
-- BillsPC_PlaceString: Textbox at (0,15) with a one-row interior, string at
|
||||
|
||||
@@ -320,12 +320,15 @@ function ItemPcMenu:leaveDeposit()
|
||||
end
|
||||
|
||||
function ItemPcMenu:offerToDeposit(id, count)
|
||||
-- .TryDepositItem's `.no_toss` arm is a bare ret: a KEY ITEM or HM stays in
|
||||
-- the bag with no message at all.
|
||||
if self:cantToss(id) then return end
|
||||
if (count or 0) < 1 then return end
|
||||
local def = self:def(id)
|
||||
local name = (def and def.name) or id
|
||||
-- .DepositItem (engine/events/pokecenter_pc.asm:504): an item with no
|
||||
-- quantity is always x1 and never reaches .AskQuantity.
|
||||
if self:cantToss(id) then
|
||||
self:deposit(id, name, 1)
|
||||
return
|
||||
end
|
||||
self:askQuantity(count,
|
||||
{ "How many do you", "want to deposit?" },
|
||||
function(qty) self:deposit(id, name, qty) end)
|
||||
@@ -518,9 +521,10 @@ function ItemPcMenu:drawList()
|
||||
local entry = self.rows[i]
|
||||
if i == self.listIndex then Chrome.cursor(5, ty) end
|
||||
Chrome.print(entry.name, 6, ty)
|
||||
-- PlaceMenuItemQuantity (engine/menus/menu_2.asm:24): the xNN is the
|
||||
-- entry's second line, right-aligned in a blank-padded 2-digit field.
|
||||
Chrome.print(TIMES .. Chrome.number(entry.count, 2), 7, ty + 1)
|
||||
-- PlaceMenuItemQuantity (engine/menus/menu_2.asm:18, :24)
|
||||
if not self:cantToss(entry.id) then
|
||||
Chrome.print(TIMES .. Chrome.number(entry.count, 2), 7, ty + 1)
|
||||
end
|
||||
elseif i == self:listTotal() then
|
||||
if i == self.listIndex then Chrome.cursor(5, ty) end
|
||||
Chrome.print("CANCEL", 6, ty)
|
||||
|
||||
+155
-36
@@ -22,12 +22,14 @@
|
||||
-- PAL_BG_YELLOW, out of a TOWN palette set at the current time of day
|
||||
-- (the routine pushes wEnvironment, forces TOWN, and pops it back).
|
||||
--
|
||||
-- Two hardware details are not reproduced. The four player OBJs all carry
|
||||
-- OAM_PRIO, so on the cart they sit behind background colours 1-3 and only
|
||||
-- show through the window's colour 0; here they are drawn straight over the
|
||||
-- background, which looks the same everywhere the window is transparent and
|
||||
-- differs only if the sprite drifts over solid train tiles. And the ride
|
||||
-- cannot be skipped, exactly as on the cart: the loop reads no input at all.
|
||||
-- Two hardware details from the cart are faithfully reproduced:
|
||||
-- 1. Viewport wrapping: the 256x144 background scrolls in 3 LY override bands,
|
||||
-- wrapped strictly within the 160x144 GB screen so pixels do not bleed into
|
||||
-- the widescreen surround.
|
||||
-- 2. OAM Priority (OAM_PRIO): the four player OBJs sit behind background
|
||||
-- colours 1-3 and only show through the window's colour 0. This is drawn
|
||||
-- via a base background pass, the player sprite, and an overlay pass where
|
||||
-- shade 0 is transparent and solid train tiles mask the sprite.
|
||||
|
||||
local Assets = require("src.render.Assets")
|
||||
local Chrome = require("src.ui.gen2.Chrome")
|
||||
@@ -46,6 +48,30 @@ local BG_W = 256 -- TILEMAP_WIDTH * 8
|
||||
local TILES_PER_ROW = 16 -- every 2bpp sheet the importer writes
|
||||
local BLACK = { 0, 0, 0 }
|
||||
|
||||
-- Overlay shader for OAM_PRIO: shade 0 is transparent; shades 1-3 are solid
|
||||
-- palette colors, masking any sprites beneath solid background tiles.
|
||||
local OVERLAY_SHADER_SOURCE = [[
|
||||
extern vec3 pal0;
|
||||
extern vec3 pal1;
|
||||
extern vec3 pal2;
|
||||
extern vec3 pal3;
|
||||
|
||||
vec4 effect(vec4 tint, Image tex, vec2 uv, vec2 screen) {
|
||||
vec4 px = Texel(tex, uv);
|
||||
float shade = floor((1.0 - px.r) * 3.0 + 0.5);
|
||||
vec3 rgb = pal0;
|
||||
if (shade > 2.5) {
|
||||
rgb = pal3;
|
||||
} else if (shade > 1.5) {
|
||||
rgb = pal2;
|
||||
} else if (shade > 0.5) {
|
||||
rgb = pal1;
|
||||
}
|
||||
float alpha = shade < 0.5 ? 0.0 : px.a;
|
||||
return vec4(rgb, alpha) * tint;
|
||||
}
|
||||
]]
|
||||
|
||||
function MagnetTrainRide:wantsFillScale() return true end
|
||||
|
||||
-- opts: toGoldenrod (the wScriptVar the officer's script set), onDone()
|
||||
@@ -97,7 +123,7 @@ function MagnetTrainRide:palette(slot)
|
||||
end
|
||||
|
||||
--------------------------------------------------------------------------
|
||||
-- Sheets
|
||||
-- Sheets & Shaders
|
||||
--------------------------------------------------------------------------
|
||||
|
||||
local function sheetFor(path)
|
||||
@@ -159,22 +185,66 @@ function MagnetTrainRide:playerQuad(vtile)
|
||||
return quad
|
||||
end
|
||||
|
||||
function MagnetTrainRide:overlayShader()
|
||||
if self.ovShader ~= nil then return self.ovShader or nil end
|
||||
if not (love and love.graphics and love.graphics.newShader) then
|
||||
self.ovShader = false
|
||||
return nil
|
||||
end
|
||||
local ok, result = pcall(love.graphics.newShader, OVERLAY_SHADER_SOURCE)
|
||||
self.ovShader = ok and result or false
|
||||
return self.ovShader or nil
|
||||
end
|
||||
|
||||
local function palChannel(colors, index)
|
||||
local c = colors and colors[index]
|
||||
if not c then return 0, 0, 0 end
|
||||
return (c[1] or 0) / 255, (c[2] or 0) / 255, (c[3] or 0) / 255
|
||||
end
|
||||
|
||||
function MagnetTrainRide:useOverlayShader(colors)
|
||||
local sh = self:overlayShader()
|
||||
if not sh then return false end
|
||||
local resolved = GbcPalette.resolve(colors)
|
||||
for i = 0, 3 do
|
||||
local u = "pal" .. i
|
||||
if sh:hasUniform(u) then
|
||||
local r, g, b = palChannel(resolved, i + 1)
|
||||
sh:send(u, { r, g, b })
|
||||
end
|
||||
end
|
||||
love.graphics.setShader(sh)
|
||||
return true
|
||||
end
|
||||
|
||||
|
||||
--------------------------------------------------------------------------
|
||||
-- The baked background
|
||||
-- The baked backgrounds (Base + OAM_PRIO Overlay)
|
||||
--------------------------------------------------------------------------
|
||||
|
||||
-- DrawMagnetTrain plus SetMagnetTrainPals, rendered once into a 256x144
|
||||
-- canvas. Tiles are drawn palette group by palette group so the three
|
||||
-- palettes cost three shader switches rather than one per tile.
|
||||
function MagnetTrainRide:background()
|
||||
if self.bgCanvas ~= nil then return self.bgCanvas or nil end
|
||||
-- DrawMagnetTrain plus SetMagnetTrainPals, rendered once into 256x144 canvases.
|
||||
-- Base canvas contains the full opaque background; Overlay canvas contains
|
||||
-- solid shades 1-3 with shade 0 transparent for OAM_PRIO window masking.
|
||||
function MagnetTrainRide:backgrounds()
|
||||
if self.bgCanvas ~= nil then
|
||||
return self.bgCanvas or nil, self.bgOverlayCanvas or nil
|
||||
end
|
||||
self.bgCanvas = false
|
||||
self.bgOverlayCanvas = false
|
||||
local rows = self.ride:tilemap()
|
||||
local sheet = self:tileSheet()
|
||||
if not (rows and sheet) then return nil end
|
||||
local ok, canvas = pcall(love.graphics.newCanvas, BG_W, SCREEN_H)
|
||||
if not ok then return nil end
|
||||
canvas:setFilter("nearest", "nearest")
|
||||
if not (rows and sheet) then return nil, nil end
|
||||
|
||||
local ok1, baseCanvas = pcall(love.graphics.newCanvas, BG_W, SCREEN_H)
|
||||
if not ok1 then return nil, nil end
|
||||
baseCanvas:setFilter("nearest", "nearest")
|
||||
|
||||
local ok2, overlayCanvas = pcall(love.graphics.newCanvas, BG_W, SCREEN_H)
|
||||
if ok2 and overlayCanvas then
|
||||
overlayCanvas:setFilter("nearest", "nearest")
|
||||
else
|
||||
overlayCanvas = nil
|
||||
end
|
||||
|
||||
local groups = {}
|
||||
for row = 1, #rows do
|
||||
@@ -188,11 +258,12 @@ function MagnetTrainRide:background()
|
||||
|
||||
local G = love.graphics
|
||||
local previous = G.getCanvas()
|
||||
-- A canvas does not reset the transform: without this the map lands under
|
||||
-- the renderer's letterbox scale and off the edge.
|
||||
G.push()
|
||||
G.push("all")
|
||||
G.origin()
|
||||
G.setCanvas(canvas)
|
||||
G.setScissor()
|
||||
|
||||
-- 1. Base background canvas (shades 0-3)
|
||||
G.setCanvas(baseCanvas)
|
||||
G.clear(0, 0, 0, 1)
|
||||
G.setColor(1, 1, 1, 1)
|
||||
local shader = GbcPalette.available()
|
||||
@@ -205,10 +276,35 @@ function MagnetTrainRide:background()
|
||||
end
|
||||
end
|
||||
if shader then GbcPalette.clear() end
|
||||
|
||||
-- 2. Overlay canvas for OAM_PRIO (shades 1-3 solid, shade 0 transparent)
|
||||
if overlayCanvas then
|
||||
G.setCanvas(overlayCanvas)
|
||||
G.clear(0, 0, 0, 0)
|
||||
G.setColor(1, 1, 1, 1)
|
||||
local ovShader = self:overlayShader()
|
||||
for slot, list in pairs(groups) do
|
||||
local colors = self:palette(slot)
|
||||
if ovShader and colors then self:useOverlayShader(colors) end
|
||||
for _, cell in ipairs(list) do
|
||||
local quad = sheet.quads[cell[1]]
|
||||
if quad then G.draw(sheet.image, quad, cell[2], cell[3]) end
|
||||
end
|
||||
end
|
||||
if ovShader then G.setShader() end
|
||||
end
|
||||
|
||||
G.setCanvas(previous)
|
||||
G.pop()
|
||||
self.bgCanvas = canvas
|
||||
return canvas
|
||||
|
||||
self.bgCanvas = baseCanvas
|
||||
self.bgOverlayCanvas = overlayCanvas
|
||||
return baseCanvas, overlayCanvas
|
||||
end
|
||||
|
||||
function MagnetTrainRide:background()
|
||||
local base = self:backgrounds()
|
||||
return base
|
||||
end
|
||||
|
||||
--------------------------------------------------------------------------
|
||||
@@ -240,19 +336,24 @@ function MagnetTrainRide:update(_dt)
|
||||
end
|
||||
end
|
||||
|
||||
function MagnetTrainRide:drawBackground()
|
||||
local canvas = self:background()
|
||||
-- Draws the 3 LY override bands strictly wrapped inside the 160px GB screen.
|
||||
function MagnetTrainRide:drawBands(canvas)
|
||||
if not canvas then return end
|
||||
local G = love.graphics
|
||||
G.setColor(1, 1, 1, 1)
|
||||
self.bandQuad = self.bandQuad
|
||||
or love.graphics.newQuad(0, 0, BG_W, 1, BG_W, SCREEN_H)
|
||||
or love.graphics.newQuad(0, 0, SCREEN_W, 1, BG_W, SCREEN_H)
|
||||
for _, band in ipairs(self.ride:bands()) do
|
||||
local top, bottom, scx = band[1], band[2], band[3] % BG_W
|
||||
local height = bottom - top + 1
|
||||
self.bandQuad:setViewport(0, top, BG_W, height, BG_W, SCREEN_H)
|
||||
G.draw(canvas, self.bandQuad, -scx, top)
|
||||
G.draw(canvas, self.bandQuad, -scx + BG_W, top)
|
||||
local w1 = math.min(SCREEN_W, BG_W - scx)
|
||||
self.bandQuad:setViewport(scx, top, w1, height, BG_W, SCREEN_H)
|
||||
G.draw(canvas, self.bandQuad, 0, top)
|
||||
if w1 < SCREEN_W then
|
||||
local w2 = SCREEN_W - w1
|
||||
self.bandQuad:setViewport(0, top, w2, height, BG_W, SCREEN_H)
|
||||
G.draw(canvas, self.bandQuad, w1, top)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -277,16 +378,19 @@ function MagnetTrainRide:drawPlayer()
|
||||
if colors then GbcPalette.use(colors) end
|
||||
G.setColor(1, 1, 1, 1)
|
||||
for _, entry in ipairs(oam) do
|
||||
local quad = self:playerQuad(entry.tile)
|
||||
if quad then
|
||||
G.draw(sheet.image, quad,
|
||||
entry.x + (entry.xflip and 8 or 0), entry.y, 0,
|
||||
entry.xflip and -1 or 1, 1)
|
||||
if entry.x >= -8 and entry.x < SCREEN_W and entry.y >= -16 and entry.y < SCREEN_H then
|
||||
local quad = self:playerQuad(entry.tile)
|
||||
if quad then
|
||||
G.draw(sheet.image, quad,
|
||||
entry.x + (entry.xflip and 8 or 0), entry.y, 0,
|
||||
entry.xflip and -1 or 1, 1)
|
||||
end
|
||||
end
|
||||
end
|
||||
if colors then GbcPalette.clear() end
|
||||
end
|
||||
|
||||
|
||||
-- The backdrop is BG colour 0 of the gray palette the train body uses; on the
|
||||
-- cart it is what shows wherever nothing was drawn.
|
||||
function MagnetTrainRide:backdrop()
|
||||
@@ -299,13 +403,24 @@ function MagnetTrainRide:drawPanel()
|
||||
G.setColor(backdrop[1] / 255, backdrop[2] / 255, backdrop[3] / 255, 1)
|
||||
G.rectangle("fill", 0, 0, SCREEN_W, SCREEN_H)
|
||||
G.setColor(1, 1, 1, 1)
|
||||
self:drawBackground()
|
||||
|
||||
local base, overlay = self:backgrounds()
|
||||
-- 1. Base background (bushes, train body, window aperture)
|
||||
self:drawBands(base)
|
||||
-- 2. Player sprite (OAM_PRIO: sits behind train body, visible through window)
|
||||
self:drawPlayer()
|
||||
-- 3. Train body / frame overlay (colors 1-3 mask sprite; color 0 is transparent)
|
||||
self:drawBands(overlay)
|
||||
|
||||
G.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
function MagnetTrainRide:draw()
|
||||
local G = love.graphics
|
||||
G.push("all")
|
||||
G.setScissor(0, 0, SCREEN_W, SCREEN_H)
|
||||
self:drawPanel()
|
||||
G.pop()
|
||||
end
|
||||
|
||||
-- MagnetTrain_LoadGFX_PlayMusic opens on ClearBGPalettes / ClearSprites
|
||||
@@ -323,7 +438,8 @@ function MagnetTrainRide:drawWidescreen(winW, winH)
|
||||
G.setColor(1, 1, 1, 1)
|
||||
local scale = Chrome.fitScale(winW, winH)
|
||||
local ox, oy = Chrome.fitOrigin(winW, winH, scale)
|
||||
G.push()
|
||||
G.push("all")
|
||||
G.setScissor(ox, oy, SCREEN_W * scale, SCREEN_H * scale)
|
||||
G.translate(ox, oy)
|
||||
G.scale(scale, scale)
|
||||
self:drawPanel()
|
||||
@@ -331,3 +447,6 @@ function MagnetTrainRide:drawWidescreen(winW, winH)
|
||||
end
|
||||
|
||||
return MagnetTrainRide
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -16,6 +16,9 @@ local PackGfx = require("src.ui.gen2.PackGfx")
|
||||
local Screens = require("src.ui.Screens")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
-- constants/sfx_constants.asm:3, :28
|
||||
local SFX_DEX_FANFARE_50_79, SFX_WRONG = 0, 25
|
||||
|
||||
local PackMenu = {}
|
||||
PackMenu.__index = PackMenu
|
||||
PackMenu.isOpaque = true
|
||||
@@ -192,6 +195,14 @@ function PackMenu:pocketOf(itemId)
|
||||
return (def and def.pocket) or "ITEM"
|
||||
end
|
||||
|
||||
-- engine/items/tmhm.asm:341
|
||||
function PackMenu:tmhmKey(itemId)
|
||||
local def = self.items and self.items[itemId]
|
||||
local n = def and tonumber(def.tmNumber)
|
||||
if n then return n end
|
||||
return 1000 + ((def and tonumber(def.index)) or 0)
|
||||
end
|
||||
|
||||
-- The name on the row. An inventory key with no ItemAttributes row behind it
|
||||
-- (an older cache, a mod's own item, a driver seeding an id that is not in
|
||||
-- items.lua) still has to draw something a person can read, so the id stands
|
||||
@@ -239,6 +250,14 @@ function PackMenu:rebuild()
|
||||
}
|
||||
end
|
||||
end
|
||||
-- engine/items/tmhm.asm:341
|
||||
if pocket == "TM_HM" then
|
||||
table.sort(rows, function(a, b)
|
||||
local ka, kb = self:tmhmKey(a.id), self:tmhmKey(b.id)
|
||||
if ka ~= kb then return ka < kb end
|
||||
return a.id < b.id
|
||||
end)
|
||||
end
|
||||
self.rows = rows
|
||||
self.index = math.min(self.index, #rows + 1)
|
||||
if self.index < 1 then self.index = 1 end
|
||||
@@ -359,6 +378,9 @@ function PackMenu:useSelected()
|
||||
-- with sound_dex_fanfare_50_79 between them; the PACK's box here holds
|
||||
-- all four rows at once, the way OAK_THIS_ISNT_THE_TIME's three fit.
|
||||
-- World has already set the decoration's flag and taken the box.
|
||||
if world.playSfxNamed then
|
||||
world:playSfxNamed("Sfx_DexFanfare5079", SFX_DEX_FANFARE_50_79)
|
||||
end
|
||||
self.message = { "There was a trophy", "inside!",
|
||||
"{PLAYER} sent the", "trophy home." }
|
||||
self:rebuild()
|
||||
@@ -641,6 +663,11 @@ function PackMenu:openTeachParty(row)
|
||||
if id == moveId then allowed = true end
|
||||
end
|
||||
if not allowed then
|
||||
-- engine/items/tmhm.asm:131
|
||||
local world = game.world
|
||||
if world and world.playSfxNamed then
|
||||
world:playSfxNamed("Sfx_Wrong", SFX_WRONG)
|
||||
end
|
||||
if game.say then
|
||||
game:say(("%s can't learn %s!"):format(
|
||||
require("src.battle.gen2.Mon").displayName(mon), moveName))
|
||||
@@ -740,7 +767,9 @@ function PackMenu:update(_dt)
|
||||
end
|
||||
|
||||
-- engine/items/pack.asm:1290 Pack_InterpretJoypad .select
|
||||
-- engine/items/tmhm.asm:207 -- the TM/HM pocket's joypad filter drops SELECT.
|
||||
function PackMenu:armSwitch()
|
||||
if self:pocket().id == "TM_HM" then return end
|
||||
if self:isCancel() then return end
|
||||
if not self.rows[self.index] then return end
|
||||
self.switching = self.index
|
||||
|
||||
+15
-5
@@ -58,6 +58,13 @@ function Check.fullAssetName(version, osName, arch, port)
|
||||
return fullAssetName(version, osName, arch, port)
|
||||
end
|
||||
|
||||
-- A legacy Android APK can run a newer downloaded payload while still lacking
|
||||
-- the native bridge that installs future APK updates. It needs one manual
|
||||
-- package update even when that payload already reports the latest engine.
|
||||
function Check.androidNeedsInstallerBootstrap(osName, hasInstaller)
|
||||
return osName == "Android" and not hasInstaller
|
||||
end
|
||||
|
||||
local CMD = "update_check_cmd"
|
||||
local STATE = "update_check_state"
|
||||
|
||||
@@ -266,12 +273,15 @@ function Check.fullUpdateAction()
|
||||
local st = Check.state()
|
||||
if st.status ~= "needs_full" and st.status ~= "full_ready" then return nil end
|
||||
local osName = love and love.system and love.system.getOS and love.system.getOS() or ""
|
||||
if osName == "Android" and type(love.system.installApk) == "function"
|
||||
and type(st.full) == "table" and type(st.full.url) == "string" then
|
||||
if st.status == "full_ready" and type(st.full.path) == "string" then
|
||||
return { label = "Install Android update", kind = "install" }
|
||||
if osName == "Android" and type(st.full) == "table"
|
||||
and type(st.full.url) == "string" then
|
||||
if type(love.system.installApk) == "function" then
|
||||
if st.status == "full_ready" and type(st.full.path) == "string" then
|
||||
return { label = "Install Android update", kind = "install" }
|
||||
end
|
||||
return { label = "Download Android update", kind = "download" }
|
||||
end
|
||||
return { label = "Download Android update", kind = "download" }
|
||||
return { label = "Update app manually", url = st.full.url }
|
||||
end
|
||||
if osName == "iOS" then
|
||||
return { label = "Re-sideload app", url =
|
||||
|
||||
@@ -243,6 +243,12 @@ local function doCheck(target)
|
||||
return
|
||||
end
|
||||
|
||||
if Check.androidNeedsInstallerBootstrap(osName,
|
||||
type(love.system.installApk) == "function") then
|
||||
postFullRequirement(rel, "native_installer_missing")
|
||||
return
|
||||
end
|
||||
|
||||
if compareVersions(rel.version, currentEngine) <= 0 then
|
||||
-- We are now running a native shell at least as new as GitHub's latest
|
||||
-- release, so a former minShell/payloadHost prompt no longer applies.
|
||||
|
||||
@@ -150,6 +150,21 @@ local function pooledNPC(pool, data, mapId, obj)
|
||||
end
|
||||
OverworldState.pooledNPC = pooledNPC -- exposed for tests
|
||||
|
||||
local function nearestWalkableCell(map, x, y)
|
||||
for r = 1, 8 do
|
||||
for dy = -r, r do
|
||||
for dx = -r, r do
|
||||
if math.abs(dx) == r or math.abs(dy) == r then
|
||||
local nx, ny = x + dx, y + dy
|
||||
if map:inBounds(nx, ny) and map:isWalkableCell(nx, ny) then
|
||||
return nx, ny
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- connection hops rendered around the current map: two, so
|
||||
-- corner-adjacent maps (connections of connections) don't pop in and
|
||||
-- out of the survey zoom at the seams (constants.world.neighborHops)
|
||||
@@ -414,6 +429,15 @@ function OverworldState:setMap(mapId, x, y, facing, opts)
|
||||
table.insert(self.npcs, npc)
|
||||
end
|
||||
end
|
||||
if opts and opts.via == "boot" and self.map:inBounds(x, y)
|
||||
and not self.map:isWalkableCell(x, y) and not self.map:isWaterCell(x, y) then
|
||||
local rx, ry = nearestWalkableCell(self.map, x, y)
|
||||
if rx then
|
||||
Logger.warn("saved position %s (%d,%d) is not walkable; moved to (%d,%d)",
|
||||
mapId, x, y, rx, ry)
|
||||
x, y = rx, ry
|
||||
end
|
||||
end
|
||||
if self.player then
|
||||
self.player.cellX, self.player.cellY = x, y
|
||||
self.player.px, self.player.py = x * 16, y * 16
|
||||
@@ -4113,7 +4137,7 @@ function OverworldState:checkBadgeGate()
|
||||
Game.stack:push(TextBox.new(Game,
|
||||
(t["_" .. g.failText] or Strings("You don't have the\nBOULDERBADGE yet!"))
|
||||
.. (t._Route22GateGuardICantLetYouPassText or ""), function()
|
||||
self:scriptMove(p, "down", 1)
|
||||
self:scriptMove(p, "down", 1, nil, { collide = true })
|
||||
end))
|
||||
return true
|
||||
end
|
||||
@@ -4141,7 +4165,7 @@ function OverworldState:checkBadgeGate()
|
||||
local text = (t["_" .. g.failText] or
|
||||
Strings("You don't have the\n{RAM} yet!")):gsub("{RAM:wNameBuffer}", badgeName)
|
||||
Game.stack:push(TextBox.new(Game, text, function()
|
||||
self:scriptMove(p, "down", 1)
|
||||
self:scriptMove(p, "down", 1, nil, { collide = true })
|
||||
end))
|
||||
return true
|
||||
end
|
||||
@@ -4181,7 +4205,7 @@ function OverworldState:checkForcedMovement()
|
||||
function()
|
||||
local back = ({ up = "down", down = "up",
|
||||
left = "right", right = "left" })[p.facing]
|
||||
self:scriptMove(p, back, 1)
|
||||
self:scriptMove(p, back, 1, nil, { collide = true })
|
||||
end))
|
||||
return true
|
||||
end
|
||||
@@ -4222,6 +4246,7 @@ function OverworldState:checkSeafoamCurrent()
|
||||
-- push so the B3F stair warps underfoot cannot bounce you back.
|
||||
self.forcedWarp = false
|
||||
require("src.core.Sound").play(Game.data, "Collision")
|
||||
-- home/overworld.asm:1891
|
||||
self:scriptMove(p, "up", c.y == 17 and 2 or 1)
|
||||
return true
|
||||
end
|
||||
@@ -4780,9 +4805,10 @@ end
|
||||
-- scripted movement
|
||||
-- -------------------------------------------------------------------------
|
||||
|
||||
function OverworldState:scriptMove(entity, dir, tiles, onDone)
|
||||
function OverworldState:scriptMove(entity, dir, tiles, onDone, opts)
|
||||
table.insert(self.scriptMoves, {
|
||||
entity = entity, dir = dir, remaining = tiles, onDone = onDone,
|
||||
collide = opts and opts.collide or nil,
|
||||
})
|
||||
end
|
||||
|
||||
@@ -4822,14 +4848,20 @@ function OverworldState:updateScriptMoves()
|
||||
e.moving = true
|
||||
e.marching = true
|
||||
e.progress = 0
|
||||
mv.remaining = mv.remaining - 1
|
||||
elseif mv.collide
|
||||
and not Collision.canMove(self.map, self.entities, e, mv.dir) then
|
||||
-- home/overworld.asm:1224
|
||||
e.facing = mv.dir
|
||||
mv.remaining = 0
|
||||
else
|
||||
e.facing = mv.dir
|
||||
local tx, ty = Collision.target(e.cellX, e.cellY, mv.dir)
|
||||
e.targetX, e.targetY = tx, ty
|
||||
e.moving = true
|
||||
e.progress = 0
|
||||
mv.remaining = mv.remaining - 1
|
||||
end
|
||||
mv.remaining = mv.remaining - 1
|
||||
end
|
||||
end
|
||||
-- march_in_place toggles: re-arm the in-place cycle each time it ends.
|
||||
|
||||
@@ -83,6 +83,7 @@ local SFX = {
|
||||
WARP_TO = 19,
|
||||
EXIT_BUILDING = 35,
|
||||
JUMP_OVER_LEDGE = 0x16,
|
||||
BUMP = 0x24,
|
||||
}
|
||||
local EMOTE_SHOCK = 0
|
||||
|
||||
@@ -2183,6 +2184,13 @@ function World:playSfxNamed(want, fallbackId)
|
||||
self:playSfx(self:sfxIdNamed(want, fallbackId))
|
||||
end
|
||||
|
||||
-- .BumpSound (engine/overworld/player_movement.asm:771), CheckSFX at
|
||||
-- home/audio.asm:477
|
||||
function World:bumpSound()
|
||||
if Sound.sfxBusy() then return end
|
||||
self:playSfxNamed("Sfx_Bump", SFX.BUMP)
|
||||
end
|
||||
|
||||
-- Script_specialsound (engine/overworld/scripting.asm:476) is not a fixed cue:
|
||||
-- it farcalls CheckItemPocket (engine/items/items.asm:512), which writes
|
||||
-- wCurItem's pocket into wItemAttributeValue, and rings SFX.GET_TM for the
|
||||
@@ -9031,6 +9039,9 @@ function World:movePlayer(dir)
|
||||
elseif result == "blocked" or result == "edge" then
|
||||
self.turningDirection = nil
|
||||
end
|
||||
-- .NotMoving and .Ice's own arm (player_movement.asm:102 and :87), which
|
||||
-- .TryJump's carry (:376) returns above.
|
||||
if result == "blocked" then self:bumpSound() end
|
||||
-- NormalStep, in order (engine/overworld/movement.asm:657-674): InitStep has
|
||||
-- already moved OBJECT_TILE_COLLISION onto the destination.
|
||||
if result == "moved" then self:playerStepGrass() end
|
||||
@@ -9738,7 +9749,8 @@ function World:stepBody()
|
||||
|
||||
local result = self:movePlayer(dir)
|
||||
if result == "edge" then
|
||||
self:tryConnection(dir)
|
||||
-- A border block is a wall: engine/overworld/player_movement.asm:264
|
||||
if not self:tryConnection(dir) then self:bumpSound() end
|
||||
elseif result == "blocked" and p.facing == dir then
|
||||
-- .CheckNPC came back 2: something movable is in the way. The step is
|
||||
-- lost either way, and the boulder is what moves.
|
||||
|
||||
@@ -83,15 +83,36 @@ return function(game)
|
||||
-- the announcement-time row performMove inserts directly as well as the
|
||||
-- ones animNext adds, which is the whole difference the fix makes.
|
||||
local function watchAnims(battle)
|
||||
local seen, mark = {}, {}
|
||||
local seen, mark, types = {}, {}, {}
|
||||
return seen, function()
|
||||
for _, row in ipairs(battle.queue) do
|
||||
if row.anim and not mark[row] then
|
||||
mark[row] = true
|
||||
seen[#seen + 1] = row.anim
|
||||
types[row.anim] = row.hit and row.hit.animType or false
|
||||
end
|
||||
end
|
||||
end, types
|
||||
end
|
||||
|
||||
-- engine/battle/animations.asm:427-437
|
||||
local function rideAndSample(battle, poll, frames, shotPath)
|
||||
local peak, shot = 0, false
|
||||
for i = 1, frames do
|
||||
poll()
|
||||
if i % 6 == 0 then U.tap(battle.game, "a") end
|
||||
local fx = battle.fx
|
||||
if fx and fx.shakeX and not battle.animPlaying then
|
||||
local dx = math.abs(fx.shakeX)
|
||||
if dx > peak then peak = dx end
|
||||
if dx > 0 and not shot then
|
||||
shot = true
|
||||
U.shot(battle.game, shotPath)
|
||||
end
|
||||
end
|
||||
U.wait(1)
|
||||
end
|
||||
return peak
|
||||
end
|
||||
|
||||
local function listOf(seen)
|
||||
@@ -136,23 +157,28 @@ return function(game)
|
||||
local battle = BattleState.newWild(game, "PIDGEY", 8)
|
||||
battle.onFinish = function() end
|
||||
ow:pushBattle(battle)
|
||||
local seen, poll = watchAnims(battle)
|
||||
local seen, poll, types = watchAnims(battle)
|
||||
check("the battle reached the menu", waitPhase(battle, "menu", 60, poll))
|
||||
check("BIDE is the move on the list", pickBide(battle, poll))
|
||||
for _ = 1, 20 do poll() U.wait(1) end
|
||||
U.shot(game, DIR .. "/bug375_store_spiral.png")
|
||||
for _ = 1, 30 do poll() U.wait(1) end
|
||||
|
||||
local peak = rideAndSample(battle, poll, 180,
|
||||
DIR .. "/bug1564_store_shake.png")
|
||||
U.shot(game, DIR .. "/bug375_store_text.png")
|
||||
|
||||
check("the storing turn queued XSTATITEM_ANIM", has(seen, "XSTATITEM_ANIM"))
|
||||
check("and not BIDE's hit animation: " .. listOf(seen), not has(seen, "BIDE"))
|
||||
check("BIDE locked in for " .. tostring(battle.player.bideTurns) .. " turns",
|
||||
battle.player.bideTurns ~= nil)
|
||||
check("the storing spiral carries wAnimationType 6 (#1564)",
|
||||
types["XSTATITEM_ANIM"] == 6)
|
||||
check(("the screen shook, peak dx = %d px (want 3)"):format(peak), peak == 3)
|
||||
|
||||
-- ride out the locked turns: the storing text needs A presses and the menu
|
||||
-- never comes back until BIDE unleashes
|
||||
local released = false
|
||||
for _ = 1, 120 do
|
||||
for _ = 1, 220 do
|
||||
U.tap(game, "a")
|
||||
for _ = 1, 6 do poll() U.wait(1) end
|
||||
if has(seen, "BIDE") then
|
||||
@@ -179,7 +205,7 @@ return function(game)
|
||||
foeBattle.enemy.mon.moves = { { id = "BIDE", pp = 10 } }
|
||||
foeBattle.enemy.curMoves = foeBattle.enemy.mon.moves
|
||||
ow:pushBattle(foeBattle)
|
||||
local foeSeen, foePoll = watchAnims(foeBattle)
|
||||
local foeSeen, foePoll, foeTypes = watchAnims(foeBattle)
|
||||
check("the foe's battle reached the menu",
|
||||
waitPhase(foeBattle, "menu", 60, foePoll))
|
||||
pickBide(foeBattle, foePoll) -- ours does not matter here, the foe's does
|
||||
@@ -192,10 +218,20 @@ return function(game)
|
||||
check("the foe's storing turn queued XSTATITEM_DUPLICATE_ANIM: "
|
||||
.. listOf(foeSeen), has(foeSeen, "XSTATITEM_DUPLICATE_ANIM"))
|
||||
|
||||
local foePeak = rideAndSample(foeBattle, foePoll, 180,
|
||||
DIR .. "/bug1564_enemy_store_shake.png")
|
||||
check("the foe's spiral carries wAnimationType 3 (#1564)",
|
||||
foeTypes["XSTATITEM_DUPLICATE_ANIM"] == 3)
|
||||
check(("the foe's shake peaked at %d px (want 6)"):format(foePeak),
|
||||
foePeak == 6)
|
||||
|
||||
U.log("The pad is yours in a battle where both sides know only BIDE, so")
|
||||
U.log("pick FIGHT then BIDE and watch turn one: the screen palette flashes")
|
||||
U.log("white and balls spiral inward, silently, and then it says storing")
|
||||
U.log("energy. No flash on the RATTATA and no BIDE thud until the turn it")
|
||||
U.log("white and balls spiral inward, silently. Then, still in silence, the")
|
||||
U.log("whole battle screen creeps sideways 1 px at a time out to 3 px and")
|
||||
U.log("back, twice, and only then does it say storing energy. On the foe's")
|
||||
U.log("turn the same creep goes out to 6 px and takes twice as long.")
|
||||
U.log("No flash on the RATTATA and no BIDE thud until the turn it")
|
||||
U.log("unleashes, where the thud and its animation come after the text and")
|
||||
U.log("before the enemy HP bar slides down. The foe's spiral looks the same.")
|
||||
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
-- scripts/Route16Gate1F.asm:38 / home/overworld.asm:1224
|
||||
-- data/maps/force_bike_surf.asm:5
|
||||
-- POKEPORT_DRIVER=tests/drivers/bike_gate_wall_bug1548_test.lua \
|
||||
-- POKEPORT_IDENTITY=bug1548 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
|
||||
-- Watch the sprite after each teleport: it must never overlap the gate
|
||||
-- building's black outline. Compare .bazinga/august21p2/media/1548-1.jpg.
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local OverworldState = require("src.world.OverworldController")
|
||||
|
||||
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
local ok = true
|
||||
local function check(label, pass)
|
||||
U.log(pass and "PASS" or "FAIL", label)
|
||||
if not pass then ok = false end
|
||||
return pass
|
||||
end
|
||||
|
||||
local function settle()
|
||||
for _ = 1, 12 do
|
||||
if game.stack:top() == game.overworld then break end
|
||||
U.tap(game, "a")
|
||||
U.wait(25)
|
||||
end
|
||||
U.wait(60)
|
||||
end
|
||||
local function landed()
|
||||
local ow = game.overworld
|
||||
local p = ow.player
|
||||
return p.cellX, p.cellY, ow.map:isWalkableCell(p.cellX, p.cellY)
|
||||
end
|
||||
local function bikeless()
|
||||
game.save.inventory.BICYCLE = nil
|
||||
game.save.onBike, game.save.forcedBike = false, nil
|
||||
end
|
||||
|
||||
game.save.player.name = "PROBE"
|
||||
game.save.party = { Pokemon.new(game.data, "CHARIZARD", 50) }
|
||||
bikeless()
|
||||
|
||||
local fm = game.data.field.forcedMovement
|
||||
check("field.forcedMovement carries the Route 16 cells",
|
||||
fm and fm.tiles and fm.tiles.ROUTE_16 ~= nil)
|
||||
|
||||
for _, c in ipairs({ { "ROUTE_16", 17, 10 }, { "ROUTE_16", 17, 11 },
|
||||
{ "ROUTE_18", 33, 8 }, { "ROUTE_18", 33, 9 } }) do
|
||||
bikeless()
|
||||
U.teleport(game, c[1], c[2], c[3], "left")
|
||||
U.wait(20)
|
||||
settle()
|
||||
local x, y, walk = landed()
|
||||
U.log(("%s (%d,%d) facing left -> (%d,%d) walkable=%s")
|
||||
:format(c[1], c[2], c[3], x, y, tostring(walk)))
|
||||
check(("%s (%d,%d): the refusal leaves the player on a walkable cell")
|
||||
:format(c[1], c[2], c[3]), walk)
|
||||
end
|
||||
|
||||
bikeless()
|
||||
U.teleport(game, "ROUTE_16", 17, 10, "left")
|
||||
U.wait(20)
|
||||
settle()
|
||||
U.shot(game, SHOT_DIR .. "/bug1548_route16_after.png")
|
||||
|
||||
bikeless()
|
||||
U.teleport(game, "ROUTE_16", 16, 10, "right")
|
||||
U.wait(20)
|
||||
U.hold(game, "right", 24)
|
||||
settle()
|
||||
local bx, by = landed()
|
||||
U.log(("stepped onto ROUTE_16 (17,10) from the west -> (%d,%d)"):format(bx, by))
|
||||
check("a shove with open ground behind it still moves one cell",
|
||||
bx == 16 and by == 10)
|
||||
|
||||
bikeless()
|
||||
while game.stack:top() do game.stack:pop() end
|
||||
game.stack:push(OverworldState, "ROUTE_16", 18, 10, "left")
|
||||
game.overworld:setMap("ROUTE_16", 18, 10, "left", { via = "boot" })
|
||||
U.wait(20)
|
||||
local cx, cy, cwalk = landed()
|
||||
U.log(("boot inside the wall (18,10) -> (%d,%d) walkable=%s")
|
||||
:format(cx, cy, tostring(cwalk)))
|
||||
check("a save parked inside the gate wall is lifted out on load", cwalk)
|
||||
settle()
|
||||
local dx, dy, dwalk = landed()
|
||||
U.log(("after the refusal that follows -> (%d,%d) walkable=%s")
|
||||
:format(dx, dy, tostring(dwalk)))
|
||||
check("and the refusal it lands on cannot push it back in", dwalk)
|
||||
U.shot(game, SHOT_DIR .. "/bug1548_repaired.png")
|
||||
|
||||
game.save.inventory.BICYCLE = 1
|
||||
game.save.onBike, game.save.forcedBike = false, nil
|
||||
U.teleport(game, "ROUTE_16", 17, 10, "left")
|
||||
U.wait(30)
|
||||
local ex, ey, ewalk = landed()
|
||||
check("with a BICYCLE the forced tile mounts instead of shoving",
|
||||
game.save.onBike == true and ex == 17 and ey == 10 and ewalk)
|
||||
|
||||
U.log(ok and "all clear" or "a check failed")
|
||||
while true do coroutine.yield() end
|
||||
end
|
||||
@@ -0,0 +1,46 @@
|
||||
-- Driver: #1517's other half, the error handler that hid the crash.
|
||||
--
|
||||
-- boot.lua's deferErrhand picks `love.errorhandler or love.errhand`, so a
|
||||
-- love.errorhandler that returns nil ends love.run's `while func do` loop and
|
||||
-- the process leaves to the OS with no error screen and nothing on stdout.
|
||||
-- LOVE 11.5 pre-populates love.errhand only, so main.lua capturing
|
||||
-- love.errorhandler captured nil and every Lua error in every shipped build
|
||||
-- exited silently. Measured on the installed 11.5:
|
||||
-- love.errorhandler type: nil / love.errhand type: function
|
||||
--
|
||||
-- Nothing here can be a unit test: the deliverable is whether a human sees
|
||||
-- LOVE's blue error screen or an app that vanishes.
|
||||
--
|
||||
-- POKEPORT_TOUCH=0 \
|
||||
-- POKEPORT_DRIVER=tests/drivers/errorhandler_probe_bug1517_test.lua love .
|
||||
--
|
||||
-- Pre-fix: the window closes on the first drawn frame after the probe arms,
|
||||
-- exit 1, no output. Post-fix: the blue screen reads "bug1517: error-handler
|
||||
-- probe" plus the lua-error.log hint, and stays up until it is dismissed.
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
|
||||
U.log("[1517] love.errorhandler at boot was: "
|
||||
.. (rawget(love, "errorhandler") and "a function" or "nil"))
|
||||
U.log("[1517] love.errhand is: " .. type(rawget(love, "errhand")))
|
||||
|
||||
U.wait(30)
|
||||
|
||||
-- Raise from a draw callback, not from this coroutine: main.lua resumes the
|
||||
-- driver under coroutine.resume and prints its errors itself, which is the
|
||||
-- one path that does NOT reach love.errorhandler. StateStack:draw only
|
||||
-- calls the states it is actually holding, so the probe goes on the live
|
||||
-- top state rather than on game.overworld, which is not on the stack while
|
||||
-- the title screen or the load menu is up.
|
||||
local target = game.stack:top()
|
||||
U.log("[1517] arming the probe on " .. tostring(target and target.screenId))
|
||||
local realDraw = target.draw
|
||||
target.draw = function(self, ...)
|
||||
if realDraw then realDraw(self, ...) end
|
||||
error("bug1517: error-handler probe")
|
||||
end
|
||||
|
||||
U.log("[1517] armed; the next drawn frame raises from love.draw")
|
||||
U.log("[1517] you should now see LOVE's blue error screen, not a closed app")
|
||||
while true do coroutine.yield() end
|
||||
end
|
||||
@@ -0,0 +1,171 @@
|
||||
-- BIDE on the real Gold battle screen: the PP counter in the move list, and
|
||||
-- the lock that keeps a storing mon on the move it started (#1664, #1665).
|
||||
--
|
||||
-- POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/gold_bide_lock_bug1664_1665.lua \
|
||||
-- POKEPORT_SHOT_DIR=/tmp/gold-bide love .
|
||||
--
|
||||
-- data/moves/effects.asm:795-800 puts `storeenergy` ahead of `doturn`, and
|
||||
-- BattleCommand_DoTurn masks SUBSTATUS_BIDE out of the PP spend
|
||||
-- (engine/battle/effect_commands.asm:977-979), so the whole three-turn Bide
|
||||
-- costs the one PP the opening turn paid. The shots are the point: the
|
||||
-- number beside BIDE in the move list must read the same on 01, 02 and 03.
|
||||
--
|
||||
-- The lock is ParsePlayerAction's own arm (engine/battle/core.asm:569-576),
|
||||
-- which sits INSIDE the FIGHT branch and skips MoveSelectionScreen only -- so
|
||||
-- the 2x2 menu still opens, a switch is still legal, and using an item runs
|
||||
-- .reset_bide (:627-629) and CANCELS the store. Block 2 submits TACKLE in
|
||||
-- the middle of a Bide on purpose: the engine has to answer with the Bide.
|
||||
--
|
||||
-- NOT covered here, and deliberately: src/ui/gen2/BattleState.lua still draws
|
||||
-- the move list on a storing turn. The cart jumps past it. That half is a
|
||||
-- screen change and is called out in the fix report rather than faked.
|
||||
local U = require("tests.drivers.util")
|
||||
|
||||
local Mon = require("src.battle.gen2.Mon")
|
||||
|
||||
local function battleScreen(game)
|
||||
for _ = 1, 900 do
|
||||
local top = game.stack:top()
|
||||
if top and top.battle then return top end
|
||||
U.wait(1)
|
||||
end
|
||||
error("battle screen never came up")
|
||||
end
|
||||
|
||||
local function drain(game, screen, frames)
|
||||
for _ = 1, (frames or 400) do
|
||||
if screen.phase == "menu" and #screen.queue == 0 and not screen.anim then
|
||||
return true
|
||||
end
|
||||
U.tap(game, "a")
|
||||
U.wait(3)
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function giveMoves(mon, game, moves)
|
||||
mon.moves = {}
|
||||
for i, id in ipairs(moves) do
|
||||
local def = assert(game.data.moves[id], id .. " is not in moves.lua")
|
||||
mon.moves[i] = { id = id, pp = def.pp, maxPp = def.pp }
|
||||
end
|
||||
return mon
|
||||
end
|
||||
|
||||
local function newWild(game, species, level, moves)
|
||||
local mon = Mon.new(game.data, species, level)
|
||||
if moves then giveMoves(mon, game, moves) end
|
||||
return mon
|
||||
end
|
||||
|
||||
return function(game)
|
||||
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-bide"
|
||||
U.wait(45)
|
||||
local world = game.world
|
||||
assert(world and world.map, "gold world did not boot")
|
||||
local failures = {}
|
||||
local function check(ok, what)
|
||||
print((ok and "[ok] " or "[FAIL] ") .. what)
|
||||
if not ok then failures[#failures + 1] = what end
|
||||
end
|
||||
local function pp(mon, slot) return mon.moves[slot].pp end
|
||||
|
||||
-- ------------------------------------------------------------------ 1
|
||||
-- One Bide, start to finish, with the move list open between turns. The
|
||||
-- foe is a SPLASH-only RATTATA so nothing interrupts the store.
|
||||
local player = Mon.new(game.data, "SNORLAX", 40)
|
||||
giveMoves(player, game, { "BIDE", "TACKLE" })
|
||||
game.save.party = { player }
|
||||
game.save.inventory = { POTION = 5 }
|
||||
local foe = newWild(game, "RATTATA", 20, { "TACKLE" })
|
||||
assert(world:startBattle({ wild = foe }), "startBattle failed")
|
||||
local screen = battleScreen(game)
|
||||
drain(game, screen, 200)
|
||||
local opening = pp(player, 1)
|
||||
print(("[driver] BIDE opens at %d PP"):format(opening))
|
||||
|
||||
screen:submit({ kind = "move", move = "BIDE" })
|
||||
drain(game, screen, 400)
|
||||
U.shot(game, out .. "/01-selected.png")
|
||||
local afterSelect = pp(player, 1)
|
||||
print(("[driver] after the SELECTION turn: %d PP"):format(afterSelect))
|
||||
check(afterSelect == opening - 1,
|
||||
"the opening turn pays its one PP through doturn")
|
||||
check(screen.battle:forcedMove(player) == "BIDE",
|
||||
"and ParsePlayerAction's bide arm holds the FIGHT choice")
|
||||
|
||||
-- The middle of the store, with TACKLE deliberately submitted: the cart
|
||||
-- never re-reads the move list, so wCurPlayerMove is still the BIDE.
|
||||
local tackleBefore = pp(player, 2)
|
||||
screen:submit({ kind = "move", move = "TACKLE" })
|
||||
drain(game, screen, 400)
|
||||
U.shot(game, out .. "/02-storing.png")
|
||||
print(("[driver] after a STORING turn: BIDE %d PP, TACKLE %d PP")
|
||||
:format(pp(player, 1), pp(player, 2)))
|
||||
check(pp(player, 1) == afterSelect, "a storing turn spends no PP at all")
|
||||
check(pp(player, 2) == tackleBefore,
|
||||
"and the move the menu offered was never run")
|
||||
|
||||
-- The release. Whatever the roll, the store is two or three turns
|
||||
-- (UnleashEnergy's `BattleRandom / and 1 / inc a / inc a`,
|
||||
-- move_effects/bide.asm:88-92), so keep pressing FIGHT until it lets go.
|
||||
local foeBefore = foe.hp
|
||||
for _ = 1, 3 do
|
||||
if not screen.battle:forcedMove(player) then break end
|
||||
screen:submit({ kind = "move", move = "BIDE" })
|
||||
drain(game, screen, 400)
|
||||
end
|
||||
U.shot(game, out .. "/03-unleashed.png")
|
||||
print(("[driver] after the RELEASE: %d PP, foe %d -> %d HP")
|
||||
:format(pp(player, 1), foeBefore, foe.hp))
|
||||
check(pp(player, 1) == afterSelect,
|
||||
"the whole Bide cost exactly one PP, the way the cart charges it")
|
||||
check(screen.battle:forcedMove(player) == nil, "and the lock is released")
|
||||
for _ = 1, 400 do
|
||||
if not game.stack:top() or not game.stack:top().battle then break end
|
||||
U.tap(game, "a")
|
||||
U.wait(3)
|
||||
end
|
||||
U.wait(60)
|
||||
|
||||
-- ------------------------------------------------------------------ 2
|
||||
-- .reset_bide: a nonzero wBattlePlayerAction that is not
|
||||
-- BATTLEPLAYERACTION_SWITCH clears SUBSTATUS_BIDE (core.asm:572-573,
|
||||
-- :627-629), so opening the PACK mid-store throws the stored damage away.
|
||||
local bider = Mon.new(game.data, "SNORLAX", 40)
|
||||
giveMoves(bider, game, { "BIDE", "TACKLE" })
|
||||
bider.hp = math.max(1, bider.hp - 30)
|
||||
game.save.party = { bider }
|
||||
game.save.inventory = { POTION = 5 }
|
||||
local foe2 = newWild(game, "RATTATA", 20, { "TACKLE" })
|
||||
assert(world:startBattle({ wild = foe2 }), "startBattle failed")
|
||||
screen = battleScreen(game)
|
||||
drain(game, screen, 200)
|
||||
screen:submit({ kind = "move", move = "BIDE" })
|
||||
drain(game, screen, 400)
|
||||
check(screen.battle:forcedMove(bider) == "BIDE", "the second Bide is up")
|
||||
screen:useItem("POTION")
|
||||
drain(game, screen, 400)
|
||||
U.shot(game, out .. "/04-item-cancelled.png")
|
||||
print("[driver] after the PACK: forcedMove="
|
||||
.. tostring(screen.battle:forcedMove(bider)))
|
||||
check(screen.battle:forcedMove(bider) == nil,
|
||||
"using an item cancels the Bide, as .reset_bide does")
|
||||
check(screen.battle:volatile(bider).bideStored == nil,
|
||||
"and the damage it had banked goes with it")
|
||||
for _ = 1, 400 do
|
||||
if not game.stack:top() or not game.stack:top().battle then break end
|
||||
U.tap(game, "a")
|
||||
U.wait(3)
|
||||
end
|
||||
|
||||
print("[driver] NOTE: the move list is still drawn on a storing turn; the "
|
||||
.. "cart's bide arm skips MoveSelectionScreen and that half lives in "
|
||||
.. "src/ui/gen2/BattleState.lua")
|
||||
if #failures > 0 then
|
||||
for _, what in ipairs(failures) do print("[FAIL] " .. what) end
|
||||
error(#failures .. " bide checks failed")
|
||||
end
|
||||
print("[driver] all bide checks passed")
|
||||
print("[driver] shots in " .. out)
|
||||
end
|
||||
@@ -0,0 +1,113 @@
|
||||
-- #1672: Bill's PC MOVE screen -- the box-name arrows.
|
||||
--
|
||||
-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold POKEPORT_TOUCH=0 \
|
||||
-- POKEPORT_DRIVER=tests/drivers/gold_billspc_arrows_bug1672.lua \
|
||||
-- perl -e 'alarm 420; exec @ARGV' \
|
||||
-- python3 -c "import pty; pty.spawn(['love','.'])"
|
||||
-- POKEPORT_SHOT_DIR=/tmp/gold-bug1672-arrows (default)
|
||||
--
|
||||
-- BillsPC_MoveMonWOMail_BoxNameAndArrows writes $5f at hlcoord 8, 1 and $5e at
|
||||
-- hlcoord 19, 1 (engine/pokemon/bills_pc.asm:957-963), replacing the box-name
|
||||
-- Textbox's own side borders. Only _MovePKMNWithoutMail calls it: .Init (:545)
|
||||
-- and .PrepInsertCursor (:698). The withdraw (:299) and deposit (:56) inits
|
||||
-- call bare BillsPC_BoxName (:965) and are the negative controls here.
|
||||
--
|
||||
-- What to look for in each shot: a solid LEFT-pointing triangle in the left
|
||||
-- border of the name box and a solid RIGHT-pointing one in the right border,
|
||||
-- both level with the name, on every move-mode shot and on neither of the last
|
||||
-- two. Getting them the wrong way round is the failure this exists to catch.
|
||||
--
|
||||
-- The run ends with the MOVE screen open so a human takes the controls there.
|
||||
local U = require("tests.drivers.util")
|
||||
|
||||
local Boxes = require("src.core.gen2.Boxes")
|
||||
local Mon = require("src.battle.gen2.Mon")
|
||||
local Screens = require("src.ui.Screens")
|
||||
|
||||
return function(game)
|
||||
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-bug1672-arrows"
|
||||
|
||||
local function tap(button, frames)
|
||||
game.input.pressQueue[#game.input.pressQueue + 1] = button
|
||||
game.input.state[button] = true
|
||||
U.wait(2)
|
||||
game.input.state[button] = false
|
||||
U.wait(frames or 6)
|
||||
end
|
||||
|
||||
U.wait(45)
|
||||
assert(game.world and game.world.map, "gold world did not boot")
|
||||
local save, data = game.save, game.data
|
||||
|
||||
local function mon(species, level) return Mon.new(data, species, level) end
|
||||
|
||||
-- IsAnyMonHoldingMail refuses the whole screen (src/ui/gen2/PcMenu.lua:303),
|
||||
-- so the seeded party carries no mail.
|
||||
save.party = {}
|
||||
for _, species in ipairs({ "CYNDAQUIL", "PIDGEY", "SENTRET" }) do
|
||||
save.party[#save.party + 1] = mon(species, 12)
|
||||
end
|
||||
local stored = Boxes.box(save, 1)
|
||||
for i = #stored, 1, -1 do stored[i] = nil end
|
||||
for i, species in ipairs({ "GEODUDE", "ZUBAT", "RATTATA" }) do
|
||||
stored[i] = mon(species, 10 + i)
|
||||
end
|
||||
Boxes.rename(save, 1, "GRASS")
|
||||
save.currentBox = 1
|
||||
|
||||
local function openBox(mode)
|
||||
Screens.push(game, "Gen2BoxMenu", {
|
||||
save = save, mode = mode,
|
||||
onClose = function() game.stack:pop() end,
|
||||
})
|
||||
U.wait(8)
|
||||
return game.stack:top()
|
||||
end
|
||||
|
||||
local function close()
|
||||
while game.stack:top() and game.stack:top().submenuRows do
|
||||
game.stack:pop()
|
||||
end
|
||||
U.wait(4)
|
||||
end
|
||||
|
||||
-- ---- 1. the move list on a renamed box -----------------------------------
|
||||
local menu = openBox("move")
|
||||
U.log("01 move, box GRASS:", "want both arrows flanking GRASS on row 1")
|
||||
U.shot(game, out .. "/01-move-box.png")
|
||||
|
||||
-- ---- 2. LEFT onto the PARTY ----------------------------------------------
|
||||
-- BillsPC_BoxName's `.party` arm; only the move screen ever loads box 0.
|
||||
tap("left")
|
||||
U.log("02 move, PARTY:", "boxIndex " .. tostring(menu.boxIndex) ..
|
||||
", want 0 and both arrows still up")
|
||||
U.shot(game, out .. "/02-move-party.png")
|
||||
tap("right")
|
||||
|
||||
-- ---- 3. the MOVE / STATS / CANCEL submenu --------------------------------
|
||||
tap("a")
|
||||
U.log("03 submenu:", "phase " .. tostring(menu.phase) ..
|
||||
", want submenu and both arrows still up")
|
||||
U.shot(game, out .. "/03-move-submenu.png")
|
||||
|
||||
-- ---- 4. the insert cursor (.PrepInsertCursor rewrites them) --------------
|
||||
tap("a", 10)
|
||||
U.log("04 insert:", "phase " .. tostring(menu.phase) ..
|
||||
", want insert and both arrows still up")
|
||||
U.shot(game, out .. "/04-move-insert.png")
|
||||
close()
|
||||
|
||||
-- ---- 5/6. the negative controls ------------------------------------------
|
||||
openBox("withdraw")
|
||||
U.log("05 withdraw:", "want plain border tiles, NO arrows")
|
||||
U.shot(game, out .. "/05-withdraw-none.png")
|
||||
close()
|
||||
|
||||
openBox("deposit")
|
||||
U.log("06 deposit:", "want plain border tiles, NO arrows")
|
||||
U.shot(game, out .. "/06-deposit-none.png")
|
||||
close()
|
||||
|
||||
openBox("move")
|
||||
U.log("done -- the MOVE screen is open; the controls are yours")
|
||||
end
|
||||
@@ -70,15 +70,18 @@ return function(game)
|
||||
U.tap(game, "right")
|
||||
shot("03-tmhm") -- TMs carry ×NN, the two HMs carry none
|
||||
|
||||
-- SELECT arms the row instead of registering it (#1427): the hollow ▷ marks
|
||||
-- TM_HEADBUTT while "Where should this be moved to?" holds the box.
|
||||
-- SELECT armed the row here for #1427; since #1567 it is filtered out of the
|
||||
-- TM/HM pocket entirely (engine/items/tmhm.asm:207), so these taps must leave
|
||||
-- the list exactly as shot 03 has it: no hollow ▷, no "Where should this be
|
||||
-- moved to?", no reorder.
|
||||
U.tap(game, "down")
|
||||
U.tap(game, "select")
|
||||
shot("04-tmhm-armed")
|
||||
shot("04-tmhm-select-ignored")
|
||||
U.tap(game, "up")
|
||||
shot("05-tmhm-destination")
|
||||
shot("05-tmhm-still-numbered")
|
||||
U.tap(game, "a")
|
||||
shot("06-tmhm-moved") -- HEADBUTT is now the first TM row
|
||||
U.tap(game, "b")
|
||||
shot("06-tmhm-unchanged")
|
||||
|
||||
U.log("[driver] bag order:", table.concat(Bag.order(save), " "))
|
||||
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
-- Assertion driver: a KEY ITEM and an HM through the player's item PC.
|
||||
--
|
||||
-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold \
|
||||
-- POKEPORT_DRIVER=tests/drivers/gold_bug1486_test.lua \
|
||||
-- perl -e 'alarm 300; exec @ARGV' \
|
||||
-- python3 -c "import pty; pty.spawn(['love','.'])"
|
||||
--
|
||||
-- Issue #1486. What the unit tier cannot show: the two screens a player
|
||||
-- actually looks at. .DepositItem (engine/events/pokecenter_pc.asm:504) reads
|
||||
-- CANT_TOSS only to force x1 and skip .AskQuantity, and PlaceMenuItemQuantity
|
||||
-- (engine/menus/menu_2.asm:18) draws no ×NN under such a row.
|
||||
--
|
||||
-- Shots land in /tmp/gold-bug1486:
|
||||
--
|
||||
-- 01-key-items-pocket.png the DEPOSIT chooser on the KEY ITEMS pocket
|
||||
-- 02-deposited.png "Deposited 1 BICYCLE(S)." -- before the fix this
|
||||
-- is the unchanged pocket with no message at all
|
||||
-- 03-pc-list.png the PC list: POTION with its ×03, BICYCLE and
|
||||
-- HM01 with no ×NN at all
|
||||
local U = require("tests.drivers.util")
|
||||
|
||||
return function(game)
|
||||
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-bug1486"
|
||||
local fails = 0
|
||||
|
||||
local function ok(cond, msg)
|
||||
if cond then print("[1486] ok " .. msg)
|
||||
else fails = fails + 1 print("[1486] FAIL " .. msg) end
|
||||
return cond
|
||||
end
|
||||
|
||||
local function tap(button, frames)
|
||||
game.input.pressQueue[#game.input.pressQueue + 1] = button
|
||||
game.input.state[button] = true
|
||||
U.wait(2)
|
||||
game.input.state[button] = false
|
||||
U.wait(frames or 4)
|
||||
end
|
||||
|
||||
U.wait(45)
|
||||
local w = game.world
|
||||
assert(w and w.map, "gold world did not boot")
|
||||
local save = game.save
|
||||
|
||||
local Bag = require("src.inventory.Bag")
|
||||
local Mon = require("src.battle.gen2.Mon")
|
||||
save.party = { Mon.new(game.data, "CYNDAQUIL", 10) }
|
||||
save.inventory = {}
|
||||
save.pcItems = { POTION = 3 }
|
||||
assert(Bag.add(save, "BICYCLE", 1, game.data), "BICYCLE into the bag")
|
||||
assert(Bag.add(save, "HM_CUT", 1, game.data), "HM01 into the bag")
|
||||
|
||||
local function topId()
|
||||
local top = game.stack:top()
|
||||
return top and top.screenId or nil
|
||||
end
|
||||
|
||||
assert(w:setMap("CHERRYGROVE_POKECENTER_1F", 4, 4, "up"),
|
||||
"setMap CHERRYGROVE_POKECENTER_1F failed")
|
||||
U.wait(5)
|
||||
local pcX, pcY
|
||||
for cy = 0, w.map.heightCells - 1 do
|
||||
for cx = 0, w.map.widthCells - 1 do
|
||||
if w.map:cellCollision(cx, cy) == 0x93 then pcX, pcY = cx, cy end
|
||||
end
|
||||
end
|
||||
assert(pcX, "no COLL_PC tile in the Pokecenter")
|
||||
assert(w:setMap("CHERRYGROVE_POKECENTER_1F", pcX, pcY + 1, "up"),
|
||||
"setMap onto the PC tile failed")
|
||||
U.wait(5)
|
||||
|
||||
tap("a", 8) -- PCScript
|
||||
tap("a", 4) -- the turn-on line
|
||||
tap("down", 4)
|
||||
tap("a", 4) -- <PLAYER>'s PC
|
||||
tap("a", 4)
|
||||
tap("a", 6) -- both PokecenterPlayersPCText pages
|
||||
ok(topId() == "Gen2ItemPcMenu",
|
||||
"<PLAYER>'s PC opens the item PC (top: " .. tostring(topId()) .. ")")
|
||||
|
||||
-- DEPOSIT ITEM -> the PACK chooser -> the KEY ITEMS pocket.
|
||||
tap("down", 4)
|
||||
tap("a", 6)
|
||||
tap("right", 4) -- ITEM -> BALL
|
||||
tap("right", 4) -- BALL -> KEY ITEMS
|
||||
U.shot(game, out .. "/01-key-items-pocket.png")
|
||||
tap("a", 6) -- the BICYCLE row: x1, no prompt
|
||||
ok(save.pcItems.BICYCLE == 1,
|
||||
"the BICYCLE landed in the PC (" .. tostring(save.pcItems.BICYCLE) .. ")")
|
||||
ok(save.inventory.BICYCLE == nil,
|
||||
"and left the bag (" .. tostring(save.inventory.BICYCLE) .. ")")
|
||||
U.shot(game, out .. "/02-deposited.png")
|
||||
tap("a", 4) -- the Deposited line
|
||||
|
||||
-- The same, one pocket over: an HM.
|
||||
tap("right", 4) -- KEY ITEMS -> TM/HM
|
||||
tap("a", 6)
|
||||
ok(save.pcItems.HM_CUT == 1,
|
||||
"HM01 landed in the PC (" .. tostring(save.pcItems.HM_CUT) .. ")")
|
||||
ok(save.inventory.HM_CUT == nil,
|
||||
"and left the bag (" .. tostring(save.inventory.HM_CUT) .. ")")
|
||||
tap("a", 4)
|
||||
tap("b", 6) -- close the PACK
|
||||
|
||||
-- WITHDRAW ITEM: the list is where PlaceMenuItemQuantity shows.
|
||||
tap("up", 4)
|
||||
tap("a", 6)
|
||||
U.shot(game, out .. "/03-pc-list.png")
|
||||
tap("a", 6) -- the first row, x1 or the selector
|
||||
print(("[1486] %d failures"):format(fails))
|
||||
love.event.quit(fails == 0 and 0 or 1)
|
||||
end
|
||||
@@ -0,0 +1,168 @@
|
||||
-- #1556, the two battle jingles: _NewDexDataText's sound_slot_machine_start
|
||||
-- (data/text/common_3.asm:285) on a first catch, and _LearnedMoveText's
|
||||
-- sound_dex_fanfare_50_79 (data/text/common_3.asm:119) on a level-up move.
|
||||
--
|
||||
-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold POKEPORT_TOUCH=0 \
|
||||
-- POKEPORT_DRIVER=tests/drivers/gold_bug1556_battle.lua \
|
||||
-- perl -e 'alarm 420; exec @ARGV' \
|
||||
-- python3 -c "import pty; pty.spawn(['love','.'])"
|
||||
-- POKEPORT_SHOT_DIR=/tmp/gold-bug1556-battle (default)
|
||||
--
|
||||
-- Both ride the message queue's `sfx` + `waitSfx` keys, which the "Gotcha!"
|
||||
-- line and the "grew to level" line already used -- the two lines next door
|
||||
-- to them did not. So the ear is comparing neighbours: the catch jingle must
|
||||
-- be followed by a SECOND, different one over the #DEX line, and the level-up
|
||||
-- fanfare by a second over "learned".
|
||||
local U = require("tests.drivers.util")
|
||||
|
||||
local Bag = require("src.inventory.Bag")
|
||||
local PackMenu = require("src.ui.gen2.PackMenu")
|
||||
local Mon = require("src.battle.gen2.Mon")
|
||||
local Sound = require("src.core.Sound")
|
||||
|
||||
return function(game)
|
||||
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-bug1556-battle"
|
||||
|
||||
local heard = {}
|
||||
local realPlay = Sound.play
|
||||
Sound.play = function(data, name)
|
||||
heard[#heard + 1] = name
|
||||
return realPlay(data, name)
|
||||
end
|
||||
local function reset() heard = {} end
|
||||
-- A throw, its wobbles and a level-up all animate for a while; a report taken
|
||||
-- before the jingle lands reads as silence.
|
||||
local function awaitSfx(name, frames, want)
|
||||
want = want or 1
|
||||
for i = 1, (frames or 200) do
|
||||
local seen = 0
|
||||
for _, n in ipairs(heard) do if n == name then seen = seen + 1 end end
|
||||
if seen >= want then return true end
|
||||
if i % 5 == 0 then U.tap(game, "a") else U.wait(2) end
|
||||
end
|
||||
return false
|
||||
end
|
||||
local function report(label, want)
|
||||
U.log(label, #heard > 0 and table.concat(heard, ", ") or "(silence)")
|
||||
U.log(" want:", want)
|
||||
end
|
||||
|
||||
U.wait(45)
|
||||
local world = game.world
|
||||
assert(world and world.map, "gold world did not boot")
|
||||
local save, data = game.save, game.data
|
||||
|
||||
local function battleScreen()
|
||||
local top = game.stack:top()
|
||||
return (top and top.battle) and top or nil
|
||||
end
|
||||
|
||||
local function tapUntil(predicate, tries, btn)
|
||||
for _ = 1, tries or 400 do
|
||||
if predicate() then return true end
|
||||
U.tap(game, btn or "a")
|
||||
U.wait(2)
|
||||
end
|
||||
return predicate()
|
||||
end
|
||||
|
||||
-- ---- 1. a first catch: "Gotcha!" then the #DEX line ---------------------
|
||||
save.party = { Mon.new(data, "CYNDAQUIL", 20) }
|
||||
save.inventory, save.bagOrder = {}, {}
|
||||
Bag.add(save, "MASTER_BALL", 1, data)
|
||||
save.pokedexReceived = true
|
||||
save.pokedex = { seen = {}, caught = {} }
|
||||
|
||||
local wild = Mon.new(data, "PIDGEY", 3)
|
||||
assert(wild, "the cache carries no PIDGEY")
|
||||
assert(world:startBattle({ wild = wild }), "the wild battle refused to start")
|
||||
assert(tapUntil(function()
|
||||
local screen = battleScreen()
|
||||
return screen ~= nil and screen.phase == "menu"
|
||||
end), "the battle never reached BattleMenu")
|
||||
local screen = battleScreen()
|
||||
|
||||
-- BattleMenuHeader's 2x2 grid: 1 FIGHT / 2 PkMn, 3 PACK / 4 RUN.
|
||||
if screen.menuIndex % 2 == 0 then U.tap(game, "left") U.wait(3) end
|
||||
if screen.menuIndex <= 2 then U.tap(game, "down") U.wait(3) end
|
||||
U.tap(game, "a")
|
||||
U.wait(6)
|
||||
local pack = game.stack:top()
|
||||
assert(pack and pack.rows, "the battle PACK did not open")
|
||||
-- The PACK restores its own cursor pocket; the BALL pocket is where a
|
||||
-- MASTER BALL lives (engine/items/pack.asm's ItemAttributes pocket byte).
|
||||
for _ = 1, #PackMenu.POCKETS do
|
||||
if pack:pocket().id == "BALL" then break end
|
||||
U.tap(game, "right")
|
||||
U.wait(3)
|
||||
end
|
||||
assert(pack:pocket().id == "BALL", "could not reach the BALL pocket")
|
||||
local ballRow
|
||||
for index, row in ipairs(pack.rows) do
|
||||
if row.id == "MASTER_BALL" then ballRow = index end
|
||||
end
|
||||
assert(ballRow, "the battle PACK does not show the MASTER BALL")
|
||||
for _ = 2, ballRow do U.tap(game, "down") U.wait(2) end
|
||||
reset()
|
||||
U.tap(game, "a")
|
||||
awaitSfx("Sfx_CaughtMon", 400)
|
||||
awaitSfx("Sfx_SlotMachineStart", 300)
|
||||
U.wait(30)
|
||||
report("01 catch:",
|
||||
"Sfx_CaughtMon, then Sfx_SlotMachineStart over the #DEX line")
|
||||
U.shot(game, out .. "/01-caught.png")
|
||||
for _ = 1, 6 do U.tap(game, "a") U.wait(20) end
|
||||
U.shot(game, out .. "/02-dex-line.png")
|
||||
for _ = 1, 30 do
|
||||
if not battleScreen() then break end
|
||||
U.tap(game, "a")
|
||||
U.wait(10)
|
||||
end
|
||||
|
||||
-- ---- 2. a level-up that teaches a move ---------------------------------
|
||||
local SPECIES = "CYNDAQUIL"
|
||||
local def = data.pokemon and data.pokemon[SPECIES]
|
||||
local target
|
||||
for _, entry in ipairs((def and def.levelMoves) or {}) do
|
||||
if entry.level and entry.level > 6 and not target then target = entry.level end
|
||||
end
|
||||
if not target then
|
||||
U.log("SKIP 02 -- no level-up move row for " .. SPECIES)
|
||||
else
|
||||
local hero = Mon.new(data, SPECIES, target - 1)
|
||||
local growth = Mon.growthFor(data, def.growthRate)
|
||||
hero.experience = Mon.experienceForLevel(growth, target) - 1
|
||||
-- Only three moves, so LearnMove takes the free slot rather than ForgetMove
|
||||
-- (engine/pokemon/learn.asm:23-29).
|
||||
while hero.moves and #hero.moves > 3 do table.remove(hero.moves) end
|
||||
save.party = { hero }
|
||||
save.inventory = {}
|
||||
U.log(("levelling %s %d -> %d for its %s row")
|
||||
:format(SPECIES, hero.level, target, tostring(target)))
|
||||
|
||||
local prey = Mon.new(data, "PIDGEY", 2)
|
||||
prey.hp = 1
|
||||
assert(world:startBattle({ wild = prey }), "the second battle refused")
|
||||
assert(tapUntil(function()
|
||||
local s = battleScreen()
|
||||
return s ~= nil and s.phase == "menu"
|
||||
end), "the second battle never reached BattleMenu")
|
||||
screen = battleScreen()
|
||||
if screen.menuIndex % 2 == 0 then U.tap(game, "left") U.wait(3) end
|
||||
if screen.menuIndex > 2 then U.tap(game, "up") U.wait(3) end
|
||||
reset()
|
||||
U.tap(game, "a") -- FIGHT
|
||||
U.wait(6)
|
||||
U.tap(game, "a") -- the first move
|
||||
awaitSfx("Sfx_DexFanfare5079", 400, 2)
|
||||
U.wait(60)
|
||||
report("02 level-up move:",
|
||||
"Sfx_DexFanfare5079 twice: \"grew to level\" and \"learned\"")
|
||||
U.shot(game, out .. "/03-level-up.png")
|
||||
for _ = 1, 8 do U.tap(game, "a") U.wait(30) end
|
||||
U.shot(game, out .. "/04-learned.png")
|
||||
end
|
||||
|
||||
Sound.play = realPlay
|
||||
U.log("done -- the controls are yours")
|
||||
end
|
||||
@@ -0,0 +1,196 @@
|
||||
-- #1556: Bill's PC -- the deposit / withdraw / release cry, and the six
|
||||
-- SFX_WRONG refusals (engine/pokemon/bills_pc.asm).
|
||||
--
|
||||
-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold POKEPORT_TOUCH=0 \
|
||||
-- POKEPORT_DRIVER=tests/drivers/gold_bug1556_billspc.lua \
|
||||
-- perl -e 'alarm 420; exec @ARGV' \
|
||||
-- python3 -c "import pty; pty.spawn(['love','.'])"
|
||||
-- POKEPORT_SHOT_DIR=/tmp/gold-bug1556-billspc (default)
|
||||
--
|
||||
-- Eight moments, each seeded, driven through the real screen and screenshotted.
|
||||
-- The log names what sounded; the ear is the point. DepositPokemon and
|
||||
-- TryWithdrawPokemon both `call PlayMonCry` right after RemoveMonFromPartyOrBox
|
||||
-- (:1762 and :1817), and PlayMonCry itself bails on an EGG
|
||||
-- (home/pokemon.asm:113), so a boxed egg must move in silence.
|
||||
--
|
||||
-- The run ends with the PC screen open so a human takes the controls there.
|
||||
local U = require("tests.drivers.util")
|
||||
|
||||
local Boxes = require("src.core.gen2.Boxes")
|
||||
local Mon = require("src.battle.gen2.Mon")
|
||||
local Screens = require("src.ui.Screens")
|
||||
local Sound = require("src.core.Sound")
|
||||
|
||||
return function(game)
|
||||
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-bug1556-billspc"
|
||||
|
||||
local heard = {}
|
||||
local realPlay, realCry = Sound.play, Sound.playCry
|
||||
Sound.play = function(data, name)
|
||||
heard[#heard + 1] = name
|
||||
return realPlay(data, name)
|
||||
end
|
||||
Sound.playCry = function(data, species, clip)
|
||||
heard[#heard + 1] = "cry:" .. tostring(species)
|
||||
return realCry(data, species, clip)
|
||||
end
|
||||
|
||||
local function reset() heard = {} end
|
||||
local function report(label, want)
|
||||
U.log(label, #heard > 0 and table.concat(heard, ", ") or "(silence)")
|
||||
U.log(" want:", want)
|
||||
end
|
||||
|
||||
local function tap(button, frames)
|
||||
game.input.pressQueue[#game.input.pressQueue + 1] = button
|
||||
game.input.state[button] = true
|
||||
U.wait(2)
|
||||
game.input.state[button] = false
|
||||
U.wait(frames or 6)
|
||||
end
|
||||
|
||||
U.wait(45)
|
||||
local w = game.world
|
||||
assert(w and w.map, "gold world did not boot")
|
||||
local save = game.save
|
||||
local data = game.data
|
||||
|
||||
local function mon(species, level)
|
||||
return Mon.new(data, species, level)
|
||||
end
|
||||
|
||||
local function openBox(mode)
|
||||
Screens.push(game, "Gen2BoxMenu", {
|
||||
save = save, mode = mode,
|
||||
onClose = function() game.stack:pop() end,
|
||||
})
|
||||
U.wait(8)
|
||||
return game.stack:top()
|
||||
end
|
||||
|
||||
local function close()
|
||||
while game.stack:top() and game.stack:top().submenuRows do
|
||||
game.stack:pop()
|
||||
end
|
||||
U.wait(4)
|
||||
end
|
||||
|
||||
local function fillBox(index, n, species)
|
||||
local box = Boxes.box(save, index)
|
||||
for i = #box, 1, -1 do box[i] = nil end
|
||||
for _ = 1, n do box[#box + 1] = mon(species or "RATTATA", 5) end
|
||||
end
|
||||
|
||||
local function setParty(n, species)
|
||||
save.party = {}
|
||||
for _ = 1, n do save.party[#save.party + 1] = mon(species or "CYNDAQUIL", 10) end
|
||||
end
|
||||
|
||||
save.currentBox = 1
|
||||
|
||||
-- ---- 1. WITHDRAW a mon: its cry ------------------------------------------
|
||||
setParty(1)
|
||||
fillBox(1, 1, "PIDGEY")
|
||||
local menu = openBox("withdraw")
|
||||
reset()
|
||||
tap("a") -- the submenu
|
||||
tap("a", 12) -- WITHDRAW
|
||||
report("01 withdraw:", "cry:PIDGEY")
|
||||
U.shot(game, out .. "/01-withdraw-cry.png")
|
||||
tap("a") -- clear anything on screen
|
||||
close()
|
||||
|
||||
-- ---- 2. DEPOSIT it back: the cry again -----------------------------------
|
||||
setParty(3)
|
||||
save.party[#save.party + 1] = mon("PIDGEY", 5)
|
||||
fillBox(1, 0)
|
||||
menu = openBox("deposit")
|
||||
for _ = 1, 3 do tap("down") end
|
||||
reset()
|
||||
tap("a")
|
||||
tap("a", 12) -- DEPOSIT
|
||||
report("02 deposit:", "cry:PIDGEY")
|
||||
U.shot(game, out .. "/02-deposit-cry.png")
|
||||
tap("a")
|
||||
close()
|
||||
|
||||
-- ---- 3. WITHDRAW into a full party: SFX_WRONG (:1845) --------------------
|
||||
setParty(Boxes.PARTY_SIZE)
|
||||
fillBox(1, 1, "PIDGEY")
|
||||
menu = openBox("withdraw")
|
||||
reset()
|
||||
tap("a")
|
||||
tap("a", 12)
|
||||
report("03 withdraw, party full:", "Sfx_Wrong + \"You can't take any more\"")
|
||||
U.shot(game, out .. "/03-party-full.png")
|
||||
tap("a")
|
||||
close()
|
||||
|
||||
-- ---- 4. DEPOSIT into a full box: SFX_WRONG (:1790) ----------------------
|
||||
setParty(3)
|
||||
fillBox(1, Boxes.MONS_PER_BOX)
|
||||
menu = openBox("deposit")
|
||||
reset()
|
||||
tap("a")
|
||||
tap("a", 12)
|
||||
report("04 deposit, box full:", "Sfx_Wrong + \"The BOX is full.\"")
|
||||
U.shot(game, out .. "/04-box-full.png")
|
||||
tap("a")
|
||||
close()
|
||||
|
||||
-- ---- 5. DEPOSIT the last healthy mon: SFX_WRONG -------------------------
|
||||
setParty(2)
|
||||
save.party[2].hp = 0
|
||||
fillBox(1, 0)
|
||||
menu = openBox("deposit")
|
||||
reset()
|
||||
tap("a")
|
||||
tap("a", 12)
|
||||
report("05 deposit, last healthy:", "Sfx_Wrong + \"can't deposit the last\"")
|
||||
U.shot(game, out .. "/05-last-mon.png")
|
||||
tap("a")
|
||||
close()
|
||||
|
||||
-- ---- 6. RELEASE a boxed mon: its cry (:1866) ----------------------------
|
||||
setParty(3)
|
||||
fillBox(1, 1, "SENTRET")
|
||||
menu = openBox("withdraw")
|
||||
reset()
|
||||
tap("select", 8) -- RELEASE asks first
|
||||
tap("up") -- the YES/NO box defaults to NO
|
||||
tap("a", 14)
|
||||
report("06 release:", "cry:SENTRET before \"was released.\"")
|
||||
U.shot(game, out .. "/06-release-cry.png")
|
||||
tap("a")
|
||||
close()
|
||||
|
||||
-- ---- 7. RELEASE an EGG: SFX_WRONG, no cry (:1625) -----------------------
|
||||
setParty(3)
|
||||
fillBox(1, 0)
|
||||
local egg = mon("TOGEPI", 5)
|
||||
egg.isEgg = true
|
||||
Boxes.box(save, 1)[1] = egg
|
||||
menu = openBox("withdraw")
|
||||
reset()
|
||||
tap("select", 10)
|
||||
report("07 release an EGG:", "Sfx_Wrong, and NO cry:TOGEPI")
|
||||
U.shot(game, out .. "/07-release-egg.png")
|
||||
tap("a")
|
||||
close()
|
||||
|
||||
-- ---- 8. MOVE into a full box: SFX_WRONG (:1567) -------------------------
|
||||
setParty(3)
|
||||
fillBox(1, 1, "HOOTHOOT")
|
||||
fillBox(2, Boxes.MONS_PER_BOX)
|
||||
menu = openBox("move")
|
||||
reset()
|
||||
tap("a") -- the MOVE/STATS/CANCEL submenu
|
||||
tap("a", 8) -- MOVE -> the insert cursor
|
||||
tap("right", 8) -- box 2, which is full
|
||||
tap("a", 10)
|
||||
report("08 move into a full box:", "Sfx_Wrong + \"There's no room!\"")
|
||||
U.shot(game, out .. "/08-no-room.png")
|
||||
|
||||
Sound.play, Sound.playCry = realPlay, realCry
|
||||
U.log("done -- the PC is open; the controls are yours")
|
||||
end
|
||||
@@ -0,0 +1,226 @@
|
||||
-- #1556: the bump sound, which the Gen 2 port never wired.
|
||||
--
|
||||
-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold POKEPORT_TOUCH=0 \
|
||||
-- POKEPORT_DRIVER=tests/drivers/gold_bug1556_bump.lua \
|
||||
-- perl -e 'alarm 420; exec @ARGV' \
|
||||
-- python3 -c "import pty; pty.spawn(['love','.'])"
|
||||
-- POKEPORT_SHOT_DIR=/tmp/gold-bug1556-bump (default)
|
||||
--
|
||||
-- Nothing here can be asserted by a unit test: the deliverable is the sound.
|
||||
-- What the log gives a reader is the RATE, which is the half that is easy to
|
||||
-- get wrong -- .BumpSound is `call CheckSFX / ret c`
|
||||
-- (engine/overworld/player_movement.asm:771), a busy-channel test rather than
|
||||
-- a frame counter, so a held direction re-rings only once the previous Sfx_Bump
|
||||
-- has stopped. A per-frame count means Sound.sfxBusy() is not seeing it.
|
||||
--
|
||||
-- Five moments, in the cart's own terms:
|
||||
-- 1. a wall .CheckLandPerms carry -> .bump (:264-265)
|
||||
-- 2. an NPC .CheckNPC = 0 -> .bump (:267-269)
|
||||
-- 3. a ledge hop .TryJump's carry returns ABOVE .NotMoving (:80-81),
|
||||
-- so the hop is Sfx_JumpOverLedge and NO bump
|
||||
-- 4. a map connection the step is taken; silence
|
||||
-- 5. a map edge with no connection behind it: the border block is a wall
|
||||
--
|
||||
-- The run ends in the overworld so a human takes the controls where it stops.
|
||||
local U = require("tests.drivers.util")
|
||||
|
||||
local Permissions = require("src.world.gen2.Permissions")
|
||||
local Sound = require("src.core.Sound")
|
||||
|
||||
return function(game)
|
||||
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-bug1556-bump"
|
||||
|
||||
local heard = {}
|
||||
local realPlay = Sound.play
|
||||
Sound.play = function(data, name)
|
||||
heard[#heard + 1] = name
|
||||
return realPlay(data, name)
|
||||
end
|
||||
|
||||
local function reset() heard = {} end
|
||||
|
||||
-- Re-entering a map re-fires its entry script, and a held direction would
|
||||
-- feed that textbox instead of the walk. World:busy() is the same gate the
|
||||
-- overworld's own input uses.
|
||||
local function settle()
|
||||
local calm = 0
|
||||
for _ = 1, 400 do
|
||||
if game.world:busy() then
|
||||
calm = 0
|
||||
U.tap(game, "a")
|
||||
U.wait(2)
|
||||
else
|
||||
calm = calm + 1
|
||||
if calm >= 12 then break end
|
||||
U.wait(2)
|
||||
end
|
||||
end
|
||||
U.wait(6)
|
||||
end
|
||||
|
||||
local function report(label)
|
||||
local counts, order = {}, {}
|
||||
for _, name in ipairs(heard) do
|
||||
if not counts[name] then order[#order + 1] = name end
|
||||
counts[name] = (counts[name] or 0) + 1
|
||||
end
|
||||
local parts = {}
|
||||
for _, name in ipairs(order) do
|
||||
parts[#parts + 1] = ("%s x%d"):format(name, counts[name])
|
||||
end
|
||||
U.log(label, #parts > 0 and table.concat(parts, ", ") or "(silence)")
|
||||
return counts
|
||||
end
|
||||
|
||||
U.wait(45)
|
||||
local w = game.world
|
||||
assert(w and w.map, "gold world did not boot")
|
||||
|
||||
local function walkable(map, x, y)
|
||||
return map:inBounds(x, y) and map:isWalkable(x, y)
|
||||
end
|
||||
|
||||
local DELTA = { up = { 0, -1 }, down = { 0, 1 },
|
||||
left = { -1, 0 }, right = { 1, 0 } }
|
||||
|
||||
-- A standing cell whose neighbour in `dir` is a wall.
|
||||
local function findWall(map)
|
||||
for y = 0, map.heightCells - 1 do
|
||||
for x = 0, map.widthCells - 1 do
|
||||
if walkable(map, x, y) then
|
||||
for dir, d in pairs(DELTA) do
|
||||
local nx, ny = x + d[1], y + d[2]
|
||||
if map:inBounds(nx, ny) and not map:isWalkable(nx, ny) then
|
||||
return x, y, dir
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- ---- 1. a wall -----------------------------------------------------------
|
||||
assert(w:setMap("NEW_BARK_TOWN", 5, 5, "down"), "setMap NEW_BARK_TOWN")
|
||||
U.wait(5)
|
||||
local wx, wy, wdir = findWall(w.map)
|
||||
if wx then
|
||||
assert(w:setMap("NEW_BARK_TOWN", wx, wy, wdir))
|
||||
U.wait(8)
|
||||
settle()
|
||||
reset()
|
||||
U.hold(game, wdir, 90)
|
||||
U.wait(5)
|
||||
local counts = report("01 wall (" .. wdir .. ", 90 frames held):")
|
||||
U.log(" Sfx_Bump over 90 held frames:", counts.Sfx_Bump or 0,
|
||||
"-- a per-frame count here means the CheckSFX gate is not working")
|
||||
U.shot(game, out .. "/01-wall.png")
|
||||
else
|
||||
U.log("SKIP 01 wall -- no wall cell found in NEW_BARK_TOWN")
|
||||
end
|
||||
|
||||
-- ---- 2. an NPC -----------------------------------------------------------
|
||||
local npc, npcDir
|
||||
for _, e in ipairs(w.entities or {}) do
|
||||
if e ~= w.player and e.cellX and not e.bigObject then
|
||||
for dir, d in pairs(DELTA) do
|
||||
local sx, sy = e.cellX - d[1], e.cellY - d[2]
|
||||
if walkable(w.map, sx, sy) then npc, npcDir = { sx, sy }, dir break end
|
||||
end
|
||||
end
|
||||
if npc then break end
|
||||
end
|
||||
if npc then
|
||||
assert(w:setMap("NEW_BARK_TOWN", npc[1], npc[2], npcDir))
|
||||
U.wait(8)
|
||||
settle()
|
||||
reset()
|
||||
U.hold(game, npcDir, 45)
|
||||
U.wait(5)
|
||||
report("02 NPC (" .. npcDir .. "):")
|
||||
U.shot(game, out .. "/02-npc.png")
|
||||
else
|
||||
U.log("SKIP 02 NPC -- no reachable NPC in NEW_BARK_TOWN")
|
||||
end
|
||||
|
||||
-- ---- 3. a ledge ----------------------------------------------------------
|
||||
local ROUTES = { "ROUTE_29", "ROUTE_30", "ROUTE_31", "ROUTE_32",
|
||||
"ROUTE_33", "ROUTE_34", "ROUTE_35", "ROUTE_36" }
|
||||
local hopped = false
|
||||
for _, mapId in ipairs(ROUTES) do
|
||||
if w:setMap(mapId, 5, 5, "down") then
|
||||
U.wait(5)
|
||||
local map = w.map
|
||||
for y = 0, map.heightCells - 1 do
|
||||
for x = 0, map.widthCells - 1 do
|
||||
local facings = Permissions.ledgeFacings(map:cellCollision(x, y))
|
||||
if facings then
|
||||
for dir, on in pairs(facings) do
|
||||
local d = on and DELTA[dir]
|
||||
if d and walkable(map, x + d[1] * 2, y + d[2] * 2) then
|
||||
assert(w:setMap(mapId, x, y, dir))
|
||||
U.wait(8)
|
||||
settle()
|
||||
reset()
|
||||
U.hold(game, dir, 40)
|
||||
U.wait(20)
|
||||
local counts = report(
|
||||
("03 ledge hop (%s %s):"):format(mapId, dir))
|
||||
U.log(" want Sfx_JumpOverLedge and NO Sfx_Bump; bumps:",
|
||||
counts.Sfx_Bump or 0)
|
||||
U.shot(game, out .. "/03-ledge.png")
|
||||
hopped = true
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
if hopped then break end
|
||||
end
|
||||
if hopped then break end
|
||||
end
|
||||
end
|
||||
if hopped then break end
|
||||
end
|
||||
if not hopped then U.log("SKIP 03 ledge -- no hoppable ledge found") end
|
||||
|
||||
-- ---- 4 and 5. map edges --------------------------------------------------
|
||||
assert(w:setMap("NEW_BARK_TOWN", 5, 5, "down"), "setMap NEW_BARK_TOWN")
|
||||
U.wait(5)
|
||||
local DIR_CONN = { up = "north", down = "south", left = "west",
|
||||
right = "east" }
|
||||
local map = w.map
|
||||
local function edgeCell(dir)
|
||||
local d = DELTA[dir]
|
||||
for y = 0, map.heightCells - 1 do
|
||||
for x = 0, map.widthCells - 1 do
|
||||
if walkable(map, x, y)
|
||||
and not map:inBounds(x + d[1], y + d[2]) then
|
||||
return x, y
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
for _, dir in ipairs({ "up", "down", "left", "right" }) do
|
||||
local conn = map:connection(DIR_CONN[dir])
|
||||
local ex, ey = edgeCell(dir)
|
||||
if ex then
|
||||
assert(w:setMap("NEW_BARK_TOWN", ex, ey, dir))
|
||||
U.wait(8)
|
||||
settle()
|
||||
reset()
|
||||
U.hold(game, dir, 40)
|
||||
U.wait(20)
|
||||
local counts = report(("%s edge %s (connection: %s):")
|
||||
:format(conn and "04" or "05", dir,
|
||||
tostring(conn and conn.mapId or "none")))
|
||||
U.log(" want", conn and "silence" or "Sfx_Bump", "-- bumps:",
|
||||
counts.Sfx_Bump or 0)
|
||||
U.shot(game, out .. ("/0%s-edge-%s.png"):format(conn and "4" or "5", dir))
|
||||
assert(w:setMap("NEW_BARK_TOWN", ex, ey, dir))
|
||||
U.wait(5)
|
||||
end
|
||||
end
|
||||
|
||||
Sound.play = realPlay
|
||||
assert(w:setMap("NEW_BARK_TOWN", 5, 5, "down"))
|
||||
U.log("done -- the controls are yours; walk into anything")
|
||||
end
|
||||
@@ -0,0 +1,228 @@
|
||||
-- #1556: the field jingles that ride a TX_SOUND at the end of a string --
|
||||
-- RARE CANDY's level-up, "X learned MOVE!", the trophy -- plus the TM/HM
|
||||
-- refusals, which are `call PlaySFX` next to the message instead.
|
||||
--
|
||||
-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold POKEPORT_TOUCH=0 \
|
||||
-- POKEPORT_DRIVER=tests/drivers/gold_bug1556_fanfare.lua \
|
||||
-- perl -e 'alarm 420; exec @ARGV' \
|
||||
-- python3 -c "import pty; pty.spawn(['love','.'])"
|
||||
-- POKEPORT_SHOT_DIR=/tmp/gold-bug1556-fanfare (default)
|
||||
--
|
||||
-- The one deliberate SILENCE is moment 4: TeachTMHM's `.compatible` arm is
|
||||
-- `callfar KnowsMove / jr c, .nope` (engine/items/tmhm.asm:139-140) with no
|
||||
-- PlaySFX at all, so "X already knows MOVE!" must stay mute. Only the
|
||||
-- INCOMPATIBLE arm rings (:131).
|
||||
--
|
||||
-- The box is supposed to HOLD for the fanfare before it takes a button:
|
||||
-- TextCommand_SOUND is PlaySFX then WaitSFX (home/text.asm), which
|
||||
-- TextBox.soundOpts models with auto.wait.
|
||||
local U = require("tests.drivers.util")
|
||||
|
||||
local Mon = require("src.battle.gen2.Mon")
|
||||
local PackMenu = require("src.ui.gen2.PackMenu")
|
||||
local Sound = require("src.core.Sound")
|
||||
|
||||
return function(game)
|
||||
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-bug1556-fanfare"
|
||||
|
||||
local heard = {}
|
||||
local realPlay = Sound.play
|
||||
Sound.play = function(data, name)
|
||||
heard[#heard + 1] = name
|
||||
return realPlay(data, name)
|
||||
end
|
||||
local function reset() heard = {} end
|
||||
local function report(label, want)
|
||||
U.log(label, #heard > 0 and table.concat(heard, ", ") or "(silence)")
|
||||
U.log(" want:", want)
|
||||
end
|
||||
|
||||
local function tap(button, frames)
|
||||
game.input.pressQueue[#game.input.pressQueue + 1] = button
|
||||
game.input.state[button] = true
|
||||
U.wait(2)
|
||||
game.input.state[button] = false
|
||||
U.wait(frames or 6)
|
||||
end
|
||||
|
||||
-- TextCommand_SOUND rings when the box FINISHES printing (home/text.asm),
|
||||
-- so a report taken mid-print reads as silence.
|
||||
local function awaitSfx(name, frames)
|
||||
for _ = 1, (frames or 90) do
|
||||
for _, n in ipairs(heard) do if n == name then return true end end
|
||||
U.wait(2)
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
U.wait(45)
|
||||
local w = game.world
|
||||
assert(w and w.map, "gold world did not boot")
|
||||
local save, data = game.save, game.data
|
||||
|
||||
local SPECIES = "CYNDAQUIL"
|
||||
local def = data.pokemon and data.pokemon[SPECIES]
|
||||
assert(def, "the cache carries no " .. SPECIES)
|
||||
|
||||
-- Which TMs this species may and may not learn, straight off its BASE_TMHM.
|
||||
local canLearn, cannotLearn = nil, nil
|
||||
local allowed = {}
|
||||
for _, moveId in ipairs(def.tmhm or {}) do allowed[moveId] = true end
|
||||
local tmIds = {}
|
||||
for itemId, item in pairs(data.items or {}) do
|
||||
if type(item) == "table" and item.teaches
|
||||
and tostring(itemId):sub(1, 3) == "TM_" then
|
||||
tmIds[#tmIds + 1] = itemId
|
||||
end
|
||||
end
|
||||
table.sort(tmIds)
|
||||
for _, itemId in ipairs(tmIds) do
|
||||
if allowed[data.items[itemId].teaches] then
|
||||
canLearn = canLearn or itemId
|
||||
else
|
||||
cannotLearn = cannotLearn or itemId
|
||||
end
|
||||
end
|
||||
U.log("TM that fits:", tostring(canLearn),
|
||||
"-- TM that does not:", tostring(cannotLearn))
|
||||
|
||||
local function seed()
|
||||
local mon = Mon.new(data, SPECIES, 12)
|
||||
save.party = { mon }
|
||||
return mon
|
||||
end
|
||||
|
||||
local function openPack()
|
||||
local pack = PackMenu.new(game, {
|
||||
save = save, world = w, onClose = function() end })
|
||||
game.stack:push(pack)
|
||||
U.wait(6)
|
||||
return pack
|
||||
end
|
||||
|
||||
local field = game.overworld or game.stack:top()
|
||||
local function popToField()
|
||||
for _ = 1, 12 do
|
||||
if game.stack:top() == field or not game.stack:top() then break end
|
||||
game.stack:pop()
|
||||
U.wait(2)
|
||||
end
|
||||
end
|
||||
|
||||
-- ---- 1. RARE CANDY: "X grew to level N!" + the level-up fanfare ---------
|
||||
local mon = seed()
|
||||
save.inventory = { RARE_CANDY = 2 }
|
||||
reset()
|
||||
game:usePartyItem("RARE_CANDY")
|
||||
U.wait(8)
|
||||
tap("a", 30) -- the party pick
|
||||
awaitSfx("Sfx_DexFanfare5079")
|
||||
report("01 rare candy:", "Sfx_DexFanfare5079 under \"grew to level 13!\"")
|
||||
U.shot(game, out .. "/01-rare-candy.png")
|
||||
tap("a", 10)
|
||||
popToField()
|
||||
|
||||
-- ---- 2. a TM the mon CAN learn: "X learned MOVE!" + the fanfare ---------
|
||||
if canLearn then
|
||||
mon = seed()
|
||||
-- A full moveset diverts into MoveAskForgetText (engine/pokemon/learn.asm:
|
||||
-- 110-137); the direct-learn arm is what carries the fanfare.
|
||||
while #mon.moves > 2 do table.remove(mon.moves) end
|
||||
save.inventory = { [canLearn] = 1 }
|
||||
local pack = openPack()
|
||||
reset()
|
||||
pack:openTeachParty({ id = canLearn })
|
||||
U.wait(10)
|
||||
tap("a", 30) -- the party pick
|
||||
awaitSfx("Sfx_DexFanfare5079")
|
||||
report("02 TM " .. canLearn .. ":",
|
||||
"Sfx_DexFanfare5079 under \"learned ...!\"")
|
||||
U.shot(game, out .. "/02-tm-learned.png")
|
||||
tap("a", 10)
|
||||
popToField()
|
||||
else
|
||||
U.log("SKIP 02 -- no TM in this cache that " .. SPECIES .. " can learn")
|
||||
end
|
||||
|
||||
-- ---- 3. a TM the mon CANNOT learn: SFX_WRONG (tmhm.asm:131) ------------
|
||||
if cannotLearn then
|
||||
mon = seed()
|
||||
save.inventory = { [cannotLearn] = 1 }
|
||||
local pack = openPack()
|
||||
reset()
|
||||
pack:openTeachParty({ id = cannotLearn })
|
||||
U.wait(10)
|
||||
tap("a", 20)
|
||||
report("03 TM " .. cannotLearn .. ":",
|
||||
"Sfx_Wrong under \"can't learn ...!\"")
|
||||
U.shot(game, out .. "/03-tm-refused.png")
|
||||
tap("a", 10)
|
||||
popToField()
|
||||
else
|
||||
U.log("SKIP 03 -- every TM in this cache fits " .. SPECIES)
|
||||
end
|
||||
|
||||
-- ---- 4. a TM the mon ALREADY KNOWS: SILENCE ----------------------------
|
||||
if canLearn then
|
||||
mon = seed()
|
||||
local moveId = data.items[canLearn].teaches
|
||||
mon.moves = mon.moves or {}
|
||||
mon.moves[1] = { id = moveId, pp = 10, maxPp = 10 }
|
||||
save.inventory = { [canLearn] = 1 }
|
||||
local pack = openPack()
|
||||
reset()
|
||||
pack:openTeachParty({ id = canLearn })
|
||||
U.wait(10)
|
||||
tap("a", 20)
|
||||
report("04 TM already known:", "SILENCE -- KnowsMove has no PlaySFX")
|
||||
U.shot(game, out .. "/04-tm-already-known.png")
|
||||
tap("a", 10)
|
||||
popToField()
|
||||
end
|
||||
|
||||
-- ---- 5. a TM on an EGG: SFX_WRONG (tmhm.asm:104-111), a regression -----
|
||||
if canLearn then
|
||||
mon = seed()
|
||||
mon.isEgg = true
|
||||
save.inventory = { [canLearn] = 1 }
|
||||
local pack = openPack()
|
||||
reset()
|
||||
pack:openTeachParty({ id = canLearn })
|
||||
U.wait(10)
|
||||
tap("a", 20)
|
||||
report("05 TM on an EGG:", "Sfx_Wrong, and the list stays up")
|
||||
U.shot(game, out .. "/05-tm-egg.png")
|
||||
tap("b", 6)
|
||||
popToField()
|
||||
end
|
||||
|
||||
-- ---- 6. the trophy box: _SentTrophyHomeText's fanfare ------------------
|
||||
if data.items and data.items.NORMAL_BOX then
|
||||
seed()
|
||||
save.inventory = { NORMAL_BOX = 1 }
|
||||
save.decorations = save.decorations or {}
|
||||
local pack = openPack()
|
||||
pack:rebuild()
|
||||
local row
|
||||
for index, entry in ipairs(pack.rows) do
|
||||
if entry.id == "NORMAL_BOX" then row = index end
|
||||
end
|
||||
if row then
|
||||
pack.index = row
|
||||
reset()
|
||||
pack:useSelected()
|
||||
U.wait(20)
|
||||
report("06 trophy box:", "Sfx_DexFanfare5079 with the trophy line")
|
||||
U.shot(game, out .. "/06-trophy.png")
|
||||
tap("a", 6)
|
||||
else
|
||||
U.log("SKIP 06 -- the NORMAL BOX is not in the ITEM pocket here")
|
||||
end
|
||||
popToField()
|
||||
else
|
||||
U.log("SKIP 06 -- this cache has no NORMAL_BOX")
|
||||
end
|
||||
|
||||
Sound.play = realPlay
|
||||
U.log("done -- the controls are yours")
|
||||
end
|
||||
@@ -0,0 +1,105 @@
|
||||
-- #1567: the TM/HM pocket's row order.
|
||||
--
|
||||
-- POKEPORT_GAME=gold POKEPORT_TOUCH=0 \
|
||||
-- POKEPORT_DRIVER=tests/drivers/gold_bug1567_test.lua love .
|
||||
-- POKEPORT_SHOT_DIR=/tmp/gold-bug1567 (default)
|
||||
--
|
||||
-- Nothing here can be asserted: the bug IS the order rows land in.
|
||||
-- TMHM_DisplayPocketItems walks the fixed 57-byte wTMsHMs array from 1 to 57
|
||||
-- and prints every non-zero slot (engine/items/tmhm.asm:341), so the cart's
|
||||
-- pocket is always TM01..TM50 then HM01..HM07 no matter what the player picked
|
||||
-- up first. The port drew it from the acquisition-ordered bag list instead.
|
||||
--
|
||||
-- The seed is deliberately scrambled -- gold_bug1425_test.lua seeds its TMs
|
||||
-- already in numeric order, which is why its screenshots never showed this.
|
||||
--
|
||||
-- The run ends with the PACK still open on the TM pocket, so a human takes the
|
||||
-- controls exactly where the screenshots stop.
|
||||
local U = require("tests.drivers.util")
|
||||
|
||||
local Bag = require("src.inventory.Bag")
|
||||
local PackMenu = require("src.ui.gen2.PackMenu")
|
||||
|
||||
-- Picked up back to front: TM/HM numbers 57, 50, 51, 5, 1, 53. TM_ROAR is the
|
||||
-- one on the far side of the ITEM_C3 hole (id $c4, number 5), so it also pins
|
||||
-- that the number and not the item id is what sorts.
|
||||
local SEED = {
|
||||
{ "HM_WATERFALL", 1 },
|
||||
{ "TM_NIGHTMARE", 2 },
|
||||
{ "HM_CUT", 1 },
|
||||
{ "TM_ROAR", 3 },
|
||||
{ "TM_DYNAMICPUNCH", 12 },
|
||||
{ "HM_SURF", 1 },
|
||||
}
|
||||
|
||||
return function(game)
|
||||
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-bug1567"
|
||||
|
||||
local function shot(name)
|
||||
U.wait(3)
|
||||
U.shot(game, ("%s/%s.png"):format(out, name))
|
||||
end
|
||||
|
||||
U.wait(45)
|
||||
assert(game.world and game.world.map, "gold world did not boot")
|
||||
|
||||
local save = game.save
|
||||
save.inventory = {}
|
||||
save.bagOrder = {}
|
||||
for _, entry in ipairs(SEED) do
|
||||
local id, count = entry[1], entry[2]
|
||||
if not (game.data.items and game.data.items[id]) then
|
||||
U.log("[driver] SKIP", id, "-- not in this cache")
|
||||
else
|
||||
save.inventory[id] = count
|
||||
table.insert(Bag.order(save), id)
|
||||
end
|
||||
end
|
||||
-- A POTION and a SUPER POTION so the ITEM pocket can be compared against the
|
||||
-- TM pocket at the end: SELECT must still arm a row over there.
|
||||
for _, entry in ipairs({ { "POTION", 5 }, { "SUPER_POTION", 2 } }) do
|
||||
if game.data.items and game.data.items[entry[1]] then
|
||||
save.inventory[entry[1]] = entry[2]
|
||||
table.insert(Bag.order(save), entry[1])
|
||||
end
|
||||
end
|
||||
|
||||
U.log("[driver] pickup order:", table.concat(Bag.order(save), " "))
|
||||
|
||||
local pack = PackMenu.new(game, { save = save, world = game.world,
|
||||
onClose = function() end })
|
||||
game.stack:push(pack)
|
||||
|
||||
shot("00-items") -- POTION over SUPER POTION, pickup order
|
||||
U.tap(game, "right")
|
||||
U.tap(game, "right")
|
||||
U.tap(game, "right")
|
||||
-- TM01 DYNAMICPUNCH ×12, TM05 ROAR ×03, TM50 NIGHTMARE ×02, then HM01 CUT,
|
||||
-- HM03 SURF, HM07 WATERFALL with no ×NN at all (engine/items/tmhm.asm:390).
|
||||
-- Before the fix this read HM07, TM50, HM01, TM05, TM01, HM03.
|
||||
shot("01-tmhm-numbered")
|
||||
|
||||
local order = {}
|
||||
for i = 1, #pack.rows do order[i] = pack.rows[i].id end
|
||||
U.log("[driver] TM/HM rows:", table.concat(order, " "))
|
||||
|
||||
-- engine/items/tmhm.asm:207 filters SELECT out of this pocket: no hollow ▷,
|
||||
-- no "Where should this be moved to?".
|
||||
U.tap(game, "select")
|
||||
shot("02-tmhm-select-ignored")
|
||||
U.tap(game, "down")
|
||||
U.tap(game, "select")
|
||||
shot("03-tmhm-select-ignored-row2")
|
||||
|
||||
-- The ITEM pocket, where SELECT still arms (engine/items/pack.asm:1290).
|
||||
U.tap(game, "right")
|
||||
U.tap(game, "select")
|
||||
shot("04-items-select-arms")
|
||||
U.tap(game, "b")
|
||||
|
||||
-- Back to the TM pocket for the human.
|
||||
U.tap(game, "right")
|
||||
U.tap(game, "right")
|
||||
U.tap(game, "right")
|
||||
U.log("[driver] shots in " .. out .. " -- the PACK is yours")
|
||||
end
|
||||
@@ -0,0 +1,197 @@
|
||||
-- #1513: exp credit has to be per THIS appearance of the enemy's active mon.
|
||||
-- ResetBattleParticipants falls through into AddBattleParticipant
|
||||
-- (engine/battle/core.asm:3033 and :3037), and AI_Switch farcalls it right
|
||||
-- after EnemySwitch (engine/battle/ai/items.asm:697), so the moment the rival
|
||||
-- rotates, the only mon still credited is the one the player has on the field.
|
||||
--
|
||||
-- POKEPORT_GAME=gold POKEPORT_TOUCH=0 POKEPORT_IDENTITY=gold-dev \
|
||||
-- POKEPORT_DRIVER=tests/drivers/gold_exp_participants_bug1513.lua love .
|
||||
--
|
||||
-- What to look for, in order:
|
||||
-- 00 CYNDAQUIL is out against JOEY's GEODUDE.
|
||||
-- 01 TOTODILE has been switched in through the real party menu, so BOTH
|
||||
-- party mons have now "met" GEODUDE.
|
||||
-- 02 "JOEY withdrew GEODUDE!" -- the rotation the ticket is about.
|
||||
-- 03 the exp lines for the KO of the mon that came IN (PIDGEY). Only
|
||||
-- TOTODILE may be named here. A "CYNDAQUIL gained N EXP. Points!" line
|
||||
-- in this shot is the bug.
|
||||
-- 04 the party screen: CYNDAQUIL's level and EXP bar are pixel-identical to
|
||||
-- shot 01's, TOTODILE's have moved.
|
||||
-- 05 GEODUDE comes back out and is KO'd. CYNDAQUIL still gains nothing --
|
||||
-- that half is correct cart behavior (ram/wram.asm:775-776, "All bits
|
||||
-- cleared if the enemy faints") and must NOT be "fixed".
|
||||
local U = require("tests.drivers.util")
|
||||
|
||||
local Mon = require("src.battle.gen2.Mon")
|
||||
|
||||
local function battleScreen(game)
|
||||
for _ = 1, 900 do
|
||||
local top = game.stack:top()
|
||||
if top and top.battle then return top end
|
||||
U.wait(1)
|
||||
end
|
||||
error("battle screen never came up")
|
||||
end
|
||||
|
||||
local function runToPhase(game, screen, phase, frames)
|
||||
for _ = 1, (frames or 600) do
|
||||
if screen.phase == phase then return true end
|
||||
if screen.battle.over then return false end
|
||||
U.tap(game, "a")
|
||||
U.wait(3)
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- Page the queue out, shooting the first box that carries `needle`.
|
||||
local function pageUntilMenu(game, screen, out, needles)
|
||||
local shot = {}
|
||||
for _ = 1, 1200 do
|
||||
local message = screen.message
|
||||
if message then
|
||||
for needle, path in pairs(needles) do
|
||||
if not shot[needle] and message:find(needle, 1, true) then
|
||||
U.shot(game, out .. "/" .. path)
|
||||
shot[needle] = message
|
||||
end
|
||||
end
|
||||
end
|
||||
if screen.phase == "menu" or screen.phase == "done"
|
||||
or screen.battle.over then
|
||||
break
|
||||
end
|
||||
U.tap(game, "a")
|
||||
U.wait(3)
|
||||
end
|
||||
return shot
|
||||
end
|
||||
|
||||
return function(game)
|
||||
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-bug1513"
|
||||
U.wait(45)
|
||||
local world = game.world
|
||||
assert(world and world.map, "gold world did not boot")
|
||||
local failures = {}
|
||||
local function check(ok, what)
|
||||
print((ok and "[ok] " or "[FAIL] ") .. what)
|
||||
if not ok then failures[#failures + 1] = what end
|
||||
end
|
||||
|
||||
local lead = Mon.new(game.data, "CYNDAQUIL", 20)
|
||||
lead.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } }
|
||||
local bench = Mon.new(game.data, "TOTODILE", 20)
|
||||
bench.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } }
|
||||
game.save.party = { lead, bench }
|
||||
|
||||
local foe1 = Mon.new(game.data, "GEODUDE", 8)
|
||||
foe1.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } }
|
||||
local foe2 = Mon.new(game.data, "PIDGEY", 8)
|
||||
foe2.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } }
|
||||
-- attributes[6] is the low byte of TRNATTR_AI_ITEM_SWITCH: OFTEN
|
||||
-- (src/battle/gen2/Ai.lua:1307). The real Rival1 class is SWITCH_SOMETIMES
|
||||
-- (pokegold:data/trainers/attributes.asm), the same path at a lower roll.
|
||||
assert(world:startBattle({ trainer = { class = "YOUNGSTER", name = "JOEY",
|
||||
party = { foe1, foe2 }, attributes = { 0, 0, 0, 0, 0, 0x01, 0 } } }),
|
||||
"trainer startBattle failed")
|
||||
local screen = battleScreen(game)
|
||||
assert(runToPhase(game, screen, "menu", 400), "never reached the menu")
|
||||
U.shot(game, out .. "/00-lead-out.png")
|
||||
|
||||
------------------------------------------------- the player's own switch
|
||||
-- Through the real party menu, so the human sees the same path they used.
|
||||
screen:chooseMenu("party")
|
||||
for _ = 1, 240 do
|
||||
if screen.battle.player == bench then break end
|
||||
local top = game.stack:top()
|
||||
if top ~= screen then
|
||||
U.tap(game, "down")
|
||||
U.wait(3)
|
||||
U.tap(game, "a")
|
||||
U.wait(6)
|
||||
U.tap(game, "a")
|
||||
U.wait(6)
|
||||
else
|
||||
U.wait(2)
|
||||
end
|
||||
end
|
||||
check(screen.battle.player == bench, "TOTODILE was switched in")
|
||||
check(screen.battle.participants[1] and screen.battle.participants[2],
|
||||
"both party mons are credited against GEODUDE")
|
||||
pageUntilMenu(game, screen, out, {})
|
||||
U.shot(game, out .. "/01-switched.png")
|
||||
print(("[driver] before the rotation: cyndaquil %d totodile %d")
|
||||
:format(lead.experience, bench.experience))
|
||||
local leadExp = lead.experience
|
||||
local benchExp = bench.experience
|
||||
|
||||
------------------------------------------------------- the AI's rotation
|
||||
-- Perish Song at one turn left is CheckAbleToSwitch's maximum score, the
|
||||
-- cheapest way to make the AI commit to the rotation on demand.
|
||||
screen.battle:volatile(screen.battle.enemy).perish = 1
|
||||
screen:submit({ kind = "move", move = "TACKLE" })
|
||||
local seen = pageUntilMenu(game, screen, out, {
|
||||
["withdrew"] = "02-rival-withdrew.png",
|
||||
["gained"] = "03-exp-lines.png",
|
||||
})
|
||||
check(seen["withdrew"] ~= nil, "JOEY withdrew GEODUDE")
|
||||
check(screen.battle.enemy == foe2, "and sent PIDGEY out")
|
||||
check(screen.battle.participants[1] == nil,
|
||||
"CYNDAQUIL lost its credit when the rival rotated")
|
||||
check(screen.battle.participants[2] == true,
|
||||
"and TOTODILE, the mon on the field, kept it")
|
||||
|
||||
------------------------------------------------------------ the KO payout
|
||||
if not screen.battle.over and (foe2.hp or 0) > 0 then
|
||||
if screen.phase ~= "menu" then
|
||||
runToPhase(game, screen, "menu", 400)
|
||||
end
|
||||
foe2.hp = 1
|
||||
screen:submit({ kind = "move", move = "TACKLE" })
|
||||
local paid = pageUntilMenu(game, screen, out, {
|
||||
["gained"] = "03-exp-lines.png",
|
||||
})
|
||||
if paid["gained"] then print("[driver] exp box: " .. paid["gained"]) end
|
||||
end
|
||||
print(("[driver] after the PIDGEY KO: cyndaquil %d totodile %d")
|
||||
:format(lead.experience, bench.experience))
|
||||
check(lead.experience == leadExp,
|
||||
"CYNDAQUIL earned NOTHING from the mon it never faced")
|
||||
check(bench.experience > benchExp, "TOTODILE was paid for it")
|
||||
|
||||
if screen.phase == "menu" then
|
||||
screen:chooseMenu("party")
|
||||
U.wait(20)
|
||||
U.shot(game, out .. "/04-party-exp.png")
|
||||
U.tap(game, "b")
|
||||
U.wait(10)
|
||||
if game.stack:top() ~= screen then
|
||||
U.tap(game, "b")
|
||||
U.wait(10)
|
||||
end
|
||||
end
|
||||
|
||||
------------------------------------------- GEODUDE comes back and faints
|
||||
-- Correct cart behavior: no residual credit for a previous appearance.
|
||||
leadExp = lead.experience
|
||||
benchExp = bench.experience
|
||||
if not screen.battle.over then
|
||||
runToPhase(game, screen, "menu", 600)
|
||||
if screen.phase == "menu" and screen.battle.enemy == foe1 then
|
||||
foe1.hp = 1
|
||||
screen:submit({ kind = "move", move = "TACKLE" })
|
||||
pageUntilMenu(game, screen, out, { ["gained"] = "05-geodude-ko.png" })
|
||||
print(("[driver] after the GEODUDE KO: cyndaquil %d totodile %d")
|
||||
:format(lead.experience, bench.experience))
|
||||
check(lead.experience == leadExp,
|
||||
"CYNDAQUIL still earns nothing when GEODUDE comes back (correct)")
|
||||
check(bench.experience > benchExp, "and TOTODILE takes the whole share")
|
||||
else
|
||||
print("[driver] GEODUDE never came back out; second half skipped")
|
||||
end
|
||||
end
|
||||
|
||||
if #failures > 0 then
|
||||
error(#failures .. " exp participant checks failed")
|
||||
end
|
||||
print("[driver] #1513 fixed; shots in " .. out)
|
||||
end
|
||||
@@ -221,6 +221,13 @@ return function(game)
|
||||
stored[i] = mon(species, 10 + i)
|
||||
end
|
||||
Boxes.rename(save, 2, "GRASS")
|
||||
-- $5d for a held item, $5c for MAIL (engine/pokemon/bills_pc.asm:1079-1094).
|
||||
-- A boxed mon can never hold mail, so the letter goes on a party mon.
|
||||
stored[1].item = "BERRY"
|
||||
save.party[2].item = "FLOWER_MAIL"
|
||||
local Mail = require("src.core.gen2.Mail")
|
||||
Mail.set(save, 2, Mail.entry("FLOWER_MAIL", "HI THERE!",
|
||||
save.player.name or "GOLD", save.player.id or 0, save.party[2].species))
|
||||
|
||||
local pc = PcMenu.new(game, { save = save })
|
||||
show("20-pc-menu", pc)
|
||||
@@ -228,12 +235,27 @@ return function(game)
|
||||
pc.pickIndex = 2
|
||||
show("21-pc-changebox", pc)
|
||||
|
||||
show("22-pc-withdraw", BoxMenu.new(game, {
|
||||
save = save, mode = "withdraw",
|
||||
}))
|
||||
show("23-pc-deposit", BoxMenu.new(game, {
|
||||
save = save, mode = "deposit",
|
||||
}))
|
||||
-- Browsing paints the 7x7 block in BillsPCOrangePalette and only .PrepSubmenu
|
||||
-- swaps the mon's colours in (engine/pokemon/bills_pc.asm:305-309, :356-369,
|
||||
-- engine/gfx/cgb_layouts.asm:284-289); the icon is BG palette 0 either way.
|
||||
local wd = BoxMenu.new(game, { save = save, mode = "withdraw" })
|
||||
show("22-pc-withdraw", wd)
|
||||
wd.index = wd:total() -- CANCEL
|
||||
wd:ensureVisible()
|
||||
show("22b-pc-withdraw-cancel", wd)
|
||||
wd.index = 1
|
||||
wd:ensureVisible()
|
||||
wd.phase = "submenu"
|
||||
show("22c-pc-withdraw-submenu", wd)
|
||||
local dep = BoxMenu.new(game, { save = save, mode = "deposit" })
|
||||
show("23-pc-deposit", dep)
|
||||
dep.index = 2 -- the mon holding FLOWER MAIL
|
||||
show("23b-pc-deposit-mail", dep)
|
||||
|
||||
-- Only the move list gets the box-name arrows, $5f left and $5e right
|
||||
-- (engine/pokemon/bills_pc.asm:957-963); 22 and 23 above are the controls.
|
||||
local mv = BoxMenu.new(game, { save = save, mode = "move" })
|
||||
show("23c-pc-move-arrows", mv)
|
||||
|
||||
-- The two clock screens NEW GAME and Mom open (timeset.asm InitClock and
|
||||
-- SetDayOfWeek), each at its picker rather than at its opening page.
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
-- Driver: #1517, a Gen 2 stat block in a Gen 1 party.
|
||||
--
|
||||
-- Gen 1's party_struct carries one Special word (macros/ram.asm:28-37) and
|
||||
-- PrintStatsBox reads four fixed cells, wLoadedMonAttack/Defense/Speed/Special
|
||||
-- (engine/pokemon/status_screen.asm:238-287). Gen 2 splits that word into
|
||||
-- SpclAtk/SpclDef (pokegold macros/ram.asm:29-42), so a mod that wrote a Gen 2
|
||||
-- block over a Yellow party leaves the port with no `special` to print.
|
||||
--
|
||||
-- cp -R ~/Library/Application\ Support/LOVE/pokemon-love2d/yellow \
|
||||
-- ~/Library/Application\ Support/LOVE/bug1517/yellow
|
||||
-- POKEPORT_VERSION=yellow POKEPORT_GAME=yellow POKEPORT_IDENTITY=bug1517 \
|
||||
-- POKEPORT_TOUCH=0 POKEPORT_SPEED=8 SHOT_DIR=/tmp/bug1517 \
|
||||
-- POKEPORT_DRIVER=tests/drivers/summary_stats_bug1517_test.lua love .
|
||||
--
|
||||
-- The copy is not optional: a fresh POKEPORT_IDENTITY has no cache and the
|
||||
-- boot hangs with no output.
|
||||
--
|
||||
-- Pre-fix the LOVE window vanishes the moment STATS is chosen, with no error
|
||||
-- screen and nothing on stdout (that silence is the other half of #1517).
|
||||
-- Post-fix the status screen opens and SPECIAL reads the CalcStats value
|
||||
-- logged below. The run ends on the open screen, so a human takes the
|
||||
-- controls at exactly the frame the reporter's build died on.
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/bug1517"
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local Stats = require("src.pokemon.Stats")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local SummaryMenu = require("src.ui.SummaryMenu")
|
||||
|
||||
local function top() return game.stack:top() end
|
||||
|
||||
U.newGame(game)
|
||||
U.wait(20)
|
||||
|
||||
-- BUTTERFREE because the reporter's party led with one, and CONFUSION is a
|
||||
-- special move, so the same block would have crashed Damage.applyStage.
|
||||
local species = game.data.pokemon.BUTTERFREE and "BUTTERFREE" or "PIDGEY"
|
||||
local def = game.data.pokemon[species]
|
||||
local mon = Pokemon.new(game.data, species, 21)
|
||||
local want = Stats.calc(def, 21, mon.dvs, mon.statExp)
|
||||
|
||||
-- the shape src/battle/gen2/Mon.lua Mon.stats produces: no `special`
|
||||
mon.stats = {
|
||||
hp = want.hp, attack = want.attack, defense = want.defense,
|
||||
speed = want.speed, specialAttack = want.special, specialDefense = want.special,
|
||||
}
|
||||
mon.hp = mon.stats.hp
|
||||
game.save.party = { mon }
|
||||
U.log(("[1517] fixture: %s :L21, stats.special = %s, CalcStats says %d")
|
||||
:format(species, tostring(mon.stats.special), want.special))
|
||||
|
||||
SaveData.validate(game.save, game.data)
|
||||
local after = game.save.party[1].stats
|
||||
U.log("[1517] after SaveData.validate, special = " .. tostring(after.special)
|
||||
.. ", specialAttack = " .. tostring(after.specialAttack))
|
||||
U.log("[1517] the repair layer is SaveData.validate -> scrubKnownMon -> "
|
||||
.. "Stats.ensure; a nil above means it did not run")
|
||||
|
||||
U.teleport(game, "PALLET_TOWN", 10, 8, "down")
|
||||
U.wait(10)
|
||||
|
||||
local function cursorTo(menu, field, wanted)
|
||||
for _ = 1, 40 do
|
||||
if not menu or menu[field] == wanted then return menu and menu[field] == wanted end
|
||||
U.tap(game, menu[field] < wanted and "down" or "up")
|
||||
U.wait(3)
|
||||
end
|
||||
return menu[field] == wanted
|
||||
end
|
||||
|
||||
U.tap(game, "start")
|
||||
U.wait(10)
|
||||
local menu = top()
|
||||
if not (menu and menu.screenId == "StartMenu") then
|
||||
U.log("[1517] FAIL no start menu; top = " .. tostring(menu and menu.screenId))
|
||||
while true do coroutine.yield() end
|
||||
end
|
||||
local row
|
||||
for i, it in ipairs(menu.items or {}) do
|
||||
if it.label == "POKéMON" then row = i break end
|
||||
end
|
||||
if not (row and cursorTo(menu, "index", row)) then
|
||||
U.log("[1517] FAIL could not reach the POKéMON row")
|
||||
while true do coroutine.yield() end
|
||||
end
|
||||
U.tap(game, "a")
|
||||
U.wait(10)
|
||||
|
||||
local party = top()
|
||||
if not (party and party.screenId == "PartyMenu") then
|
||||
U.log("[1517] FAIL no party menu; top = " .. tostring(party and party.screenId))
|
||||
while true do coroutine.yield() end
|
||||
end
|
||||
U.shot(game, DIR .. "/01-party-list.png")
|
||||
U.tap(game, "a")
|
||||
U.wait(8)
|
||||
local subRow
|
||||
for i, it in ipairs(party.subItems or {}) do
|
||||
if it.action == "stats" then subRow = i break end
|
||||
end
|
||||
if not (subRow and cursorTo(party, "subIndex", subRow)) then
|
||||
U.log("[1517] FAIL could not reach the STATS row")
|
||||
while true do coroutine.yield() end
|
||||
end
|
||||
U.shot(game, DIR .. "/02-stats-selected.png")
|
||||
|
||||
U.log("[1517] opening STATS -- pre-fix the app dies here, silently")
|
||||
U.tap(game, "a")
|
||||
U.wait(30)
|
||||
|
||||
local screen = top()
|
||||
local opened = getmetatable(screen) == SummaryMenu or screen.screenId == "SummaryMenu"
|
||||
U.log("[1517] SURVIVED, top = " .. tostring(screen and screen.screenId))
|
||||
U.shot(game, DIR .. "/03-status-screen.png")
|
||||
U.log(("[1517] %s: SPECIAL on screen must read %d")
|
||||
:format(opened and "read it off the frame" or "WRONG SCREEN", want.special))
|
||||
U.log("[1517] shots in " .. DIR .. " -- the status screen is yours")
|
||||
while true do coroutine.yield() end
|
||||
end
|
||||
@@ -0,0 +1,80 @@
|
||||
-- data/maps/force_bike_surf.asm:5 / engine/overworld/player_state.asm:34-72,
|
||||
-- home/overworld.asm:690-703 (the map-change fade has no fade back in).
|
||||
-- POKEPORT_DRIVER=tests/drivers/warp_midpoint_box_bug1663_test.lua \
|
||||
-- POKEPORT_IDENTITY=bug1663 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
|
||||
-- Ride into the Route 16 gate, drop the BICYCLE inside, then walk back out
|
||||
-- the west door. The warp lands on a forced-bike tile, so the refusal box
|
||||
-- opens from inside the fade's midpoint: it must be on screen when the fade
|
||||
-- ends (#1663 ate it), and dismissing it must leave the player on Route 16's
|
||||
-- (17,10), not shoved into the gate wall at (18,10) (#1548).
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
|
||||
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
local ok = true
|
||||
local function check(label, pass)
|
||||
U.log(pass and "PASS" or "FAIL", label)
|
||||
if not pass then ok = false end
|
||||
return pass
|
||||
end
|
||||
|
||||
local function where()
|
||||
local ow = game.overworld
|
||||
return ow.map.id, ow.player.cellX, ow.player.cellY
|
||||
end
|
||||
local function bikeless()
|
||||
game.save.inventory.BICYCLE = nil
|
||||
game.save.onBike, game.save.forcedBike = false, nil
|
||||
end
|
||||
|
||||
game.save.player.name = "PROBE"
|
||||
game.save.party = { Pokemon.new(game.data, "CHARIZARD", 50) }
|
||||
game.save.inventory.BICYCLE = 1
|
||||
game.save.onBike, game.save.forcedBike = false, nil
|
||||
|
||||
-- (16,10) is the open cell west of the gate door; (17,10) is the forced
|
||||
-- tile the door sits on, so the step onto it mounts and then warps
|
||||
U.teleport(game, "ROUTE_16", 16, 10, "right")
|
||||
U.wait(20)
|
||||
U.hold(game, "right", 40)
|
||||
U.wait(40)
|
||||
local map, x, y = where()
|
||||
U.log(("rode east into the gate -> %s (%d,%d)"):format(map, x, y))
|
||||
check("the bike carried the player into the gate",
|
||||
map == "ROUTE_16_GATE_1F")
|
||||
|
||||
bikeless()
|
||||
U.hold(game, "left", 40)
|
||||
-- the map-change fade is 32 frames and hands back with no fade in
|
||||
U.wait(50)
|
||||
|
||||
map, x, y = where()
|
||||
local top = game.stack:top()
|
||||
U.log(("walked back out -> %s (%d,%d), top=%s"):format(map, x, y,
|
||||
tostring(getmetatable(top) == TextBox and "TextBox" or top)))
|
||||
check("the west door lands back on Route 16", map == "ROUTE_16")
|
||||
check("on the forced tile the gate exit warps to", x == 17 and y == 10)
|
||||
check("the refusal box the warp opened is on screen",
|
||||
getmetatable(top) == TextBox)
|
||||
U.shot(game, SHOT_DIR .. "/bug1663_refusal.png")
|
||||
|
||||
-- close it: the shove back east is refused by collision, (18,10) is wall
|
||||
for _ = 1, 12 do
|
||||
if game.stack:top() == game.overworld then break end
|
||||
U.tap(game, "a")
|
||||
U.wait(25)
|
||||
end
|
||||
U.wait(30)
|
||||
map, x, y = where()
|
||||
local walkable = game.overworld.map:isWalkableCell(x, y)
|
||||
U.log(("after the refusal -> %s (%d,%d) walkable=%s")
|
||||
:format(map, x, y, tostring(walkable)))
|
||||
check("dismissing it leaves the player on a walkable cell", walkable)
|
||||
check("and not inside the gate wall at (18,10)", not (x == 18 and y == 10))
|
||||
U.shot(game, SHOT_DIR .. "/bug1663_after.png")
|
||||
|
||||
U.log(ok and "all clear" or "a check failed")
|
||||
while true do coroutine.yield() end
|
||||
end
|
||||
@@ -77,4 +77,26 @@ do
|
||||
check(Runtime.modRequire == nil, "Runtime.reset clears modRequire")
|
||||
end
|
||||
|
||||
-- 4. returnToLauncher / closeEditor also clear Assets.loader (orphaned Loader)
|
||||
do
|
||||
local Assets = require("src.render.Assets")
|
||||
Assets.installLoader({
|
||||
overrideOrder = function() return { { id = "x", path = "mods/x" } } end,
|
||||
derivedPath = function() return nil end,
|
||||
})
|
||||
check(Assets.loader ~= nil, "Assets.loader installed for the session")
|
||||
Assets.installLoader(nil)
|
||||
check(Assets.loader == nil, "session teardown clears Assets.loader")
|
||||
end
|
||||
|
||||
-- 5. Gen1 Game:reset exists so main.lua need not guess field names
|
||||
do
|
||||
local Game = require("src.core.Game")
|
||||
check(type(Game.reset) == "function", "Game:reset is defined for in-process return")
|
||||
Game.mods = { stale = true }
|
||||
Game:reset()
|
||||
check(Game.mods == nil, "Game:reset clears session fields")
|
||||
check(type(Game.load) == "function", "Game:reset keeps methods")
|
||||
end
|
||||
|
||||
T.finish("android_exit_to_launcher_test")
|
||||
|
||||
@@ -57,6 +57,36 @@ eq(love.filesystem.read("assets/generated/fonts/font.png"), "font-bytes",
|
||||
eq(love.filesystem.read("data/generated/constants.lua"), "return {}",
|
||||
"post-mount probe reads red data at the un-prefixed path")
|
||||
|
||||
-- unmountVersion peels generated overlays (LIFO) then the version folder, so
|
||||
-- a later Play/Edit on another game cannot resolve this version's files.
|
||||
do
|
||||
local mounts = love.filesystem._mounts
|
||||
local archives = {}
|
||||
for _, m in ipairs(mounts) do archives[#archives + 1] = m.archive end
|
||||
local function indexOf(name)
|
||||
for i, a in ipairs(archives) do if a == name then return i end end
|
||||
end
|
||||
local dataIdx = indexOf("red/data/generated")
|
||||
local assetsIdx = indexOf("red/assets/generated")
|
||||
check(dataIdx and assetsIdx, "mountVersion recorded generated-tree overlays")
|
||||
check(assetsIdx > dataIdx,
|
||||
"assets/generated was mounted after data/generated (LIFO unmount peels it first)")
|
||||
end
|
||||
check(CacheFs.unmountVersion("red") == true, "unmountVersion(red) returns true")
|
||||
check(love.filesystem.read("assets/generated/fonts/font.png") == nil,
|
||||
"post-unmount probe no longer sees red assets at the un-prefixed path")
|
||||
check(love.filesystem.read("data/generated/constants.lua") == nil,
|
||||
"post-unmount probe no longer sees red data at the un-prefixed path")
|
||||
do
|
||||
local left = {}
|
||||
for _, m in ipairs(love.filesystem._mounts) do
|
||||
if tostring(m.archive):find("red", 1, true) then
|
||||
left[#left + 1] = m.archive
|
||||
end
|
||||
end
|
||||
eq(#left, 0, "no red/ mounts remain after unmountVersion")
|
||||
end
|
||||
|
||||
-- no legacy cache at all: migration is a no-op, not an error
|
||||
love.filesystem.remove("red/rom-cache.complete")
|
||||
love.filesystem.remove("red/data/generated/maps.lua")
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
-- Five Gen 2 battle messages printed something the cart does not say. Each
|
||||
-- case below drives a real turn and compares the emitted line against the
|
||||
-- label in pokegold's data/text/battle.asm:
|
||||
--
|
||||
-- SuperEffectiveText :603
|
||||
-- NotVeryEffectiveText :608
|
||||
-- BattleText_TheresNoPPLeftForThisMove :315
|
||||
-- PlayerHitTimesText / EnemyHitTimesText:749, :755
|
||||
-- StartPerishText :986
|
||||
--
|
||||
-- The marker is the one src/import/RomExtractorGen2.lua decodes the cart's own
|
||||
-- $4e (`line`) into, so a line written here reads exactly as an extracted one
|
||||
-- would. The tail case pins the other half of that: printMessage draws at
|
||||
-- most TEXT_ROWS rows and cuts the rest, so a line has to fit two of them.
|
||||
--
|
||||
-- luajit tests/engine/gen2_battle_text_matches_cart.lua
|
||||
--
|
||||
-- ROM-free: the fixtures below are the extractor's shapes.
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
love = require("tests.love_stub")
|
||||
|
||||
local T = require("tests.harness")
|
||||
local Battle = require("src.battle.gen2.Battle")
|
||||
local Mon = require("src.battle.gen2.Mon")
|
||||
|
||||
local check = T.check
|
||||
|
||||
-- ---------------------------------------------------------------- fixtures
|
||||
|
||||
local TYPES = {
|
||||
NORMAL = { id = "NORMAL", index = 0, category = "physical" },
|
||||
GROUND = { id = "GROUND", index = 4, category = "physical" },
|
||||
ROCK = { id = "ROCK", index = 5, category = "physical" },
|
||||
FIRE = { id = "FIRE", index = 20, category = "special" },
|
||||
WATER = { id = "WATER", index = 21, category = "special" },
|
||||
}
|
||||
|
||||
local MATCHUPS = {
|
||||
{ attacker = "NORMAL", defender = "ROCK", multiplier = 5 },
|
||||
{ attacker = "WATER", defender = "FIRE", multiplier = 20 },
|
||||
}
|
||||
|
||||
local MOVES = {
|
||||
TACKLE = { id = "TACKLE", name = "TACKLE", power = 35, type = "NORMAL",
|
||||
accuracy = 100, pp = 35, effect = "EFFECT_NORMAL_HIT" },
|
||||
WATER_GUN = { id = "WATER_GUN", name = "WATER GUN", power = 40,
|
||||
type = "WATER", accuracy = 100, pp = 25, effect = "EFFECT_NORMAL_HIT" },
|
||||
DOUBLESLAP = { id = "DOUBLESLAP", name = "DOUBLESLAP", power = 15,
|
||||
type = "NORMAL", accuracy = 100, pp = 10, effect = "EFFECT_MULTI_HIT" },
|
||||
PERISH_SONG = { id = "PERISH_SONG", name = "PERISH SONG", power = 0,
|
||||
type = "NORMAL", accuracy = 100, pp = 5, effect = "EFFECT_PERISH_SONG" },
|
||||
}
|
||||
|
||||
local GROWTH = {
|
||||
GROWTH_MEDIUM_SLOW = { numerator = 6, denominator = 5, squared = -15,
|
||||
linear = 100, constant = 140 },
|
||||
}
|
||||
|
||||
local function species(id, index, types)
|
||||
return { id = id, index = index, name = id,
|
||||
baseStats = { hp = 50, attack = 60, defense = 50, speed = 50,
|
||||
specialAttack = 50, specialDefense = 50 },
|
||||
types = types, catchRate = 255, baseExp = 60,
|
||||
growthRate = "GROWTH_MEDIUM_SLOW", genderRatio = 31,
|
||||
levelMoves = { { level = 1, move = "TACKLE" } }, evolutions = {} }
|
||||
end
|
||||
|
||||
local POKEMON = {
|
||||
growthRates = GROWTH,
|
||||
CYNDAQUIL = species("CYNDAQUIL", 155, { "FIRE", "FIRE" }),
|
||||
GEODUDE = species("GEODUDE", 74, { "ROCK", "GROUND" }),
|
||||
}
|
||||
|
||||
local DATA = { pokemon = POKEMON, moves = MOVES,
|
||||
type_chart = { types = TYPES, matchups = MATCHUPS }, items = {} }
|
||||
|
||||
local perfect = { attack = 15, defense = 15, speed = 15, special = 15 }
|
||||
perfect.hp = Mon.hpDV(perfect)
|
||||
|
||||
-- The smallest roll that is neither a critical hit nor a miss.
|
||||
local function detRandom(n)
|
||||
if (n or 1) <= 1 then return 0 end
|
||||
return 1
|
||||
end
|
||||
|
||||
-- One turn of the player's move, returning every message line it printed.
|
||||
local function linesFrom(playerSpecies, playerMoves, wildSpecies, moveId)
|
||||
local player = Mon.new(DATA, playerSpecies, 20, { dvs = perfect })
|
||||
player.moves = playerMoves
|
||||
local wild = Mon.new(DATA, wildSpecies, 20, { dvs = perfect })
|
||||
wild.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } }
|
||||
local battle = Battle.new({ data = DATA, party = { player }, wild = wild,
|
||||
random = detRandom })
|
||||
local said = {}
|
||||
for _, event in ipairs(battle:takeTurn({ kind = "move", move = moveId })) do
|
||||
if event.kind == "message" and event.text then
|
||||
said[#said + 1] = event.text
|
||||
end
|
||||
end
|
||||
return said
|
||||
end
|
||||
|
||||
local function saw(said, text)
|
||||
for _, line in ipairs(said) do
|
||||
if line == text then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function shown(said)
|
||||
return "printed: " .. table.concat(said, " | ")
|
||||
end
|
||||
|
||||
-- ---- the two effectiveness lines ------------------------------------------
|
||||
-- Both break across the box's two lines on the cart, and "super-" is
|
||||
-- hyphenated to make the break. The not-very line ends on the single
|
||||
-- ellipsis glyph the Gold charmap carries at $75, not on three periods.
|
||||
do
|
||||
local said = linesFrom("CYNDAQUIL",
|
||||
{ { id = "TACKLE", pp = 35, maxPp = 35 } }, "GEODUDE", "TACKLE")
|
||||
check(saw(said, "It's not very\neffective…"),
|
||||
"NotVeryEffectiveText prints as the cart writes it. " .. shown(said))
|
||||
|
||||
said = linesFrom("GEODUDE",
|
||||
{ { id = "WATER_GUN", pp = 25, maxPp = 25 } }, "CYNDAQUIL", "WATER_GUN")
|
||||
check(saw(said, "It's super-\neffective!"),
|
||||
"SuperEffectiveText keeps its hyphen and its break. " .. shown(said))
|
||||
end
|
||||
|
||||
-- ---- a move at zero PP ----------------------------------------------------
|
||||
do
|
||||
-- a second move with PP left, so the turn refuses the empty one rather
|
||||
-- than falling through to Struggle
|
||||
local said = linesFrom("CYNDAQUIL",
|
||||
{ { id = "TACKLE", pp = 0, maxPp = 35 },
|
||||
{ id = "WATER_GUN", pp = 25, maxPp = 25 } }, "GEODUDE", "TACKLE")
|
||||
check(saw(said, "There's no PP left\nfor this move!"),
|
||||
"the empty-PP refusal is the cart's sentence. " .. shown(said))
|
||||
end
|
||||
|
||||
-- ---- the multi-hit tally --------------------------------------------------
|
||||
-- "Hit @ times!" has no singular form on the cart, which is why the "(s)"
|
||||
-- this used to print is not a hedge the game ever makes. Gen 1 says it the
|
||||
-- same way (src/battle/EffectRegistry.lua, _HitXTimesText).
|
||||
do
|
||||
local said = linesFrom("CYNDAQUIL",
|
||||
{ { id = "DOUBLESLAP", pp = 10, maxPp = 10 } }, "GEODUDE", "DOUBLESLAP")
|
||||
local tally
|
||||
for _, line in ipairs(said) do
|
||||
if line:match("^Hit %d+ times!$") then tally = line end
|
||||
end
|
||||
check(tally ~= nil,
|
||||
"the multi-hit tally reads \"Hit N times!\". " .. shown(said))
|
||||
check(not table.concat(said, " "):find("time(s)", 1, true),
|
||||
"and no line hedges the plural with a parenthetical. " .. shown(said))
|
||||
end
|
||||
|
||||
-- ---- Perish Song ----------------------------------------------------------
|
||||
-- What shipped here was a sentence no cart prints. StartPerishText names
|
||||
-- both sides and counts in digits.
|
||||
do
|
||||
local said = linesFrom("CYNDAQUIL",
|
||||
{ { id = "PERISH_SONG", pp = 5, maxPp = 5 } }, "GEODUDE", "PERISH_SONG")
|
||||
check(saw(said, "Both POKéMON will\nfaint in 3 turns!"),
|
||||
"Perish Song prints StartPerishText. " .. shown(said))
|
||||
end
|
||||
|
||||
-- ---- and every one of them fits the box -----------------------------------
|
||||
-- printMessage draws Chrome.wrap(self.message, TEXT_WIDTH) and stops at
|
||||
-- TEXT_ROWS (home/text.asm:143, :397), so a third row is cut rather than
|
||||
-- spilled. A cart line that needs one, such as SpikesText's `cont` row
|
||||
-- carrying <TARGET>, cannot be told on this path at all: engine messages set
|
||||
-- self.message directly (src/ui/gen2/BattleState.lua) instead of going
|
||||
-- through showPages, so nothing paginates them. Hence Spikes is left alone
|
||||
-- above, and every line that IS changed is checked to fit here.
|
||||
do
|
||||
local Chrome = require("src.ui.gen2.Chrome")
|
||||
-- TEXT_WIDTH and TEXT_ROWS are file-locals in BattleState, so their values
|
||||
-- are repeated rather than required.
|
||||
local BOX_WIDTH, BOX_ROWS = 18, 2
|
||||
for _, line in ipairs({
|
||||
"It's super-\neffective!",
|
||||
"It's not very\neffective…",
|
||||
"There's no PP left\nfor this move!",
|
||||
"Hit 3 times!",
|
||||
"Both POKéMON will\nfaint in 3 turns!",
|
||||
}) do
|
||||
local rows = #Chrome.wrap(line, BOX_WIDTH)
|
||||
check(rows <= BOX_ROWS,
|
||||
("\"%s\" wraps to %d rows, and the box draws %d")
|
||||
:format((line:gsub("\n", "\\n")), rows, BOX_ROWS))
|
||||
end
|
||||
end
|
||||
|
||||
T.finish("gen2 battle text matches the cart")
|
||||
@@ -0,0 +1,111 @@
|
||||
-- In-process launcher session teardown: Game:reset, Renderer canvas release,
|
||||
-- Runtime/Assets/LegacyCompat cleanup, and editor package.loaded discovery flush.
|
||||
-- luajit tests/engine/launcher_session_teardown_test.lua
|
||||
|
||||
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 Runtime = require("src.mods.Runtime")
|
||||
local Assets = require("src.render.Assets")
|
||||
local LegacyCompat = require("src.mods.LegacyCompat")
|
||||
local Game = require("src.core.Game")
|
||||
local Renderer = require("src.render.Renderer")
|
||||
local StateStack = require("src.core.StateStack")
|
||||
|
||||
-- ---- Game:reset drops instance state, keeps methods ----------------------
|
||||
do
|
||||
StateStack:init()
|
||||
StateStack:push({ name = "stale" })
|
||||
Game.mods = { id = "orphan" }
|
||||
Game.save = { money = 1 }
|
||||
Game.network = { live = true } -- future field: must not need a whitelist
|
||||
Game.stack = StateStack
|
||||
Game.renderer = Renderer
|
||||
Renderer.canvas = love.graphics.newCanvas(8, 8)
|
||||
|
||||
Game:reset()
|
||||
|
||||
check(type(Game.load) == "function", "Game:reset keeps methods")
|
||||
check(type(Game.reset) == "function", "Game:reset keeps itself")
|
||||
check(Game.mods == nil, "Game:reset clears mods")
|
||||
check(Game.save == nil, "Game:reset clears save")
|
||||
check(Game.network == nil, "Game:reset clears arbitrary future fields")
|
||||
check(Game.stack == nil, "Game:reset clears stack reference")
|
||||
check(Game.renderer == nil, "Game:reset clears renderer reference")
|
||||
check(StateStack:top() == nil, "Game:reset cleared the shared StateStack")
|
||||
end
|
||||
|
||||
-- ---- Renderer:init releases prior canvases before realloc ----------------
|
||||
do
|
||||
local first = love.graphics.newCanvas(16, 16)
|
||||
Renderer.canvas = first
|
||||
Renderer.battleHUDCanvas = love.graphics.newCanvas(16, 16)
|
||||
Renderer.worldCanvas = love.graphics.newCanvas(16, 16)
|
||||
Renderer.uprightCanvas = love.graphics.newCanvas(16, 16)
|
||||
Renderer:init()
|
||||
check(first.released == true,
|
||||
"Renderer:init Object:release()s the previous primary canvas")
|
||||
check(Renderer.canvas ~= nil and Renderer.canvas ~= first,
|
||||
"Renderer:init allocates a fresh primary canvas")
|
||||
check(Renderer.canvas.released ~= true,
|
||||
"the new primary canvas is not released")
|
||||
-- second init also releases the one just created
|
||||
local second = Renderer.canvas
|
||||
Renderer:init()
|
||||
check(second.released == true,
|
||||
"a second Renderer:init releases the canvas from the prior init")
|
||||
end
|
||||
|
||||
-- ---- Shared singleton teardown contract (closeEditor / returnToLauncher)
|
||||
do
|
||||
Runtime.install({ emit = function() end }, { call = function() end }, { "e" })
|
||||
Assets.installLoader({
|
||||
overrideOrder = function() return {} end,
|
||||
derivedPath = function() return nil end,
|
||||
})
|
||||
LegacyCompat.reports = { some_mod = { order = {} } }
|
||||
|
||||
-- Mirrors main.lua teardownMountedSession without mounting CacheFs.
|
||||
require("src.core.Data"):unloadGenerated()
|
||||
Runtime.reset()
|
||||
Assets.installLoader(nil)
|
||||
LegacyCompat.reset()
|
||||
|
||||
check(Runtime.errors == nil, "teardown clears Runtime.errors")
|
||||
check(Assets.loader == nil, "teardown clears Assets.loader")
|
||||
eq(next(LegacyCompat.reports), nil, "teardown clears LegacyCompat.reports")
|
||||
end
|
||||
|
||||
-- ---- Editor package.loaded discovery flush (no panel whitelist) ---------
|
||||
do
|
||||
love.filesystem.write("tools/save-editor/App.lua", "return {}")
|
||||
love.filesystem.write("tools/save-editor/panels/NewPanel.lua", "return {}")
|
||||
package.loaded["App"] = { stale = true }
|
||||
package.loaded["NewPanel"] = { stale = true }
|
||||
package.loaded["src.core.Data"] = package.loaded["src.core.Data"] -- keep
|
||||
|
||||
local function isEditorFlat(name)
|
||||
if name:find("[./]") then return false end
|
||||
return love.filesystem.getInfo("tools/save-editor/" .. name .. ".lua") ~= nil
|
||||
or love.filesystem.getInfo("tools/save-editor/panels/" .. name .. ".lua") ~= nil
|
||||
end
|
||||
for k in pairs(package.loaded) do
|
||||
if type(k) == "string"
|
||||
and (k:find("save%-editor", 1, false) or isEditorFlat(k)) then
|
||||
package.loaded[k] = nil
|
||||
end
|
||||
end
|
||||
|
||||
check(package.loaded["App"] == nil, "discovery flush drops flat App")
|
||||
check(package.loaded["NewPanel"] == nil,
|
||||
"discovery flush drops a new panel without a hardcoded list")
|
||||
check(package.loaded["src.core.Data"] ~= nil,
|
||||
"discovery flush leaves engine modules alone")
|
||||
love.filesystem.remove("tools/save-editor/App.lua")
|
||||
love.filesystem.remove("tools/save-editor/panels/NewPanel.lua")
|
||||
end
|
||||
|
||||
T.finish("launcher_session_teardown_test")
|
||||
@@ -0,0 +1,172 @@
|
||||
-- A Gen 1 move slot stores current PP in six bits of one byte and the PP Up
|
||||
-- count in the other two (constants/pokemon_data_constants.asm:101-102), and
|
||||
-- the status screen reads it back with `and PP_MASK` before PrintNumber
|
||||
-- (engine/pokemon/status_screen.asm:357-365), so "not a number" is not a state
|
||||
-- the hardware record can hold and the load-time repair must normalize it.
|
||||
-- Max PP follows GetMaxPP/AddBonusPP (engine/items/item_effects.asm:2467,
|
||||
-- 2418). #1668
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.harness")
|
||||
local check, eq = T.check, T.eq
|
||||
love = love or require("tests.love_stub")
|
||||
|
||||
local SaveData = require("src.core.SaveData")
|
||||
|
||||
local data = {
|
||||
pokemon = { FIXMON_A = { id = "FIXMON_A", name = "FIXMON A", types = { "GRASS" },
|
||||
baseStats = { hp = 45, attack = 49, defense = 49, speed = 45, special = 65 } } },
|
||||
moves = {
|
||||
FIX_TACKLE = { id = "FIX_TACKLE", name = "TACKLE", pp = 35 },
|
||||
FIX_GROWL = { id = "FIX_GROWL", name = "GROWL", pp = 40 },
|
||||
},
|
||||
items = {}, maps = { FIXMAP = { id = "FIXMAP" } },
|
||||
constants = { fallbackMove = "FIX_TACKLE" },
|
||||
}
|
||||
|
||||
local function saveWith(moves)
|
||||
return {
|
||||
party = { { species = "FIXMON_A", level = 21, hp = 62, exp = 9000,
|
||||
dvs = { hp = 15, attack = 9, defense = 8, speed = 8, special = 8 },
|
||||
statExp = {}, moves = moves } },
|
||||
boxes = {}, inventory = {}, pcItems = {},
|
||||
player = { map = "FIXMAP", x = 1, y = 1, name = "RED", id = 1 },
|
||||
}
|
||||
end
|
||||
|
||||
local function scrub(moves)
|
||||
local save = saveWith(moves)
|
||||
SaveData.validate(save, data)
|
||||
return save.party[1].moves
|
||||
end
|
||||
|
||||
-- SummaryMenu page 2 and every battle reader index the save's own slot table
|
||||
local function readers(mv)
|
||||
local mdef = data.moves[mv.id]
|
||||
local maxPP = mdef.pp + (mv.ppUps or 0) * math.floor(mdef.pp / 5)
|
||||
local okFmt = pcall(string.format, "%2d/%2d", mv.pp, maxPP)
|
||||
local okCmp = pcall(function() return mv.pp > 0 end)
|
||||
return okFmt, okCmp, maxPP
|
||||
end
|
||||
|
||||
do -- every non-numeric pp shape the tree can be handed
|
||||
local moves = scrub({
|
||||
{ id = "FIX_TACKLE" },
|
||||
{ id = "FIX_TACKLE", pp = {} },
|
||||
{ id = "FIX_TACKLE", pp = "35" },
|
||||
{ id = "FIX_TACKLE", pp = 0 / 0 },
|
||||
})
|
||||
eq(#moves, 4, "all four slots survive")
|
||||
for i = 1, 4 do
|
||||
local mv = moves[i]
|
||||
check(type(mv.pp) == "number", "slot " .. i .. " carries a numeric pp")
|
||||
local okFmt, okCmp, maxPP = readers(mv)
|
||||
check(okFmt, "slot " .. i .. ": SummaryMenu's ('%2d/%2d'):format no longer raises")
|
||||
check(okCmp, "slot " .. i .. ": playerHasPP's `mv.pp > 0` no longer raises")
|
||||
check(type(mv.pp) == "number" and mv.pp >= 0 and mv.pp <= maxPP,
|
||||
"slot " .. i .. " sits inside 0..maxPP")
|
||||
end
|
||||
eq(moves[1].pp, 35, "a missing pp heals to full, not to Struggle")
|
||||
eq(moves[2].pp, 35, "a table pp heals to full")
|
||||
eq(moves[3].pp, 35, "a numeric string becomes the number it spells")
|
||||
eq(moves[4].pp, 35, "a nan pp heals to full")
|
||||
end
|
||||
|
||||
do -- numeric, but not a value the unsigned byte field can express
|
||||
local moves = scrub({
|
||||
{ id = "FIX_TACKLE", pp = -4 },
|
||||
{ id = "FIX_TACKLE", pp = 1.7 },
|
||||
{ id = "FIX_TACKLE", pp = math.huge },
|
||||
{ id = "FIX_TACKLE", pp = -math.huge },
|
||||
})
|
||||
eq(moves[1].pp, 0, "a negative pp clamps up to zero")
|
||||
eq(moves[2].pp, 1, "a fractional pp floors to an integer")
|
||||
eq(moves[3].pp, 35, "an infinite pp heals to full")
|
||||
eq(moves[4].pp, 35, "so does a negative infinity")
|
||||
end
|
||||
|
||||
-- MimicEffect writes the copied move id into the slot and never touches the
|
||||
-- PP byte (engine/battle/effects.asm:1261-1266), and this port's battler reads
|
||||
-- mon.moves by identity, so a mid-battle save legitimately carries a PP count
|
||||
-- above the slot's current move's max. Clamping it down corrupts a battle
|
||||
-- checkpoint, which is why the repair only replaces values that are not
|
||||
-- numbers at all.
|
||||
do
|
||||
local moves = scrub({
|
||||
{ id = "FIX_TACKLE", pp = 40, mimic = true },
|
||||
{ id = "FIX_TACKLE", pp = 999 },
|
||||
{ id = "FIX_GROWL", pp = 40, ppUps = 0 },
|
||||
})
|
||||
eq(moves[1].pp, 40, "a Mimic'd slot keeps the 40 PP the GROWL it replaced had")
|
||||
check(moves[1].mimic == true, "and the battler's own restore marker survives")
|
||||
eq(moves[2].pp, 999, "an over-max pp is left alone, not clamped")
|
||||
eq(moves[3].pp, 40, "and a full slot is unchanged")
|
||||
end
|
||||
|
||||
do -- the PP Up count is two bits, so 0..3
|
||||
local moves = scrub({
|
||||
{ id = "FIX_TACKLE", pp = 5, ppUps = "x" },
|
||||
{ id = "FIX_TACKLE", pp = 5, ppUps = 9 },
|
||||
{ id = "FIX_TACKLE", pp = 49, ppUps = 2 },
|
||||
{ id = "FIX_TACKLE", pp = 5 },
|
||||
})
|
||||
eq(moves[1].ppUps, 0, "a non-numeric ppUps becomes zero")
|
||||
eq(moves[2].ppUps, 3, "an over-max ppUps clamps to three")
|
||||
eq(moves[3].ppUps, 2, "a legal ppUps is kept")
|
||||
eq(moves[3].pp, 49, "and its PP-Upped count is untouched: 35 + 2 * 7")
|
||||
eq(moves[4].ppUps, nil, "a slot without a ppUps does not grow one")
|
||||
for i = 1, 4 do
|
||||
local okFmt = pcall(string.format, "%2d/%2d", moves[i].pp, 35)
|
||||
check(okFmt, "slot " .. i .. " formats after a ppUps repair")
|
||||
local mdef = data.moves[moves[i].id]
|
||||
local ok = pcall(function()
|
||||
return mdef.pp + (moves[i].ppUps or 0) * math.floor(mdef.pp / 5)
|
||||
end)
|
||||
check(ok, "slot " .. i .. ": SummaryMenu's maxPP arithmetic no longer raises")
|
||||
end
|
||||
end
|
||||
|
||||
-- A scalar move slot is a shape the id filter has always tolerated, and
|
||||
-- tests/modkit/cases/checkpoints.lua treats one as valid content that
|
||||
-- SaveData.validate must not rewrite. It stays out of the repair; the
|
||||
-- SummaryMenu.lua:206 crash it causes is a separate defect.
|
||||
do
|
||||
local moves = scrub({ "FIX_TACKLE", { id = "FIX_GROWL" } })
|
||||
eq(#moves, 2, "the scalar slot survives the id filter, as before")
|
||||
eq(moves[1], "FIX_TACKLE", "and is left exactly as the save stored it")
|
||||
eq(moves[2].pp, 40, "while its table-shaped neighbour is still repaired")
|
||||
end
|
||||
|
||||
do -- the moveless-mon fallback slot goes through the same normalization
|
||||
local moves = scrub({ { id = "NOT_A_MOVE", pp = "junk" } })
|
||||
eq(#moves, 1, "the unknown move is replaced by the fallback")
|
||||
eq(moves[1].id, "FIX_TACKLE", "which is data.constants.fallbackMove")
|
||||
check(type(moves[1].pp) == "number", "and carries a numeric pp")
|
||||
eq(moves[1].pp, 35, "at full")
|
||||
end
|
||||
|
||||
do -- a vanilla slot passes through byte-identical
|
||||
local moves = scrub({
|
||||
{ id = "FIX_TACKLE", pp = 20 },
|
||||
{ id = "FIX_GROWL", pp = 0 },
|
||||
{ id = "FIX_GROWL", pp = 64, ppUps = 3 },
|
||||
})
|
||||
eq(moves[1].pp, 20, "a mid-fight pp is left alone")
|
||||
eq(moves[2].pp, 0, "an exhausted move is left on zero, not healed")
|
||||
eq(moves[3].pp, 64, "and a PP-Upped slot agrees with SummaryMenu's own maxPP")
|
||||
eq(moves[3].ppUps, 3, "with its PP Up count intact")
|
||||
end
|
||||
|
||||
do -- box mons are reached by the same pass
|
||||
local save = saveWith({ { id = "FIX_TACKLE", pp = 20 } })
|
||||
save.boxes = { { { species = "FIXMON_A", level = 21, hp = 62,
|
||||
dvs = {}, statExp = {},
|
||||
moves = { { id = "FIX_TACKLE", pp = "nonsense" } } } } }
|
||||
SaveData.validate(save, data)
|
||||
local mv = save.boxes[1][1].moves[1]
|
||||
check(type(mv.pp) == "number", "a box mon's move slot is repaired too")
|
||||
eq(mv.pp, 35, "to full PP")
|
||||
end
|
||||
|
||||
T.finish()
|
||||
@@ -0,0 +1,100 @@
|
||||
-- A Gen 1 party mon carries one Special word (macros/ram.asm:28-37) and
|
||||
-- CalcStats writes all NUM_STATS stats or none (home/move_mon.asm:33-48,
|
||||
-- constants/battle_constants.asm:11-17), so a block missing a key is not a
|
||||
-- Gen 1 record and the load-time repair must rebuild it. Gen 2 splits it
|
||||
-- into SpclAtk/SpclDef (pokegold macros/ram.asm:29-42). #1517
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.harness")
|
||||
local check, eq = T.check, T.eq
|
||||
love = love or require("tests.love_stub")
|
||||
|
||||
local Stats = require("src.pokemon.Stats")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
|
||||
local data = {
|
||||
pokemon = { FIXMON_A = { id = "FIXMON_A", name = "FIXMON A", types = { "GRASS" },
|
||||
baseStats = { hp = 45, attack = 49, defense = 49, speed = 45, special = 65 } } },
|
||||
moves = { FIX_TACKLE = { id = "FIX_TACKLE", name = "TACKLE", pp = 35 } },
|
||||
items = {}, maps = { FIXMAP = { id = "FIXMAP" } },
|
||||
constants = { fallbackMove = "FIX_TACKLE" },
|
||||
}
|
||||
local DEF = data.pokemon.FIXMON_A
|
||||
local DVS = { hp = 15, attack = 9, defense = 8, speed = 8, special = 8 }
|
||||
|
||||
-- src/battle/gen2/Mon.lua Mon.stats' shape: no `special`
|
||||
local function gen2Shaped()
|
||||
return {
|
||||
species = "FIXMON_A", level = 21, hp = 62, exp = 9000,
|
||||
dvs = { hp = 15, attack = 9, defense = 8, speed = 8, special = 8 },
|
||||
statExp = {},
|
||||
stats = { hp = 62, attack = 27, defense = 29, speed = 37,
|
||||
specialAttack = 8, specialDefense = 8 },
|
||||
moves = { { id = "FIX_TACKLE", pp = 35 } },
|
||||
}
|
||||
end
|
||||
|
||||
do
|
||||
local mon = gen2Shaped()
|
||||
eq(mon.stats.special, nil, "premise: a Gen 2 block has no `special`")
|
||||
Stats.ensure(DEF, mon)
|
||||
local want = Stats.calc(DEF, 21, DVS, {})
|
||||
for _, key in ipairs(Stats.ORDER) do
|
||||
eq(mon.stats[key], want[key], "ensure rebuilds " .. key .. " from CalcStats")
|
||||
end
|
||||
eq(mon.stats.specialAttack, nil, "and drops the Gen 2 keys")
|
||||
eq(mon.stats.specialDefense, nil, "both of them")
|
||||
end
|
||||
|
||||
do
|
||||
local mon = gen2Shaped()
|
||||
local save = { party = { mon }, boxes = {}, inventory = {}, pcItems = {},
|
||||
player = { map = "FIXMAP", x = 1, y = 1, name = "RED", id = 1 } }
|
||||
SaveData.validate(save, data)
|
||||
check(type(save.party[1].stats.special) == "number",
|
||||
"validate repairs a party mon carrying a Gen 2 stat block")
|
||||
local ok = pcall(string.format, "%3d", save.party[1].stats.special)
|
||||
check(ok, "SummaryMenu's ('%3d'):format over stats.special no longer raises")
|
||||
ok = pcall(Stats.applyStage, save.party[1].stats.special, 0)
|
||||
check(ok, "and Damage's applyStage over curStats.special no longer raises")
|
||||
end
|
||||
|
||||
-- a box mon reached through the same pass
|
||||
do
|
||||
local save = { party = {}, boxes = { { gen2Shaped() } }, inventory = {},
|
||||
pcItems = {},
|
||||
player = { map = "FIXMAP", x = 1, y = 1, name = "RED", id = 1 } }
|
||||
SaveData.validate(save, data)
|
||||
check(type(save.boxes[1][1].stats.special) == "number",
|
||||
"the repair reaches box mons too")
|
||||
end
|
||||
|
||||
do -- a complete block is never rewritten
|
||||
local want = Stats.calc(DEF, 21, DVS, {})
|
||||
local mon = { species = "FIXMON_A", level = 21, hp = 5, dvs = DVS, statExp = {},
|
||||
stats = { hp = want.hp, attack = 999, defense = want.defense,
|
||||
speed = want.speed, special = want.special },
|
||||
moves = { { id = "FIX_TACKLE", pp = 35 } } }
|
||||
Stats.ensure(DEF, mon)
|
||||
eq(mon.stats.attack, 999, "a complete block is returned untouched")
|
||||
eq(mon.hp, 5, "and a stored current HP below the max is kept")
|
||||
end
|
||||
|
||||
do -- a mon with no block at all still gets one (#233, #304 unchanged)
|
||||
local mon = { species = "FIXMON_A", level = 21, dvs = DVS, statExp = {} }
|
||||
Stats.ensure(DEF, mon)
|
||||
local want = Stats.calc(DEF, 21, DVS, {})
|
||||
eq(mon.stats.special, want.special, "a box mon with no stats still gets them")
|
||||
eq(mon.hp, want.hp, "and a missing current HP fills to the maximum")
|
||||
end
|
||||
|
||||
do -- no species definition: nothing to rebuild from, so leave it alone
|
||||
local mon = { species = "MISSINGNO", level = 21,
|
||||
stats = { hp = 62, attack = 27 } }
|
||||
Stats.ensure(nil, mon)
|
||||
eq(mon.stats.attack, 27, "an unknown species leaves a partial block as-is")
|
||||
eq(mon.stats.special, nil, "rather than inventing one")
|
||||
end
|
||||
|
||||
T.finish()
|
||||
@@ -0,0 +1,133 @@
|
||||
-- home/overworld.asm:690-703 (PlayMapChangeSound tail-calls GBFadeOutToBlack)
|
||||
-- and home/fade.asm:43-46: the map-change fade has no matching fade in, so the
|
||||
-- warp shape ends in the same frame its midpoint runs. The midpoint is where
|
||||
-- setMap opens things (the Cycling Road refusal box, a map script's onEnter),
|
||||
-- and a fade that popped the top of the stack after that ate them and then
|
||||
-- finished a second time (#1663).
|
||||
-- luajit tests/engine/transition_identity_pop_bug1663.lua
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.harness")
|
||||
local check, eq = T.check, T.eq
|
||||
love = love or require("tests.love_stub")
|
||||
|
||||
local StateStack = require("src.core.StateStack")
|
||||
local Transition = require("src.render.Transition")
|
||||
local Timing = require("src.core.Timing")
|
||||
|
||||
local function overworld() return { name = "overworld", isOpaque = true } end
|
||||
|
||||
-- ------------------------------------------------ the warp shape (#1663)
|
||||
|
||||
do
|
||||
StateStack:init()
|
||||
local ow = overworld()
|
||||
StateStack:push(ow)
|
||||
|
||||
local box = { name = "refusal" }
|
||||
local entered, depthAtMidpoint, topAtMidpoint = 0, nil, nil
|
||||
function box:enter() entered = entered + 1 end
|
||||
local dones = 0
|
||||
local fade
|
||||
fade = Transition.new({ stack = StateStack }, function()
|
||||
depthAtMidpoint = #StateStack.states
|
||||
topAtMidpoint = StateStack:top()
|
||||
StateStack:push(box)
|
||||
end, function() dones = dones + 1 end, true)
|
||||
StateStack:push(fade)
|
||||
|
||||
local finishedOn
|
||||
for frame = 1, 120 do
|
||||
StateStack:update(1 / 60)
|
||||
if dones > 0 and not finishedOn then finishedOn = frame end
|
||||
end
|
||||
|
||||
eq(finishedOn, Timing.WARP_FADE_OUT, "the warp hands back at the end of the fade")
|
||||
eq(dones, 1, "and hands back exactly once")
|
||||
eq(depthAtMidpoint, 1, "the fade is off the stack before the midpoint runs")
|
||||
check(topAtMidpoint == ow, "so the map switch sees the overworld on top")
|
||||
eq(entered, 1, "the state the midpoint pushed entered once")
|
||||
eq(#StateStack.states, 2, "the fade is gone and the pushed state is not")
|
||||
check(StateStack.states[1] == ow, "the overworld is still the base")
|
||||
check(StateStack:top() == box, "the box the midpoint opened owns the screen")
|
||||
end
|
||||
|
||||
-- a midpoint that pushes nothing still leaves exactly the overworld behind
|
||||
do
|
||||
StateStack:init()
|
||||
local ow = overworld()
|
||||
StateStack:push(ow)
|
||||
local mids, dones = 0, 0
|
||||
local fade = Transition.new({ stack = StateStack },
|
||||
function() mids = mids + 1 end,
|
||||
function() dones = dones + 1 end, true)
|
||||
StateStack:push(fade)
|
||||
for _ = 1, 120 do StateStack:update(1 / 60) end
|
||||
eq(mids, 1, "the map switched once")
|
||||
eq(dones, 1, "the plain warp still hands back exactly once")
|
||||
eq(#StateStack.states, 1, "and pops nothing but itself")
|
||||
check(StateStack:top() == ow, "leaving the overworld on top")
|
||||
end
|
||||
|
||||
-- ------------------------------------------ the script fade is unchanged
|
||||
|
||||
-- ViridianGym.asm .afterBeat / RocketHideoutB4F BeatGiovanniScript bracket
|
||||
-- their HideObject with GBFadeOutToBlack -> GBFadeInFromBlack, so those keep
|
||||
-- a real fade in and wait under whatever the midpoint opened.
|
||||
do
|
||||
StateStack:init()
|
||||
local ow = overworld()
|
||||
StateStack:push(ow)
|
||||
|
||||
local box = { name = "script box" }
|
||||
local dones = 0
|
||||
local fade = Transition.new({ stack = StateStack },
|
||||
function() StateStack:push(box) end,
|
||||
function() dones = dones + 1 end, false)
|
||||
StateStack:push(fade)
|
||||
|
||||
for _ = 1, Timing.WARP_FADE_OUT + 8 do StateStack:update(1 / 60) end
|
||||
eq(dones, 0, "a fade with a fade-in waits under what its midpoint opened")
|
||||
check(StateStack:top() == box, "the pushed state is on top")
|
||||
check(StateStack.states[2] == fade, "with the fade still underneath it")
|
||||
|
||||
StateStack:pop()
|
||||
for _ = 1, Timing.FADE_IN_FROM_BLACK + 8 do StateStack:update(1 / 60) end
|
||||
eq(dones, 1, "and finishes once the box is gone")
|
||||
eq(#StateStack.states, 1, "leaving the overworld alone on the stack")
|
||||
check(StateStack:top() == ow, "and nothing else came off with it")
|
||||
end
|
||||
|
||||
-- ------------------------------------------------- finish is idempotent
|
||||
|
||||
do
|
||||
StateStack:init()
|
||||
local ow = overworld()
|
||||
StateStack:push(ow)
|
||||
local dones = 0
|
||||
local fade = Transition.new({ stack = StateStack }, nil,
|
||||
function() dones = dones + 1 end, true)
|
||||
StateStack:push(fade)
|
||||
fade:finish()
|
||||
fade:finish()
|
||||
eq(dones, 1, "a second finish does not hand back a second time")
|
||||
eq(#StateStack.states, 1, "and takes nothing else off the stack")
|
||||
check(StateStack:top() == ow, "the overworld survives the second call")
|
||||
end
|
||||
|
||||
-- a mod record may still ask a warp for a fade in; framesIn 0 is truthy in
|
||||
-- Lua, so the built-in warp keeps its 0 and the retimed one keeps its own
|
||||
do
|
||||
local retimed = { transitions = { warp_fade = { kind = "fade", frames = 32,
|
||||
framesIn = 16 } } }
|
||||
local fade = Transition.new({ data = retimed, stack = StateStack }, nil,
|
||||
nil, true)
|
||||
eq(fade.framesIn, 16, "a retimed warp record keeps its fade in")
|
||||
local vanilla = Transition.new({ stack = StateStack }, nil, nil, true)
|
||||
eq(vanilla.framesIn, Timing.WARP_FADE_IN, "the built-in warp has none")
|
||||
end
|
||||
|
||||
StateStack:clear()
|
||||
|
||||
T.finish("transition_identity_pop_bug1663")
|
||||
@@ -48,6 +48,15 @@ eq(Check.fullAssetName("1.4.2", "iOS", "arm64"),
|
||||
eq(Check.fullAssetName("not-a-version", "Android", "arm64"), nil,
|
||||
"invalid full-package version rejected")
|
||||
|
||||
-- A legacy Android shell needs one manual package update even when its
|
||||
-- downloaded payload already reports the latest engine version.
|
||||
eq(Check.androidNeedsInstallerBootstrap("Android", false), true,
|
||||
"legacy Android shell requires one bootstrap package")
|
||||
eq(Check.androidNeedsInstallerBootstrap("Android", true), false,
|
||||
"current Android shell keeps the selective updater")
|
||||
eq(Check.androidNeedsInstallerBootstrap("Windows", false), false,
|
||||
"non-Android update behavior remains unchanged")
|
||||
|
||||
local withNotes = Check.parseRelease(Json.encode({
|
||||
tag_name = "v1.4.2",
|
||||
body = "## Issues closed\n\n- #1 cart padding",
|
||||
|
||||
@@ -89,6 +89,15 @@ local MOVES = {
|
||||
accuracy = 100, pp = 10, effect = "EFFECT_ENDURE" },
|
||||
RAGE = { id = "RAGE", name = "RAGE", power = 20, type = "NORMAL",
|
||||
accuracy = 100, pp = 20, effect = "EFFECT_RAGE" },
|
||||
-- data/moves/moves.asm:133, :230, :189 and :92 rows, unedited.
|
||||
BIDE = { id = "BIDE", name = "BIDE", power = 0, type = "NORMAL",
|
||||
accuracy = 100, pp = 10, effect = "EFFECT_BIDE" },
|
||||
SLEEP_TALK = { id = "SLEEP_TALK", name = "SLEEP TALK", power = 0,
|
||||
type = "NORMAL", accuracy = 100, pp = 10, effect = "EFFECT_SLEEP_TALK" },
|
||||
SNORE = { id = "SNORE", name = "SNORE", power = 40, type = "NORMAL",
|
||||
accuracy = 100, pp = 15, effect = "EFFECT_SNORE", effectChance = 30 },
|
||||
SOLARBEAM = { id = "SOLARBEAM", name = "SOLARBEAM", power = 120,
|
||||
type = "GRASS", accuracy = 100, pp = 10, effect = "EFFECT_SOLARBEAM" },
|
||||
}
|
||||
|
||||
local GROWTH = {
|
||||
@@ -851,6 +860,83 @@ do
|
||||
check("the trainer's PP is untouched", refuseParty[1].moves[1].pp, 35)
|
||||
end
|
||||
|
||||
-- ResetBattleParticipants falls through into AddBattleParticipant
|
||||
-- (engine/battle/core.asm:3033 and :3037), so every ENEMY-initiated mon change
|
||||
-- wipes both bitfields and re-credits the mon the player has out. The player's
|
||||
-- own send-outs only ever call AddBattleParticipant (core.asm:2655, :2681,
|
||||
-- :3783, :4989, :5014).
|
||||
do
|
||||
local function creditBattle()
|
||||
local party = {
|
||||
Mon.new(DATA, "CYNDAQUIL", 20, { dvs = perfect }),
|
||||
Mon.new(DATA, "TOTODILE", 20, { dvs = perfect }),
|
||||
}
|
||||
local foes = {
|
||||
Mon.new(DATA, "GEODUDE", 8, { dvs = perfect }),
|
||||
Mon.new(DATA, "PIDGEY", 8, { dvs = perfect }),
|
||||
}
|
||||
for _, mon in ipairs(party) do
|
||||
mon.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } }
|
||||
end
|
||||
for _, mon in ipairs(foes) do
|
||||
mon.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } }
|
||||
end
|
||||
local battle = Battle.new({
|
||||
data = DATA, party = party,
|
||||
-- attributes[6] is the low byte of the switch flags: OFTEN.
|
||||
trainer = { class = "YOUNGSTER", name = "JOEY", party = foes,
|
||||
attributes = { 0, 0, 0, 0, 0, 0x01, 0 } },
|
||||
random = zeroRandom,
|
||||
})
|
||||
battle:switch(2)
|
||||
return battle, party, foes
|
||||
end
|
||||
|
||||
-- AI_Switch (engine/battle/ai/items.asm:697).
|
||||
local rotate, rotateParty, rotateFoes = creditBattle()
|
||||
check("both mons are credited before the rotation",
|
||||
rotate.participants[1] and rotate.participants[2], true)
|
||||
rotate:volatile(rotate.enemy).perish = 1
|
||||
check("the AI rotated", rotate:enemyTrySwitchOrItem(), true)
|
||||
check("the rotation installed the second foe", rotate.enemy, rotateFoes[2])
|
||||
check("the bench mon lost its credit", rotate.participants[1], nil)
|
||||
check("only the mon on the field keeps it", rotate.participants[2], true)
|
||||
local benchExp = rotateParty[1].experience
|
||||
local activeExp = rotateParty[2].experience
|
||||
rotate:awardExperience(rotate.enemy)
|
||||
check("the bench mon earns nothing from the new foe",
|
||||
rotateParty[1].experience, benchExp)
|
||||
check("and the mon that faced it is still paid",
|
||||
rotateParty[2].experience > activeExp, true)
|
||||
|
||||
-- ForceEnemySwitch (engine/battle/core.asm:2937), reached only from
|
||||
-- BattleCommand_ForceSwitch (effect_commands.asm:4999).
|
||||
local roar, _, roarFoes = creditBattle()
|
||||
roar.firstMover = "enemy"
|
||||
Battle.MOVE_EFFECTS.EFFECT_FORCE_SWITCH(roar, roar.player, roar.enemy,
|
||||
nil, "ROAR", true)
|
||||
check("Roar dragged the second foe out", roar.enemy, roarFoes[2])
|
||||
check("the bench mon lost its credit to Roar", roar.participants[1], nil)
|
||||
check("and the mon on the field keeps it", roar.participants[2], true)
|
||||
|
||||
-- engine/battle/move_effects/baton_pass.asm:59.
|
||||
local baton, _, batonFoes = creditBattle()
|
||||
Battle.MOVE_EFFECTS.EFFECT_BATON_PASS(baton, baton.enemy)
|
||||
check("the baton passed to the second foe", baton.enemy, batonFoes[2])
|
||||
check("the bench mon lost its credit to the baton",
|
||||
baton.participants[1], nil)
|
||||
check("and the mon on the field keeps it", baton.participants[2], true)
|
||||
|
||||
-- PassedBattleMonEntrance only adds (engine/battle/core.asm:5014): the
|
||||
-- player's own baton pass must NOT wipe the set.
|
||||
local playerBaton, playerBatonParty = creditBattle()
|
||||
Battle.MOVE_EFFECTS.EFFECT_BATON_PASS(playerBaton, playerBaton.player)
|
||||
check("the player's baton pass moved the lead back in",
|
||||
playerBaton.player, playerBatonParty[1])
|
||||
check("and credited both mons",
|
||||
playerBaton.participants[1] and playerBaton.participants[2], true)
|
||||
end
|
||||
|
||||
-- Switching costs the turn and adds the newcomer to the participant set.
|
||||
local switchParty = {
|
||||
Mon.new(DATA, "CYNDAQUIL", 10, { dvs = perfect }),
|
||||
@@ -2713,6 +2799,259 @@ end)()
|
||||
check("and its user reappears", b:volatile(player).vanished, nil)
|
||||
end)()
|
||||
|
||||
-- ------------------------------------------------------------------- Bide
|
||||
--
|
||||
-- data/moves/effects.asm:795-800 runs `storeenergy` ahead of `doturn`, and
|
||||
-- BattleCommand_DoTurn's mask drops SUBSTATUS_BIDE outright
|
||||
-- (engine/battle/effect_commands.asm:977-979), so the whole Bide costs the
|
||||
-- one PP its opening turn spent. The lock is ParsePlayerAction's own arm
|
||||
-- (engine/battle/core.asm:569-576) for the player and CheckEnemyLockedIn
|
||||
-- (:5650) for the foe.
|
||||
;(function()
|
||||
local function said(events, text)
|
||||
for _, e in ipairs(events) do
|
||||
if e.kind == "message" and e.text == text then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
local player = Mon.new(DATA, "CYNDAQUIL", 20, { dvs = perfect })
|
||||
player.moves = { { id = "BIDE", pp = 10, maxPp = 10 },
|
||||
{ id = "TACKLE", pp = 35, maxPp = 35 } }
|
||||
player.hp, player.maxHp = 999, 999
|
||||
local wild = Mon.new(DATA, "PIDGEY", 20, { dvs = perfect })
|
||||
wild.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } }
|
||||
wild.hp, wild.maxHp = 9999, 9999
|
||||
local b = Battle.new({ data = DATA, party = { player }, wild = wild,
|
||||
random = zeroRandom })
|
||||
b:takeEvents()
|
||||
|
||||
check("nothing forces the first BIDE", b:forcedMove(player), nil)
|
||||
b:useMove(player, wild, "BIDE")
|
||||
b:takeEvents()
|
||||
check("the opening turn spends one PP", player.moves[1].pp, 9)
|
||||
check("and the FIGHT menu is locked into it", b:forcedMove(player), "BIDE")
|
||||
check("with the move list narrowed to the one",
|
||||
#b:usableMoves(player), 1)
|
||||
|
||||
-- BattleCommand_StoreEnergy banks wPlayerDamageTaken while the bit is up.
|
||||
b:dealDamage(wild, player, 5, {})
|
||||
b:takeEvents()
|
||||
b:useMove(player, wild, "BIDE")
|
||||
check("a storing turn spends no PP", player.moves[1].pp, 9)
|
||||
check("and stays locked", b:forcedMove(player), "BIDE")
|
||||
check("`.still_storing` prints rather than attacking",
|
||||
said(b:takeEvents(), "CYNDAQUIL is storing energy!"), true)
|
||||
|
||||
-- A dry BIDE keeps running: the bide arm jumps past MoveSelectionScreen,
|
||||
-- where .CheckPlayerHasUsableMoves lives (engine/battle/core.asm:5058).
|
||||
player.moves[1].pp = 0
|
||||
check("a spent BIDE is still offered", #b:usableMoves(player), 1)
|
||||
check("...and it is the BIDE", b:usableMoves(player)[1].id, "BIDE")
|
||||
player.moves[1].pp = 9
|
||||
|
||||
local hpBefore = wild.hp
|
||||
b:useMove(player, wild, "BIDE")
|
||||
b:takeEvents()
|
||||
check("the release spends no PP either", player.moves[1].pp, 9)
|
||||
check("UnleashEnergy pays back double", wild.hp, hpBefore - 10)
|
||||
check("and the lock is gone", b:forcedMove(player), nil)
|
||||
|
||||
-- CheckEnemyLockedIn holds SUBSTATUS_BIDE, so the AI is never asked.
|
||||
local es = b:volatile(b.enemy)
|
||||
es.bideTurns, es.bideMove, es.bideStored = 2, "BIDE", 0
|
||||
check("a biding foe re-uses its Bide", b:enemyMove(), "BIDE")
|
||||
es.bideTurns, es.bideMove, es.bideStored = nil, nil, nil
|
||||
|
||||
-- .reset_bide (engine/battle/core.asm:572-573, :627-629): the PACK cancels
|
||||
-- a Bide, a switch does not.
|
||||
b:useMove(player, wild, "BIDE")
|
||||
b:takeEvents()
|
||||
check("locked again", b:forcedMove(player), "BIDE")
|
||||
b:takeTurn({ kind = "item", item = "POTION" })
|
||||
b:takeEvents()
|
||||
check("using an item cancels the Bide", b:forcedMove(player), nil)
|
||||
check("and drops the bank", b:volatile(player).bideStored, nil)
|
||||
|
||||
-- CantMove (engine/battle/effect_commands.asm:344-353) clears SUBSTATUS_BIDE
|
||||
-- on every arm that spends the turn, so a flinch ends the Bide.
|
||||
b:useMove(player, wild, "BIDE")
|
||||
b:takeEvents()
|
||||
check("locked once more", b:forcedMove(player), "BIDE")
|
||||
b:volatile(player).flinched = true
|
||||
check("a flinch spends the turn", b:canAct(player, "BIDE"), false)
|
||||
b:takeEvents()
|
||||
check("and CantMove ends the Bide", b:forcedMove(player), nil)
|
||||
check("bank dropped with it", b:volatile(player).bideStored, nil)
|
||||
|
||||
-- .not_linked reads SUBSTATUS_ENCORED before CheckEnemyLockedIn
|
||||
-- (engine/battle/core.asm:5524-5533), so an encored foe obeys the Encore.
|
||||
es.bideTurns, es.bideMove, es.bideStored = 2, "BIDE", 0
|
||||
es.encore, es.encoreTurns = "TACKLE", 3
|
||||
check("Encore outranks the foe's Bide lock", b:enemyMove(), "TACKLE")
|
||||
es.encore, es.encoreTurns = nil, nil
|
||||
check("without it the Bide lock holds", b:enemyMove(), "BIDE")
|
||||
es.bideTurns, es.bideMove, es.bideStored = nil, nil, nil
|
||||
end)()
|
||||
|
||||
-- --------------------------------------------------- Snore and Sleep Talk
|
||||
--
|
||||
-- `.fast_asleep` prints FastAsleepText and then falls into `.not_asleep` for
|
||||
-- those two moves instead of `call CantMove / jp EndTurn`
|
||||
-- (engine/battle/effect_commands.asm:188-200). BattleCommand_SleepTalk opens
|
||||
-- on ClearLastMove and ends in ResetTurn (move_effects/sleep_talk.asm:2, :61).
|
||||
;(function()
|
||||
local function said(events, text)
|
||||
for _, e in ipairs(events) do
|
||||
if e.kind == "message" and e.text == text then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
local function sleeper(moves)
|
||||
local player = Mon.new(DATA, "CYNDAQUIL", 20, { dvs = perfect })
|
||||
player.moves = moves
|
||||
player.hp, player.maxHp = 999, 999
|
||||
local wild = Mon.new(DATA, "PIDGEY", 20, { dvs = perfect })
|
||||
wild.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } }
|
||||
wild.hp, wild.maxHp = 9999, 9999
|
||||
local b = Battle.new({ data = DATA, party = { player }, wild = wild,
|
||||
random = zeroRandom })
|
||||
b:takeEvents()
|
||||
return b, player, wild
|
||||
end
|
||||
|
||||
local b, player, wild = sleeper({
|
||||
{ id = "SLEEP_TALK", pp = 10, maxPp = 10 },
|
||||
{ id = "TACKLE", pp = 35, maxPp = 35 } })
|
||||
|
||||
player.status, player.statusTurns = "sleep", 3
|
||||
check("an ordinary move still loses the turn to sleep",
|
||||
b:canAct(player, "TACKLE"), false)
|
||||
check("and the counter was spent", player.statusTurns, 2)
|
||||
check("FastAsleepText still goes up",
|
||||
said(b:takeEvents(), "CYNDAQUIL is fast asleep!"), true)
|
||||
|
||||
player.statusTurns = 3
|
||||
check("SLEEP TALK is let through", b:canAct(player, "SLEEP_TALK"), true)
|
||||
check("...and still spends its sleep turn", player.statusTurns, 2)
|
||||
check("...and still prints the line",
|
||||
said(b:takeEvents(), "CYNDAQUIL is fast asleep!"), true)
|
||||
player.statusTurns = 3
|
||||
check("SNORE is let through too", b:canAct(player, "SNORE"), true)
|
||||
b:takeEvents()
|
||||
|
||||
-- The wake-up arm is not a bypass: it answers for the whole turn.
|
||||
player.statusTurns = 1
|
||||
check("the last sleep turn wakes up", b:canAct(player, "SLEEP_TALK"), true)
|
||||
check("and clears the status", player.status, nil)
|
||||
|
||||
player.status, player.statusTurns = "sleep", 5
|
||||
local hpBefore = wild.hp
|
||||
b:useMove(player, wild, "SLEEP_TALK")
|
||||
b:takeEvents()
|
||||
check("SLEEP TALK pays its own PP through doturn", player.moves[1].pp, 9)
|
||||
check("but the move it calls pays none (ResetTurn)", player.moves[2].pp, 35)
|
||||
check("and that move really landed", wild.hp < hpBefore, true)
|
||||
check("ClearLastMove leaves no last move (used_move_text.asm:30-36)",
|
||||
b:volatile(player).lastMove, nil)
|
||||
|
||||
-- .check_two_turn_move (sleep_talk.asm:117-141) drops the five charge
|
||||
-- effects and EFFECT_BIDE, so a mon with nothing else fails.
|
||||
local b2, player2, wild2 = sleeper({
|
||||
{ id = "SLEEP_TALK", pp = 10, maxPp = 10 },
|
||||
{ id = "SOLARBEAM", pp = 10, maxPp = 10 } })
|
||||
player2.status, player2.statusTurns = "sleep", 5
|
||||
b2:useMove(player2, wild2, "SLEEP_TALK")
|
||||
check("a two-turn move is never sampled",
|
||||
said(b2:takeEvents(), "But it failed!"), true)
|
||||
check("and nothing was called", b2:volatile(player2).chargeMove, nil)
|
||||
|
||||
-- BattleCommand_SleepTalk's own `and SLP_MASK / jr z, .fail` (:16-19).
|
||||
local b3, player3, wild3 = sleeper({
|
||||
{ id = "SLEEP_TALK", pp = 10, maxPp = 10 },
|
||||
{ id = "TACKLE", pp = 35, maxPp = 35 } })
|
||||
b3:useMove(player3, wild3, "SLEEP_TALK")
|
||||
check("an awake SLEEP TALK fails",
|
||||
said(b3:takeEvents(), "But it failed!"), true)
|
||||
|
||||
-- BattleCommand_Snore (move_effects/snore.asm:1-9) is the same refusal.
|
||||
local b4, player4, wild4 = sleeper({ { id = "SNORE", pp = 15, maxPp = 15 } })
|
||||
local snoreBefore = wild4.hp
|
||||
b4:useMove(player4, wild4, "SNORE")
|
||||
check("an awake SNORE fails", said(b4:takeEvents(), "But it failed!"), true)
|
||||
check("and deals nothing", wild4.hp, snoreBefore)
|
||||
player4.status, player4.statusTurns = "sleep", 5
|
||||
b4:useMove(player4, wild4, "SNORE")
|
||||
b4:takeEvents()
|
||||
check("a sleeping SNORE hits", wild4.hp < snoreBefore, true)
|
||||
end)()
|
||||
|
||||
-- ------------------------------------------- fainted mons stop participating
|
||||
--
|
||||
-- UpdateFaintedPlayerMon RESET_FLAGs wBattleParticipantsNotFainted
|
||||
-- (engine/battle/core.asm:2551-2556) and .EvenlyDivideExpAmongParticipants
|
||||
-- divides by the count of set bits (:7118-7130), so the survivor of a lost
|
||||
-- lead collects a whole share, not half of one.
|
||||
;(function()
|
||||
local function twoMonBattle()
|
||||
local one = Mon.new(DATA, "CYNDAQUIL", 20, { dvs = perfect })
|
||||
one.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } }
|
||||
local two = Mon.new(DATA, "CYNDAQUIL", 20, { dvs = perfect })
|
||||
two.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } }
|
||||
local wild = Mon.new(DATA, "PIDGEY", 20, { dvs = perfect })
|
||||
wild.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } }
|
||||
local b = Battle.new({ data = DATA, party = { one, two }, wild = wild,
|
||||
random = zeroRandom })
|
||||
b:takeEvents()
|
||||
return b, one, two, wild
|
||||
end
|
||||
|
||||
local b, one, two, wild = twoMonBattle()
|
||||
check("the lead starts as a participant", b.participants[1], true)
|
||||
one.hp = 0
|
||||
b:resolveFaints()
|
||||
b:takeEvents()
|
||||
check("a fainted participant drops out", b.participants[1], nil)
|
||||
|
||||
-- The clear is one-shot: GiveExperiencePoints' `.done` falls through
|
||||
-- ResetBattleParticipants into AddBattleParticipant (:7116, :3033-3037) and
|
||||
-- puts the dead slot's bit back, and nothing takes it off again.
|
||||
local oneShot = twoMonBattle()
|
||||
oneShot.player.hp = 0
|
||||
oneShot:resolveFaints()
|
||||
oneShot:takeEvents()
|
||||
oneShot:resetParticipants()
|
||||
oneShot:resolveFaints()
|
||||
oneShot:takeEvents()
|
||||
check("and the cart's own re-add survives a second pass",
|
||||
oneShot.participants[1], true)
|
||||
|
||||
b:switch(2)
|
||||
b:takeEvents()
|
||||
check("the replacement is a participant", b.participants[2], true)
|
||||
check("...and the fainted lead is not", b.participants[1], nil)
|
||||
local before = two.experience
|
||||
wild.hp = 0
|
||||
b:resolveFaints()
|
||||
b:takeEvents()
|
||||
local shared = two.experience - before
|
||||
|
||||
-- The control: the same KO with the same mon as the only party member.
|
||||
local solo = Mon.new(DATA, "CYNDAQUIL", 20, { dvs = perfect })
|
||||
solo.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } }
|
||||
local soloWild = Mon.new(DATA, "PIDGEY", 20, { dvs = perfect })
|
||||
soloWild.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } }
|
||||
local soloBattle = Battle.new({ data = DATA, party = { solo },
|
||||
wild = soloWild, random = zeroRandom })
|
||||
soloBattle:takeEvents()
|
||||
local soloBefore = solo.experience
|
||||
soloWild.hp = 0
|
||||
soloBattle:resolveFaints()
|
||||
soloBattle:takeEvents()
|
||||
check("the survivor gets a whole share, not half",
|
||||
shared, solo.experience - soloBefore)
|
||||
check("and the share is a real number", shared > 0, true)
|
||||
end)()
|
||||
|
||||
print(("gen2 battle: %d checks, %d failures"):format(checks, failures))
|
||||
|
||||
-- Raise rather than os.exit: tests/run_tests.lua dofiles this file, so an
|
||||
|
||||
@@ -175,7 +175,7 @@ do
|
||||
dealt = event.amount
|
||||
end
|
||||
if event.kind == "message"
|
||||
and event.text == "It's not very effective..." then
|
||||
and event.text == "It's not very\neffective…" then
|
||||
sawNve = true
|
||||
end
|
||||
end
|
||||
|
||||
@@ -42,7 +42,7 @@ check(type(RomExtractorGen2.extractDiploma) == "function",
|
||||
"RomExtractorGen2:extractDiploma exists")
|
||||
check(extractorSource:find("results.diploma = self:extractDiploma()", 1, true)
|
||||
~= nil, "and RomExtractorGen2:run calls it")
|
||||
check(extractorSource:find("local STAGE_COUNT = 26", 1, true) ~= nil,
|
||||
check(extractorSource:find("local STAGE_COUNT = 27", 1, true) ~= nil,
|
||||
"STAGE_COUNT counts the new stage, so the progress bar still ends at 1")
|
||||
|
||||
-- The three symbols the stage reads have to be in the curated manifest set or
|
||||
|
||||
@@ -213,4 +213,115 @@ do
|
||||
"all four arms compose in the cart's order")
|
||||
end
|
||||
|
||||
-- ---- battle.exp_award: applyShare's third argument ------------------------
|
||||
--
|
||||
-- Gen 1's applyShare(mon, split, announce) pays the mon and prints its
|
||||
-- GainedText only when `announce` is truthy (src/battle/BattleState.lua).
|
||||
-- Gold accepted the argument and ignored it, so a mod that pays the whole
|
||||
-- party -- the Exp Share mod's own documented behaviour -- could not print one
|
||||
-- summary line here; it got a box per recipient.
|
||||
--
|
||||
-- Two tests, per Route B in CONTRIBUTING-mods.md: vanilla is unchanged with no
|
||||
-- subscriber, and the seam is driven through the public hook API.
|
||||
|
||||
local Events = require("src.mods.Events")
|
||||
local Hooks = require("src.mods.Hooks")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
|
||||
-- Counts the GainedText boxes an award produced.
|
||||
local function expLines(events)
|
||||
local out = {}
|
||||
for _, event in ipairs(events) do
|
||||
if event.kind == "experience" then out[#out + 1] = event.text end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
local function kindsOf(events)
|
||||
local out = {}
|
||||
for _, event in ipairs(events) do out[#out + 1] = event.kind end
|
||||
return table.concat(out, ",")
|
||||
end
|
||||
|
||||
-- ---- the no-mod test: nothing subscribed, nothing changes -----------------
|
||||
do
|
||||
local _, events = award({ { otId = PLAYER_ID, participant = true } })
|
||||
eq(#expLines(events), 1, "no subscriber: a solo participant still prints one line")
|
||||
|
||||
local _, twoPass = award({
|
||||
{ otId = PLAYER_ID, participant = true },
|
||||
{ otId = PLAYER_ID, item = "EXP_SHARE" },
|
||||
})
|
||||
eq(#expLines(twoPass), 2,
|
||||
"no subscriber: the EXP.SHARE double pass still prints both lines")
|
||||
end
|
||||
|
||||
-- ---- the mod-API test: driven through hooks:wrap, not internals -----------
|
||||
do
|
||||
local savedEvents, savedHooks = Runtime.events, Runtime.hooks
|
||||
local hooks = Hooks.new()
|
||||
Runtime.install(Events.new(), hooks)
|
||||
|
||||
-- Each case pays a two-mon party through ctx.applyShare and reports the
|
||||
-- GainedText boxes that survived. The party mon in slot 2 never fights, so
|
||||
-- it stands in for the bench a party-wide mod pays.
|
||||
local function awardVia(payer)
|
||||
local unsub = hooks:wrap("battle.exp_award", function(_nextFn, ctx)
|
||||
payer(ctx)
|
||||
end)
|
||||
local gained, events, party = award({
|
||||
{ otId = PLAYER_ID, participant = true },
|
||||
{ otId = PLAYER_ID },
|
||||
})
|
||||
unsub()
|
||||
return gained, events, party
|
||||
end
|
||||
|
||||
-- omitted: the Gen 2-era call, which has always announced
|
||||
local gained, events = awardVia(function(ctx)
|
||||
ctx.applyShare(ctx.battle.party[1], 1)
|
||||
ctx.applyShare(ctx.battle.party[2], 1)
|
||||
end)
|
||||
eq(#expLines(events), 2,
|
||||
"applyShare(mon, split) still announces -- no existing mod changes")
|
||||
check(gained[1] > 0 and gained[2] > 0, "and both mons were paid")
|
||||
|
||||
-- passed nil: "pay this one quietly", Gen 1's reading
|
||||
local quietGained, quietEvents = awardVia(function(ctx)
|
||||
ctx.applyShare(ctx.battle.party[1], 1, true)
|
||||
ctx.applyShare(ctx.battle.party[2], 1, nil)
|
||||
end)
|
||||
eq(#expLines(quietEvents), 1,
|
||||
"an explicit nil third argument pays the mon silently")
|
||||
eq(expLines(quietEvents)[1], "MACHOP gained 110 EXP. Points!",
|
||||
"and the announced participant keeps its own line")
|
||||
check(quietGained[2] > 0, "the silent mon is still paid the same exp")
|
||||
eq(quietGained[1], gained[1], "and the announced mon's exp is untouched")
|
||||
eq(quietGained[2], gained[2], "as is the silent one's")
|
||||
|
||||
-- false reads the same as nil; a truthy string is Gen 1's EXP.ALL variant
|
||||
local _, falseEvents = awardVia(function(ctx)
|
||||
ctx.applyShare(ctx.battle.party[1], 1, false)
|
||||
end)
|
||||
eq(#expLines(falseEvents), 0, "false is silent too")
|
||||
|
||||
local _, expAllEvents = awardVia(function(ctx)
|
||||
ctx.applyShare(ctx.battle.party[1], 1, "expAll")
|
||||
end)
|
||||
eq(#expLines(expAllEvents), 1,
|
||||
"any truthy value announces, so Gen 1's \"expAll\" carries over")
|
||||
|
||||
-- a silent award is still a whole award: only the line goes
|
||||
local _, levelEvents, levelParty = awardVia(function(ctx)
|
||||
ctx.applyShare(ctx.battle.party[2], 1, nil)
|
||||
end)
|
||||
eq(#expLines(levelEvents), 0, "the silent pass prints no GainedText")
|
||||
check(levelParty[2].statExp.attack > 0,
|
||||
"but stat exp is still awarded on a silent pass")
|
||||
check(not kindsOf(levelEvents):find("experience", 1, true),
|
||||
"and no experience event leaks into the queue")
|
||||
|
||||
Runtime.install(savedEvents, savedHooks)
|
||||
end
|
||||
|
||||
S.finish()
|
||||
|
||||
@@ -266,6 +266,18 @@ do
|
||||
"and with no hook at all the special is a no-op, not a clobber")
|
||||
end
|
||||
|
||||
-- ---- MagnetTrainRide UI screen --------------------------------------------
|
||||
do
|
||||
local MagnetTrainRide = require("src.ui.gen2.MagnetTrainRide")
|
||||
check(MagnetTrainRide.isOpaque, "MagnetTrainRide is opaque")
|
||||
local ride = MagnetTrainRide.new(nil, { toGoldenrod = false })
|
||||
check(ride:wantsFillScale(), "wantsFillScale returns true")
|
||||
check(ride:drawsWidescreen(), "drawsWidescreen returns true")
|
||||
check(type(ride.drawBands) == "function", "drawBands exists")
|
||||
check(type(ride.backgrounds) == "function", "backgrounds exists for OAM_PRIO")
|
||||
end
|
||||
|
||||
|
||||
-- ---- the arrival, from `warpcheck` to the officer's line -------------------
|
||||
--
|
||||
-- The ride is only half the feature: the two station scripts end
|
||||
|
||||
@@ -483,6 +483,99 @@ packInput:press("right")
|
||||
pack:update(0)
|
||||
check("pocket wraps around", pack:pocket().id, "ITEM")
|
||||
|
||||
-- engine/items/tmhm.asm:341 -- TMHM_DisplayPocketItems walks wTMsHMs 1..57, so
|
||||
-- the pocket is TM01..TM50 then HM01..HM07 whatever order the player picked
|
||||
-- them up in, and tmhm.asm:207 keeps SELECT out of it entirely.
|
||||
do
|
||||
local tmSave2 = Save.newGame()
|
||||
tmSave2.inventory = {
|
||||
HM_WATERFALL = 1, TM_NIGHTMARE = 2, HM_CUT = 1, TM_ROAR = 3,
|
||||
TM_DYNAMICPUNCH = 12, TM_LEGACY = 1,
|
||||
}
|
||||
tmSave2.bagOrder = {
|
||||
"HM_WATERFALL", "TM_NIGHTMARE", "HM_CUT", "TM_ROAR", "TM_DYNAMICPUNCH",
|
||||
"TM_LEGACY",
|
||||
}
|
||||
local tmGame2, tmInput2 = newGame(tmSave2)
|
||||
tmGame2.data.items = {
|
||||
TM_DYNAMICPUNCH = { id = "TM_DYNAMICPUNCH", name = "TM01",
|
||||
pocket = "TM_HM", index = 191, tmNumber = 1 },
|
||||
TM_ROAR = { id = "TM_ROAR", name = "TM05", pocket = "TM_HM",
|
||||
index = 195, tmNumber = 5 },
|
||||
TM_NIGHTMARE = { id = "TM_NIGHTMARE", name = "TM50", pocket = "TM_HM",
|
||||
index = 240, tmNumber = 50 },
|
||||
HM_CUT = { id = "HM_CUT", name = "HM01", pocket = "TM_HM",
|
||||
index = 241, tmNumber = 51 },
|
||||
HM_WATERFALL = { id = "HM_WATERFALL", name = "HM07", pocket = "TM_HM",
|
||||
index = 247, tmNumber = 57 },
|
||||
-- A cache (or a mod) with no tmNumber falls back to the ItemNames index.
|
||||
TM_LEGACY = { id = "TM_LEGACY", name = "TM??", pocket = "TM_HM",
|
||||
index = 300 },
|
||||
}
|
||||
local tmPack = PackMenu.new(tmGame2, { pocket = "TM_HM" })
|
||||
local function ids(rows)
|
||||
local out = {}
|
||||
for i = 1, #rows do out[i] = rows[i].id end
|
||||
return table.concat(out, ",")
|
||||
end
|
||||
check("TM/HM pocket is TM-number ordered, not pickup ordered",
|
||||
ids(tmPack.rows),
|
||||
"TM_DYNAMICPUNCH,TM_ROAR,TM_NIGHTMARE,HM_CUT,HM_WATERFALL,TM_LEGACY")
|
||||
check("the first row is TM01", tmPack.rows[1].id, "TM_DYNAMICPUNCH")
|
||||
check("and a tmNumber-less item lands after HM07",
|
||||
tmPack.rows[#tmPack.rows].id, "TM_LEGACY")
|
||||
check("the TM keeps its count", tmPack.rows[1].showCount, true)
|
||||
check("the HM shows none", tmPack.rows[4].showCount, false)
|
||||
|
||||
tmPack.index = 1
|
||||
tmInput2:press("select")
|
||||
tmPack:update(0)
|
||||
check("SELECT cannot arm a TM/HM row", tmPack.switching, nil)
|
||||
check("and prints no move prompt", tmPack.message, nil)
|
||||
|
||||
-- The other three pockets still reorder on SELECT (pack.asm:1290).
|
||||
tmSave2.inventory.POTION = 1
|
||||
tmSave2.inventory.SUPER_POTION = 1
|
||||
tmSave2.bagOrder = { "POTION", "SUPER_POTION" }
|
||||
tmGame2.data.items.POTION =
|
||||
{ id = "POTION", name = "POTION", pocket = "ITEM", index = 1 }
|
||||
tmGame2.data.items.SUPER_POTION =
|
||||
{ id = "SUPER_POTION", name = "SUPER POTION", pocket = "ITEM", index = 2 }
|
||||
tmPack.pocketIndex = 1
|
||||
tmPack:rebuild()
|
||||
tmPack.index = 1
|
||||
tmInput2:press("select")
|
||||
tmPack:update(0)
|
||||
check("but the ITEM pocket still arms", tmPack.switching, 1)
|
||||
end
|
||||
|
||||
-- item_data_constants.asm:47 MAX_ITEMS / MAX_BALLS / MAX_KEY_ITEMS, and the
|
||||
-- TM/HM pocket is wTMsHMs (ram/wram.asm:2421), NUM_TMS + NUM_HMS = 57 bytes:
|
||||
-- 50 add_tm rows and 7 add_hm rows in constants/item_constants.asm:220-293.
|
||||
do
|
||||
local Bag = require("src.inventory.Bag")
|
||||
check("ITEM pocket is MAX_ITEMS", Bag.capacity(packGame.data, "ITEM"), 20)
|
||||
check("BALL pocket is MAX_BALLS", Bag.capacity(packGame.data, "BALL"), 12)
|
||||
check("KEY_ITEM pocket is MAX_KEY_ITEMS",
|
||||
Bag.capacity(packGame.data, "KEY_ITEM"), 25)
|
||||
check("TM/HM pocket is NUM_TMS + NUM_HMS",
|
||||
Bag.capacity(packGame.data, "TM_HM"), 57)
|
||||
|
||||
-- Bag.add tests the cap before inserting, so all 57 cart TM/HMs fit.
|
||||
local tmData = { items = {}, constants = { bagSize = 2 } }
|
||||
for i = 1, 58 do
|
||||
tmData.items["TM_FIX_" .. i] = { id = "TM_FIX_" .. i, name = "TM" .. i,
|
||||
pocket = "TM_HM", index = 200 + i }
|
||||
end
|
||||
check("a mod's bagSize resizes the ITEM pocket only",
|
||||
Bag.capacity(tmData, "TM_HM"), 57)
|
||||
local tmSave = { inventory = {}, bagOrder = {} }
|
||||
for i = 1, 57 do Bag.add(tmSave, "TM_FIX_" .. i, 1, tmData) end
|
||||
check("all 57 of them fit", Bag.slots(tmSave, tmData, "TM_HM"), 57)
|
||||
check("and a 58th TM/HM id has no byte to live in",
|
||||
Bag.add(tmSave, "TM_FIX_58", 1, tmData), false)
|
||||
end
|
||||
|
||||
-- CANCEL sits one past the last row.
|
||||
check("cancel is past the end", pack:total(), #pack.rows + 1)
|
||||
pack.index = pack:total()
|
||||
|
||||
@@ -310,7 +310,8 @@ do
|
||||
eq(save.inventory.TM_HEADBUTT, nil, "and leaves the bag")
|
||||
end
|
||||
|
||||
-- .TryDepositItem's .no_toss: a KEY ITEM stays in the bag, silently.
|
||||
-- .DepositItem: CANT_TOSS only skips .AskQuantity. A KEY ITEM deposits x1,
|
||||
-- and .Submenu withdraws it back the same way (issue #1486).
|
||||
do
|
||||
local save = newSave(1)
|
||||
local game, input = newGame(save)
|
||||
@@ -320,9 +321,63 @@ do
|
||||
press(pc, input, "right", "right") -- ITEM -> BALL -> KEY_ITEM pocket
|
||||
press(pc, input, "a") -- choose the BICYCLE
|
||||
eq(pc.qtyState, nil, "no quantity selector for a KEY ITEM")
|
||||
eq(pc.message, nil, "no message either: .no_toss is a bare ret")
|
||||
eq(save.pcItems.BICYCLE, nil, "and the BICYCLE never leaves the bag")
|
||||
eq(save.inventory.BICYCLE, 1, "still there")
|
||||
eq(save.pcItems.BICYCLE, 1, "the BICYCLE lands in the PC")
|
||||
eq(save.inventory.BICYCLE, nil, "and leaves the bag")
|
||||
eq(pc.message.pages[1][1], "Deposited 1", "_PlayersPCDepositItemsText")
|
||||
press(pc, input, "a") -- clear it
|
||||
press(pc, input, "b") -- close the PACK
|
||||
press(pc, input, "up", "a") -- WITHDRAW ITEM
|
||||
eq(pc.phase, "withdraw", "the PC item list opens on the KEY ITEM")
|
||||
press(pc, input, "a") -- the BICYCLE row
|
||||
eq(pc.qtyState, nil, "no quantity selector on the way back either")
|
||||
eq(save.inventory.BICYCLE, 1, "the BICYCLE is back in the bag")
|
||||
eq(save.pcItems.BICYCLE, nil, "and out of the PC")
|
||||
end
|
||||
|
||||
-- The TM_HM pocket's HMs are CANT_TOSS too, and deposit prompt-free x1.
|
||||
do
|
||||
local save = newSave(1)
|
||||
local game, input = newGame(save)
|
||||
Bag.add(save, "HM_CUT", 1, game.data)
|
||||
local pc = ItemPcMenu.new(game, { save = save, items = ITEMS })
|
||||
press(pc, input, "down", "a") -- DEPOSIT ITEM
|
||||
press(pc, input, "right", "right", "right") -- ITEM -> BALL -> KEY_ITEM -> TM_HM
|
||||
press(pc, input, "a") -- choose HM01
|
||||
eq(pc.qtyState, nil, "no quantity selector for an HM")
|
||||
eq(save.pcItems.HM_CUT, 1, "HM01 lands in the PC")
|
||||
eq(save.inventory.HM_CUT, nil, "and leaves the bag")
|
||||
end
|
||||
|
||||
-- PlaceMenuItemQuantity: the PC list draws no xNN for a CANT_TOSS row.
|
||||
do
|
||||
local save = newSave(1)
|
||||
local game = newGame(save)
|
||||
save.pcItems = { POTION = 3, HM_CUT = 1 }
|
||||
local pc = ItemPcMenu.new(game, { save = save, items = ITEMS })
|
||||
local Chrome = require("src.ui.gen2.Chrome")
|
||||
local TIMES = "\xc3\x97"
|
||||
local saved = { print = Chrome.print, box = Chrome.box,
|
||||
cursor = Chrome.cursor }
|
||||
local printed = {}
|
||||
Chrome.print = function(text, x, y)
|
||||
printed[#printed + 1] = { text = text, x = x, y = y }
|
||||
end
|
||||
Chrome.box = function() end
|
||||
Chrome.cursor = function() end
|
||||
pc.phase = "withdraw"
|
||||
pc:rebuild()
|
||||
local drew, err = pcall(function() pc:drawList() end)
|
||||
Chrome.print, Chrome.box, Chrome.cursor = saved.print, saved.box, saved.cursor
|
||||
check(drew, "the PC list draws: " .. tostring(err))
|
||||
local rowY = {}
|
||||
for i, row in ipairs(pc.rows) do rowY[row.id] = i * 2 end
|
||||
local counts = {}
|
||||
for _, p in ipairs(printed) do
|
||||
if p.x == 7 then counts[p.y] = p.text end
|
||||
end
|
||||
eq(counts[rowY.POTION + 1], TIMES .. " 3", "the POTION stack keeps its xNN")
|
||||
eq(counts[rowY.HM_CUT + 1], nil,
|
||||
"and the HM row draws none (PlaceMenuItemQuantity .done)")
|
||||
end
|
||||
|
||||
-- An empty bag never opens the PACK (.CheckItemsInBag).
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
-- Gen 2's engine text, the counterpart to Gen 1's data/generated/text.lua.
|
||||
--
|
||||
-- Gold and Silver had no label-keyed string table at all: the manifests
|
||||
-- carried no `text` section, RomExtractorGen2 had no extractText, and
|
||||
-- game.data.text was never assigned, so every call through
|
||||
-- src/core/RomText.lua fell back to the literal written beside it. These
|
||||
-- cover the three halves of closing that: the manifest names the labels and
|
||||
-- resolves every one, the decoder emits the runtime name slots rather than
|
||||
-- dropping them, and RomText fills those slots.
|
||||
--
|
||||
-- GOLD_CACHE="..." luajit tests/gen2_rom_text_test.lua
|
||||
--
|
||||
-- ROM-free apart from the last section, which reads an imported cache's
|
||||
-- rom_text.lua and skips cleanly when there is none.
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local S = require("tests.harness").suite("gen2 rom text")
|
||||
local check, eq = S.check, S.eq
|
||||
|
||||
love = require("tests.love_stub")
|
||||
|
||||
local Json = require("src.link.Json")
|
||||
local romText = require("src.core.RomText")
|
||||
|
||||
local function manifest(path)
|
||||
local file = assert(io.open(path, "r"))
|
||||
local data = assert(Json.decode(file:read("*a")))
|
||||
file:close()
|
||||
return data
|
||||
end
|
||||
|
||||
-- ---- the label list, and that every label resolves ------------------------
|
||||
-- A label the list names but the symbol table cannot place would fail the
|
||||
-- import at the Dialogue stage rather than at generation time, so the pairing
|
||||
-- is asserted here instead.
|
||||
for _, edition in ipairs({ "gold", "silver" }) do
|
||||
local data = manifest("tools/rom_manifest_" .. edition .. ".json")
|
||||
local labels = (data.text or {}).labels or {}
|
||||
check((data.text or {}).labels ~= nil,
|
||||
edition .. " carries a text section")
|
||||
check(#labels > 800,
|
||||
("%s names %d text labels"):format(edition, #labels))
|
||||
|
||||
local unresolved = {}
|
||||
for _, label in ipairs(labels) do
|
||||
if not data.symbols[label] then unresolved[#unresolved + 1] = label end
|
||||
end
|
||||
eq(#unresolved, 0,
|
||||
("every %s text label resolves to a symbol (%s)")
|
||||
:format(edition, table.concat(unresolved, ", "):sub(1, 60)))
|
||||
|
||||
local named = {}
|
||||
for _, label in ipairs(labels) do named[label] = true end
|
||||
|
||||
-- data/text/ also holds keyboard layouts and kana tables. Decoded as text
|
||||
-- they come out as keyboard rows, so make_gold_manifest.TEXT_SOURCES leaves
|
||||
-- their files out. `BattleText::` is excluded for a different reason: it
|
||||
-- is a bank anchor sharing an address with the first real label under it,
|
||||
-- and its own comment in the disassembly says so.
|
||||
for _, excluded in ipairs({ "NameInputLower", "MailEntry_Uppercase",
|
||||
"Dakutens", "Gen1TrainerClassNames", "BattleText" }) do
|
||||
check(not named[excluded],
|
||||
edition .. " leaves " .. excluded .. " out of the text list")
|
||||
end
|
||||
end
|
||||
|
||||
-- Both editions describe the same strings; only the addresses move.
|
||||
do
|
||||
local gold = (manifest("tools/rom_manifest_gold.json").text or {}).labels or {}
|
||||
local silver =
|
||||
(manifest("tools/rom_manifest_silver.json").text or {}).labels or {}
|
||||
eq(#gold, #silver, "Gold and Silver name the same number of labels")
|
||||
local mismatch
|
||||
for index, label in ipairs(gold) do
|
||||
if silver[index] ~= label then mismatch = label; break end
|
||||
end
|
||||
eq(mismatch, nil, "and the same labels in the same order")
|
||||
end
|
||||
|
||||
-- ---- the slots RomText fills ----------------------------------------------
|
||||
-- decodeGen2Text emits {USER}, {TARGET} and {ENEMY} for the three names
|
||||
-- PlaceMoveUsersName / PlaceMoveTargetsName / PlaceEnemysName write at
|
||||
-- runtime (home/text.asm:302, :307, :327). Dropped, the line printed with a
|
||||
-- hole where the name belongs.
|
||||
do
|
||||
local data = { text = {
|
||||
SubTookDamageText = "The SUBSTITUTE\ntook damage for\v{TARGET}!",
|
||||
WantsToBattleText = "{ENEMY}\nwants to battle!",
|
||||
ConfusedNoMoreText = "{USER}'s\nconfused no more!",
|
||||
SuperEffectiveText = "It's super-\neffective!",
|
||||
} }
|
||||
|
||||
eq(romText(data, "SubTookDamageText", "fallback", "GEODUDE"),
|
||||
"The SUBSTITUTE\ntook damage for\vGEODUDE!",
|
||||
"a {TARGET} slot takes the name the caller passes")
|
||||
eq(romText(data, "WantsToBattleText", "fallback", "FALKNER"),
|
||||
"FALKNER\nwants to battle!", "and so does {ENEMY}")
|
||||
eq(romText(data, "ConfusedNoMoreText", "fallback", "CYNDAQUIL"),
|
||||
"CYNDAQUIL's\nconfused no more!", "and {USER}")
|
||||
eq(romText(data, "SuperEffectiveText", "It's super effective!"),
|
||||
"It's super-\neffective!",
|
||||
"a line with no slot comes back as the cart wrote it")
|
||||
eq(romText(data, "NoSuchLabel", "the engine's own wording"),
|
||||
"the engine's own wording",
|
||||
"and a label the cache does not carry falls back")
|
||||
end
|
||||
|
||||
-- ---- against a real imported cache ----------------------------------------
|
||||
do
|
||||
local cache = os.getenv("GOLD_CACHE")
|
||||
if not cache then
|
||||
local home = os.getenv("HOME") or ""
|
||||
cache = home .. "/Library/Application Support/LOVE/gold-dev/gold"
|
||||
end
|
||||
local path = cache .. "/data/generated/rom_text.lua"
|
||||
local file = io.open(path, "r")
|
||||
if not file then
|
||||
print(" (skipped: no rom_text.lua at " .. path .. ")")
|
||||
else
|
||||
file:close()
|
||||
local texts = assert(loadfile(path))()
|
||||
check(next(texts) ~= nil, "the imported cache carries strings")
|
||||
-- Wording taken from pokegold's data/text/battle.asm, with \n for `line`
|
||||
-- and \v for `cont`, which is what RomExtractorGen2 decodes those to.
|
||||
eq(texts.SuperEffectiveText, "It's super-\neffective!",
|
||||
"SuperEffectiveText comes off the cart hyphenated and broken")
|
||||
eq(texts.NotVeryEffectiveText, "It's not very\neffective…",
|
||||
"NotVeryEffectiveText ends on the ellipsis glyph")
|
||||
eq(texts.StartPerishText, "Both POKéMON will\nfaint in 3 turns!",
|
||||
"StartPerishText names both sides")
|
||||
eq(texts.ButItFailedText, "But it failed!", "and a one-row line is one row")
|
||||
eq(texts.SubTookDamageText, "The SUBSTITUTE\ntook damage for\v{TARGET}!",
|
||||
"SpikesText's neighbour keeps its cont row and its target slot")
|
||||
eq(texts.PlayerHitTimesText, "Hit {NUM} times!",
|
||||
"a text_decimal reads back as {NUM}")
|
||||
end
|
||||
end
|
||||
|
||||
S.finish()
|
||||
@@ -45,7 +45,7 @@ check(type(RomExtractorGen2.extractTrade) == "function",
|
||||
"RomExtractorGen2:extractTrade exists")
|
||||
check(extractorSource:find("results.trade = self:extractTrade()", 1, true)
|
||||
~= nil, "and RomExtractorGen2:run calls it")
|
||||
check(extractorSource:find("local STAGE_COUNT = 26", 1, true) ~= nil,
|
||||
check(extractorSource:find("local STAGE_COUNT = 27", 1, true) ~= nil,
|
||||
"STAGE_COUNT counts the new stage, so the progress bar still ends at 1")
|
||||
|
||||
-- pokegold.sym, bank $0a. These are also what the cache is checked against
|
||||
|
||||
+3
-1
@@ -56,7 +56,9 @@ stub.graphics = {
|
||||
end,
|
||||
newQuad = function(x, y, w, h) return { x = x, y = y, w = w, h = h } end,
|
||||
newCanvas = function(w, h)
|
||||
return setmetatable({ w = w, h = h, setFilter = noop }, Image)
|
||||
local canvas = setmetatable({ w = w, h = h, setFilter = noop, released = false }, Image)
|
||||
function canvas:release() self.released = true end
|
||||
return canvas
|
||||
end,
|
||||
newSpriteBatch = function(image, size)
|
||||
local batch = { image = image, sprites = {} }
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
-- T4: a mod may not reach a Gen 2 engine module from a Gen 1 game.
|
||||
--
|
||||
-- src/battle/gen2/Mon.lua returns { hp, attack, defense, speed, specialAttack,
|
||||
-- specialDefense }; a Gen 1 party mon carries `special` instead. A Yellow mod
|
||||
-- that required that module and ran refreshStats over save.party wrote the Gen
|
||||
-- 2 block onto Gen 1 mons, and every reader of stats.special raised from then
|
||||
-- on (#1517). The require shim refuses it now, whatever the manifest declares:
|
||||
-- engine_internals is a disclosure, not a generation gate.
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.modkit")
|
||||
|
||||
local PROBE = [[
|
||||
local mod = ...
|
||||
local out = mod.exports
|
||||
local function attempt(name)
|
||||
local ok, result = pcall(require, name)
|
||||
if ok then return nil, type(result) end
|
||||
return tostring(result), nil
|
||||
end
|
||||
out.attempt = attempt
|
||||
out.monErr, out.monType = attempt("src.battle.gen2.Mon")
|
||||
out.saveErr = attempt("src.core.gen2.Save")
|
||||
out.worldErr = attempt("src.ui.gen2.Chrome")
|
||||
out.game2Err = attempt("src.core.Game2")
|
||||
out.semverErr, out.semverType = attempt("src.mods.Semver")
|
||||
out.statsErr, out.statsType = attempt("src.pokemon.Stats")
|
||||
out.compatErr, out.compatType = attempt("src.mods.Gen2Compat")
|
||||
out.loggerErr, out.loggerType = attempt("src.core.Logger")
|
||||
]]
|
||||
|
||||
local function manifest(id, permissions)
|
||||
return ('{"id":"%s","name":"%s","version":"1.0.0","entry":"main.lua",'
|
||||
.. '"api":2,"games":["all"]%s}'):format(id, id, permissions or "")
|
||||
end
|
||||
|
||||
-- ------- Gen 1 boot: refused, and the permission does not unlock it
|
||||
|
||||
local FILES = {
|
||||
["mods/gen2_declared/manifest.json"] =
|
||||
manifest("gen2_declared", ',"permissions":["engine_internals"]'),
|
||||
["mods/gen2_declared/main.lua"] = PROBE,
|
||||
["mods/gen2_undeclared/manifest.json"] = manifest("gen2_undeclared"),
|
||||
["mods/gen2_undeclared/main.lua"] = PROBE,
|
||||
}
|
||||
|
||||
T.eq(package.loaded["src.battle.gen2.Mon"], nil,
|
||||
"the Gen 2 mon module is not loaded before the Gen 1 boot")
|
||||
|
||||
local run = T.sdk.loadMods({ "mods/gen2_declared", "mods/gen2_undeclared" },
|
||||
{ fs = T.sdk.memfs(FILES), data = {}, generation = 1 })
|
||||
T.eq(#run.errors, 0,
|
||||
"a mod that only probes still loads clean (" .. tostring(run.errors[1]) .. ")")
|
||||
|
||||
local declared = run.loader.exports.gen2_declared or {}
|
||||
local undeclared = run.loader.exports.gen2_undeclared or {}
|
||||
|
||||
T.check(declared.monErr, "src.battle.gen2.Mon is refused on a Gen 1 game")
|
||||
T.check(declared.monErr and declared.monErr:find("src.battle.gen2.Mon", 1, true),
|
||||
"the refusal names the module: " .. tostring(declared.monErr))
|
||||
T.check(declared.monErr and declared.monErr:find("gen2_declared", 1, true),
|
||||
"and the mod that asked for it")
|
||||
T.check(declared.monErr and declared.monErr:find("mod.game", 1, true),
|
||||
"and the surface to use instead")
|
||||
T.eq(declared.monType, nil, "nothing came back for the mod to call")
|
||||
T.eq(package.loaded["src.battle.gen2.Mon"], nil,
|
||||
"and the module was never loaded at all")
|
||||
|
||||
T.check(declared.saveErr, "src.core.gen2.Save is refused the same way")
|
||||
T.check(declared.worldErr, "so is src.ui.gen2.Chrome")
|
||||
T.check(declared.game2Err and declared.game2Err:find("src.core.Game2", 1, true),
|
||||
"and src.core.Game2, the Gen 2 service owner: " .. tostring(declared.game2Err))
|
||||
|
||||
T.check(undeclared.monErr,
|
||||
"a mod with no permissions is refused for the same reason")
|
||||
T.eq(declared.monErr and declared.monErr:gsub("gen2_declared", "X"),
|
||||
undeclared.monErr and undeclared.monErr:gsub("gen2_undeclared", "X"),
|
||||
"engine_internals is a disclosure, not a generation gate: same refusal")
|
||||
|
||||
-- ------- what the gate does NOT touch
|
||||
|
||||
T.eq(declared.semverErr, nil, "the supported requires still resolve")
|
||||
T.eq(declared.semverType, "table", "and hand back the module")
|
||||
T.eq(declared.statsErr, nil, "src.pokemon.Stats is still published to mods")
|
||||
T.eq(declared.compatErr, nil,
|
||||
"src.mods.Gen2Compat is not a Gen 2 engine module: it is the adapter table")
|
||||
T.eq(declared.compatType, "table", "so it still answers")
|
||||
T.eq(declared.loggerErr, nil,
|
||||
"an undeclared engine require is still a warning, not a block")
|
||||
T.eq(declared.loggerType, "table", "and still resolves")
|
||||
T.eq(undeclared.loggerErr, nil,
|
||||
"including for a mod that declared no permissions at all")
|
||||
|
||||
-- a lazy require made long after the entry chunk ran is gated too: the mod's
|
||||
-- own require is what the shim identifies, not the load phase
|
||||
do
|
||||
local err = declared.attempt and declared.attempt("src.world.gen2.World")
|
||||
T.check(err and err:find("src.world.gen2.World", 1, true),
|
||||
"a require made after load is refused too: " .. tostring(err))
|
||||
end
|
||||
|
||||
-- the shim only judges a mod's own require; engine and harness callers keep
|
||||
-- the module they asked for
|
||||
do
|
||||
local ok, module = pcall(require, "src.world.gen2.WorldAPI")
|
||||
T.check(ok, "a non-mod caller still reaches a Gen 2 module: " .. tostring(module))
|
||||
T.eq(type(module), "table", "and gets the real one")
|
||||
end
|
||||
|
||||
run.release()
|
||||
|
||||
-- ------- an unguarded reach fails the whole mod, on the boot error feed
|
||||
|
||||
local HARD = {
|
||||
["mods/gen2_hard/manifest.json"] = manifest("gen2_hard",
|
||||
',"permissions":["engine_internals"]'),
|
||||
["mods/gen2_hard/main.lua"] = [[
|
||||
local mod = ...
|
||||
local Mon = require("src.battle.gen2.Mon")
|
||||
mod.exports.reached = Mon ~= nil
|
||||
]],
|
||||
}
|
||||
local hard = T.sdk.loadMods({ "mods/gen2_hard" },
|
||||
{ fs = T.sdk.memfs(HARD), data = {}, generation = 1 })
|
||||
T.eq(hard.loader.mods.gen2_hard.state, "failed",
|
||||
"a mod that reaches across the generation does not load")
|
||||
T.eq(hard.loader.exports.gen2_hard, nil, "and publishes nothing")
|
||||
T.check(hard.errors[1] and hard.errors[1]:find("src.battle.gen2.Mon", 1, true),
|
||||
"the boot error feed names the module: " .. tostring(hard.errors[1]))
|
||||
hard.release()
|
||||
|
||||
-- ------- Gen 2 boot: the same require is the mod's own generation
|
||||
|
||||
local gen2 = T.sdk.loadMods({ "mods/gen2_declared" },
|
||||
{ fs = T.sdk.memfs(FILES), data = {}, generation = 2 })
|
||||
local onGold = gen2.loader.exports.gen2_declared or {}
|
||||
T.eq(onGold.monErr, nil,
|
||||
"on a Gen 2 game the same require is answered: " .. tostring(onGold.monErr))
|
||||
T.eq(onGold.monType, "table", "with the real Gen 2 mon module")
|
||||
T.eq(onGold.game2Err, nil, "and src.core.Game2 is this game's service owner")
|
||||
gen2.release()
|
||||
|
||||
T.finish("cross_generation_require")
|
||||
@@ -311,6 +311,46 @@ ow.transitioning = false -- undo the queued warp transition
|
||||
Game.save.onBike = false
|
||||
Game.save.inventory.BICYCLE = nil
|
||||
|
||||
-- home/overworld.asm:1224
|
||||
local function settle(o)
|
||||
drainText()
|
||||
for _ = 1, 60 do o:updateScriptMoves(); o.player:update() end
|
||||
end
|
||||
for _, c in ipairs({ { "ROUTE_16", 17, 10 }, { "ROUTE_16", 17, 11 },
|
||||
{ "ROUTE_18", 33, 8 }, { "ROUTE_18", 33, 9 } }) do
|
||||
Game.save.inventory.BICYCLE = nil
|
||||
Game.save.onBike, Game.save.forcedBike = false, nil
|
||||
pushOW(c[1], c[2], c[3], "left")
|
||||
ow = OW
|
||||
check(not ow.map:isWalkableCell(c[2] + 1, c[3]),
|
||||
("%s (%d,%d): the cell behind a left-facing arrival is the gate wall")
|
||||
:format(c[1], c[2], c[3]))
|
||||
settle(ow)
|
||||
eq(#ow.scriptMoves, 0,
|
||||
("%s (%d,%d): the refusal shove settles"):format(c[1], c[2], c[3]))
|
||||
check(ow.map:isWalkableCell(ow.player.cellX, ow.player.cellY),
|
||||
("%s (%d,%d): the bikeless Cycling Road refusal never shoves into the "
|
||||
.. "gate wall"):format(c[1], c[2], c[3]))
|
||||
end
|
||||
|
||||
Game.save.onBike, Game.save.forcedBike = false, nil
|
||||
pushOW("ROUTE_16", 17, 10, "right")
|
||||
ow = OW
|
||||
settle(ow)
|
||||
eq(ow.player.cellX, 16, "an unobstructed refusal shove still moves one cell")
|
||||
eq(ow.player.cellY, 10, "and only along the arrival axis")
|
||||
|
||||
Game.save.onBike, Game.save.forcedBike = false, nil
|
||||
ow:setMap("ROUTE_16", 18, 10, "left", { via = "boot" })
|
||||
check(ow.map:isWalkableCell(ow.player.cellX, ow.player.cellY),
|
||||
"a save parked inside the Route 16 gate wall is lifted out on load")
|
||||
eq(ow.player.cellY, 10, "the repair stays on the row it was saved on")
|
||||
settle(ow)
|
||||
check(ow.map:isWalkableCell(ow.player.cellX, ow.player.cellY),
|
||||
"and the refusal it lands on cannot push it back in")
|
||||
popToOW()
|
||||
Game.save.onBike, Game.save.forcedBike = false, nil
|
||||
|
||||
-- Seafoam B4F: only the stairs square (dbmapcoord 7,11) refuses, and only
|
||||
-- until BOTH boulders are down (CheckBothEventsSet)
|
||||
Game.save.flags["EVENT_SEAFOAM4_BOULDER1_DOWN_HOLE"] = nil
|
||||
|
||||
@@ -42,7 +42,8 @@ local function trace(tb)
|
||||
if row.text then
|
||||
out[#out + 1] = { text = (row.text:gsub("\n", " ")) }
|
||||
elseif row.anim then
|
||||
out[#out + 1] = { anim = row.anim, isPlayer = row.attackerIsPlayer }
|
||||
out[#out + 1] = { anim = row.anim, isPlayer = row.attackerIsPlayer,
|
||||
hit = row.hit }
|
||||
elseif row.drain then
|
||||
out[#out + 1] = { drain = true }
|
||||
end
|
||||
@@ -95,6 +96,9 @@ do
|
||||
check(spiral ~= nil, "the storing turn plays XSTATITEM_ANIM")
|
||||
eq(rows[spiral] and rows[spiral].isPlayer, true,
|
||||
"on the player's side of the field")
|
||||
-- effects.asm:1461-1471
|
||||
eq(rows[spiral] and rows[spiral].hit and rows[spiral].hit.animType, 6,
|
||||
"PlayBattleAnimation2 stamps wAnimationType 6 on the player's turn (#1564)")
|
||||
local used = indexOfText(rows, "used BIDE!")
|
||||
local storing = indexOfText(rows, "storing energy!")
|
||||
check(used and spiral and used < spiral,
|
||||
@@ -119,6 +123,8 @@ do
|
||||
local dup = indexOfAnim(rows, "XSTATITEM_DUPLICATE_ANIM")
|
||||
check(dup ~= nil, "the foe's storing turn plays XSTATITEM_DUPLICATE_ANIM")
|
||||
check(dup and not rows[dup].isPlayer, "attributed to the enemy side")
|
||||
eq(rows[dup] and rows[dup].hit and rows[dup].hit.animType, 3,
|
||||
"and wAnimationType 3 on the enemy's turn (#1564)")
|
||||
end
|
||||
|
||||
-- locked turns: .BideCheck decrements with no text of its own in pokered and
|
||||
@@ -151,6 +157,10 @@ do
|
||||
check(unleashed ~= nil, "the release prints UnleashedEnergyText")
|
||||
check(hit ~= nil, "the release plays BIDE's own animation (#375)")
|
||||
eq(rows[hit] and rows[hit].isPlayer, true, "from the player's side")
|
||||
-- core.asm:3526-3528 -> effects.asm:1476-1477
|
||||
check(rows[hit] and rows[hit].hit == nil,
|
||||
".UnleashEnergy rejoins PlayCurrentMoveAnimation, which zeroes "
|
||||
.. "wAnimationType")
|
||||
check(unleashed and hit and unleashed < hit,
|
||||
"the animation follows the text")
|
||||
local drainAt
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
-- #1274: with several cartridge dumps sitting in the save directory, Choose
|
||||
-- ROM imported whichever version was not yet ready rather than the one the
|
||||
-- player picked. Select Red, get Blue.
|
||||
--
|
||||
-- The fallback exists for devices with no file picker (handheld Linux with
|
||||
-- no zenity or kdialog, and the Android USB-drop path), where chooseRom
|
||||
-- returns nil and the importer scans the save directory instead. That scan,
|
||||
-- findPendingRom, answered with the first dump whose SHA-1 mapped to any
|
||||
-- not-yet-ready version, so the selection was dropped on the floor.
|
||||
--
|
||||
-- Self-contained: `luajit tests/rom_importer_choose_version_test.lua`
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
if not _G.love then _G.love = require("tests.love_stub") end
|
||||
|
||||
local S = require("tests.harness").suite("rom importer honours the chosen version")
|
||||
local eq = S.eq
|
||||
local check = S.check
|
||||
|
||||
local RomImporter = require("src.import.RomImporter")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
|
||||
local ROM_BYTES = 1024 * 1024
|
||||
|
||||
-- One dump per version, each a blob of the right size whose first byte says
|
||||
-- which cart it is. love.data.hash is stubbed to answer with the real SHA-1
|
||||
-- from GameVersion, so GameVersion.forSha1 does its own lookup unchanged.
|
||||
local MARK = { R = "red", B = "blue", Y = "yellow" }
|
||||
local FILES = {
|
||||
["blue.gb"] = string.rep("B", ROM_BYTES),
|
||||
["red.gb"] = string.rep("R", ROM_BYTES),
|
||||
["yellow.gbc"] = string.rep("Y", ROM_BYTES),
|
||||
}
|
||||
-- Deliberately not alphabetical and not selection order: the bug returned
|
||||
-- whatever getDirectoryItems listed first.
|
||||
local LISTING = { "blue.gb", "red.gb", "yellow.gbc" }
|
||||
|
||||
love.system = love.system or {}
|
||||
love.data = love.data or {}
|
||||
|
||||
local saved = {
|
||||
getOS = love.system.getOS,
|
||||
hash = love.data.hash,
|
||||
encode = love.data.encode,
|
||||
getDirectoryItems = love.filesystem.getDirectoryItems,
|
||||
getInfo = love.filesystem.getInfo,
|
||||
read = love.filesystem.read,
|
||||
getSaveDirectory = love.filesystem.getSaveDirectory,
|
||||
}
|
||||
|
||||
-- Not OS X, Windows or Linux, so chooseRom returns nil without shelling out
|
||||
-- to a dialog that may or may not exist on the machine running the suite.
|
||||
-- The fallback under test is the same one every pickerless device takes.
|
||||
love.system.getOS = function() return "Unknown" end
|
||||
love.filesystem.getSaveDirectory = function() return "/tmp/pokemon-love2d" end
|
||||
love.filesystem.getDirectoryItems = function() return LISTING end
|
||||
love.filesystem.getInfo = function(name, filter)
|
||||
if FILES[name] then return { type = "file", size = #FILES[name] } end
|
||||
return nil
|
||||
end
|
||||
love.filesystem.read = function(name) return FILES[name] end
|
||||
-- RomImporter's sha1 helper is hash then encode-to-hex, so the pair is
|
||||
-- stubbed together: hash answers with the digest already in the form
|
||||
-- GameVersion stores, and encode hands it back untouched.
|
||||
love.data.hash = function(_, data)
|
||||
local version = MARK[data:sub(1, 1)]
|
||||
return version and GameVersion.info(version).sha1 or "0"
|
||||
end
|
||||
love.data.encode = function(_, _, digest) return digest end
|
||||
|
||||
local function freshImporter()
|
||||
return setmetatable({
|
||||
android = false,
|
||||
nativePicker = false,
|
||||
workState = nil,
|
||||
ready = { red = false, blue = false, yellow = false },
|
||||
notice = nil,
|
||||
modNotice = nil,
|
||||
saveNotice = {},
|
||||
baseRoms = {},
|
||||
chooseVersion = nil,
|
||||
startData = function(self, data, displayName)
|
||||
self._started = { data = data, name = displayName }
|
||||
end,
|
||||
startPath = function(self, path) self._startedPath = path end,
|
||||
}, RomImporter)
|
||||
end
|
||||
|
||||
-- ------- the report: pick Red, get Red
|
||||
|
||||
local ri = freshImporter()
|
||||
ri:choose("red")
|
||||
check(ri._started ~= nil, "a pickerless Choose still imports from the save dir")
|
||||
eq(ri._started and ri._started.name, "red.gb",
|
||||
"and it imports the cart that was chosen, not the first pending one")
|
||||
|
||||
-- ------- the same for a version listed after another pending dump
|
||||
|
||||
ri = freshImporter()
|
||||
ri:choose("yellow")
|
||||
eq(ri._started and ri._started.name, "yellow.gbc",
|
||||
"Yellow is imported for Yellow, with Blue and Red still pending")
|
||||
|
||||
-- ------- a chosen version with no dump present imports nothing
|
||||
|
||||
ri = freshImporter()
|
||||
ri.ready = { red = false, blue = false, yellow = false, gold = false }
|
||||
ri:choose("gold")
|
||||
check(ri._started == nil,
|
||||
"choosing a version whose dump is absent imports no other cart")
|
||||
|
||||
-- ------- an already-imported cart is still never re-extracted (#167)
|
||||
|
||||
ri = freshImporter()
|
||||
ri.ready = { red = true, blue = false, yellow = false }
|
||||
ri:choose("red")
|
||||
check(ri._started == nil,
|
||||
"and a version already imported is not extracted a second time")
|
||||
|
||||
for name, fn in pairs(saved) do
|
||||
if name == "getOS" then love.system.getOS = fn
|
||||
elseif name == "hash" then love.data.hash = fn
|
||||
elseif name == "encode" then love.data.encode = fn
|
||||
else love.filesystem[name] = fn end
|
||||
end
|
||||
|
||||
S.finish()
|
||||
@@ -3526,6 +3526,7 @@ runSuites({
|
||||
"tests/gen2_decorations_test.lua",
|
||||
"tests/gen2_pokerus_test.lua",
|
||||
"tests/gen2_common_text_test.lua",
|
||||
"tests/gen2_rom_text_test.lua",
|
||||
"tests/gen2_magnet_train_test.lua",
|
||||
"tests/gen2_bank_of_mom_test.lua",
|
||||
"tests/gen2_trainerhouse_test.lua",
|
||||
@@ -3623,6 +3624,8 @@ runSuites({ "tests/rom_importer_android_mod_pick_test.lua" })
|
||||
-- ---------------------------------------------- import with no picker (#482)
|
||||
runSuites({ "tests/rom_importer_no_picker_test.lua" })
|
||||
runSuites({ "tests/rom_importer_double_pick_test.lua" })
|
||||
-- the same pickerless scan, asked for one version in particular (#1274)
|
||||
runSuites({ "tests/rom_importer_choose_version_test.lua" })
|
||||
-- ---------------------------------------------- Switch platform capabilities
|
||||
-- platform_nx_* / rom_importer_nx_* live in tests/engine/ (ROM-free T2) so
|
||||
-- CI's headless lane runs them without data/generated/.
|
||||
|
||||
@@ -143,6 +143,28 @@ local ok, err = pcall(function()
|
||||
check(#report.lostMons == 0 and probe.party[1].species == "EDITMON",
|
||||
"validate keeps the modded mon while the mod is enabled")
|
||||
|
||||
-- Second editor session in the same process (launcher Close → Edit again):
|
||||
-- host teardown must leave Runtime/Assets clean and Data unloaded so the
|
||||
-- next App.load remounts mods instead of serving vanilla catalogs.
|
||||
App.unload()
|
||||
if Data._pristineKeys or Data.pokemon then Data:unloadGenerated() end
|
||||
Runtime.reset()
|
||||
Assets.installLoader(nil)
|
||||
package.loaded["App"] = nil
|
||||
package.loaded["Catalog"] = nil
|
||||
App = require("App")
|
||||
App.load(tmpPath)
|
||||
S = App.getState()
|
||||
loaded = S.mods:status().loaded
|
||||
check(#loaded == 1 and loaded[1].id == "zz_editor_fixture",
|
||||
"fixture mod loads again after host-style teardown")
|
||||
hasSpecies = false
|
||||
for _, id in ipairs(S.cat.species) do
|
||||
if id == "EDITMON" then hasSpecies = true end
|
||||
end
|
||||
check(hasSpecies, "second App.load catalog still carries the mod's mon")
|
||||
check(Data.pokemon.EDITMON ~= nil, "second merge lands EDITMON in Data again")
|
||||
|
||||
-- Gold-targeted mods: spa/spd records are usable, and a Gen 1-only
|
||||
-- manifest stays out of Gold (no ROM cache / App.load("gold") required).
|
||||
local ModTargets = require("src.mods.ModTargets")
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
local S = require("tests.harness").suite("yellow title palette")
|
||||
local check, eq = S.check, S.eq
|
||||
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
local TitleState = require("src.ui.TitleState")
|
||||
|
||||
GameVersion.set("yellow")
|
||||
local prevMode = PaletteFX.mode
|
||||
|
||||
-- Test 1: SGB mode
|
||||
PaletteFX.setMode("gbc")
|
||||
local mewmonSgb = PaletteFX.pal(nil, "MEWMON")
|
||||
check(mewmonSgb ~= nil, "MEWMON palette exists in SGB mode on Yellow")
|
||||
-- In Yellow SGB SuperPalettes: MEWMON color 1 is Yellow {255, 247, 181}, color 2 is Red {222, 132, 132}
|
||||
eq(mewmonSgb[1][1], 255, "SGB: Color 0 is white R=255")
|
||||
eq(mewmonSgb[2][1], 255, "SGB: Color 1 is yellow R=255")
|
||||
eq(mewmonSgb[3][1], 222, "SGB: Color 2 is red R=222")
|
||||
|
||||
-- Test 2: ADVANCED mode (redpp)
|
||||
PaletteFX.setMode("redpp")
|
||||
local mewmonAdv = PaletteFX.pal(nil, "MEWMON")
|
||||
check(mewmonAdv ~= nil, "MEWMON palette exists in ADVANCED mode on Yellow")
|
||||
-- Must NOT be Red++'s Mew purple {115, 33, 165}! It must be Yellow's Pikachu palette!
|
||||
eq(mewmonAdv[1][1], 255, "ADVANCED: Color 0 is white R=255 (Pikachu eye sclera)")
|
||||
eq(mewmonAdv[2][1], 255, "ADVANCED: Color 1 is yellow R=255 (Pikachu body)")
|
||||
check(mewmonAdv[3][3] < 150, "ADVANCED: Color 2 is not purple (B < 150, red cheeks)")
|
||||
|
||||
-- Test 3: OG YELLOW mode (ogred on Yellow)
|
||||
PaletteFX.setMode("ogred")
|
||||
local mewmonOg = PaletteFX.pal(nil, "MEWMON")
|
||||
check(mewmonOg ~= nil, "MEWMON palette exists in OG YELLOW mode")
|
||||
eq(mewmonOg[1][1], 255, "OG YELLOW: Color 0 is white")
|
||||
eq(mewmonOg[2][1], 255, "OG YELLOW: Color 1 is yellow")
|
||||
|
||||
-- Test 4: TitleState sgbPalettes
|
||||
local fakeGame = { data = { palettes = PaletteFX.yellowPack() } }
|
||||
local ts = { yellowLayout = true }
|
||||
local zones = TitleState.sgbPalettes(ts, fakeGame)
|
||||
check(zones ~= nil and #zones >= 3, "TitleState produces zones for Yellow")
|
||||
eq(zones[1].colors, PaletteFX.pal(fakeGame.data, "LOGO2"), "Zone 1 uses LOGO2")
|
||||
eq(zones[2].colors, PaletteFX.pal(fakeGame.data, "MEWMON"), "Zone 2 uses MEWMON (Pikachu yellow/red)")
|
||||
|
||||
PaletteFX.setMode(prevMode)
|
||||
GameVersion.set("red")
|
||||
|
||||
S.finish()
|
||||
@@ -699,6 +699,9 @@ REQUIRED_SYMBOLS = {
|
||||
# marker without this symbol.
|
||||
"MonMenuIcons", "Icons", "IconPointers", "HeldItemIcons",
|
||||
"PokedexDataPointerTable",
|
||||
# engine/pokemon/bills_pc.asm:2170-2173 (the four vTiles2 $5c tiles
|
||||
# PCMonInfo prints at :1086/:1091) and engine/gfx/cgb_layouts.asm:287.
|
||||
"PCMailGFX", "BillsPCOrangePalette",
|
||||
# New-game / Pokecenter respawn table (data/maps/spawn_points.asm)
|
||||
"SpawnPoints",
|
||||
"NewPokedexOrder", "AlphabeticalPokedexOrder", "Landmarks",
|
||||
@@ -904,9 +907,62 @@ REQUIRED_SYMBOLS = {
|
||||
}
|
||||
|
||||
|
||||
def embedded_symbols(symbols, pokemon_labels, song_labels=()):
|
||||
"""Resolve REQUIRED_SYMBOLS + pic labels + Music_* song headers."""
|
||||
names = set(REQUIRED_SYMBOLS) | set(pokemon_labels) | set(song_labels)
|
||||
# The engine's own text, the counterpart to make_rom_manifest.text_metadata.
|
||||
#
|
||||
# Only these five carry dialogue. data/text/'s other files are character
|
||||
# tables rather than strings: dakutens.asm and name_input_chars.asm /
|
||||
# mail_input_chars.asm are keyboard layouts, and unused_gen1_trainer_names.asm
|
||||
# is a dead Gen 1 leftover. Decoding those as text yields keyboard rows and
|
||||
# kana runs, so they are left out by name rather than filtered afterwards.
|
||||
#
|
||||
# None of the five carries an IF DEF(_GOLD) / IF DEF(_SILVER) arm, so the
|
||||
# label set is one list for both editions and make_silver_manifest.py inherits
|
||||
# it with the addresses re-resolved from pokesilver.sym.
|
||||
TEXT_SOURCES = (
|
||||
"battle.asm",
|
||||
"common_1.asm",
|
||||
"common_2.asm",
|
||||
"common_3.asm",
|
||||
"std_text.asm",
|
||||
)
|
||||
|
||||
|
||||
def text_labels(pokegold):
|
||||
"""Every text label in TEXT_SOURCES, in sorted order.
|
||||
|
||||
Unlike Gen 1 there is no `dynamic` map beside this. pokered's decoder is
|
||||
told which runtime token each label carries; RomExtractorGen2's reads the
|
||||
cart's own TX_RAM / TX_DECIMAL command bytes and emits {STRBUF} / {NUM}
|
||||
itself, so the label alone is enough.
|
||||
"""
|
||||
labels = set()
|
||||
for name in TEXT_SOURCES:
|
||||
path = os.path.join(pokegold, "data/text", name)
|
||||
pending = None
|
||||
for _, line in read_asm(path):
|
||||
stripped = line.strip()
|
||||
if not stripped:
|
||||
continue
|
||||
match = re.match(r"(\w+)::?\s*$", stripped)
|
||||
if match:
|
||||
# A label whose next line is another label owns no string of
|
||||
# its own. `BattleText::` is the one in this set: its own
|
||||
# comment says "used only for BANK(BattleText)", and it shares
|
||||
# an address with the first real label under it, so taking it
|
||||
# would decode that neighbour's string a second time.
|
||||
pending = match.group(1)
|
||||
continue
|
||||
if pending:
|
||||
labels.add(pending)
|
||||
pending = None
|
||||
return sorted(labels)
|
||||
|
||||
|
||||
def embedded_symbols(symbols, pokemon_labels, song_labels=(),
|
||||
text_label_names=()):
|
||||
"""Resolve REQUIRED_SYMBOLS + pic labels + songs + text labels."""
|
||||
names = (set(REQUIRED_SYMBOLS) | set(pokemon_labels)
|
||||
| set(song_labels) | set(text_label_names))
|
||||
for symbol_name in symbols.by_name:
|
||||
# Pokedex entries are split across four banks and the game derives the
|
||||
# bank arithmetically from the species id (radio.asm's rlca/maskbits
|
||||
@@ -1108,6 +1164,7 @@ def generate(pokegold, symbols_path):
|
||||
pokemon_labels.append(asset["backLabel"])
|
||||
|
||||
songs = music_order(pokegold)
|
||||
text_label_names = text_labels(pokegold)
|
||||
sfx = sfx_order(pokegold)
|
||||
|
||||
# Index 0 is NO_ITEM, so the parsed list is already 1-based on item id.
|
||||
@@ -1199,6 +1256,9 @@ def generate(pokegold, symbols_path):
|
||||
"battleAnimBgPaletteOrder": battle_anim_bg_pals,
|
||||
"battleAnimObPaletteOrder": battle_anim_ob_pals,
|
||||
},
|
||||
# Label -> decoded string is built at import time from these, the
|
||||
# same way Gen 1 builds data/generated/text.lua from its own list.
|
||||
"text": {"labels": text_label_names},
|
||||
"charmap": charmap(pokegold),
|
||||
"fontCharmap": font_extract.parse_charmap(pokegold),
|
||||
"pokemonAssets": assets,
|
||||
@@ -1207,7 +1267,8 @@ def generate(pokegold, symbols_path):
|
||||
"maps": {name: map_groups[name] for name in map_order},
|
||||
"tilesets": {name: {} for name in tilesets},
|
||||
}
|
||||
data["symbols"] = embedded_symbols(symbols, pokemon_labels, songs)
|
||||
data["symbols"] = embedded_symbols(
|
||||
symbols, pokemon_labels, songs, text_label_names)
|
||||
return data
|
||||
|
||||
|
||||
|
||||
+4121
-56
File diff suppressed because it is too large
Load Diff
+4105
-40
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user