Merge pull request #1523 from bryanthaboi/dev

some bugs and some switch stuff
This commit is contained in:
bryanthaboi
2026-08-18 10:18:58 -04:00
committed by GitHub
37 changed files with 1025 additions and 150 deletions
+4 -1
View File
@@ -120,7 +120,7 @@ jobs:
echo "changed=true" >> "$GITHUB_OUTPUT"
exit 0
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"
else
echo "changed=false" >> "$GITHUB_OUTPUT"
@@ -151,6 +151,9 @@ jobs:
luajit tests/engine/assets_version_fallback_test.lua
luajit tests/engine/nx_generated_guard_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:
name: Switch fused build
+10 -1
View File
@@ -26,7 +26,7 @@ as-is. Supported keys:
| `overlayN_overlay` | bezel image |
| `overlayN_full_screen` | stretch the page to the window |
| `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_viewport` | `x,y,w,h`, the screen cutout |
| `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
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
images already in the skin folder; the **Import** button beside each one opens
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/nx_generated_guard_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_*`,
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
headlessly (`luajit tests/engine/assets_version_fallback_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
(`bryanthaboi/gen1recomp`), on the self-hosted Mac runner
(`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:
`gen1recomp-*-switch.zip`. Extract it at the root of your microSD (install
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).
> 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:
1. Put your own legally obtained Pokémon Red, Blue (`.gb`), or Yellow
(`.gbc`) dump into `switch/gen1recomp/pokemon-love2d/imports/` (the
launcher also shows the live save-dir path). All three can sit in the
1. Put your own legally obtained Pokémon Red, Blue (`.gb`), Yellow, or
Gold (`.gbc`) dump into `switch/gen1recomp/pokemon-love2d/imports/` (the
launcher also shows the live save-dir path). All four can sit in the
same folder.
2. Use **Scan again** on that game's tab (Red / Blue / Yellow). Rescan
matches by ROM SHA-1 for the open tab only. A Red dump never imports
from the Yellow tab (and vice versa).
2. Use **Scan again** on that game's tab (Red / Blue / Yellow / Gold).
Rescan matches by ROM SHA-1 for the open tab only. A Red dump never
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`
@@ -109,10 +110,13 @@ SD / FTP, same transfer methods as ROMs. Paths are **per game**:
| Red | `imports/saves/red/` | `exports/red/` |
| Blue | `imports/saves/blue/` | `exports/blue/` |
| 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)).
2. With the game's ROM already imported, open **that game's tab**
**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 |
| 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** |
| Save `.sav` inbox | Same save dir → `imports/saves/red\|blue\|yellow/` then that game's SAVE FILES → **Import save** |
| Save exports | Same save dir → `exports/red\|blue\|yellow/` (pull after **Export save**; MTP / SD / FTP) |
| 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\|gold/` (pull after **Export save**; Gold cart `.sav` not supported yet) |
| Opt-in diagnostics | Empty `switch-debug.txt` in the save dir → `switch.log` |
| 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
(or copy NRO / `game.love` for loose).
4. For ROMs/mods/saves, open the save-dir `imports/`, `imports/mods/`,
`imports/saves/<red|blue|yellow>/`, or `exports/<red|blue|yellow>/` path the
launcher prints.
`imports/saves/<red|blue|yellow|gold>/`, or `exports/<red|blue|yellow|gold>/`
path the launcher prints.
5. Wait for the queue; refresh; exit MTP responder; title-override launch.
macOS clients often create AppleDouble sidecars (`._Something.zip`,
+12 -6
View File
@@ -71,15 +71,15 @@ First install or update (same steps):
your saves, imported ROMs, mods, and options. Re-extracting only
replaces the NRO(s) and these help files.
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/
then use Scan again in the launcher if needed.
Inboxes (drop files here via MTP / SD / FTP):
imports/ — ROM .gb / .gbc
imports/mods/ — community mod .zip
imports/saves/red|blue|yellow/ — raw .sav import
exports/red|blue|yellow/ — pull after Export save
imports/ — ROM .gb / .gbc
imports/mods/ — community mod .zip
imports/saves/red|blue|yellow|gold/ — raw .sav import (Gold cart .sav not yet)
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
EOF
@@ -92,7 +92,7 @@ write_readme() {
}
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" \
"Put community mod .zip files here, then MODS → Scan again."
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."
write_readme "$SAVE_ROOT/imports/saves/yellow/README.txt" \
"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" \
"After Export save (Red), copy the .sav out of this folder via MTP / SD / FTP."
write_readme "$SAVE_ROOT/exports/blue/README.txt" \
"After Export save (Blue), copy the .sav out of this folder via MTP / SD / FTP."
write_readme "$SAVE_ROOT/exports/yellow/README.txt" \
"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"
(
@@ -135,9 +139,11 @@ REQUIRED=(
"switch/gen1recomp/pokemon-love2d/imports/saves/red/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/gold/README.txt"
"switch/gen1recomp/pokemon-love2d/exports/red/README.txt"
"switch/gen1recomp/pokemon-love2d/exports/blue/README.txt"
"switch/gen1recomp/pokemon-love2d/exports/yellow/README.txt"
"switch/gen1recomp/pokemon-love2d/exports/gold/README.txt"
)
for rel in "${REQUIRED[@]}"; do
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/blue/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/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
printf '%s\n' "$ZIP_LIST" | grep -Fq "$rel" || PACK_MISSING="${PACK_MISSING} ${rel}"
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 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 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 "T1/T2 engine invariants + parity gates" "$LUA" tests/run_engine.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")
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]
return renderEffect(data, header, {
frequencyOffset = pitch or 0,
frameTicks = 0x80 + (tempo or 0x80),
plainFrames = plainFrames,
})
end
+12 -6
View File
@@ -280,6 +280,7 @@ function Channel.new(engine, spec, options)
allowLoops = options.allowLoops ~= false,
frequencyOffset = options.frequencyOffset or 0,
frameTicks = options.frameTicks or FRAME_TICKS,
plainTicks = (options.plainFrames or 0) * FRAME_TICKS,
speed = 12,
noteLength = 1, -- Gen 2 CHANNEL_NOTE_LENGTH (note_type)
durationModifier = 0, -- Gen 2 fractional-frame carry
@@ -349,8 +350,9 @@ function Channel:frequencyGen2(note, octave)
return bit.band(register + self.frequencyOffset, 0x7FF)
end
function Channel:durationTicks(length)
local tempo = self.sfx and self.frameTicks or self.engine.tempo
function Channel:durationTicks(length, plain)
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)
or self.speed
return length * speed * tempo
@@ -586,6 +588,9 @@ function Channel:nextEvent()
local packed = self:byte()
local volume = bit.rshift(packed, 4)
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
-- Audio2_ApplyWavePatternAndFrequency adds wFrequencyModifier to the
-- 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
-- battle hit sounds at their unmodified pitches, where super effective
-- 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(
self:durationTicks(length), volume, fade, parameter)
self:durationTicks(length, plain), volume, fade, parameter)
end
local register = bit.band(self:word() + self.frequencyOffset, 0x7FF)
return self:tone(self:durationTicks(length), register, volume, fade)
local register = bit.band(self:word() + offset, 0x7FF)
return self:tone(self:durationTicks(length, plain), register, volume, fade)
elseif command == 0x10 then
local packed = self:byte()
self.sweep = {
@@ -1260,6 +1265,7 @@ function Engine.new(data, header, options)
allowLoops = options.allowLoops,
frequencyOffset = options.frequencyOffset,
frameTicks = frameTicks,
plainFrames = options.plainFrames,
})
end
return engine
+5 -5
View File
@@ -120,11 +120,11 @@ local function visibleBaseState(stack)
end
local function loadGenerated(path)
local chunk = love.filesystem.load(path)
if not chunk then return nil end
local ok, data = pcall(chunk)
if ok then return data end
return nil
-- CacheFs.loadActive, not love.filesystem.load: Gold's cache lives under
-- gold/ and fused NX often cannot mount that tree onto data/generated/.
local CacheFs = require("src.import.CacheFs")
local data = CacheFs.loadActive(path)
return data
end
-- NewGame (engine/menus/intro_menu.asm) calls OakSpeech, and OakSpeech's first
+30 -13
View File
@@ -1,10 +1,11 @@
-- 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
-- wraps EVERY read-side love entry point that accepts a filesystem path
-- once at boot: any string path under assets/generated/ that does not
-- resolve falls back to the active version's prefixed copy
-- (yellow|blue/assets/generated/...). Covering the whole read surface --
-- once at boot: any string path under assets/generated/ or data/generated/
-- prefers the active version's prefixed copy (yellow|blue|gold/...) when
-- 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
-- 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
-- state without these wrappers; ChipAudio.slimAudio hands it the prefix
-- explicitly as audio.programPrefix.
-- * data/generated module loads go through CacheFs.readActive, which
-- already implements the same fallback for require bytes.
-- * Gen 1 Data:load and Gold Game2/World go through CacheFs.readActive /
-- 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 GENERATED = "assets/generated/"
local GENERATED_PREFIXES = {
"assets/generated/",
"data/generated/",
}
local NxAssetOverlay = {}
local originals -- raw love functions, non-nil while installed
-- Resolve `path` to the versioned copy when the un-prefixed file is missing
-- and the active version (Blue/Yellow) carries it. Returns nil when the
-- caller's path should be used untouched (non-generated path, Red, the real
-- file exists, or no versioned copy).
-- Resolve `path` to the active version's prefixed copy when that file
-- exists (gold|yellow|blue|red/{assets,data}/generated/...). The versioned
-- tree wins over a leftover un-prefixed file so a pre-#899 Red root cache
-- (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)
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()
if prefix == "" then return nil end
if originals.getInfo(path) then return nil end
local candidate = prefix .. path
if originals.getInfo(candidate) then return candidate end
return nil
+109 -36
View File
@@ -8,6 +8,7 @@
local Assets = require("src.render.Assets")
local Logger = require("src.core.Logger")
local Runtime = require("src.mods.Runtime")
local bit = require("bit")
local Sound = {}
@@ -142,10 +143,10 @@ local function newFileSource(def)
return s
end
local function newSfxSource(data, key, def, pitch, tempo)
local function newSfxSource(data, key, def, pitch, tempo, plain)
if isChipDef(def) then
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 s then return nil, "no source" end
return s
@@ -153,12 +154,12 @@ local function newSfxSource(data, key, def, pitch, tempo)
return newFileSource(def)
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
local src = cache[key]
if src == false then return nil end -- known bad, already logged
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
cache[key] = false
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
-- SFX_BATTLE_2A (CHAN5+6+8) at all -- unguarded, its tail is heard running
-- 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)
if not (a and b) then return false end
for _, x in ipairs(a) do
for _, y in ipairs(b) do
if x == y then return true end
end
local function sourceAlive(entry)
local ok, playing = pcall(entry.src.isPlaying, entry.src)
return ok and playing
end
local function pruneMoveSfx()
for ch, cur in pairs(moveSfxChannels) do
if not sourceAlive(cur) then moveSfxChannels[ch] = nil end
end
return false
end
-- would PlaySound start this def now? Taking a channel over also stops the
-- sound that held it, the way .playChannel resets the channel.
local function sfxChannelGate(data, def)
local cur = lastMoveSfx
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
pruneMoveSfx()
-- an unrankable def (file asset, or another engine's bank) has no
-- comparable sound id: leave it to the mixer, as before
if type(def) ~= "table" or not def.address or def.engine ~= cur.engine then
return true
end
if type(def) ~= "table" or not def.address then return true end
local channels = require("src.core.ChipSynth").effectChannels(data, def)
if not channelsOverlap(channels, cur.channels) then return true end
if def.address > cur.rank then return false end
pcall(cur.src.stop, cur.src)
lastMoveSfx = nil
return true
if not channels then return true end
local takeover
for _, ch in ipairs(channels) do
local cur = moveSfxChannels[ch]
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
local function noteMoveSfx(data, def, src)
if not src or type(def) ~= "table" or not def.address then
lastMoveSfx = nil
moveSfxChannels = {}
return
end
lastMoveSfx = {
src = src, rank = def.address, engine = def.engine,
channels = require("src.core.ChipSynth").effectChannels(data, def),
}
local channels = require("src.core.ChipSynth").effectChannels(data, def)
if not channels then
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
function Sound.playMove(data, anim)
@@ -466,13 +526,20 @@ function Sound.playMove(data, anim)
local name = anim.sound
local pitch, tempo = anim.pitch or 0, anim.tempo or 0x80
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
-- a chip program synthesizes the modified variant on demand; a file def
-- can only reach for a pre-rendered one
if isChipDef(def) then
src = playPath(data, ("%s@%02x%02x"):format(name, pitch, tempo),
def, pitch, tempo)
local plain = 0
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
local key = ("%s@%02x%02x"):format(name, pitch, tempo)
if (pitch ~= 0 or tempo ~= 0x80) and sfx[key] then
@@ -481,6 +548,12 @@ function Sound.playMove(data, anim)
src = playPath(data, name, def)
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
played("move", name)
noteMoveSfx(data, def, src)
@@ -711,7 +784,7 @@ end
-- 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
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.
curSfx = nil
sfxIds = nil
+90 -4
View File
@@ -142,7 +142,11 @@ function TouchSkin.parse(text)
controls = {},
}
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)
and PORTRAIT_ASPECT or DEFAULT_ASPECT
end
@@ -170,6 +174,12 @@ function TouchSkin.parse(text)
pages[#pages + 1] = page
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 }
end
@@ -255,6 +265,9 @@ function TouchSkin.parseNative(text)
rangeMod = num(raw.rangeMod, 1),
alphaMod = num(raw.alphaMod, 1),
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 },
controls = {},
}
@@ -287,6 +300,7 @@ function TouchSkin.parseNative(text)
nextTarget = c.nextTarget,
}
end
if not page.orient then page.orient = TouchSkin.pageOrient(page) end
pages[#pages + 1] = page
end
return { pages = pages, name = data.name, author = data.author,
@@ -309,6 +323,9 @@ function TouchSkin.toNative(skin)
rangeMod = page.rangeMod,
alphaMod = page.alphaMod,
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 = {},
}
if page.rect and (page.rect.x ~= 0 or page.rect.y ~= 0
@@ -704,6 +721,10 @@ end
TouchSkin.active = nil
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
@@ -732,9 +753,68 @@ function TouchSkin.select(id)
return TouchSkin.setActive(skin)
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()
local skin = TouchSkin.active
if not skin then return nil end
local w, h = displaySize()
TouchSkin.syncOrientation(w, h)
return skin.pages[TouchSkin.pageIndex] or skin.pages[1]
end
@@ -766,7 +846,12 @@ function TouchSkin.pageBox(page, w, h, ox, oy)
ox, oy = ox or 0, oy or 0
if not page then return ox, oy, w, h end
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
if displayAspect > page.aspect then
bw = h * page.aspect
@@ -833,8 +918,9 @@ function TouchSkin.viewport(w, h, ox, oy)
local page = TouchSkin.page()
if not page or not page.viewport or not TouchSkin.drawable() then return nil end
local v = page.viewport
local x, y = (ox or 0) + v.x * w, (oy or 0) + v.y * h
local vw, vh = v.w * w, v.h * h
local bx, by, bw, bh = TouchSkin.pageBox(page, w, h, ox, oy)
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
return x, y, vw, vh, page.viewportFill == true, page.viewportExpand == true
end
+19 -7
View File
@@ -220,6 +220,9 @@ function SwitchDiagnostics.probeAssets(version)
"assets/generated/tilesets/reds_house.png",
"assets/generated/sprites/red.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
local versioned = prefix ~= "" and (prefix .. path) or path
@@ -230,17 +233,26 @@ function SwitchDiagnostics.probeAssets(version)
lines[#lines + 1] = "versioned=" .. probeInfo(filesystem, versioned)
end
lines[#lines + 1] = "resolve=" .. tostring(resolved)
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)
if path:sub(-4) == ".lua" then
local CacheFs = require("src.import.CacheFs")
local loaded, err = CacheFs.loadActive(path)
lines[#lines + 1] = "loadActive=" .. (loaded ~= nil and "ok"
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
-- Shallow listing so we can see if the extract tree exists at all.
local roots = { "yellow", "blue", "assets", "yellow/assets/generated",
"yellow/assets/generated/sprites", "blue/assets/generated/sprites" }
local roots = { "yellow", "blue", "gold", "assets", "yellow/assets/generated",
"yellow/assets/generated/sprites", "blue/assets/generated/sprites",
"gold/assets/generated", "gold/assets/generated/sprites",
"gold/data/generated" }
for _, dir in ipairs(roots) do
local info = filesystem.getInfo(dir)
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
-- 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
-- through here -- CacheFs.mountVersion overlays the active version's subtree
-- onto the un-prefixed paths instead.
@@ -305,7 +305,7 @@ function CacheFs.read(rel)
end
-- 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
-- `rel` under the caller's CacheFs.prefix. Returns the bytes or nil.
function CacheFs.readActive(rel)
@@ -322,6 +322,32 @@ function CacheFs.readActive(rel)
return nil
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?
function CacheFs.exists(rel)
rel = withPrefix(rel)
+26 -28
View File
@@ -192,20 +192,28 @@ local PAL = {
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,
-- otherwise the save directory through love.filesystem. It honors
-- CacheFs.prefix, so we point it at the version's cache subtree (red/,
-- blue/, yellow/).
-- blue/, yellow/, gold/).
local function allRequiredFilesExist(version)
local CacheFs = require("src.import.CacheFs")
local saved = CacheFs.prefix
CacheFs.prefix = GameVersion.cachePrefix(version)
local ok = true
local required = VERSION_REQUIRED_FILES_OVERRIDE[version] or REQUIRED_FILES
local required, isOverride = requiredFilesFor(version)
for _, path in ipairs(required) do
if not CacheFs.exists(path) then ok = false; break 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
if not CacheFs.exists(path) then ok = false; break end
end
@@ -215,20 +223,23 @@ local function allRequiredFilesExist(version)
end
-- A developer checkout / Python build leaves generated data in the physfs
-- source: Red at the historical root, Blue/Yellow in their versioned trees.
-- Imported Red caches still live under red/. Check source paths directly so
-- that cache prefix cannot hide Red's source tree, and keep save-dir caches
-- from counting as current source data.
-- source: Red at the historical root, Blue/Yellow/Gold in their versioned
-- trees. Imported Red caches still live under red/. Check source paths
-- directly so that cache prefix cannot hide Red's source tree, and keep
-- save-dir caches from counting as current source data.
local function sourceTreeHasData(version)
if not love.filesystem.getRealDirectory then return false end
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
end
for _, path in ipairs(VERSION_REQUIRED_FILES[version] or {}) do
if love.filesystem.getInfo(prefix .. path, "file") == nil then return false end
if not isOverride then
for _, path in ipairs(VERSION_REQUIRED_FILES[version] or {}) do
if love.filesystem.getInfo(prefix .. path, "file") == nil then return false end
end
end
local path = prefix .. REQUIRED_FILES[1]
local path = prefix .. required[1]
local real = love.filesystem.getRealDirectory(path)
return real == love.filesystem.getSource()
end
@@ -285,7 +296,7 @@ local function purgeSaveDirCache()
return true
end
-- 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
local prefix = GameVersion.cachePrefix(version)
if saveDirHas(prefix .. MARKER_PATH) or saveDirHas(prefix .. REQUIRED_FILES[1]) then
@@ -4324,25 +4335,12 @@ function RomImporter:_findRows()
category = self.findCategory,
})
if self.modScope then
local ModTargets = require("src.mods.ModTargets")
local gen = GameVersion.generation(self.modScope)
local kept = {}
for _, entry in ipairs(rows) do
local has1, has2 = false, false
local function note(s)
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
local versions = ModTargets.normalize(entry.games)
if #versions == 0 or ModTargets.covers(versions, gen) then
kept[#kept + 1] = entry
end
end
+1 -1
View File
@@ -141,7 +141,7 @@ function SaveFileIO.exportActiveSlot(version)
fs.createDirectory("exports")
fs.createDirectory("exports/" .. version)
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 ok, writeErr = fs.write(rel, bytes)
if not ok then return false, "could not write the export: " .. tostring(writeErr) end
+1
View File
@@ -160,6 +160,7 @@ local function parseEntry(raw)
summary = str(raw.summary) or "",
categories = strArray(raw.categories),
tags = strArray(raw.tags),
games = strArray(raw.games),
license = str(raw.license),
repo = str(raw.repo),
github = str(raw.github),
+2 -1
View File
@@ -166,7 +166,8 @@ function EvolutionState:draw()
if sprite then
local x = math.floor((160 - sprite:getWidth()) / 2)
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
require("src.render.PaletteFX").markTrueColor(x, y, sprite:getDimensions())
end
+127 -9
View File
@@ -20,6 +20,17 @@ Studio.CANVASES = {
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 HANDLES = {
{ "nw", 0, 0 }, { "n", 0.5, 0 }, { "ne", 1, 0 },
@@ -64,9 +75,91 @@ local function syncActive()
TouchSkin.pageIndex = Studio.pageIndex
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
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 page = Studio.page()
if page and canvas.lockViewport then
@@ -77,7 +170,11 @@ function Studio.setCanvas(index)
page.viewportFill = false
markDirty()
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
function Studio.load(opts)
@@ -102,6 +199,10 @@ function Studio.load(opts)
TouchControls.active = true
TouchControls.enabled = 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
if not start then
@@ -130,6 +231,7 @@ function Studio.open(id)
Studio.dirty = false
Studio.images = TouchSkin.listImages(Studio.skin.root)
syncActive()
Studio.applyImportedOrient()
return true
end
@@ -137,6 +239,7 @@ function Studio.unload()
Studio.pendingPlay = false
TouchSkin.setSurface(nil)
TouchSkin.setActive(nil)
TouchSkin.autoOrient = true
TouchControls:setPreview(false)
TouchControls:reset()
Studio.skin = nil
@@ -358,6 +461,7 @@ function Studio.addPage()
Studio.pageIndex = #skin.pages
Studio.selected = nil
syncActive()
Studio.syncCanvasToPage()
markDirty()
end
@@ -432,7 +536,8 @@ end
local function viewportRect(page, r)
local v = page.viewport
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
local function handleRects(bx, by, bw, bh)
@@ -540,17 +645,19 @@ function Studio.updateDrag(mx, my, r)
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
local ctl = Studio.selectedControl()
if not ctl then return end
ctl.x = clamp01(((bx + bw * 0.5) - r.x) / r.w)
ctl.y = clamp01(((by + bh * 0.5) - r.y) / r.h)
ctl.rangeX = math.max(0.002, (bw * 0.5) / r.w)
ctl.rangeY = math.max(0.002, (bh * 0.5) / r.h)
ctl.x = clamp01(((bx + bw * 0.5) - px) / pw)
ctl.y = clamp01(((by + bh * 0.5) - py) / ph)
ctl.rangeX = math.max(0.002, (bw * 0.5) / pw)
ctl.rangeY = math.max(0.002, (bh * 0.5) / ph)
else
page.viewport = {
x = clamp01((bx - r.x) / r.w), y = clamp01((by - r.y) / r.h),
w = math.max(0.02, bw / r.w), h = math.max(0.02, bh / r.h),
x = clamp01((bx - px) / pw), y = clamp01((by - py) / ph),
w = math.max(0.02, bw / pw), h = math.max(0.02, bh / ph),
}
end
markDirty()
@@ -665,11 +772,22 @@ local function inspectorBody(x, y, w)
Studio.pageIndex = (Studio.pageIndex % #Studio.skin.pages) + 1
Studio.selected = nil
syncActive()
Studio.syncCanvasToPage()
end
if Kit.button(x + half + gap, cy, half, rowH, "Add page", { id = "pageadd" }) then
Studio.addPage()
end
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
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
-- hWY $50, so it sits in the bottom half of the screen
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
require("src.render.PaletteFX").markTrueColor(
56 - self.scx, 16, self.sentSprite:getDimensions())
@@ -501,7 +502,8 @@ function TradeAnim:draw()
elseif p == "show_enemy" 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
require("src.render.PaletteFX").markTrueColor(
56, 16, self.recvSprite:getDimensions())
+4 -5
View File
@@ -396,11 +396,10 @@ local function givePokeMon(data, speciesIndex, level, itemIndex)
end
local function loadGenerated(path)
local chunk, err = love.filesystem.load(path)
if not chunk then return nil, err end
local ok, value = pcall(chunk)
if not ok then return nil, value end
return value
-- Same NX gold/ fallback Game2 uses. World:load is what surfaces
-- "Gold cache incomplete" when maps.lua is invisible at the unprefixed path.
local CacheFs = require("src.import.CacheFs")
return CacheFs.loadActive(path)
end
-- Paste the 9-tile roof sheet over atlas tiles $0a-$12.
@@ -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
+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",
"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")
eq(love.filesystem.read(PNG), "root-png-bytes",
"overlay prefers the real un-prefixed file over the versioned copy")
eq(love.filesystem.read(PNG), "yellow-png-bytes",
"overlay prefers the versioned copy over leftover unprefixed")
clearPath(PNG)
-- Blue gets the same treatment
@@ -110,7 +111,29 @@ clearPath("yellow/" .. PNG)
eq(love.filesystem.read(PNG), "blue-png-bytes",
"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")
clearPath("blue/" .. PNG)
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",
"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")
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")
check(CacheFs.readActive("data/generated/audio.lua") == nil,
"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()
+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,
"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()
@@ -59,7 +59,7 @@ package.loaded["src.import.RomImporter"] = nil
RomImporter = require("src.import.RomImporter")
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
for _, name in ipairs(love.filesystem.getDirectoryItems(dir) or {}) do
love.filesystem.remove(dir .. "/" .. name)
@@ -135,6 +135,8 @@ check(createdDirs["imports/saves/blue"] == true,
"RES-01: ensureSavesInboxDir creates imports/saves/blue/")
check(createdDirs["imports/saves/yellow"] == true,
"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
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[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
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")
check(not probe:find(string.char(0xEA, 0x9B), 1, true),
"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 }
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/tilesets/reds_house.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()
+104 -1
View File
@@ -51,6 +51,7 @@ local page = skin.pages[1]
eq(page.name, "shell", "page name")
eq(page.imagePath, "img/back.png", "page background path")
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.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(sw, 400, "viewport w") eq(sh, 400, "viewport h")
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(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
check(named[btn], "gb_anim binds GB " .. btn)
end
check(not TouchSkin.hasOrientPair(bundled),
"gb_anim is not a portrait/landscape auto-rotate overlay")
end
local tv = TouchSkin.load("assets/skins/tv_crt", "tv_crt")
@@ -322,4 +325,104 @@ end
TouchSkin.setActive(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")
+5 -1
View File
@@ -26,7 +26,7 @@ end
-- Also gates the NX runtime modules and the NX engine suites so an NX
-- runtime regression cannot slip past switch-selftest / switch-build.
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 release = read(".github/workflows/release.yml")
@@ -50,6 +50,7 @@ for _, fragment in ipairs({
"nx_generated_guard",
"nx_yellow_boot",
"switch_diagnostics",
"cache_fs_gold_nx_load",
"tests/engine/platform_nx",
}) do
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/nx_generated_guard_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")
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/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/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_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/nx_generated_guard_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_*",
}) do
+4
View File
@@ -30,6 +30,7 @@ mustContain(transfer, "FTP", "transfer")
mustContain(transfer, "sdmc:/switch/gen1recomp/", "transfer")
mustContain(transfer, "imports/", "transfer")
mustContain(transfer, "imports/mods/", "transfer")
mustContain(transfer, "gold", "transfer")
mustContain(transfer, "1: SD Card", "transfer")
mustContain(transfer, "Scan again", "transfer")
mustContain(transfer, "documented example", "transfer")
@@ -70,6 +71,9 @@ mustContain(install, "PERFORMANCE", "install")
mustContain(install, "Stock engine effect", "install")
mustContain(install, "## Limitations", "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, "switch-development", "install")
mustContain(build, "switch-transfer.md", "build")