Compare commits

...

18 Commits

Author SHA1 Message Date
bryanthaboi f74e21782b Merge pull request #798 from bryanthaboi/dev
perhaps a massive PR
2026-08-04 10:35:29 -04:00
bryanthaboi 8fbe819493 bingus dingus 2026-08-04 10:32:51 -04:00
Shane McGovern 626080d228 Show mod downloads and release dates in the launcher MODS and Find Mods panels (#793)
* Resolve Find Mods stats from each mod's GitHub repo when the feed lacks them

A FIND MODS row now shows download/date stats even when its feed publishes
none: the row fetches the mod's own GitHub releases through the same
cached ModUpdate.fetchReleases the MODS tab uses (six-hour options cache,
so an installed mod's repo is instant). Feed-published stats still win
when present; otherwise one repo is fetched per frame -- the thumbnail
budget pattern -- so opening the tab never stalls for the whole listing.
ModUpdate.statsForReleases is the shared resolver.

* Fix crash opening the Find Mods tab: rename the stats cache field

The resolver stored results in self._findStats, which collides with the
method of the same name: self._findStats resolves through the metatable to
the function, so the or {} guard never fired and indexing it crashed the
launcher the moment the panel built. State now lives in _findStatsCache.

* Fix Find Mods crash: require ModUpdate in the find panel

buildFindPanel called ModUpdate.statsLine without a local require --
only buildModsPanel had one -- so opening the tab indexed a nil global.

* Retry Find Mods stats after failed repo fetches

A failed repo fetch (hourly GitHub API rate limit, transient network error)
was memoized as resolved, so a rate-limited first visit left those rows
empty for the whole session. Failures now schedule a 60s retry; a 404 is
still permanent so a renamed or vanished repo is fetched once.

* Add the MODS tab sort options to the Find Mods tab
2026-08-04 10:16:44 -04:00
bryanthaboi f56e82de81 translate the launcher: load mod string catalogs before it draws (#794)
the launcher runs before Game:load, so #767's Strings hooks had nothing
filling the catalog and a restart could not help - the ordering is the same
every launch. reads lang/strings.lua from enabled mods only, data not the
entry chunk, sandboxed.

plus a plain pixel fallback on the ui faces, or the kana draw as tofu.
2026-08-04 10:15:10 -04:00
bryanthaboi 7d3950658a launcher: route the last raw literals through strings (#791)
finishes what #767 started (thanks @hernan0078). most of that pr landed
already when the launcher panels moved to LauncherView; these six sites were
what was left, and they sit next to siblings that already call Strings.

also pins the delete chip to the wider of Delete/Sure? instead of to Delete.
english happens to have the longer word first, a translation need not.
2026-08-04 09:50:51 -04:00
bryanthaboi 3c1246ee06 Merge pull request #787 from luisgonzaleznf/fix/love12-scissor-bias
Fix LÖVE 12 anchored UI scissor seam
2026-08-04 09:45:17 -04:00
bryanthaboi d69965e3b1 Merge pull request #790 from bryanthaboi/ttf-box-color
fix ttf text erased after a box, and type names outside battle
2026-08-04 09:44:46 -04:00
bryanthaboi 30edac03a6 fix ttf text erased after a box, and type names outside battle
drawBox left the color white, which tiles ignore but ttf text does not, so
every label after a box drew white on white.

typechart only loaded on entering a battle, so the summary screen got raw
type ids back instead of display names.
2026-08-04 09:43:01 -04:00
bryanthaboi dd4aface93 test fixes 2026-08-04 09:41:44 -04:00
bryanthaboi b1350fdecd Merge branch 'dev' of https://github.com/bryanthaboi/gen1recomp into dev 2026-08-04 09:25:30 -04:00
bryanthaboi f2d9b74490 CLOSES #644, CLOSES #703, CLOSES #726, CLOSES #737, CLOSES #750, CLOSES #752, CLOSES #764, CLOSES #765, CLOSES #768, CLOSES #773, CLOSES #774, CLOSES #775, CLOSES #777, CLOSES #780, CLOSES #782 2026-08-04 09:25:28 -04:00
luisgonzaleznf 84d09d0894 Fix LÖVE 12 scissor seam 2026-08-04 15:20:06 +02:00
bryanthaboi e61f231316 Merge pull request #789 from bryanthaboi/ttf-tiles
add ttf tiles option
2026-08-04 09:14:49 -04:00
bryanthaboi 33998aab5d add ttf tiles option
lets a translation keep chosen characters on the rom tiles instead of the ttf.
needed for japanese: sizing the font for kana makes latin narrower, which
knocks the party menu numbers out of line.
2026-08-04 09:11:05 -04:00
bryanthaboi 9ce7379946 Merge pull request #786 from ShaneMcGovernIE/find-mods-release-stats
Show feed-published release stats on Find Mods rows
2026-08-04 08:00:46 -04:00
Shane McGovern b692474e24 Show feed-published release stats on Find Mods rows
A mod index can now publish three optional per-entry fields -- downloads
(total across every release), first_release and last_release (ISO days) --
which the FIND MODS listing shows in the same gold line the MODS tab uses.

The fields are additive by design: feeds that carry them stay readable by
every build that predates them (schema_version stays 1), and feeds that do
not render exactly as before. ModUpdate.statsLine builds the shared line;
the MODS tab reuses it. Parser, formatting, and parse coverage are tested.
2026-08-04 12:37:28 +01:00
bryanthaboi 6cfc85ca83 icons update 2026-08-04 06:52:06 -04:00
github-actions 7b060c19f4 chore(ios): update app-repo.json [skip ci] 2026-08-04 06:50:19 -04:00
90 changed files with 4058 additions and 391 deletions
+12 -2
View File
@@ -49,7 +49,7 @@ local function gift(opts)
end
end)
end
if opts.pre then say(opts.pre, "", give) else give() end
if opts.pre then say(opts.pre, opts.preFallback or "", give) else give() end
end
end
@@ -118,11 +118,21 @@ M.CINNABAR_LAB_METRONOME_ROOM = {
},
}
-- TM42 Dream Eater (scripts/ViridianCity.asm, the fisher; no pre text)
-- TM42 Dream Eater (scripts/ViridianCity.asm, the fisher). The fisher's
-- YouCanHaveThisText prints before GiveItem, so this gift needs a pre
-- text (#775). Like the SilphCo2F worker (#393) that label carries no
-- leading underscore, and on Red it sits outside the extractor's symbol
-- set, so the literal from text/ViridianCity.asm rides along as the
-- fallback; Yellow resolves the ROM string instead.
M.VIRIDIAN_CITY = {
talk = {
TEXT_VIRIDIANCITY_FISHER = gift({
flag = "EVENT_GOT_TM42", item = "TM_DREAM_EATER",
pre = "ViridianCityFisherYouCanHaveThisText",
preFallback = "Yawn!\nI must have dozed\voff in the sun."
.. "\fI had this dream\nabout a DROWZEE\veating my dream."
.. "\vWhat's this?\vWhere did this TM\vcome from?"
.. "\fThis is spooky!\nHere, you can\vhave this TM.",
received = "_ViridianCityFisherReceivedTM42Text",
explain = "_ViridianCityFisherTM42ExplanationText",
noRoom = "_ViridianCityFisherTM42NoRoomText",
+7 -5
View File
@@ -195,11 +195,13 @@ through `src/import/SaveFileIO.lua`, which sits on top of
(checked against `listSlots`). `SaveFileIO.exportActiveSlot` loads the active
slot, encodes it back with `SaveConvert.exportSav` (a slot never keeps
`rawImport`, so this is a zero-filled template export, which is valid), and
writes `exports/<version>/gen1recomp-<version>-<slotId>.sav` in the save
directory (`exports/` and `exports/<version>/` are created as needed). On
desktop it returns the absolute path (`love.filesystem.getSaveDirectory()`),
which the notice line shows with an "Open folder" affordance
(`love.system.openURL("file://" .. dir)`).
writes `exports/<version>/gen1recomp-<version>-<slotId>.sav` under the same
root `persistFs` writes slots to: the portable game folder when `portable.txt`
marks the install, otherwise the save directory (`exports/` and
`exports/<version>/` are created as needed; #752). On desktop it returns the
absolute path (`SaveData.portableBaseDir()` when portable, else
`love.filesystem.getSaveDirectory()`), which the notice line shows with an
"Open folder" affordance (`love.system.openURL("file://" .. dir)`).
On Android the bytes are also staged as `pending_export.sav` and
`love.system.createFile(suggestedName)` opens `ACTION_CREATE_DOCUMENT` so the
player can save to Downloads / Drive / etc.; on return `export_done.flag`
+21 -2
View File
@@ -386,8 +386,17 @@ Vautour (Burpy Fresh), licensed under CC-BY 4.0 (5x11 base characters,
the registry entry: `file` for a mod-shipped TTF, `size` (the font's
design em; Plain Pixel rasterizes cleanly only at multiples of 15),
`spacing` added to every advance, `yOffset` for vertical alignment
against the 8px cell grid, and `bold`, which double-prints at a 1px
offset for fonts whose strokes read too light.
against the 8px cell grid, `bold`, which double-prints at a 1px
offset for fonts whose strokes read too light, and `tiles`, the
characters that keep their ROM tile instead of coming from the TTF.
`tiles` matters for a CJK translation. Sizing the font so a kana fills
the 8px cell leaves Latin narrower than the tile font it replaces, which
pulls the numeric columns out of line: the party menu's `:L12` stops
sitting over `34/ 34`. Naming `"0123456789/:"` keeps those on the
vanilla tiles, so numbers render exactly as they do in English while
kana still come from the font. It takes a string of characters, or a
list when a multi-character charmap sequence is meant.
See the wiki's Translations guide.
@@ -471,6 +480,16 @@ automatically, so the tab opens on an "Add an index" prompt until you name
one; paste an index URL or its `owner/repo` and it is remembered in
`options.lua`. More than one index can be added, and the listings merge.
A feed author can publish per-mod release stats by adding three optional
fields to an entry -- `downloads` (total across every release), and
`first_release` / `last_release` (ISO days) -- which the listing shows in
the same gold line the MODS tab uses. When a feed does not carry them,
the row fetches the mod's own GitHub releases instead -- the same cached
`ModUpdate` fetch the MODS tab uses, one entry per frame -- so the stats
appear for any mod with a `github` field regardless of feed maintenance.
The fields are additive: feeds that carry them stay readable by every
build that predates them, and feeds that do not render exactly as before.
## Soft reset (all versions)
Holding A, B, START and SELECT together restarts the game the way flicking
+5
View File
@@ -107,6 +107,11 @@ function FONT_CACHE.get(size, fontPath)
font = love.graphics.newFont(size)
end
-- Per-glyph fallback so a non-Latin UI string is not drawn as tofu.
-- pcall'd require: FlexLove is vendored and must still load standalone.
local okUi, UiFont = pcall(require, "src.render.UiFont")
if okUi and UiFont then UiFont.attach(font, size) end
-- Add to cache with LRU metadata
FONT_CACHE[cacheKey] = {
font = font,
+12
View File
@@ -313,6 +313,18 @@ function love.load(args)
return
end
-- The launcher draws before any game boots, so the mod loader has not run
-- and Strings has no catalog. Routing the launcher's text through Strings
-- (#767) only pays off if something fills that catalog this early, and no
-- restart could: the ordering is the same on every launch. Read the
-- enabled mods' string catalogs -- data only, no entry chunk -- so a
-- translation reaches the launcher too. Game:load replaces this with the
-- real merged catalog once a version boots.
do
local preload = require("src.mods.LauncherMods").translationStrings()
if preload then require("src.core.Strings").load({ strings = preload }) end
end
-- Interactive: the launcher always runs. Red, Blue, and Yellow are each
-- live: a column shows Play when that game's ROM is already imported, or
-- Choose ROM / drag-drop when it is not. Any dropped .gb is routed by its
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 42 KiB

+7
View File
@@ -12,6 +12,13 @@
"tintColor": "3b5ca8",
"category": "games",
"versions": [
{
"version": "0.1.66",
"date": "2026-08-04",
"size": 9749980,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.66/gen1recomp-0.1.66-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #691 Save file transfer\n- #743 Cannot Scroll Main Menu/Mod Menu\n- #779 \"Enemy \" untranslateable\n\n## Contributors\n\n- @bryanthaboi\n- @jherediagu\n- @ShaneMcGovernIE\n- @vegerot"
},
{
"version": "0.1.65",
"date": "2026-08-03",
+90 -3
View File
@@ -121,6 +121,32 @@ else
say "version '$VERSION' is not X.Y.Z, shipping default engine (no stamp)"
fi
# --------------------------------------------------------------- app icon
# One source of truth for every platform's launcher icon; iOS resizes the
# same file in scripts/build_ios.sh (apply_ios_icon) and the Android res/
# drawables are generated from it too.
ICON_SRC="$ROOT/assets/logo/gen1recomp_cover.png"
# pipx installs peresed (Windows exe icon patcher) here, off the default PATH.
PATH="$PATH:$HOME/.local/bin"
make_icns() { # $1 = output .icns path
[ -f "$ICON_SRC" ] || fail "missing icon source: $ICON_SRC"
local iconset="$WORK/GameIcon.iconset" size scaled
rm -rf "$iconset"; mkdir -p "$iconset"
for size in 16 32 128 256 512; do
sips -z "$size" "$size" "$ICON_SRC" --out "$iconset/icon_${size}x${size}.png" >/dev/null
scaled=$((size * 2))
sips -z "$scaled" "$scaled" "$ICON_SRC" --out "$iconset/icon_${size}x${size}@2x.png" >/dev/null
done
iconutil -c icns "$iconset" -o "$1"
}
make_ico() { # $1 = output .ico path
[ -f "$ICON_SRC" ] || fail "missing icon source: $ICON_SRC"
magick "$ICON_SRC" -define icon:auto-resize=256,128,64,48,32,16 "$1"
}
# --------------------------------------------------------------- macOS
build_mac() {
say "building macOS app"
@@ -147,9 +173,21 @@ build_mac() {
/usr/libexec/PlistBuddy -c "Set :CFBundleVersion $VERSION" "$plist" 2>/dev/null \
|| /usr/libexec/PlistBuddy -c "Add :CFBundleVersion string $VERSION" "$plist"
if [ -f "$ROOT/assets/icon.icns" ]; then
cp "$ROOT/assets/icon.icns" "$out_app/Contents/Resources/GameIcon.icns"
# Brand the app icon. LÖVE.app resolves its icon through CFBundleIconName ->
# Assets.car first, so overwriting the loose .icns files alone changes
# nothing; the compiled asset catalog has to go and the plist has to fall
# back to CFBundleIconFile.
local icns="$ROOT/assets/icon.icns"
if [ ! -f "$icns" ]; then
icns="$WORK/GameIcon.icns"
make_icns "$icns"
fi
cp "$icns" "$out_app/Contents/Resources/GameIcon.icns"
cp "$icns" "$out_app/Contents/Resources/OS X AppIcon.icns"
rm -f "$out_app/Contents/Resources/Assets.car"
/usr/libexec/PlistBuddy -c "Delete :CFBundleIconName" "$plist" 2>/dev/null || true
/usr/libexec/PlistBuddy -c "Set :CFBundleIconFile OS X AppIcon" "$plist" 2>/dev/null \
|| /usr/libexec/PlistBuddy -c "Add :CFBundleIconFile string 'OS X AppIcon'" "$plist"
local id="$IDENTITY"
if [ -z "$id" ]; then
@@ -223,7 +261,47 @@ build_win() {
cp "$love_dir"/*.dll "$out_dir"/
cp "$love_dir"/license.txt "$out_dir"/ 2>/dev/null || true
cat "$love_dir/love.exe" "$LOVE_FILE" > "$out_dir/$APP_NAME.exe"
# The exe's icon lives in love.exe's PE resources, so it must be patched
# BEFORE the .love is appended: peresed rewrites the whole file and would
# drop the fused bytes. peresed (pipx install pe_tools) has no .ico input,
# only raw --set-resource, so split the .ico into RT_ICON blobs plus a
# GRPICONDIR that reuses love.exe's existing resource ids (1..N, lang 1033).
local ico="$WORK/$APP_NAME.ico"
make_ico "$ico"
if command -v peresed >/dev/null 2>&1; then
local ico_parts="$WORK/ico-parts"
rm -rf "$ico_parts"; mkdir -p "$ico_parts"
python3 - "$ico" "$ico_parts" <<'PY'
import struct, sys
data = open(sys.argv[1], "rb").read()
outdir = sys.argv[2]
count = struct.unpack_from("<H", data, 4)[0]
group = struct.pack("<HHH", 0, 1, count)
for i in range(count):
w, h, colors, res, planes, bpp, size, off = struct.unpack_from("<BBBBHHII", data, 6 + 16 * i)
open("%s/icon_%d.bin" % (outdir, i + 1), "wb").write(data[off:off + size])
group += struct.pack("<BBBBHHIH", w, h, colors, res, planes, bpp, size, i + 1)
open(outdir + "/group.bin", "wb").write(group)
print(count)
PY
local n_icons args=()
n_icons=$(ls "$ico_parts" | grep -c '^icon_')
for i in $(seq 1 "$n_icons"); do
args+=(-R RT_ICON "#$i" 1033 "$ico_parts/icon_$i.bin")
done
args+=(-R RT_GROUP_ICON "#1" 1033 "$ico_parts/group.bin")
local exe_branded="$WORK/love-branded.exe"
cp "$love_dir/love.exe" "$exe_branded"
if peresed "${args[@]}" "$exe_branded" >/dev/null; then
cat "$exe_branded" "$LOVE_FILE" > "$out_dir/$APP_NAME.exe"
else
warn "peresed failed to patch the exe icon, shipping stock LÖVE icon"
cat "$love_dir/love.exe" "$LOVE_FILE" > "$out_dir/$APP_NAME.exe"
fi
else
warn "peresed not found (pipx install pe_tools), shipping stock LÖVE exe icon"
cat "$love_dir/love.exe" "$LOVE_FILE" > "$out_dir/$APP_NAME.exe"
fi
local zip_out="$DIST/win/$APP_NAME-win64.zip"
rm -f "$zip_out"
@@ -278,6 +356,15 @@ build_linux() {
unsquashfs -q -no-xattrs -o "$sfs_offset" -d "$appdir" "$love_appimage" >/dev/null
cp "$LOVE_FILE" "$appdir/game.love"
# The .desktop's Icon=love resolves against the AppDir root by basename,
# so drop the stock love.svg and provide our PNG under the same name;
# .DirIcon is what appimaged/thumbnailers show for the file itself.
[ -f "$ICON_SRC" ] || fail "missing icon source: $ICON_SRC"
rm -f "$appdir/love.svg" "$appdir/.DirIcon"
sips -z 512 512 "$ICON_SRC" --out "$appdir/love.png" >/dev/null
cp "$appdir/love.png" "$appdir/.DirIcon"
sed -i '' 's|^#FUSE_PATH="$APPDIR/my_game.love"$|FUSE_PATH="$APPDIR/game.love"|' "$appdir/AppRun"
grep -q '^FUSE_PATH="\$APPDIR/game.love"$' "$appdir/AppRun" \
|| fail "failed to enable FUSE_PATH in AppRun (upstream AppRun changed?)"
+11 -6
View File
@@ -212,14 +212,19 @@ pack_game_love() {
tools/rom_manifest_yellow.json \
-x '*.DS_Store' -x '*/.git/*' -x '*/.DS_Store' \
-x 'data/generated/*' -x 'assets/generated/*')
if unzip -Z1 "$LOVE_FILE" \
| grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/'; then
fail "game.love unexpectedly contains generated ROM data"
fi
# Do not pipe unzip straight into grep here: on a large archive grep can
# finish early and make unzip report SIGPIPE under `set -o pipefail`.
# List once and match against the captured text: piping unzip straight into
# grep under `set -o pipefail` SIGPIPEs unzip as soon as grep exits early,
# and the pipeline's 141 outranks grep's own status. For the generated-data
# guard that inverted the test -- an archive that really did carry generated
# ROM data made grep match, killed unzip, and the `if` read the 141 as "no
# match" and let the build through (#774). Same listing feeds the
# required-file gates below, as in scripts/build.sh and scripts/pack_love.sh.
local archive_entries
archive_entries="$(unzip -Z1 "$LOVE_FILE")"
if grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/' \
<<< "$archive_entries"; then
fail "game.love unexpectedly contains generated ROM data"
fi
grep -qx 'tools/save-editor/App.lua' <<< "$archive_entries" \
|| fail "game.love is missing the save editor (Edit on a save row would crash)"
grep -qx "$YELLOW_MANIFEST_RELATIVE" <<< "$archive_entries" \
+88 -15
View File
@@ -528,6 +528,10 @@ BattleState.StatBox = StatBox -- the level-up stat window (PrintStatsBox)
local function newBattle(game)
local self = setmetatable({}, BattleState)
self.game = game
-- InitBattleVariables (engine/battle/init_battle_variables.asm) zeroes
-- wPartyAndBillsPCSavedMenuItem, so entering a battle drops the party
-- cursor the field menu has been carrying (src/ui/PartyMenu.lua). #768
game.partyMenuSavedIndex = nil
self.data = game.data
-- ruleset from the merged registry (the requires above are the same
-- records on a mod-free boot); an unknown save value falls back to the
@@ -644,6 +648,9 @@ function BattleState.newTrainer(game, oppClass, partyIndex)
local self = newBattle(game)
self.kind = "trainer"
self.oppClass = oppClass
-- the object_event trainer arg (roster index). computeMusicKind keys
-- data/scripts/victories.lua on class#party, so keep it on the battle (#782).
self.partyIndex = partyIndex or 1
self.trainer = game.data.trainers[oppClass]
assert(self.trainer, "unknown trainer class " .. tostring(oppClass))
-- pret GetTrainerName_: RIVAL1/2/3 copy wRivalName into wTrainerName
@@ -824,6 +831,16 @@ function BattleState:say(text)
table.insert(self.queue, { text = text })
end
-- A message whose ROM tail is `text_end` / `done` rather than `prompt`:
-- NextTextCommand returns straight out of PrintText on TX_END
-- (home/text.asm:328-334) and only TX_PROMPT_BUTTON blinks the arrow and
-- runs ManualTextScroll (home/text.asm:434-446), so these pages never wait
-- on the player. autoDelay is the frame hold before the queue moves on
-- (0 = the next row starts immediately, as PrintText returning does) (#765).
function BattleState:sayAuto(text, delay)
table.insert(self.queue, { text = text, auto = true, autoDelay = delay or 0 })
end
-- Message that opens YES/NO once typed out, keeping the text visible
-- underneath (pokered `done` + TWO_OPTION_MENU / TextBox opts.choice).
function BattleState:sayChoice(text, onChoose)
@@ -864,6 +881,13 @@ function BattleState:sayNext(text)
table.insert(self.queue, self.nextInsert, { text = text })
end
-- sayNext for a page that ends in `text_end` (see sayAuto) (#765)
function BattleState:sayNextAuto(text, delay)
self.nextInsert = (self.nextInsert or 0) + 1
table.insert(self.queue, self.nextInsert,
{ text = text, auto = true, autoDelay = delay or 0 })
end
-- insert a UI push right after the current queue item (dex page, the
-- level-up stat box -- anything that must keep queue order)
function BattleState:uiNext(factory)
@@ -1014,6 +1038,8 @@ function BattleState:startMessage(item)
self.charIndex = 0
self.msgWaiting = nil
self.msgPrompt = nil
self.msgAutoWait = nil
self.msgHold = nil
self.scrollPx = nil
self:beginMsgLine()
end
@@ -1268,7 +1294,25 @@ function BattleState:updateQueue()
end))
return true
end
if not (item and item.choice) then
if item and item.auto then
-- No prompt: this page's ROM tail is `text_end`, so PrintText is
-- already back with the box still on screen -- pokered's used-move
-- line (engine/battle/used_move_text.asm EndUsedMove1Text..
-- EndUsedMove5Text) and the item-use line (ItemUseText00,
-- engine/items/item_effects.asm) are both of that kind. Only
-- TX_PROMPT_BUTTON waits on A/B (home/text.asm:434-446) (#765).
self.msgAutoWait = self.msgAutoWait or item.autoDelay or 0
if self.msgAutoWait > 0 then
self.msgAutoWait = self.msgAutoWait - 1
else
self.msgAutoWait = nil
-- the typed page stays drawn behind whatever runs next (the move
-- animation, the ball toss): PrintText leaves the textbox tilemap
-- alone and animations only touch sprites (#296)
self.msgHold = true
self.current = nil
end
elseif not (item and item.choice) then
-- The page is typed out and waiting on the player: PromptText
-- (home/text.asm:209-217) writes '▼' at (18,16) and ManualTextScroll
-- blinks it until A/B, so the arrow belongs on a finished page and not
@@ -1320,14 +1364,17 @@ end
-- gets the final-battle theme
function BattleState:computeMusicKind()
local isBoss = false
if self.kind == "trainer" and self.trainer then
if self.kind == "trainer" and self.oppClass then
-- wGymLeaderNo is written only by the eight gym scripts
-- (scripts/PewterGym.asm .. ViridianGym.asm), so the badge rosters in
-- victories.lua are exactly the fights that set it. The lookup must
-- include the party index: a class-wide prefix match also caught
-- Giovanni's Rocket Hideout (#1) and Silph Co (#2) battles, which never
-- touch wGymLeaderNo and take MUSIC_TRAINER_BATTLE like any other
-- trainer (#782).
local victories = require("data.scripts.victories")
for key, reward in pairs(victories) do
if reward.badge and key:find(self.trainer.id .. "#", 1, true) == 1 then
isBoss = true
break
end
end
local reward = victories[self.oppClass .. "#" .. tostring(self.partyIndex or 1)]
isBoss = reward ~= nil and reward.badge ~= nil
end
-- init_battle.asm: challenging a gym leader (wGymLeaderNo, the badge
-- fights only -- not Lance or the Champion) bumps the companion's
@@ -1575,6 +1622,9 @@ end
-- (end_of_battle.asm clears wLowHealthAlarm when a battle ends)
function BattleState:exit()
require("src.core.Sound").stopLoop("Low_Health_Alarm")
-- end_of_battle.asm clears wPartyAndBillsPCSavedMenuItem as well, so the
-- field party menu comes back on slot 1 after a battle. #768
self.game.partyMenuSavedIndex = nil
-- Free this battle's own GPU objects now rather than waiting on a GC
-- finalizer: the two full-screen wavy-effect canvases (colorMode) and
-- the AnimPlayer's per-instance tilesheet images/quads. The shared
@@ -1601,6 +1651,19 @@ local function clearTrapping(battler)
battler.trapDamage = nil
end
-- SendOutMon (core.asm:1733-1735) clears both battle cursors, though the
-- disassembly only names one of them: `ld hl, wBattleAndStartSavedMenuItem /
-- ld [hli], a / ld [hl], a` writes zero to that byte AND to the byte behind
-- it, which is wPlayerMoveListIndex (wram.asm:242-244). So every player
-- send-out puts the main menu back on FIGHT and the move list back on the
-- first slot; the cursors are only remembered across sub-menus of the mon
-- that is already out (#737). Enemy send-outs run EnemySendOutFirstMon,
-- not SendOutMon, and leave both alone.
local function sendOutMonCursors(self)
self.menuIndex = 1
self.moveIndex = 1
end
-- core.asm:297-300: both sides' FLINCHED bits are cleared as a turn's move
-- selection opens, but the clear is skipped for a mon that must recharge or
-- is locked into Rage (core.asm:293-295 -- the Hyper Beam flinch-recharge
@@ -2084,7 +2147,7 @@ function BattleState:oldManThrow()
self.phase = "messages"
self.afterQueue = "finish"
self.result = "run" -- nothing is kept; wBattleResult only ends the demo
self:say(Strings("%s used\nPOKé BALL!", self.demoName or "OLD MAN"))
self:sayAuto(Strings("%s used\nPOKé BALL!", self.demoName or "OLD MAN"))
self:act(function()
require("src.core.Sound").play(self.data, "Ball_Toss")
-- ItemUseBall's beat before the toss chain (like throwBall)
@@ -2278,6 +2341,7 @@ function BattleState:resolveSwitch(newMon)
previous = previous,
})
self:markParticipant()
sendOutMonCursors(self)
self.sendingOut = true
self:sayNext(self:sendOutText(self.player.name))
self:animNext("POOF_ANIM", false)
@@ -3252,10 +3316,16 @@ end
-- damaging pipeline (EffectRegistry.runDamaging).
-- Gen 1 status/stat primary effects call PlayCurrentMoveAnimation only
-- after they land; these failure texts print with no animation.
-- after they land; these failure texts print with no animation. Failures
-- whose text is an ordinary sentence rather than one of the shared fail
-- lines set msgs.failed instead of relying on this sniffer -- Substitute's
-- two failure lines name the move, not the failure (#644).
local function primaryEffectFailed(msgs)
if not msgs or #msgs == 0 then return true end
local m = msgs[1]
if msgs.failed then return true end
-- the extracted lines keep the ROM's own trailing blank ("But, it
-- failed! "), so match with it trimmed or a refused status animates
local m = msgs[1]:gsub("%s+$", "")
if m == "But, it failed!" or m == "Nothing happened!" then return true end
if m:find("didn't affect", 1, true) then return true end
if m:find("is unaffected", 1, true) then return true end
@@ -3300,7 +3370,7 @@ function BattleState:performMove(user, target, moveInst, isCalled)
self.moveAnimRow = nil
if not (user.thrashTurns and moveInst == user.thrashMove and user.thrashAnnounced) then
self:sayNext(self:romText("_ItemUseText001", "%s\nused %s!", displayName(user), move.name))
self:sayNextAuto(self:romText("_ItemUseText001", "%s\nused %s!", displayName(user), move.name))
-- the move's animation plays right after the announcement; the
-- damage path attaches the target's hit blink to this row so the
-- blink follows the animation (pokered's order). Mimic is the
@@ -3796,6 +3866,7 @@ function BattleState:enemyMonFainted()
self.participants = {}
self:markParticipant()
self.nextInsert = 0
sendOutMonCursors(self)
self.sendingOut = true
self:sayNext(self:sendOutText(self.player.name))
self:animNext("POOF_ANIM", false)
@@ -3988,6 +4059,7 @@ function BattleState:openReplacementMenu()
})
self:markParticipant()
self.nextInsert = 0
sendOutMonCursors(self)
self.sendingOut = true
self:sayNext(self:sendOutText(self.player.name))
self:animNext("POOF_ANIM", false)
@@ -4026,7 +4098,7 @@ function BattleState:safariAction(choice)
if choice == "ball" then
st.balls = st.balls - 1
self:say(Strings("%s used\nSAFARI BALL!", playerName))
self:sayAuto(Strings("%s used\nSAFARI BALL!", playerName))
self:act(function()
require("src.core.Sound").play(self.data, "Ball_Toss")
self.lastBall = "SAFARI_BALL"
@@ -4348,7 +4420,7 @@ function BattleState:throwBall(ball)
-- "<PLAYER> used <ITEM>!" line (#291). Safari and the old man demo are
-- still wIsInBattle == 1, and this port models both as kind == "wild".
if self.kind == "wild" then
self:say(self:romText("_ItemUseText001", "%s used\n%s!", self.game.save.player.name,
self:sayAuto(self:romText("_ItemUseText001", "%s used\n%s!", self.game.save.player.name,
self.data.items[ball].name))
end
self:act(function()
@@ -5348,7 +5420,8 @@ end
function BattleState:drawTextArea()
Font.drawBox(0, 12, 20, 6)
love.graphics.setColor(0, 0, 0, 1)
if self.phase == "messages" and (self.current or self.animPlaying) then
if self.phase == "messages"
and (self.current or self.animPlaying or self.msgHold) then
-- during the move animation self.current is nil but shown still holds
-- the "used X!" lines; keep drawing them like pokered, whose move
-- animations only touch sprites and never the textbox tilemap (#296)
+16 -2
View File
@@ -259,15 +259,29 @@ MoveEffects.primary = {
return { romText(battle.data, "_StatusChangesEliminatedText", "All STATUS changes\nare eliminated!") }
end,
-- substitute.asm reaches its PlayCurrentMoveAnimation / AnimationSubstitute
-- Bankswitch only inside the success branch, after `set HAS_SUBSTITUTE_UP`;
-- .alreadyHasSubstitute and .notEnoughHP fall straight through to PrintText,
-- so both failures print with no animation at all. That is load bearing
-- here: the SUBSTITUTE animation opens with SE_SLIDE_MON_OFF, which leaves
-- the user's pic hidden (BattleState.lua slideOff end state) until the doll
-- is drawn in its place -- and with no substituteHP raised there is no doll,
-- so a failed Substitute used to erase the user's sprite for the rest of the
-- battle (#644). The failed flag rides the message list so performMove can
-- peel the announcement-time anim row without matching on printed text.
SUBSTITUTE_EFFECT = function(battle, user)
if user.substituteHP then return { romText(battle.data, "_HasSubstituteText", "%s\nhas a SUBSTITUTE!", displayName(user)) } end
if user.substituteHP then
return { romText(battle.data, "_HasSubstituteText", "%s\nhas a SUBSTITUTE!", displayName(user)),
failed = true }
end
local cost = math.floor(user.mon.stats.hp / 4)
-- substitute.asm only fails on subtraction underflow (current HP
-- strictly below maxHP/4); at equality the substitute is built and
-- the user is left standing on exactly 0 HP (it faints only when
-- the engine next checks HP, not here)
if user.mon.hp < cost then
return { romText(battle.data, "_TooWeakSubstituteText", "Too weak to make\na SUBSTITUTE!") }
return { romText(battle.data, "_TooWeakSubstituteText", "Too weak to make\na SUBSTITUTE!"),
failed = true }
end
user.mon.hp = user.mon.hp - cost
user.substituteHP = cost + 1
+22
View File
@@ -41,6 +41,13 @@ function Game:load()
-- render pipelines dispatch off the merged dataset; point them at the
-- one the mods just merged into before anything can draw a frame
require("src.render.Pipelines").install(Data)
-- Same reason, same moment: TypeChart caches the merged type records in an
-- upvalue, and until now only BattleState loaded it, on entering a battle.
-- Every non-battle reader of a type -- the summary screen's TYPE1/TYPE2
-- rows, the move-select TYPE/ box -- ran against an unloaded module and got
-- the raw id back instead of the display name, so a translation could not
-- reach them. Loading here means a type reads the same whoever asks first.
require("src.battle.TypeChart").load(Data)
self.input = Input
Input:init()
@@ -279,6 +286,21 @@ function Game.worldBgBattleDim(stack)
return nil
end
-- Is a BATTLE BG "world" battle composing itself over the live map right now?
-- Same whole-stack walk as worldBgBattleDim, asked for a different reason: the
-- dark-cave shade shift (wMapPalOffset) must not reach a frame a battle is
-- drawing in. InitBattleCommon (engine/battle/core.asm) pushes wMapPalOffset,
-- InitBattleVariables (engine/battle/init_battle_variables.asm) writes 0 over
-- it and core.asm pops it back when the battle ends, so a battle in an
-- un-flashed Rock Tunnel is lit on hardware. Every other BATTLE BG gets that
-- for free -- no map draws beneath an opaque battle, so nothing re-arms the
-- per-frame shade map -- but "world" keeps the overworld drawing underneath,
-- and its arming then darkened the battle's own pics, HUD and text at colorize
-- time (#773).
function Game.worldBgBattleInStack(stack)
return Game.worldBgBattleDim(stack) ~= nil
end
-- Does anything on the stack want the surface scaled to FILL the window
-- (aspect preserved, bars on the long axis) rather than sit at the fixed
-- integer scale?
+124 -26
View File
@@ -218,14 +218,13 @@ function LauncherView.update(imp, dt)
if not imp._flex then return end
FlexLove.update(dt)
-- Drain the action queue OUTSIDE FlexLove's dispatch, so an action is free
-- to destroy the view (Play/Edit) or block in a native picker.
-- to destroy the view (Play/Edit) or block in a native picker. The batch
-- is resolved by RomImporter:runActions so the drop/disarm rules are
-- testable without a live FlexLove tree (#780).
local queue = imp._uiActions
if queue and #queue > 0 then
imp._uiActions = {}
for _, fn in ipairs(queue) do
local ok, err = pcall(fn)
if not ok then print("launcher action error: " .. tostring(err)) end
end
imp:runActions(queue)
end
end
@@ -292,9 +291,12 @@ local function queueAction(imp, key, fn, keepArm)
if last and now - last < ACT_DEDUP then return end
imp._actAt[key] = now
-- Any press that is not a Delete's own second click disarms the pending
-- delete confirm (#433's rule, preserved from the hit-rect launcher).
if not keepArm then imp._confirmDelete = nil end
imp._uiActions[#imp._uiActions + 1] = fn
-- delete confirm (#433's rule, preserved from the hit-rect launcher). The
-- disarm itself is applied by RomImporter:runActions when the batch drains,
-- not here: one touch lands on a row AND on the chip inside it, and
-- clearing the arm as the row queued left Delete stuck on its first press
-- (#780).
imp._uiActions[#imp._uiActions + 1] = { key = key, fn = fn, keepArm = keepArm }
end
local function handler(imp, key, action, keepArm)
@@ -327,7 +329,9 @@ local function mfont(size)
size = math.max(8, math.floor(size + 0.5))
local f = measureFonts[size]
if not f then
f = love.graphics.newFont(size)
-- same fallback the rendering faces get (FlexLove FontCache), or the
-- launcher measures Latin widths for text it draws with kana
f = require("src.render.UiFont").attach(love.graphics.newFont(size), size)
measureFonts[size] = f
end
return f
@@ -805,7 +809,7 @@ local function buildRomCard(imp, parent, m, version, info, ready, locked)
local accent = version == "yellow" and "gold" or version
local c = card(parent, { padding = m.cardPad, gap = 8 * m.s })
label(c, "ROM", 12 * m.s + 1, C("gray"))
label(c, Strings("ROM"), 12 * m.s + 1, C("gray"))
label(c, romState, 15 * m.s + 2, C("white"))
label(c, romDetail, 12 * m.s + 2, C("detail"))
if romProgress ~= nil then
@@ -845,7 +849,7 @@ local function buildSaveFilesCard(imp, parent, m, version, ready, locked)
local savImportLabel = imp.isNX and Strings("Scan again") or Strings("Import save")
local c = card(parent, { padding = m.cardPad, gap = 8 * m.s })
label(c, "SAVE FILES", 12 * m.s + 1, C("gray"))
label(c, Strings("SAVE FILES"), 12 * m.s + 1, C("gray"))
local row = mk({ parent = c, width = "100%",
positioning = "flex", flexDirection = "horizontal", gap = 10 * m.s })
-- explicit halves rather than flex growth, which mis-distributed inside
@@ -887,7 +891,7 @@ local function buildSlotCard(imp, parent, m, version)
local head = mk({ parent = c, width = "100%",
positioning = "flex", flexDirection = "horizontal",
justifyContent = "space-between", alignItems = "center" })
label(head, "SAVE SLOT", 12 * m.s + 1, C("gray"), { textWrap = false })
label(head, Strings("SAVE SLOT"), 12 * m.s + 1, C("gray"), { textWrap = false })
label(head, n == 1 and Strings("1 slot") or Strings("%d slots", n),
12 * m.s + 1, C("gray"), { textWrap = false })
@@ -981,10 +985,14 @@ local function buildSlotCard(imp, parent, m, version)
})
end
local armed = deleteArmed(imp, "slot", slot.id, version)
-- width pinned to the unarmed label so arming to "Sure?" never reflows
-- the row under the pointer (#433)
-- width pinned so arming to "Sure?" never reflows the row under the
-- pointer (#433). Pinned to the wider of the two captions, not to the
-- unarmed one: English "Delete" is the longer of the pair, but a
-- translation need not keep that order (Japanese さくじょ is shorter than
-- よろしい?), and pinning to the shorter one clips the other.
button(imp, btnRow, rowKey .. "-del", DELETE_LABEL(armed), {
w = math.ceil(textWidth(chipSize, DELETE_LABEL(false))) + 26,
w = math.ceil(math.max(textWidth(chipSize, DELETE_LABEL(false)),
textWidth(chipSize, DELETE_LABEL(true)))) + 26,
size = chipSize, kind = armed and "dangerArmed" or "danger",
keepArm = true,
action = function()
@@ -1241,13 +1249,9 @@ local function buildModsPanel(imp, parent, m)
-- counts, so a pre-downloads cache entry costs the line, not a wrong "0".
local dlLine
if info and info.downloads then
local formatted = ModUpdate.formatCount(info.downloads.total)
if info.dates then
dlLine = Strings("%s downloads across all releases - Released %s - Updated %s",
formatted, info.dates.first, info.dates.latest)
else
dlLine = Strings("%s downloads across all releases", formatted)
end
local d = info.dates
dlLine = ModUpdate.statsLine(info.downloads.total,
d and d.first, d and d.latest)
end
-- measure the body: name (with the badge beside it only when it fits),
@@ -1369,9 +1373,11 @@ end
local function buildFindPanel(imp, parent, m)
imp._findThumbFetched = false
imp._findStatsFetched = false
imp:_ensureFind()
imp:_ensureMods()
local ModIndex = require("src.mods.ModIndex")
local ModUpdate = require("src.mods.ModUpdate")
local sources = imp.findSources or {}
local rows = imp:_findRows()
local total = #((imp.findIndex and imp.findIndex.mods) or {})
@@ -1509,6 +1515,80 @@ local function buildFindPanel(imp, parent, m)
return
end
-- Sort row: Name / Popularity / Release date / Last updated, the same
-- options the MODS tab offers, sharing its persisted choice
-- (options.modSort). Data comes from the same _findStats resolution the
-- cards use (feed-published, else the repo fetch); rows whose stats have
-- not resolved yet sink to the bottom of data sorts and rise as the
-- one-per-frame fetches complete.
local sortKey = imp.modSort or "name"
if imp.modSort == nil then
local ok, opts = pcall(require("src.core.SaveData").loadOptions)
if ok and type(opts) == "table" and type(opts.modSort) == "string" then
sortKey = opts.modSort
imp.modSort = sortKey
end
end
local sortRow = mk({ parent = parent, width = "100%",
positioning = "flex", flexDirection = "horizontal",
flexWrap = "wrap", alignItems = "center", gap = 6 * m.s })
label(sortRow, Strings("Sort:"), 11 * m.s + 2, C("detail"), { textWrap = false })
local sorts = {
{ key = "name", label = Strings("Name") },
{ key = "popularity", label = Strings("Popularity") },
{ key = "release", label = Strings("Release date") },
{ key = "updated", label = Strings("Last updated") },
}
for _, s in ipairs(sorts) do
local active = sortKey == s.key
local key = "find-sort-" .. s.key
mk({
parent = sortRow, text = s.label,
textColor = active and C("green")
or (imp._hot[key] and C("white") or C("detail")),
textSize = 11 * m.s + 2, textAlign = "center-center", autoScaleText = false,
backgroundColor = active and C("green", 0.18) or C("border", 0.10),
border = 1,
borderColor = active and C("green", 0.6) or C("border", 0.35),
cornerRadius = 999,
padding = { horizontal = 10, vertical = 4 },
onEvent = handler(imp, key, function()
imp.modSort = s.key
pcall(function()
local SaveData = require("src.core.SaveData")
local opts = SaveData.loadOptions()
opts.modSort = s.key
SaveData.saveOptions(opts)
end)
end),
})
end
local sorted = {}
for i, v in ipairs(rows) do sorted[i] = v end
table.sort(sorted, function(a, b)
local function value(entry)
if sortKey == "name" then
return (entry.title or entry.id or ""):lower()
end
local stats = imp:_findStats(entry)
if sortKey == "popularity" then
return stats and stats.total or -1
end
if sortKey == "release" then
return stats and stats.first or "0000-00-00"
end
return stats and stats.latest or "0000-00-00"
end
local va, vb = value(a), value(b)
if va ~= vb then
if sortKey == "name" then return va < vb end
return va > vb -- data sorts newest / most popular first
end
return (a.title or a.id or ""):lower() < (b.title or b.id or ""):lower()
end)
rows = sorted
local installed = imp:_findInstalledMap()
local thumbW = 64 * m.s
-- Explicit measured widths AND heights, same reasoning as the mods card:
@@ -1522,9 +1602,18 @@ local function buildFindPanel(imp, parent, m)
local btnH = math.ceil(textHeight(chipSize)) + 14
for _, entry in ipairs(rows) do
local action, note = findActionFor(entry, installed[entry.id])
-- Release stats for the row: feed-published when the feed carries
-- them, otherwise fetched from the mod's GitHub repo (one per frame,
-- cached six hours) exactly like the MODS tab does.
local stats = imp:_findStats(entry)
local statsLine
if stats and (stats.total ~= nil or stats.first or stats.latest) then
statsLine = ModUpdate.statsLine(stats.total, stats.first, stats.latest)
end
local bodyH = math.ceil(textHeight(titleSize))
+ 4 + math.ceil(textHeight(smallSize))
if statsLine then bodyH = bodyH + 4 + wrapHeight(smallSize, statsLine, bodyW) end
if note then bodyH = bodyH + 4 + wrapHeight(smallSize, note, bodyW) end
if entry.summary and entry.summary ~= "" then
bodyH = bodyH + 4 + wrapHeight(smallSize, entry.summary, bodyW)
@@ -1569,6 +1658,9 @@ local function buildFindPanel(imp, parent, m)
end
label(body, meta, smallSize, C("detail"),
{ width = "100%", textWrap = false, textOverflow = "ellipsis" })
if statsLine then
label(body, statsLine, smallSize, C("gold"), { width = "100%" })
end
if note then label(body, note, smallSize, C("green"), { width = "100%" }) end
if entry.summary and entry.summary ~= "" then
label(body, entry.summary, smallSize, C("detail"), { width = "100%" })
@@ -1753,12 +1845,18 @@ end
local function buildConfirmModal(imp, m)
local c = imp._modConfirm
local overlay = modalOverlay(imp, m, "confirm-out")
local panel = modalPanel(overlay, m, 420 * m.s)
label(panel, c.title or Strings("Confirm"), 15 * m.s + 2, C("white"))
-- roomier than the shared 420 default: the install confirm carries the
-- compat issue list and the trust warning, and those lines need air
local panel = modalPanel(overlay, m, 520 * m.s, {
gap = 12 * m.s, padding = { horizontal = 22, vertical = 20 },
})
label(panel, c.title or Strings("Confirm"), 17 * m.s + 2, C("white"))
for _, line in ipairs(c.lines or {}) do
label(panel, line, 12 * m.s + 1, C("detail"))
label(panel, line, 13 * m.s + 1, C("detail"))
end
local btnRow = mk({ parent = panel, width = "100%",
-- explicit height: an auto-sized row measures short while the panel
-- auto-sizes, which clipped the buttons at the panel's bottom border
local btnRow = mk({ parent = panel, width = "100%", height = m.btnH,
positioning = "flex", flexDirection = "horizontal", gap = 10 * m.s })
button(imp, btnRow, "confirm-yes", c.yesLabel or Strings("OK"), {
flex = 1, h = m.btnH, size = 13 * m.s + 1, kind = "primary",
+95 -1
View File
@@ -1639,9 +1639,100 @@ function RomExtractor:raw1bpp(label, width, height, relative, transparent)
return image
end
-- Trading animation art: gfx/trade.asm TradingAnimationGraphics is one
-- 49-tile atlas (game_boy.2bpp, built with --remove-duplicates, then
-- link_cable.2bpp), and the Game Boy and open-cable plates are painted out
-- of it through the tilemaps in data/tilemaps.asm (GameBoyTiles 6x8,
-- LinkCableTiles 12x3), whose ids are absolute vChars2 ids starting at $31
-- because trade.asm reaches them through
-- CopyTileIDsFromList_ZeroBaseTileID. Only the developer-only Python path
-- ever wrote these files, so an imported cache had none of them and
-- TradeAnim drew the whole cinematic as plain rectangles (#750).
function RomExtractor:extractTradeArt()
local BASE, COUNT = 0x31, 49
local gfx = self:symbol("TradingAnimationGraphics")
local atlas = ImageWriter.decode2bpp(
self.rom:bytes(gfx.bank, gfx.address, COUNT * 16), COUNT * 8, 8)
local function tileX(id)
local index = id - BASE
assert(index >= 0 and index < COUNT,
("trade tile $%02X is outside the animation atlas"):format(id))
return index * 8
end
local function plate(label, tilesWide, tilesHigh, relative, matte)
local map = self:symbol(label)
local ids = self.rom:bytes(map.bank, map.address, tilesWide * tilesHigh)
local image = ImageWriter.blank(tilesWide * 8, tilesHigh * 8, 1, 1, 1, 1)
for index, id in ipairs(ids) do
ImageWriter.blit(image, atlas,
(index - 1) % tilesWide * 8,
math.floor((index - 1) / tilesWide) * 8, tileX(id), 0, 8, 8)
end
if matte then image = ImageWriter.matteColor0(image) end
self:save(image, relative)
end
plate("GameBoyTiles", 6, 8, "trade/game_boy.png", true)
plate("LinkCableTiles", 12, 3, "trade/open_cable.png", false)
for _, spec in ipairs({
{ 0x5D, "cable_conn" }, { 0x5E, "cable_seg" }, { 0x5F, "cable_corner" },
{ 0x60, "cable_end" }, { 0x61, "cable_vert" },
}) do
local tile = ImageWriter.blank(8, 8, 1, 1, 1, 1)
ImageWriter.blit(tile, atlas, 0, 0, tileX(spec[1]), 0, 8, 8)
self:save(tile, "trade/" .. spec[2] .. ".png")
end
-- Trade_DrawCableAcrossScreen fills a whole 20-tile row with tile $5e.
local horizontal = ImageWriter.blank(160, 8, 1, 1, 1, 1)
for column = 0, 19 do
ImageWriter.blit(horizontal, atlas, column * 8, 0, tileX(0x5E), 0, 8, 8)
end
self:save(horizontal, "trade/cable_horiz.png")
-- Trade_BallInsideLinkCableOAMBlock draws one tile four times with the
-- X/Y flips, so each of the two frames -- $7e travelling, $7f bulging,
-- the bottom row of TradingAnimationGraphics2 -- makes a 16x16 ball.
local ball = self:symbol("TradingAnimationGraphics2")
local frames = ImageWriter.decode2bpp(
self.rom:bytes(ball.bank, ball.address, 64), 16, 16, true)
for index, name in ipairs({ "cable_ball", "cable_ball_alt" }) do
local image = ImageWriter.blank(16, 16, 1, 1, 1, 0)
for y = 0, 7 do
for x = 0, 7 do
local r, g, b, a = frames:getPixel((index - 1) * 8 + x, 8 + y)
image:setPixel(x, y, r, g, b, a)
image:setPixel(15 - x, y, r, g, b, a)
image:setPixel(x, 15 - y, r, g, b, a)
image:setPixel(15 - x, 15 - y, r, g, b, a)
end
end
self:save(image, "trade/" .. name .. ".png")
end
-- The ring around the travelling mon: one 16x16 quadrant per animation
-- frame (engine/gfx/mon_icons.asm TradeBubbleIconGFX), mirrored into a
-- 32x32 circle by the OAM attributes in Trade_CircleOAMBlocks.
local bubble = self:symbol("TradeBubbleIconGFX")
self:write2bpp(self.rom:bytes(bubble.bank, bubble.address, 128),
16, 32, "trade/bubble.png", true)
return {
gameBoy = "assets/generated/trade/game_boy.png",
openCable = "assets/generated/trade/open_cable.png",
cableHoriz = "assets/generated/trade/cable_horiz.png",
cableConn = "assets/generated/trade/cable_conn.png",
cableVert = "assets/generated/trade/cable_vert.png",
cableCorner = "assets/generated/trade/cable_corner.png",
cableEnd = "assets/generated/trade/cable_end.png",
cableBall = "assets/generated/trade/cable_ball.png",
cableBallAlt = "assets/generated/trade/cable_ball_alt.png",
bubble = "assets/generated/trade/bubble.png",
source = "ROM:TradingAnimationGraphics + ROM:TradeBubbleIconGFX"
.. " (engine/movie/trade.asm InternalClockTradeAnim)",
}
end
function RomExtractor:extractField()
self:beginStage("Interface artwork")
local done, total = 0, 49
local done, total = 0, 50
local function tick()
done = done + 1
self:tick("Interface artwork", math.min(done, total), total)
@@ -1832,6 +1923,8 @@ function RomExtractor:extractField()
end
self:save(emotes, "emotes.png"); tick()
local tradeArt = self:extractTradeArt(); tick()
-- Yellow-only: the Surfing Pikachu minigame sheets
-- (gfx/surfing_pikachu.asm) at pret's canvas widths, so
-- src/ui/SurfingMinigame.lua's quads can be read off the source pngs.
@@ -1962,6 +2055,7 @@ function RomExtractor:extractField()
local converted = {}
for index, values in pairs(adjacency) do converted[tonumber(index)] = values end
data.hiddenExtras.trashCans.adjacent = converted
data.tradeArt = tradeArt
data.source = "canonical Pokemon Red ROM + bundled port metadata"
self:write("field", data)
self:tick("Interface artwork", total, total)
+102 -3
View File
@@ -57,6 +57,10 @@ local REQUIRED_FILES = {
"assets/generated/battle/anims/move_anim_0.png",
"assets/generated/battle/anims/move_anim_1.png",
"assets/generated/audio/programs.bin",
-- The trade cinematic's Game Boy / cable art. Caches built before #750
-- carry none of it and fall back to plain rectangles, so listing one of
-- the files re-imports them without a CACHE_FORMAT bump.
"assets/generated/trade/game_boy.png",
}
-- Files only one version's cache carries. A version that predates one of
@@ -1826,6 +1830,18 @@ function RomImporter:update(dt)
end
local tab = os.getenv("POKEPORT_LAUNCHER_TAB")
if tab and tab ~= "" then self:_switchTab(tab) end
-- POKEPORT_LAUNCHER_CONFIRM=1 arms a representative install confirm so
-- a capture can see the modal (it is otherwise only reachable by click)
if os.getenv("POKEPORT_LAUNCHER_CONFIRM") == "1" then
self._modConfirm = {
kind = "update",
title = "Install mod",
yesLabel = "Install",
lines = { "JP GREEN - Poketto Monsuta Midori v0.4.4",
"by bryanthaboi",
"Mods are not reviewed - trust the author." },
}
end
local query = os.getenv("POKEPORT_LAUNCHER_QUERY")
if query and query ~= "" then
self.findQuery = query
@@ -2242,6 +2258,39 @@ function RomImporter:pressDelete(kind, id, version, commit)
return false
end
-- Drain one frame's queued launcher actions; LauncherView.update hands the
-- batch straight over. A touch tap fires on EVERY element whose bounds hold
-- the finger, not only the topmost one: FlexLove gates its mouse path on
-- Context.findInteractiveAtPosition (libs/flexlove/modules/behaviors/
-- Clickable.lua) but polls touches per element with a bare bounds test
-- (EventHandler:processTouchEvents), so a phone tap on a save row's Delete
-- chip also lands on the row behind it. Control keys inside a row are the
-- row's key plus "-<what>", so a row's own action is dropped whenever a
-- control inside that row queued in the same batch, and #433's disarm runs
-- here instead of at queue time. Without both halves an Android tap on
-- Delete selected the slot and wiped the arm it had just set, so a secondary
-- slot became the loaded one and could never be deleted (#780).
function RomImporter:runActions(queue)
for i = 1, #queue do
local entry = queue[i]
local key = type(entry.key) == "string" and entry.key or ""
local superseded = false
for j = 1, #queue do
local other = queue[j]
if j ~= i and type(other.key) == "string"
and other.key:sub(1, #key + 1) == key .. "-" then
superseded = true
break
end
end
if not superseded then
if not entry.keepArm then self._confirmDelete = nil end
local ok, err = pcall(entry.fn)
if not ok then print("launcher action error: " .. tostring(err)) end
end
end
end
-- Clicks are polled inside FlexLove (mouse + love.touch); host-forwarded
-- mousepressed stays inert so Android's synthesized mouse path cannot
-- double-fire a tap (#553). Touch move/press/release must still reach
@@ -2851,7 +2900,7 @@ end
-- NX / desktop / Android labels and inbox hints for the FlexLove view.
function RomImporter:_modsImportButtonLabel()
if self.isNX then return Strings("Scan again") end
return "Import mod .zip"
return Strings("Import mod .zip")
end
function RomImporter:_modsDefaultHint()
@@ -2862,7 +2911,7 @@ function RomImporter:_modsDefaultHint()
return Strings("Copy a .zip via MTP into %s/imports/mods/\n"
.. "DBI MTP → 1: SD Card/%simports/mods/", saveDir, rel)
end
if self.android then return "Or copy a mod .zip via USB." end
if self.android then return Strings("Or copy a mod .zip via USB.") end
return Strings("Or drop a mod .zip onto the window.")
end
@@ -2878,7 +2927,7 @@ function RomImporter:_savesDefaultHint(version)
.. "DBI MTP → 1: SD Card/%s%s/", game, saveDir, inbox, rel, inbox)
end
if self.android then
return "Import or export a .sav with the system file picker."
return Strings("Import or export a .sav with the system file picker.")
end
return Strings("Import a .sav to a new slot, or export the active slot.")
end
@@ -3038,6 +3087,56 @@ function RomImporter:_findThumb(entry)
return ok and image or nil
end
-- Release stats for a FIND MODS row, resolved the same way the MODS tab
-- does it: the mod's own GitHub releases through ModUpdate's cached fetch,
-- so an installed mod's repo is instant and every result lands in
-- options.modUpdateCache for six hours. A feed that publishes stats wins
-- outright (fresher, zero network); otherwise the repo is fetched, one
-- entry per frame so opening the tab cannot stall for the whole listing.
-- The result is memoized per id for the session; a repo with no releases
-- or a failed fetch resolves to an empty table so it is tried once.
function RomImporter:_findStats(entry)
self._findStatsCache = self._findStatsCache or {}
local cached = self._findStatsCache[entry.id]
if cached then
if cached.done or (cached.retryAt and os.time() < cached.retryAt) then
return cached
end
self._findStatsCache[entry.id] = nil -- retry window open, refetch
end
if entry.downloads ~= nil or entry.first_release or entry.last_release then
cached = { total = entry.downloads, first = entry.first_release,
latest = entry.last_release, done = true }
self._findStatsCache[entry.id] = cached
return cached
end
if self._findStatsFetched then return nil end -- budget spent this frame
if not entry.github or entry.github == "" then
cached = { done = true }
self._findStatsCache[entry.id] = cached
return cached
end
self._findStatsFetched = true
local ModUpdate = require("src.mods.ModUpdate")
local list, fetchErr
local ok = pcall(function()
list, fetchErr = ModUpdate.fetchReleases(entry.github, entry.id, {})
end)
local stats = list and ModUpdate.statsForReleases(list) or nil
if stats then
cached = { total = stats.total, first = stats.first,
latest = stats.latest, done = true }
else
-- A repo that does not exist is permanent; every other failure (the
-- hourly API rate limit, a hiccup) is retried in a minute so rows can
-- recover without restarting the launcher.
local permanent = tostring(fetchErr):find("Not Found", 1, true) ~= nil
cached = { done = permanent, retryAt = os.time() + 60 }
end
self._findStatsCache[entry.id] = cached
return cached
end
-- Open the "add an index" text prompt. Deliberately a typed URL rather than a
-- picked-from-a-list affair: there is no blessed index, and presenting one
-- would make the launcher's choice look like an endorsement.
+24 -6
View File
@@ -6,8 +6,10 @@
-- bytes), runs them through SaveConvert.importSav (32768-byte + checksum
-- validated), then registers a fresh slot, writes it, and makes it active.
-- Export loads the active slot, encodes it back to a 32768-byte SRAM image, and
-- drops it in the save directory's exports/<version>/ folder, returning the
-- absolute path so the launcher can offer an "open folder" affordance.
-- drops it in exports/<version>/ under the same root SaveData's persistFs
-- writes slots to -- the portable game folder when portable.txt marks the
-- install, otherwise the LOVE save directory (#752) -- returning the absolute
-- path so the launcher can offer an "open folder" affordance.
--
-- Every failure returns false + a friendly one-line message (never raises), so
-- the card can surface it as a red notice line rather than crashing.
@@ -99,9 +101,10 @@ end
-- exportActiveSlot(version) -> ok, pathOrErr
-- Loads the version's active slot save (SaveData.load semantics), encodes it
-- back to a 32768-byte SRAM image, and writes it to
-- exports/<version>/gen1recomp-<version>-<slotId>.sav in the save directory
-- (created if absent). Returns true + the absolute path on success, false + a
-- friendly message otherwise.
-- exports/<version>/gen1recomp-<version>-<slotId>.sav under the portable game
-- folder when portable mode is on, otherwise the save directory (created if
-- absent). Returns true + the absolute path on success, false + a friendly
-- message otherwise.
function SaveFileIO.exportActiveSlot(version)
version = version or GameVersion.get()
local save = SaveData.load(version)
@@ -109,7 +112,14 @@ function SaveFileIO.exportActiveSlot(version)
local bytes, exportErr = SaveConvert.exportSav(save, version)
if not bytes then return false, exportErr end
local slotId = SaveData.activeSlot(version) or "save"
local fs = love and love.filesystem
-- Portable mode is the same seam SaveData's own persistFs uses: when
-- portable.txt marks the install every persistent write leaves the OS save
-- directory for the game folder, and an export is no exception. Writing
-- through love.filesystem here dropped the .sav in AppData while the slots
-- it came from lived on the stick, and the desktop "Open folder" affordance
-- (RomImporter:exportSave) followed the returned path straight there (#752).
local portableFs = SaveData.portableFs()
local fs = portableFs or (love and love.filesystem)
if not (fs and fs.write) then return false, "no filesystem available to export to" end
if fs.createDirectory then
fs.createDirectory("exports")
@@ -119,6 +129,14 @@ function SaveFileIO.exportActiveSlot(version)
local rel = ("exports/%s/gen1recomp-%s-%s.sav"):format(version, version, slotId)
local ok, writeErr = fs.write(rel, bytes)
if not ok then return false, "could not write the export: " .. tostring(writeErr) end
-- Absolute path for the notice line, resolved against whichever root took
-- the write. Portable paths use the OS separator (slotDiskPath does the
-- same); LOVE save-directory paths stay "/"-joined as before.
local portableBase = SaveData.portableBaseDir()
if portableBase then
local sep = package.config:sub(1, 1)
return true, portableBase .. sep .. rel:gsub("/", sep)
end
local base = fs.getSaveDirectory and fs.getSaveDirectory() or ""
if base ~= "" then return true, base .. "/" .. rel end
return true, rel
+74
View File
@@ -254,6 +254,80 @@ function LauncherMods.list()
return result or {}
end
-- ------- pre-boot translation strings
--
-- The launcher draws before Game:load, so the loader has not run and Strings
-- has no catalog. #767/#791 routed the launcher's text through Strings, but
-- nothing filled the catalog this early, so a translation mod still could not
-- reach the launcher however complete it was -- and no restart helped, because
-- the ordering is the same on every launch.
--
-- This fills it, and deliberately does the smallest thing that can: one
-- declarative file per enabled mod, lang/strings.lua, and never the entry
-- chunk. That keeps the promise the rest of this module is built on -- no mod
-- behaviour runs before the game boots -- because a catalog is data.
--
-- It is still a mod-authored chunk, so it runs with an empty environment: a
-- plain `return { ... }` evaluates fine, while anything reaching for love, io
-- or os raises and is skipped rather than being trusted this early.
--
-- Game:load calls Strings.load(Data) again after the real merge, which
-- replaces whatever this installed, so the two never disagree for long.
local STRINGS_CATALOG = "lang/strings.lua"
local function readStringsCatalog(path)
local fs = love and love.filesystem
if not (fs and fs.read) then return nil end
local rel = path .. "/" .. STRINGS_CATALOG
local raw = fs.read(rel)
if type(raw) ~= "string" or raw == "" then return nil end
local chunk = loadstring(raw, "@" .. rel)
if not chunk then return nil end
-- Lua 5.1/LuaJIT: no _ENV, so setfenv is the sandbox.
if setfenv then setfenv(chunk, {}) end
local ok, result = pcall(chunk)
if not ok or type(result) ~= "table" then return nil end
return result
end
-- deriveStrings(rows, byId, read) -> the merged catalog, pure.
-- rows is deriveList's output, byId the id -> manifest map, and read(path) a
-- reader returning that mod's catalog table (or nil). Split out so the engine
-- tier can table-drive the enable/precedence rules with no filesystem.
function LauncherMods.deriveStrings(rows, byId, read)
local out, any = {}, false
for _, row in ipairs(rows or {}) do
local manifest = row.enabled and byId and byId[row.id] or nil
local catalog = manifest and manifest.path and read(manifest.path)
for source, value in pairs(catalog or {}) do
-- an empty value means "not translated yet", never "translate to
-- blank" -- the same rule the mod's own loader applies
if type(source) == "string" and type(value) == "string"
and value ~= "" then
out[source] = value
any = true
end
end
end
return any and out or nil
end
-- translationStrings() -> a source -> translation map for the launcher, or nil
-- when no enabled mod ships one. Enable-state and ordering are deriveList's,
-- so a mod that wins a key here wins it at boot too.
function LauncherMods.translationStrings()
local ok, merged = pcall(function()
local manifests = discover()
if #manifests == 0 then return nil end
local rows = LauncherMods.deriveList(manifests, SaveData.loadOptions())
local byId = {}
for _, m in ipairs(manifests) do byId[m.id] = m end
return LauncherMods.deriveStrings(rows, byId, readStringsCatalog)
end)
if not ok then return nil end
return merged
end
-- setEnabled(id, enabled): persist options.mods[id] in the exact shape
-- Loader:_saveState writes (a plain boolean), so the running game and the
-- in-game ManagerState pick it up unchanged.
+7
View File
@@ -174,6 +174,13 @@ local function parseEntry(raw)
conflicts = raw.conflicts,
thumbnail = str(raw.thumbnail),
description_url = str(raw.description_url),
-- Optional release stats a feed author can publish: total downloads
-- across all releases plus first/last release dates. Additive-only,
-- so a feed that carries them stays readable by every build that
-- predates them (and one that does not still renders fine here).
downloads = tonumber(raw.downloads),
first_release = str(raw.first_release),
last_release = str(raw.last_release),
latest = parseLatest(raw.latest),
update_check = str(raw.update_check) or "pending",
}
+34
View File
@@ -7,6 +7,8 @@ local ModUpdate = {}
ModUpdate.CACHE_TTL = 6 * 60 * 60 -- six hours
local Strings = require("src.core.Strings")
local function stripV(tag)
return (tostring(tag):gsub("^[vV]", ""))
end
@@ -227,6 +229,20 @@ function ModUpdate.releaseDates(releases)
return { first = first, latest = latest }
end
-- One resolver over a release list: { total, first, latest } or nil when
-- the list carries neither counts nor dates. The FIND MODS rows use this
-- on the repo's fetched releases, the same source the MODS tab trusts.
function ModUpdate.statsForReleases(releases)
local dl = ModUpdate.totalDownloads(releases)
local d = ModUpdate.releaseDates(releases)
if not dl and not d then return nil end
return {
total = dl and dl.total or nil,
first = d and d.first or nil,
latest = d and d.latest or nil,
}
end
-- Thousands-separated count for the launcher ("12,345"), plain for small
-- numbers. Never throws; garbage in, "0" out.
function ModUpdate.formatCount(n)
@@ -237,6 +253,24 @@ function ModUpdate.formatCount(n)
return (s:gsub("^,", ""))
end
-- The one-line stats string both launcher panels show: "1,234 downloads
-- across all releases - Released 2024-05-31 - Updated 2026-07-01".
-- Each part is optional; nil everywhere means nil, so a row with no data
-- shows no line rather than a wrong "0".
function ModUpdate.statsLine(total, first, latest)
local parts = {}
if total ~= nil then
parts[#parts + 1] = Strings("%s downloads across all releases",
ModUpdate.formatCount(total))
end
if first or latest then
parts[#parts + 1] = Strings("Released %s - Updated %s",
first or "?", latest or "?")
end
if #parts == 0 then return nil end
return table.concat(parts, " - ")
end
-- ------- cache (options.modUpdateCache[repo])
local function cacheStore()
+19 -7
View File
@@ -74,7 +74,11 @@ function f.map(key, value)
desc = ("map of %s -> %s"):format(key.desc, value.desc) }
end
function f.rec(fields)
-- opts.strict closes the record to unknown fields even at the extensible
-- top level. A union alternative whose fields are ALL optional needs this:
-- with top-level leniency it matches every table, so the union stops
-- rejecting anything (the font "ttf" shape was the first such alternative).
function f.rec(fields, opts)
local names = {}
for name in pairs(fields) do names[#names + 1] = name end
table.sort(names)
@@ -83,7 +87,7 @@ function f.rec(fields)
local ft = fields[name]
parts[#parts + 1] = name .. (ft.kind == "opt" and "?" or "")
end
return { kind = "rec", fields = fields,
return { kind = "rec", fields = fields, strict = opts and opts.strict or nil,
desc = "{" .. table.concat(parts, ", ") .. "}" }
end
@@ -175,7 +179,7 @@ checkValue = function(t, value, path, patchMode, errors, top)
-- unknown keys are preserved unless they read as a typo of a known
-- field. Nested recs stay strict, that is where typos hide.
local hint = suggest(t.fields, key)
if hint or not top then
if hint or not top or t.strict then
errors[#errors + 1] = ("%s.%s: unknown field%s"):format(path, tostring(key),
hint and (' (did you mean "' .. hint .. '"?)') or "")
end
@@ -1077,9 +1081,17 @@ R.font = {
advance = f.opt(f.int(1)),
charmap = f.opt(f.list(f.rec{ code = f.int(0), seq = f.str })) },
f.rec{ seq = f.str, code = f.int(0) },
f.rec{ file = f.opt(f.path), size = f.opt(f.int(1)),
spacing = f.opt(f.num), yOffset = f.opt(f.num),
bold = f.opt(f.bool) },
-- strict: every field here is optional ({} is a legal "ttf" entry), so
-- with the usual top-level leniency this alternative would match ANY
-- table and let malformed pages through the union unchecked
f.rec({ file = f.opt(f.path), size = f.opt(f.int(1)),
spacing = f.opt(f.num), yOffset = f.opt(f.num),
bold = f.opt(f.bool),
-- characters that keep their ROM tile instead of coming from the
-- TTF: a string of them, or a list when a multi-character charmap
-- sequence is meant (src/render/Font.lua)
tiles = f.opt(f.union{ f.str, f.list(f.str) }) },
{ strict = true }),
},
extra = function(id, value)
if fontIsCharmap(id) then
@@ -1092,7 +1104,7 @@ R.font = {
elseif fontIsTtf(id) then
-- every field optional: {} is "the bundled font at its native size"
if value.image ~= nil or value.base ~= nil then
return 'the "ttf" entry takes file/size/spacing/yOffset/bold, not a page'
return 'the "ttf" entry takes file/size/spacing/yOffset/bold/tiles, not a page'
end
elseif value.image == nil or value.base == nil then
return "a font page needs an image and a base"
+50 -2
View File
@@ -55,6 +55,34 @@ local function pagesOf(def)
return pages
end
-- ttf.tiles as a lookup keyed by charmap sequence. Accepts a plain string
-- ("0123456789"), which is split into UTF-8 characters, or a list of
-- sequences ({ "0", "1", "<PK>" }) when a multi-character macro is meant.
local function tileSet(spec)
local set = {}
if type(spec) == "table" then
for _, seq in ipairs(spec) do set[tostring(seq)] = true end
elseif type(spec) == "string" then
-- split on UTF-8 lead bytes rather than utf8Decode, which this file
-- declares further down and would be nil here
local i, n = 1, #spec
while i <= n do
local last = i
if spec:byte(i) >= 0xC0 then
local k = i + 1
while k <= n do
local b = spec:byte(k)
if b < 0x80 or b > 0xBF then break end
last, k = k, k + 1
end
end
set[spec:sub(i, last)] = true
i = last + 1
end
end
return set
end
function Font.load(data)
loadedFrom = data
local def = data.font
@@ -128,6 +156,15 @@ function Font.load(data)
-- caps line up and descenders hang below as the GB font's own do
yOffset = def.ttf.yOffset or (obj.getBaseline
and (GLYPH - 1 - obj:getBaseline()) or (GLYPH - obj:getHeight())),
-- Single characters that keep their ROM tile instead of coming from
-- the TTF. A CJK translation sizes the font so a kana fills the 8px
-- cell, which leaves Latin narrower than the tile font it replaces:
-- the numbers in a right-aligned column (the party menu's ":L12" over
-- "34/ 34") then no longer land where the 8px-per-character layout put
-- them. Naming "0123456789" here keeps digits on the vanilla tiles --
-- identical to the English build -- while kana still come from the
-- font. Sequence keys, so "é" or a "<PK>" macro can be listed too.
tiles = tileSet(def.ttf.tiles),
widths = {}, chars = {},
}
else
@@ -227,9 +264,10 @@ function Font.split(text)
for _, entry in ipairs(candidates) do
local len = #entry.seq
if text:sub(i, i + len - 1) == entry.seq then
if ttf then
if ttf and not ttf.tiles[entry.seq] then
-- single characters belong to the TTF; only multi-character
-- sequences (ligatures, <PK> macros) keep their tile mapping
-- sequences (ligatures, <PK> macros) keep their tile mapping,
-- plus anything the mod named in ttf.tiles (see Font.load)
local cp, last = utf8Decode(entry.seq, 1)
if cp and last == len then break end
end
@@ -367,8 +405,18 @@ for key, code in pairs(Font.DEFAULT_BORDER) do Font.BORDER[key] = code end
-- Draw a Game Boy style bordered box in tile coordinates.
function Font.drawBox(tx, ty, tw, th)
-- The white interior is a fill, so it needs the color; everything after it
-- is a glyph and needs the caller's. Restoring is not cosmetic: the tile
-- pages are black glyphs on transparent, so they come out black whatever
-- the color is, and leaking white here was invisible for as long as every
-- glyph was a tile. TTF text is not immune -- it draws in the current
-- color -- so a leaked white left every label printed after a box white on
-- white. On the summary screen that erased ATTACK/DEFENSE/SPEED/SPECIAL
-- and TYPE1/TYPE2 while the numbers beside them, still tiles, stayed put.
local r, g, b, a = love.graphics.getColor()
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", tx * 8, ty * 8, tw * 8, th * 8)
love.graphics.setColor(r, g, b, a)
local B = Font.BORDER
Font.drawCode(B.tl, tx * 8, ty * 8)
Font.drawCode(B.tr, (tx + tw - 1) * 8, ty * 8)
+35 -13
View File
@@ -564,16 +564,24 @@ function Renderer:drawTiltedWorld(zoneList, sx, sy, wox, woy, target)
return true
end
-- LÖVE 11 truncates scissor arguments to framebuffer pixels; the half-pixel
-- bias keeps values divided back through a fractional DPI scale from landing
-- one short. LÖVE 12 passes fractional arguments through and rounds in the
-- graphics backend instead, where that bias shifts each origin by one pixel
-- and extends its far edge by two (#673).
local SCISSOR_PIXEL_BIAS = 0.5
if love and love.getVersion and select(1, love.getVersion()) >= 12 then
SCISSOR_PIXEL_BIAS = 0
end
-- Clamp a scissor rect to the viewport box, then round it outward to whole
-- framebuffer pixels. love.graphics.setScissor truncates x, y, w and h to
-- pixels independently, so a rect with fractional unit edges (Android's
-- non-integer DPI puts fitScale/dpi in Sx/Sy) loses up to a pixel per side
-- and two adjacent SGB zones stop sharing an edge: the letterbox clear shows
-- through as a horizontal seam at every zone boundary (#373). Rounding
-- outward makes neighbours overlap by at most one row instead -- the overlap
-- redraws the same canvas pixels one palette later, and past the canvas edge
-- there is nothing to draw. The half pixel keeps LOVE's truncation on the
-- snapped edge rather than one short of it.
-- framebuffer pixels. On LÖVE 11, x, y, w and h are truncated independently,
-- so a rect with fractional unit edges (Android's non-integer DPI puts
-- fitScale/dpi in Sx/Sy) loses up to a pixel per side and two adjacent SGB
-- zones stop sharing an edge: the letterbox clear shows through as a seam at
-- every zone boundary (#373). Rounding outward makes neighbours overlap by
-- at most one row instead; SCISSOR_PIXEL_BIAS preserves that result across the
-- LÖVE 11 and 12 conversion rules.
local function scissorClamped(x, y, w, h, ox, oy, vpw, vph, dpiX, dpiY)
local x2, y2 = math.min(x + w, ox + vpw), math.min(y + h, oy + vph)
x, y = math.max(x, ox), math.max(y, oy)
@@ -581,9 +589,10 @@ local function scissorClamped(x, y, w, h, ox, oy, vpw, vph, dpiX, dpiY)
dpiX, dpiY = dpiX or 1, dpiY or 1
local px1, py1 = math.floor(x * dpiX), math.floor(y * dpiY)
local px2, py2 = math.ceil(x2 * dpiX), math.ceil(y2 * dpiY)
love.graphics.setScissor((px1 + 0.5) / dpiX, (py1 + 0.5) / dpiY,
(px2 - px1 + 0.5) / dpiX,
(py2 - py1 + 0.5) / dpiY)
local b = SCISSOR_PIXEL_BIAS
love.graphics.setScissor((px1 + b) / dpiX, (py1 + b) / dpiY,
(px2 - px1 + b) / dpiX,
(py2 - py1 + b) / dpiY)
return true
end
@@ -941,9 +950,22 @@ function Renderer:endFrame(zones, worldZones)
-- reads as the foreground instead of competing with a fully lit map. Goes
-- here rather than in the letterbox clear because with the world pass
-- active there is no clear -- the world already covers the surface.
--
-- The veil covers the voids ONLY, never the battle's own letterbox: "world"
-- changes what surrounds the battle and leaves the battle screen alone
-- (BattleState:bgMode). On hardware there is no "behind the battle" to dim
-- at all -- _InitBattleCommon calls ClearScreen (pokered home/copy2.asm),
-- which blanks the whole tilemap before the battle draws. A whole-surface
-- fill was invisible only because the classic battle paints an opaque paper
-- field over it a few lines below; a render pipeline that stages the fight
-- on the map and keys that field out got the veil straight onto its
-- sprites, HP bars and HUD, which is the 55% whole-window dim of #777
-- (and its duplicate #772).
if self.battleDim and self.battleDim > 0 then
love.graphics.setColor(0, 0, 0, self.battleDim)
love.graphics.rectangle("fill", 0, 0, ww, wh)
for _, r in ipairs(subtractRect({ { 0, 0, ww, wh } }, uox, uoy, uvpw, uvph)) do
love.graphics.rectangle("fill", r[1], r[2], r[3], r[4])
end
love.graphics.setColor(1, 1, 1, 1)
end
+14 -4
View File
@@ -59,7 +59,13 @@ local function styleOf(game, id)
return record or Transition.STYLES[id]
end
function Transition.new(game, onMidpoint, onDone)
-- `warp` marks the map-change fade: PlayMapChangeSound's GBFadeOutToBlack
-- has no matching fade in (LoadGBPal restores the palettes in one write), so
-- warps land with framesIn 0. Script fades that bracket a HideObject
-- (ViridianGym.asm .afterBeat, RocketHideoutB4F BeatGiovanniScript) call
-- GBFadeOutToBlack -> GBFadeInFromBlack instead, so the default keeps the
-- symmetric 32-frame fade back in (home/fade.asm:21, b = 4).
function Transition.new(game, onMidpoint, onDone, warp)
local self = setmetatable({}, Transition)
self.game = game
self.onMidpoint = onMidpoint
@@ -68,9 +74,13 @@ function Transition.new(game, onMidpoint, onDone)
self.phase = "out"
local style = styleOf(game, "warp_fade")
self.frames = style.frames or FRAMES
-- a style may still ask for a fade in (mods, and the record is data-driven);
-- the built-in warp is 0, matching hardware
self.framesIn = style.framesIn or FRAMES_IN
if warp then
-- a style may still ask for a fade in (mods, and the record is
-- data-driven); the built-in warp is 0, matching hardware
self.framesIn = style.framesIn or FRAMES_IN
else
self.framesIn = Timing.FADE_IN_FROM_BLACK
end
return self
end
+62
View File
@@ -0,0 +1,62 @@
-- Per-glyph fallback for the launcher's UI faces.
--
-- The launcher draws with LÖVE's default face, which covers Latin and little
-- else. That was invisible while the launcher was English-only, but its text
-- now goes through Strings (#767/#791) and a translation mod's catalog is
-- loaded before the first launcher frame (LauncherMods.translationStrings), so
-- the moment one is Japanese every kana lands as a tofu box.
--
-- Font:setFallbacks fills ONLY the codepoints the primary face is missing, so
-- Latin keeps the launcher's own look and nothing about an English install
-- changes; only the glyphs it genuinely cannot draw come from the bundled
-- Plain Pixel (assets/fonts/plainpixel/README.md: CC-BY 4.0, Douglas
-- Vautour), which covers kana and CJK. That is the same face the in-game TTF
-- text mode uses, so a translated launcher and a translated game agree.
--
-- Measuring and rendering must attach the same fallback or the launcher
-- measures a width it does not draw -- which is how buttons clip.
local UiFont = {}
local FALLBACK_PATH = "assets/fonts/plainpixel/PlainPixel-Regular.ttf"
-- Plain Pixel only rasterizes evenly at multiples of its 15px design em, so
-- snap rather than matching the primary size exactly: a fallback glyph a pixel
-- off its grid is far more obvious than one a pixel off its neighbours.
local DESIGN_EM = 15
local cache = {}
local unavailable = false
local function fallbackFor(size)
if unavailable then return nil end
local snapped = math.max(DESIGN_EM,
math.floor(size / DESIGN_EM + 0.5) * DESIGN_EM)
local hit = cache[snapped]
if hit ~= nil then return hit or nil end
local ok, font = pcall(love.graphics.newFont, FALLBACK_PATH, snapped)
if not ok or not font then
-- one failure means the file is absent (a trimmed build); stop retrying
unavailable = true
return nil
end
if font.setFilter then pcall(font.setFilter, font, "nearest", "nearest") end
cache[snapped] = font
return font
end
-- attach(font, size) -> font. Safe to call on every cache miss; a font whose
-- fallback is already set is left alone, and any failure is swallowed so a
-- missing fallback file can never take the launcher down with it.
function UiFont.attach(font, size)
if not (font and font.setFallbacks) then return font end
local fallback = fallbackFor(size or (font.getHeight and font:getHeight()) or DESIGN_EM)
if not fallback or rawequal(fallback, font) then return font end
pcall(font.setFallbacks, font, fallback)
return font
end
function UiFont.clear()
cache, unavailable = {}, false
end
return UiFont
+21 -6
View File
@@ -9,8 +9,9 @@
-- CRED_TEXT_MON text appears at once, hold 110, mon wipe
-- CRED_TEXT_FADE fade in, hold 120, next screen replaces the text
-- CRED_TEXT text appears at once, hold 140
-- The mon wipe is DisplayCreditsMon: the middle band scrolls left 8px per
-- frame for 27 frames (ScrollCreditsMonLeft x7 then x20) while the next
-- The mon wipe is DisplayCreditsMon: three CreditsCopyTileMapToVRAM copies
-- (9 frames of Delay3, text still up), then the middle band scrolls left 8px
-- per frame for 27 frames (ScrollCreditsMonLeft x7 then x20) while the next
-- CreditsMons entry crosses right-to-left as a black silhouette
-- (BGP %11111100), leaving the band blank; BGP is left at %11000000, which
-- is why every post-wipe screen is a FADE variant. CRED_COPYRIGHT
@@ -52,6 +53,14 @@ local HOLD_FADE = 120
local HOLD_TEXT = 140
local WIPE_FRAMES = 27 -- ScrollCreditsMonLeft: 7 + 20 calls, 8px/frame
-- DisplayCreditsMon runs three CreditsCopyTileMapToVRAM calls (vBGMap0+$c,
-- vBGMap0, vBGMap1) before the first scroll, and each one ends in `jp Delay3`
-- (home/palettes.asm), so the credits text sits still for 9 more frames on
-- every mon screen. Dropping them ran the 15 mon screens 135 frames short and
-- brought THE END up 2.2s early against a credits theme whose length is fixed
-- by the ROM program (Music_Credits is 5880 frames and does not loop), which
-- is what made the song look like it overran the roll (#703).
local MON_PREP_FRAMES = 9
-- LoadCopyrightTiles (engine/movie/title.asm CopyrightTextString): tile
-- sequences into the extracted title/copyright.png strip (tiles $60-$72:
@@ -204,12 +213,17 @@ function Credits:update(dt)
self.timer = self.screen.mon and HOLD_FADE_MON or HOLD_FADE
elseif self.phase == "hold" then
if self.screen.mon then
self.phase = "wipe"
self.timer = WIPE_FRAMES
self.monImg, self.monTint = self:monSprite(self.screen.mon)
-- the text stays up through DisplayCreditsMon's VRAM copies; the
-- silhouette only starts moving once ScrollCreditsMonLeft does
self.phase = "mon_prep"
self.timer = MON_PREP_FRAMES
else
self:nextScreen()
end
elseif self.phase == "mon_prep" then
self.phase = "wipe"
self.timer = WIPE_FRAMES
self.monImg, self.monTint = self:monSprite(self.screen.mon)
elseif self.phase == "wipe" then
self.monImg = nil
self:nextScreen()
@@ -313,7 +327,8 @@ function Credits:draw()
love.graphics.rectangle("fill", 0, 0, 160, 32)
love.graphics.rectangle("fill", 0, 112, 160, 32)
love.graphics.setColor(1, 1, 1, 1)
if self.phase == "fade" or self.phase == "hold" then
if self.phase == "fade" or self.phase == "hold"
or self.phase == "mon_prep" then
self:drawPage(self.screen, 0, self.shade)
elseif self.phase == "wipe" then
-- ScrollCreditsMonLeft: the middle band scrolls left 8px/frame while
+36 -8
View File
@@ -155,7 +155,11 @@ local function obpIcon(path)
return love.graphics.newImage(id)
end
local function drawIcon(game, mon, x, y, selected, counter)
-- `forceAlt` picks the second animation frame outright, for callers with no
-- selection cursor of their own: Trade_AnimCircledMon
-- (engine/movie/trade.asm) cycles the party sprite's two frames the whole
-- time the mon rides the link cable (#750).
function PartyMenu.drawIcon(game, mon, x, y, selected, counter, forceAlt)
local icons = game.data.icons
if not icons then return end
local def = game.data.pokemon[mon.species]
@@ -202,7 +206,7 @@ local function drawIcon(game, mon, x, y, selected, counter)
end
local img = iconImages[key]
if not img then return end
local alt = false
local alt = forceAlt or false
if selected then
local px = math.floor(mon.hp * 48 / math.max(1, mon.stats.hp))
local speed = px >= 27 and 5 or px >= 10 and 16 or 32
@@ -237,13 +241,24 @@ local function drawIcon(game, mon, x, y, selected, counter)
-- whatever size the file is (unchanged path)
love.graphics.draw(img, x, y)
end
return true
end
function PartyMenu.new(game, opts)
opts = opts or {}
local self = setmetatable({}, PartyMenu)
self.game = game
self.index = 1
-- PartyMenuInit (home/pokemon.asm) seeds the cursor from
-- wPartyAndBillsPCSavedMenuItem rather than from zero, and
-- HandlePartyMenuInput writes wCurrentMenuItem back into it on every
-- input, so the party cursor survives closing and reopening the menu.
-- Only a battle clears it -- InitBattleVariables and end_of_battle.asm
-- both zero the byte, which BattleState mirrors. The clamp covers a
-- party that shrank (deposit / release) while the saved index was
-- pointing past the end. #768
local count = #(opts.party or (game.save and game.save.party) or {})
self.index = math.min(math.max(1, game.partyMenuSavedIndex or 1),
math.max(1, count))
self.onSwitch = opts.onSwitch
self.onCancel = opts.onCancel
self.pickOnly = opts.pickOnly
@@ -518,8 +533,10 @@ function PartyMenu:update(dt)
if input:wasPressed("up") then
self.index = self.index > 1 and self.index - 1 or math.max(1, #party)
self.game.partyMenuSavedIndex = self.index -- HandlePartyMenuInput #768
elseif input:wasPressed("down") then
self.index = self.index < #party and self.index + 1 or 1
self.game.partyMenuSavedIndex = self.index -- HandlePartyMenuInput #768
elseif input:wasPressed("b") then
self.game.stack:pop()
if self.onCancel then self.onCancel() end
@@ -567,10 +584,17 @@ function PartyMenu:update(dt)
{ label = Strings("STATS"), action = "stats" },
{ label = Strings("CANCEL"), action = "cancel" } }
else
-- STATS/SWITCH plus this mon's field moves (start_sub_menus.asm
-- builds the same dynamic list)
items = { { label = Strings("STATS"), action = "stats" },
{ label = Strings("SWITCH"), action = "switch" } }
-- This mon's field moves FIRST, then STATS/SWITCH
-- (start_sub_menus.asm builds the same dynamic list). The order is
-- load bearing: DisplayFieldMoveMonMenu (engine/menus/text_box.asm)
-- grows the box upward one row per field move and prints the field
-- move names ABOVE PokemonMenuEntries ("STATS/SWITCH/CANCEL"), and
-- StartMenu_Pokemon .choseOutOfBattleMove indexes wFieldMoves with
-- menu items 0..n-1 while STATS/SWITCH sit at the bottom of the
-- list. GetMonFieldMoves walks wPartyMon1Moves in slot order, so
-- the field moves keep the mon's move-list order -- which the loop
-- below already does. #768
items = {}
-- Field moves (HMs/TMs) are usable out of battle even when the mon
-- is fainted -- Gen 1 does not require HP for Cut/Fly/Surf/etc.
-- Battle still excludes this list via `not self.battle`. Softboiled
@@ -615,6 +639,10 @@ function PartyMenu:update(dt)
end
end
end
-- PokemonMenuEntries always closes the list, under the field moves
-- (text_box.asm .donePrintingNames). #768
items[#items + 1] = { label = Strings("STATS"), action = "stats" }
items[#items + 1] = { label = Strings("SWITCH"), action = "switch" }
end
local ctx = { battle = self.battle, overworld = ow }
local hooked = Runtime.call("ui.party.submenu", sameItems,
@@ -685,7 +713,7 @@ function PartyMenu:draw()
local def = self.game.data.pokemon[mon.species]
local y = PartyMenu.entryY(i)
love.graphics.setColor(1, 1, 1, 1)
drawIcon(self.game, mon, 8, y, i == self.index, self.blink or 0)
PartyMenu.drawIcon(self.game, mon, 8, y, i == self.index, self.blink or 0)
love.graphics.setColor(0, 0, 0, 1)
Font.draw(mon.nickname or def.name, 24, y)
-- level at column 13 (<LV> tile + digits, PrintLevel) AND the
+15 -5
View File
@@ -66,14 +66,24 @@ function StartMenu.new(game)
panel .. Strings("\fWould you like to\nSAVE the game?"), nil, {
choice = function(yes)
if not yes then return end
-- "Now saving..." beat before the write (save.asm
-- NowSavingString), then GameSavedText + SFX_SAVE
-- SaveMenu .save (engine/menus/save.asm:164-181): "Now saving..."
-- is a bare PlaceString held by DelayFrames 120, then GameSavedText,
-- which ends in `done` and so never reaches TX_PROMPT_BUTTON.
-- Neither page takes a button press (#765); the second waits on
-- SFX_SAVE (PlaySoundWaitForCurrent + WaitForSoundToFinish) and then
-- DelayFrames 30. The write itself is invisible either side of the
-- "Now saving..." hold, so it stays on that box's onDone.
game.stack:push(TextBox.new(game, Strings("Now saving..."), function()
game:writeSave()
require("src.core.Sound").play(game.data, "Save")
game.stack:push(TextBox.new(game,
Strings("%s saved\nthe game!", game.save.player.name or "RED")))
end))
Strings("%s saved\nthe game!", game.save.player.name or "RED"),
nil, { auto = {
sound = function()
return require("src.core.Sound").play(game.data, "Save")
end,
delay = 30,
} }))
end, { auto = { delay = 120 } }))
end,
}))
end })
+281 -102
View File
@@ -3,14 +3,21 @@
-- spin in the air and land flat for points; a crooked landing wipes out
-- and ends the run. The scene is built from the real ROM sheets
-- (gfx/surfing_pikachu.asm, ripped at import to
-- assets/generated/minigame/surf_1a/1b.png): the scalloped water tiles,
-- the beach with the palm and the doll hut, the "HP:" score strip with
-- the sheet digits, the cloud, and the OAM Pikachu poses -- the air
-- tricks quantize to the sheet's rotation frames like the original's
-- sprite anims, instead of free-rotating one pose. The original drew
-- the big wave with per-scanline scroll tricks (wLYOverrides); here the
-- crest profile is a curve filled with the sheet's foam/shade tiles.
-- Score model keeps the original's shape (ride ticks + airtime + full
-- assets/generated/minigame/surf_1a/1b.png).
--
-- #726: the background is the original's own metatile scroller, not a
-- procedural stand-in. SurfingPikachu1Graphics1 is copied to vChars2
-- with LCDC's BG char base unset, so BG tile id N is simply tile N of
-- surf_1a (5 tiles per row). SurfingMinigame_ScrollAndGenerateBGMap
-- walks a jumptable of wave states, each of which hands back one
-- 8-metatile column (2x2 tiles each, so 16px wide by the 128px the BG
-- shows above the HP window) plus the two Pikachu ride heights for that
-- column. Porting those tables verbatim is what makes the water read as
-- water: the earlier stand-in tiled the wave-face tiles ($02/$07) over
-- the whole sea and drew the swell as a LOVE ellipse, which is the
-- "messed up graphics" in the report.
--
-- Score model keeps the port's shape (ride ticks + airtime + full
-- rotations); high score persists in save.surfingHighScore for the
-- beach-house printer.
@@ -23,10 +30,13 @@ local SurfingMinigame = {}
SurfingMinigame.__index = SurfingMinigame
SurfingMinigame.isOpaque = true
local PIKA_X = 44 -- fixed screen x while riding
local RUN_DISTANCE = 3200 -- scroll px from paddle-out to the beach
-- SURFING_MINIGAME_CENTER_X/FLAT_WATER_Y (surfing_pikachu.asm:1-2) are OAM
-- coordinates; screen x/y are those minus OAM_X_OFS/OAM_Y_OFS.
local FLAT_WATER_Y = 116
local PIKA_X = 68 -- fixed screen x while riding (center 80, 24px pose)
local RUN_DISTANCE = 3072 -- 24 sections of 8 metatile columns
local GRAVITY = 0.14
local HORIZON = 24 -- sea starts under the sky strip
local BG_HEIGHT = 128 -- rows the BG shows; the HP window covers the rest
-- surf_1b quads: {x, y, w, h} in sheet pixels (pose pitch is 24x24)
local B = {
@@ -50,18 +60,158 @@ local POSES = {
[315] = { 0, 0, 24, 24 }, -- tail down
}
-- surf_1a quads (BG tiles)
local A = {
scallop = { 16, 0, 8, 8 }, -- open-water pattern, row A
scallop2 = { 16, 8, 8, 8 }, -- row B variant
shade = { 8, 16, 8, 8 }, -- gray dither, wave belly
lip = { 24, 0, 8, 8 }, -- foam curl for the crest edge
palm = { 8, 32, 8, 8 }, -- palm fronds
beach = { 24, 32, 16, 8 }, -- black shore silhouette
hut = { 8, 40, 16, 8 }, -- the Pikachu doll hut on the sand
hp = { 20, 40, 20, 8 }, -- "HP:" score label
-- surf_1a is 5 tiles wide, so BG tile id N lives at (N%5*8, N/5*8). The
-- only quad the scene needs by hand is the window's "HP:" label, which
-- straddles a tile boundary in the sheet.
local HP_LABEL = { 20, 40, 20, 8 }
-- SurfingMinigame_BGMetatileTable (surfing_pikachu.asm): 2x2 tiles each,
-- stored top-left, top-right, bottom-left, bottom-right.
local BG_METATILES = {
[0x00] = { 0x00, 0x00, 0x00, 0x00 }, -- sky block (blank)
[0x01] = { 0x0b, 0x0b, 0x0b, 0x0b }, -- open water
[0x02] = { 0x0b, 0x02, 0x02, 0x06 },
[0x03] = { 0x03, 0x0b, 0x07, 0x03 },
[0x04] = { 0x06, 0x06, 0x06, 0x06 },
[0x05] = { 0x07, 0x07, 0x07, 0x07 },
[0x06] = { 0x06, 0x04, 0x04, 0x08 },
[0x07] = { 0x05, 0x07, 0x08, 0x05 },
[0x08] = { 0x0b, 0x0b, 0x11, 0x12 },
[0x09] = { 0x0b, 0x0b, 0x13, 0x03 },
[0x0a] = { 0x14, 0x12, 0x04, 0x08 },
[0x0b] = { 0x13, 0x07, 0x08, 0x05 },
[0x0c] = { 0x06, 0x14, 0x06, 0x14 }, -- unused, identical to 11
[0x0d] = { 0x13, 0x07, 0x13, 0x07 },
[0x0e] = { 0x08, 0x08, 0x08, 0x08 }, -- solid blue
[0x0f] = { 0x14, 0x12, 0x14, 0x12 },
[0x10] = { 0x0b, 0x11, 0x02, 0x14 },
[0x11] = { 0x06, 0x14, 0x06, 0x14 },
[0x12] = { 0x0c, 0x0c, 0x0d, 0x0d }, -- beach top block
[0x13] = { 0x0d, 0x0d, 0x0d, 0x0d }, -- beach sand block
[0x14] = { 0x0e, 0x0f, 0x10, 0x0b }, -- beach shore block
[0x15] = { 0x12, 0x13, 0x12, 0x13 },
}
-- SurfingMinigameWavePattern00..1C plus SurfingMinigameBeachPattern: one
-- column of 8 metatiles, top to bottom.
local WAVE_PATTERNS = {
[0x00] = { 0x00, 0x00, 0x00, 0x01, 0x01, 0x01, 0x01, 0x01 },
[0x01] = { 0x00, 0x00, 0x00, 0x01, 0x01, 0x02, 0x04, 0x06 },
[0x02] = { 0x00, 0x00, 0x00, 0x01, 0x02, 0x04, 0x06, 0x0e },
[0x03] = { 0x00, 0x00, 0x00, 0x10, 0x11, 0x06, 0x0e, 0x0e },
[0x04] = { 0x00, 0x00, 0x00, 0x15, 0x15, 0x0e, 0x0e, 0x0e },
[0x05] = { 0x00, 0x00, 0x00, 0x03, 0x05, 0x07, 0x0e, 0x0e },
[0x06] = { 0x00, 0x00, 0x00, 0x01, 0x03, 0x05, 0x07, 0x0e },
[0x07] = { 0x00, 0x00, 0x00, 0x01, 0x01, 0x03, 0x05, 0x07 },
[0x08] = { 0x00, 0x00, 0x00, 0x01, 0x01, 0x02, 0x04, 0x06 },
[0x09] = { 0x00, 0x00, 0x00, 0x01, 0x02, 0x04, 0x06, 0x0e },
[0x0a] = { 0x00, 0x00, 0x00, 0x08, 0x0f, 0x0a, 0x0e, 0x0e },
[0x0b] = { 0x00, 0x00, 0x00, 0x09, 0x0d, 0x0b, 0x0e, 0x0e },
[0x0c] = { 0x00, 0x00, 0x00, 0x01, 0x03, 0x05, 0x07, 0x0e },
[0x0d] = { 0x00, 0x00, 0x00, 0x01, 0x01, 0x03, 0x05, 0x07 },
[0x0e] = { 0x00, 0x00, 0x00, 0x01, 0x01, 0x02, 0x04, 0x06 },
[0x0f] = { 0x00, 0x00, 0x00, 0x01, 0x10, 0x11, 0x06, 0x0e },
[0x10] = { 0x00, 0x00, 0x00, 0x01, 0x15, 0x15, 0x0e, 0x0e },
[0x11] = { 0x00, 0x00, 0x00, 0x01, 0x03, 0x05, 0x07, 0x0e },
[0x12] = { 0x00, 0x00, 0x00, 0x01, 0x01, 0x03, 0x05, 0x07 },
[0x13] = { 0x00, 0x00, 0x00, 0x01, 0x01, 0x02, 0x04, 0x06 },
[0x14] = { 0x00, 0x00, 0x00, 0x01, 0x08, 0x0f, 0x0a, 0x0e },
[0x15] = { 0x00, 0x00, 0x00, 0x01, 0x09, 0x0d, 0x0b, 0x0e },
[0x16] = { 0x00, 0x00, 0x00, 0x01, 0x01, 0x03, 0x05, 0x07 },
[0x17] = { 0x00, 0x00, 0x00, 0x01, 0x01, 0x10, 0x11, 0x06 },
[0x18] = { 0x00, 0x00, 0x00, 0x01, 0x01, 0x15, 0x15, 0x0e },
[0x19] = { 0x00, 0x00, 0x00, 0x01, 0x01, 0x03, 0x05, 0x07 },
[0x1a] = { 0x00, 0x00, 0x00, 0x01, 0x01, 0x08, 0x0f, 0x0a },
[0x1b] = { 0x00, 0x00, 0x00, 0x01, 0x01, 0x09, 0x0d, 0x0b },
[0x1c] = { 0x00, 0x00, 0x00, 0x14, 0x14, 0x14, 0x14, 0x14 },
beach = { 0x00, 0x00, 0x00, 0x12, 0x13, 0x13, 0x13, 0x13 },
}
-- RunSurfingMinigameRoutine's .WaveFunctions jumptable, flattened:
-- { pattern, left ride height, right ride height, what to do next }.
-- next: 0 = advance one state, 1 = reset to the chooser, 2 = stay put.
-- State 0 is SurfingMinigame_ChooseNextWaveSequence and is handled in
-- code because it rolls Random and forces the Big Kahuna near the goal.
local ADV, RESET, STAY = 0, 1, 2
local WAVE_STEPS = {
[0x01] = { 0x13, 116, 108, ADV }, [0x02] = { 0x14, 100, 92, ADV },
[0x03] = { 0x15, 92, 92, ADV }, [0x04] = { 0x16, 100, 108, ADV },
[0x05] = { 0x00, 116, 116, ADV }, [0x06] = { 0x17, 116, 108, ADV },
[0x07] = { 0x18, 100, 100, ADV }, [0x08] = { 0x19, 100, 108, ADV },
[0x09] = { 0x00, 116, 116, ADV }, [0x0a] = { 0x00, 116, 116, ADV },
[0x0b] = { 0x00, 116, 116, ADV }, [0x0c] = { 0x00, 116, 116, ADV },
[0x0d] = { 0x00, 116, 116, RESET },
[0x0e] = { 0x08, 116, 108, ADV }, [0x0f] = { 0x09, 100, 92, ADV },
[0x10] = { 0x0a, 84, 76, ADV }, [0x11] = { 0x0b, 76, 76, ADV },
[0x12] = { 0x0c, 84, 92, ADV }, [0x13] = { 0x0d, 100, 108, ADV },
[0x14] = { 0x00, 116, 116, ADV }, [0x15] = { 0x00, 116, 116, ADV },
[0x16] = { 0x00, 116, 116, ADV }, [0x17] = { 0x00, 116, 116, ADV },
[0x18] = { 0x00, 116, 116, ADV }, [0x19] = { 0x00, 116, 116, RESET },
[0x1a] = { 0x0e, 116, 108, ADV }, [0x1b] = { 0x0f, 100, 92, ADV },
[0x1c] = { 0x10, 84, 84, ADV }, [0x1d] = { 0x11, 84, 92, ADV },
[0x1e] = { 0x12, 100, 108, ADV }, [0x1f] = { 0x0e, 116, 108, ADV },
[0x20] = { 0x0f, 100, 92, ADV }, [0x21] = { 0x10, 84, 84, ADV },
[0x22] = { 0x11, 84, 92, ADV }, [0x23] = { 0x12, 100, 108, ADV },
[0x24] = { 0x00, 116, 116, ADV }, [0x25] = { 0x00, 116, 116, ADV },
[0x26] = { 0x00, 116, 116, ADV }, [0x27] = { 0x00, 116, 116, ADV },
[0x28] = { 0x00, 116, 116, RESET },
[0x29] = { 0x13, 116, 108, ADV }, [0x2a] = { 0x14, 100, 92, ADV },
[0x2b] = { 0x15, 92, 92, ADV }, [0x2c] = { 0x16, 100, 108, ADV },
[0x2d] = { 0x00, 116, 116, ADV }, [0x2e] = { 0x00, 116, 116, ADV },
[0x2f] = { 0x00, 116, 116, ADV }, [0x30] = { 0x00, 116, 116, ADV },
[0x31] = { 0x00, 116, 116, RESET },
[0x32] = { 0x17, 116, 108, ADV }, [0x33] = { 0x18, 100, 100, ADV },
[0x34] = { 0x19, 100, 108, ADV }, [0x35] = { 0x17, 116, 108, ADV },
[0x36] = { 0x18, 100, 100, ADV }, [0x37] = { 0x19, 100, 108, ADV },
[0x38] = { 0x17, 116, 108, ADV }, [0x39] = { 0x18, 100, 100, ADV },
[0x3a] = { 0x19, 100, 108, ADV }, [0x3b] = { 0x00, 116, 116, ADV },
[0x3c] = { 0x00, 116, 116, ADV }, [0x3d] = { 0x00, 116, 116, ADV },
[0x3e] = { 0x00, 116, 116, ADV }, [0x3f] = { 0x00, 116, 116, RESET },
[0x40] = { 0x1a, 116, 108, ADV }, [0x41] = { 0x1b, 108, 108, ADV },
[0x42] = { 0x0e, 116, 108, ADV }, [0x43] = { 0x0f, 100, 92, ADV },
[0x44] = { 0x10, 84, 84, ADV }, [0x45] = { 0x11, 84, 92, ADV },
[0x46] = { 0x12, 100, 108, ADV }, [0x47] = { 0x1a, 116, 108, ADV },
[0x48] = { 0x1b, 108, 108, ADV }, [0x49] = { 0x00, 116, 116, ADV },
[0x4a] = { 0x00, 116, 116, ADV }, [0x4b] = { 0x00, 116, 116, ADV },
[0x4c] = { 0x00, 116, 116, RESET },
[0x4d] = { 0x08, 116, 108, ADV }, [0x4e] = { 0x09, 100, 92, ADV },
[0x4f] = { 0x0a, 84, 76, ADV }, [0x50] = { 0x0b, 76, 76, ADV },
[0x51] = { 0x0c, 84, 92, ADV }, [0x52] = { 0x0d, 100, 108, ADV },
[0x53] = { 0x00, 116, 116, ADV }, [0x54] = { 0x1a, 116, 108, ADV },
[0x55] = { 0x1b, 108, 108, ADV }, [0x56] = { 0x1a, 116, 108, ADV },
[0x57] = { 0x1b, 108, 108, ADV }, [0x58] = { 0x00, 116, 116, ADV },
[0x59] = { 0x00, 116, 116, ADV }, [0x5a] = { 0x00, 116, 116, ADV },
[0x5b] = { 0x00, 116, 116, RESET },
[0x5c] = { 0x0e, 116, 108, ADV }, [0x5d] = { 0x0f, 100, 92, ADV },
[0x5e] = { 0x10, 84, 84, ADV }, [0x5f] = { 0x11, 84, 92, ADV },
[0x60] = { 0x12, 100, 108, ADV }, [0x61] = { 0x13, 116, 108, ADV },
[0x62] = { 0x14, 100, 92, ADV }, [0x63] = { 0x15, 92, 92, ADV },
[0x64] = { 0x16, 100, 108, ADV }, [0x65] = { 0x00, 116, 116, ADV },
[0x66] = { 0x00, 116, 116, ADV }, [0x67] = { 0x00, 116, 116, ADV },
[0x68] = { 0x00, 116, 116, ADV }, [0x69] = { 0x00, 116, 116, RESET },
-- 6a..71: the forced "Big Kahuna" finale; 71 holds flat water (its
-- loader just rets, so the state never advances on its own).
[0x6a] = { 0x01, 116, 108, ADV }, [0x6b] = { 0x02, 100, 92, ADV },
[0x6c] = { 0x03, 84, 76, ADV }, [0x6d] = { 0x04, 68, 68, ADV },
[0x6e] = { 0x05, 68, 76, ADV }, [0x6f] = { 0x06, 84, 92, ADV },
[0x70] = { 0x07, 100, 108, ADV }, [0x71] = { 0x00, 116, 116, STAY },
-- 72..7b: the run-out to the beach, entered by hand at the goal
-- (SurfingMinigame_WaitToShowResults writes $72).
[0x72] = { 0x00, 116, 116, ADV }, [0x73] = { 0x1c, 116, 116, ADV },
[0x74] = { "beach", 116, 116, ADV }, [0x75] = { "beach", 116, 116, ADV },
[0x76] = { "beach", 116, 116, ADV }, [0x77] = { "beach", 116, 116, ADV },
[0x78] = { "beach", 116, 116, ADV }, [0x79] = { "beach", 116, 116, ADV },
[0x7a] = { "beach", 116, 116, ADV }, [0x7b] = { "beach", 116, 116, RESET },
}
-- SurfingMinigame_WaveSequenceStarts
local SEQ_STARTS = { 0x01, 0x0e, 0x1a, 0x29, 0x32, 0x40, 0x4d, 0x5c }
-- the #726 table-integrity check in tests/drivers reads these; nothing
-- else should
SurfingMinigame.BG_METATILES = BG_METATILES
SurfingMinigame.WAVE_PATTERNS = WAVE_PATTERNS
SurfingMinigame.WAVE_STEPS = WAVE_STEPS
-- SGB-style zones: one sea palette over the frame plus a yellow
-- OBJ-flavored palette tracking Pikachu's tiles (rectangular attribute
-- blocks are all the SGB could do, bleed and all)
@@ -91,6 +241,17 @@ function SurfingMinigame.new(game, onDone)
self.resultShown = 0
self.banner = nil -- {quad, frames}: GOOD!/YEAH-/Oh no..
-- SurfingPikachuMinigame_LoadGFXAndLayout prefills the BG with flat
-- water and only starts generating $a0 pixels (ten metatile columns)
-- ahead of the viewport, so the run opens on calm sea.
self.waveFn = 0
self.cols = {}
for c = 0, 10 do
self.cols[c] = { pat = WAVE_PATTERNS[0x00],
hl = FLAT_WATER_Y, hr = FLAT_WATER_Y }
end
self.colTail = 10
local function sheet(path)
local ok, img = pcall(love.graphics.newImage, path)
return ok and img or nil
@@ -98,8 +259,12 @@ function SurfingMinigame.new(game, onDone)
self.bg = sheet("assets/generated/minigame/surf_1a.png")
self.ob = sheet("assets/generated/minigame/surf_1b.png")
if self.bg then
self.aq = {}
for k, spec in pairs(A) do self.aq[k] = newQuad(spec, self.bg) end
self.tq = {}
for n = 0, 64 do
self.tq[n] = love.graphics.newQuad((n % 5) * 8, math.floor(n / 5) * 8,
8, 8, self.bg:getDimensions())
end
self.hpq = newQuad(HP_LABEL, self.bg)
end
if self.ob then
self.bq = {}
@@ -122,11 +287,53 @@ function SurfingMinigame.new(game, onDone)
return self
end
-- crest height at screen x for the current scroll (two sines so the
-- wave rolls instead of looping visibly)
-- SurfingMinigame_ChooseNextWaveSequence: past section $16 the finale is
-- forced, otherwise a nonzero Random picks one of eight sequence starts.
-- Either way this column itself is flat water.
function SurfingMinigame:chooseSequence()
if math.floor(self.distance / 128) >= 0x16 then
self.waveFn = 0x6a
else
local r = math.random(0, 255)
if r ~= 0 then self.waveFn = SEQ_STARTS[((r - 1) % 8) + 1] end
end
return WAVE_PATTERNS[0x00], FLAT_WATER_Y, FLAT_WATER_Y
end
-- one 16px metatile column, appended on the right as the sea scrolls
function SurfingMinigame:pushColumn()
local pat, hl, hr
if self.waveFn == 0 then
pat, hl, hr = self:chooseSequence()
else
local step = WAVE_STEPS[self.waveFn]
if not step then
self.waveFn = 0
pat, hl, hr = WAVE_PATTERNS[0x00], FLAT_WATER_Y, FLAT_WATER_Y
else
pat, hl, hr = WAVE_PATTERNS[step[1]], step[2], step[3]
if step[4] == ADV then self.waveFn = self.waveFn + 1
elseif step[4] == RESET then self.waveFn = 0 end
end
end
self.colTail = self.colTail + 1
self.cols[self.colTail] = { pat = pat, hl = hl, hr = hr }
self.cols[self.colTail - 24] = nil -- columns behind the viewport
end
-- keep the generated columns covering the viewport plus the lookahead
function SurfingMinigame:generateAhead()
while self.colTail * 16 < self.distance + 176 do self:pushColumn() end
end
-- Pikachu's screen y for a screen x, from the per-tile-column ride
-- heights the wave states hand back (SurfingMinigame_SetPikachuHeight
-- samples the same array either side of the scroll's low bit).
function SurfingMinigame:seaY(x)
local s = self.distance + x
return 92 - 14 * math.sin(s / 26) - 6 * math.sin(s / 9.5)
local tile = math.floor((self.distance + x) / 8)
local col = self.cols[math.floor(tile / 2)]
if not col then return FLAT_WATER_Y - 16 end
return (tile % 2 == 0 and col.hl or col.hr) - 16
end
function SurfingMinigame:finishRun()
@@ -146,6 +353,13 @@ function SurfingMinigame:update()
if self.banner.frames <= 0 then self.banner = nil end
end
if self.phase == "results" then
-- the sea keeps sliding under the card for a beat so the beach
-- run-out (states $72..$7b) actually crosses the screen, like
-- SurfingMinigame_WaitToShowResults scrolling to the sand
if self.resultShown < 150 then
self.distance = self.distance + 1.2
self:generateAhead()
end
self.resultShown = self.resultShown + 1
if self.resultShown > 30
and (input:wasPressed("a") or input:wasPressed("b")) then
@@ -162,9 +376,13 @@ function SurfingMinigame:update()
-- the wave scrolls by the current speed; the beach ends the run
self.distance = self.distance + 0.8 + self.speed * 0.35
self:generateAhead()
if self.distance >= RUN_DISTANCE then
-- rode it all the way in: distance bonus like the original's goal
self.score = self.score + 500
-- SurfingMinigame_WaitToShowResults hands the generator state $72 so
-- the sand runs out under the coast-in
self.waveFn = 0x72
self:finishRun()
return
end
@@ -218,16 +436,11 @@ function SurfingMinigame:update()
end
end
-- draw one 8x8 sheet tile quad at x, y
function SurfingMinigame:tile(q, x, y)
love.graphics.draw(self.bg, self.aq[q], x, y)
end
function SurfingMinigame:sgbPalettes()
local P = require("src.render.PaletteFX")
local zones = { P.whole(SEA_PAL) }
if self.phase ~= "wipeout" and self.phase ~= "results" then
local tx = math.floor((PIKA_X - 12) / 8)
local tx = math.floor(PIKA_X / 8)
local ty = math.floor(math.max(0, self.pikaScreenY or 60) / 8)
zones[#zones + 1] = P.zone(PIKA_PAL, tx, ty, tx + 3, ty + 3)
end
@@ -242,87 +455,52 @@ function SurfingMinigame:drawScore(x, y, n)
end
end
-- the BG map: metatile columns from the wave generator, scrolled by
-- distance (SurfingMinigame_ScrollAndGenerateBGMap)
function SurfingMinigame:drawBackground()
local scx = math.floor(self.distance)
local first = math.floor(scx / 16)
for c = first, first + 10 do
local col = self.cols[c]
if col then
local x = c * 16 - scx
for i = 1, 8 do
local mt = BG_METATILES[col.pat[i]]
if mt then
local y = (i - 1) * 16
love.graphics.draw(self.bg, self.tq[mt[1]], x, y)
love.graphics.draw(self.bg, self.tq[mt[2]], x + 8, y)
love.graphics.draw(self.bg, self.tq[mt[3]], x, y + 8)
love.graphics.draw(self.bg, self.tq[mt[4]], x + 8, y + 8)
end
end
end
end
end
function SurfingMinigame:draw()
local haveSheets = self.bg and self.ob
-- sky
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 0, 0, 160, 144)
if not haveSheets then
-- cache predates the surf sheets: plain shapes keep it playable
love.graphics.setColor(0, 0, 0, 1)
Font.draw(Strings("SCORE %d", self.score), 4, 4)
love.graphics.rectangle("fill", PIKA_X - 8,
self:seaY(PIKA_X) - 16 - self.y, 16, 16)
love.graphics.rectangle("fill", PIKA_X, self:seaY(PIKA_X) - self.y, 16, 16)
love.graphics.setColor(1, 1, 1, 1)
return
end
self:drawBackground()
-- cloud in the sky strip
love.graphics.draw(self.ob, self.bq.cloud, 112, 8)
-- open water: the scalloped pattern tiles the whole sea, phase-locked
-- to the scroll so the surface slides
local shift = math.floor(self.distance) % 8
for ty = HORIZON, 136, 8 do
local alt = (ty / 8) % 2 == 0
for tx = -8, 160, 8 do
self:tile(alt and "scallop" or "scallop2", tx - shift, ty)
end
end
-- the wave face: a white patch hugging the ride line (the original
-- carved it with per-scanline scroll; the ellipse stands in), with a
-- few scallops floating inside and the foam lip along its upper edge
local faceY = self:seaY(56) + 10
love.graphics.setColor(1, 1, 1, 1)
love.graphics.ellipse("fill", 56, faceY, 46, 30)
love.graphics.ellipse("fill", 100, faceY + 16, 40, 22)
for _, spot in ipairs({ { 30, 8 }, { 70, 16 }, { 48, 22 } }) do
self:tile("scallop", 56 - 46 + spot[1] - shift, faceY - 24 + spot[2])
end
local pikaY = self:seaY(PIKA_X) - 20 - self.y
for a = 205, 335, 18 do
local r = math.rad(a)
local lx = 56 + math.cos(r) * 44 - 4
local ly = faceY + math.sin(r) * 28 - 4
-- foam that would land inside Pikachu's SGB zone comes out orange;
-- leave that patch to the spray ellipse instead
if math.abs(lx - PIKA_X) > 28 or math.abs(ly - (pikaY + 12)) > 26 then
self:tile("lip", lx, ly)
end
end
self:tile("shade", 92 - shift, faceY + 20)
self:tile("shade", 116 - shift, faceY + 24)
-- beach slides through at the start and again before the goal
local beachX
if self.distance < 160 then
beachX = -self.distance
elseif self.distance > RUN_DISTANCE - 200 then
beachX = 160 - (self.distance - (RUN_DISTANCE - 200))
end
if beachX then
for tx = 0, 32, 8 do
self:tile("beach", beachX + tx, 128)
self:tile("beach", beachX + tx, 136)
end
love.graphics.setColor(0, 0, 0, 1)
love.graphics.rectangle("fill", beachX + 9, 118, 2, 10)
love.graphics.setColor(1, 1, 1, 1)
self:tile("palm", beachX + 6, 112)
self:tile("hut", beachX + 20, 118)
end
-- Pikachu. The white spray patch under him doubles as the yellow SGB
-- zone's backdrop: shade 0 maps to white in both palettes, so the
-- attribute-block bleed never shows on the water pattern.
love.graphics.setColor(1, 1, 1, 1)
local py = self:seaY(PIKA_X) - 20 - self.y
-- Pikachu rides at the height his tile column reports
local py = self:seaY(PIKA_X) - self.y
self.pikaScreenY = py -- the yellow SGB zone tracks this
love.graphics.ellipse("fill", PIKA_X, py + 12, 25, 21)
if self.phase == "wipeout" then
love.graphics.draw(self.ob, self.bq.splash, PIKA_X - 16,
self:seaY(PIKA_X) - 16)
love.graphics.draw(self.ob, self.bq.splash, PIKA_X - 4, py)
else
local quad
if self.phase == "ride" and self.speed <= 2
@@ -332,7 +510,7 @@ function SurfingMinigame:draw()
local bucket = math.floor(((self.rot % 360) + 22.5) / 45) % 8 * 45
quad = self.bq.poses[bucket] or self.bq.poses[0]
end
love.graphics.draw(self.ob, quad, PIKA_X - 12, py)
love.graphics.draw(self.ob, quad, PIKA_X, py)
end
-- banner beats: GOOD! / YEAH- / Oh no..
@@ -340,11 +518,12 @@ function SurfingMinigame:draw()
love.graphics.draw(self.ob, self.bq[self.banner.quad], 60, 40)
end
-- score strip, bottom right: HP: + sheet digits
-- the HP window sits under the BG rows ($7e into hWY puts it at y 126;
-- tile-aligned here): "HP:" plus the sheet digits over plain white
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 100, 134, 60, 10)
love.graphics.draw(self.bg, self.aq.hp, 102, 135)
self:drawScore(126, 135, self.score)
love.graphics.rectangle("fill", 0, BG_HEIGHT, 160, 144 - BG_HEIGHT)
love.graphics.draw(self.bg, self.hpq, 8, BG_HEIGHT + 4)
self:drawScore(32, BG_HEIGHT + 4, self.score)
if self.phase == "results" then
love.graphics.setColor(1, 1, 1, 1)
+35 -20
View File
@@ -10,8 +10,16 @@ local TradeAnim = {}
TradeAnim.__index = TradeAnim
TradeAnim.isOpaque = true
-- Trade_LoadMonSprite runs SET_PAL_POKEMON_WHOLE_SCREEN for the mon it puts
-- on screen; every other step of the sequence runs SET_PAL_GENERIC, which is
-- PAL_MEWMON (data/sgb/sgb_packets.asm PalPacket_Generic). #750
function TradeAnim:sgbPalettes(game)
return require("src.render.PaletteFX").wholeNamed(game.data, "MEWMON")
local P = require("src.render.PaletteFX")
local mon = (self.phase == "show_player" and self.sent)
or (self.phase == "show_enemy" and self.received)
local colors = mon and P.monPal(game.data, mon.species)
if colors then return { P.whole(colors) } end
return P.wholeNamed(game.data, "MEWMON")
end
local DEFAULT_ART = {
@@ -350,17 +358,17 @@ function TradeAnim:drawMonInfo(mon, ot, otId, boxTy)
love.graphics.setColor(1, 1, 1, 1)
end
function TradeAnim:drawIconInBubble(sprite, x, y)
local spr = sprite
if spr then
local sw, sh = spr:getDimensions()
local s = 16 / math.max(sw, sh)
love.graphics.draw(spr, x, y, 0, s, s)
else
love.graphics.setColor(0, 0, 0, 1)
love.graphics.rectangle("fill", x + 4, y + 4, 8, 8)
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
local iw, ih = self.img.bubble:getDimensions()
@@ -370,7 +378,19 @@ function TradeAnim:drawIconInBubble(sprite, x, y)
or self.bubbleQuad
end
local q = self.cableFlash and self.bubbleQuadAlt or self.bubbleQuad
love.graphics.draw(self.img.bubble, q, x - 8, y - 8)
local left, top = x - 8, y - 8
local right, bottom = left + 32, top + 32
love.graphics.draw(self.img.bubble, q, left, top)
love.graphics.draw(self.img.bubble, q, right, top, 0, -1, 1)
love.graphics.draw(self.img.bubble, q, left, bottom, 0, 1, -1)
love.graphics.draw(self.img.bubble, q, right, bottom, 0, -1, -1)
end
local drawn = mon and require("src.ui.PartyMenu").drawIcon(
self.game, mon, x, y, false, 0, self.cableFlash)
if not drawn then
love.graphics.setColor(0, 0, 0, 1)
love.graphics.rectangle("fill", x + 4, y + 4, 8, 8)
love.graphics.setColor(1, 1, 1, 1)
end
end
@@ -471,13 +491,8 @@ function TradeAnim:draw()
love.graphics.translate(160, 0)
self:drawRightGB()
love.graphics.pop()
local sprite
if p == "transfer_lr" then
sprite = self.sentSprite
else
sprite = self.recvSprite
end
self:drawIconInBubble(sprite, self.monX, self.monY)
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)
+62 -22
View File
@@ -2915,6 +2915,34 @@ function OverworldState:trainerDefeated(npc)
return false
end
-- data/trainers/encounter_types.asm
local FEMALE_TRAINERS = {
OPP_LASS = true, OPP_JR_TRAINER_F = true, OPP_BEAUTY = true,
OPP_COOLTRAINER_F = true,
}
local EVIL_TRAINERS = {
OPP_UNUSED_JUGGLER = true, OPP_GAMBLER = true, OPP_ROCKER = true,
OPP_JUGGLER = true, OPP_CHIEF = true, OPP_SCIENTIST = true,
OPP_GIOVANNI = true, OPP_ROCKET = true,
}
-- PlayTrainerMusic (home/trainers.asm:399) picks the encounter sting from
-- the engaged class: evil list, then female list, then male by default.
-- The rivals `ret z` out of it and keep the MUSIC_MEET_RIVAL their own
-- scripts start (data/scripts/oaks_lab.lua, story5.lua). Its other gate,
-- wGymLeaderNo, is not a leader test: that byte aliases wLoneAttackNo
-- (ram/wram.asm:1264), is cleared on every map entry
-- (engine/overworld/clear_variables.asm:8), and each gym script writes it
-- only AFTER its own `call EngageMapTrainer` (scripts/PewterGym.asm:122),
-- so leaders do get the sting and nothing on a map can be suppressed by it
-- before the leader is beaten. Returns nil when the class gets no sting.
local function meetTrainerTheme(cls)
if not cls or cls:find("RIVAL") then return nil end
return EVIL_TRAINERS[cls] and "Music_MeetEvilTrainer"
or FEMALE_TRAINERS[cls] and "Music_MeetFemaleTrainer"
or "Music_MeetMaleTrainer"
end
-- Run the pre-battle text -> battle -> won text -> flags sequence.
function OverworldState:engageTrainer(npc, onDone)
local d = npc.def
@@ -2930,6 +2958,20 @@ function OverworldState:engageTrainer(npc, onDone)
local BattleState = require("src.battle.BattleState")
Game.stack:push(TextBox.new(Game, battleText, function()
-- TalkToTrainer (home/trainers.asm:88) prints the before-battle text
-- FIRST and only then runs `call EngageMapTrainer` / `jp
-- StartTrainerBattle`, so a trainer challenged on foot gets the sting
-- over the battle transition rather than under the dialogue. Its
-- `bit BIT_SEEN_BY_TRAINER, [hl] / ret nz` guard is self.engaging
-- here: TrainerEngage (engine/overworld/trainer_sight.asm:224) already
-- started the sting before the "!" bubble on the sight path, so it
-- must not restart. Script-driven challenges (gyms.lua leaders,
-- scripts/SilphCo11F.asm:269 Giovanni, scripts/FightingDojo.asm:122)
-- all `call EngageMapTrainer` too, and reach this same path (#764).
if not self.engaging then
local theme = meetTrainerTheme(d.trainerClass)
if theme then require("src.core.Music").play(Game.data, theme) end
end
local battle = BattleState.newTrainer(Game, d.trainerClass, d.trainerParty)
-- PrintEndBattleText (home/trainers.asm:341) is called from
-- TrainerBattleVictory (engine/battle/core.asm:942), i.e. ON the battle
@@ -3101,29 +3143,15 @@ function OverworldState:checkTrainerSight()
end
end
-- data/trainers/encounter_types.asm
local FEMALE_TRAINERS = {
OPP_LASS = true, OPP_JR_TRAINER_F = true, OPP_BEAUTY = true,
OPP_COOLTRAINER_F = true,
}
local EVIL_TRAINERS = {
OPP_UNUSED_JUGGLER = true, OPP_GAMBLER = true, OPP_ROCKER = true,
OPP_JUGGLER = true, OPP_CHIEF = true, OPP_SCIENTIST = true,
OPP_GIOVANNI = true, OPP_ROCKET = true,
}
function OverworldState:startTrainerApproach(npc, dist)
self.engaging = true
npc.frozen = true
-- the encounter sting (PlayTrainerMusic): evil / female / male by
-- class; rivals and gym leaders keep their own music
local cls = npc.def.trainerClass
if cls and not cls:find("RIVAL") then
local theme = EVIL_TRAINERS[cls] and "Music_MeetEvilTrainer"
or FEMALE_TRAINERS[cls] and "Music_MeetFemaleTrainer"
or "Music_MeetMaleTrainer"
require("src.core.Music").play(Game.data, theme)
end
-- TrainerEngage (engine/overworld/trainer_sight.asm:224) sets
-- BIT_SEEN_BY_TRAINER and calls EngageMapTrainer before the "!" bubble,
-- so the sighting sting starts ahead of the walk-up; engageTrainer sees
-- self.engaging and does not restart it (#764)
local theme = meetTrainerTheme(npc.def.trainerClass)
if theme then require("src.core.Music").play(Game.data, theme) end
local function fight()
self:engageTrainer(npc, function()
npc.frozen = false
@@ -3981,7 +4009,7 @@ function OverworldState:startWarpTo(mapId, x, y, facing, onDone, opts)
end, function()
self.transitioning = false
if onDone then onDone() end
end))
end, true)) -- warp shape: no fade back in (LoadGBPal restores in one write)
end
-- Re-read a map record after its data changed (WorldAPI:invalidateMap,
@@ -4228,7 +4256,19 @@ function OverworldState:drawWorld()
-- Renderer:beginFrame cleared it, so a battle or a full-screen menu -- which
-- draws with no map beneath it -- stays lit exactly like
-- init_battle_variables.asm's `ld [wMapPalOffset], a` leaves the original.
PaletteFX.setShadeMap(self.dark and PaletteFX.DARK_BGP or nil)
--
-- BATTLE BG "world" is the one case where a map DOES draw in a battle's
-- frame (Game.drawBaseInStack), and the shift armed here reached the
-- battle's own colorize pass, so an un-flashed Rock Tunnel battle came out
-- with FadePal2 over its pics, HUD and text (#773). The battle zeroes
-- wMapPalOffset for its whole run and restores it on the way out
-- (engine/battle/core.asm InitBattleCommon push/pop), so the map behind it
-- goes lit too for as long as the battle is up -- which is what the
-- original's saved offset means.
local battleOverWorld = Game and Game.stack
and Game.worldBgBattleInStack(Game.stack)
PaletteFX.setShadeMap((self.dark and not battleOverWorld)
and PaletteFX.DARK_BGP or nil)
-- advance the water/flower tile animation (runs under dialogs too).
-- TileRenderer.tick uses wall-clock 60Hz steps so display refresh rate
-- does not speed or slow the cycle (issue #4).
@@ -0,0 +1,184 @@
-- Eye check: BATTLE BG = WORLD dims the surround only, never the battle's own
-- 160x144 field (#777, dup #772). Renderer:endFrame used to fill the WHOLE
-- window with the 55% veil; the classic battle hid that under its opaque paper
-- field, but a pipeline that stages the fight on the map and keys the field
-- out (the Dramatic Shape Voxel Mod) got the veil straight onto its sprites
-- and HP boxes. On hardware there is nothing behind the battle to dim at all:
-- _InitBattleCommon calls ClearScreen (pokered home/copy2.asm) over the whole
-- tilemap before the battle draws.
-- POKEPORT_DRIVER=tests/drivers/battle_bg_world_dim_bug777_test.lua POKEPORT_IDENTITY=bug777 POKEPORT_TOUCH=0 SHOT_DIR=/tmp/shots love .
-- POKEPORT_TOUCH=0 matters: the on-screen controls draw after endFrame and
-- would land in the void samples. No POKEPORT_SPEED around the shots.
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local Game = require("src.core.Game")
local Renderer = require("src.render.Renderer")
local Pokemon = require("src.pokemon.Pokemon")
local BattleState = require("src.battle.BattleState")
-- pokered data/maps/objects/Route1.asm puts both youngsters at (5,24) and
-- (15,13) and the sign at (9,27), so the top of the road is empty; the
-- battle is pushed straight in, the cell is only somewhere to stand.
local MAP = "ROUTE_1"
local STAND = { x = 5, y = 6, facing = "down" }
local PARTY = { { "BULBASAUR", 12 }, { "PIDGEOTTO", 18 } }
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
return ok
end
-- Load a captured PNG back as ImageData. love.image cannot read absolute
-- paths, so go through io.open + newFileData.
local function loadShot(path)
local f = io.open(path, "rb")
if not f then return nil end
local bytes = f:read("*a")
f:close()
local ok, img = pcall(function()
return love.image.newImageData(
love.filesystem.newFileData(bytes, "shot.png"))
end)
return ok and img or nil
end
-- Mean brightness of a pixel rect. Whole-region averages, so NPC steps and
-- flower/water animation between two shots wash out instead of flipping a
-- single-pixel compare.
local function regionMean(img, x, y, w, h)
local sum, n = 0, 0
local x2 = math.min(x + w, img:getWidth()) - 1
local y2 = math.min(y + h, img:getHeight()) - 1
for yy = math.max(y, 0), y2 do
for xx = math.max(x, 0), x2 do
local r, g, b = img:getPixel(xx, yy)
sum = sum + (r + g + b) / 3
n = n + 1
end
end
return n > 0 and sum / n or 0, n
end
-- The UI letterbox in framebuffer pixels, the same math endFrame uses for
-- uox/uoy/uvpw/uvph (screenshots are framebuffer-sized, so no dpi divide).
local function uiBox(img)
local pw, ph = img:getWidth(), img:getHeight()
local uiw, uih = Renderer:uiSize()
local Up = Renderer:uiScale()
local bw, bh = math.floor(uiw * Up + 0.5), math.floor(uih * Up + 0.5)
local bx = math.floor((pw - bw) / 2)
local by = math.floor((ph - bh) / 2)
return bx, by, bw, bh, pw, ph
end
game.save.options = game.save.options or {}
game.save.options.battleFit = nil -- classic letterbox, the geometry under test
game.save.party = {}
for _, slot in ipairs(PARTY) do
table.insert(game.save.party, Pokemon.new(game.data, slot[1], slot[2]))
end
U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing)
U.wait(30)
local ow = game.overworld
check("the overworld is up on " .. MAP, ow ~= nil and ow.map.id == MAP)
-- ---- machine half: veil geometry, no battle needed -----------------------
-- In the overworld the UI canvas is transparent over the map, so the veil
-- is not hidden by an opaque field the way the classic battle hides it.
-- Arm the dim through the real per-frame wiring (Game:draw reads
-- Game.worldBgBattleDim every frame) and compare against an unarmed shot:
-- the surround must darken, the letterbox interior must not.
local baseShot = DIR .. "/bug777_0_overworld.png"
local veilShot = DIR .. "/bug777_0_overworld_veiled.png"
U.shot(game, baseShot)
local realDim = Game.worldBgBattleDim
Game.worldBgBattleDim = function() return BattleState.BG_WORLD_DIM end
U.wait(2)
U.shot(game, veilShot)
Game.worldBgBattleDim = realDim
U.wait(2)
local base, veil = loadShot(baseShot), loadShot(veilShot)
if check("both overworld shots decoded", base ~= nil and veil ~= nil) then
local bx, by, bw, bh, pw = uiBox(base)
local inBase = regionMean(base, bx, by, bw, bh)
local inVeil = regionMean(veil, bx, by, bw, bh)
U.log((" letterbox %dx%d at (%d, %d), interior mean %.3f -> %.3f")
:format(bw, bh, bx, by, inBase, inVeil))
-- 0.75 sits between "unchanged" and the 0.55 veil's 0.45x; whole-box
-- means make a frame of tile animation worth far less than that gap.
check("the veil leaves the battle box alone (#777)",
inVeil >= inBase * 0.75)
if bx >= 8 then
local outBase = regionMean(base, 0, 0, bx, base:getHeight())
local outVeil = regionMean(veil, 0, 0, bx, veil:getHeight())
U.log((" left void strip mean %.3f -> %.3f"):format(outBase, outVeil))
check("the veil still dims the surround", outVeil <= outBase * 0.75)
else
U.log(" window has no side voids at this scale, surround check skipped")
end
end
-- ---- the real battle, all three BATTLE BG modes --------------------------
local battle = BattleState.newWild(game, "RATTATA", 5)
battle.onFinish = function() end
ow:pushBattle(battle)
for _ = 1, 400 do
if game.stack:top() == battle and (battle.introSlide or 0) == 0 then break end
U.wait(1)
end
check("the battle reached the screen", game.stack:top() == battle)
for _ = 1, 120 do
if battle.phase == "menu" then break end
U.tap(game, "a")
U.wait(6)
end
check("the battle reached its FIGHT/PKMN/ITEM/RUN menu",
battle.phase == "menu")
-- bgMode reads save.options.battleBg per frame, so one battle covers all
-- three modes; the menu is idle between shots.
local shots = {}
for _, mode in ipairs({ "white", "black", "world" }) do
game.save.options.battleBg = mode
U.wait(5)
local path = DIR .. "/bug777_" .. mode .. ".png"
U.shot(game, path)
shots[mode] = loadShot(path)
end
if check("all three battle shots decoded",
shots.white ~= nil and shots.black ~= nil and shots.world ~= nil) then
local bx, by, bw, bh = uiBox(shots.white)
local inWhite = regionMean(shots.white, bx, by, bw, bh)
local inWorld = regionMean(shots.world, bx, by, bw, bh)
U.log((" battle box mean, WHITE %.3f vs WORLD %.3f"):format(inWhite, inWorld))
check("WORLD leaves the battle's own screen at WHITE's brightness",
math.abs(inWhite - inWorld) < 0.02)
if bx >= 8 then
local outWhite = regionMean(shots.white, 0, 0, bx, shots.white:getHeight())
local outWorld = regionMean(shots.world, 0, 0, bx, shots.world:getHeight())
U.log((" void strip mean, WHITE %.3f vs WORLD %.3f"):format(outWhite, outWorld))
check("WORLD's surround is the dimmed map, not paper",
outWorld < outWhite - 0.05)
end
end
-- ---- over to you ---------------------------------------------------------
U.log("You are at the battle menu with BATTLE BG = WORLD: the frozen Route 1")
U.log("map sits around the battle at 55% brightness. Put bug777_white.png and")
U.log("bug777_world.png side by side: inside the battle box they must match")
U.log("exactly, same paper, same pics, same HP bars; only the frame around it")
U.log("changes. #777 dimmed the whole window instead, which with a mod that")
U.log("stages the fight on the map dropped the veil straight over the sprites")
U.log("and health boxes. The near miss to look for is the opposite failure:")
U.log("a surround that is NOT dimmed at all, which means the veil got lost")
U.log("rather than scoped.")
U.log("Shots: " .. DIR .. "/bug777_*.png")
while true do
coroutine.yield()
end
end
+111
View File
@@ -0,0 +1,111 @@
-- Manual check that Silph Co Giovanni gets the ordinary trainer theme (#782).
-- PlayBattleMusic (audio/play_battle_music.asm) only picks
-- MUSIC_GYM_LEADER_BATTLE when wGymLeaderNo is set, and scripts/SilphCo11F.asm
-- never writes it -- only the eight gym scripts do. The port keyed the boss
-- check on the trainer class alone, so this fight (OPP_GIOVANNI#2) borrowed
-- the Viridian Gym roster's theme, victory jingle, and Pikachu happiness bump.
-- The data half is asserted in tests/parity_battle_music_bug782.lua.
-- POKEPORT_DRIVER=tests/drivers/battle_music_bug782_test.lua POKEPORT_IDENTITY=bug782 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local Pokemon = require("src.pokemon.Pokemon")
local Music = require("src.core.Music")
local BattleState = require("src.battle.BattleState")
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
return ok
end
local vol = game.save.options and game.save.options.musicVol
if vol == 0 then
U.log("music volume is 0 in options; raise it or nothing will be audible")
end
-- record what the engine asks the music system for; the real playback
-- still happens underneath, so the listening half is unaffected
local played = {}
local realPlayBattle = Music.playBattle
Music.playBattle = function(data, kind, trainerId)
played[#played + 1] = { call = "battle", kind = kind }
return realPlayBattle(data, kind, trainerId)
end
local realPlayVictory = Music.playVictory
Music.playVictory = function(data, kind, trainerId)
played[#played + 1] = { call = "victory", kind = kind }
return realPlayVictory(data, kind, trainerId)
end
-- a party that can win this quickly, so the victory jingle is reachable
game.save.party = {
Pokemon.new(game.data, "MEWTWO", 90),
Pokemon.new(game.data, "CHARIZARD", 80),
}
game.save.player.name = "RED"
-- Giovanni's fight is a coordinate trigger, not a talk:
-- SilphCo11FDefaultScript (pokered scripts/SilphCo11F.asm
-- .PlayerCoordsArray) fires on (6,13) or (7,12), walks him three tiles
-- down from his object_event spot at (6,9)
-- (pokered data/maps/objects/SilphCo11F.asm), shows his speech and starts
-- the battle. The (6,13) pad sits behind the 11F card key door, which this
-- teleported-in save has not opened (no CARD KEY, so tryCardKeyDoor never
-- swaps the door block), so take the pad on the open side: stand on (6,12)
-- and step east onto (7,12).
U.teleport(game, "SILPH_CO_11F", 6, 12, "right")
U.wait(10)
U.hold(game, "right", 40)
U.wait(10)
-- Giovanni's approach, then his pre-battle text box: mash A until the
-- battle state is on top of the stack
local battle
for _ = 1, 400 do
local top = game.stack:top()
if getmetatable(top) == BattleState and top.kind == "trainer" then
battle = top
break
end
U.tap(game, "a")
U.wait(3)
end
check("the coordinate trigger engaged a trainer battle", battle ~= nil)
if battle then
check("the opponent is Giovanni (OPP_GIOVANNI#2)",
battle.oppClass == "OPP_GIOVANNI" and battle.partyIndex == 2)
check("musicKind is \"trainer\", not \"gym\"",
battle.musicKind == "trainer")
check("isGymLeader is unset (no Pikachu GYMLEADER happiness bump)",
not battle.isGymLeader)
local battleCall
for _, p in ipairs(played) do
if p.call == "battle" then battleCall = p.kind end
end
check("Music.playBattle was asked for the trainer theme",
battleCall == "trainer")
end
U.log("You are in the Silph Co Giovanni fight. The theme playing now")
U.log("should be the ordinary Vs. Trainer battle music, not the gym-leader")
U.log("theme this fight used to borrow. Win it (MEWTWO 90 vs his level")
U.log("~40 party) and the jingle at \"defeated GIOVANNI\" should be the")
U.log("plain trainer victory fanfare, again not the gym-leader one.")
U.log("For the correct-by-contrast case, the Viridian Gym rematch")
U.log("(OPP_GIOVANNI#3) still keeps the gym-leader theme.")
-- report the victory request when the win lands, then keep idling
local reported = false
while true do
if not reported then
for _, p in ipairs(played) do
if p.call == "victory" then
check("Music.playVictory was asked for the trainer jingle",
p.kind == "trainer")
reported = true
end
end
end
coroutine.yield()
end
end
@@ -0,0 +1,95 @@
-- Real-time check of the credits roll vs Music_Credits (#703).
-- The song is a fixed 5880-frame program (audio/music/credits.asm: tempo 140,
-- no loop), so on hardware it outlasts THE END by ~12s. Our roll ran 135
-- frames short of pokered because Credits:update skipped DisplayCreditsMon's
-- three CreditsCopyTileMapToVRAM calls (each `jp Delay3`, 9 frames per mon
-- screen, 15 mon screens); that stretched the overhang to ~14.3s and made the
-- music look too fast. This driver plays the whole roll in real time (~98s,
-- do NOT set POKEPORT_SPEED: the ear half needs the real clock), counts the
-- fixed frames itself, and leaves the screen on THE END with the theme still
-- going so a listener can judge the tail.
-- SHOT_DIR=/tmp/shots POKEPORT_DRIVER=tests/drivers/credits_overhang_bug703_test.lua POKEPORT_IDENTITY=bug703 POKEPORT_TOUCH=0 love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
return ok
end
if os.getenv("POKEPORT_SPEED") then
U.log("warning: POKEPORT_SPEED is set; the listening half of this run",
"is meaningless at any speed but 1")
end
local opts = game.save and game.save.options
if opts and opts.musicVolume == 0 then
U.log("warning: options music volume is 0, nothing will be audible")
end
-- the roll ends in an autosave; put the user's save back afterwards
local prevSave = love.filesystem.read("save.lua")
U.teleport(game, "HALL_OF_FAME", 4, 2, "right")
game.save.party = { { species = "PIKACHU", level = 81 } }
game.overworld.runner:run({ { "record_hall_of_fame" } })
U.wait(2)
-- A through the induction until the credits state is on top; the full
-- hall-of-fame walk takes well over 60 taps, so give it real room
local Credits = require("src.ui.Credits")
local credits
for _ = 1, 2000 do
local top = game.stack:top()
if getmetatable(top) == Credits then credits = top break end
U.tap(game, "a")
U.wait(2)
end
if not check("credits state reached", credits ~= nil) then
while true do coroutine.yield() end
end
-- Frame accounting, one fixed step per sample. From the frame Music_Credits
-- starts (phase leaves "white") pokered reaches the end of THE END's fade at
-- 128 + 35 screens + 16 + 20 = 5154 frames; the 15 mon screens each spend
-- 9 frames in mon_prep (the Delay3 x3) before their 27-frame wipe.
-- no screenshots inside this loop: U.shot yields extra fixed steps of its
-- own and would silently skew the count
local musicStart, theEndAt, prepFrames = nil, nil, 0
for f = 1, 7000 do
U.wait(1)
local phase = credits.phase
if not musicStart and phase ~= "white" then musicStart = f end
if phase == "mon_prep" then prepFrames = prepFrames + 1 end
if phase == "end_hold" then theEndAt = f break end
end
check("mon_prep ran 9 frames on each of the 15 mon screens (135 total)",
prepFrames == 135)
check("THE END finishes fading 5154 frames after the music starts",
musicStart ~= nil and theEndAt ~= nil
and theEndAt - musicStart == 5154)
U.log("music started at driver frame", musicStart,
"THE END done at", theEndAt, "mon_prep frames", prepFrames)
U.shot(game, DIR .. "/bug703_the_end.png")
-- Music_Credits is 5880 frames long, so from here the theme has
-- 5880 - 5154 = 726 frames (~12.1s) left. That overhang is authentic:
-- the original does the same on hardware, and this fix only removed the
-- extra 2.2s our shortened roll had added on top of it.
U.log("listen: the theme should keep playing about 12 seconds past this")
U.wait(726)
U.log("the song should be ending right about now; silence after this",
"point is correct, the program has no loop")
U.wait(120)
if prevSave then
love.filesystem.write("save.lua", prevSave)
else
love.filesystem.remove("save.lua")
end
U.log("done; screen stays on THE END (A or B would soft-reset)")
while true do
coroutine.yield()
end
end
+2 -6
View File
@@ -68,14 +68,10 @@ return function(game)
U.wait(4)
-- open the party menu and pick DIG on slot 1
-- (submenu order for a DIG-only mon: STATS / SWITCH / DIG)
-- (submenu order for a DIG-only mon: DIG / STATS / SWITCH -- #768)
Screens.push(game, "PartyMenu")
U.wait(5)
U.tap(game, "a") -- open the per-mon submenu
U.wait(2)
U.tap(game, "down") -- STATS -> SWITCH
U.wait(2)
U.tap(game, "down") -- SWITCH -> DIG
U.tap(game, "a") -- open the per-mon submenu, cursor on DIG
U.wait(2)
U.tap(game, "a") -- choose DIG
U.wait(2)
+3 -6
View File
@@ -38,14 +38,11 @@ return function(game)
U.teleport(game, "PALLET_TOWN", 10, 8, "down")
U.wait(5)
-- open the party menu and pick FLY on slot 1 (submenu: STATS / SWITCH / FLY)
-- open the party menu and pick FLY on slot 1 (submenu: FLY / STATS / SWITCH,
-- field moves on top like start_sub_menus.asm -- #768)
Screens.push(game, "PartyMenu")
U.wait(5)
U.tap(game, "a") -- open the per-mon submenu
U.wait(2)
U.tap(game, "down") -- STATS -> SWITCH
U.wait(2)
U.tap(game, "down") -- SWITCH -> FLY
U.tap(game, "a") -- open the per-mon submenu, cursor on FLY
U.wait(2)
U.tap(game, "a") -- choose FLY
U.wait(5)
+3 -6
View File
@@ -30,14 +30,11 @@ return function(game)
U.teleport(game, "PALLET_TOWN", 10, 8, "down")
U.wait(5)
-- open the party menu and pick FLY on slot 1 (submenu: STATS / SWITCH / FLY)
-- open the party menu and pick FLY on slot 1 (submenu: FLY / STATS / SWITCH,
-- field moves on top like start_sub_menus.asm -- #768)
Screens.push(game, "PartyMenu")
U.wait(5)
U.tap(game, "a") -- open the per-mon submenu
U.wait(2)
U.tap(game, "down") -- STATS -> SWITCH
U.wait(2)
U.tap(game, "down") -- SWITCH -> FLY
U.tap(game, "a") -- open the per-mon submenu, cursor on FLY
U.wait(2)
U.tap(game, "a") -- choose FLY
U.wait(5)
@@ -0,0 +1,76 @@
-- Manual check for #768: the field party menu keeps its cursor across
-- close/reopen (wPartyAndBillsPCSavedMenuItem: PartyMenuInit reads it,
-- HandlePartyMenuInput writes it back, only a battle zeroes it via
-- InitBattleVariables / end_of_battle.asm), and the per-mon submenu lists
-- field moves ABOVE STATS/SWITCH (DisplayFieldMoveMonMenu prints the move
-- names above PokemonMenuEntries, engine/menus/text_box.asm).
-- POKEPORT_DRIVER=tests/drivers/party_cursor_bug768_test.lua POKEPORT_IDENTITY=bug768 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local Pokemon = require("src.pokemon.Pokemon")
local Screens = require("src.ui.Screens")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
return ok
end
-- Slot 1 knows FLY (movesAtLevel never grants it, so inject it, same as
-- the #203 driver); the badge gates the submenu entry.
local flyer = Pokemon.new(game.data, "PIDGEOT", 40)
flyer.moves[1] = { id = "FLY", pp = 15 }
game.save.party = {
flyer,
Pokemon.new(game.data, "PIKACHU", 30),
Pokemon.new(game.data, "SNORLAX", 77),
}
game.save.player.name = "bryan"
game.save.inventory = game.save.inventory or {}
game.save.inventory.THUNDERBADGE = true
-- Pallet Town is OVERWORLD, so FLY is listed (CheckIfInOutsideMap)
U.teleport(game, "PALLET_TOWN", 10, 8, "down")
U.wait(10)
-- half 1: the submenu puts the field move on top
Screens.push(game, "PartyMenu")
U.wait(5)
local pm = game.stack:top()
U.tap(game, "a") -- open the per-mon submenu on the FLY mon
U.wait(2)
local items = pm.subItems or {}
check("submenu row 1 is FLY, not STATS",
items[1] ~= nil and items[1].action == "fly")
check("STATS/SWITCH close the list under the field move",
#items == 3 and items[2].action == "stats"
and items[3].action == "switch")
U.shot(game, DIR .. "/bug768_submenu.png")
U.tap(game, "b") -- back out of the submenu
U.wait(2)
-- half 2: the cursor survives closing and reopening the menu
U.tap(game, "down")
U.wait(2)
U.tap(game, "down")
U.wait(2)
check("cursor moved to slot 3", pm.index == 3)
U.tap(game, "b") -- close the party menu entirely
U.wait(5)
Screens.push(game, "PartyMenu")
U.wait(5)
local pm2 = game.stack:top()
check("reopened menu is still on slot 3 (SNORLAX)",
pm2 ~= pm and pm2.index == 3)
U.shot(game, DIR .. "/bug768_reopened.png")
U.log("The party menu on screen was just reopened; the cursor should sit")
U.log("on slot 3 (SNORLAX), not slot 1. A on slot 1 shows FLY above")
U.log("STATS/SWITCH. Input is yours now: walk north into the Route 1")
U.log("grass, win or run from a wild battle, then reopen the party menu --")
U.log("the cursor should be back on slot 1 (the battle cleared it).")
while true do
coroutine.yield()
end
end
@@ -0,0 +1,84 @@
-- Driver: BATTLE BG "world" plus an un-flashed Rock Tunnel put a dark map in
-- the same frame as the battle, and OverworldState:drawWorld's rBGP shift then
-- coloured the battle itself (#773). On hardware InitBattleCommon
-- (engine/battle/core.asm) pushes wMapPalOffset, InitBattleVariables zeroes it
-- and core.asm pops it back after EndOfBattle, so the battle is lit.
-- POKEPORT_DRIVER=tests/drivers/rock_tunnel_dark_battle_bug773_test.lua \
-- POKEPORT_IDENTITY=bug773 POKEPORT_TOUCH=0 SHOT_DIR=/tmp/shots love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local Probe = dofile("tests/drivers/shot_probe.lua")
local PaletteFX = require("src.render.PaletteFX")
local BattleState = require("src.battle.BattleState")
local Pokemon = require("src.pokemon.Pokemon")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local fails = 0
local function check(ok, msg)
U.log(ok and "PASS" or "FAIL", msg)
if not ok then fails = fails + 1 end
return ok
end
game.save.flags.EVENT_GOT_STARTER = true
if #game.save.party == 0 then
table.insert(game.save.party, Pokemon.new(game.data, "CHARMANDER", 20))
end
game.save.options = game.save.options or {}
game.save.options.textSpeed = 1
game.save.options.colors = "gbc"
game.save.options.battleBg = "world"
PaletteFX.setMode("gbc")
local CAVE = PaletteFX.pal(game.data, "CAVE")
local caveDark = PaletteFX.permute(CAVE, PaletteFX.DARK_BGP)
-- data/maps/objects/RockTunnel1F.asm: the Route 10 entrance warp is 15, 3,
-- so two cells south of it is floor that does not re-trigger the warp.
game.save.flashLit = nil
U.teleport(game, "ROCK_TUNNEL_1F", 15, 5, "down")
U.wait(20)
local ow = game.overworld
check(ow ~= nil and ow.dark == true, "standing in an un-flashed ROCK_TUNNEL_1F")
U.shot(game, DIR .. "/bug773_1_dark_map.png")
check(PaletteFX.shadeMap() == PaletteFX.DARK_BGP,
"the map frame really is drawn with DARK_BGP armed")
local ok = pcall(function()
local battle = BattleState.newWild(game, "ZUBAT", 15)
battle.onFinish = function() end
game.overworld:pushBattle(battle)
end)
if not ok then
U.log("WARN could not force a wild battle; nothing to judge")
while true do coroutine.yield() end
end
U.wait(120) -- through the transition wipe and the intro slide-in
U.shot(game, DIR .. "/bug773_2_battle_world_bg.png")
check(PaletteFX.shadeMap() == nil,
"no shade map is armed while the world-bg battle draws (#773)")
-- The battle keeps its own 160x144 field in the middle of the window; the
-- dimmed map only fills the surround, so probe the centre.
local CENTRE = { 0.4, 0.4, 0.6, 0.6 }
local shot = Probe.grab()
if shot then
local c = Probe.count(shot, { litPaper = CAVE[1], darkPaper = caveDark[1] },
3, CENTRE)
check(c.litPaper > 0,
"the battle screen keeps its paper white -- it is not FadePal2'd")
local top = Probe.top(shot, 5, 3, CENTRE)
U.log("battle centre top colours:", Probe.fmt(top))
else
U.log("WARN pixel probe unavailable; judge the shots by eye")
end
U.log(fails == 0 and "#773 checks passed" or (fails .. " #773 check(s) FAILED"))
U.log("Look at " .. DIR .. "/bug773_2_battle_world_bg.png: the battle screen")
U.log("should read exactly like any other battle -- white paper, normal HUD")
U.log("and pic colours -- with the dimmed tunnel only in the surround.")
U.log("The separate uniform dim of the world backdrop is #777, not this.")
while true do coroutine.yield() end
end
+148
View File
@@ -0,0 +1,148 @@
-- Manual check that the Surfing Pikachu minigame background is the
-- ROM's metatile scroller, not the old procedural stand-in (#726).
-- The stand-in tiled wave-face foam tiles ($02/$07) over the whole sea
-- and drew the swell as two LOVE ellipses, which read as zigzag noise
-- with white blobs. The fix ports SurfingMinigame_BGMetatileTable, the
-- WavePattern columns and the .WaveFunctions state table from
-- ../pokeyellow/engine/minigame/surfing_pikachu.asm; this driver
-- machine-checks the transcribed tables and the ride heights, then
-- parks a human in front of the water for the part only eyes can judge.
-- Needs a Yellow cache in the active identity (a sandboxed
-- POKEPORT_IDENTITY starts empty and would sit on the launcher).
-- SHOT_DIR=/tmp/shots POKEPORT_VERSION=yellow POKEPORT_TOUCH=0 POKEPORT_DRIVER=tests/drivers/surfing_bg_bug726_test.lua love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local Pokemon = require("src.pokemon.Pokemon")
local SurfingMinigame = require("src.ui.SurfingMinigame")
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
return ok
end
-- table integrity first, it needs no ROM state at all. surf_1a is
-- ripped as 65 tiles (src/import/RomExtractor.lua), so every tile id
-- a metatile names must be 0..64, every pattern entry must name a
-- metatile, and every wave state must name a pattern with plausible
-- ride heights (the asm range is FLAT_WATER_Y $74 down to -6 tiles).
local ok = true
for id, mt in pairs(SurfingMinigame.BG_METATILES) do
for i = 1, 4 do
if type(mt[i]) ~= "number" or mt[i] < 0 or mt[i] > 64 then
U.log("metatile", id, "slot", i, "has bad tile id", tostring(mt[i]))
ok = false
end
end
end
check("every metatile tile id lands inside the 65-tile surf_1a sheet", ok)
ok = true
for id, pat in pairs(SurfingMinigame.WAVE_PATTERNS) do
if #pat ~= 8 then ok = false U.log("pattern", id, "is not 8 rows") end
for i = 1, 8 do
if not SurfingMinigame.BG_METATILES[pat[i]] then
U.log("pattern", id, "row", i, "names missing metatile",
tostring(pat[i]))
ok = false
end
end
end
check("every wave pattern row names a real metatile", ok)
ok = true
for id, step in pairs(SurfingMinigame.WAVE_STEPS) do
if not SurfingMinigame.WAVE_PATTERNS[step[1]] then
U.log("wave state", id, "names missing pattern", tostring(step[1]))
ok = false
end
for i = 2, 3 do
if step[i] < 116 - 6 * 8 or step[i] > 116 then
U.log("wave state", id, "ride height", step[i], "out of range")
ok = false
end
end
end
check("every wave state names a real pattern with sane ride heights", ok)
-- pokeyellow data/maps/objects/SummerBeachHouse.asm: the Surfin' Dude
-- is object_event 2, 3, so (2, 2) facing down talks to him. He only
-- offers the run to a party Pikachu that knows SURF.
game.save.party = { Pokemon.new(game.data, "PIKACHU", 50) }
game.save.party[1].moves = { { id = "SURF", pp = 15 } }
U.teleport(game, "SUMMER_BEACH_HOUSE", 2, 2, "down")
U.wait(5)
-- mash A through the pitch and the YES into the game itself
local mg
for _ = 1, 300 do
local top = game.stack:top()
if top and top.seaY then mg = top break end
U.tap(game, "a")
U.wait(4)
end
check("the minigame opened", mg ~= nil)
if not mg then
U.log("could not reach the minigame; nothing more to show")
while true do coroutine.yield() end
end
-- the run opens on prefilled flat water: every visible column should
-- be the open-water pattern, whose bottom rows are metatile $01
-- (tile $0b everywhere), and Pikachu should sit on the flat waterline
ok = true
for c = 0, 10 do
local col = mg.cols[c]
if not (col and col.pat[8] == 0x01 and col.hl == 116) then ok = false end
end
check("the opening sea is flat open water, pattern 00 all the way", ok)
check("Pikachu's ride line starts on the flat waterline",
mg:seaY(68) == 100)
U.shot(game, DIR .. "/bug726_1_flat.png")
-- paddle up and ride until the generator has rolled some swells; the
-- chooser leaves flat water only on a nonzero roll, so give it room
for _ = 1, 8 do U.tap(game, "a") U.wait(3) end
local sawSwell, seaTracks = false, true
for _ = 1, 1500 do
if mg.phase ~= "ride" and mg.phase ~= "air" then break end
for c, col in pairs(mg.cols) do
if col.hl < 116 then sawSwell = true end
end
-- the ride height must always come from the column under Pikachu
local tile = math.floor((mg.distance + 68) / 8)
local col = mg.cols[math.floor(tile / 2)]
if col then
local want = (tile % 2 == 0 and col.hl or col.hr) - 16
if mg:seaY(68) ~= want then seaTracks = false end
end
if sawSwell and mg.distance > 400 then break end
U.wait(1)
if (U.frame() % 5) == 0 then U.tap(game, "a") end
end
check("the wave generator produced swells (ride heights above flat)",
sawSwell)
check("seaY always follows the generated column heights", seaTracks)
U.shot(game, DIR .. "/bug726_2_swell.png")
-- one jump for the air shot, then hand it over
if mg.phase == "ride" then
U.tap(game, "up")
U.hold(game, "right", 20)
U.shot(game, DIR .. "/bug726_3_air.png")
end
U.log("shots in", DIR, "- bug726_1_flat, bug726_2_swell, bug726_3_air")
U.log("What to look for: the open sea is the flat speckled water tile,")
U.log("not a diagonal zigzag field; no white ellipse blobs and no loose")
U.log("blue squares floating on the surface; swells build from the left,")
U.log("crest with foam and flatten out again; Pikachu sits on the wave")
U.log("at every point of the swell; the HP strip sits in the bottom band")
U.log("over unbroken white. The game is still live: keep riding to the")
U.log("goal and the sand should slide in under the coast-in before the")
U.log("results card.")
while true do
coroutine.yield()
end
end
@@ -0,0 +1,86 @@
-- Manual check that a voluntary switch resets both battle cursors (#737).
-- SendOutMon (pokered engine/battle/core.asm:1733-1735) zeroes
-- wBattleAndStartSavedMenuItem and, via the same hli/hl pair, the
-- wPlayerMoveListIndex byte behind it (wram.asm:242-244), so after any
-- player send-out the main menu reopens on FIGHT and the move list on
-- slot 1. The port used to keep both cursors where they were.
-- SHOT_DIR=/tmp/shots POKEPORT_DRIVER=tests/drivers/switch_cursor_bug737_test.lua love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local Pokemon = require("src.pokemon.Pokemon")
local BattleState = require("src.battle.BattleState")
-- CHARMANDER at 12 knows Scratch/Growl/Ember, so the move cursor can be
-- parked on slot 3 before the switch
game.save.party = {
Pokemon.new(game.data, "CHARMANDER", 12),
Pokemon.new(game.data, "SQUIRTLE", 10),
}
U.teleport(game, "ROUTE_1", 5, 5, "down")
local ow = game.overworld
local battle = BattleState.newWild(game, "PIDGEY", 4)
battle.onFinish = function() end
ow:pushBattle(battle)
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
return ok
end
local function mashUntil(cond, max)
for _ = 1, max or 120 do
if cond() then return true end
U.tap(game, "a")
U.wait(4)
end
return false
end
check("intro drains to the battle menu",
mashUntil(function() return battle.phase == "menu" end))
-- open FIGHT and park the cursor on move slot 3, then back out
U.tap(game, "a"); U.wait(6)
U.tap(game, "down"); U.wait(4)
U.tap(game, "down"); U.wait(4)
check("move cursor parked on slot 3", battle.moveIndex == 3)
U.tap(game, "b"); U.wait(6)
-- FIGHT/PKMN/ITEM/RUN: right to PKMN, A opens the party
U.tap(game, "right"); U.wait(4)
check("battle menu parked on PKMN", battle.menuIndex == 2)
U.tap(game, "a"); U.wait(12)
local pm = game.stack:top()
check("party menu opened", pm ~= nil and pm.onSwitch ~= nil)
-- pick SQUIRTLE, then SWITCH from the SWITCH/STATS/CANCEL submenu
U.tap(game, "down"); U.wait(4)
U.tap(game, "a"); U.wait(8)
U.tap(game, "a"); U.wait(8)
-- the switch queues "Come back!" / "Go!" plus the enemy's free move;
-- drain back to the next command menu
check("switch turn drains back to the menu",
mashUntil(function() return battle.phase == "menu" end, 300))
check("player is now SQUIRTLE", battle.player.mon.species == "SQUIRTLE")
-- the machine-checkable half of #737
check("battle menu is back on FIGHT", battle.menuIndex == 1)
check("move cursor is back on slot 1", battle.moveIndex == 1)
U.shot(game, DIR .. "/bug737_menu_after_switch.png")
U.tap(game, "a"); U.wait(8)
U.shot(game, DIR .. "/bug737_moves_after_switch.png")
U.log("captured", DIR .. "/bug737_menu_after_switch.png",
"and", DIR .. "/bug737_moves_after_switch.png")
U.log("The fight menu on screen is SQUIRTLE's, opened right after the")
U.log("switch. The cursor should sit on the first move; before #737 it")
U.log("kept CHARMANDER's old slot (3), and the main menu reopened on PKMN.")
while true do
coroutine.yield()
end
end
+161
View File
@@ -0,0 +1,161 @@
-- Manual check for the pages that must NOT wait on a button (#765).
-- Only TX_PROMPT_BUTTON blinks the arrow and waits (home/text.asm:434-446);
-- the used-move line (engine/battle/used_move_text.asm) ends in `text_end`
-- and both save pages come from SaveMenu .save (engine/menus/save.asm:164-181),
-- where "Now saving..." is a bare PlaceString + DelayFrames 120 and
-- GameSavedText ends in `done`. Ordering is asserted headlessly in
-- tests/parity_battle_auto_text_bug765.lua; this run is for pacing.
-- No POKEPORT_SPEED: the save beat and the SFX_SAVE hold are the moment.
-- POKEPORT_DRIVER=tests/drivers/text_advance_bug765_test.lua POKEPORT_IDENTITY=bug765 POKEPORT_TOUCH=0 love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local Pokemon = require("src.pokemon.Pokemon")
local BattleState = require("src.battle.BattleState")
local TextBox = require("src.render.TextBox")
local Menu = require("src.ui.Menu")
local ChoiceBox = require("src.ui.ChoiceBox")
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
return ok
end
game.save.party = { Pokemon.new(game.data, "BULBASAUR", 50) }
game.save.player.name = "RED"
U.teleport(game, "PALLET_TOWN", 10, 8, "down")
U.wait(10)
-- ---- part 1: START -> SAVE -> YES, hands off the pad -------------------
U.tap(game, "start")
U.wait(10)
local menu = game.stack:top()
check("START opened the menu", getmetatable(menu) == Menu)
if getmetatable(menu) == Menu then
-- walk the cursor onto SAVE by label: which rows exist depends on
-- story flags, so counting from the top is not stable
local target
for i, item in ipairs(menu.items) do
if tostring(item.label) == "SAVE" then target = i end
end
check("the menu lists SAVE", target ~= nil)
-- the cursor position survives closing the menu
-- (wBattleAndStartSavedMenuItem), so it may start above OR below SAVE
for _ = 1, #menu.items do
if not target or menu.index == target then break end
U.tap(game, menu.index < target and "down" or "up")
U.wait(4)
end
U.tap(game, "a")
U.wait(10)
end
-- the player/badges/dex/time panel types out, page-breaks (\f) into the
-- confirmation, then the YES/NO box opens; A through all of it, YES last
local chose = false
for _ = 1, 30 do
local top = game.stack:top()
if getmetatable(top) == ChoiceBox then
U.tap(game, "a")
chose = true
break
end
U.tap(game, "a")
U.wait(20)
end
check("the SAVE confirmation was reached and answered YES", chose)
-- the answered box holds 15 frames before it pops and runs the choice
-- (DisplayTwoOptionMenu, engine/menus/text_box.asm:322-334), so wait it out
for _ = 1, 60 do
if getmetatable(game.stack:top()) ~= ChoiceBox then break end
U.wait(1)
end
-- from here NOTHING is pressed: both boxes must clear themselves
U.wait(2)
local saving = game.stack:top()
check("the Now saving... box is up", getmetatable(saving) == TextBox)
local savingPopped
for f = 1, 300 do
if game.stack:top() ~= saving then savingPopped = f break end
U.wait(1)
end
check("it held about 2s and popped with no button (DelayFrames 120)",
savingPopped ~= nil and savingPopped > 60)
local savedPopped = false
for _ = 1, 600 do
local top = game.stack:top()
if getmetatable(top) ~= TextBox and getmetatable(top) ~= Menu then
savedPopped = true
break
end
U.wait(1)
end
check("the saved-the-game box cleared itself after SFX_SAVE", savedPopped)
-- ---- part 2: a wild battle's used-move line ----------------------------
local wild = BattleState.newWild(game, "RATTATA", 2)
wild.onFinish = function() end
local ow = game.overworld
if ow then ow:pushBattle(wild) end
for _ = 1, 400 do
if game.stack:top() == wild and (wild.introSlide or 0) == 0 then break end
U.wait(1)
end
check("the wild battle reached the screen", game.stack:top() == wild)
-- the intro page ends in `prompt` (WildMonAppearedText), so it still
-- waits; A through it and the send-out, then pick FIGHT + first move
for _ = 1, 600 do
if wild.phase == "menu" then break end
if wild.msgPrompt then U.tap(game, "a") end
U.wait(1)
end
check("the intro still holds on its arrow and A walks it to the menu",
wild.phase == "menu")
U.tap(game, "a") -- FIGHT
U.wait(10)
U.tap(game, "a") -- first move
-- from here NOTHING is pressed: "BULBASAUR used X!" must flow straight
-- into its animation with the line still on screen and no arrow
local sawUsed, promptedOnUsed, handedOff = false, false, false
for _ = 1, 900 do
local cur = wild.current
local t = cur and cur.text
if t and t:find("used", 1, true) then
sawUsed = true
if wild.msgPrompt then promptedOnUsed = true end
elseif sawUsed then
handedOff = true
break
end
U.wait(1)
end
check("the used-move line reached the screen", sawUsed)
check("it never raised the prompt arrow", not promptedOnUsed)
check("it handed off by itself, no A pressed", handedOff)
check("the line stays drawn under the animation (msgHold)",
wild.msgHold == true or wild.animPlaying == true)
-- ...and the pages after it still wait: a level-50 BULBASAUR one-shots a
-- level-2 RATTATA, so the faint line (BattleMonFaintedText class, ends in
-- `prompt`) comes up next and must hold on its arrow
local promptAfter = false
for _ = 1, 900 do
if wild.msgPrompt then promptAfter = true break end
U.wait(1)
end
check("the page after it still holds on the arrow", promptAfter)
U.log("Handing off. What just happened, and what to look for on a replay:")
U.log("the save flow ran with no button after YES: \"Now saving...\" held")
U.log("about 2 seconds, then \"RED saved the game!\" played the save jingle")
U.log("and cleared itself. In the battle, \"BULBASAUR used <MOVE>!\" flowed")
U.log("straight into the move animation with the line still up and no")
U.log("blinking arrow; the faint line after it is waiting on A right now.")
while true do
coroutine.yield()
end
end
+152
View File
@@ -0,0 +1,152 @@
-- Manual check that the trade cinematic draws real art, not rectangles (#750).
-- The ROM importer never wrote assets/generated/trade/*, so on a player's
-- cache every tryImage in TradeAnim.new returned nil and the whole
-- InternalClockTradeAnim sequence fell back to love.graphics.rectangle:
-- an outlined box for the Game Boy, a flat bar for the cable, a blank
-- screen during the open-cable phase. The machine half below asserts the
-- ten art files are in the cache at the sizes trade.asm implies and that
-- the running TradeAnim actually loaded them; the shots are for the human
-- half. A cache imported before this fix re-imports on launch (the
-- REQUIRED_FILES entry), so a FAIL on the file checks means the re-import
-- has not happened yet.
-- SHOT_DIR=/tmp/trade750 POKEPORT_DRIVER=tests/drivers/trade_anim_bug750_test.lua POKEPORT_IDENTITY=bug750 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/trade750"
local Pokemon = require("src.pokemon.Pokemon")
local TradeAnim = require("src.ui.TradeAnim")
local TextBox = require("src.render.TextBox")
local PartyMenu = require("src.ui.PartyMenu")
local ChoiceBox = require("src.ui.ChoiceBox")
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
return ok
end
-- Expected sizes: GameBoyTiles is a 6x8-tile plate and LinkCableTiles a
-- 12x3 one (pokered data/tilemaps.asm), Trade_DrawCableAcrossScreen fills
-- a 20-tile row, the ball and its bulge frame are one tile mirrored into
-- 2x2 OAM blocks, and TradeBubbleIconGFX is two 16x16 quadrant frames.
local FILES = {
{ "assets/generated/trade/game_boy.png", 48, 64 },
{ "assets/generated/trade/open_cable.png", 96, 24 },
{ "assets/generated/trade/cable_horiz.png", 160, 8 },
{ "assets/generated/trade/cable_conn.png", 8, 8 },
{ "assets/generated/trade/cable_seg.png", 8, 8 },
{ "assets/generated/trade/cable_corner.png", 8, 8 },
{ "assets/generated/trade/cable_end.png", 8, 8 },
{ "assets/generated/trade/cable_vert.png", 8, 8 },
{ "assets/generated/trade/cable_ball.png", 16, 16 },
{ "assets/generated/trade/cable_ball_alt.png", 16, 16 },
{ "assets/generated/trade/bubble.png", 16, 32 },
}
for _, spec in ipairs(FILES) do
local ok, image = pcall(love.graphics.newImage, spec[1])
if check(spec[1] .. " is in the cache", ok and image ~= nil) then
local w, h = image:getDimensions()
check(("%s is %dx%d"):format(spec[1], spec[2], spec[3]),
w == spec[2] and h == spec[3])
end
end
check("field.lua carries tradeArt paths",
game.data.field and game.data.field.tradeArt
and game.data.field.tradeArt.gameBoy ~= nil)
local function topIs(cls)
return getmetatable(game.stack:top()) == cls
end
-- pokered data/maps/objects/VermilionTradeHouse.asm: the LITTLE_GIRL
-- (SPEAROW -> DUX FARFETCH'D) stands at (3, 5) facing up, so (3, 6)
-- facing up puts the player in front of her.
game.save.party = { Pokemon.new(game.data, "SPEAROW", 10) }
U.teleport(game, "VERMILION_TRADE_HOUSE", 3, 6, "up")
U.wait(5)
U.tap(game, "a")
U.wait(10)
for _ = 1, 200 do
if topIs(ChoiceBox) then break end
U.tap(game, "a")
U.wait(2)
end
check("trade offer choice appeared", topIs(ChoiceBox))
U.tap(game, "a") -- YES
U.wait(6)
for _ = 1, 60 do
if topIs(PartyMenu) then break end
U.wait(1)
end
check("party menu opened", topIs(PartyMenu))
U.tap(game, "a") -- pick the SPEAROW
U.wait(6)
for _ = 1, 200 do
if topIs(TradeAnim) then break end
U.tap(game, "a")
U.wait(2)
end
local anim = game.stack:top()
if not check("TradeAnim is on the stack", getmetatable(anim) == TradeAnim) then
U.log("cannot reach the cinematic; nothing more to verify")
while true do coroutine.yield() end
end
-- the running state loaded the art rather than falling back
check("TradeAnim loaded the Game Boy plate", anim.img.gameBoy ~= nil)
check("TradeAnim loaded the open cable plate", anim.img.openCable ~= nil)
check("TradeAnim loaded the cable ball", anim.img.cableBall ~= nil)
check("TradeAnim loaded the bubble ring", anim.img.bubble ~= nil)
-- step the phases at real speed and shoot the moments the reporter's
-- video shows broken
local function ffUntil(phase, cap)
for _ = 1, cap or 3000 do
if anim.phase == phase or anim.phase == "done" then break end
if anim.waitingText or topIs(TextBox) then
U.wait(1)
else
anim:update(1 / 60)
end
end
U.wait(1)
end
ffUntil("open_cable", 800)
while anim.phase == "open_cable" and anim.scx > 0 do
anim:update(1 / 60)
end
U.wait(1)
U.shot(game, DIR .. "/bug750_open_cable.png")
ffUntil("ball_enter", 200)
while anim.phase == "ball_enter" and anim.ballX < 0x80 do
anim:update(1 / 60)
end
U.wait(1)
U.shot(game, DIR .. "/bug750_ball_enter.png")
ffUntil("transfer_lr", 400)
for _ = 1, 24 do anim:update(1 / 60) end
U.wait(1)
U.shot(game, DIR .. "/bug750_transfer_lr.png")
ffUntil("transfer_rl", 4000)
for _ = 1, 140 do
if anim.phase ~= "transfer_rl" then break end
anim:update(1 / 60)
end
U.wait(1)
U.shot(game, DIR .. "/bug750_transfer_rl.png")
U.log("shots in", DIR)
U.log("open_cable should be the link cable plate with its open end, not a")
U.log("blank screen; ball_enter a small ball riding the cable; the two")
U.log("transfer shots a real Game Boy body with the cable plugged in and")
U.log("the mon's 16x16 party icon inside a round 32x32 ring -- no outlined")
U.log("rectangles, no flat gray bar, no squashed battle pic.")
while true do
coroutine.yield()
end
end
@@ -0,0 +1,89 @@
-- Manual check that challenging a trainer by talking to them plays the
-- encounter sting (#764). TalkToTrainer (pokered home/trainers.asm:88)
-- prints the before-battle text and then EngageMapTrainer ->
-- PlayTrainerMusic; the port only did that on the sight-line path, so a
-- trainer approached from the side or back went into battle in map music.
-- POKEPORT_DRIVER=tests/drivers/trainer_fanfare_bug764_test.lua POKEPORT_IDENTITY=bug764 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
return function(game)
local U = dofile("tests/drivers/util.lua")
-- pokered data/maps/objects/ViridianForest.asm: YOUNGSTER2 (the first
-- Bug Catcher) stands at (30, 33) facing LEFT, so his sight line runs
-- west; the cell below him, (30, 34), is outside it and lets us talk
-- our way into the battle instead of being spotted.
local MAP = "VIRIDIAN_FOREST"
local TRAINER = "VIRIDIANFOREST_YOUNGSTER2"
local STAND = { x = 30, y = 34, facing = "up" }
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
return ok
end
-- record every song the engine starts; wrapping keeps real playback so
-- the human half of this check still has something to hear
local Music = require("src.core.Music")
local played = {}
local realPlay = Music.play
Music.play = function(data, song, ...)
played[#played + 1] = song
return realPlay(data, song, ...)
end
U.newGame(game)
check("music volume is audible (save.options.musicVol)",
(game.save.options.musicVol or 0) > 0)
U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing)
U.wait(30)
local ow = game.overworld
local npc
for _, n in ipairs(ow and ow.npcs or {}) do
if n.def and n.def.name == TRAINER then npc = n end
end
check("Bug Catcher object loaded on " .. MAP, npc ~= nil)
if npc then
check("standing on his blind side, facing him",
ow:npcAtCell(ow.player:facingCell()) == npc)
check("he did not spot us on the way in", not ow.engaging)
end
-- talk; the sting must start only once the before-battle text closes
-- (TalkToTrainer prints first, then engages)
played = {}
U.tap(game, "a")
U.wait(30)
check("no sting while the dialogue is up", #played == 0)
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
U.shot(game, SHOT_DIR .. "/bug764_dialogue.png")
-- close the text; a Bug Catcher is neither female-list nor evil-list,
-- so PlayTrainerMusic lands on the male sting. A presses both finish
-- the typewriter and turn pages, so keep tapping until the sting lands
-- or the drain gives up.
local sting
for _ = 1, 8 do
U.tap(game, "a")
U.wait(30)
for _, song in ipairs(played) do
if song:find("Music_Meet", 1, true) then sting = song end
end
if sting then break end
end
check("closing the text started an encounter sting", sting ~= nil)
check("it is the male trainer sting", sting == "Music_MeetMaleTrainer")
U.shot(game, SHOT_DIR .. "/bug764_transition.png")
U.log("songs started since the A press:", table.concat(played, ", "))
U.log("The Bug Catcher's line has just closed and the battle is opening.")
U.log("You should have heard the male trainer sting begin the moment the")
U.log("text box shut, carrying over the battle transition. Before #764")
U.log("the forest theme played straight through into the fight. To hear")
U.log("the sight path for comparison, lose or run, step west across his")
U.log("eyeline, and the same sting should fire once at the \"!\" bubble.")
while true do
coroutine.yield()
end
end
@@ -0,0 +1,81 @@
-- Manual check of the Viridian fisher's TM42 gift pre text (#775).
-- pokered ViridianCityFisherText (scripts/ViridianCity.asm) prints
-- .YouCanHaveThisText ("Yawn! I must have dozed off...") before GiveItem;
-- the port had no pre entry, so A jumped straight to "received TM42!".
-- POKEPORT_DRIVER=tests/drivers/viridian_fisher_bug775_test.lua POKEPORT_IDENTITY=bug775 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
return function(game)
local U = dofile("tests/drivers/util.lua")
-- pokered data/maps/objects/ViridianCity.asm: the FISHER stays at (6, 23)
-- facing down, so stand one cell below him and look up
local MAP = "VIRIDIAN_CITY"
local STAND = { x = 6, y = 24, facing = "up" }
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
return ok
end
U.newGame(game)
U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing)
U.wait(10)
local TextBox = require("src.render.TextBox")
local function boxText()
local top = game.stack:top()
if getmetatable(top) ~= TextBox then return nil end
local lines = {}
for _, page in ipairs(top.pages or {}) do
for _, line in ipairs(page) do lines[#lines + 1] = line end
end
return table.concat(lines, " / ")
end
check("no TM42 in the bag before talking",
(game.save.inventory.TM_DREAM_EATER or 0) == 0)
U.tap(game, "a")
U.wait(30)
local first = boxText()
check("pressing A opened a text box", first ~= nil)
U.log("first box reads:", first or "(none)")
check("it opens on the pre text, not the receipt",
first ~= nil and first:find("Yawn!", 1, true) ~= nil)
check("the DROWZEE dream paragraph is in it",
first ~= nil and first:find("DROWZEE", 1, true) ~= nil)
check("the receipt has not fired yet",
first == nil or first:find("received", 1, true) == nil)
check("the flag is still unset mid pre text",
not game.save.flags.EVENT_GOT_TM42)
U.shot(game, SHOT_DIR .. "/bug775_pre.png")
-- type out and dismiss every page of the pre text, then the receipt and
-- the explanation behind it
for _ = 1, 40 do
if not boxText() then break end
U.tap(game, "a")
U.wait(15)
end
check("TM42 reached the bag", (game.save.inventory.TM_DREAM_EATER or 0) == 1)
check("EVENT_GOT_TM42 is set", game.save.flags.EVENT_GOT_TM42 == true)
-- second talk: the flag routes to the DREAM EATER explanation, no re-gift
U.tap(game, "a")
U.wait(30)
local again = boxText()
U.log("second talk reads:", again or "(none)")
check("a second talk shows the explanation, not Yawn! again",
again ~= nil and again:find("Yawn!", 1, true) == nil)
U.shot(game, SHOT_DIR .. "/bug775_repeat.png")
U.log("The screen is on the fisher's repeat-visit line now. The first")
U.log("talk should have read three pages: Yawn / the DROWZEE dream /")
U.log("\"Here, you can have this TM.\", and only then the TM42 receipt.")
U.log("Shots are in " .. SHOT_DIR .. " as bug775_pre.png / bug775_repeat.png.")
while true do
coroutine.yield()
end
end
+13
View File
@@ -75,6 +75,19 @@ T.eq(Game.worldBgBattleDim(stack(overworld, battleBg("world"), partyMenu)),
T.eq(Game.worldBgBattleDim(stack(overworld)), nil, "no battle, no dim")
T.eq(Game.worldBgBattleDim(nil), nil, "and no stack is safe")
-- #773: the same walk decides whether the dark-cave shade shift may be armed
-- for this frame. A battle zeroes wMapPalOffset (init_battle_variables.asm),
-- so a world-bg battle over an un-flashed Rock Tunnel must suppress it.
T.check(Game.worldBgBattleInStack(stack(overworld, battleBg("world"))),
"a world-bg battle claims the frame, so the dark shift stays off it")
T.check(not Game.worldBgBattleInStack(stack(overworld, battleBg("white"))),
"a white-bg battle draws with no map under it and claims nothing")
T.check(Game.worldBgBattleInStack(stack(overworld, battleBg("world"), partyMenu)),
"a menu opened over the world-bg battle does not hand the shift back")
T.check(not Game.worldBgBattleInStack(stack(overworld)),
"a plain dark map still arms it")
T.check(not Game.worldBgBattleInStack(nil), "and no stack is safe")
T.check(BattleState.BG_WORLD_DIM > 0 and BattleState.BG_WORLD_DIM < 1,
"the dim is a fraction, not a full blackout")
@@ -0,0 +1,117 @@
-- #774: packager archive checks must never pipe an `unzip -Z1` listing
-- straight into `grep -q`. grep -q exits on the first match, unzip takes
-- SIGPIPE (141), and under the scripts' `set -o pipefail` the pipeline
-- reports 141 -- so an `if` guard reads a real match as "no match". For
-- build_android.sh's generated-data guard that failed open on exactly the
-- archive it exists to reject (one carrying the user's extracted ROM
-- data). The fix everywhere is to capture the listing once and grep the
-- captured text (build.sh, pack_love.sh, build_ios.sh, build_android.sh
-- all do); this suite keeps the class of bug from regressing. The
-- `unzip -p ... Version.lua | grep` readbacks are fine -- a 1.4 KB single
-- write fits the pipe buffer, so unzip returns before grep can close the
-- read end -- and the scan below deliberately matches only -Z1 listings.
-- Self-contained: luajit tests/engine/build_zip_pipe_guard_bug774.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check = T.check
local function readFile(path)
local f = io.open(path, "rb")
if not f then return nil end
local body = f:read("*a")
f:close()
return body
end
local function listShellFiles()
local out = {}
local p = io.popen('find scripts -name "*.sh" -type f')
if not p then return out end
for line in p:lines() do
out[#out + 1] = line
end
p:close()
table.sort(out)
return out
end
-- ------------------------------------------------------------- static scan
-- Join backslash continuations first: the buggy form in build_android.sh
-- spread the pipeline across two lines.
local scripts = listShellFiles()
check(#scripts > 0, "found shell scripts under scripts/ to scan")
local violations = {}
for _, file in ipairs(scripts) do
local body = readFile(file)
if body then
local joined = body:gsub("\\\n%s*", " ")
if joined:find("unzip %-Z1[^\n|]*|%s*grep %-%a*q") then
violations[#violations + 1] = file
end
end
end
check(#violations == 0,
"no script pipes an unzip -Z1 listing into grep -q (#774: pipefail turns"
.. " the SIGPIPE into an inverted guard)"
.. (#violations > 0 and (":\n " .. table.concat(violations, "\n ")) or ""))
-- --------------------------------------------------------- replay the guard
-- Run build_android.sh's own forbidden-content pattern, in the captured
-- form the script now uses, over a throwaway archive that really does
-- carry a data/generated entry, and over a clean one. This pins the
-- capture-then-grep idiom's behavior rather than trusting the scan alone.
local androidBody = readFile("scripts/build_android.sh") or ""
local pattern = androidBody:match("grep %-Eq '([^']*generated[^']*)'")
check(pattern ~= nil,
"build_android.sh still greps a generated-data pattern over the listing")
local function haveCommand(name)
local probe = io.popen("command -v " .. name .. " 2>/dev/null")
if not probe then return false end
local out = probe:read("*a")
probe:close()
return out ~= nil and out:match("%S") ~= nil
end
if pattern and haveCommand("zip") and haveCommand("unzip")
and haveCommand("bash") then
local tmpDir = (os.getenv("TMPDIR") or "/tmp"):gsub("[/\\]+$", "")
local stage = ("%s/pokeport_bug774_%d_%d"):format(
tmpDir, os.time(), math.random(1, 999999))
os.execute(('mkdir -p "%s/pay/data/generated"'):format(stage))
os.execute(('touch "%s/pay/data/generated/x.lua" "%s/pay/main.lua"')
:format(stage, stage))
os.execute(('cd "%s/pay" && zip -qr ../bad.love .'):format(stage))
os.execute(('cd "%s/pay" && zip -qr ../clean.love main.lua'):format(stage))
local function guardVerdict(archive)
-- exactly the script's shape: pipefail on, listing captured once,
-- grep runs over the captured text so nothing can take a SIGPIPE
local cmd = ("bash -c 'set -euo pipefail\n"
.. 'archive_entries="$(unzip -Z1 "%s")"\n'
.. "if grep -Eq '\\''%s'\\'' <<< \"$archive_entries\"; then"
.. " echo CAUGHT; else echo CLEAN; fi' 2>/dev/null"):format(
archive, pattern)
local p = io.popen(cmd)
if not p then return nil end
local out = p:read("*a") or ""
p:close()
return out:match("%S+")
end
check(guardVerdict(stage .. "/bad.love") == "CAUGHT",
"the captured-listing guard rejects an archive carrying data/generated"
.. " (#774: the piped form let this ship in an APK)")
check(guardVerdict(stage .. "/clean.love") == "CLEAN",
"the captured-listing guard passes an archive without generated data")
os.execute(('rm -rf "%s"'):format(stage))
else
print("[#774] zip/unzip/bash not all present: guard replay skipped,"
.. " the static scan above still ran")
end
T.finish()
+2 -2
View File
@@ -1,8 +1,8 @@
-- Launcher Delete affordance (src/import/RomImporter.lua): the two-click arm
-- that guards both save-slot and mod deletes (#433). Every Delete control in
-- the FlexLove view routes through RomImporter:pressDelete, and every other
-- queued action clears self._confirmDelete (LauncherView's queueAction), so
-- the guarantees live on this seam: the first press only arms, the second
-- queued action clears self._confirmDelete (RomImporter:runActions as the
-- batch drains, #780), so the guarantees live on this seam: the first press only arms, the second
-- press on the SAME target commits, any other target or a cleared arm asks
-- again, and a stale arm expires instead of committing much later.
-- luajit tests/engine/launcher_delete_confirm.lua
+43
View File
@@ -328,4 +328,47 @@ do
eq(bad.name, "abc", "surrogates and overlongs are dropped")
end
-- ------- pre-boot translation strings (deriveStrings)
--
-- The launcher draws before Game:load, so a translation mod's catalog has to
-- reach Strings without the loader running. These are the rules that decide
-- what it may contribute, with the filesystem read injected.
do
local manifests = {
{ id = "aaa", name = "A", version = "1.0.0", path = "mods/aaa" },
{ id = "zzz", name = "Z", version = "1.0.0", path = "mods/zzz" },
}
local catalogs = {
["mods/aaa"] = { ["Import ROM"] = "A-rom", ["Delete"] = "A-del",
["Cancel"] = "" },
["mods/zzz"] = { ["Import ROM"] = "Z-rom" },
}
local function read(path) return catalogs[path] end
local function byIdMap(ms)
local m = {}
for _, x in ipairs(ms) do m[x.id] = x end
return m
end
local rows = LauncherMods.deriveList(manifests, { mods = {} })
local merged = LauncherMods.deriveStrings(rows, byIdMap(manifests), read)
eq(merged["Delete"], "A-del", "an enabled mod contributes its catalog")
eq(merged["Import ROM"], "Z-rom",
"later id wins a shared key, as it would at boot")
eq(merged["Cancel"], nil,
"an empty value is untranslated, never a blank translation")
local offRows = LauncherMods.deriveList(manifests, { mods = { zzz = false } })
local off = LauncherMods.deriveStrings(offRows, byIdMap(manifests), read)
eq(off["Import ROM"], "A-rom", "a disabled mod contributes nothing")
local none = LauncherMods.deriveStrings(
LauncherMods.deriveList(manifests, { mods = { aaa = false, zzz = false } }),
byIdMap(manifests), read)
eq(none, nil, "no enabled catalog leaves the launcher on its English source")
eq(LauncherMods.deriveStrings(rows, byIdMap(manifests), function() return nil end),
nil, "a mod that ships no catalog is skipped, not an error")
end
T.finish("launcher_mods")
@@ -0,0 +1,102 @@
-- Launcher action drain (src/import/RomImporter.lua): on a phone one tap
-- lands on a save row AND on the chip drawn inside it, because FlexLove's
-- touch path has no topmost gate (EventHandler:processTouchEvents) while its
-- mouse path does. The drain therefore drops a row's own action when a
-- control inside that row fired in the same batch, and applies #433's disarm
-- as it runs each action rather than as the view queues them -- otherwise the
-- row's select cleared the arm the same tap had set and Delete never reached
-- its second press (#780).
-- luajit tests/engine/launcher_touch_dispatch_bug780.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 clock = 1000
love.timer.getTime = function() return clock end
local RomImporter = require("src.import.RomImporter")
local function launcher()
local self = setmetatable({}, RomImporter)
self.ran = {}
return self
end
local function tapDeleteChip(self, rowKey)
-- what one Android tap on a row's Delete chip queues: the row itself, then
-- the chip drawn on top of it
return {
{ key = rowKey, keepArm = false, fn = function()
table.insert(self.ran, "select")
end },
{ key = rowKey .. "-del", keepArm = true, fn = function()
table.insert(self.ran, "delete")
self:pressDelete("slot", "slot2", "red", function()
table.insert(self.ran, "deleted")
end)
end },
}
end
-- ------- the chip wins the tap, and two taps delete
do
local self = launcher()
self:runActions(tapDeleteChip(self, "slot-red-slot2"))
eq(self.ran[1], "delete", "the chip's action runs, not the row's select")
eq(#self.ran, 1, "the row behind the chip is dropped from the batch")
check(self._confirmDelete ~= nil, "the first tap leaves Delete armed")
self:runActions(tapDeleteChip(self, "slot-red-slot2"))
eq(self.ran[3], "deleted", "the second tap on the same chip commits")
eq(self._confirmDelete, nil, "the arm is spent")
end
-- ------- a tap on the row itself still selects, and still disarms
do
local self = launcher()
self:pressDelete("slot", "slot2", "red", function() end)
check(self._confirmDelete ~= nil, "armed")
self:runActions({
{ key = "slot-red-slot2", keepArm = false, fn = function()
table.insert(self.ran, "select")
end },
})
eq(self.ran[1], "select", "a tap on empty row area selects the slot")
eq(self._confirmDelete, nil, "and disarms the pending Delete (#433)")
end
-- ------- a sibling row's chip does not swallow another row
do
local self = launcher()
self:runActions({
{ key = "slot-red-slot1", keepArm = false, fn = function()
table.insert(self.ran, "row1")
end },
{ key = "slot-red-slot10-del", keepArm = true, fn = function()
table.insert(self.ran, "del10")
end },
})
eq(self.ran[1], "row1", "slot1 is not a prefix-key parent of slot10")
eq(self.ran[2], "del10", "and slot10's chip still runs")
end
-- ------- a failing action does not sink the rest of the batch
do
local self = launcher()
self:runActions({
{ key = "a", keepArm = false, fn = function() error("boom") end },
{ key = "b", keepArm = false, fn = function()
table.insert(self.ran, "b")
end },
})
eq(self.ran[1], "b", "the queue drains past a handler that threw")
end
print("launcher touch dispatch (#780) ok")
+21
View File
@@ -110,6 +110,27 @@ do
"the release asset URL survives parsing")
eq(m.permissions[1], "engine_internals", "permissions are kept")
eq(m.update_check, "ok", "update_check is kept")
check(m.downloads == nil and m.first_release == nil and m.last_release == nil,
"a feed without release stats parses them as absent")
end
-- release stats a feed can publish: total downloads and first/last dates
-- ride along additively, so a feed carrying them stays readable by every
-- build that predates them
do
local withStats = {}
for k, v in pairs(NUZLOCKE) do withStats[k] = v end
withStats.downloads = 1234
withStats.first_release = "2024-05-31"
withStats.last_release = "2026-07-01"
local index = ModIndex.parse(feed({ withStats }))
local m = index.mods[1]
eq(m.downloads, 1234, "total downloads are kept")
eq(m.first_release, "2024-05-31", "first release date is kept")
eq(m.last_release, "2026-07-01", "last release date is kept")
withStats.downloads = "9999"
m = ModIndex.parse(feed({ withStats })).mods[1]
eq(m.downloads, 9999, "numeric-string downloads are coerced")
end
-- schema_version is a contract, not a hint: an unknown one is refused rather
+35
View File
@@ -183,6 +183,22 @@ eq(ModUpdate.formatCount("12345"), "12,345", "numeric strings are accepted")
eq(ModUpdate.formatCount(nil), "0", "nil formats as zero")
eq(ModUpdate.formatCount("garbage"), "0", "garbage formats as zero")
-- statsLine: the shared launcher row line, part by part
eq(ModUpdate.statsLine(1234567, "2024-05-31", "2026-07-01"),
"1,234,567 downloads across all releases - Released 2024-05-31 - Updated 2026-07-01",
"full stats line")
eq(ModUpdate.statsLine(82, nil, nil),
"82 downloads across all releases",
"downloads alone")
eq(ModUpdate.statsLine(nil, "2024-05-31", "2026-07-01"),
"Released 2024-05-31 - Updated 2026-07-01",
"dates alone")
eq(ModUpdate.statsLine(0, nil, nil),
"0 downloads across all releases",
"a real zero still shows")
check(ModUpdate.statsLine(nil, nil, nil) == nil,
"no data at all means no line")
-- cacheUsable: a cache entry from before downloads existed must not be
-- trusted, everything current is
do
@@ -237,4 +253,23 @@ do
HostShell.canFetch, HostShell.httpGet = realCanFetch, realHttpGet
end
-- statsForReleases: one resolver over a release list, the FIND MODS path
do
local stats = ModUpdate.statsForReleases({
{ version = "1.0.0", downloads = 41, published = "2024-05-31" },
{ version = "1.1.0", downloads = 9, published = "2025-11-02" },
})
eq(stats.total, 50, "total downloads across releases")
eq(stats.first, "2024-05-31", "first release date")
eq(stats.latest, "2025-11-02", "latest release date")
check(ModUpdate.statsForReleases({ { version = "1.0.0" } }) == nil,
"a list with neither counts nor dates resolves to nil")
check(ModUpdate.statsForReleases(nil) == nil, "nil resolves to nil")
local datesOnly = ModUpdate.statsForReleases({
{ version = "1.0.0", published = "2024-05-31" },
})
eq(datesOnly.total, nil, "dates without counts keep total nil")
eq(datesOnly.first, "2024-05-31", "but keep the date")
end
print("ok mod_update_tests")
@@ -0,0 +1,92 @@
-- Portable-mode save export (#752): with portable.txt beside the game the
-- launcher's Export save must land in the game folder, never in the OS save
-- directory LOVE hands out. SaveConvert is stubbed so this stays ROM-free;
-- what is under test is only which filesystem SaveFileIO.exportActiveSlot
-- writes through and which root the returned path reports.
-- luajit tests/engine/save_export_portable_bug752.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 SEP = package.config:sub(1, 1)
local tmp = os.getenv("TMPDIR") or os.getenv("TEMP") or "/tmp"
tmp = tmp:gsub("[/\\]$", "")
local base = tmp .. SEP .. "pokeport752"
if SEP == "\\" then
os.execute('rmdir /s /q "' .. base .. '" 2>nul')
os.execute('mkdir "' .. base .. '" 2>nul')
else
os.execute('rm -rf "' .. base .. '" && mkdir -p "' .. base .. '"')
end
local marker = io.open(base .. SEP .. "portable.txt", "wb")
check(marker ~= nil, "the temp portable folder is writable")
if not marker then T.finish("save_export_portable_bug752") return end
marker:write("portable\n")
marker:close()
-- The love surface portable detection reads: a desktop OS plus a source
-- folder holding the marker (SaveData.gameFolders). A memfs stands in for
-- the OS save directory so a stray write there is visible in the test rather
-- than silently landing on the real machine.
local strayFiles = {}
love.system = love.system or {}
love.system.getOS = function() return "Linux" end
love.filesystem = {
getSource = function() return base end,
getSourceBaseDirectory = function() return base end,
getSaveDirectory = function() return "/fake/save" end,
getInfo = function() return nil end,
read = function() return nil end,
write = function(path, content) strayFiles[path] = content return true end,
remove = function() return true end,
createDirectory = function() return true end,
}
-- SaveConvert stubbed before SaveFileIO requires it: the codec needs
-- data/generated/ crosswalks, and the byte content is irrelevant here.
package.loaded["src.save_convert.SaveConvert"] = {
SAVE_SIZE = 32768,
exportSav = function() return string.rep("\0", 32768) end,
importSav = function() return nil, "not used here" end,
}
local SaveData = require("src.core.SaveData")
local GameVersion = require("src.core.GameVersion")
GameVersion.set("red")
check(SaveData.isPortable(), "portable.txt beside the game turns portable mode on")
local slotId = SaveData.createSlot("red")
check(slotId ~= nil, "a slot registers in the portable folder")
SaveData.setActiveSlot("red", slotId)
check(SaveData.writeSlot("red", slotId, SaveData.newGame()), "the slot writes")
local SaveFileIO = require("src.import.SaveFileIO")
local ok, path = SaveFileIO.exportActiveSlot("red")
eq(ok, true, "exportActiveSlot succeeds in portable mode")
local expected = base .. SEP .. "exports" .. SEP .. "red" .. SEP
.. "gen1recomp-red-" .. tostring(slotId) .. ".sav"
eq(path, expected, "the reported path is inside the portable game folder")
local f = io.open(expected, "rb")
check(f ~= nil, "the export file exists in the portable exports/ folder")
if f then
local bytes = f:read("*a")
f:close()
eq(#bytes, 32768, "the export is exactly 32768 bytes")
end
for name in pairs(strayFiles) do
check(not name:find("^exports"),
"no export leaked into the OS save directory: " .. name)
end
if SEP == "\\" then
os.execute('rmdir /s /q "' .. base .. '" 2>nul')
else
os.execute('rm -rf "' .. base .. '"')
end
T.finish("save_export_portable_bug752")
+3 -1
View File
@@ -235,8 +235,10 @@ local mid, done, tpopped = 0, 0, 0
local g5 = { data = Data, save = SaveData.newGame() }
g5.stack = { push = function() end, pop = function() tpopped = tpopped + 1 end,
top = function() return nil end }
-- warp = true: the map-change shape with no fade back in. Script fades
-- (ViridianGym.asm .afterBeat) keep the symmetric GBFadeInFromBlack.
local fade = Transition.new(g5, function() mid = mid + 1 end,
function() done = done + 1 end)
function() done = done + 1 end, true)
local f = 0
while done == 0 and f < 500 do
+28 -8
View File
@@ -15,21 +15,41 @@ local check, eq, same = T.check, T.eq, T.same
-- endFrame's blit closure, which needs canvases and a compiled shader. The
-- source is loaded directly so the rect arithmetic can be exercised with no
-- GPU, the same way parity_picker_pointer_grab reads RomImporter (#254).
local scissorClamped, captured
do
local captured
local function loadScissor(loveMajor)
local f = io.open("src/render/Renderer.lua", "rb")
check(f ~= nil, "Renderer source is readable")
local src = f and f:read("*a") or ""
if f then f:close() end
local bias = src:match("\nlocal SCISSOR_PIXEL_BIAS = 0%.5.-\nend\n")
check(bias ~= nil, "scissor bias is still version-gated")
local body = src:match("\nlocal function scissorClamped.-\nend\n")
check(body ~= nil, "scissorClamped is still a single local function")
local fakeLove = { graphics = { setScissor = function(x, y, w, h)
captured = { x = x, y = y, w = w, h = h }
end } }
local chunk = assert(loadstring("local love = ...\n" .. (body or "")
local fakeLove = {
getVersion = function() return loveMajor, 0, 0 end,
graphics = { setScissor = function(x, y, w, h)
captured = { x = x, y = y, w = w, h = h }
end },
}
local chunk = assert(loadstring("local love = ...\n" .. (bias or "")
.. (body or "")
.. "\nreturn scissorClamped"))
scissorClamped = chunk(fakeLove)
check(type(scissorClamped) == "function", "scissorClamped loads standalone")
local scissor = chunk(fakeLove)
check(type(scissor) == "function", "scissorClamped loads standalone")
return scissor
end
local scissorClamped = loadScissor(11)
-- LÖVE 12 changed setScissor from truncating Lua integers to accepting floats
-- and rounding in the backend. Its arguments must therefore describe the
-- already-snapped rectangle exactly, without LÖVE 11's half-pixel nudge.
do
local scissor12 = loadScissor(12)
captured = nil
check(scissor12(50, 60, 10, 20, 0, 0, 100, 100, 2, 2),
"LÖVE 12 integer test rect draws")
same(captured, { x = 50, y = 60, w = 10, h = 20 },
"LÖVE 12 receives an unbiased snapped scissor")
end
-- The title's three SGB zones in canvas pixels (PaletteFX.zone turns the
+98
View File
@@ -0,0 +1,98 @@
-- The trade cinematic's Game Boy / cable / ball / bubble art must come out
-- of the ROM importer, not just the developer-only Python path (#750).
-- RomExtractor:extractTradeArt reads five symbols -- gfx/trade.asm
-- TradingAnimationGraphics(+2), engine/gfx/mon_icons.asm TradeBubbleIconGFX,
-- and the data/tilemaps.asm GameBoyTiles / LinkCableTiles id lists -- so
-- every shipped manifest has to carry them, the manifest generator has to
-- keep them on a regen, and RomImporter has to force pre-#750 caches to
-- re-import. Addresses below were byte-verified against the canonical
-- Red/Blue/Yellow ROMs (each payload occurs exactly once) and match
-- pokered.sym / pokeblue.sym.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local function readFile(path)
local handle = io.open(path, "r")
if not handle then return nil end
local text = handle:read("*a")
handle:close()
return text
end
-- Red and Blue place the trade art identically; Yellow shifted it.
local RED_BLUE = {
GameBoyTiles = { 30, 23584 }, -- 1e:5c20
LinkCableTiles = { 30, 23632 }, -- 1e:5c50
TradeBubbleIconGFX = { 28, 23129 }, -- 1c:5a59
TradingAnimationGraphics = { 14, 27070 }, -- 0e:69be
TradingAnimationGraphics2 = { 14, 27854 }, -- 0e:6cce
}
local YELLOW = {
GameBoyTiles = { 30, 23932 },
LinkCableTiles = { 30, 23980 },
TradeBubbleIconGFX = { 28, 23302 },
TradingAnimationGraphics = { 14, 27240 },
TradingAnimationGraphics2 = { 14, 28024 },
}
local MANIFESTS = {
{ "tools/rom_manifest.json", RED_BLUE },
{ "tools/rom_manifest_blue.json", RED_BLUE },
{ "tools/rom_manifest_yellow.json", YELLOW },
}
for _, spec in ipairs(MANIFESTS) do
local path, expected = spec[1], spec[2]
local text = readFile(path)
T.check(text ~= nil, path .. " is readable")
if text then
for name, location in pairs(expected) do
local bank, addr = text:match(
'"' .. name .. '"%s*:%s*%[%s*(%d+)%s*,%s*(%d+)%s*%]')
T.eq(bank, tostring(location[1]),
path .. ": " .. name .. " bank")
T.eq(addr, tostring(location[2]),
path .. ": " .. name .. " address")
end
end
end
-- the manifests are generated, so the generator has to keep asking for the
-- symbols or the next regen silently drops the trade art again
local gen = readFile("tools/make_rom_manifest.py")
T.check(gen ~= nil, "tools/make_rom_manifest.py is readable")
if gen then
for name in pairs(RED_BLUE) do
T.check(gen:find('"' .. name .. '"', 1, true) ~= nil,
"a regenerated manifest keeps " .. name)
end
end
-- extractTradeArt exists, extractField calls it, and the field table
-- publishes the paths the same way the Python path's field.py does, so
-- TradeAnim's `game.data.field.tradeArt` lookup lands on both build paths
local extractor = readFile("src/import/RomExtractor.lua")
T.check(extractor ~= nil, "src/import/RomExtractor.lua is readable")
if extractor then
T.check(extractor:find("function RomExtractor:extractTradeArt", 1, true) ~= nil,
"RomExtractor has extractTradeArt")
T.check(extractor:find("self:extractTradeArt()", 1, true) ~= nil,
"extractField runs it")
T.check(extractor:find("data.tradeArt = tradeArt", 1, true) ~= nil,
"field.lua publishes tradeArt")
end
-- a cache imported before #750 has none of the art; listing one of the
-- files in REQUIRED_FILES is what makes it re-import
local importer = readFile("src/import/RomImporter.lua")
T.check(importer ~= nil, "src/import/RomImporter.lua is readable")
if importer then
local required = importer:match("local REQUIRED_FILES = {(.-)\n}")
T.check(required ~= nil, "REQUIRED_FILES parses")
T.check(required ~= nil and required:find(
'"assets/generated/trade/game_boy.png"', 1, true) ~= nil,
"REQUIRED_FILES makes pre-#750 caches re-import the trade art")
end
T.finish("trade art import")
+104
View File
@@ -0,0 +1,104 @@
-- Talking a trainer into battle must start the encounter sting (#764).
-- TalkToTrainer (pokered home/trainers.asm:88) prints the before-battle
-- text and then `call EngageMapTrainer` -> PlayTrainerMusic
-- (home/trainers.asm:399): evil list, female list, male by default, rivals
-- excluded. The port only ran the sting on the sight-line path
-- (startTrainerApproach), so a trainer challenged from the side or back --
-- and every scripted battle routed through ow:engageTrainer -- went into
-- the battle in map music. Asserts the talk path now plays the class
-- sting, skips rivals, and does not restart it when the sight path
-- (self.engaging) already did.
-- ROM-free: stubs Game/TextBox/BattleState/Music around engageTrainer.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local OW = require("src.world.OverworldController")
local function setUpvalue(fn, name, val)
local i = 1
while true do
local n = debug.getupvalue(fn, i)
if not n then return false end
if n == name then debug.setupvalue(fn, i, val); return true end
i = i + 1
end
end
local pushed = {}
local stackStub = {
push = function(_, item) pushed[#pushed + 1] = item end,
}
local textBoxStub = {
new = function(_, text, onDone) return { text = text, onDone = onDone } end,
substitute = function(_, text) return text end,
}
-- Music / BattleState are required lazily at the call site; stub via
-- package.loaded (same trick as tests/engine/oaks_pc_flow.lua)
local plays = {}
local realMusic = package.loaded["src.core.Music"]
package.loaded["src.core.Music"] = {
play = function(_, song) plays[#plays + 1] = song end,
}
local realBattle = package.loaded["src.battle.BattleState"]
package.loaded["src.battle.BattleState"] = {
newTrainer = function() return {} end,
}
local fakeGame = {
data = {
text = {},
trainerHeader = function() return nil end,
resolveText = function() return "You looked at me\nfunny!" end,
},
stack = stackStub,
}
T.check(setUpvalue(OW.engageTrainer, "Game", fakeGame),
"Game upvalue on engageTrainer")
T.check(setUpvalue(OW.engageTrainer, "TextBox", textBoxStub),
"TextBox upvalue on engageTrainer")
-- pushBattle would touch the real stack machinery; the engagement is over
-- by the time it runs, so a no-op keeps the test on the music question
local fakeSelf = setmetatable({
map = { def = { label = "Route24" } },
pushBattle = function() end,
}, { __index = OW })
-- run engageTrainer for one class and return what the sting played
local function stingFor(cls, engaging)
pushed, plays = {}, {}
fakeSelf.engaging = engaging
fakeSelf:engageTrainer({ id = "npc#1", def = { trainerClass = cls,
trainerParty = 1, index = 1 } })
T.eq(#pushed, 1, "engageTrainer pushes the before-battle text")
T.eq(#plays, 0, "no sting while the dialogue is still up (" .. cls .. ")")
pushed[1].onDone() -- close the box; TalkToTrainer engages here
return plays[1], #plays
end
-- PlayTrainerMusic's three buckets (data/trainers/encounter_types.asm)
T.eq(stingFor("OPP_LASS"), "Music_MeetFemaleTrainer",
"female-list class plays the female sting")
T.eq(stingFor("OPP_ROCKET"), "Music_MeetEvilTrainer",
"evil-list class plays the evil sting")
T.eq(stingFor("OPP_YOUNGSTER"), "Music_MeetMaleTrainer",
"any other class defaults to the male sting")
-- the rivals `ret z` out of PlayTrainerMusic; their scripts run
-- MUSIC_MEET_RIVAL themselves (data/scripts/oaks_lab.lua)
local _, rivalCount = stingFor("OPP_RIVAL1")
T.eq(rivalCount, 0, "rival classes play no encounter sting here")
-- sight path already engaged: TrainerEngage started the sting before the
-- "!" bubble, and TalkToTrainer's BIT_SEEN_BY_TRAINER guard keeps the
-- talk path from restarting it
local _, seenCount = stingFor("OPP_LASS", true)
T.eq(seenCount, 0, "self.engaging suppresses a second sting")
if realMusic ~= nil then package.loaded["src.core.Music"] = realMusic
else package.loaded["src.core.Music"] = nil end
if realBattle ~= nil then package.loaded["src.battle.BattleState"] = realBattle
else package.loaded["src.battle.BattleState"] = nil end
T.finish("trainer_talk_sting_bug764")
+43
View File
@@ -53,6 +53,42 @@ T.eq(#Font.encode("'d"), 1, "and still consumes the whole sequence")
T.eq(Font.encode("\227\129\130")[1], BASE + 0x3042,
"kana with no charmap entry at all still gets a glyph")
-- ------------------------------------------------- ttf.tiles opts back out
-- A CJK translation sizes the font so a kana fills the 8px cell, which leaves
-- Latin narrower than the tile font it replaces and pulls the numeric columns
-- (the party menu's ":L12" over "34/ 34") out of line. ttf.tiles names the
-- characters that keep their ROM tile anyway, so numbers stay identical to the
-- English build while kana still come from the font.
Font.load({ font = { charmap = CHARMAP, ttf = { tiles = "A" } } })
T.eq(Font.encode("A")[1], 0x80, "a character in ttf.tiles keeps its tile glyph")
T.eq(Font.advanceOf(0x80), 8, "and its 8px monospace advance with it")
T.eq(Font.encode("\195\169")[1], BASE + 0xE9,
"while everything else still routes to the TTF")
-- a list form, for naming a multi-character sequence explicitly
Font.load({ font = { charmap = CHARMAP, ttf = { tiles = { "\195\169" } } } })
T.eq(Font.encode("\195\169")[1], 0xBA,
"the list form accepts a multi-byte character")
T.eq(Font.encode("A")[1], BASE + 65, "and leaves the others on the TTF")
Font.load({ font = { charmap = CHARMAP, ttf = {} } })
T.eq(Font.encode("A")[1], BASE + 65, "no tiles list means the TTF takes it back")
-- ------------------------------------------------- drawBox restores color
-- drawBox fills its interior white and used to leave the color that way.
-- Tile pages are black glyphs on transparent, so they draw black whatever the
-- color is and the leak stayed invisible for as long as every glyph was a
-- tile. TTF text draws in the current color, so every label printed after a
-- box came out white on white -- the summary screen lost ATTACK/DEFENSE/
-- SPEED/SPECIAL and TYPE1/TYPE2 while the numbers beside them survived.
love.graphics.setColor(0, 0, 0, 1)
Font.drawBox(0, 0, 4, 4)
local r, g, b, a = love.graphics.getColor()
T.eq(("%s,%s,%s,%s"):format(r, g, b, a), "0,0,0,1",
"drawBox leaves the caller's color alone")
-- metrics come straight from the font object (the stub: half the point
-- size per codepoint, doubled from U+1000 up, mimicking Plain Pixel's
-- single/double width split)
@@ -161,6 +197,13 @@ do
T.check(select(1, Schemas.check(spec, "font", "ttf",
{ file = "mods/x/f.ttf", size = 11, spacing = 1, yOffset = -3 })) == true,
"so is a fully tuned entry")
T.check(select(1, Schemas.check(spec, "font", "ttf",
{ size = 10, tiles = "0123456789/:" })) == true,
"tiles takes a string of characters")
T.check(select(1, Schemas.check(spec, "font", "ttf",
{ tiles = { "0", "<PK>" } })) == true, "or a list of charmap sequences")
T.check(Schemas.check(spec, "font", "ttf", { tiles = 10 }) == nil,
"but not a number")
T.check(Schemas.check(spec, "font", "ttf", { image = "x.png", base = 0x100 })
== nil, "a page payload under the ttf id is refused")
T.check(Schemas.check(spec, "font", "page", {}) == nil,
@@ -0,0 +1,89 @@
-- Headless regression: the Viridian fisher's TM42 gift skipped his pre
-- text and jumped straight to "received TM42!" (#775). pokered's
-- ViridianCityFisherText (scripts/ViridianCity.asm) prints
-- .YouCanHaveThisText before GiveItem; on Red that label sits outside the
-- extractor's symbol set (no leading underscore, same class as the
-- SilphCo2F worker in #393), so the ported literal has to carry the flow
-- when the text table has no entry. ROM-free: the gift closure only
-- touches text/items/flags, so TextBox, Sound and Bag are stubbed and the
-- boxes are advanced by hand.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
-- story5's gift() requires these at call time, so preloading stubs is
-- enough; each tier suite is its own process, nothing leaks
local boxes = {}
package.loaded["src.render.TextBox"] = {
new = function(_, s, done) return { text = s, onDone = done } end,
}
package.loaded["src.core.Sound"] = { play = function() end }
package.loaded["src.inventory.Bag"] = {
add = function(save, item, n)
save.inventory[item] = (save.inventory[item] or 0) + n
return true
end,
}
local story5 = require("data.scripts.story5")
local fisher = story5.VIRIDIAN_CITY.talk.TEXT_VIRIDIANCITY_FISHER
T.check(type(fisher) == "function", "the fisher talk entry is a gift closure")
local function newGame(textTable)
boxes = {}
return {
data = {
text = textTable,
items = { TM_DREAM_EATER = { name = "TM42" } },
},
save = {
flags = {}, inventory = {}, player = { name = "RED" },
},
stack = {
push = function(_, box) boxes[#boxes + 1] = box end,
},
}
end
-- Red-like: empty text table, the fallback literal must carry the scene
local game = newGame({})
local finished = false
fisher(game, nil, nil, function() finished = true end)
T.eq(#boxes, 1, "talking opens exactly one box before any A press")
local pre = boxes[1].text
T.check(type(pre) == "string" and pre:sub(1, 5) == "Yawn!",
"the first box is the fisher's pre text, not the receipt")
T.check(pre:find("DROWZEE", 1, true) ~= nil,
"the fallback carries the DROWZEE dream paragraph")
T.check(pre:find("have this TM.", 1, true) ~= nil,
"and ends on the hand-over line")
T.check(not game.save.flags.EVENT_GOT_TM42,
"the flag stays unset until the pre text is dismissed")
boxes[1].onDone()
T.eq(#boxes, 2, "dismissing the pre text opens the received box")
T.eq(boxes[2].text, "RED received\nTM42!",
"the received fallback is filled with player and item")
T.eq(game.save.inventory.TM_DREAM_EATER, 1, "TM42 reached the bag")
T.check(game.save.flags.EVENT_GOT_TM42 == true, "the event flag is set")
boxes[2].onDone()
T.eq(#boxes, 3, "the explanation box follows the receipt")
boxes[3].onDone()
T.check(finished, "the talk chain hands control back")
-- Yellow-like: the extracted string exists, so it wins over the fallback
game = newGame({ ViridianCityFisherYouCanHaveThisText = "ROM STRING" })
fisher(game, nil, nil, function() end)
T.eq(boxes[1].text, "ROM STRING",
"an extracted ViridianCityFisherYouCanHaveThisText beats the fallback")
-- repeat visit: the flag routes straight to the explanation, no re-gift
game = newGame({ _ViridianCityFisherTM42ExplanationText = "EXPLAIN" })
game.save.flags.EVENT_GOT_TM42 = true
fisher(game, nil, nil, function() end)
T.eq(#boxes, 1, "a second talk opens a single box")
T.eq(boxes[1].text, "EXPLAIN", "and it is the TM42 explanation")
T.eq(game.save.inventory.TM_DREAM_EATER, nil, "no duplicate TM42")
T.finish("viridian_fisher_pre_bug775")
+7
View File
@@ -348,6 +348,7 @@ function ImageData:getDimensions() return self.w, self.h end
function ImageData:getPixel() return 0, 0, 0, 1 end
function ImageData:setPixel() end
function ImageData:mapPixel() end
function ImageData:paste() end
function ImageData:encode() return { getString = function() return "" end } end
stub.image = {
@@ -362,6 +363,12 @@ stub.image = {
end,
}
-- Headless runs report the desktop OS so platform gates (GamepadMap's NX
-- check, the touch-overlay filter) take their desktop branch.
stub.system = {
getOS = function() return "OS X" end,
}
-- Desktop / headless: full-window safe area (matches LÖVE's fallback).
stub.window = {
getSafeArea = function()
+2 -1
View File
@@ -52,7 +52,8 @@ local function makeGame(party)
function stack:pop() return table.remove(self.states) end
function stack:top() return self.states[#self.states] end
return { data = Data, save = save, stack = stack,
input = { wasPressed = function() return true end } }
input = { wasPressed = function() return true end,
isDown = function() return true end } }
end
local function pump(battle, limit)
+13 -7
View File
@@ -612,9 +612,13 @@ check(PaletteFX.usesGbcPack(), "redpp mode selects the gbc pack")
local gbc = PaletteFX.gbcPack()
check(gbc ~= nil and gbc.palettes.BULBASAUR ~= nil,
"data/palettes_gbc.lua ships per-species pals")
check(PaletteFX.monPalName({ palettes = nil }, "BULBASAUR") == "BULBASAUR",
-- the pack's species map follows pokered-gbc's Gen 1 (non-GEN_2_GRAPHICS)
-- palette assignments -- data/pokemon/palettes.asm ELSE branch -- so
-- Bulbasaur wears GREENMON, not a per-species PAL_BULBASAUR authored for
-- Gen 2 sprite art (see the pokemon table comment in data/palettes_gbc.lua)
check(PaletteFX.monPalName({ palettes = nil }, "BULBASAUR") == "GREENMON",
"RED++ monPalName resolves to the species palette id")
check(PaletteFX.monPal({ palettes = nil }, "BULBASAUR") == gbc.palettes.BULBASAUR,
check(PaletteFX.monPal({ palettes = nil }, "BULBASAUR") == gbc.palettes.GREENMON,
"RED++ monPal reads the species colors without a ROM pack")
check(PaletteFX.pal({ palettes = nil }, "ROUTE") == gbc.palettes.ROUTE,
"RED++ still has ROUTE (aliased from VIRIDIAN)")
@@ -813,9 +817,9 @@ do
Renderer:beginWorldPass()
Renderer:endWorldPass()
wipe:draw()
check(Renderer.battleCascadeProg ~= nil
and Renderer.battleCascadeProg > 0
and Renderer.battleCascadeProg < 1,
check(Renderer.battleWipe ~= nil
and Renderer.battleWipe.prog > 0
and Renderer.battleWipe.prog < 1,
"battle wipe publishes mid-progress cascade to the renderer")
rects = {}
Renderer:endFrame(nil, fullWorldZones())
@@ -911,7 +915,8 @@ local vanilla = BattleTransition.new({ stack = stack }, nil,
{ trainer = true, stronger = true })
check(vanilla.style == "spiralout",
"the vanilla 3-bit select is the hook's default (trainer+stronger)")
check(vanilla.wipeLen == 40, "the selected wipe brings its own length")
check(vanilla.wipeLen == BattleTransition.STYLES.spiralout.frames,
"the selected wipe brings its own length")
local savedRuntime = { events = Runtime.events, hooks = Runtime.hooks,
errors = Runtime.errors }
@@ -924,7 +929,8 @@ hooks:wrap("transition.style", function(nextLink, ctx)
end, 0, "test")
local hooked = BattleTransition.new({ stack = stack }, nil, { trainer = true })
check(hooked.style == "hstripes", "a transition.style hook picks the wipe")
check(hooked.wipeLen == 24, "the hooked style brings its own length")
check(hooked.wipeLen == BattleTransition.STYLES.hstripes.frames,
"the hooked style brings its own length")
check(seenCtx.trainer == true and seenCtx.stronger == nil,
"the hook receives the selection bits as context")
+7
View File
@@ -124,6 +124,12 @@ end
do
Zoom.reset()
-- Zoom.reset() clears only the offset: allowSurvey is the performance
-- tier's clamp (Game:applyOptions), and an earlier suite in the same
-- process may have applied a LOW tier. Pin the vanilla precondition and
-- restore whatever the run had afterwards.
local savedSurvey = Zoom.allowSurvey
Zoom.allowSurvey = true
local lo, hi = Zoom.offsetRange(4)
check(lo == -3 and hi == 4, "vanilla zoom.range is (1-S, S)")
local unsub = wrap("zoom.range", function(next, a, b, S)
@@ -139,6 +145,7 @@ do
unsub()
Zoom.reset()
check(Zoom.scale(4) == 4, "unwrapped zoom returns to FIT")
Zoom.allowSurvey = savedSurvey
end
-- ------- battle.overlay (shiny sparkles / HUD chrome)
+45 -24
View File
@@ -282,9 +282,11 @@ local function optGame()
end
local om = OptionsMenu.new(optGame())
local WANT_IDS = { "textSpeed", "animations", "battleStyle", "battleLayout",
"battleFit", "battleBg", "uiLayout",
"ruleset", "musicVol", "sfxVol", "musicFilter",
"performance", "colors",
"tilt", "gbcfx", "zoom", "voidFill", "videoMode", "fpsCap",
"tilt", "gbcfx", "zoom", "voidFill", "videoMode",
"faithfulRes", "fpsCap",
"speed", "mods", "controls" }
check(#om.rows == #WANT_IDS, "vanilla options row count (plus MODS/CONTROLS)")
for i, id in ipairs(WANT_IDS) do
@@ -293,11 +295,11 @@ end
-- ruleset row cycles the sorted non-hidden registry ids showing name
om.game.save.options.ruleset = "gen1_faithful"
check(om.rows[5].value(om.game) == "GEN 1", "ruleset row shows record.name")
om.rows[5].step(om.game, 1)
check(om.rows[8].value(om.game) == "GEN 1", "ruleset row shows record.name")
om.rows[8].step(om.game, 1)
check(om.game.save.options.ruleset == "modern_clean",
"ruleset row cycles sorted registry ids")
om.rows[5].step(om.game, 1)
om.rows[8].step(om.game, 1)
check(om.game.save.options.ruleset == "gen1_faithful",
"hidden rulesets are excluded from the cycle")
@@ -316,41 +318,42 @@ check(om.game.save.options.battleLayout == "wide", "battle layout flips to WIDE"
check(om.rows[4].value(om.game) == "WIDE", "the WIDE layout renders its label")
om.rows[4].step(om.game, 1)
check(om.game.save.options.battleLayout == "og", "battle layout flips back")
om.rows[6].step(om.game, -1)
om.rows[9].step(om.game, -1)
check(om.game.save.options.musicVol == 6, "music volume steps down")
for _ = 1, 10 do om.rows[6].step(om.game, -1) end
for _ = 1, 10 do om.rows[9].step(om.game, -1) end
check(om.game.save.options.musicVol == 0, "music volume clamps at 0")
-- ZOOM / VOID FILL rows (indices shifted +1 by the PERFORMANCE row spliced
-- in ahead of COLORS)
-- ZOOM / VOID FILL rows (indices track WANT_IDS above; the battle
-- composition rows -- BATTLE SIZE / BATTLE BG / UI LAYOUT -- sit ahead of
-- RULESET, and FAITHFUL RATIO lands between VIDEO MODE and MAX FPS)
local Zoom = require("src.render.Zoom")
local TileRenderer = require("src.render.TileRenderer")
om.game.save.options.zoom = 0
Zoom.offset = 0
check(om.rows[13].value(om.game) == "FIT", "ZOOM row shows FIT at offset 0")
om.rows[13].step(om.game, 1)
check(om.rows[16].value(om.game) == "FIT", "ZOOM row shows FIT at offset 0")
om.rows[16].step(om.game, 1)
check(om.game.save.options.zoom == 1 and Zoom.offset == 1,
"ZOOM row steps to IN1")
om.rows[14].step(om.game, 1)
om.rows[17].step(om.game, 1)
check(om.game.save.options.voidFill == "water"
and TileRenderer.voidFill == "water",
"VOID FILL row cycles TREES → WATER")
om.rows[14].step(om.game, 1)
om.rows[17].step(om.game, 1)
check(om.game.save.options.voidFill == "black", "VOID FILL steps to BLACK")
om.rows[14].step(om.game, 1)
om.rows[17].step(om.game, 1)
check(om.game.save.options.voidFill == "trees", "VOID FILL wraps to TREES")
-- the MAX FPS row cycles the render-cap steps and shows the value plain
om.game.save.options.fpsCap = nil
check(om.rows[16].value(om.game) == "60",
check(om.rows[20].value(om.game) == "60",
"MAX FPS row defaults to 60 with no saved cap")
om.rows[16].step(om.game, 1)
om.rows[20].step(om.game, 1)
check(om.game.save.options.fpsCap == 75, "MAX FPS steps up from 60 to 75")
check(om.rows[16].value(om.game) == "75", "the MAX FPS row renders the cap")
check(om.rows[20].value(om.game) == "75", "the MAX FPS row renders the cap")
om.game.save.options.fpsCap = 160
om.rows[16].step(om.game, 1)
om.rows[20].step(om.game, 1)
check(om.game.save.options.fpsCap == 30, "MAX FPS wraps past the ceiling to 30")
om.rows[16].step(om.game, -1)
om.rows[20].step(om.game, -1)
check(om.game.save.options.fpsCap == 160, "MAX FPS wraps back down to the ceiling")
-- ------- FrameCap normalize / cycle (issue #88)
@@ -380,7 +383,7 @@ check(FrameCap.current == 60, "FrameCap.applyOptions defaults a missing key to 6
-- the MODS row is the manager's discoverable home
local mgGame = optGame()
om = OptionsMenu.new(mgGame)
om.rows[18].activate(mgGame)
om.rows[22].activate(mgGame)
check(getmetatable(mgGame.stack:top()) == ManagerState,
"the MODS row opens the manager")
check(mgGame.stack:top().screenId == "ManagerState",
@@ -390,7 +393,7 @@ check(mgGame.stack:top().screenId == "ManagerState",
local BindingsMenu = require("src.ui.BindingsMenu")
local cbGame = optGame()
om = OptionsMenu.new(cbGame)
om.rows[19].activate(cbGame)
om.rows[23].activate(cbGame)
local bm = cbGame.stack:top()
check(getmetatable(bm) == BindingsMenu,
"the CONTROLS row opens the rebind list")
@@ -435,9 +438,12 @@ local Input = require("src.core.Input")
local gpGame = { stack = newStack() }
local sawPad
gpGame.stack:push({ onGamepadPressed = function(_, b) sawPad = b end })
-- gamepadpressed reads Input:isDown("select") for the display-chord and
-- shoulder-hotkey gates before it routes to the capturing state, so the
-- button state table must exist first
Input:init()
Game.gamepadpressed(gpGame, nil, "y")
check(sawPad == "y", "pad buttons reach a capturing top state")
Input:init()
gpGame.stack:pop()
Game.gamepadpressed(gpGame, nil, "a")
Input:step()
@@ -510,6 +516,21 @@ check(#pm.subItems == 2, "a non-table submenu result keeps the vanilla list")
hooks:removeOwner("bad")
pm.submenu = nil
-- ------- #768: the party cursor persists until a battle
-- (PartyMenuInit reads wPartyAndBillsPCSavedMenuItem, HandlePartyMenuInput
-- writes it back; InitBattleVariables / end_of_battle.asm zero it)
pgame.save.party[2] = { species = "PIKACHU", hp = 10, stats = { hp = 10 },
level = 5, moves = { { id = "TACKLE" } } }
press(pm, "down")
check(pgame.partyMenuSavedIndex == 2, "the party cursor is saved on move")
local pm2 = PartyMenu.new(pgame)
check(pm2.index == 2, "reopening the party menu keeps the cursor (#768)")
pgame.save.party[2] = nil
check(PartyMenu.new(pgame).index == 1,
"a shrunken party clamps the saved cursor back into range")
pgame.partyMenuSavedIndex = nil -- a battle clears it (InitBattleVariables)
check(PartyMenu.new(pgame).index == 1, "a battle resets the party cursor")
-- ------- battle PKMN: SWITCH / STATS / CANCEL (#180)
local switched
local bgame = partyGame()
@@ -555,9 +576,9 @@ do
pm.game = sgame
sgame.stack:push(pm)
press(pm, "a") -- open the submenu
check(pm.subItems[#pm.subItems].action == "strength",
"the strength row is listed with badge + move")
pm.subIndex = #pm.subItems
check(pm.subItems[1].action == "strength",
"the strength row is listed with badge + move, above STATS/SWITCH (#768)")
pm.subIndex = 1
press(pm, "a") -- run STRENGTH
local states = sgame.stack.states
check(#states == 2 and states[1] == pm and states[2].pages ~= nil,
+15 -15
View File
@@ -114,10 +114,10 @@ eq(ow:checkBoulderPush("right"), false, "no push before activation (bump 1)")
eq(ow:checkBoulderPush("right"), false, "no push before activation (bump 2)")
eq(boulder.cellX, 18, "boulder unmoved while STRENGTH is inactive")
-- activate via the party menu STRENGTH action (submenu {STATS,SWITCH,STRENGTH})
-- activate via the party menu STRENGTH action (submenu {STRENGTH,STATS,SWITCH})
clearCaptured()
local pmStr = PartyMenu.new(Game)
selectSubItem(pmStr, 3)
selectSubItem(pmStr, 1)
eq(Game.overworld.strengthActive, true, "party-menu STRENGTH sets strengthActive")
check(onStack(pmStr), "party menu stays under the STRENGTH texts (#385)")
check(sawText("used") and sawText("STRENGTH"), "_UsedStrengthText shown")
@@ -156,7 +156,7 @@ Game.save.inventory.SOULBADGE = true
ow.player.facing = "up"; ow.player.surfing = false
clearCaptured()
local pmSurfFail = PartyMenu.new(Game)
selectSubItem(pmSurfFail, 3)
selectSubItem(pmSurfFail, 1)
check(sawText("No SURFing"), "_NoSurfingHereText when not facing water")
check(pmSurfFail.submenu == true, "party menu stays open after a failed SURF")
eq(ow.player.surfing, false, "no mount when SURF fails")
@@ -166,7 +166,7 @@ popToOW()
ow.player.facing = "down"; ow.player.surfing = false
clearCaptured()
local pmSurf = PartyMenu.new(Game)
selectSubItem(pmSurf, 3)
selectSubItem(pmSurf, 1)
-- the got-on text prints over the menu (#385); dismissing it closes the
-- menu and mounts, and the blink that follows carries the step
check(onStack(pmSurf), "party menu stays under the got-on text")
@@ -228,7 +228,7 @@ ow = pushOW("CERULEAN_CITY", 19, 27, "down")
-- success path: facing the tree -> _UsedCutText, menu closes, tree replaced
clearCaptured()
local pmCut = PartyMenu.new(Game)
selectSubItem(pmCut, 3)
selectSubItem(pmCut, 1)
check(not onStack(pmCut), "party menu closes after a successful CUT")
check(sawText("CUT"), "_UsedCutText shown on a successful CUT")
drainText() -- the tree swap is deferred until the message is dismissed
@@ -239,7 +239,7 @@ popToOW()
ow.player.facing = "up"
clearCaptured()
local pmCutFail = PartyMenu.new(Game)
selectSubItem(pmCutFail, 3)
selectSubItem(pmCutFail, 1)
check(sawText("anything to CUT"), "_NothingToCutText when not facing a tree")
check(pmCutFail.submenu == true, "party menu stays open after a failed CUT")
ow.player.facing = "right"
@@ -276,7 +276,7 @@ Game.save.forcedBike = true
eq(ow:useSurfFieldMove(), "forced_bike", "forced bike refuses SURF (even facing water)")
clearCaptured()
local pmBike = PartyMenu.new(Game)
selectSubItem(pmBike, 3)
selectSubItem(pmBike, 1)
check(sawText("Cycling is fun!\nForget SURFing!"), "_CyclingIsFunText verbatim")
check(pmBike.submenu == true, "party menu stays open (.loop) after the bike refusal")
eq(ow.player.surfing, false, "no mount on the Cycling Road")
@@ -320,7 +320,7 @@ check(ow.map:isWaterCell(7, 12), "water south of the B4F stairs square")
eq(ow:useSurfFieldMove(), "current", "B4F stairs square refuses SURF pre-boulders")
clearCaptured()
local pmCur = PartyMenu.new(Game)
selectSubItem(pmCur, 3)
selectSubItem(pmCur, 1)
check(sawText("The current is\nmuch too fast!"), "_CurrentTooFastText verbatim")
check(pmCur.submenu == true, "party menu stays open (.loop) after the current refusal")
eq(ow.player.surfing, false, "no mount against the current")
@@ -356,7 +356,7 @@ table.remove(ow.entities)
-- .goBackToMap) and the simulated pad press steps the player ashore
clearCaptured()
local pmOff = PartyMenu.new(Game)
selectSubItem(pmOff, 3)
selectSubItem(pmOff, 1)
check(not onStack(pmOff), "party menu closes on dismount")
eq(ow.player.surfing, false, ".stopSurfing returns to walking before the step")
eq(#captured, 0, "no message on a successful dismount")
@@ -375,7 +375,7 @@ ow.player.px, ow.player.py = 4 * 16, 15 * 16
ow.player.facing = "down"
clearCaptured()
local pmNoOff = PartyMenu.new(Game)
selectSubItem(pmNoOff, 3)
selectSubItem(pmNoOff, 1)
check(sawText("There's no place\nto get off!"), "_SurfingNoPlaceToGetOffText verbatim")
check(onStack(pmNoOff), "the menu stays under the message (#385)")
eq(ow.player.surfing, true, "still surfing after a blocked dismount")
@@ -394,7 +394,7 @@ Game.save.inventory = { RAINBOWBADGE = true }
ow = pushOW("SEAFOAM_ISLANDS_1F", 17, 10, "right")
clearCaptured()
local pmStr2 = PartyMenu.new(Game)
selectSubItem(pmStr2, 3)
selectSubItem(pmStr2, 1)
local page1 = Game.stack:top()
check(page1 ~= nil and page1.pages ~= nil and page1.auto ~= nil,
"_UsedStrengthText box is a no-prompt (auto) page")
@@ -486,8 +486,8 @@ Game.save.inventory = {
ow = pushOW("SEAFOAM_ISLANDS_1F", 17, 10, "right")
clearCaptured()
local pmFaintStr = PartyMenu.new(Game)
-- Seafoam is not OVERWORLD, so FLY is omitted: STATS, SWITCH, CUT, STRENGTH, SURF
selectSubItem(pmFaintStr, 4)
-- Seafoam is not OVERWORLD, so FLY is omitted: CUT, STRENGTH, SURF, STATS, SWITCH
selectSubItem(pmFaintStr, 2)
eq(Game.overworld.strengthActive, true,
"fainted mon can activate STRENGTH from the party menu")
check(sawText("used") and sawText("STRENGTH"),
@@ -501,9 +501,9 @@ ow = pushOW("PALLET_TOWN", 4, 13, "down")
ow.player.surfing = false
eq(ow:useSurfFieldMove(), "ok", "useSurfFieldMove ok with only a fainted SURF mon")
clearCaptured()
-- submenu order: STATS, SWITCH, FLY, CUT, STRENGTH, SURF (move order on mon)
-- submenu order: FLY, CUT, STRENGTH, SURF (move order on mon), then STATS, SWITCH
local pmFaintSurf = PartyMenu.new(Game)
selectSubItem(pmFaintSurf, 6)
selectSubItem(pmFaintSurf, 4)
Game.stack:pop().onDone() -- dismiss the text: menu closes, mount (#320, #385)
eq(ow.player.surfing, true, "fainted mon can SURF from the party menu")
check(not onStack(pmFaintSurf), "party menu closes after fainted SURF")
+10 -3
View File
@@ -283,7 +283,8 @@ end
do
local pressed = {}
local tb = freshBattle()
tb.game = { input = { wasPressed = function(_, k) return pressed[k] or false end },
tb.game = { input = { wasPressed = function(_, k) return pressed[k] or false end,
isDown = function(_, k) return pressed[k] or false end },
stack = { top = function() return tb end },
save = Game.save }
tb.kind = "wild"
@@ -359,7 +360,8 @@ do
local fg = {
data = Data,
save = require("src.core.SaveData").newGame(),
input = { wasPressed = function(_, k) return pressed[k] or false end },
input = { wasPressed = function(_, k) return pressed[k] or false end,
isDown = function(_, k) return pressed[k] or false end },
stack = stack,
}
fg.save.party = { Pokemon.new(Data, "BULBASAUR", 20) }
@@ -426,10 +428,15 @@ do
bag:update(1 / 60)
end
check(stack:top() ~= bag, "the ball is thrown without input")
-- Battle exit now rides Transition.battleReturn (MapEntryAfterBattle's
-- GBFadeInFromWhite, home/overworld.asm:749-753): the battle pops itself
-- and pushes the fade, which fires onFinish only once ITS update counts
-- down -- so pump whatever sits on top of the stack, not the demo state.
for _ = 1, 2000 do
if finished then break end
pressed.a = true
demo:update(1 / 60)
local top = stack:top()
if top then top:update(1 / 60) else break end
end
pressed.a = false
check(finished, "the throw ends the demo battle")
+12 -8
View File
@@ -59,7 +59,11 @@ local t = battler{
disabledSlot = 1, disabledTurns = 2, xAccuracy = true,
mon = { status = "SLP" }, name = "TARGET",
}
local msg = MoveEffects.primary.HAZE_EFFECT(nil, u, t)
-- Effect rows take the battle handle first so romText can serve the ROM's
-- own wording; a data-only stub is all these pure rows dereference.
local B = { data = Data }
local msg = MoveEffects.primary.HAZE_EFFECT(B, u, t)
check(next(u.stages) == nil, "Haze clears all of the user's stat stages")
check(next(t.stages) == nil, "Haze clears all of the target's stat stages")
@@ -89,13 +93,13 @@ eq(msg[1], "All STATUS changes\nare eliminated!", "Haze prints the elimination t
-- FRZ target also forfeits its move.
local frz = battler{ mon = { status = "FRZ" }, name = "FROZEN" }
MoveEffects.primary.HAZE_EFFECT(nil, battler{ mon = {} }, frz)
MoveEffects.primary.HAZE_EFFECT(B, battler{ mon = {} }, frz)
eq(frz.mon.status, nil, "target's freeze is cured")
check(frz.skipMove == true, "curing target's freeze forfeits its move")
-- Badly-poisoned TARGET: status cured, no forfeit, toxic counter gone.
local psnT = battler{ mon = { status = "PSN" }, toxicCounter = 4, name = "PSN_T" }
MoveEffects.primary.HAZE_EFFECT(nil, battler{ mon = {} }, psnT)
MoveEffects.primary.HAZE_EFFECT(B, battler{ mon = {} }, psnT)
eq(psnT.mon.status, nil, "badly-poisoned target is fully cured of poison")
check(psnT.toxicCounter == nil, "badly-poisoned target's toxic counter cleared")
check(not psnT.skipMove, "curing poison does NOT forfeit the target's move")
@@ -103,14 +107,14 @@ check(not psnT.skipMove, "curing poison does NOT forfeit the target's move")
-- BRN / PAR targets: cured, no forfeit.
for _, st in ipairs({ "BRN", "PAR" }) do
local tb = battler{ mon = { status = st }, name = st }
MoveEffects.primary.HAZE_EFFECT(nil, battler{ mon = {} }, tb)
MoveEffects.primary.HAZE_EFFECT(B, battler{ mon = {} }, tb)
eq(tb.mon.status, nil, "target's " .. st .. " is cured")
check(not tb.skipMove, st .. " target keeps its move (no sleep/freeze forfeit)")
end
-- A burned USER keeps its own burn (status not the one Haze cures).
local burnedUser = battler{ mon = { status = "BRN" }, name = "BURNER" }
MoveEffects.primary.HAZE_EFFECT(nil, burnedUser, battler{ mon = {} })
MoveEffects.primary.HAZE_EFFECT(B, burnedUser, battler{ mon = {} })
eq(burnedUser.mon.status, "BRN", "user's own burn is not cured by Haze")
-- =====================================================================
@@ -145,7 +149,7 @@ eq(dBurnedHaze, dHealthy, "Haze lifts the burn Attack-halving (damage == unburne
-- A stat-stage change re-bakes the penalty (effects.asm:505-506). Bump the
-- attacker's DEFENSE (irrelevant to its own offense) so only hazeStatReset flips.
MoveEffects.primary.DEFENSE_UP1_EFFECT(nil, hazedAtk, nil)
MoveEffects.primary.DEFENSE_UP1_EFFECT(B, hazedAtk, nil)
check(hazedAtk.hazeStatReset == nil, "a stat-stage change re-arms the burn penalty")
local dAfter = Damage.compute(ruleset, hazedAtk, defender, move, opts)
eq(dAfter, dBurnedRaw, "burn Attack-halving returns after the stage change")
@@ -159,10 +163,10 @@ local para = battler{
mon = { status = "PAR", level = 50, stats = { hp = 100 } }, name = "PARA",
}
eq(TurnOrder.effectiveSpeed(para), 25, "paralysis quarters speed before Haze (100 -> 25)")
MoveEffects.primary.HAZE_EFFECT(nil, para, battler{ mon = {} })
MoveEffects.primary.HAZE_EFFECT(B, para, battler{ mon = {} })
eq(TurnOrder.effectiveSpeed(para), 100, "Haze lifts paralysis Speed-quartering")
-- Re-arm via an ATTACK stage change (irrelevant to the speed calc).
MoveEffects.primary.ATTACK_UP1_EFFECT(nil, para, nil)
MoveEffects.primary.ATTACK_UP1_EFFECT(B, para, nil)
check(para.hazeStatReset == nil, "stage change re-arms the paralysis penalty")
eq(TurnOrder.effectiveSpeed(para), 25, "Speed-quartering resumes after the stage change")
+10 -1
View File
@@ -39,7 +39,16 @@ check(type(script) == "string", SCRIPT .. " is readable")
-- The trim is only dangerous because it edits the tracked manifest in place; if
-- that stops being true, everything below tests a file the build never touches.
if script then
local target = script:match('local manifest="([^"]+)"')
-- The script has grown other `local manifest=` locals (the Yellow ROM
-- import manifest recovery), so scan every assignment for the one that
-- names the Android manifest instead of trusting the first match.
local target
for candidate in script:gmatch('local manifest="([^"]+)"') do
if candidate:find("AndroidManifest.xml", 1, true) then
target = candidate
break
end
end
check(target ~= nil, "build_android.sh names the manifest it rewrites")
check(target ~= nil
and target:find("app/src/main/AndroidManifest.xml", 1, true) ~= nil,
+4 -1
View File
@@ -190,7 +190,10 @@ do
eq(tb.fx.shakeProg, nil, "type 4 arms no shake (#354 must not regress it)")
check(tb.fx.blink ~= nil and tb.fx.blink.target == tb.enemy,
"type 4 blinks the enemy pic")
eq(tb.waitFrames, 20, "for the 20 frames AnimationBlinkEnemyMon takes")
-- AnimationBlinkMon (animations.asm:1360-1376) is `ld c, 6` iterations
-- of hide + DelayFrames 5 + show + DelayFrames 5 = 60 frames; the port
-- once ran it in 20, a third of its length (Timing.BLINK_MON).
eq(tb.waitFrames, 60, "for the 60 frames AnimationBlinkEnemyMon takes")
end
-- the OPTIONS animation toggle still gates the whole thing; the sound does not
+115
View File
@@ -0,0 +1,115 @@
-- Parity test: battle pages whose ROM tail is `text_end` / `done` hand off
-- with no button press (#765). Only TX_PROMPT_BUTTON writes the '▼' and
-- runs ManualTextScroll (home/text.asm:434-446); a TX_END tail returns
-- straight out of PrintText (home/text.asm:328-334). The used-move line
-- (engine/battle/used_move_text.asm EndUsedMove1Text..EndUsedMove5Text) and
-- the item-use line (ItemUseText00, engine/items/item_effects.asm) are both
-- of that kind, so a sayAuto row must flow into the next queue row untouched
-- while a plain say page still waits on A/B like PromptText.
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("parity battle auto text (#765)")
local check, eq = S.check, S.eq
local Data = require("src.core.Data")
if not Data.maps then Data:load() end
local Font = require("src.render.Font")
Font.load(Data)
local BattleState = require("src.battle.BattleState")
local Pokemon = require("src.pokemon.Pokemon")
local SaveData = require("src.core.SaveData")
local Sound = require("src.core.Sound")
local Music = require("src.core.Music")
local Timing = require("src.core.Timing")
-- Silence audio: BattleState reaches both modules through require() at the
-- call site, so patching the fields here is what the battle ends up calling.
Sound.playCry = function() end
Sound.play = function() end
Sound.playMove = function() end
Sound.playMoveCry = function() end
Sound.stopLoop = function() end
Music.playBattle = function() end
Music.play = function() end
-- stub stack + input, like the other headless battle probes
local press = {}
local function makeGame(party)
local save = SaveData.newGame()
save.party = party
local stack = { states = {} }
function stack:push(state) self.states[#self.states + 1] = state end
function stack:pop() return table.remove(self.states) end
function stack:top() return self.states[#self.states] end
return { data = Data, save = save, stack = stack,
input = { wasPressed = function(_, b) return press[b] == true end,
isDown = function(_, b) return press[b] == true end } }
end
local game = makeGame({ Pokemon.new(Data, "BULBASAUR", 50) })
local battle = BattleState.newWild(game, "RATTATA", 2)
battle.onFinish = function() end
battle:enter()
-- strip the intro so the queue under test is exactly what gets inserted;
-- afterQueue is cleared so a drained queue between probes cannot flip the
-- phase to "menu" and stop update() from pumping messages
battle.queue = {}
battle.current = nil
battle.introSlide = 0
battle.phase = "messages"
battle.afterQueue = nil
-- ------------------------------------------------- auto page, no delay
local ran = false
battle:sayAuto("AUTO PAGE")
battle:act(function() ran = true end)
local promptedDuringAuto = false
for _ = 1, 300 do
if ran then break end
if battle.msgPrompt then promptedDuringAuto = true end
battle:update(1 / 60)
end
check(ran, "an auto page hands off to the next row with no button")
check(not promptedDuringAuto, "the prompt flag never rises on an auto page")
eq(battle.msgHold, true,
"the finished auto page stays held for drawTextArea (#296)")
-- ------------------------------------------------- auto page, autoDelay
local ran2, typedFrame, ranFrame = false, nil, nil
battle:sayAuto("HELD PAGE", 30)
battle:act(function() ran2 = true end)
for f = 1, 600 do
battle:update(1 / 60)
if not typedFrame and battle.current
and battle.charIndex >= battle.total then
typedFrame = f
end
if ran2 then ranFrame = f break end
end
check(ran2, "the delayed auto page still hands off by itself")
check(typedFrame ~= nil and ranFrame ~= nil
and ranFrame - typedFrame >= 30,
"autoDelay holds the finished page for its frame count first")
-- ------------------------------------------------- plain page still prompts
battle:say("PROMPT PAGE")
local prompted = false
for _ = 1, 300 do
battle:update(1 / 60)
if battle.msgPrompt then prompted = true break end
end
check(prompted, "a plain page still raises the blinking prompt (#317)")
-- PromptText runs ProtectedDelay3 before ManualTextScroll watches the
-- joypad (home/text.asm:213-217), so pay that hold before pressing
for _ = 1, Timing.TEXT_PRE_ADVANCE do battle:update(1 / 60) end
check(battle.current ~= nil, "and the page holds on screen with no button")
press.a = true
battle:update(1 / 60)
press.a = false
eq(battle.msgPrompt, nil, "the A press clears the prompt")
eq(battle.current, nil, "and dismisses the page")
S.finish()
+2 -1
View File
@@ -38,7 +38,8 @@ local function makeGame(party)
function stack:pop() return table.remove(self.states) end
function stack:top() return self.states[#self.states] end
return { data = Data, save = save, stack = stack,
input = { wasPressed = function(_, b) return press[b] == true end } }
input = { wasPressed = function(_, b) return press[b] == true end,
isDown = function(_, b) return press[b] == true end } }
end
local function step(battle)
+2 -1
View File
@@ -44,7 +44,8 @@ local function makeGame(party)
function stack:pop() return table.remove(self.states) end
function stack:top() return self.states[#self.states] end
return { data = Data, save = save, stack = stack,
input = { wasPressed = function() return false end } }
input = { wasPressed = function() return false end,
isDown = function() return false end } }
end
local game = makeGame({ Pokemon.new(Data, "BULBASAUR", 50) })
+5 -1
View File
@@ -172,7 +172,11 @@ do
local box = game.stack:top()
check(isBox(box), "the restored-HP message opened (#379)")
if isBox(box) then
check(box.text:find("was restored", 1, true) ~= nil,
-- the line is _PotionText itself when the cache carries it ("<mon>
-- recovered by <n>!") and the engine's "was restored" wording on a
-- dataset without the label (src/core/RomText.lua)
check((box.text:find("recovered by", 1, true)
or box.text:find("was restored", 1, true)) ~= nil,
"and it is the restored-HP line: " .. tostring(box.text))
dismiss(game.stack, box)
end
+72
View File
@@ -0,0 +1,72 @@
-- Parity: which trainers get the gym-leader battle theme (#782).
-- PlayBattleMusic (audio/play_battle_music.asm) picks MUSIC_GYM_LEADER_BATTLE
-- only when wGymLeaderNo is set, and the eight gym scripts
-- (scripts/PewterGym.asm .. ViridianGym.asm) are its only writers; Lance
-- shares the theme by opponent class and the Champion (OPP_RIVAL3) takes
-- MUSIC_FINAL_BATTLE. Giovanni's Rocket Hideout (OPP_GIOVANNI#1) and Silph
-- Co (OPP_GIOVANNI#2) fights never touch the byte, so they must play
-- MUSIC_TRAINER_BATTLE. The port keyed the boss check on the trainer CLASS
-- alone, so every Giovanni battle borrowed the Earth Badge roster's theme,
-- the gym victory jingle, and the Pikachu GYMLEADER happiness bump.
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("parity battle music bug782")
local check, eq = S.check, S.eq
local Data = require("src.core.Data")
if not Data.maps then Data:load() end
local Font = require("src.render.Font")
Font.load(Data)
local BattleState = require("src.battle.BattleState")
local Pokemon = require("src.pokemon.Pokemon")
local SaveData = require("src.core.SaveData")
local function makeGame()
local save = SaveData.newGame()
save.party = { Pokemon.new(Data, "BULBASAUR", 60) }
return { data = Data, save = save,
input = { wasPressed = function() return false end,
isDown = function() return false end },
stack = { top = function() return nil end,
push = function() end, pop = function() end } }
end
local function kindOf(oppClass, partyIndex)
local battle = BattleState.newTrainer(makeGame(), oppClass, partyIndex)
return battle:computeMusicKind(), battle.isGymLeader
end
-- the two non-gym Giovanni fights: plain trainer theme, no gym-leader flag
do
local kind, gym = kindOf("OPP_GIOVANNI", 1) -- Rocket Hideout B4F
eq(kind, "trainer", "Rocket Hideout Giovanni plays the trainer theme")
check(not gym, "Rocket Hideout Giovanni is not a gym leader")
kind, gym = kindOf("OPP_GIOVANNI", 2) -- Silph Co 11F
eq(kind, "trainer", "Silph Co Giovanni plays the trainer theme")
check(not gym, "Silph Co Giovanni is not a gym leader")
end
-- the badge fight itself keeps the gym theme and the happiness bump
do
local kind, gym = kindOf("OPP_GIOVANNI", 3) -- Viridian Gym
eq(kind, "gym", "Viridian Gym Giovanni plays the gym-leader theme")
check(gym, "Viridian Gym Giovanni sets isGymLeader")
end
-- regression guards around the branch below the badge lookup
do
local kind, gym = kindOf("OPP_BROCK", 1)
eq(kind, "gym", "Brock plays the gym-leader theme")
check(gym, "Brock sets isGymLeader")
kind, gym = kindOf("OPP_LANCE", 1)
eq(kind, "gym", "Lance shares the gym-leader theme")
check(not gym, "Lance is not a wGymLeaderNo writer (no happiness bump)")
kind = kindOf("OPP_RIVAL3", 1)
eq(kind, "final", "the Champion plays the final-battle theme")
kind, gym = kindOf("OPP_YOUNGSTER", 1)
eq(kind, "trainer", "an ordinary trainer plays the trainer theme")
check(not gym, "an ordinary trainer is not a gym leader")
end
S.finish()
+2 -1
View File
@@ -28,7 +28,8 @@ local function makeGame(species, level, moves)
function stack:pop() return table.remove(self.states) end
function stack:top() return self.states[#self.states] end
return { data = Data, save = save, stack = stack,
input = { wasPressed = function() return true end } }
input = { wasPressed = function() return true end,
isDown = function() return true end } }
end
local function pumpToMenu(battle)
+4 -4
View File
@@ -111,7 +111,7 @@ Game.save.party = { mkMon("MACHOP", "STRENGTH") }
Game.save.inventory = { RAINBOWBADGE = true }
local ow = pushOW("SEAFOAM_ISLANDS_1F", 17, 10, "right")
local pmStr = PartyMenu.new(Game)
selectSubItem(pmStr, 3)
selectSubItem(pmStr, 1)
check(isText(Game.stack:top()), "STRENGTH opens _UsedStrengthText")
eq(backdrop(), pmStr, "the party menu is the backdrop of _UsedStrengthText")
drainOne()
@@ -133,7 +133,7 @@ Game.save.inventory = { SOULBADGE = true }
ow = pushOW("PALLET_TOWN", 4, 13, "down")
ow.player.surfing = false
local pmSurf = PartyMenu.new(Game)
selectSubItem(pmSurf, 3)
selectSubItem(pmSurf, 1)
check(isText(Game.stack:top()), "SURF opens _SurfingGotOnText")
eq(backdrop(), pmSurf, "the party menu is the backdrop of _SurfingGotOnText")
drainOne()
@@ -148,7 +148,7 @@ eq(Game.stack:top(), ow, "SURF ends on the map")
ow = pushOW("PALLET_TOWN", 4, 15, "down")
ow.player.surfing = true
local pmNoOff = PartyMenu.new(Game)
selectSubItem(pmNoOff, 3)
selectSubItem(pmNoOff, 1)
check(isText(Game.stack:top()), "a blocked dismount opens _SurfingNoPlaceToGetOffText")
eq(backdrop(), pmNoOff, "the party menu is the backdrop of the no-place message")
drainOne()
@@ -172,7 +172,7 @@ Game.save.inventory = { BOULDERBADGE = true }
ow = pushOW("ROCK_TUNNEL_1F", 15, 4, "down")
eq(ow.dark, true, "ROCK_TUNNEL_1F loads dark before FLASH")
local pmFlash = PartyMenu.new(Game)
selectSubItem(pmFlash, 3)
selectSubItem(pmFlash, 1)
check(isText(Game.stack:top()), "FLASH opens _FlashLightsAreaText")
eq(backdrop(), pmFlash, "the party menu is the backdrop of _FlashLightsAreaText")
eq(ow.dark, true, "the tunnel is still dark while the message is up")
+1 -2
View File
@@ -66,8 +66,7 @@ eq(ow.dark, true, "ROCK_TUNNEL_1F loads dark before FLASH")
local pm = PartyMenu.new(Game)
Game.stack:push(pm)
frame({ "a" }) -- open the field-move submenu on PIKACHU
for _ = 2, 3 do frame({ "down" }) end
frame({ "a" }) -- FLASH
frame({ "a" }) -- FLASH is the top row now (#768)
drainOne() -- dismiss _FlashLightsAreaText
local blink = Game.stack:top()
+3 -1
View File
@@ -62,7 +62,9 @@ local expected = 100 + 128 + 16 + 20 + 600
for _, s in ipairs(credits.screens) do
expected = expected + (s.fade and 20 or 0)
+ (s.mon and (s.fade and 90 or 110) or (s.fade and 120 or 140))
+ (s.mon and 27 or 0)
-- DisplayCreditsMon: 3 x CreditsCopyTileMapToVRAM (Delay3) then 27 scroll
-- frames (#703)
+ (s.mon and (9 + 27) or 0)
end
while roll.phase ~= "end_wait" and frame < expected + 120 do
frame = frame + 1
+90
View File
@@ -0,0 +1,90 @@
-- Parity test: Substitute's failure branches play no animation (#644).
-- SUBSTITUTE_EFFECT is a ResidualEffects1 entry
-- (data/battle/residual_effects_1.asm), so the caller never plays the move
-- animation; SubstituteEffect_ (engine/battle/move_effects/substitute.asm)
-- reaches PlayCurrentMoveAnimation / AnimationSubstitute only after
-- `set HAS_SUBSTITUTE_UP, [hl]`, while .alreadyHasSubstitute and
-- .notEnoughHP jump straight to PrintText. The animation opens with
-- SE_SLIDE_MON_OFF, which hides the user's pic until the doll replaces it,
-- so a failed Substitute that still animated left the user invisible.
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local Data = require("src.core.Data")
if not (Data.moves and Data.moves.SUBSTITUTE) then Data:load() end
local TypeChart = require("src.battle.TypeChart")
TypeChart.load(Data)
local Font = require("src.render.Font")
if not pcall(Font.encode, "A") then Font.load(Data) end
local Game = require("src.core.Game")
Game.data = Data
Game.save = require("src.core.SaveData").newGame()
local Pokemon = require("src.pokemon.Pokemon")
local BattleState = require("src.battle.BattleState")
local S = require("tests.harness").suite("parity substitute anim")
local check, eq = S.check, S.eq
local function freshBattle()
Game.save.party = { Pokemon.new(Data, "BULBASAUR", 20) }
local tb = BattleState.newWild(Game, "PIDGEY", 10)
tb.queue, tb.nextInsert = {}, 0
return tb
end
local function anyAnim(tb, name)
for _, row in ipairs(tb.queue) do
if row.anim == name then return true end
end
return false
end
local function anyText(tb, needle)
for _, row in ipairs(tb.queue) do
if row.text and row.text:gsub("\n", " "):find(needle, 1, true) then return true end
end
return false
end
do
local anims = Data.battle_anims and Data.battle_anims.moveAnims
check(anims ~= nil, "battle_anims carries moveAnims")
check(anims == nil or anims.SUBSTITUTE ~= nil, "SUBSTITUTE has an animation")
end
-- success: the doll animation plays and the substitute stands
do
local tb = freshBattle()
tb.enemy.mon.hp = tb.enemy.mon.stats.hp
tb:performMove(tb.enemy, tb.player, { id = "SUBSTITUTE", pp = 10 }, false)
check(tb.enemy.substituteHP ~= nil, "a healthy user builds its substitute")
check(anyAnim(tb, "SUBSTITUTE"), "and the doll animation plays")
end
-- .notEnoughHP: text only, no animation, and no dangling row
do
local tb = freshBattle()
tb.enemy.mon.hp = math.floor(tb.enemy.mon.stats.hp / 4) - 1
tb:performMove(tb.enemy, tb.player, { id = "SUBSTITUTE", pp = 10 }, false)
check(tb.enemy.substituteHP == nil, "too little HP fails the substitute")
check(not anyAnim(tb, "SUBSTITUTE"),
"a failed substitute plays no animation (#644)")
check(tb.moveAnimRow == nil, "the peeled move-anim row is not left dangling")
check(anyText(tb, "SUBSTITUTE"), "the failure text still prints")
end
-- .alreadyHasSubstitute: same, with a doll already standing
do
local tb = freshBattle()
tb.enemy.mon.hp = tb.enemy.mon.stats.hp
tb.enemy.substituteHP = 10
tb:performMove(tb.enemy, tb.player, { id = "SUBSTITUTE", pp = 10 }, false)
eq(tb.enemy.substituteHP, 10, "the standing substitute is untouched")
check(not anyAnim(tb, "SUBSTITUTE"),
"a second substitute plays no animation (#644)")
check(tb.moveAnimRow == nil, "and peels its anim row")
end
S.finish()
+49
View File
@@ -0,0 +1,49 @@
-- Parity: a player send-out zeroes both battle cursors (#737). SendOutMon
-- (engine/battle/core.asm:1733-1735) clears wBattleAndStartSavedMenuItem and,
-- with the same hli/hl pair, wPlayerMoveListIndex behind it (wram.asm:242-244),
-- so the menu reopens on FIGHT and the move list on the first slot.
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local Data = require("src.core.Data")
if not Data.maps then Data:load() end
local TypeChart = require("src.battle.TypeChart")
TypeChart.load(Data)
local Pokemon = require("src.pokemon.Pokemon")
local SaveData = require("src.core.SaveData")
local BattleState = require("src.battle.BattleState")
local S = require("tests.harness").suite("parity switch cursor reset")
local eq = S.eq
local pressed = {}
local save = SaveData.newGame()
save.party = {
Pokemon.new(Data, "BULBASAUR", 10),
Pokemon.new(Data, "PIDGEY", 10),
}
local game = {
data = Data,
save = save,
input = {
wasPressed = function(_, key) return pressed[key] == true end,
isDown = function(_, key) return pressed[key] == true end,
},
stack = { push = function() end, pop = function() end, top = function() end },
}
local battle = BattleState.newWild(game, "RATTATA", 3)
battle.phase = "menu"
battle.menuIndex = 4
battle.moveIndex = 3
battle:resolveSwitch(save.party[2])
for i = 1, 4000 do
if battle.phase == "menu" then break end
pressed.a = (i % 4 == 0)
battle:update(1 / 60)
pressed.a = nil
end
eq(battle.moveIndex, 1, "the move cursor is back on the first slot")
eq(battle.menuIndex, 1, "the battle menu is back on FIGHT")
S.finish()
+12 -5
View File
@@ -98,9 +98,13 @@ for _, modname in ipairs({ "data.scripts.story", "data.scripts.story2",
check(WIRED[idx] == flag,
("%s/%s: trade %s pairs with %s"):format(
mapId, const, tostring(idx), tostring(flag)))
check(not seen[idx],
("trade index %s wired by only one NPC"):format(tostring(idx)))
seen[idx] = true
-- one NPC per index PER VERSION: Route 18 gate wires slot 6
-- twice on purpose (Red's YOUNGSTER/MARC, Yellow's COOK/SPIKE;
-- pokeyellow/scripts/Route18Gate2F.asm, #651) -- each version's
-- map only spawns its own NPC, so a same-map twin is fine
check(not seen[idx] or seen[idx] == mapId,
("trade index %s wired by only one NPC per version"):format(tostring(idx)))
seen[idx] = mapId
end
end
end
@@ -116,9 +120,12 @@ check(not seen[3], "unused CHIKUCHIKU trade (index 3) stays unwired")
-- === harness: run a talk script headless, recording show_text ids ===
local shown = {}
local origShow = Commands.show_text
Commands.show_text = function(ctx, textId, subs)
-- forward extraOpts too: Commands.ask rides show_text's 4th argument
-- (opts.choice, so the YES/NO box pops over the still-visible question);
-- dropping it here would strand every prompt on the NO branch
Commands.show_text = function(ctx, textId, subs, ...)
table.insert(shown, textId)
return origShow(ctx, textId, subs)
return origShow(ctx, textId, subs, ...)
end
-- pressFn returns the Input.pressed table for this frame (default: A)
+7 -2
View File
@@ -77,8 +77,13 @@ check(#Game.stack.states == 1,
Game.stack:pop()
moreText.onDone()
local evolutionText = Game.stack:top()
check(stateHas(evolutionText, "evolving"),
-- The evolution runs as the EvolutionState cutscene screen now (the
-- evolve_mon.asm sequence lives in src/ui/EvolutionState.lua), not a bare
-- "evolving!" text box, so assert the screen itself took the stack.
local evolution = Game.stack:top()
check(evolution ~= nil
and (evolution.screenId == "EvolutionState"
or stateHas(evolution, "evolving")),
"the level evolution starts after trainer after-text closes")
S.finish()
+58 -24
View File
@@ -114,6 +114,12 @@ do
"cont boundary waits for A with contAdvance")
check(not box.done, "cont wait is not the final done prompt")
eq(box.lineIndex, 2, "cont wait stays on the finished line until A")
-- ProtectedDelay3 (home/text.asm:265): the ▼ swallows the button for
-- three frames before ManualTextScroll starts listening
for _ = 1, 80 do
if (box.preWait or 0) == 0 then break end
box:update(0)
end
pressed.a = true
box:update(0)
check(not box.waiting and not box.contAdvance, "A clears cont wait")
@@ -1126,14 +1132,17 @@ do
eq(pb:sendOutText("PIKA"), "The enemy's weak!\nGet'm! PIKA!",
"send-out below 10%")
-- HP-bar drain converges at UpdateHPBar's pixel pace (maxHP/96/frame)
-- HP-bar drain converges at UpdateHPBar's per-side pace (2 frames per
-- bar pixel, enemy HP steps free; hp_bar.asm:81-148 via Timing)
local db = BattleState.newWild(Game, "RATTATA", 5)
local maxHP = db.enemy.mon.stats.hp
local startHP = db.enemy.mon.hp
db.enemy.mon.hp = math.max(0, db.enemy.mon.hp - 5)
local frames = 0
while db:stepHPDrain() and frames < 2000 do frames = frames + 1 end
eq(db.enemy.shownHP, db.enemy.mon.hp, "drain settles on the true HP")
local expect = math.ceil(5 / (maxHP / 96))
local expect = require("src.core.Timing").hpDrainFrames(
startHP, db.enemy.mon.hp, maxHP, false)
check(math.abs(frames - expect) <= 1,
("drain speed ~2 frames per bar pixel (%d ~ %d)"):format(frames, expect))
@@ -2631,7 +2640,13 @@ do
local popped = false
local og = { data = Data, save = SD.newGame(),
input = OInput, stack = { pop = function() popped = true end },
writeOptions = function(self) SD.saveOptions(self.save.options) end }
writeOptions = function(self) SD.saveOptions(self.save.options) end,
-- the PERFORMANCE row routes through Game:applyOptions; the
-- stub carries the headless slice of it (the tier record),
-- the display modules are re-applied at the end of the suite
applyOptions = function(self, o)
require("src.core.Performance").applyOptions(o)
end }
local om = OptionsMenu.new(og)
local function press(btn)
OInput.pressed = { [btn] = true }
@@ -2655,9 +2670,9 @@ do
"A switches the battle screen to the WIDE layout")
press("a")
eq(og.save.options.battleLayout, "og", "BATTLE LAYOUT wraps back to OG")
for _ = 1, 2 do press("down") end
eq(om.index, 6, "cursor reaches MUSIC VOL")
eq(om.scroll, 2, "viewport scrolls to keep MUSIC VOL on screen")
for _ = 1, 5 do press("down") end
eq(om.index, 9, "cursor reaches MUSIC VOL")
eq(om.scroll, 5, "viewport scrolls to keep MUSIC VOL on screen")
press("left")
eq(og.save.options.musicVol, 6, "left lowers MUSIC VOL")
press("right")
@@ -2672,25 +2687,33 @@ do
press("a")
eq(og.save.options.musicFilter, 0, "MUSIC FILTER wraps back to OFF")
press("down")
eq(om.index, 9, "cursor reaches COLORS")
eq(om.index, 12, "cursor reaches PERFORMANCE")
press("a")
eq(og.save.options.performance, "high", "A cycles PERFORMANCE to HIGH")
eq(require("src.core.Performance").tier, "high",
"the live tier tracks the PERFORMANCE option")
for _ = 1, 3 do press("a") end
eq(og.save.options.performance, "auto", "PERFORMANCE wraps back to AUTO")
press("down")
eq(om.index, 13, "cursor reaches COLORS")
press("a")
for _ = 1, 4 do press("a") end
press("down")
eq(om.index, 10, "cursor reaches TILT")
eq(om.index, 14, "cursor reaches TILT")
press("a")
eq(og.save.options.tilt, 1, "A cycles TILT to 15")
eq(Tilt.level, 1, "Tilt level tracks TILT option")
press("a"); press("a"); press("a")
eq(og.save.options.tilt, 0, "TILT wraps back to OFF")
press("down")
eq(om.index, 11, "cursor reaches GBC FX")
eq(om.index, 15, "cursor reaches GBC FX")
press("a")
eq(og.save.options.gbcfx, 1, "A cycles GBC FX to 1")
eq(GBCFX.level, 1, "GBCFX level tracks GBC FX option")
for _ = 1, 4 do press("a") end
eq(og.save.options.gbcfx, 0, "GBC FX wraps back to OFF")
press("down")
eq(om.index, 12, "cursor reaches ZOOM")
eq(om.index, 16, "cursor reaches ZOOM")
local ZoomOpt = require("src.render.Zoom")
press("a")
eq(og.save.options.zoom, 1, "A cycles ZOOM to IN1")
@@ -2698,7 +2721,7 @@ do
press("left")
eq(og.save.options.zoom, 0, "left steps ZOOM back to FIT")
press("down")
eq(om.index, 13, "cursor reaches VOID FILL")
eq(om.index, 17, "cursor reaches VOID FILL")
local TR = require("src.render.TileRenderer")
press("a")
eq(og.save.options.voidFill, "water", "A cycles VOID FILL to WATER")
@@ -2708,7 +2731,7 @@ do
press("a")
eq(og.save.options.voidFill, "trees", "VOID FILL wraps back to TREES")
press("down")
eq(om.index, 14, "cursor reaches VIDEO MODE")
eq(om.index, 18, "cursor reaches VIDEO MODE")
press("a")
eq(og.save.options.videoMode, "borderless",
"A cycles VIDEO MODE to BORDERLESS")
@@ -2716,7 +2739,9 @@ do
eq(og.save.options.videoMode, "windowed",
"VIDEO MODE wraps back to WINDOWED")
press("down")
eq(om.index, 15, "cursor reaches MAX FPS")
eq(om.index, 19, "cursor reaches FAITHFUL RATIO")
press("down")
eq(om.index, 20, "cursor reaches MAX FPS")
press("a")
eq(og.save.options.fpsCap, 75, "A cycles MAX FPS up from 60 to 75")
eq(FrameCap.current, 75, "the live render cap tracks the MAX FPS option")
@@ -2725,7 +2750,7 @@ do
for _ = 1, #FrameCap.STEPS - 1 do press("a") end
eq(og.save.options.fpsCap, 60, "MAX FPS wraps back to 60")
press("down")
eq(om.index, 16, "cursor reaches GAME SPEED")
eq(om.index, 21, "cursor reaches GAME SPEED")
press("a")
eq(og.save.options.speed, 2, "A cycles GAME SPEED to 2X")
-- Driven by the level list rather than a literal press count: adding a
@@ -2734,19 +2759,19 @@ do
for _ = 1, #GameSpeed.LEVELS - 1 do press("a") end
eq(og.save.options.speed, 1, "GAME SPEED wraps back to NORMAL")
press("down")
eq(om.index, 17, "cursor reaches MODS")
eq(om.index, 22, "cursor reaches MODS")
press("down")
eq(om.index, 18, "cursor reaches CONTROLS")
eq(om.index, 23, "cursor reaches CONTROLS")
press("down")
eq(om.index, 19, "CANCEL stays the fixed final row")
eq(om.scroll, 14, "CANCEL keeps the last option boxes on screen")
eq(om.index, 24, "CANCEL stays the fixed final row")
eq(om.scroll, 19, "CANCEL keeps the last option boxes on screen")
om:draw() -- smoke: scrolled layout draws under the headless stub
press("a")
check(popped, "A on CANCEL closes the options menu")
local om2 = OptionsMenu.new(og)
OInput.pressed = { up = true }; om2:update(1 / 60); OInput.pressed = {}
eq(om2.index, 19, "up from the top wraps to CANCEL")
eq(om2.scroll, 14, "wrapping to CANCEL scrolls to the tail")
eq(om2.index, 24, "up from the top wraps to CANCEL")
eq(om2.scroll, 19, "wrapping to CANCEL scrolls to the tail")
-- headless-safe: no love.audio, setters only update internal state
require("src.core.Music").applyOptions(og.save.options)
require("src.core.Sound").applyOptions(og.save.options)
@@ -2809,16 +2834,19 @@ do
menu:update(0)
eq(game.popCount(), 1, "Menu START-press closes when startCloses (start menu's PAD_START mask; no beep per HandleMenuInput_)")
-- both DisplayTwoOptionMenu branches hold 15 frames with the menu still
-- on screen before the answer lands (ChoiceBox.pending), so pump the
-- hold out after the press
game = stubGame({ a = true })
local yes
local box = ChoiceBox.new(game, function(v) yes = v end)
box:update(0)
for _ = 0, require("src.core.Timing").YES_NO_ANSWER do box:update(0) end
eq(yes, true, "ChoiceBox A on YES chooses true")
game = stubGame({ b = true })
local no
box = ChoiceBox.new(game, function(v) no = v end)
box:update(0)
for _ = 0, require("src.core.Timing").YES_NO_ANSWER do box:update(0) end
eq(no, false, "ChoiceBox B chooses false")
end
end
@@ -2837,7 +2865,8 @@ do
local qreturned = 0
local qg = {
data = Data, save = qsave, stack = qstack,
input = { wasPressed = function(_, k) return qpressed[k] end },
input = { wasPressed = function(_, k) return qpressed[k] end,
isDown = function(_, k) return qpressed[k] or false end },
returnToTitle = function() qreturned = qreturned + 1 end,
}
local qmenu = StartMenuQ.new(qg)
@@ -2858,7 +2887,12 @@ do
check(qbox ~= qmenu and qbox ~= nil and qbox.pages ~= nil,
"QUIT pushes a confirmation textbox")
eq(qbox.pages[1][1], "RETURN TO MAIN", "confirm asks RETURN TO MAIN MENU?")
qbox.onDone()
-- opts.choice: the box pushes the YES/NO itself once the last page has
-- typed out, so pump it rather than reaching for the old onDone hook
for _ = 1, 600 do
if qstack:top() ~= qbox then break end
qbox:update(1 / 60)
end
local qchoice = qstack:top()
check(qchoice ~= qbox and qchoice ~= nil and qchoice.onChoose ~= nil,
"textbox is followed by a YES/NO choice")
+5
View File
@@ -567,6 +567,7 @@ FIELD_ASSET_SYMBOLS = {
"FightIntroFrontMon",
"FightIntroFrontMon2",
"FightIntroFrontMon3",
"GameBoyTiles",
"GameFreakIntro",
"GameFreakLogoGraphics",
"GengarIntroTiles1",
@@ -575,6 +576,7 @@ FIELD_ASSET_SYMBOLS = {
"HappyEmote",
"HpBarAndStatusGraphics",
"LedgeHoppingShadow",
"LinkCableTiles",
"MoveAnimationTiles1",
"NintendoCopyrightLogoGraphics",
"PlayerCharacterTitleGraphics",
@@ -593,6 +595,9 @@ FIELD_ASSET_SYMBOLS = {
"SlotMachineTiles2",
"TheEndGfx",
"TownMapCursor",
"TradeBubbleIconGFX",
"TradingAnimationGraphics",
"TradingAnimationGraphics2",
"Version_GFX",
"WorldMapTileGraphics",
}
+20
View File
@@ -20442,6 +20442,10 @@
19,
21537
],
"GameBoyTiles": [
30,
23584
],
"GameCornerPrizeRoom_h": [
18,
20708
@@ -20818,6 +20822,10 @@
9,
21671
],
"LinkCableTiles": [
30,
23632
],
"LoreleiPic": [
19,
30585
@@ -22122,10 +22130,22 @@
28,
20288
],
"TradeBubbleIconGFX": [
28,
23129
],
"TradeCenter_h": [
19,
32004
],
"TradingAnimationGraphics": [
14,
27070
],
"TradingAnimationGraphics2": [
14,
27854
],
"TrainerAI": [
14,
25902
+20
View File
@@ -20419,6 +20419,10 @@
19,
21537
],
"GameBoyTiles": [
30,
23584
],
"GameCornerPrizeRoom_h": [
18,
20708
@@ -20795,6 +20799,10 @@
9,
21671
],
"LinkCableTiles": [
30,
23632
],
"LoreleiPic": [
19,
30585
@@ -22099,10 +22107,22 @@
28,
20288
],
"TradeBubbleIconGFX": [
28,
23129
],
"TradeCenter_h": [
19,
32004
],
"TradingAnimationGraphics": [
14,
27070
],
"TradingAnimationGraphics2": [
14,
27854
],
"TrainerAI": [
14,
25902
+20
View File
@@ -21703,6 +21703,10 @@
19,
21537
],
"GameBoyTiles": [
30,
23932
],
"GameCornerBeauty1Text": [
18,
19656
@@ -22251,6 +22255,10 @@
9,
21604
],
"LinkCableTiles": [
30,
23980
],
"LoreleiPic": [
19,
30454
@@ -26371,6 +26379,10 @@
28,
20420
],
"TradeBubbleIconGFX": [
28,
23302
],
"TradeCenterOpponentText": [
19,
32451
@@ -26379,6 +26391,14 @@
19,
32377
],
"TradingAnimationGraphics": [
14,
27240
],
"TradingAnimationGraphics2": [
14,
28024
],
"TrainerAI": [
14,
26034