From cfa8406306d8c3e06339a4f7a6ed9ac1269a5214 Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Tue, 18 Aug 2026 04:52:00 -0400 Subject: [PATCH 1/5] CLOSES #1508 --- src/core/ChipAudio.lua | 3 +- src/core/ChipSynth.lua | 18 ++- src/core/Sound.lua | 143 +++++++++++++----- src/ui/EvolutionState.lua | 3 +- src/ui/TradeAnim.lua | 6 +- tests/drivers/evolution_flip_bug1412_test.lua | 92 +++++++++++ tests/drivers/leer_sound_bug1414_test.lua | 94 ++++++++++++ 7 files changed, 313 insertions(+), 46 deletions(-) create mode 100644 tests/drivers/evolution_flip_bug1412_test.lua create mode 100644 tests/drivers/leer_sound_bug1414_test.lua diff --git a/src/core/ChipAudio.lua b/src/core/ChipAudio.lua index 1a57d155..2b16e6b6 100644 --- a/src/core/ChipAudio.lua +++ b/src/core/ChipAudio.lua @@ -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 diff --git a/src/core/ChipSynth.lua b/src/core/ChipSynth.lua index e21500e5..f870ddb5 100644 --- a/src/core/ChipSynth.lua +++ b/src/core/ChipSynth.lua @@ -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 diff --git a/src/core/Sound.lua b/src/core/Sound.lua index 08888cf4..26433019 100644 --- a/src/core/Sound.lua +++ b/src/core/Sound.lua @@ -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,10 @@ 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 pcall(old.stop, old) end + end if src then played("move", name) noteMoveSfx(data, def, src) @@ -711,7 +782,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 diff --git a/src/ui/EvolutionState.lua b/src/ui/EvolutionState.lua index d704a3e7..972ec407 100644 --- a/src/ui/EvolutionState.lua +++ b/src/ui/EvolutionState.lua @@ -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 diff --git a/src/ui/TradeAnim.lua b/src/ui/TradeAnim.lua index 791f7ebe..bb693588 100644 --- a/src/ui/TradeAnim.lua +++ b/src/ui/TradeAnim.lua @@ -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()) diff --git a/tests/drivers/evolution_flip_bug1412_test.lua b/tests/drivers/evolution_flip_bug1412_test.lua new file mode 100644 index 00000000..7d49a6cd --- /dev/null +++ b/tests/drivers/evolution_flip_bug1412_test.lua @@ -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 diff --git a/tests/drivers/leer_sound_bug1414_test.lua b/tests/drivers/leer_sound_bug1414_test.lua new file mode 100644 index 00000000..df046a92 --- /dev/null +++ b/tests/drivers/leer_sound_bug1414_test.lua @@ -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 From 675971068e5e129eaf99db74a7ebb71492a3d834 Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Tue, 18 Aug 2026 09:16:04 -0400 Subject: [PATCH 2/5] CLOSES #1503 --- docs/skin-studio.md | 11 ++- src/core/TouchSkin.lua | 94 ++++++++++++++++++++- src/import/RomImporter.lua | 19 +---- src/mods/ModIndex.lua | 1 + src/ui/SkinStudio.lua | 136 ++++++++++++++++++++++++++++-- tests/engine/skin_studio_test.lua | 49 +++++++++++ tests/engine/touch_skin_test.lua | 105 ++++++++++++++++++++++- 7 files changed, 384 insertions(+), 31 deletions(-) diff --git a/docs/skin-studio.md b/docs/skin-studio.md index 27218336..068ae316 100644 --- a/docs/skin-studio.md +++ b/docs/skin-studio.md @@ -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, diff --git a/src/core/TouchSkin.lua b/src/core/TouchSkin.lua index db1db502..64eddfae 100644 --- a/src/core/TouchSkin.lua +++ b/src/core/TouchSkin.lua @@ -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 diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index 81b15feb..19260433 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -4324,25 +4324,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 diff --git a/src/mods/ModIndex.lua b/src/mods/ModIndex.lua index f170d920..2cd39f06 100644 --- a/src/mods/ModIndex.lua +++ b/src/mods/ModIndex.lua @@ -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), diff --git a/src/ui/SkinStudio.lua b/src/ui/SkinStudio.lua index 936ada14..4233906f 100644 --- a/src/ui/SkinStudio.lua +++ b/src/ui/SkinStudio.lua @@ -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)" diff --git a/tests/engine/skin_studio_test.lua b/tests/engine/skin_studio_test.lua index 3fd66f18..ce97a6f1 100644 --- a/tests/engine/skin_studio_test.lua +++ b/tests/engine/skin_studio_test.lua @@ -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") diff --git a/tests/engine/touch_skin_test.lua b/tests/engine/touch_skin_test.lua index c0223638..7d43e4c0 100644 --- a/tests/engine/touch_skin_test.lua +++ b/tests/engine/touch_skin_test.lua @@ -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") From 55616fc03d70f09e0b566895321ea69899ace15f Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Tue, 18 Aug 2026 09:20:07 -0400 Subject: [PATCH 3/5] CLOSES #1390 --- .github/workflows/ci.yml | 5 +- docs/switch-build.md | 4 +- scripts/test.sh | 1 + src/core/Game2.lua | 10 ++-- src/core/NxAssetOverlay.lua | 34 +++++++---- src/debug/SwitchDiagnostics.lua | 26 ++++++--- src/import/CacheFs.lua | 28 ++++++++- src/world/gen2/World.lua | 9 ++- tests/engine/assets_version_fallback_test.lua | 12 ++++ tests/engine/cache_fs_blue_mount_test.lua | 11 ++++ tests/engine/cache_fs_gold_nx_load_test.lua | 57 +++++++++++++++++++ tests/engine/cache_fs_headless_test.lua | 3 + tests/engine/switch_diagnostics_test.lua | 19 +++++++ tests/switch_ci_workflows_test.lua | 6 +- 14 files changed, 194 insertions(+), 31 deletions(-) create mode 100644 tests/engine/cache_fs_gold_nx_load_test.lua diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0db3b785..3bb5b18d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/docs/switch-build.md b/docs/switch-build.md index 61f8a574..f84a4d11 100644 --- a/docs/switch-build.md +++ b/docs/switch-build.md @@ -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 diff --git a/scripts/test.sh b/scripts/test.sh index cec1143c..b41b683d 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -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 diff --git a/src/core/Game2.lua b/src/core/Game2.lua index e1cfd40d..bddb3291 100644 --- a/src/core/Game2.lua +++ b/src/core/Game2.lua @@ -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 diff --git a/src/core/NxAssetOverlay.lua b/src/core/NxAssetOverlay.lua index f14a593e..e2e2ef92 100644 --- a/src/core/NxAssetOverlay.lua +++ b/src/core/NxAssetOverlay.lua @@ -1,10 +1,10 @@ -- 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/ +-- that does not resolve falls back to the active version's prefixed copy +-- (yellow|blue|gold/... ). 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,24 +18,38 @@ -- * 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 +-- and the active version (Blue/Yellow/Gold) carries it. Returns nil when the +-- caller's path should be used untouched (non-generated path, the real -- file exists, 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 diff --git a/src/debug/SwitchDiagnostics.lua b/src/debug/SwitchDiagnostics.lua index e40cb6fc..8d60a147 100644 --- a/src/debug/SwitchDiagnostics.lua +++ b/src/debug/SwitchDiagnostics.lua @@ -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 diff --git a/src/import/CacheFs.lua b/src/import/CacheFs.lua index 9284e394..ab0a77de 100644 --- a/src/import/CacheFs.lua +++ b/src/import/CacheFs.lua @@ -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) diff --git a/src/world/gen2/World.lua b/src/world/gen2/World.lua index 911ac65b..05e0cadf 100644 --- a/src/world/gen2/World.lua +++ b/src/world/gen2/World.lua @@ -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. diff --git a/tests/engine/assets_version_fallback_test.lua b/tests/engine/assets_version_fallback_test.lua index e891d612..4eb0b7a2 100644 --- a/tests/engine/assets_version_fallback_test.lua +++ b/tests/engine/assets_version_fallback_test.lua @@ -110,6 +110,18 @@ clearPath("yellow/" .. PNG) eq(love.filesystem.read(PNG), "blue-png-bytes", "overlay maps generated reads to blue/ for Blue") +-- 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. +GameVersion.set("gold") +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 prefix: nothing is rewritten GameVersion.set("red") clearPath("blue/" .. PNG) diff --git a/tests/engine/cache_fs_blue_mount_test.lua b/tests/engine/cache_fs_blue_mount_test.lua index f0a81e8a..f6b24e9b 100644 --- a/tests/engine/cache_fs_blue_mount_test.lua +++ b/tests/engine/cache_fs_blue_mount_test.lua @@ -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() diff --git a/tests/engine/cache_fs_gold_nx_load_test.lua b/tests/engine/cache_fs_gold_nx_load_test.lua new file mode 100644 index 00000000..dd7d636a --- /dev/null +++ b/tests/engine/cache_fs_gold_nx_load_test.lua @@ -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() diff --git a/tests/engine/cache_fs_headless_test.lua b/tests/engine/cache_fs_headless_test.lua index 286599c4..77d4da6e 100644 --- a/tests/engine/cache_fs_headless_test.lua +++ b/tests/engine/cache_fs_headless_test.lua @@ -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() diff --git a/tests/engine/switch_diagnostics_test.lua b/tests/engine/switch_diagnostics_test.lua index 8c781f6e..2be22cb2 100644 --- a/tests/engine/switch_diagnostics_test.lua +++ b/tests/engine/switch_diagnostics_test.lua @@ -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() diff --git a/tests/switch_ci_workflows_test.lua b/tests/switch_ci_workflows_test.lua index 5ae2d693..9cf381be 100644 --- a/tests/switch_ci_workflows_test.lua +++ b/tests/switch_ci_workflows_test.lua @@ -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 From 824020525451643a3bb06298e5635a0cd4c6fd68 Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Tue, 18 Aug 2026 09:51:22 -0400 Subject: [PATCH 4/5] ingested --- docs/switch-install.md | 22 +++++++----- docs/switch-transfer.md | 8 ++--- scripts/switch/pack_sd_zip.sh | 18 ++++++---- scripts/switch/selftest_build_switch.sh | 4 ++- src/core/NxAssetOverlay.lua | 17 +++++---- src/import/CacheFs.lua | 2 +- src/import/RomImporter.lua | 35 ++++++++++++------- src/import/SaveFileIO.lua | 2 +- tests/engine/assets_version_fallback_test.lua | 21 ++++++++--- tests/engine/nx_generated_guard_test.lua | 17 +++++++++ .../rom_importer_nx_saves_inbox_test.lua | 4 ++- .../engine/rom_importer_source_tree_test.lua | 30 ++++++++++++++++ tests/switch_transfer_docs_test.lua | 4 +++ 13 files changed, 137 insertions(+), 47 deletions(-) create mode 100644 tests/engine/rom_importer_source_tree_test.lua diff --git a/docs/switch-install.md b/docs/switch-install.md index 2a54aa06..2567cf2c 100644 --- a/docs/switch-install.md +++ b/docs/switch-install.md @@ -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. diff --git a/docs/switch-transfer.md b/docs/switch-transfer.md index 1e53de2f..1c027086 100644 --- a/docs/switch-transfer.md +++ b/docs/switch-transfer.md @@ -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//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//`, or `exports//` path the - launcher prints. + `imports/saves//`, or `exports//` + path the launcher prints. 5. Wait for the queue; refresh; exit MTP responder; title-override launch. macOS clients often create AppleDouble sidecars (`._Something.zip`, diff --git a/scripts/switch/pack_sd_zip.sh b/scripts/switch/pack_sd_zip.sh index 6e7d7a7f..eb9d4b53 100755 --- a/scripts/switch/pack_sd_zip.sh +++ b/scripts/switch/pack_sd_zip.sh @@ -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" diff --git a/scripts/switch/selftest_build_switch.sh b/scripts/switch/selftest_build_switch.sh index 99928faf..9554cdd4 100755 --- a/scripts/switch/selftest_build_switch.sh +++ b/scripts/switch/selftest_build_switch.sh @@ -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 diff --git a/src/core/NxAssetOverlay.lua b/src/core/NxAssetOverlay.lua index e2e2ef92..8d900ca1 100644 --- a/src/core/NxAssetOverlay.lua +++ b/src/core/NxAssetOverlay.lua @@ -3,8 +3,9 @@ -- 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/ or data/generated/ --- that does not resolve falls back to the active version's prefixed copy --- (yellow|blue|gold/... ). Covering the whole read surface -- +-- 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. -- @@ -35,10 +36,13 @@ 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/Gold) carries it. Returns nil when the --- caller's path should be used untouched (non-generated path, 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 local generated = false @@ -52,7 +56,6 @@ local function versioned(path) 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 diff --git a/src/import/CacheFs.lua b/src/import/CacheFs.lua index ab0a77de..a3bfd889 100644 --- a/src/import/CacheFs.lua +++ b/src/import/CacheFs.lua @@ -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. diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index 19260433..05a7643a 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -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 diff --git a/src/import/SaveFileIO.lua b/src/import/SaveFileIO.lua index c9a149f5..0a36fa1a 100644 --- a/src/import/SaveFileIO.lua +++ b/src/import/SaveFileIO.lua @@ -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 diff --git a/tests/engine/assets_version_fallback_test.lua b/tests/engine/assets_version_fallback_test.lua index 4eb0b7a2..d8a286cf 100644 --- a/tests/engine/assets_version_fallback_test.lua +++ b/tests/engine/assets_version_fallback_test.lua @@ -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,9 +111,18 @@ clearPath("yellow/" .. PNG) eq(love.filesystem.read(PNG), "blue-png-bytes", "overlay maps generated reads to blue/ for Blue") +-- 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. -GameVersion.set("gold") local MAPS = "data/generated/maps.lua" love.filesystem.write("gold/" .. MAPS, "return { NEW_BARK_TOWN = true }") local mapsChunk = love.filesystem.load(MAPS) @@ -122,7 +132,8 @@ eq(love.filesystem.read(MAPS), "return { NEW_BARK_TOWN = true }", "wrapped filesystem.read returns gold maps.lua bytes") clearPath("gold/" .. MAPS) --- Red has no prefix: nothing is rewritten +-- 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") diff --git a/tests/engine/nx_generated_guard_test.lua b/tests/engine/nx_generated_guard_test.lua index 2780e395..6b5d0225 100644 --- a/tests/engine/nx_generated_guard_test.lua +++ b/tests/engine/nx_generated_guard_test.lua @@ -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() diff --git a/tests/engine/rom_importer_nx_saves_inbox_test.lua b/tests/engine/rom_importer_nx_saves_inbox_test.lua index 9a911531..5bdba458 100644 --- a/tests/engine/rom_importer_nx_saves_inbox_test.lua +++ b/tests/engine/rom_importer_nx_saves_inbox_test.lua @@ -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// MTP path ri = freshImporter() diff --git a/tests/engine/rom_importer_source_tree_test.lua b/tests/engine/rom_importer_source_tree_test.lua new file mode 100644 index 00000000..8a66d846 --- /dev/null +++ b/tests/engine/rom_importer_source_tree_test.lua @@ -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() diff --git a/tests/switch_transfer_docs_test.lua b/tests/switch_transfer_docs_test.lua index 808f76a3..6dbe1198 100644 --- a/tests/switch_transfer_docs_test.lua +++ b/tests/switch_transfer_docs_test.lua @@ -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") From 25ec896545ff282e009f2c06fe24c72bf8ea217c Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Tue, 18 Aug 2026 10:14:44 -0400 Subject: [PATCH 5/5] 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. --- src/core/Sound.lua | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/core/Sound.lua b/src/core/Sound.lua index 26433019..5e0d5827 100644 --- a/src/core/Sound.lua +++ b/src/core/Sound.lua @@ -550,7 +550,9 @@ function Sound.playMove(data, anim) end -- audio/engine_2.asm:1537 if superseded then - for old in pairs(superseded) do pcall(old.stop, old) end + for old in pairs(superseded) do + if old ~= src then pcall(old.stop, old) end + end end if src then played("move", name)