Compare commits

..

13 Commits

Author SHA1 Message Date
bryanthaboi 5ba49bdf3f Merge pull request #1524 from bryanthaboi/dev
squishing some fun ones
2026-08-18 11:55:28 -04:00
bryanthaboi abf95ce1c4 CLOSES #1519 2026-08-18 11:43:23 -04:00
bryanthaboi bd1046f398 CLOSES #1393 2026-08-18 11:29:50 -04:00
bryanthaboi d5ad830fb8 CLOSES #1418 2026-08-18 10:49:04 -04:00
bryanthaboi c2c7fdafcf CLOSES #1430 2026-08-18 10:36:44 -04:00
github-actions c01bda3570 chore(ios): update app-repo.json [skip ci] 2026-08-18 10:28:41 -04:00
bryanthaboi 74e04cb086 Merge pull request #1523 from bryanthaboi/dev
some bugs and some switch stuff
2026-08-18 10:18:58 -04:00
bryanthaboi 25ec896545 fix(audio): do not stop the move sfx source the replay just restarted
The takeover stop in Sound.playMove moved after the new source starts in
cfa84063, but an equal-id replay reuses the cached source, so the stop
killed the sound it had just restarted.
2026-08-18 10:14:44 -04:00
bryanthaboi 8240205254 ingested 2026-08-18 09:51:22 -04:00
bryanthaboi 55616fc03d CLOSES #1390 2026-08-18 09:20:07 -04:00
bryanthaboi 675971068e CLOSES #1503 2026-08-18 09:16:04 -04:00
bryanthaboi cfa8406306 CLOSES #1508 2026-08-18 04:52:00 -04:00
github-actions 0ea224d5db chore(ios): update app-repo.json [skip ci] 2026-08-17 23:13:43 -04:00
60 changed files with 2151 additions and 216 deletions
+4 -1
View File
@@ -120,7 +120,7 @@ jobs:
echo "changed=true" >> "$GITHUB_OUTPUT" echo "changed=true" >> "$GITHUB_OUTPUT"
exit 0 exit 0
fi fi
if git diff --name-only "$BASE_SHA" "$HEAD_SHA" | grep -Eq '^(scripts/build_switch\.sh$|scripts/switch/|docs/switch-.*\.md$|tests/switch_ci_workflows_test\.lua$|tests/switch_transfer_docs_test\.lua$|\.github/workflows/(ci|release|switch-artifact-comment)\.yml$|src/core/(NxAssetOverlay|Platform|GameVersion)\.lua$|src/import/CacheFs\.lua$|tests/engine/(assets_version_fallback|nx_generated_guard|nx_yellow_boot|switch_diagnostics)_test\.lua$|tests/engine/platform_nx)'; then if git diff --name-only "$BASE_SHA" "$HEAD_SHA" | grep -Eq '^(scripts/build_switch\.sh$|scripts/switch/|docs/switch-.*\.md$|tests/switch_ci_workflows_test\.lua$|tests/switch_transfer_docs_test\.lua$|\.github/workflows/(ci|release|switch-artifact-comment)\.yml$|src/core/(NxAssetOverlay|Platform|GameVersion)\.lua$|src/import/CacheFs\.lua$|tests/engine/(assets_version_fallback|nx_generated_guard|nx_yellow_boot|switch_diagnostics|cache_fs_gold_nx_load)_test\.lua$|tests/engine/platform_nx)'; then
echo "changed=true" >> "$GITHUB_OUTPUT" echo "changed=true" >> "$GITHUB_OUTPUT"
else else
echo "changed=false" >> "$GITHUB_OUTPUT" echo "changed=false" >> "$GITHUB_OUTPUT"
@@ -151,6 +151,9 @@ jobs:
luajit tests/engine/assets_version_fallback_test.lua luajit tests/engine/assets_version_fallback_test.lua
luajit tests/engine/nx_generated_guard_test.lua luajit tests/engine/nx_generated_guard_test.lua
luajit tests/engine/nx_yellow_boot_test.lua luajit tests/engine/nx_yellow_boot_test.lua
luajit tests/engine/cache_fs_gold_nx_load_test.lua
luajit tests/engine/cache_fs_blue_mount_test.lua
luajit tests/engine/switch_diagnostics_test.lua
switch-build: switch-build:
name: Switch fused build name: Switch fused build
+1 -1
View File
@@ -9,7 +9,7 @@ And before you say, "that's not a recomp", you're wrong. Recomp is an acronym. *
[Click Here for the AI Use Disclosure!](AIDisclosure.md) [Click Here for the AI Use Disclosure!](AIDisclosure.md)
> [!CAUTION] > [!CAUTION]
> **We are NOT affiliated with the website `gen1recomp[.]com`** That website is not run by this project, was not authorized by us, and we have no idea who operates it. It is impersonating this project; do not download anything from it, and treat anything it hosts or claims as untrustworthy. Even if the site currently links back to this repository, the people behind it can change its content at any time, so nothing on it should ever be trusted. This GitHub repository and the Discord linked below are the only official sources for this project. > **We are NOT affiliated with the website `gen1recomp[.]com`** That website is not run by this project, was not authorized by us, and we have no idea who operates it. It is impersonating this project; do not download anything from it, and treat anything it hosts or claims as untrustworthy. Even if the site currently links back to this repository, the people behind it can change its content at any time, so nothing on it should ever be trusted. This GitHub repository and the Discord linked below are the only official sources for this project. Also, as I assumed would eventually happen, the idiot that made that website now pumped it full of adware. Please stay away from that website.
<p align="center"><img src="https://raw.githubusercontent.com/bryanthaboi/gen1recomp/refs/heads/dev/assets/logo/logo.png"></p> <p align="center"><img src="https://raw.githubusercontent.com/bryanthaboi/gen1recomp/refs/heads/dev/assets/logo/logo.png"></p>
+1
View File
@@ -13,6 +13,7 @@ Features intentionally added beyond the original Pokémon Red, Blue, and Yellow
* **Mobile touch controls** with editable layouts, vibration, and orientation settings * **Mobile touch controls** with editable layouts, vibration, and orientation settings
* **Touch skins** in RetroArch overlay format, with bezel art, per-button press states, and Super Game Boy borders * **Touch skins** in RetroArch overlay format, with bezel art, per-button press states, and Super Game Boy borders
* **Pokédex diploma and printer image exports** * **Pokédex diploma and printer image exports**
* **Mod download counts** from the index feed, with Most-downloaded and Trending sorts
## Gen 2 Specifics ## Gen 2 Specifics
+10 -1
View File
@@ -26,7 +26,7 @@ as-is. Supported keys:
| `overlayN_overlay` | bezel image | | `overlayN_overlay` | bezel image |
| `overlayN_full_screen` | stretch the page to the window | | `overlayN_full_screen` | stretch the page to the window |
| `overlayN_rect` | page placement, default `0,0,1,1` | | `overlayN_rect` | page placement, default `0,0,1,1` |
| `overlayN_aspect_ratio` | fallback aspect when not full screen | | `overlayN_aspect_ratio` | design aspect; the overlay letterboxes to it even when full screen |
| `overlayN_range_mod`, `overlayN_alpha_mod` | desc defaults | | `overlayN_range_mod`, `overlayN_alpha_mod` | desc defaults |
| `overlayN_viewport` | `x,y,w,h`, the screen cutout | | `overlayN_viewport` | `x,y,w,h`, the screen cutout |
| `overlayN_viewport_fill` | parsed; the engine always fits, see below | | `overlayN_viewport_fill` | parsed; the engine always fits, see below |
@@ -162,6 +162,15 @@ coordinate its art was drawn at. Bind, hitbox shape, hit reach and idle and
pressed images are per control; the bezel, the pages and the screen cutout are pressed images are per control; the bezel, the pages and the screen cutout are
per page. The cutout is itself a draggable element with a 10:9 lock. per page. The cutout is itself a draggable element with a 10:9 lock.
Each page can **Lock** to portrait or landscape. With **Match canvas** on
(the default), Next page picks a matching mock device and the canvas preset
picks a matching page. Turn Match canvas off to look at a portrait page on a
landscape device.
A RetroArch overlay whose pages are already named portrait / landscape
(the auto-rotate convention) locks those pages and turns Match canvas on
when you open it. You do not have to click Lock first.
**Art.** The **Bezel**, **Idle art** and **Pressed art** rows cycle through the **Art.** The **Bezel**, **Idle art** and **Pressed art** rows cycle through the
images already in the skin folder; the **Import** button beside each one opens images already in the skin folder; the **Import** button beside each one opens
the host file picker (`src/core/FilePicker.lua`: osascript, PowerShell, the host file picker (`src/core/FilePicker.lua`: osascript, PowerShell,
+3 -1
View File
@@ -169,6 +169,7 @@ the NX runtime modules `src/core/NxAssetOverlay.lua`, `src/core/Platform.lua`,
`tests/engine/assets_version_fallback_test.lua`, `tests/engine/assets_version_fallback_test.lua`,
`tests/engine/nx_generated_guard_test.lua`, `tests/engine/nx_generated_guard_test.lua`,
`tests/engine/nx_yellow_boot_test.lua`, `tests/engine/nx_yellow_boot_test.lua`,
`tests/engine/cache_fs_gold_nx_load_test.lua`,
`tests/engine/switch_diagnostics_test.lua`, `tests/engine/platform_nx_*`, `tests/engine/switch_diagnostics_test.lua`, `tests/engine/platform_nx_*`,
or the Switch-related workflow YAML), CI runs: or the Switch-related workflow YAML), CI runs:
@@ -179,7 +180,8 @@ or the Switch-related workflow YAML), CI runs:
`luajit tests/switch_transfer_docs_test.lua`, and the NX engine suites `luajit tests/switch_transfer_docs_test.lua`, and the NX engine suites
headlessly (`luajit tests/engine/assets_version_fallback_test.lua`, headlessly (`luajit tests/engine/assets_version_fallback_test.lua`,
`luajit tests/engine/nx_generated_guard_test.lua`, `luajit tests/engine/nx_generated_guard_test.lua`,
`luajit tests/engine/nx_yellow_boot_test.lua`). `luajit tests/engine/nx_yellow_boot_test.lua`,
`luajit tests/engine/cache_fs_gold_nx_load_test.lua`).
2. **Fused NRO build** only on the **main** repository 2. **Fused NRO build** only on the **main** repository
(`bryanthaboi/gen1recomp`), on the self-hosted Mac runner (`bryanthaboi/gen1recomp`), on the self-hosted Mac runner
(`scripts/build_switch.sh --fetch --fused`), and only when the workflow (`scripts/build_switch.sh --fetch --fused`), and only when the workflow
+13 -9
View File
@@ -3,7 +3,7 @@
Every GitHub Release that includes Switch support ships an SD-ready zip: Every GitHub Release that includes Switch support ships an SD-ready zip:
`gen1recomp-*-switch.zip`. Extract it at the root of your microSD (install `gen1recomp-*-switch.zip`. Extract it at the root of your microSD (install
or update, same steps), launch with **title override**, then import your or update, same steps), launch with **title override**, then import your
own legal `.gb` ROM. own legal `.gb` / `.gbc` ROM.
> You need a console that can run Switch homebrew (custom firmware / hbmenu). > You need a console that can run Switch homebrew (custom firmware / hbmenu).
> This project does not help you set that up. > This project does not help you set that up.
@@ -91,13 +91,14 @@ Do **not** launch from the Album applet path for normal play.
This project ships **no** game data. On first launch: This project ships **no** game data. On first launch:
1. Put your own legally obtained Pokémon Red, Blue (`.gb`), or Yellow 1. Put your own legally obtained Pokémon Red, Blue (`.gb`), Yellow, or
(`.gbc`) dump into `switch/gen1recomp/pokemon-love2d/imports/` (the Gold (`.gbc`) dump into `switch/gen1recomp/pokemon-love2d/imports/` (the
launcher also shows the live save-dir path). All three can sit in the launcher also shows the live save-dir path). All four can sit in the
same folder. same folder.
2. Use **Scan again** on that game's tab (Red / Blue / Yellow). Rescan 2. Use **Scan again** on that game's tab (Red / Blue / Yellow / Gold).
matches by ROM SHA-1 for the open tab only. A Red dump never imports Rescan matches by ROM SHA-1 for the open tab only. A Red dump never
from the Yellow tab (and vice versa). imports from the Yellow tab (and vice versa). Gold is Beta in the
launcher; a clean US Gold dump is enough to Play.
## 5. Import / Export a raw `.sav` ## 5. Import / Export a raw `.sav`
@@ -109,10 +110,13 @@ SD / FTP, same transfer methods as ROMs. Paths are **per game**:
| Red | `imports/saves/red/` | `exports/red/` | | Red | `imports/saves/red/` | `exports/red/` |
| Blue | `imports/saves/blue/` | `exports/blue/` | | Blue | `imports/saves/blue/` | `exports/blue/` |
| Yellow | `imports/saves/yellow/` | `exports/yellow/` | | Yellow | `imports/saves/yellow/` | `exports/yellow/` |
| Gold | `imports/saves/gold/` | `exports/gold/` |
(Under the save dir `pokemon-love2d/`. The zip already creates these folders.) (Under the save dir `pokemon-love2d/`. The zip already creates these folders.
Gold cart `.sav` import/export is not supported yet -- the folders exist so
MTP browsing matches the other games. Gold progress still saves in-engine.)
1. Copy a Gen1 `.sav` (32 KB) into that game's inbox under the save dir 1. Copy a Gen 1 `.sav` (32 KB) into that game's inbox under the save dir
([switch-transfer.md](switch-transfer.md)). ([switch-transfer.md](switch-transfer.md)).
2. With the game's ROM already imported, open **that game's tab** 2. With the game's ROM already imported, open **that game's tab**
**SAVE FILES****Import save**. Only that folder is scanned. **SAVE FILES****Import save**. Only that folder is scanned.
+4 -4
View File
@@ -22,8 +22,8 @@ Player install (what to download, title override) stays in
| Loose iteration pair | `sdmc:/switch/gen1recomp/gen1recomp.nro` **and** `game.love` beside it | | Loose iteration pair | `sdmc:/switch/gen1recomp/gen1recomp.nro` **and** `game.love` beside it |
| ROM inbox | LÖVE save dir → `imports/` (launcher shows the live `getSaveDirectory()` path; under MTP often `1: SD Card/<save identity>/imports/`) | | ROM inbox | LÖVE save dir → `imports/` (launcher shows the live `getSaveDirectory()` path; under MTP often `1: SD Card/<save identity>/imports/`) |
| Mod zip inbox | Same save dir → `imports/mods/` then MODS → **Scan again** | | Mod zip inbox | Same save dir → `imports/mods/` then MODS → **Scan again** |
| Save `.sav` inbox | Same save dir → `imports/saves/red\|blue\|yellow/` then that game's SAVE FILES → **Import save** | | Save `.sav` inbox | Same save dir → `imports/saves/red\|blue\|yellow\|gold/` then that game's SAVE FILES → **Import save** (Gold cart `.sav` not supported yet) |
| Save exports | Same save dir → `exports/red\|blue\|yellow/` (pull after **Export save**; MTP / SD / FTP) | | Save exports | Same save dir → `exports/red\|blue\|yellow\|gold/` (pull after **Export save**; Gold cart `.sav` not supported yet) |
| Opt-in diagnostics | Empty `switch-debug.txt` in the save dir → `switch.log` | | Opt-in diagnostics | Empty `switch-debug.txt` in the save dir → `switch.log` |
| Lua error log | `lua-error.log` in the save dir | | Lua error log | `lua-error.log` in the save dir |
@@ -54,8 +54,8 @@ macOS, not a Mac-only requirement.
3. Create `switch/gen1recomp/` if needed; extract the release zip at SD root 3. Create `switch/gen1recomp/` if needed; extract the release zip at SD root
(or copy NRO / `game.love` for loose). (or copy NRO / `game.love` for loose).
4. For ROMs/mods/saves, open the save-dir `imports/`, `imports/mods/`, 4. For ROMs/mods/saves, open the save-dir `imports/`, `imports/mods/`,
`imports/saves/<red|blue|yellow>/`, or `exports/<red|blue|yellow>/` path the `imports/saves/<red|blue|yellow|gold>/`, or `exports/<red|blue|yellow|gold>/`
launcher prints. path the launcher prints.
5. Wait for the queue; refresh; exit MTP responder; title-override launch. 5. Wait for the queue; refresh; exit MTP responder; title-override launch.
macOS clients often create AppleDouble sidecars (`._Something.zip`, macOS clients often create AppleDouble sidecars (`._Something.zip`,
+14
View File
@@ -12,6 +12,20 @@
"tintColor": "3b5ca8", "tintColor": "3b5ca8",
"category": "games", "category": "games",
"versions": [ "versions": [
{
"version": "0.2.5",
"date": "2026-08-18",
"size": 13586158,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.5/gen1recomp++-0.2.5-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1390 switch and gold\n- #1503 Retroarch Skin Problem\n- #1508 Please check #1412 & #1414 again, we had a misunderstanding\n\n## Contributors\n\n- @bryanthaboi"
},
{
"version": "0.2.4",
"date": "2026-08-18",
"size": 13582747,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.4/gen1recomp++-0.2.4-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1496 Investigate Security according to https://hdbreaker.github.io/blog/pokemon-gen1recomp-hate-cheat/\n\n## Contributors\n\n- @bryanthaboi"
},
{ {
"version": "0.2.3", "version": "0.2.3",
"date": "2026-08-18", "date": "2026-08-18",
+12 -6
View File
@@ -71,15 +71,15 @@ First install or update (same steps):
your saves, imported ROMs, mods, and options. Re-extracting only your saves, imported ROMs, mods, and options. Re-extracting only
replaces the NRO(s) and these help files. replaces the NRO(s) and these help files.
3. Launch with title override (hold R on HOME, open any title → hbmenu). 3. Launch with title override (hold R on HOME, open any title → hbmenu).
4. Copy a legal Pokemon Red/Blue .gb into: 4. Copy a legal Pokemon Red/Blue .gb or Yellow/Gold .gbc into:
switch/gen1recomp/pokemon-love2d/imports/ switch/gen1recomp/pokemon-love2d/imports/
then use Scan again in the launcher if needed. then use Scan again in the launcher if needed.
Inboxes (drop files here via MTP / SD / FTP): Inboxes (drop files here via MTP / SD / FTP):
imports/ — ROM .gb / .gbc imports/ — ROM .gb / .gbc
imports/mods/ — community mod .zip imports/mods/ — community mod .zip
imports/saves/red|blue|yellow/ — raw .sav import imports/saves/red|blue|yellow|gold/ — raw .sav import (Gold cart .sav not yet)
exports/red|blue|yellow/ — pull after Export save exports/red|blue|yellow|gold/ — pull after Export save (Gold not yet)
Full guide: https://github.com/bryanthaboi/gen1recomp/blob/main/docs/switch-install.md Full guide: https://github.com/bryanthaboi/gen1recomp/blob/main/docs/switch-install.md
EOF EOF
@@ -92,7 +92,7 @@ write_readme() {
} }
write_readme "$SAVE_ROOT/imports/README.txt" \ write_readme "$SAVE_ROOT/imports/README.txt" \
"Put a legal Pokemon Red or Blue .gb / .gbc here, then Scan again in the launcher." "Put a legal Pokemon Red/Blue .gb or Yellow/Gold .gbc here, then Scan again in the launcher."
write_readme "$SAVE_ROOT/imports/mods/README.txt" \ write_readme "$SAVE_ROOT/imports/mods/README.txt" \
"Put community mod .zip files here, then MODS → Scan again." "Put community mod .zip files here, then MODS → Scan again."
write_readme "$SAVE_ROOT/imports/saves/red/README.txt" \ write_readme "$SAVE_ROOT/imports/saves/red/README.txt" \
@@ -101,12 +101,16 @@ write_readme "$SAVE_ROOT/imports/saves/blue/README.txt" \
"Put a Blue .sav (32 KB) here, then Blue tab → SAVE FILES → Import save." "Put a Blue .sav (32 KB) here, then Blue tab → SAVE FILES → Import save."
write_readme "$SAVE_ROOT/imports/saves/yellow/README.txt" \ write_readme "$SAVE_ROOT/imports/saves/yellow/README.txt" \
"Put a Yellow .sav (32 KB) here, then Yellow tab → SAVE FILES → Import save." "Put a Yellow .sav (32 KB) here, then Yellow tab → SAVE FILES → Import save."
write_readme "$SAVE_ROOT/imports/saves/gold/README.txt" \
"Gold cart .sav import is not supported yet. Folder reserved so MTP matches the other games."
write_readme "$SAVE_ROOT/exports/red/README.txt" \ write_readme "$SAVE_ROOT/exports/red/README.txt" \
"After Export save (Red), copy the .sav out of this folder via MTP / SD / FTP." "After Export save (Red), copy the .sav out of this folder via MTP / SD / FTP."
write_readme "$SAVE_ROOT/exports/blue/README.txt" \ write_readme "$SAVE_ROOT/exports/blue/README.txt" \
"After Export save (Blue), copy the .sav out of this folder via MTP / SD / FTP." "After Export save (Blue), copy the .sav out of this folder via MTP / SD / FTP."
write_readme "$SAVE_ROOT/exports/yellow/README.txt" \ write_readme "$SAVE_ROOT/exports/yellow/README.txt" \
"After Export save (Yellow), copy the .sav out of this folder via MTP / SD / FTP." "After Export save (Yellow), copy the .sav out of this folder via MTP / SD / FTP."
write_readme "$SAVE_ROOT/exports/gold/README.txt" \
"Gold cart .sav export is not supported yet. Folder reserved so MTP matches the other games."
rm -f "$OUT_ZIP" rm -f "$OUT_ZIP"
( (
@@ -135,9 +139,11 @@ REQUIRED=(
"switch/gen1recomp/pokemon-love2d/imports/saves/red/README.txt" "switch/gen1recomp/pokemon-love2d/imports/saves/red/README.txt"
"switch/gen1recomp/pokemon-love2d/imports/saves/blue/README.txt" "switch/gen1recomp/pokemon-love2d/imports/saves/blue/README.txt"
"switch/gen1recomp/pokemon-love2d/imports/saves/yellow/README.txt" "switch/gen1recomp/pokemon-love2d/imports/saves/yellow/README.txt"
"switch/gen1recomp/pokemon-love2d/imports/saves/gold/README.txt"
"switch/gen1recomp/pokemon-love2d/exports/red/README.txt" "switch/gen1recomp/pokemon-love2d/exports/red/README.txt"
"switch/gen1recomp/pokemon-love2d/exports/blue/README.txt" "switch/gen1recomp/pokemon-love2d/exports/blue/README.txt"
"switch/gen1recomp/pokemon-love2d/exports/yellow/README.txt" "switch/gen1recomp/pokemon-love2d/exports/yellow/README.txt"
"switch/gen1recomp/pokemon-love2d/exports/gold/README.txt"
) )
for rel in "${REQUIRED[@]}"; do for rel in "${REQUIRED[@]}"; do
printf '%s\n' "$LISTING" | grep -Fq "$rel" || fail "zip missing $rel" printf '%s\n' "$LISTING" | grep -Fq "$rel" || fail "zip missing $rel"
+3 -1
View File
@@ -270,9 +270,11 @@ for rel in \
"switch/gen1recomp/pokemon-love2d/imports/saves/red/README.txt" \ "switch/gen1recomp/pokemon-love2d/imports/saves/red/README.txt" \
"switch/gen1recomp/pokemon-love2d/imports/saves/blue/README.txt" \ "switch/gen1recomp/pokemon-love2d/imports/saves/blue/README.txt" \
"switch/gen1recomp/pokemon-love2d/imports/saves/yellow/README.txt" \ "switch/gen1recomp/pokemon-love2d/imports/saves/yellow/README.txt" \
"switch/gen1recomp/pokemon-love2d/imports/saves/gold/README.txt" \
"switch/gen1recomp/pokemon-love2d/exports/red/README.txt" \ "switch/gen1recomp/pokemon-love2d/exports/red/README.txt" \
"switch/gen1recomp/pokemon-love2d/exports/blue/README.txt" \ "switch/gen1recomp/pokemon-love2d/exports/blue/README.txt" \
"switch/gen1recomp/pokemon-love2d/exports/yellow/README.txt" "switch/gen1recomp/pokemon-love2d/exports/yellow/README.txt" \
"switch/gen1recomp/pokemon-love2d/exports/gold/README.txt"
do do
printf '%s\n' "$ZIP_LIST" | grep -Fq "$rel" || PACK_MISSING="${PACK_MISSING} ${rel}" printf '%s\n' "$ZIP_LIST" | grep -Fq "$rel" || PACK_MISSING="${PACK_MISSING} ${rel}"
done done
+1
View File
@@ -74,6 +74,7 @@ run_tier "T0 switch transfer docs gate" "$LUA" tests/switch_transfer_docs_test.l
run_tier "T0 NX asset overlay fallback" "$LUA" tests/engine/assets_version_fallback_test.lua run_tier "T0 NX asset overlay fallback" "$LUA" tests/engine/assets_version_fallback_test.lua
run_tier "T0 NX generated-path static guard" "$LUA" tests/engine/nx_generated_guard_test.lua run_tier "T0 NX generated-path static guard" "$LUA" tests/engine/nx_generated_guard_test.lua
run_tier "T0 NX Yellow/Blue boot (dynamic paths)" "$LUA" tests/engine/nx_yellow_boot_test.lua run_tier "T0 NX Yellow/Blue boot (dynamic paths)" "$LUA" tests/engine/nx_yellow_boot_test.lua
run_tier "T0 NX Gold cache load (maps.lua prefix)" "$LUA" tests/engine/cache_fs_gold_nx_load_test.lua
run_tier "T0 touch-controls pad cursor" "$LUA" tests/engine/touch_controls_pad_cursor_test.lua run_tier "T0 touch-controls pad cursor" "$LUA" tests/engine/touch_controls_pad_cursor_test.lua
run_tier "T1/T2 engine invariants + parity gates" "$LUA" tests/run_engine.lua run_tier "T1/T2 engine invariants + parity gates" "$LUA" tests/run_engine.lua
run_tier "T4 mod-SDK" "$LUA" tests/run_modkit.lua run_tier "T4 mod-SDK" "$LUA" tests/run_modkit.lua
+2 -1
View File
@@ -424,11 +424,12 @@ local function renderEffect(data, header, options)
return love.audio.newSource(sd, "static") return love.audio.newSource(sd, "static")
end end
function ChipAudio.newSfx(data, name, pitch, tempo, header) function ChipAudio.newSfx(data, name, pitch, tempo, header, plainFrames)
header = header or data.audio.sfx[name] header = header or data.audio.sfx[name]
return renderEffect(data, header, { return renderEffect(data, header, {
frequencyOffset = pitch or 0, frequencyOffset = pitch or 0,
frameTicks = 0x80 + (tempo or 0x80), frameTicks = 0x80 + (tempo or 0x80),
plainFrames = plainFrames,
}) })
end end
+12 -6
View File
@@ -280,6 +280,7 @@ function Channel.new(engine, spec, options)
allowLoops = options.allowLoops ~= false, allowLoops = options.allowLoops ~= false,
frequencyOffset = options.frequencyOffset or 0, frequencyOffset = options.frequencyOffset or 0,
frameTicks = options.frameTicks or FRAME_TICKS, frameTicks = options.frameTicks or FRAME_TICKS,
plainTicks = (options.plainFrames or 0) * FRAME_TICKS,
speed = 12, speed = 12,
noteLength = 1, -- Gen 2 CHANNEL_NOTE_LENGTH (note_type) noteLength = 1, -- Gen 2 CHANNEL_NOTE_LENGTH (note_type)
durationModifier = 0, -- Gen 2 fractional-frame carry durationModifier = 0, -- Gen 2 fractional-frame carry
@@ -349,8 +350,9 @@ function Channel:frequencyGen2(note, octave)
return bit.band(register + self.frequencyOffset, 0x7FF) return bit.band(register + self.frequencyOffset, 0x7FF)
end end
function Channel:durationTicks(length) function Channel:durationTicks(length, plain)
local tempo = self.sfx and self.frameTicks or self.engine.tempo local tempo = self.sfx and (plain and FRAME_TICKS or self.frameTicks)
or self.engine.tempo
local speed = self.sfx and (self.executeMusic and self.speed or 1) local speed = self.sfx and (self.executeMusic and self.speed or 1)
or self.speed or self.speed
return length * speed * tempo return length * speed * tempo
@@ -586,6 +588,9 @@ function Channel:nextEvent()
local packed = self:byte() local packed = self:byte()
local volume = bit.rshift(packed, 4) local volume = bit.rshift(packed, 4)
local fade = fadeValue(bit.band(packed, 0x0F)) local fade = fadeValue(bit.band(packed, 0x0F))
-- audio/engine_2.asm:991-1013, :1015-1033, :1077-1096
local plain = self.timeTicks < self.plainTicks
local offset = plain and 0 or self.frequencyOffset
if self.noise then if self.noise then
-- Audio2_ApplyWavePatternAndFrequency adds wFrequencyModifier to the -- Audio2_ApplyWavePatternAndFrequency adds wFrequencyModifier to the
-- frequency low byte for every channel at or past CHAN5, the noise -- frequency low byte for every channel at or past CHAN5, the noise
@@ -595,12 +600,12 @@ function Channel:nextEvent()
-- byte that noise does not use for frequency. Dropping it left the -- byte that noise does not use for frequency. Dropping it left the
-- battle hit sounds at their unmodified pitches, where super effective -- battle hit sounds at their unmodified pitches, where super effective
-- reads as the duller of the two (#826). -- reads as the duller of the two (#826).
local parameter = bit.band(self:byte() + self.frequencyOffset, 0xFF) local parameter = bit.band(self:byte() + offset, 0xFF)
return self:noiseEvent( return self:noiseEvent(
self:durationTicks(length), volume, fade, parameter) self:durationTicks(length, plain), volume, fade, parameter)
end end
local register = bit.band(self:word() + self.frequencyOffset, 0x7FF) local register = bit.band(self:word() + offset, 0x7FF)
return self:tone(self:durationTicks(length), register, volume, fade) return self:tone(self:durationTicks(length, plain), register, volume, fade)
elseif command == 0x10 then elseif command == 0x10 then
local packed = self:byte() local packed = self:byte()
self.sweep = { self.sweep = {
@@ -1260,6 +1265,7 @@ function Engine.new(data, header, options)
allowLoops = options.allowLoops, allowLoops = options.allowLoops,
frequencyOffset = options.frequencyOffset, frequencyOffset = options.frequencyOffset,
frameTicks = frameTicks, frameTicks = frameTicks,
plainFrames = options.plainFrames,
}) })
end end
return engine return engine
+6 -5
View File
@@ -120,11 +120,11 @@ local function visibleBaseState(stack)
end end
local function loadGenerated(path) local function loadGenerated(path)
local chunk = love.filesystem.load(path) -- CacheFs.loadActive, not love.filesystem.load: Gold's cache lives under
if not chunk then return nil end -- gold/ and fused NX often cannot mount that tree onto data/generated/.
local ok, data = pcall(chunk) local CacheFs = require("src.import.CacheFs")
if ok then return data end local data = CacheFs.loadActive(path)
return nil return data
end end
-- NewGame (engine/menus/intro_menu.asm) calls OakSpeech, and OakSpeech's first -- NewGame (engine/menus/intro_menu.asm) calls OakSpeech, and OakSpeech's first
@@ -1968,6 +1968,7 @@ function Game2:applyOptions()
}) })
require("src.core.VideoMode").applyOptions(options) require("src.core.VideoMode").applyOptions(options)
require("src.core.FrameCap").applyOptions(options) require("src.core.FrameCap").applyOptions(options)
require("src.world.gen2.BorderFill").applyOptions(options)
local GBCFX = require("src.render.GBCFX") local GBCFX = require("src.render.GBCFX")
if GBCFX.applyOptions(options) and self.save then if GBCFX.applyOptions(options) and self.save then
-- applyOptions returns true when it had to clear an unsupported level. -- applyOptions returns true when it had to clear an unsupported level.
+30 -13
View File
@@ -1,10 +1,11 @@
-- NX-only asset overlay: fused love-nx cannot reliably mount -- NX-only asset overlay: fused love-nx cannot reliably mount
-- blue|yellow/assets/generated onto the un-prefixed assets/generated, so -- blue|yellow|gold/{assets,data}/generated onto the un-prefixed paths, so
-- instead of teaching every call site about versioned caches, this module -- instead of teaching every call site about versioned caches, this module
-- wraps EVERY read-side love entry point that accepts a filesystem path -- wraps EVERY read-side love entry point that accepts a filesystem path
-- once at boot: any string path under assets/generated/ that does not -- once at boot: any string path under assets/generated/ or data/generated/
-- resolve falls back to the active version's prefixed copy -- prefers the active version's prefixed copy (yellow|blue|gold/...) when
-- (yellow|blue/assets/generated/...). Covering the whole read surface -- -- that file exists, so leftover unprefixed Red cache cannot shadow it.
-- Covering the whole read surface --
-- not just the loaders we happened to need -- is what keeps future states -- not just the loaders we happened to need -- is what keeps future states
-- and mods inside the fallback without anyone updating this file. -- and mods inside the fallback without anyone updating this file.
-- --
@@ -18,27 +19,43 @@
-- * the chip-audio worker (src/core/chip_worker.lua) is a separate Lua -- * the chip-audio worker (src/core/chip_worker.lua) is a separate Lua
-- state without these wrappers; ChipAudio.slimAudio hands it the prefix -- state without these wrappers; ChipAudio.slimAudio hands it the prefix
-- explicitly as audio.programPrefix. -- explicitly as audio.programPrefix.
-- * data/generated module loads go through CacheFs.readActive, which -- * Gen 1 Data:load and Gold Game2/World go through CacheFs.readActive /
-- already implements the same fallback for require bytes. -- CacheFs.loadActive, which implement the same fallback for Lua bytes.
-- data/generated is still rewritten here so any leftover
-- love.filesystem.load("data/generated/...") call (the Gold intro /
-- naming / maps hole on 0.2.4) stays inside the overlay.
local GameVersion = require("src.core.GameVersion") local GameVersion = require("src.core.GameVersion")
local GENERATED = "assets/generated/" local GENERATED_PREFIXES = {
"assets/generated/",
"data/generated/",
}
local NxAssetOverlay = {} local NxAssetOverlay = {}
local originals -- raw love functions, non-nil while installed local originals -- raw love functions, non-nil while installed
-- Resolve `path` to the versioned copy when the un-prefixed file is missing -- Resolve `path` to the active version's prefixed copy when that file
-- and the active version (Blue/Yellow) carries it. Returns nil when the -- exists (gold|yellow|blue|red/{assets,data}/generated/...). The versioned
-- caller's path should be used untouched (non-generated path, Red, the real -- tree wins over a leftover un-prefixed file so a pre-#899 Red root cache
-- file exists, or no versioned copy). -- (assets/generated/font.png in the save dir) cannot shadow Gold/Blue/
-- Yellow art that shares a name. Returns nil when the caller's path
-- should be used untouched (non-generated path, empty prefix, or no
-- versioned copy).
local function versioned(path) local function versioned(path)
if type(path) ~= "string" then return nil end if type(path) ~= "string" then return nil end
if path:sub(1, #GENERATED) ~= GENERATED then return nil end local generated = false
for i = 1, #GENERATED_PREFIXES do
local gen = GENERATED_PREFIXES[i]
if path:sub(1, #gen) == gen then
generated = true
break
end
end
if not generated then return nil end
local prefix = GameVersion.cachePrefix() local prefix = GameVersion.cachePrefix()
if prefix == "" then return nil end if prefix == "" then return nil end
if originals.getInfo(path) then return nil end
local candidate = prefix .. path local candidate = prefix .. path
if originals.getInfo(candidate) then return candidate end if originals.getInfo(candidate) then return candidate end
return nil return nil
+109 -36
View File
@@ -8,6 +8,7 @@
local Assets = require("src.render.Assets") local Assets = require("src.render.Assets")
local Logger = require("src.core.Logger") local Logger = require("src.core.Logger")
local Runtime = require("src.mods.Runtime") local Runtime = require("src.mods.Runtime")
local bit = require("bit")
local Sound = {} local Sound = {}
@@ -142,10 +143,10 @@ local function newFileSource(def)
return s return s
end end
local function newSfxSource(data, key, def, pitch, tempo) local function newSfxSource(data, key, def, pitch, tempo, plain)
if isChipDef(def) then if isChipDef(def) then
local ok, s = pcall(require("src.core.ChipAudio").newSfx, local ok, s = pcall(require("src.core.ChipAudio").newSfx,
data, key:match("^([^@]+)") or key, pitch, tempo, def) data, key:match("^([^@]+)") or key, pitch, tempo, def, plain)
if not ok then return nil, tostring(s) end if not ok then return nil, tostring(s) end
if not s then return nil, "no source" end if not s then return nil, "no source" end
return s return s
@@ -153,12 +154,12 @@ local function newSfxSource(data, key, def, pitch, tempo)
return newFileSource(def) return newFileSource(def)
end end
local function playPath(data, key, def, pitch, tempo) local function playPath(data, key, def, pitch, tempo, plain)
if not love.audio or not def then return nil end if not love.audio or not def then return nil end
local src = cache[key] local src = cache[key]
if src == false then return nil end -- known bad, already logged if src == false then return nil end -- known bad, already logged
if not src then if not src then
local s, err = newSfxSource(data, key, def, pitch, tempo) local s, err = newSfxSource(data, key, def, pitch, tempo, plain)
if not s then if not s then
cache[key] = false cache[key] = false
reportBadDef("sfx", key, owner(data, "sfx", key), err) reportBadDef("sfx", key, owner(data, "sfx", key), err)
@@ -413,50 +414,109 @@ end
-- still sounding when the second row starts, so the original never plays -- still sounding when the second row starts, so the original never plays
-- SFX_BATTLE_2A (CHAN5+6+8) at all -- unguarded, its tail is heard running -- SFX_BATTLE_2A (CHAN5+6+8) at all -- unguarded, its tail is heard running
-- past the end of the animation (#844). -- past the end of the animation (#844).
local lastMoveSfx -- { src, rank, engine, channels } of the last row sound local moveSfxChannels = {} -- software channel (5-8) -> { src, address, engine }
local function channelsOverlap(a, b) local function sourceAlive(entry)
if not (a and b) then return false end local ok, playing = pcall(entry.src.isPlaying, entry.src)
for _, x in ipairs(a) do return ok and playing
for _, y in ipairs(b) do end
if x == y then return true end
end local function pruneMoveSfx()
for ch, cur in pairs(moveSfxChannels) do
if not sourceAlive(cur) then moveSfxChannels[ch] = nil end
end end
return false
end end
-- would PlaySound start this def now? Taking a channel over also stops the -- would PlaySound start this def now? Taking a channel over also stops the
-- sound that held it, the way .playChannel resets the channel. -- sound that held it, the way .playChannel resets the channel.
local function sfxChannelGate(data, def) local function sfxChannelGate(data, def)
local cur = lastMoveSfx pruneMoveSfx()
if not cur then return true end
local ok, playing = pcall(cur.src.isPlaying, cur.src)
if not (ok and playing) then
lastMoveSfx = nil
return true
end
-- an unrankable def (file asset, or another engine's bank) has no -- an unrankable def (file asset, or another engine's bank) has no
-- comparable sound id: leave it to the mixer, as before -- comparable sound id: leave it to the mixer, as before
if type(def) ~= "table" or not def.address or def.engine ~= cur.engine then if type(def) ~= "table" or not def.address then return true end
return true
end
local channels = require("src.core.ChipSynth").effectChannels(data, def) local channels = require("src.core.ChipSynth").effectChannels(data, def)
if not channelsOverlap(channels, cur.channels) then return true end if not channels then return true end
if def.address > cur.rank then return false end local takeover
pcall(cur.src.stop, cur.src) for _, ch in ipairs(channels) do
lastMoveSfx = nil local cur = moveSfxChannels[ch]
return true if cur and cur.engine == def.engine then
if def.address > cur.address then return false end
takeover = takeover or {}
takeover[cur.src] = true
end
end
if takeover then
for ch, cur in pairs(moveSfxChannels) do
if takeover[cur.src] then moveSfxChannels[ch] = nil end
end
end
return true, takeover
end end
local function noteMoveSfx(data, def, src) local function noteMoveSfx(data, def, src)
if not src or type(def) ~= "table" or not def.address then if not src or type(def) ~= "table" or not def.address then
lastMoveSfx = nil moveSfxChannels = {}
return return
end end
lastMoveSfx = { local channels = require("src.core.ChipSynth").effectChannels(data, def)
src = src, rank = def.address, engine = def.engine, if not channels then
channels = require("src.core.ChipSynth").effectChannels(data, def), moveSfxChannels = {}
} return
end
local entry = { src = src, address = def.address, engine = def.engine }
for _, ch in ipairs(channels) do moveSfxChannels[ch] = entry end
end
-- constants/music_constants.asm:4
local function sfxHeaderId(def)
if type(def) ~= "table" or not def.address then return nil end
local rel = def.address - 0x4000
if rel <= 0 or rel % 3 ~= 0 then return nil end
return rel / 3
end
local function remainingFrames(src)
local ok, dur = pcall(src.getDuration, src, "seconds")
if not ok or type(dur) ~= "number" then return nil end
local pos
ok, pos = pcall(src.tell, src, "seconds")
if not ok or type(pos) ~= "number" then return nil end
return math.max(0, math.ceil((dur - pos) * 60))
end
-- audio/engine_2.asm:1077-1096, :991-1013, :1015-1033
local function plainMoveFrames(data, def, channels)
local id = sfxHeaderId(def)
local sfx = data.audio and data.audio.sfx
if not (id and sfx and channels) then return 0 end
local first = sfxHeaderId(sfx.Peck) -- constants/music_constants.asm:178
local last = sfxHeaderId(sfx.Trainer_Appeared) -- constants/music_constants.asm:228
if not (first and last) then return 0 end
local claimed = {}
for _, ch in ipairs(channels) do claimed[ch] = true end
local base = (claimed[5] or claimed[8]) and id or 0
local others = {}
for _, ch in ipairs({ 5, 8 }) do
local cur = (not claimed[ch]) and moveSfxChannels[ch] or nil
if cur and cur.engine == def.engine and sourceAlive(cur) then
local otherId = sfxHeaderId(cur)
local rem = remainingFrames(cur.src)
if otherId and rem and rem > 0 then
others[#others + 1] = { id = otherId, rem = rem }
end
end
end
table.sort(others, function(a, b) return a.rem < b.rem end)
local plain = 0
for drop = 0, #others do
local combined = base
for i = drop + 1, #others do
combined = bit.bor(combined, others[i].id)
end
if combined >= first and combined <= last then return plain end
if drop < #others then plain = others[drop + 1].rem end
end
return plain
end end
function Sound.playMove(data, anim) function Sound.playMove(data, anim)
@@ -466,13 +526,20 @@ function Sound.playMove(data, anim)
local name = anim.sound local name = anim.sound
local pitch, tempo = anim.pitch or 0, anim.tempo or 0x80 local pitch, tempo = anim.pitch or 0, anim.tempo or 0x80
local def = sfx[name] local def = sfx[name]
if not sfxChannelGate(data, def) then return end local allowed, superseded = sfxChannelGate(data, def)
if not allowed then return end
local src local src
-- a chip program synthesizes the modified variant on demand; a file def -- a chip program synthesizes the modified variant on demand; a file def
-- can only reach for a pre-rendered one -- can only reach for a pre-rendered one
if isChipDef(def) then if isChipDef(def) then
src = playPath(data, ("%s@%02x%02x"):format(name, pitch, tempo), local plain = 0
def, pitch, tempo) if pitch ~= 0 or tempo ~= 0x80 then
local channels = require("src.core.ChipSynth").effectChannels(data, def)
plain = plainMoveFrames(data, def, channels)
end
local key = ("%s@%02x%02x"):format(name, pitch, tempo)
if plain > 0 then key = ("%s~%d"):format(key, plain) end
src = playPath(data, key, def, pitch, tempo, plain)
else else
local key = ("%s@%02x%02x"):format(name, pitch, tempo) local key = ("%s@%02x%02x"):format(name, pitch, tempo)
if (pitch ~= 0 or tempo ~= 0x80) and sfx[key] then if (pitch ~= 0 or tempo ~= 0x80) and sfx[key] then
@@ -481,6 +548,12 @@ function Sound.playMove(data, anim)
src = playPath(data, name, def) src = playPath(data, name, def)
end end
end end
-- audio/engine_2.asm:1537
if superseded then
for old in pairs(superseded) do
if old ~= src then pcall(old.stop, old) end
end
end
if src then if src then
played("move", name) played("move", name)
noteMoveSfx(data, def, src) noteMoveSfx(data, def, src)
@@ -711,7 +784,7 @@ end
-- hot reload / jukebox A-B: drop one key's sources (its pitch-tempo -- hot reload / jukebox A-B: drop one key's sources (its pitch-tempo
-- variants included) or all of them, so the next play re-resolves the def -- variants included) or all of them, so the next play re-resolves the def
function Sound.invalidate(name) function Sound.invalidate(name)
lastMoveSfx = nil -- its source is about to be dropped or stopped moveSfxChannels = {} -- their sources are about to be dropped or stopped
-- Same for wCurSFX, and a reloaded table can repoint the id order. -- Same for wCurSFX, and a reloaded table can repoint the id order.
curSfx = nil curSfx = nil
sfxIds = nil sfxIds = nil
+90 -4
View File
@@ -142,7 +142,11 @@ function TouchSkin.parse(text)
controls = {}, controls = {},
} }
if page.imagePath == "" then page.imagePath = nil end if page.imagePath == "" then page.imagePath = nil end
if not page.aspect or page.aspect <= 0 then -- An explicit aspect_ratio is the overlay's design aspect. RetroArch
-- letterboxes to it even when full_screen is set, so range_x/range_y
-- that were authored as a circle stay a circle. #1503
page.aspectFromCfg = page.aspect ~= nil and page.aspect > 0
if not page.aspectFromCfg then
page.aspect = page.name:lower():find("portrait", 1, true) page.aspect = page.name:lower():find("portrait", 1, true)
and PORTRAIT_ASPECT or DEFAULT_ASPECT and PORTRAIT_ASPECT or DEFAULT_ASPECT
end end
@@ -170,6 +174,12 @@ function TouchSkin.parse(text)
pages[#pages + 1] = page pages[#pages + 1] = page
end end
-- RetroArch auto-rotate: overlay names containing portrait / landscape
-- are the lock. Stamp it so the studio does not need a second click. #1503
for _, page in ipairs(pages) do
if not page.orient then page.orient = TouchSkin.pageOrient(page) end
end
return { pages = pages } return { pages = pages }
end end
@@ -255,6 +265,9 @@ function TouchSkin.parseNative(text)
rangeMod = num(raw.rangeMod, 1), rangeMod = num(raw.rangeMod, 1),
alphaMod = num(raw.alphaMod, 1), alphaMod = num(raw.alphaMod, 1),
aspect = num(raw.aspect, DEFAULT_ASPECT), aspect = num(raw.aspect, DEFAULT_ASPECT),
aspectFromCfg = raw.fitAspect == true,
orient = (raw.orient == "portrait" or raw.orient == "landscape"
or raw.orient == "any") and raw.orient or nil,
rect = { x = 0, y = 0, w = 1, h = 1 }, rect = { x = 0, y = 0, w = 1, h = 1 },
controls = {}, controls = {},
} }
@@ -287,6 +300,7 @@ function TouchSkin.parseNative(text)
nextTarget = c.nextTarget, nextTarget = c.nextTarget,
} }
end end
if not page.orient then page.orient = TouchSkin.pageOrient(page) end
pages[#pages + 1] = page pages[#pages + 1] = page
end end
return { pages = pages, name = data.name, author = data.author, return { pages = pages, name = data.name, author = data.author,
@@ -309,6 +323,9 @@ function TouchSkin.toNative(skin)
rangeMod = page.rangeMod, rangeMod = page.rangeMod,
alphaMod = page.alphaMod, alphaMod = page.alphaMod,
aspect = page.aspect, aspect = page.aspect,
fitAspect = page.aspectFromCfg or nil,
orient = (page.orient == "portrait" or page.orient == "landscape"
or page.orient == "any") and page.orient or nil,
controls = {}, controls = {},
} }
if page.rect and (page.rect.x ~= 0 or page.rect.y ~= 0 if page.rect and (page.rect.x ~= 0 or page.rect.y ~= 0
@@ -704,6 +721,10 @@ end
TouchSkin.active = nil TouchSkin.active = nil
TouchSkin.pageIndex = 1 TouchSkin.pageIndex = 1
-- RetroArch Auto-Rotate Overlay (1.7.9, default on mobile): a cfg whose
-- pages are named portrait / landscape is swapped to match the display.
-- The Skin Studio turns this off so PAGE and the canvas preset stay independent.
TouchSkin.autoOrient = true
TouchSkin.surfaceRect = nil TouchSkin.surfaceRect = nil
@@ -732,9 +753,68 @@ function TouchSkin.select(id)
return TouchSkin.setActive(skin) return TouchSkin.setActive(skin)
end end
local function displaySize()
local r = TouchSkin.surfaceRect
if r and r.w and r.h and r.w > 0 and r.h > 0 then return r.w, r.h end
if love and love.graphics and love.graphics.getDimensions then
return love.graphics.getDimensions()
end
return 0, 0
end
-- Explicit lock (studio) wins; otherwise the page name, the RetroArch
-- auto-rotate convention. "any" means unlocked even if the name says
-- portrait or landscape.
function TouchSkin.pageOrient(page)
if not page then return nil end
if page.orient == "any" then return nil end
if page.orient == "portrait" or page.orient == "landscape" then
return page.orient
end
local n = tostring(page.name or ""):lower()
if n:find("landscape", 1, true) then return "landscape" end
if n:find("portrait", 1, true) then return "portrait" end
return nil
end
function TouchSkin.hasOrientPair(skin)
local saw = {}
for _, page in ipairs(skin and skin.pages or {}) do
local o = TouchSkin.pageOrient(page)
if o then saw[o] = true end
end
return saw.portrait == true and saw.landscape == true
end
local function findOrientPage(skin, keyword)
for i, page in ipairs(skin.pages or {}) do
if TouchSkin.pageOrient(page) == keyword then return i end
end
return nil
end
-- If the current page is the wrong orientation of a portrait/landscape pair,
-- jump to the matching one. Pages locked to neither (gb_anim's GameBoy /
-- GameBoyColor) are left alone. #1503
function TouchSkin.syncOrientation(w, h)
if not TouchSkin.autoOrient then return end
local skin = TouchSkin.active
if not skin or not w or not h or w <= 0 or h <= 0 then return end
local want = w > h and "landscape" or "portrait"
local unwant = w > h and "portrait" or "landscape"
local page = skin.pages[TouchSkin.pageIndex] or skin.pages[1]
local current = TouchSkin.pageOrient(page)
if current == want then return end
if current ~= unwant then return end
local idx = findOrientPage(skin, want)
if idx then TouchSkin.pageIndex = idx end
end
function TouchSkin.page() function TouchSkin.page()
local skin = TouchSkin.active local skin = TouchSkin.active
if not skin then return nil end if not skin then return nil end
local w, h = displaySize()
TouchSkin.syncOrientation(w, h)
return skin.pages[TouchSkin.pageIndex] or skin.pages[1] return skin.pages[TouchSkin.pageIndex] or skin.pages[1]
end end
@@ -766,7 +846,12 @@ function TouchSkin.pageBox(page, w, h, ox, oy)
ox, oy = ox or 0, oy or 0 ox, oy = ox or 0, oy or 0
if not page then return ox, oy, w, h end if not page then return ox, oy, w, h end
local bx, by, bw, bh = ox, oy, w, h local bx, by, bw, bh = ox, oy, w, h
if not page.fullScreen and h > 0 then -- full_screen means "relative to the window, not the game viewport".
-- When the cfg also names an aspect_ratio, that window is then fitted
-- to the overlay's design aspect so buttons do not stretch. #1503
local fit = ((not page.fullScreen) or page.aspectFromCfg)
and page.aspect and page.aspect > 0 and h > 0
if fit then
local displayAspect = w / h local displayAspect = w / h
if displayAspect > page.aspect then if displayAspect > page.aspect then
bw = h * page.aspect bw = h * page.aspect
@@ -833,8 +918,9 @@ function TouchSkin.viewport(w, h, ox, oy)
local page = TouchSkin.page() local page = TouchSkin.page()
if not page or not page.viewport or not TouchSkin.drawable() then return nil end if not page or not page.viewport or not TouchSkin.drawable() then return nil end
local v = page.viewport local v = page.viewport
local x, y = (ox or 0) + v.x * w, (oy or 0) + v.y * h local bx, by, bw, bh = TouchSkin.pageBox(page, w, h, ox, oy)
local vw, vh = v.w * w, v.h * h local x, y = bx + v.x * bw, by + v.y * bh
local vw, vh = v.w * bw, v.h * bh
if vw <= 0 or vh <= 0 then return nil end if vw <= 0 or vh <= 0 then return nil end
return x, y, vw, vh, page.viewportFill == true, page.viewportExpand == true return x, y, vw, vh, page.viewportFill == true, page.viewportExpand == true
end end
+3
View File
@@ -274,6 +274,9 @@ Save.DEFAULT_OPTIONS = {
color = "gbc", color = "gbc",
videoMode = "windowed", videoMode = "windowed",
fpsCap = 60, fpsCap = 60,
-- VOID FILL: fade | water | trees | black. fade is each map header's own
-- border block with the dissolve across a boundary (#1418).
voidFill = "fade",
musicVol = 7, -- 0-7, like the GB's NR50 master volume musicVol = 7, -- 0-7, like the GB's NR50 master volume
sfxVol = 7, -- 0-7 sfxVol = 7, -- 0-7
musicFilter = 0, -- low-pass steps, 0 = off musicFilter = 0, -- low-pass steps, 0 = off
+19 -7
View File
@@ -220,6 +220,9 @@ function SwitchDiagnostics.probeAssets(version)
"assets/generated/tilesets/reds_house.png", "assets/generated/tilesets/reds_house.png",
"assets/generated/sprites/red.png", "assets/generated/sprites/red.png",
"assets/generated/sprites/monster.png", "assets/generated/sprites/monster.png",
"data/generated/maps.lua",
"data/generated/oak_speech.lua",
"data/generated/font.lua",
} }
for _, path in ipairs(samples) do for _, path in ipairs(samples) do
local versioned = prefix ~= "" and (prefix .. path) or path local versioned = prefix ~= "" and (prefix .. path) or path
@@ -230,17 +233,26 @@ function SwitchDiagnostics.probeAssets(version)
lines[#lines + 1] = "versioned=" .. probeInfo(filesystem, versioned) lines[#lines + 1] = "versioned=" .. probeInfo(filesystem, versioned)
end end
lines[#lines + 1] = "resolve=" .. tostring(resolved) lines[#lines + 1] = "resolve=" .. tostring(resolved)
lines[#lines + 1] = "newImage=" .. probeOpen("image", resolved) if path:sub(-4) == ".lua" then
lines[#lines + 1] = "newImageData=" .. probeOpen("imageData", resolved) local CacheFs = require("src.import.CacheFs")
if prefix ~= "" and resolved ~= versioned then local loaded, err = CacheFs.loadActive(path)
lines[#lines + 1] = "newImage_versioned=" .. probeOpen("image", versioned) lines[#lines + 1] = "loadActive=" .. (loaded ~= nil and "ok"
lines[#lines + 1] = "newImageData_versioned=" .. probeOpen("imageData", versioned) or ("FAIL " .. tostring(err):gsub("%s+", " "):sub(1, 160)))
else
lines[#lines + 1] = "newImage=" .. probeOpen("image", resolved)
lines[#lines + 1] = "newImageData=" .. probeOpen("imageData", resolved)
if prefix ~= "" and resolved ~= versioned then
lines[#lines + 1] = "newImage_versioned=" .. probeOpen("image", versioned)
lines[#lines + 1] = "newImageData_versioned=" .. probeOpen("imageData", versioned)
end
end end
end end
-- Shallow listing so we can see if the extract tree exists at all. -- Shallow listing so we can see if the extract tree exists at all.
local roots = { "yellow", "blue", "assets", "yellow/assets/generated", local roots = { "yellow", "blue", "gold", "assets", "yellow/assets/generated",
"yellow/assets/generated/sprites", "blue/assets/generated/sprites" } "yellow/assets/generated/sprites", "blue/assets/generated/sprites",
"gold/assets/generated", "gold/assets/generated/sprites",
"gold/data/generated" }
for _, dir in ipairs(roots) do for _, dir in ipairs(roots) do
local info = filesystem.getInfo(dir) local info = filesystem.getInfo(dir)
if info and info.type == "directory" and filesystem.getDirectoryItems then if info and info.type == "directory" and filesystem.getDirectoryItems then
+28 -2
View File
@@ -34,7 +34,7 @@ local SEP = package.config:sub(1, 1)
-- Cache-relative paths are prefixed with this before every read/write, so a -- Cache-relative paths are prefixed with this before every read/write, so a
-- version's import lands under its GameVersion.cachePrefix (red/, blue/, -- version's import lands under its GameVersion.cachePrefix (red/, blue/,
-- yellow/). The launcher sets it per import / per readiness check; it stays -- yellow/, gold/). The launcher sets it per import / per readiness check; it stays
-- "" outside those flows. Runtime *reads* (require / newImage) do NOT go -- "" outside those flows. Runtime *reads* (require / newImage) do NOT go
-- through here -- CacheFs.mountVersion overlays the active version's subtree -- through here -- CacheFs.mountVersion overlays the active version's subtree
-- onto the un-prefixed paths instead. -- onto the un-prefixed paths instead.
@@ -305,7 +305,7 @@ function CacheFs.read(rel)
end end
-- Read cache-relative `rel` for the active GameVersion when PhysFS may hide -- Read cache-relative `rel` for the active GameVersion when PhysFS may hide
-- prefixed Blue/Yellow trees (fused NX mount hole). Same order Data:load -- prefixed Blue/Yellow/Gold trees (fused NX mount hole). Same order Data:load
-- already used: active version prefix with CacheFs.prefix cleared, then -- already used: active version prefix with CacheFs.prefix cleared, then
-- `rel` under the caller's CacheFs.prefix. Returns the bytes or nil. -- `rel` under the caller's CacheFs.prefix. Returns the bytes or nil.
function CacheFs.readActive(rel) function CacheFs.readActive(rel)
@@ -322,6 +322,32 @@ function CacheFs.readActive(rel)
return nil return nil
end end
-- Load a generated Lua table the way Data:load does: versioned save-dir
-- bytes first (gold/data/generated/maps.lua), then the un-prefixed path.
-- Game2/World used love.filesystem.load("data/generated/...") which misses
-- on fused NX when the gold/ overlay mount fails -- intro art still loads
-- via NxAssetOverlay, but oak_speech.lua / font.lua / maps.lua do not.
function CacheFs.loadActive(rel)
local bytes = CacheFs.readActive(rel)
if type(bytes) == "string" then
local GameVersion = require("src.core.GameVersion")
local loader = loadstring or load
local chunk, err = loader(bytes, "@" .. GameVersion.cachePrefix() .. rel)
if not chunk then return nil, err end
local ok, value = pcall(chunk)
if not ok then return nil, value end
return value
end
if love and love.filesystem and love.filesystem.load then
local chunk, err = love.filesystem.load(rel)
if not chunk then return nil, err end
local ok, value = pcall(chunk)
if not ok then return nil, value end
return value
end
return nil, "Could not open file " .. rel .. ". Does not exist."
end
-- does cache-relative `rel` exist as a file? -- does cache-relative `rel` exist as a file?
function CacheFs.exists(rel) function CacheFs.exists(rel)
rel = withPrefix(rel) rel = withPrefix(rel)
+18 -3
View File
@@ -529,9 +529,9 @@ end
-- Gold reads NONE of the rows above. Its OPTION screen writes a different -- Gold reads NONE of the rows above. Its OPTION screen writes a different
-- set of names, several of which collide with Gen 1's at a different TYPE -- set of names, several of which collide with Gen 1's at a different TYPE
-- (battleStyle "SHIFT" vs "shift", textSpeed a label vs a frame delay), and -- (battleStyle "SHIFT" vs "shift", textSpeed a label vs a frame delay), and
-- its renderer has no battle layout, no SGB palette packs and no void fill -- -- its renderer has no battle layout and no SGB palette packs -- so a gear
-- so a gear opened on the Gold tab used to offer a dozen controls that did -- opened on the Gold tab used to offer a dozen controls that did nothing
-- nothing and hide the seven that the cart itself has. -- and hide the seven that the cart itself has.
-- --
-- The block lives in options.lua under `gold`, which is exactly where -- The block lives in options.lua under `gold`, which is exactly where
-- src/core/gen2/Save.lua loadOptions reads it, so an edit here is live on the -- src/core/gen2/Save.lua loadOptions reads it, so an edit here is live on the
@@ -617,6 +617,21 @@ local function gen2Rows(opts, hooks)
end) end)
end end
local okFill, BorderFill = pcall(require, "src.world.gen2.BorderFill")
if okFill and BorderFill.VOID_FILLS then
add(Strings("VOID FILL"),
function() return BorderFill.voidFillLabel(opts.voidFill) end,
function(dir)
local modes = BorderFill.VOID_FILLS
local cur, idx = opts.voidFill or "fade", 1
for i, m in ipairs(modes) do
if m == cur then idx = i break end
end
opts.voidFill = modes[wrapIndex(idx - 1 + dir, #modes) + 1]
return true
end)
end
-- Same #136 gate as the Gen 1 row and the in-game one. -- Same #136 gate as the Gen 1 row and the in-game one.
local okFx, GBCFX = pcall(require, "src.render.GBCFX") local okFx, GBCFX = pcall(require, "src.render.GBCFX")
if okFx and GBCFX.isSupported() then if okFx and GBCFX.isSupported() then
+51 -23
View File
@@ -1626,13 +1626,17 @@ end
-- The persisted sort choice both mod panels share. The chooser itself is a -- The persisted sort choice both mod panels share. The chooser itself is a
-- popup (buildSortModal); panels just read the current key and offer a -- popup (buildSortModal); panels just read the current key and offer a
-- "Sort" button, which is what freed the chip row's two lines of space. -- "Sort" button, which is what freed the chip row's two lines of space.
local function sortDefs() local function sortDefs(scope)
return { local defs = {
{ key = "name", label = Strings("Name") }, { key = "name", label = Strings("Name") },
{ key = "popularity", label = Strings("Popularity") }, { key = "popularity", label = Strings("Most downloaded") },
{ key = "release", label = Strings("Release date") },
{ key = "updated", label = Strings("Last updated") },
} }
if scope == "find" then
defs[#defs + 1] = { key = "trending", label = Strings("Trending") }
end
defs[#defs + 1] = { key = "release", label = Strings("Release date") }
defs[#defs + 1] = { key = "updated", label = Strings("Last updated") }
return defs
end end
-- Sorting is decorate-sort-undecorate: the key is computed once per entry -- Sorting is decorate-sort-undecorate: the key is computed once per entry
@@ -1684,7 +1688,7 @@ local function sortCacheOk(cache, src, key, rev, pending)
return pending and (Kit.time - (cache.at or 0)) < RESORT_DEBOUNCE return pending and (Kit.time - (cache.at or 0)) < RESORT_DEBOUNCE
end end
local function currentSort(imp) local function currentSort(imp, scope)
local sortKey = imp.modSort local sortKey = imp.modSort
if sortKey == nil then if sortKey == nil then
local ok, opts = pcall(require("src.core.SaveData").loadOptions) local ok, opts = pcall(require("src.core.SaveData").loadOptions)
@@ -1694,6 +1698,7 @@ local function currentSort(imp)
sortKey = sortKey or "popularity" sortKey = sortKey or "popularity"
imp.modSort = sortKey imp.modSort = sortKey
end end
if sortKey == "trending" and scope ~= "find" then return "popularity" end
return sortKey return sortKey
end end
@@ -1760,7 +1765,7 @@ local function buildModsPanel(imp, x, y, w, availH, m)
action = function() imp:_syncModUpdateInfo(true) end }) action = function() imp:_syncModUpdateInfo(true) end })
btn(imp, place(sortW), cy, sortW, bh, "mods-sort", Strings("Sort"), { btn(imp, place(sortW), cy, sortW, bh, "mods-sort", Strings("Sort"), {
font = "small", font = "small",
action = function() imp._sortPopup = true end }) action = function() imp._sortPopup = "mods" end })
elseif medReq <= w then elseif medReq <= w then
-- Tier 2 (Medium / Compact): Surface Import, Updates, and Sort directly -- Tier 2 (Medium / Compact): Surface Import, Updates, and Sort directly
btn(imp, place(importW), cy, importW, bh, "mods-import", importLabel, { btn(imp, place(importW), cy, importW, bh, "mods-import", importLabel, {
@@ -1771,7 +1776,7 @@ local function buildModsPanel(imp, x, y, w, availH, m)
action = function() imp:_syncModUpdateInfo(true) end }) action = function() imp:_syncModUpdateInfo(true) end })
btn(imp, place(sortW), cy, sortW, bh, "mods-sort", Strings("Sort"), { btn(imp, place(sortW), cy, sortW, bh, "mods-sort", Strings("Sort"), {
font = "small", font = "small",
action = function() imp._sortPopup = true end }) action = function() imp._sortPopup = "mods" end })
btn(imp, place(moreW), cy, moreW, bh, "mods-more-actions", Strings("More..."), { btn(imp, place(moreW), cy, moreW, bh, "mods-more-actions", Strings("More..."), {
font = "small", font = "small",
action = function() imp._modHeaderActionsPopup = true end }) action = function() imp._modHeaderActionsPopup = true end })
@@ -1787,7 +1792,7 @@ local function buildModsPanel(imp, x, y, w, availH, m)
action = function() imp:chooseMod() end }) action = function() imp:chooseMod() end })
btn(imp, place(sortW), cy, sortW, bh, "mods-sort", Strings("Sort"), { btn(imp, place(sortW), cy, sortW, bh, "mods-sort", Strings("Sort"), {
font = "small", font = "small",
action = function() imp._sortPopup = true end }) action = function() imp._sortPopup = "mods" end })
btn(imp, place(moreW), cy, moreW, bh, "mods-more-actions", Strings("More..."), { btn(imp, place(moreW), cy, moreW, bh, "mods-more-actions", Strings("More..."), {
font = "small", font = "small",
action = function() imp._modHeaderActionsPopup = true end }) action = function() imp._modHeaderActionsPopup = true end })
@@ -1819,7 +1824,7 @@ local function buildModsPanel(imp, x, y, w, availH, m)
return return
end end
local sortKey = currentSort(imp) local sortKey = currentSort(imp, "mods")
-- Immediate mode paints this panel every frame; re-sorting the whole list -- Immediate mode paints this panel every frame; re-sorting the whole list
-- per frame (with lowercased-string allocations in the comparator) fed the -- per frame (with lowercased-string allocations in the comparator) fed the
@@ -2210,7 +2215,7 @@ local function buildFindPanel(imp, x, y, w, availH, m)
local sw = Kit.textWidth("small", Strings("Sort")) + math.floor(20 * m.s) local sw = Kit.textWidth("small", Strings("Sort")) + math.floor(20 * m.s)
btn(imp, place(sw), cy, sw, fieldH, "find-sort", Strings("Sort"), { btn(imp, place(sw), cy, sw, fieldH, "find-sort", Strings("Sort"), {
font = "small", font = "small",
action = function() imp._sortPopup = true end }) action = function() imp._sortPopup = "find" end })
-- The Filter button carries its state: blue while a category is active, -- The Filter button carries its state: blue while a category is active,
-- so a filtered-down list never reads as "the index shrank". -- so a filtered-down list never reads as "the index shrank".
local fw = Kit.textWidth("small", Strings("Filter")) + math.floor(20 * m.s) local fw = Kit.textWidth("small", Strings("Filter")) + math.floor(20 * m.s)
@@ -2230,7 +2235,7 @@ local function buildFindPanel(imp, x, y, w, availH, m)
return return
end end
local sortKey = currentSort(imp) local sortKey = currentSort(imp, "find")
-- Same caching rule as the MODS tab: the comparator allocates, so only -- Same caching rule as the MODS tab: the comparator allocates, so only
-- re-sort when the inputs actually change. -- re-sort when the inputs actually change.
@@ -2249,6 +2254,7 @@ local function buildFindPanel(imp, x, y, w, availH, m)
-- fetch for every entry in the index (see _findStatsCached). -- fetch for every entry in the index (see _findStatsCached).
local stats = imp:_findStatsCached(entry) local stats = imp:_findStatsCached(entry)
if sortKey == "popularity" then return stats and stats.total or -1 end if sortKey == "popularity" then return stats and stats.total or -1 end
if sortKey == "trending" then return stats and stats.recent or -1 end
if sortKey == "release" then return stats and stats.first or "0000-00-00" end if sortKey == "release" then return stats and stats.first or "0000-00-00" end
return stats and stats.latest or "0000-00-00" return stats and stats.latest or "0000-00-00"
end, end,
@@ -2336,13 +2342,14 @@ local function buildFindPanel(imp, x, y, w, availH, m)
bx, ly, PAL.heading) bx, ly, PAL.heading)
local by2 = ly + Kit.textHeight("button") + math.floor(4 * m.s) local by2 = ly + Kit.textHeight("button") + math.floor(4 * m.s)
-- meta and stats on one line, the download count first (and green) -- meta and stats on one line, the download count first (and green)
-- because it is what the default Popularity sort is ordering by: a -- because it is what the default Most-downloaded sort is ordering by: a
-- narrow window ellipsizes the tail, and the count must survive that. -- narrow window ellipsizes the tail, and the count must survive that.
local stats = imp:_findStats(entry) local stats = imp:_findStats(entry)
local baseCol = note and PAL.green or PAL.detail local baseCol = note and PAL.green or PAL.detail
local lead = "v" .. tostring(ModIndex.displayVersion(entry)) local lead = "v" .. tostring(ModIndex.displayVersion(entry))
if note then lead = lead .. " - " .. note end if note then lead = lead .. " - " .. note end
local dl = stats and ModUpdate.downloadsLine(stats.total) or nil local dl = stats and ModUpdate.downloadsShort(stats.total) or nil
local hasCount = stats ~= nil and stats.total ~= nil
local dates = stats and ModUpdate.datesLine(stats.first, stats.latest) local dates = stats and ModUpdate.datesLine(stats.first, stats.latest)
or nil or nil
local rest = {} local rest = {}
@@ -2352,11 +2359,13 @@ local function buildFindPanel(imp, x, y, w, availH, m)
end end
if dates then if dates then
rest[#rest + 1] = dates rest[#rest + 1] = dates
elseif not dl and (entry.summary or "") ~= "" then elseif not hasCount and (entry.summary or "") ~= "" then
rest[#rest + 1] = entry.summary rest[#rest + 1] = entry.summary
end end
local segs = { { lead, baseCol } } local segs = { { lead, baseCol } }
if dl then segs[#segs + 1] = { " - " .. dl, PAL.green } end if dl then
segs[#segs + 1] = { " - " .. dl, hasCount and PAL.green or PAL.faint }
end
if #rest > 0 then if #rest > 0 then
segs[#segs + 1] = { " - " .. table.concat(rest, " - "), baseCol } segs[#segs + 1] = { " - " .. table.concat(rest, " - "), baseCol }
end end
@@ -2944,7 +2953,7 @@ local function buildModHeaderActionsModal(imp, m)
{ label = Strings("Check for updates"), action = function() imp:_syncModUpdateInfo(true) end }, { label = Strings("Check for updates"), action = function() imp:_syncModUpdateInfo(true) end },
{ label = Strings("Enable all mods"), kind = "good", action = function() imp:_setAllMods(true) end }, { label = Strings("Enable all mods"), kind = "good", action = function() imp:_setAllMods(true) end },
{ label = Strings("Disable all mods"), kind = "warn", action = function() imp:_setAllMods(false) end }, { label = Strings("Disable all mods"), kind = "warn", action = function() imp:_setAllMods(false) end },
{ label = Strings("Sort mods..."), action = function() imp._sortPopup = true end }, { label = Strings("Sort mods..."), action = function() imp._sortPopup = "mods" end },
} }
local h = pad + Kit.textHeight("button") + math.floor(12 * m.s) local h = pad + Kit.textHeight("button") + math.floor(12 * m.s)
+ #btns * (m.btnH + gap) + m.btnH + pad + #btns * (m.btnH + gap) + m.btnH + pad
@@ -2972,7 +2981,8 @@ end
-- Sort chooser, shared by the MODS and FIND MODS tabs (they share the -- Sort chooser, shared by the MODS and FIND MODS tabs (they share the
-- persisted key, so one popup serves both). -- persisted key, so one popup serves both).
local function buildSortModal(imp, m) local function buildSortModal(imp, m)
local defs = sortDefs() local scope = imp._sortPopup
local defs = sortDefs(scope)
local pad = math.floor(18 * m.s) local pad = math.floor(18 * m.s)
local w = math.floor(360 * m.s) local w = math.floor(360 * m.s)
local gap = math.floor(8 * m.s) local gap = math.floor(8 * m.s)
@@ -2982,7 +2992,7 @@ local function buildSortModal(imp, m)
local cy = py + pad local cy = py + pad
Kit.text("button", Strings("Sort by"), px + pad, cy, PAL.heading) Kit.text("button", Strings("Sort by"), px + pad, cy, PAL.heading)
cy = cy + Kit.textHeight("button") + math.floor(12 * m.s) cy = cy + Kit.textHeight("button") + math.floor(12 * m.s)
local cur = currentSort(imp) local cur = currentSort(imp, scope)
for _, s in ipairs(defs) do for _, s in ipairs(defs) do
local key = s.key local key = s.key
btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "sortpop-" .. key, s.label, { btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "sortpop-" .. key, s.label, {
@@ -3362,25 +3372,43 @@ local function buildFindEntryModal(imp, m)
local gap = math.floor(8 * m.s) local gap = math.floor(8 * m.s)
local nBtns = 3 -- install row, details/source row, close row local nBtns = 3 -- install row, details/source row, close row
local noteH = note and (Kit.textHeight("small") + math.floor(4 * m.s)) or 0 local noteH = note and (Kit.textHeight("small") + math.floor(4 * m.s)) or 0
local stats = imp:_findStats(entry)
local trend = {}
local trendLine = stats and ModUpdate.trendingLine(stats.recent, stats.windowDays)
if trendLine then trend[#trend + 1] = trendLine end
if stats and stats.asOf then
trend[#trend + 1] = Strings("counts approximate, as of %s",
tostring(stats.asOf):match("^%d%d%d%d%-%d%d%-%d%d") or stats.asOf)
end
trend = (#trend > 0) and table.concat(trend, " - ") or nil
local trendH = trend and (Kit.textHeight("small") + math.floor(2 * m.s)) or 0
local h = pad + Kit.textHeight("button") + math.floor(4 * m.s) local h = pad + Kit.textHeight("button") + math.floor(4 * m.s)
+ Kit.textHeight("small") + noteH + math.floor(12 * m.s) + Kit.textHeight("small") + trendH + noteH + math.floor(12 * m.s)
+ nBtns * (m.btnH + gap) - gap + pad + nBtns * (m.btnH + gap) - gap + pad
local px, py, pw = modalPanel(m, w, h) local px, py, pw = modalPanel(m, w, h)
local cy = py + pad local cy = py + pad
Kit.text("button", Kit.ellipsize("button", entry.title or entry.id, Kit.text("button", Kit.ellipsize("button", entry.title or entry.id,
pw - 2 * pad), px + pad, cy, PAL.heading) pw - 2 * pad), px + pad, cy, PAL.heading)
cy = cy + Kit.textHeight("button") + math.floor(4 * m.s) cy = cy + Kit.textHeight("button") + math.floor(4 * m.s)
local stats = imp:_findStats(entry)
local lead = "v" .. tostring(ModIndex.displayVersion(entry)) local lead = "v" .. tostring(ModIndex.displayVersion(entry))
if entry.author then lead = lead .. " - " .. entry.author end if entry.author then lead = lead .. " - " .. entry.author end
if entry.categories and entry.categories[1] then if entry.categories and entry.categories[1] then
lead = lead .. " - " .. entry.categories[1] lead = lead .. " - " .. entry.categories[1]
end end
local dl = stats and ModUpdate.downloadsLine(stats.total) or nil local dl = stats and ModUpdate.downloadsShort(stats.total) or nil
local hasCount = stats ~= nil and stats.total ~= nil
local segs = { { lead, PAL.detail } } local segs = { { lead, PAL.detail } }
if dl then segs[#segs + 1] = { " - " .. dl, PAL.green } end if dl then
segs[#segs + 1] = { " - " .. dl, hasCount and PAL.green or PAL.faint }
end
segLine("small", segs, px + pad, cy, pw - 2 * pad) segLine("small", segs, px + pad, cy, pw - 2 * pad)
cy = cy + Kit.textHeight("small") cy = cy + Kit.textHeight("small")
if trend then
cy = cy + math.floor(2 * m.s)
Kit.text("small", Kit.ellipsize("small", trend, pw - 2 * pad),
px + pad, cy, PAL.muted)
cy = cy + Kit.textHeight("small")
end
if note then if note then
cy = cy + math.floor(4 * m.s) cy = cy + math.floor(4 * m.s)
Kit.text("small", note, px + pad, cy, PAL.green) Kit.text("small", note, px + pad, cy, PAL.green)
+34 -31
View File
@@ -192,20 +192,28 @@ local PAL = {
chipInkGold = { 58, 44, 0 }, -- #3a2c00 dark "Y" on the gold chip chipInkGold = { 58, 44, 0 }, -- #3a2c00 dark "Y" on the gold chip
} }
-- Per-version required cache files. Gold replaces the Gen 1 list entirely
-- (VERSION_REQUIRED_FILES_OVERRIDE); Yellow adds a few extra markers.
local function requiredFilesFor(version)
local override = VERSION_REQUIRED_FILES_OVERRIDE[version]
if override then return override, true end
return REQUIRED_FILES, false
end
-- CacheFs.exists checks the game folder directly for a portable install, -- CacheFs.exists checks the game folder directly for a portable install,
-- otherwise the save directory through love.filesystem. It honors -- otherwise the save directory through love.filesystem. It honors
-- CacheFs.prefix, so we point it at the version's cache subtree (red/, -- CacheFs.prefix, so we point it at the version's cache subtree (red/,
-- blue/, yellow/). -- blue/, yellow/, gold/).
local function allRequiredFilesExist(version) local function allRequiredFilesExist(version)
local CacheFs = require("src.import.CacheFs") local CacheFs = require("src.import.CacheFs")
local saved = CacheFs.prefix local saved = CacheFs.prefix
CacheFs.prefix = GameVersion.cachePrefix(version) CacheFs.prefix = GameVersion.cachePrefix(version)
local ok = true local ok = true
local required = VERSION_REQUIRED_FILES_OVERRIDE[version] or REQUIRED_FILES local required, isOverride = requiredFilesFor(version)
for _, path in ipairs(required) do for _, path in ipairs(required) do
if not CacheFs.exists(path) then ok = false; break end if not CacheFs.exists(path) then ok = false; break end
end end
if ok and not VERSION_REQUIRED_FILES_OVERRIDE[version] then if ok and not isOverride then
for _, path in ipairs(VERSION_REQUIRED_FILES[version] or {}) do for _, path in ipairs(VERSION_REQUIRED_FILES[version] or {}) do
if not CacheFs.exists(path) then ok = false; break end if not CacheFs.exists(path) then ok = false; break end
end end
@@ -215,20 +223,23 @@ local function allRequiredFilesExist(version)
end end
-- A developer checkout / Python build leaves generated data in the physfs -- A developer checkout / Python build leaves generated data in the physfs
-- source: Red at the historical root, Blue/Yellow in their versioned trees. -- source: Red at the historical root, Blue/Yellow/Gold in their versioned
-- Imported Red caches still live under red/. Check source paths directly so -- trees. Imported Red caches still live under red/. Check source paths
-- that cache prefix cannot hide Red's source tree, and keep save-dir caches -- directly so that cache prefix cannot hide Red's source tree, and keep
-- from counting as current source data. -- save-dir caches from counting as current source data.
local function sourceTreeHasData(version) local function sourceTreeHasData(version)
if not love.filesystem.getRealDirectory then return false end if not love.filesystem.getRealDirectory then return false end
local prefix = version == "red" and "" or GameVersion.cachePrefix(version) local prefix = version == "red" and "" or GameVersion.cachePrefix(version)
for _, path in ipairs(REQUIRED_FILES) do local required, isOverride = requiredFilesFor(version)
for _, path in ipairs(required) do
if love.filesystem.getInfo(prefix .. path, "file") == nil then return false end if love.filesystem.getInfo(prefix .. path, "file") == nil then return false end
end end
for _, path in ipairs(VERSION_REQUIRED_FILES[version] or {}) do if not isOverride then
if love.filesystem.getInfo(prefix .. path, "file") == nil then return false end for _, path in ipairs(VERSION_REQUIRED_FILES[version] or {}) do
if love.filesystem.getInfo(prefix .. path, "file") == nil then return false end
end
end end
local path = prefix .. REQUIRED_FILES[1] local path = prefix .. required[1]
local real = love.filesystem.getRealDirectory(path) local real = love.filesystem.getRealDirectory(path)
return real == love.filesystem.getSource() return real == love.filesystem.getSource()
end end
@@ -285,7 +296,7 @@ local function purgeSaveDirCache()
return true return true
end end
-- Purge each version's stale save-directory copy (under its red/ / blue/ -- Purge each version's stale save-directory copy (under its red/ / blue/
-- / yellow/ prefix) so it cannot shadow the portable game-folder cache. -- / yellow/ / gold/ prefix) so it cannot shadow the portable game-folder cache.
for _, version in ipairs(GameVersion.ORDER) do for _, version in ipairs(GameVersion.ORDER) do
local prefix = GameVersion.cachePrefix(version) local prefix = GameVersion.cachePrefix(version)
if saveDirHas(prefix .. MARKER_PATH) or saveDirHas(prefix .. REQUIRED_FILES[1]) then if saveDirHas(prefix .. MARKER_PATH) or saveDirHas(prefix .. REQUIRED_FILES[1]) then
@@ -4324,25 +4335,12 @@ function RomImporter:_findRows()
category = self.findCategory, category = self.findCategory,
}) })
if self.modScope then if self.modScope then
local ModTargets = require("src.mods.ModTargets")
local gen = GameVersion.generation(self.modScope) local gen = GameVersion.generation(self.modScope)
local kept = {} local kept = {}
for _, entry in ipairs(rows) do for _, entry in ipairs(rows) do
local has1, has2 = false, false local versions = ModTargets.normalize(entry.games)
local function note(s) if #versions == 0 or ModTargets.covers(versions, gen) then
s = tostring(s or ""):lower()
if s == "gen1" or s == "gen 1" or s == "red" or s == "blue"
or s == "yellow" then
has1 = true
end
if s == "gen2" or s == "gen 2" or s == "gold" then
has2 = true
end
end
for _, cat in ipairs(entry.categories or {}) do note(cat) end
for _, tag in ipairs(entry.tags or {}) do note(tag) end
if (not has1 and not has2)
or (gen == 2 and has2)
or (gen ~= 2 and has1) then
kept[#kept + 1] = entry kept[#kept + 1] = entry
end end
end end
@@ -4451,9 +4449,14 @@ function RomImporter:_findStatsCached(entry)
end end
return nil -- retry window open; _requestFindStats decides what to do return nil -- retry window open; _requestFindStats decides what to do
end end
if entry.downloads ~= nil or entry.first_release or entry.last_release then local ModIndex = require("src.mods.ModIndex")
cached = { total = entry.downloads, first = entry.first_release, local dl = ModIndex.downloadStats(entry)
latest = entry.last_release, done = true } local dates = ModIndex.releaseDates(entry)
if dl or dates then
cached = { total = dl and dl.total, recent = dl and dl.recent,
windowDays = dl and dl.window_days, asOf = dl and dl.as_of,
first = dates and dates.first, latest = dates and dates.latest,
done = true }
self._findStatsCache[entry.id] = cached self._findStatsCache[entry.id] = cached
return cached return cached
end end
+1 -1
View File
@@ -141,7 +141,7 @@ function SaveFileIO.exportActiveSlot(version)
fs.createDirectory("exports") fs.createDirectory("exports")
fs.createDirectory("exports/" .. version) fs.createDirectory("exports/" .. version)
end end
-- Per-game folder so MTP browsing matches inbox layout (red/blue/yellow). -- Per-game folder so MTP browsing matches inbox layout (red/blue/yellow/gold).
local rel = ("exports/%s/gen1recomp-%s-%s.sav"):format(version, version, slotId) local rel = ("exports/%s/gen1recomp-%s-%s.sav"):format(version, version, slotId)
local ok, writeErr = fs.write(rel, bytes) local ok, writeErr = fs.write(rel, bytes)
if not ok then return false, "could not write the export: " .. tostring(writeErr) end if not ok then return false, "could not write the export: " .. tostring(writeErr) end
+43 -2
View File
@@ -26,6 +26,7 @@ local ModIndex = {}
-- single repo's releases; a whole index is heavier and changes more slowly. -- single repo's releases; a whole index is heavier and changes more slowly.
ModIndex.CACHE_TTL = 24 * 60 * 60 ModIndex.CACHE_TTL = 24 * 60 * 60
ModIndex.SCHEMA_VERSION = 1 ModIndex.SCHEMA_VERSION = 1
ModIndex.CACHE_VERSION = 2
-- ------- pure: source resolution -- ------- pure: source resolution
@@ -149,6 +150,43 @@ local function parseLatest(raw)
} }
end end
local function parseDownloads(raw)
if type(raw) == "number" or type(raw) == "string" then
local n = tonumber(raw)
return n and { total = n } or nil
end
if type(raw) ~= "table" then return nil end
local out = {
total = tonumber(raw.total),
recent = tonumber(raw.recent),
window_days = tonumber(raw.window_days),
as_of = str(raw.as_of),
}
if out.total == nil and out.recent == nil then return nil end
return out
end
-- downloadStats(entry) -> { total, recent, window_days, as_of } | nil
function ModIndex.downloadStats(entry)
if type(entry) ~= "table" then return nil end
return parseDownloads(entry.downloads)
end
local function isoDay(v)
if type(v) ~= "string" then return nil end
return v:match("^(%d%d%d%d%-%d%d%-%d%d)")
end
-- releaseDates(entry) -> { first, latest } | nil
function ModIndex.releaseDates(entry)
if type(entry) ~= "table" then return nil end
local first = isoDay(entry.first_release)
local latest = isoDay(entry.last_release)
or isoDay(entry.latest and entry.latest.published_at)
if not (first or latest) then return nil end
return { first = first, latest = latest }
end
local function parseEntry(raw) local function parseEntry(raw)
if type(raw) ~= "table" or not str(raw.id) then return nil end if type(raw) ~= "table" or not str(raw.id) then return nil end
return { return {
@@ -160,6 +198,7 @@ local function parseEntry(raw)
summary = str(raw.summary) or "", summary = str(raw.summary) or "",
categories = strArray(raw.categories), categories = strArray(raw.categories),
tags = strArray(raw.tags), tags = strArray(raw.tags),
games = strArray(raw.games),
license = str(raw.license), license = str(raw.license),
repo = str(raw.repo), repo = str(raw.repo),
github = str(raw.github), github = str(raw.github),
@@ -174,11 +213,11 @@ local function parseEntry(raw)
conflicts = raw.conflicts, conflicts = raw.conflicts,
thumbnail = str(raw.thumbnail), thumbnail = str(raw.thumbnail),
description_url = str(raw.description_url), description_url = str(raw.description_url),
-- Optional release stats a feed author can publish: total downloads -- Optional release stats a feed author can publish: download counts
-- across all releases plus first/last release dates. Additive-only, -- across all releases plus first/last release dates. Additive-only,
-- so a feed that carries them stays readable by every build that -- so a feed that carries them stays readable by every build that
-- predates them (and one that does not still renders fine here). -- predates them (and one that does not still renders fine here).
downloads = tonumber(raw.downloads), downloads = parseDownloads(raw.downloads),
first_release = str(raw.first_release), first_release = str(raw.first_release),
last_release = str(raw.last_release), last_release = str(raw.last_release),
latest = parseLatest(raw.latest), latest = parseLatest(raw.latest),
@@ -515,6 +554,7 @@ function ModIndex.cacheFresh(entry, now, ttl)
now = now or os.time() now = now or os.time()
ttl = ttl or ModIndex.CACHE_TTL ttl = ttl or ModIndex.CACHE_TTL
return entry ~= nil and type(entry.checkedAt) == "number" return entry ~= nil and type(entry.checkedAt) == "number"
and entry.version == ModIndex.CACHE_VERSION
and (now - entry.checkedAt) < ttl and (now - entry.checkedAt) < ttl
end end
@@ -526,6 +566,7 @@ function ModIndex.writeCache(feed, index)
opts.modIndexCache = opts.modIndexCache or {} opts.modIndexCache = opts.modIndexCache or {}
opts.modIndexCache[feed] = { opts.modIndexCache[feed] = {
checkedAt = os.time(), checkedAt = os.time(),
version = ModIndex.CACHE_VERSION,
generatedAt = index.generatedAt, generatedAt = index.generatedAt,
categories = index.categories, categories = index.categories,
mods = index.mods, mods = index.mods,
+34 -1
View File
@@ -265,9 +265,42 @@ function ModUpdate.downloadsLine(total)
ModUpdate.formatCount(total)) ModUpdate.formatCount(total))
end end
ModUpdate.NO_COUNT = "?"
function ModUpdate.abbrevCount(n)
n = tonumber(n)
if not n or n ~= n or n < 0 then return ModUpdate.NO_COUNT end
n = math.floor(n)
if n < 1000 then return tostring(n) end
local units, idx, value = { "k", "M", "B" }, 0, n
repeat
value = value / 1000
idx = idx + 1
until idx >= #units or math.floor(value * 10 + 0.5) / 10 < 1000
local scaled = math.floor(value * 10 + 0.5) / 10
local shown = (scaled % 1 == 0) and tostring(math.floor(scaled))
or string.format("%.1f", scaled)
return shown .. units[idx]
end
function ModUpdate.downloadsShort(total)
return Strings("%s downloads", ModUpdate.abbrevCount(total))
end
function ModUpdate.trendingLine(recent, windowDays)
if recent == nil then return nil end
if not windowDays then
return Strings("%s recently", ModUpdate.abbrevCount(recent))
end
return Strings("%s in the last %d days", ModUpdate.abbrevCount(recent),
math.floor(windowDays))
end
function ModUpdate.datesLine(first, latest) function ModUpdate.datesLine(first, latest)
if not (first or latest) then return nil end if not (first or latest) then return nil end
return Strings("Released %s - Updated %s", first or "?", latest or "?") if not first then return Strings("Updated %s", latest) end
if not latest then return Strings("Released %s", first) end
return Strings("Released %s - Updated %s", first, latest)
end end
function ModUpdate.statsLine(total, first, latest) function ModUpdate.statsLine(total, first, latest)
+2 -1
View File
@@ -166,7 +166,8 @@ function EvolutionState:draw()
if sprite then if sprite then
local x = math.floor((160 - sprite:getWidth()) / 2) local x = math.floor((160 - sprite:getWidth()) / 2)
local y = math.max(8, 64 - sprite:getHeight()) local y = math.max(8, 64 - sprite:getHeight())
love.graphics.draw(sprite, x, y) -- engine/movie/evolution.asm:103
love.graphics.draw(sprite, x + sprite:getWidth(), y, 0, -1, 1)
if spriteTrueColor then if spriteTrueColor then
require("src.render.PaletteFX").markTrueColor(x, y, sprite:getDimensions()) require("src.render.PaletteFX").markTrueColor(x, y, sprite:getDimensions())
end end
+127 -9
View File
@@ -20,6 +20,17 @@ Studio.CANVASES = {
lockViewport = { x = 48 / 256, y = 40 / 224, w = 160 / 256, h = 144 / 224 } }, lockViewport = { x = 48 / 256, y = 40 / 224, w = 160 / 256, h = 144 / 224 } },
} }
-- Per-page lock, and whether the canvas preset follows it. Match is on
-- by default so a portrait/landscape overlay pair does not need two
-- separate clicks to preview the right way up. #1503
Studio.matchOrient = true
Studio.ORIENT_CYCLE = { "any", "portrait", "landscape" }
Studio.ORIENT_LABEL = {
any = "Lock: Off",
portrait = "Lock: Portrait",
landscape = "Lock: Landscape",
}
local HANDLE = 7 local HANDLE = 7
local HANDLES = { local HANDLES = {
{ "nw", 0, 0 }, { "n", 0.5, 0 }, { "ne", 1, 0 }, { "nw", 0, 0 }, { "n", 0.5, 0 }, { "ne", 1, 0 },
@@ -64,9 +75,91 @@ local function syncActive()
TouchSkin.pageIndex = Studio.pageIndex TouchSkin.pageIndex = Studio.pageIndex
end end
function Studio.setCanvas(index) local function canvasOrientation(canvas)
canvas = canvas or Studio.canvas()
if not canvas then return nil end
return canvas.w > canvas.h and "landscape" or "portrait"
end
local function pickCanvasIndex(want)
local cur = Studio.canvas()
if canvasOrientation(cur) == want then return Studio.canvasIndex end
if cur and cur.id then
local hint = cur.id:gsub("portrait", want):gsub("landscape", want)
for i, c in ipairs(Studio.CANVASES) do
if c.id == hint then return i end
end
end
for i, c in ipairs(Studio.CANVASES) do
if canvasOrientation(c) == want and not c.lockViewport then return i end
end
return nil
end
function Studio.applyImportedOrient()
if not Studio.skin then return false end
if not TouchSkin.hasOrientPair(Studio.skin) then
Studio.syncCanvasToPage()
return false
end
-- A RetroArch overlay that already auto-rotates should do the same in
-- the studio: lock is on, canvas follows, and the visible page matches
-- the mock device. #1503
Studio.matchOrient = true
Studio.syncPageToCanvas()
Studio.syncCanvasToPage()
return true
end
function Studio.syncCanvasToPage()
if not Studio.matchOrient then return end
local want = TouchSkin.pageOrient(Studio.page())
if not want then return end
local idx = pickCanvasIndex(want)
if idx and idx ~= Studio.canvasIndex then Studio.setCanvas(idx, true) end
end
function Studio.syncPageToCanvas()
if not Studio.matchOrient or not Studio.skin then return end
local want = canvasOrientation()
if TouchSkin.pageOrient(Studio.page()) == want then return end
for i, page in ipairs(Studio.skin.pages or {}) do
if TouchSkin.pageOrient(page) == want then
Studio.pageIndex = i
Studio.selected = nil
syncActive()
return
end
end
end
function Studio.cyclePageOrient(dir)
local page = Studio.page()
if not page then return end
local cur = TouchSkin.pageOrient(page) or "any"
local idx = 1
for i, o in ipairs(Studio.ORIENT_CYCLE) do
if o == cur then idx = i break end
end
local n = #Studio.ORIENT_CYCLE
local nxt = Studio.ORIENT_CYCLE[((idx - 1 + (dir or 1)) % n) + 1]
page.orient = nxt
local name = tostring(page.name or "")
if (nxt == "portrait" or nxt == "landscape")
and (name == "" or name == "main" or name:match("^page%d+$")) then
page.name = nxt
end
markDirty()
Studio.syncCanvasToPage()
return nxt
end
function Studio.setCanvas(index, fromSync)
local n = #Studio.CANVASES local n = #Studio.CANVASES
Studio.canvasIndex = ((index - 1) % n) + 1 Studio.canvasIndex = ((index - 1) % n) + 1
-- Pick the matching page before writing canvas-owned fields onto it,
-- so a landscape preset does not stamp a portrait page. #1503
if not fromSync then Studio.syncPageToCanvas() end
local canvas = Studio.canvas() local canvas = Studio.canvas()
local page = Studio.page() local page = Studio.page()
if page and canvas.lockViewport then if page and canvas.lockViewport then
@@ -77,7 +170,11 @@ function Studio.setCanvas(index)
page.viewportFill = false page.viewportFill = false
markDirty() markDirty()
end end
if page then page.aspect = canvas.w / canvas.h end -- A cfg-authored aspect_ratio is the overlay's design aspect; keep it so
-- the preview letterboxes like RetroArch instead of stretching (#1503).
if page and not page.aspectFromCfg then
page.aspect = canvas.w / canvas.h
end
end end
function Studio.load(opts) function Studio.load(opts)
@@ -102,6 +199,10 @@ function Studio.load(opts)
TouchControls.active = true TouchControls.active = true
TouchControls.enabled = true TouchControls.enabled = true
TouchControls:setPreview(true) TouchControls:setPreview(true)
-- Play snaps pages from the window aspect. The studio uses its own
-- Match canvas toggle against the mock device instead. #1503
TouchSkin.autoOrient = false
Studio.matchOrient = true
local start = opts.skinId local start = opts.skinId
if not start then if not start then
@@ -130,6 +231,7 @@ function Studio.open(id)
Studio.dirty = false Studio.dirty = false
Studio.images = TouchSkin.listImages(Studio.skin.root) Studio.images = TouchSkin.listImages(Studio.skin.root)
syncActive() syncActive()
Studio.applyImportedOrient()
return true return true
end end
@@ -137,6 +239,7 @@ function Studio.unload()
Studio.pendingPlay = false Studio.pendingPlay = false
TouchSkin.setSurface(nil) TouchSkin.setSurface(nil)
TouchSkin.setActive(nil) TouchSkin.setActive(nil)
TouchSkin.autoOrient = true
TouchControls:setPreview(false) TouchControls:setPreview(false)
TouchControls:reset() TouchControls:reset()
Studio.skin = nil Studio.skin = nil
@@ -358,6 +461,7 @@ function Studio.addPage()
Studio.pageIndex = #skin.pages Studio.pageIndex = #skin.pages
Studio.selected = nil Studio.selected = nil
syncActive() syncActive()
Studio.syncCanvasToPage()
markDirty() markDirty()
end end
@@ -432,7 +536,8 @@ end
local function viewportRect(page, r) local function viewportRect(page, r)
local v = page.viewport local v = page.viewport
if not v then return nil end if not v then return nil end
return r.x + v.x * r.w, r.y + v.y * r.h, v.w * r.w, v.h * r.h local bx, by, bw, bh = TouchSkin.pageBox(page, r.w, r.h, r.x, r.y)
return bx + v.x * bw, by + v.y * bh, v.w * bw, v.h * bh
end end
local function handleRects(bx, by, bw, bh) local function handleRects(bx, by, bw, bh)
@@ -540,17 +645,19 @@ function Studio.updateDrag(mx, my, r)
end end
end end
local px, py, pw, ph = TouchSkin.pageBox(page, r.w, r.h, r.x, r.y)
if pw <= 0 or ph <= 0 then return end
if d.kind:find("control") then if d.kind:find("control") then
local ctl = Studio.selectedControl() local ctl = Studio.selectedControl()
if not ctl then return end if not ctl then return end
ctl.x = clamp01(((bx + bw * 0.5) - r.x) / r.w) ctl.x = clamp01(((bx + bw * 0.5) - px) / pw)
ctl.y = clamp01(((by + bh * 0.5) - r.y) / r.h) ctl.y = clamp01(((by + bh * 0.5) - py) / ph)
ctl.rangeX = math.max(0.002, (bw * 0.5) / r.w) ctl.rangeX = math.max(0.002, (bw * 0.5) / pw)
ctl.rangeY = math.max(0.002, (bh * 0.5) / r.h) ctl.rangeY = math.max(0.002, (bh * 0.5) / ph)
else else
page.viewport = { page.viewport = {
x = clamp01((bx - r.x) / r.w), y = clamp01((by - r.y) / r.h), x = clamp01((bx - px) / pw), y = clamp01((by - py) / ph),
w = math.max(0.02, bw / r.w), h = math.max(0.02, bh / r.h), w = math.max(0.02, bw / pw), h = math.max(0.02, bh / ph),
} }
end end
markDirty() markDirty()
@@ -665,11 +772,22 @@ local function inspectorBody(x, y, w)
Studio.pageIndex = (Studio.pageIndex % #Studio.skin.pages) + 1 Studio.pageIndex = (Studio.pageIndex % #Studio.skin.pages) + 1
Studio.selected = nil Studio.selected = nil
syncActive() syncActive()
Studio.syncCanvasToPage()
end end
if Kit.button(x + half + gap, cy, half, rowH, "Add page", { id = "pageadd" }) then if Kit.button(x + half + gap, cy, half, rowH, "Add page", { id = "pageadd" }) then
Studio.addPage() Studio.addPage()
end end
cy = cy + rowH + gap cy = cy + rowH + gap
local lock = TouchSkin.pageOrient(page) or "any"
if Kit.button(x, cy, half, rowH, Studio.ORIENT_LABEL[lock] or "Lock: Off",
{ id = "orient" }) then
Studio.cyclePageOrient(1)
end
local matchOn = Studio.matchOrient
Studio.matchOrient = Kit.checkbox(x + half + gap, cy, half, rowH,
Studio.matchOrient, "Match canvas", "matchorient")
if Studio.matchOrient and not matchOn then Studio.syncCanvasToPage() end
cy = cy + rowH + gap
if page then if page then
local bezel = page.imagePath or "(none)" local bezel = page.imagePath or "(none)"
+4 -2
View File
@@ -445,7 +445,8 @@ function TradeAnim:draw()
-- mon pic on the BG at hlcoord 7, 2; info box on the window at -- mon pic on the BG at hlcoord 7, 2; info box on the window at
-- hWY $50, so it sits in the bottom half of the screen -- hWY $50, so it sits in the bottom half of the screen
if self.monVisible and self.sentSprite then if self.monVisible and self.sentSprite then
love.graphics.draw(self.sentSprite, 56, 16) -- engine/movie/trade.asm:751
love.graphics.draw(self.sentSprite, 56 + self.sentSprite:getWidth(), 16, 0, -1, 1)
if self.sentSpriteTrueColor then if self.sentSpriteTrueColor then
require("src.render.PaletteFX").markTrueColor( require("src.render.PaletteFX").markTrueColor(
56 - self.scx, 16, self.sentSprite:getDimensions()) 56 - self.scx, 16, self.sentSprite:getDimensions())
@@ -501,7 +502,8 @@ function TradeAnim:draw()
elseif p == "show_enemy" then elseif p == "show_enemy" then
if self.monVisible and self.recvSprite then if self.monVisible and self.recvSprite then
love.graphics.draw(self.recvSprite, 56, 16) -- engine/movie/trade.asm:751
love.graphics.draw(self.recvSprite, 56 + self.recvSprite:getWidth(), 16, 0, -1, 1)
if self.recvSpriteTrueColor then if self.recvSpriteTrueColor then
require("src.render.PaletteFX").markTrueColor( require("src.render.PaletteFX").markTrueColor(
56, 16, self.recvSprite:getDimensions()) 56, 16, self.recvSprite:getDimensions())
+33 -16
View File
@@ -24,14 +24,18 @@
-- MoneyTopRightMenuHeader menu_coords 11, 0, 19, 2 -- a 9x3 box, and -- MoneyTopRightMenuHeader menu_coords 11, 0, 19, 2 -- a 9x3 box, and
-- PlaceMoneyTextbox writes the amount at -- PlaceMoneyTextbox writes the amount at
-- MenuBoxCoord2Tile + SCREEN_WIDTH + 1 = (12,1) -- MenuBoxCoord2Tile + SCREEN_WIDTH + 1 = (12,1)
-- MenuHeader_Buy menu_coords 1, 3, 19, 11 -- a 19x9 box holding 4 -- MenuHeader_Buy menu_coords 1, 3, 19, 11 -- a 19x9 rect holding 4
-- entries of two rows. ScrollingMenu_UpdateDisplay -- entries of two rows. BuyMenuLoop copies that
-- starts at (2,4); PlaceMenuItemName prints the name -- header and calls ScrollingMenu; it never calls
-- there and .PrintBCDPrices is handed that origin -- InitScrollingMenu or MenuBox, so the rect is NOT
-- plus the menu's own width (8) plus SCREEN_WIDTH, so -- a framed window. ScrollingMenu_UpdateDisplay
-- the price lands at (10,5). The ▲ sits on the box's -- calls ClearWholeMenuBox on it (spaces, no border)
-- top right corner (19,3) and the ▼ on its bottom -- and starts printing at (2,4); PlaceMenuItemName
-- right (19,11). -- prints the name there and .PrintBCDPrices is
-- handed that origin plus the menu's own width (8)
-- plus SCREEN_WIDTH, so the price lands at (10,5).
-- The ▲ sits on the rect's top right (19,3) and
-- the ▼ on its bottom right (19,11).
-- UpdateItemDescription Textbox (0,12) interior 18x4 -- a 20x6 box -- with -- UpdateItemDescription Textbox (0,12) interior 18x4 -- a 20x6 box -- with
-- the description at (1,14) -- the description at (1,14)
-- BuyItem_MenuHeader menu_coords 7, 15, 19, 17 -- a 13x3 box, and -- BuyItem_MenuHeader menu_coords 7, 15, 19, 17 -- a 13x3 box, and
@@ -75,7 +79,9 @@ local SFX_TRANSACTION = "Sfx_Transaction"
local MartMenu = {} local MartMenu = {}
MartMenu.__index = MartMenu MartMenu.__index = MartMenu
-- engine/items/mart.asm:54 -- The top menu overlays the mart (StandardMart .HowMayIHelpYou /
-- .AnythingElse: LoadStandardMenuHeader + PrintText). BuyMenu is
-- FadeToMenu + BlankScreen, so enterBuy shadows this for the list.
MartMenu.isOpaque = false MartMenu.isOpaque = false
-- constants/mart_constants.asm. The `pokemart` macro emits this as one byte -- constants/mart_constants.asm. The `pokemart` macro emits this as one byte
@@ -563,6 +569,10 @@ function MartMenu:enterBuy()
self.phase = "buy" self.phase = "buy"
self.index = 1 self.index = 1
self.scroll = 0 self.scroll = 0
-- BuyMenu (engine/items/mart.asm): `call FadeToMenu / farcall BlankScreen`.
-- BlankScreen fills the tilemap with spaces and the palettes with white, so
-- the mart must not keep drawing in the letterbox under the list.
self.isOpaque = (self.phase == "buy")
end end
function MartMenu:total() function MartMenu:total()
@@ -618,6 +628,8 @@ end
-- BuyMenu returns into StandardMart .Buy, which falls through to -- BuyMenu returns into StandardMart .Buy, which falls through to
-- .AnythingElse; the other three dialog kinds end on their come-again line. -- .AnythingElse; the other three dialog kinds end on their come-again line.
function MartMenu:leaveBuy() function MartMenu:leaveBuy()
-- CloseSubmenu restores the map before .AnythingElse / the come-again line.
self.isOpaque = nil
if self.martType == "STANDARD" then if self.martType == "STANDARD" then
self:enterTop(self.text.askMore) self:enterTop(self.text.askMore)
else else
@@ -885,11 +897,10 @@ function MartMenu:description()
return def and def.description or nil return def and def.description or nil
end end
-- The bottom visible entry's price row IS the box's bottom border row -- The bottom visible entry's price row is MenuHeader_Buy's last line
-- (entry row 10 + SCREEN_WIDTH = 11, MenuHeader_Buy's own last line), and a -- (entry row 10 + SCREEN_WIDTH = 11). A GB glyph REPLACES the tile it
-- GB glyph REPLACES the tile it prints over, border and all. White under -- prints over, so white under the seven money tiles first is that
-- the seven money tiles first is that replacement; on the three rows clear -- replacement. On a BlankScreen field it is white over white.
-- of the border it repaints white over white.
local function printPriceOpaque(amount, ty) local function printPriceOpaque(amount, ty)
local G = love.graphics local G = love.graphics
G.setColor(1, 1, 1, 1) G.setColor(1, 1, 1, 1)
@@ -899,8 +910,10 @@ local function printPriceOpaque(amount, ty)
end end
function MartMenu:drawBuyList() function MartMenu:drawBuyList()
-- engine/items/mart.asm:542 -- ScrollingMenu_UpdateDisplay (engine/menus/scrolling_menu.asm) calls
Chrome.box(LIST_BOX_X, LIST_BOX_Y, LIST_BOX_W, LIST_BOX_H) -- ClearWholeMenuBox, not MenuBox: the MenuHeader_Buy rect is a cleared
-- field, not a framed window. BlankScreen already filled the tilemap
-- with spaces, so the names just land on white.
for row = 1, VISIBLE_ROWS do for row = 1, VISIBLE_ROWS do
local i = row + self.scroll local i = row + self.scroll
local ty = LIST_Y + (row - 1) * LIST_SPACING local ty = LIST_Y + (row - 1) * LIST_SPACING
@@ -960,6 +973,10 @@ function MartMenu:drawUnder()
self:drawTopMenu() self:drawTopMenu()
self:drawTextBox(self.topLines) self:drawTextBox(self.topLines)
elseif phase == "buy" or phase == "buyQuantity" then elseif phase == "buy" or phase == "buyQuantity" then
-- BlankScreen (engine/overworld/player_object.asm): the whole tilemap
-- is spaces before PlaceMoneyTopRight / the scrolling list / the
-- description textbox go down. Only those last two are framed.
Chrome.clear()
self:drawMoneyBox() self:drawMoneyBox()
self:drawBuyList() self:drawBuyList()
self:drawDescription() self:drawDescription()
+13
View File
@@ -134,6 +134,19 @@ local ROWS = {
text = function(options) text = function(options)
return require("src.render.Zoom").offsetLabel(options.zoom or 0) return require("src.render.Zoom").offsetLabel(options.zoom or 0)
end }, end },
-- VOID FILL: FADE is each map's own border block with the dissolve across
-- a boundary; WATER / TREES force one outdoor block; BLACK is a flat void.
-- #1418. Same key the Gen 1 OPTION screen uses, different ladder (FADE
-- is Gold's default because that is already what the maps call for).
{ label = "VOID FILL", key = "voidFill", port = true,
cycle = function(options, delta)
local BorderFill = require("src.world.gen2.BorderFill")
BorderFill.setVoidFill(options.voidFill or "fade")
options.voidFill = BorderFill.cycle(delta)
end,
text = function(options)
return require("src.world.gen2.BorderFill").voidFillLabel(options.voidFill)
end },
{ label = "TILT", key = "tilt", port = true, { label = "TILT", key = "tilt", port = true,
cycle = function(options, delta) cycle = function(options, delta)
local Tilt = require("src.render.Tilt") local Tilt = require("src.render.Tilt")
+7
View File
@@ -329,6 +329,10 @@ function OverworldState:setMap(mapId, x, y, facing, opts)
MapLoader.invalidateAll() MapLoader.invalidateAll()
end end
self.map = MapLoader.load(Game.data, mapId) self.map = MapLoader.load(Game.data, mapId)
-- EnterMap -> ClearVariablesOnEnterMap zeroes wStepCounter
-- (engine/overworld/clear_variables.asm:7), but a connection crossing
-- never reaches EnterMap (home/overworld.asm:675 .loadNewMap)
if not (opts and opts.seamless) then Game.save.poisonSteps = 0 end
-- STRENGTH deactivates on every real map load (home/overworld.asm -- STRENGTH deactivates on every real map load (home/overworld.asm
-- EnterMap -> ResetUsingStrengthOutOfBattleBit clears BIT_STRENGTH_ACTIVE -- EnterMap -> ResetUsingStrengthOutOfBattleBit clears BIT_STRENGTH_ACTIVE
-- of wStatusFlags1). setMap is the single choke point for every map-id -- of wStatusFlags1). setMap is the single choke point for every map-id
@@ -4296,6 +4300,9 @@ end
-- battle is optional; when given, Oak's Lab OPP_RIVAL1 losses skip the -- battle is optional; when given, Oak's Lab OPP_RIVAL1 losses skip the
-- blackout (pret HandlePlayerBlackOut) so the map script can HealParty. -- blackout (pret HandlePlayerBlackOut) so the map script can HealParty.
function OverworldState:afterBattle(result, battle) function OverworldState:afterBattle(result, battle)
-- .battleOccurred tail-jumps to EnterMap (home/overworld.asm:353), so
-- ClearVariablesOnEnterMap zeroes wStepCounter on every battle return
Game.save.poisonSteps = 0
if battle and battle.kind == "wild" then if battle and battle.kind == "wild" then
self.wildEncounterGraceSteps = WILD_ENCOUNTER_GRACE_STEPS self.wildEncounterGraceSteps = WILD_ENCOUNTER_GRACE_STEPS
end end
+83 -4
View File
@@ -152,16 +152,95 @@ function BorderFill.bake(atlas, tileset, blockId, bgSet, waterFrame)
return img return img
end end
-- VOID FILL (#1418): the beyond-edge scenery under survey zoom. FADE (the
-- default) is each map header's own border block with the dissolve below;
-- WATER / TREES force one block on outdoor tilesets that actually have that
-- scenery; BLACK is a flat void. Indoor and cave tilesets have no tree wall
-- or water metatile, so water/trees fall through to the map's own border
-- rather than painting a random block.
--
-- Canonical ids are the wMapBorderBlock values already used as those fills
-- in data/maps/attributes.asm: New Bark / Route 30 $05 trees, Cherrygrove
-- $35 water, Pallet $0f trees, Cinnabar $43 water, Ilex Forest $05 trees.
BorderFill.VOID_FILLS = { "fade", "water", "trees", "black" }
BorderFill.voidFill = "fade"
local FILL_BLOCKS = {
TILESET_JOHTO = { trees = 0x05, water = 0x35 },
TILESET_JOHTO_MODERN = { trees = 0x05, water = 0x35 },
TILESET_KANTO = { trees = 0x0f, water = 0x43 },
TILESET_FOREST = { trees = 0x05 },
}
function BorderFill.setVoidFill(mode)
local ok = false
for _, name in ipairs(BorderFill.VOID_FILLS) do
if name == mode then ok = true; break end
end
BorderFill.voidFill = ok and mode or "fade"
end
function BorderFill.cycle(delta)
local cur = BorderFill.voidFill or "fade"
local at = 1
for i, name in ipairs(BorderFill.VOID_FILLS) do
if name == cur then at = i; break end
end
local n = #BorderFill.VOID_FILLS
at = (at - 1 + (delta or 1)) % n + 1
BorderFill.setVoidFill(BorderFill.VOID_FILLS[at])
return BorderFill.voidFill
end
function BorderFill.applyOptions(opts)
BorderFill.setVoidFill(opts and opts.voidFill or "fade")
end
function BorderFill.voidFillLabel(mode)
mode = mode or BorderFill.voidFill or "fade"
if mode == "water" then return "WATER" end
if mode == "trees" then return "TREES" end
if mode == "black" then return "BLACK" end
-- trailing space blanks the 5-char WATER/TREES/BLACK from the value column
return "FADE "
end
-- The metatile the void should bake, or false when BLACK skips tiling.
function BorderFill.fillBlock(def)
local mode = BorderFill.voidFill or "fade"
if mode == "black" then return false end
if (mode == "water" or mode == "trees") and def then
local fills = FILL_BLOCKS[def.tileset]
local block = fills and fills[mode]
if block ~= nil then return block end
end
return def and def.borderBlock or 0
end
-- Crossfade identity: FADE dissolves per map (Cherrygrove water -> Route 30
-- trees). WATER/TREES dissolve only when the forced block itself changes
-- (Johto water -> Kanto water), so walking two Johto routes does not fade
-- identical water against itself. BLACK is one sheet everywhere.
function BorderFill.fillKey(def)
local mode = BorderFill.voidFill or "fade"
if mode == "black" then return "black" end
local block = BorderFill.fillBlock(def)
if mode == "water" or mode == "trees" then
return mode .. "|" .. tostring(def and def.tileset) .. "|" .. tostring(block)
end
return "fade|" .. tostring(def and def.id)
end
-- Each map header carries its OWN border block, so crossing a boundary swaps -- Each map header carries its OWN border block, so crossing a boundary swaps
-- the whole void from one block to another: Cherrygrove's water becomes Route -- the whole void from one block to another: Cherrygrove's water becomes Route
-- 30's trees between one frame and the next. On a 20x18 viewport that is a few -- 30's trees between one frame and the next. On a 20x18 viewport that is a few
-- pixels at the screen edge and nobody sees it; under survey zoom the void is -- pixels at the screen edge and nobody sees it; under survey zoom the void is
-- most of the window, and the swap reads as the background popping. -- most of the window, and the swap reads as the background popping.
-- --
-- So the swap is dissolved rather than cut. `key` is the map the image belongs -- So the swap is dissolved rather than cut. `key` is the fill identity
-- to, not the image itself: the same block gets re-baked by the daytime -- (BorderFill.fillKey), not the image itself: the same block gets re-baked by
-- rollover, the COLOR option and the two-frame cave flicker, and a dissolve on -- the daytime rollover, the COLOR option and the two-frame cave flicker, and a
-- any of those would smear the flicker into mush. -- dissolve on any of those would smear the flicker into mush.
BorderFill.CROSSFADE_FRAMES = 20 BorderFill.CROSSFADE_FRAMES = 20
-- The bookkeeping half, love-free so it can be checked without a canvas. -- The bookkeeping half, love-free so it can be checked without a canvas.
+27 -10
View File
@@ -396,11 +396,10 @@ local function givePokeMon(data, speciesIndex, level, itemIndex)
end end
local function loadGenerated(path) local function loadGenerated(path)
local chunk, err = love.filesystem.load(path) -- Same NX gold/ fallback Game2 uses. World:load is what surfaces
if not chunk then return nil, err end -- "Gold cache incomplete" when maps.lua is invisible at the unprefixed path.
local ok, value = pcall(chunk) local CacheFs = require("src.import.CacheFs")
if not ok then return nil, value end return CacheFs.loadActive(path)
return value
end end
-- Paste the 9-tile roof sheet over atlas tiles $0a-$12. -- Paste the 9-tile roof sheet over atlas tiles $0a-$12.
@@ -7901,8 +7900,10 @@ function World:borderWaterFrame(def, tileset)
end end
end end
if not tile then return nil end if not tile then return nil end
local fill = BorderFill.fillBlock(def)
if fill == false then return nil end
local block = tileset.blocks local block = tileset.blocks
and tileset.blocks[BorderFill.blockFor(0, def.borderBlock) + 1] and tileset.blocks[BorderFill.blockFor(0, fill) + 1]
if not block then return nil end if not block then return nil end
local found = false local found = false
for i = 1, 16 do for i = 1, 16 do
@@ -7934,11 +7935,19 @@ function World:borderImageFor(mapId)
if not def then return nil end if not def then return nil end
local tileset = self.tilesets and self.tilesets[def.tileset] local tileset = self.tilesets and self.tilesets[def.tileset]
if not tileset then return nil end if not tileset then return nil end
-- VOID FILL black skips the tiled bake; drawGround paints a flat void.
local fill = BorderFill.fillBlock(def)
if fill == false then return nil end
local blockId = BorderFill.blockFor(0, fill)
-- A border block made of water animates with the rest of the map, so this -- A border block made of water animates with the rest of the map, so this
-- frame's row joins the key: four bakes per map instead of one. -- frame's row joins the key: four bakes per map instead of one. The VOID
-- FILL mode is in the key so switching FADE/WATER/TREES does not keep a
-- stale bake (#1418).
local waterFrame = self:borderWaterFrame(def, tileset) local waterFrame = self:borderWaterFrame(def, tileset)
local cacheKey = BorderFill.cacheKey(mapId .. "|" .. tostring(daytime) local cacheKey = BorderFill.cacheKey(mapId .. "|" .. tostring(daytime)
.. "|" .. tostring(GbcPalette.mode) .. "|" .. tostring(flicker) .. "|" .. tostring(GbcPalette.mode) .. "|" .. tostring(flicker)
.. "|" .. tostring(BorderFill.voidFill or "fade")
.. "|" .. tostring(blockId)
.. "|" .. tostring(waterFrame and waterFrame.row or 0)) .. "|" .. tostring(waterFrame and waterFrame.row or 0))
local cached = self.mapImages[cacheKey] local cached = self.mapImages[cacheKey]
if cached ~= nil then return cached or nil end if cached ~= nil then return cached or nil end
@@ -7950,7 +7959,7 @@ function World:borderImageFor(mapId)
bgSet = Palettes.withCaveFlicker(bgSet, flicker or 1) bgSet = Palettes.withCaveFlicker(bgSet, flicker or 1)
end end
local ok, img = pcall(BorderFill.bake, atlas, tileset, local ok, img = pcall(BorderFill.bake, atlas, tileset,
BorderFill.blockFor(0, def.borderBlock), bgSet, waterFrame) blockId, bgSet, waterFrame)
-- `false` rather than nil: a bake that cannot be made (a headless run with -- `false` rather than nil: a bake that cannot be made (a headless run with
-- no canvas support) must not be retried once per frame forever. -- no canvas support) must not be retried once per frame forever.
self.mapImages[cacheKey] = (ok and img) or false self.mapImages[cacheKey] = (ok and img) or false
@@ -9720,8 +9729,16 @@ function World:drawGround(s)
else else
bw, bh = GameViewport.dimensions() bw, bh = GameViewport.dimensions()
end end
BorderFill.draw(self, self:borderImageFor(self.map.id), if BorderFill.fillBlock(self.map.def) == false then
cam.x, cam.y, bw, bh, s, self.map.id) -- BLACK: World:draw clears to a brown letterbox, so the void itself
-- has to be an actual black sheet or the map sits on that colour.
G.setColor(0, 0, 0, 1)
G.rectangle("fill", 0, 0, bw, bh)
G.setColor(1, 1, 1, 1)
else
BorderFill.draw(self, self:borderImageFor(self.map.id),
cam.x, cam.y, bw, bh, s, BorderFill.fillKey(self.map.def))
end
end end
for _, nb in ipairs(self.neighbors) do for _, nb in ipairs(self.neighbors) do
G.draw(nb.image, G.draw(nb.image,
@@ -0,0 +1,92 @@
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/bug1412"
local Pokemon = require("src.pokemon.Pokemon")
local Evolution = require("src.pokemon.Evolution")
local Screens = require("src.ui.Screens")
local TradeAnim = require("src.ui.TradeAnim")
local TextBox = require("src.render.TextBox")
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
return ok
end
U.log("Issue #1412: mon pics must be mirrored on the evolution and trade")
U.log("screens, and only there; battle keeps the raw orientation.")
U.teleport(game, "ROUTE_1", 5, 5, "down")
U.wait(10)
check("the overworld fixture is ready", game.overworld ~= nil)
local mon = Pokemon.new(game.data, "PIKACHU", 20)
game.save.party = { mon }
-- engine/movie/evolution.asm:103
Evolution.evolve(game, mon, "RAICHU", nil, "ITEM")
U.wait(12)
local top = game.stack:top()
check("the evolution screen opened",
top and top.screenId == "EvolutionState")
U.shot(game, DIR .. "/bug1412_evo_old_pikachu.png")
for _ = 1, 600 do
if top.done then break end
U.wait(1)
end
check("the evolution finished", top.done and not top.canceled)
U.wait(2)
U.shot(game, DIR .. "/bug1412_evo_new_raichu.png")
for _ = 1, 60 do
if game.stack:top() == game.overworld then break end
U.tap(game, "a")
U.wait(4)
end
-- engine/movie/trade.asm:751
local sent = Pokemon.new(game.data, "SPEAROW", 10)
local recv = Pokemon.new(game.data, "FARFETCHD", 10)
recv.nickname = "DUX"
recv.traded = true
recv.ot = "TRAINER"
U.teleport(game, "ROUTE_1", 5, 5, "down")
local anim = Screens.push(game, "TradeAnim", {
sent = sent, received = recv, enemyName = "TRAINER",
})
check("the trade cinematic opened", getmetatable(anim) == TradeAnim)
for _ = 1, 200 do
if anim.sub == "hold" then break end
U.wait(1)
end
check("the sent SPEAROW pic is on screen",
anim.phase == "show_player" and anim.sub == "hold" and anim.monVisible)
U.shot(game, DIR .. "/bug1412_trade_sent_spearow.png")
local function topIsText()
return getmetatable(game.stack:top()) == TextBox
end
for _ = 1, 8000 do
if anim.phase == "show_enemy" and anim.monVisible then break end
if anim.phase == "done" then break end
if anim.waitingText or topIsText() then
U.wait(1)
else
anim:update(1 / 60)
end
end
U.wait(2)
check("the received FARFETCH'D pic is on screen",
anim.phase == "show_enemy" and anim.monVisible)
U.shot(game, DIR .. "/bug1412_trade_recv_farfetchd.png")
U.log("shots in", DIR)
U.log("Right: all four pics are mirrored left-right compared to the same")
U.log("mon's battle front sprite (compare the hardware capture on #1412:")
U.log("PIKACHU's face points the other way than it does in battle).")
U.log("Wrong: any of the four matches the battle orientation.")
U.log("Battle, pokedex, title, Hall of Fame, League PC, credits, and the")
U.log("museum fossils are untouched and must still match the cart.")
while true do
coroutine.yield()
end
end
@@ -0,0 +1,79 @@
-- Gold VOID FILL (#1418): FADE (each map's own border, dissolving across a
-- boundary), WATER, TREES, or BLACK. Parks you on Cherrygrove zoomed out so
-- the void is most of the window, cycles the four modes, then hands the pad
-- over. Do not add POKEPORT_SPEED: the water anim and the dissolve both run
-- on the real clock.
--
-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold \
-- POKEPORT_DRIVER=tests/drivers/gold_void_fill_bug1418_test.lua love .
local U = require("tests.drivers.util")
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-void-fill"
local BorderFill = require("src.world.gen2.BorderFill")
local Tilt = require("src.render.Tilt")
local Zoom = require("src.render.Zoom")
local failures = 0
local function ok(label, condition, detail)
if condition then
print("[void] ok " .. label)
else
failures = failures + 1
print("[void] FAIL " .. label .. " " .. tostring(detail))
end
end
U.wait(45)
local world = game.world
assert(world and world.map, "gold world did not boot")
-- Cherrygrove's header border is water ($35). FADE shows that water,
-- TREES replaces it, BLACK drops the sheet. attributes.asm:123.
world:setMap("CHERRYGROVE_CITY", 22, 16, "down")
U.wait(10)
Zoom.offset = -3
world:rebuildNeighbors()
world:rebuildPeople({ seamless = true })
Tilt.setLevel(3)
for _ = 1, 40 do
Tilt.update(1 / 60)
U.wait(1)
end
local function show(mode, file, label)
BorderFill.setVoidFill(mode)
if game.options then game.options.voidFill = mode end
if game.save and game.save.options then
game.save.options.voidFill = mode
end
U.wait(8)
ok(label, BorderFill.voidFill == mode, BorderFill.voidFill)
U.shot(game, out .. "/" .. file)
end
show("fade", "01-fade.png", "FADE is the live fill")
show("water", "02-water.png", "WATER forces the water block")
show("trees", "03-trees.png", "TREES forces the tree wall")
show("black", "04-black.png", "BLACK drops the tiled void")
show("fade", "05-fade-again.png", "and FADE restores the map's own border")
Tilt.setLevel(0)
Zoom.offset = 0
world:rebuildNeighbors()
world:rebuildPeople({ seamless = true })
if failures > 0 then
print(("[driver] FAIL gold void fill: %d check(s)"):format(failures))
return
end
print("[driver] PASS gold void fill in " .. out)
U.log("cycled fade/water/trees/black on cherrygrove (#1418).")
U.log("OPTION > VOID FILL, zoom out (-) to see the void.")
while true do
coroutine.yield()
end
end
@@ -0,0 +1,121 @@
-- Driver: renders the launcher's FIND MODS tab against a canned index whose
-- listings carry the feed's `downloads` object, so the card line, the sort
-- popup and the per-mod popup can be eyeballed with real numbers, real
-- unknowns and a real zero in the same list.
-- SHOT_DIR=/tmp/finddl POKEPORT_DRIVER=tests/drivers/launcher_find_downloads_shot.lua love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local RomImporter = require("src.import.RomImporter")
local dir = os.getenv("SHOT_DIR") or "/tmp/finddl"
os.execute('mkdir -p "' .. dir .. '" 2>/dev/null')
love.window.setMode(1024, 768, { resizable = true, highdpi = true })
U.wait(2)
local imp = RomImporter.new(function() end, { launcher = true })
local function mod(id, title, downloads)
return {
id = id, title = title, author = "someone", version = "1.0.0",
summary = "A canned listing for the screenshot.",
categories = { "GAMEPLAY" }, tags = {}, games = {},
update_check = "ok",
downloads = downloads,
latest = { version = "1.0.0", zip = {
url = "https://example.invalid/" .. id .. ".zip" } },
}
end
-- the canned listing IS the fixture: never let the tab go and fetch the
-- player's real sources over it
-- new() prewarms a real fetch; drop the in-flight handle and the pump
imp._refreshFindSources = function() end
imp._refreshFind = function() end
imp._pumpFindFetch = function() end
imp._findFetch = nil
imp.findSources = { { feed = "https://example.invalid/data/index.json",
base = "https://example.invalid/",
label = "example/index" } }
imp.findIndex = { schemaVersion = 1, categories = { "GAMEPLAY" }, mods = {
mod("busy", "Busy Mod",
{ total = 1578, recent = 388, window_days = 30,
as_of = "2026-08-18T05:17:00.000Z" }),
mod("huge", "Huge Mod",
{ total = 1234567, recent = 24000, window_days = 30,
as_of = "2026-08-18T05:17:00.000Z" }),
mod("young", "Young Mod",
{ total = 42, as_of = "2026-08-18T05:17:00.000Z" }),
mod("counted-zero", "Counted Zero Mod", { total = 0 }),
mod("unknown", "Unknown Mod", nil),
} }
imp.findLoaded = true
imp:_switchTab("find")
U.wait(3)
local pending = nil
love.draw = function()
imp:draw()
if pending then
local path = pending
pending = nil
love.graphics.captureScreenshot(function(imagedata)
local f = io.open(path, "wb")
if f then f:write(imagedata:encode("png"):getString()) f:close() end
end)
end
end
local function shot(name)
pending = dir .. "/" .. name
for _ = 1, 90 do
if not pending then break end
imp:update(1 / 60)
coroutine.yield()
end
U.wait(3)
local f = io.open(dir .. "/" .. name, "rb")
U.log(f and "shot" or "FAIL shot", name)
if f then f:close() end
end
local ModUpdate = require("src.mods.ModUpdate")
for _, e in ipairs(imp.findIndex.mods) do
local s = imp:_findStats(e)
U.log((" %-13s %s | trending=%s"):format(e.id,
ModUpdate.downloadsShort(s and s.total),
tostring(s and ModUpdate.trendingLine(s.recent, s.windowDays))))
end
for _, key in ipairs({ "popularity", "trending", "name" }) do
imp.modSort = key
imp._findSortCache = nil
U.wait(2)
shot("find_sort_" .. key .. ".png")
local order = {}
for _, e in ipairs(imp._findSortCache.list) do order[#order + 1] = e.id end
U.log("sort " .. key .. ":", table.concat(order, ", "))
end
imp.modSort = "popularity"
imp._sortPopup = "find"
U.wait(2)
shot("find_sort_popup.png")
imp._sortPopup = nil
imp._findEntry = imp.findIndex.mods[1]
U.wait(2)
shot("find_entry_counted.png")
imp._findEntry = imp.findIndex.mods[5]
U.wait(2)
shot("find_entry_unknown.png")
imp._findEntry = nil
-- the installed-mods tab shares the persisted sort key; Trending there has
-- nothing to sort by, so it must degrade rather than reorder on nothing
imp.modSort = "trending"
imp:_switchTab("mods")
U.wait(3)
shot("mods_tab_trending_fallback.png")
U.log("done")
love.event.quit()
end
@@ -0,0 +1,93 @@
-- Driver: opens FIND MODS against the player's real index sources, lets the
-- prewarm fetch land, and reports what the panel resolved without asking
-- GitHub anything -- the check behind "sorting only covers the pages I
-- visited". Shoots the first and last page of the download sort.
-- SHOT_DIR=/tmp/findreal POKEPORT_DRIVER=tests/drivers/launcher_find_real_index.lua love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local ModUpdate = require("src.mods.ModUpdate")
local fetched = 0
local realBegin = ModUpdate.beginFetchReleases
ModUpdate.beginFetchReleases = function(...)
fetched = fetched + 1
return realBegin(...)
end
local RomImporter = require("src.import.RomImporter")
local dir = os.getenv("SHOT_DIR") or "/tmp/findreal"
os.execute('mkdir -p "' .. dir .. '" 2>/dev/null')
love.window.setMode(1024, 768, { resizable = true, highdpi = true })
U.wait(2)
local imp = RomImporter.new(function() end, { launcher = true })
imp:_switchTab("find")
for _ = 1, 900 do
imp:update(1 / 60)
coroutine.yield()
if imp.findLoaded and not imp._findFetch then break end
end
local mods = (imp.findIndex and imp.findIndex.mods) or {}
U.log("index mods:", #mods)
U.log("stale copy:", tostring(imp.findIndex and imp.findIndex.stale))
local pending = nil
love.draw = function()
imp:draw()
if pending then
local path = pending
pending = nil
love.graphics.captureScreenshot(function(imagedata)
local f = io.open(path, "wb")
if f then f:write(imagedata:encode("png"):getString()) f:close() end
end)
end
end
local function shot(name)
pending = dir .. "/" .. name
for _ = 1, 90 do
if not pending then break end
imp:update(1 / 60)
coroutine.yield()
end
U.wait(3)
local f = io.open(dir .. "/" .. name, "rb")
U.log(f and "shot" or "FAIL shot", name)
if f then f:close() end
end
local counted, dated, unknown = 0, 0, 0
for _, e in ipairs(mods) do
local s = imp:_findStatsCached(e)
if s and s.total then counted = counted + 1 else unknown = unknown + 1 end
if s and s.latest then dated = dated + 1 end
end
U.log(("counts=%d dated=%d unknown=%d github requests=%d")
:format(counted, dated, unknown, fetched))
imp.modSort = "popularity"
imp._findSortCache = nil
U.wait(3)
shot("real_page1.png")
local sorted = imp._findSortCache and imp._findSortCache.list or {}
for i = 1, math.min(3, #sorted) do
local s = imp:_findStatsCached(sorted[i])
U.log((" #%d %s %s"):format(i, sorted[i].title,
ModUpdate.downloadsShort(s and s.total)))
end
for i = math.max(1, #sorted - 2), #sorted do
local s = imp:_findStatsCached(sorted[i])
U.log((" #%d %s %s"):format(i, sorted[i].title,
ModUpdate.downloadsShort(s and s.total)))
end
-- the last page is the one a page-by-page resolve would have left unsorted
imp._pages["find"] = 99
U.wait(3)
U.log("github requests after sorting:", fetched)
shot("real_last_page.png")
U.log("done")
love.event.quit()
end
+94
View File
@@ -0,0 +1,94 @@
-- data/moves/sfx.asm:46, data/moves/animations.asm:448, audio/engine_2.asm:1077
return function(game)
local U = dofile("tests/drivers/util.lua")
local Pokemon = require("src.pokemon.Pokemon")
local BattleState = require("src.battle.BattleState")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
return ok
end
local opts = game.save.options or {}
check(("sfx volume %d (needs > 0 to hear anything)"):format(opts.sfxVol or 7),
(opts.sfxVol or 7) > 0)
check("battle animations are on", opts.animations ~= false)
local mdef = game.data.moves.LEER
check("LEER is in the move table", mdef ~= nil)
local anim = mdef and mdef.anim
check(("LEER maps to %s pitch %s tempo %s (wants Battle_31 255 64)"):format(
anim and tostring(anim.sound) or "?",
anim and tostring(anim.pitch) or "?",
anim and tostring(anim.tempo) or "?"),
anim ~= nil and anim.sound == "Battle_31"
and anim.pitch == 255 and anim.tempo == 64)
local lead = Pokemon.new(game.data, "CHARMANDER", 20)
lead.moves = { { id = "LEER", pp = 30 } }
game.save.party = { lead }
U.teleport(game, "ROUTE_1", 5, 5, "down")
U.wait(15)
local ow = game.overworld
check("standing on ROUTE_1", ow.map.id == "ROUTE_1")
local ChipAudio = require("src.core.ChipAudio")
local renders = {}
local origNewSfx = ChipAudio.newSfx
ChipAudio.newSfx = function(data, name, pitch, tempo, header, plain)
renders[#renders + 1] = { name = name, pitch = pitch, tempo = tempo,
plain = plain }
return origNewSfx(data, name, pitch, tempo, header, plain)
end
local battle = BattleState.newWild(game, "PIDGEY", 8)
battle.onFinish = function() end
ow:pushBattle(battle)
local function waitPhase(phase, tries)
for _ = 1, tries do
if battle.phase == phase then return true end
U.tap(game, "a")
U.wait(6)
end
return battle.phase == phase
end
U.log("")
U.log("LISTEN: the cart's LEER opens with a short PIERCING high tone")
U.log(" (about a third of a second, while the seed-drop noise is still")
U.log(" running) and only then falls to the slow low buzz. Before the")
U.log(" fix the recomp skipped the piercing part and played the whole")
U.log(" sound as the low buzz.")
U.log("")
for round = 1, 3 do
check(("round %d: menu is up"):format(round), waitPhase("menu", 150))
U.tap(game, "a")
check(("round %d: move list is up"):format(round),
waitPhase("moveSelect", 12))
U.tap(game, "a")
U.wait(150)
if round == 1 then U.shot(game, DIR .. "/bug1414_leer.png") end
end
ChipAudio.newSfx = origNewSfx
local sawPlain, sawSeed = false, false
for _, r in ipairs(renders) do
if r.name == "Battle_31" and r.pitch == 255 and r.tempo == 64
and (r.plain or 0) > 0 then
sawPlain = true
end
if r.name == "Battle_1B" then sawSeed = true end
end
check("the seed-drop noise (Battle_1B) rendered", sawSeed)
check("LEER's Battle_31 rendered with an unmodified opening"
.. " while the noise still ran", sawPlain)
for _, r in ipairs(renders) do
U.log((" rendered %s pitch=%s tempo=%s plainFrames=%s"):format(
r.name, tostring(r.pitch), tostring(r.tempo), tostring(r.plain)))
end
U.log("done; the window stays open, pick LEER again to re-listen")
end
+27 -4
View File
@@ -97,10 +97,11 @@ local launcher = love.graphics.newImage("assets/launcher/gear.png")
eq(launcher.path, "assets/launcher/gear.png", eq(launcher.path, "assets/launcher/gear.png",
"overlay leaves non-generated paths alone") "overlay leaves non-generated paths alone")
-- The real un-prefixed file wins when it exists -- Leftover un-prefixed Red cache must not shadow the versioned copy
-- (pre-#899 assets/generated at the save-dir root).
love.filesystem.write(PNG, "root-png-bytes") love.filesystem.write(PNG, "root-png-bytes")
eq(love.filesystem.read(PNG), "root-png-bytes", eq(love.filesystem.read(PNG), "yellow-png-bytes",
"overlay prefers the real un-prefixed file over the versioned copy") "overlay prefers the versioned copy over leftover unprefixed")
clearPath(PNG) clearPath(PNG)
-- Blue gets the same treatment -- Blue gets the same treatment
@@ -110,7 +111,29 @@ clearPath("yellow/" .. PNG)
eq(love.filesystem.read(PNG), "blue-png-bytes", eq(love.filesystem.read(PNG), "blue-png-bytes",
"overlay maps generated reads to blue/ for Blue") "overlay maps generated reads to blue/ for Blue")
-- Red has no prefix: nothing is rewritten -- Leftover unprefixed font (old Red root) must not beat Gold's copy.
GameVersion.set("gold")
local FONT = "assets/generated/fonts/font.png"
love.filesystem.write(FONT, "stale-red-font")
love.filesystem.write("gold/" .. FONT, "gold-font")
eq(love.filesystem.read(FONT), "gold-font",
"Gold versioned font wins over leftover unprefixed Red font")
clearPath(FONT)
clearPath("gold/" .. FONT)
-- Gold data/generated: fused NX hides gold/maps.lua at the unprefixed path,
-- which is the "Gold cache incomplete / maps.lua Does not exist" crash.
local MAPS = "data/generated/maps.lua"
love.filesystem.write("gold/" .. MAPS, "return { NEW_BARK_TOWN = true }")
local mapsChunk = love.filesystem.load(MAPS)
eq(type(mapsChunk) == "function" and mapsChunk().NEW_BARK_TOWN or nil, true,
"wrapped filesystem.load resolves gold/data/generated/maps.lua")
eq(love.filesystem.read(MAPS), "return { NEW_BARK_TOWN = true }",
"wrapped filesystem.read returns gold maps.lua bytes")
clearPath("gold/" .. MAPS)
-- Red has no empty prefix anymore (cachePrefix is red/), but with no
-- red/ copy the unprefixed miss stays a miss.
GameVersion.set("red") GameVersion.set("red")
clearPath("blue/" .. PNG) clearPath("blue/" .. PNG)
eq(love.filesystem.read(PNG), nil, "Red keeps the stock miss behavior") eq(love.filesystem.read(PNG), nil, "Red keeps the stock miss behavior")
+11
View File
@@ -49,5 +49,16 @@ check(CacheFs.mountVersion("yellow") == true, "mountVersion(yellow) returns true
eq(love.filesystem.read("assets/generated/fonts/font.png"), "yellow-font", eq(love.filesystem.read("assets/generated/fonts/font.png"), "yellow-font",
"Yellow mount exposes fonts/font.png at the unprefixed path") "Yellow mount exposes fonts/font.png at the unprefixed path")
-- Gold-only: same overlay contract (the Switch intro/maps.lua hole)
love.filesystem._mounts = {}
GameVersion.set("gold")
love.filesystem.write("gold/data/generated/maps.lua", "return { ok = true }")
love.filesystem.write("gold/assets/generated/fonts/font.png", "gold-font")
check(CacheFs.mountVersion("gold") == true, "mountVersion(gold) returns true")
eq(love.filesystem.read("assets/generated/fonts/font.png"), "gold-font",
"Gold mount exposes fonts/font.png at the unprefixed path")
eq(love.filesystem.read("data/generated/maps.lua"), "return { ok = true }",
"Gold mount exposes maps.lua at the unprefixed path")
GameVersion.set("red") GameVersion.set("red")
T.finish() T.finish()
@@ -0,0 +1,57 @@
-- Gold on fused NX: gold/data/generated exists, but the overlay mount
-- onto data/generated often fails. Game2/World used to love.filesystem.load
-- the unprefixed path and crash with "Gold cache incomplete / maps.lua
-- Does not exist" after a textless intro. CacheFs.loadActive must read the
-- versioned file without any mount.
-- Self-contained: luajit tests/engine/cache_fs_gold_nx_load_test.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local T = require("tests.harness")
local check = T.check
local eq = T.eq
local CacheFs = require("src.import.CacheFs")
local GameVersion = require("src.core.GameVersion")
local savedVersion = GameVersion.get()
local savedPrefix = CacheFs.prefix
love.filesystem._mounts = {}
GameVersion.set("gold")
CacheFs.prefix = GameVersion.cachePrefix()
love.filesystem.write("gold/data/generated/maps.lua",
"return { NEW_BARK_TOWN = { id = 1 } }")
love.filesystem.write("gold/data/generated/oak_speech.lua",
"return { text = { _OakText1 = 'Hello!' } }")
love.filesystem.write("gold/data/generated/font.lua",
"return { width = 8 }")
-- Unprefixed path is a miss: the NX mount hole.
eq(love.filesystem.read("data/generated/maps.lua"), nil,
"unprefixed maps.lua is missing (mount hole)")
eq(love.filesystem.load("data/generated/maps.lua"), nil,
"love.filesystem.load misses unprefixed maps.lua")
local maps, mapsErr = CacheFs.loadActive("data/generated/maps.lua")
check(maps ~= nil, "loadActive finds gold/data/generated/maps.lua ("
.. tostring(mapsErr) .. ")")
eq(maps and maps.NEW_BARK_TOWN and maps.NEW_BARK_TOWN.id, 1,
"loadActive returns the Gold maps table")
local oak = CacheFs.loadActive("data/generated/oak_speech.lua")
eq(oak and oak.text and oak.text._OakText1, "Hello!",
"loadActive returns oak_speech.lua from gold/")
local font = CacheFs.loadActive("data/generated/font.lua")
eq(font and font.width, 8,
"loadActive returns font.lua from gold/")
love.filesystem.remove("gold/data/generated/maps.lua")
love.filesystem.remove("gold/data/generated/oak_speech.lua")
love.filesystem.remove("gold/data/generated/font.lua")
CacheFs.prefix = savedPrefix
GameVersion.set(savedVersion)
T.finish()
+3
View File
@@ -16,5 +16,8 @@ check(CacheFs.read("data/generated/audio.lua") == nil,
"read is a nil miss headless, not a crash") "read is a nil miss headless, not a crash")
check(CacheFs.readActive("data/generated/audio.lua") == nil, check(CacheFs.readActive("data/generated/audio.lua") == nil,
"readActive is a nil miss headless, not a crash") "readActive is a nil miss headless, not a crash")
local loaded, loadErr = CacheFs.loadActive("data/generated/audio.lua")
check(loaded == nil,
"loadActive is a nil miss headless, not a crash (" .. tostring(loadErr) .. ")")
T.finish() T.finish()
@@ -43,6 +43,15 @@ local gold = LauncherSettings.open(hooks, "gold")
check(has(gold, "TOUCH PAD"), "Gold's gear offers TOUCH PAD") check(has(gold, "TOUCH PAD"), "Gold's gear offers TOUCH PAD")
check(has(gold, "VIBRATION"), "and VIBRATION") check(has(gold, "VIBRATION"), "and VIBRATION")
check(has(gold, "TOUCH CONTROLS"), "and the layout editor") check(has(gold, "TOUCH CONTROLS"), "and the layout editor")
check(has(gold, "VOID FILL"), "and VOID FILL")
local voidFill = findRow(gold, "VOID FILL")
eq(voidFill.value(), "FADE ", "VOID FILL defaults to FADE")
voidFill.step(1)
eq(gold.opts.gold.voidFill, "water", "right stores water in the gold block")
eq(voidFill.value(), "WATER", "and the row reads WATER")
voidFill.step(-1)
eq(gold.opts.gold.voidFill, "fade", "left restores fade")
-- Every write has to land in the gold block: the flat keys beside it are -- Every write has to land in the gold block: the flat keys beside it are
-- Red's, and Gold's boot never reads them (src/core/gen2/Save.lua:299). -- Red's, and Gold's boot never reads them (src/core/gen2/Save.lua:299).
+125
View File
@@ -0,0 +1,125 @@
-- FIND MODS download counts: the index feed publishes them per listing, so
-- the panel reads the one fetch it already made instead of asking GitHub per
-- mod (that fan-out is what blew the hourly API limit in _findStatsCached's
-- own comment). This pins the resolver's precedence, the unknown-is-not-zero
-- rule the card and the sorts both depend on, and the Trending wiring.
-- luajit tests/engine/launcher_mod_downloads.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local T = require("tests.harness")
local check, eq = T.check, T.eq
-- No launcher test may reach the network: beginFetchReleases is the only door
-- out of _requestFindStats, and counting the calls is the assertion.
local ModUpdate = require("src.mods.ModUpdate")
local fetched = {}
ModUpdate.beginFetchReleases = function(repo, id)
fetched[#fetched + 1] = id
return { stage = "done" }
end
local RomImporter = require("src.import.RomImporter")
local function launcher()
return setmetatable({ tab = "find" }, RomImporter)
end
local function entry(id, downloads, github)
return { id = id, title = id, downloads = downloads, github = github }
end
-- ------- the feed's counts win, and cost nothing
do
local ri = launcher()
local e = entry("counted", { total = 1578, recent = 388, window_days = 30,
as_of = "2026-08-18T05:17:00.000Z" },
"someone/counted")
local stats = ri:_findStats(e)
eq(stats.total, 1578, "the feed's total is what the card shows")
eq(stats.recent, 388, "the trailing-window count rides along")
eq(stats.windowDays, 30, "so does the window it covers")
eq(stats.asOf, "2026-08-18T05:17:00.000Z", "and when the index read it")
eq(#fetched, 0, "a listing with a published total asks GitHub for nothing")
end
-- A bare number is what a cache written before the object shipped holds; it
-- is read back through the same resolver rather than migrated.
do
local ri = launcher()
eq(ri:_findStats(entry("legacy", 4321, "someone/legacy")).total, 4321,
"a cached scalar count still resolves")
eq(#fetched, 0, "and still costs no request")
end
-- Whole-list, not page-by-page: sorting calls the cached read for every entry
-- in the index, so all of them have to resolve from the feed alone. A
-- listing the feed dates but does not count still resolves, off `latest`.
do
local ri = launcher()
local dated = { id = "dated", title = "Dated", github = "someone/dated",
latest = { version = "1.0.0",
published_at = "2026-08-11T17:19:00Z" } }
local stats = ri:_findStats(dated)
eq(stats.latest, "2026-08-11", "the feed's own release blob dates the row")
eq(#fetched, 0, "with no repo fetch to wait on")
eq(ModUpdate.datesLine(stats.first, stats.latest), "Updated 2026-08-11",
"and the card states what it knows instead of a Released ?")
end
-- ------- unknown is unknown: null falls through to the repo, as any other
-- missing feed stat does, and a listing with no repo resolves to no counts
-- rather than to zero.
do
local ri = launcher()
check(ri:_findStats(entry("nulled", nil, "someone/nulled")) == nil,
"a null count is not an answer; the repo is still consulted")
eq(#fetched, 1, "which is the fetch the panel already made for dates")
local bare = launcher()
local stats = bare:_findStats(entry("no-repo", nil, nil))
check(stats ~= nil and stats.total == nil,
"a listing with neither counts nor a repo is resolved-but-unknown")
check(stats.recent == nil, "and has nothing to trend on")
eq(#fetched, 1, "and queues nothing of its own")
end
-- A real zero is not unknown: the index has seen the releases and counted
-- none. The card prints it and the sort ranks it above the unknowns.
do
local ri = launcher()
local stats = ri:_findStats(entry("zero", { total = 0 }, "someone/zero"))
eq(stats.total, 0, "a counted zero survives as a number")
eq(ModUpdate.downloadsShort(stats.total), "0 downloads",
"and prints as zero rather than as a dash")
end
-- ------- the panel wiring around those numbers
local view = (function()
local f = assert(io.open("src/import/LauncherView.lua", "r"))
local src = f:read("*a")
f:close()
return src
end)()
check(view:find('key = "trending"', 1, true) ~= nil,
"a Trending sort exists")
check(view:find('sortKey == "trending" then return stats and stats.recent or %-1'),
"Trending orders by the trailing-window count, unknown last")
check(view:find('sortKey == "popularity" then return stats and stats.total or %-1'),
"Most downloaded orders by the total, unknown last")
-- Trending has no meaning on the installed-mods tab, and the persisted key is
-- shared, so that panel has to degrade rather than sort on nothing.
check(view:find('if scope == "find" then', 1, true) ~= nil,
"Trending is offered only where the feed's counts exist")
check(view:find('sortKey == "trending" and scope ~= "find"', 1, true) ~= nil,
"the MODS tab falls back when the shared sort key is FIND-only")
check(view:find('sortKey or "popularity"', 1, true) ~= nil,
"the default sort is unchanged")
check(view:find("downloadsShort", 1, true) ~= nil,
"the browse card prints the abbreviated count")
T.finish("launcher_mod_downloads")
+93 -6
View File
@@ -114,23 +114,110 @@ do
"a feed without release stats parses them as absent") "a feed without release stats parses them as absent")
end end
-- release stats a feed can publish: total downloads and first/last dates -- release stats a feed can publish: download counts and first/last dates
-- ride along additively, so a feed carrying them stays readable by every -- ride along additively, so a feed carrying them stays readable by every
-- build that predates them -- build that predates them
do do
local withStats = {} local withStats = {}
for k, v in pairs(NUZLOCKE) do withStats[k] = v end for k, v in pairs(NUZLOCKE) do withStats[k] = v end
withStats.downloads = 1234 withStats.downloads = { total = 1578, recent = 388, window_days = 30,
as_of = "2026-08-18T05:17:00.000Z" }
withStats.first_release = "2024-05-31" withStats.first_release = "2024-05-31"
withStats.last_release = "2026-07-01" withStats.last_release = "2026-07-01"
local index = ModIndex.parse(feed({ withStats })) local index = ModIndex.parse(feed({ withStats }))
local m = index.mods[1] local m = index.mods[1]
eq(m.downloads, 1234, "total downloads are kept") eq(m.downloads.total, 1578, "total downloads are kept")
eq(m.downloads.recent, 388, "the trailing-window count is kept")
eq(m.downloads.window_days, 30, "the window length is kept")
eq(m.downloads.as_of, "2026-08-18T05:17:00.000Z", "the read time is kept")
eq(m.first_release, "2024-05-31", "first release date is 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") eq(m.last_release, "2026-07-01", "last release date is kept")
withStats.downloads = "9999" end
m = ModIndex.parse(feed({ withStats })).mods[1]
eq(m.downloads, 9999, "numeric-string downloads are coerced") -- ------- download counts: unknown is not zero
--
-- The feed's `downloads` object has three ways of saying "not known" -- the
-- field absent, the field null, and a null count inside it -- and every one
-- of them has to stay distinguishable from a real zero, because the browse
-- card prints one and sorts the other.
do
local function jsonWith(downloads)
local raw = {}
for k, v in pairs(NUZLOCKE) do raw[k] = v end
raw.downloads = downloads
return feed({ raw })
end
local function statsForJson(text)
return ModIndex.downloadStats(ModIndex.parse(text).mods[1])
end
local function statsFor(downloads)
return statsForJson(jsonWith(downloads))
end
check(statsFor(nil) == nil, "an absent downloads field is unknown")
-- Json.encode has no null of its own, so the literal the feed actually
-- sends is patched into the text.
local nulled = jsonWith({}):gsub('"downloads":%[%]', '"downloads":null', 1)
check(nulled:find('"downloads":null', 1, true) ~= nil,
"the null feed fixture really contains a null")
check(statsForJson(nulled) == nil, "a null downloads field is unknown")
check(statsFor({}) == nil, "an object with no counts is unknown")
eq(statsFor({ total = 0 }).total, 0, "a real zero total survives")
-- recent / window_days stay null until the index has more than a day of
-- history, even once total is a real number.
local young = statsFor({ total = 12, as_of = "2026-08-18T05:17:00.000Z" })
eq(young.total, 12, "a total with no window yet is still a total")
check(young.recent == nil and young.window_days == nil,
"no trailing window means no trending figure, not a zero one")
-- A cache written before the object shipped stored a bare number; it is
-- read back through the same door rather than migrated.
local legacy = ModIndex.downloadStats({ downloads = 4321 })
eq(legacy.total, 4321, "a bare number reads as the total")
check(legacy.recent == nil, "and carries no trending figure")
check(ModIndex.downloadStats({}) == nil, "a row with no counts is unknown")
check(ModIndex.downloadStats(nil) == nil, "no entry is unknown")
end
-- ------- release dates: the feed already dates every listing it can install
--
-- Sorting must span the whole index, not the pages a reader happened to
-- visit, so the "last updated" date comes off the feed's own `latest` blob
-- rather than out of a per-mod repo fetch.
do
local raw = {}
for k, v in pairs(NUZLOCKE) do raw[k] = v end
local d = ModIndex.releaseDates(ModIndex.parse(feed({ raw })).mods[1])
eq(d.latest, "2026-07-31", "latest release date comes from latest.published_at")
check(d.first == nil, "the feed cannot date a first release from that alone")
raw.first_release = "2024-05-31"
raw.last_release = "2026-07-01"
d = ModIndex.releaseDates(ModIndex.parse(feed({ raw })).mods[1])
eq(d.first, "2024-05-31", "an explicit first_release wins")
eq(d.latest, "2026-07-01", "an explicit last_release beats the latest blob")
local bare = {}
for k, v in pairs(NUZLOCKE) do bare[k] = v end
bare.latest, bare.update_check = nil, "no installable release"
check(ModIndex.releaseDates(ModIndex.parse(feed({ bare })).mods[1]) == nil,
"a listing with no releases has no dates")
check(ModIndex.releaseDates(nil) == nil, "no entry has no dates")
end
-- ------- cache version: a copy written before a field existed cannot answer
-- for it, and the TTL is a whole day
do
local now = os.time()
check(ModIndex.cacheFresh({ checkedAt = now,
version = ModIndex.CACHE_VERSION }), "a current cache is fresh")
check(not ModIndex.cacheFresh({ checkedAt = now }),
"an unstamped cache is refetched rather than trusted for a day")
check(not ModIndex.cacheFresh({ checkedAt = now,
version = ModIndex.CACHE_VERSION - 1 }), "so is an older stamp")
check(not ModIndex.cacheFresh({ checkedAt = now - ModIndex.CACHE_TTL - 1,
version = ModIndex.CACHE_VERSION }), "and an expired one")
end end
-- schema_version is a contract, not a hint: an unknown one is refused rather -- schema_version is a contract, not a hint: an unknown one is refused rather
+26
View File
@@ -208,6 +208,32 @@ eq(ModUpdate.formatCount("12345"), "12,345", "numeric strings are accepted")
eq(ModUpdate.formatCount(nil), "0", "nil formats as zero") eq(ModUpdate.formatCount(nil), "0", "nil formats as zero")
eq(ModUpdate.formatCount("garbage"), "0", "garbage formats as zero") eq(ModUpdate.formatCount("garbage"), "0", "garbage formats as zero")
-- abbrevCount: what a browse card has room for
eq(ModUpdate.abbrevCount(0), "0", "zero abbreviates to itself")
eq(ModUpdate.abbrevCount(999), "999", "below 1000 stays exact")
eq(ModUpdate.abbrevCount(1000), "1k", "a round thousand drops its decimal")
eq(ModUpdate.abbrevCount(1578), "1.6k", "thousands round to one decimal")
eq(ModUpdate.abbrevCount(24000), "24k", "a round figure keeps no .0")
eq(ModUpdate.abbrevCount(999999), "1M", "no count ever reads as 1000k")
eq(ModUpdate.abbrevCount(1234567), "1.2M", "millions abbreviate too")
eq(ModUpdate.abbrevCount("1578"), "1.6k", "numeric strings are accepted")
eq(ModUpdate.abbrevCount(nil), "?", "an unknown count is not a zero")
eq(ModUpdate.abbrevCount("garbage"), "?", "garbage is unknown, not a zero")
-- downloadsShort / trendingLine: the card and detail forms
eq(ModUpdate.downloadsShort(1578), "1.6k downloads", "the card form abbreviates")
eq(ModUpdate.downloadsShort(0), "0 downloads", "a real zero is still stated")
eq(ModUpdate.downloadsShort(nil), "? downloads",
"an unknown count says so rather than vanishing or reading as zero")
eq(ModUpdate.trendingLine(388, 30), "388 in the last 30 days",
"the trending line names its window")
eq(ModUpdate.trendingLine(388, 12), "388 in the last 12 days",
"a short window says so rather than claiming a month")
eq(ModUpdate.trendingLine(388, nil), "388 recently",
"a count with no window still reads")
check(ModUpdate.trendingLine(nil, 30) == nil,
"no trailing-window count means no trending line")
-- statsLine: the shared launcher row line, part by part -- statsLine: the shared launcher row line, part by part
eq(ModUpdate.statsLine(1234567, "2024-05-31", "2026-07-01"), 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", "1,234,567 downloads across all releases - Released 2024-05-31 - Updated 2026-07-01",
+17
View File
@@ -66,4 +66,21 @@ check(mainSrc:find("isNX", 1, true) ~= nil
and mainSrc:find('require("src.core.NxAssetOverlay").install()', 1, true) ~= nil, and mainSrc:find('require("src.core.NxAssetOverlay").install()', 1, true) ~= nil,
"the overlay install stays gated on Platform.isNX()") "the overlay install stays gated on Platform.isNX()")
-- Gold Game2 / World must compile data/generated via CacheFs.loadActive so
-- a fused NX boot does not depend on mounting gold/ onto data/generated.
local function readSrc(path)
local fh = io.open(path, "r")
local body = fh and fh:read("*a") or ""
if fh then fh:close() end
return body
end
local game2Src = readSrc("src/core/Game2.lua")
check(game2Src:find("loadActive", 1, true) ~= nil,
"Game2 compiles generated modules through CacheFs.loadActive")
check(game2Src:find("loadActive(path)", 1, true) ~= nil,
"Game2.loadGenerated calls loadActive")
local worldSrc = readSrc("src/world/gen2/World.lua")
check(worldSrc:find("loadActive", 1, true) ~= nil,
"World's on-disk fallback also uses CacheFs.loadActive")
T.finish() T.finish()
@@ -59,7 +59,7 @@ package.loaded["src.import.RomImporter"] = nil
RomImporter = require("src.import.RomImporter") RomImporter = require("src.import.RomImporter")
local function clearSavesInbox() local function clearSavesInbox()
for _, ver in ipairs({ "red", "blue", "yellow" }) do for _, ver in ipairs({ "red", "blue", "yellow", "gold" }) do
local dir = "imports/saves/" .. ver local dir = "imports/saves/" .. ver
for _, name in ipairs(love.filesystem.getDirectoryItems(dir) or {}) do for _, name in ipairs(love.filesystem.getDirectoryItems(dir) or {}) do
love.filesystem.remove(dir .. "/" .. name) love.filesystem.remove(dir .. "/" .. name)
@@ -135,6 +135,8 @@ check(createdDirs["imports/saves/blue"] == true,
"RES-01: ensureSavesInboxDir creates imports/saves/blue/") "RES-01: ensureSavesInboxDir creates imports/saves/blue/")
check(createdDirs["imports/saves/yellow"] == true, check(createdDirs["imports/saves/yellow"] == true,
"RES-01: ensureSavesInboxDir creates imports/saves/yellow/") "RES-01: ensureSavesInboxDir creates imports/saves/yellow/")
check(createdDirs["imports/saves/gold"] == true,
"RES-01: ensureSavesInboxDir creates imports/saves/gold/")
-- NXSAV-02: notice/hint includes save dir + per-game imports/saves/<version>/ MTP path -- NXSAV-02: notice/hint includes save dir + per-game imports/saves/<version>/ MTP path
ri = freshImporter() ri = freshImporter()
@@ -0,0 +1,30 @@
-- sourceTreeHasData must use each version's required-file list. Gold's
-- cache has no Gen 1 trade art / pikachu.png; validating it against
-- REQUIRED_FILES made a Gold source tree look incomplete forever.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check = T.check
local f = assert(io.open("src/import/RomImporter.lua", "r"))
local src = f:read("*a")
f:close()
local start = src:find("local function sourceTreeHasData", 1, true)
check(start ~= nil, "sourceTreeHasData is defined")
local finish = src:find("\nfunction RomImporter.isReady", start, true)
check(finish ~= nil, "sourceTreeHasData ends before isReady")
local body = src:sub(start, finish)
check(body:find("requiredFilesFor", 1, true) ~= nil,
"sourceTreeHasData uses requiredFilesFor (Gold override, not Gen 1 only)")
check(body:find("ipairs(REQUIRED_FILES)", 1, true) == nil,
"sourceTreeHasData does not iterate the Gen 1 REQUIRED_FILES list raw")
local helperStart = src:find("local function requiredFilesFor", 1, true)
check(helperStart ~= nil, "requiredFilesFor helper exists")
local helper = src:sub(helperStart, start)
check(helper:find("VERSION_REQUIRED_FILES_OVERRIDE", 1, true) ~= nil,
"requiredFilesFor consults VERSION_REQUIRED_FILES_OVERRIDE")
T.finish()
+49
View File
@@ -195,6 +195,55 @@ Studio.addControl()
eq(#Studio.skin.pages[2].controls, 1, "controls land on the active page") eq(#Studio.skin.pages[2].controls, 1, "controls land on the active page")
eq(#Studio.skin.pages[1].controls, 0, "and not on the other one") eq(#Studio.skin.pages[1].controls, 0, "and not on the other one")
-- page orientation lock follows the canvas when Match canvas is on (#1503)
session()
Studio.canvasIndex = 1
Studio.matchOrient = true
eq(Studio.cyclePageOrient(1), "portrait", "cycle starts at portrait from unlocked")
eq(Studio.page().orient, "portrait", "and stores the lock on the page")
eq(Studio.page().name, "portrait", "a generic page is renamed so play can auto-rotate")
eq(Studio.canvas().id, "phone_portrait", "and the canvas stays portrait")
Studio.addPage()
eq(Studio.cyclePageOrient(1), "portrait", "the new page starts unlocked, first lock is portrait")
Studio.cyclePageOrient(1)
eq(Studio.page().orient, "landscape", "second cycle is landscape")
eq(Studio.canvas().id, "phone_landscape", "Match canvas flips the mock device with the page")
Studio.pageIndex = 1
Studio.syncCanvasToPage()
eq(Studio.canvas().id, "phone_portrait", "switching back to the portrait page restores portrait canvas")
Studio.setCanvas(2)
eq(Studio.pageIndex, 2, "picking a landscape canvas selects the landscape page")
Studio.matchOrient = false
Studio.pageIndex = 1
Studio.setCanvas(2)
eq(Studio.pageIndex, 1, "Match canvas off leaves the page when the device changes")
eq(Studio.canvas().id, "phone_landscape", "and still honours the canvas click")
-- a RetroArch overlay that already auto-rotates locks itself on open (#1503)
session()
Studio.matchOrient = false
Studio.canvasIndex = 2
Studio.skin = assert(TouchSkin.parse([[
overlays = 2
overlay0_name = "portrait"
overlay0_full_screen = true
overlay0_descs = 1
overlay0_desc0 = "a,0.5,0.5,radial,0.05,0.05"
overlay1_name = "landscape"
overlay1_full_screen = true
overlay1_descs = 1
overlay1_desc0 = "a,0.5,0.5,radial,0.05,0.05"
]]))
Studio.pageIndex = 1
check(Studio.applyImportedOrient(), "import of a portrait/landscape pair is automatic")
check(Studio.matchOrient, "and turns Match canvas on")
eq(Studio.page().name, "landscape", "keeping the landscape canvas already on screen")
eq(Studio.page().orient, "landscape", "with the page already locked")
-- --------------------------------------------------------------- clone -- --------------------------------------------------------------- clone
local source = TouchSkin.load("assets/skins/gb_anim", "gb_anim") local source = TouchSkin.load("assets/skins/gb_anim", "gb_anim")
+19
View File
@@ -89,6 +89,23 @@ check(probe:find("resolve=yellow/assets/generated/fonts/font.png", 1, true) ~= n
"probe records versioned font path visibility") "probe records versioned font path visibility")
check(not probe:find(string.char(0xEA, 0x9B), 1, true), check(not probe:find(string.char(0xEA, 0x9B), 1, true),
"probe log contains no ROM-like binary") "probe log contains no ROM-like binary")
check(probe:find("data/generated/maps.lua", 1, true) ~= nil,
"probe records Gold/Gen2 maps.lua visibility")
check(probe:find("loadActive=", 1, true) ~= nil,
"probe uses loadActive for generated Lua instead of newImage")
-- Gold maps.lua / gold/ folders: fused NX intro/naming/overworld crash.
GameVersion.set("gold")
love.filesystem.write("gold/data/generated/maps.lua", "return { NEW_BARK_TOWN = true }")
love.filesystem.write("gold/data/generated/oak_speech.lua", "return {}")
SwitchDiagnostics.probeAssets("gold")
probe = love.filesystem.read("nx-asset-probe.log") or ""
check(probe:find("cachePrefix=gold/", 1, true) ~= nil, "probe records gold prefix")
check(probe:find("data/generated/maps.lua", 1, true) ~= nil,
"probe lists maps.lua (the Gold cache incomplete path)")
check(probe:find("list gold", 1, true) ~= nil, "probe lists gold/ folder")
check(probe:find("list gold/data/generated", 1, true) ~= nil,
"probe lists gold/data/generated/")
love.system = { getOS = function() return "OS X" end } love.system = { getOS = function() return "OS X" end }
Platform._resetForTests() Platform._resetForTests()
@@ -104,5 +121,7 @@ love.filesystem.remove("nx-asset-probe.log")
love.filesystem.remove("yellow/assets/generated/fonts/font.png") love.filesystem.remove("yellow/assets/generated/fonts/font.png")
love.filesystem.remove("yellow/assets/generated/tilesets/reds_house.png") love.filesystem.remove("yellow/assets/generated/tilesets/reds_house.png")
love.filesystem.remove("yellow/assets/generated/sprites/red.png") love.filesystem.remove("yellow/assets/generated/sprites/red.png")
love.filesystem.remove("gold/data/generated/maps.lua")
love.filesystem.remove("gold/data/generated/oak_speech.lua")
T.finish() T.finish()
+104 -1
View File
@@ -51,6 +51,7 @@ local page = skin.pages[1]
eq(page.name, "shell", "page name") eq(page.name, "shell", "page name")
eq(page.imagePath, "img/back.png", "page background path") eq(page.imagePath, "img/back.png", "page background path")
check(page.fullScreen, "full_screen parsed") check(page.fullScreen, "full_screen parsed")
check(not page.aspectFromCfg, "a cfg without aspect_ratio does not lock aspect")
eq(page.alphaMod, 0.001, "overlay alpha_mod parsed") eq(page.alphaMod, 0.001, "overlay alpha_mod parsed")
eq(#page.controls, 7, "seven descs parsed") eq(#page.controls, 7, "seven descs parsed")
@@ -109,7 +110,7 @@ local sx, sy, sw, sh, fill = TouchSkin.viewport(W, H)
eq(sx, 0, "viewport x") eq(sy, 0, "viewport y") eq(sx, 0, "viewport x") eq(sy, 0, "viewport y")
eq(sw, 400, "viewport w") eq(sh, 400, "viewport h") eq(sw, 400, "viewport w") eq(sh, 400, "viewport h")
check(fill, "viewport fill flag returned") check(fill, "viewport fill flag returned")
eq(TouchSkin.viewport(W, H), 0, "viewport is window-relative, not page-relative") eq(TouchSkin.viewport(W, H), 0, "without a locked aspect, viewport tracks the window")
check(TouchControls:touchpressed("f1", at(0.80, 0.75)), "press on A is captured") check(TouchControls:touchpressed("f1", at(0.80, 0.75)), "press on A is captured")
check(Input:isDown("a"), "skin A presses GB a") check(Input:isDown("a"), "skin A presses GB a")
@@ -195,6 +196,8 @@ if bundled then
for _, btn in ipairs({ "a", "b", "start", "select", "up", "down", "left", "right" }) do for _, btn in ipairs({ "a", "b", "start", "select", "up", "down", "left", "right" }) do
check(named[btn], "gb_anim binds GB " .. btn) check(named[btn], "gb_anim binds GB " .. btn)
end end
check(not TouchSkin.hasOrientPair(bundled),
"gb_anim is not a portrait/landscape auto-rotate overlay")
end end
local tv = TouchSkin.load("assets/skins/tv_crt", "tv_crt") local tv = TouchSkin.load("assets/skins/tv_crt", "tv_crt")
@@ -322,4 +325,104 @@ end
TouchSkin.setActive(nil) TouchSkin.setActive(nil)
TouchControls:setHotkeyHandler(nil) TouchControls:setHotkeyHandler(nil)
-- ------------------------------------------ auto-rotate (#1503)
-- RetroArch overlays name a portrait page and a landscape page and wire
-- overlay_next between them. Play has to pick the one that matches the
-- window, or landscape stretches the portrait ranges into wide ovals.
local ORIENT_CFG = [[
overlays = 2
overlay0_name = "portrait"
overlay0_full_screen = true
overlay0_normalized = true
overlay0_aspect_ratio = 0.45
overlay0_descs = 1
overlay0_desc0 = "a,0.84382,0.69563,radial,0.09722,0.04375"
overlay1_name = "landscape"
overlay1_full_screen = true
overlay1_normalized = true
overlay1_aspect_ratio = 2.22222222222222
overlay1_descs = 1
overlay1_desc0 = "a,0.8307031248,0.8614400000,radial,0.0364168752,0.0813300000"
]]
local orient = assert(TouchSkin.parse(ORIENT_CFG))
check(orient.pages[1].aspectFromCfg, "portrait page locks the cfg aspect_ratio")
check(orient.pages[2].aspectFromCfg, "landscape page locks the cfg aspect_ratio")
eq(orient.pages[1].orient, "portrait", "a portrait page name locks on import")
eq(orient.pages[2].orient, "landscape", "a landscape page name locks on import")
check(TouchSkin.hasOrientPair(orient), "the pair is an auto-rotate overlay")
TouchSkin.setActive(orient)
TouchSkin.autoOrient = true
local PW, PH = 720, 1600
TouchSkin.setSurface(0, 0, PW, PH)
eq(TouchSkin.page().name, "portrait", "a tall window picks the portrait page")
local _, _, pHalfW, pHalfH =
TouchSkin.controlGeometry(TouchSkin.page(), TouchSkin.page().controls[1], PW, PH)
eq(math.floor(pHalfW + 0.5), 70, "portrait A half-width follows range_x")
eq(math.floor(pHalfH + 0.5), 70, "portrait A half-height follows range_y")
local LW, LH = 1600, 720
TouchSkin.setSurface(0, 0, LW, LH)
eq(TouchSkin.page().name, "landscape", "a wide window picks the landscape page")
local _, _, lHalfW, lHalfH =
TouchSkin.controlGeometry(TouchSkin.page(), TouchSkin.page().controls[1], LW, LH)
eq(math.floor(lHalfW + 0.5), 58, "landscape A half-width follows the landscape range")
eq(math.floor(lHalfH + 0.5), 59, "landscape A half-height follows the landscape range")
check(math.abs(lHalfW - lHalfH) < 2, "landscape A stays round instead of stretching")
-- 16:9 is not the overlay's 20:9. Letterbox to aspect_ratio so A stays round
-- instead of filling the window and turning into a tall oval (#1503).
TouchSkin.setSurface(0, 0, 1920, 1080)
eq(TouchSkin.page().name, "landscape", "16:9 still picks landscape")
local _, _, boxW, boxH = TouchSkin.pageBox(TouchSkin.page(), 1920, 1080)
eq(math.floor(boxW + 0.5), 1920, "letterbox keeps the 16:9 width")
eq(math.floor(boxH + 0.5), 864, "and the overlay's 20:9 height")
local _, _, sHalfW, sHalfH =
TouchSkin.controlGeometry(TouchSkin.page(), TouchSkin.page().controls[1], 1920, 1080)
check(math.abs(sHalfW - sHalfH) < 2, "A stays round on a 16:9 window")
local nativeOrient = TouchSkin.parseNative(TouchSkin.serialize(orient))
check(nativeOrient and nativeOrient.pages[2].aspectFromCfg,
"native export keeps the aspect lock")
-- an explicit lock wins over the page name, so a studio-authored "main"
-- page can still auto-rotate in play
local LOCK_CFG = [[
overlays = 2
overlay0_name = "main"
overlay0_full_screen = true
overlay0_descs = 1
overlay0_desc0 = "a,0.5,0.5,radial,0.05,0.05"
overlay1_name = "wide"
overlay1_full_screen = true
overlay1_descs = 1
overlay1_desc0 = "a,0.5,0.5,radial,0.05,0.05"
]]
local locked = assert(TouchSkin.parse(LOCK_CFG))
locked.pages[1].orient = "portrait"
locked.pages[2].orient = "landscape"
TouchSkin.setActive(locked)
TouchSkin.setSurface(0, 0, 1600, 720)
eq(TouchSkin.page().name, "wide", "orient lock auto-rotates a page not named landscape")
local roundTrip = TouchSkin.parseNative(TouchSkin.serialize(locked))
eq(roundTrip.pages[1].orient, "portrait", "native export keeps a portrait lock")
eq(roundTrip.pages[2].orient, "landscape", "and a landscape lock")
TouchSkin.setActive(orient)
TouchSkin.autoOrient = false
TouchSkin.pageIndex = 1
eq(TouchSkin.page().name, "portrait",
"the studio can keep a portrait page on a landscape canvas")
TouchSkin.autoOrient = true
TouchSkin.setSurface(nil)
-- pages that are not a portrait/landscape pair (gb_anim) stay put
TouchSkin.setActive(skin)
TouchSkin.setSurface(0, 0, LW, LH)
eq(TouchSkin.page().name, "shell", "a non-oriented skin does not auto-rotate")
TouchSkin.setSurface(nil)
TouchSkin.setActive(nil)
T.finish("touch_skin") T.finish("touch_skin")
+57
View File
@@ -84,6 +84,63 @@ end
local _, _, vw0, vh0 = BorderFill.viewport(0, 0, 160, 144, 0) local _, _, vw0, vh0 = BorderFill.viewport(0, 0, 160, 144, 0)
check(vw0 > 0 and vh0 > 0, "scale 0 falls back to 1 rather than collapsing") check(vw0 > 0 and vh0 > 0, "scale 0 falls back to 1 rather than collapsing")
-- ---------------------------------------------------------------------------
-- VOID FILL (#1418): FADE / WATER / TREES / BLACK
-- ---------------------------------------------------------------------------
eq(BorderFill.voidFill, "fade", "the live fill defaults to FADE")
eq(BorderFill.voidFillLabel(), "FADE ", "and prints with a trailing space")
local johto = { id = "CHERRYGROVE_CITY", tileset = "TILESET_JOHTO",
borderBlock = 0x35 }
local route = { id = "ROUTE_30", tileset = "TILESET_JOHTO", borderBlock = 0x05 }
local kanto = { id = "PALLET_TOWN", tileset = "TILESET_KANTO",
borderBlock = 0x0f }
local cave = { id = "UNION_CAVE_1F", tileset = "TILESET_CAVE", borderBlock = 0x09 }
local house = { id = "PLAYERS_HOUSE_1F", tileset = "TILESET_PLAYERS_HOUSE",
borderBlock = 0x00 }
eq(BorderFill.fillBlock(johto), 0x35, "FADE keeps Cherrygrove's water")
eq(BorderFill.fillBlock(route), 0x05, "and Route 30's trees")
eq(BorderFill.fillKey(johto), "fade|CHERRYGROVE_CITY",
"FADE dissolves per map")
eq(BorderFill.fillKey(route) ~= BorderFill.fillKey(johto), true,
"so the two maps are different fills")
BorderFill.setVoidFill("water")
eq(BorderFill.fillBlock(johto), 0x35, "WATER uses Cherrygrove's water block")
eq(BorderFill.fillBlock(route), 0x35, "on Route 30 too")
eq(BorderFill.fillBlock(kanto), 0x43, "and Cinnabar's water on Kanto")
eq(BorderFill.fillBlock(cave), 0x09, "but a cave keeps its own border")
eq(BorderFill.fillBlock(house), 0x00, "and so does a house")
eq(BorderFill.fillKey(johto), BorderFill.fillKey(route),
"two Johto maps share one WATER fill")
eq(BorderFill.fillKey(johto) ~= BorderFill.fillKey(kanto), true,
"Kanto water is a different sheet")
BorderFill.setVoidFill("trees")
eq(BorderFill.fillBlock(johto), 0x05, "TREES uses New Bark's tree wall")
eq(BorderFill.fillBlock(kanto), 0x0f, "and Pallet's on Kanto")
eq(BorderFill.fillBlock(cave), 0x09, "caves still keep their own")
BorderFill.setVoidFill("black")
eq(BorderFill.fillBlock(johto), false, "BLACK skips the tiled bake")
eq(BorderFill.fillBlock(house), false, "indoors too")
eq(BorderFill.fillKey(johto), "black", "and is one sheet everywhere")
BorderFill.setVoidFill("nope")
eq(BorderFill.voidFill, "fade", "an unknown mode falls back to FADE")
BorderFill.setVoidFill("fade")
eq(BorderFill.cycle(1), "water", "cycle steps FADE to WATER")
eq(BorderFill.cycle(1), "trees", "then TREES")
eq(BorderFill.cycle(1), "black", "then BLACK")
eq(BorderFill.cycle(1), "fade", "and wraps to FADE")
eq(BorderFill.cycle(-1), "black", "left wraps the other way")
BorderFill.applyOptions({ voidFill = "trees" })
eq(BorderFill.voidFill, "trees", "applyOptions pushes the saved mode")
BorderFill.applyOptions({})
eq(BorderFill.voidFill, "fade", "and a missing key restores FADE")
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
-- The real cache: the map the bug was reported on -- The real cache: the map the bug was reported on
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
+16
View File
@@ -213,6 +213,22 @@ previous, alpha = BorderFill.crossfade(owner, trees, "ROUTE_30")
check("then it is over", previous, nil) check("then it is over", previous, nil)
check("and the new block owns the void", alpha, 1) check("and the new block owns the void", alpha, 1)
-- Forced WATER on two Johto maps shares a fill key, so the dissolve does
-- not run identical water against itself.
BorderFill.setVoidFill("water")
local johtoWater = { id = "CHERRYGROVE_CITY", tileset = "TILESET_JOHTO",
borderBlock = 0x35 }
local routeTrees = { id = "ROUTE_30", tileset = "TILESET_JOHTO",
borderBlock = 0x05 }
check("WATER fill keys match across Johto",
BorderFill.fillKey(johtoWater), BorderFill.fillKey(routeTrees))
local forced = {}
BorderFill.crossfade(forced, "water", BorderFill.fillKey(johtoWater))
local fromForced = BorderFill.crossfade(forced, "water",
BorderFill.fillKey(routeTrees))
check("so walking Route 30 does not start a fade", fromForced, nil)
BorderFill.setVoidFill("fade")
-- No key at all (an old caller) is the plain single draw. -- No key at all (an old caller) is the plain single draw.
local bare = {} local bare = {}
previous, alpha = BorderFill.crossfade(bare, trees, nil) previous, alpha = BorderFill.crossfade(bare, trees, nil)
+29 -2
View File
@@ -358,7 +358,7 @@ local options = OptionsMenu.new(optionsGame, {
}) })
-- The cart's seven rows, then the port's: CONTROLS, audio, speed, display, -- The cart's seven rows, then the port's: CONTROLS, audio, speed, display,
-- video mode, the mobile-gated touch three (buildRows), MAX FPS and CANCEL. -- video mode, the mobile-gated touch three (buildRows), MAX FPS and CANCEL.
check("twenty-two rows", #OptionsMenu.ROWS, 22) check("twenty-three rows", #OptionsMenu.ROWS, 23)
check("the cart's rows come first", OptionsMenu.ROWS[7].key, "frame") check("the cart's rows come first", OptionsMenu.ROWS[7].key, "frame")
check("then the rebind screen", OptionsMenu.ROWS[8].id, "controls") check("then the rebind screen", OptionsMenu.ROWS[8].id, "controls")
check("then the port's audio group", OptionsMenu.ROWS[9].key, "musicVol") check("then the port's audio group", OptionsMenu.ROWS[9].key, "musicVol")
@@ -861,10 +861,15 @@ check("GBC leaves a palette alone",
1)[1], 1) 1)[1], 1)
check("and has no present pass", GbcPalette.presentColors(), nil) check("and has no present pass", GbcPalette.presentColors(), nil)
local gbcfxIndex local zoomIndex, gbcfxIndex
for i, row in ipairs(OptionsMenu.ROWS) do for i, row in ipairs(OptionsMenu.ROWS) do
if row.label == "ZOOM" then zoomIndex = i end
if row.label == "GBC FX" then gbcfxIndex = i end if row.label == "GBC FX" then gbcfxIndex = i end
end end
check("VOID FILL follows ZOOM", OptionsMenu.ROWS[zoomIndex + 1].label,
"VOID FILL")
check("and TILT follows VOID FILL", OptionsMenu.ROWS[zoomIndex + 2].label,
"TILT")
check("VIDEO MODE follows GBC FX", OptionsMenu.ROWS[gbcfxIndex + 1].label, check("VIDEO MODE follows GBC FX", OptionsMenu.ROWS[gbcfxIndex + 1].label,
"VIDEO MODE") "VIDEO MODE")
check("and TOUCH PAD follows it", OptionsMenu.ROWS[gbcfxIndex + 2].label, check("and TOUCH PAD follows it", OptionsMenu.ROWS[gbcfxIndex + 2].label,
@@ -897,6 +902,28 @@ scrollOptions:cycle(videoRow, -1)
check("left toggles back to windowed", check("left toggles back to windowed",
scrollOptions.options.videoMode, "windowed") scrollOptions.options.videoMode, "windowed")
local voidRow = select(2, rowNamed("VOID FILL"))
check("VOID FILL is a row", voidRow ~= nil, true)
check("and it defaults to FADE", Save.DEFAULT_OPTIONS.voidFill, "fade")
scrollOptions.options.voidFill = "fade"
local BorderFill = require("src.world.gen2.BorderFill")
BorderFill.setVoidFill("fade")
scrollOptions:cycle(voidRow, 1)
check("right steps to WATER", scrollOptions.options.voidFill, "water")
check("and the live fill tracks it", BorderFill.voidFill, "water")
scrollOptions:cycle(voidRow, 1)
check("then TREES", scrollOptions.options.voidFill, "trees")
scrollOptions:cycle(voidRow, 1)
check("then BLACK", scrollOptions.options.voidFill, "black")
scrollOptions:cycle(voidRow, 1)
check("and wraps back to FADE", scrollOptions.options.voidFill, "fade")
check("FADE blanks the longer labels", voidRow.text(scrollOptions.options),
"FADE ")
scrollOptions.options.voidFill = "water"
check("WATER fits the value column", voidRow.text(scrollOptions.options),
"WATER")
BorderFill.setVoidFill("fade")
check("text reads WINDOWED", videoRow.text(scrollOptions.options), "WINDOWED") check("text reads WINDOWED", videoRow.text(scrollOptions.options), "WINDOWED")
scrollOptions.options.videoMode = "borderless" scrollOptions.options.videoMode = "borderless"
check("text reads FULL, not BORDERLESS", videoRow.text(scrollOptions.options), check("text reads FULL, not BORDERLESS", videoRow.text(scrollOptions.options),
+59
View File
@@ -179,6 +179,65 @@ check("the cursor is the column-1 arrow on the label row",
check("the cursor starts on the first row", cursorAt and cursorAt.y, 2) check("the cursor starts on the first row", cursorAt and cursorAt.y, 2)
check("FRAME still prints its literal TYPE", findPrint(":TYPE") ~= nil, true) check("FRAME still prints its literal TYPE", findPrint(":TYPE") ~= nil, true)
-- ------------------------------------------------------- mart buy list
--
-- BuyMenu (engine/items/mart.asm) is FadeToMenu + BlankScreen, then
-- PlaceMoneyTopRight and ScrollingMenu_UpdateDisplay's ClearWholeMenuBox.
-- The list is not MenuBox'd; only the money box and UpdateItemDescription's
-- textbox are. The top menu still overlays the mart, so it must not blank.
local MartMenu = require("src.ui.gen2.MartMenu")
local Save = require("src.core.gen2.Save")
local martBoxes, martClears = {}, 0
local martClear, martBox = Chrome.clear, Chrome.box
local martPrint, martCursor = Chrome.print, Chrome.cursor
Chrome.clear = function() martClears = martClears + 1 end
Chrome.box = function(x, y, w, h)
martBoxes[#martBoxes + 1] = { x = x, y = y, w = w, h = h }
end
Chrome.print = function() end
Chrome.cursor = function() end
local function martHasBox(x, y, w, h)
for _, b in ipairs(martBoxes) do
if b.x == x and b.y == y and b.w == w and b.h == h then return true end
end
return false
end
local martSave = Save.newGame()
local martItems = {
POTION = { id = "POTION", name = "POTION", pocket = "ITEM", price = 300,
description = "Restores HP\nby 20." },
}
local mart = MartMenu.new({ save = martSave, data = { items = martItems } }, {
save = martSave,
items = martItems,
marts = { lists = { { "POTION" } } },
})
mart:draw()
check("the top menu does not blank the tilemap", martClears, 0)
check("and frames BUY/SELL/QUIT", martHasBox(0, 0, 12, 9), true)
check("and the welcome speech box", martHasBox(0, 12, 20, 6), true)
check("the top menu is not opaque", mart.isOpaque, false)
martClears, martBoxes = 0, {}
mart:enterBuy()
mart:draw()
check("BUY blanks the screen (BlankScreen)", martClears > 0, true)
check("the money box is framed", martHasBox(11, 0, 9, 3), true)
check("the description box is framed", martHasBox(0, 12, 20, 6), true)
check("the item list is not (ClearWholeMenuBox, not MenuBox)",
martHasBox(1, 3, 19, 9), false)
check("BlankScreen shadows isOpaque so the letterbox is not the mart",
mart.isOpaque, true)
mart:leaveBuy()
check("leaving BUY unshadows isOpaque", mart.isOpaque, false)
Chrome.clear, Chrome.box = martClear, martBox
Chrome.print, Chrome.cursor = martPrint, martCursor
-- ------------------------------------------------------- one blit scale -- ------------------------------------------------------- one blit scale
-- --
-- Every Gold screen paints its 160x144 panel through the same helper. A -- Every Gold screen paints its 160x144 panel through the same helper. A
+87
View File
@@ -0,0 +1,87 @@
-- Parity test (#1519): wStepCounter is zeroed by ClearVariablesOnEnterMap
-- (engine/overworld/clear_variables.asm:7), reached from EnterMap on a warp
-- and from the post-battle tail jump (home/overworld.asm:353), but never from
-- CheckMapConnections (home/overworld.asm:675), so a seam crossing keeps phase.
-- Self-contained; run via `luajit tests/parity_field_poison_phase.lua`.
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 and Data.maps.PALLET_TOWN) then Data:load() end
local S = require("tests.harness").suite("parity field poison phase")
local check, eq = S.check, S.eq
require("src.render.Font").load(Data)
local Game = require("src.core.Game")
local Input = require("src.core.Input")
local StateStack = require("src.core.StateStack")
local Renderer = require("src.render.Renderer")
local SaveData = require("src.core.SaveData")
local Pokemon = require("src.pokemon.Pokemon")
local OW = require("src.world.OverworldController")
Game.data = Data
Game.input = Input; Input:init()
Game.renderer = Renderer; Renderer:init()
Game.stack = StateStack; StateStack:init()
Game.save = SaveData.newGame()
Game.save.party = { Pokemon.new(Data, "SQUIRTLE", 12) }
Game.stack:push(OW, "PALLET_TOWN", 5, 6, "down")
local ow = Game.stack:top()
Game.overworld = ow
local mon = Game.save.party[1]
mon.status = "PSN"
-- Walk until the poison bites, and report which step it was.
local function stepsToTick(limit)
for i = 1, limit or 12 do
local before = mon.hp
ow:applyFieldPoison()
if mon.hp < before then return i end
end
return nil
end
local function reheal()
mon.hp = mon.stats.hp
mon.status = "PSN"
end
-- ---- the free-running four-step cycle ------------------------------------
Game.save.poisonSteps = 0
eq(stepsToTick(), 4, "a cleared counter bites on the fourth step")
reheal()
eq(stepsToTick(), 4, "and every fourth step after that")
reheal()
Game.save.poisonSteps = 2
eq(stepsToTick(), 2, "a counter two steps into the cycle bites two steps later")
-- ---- a real map load re-phases it ----------------------------------------
reheal()
Game.save.poisonSteps = 2
ow:setMap("PALLET_TOWN", 5, 6, "down", { via = "warp" })
eq(Game.save.poisonSteps, 0, "setMap clears the counter")
eq(stepsToTick(), 4, "so the first tick after a warp is four steps out")
-- ---- a connection crossing does not --------------------------------------
reheal()
Game.save.poisonSteps = 2
ow:setMap("ROUTE_1", 9, 33, "up", { seamless = true, keepMusic = true })
eq(Game.save.poisonSteps, 2, "a seamless crossing leaves the counter alone")
eq(stepsToTick(), 2, "so the seam does not delay the tick")
-- ---- a battle return re-phases it ----------------------------------------
reheal()
Game.save.poisonSteps = 2
ow:afterBattle("win", { kind = "wild" })
eq(Game.save.poisonSteps, 0, "afterBattle clears the counter")
eq(stepsToTick(), 4, "so poison always bites four steps after a battle")
reheal()
Game.save.poisonSteps = 3
ow:afterBattle("win", { kind = "trainer", oppClass = "OPP_YOUNGSTER" })
eq(stepsToTick(), 4, "trainer battles re-phase it the same way")
S.finish()
+5 -1
View File
@@ -26,7 +26,7 @@ end
-- Also gates the NX runtime modules and the NX engine suites so an NX -- Also gates the NX runtime modules and the NX engine suites so an NX
-- runtime regression cannot slip past switch-selftest / switch-build. -- runtime regression cannot slip past switch-selftest / switch-build.
local SWITCH_PATH_REGEX = local SWITCH_PATH_REGEX =
[[^(scripts/build_switch\.sh$|scripts/switch/|docs/switch-.*\.md$|tests/switch_ci_workflows_test\.lua$|tests/switch_transfer_docs_test\.lua$|\.github/workflows/(ci|release|switch-artifact-comment)\.yml$|src/core/(NxAssetOverlay|Platform|GameVersion)\.lua$|src/import/CacheFs\.lua$|tests/engine/(assets_version_fallback|nx_generated_guard|nx_yellow_boot|switch_diagnostics)_test\.lua$|tests/engine/platform_nx)]] [[^(scripts/build_switch\.sh$|scripts/switch/|docs/switch-.*\.md$|tests/switch_ci_workflows_test\.lua$|tests/switch_transfer_docs_test\.lua$|\.github/workflows/(ci|release|switch-artifact-comment)\.yml$|src/core/(NxAssetOverlay|Platform|GameVersion)\.lua$|src/import/CacheFs\.lua$|tests/engine/(assets_version_fallback|nx_generated_guard|nx_yellow_boot|switch_diagnostics|cache_fs_gold_nx_load)_test\.lua$|tests/engine/platform_nx)]]
local ci = read(".github/workflows/ci.yml") local ci = read(".github/workflows/ci.yml")
local release = read(".github/workflows/release.yml") local release = read(".github/workflows/release.yml")
@@ -50,6 +50,7 @@ for _, fragment in ipairs({
"nx_generated_guard", "nx_generated_guard",
"nx_yellow_boot", "nx_yellow_boot",
"switch_diagnostics", "switch_diagnostics",
"cache_fs_gold_nx_load",
"tests/engine/platform_nx", "tests/engine/platform_nx",
}) do }) do
mustContain(ci, fragment, "ci.yml path regex NX fragment") mustContain(ci, fragment, "ci.yml path regex NX fragment")
@@ -79,6 +80,7 @@ do
mustContain(block, "luajit tests/engine/assets_version_fallback_test.lua", "switch-selftest") mustContain(block, "luajit tests/engine/assets_version_fallback_test.lua", "switch-selftest")
mustContain(block, "luajit tests/engine/nx_generated_guard_test.lua", "switch-selftest") mustContain(block, "luajit tests/engine/nx_generated_guard_test.lua", "switch-selftest")
mustContain(block, "luajit tests/engine/nx_yellow_boot_test.lua", "switch-selftest") mustContain(block, "luajit tests/engine/nx_yellow_boot_test.lua", "switch-selftest")
mustContain(block, "luajit tests/engine/cache_fs_gold_nx_load_test.lua", "switch-selftest")
mustNotContain(block, "continue-on-error:", "switch-selftest") mustNotContain(block, "continue-on-error:", "switch-selftest")
end end
@@ -185,6 +187,7 @@ mustContain(test_sh, "T0 switch transfer docs gate", "scripts/test.sh")
mustContain(test_sh, "tests/engine/assets_version_fallback_test.lua", "scripts/test.sh") mustContain(test_sh, "tests/engine/assets_version_fallback_test.lua", "scripts/test.sh")
mustContain(test_sh, "tests/engine/nx_generated_guard_test.lua", "scripts/test.sh") mustContain(test_sh, "tests/engine/nx_generated_guard_test.lua", "scripts/test.sh")
mustContain(test_sh, "tests/engine/nx_yellow_boot_test.lua", "scripts/test.sh") mustContain(test_sh, "tests/engine/nx_yellow_boot_test.lua", "scripts/test.sh")
mustContain(test_sh, "tests/engine/cache_fs_gold_nx_load_test.lua", "scripts/test.sh")
mustContain(build_doc, "tests/switch_ci_workflows_test.lua", "switch-build.md path list") mustContain(build_doc, "tests/switch_ci_workflows_test.lua", "switch-build.md path list")
mustContain(build_doc, "tests/switch_transfer_docs_test.lua", "switch-build.md path list") mustContain(build_doc, "tests/switch_transfer_docs_test.lua", "switch-build.md path list")
@@ -197,6 +200,7 @@ for _, path in ipairs({
"tests/engine/assets_version_fallback_test.lua", "tests/engine/assets_version_fallback_test.lua",
"tests/engine/nx_generated_guard_test.lua", "tests/engine/nx_generated_guard_test.lua",
"tests/engine/nx_yellow_boot_test.lua", "tests/engine/nx_yellow_boot_test.lua",
"tests/engine/cache_fs_gold_nx_load_test.lua",
"tests/engine/switch_diagnostics_test.lua", "tests/engine/switch_diagnostics_test.lua",
"tests/engine/platform_nx_*", "tests/engine/platform_nx_*",
}) do }) do
+4
View File
@@ -30,6 +30,7 @@ mustContain(transfer, "FTP", "transfer")
mustContain(transfer, "sdmc:/switch/gen1recomp/", "transfer") mustContain(transfer, "sdmc:/switch/gen1recomp/", "transfer")
mustContain(transfer, "imports/", "transfer") mustContain(transfer, "imports/", "transfer")
mustContain(transfer, "imports/mods/", "transfer") mustContain(transfer, "imports/mods/", "transfer")
mustContain(transfer, "gold", "transfer")
mustContain(transfer, "1: SD Card", "transfer") mustContain(transfer, "1: SD Card", "transfer")
mustContain(transfer, "Scan again", "transfer") mustContain(transfer, "Scan again", "transfer")
mustContain(transfer, "documented example", "transfer") mustContain(transfer, "documented example", "transfer")
@@ -70,6 +71,9 @@ mustContain(install, "PERFORMANCE", "install")
mustContain(install, "Stock engine effect", "install") mustContain(install, "Stock engine effect", "install")
mustContain(install, "## Limitations", "install") mustContain(install, "## Limitations", "install")
mustContain(install, "Launch with title override", "install") mustContain(install, "Launch with title override", "install")
mustContain(install, "Gold", "install")
mustContain(install, "imports/saves/gold/", "install")
mustContain(install, "exports/gold/", "install")
mustNotContain(install, "VoxelMod", "install") mustNotContain(install, "VoxelMod", "install")
mustNotContain(install, "switch-development", "install") mustNotContain(install, "switch-development", "install")
mustContain(build, "switch-transfer.md", "build") mustContain(build, "switch-transfer.md", "build")