Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f74e21782b | |||
| 8fbe819493 | |||
| 626080d228 | |||
| f56e82de81 | |||
| 7d3950658a | |||
| 3c1246ee06 | |||
| d69965e3b1 | |||
| 30edac03a6 | |||
| dd4aface93 | |||
| b1350fdecd | |||
| f2d9b74490 | |||
| 84d09d0894 | |||
| e61f231316 | |||
| 33998aab5d | |||
| 9ce7379946 | |||
| b692474e24 | |||
| 6cfc85ca83 | |||
| 7b060c19f4 |
@@ -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",
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 8.9 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 4.8 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 7.5 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 42 KiB |
@@ -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",
|
||||
|
||||
@@ -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?)"
|
||||
|
||||
@@ -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" \
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
@@ -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) })
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||