Merge pull request #1523 from bryanthaboi/dev

some bugs and some switch stuff
This commit is contained in:
bryanthaboi
2026-08-18 10:18:58 -04:00
committed by GitHub
37 changed files with 1025 additions and 150 deletions
+2 -1
View File
@@ -424,11 +424,12 @@ local function renderEffect(data, header, options)
return love.audio.newSource(sd, "static")
end
function ChipAudio.newSfx(data, name, pitch, tempo, header)
function ChipAudio.newSfx(data, name, pitch, tempo, header, plainFrames)
header = header or data.audio.sfx[name]
return renderEffect(data, header, {
frequencyOffset = pitch or 0,
frameTicks = 0x80 + (tempo or 0x80),
plainFrames = plainFrames,
})
end
+12 -6
View File
@@ -280,6 +280,7 @@ function Channel.new(engine, spec, options)
allowLoops = options.allowLoops ~= false,
frequencyOffset = options.frequencyOffset or 0,
frameTicks = options.frameTicks or FRAME_TICKS,
plainTicks = (options.plainFrames or 0) * FRAME_TICKS,
speed = 12,
noteLength = 1, -- Gen 2 CHANNEL_NOTE_LENGTH (note_type)
durationModifier = 0, -- Gen 2 fractional-frame carry
@@ -349,8 +350,9 @@ function Channel:frequencyGen2(note, octave)
return bit.band(register + self.frequencyOffset, 0x7FF)
end
function Channel:durationTicks(length)
local tempo = self.sfx and self.frameTicks or self.engine.tempo
function Channel:durationTicks(length, plain)
local tempo = self.sfx and (plain and FRAME_TICKS or self.frameTicks)
or self.engine.tempo
local speed = self.sfx and (self.executeMusic and self.speed or 1)
or self.speed
return length * speed * tempo
@@ -586,6 +588,9 @@ function Channel:nextEvent()
local packed = self:byte()
local volume = bit.rshift(packed, 4)
local fade = fadeValue(bit.band(packed, 0x0F))
-- audio/engine_2.asm:991-1013, :1015-1033, :1077-1096
local plain = self.timeTicks < self.plainTicks
local offset = plain and 0 or self.frequencyOffset
if self.noise then
-- Audio2_ApplyWavePatternAndFrequency adds wFrequencyModifier to the
-- frequency low byte for every channel at or past CHAN5, the noise
@@ -595,12 +600,12 @@ function Channel:nextEvent()
-- byte that noise does not use for frequency. Dropping it left the
-- battle hit sounds at their unmodified pitches, where super effective
-- reads as the duller of the two (#826).
local parameter = bit.band(self:byte() + self.frequencyOffset, 0xFF)
local parameter = bit.band(self:byte() + offset, 0xFF)
return self:noiseEvent(
self:durationTicks(length), volume, fade, parameter)
self:durationTicks(length, plain), volume, fade, parameter)
end
local register = bit.band(self:word() + self.frequencyOffset, 0x7FF)
return self:tone(self:durationTicks(length), register, volume, fade)
local register = bit.band(self:word() + offset, 0x7FF)
return self:tone(self:durationTicks(length, plain), register, volume, fade)
elseif command == 0x10 then
local packed = self:byte()
self.sweep = {
@@ -1260,6 +1265,7 @@ function Engine.new(data, header, options)
allowLoops = options.allowLoops,
frequencyOffset = options.frequencyOffset,
frameTicks = frameTicks,
plainFrames = options.plainFrames,
})
end
return engine
+5 -5
View File
@@ -120,11 +120,11 @@ local function visibleBaseState(stack)
end
local function loadGenerated(path)
local chunk = love.filesystem.load(path)
if not chunk then return nil end
local ok, data = pcall(chunk)
if ok then return data end
return nil
-- CacheFs.loadActive, not love.filesystem.load: Gold's cache lives under
-- gold/ and fused NX often cannot mount that tree onto data/generated/.
local CacheFs = require("src.import.CacheFs")
local data = CacheFs.loadActive(path)
return data
end
-- NewGame (engine/menus/intro_menu.asm) calls OakSpeech, and OakSpeech's first
+30 -13
View File
@@ -1,10 +1,11 @@
-- NX-only asset overlay: fused love-nx cannot reliably mount
-- blue|yellow/assets/generated onto the un-prefixed assets/generated, so
-- blue|yellow|gold/{assets,data}/generated onto the un-prefixed paths, so
-- instead of teaching every call site about versioned caches, this module
-- wraps EVERY read-side love entry point that accepts a filesystem path
-- once at boot: any string path under assets/generated/ that does not
-- resolve falls back to the active version's prefixed copy
-- (yellow|blue/assets/generated/...). Covering the whole read surface --
-- once at boot: any string path under assets/generated/ or data/generated/
-- prefers the active version's prefixed copy (yellow|blue|gold/...) when
-- that file exists, so leftover unprefixed Red cache cannot shadow it.
-- Covering the whole read surface --
-- not just the loaders we happened to need -- is what keeps future states
-- and mods inside the fallback without anyone updating this file.
--
@@ -18,27 +19,43 @@
-- * the chip-audio worker (src/core/chip_worker.lua) is a separate Lua
-- state without these wrappers; ChipAudio.slimAudio hands it the prefix
-- explicitly as audio.programPrefix.
-- * data/generated module loads go through CacheFs.readActive, which
-- already implements the same fallback for require bytes.
-- * Gen 1 Data:load and Gold Game2/World go through CacheFs.readActive /
-- CacheFs.loadActive, which implement the same fallback for Lua bytes.
-- data/generated is still rewritten here so any leftover
-- love.filesystem.load("data/generated/...") call (the Gold intro /
-- naming / maps hole on 0.2.4) stays inside the overlay.
local GameVersion = require("src.core.GameVersion")
local GENERATED = "assets/generated/"
local GENERATED_PREFIXES = {
"assets/generated/",
"data/generated/",
}
local NxAssetOverlay = {}
local originals -- raw love functions, non-nil while installed
-- Resolve `path` to the versioned copy when the un-prefixed file is missing
-- and the active version (Blue/Yellow) carries it. Returns nil when the
-- caller's path should be used untouched (non-generated path, Red, the real
-- file exists, or no versioned copy).
-- Resolve `path` to the active version's prefixed copy when that file
-- exists (gold|yellow|blue|red/{assets,data}/generated/...). The versioned
-- tree wins over a leftover un-prefixed file so a pre-#899 Red root cache
-- (assets/generated/font.png in the save dir) cannot shadow Gold/Blue/
-- Yellow art that shares a name. Returns nil when the caller's path
-- should be used untouched (non-generated path, empty prefix, or no
-- versioned copy).
local function versioned(path)
if type(path) ~= "string" then return nil end
if path:sub(1, #GENERATED) ~= GENERATED then return nil end
local generated = false
for i = 1, #GENERATED_PREFIXES do
local gen = GENERATED_PREFIXES[i]
if path:sub(1, #gen) == gen then
generated = true
break
end
end
if not generated then return nil end
local prefix = GameVersion.cachePrefix()
if prefix == "" then return nil end
if originals.getInfo(path) then return nil end
local candidate = prefix .. path
if originals.getInfo(candidate) then return candidate end
return nil
+109 -36
View File
@@ -8,6 +8,7 @@
local Assets = require("src.render.Assets")
local Logger = require("src.core.Logger")
local Runtime = require("src.mods.Runtime")
local bit = require("bit")
local Sound = {}
@@ -142,10 +143,10 @@ local function newFileSource(def)
return s
end
local function newSfxSource(data, key, def, pitch, tempo)
local function newSfxSource(data, key, def, pitch, tempo, plain)
if isChipDef(def) then
local ok, s = pcall(require("src.core.ChipAudio").newSfx,
data, key:match("^([^@]+)") or key, pitch, tempo, def)
data, key:match("^([^@]+)") or key, pitch, tempo, def, plain)
if not ok then return nil, tostring(s) end
if not s then return nil, "no source" end
return s
@@ -153,12 +154,12 @@ local function newSfxSource(data, key, def, pitch, tempo)
return newFileSource(def)
end
local function playPath(data, key, def, pitch, tempo)
local function playPath(data, key, def, pitch, tempo, plain)
if not love.audio or not def then return nil end
local src = cache[key]
if src == false then return nil end -- known bad, already logged
if not src then
local s, err = newSfxSource(data, key, def, pitch, tempo)
local s, err = newSfxSource(data, key, def, pitch, tempo, plain)
if not s then
cache[key] = false
reportBadDef("sfx", key, owner(data, "sfx", key), err)
@@ -413,50 +414,109 @@ end
-- still sounding when the second row starts, so the original never plays
-- SFX_BATTLE_2A (CHAN5+6+8) at all -- unguarded, its tail is heard running
-- past the end of the animation (#844).
local lastMoveSfx -- { src, rank, engine, channels } of the last row sound
local moveSfxChannels = {} -- software channel (5-8) -> { src, address, engine }
local function channelsOverlap(a, b)
if not (a and b) then return false end
for _, x in ipairs(a) do
for _, y in ipairs(b) do
if x == y then return true end
end
local function sourceAlive(entry)
local ok, playing = pcall(entry.src.isPlaying, entry.src)
return ok and playing
end
local function pruneMoveSfx()
for ch, cur in pairs(moveSfxChannels) do
if not sourceAlive(cur) then moveSfxChannels[ch] = nil end
end
return false
end
-- would PlaySound start this def now? Taking a channel over also stops the
-- sound that held it, the way .playChannel resets the channel.
local function sfxChannelGate(data, def)
local cur = lastMoveSfx
if not cur then return true end
local ok, playing = pcall(cur.src.isPlaying, cur.src)
if not (ok and playing) then
lastMoveSfx = nil
return true
end
pruneMoveSfx()
-- an unrankable def (file asset, or another engine's bank) has no
-- comparable sound id: leave it to the mixer, as before
if type(def) ~= "table" or not def.address or def.engine ~= cur.engine then
return true
end
if type(def) ~= "table" or not def.address then return true end
local channels = require("src.core.ChipSynth").effectChannels(data, def)
if not channelsOverlap(channels, cur.channels) then return true end
if def.address > cur.rank then return false end
pcall(cur.src.stop, cur.src)
lastMoveSfx = nil
return true
if not channels then return true end
local takeover
for _, ch in ipairs(channels) do
local cur = moveSfxChannels[ch]
if cur and cur.engine == def.engine then
if def.address > cur.address then return false end
takeover = takeover or {}
takeover[cur.src] = true
end
end
if takeover then
for ch, cur in pairs(moveSfxChannels) do
if takeover[cur.src] then moveSfxChannels[ch] = nil end
end
end
return true, takeover
end
local function noteMoveSfx(data, def, src)
if not src or type(def) ~= "table" or not def.address then
lastMoveSfx = nil
moveSfxChannels = {}
return
end
lastMoveSfx = {
src = src, rank = def.address, engine = def.engine,
channels = require("src.core.ChipSynth").effectChannels(data, def),
}
local channels = require("src.core.ChipSynth").effectChannels(data, def)
if not channels then
moveSfxChannels = {}
return
end
local entry = { src = src, address = def.address, engine = def.engine }
for _, ch in ipairs(channels) do moveSfxChannels[ch] = entry end
end
-- constants/music_constants.asm:4
local function sfxHeaderId(def)
if type(def) ~= "table" or not def.address then return nil end
local rel = def.address - 0x4000
if rel <= 0 or rel % 3 ~= 0 then return nil end
return rel / 3
end
local function remainingFrames(src)
local ok, dur = pcall(src.getDuration, src, "seconds")
if not ok or type(dur) ~= "number" then return nil end
local pos
ok, pos = pcall(src.tell, src, "seconds")
if not ok or type(pos) ~= "number" then return nil end
return math.max(0, math.ceil((dur - pos) * 60))
end
-- audio/engine_2.asm:1077-1096, :991-1013, :1015-1033
local function plainMoveFrames(data, def, channels)
local id = sfxHeaderId(def)
local sfx = data.audio and data.audio.sfx
if not (id and sfx and channels) then return 0 end
local first = sfxHeaderId(sfx.Peck) -- constants/music_constants.asm:178
local last = sfxHeaderId(sfx.Trainer_Appeared) -- constants/music_constants.asm:228
if not (first and last) then return 0 end
local claimed = {}
for _, ch in ipairs(channels) do claimed[ch] = true end
local base = (claimed[5] or claimed[8]) and id or 0
local others = {}
for _, ch in ipairs({ 5, 8 }) do
local cur = (not claimed[ch]) and moveSfxChannels[ch] or nil
if cur and cur.engine == def.engine and sourceAlive(cur) then
local otherId = sfxHeaderId(cur)
local rem = remainingFrames(cur.src)
if otherId and rem and rem > 0 then
others[#others + 1] = { id = otherId, rem = rem }
end
end
end
table.sort(others, function(a, b) return a.rem < b.rem end)
local plain = 0
for drop = 0, #others do
local combined = base
for i = drop + 1, #others do
combined = bit.bor(combined, others[i].id)
end
if combined >= first and combined <= last then return plain end
if drop < #others then plain = others[drop + 1].rem end
end
return plain
end
function Sound.playMove(data, anim)
@@ -466,13 +526,20 @@ function Sound.playMove(data, anim)
local name = anim.sound
local pitch, tempo = anim.pitch or 0, anim.tempo or 0x80
local def = sfx[name]
if not sfxChannelGate(data, def) then return end
local allowed, superseded = sfxChannelGate(data, def)
if not allowed then return end
local src
-- a chip program synthesizes the modified variant on demand; a file def
-- can only reach for a pre-rendered one
if isChipDef(def) then
src = playPath(data, ("%s@%02x%02x"):format(name, pitch, tempo),
def, pitch, tempo)
local plain = 0
if pitch ~= 0 or tempo ~= 0x80 then
local channels = require("src.core.ChipSynth").effectChannels(data, def)
plain = plainMoveFrames(data, def, channels)
end
local key = ("%s@%02x%02x"):format(name, pitch, tempo)
if plain > 0 then key = ("%s~%d"):format(key, plain) end
src = playPath(data, key, def, pitch, tempo, plain)
else
local key = ("%s@%02x%02x"):format(name, pitch, tempo)
if (pitch ~= 0 or tempo ~= 0x80) and sfx[key] then
@@ -481,6 +548,12 @@ function Sound.playMove(data, anim)
src = playPath(data, name, def)
end
end
-- audio/engine_2.asm:1537
if superseded then
for old in pairs(superseded) do
if old ~= src then pcall(old.stop, old) end
end
end
if src then
played("move", name)
noteMoveSfx(data, def, src)
@@ -711,7 +784,7 @@ end
-- hot reload / jukebox A-B: drop one key's sources (its pitch-tempo
-- variants included) or all of them, so the next play re-resolves the def
function Sound.invalidate(name)
lastMoveSfx = nil -- its source is about to be dropped or stopped
moveSfxChannels = {} -- their sources are about to be dropped or stopped
-- Same for wCurSFX, and a reloaded table can repoint the id order.
curSfx = nil
sfxIds = nil
+90 -4
View File
@@ -142,7 +142,11 @@ function TouchSkin.parse(text)
controls = {},
}
if page.imagePath == "" then page.imagePath = nil end
if not page.aspect or page.aspect <= 0 then
-- An explicit aspect_ratio is the overlay's design aspect. RetroArch
-- letterboxes to it even when full_screen is set, so range_x/range_y
-- that were authored as a circle stay a circle. #1503
page.aspectFromCfg = page.aspect ~= nil and page.aspect > 0
if not page.aspectFromCfg then
page.aspect = page.name:lower():find("portrait", 1, true)
and PORTRAIT_ASPECT or DEFAULT_ASPECT
end
@@ -170,6 +174,12 @@ function TouchSkin.parse(text)
pages[#pages + 1] = page
end
-- RetroArch auto-rotate: overlay names containing portrait / landscape
-- are the lock. Stamp it so the studio does not need a second click. #1503
for _, page in ipairs(pages) do
if not page.orient then page.orient = TouchSkin.pageOrient(page) end
end
return { pages = pages }
end
@@ -255,6 +265,9 @@ function TouchSkin.parseNative(text)
rangeMod = num(raw.rangeMod, 1),
alphaMod = num(raw.alphaMod, 1),
aspect = num(raw.aspect, DEFAULT_ASPECT),
aspectFromCfg = raw.fitAspect == true,
orient = (raw.orient == "portrait" or raw.orient == "landscape"
or raw.orient == "any") and raw.orient or nil,
rect = { x = 0, y = 0, w = 1, h = 1 },
controls = {},
}
@@ -287,6 +300,7 @@ function TouchSkin.parseNative(text)
nextTarget = c.nextTarget,
}
end
if not page.orient then page.orient = TouchSkin.pageOrient(page) end
pages[#pages + 1] = page
end
return { pages = pages, name = data.name, author = data.author,
@@ -309,6 +323,9 @@ function TouchSkin.toNative(skin)
rangeMod = page.rangeMod,
alphaMod = page.alphaMod,
aspect = page.aspect,
fitAspect = page.aspectFromCfg or nil,
orient = (page.orient == "portrait" or page.orient == "landscape"
or page.orient == "any") and page.orient or nil,
controls = {},
}
if page.rect and (page.rect.x ~= 0 or page.rect.y ~= 0
@@ -704,6 +721,10 @@ end
TouchSkin.active = nil
TouchSkin.pageIndex = 1
-- RetroArch Auto-Rotate Overlay (1.7.9, default on mobile): a cfg whose
-- pages are named portrait / landscape is swapped to match the display.
-- The Skin Studio turns this off so PAGE and the canvas preset stay independent.
TouchSkin.autoOrient = true
TouchSkin.surfaceRect = nil
@@ -732,9 +753,68 @@ function TouchSkin.select(id)
return TouchSkin.setActive(skin)
end
local function displaySize()
local r = TouchSkin.surfaceRect
if r and r.w and r.h and r.w > 0 and r.h > 0 then return r.w, r.h end
if love and love.graphics and love.graphics.getDimensions then
return love.graphics.getDimensions()
end
return 0, 0
end
-- Explicit lock (studio) wins; otherwise the page name, the RetroArch
-- auto-rotate convention. "any" means unlocked even if the name says
-- portrait or landscape.
function TouchSkin.pageOrient(page)
if not page then return nil end
if page.orient == "any" then return nil end
if page.orient == "portrait" or page.orient == "landscape" then
return page.orient
end
local n = tostring(page.name or ""):lower()
if n:find("landscape", 1, true) then return "landscape" end
if n:find("portrait", 1, true) then return "portrait" end
return nil
end
function TouchSkin.hasOrientPair(skin)
local saw = {}
for _, page in ipairs(skin and skin.pages or {}) do
local o = TouchSkin.pageOrient(page)
if o then saw[o] = true end
end
return saw.portrait == true and saw.landscape == true
end
local function findOrientPage(skin, keyword)
for i, page in ipairs(skin.pages or {}) do
if TouchSkin.pageOrient(page) == keyword then return i end
end
return nil
end
-- If the current page is the wrong orientation of a portrait/landscape pair,
-- jump to the matching one. Pages locked to neither (gb_anim's GameBoy /
-- GameBoyColor) are left alone. #1503
function TouchSkin.syncOrientation(w, h)
if not TouchSkin.autoOrient then return end
local skin = TouchSkin.active
if not skin or not w or not h or w <= 0 or h <= 0 then return end
local want = w > h and "landscape" or "portrait"
local unwant = w > h and "portrait" or "landscape"
local page = skin.pages[TouchSkin.pageIndex] or skin.pages[1]
local current = TouchSkin.pageOrient(page)
if current == want then return end
if current ~= unwant then return end
local idx = findOrientPage(skin, want)
if idx then TouchSkin.pageIndex = idx end
end
function TouchSkin.page()
local skin = TouchSkin.active
if not skin then return nil end
local w, h = displaySize()
TouchSkin.syncOrientation(w, h)
return skin.pages[TouchSkin.pageIndex] or skin.pages[1]
end
@@ -766,7 +846,12 @@ function TouchSkin.pageBox(page, w, h, ox, oy)
ox, oy = ox or 0, oy or 0
if not page then return ox, oy, w, h end
local bx, by, bw, bh = ox, oy, w, h
if not page.fullScreen and h > 0 then
-- full_screen means "relative to the window, not the game viewport".
-- When the cfg also names an aspect_ratio, that window is then fitted
-- to the overlay's design aspect so buttons do not stretch. #1503
local fit = ((not page.fullScreen) or page.aspectFromCfg)
and page.aspect and page.aspect > 0 and h > 0
if fit then
local displayAspect = w / h
if displayAspect > page.aspect then
bw = h * page.aspect
@@ -833,8 +918,9 @@ function TouchSkin.viewport(w, h, ox, oy)
local page = TouchSkin.page()
if not page or not page.viewport or not TouchSkin.drawable() then return nil end
local v = page.viewport
local x, y = (ox or 0) + v.x * w, (oy or 0) + v.y * h
local vw, vh = v.w * w, v.h * h
local bx, by, bw, bh = TouchSkin.pageBox(page, w, h, ox, oy)
local x, y = bx + v.x * bw, by + v.y * bh
local vw, vh = v.w * bw, v.h * bh
if vw <= 0 or vh <= 0 then return nil end
return x, y, vw, vh, page.viewportFill == true, page.viewportExpand == true
end
+19 -7
View File
@@ -220,6 +220,9 @@ function SwitchDiagnostics.probeAssets(version)
"assets/generated/tilesets/reds_house.png",
"assets/generated/sprites/red.png",
"assets/generated/sprites/monster.png",
"data/generated/maps.lua",
"data/generated/oak_speech.lua",
"data/generated/font.lua",
}
for _, path in ipairs(samples) do
local versioned = prefix ~= "" and (prefix .. path) or path
@@ -230,17 +233,26 @@ function SwitchDiagnostics.probeAssets(version)
lines[#lines + 1] = "versioned=" .. probeInfo(filesystem, versioned)
end
lines[#lines + 1] = "resolve=" .. tostring(resolved)
lines[#lines + 1] = "newImage=" .. probeOpen("image", resolved)
lines[#lines + 1] = "newImageData=" .. probeOpen("imageData", resolved)
if prefix ~= "" and resolved ~= versioned then
lines[#lines + 1] = "newImage_versioned=" .. probeOpen("image", versioned)
lines[#lines + 1] = "newImageData_versioned=" .. probeOpen("imageData", versioned)
if path:sub(-4) == ".lua" then
local CacheFs = require("src.import.CacheFs")
local loaded, err = CacheFs.loadActive(path)
lines[#lines + 1] = "loadActive=" .. (loaded ~= nil and "ok"
or ("FAIL " .. tostring(err):gsub("%s+", " "):sub(1, 160)))
else
lines[#lines + 1] = "newImage=" .. probeOpen("image", resolved)
lines[#lines + 1] = "newImageData=" .. probeOpen("imageData", resolved)
if prefix ~= "" and resolved ~= versioned then
lines[#lines + 1] = "newImage_versioned=" .. probeOpen("image", versioned)
lines[#lines + 1] = "newImageData_versioned=" .. probeOpen("imageData", versioned)
end
end
end
-- Shallow listing so we can see if the extract tree exists at all.
local roots = { "yellow", "blue", "assets", "yellow/assets/generated",
"yellow/assets/generated/sprites", "blue/assets/generated/sprites" }
local roots = { "yellow", "blue", "gold", "assets", "yellow/assets/generated",
"yellow/assets/generated/sprites", "blue/assets/generated/sprites",
"gold/assets/generated", "gold/assets/generated/sprites",
"gold/data/generated" }
for _, dir in ipairs(roots) do
local info = filesystem.getInfo(dir)
if info and info.type == "directory" and filesystem.getDirectoryItems then
+28 -2
View File
@@ -34,7 +34,7 @@ local SEP = package.config:sub(1, 1)
-- Cache-relative paths are prefixed with this before every read/write, so a
-- version's import lands under its GameVersion.cachePrefix (red/, blue/,
-- yellow/). The launcher sets it per import / per readiness check; it stays
-- yellow/, gold/). The launcher sets it per import / per readiness check; it stays
-- "" outside those flows. Runtime *reads* (require / newImage) do NOT go
-- through here -- CacheFs.mountVersion overlays the active version's subtree
-- onto the un-prefixed paths instead.
@@ -305,7 +305,7 @@ function CacheFs.read(rel)
end
-- Read cache-relative `rel` for the active GameVersion when PhysFS may hide
-- prefixed Blue/Yellow trees (fused NX mount hole). Same order Data:load
-- prefixed Blue/Yellow/Gold trees (fused NX mount hole). Same order Data:load
-- already used: active version prefix with CacheFs.prefix cleared, then
-- `rel` under the caller's CacheFs.prefix. Returns the bytes or nil.
function CacheFs.readActive(rel)
@@ -322,6 +322,32 @@ function CacheFs.readActive(rel)
return nil
end
-- Load a generated Lua table the way Data:load does: versioned save-dir
-- bytes first (gold/data/generated/maps.lua), then the un-prefixed path.
-- Game2/World used love.filesystem.load("data/generated/...") which misses
-- on fused NX when the gold/ overlay mount fails -- intro art still loads
-- via NxAssetOverlay, but oak_speech.lua / font.lua / maps.lua do not.
function CacheFs.loadActive(rel)
local bytes = CacheFs.readActive(rel)
if type(bytes) == "string" then
local GameVersion = require("src.core.GameVersion")
local loader = loadstring or load
local chunk, err = loader(bytes, "@" .. GameVersion.cachePrefix() .. rel)
if not chunk then return nil, err end
local ok, value = pcall(chunk)
if not ok then return nil, value end
return value
end
if love and love.filesystem and love.filesystem.load then
local chunk, err = love.filesystem.load(rel)
if not chunk then return nil, err end
local ok, value = pcall(chunk)
if not ok then return nil, value end
return value
end
return nil, "Could not open file " .. rel .. ". Does not exist."
end
-- does cache-relative `rel` exist as a file?
function CacheFs.exists(rel)
rel = withPrefix(rel)
+26 -28
View File
@@ -192,20 +192,28 @@ local PAL = {
chipInkGold = { 58, 44, 0 }, -- #3a2c00 dark "Y" on the gold chip
}
-- Per-version required cache files. Gold replaces the Gen 1 list entirely
-- (VERSION_REQUIRED_FILES_OVERRIDE); Yellow adds a few extra markers.
local function requiredFilesFor(version)
local override = VERSION_REQUIRED_FILES_OVERRIDE[version]
if override then return override, true end
return REQUIRED_FILES, false
end
-- CacheFs.exists checks the game folder directly for a portable install,
-- otherwise the save directory through love.filesystem. It honors
-- CacheFs.prefix, so we point it at the version's cache subtree (red/,
-- blue/, yellow/).
-- blue/, yellow/, gold/).
local function allRequiredFilesExist(version)
local CacheFs = require("src.import.CacheFs")
local saved = CacheFs.prefix
CacheFs.prefix = GameVersion.cachePrefix(version)
local ok = true
local required = VERSION_REQUIRED_FILES_OVERRIDE[version] or REQUIRED_FILES
local required, isOverride = requiredFilesFor(version)
for _, path in ipairs(required) do
if not CacheFs.exists(path) then ok = false; break end
end
if ok and not VERSION_REQUIRED_FILES_OVERRIDE[version] then
if ok and not isOverride then
for _, path in ipairs(VERSION_REQUIRED_FILES[version] or {}) do
if not CacheFs.exists(path) then ok = false; break end
end
@@ -215,20 +223,23 @@ local function allRequiredFilesExist(version)
end
-- A developer checkout / Python build leaves generated data in the physfs
-- source: Red at the historical root, Blue/Yellow in their versioned trees.
-- Imported Red caches still live under red/. Check source paths directly so
-- that cache prefix cannot hide Red's source tree, and keep save-dir caches
-- from counting as current source data.
-- source: Red at the historical root, Blue/Yellow/Gold in their versioned
-- trees. Imported Red caches still live under red/. Check source paths
-- directly so that cache prefix cannot hide Red's source tree, and keep
-- save-dir caches from counting as current source data.
local function sourceTreeHasData(version)
if not love.filesystem.getRealDirectory then return false end
local prefix = version == "red" and "" or GameVersion.cachePrefix(version)
for _, path in ipairs(REQUIRED_FILES) do
local required, isOverride = requiredFilesFor(version)
for _, path in ipairs(required) do
if love.filesystem.getInfo(prefix .. path, "file") == nil then return false end
end
for _, path in ipairs(VERSION_REQUIRED_FILES[version] or {}) do
if love.filesystem.getInfo(prefix .. path, "file") == nil then return false end
if not isOverride then
for _, path in ipairs(VERSION_REQUIRED_FILES[version] or {}) do
if love.filesystem.getInfo(prefix .. path, "file") == nil then return false end
end
end
local path = prefix .. REQUIRED_FILES[1]
local path = prefix .. required[1]
local real = love.filesystem.getRealDirectory(path)
return real == love.filesystem.getSource()
end
@@ -285,7 +296,7 @@ local function purgeSaveDirCache()
return true
end
-- Purge each version's stale save-directory copy (under its red/ / blue/
-- / yellow/ prefix) so it cannot shadow the portable game-folder cache.
-- / yellow/ / gold/ prefix) so it cannot shadow the portable game-folder cache.
for _, version in ipairs(GameVersion.ORDER) do
local prefix = GameVersion.cachePrefix(version)
if saveDirHas(prefix .. MARKER_PATH) or saveDirHas(prefix .. REQUIRED_FILES[1]) then
@@ -4324,25 +4335,12 @@ function RomImporter:_findRows()
category = self.findCategory,
})
if self.modScope then
local ModTargets = require("src.mods.ModTargets")
local gen = GameVersion.generation(self.modScope)
local kept = {}
for _, entry in ipairs(rows) do
local has1, has2 = false, false
local function note(s)
s = tostring(s or ""):lower()
if s == "gen1" or s == "gen 1" or s == "red" or s == "blue"
or s == "yellow" then
has1 = true
end
if s == "gen2" or s == "gen 2" or s == "gold" then
has2 = true
end
end
for _, cat in ipairs(entry.categories or {}) do note(cat) end
for _, tag in ipairs(entry.tags or {}) do note(tag) end
if (not has1 and not has2)
or (gen == 2 and has2)
or (gen ~= 2 and has1) then
local versions = ModTargets.normalize(entry.games)
if #versions == 0 or ModTargets.covers(versions, gen) then
kept[#kept + 1] = entry
end
end
+1 -1
View File
@@ -141,7 +141,7 @@ function SaveFileIO.exportActiveSlot(version)
fs.createDirectory("exports")
fs.createDirectory("exports/" .. version)
end
-- Per-game folder so MTP browsing matches inbox layout (red/blue/yellow).
-- Per-game folder so MTP browsing matches inbox layout (red/blue/yellow/gold).
local rel = ("exports/%s/gen1recomp-%s-%s.sav"):format(version, version, slotId)
local ok, writeErr = fs.write(rel, bytes)
if not ok then return false, "could not write the export: " .. tostring(writeErr) end
+1
View File
@@ -160,6 +160,7 @@ local function parseEntry(raw)
summary = str(raw.summary) or "",
categories = strArray(raw.categories),
tags = strArray(raw.tags),
games = strArray(raw.games),
license = str(raw.license),
repo = str(raw.repo),
github = str(raw.github),
+2 -1
View File
@@ -166,7 +166,8 @@ function EvolutionState:draw()
if sprite then
local x = math.floor((160 - sprite:getWidth()) / 2)
local y = math.max(8, 64 - sprite:getHeight())
love.graphics.draw(sprite, x, y)
-- engine/movie/evolution.asm:103
love.graphics.draw(sprite, x + sprite:getWidth(), y, 0, -1, 1)
if spriteTrueColor then
require("src.render.PaletteFX").markTrueColor(x, y, sprite:getDimensions())
end
+127 -9
View File
@@ -20,6 +20,17 @@ Studio.CANVASES = {
lockViewport = { x = 48 / 256, y = 40 / 224, w = 160 / 256, h = 144 / 224 } },
}
-- Per-page lock, and whether the canvas preset follows it. Match is on
-- by default so a portrait/landscape overlay pair does not need two
-- separate clicks to preview the right way up. #1503
Studio.matchOrient = true
Studio.ORIENT_CYCLE = { "any", "portrait", "landscape" }
Studio.ORIENT_LABEL = {
any = "Lock: Off",
portrait = "Lock: Portrait",
landscape = "Lock: Landscape",
}
local HANDLE = 7
local HANDLES = {
{ "nw", 0, 0 }, { "n", 0.5, 0 }, { "ne", 1, 0 },
@@ -64,9 +75,91 @@ local function syncActive()
TouchSkin.pageIndex = Studio.pageIndex
end
function Studio.setCanvas(index)
local function canvasOrientation(canvas)
canvas = canvas or Studio.canvas()
if not canvas then return nil end
return canvas.w > canvas.h and "landscape" or "portrait"
end
local function pickCanvasIndex(want)
local cur = Studio.canvas()
if canvasOrientation(cur) == want then return Studio.canvasIndex end
if cur and cur.id then
local hint = cur.id:gsub("portrait", want):gsub("landscape", want)
for i, c in ipairs(Studio.CANVASES) do
if c.id == hint then return i end
end
end
for i, c in ipairs(Studio.CANVASES) do
if canvasOrientation(c) == want and not c.lockViewport then return i end
end
return nil
end
function Studio.applyImportedOrient()
if not Studio.skin then return false end
if not TouchSkin.hasOrientPair(Studio.skin) then
Studio.syncCanvasToPage()
return false
end
-- A RetroArch overlay that already auto-rotates should do the same in
-- the studio: lock is on, canvas follows, and the visible page matches
-- the mock device. #1503
Studio.matchOrient = true
Studio.syncPageToCanvas()
Studio.syncCanvasToPage()
return true
end
function Studio.syncCanvasToPage()
if not Studio.matchOrient then return end
local want = TouchSkin.pageOrient(Studio.page())
if not want then return end
local idx = pickCanvasIndex(want)
if idx and idx ~= Studio.canvasIndex then Studio.setCanvas(idx, true) end
end
function Studio.syncPageToCanvas()
if not Studio.matchOrient or not Studio.skin then return end
local want = canvasOrientation()
if TouchSkin.pageOrient(Studio.page()) == want then return end
for i, page in ipairs(Studio.skin.pages or {}) do
if TouchSkin.pageOrient(page) == want then
Studio.pageIndex = i
Studio.selected = nil
syncActive()
return
end
end
end
function Studio.cyclePageOrient(dir)
local page = Studio.page()
if not page then return end
local cur = TouchSkin.pageOrient(page) or "any"
local idx = 1
for i, o in ipairs(Studio.ORIENT_CYCLE) do
if o == cur then idx = i break end
end
local n = #Studio.ORIENT_CYCLE
local nxt = Studio.ORIENT_CYCLE[((idx - 1 + (dir or 1)) % n) + 1]
page.orient = nxt
local name = tostring(page.name or "")
if (nxt == "portrait" or nxt == "landscape")
and (name == "" or name == "main" or name:match("^page%d+$")) then
page.name = nxt
end
markDirty()
Studio.syncCanvasToPage()
return nxt
end
function Studio.setCanvas(index, fromSync)
local n = #Studio.CANVASES
Studio.canvasIndex = ((index - 1) % n) + 1
-- Pick the matching page before writing canvas-owned fields onto it,
-- so a landscape preset does not stamp a portrait page. #1503
if not fromSync then Studio.syncPageToCanvas() end
local canvas = Studio.canvas()
local page = Studio.page()
if page and canvas.lockViewport then
@@ -77,7 +170,11 @@ function Studio.setCanvas(index)
page.viewportFill = false
markDirty()
end
if page then page.aspect = canvas.w / canvas.h end
-- A cfg-authored aspect_ratio is the overlay's design aspect; keep it so
-- the preview letterboxes like RetroArch instead of stretching (#1503).
if page and not page.aspectFromCfg then
page.aspect = canvas.w / canvas.h
end
end
function Studio.load(opts)
@@ -102,6 +199,10 @@ function Studio.load(opts)
TouchControls.active = true
TouchControls.enabled = true
TouchControls:setPreview(true)
-- Play snaps pages from the window aspect. The studio uses its own
-- Match canvas toggle against the mock device instead. #1503
TouchSkin.autoOrient = false
Studio.matchOrient = true
local start = opts.skinId
if not start then
@@ -130,6 +231,7 @@ function Studio.open(id)
Studio.dirty = false
Studio.images = TouchSkin.listImages(Studio.skin.root)
syncActive()
Studio.applyImportedOrient()
return true
end
@@ -137,6 +239,7 @@ function Studio.unload()
Studio.pendingPlay = false
TouchSkin.setSurface(nil)
TouchSkin.setActive(nil)
TouchSkin.autoOrient = true
TouchControls:setPreview(false)
TouchControls:reset()
Studio.skin = nil
@@ -358,6 +461,7 @@ function Studio.addPage()
Studio.pageIndex = #skin.pages
Studio.selected = nil
syncActive()
Studio.syncCanvasToPage()
markDirty()
end
@@ -432,7 +536,8 @@ end
local function viewportRect(page, r)
local v = page.viewport
if not v then return nil end
return r.x + v.x * r.w, r.y + v.y * r.h, v.w * r.w, v.h * r.h
local bx, by, bw, bh = TouchSkin.pageBox(page, r.w, r.h, r.x, r.y)
return bx + v.x * bw, by + v.y * bh, v.w * bw, v.h * bh
end
local function handleRects(bx, by, bw, bh)
@@ -540,17 +645,19 @@ function Studio.updateDrag(mx, my, r)
end
end
local px, py, pw, ph = TouchSkin.pageBox(page, r.w, r.h, r.x, r.y)
if pw <= 0 or ph <= 0 then return end
if d.kind:find("control") then
local ctl = Studio.selectedControl()
if not ctl then return end
ctl.x = clamp01(((bx + bw * 0.5) - r.x) / r.w)
ctl.y = clamp01(((by + bh * 0.5) - r.y) / r.h)
ctl.rangeX = math.max(0.002, (bw * 0.5) / r.w)
ctl.rangeY = math.max(0.002, (bh * 0.5) / r.h)
ctl.x = clamp01(((bx + bw * 0.5) - px) / pw)
ctl.y = clamp01(((by + bh * 0.5) - py) / ph)
ctl.rangeX = math.max(0.002, (bw * 0.5) / pw)
ctl.rangeY = math.max(0.002, (bh * 0.5) / ph)
else
page.viewport = {
x = clamp01((bx - r.x) / r.w), y = clamp01((by - r.y) / r.h),
w = math.max(0.02, bw / r.w), h = math.max(0.02, bh / r.h),
x = clamp01((bx - px) / pw), y = clamp01((by - py) / ph),
w = math.max(0.02, bw / pw), h = math.max(0.02, bh / ph),
}
end
markDirty()
@@ -665,11 +772,22 @@ local function inspectorBody(x, y, w)
Studio.pageIndex = (Studio.pageIndex % #Studio.skin.pages) + 1
Studio.selected = nil
syncActive()
Studio.syncCanvasToPage()
end
if Kit.button(x + half + gap, cy, half, rowH, "Add page", { id = "pageadd" }) then
Studio.addPage()
end
cy = cy + rowH + gap
local lock = TouchSkin.pageOrient(page) or "any"
if Kit.button(x, cy, half, rowH, Studio.ORIENT_LABEL[lock] or "Lock: Off",
{ id = "orient" }) then
Studio.cyclePageOrient(1)
end
local matchOn = Studio.matchOrient
Studio.matchOrient = Kit.checkbox(x + half + gap, cy, half, rowH,
Studio.matchOrient, "Match canvas", "matchorient")
if Studio.matchOrient and not matchOn then Studio.syncCanvasToPage() end
cy = cy + rowH + gap
if page then
local bezel = page.imagePath or "(none)"
+4 -2
View File
@@ -445,7 +445,8 @@ function TradeAnim:draw()
-- mon pic on the BG at hlcoord 7, 2; info box on the window at
-- hWY $50, so it sits in the bottom half of the screen
if self.monVisible and self.sentSprite then
love.graphics.draw(self.sentSprite, 56, 16)
-- engine/movie/trade.asm:751
love.graphics.draw(self.sentSprite, 56 + self.sentSprite:getWidth(), 16, 0, -1, 1)
if self.sentSpriteTrueColor then
require("src.render.PaletteFX").markTrueColor(
56 - self.scx, 16, self.sentSprite:getDimensions())
@@ -501,7 +502,8 @@ function TradeAnim:draw()
elseif p == "show_enemy" then
if self.monVisible and self.recvSprite then
love.graphics.draw(self.recvSprite, 56, 16)
-- engine/movie/trade.asm:751
love.graphics.draw(self.recvSprite, 56 + self.recvSprite:getWidth(), 16, 0, -1, 1)
if self.recvSpriteTrueColor then
require("src.render.PaletteFX").markTrueColor(
56, 16, self.recvSprite:getDimensions())
+4 -5
View File
@@ -396,11 +396,10 @@ local function givePokeMon(data, speciesIndex, level, itemIndex)
end
local function loadGenerated(path)
local chunk, err = love.filesystem.load(path)
if not chunk then return nil, err end
local ok, value = pcall(chunk)
if not ok then return nil, value end
return value
-- Same NX gold/ fallback Game2 uses. World:load is what surfaces
-- "Gold cache incomplete" when maps.lua is invisible at the unprefixed path.
local CacheFs = require("src.import.CacheFs")
return CacheFs.loadActive(path)
end
-- Paste the 9-tile roof sheet over atlas tiles $0a-$12.